Add remote control (#164)

* draft add remotes

* add favorite, rating

* add basic auth
This commit is contained in:
Kendall Garner 2023-07-23 12:23:18 +00:00 committed by GitHub
parent 0a13d047bb
commit c9dbf9b5be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
66 changed files with 2585 additions and 298 deletions

View File

@ -0,0 +1,119 @@
import 'webpack-dev-server';
import path from 'path';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import webpack from 'webpack';
import { merge } from 'webpack-merge';
import checkNodeEnv from '../scripts/check-node-env';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
// at the dev webpack config is not accidentally run in a production environment
if (process.env.NODE_ENV === 'production') {
checkNodeEnv('development');
}
const port = process.env.PORT || 4343;
const configuration: webpack.Configuration = {
devtool: 'inline-source-map',
mode: 'development',
target: ['web'],
entry: [path.join(webpackPaths.srcRemotePath, 'index.tsx')],
output: {
path: webpackPaths.dllPath,
publicPath: '/',
filename: 'remote.js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?css$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
],
},
plugins: [
new webpack.NoEmitOnErrorsPlugin(),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*
* By default, use 'development' as NODE_ENV. This can be overriden with
* 'staging', for example, by changing the ENV variables in the npm scripts
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
}),
new HtmlWebpackPlugin({
filename: path.join('index.html'),
template: path.join(webpackPaths.srcRemotePath, 'index.ejs'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: true,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
}),
],
node: {
__dirname: false,
__filename: false,
},
watch: true,
};
export default merge(baseConfig, configuration);

View File

@ -0,0 +1,131 @@
/**
* Build config for electron renderer process
*/
import path from 'path';
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import TerserPlugin from 'terser-webpack-plugin';
import webpack from 'webpack';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import { merge } from 'webpack-merge';
import checkNodeEnv from '../scripts/check-node-env';
import deleteSourceMaps from '../scripts/delete-source-maps';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
checkNodeEnv('production');
deleteSourceMaps();
const devtoolsConfig =
process.env.DEBUG_PROD === 'true'
? {
devtool: 'source-map',
}
: {};
const configuration: webpack.Configuration = {
...devtoolsConfig,
mode: 'production',
target: ['web'],
entry: [path.join(webpackPaths.srcRemotePath, 'index.tsx')],
output: {
path: webpackPaths.distRemotePath,
publicPath: './',
filename: 'remote.js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?(a|c)ss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?(a|c)ss$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
],
},
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
}),
new CssMinimizerPlugin(),
],
},
plugins: [
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
DEBUG_PROD: false,
}),
new MiniCssExtractPlugin({
filename: 'remote.css',
}),
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
}),
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
isDevelopment: process.env.NODE_ENV !== 'production',
}),
],
};
export default merge(baseConfig, configuration);

View File

@ -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));

View File

@ -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,
};

View File

@ -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"',
),
);
}

View File

@ -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'));
}

View File

@ -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",

View File

@ -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",

View File

@ -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"

View File

@ -1,3 +1,4 @@
import './lyrics';
import './player';
import './remote';
import './settings';

View File

@ -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);
});

View File

