diff --git a/.erb/configs/webpack.config.remote.dev.ts b/.erb/configs/webpack.config.remote.dev.ts new file mode 100644 index 00000000..a4f30baf --- /dev/null +++ b/.erb/configs/webpack.config.remote.dev.ts @@ -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); diff --git a/.erb/configs/webpack.config.remote.prod.ts b/.erb/configs/webpack.config.remote.prod.ts new file mode 100644 index 00000000..d86406d6 --- /dev/null +++ b/.erb/configs/webpack.config.remote.prod.ts @@ -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); diff --git a/.erb/configs/webpack.config.renderer.dev.ts b/.erb/configs/webpack.config.renderer.dev.ts index d4967f69..f6ca25d8 100644 --- a/.erb/configs/webpack.config.renderer.dev.ts +++ b/.erb/configs/webpack.config.renderer.dev.ts @@ -171,6 +171,14 @@ const configuration: webpack.Configuration = { .on('close', (code: number) => process.exit(code!)) .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...'); spawn('npm', ['run', 'start:main'], { shell: true, @@ -178,6 +186,7 @@ const configuration: webpack.Configuration = { }) .on('close', (code: number) => { preloadProcess.kill(); + remoteProcess.kill(); process.exit(code!); }) .on('error', (spawnError) => console.error(spawnError)); diff --git a/.erb/configs/webpack.paths.ts b/.erb/configs/webpack.paths.ts index e5ba5734..08ca36f9 100644 --- a/.erb/configs/webpack.paths.ts +++ b/.erb/configs/webpack.paths.ts @@ -6,6 +6,7 @@ const dllPath = path.join(__dirname, '../dll'); const srcPath = path.join(rootPath, 'src'); const srcMainPath = path.join(srcPath, 'main'); +const srcRemotePath = path.join(srcPath, 'remote'); const srcRendererPath = path.join(srcPath, 'renderer'); const releasePath = path.join(rootPath, 'release'); @@ -16,6 +17,7 @@ const srcNodeModulesPath = path.join(srcPath, 'node_modules'); const distPath = path.join(appPath, 'dist'); const distMainPath = path.join(distPath, 'main'); +const distRemotePath = path.join(distPath, 'remote'); const distRendererPath = path.join(distPath, 'renderer'); const buildPath = path.join(releasePath, 'build'); @@ -25,6 +27,7 @@ export default { dllPath, srcPath, srcMainPath, + srcRemotePath, srcRendererPath, releasePath, appPath, @@ -33,6 +36,7 @@ export default { srcNodeModulesPath, distPath, distMainPath, + distRemotePath, distRendererPath, buildPath, }; diff --git a/.erb/scripts/check-build-exists.ts b/.erb/scripts/check-build-exists.ts index ccb0ba8a..1d299a2f 100644 --- a/.erb/scripts/check-build-exists.ts +++ b/.erb/scripts/check-build-exists.ts @@ -5,20 +5,29 @@ import fs from 'fs'; import webpackPaths from '../configs/webpack.paths'; const mainPath = path.join(webpackPaths.distMainPath, 'main.js'); +const remotePath = path.join(webpackPaths.distMainPath, 'remote.js'); const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js'); if (!fs.existsSync(mainPath)) { throw new Error( 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)) { throw new Error( 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"', + ), ); } diff --git a/.erb/scripts/delete-source-maps.js b/.erb/scripts/delete-source-maps.js index 3d051eab..21d6cb58 100644 --- a/.erb/scripts/delete-source-maps.js +++ b/.erb/scripts/delete-source-maps.js @@ -4,5 +4,6 @@ import webpackPaths from '../configs/webpack.paths'; export default function deleteSourceMaps() { rimraf.sync(path.join(webpackPaths.distMainPath, '*.js.map')); + rimraf.sync(path.join(webpackPaths.distRemotePath, '*.js.map')); rimraf.sync(path.join(webpackPaths.distRendererPath, '*.js.map')); } diff --git a/package.json b/package.json index 9375b17b..a2c8015f 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,9 @@ "description": "Feishin music server", "version": "0.2.0", "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: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", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", "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: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: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:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts", "test": "jest", diff --git a/release/app/package-lock.json b/release/app/package-lock.json index ac6795c7..a4264d5f 100644 --- a/release/app/package-lock.json +++ b/release/app/package-lock.json @@ -11,7 +11,8 @@ "license": "GPL-3.0", "dependencies": { "cheerio": "^1.0.0-rc.12", - "mpris-service": "^2.1.2" + "mpris-service": "^2.1.2", + "ws": "^8.13.0" }, "devDependencies": { "electron": "22.3.1" @@ -26,6 +27,7 @@ "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", + "global-agent": "^3.0.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", @@ -293,6 +295,7 @@ "integrity": "sha512-tzQq/+wrTZ2yU+U5PoeXc97KABhX2v55C/T0finH3tSKYuI8H/SqppIFymBBrUHcK13LvEGY3vdj3ikPPenL5g==", "dependencies": { "@nornagon/put": "0.0.8", + "abstract-socket": "^2.0.0", "event-stream": "3.3.4", "hexy": "^0.2.10", "jsbi": "^2.0.5", @@ -538,6 +541,7 @@ "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", "dev": true, "dependencies": { + "@types/yauzl": "^2.9.1", "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" @@ -867,6 +871,9 @@ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, + "dependencies": { + "graceful-fs": "^4.1.6" + }, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -1284,6 +1291,26 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "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": { "version": "0.4.23", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", @@ -2269,6 +2296,12 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "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": { "version": "0.4.23", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", diff --git a/release/app/package.json b/release/app/package.json index 3d3abe0c..1f62af48 100644 --- a/release/app/package.json +++ b/release/app/package.json @@ -14,7 +14,8 @@ }, "dependencies": { "cheerio": "^1.0.0-rc.12", - "mpris-service": "^2.1.2" + "mpris-service": "^2.1.2", + "ws": "^8.13.0" }, "devDependencies": { "electron": "22.3.1" diff --git a/src/main/features/core/index.ts b/src/main/features/core/index.ts index 06a0a8da..e2b2455b 100644 --- a/src/main/features/core/index.ts +++ b/src/main/features/core/index.ts @@ -1,3 +1,4 @@ import './lyrics'; import './player'; +import './remote'; import './settings'; diff --git a/src/main/features/core/player/index.ts b/src/main/features/core/player/index.ts index 04ca268e..a7cd32b3 100644 --- a/src/main/features/core/player/index.ts +++ b/src/main/features/core/player/index.ts @@ -207,9 +207,9 @@ ipcMain.on('player-volume', async (_event, value: number) => { }); // Toggles the mute status -ipcMain.on('player-mute', async () => { +ipcMain.on('player-mute', async (_event, mute: boolean) => { await getMpvInstance() - ?.mute() + ?.mute(mute) .catch((err) => { console.log('MPV failed to toggle mute', err); }); diff --git a/src/main/features/core/remote/index.ts b/src/main/features/core/remote/index.ts new file mode 100644 index 00000000..98e217ff --- /dev/null +++ b/src/main/features/core/remote/index.ts @@ -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 | 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>(); + +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 { + 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 => { + return new Promise((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' }); + }); +} diff --git a/src/main/features/core/settings/index.ts b/src/main/features/core/settings/index.ts index f45addea..856e89b1 100644 --- a/src/main/features/core/settings/index.ts +++ b/src/main/features/core/settings/index.ts @@ -1,5 +1,5 @@ -import { ipcMain, safeStorage } from 'electron'; import Store from 'electron-store'; +import { ipcMain, safeStorage } from 'electron'; export const store = new Store(); diff --git a/src/main/features/linux/mpris.ts b/src/main/features/linux/mpris.ts index 3b005ac0..16c63c5f 100644 --- a/src/main/features/linux/mpris.ts +++ b/src/main/features/linux/mpris.ts @@ -1,8 +1,7 @@ import { ipcMain } from 'electron'; import Player from 'mpris-service'; -import { QueueSong, RelatedArtist } from '../../../renderer/api/types'; +import { PlayerRepeat, PlayerStatus, SongUpdate } from '../../../renderer/types'; import { getMainWindow } from '../../main'; -import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/renderer/types'; const mprisPlayer = Player({ identity: 'Feishin', @@ -59,9 +58,17 @@ mprisPlayer.on('previous', () => { } }); -mprisPlayer.on('volume', (event: any) => { - getMainWindow()?.webContents.send('mpris-request-volume', { - volume: event, +mprisPlayer.on('volume', (vol: number) => { + let volume = Math.round(vol * 100); + + 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) => { - getMainWindow()?.webContents.send('mpris-request-position', { + getMainWindow()?.webContents.send('request-position', { position: event.position / 1e6, }); }); mprisPlayer.on('seek', (event: number) => { - getMainWindow()?.webContents.send('mpris-request-seek', { + getMainWindow()?.webContents.send('request-seek', { offset: event / 1e6, }); }); @@ -95,76 +102,68 @@ ipcMain.on('mpris-update-seek', (_event, arg) => { mprisPlayer.seeked(arg * 1e6); }); -ipcMain.on('mpris-update-volume', (_event, arg) => { - mprisPlayer.volume = Number(arg); +ipcMain.on('update-volume', (_event, volume) => { + mprisPlayer.volume = Number(volume) / 100; }); -ipcMain.on('mpris-update-repeat', (_event, arg) => { - mprisPlayer.loopStatus = arg; +const REPEAT_TO_MPRIS: Record = { + [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) => { - mprisPlayer.shuffle = arg; +ipcMain.on('update-shuffle', (_event, shuffle: boolean) => { + mprisPlayer.shuffle = shuffle; }); -ipcMain.on( - 'mpris-update-song', - ( - _event, - args: { - currentTime: number; - repeat: PlayerRepeat; - shuffle: PlayerShuffle; - song: QueueSong; - status: PlayerStatus; - }, - ) => { - const { song, status, repeat, shuffle } = args || {}; +ipcMain.on('update-song', (_event, args: SongUpdate) => { + const { song, status, repeat, shuffle } = args || {}; - try { - mprisPlayer.playbackStatus = status; + try { + mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused'; - if (repeat) { - mprisPlayer.loopStatus = - 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 (repeat) { + mprisPlayer.loopStatus = REPEAT_TO_MPRIS[repeat]; } - }, -); + + 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 }; diff --git a/src/main/main.ts b/src/main/main.ts index b32cb71b..61adc9e0 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -11,6 +11,11 @@ import { access, constants, readFile, writeFile } from 'fs'; import path, { join } from 'path'; 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 { app, BrowserWindow, @@ -22,11 +27,6 @@ import { nativeImage, BrowserWindowConstructorOptions, } 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 { store } from './features/core/settings/index'; import MenuBuilder from './menu'; diff --git a/src/main/preload.ts b/src/main/preload.ts index 5527baff..c87e5877 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -5,6 +5,7 @@ import { localSettings } from './preload/local-settings'; import { lyrics } from './preload/lyrics'; import { mpris } from './preload/mpris'; import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player'; +import { remote } from './preload/remote'; import { utils } from './preload/utils'; contextBridge.exposeInMainWorld('electron', { @@ -15,5 +16,6 @@ contextBridge.exposeInMainWorld('electron', { mpris, mpvPlayer, mpvPlayerListener, + remote, utils, }); diff --git a/src/main/preload/ipc.ts b/src/main/preload/ipc.ts index 32eaa597..44001069 100644 --- a/src/main/preload/ipc.ts +++ b/src/main/preload/ipc.ts @@ -12,3 +12,5 @@ export const ipc = { removeAllListeners, send, }; + +export type Ipc = typeof ipc; diff --git a/src/main/preload/local-settings.ts b/src/main/preload/local-settings.ts index 8facb8a3..6fc75d77 100644 --- a/src/main/preload/local-settings.ts +++ b/src/main/preload/local-settings.ts @@ -1,5 +1,5 @@ -import { ipcRenderer, webFrame } from 'electron'; import Store from 'electron-store'; +import { ipcRenderer, webFrame } from 'electron'; const store = new Store(); @@ -50,3 +50,5 @@ export const localSettings = { set, setZoomFactor, }; + +export type LocalSettings = typeof localSettings; diff --git a/src/main/preload/lyrics.ts b/src/main/preload/lyrics.ts index a472357c..1b6694cc 100644 --- a/src/main/preload/lyrics.ts +++ b/src/main/preload/lyrics.ts @@ -1,17 +1,25 @@ 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 result = ipcRenderer.invoke('lyric-by-song', song); return result; }; -const searchRemoteLyrics = (params: LyricSearchQuery) => { +const searchRemoteLyrics = ( + params: LyricSearchQuery, +): Promise> => { const result = ipcRenderer.invoke('lyric-search', params); return result; }; -const getRemoteLyricsByRemoteId = (id: string) => { +const getRemoteLyricsByRemoteId = (id: LyricGetQuery) => { const result = ipcRenderer.invoke('lyric-by-remote-id', id); return result; }; @@ -21,3 +29,5 @@ export const lyrics = { getRemoteLyricsBySong, searchRemoteLyrics, }; + +export type Lyrics = typeof lyrics; diff --git a/src/main/preload/mpris.ts b/src/main/preload/mpris.ts index 273bb83f..9777201e 100644 --- a/src/main/preload/mpris.ts +++ b/src/main/preload/mpris.ts @@ -1,9 +1,5 @@ import { IpcRendererEvent, ipcRenderer } from 'electron'; -import { QueueSong } from '/@/renderer/api/types'; - -const updateSong = (args: { currentTime: number; song: QueueSong }) => { - ipcRenderer.send('mpris-update-song', args); -}; +import type { PlayerRepeat } from '/@/renderer/types'; const updatePosition = (timeSec: number) => { ipcRenderer.send('mpris-update-position', timeSec); @@ -13,18 +9,6 @@ const updateSeek = (timeSec: number) => { 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 = () => { ipcRenderer.send('mpris-toggle-repeat'); }; @@ -33,38 +17,25 @@ const toggleShuffle = () => { ipcRenderer.send('mpris-toggle-shuffle'); }; -const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => { - ipcRenderer.on('mpris-request-position', cb); -}; - -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) => { +const requestToggleRepeat = ( + cb: (event: IpcRendererEvent, data: { repeat: PlayerRepeat }) => void, +) => { 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); }; export const mpris = { - requestPosition, - requestSeek, requestToggleRepeat, requestToggleShuffle, - requestVolume, toggleRepeat, toggleShuffle, updatePosition, - updateRepeat, updateSeek, - updateShuffle, - updateSong, - updateVolume, }; + +export type Mpris = typeof mpris; diff --git a/src/main/preload/mpv-player.ts b/src/main/preload/mpv-player.ts index e1f57da9..49c1e34c 100644 --- a/src/main/preload/mpv-player.ts +++ b/src/main/preload/mpv-player.ts @@ -1,5 +1,5 @@ import { ipcRenderer, IpcRendererEvent } from 'electron'; -import { PlayerData } from '/@/renderer/store'; +import { PlayerData, PlayerState } from '/@/renderer/store'; const initialize = (data: { extraParameters?: string[]; properties?: Record }) => { ipcRenderer.send('player-initialize', data); @@ -30,8 +30,8 @@ const currentTime = () => { ipcRenderer.send('player-current-time'); }; -const mute = () => { - ipcRenderer.send('player-mute'); +const mute = (mute: boolean) => { + ipcRenderer.send('player-mute', mute); }; const next = () => { @@ -158,7 +158,9 @@ const rendererSaveQueue = (cb: (event: IpcRendererEvent) => void) => { ipcRenderer.on('renderer-player-save-queue', cb); }; -const rendererRestoreQueue = (cb: (event: IpcRendererEvent) => void) => { +const rendererRestoreQueue = ( + cb: (event: IpcRendererEvent, data: Partial) => void, +) => { ipcRenderer.on('renderer-player-restore-queue', cb); }; @@ -212,3 +214,6 @@ export const mpvPlayerListener = { rendererVolumeMute, rendererVolumeUp, }; + +export type MpvPLayer = typeof mpvPlayer; +export type MpvPlayerListener = typeof mpvPlayerListener; diff --git a/src/main/preload/remote.ts b/src/main/preload/remote.ts new file mode 100644 index 00000000..16fa140a --- /dev/null +++ b/src/main/preload/remote.ts @@ -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 => { + const result = ipcRenderer.invoke('remote-enable', enabled); + return result; +}; + +const setRemotePort = (port: number): Promise => { + 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 => { + 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; diff --git a/src/main/preload/utils.ts b/src/main/preload/utils.ts index 1b09b37e..77bc4dad 100644 --- a/src/main/preload/utils.ts +++ b/src/main/preload/utils.ts @@ -5,3 +5,5 @@ export const utils = { isMacOS, isWindows, }; + +export type Utils = typeof utils; diff --git a/src/remote/app.tsx b/src/remote/app.tsx new file mode 100644 index 00000000..747ba024 --- /dev/null +++ b/src/remote/app.tsx @@ -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 ( + ({ + 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', + }, + }} + > + + + ); +}; diff --git a/src/remote/components/buttons/image-button.tsx b/src/remote/components/buttons/image-button.tsx new file mode 100644 index 00000000..9aabd215 --- /dev/null +++ b/src/remote/components/buttons/image-button.tsx @@ -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 ( + toggleImage()} + > + {showImage ? : } + + ); +}; diff --git a/src/remote/components/buttons/reconnect-button.tsx b/src/remote/components/buttons/reconnect-button.tsx new file mode 100644 index 00000000..41b5ed20 --- /dev/null +++ b/src/remote/components/buttons/reconnect-button.tsx @@ -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 ( + reconnect()} + > + + + ); +}; diff --git a/src/remote/components/buttons/remote-button.tsx b/src/remote/components/buttons/remote-button.tsx new file mode 100644 index 00000000..35fecd42 --- /dev/null +++ b/src/remote/components/buttons/remote-button.tsx @@ -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) => void; + onMouseDown?: (e: React.MouseEvent) => void; + ref: Ref; +} + +export interface ButtonProps extends StyledButtonProps { + tooltip: string; +} + +const StyledButton = styled(Button)` + 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( + ({ children, tooltip, ...props }: ButtonProps, ref) => { + return ( + + + {children} + + + ); + }, +); + +RemoteButton.defaultProps = { + $active: false, + onClick: undefined, + onMouseDown: undefined, +}; diff --git a/src/remote/components/buttons/theme-button.tsx b/src/remote/components/buttons/theme-button.tsx new file mode 100644 index 00000000..4e6bfc24 --- /dev/null +++ b/src/remote/components/buttons/theme-button.tsx @@ -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 ( + toggleDark()} + > + {isDark ? : } + + ); +}; diff --git a/src/remote/components/remote-container.tsx b/src/remote/components/remote-container.tsx new file mode 100644 index 00000000..b09b29d2 --- /dev/null +++ b/src/remote/components/remote-container.tsx @@ -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 && ( + <> + {song.name} + + Album: {song.album} + Artist: {song.artistName} + + + Duration: {formatDuration(song.duration * 1000)} + {song.releaseDate && ( + + Released: {new Date(song.releaseDate).toLocaleDateString()} + + )} + Plays: {song.playCount} + + + )} + + send({ event: 'previous' })} + > + + + { + if (status === PlayerStatus.PLAYING) { + send({ event: 'pause' }); + } else if (status === PlayerStatus.PAUSED) { + send({ event: 'play' }); + } + }} + > + {status === PlayerStatus.PLAYING ? ( + + ) : ( + + )} + + send({ event: 'next' })} + > + + + + + send({ event: 'shuffle' })} + > + + + send({ event: 'repeat' })} + > + {repeat === undefined || repeat === PlayerRepeat.ONE ? ( + + ) : ( + + )} + + { + if (!id) return; + + send({ event: 'favorite', favorite: !song.userFavorite, id }); + }} + > + + + {(song?.serverType === 'navidrome' || song?.serverType === 'subsonic') && ( +
+ + debouncedSetRating(0)} + /> + +
+ )} +
+ } + max={100} + rightLabel={ + + {volume ?? 0} + + } + value={volume ?? 0} + onChangeEnd={(e) => send({ event: 'volume', volume: e })} + /> + {showImage && ( + send({ event: 'proxy' })} + /> + )} + + ); +}; diff --git a/src/remote/components/shell.tsx b/src/remote/components/shell.tsx new file mode 100644 index 00000000..ce188012 --- /dev/null +++ b/src/remote/components/shell.tsx @@ -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 ( + + + +
+ +
+
+ + + Feishin Remote + + + + + + + + + + +
+ + } + padding="md" + > + + {connected ? ( + + ) : ( + + )} + +
+ ); +}; diff --git a/src/remote/components/wrapped-slider.tsx b/src/remote/components/wrapped-slider.tsx new file mode 100644 index 00000000..b3804259 --- /dev/null +++ b/src/remote/components/wrapped-slider.tsx @@ -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 { + 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 ( + + {leftLabel && {leftLabel}} + + { + setIsSeeking(true); + setSeek(e); + }} + onChangeEnd={(e) => { + props.onChangeEnd(e); + setIsSeeking(false); + }} + /> + + {rightLabel && {rightLabel}} + + ); +}; diff --git a/src/remote/index.ejs b/src/remote/index.ejs new file mode 100644 index 00000000..a65faf5e --- /dev/null +++ b/src/remote/index.ejs @@ -0,0 +1,15 @@ + + + + + + + + Feishin Remote + + + +
+ + + diff --git a/src/remote/index.tsx b/src/remote/index.tsx new file mode 100644 index 00000000..f428da48 --- /dev/null +++ b/src/remote/index.tsx @@ -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( + <> + + + , +); diff --git a/src/remote/store/index.ts b/src/remote/store/index.ts new file mode 100644 index 00000000..91e9f26d --- /dev/null +++ b/src/remote/store/index.ts @@ -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; + 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()( + 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); diff --git a/src/remote/styles/global.scss b/src/remote/styles/global.scss new file mode 100644 index 00000000..e74602c9 --- /dev/null +++ b/src/remote/styles/global.scss @@ -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; +} diff --git a/src/remote/types.ts b/src/remote/types.ts new file mode 100644 index 00000000..f5a648f4 --- /dev/null +++ b/src/remote/types.ts @@ -0,0 +1,56 @@ +import type { QueueSong } from '/@/renderer/api/types'; +import type { SongUpdate } from '/@/renderer/types'; + +export interface SongUpdateSocket extends Omit { + 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; diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts index 9934bb73..da23eb9e 100644 --- a/src/renderer/api/navidrome/navidrome-api.ts +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -1,7 +1,7 @@ import { initClient, initContract } from '@ts-rest/core'; import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from 'axios'; import isElectron from 'is-electron'; -import { debounce } from 'lodash'; +import debounce from 'lodash/debounce'; import omitBy from 'lodash/omitBy'; import qs from 'qs'; import { ndType } from './navidrome-types'; diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 2b21d418..ada4a3b9 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -5,12 +5,16 @@ import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model'; import { MantineProvider } from '@mantine/core'; import { ModalsProvider } from '@mantine/modals'; import { initSimpleImg } from 'react-simple-img'; -import { BaseContextModal } from './components'; +import { BaseContextModal, toast } from './components'; import { useTheme } from './hooks'; 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 '@ag-grid-community/styles/ag-grid.css'; import { ContextMenuProvider } from '/@/renderer/features/context-menu'; import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add'; import { PlayQueueHandlerContext } from '/@/renderer/features/player'; @@ -27,6 +31,7 @@ initSimpleImg({ threshold: 0.05 }, true); const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null; const ipc = isElectron() ? window.electron.ipc : null; +const remote = isElectron() ? window.electron.remote : null; export const App = () => { const theme = useTheme(); @@ -35,6 +40,7 @@ export const App = () => { const { bindings } = useHotkeySettings(); const handlePlayQueueAdd = useHandlePlayQueueAdd(); const { clearQueue, restoreQueue } = useQueueControls(); + const remoteSettings = useRemoteSettings(); useEffect(() => { const root = document.documentElement; @@ -80,9 +86,9 @@ export const App = () => { useEffect(() => { if (isElectron()) { - mpvPlayer.restoreQueue(); + mpvPlayer!.restoreQueue(); - mpvPlayerListener.rendererSaveQueue(() => { + mpvPlayerListener!.rendererSaveQueue(() => { const { current, queue } = usePlayerStore.getState(); const stateToSave: Partial> = { current: { @@ -91,13 +97,13 @@ export const App = () => { }, queue, }; - mpvPlayer.saveQueue(stateToSave); + mpvPlayer!.saveQueue(stateToSave); }); - mpvPlayerListener.rendererRestoreQueue((_event: any, data: Partial) => { + mpvPlayerListener!.rendererRestoreQueue((_event: any, data) => { const playerData = restoreQueue(data); if (playbackType === PlaybackType.LOCAL) { - mpvPlayer.setQueue(playerData, true); + mpvPlayer!.setQueue(playerData, true); } }); } @@ -108,6 +114,23 @@ export const App = () => { }; }, [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 ( { const [mpvPath, setMpvPath] = useState(''); const handleSetMpvPath = (e: File) => { - localSettings.set('mpv_path', e.path); + localSettings?.set('mpv_path', e.path); }; useEffect(() => { const getMpvPath = async () => { - if (!isElectron()) return setMpvPath(''); + if (!localSettings) return setMpvPath(''); const mpvPath = localSettings.get('mpv_path') as string; return setMpvPath(mpvPath); }; @@ -37,7 +37,7 @@ export const MpvRequired = () => { placeholder={mpvPath} onChange={handleSetMpvPath} /> - + ); }; diff --git a/src/renderer/features/action-required/routes/action-required-route.tsx b/src/renderer/features/action-required/routes/action-required-route.tsx index 3acbf4a3..69c93325 100644 --- a/src/renderer/features/action-required/routes/action-required-route.tsx +++ b/src/renderer/features/action-required/routes/action-required-route.tsx @@ -22,7 +22,7 @@ const ActionRequiredRoute = () => { useEffect(() => { const getMpvPath = async () => { - if (!isElectron()) return setIsMpvRequired(false); + if (!localSettings) return setIsMpvRequired(false); const mpvPath = await localSettings.get('mpv_path'); if (mpvPath) { diff --git a/src/renderer/features/context-menu/context-menu-provider.tsx b/src/renderer/features/context-menu/context-menu-provider.tsx index 55e17ada..109a75ec 100644 --- a/src/renderer/features/context-menu/context-menu-provider.tsx +++ b/src/renderer/features/context-menu/context-menu-provider.tsx @@ -80,6 +80,7 @@ const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating']; // const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = []; const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; +const remote = isElectron() ? window.electron.remote : null; export interface ContextMenuProviderProps { children: React.ReactNode; @@ -555,7 +556,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { const playerData = moveToBottomOfQueue(uniqueIds); if (playerType === PlaybackType.LOCAL) { - mpvPlayer.setQueueNext(playerData); + mpvPlayer!.setQueueNext(playerData); } }, [ctx.dataNodes, moveToBottomOfQueue, playerType]); @@ -566,7 +567,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { const playerData = moveToTopOfQueue(uniqueIds); if (playerType === PlaybackType.LOCAL) { - mpvPlayer.setQueueNext(playerData); + mpvPlayer!.setQueueNext(playerData); } }, [ctx.dataNodes, moveToTopOfQueue, playerType]); @@ -580,11 +581,15 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { if (playerType === PlaybackType.LOCAL) { if (isCurrentSongRemoved) { - mpvPlayer.setQueue(playerData); + mpvPlayer!.setQueue(playerData); } else { - mpvPlayer.setQueueNext(playerData); + mpvPlayer!.setQueueNext(playerData); } } + + if (isCurrentSongRemoved) { + remote?.updateSong({ song: playerData.current.song }); + } }, [ctx.dataNodes, playerType, removeFromQueue]); const handleDeselectAll = useCallback(() => { diff --git a/src/renderer/features/lyrics/queries/lyric-query.ts b/src/renderer/features/lyrics/queries/lyric-query.ts index fc48a396..08336d6a 100644 --- a/src/renderer/features/lyrics/queries/lyric-query.ts +++ b/src/renderer/features/lyrics/queries/lyric-query.ts @@ -152,8 +152,8 @@ export const useSongLyricsByRemoteId = ( enabled: !!query.remoteSongId && !!query.remoteSource, onError: () => {}, queryFn: async () => { - const remoteLyricsResult: string | null = await lyricsIpc?.getRemoteLyricsByRemoteId( - query, + const remoteLyricsResult = await lyricsIpc?.getRemoteLyricsByRemoteId( + query as LyricGetQuery, ); if (remoteLyricsResult) { diff --git a/src/renderer/features/lyrics/queries/lyric-search-query.ts b/src/renderer/features/lyrics/queries/lyric-search-query.ts index b4e580d8..f82e2adb 100644 --- a/src/renderer/features/lyrics/queries/lyric-search-query.ts +++ b/src/renderer/features/lyrics/queries/lyric-search-query.ts @@ -16,7 +16,12 @@ export const useLyricSearch = (args: Omit, 'serv return useQuery>({ cacheTime: 1000 * 60 * 1, enabled: !!query.artist || !!query.name, - queryFn: () => lyricsIpc?.searchRemoteLyrics(query), + queryFn: () => { + if (lyricsIpc) { + return lyricsIpc.searchRemoteLyrics(query); + } + return {} as Record; + }, queryKey: queryKeys.songs.lyricsSearch(query), staleTime: 1000 * 60 * 1, ...options, diff --git a/src/renderer/features/now-playing/components/play-queue-list-controls.tsx b/src/renderer/features/now-playing/components/play-queue-list-controls.tsx index 08c8cf9d..29cc7700 100644 --- a/src/renderer/features/now-playing/components/play-queue-list-controls.tsx +++ b/src/renderer/features/now-playing/components/play-queue-list-controls.tsx @@ -19,6 +19,7 @@ import { usePlayerStore, useSetCurrentTime } from '../../../store/player.store'; import { TableConfigDropdown } from '/@/renderer/components/virtual-table'; const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; +const remote = isElectron() ? window.electron.remote : null; interface PlayQueueListOptionsProps { tableRef: MutableRefObject<{ grid: AgGridReactType } | null>; @@ -42,7 +43,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr const playerData = moveToBottomOfQueue(uniqueIds); if (playerType === PlaybackType.LOCAL) { - mpvPlayer.setQueueNext(playerData); + mpvPlayer!.setQueueNext(playerData); } }; @@ -54,7 +55,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr const playerData = moveToTopOfQueue(uniqueIds); 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 (isCurrentSongRemoved) { - mpvPlayer.setQueue(playerData); + mpvPlayer!.setQueue(playerData); } else { - mpvPlayer.setQueueNext(playerData); + mpvPlayer!.setQueueNext(playerData); } } + + if (isCurrentSongRemoved) { + remote?.updateSong({ song: playerData.current.song }); + } }; const handleClearQueue = () => { const playerData = clearQueue(); if (playerType === PlaybackType.LOCAL) { - mpvPlayer.setQueue(playerData); - mpvPlayer.pause(); + mpvPlayer!.setQueue(playerData); + mpvPlayer!.pause(); } + remote?.updateSong({ song: undefined }); + setCurrentTime(0); pause(); }; @@ -92,7 +99,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr const playerData = shuffleQueue(); if (playerType === PlaybackType.LOCAL) { - mpvPlayer.setQueueNext(playerData); + mpvPlayer!.setQueueNext(playerData); } }; diff --git a/src/renderer/features/now-playing/components/play-queue.tsx b/src/renderer/features/now-playing/components/play-queue.tsx index 3c6061ba..5f9a2b7b 100644 --- a/src/renderer/features/now-playing/components/play-queue.tsx +++ b/src/renderer/features/now-playing/components/play-queue.tsx @@ -28,15 +28,14 @@ import debounce from 'lodash/debounce'; import { ErrorBoundary } from 'react-error-boundary'; import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table'; 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 { useHandleTableContextMenu } from '/@/renderer/features/context-menu'; import { QUEUE_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid'; const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; -const utils = isElectron() ? window.electron.utils : null; -const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null; +const remote = isElectron() ? window.electron.remote : null; type QueueProps = { type: TableType; @@ -72,15 +71,15 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref) => { const handleDoubleClick = (e: CellDoubleClickedEvent) => { const playerData = setCurrentTrack(e.data.uniqueId); - mpris?.updateSong({ + remote?.updateSong({ currentTime: 0, song: playerData.current.song, - status: 'Playing', + status: PlayerStatus.PLAYING, }); if (playerType === PlaybackType.LOCAL) { - mpvPlayer.setQueue(playerData); - mpvPlayer.play(); + mpvPlayer!.setQueue(playerData); + mpvPlayer!.play(); } play(); @@ -102,7 +101,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref) => { const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId); if (playerType === PlaybackType.LOCAL) { - mpvPlayer.setQueueNext(playerData); + mpvPlayer!.setQueueNext(playerData); } if (type === 'sideDrawerQueue') { diff --git a/src/renderer/features/player/components/playerbar.tsx b/src/renderer/features/player/components/playerbar.tsx index 54cb8f86..43e3097b 100644 --- a/src/renderer/features/player/components/playerbar.tsx +++ b/src/renderer/features/player/components/playerbar.tsx @@ -59,8 +59,7 @@ const CenterGridItem = styled.div` overflow: hidden; `; -const utils = isElectron() ? window.electron.utils : null; -const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null; +const remote = isElectron() ? window.electron.remote : null; export const Playerbar = () => { const playersRef = PlayersRef; @@ -74,11 +73,13 @@ export const Playerbar = () => { const { autoNext } = usePlayerControls(); const autoNextFn = useCallback(() => { - const playerData = autoNext(); - mpris?.updateSong({ - currentTime: 0, - song: playerData.current.song, - }); + if (remote) { + const playerData = autoNext(); + remote.updateSong({ + currentTime: 0, + song: playerData.current.song, + }); + } }, [autoNext]); return ( diff --git a/src/renderer/features/player/components/right-controls.tsx b/src/renderer/features/player/components/right-controls.tsx index 2d3ac95b..c3124f94 100644 --- a/src/renderer/features/player/components/right-controls.tsx +++ b/src/renderer/features/player/components/right-controls.tsx @@ -1,6 +1,7 @@ -import { MouseEvent } from 'react'; +import { MouseEvent, useEffect } from 'react'; import { Flex, Group } from '@mantine/core'; import { useHotkeys, useMediaQuery } from '@mantine/hooks'; +import isElectron from 'is-electron'; import { HiOutlineQueueList } from 'react-icons/hi2'; import { RiVolumeUpFill, @@ -20,11 +21,14 @@ import { } from '/@/renderer/store'; import { useRightControls } from '../hooks/use-right-controls'; 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 { Rating } from '/@/renderer/components'; 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 = () => { const isMinWidth = useMediaQuery('(max-width: 480px)'); const volume = useVolume(); @@ -113,6 +117,44 @@ export const RightControls = () => { [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 ( { @@ -87,12 +88,12 @@ export const useCenterControls = (args: { playersRef: any }) => { const playStatus = status || usePlayerStore.getState().current.status; const track = song || usePlayerStore.getState().current.song; - mpris?.updateSong({ + remote?.updateSong({ currentTime: time, repeat: usePlayerStore.getState().repeat, - shuffle: usePlayerStore.getState().shuffle, + shuffle: usePlayerStore.getState().shuffle !== PlayerShuffle.NONE, song: track, - status: playStatus === PlayerStatus.PLAYING ? 'Playing' : 'Paused', + status: playStatus, }); if (mediaSession) { @@ -133,7 +134,7 @@ export const useCenterControls = (args: { playersRef: any }) => { if (isMpvPlayer) { mpvPlayer?.volume(usePlayerStore.getState().volume); - mpvPlayer.play(); + mpvPlayer!.play(); } else { currentPlayerRef.getInternalPlayer().play(); } @@ -145,7 +146,7 @@ export const useCenterControls = (args: { playersRef: any }) => { mprisUpdateSong({ status: PlayerStatus.PAUSED }); if (isMpvPlayer) { - mpvPlayer.pause(); + mpvPlayer!.pause(); } pause(); @@ -155,8 +156,8 @@ export const useCenterControls = (args: { playersRef: any }) => { mprisUpdateSong({ status: PlayerStatus.PAUSED }); if (isMpvPlayer) { - mpvPlayer.pause(); - mpvPlayer.seekTo(0); + mpvPlayer!.pause(); + mpvPlayer!.seekTo(0); } else { stopPlayback(); } @@ -168,29 +169,29 @@ export const useCenterControls = (args: { playersRef: any }) => { const handleToggleShuffle = useCallback(() => { if (shuffleStatus === PlayerShuffle.NONE) { const playerData = setShuffle(PlayerShuffle.TRACK); - mpris?.updateShuffle(true); - return mpvPlayer.setQueueNext(playerData); + remote?.updateShuffle(true); + return mpvPlayer?.setQueueNext(playerData); } const playerData = setShuffle(PlayerShuffle.NONE); - mpris?.updateShuffle(false); - return mpvPlayer.setQueueNext(playerData); + remote?.updateShuffle(false); + return mpvPlayer?.setQueueNext(playerData); }, [setShuffle, shuffleStatus]); const handleToggleRepeat = useCallback(() => { if (repeatStatus === PlayerRepeat.NONE) { const playerData = setRepeat(PlayerRepeat.ALL); - mpris?.updateRepeat('Playlist'); - return mpvPlayer.setQueueNext(playerData); + remote?.updateRepeat(PlayerRepeat.ALL); + return mpvPlayer?.setQueueNext(playerData); } if (repeatStatus === PlayerRepeat.ALL) { const playerData = setRepeat(PlayerRepeat.ONE); - mpris?.updateRepeat('Track'); - return mpvPlayer.setQueueNext(playerData); + remote?.updateRepeat(PlayerRepeat.ONE); + return mpvPlayer?.setQueueNext(playerData); } - mpris?.updateRepeat('None'); + remote?.updateRepeat(PlayerRepeat.NONE); return setRepeat(PlayerRepeat.NONE); }, [repeatStatus, setRepeat]); @@ -209,7 +210,7 @@ export const useCenterControls = (args: { playersRef: any }) => { local: () => { const playerData = autoNext(); mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING }); - mpvPlayer.autoNext(playerData); + mpvPlayer!.autoNext(playerData); play(); }, web: () => { @@ -223,7 +224,7 @@ export const useCenterControls = (args: { playersRef: any }) => { if (isLastTrack) { const playerData = setCurrentIndex(0); mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PAUSED }); - mpvPlayer.setQueue(playerData, true); + mpvPlayer!.setQueue(playerData, true); pause(); } else { const playerData = autoNext(); @@ -231,7 +232,7 @@ export const useCenterControls = (args: { playersRef: any }) => { song: playerData.current.song, status: PlayerStatus.PLAYING, }); - mpvPlayer.autoNext(playerData); + mpvPlayer!.autoNext(playerData); play(); } }, @@ -255,7 +256,7 @@ export const useCenterControls = (args: { playersRef: any }) => { local: () => { const playerData = autoNext(); mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING }); - mpvPlayer.autoNext(playerData); + mpvPlayer!.autoNext(playerData); play(); }, web: () => { @@ -306,8 +307,8 @@ export const useCenterControls = (args: { playersRef: any }) => { local: () => { const playerData = next(); mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING }); - mpvPlayer.setQueue(playerData); - mpvPlayer.next(); + mpvPlayer!.setQueue(playerData); + mpvPlayer!.next(); }, web: () => { const playerData = next(); @@ -320,8 +321,8 @@ export const useCenterControls = (args: { playersRef: any }) => { if (isLastTrack) { const playerData = setCurrentIndex(0); mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PAUSED }); - mpvPlayer.setQueue(playerData); - mpvPlayer.pause(); + mpvPlayer!.setQueue(playerData); + mpvPlayer!.pause(); pause(); } else { const playerData = next(); @@ -329,8 +330,8 @@ export const useCenterControls = (args: { playersRef: any }) => { song: playerData.current.song, status: PlayerStatus.PLAYING, }); - mpvPlayer.setQueue(playerData); - mpvPlayer.next(); + mpvPlayer!.setQueue(playerData); + mpvPlayer!.next(); } }, web: () => { @@ -357,8 +358,8 @@ export const useCenterControls = (args: { playersRef: any }) => { local: () => { const playerData = next(); mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING }); - mpvPlayer.setQueue(playerData); - mpvPlayer.next(); + mpvPlayer!.setQueue(playerData); + mpvPlayer!.next(); }, web: () => { if (!isLastTrack) { @@ -409,7 +410,7 @@ export const useCenterControls = (args: { playersRef: any }) => { handleScrobbleFromSongRestart(currentTime); mpris?.updateSeek(0); if (isMpvPlayer) { - return mpvPlayer.seekTo(0); + return mpvPlayer!.seekTo(0); } return currentPlayerRef.seekTo(0); } @@ -424,16 +425,16 @@ export const useCenterControls = (args: { playersRef: any }) => { song: playerData.current.song, status: PlayerStatus.PLAYING, }); - mpvPlayer.setQueue(playerData); - mpvPlayer.previous(); + mpvPlayer!.setQueue(playerData); + mpvPlayer!.previous(); } else { const playerData = setCurrentIndex(queue.length - 1); mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING, }); - mpvPlayer.setQueue(playerData); - mpvPlayer.previous(); + mpvPlayer!.setQueue(playerData); + mpvPlayer!.previous(); } }, web: () => { @@ -458,12 +459,12 @@ export const useCenterControls = (args: { playersRef: any }) => { const handleRepeatNone = { local: () => { const playerData = previous(); - mpris?.updateSong({ + remote?.updateSong({ currentTime: usePlayerStore.getState().current.time, song: playerData.current.song, }); - mpvPlayer.setQueue(playerData); - mpvPlayer.previous(); + mpvPlayer!.setQueue(playerData); + mpvPlayer!.previous(); }, web: () => { if (isFirstTrack) { @@ -489,10 +490,10 @@ export const useCenterControls = (args: { playersRef: any }) => { song: playerData.current.song, status: PlayerStatus.PLAYING, }); - mpvPlayer.setQueue(playerData); - mpvPlayer.previous(); + mpvPlayer!.setQueue(playerData); + mpvPlayer!.previous(); } else { - mpvPlayer.stop(); + mpvPlayer!.stop(); } }, web: () => { @@ -556,7 +557,7 @@ export const useCenterControls = (args: { playersRef: any }) => { mpris?.updateSeek(newTime); if (isMpvPlayer) { - mpvPlayer.seek(-seconds); + mpvPlayer!.seek(-seconds); } else { resetNextPlayer(); currentPlayerRef.seekTo(newTime); @@ -570,7 +571,7 @@ export const useCenterControls = (args: { playersRef: any }) => { if (isMpvPlayer) { const newTime = currentTime + seconds; - mpvPlayer.seek(seconds); + mpvPlayer!.seek(seconds); mpris?.updateSeek(newTime); setCurrentTime(newTime, true); } else { @@ -588,7 +589,7 @@ export const useCenterControls = (args: { playersRef: any }) => { const debouncedSeek = debounce((e: number) => { if (isMpvPlayer) { - mpvPlayer.seekTo(e); + mpvPlayer!.seekTo(e); } else { currentPlayerRef.seekTo(e); } @@ -606,20 +607,20 @@ export const useCenterControls = (args: { playersRef: any }) => { ); const handleQuit = useCallback(() => { - mpvPlayer.quit(); + mpvPlayer!.quit(); }, []); const handleError = useCallback( (message: string) => { toast.error({ id: 'mpv-error', message, title: 'An error occurred during playback' }); pause(); - mpvPlayer.pause(); + mpvPlayer!.pause(); }, [pause], ); useEffect(() => { - if (isElectron()) { + if (mpvPlayerListener) { mpvPlayerListener.rendererPlayPause(() => { handlePlayPause(); }); @@ -769,12 +770,39 @@ export const useCenterControls = (args: { playersRef: any }) => { useEffect(() => { 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; handleSeekSlider(newTime); }); - mpris.requestSeek((_e: any, data: { offset: number }) => { + remote.requestSeek((_e: any, data: { offset: number }) => { const currentTime = usePlayerStore.getState().current.time; const currentSongDuration = usePlayerStore.getState().current.song?.duration || 0; const resultingTime = currentTime + data.offset; @@ -791,50 +819,23 @@ export const useCenterControls = (args: { playersRef: any }) => { handleSeekSlider(newTime); }); - mpris.requestVolume((_e: any, data: { volume: number }) => { - let newVolume = Math.round(data.volume * 100); - - if (newVolume > 100) { - newVolume = 100; - } else if (newVolume < 0) { - newVolume = 0; - } - - usePlayerStore.getState().actions.setVolume(newVolume); - mpris.updateVolume(data.volume); + remote.requestVolume((_e: any, data: { volume: number }) => { + usePlayerStore.getState().actions.setVolume(data.volume); 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 () => { - ipc?.removeAllListeners('mpris-request-position'); - ipc?.removeAllListeners('mpris-request-seek'); - ipc?.removeAllListeners('mpris-request-volume'); - ipc?.removeAllListeners('mpris-request-toggle-repeat'); - ipc?.removeAllListeners('mpris-request-toggle-shuffle'); + ipc?.removeAllListeners('request-position'); + ipc?.removeAllListeners('request-seek'); + ipc?.removeAllListeners('request-volume'); }; } return () => {}; - }, [handleSeekSlider, isMpvPlayer]); + }); return { handleNextTrack, diff --git a/src/renderer/features/player/hooks/use-handle-playqueue-add.ts b/src/renderer/features/player/hooks/use-handle-playqueue-add.ts index 1adfe66e..9423f282 100644 --- a/src/renderer/features/player/hooks/use-handle-playqueue-add.ts +++ b/src/renderer/features/player/hooks/use-handle-playqueue-add.ts @@ -2,7 +2,13 @@ import { useCallback, useRef } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useCurrentServer, usePlayerControls, usePlayerStore } from '/@/renderer/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 isElectron from 'is-electron'; import { nanoid } from 'nanoid/non-secure'; @@ -47,8 +53,7 @@ const getRootQueryKey = (itemType: LibraryItem, serverId: string) => { }; const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; -const utils = isElectron() ? window.electron.utils : null; -const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null; +const remote = isElectron() ? window.electron.remote : null; const addToQueue = usePlayerStore.getState().actions.addToQueue; @@ -154,26 +159,26 @@ export const useHandlePlayQueueAdd = () => { const playerData = addToQueue({ initialIndex: initialSongIndex, playType, songs }); if (playerType === PlaybackType.LOCAL) { - mpvPlayer?.volume(usePlayerStore.getState().volume); + mpvPlayer!.volume(usePlayerStore.getState().volume); if (playType === Play.NEXT || playType === Play.LAST) { - mpvPlayer?.setQueueNext(playerData); + mpvPlayer!.setQueueNext(playerData); } if (playType === Play.NOW) { - mpvPlayer?.setQueue(playerData); - mpvPlayer?.play(); + mpvPlayer!.setQueue(playerData); + mpvPlayer!.play(); } } play(); - mpris?.updateSong({ + remote?.updateSong({ currentTime: usePlayerStore.getState().current.time, repeat: usePlayerStore.getState().repeat, - shuffle: usePlayerStore.getState().shuffle, + shuffle: usePlayerStore.getState().shuffle !== PlayerShuffle.NONE, song: playerData.current.song, - status: 'Playing', + status: PlayerStatus.PLAYING, }); return null; diff --git a/src/renderer/features/player/hooks/use-right-controls.ts b/src/renderer/features/player/hooks/use-right-controls.ts index 4b4e944f..6e33ae2c 100644 --- a/src/renderer/features/player/hooks/use-right-controls.ts +++ b/src/renderer/features/player/hooks/use-right-controls.ts @@ -6,8 +6,7 @@ import { useGeneralSettings } from '/@/renderer/store/settings.store'; const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null; const ipc = isElectron() ? window.electron.ipc : null; -const utils = isElectron() ? window.electron.utils : null; -const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null; +const remote = isElectron() ? window.electron.remote : null; const calculateVolumeUp = (volume: number, volumeWheelStep: number) => { let volumeToSet; @@ -41,12 +40,13 @@ export const useRightControls = () => { // Ensure that the mpv player volume is set on startup useEffect(() => { - if (isElectron()) { + remote?.updateVolume(volume); + + if (mpvPlayer) { mpvPlayer.volume(volume); - mpris?.updateVolume(volume / 100); if (muted) { - mpvPlayer.mute(); + mpvPlayer.mute(muted); } } @@ -55,26 +55,26 @@ export const useRightControls = () => { const handleVolumeSlider = (e: number) => { mpvPlayer?.volume(e); - mpris?.updateVolume(e / 100); + remote?.updateVolume(e); setVolume(e); }; const handleVolumeSliderState = (e: number) => { - mpris?.updateVolume(e / 100); + remote?.updateVolume(e); setVolume(e); }; const handleVolumeDown = useCallback(() => { const volumeToSet = calculateVolumeDown(volume, volumeWheelStep); mpvPlayer?.volume(volumeToSet); - mpris?.updateVolume(volumeToSet / 100); + remote?.updateVolume(volumeToSet); setVolume(volumeToSet); }, [setVolume, volume, volumeWheelStep]); const handleVolumeUp = useCallback(() => { const volumeToSet = calculateVolumeUp(volume, volumeWheelStep); mpvPlayer?.volume(volumeToSet); - mpris?.updateVolume(volumeToSet / 100); + remote?.updateVolume(volumeToSet); setVolume(volumeToSet); }, [setVolume, volume, volumeWheelStep]); @@ -88,7 +88,7 @@ export const useRightControls = () => { } mpvPlayer?.volume(volumeToSet); - mpris?.updateVolume(volumeToSet / 100); + remote?.updateVolume(volumeToSet); setVolume(volumeToSet); }, [setVolume, volume, volumeWheelStep], @@ -96,7 +96,7 @@ export const useRightControls = () => { const handleMute = useCallback(() => { setMuted(!muted); - mpvPlayer?.mute(); + mpvPlayer?.mute(!muted); }, [muted, setMuted]); useEffect(() => { diff --git a/src/renderer/features/servers/components/edit-server-form.tsx b/src/renderer/features/servers/components/edit-server-form.tsx index fece0ee6..2887e438 100644 --- a/src/renderer/features/servers/components/edit-server-form.tsx +++ b/src/renderer/features/servers/components/edit-server-form.tsx @@ -16,7 +16,7 @@ const localSettings = isElectron() ? window.electron.localSettings : null; interface EditServerFormProps { isUpdate?: boolean; onCancel: () => void; - password?: string; + password: string | null; server: ServerListItem; } diff --git a/src/renderer/features/settings/components/general/application-settings.tsx b/src/renderer/features/settings/components/general/application-settings.tsx index 2c6d6b93..f0b7c2aa 100644 --- a/src/renderer/features/settings/components/general/application-settings.tsx +++ b/src/renderer/features/settings/components/general/application-settings.tsx @@ -74,7 +74,7 @@ export const ApplicationSettings = () => { zoomFactor: newVal, }, }); - localSettings.setZoomFactor(newVal); + localSettings!.setZoomFactor(newVal); }} /> ), diff --git a/src/renderer/features/settings/components/general/general-tab.tsx b/src/renderer/features/settings/components/general/general-tab.tsx index 19bffa02..748648b7 100644 --- a/src/renderer/features/settings/components/general/general-tab.tsx +++ b/src/renderer/features/settings/components/general/general-tab.tsx @@ -3,6 +3,7 @@ import { ApplicationSettings } from '/@/renderer/features/settings/components/ge import { ControlSettings } from '/@/renderer/features/settings/components/general/control-settings'; import { SidebarSettings } from '/@/renderer/features/settings/components/general/sidebar-settings'; import { ThemeSettings } from '/@/renderer/features/settings/components/general/theme-settings'; +import { RemoteSettings } from '/@/renderer/features/settings/components/general/remote-settings'; export const GeneralTab = () => { return ( @@ -14,6 +15,8 @@ export const GeneralTab = () => { + + ); }; diff --git a/src/renderer/features/settings/components/general/remote-settings.tsx b/src/renderer/features/settings/components/general/remote-settings.tsx new file mode 100644 index 00000000..9844d61f --- /dev/null +++ b/src/renderer/features/settings/components/general/remote-settings.tsx @@ -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: ( + { + const enabled = e.currentTarget.checked; + await debouncedEnableRemote(enabled); + }} + /> + ), + description: ( +
+ Start an HTTP server to remotely control Feishin. This will listen on{' '} + + {url} + +
+ ), + isHidden, + title: 'Enable remote control', + }, + { + control: ( + { + 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: ( + { + 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: ( + { + 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 ( + <> + + + + 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 + + + + ); +}; diff --git a/src/renderer/features/settings/components/hotkeys/window-hotkey-settings.tsx b/src/renderer/features/settings/components/hotkeys/window-hotkey-settings.tsx index bda12292..00d23ed8 100644 --- a/src/renderer/features/settings/components/hotkeys/window-hotkey-settings.tsx +++ b/src/renderer/features/settings/components/hotkeys/window-hotkey-settings.tsx @@ -23,12 +23,12 @@ export const WindowHotkeySettings = () => { globalMediaHotkeys: e.currentTarget.checked, }, }); - localSettings.set('global_media_hotkeys', e.currentTarget.checked); + localSettings!.set('global_media_hotkeys', e.currentTarget.checked); if (e.currentTarget.checked) { - localSettings.enableMediaKeys(); + localSettings!.enableMediaKeys(); } else { - localSettings.disableMediaKeys(); + localSettings!.disableMediaKeys(); } }} /> diff --git a/src/renderer/features/settings/components/playback/audio-settings.tsx b/src/renderer/features/settings/components/playback/audio-settings.tsx index 0bbd0605..7d946761 100644 --- a/src/renderer/features/settings/components/playback/audio-settings.tsx +++ b/src/renderer/features/settings/components/playback/audio-settings.tsx @@ -56,7 +56,7 @@ export const AudioSettings = () => { setSettings({ playback: { ...settings, type: e as PlaybackType } }); if (isElectron() && e === PlaybackType.LOCAL) { const queueData = usePlayerStore.getState().actions.getPlayerData(); - mpvPlayer.setQueue(queueData); + mpvPlayer!.setQueue(queueData); } }} /> diff --git a/src/renderer/features/settings/components/playback/mpv-settings.tsx b/src/renderer/features/settings/components/playback/mpv-settings.tsx index 725d50f5..913a6d7d 100644 --- a/src/renderer/features/settings/components/playback/mpv-settings.tsx +++ b/src/renderer/features/settings/components/playback/mpv-settings.tsx @@ -36,7 +36,7 @@ export const getMpvSetting = ( case 'replayGainPreampDB': return { 'replaygain-preamp': value || 0 }; default: - return key; + return { 'audio-format': value }; } }; @@ -66,12 +66,12 @@ export const MpvSettings = () => { const [mpvPath, setMpvPath] = useState(''); const handleSetMpvPath = (e: File) => { - localSettings.set('mpv_path', e.path); + localSettings?.set('mpv_path', e.path); }; useEffect(() => { const getMpvPath = async () => { - if (!isElectron()) return setMpvPath(''); + if (!localSettings) return setMpvPath(''); const mpvPath = (await localSettings.get('mpv_path')) as string; return setMpvPath(mpvPath); }; diff --git a/src/renderer/features/settings/components/window/window-settings.tsx b/src/renderer/features/settings/components/window/window-settings.tsx index 9a71109a..0dcfa88f 100644 --- a/src/renderer/features/settings/components/window/window-settings.tsx +++ b/src/renderer/features/settings/components/window/window-settings.tsx @@ -46,7 +46,7 @@ export const WindowSettings = () => { message: 'Restart to apply changes... close the notification to restart Feishin', onClose: () => { - window.electron.ipc.send('app-restart'); + window.electron.ipc!.send('app-restart'); }, title: 'Restart required', }); diff --git a/src/renderer/features/shared/mutations/create-favorite-mutation.ts b/src/renderer/features/shared/mutations/create-favorite-mutation.ts index a518f6dd..7364c630 100644 --- a/src/renderer/features/shared/mutations/create-favorite-mutation.ts +++ b/src/renderer/features/shared/mutations/create-favorite-mutation.ts @@ -11,6 +11,9 @@ import { } from '/@/renderer/api/types'; import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { getServerById, useSetAlbumListItemDataById, useSetQueueFavorite } from '/@/renderer/store'; +import isElectron from 'is-electron'; + +const remote = isElectron() ? window.electron.remote : null; export const useCreateFavorite = (args: MutationHookArgs) => { const { options } = args || {}; @@ -42,6 +45,7 @@ export const useCreateFavorite = (args: MutationHookArgs) => { } if (variables.query.type === LibraryItem.SONG) { + remote?.updateFavorite(true, serverId, variables.query.id); setQueueFavorite(variables.query.id, true); } diff --git a/src/renderer/features/shared/mutations/delete-favorite-mutation.ts b/src/renderer/features/shared/mutations/delete-favorite-mutation.ts index 5f44fa1d..bb80abb8 100644 --- a/src/renderer/features/shared/mutations/delete-favorite-mutation.ts +++ b/src/renderer/features/shared/mutations/delete-favorite-mutation.ts @@ -11,6 +11,9 @@ import { } from '/@/renderer/api/types'; import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { getServerById, useSetAlbumListItemDataById, useSetQueueFavorite } from '/@/renderer/store'; +import isElectron from 'is-electron'; + +const remote = isElectron() ? window.electron.remote : null; export const useDeleteFavorite = (args: MutationHookArgs) => { const { options } = args || {}; @@ -42,6 +45,7 @@ export const useDeleteFavorite = (args: MutationHookArgs) => { } if (variables.query.type === LibraryItem.SONG) { + remote?.updateFavorite(false, serverId, variables.query.id); setQueueFavorite(variables.query.id, false); } diff --git a/src/renderer/features/shared/mutations/set-rating-mutation.ts b/src/renderer/features/shared/mutations/set-rating-mutation.ts index c83d494c..62423089 100644 --- a/src/renderer/features/shared/mutations/set-rating-mutation.ts +++ b/src/renderer/features/shared/mutations/set-rating-mutation.ts @@ -14,6 +14,9 @@ import { } from '/@/renderer/api/types'; import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { getServerById, useSetAlbumListItemDataById, useSetQueueRating } from '/@/renderer/store'; +import isElectron from 'is-electron'; + +const remote = isElectron() ? window.electron.remote : null; export const useSetRating = (args: MutationHookArgs) => { 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 } }; }, onSuccess: (_data, variables) => { diff --git a/src/renderer/features/titlebar/components/app-menu.tsx b/src/renderer/features/titlebar/components/app-menu.tsx index 3ce8d87f..6f445bc1 100644 --- a/src/renderer/features/titlebar/components/app-menu.tsx +++ b/src/renderer/features/titlebar/components/app-menu.tsx @@ -47,7 +47,7 @@ export const AppMenu = () => { }; const handleCredentialsModal = async (server: ServerListItem) => { - let password: string | undefined; + let password: string | null = null; try { if (localSettings && server.savePassword) { diff --git a/src/renderer/preload.d.ts b/src/renderer/preload.d.ts index 72b7ddba..5f0ef1c1 100644 --- a/src/renderer/preload.d.ts +++ b/src/renderer/preload.d.ts @@ -1,12 +1,19 @@ import { IpcRendererEvent } from 'electron'; import { PlayerData, PlayerState } from './store'; 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 { interface Window { electron: { browser: any; - ipc: any; + ipc?: Ipc; ipcRenderer: { APP_RESTART(): void; LYRIC_FETCH(data: QueueSong): void; @@ -37,6 +44,8 @@ declare global { PLAYER_SET_QUEUE_NEXT(data: PlayerData): void; PLAYER_STOP(): void; PLAYER_VOLUME(value: number): void; + REMOTE_ENABLE(enabled: boolean): Promise; + REMOTE_PORT(port: number): Promise; RENDERER_PLAYER_AUTO_NEXT(cb: (event: IpcRendererEvent, data: any) => void): void; RENDERER_PLAYER_CURRENT_TIME( cb: (event: IpcRendererEvent, data: any) => void, @@ -59,12 +68,13 @@ declare global { windowMinimize(): void; windowUnmaximize(): void; }; - localSettings: any; - lyrics: any; - mpris: any; - mpvPlayer: any; - mpvPlayerListener: any; - utils: any; + localSettings: LocalSettings; + lyrics?: Lyrics; + mpris?: Mpris; + mpvPlayer?: MpvPLayer; + mpvPlayerListener?: MpvPlayerListener; + remote?: Remote; + utils?: Utils; }; } } diff --git a/src/renderer/router/app-outlet.tsx b/src/renderer/router/app-outlet.tsx index 7984fd94..6bd6daf4 100644 --- a/src/renderer/router/app-outlet.tsx +++ b/src/renderer/router/app-outlet.tsx @@ -11,7 +11,7 @@ export const AppOutlet = () => { const isActionsRequired = useMemo(() => { const isMpvRequired = () => { - if (!isElectron()) return false; + if (!localSettings) return false; const mpvPath = localSettings.get('mpv_path'); if (mpvPath) return false; return true; diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 56f254b2..7115c593 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -20,6 +20,7 @@ import { TableType, Platform, } from '/@/renderer/types'; +import { randomString } from '/@/renderer/utils'; const utils = isElectron() ? window.electron.utils : null; @@ -151,6 +152,12 @@ export interface SettingsState { style: PlaybackStyle; type: PlaybackType; }; + remote: { + enabled: boolean; + password: string; + port: number; + username: string; + }; tab: 'general' | 'playback' | 'window' | 'hotkeys' | string; tables: { fullScreen: DataTableProps; @@ -177,7 +184,7 @@ export interface SettingsSlice extends SettingsState { // Determines the default/initial windowBarStyle value based on the current 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(); @@ -258,6 +265,12 @@ const initialState: SettingsState = { style: PlaybackStyle.GAPLESS, type: PlaybackType.LOCAL, }, + remote: { + enabled: false, + password: randomString(8), + port: 4333, + username: 'feishin', + }, tab: 'general', tables: { fullScreen: { @@ -450,3 +463,5 @@ export const useMpvSettings = () => useSettingsStore((state) => state.playback.mpvProperties, shallow); export const useLyricsSettings = () => useSettingsStore((state) => state.lyrics, shallow); + +export const useRemoteSettings = () => useSettingsStore((state) => state.remote, shallow); diff --git a/src/renderer/types.ts b/src/renderer/types.ts index cf0263ec..a7c71fd3 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -187,3 +187,13 @@ export type GridCardData = { resetInfiniteLoaderCache: () => void; route: CardRoute; }; + +export type SongUpdate = { + currentTime?: number; + repeat?: PlayerRepeat; + shuffle?: boolean; + song?: QueueSong; + status?: PlayerStatus; + /** This volume is in range 0-100 */ + volume?: number; +};