Compare commits
208 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e3e038d42 | |||
| b375238baf | |||
| 02b06a07be | |||
| d7f21b3c6b | |||
| 1113ef972f | |||
| 46a2c29b22 | |||
| ebcb7bc4d1 | |||
| 0b62bee3a6 | |||
| d3503af12c | |||
| 571ea3c653 | |||
| f0e518d3c8 | |||
| 47dc83f360 | |||
| 8cbc25a932 | |||
| 0cba405b45 | |||
| 45b80ac395 | |||
| 8b0fe69e1c | |||
| 25e621372c | |||
| 14f4649b93 | |||
| 1a87adb728 | |||
| bb9bf7ba6a | |||
| fb7e7bfa3e | |||
| a8a14a62c0 | |||
| cd836d54db | |||
| c90c43944d | |||
| fd7468a4fe | |||
| c4f9868a6b | |||
| fbb0907a70 | |||
| 201ee895f9 | |||
| 51be0153d3 | |||
| 29a9a11085 | |||
| 65f28bb9dc | |||
| fd264daffc | |||
| 18e35f2ba9 | |||
| 487e9be8ec | |||
| d9049ed066 | |||
| 6e62448b88 | |||
| ec457d5125 | |||
| d45b01625b | |||
| 2defa5cc13 | |||
| 9cc9c3a87f | |||
| 153d8ce6ce | |||
| 5e33212112 | |||
| 7d6990eb90 | |||
| d75ea94161 | |||
| 1badecc20a | |||
| c90a56811d | |||
| 4e5e3bc9a1 | |||
| c8397bb5ef | |||
| 0ae53b023c | |||
| 1acfa93f1a | |||
| b60ba27892 | |||
| 7ddba8ede7 | |||
| a8bd53b757 | |||
| 877b2e9f3b | |||
| 663893dccb | |||
| 96ace40fc3 | |||
| e9de9f5b65 | |||
| c92c94cf1a | |||
| 1d664bbbd7 | |||
| 7c59722f0a | |||
| 3f813b1a26 | |||
| 13d6758500 | |||
| 2b6323c396 | |||
| 8338aaf18d | |||
| 5f5c3bbb11 | |||
| 2a9d30e43d | |||
| d1e5571163 | |||
| e542fcb8aa | |||
| 1111fd00a1 | |||
| 6ae8046781 | |||
| 689b40eb91 | |||
| b3bdff446d | |||
| 8686a7c592 | |||
| 59a851f8c8 | |||
| fedef48411 | |||
| e3fc99cf82 | |||
| f09ad1da89 | |||
| 1ab75f7187 | |||
| 3d1f36e85a | |||
| 23e791c829 | |||
| aaaaee7043 | |||
| fca135ce2b | |||
| 72b4a60c7b | |||
| ff68de8c09 | |||
| c8663db4ba | |||
| 95780f1969 | |||
| 1327d58b23 | |||
| f6d239d87c | |||
| 80fb844d3c | |||
| b35d3c3256 | |||
| 14453a8524 | |||
| d0aba6e16e | |||
| 0b207c78e7 | |||
| ee83fdba71 | |||
| adfa748bfb | |||
| 505974320f | |||
| 5896d886d7 | |||
| f6d74ce9c3 | |||
| f443c466b0 | |||
| 8029712b55 | |||
| 4d5085f230 | |||
| 9f60769b65 | |||
| e618ac7590 | |||
| 9f55238b74 | |||
| 93e00e7afb | |||
| 8e83beffcc | |||
| 230fa33525 | |||
| ed070850a4 | |||
| 2072f9554e | |||
| 2aaf3c34c8 | |||
| b57f601e1b | |||
| 51f8415025 | |||
| e6bcb4e237 | |||
| c9dbf9b5be | |||
| 0a13d047bb | |||
| 84bec824f2 | |||
| 03a4a1da55 | |||
| 2c9509b58d | |||
| 42ea5af2eb | |||
| ebf0d3b47f | |||
| e44b8592e5 | |||
| f9338aafcd | |||
| 3aec139f58 | |||
| 8a367b00a3 | |||
| 46374ef2b5 | |||
| febe1a703c | |||
| 853770ea8e | |||
| 48eaddbeda | |||
| 0a26c489b6 | |||
| bbee3fc655 | |||
| a8dfc7bcd6 | |||
| 74384639de | |||
| 20524452ae | |||
| f274801be6 | |||
| 9d18384b2d | |||
| 92d7560362 | |||
| 47d84fae2d | |||
| c3d8791455 | |||
| 3d6f5a2748 | |||
| 61403510d4 | |||
| e796b031ea | |||
| 2d62b9d72d | |||
| f5cbcace64 | |||
| e7c15ef5f1 | |||
| 31eb22f968 | |||
| 713260bfc9 | |||
| ba00538cc3 | |||
| dd2dd797a1 | |||
| eec556d34a | |||
| 7378fd1f20 | |||
| 6821735f65 | |||
| 1cb0a1d72a | |||
| 287f1dc0e1 | |||
| 6dd9333dbb | |||
| 55937e71db | |||
| c0e3174d09 | |||
| 440cc04fbc | |||
| 6cd27c3e88 | |||
| 85964bfded | |||
| 8b4a2d1ac0 | |||
| 9bcefb3105 | |||
| 4029127018 | |||
| f9ddd3140a | |||
| 651af8539a | |||
| 4e4eca14ec | |||
| 1ec70bfa78 | |||
| c3f97dfa4c | |||
| bba27c5ddb | |||
| 78860db537 | |||
| ece7fecc76 | |||
| 919016ca5a | |||
| b8dfbf9d49 | |||
| 179129b7cb | |||
| 817675ee0e | |||
| 57cdb0eb69 | |||
| 8233a56def | |||
| 0c54b79c09 | |||
| 3fb9853eb6 | |||
| 1de89071e8 | |||
| be37dada13 | |||
| c27a9a8ffb | |||
| be0792a5c7 | |||
| 37e4940c2e | |||
| e965bd2663 | |||
| b9caa73405 | |||
| 0ba8d5bf70 | |||
| 1fc5e9a0e8 | |||
| f09227d963 | |||
| 47ecbf0601 | |||
| 481258484c | |||
| 3dcb0dc4ed | |||
| d64040f3f0 | |||
| 63a77ae68c | |||
| e980e31bd2 | |||
| 3b5dff795f | |||
| 8129a3994b | |||
| 734b632c6c | |||
| 34f05fa2a5 | |||
| f74e02eb09 | |||
| 287fbab29a | |||
| e9d1e4a597 | |||
| 70f893e5e9 | |||
| 30e52ebb54 | |||
| 22af76b4d6 | |||
| cb7bf438e9 | |||
| a1b5c21a84 | |||
| 4c5fa0750b | |||
| 22160ba59f |
@@ -7,6 +7,10 @@ import { dependencies as externals } from '../../release/app/package.json';
|
||||
import webpackPaths from './webpack.paths';
|
||||
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
|
||||
|
||||
const createStyledComponentsTransformer = require('typescript-plugin-styled-components').default;
|
||||
|
||||
const styledComponentsTransformer = createStyledComponentsTransformer();
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
externals: [...Object.keys(externals || {})],
|
||||
|
||||
@@ -20,6 +24,7 @@ const configuration: webpack.Configuration = {
|
||||
options: {
|
||||
// Remove this line to enable type checking in webpack builds
|
||||
transpileOnly: true,
|
||||
getCustomTransformers: () => ({ before: [styledComponentsTransformer] }),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
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'),
|
||||
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
|
||||
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);
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 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'),
|
||||
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true,
|
||||
},
|
||||
isBrowser: false,
|
||||
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
@@ -67,7 +67,10 @@ const configuration: webpack.Configuration = {
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: true,
|
||||
modules: {
|
||||
localIdentName: '[name]__[local]--[hash:base64:5]',
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
},
|
||||
sourceMap: true,
|
||||
importLoaders: 1,
|
||||
},
|
||||
@@ -168,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,
|
||||
@@ -175,6 +186,7 @@ const configuration: webpack.Configuration = {
|
||||
})
|
||||
.on('close', (code: number) => {
|
||||
preloadProcess.kill();
|
||||
remoteProcess.kill();
|
||||
process.exit(code!);
|
||||
})
|
||||
.on('error', (spawnError) => console.error(spawnError));
|
||||
|
||||
@@ -54,7 +54,10 @@ const configuration: webpack.Configuration = {
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: true,
|
||||
modules: {
|
||||
localIdentName: '[name]__[local]--[hash:base64:5]',
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
},
|
||||
sourceMap: true,
|
||||
importLoaders: 1,
|
||||
},
|
||||
|
||||
@@ -49,7 +49,10 @@ const configuration: webpack.Configuration = {
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: true,
|
||||
modules: {
|
||||
localIdentName: '[name]__[local]--[hash:base64:5]',
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
},
|
||||
sourceMap: true,
|
||||
importLoaders: 1,
|
||||
},
|
||||
@@ -103,6 +106,7 @@ const configuration: webpack.Configuration = {
|
||||
new HtmlWebpackPlugin({
|
||||
filename: path.join('index.html'),
|
||||
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
|
||||
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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.srcRendererPath, 'index.tsx')],
|
||||
|
||||
output: {
|
||||
path: webpackPaths.distWebPath,
|
||||
publicPath: '/',
|
||||
filename: 'renderer.js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
},
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.s?(a|c)ss$/,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: {
|
||||
localIdentName: '[name]__[local]--[hash:base64:5]',
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
},
|
||||
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: 'style.css',
|
||||
}),
|
||||
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
|
||||
}),
|
||||
|
||||
new HtmlWebpackPlugin({
|
||||
filename: 'index.html',
|
||||
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
|
||||
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true,
|
||||
},
|
||||
isBrowser: false,
|
||||
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
@@ -5,7 +5,9 @@ const rootPath = path.join(__dirname, '../..');
|
||||
const dllPath = path.join(__dirname, '../dll');
|
||||
|
||||
const srcPath = path.join(rootPath, 'src');
|
||||
const assetsPath = path.join(rootPath, 'assets');
|
||||
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,15 +18,19 @@ 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 distWebPath = path.join(distPath, 'web');
|
||||
|
||||
const buildPath = path.join(releasePath, 'build');
|
||||
|
||||
export default {
|
||||
assetsPath,
|
||||
rootPath,
|
||||
dllPath,
|
||||
srcPath,
|
||||
srcMainPath,
|
||||
srcRemotePath,
|
||||
srcRendererPath,
|
||||
releasePath,
|
||||
appPath,
|
||||
@@ -33,6 +39,8 @@ export default {
|
||||
srcNodeModulesPath,
|
||||
distPath,
|
||||
distMainPath,
|
||||
distRemotePath,
|
||||
distRendererPath,
|
||||
distWebPath,
|
||||
buildPath,
|
||||
};
|
||||
|
||||
@@ -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"',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -16,12 +16,13 @@ module.exports = {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/no-shadow': ['off'],
|
||||
'@typescript-eslint/no-unused-vars': ['error'],
|
||||
'@typescript-eslint/no-use-before-define': ['error'],
|
||||
'default-case': 'off',
|
||||
'import/extensions': 'off',
|
||||
'import/no-absolute-path': 'off',
|
||||
// A temporary hack related to IDE not resolving correct package.json
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
|
||||
'import/no-unresolved': 'error',
|
||||
'import/order': [
|
||||
'error',
|
||||
@@ -50,8 +51,14 @@ module.exports = {
|
||||
'no-console': 'off',
|
||||
'no-nested-ternary': 'off',
|
||||
'no-restricted-syntax': 'off',
|
||||
'no-shadow': 'off',
|
||||
'no-underscore-dangle': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'no-use-before-define': 'off',
|
||||
'prefer-destructuring': 'off',
|
||||
'react/function-component-definition': 'off',
|
||||
'react/jsx-filename-extension': [2, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }],
|
||||
'react/jsx-no-useless-fragment': 'off',
|
||||
'react/jsx-props-no-spreading': 'off',
|
||||
'react/jsx-sort-props': [
|
||||
'error',
|
||||
|
||||
@@ -10,8 +10,8 @@ exemptLabels:
|
||||
staleLabel: wontfix
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
|
||||
|
||||
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Referenced from: https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#introduction
|
||||
name: Publish Docker to GHCR
|
||||
permissions: write-all
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,38 @@
|
||||
# Referenced from: https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#introduction
|
||||
name: Publish Docker to GHCR (Manual)
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npm run package
|
||||
npm run lint
|
||||
npm run package
|
||||
npm exec tsc
|
||||
npm test
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
"printWidth": 100,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.css", "**/*.scss", "**/*.html"],
|
||||
"options": {
|
||||
"singleQuote": false
|
||||
"singleQuote": true
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,31 +1,17 @@
|
||||
{
|
||||
"processors": ["stylelint-processor-styled-components"],
|
||||
"customSyntax": "postcss-scss",
|
||||
"customSyntax": "postcss-styled-syntax",
|
||||
"extends": [
|
||||
"stylelint-config-standard-scss",
|
||||
"stylelint-config-standard",
|
||||
"stylelint-config-styled-components",
|
||||
"stylelint-config-rational-order"
|
||||
"stylelint-config-recess-order"
|
||||
],
|
||||
"rules": {
|
||||
"color-function-notation": ["legacy"],
|
||||
"declaration-empty-line-before": null,
|
||||
"order/properties-order": [],
|
||||
"plugin/rational-order": [
|
||||
true,
|
||||
{
|
||||
"border-in-box-model": false,
|
||||
"empty-line-between-groups": false
|
||||
}
|
||||
],
|
||||
"string-quotes": "single",
|
||||
"declaration-block-no-redundant-longhand-properties": null,
|
||||
"selector-class-pattern": null,
|
||||
"selector-type-case": ["lower", { "ignoreTypes": ["/^\\$\\w+/"] }],
|
||||
"selector-type-no-unknown": [
|
||||
true,
|
||||
{ "ignoreTypes": ["/-styled-mixin/", "/^\\$\\w+/"] }
|
||||
],
|
||||
"value-keyword-case": ["lower", { "ignoreKeywords": ["dummyValue"] }],
|
||||
"declaration-colon-newline-after": null
|
||||
"selector-type-no-unknown": [true, { "ignoreTypes": ["/-styled-mixin/", "/^\\$\\w+/"] }],
|
||||
"declaration-colon-newline-after": null,
|
||||
"property-no-vendor-prefix": null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"stylelint.vscode-stylelint",
|
||||
"esbenp.prettier-vscode"
|
||||
"esbenp.prettier-vscode",
|
||||
"clinyong.vscode-css-modules",
|
||||
"Huuums.vscode-fast-folder-structure"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
"request": "launch",
|
||||
"protocol": "inspector",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run start:main --inspect=5858 --remote-debugging-port=9223"
|
||||
],
|
||||
"runtimeArgs": ["run start:main --inspect=5858 --remote-debugging-port=9223"],
|
||||
"preLaunchTask": "Start Webpack Dev"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,14 +10,17 @@
|
||||
{ "directory": "./server", "changeProcessCWD": true }
|
||||
],
|
||||
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
|
||||
"editor.tabSize": 2,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true,
|
||||
"source.fixAll.stylelint": false
|
||||
"source.fixAll.stylelint": true,
|
||||
"source.organizeImports": false,
|
||||
"source.formatDocument": true
|
||||
},
|
||||
"css.validate": false,
|
||||
"css.validate": true,
|
||||
"less.validate": false,
|
||||
"scss.validate": false,
|
||||
"scss.validate": true,
|
||||
"scss.lint.unknownAtRules": "warning",
|
||||
"scss.lint.unknownProperties": "warning",
|
||||
"javascript.validate.enable": false,
|
||||
"javascript.format.enable": false,
|
||||
"typescript.format.enable": false,
|
||||
@@ -35,9 +38,35 @@
|
||||
"i18n-ally.localesPaths": ["src/i18n", "src/i18n/locales"],
|
||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"stylelint.validate": ["css", "less", "postcss", "typescript", "typescriptreact", "scss"],
|
||||
"stylelint.validate": ["css", "scss", "typescript", "typescriptreact"],
|
||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
||||
"[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
||||
"typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": true
|
||||
"typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": true,
|
||||
"folderTemplates.structures": [
|
||||
{
|
||||
"name": "TypeScript Feature Component With CSS Modules",
|
||||
"omitParentDirectory": true,
|
||||
"structure": [
|
||||
{
|
||||
"fileName": "<FTName | kebabcase>.tsx",
|
||||
"template": "Functional Component with CSS Modules"
|
||||
},
|
||||
{
|
||||
"fileName": "<FTName | kebabcase>.module.scss"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"folderTemplates.fileTemplates": {
|
||||
"Functional Component with CSS Modules": [
|
||||
"import styles from './<FTName | kebabcase>.module.scss';",
|
||||
"",
|
||||
"interface <FTName | pascalcase>Props {}",
|
||||
"",
|
||||
"export const <FTName | pascalcase> = ({}: <FTName | pascalcase>Props) => {",
|
||||
" return <div></div>;",
|
||||
"};"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# --- Builder stage
|
||||
FROM node:18-alpine as builder
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
|
||||
# Scripts include electron-specific dependencies, which we don't need
|
||||
RUN npm install --legacy-peer-deps --ignore-scripts
|
||||
RUN npm run build:web
|
||||
|
||||
# --- Production stage
|
||||
FROM nginx:alpine-slim
|
||||
|
||||
COPY --from=builder /app/release/app/dist/web /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 9180
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,3 +1,5 @@
|
||||
<img src="assets/icons/icon.png" alt="logo" title="feishin" align="right" height="60px" />
|
||||
|
||||
# Feishin
|
||||
|
||||
<p align="center">
|
||||
@@ -43,9 +45,26 @@ Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
|
||||
|
||||
## Getting Started
|
||||
|
||||
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases).
|
||||
### Desktop (recommended)
|
||||
|
||||
If you're using an M1 macOS device, [check here](https://github.com/jeffvli/feishin/issues/104#issuecomment-1553914730) for instructions on how to remove the app from quarantine.
|
||||
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases). The desktop client is the recommended way to use Feishin. It supports both the MPV and web player backends, as well as includes built-in fetching for lyrics.
|
||||
|
||||
If you're using a device running macOS 12 (Monterey) or higher, [check here](https://github.com/jeffvli/feishin/issues/104#issuecomment-1553914730) for instructions on how to remove the app from quarantine.
|
||||
|
||||
### Web and Docker
|
||||
|
||||
Visit [https://feishin.vercel.app](https://feishin.vercel.app) to use the hosted web version of Feishin. The web client only supports the web player backend.
|
||||
|
||||
Feishin is also available as a Docker image. The images are hosted via `ghcr.io` and are available to view [here](https://github.com/jeffvli/feishin/pkgs/container/feishin). You can run the container using the following commands:
|
||||
|
||||
```bash
|
||||
# Run the latest version
|
||||
docker run --name feishin --port 9180:9180 ghcr.io/jeffvli/feishin:latest
|
||||
|
||||
# Build the image locally
|
||||
docker build -t feishin .
|
||||
docker run --name feishin --port 9180:9180 feishin
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -57,6 +76,10 @@ If you're using an M1 macOS device, [check here](https://github.com/jeffvli/feis
|
||||
|
||||
## FAQ
|
||||
|
||||
### MPV is either not working or is rapidly switching between pause/play states
|
||||
|
||||
First thing to do is check that your MPV binary path is correct. Navigate to the settings page and re-set the path and restart the app. If your issue still isn't resolved, try reinstalling MPV. Known working versions include `v0.35.x` and `v0.36.x`. `v0.34.x` is a known broken version.
|
||||
|
||||
### What music servers does Feishin support?
|
||||
|
||||
Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/) or [Jellyfin](https://jellyfin.org/) API. **Subsonic API is not currently supported**. This will likely be added in [later when the new Subsonic API is decided on](https://support.symfonium.app/t/subsonic-servers-participation/1233).
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 101 KiB |
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 9180;
|
||||
sendfile on;
|
||||
default_type application/octet-stream;
|
||||
|
||||
gzip on;
|
||||
gzip_http_version 1.1;
|
||||
gzip_disable "MSIE [1-6]\.";
|
||||
gzip_min_length 256;
|
||||
gzip_vary on;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_comp_level 9;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html =404;
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,18 @@
|
||||
"name": "feishin",
|
||||
"productName": "Feishin",
|
||||
"description": "Feishin music server",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.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",
|
||||
"build:web": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.web.prod.ts",
|
||||
"build:docker": "npm run build:web && docker build -t jeffvli/feishin .",
|
||||
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
|
||||
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"lint:styles": "npx stylelint **/*.tsx",
|
||||
"lint": "concurrently \"npm run lint:code\" \"npm run lint:styles\"",
|
||||
"lint:code": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
|
||||
"lint:styles": "npx stylelint **/*.tsx --fix",
|
||||
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
|
||||
"package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
|
||||
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
|
||||
@@ -17,6 +21,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",
|
||||
@@ -51,7 +56,7 @@
|
||||
"package.json"
|
||||
],
|
||||
"afterSign": ".erb/scripts/notarize.js",
|
||||
"electronVersion": "22.3.1",
|
||||
"electronVersion": "25.8.1",
|
||||
"mac": {
|
||||
"target": {
|
||||
"target": "default",
|
||||
@@ -60,6 +65,7 @@
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
"icon": "assets/icons/icon.icns",
|
||||
"type": "distribution",
|
||||
"hardenedRuntime": true,
|
||||
"entitlements": "assets/entitlements.mac.plist",
|
||||
@@ -84,14 +90,15 @@
|
||||
"target": [
|
||||
"nsis",
|
||||
"zip"
|
||||
]
|
||||
],
|
||||
"icon": "assets/icons/icon.ico"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
"tar.xz"
|
||||
],
|
||||
"icon": "assets/icons/placeholder.png",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"category": "Development"
|
||||
},
|
||||
"directories": {
|
||||
@@ -190,8 +197,8 @@
|
||||
"css-loader": "^6.7.1",
|
||||
"css-minimizer-webpack-plugin": "^3.4.1",
|
||||
"detect-port": "^1.3.0",
|
||||
"electron": "^22.3.1",
|
||||
"electron-builder": "^24.0.0-alpha.13",
|
||||
"electron": "^25.8.1",
|
||||
"electron-builder": "^24.6.3",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-notarize": "^1.2.1",
|
||||
"electronmon": "^2.0.2",
|
||||
@@ -218,6 +225,7 @@
|
||||
"lint-staged": "^12.3.7",
|
||||
"mini-css-extract-plugin": "^2.6.0",
|
||||
"postcss-scss": "^4.0.4",
|
||||
"postcss-styled-syntax": "^0.5.0",
|
||||
"postcss-syntax": "^0.36.2",
|
||||
"prettier": "^2.6.2",
|
||||
"react-refresh": "^0.12.0",
|
||||
@@ -227,19 +235,19 @@
|
||||
"sass": "^1.49.11",
|
||||
"sass-loader": "^12.6.0",
|
||||
"style-loader": "^3.3.1",
|
||||
"stylelint": "^14.9.1",
|
||||
"stylelint-config-rational-order": "^0.1.2",
|
||||
"stylelint": "^15.10.3",
|
||||
"stylelint-config-css-modules": "^4.3.0",
|
||||
"stylelint-config-recess-order": "^4.3.0",
|
||||
"stylelint-config-standard": "^34.0.0",
|
||||
"stylelint-config-standard-scss": "^4.0.0",
|
||||
"stylelint-config-styled-components": "^0.1.1",
|
||||
"stylelint-order": "^5.0.0",
|
||||
"stylelint-processor-styled-components": "^1.10.0",
|
||||
"terser-webpack-plugin": "^5.3.1",
|
||||
"ts-jest": "^27.1.4",
|
||||
"ts-loader": "^9.2.8",
|
||||
"ts-node": "^10.7.0",
|
||||
"tsconfig-paths-webpack-plugin": "^4.0.0",
|
||||
"typescript": "^4.8.4",
|
||||
"typescript-plugin-styled-components": "^2.0.0",
|
||||
"typescript": "^5.2.2",
|
||||
"typescript-plugin-styled-components": "^3.0.0",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.71.0",
|
||||
"webpack-bundle-analyzer": "^4.5.0",
|
||||
@@ -254,17 +262,19 @@
|
||||
"@ag-grid-community/react": "^28.2.1",
|
||||
"@ag-grid-community/styles": "^28.2.1",
|
||||
"@emotion/react": "^11.10.4",
|
||||
"@mantine/core": "^6.0.13",
|
||||
"@mantine/dates": "^6.0.13",
|
||||
"@mantine/form": "^6.0.13",
|
||||
"@mantine/hooks": "^6.0.13",
|
||||
"@mantine/modals": "^6.0.13",
|
||||
"@mantine/notifications": "^6.0.13",
|
||||
"@mantine/utils": "^6.0.13",
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"@tanstack/react-query-devtools": "^4.29.6",
|
||||
"@mantine/core": "^6.0.17",
|
||||
"@mantine/dates": "^6.0.17",
|
||||
"@mantine/form": "^6.0.17",
|
||||
"@mantine/hooks": "^6.0.17",
|
||||
"@mantine/modals": "^6.0.17",
|
||||
"@mantine/notifications": "^6.0.17",
|
||||
"@mantine/utils": "^6.0.17",
|
||||
"@tanstack/react-query": "^4.32.1",
|
||||
"@tanstack/react-query-devtools": "^4.32.1",
|
||||
"@tanstack/react-query-persist-client": "^4.32.1",
|
||||
"@ts-rest/core": "^3.23.0",
|
||||
"axios": "^1.4.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"dayjs": "^1.11.6",
|
||||
"electron-debug": "^3.2.0",
|
||||
@@ -274,10 +284,11 @@
|
||||
"electron-updater": "^4.6.5",
|
||||
"fast-average-color": "^9.3.0",
|
||||
"format-duration": "^2.0.0",
|
||||
"framer-motion": "^9.1.7",
|
||||
"framer-motion": "^10.13.0",
|
||||
"fuse.js": "^6.6.2",
|
||||
"history": "^5.3.0",
|
||||
"i18next": "^21.6.16",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^9.0.21",
|
||||
"is-electron": "^2.2.2",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -286,11 +297,13 @@
|
||||
"nanoid": "^3.3.3",
|
||||
"net": "^1.0.2",
|
||||
"node-mpv": "github:jeffvli/Node-MPV",
|
||||
"overlayscrollbars": "^2.2.1",
|
||||
"overlayscrollbars-react": "^0.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-i18next": "^11.16.7",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-icons": "^4.10.1",
|
||||
"react-player": "^2.11.0",
|
||||
"react-router": "^6.5.0",
|
||||
"react-router-dom": "^6.5.0",
|
||||
@@ -298,13 +311,13 @@
|
||||
"react-virtualized-auto-sizer": "^1.0.17",
|
||||
"react-window": "^1.8.9",
|
||||
"react-window-infinite-loader": "^1.0.9",
|
||||
"styled-components": "^5.3.11",
|
||||
"styled-components": "^6.0.8",
|
||||
"swiper": "^9.3.1",
|
||||
"zod": "^3.21.4",
|
||||
"zustand": "^4.3.8"
|
||||
"zustand": "^4.3.9"
|
||||
},
|
||||
"resolutions": {
|
||||
"styled-components": "^5"
|
||||
"styled-components": "^6"
|
||||
},
|
||||
"devEngines": {
|
||||
"node": ">=14.x",
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishin",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"hasInstallScript": true,
|
||||
"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"
|
||||
"electron": "25.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get": {
|
||||
@@ -98,9 +99,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "16.18.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.16.tgz",
|
||||
"integrity": "sha512-ZOzvDRWp8dCVBmgnkIqYCArgdFOO9YzocZp8Ra25N/RStKiWvMOXHMz+GjSeVNe5TstaTmTWPucGJkDw0XXJWA==",
|
||||
"version": "18.16.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.19.tgz",
|
||||
"integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/responselike": {
|
||||
@@ -452,14 +453,14 @@
|
||||
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "22.3.1",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-22.3.1.tgz",
|
||||
"integrity": "sha512-iDltL9j12bINK3aOp8ZoGq4NFBFjJhw1AYHelbWj93XUCAIT4fdA+PRsq0aaTHg3bthLLlLRvIZVgNsZPqWcqg==",
|
||||
"version": "25.3.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-25.3.0.tgz",
|
||||
"integrity": "sha512-cyqotxN+AroP5h2IxUsJsmehYwP5LrFAOO7O7k9tILME3Sa1/POAg3shrhx4XEnaAMyMqMLxzGvkzCVxzEErnA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^16.11.26",
|
||||
"@types/node": "^18.11.18",
|
||||
"extract-zip": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1284,6 +1285,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",
|
||||
@@ -1387,9 +1408,9 @@
|
||||
}
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "16.18.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.16.tgz",
|
||||
"integrity": "sha512-ZOzvDRWp8dCVBmgnkIqYCArgdFOO9YzocZp8Ra25N/RStKiWvMOXHMz+GjSeVNe5TstaTmTWPucGJkDw0XXJWA==",
|
||||
"version": "18.16.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.19.tgz",
|
||||
"integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/responselike": {
|
||||
@@ -1651,13 +1672,13 @@
|
||||
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
||||
},
|
||||
"electron": {
|
||||
"version": "22.3.1",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-22.3.1.tgz",
|
||||
"integrity": "sha512-iDltL9j12bINK3aOp8ZoGq4NFBFjJhw1AYHelbWj93XUCAIT4fdA+PRsq0aaTHg3bthLLlLRvIZVgNsZPqWcqg==",
|
||||
"version": "25.3.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-25.3.0.tgz",
|
||||
"integrity": "sha512-cyqotxN+AroP5h2IxUsJsmehYwP5LrFAOO7O7k9tILME3Sa1/POAg3shrhx4XEnaAMyMqMLxzGvkzCVxzEErnA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^16.11.26",
|
||||
"@types/node": "^18.11.18",
|
||||
"extract-zip": "^2.0.1"
|
||||
}
|
||||
},
|
||||
@@ -2269,6 +2290,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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"description": "",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
@@ -14,10 +14,11 @@
|
||||
},
|
||||
"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"
|
||||
"electron": "25.3.0"
|
||||
},
|
||||
"license": "GPL-3.0"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import './lyrics';
|
||||
import './player';
|
||||
import './remote';
|
||||
import './settings';
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { load } from 'cheerio';
|
||||
import { orderSearchResults } from './shared';
|
||||
import {
|
||||
LyricSource,
|
||||
InternetProviderLyricResponse,
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricSearchQuery,
|
||||
} from '../../../../renderer/api/types';
|
||||
import { orderSearchResults } from './shared';
|
||||
|
||||
const SEARCH_URL = 'https://genius.com/api/search/song';
|
||||
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
InternetProviderLyricResponse,
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricSearchQuery,
|
||||
QueueSong,
|
||||
LyricGetQuery,
|
||||
LyricSource,
|
||||
} from '../../../../renderer/api/types';
|
||||
import { store } from '../settings/index';
|
||||
import {
|
||||
query as queryGenius,
|
||||
getSearchResults as searchGenius,
|
||||
@@ -23,6 +14,15 @@ import {
|
||||
getSearchResults as searchNetease,
|
||||
getLyricsBySongId as getNetease,
|
||||
} from './netease';
|
||||
import {
|
||||
InternetProviderLyricResponse,
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricSearchQuery,
|
||||
QueueSong,
|
||||
LyricGetQuery,
|
||||
LyricSource,
|
||||
} from '../../../../renderer/api/types';
|
||||
import { store } from '../settings/index';
|
||||
|
||||
type SongFetcher = (params: LyricSearchQuery) => Promise<InternetProviderLyricResponse | null>;
|
||||
type SearchFetcher = (
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Credits to https://github.com/tranxuanthang/lrcget for API implementation
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { orderSearchResults } from './shared';
|
||||
import {
|
||||
InternetProviderLyricResponse,
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricSearchQuery,
|
||||
LyricSource,
|
||||
} from '../../../../renderer/api/types';
|
||||
import { orderSearchResults } from './shared';
|
||||
|
||||
const FETCH_URL = 'https://lrclib.net/api/get';
|
||||
const SEEARCH_URL = 'https://lrclib.net/api/search';
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import console from 'console';
|
||||
import { ipcMain } from 'electron';
|
||||
import { getMainWindow, getMpvInstance } from '../../../main';
|
||||
import { getMpvInstance } from '../../../main';
|
||||
import { PlayerData } from '/@/renderer/store';
|
||||
|
||||
declare module 'node-mpv';
|
||||
|
||||
function wait(timeout: number) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve('resolved');
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
// function wait(timeout: number) {
|
||||
// return new Promise((resolve) => {
|
||||
// setTimeout(() => {
|
||||
// resolve('resolved');
|
||||
// }, timeout);
|
||||
// });
|
||||
// }
|
||||
|
||||
ipcMain.handle('player-is-running', async () => {
|
||||
return getMpvInstance()?.isRunning();
|
||||
@@ -101,6 +101,7 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to clear playlist', err);
|
||||
});
|
||||
|
||||
await getMpvInstance()
|
||||
?.pause()
|
||||
.catch((err) => {
|
||||
@@ -109,42 +110,27 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
|
||||
return;
|
||||
}
|
||||
|
||||
let complete = false;
|
||||
let tryAttempts = 0;
|
||||
|
||||
while (!complete) {
|
||||
if (tryAttempts > 3) {
|
||||
getMainWindow()?.webContents.send('renderer-player-error', 'Failed to load song');
|
||||
complete = true;
|
||||
} else {
|
||||
try {
|
||||
if (data.queue.current) {
|
||||
await getMpvInstance()
|
||||
getMpvInstance()
|
||||
?.load(data.queue.current.streamUrl, 'replace')
|
||||
.then(() => {
|
||||
// eslint-disable-next-line promise/always-return
|
||||
if (data.queue.next) {
|
||||
getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to load song', err);
|
||||
getMpvInstance()?.play();
|
||||
});
|
||||
}
|
||||
|
||||
if (data.queue.next) {
|
||||
await getMpvInstance()
|
||||
?.load(data.queue.next.streamUrl, 'append')
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to load next song', err);
|
||||
});
|
||||
}
|
||||
|
||||
complete = true;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
tryAttempts += 1;
|
||||
await wait(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pause) {
|
||||
await getMpvInstance()?.pause();
|
||||
getMpvInstance()?.pause();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -186,6 +172,7 @@ ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
|
||||
?.playlistRemove(0)
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to remove song from playlist', err);
|
||||
getMpvInstance()?.pause();
|
||||
});
|
||||
|
||||
if (data.queue.next) {
|
||||
@@ -207,9 +194,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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
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;
|
||||
auth: 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) {
|
||||
if (client.alive && client.auth) {
|
||||
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}`;
|
||||
const 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, 'favicon', 'ico', res);
|
||||
break;
|
||||
}
|
||||
case '/remote.css': {
|
||||
await serveFile(req, 'remote', 'css', res);
|
||||
break;
|
||||
}
|
||||
case '/remote.js': {
|
||||
await serveFile(req, 'remote', 'js', res);
|
||||
break;
|
||||
}
|
||||
case '/credentials': {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.end(req.headers.authorization);
|
||||
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) => {
|
||||
let authFail: number | undefined;
|
||||
ws.alive = true;
|
||||
|
||||
if (!settings.username && !settings.password) {
|
||||
ws.auth = true;
|
||||
} else {
|
||||
authFail = setTimeout(() => {
|
||||
if (!ws.auth) {
|
||||
ws.close();
|
||||
}
|
||||
}, 10000) as unknown as number;
|
||||
}
|
||||
|
||||
ws.on('error', console.error);
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const json = JSON.parse(data.toString()) as ClientEvent;
|
||||
const event = json.event;
|
||||
|
||||
if (!ws.auth) {
|
||||
if (event === 'authenticate') {
|
||||
const auth = json.header.split(' ')[1];
|
||||
const [login, password] = Buffer.from(auth, 'base64')
|
||||
.toString()
|
||||
.split(':');
|
||||
|
||||
if (login === settings.username && password === settings.password) {
|
||||
ws.auth = true;
|
||||
} else {
|
||||
ws.close();
|
||||
}
|
||||
|
||||
clearTimeout(authFail);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (event) {
|
||||
case 'pause': {
|
||||
getMainWindow()?.webContents.send('renderer-player-pause');
|
||||
break;
|
||||
}
|
||||
case 'play': {
|
||||
getMainWindow()?.webContents.send('renderer-player-play');
|
||||
break;
|
||||
}
|
||||
case 'next': {
|
||||
getMainWindow()?.webContents.send('renderer-player-next');
|
||||
break;
|
||||
}
|
||||
case 'previous': {
|
||||
getMainWindow()?.webContents.send('renderer-player-previous');
|
||||
break;
|
||||
}
|
||||
case 'proxy': {
|
||||
const toFetch = currentSong.song?.imageUrl?.replaceAll(
|
||||
/&(size|width|height=\d+)/g,
|
||||
'',
|
||||
);
|
||||
|
||||
if (!toFetch) return;
|
||||
|
||||
axios
|
||||
.get(toFetch, { responseType: 'arraybuffer' })
|
||||
.then((resp) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
send({
|
||||
client: ws,
|
||||
data: Buffer.from(resp.data, 'binary').toString(
|
||||
'base64',
|
||||
),
|
||||
event: 'proxy',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
send({
|
||||
client: ws,
|
||||
data: error.message,
|
||||
event: 'error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case 'repeat': {
|
||||
getMainWindow()?.webContents.send('renderer-player-toggle-repeat');
|
||||
break;
|
||||
}
|
||||
case 'shuffle': {
|
||||
getMainWindow()?.webContents.send('renderer-player-toggle-shuffle');
|
||||
break;
|
||||
}
|
||||
case 'volume': {
|
||||
let volume = Number(json.volume);
|
||||
|
||||
if (volume > 100) {
|
||||
volume = 100;
|
||||
} else if (volume < 0) {
|
||||
volume = 0;
|
||||
}
|
||||
|
||||
currentSong.volume = volume;
|
||||
|
||||
broadcast({ data: { volume }, event: 'song' });
|
||||
getMainWindow()?.webContents.send('request-volume', {
|
||||
volume,
|
||||
});
|
||||
|
||||
if (mprisPlayer) {
|
||||
mprisPlayer.volume = volume / 100;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'favorite': {
|
||||
const { favorite, id } = json;
|
||||
if (id && id === currentSong.song?.id) {
|
||||
getMainWindow()?.webContents.send('request-favorite', {
|
||||
favorite,
|
||||
id,
|
||||
serverId: currentSong.song.serverId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rating': {
|
||||
const { rating, id } = json;
|
||||
if (id && id === currentSong.song?.id) {
|
||||
getMainWindow()?.webContents.send('request-rating', {
|
||||
id,
|
||||
rating,
|
||||
serverId: currentSong.song.serverId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('pong', () => {
|
||||
ws.alive = true;
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({ data: currentSong, event: 'song' }));
|
||||
});
|
||||
|
||||
const heartBeat = setInterval(() => {
|
||||
wsServer?.clients.forEach((ws) => {
|
||||
if (!ws.alive) {
|
||||
ws.terminate();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.alive = false;
|
||||
ws.ping();
|
||||
});
|
||||
}, PING_TIMEOUT_MS);
|
||||
|
||||
wsServer.on('close', () => {
|
||||
clearInterval(heartBeat);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
reject(new Error('Server did not come up'));
|
||||
}, UP_TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
shutdownServer();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ipcMain.handle('remote-enable', async (_event, enabled: boolean) => {
|
||||
settings.enabled = enabled;
|
||||
|
||||
if (enabled) {
|
||||
try {
|
||||
await enableServer(settings);
|
||||
} catch (error) {
|
||||
return (error as Error).message;
|
||||
}
|
||||
} else {
|
||||
shutdownServer();
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
ipcMain.handle('remote-port', async (_event, port: number) => {
|
||||
settings.port = port;
|
||||
});
|
||||
|
||||
ipcMain.on('remote-password', (_event, password: string) => {
|
||||
settings.password = password;
|
||||
wsServer?.clients.forEach((client) => client.close(4002));
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'remote-settings',
|
||||
async (_event, enabled: boolean, port: number, username: string, password: string) => {
|
||||
settings.enabled = enabled;
|
||||
settings.password = password;
|
||||
settings.port = port;
|
||||
settings.username = username;
|
||||
|
||||
if (enabled) {
|
||||
try {
|
||||
await enableServer(settings);
|
||||
} catch (error) {
|
||||
return (error as Error).message;
|
||||
}
|
||||
} else {
|
||||
shutdownServer();
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.on('remote-username', (_event, username: string) => {
|
||||
settings.username = username;
|
||||
wsServer?.clients.forEach((client) => client.close(4002));
|
||||
});
|
||||
|
||||
ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids: string[]) => {
|
||||
if (currentSong.song?.serverId !== serverId) return;
|
||||
|
||||
const id = currentSong.song.id;
|
||||
|
||||
for (const songId of ids) {
|
||||
if (songId === id) {
|
||||
currentSong.song.userFavorite = favorite;
|
||||
broadcast({ data: { favorite, id: songId }, event: 'favorite' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: string[]) => {
|
||||
if (currentSong.song?.serverId !== serverId) return;
|
||||
|
||||
const id = currentSong.song.id;
|
||||
|
||||
for (const songId of ids) {
|
||||
if (songId === id) {
|
||||
currentSong.song.userRating = rating;
|
||||
broadcast({ data: { id: songId, rating }, event: 'rating' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => {
|
||||
currentSong.repeat = repeat;
|
||||
broadcast({ data: { repeat }, event: 'song' });
|
||||
});
|
||||
|
||||
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
|
||||
currentSong.shuffle = shuffle;
|
||||
broadcast({ data: { shuffle }, event: 'song' });
|
||||
});
|
||||
|
||||
ipcMain.on('update-song', (_event, data: SongUpdate) => {
|
||||
const { song, ...rest } = data;
|
||||
const songChanged = song?.id !== currentSong.song?.id;
|
||||
|
||||
if (!song?.id) {
|
||||
currentSong = {
|
||||
...currentSong,
|
||||
...data,
|
||||
song: undefined,
|
||||
};
|
||||
} else {
|
||||
currentSong = {
|
||||
...currentSong,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
if (songChanged) {
|
||||
broadcast({ data: { ...rest, song: song || null }, event: 'song' });
|
||||
} else {
|
||||
broadcast({ data: rest, event: 'song' });
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('update-volume', (_event, volume: number) => {
|
||||
currentSong.volume = volume;
|
||||
broadcast({ data: { volume }, event: 'song' });
|
||||
});
|
||||
|
||||
if (mprisPlayer) {
|
||||
mprisPlayer.on('loopStatus', (event: string) => {
|
||||
const repeat =
|
||||
event === 'Playlist'
|
||||
? PlayerRepeat.ALL
|
||||
: event === 'Track'
|
||||
? PlayerRepeat.ONE
|
||||
: PlayerRepeat.NONE;
|
||||
|
||||
currentSong.repeat = repeat;
|
||||
broadcast({ data: { repeat }, event: 'song' });
|
||||
});
|
||||
|
||||
mprisPlayer.on('shuffle', (shuffle: boolean) => {
|
||||
currentSong.shuffle = shuffle;
|
||||
broadcast({ data: { shuffle }, event: 'song' });
|
||||
});
|
||||
|
||||
mprisPlayer.on('volume', (vol: number) => {
|
||||
let volume = Math.round(vol * 100);
|
||||
|
||||
if (volume > 100) {
|
||||
volume = 100;
|
||||
} else if (volume < 0) {
|
||||
volume = 0;
|
||||
}
|
||||
currentSong.volume = volume;
|
||||
broadcast({ data: { volume }, event: 'song' });
|
||||
});
|
||||
}
|
||||
@@ -1,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,42 +102,36 @@ 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;
|
||||
},
|
||||
) => {
|
||||
ipcMain.on('update-song', (_event, args: SongUpdate) => {
|
||||
const { song, status, repeat, shuffle } = args || {};
|
||||
|
||||
try {
|
||||
mprisPlayer.playbackStatus = status;
|
||||
mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused';
|
||||
|
||||
if (repeat) {
|
||||
mprisPlayer.loopStatus =
|
||||
repeat === 'all' ? 'Playlist' : repeat === 'one' ? 'Track' : 'None';
|
||||
mprisPlayer.loopStatus = REPEAT_TO_MPRIS[repeat];
|
||||
}
|
||||
|
||||
if (shuffle) {
|
||||
mprisPlayer.shuffle = shuffle !== 'none';
|
||||
mprisPlayer.shuffle = shuffle;
|
||||
}
|
||||
|
||||
if (!song) return;
|
||||
@@ -144,16 +145,15 @@ ipcMain.on(
|
||||
|
||||
mprisPlayer.metadata = {
|
||||
'mpris:artUrl': upsizedImageUrl,
|
||||
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e6) : null,
|
||||
'mpris:trackid': song?.id
|
||||
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : 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)
|
||||
'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,
|
||||
@@ -164,5 +164,6 @@ ipcMain.on(
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
export { mprisPlayer };
|
||||
|
||||
@@ -84,6 +84,10 @@ const singleInstance = app.requestSingleInstanceLock();
|
||||
|
||||
if (!singleInstance) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
mainWindow?.show();
|
||||
});
|
||||
}
|
||||
|
||||
const RESOURCES_PATH = app.isPackaged
|
||||
@@ -125,7 +129,9 @@ const createTray = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
tray = isLinux() ? new Tray(getAssetPath('icon.png')) : new Tray(getAssetPath('icon.ico'));
|
||||
tray = isLinux()
|
||||
? new Tray(getAssetPath('icons/icon.png'))
|
||||
: new Tray(getAssetPath('icons/icon.ico'));
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
click: () => {
|
||||
@@ -208,7 +214,7 @@ const createWindow = async () => {
|
||||
autoHideMenuBar: true,
|
||||
frame: false,
|
||||
height: 900,
|
||||
icon: getAssetPath('icon.png'),
|
||||
icon: getAssetPath('icons/icon.png'),
|
||||
minHeight: 640,
|
||||
minWidth: 480,
|
||||
show: false,
|
||||
@@ -422,7 +428,7 @@ const prefetchPlaylistParams = [
|
||||
];
|
||||
|
||||
const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
|
||||
const parameters = ['--idle=yes'];
|
||||
const parameters = ['--idle=yes', '--no-config', '--load-scripts=no'];
|
||||
|
||||
if (!extraParameters?.some((param) => prefetchPlaylistParams.includes(param))) {
|
||||
parameters.push('--prefetch-playlist=yes');
|
||||
@@ -439,21 +445,27 @@ const createMpv = (data: { extraParameters?: string[]; properties?: Record<strin
|
||||
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
|
||||
console.log('Setting mpv params: ', params);
|
||||
|
||||
const extra = isDevelopment ? '-dev' : '';
|
||||
|
||||
const mpv = new MpvAPI(
|
||||
{
|
||||
audio_only: true,
|
||||
auto_restart: false,
|
||||
binary: MPV_BINARY_PATH || '',
|
||||
socket: isWindows() ? `\\\\.\\pipe\\mpvserver${extra}` : `/tmp/node-mpv${extra}.sock`,
|
||||
time_update: 1,
|
||||
},
|
||||
params,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line promise/catch-or-return
|
||||
mpv.start()
|
||||
.catch((error) => {
|
||||
console.log('MPV failed to start', error);
|
||||
})
|
||||
.finally(() => {
|
||||
console.log('Setting MPV properties: ', properties);
|
||||
mpv.setMultipleProperties(properties || {});
|
||||
|
||||
mpv.start().catch((error) => {
|
||||
console.log('MPV failed to start', error);
|
||||
});
|
||||
|
||||
mpv.on('status', (status, ...rest) => {
|
||||
@@ -575,7 +587,8 @@ const HOTKEY_ACTIONS: Record<BindingActions, () => void> = {
|
||||
[BindingActions.STOP]: () => getMainWindow()?.webContents.send('renderer-player-stop'),
|
||||
[BindingActions.TOGGLE_REPEAT]: () =>
|
||||
getMainWindow()?.webContents.send('renderer-player-toggle-repeat'),
|
||||
[BindingActions.VOLUME_UP]: () => getMainWindow()?.webContents.send('renderer-player-volume-up'),
|
||||
[BindingActions.VOLUME_UP]: () =>
|
||||
getMainWindow()?.webContents.send('renderer-player-volume-up'),
|
||||
[BindingActions.VOLUME_DOWN]: () =>
|
||||
getMainWindow()?.webContents.send('renderer-player-volume-down'),
|
||||
[BindingActions.GLOBAL_SEARCH]: () => {},
|
||||
@@ -596,10 +609,13 @@ ipcMain.on(
|
||||
for (const shortcut of Object.keys(data)) {
|
||||
const isGlobalHotkey = data[shortcut as BindingActions].isGlobal;
|
||||
const isValidHotkey =
|
||||
data[shortcut as BindingActions].hotkey && data[shortcut as BindingActions].hotkey !== '';
|
||||
data[shortcut as BindingActions].hotkey &&
|
||||
data[shortcut as BindingActions].hotkey !== '';
|
||||
|
||||
if (isGlobalHotkey && isValidHotkey) {
|
||||
const accelerator = hotkeyToElectronAccelerator(data[shortcut as BindingActions].hotkey);
|
||||
const accelerator = hotkeyToElectronAccelerator(
|
||||
data[shortcut as BindingActions].hotkey,
|
||||
);
|
||||
|
||||
globalShortcut.register(accelerator, () => {
|
||||
HOTKEY_ACTIONS[shortcut as BindingActions]();
|
||||
@@ -632,8 +648,7 @@ app.on('window-all-closed', () => {
|
||||
}
|
||||
});
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
app.whenReady()
|
||||
.then(() => {
|
||||
createWindow();
|
||||
createTray();
|
||||
|
||||
@@ -18,7 +18,9 @@ export default class MenuBuilder {
|
||||
}
|
||||
|
||||
const template =
|
||||
process.platform === 'darwin' ? this.buildDarwinTemplate() : this.buildDefaultTemplate();
|
||||
process.platform === 'darwin'
|
||||
? this.buildDarwinTemplate()
|
||||
: this.buildDefaultTemplate();
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
@@ -151,7 +153,9 @@ export default class MenuBuilder {
|
||||
},
|
||||
{
|
||||
click() {
|
||||
shell.openExternal('https://github.com/electron/electron/tree/main/docs#readme');
|
||||
shell.openExternal(
|
||||
'https://github.com/electron/electron/tree/main/docs#readme',
|
||||
);
|
||||
},
|
||||
label: 'Documentation',
|
||||
},
|
||||
@@ -211,7 +215,9 @@ export default class MenuBuilder {
|
||||
{
|
||||
accelerator: 'F11',
|
||||
click: () => {
|
||||
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
|
||||
this.mainWindow.setFullScreen(
|
||||
!this.mainWindow.isFullScreen(),
|
||||
);
|
||||
},
|
||||
label: 'Toggle &Full Screen',
|
||||
},
|
||||
@@ -227,7 +233,9 @@ export default class MenuBuilder {
|
||||
{
|
||||
accelerator: 'F11',
|
||||
click: () => {
|
||||
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
|
||||
this.mainWindow.setFullScreen(
|
||||
!this.mainWindow.isFullScreen(),
|
||||
);
|
||||
},
|
||||
label: 'Toggle &Full Screen',
|
||||
},
|
||||
@@ -244,7 +252,9 @@ export default class MenuBuilder {
|
||||
},
|
||||
{
|
||||
click() {
|
||||
shell.openExternal('https://github.com/electron/electron/tree/main/docs#readme');
|
||||
shell.openExternal(
|
||||
'https://github.com/electron/electron/tree/main/docs#readme',
|
||||
);
|
||||
},
|
||||
label: 'Documentation',
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -12,3 +12,5 @@ export const ipc = {
|
||||
removeAllListeners,
|
||||
send,
|
||||
};
|
||||
|
||||
export type Ipc = typeof ipc;
|
||||
|
||||
@@ -50,3 +50,5 @@ export const localSettings = {
|
||||
set,
|
||||
setZoomFactor,
|
||||
};
|
||||
|
||||
export type LocalSettings = typeof localSettings;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { IpcRendererEvent, ipcRenderer } from 'electron';
|
||||
import { SongUpdate } from '/@/renderer/types';
|
||||
|
||||
const requestFavorite = (
|
||||
cb: (
|
||||
event: IpcRendererEvent,
|
||||
data: { favorite: boolean; id: string; serverId: string },
|
||||
) => void,
|
||||
) => {
|
||||
ipcRenderer.on('request-favorite', cb);
|
||||
};
|
||||
|
||||
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
|
||||
ipcRenderer.on('request-position', cb);
|
||||
};
|
||||
|
||||
const requestRating = (
|
||||
cb: (event: IpcRendererEvent, data: { id: string; rating: number; serverId: string }) => void,
|
||||
) => {
|
||||
ipcRenderer.on('request-rating', cb);
|
||||
};
|
||||
|
||||
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
|
||||
ipcRenderer.on('request-seek', cb);
|
||||
};
|
||||
|
||||
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
|
||||
ipcRenderer.on('request-volume', cb);
|
||||
};
|
||||
|
||||
const setRemoteEnabled = (enabled: boolean): Promise<string | null> => {
|
||||
const result = ipcRenderer.invoke('remote-enable', enabled);
|
||||
return result;
|
||||
};
|
||||
|
||||
const setRemotePort = (port: number): Promise<string | null> => {
|
||||
const result = ipcRenderer.invoke('remote-port', port);
|
||||
return result;
|
||||
};
|
||||
|
||||
const updateFavorite = (favorite: boolean, serverId: string, ids: string[]) => {
|
||||
ipcRenderer.send('update-favorite', favorite, serverId, ids);
|
||||
};
|
||||
|
||||
const updatePassword = (password: string) => {
|
||||
ipcRenderer.send('remote-password', password);
|
||||
};
|
||||
|
||||
const updateSetting = (
|
||||
enabled: boolean,
|
||||
port: number,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<string | null> => {
|
||||
return ipcRenderer.invoke('remote-settings', enabled, port, username, password);
|
||||
};
|
||||
|
||||
const updateRating = (rating: number, serverId: string, ids: string[]) => {
|
||||
ipcRenderer.send('update-rating', rating, serverId, ids);
|
||||
};
|
||||
|
||||
const updateRepeat = (repeat: string) => {
|
||||
ipcRenderer.send('update-repeat', repeat);
|
||||
};
|
||||
|
||||
const updateShuffle = (shuffle: boolean) => {
|
||||
ipcRenderer.send('update-shuffle', shuffle);
|
||||
};
|
||||
|
||||
const updateSong = (args: SongUpdate) => {
|
||||
ipcRenderer.send('update-song', args);
|
||||
};
|
||||
|
||||
const updateUsername = (username: string) => {
|
||||
ipcRenderer.send('remote-username', username);
|
||||
};
|
||||
|
||||
const updateVolume = (volume: number) => {
|
||||
ipcRenderer.send('update-volume', volume);
|
||||
};
|
||||
|
||||
export const remote = {
|
||||
requestFavorite,
|
||||
requestPosition,
|
||||
requestRating,
|
||||
requestSeek,
|
||||
requestVolume,
|
||||
setRemoteEnabled,
|
||||
setRemotePort,
|
||||
updateFavorite,
|
||||
updatePassword,
|
||||
updateRating,
|
||||
updateRepeat,
|
||||
updateSetting,
|
||||
updateShuffle,
|
||||
updateSong,
|
||||
updateUsername,
|
||||
updateVolume,
|
||||
};
|
||||
|
||||
export type Remote = typeof remote;
|
||||
@@ -5,3 +5,5 @@ export const utils = {
|
||||
isMacOS,
|
||||
isWindows,
|
||||
};
|
||||
|
||||
export type Utils = typeof utils;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect } from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import './styles/global.scss';
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { MouseEvent, ReactNode, 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: ReactNode;
|
||||
onClick?: (e: MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onMouseDown?: (e: 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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)}</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' })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
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
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState, ReactNode } 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 0;
|
||||
`;
|
||||
|
||||
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?: ReactNode;
|
||||
onChangeEnd: (value: number) => void;
|
||||
rightLabel?: ReactNode;
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 />
|
||||
</>,
|
||||
);
|
||||
@@ -0,0 +1,239 @@
|
||||
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: async () => {
|
||||
const existing = get().socket;
|
||||
|
||||
if (existing) {
|
||||
if (
|
||||
existing.readyState === WebSocket.OPEN ||
|
||||
existing.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
existing.natural = true;
|
||||
existing.close(4001);
|
||||
}
|
||||
}
|
||||
|
||||
let authHeader: string | undefined;
|
||||
|
||||
try {
|
||||
const credentials = await fetch('/credentials');
|
||||
authHeader = await credentials.text();
|
||||
} catch (error) {
|
||||
console.error('Failed to get credentials');
|
||||
}
|
||||
|
||||
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', () => {
|
||||
if (authHeader) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
event: 'authenticate',
|
||||
header: authHeader,
|
||||
}),
|
||||
);
|
||||
}
|
||||
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) => {
|
||||
console.log(data, get().socket);
|
||||
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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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 interface ClientAuth {
|
||||
event: 'authenticate';
|
||||
header: string;
|
||||
}
|
||||
|
||||
export type ClientEvent =
|
||||
| ClientAuth
|
||||
| ClientSimpleEvent
|
||||
| ClientFavorite
|
||||
| ClientRating
|
||||
| ClientVolume;
|
||||
@@ -420,7 +420,10 @@ const deleteFavorite = async (args: FavoriteArgs) => {
|
||||
|
||||
const updateRating = async (args: SetRatingArgs) => {
|
||||
return (
|
||||
apiController('setRating', args.apiClientProps.server?.type) as ControllerEndpoint['setRating']
|
||||
apiController(
|
||||
'setRating',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['setRating']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
@@ -435,7 +438,10 @@ const getTopSongList = async (args: TopSongListArgs) => {
|
||||
|
||||
const scrobble = async (args: ScrobbleArgs) => {
|
||||
return (
|
||||
apiController('scrobble', args.apiClientProps.server?.type) as ControllerEndpoint['scrobble']
|
||||
apiController(
|
||||
'scrobble',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['scrobble']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
@@ -456,7 +462,10 @@ const getRandomSongList = async (args: RandomSongListArgs) => {
|
||||
|
||||
const getLyrics = async (args: LyricsArgs) => {
|
||||
return (
|
||||
apiController('getLyrics', args.apiClientProps.server?.type) as ControllerEndpoint['getLyrics']
|
||||
apiController(
|
||||
'getLyrics',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getLyrics']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ export interface JFGenreListResponse extends JFBasePaginatedResponse {
|
||||
|
||||
export type JFGenreList = JFGenreListResponse;
|
||||
|
||||
export enum JFGenreListSort {
|
||||
NAME = 'SortName',
|
||||
}
|
||||
|
||||
export type JFAlbumArtistDetailResponse = JFAlbumArtist;
|
||||
|
||||
export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse;
|
||||
|
||||
@@ -108,6 +108,7 @@ export const contract = c.router({
|
||||
getGenreList: {
|
||||
method: 'GET',
|
||||
path: 'genres',
|
||||
query: jfType._parameters.genreList,
|
||||
responses: {
|
||||
200: jfType._response.genreList,
|
||||
400: jfType._response.error,
|
||||
@@ -271,6 +272,12 @@ axiosClient.interceptors.response.use(
|
||||
if (error.response && error.response.status === 401) {
|
||||
const currentServer = useAuthStore.getState().currentServer;
|
||||
|
||||
if (currentServer) {
|
||||
useAuthStore
|
||||
.getState()
|
||||
.actions.updateServer(currentServer.id, { credential: undefined });
|
||||
}
|
||||
|
||||
authenticationFailure(currentServer);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
RandomSongListArgs,
|
||||
LyricsArgs,
|
||||
LyricsResponse,
|
||||
genreListSortMap,
|
||||
} from '/@/renderer/api/types';
|
||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||
import { jfNormalize } from './jellyfin-normalize';
|
||||
@@ -53,11 +54,37 @@ import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||
import packageJson from '../../../../package.json';
|
||||
import { z } from 'zod';
|
||||
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
|
||||
import isElectron from 'is-electron';
|
||||
|
||||
const formatCommaDelimitedString = (value: string[]) => {
|
||||
return value.join(',');
|
||||
};
|
||||
|
||||
function getHostname(): string {
|
||||
if (isElectron()) {
|
||||
return 'Desktop Client';
|
||||
}
|
||||
const agent = navigator.userAgent;
|
||||
switch (true) {
|
||||
case agent.toLowerCase().indexOf('edge') > -1:
|
||||
return 'Microsoft Edge';
|
||||
case agent.toLowerCase().indexOf('edg/') > -1:
|
||||
return 'Edge Chromium'; // Match also / to avoid matching for the older Edge
|
||||
case agent.toLowerCase().indexOf('opr') > -1:
|
||||
return 'Opera';
|
||||
case agent.toLowerCase().indexOf('chrome') > -1:
|
||||
return 'Chrome';
|
||||
case agent.toLowerCase().indexOf('trident') > -1:
|
||||
return 'Internet Explorer';
|
||||
case agent.toLowerCase().indexOf('firefox') > -1:
|
||||
return 'Firefox';
|
||||
case agent.toLowerCase().indexOf('safari') > -1:
|
||||
return 'Safari';
|
||||
default:
|
||||
return 'PC';
|
||||
}
|
||||
}
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
body: {
|
||||
@@ -73,7 +100,9 @@ const authenticate = async (
|
||||
Username: body.username,
|
||||
},
|
||||
headers: {
|
||||
'x-emby-authorization': `MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="${packageJson.version}"`,
|
||||
'x-emby-authorization': `MediaBrowser Client="Feishin", Device="${getHostname()}", DeviceId="Feishin", Version="${
|
||||
packageJson.version
|
||||
}"`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -116,18 +145,33 @@ const getMusicFolderList = async (args: MusicFolderListArgs): Promise<MusicFolde
|
||||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
|
||||
const { apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getGenreList();
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getGenreList({
|
||||
query: {
|
||||
Fields: 'ItemCounts',
|
||||
ParentId: query?.musicFolderId,
|
||||
Recursive: true,
|
||||
SearchTerm: query?.searchTerm,
|
||||
SortBy: genreListSortMap.jellyfin[query.sortBy] || 'SortName',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: query.startIndex,
|
||||
UserId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get genre list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.Items.map(jfNormalize.genre),
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body?.Items?.length || 0,
|
||||
items: res.body.Items.map((item) => jfNormalize.genre(item, apiClientProps.server)),
|
||||
startIndex: query.startIndex || 0,
|
||||
totalRecordCount: res.body?.TotalRecordCount || 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -248,7 +292,7 @@ const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailRespons
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
IncludeItemTypes: 'Audio',
|
||||
ParentId: query.id,
|
||||
SortBy: 'Album,SortName',
|
||||
SortBy: 'ParentIndexNumber,IndexNumber,SortName',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -284,7 +328,9 @@ const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> =>
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
AlbumArtistIds: query.artistIds ? formatCommaDelimitedString(query.artistIds) : undefined,
|
||||
AlbumArtistIds: query.artistIds
|
||||
? formatCommaDelimitedString(query.artistIds)
|
||||
: undefined,
|
||||
IncludeItemTypes: 'MusicAlbum',
|
||||
Limit: query.limit,
|
||||
ParentId: query.musicFolderId,
|
||||
@@ -363,7 +409,9 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
||||
|
||||
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
|
||||
const albumIdsFilter = query.albumIds ? formatCommaDelimitedString(query.albumIds) : undefined;
|
||||
const artistIdsFilter = query.artistIds ? formatCommaDelimitedString(query.artistIds) : undefined;
|
||||
const artistIdsFilter = query.artistIds
|
||||
? formatCommaDelimitedString(query.artistIds)
|
||||
: undefined;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getSongList({
|
||||
params: {
|
||||
@@ -391,7 +439,9 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||
items: res.body.Items.map((item) =>
|
||||
jfNormalize.song(item, apiClientProps.server, '', query.imageSize),
|
||||
),
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: res.body.TotalRecordCount,
|
||||
};
|
||||
@@ -509,6 +559,20 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const musicFoldersRes = await jfApiClient(apiClientProps).getMusicFolderList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (musicFoldersRes.status !== 200) {
|
||||
throw new Error('Failed playlist folder');
|
||||
}
|
||||
|
||||
const playlistFolder = musicFoldersRes.body.Items.filter(
|
||||
(folder) => folder.CollectionType === jfType._enum.collection.PLAYLISTS,
|
||||
)?.[0];
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getPlaylistList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
@@ -517,8 +581,8 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
|
||||
Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
|
||||
IncludeItemTypes: 'Playlist',
|
||||
Limit: query.limit,
|
||||
MediaTypes: 'Audio',
|
||||
Recursive: true,
|
||||
ParentId: playlistFolder?.Id,
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: playlistListSortMap.jellyfin[query.sortBy],
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: query.startIndex,
|
||||
@@ -798,7 +862,9 @@ const search = async (args: SearchArgs): Promise<SearchResponse> => {
|
||||
}
|
||||
|
||||
return {
|
||||
albumArtists: albumArtists.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
|
||||
albumArtists: albumArtists.map((item) =>
|
||||
jfNormalize.albumArtist(item, apiClientProps.server),
|
||||
),
|
||||
albums: albums.map((item) => jfNormalize.album(item, apiClientProps.server)),
|
||||
songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||
};
|
||||
|
||||
@@ -140,7 +140,9 @@ const normalizeSong = (
|
||||
imageUrl: null,
|
||||
name: entry.Name,
|
||||
})),
|
||||
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
|
||||
bitRate:
|
||||
item.MediaSources?.[0].Bitrate &&
|
||||
Number(Math.trunc(item.MediaSources[0].Bitrate / 1000)),
|
||||
bpm: null,
|
||||
channels: null,
|
||||
comment: null,
|
||||
@@ -148,8 +150,19 @@ const normalizeSong = (
|
||||
container: (item.MediaSources && item.MediaSources[0]?.Container) || null,
|
||||
createdAt: item.DateCreated,
|
||||
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
|
||||
duration: item.RunTimeTicks / 10000000,
|
||||
genres: item.GenreItems.map((entry: any) => ({ id: entry.Id, name: entry.Name })),
|
||||
discSubtitle: null,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
gain: item.LUFS
|
||||
? {
|
||||
track: -18 - item.LUFS,
|
||||
}
|
||||
: null,
|
||||
genres: item.GenreItems?.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
itemType: LibraryItem.GENRE,
|
||||
name: entry.Name,
|
||||
})),
|
||||
id: item.Id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }),
|
||||
@@ -158,6 +171,7 @@ const normalizeSong = (
|
||||
lyrics: null,
|
||||
name: item.Name,
|
||||
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
|
||||
peak: null,
|
||||
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
||||
playlistItemId: item.PlaylistItemId,
|
||||
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
|
||||
@@ -194,11 +208,20 @@ const normalizeAlbum = (
|
||||
imageUrl: null,
|
||||
name: entry.Name,
|
||||
})) || [],
|
||||
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
|
||||
artists: item.ArtistItems?.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
name: entry.Name,
|
||||
})),
|
||||
backdropImageUrl: null,
|
||||
createdAt: item.DateCreated,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
genres: item.GenreItems?.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
itemType: LibraryItem.GENRE,
|
||||
name: entry.Name,
|
||||
})),
|
||||
id: item.Id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl: getAlbumCoverArtUrl({
|
||||
@@ -250,7 +273,12 @@ const normalizeAlbumArtist = (
|
||||
backgroundImageUrl: null,
|
||||
biography: item.Overview || null,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
genres: item.GenreItems?.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
itemType: LibraryItem.GENRE,
|
||||
name: entry.Name,
|
||||
})),
|
||||
id: item.Id,
|
||||
imageUrl: getAlbumArtistCoverArtUrl({
|
||||
baseUrl: server?.url || '',
|
||||
@@ -286,7 +314,12 @@ const normalizePlaylist = (
|
||||
return {
|
||||
description: item.Overview || null,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
genres: item.GenreItems?.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
itemType: LibraryItem.GENRE,
|
||||
name: entry.Name,
|
||||
})),
|
||||
id: item.Id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl: imageUrl || null,
|
||||
@@ -331,10 +364,32 @@ const normalizeMusicFolder = (item: JFMusicFolder): MusicFolder => {
|
||||
// };
|
||||
// };
|
||||
|
||||
const normalizeGenre = (item: JFGenre): Genre => {
|
||||
const getGenreCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
item: z.infer<typeof jfType._response.genre>;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
if (!args.item.ImageTags?.Primary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeGenre = (item: JFGenre, server: ServerListItem | null): Genre => {
|
||||
return {
|
||||
albumCount: undefined,
|
||||
id: item.Id,
|
||||
imageUrl: getGenreCoverArtUrl({ baseUrl: server?.url || '', item, size: 200 }),
|
||||
itemType: LibraryItem.GENRE,
|
||||
name: item.Name,
|
||||
songCount: undefined,
|
||||
};
|
||||
|
||||
@@ -304,10 +304,21 @@ const genre = z.object({
|
||||
Type: z.string(),
|
||||
});
|
||||
|
||||
const genreList = z.object({
|
||||
const genreList = pagination.extend({
|
||||
Items: z.array(genre),
|
||||
});
|
||||
|
||||
const genreListSort = {
|
||||
NAME: 'SortName',
|
||||
} as const;
|
||||
|
||||
const genreListParameters = paginationParameters.merge(
|
||||
baseParameters.extend({
|
||||
SearchTerm: z.string().optional(),
|
||||
SortBy: z.nativeEnum(genreListSort).optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
const musicFolder = z.object({
|
||||
BackdropImageTags: z.array(z.string()),
|
||||
ChannelId: z.null(),
|
||||
@@ -352,7 +363,7 @@ const playlist = z.object({
|
||||
UserData: userData,
|
||||
});
|
||||
|
||||
const jfPlaylistListSort = {
|
||||
const playlistListSort = {
|
||||
ALBUM_ARTIST: 'AlbumArtist,SortName',
|
||||
DURATION: 'Runtime',
|
||||
NAME: 'SortName',
|
||||
@@ -363,7 +374,7 @@ const jfPlaylistListSort = {
|
||||
const playlistListParameters = paginationParameters.merge(
|
||||
baseParameters.extend({
|
||||
IncludeItemTypes: z.literal('Playlist'),
|
||||
SortBy: z.nativeEnum(jfPlaylistListSort).optional(),
|
||||
SortBy: z.nativeEnum(playlistListSort).optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -395,6 +406,7 @@ const song = z.object({
|
||||
ImageTags: imageTags,
|
||||
IndexNumber: z.number(),
|
||||
IsFolder: z.boolean(),
|
||||
LUFS: z.number().optional(),
|
||||
LocationType: z.string(),
|
||||
MediaSources: z.array(mediaSources),
|
||||
MediaType: z.string(),
|
||||
@@ -461,7 +473,7 @@ const album = z.object({
|
||||
UserData: userData.optional(),
|
||||
});
|
||||
|
||||
const jfAlbumListSort = {
|
||||
const albumListSort = {
|
||||
ALBUM_ARTIST: 'AlbumArtist,SortName',
|
||||
COMMUNITY_RATING: 'CommunityRating,SortName',
|
||||
CRITIC_RATING: 'CriticRating,SortName',
|
||||
@@ -479,7 +491,7 @@ const albumListParameters = paginationParameters.merge(
|
||||
IncludeItemTypes: z.literal('MusicAlbum'),
|
||||
IsFavorite: z.boolean().optional(),
|
||||
SearchTerm: z.string().optional(),
|
||||
SortBy: z.nativeEnum(jfAlbumListSort).optional(),
|
||||
SortBy: z.nativeEnum(albumListSort).optional(),
|
||||
Tags: z.string().optional(),
|
||||
Years: z.string().optional(),
|
||||
}),
|
||||
@@ -489,7 +501,7 @@ const albumList = pagination.extend({
|
||||
Items: z.array(album),
|
||||
});
|
||||
|
||||
const jfAlbumArtistListSort = {
|
||||
const albumArtistListSort = {
|
||||
ALBUM: 'Album,SortName',
|
||||
DURATION: 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME: 'Name,SortName',
|
||||
@@ -502,7 +514,7 @@ const albumArtistListParameters = paginationParameters.merge(
|
||||
baseParameters.extend({
|
||||
Filters: z.string().optional(),
|
||||
Genres: z.string().optional(),
|
||||
SortBy: z.nativeEnum(jfAlbumArtistListSort).optional(),
|
||||
SortBy: z.nativeEnum(albumArtistListSort).optional(),
|
||||
Years: z.string().optional(),
|
||||
}),
|
||||
);
|
||||
@@ -515,9 +527,10 @@ const similarArtistListParameters = baseParameters.extend({
|
||||
Limit: z.number().optional(),
|
||||
});
|
||||
|
||||
const jfSongListSort = {
|
||||
const songListSort = {
|
||||
ALBUM: 'Album,SortName',
|
||||
ALBUM_ARTIST: 'AlbumArtist,Album,SortName',
|
||||
ALBUM_DETAIL: 'ParentIndexNumber,IndexNumber,SortName',
|
||||
ARTIST: 'Artist,Album,SortName',
|
||||
COMMUNITY_RATING: 'CommunityRating,SortName',
|
||||
DURATION: 'Runtime,AlbumArtist,Album,SortName',
|
||||
@@ -539,7 +552,7 @@ const songListParameters = paginationParameters.merge(
|
||||
Genres: z.string().optional(),
|
||||
IsFavorite: z.boolean().optional(),
|
||||
SearchTerm: z.string().optional(),
|
||||
SortBy: z.nativeEnum(jfSongListSort).optional(),
|
||||
SortBy: z.nativeEnum(songListSort).optional(),
|
||||
Tags: z.string().optional(),
|
||||
Years: z.string().optional(),
|
||||
}),
|
||||
@@ -642,9 +655,14 @@ const lyrics = z.object({
|
||||
|
||||
export const jfType = {
|
||||
_enum: {
|
||||
albumArtistList: albumArtistListSort,
|
||||
albumList: albumListSort,
|
||||
collection: jfCollection,
|
||||
external: jfExternal,
|
||||
genreList: genreListSort,
|
||||
image: jfImage,
|
||||
playlistList: playlistListSort,
|
||||
songList: songListSort,
|
||||
},
|
||||
_parameters: {
|
||||
addToPlaylist: addToPlaylistParameters,
|
||||
@@ -656,6 +674,7 @@ export const jfType = {
|
||||
createPlaylist: createPlaylistParameters,
|
||||
deletePlaylist: deletePlaylistParameters,
|
||||
favorite: favoriteParameters,
|
||||
genreList: genreListParameters,
|
||||
musicFolderList: musicFolderListParameters,
|
||||
playlistDetail: playlistDetailParameters,
|
||||
playlistList: playlistListParameters,
|
||||
|
||||
@@ -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';
|
||||
@@ -88,6 +88,7 @@ export const contract = c.router({
|
||||
getGenreList: {
|
||||
method: 'GET',
|
||||
path: 'genre',
|
||||
query: ndType._parameters.genreList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.genreList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
@@ -181,7 +182,8 @@ const parsePath = (fullPath: string) => {
|
||||
const newParams: Record<string, any> = {};
|
||||
Object.keys(parsedParams).forEach((key) => {
|
||||
const isIndexedArrayObject =
|
||||
typeof parsedParams[key] === 'object' && Object.keys(parsedParams[key] || {}).includes('0');
|
||||
typeof parsedParams[key] === 'object' &&
|
||||
Object.keys(parsedParams[key] || {}).includes('0');
|
||||
|
||||
if (!isIndexedArrayObject) {
|
||||
newParams[key] = parsedParams[key];
|
||||
@@ -280,7 +282,9 @@ axiosClient.interceptors.response.use(
|
||||
});
|
||||
|
||||
const serverId = currentServer.id;
|
||||
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
|
||||
useAuthStore
|
||||
.getState()
|
||||
.actions.updateServer(serverId, { ndCredential: undefined });
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
|
||||
// special error to prevent sending a second message, and stop other messages that could be enqueued
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
PlaylistSongListResponse,
|
||||
RemoveFromPlaylistResponse,
|
||||
RemoveFromPlaylistArgs,
|
||||
genreListSortMap,
|
||||
} from '../types';
|
||||
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
|
||||
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
|
||||
@@ -94,17 +95,25 @@ const getUserList = async (args: UserListArgs): Promise<UserListResponse> => {
|
||||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
|
||||
const { apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getGenreList({});
|
||||
const res = await ndApiClient(apiClientProps).getGenreList({
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: genreListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
name: query.searchTerm,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get genre list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data,
|
||||
startIndex: 0,
|
||||
items: res.body.data.map((genre) => ndNormalize.genre(genre)),
|
||||
startIndex: query.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
@@ -258,7 +267,9 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server, '')),
|
||||
items: res.body.data.map((song) =>
|
||||
ndNormalize.song(song, apiClientProps.server, '', query.imageSize),
|
||||
),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
@@ -351,6 +362,7 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
|
||||
_start: query.startIndex,
|
||||
q: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
@@ -394,7 +406,9 @@ const getPlaylistSongList = async (
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC',
|
||||
_sort: query.sortBy ? songListSortMap.navidrome[query.sortBy] : ndType._enum.songList.ID,
|
||||
_sort: query.sortBy
|
||||
? songListSortMap.navidrome[query.sortBy]
|
||||
: ndType._enum.songList.ID,
|
||||
_start: query.startIndex,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Song, LibraryItem, Album, Playlist, User, AlbumArtist } from '/@/renderer/api/types';
|
||||
import {
|
||||
Song,
|
||||
LibraryItem,
|
||||
Album,
|
||||
Playlist,
|
||||
User,
|
||||
AlbumArtist,
|
||||
Genre,
|
||||
} from '/@/renderer/api/types';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
import z from 'zod';
|
||||
import { ndType } from './navidrome-types';
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import { NDGenre } from '/@/renderer/api/navidrome.types';
|
||||
|
||||
const getImageUrl = (args: { url: string | null }) => {
|
||||
const { url } = args;
|
||||
if (url === '/app/artist-placeholder.webp') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url?.match('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
const getCoverArtUrl = (args: {
|
||||
baseUrl: string | undefined;
|
||||
@@ -52,7 +74,6 @@ const normalizeSong = (
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
album: item.album,
|
||||
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
@@ -67,8 +88,18 @@ const normalizeSong = (
|
||||
container: item.suffix,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
discNumber: item.discNumber,
|
||||
duration: item.duration,
|
||||
genres: item.genres,
|
||||
discSubtitle: item.discSubtitle ? item.discSubtitle : null,
|
||||
duration: item.duration * 1000,
|
||||
gain:
|
||||
item.rgAlbumGain || item.rgTrackGain
|
||||
? { album: item.rgAlbumGain, track: item.rgTrackGain }
|
||||
: null,
|
||||
genres: item.genres?.map((genre) => ({
|
||||
id: genre.id,
|
||||
imageUrl: null,
|
||||
itemType: LibraryItem.GENRE,
|
||||
name: genre.name,
|
||||
})),
|
||||
id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
@@ -77,6 +108,10 @@ const normalizeSong = (
|
||||
lyrics: item.lyrics ? item.lyrics : null,
|
||||
name: item.title,
|
||||
path: item.path,
|
||||
peak:
|
||||
item.rgAlbumPeak || item.rgTrackPeak
|
||||
? { album: item.rgAlbumPeak, track: item.rgTrackPeak }
|
||||
: null,
|
||||
playCount: item.playCount,
|
||||
playlistItemId,
|
||||
releaseDate: new Date(item.year, 0, 1).toISOString(),
|
||||
@@ -117,7 +152,12 @@ const normalizeAlbum = (
|
||||
backdropImageUrl: imageBackdropUrl,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
duration: item.duration * 1000 || null,
|
||||
genres: item.genres,
|
||||
genres: item.genres?.map((genre) => ({
|
||||
id: genre.id,
|
||||
imageUrl: null,
|
||||
itemType: LibraryItem.GENRE,
|
||||
name: genre.name,
|
||||
})),
|
||||
id: item.id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
@@ -146,15 +186,19 @@ const normalizeAlbumArtist = (
|
||||
},
|
||||
server: ServerListItem | null,
|
||||
): AlbumArtist => {
|
||||
const imageUrl =
|
||||
item.largeImageUrl === '/app/artist-placeholder.webp' ? null : item.largeImageUrl;
|
||||
const imageUrl = getImageUrl({ url: item?.largeImageUrl || null });
|
||||
|
||||
return {
|
||||
albumCount: item.albumCount,
|
||||
backgroundImageUrl: null,
|
||||
biography: item.biography || null,
|
||||
duration: null,
|
||||
genres: item.genres,
|
||||
genres: item.genres?.map((genre) => ({
|
||||
id: genre.id,
|
||||
imageUrl: null,
|
||||
itemType: LibraryItem.GENRE,
|
||||
name: genre.name,
|
||||
})),
|
||||
id: item.id,
|
||||
imageUrl: imageUrl || null,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
@@ -210,6 +254,17 @@ const normalizePlaylist = (
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeGenre = (item: NDGenre): Genre => {
|
||||
return {
|
||||
albumCount: undefined,
|
||||
id: item.id,
|
||||
imageUrl: null,
|
||||
itemType: LibraryItem.GENRE,
|
||||
name: item.name,
|
||||
songCount: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
|
||||
return {
|
||||
createdAt: item.createdAt,
|
||||
@@ -225,6 +280,7 @@ const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
|
||||
export const ndNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
genre: normalizeGenre,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
user: normalizeUser,
|
||||
|
||||
@@ -52,6 +52,16 @@ const genre = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
const genreListSort = {
|
||||
NAME: 'name',
|
||||
SONG_COUNT: 'songCount',
|
||||
} as const;
|
||||
|
||||
const genreListParameters = paginationParameters.extend({
|
||||
_sort: z.nativeEnum(genreListSort).optional(),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
const genreList = z.array(genre);
|
||||
|
||||
const albumArtist = z.object({
|
||||
@@ -171,22 +181,30 @@ const song = z.object({
|
||||
bitRate: z.number(),
|
||||
bookmarkPosition: z.number(),
|
||||
bpm: z.number().optional(),
|
||||
catalogNum: z.string().optional(),
|
||||
channels: z.number().optional(),
|
||||
comment: z.string().optional(),
|
||||
compilation: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
discNumber: z.number(),
|
||||
discSubtitle: z.string().optional(),
|
||||
duration: z.number(),
|
||||
embedArtPath: z.string().optional(),
|
||||
externalInfoUpdatedAt: z.string().optional(),
|
||||
externalUrl: z.string().optional(),
|
||||
fullText: z.string(),
|
||||
genre: z.string(),
|
||||
genres: z.array(genre),
|
||||
hasCoverArt: z.boolean(),
|
||||
id: z.string(),
|
||||
imageFiles: z.string().optional(),
|
||||
largeImageUrl: z.string().optional(),
|
||||
lyrics: z.string().optional(),
|
||||
mbzAlbumArtistId: z.string().optional(),
|
||||
mbzAlbumId: z.string().optional(),
|
||||
mbzArtistId: z.string().optional(),
|
||||
mbzTrackId: z.string().optional(),
|
||||
mediumImageUrl: z.string().optional(),
|
||||
orderAlbumArtistName: z.string(),
|
||||
orderAlbumName: z.string(),
|
||||
orderArtistName: z.string(),
|
||||
@@ -195,7 +213,12 @@ const song = z.object({
|
||||
playCount: z.number(),
|
||||
playDate: z.string(),
|
||||
rating: z.number().optional(),
|
||||
rgAlbumGain: z.number().optional(),
|
||||
rgAlbumPeak: z.number().optional(),
|
||||
rgTrackGain: z.number().optional(),
|
||||
rgTrackPeak: z.number().optional(),
|
||||
size: z.number(),
|
||||
smallImageUrl: z.string().optional(),
|
||||
sortAlbumArtistName: z.string(),
|
||||
sortArtistName: z.string(),
|
||||
starred: z.boolean(),
|
||||
@@ -236,6 +259,7 @@ const songListParameters = paginationParameters.extend({
|
||||
album_id: z.array(z.string()).optional(),
|
||||
artist_id: z.array(z.string()).optional(),
|
||||
genre_id: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
starred: z.boolean().optional(),
|
||||
title: z.string().optional(),
|
||||
year: z.number().optional(),
|
||||
@@ -273,6 +297,7 @@ const ndPlaylistListSort = {
|
||||
const playlistListParameters = paginationParameters.extend({
|
||||
_sort: z.nativeEnum(ndPlaylistListSort).optional(),
|
||||
owner_id: z.string().optional(),
|
||||
q: z.string().optional(),
|
||||
smart: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -321,6 +346,7 @@ export const ndType = {
|
||||
_enum: {
|
||||
albumArtistList: ndAlbumArtistListSort,
|
||||
albumList: ndAlbumListSort,
|
||||
genreList: genreListSort,
|
||||
playlistList: ndPlaylistListSort,
|
||||
songList: ndSongListSort,
|
||||
userList: ndUserListSort,
|
||||
@@ -331,6 +357,7 @@ export const ndType = {
|
||||
albumList: albumListParameters,
|
||||
authenticate: authenticateParameters,
|
||||
createPlaylist: createPlaylistParameters,
|
||||
genreList: genreListParameters,
|
||||
playlistList: playlistListParameters,
|
||||
removeFromPlaylist: removeFromPlaylistParameters,
|
||||
songList: songListParameters,
|
||||
|
||||
@@ -17,8 +17,33 @@ import type {
|
||||
RandomSongListQuery,
|
||||
LyricsQuery,
|
||||
LyricSearchQuery,
|
||||
GenreListQuery,
|
||||
} from './types';
|
||||
|
||||
export const splitPaginatedQuery = (key: any) => {
|
||||
const { startIndex, limit, ...filter } = key || {};
|
||||
|
||||
if (startIndex !== undefined || limit !== undefined) {
|
||||
return {
|
||||
filter,
|
||||
pagination: {
|
||||
limit,
|
||||
startIndex,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
filter,
|
||||
pagination: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export type QueryPagination = {
|
||||
limit?: number;
|
||||
startIndex?: number;
|
||||
};
|
||||
|
||||
export const queryKeys: Record<
|
||||
string,
|
||||
Record<string, (...props: any) => QueryFunctionContext['queryKey']>
|
||||
@@ -29,7 +54,15 @@ export const queryKeys: Record<
|
||||
return [serverId, 'albumArtists', 'detail'] as const;
|
||||
},
|
||||
list: (serverId: string, query?: AlbumArtistListQuery) => {
|
||||
if (query) return [serverId, 'albumArtists', 'list', query] as const;
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
if (query && pagination) {
|
||||
return [serverId, 'albumArtists', 'list', filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
return [serverId, 'albumArtists', 'list', filter] as const;
|
||||
}
|
||||
|
||||
return [serverId, 'albumArtists', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'albumArtists'] as const,
|
||||
@@ -41,10 +74,34 @@ export const queryKeys: Record<
|
||||
albums: {
|
||||
detail: (serverId: string, query?: AlbumDetailQuery) =>
|
||||
[serverId, 'albums', 'detail', query] as const,
|
||||
list: (serverId: string, query?: AlbumListQuery) => {
|
||||
if (query) return [serverId, 'albums', 'list', query] as const;
|
||||
list: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
|
||||
if (query && pagination && artistId) {
|
||||
return [serverId, 'albums', 'list', artistId, filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query && pagination) {
|
||||
return [serverId, 'albums', 'list', filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query && artistId) {
|
||||
return [serverId, 'albums', 'list', artistId, filter] as const;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
return [serverId, 'albums', 'list', filter] as const;
|
||||
}
|
||||
|
||||
return [serverId, 'albums', 'list'] as const;
|
||||
},
|
||||
related: (serverId: string, id: string, query?: AlbumDetailQuery) => {
|
||||
if (query) {
|
||||
return [serverId, 'albums', id, 'related', query] as const;
|
||||
}
|
||||
|
||||
return [serverId, 'albums', id, 'related'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'albums'],
|
||||
serverRoot: (serverId: string) => [serverId, 'albums'],
|
||||
songs: (serverId: string, query: SongListQuery) =>
|
||||
@@ -52,13 +109,32 @@ export const queryKeys: Record<
|
||||
},
|
||||
artists: {
|
||||
list: (serverId: string, query?: ArtistListQuery) => {
|
||||
if (query) return [serverId, 'artists', 'list', query] as const;
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
if (query && pagination) {
|
||||
return [serverId, 'artists', 'list', filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
return [serverId, 'artists', 'list', filter] as const;
|
||||
}
|
||||
|
||||
return [serverId, 'artists', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'artists'] as const,
|
||||
},
|
||||
genres: {
|
||||
list: (serverId: string) => [serverId, 'genres', 'list'] as const,
|
||||
list: (serverId: string, query?: GenreListQuery) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
if (query && pagination) {
|
||||
return [serverId, 'genres', 'list', filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
return [serverId, 'genres', 'list', filter] as const;
|
||||
}
|
||||
|
||||
return [serverId, 'genres', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'genres'] as const,
|
||||
},
|
||||
musicFolders: {
|
||||
@@ -66,22 +142,56 @@ export const queryKeys: Record<
|
||||
},
|
||||
playlists: {
|
||||
detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => {
|
||||
if (query) return [serverId, 'playlists', id, 'detail', query] as const;
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
if (query && pagination) {
|
||||
return [serverId, 'playlists', id, 'detail', filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
return [serverId, 'playlists', id, 'detail', filter] as const;
|
||||
}
|
||||
|
||||
if (id) return [serverId, 'playlists', id, 'detail'] as const;
|
||||
return [serverId, 'playlists', 'detail'] as const;
|
||||
},
|
||||
detailSongList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
|
||||
if (query) return [serverId, 'playlists', id, 'detailSongList', query] as const;
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
|
||||
if (query && id && pagination) {
|
||||
return [serverId, 'playlists', id, 'detailSongList', filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query && id) {
|
||||
return [serverId, 'playlists', id, 'detailSongList', filter] as const;
|
||||
}
|
||||
|
||||
if (id) return [serverId, 'playlists', id, 'detailSongList'] as const;
|
||||
|
||||
return [serverId, 'playlists', 'detailSongList'] as const;
|
||||
},
|
||||
list: (serverId: string, query?: PlaylistListQuery) => {
|
||||
if (query) return [serverId, 'playlists', 'list', query] as const;
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
if (query && pagination) {
|
||||
return [serverId, 'playlists', 'list', filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
return [serverId, 'playlists', 'list', filter] as const;
|
||||
}
|
||||
|
||||
return [serverId, 'playlists', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'playlists'] as const,
|
||||
songList: (serverId: string, id?: string, query?: PlaylistSongListQuery) => {
|
||||
if (query && id) return [serverId, 'playlists', id, 'songList', query] as const;
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
if (query && id && pagination) {
|
||||
return [serverId, 'playlists', id, 'songList', filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query && id) {
|
||||
return [serverId, 'playlists', id, 'songList', filter] as const;
|
||||
}
|
||||
|
||||
if (id) return [serverId, 'playlists', id, 'songList'] as const;
|
||||
return [serverId, 'playlists', 'songList'] as const;
|
||||
},
|
||||
@@ -102,11 +212,19 @@ export const queryKeys: Record<
|
||||
return [serverId, 'songs', 'detail'] as const;
|
||||
},
|
||||
list: (serverId: string, query?: SongListQuery) => {
|
||||
if (query) return [serverId, 'songs', 'list', query] as const;
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
if (query && pagination) {
|
||||
return [serverId, 'songs', 'list', filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
return [serverId, 'songs', 'list', filter] as const;
|
||||
}
|
||||
|
||||
return [serverId, 'songs', 'list'] as const;
|
||||
},
|
||||
lyrics: (serverId: string, query?: LyricsQuery) => {
|
||||
if (query) return [serverId, 'song', 'lyrics', query] as const;
|
||||
if (query) return [serverId, 'song', 'lyrics', 'select', query] as const;
|
||||
return [serverId, 'song', 'lyrics'] as const;
|
||||
},
|
||||
lyricsByRemoteId: (searchQuery: { remoteSongId: string; remoteSource: LyricSource }) => {
|
||||
|
||||
@@ -161,7 +161,9 @@ export const ssApiClient = (args: {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await axiosClient.request<z.infer<typeof ssType._response.baseResponse>>({
|
||||
const result = await axiosClient.request<
|
||||
z.infer<typeof ssType._response.baseResponse>
|
||||
>({
|
||||
data: body,
|
||||
headers,
|
||||
method: method as Method,
|
||||
|
||||
@@ -266,8 +266,9 @@ const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse>
|
||||
|
||||
return {
|
||||
items:
|
||||
res.body.topSongs?.song?.map((song) => ssNormalize.song(song, apiClientProps.server, '')) ||
|
||||
[],
|
||||
res.body.topSongs?.song?.map((song) =>
|
||||
ssNormalize.song(song, apiClientProps.server, ''),
|
||||
) || [],
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.topSongs?.song?.length || 0,
|
||||
};
|
||||
|
||||
@@ -67,11 +67,15 @@ const normalizeSong = (
|
||||
container: item.contentType,
|
||||
createdAt: item.created,
|
||||
discNumber: item.discNumber || 1,
|
||||
duration: item.duration || 0,
|
||||
discSubtitle: null,
|
||||
duration: item.duration ? item.duration * 1000 : 0,
|
||||
gain: null,
|
||||
genres: item.genre
|
||||
? [
|
||||
{
|
||||
id: item.genre,
|
||||
imageUrl: null,
|
||||
itemType: LibraryItem.GENRE,
|
||||
name: item.genre,
|
||||
},
|
||||
]
|
||||
@@ -84,6 +88,7 @@ const normalizeSong = (
|
||||
lyrics: null,
|
||||
name: item.title,
|
||||
path: item.path,
|
||||
peak: null,
|
||||
playCount: item?.playCount || 0,
|
||||
releaseDate: null,
|
||||
releaseYear: item.year ? String(item.year) : null,
|
||||
@@ -145,12 +150,23 @@ const normalizeAlbum = (
|
||||
}) || null;
|
||||
|
||||
return {
|
||||
albumArtists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
|
||||
albumArtists: item.artistId
|
||||
? [{ id: item.artistId, imageUrl: null, name: item.artist }]
|
||||
: [],
|
||||
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
|
||||
backdropImageUrl: null,
|
||||
createdAt: item.created,
|
||||
duration: item.duration,
|
||||
genres: item.genre ? [{ id: item.genre, name: item.genre }] : [],
|
||||
genres: item.genre
|
||||
? [
|
||||
{
|
||||
id: item.genre,
|
||||
imageUrl: null,
|
||||
itemType: LibraryItem.GENRE,
|
||||
name: item.genre,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { jfType } from './jellyfin/jellyfin-types';
|
||||
import {
|
||||
JFSortOrder,
|
||||
JFAlbumListSort,
|
||||
@@ -6,8 +7,9 @@ import {
|
||||
JFAlbumArtistListSort,
|
||||
JFArtistListSort,
|
||||
JFPlaylistListSort,
|
||||
JFGenreListSort,
|
||||
} from './jellyfin.types';
|
||||
import { jfType } from './jellyfin/jellyfin-types';
|
||||
import { ndType } from './navidrome/navidrome-types';
|
||||
import {
|
||||
NDSortOrder,
|
||||
NDOrder,
|
||||
@@ -16,13 +18,14 @@ import {
|
||||
NDPlaylistListSort,
|
||||
NDSongListSort,
|
||||
NDUserListSort,
|
||||
NDGenreListSort,
|
||||
} from './navidrome.types';
|
||||
import { ndType } from './navidrome/navidrome-types';
|
||||
|
||||
export enum LibraryItem {
|
||||
ALBUM = 'album',
|
||||
ALBUM_ARTIST = 'albumArtist',
|
||||
ARTIST = 'artist',
|
||||
GENRE = 'genre',
|
||||
PLAYLIST = 'playlist',
|
||||
SONG = 'song',
|
||||
}
|
||||
@@ -134,6 +137,8 @@ export type AuthenticationResponse = {
|
||||
export type Genre = {
|
||||
albumCount?: number;
|
||||
id: string;
|
||||
imageUrl: string | null;
|
||||
itemType: LibraryItem.GENRE;
|
||||
name: string;
|
||||
songCount?: number;
|
||||
};
|
||||
@@ -166,6 +171,11 @@ export type Album = {
|
||||
userRating: number | null;
|
||||
} & { songs?: Song[] };
|
||||
|
||||
export type GainInfo = {
|
||||
album?: number;
|
||||
track?: number;
|
||||
};
|
||||
|
||||
export type Song = {
|
||||
album: string | null;
|
||||
albumArtists: RelatedArtist[];
|
||||
@@ -180,7 +190,9 @@ export type Song = {
|
||||
container: string | null;
|
||||
createdAt: string;
|
||||
discNumber: number;
|
||||
discSubtitle: string | null;
|
||||
duration: number;
|
||||
gain: GainInfo | null;
|
||||
genres: Genre[];
|
||||
id: string;
|
||||
imagePlaceholderUrl: string | null;
|
||||
@@ -190,6 +202,7 @@ export type Song = {
|
||||
lyrics: string | null;
|
||||
name: string;
|
||||
path: string | null;
|
||||
peak: GainInfo | null;
|
||||
playCount: number;
|
||||
playlistItemId?: string;
|
||||
releaseDate: string | null;
|
||||
@@ -292,7 +305,40 @@ export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefine
|
||||
|
||||
export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
|
||||
|
||||
export type GenreListQuery = null;
|
||||
export enum GenreListSort {
|
||||
NAME = 'name',
|
||||
}
|
||||
|
||||
export type GenreListQuery = {
|
||||
_custom?: {
|
||||
jellyfin?: null;
|
||||
navidrome?: null;
|
||||
};
|
||||
limit?: number;
|
||||
musicFolderId?: string;
|
||||
searchTerm?: string;
|
||||
sortBy: GenreListSort;
|
||||
sortOrder: SortOrder;
|
||||
startIndex: number;
|
||||
};
|
||||
|
||||
type GenreListSortMap = {
|
||||
jellyfin: Record<GenreListSort, JFGenreListSort | undefined>;
|
||||
navidrome: Record<GenreListSort, NDGenreListSort | undefined>;
|
||||
subsonic: Record<UserListSort, undefined>;
|
||||
};
|
||||
|
||||
export const genreListSortMap: GenreListSortMap = {
|
||||
jellyfin: {
|
||||
name: JFGenreListSort.NAME,
|
||||
},
|
||||
navidrome: {
|
||||
name: NDGenreListSort.NAME,
|
||||
},
|
||||
subsonic: {
|
||||
name: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// Album List
|
||||
export type AlbumListResponse = BasePaginatedResponse<Album[]> | null | undefined;
|
||||
@@ -402,7 +448,7 @@ export type AlbumDetailQuery = { id: string };
|
||||
export type AlbumDetailArgs = { query: AlbumDetailQuery } & BaseEndpointArgs;
|
||||
|
||||
// Song List
|
||||
export type SongListResponse = BasePaginatedResponse<Song[]>;
|
||||
export type SongListResponse = BasePaginatedResponse<Song[]> | null | undefined;
|
||||
|
||||
export enum SongListSort {
|
||||
ALBUM = 'album',
|
||||
@@ -435,6 +481,7 @@ export type SongListQuery = {
|
||||
};
|
||||
albumIds?: string[];
|
||||
artistIds?: string[];
|
||||
imageSize?: number;
|
||||
limit?: number;
|
||||
musicFolderId?: string;
|
||||
searchTerm?: string;
|
||||
@@ -522,7 +569,7 @@ export type SongDetailQuery = { id: string };
|
||||
export type SongDetailArgs = { query: SongDetailQuery } & BaseEndpointArgs;
|
||||
|
||||
// Album Artist List
|
||||
export type AlbumArtistListResponse = BasePaginatedResponse<AlbumArtist[]> | null;
|
||||
export type AlbumArtistListResponse = BasePaginatedResponse<AlbumArtist[]> | null | undefined;
|
||||
|
||||
export enum AlbumArtistListSort {
|
||||
ALBUM = 'album',
|
||||
@@ -610,7 +657,7 @@ export type AlbumArtistDetailQuery = { id: string };
|
||||
export type AlbumArtistDetailArgs = { query: AlbumArtistDetailQuery } & BaseEndpointArgs;
|
||||
|
||||
// Artist List
|
||||
export type ArtistListResponse = BasePaginatedResponse<Artist[]>;
|
||||
export type ArtistListResponse = BasePaginatedResponse<Artist[]> | null | undefined;
|
||||
|
||||
export enum ArtistListSort {
|
||||
ALBUM = 'album',
|
||||
@@ -798,7 +845,7 @@ export type DeletePlaylistArgs = {
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
// Playlist List
|
||||
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>;
|
||||
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]> | null | undefined;
|
||||
|
||||
export enum PlaylistListSort {
|
||||
DURATION = 'duration',
|
||||
@@ -866,7 +913,7 @@ export type PlaylistDetailQuery = {
|
||||
export type PlaylistDetailArgs = { query: PlaylistDetailQuery } & BaseEndpointArgs;
|
||||
|
||||
// Playlist Songs
|
||||
export type PlaylistSongListResponse = BasePaginatedResponse<Song[]>;
|
||||
export type PlaylistSongListResponse = BasePaginatedResponse<Song[]> | null | undefined;
|
||||
|
||||
export type PlaylistSongListQuery = {
|
||||
id: string;
|
||||
@@ -879,7 +926,7 @@ export type PlaylistSongListQuery = {
|
||||
export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs;
|
||||
|
||||
// Music Folder List
|
||||
export type MusicFolderListResponse = BasePaginatedResponse<MusicFolder[]>;
|
||||
export type MusicFolderListResponse = BasePaginatedResponse<MusicFolder[]> | null | undefined;
|
||||
|
||||
export type MusicFolderListQuery = null;
|
||||
|
||||
@@ -887,7 +934,7 @@ export type MusicFolderListArgs = BaseEndpointArgs;
|
||||
|
||||
// User list
|
||||
// Playlist List
|
||||
export type UserListResponse = BasePaginatedResponse<User[]>;
|
||||
export type UserListResponse = BasePaginatedResponse<User[]> | null | undefined;
|
||||
|
||||
export enum UserListSort {
|
||||
NAME = 'name',
|
||||
@@ -927,7 +974,7 @@ export const userListSortMap: UserListSortMap = {
|
||||
};
|
||||
|
||||
// Top Songs List
|
||||
export type TopSongListResponse = BasePaginatedResponse<Song[]>;
|
||||
export type TopSongListResponse = BasePaginatedResponse<Song[]> | null | undefined;
|
||||
|
||||
export type TopSongListQuery = {
|
||||
artist: string;
|
||||
@@ -1073,6 +1120,7 @@ export type LyricSearchQuery = {
|
||||
export type LyricGetQuery = {
|
||||
remoteSongId: string;
|
||||
remoteSource: LyricSource;
|
||||
song: Song;
|
||||
};
|
||||
|
||||
export enum LyricSource {
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
|
||||
import { ModuleRegistry } from '@ag-grid-community/core';
|
||||
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import isElectron from 'is-electron';
|
||||
import { initSimpleImg } from 'react-simple-img';
|
||||
import { BaseContextModal } from './components';
|
||||
import { BaseContextModal, toast } from './components';
|
||||
import { useTheme } from './hooks';
|
||||
import { IsUpdatedDialog } from './is-updated-dialog';
|
||||
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';
|
||||
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
|
||||
import isElectron from 'is-electron';
|
||||
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
|
||||
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store';
|
||||
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
|
||||
import '@ag-grid-community/styles/ag-grid.css';
|
||||
|
||||
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
|
||||
|
||||
@@ -27,6 +33,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,17 +42,24 @@ export const App = () => {
|
||||
const { bindings } = useHotkeySettings();
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
const { clearQueue, restoreQueue } = useQueueControls();
|
||||
const remoteSettings = useRemoteSettings();
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--content-font-family', contentFont);
|
||||
}, [contentFont]);
|
||||
|
||||
const providerValue = useMemo(() => {
|
||||
return { handlePlayQueueAdd };
|
||||
}, [handlePlayQueueAdd]);
|
||||
|
||||
// Start the mpv instance on startup
|
||||
useEffect(() => {
|
||||
const initializeMpv = async () => {
|
||||
const isRunning: boolean | undefined = await mpvPlayer?.isRunning();
|
||||
|
||||
mpvPlayer?.stop();
|
||||
|
||||
if (!isRunning) {
|
||||
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
|
||||
const properties = {
|
||||
@@ -59,6 +73,7 @@ export const App = () => {
|
||||
|
||||
mpvPlayer?.volume(properties.volume);
|
||||
}
|
||||
mpvPlayer?.restoreQueue();
|
||||
};
|
||||
|
||||
if (isElectron() && playbackType === PlaybackType.LOCAL) {
|
||||
@@ -80,9 +95,7 @@ export const App = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
mpvPlayer.restoreQueue();
|
||||
|
||||
mpvPlayerListener.rendererSaveQueue(() => {
|
||||
mpvPlayerListener!.rendererSaveQueue(() => {
|
||||
const { current, queue } = usePlayerStore.getState();
|
||||
const stateToSave: Partial<Pick<PlayerState, 'current' | 'queue'>> = {
|
||||
current: {
|
||||
@@ -91,13 +104,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 +121,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
|
||||
@@ -178,12 +208,13 @@ export const App = () => {
|
||||
}}
|
||||
modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }}
|
||||
>
|
||||
<PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}>
|
||||
<PlayQueueHandlerContext.Provider value={providerValue}>
|
||||
<ContextMenuProvider>
|
||||
<AppRouter />
|
||||
</ContextMenuProvider>
|
||||
</PlayQueueHandlerContext.Provider>
|
||||
</ModalsProvider>
|
||||
<IsUpdatedDialog />
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useImperativeHandle, forwardRef, useRef, useState, useCallback, useEffect } from 'react';
|
||||
import isElectron from 'is-electron';
|
||||
import type { ReactPlayerProps } from 'react-player';
|
||||
import ReactPlayer from 'react-player';
|
||||
import ReactPlayer from 'react-player/lazy';
|
||||
import type { Song } from '/@/renderer/api/types';
|
||||
import {
|
||||
crossfadeHandler,
|
||||
@@ -33,6 +33,11 @@ const getDuration = (ref: any) => {
|
||||
return ref.current?.player?.player?.player?.duration;
|
||||
};
|
||||
|
||||
type WebAudio = {
|
||||
context: AudioContext;
|
||||
gain: GainNode;
|
||||
};
|
||||
|
||||
export const AudioPlayer = forwardRef(
|
||||
(
|
||||
{
|
||||
@@ -49,10 +54,86 @@ export const AudioPlayer = forwardRef(
|
||||
}: AudioPlayerProps,
|
||||
ref: any,
|
||||
) => {
|
||||
const player1Ref = useRef<any>(null);
|
||||
const player2Ref = useRef<any>(null);
|
||||
const player1Ref = useRef<ReactPlayer>(null);
|
||||
const player2Ref = useRef<ReactPlayer>(null);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
|
||||
const playback = useSettingsStore((state) => state.playback.mpvProperties);
|
||||
|
||||
const [webAudio, setWebAudio] = useState<WebAudio | null>(null);
|
||||
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(
|
||||
null,
|
||||
);
|
||||
const [player2Source, setPlayer2Source] = useState<MediaElementAudioSourceNode | null>(
|
||||
null,
|
||||
);
|
||||
const calculateReplayGain = useCallback(
|
||||
(song: Song): number => {
|
||||
if (playback.replayGainMode === 'no') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
let gain: number | undefined;
|
||||
let peak: number | undefined;
|
||||
|
||||
if (playback.replayGainMode === 'track') {
|
||||
gain = song.gain?.track ?? song.gain?.album;
|
||||
peak = song.peak?.track ?? song.peak?.album;
|
||||
} else {
|
||||
gain = song.gain?.album ?? song.gain?.track;
|
||||
peak = song.peak?.album ?? song.peak?.track;
|
||||
}
|
||||
|
||||
if (gain === undefined) {
|
||||
gain = playback.replayGainFallbackDB;
|
||||
|
||||
if (!gain) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (peak === undefined) {
|
||||
peak = 1;
|
||||
}
|
||||
|
||||
const preAmp = playback.replayGainPreampDB ?? 0;
|
||||
|
||||
// https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification§ion=19
|
||||
// Normalized to max gain
|
||||
const expectedGain = 10 ** ((gain + preAmp) / 20);
|
||||
|
||||
if (playback.replayGainClip) {
|
||||
return Math.min(expectedGain, 1 / peak);
|
||||
}
|
||||
return expectedGain;
|
||||
},
|
||||
[
|
||||
playback.replayGainClip,
|
||||
playback.replayGainFallbackDB,
|
||||
playback.replayGainMode,
|
||||
playback.replayGainPreampDB,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if ('AudioContext' in window) {
|
||||
const context = new AudioContext({
|
||||
latencyHint: 'playback',
|
||||
sampleRate: playback.audioSampleRateHz || undefined,
|
||||
});
|
||||
const gain = context.createGain();
|
||||
gain.connect(context.destination);
|
||||
|
||||
setWebAudio({ context, gain });
|
||||
|
||||
return () => {
|
||||
return context.close();
|
||||
};
|
||||
}
|
||||
return () => {};
|
||||
// Intentionally ignore the sample rate dependency, as it makes things really messy
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
get player1() {
|
||||
@@ -159,10 +240,71 @@ export const AudioPlayer = forwardRef(
|
||||
}
|
||||
}, [audioDeviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (webAudio && player1Source) {
|
||||
if (player1 === undefined) {
|
||||
player1Source.disconnect();
|
||||
setPlayer1Source(null);
|
||||
} else if (currentPlayer === 1) {
|
||||
webAudio.gain.gain.setValueAtTime(calculateReplayGain(player1), 0);
|
||||
}
|
||||
}
|
||||
}, [calculateReplayGain, currentPlayer, player1, player1Source, webAudio]);
|
||||
|
||||
useEffect(() => {
|
||||
if (webAudio && player2Source) {
|
||||
if (player2 === undefined) {
|
||||
player2Source.disconnect();
|
||||
setPlayer2Source(null);
|
||||
} else if (currentPlayer === 2) {
|
||||
webAudio.gain.gain.setValueAtTime(calculateReplayGain(player2), 0);
|
||||
}
|
||||
}
|
||||
}, [calculateReplayGain, currentPlayer, player2, player2Source, webAudio]);
|
||||
|
||||
const handlePlayer1Start = useCallback(
|
||||
async (player: ReactPlayer) => {
|
||||
if (!webAudio || player1Source) return;
|
||||
if (webAudio.context.state !== 'running') {
|
||||
await webAudio.context.resume();
|
||||
}
|
||||
|
||||
const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;
|
||||
if (internal) {
|
||||
const { context, gain } = webAudio;
|
||||
const source = context.createMediaElementSource(internal);
|
||||
source.connect(gain);
|
||||
setPlayer1Source(source);
|
||||
}
|
||||
},
|
||||
[player1Source, webAudio],
|
||||
);
|
||||
|
||||
const handlePlayer2Start = useCallback(
|
||||
async (player: ReactPlayer) => {
|
||||
if (!webAudio || player2Source) return;
|
||||
if (webAudio.context.state !== 'running') {
|
||||
await webAudio.context.resume();
|
||||
}
|
||||
|
||||
const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;
|
||||
if (internal) {
|
||||
const { context, gain } = webAudio;
|
||||
const source = context.createMediaElementSource(internal);
|
||||
source.connect(gain);
|
||||
setPlayer2Source(source);
|
||||
}
|
||||
},
|
||||
[player2Source, webAudio],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactPlayer
|
||||
ref={player1Ref}
|
||||
config={{
|
||||
file: { attributes: { crossOrigin: 'anonymous' }, forceAudio: true },
|
||||
}}
|
||||
height={0}
|
||||
muted={muted}
|
||||
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
|
||||
@@ -171,10 +313,16 @@ export const AudioPlayer = forwardRef(
|
||||
volume={volume}
|
||||
width={0}
|
||||
onEnded={handleOnEnded}
|
||||
onProgress={playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1}
|
||||
onProgress={
|
||||
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1
|
||||
}
|
||||
onReady={handlePlayer1Start}
|
||||
/>
|
||||
<ReactPlayer
|
||||
ref={player2Ref}
|
||||
config={{
|
||||
file: { attributes: { crossOrigin: 'anonymous' }, forceAudio: true },
|
||||
}}
|
||||
height={0}
|
||||
muted={muted}
|
||||
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
|
||||
@@ -183,7 +331,10 @@ export const AudioPlayer = forwardRef(
|
||||
volume={volume}
|
||||
width={0}
|
||||
onEnded={handleOnEnded}
|
||||
onProgress={playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2}
|
||||
onProgress={
|
||||
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2
|
||||
}
|
||||
onReady={handlePlayer2Start}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -101,9 +101,11 @@ export const crossfadeHandler = (args: {
|
||||
|
||||
percentageOfFadeLeft = timeLeft / fadeDuration;
|
||||
currentPlayerVolumeCalculation =
|
||||
Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) - 1)) * volume;
|
||||
Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) - 1)) *
|
||||
volume;
|
||||
nextPlayerVolumeCalculation =
|
||||
Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) + 1)) * volume;
|
||||
Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) + 1)) *
|
||||
volume;
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -27,8 +27,8 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
|
||||
transition: background 0.2s ease-in-out, color 0.2s ease-in-out, border 0.2s ease-in-out;
|
||||
|
||||
svg {
|
||||
transition: fill 0.2s ease-in-out;
|
||||
fill: ${(props) => `var(--btn-${props.variant}-fg)`};
|
||||
transition: fill 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -65,7 +65,6 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
margin-right: 0.5rem;
|
||||
transform: translateY(-0.1rem);
|
||||
}
|
||||
|
||||
.mantine-Button-rightIcon {
|
||||
|
||||
@@ -14,9 +14,9 @@ const CardWrapper = styled.div<{
|
||||
link?: boolean;
|
||||
}>`
|
||||
padding: 1rem;
|
||||
cursor: ${({ link }) => link && 'pointer'};
|
||||
background: var(--card-default-bg);
|
||||
border-radius: var(--card-default-radius);
|
||||
cursor: ${({ link }) => link && 'pointer'};
|
||||
transition: border 0.2s ease-in-out, background 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
@@ -61,17 +61,17 @@ const ImageSection = styled.div`
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
content: '';
|
||||
user-select: none;
|
||||
background: linear-gradient(0deg, rgb(0 0 0 / 100%) 35%, rgb(0 0 0 / 0%) 100%);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
`;
|
||||
|
||||
const Image = styled(SimpleImg)`
|
||||
border-radius: var(--card-default-radius);
|
||||
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 20%);
|
||||
box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 20%);
|
||||
`;
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
@@ -95,8 +95,8 @@ const Row = styled.div<{ $secondary?: boolean }>`
|
||||
padding: 0 0.2rem;
|
||||
overflow: hidden;
|
||||
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const PlayButton = styled.button<PlayButtonType>`
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-color: rgb(255, 255, 255);
|
||||
background-color: rgb(255 255 255);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
opacity: 0.8;
|
||||
@@ -41,8 +41,8 @@ const PlayButton = styled.button<PlayButtonType>`
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: rgb(0, 0, 0);
|
||||
stroke: rgb(0, 0, 0);
|
||||
fill: rgb(0 0 0);
|
||||
stroke: rgb(0 0 0);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { Album, AlbumArtist, Artist } from '/@/renderer/api/types';
|
||||
import { Album, AlbumArtist, Artist, Playlist, Song } from '/@/renderer/api/types';
|
||||
import { Text } from '/@/renderer/components/text';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { CardRow } from '/@/renderer/types';
|
||||
@@ -14,8 +14,8 @@ const Row = styled.div<{ $secondary?: boolean }>`
|
||||
padding: 0 0.2rem;
|
||||
overflow: hidden;
|
||||
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
@@ -60,7 +60,10 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
||||
row.route!.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[row.property][itemIndex][slug.idProperty],
|
||||
[slug.slugProperty]:
|
||||
data[row.property][itemIndex][
|
||||
slug.idProperty
|
||||
],
|
||||
};
|
||||
}, {}),
|
||||
)}
|
||||
@@ -180,6 +183,60 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
|
||||
},
|
||||
};
|
||||
|
||||
export const SONG_CARD_ROWS: { [key: string]: CardRow<Song> } = {
|
||||
album: {
|
||||
property: 'album',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'albumId', slugProperty: 'albumId' }],
|
||||
},
|
||||
},
|
||||
albumArtists: {
|
||||
arrayProperty: 'name',
|
||||
property: 'albumArtists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
artists: {
|
||||
arrayProperty: 'name',
|
||||
property: 'artists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
createdAt: {
|
||||
property: 'createdAt',
|
||||
},
|
||||
duration: {
|
||||
property: 'duration',
|
||||
},
|
||||
lastPlayedAt: {
|
||||
property: 'lastPlayedAt',
|
||||
},
|
||||
name: {
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'albumId', slugProperty: 'albumId' }],
|
||||
},
|
||||
},
|
||||
playCount: {
|
||||
property: 'playCount',
|
||||
},
|
||||
rating: {
|
||||
property: 'userRating',
|
||||
},
|
||||
releaseDate: {
|
||||
property: 'releaseDate',
|
||||
},
|
||||
releaseYear: {
|
||||
property: 'releaseYear',
|
||||
},
|
||||
};
|
||||
|
||||
export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
|
||||
albumCount: {
|
||||
property: 'albumCount',
|
||||
@@ -210,3 +267,35 @@ export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
|
||||
property: 'songCount',
|
||||
},
|
||||
};
|
||||
|
||||
export const PLAYLIST_CARD_ROWS: { [key: string]: CardRow<Playlist> } = {
|
||||
duration: {
|
||||
property: 'duration',
|
||||
},
|
||||
name: {
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.PLAYLISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }],
|
||||
},
|
||||
},
|
||||
nameFull: {
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.PLAYLISTS_DETAIL_SONGS,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }],
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
property: 'owner',
|
||||
},
|
||||
public: {
|
||||
property: 'public',
|
||||
},
|
||||
songCount: {
|
||||
property: 'songCount',
|
||||
},
|
||||
updatedAt: {
|
||||
property: 'songCount',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,8 +33,8 @@ const PosterCardContainer = styled.div<{ $isHidden?: boolean }>`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
|
||||
pointer-events: auto;
|
||||
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
|
||||
|
||||
.card-controls {
|
||||
opacity: 0;
|
||||
@@ -57,11 +57,11 @@ const ImageContainerStyles = css`
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
content: '';
|
||||
user-select: none;
|
||||
background: linear-gradient(0deg, rgb(0 0 0 / 100%) 35%, rgb(0 0 0 / 0%) 100%);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, ReactNode, Ref } from 'react';
|
||||
import { ComponentPropsWithoutRef, forwardRef, ReactNode, Ref } from 'react';
|
||||
import { Box, Group, UnstyledButton, UnstyledButtonProps } from '@mantine/core';
|
||||
import { motion, Variants } from 'framer-motion';
|
||||
import styled from 'styled-components';
|
||||
@@ -20,7 +20,7 @@ const ContextMenuContainer = styled(motion.div)<Omit<ContextMenuProps, 'children
|
||||
max-width: ${({ maxWidth }) => maxWidth}px;
|
||||
background: var(--dropdown-menu-bg);
|
||||
border-radius: var(--dropdown-menu-border-radius);
|
||||
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
|
||||
box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%);
|
||||
|
||||
button:first-child {
|
||||
border-top-left-radius: var(--dropdown-menu-border-radius);
|
||||
@@ -35,13 +35,13 @@ const ContextMenuContainer = styled(motion.div)<Omit<ContextMenuProps, 'children
|
||||
|
||||
export const StyledContextMenuButton = styled(UnstyledButton)`
|
||||
padding: var(--dropdown-menu-item-padding);
|
||||
color: var(--dropdown-menu-fg);
|
||||
font-weight: 500;
|
||||
font-family: var(--content-font-family);
|
||||
font-weight: 500;
|
||||
color: var(--dropdown-menu-fg);
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
background: var(--dropdown-menu-bg);
|
||||
border: none;
|
||||
cursor: default;
|
||||
|
||||
& .mantine-Button-inner {
|
||||
justify-content: flex-start;
|
||||
@@ -65,7 +65,7 @@ export const ContextMenuButton = forwardRef(
|
||||
leftIcon,
|
||||
...props
|
||||
}: UnstyledButtonProps &
|
||||
React.ComponentPropsWithoutRef<'button'> & {
|
||||
ComponentPropsWithoutRef<'button'> & {
|
||||
leftIcon?: ReactNode;
|
||||
rightIcon?: ReactNode;
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ const StyledDatePicker = styled(MantineDatePicker)<DatePickerProps>`
|
||||
}
|
||||
|
||||
& .mantine-DatePicker-label {
|
||||
font-family: var(--label-font-faimly);
|
||||
font-family: var(--label-font-family);
|
||||
}
|
||||
|
||||
& .mantine-DateRangePicker-disabled {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { DialogProps as MantineDialogProps } from '@mantine/core';
|
||||
import { Dialog as MantineDialog } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledDialog = styled(MantineDialog)`
|
||||
&.mantine-Dialog-root {
|
||||
background-color: var(--modal-bg);
|
||||
box-shadow: 2px 2px 10px 2px rgb(0 0 0 / 40%);
|
||||
}
|
||||
`;
|
||||
|
||||
export const Dialog = ({ ...props }: MantineDialogProps) => {
|
||||
return <StyledDialog {...props} />;
|
||||
};
|
||||