@ -0,0 +1,625 @@
import { Stats, promises } from 'fs';
import { readFile } from 'fs/promises';
import { IncomingMessage, Server, ServerResponse, createServer } from 'http';
import { join } from 'path';
import { deflate, gzip } from 'zlib';
import axios from 'axios';
import { app, ipcMain } from 'electron';
import { Server as WsServer, WebSocketServer, WebSocket } from 'ws';
import { ClientEvent, ServerEvent } from '../../../../remote/types';
import { PlayerRepeat, SongUpdate } from '../../../../renderer/types';
import { getMainWindow } from '../../../main';
import { isLinux } from '../../../utils';
let mprisPlayer: any | undefined;
if (isLinux()) {
// eslint-disable-next-line global-require
mprisPlayer = require('../../linux/mpris').mprisPlayer;
}
interface RemoteConfig {
enabled: boolean;
password: string;
port: number;
username: string;
}
interface MimeType {
css: string;
html: string;
ico: string;
js: string;
}
interface StatefulWebSocket extends WebSocket {
alive: boolean;
}
let server: Server | undefined;
let wsServer: WsServer<StatefulWebSocket> | undefined;
const settings: RemoteConfig = {
enabled: false,
password: '',
port: 4333,
username: '',
};
type SendData = ServerEvent & {
client: StatefulWebSocket;
};
function send({ client, event, data }: SendData): void {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ data, event }));
}
}
function broadcast(message: ServerEvent): void {
if (wsServer) {
for (const client of wsServer.clients) {
send({ client, ...message });
}
}
}
const shutdownServer = () => {
if (wsServer) {
wsServer.clients.forEach((client) => client.close(4000));
wsServer.close();
wsServer = undefined;
}
if (server) {
server.close();
server = undefined;
}
};
const MIME_TYPES: MimeType = {
css: 'text/css',
html: 'text/html; charset=UTF-8',
ico: 'image/x-icon',
js: 'application/javascript',
};
const PING_TIMEOUT_MS = 10000;
const UP_TIMEOUT_MS = 5000;
enum Encoding {
GZIP = 'gzip',
NONE = 'none',
ZLIB = 'deflate',
}
const GZIP_REGEX = /\bgzip\b/;
const ZLIB_REGEX = /bdeflate\b/;
let currentSong: SongUpdate = {
currentTime: 0,
};
const getEncoding = (encoding: string | string[]): Encoding => {
const encodingArray = Array.isArray(encoding) ? encoding : [encoding];
for (const code of encodingArray) {
if (code.match(GZIP_REGEX)) {
return Encoding.GZIP;
}
if (code.match(ZLIB_REGEX)) {
return Encoding.ZLIB;
}
}
return Encoding.NONE;
};
const cache = new Map<string, Map<Encoding, [number, Buffer]>>();
function setOk(
res: ServerResponse,
mtimeMs: number,
extension: keyof MimeType,
encoding: Encoding,
data?: Buffer,
) {
res.statusCode = data ? 200 : 304;
res.setHeader('Content-Type', MIME_TYPES[extension]);
res.setHeader('ETag', `"${mtimeMs}"`);
res.setHeader('Cache-Control', 'public');
if (encoding !== 'none') res.setHeader('Content-Encoding', encoding);
res.end(data);
}
async function serveFile(
req: IncomingMessage,
file: string,
extension: keyof MimeType,
res: ServerResponse,
): Promise<void> {
const fileName = `${file}.${extension}`;
let path: string;
if (extension === 'ico') {
path = app.isPackaged
? join(process.resourcesPath, 'assets', fileName)
: join(__dirname, '../../../../../assets', fileName);
} else {
path = app.isPackaged
? join(__dirname, '../remote', fileName)
: join(__dirname, '../../../../../.erb/dll', fileName);
}
let stats: Stats;
try {
stats = await promises.stat(path);
} catch (error) {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end((error as Error).message);
// This is a resolve, even though it is an error, because we want specific (non 500) status
return Promise.resolve();
}
const encodings = req.headers['accept-encoding'] ?? '';
const selectedEncoding = getEncoding(encodings);
const ifMatch = req.headers['if-none-match'];
const fileInfo = cache.get(fileName);
let cached = fileInfo?.get(selectedEncoding);
if (cached && cached[0] !== stats.mtimeMs) {
cache.get(fileName)!.delete(selectedEncoding);
cached = undefined;
}
if (ifMatch && cached) {
const options = ifMatch.split(',');
for (const option of options) {
const mTime = Number(option.replaceAll('"', '').trim());
if (cached[0] === mTime) {
setOk(res, cached[0], extension, selectedEncoding);
return Promise.resolve();
}
}
}
if (!cached || cached[0] !== stats.mtimeMs) {
const content = await readFile(path);
switch (selectedEncoding) {
case Encoding.GZIP:
return new Promise((resolve, reject) => {
gzip(content, (error, result) => {
if (error) {
reject(error);
return;
}
const newEntry: [number, Buffer] = [stats.mtimeMs, result];
if (fileInfo) {
fileInfo.set(selectedEncoding, newEntry);
} else {
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
}
setOk(res, stats.mtimeMs, extension, selectedEncoding, result);
resolve();
});
});
case Encoding.ZLIB:
return new Promise((resolve, reject) => {
deflate(content, (error, result) => {
if (error) {
reject(error);
return;
}
const newEntry: [number, Buffer] = [stats.mtimeMs, result];
if (fileInfo) {
fileInfo.set(selectedEncoding, newEntry);
} else {
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
}
setOk(res, stats.mtimeMs, extension, selectedEncoding, result);
resolve();
});
});
default: {
const newEntry: [number, Buffer] = [stats.mtimeMs, content];
if (fileInfo) {
fileInfo.set(selectedEncoding, newEntry);
} else {
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
}
setOk(res, stats.mtimeMs, extension, selectedEncoding, content);
return Promise.resolve();
}
}
}
setOk(res, cached[0], extension, selectedEncoding, cached[1]);
return Promise.resolve();
}
function authorize(req: IncomingMessage): boolean {
if (settings.username || settings.password) {
// https://stackoverflow.com/questions/23616371/basic-http-authentication-with-node-and-express-4
const authorization = req.headers.authorization?.split(' ')[1] || '';
const [login, password] = Buffer.from(authorization, 'base64').toString().split(':');
return login === settings.username && password === settings.password;
}
return true;
}
const enableServer = (config: RemoteConfig): Promise<void> => {
return new Promise<void>((resolve, reject) => {
try {
if (server) {
server.close();
}
server = createServer({}, async (req, res) => {
if (!authorize(req)) {
res.statusCode = 401;
res.setHeader('WWW-Authenticate', 'Basic realm="401"');
res.end('Authorization required');
return;
}
try {
switch (req.url) {
case '/': {
await serveFile(req, 'index', 'html', res);
break;
}
case '/favicon.ico': {
await serveFile(req, 'icon', 'ico', res);
break;
}
case '/remote.css': {
await serveFile(req, 'remote', 'css', res);
break;
}
case '/remote.js': {
await serveFile(req, 'remote', 'js', res);
break;
}
default: {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end('Not FOund');
}
}
} catch (error) {
res.statusCode = 500;
res.setHeader('Content-Type', 'text/plain');
res.end((error as Error).message);
}
});
server.listen(config.port, resolve);
wsServer = new WebSocketServer({ server });
wsServer.on('connection', (ws, req) => {
if (!authorize(req)) {
ws.close(4003);
return;
}
ws.alive = true;
ws.on('error', console.error);
ws.on('message', (data) => {
try {
const json = JSON.parse(data.toString()) as ClientEvent;
const event = json.event;
switch (event) {
case 'pause': {
getMainWindow()?.webContents.send('renderer-player-pause');
break;
}
case 'play': {
getMainWindow()?.webContents.send('renderer-player-play');
break;
}
case 'next': {
getMainWindow()?.webContents.send('renderer-player-next');
break;
}
case 'previous': {
getMainWindow()?.webContents.send('renderer-player-previous');
break;
}
case 'proxy': {
const toFetch = currentSong.song?.imageUrl?.replaceAll(
/&(size|width|height=\d+)/g,
'',
);
if (!toFetch) return;
axios
.get(toFetch, { responseType: 'arraybuffer' })
.then((resp) => {
if (ws.readyState === WebSocket.OPEN) {
send({
client: ws,
data: Buffer.from(resp.data, 'binary').toString(
'base64',
),
event: 'proxy',
});
}
return null;
})
.catch((error) => {
if (ws.readyState === WebSocket.OPEN) {
send({
client: ws,
data: error.message,
event: 'error',
});
}
});
break;
}
case 'repeat': {
getMainWindow()?.webContents.send('renderer-player-toggle-repeat');
break;
}
case 'shuffle': {
getMainWindow()?.webContents.send('renderer-player-toggle-shuffle');
break;
}
case 'volume': {
let volume = Number(json.volume);
if (volume > 100) {
volume = 100;
} else if (volume < 0) {
volume = 0;
}
currentSong.volume = volume;
broadcast({ data: { volume }, event: 'song' });
getMainWindow()?.webContents.send('request-volume', {
volume,
});
if (mprisPlayer) {
mprisPlayer.volume = volume / 100;
}
break;
}
case 'favorite': {
const { favorite, id } = json;
if (id && id === currentSong.song?.id) {
getMainWindow()?.webContents.send('request-favorite', {
favorite,
id,
serverId: currentSong.song.serverId,
});
}
break;
}
case 'rating': {
const { rating, id } = json;
if (id && id === currentSong.song?.id) {
getMainWindow()?.webContents.send('request-rating', {
id,
rating,
serverId: currentSong.song.serverId,
});
}
break;
}
}
} catch (error) {
console.error(error);
}
});
ws.on('pong', () => {
ws.alive = true;
});
ws.send(JSON.stringify({ data: currentSong, event: 'song' }));
});
const heartBeat = setInterval(() => {
wsServer?.clients.forEach((ws) => {
if (!ws.alive) {
ws.terminate();
return;
}
ws.alive = false;
ws.ping();
});
}, PING_TIMEOUT_MS);
wsServer.on('close', () => {
clearInterval(heartBeat);
});
setTimeout(() => {
reject(new Error('Server did not come up'));
}, UP_TIMEOUT_MS);
} catch (error) {
reject(error);
shutdownServer();
}
});
};
ipcMain.handle('remote-enable', async (_event, enabled: boolean) => {
settings.enabled = enabled;
if (enabled) {
try {
await enableServer(settings);
} catch (error) {
return (error as Error).message;
}
} else {
shutdownServer();
}
return null;
});
ipcMain.handle('remote-port', async (_event, port: number) => {
settings.port = port;
});
ipcMain.on('remote-password', (_event, password: string) => {
settings.password = password;
wsServer?.clients.forEach((client) => client.close(4002));
});
ipcMain.handle(
'remote-settings',
async (_event, enabled: boolean, port: number, username: string, password: string) => {
settings.enabled = enabled;
settings.password = password;
settings.port = port;
settings.username = username;
if (enabled) {
try {
await enableServer(settings);
} catch (error) {
return (error as Error).message;
}
} else {
shutdownServer();
}
return null;
},
);
ipcMain.on('remote-username', (_event, username: string) => {
settings.username = username;
wsServer?.clients.forEach((client) => client.close(4002));
});
ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids: string[]) => {
if (currentSong.song?.serverId !== serverId) return;
const id = currentSong.song.id;
for (const songId of ids) {
if (songId === id) {
currentSong.song.userFavorite = favorite;
broadcast({ data: { favorite, id: songId }, event: 'favorite' });
return;
}
}
});
ipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: string[]) => {
if (currentSong.song?.serverId !== serverId) return;
const id = currentSong.song.id;
for (const songId of ids) {
if (songId === id) {
currentSong.song.userRating = rating;
broadcast({ data: { id: songId, rating }, event: 'rating' });
return;
}
}
});
ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => {
currentSong.repeat = repeat;
broadcast({ data: { repeat }, event: 'song' });
});
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
currentSong.shuffle = shuffle;
broadcast({ data: { shuffle }, event: 'song' });
});
ipcMain.on('update-song', (_event, data: SongUpdate) => {
const { song, ...rest } = data;
const songChanged = song?.id !== currentSong.song?.id;
if (!song?.id) {
currentSong = {
...currentSong,
...data,
song: undefined,
};
} else {
currentSong = {
...currentSong,
...data,
};
}
if (songChanged) {
broadcast({ data: { ...rest, song: song || null }, event: 'song' });
} else {
broadcast({ data: rest, event: 'song' });
}
});
ipcMain.on('update-volume', (_event, volume: number) => {
currentSong.volume = volume;
broadcast({ data: { volume }, event: 'song' });
});
if (mprisPlayer) {
mprisPlayer.on('loopStatus', (event: string) => {
const repeat =
event === 'Playlist'
? PlayerRepeat.ALL
: event === 'Track'
? PlayerRepeat.ONE
: PlayerRepeat.NONE;
currentSong.repeat = repeat;
broadcast({ data: { repeat }, event: 'song' });
});
mprisPlayer.on('shuffle', (shuffle: boolean) => {
currentSong.shuffle = shuffle;
broadcast({ data: { shuffle }, event: 'song' });
});
mprisPlayer.on('volume', (vol: number) => {
let volume = Math.round(vol * 100);
if (volume > 100) {
volume = 100;
} else if (volume < 0) {
volume = 0;
}
currentSong.volume = volume;
broadcast({ data: { volume }, event: 'song' });
});
}

