mirror of
https://github.com/jeffvli/feishin.git
synced 2024-11-20 06:27:09 +01:00
Add remote control (#164)
* draft add remotes * add favorite, rating * add basic auth
This commit is contained in:
parent
0a13d047bb
commit
c9dbf9b5be
119
.erb/configs/webpack.config.remote.dev.ts
Normal file
119
.erb/configs/webpack.config.remote.dev.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import 'webpack-dev-server';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||||
|
import webpack from 'webpack';
|
||||||
|
import { merge } from 'webpack-merge';
|
||||||
|
|
||||||
|
import checkNodeEnv from '../scripts/check-node-env';
|
||||||
|
import baseConfig from './webpack.config.base';
|
||||||
|
import webpackPaths from './webpack.paths';
|
||||||
|
|
||||||
|
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
|
||||||
|
// at the dev webpack config is not accidentally run in a production environment
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
checkNodeEnv('development');
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = process.env.PORT || 4343;
|
||||||
|
|
||||||
|
const configuration: webpack.Configuration = {
|
||||||
|
devtool: 'inline-source-map',
|
||||||
|
|
||||||
|
mode: 'development',
|
||||||
|
|
||||||
|
target: ['web'],
|
||||||
|
|
||||||
|
entry: [path.join(webpackPaths.srcRemotePath, 'index.tsx')],
|
||||||
|
|
||||||
|
output: {
|
||||||
|
path: webpackPaths.dllPath,
|
||||||
|
publicPath: '/',
|
||||||
|
filename: 'remote.js',
|
||||||
|
library: {
|
||||||
|
type: 'umd',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.s?css$/,
|
||||||
|
use: [
|
||||||
|
'style-loader',
|
||||||
|
{
|
||||||
|
loader: 'css-loader',
|
||||||
|
options: {
|
||||||
|
modules: true,
|
||||||
|
sourceMap: true,
|
||||||
|
importLoaders: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'sass-loader',
|
||||||
|
],
|
||||||
|
include: /\.module\.s?(c|a)ss$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.s?css$/,
|
||||||
|
use: ['style-loader', 'css-loader', 'sass-loader'],
|
||||||
|
exclude: /\.module\.s?(c|a)ss$/,
|
||||||
|
},
|
||||||
|
// Fonts
|
||||||
|
{
|
||||||
|
test: /\.(woff|woff2|eot|ttf|otf)$/i,
|
||||||
|
type: 'asset/resource',
|
||||||
|
},
|
||||||
|
// Images
|
||||||
|
{
|
||||||
|
test: /\.(png|svg|jpg|jpeg|gif)$/i,
|
||||||
|
type: 'asset/resource',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new webpack.NoEmitOnErrorsPlugin(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create global constants which can be configured at compile time.
|
||||||
|
*
|
||||||
|
* Useful for allowing different behaviour between development builds and
|
||||||
|
* release builds
|
||||||
|
*
|
||||||
|
* NODE_ENV should be production so that modules do not perform certain
|
||||||
|
* development checks
|
||||||
|
*
|
||||||
|
* By default, use 'development' as NODE_ENV. This can be overriden with
|
||||||
|
* 'staging', for example, by changing the ENV variables in the npm scripts
|
||||||
|
*/
|
||||||
|
new webpack.EnvironmentPlugin({
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
}),
|
||||||
|
|
||||||
|
new webpack.LoaderOptionsPlugin({
|
||||||
|
debug: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
filename: path.join('index.html'),
|
||||||
|
template: path.join(webpackPaths.srcRemotePath, 'index.ejs'),
|
||||||
|
minify: {
|
||||||
|
collapseWhitespace: true,
|
||||||
|
removeAttributeQuotes: true,
|
||||||
|
removeComments: true,
|
||||||
|
},
|
||||||
|
isBrowser: true,
|
||||||
|
env: process.env.NODE_ENV,
|
||||||
|
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||||
|
nodeModules: webpackPaths.appNodeModulesPath,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
node: {
|
||||||
|
__dirname: false,
|
||||||
|
__filename: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default merge(baseConfig, configuration);
|
131
.erb/configs/webpack.config.remote.prod.ts
Normal file
131
.erb/configs/webpack.config.remote.prod.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Build config for electron renderer process
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
|
||||||
|
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||||
|
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
|
||||||
|
import TerserPlugin from 'terser-webpack-plugin';
|
||||||
|
import webpack from 'webpack';
|
||||||
|
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
|
||||||
|
import { merge } from 'webpack-merge';
|
||||||
|
|
||||||
|
import checkNodeEnv from '../scripts/check-node-env';
|
||||||
|
import deleteSourceMaps from '../scripts/delete-source-maps';
|
||||||
|
import baseConfig from './webpack.config.base';
|
||||||
|
import webpackPaths from './webpack.paths';
|
||||||
|
|
||||||
|
checkNodeEnv('production');
|
||||||
|
deleteSourceMaps();
|
||||||
|
|
||||||
|
const devtoolsConfig =
|
||||||
|
process.env.DEBUG_PROD === 'true'
|
||||||
|
? {
|
||||||
|
devtool: 'source-map',
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const configuration: webpack.Configuration = {
|
||||||
|
...devtoolsConfig,
|
||||||
|
|
||||||
|
mode: 'production',
|
||||||
|
|
||||||
|
target: ['web'],
|
||||||
|
|
||||||
|
entry: [path.join(webpackPaths.srcRemotePath, 'index.tsx')],
|
||||||
|
|
||||||
|
output: {
|
||||||
|
path: webpackPaths.distRemotePath,
|
||||||
|
publicPath: './',
|
||||||
|
filename: 'remote.js',
|
||||||
|
library: {
|
||||||
|
type: 'umd',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.s?(a|c)ss$/,
|
||||||
|
use: [
|
||||||
|
MiniCssExtractPlugin.loader,
|
||||||
|
{
|
||||||
|
loader: 'css-loader',
|
||||||
|
options: {
|
||||||
|
modules: true,
|
||||||
|
sourceMap: true,
|
||||||
|
importLoaders: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'sass-loader',
|
||||||
|
],
|
||||||
|
include: /\.module\.s?(c|a)ss$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.s?(a|c)ss$/,
|
||||||
|
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
|
||||||
|
exclude: /\.module\.s?(c|a)ss$/,
|
||||||
|
},
|
||||||
|
// Fonts
|
||||||
|
{
|
||||||
|
test: /\.(woff|woff2|eot|ttf|otf)$/i,
|
||||||
|
type: 'asset/resource',
|
||||||
|
},
|
||||||
|
// Images
|
||||||
|
{
|
||||||
|
test: /\.(png|svg|jpg|jpeg|gif)$/i,
|
||||||
|
type: 'asset/resource',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
optimization: {
|
||||||
|
minimize: true,
|
||||||
|
minimizer: [
|
||||||
|
new TerserPlugin({
|
||||||
|
parallel: true,
|
||||||
|
}),
|
||||||
|
new CssMinimizerPlugin(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
/**
|
||||||
|
* Create global constants which can be configured at compile time.
|
||||||
|
*
|
||||||
|
* Useful for allowing different behaviour between development builds and
|
||||||
|
* release builds
|
||||||
|
*
|
||||||
|
* NODE_ENV should be production so that modules do not perform certain
|
||||||
|
* development checks
|
||||||
|
*/
|
||||||
|
new webpack.EnvironmentPlugin({
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
DEBUG_PROD: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new MiniCssExtractPlugin({
|
||||||
|
filename: 'remote.css',
|
||||||
|
}),
|
||||||
|
|
||||||
|
new BundleAnalyzerPlugin({
|
||||||
|
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
|
||||||
|
}),
|
||||||
|
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
filename: 'index.html',
|
||||||
|
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
|
||||||
|
minify: {
|
||||||
|
collapseWhitespace: true,
|
||||||
|
removeAttributeQuotes: true,
|
||||||
|
removeComments: true,
|
||||||
|
},
|
||||||
|
isBrowser: false,
|
||||||
|
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default merge(baseConfig, configuration);
|
@ -171,6 +171,14 @@ const configuration: webpack.Configuration = {
|
|||||||
.on('close', (code: number) => process.exit(code!))
|
.on('close', (code: number) => process.exit(code!))
|
||||||
.on('error', (spawnError) => console.error(spawnError));
|
.on('error', (spawnError) => console.error(spawnError));
|
||||||
|
|
||||||
|
console.log('Starting remote.js builder...');
|
||||||
|
const remoteProcess = spawn('npm', ['run', 'start:remote'], {
|
||||||
|
shell: true,
|
||||||
|
stdio: 'inherit',
|
||||||
|
})
|
||||||
|
.on('close', (code: number) => process.exit(code!))
|
||||||
|
.on('error', (spawnError) => console.error(spawnError));
|
||||||
|
|
||||||
console.log('Starting Main Process...');
|
console.log('Starting Main Process...');
|
||||||
spawn('npm', ['run', 'start:main'], {
|
spawn('npm', ['run', 'start:main'], {
|
||||||
shell: true,
|
shell: true,
|
||||||
@ -178,6 +186,7 @@ const configuration: webpack.Configuration = {
|
|||||||
})
|
})
|
||||||
.on('close', (code: number) => {
|
.on('close', (code: number) => {
|
||||||
preloadProcess.kill();
|
preloadProcess.kill();
|
||||||
|
remoteProcess.kill();
|
||||||
process.exit(code!);
|
process.exit(code!);
|
||||||
})
|
})
|
||||||
.on('error', (spawnError) => console.error(spawnError));
|
.on('error', (spawnError) => console.error(spawnError));
|
||||||
|
@ -6,6 +6,7 @@ const dllPath = path.join(__dirname, '../dll');
|
|||||||
|
|
||||||
const srcPath = path.join(rootPath, 'src');
|
const srcPath = path.join(rootPath, 'src');
|
||||||
const srcMainPath = path.join(srcPath, 'main');
|
const srcMainPath = path.join(srcPath, 'main');
|
||||||
|
const srcRemotePath = path.join(srcPath, 'remote');
|
||||||
const srcRendererPath = path.join(srcPath, 'renderer');
|
const srcRendererPath = path.join(srcPath, 'renderer');
|
||||||
|
|
||||||
const releasePath = path.join(rootPath, 'release');
|
const releasePath = path.join(rootPath, 'release');
|
||||||
@ -16,6 +17,7 @@ const srcNodeModulesPath = path.join(srcPath, 'node_modules');
|
|||||||
|
|
||||||
const distPath = path.join(appPath, 'dist');
|
const distPath = path.join(appPath, 'dist');
|
||||||
const distMainPath = path.join(distPath, 'main');
|
const distMainPath = path.join(distPath, 'main');
|
||||||
|
const distRemotePath = path.join(distPath, 'remote');
|
||||||
const distRendererPath = path.join(distPath, 'renderer');
|
const distRendererPath = path.join(distPath, 'renderer');
|
||||||
|
|
||||||
const buildPath = path.join(releasePath, 'build');
|
const buildPath = path.join(releasePath, 'build');
|
||||||
@ -25,6 +27,7 @@ export default {
|
|||||||
dllPath,
|
dllPath,
|
||||||
srcPath,
|
srcPath,
|
||||||
srcMainPath,
|
srcMainPath,
|
||||||
|
srcRemotePath,
|
||||||
srcRendererPath,
|
srcRendererPath,
|
||||||
releasePath,
|
releasePath,
|
||||||
appPath,
|
appPath,
|
||||||
@ -33,6 +36,7 @@ export default {
|
|||||||
srcNodeModulesPath,
|
srcNodeModulesPath,
|
||||||
distPath,
|
distPath,
|
||||||
distMainPath,
|
distMainPath,
|
||||||
|
distRemotePath,
|
||||||
distRendererPath,
|
distRendererPath,
|
||||||
buildPath,
|
buildPath,
|
||||||
};
|
};
|
||||||
|
@ -5,20 +5,29 @@ import fs from 'fs';
|
|||||||
import webpackPaths from '../configs/webpack.paths';
|
import webpackPaths from '../configs/webpack.paths';
|
||||||
|
|
||||||
const mainPath = path.join(webpackPaths.distMainPath, 'main.js');
|
const mainPath = path.join(webpackPaths.distMainPath, 'main.js');
|
||||||
|
const remotePath = path.join(webpackPaths.distMainPath, 'remote.js');
|
||||||
const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js');
|
const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js');
|
||||||
|
|
||||||
if (!fs.existsSync(mainPath)) {
|
if (!fs.existsSync(mainPath)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
chalk.whiteBright.bgRed.bold(
|
chalk.whiteBright.bgRed.bold(
|
||||||
'The main process is not built yet. Build it by running "npm run build:main"'
|
'The main process is not built yet. Build it by running "npm run build:main"',
|
||||||
)
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(remotePath)) {
|
||||||
|
throw new Error(
|
||||||
|
chalk.whiteBright.bgRed.bold(
|
||||||
|
'The remote process is not built yet. Build it by running "npm run build:remote"',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(rendererPath)) {
|
if (!fs.existsSync(rendererPath)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
chalk.whiteBright.bgRed.bold(
|
chalk.whiteBright.bgRed.bold(
|
||||||
'The renderer process is not built yet. Build it by running "npm run build:renderer"'
|
'The renderer process is not built yet. Build it by running "npm run build:renderer"',
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,5 +4,6 @@ import webpackPaths from '../configs/webpack.paths';
|
|||||||
|
|
||||||
export default function deleteSourceMaps() {
|
export default function deleteSourceMaps() {
|
||||||
rimraf.sync(path.join(webpackPaths.distMainPath, '*.js.map'));
|
rimraf.sync(path.join(webpackPaths.distMainPath, '*.js.map'));
|
||||||
|
rimraf.sync(path.join(webpackPaths.distRemotePath, '*.js.map'));
|
||||||
rimraf.sync(path.join(webpackPaths.distRendererPath, '*.js.map'));
|
rimraf.sync(path.join(webpackPaths.distRendererPath, '*.js.map'));
|
||||||
}
|
}
|
||||||
|
@ -4,8 +4,9 @@
|
|||||||
"description": "Feishin music server",
|
"description": "Feishin music server",
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
|
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\" \"npm run build:remote\"",
|
||||||
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
||||||
|
"build:remote": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.remote.prod.ts",
|
||||||
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
|
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
|
||||||
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
|
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
|
||||||
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
|
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
|
||||||
@ -18,6 +19,7 @@
|
|||||||
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
|
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
|
||||||
"start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
|
"start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
|
||||||
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
|
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
|
||||||
|
"start:remote": "cross-env NODE_ENV=developemnt TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.remote.dev.ts",
|
||||||
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
|
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
|
||||||
"start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts",
|
"start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
|
35
release/app/package-lock.json
generated
35
release/app/package-lock.json
generated
@ -11,7 +11,8 @@
|
|||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"mpris-service": "^2.1.2"
|
"mpris-service": "^2.1.2",
|
||||||
|
"ws": "^8.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "22.3.1"
|
"electron": "22.3.1"
|
||||||
@ -26,6 +27,7 @@
|
|||||||
"debug": "^4.1.1",
|
"debug": "^4.1.1",
|
||||||
"env-paths": "^2.2.0",
|
"env-paths": "^2.2.0",
|
||||||
"fs-extra": "^8.1.0",
|
"fs-extra": "^8.1.0",
|
||||||
|
"global-agent": "^3.0.0",
|
||||||
"got": "^11.8.5",
|
"got": "^11.8.5",
|
||||||
"progress": "^2.0.3",
|
"progress": "^2.0.3",
|
||||||
"semver": "^6.2.0",
|
"semver": "^6.2.0",
|
||||||
@ -293,6 +295,7 @@
|
|||||||
"integrity": "sha512-tzQq/+wrTZ2yU+U5PoeXc97KABhX2v55C/T0finH3tSKYuI8H/SqppIFymBBrUHcK13LvEGY3vdj3ikPPenL5g==",
|
"integrity": "sha512-tzQq/+wrTZ2yU+U5PoeXc97KABhX2v55C/T0finH3tSKYuI8H/SqppIFymBBrUHcK13LvEGY3vdj3ikPPenL5g==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nornagon/put": "0.0.8",
|
"@nornagon/put": "0.0.8",
|
||||||
|
"abstract-socket": "^2.0.0",
|
||||||
"event-stream": "3.3.4",
|
"event-stream": "3.3.4",
|
||||||
"hexy": "^0.2.10",
|
"hexy": "^0.2.10",
|
||||||
"jsbi": "^2.0.5",
|
"jsbi": "^2.0.5",
|
||||||
@ -538,6 +541,7 @@
|
|||||||
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
|
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/yauzl": "^2.9.1",
|
||||||
"debug": "^4.1.1",
|
"debug": "^4.1.1",
|
||||||
"get-stream": "^5.1.0",
|
"get-stream": "^5.1.0",
|
||||||
"yauzl": "^2.10.0"
|
"yauzl": "^2.10.0"
|
||||||
@ -867,6 +871,9 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.1.6"
|
||||||
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"graceful-fs": "^4.1.6"
|
"graceful-fs": "^4.1.6"
|
||||||
}
|
}
|
||||||
@ -1284,6 +1291,26 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
|
||||||
|
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml2js": {
|
"node_modules/xml2js": {
|
||||||
"version": "0.4.23",
|
"version": "0.4.23",
|
||||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||||
@ -2269,6 +2296,12 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"ws": {
|
||||||
|
"version": "8.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
|
||||||
|
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
"xml2js": {
|
"xml2js": {
|
||||||
"version": "0.4.23",
|
"version": "0.4.23",
|
||||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||||
|
@ -14,7 +14,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"mpris-service": "^2.1.2"
|
"mpris-service": "^2.1.2",
|
||||||
|
"ws": "^8.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "22.3.1"
|
"electron": "22.3.1"
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
import './lyrics';
|
import './lyrics';
|
||||||
import './player';
|
import './player';
|
||||||
|
import './remote';
|
||||||
import './settings';
|
import './settings';
|
||||||
|
@ -207,9 +207,9 @@ ipcMain.on('player-volume', async (_event, value: number) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Toggles the mute status
|
// Toggles the mute status
|
||||||
ipcMain.on('player-mute', async () => {
|
ipcMain.on('player-mute', async (_event, mute: boolean) => {
|
||||||
await getMpvInstance()
|
await getMpvInstance()
|
||||||
?.mute()
|
?.mute(mute)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.log('MPV failed to toggle mute', err);
|
console.log('MPV failed to toggle mute', err);
|
||||||
});
|
});
|
||||||
|
625
src/main/features/core/remote/index.ts
Normal file
625
src/main/features/core/remote/index.ts
Normal file
@ -0,0 +1,625 @@
|
|||||||
|
import { Stats, promises } from 'fs';
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import { IncomingMessage, Server, ServerResponse, createServer } from 'http';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { deflate, gzip } from 'zlib';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { app, ipcMain } from 'electron';
|
||||||
|
import { Server as WsServer, WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import { ClientEvent, ServerEvent } from '../../../../remote/types';
|
||||||
|
import { PlayerRepeat, SongUpdate } from '../../../../renderer/types';
|
||||||
|
import { getMainWindow } from '../../../main';
|
||||||
|
import { isLinux } from '../../../utils';
|
||||||
|
|
||||||
|
let mprisPlayer: any | undefined;
|
||||||
|
|
||||||
|
if (isLinux()) {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
mprisPlayer = require('../../linux/mpris').mprisPlayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
password: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MimeType {
|
||||||
|
css: string;
|
||||||
|
html: string;
|
||||||
|
ico: string;
|
||||||
|
js: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatefulWebSocket extends WebSocket {
|
||||||
|
alive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let server: Server | undefined;
|
||||||
|
let wsServer: WsServer<StatefulWebSocket> | undefined;
|
||||||
|
|
||||||
|
const settings: RemoteConfig = {
|
||||||
|
enabled: false,
|
||||||
|
password: '',
|
||||||
|
port: 4333,
|
||||||
|
username: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
type SendData = ServerEvent & {
|
||||||
|
client: StatefulWebSocket;
|
||||||
|
};
|
||||||
|
|
||||||
|
function send({ client, event, data }: SendData): void {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(JSON.stringify({ data, event }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(message: ServerEvent): void {
|
||||||
|
if (wsServer) {
|
||||||
|
for (const client of wsServer.clients) {
|
||||||
|
send({ client, ...message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shutdownServer = () => {
|
||||||
|
if (wsServer) {
|
||||||
|
wsServer.clients.forEach((client) => client.close(4000));
|
||||||
|
wsServer.close();
|
||||||
|
wsServer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server) {
|
||||||
|
server.close();
|
||||||
|
server = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIME_TYPES: MimeType = {
|
||||||
|
css: 'text/css',
|
||||||
|
html: 'text/html; charset=UTF-8',
|
||||||
|
ico: 'image/x-icon',
|
||||||
|
js: 'application/javascript',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PING_TIMEOUT_MS = 10000;
|
||||||
|
const UP_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
enum Encoding {
|
||||||
|
GZIP = 'gzip',
|
||||||
|
NONE = 'none',
|
||||||
|
ZLIB = 'deflate',
|
||||||
|
}
|
||||||
|
|
||||||
|
const GZIP_REGEX = /\bgzip\b/;
|
||||||
|
const ZLIB_REGEX = /bdeflate\b/;
|
||||||
|
|
||||||
|
let currentSong: SongUpdate = {
|
||||||
|
currentTime: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEncoding = (encoding: string | string[]): Encoding => {
|
||||||
|
const encodingArray = Array.isArray(encoding) ? encoding : [encoding];
|
||||||
|
|
||||||
|
for (const code of encodingArray) {
|
||||||
|
if (code.match(GZIP_REGEX)) {
|
||||||
|
return Encoding.GZIP;
|
||||||
|
}
|
||||||
|
if (code.match(ZLIB_REGEX)) {
|
||||||
|
return Encoding.ZLIB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Encoding.NONE;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cache = new Map<string, Map<Encoding, [number, Buffer]>>();
|
||||||
|
|
||||||
|
function setOk(
|
||||||
|
res: ServerResponse,
|
||||||
|
mtimeMs: number,
|
||||||
|
extension: keyof MimeType,
|
||||||
|
encoding: Encoding,
|
||||||
|
data?: Buffer,
|
||||||
|
) {
|
||||||
|
res.statusCode = data ? 200 : 304;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', MIME_TYPES[extension]);
|
||||||
|
res.setHeader('ETag', `"${mtimeMs}"`);
|
||||||
|
res.setHeader('Cache-Control', 'public');
|
||||||
|
|
||||||
|
if (encoding !== 'none') res.setHeader('Content-Encoding', encoding);
|
||||||
|
res.end(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serveFile(
|
||||||
|
req: IncomingMessage,
|
||||||
|
file: string,
|
||||||
|
extension: keyof MimeType,
|
||||||
|
res: ServerResponse,
|
||||||
|
): Promise<void> {
|
||||||
|
const fileName = `${file}.${extension}`;
|
||||||
|
let path: string;
|
||||||
|
|
||||||
|
if (extension === 'ico') {
|
||||||
|
path = app.isPackaged
|
||||||
|
? join(process.resourcesPath, 'assets', fileName)
|
||||||
|
: join(__dirname, '../../../../../assets', fileName);
|
||||||
|
} else {
|
||||||
|
path = app.isPackaged
|
||||||
|
? join(__dirname, '../remote', fileName)
|
||||||
|
: join(__dirname, '../../../../../.erb/dll', fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stats: Stats;
|
||||||
|
|
||||||
|
try {
|
||||||
|
stats = await promises.stat(path);
|
||||||
|
} catch (error) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.end((error as Error).message);
|
||||||
|
// This is a resolve, even though it is an error, because we want specific (non 500) status
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodings = req.headers['accept-encoding'] ?? '';
|
||||||
|
const selectedEncoding = getEncoding(encodings);
|
||||||
|
|
||||||
|
const ifMatch = req.headers['if-none-match'];
|
||||||
|
|
||||||
|
const fileInfo = cache.get(fileName);
|
||||||
|
let cached = fileInfo?.get(selectedEncoding);
|
||||||
|
|
||||||
|
if (cached && cached[0] !== stats.mtimeMs) {
|
||||||
|
cache.get(fileName)!.delete(selectedEncoding);
|
||||||
|
cached = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ifMatch && cached) {
|
||||||
|
const options = ifMatch.split(',');
|
||||||
|
|
||||||
|
for (const option of options) {
|
||||||
|
const mTime = Number(option.replaceAll('"', '').trim());
|
||||||
|
|
||||||
|
if (cached[0] === mTime) {
|
||||||
|
setOk(res, cached[0], extension, selectedEncoding);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cached || cached[0] !== stats.mtimeMs) {
|
||||||
|
const content = await readFile(path);
|
||||||
|
|
||||||
|
switch (selectedEncoding) {
|
||||||
|
case Encoding.GZIP:
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
gzip(content, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEntry: [number, Buffer] = [stats.mtimeMs, result];
|
||||||
|
|
||||||
|
if (fileInfo) {
|
||||||
|
fileInfo.set(selectedEncoding, newEntry);
|
||||||
|
} else {
|
||||||
|
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
setOk(res, stats.mtimeMs, extension, selectedEncoding, result);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
case Encoding.ZLIB:
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
deflate(content, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEntry: [number, Buffer] = [stats.mtimeMs, result];
|
||||||
|
|
||||||
|
if (fileInfo) {
|
||||||
|
fileInfo.set(selectedEncoding, newEntry);
|
||||||
|
} else {
|
||||||
|
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
setOk(res, stats.mtimeMs, extension, selectedEncoding, result);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
default: {
|
||||||
|
const newEntry: [number, Buffer] = [stats.mtimeMs, content];
|
||||||
|
|
||||||
|
if (fileInfo) {
|
||||||
|
fileInfo.set(selectedEncoding, newEntry);
|
||||||
|
} else {
|
||||||
|
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
setOk(res, stats.mtimeMs, extension, selectedEncoding, content);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOk(res, cached[0], extension, selectedEncoding, cached[1]);
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorize(req: IncomingMessage): boolean {
|
||||||
|
if (settings.username || settings.password) {
|
||||||
|
// https://stackoverflow.com/questions/23616371/basic-http-authentication-with-node-and-express-4
|
||||||
|
|
||||||
|
const authorization = req.headers.authorization?.split(' ')[1] || '';
|
||||||
|
const [login, password] = Buffer.from(authorization, 'base64').toString().split(':');
|
||||||
|
|
||||||
|
return login === settings.username && password === settings.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableServer = (config: RemoteConfig): Promise<void> => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
if (server) {
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
server = createServer({}, async (req, res) => {
|
||||||
|
if (!authorize(req)) {
|
||||||
|
res.statusCode = 401;
|
||||||
|
res.setHeader('WWW-Authenticate', 'Basic realm="401"');
|
||||||
|
res.end('Authorization required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (req.url) {
|
||||||
|
case '/': {
|
||||||
|
await serveFile(req, 'index', 'html', res);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '/favicon.ico': {
|
||||||
|
await serveFile(req, 'icon', 'ico', res);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '/remote.css': {
|
||||||
|
await serveFile(req, 'remote', 'css', res);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '/remote.js': {
|
||||||
|
await serveFile(req, 'remote', 'js', res);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.end('Not FOund');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.end((error as Error).message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(config.port, resolve);
|
||||||
|
wsServer = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
wsServer.on('connection', (ws, req) => {
|
||||||
|
if (!authorize(req)) {
|
||||||
|
ws.close(4003);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.alive = true;
|
||||||
|
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data.toString()) as ClientEvent;
|
||||||
|
const event = json.event;
|
||||||
|
|
||||||
|
switch (event) {
|
||||||
|
case 'pause': {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-pause');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'play': {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-play');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'next': {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-next');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'previous': {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-previous');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'proxy': {
|
||||||
|
const toFetch = currentSong.song?.imageUrl?.replaceAll(
|
||||||
|
/&(size|width|height=\d+)/g,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!toFetch) return;
|
||||||
|
|
||||||
|
axios
|
||||||
|
.get(toFetch, { responseType: 'arraybuffer' })
|
||||||
|
.then((resp) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
send({
|
||||||
|
client: ws,
|
||||||
|
data: Buffer.from(resp.data, 'binary').toString(
|
||||||
|
'base64',
|
||||||
|
),
|
||||||
|
event: 'proxy',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
send({
|
||||||
|
client: ws,
|
||||||
|
data: error.message,
|
||||||
|
event: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'repeat': {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-toggle-repeat');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'shuffle': {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-toggle-shuffle');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'volume': {
|
||||||
|
let volume = Number(json.volume);
|
||||||
|
|
||||||
|
if (volume > 100) {
|
||||||
|
volume = 100;
|
||||||
|
} else if (volume < 0) {
|
||||||
|
volume = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSong.volume = volume;
|
||||||
|
|
||||||
|
broadcast({ data: { volume }, event: 'song' });
|
||||||
|
getMainWindow()?.webContents.send('request-volume', {
|
||||||
|
volume,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mprisPlayer) {
|
||||||
|
mprisPlayer.volume = volume / 100;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'favorite': {
|
||||||
|
const { favorite, id } = json;
|
||||||
|
if (id && id === currentSong.song?.id) {
|
||||||
|
getMainWindow()?.webContents.send('request-favorite', {
|
||||||
|
favorite,
|
||||||
|
id,
|
||||||
|
serverId: currentSong.song.serverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rating': {
|
||||||
|
const { rating, id } = json;
|
||||||
|
if (id && id === currentSong.song?.id) {
|
||||||
|
getMainWindow()?.webContents.send('request-rating', {
|
||||||
|
id,
|
||||||
|
rating,
|
||||||
|
serverId: currentSong.song.serverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('pong', () => {
|
||||||
|
ws.alive = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ data: currentSong, event: 'song' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const heartBeat = setInterval(() => {
|
||||||
|
wsServer?.clients.forEach((ws) => {
|
||||||
|
if (!ws.alive) {
|
||||||
|
ws.terminate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.alive = false;
|
||||||
|
ws.ping();
|
||||||
|
});
|
||||||
|
}, PING_TIMEOUT_MS);
|
||||||
|
|
||||||
|
wsServer.on('close', () => {
|
||||||
|
clearInterval(heartBeat);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error('Server did not come up'));
|
||||||
|
}, UP_TIMEOUT_MS);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
shutdownServer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcMain.handle('remote-enable', async (_event, enabled: boolean) => {
|
||||||
|
settings.enabled = enabled;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
try {
|
||||||
|
await enableServer(settings);
|
||||||
|
} catch (error) {
|
||||||
|
return (error as Error).message;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shutdownServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('remote-port', async (_event, port: number) => {
|
||||||
|
settings.port = port;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('remote-password', (_event, password: string) => {
|
||||||
|
settings.password = password;
|
||||||
|
wsServer?.clients.forEach((client) => client.close(4002));
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'remote-settings',
|
||||||
|
async (_event, enabled: boolean, port: number, username: string, password: string) => {
|
||||||
|
settings.enabled = enabled;
|
||||||
|
settings.password = password;
|
||||||
|
settings.port = port;
|
||||||
|
settings.username = username;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
try {
|
||||||
|
await enableServer(settings);
|
||||||
|
} catch (error) {
|
||||||
|
return (error as Error).message;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shutdownServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.on('remote-username', (_event, username: string) => {
|
||||||
|
settings.username = username;
|
||||||
|
wsServer?.clients.forEach((client) => client.close(4002));
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids: string[]) => {
|
||||||
|
if (currentSong.song?.serverId !== serverId) return;
|
||||||
|
|
||||||
|
const id = currentSong.song.id;
|
||||||
|
|
||||||
|
for (const songId of ids) {
|
||||||
|
if (songId === id) {
|
||||||
|
currentSong.song.userFavorite = favorite;
|
||||||
|
broadcast({ data: { favorite, id: songId }, event: 'favorite' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: string[]) => {
|
||||||
|
if (currentSong.song?.serverId !== serverId) return;
|
||||||
|
|
||||||
|
const id = currentSong.song.id;
|
||||||
|
|
||||||
|
for (const songId of ids) {
|
||||||
|
if (songId === id) {
|
||||||
|
currentSong.song.userRating = rating;
|
||||||
|
broadcast({ data: { id: songId, rating }, event: 'rating' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => {
|
||||||
|
currentSong.repeat = repeat;
|
||||||
|
broadcast({ data: { repeat }, event: 'song' });
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
|
||||||
|
currentSong.shuffle = shuffle;
|
||||||
|
broadcast({ data: { shuffle }, event: 'song' });
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('update-song', (_event, data: SongUpdate) => {
|
||||||
|
const { song, ...rest } = data;
|
||||||
|
const songChanged = song?.id !== currentSong.song?.id;
|
||||||
|
|
||||||
|
if (!song?.id) {
|
||||||
|
currentSong = {
|
||||||
|
...currentSong,
|
||||||
|
...data,
|
||||||
|
song: undefined,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
currentSong = {
|
||||||
|
...currentSong,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (songChanged) {
|
||||||
|
broadcast({ data: { ...rest, song: song || null }, event: 'song' });
|
||||||
|
} else {
|
||||||
|
broadcast({ data: rest, event: 'song' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('update-volume', (_event, volume: number) => {
|
||||||
|
currentSong.volume = volume;
|
||||||
|
broadcast({ data: { volume }, event: 'song' });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mprisPlayer) {
|
||||||
|
mprisPlayer.on('loopStatus', (event: string) => {
|
||||||
|
const repeat =
|
||||||
|
event === 'Playlist'
|
||||||
|
? PlayerRepeat.ALL
|
||||||
|
: event === 'Track'
|
||||||
|
? PlayerRepeat.ONE
|
||||||
|
: PlayerRepeat.NONE;
|
||||||
|
|
||||||
|
currentSong.repeat = repeat;
|
||||||
|
broadcast({ data: { repeat }, event: 'song' });
|
||||||
|
});
|
||||||
|
|
||||||
|
mprisPlayer.on('shuffle', (shuffle: boolean) => {
|
||||||
|
currentSong.shuffle = shuffle;
|
||||||
|
broadcast({ data: { shuffle }, event: 'song' });
|
||||||
|
});
|
||||||
|
|
||||||
|
mprisPlayer.on('volume', (vol: number) => {
|
||||||
|
let volume = Math.round(vol * 100);
|
||||||
|
|
||||||
|
if (volume > 100) {
|
||||||
|
volume = 100;
|
||||||
|
} else if (volume < 0) {
|
||||||
|
volume = 0;
|
||||||
|
}
|
||||||
|
currentSong.volume = volume;
|
||||||
|
broadcast({ data: { volume }, event: 'song' });
|
||||||
|
});
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import { ipcMain, safeStorage } from 'electron';
|
|
||||||
import Store from 'electron-store';
|
import Store from 'electron-store';
|
||||||
|
import { ipcMain, safeStorage } from 'electron';
|
||||||
|
|
||||||
export const store = new Store();
|
export const store = new Store();
|
||||||
|
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { ipcMain } from 'electron';
|
import { ipcMain } from 'electron';
|
||||||
import Player from 'mpris-service';
|
import Player from 'mpris-service';
|
||||||
import { QueueSong, RelatedArtist } from '../../../renderer/api/types';
|
import { PlayerRepeat, PlayerStatus, SongUpdate } from '../../../renderer/types';
|
||||||
import { getMainWindow } from '../../main';
|
import { getMainWindow } from '../../main';
|
||||||
import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/renderer/types';
|
|
||||||
|
|
||||||
const mprisPlayer = Player({
|
const mprisPlayer = Player({
|
||||||
identity: 'Feishin',
|
identity: 'Feishin',
|
||||||
@ -59,9 +58,17 @@ mprisPlayer.on('previous', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
mprisPlayer.on('volume', (event: any) => {
|
mprisPlayer.on('volume', (vol: number) => {
|
||||||
getMainWindow()?.webContents.send('mpris-request-volume', {
|
let volume = Math.round(vol * 100);
|
||||||
volume: event,
|
|
||||||
|
if (volume > 100) {
|
||||||
|
volume = 100;
|
||||||
|
} else if (volume < 0) {
|
||||||
|
volume = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMainWindow()?.webContents.send('request-volume', {
|
||||||
|
volume,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -76,13 +83,13 @@ mprisPlayer.on('loopStatus', (event: string) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
mprisPlayer.on('position', (event: any) => {
|
mprisPlayer.on('position', (event: any) => {
|
||||||
getMainWindow()?.webContents.send('mpris-request-position', {
|
getMainWindow()?.webContents.send('request-position', {
|
||||||
position: event.position / 1e6,
|
position: event.position / 1e6,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
mprisPlayer.on('seek', (event: number) => {
|
mprisPlayer.on('seek', (event: number) => {
|
||||||
getMainWindow()?.webContents.send('mpris-request-seek', {
|
getMainWindow()?.webContents.send('request-seek', {
|
||||||
offset: event / 1e6,
|
offset: event / 1e6,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -95,76 +102,68 @@ ipcMain.on('mpris-update-seek', (_event, arg) => {
|
|||||||
mprisPlayer.seeked(arg * 1e6);
|
mprisPlayer.seeked(arg * 1e6);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('mpris-update-volume', (_event, arg) => {
|
ipcMain.on('update-volume', (_event, volume) => {
|
||||||
mprisPlayer.volume = Number(arg);
|
mprisPlayer.volume = Number(volume) / 100;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('mpris-update-repeat', (_event, arg) => {
|
const REPEAT_TO_MPRIS: Record<PlayerRepeat, string> = {
|
||||||
mprisPlayer.loopStatus = arg;
|
[PlayerRepeat.ALL]: 'Playlist',
|
||||||
|
[PlayerRepeat.ONE]: 'Track',
|
||||||
|
[PlayerRepeat.NONE]: 'None',
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcMain.on('update-repeat', (_event, arg: PlayerRepeat) => {
|
||||||
|
mprisPlayer.loopStatus = REPEAT_TO_MPRIS[arg];
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('mpris-update-shuffle', (_event, arg) => {
|
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
|
||||||
mprisPlayer.shuffle = arg;
|
mprisPlayer.shuffle = shuffle;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on(
|
ipcMain.on('update-song', (_event, args: SongUpdate) => {
|
||||||
'mpris-update-song',
|
const { song, status, repeat, shuffle } = args || {};
|
||||||
(
|
|
||||||
_event,
|
|
||||||
args: {
|
|
||||||
currentTime: number;
|
|
||||||
repeat: PlayerRepeat;
|
|
||||||
shuffle: PlayerShuffle;
|
|
||||||
song: QueueSong;
|
|
||||||
status: PlayerStatus;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const { song, status, repeat, shuffle } = args || {};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
mprisPlayer.playbackStatus = status;
|
mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused';
|
||||||
|
|
||||||
if (repeat) {
|
if (repeat) {
|
||||||
mprisPlayer.loopStatus =
|
mprisPlayer.loopStatus = REPEAT_TO_MPRIS[repeat];
|
||||||
repeat === 'all' ? 'Playlist' : repeat === 'one' ? 'Track' : 'None';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shuffle) {
|
|
||||||
mprisPlayer.shuffle = shuffle !== 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!song) return;
|
|
||||||
|
|
||||||
const upsizedImageUrl = song.imageUrl
|
|
||||||
? song.imageUrl
|
|
||||||
?.replace(/&size=\d+/, '&size=300')
|
|
||||||
.replace(/\?width=\d+/, '?width=300')
|
|
||||||
.replace(/&height=\d+/, '&height=300')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
mprisPlayer.metadata = {
|
|
||||||
'mpris:artUrl': upsizedImageUrl,
|
|
||||||
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e6) : null,
|
|
||||||
'mpris:trackid': song?.id
|
|
||||||
? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
|
|
||||||
: '',
|
|
||||||
'xesam:album': song.album || null,
|
|
||||||
'xesam:albumArtist': song.albumArtists?.length ? song.albumArtists[0].name : null,
|
|
||||||
'xesam:artist':
|
|
||||||
song.artists?.length !== 0
|
|
||||||
? song.artists?.map((artist: RelatedArtist) => artist.name)
|
|
||||||
: null,
|
|
||||||
'xesam:discNumber': song.discNumber ? song.discNumber : null,
|
|
||||||
'xesam:genre': song.genres?.length
|
|
||||||
? song.genres.map((genre: any) => genre.name)
|
|
||||||
: null,
|
|
||||||
'xesam:title': song.name || null,
|
|
||||||
'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,
|
|
||||||
'xesam:useCount':
|
|
||||||
song.playCount !== null && song.playCount !== undefined ? song.playCount : null,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
);
|
if (shuffle) {
|
||||||
|
mprisPlayer.shuffle = shuffle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!song) return;
|
||||||
|
|
||||||
|
const upsizedImageUrl = song.imageUrl
|
||||||
|
? song.imageUrl
|
||||||
|
?.replace(/&size=\d+/, '&size=300')
|
||||||
|
.replace(/\?width=\d+/, '?width=300')
|
||||||
|
.replace(/&height=\d+/, '&height=300')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
mprisPlayer.metadata = {
|
||||||
|
'mpris:artUrl': upsizedImageUrl,
|
||||||
|
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e6) : null,
|
||||||
|
'mpris:trackid': song.id
|
||||||
|
? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
|
||||||
|
: '',
|
||||||
|
'xesam:album': song.album || null,
|
||||||
|
'xesam:albumArtist': song.albumArtists?.length
|
||||||
|
? song.albumArtists.map((artist) => artist.name)
|
||||||
|
: null,
|
||||||
|
'xesam:artist': song.artists?.length ? song.artists.map((artist) => artist.name) : null,
|
||||||
|
'xesam:discNumber': song.discNumber ? song.discNumber : null,
|
||||||
|
'xesam:genre': song.genres?.length ? song.genres.map((genre: any) => genre.name) : null,
|
||||||
|
'xesam:title': song.name || null,
|
||||||
|
'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,
|
||||||
|
'xesam:useCount':
|
||||||
|
song.playCount !== null && song.playCount !== undefined ? song.playCount : null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { mprisPlayer };
|
||||||
|
@ -11,6 +11,11 @@
|
|||||||
import { access, constants, readFile, writeFile } from 'fs';
|
import { access, constants, readFile, writeFile } from 'fs';
|
||||||
import path, { join } from 'path';
|
import path, { join } from 'path';
|
||||||
import { deflate, inflate } from 'zlib';
|
import { deflate, inflate } from 'zlib';
|
||||||
|
import electronLocalShortcut from 'electron-localshortcut';
|
||||||
|
import log from 'electron-log';
|
||||||
|
import { autoUpdater } from 'electron-updater';
|
||||||
|
import uniq from 'lodash/uniq';
|
||||||
|
import MpvAPI from 'node-mpv';
|
||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
@ -22,11 +27,6 @@ import {
|
|||||||
nativeImage,
|
nativeImage,
|
||||||
BrowserWindowConstructorOptions,
|
BrowserWindowConstructorOptions,
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import electronLocalShortcut from 'electron-localshortcut';
|
|
||||||
import log from 'electron-log';
|
|
||||||
import { autoUpdater } from 'electron-updater';
|
|
||||||
import uniq from 'lodash/uniq';
|
|
||||||
import MpvAPI from 'node-mpv';
|
|
||||||
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
|
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
|
||||||
import { store } from './features/core/settings/index';
|
import { store } from './features/core/settings/index';
|
||||||
import MenuBuilder from './menu';
|
import MenuBuilder from './menu';
|
||||||
|
@ -5,6 +5,7 @@ import { localSettings } from './preload/local-settings';
|
|||||||
import { lyrics } from './preload/lyrics';
|
import { lyrics } from './preload/lyrics';
|
||||||
import { mpris } from './preload/mpris';
|
import { mpris } from './preload/mpris';
|
||||||
import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
|
import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
|
||||||
|
import { remote } from './preload/remote';
|
||||||
import { utils } from './preload/utils';
|
import { utils } from './preload/utils';
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electron', {
|
contextBridge.exposeInMainWorld('electron', {
|
||||||
@ -15,5 +16,6 @@ contextBridge.exposeInMainWorld('electron', {
|
|||||||
mpris,
|
mpris,
|
||||||
mpvPlayer,
|
mpvPlayer,
|
||||||
mpvPlayerListener,
|
mpvPlayerListener,
|
||||||
|
remote,
|
||||||
utils,
|
utils,
|
||||||
});
|
});
|
||||||
|
@ -12,3 +12,5 @@ export const ipc = {
|
|||||||
removeAllListeners,
|
removeAllListeners,
|
||||||
send,
|
send,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Ipc = typeof ipc;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ipcRenderer, webFrame } from 'electron';
|
|
||||||
import Store from 'electron-store';
|
import Store from 'electron-store';
|
||||||
|
import { ipcRenderer, webFrame } from 'electron';
|
||||||
|
|
||||||
const store = new Store();
|
const store = new Store();
|
||||||
|
|
||||||
@ -50,3 +50,5 @@ export const localSettings = {
|
|||||||
set,
|
set,
|
||||||
setZoomFactor,
|
setZoomFactor,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LocalSettings = typeof localSettings;
|
||||||
|
@ -1,17 +1,25 @@
|
|||||||
import { ipcRenderer } from 'electron';
|
import { ipcRenderer } from 'electron';
|
||||||
import { LyricSearchQuery, QueueSong } from '/@/renderer/api/types';
|
import {
|
||||||
|
InternetProviderLyricSearchResponse,
|
||||||
|
LyricGetQuery,
|
||||||
|
LyricSearchQuery,
|
||||||
|
LyricSource,
|
||||||
|
QueueSong,
|
||||||
|
} from '/@/renderer/api/types';
|
||||||
|
|
||||||
const getRemoteLyricsBySong = (song: QueueSong) => {
|
const getRemoteLyricsBySong = (song: QueueSong) => {
|
||||||
const result = ipcRenderer.invoke('lyric-by-song', song);
|
const result = ipcRenderer.invoke('lyric-by-song', song);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchRemoteLyrics = (params: LyricSearchQuery) => {
|
const searchRemoteLyrics = (
|
||||||
|
params: LyricSearchQuery,
|
||||||
|
): Promise<Record<LyricSource, InternetProviderLyricSearchResponse[]>> => {
|
||||||
const result = ipcRenderer.invoke('lyric-search', params);
|
const result = ipcRenderer.invoke('lyric-search', params);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRemoteLyricsByRemoteId = (id: string) => {
|
const getRemoteLyricsByRemoteId = (id: LyricGetQuery) => {
|
||||||
const result = ipcRenderer.invoke('lyric-by-remote-id', id);
|
const result = ipcRenderer.invoke('lyric-by-remote-id', id);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
@ -21,3 +29,5 @@ export const lyrics = {
|
|||||||
getRemoteLyricsBySong,
|
getRemoteLyricsBySong,
|
||||||
searchRemoteLyrics,
|
searchRemoteLyrics,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Lyrics = typeof lyrics;
|
||||||
|
@ -1,9 +1,5 @@
|
|||||||
import { IpcRendererEvent, ipcRenderer } from 'electron';
|
import { IpcRendererEvent, ipcRenderer } from 'electron';
|
||||||
import { QueueSong } from '/@/renderer/api/types';
|
import type { PlayerRepeat } from '/@/renderer/types';
|
||||||
|
|
||||||
const updateSong = (args: { currentTime: number; song: QueueSong }) => {
|
|
||||||
ipcRenderer.send('mpris-update-song', args);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePosition = (timeSec: number) => {
|
const updatePosition = (timeSec: number) => {
|
||||||
ipcRenderer.send('mpris-update-position', timeSec);
|
ipcRenderer.send('mpris-update-position', timeSec);
|
||||||
@ -13,18 +9,6 @@ const updateSeek = (timeSec: number) => {
|
|||||||
ipcRenderer.send('mpris-update-seek', timeSec);
|
ipcRenderer.send('mpris-update-seek', timeSec);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateVolume = (volume: number) => {
|
|
||||||
ipcRenderer.send('mpris-update-volume', volume);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateRepeat = (repeat: string) => {
|
|
||||||
ipcRenderer.send('mpris-update-repeat', repeat);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateShuffle = (shuffle: boolean) => {
|
|
||||||
ipcRenderer.send('mpris-update-shuffle', shuffle);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleRepeat = () => {
|
const toggleRepeat = () => {
|
||||||
ipcRenderer.send('mpris-toggle-repeat');
|
ipcRenderer.send('mpris-toggle-repeat');
|
||||||
};
|
};
|
||||||
@ -33,38 +17,25 @@ const toggleShuffle = () => {
|
|||||||
ipcRenderer.send('mpris-toggle-shuffle');
|
ipcRenderer.send('mpris-toggle-shuffle');
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
|
const requestToggleRepeat = (
|
||||||
ipcRenderer.on('mpris-request-position', cb);
|
cb: (event: IpcRendererEvent, data: { repeat: PlayerRepeat }) => void,
|
||||||
};
|
) => {
|
||||||
|
|
||||||
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
|
|
||||||
ipcRenderer.on('mpris-request-seek', cb);
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
|
|
||||||
ipcRenderer.on('mpris-request-volume', cb);
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestToggleRepeat = (cb: (event: IpcRendererEvent) => void) => {
|
|
||||||
ipcRenderer.on('mpris-request-toggle-repeat', cb);
|
ipcRenderer.on('mpris-request-toggle-repeat', cb);
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestToggleShuffle = (cb: (event: IpcRendererEvent) => void) => {
|
const requestToggleShuffle = (
|
||||||
|
cb: (event: IpcRendererEvent, data: { shuffle: boolean }) => void,
|
||||||
|
) => {
|
||||||
ipcRenderer.on('mpris-request-toggle-shuffle', cb);
|
ipcRenderer.on('mpris-request-toggle-shuffle', cb);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mpris = {
|
export const mpris = {
|
||||||
requestPosition,
|
|
||||||
requestSeek,
|
|
||||||
requestToggleRepeat,
|
requestToggleRepeat,
|
||||||
requestToggleShuffle,
|
requestToggleShuffle,
|
||||||
requestVolume,
|
|
||||||
toggleRepeat,
|
toggleRepeat,
|
||||||
toggleShuffle,
|
toggleShuffle,
|
||||||
updatePosition,
|
updatePosition,
|
||||||
updateRepeat,
|
|
||||||
updateSeek,
|
updateSeek,
|
||||||
updateShuffle,
|
|
||||||
updateSong,
|
|
||||||
updateVolume,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Mpris = typeof mpris;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||||
import { PlayerData } from '/@/renderer/store';
|
import { PlayerData, PlayerState } from '/@/renderer/store';
|
||||||
|
|
||||||
const initialize = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
const initialize = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||||
ipcRenderer.send('player-initialize', data);
|
ipcRenderer.send('player-initialize', data);
|
||||||
@ -30,8 +30,8 @@ const currentTime = () => {
|
|||||||
ipcRenderer.send('player-current-time');
|
ipcRenderer.send('player-current-time');
|
||||||
};
|
};
|
||||||
|
|
||||||
const mute = () => {
|
const mute = (mute: boolean) => {
|
||||||
ipcRenderer.send('player-mute');
|
ipcRenderer.send('player-mute', mute);
|
||||||
};
|
};
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
@ -158,7 +158,9 @@ const rendererSaveQueue = (cb: (event: IpcRendererEvent) => void) => {
|
|||||||
ipcRenderer.on('renderer-player-save-queue', cb);
|
ipcRenderer.on('renderer-player-save-queue', cb);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rendererRestoreQueue = (cb: (event: IpcRendererEvent) => void) => {
|
const rendererRestoreQueue = (
|
||||||
|
cb: (event: IpcRendererEvent, data: Partial<PlayerState>) => void,
|
||||||
|
) => {
|
||||||
ipcRenderer.on('renderer-player-restore-queue', cb);
|
ipcRenderer.on('renderer-player-restore-queue', cb);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -212,3 +214,6 @@ export const mpvPlayerListener = {
|
|||||||
rendererVolumeMute,
|
rendererVolumeMute,
|
||||||
rendererVolumeUp,
|
rendererVolumeUp,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MpvPLayer = typeof mpvPlayer;
|
||||||
|
export type MpvPlayerListener = typeof mpvPlayerListener;
|
||||||
|
101
src/main/preload/remote.ts
Normal file
101
src/main/preload/remote.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { IpcRendererEvent, ipcRenderer } from 'electron';
|
||||||
|
import { SongUpdate } from '/@/renderer/types';
|
||||||
|
|
||||||
|
const requestFavorite = (
|
||||||
|
cb: (
|
||||||
|
event: IpcRendererEvent,
|
||||||
|
data: { favorite: boolean; id: string; serverId: string },
|
||||||
|
) => void,
|
||||||
|
) => {
|
||||||
|
ipcRenderer.on('request-favorite', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
|
||||||
|
ipcRenderer.on('request-position', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestRating = (
|
||||||
|
cb: (event: IpcRendererEvent, data: { id: string; rating: number; serverId: string }) => void,
|
||||||
|
) => {
|
||||||
|
ipcRenderer.on('request-rating', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
|
||||||
|
ipcRenderer.on('request-seek', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
|
||||||
|
ipcRenderer.on('request-volume', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRemoteEnabled = (enabled: boolean): Promise<string | null> => {
|
||||||
|
const result = ipcRenderer.invoke('remote-enable', enabled);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRemotePort = (port: number): Promise<string | null> => {
|
||||||
|
const result = ipcRenderer.invoke('remote-port', port);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFavorite = (favorite: boolean, serverId: string, ids: string[]) => {
|
||||||
|
ipcRenderer.send('update-favorite', favorite, serverId, ids);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePassword = (password: string) => {
|
||||||
|
ipcRenderer.send('remote-password', password);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSetting = (
|
||||||
|
enabled: boolean,
|
||||||
|
port: number,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<string | null> => {
|
||||||
|
return ipcRenderer.invoke('remote-settings', enabled, port, username, password);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRating = (rating: number, serverId: string, ids: string[]) => {
|
||||||
|
ipcRenderer.send('update-rating', rating, serverId, ids);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRepeat = (repeat: string) => {
|
||||||
|
ipcRenderer.send('update-repeat', repeat);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateShuffle = (shuffle: boolean) => {
|
||||||
|
ipcRenderer.send('update-shuffle', shuffle);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSong = (args: SongUpdate) => {
|
||||||
|
ipcRenderer.send('update-song', args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUsername = (username: string) => {
|
||||||
|
ipcRenderer.send('remote-username', username);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateVolume = (volume: number) => {
|
||||||
|
ipcRenderer.send('update-volume', volume);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const remote = {
|
||||||
|
requestFavorite,
|
||||||
|
requestPosition,
|
||||||
|
requestRating,
|
||||||
|
requestSeek,
|
||||||
|
requestVolume,
|
||||||
|
setRemoteEnabled,
|
||||||
|
setRemotePort,
|
||||||
|
updateFavorite,
|
||||||
|
updatePassword,
|
||||||
|
updateRating,
|
||||||
|
updateRepeat,
|
||||||
|
updateSetting,
|
||||||
|
updateShuffle,
|
||||||
|
updateSong,
|
||||||
|
updateUsername,
|
||||||
|
updateVolume,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Remote = typeof remote;
|
@ -5,3 +5,5 @@ export const utils = {
|
|||||||
isMacOS,
|
isMacOS,
|
||||||
isWindows,
|
isWindows,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Utils = typeof utils;
|
||||||
|
85
src/remote/app.tsx
Normal file
85
src/remote/app.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import './styles/global.scss';
|
||||||
|
import '@ag-grid-community/styles/ag-grid.css';
|
||||||
|
import { useIsDark, useReconnect } from '/@/remote/store';
|
||||||
|
import { Shell } from '/@/remote/components/shell';
|
||||||
|
|
||||||
|
export const App = () => {
|
||||||
|
const isDark = useIsDark();
|
||||||
|
const reconnect = useReconnect();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reconnect();
|
||||||
|
}, [reconnect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MantineProvider
|
||||||
|
withGlobalStyles
|
||||||
|
withNormalizeCSS
|
||||||
|
theme={{
|
||||||
|
colorScheme: isDark ? 'dark' : 'light',
|
||||||
|
components: {
|
||||||
|
AppShell: {
|
||||||
|
styles: {
|
||||||
|
body: {
|
||||||
|
height: '100vh',
|
||||||
|
overflow: 'scroll',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Modal: {
|
||||||
|
styles: {
|
||||||
|
body: {
|
||||||
|
background: 'var(--modal-bg)',
|
||||||
|
height: '100vh',
|
||||||
|
},
|
||||||
|
close: { marginRight: '0.5rem' },
|
||||||
|
content: { borderRadius: '5px' },
|
||||||
|
header: {
|
||||||
|
background: 'var(--modal-header-bg)',
|
||||||
|
paddingBottom: '1rem',
|
||||||
|
},
|
||||||
|
title: { fontSize: 'medium', fontWeight: 500 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultRadius: 'xs',
|
||||||
|
dir: 'ltr',
|
||||||
|
focusRing: 'auto',
|
||||||
|
focusRingStyles: {
|
||||||
|
inputStyles: () => ({
|
||||||
|
border: '1px solid var(--primary-color)',
|
||||||
|
}),
|
||||||
|
resetStyles: () => ({ outline: 'none' }),
|
||||||
|
styles: () => ({
|
||||||
|
outline: '1px solid var(--primary-color)',
|
||||||
|
outlineOffset: '-1px',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
fontFamily: 'var(--content-font-family)',
|
||||||
|
fontSizes: {
|
||||||
|
lg: '1.1rem',
|
||||||
|
md: '1rem',
|
||||||
|
sm: '0.9rem',
|
||||||
|
xl: '1.5rem',
|
||||||
|
xs: '0.8rem',
|
||||||
|
},
|
||||||
|
headings: {
|
||||||
|
fontFamily: 'var(--content-font-family)',
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
other: {},
|
||||||
|
spacing: {
|
||||||
|
lg: '2rem',
|
||||||
|
md: '1rem',
|
||||||
|
sm: '0.5rem',
|
||||||
|
xl: '4rem',
|
||||||
|
xs: '0rem',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Shell />
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
};
|
20
src/remote/components/buttons/image-button.tsx
Normal file
20
src/remote/components/buttons/image-button.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { CiImageOff, CiImageOn } from 'react-icons/ci';
|
||||||
|
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
|
||||||
|
import { useShowImage, useToggleShowImage } from '/@/remote/store';
|
||||||
|
|
||||||
|
export const ImageButton = () => {
|
||||||
|
const showImage = useShowImage();
|
||||||
|
const toggleImage = useToggleShowImage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RemoteButton
|
||||||
|
mr={5}
|
||||||
|
size="xl"
|
||||||
|
tooltip={showImage ? 'Hide Image' : 'Show Image'}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => toggleImage()}
|
||||||
|
>
|
||||||
|
{showImage ? <CiImageOff size={30} /> : <CiImageOn size={30} />}
|
||||||
|
</RemoteButton>
|
||||||
|
);
|
||||||
|
};
|
21
src/remote/components/buttons/reconnect-button.tsx
Normal file
21
src/remote/components/buttons/reconnect-button.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
|
||||||
|
import { useConnected, useReconnect } from '/@/remote/store';
|
||||||
|
import { RiRestartLine } from 'react-icons/ri';
|
||||||
|
|
||||||
|
export const ReconnectButton = () => {
|
||||||
|
const connected = useConnected();
|
||||||
|
const reconnect = useReconnect();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RemoteButton
|
||||||
|
$active={!connected}
|
||||||
|
mr={5}
|
||||||
|
size="xl"
|
||||||
|
tooltip={connected ? 'Reconnect' : 'Not connected. Reconnect.'}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => reconnect()}
|
||||||
|
>
|
||||||
|
<RiRestartLine size={30} />
|
||||||
|
</RemoteButton>
|
||||||
|
);
|
||||||
|
};
|
60
src/remote/components/buttons/remote-button.tsx
Normal file
60
src/remote/components/buttons/remote-button.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { Ref, forwardRef } from 'react';
|
||||||
|
import { Button, type ButtonProps as MantineButtonProps } from '@mantine/core';
|
||||||
|
import { Tooltip } from '/@/renderer/components/tooltip';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
interface StyledButtonProps extends MantineButtonProps {
|
||||||
|
$active?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||||
|
onMouseDown?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||||
|
ref: Ref<HTMLButtonElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ButtonProps extends StyledButtonProps {
|
||||||
|
tooltip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledButton = styled(Button)<StyledButtonProps>`
|
||||||
|
svg {
|
||||||
|
display: flex;
|
||||||
|
fill: ${({ $active: active }) =>
|
||||||
|
active ? 'var(--primary-color)' : 'var(--playerbar-btn-fg)'};
|
||||||
|
stroke: var(--playerbar-btn-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--playerbar-btn-bg-hover);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: ${({ $active: active }) =>
|
||||||
|
active
|
||||||
|
? 'var(--primary-color) !important'
|
||||||
|
: 'var(--playerbar-btn-fg-hover) !important'};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const RemoteButton = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ children, tooltip, ...props }: ButtonProps, ref) => {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
withinPortal
|
||||||
|
label={tooltip}
|
||||||
|
>
|
||||||
|
<StyledButton
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</StyledButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
RemoteButton.defaultProps = {
|
||||||
|
$active: false,
|
||||||
|
onClick: undefined,
|
||||||
|
onMouseDown: undefined,
|
||||||
|
};
|
27
src/remote/components/buttons/theme-button.tsx
Normal file
27
src/remote/components/buttons/theme-button.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useIsDark, useToggleDark } from '/@/remote/store';
|
||||||
|
import { RiMoonLine, RiSunLine } from 'react-icons/ri';
|
||||||
|
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
|
||||||
|
import { AppTheme } from '/@/renderer/themes/types';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export const ThemeButton = () => {
|
||||||
|
const isDark = useIsDark();
|
||||||
|
const toggleDark = useToggleDark();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const targetTheme: AppTheme = isDark ? AppTheme.DEFAULT_DARK : AppTheme.DEFAULT_LIGHT;
|
||||||
|
document.body.setAttribute('data-theme', targetTheme);
|
||||||
|
}, [isDark]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RemoteButton
|
||||||
|
mr={5}
|
||||||
|
size="xl"
|
||||||
|
tooltip="Toggle Theme"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => toggleDark()}
|
||||||
|
>
|
||||||
|
{isDark ? <RiSunLine size={30} /> : <RiMoonLine size={30} />}
|
||||||
|
</RemoteButton>
|
||||||
|
);
|
||||||
|
};
|
175
src/remote/components/remote-container.tsx
Normal file
175
src/remote/components/remote-container.tsx
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { Group, Image, Rating, Text, Title } from '@mantine/core';
|
||||||
|
import { useInfo, useSend, useShowImage } from '/@/remote/store';
|
||||||
|
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
|
||||||
|
import formatDuration from 'format-duration';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import {
|
||||||
|
RiHeartLine,
|
||||||
|
RiPauseFill,
|
||||||
|
RiPlayFill,
|
||||||
|
RiRepeat2Line,
|
||||||
|
RiRepeatOneLine,
|
||||||
|
RiShuffleFill,
|
||||||
|
RiSkipBackFill,
|
||||||
|
RiSkipForwardFill,
|
||||||
|
RiVolumeUpFill,
|
||||||
|
} from 'react-icons/ri';
|
||||||
|
import { PlayerRepeat, PlayerStatus } from '/@/renderer/types';
|
||||||
|
import { WrapperSlider } from '/@/remote/components/wrapped-slider';
|
||||||
|
import { Tooltip } from '/@/renderer/components/tooltip';
|
||||||
|
|
||||||
|
export const RemoteContainer = () => {
|
||||||
|
const { repeat, shuffle, song, status, volume } = useInfo();
|
||||||
|
const send = useSend();
|
||||||
|
const showImage = useShowImage();
|
||||||
|
|
||||||
|
const id = song?.id;
|
||||||
|
|
||||||
|
const setRating = useCallback(
|
||||||
|
(rating: number) => {
|
||||||
|
send({ event: 'rating', id: id!, rating });
|
||||||
|
},
|
||||||
|
[send, id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const debouncedSetRating = debounce(setRating, 400);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{song && (
|
||||||
|
<>
|
||||||
|
<Title order={1}>{song.name}</Title>
|
||||||
|
<Group align="flex-end">
|
||||||
|
<Title order={2}>Album: {song.album}</Title>
|
||||||
|
<Title order={2}>Artist: {song.artistName}</Title>
|
||||||
|
</Group>
|
||||||
|
<Group position="apart">
|
||||||
|
<Title order={3}>Duration: {formatDuration(song.duration * 1000)}</Title>
|
||||||
|
{song.releaseDate && (
|
||||||
|
<Title order={3}>
|
||||||
|
Released: {new Date(song.releaseDate).toLocaleDateString()}
|
||||||
|
</Title>
|
||||||
|
)}
|
||||||
|
<Title order={3}>Plays: {song.playCount}</Title>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Group
|
||||||
|
grow
|
||||||
|
spacing={0}
|
||||||
|
>
|
||||||
|
<RemoteButton
|
||||||
|
tooltip="Previous track"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => send({ event: 'previous' })}
|
||||||
|
>
|
||||||
|
<RiSkipBackFill size={25} />
|
||||||
|
</RemoteButton>
|
||||||
|
<RemoteButton
|
||||||
|
tooltip={status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
if (status === PlayerStatus.PLAYING) {
|
||||||
|
send({ event: 'pause' });
|
||||||
|
} else if (status === PlayerStatus.PAUSED) {
|
||||||
|
send({ event: 'play' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === PlayerStatus.PLAYING ? (
|
||||||
|
<RiPauseFill size={25} />
|
||||||
|
) : (
|
||||||
|
<RiPlayFill size={25} />
|
||||||
|
)}
|
||||||
|
</RemoteButton>
|
||||||
|
<RemoteButton
|
||||||
|
tooltip="Next track"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => send({ event: 'next' })}
|
||||||
|
>
|
||||||
|
<RiSkipForwardFill size={25} />
|
||||||
|
</RemoteButton>
|
||||||
|
</Group>
|
||||||
|
<Group
|
||||||
|
grow
|
||||||
|
spacing={0}
|
||||||
|
>
|
||||||
|
<RemoteButton
|
||||||
|
$active={shuffle || false}
|
||||||
|
tooltip={shuffle ? 'Shuffle tracks' : 'Shuffle disabled'}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => send({ event: 'shuffle' })}
|
||||||
|
>
|
||||||
|
<RiShuffleFill size={25} />
|
||||||
|
</RemoteButton>
|
||||||
|
<RemoteButton
|
||||||
|
$active={repeat !== undefined && repeat !== PlayerRepeat.NONE}
|
||||||
|
tooltip={`Repeat ${
|
||||||
|
repeat === PlayerRepeat.ONE
|
||||||
|
? 'One'
|
||||||
|
: repeat === PlayerRepeat.ALL
|
||||||
|
? 'all'
|
||||||
|
: 'none'
|
||||||
|
}`}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => send({ event: 'repeat' })}
|
||||||
|
>
|
||||||
|
{repeat === undefined || repeat === PlayerRepeat.ONE ? (
|
||||||
|
<RiRepeatOneLine size={25} />
|
||||||
|
) : (
|
||||||
|
<RiRepeat2Line size={25} />
|
||||||
|
)}
|
||||||
|
</RemoteButton>
|
||||||
|
<RemoteButton
|
||||||
|
$active={song?.userFavorite}
|
||||||
|
disabled={!song}
|
||||||
|
tooltip={song?.userFavorite ? 'Unfavorite' : 'Favorite'}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
send({ event: 'favorite', favorite: !song.userFavorite, id });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiHeartLine size={25} />
|
||||||
|
</RemoteButton>
|
||||||
|
{(song?.serverType === 'navidrome' || song?.serverType === 'subsonic') && (
|
||||||
|
<div style={{ margin: 'auto' }}>
|
||||||
|
<Tooltip
|
||||||
|
label="Double click to clear"
|
||||||
|
openDelay={1000}
|
||||||
|
>
|
||||||
|
<Rating
|
||||||
|
sx={{ margin: 'auto' }}
|
||||||
|
value={song.userRating ?? 0}
|
||||||
|
onChange={debouncedSetRating}
|
||||||
|
onDoubleClick={() => debouncedSetRating(0)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<WrapperSlider
|
||||||
|
leftLabel={<RiVolumeUpFill size={20} />}
|
||||||
|
max={100}
|
||||||
|
rightLabel={
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
weight={600}
|
||||||
|
>
|
||||||
|
{volume ?? 0}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
value={volume ?? 0}
|
||||||
|
onChangeEnd={(e) => send({ event: 'volume', volume: e })}
|
||||||
|
/>
|
||||||
|
{showImage && (
|
||||||
|
<Image
|
||||||
|
src={song?.imageUrl?.replaceAll(/&(size|width|height=\d+)/g, '')}
|
||||||
|
onError={() => send({ event: 'proxy' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
76
src/remote/components/shell.tsx
Normal file
76
src/remote/components/shell.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
AppShell,
|
||||||
|
Container,
|
||||||
|
Flex,
|
||||||
|
Grid,
|
||||||
|
Header,
|
||||||
|
Image,
|
||||||
|
MediaQuery,
|
||||||
|
Skeleton,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { ThemeButton } from '/@/remote/components/buttons/theme-button';
|
||||||
|
import { ImageButton } from '/@/remote/components/buttons/image-button';
|
||||||
|
import { RemoteContainer } from '/@/remote/components/remote-container';
|
||||||
|
import { ReconnectButton } from '/@/remote/components/buttons/reconnect-button';
|
||||||
|
import { useConnected } from '/@/remote/store';
|
||||||
|
|
||||||
|
export const Shell = () => {
|
||||||
|
const connected = useConnected();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
header={
|
||||||
|
<Header height={60}>
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span="auto">
|
||||||
|
<div>
|
||||||
|
<Image
|
||||||
|
bg="rgb(25, 25, 25)"
|
||||||
|
fit="contain"
|
||||||
|
height={60}
|
||||||
|
src="/favicon.ico"
|
||||||
|
width={60}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Grid.Col>
|
||||||
|
<MediaQuery
|
||||||
|
smallerThan="sm"
|
||||||
|
styles={{ display: 'none' }}
|
||||||
|
>
|
||||||
|
<Grid.Col
|
||||||
|
sm={6}
|
||||||
|
xs={0}
|
||||||
|
>
|
||||||
|
<Title ta="center">Feishin Remote</Title>
|
||||||
|
</Grid.Col>
|
||||||
|
</MediaQuery>
|
||||||
|
|
||||||
|
<Grid.Col span="auto">
|
||||||
|
<Flex
|
||||||
|
direction="row"
|
||||||
|
justify="right"
|
||||||
|
>
|
||||||
|
<ReconnectButton />
|
||||||
|
<ImageButton />
|
||||||
|
<ThemeButton />
|
||||||
|
</Flex>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Header>
|
||||||
|
}
|
||||||
|
padding="md"
|
||||||
|
>
|
||||||
|
<Container>
|
||||||
|
{connected ? (
|
||||||
|
<RemoteContainer />
|
||||||
|
) : (
|
||||||
|
<Skeleton
|
||||||
|
height={300}
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
};
|
62
src/remote/components/wrapped-slider.tsx
Normal file
62
src/remote/components/wrapped-slider.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { SliderProps } from '@mantine/core';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
|
||||||
|
|
||||||
|
const SliderContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
width: 95%;
|
||||||
|
height: 20px;
|
||||||
|
margin: 10px 0px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SliderValueWrapper = styled.div<{ position: 'left' | 'right' }>`
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-self: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 50px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SliderWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex: 6;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface WrappedProps extends Omit<SliderProps, 'onChangeEnd'> {
|
||||||
|
leftLabel?: JSX.Element;
|
||||||
|
onChangeEnd: (value: number) => void;
|
||||||
|
rightLabel?: JSX.Element;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WrapperSlider = ({ leftLabel, rightLabel, value, ...props }: WrappedProps) => {
|
||||||
|
const [isSeeking, setIsSeeking] = useState(false);
|
||||||
|
const [seek, setSeek] = useState(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SliderContainer>
|
||||||
|
{leftLabel && <SliderValueWrapper position="left">{leftLabel}</SliderValueWrapper>}
|
||||||
|
<SliderWrapper>
|
||||||
|
<PlayerbarSlider
|
||||||
|
{...props}
|
||||||
|
min={0}
|
||||||
|
size={6}
|
||||||
|
value={!isSeeking ? value ?? 0 : seek}
|
||||||
|
w="100%"
|
||||||
|
onChange={(e) => {
|
||||||
|
setIsSeeking(true);
|
||||||
|
setSeek(e);
|
||||||
|
}}
|
||||||
|
onChangeEnd={(e) => {
|
||||||
|
props.onChangeEnd(e);
|
||||||
|
setIsSeeking(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SliderWrapper>
|
||||||
|
{rightLabel && <SliderValueWrapper position="right">{rightLabel}</SliderValueWrapper>}
|
||||||
|
</SliderContainer>
|
||||||
|
);
|
||||||
|
};
|
15
src/remote/index.ejs
Normal file
15
src/remote/index.ejs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Feishin Remote</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
16
src/remote/index.tsx
Normal file
16
src/remote/index.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Notifications } from '@mantine/notifications';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from '/@/remote/app';
|
||||||
|
|
||||||
|
const container = document.getElementById('root')! as HTMLElement;
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<>
|
||||||
|
<Notifications
|
||||||
|
containerWidth="300px"
|
||||||
|
position="bottom-center"
|
||||||
|
/>
|
||||||
|
<App />
|
||||||
|
</>,
|
||||||
|
);
|
220
src/remote/store/index.ts
Normal file
220
src/remote/store/index.ts
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import { hideNotification, showNotification } from '@mantine/notifications';
|
||||||
|
import type { NotificationProps as MantineNotificationProps } from '@mantine/notifications';
|
||||||
|
import merge from 'lodash/merge';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools, persist } from 'zustand/middleware';
|
||||||
|
import { immer } from 'zustand/middleware/immer';
|
||||||
|
import { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/remote/types';
|
||||||
|
|
||||||
|
interface StatefulWebSocket extends WebSocket {
|
||||||
|
natural: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsState {
|
||||||
|
connected: boolean;
|
||||||
|
info: Omit<SongUpdateSocket, 'currentTime'>;
|
||||||
|
isDark: boolean;
|
||||||
|
showImage: boolean;
|
||||||
|
socket?: StatefulWebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsSlice extends SettingsState {
|
||||||
|
actions: {
|
||||||
|
reconnect: () => void;
|
||||||
|
send: (data: ClientEvent) => void;
|
||||||
|
toggleIsDark: () => void;
|
||||||
|
toggleShowImage: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: SettingsState = {
|
||||||
|
connected: false,
|
||||||
|
info: {},
|
||||||
|
isDark: window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||||
|
showImage: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface NotificationProps extends MantineNotificationProps {
|
||||||
|
type?: 'error' | 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
const showToast = ({ type, ...props }: NotificationProps) => {
|
||||||
|
const color = type === 'warning' ? 'var(--warning-color)' : 'var(--danger-color)';
|
||||||
|
|
||||||
|
const defaultTitle = type === 'warning' ? 'Warning' : 'Error';
|
||||||
|
|
||||||
|
const defaultDuration = type === 'error' ? 2000 : 1000;
|
||||||
|
|
||||||
|
return showNotification({
|
||||||
|
autoClose: defaultDuration,
|
||||||
|
styles: () => ({
|
||||||
|
closeButton: {
|
||||||
|
'&:hover': {
|
||||||
|
background: 'transparent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
color: 'var(--toast-description-fg)',
|
||||||
|
fontSize: '1rem',
|
||||||
|
},
|
||||||
|
loader: {
|
||||||
|
margin: '1rem',
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
'&::before': { backgroundColor: color },
|
||||||
|
background: 'var(--toast-bg)',
|
||||||
|
border: '2px solid var(--generic-border-color)',
|
||||||
|
bottom: '90px',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: 'var(--toast-title-fg)',
|
||||||
|
fontSize: '1.3rem',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
title: defaultTitle,
|
||||||
|
...props,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toast = {
|
||||||
|
error: (props: NotificationProps) => showToast({ type: 'error', ...props }),
|
||||||
|
hide: hideNotification,
|
||||||
|
warn: (props: NotificationProps) => showToast({ type: 'warning', ...props }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRemoteStore = create<SettingsSlice>()(
|
||||||
|
persist(
|
||||||
|
devtools(
|
||||||
|
immer((set, get) => ({
|
||||||
|
actions: {
|
||||||
|
reconnect: () => {
|
||||||
|
const existing = get().socket;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (
|
||||||
|
existing.readyState === WebSocket.OPEN ||
|
||||||
|
existing.readyState === WebSocket.CONNECTING
|
||||||
|
) {
|
||||||
|
existing.natural = true;
|
||||||
|
existing.close(4001);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set((state) => {
|
||||||
|
const socket = new WebSocket(
|
||||||
|
// eslint-disable-next-line no-restricted-globals
|
||||||
|
location.href.replace('http', 'ws'),
|
||||||
|
) as StatefulWebSocket;
|
||||||
|
|
||||||
|
socket.natural = false;
|
||||||
|
|
||||||
|
socket.addEventListener('message', (message) => {
|
||||||
|
const { event, data } = JSON.parse(message.data) as ServerEvent;
|
||||||
|
|
||||||
|
switch (event) {
|
||||||
|
case 'error': {
|
||||||
|
toast.error({ message: data, title: 'Socket error' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'favorite': {
|
||||||
|
set((state) => {
|
||||||
|
if (state.info.song?.id === data.id) {
|
||||||
|
state.info.song.userFavorite = data.favorite;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'proxy': {
|
||||||
|
set((state) => {
|
||||||
|
if (state.info.song) {
|
||||||
|
state.info.song.imageUrl = `data:image/jpeg;base64,${data}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rating': {
|
||||||
|
set((state) => {
|
||||||
|
if (state.info.song?.id === data.id) {
|
||||||
|
state.info.song.userRating = data.rating;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'song': {
|
||||||
|
set((nested) => {
|
||||||
|
nested.info = { ...nested.info, ...data };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener('open', () => {
|
||||||
|
set({ connected: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener('close', (reason) => {
|
||||||
|
if (reason.code === 4002 || reason.code === 4003) {
|
||||||
|
// eslint-disable-next-line no-restricted-globals
|
||||||
|
location.reload();
|
||||||
|
} else if (reason.code === 4000) {
|
||||||
|
toast.warn({
|
||||||
|
message: 'Feishin remote server is down',
|
||||||
|
title: 'Connection closed',
|
||||||
|
});
|
||||||
|
} else if (reason.code !== 4001 && !socket.natural) {
|
||||||
|
toast.error({
|
||||||
|
message: 'Socket closed for unexpected reason',
|
||||||
|
title: 'Connection closed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!socket.natural) {
|
||||||
|
set({ connected: false, info: {} });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.socket = socket;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
send: (data: ClientEvent) => {
|
||||||
|
get().socket?.send(JSON.stringify(data));
|
||||||
|
},
|
||||||
|
toggleIsDark: () => {
|
||||||
|
set((state) => {
|
||||||
|
state.isDark = !state.isDark;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
toggleShowImage: () => {
|
||||||
|
set((state) => {
|
||||||
|
state.showImage = !state.showImage;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...initialState,
|
||||||
|
})),
|
||||||
|
{ name: 'store_settings' },
|
||||||
|
),
|
||||||
|
{
|
||||||
|
merge: (persistedState, currentState) => {
|
||||||
|
return merge(currentState, persistedState);
|
||||||
|
},
|
||||||
|
name: 'store_settings',
|
||||||
|
version: 6,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useConnected = () => useRemoteStore((state) => state.connected);
|
||||||
|
|
||||||
|
export const useInfo = () => useRemoteStore((state) => state.info);
|
||||||
|
|
||||||
|
export const useIsDark = () => useRemoteStore((state) => state.isDark);
|
||||||
|
|
||||||
|
export const useReconnect = () => useRemoteStore((state) => state.actions.reconnect);
|
||||||
|
|
||||||
|
export const useShowImage = () => useRemoteStore((state) => state.showImage);
|
||||||
|
|
||||||
|
export const useSend = () => useRemoteStore((state) => state.actions.send);
|
||||||
|
|
||||||
|
export const useToggleDark = () => useRemoteStore((state) => state.actions.toggleIsDark);
|
||||||
|
|
||||||
|
export const useToggleShowImage = () => useRemoteStore((state) => state.actions.toggleShowImage);
|
127
src/remote/styles/global.scss
Normal file
127
src/remote/styles/global.scss
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
@use '../../renderer/themes/default.scss';
|
||||||
|
@use '../../renderer/themes/dark.scss';
|
||||||
|
@use '../../renderer/themes/light.scss';
|
||||||
|
@use '../../renderer/styles/ag-grid.scss';
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: hidden;
|
||||||
|
color: var(--content-text-color);
|
||||||
|
background: var(--content-bg);
|
||||||
|
font-family: var(--content-font-family);
|
||||||
|
font-size: var(--root-font-size);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 639px) {
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*:before,
|
||||||
|
*:after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: var(--scrollbar-track-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--scrollbar-track-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--scrollbar-thumb-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-scrollbar {
|
||||||
|
overflow-y: overlay !important;
|
||||||
|
overflow-x: overlay !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none; /* Safari and Chrome */
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mantine-ScrollArea-thumb[data-state='visible'] {
|
||||||
|
animation: fadeIn 0.3s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mantine-ScrollArea-scrollbar[data-state='hidden'] {
|
||||||
|
animation: fadeOut 0.2s forwards;
|
||||||
|
}
|
56
src/remote/types.ts
Normal file
56
src/remote/types.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import type { QueueSong } from '/@/renderer/api/types';
|
||||||
|
import type { SongUpdate } from '/@/renderer/types';
|
||||||
|
|
||||||
|
export interface SongUpdateSocket extends Omit<SongUpdate, 'song'> {
|
||||||
|
song?: QueueSong | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerError {
|
||||||
|
data: string;
|
||||||
|
event: 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerFavorite {
|
||||||
|
data: { favorite: boolean; id: string };
|
||||||
|
event: 'favorite';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerProxy {
|
||||||
|
data: string;
|
||||||
|
event: 'proxy';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerRating {
|
||||||
|
data: { id: string; rating: number };
|
||||||
|
event: 'rating';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerSong {
|
||||||
|
data: SongUpdateSocket;
|
||||||
|
event: 'song';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerEvent = ServerError | ServerFavorite | ServerRating | ServerSong | ServerProxy;
|
||||||
|
|
||||||
|
export interface ClientSimpleEvent {
|
||||||
|
event: 'next' | 'pause' | 'play' | 'previous' | 'proxy' | 'repeat' | 'shuffle';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientFavorite {
|
||||||
|
event: 'favorite';
|
||||||
|
favorite: boolean;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientRating {
|
||||||
|
event: 'rating';
|
||||||
|
id: string;
|
||||||
|
rating: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientVolume {
|
||||||
|
event: 'volume';
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientEvent = ClientSimpleEvent | ClientFavorite | ClientRating | ClientVolume;
|
@ -1,7 +1,7 @@
|
|||||||
import { initClient, initContract } from '@ts-rest/core';
|
import { initClient, initContract } from '@ts-rest/core';
|
||||||
import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from 'axios';
|
import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from 'axios';
|
||||||
import isElectron from 'is-electron';
|
import isElectron from 'is-electron';
|
||||||
import { debounce } from 'lodash';
|
import debounce from 'lodash/debounce';
|
||||||
import omitBy from 'lodash/omitBy';
|
import omitBy from 'lodash/omitBy';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
import { ndType } from './navidrome-types';
|
import { ndType } from './navidrome-types';
|
||||||
|
@ -5,12 +5,16 @@ import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
|
|||||||
import { MantineProvider } from '@mantine/core';
|
import { MantineProvider } from '@mantine/core';
|
||||||
import { ModalsProvider } from '@mantine/modals';
|
import { ModalsProvider } from '@mantine/modals';
|
||||||
import { initSimpleImg } from 'react-simple-img';
|
import { initSimpleImg } from 'react-simple-img';
|
||||||
import { BaseContextModal } from './components';
|
import { BaseContextModal, toast } from './components';
|
||||||
import { useTheme } from './hooks';
|
import { useTheme } from './hooks';
|
||||||
import { AppRouter } from './router/app-router';
|
import { AppRouter } from './router/app-router';
|
||||||
import { useHotkeySettings, usePlaybackSettings, useSettingsStore } from './store/settings.store';
|
import {
|
||||||
|
useHotkeySettings,
|
||||||
|
usePlaybackSettings,
|
||||||
|
useRemoteSettings,
|
||||||
|
useSettingsStore,
|
||||||
|
} from './store/settings.store';
|
||||||
import './styles/global.scss';
|
import './styles/global.scss';
|
||||||
import '@ag-grid-community/styles/ag-grid.css';
|
|
||||||
import { ContextMenuProvider } from '/@/renderer/features/context-menu';
|
import { ContextMenuProvider } from '/@/renderer/features/context-menu';
|
||||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||||
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
||||||
@ -27,6 +31,7 @@ initSimpleImg({ threshold: 0.05 }, true);
|
|||||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||||
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
|
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
|
||||||
const ipc = isElectron() ? window.electron.ipc : null;
|
const ipc = isElectron() ? window.electron.ipc : null;
|
||||||
|
const remote = isElectron() ? window.electron.remote : null;
|
||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@ -35,6 +40,7 @@ export const App = () => {
|
|||||||
const { bindings } = useHotkeySettings();
|
const { bindings } = useHotkeySettings();
|
||||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||||
const { clearQueue, restoreQueue } = useQueueControls();
|
const { clearQueue, restoreQueue } = useQueueControls();
|
||||||
|
const remoteSettings = useRemoteSettings();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@ -80,9 +86,9 @@ export const App = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
mpvPlayer.restoreQueue();
|
mpvPlayer!.restoreQueue();
|
||||||
|
|
||||||
mpvPlayerListener.rendererSaveQueue(() => {
|
mpvPlayerListener!.rendererSaveQueue(() => {
|
||||||
const { current, queue } = usePlayerStore.getState();
|
const { current, queue } = usePlayerStore.getState();
|
||||||
const stateToSave: Partial<Pick<PlayerState, 'current' | 'queue'>> = {
|
const stateToSave: Partial<Pick<PlayerState, 'current' | 'queue'>> = {
|
||||||
current: {
|
current: {
|
||||||
@ -91,13 +97,13 @@ export const App = () => {
|
|||||||
},
|
},
|
||||||
queue,
|
queue,
|
||||||
};
|
};
|
||||||
mpvPlayer.saveQueue(stateToSave);
|
mpvPlayer!.saveQueue(stateToSave);
|
||||||
});
|
});
|
||||||
|
|
||||||
mpvPlayerListener.rendererRestoreQueue((_event: any, data: Partial<PlayerState>) => {
|
mpvPlayerListener!.rendererRestoreQueue((_event: any, data) => {
|
||||||
const playerData = restoreQueue(data);
|
const playerData = restoreQueue(data);
|
||||||
if (playbackType === PlaybackType.LOCAL) {
|
if (playbackType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer.setQueue(playerData, true);
|
mpvPlayer!.setQueue(playerData, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -108,6 +114,23 @@ export const App = () => {
|
|||||||
};
|
};
|
||||||
}, [playbackType, restoreQueue]);
|
}, [playbackType, restoreQueue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (remote) {
|
||||||
|
remote
|
||||||
|
?.updateSetting(
|
||||||
|
remoteSettings.enabled,
|
||||||
|
remoteSettings.port,
|
||||||
|
remoteSettings.username,
|
||||||
|
remoteSettings.password,
|
||||||
|
)
|
||||||
|
.catch((error) => {
|
||||||
|
toast.warn({ message: error, title: 'Failed to enable remote' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// We only want to fire this once
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
withGlobalStyles
|
withGlobalStyles
|
||||||
|
@ -7,12 +7,12 @@ const localSettings = isElectron() ? window.electron.localSettings : null;
|
|||||||
export const MpvRequired = () => {
|
export const MpvRequired = () => {
|
||||||
const [mpvPath, setMpvPath] = useState('');
|
const [mpvPath, setMpvPath] = useState('');
|
||||||
const handleSetMpvPath = (e: File) => {
|
const handleSetMpvPath = (e: File) => {
|
||||||
localSettings.set('mpv_path', e.path);
|
localSettings?.set('mpv_path', e.path);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getMpvPath = async () => {
|
const getMpvPath = async () => {
|
||||||
if (!isElectron()) return setMpvPath('');
|
if (!localSettings) return setMpvPath('');
|
||||||
const mpvPath = localSettings.get('mpv_path') as string;
|
const mpvPath = localSettings.get('mpv_path') as string;
|
||||||
return setMpvPath(mpvPath);
|
return setMpvPath(mpvPath);
|
||||||
};
|
};
|
||||||
@ -37,7 +37,7 @@ export const MpvRequired = () => {
|
|||||||
placeholder={mpvPath}
|
placeholder={mpvPath}
|
||||||
onChange={handleSetMpvPath}
|
onChange={handleSetMpvPath}
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => localSettings.restart()}>Restart</Button>
|
<Button onClick={() => localSettings?.restart()}>Restart</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -22,7 +22,7 @@ const ActionRequiredRoute = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getMpvPath = async () => {
|
const getMpvPath = async () => {
|
||||||
if (!isElectron()) return setIsMpvRequired(false);
|
if (!localSettings) return setIsMpvRequired(false);
|
||||||
const mpvPath = await localSettings.get('mpv_path');
|
const mpvPath = await localSettings.get('mpv_path');
|
||||||
|
|
||||||
if (mpvPath) {
|
if (mpvPath) {
|
||||||
|
@ -80,6 +80,7 @@ const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating'];
|
|||||||
// const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
|
// const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
|
||||||
|
|
||||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||||
|
const remote = isElectron() ? window.electron.remote : null;
|
||||||
|
|
||||||
export interface ContextMenuProviderProps {
|
export interface ContextMenuProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -555,7 +556,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
const playerData = moveToBottomOfQueue(uniqueIds);
|
const playerData = moveToBottomOfQueue(uniqueIds);
|
||||||
|
|
||||||
if (playerType === PlaybackType.LOCAL) {
|
if (playerType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer.setQueueNext(playerData);
|
mpvPlayer!.setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
}, [ctx.dataNodes, moveToBottomOfQueue, playerType]);
|
}, [ctx.dataNodes, moveToBottomOfQueue, playerType]);
|
||||||
|
|
||||||
@ -566,7 +567,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
const playerData = moveToTopOfQueue(uniqueIds);
|
const playerData = moveToTopOfQueue(uniqueIds);
|
||||||
|
|
||||||
if (playerType === PlaybackType.LOCAL) {
|
if (playerType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer.setQueueNext(playerData);
|
mpvPlayer!.setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
}, [ctx.dataNodes, moveToTopOfQueue, playerType]);
|
}, [ctx.dataNodes, moveToTopOfQueue, playerType]);
|
||||||
|
|
||||||
@ -580,11 +581,15 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
|
|
||||||
if (playerType === PlaybackType.LOCAL) {
|
if (playerType === PlaybackType.LOCAL) {
|
||||||
if (isCurrentSongRemoved) {
|
if (isCurrentSongRemoved) {
|
||||||
mpvPlayer.setQueue(playerData);
|
mpvPlayer!.setQueue(playerData);
|
||||||
} else {
|
} else {
|
||||||
mpvPlayer.setQueueNext(playerData);
|
mpvPlayer!.setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCurrentSongRemoved) {
|
||||||
|
remote?.updateSong({ song: playerData.current.song });
|
||||||
|
}
|
||||||
}, [ctx.dataNodes, playerType, removeFromQueue]);
|
}, [ctx.dataNodes, playerType, removeFromQueue]);
|
||||||
|
|
||||||
const handleDeselectAll = useCallback(() => {
|
const handleDeselectAll = useCallback(() => {
|
||||||
|
@ -152,8 +152,8 @@ export const useSongLyricsByRemoteId = (
|
|||||||
enabled: !!query.remoteSongId && !!query.remoteSource,
|
enabled: !!query.remoteSongId && !!query.remoteSource,
|
||||||
onError: () => {},
|
onError: () => {},
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const remoteLyricsResult: string | null = await lyricsIpc?.getRemoteLyricsByRemoteId(
|
const remoteLyricsResult = await lyricsIpc?.getRemoteLyricsByRemoteId(
|
||||||
query,
|
query as LyricGetQuery,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (remoteLyricsResult) {
|
if (remoteLyricsResult) {
|
||||||
|
@ -16,7 +16,12 @@ export const useLyricSearch = (args: Omit<QueryHookArgs<LyricSearchQuery>, 'serv
|
|||||||
return useQuery<Record<LyricSource, InternetProviderLyricSearchResponse[]>>({
|
return useQuery<Record<LyricSource, InternetProviderLyricSearchResponse[]>>({
|
||||||
cacheTime: 1000 * 60 * 1,
|
cacheTime: 1000 * 60 * 1,
|
||||||
enabled: !!query.artist || !!query.name,
|
enabled: !!query.artist || !!query.name,
|
||||||
queryFn: () => lyricsIpc?.searchRemoteLyrics(query),
|
queryFn: () => {
|
||||||
|
if (lyricsIpc) {
|
||||||
|
return lyricsIpc.searchRemoteLyrics(query);
|
||||||
|
}
|
||||||
|
return {} as Record<LyricSource, InternetProviderLyricSearchResponse[]>;
|
||||||
|
},
|
||||||
queryKey: queryKeys.songs.lyricsSearch(query),
|
queryKey: queryKeys.songs.lyricsSearch(query),
|
||||||
staleTime: 1000 * 60 * 1,
|
staleTime: 1000 * 60 * 1,
|
||||||
...options,
|
...options,
|
||||||
|
@ -19,6 +19,7 @@ import { usePlayerStore, useSetCurrentTime } from '../../../store/player.store';
|
|||||||
import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
|
import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
|
||||||
|
|
||||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||||
|
const remote = isElectron() ? window.electron.remote : null;
|
||||||
|
|
||||||
interface PlayQueueListOptionsProps {
|
interface PlayQueueListOptionsProps {
|
||||||
tableRef: MutableRefObject<{ grid: AgGridReactType<Song> } | null>;
|
tableRef: MutableRefObject<{ grid: AgGridReactType<Song> } | null>;
|
||||||
@ -42,7 +43,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
|||||||
const playerData = moveToBottomOfQueue(uniqueIds);
|
const playerData = moveToBottomOfQueue(uniqueIds);
|
||||||
|
|
||||||
if (playerType === PlaybackType.LOCAL) {
|
if (playerType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer.setQueueNext(playerData);
|
mpvPlayer!.setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -54,7 +55,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
|||||||
const playerData = moveToTopOfQueue(uniqueIds);
|
const playerData = moveToTopOfQueue(uniqueIds);
|
||||||
|
|
||||||
if (playerType === PlaybackType.LOCAL) {
|
if (playerType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer.setQueueNext(playerData);
|
mpvPlayer!.setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -69,21 +70,27 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
|||||||
|
|
||||||
if (playerType === PlaybackType.LOCAL) {
|
if (playerType === PlaybackType.LOCAL) {
|
||||||
if (isCurrentSongRemoved) {
|
if (isCurrentSongRemoved) {
|
||||||
mpvPlayer.setQueue(playerData);
|
mpvPlayer!.setQueue(playerData);
|
||||||
} else {
|
} else {
|
||||||
mpvPlayer.setQueueNext(playerData);
|
mpvPlayer!.setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCurrentSongRemoved) {
|
||||||
|
remote?.updateSong({ song: playerData.current.song });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearQueue = () => {
|
const handleClearQueue = () => {
|
||||||
const playerData = clearQueue();
|
const playerData = clearQueue();
|
||||||
|
|
||||||
if (playerType === PlaybackType.LOCAL) {
|
if (playerType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer.setQueue(playerData);
|
mpvPlayer!.setQueue(playerData);
|
||||||
mpvPlayer.pause();
|
mpvPlayer!.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remote?.updateSong({ song: undefined });
|
||||||
|
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
pause();
|
pause();
|
||||||
};
|
};
|
||||||
@ -92,7 +99,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
|||||||
const playerData = shuffleQueue();
|
const playerData = shuffleQueue();
|
||||||
|
|
||||||
if (playerType === PlaybackType.LOCAL) {
|
if (playerType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer.setQueueNext(playerData);
|
mpvPlayer!.setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -28,15 +28,14 @@ import debounce from 'lodash/debounce';
|
|||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
|
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
|
||||||
import { ErrorFallback } from '/@/renderer/features/action-required';
|
import { ErrorFallback } from '/@/renderer/features/action-required';
|
||||||
import { PlaybackType, TableType } from '/@/renderer/types';
|
import { PlaybackType, PlayerStatus, TableType } from '/@/renderer/types';
|
||||||
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
|
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
|
||||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||||
import { QUEUE_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
import { QUEUE_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||||
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
|
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
|
||||||
|
|
||||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||||
const utils = isElectron() ? window.electron.utils : null;
|
const remote = isElectron() ? window.electron.remote : null;
|
||||||
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
|
|
||||||
|
|
||||||
type QueueProps = {
|
type QueueProps = {
|
||||||
type: TableType;
|
type: TableType;
|
||||||
@ -72,15 +71,15 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
|||||||
|
|
||||||
const handleDoubleClick = (e: CellDoubleClickedEvent) => {
|
const handleDoubleClick = (e: CellDoubleClickedEvent) => {
|
||||||
const playerData = setCurrentTrack(e.data.uniqueId);
|
const playerData = setCurrentTrack(e.data.uniqueId);
|
||||||
mpris?.updateSong({
|
remote?.updateSong({
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
song: playerData.current.song,
|
song: playerData.current.song,
|
||||||
status: 'Playing',
|
status: PlayerStatus.PLAYING,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (playerType === PlaybackType.LOCAL) {
|
if (playerType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer.setQueue(playerData);
|
mpvPlayer!.setQueue(playerData);
|
||||||
mpvPlayer.play();
|
mpvPlayer!.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
play();
|
play();
|
||||||
@ -102,7 +101,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
|||||||
const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId);
|
const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId);
|
||||||
|
|
||||||
if (playerType === PlaybackType.LOCAL) {
|
if (playerType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer.setQueueNext(playerData);
|
mpvPlayer!.setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'sideDrawerQueue') {
|
if (type === 'sideDrawerQueue') {
|
||||||
|
@ -59,8 +59,7 @@ const CenterGridItem = styled.div`
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const utils = isElectron() ? window.electron.utils : null;
|
const remote = isElectron() ? window.electron.remote : null;
|
||||||
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
|
|
||||||
|
|
||||||
export const Playerbar = () => {
|
export const Playerbar = () => {
|
||||||
const playersRef = PlayersRef;
|
const playersRef = PlayersRef;
|
||||||
@ -74,11 +73,13 @@ export const Playerbar = () => {
|
|||||||
const { autoNext } = usePlayerControls();
|
const { autoNext } = usePlayerControls();
|
||||||
|
|
||||||
const autoNextFn = useCallback(() => {
|
const autoNextFn = useCallback(() => {
|
||||||
const playerData = autoNext();
|
if (remote) {
|
||||||
mpris?.updateSong({
|
const playerData = autoNext();
|
||||||
currentTime: 0,
|
remote.updateSong({
|
||||||
song: playerData.current.song,
|
currentTime: 0,
|
||||||
});
|
song: playerData.current.song,
|
||||||
|
});
|
||||||
|
}
|
||||||
}, [autoNext]);
|
}, [autoNext]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { MouseEvent } from 'react';
|
import { MouseEvent, useEffect } from 'react';
|
||||||
import { Flex, Group } from '@mantine/core';
|
import { Flex, Group } from '@mantine/core';
|
||||||
import { useHotkeys, useMediaQuery } from '@mantine/hooks';
|
import { useHotkeys, useMediaQuery } from '@mantine/hooks';
|
||||||
|
import isElectron from 'is-electron';
|
||||||
import { HiOutlineQueueList } from 'react-icons/hi2';
|
import { HiOutlineQueueList } from 'react-icons/hi2';
|
||||||
import {
|
import {
|
||||||
RiVolumeUpFill,
|
RiVolumeUpFill,
|
||||||
@ -20,11 +21,14 @@ import {
|
|||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
import { useRightControls } from '../hooks/use-right-controls';
|
import { useRightControls } from '../hooks/use-right-controls';
|
||||||
import { PlayerButton } from './player-button';
|
import { PlayerButton } from './player-button';
|
||||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
import { LibraryItem, ServerType, Song } from '/@/renderer/api/types';
|
||||||
import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared';
|
import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared';
|
||||||
import { Rating } from '/@/renderer/components';
|
import { Rating } from '/@/renderer/components';
|
||||||
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
|
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
|
||||||
|
|
||||||
|
const ipc = isElectron() ? window.electron.ipc : null;
|
||||||
|
const remote = isElectron() ? window.electron.remote : null;
|
||||||
|
|
||||||
export const RightControls = () => {
|
export const RightControls = () => {
|
||||||
const isMinWidth = useMediaQuery('(max-width: 480px)');
|
const isMinWidth = useMediaQuery('(max-width: 480px)');
|
||||||
const volume = useVolume();
|
const volume = useVolume();
|
||||||
@ -113,6 +117,44 @@ export const RightControls = () => {
|
|||||||
[bindings.toggleQueue.isGlobal ? '' : bindings.toggleQueue.hotkey, handleToggleQueue],
|
[bindings.toggleQueue.isGlobal ? '' : bindings.toggleQueue.hotkey, handleToggleQueue],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (remote) {
|
||||||
|
remote.requestFavorite((_event, { favorite, id, serverId }) => {
|
||||||
|
const mutator = favorite ? addToFavoritesMutation : removeFromFavoritesMutation;
|
||||||
|
mutator.mutate({
|
||||||
|
query: {
|
||||||
|
id: [id],
|
||||||
|
type: LibraryItem.SONG,
|
||||||
|
},
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
remote.requestRating((_event, { id, rating, serverId }) => {
|
||||||
|
updateRatingMutation.mutate({
|
||||||
|
query: {
|
||||||
|
item: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
itemType: LibraryItem.SONG,
|
||||||
|
serverId,
|
||||||
|
} as Song, // This is not a type-safe cast, but it works because those are all the prop
|
||||||
|
],
|
||||||
|
rating,
|
||||||
|
},
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ipc?.removeAllListeners('request-favorite');
|
||||||
|
ipc?.removeAllListeners('request-rating');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {};
|
||||||
|
}, [addToFavoritesMutation, removeFromFavoritesMutation, updateRatingMutation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
align="flex-end"
|
align="flex-end"
|
||||||
|
@ -24,6 +24,7 @@ const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : nul
|
|||||||
const ipc = isElectron() ? window.electron.ipc : null;
|
const ipc = isElectron() ? window.electron.ipc : null;
|
||||||
const utils = isElectron() ? window.electron.utils : null;
|
const utils = isElectron() ? window.electron.utils : null;
|
||||||
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
|
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
|
||||||
|
const remote = isElectron() ? window.electron.remote : null;
|
||||||
const mediaSession = !isElectron() || !utils?.isLinux() ? navigator.mediaSession : null;
|
const mediaSession = !isElectron() || !utils?.isLinux() ? navigator.mediaSession : null;
|
||||||
|
|
||||||
export const useCenterControls = (args: { playersRef: any }) => {
|
export const useCenterControls = (args: { playersRef: any }) => {
|
||||||
@ -87,12 +88,12 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
const playStatus = status || usePlayerStore.getState().current.status;
|
const playStatus = status || usePlayerStore.getState().current.status;
|
||||||
const track = song || usePlayerStore.getState().current.song;
|
const track = song || usePlayerStore.getState().current.song;
|
||||||
|
|
||||||
mpris?.updateSong({
|
remote?.updateSong({
|
||||||
currentTime: time,
|
currentTime: time,
|
||||||
repeat: usePlayerStore.getState().repeat,
|
repeat: usePlayerStore.getState().repeat,
|
||||||
shuffle: usePlayerStore.getState().shuffle,
|
shuffle: usePlayerStore.getState().shuffle !== PlayerShuffle.NONE,
|
||||||
song: track,
|
song: track,
|
||||||
status: playStatus === PlayerStatus.PLAYING ? 'Playing' : 'Paused',
|
status: playStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mediaSession) {
|
if (mediaSession) {
|
||||||
@ -133,7 +134,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
|
|
||||||
if (isMpvPlayer) {
|
if (isMpvPlayer) {
|
||||||
mpvPlayer?.volume(usePlayerStore.getState().volume);
|
mpvPlayer?.volume(usePlayerStore.getState().volume);
|
||||||
mpvPlayer.play();
|
mpvPlayer!.play();
|
||||||
} else {
|
} else {
|
||||||
currentPlayerRef.getInternalPlayer().play();
|
currentPlayerRef.getInternalPlayer().play();
|
||||||
}
|
}
|
||||||
@ -145,7 +146,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
mprisUpdateSong({ status: PlayerStatus.PAUSED });
|
mprisUpdateSong({ status: PlayerStatus.PAUSED });
|
||||||
|
|
||||||
if (isMpvPlayer) {
|
if (isMpvPlayer) {
|
||||||
mpvPlayer.pause();
|
mpvPlayer!.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
pause();
|
pause();
|
||||||
@ -155,8 +156,8 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
mprisUpdateSong({ status: PlayerStatus.PAUSED });
|
mprisUpdateSong({ status: PlayerStatus.PAUSED });
|
||||||
|
|
||||||
if (isMpvPlayer) {
|
if (isMpvPlayer) {
|
||||||
mpvPlayer.pause();
|
mpvPlayer!.pause();
|
||||||
mpvPlayer.seekTo(0);
|
mpvPlayer!.seekTo(0);
|
||||||
} else {
|
} else {
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
}
|
}
|
||||||
@ -168,29 +169,29 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
const handleToggleShuffle = useCallback(() => {
|
const handleToggleShuffle = useCallback(() => {
|
||||||
if (shuffleStatus === PlayerShuffle.NONE) {
|
if (shuffleStatus === PlayerShuffle.NONE) {
|
||||||
const playerData = setShuffle(PlayerShuffle.TRACK);
|
const playerData = setShuffle(PlayerShuffle.TRACK);
|
||||||
mpris?.updateShuffle(true);
|
remote?.updateShuffle(true);
|
||||||
return mpvPlayer.setQueueNext(playerData);
|
return mpvPlayer?.setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerData = setShuffle(PlayerShuffle.NONE);
|
const playerData = setShuffle(PlayerShuffle.NONE);
|
||||||
mpris?.updateShuffle(false);
|
remote?.updateShuffle(false);
|
||||||
return mpvPlayer.setQueueNext(playerData);
|
return mpvPlayer?.setQueueNext(playerData);
|
||||||
}, [setShuffle, shuffleStatus]);
|
}, [setShuffle, shuffleStatus]);
|
||||||
|
|
||||||
const handleToggleRepeat = useCallback(() => {
|
const handleToggleRepeat = useCallback(() => {
|
||||||
if (repeatStatus === PlayerRepeat.NONE) {
|
if (repeatStatus === PlayerRepeat.NONE) {
|
||||||
const playerData = setRepeat(PlayerRepeat.ALL);
|
const playerData = setRepeat(PlayerRepeat.ALL);
|
||||||
mpris?.updateRepeat('Playlist');
|
remote?.updateRepeat(PlayerRepeat.ALL);
|
||||||
return mpvPlayer.setQueueNext(playerData);
|
return mpvPlayer?.setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (repeatStatus === PlayerRepeat.ALL) {
|
if (repeatStatus === PlayerRepeat.ALL) {
|
||||||
const playerData = setRepeat(PlayerRepeat.ONE);
|
const playerData = setRepeat(PlayerRepeat.ONE);
|
||||||
mpris?.updateRepeat('Track');
|
remote?.updateRepeat(PlayerRepeat.ONE);
|
||||||
return mpvPlayer.setQueueNext(playerData);
|
return mpvPlayer?.setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
|
|
||||||
mpris?.updateRepeat('None');
|
remote?.updateRepeat(PlayerRepeat.NONE);
|
||||||
return setRepeat(PlayerRepeat.NONE);
|
return setRepeat(PlayerRepeat.NONE);
|
||||||
}, [repeatStatus, setRepeat]);
|
}, [repeatStatus, setRepeat]);
|
||||||
|
|
||||||
@ -209,7 +210,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
local: () => {
|
local: () => {
|
||||||
const playerData = autoNext();
|
const playerData = autoNext();
|
||||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
|
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
|
||||||
mpvPlayer.autoNext(playerData);
|
mpvPlayer!.autoNext(playerData);
|
||||||
play();
|
play();
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
@ -223,7 +224,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
if (isLastTrack) {
|
if (isLastTrack) {
|
||||||
const playerData = setCurrentIndex(0);
|
const playerData = setCurrentIndex(0);
|
||||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PAUSED });
|
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PAUSED });
|
||||||
mpvPlayer.setQueue(playerData, true);
|
mpvPlayer!.setQueue(playerData, true);
|
||||||
pause();
|
pause();
|
||||||
} else {
|
} else {
|
||||||
const playerData = autoNext();
|
const playerData = autoNext();
|
||||||
@ -231,7 +232,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
song: playerData.current.song,
|
song: playerData.current.song,
|
||||||
status: PlayerStatus.PLAYING,
|
status: PlayerStatus.PLAYING,
|
||||||
});
|
});
|
||||||
mpvPlayer.autoNext(playerData);
|
mpvPlayer!.autoNext(playerData);
|
||||||
play();
|
play();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -255,7 +256,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
local: () => {
|
local: () => {
|
||||||
const playerData = autoNext();
|
const playerData = autoNext();
|
||||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
|
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
|
||||||
mpvPlayer.autoNext(playerData);
|
mpvPlayer!.autoNext(playerData);
|
||||||
play();
|
play();
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
@ -306,8 +307,8 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
local: () => {
|
local: () => {
|
||||||
const playerData = next();
|
const playerData = next();
|
||||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
|
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
|
||||||
mpvPlayer.setQueue(playerData);
|
mpvPlayer!.setQueue(playerData);
|
||||||
mpvPlayer.next();
|
mpvPlayer!.next();
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
const playerData = next();
|
const playerData = next();
|
||||||
@ -320,8 +321,8 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
if (isLastTrack) {
|
if (isLastTrack) {
|
||||||
const playerData = setCurrentIndex(0);
|
const playerData = setCurrentIndex(0);
|
||||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PAUSED });
|
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PAUSED });
|
||||||
mpvPlayer.setQueue(playerData);
|
mpvPlayer!.setQueue(playerData);
|
||||||
mpvPlayer.pause();
|
mpvPlayer!.pause();
|
||||||
pause();
|
pause();
|
||||||
} else {
|
} else {
|
||||||
const playerData = next();
|
const playerData = next();
|
||||||
@ -329,8 +330,8 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
song: playerData.current.song,
|
song: playerData.current.song,
|
||||||
status: PlayerStatus.PLAYING,
|
status: PlayerStatus.PLAYING,
|
||||||
});
|
});
|
||||||
mpvPlayer.setQueue(playerData);
|
mpvPlayer!.setQueue(playerData);
|
||||||
mpvPlayer.next();
|
mpvPlayer!.next();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
@ -357,8 +358,8 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
local: () => {
|
local: () => {
|
||||||
const playerData = next();
|
const playerData = next();
|
||||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
|
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
|
||||||
mpvPlayer.setQueue(playerData);
|
mpvPlayer!.setQueue(playerData);
|
||||||
mpvPlayer.next();
|
mpvPlayer!.next();
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
if (!isLastTrack) {
|
if (!isLastTrack) {
|
||||||
@ -409,7 +410,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
handleScrobbleFromSongRestart(currentTime);
|
handleScrobbleFromSongRestart(currentTime);
|
||||||
mpris?.updateSeek(0);
|
mpris?.updateSeek(0);
|
||||||
if (isMpvPlayer) {
|
if (isMpvPlayer) {
|
||||||
return mpvPlayer.seekTo(0);
|
return mpvPlayer!.seekTo(0);
|
||||||
}
|
}
|
||||||
return currentPlayerRef.seekTo(0);
|
return currentPlayerRef.seekTo(0);
|
||||||
}
|
}
|
||||||
@ -424,16 +425,16 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
song: playerData.current.song,
|
song: playerData.current.song,
|
||||||
status: PlayerStatus.PLAYING,
|
status: PlayerStatus.PLAYING,
|
||||||
});
|
});
|
||||||
mpvPlayer.setQueue(playerData);
|
mpvPlayer!.setQueue(playerData);
|
||||||
mpvPlayer.previous();
|
mpvPlayer!.previous();
|
||||||
} else {
|
} else {
|
||||||
const playerData = setCurrentIndex(queue.length - 1);
|
const playerData = setCurrentIndex(queue.length - 1);
|
||||||
mprisUpdateSong({
|
mprisUpdateSong({
|
||||||
song: playerData.current.song,
|
song: playerData.current.song,
|
||||||
status: PlayerStatus.PLAYING,
|
status: PlayerStatus.PLAYING,
|
||||||
});
|
});
|
||||||
mpvPlayer.setQueue(playerData);
|
mpvPlayer!.setQueue(playerData);
|
||||||
mpvPlayer.previous();
|
mpvPlayer!.previous();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
@ -458,12 +459,12 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
const handleRepeatNone = {
|
const handleRepeatNone = {
|
||||||
local: () => {
|
local: () => {
|
||||||
const playerData = previous();
|
const playerData = previous();
|
||||||
mpris?.updateSong({
|
remote?.updateSong({
|
||||||
currentTime: usePlayerStore.getState().current.time,
|
currentTime: usePlayerStore.getState().current.time,
|
||||||
song: playerData.current.song,
|
song: playerData.current.song,
|
||||||
});
|
});
|
||||||
mpvPlayer.setQueue(playerData);
|
mpvPlayer!.setQueue(playerData);
|
||||||
mpvPlayer.previous();
|
mpvPlayer!.previous();
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
if (isFirstTrack) {
|
if (isFirstTrack) {
|
||||||
@ -489,10 +490,10 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
song: playerData.current.song,
|
song: playerData.current.song,
|
||||||
status: PlayerStatus.PLAYING,
|
status: PlayerStatus.PLAYING,
|
||||||
});
|
});
|
||||||
mpvPlayer.setQueue(playerData);
|
mpvPlayer!.setQueue(playerData);
|
||||||
mpvPlayer.previous();
|
mpvPlayer!.previous();
|
||||||
} else {
|
} else {
|
||||||
mpvPlayer.stop();
|
mpvPlayer!.stop();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
@ -556,7 +557,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
mpris?.updateSeek(newTime);
|
mpris?.updateSeek(newTime);
|
||||||
|
|
||||||
if (isMpvPlayer) {
|
if (isMpvPlayer) {
|
||||||
mpvPlayer.seek(-seconds);
|
mpvPlayer!.seek(-seconds);
|
||||||
} else {
|
} else {
|
||||||
resetNextPlayer();
|
resetNextPlayer();
|
||||||
currentPlayerRef.seekTo(newTime);
|
currentPlayerRef.seekTo(newTime);
|
||||||
@ -570,7 +571,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
|
|
||||||
if (isMpvPlayer) {
|
if (isMpvPlayer) {
|
||||||
const newTime = currentTime + seconds;
|
const newTime = currentTime + seconds;
|
||||||
mpvPlayer.seek(seconds);
|
mpvPlayer!.seek(seconds);
|
||||||
mpris?.updateSeek(newTime);
|
mpris?.updateSeek(newTime);
|
||||||
setCurrentTime(newTime, true);
|
setCurrentTime(newTime, true);
|
||||||
} else {
|
} else {
|
||||||
@ -588,7 +589,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
|
|
||||||
const debouncedSeek = debounce((e: number) => {
|
const debouncedSeek = debounce((e: number) => {
|
||||||
if (isMpvPlayer) {
|
if (isMpvPlayer) {
|
||||||
mpvPlayer.seekTo(e);
|
mpvPlayer!.seekTo(e);
|
||||||
} else {
|
} else {
|
||||||
currentPlayerRef.seekTo(e);
|
currentPlayerRef.seekTo(e);
|
||||||
}
|
}
|
||||||
@ -606,20 +607,20 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleQuit = useCallback(() => {
|
const handleQuit = useCallback(() => {
|
||||||
mpvPlayer.quit();
|
mpvPlayer!.quit();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleError = useCallback(
|
const handleError = useCallback(
|
||||||
(message: string) => {
|
(message: string) => {
|
||||||
toast.error({ id: 'mpv-error', message, title: 'An error occurred during playback' });
|
toast.error({ id: 'mpv-error', message, title: 'An error occurred during playback' });
|
||||||
pause();
|
pause();
|
||||||
mpvPlayer.pause();
|
mpvPlayer!.pause();
|
||||||
},
|
},
|
||||||
[pause],
|
[pause],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isElectron()) {
|
if (mpvPlayerListener) {
|
||||||
mpvPlayerListener.rendererPlayPause(() => {
|
mpvPlayerListener.rendererPlayPause(() => {
|
||||||
handlePlayPause();
|
handlePlayPause();
|
||||||
});
|
});
|
||||||
@ -769,12 +770,39 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (utils?.isLinux()) {
|
if (utils?.isLinux()) {
|
||||||
mpris.requestPosition((_e: any, data: { position: number }) => {
|
mpris!.requestToggleRepeat((_e: any, data: { repeat: string }) => {
|
||||||
|
if (data.repeat === 'Playlist') {
|
||||||
|
usePlayerStore.getState().actions.setRepeat(PlayerRepeat.ALL);
|
||||||
|
} else if (data.repeat === 'Track') {
|
||||||
|
usePlayerStore.getState().actions.setRepeat(PlayerRepeat.ONE);
|
||||||
|
} else {
|
||||||
|
usePlayerStore.getState().actions.setRepeat(PlayerRepeat.NONE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mpris!.requestToggleShuffle((_e: any, data: { shuffle: boolean }) => {
|
||||||
|
usePlayerStore
|
||||||
|
.getState()
|
||||||
|
.actions.setShuffle(data.shuffle ? PlayerShuffle.TRACK : PlayerShuffle.NONE);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ipc?.removeAllListeners('mpris-request-toggle-repeat');
|
||||||
|
ipc?.removeAllListeners('mpris-request-toggle-shuffle');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {};
|
||||||
|
}, [handleSeekSlider, isMpvPlayer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (remote) {
|
||||||
|
remote.requestPosition((_e: any, data: { position: number }) => {
|
||||||
const newTime = data.position;
|
const newTime = data.position;
|
||||||
handleSeekSlider(newTime);
|
handleSeekSlider(newTime);
|
||||||
});
|
});
|
||||||
|
|
||||||
mpris.requestSeek((_e: any, data: { offset: number }) => {
|
remote.requestSeek((_e: any, data: { offset: number }) => {
|
||||||
const currentTime = usePlayerStore.getState().current.time;
|
const currentTime = usePlayerStore.getState().current.time;
|
||||||
const currentSongDuration = usePlayerStore.getState().current.song?.duration || 0;
|
const currentSongDuration = usePlayerStore.getState().current.song?.duration || 0;
|
||||||
const resultingTime = currentTime + data.offset;
|
const resultingTime = currentTime + data.offset;
|
||||||
@ -791,50 +819,23 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
handleSeekSlider(newTime);
|
handleSeekSlider(newTime);
|
||||||
});
|
});
|
||||||
|
|
||||||
mpris.requestVolume((_e: any, data: { volume: number }) => {
|
remote.requestVolume((_e: any, data: { volume: number }) => {
|
||||||
let newVolume = Math.round(data.volume * 100);
|
usePlayerStore.getState().actions.setVolume(data.volume);
|
||||||
|
|
||||||
if (newVolume > 100) {
|
|
||||||
newVolume = 100;
|
|
||||||
} else if (newVolume < 0) {
|
|
||||||
newVolume = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
usePlayerStore.getState().actions.setVolume(newVolume);
|
|
||||||
mpris.updateVolume(data.volume);
|
|
||||||
|
|
||||||
if (isMpvPlayer) {
|
if (isMpvPlayer) {
|
||||||
mpvPlayer.volume(newVolume);
|
mpvPlayer!.volume(data.volume);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
mpris.requestToggleRepeat((_e: any, data: { repeat: string }) => {
|
|
||||||
if (data.repeat === 'Playlist') {
|
|
||||||
usePlayerStore.getState().actions.setRepeat(PlayerRepeat.ALL);
|
|
||||||
} else if (data.repeat === 'Track') {
|
|
||||||
usePlayerStore.getState().actions.setRepeat(PlayerRepeat.ONE);
|
|
||||||
} else {
|
|
||||||
usePlayerStore.getState().actions.setRepeat(PlayerRepeat.NONE);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
mpris.requestToggleShuffle((_e: any, data: { shuffle: boolean }) => {
|
|
||||||
usePlayerStore
|
|
||||||
.getState()
|
|
||||||
.actions.setShuffle(data.shuffle ? PlayerShuffle.TRACK : PlayerShuffle.NONE);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
ipc?.removeAllListeners('mpris-request-position');
|
ipc?.removeAllListeners('request-position');
|
||||||
ipc?.removeAllListeners('mpris-request-seek');
|
ipc?.removeAllListeners('request-seek');
|
||||||
ipc?.removeAllListeners('mpris-request-volume');
|
ipc?.removeAllListeners('request-volume');
|
||||||
ipc?.removeAllListeners('mpris-request-toggle-repeat');
|
|
||||||
ipc?.removeAllListeners('mpris-request-toggle-shuffle');
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {};
|
return () => {};
|
||||||
}, [handleSeekSlider, isMpvPlayer]);
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleNextTrack,
|
handleNextTrack,
|
||||||
|
@ -2,7 +2,13 @@ import { useCallback, useRef } from 'react';
|
|||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useCurrentServer, usePlayerControls, usePlayerStore } from '/@/renderer/store';
|
import { useCurrentServer, usePlayerControls, usePlayerStore } from '/@/renderer/store';
|
||||||
import { usePlayerType } from '/@/renderer/store/settings.store';
|
import { usePlayerType } from '/@/renderer/store/settings.store';
|
||||||
import { PlayQueueAddOptions, Play, PlaybackType } from '/@/renderer/types';
|
import {
|
||||||
|
PlayQueueAddOptions,
|
||||||
|
Play,
|
||||||
|
PlaybackType,
|
||||||
|
PlayerStatus,
|
||||||
|
PlayerShuffle,
|
||||||
|
} from '/@/renderer/types';
|
||||||
import { toast } from '/@/renderer/components/toast/index';
|
import { toast } from '/@/renderer/components/toast/index';
|
||||||
import isElectron from 'is-electron';
|
import isElectron from 'is-electron';
|
||||||
import { nanoid } from 'nanoid/non-secure';
|
import { nanoid } from 'nanoid/non-secure';
|
||||||
@ -47,8 +53,7 @@ const getRootQueryKey = (itemType: LibraryItem, serverId: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||||
const utils = isElectron() ? window.electron.utils : null;
|
const remote = isElectron() ? window.electron.remote : null;
|
||||||
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
|
|
||||||
|
|
||||||
const addToQueue = usePlayerStore.getState().actions.addToQueue;
|
const addToQueue = usePlayerStore.getState().actions.addToQueue;
|
||||||
|
|
||||||
@ -154,26 +159,26 @@ export const useHandlePlayQueueAdd = () => {
|
|||||||
const playerData = addToQueue({ initialIndex: initialSongIndex, playType, songs });
|
const playerData = addToQueue({ initialIndex: initialSongIndex, playType, songs });
|
||||||
|
|
||||||
if (playerType === PlaybackType.LOCAL) {
|
if (playerType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer?.volume(usePlayerStore.getState().volume);
|
mpvPlayer!.volume(usePlayerStore.getState().volume);
|
||||||
|
|
||||||
if (playType === Play.NEXT || playType === Play.LAST) {
|
if (playType === Play.NEXT || playType === Play.LAST) {
|
||||||
mpvPlayer?.setQueueNext(playerData);
|
mpvPlayer!.setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playType === Play.NOW) {
|
if (playType === Play.NOW) {
|
||||||
mpvPlayer?.setQueue(playerData);
|
mpvPlayer!.setQueue(playerData);
|
||||||
mpvPlayer?.play();
|
mpvPlayer!.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
play();
|
play();
|
||||||
|
|
||||||
mpris?.updateSong({
|
remote?.updateSong({
|
||||||
currentTime: usePlayerStore.getState().current.time,
|
currentTime: usePlayerStore.getState().current.time,
|
||||||
repeat: usePlayerStore.getState().repeat,
|
repeat: usePlayerStore.getState().repeat,
|
||||||
shuffle: usePlayerStore.getState().shuffle,
|
shuffle: usePlayerStore.getState().shuffle !== PlayerShuffle.NONE,
|
||||||
song: playerData.current.song,
|
song: playerData.current.song,
|
||||||
status: 'Playing',
|
status: PlayerStatus.PLAYING,
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
@ -6,8 +6,7 @@ import { useGeneralSettings } from '/@/renderer/store/settings.store';
|
|||||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||||
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
|
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
|
||||||
const ipc = isElectron() ? window.electron.ipc : null;
|
const ipc = isElectron() ? window.electron.ipc : null;
|
||||||
const utils = isElectron() ? window.electron.utils : null;
|
const remote = isElectron() ? window.electron.remote : null;
|
||||||
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
|
|
||||||
|
|
||||||
const calculateVolumeUp = (volume: number, volumeWheelStep: number) => {
|
const calculateVolumeUp = (volume: number, volumeWheelStep: number) => {
|
||||||
let volumeToSet;
|
let volumeToSet;
|
||||||
@ -41,12 +40,13 @@ export const useRightControls = () => {
|
|||||||
|
|
||||||
// Ensure that the mpv player volume is set on startup
|
// Ensure that the mpv player volume is set on startup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isElectron()) {
|
remote?.updateVolume(volume);
|
||||||
|
|
||||||
|
if (mpvPlayer) {
|
||||||
mpvPlayer.volume(volume);
|
mpvPlayer.volume(volume);
|
||||||
mpris?.updateVolume(volume / 100);
|
|
||||||
|
|
||||||
if (muted) {
|
if (muted) {
|
||||||
mpvPlayer.mute();
|
mpvPlayer.mute(muted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,26 +55,26 @@ export const useRightControls = () => {
|
|||||||
|
|
||||||
const handleVolumeSlider = (e: number) => {
|
const handleVolumeSlider = (e: number) => {
|
||||||
mpvPlayer?.volume(e);
|
mpvPlayer?.volume(e);
|
||||||
mpris?.updateVolume(e / 100);
|
remote?.updateVolume(e);
|
||||||
setVolume(e);
|
setVolume(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVolumeSliderState = (e: number) => {
|
const handleVolumeSliderState = (e: number) => {
|
||||||
mpris?.updateVolume(e / 100);
|
remote?.updateVolume(e);
|
||||||
setVolume(e);
|
setVolume(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVolumeDown = useCallback(() => {
|
const handleVolumeDown = useCallback(() => {
|
||||||
const volumeToSet = calculateVolumeDown(volume, volumeWheelStep);
|
const volumeToSet = calculateVolumeDown(volume, volumeWheelStep);
|
||||||
mpvPlayer?.volume(volumeToSet);
|
mpvPlayer?.volume(volumeToSet);
|
||||||
mpris?.updateVolume(volumeToSet / 100);
|
remote?.updateVolume(volumeToSet);
|
||||||
setVolume(volumeToSet);
|
setVolume(volumeToSet);
|
||||||
}, [setVolume, volume, volumeWheelStep]);
|
}, [setVolume, volume, volumeWheelStep]);
|
||||||
|
|
||||||
const handleVolumeUp = useCallback(() => {
|
const handleVolumeUp = useCallback(() => {
|
||||||
const volumeToSet = calculateVolumeUp(volume, volumeWheelStep);
|
const volumeToSet = calculateVolumeUp(volume, volumeWheelStep);
|
||||||
mpvPlayer?.volume(volumeToSet);
|
mpvPlayer?.volume(volumeToSet);
|
||||||
mpris?.updateVolume(volumeToSet / 100);
|
remote?.updateVolume(volumeToSet);
|
||||||
setVolume(volumeToSet);
|
setVolume(volumeToSet);
|
||||||
}, [setVolume, volume, volumeWheelStep]);
|
}, [setVolume, volume, volumeWheelStep]);
|
||||||
|
|
||||||
@ -88,7 +88,7 @@ export const useRightControls = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mpvPlayer?.volume(volumeToSet);
|
mpvPlayer?.volume(volumeToSet);
|
||||||
mpris?.updateVolume(volumeToSet / 100);
|
remote?.updateVolume(volumeToSet);
|
||||||
setVolume(volumeToSet);
|
setVolume(volumeToSet);
|
||||||
},
|
},
|
||||||
[setVolume, volume, volumeWheelStep],
|
[setVolume, volume, volumeWheelStep],
|
||||||
@ -96,7 +96,7 @@ export const useRightControls = () => {
|
|||||||
|
|
||||||
const handleMute = useCallback(() => {
|
const handleMute = useCallback(() => {
|
||||||
setMuted(!muted);
|
setMuted(!muted);
|
||||||
mpvPlayer?.mute();
|
mpvPlayer?.mute(!muted);
|
||||||
}, [muted, setMuted]);
|
}, [muted, setMuted]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -16,7 +16,7 @@ const localSettings = isElectron() ? window.electron.localSettings : null;
|
|||||||
interface EditServerFormProps {
|
interface EditServerFormProps {
|
||||||
isUpdate?: boolean;
|
isUpdate?: boolean;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
password?: string;
|
password: string | null;
|
||||||
server: ServerListItem;
|
server: ServerListItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,7 +74,7 @@ export const ApplicationSettings = () => {
|
|||||||
zoomFactor: newVal,
|
zoomFactor: newVal,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
localSettings.setZoomFactor(newVal);
|
localSettings!.setZoomFactor(newVal);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
@ -3,6 +3,7 @@ import { ApplicationSettings } from '/@/renderer/features/settings/components/ge
|
|||||||
import { ControlSettings } from '/@/renderer/features/settings/components/general/control-settings';
|
import { ControlSettings } from '/@/renderer/features/settings/components/general/control-settings';
|
||||||
import { SidebarSettings } from '/@/renderer/features/settings/components/general/sidebar-settings';
|
import { SidebarSettings } from '/@/renderer/features/settings/components/general/sidebar-settings';
|
||||||
import { ThemeSettings } from '/@/renderer/features/settings/components/general/theme-settings';
|
import { ThemeSettings } from '/@/renderer/features/settings/components/general/theme-settings';
|
||||||
|
import { RemoteSettings } from '/@/renderer/features/settings/components/general/remote-settings';
|
||||||
|
|
||||||
export const GeneralTab = () => {
|
export const GeneralTab = () => {
|
||||||
return (
|
return (
|
||||||
@ -14,6 +15,8 @@ export const GeneralTab = () => {
|
|||||||
<ControlSettings />
|
<ControlSettings />
|
||||||
<Divider />
|
<Divider />
|
||||||
<SidebarSettings />
|
<SidebarSettings />
|
||||||
|
<Divider />
|
||||||
|
<RemoteSettings />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,156 @@
|
|||||||
|
import isElectron from 'is-electron';
|
||||||
|
import { SettingsSection } from '/@/renderer/features/settings/components/settings-section';
|
||||||
|
import { useRemoteSettings, useSettingsStoreActions } from '/@/renderer/store';
|
||||||
|
import { NumberInput, Switch, Text, TextInput, toast } from '/@/renderer/components';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
|
||||||
|
const remote = isElectron() ? window.electron.remote : null;
|
||||||
|
|
||||||
|
export const RemoteSettings = () => {
|
||||||
|
const settings = useRemoteSettings();
|
||||||
|
const { setSettings } = useSettingsStoreActions();
|
||||||
|
|
||||||
|
const url = `http://localhost:${settings.port}`;
|
||||||
|
|
||||||
|
const debouncedEnableRemote = debounce(async (enabled: boolean) => {
|
||||||
|
const errorMsg = await remote!.setRemoteEnabled(enabled);
|
||||||
|
|
||||||
|
if (errorMsg === null) {
|
||||||
|
setSettings({
|
||||||
|
remote: {
|
||||||
|
...settings,
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error({
|
||||||
|
message: errorMsg,
|
||||||
|
title: enabled ? 'Error enabling remote' : 'Error disabling remote',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
const debouncedChangeRemotePort = debounce(async (port: number) => {
|
||||||
|
const errorMsg = await remote!.setRemotePort(port);
|
||||||
|
if (errorMsg === null) {
|
||||||
|
setSettings({
|
||||||
|
remote: {
|
||||||
|
...settings,
|
||||||
|
port,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error({
|
||||||
|
message: errorMsg,
|
||||||
|
title: 'Error setting port',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const isHidden = !isElectron();
|
||||||
|
|
||||||
|
const controlOptions = [
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
aria-label="Enable remote control server"
|
||||||
|
defaultChecked={settings.enabled}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const enabled = e.currentTarget.checked;
|
||||||
|
await debouncedEnableRemote(enabled);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
Start an HTTP server to remotely control Feishin. This will listen on{' '}
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
isHidden,
|
||||||
|
title: 'Enable remote control',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<NumberInput
|
||||||
|
aria-label="Set remote port"
|
||||||
|
max={65535}
|
||||||
|
value={settings.port}
|
||||||
|
onBlur={async (e) => {
|
||||||
|
if (!e) return;
|
||||||
|
const port = Number(e.currentTarget.value);
|
||||||
|
await debouncedChangeRemotePort(port);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description:
|
||||||
|
'Remote server port. Changes here only take effect when you enable the remote',
|
||||||
|
isHidden,
|
||||||
|
title: 'Remove server port',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<TextInput
|
||||||
|
aria-label="Set remote username"
|
||||||
|
defaultValue={settings.username}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const username = e.currentTarget.value;
|
||||||
|
if (username === settings.username) return;
|
||||||
|
remote!.updateUsername(username);
|
||||||
|
setSettings({
|
||||||
|
remote: {
|
||||||
|
...settings,
|
||||||
|
username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description:
|
||||||
|
'Username that must be provided to access remote. If both username and password are empty, disable authentication',
|
||||||
|
isHidden,
|
||||||
|
title: 'Remote username',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<TextInput
|
||||||
|
aria-label="Set remote password"
|
||||||
|
defaultValue={settings.password}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const password = e.currentTarget.value;
|
||||||
|
if (password === settings.password) return;
|
||||||
|
remote!.updatePassword(password);
|
||||||
|
setSettings({
|
||||||
|
remote: {
|
||||||
|
...settings,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: 'Password to access remote',
|
||||||
|
isHidden,
|
||||||
|
title: 'Remote password',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSection options={controlOptions} />
|
||||||
|
<Text size="lg">
|
||||||
|
<b>
|
||||||
|
NOTE: these credentials are by default transferred insecurely. Do not use a
|
||||||
|
password you care about. Changing username/password will disconnect clients and
|
||||||
|
require them to reauthenticate
|
||||||
|
</b>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -23,12 +23,12 @@ export const WindowHotkeySettings = () => {
|
|||||||
globalMediaHotkeys: e.currentTarget.checked,
|
globalMediaHotkeys: e.currentTarget.checked,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
localSettings.set('global_media_hotkeys', e.currentTarget.checked);
|
localSettings!.set('global_media_hotkeys', e.currentTarget.checked);
|
||||||
|
|
||||||
if (e.currentTarget.checked) {
|
if (e.currentTarget.checked) {
|
||||||
localSettings.enableMediaKeys();
|
localSettings!.enableMediaKeys();
|
||||||
} else {
|
} else {
|
||||||
localSettings.disableMediaKeys();
|
localSettings!.disableMediaKeys();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -56,7 +56,7 @@ export const AudioSettings = () => {
|
|||||||
setSettings({ playback: { ...settings, type: e as PlaybackType } });
|
setSettings({ playback: { ...settings, type: e as PlaybackType } });
|
||||||
if (isElectron() && e === PlaybackType.LOCAL) {
|
if (isElectron() && e === PlaybackType.LOCAL) {
|
||||||
const queueData = usePlayerStore.getState().actions.getPlayerData();
|
const queueData = usePlayerStore.getState().actions.getPlayerData();
|
||||||
mpvPlayer.setQueue(queueData);
|
mpvPlayer!.setQueue(queueData);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -36,7 +36,7 @@ export const getMpvSetting = (
|
|||||||
case 'replayGainPreampDB':
|
case 'replayGainPreampDB':
|
||||||
return { 'replaygain-preamp': value || 0 };
|
return { 'replaygain-preamp': value || 0 };
|
||||||
default:
|
default:
|
||||||
return key;
|
return { 'audio-format': value };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -66,12 +66,12 @@ export const MpvSettings = () => {
|
|||||||
const [mpvPath, setMpvPath] = useState('');
|
const [mpvPath, setMpvPath] = useState('');
|
||||||
|
|
||||||
const handleSetMpvPath = (e: File) => {
|
const handleSetMpvPath = (e: File) => {
|
||||||
localSettings.set('mpv_path', e.path);
|
localSettings?.set('mpv_path', e.path);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getMpvPath = async () => {
|
const getMpvPath = async () => {
|
||||||
if (!isElectron()) return setMpvPath('');
|
if (!localSettings) return setMpvPath('');
|
||||||
const mpvPath = (await localSettings.get('mpv_path')) as string;
|
const mpvPath = (await localSettings.get('mpv_path')) as string;
|
||||||
return setMpvPath(mpvPath);
|
return setMpvPath(mpvPath);
|
||||||
};
|
};
|
||||||
|
@ -46,7 +46,7 @@ export const WindowSettings = () => {
|
|||||||
message:
|
message:
|
||||||
'Restart to apply changes... close the notification to restart Feishin',
|
'Restart to apply changes... close the notification to restart Feishin',
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
window.electron.ipc.send('app-restart');
|
window.electron.ipc!.send('app-restart');
|
||||||
},
|
},
|
||||||
title: 'Restart required',
|
title: 'Restart required',
|
||||||
});
|
});
|
||||||
|
@ -11,6 +11,9 @@ import {
|
|||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
||||||
import { getServerById, useSetAlbumListItemDataById, useSetQueueFavorite } from '/@/renderer/store';
|
import { getServerById, useSetAlbumListItemDataById, useSetQueueFavorite } from '/@/renderer/store';
|
||||||
|
import isElectron from 'is-electron';
|
||||||
|
|
||||||
|
const remote = isElectron() ? window.electron.remote : null;
|
||||||
|
|
||||||
export const useCreateFavorite = (args: MutationHookArgs) => {
|
export const useCreateFavorite = (args: MutationHookArgs) => {
|
||||||
const { options } = args || {};
|
const { options } = args || {};
|
||||||
@ -42,6 +45,7 @@ export const useCreateFavorite = (args: MutationHookArgs) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (variables.query.type === LibraryItem.SONG) {
|
if (variables.query.type === LibraryItem.SONG) {
|
||||||
|
remote?.updateFavorite(true, serverId, variables.query.id);
|
||||||
setQueueFavorite(variables.query.id, true);
|
setQueueFavorite(variables.query.id, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,6 +11,9 @@ import {
|
|||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
||||||
import { getServerById, useSetAlbumListItemDataById, useSetQueueFavorite } from '/@/renderer/store';
|
import { getServerById, useSetAlbumListItemDataById, useSetQueueFavorite } from '/@/renderer/store';
|
||||||
|
import isElectron from 'is-electron';
|
||||||
|
|
||||||
|
const remote = isElectron() ? window.electron.remote : null;
|
||||||
|
|
||||||
export const useDeleteFavorite = (args: MutationHookArgs) => {
|
export const useDeleteFavorite = (args: MutationHookArgs) => {
|
||||||
const { options } = args || {};
|
const { options } = args || {};
|
||||||
@ -42,6 +45,7 @@ export const useDeleteFavorite = (args: MutationHookArgs) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (variables.query.type === LibraryItem.SONG) {
|
if (variables.query.type === LibraryItem.SONG) {
|
||||||
|
remote?.updateFavorite(false, serverId, variables.query.id);
|
||||||
setQueueFavorite(variables.query.id, false);
|
setQueueFavorite(variables.query.id, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,6 +14,9 @@ import {
|
|||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
||||||
import { getServerById, useSetAlbumListItemDataById, useSetQueueRating } from '/@/renderer/store';
|
import { getServerById, useSetAlbumListItemDataById, useSetQueueRating } from '/@/renderer/store';
|
||||||
|
import isElectron from 'is-electron';
|
||||||
|
|
||||||
|
const remote = isElectron() ? window.electron.remote : null;
|
||||||
|
|
||||||
export const useSetRating = (args: MutationHookArgs) => {
|
export const useSetRating = (args: MutationHookArgs) => {
|
||||||
const { options } = args || {};
|
const { options } = args || {};
|
||||||
@ -56,6 +59,11 @@ export const useSetRating = (args: MutationHookArgs) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (remote) {
|
||||||
|
const ids = variables.query.item.map((item) => item.id);
|
||||||
|
remote.updateRating(variables.query.rating, variables.query.item[0].serverId, ids);
|
||||||
|
}
|
||||||
|
|
||||||
return { previous: { items: variables.query.item } };
|
return { previous: { items: variables.query.item } };
|
||||||
},
|
},
|
||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
|
@ -47,7 +47,7 @@ export const AppMenu = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCredentialsModal = async (server: ServerListItem) => {
|
const handleCredentialsModal = async (server: ServerListItem) => {
|
||||||
let password: string | undefined;
|
let password: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (localSettings && server.savePassword) {
|
if (localSettings && server.savePassword) {
|
||||||
|
24
src/renderer/preload.d.ts
vendored
24
src/renderer/preload.d.ts
vendored
@ -1,12 +1,19 @@
|
|||||||
import { IpcRendererEvent } from 'electron';
|
import { IpcRendererEvent } from 'electron';
|
||||||
import { PlayerData, PlayerState } from './store';
|
import { PlayerData, PlayerState } from './store';
|
||||||
import { InternetProviderLyricResponse, QueueSong } from '/@/renderer/api/types';
|
import { InternetProviderLyricResponse, QueueSong } from '/@/renderer/api/types';
|
||||||
|
import { Remote } from '/@/main/preload/remote';
|
||||||
|
import { Mpris } from '/@/main/preload/mpris';
|
||||||
|
import { MpvPLayer, MpvPlayerListener } from '/@/main/preload/mpv-player';
|
||||||
|
import { Lyrics } from '/@/main/preload/lyrics';
|
||||||
|
import { Utils } from '/@/main/preload/utils';
|
||||||
|
import { LocalSettings } from '/@/main/preload/local-settings';
|
||||||
|
import { Ipc } from '/@/main/preload/ipc';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
electron: {
|
electron: {
|
||||||
browser: any;
|
browser: any;
|
||||||
ipc: any;
|
ipc?: Ipc;
|
||||||
ipcRenderer: {
|
ipcRenderer: {
|
||||||
APP_RESTART(): void;
|
APP_RESTART(): void;
|
||||||
LYRIC_FETCH(data: QueueSong): void;
|
LYRIC_FETCH(data: QueueSong): void;
|
||||||
@ -37,6 +44,8 @@ declare global {
|
|||||||
PLAYER_SET_QUEUE_NEXT(data: PlayerData): void;
|
PLAYER_SET_QUEUE_NEXT(data: PlayerData): void;
|
||||||
PLAYER_STOP(): void;
|
PLAYER_STOP(): void;
|
||||||
PLAYER_VOLUME(value: number): void;
|
PLAYER_VOLUME(value: number): void;
|
||||||
|
REMOTE_ENABLE(enabled: boolean): Promise<string | null>;
|
||||||
|
REMOTE_PORT(port: number): Promise<string | null>;
|
||||||
RENDERER_PLAYER_AUTO_NEXT(cb: (event: IpcRendererEvent, data: any) => void): void;
|
RENDERER_PLAYER_AUTO_NEXT(cb: (event: IpcRendererEvent, data: any) => void): void;
|
||||||
RENDERER_PLAYER_CURRENT_TIME(
|
RENDERER_PLAYER_CURRENT_TIME(
|
||||||
cb: (event: IpcRendererEvent, data: any) => void,
|
cb: (event: IpcRendererEvent, data: any) => void,
|
||||||
@ -59,12 +68,13 @@ declare global {
|
|||||||
windowMinimize(): void;
|
windowMinimize(): void;
|
||||||
windowUnmaximize(): void;
|
windowUnmaximize(): void;
|
||||||
};
|
};
|
||||||
localSettings: any;
|
localSettings: LocalSettings;
|
||||||
lyrics: any;
|
lyrics?: Lyrics;
|
||||||
mpris: any;
|
mpris?: Mpris;
|
||||||
mpvPlayer: any;
|
mpvPlayer?: MpvPLayer;
|
||||||
mpvPlayerListener: any;
|
mpvPlayerListener?: MpvPlayerListener;
|
||||||
utils: any;
|
remote?: Remote;
|
||||||
|
utils?: Utils;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,7 @@ export const AppOutlet = () => {
|
|||||||
|
|
||||||
const isActionsRequired = useMemo(() => {
|
const isActionsRequired = useMemo(() => {
|
||||||
const isMpvRequired = () => {
|
const isMpvRequired = () => {
|
||||||
if (!isElectron()) return false;
|
if (!localSettings) return false;
|
||||||
const mpvPath = localSettings.get('mpv_path');
|
const mpvPath = localSettings.get('mpv_path');
|
||||||
if (mpvPath) return false;
|
if (mpvPath) return false;
|
||||||
return true;
|
return true;
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
TableType,
|
TableType,
|
||||||
Platform,
|
Platform,
|
||||||
} from '/@/renderer/types';
|
} from '/@/renderer/types';
|
||||||
|
import { randomString } from '/@/renderer/utils';
|
||||||
|
|
||||||
const utils = isElectron() ? window.electron.utils : null;
|
const utils = isElectron() ? window.electron.utils : null;
|
||||||
|
|
||||||
@ -151,6 +152,12 @@ export interface SettingsState {
|
|||||||
style: PlaybackStyle;
|
style: PlaybackStyle;
|
||||||
type: PlaybackType;
|
type: PlaybackType;
|
||||||
};
|
};
|
||||||
|
remote: {
|
||||||
|
enabled: boolean;
|
||||||
|
password: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
tab: 'general' | 'playback' | 'window' | 'hotkeys' | string;
|
tab: 'general' | 'playback' | 'window' | 'hotkeys' | string;
|
||||||
tables: {
|
tables: {
|
||||||
fullScreen: DataTableProps;
|
fullScreen: DataTableProps;
|
||||||
@ -177,7 +184,7 @@ export interface SettingsSlice extends SettingsState {
|
|||||||
|
|
||||||
// Determines the default/initial windowBarStyle value based on the current platform.
|
// Determines the default/initial windowBarStyle value based on the current platform.
|
||||||
const getPlatformDefaultWindowBarStyle = (): Platform => {
|
const getPlatformDefaultWindowBarStyle = (): Platform => {
|
||||||
return isElectron() ? (utils.isMacOS() ? Platform.MACOS : Platform.WINDOWS) : Platform.WEB;
|
return utils ? (utils.isMacOS() ? Platform.MACOS : Platform.WINDOWS) : Platform.WEB;
|
||||||
};
|
};
|
||||||
|
|
||||||
const platformDefaultWindowBarStyle: Platform = getPlatformDefaultWindowBarStyle();
|
const platformDefaultWindowBarStyle: Platform = getPlatformDefaultWindowBarStyle();
|
||||||
@ -258,6 +265,12 @@ const initialState: SettingsState = {
|
|||||||
style: PlaybackStyle.GAPLESS,
|
style: PlaybackStyle.GAPLESS,
|
||||||
type: PlaybackType.LOCAL,
|
type: PlaybackType.LOCAL,
|
||||||
},
|
},
|
||||||
|
remote: {
|
||||||
|
enabled: false,
|
||||||
|
password: randomString(8),
|
||||||
|
port: 4333,
|
||||||
|
username: 'feishin',
|
||||||
|
},
|
||||||
tab: 'general',
|
tab: 'general',
|
||||||
tables: {
|
tables: {
|
||||||
fullScreen: {
|
fullScreen: {
|
||||||
@ -450,3 +463,5 @@ export const useMpvSettings = () =>
|
|||||||
useSettingsStore((state) => state.playback.mpvProperties, shallow);
|
useSettingsStore((state) => state.playback.mpvProperties, shallow);
|
||||||
|
|
||||||
export const useLyricsSettings = () => useSettingsStore((state) => state.lyrics, shallow);
|
export const useLyricsSettings = () => useSettingsStore((state) => state.lyrics, shallow);
|
||||||
|
|
||||||
|
export const useRemoteSettings = () => useSettingsStore((state) => state.remote, shallow);
|
||||||
|
@ -187,3 +187,13 @@ export type GridCardData = {
|
|||||||
resetInfiniteLoaderCache: () => void;
|
resetInfiniteLoaderCache: () => void;
|
||||||
route: CardRoute;
|
route: CardRoute;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SongUpdate = {
|
||||||
|
currentTime?: number;
|
||||||
|
repeat?: PlayerRepeat;
|
||||||
|
shuffle?: boolean;
|
||||||
|
song?: QueueSong;
|
||||||
|
status?: PlayerStatus;
|
||||||
|
/** This volume is in range 0-100 */
|
||||||
|
volume?: number;
|
||||||
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user