View File

@ -1,5 +1,5 @@
import { ipcMain, safeStorage } from 'electron';
import Store from 'electron-store';
import { ipcMain, safeStorage } from 'electron';
export const store = new Store();

View File

@ -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, string> = {
[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 };

View File

@ -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';

View File

@ -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,
});

View File

@ -12,3 +12,5 @@ export const ipc = {
removeAllListeners,
send,
};
export type Ipc = typeof ipc;

View File

@ -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;

View File

@ -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<Record<LyricSource, InternetProviderLyricSearchResponse[]>> => {
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;

View File

@ -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;

View File

@ -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<string, any> }) => {
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<PlayerState>) => 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;

101
src/main/preload/remote.ts Normal file
View File

@ -0,0 +1,101 @@
import { IpcRendererEvent, ipcRenderer } from 'electron';
import { SongUpdate } from '/@/renderer/types';
const requestFavorite = (
cb: (
event: IpcRendererEvent,
data: { favorite: boolean; id: string; serverId: string },
) => void,
) => {
ipcRenderer.on('request-favorite', cb);
};
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
ipcRenderer.on('request-position', cb);
};
const requestRating = (
cb: (event: IpcRendererEvent, data: { id: string; rating: number; serverId: string }) => void,
) => {
ipcRenderer.on('request-rating', cb);
};
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
ipcRenderer.on('request-seek', cb);
};
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
ipcRenderer.on('request-volume', cb);
};
const setRemoteEnabled = (enabled: boolean): Promise<string | null> => {
const result = ipcRenderer.invoke('remote-enable', enabled);
return result;
};
const setRemotePort = (port: number): Promise<string | null> => {
const result = ipcRenderer.invoke('remote-port', port);
return result;
};
const updateFavorite = (favorite: boolean, serverId: string, ids: string[]) => {
ipcRenderer.send('update-favorite', favorite, serverId, ids);
};
const updatePassword = (password: string) => {
ipcRenderer.send('remote-password', password);
};
const updateSetting = (
enabled: boolean,
port: number,
username: string,
password: string,
): Promise<string | null> => {
return ipcRenderer.invoke('remote-settings', enabled, port, username, password);
};
const updateRating = (rating: number, serverId: string, ids: string[]) => {
ipcRenderer.send('update-rating', rating, serverId, ids);
};
const updateRepeat = (repeat: string) => {
ipcRenderer.send('update-repeat', repeat);
};
const updateShuffle = (shuffle: boolean) => {
ipcRenderer.send('update-shuffle', shuffle);
};
const updateSong = (args: SongUpdate) => {
ipcRenderer.send('update-song', args);
};
const updateUsername = (username: string) => {
ipcRenderer.send('remote-username', username);
};
const updateVolume = (volume: number) => {
ipcRenderer.send('update-volume', volume);
};
export const remote = {
requestFavorite,
requestPosition,
requestRating,
requestSeek,
requestVolume,
setRemoteEnabled,
setRemotePort,
updateFavorite,
updatePassword,
updateRating,
updateRepeat,
updateSetting,
updateShuffle,
updateSong,
updateUsername,
updateVolume,
};
export type Remote = typeof remote;

View File

@ -5,3 +5,5 @@ export const utils = {
isMacOS,
isWindows,
};
export type Utils = typeof utils;

85
src/remote/app.tsx Normal file
View File

@ -0,0 +1,85 @@
import { useEffect } from 'react';
import { MantineProvider } from '@mantine/core';
import './styles/global.scss';
import '@ag-grid-community/styles/ag-grid.css';
import { useIsDark, useReconnect } from '/@/remote/store';
import { Shell } from '/@/remote/components/shell';
export const App = () => {
const isDark = useIsDark();
const reconnect = useReconnect();
useEffect(() => {
reconnect();
}, [reconnect]);
return (
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{
colorScheme: isDark ? 'dark' : 'light',
components: {
AppShell: {
styles: {
body: {
height: '100vh',
overflow: 'scroll',
},
},
},
Modal: {
styles: {
body: {
background: 'var(--modal-bg)',
height: '100vh',
},
close: { marginRight: '0.5rem' },
content: { borderRadius: '5px' },
header: {
background: 'var(--modal-header-bg)',
paddingBottom: '1rem',
},
title: { fontSize: 'medium', fontWeight: 500 },
},
},
},
defaultRadius: 'xs',
dir: 'ltr',
focusRing: 'auto',
focusRingStyles: {
inputStyles: () => ({
border: '1px solid var(--primary-color)',
}),
resetStyles: () => ({ outline: 'none' }),
styles: () => ({
outline: '1px solid var(--primary-color)',
outlineOffset: '-1px',
}),
},
fontFamily: 'var(--content-font-family)',
fontSizes: {
lg: '1.1rem',
md: '1rem',
sm: '0.9rem',
xl: '1.5rem',
xs: '0.8rem',
},
headings: {
fontFamily: 'var(--content-font-family)',
fontWeight: 700,
},
other: {},
spacing: {
lg: '2rem',
md: '1rem',
sm: '0.5rem',
xl: '4rem',
xs: '0rem',
},
}}
>
<Shell />
</MantineProvider>
);
};

View File

@ -0,0 +1,20 @@
import { CiImageOff, CiImageOn } from 'react-icons/ci';
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import { useShowImage, useToggleShowImage } from '/@/remote/store';
export const ImageButton = () => {
const showImage = useShowImage();
const toggleImage = useToggleShowImage();
return (
<RemoteButton
mr={5}
size="xl"
tooltip={showImage ? 'Hide Image' : 'Show Image'}
variant="default"
onClick={() => toggleImage()}
>
{showImage ? <CiImageOff size={30} /> : <CiImageOn size={30} />}
</RemoteButton>
);
};

View File

@ -0,0 +1,21 @@
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import { useConnected, useReconnect } from '/@/remote/store';
import { RiRestartLine } from 'react-icons/ri';
export const ReconnectButton = () => {
const connected = useConnected();
const reconnect = useReconnect();
return (
<RemoteButton
$active={!connected}
mr={5}
size="xl"
tooltip={connected ? 'Reconnect' : 'Not connected. Reconnect.'}
variant="default"
onClick={() => reconnect()}
>
<RiRestartLine size={30} />
</RemoteButton>
);
};

View File

@ -0,0 +1,60 @@
import { Ref, forwardRef } from 'react';
import { Button, type ButtonProps as MantineButtonProps } from '@mantine/core';
import { Tooltip } from '/@/renderer/components/tooltip';
import styled from 'styled-components';
interface StyledButtonProps extends MantineButtonProps {
$active?: boolean;
children: React.ReactNode;
onClick?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onMouseDown?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
ref: Ref<HTMLButtonElement>;
}
export interface ButtonProps extends StyledButtonProps {
tooltip: string;
}
const StyledButton = styled(Button)<StyledButtonProps>`
svg {
display: flex;
fill: ${({ $active: active }) =>
active ? 'var(--primary-color)' : 'var(--playerbar-btn-fg)'};
stroke: var(--playerbar-btn-fg);
}
&:hover {
background: var(--playerbar-btn-bg-hover);
svg {
fill: ${({ $active: active }) =>
active
? 'var(--primary-color) !important'
: 'var(--playerbar-btn-fg-hover) !important'};
}
}
`;
export const RemoteButton = forwardRef<HTMLButtonElement, ButtonProps>(
({ children, tooltip, ...props }: ButtonProps, ref) => {
return (
<Tooltip
withinPortal
label={tooltip}
>
<StyledButton
{...props}
ref={ref}
>
{children}
</StyledButton>
</Tooltip>
);
},
);
RemoteButton.defaultProps = {
$active: false,
onClick: undefined,
onMouseDown: undefined,
};

View File

@ -0,0 +1,27 @@
import { useIsDark, useToggleDark } from '/@/remote/store';
import { RiMoonLine, RiSunLine } from 'react-icons/ri';
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import { AppTheme } from '/@/renderer/themes/types';
import { useEffect } from 'react';
export const ThemeButton = () => {
const isDark = useIsDark();
const toggleDark = useToggleDark();
useEffect(() => {
const targetTheme: AppTheme = isDark ? AppTheme.DEFAULT_DARK : AppTheme.DEFAULT_LIGHT;
document.body.setAttribute('data-theme', targetTheme);
}, [isDark]);
return (
<RemoteButton
mr={5}
size="xl"
tooltip="Toggle Theme"
variant="default"
onClick={() => toggleDark()}
>
{isDark ? <RiSunLine size={30} /> : <RiMoonLine size={30} />}
</RemoteButton>
);
};

View File

@ -0,0 +1,175 @@
import { useCallback } from 'react';
import { Group, Image, Rating, Text, Title } from '@mantine/core';
import { useInfo, useSend, useShowImage } from '/@/remote/store';
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import formatDuration from 'format-duration';
import debounce from 'lodash/debounce';
import {
RiHeartLine,
RiPauseFill,
RiPlayFill,
RiRepeat2Line,
RiRepeatOneLine,
RiShuffleFill,
RiSkipBackFill,
RiSkipForwardFill,
RiVolumeUpFill,
} from 'react-icons/ri';
import { PlayerRepeat, PlayerStatus } from '/@/renderer/types';
import { WrapperSlider } from '/@/remote/components/wrapped-slider';
import { Tooltip } from '/@/renderer/components/tooltip';
export const RemoteContainer = () => {
const { repeat, shuffle, song, status, volume } = useInfo();
const send = useSend();
const showImage = useShowImage();
const id = song?.id;
const setRating = useCallback(
(rating: number) => {
send({ event: 'rating', id: id!, rating });
},
[send, id],
);
const debouncedSetRating = debounce(setRating, 400);
return (
<>
{song && (
<>
<Title order={1}>{song.name}</Title>
<Group align="flex-end">
<Title order={2}>Album: {song.album}</Title>
<Title order={2}>Artist: {song.artistName}</Title>
</Group>
<Group position="apart">
<Title order={3}>Duration: {formatDuration(song.duration * 1000)}</Title>
{song.releaseDate && (
<Title order={3}>
Released: {new Date(song.releaseDate).toLocaleDateString()}
</Title>
)}
<Title order={3}>Plays: {song.playCount}</Title>
</Group>
</>
)}
<Group
grow
spacing={0}
>
<RemoteButton
tooltip="Previous track"
variant="default"
onClick={() => send({ event: 'previous' })}
>
<RiSkipBackFill size={25} />
</RemoteButton>
<RemoteButton
tooltip={status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
variant="default"
onClick={() => {
if (status === PlayerStatus.PLAYING) {
send({ event: 'pause' });
} else if (status === PlayerStatus.PAUSED) {
send({ event: 'play' });
}
}}
>
{status === PlayerStatus.PLAYING ? (
<RiPauseFill size={25} />
) : (
<RiPlayFill size={25} />
)}
</RemoteButton>
<RemoteButton
tooltip="Next track"
variant="default"
onClick={() => send({ event: 'next' })}
>
<RiSkipForwardFill size={25} />
</RemoteButton>
</Group>
<Group
grow
spacing={0}
>
<RemoteButton
$active={shuffle || false}
tooltip={shuffle ? 'Shuffle tracks' : 'Shuffle disabled'}
variant="default"
onClick={() => send({ event: 'shuffle' })}
>
<RiShuffleFill size={25} />
</RemoteButton>
<RemoteButton
$active={repeat !== undefined && repeat !== PlayerRepeat.NONE}
tooltip={`Repeat ${
repeat === PlayerRepeat.ONE
? 'One'
: repeat === PlayerRepeat.ALL
? 'all'
: 'none'
}`}
variant="default"
onClick={() => send({ event: 'repeat' })}
>
{repeat === undefined || repeat === PlayerRepeat.ONE ? (
<RiRepeatOneLine size={25} />
) : (
<RiRepeat2Line size={25} />
)}
</RemoteButton>
<RemoteButton
$active={song?.userFavorite}
disabled={!song}
tooltip={song?.userFavorite ? 'Unfavorite' : 'Favorite'}
variant="default"
onClick={() => {
if (!id) return;
send({ event: 'favorite', favorite: !song.userFavorite, id });
}}
>
<RiHeartLine size={25} />
</RemoteButton>
{(song?.serverType === 'navidrome' || song?.serverType === 'subsonic') && (
<div style={{ margin: 'auto' }}>
<Tooltip
label="Double click to clear"
openDelay={1000}
>
<Rating
sx={{ margin: 'auto' }}
value={song.userRating ?? 0}
onChange={debouncedSetRating}
onDoubleClick={() => debouncedSetRating(0)}
/>
</Tooltip>
</div>
)}
</Group>
<WrapperSlider
leftLabel={<RiVolumeUpFill size={20} />}
max={100}
rightLabel={
<Text
size="xs"
weight={600}
>
{volume ?? 0}
</Text>
}
value={volume ?? 0}
onChangeEnd={(e) => send({ event: 'volume', volume: e })}
/>
{showImage && (
<Image
src={song?.imageUrl?.replaceAll(/&(size|width|height=\d+)/g, '')}
onError={() => send({ event: 'proxy' })}
/>
)}
</>
);
};

View File

@ -0,0 +1,76 @@
import {
AppShell,
Container,
Flex,
Grid,
Header,
Image,
MediaQuery,
Skeleton,
Title,
} from '@mantine/core';
import { ThemeButton } from '/@/remote/components/buttons/theme-button';
import { ImageButton } from '/@/remote/components/buttons/image-button';
import { RemoteContainer } from '/@/remote/components/remote-container';
import { ReconnectButton } from '/@/remote/components/buttons/reconnect-button';
import { useConnected } from '/@/remote/store';
export const Shell = () => {
const connected = useConnected();
return (
<AppShell
header={
<Header height={60}>
<Grid>
<Grid.Col span="auto">
<div>
<Image
bg="rgb(25, 25, 25)"
fit="contain"
height={60}
src="/favicon.ico"
width={60}
/>
</div>
</Grid.Col>
<MediaQuery
smallerThan="sm"
styles={{ display: 'none' }}
>
<Grid.Col
sm={6}
xs={0}
>
<Title ta="center">Feishin Remote</Title>
</Grid.Col>
</MediaQuery>
<Grid.Col span="auto">
<Flex
direction="row"
justify="right"
>
<ReconnectButton />
<ImageButton />
<ThemeButton />
</Flex>
</Grid.Col>
</Grid>
</Header>
}
padding="md"
>
<Container>
{connected ? (
<RemoteContainer />
) : (
<Skeleton
height={300}
width="100%"
/>
)}
</Container>
</AppShell>
);
};

View File

@ -0,0 +1,62 @@
import { useState } from 'react';
import { SliderProps } from '@mantine/core';
import styled from 'styled-components';
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
const SliderContainer = styled.div`
display: flex;
width: 95%;
height: 20px;
margin: 10px 0px;
`;
const SliderValueWrapper = styled.div<{ position: 'left' | 'right' }>`
display: flex;
flex: 1;
align-self: flex-end;
justify-content: center;
max-width: 50px;
`;
const SliderWrapper = styled.div`
display: flex;
flex: 6;
align-items: center;
height: 100%;
`;
export interface WrappedProps extends Omit<SliderProps, 'onChangeEnd'> {
leftLabel?: JSX.Element;
onChangeEnd: (value: number) => void;
rightLabel?: JSX.Element;
value: number;
}
export const WrapperSlider = ({ leftLabel, rightLabel, value, ...props }: WrappedProps) => {
const [isSeeking, setIsSeeking] = useState(false);
const [seek, setSeek] = useState(0);
return (
<SliderContainer>
{leftLabel && <SliderValueWrapper position="left">{leftLabel}</SliderValueWrapper>}
<SliderWrapper>
<PlayerbarSlider
{...props}
min={0}
size={6}
value={!isSeeking ? value ?? 0 : seek}
w="100%"
onChange={(e) => {
setIsSeeking(true);
setSeek(e);
}}
onChangeEnd={(e) => {
props.onChangeEnd(e);
setIsSeeking(false);
}}
/>
</SliderWrapper>
{rightLabel && <SliderValueWrapper position="right">{rightLabel}</SliderValueWrapper>}
</SliderContainer>
);
};

15
src/remote/index.ejs Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Feishin Remote</title>
</head>
<body>
<div id="root"></div>
</body>
</html>

16
src/remote/index.tsx Normal file
View File

@ -0,0 +1,16 @@
import { Notifications } from '@mantine/notifications';
import { createRoot } from 'react-dom/client';
import { App } from '/@/remote/app';
const container = document.getElementById('root')! as HTMLElement;
const root = createRoot(container);
root.render(
<>
<Notifications
containerWidth="300px"
position="bottom-center"
/>
<App />
</>,
);

220
src/remote/store/index.ts Normal file
View File

@ -0,0 +1,220 @@
import { hideNotification, showNotification } from '@mantine/notifications';
import type { NotificationProps as MantineNotificationProps } from '@mantine/notifications';
import merge from 'lodash/merge';
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/remote/types';
interface StatefulWebSocket extends WebSocket {
natural: boolean;
}
interface SettingsState {
connected: boolean;
info: Omit<SongUpdateSocket, 'currentTime'>;
isDark: boolean;
showImage: boolean;
socket?: StatefulWebSocket;
}
export interface SettingsSlice extends SettingsState {
actions: {
reconnect: () => void;
send: (data: ClientEvent) => void;
toggleIsDark: () => void;
toggleShowImage: () => void;
};
}
const initialState: SettingsState = {
connected: false,
info: {},
isDark: window.matchMedia('(prefers-color-scheme: dark)').matches,
showImage: true,
};
interface NotificationProps extends MantineNotificationProps {
type?: 'error' | 'warning';
}
const showToast = ({ type, ...props }: NotificationProps) => {
const color = type === 'warning' ? 'var(--warning-color)' : 'var(--danger-color)';
const defaultTitle = type === 'warning' ? 'Warning' : 'Error';
const defaultDuration = type === 'error' ? 2000 : 1000;
return showNotification({
autoClose: defaultDuration,
styles: () => ({
closeButton: {
'&:hover': {
background: 'transparent',
},
},
description: {
color: 'var(--toast-description-fg)',
fontSize: '1rem',
},
loader: {
margin: '1rem',
},
root: {
'&::before': { backgroundColor: color },
background: 'var(--toast-bg)',
border: '2px solid var(--generic-border-color)',
bottom: '90px',
},
title: {
color: 'var(--toast-title-fg)',
fontSize: '1.3rem',
},
}),
title: defaultTitle,
...props,
});
};
const toast = {
error: (props: NotificationProps) => showToast({ type: 'error', ...props }),
hide: hideNotification,
warn: (props: NotificationProps) => showToast({ type: 'warning', ...props }),
};
export const useRemoteStore = create<SettingsSlice>()(
persist(
devtools(
immer((set, get) => ({
actions: {
reconnect: () => {
const existing = get().socket;
if (existing) {
if (
existing.readyState === WebSocket.OPEN ||
existing.readyState === WebSocket.CONNECTING
) {
existing.natural = true;
existing.close(4001);
}
}
set((state) => {
const socket = new WebSocket(
// eslint-disable-next-line no-restricted-globals
location.href.replace('http', 'ws'),
) as StatefulWebSocket;
socket.natural = false;
socket.addEventListener('message', (message) => {
const { event, data } = JSON.parse(message.data) as ServerEvent;
switch (event) {
case 'error': {
toast.error({ message: data, title: 'Socket error' });
break;
}
case 'favorite': {
set((state) => {
if (state.info.song?.id === data.id) {
state.info.song.userFavorite = data.favorite;
}
});
break;
}
case 'proxy': {
set((state) => {
if (state.info.song) {
state.info.song.imageUrl = `data:image/jpeg;base64,${data}`;
}
});
break;
}
case 'rating': {
set((state) => {
if (state.info.song?.id === data.id) {
state.info.song.userRating = data.rating;
}
});
break;
}
case 'song': {
set((nested) => {
nested.info = { ...nested.info, ...data };
});
}
}
});
socket.addEventListener('open', () => {
set({ connected: true });
});
socket.addEventListener('close', (reason) => {
if (reason.code === 4002 || reason.code === 4003) {
// eslint-disable-next-line no-restricted-globals
location.reload();
} else if (reason.code === 4000) {
toast.warn({
message: 'Feishin remote server is down',
title: 'Connection closed',
});
} else if (reason.code !== 4001 && !socket.natural) {
toast.error({
message: 'Socket closed for unexpected reason',
title: 'Connection closed',
});
}
if (!socket.natural) {
set({ connected: false, info: {} });
}
});
state.socket = socket;
});
},
send: (data: ClientEvent) => {
get().socket?.send(JSON.stringify(data));
},
toggleIsDark: () => {
set((state) => {
state.isDark = !state.isDark;
});
},
toggleShowImage: () => {
set((state) => {
state.showImage = !state.showImage;
});
},
},
...initialState,
})),
{ name: 'store_settings' },
),
{
merge: (persistedState, currentState) => {
return merge(currentState, persistedState);
},
name: 'store_settings',
version: 6,
},
),
);
export const useConnected = () => useRemoteStore((state) => state.connected);
export const useInfo = () => useRemoteStore((state) => state.info);
export const useIsDark = () => useRemoteStore((state) => state.isDark);
export const useReconnect = () => useRemoteStore((state) => state.actions.reconnect);
export const useShowImage = () => useRemoteStore((state) => state.showImage);
export const useSend = () => useRemoteStore((state) => state.actions.send);
export const useToggleDark = () => useRemoteStore((state) => state.actions.toggleIsDark);
export const useToggleShowImage = () => useRemoteStore((state) => state.actions.toggleShowImage);

View File

@ -0,0 +1,127 @@
@use '../../renderer/themes/default.scss';
@use '../../renderer/themes/dark.scss';
@use '../../renderer/themes/light.scss';
@use '../../renderer/styles/ag-grid.scss';
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body,
html {
position: absolute;
display: block;
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: hidden;
color: var(--content-text-color);
background: var(--content-bg);
font-family: var(--content-font-family);
font-size: var(--root-font-size);
user-select: none;
}
@media only screen and (max-width: 639px) {
body,
html {
overflow-x: auto;
}
}
#app {
height: inherit;
}
*,
*:before,
*:after {
box-sizing: border-box;
text-rendering: optimizeLegibility;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-text-size-adjust: none;
outline: none;
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-corner {
background: var(--scrollbar-track-bg);
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track-bg);
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb-bg);
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-bg-hover);
}
a {
text-decoration: none;
}
button {
-webkit-app-region: no-drag;
}
.overlay-scrollbar {
overflow-y: overlay !important;
overflow-x: overlay !important;
}
.hide-scrollbar {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.mantine-ScrollArea-thumb[data-state='visible'] {
animation: fadeIn 0.3s forwards;
}
.mantine-ScrollArea-scrollbar[data-state='hidden'] {
animation: fadeOut 0.2s forwards;
}

56
src/remote/types.ts Normal file
View File

@ -0,0 +1,56 @@
import type { QueueSong } from '/@/renderer/api/types';
import type { SongUpdate } from '/@/renderer/types';
export interface SongUpdateSocket extends Omit<SongUpdate, 'song'> {
song?: QueueSong | null;
}
export interface ServerError {
data: string;
event: 'error';
}
export interface ServerFavorite {
data: { favorite: boolean; id: string };
event: 'favorite';
}
export interface ServerProxy {
data: string;
event: 'proxy';
}
export interface ServerRating {
data: { id: string; rating: number };
event: 'rating';
}
export interface ServerSong {
data: SongUpdateSocket;
event: 'song';
}
export type ServerEvent = ServerError | ServerFavorite | ServerRating | ServerSong | ServerProxy;
export interface ClientSimpleEvent {
event: 'next' | 'pause' | 'play' | 'previous' | 'proxy' | 'repeat' | 'shuffle';
}
export interface ClientFavorite {
event: 'favorite';
favorite: boolean;
id: string;
}
export interface ClientRating {
event: 'rating';
id: string;
rating: number;
}
export interface ClientVolume {
event: 'volume';
volume: number;
}
export type ClientEvent = ClientSimpleEvent | ClientFavorite | ClientRating | ClientVolume;

View File

@ -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';

View File

@ -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<Pick<PlayerState, 'current' | 'queue'>> = {
current: {
@ -91,13 +97,13 @@ export const App = () => {
},
queue,
};
mpvPlayer.saveQueue(stateToSave);
mpvPlayer!.saveQueue(stateToSave);
});
mpvPlayerListener.rendererRestoreQueue((_event: any, data: Partial<PlayerState>) => {
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 (
<MantineProvider
withGlobalStyles

View File

@ -7,12 +7,12 @@ const localSettings = isElectron() ? window.electron.localSettings : null;
export const MpvRequired = () => {
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}
/>
<Button onClick={() => localSettings.restart()}>Restart</Button>
<Button onClick={() => localSettings?.restart()}>Restart</Button>
</>
);
};

View File

@ -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) {

View File

@ -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(() => {

View File

@ -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) {

View File

@ -16,7 +16,12 @@ export const useLyricSearch = (args: Omit<QueryHookArgs<LyricSearchQuery>, 'serv
return useQuery<Record<LyricSource, InternetProviderLyricSearchResponse[]>>({
cacheTime: 1000 * 60 * 1,
enabled: !!query.artist || !!query.name,
queryFn: () => lyricsIpc?.searchRemoteLyrics(query),
queryFn: () => {
if (lyricsIpc) {
return lyricsIpc.searchRemoteLyrics(query);
}
return {} as Record<LyricSource, InternetProviderLyricSearchResponse[]>;
},
queryKey: queryKeys.songs.lyricsSearch(query),
staleTime: 1000 * 60 * 1,
...options,

View File

@ -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<Song> } | 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);
}
};

View File

@ -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<any>) => {
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<any>) => {
const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId);
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueueNext(playerData);
mpvPlayer!.setQueueNext(playerData);
}
if (type === 'sideDrawerQueue') {

View File

@ -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 (

View File

@ -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 (
<Flex
align="flex-end"

View File

@ -24,6 +24,7 @@ const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : nul
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 mediaSession = !isElectron() || !utils?.isLinux() ? navigator.mediaSession : null;
export const useCenterControls = (args: { playersRef: any }) => {
@ -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,

View File

@ -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;

View File

@ -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(() => {

View File

@ -16,7 +16,7 @@ const localSettings = isElectron() ? window.electron.localSettings : null;
interface EditServerFormProps {
isUpdate?: boolean;
onCancel: () => void;
password?: string;
password: string | null;
server: ServerListItem;
}

View File

@ -74,7 +74,7 @@ export const ApplicationSettings = () => {
zoomFactor: newVal,
},
});
localSettings.setZoomFactor(newVal);
localSettings!.setZoomFactor(newVal);
}}
/>
),

View File

@ -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 = () => {
<ControlSettings />
<Divider />
<SidebarSettings />
<Divider />
<RemoteSettings />
</Stack>
);
};

View File

@ -0,0 +1,156 @@
import isElectron from 'is-electron';
import { SettingsSection } from '/@/renderer/features/settings/components/settings-section';
import { useRemoteSettings, useSettingsStoreActions } from '/@/renderer/store';
import { NumberInput, Switch, Text, TextInput, toast } from '/@/renderer/components';
import debounce from 'lodash/debounce';
const remote = isElectron() ? window.electron.remote : null;
export const RemoteSettings = () => {
const settings = useRemoteSettings();
const { setSettings } = useSettingsStoreActions();
const url = `http://localhost:${settings.port}`;
const debouncedEnableRemote = debounce(async (enabled: boolean) => {
const errorMsg = await remote!.setRemoteEnabled(enabled);
if (errorMsg === null) {
setSettings({
remote: {
...settings,
enabled,
},
});
} else {
toast.error({
message: errorMsg,
title: enabled ? 'Error enabling remote' : 'Error disabling remote',
});
}
}, 100);
const debouncedChangeRemotePort = debounce(async (port: number) => {
const errorMsg = await remote!.setRemotePort(port);
if (errorMsg === null) {
setSettings({
remote: {
...settings,
port,
},
});
} else {
toast.error({
message: errorMsg,
title: 'Error setting port',
});
}
});
const isHidden = !isElectron();
const controlOptions = [
{
control: (
<Switch
aria-label="Enable remote control server"
defaultChecked={settings.enabled}
onChange={async (e) => {
const enabled = e.currentTarget.checked;
await debouncedEnableRemote(enabled);
}}
/>
),
description: (
<div>
Start an HTTP server to remotely control Feishin. This will listen on{' '}
<a
href={url}
rel="noreferrer noopener"
target="_blank"
>
{url}
</a>
</div>
),
isHidden,
title: 'Enable remote control',
},
{
control: (
<NumberInput
aria-label="Set remote port"
max={65535}
value={settings.port}
onBlur={async (e) => {
if (!e) return;
const port = Number(e.currentTarget.value);
await debouncedChangeRemotePort(port);
}}
/>
),
description:
'Remote server port. Changes here only take effect when you enable the remote',
isHidden,
title: 'Remove server port',
},
{
control: (
<TextInput
aria-label="Set remote username"
defaultValue={settings.username}
onBlur={(e) => {
const username = e.currentTarget.value;
if (username === settings.username) return;
remote!.updateUsername(username);
setSettings({
remote: {
...settings,
username,
},
});
}}
/>
),
description:
'Username that must be provided to access remote. If both username and password are empty, disable authentication',
isHidden,
title: 'Remote username',
},
{
control: (
<TextInput
aria-label="Set remote password"
defaultValue={settings.password}
onBlur={(e) => {
const password = e.currentTarget.value;
if (password === settings.password) return;
remote!.updatePassword(password);
setSettings({
remote: {
...settings,
password,
},
});
}}
/>
),
description: 'Password to access remote',
isHidden,
title: 'Remote password',
},
];
return (
<>
<SettingsSection options={controlOptions} />
<Text size="lg">
<b>
NOTE: these credentials are by default transferred insecurely. Do not use a
password you care about. Changing username/password will disconnect clients and
require them to reauthenticate
</b>
</Text>
</>
);
};

View File

@ -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();
}
}}
/>

View File

@ -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);
}
}}
/>

View File

@ -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);
};

View File

@ -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',
});

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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) => {

View File

@ -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) {

View File

@ -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<string | null>;
REMOTE_PORT(port: number): Promise<string | null>;
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;
};
}
}

View File

@ -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;

View File

@ -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);

View File

@ -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;
};