mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 12:30:12 +02:00
Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a177061f18 | |||
| d806ade84c | |||
| 854b76702b | |||
| 1b08dfc5a5 | |||
| 60fc552088 | |||
| 86438fdb3d | |||
| 92beae6665 | |||
| b110cfb94b | |||
| 08d4e80777 | |||
| ba03c19439 | |||
| 1c6346572b | |||
| b91c715b13 | |||
| f22434a50c | |||
| 2086b57fb2 | |||
| 2247ba08ea | |||
| 5601613c3d | |||
| d04d786951 | |||
| 613bfa7ae6 | |||
| a7a5b92011 | |||
| a9315be259 | |||
| e7b2f30718 | |||
| 73845a9432 | |||
| d52d9136b8 | |||
| a45e7f24e4 | |||
| 742cef3d81 | |||
| 132b0e173f | |||
| f7f3c5fe30 | |||
| 83b5afb187 | |||
| 237fb91a60 | |||
| 8265ce48c4 | |||
| bf3431cbc6 | |||
| cc6cad1d70 | |||
| c8b1e2312a | |||
| 84837a6887 | |||
| f1f6ccfd02 | |||
| f50d1e0a8c | |||
| 753ca01d41 | |||
| 960c126283 | |||
| 94b649fefe | |||
| 28bb699024 | |||
| 5caf0d439f | |||
| 77fa723cf8 | |||
| 77e220c873 | |||
| 860dd8b499 | |||
| 9113c6cc2e | |||
| 12d0eca2dd | |||
| 3a116e938e | |||
| f81bea339b | |||
| c947d09615 | |||
| af90d07414 | |||
| 025124c379 | |||
| 74075fc374 | |||
| dae2f9bd0a | |||
| 9a43ea0e4a | |||
| 2f105956b9 | |||
| ce9c03b0e1 | |||
| 1e5d446ced | |||
| b2fce071a9 | |||
| 20b161ee86 | |||
| 6e677d7454 | |||
| f796a35f5c | |||
| 83d5fee442 | |||
| eab11658bb | |||
| b4092c394a | |||
| 9b0c9ba3ac | |||
| e6b01d4e2b | |||
| fb08502e51 | |||
| 8f4ff9286a | |||
| dcd130fb6c | |||
| 60105103f3 | |||
| ff4ce89bc9 | |||
| eb4d099804 | |||
| b69290f9f2 | |||
| 69f82a9427 | |||
| adf5fc348a | |||
| f82da2e76b | |||
| 8de21a707c | |||
| 1f1916f005 | |||
| ae8fc6df13 | |||
| 0a658e3a22 | |||
| 92478b5ca5 | |||
| 49cbef729b | |||
| 73c6ddd116 | |||
| e3553074a3 | |||
| 29df2a6215 | |||
| aba7cb302f | |||
| 46cc1a635f | |||
| 6520a105d2 | |||
| 69cb63a8b0 | |||
| 1fb7290603 | |||
| 24bf7ae31f | |||
| 86a93866d0 | |||
| 933573b57f | |||
| ccb0e14e48 | |||
| aca6826221 | |||
| 095edfd49f | |||
| 73cd647486 | |||
| 9e4664a54c | |||
| efa0d9ec35 | |||
| 9720fcc202 | |||
| 7c25d12639 | |||
| 3daa1aef4b | |||
| 6aba41c3d9 | |||
| eff1cee6a3 | |||
| 9995b2e774 | |||
| 04a468f8c9 | |||
| afb8510cd7 | |||
| 8287347f91 | |||
| 5cc2276781 | |||
| 47ce0ed47b | |||
| f467a85a86 | |||
| 097211954c | |||
| 0cdfc64023 | |||
| bc7f4a5722 | |||
| 8e7356fa7b | |||
| bbf59a4942 | |||
| 45e589fbb1 | |||
| 527e6a76b5 | |||
| 362a88b6bc | |||
| 26102bd70a | |||
| 5f1d0a3b5e | |||
| 3bca85b3a8 | |||
| 61ecd3253e | |||
| 5e9ef9f23f | |||
| c8701d1da4 | |||
| b3a9e7ccba | |||
| 33972c2a83 | |||
| f0f2f54e5a | |||
| 99a188a62d | |||
| 1760e14ac5 | |||
| 888b5f9e90 | |||
| 5f099bedc2 | |||
| b2bb2f33a9 | |||
| f169fc7f3b | |||
| a970f967bc | |||
| 7c0320d69a | |||
| 8fcfbce0d5 | |||
| 432a128b85 | |||
| 85e889a414 | |||
| 4df5c555b0 | |||
| 372b96a349 | |||
| dcccccea2f | |||
| 2095ff6ab9 | |||
| a0b761c9ac | |||
| ea67a18962 | |||
| 5516daab6e | |||
| 2f28cb07bc | |||
| 21cd657f0c | |||
| 1ef7968834 | |||
| 925b1b4f68 | |||
| e7c665b0a0 | |||
| 245213d08a | |||
| c5e08b643d | |||
| 960427fce8 | |||
| 118a9f3257 | |||
| 971bfe3823 | |||
| 7765f14110 | |||
| 36b465504f | |||
| 7b639b45f7 | |||
| 85d9162b12 | |||
| 36670b330f | |||
| 9c380a8241 | |||
| c26820ee82 | |||
| dccd6afc3d | |||
| c6a520b0d7 | |||
| 1f4f3a5497 | |||
| 58d04b3126 | |||
| fcac4a5547 | |||
| c05b474827 | |||
| a8814d3e8a | |||
| 3f9cdab450 | |||
| 6f969294b0 | |||
| 3c278d5e17 |
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
Dockerfile
|
||||
docker-compose.*
|
||||
@@ -16,7 +16,7 @@ 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');
|
||||
checkNodeEnv('development');
|
||||
}
|
||||
|
||||
const port = process.env.PORT || 4343;
|
||||
@@ -28,171 +28,174 @@ const requiredByDLLConfig = module.parent!.filename.includes('webpack.config.ren
|
||||
* Warn if the DLL is not built
|
||||
*/
|
||||
if (!requiredByDLLConfig && !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))) {
|
||||
console.log(
|
||||
chalk.black.bgYellow.bold(
|
||||
'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"',
|
||||
),
|
||||
);
|
||||
execSync('npm run postinstall');
|
||||
console.log(
|
||||
chalk.black.bgYellow.bold(
|
||||
'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"',
|
||||
),
|
||||
);
|
||||
execSync('npm run postinstall');
|
||||
}
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
devtool: 'inline-source-map',
|
||||
devtool: 'inline-source-map',
|
||||
|
||||
mode: 'development',
|
||||
mode: 'development',
|
||||
|
||||
target: ['web', 'electron-renderer'],
|
||||
target: ['web', 'electron-renderer'],
|
||||
|
||||
entry: [
|
||||
`webpack-dev-server/client?http://localhost:${port}/dist`,
|
||||
'webpack/hot/only-dev-server',
|
||||
path.join(webpackPaths.srcRendererPath, 'index.tsx'),
|
||||
],
|
||||
|
||||
output: {
|
||||
path: webpackPaths.distRendererPath,
|
||||
publicPath: '/',
|
||||
filename: 'renderer.dev.js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
},
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.s?css$/,
|
||||
use: [
|
||||
'style-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?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',
|
||||
},
|
||||
entry: [
|
||||
`webpack-dev-server/client?http://localhost:${port}/dist`,
|
||||
'webpack/hot/only-dev-server',
|
||||
path.join(webpackPaths.srcRendererPath, 'index.tsx'),
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
...(requiredByDLLConfig
|
||||
? []
|
||||
: [
|
||||
new webpack.DllReferencePlugin({
|
||||
context: webpackPaths.dllPath,
|
||||
manifest: require(manifest),
|
||||
sourceType: 'var',
|
||||
}),
|
||||
]),
|
||||
|
||||
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 ReactRefreshWebpackPlugin(),
|
||||
|
||||
new HtmlWebpackPlugin({
|
||||
filename: path.join('index.html'),
|
||||
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true,
|
||||
},
|
||||
isBrowser: false,
|
||||
env: process.env.NODE_ENV,
|
||||
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||
nodeModules: webpackPaths.appNodeModulesPath,
|
||||
}),
|
||||
],
|
||||
|
||||
node: {
|
||||
__dirname: false,
|
||||
__filename: false,
|
||||
},
|
||||
|
||||
devServer: {
|
||||
port,
|
||||
compress: true,
|
||||
hot: true,
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
static: {
|
||||
publicPath: '/',
|
||||
output: {
|
||||
path: webpackPaths.distRendererPath,
|
||||
publicPath: '/',
|
||||
filename: 'renderer.dev.js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
},
|
||||
},
|
||||
historyApiFallback: {
|
||||
verbose: true,
|
||||
},
|
||||
setupMiddlewares(middlewares) {
|
||||
console.log('Starting preload.js builder...');
|
||||
const preloadProcess = spawn('npm', ['run', 'start:preload'], {
|
||||
shell: true,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
.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,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
.on('close', (code: number) => {
|
||||
preloadProcess.kill();
|
||||
remoteProcess.kill();
|
||||
process.exit(code!);
|
||||
})
|
||||
.on('error', (spawnError) => console.error(spawnError));
|
||||
return middlewares;
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.s?css$/,
|
||||
use: [
|
||||
'style-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?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: [
|
||||
...(requiredByDLLConfig
|
||||
? []
|
||||
: [
|
||||
new webpack.DllReferencePlugin({
|
||||
context: webpackPaths.dllPath,
|
||||
manifest: require(manifest),
|
||||
sourceType: 'var',
|
||||
}),
|
||||
]),
|
||||
|
||||
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 ReactRefreshWebpackPlugin(),
|
||||
|
||||
new HtmlWebpackPlugin({
|
||||
filename: path.join('index.html'),
|
||||
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true,
|
||||
},
|
||||
isBrowser: false,
|
||||
env: process.env.NODE_ENV,
|
||||
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||
nodeModules: webpackPaths.appNodeModulesPath,
|
||||
templateParameters: {
|
||||
web: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
node: {
|
||||
__dirname: false,
|
||||
__filename: false,
|
||||
},
|
||||
|
||||
devServer: {
|
||||
port,
|
||||
compress: true,
|
||||
hot: true,
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
static: {
|
||||
publicPath: '/',
|
||||
},
|
||||
historyApiFallback: {
|
||||
verbose: true,
|
||||
},
|
||||
setupMiddlewares(middlewares) {
|
||||
console.log('Starting preload.js builder...');
|
||||
const preloadProcess = spawn('npm', ['run', 'start:preload'], {
|
||||
shell: true,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
.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,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
.on('close', (code: number) => {
|
||||
preloadProcess.kill();
|
||||
remoteProcess.kill();
|
||||
process.exit(code!);
|
||||
})
|
||||
.on('error', (spawnError) => console.error(spawnError));
|
||||
return middlewares;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
|
||||
@@ -21,114 +21,117 @@ checkNodeEnv('production');
|
||||
deleteSourceMaps();
|
||||
|
||||
const devtoolsConfig =
|
||||
process.env.DEBUG_PROD === 'true'
|
||||
? {
|
||||
devtool: 'source-map',
|
||||
}
|
||||
: {};
|
||||
process.env.DEBUG_PROD === 'true'
|
||||
? {
|
||||
devtool: 'source-map',
|
||||
}
|
||||
: {};
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
...devtoolsConfig,
|
||||
...devtoolsConfig,
|
||||
|
||||
mode: 'production',
|
||||
mode: 'production',
|
||||
|
||||
target: ['web', 'electron-renderer'],
|
||||
target: ['web', 'electron-renderer'],
|
||||
|
||||
entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')],
|
||||
entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')],
|
||||
|
||||
output: {
|
||||
path: webpackPaths.distRendererPath,
|
||||
publicPath: './',
|
||||
filename: 'renderer.js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
output: {
|
||||
path: webpackPaths.distRendererPath,
|
||||
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,
|
||||
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',
|
||||
},
|
||||
},
|
||||
'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'),
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true,
|
||||
},
|
||||
isBrowser: false,
|
||||
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||
templateParameters: {
|
||||
web: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
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'),
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true,
|
||||
},
|
||||
isBrowser: false,
|
||||
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
|
||||
@@ -116,6 +116,9 @@ const configuration: webpack.Configuration = {
|
||||
env: process.env.NODE_ENV,
|
||||
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||
nodeModules: webpackPaths.appNodeModulesPath,
|
||||
templateParameters: {
|
||||
web: false, // with hot reload, we don't have NGINX injecting variables
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
|
||||
@@ -128,6 +128,9 @@ const configuration: webpack.Configuration = {
|
||||
},
|
||||
isBrowser: false,
|
||||
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||
templateParameters: {
|
||||
web: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -37,3 +37,17 @@ jobs:
|
||||
npm run build
|
||||
npm exec electron-builder -- --publish always --linux
|
||||
on_retry_command: npm cache clean --force
|
||||
|
||||
- name: Publish releases (arm64)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
npm run postinstall
|
||||
npm run build
|
||||
npm exec electron-builder -- --publish always --arm64
|
||||
on_retry_command: npm cache clean --force
|
||||
|
||||
Vendored
+4
-4
@@ -11,10 +11,10 @@
|
||||
],
|
||||
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true,
|
||||
"source.fixAll.stylelint": true,
|
||||
"source.organizeImports": false,
|
||||
"source.formatDocument": true
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.fixAll.stylelint": "explicit",
|
||||
"source.organizeImports": "never",
|
||||
"source.formatDocument": "explicit"
|
||||
},
|
||||
"css.validate": true,
|
||||
"less.validate": false,
|
||||
|
||||
+5
-1
@@ -1,16 +1,20 @@
|
||||
# --- Builder stage
|
||||
FROM node:18-alpine as builder
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
|
||||
#Copy package.json first to cache node_modules
|
||||
COPY package.json package-lock.json .
|
||||
# Scripts include electron-specific dependencies, which we don't need
|
||||
RUN npm install --legacy-peer-deps --ignore-scripts
|
||||
#Copy code and build with cached modules
|
||||
COPY . .
|
||||
RUN npm run build:web
|
||||
|
||||
# --- Production stage
|
||||
FROM nginx:alpine-slim
|
||||
|
||||
COPY --chown=nginx:nginx --from=builder /app/release/app/dist/web /usr/share/nginx/html
|
||||
COPY ./settings.js.template /etc/nginx/templates/settings.js.template
|
||||
COPY ng.conf.template /etc/nginx/templates/default.conf.template
|
||||
|
||||
ENV PUBLIC_PATH="/"
|
||||
|
||||
@@ -49,8 +49,12 @@ Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
|
||||
|
||||
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.
|
||||
|
||||
#### MacOS Notes
|
||||
|
||||
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.
|
||||
|
||||
For media keys to work, you will be prompted to allow Feishin to be a Trusted Accessibility Client. After allowing, you will need to restart Feishin for the privacy settings to take effect.
|
||||
|
||||
### 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.
|
||||
@@ -73,9 +77,12 @@ docker run --name feishin -p 9180:9180 feishin
|
||||
2. After restarting the app, you will be prompted to select a server. Click the `Open menu` button and select `Manage servers`. Click the `Add server` button in the popup and fill out all applicable details. You will need to enter the full URL to your server, including the protocol and port if applicable (e.g. `https://navidrome.my-server.com` or `http://192.168.0.1:4533`).
|
||||
|
||||
- **Navidrome** - For the best experience, select "Save password" when creating the server and configure the `SessionTimeout` setting in your Navidrome config to a larger value (e.g. 72h).
|
||||
- **Linux users** - The default password store uses `libsecret`. `kwallet4/5/6` are also supported, but must be explicitly set in Settings > Window > Passwords/secret score.
|
||||
|
||||
3. _Optional_ - If you want to host Feishin on a subpath (not `/`), then pass in the following environment variable: `PUBLIC_PATH=PATH`. For example, to host on `/feishin`, pass in `PUBLIC_PATH=/feishin`.
|
||||
|
||||
4. _Optional_ - To hard code the server url, pass the following environment variables: `SERVER_NAME`, `SERVER_TYPE` (one of `jellyfin` or `navidrome`), `SERVER_URL`. To prevent users from changing these settings, pass `SERVER_LOCK=true`. This can only be set if all three of the previous values are set.
|
||||
|
||||
## FAQ
|
||||
|
||||
### MPV is either not working or is rapidly switching between pause/play states
|
||||
@@ -86,7 +93,7 @@ First thing to do is check that your MPV binary path is correct. Navigate to the
|
||||
|
||||
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).
|
||||
|
||||
- [Navidrome](https://github.com/navidrome/navidrome)
|
||||
- [Navidrome](https://github.com/navidrome/navidrome) version 0.48.0 and newer
|
||||
- [Jellyfin](https://github.com/jellyfin/jellyfin)
|
||||
- [Funkwhale](https://funkwhale.audio/) - TBD
|
||||
- Subsonic-compatible servers - TBD
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
version: '3.5'
|
||||
services:
|
||||
feishin:
|
||||
container_name: feishin
|
||||
image: jeffvli/feishin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 9180:9180
|
||||
environment:
|
||||
- SERVER_NAME=jellyfin # pre defined server name
|
||||
- SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled
|
||||
- SERVER_TYPE=jellyfin # navidrome also works
|
||||
- SERVER_URL= # http://address:port
|
||||
@@ -16,4 +16,12 @@ server {
|
||||
alias /usr/share/nginx/html/;
|
||||
try_files $uri $uri/ /index.html =404;
|
||||
}
|
||||
|
||||
location ${PUBLIC_PATH}settings.js {
|
||||
alias /etc/nginx/conf.d/settings.js;
|
||||
}
|
||||
|
||||
location ${PUBLIC_PATH}/settings.js {
|
||||
alias /etc/nginx/conf.d/settings.js;
|
||||
}
|
||||
}
|
||||
Generated
+189
-258
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishin",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -28,13 +28,13 @@
|
||||
"@tanstack/react-query-persist-client": "^4.32.1",
|
||||
"@ts-rest/core": "^3.23.0",
|
||||
"@xhayper/discord-rpc": "^1.0.24",
|
||||
"axios": "^1.4.0",
|
||||
"axios": "^1.6.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"dayjs": "^1.11.6",
|
||||
"electron-debug": "^3.2.0",
|
||||
"electron-localshortcut": "^3.2.1",
|
||||
"electron-log": "^4.4.6",
|
||||
"electron-log": "^5.1.1",
|
||||
"electron-store": "^8.1.0",
|
||||
"electron-updater": "^4.6.5",
|
||||
"fast-average-color": "^9.3.0",
|
||||
@@ -66,9 +66,10 @@
|
||||
"react-virtualized-auto-sizer": "^1.0.17",
|
||||
"react-window": "^1.8.9",
|
||||
"react-window-infinite-loader": "^1.0.9",
|
||||
"semver": "^7.5.4",
|
||||
"styled-components": "^6.0.8",
|
||||
"swiper": "^9.3.1",
|
||||
"zod": "^3.21.4",
|
||||
"zod": "^3.22.3",
|
||||
"zustand": "^4.3.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -103,7 +104,7 @@
|
||||
"css-loader": "^6.7.1",
|
||||
"css-minimizer-webpack-plugin": "^3.4.1",
|
||||
"detect-port": "^1.3.0",
|
||||
"electron": "^27.1.0",
|
||||
"electron": "^26.6.10",
|
||||
"electron-builder": "^24.9.0",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-notarize": "^1.2.1",
|
||||
@@ -113,7 +114,7 @@
|
||||
"eslint-config-erb": "^4.0.3",
|
||||
"eslint-import-resolver-typescript": "^2.7.1",
|
||||
"eslint-import-resolver-webpack": "^0.13.2",
|
||||
"eslint-plugin-compat": "^4.0.2",
|
||||
"eslint-plugin-compat": "^4.2.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^26.1.3",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
@@ -286,11 +287,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.22.13",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
|
||||
"integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
|
||||
"version": "7.23.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz",
|
||||
"integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==",
|
||||
"dependencies": {
|
||||
"@babel/highlight": "^7.22.13",
|
||||
"@babel/highlight": "^7.23.4",
|
||||
"chalk": "^2.4.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -407,11 +408,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.22.15",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.15.tgz",
|
||||
"integrity": "sha512-Zu9oWARBqeVOW0dZOjXc3JObrzuqothQ3y/n1kUtrjCoCPLkXUwMvOo/F/TCfoHMbWIFlWwpZtkZVb9ga4U2pA==",
|
||||
"version": "7.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz",
|
||||
"integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.22.15",
|
||||
"@babel/types": "^7.23.6",
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
"@jridgewell/trace-mapping": "^0.3.17",
|
||||
"jsesc": "^2.5.1"
|
||||
@@ -548,20 +549,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-environment-visitor": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz",
|
||||
"integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==",
|
||||
"version": "7.22.20",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz",
|
||||
"integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-function-name": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz",
|
||||
"integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==",
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz",
|
||||
"integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.22.5",
|
||||
"@babel/types": "^7.22.5"
|
||||
"@babel/template": "^7.22.15",
|
||||
"@babel/types": "^7.23.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -703,17 +704,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
|
||||
"integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==",
|
||||
"version": "7.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz",
|
||||
"integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.22.19",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.19.tgz",
|
||||
"integrity": "sha512-Tinq7ybnEPFFXhlYOYFiSjespWQk0dq2dRNAiMdRTOYQzEGqnnNyrTxPYHP5r6wGjlF1rFgABdDV0g8EwD6Qbg==",
|
||||
"version": "7.22.20",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
|
||||
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -753,11 +754,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight": {
|
||||
"version": "7.22.13",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.13.tgz",
|
||||
"integrity": "sha512-C/BaXcnnvBCmHTpz/VGZ8jgtE2aYlW4hxDhseJAWZb7gqGM/qtCK6iZUb0TyKFf7BOUsBH7Q7fkRsDRhg1XklQ==",
|
||||
"version": "7.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz",
|
||||
"integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.22.5",
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"chalk": "^2.4.2",
|
||||
"js-tokens": "^4.0.0"
|
||||
},
|
||||
@@ -830,9 +831,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.22.16",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.16.tgz",
|
||||
"integrity": "sha512-+gPfKv8UWeKKeJTUxe59+OobVcrYHETCsORl61EmSkmgymguYk/X5bp7GuUIXaFsc6y++v8ZxPsLSSuujqDphA==",
|
||||
"version": "7.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz",
|
||||
"integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==",
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
@@ -2178,19 +2179,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.22.19",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.19.tgz",
|
||||
"integrity": "sha512-ZCcpVPK64krfdScRbpxF6xA5fz7IOsfMwx1tcACvCzt6JY+0aHkBk7eIU8FRDSZRU5Zei6Z4JfgAxN1bqXGECg==",
|
||||
"version": "7.23.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.7.tgz",
|
||||
"integrity": "sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.22.13",
|
||||
"@babel/generator": "^7.22.15",
|
||||
"@babel/helper-environment-visitor": "^7.22.5",
|
||||
"@babel/helper-function-name": "^7.22.5",
|
||||
"@babel/code-frame": "^7.23.5",
|
||||
"@babel/generator": "^7.23.6",
|
||||
"@babel/helper-environment-visitor": "^7.22.20",
|
||||
"@babel/helper-function-name": "^7.23.0",
|
||||
"@babel/helper-hoist-variables": "^7.22.5",
|
||||
"@babel/helper-split-export-declaration": "^7.22.6",
|
||||
"@babel/parser": "^7.22.16",
|
||||
"@babel/types": "^7.22.19",
|
||||
"debug": "^4.1.0",
|
||||
"@babel/parser": "^7.23.6",
|
||||
"@babel/types": "^7.23.6",
|
||||
"debug": "^4.3.1",
|
||||
"globals": "^11.1.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2206,12 +2207,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.22.19",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.19.tgz",
|
||||
"integrity": "sha512-P7LAw/LbojPzkgp5oznjE6tQEIWbp4PkkfrZDINTro9zgBRtI324/EYsiSI7lhPbpIQ+DCeR2NNmMWANGGfZsg==",
|
||||
"version": "7.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz",
|
||||
"integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.22.5",
|
||||
"@babel/helper-validator-identifier": "^7.22.19",
|
||||
"@babel/helper-string-parser": "^7.23.4",
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3861,9 +3862,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mdn/browser-compat-data": {
|
||||
"version": "5.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-5.3.5.tgz",
|
||||
"integrity": "sha512-aGZ/OZCv96fEvUNuOoHbWN9ySODB1Xf04+TFgK3QO/PaM1NI95INaf0YUBLUbbK9lMKWqUzyJk2TPLe3ybFFXg==",
|
||||
"version": "5.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-5.5.10.tgz",
|
||||
"integrity": "sha512-s2GGND9oLhEuksOFtICYOBZdMWPANBXTMqAXh89q6g1Mi3+OuWEmp9WFzw2v/nmS175vqeewpC1kDJA7taaxyA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@nicolo-ribaudo/chokidar-2": {
|
||||
@@ -6325,9 +6326,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz",
|
||||
"integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz",
|
||||
"integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.0",
|
||||
"form-data": "^4.0.0",
|
||||
@@ -6445,30 +6446,6 @@
|
||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-styled-components": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.0.7.tgz",
|
||||
"integrity": "sha512-i7YhvPgVqRKfoQ66toiZ06jPNA3p6ierpfUuEWxNF+fV27Uv5gxBkf8KZLHUCc1nFA9j6+80pYoIpqCeyW3/bA==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-annotate-as-pure": "^7.16.0",
|
||||
"@babel/helper-module-imports": "^7.16.0",
|
||||
"babel-plugin-syntax-jsx": "^6.18.0",
|
||||
"lodash": "^4.17.11",
|
||||
"picomatch": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"styled-components": ">= 2"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-syntax-jsx": {
|
||||
"version": "6.18.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
|
||||
"integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/babel-preset-current-node-syntax": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz",
|
||||
@@ -7097,9 +7074,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001517",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001517.tgz",
|
||||
"integrity": "sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==",
|
||||
"version": "1.0.30001585",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001585.tgz",
|
||||
"integrity": "sha512-yr2BWR1yLXQ8fMpdS/4ZZXpseBgE7o4g41x3a6AJOqZuOi+iE/WdJYAuZ6Y95i4Ohd2Y+9MzIWRR+uGABH4s3Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -8920,9 +8897,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "27.1.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-27.1.0.tgz",
|
||||
"integrity": "sha512-XPdJiO475QJ8cx59/goWNNWnlV0vab+Ut3occymos7VDxkHV5mFrlW6tcGi+M3bW6gBfwpJocWMng8tw542vww==",
|
||||
"version": "26.6.10",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-26.6.10.tgz",
|
||||
"integrity": "sha512-pV2SD0RXzAiNRb/2yZrsVmVkBOMrf+DVsPulIgRjlL0+My9BL5spFuhHVMQO9yHl9tFpWtuRpQv0ofM/i9P8xg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
@@ -9098,9 +9075,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-log": {
|
||||
"version": "4.4.6",
|
||||
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-4.4.6.tgz",
|
||||
"integrity": "sha512-nirYgRdY+F+vclr8ijdwy2vW03IzFpDHTaKNWu76dEN21Y76+smcES5knS7cgHUUB0qNLOi8vZO36taakjbSXA=="
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.1.1.tgz",
|
||||
"integrity": "sha512-If7HU4Slbh2xfjOXOLxifkbgu6HmWDNJyXPLW+XNTOHMfFKisg0trA3d/7syyu25S+lHosfsd0VMfDSjGn1+Pw==",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-notarize": {
|
||||
"version": "1.2.1",
|
||||
@@ -9949,19 +9929,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-compat": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-compat/-/eslint-plugin-compat-4.1.4.tgz",
|
||||
"integrity": "sha512-RxySWBmzfIROLFKgeJBJue2BU/6vM2KJWXWAUq+oW4QtrsZXRxbjgxmO1OfF3sHcRuuIenTS/wgo3GyUWZF24w==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-compat/-/eslint-plugin-compat-4.2.0.tgz",
|
||||
"integrity": "sha512-RDKSYD0maWy5r7zb5cWQS+uSPc26mgOzdORJ8hxILmWM7S/Ncwky7BcAtXVY5iRbKjBdHsWU8Yg7hfoZjtkv7w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@mdn/browser-compat-data": "^5.2.47",
|
||||
"@tsconfig/node14": "^1.0.3",
|
||||
"@mdn/browser-compat-data": "^5.3.13",
|
||||
"ast-metadata-inferer": "^0.8.0",
|
||||
"browserslist": "^4.21.5",
|
||||
"caniuse-lite": "^1.0.30001473",
|
||||
"browserslist": "^4.21.10",
|
||||
"caniuse-lite": "^1.0.30001524",
|
||||
"find-up": "^5.0.0",
|
||||
"lodash.memoize": "4.1.2",
|
||||
"semver": "7.3.8"
|
||||
"lodash.memoize": "^4.1.2",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.x"
|
||||
@@ -11056,9 +11035,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
|
||||
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
|
||||
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -12553,9 +12532,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz",
|
||||
"integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=",
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz",
|
||||
"integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
@@ -15083,9 +15062,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
|
||||
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -15970,9 +15949,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.27",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz",
|
||||
"integrity": "sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==",
|
||||
"version": "8.4.33",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz",
|
||||
"integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -15988,7 +15967,7 @@
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.6",
|
||||
"nanoid": "^3.3.7",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
@@ -17952,9 +17931,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.3.8",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
|
||||
"integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
|
||||
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
@@ -18251,21 +18230,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier/node_modules/semver": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
|
||||
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sirv": {
|
||||
"version": "1.0.19",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-1.0.19.tgz",
|
||||
@@ -19979,9 +19943,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz",
|
||||
"integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA=="
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
|
||||
},
|
||||
"node_modules/tsutils": {
|
||||
"version": "3.21.0",
|
||||
@@ -21387,9 +21351,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.21.4",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
|
||||
"integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==",
|
||||
"version": "3.22.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz",
|
||||
"integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -21510,11 +21474,11 @@
|
||||
}
|
||||
},
|
||||
"@babel/code-frame": {
|
||||
"version": "7.22.13",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
|
||||
"integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
|
||||
"version": "7.23.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz",
|
||||
"integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==",
|
||||
"requires": {
|
||||
"@babel/highlight": "^7.22.13",
|
||||
"@babel/highlight": "^7.23.4",
|
||||
"chalk": "^2.4.2"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -21604,11 +21568,11 @@
|
||||
}
|
||||
},
|
||||
"@babel/generator": {
|
||||
"version": "7.22.15",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.15.tgz",
|
||||
"integrity": "sha512-Zu9oWARBqeVOW0dZOjXc3JObrzuqothQ3y/n1kUtrjCoCPLkXUwMvOo/F/TCfoHMbWIFlWwpZtkZVb9ga4U2pA==",
|
||||
"version": "7.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz",
|
||||
"integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.22.15",
|
||||
"@babel/types": "^7.23.6",
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
"@jridgewell/trace-mapping": "^0.3.17",
|
||||
"jsesc": "^2.5.1"
|
||||
@@ -21715,17 +21679,17 @@
|
||||
}
|
||||
},
|
||||
"@babel/helper-environment-visitor": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz",
|
||||
"integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q=="
|
||||
"version": "7.22.20",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz",
|
||||
"integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA=="
|
||||
},
|
||||
"@babel/helper-function-name": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz",
|
||||
"integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==",
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz",
|
||||
"integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==",
|
||||
"requires": {
|
||||
"@babel/template": "^7.22.5",
|
||||
"@babel/types": "^7.22.5"
|
||||
"@babel/template": "^7.22.15",
|
||||
"@babel/types": "^7.23.0"
|
||||
}
|
||||
},
|
||||
"@babel/helper-hoist-variables": {
|
||||
@@ -21822,14 +21786,14 @@
|
||||
}
|
||||
},
|
||||
"@babel/helper-string-parser": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
|
||||
"integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw=="
|
||||
"version": "7.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz",
|
||||
"integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ=="
|
||||
},
|
||||
"@babel/helper-validator-identifier": {
|
||||
"version": "7.22.19",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.19.tgz",
|
||||
"integrity": "sha512-Tinq7ybnEPFFXhlYOYFiSjespWQk0dq2dRNAiMdRTOYQzEGqnnNyrTxPYHP5r6wGjlF1rFgABdDV0g8EwD6Qbg=="
|
||||
"version": "7.22.20",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
|
||||
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A=="
|
||||
},
|
||||
"@babel/helper-validator-option": {
|
||||
"version": "7.22.15",
|
||||
@@ -21857,11 +21821,11 @@
|
||||
}
|
||||
},
|
||||
"@babel/highlight": {
|
||||
"version": "7.22.13",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.13.tgz",
|
||||
"integrity": "sha512-C/BaXcnnvBCmHTpz/VGZ8jgtE2aYlW4hxDhseJAWZb7gqGM/qtCK6iZUb0TyKFf7BOUsBH7Q7fkRsDRhg1XklQ==",
|
||||
"version": "7.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz",
|
||||
"integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==",
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.22.5",
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"chalk": "^2.4.2",
|
||||
"js-tokens": "^4.0.0"
|
||||
},
|
||||
@@ -21918,9 +21882,9 @@
|
||||
}
|
||||
},
|
||||
"@babel/parser": {
|
||||
"version": "7.22.16",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.16.tgz",
|
||||
"integrity": "sha512-+gPfKv8UWeKKeJTUxe59+OobVcrYHETCsORl61EmSkmgymguYk/X5bp7GuUIXaFsc6y++v8ZxPsLSSuujqDphA=="
|
||||
"version": "7.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz",
|
||||
"integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ=="
|
||||
},
|
||||
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
|
||||
"version": "7.22.15",
|
||||
@@ -22789,19 +22753,19 @@
|
||||
}
|
||||
},
|
||||
"@babel/traverse": {
|
||||
"version": "7.22.19",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.19.tgz",
|
||||
"integrity": "sha512-ZCcpVPK64krfdScRbpxF6xA5fz7IOsfMwx1tcACvCzt6JY+0aHkBk7eIU8FRDSZRU5Zei6Z4JfgAxN1bqXGECg==",
|
||||
"version": "7.23.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.7.tgz",
|
||||
"integrity": "sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==",
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.22.13",
|
||||
"@babel/generator": "^7.22.15",
|
||||
"@babel/helper-environment-visitor": "^7.22.5",
|
||||
"@babel/helper-function-name": "^7.22.5",
|
||||
"@babel/code-frame": "^7.23.5",
|
||||
"@babel/generator": "^7.23.6",
|
||||
"@babel/helper-environment-visitor": "^7.22.20",
|
||||
"@babel/helper-function-name": "^7.23.0",
|
||||
"@babel/helper-hoist-variables": "^7.22.5",
|
||||
"@babel/helper-split-export-declaration": "^7.22.6",
|
||||
"@babel/parser": "^7.22.16",
|
||||
"@babel/types": "^7.22.19",
|
||||
"debug": "^4.1.0",
|
||||
"@babel/parser": "^7.23.6",
|
||||
"@babel/types": "^7.23.6",
|
||||
"debug": "^4.3.1",
|
||||
"globals": "^11.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -22813,12 +22777,12 @@
|
||||
}
|
||||
},
|
||||
"@babel/types": {
|
||||
"version": "7.22.19",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.19.tgz",
|
||||
"integrity": "sha512-P7LAw/LbojPzkgp5oznjE6tQEIWbp4PkkfrZDINTro9zgBRtI324/EYsiSI7lhPbpIQ+DCeR2NNmMWANGGfZsg==",
|
||||
"version": "7.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz",
|
||||
"integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==",
|
||||
"requires": {
|
||||
"@babel/helper-string-parser": "^7.22.5",
|
||||
"@babel/helper-validator-identifier": "^7.22.19",
|
||||
"@babel/helper-string-parser": "^7.23.4",
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
}
|
||||
},
|
||||
@@ -24055,9 +24019,9 @@
|
||||
"requires": {}
|
||||
},
|
||||
"@mdn/browser-compat-data": {
|
||||
"version": "5.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-5.3.5.tgz",
|
||||
"integrity": "sha512-aGZ/OZCv96fEvUNuOoHbWN9ySODB1Xf04+TFgK3QO/PaM1NI95INaf0YUBLUbbK9lMKWqUzyJk2TPLe3ybFFXg==",
|
||||
"version": "5.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-5.5.10.tgz",
|
||||
"integrity": "sha512-s2GGND9oLhEuksOFtICYOBZdMWPANBXTMqAXh89q6g1Mi3+OuWEmp9WFzw2v/nmS175vqeewpC1kDJA7taaxyA==",
|
||||
"dev": true
|
||||
},
|
||||
"@nicolo-ribaudo/chokidar-2": {
|
||||
@@ -26001,9 +25965,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"axios": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz",
|
||||
"integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz",
|
||||
"integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==",
|
||||
"requires": {
|
||||
"follow-redirects": "^1.15.0",
|
||||
"form-data": "^4.0.0",
|
||||
@@ -26095,27 +26059,6 @@
|
||||
"@babel/helper-define-polyfill-provider": "^0.4.2"
|
||||
}
|
||||
},
|
||||
"babel-plugin-styled-components": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.0.7.tgz",
|
||||
"integrity": "sha512-i7YhvPgVqRKfoQ66toiZ06jPNA3p6ierpfUuEWxNF+fV27Uv5gxBkf8KZLHUCc1nFA9j6+80pYoIpqCeyW3/bA==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@babel/helper-annotate-as-pure": "^7.16.0",
|
||||
"@babel/helper-module-imports": "^7.16.0",
|
||||
"babel-plugin-syntax-jsx": "^6.18.0",
|
||||
"lodash": "^4.17.11",
|
||||
"picomatch": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"babel-plugin-syntax-jsx": {
|
||||
"version": "6.18.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
|
||||
"integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"babel-preset-current-node-syntax": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz",
|
||||
@@ -26597,9 +26540,9 @@
|
||||
}
|
||||
},
|
||||
"caniuse-lite": {
|
||||
"version": "1.0.30001517",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001517.tgz",
|
||||
"integrity": "sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA=="
|
||||
"version": "1.0.30001585",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001585.tgz",
|
||||
"integrity": "sha512-yr2BWR1yLXQ8fMpdS/4ZZXpseBgE7o4g41x3a6AJOqZuOi+iE/WdJYAuZ6Y95i4Ohd2Y+9MzIWRR+uGABH4s3Q=="
|
||||
},
|
||||
"chalk": {
|
||||
"version": "4.1.2",
|
||||
@@ -27958,9 +27901,9 @@
|
||||
}
|
||||
},
|
||||
"electron": {
|
||||
"version": "27.1.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-27.1.0.tgz",
|
||||
"integrity": "sha512-XPdJiO475QJ8cx59/goWNNWnlV0vab+Ut3occymos7VDxkHV5mFrlW6tcGi+M3bW6gBfwpJocWMng8tw542vww==",
|
||||
"version": "26.6.10",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-26.6.10.tgz",
|
||||
"integrity": "sha512-pV2SD0RXzAiNRb/2yZrsVmVkBOMrf+DVsPulIgRjlL0+My9BL5spFuhHVMQO9yHl9tFpWtuRpQv0ofM/i9P8xg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@electron/get": "^2.0.0",
|
||||
@@ -28109,9 +28052,9 @@
|
||||
}
|
||||
},
|
||||
"electron-log": {
|
||||
"version": "4.4.6",
|
||||
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-4.4.6.tgz",
|
||||
"integrity": "sha512-nirYgRdY+F+vclr8ijdwy2vW03IzFpDHTaKNWu76dEN21Y76+smcES5knS7cgHUUB0qNLOi8vZO36taakjbSXA=="
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.1.1.tgz",
|
||||
"integrity": "sha512-If7HU4Slbh2xfjOXOLxifkbgu6HmWDNJyXPLW+XNTOHMfFKisg0trA3d/7syyu25S+lHosfsd0VMfDSjGn1+Pw=="
|
||||
},
|
||||
"electron-notarize": {
|
||||
"version": "1.2.1",
|
||||
@@ -28780,19 +28723,18 @@
|
||||
}
|
||||
},
|
||||
"eslint-plugin-compat": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-compat/-/eslint-plugin-compat-4.1.4.tgz",
|
||||
"integrity": "sha512-RxySWBmzfIROLFKgeJBJue2BU/6vM2KJWXWAUq+oW4QtrsZXRxbjgxmO1OfF3sHcRuuIenTS/wgo3GyUWZF24w==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-compat/-/eslint-plugin-compat-4.2.0.tgz",
|
||||
"integrity": "sha512-RDKSYD0maWy5r7zb5cWQS+uSPc26mgOzdORJ8hxILmWM7S/Ncwky7BcAtXVY5iRbKjBdHsWU8Yg7hfoZjtkv7w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@mdn/browser-compat-data": "^5.2.47",
|
||||
"@tsconfig/node14": "^1.0.3",
|
||||
"@mdn/browser-compat-data": "^5.3.13",
|
||||
"ast-metadata-inferer": "^0.8.0",
|
||||
"browserslist": "^4.21.5",
|
||||
"caniuse-lite": "^1.0.30001473",
|
||||
"browserslist": "^4.21.10",
|
||||
"caniuse-lite": "^1.0.30001524",
|
||||
"find-up": "^5.0.0",
|
||||
"lodash.memoize": "4.1.2",
|
||||
"semver": "7.3.8"
|
||||
"lodash.memoize": "^4.1.2",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"ast-metadata-inferer": {
|
||||
@@ -29589,9 +29531,9 @@
|
||||
}
|
||||
},
|
||||
"follow-redirects": {
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
|
||||
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA=="
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
|
||||
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw=="
|
||||
},
|
||||
"form-data": {
|
||||
"version": "4.0.0",
|
||||
@@ -30718,9 +30660,9 @@
|
||||
}
|
||||
},
|
||||
"ip": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz",
|
||||
"integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=",
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz",
|
||||
"integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==",
|
||||
"dev": true
|
||||
},
|
||||
"ipaddr.js": {
|
||||
@@ -32603,9 +32545,9 @@
|
||||
}
|
||||
},
|
||||
"nanoid": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
|
||||
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA=="
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g=="
|
||||
},
|
||||
"natural-compare": {
|
||||
"version": "1.4.0",
|
||||
@@ -33273,11 +33215,11 @@
|
||||
}
|
||||
},
|
||||
"postcss": {
|
||||
"version": "8.4.27",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz",
|
||||
"integrity": "sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==",
|
||||
"version": "8.4.33",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz",
|
||||
"integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==",
|
||||
"requires": {
|
||||
"nanoid": "^3.3.6",
|
||||
"nanoid": "^3.3.7",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
}
|
||||
@@ -34650,9 +34592,9 @@
|
||||
}
|
||||
},
|
||||
"semver": {
|
||||
"version": "7.3.8",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
|
||||
"integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
|
||||
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
|
||||
"requires": {
|
||||
"lru-cache": "^6.0.0"
|
||||
}
|
||||
@@ -34895,17 +34837,6 @@
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
|
||||
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"lru-cache": "^6.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sirv": {
|
||||
@@ -36220,9 +36151,9 @@
|
||||
}
|
||||
},
|
||||
"tslib": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz",
|
||||
"integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA=="
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
|
||||
},
|
||||
"tsutils": {
|
||||
"version": "3.21.0",
|
||||
@@ -37255,9 +37186,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"zod": {
|
||||
"version": "3.21.4",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
|
||||
"integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw=="
|
||||
"version": "3.22.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz",
|
||||
"integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="
|
||||
},
|
||||
"zustand": {
|
||||
"version": "4.3.9",
|
||||
|
||||
+9
-8
@@ -2,14 +2,14 @@
|
||||
"name": "feishin",
|
||||
"productName": "Feishin",
|
||||
"description": "Feishin music server",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.1",
|
||||
"scripts": {
|
||||
"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 .",
|
||||
"build:docker": "docker build -t jeffvli/feishin .",
|
||||
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
|
||||
"lint": "concurrently \"npm run lint:code\" \"npm run lint:styles\"",
|
||||
"lint:code": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
|
||||
@@ -132,7 +132,7 @@
|
||||
"tar.xz"
|
||||
],
|
||||
"icon": "assets/icons/icon.png",
|
||||
"category": "Development"
|
||||
"category": "AudioVideo;Audio;Player"
|
||||
},
|
||||
"directories": {
|
||||
"app": "release/app",
|
||||
@@ -230,7 +230,7 @@
|
||||
"css-loader": "^6.7.1",
|
||||
"css-minimizer-webpack-plugin": "^3.4.1",
|
||||
"detect-port": "^1.3.0",
|
||||
"electron": "^27.1.0",
|
||||
"electron": "^26.6.10",
|
||||
"electron-builder": "^24.9.0",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-notarize": "^1.2.1",
|
||||
@@ -240,7 +240,7 @@
|
||||
"eslint-config-erb": "^4.0.3",
|
||||
"eslint-import-resolver-typescript": "^2.7.1",
|
||||
"eslint-import-resolver-webpack": "^0.13.2",
|
||||
"eslint-plugin-compat": "^4.0.2",
|
||||
"eslint-plugin-compat": "^4.2.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^26.1.3",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
@@ -307,13 +307,13 @@
|
||||
"@tanstack/react-query-persist-client": "^4.32.1",
|
||||
"@ts-rest/core": "^3.23.0",
|
||||
"@xhayper/discord-rpc": "^1.0.24",
|
||||
"axios": "^1.4.0",
|
||||
"axios": "^1.6.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"dayjs": "^1.11.6",
|
||||
"electron-debug": "^3.2.0",
|
||||
"electron-localshortcut": "^3.2.1",
|
||||
"electron-log": "^4.4.6",
|
||||
"electron-log": "^5.1.1",
|
||||
"electron-store": "^8.1.0",
|
||||
"electron-updater": "^4.6.5",
|
||||
"fast-average-color": "^9.3.0",
|
||||
@@ -345,9 +345,10 @@
|
||||
"react-virtualized-auto-sizer": "^1.0.17",
|
||||
"react-window": "^1.8.9",
|
||||
"react-window-infinite-loader": "^1.0.9",
|
||||
"semver": "^7.5.4",
|
||||
"styled-components": "^6.0.8",
|
||||
"swiper": "^9.3.1",
|
||||
"zod": "^3.21.4",
|
||||
"zod": "^3.22.3",
|
||||
"zustand": "^4.3.9"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishin",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -15,7 +15,7 @@
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "25.3.0"
|
||||
"electron": "25.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get": {
|
||||
@@ -453,9 +453,9 @@
|
||||
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "25.3.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-25.3.0.tgz",
|
||||
"integrity": "sha512-cyqotxN+AroP5h2IxUsJsmehYwP5LrFAOO7O7k9tILME3Sa1/POAg3shrhx4XEnaAMyMqMLxzGvkzCVxzEErnA==",
|
||||
"version": "25.8.4",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-25.8.4.tgz",
|
||||
"integrity": "sha512-hUYS3RGdaa6E1UWnzeGnsdsBYOggwMMg4WGxNGvAoWtmRrr6J1BsjFW/yRq4WsJHJce2HdzQXtz4OGXV6yUCLg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
@@ -647,9 +647,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/global-agent/node_modules/semver": {
|
||||
"version": "7.3.8",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
|
||||
"integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
|
||||
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1166,9 +1166,9 @@
|
||||
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
|
||||
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -1672,9 +1672,9 @@
|
||||
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
||||
},
|
||||
"electron": {
|
||||
"version": "25.3.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-25.3.0.tgz",
|
||||
"integrity": "sha512-cyqotxN+AroP5h2IxUsJsmehYwP5LrFAOO7O7k9tILME3Sa1/POAg3shrhx4XEnaAMyMqMLxzGvkzCVxzEErnA==",
|
||||
"version": "25.8.4",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-25.8.4.tgz",
|
||||
"integrity": "sha512-hUYS3RGdaa6E1UWnzeGnsdsBYOggwMMg4WGxNGvAoWtmRrr6J1BsjFW/yRq4WsJHJce2HdzQXtz4OGXV6yUCLg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@electron/get": "^2.0.0",
|
||||
@@ -1818,9 +1818,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": {
|
||||
"version": "7.3.8",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
|
||||
"integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
|
||||
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2198,9 +2198,9 @@
|
||||
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
|
||||
},
|
||||
"semver": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
|
||||
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true
|
||||
},
|
||||
"semver-compare": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.1",
|
||||
"description": "",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
@@ -18,7 +18,7 @@
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "25.3.0"
|
||||
"electron": "25.8.4"
|
||||
},
|
||||
"license": "GPL-3.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"use strict";window.SERVER_URL="${SERVER_URL}";window.SERVER_NAME="${SERVER_NAME}";window.SERVER_TYPE="${SERVER_TYPE}";window.SERVER_LOCK=${SERVER_LOCK};
|
||||
+20
-5
@@ -1,4 +1,4 @@
|
||||
import { PostProcessorModule } from 'i18next';
|
||||
import { PostProcessorModule, TOptions, StringMap } from 'i18next';
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import en from './locales/en.json';
|
||||
@@ -14,6 +14,8 @@ import ptBr from './locales/pt-BR.json';
|
||||
import sr from './locales/sr.json';
|
||||
import sv from './locales/sv.json';
|
||||
import cs from './locales/cs.json';
|
||||
import nbNO from './locales/nb-NO.json';
|
||||
import nl from './locales/nl.json';
|
||||
|
||||
const resources = {
|
||||
en: { translation: en },
|
||||
@@ -29,6 +31,8 @@ const resources = {
|
||||
sr: { translation: sr },
|
||||
sv: { translation: sv },
|
||||
cs: { translation: cs },
|
||||
nl: { translation: nl },
|
||||
'nb-NO': { translation: nbNO },
|
||||
};
|
||||
|
||||
export const languages = [
|
||||
@@ -61,9 +65,14 @@ export const languages = [
|
||||
value: 'ja',
|
||||
},
|
||||
{
|
||||
label: 'Русский',
|
||||
value: 'ru',
|
||||
label: 'Nederlands',
|
||||
value: 'nl',
|
||||
},
|
||||
{
|
||||
label: 'Norsk (Bokmål)',
|
||||
value: 'nb-NO',
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Português (Brasil)',
|
||||
value: 'pt-BR',
|
||||
@@ -72,6 +81,10 @@ export const languages = [
|
||||
label: 'Polski',
|
||||
value: 'pl',
|
||||
},
|
||||
{
|
||||
label: 'Русский',
|
||||
value: 'ru',
|
||||
},
|
||||
{
|
||||
label: 'Srpski',
|
||||
value: 'sr',
|
||||
@@ -112,16 +125,18 @@ const titleCasePostProcessor: PostProcessorModule = {
|
||||
},
|
||||
};
|
||||
|
||||
const ignoreSentenceCaseLanguages = ['de']
|
||||
|
||||
const sentenceCasePostProcessor: PostProcessorModule = {
|
||||
type: 'postProcessor',
|
||||
name: 'sentenceCase',
|
||||
process: (value: string) => {
|
||||
process: (value: string, _key: string, _options: TOptions<StringMap>, translator: any) => {
|
||||
const sentences = value.split('. ');
|
||||
|
||||
return sentences
|
||||
.map((sentence) => {
|
||||
return (
|
||||
sentence.charAt(0).toLocaleUpperCase() + sentence.slice(1).toLocaleLowerCase()
|
||||
sentence.charAt(0).toLocaleUpperCase() + (!ignoreSentenceCaseLanguages.includes(translator.language) ? sentence.slice(1).toLocaleLowerCase() : sentence.slice(1))
|
||||
);
|
||||
})
|
||||
.join('. ');
|
||||
|
||||
+16
-11
@@ -41,7 +41,6 @@
|
||||
"hotkey_playbackPause": "pozastavení",
|
||||
"replayGainFallback": "fallback {{ReplayGain}}",
|
||||
"sidebarCollapsedNavigation_description": "zobrazit nebo skrýt navigaci ve sbaleném postranním panelu",
|
||||
"mpvExecutablePath_help": "jedna na řádek",
|
||||
"hotkey_volumeUp": "zvýšení hlasitosti",
|
||||
"skipDuration": "doba k přeskočení",
|
||||
"discordIdleStatus_description": "při povolení bude upraven stav když je přehrávač nečinný",
|
||||
@@ -53,7 +52,7 @@
|
||||
"skipDuration_description": "nastavení doby k přeskočení při použití tlačítek k přeskočení na liště přehrávače",
|
||||
"enableRemote_description": "povolí vzdálený ovládací server pro umožnění ostatním zařízením ovládat aplikaci",
|
||||
"fontType_optionSystem": "systémové písmo",
|
||||
"mpvExecutablePath_description": "nastavení cesty ke spustitelnému souboru mpv",
|
||||
"mpvExecutablePath_description": "nastavení cesty ke spustitelnému souboru mpv. pokud je prázdné, bude použita výchozí cesta",
|
||||
"replayGainClipping_description": "Zabránění clippingu způsobenému funkcí {{ReplayGain}} automatickým snížením zesílení",
|
||||
"replayGainPreamp": "před-zesílení {{ReplayGain}} (dB)",
|
||||
"hotkey_favoriteCurrentSong": "oblíbit $t(common.currentSong)",
|
||||
@@ -61,7 +60,7 @@
|
||||
"crossfadeStyle": "způsob prolnutí",
|
||||
"sidePlayQueueStyle_optionAttached": "připojené",
|
||||
"sidebarConfiguration": "nastavení postranního panelu",
|
||||
"sampleRate_description": "vyberte výstupní vzorkovací frekvenci k použití, když je vybraná vzorkovací frekvence jiná, než ta u aktuálního média",
|
||||
"sampleRate_description": "vyberte výstupní vzorkovací frekvenci k použití, když je vybraná vzorkovací frekvence jiná, než ta u aktuálního média. hodnota nižší než 8000 použije výchozí frekvenci",
|
||||
"replayGainMode_optionNone": "$t(common.none)",
|
||||
"replayGainClipping": "clipping {{ReplayGain}}",
|
||||
"hotkey_zoomIn": "přiblížení",
|
||||
@@ -191,7 +190,13 @@
|
||||
"discordRichPresence": "{{discord}} rich presence",
|
||||
"font_description": "nastavení písma použitého v aplikaci",
|
||||
"savePlayQueue_description": "uložit frontu přehrávání, když je aplikace zavřena a obnovit ji při otevření aplikace",
|
||||
"useSystemTheme": "použít systémový motiv"
|
||||
"useSystemTheme": "použít systémový motiv",
|
||||
"buttonSize": "velikost tlačítek lišty přehrávače",
|
||||
"buttonSize_description": "velikost tlačítek na liště přehrávače",
|
||||
"clearCache": "vymazat mezipaměť prohlížeče",
|
||||
"clearCache_description": "„tvrdé pročištění“ aplikace feishin. kromě mezipaměti aplikace feishin vymaže i mezipaměť prohlížeče (uložené obrázky a další zdroje). přihlašovací údaje k serveru a nastavení nebudou ovlivněny",
|
||||
"clearQueryCache": "vymazat mezipaměť aplikace feishin",
|
||||
"clearQueryCache_description": "„lehké pročištění“ aplikace feishin. tímto obnovíte seznamy skladeb, metadata skladeb a resetujete uložené texty. nastavení, přihlašovací údaje k serveru a obrázky v mezipaměti nebudou ovlivněny"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "upravit $t(entity.playlist_one)",
|
||||
@@ -559,7 +564,7 @@
|
||||
"error_savePassword": "při ukládání hesla se vyskytla chyba"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "přidáno {{message}} $t(entity.song_other) do {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"success": "přidáno $t(entity.trackWithCount, {\"count\": {{message}} }) do $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||
"title": "přidat do $t(entity.playlist_one)",
|
||||
"input_skipDuplicates": "přeskočit duplicity",
|
||||
"input_playlists": "$t(entity.playlist_other)"
|
||||
@@ -584,16 +589,16 @@
|
||||
"entity": {
|
||||
"genre_one": "žánr",
|
||||
"genre_few": "žánry",
|
||||
"genre_other": "žánrů",
|
||||
"genre_other": "žánry",
|
||||
"playlistWithCount_one": "{{count}} playlist",
|
||||
"playlistWithCount_few": "{{count}} playlisty",
|
||||
"playlistWithCount_other": "{{count}} playlistů",
|
||||
"playlist_one": "playlist",
|
||||
"playlist_few": "playlisty",
|
||||
"playlist_other": "playlistů",
|
||||
"playlist_other": "playlisty",
|
||||
"artist_one": "umělec",
|
||||
"artist_few": "umělci",
|
||||
"artist_other": "umělců",
|
||||
"artist_other": "umělci",
|
||||
"folderWithCount_one": "{{count}} složka",
|
||||
"folderWithCount_few": "{{count}} složky",
|
||||
"folderWithCount_other": "{{count}} složek",
|
||||
@@ -602,7 +607,7 @@
|
||||
"albumArtist_other": "umělců alba",
|
||||
"track_one": "skladba",
|
||||
"track_few": "skladby",
|
||||
"track_other": "skladeb",
|
||||
"track_other": "skladby",
|
||||
"albumArtistCount_one": "{{count}} umělec alba",
|
||||
"albumArtistCount_few": "{{count}} umělci alba",
|
||||
"albumArtistCount_other": "{{count}} umělců alba",
|
||||
@@ -617,11 +622,11 @@
|
||||
"artistWithCount_other": "{{count}} umělců",
|
||||
"folder_one": "složka",
|
||||
"folder_few": "složky",
|
||||
"folder_other": "složek",
|
||||
"folder_other": "složky",
|
||||
"smartPlaylist": "chytrý $t(entity.playlist_one)",
|
||||
"album_one": "album",
|
||||
"album_few": "alba",
|
||||
"album_other": "alb",
|
||||
"album_other": "alba",
|
||||
"genreWithCount_one": "{{count}} žánr",
|
||||
"genreWithCount_few": "{{count}} žánry",
|
||||
"genreWithCount_other": "{{count}} žánrů",
|
||||
|
||||
+98
-27
@@ -8,8 +8,8 @@
|
||||
"deletePlaylist": "löschen $t(entity.playlist_one)",
|
||||
"deselectAll": "Alle abwählen",
|
||||
"goToPage": "Gehe zur Seite",
|
||||
"moveToTop": "Nach Oben",
|
||||
"moveToBottom": "Nach Unten",
|
||||
"moveToTop": "Nach oben",
|
||||
"moveToBottom": "Nach unten",
|
||||
"removeFromPlaylist": "Entfernen von $t(entity.playlist_one)",
|
||||
"viewPlaylists": "Ansicht $t(entity.playlist_other)",
|
||||
"refresh": "$t(common.refresh)",
|
||||
@@ -23,7 +23,7 @@
|
||||
"increase": "erhöhen",
|
||||
"rating": "Wertung",
|
||||
"bpm": "bpm",
|
||||
"refresh": "erneuern",
|
||||
"refresh": "Aktualisieren",
|
||||
"unknown": "Unbekannt",
|
||||
"areYouSure": "Bist Du sicher?",
|
||||
"edit": "Bearbeiten",
|
||||
@@ -61,7 +61,9 @@
|
||||
"delete": "Löschen",
|
||||
"cancel": "Abbrechen",
|
||||
"forceRestartRequired": "Neustarten um die Änderungen zu übernehmen... Schließe die Benachrichtigung zum Neustarten",
|
||||
"setting": "Einstellung",
|
||||
"setting": "Einstellungen",
|
||||
"setting_one": "",
|
||||
"setting_other": "Einstellungen",
|
||||
"version": "Version",
|
||||
"title": "Titel",
|
||||
"filter_one": "Filter",
|
||||
@@ -84,7 +86,7 @@
|
||||
"sortOrder": "Reihenfolge",
|
||||
"none": "keine",
|
||||
"menu": "Menü",
|
||||
"restartRequired": "Neustart benötigt",
|
||||
"restartRequired": "(Neustart benötigt)",
|
||||
"previousSong": "vorheriger $t(entity.track_one)",
|
||||
"noResultsFromQuery": "Die Abfrage brachte keine Ergebnisse",
|
||||
"quit": "Verlassen",
|
||||
@@ -120,7 +122,7 @@
|
||||
"loginRateError": "Zu viele Anmeldeversuche, bitte versuche es in einigen Sekunden erneut"
|
||||
},
|
||||
"filter": {
|
||||
"mostPlayed": "Meist gespielt",
|
||||
"mostPlayed": "Meistgespielt",
|
||||
"comment": "Kommentar",
|
||||
"playCount": "Anzahl abgespielt",
|
||||
"recentlyUpdated": "kürzlich aktualisiert",
|
||||
@@ -191,7 +193,7 @@
|
||||
"error_savePassword": "Beim Versuch, das Passwort zu speichern, ist ein Fehler aufgetreten"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "{{message}} $t(entity.song_other) zu {{numOfPlaylists}} $t(entity.playlist_other) hinzugefügt",
|
||||
"success": "{{message}} $t(entity.track_other) zu {{numOfPlaylists}} $t(entity.playlist_other) hinzugefügt",
|
||||
"title": "Zu $t(entity.playlist_one) hinzufügen",
|
||||
"input_skipDuplicates": "Duplikate überspringen",
|
||||
"input_playlists": "$t(entity.playlist_other)"
|
||||
@@ -224,12 +226,12 @@
|
||||
"artist_other": "Interpreten",
|
||||
"folderWithCount_one": "{{count}} Verzeichnis",
|
||||
"folderWithCount_other": "{{count}} Verzeichnisse",
|
||||
"albumArtist_one": "Album Interpret",
|
||||
"albumArtist_other": "Album Interpreten",
|
||||
"albumArtist_one": "Albuminterpret",
|
||||
"albumArtist_other": "Albuminterpreten",
|
||||
"track_one": "Track",
|
||||
"track_other": "Tracks",
|
||||
"albumArtistCount_one": "{{count}} Album Interpret",
|
||||
"albumArtistCount_other": "{{count}} Album Interpreten",
|
||||
"albumArtistCount_one": "{{count}} Albuminterpret",
|
||||
"albumArtistCount_other": "{{count}} Albuminterpreten",
|
||||
"albumWithCount_one": "{{count}} Album",
|
||||
"albumWithCount_other": "{{count}} Alben",
|
||||
"favorite_one": "Favorit",
|
||||
@@ -249,8 +251,66 @@
|
||||
"table": {
|
||||
"config": {
|
||||
"view": {
|
||||
"table": "Tabelle"
|
||||
"table": "Tabelle",
|
||||
"card": "Karte",
|
||||
"poster": "Poster"
|
||||
},
|
||||
"general": {
|
||||
"tableColumns": "Tabellenspalten",
|
||||
"gap": "$t(common.gap)",
|
||||
"size": "$t(common.size)",
|
||||
"displayType": "Anzeigestil"
|
||||
},
|
||||
"label": {
|
||||
"dateAdded": "Hinzugefügt am",
|
||||
"lastPlayed": "zuletzt gespielt",
|
||||
"rowIndex": "Reihenindex",
|
||||
"trackNumber": "Tracknummer",
|
||||
"biography": "$t(common.biography)",
|
||||
"bitrate": "$t(common.bitrate)",
|
||||
"albumArtist": "$t(entity.albumArtist_one)",
|
||||
"artist": "$t(entity.artist_one)",
|
||||
"favorite": "$t(common.favorite)",
|
||||
"actions": "$t(common.action_other)",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"album": "$t(entity.album_one)",
|
||||
"size": "$t(common.size)",
|
||||
"bpm": "$t(common.bpm)",
|
||||
"titleCombined": "$t(common.title) (kombiniert)",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"duration": "$t(common.duration)",
|
||||
"note": "$t(common.note)",
|
||||
"owner": "$t(common.owner)",
|
||||
"path": "$t(common.path)",
|
||||
"rating": "$t(common.rating)",
|
||||
"releaseDate": "Veröffentlichungsdatum",
|
||||
"title": "$t(common.title)",
|
||||
"year": "$t(common.year)"
|
||||
}
|
||||
},
|
||||
"column": {
|
||||
"releaseYear": "Jahr",
|
||||
"biography": "Biografie",
|
||||
"releaseDate": "Veröffentlichungsdatum",
|
||||
"bitrate": "Bitrate",
|
||||
"title": "Titel",
|
||||
"path": "Pfad",
|
||||
"album": "Album",
|
||||
"albumArtist": "Albenkünstler",
|
||||
"bpm": "bpm",
|
||||
"favorite": "Favorit",
|
||||
"lastPlayed": "zuletzt gespielt",
|
||||
"rating": "Bewertung",
|
||||
"albumCount": "$t(entity.album_other)",
|
||||
"artist": "$t(entity.artist_one)",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"comment": "Kommentar",
|
||||
"dateAdded": "hinzugefügt am",
|
||||
"playCount": "Abgespielt",
|
||||
"discNumber": "Disk",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"songCount": "$t(entity.track_other)",
|
||||
"trackNumber": "Nr."
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
@@ -261,12 +321,12 @@
|
||||
"synchronized": "synchronisiert",
|
||||
"followCurrentLyric": "dem Songtext folgen",
|
||||
"opacity": "Deckkraft",
|
||||
"lyricSize": "Songtext Größe",
|
||||
"lyricSize": "Songtext-Größe",
|
||||
"showLyricProvider": "Songtext-Anbieter anzeigen",
|
||||
"unsynchronized": "nicht synchronisiert",
|
||||
"lyricAlignment": "Songtext Ausrichtung",
|
||||
"lyricAlignment": "Songtext-Ausrichtung",
|
||||
"useImageAspectRatio": "Bildseitenverhältnis verwenden",
|
||||
"lyricGap": "Songtext Lücke"
|
||||
"lyricGap": "Songtext-Lücke"
|
||||
},
|
||||
"upNext": "als nächstes",
|
||||
"lyrics": "Songtexte",
|
||||
@@ -278,21 +338,21 @@
|
||||
"manageServers": "Server verwalten",
|
||||
"expandSidebar": "Seitenleiste erweitern",
|
||||
"collapseSidebar": "Seitenleiste einklappen",
|
||||
"openBrowserDevtools": "Browser Entwicklungswerkzeuge öffnen",
|
||||
"openBrowserDevtools": "Browser-Entwicklungswerkzeuge öffnen",
|
||||
"goBack": "Gehe zurück",
|
||||
"goForward": "Gehe vorwärts",
|
||||
"settings": "$t(common.setting_other)",
|
||||
"quit": "$t(common.quit)"
|
||||
},
|
||||
"home": {
|
||||
"mostPlayed": "Meist gespielt",
|
||||
"mostPlayed": "Meistgespielt",
|
||||
"newlyAdded": "Neu hinzugefügte Veröffentlichungen",
|
||||
"explore": "Entdecken Sie Ihre Bibliothek",
|
||||
"explore": "Entdecke deine Bibliothek",
|
||||
"recentlyPlayed": "Kürzlich gespielt",
|
||||
"title": "$t(common.home)"
|
||||
},
|
||||
"albumDetail": {
|
||||
"moreFromArtist": "Mehr von diesem $t(entity.genre_one)",
|
||||
"moreFromArtist": "Mehr von diesem $t(entity.artist_one)",
|
||||
"moreFromGeneric": "Mehr von {{item}}"
|
||||
},
|
||||
"globalSearch": {
|
||||
@@ -389,7 +449,7 @@
|
||||
},
|
||||
"setting": {
|
||||
"audioDevice_description": "Wählen Sie das Audiogerät aus, das für die Wiedergabe verwendet werden soll (nur Webplayer).",
|
||||
"audioExclusiveMode": "Audio Exklusiver Modus",
|
||||
"audioExclusiveMode": "Audio-Exklusivmodus",
|
||||
"audioDevice": "Audiogerät",
|
||||
"accentColor": "Akzentfarbe",
|
||||
"accentColor_description": "Legt die Akzentfarbe für die Anwendung fest",
|
||||
@@ -418,23 +478,22 @@
|
||||
"theme_description": "Legt das für die Anwendung zu verwendende Thema fest",
|
||||
"hotkey_playbackPause": "Pause",
|
||||
"sidebarCollapsedNavigation_description": "Zeigt die Navigation in der minimierten Seitenleiste an oder verbirgt sie",
|
||||
"mpvExecutablePath_help": "eine pro Zeile",
|
||||
"hotkey_volumeUp": "Lauter",
|
||||
"skipDuration": "Sprung Dauer",
|
||||
"skipDuration": "Sprungdauer",
|
||||
"showSkipButtons": "Schaltflächen zum Überspringen anzeigen",
|
||||
"playButtonBehavior_optionPlay": "$t(player.play)",
|
||||
"minimumScrobblePercentage": "minimale Scrobble-Dauer (Prozentsatz)",
|
||||
"lyricFetch": "Songtexte aus dem Internet abrufen",
|
||||
"scrobble": "Scrobbeln",
|
||||
"skipDuration_description": "Legt die zu überspringende Dauer fest, wenn die Überspringen-Schaltflächen in der Player-Leiste verwendet werden",
|
||||
"mpvExecutablePath_description": "Legt den Pfad zur ausführbaren MPV-Datei fest",
|
||||
"mpvExecutablePath_description": "Legt den Pfad zur ausführbaren MPV-Datei fest. Wenn leer gelassen, wird der Standard-Pfad verwendet",
|
||||
"replayGainClipping_description": "Verhindern Sie durch {{ReplayGain}} verursachtes Clipping, indem Sie die Verstärkung automatisch verringern",
|
||||
"replayGainPreamp": "{{ReplayGain}} Vorverstärker (db)",
|
||||
"hotkey_favoriteCurrentSong": "Favorit $t(common.currentSong)",
|
||||
"sampleRate": "Abtastrate",
|
||||
"sidePlayQueueStyle_optionAttached": "angefügt",
|
||||
"sidebarConfiguration": "Seitenleistenkonfiguration",
|
||||
"sampleRate_description": "Wählen Sie die auszugebende Abtastrate aus, wenn sich die ausgewählte Abtastfrequenz von der des aktuellen Mediums unterscheidet",
|
||||
"sampleRate_description": "Wähle die auszugebende Abtastrate aus, wenn sich die ausgewählte Abtastfrequenz von der des aktuellen Mediums unterscheidet. Ein Wert unter 8000 wird die Standard-Frequenz verwenden",
|
||||
"replayGainMode_optionNone": "$t(common.none)",
|
||||
"hotkey_zoomIn": "Hineinzoomen",
|
||||
"scrobble_description": "Scrobble wird auf Ihrem Medienserver abgespielt",
|
||||
@@ -484,7 +543,7 @@
|
||||
"savePlayQueue": "Wiedergabe-Warteschlange speichern",
|
||||
"minimumScrobbleSeconds_description": "die Mindestdauer in Sekunden, die das Lied abspielen muss, bevor es gescrobbelt wird",
|
||||
"skipPlaylistPage_description": "Gehen Sie beim Navigieren zu einer Wiedergabeliste zur Titelseite der Wiedergabeliste und nicht zur Standardseite",
|
||||
"fontType_description": "Die integrierte Schriftart wählt eine der von Feishin bereitgestellten Schriftarten aus. Mit der Systemschriftart können Sie jede von Ihrem Betriebssystem bereitgestellte Schriftart auswählen. Benutzerdefiniert erlaubt es eine eigene Schriftart bereitstellen",
|
||||
"fontType_description": "Die integrierte Schriftart wählt eine der von Feishin bereitgestellten Schriftarten aus. Mit der Systemschriftart können Sie jede von Ihrem Betriebssystem bereitgestellte Schriftart auswählen. Benutzerdefiniert erlaubt es eine eigene Schriftart bereitzustellen",
|
||||
"playButtonBehavior": "Verhalten der Wiedergabetaste",
|
||||
"volumeWheelStep": "Lautstärkeregler Stufe",
|
||||
"sidebarPlaylistList_description": "Ein- oder Ausblenden der Playlisten-Liste in der Seitenleiste",
|
||||
@@ -499,7 +558,7 @@
|
||||
"sidebarConfiguration_description": "Wählen Sie die Elemente und die Reihenfolge aus, in der sie in der Seitenleiste angezeigt werden",
|
||||
"remotePort": "Port des Fernsteuerungsserver",
|
||||
"hotkey_playbackNext": "Nächster Track",
|
||||
"useSystemTheme_description": "der systemdefinierten Hell oder Dunkel Präferenz folgen",
|
||||
"useSystemTheme_description": "der systemdefinierten Hell- oder Dunkelpräferenz folgen",
|
||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||
"lyricFetch_description": "Songtexte aus verschiedenen Internetquellen abrufen",
|
||||
"lyricFetchProvider_description": "Wählen Sie die Anbieter aus, von denen Sie Liedtexte abrufen möchten. Die Reihenfolge der Anbieter ist die Reihenfolge, in der sie abgefragt werden",
|
||||
@@ -537,6 +596,18 @@
|
||||
"fontType": "Schriftartenquelle",
|
||||
"followLyric": "Songtext synchronisieren",
|
||||
"floatingQueueArea_description": "Zeige ein Icon auf der rechten Seite, um beim Darüberfahren die Wartschlange anzuzeigen",
|
||||
"font_description": "Wähle die Schriftart für die Anwendung"
|
||||
"font_description": "Wähle die Schriftart für die Anwendung",
|
||||
"themeLight": "Thema (hell)",
|
||||
"sidePlayQueueStyle_optionDetached": "lösgelöst",
|
||||
"windowBarStyle_description": "Wähle den Stil der Windows-Leiste",
|
||||
"hotkey_toggleCurrentSongFavorite": "$t(common.currentSong) zu Favoriten hinzufügen",
|
||||
"clearQueryCache_description": "\"Weiches\" Zurücksetzen. Dies wird Playlisten, Musik-Metadaten und gespeicherte Liedtexte zurücksetzen, Zugangsinformationen und zwischengespeicherte Bilder werden behalten",
|
||||
"discordRichPresence_description": "Zeige deinen Wiedergabe-Status in {{discord}} als rich presence an. Angezeigte Bilder sind: {{icon}}, {{playing}}, und {{paused}} ",
|
||||
"clearCache": "Browser-Zwischenspeicher löschen",
|
||||
"clearQueryCache": "feishins Zwischenspeicher leeren",
|
||||
"clearCache_description": "Hartes Zurücksetzen. Neben feishins Zwischenspeicher wird auch der des Browsers gelöscht (Bilder und andere Daten). Zugangsinformationen und Einstellungen werden behalten",
|
||||
"sidePlayQueueStyle": "Wiedergabelistenstil in der Seitenleiste",
|
||||
"zoom_description": "Setzt den Zoom (in %) für das Programm",
|
||||
"zoom": "Zoom"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
"removeFromQueue": "remove from queue",
|
||||
"setRating": "set rating",
|
||||
"toggleSmartPlaylistEditor": "toggle $t(entity.smartPlaylist) editor",
|
||||
"viewPlaylists": "view $t(entity.playlist_other)"
|
||||
"viewPlaylists": "view $t(entity.playlist_other)",
|
||||
"openIn": {
|
||||
"lastfm": "Open in Last.fm",
|
||||
"musicbrainz": "Open in MusicBrainz"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"action_one": "action",
|
||||
@@ -33,6 +37,8 @@
|
||||
"channel_one": "channel",
|
||||
"channel_other": "channels",
|
||||
"clear": "clear",
|
||||
"close": "close",
|
||||
"codec": "codec",
|
||||
"collapse": "collapse",
|
||||
"comingSoon": "coming soon…",
|
||||
"configure": "configure",
|
||||
@@ -80,6 +86,7 @@
|
||||
"random": "random",
|
||||
"rating": "rating",
|
||||
"refresh": "refresh",
|
||||
"reload": "reload",
|
||||
"reset": "reset",
|
||||
"resetToDefault": "reset to default",
|
||||
"restartRequired": "restart required",
|
||||
@@ -216,7 +223,7 @@
|
||||
"addToPlaylist": {
|
||||
"input_playlists": "$t(entity.playlist_other)",
|
||||
"input_skipDuplicates": "skip duplicates",
|
||||
"success": "added {{message}} $t(entity.song_other) to {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"success": "added $t(entity.trackWithCount, {\"count\": {{message}} }) to $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||
"title": "add to $t(entity.playlist_one)"
|
||||
},
|
||||
"createPlaylist": {
|
||||
@@ -250,6 +257,17 @@
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"albumArtistDetail": {
|
||||
"about": "About {{artist}}",
|
||||
"appearsOn": "appears on",
|
||||
"recentReleases": "recent releases",
|
||||
"viewDiscography": "view discography",
|
||||
"relatedArtists": "related $t(entity.artist_other)",
|
||||
"topSongs": "top songs",
|
||||
"topSongsFrom": "Top songs from {{title}}",
|
||||
"viewAll": "view all",
|
||||
"viewAllTracks": "view all $t(entity.track_other)"
|
||||
},
|
||||
"albumArtistList": {
|
||||
"title": "$t(entity.albumArtist_other)"
|
||||
},
|
||||
@@ -293,6 +311,8 @@
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
"dynamicBackground": "dynamic background",
|
||||
"dynamicImageBlur": "image blur size",
|
||||
"dynamicIsImage": "enable background image",
|
||||
"followCurrentLyric": "follow current lyric",
|
||||
"lyricAlignment": "lyric alignment",
|
||||
"lyricGap": "lyric gap",
|
||||
@@ -346,9 +366,12 @@
|
||||
"playlists": "$t(entity.playlist_other)",
|
||||
"search": "$t(common.search)",
|
||||
"settings": "$t(common.setting_other)",
|
||||
"shared": "shared $t(entity.playlist_other)",
|
||||
"tracks": "$t(entity.track_other)"
|
||||
},
|
||||
"trackList": {
|
||||
"artistTracks": "Tracks by {{artist}}",
|
||||
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
|
||||
"title": "$t(entity.track_other)"
|
||||
}
|
||||
},
|
||||
@@ -396,6 +419,13 @@
|
||||
"audioExclusiveMode_description": "enable exclusive output mode. In this mode, the system is usually locked out, and only mpv will be able to output audio",
|
||||
"audioPlayer": "audio player",
|
||||
"audioPlayer_description": "select the audio player to use for playback",
|
||||
"buttonSize": "player bar button size",
|
||||
"buttonSize_description": "the size of the player bar buttons",
|
||||
"clearCache": "clear browser cache",
|
||||
"clearCache_description": "a 'hard clear' of feishin. in addition to clearing feishin's cache, empty the browser cache (saved images and other assets). server credentials and settings are preserved",
|
||||
"clearQueryCache": "clear feishin cache",
|
||||
"clearQueryCache_description": "a 'soft clear' of feishin. this will refresh playlists, track metadata, and reset saved lyrics. settings, server credentials and cached images are preserved",
|
||||
"clearCacheSuccess": "cache cleared successfully",
|
||||
"crossfadeDuration": "crossfade duration",
|
||||
"crossfadeDuration_description": "sets the duration of the crossfade effect",
|
||||
"crossfadeStyle": "crossfade style",
|
||||
@@ -414,6 +444,8 @@
|
||||
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
|
||||
"enableRemote": "enable remote control server",
|
||||
"enableRemote_description": "enables the remote control server to allow other devices to control the application",
|
||||
"externalLinks": "show external links",
|
||||
"externalLinks_description": "enables showing external links (Last.fm, MusicBrainz) on artist/album pages",
|
||||
"exitToTray": "exit to tray",
|
||||
"exitToTray_description": "exit the application to the system tray",
|
||||
"floatingQueueArea": "show floating queue hover area",
|
||||
@@ -432,6 +464,8 @@
|
||||
"gaplessAudio_optionWeak": "weak (recommended)",
|
||||
"globalMediaHotkeys": "global media hotkeys",
|
||||
"globalMediaHotkeys_description": "enable or disable the usage of your system media hotkeys to control playback",
|
||||
"homeConfiguration": "home page configuration",
|
||||
"homeConfiguration_description": "configure what items are shown, and in what order, on the home page",
|
||||
"hotkey_browserBack": "browser back",
|
||||
"hotkey_browserForward": "browser forward",
|
||||
"hotkey_favoriteCurrentSong": "favorite $t(common.currentSong)",
|
||||
@@ -480,9 +514,11 @@
|
||||
"minimumScrobbleSeconds": "minimum scrobble (seconds)",
|
||||
"minimumScrobbleSeconds_description": "the minimum duration in seconds of the song that must be played before it is scrobbled",
|
||||
"mpvExecutablePath": "mpv executable path",
|
||||
"mpvExecutablePath_description": "sets the path to the mpv executable",
|
||||
"mpvExecutablePath_help": "one per line",
|
||||
"mpvExecutablePath_description": "sets the path to the mpv executable. if left empty, the default path will be used",
|
||||
"mpvExtraParameters": "mpv parameters",
|
||||
"mpvExtraParameters_help": "one per line",
|
||||
"passwordStore": "passwords/secret store",
|
||||
"passwordStore_description": "what password/secret store to use. change this if you are having issues storing passwords.",
|
||||
"playbackStyle": "playback style",
|
||||
"playbackStyle_description": "select the playback style to use for the audio player",
|
||||
"playbackStyle_optionCrossFade": "crossfade",
|
||||
@@ -492,6 +528,8 @@
|
||||
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||
"playButtonBehavior_optionPlay": "$t(player.play)",
|
||||
"playerAlbumArtResolution": "player album art resolution",
|
||||
"playerAlbumArtResolution_description": "the resolution for the large player's album art preview. larger makes it look more crisp, but may slow loading down. defaults to 0, meaning auto",
|
||||
"remotePassword": "remote control server password",
|
||||
"remotePassword_description": "sets the password for the remote control server. These credentials are by default transferred insecurely, so you should use a unique password that you do not care about",
|
||||
"remotePort": "remote control server port",
|
||||
@@ -510,7 +548,7 @@
|
||||
"replayGainPreamp": "{{ReplayGain}} preamp (dB)",
|
||||
"replayGainPreamp_description": "adjust the preamp gain applied to the {{ReplayGain}} values",
|
||||
"sampleRate": "sample rate",
|
||||
"sampleRate_description": "select the output sample rate to be used if the sample frequency selected is different from that of the current media",
|
||||
"sampleRate_description": "select the output sample rate to be used if the sample frequency selected is different from that of the current media. a value less than 8000 will use the default frequency",
|
||||
"savePlayQueue": "save play queue",
|
||||
"savePlayQueue_description": "save the play queue when the application is closed and restore it when the application is opened",
|
||||
"scrobble": "scrobble",
|
||||
@@ -533,6 +571,8 @@
|
||||
"skipDuration_description": "sets the duration to skip when using the skip buttons on the player bar",
|
||||
"skipPlaylistPage": "skip playlist page",
|
||||
"skipPlaylistPage_description": "when navigating to a playlist, go to the playlist song list page instead of the default page",
|
||||
"startMinimized": "start minimized",
|
||||
"startMinimized_description": "start the application in system tray",
|
||||
"theme": "theme",
|
||||
"theme_description": "sets the theme to use for the application",
|
||||
"themeDark": "theme (dark)",
|
||||
@@ -558,6 +598,7 @@
|
||||
"bitrate": "bitrate",
|
||||
"bpm": "bpm",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"codec": "$t(common.codec)",
|
||||
"comment": "comment",
|
||||
"dateAdded": "date added",
|
||||
"discNumber": "disc",
|
||||
@@ -578,6 +619,8 @@
|
||||
"autoFitColumns": "auto fit columns",
|
||||
"displayType": "display type",
|
||||
"gap": "$t(common.gap)",
|
||||
"itemGap": "item gap (px)",
|
||||
"itemSize": "item size (px)",
|
||||
"size": "$t(common.size)",
|
||||
"tableColumns": "table columns"
|
||||
},
|
||||
@@ -590,6 +633,7 @@
|
||||
"bitrate": "$t(common.bitrate)",
|
||||
"bpm": "$t(common.bpm)",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"codec": "$t(common.codec)",
|
||||
"dateAdded": "date added",
|
||||
"discNumber": "disc number",
|
||||
"duration": "$t(common.duration)",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"hotkey_playbackPause": "pausa",
|
||||
"replayGainFallback": "{{ReplayGain}} alternativa",
|
||||
"sidebarCollapsedNavigation_description": "mostrar u ocultar la navegación en la barra lateral contraída",
|
||||
"mpvExecutablePath_help": "uno por línea",
|
||||
"hotkey_volumeUp": "subir volumen",
|
||||
"skipDuration": "duración de salto",
|
||||
"discordIdleStatus_description": "cuando se activa, actualiza el estado mientras el reproductor está inactivo",
|
||||
@@ -52,7 +51,7 @@
|
||||
"skipDuration_description": "establece la duración a saltar cuando se usa los botones de saltar en la barra del reproductor",
|
||||
"enableRemote_description": "activa el control remoto del servidor para permitir a otros dispositivos controlar la aplicación",
|
||||
"fontType_optionSystem": "fuente del sistema",
|
||||
"mpvExecutablePath_description": "establece la ruta del ejecutable mpv",
|
||||
"mpvExecutablePath_description": "establece la ruta del ejecutable mpv. si se deja vacío, se usará la ruta predeterminada",
|
||||
"replayGainClipping_description": "previene el recorte causado por {{ReplayGain}} bajando automáticamente la ganancia",
|
||||
"replayGainPreamp": "preamplificador (dB) de {{ReplayGain}}",
|
||||
"hotkey_favoriteCurrentSong": "$t(common.currentSong) favorita",
|
||||
@@ -60,7 +59,7 @@
|
||||
"crossfadeStyle": "estilo de crossfade",
|
||||
"sidePlayQueueStyle_optionAttached": "acoplada",
|
||||
"sidebarConfiguration": "configuración de la barra lateral",
|
||||
"sampleRate_description": "selecciona el ratio de muestreo de salida a ser usado si la frecuencia de muestreo seleccionada es diferente de la del medio actual",
|
||||
"sampleRate_description": "selecciona el ratio de muestreo de salida a ser usado si la frecuencia de muestreo seleccionada es diferente de la del medio actual. un valor inferior a 8000 usará la frecuencia predeterminada",
|
||||
"replayGainMode_optionNone": "$t(common.none)",
|
||||
"replayGainClipping": "recortar {{ReplayGain}}",
|
||||
"hotkey_zoomIn": "ampliar",
|
||||
@@ -191,7 +190,13 @@
|
||||
"accentColor_description": "establece el color de realce de la aplicación",
|
||||
"skipPlaylistPage": "saltar página de lista de reproducción",
|
||||
"hotkey_browserForward": "avance",
|
||||
"hotkey_browserBack": "retroceso"
|
||||
"hotkey_browserBack": "retroceso",
|
||||
"clearCache": "limpiar la caché del navegador",
|
||||
"clearQueryCache": "limpiar la caché de feishin",
|
||||
"clearQueryCache_description": "una 'limpieza suave' de feishin. esto refrescará las listas de reproducción, metadatos de pistas y restablece las letras guardadas. se mantienen los ajustes, credenciales del servidor y las imágenes en caché",
|
||||
"buttonSize": "tamaño del botón de la barra de reproducción",
|
||||
"clearCache_description": "una 'limpieza fuerte' de feishin. para limpiar la caché de feishin, vacía la caché del navegador (imágenes guardadas y otros elementos). se mantienen las credenciales y ajustes del servidor",
|
||||
"buttonSize_description": "el tamaño de los botones de la barra de reproducción"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "editar $t(entity.playlist_one)",
|
||||
@@ -491,7 +496,7 @@
|
||||
"error_savePassword": "un error ocurrió cuando se intentó guardar la contraseña"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "añadido {{message}} $t(entity.song_other) a {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"success": "añadido $t(entity.trackWithCount, {\"count\": {{message}} }) a $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||
"title": "añadir a $t(entity.playlist_one)",
|
||||
"input_skipDuplicates": "saltar duplicados",
|
||||
"input_playlists": "$t(entity.playlist_other)"
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
{
|
||||
"player": {
|
||||
"repeat_all": "تکرار همه",
|
||||
"stop": "توقف",
|
||||
"repeat": "تکرار",
|
||||
"skip": "رد کن",
|
||||
"toggleFullscreenPlayer": "تغییر به پخشکنندهٔ تمامصفحه",
|
||||
"skip_back": "برو عقب",
|
||||
"shuffle": "شافل",
|
||||
"repeat_off": "تکرار غیرفعال",
|
||||
"pause": "pause",
|
||||
"unfavorite": "حذف از موردعلاقهها",
|
||||
"shuffle_off": "شافل غیرفعال",
|
||||
"skip_forward": "برو جلو"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "ویرایش $t(entity.playlist_one)",
|
||||
"goToPage": "برو به صفحهٔ",
|
||||
"moveToTop": "انتقال به بالا",
|
||||
"clearQueue": "خالی کردن صف",
|
||||
"addToFavorites": "افزودن به $t(entity.favorite_other)",
|
||||
"addToPlaylist": "افزودن به $t(entity.playlist_one)",
|
||||
"createPlaylist": "ساخت $t(entity.playlist_one)",
|
||||
"removeFromPlaylist": "حذف از $t(entity.playlist_one)",
|
||||
"viewPlaylists": "نمایش $t(entity.playlist_other)",
|
||||
"refresh": "$t(common.refresh)",
|
||||
"deletePlaylist": "حذف $t(entity.playlist_one)",
|
||||
"removeFromQueue": "حذف از صف",
|
||||
"deselectAll": "لغو انتخاب همه",
|
||||
"moveToBottom": "انتقال به پایین",
|
||||
"setRating": "تعیین امتیاز",
|
||||
"toggleSmartPlaylistEditor": "تغییر $t(entity.smartPlaylist) ویرایشگر",
|
||||
"removeFromFavorites": "حذف از $t(entity.favorite_other)"
|
||||
},
|
||||
"setting": {
|
||||
"hotkey_skipBackward": "برو عقب",
|
||||
"audioDevice_description": "دستگاه صوتی را برای پخش انتخاب کنید (فقط پخشکنندهٔ تحت وب)",
|
||||
"hotkey_playbackPause": "pause",
|
||||
"hotkey_volumeUp": "زیاد کردن صدا",
|
||||
"playButtonBehavior_optionPlay": "$t(player.play)",
|
||||
"lyricFetch": "دریافت متن ترانه از اینترنت",
|
||||
"enableRemote_description": "کنترل از راه دور سرویسدهنده را فعال کنید تا به دستگاههای دیگر اجازهٔ مدیریت اپلیکیشن را بدهید",
|
||||
"mpvExecutablePath_description": "تعیین مسیر فایل اجرایی MPV",
|
||||
"sampleRate": "sample rate",
|
||||
"replayGainMode_optionNone": "$t(common.none)",
|
||||
"hotkey_rate1": "امتیاز ۱ ستاره",
|
||||
"hotkey_skipForward": "برو جلو",
|
||||
"disableLibraryUpdateOnStartup": "غیرفعال کردن بررسی آخرین نسخه در آغاز به کار برنامه",
|
||||
"discordApplicationId_description": "the application id for {{discord}} rich presence (defaults to {{defaultId}})",
|
||||
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||
"hotkey_playbackPlay": "پخش",
|
||||
"hotkey_volumeDown": "کم کردن صدا",
|
||||
"audioPlayer_description": "پخشکنندهٔ صدا را برای پخش انتخاب کنید",
|
||||
"hotkey_globalSearch": "جست و جوی سراسری",
|
||||
"disableAutomaticUpdates": "غیرفعال کردن بهروزرسانی خودکار",
|
||||
"exitToTray_description": "خروج از اپلیکیشن به system tray",
|
||||
"replayGainMode_optionAlbum": "$t(entity.album_one)",
|
||||
"discordUpdateInterval_description": "فاصلهٔ بین هر به روزرسانی به ثانیه (حداقل ۱۵ ثانیه)",
|
||||
"audioExclusiveMode": "حالت اختصاصی صدا",
|
||||
"remotePassword": "رمز عبور کنترل از راه دور",
|
||||
"language_description": "زبان اپلیکیشن را معین میکند $t(common.restartRequired)",
|
||||
"hotkey_rate3": "امتیاز ۳ ستاره",
|
||||
"font": "قلم",
|
||||
"replayGainMode_optionTrack": "$t(entity.track_one)",
|
||||
"hotkey_toggleFullScreenPlayer": "تغییر به پخشکنندهٔ تمامصفحه",
|
||||
"hotkey_localSearch": "جست و جو در صفحه",
|
||||
"hotkey_toggleQueue": "تغییر صف",
|
||||
"hotkey_rate5": "امتیاز ۵ ستاره",
|
||||
"hotkey_playbackPrevious": "قطعهٔ قبل",
|
||||
"language": "زبان",
|
||||
"hotkey_toggleShuffle": "تغییر شافل",
|
||||
"mpvExecutablePath": "مسیر اجرای MPV",
|
||||
"audioDevice": "دستگاه صوتی",
|
||||
"hotkey_rate2": "امتیاز ۲ ستاره",
|
||||
"playButtonBehavior_description": "رفتار پیشفرض دکمهٔ پخش را هنگامی که آهنگی به صف اضافه میشود معین میکند",
|
||||
"exitToTray": "خروج به tray",
|
||||
"hotkey_rate4": "امتیاز ۴ ستاره",
|
||||
"enableRemote": "فعال کردن کنترل از راه دور سرویسدهنده",
|
||||
"showSkipButton_description": "نمایش یا مخفی کردن دکمهٔ رد کردن روی نوار پخشکننده",
|
||||
"playButtonBehavior": "رفتار دکمهٔ پخش",
|
||||
"playbackStyle_optionNormal": "عادی",
|
||||
"hotkey_toggleRepeat": "تغییر تکرار",
|
||||
"fontType": "نوع قلم",
|
||||
"hotkey_playbackNext": "قطعهٔ بعد",
|
||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||
"lyricFetch_description": "دریافت متن ترانه از منابع اینترنتی",
|
||||
"customFontPath": "مسیر قلم سفارشی",
|
||||
"audioPlayer": "پخشکنندهٔ صدا",
|
||||
"hotkey_rate0": "حذف امتیاز",
|
||||
"discordApplicationId": "{{discord}} application id",
|
||||
"hotkey_volumeMute": "بستن صدا",
|
||||
"showSkipButton": "نمایش دکمهٔ رد کردن",
|
||||
"customFontPath_description": "مسیر قلم سفارشی را برای استفاده در اپلیکیشن مشخص کنید",
|
||||
"gaplessAudio_optionWeak": "ضعیف (توصیه شده)",
|
||||
"hotkey_playbackStop": "توقف",
|
||||
"font_description": "قلم مورد استفادهٔ اپلیکیشن را معین میکند"
|
||||
},
|
||||
"common": {
|
||||
"backward": "به عقب",
|
||||
"increase": "افزایش",
|
||||
"rating": "امتیاز",
|
||||
"bpm": "bpm",
|
||||
"refresh": "تازهسازی",
|
||||
"unknown": "ناشناخته",
|
||||
"areYouSure": "مطمئنید؟",
|
||||
"edit": "ویرایش",
|
||||
"favorite": "موردعلاقه",
|
||||
"left": "چپ",
|
||||
"save": "ذخیره",
|
||||
"right": "راست",
|
||||
"currentSong": "فعلی $t(entity.track_one)",
|
||||
"collapse": "بستن",
|
||||
"trackNumber": "قطعه",
|
||||
"descending": "نزولی",
|
||||
"add": "افزودن",
|
||||
"gap": "فاصله",
|
||||
"ascending": "صعودی",
|
||||
"dismiss": "رد",
|
||||
"year": "سال",
|
||||
"manage": "مدیریت",
|
||||
"limit": "محدود",
|
||||
"minimize": "کمینه",
|
||||
"modified": "ویراسته شده",
|
||||
"duration": "مدت",
|
||||
"name": "نام",
|
||||
"maximize": "بیشینه",
|
||||
"decrease": "کم کردن",
|
||||
"ok": "باشه",
|
||||
"description": "شرح",
|
||||
"configure": "تنظیم",
|
||||
"path": "مسیر",
|
||||
"center": "وسط",
|
||||
"no": "خیر",
|
||||
"owner": "مالک",
|
||||
"enable": "فعال",
|
||||
"clear": "خالی",
|
||||
"forward": "جلو",
|
||||
"delete": "حذف",
|
||||
"cancel": "لغو",
|
||||
"forceRestartRequired": "برای اعمال تغییرها دوباره راهاندازی کنید… اعلان را برای راهاندازی دوباره ببندید",
|
||||
"version": "نسخه",
|
||||
"title": "عنوان",
|
||||
"filter_one": "فیلتر",
|
||||
"filter_other": "فیلتر",
|
||||
"filters": "فیلتر",
|
||||
"create": "ساختن",
|
||||
"bitrate": "بیتریت",
|
||||
"saveAndReplace": "ذخیره و جایگزین",
|
||||
"action_one": "عملیات",
|
||||
"action_other": "عملیات",
|
||||
"playerMustBePaused": "پخشکننده باید متوقف شود",
|
||||
"confirm": "تایید",
|
||||
"resetToDefault": "بازنشانی به پیشفرض",
|
||||
"home": "خانه",
|
||||
"comingSoon": "به زودی…",
|
||||
"reset": "بازنشانی",
|
||||
"channel_one": "کانال",
|
||||
"channel_other": "کانال",
|
||||
"disable": "غیرفعال",
|
||||
"sortOrder": "ترتیب",
|
||||
"none": "هیچ",
|
||||
"menu": "منو",
|
||||
"restartRequired": "راهاندازی دوباره لازم است",
|
||||
"previousSong": "$t(entity.track_one) پیشین",
|
||||
"noResultsFromQuery": "جست و جو نتیجهای نداشت",
|
||||
"quit": "خروج",
|
||||
"expand": "گسترش",
|
||||
"search": "جست و جو",
|
||||
"saveAs": "ذخیره کن با اسم",
|
||||
"disc": "دیسک",
|
||||
"yes": "بله",
|
||||
"random": "تصادفی",
|
||||
"size": "حجم",
|
||||
"biography": "زندگینامه",
|
||||
"note": "توجه"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "برای تعیین port تازه، سرویس دهنده را دوباره راهاندازی کنید",
|
||||
"playbackError": "هنگام پخش خطایی رخ داد",
|
||||
"remotePortError": "هنگام تعیین port سرویس دهنده خطایی رخ داد",
|
||||
"serverRequired": "سرویسدهنده ضروری است",
|
||||
"authenticationFailed": "احراز هویت شکست خورد",
|
||||
"apiRouteError": "درخواست منتقل نشد",
|
||||
"genericError": "خطایی رخ داد",
|
||||
"credentialsRequired": "باید وارد شوید",
|
||||
"sessionExpiredError": "جلسه شما منقضی شده است",
|
||||
"remoteEnableError": "هنگام $t(common.enable) سرویس دهنده خطای رخ داد",
|
||||
"serverNotSelectedError": "سرویسدهندهای انتخاب نشده",
|
||||
"remoteDisableError": "هنگام $t(common.disable) سرویس دهنده خطایی رخ داد",
|
||||
"mpvRequired": "وجود MPV ضروری است",
|
||||
"audioDeviceFetchError": "هنگام دسترسی به دستگاه صوتی خطایی رخ داد"
|
||||
},
|
||||
"filter": {
|
||||
"mostPlayed": "بیشتر پخش شده",
|
||||
"comment": "نظر",
|
||||
"playCount": "تعداد پخش",
|
||||
"recentlyUpdated": "به تازگی به روز شده",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"recentlyPlayed": "به تازگی پخش شده",
|
||||
"isRated": "امتیاز داده شده است",
|
||||
"owner": "$t(common.owner)",
|
||||
"title": "عنوان",
|
||||
"rating": "امتیاز",
|
||||
"search": "جست و جو",
|
||||
"bitrate": "بیتریت",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"recentlyAdded": "به تازگی اضافه شده",
|
||||
"note": "توجه",
|
||||
"name": "نام",
|
||||
"dateAdded": "تاریخ اضافه شدن",
|
||||
"releaseDate": "تاریخ انتشار",
|
||||
"albumCount": "$t(entity.album_other) عدد",
|
||||
"path": "مسیر",
|
||||
"favorited": "موردعلاقه",
|
||||
"albumArtist": "$t(entity.albumArtist_one)",
|
||||
"isRecentlyPlayed": "به تازگی پخش شده است",
|
||||
"isFavorited": "موردعلاقه است",
|
||||
"bpm": "bpm",
|
||||
"releaseYear": "سال انتشار",
|
||||
"id": "id",
|
||||
"disc": "دیسک",
|
||||
"biography": "زندگینامه",
|
||||
"songCount": "تعداد ترانه",
|
||||
"artist": "$t(entity.artist_one)",
|
||||
"duration": "مدت",
|
||||
"isPublic": "عمومی است",
|
||||
"random": "تصادفی",
|
||||
"lastPlayed": "به تازگی پخش شده",
|
||||
"toYear": "تا سال",
|
||||
"fromYear": "از سال",
|
||||
"criticRating": "امتیاز منتقدین",
|
||||
"album": "$t(entity.album_one)",
|
||||
"trackNumber": "قطعه"
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
"title": "حذف $t(entity.playlist_one)",
|
||||
"success": "$t(entity.playlist_one) حذف شد",
|
||||
"input_confirm": "برای تایید، نام $t(entity.playlist_one) را وارد کنید"
|
||||
},
|
||||
"createPlaylist": {
|
||||
"input_description": "$t(common.description)",
|
||||
"title": "ساخت $t(entity.playlist_one)",
|
||||
"input_public": "عمومی",
|
||||
"input_name": "$t(common.name)",
|
||||
"success": "$t(entity.playlist_one) ساخته شد",
|
||||
"input_owner": "$t(common.owner)"
|
||||
},
|
||||
"addServer": {
|
||||
"title": "افزودن سرویس دهنده",
|
||||
"input_username": "نام کاربری",
|
||||
"input_url": "نشانی",
|
||||
"input_password": "رمز عبور",
|
||||
"input_name": "نام سرویسدهنده",
|
||||
"success": "سرویسدهنده اضافه شد",
|
||||
"input_savePassword": "ذخیرهٔ رمز",
|
||||
"error_savePassword": "هنگام ذخیره رمز خطایی رخ داد"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "$t(entity.song_other) به {{numOfPlaylists}}$t(entity.playlist_other) اضافه شد",
|
||||
"title": "افزودن به $t(entity.playlist_one)",
|
||||
"input_playlists": "$t(entity.playlist_other)"
|
||||
},
|
||||
"lyricSearch": {
|
||||
"input_name": "$t(common.name)",
|
||||
"input_artist": "$t(entity.artist_one)"
|
||||
},
|
||||
"editPlaylist": {
|
||||
"title": "ویرایش $t(entity.playlist_one)"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"genre_one": "ژانر",
|
||||
"genre_other": "ژانر",
|
||||
"playlistWithCount_one": "{{count}} فهرست پخش",
|
||||
"playlistWithCount_other": "{{count}} فهرست پخش",
|
||||
"playlist_one": "فهرست پخش",
|
||||
"playlist_other": "فهرست پخش",
|
||||
"artist_one": "هنرمند",
|
||||
"artist_other": "هنرمند",
|
||||
"folderWithCount_one": "{{count}} پوشه",
|
||||
"folderWithCount_other": "{{count}} پوشه",
|
||||
"albumArtist_one": "هنرمند آلبوم",
|
||||
"albumArtist_other": "هنرمند آلبوم",
|
||||
"track_one": "قطعه",
|
||||
"track_other": "قطعه",
|
||||
"albumArtistCount_one": "{{count}} هنرمند آلبوم",
|
||||
"albumArtistCount_other": "{{count}} هنرمند آلبوم",
|
||||
"albumWithCount_one": "{{count}} آلبوم",
|
||||
"albumWithCount_other": "{{count}} آلبوم",
|
||||
"favorite_one": "موردعلاقه",
|
||||
"favorite_other": "موردعلاقه",
|
||||
"artistWithCount_one": "{{count}} هنرمند",
|
||||
"artistWithCount_other": "{{count}} هنرمند",
|
||||
"folder_one": "پوشه",
|
||||
"folder_other": "پوشه",
|
||||
"smartPlaylist": "$t(entity.playlist_one) هوشمند",
|
||||
"album_one": "آلبوم",
|
||||
"album_other": "آلبوم",
|
||||
"genreWithCount_one": "{{count}} ژانر",
|
||||
"genreWithCount_other": "{{count}} ژانر",
|
||||
"trackWithCount_one": "{{count}} قطعه",
|
||||
"trackWithCount_other": "{{count}} قطعه"
|
||||
}
|
||||
}
|
||||
+26
-15
@@ -62,7 +62,7 @@
|
||||
"left": "gauche",
|
||||
"save": "sauvegarder",
|
||||
"right": "droite",
|
||||
"currentSong": "actuelle $t(entity.track_one)",
|
||||
"currentSong": "$t(entity.track_one) actuelle",
|
||||
"collapse": "réduire",
|
||||
"trackNumber": "piste",
|
||||
"descending": "décroisant",
|
||||
@@ -225,7 +225,7 @@
|
||||
"lyricSize": "Taille des paroles",
|
||||
"lyricGap": "espacement des lettres"
|
||||
},
|
||||
"upNext": "suivant",
|
||||
"upNext": "à suivre",
|
||||
"lyrics": "paroles",
|
||||
"related": "similaire"
|
||||
},
|
||||
@@ -320,7 +320,6 @@
|
||||
"remotePort_description": "définit le port du serveur de contrôle à distance",
|
||||
"hotkey_skipBackward": "reculer",
|
||||
"hotkey_playbackPause": "pause",
|
||||
"mpvExecutablePath_help": "line par line",
|
||||
"hotkey_volumeUp": "monter le volume",
|
||||
"discordIdleStatus_description": "quand activé, mettre à jour le status pendant que le lecteur est inactif",
|
||||
"showSkipButtons": "affiche les boutons suivants et précédents",
|
||||
@@ -332,7 +331,7 @@
|
||||
"mpvExecutablePath_description": "définit le chemin vers l'exécutable mpv",
|
||||
"hotkey_favoriteCurrentSong": "favori $t(common.currentSong)",
|
||||
"sampleRate": "taux d'échantillonnage",
|
||||
"sampleRate_description": "sélectionnez le taux d'échantillonnage de sortie utilisé si la fréquence d'échantillonnage sélectionnée est différente du média actuel",
|
||||
"sampleRate_description": "sélectionnez le taux d'échantillonnage de sortie utilisé si la fréquence d'échantillonnage sélectionnée est différente de celle du média actuel",
|
||||
"hotkey_zoomIn": "zoom avant",
|
||||
"scrobble_description": "scrobble les lectures à votre serveur multimédia",
|
||||
"hotkey_browserForward": "avancer",
|
||||
@@ -341,7 +340,7 @@
|
||||
"hotkey_playbackPlayPause": "lecture / pause",
|
||||
"hotkey_rate1": "noter 1 étoile",
|
||||
"hotkey_skipForward": "avancer",
|
||||
"disableLibraryUpdateOnStartup": "désactive la vérification de mise à jour au démarrage",
|
||||
"disableLibraryUpdateOnStartup": "désactive la recherche de mise à jour au démarrage",
|
||||
"gaplessAudio": "audio sans interruption",
|
||||
"minimizeToTray_description": "réduit l'application vers la barre des tâches",
|
||||
"hotkey_playbackPlay": "lecture",
|
||||
@@ -402,10 +401,10 @@
|
||||
"followLyric": "suivre les paroles actuelles",
|
||||
"discordIdleStatus": "afficher l'état d'inactivité dans le status de l'activité",
|
||||
"hotkey_zoomOut": "zoom arrière",
|
||||
"hotkey_unfavoriteCurrentSong": "défavorisé $t(common.currentSong)",
|
||||
"hotkey_unfavoriteCurrentSong": "retirer des favoris la $t(common.currentSong)",
|
||||
"hotkey_rate0": "supprimer la note",
|
||||
"hotkey_volumeMute": "couper le son",
|
||||
"hotkey_toggleCurrentSongFavorite": "basculer $t(common.currentSong) favori",
|
||||
"hotkey_toggleCurrentSongFavorite": "basculer favori de la $t(common.currentSong)",
|
||||
"remoteUsername": "nom d'utilisateur du serveur de contrôle à distance",
|
||||
"hotkey_browserBack": "retour arrière",
|
||||
"showSkipButton": "affiche les boutons suivants et précédents",
|
||||
@@ -414,21 +413,21 @@
|
||||
"minimumScrobbleSeconds": "scrobble minimum (secondes)",
|
||||
"hotkey_playbackStop": "stop",
|
||||
"font_description": "définit la police à utiliser pour l'application",
|
||||
"savePlayQueue_description": "sauvegarde la liste de lecture quand l'application est fermée et restaure là quand l'application est ouverte",
|
||||
"savePlayQueue_description": "sauvegarde la liste de lecture quand l'application est fermée et la restaure quand l'application est ouverte",
|
||||
"sidebarCollapsedNavigation_description": "affiche ou cache la navigation dans la barre latérale réduite",
|
||||
"sidebarConfiguration": "configuration de la barre latérale",
|
||||
"sidebarConfiguration_description": "sélectionnez les items et l'ordre dans lesquels ils seront affichaient dans la barre latérale",
|
||||
"sidebarPlaylistList": "liste de playlist de la barre latérale",
|
||||
"sidebarCollapsedNavigation": "navigation de la barre latéral (réduite)",
|
||||
"skipDuration": "temps de l'avance rapide",
|
||||
"skipDuration": "durée de l'avance rapide",
|
||||
"sidePlayQueueStyle_optionAttached": "attaché",
|
||||
"sidePlayQueueStyle": "style de la liste de lecture latérale",
|
||||
"sidebarPlaylistList_description": "affiche ou cache la liste de playlist de la barre latérale",
|
||||
"sidePlayQueueStyle_description": "définit le style de la liste de lecture latérale",
|
||||
"sidePlayQueueStyle_optionDetached": "détaché",
|
||||
"volumeWheelStep_description": "la quantité de volume à modifier lors du défilement de la molette de la souris sur le curseur de volume",
|
||||
"volumeWheelStep_description": "la valeur de volume à modifier lors du défilement de la molette de la souris sur le curseur de volume",
|
||||
"theme_description": "définit le thème à utiliser pour l'application",
|
||||
"skipDuration_description": "définit le durée de l'avance rapide, lors de l'utilisation des boutons skip dans la barre de lecture",
|
||||
"skipDuration_description": "définit le durée du saut rapide, lors de l'utilisation des boutons avancer/reculer de la barre de lecture",
|
||||
"themeLight": "thème (clair)",
|
||||
"zoom": "pourcentage de zoom",
|
||||
"themeDark_description": "définit le thème sombre à utiliser pour l'application",
|
||||
@@ -436,7 +435,7 @@
|
||||
"zoom_description": "définit le pourcentage de zoom de l'application",
|
||||
"theme": "thème",
|
||||
"skipPlaylistPage_description": "lors de la navigation dans une playlist, aller directement vers le liste des morceaux, au lieu de la page par défaut",
|
||||
"volumeWheelStep": "marche du curseur de volume",
|
||||
"volumeWheelStep": "valeur du pas de volume",
|
||||
"windowBarStyle": "style de la barre de la fenêtre",
|
||||
"useSystemTheme_description": "suivre les préférence du système (sombre ou clair)",
|
||||
"skipPlaylistPage": "sauter la page de playlist",
|
||||
@@ -453,7 +452,15 @@
|
||||
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||
"replayGainMode_optionAlbum": "$t(entity.album_one)",
|
||||
"replayGainMode_optionTrack": "$t(entity.track_one)",
|
||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)"
|
||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||
"replayGainMode_description": "ajuste le gain de volume accordement à la valeur de {{ReplayGain}} sauvegardé dans les métadonnées du fichier",
|
||||
"replayGainFallback": "{{ReplayGain}} fallback",
|
||||
"replayGainClipping_description": "Préviens le clipping causé par {{ReplayGain}} en baissant automatiquement le gain",
|
||||
"replayGainPreamp": "préamplificateur (dB) de {{ReplayGain}}",
|
||||
"replayGainClipping": "{{ReplayGain}} clipping",
|
||||
"replayGainMode": "mode de {{ReplayGain}}",
|
||||
"replayGainFallback_description": "gain en dB à appliquer si le fichier n'a pas de tag {{ReplayGain}}",
|
||||
"replayGainPreamp_description": "ajuste le gain de préampli appliqué a la valeur de {{ReplayGain}}"
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
@@ -475,7 +482,7 @@
|
||||
"error_savePassword": "une erreur s’est produite lors de la tentative de sauvegarde du mot de passe"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "{{message}} $t(entity.song_other) ajouté à {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"success": "{{message}} $t(entity.track_other) ajouté à {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"title": "ajouter à $t(entity.playlist_one)",
|
||||
"input_skipDuplicates": "sauter les doublons",
|
||||
"input_playlists": "$t(entity.playlist_other)"
|
||||
@@ -589,7 +596,11 @@
|
||||
"rating": "$t(common.rating)",
|
||||
"note": "$t(common.note)",
|
||||
"owner": "$t(common.owner)",
|
||||
"path": "$t(common.path)"
|
||||
"path": "$t(common.path)",
|
||||
"title": "$t(common.title)",
|
||||
"size": "$t(common.size)",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"year": "$t(common.year)"
|
||||
}
|
||||
},
|
||||
"column": {
|
||||
|
||||
+30
-16
@@ -46,7 +46,7 @@
|
||||
"left": "sinistra",
|
||||
"save": "salva",
|
||||
"right": "destra",
|
||||
"currentSong": "$t(entity.track_one) corrent",
|
||||
"currentSong": "$t(entity.track_one) corrente",
|
||||
"trackNumber": "traccia",
|
||||
"descending": "decrescente",
|
||||
"gap": "gap",
|
||||
@@ -102,9 +102,9 @@
|
||||
"note": "nota"
|
||||
},
|
||||
"player": {
|
||||
"repeat_all": "ripeti tutto",
|
||||
"repeat_all": "ripeti coda",
|
||||
"stop": "ferma",
|
||||
"repeat": "ripeti",
|
||||
"repeat": "ripeti traccia",
|
||||
"queue_remove": "rimuovi selezionati",
|
||||
"playRandom": "riproduci casuale",
|
||||
"skip": "salta",
|
||||
@@ -113,21 +113,21 @@
|
||||
"skip_back": "salta indietro",
|
||||
"favorite": "preferito",
|
||||
"next": "successivo",
|
||||
"shuffle": "mischia",
|
||||
"shuffle": "mescola",
|
||||
"playbackFetchNoResults": "nessuna canzone trovata",
|
||||
"playbackFetchInProgress": "caricamento canzoni…",
|
||||
"addNext": "aggiungi successivo",
|
||||
"playbackSpeed": "velocità riproduzione",
|
||||
"playbackSpeed": "velocità di riproduzione",
|
||||
"playbackFetchCancel": "ci sta mettendo un po'... chiudi la notifica per annullare",
|
||||
"play": "riproduci",
|
||||
"repeat_off": "ripeti disabilitato",
|
||||
"repeat_off": "non ripetere",
|
||||
"pause": "pausa",
|
||||
"queue_clear": "cancella coda",
|
||||
"muted": "silenziato",
|
||||
"unfavorite": "togli dai preferiti",
|
||||
"queue_moveToTop": "sposta selezionati in fondo",
|
||||
"queue_moveToBottom": "sposta selezionati in cima",
|
||||
"shuffle_off": "mischia disabilitato",
|
||||
"shuffle_off": "non mescolare",
|
||||
"addLast": "aggiungi in coda",
|
||||
"mute": "silenzia",
|
||||
"skip_forward": "salta avanti"
|
||||
@@ -140,7 +140,6 @@
|
||||
"audioDevice_description": "seleziona il device audioda usare per la riproduzione (solo web player)",
|
||||
"theme_description": "imposta il tema da usare per l'applicazione",
|
||||
"hotkey_playbackPause": "pausa",
|
||||
"mpvExecutablePath_help": "uno per linea",
|
||||
"hotkey_volumeUp": "alza volume",
|
||||
"skipDuration": "salta durata",
|
||||
"discordIdleStatus_description": "quando è attivo, aggiorna lo stato mentre il player è inattivo",
|
||||
@@ -156,8 +155,8 @@
|
||||
"crossfadeStyle": "stile dissolvenza",
|
||||
"sidebarConfiguration": "configurazione barra laterale",
|
||||
"replayGainMode_optionNone": "$t(common.none)",
|
||||
"hotkey_zoomIn": "ingrandisci",
|
||||
"scrobble_description": "esegui scrobble delle riproduzioni al tuo media server",
|
||||
"hotkey_zoomIn": "ingrandisci layout",
|
||||
"scrobble_description": "invia lo scrobble delle riproduzioni al tuo media server",
|
||||
"audioExclusiveMode_description": "abilità modalità output esclusiva. In questa modalità il sistema è di solito chiuso fuori, e solo mpv potrà riprodurre audio",
|
||||
"discordUpdateInterval": "intervallo aggiornamento stato attività {{discord}}",
|
||||
"themeLight": "tema (chiaro)",
|
||||
@@ -207,7 +206,7 @@
|
||||
"crossfadeDuration_description": "imposta la durata dell'effetto di dissolvenza",
|
||||
"language": "lingua",
|
||||
"playbackStyle": "stile riproduzione",
|
||||
"hotkey_toggleShuffle": "attiva/disattiva mischia",
|
||||
"hotkey_toggleShuffle": "attiva/disattiva mescolamento",
|
||||
"theme": "tema",
|
||||
"playbackStyle_description": "selezione lo stile di riproduzione da usare per il player audio",
|
||||
"discordRichPresence_description": "abilita lo status del playback nello stato attività di {{discord}}. Le chiavi immagine sono: {{icon}}, {{playing}} e {{paused}} ",
|
||||
@@ -247,7 +246,7 @@
|
||||
"crossfadeDuration": "durata dissolvenza",
|
||||
"discordIdleStatus": "visualizza lo stato attività in stato inattivo",
|
||||
"audioPlayer": "player audio",
|
||||
"hotkey_zoomOut": "rimpicciolisci",
|
||||
"hotkey_zoomOut": "rimpicciolisci layout",
|
||||
"hotkey_rate0": "rimuovi voto",
|
||||
"discordApplicationId": "application id {{discord}}",
|
||||
"applicationHotkeys_description": "configura tasti a scelta rapida dell'applicazione. attiva/disattiva la casella per impostare un tasto a scelta rapida globale (solo desktop)",
|
||||
@@ -275,7 +274,21 @@
|
||||
"showSkipButton_description": "mostra o nascondi i pulsanti per saltare nella barra del player",
|
||||
"hotkey_unfavoriteCurrentSong": "rimuovi $t(common.currentSong) dai preferiti",
|
||||
"hotkey_toggleCurrentSongFavorite": "imposta/rimuovi $t(common.currentSong) favorito",
|
||||
"showSkipButton": "mostra pulsanti per saltare"
|
||||
"showSkipButton": "mostra pulsanti per saltare",
|
||||
"hotkey_browserForward": "Vai avanti di una pagina",
|
||||
"hotkey_browserBack": "Torna indietro di una pagina",
|
||||
"sidebarCollapsedNavigation_description": "mostra o nascondi la navigazione nella barra laterale collassata",
|
||||
"replayGainClipping_description": "Previeni il clipping causato da {{ReplayGain}} abbassando automaticamente il gain",
|
||||
"replayGainPreamp": "preamplificazione {{ReplayGain}} (dB)",
|
||||
"sidePlayQueueStyle": "stile della coda di riproduzione laterale",
|
||||
"showSkipButtons_description": "mostra o nascondi i pulsanti per saltare dalla barra di riproduzione",
|
||||
"skipPlaylistPage_description": "quando si naviga in una playlist, si va alla pagina dell'elenco dei brani della playlist invece che alla pagina predefinita",
|
||||
"sidePlayQueueStyle_description": "imposta lo stile della coda di riproduzione laterale",
|
||||
"replayGainMode": "modalità {{ReplayGain}}",
|
||||
"replayGainFallback_description": "gain in db da applicare se il file non possiede tag {{ReplayGain}}",
|
||||
"replayGainPreamp_description": "aggiusta la preamplificazione del gain applicato sui valori {{ReplayGain}}",
|
||||
"skipPlaylistPage": "Salta la pagina playlist",
|
||||
"sidebarCollapsedNavigation": "navigazione con barra laterale (collassata)"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "riavvia il server per applicare la nuova porta",
|
||||
@@ -378,7 +391,7 @@
|
||||
"selectServer": "seleziona server",
|
||||
"version": "versione {{version}}",
|
||||
"settings": "$t(common.setting_other)",
|
||||
"manageServers": "gestisci sever",
|
||||
"manageServers": "gestisci server",
|
||||
"expandSidebar": "espandi barra laterale",
|
||||
"collapseSidebar": "collassa barra laterale",
|
||||
"openBrowserDevtools": "apri devtools browser",
|
||||
@@ -473,7 +486,7 @@
|
||||
"error_savePassword": "si è verificato un errore quando si è provato a salvare la password"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "aggiunto {{message}} $t(entity.song_other) a {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"success": "aggiunto {{message}} $t(entity.track_other) a {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"title": "aggiungi a $t(entity.playlist_one)",
|
||||
"input_skipDuplicates": "salta duplicati",
|
||||
"input_playlists": "$t(entity.playlist_other)"
|
||||
@@ -505,7 +518,8 @@
|
||||
"size": "$t(common.size)"
|
||||
},
|
||||
"view": {
|
||||
"table": "tabella"
|
||||
"table": "tabella",
|
||||
"card": "Scheda"
|
||||
},
|
||||
"label": {
|
||||
"releaseDate": "data rilascio",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"hotkey_playbackPause": "一時停止",
|
||||
"replayGainFallback": "{{ReplayGain}} フォールバック",
|
||||
"sidebarCollapsedNavigation_description": "折りたたみサイドバーのナビゲーションを表示/非表示にします",
|
||||
"mpvExecutablePath_help": "1行ごとに1アイテム",
|
||||
"hotkey_volumeUp": "音量を上げる",
|
||||
"skipDuration": "スキップの長さ",
|
||||
"discordIdleStatus_description": "プレイヤーがアイドル状態でもステータスを更新します",
|
||||
@@ -553,7 +552,7 @@
|
||||
"error_savePassword": "パスワードを保存する際にエラーが発生しました"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "{{message}} $t(entity.song_other) を {{numOfPlaylists}} $t(entity.playlist_other) に追加しました",
|
||||
"success": "{{message}} $t(entity.track_other) を {{numOfPlaylists}} $t(entity.playlist_other) に追加しました",
|
||||
"title": "$t(entity.playlist_one) に追加",
|
||||
"input_skipDuplicates": "重複をスキップ",
|
||||
"input_playlists": "$t(entity.playlist_other)"
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
{
|
||||
"action": {
|
||||
"editPlaylist": "pas $t(entity.playlist_one) aan",
|
||||
"goToPage": "ga naar pagina",
|
||||
"moveToTop": "verplaats naar top",
|
||||
"addToFavorites": "toevoegen aan $t(entity.favorite_other)",
|
||||
"addToPlaylist": "toevoegen aan $t(entity.playlist_one)",
|
||||
"createPlaylist": "maak $t(entity.playlist_one)",
|
||||
"removeFromPlaylist": "verwijder van $t(entity.playlist_one)",
|
||||
"viewPlaylists": "bekijk $t(entity.playlist_other)",
|
||||
"refresh": "$t(common.refresh)",
|
||||
"deletePlaylist": "verwijder $t(entity.playlist_one)",
|
||||
"removeFromQueue": "verwijder van lijst",
|
||||
"deselectAll": "deselecteer alles",
|
||||
"moveToBottom": "verplaats naar bodem",
|
||||
"setRating": "selecteer rating",
|
||||
"toggleSmartPlaylistEditor": "editor $t(entity.smartPlaylist) schakelen",
|
||||
"removeFromFavorites": "verwijder van $t(entity.favorite_other)",
|
||||
"clearQueue": "lijst leegmaken"
|
||||
},
|
||||
"common": {
|
||||
"backward": "achteruit",
|
||||
"increase": "verhogen",
|
||||
"rating": "rating",
|
||||
"bpm": "bpm",
|
||||
"areYouSure": "weet je het zeker?",
|
||||
"edit": "aanpassen",
|
||||
"favorite": "favoriet",
|
||||
"left": "links",
|
||||
"currentSong": "huidig $t(entity.track_one)",
|
||||
"collapse": "samenvouwen",
|
||||
"descending": "aflopend",
|
||||
"add": "toevoegen",
|
||||
"gap": "gat",
|
||||
"ascending": "oplopend",
|
||||
"dismiss": "negeren",
|
||||
"manage": "beheren",
|
||||
"limit": "limiet",
|
||||
"minimize": "minimaliseren",
|
||||
"modified": "aangepast",
|
||||
"duration": "duur",
|
||||
"name": "naam",
|
||||
"maximize": "maximaliseren",
|
||||
"decrease": "verminder",
|
||||
"ok": "ok",
|
||||
"description": "beschrijving",
|
||||
"configure": "configureren",
|
||||
"path": "pad",
|
||||
"center": "centreren",
|
||||
"no": "nee",
|
||||
"owner": "eigenaar",
|
||||
"enable": "activeren",
|
||||
"clear": "opschonen",
|
||||
"forward": "vooruit",
|
||||
"delete": "verwijder",
|
||||
"cancel": "annuleer",
|
||||
"forceRestartRequired": "herstart om aanpassingen toe te passen... wanneer de notificatie gesloten wordt zal de applicatie herstarten",
|
||||
"filter_one": "filter",
|
||||
"filter_other": "filters",
|
||||
"filters": "filters",
|
||||
"create": "aanmaken",
|
||||
"bitrate": "bitrate",
|
||||
"action_one": "actie",
|
||||
"action_other": "acties",
|
||||
"playerMustBePaused": "player moet gepauzeerd zijn",
|
||||
"confirm": "bevestig",
|
||||
"home": "home",
|
||||
"comingSoon": "komt binnenkort…",
|
||||
"channel_one": "kanaal",
|
||||
"channel_other": "kanalen",
|
||||
"disable": "deactiveren",
|
||||
"none": "geen",
|
||||
"menu": "menu",
|
||||
"previousSong": "vorige $t(entity.track_one)",
|
||||
"noResultsFromQuery": "de zoekopdracht leverde geen resultaten op",
|
||||
"quit": "sluiten",
|
||||
"expand": "vergroten",
|
||||
"disc": "disk",
|
||||
"random": "willekeurig",
|
||||
"biography": "biografie",
|
||||
"note": "Opmerking",
|
||||
"refresh": "verversen",
|
||||
"unknown": "onbekend",
|
||||
"save": "opslaan",
|
||||
"right": "rechts",
|
||||
"trackNumber": "track",
|
||||
"year": "jaar",
|
||||
"version": "versie",
|
||||
"title": "titel",
|
||||
"saveAndReplace": "opslaan en vervangen",
|
||||
"resetToDefault": "herstellen naar standaard",
|
||||
"reset": "terugzetten",
|
||||
"sortOrder": "volgorde",
|
||||
"restartRequired": "herstart is nodig",
|
||||
"search": "zoeken",
|
||||
"saveAs": "opslaan als",
|
||||
"yes": "ja",
|
||||
"size": "grootte"
|
||||
},
|
||||
"filter": {
|
||||
"rating": "rating",
|
||||
"communityRating": "community rating",
|
||||
"criticRating": "criticus rating",
|
||||
"mostPlayed": "meest gespeeld",
|
||||
"comment": "commentaar",
|
||||
"playCount": "aantal keer afgespeeld",
|
||||
"recentlyUpdated": "recentelijk geüpdate",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"isCompilation": "is compilatie",
|
||||
"recentlyPlayed": "recentelijk afgespeeld",
|
||||
"isRated": "is rated",
|
||||
"owner": "$t(common.owner)",
|
||||
"bitrate": "bitrate",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"recentlyAdded": "recentelijk toegevoegd",
|
||||
"note": "notitie",
|
||||
"name": "naam",
|
||||
"dateAdded": "datum toegevoegd",
|
||||
"albumCount": "$t(entity.album_other) totaal",
|
||||
"path": "pad",
|
||||
"favorited": "favoriet",
|
||||
"albumArtist": "$t(entity.albumArtist_one)",
|
||||
"isRecentlyPlayed": "is recentelijk afgespeeld",
|
||||
"isFavorited": "is favoriet",
|
||||
"bpm": "bpm",
|
||||
"id": "id",
|
||||
"disc": "disk",
|
||||
"biography": "biografie",
|
||||
"artist": "$t(entity.artist_one)",
|
||||
"duration": "duratie",
|
||||
"isPublic": "is publiek",
|
||||
"random": "willekeurig",
|
||||
"lastPlayed": "laatst gespeeld",
|
||||
"fromYear": "van jaar",
|
||||
"album": "$t(entity.album_one)",
|
||||
"title": "titel",
|
||||
"search": "zoeken",
|
||||
"releaseDate": "releasedatum",
|
||||
"releaseYear": "release jaar",
|
||||
"songCount": "aantal nummers",
|
||||
"toYear": "tot jaar",
|
||||
"trackNumber": "track"
|
||||
},
|
||||
"page": {
|
||||
"contextMenu": {
|
||||
"setRating": "$t(action.setRating)",
|
||||
"addToPlaylist": "$t(action.addToPlaylist)",
|
||||
"addToFavorites": "$t(action.addToFavorites)",
|
||||
"moveToTop": "$t(action.moveToTop)",
|
||||
"deletePlaylist": "$t(action.deletePlaylist)",
|
||||
"moveToBottom": "$t(action.moveToBottom)",
|
||||
"createPlaylist": "$t(action.createPlaylist)",
|
||||
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
|
||||
"removeFromFavorites": "$t(action.removeFromFavorites)",
|
||||
"addNext": "$t(player.addNext)",
|
||||
"deselectAll": "$t(action.deselectAll)",
|
||||
"addLast": "$t(player.addLast)",
|
||||
"addFavorite": "$t(action.addToFavorites)",
|
||||
"play": "$t(player.play)",
|
||||
"numberSelected": "{{count}} geselecteerd",
|
||||
"removeFromQueue": "$t(action.removeFromQueue)"
|
||||
},
|
||||
"appMenu": {
|
||||
"selectServer": "selecteer server",
|
||||
"version": "versie {{version}}",
|
||||
"settings": "$t(common.setting_other)",
|
||||
"manageServers": "beheer servers",
|
||||
"expandSidebar": "sidebar uitklappen",
|
||||
"collapseSidebar": "sidebar inklappen",
|
||||
"openBrowserDevtools": "open browser devtools",
|
||||
"quit": "$t(common.quit)",
|
||||
"goBack": "terug",
|
||||
"goForward": "vooruit"
|
||||
},
|
||||
"albumDetail": {
|
||||
"moreFromArtist": "meer van deze $t(entity.artist_one)",
|
||||
"moreFromGeneric": "meer van {{item}}"
|
||||
},
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
"dynamicBackground": "dynamische achtergrond",
|
||||
"followCurrentLyric": "volg de actuele songtekst",
|
||||
"opacity": "opaciteit",
|
||||
"lyricSize": "tekstgrootte",
|
||||
"lyricAlignment": "songtekst uitlijning",
|
||||
"lyricGap": "tekstkloof"
|
||||
}
|
||||
},
|
||||
"albumArtistList": {
|
||||
"title": "$t(entity.albumArtist_other)"
|
||||
},
|
||||
"albumList": {
|
||||
"title": "$t(entity.album_other)"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "herstart de server om de nieuwe poort in te stellen",
|
||||
"systemFontError": "er is iets fout gegaan tijdens het verkrijgen van systeem fonts",
|
||||
"playbackError": "er is iets fout gegaan bij het afspelen van de media",
|
||||
"endpointNotImplementedError": "endpoint {{endpoint}} is niet geïmplementeerd voor {{serverType}}",
|
||||
"remotePortError": "er is iets fout gegaan tijdens het selecteren van de remote server",
|
||||
"serverRequired": "server vereist",
|
||||
"authenticationFailed": "authenticatie mislukt",
|
||||
"apiRouteError": "verzoek kan niet doorgestuurd worden",
|
||||
"genericError": "er is iets fout gegaan",
|
||||
"credentialsRequired": "inloggegevens vereist",
|
||||
"sessionExpiredError": "jouw sessie is verlopen",
|
||||
"remoteEnableError": "er is iets fout gegaan tijdens het $t(common.enable) van de remote server",
|
||||
"localFontAccessDenied": "toegang geweigerd tot lokale fonts",
|
||||
"serverNotSelectedError": "geen server geselecteerd",
|
||||
"remoteDisableError": "er is iets fout gegaan tijdens het $t(common.disable) van de remote server",
|
||||
"mpvRequired": "MPV vereist",
|
||||
"audioDeviceFetchError": "er is iets mis gegaan met het ophalen van de audioapparaten",
|
||||
"invalidServer": "ongeldige server",
|
||||
"loginRateError": "te veel login pogingen, probeer het opnieuw in een paar seconde"
|
||||
},
|
||||
"entity": {
|
||||
"genre_one": "genre",
|
||||
"genre_other": "genres",
|
||||
"playlistWithCount_one": "{{count}} afspeellijst",
|
||||
"playlistWithCount_other": "{{count}} afspeellijsten",
|
||||
"playlist_one": "afspeellijst",
|
||||
"playlist_other": "afspeellijsten",
|
||||
"artist_one": "artiest",
|
||||
"artist_other": "artiesten",
|
||||
"folderWithCount_one": "{{count}} folder",
|
||||
"folderWithCount_other": "{{count}} folders",
|
||||
"albumArtist_one": "album artiest",
|
||||
"albumArtist_other": "album artiesten",
|
||||
"track_one": "track",
|
||||
"track_other": "tracks",
|
||||
"albumArtistCount_one": "{{count}} album artiest",
|
||||
"albumArtistCount_other": "{{count}} album artiesten",
|
||||
"albumWithCount_one": "{{count}} album",
|
||||
"albumWithCount_other": "{{count}} albums",
|
||||
"favorite_one": "favoriet",
|
||||
"favorite_other": "favorieten",
|
||||
"artistWithCount_one": "{{count}} artiest",
|
||||
"artistWithCount_other": "{{count}} artiesten",
|
||||
"folder_one": "folder",
|
||||
"folder_other": "folders",
|
||||
"smartPlaylist": "smart $t(entity.playlist_one)",
|
||||
"album_one": "album",
|
||||
"album_other": "albums",
|
||||
"genreWithCount_one": "{{count}} genre",
|
||||
"genreWithCount_other": "{{count}} genres",
|
||||
"trackWithCount_one": "{{count}} track",
|
||||
"trackWithCount_other": "{{count}} tracks"
|
||||
},
|
||||
"table": {
|
||||
"column": {
|
||||
"rating": "rating"
|
||||
},
|
||||
"config": {
|
||||
"label": {
|
||||
"rating": "$t(common.rating)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"setting": {
|
||||
"hotkey_rate5": "rating 5 sterren",
|
||||
"hotkey_rate4": "rating 4 sterren"
|
||||
},
|
||||
"form": {
|
||||
"addServer": {
|
||||
"title": "server toevoegen",
|
||||
"input_username": "gebruikersnaam",
|
||||
"input_url": "url",
|
||||
"input_password": "wachtwoord",
|
||||
"input_legacyAuthentication": "activeer legacy authenticatie",
|
||||
"input_name": "server naam",
|
||||
"success": "server met succes toegevoegd",
|
||||
"input_savePassword": "wachtwoord opslaan",
|
||||
"ignoreSsl": "negeer ssl $t(common.restartRequired)",
|
||||
"ignoreCors": "negeer cors $t(common.restartRequired)",
|
||||
"error_savePassword": "er is iets mis gegaan met het opslaan van het wachtwoord"
|
||||
},
|
||||
"deletePlaylist": {
|
||||
"title": "verwijder $t(entity.playlist_one)",
|
||||
"success": "$t(entity.playlist_one) succesvol verwijdert",
|
||||
"input_confirm": "Typ de naam van $t(entity.playlist_one) om te bevestigen"
|
||||
},
|
||||
"createPlaylist": {
|
||||
"input_description": "$t(common.description)",
|
||||
"title": "$t(entity.playlist_one) aanmaken",
|
||||
"input_public": "publiek",
|
||||
"input_name": "$t(common.name)",
|
||||
"success": "$t(entity.playlist_one) aangemaakt",
|
||||
"input_owner": "$t(common.owner)"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "{{message}}$t(entity.song_other) aan {{numOfPlaylists}} $t(entity.playlist_other) toegevoegd",
|
||||
"title": "aan $t(entity.playlist_one) toevoegen",
|
||||
"input_skipDuplicates": "duplicaten overslaan",
|
||||
"input_playlists": "$t(entity.playlist_other)"
|
||||
},
|
||||
"queryEditor": {
|
||||
"input_optionMatchAll": "alles matchen",
|
||||
"input_optionMatchAny": "elke match"
|
||||
},
|
||||
"lyricSearch": {
|
||||
"input_name": "$t(common.name)",
|
||||
"input_artist": "$t(entity.artist_one)",
|
||||
"title": "tekst zoeken"
|
||||
},
|
||||
"editPlaylist": {
|
||||
"title": "$t(entity.playlist_one) aanpassen"
|
||||
},
|
||||
"updateServer": {
|
||||
"title": "update server",
|
||||
"success": "server succesvol geüpdatet"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,7 +242,7 @@
|
||||
"error_savePassword": "wystąpił błąd podczas próby zapisania hasła"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "dodano {{message}} $t(entity.song_other) do {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"success": "dodano {{message}} $t(entity.track_other) do {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"title": "dodano do $t(entity.playlist_one)",
|
||||
"input_skipDuplicates": "pomiń duplikaty",
|
||||
"input_playlists": "$t(entity.playlist_other)"
|
||||
@@ -372,7 +372,7 @@
|
||||
"stop": "stop",
|
||||
"repeat": "powtarzaj jeden",
|
||||
"queue_remove": "usuń zaznaczone",
|
||||
"playRandom": "odtwarzaj losowe",
|
||||
"playRandom": "odtwarzaj losowo",
|
||||
"skip": "pomiń",
|
||||
"previous": "poprzedni",
|
||||
"toggleFullscreenPlayer": "przełącz odtwarzacz pełnoekranowy",
|
||||
@@ -487,7 +487,6 @@
|
||||
"hotkey_playbackStop": "zatrzymaj",
|
||||
"discordRichPresence": "{{discord}} obszernie obecny",
|
||||
"font_description": "ustaw czcionkę dla aplikacji",
|
||||
"mpvExecutablePath_help": "jedna na linnię",
|
||||
"playButtonBehavior_optionPlay": "$t(player.play)",
|
||||
"minimumScrobblePercentage": "minimalny czas trwania scrobble (procentowy)",
|
||||
"mpvExecutablePath_description": "ustaw ścieżkę dla plików wykonywalnych mpv",
|
||||
@@ -501,18 +500,18 @@
|
||||
"mpvExecutablePath": "ścieżka pliku wykonywalnego mpv",
|
||||
"playButtonBehavior_description": "ustaw domyślne zachowanie dla przycisku odtwarzania kiedy piosenka zostanie dodana do kolejki",
|
||||
"minimumScrobblePercentage_description": "minimalny czas odtwarzania piosenki który musi upłynąć aby uznać ją za scrobble",
|
||||
"minimumScrobbleSeconds_description": "minimalny czas odtwarzania piosenki w sekundach jaki musi upłynąć aby uznać ją za scrobble",
|
||||
"minimumScrobbleSeconds_description": "minimalny czas odtwarzania piosenki w sekundach jaki musi upłynąć aby uznać ją za scrobbling",
|
||||
"playButtonBehavior": "zachowanie przycisku odtwarzania",
|
||||
"playbackStyle_optionNormal": "normalny",
|
||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||
"minimumScrobbleSeconds": "minimalne scrobble (sekund)",
|
||||
"minimumScrobbleSeconds": "minimalne scrobble (w sekundach)",
|
||||
"remotePort_description": "ustaw port dla serwera zdalnej kontroli",
|
||||
"replayGainMode_description": "dostosuj wzmocnienie dźwięku zgodnie z wartościami {{ReplayGain}} przechowywanymi w metadanych do pliku",
|
||||
"replayGainFallback": "rezerwowy {{ReplayGain}}",
|
||||
"sidebarCollapsedNavigation_description": "pokaż lub ukryj nawigację na zwiniętym pasku bocznym",
|
||||
"skipDuration": "czas trwania pominięcia",
|
||||
"showSkipButtons": "pokaż przyciski pomijania",
|
||||
"scrobble": "scrobble",
|
||||
"scrobble": "scrobbling",
|
||||
"skipDuration_description": "ustaw czas pominięcia kiedy zostanie użyty przycisk pominięcia na pasku odtwarzania",
|
||||
"replayGainClipping_description": "Zapobiegaj wzmocnieniu spowodowanemu przez {{ReplayGain}} na automatyczne obniżanie wzmocnienia",
|
||||
"replayGainPreamp": "przedwzmacniacz {{ReplayGain}} (db)",
|
||||
|
||||
+47
-11
@@ -9,7 +9,7 @@
|
||||
"bitrate": "taxa de bits",
|
||||
"action_one": "ação",
|
||||
"action_many": "ações",
|
||||
"action_other": "(n == 0 || n == 1) ? ação : ações",
|
||||
"action_other": "ações",
|
||||
"biography": "biografia",
|
||||
"bpm": "bpm",
|
||||
"edit": "editar",
|
||||
@@ -31,7 +31,7 @@
|
||||
"comingSoon": "em breve…",
|
||||
"channel_one": "canal",
|
||||
"channel_many": "canais",
|
||||
"channel_other": "(n == 0 || n == 1) ? canal : canais",
|
||||
"channel_other": "canais",
|
||||
"disable": "desabilitar",
|
||||
"expand": "expandir",
|
||||
"disc": "disco",
|
||||
@@ -62,7 +62,7 @@
|
||||
"version": "versão",
|
||||
"filter_one": "filtro",
|
||||
"filter_many": "filtros",
|
||||
"filter_other": "(n == 0 || n == 1) ? filtro : filtros",
|
||||
"filter_other": "filtros",
|
||||
"filters": "filtros",
|
||||
"saveAndReplace": "salvar e substituir",
|
||||
"playerMustBePaused": "o player deve estar pausado",
|
||||
@@ -184,27 +184,63 @@
|
||||
"entity": {
|
||||
"albumArtist_one": "artista do álbum",
|
||||
"albumArtist_many": "artistas do álbum",
|
||||
"albumArtist_other": "(n == 0 || n == 1) ? artista do álbum : artistas do álbum",
|
||||
"albumArtist_other": "artistas do álbum",
|
||||
"albumArtistCount_one": "{{count}} artista do álbum",
|
||||
"albumArtistCount_many": "{{count}} artistas do álbum",
|
||||
"albumArtistCount_other": "(n == 0 || n == 1) ? {{count}} artista do álbum : {{count}} artistas do álbum",
|
||||
"albumArtistCount_other": "{{count}} artistas do álbum",
|
||||
"album_one": "álbum",
|
||||
"album_many": "álbuns",
|
||||
"album_other": "(n == 0 || n == 1) ? álbum : álbuns",
|
||||
"album_other": "álbuns",
|
||||
"artist_one": "artista",
|
||||
"artist_many": "artistas",
|
||||
"artist_other": "(n == 0 || n == 1) ? artista : artistas",
|
||||
"artist_other": "artistas",
|
||||
"albumWithCount_one": "{{count}} álbum",
|
||||
"albumWithCount_many": "{{count}} álbuns",
|
||||
"albumWithCount_other": "(n == 0 || n == 1) ? {{count}} álbum : {{count}} álbuns",
|
||||
"albumWithCount_other": "{{count}} álbuns",
|
||||
"favorite_one": "favorito",
|
||||
"favorite_many": "favoritos",
|
||||
"favorite_other": "(n == 0 || n == 1) ? favorito : favoritos",
|
||||
"favorite_other": "favoritos",
|
||||
"artistWithCount_one": "{{count}} artista",
|
||||
"artistWithCount_many": "{{count}} artistas",
|
||||
"artistWithCount_other": "(n == 0 || n == 1) ? artista : artistas",
|
||||
"artistWithCount_other": "{{count}} artistas",
|
||||
"folder_one": "pasta",
|
||||
"folder_many": "pastas",
|
||||
"folder_other": "(n == 0 || n == 1) ? pasta : pastas"
|
||||
"folder_other": "pastas",
|
||||
"genre_one": "gênero",
|
||||
"genre_many": "gêneros",
|
||||
"genre_other": "gêneros",
|
||||
"playlistWithCount_one": "{{count}} playlist",
|
||||
"playlistWithCount_many": "{{count}} playlists",
|
||||
"playlistWithCount_other": "{{count}} playlists",
|
||||
"playlist_one": "playlist",
|
||||
"playlist_many": "playlists",
|
||||
"playlist_other": "playlists",
|
||||
"folderWithCount_one": "{{count}} pasta",
|
||||
"folderWithCount_many": "{{count}} pastas",
|
||||
"folderWithCount_other": "{{count}} pastas",
|
||||
"genreWithCount_one": "{{count}} gênero",
|
||||
"genreWithCount_many": "{{count}} gêneros",
|
||||
"genreWithCount_other": "{{count}} gêneros"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "reinicie o servidor para aplicar a nova porta",
|
||||
"systemFontError": "ocorreu um erro ao tentar obter fontes do sistema",
|
||||
"playbackError": "ocorreu um erro ao tentar reproduzir a mídia",
|
||||
"endpointNotImplementedError": "endpoint {{endpoint}} não está implementado para {{serverType}}",
|
||||
"remotePortError": "ocorreu um erro ao tentar definir a porta do servidor remoto",
|
||||
"serverRequired": "servidor necessário",
|
||||
"authenticationFailed": "falha na autenticação",
|
||||
"apiRouteError": "não é possível encaminhar a solicitação",
|
||||
"genericError": "um erro ocorreu",
|
||||
"credentialsRequired": "credenciais necessárias",
|
||||
"sessionExpiredError": "sua sessão expirou",
|
||||
"remoteEnableError": "ocorreu um erro ao tentar $t(common.enable) o servidor remoto",
|
||||
"localFontAccessDenied": "acesso negado a fontes locais",
|
||||
"serverNotSelectedError": "nenhum servidor selecionado",
|
||||
"remoteDisableError": "ocorreu um erro ao tentar $t(common.disable) o servidor remoto",
|
||||
"mpvRequired": "MPV necessário",
|
||||
"audioDeviceFetchError": "ocorreu um erro ao tentar obter dispositivos de áudio",
|
||||
"invalidServer": "servidor inválido",
|
||||
"loginRateError": "muitas tentativas de login, tente novamente em alguns segundos"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,7 +434,7 @@
|
||||
"error_savePassword": "произошла ошибка во время попытки сохранения пароля"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "добавлено(а) {{message}} $t(entity.song_other) в {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"success": "добавлено(а) {{message}} $t(entity.track_other) в {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"title": "добавить в $t(entity.playlist_one)",
|
||||
"input_skipDuplicates": "пропустить дубликаты",
|
||||
"input_playlists": "$t(entity.playlist_other)"
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"hotkey_playbackPause": "pauza",
|
||||
"replayGainFallback": "{{ReplayGain}} alternativa",
|
||||
"sidebarCollapsedNavigation_description": "prikaži ili sakrij navigaciju u sklopljenoj bočnoj traci",
|
||||
"mpvExecutablePath_help": "po jedna po liniji",
|
||||
"hotkey_volumeUp": "pojačaj glasnoću",
|
||||
"skipDuration": "dužina preskakanja",
|
||||
"discordIdleStatus_description": "kada je omogućeno, ažurira status dok je plejer u mirovanju",
|
||||
@@ -559,7 +558,7 @@
|
||||
"error_savePassword": "došlo je do greške prilikom pokušaja čuvanja lozinke"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "dodato {{message}} $t(entity.song_other) u {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"success": "dodato {{message}} $t(entity.track_other) u {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"title": "dodaj u $t(entity.playlist_one)",
|
||||
"input_skipDuplicates": "preskoči duplikate",
|
||||
"input_playlists": "$t(entity.playlist_other)"
|
||||
|
||||
@@ -95,7 +95,8 @@
|
||||
"random": "slumpmässig",
|
||||
"size": "storlek",
|
||||
"biography": "biografi",
|
||||
"note": "anteckning"
|
||||
"note": "anteckning",
|
||||
"center": "center"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "starta om servern för att tillämpa den nya porten",
|
||||
@@ -157,7 +158,9 @@
|
||||
"toYear": "till år",
|
||||
"fromYear": "från år",
|
||||
"album": "$t(entity.album_one)",
|
||||
"trackNumber": "spår"
|
||||
"trackNumber": "spår",
|
||||
"songCount": "sångräkning",
|
||||
"criticRating": "kritikerbetyg"
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
@@ -187,7 +190,7 @@
|
||||
"error_savePassword": "ett fel uppstod när lösenordet skulle sparas"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "tillade {{message}} $t(entity.song_other) til {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"success": "tillade {{message}} $t(entity.track_other) til {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"title": "lägg till i $t(entity.playlist_one)",
|
||||
"input_skipDuplicates": "hoppa över dubbletter",
|
||||
"input_playlists": "$t(entity.playlist_other)"
|
||||
@@ -218,8 +221,13 @@
|
||||
"opacity": "ogenomskinlighet",
|
||||
"lyricSize": "låttext storlek",
|
||||
"lyricAlignment": "låttext justering",
|
||||
"lyricGap": "låttext mellanrum"
|
||||
}
|
||||
"lyricGap": "låttext mellanrum",
|
||||
"synchronized": "synkroniserad",
|
||||
"showLyricProvider": "visa sångtextleverantör",
|
||||
"unsynchronized": "osynkroniserad"
|
||||
},
|
||||
"lyrics": "sångtext",
|
||||
"related": "relaterad"
|
||||
},
|
||||
"appMenu": {
|
||||
"selectServer": "välj server",
|
||||
@@ -230,7 +238,8 @@
|
||||
"openBrowserDevtools": "öppna webbläsarens utvecklingsverktyg",
|
||||
"quit": "$t(common.quit)",
|
||||
"goBack": "gå tillbaka",
|
||||
"goForward": "gå framåt"
|
||||
"goForward": "gå framåt",
|
||||
"collapseSidebar": "växla sidofältet"
|
||||
},
|
||||
"contextMenu": {
|
||||
"addToPlaylist": "$t(action.addToPlaylist)",
|
||||
@@ -251,7 +260,7 @@
|
||||
"removeFromQueue": "$t(action.removeFromQueue)"
|
||||
},
|
||||
"albumDetail": {
|
||||
"moreFromArtist": "Mer från $t(entity.genre_one)",
|
||||
"moreFromArtist": "mer från $t(entity.genre_one)",
|
||||
"moreFromGeneric": "mer från {{item}}"
|
||||
},
|
||||
"albumArtistList": {
|
||||
@@ -259,6 +268,29 @@
|
||||
},
|
||||
"albumList": {
|
||||
"title": "$t(entity.album_other)"
|
||||
},
|
||||
"sidebar": {
|
||||
"nowPlaying": "nu spelas"
|
||||
},
|
||||
"home": {
|
||||
"mostPlayed": "mest spelade",
|
||||
"newlyAdded": "nytillkomna utgåvor",
|
||||
"explore": "utforska från ditt bibliotek",
|
||||
"recentlyPlayed": "nyligen spelat"
|
||||
},
|
||||
"setting": {
|
||||
"playbackTab": "uppspelning",
|
||||
"generalTab": "allmänt",
|
||||
"hotkeysTab": "snabbtangenter",
|
||||
"windowTab": "fönster"
|
||||
},
|
||||
"globalSearch": {
|
||||
"commands": {
|
||||
"serverCommands": "serverkommandon",
|
||||
"goToPage": "gå till sidan",
|
||||
"searchFor": "sök efter {{query}}"
|
||||
},
|
||||
"title": "kommandon"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -277,6 +309,37 @@
|
||||
"folder_one": "mapp",
|
||||
"folder_other": "mappar",
|
||||
"album_one": "album",
|
||||
"album_other": "album"
|
||||
"album_other": "album",
|
||||
"playlistWithCount_one": "{{count}} spellista",
|
||||
"playlistWithCount_other": "{{count}} spellistor",
|
||||
"folderWithCount_one": "{{count}} mapp",
|
||||
"folderWithCount_other": "{{count}} mappar",
|
||||
"track_one": "spår",
|
||||
"track_other": "spår",
|
||||
"trackWithCount_one": "{{count}} spår",
|
||||
"trackWithCount_other": "{{count}} spår"
|
||||
},
|
||||
"player": {
|
||||
"repeat_all": "repetera alla",
|
||||
"repeat": "repetera",
|
||||
"queue_remove": "ta bort markerad",
|
||||
"playRandom": "spela slumpmässigt",
|
||||
"previous": "föregående",
|
||||
"favorite": "favorit",
|
||||
"next": "nästa",
|
||||
"shuffle": "blanda",
|
||||
"playbackFetchNoResults": "inga låtar hittades",
|
||||
"playbackFetchInProgress": "laddar låtar…",
|
||||
"addNext": "lägg till nästa",
|
||||
"playbackSpeed": "uppspelningshastighet",
|
||||
"playbackFetchCancel": "det här tar ett tag... stäng aviseringen för att avbryta",
|
||||
"play": "spela",
|
||||
"repeat_off": "repetera inaktiverad",
|
||||
"queue_clear": "rensa kö",
|
||||
"muted": "mutad",
|
||||
"queue_moveToTop": "flytta markerad till botten",
|
||||
"queue_moveToBottom": "flytta markerad till toppen",
|
||||
"addLast": "lägg till sist",
|
||||
"mute": "muta"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
"queue_remove": "移除所选",
|
||||
"playRandom": "随机播放",
|
||||
"skip": "跳过",
|
||||
"previous": "前一首",
|
||||
"previous": "上一首",
|
||||
"toggleFullscreenPlayer": "全屏",
|
||||
"skip_back": "向后跳过",
|
||||
"favorite": "收藏",
|
||||
@@ -192,11 +192,11 @@
|
||||
"scrobble": "记录播放信息(Scrobble)",
|
||||
"skipDuration_description": "设置每次按下跳过按钮将会跳过的时长",
|
||||
"fontType_optionSystem": "系统字体",
|
||||
"mpvExecutablePath_description": "设置 mpv 二进制文件的路径",
|
||||
"mpvExecutablePath_description": "设置 mpv 二进制文件的路径。如果留空,则使用默认路径",
|
||||
"sampleRate": "采样率",
|
||||
"sidePlayQueueStyle_optionAttached": "吸附",
|
||||
"sidebarConfiguration": "侧边栏设定",
|
||||
"sampleRate_description": "所选的采样率与当前媒体的频率不同时,用于输出的采样率",
|
||||
"sampleRate_description": "如果选择的采样频率与当前媒体的采样频率不同,请选择要使用的输出采样率。小于 8000 的值将使用默认频率",
|
||||
"replayGainMode_optionNone": "$t(common.none)",
|
||||
"hotkey_zoomIn": "放大",
|
||||
"scrobble_description": "在你的社交媒体中记录播放信息",
|
||||
@@ -208,7 +208,7 @@
|
||||
"hotkey_skipForward": "向后跳过",
|
||||
"sidePlayQueueStyle": "侧边播放列表样式",
|
||||
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||
"zoom": "放大率",
|
||||
"zoom": "缩放率",
|
||||
"minimizeToTray_description": "将应用程序最小化到系统托盘",
|
||||
"hotkey_playbackPlay": "播放",
|
||||
"hotkey_togglePreviousSongFavorite": "收藏 / 取消收藏$t(common.previousSong)",
|
||||
@@ -233,7 +233,7 @@
|
||||
"hotkey_toggleFullScreenPlayer": "全屏播放",
|
||||
"hotkey_localSearch": "页面内搜索",
|
||||
"hotkey_toggleQueue": "显示 / 隐藏播放队列",
|
||||
"zoom_description": "设置应用的放大率",
|
||||
"zoom_description": "设置应用程序的缩放率",
|
||||
"remotePassword_description": "设置远程控制服务器的密码。这些凭据默认以不安全的方式传输,因此您应该使用一个您不在意的唯一密码",
|
||||
"hotkey_rate5": "评为 5 星",
|
||||
"hotkey_playbackPrevious": "上一曲",
|
||||
@@ -292,7 +292,6 @@
|
||||
"windowBarStyle_description": "选择窗口顶栏的风格",
|
||||
"savePlayQueue_description": "当应用程序关闭时保存播放队列,并在应用程序打开时恢复它",
|
||||
"useSystemTheme": "跟随系统",
|
||||
"mpvExecutablePath_help": "每行一个",
|
||||
"discordIdleStatus_description": "启用后将会在播放器闲置时更新状态",
|
||||
"replayGainClipping_description": "自动降低增益以防止{{ReplayGain}}造成削波",
|
||||
"replayGainPreamp": "{{ReplayGain}}前置放大(分贝)",
|
||||
@@ -305,7 +304,13 @@
|
||||
"accentColor_description": "设置应用的强调色",
|
||||
"replayGainPreamp_description": "调整应用在{{ReplayGain}}值上的前置放大增益",
|
||||
"discordIdleStatus": "显示 rich presence 闲置状态",
|
||||
"discordRichPresence": "{{discord}} rich presence"
|
||||
"discordRichPresence": "{{discord}} rich presence",
|
||||
"clearCache": "清除浏览器缓存",
|
||||
"buttonSize": "播放器栏按钮大小",
|
||||
"buttonSize_description": "播放器栏按钮大小",
|
||||
"clearCache_description": "feishin的“硬清除”。除了清除feishin的缓存,清空浏览器缓存(保存的图像和其他资源)。会保留服务器凭据和设置",
|
||||
"clearQueryCache_description": "feishin的“软清除”。这将会刷新播放列表、元数据并重置保存的歌词。会保留设置、服务器凭据和缓存图像",
|
||||
"clearQueryCache": "清除feishin缓存"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "重启服务器使新端口生效",
|
||||
@@ -495,7 +500,7 @@
|
||||
"input_url": "url"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "添加 {{message}} $t(entity.song_other) 到 {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"success": "添加 $t(entity.trackWithCount, {\"count\": {{message}} }) 到 $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||
"title": "添加到$t(entity.playlist_one)",
|
||||
"input_skipDuplicates": "跳过重复",
|
||||
"input_playlists": "$t(entity.playlist_other)"
|
||||
|
||||
@@ -1 +1,600 @@
|
||||
{}
|
||||
{
|
||||
"common": {
|
||||
"backward": "返回",
|
||||
"biography": "簡介",
|
||||
"bitrate": "比特率",
|
||||
"bpm": "bpm",
|
||||
"clear": "清空",
|
||||
"collapse": "折疊",
|
||||
"comingSoon": "即將上線…",
|
||||
"confirm": "確認",
|
||||
"decrease": "降低",
|
||||
"delete": "刪除",
|
||||
"descending": "降序",
|
||||
"description": "描述",
|
||||
"forceRestartRequired": "重啓應用使更改生效…關閉通知即可重啓",
|
||||
"menu": "菜單",
|
||||
"action_other": "操作",
|
||||
"add": "添加",
|
||||
"areYouSure": "是否繼續?",
|
||||
"ascending": "升序",
|
||||
"disable": "禁用",
|
||||
"disc": "盤",
|
||||
"dismiss": "忽略",
|
||||
"duration": "時長",
|
||||
"edit": "編輯",
|
||||
"enable": "啓用",
|
||||
"expand": "展開",
|
||||
"favorite": "收藏",
|
||||
"filter_other": "篩選",
|
||||
"filters": "篩選",
|
||||
"forward": "前進",
|
||||
"gap": "空隙",
|
||||
"home": "主頁",
|
||||
"increase": "增高",
|
||||
"left": "左",
|
||||
"limit": "限制",
|
||||
"manage": "管理",
|
||||
"maximize": "最大化",
|
||||
"ok": "好",
|
||||
"owner": "所有者",
|
||||
"path": "路徑",
|
||||
"playerMustBePaused": "播放器須被暫停",
|
||||
"previousSong": "上壹首$t(entity.track_one)",
|
||||
"quit": "退出",
|
||||
"random": "隨機",
|
||||
"rating": "評分",
|
||||
"refresh": "刷新",
|
||||
"reset": "重置",
|
||||
"resetToDefault": "重置爲默認",
|
||||
"restartRequired": "需要重啓應用",
|
||||
"right": "右",
|
||||
"save": "保存",
|
||||
"saveAndReplace": "保存並替換",
|
||||
"saveAs": "保存爲",
|
||||
"search": "搜索",
|
||||
"sortOrder": "順序",
|
||||
"title": "標題",
|
||||
"trackNumber": "音軌編號",
|
||||
"unknown": "未知",
|
||||
"size": "大小",
|
||||
"version": "版本",
|
||||
"year": "年份",
|
||||
"yes": "是",
|
||||
"cancel": "取消",
|
||||
"center": "中央",
|
||||
"channel_other": "頻道",
|
||||
"configure": "配置",
|
||||
"create": "創建",
|
||||
"currentSong": "當前$t(entity.track_one)",
|
||||
"minimize": "最小化",
|
||||
"modified": "已修改",
|
||||
"name": "名稱",
|
||||
"no": "否",
|
||||
"none": "無",
|
||||
"noResultsFromQuery": "未查詢到匹配結果",
|
||||
"note": "注釋"
|
||||
},
|
||||
"error": {
|
||||
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
|
||||
"apiRouteError": "請求失敗:無法路由",
|
||||
"audioDeviceFetchError": "無法獲取音頻設備",
|
||||
"authenticationFailed": "認證失敗",
|
||||
"credentialsRequired": "需要憑證",
|
||||
"genericError": "發生了錯誤",
|
||||
"invalidServer": "無效的服務器",
|
||||
"localFontAccessDenied": "無法獲取本地字體",
|
||||
"loginRateError": "登錄請求嘗試次數過多,請稍後再試",
|
||||
"remoteDisableError": "$t(common.disable)遠程服務器時出現錯誤",
|
||||
"remoteEnableError": "$t(common.enable)遠程服務器時出現錯誤",
|
||||
"remotePortError": "設置遠程服務器端口時發生錯誤",
|
||||
"remotePortWarning": "重啓服務器使新端口生效",
|
||||
"serverRequired": "需要服務器",
|
||||
"sessionExpiredError": "會話已過期",
|
||||
"systemFontError": "獲取系統字體時出現錯誤",
|
||||
"serverNotSelectedError": "未選擇服務器",
|
||||
"mpvRequired": "需要 MPV",
|
||||
"playbackError": "無法播放媒體"
|
||||
},
|
||||
"page": {
|
||||
"contextMenu": {
|
||||
"removeFromFavorites": "$t(action.removeFromFavorites)",
|
||||
"addToFavorites": "$t(action.addToFavorites)",
|
||||
"addToPlaylist": "$t(action.addToPlaylist)",
|
||||
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
|
||||
"removeFromQueue": "$t(action.removeFromQueue)",
|
||||
"addFavorite": "$t(action.addToFavorites)",
|
||||
"addLast": "$t(player.addLast)",
|
||||
"addNext": "$t(player.addNext)",
|
||||
"createPlaylist": "$t(action.createPlaylist)",
|
||||
"deletePlaylist": "$t(action.deletePlaylist)",
|
||||
"deselectAll": "$t(action.deselectAll)",
|
||||
"moveToBottom": "$t(action.moveToBottom)",
|
||||
"setRating": "$t(action.setRating)",
|
||||
"moveToTop": "$t(action.moveToTop)",
|
||||
"numberSelected": "{{count}} 已選擇",
|
||||
"play": "$t(player.play)"
|
||||
},
|
||||
"globalSearch": {
|
||||
"title": "命令",
|
||||
"commands": {
|
||||
"goToPage": "跳至頁面",
|
||||
"searchFor": "搜索 {{query}}",
|
||||
"serverCommands": "服務器命令"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"explore": "從庫中搜索",
|
||||
"recentlyPlayed": "最近播放",
|
||||
"title": "$t(common.home)",
|
||||
"mostPlayed": "最多播放",
|
||||
"newlyAdded": "最近添加的發布"
|
||||
},
|
||||
"appMenu": {
|
||||
"openBrowserDevtools": "打開浏覽器開發者工具",
|
||||
"collapseSidebar": "折疊側邊欄",
|
||||
"expandSidebar": "展開側邊欄",
|
||||
"goBack": "返回",
|
||||
"goForward": "前進",
|
||||
"quit": "$t(common.quit)",
|
||||
"selectServer": "選擇服務器",
|
||||
"settings": "$t(common.setting_other)",
|
||||
"version": "版本 {{version}}",
|
||||
"manageServers": "管理服務器"
|
||||
},
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
"showLyricProvider": "顯示歌詞提供者",
|
||||
"useImageAspectRatio": "使用圖片縱橫比",
|
||||
"dynamicBackground": "動態背景",
|
||||
"followCurrentLyric": "跟隨當前歌詞",
|
||||
"lyricAlignment": "歌詞對齊",
|
||||
"lyricGap": "歌詞間距",
|
||||
"lyricSize": "歌詞字體大小",
|
||||
"synchronized": "已同步",
|
||||
"unsynchronized": "未同步",
|
||||
"opacity": "透明度",
|
||||
"showLyricMatch": "顯示匹配的歌詞"
|
||||
},
|
||||
"lyrics": "歌詞",
|
||||
"related": "相關",
|
||||
"upNext": "即將播放"
|
||||
},
|
||||
"playlistList": {
|
||||
"title": "$t(entity.playlist_other)"
|
||||
},
|
||||
"setting": {
|
||||
"hotkeysTab": "快捷鍵",
|
||||
"playbackTab": "播放",
|
||||
"windowTab": "窗口",
|
||||
"generalTab": "通用"
|
||||
},
|
||||
"albumArtistList": {
|
||||
"title": "$t(entity.albumArtist_other)"
|
||||
},
|
||||
"albumDetail": {
|
||||
"moreFromArtist": "更多該$t(entity.artist_one)作品",
|
||||
"moreFromGeneric": "更多{{item}}作品"
|
||||
},
|
||||
"albumList": {
|
||||
"title": "$t(entity.album_other)"
|
||||
},
|
||||
"genreList": {
|
||||
"title": "$t(entity.genre_other)"
|
||||
},
|
||||
"sidebar": {
|
||||
"albumArtists": "$t(entity.albumArtist_other)",
|
||||
"albums": "$t(entity.album_other)",
|
||||
"artists": "$t(entity.artist_other)",
|
||||
"folders": "$t(entity.folder_other)",
|
||||
"search": "$t(common.search)",
|
||||
"settings": "$t(common.setting_other)",
|
||||
"tracks": "$t(entity.track_other)",
|
||||
"genres": "$t(entity.genre_other)",
|
||||
"home": "$t(common.home)",
|
||||
"nowPlaying": "正在播放",
|
||||
"playlists": "$t(entity.playlist_other)"
|
||||
},
|
||||
"trackList": {
|
||||
"title": "$t(entity.track_other)"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playbackFetchInProgress": "正在加載歌曲…",
|
||||
"addLast": "添加到播放列表末尾",
|
||||
"addNext": "添加爲播放列表下壹首",
|
||||
"favorite": "收藏",
|
||||
"mute": "靜音",
|
||||
"muted": "已靜音",
|
||||
"playbackFetchNoResults": "未找到歌曲",
|
||||
"playbackSpeed": "播放速度",
|
||||
"playRandom": "隨機播放",
|
||||
"previous": "上壹首",
|
||||
"queue_clear": "清空播放隊列",
|
||||
"queue_remove": "移除所選",
|
||||
"repeat": "循環",
|
||||
"repeat_all": "全部循環",
|
||||
"repeat_off": "不循環",
|
||||
"shuffle": "隨機播放",
|
||||
"shuffle_off": "未啓用隨機播放",
|
||||
"skip": "跳過",
|
||||
"skip_back": "向後跳過",
|
||||
"skip_forward": "向前跳過",
|
||||
"stop": "停止",
|
||||
"toggleFullscreenPlayer": "全屏",
|
||||
"unfavorite": "取消收藏",
|
||||
"pause": "暫停",
|
||||
"next": "下壹首",
|
||||
"play": "播放",
|
||||
"playbackFetchCancel": "請稍等…關閉通知以取消操作",
|
||||
"queue_moveToBottom": "使所選置頂",
|
||||
"queue_moveToTop": "使所選置底"
|
||||
},
|
||||
"setting": {
|
||||
"audioPlayer_description": "選擇用于播放的音頻播放器",
|
||||
"themeLight": "主題(淺色)",
|
||||
"themeLight_description": "應用將使用淺色主題",
|
||||
"discordRichPresence": "{{discord}} rich presence",
|
||||
"hotkey_volumeDown": "音量降低",
|
||||
"hotkey_volumeMute": "靜音",
|
||||
"minimumScrobblePercentage": "最小 scrobble 時長(百分比)",
|
||||
"minimumScrobblePercentage_description": "歌曲被記錄爲已播放(scrobble)所需的最小播放百分比",
|
||||
"theme_description": "設置應用的主題",
|
||||
"accentColor": "強調色",
|
||||
"accentColor_description": "設置應用的強調色",
|
||||
"applicationHotkeys": "應用快捷鍵",
|
||||
"applicationHotkeys_description": "配置應用快捷鍵。勾選設爲全局快捷鍵(僅桌面端)",
|
||||
"audioDevice": "音頻設備",
|
||||
"audioDevice_description": "選擇用于播放的音頻設備(僅 web 播放器)",
|
||||
"audioExclusiveMode": "音頻獨占模式",
|
||||
"audioExclusiveMode_description": "啓用獨占輸出模式。在此模式下,系統通常被鎖定,只有 mpv 能夠輸出音頻",
|
||||
"audioPlayer": "音頻播放器",
|
||||
"crossfadeDuration": "淡入淡出持續時間",
|
||||
"crossfadeDuration_description": "設置淡入淡出持續時間",
|
||||
"crossfadeStyle": "淡入淡出風格",
|
||||
"crossfadeStyle_description": "選擇用于音頻播放器的淡入淡出風格",
|
||||
"customFontPath": "自定義字體路徑",
|
||||
"customFontPath_description": "設置應用使用的自定義字體路徑",
|
||||
"disableAutomaticUpdates": "禁用自動更新",
|
||||
"disableLibraryUpdateOnStartup": "禁用啓動時查找新版本",
|
||||
"discordApplicationId": "{{discord}} 應用 id",
|
||||
"discordApplicationId_description": "{{discord}} rich presence 應用 id(默認爲 {{defaultId}})",
|
||||
"discordIdleStatus": "顯示 rich presence 閑置狀態",
|
||||
"discordIdleStatus_description": "啓用後將會在播放器閑置時更新狀態",
|
||||
"discordRichPresence_description": "在 {{discord}} rich presence 中顯示播放狀態。圖片鍵爲:{{icon}}、{{playing}} 和 {{paused}} ",
|
||||
"discordUpdateInterval": "{{discord}} rich presence 更新間隔",
|
||||
"discordUpdateInterval_description": "更新間隔秒數(至少 15 秒)",
|
||||
"enableRemote": "啓用遠程控制服務器",
|
||||
"enableRemote_description": "啓用遠程控制服務器,以允許其他設備控制此應用",
|
||||
"exitToTray": "退出時最小化到托盤",
|
||||
"floatingQueueArea_description": "在屏幕右側顯示壹個懸停圖標,以查看播放隊列",
|
||||
"followLyric": "跟隨當前歌詞",
|
||||
"font_description": "設置應用使用的字體",
|
||||
"fontType": "字體類型",
|
||||
"fontType_description": "內置字體可以選擇 Feishin 提供的字體之壹。系統字體允許您選擇操作系統提供的任何字體。自定義選項允許您使用自己的字體",
|
||||
"fontType_optionBuiltIn": "內置字體",
|
||||
"fontType_optionCustom": "自定義字體",
|
||||
"fontType_optionSystem": "系統字體",
|
||||
"gaplessAudio": "無縫音頻",
|
||||
"gaplessAudio_description": "調整 mpv 無縫音頻設置",
|
||||
"gaplessAudio_optionWeak": "弱(推薦)",
|
||||
"globalMediaHotkeys": "全局媒體快捷鍵",
|
||||
"hotkey_browserForward": "浏覽器前進",
|
||||
"hotkey_favoritePreviousSong": "收藏 $t(common.previousSong)",
|
||||
"hotkey_globalSearch": "全局搜索",
|
||||
"hotkey_localSearch": "頁面內搜索",
|
||||
"hotkey_playbackNext": "下壹曲",
|
||||
"hotkey_playbackPause": "暫停",
|
||||
"hotkey_playbackPlay": "播放",
|
||||
"hotkey_playbackPlayPause": "播放/暫停",
|
||||
"hotkey_playbackPrevious": "上壹曲",
|
||||
"hotkey_rate2": "評爲 2 星",
|
||||
"hotkey_rate1": "評爲 1 星",
|
||||
"hotkey_rate3": "評爲 3 星",
|
||||
"hotkey_rate4": "評爲 4 星",
|
||||
"hotkey_rate5": "評爲 5 星",
|
||||
"hotkey_skipBackward": "向回跳過",
|
||||
"hotkey_skipForward": "向後跳過",
|
||||
"hotkey_toggleCurrentSongFavorite": "收藏 / 取消收藏$t(common.currentSong)",
|
||||
"hotkey_toggleFullScreenPlayer": "全屏播放",
|
||||
"hotkey_togglePreviousSongFavorite": "收藏 / 取消收藏$t(common.previousSong)",
|
||||
"hotkey_toggleQueue": "顯示 / 隱藏播放隊列",
|
||||
"hotkey_toggleRepeat": "切換循環播放設定",
|
||||
"hotkey_toggleShuffle": "切換隨機播放設定",
|
||||
"hotkey_unfavoriteCurrentSong": "取消收藏$t(common.currentSong)",
|
||||
"hotkey_unfavoritePreviousSong": "取消收藏$t(common.previousSong)",
|
||||
"hotkey_zoomIn": "放大",
|
||||
"hotkey_zoomOut": "縮小",
|
||||
"language": "語言",
|
||||
"language_description": "設置應用的語言($t(common.restartRequired))",
|
||||
"lyricFetch": "從互聯網獲取歌詞",
|
||||
"lyricFetch_description": "從多個互聯網源獲取歌詞",
|
||||
"lyricFetchProvider": "歌詞源",
|
||||
"lyricOffset": "歌詞偏移(毫秒)",
|
||||
"lyricOffset_description": "將歌詞偏移指定的毫秒數",
|
||||
"lyricFetchProvider_description": "選擇歌詞源。 歌詞源順序與查詢順序壹致",
|
||||
"minimizeToTray": "最小化到托盤",
|
||||
"minimizeToTray_description": "將應用程序最小化到系統托盤",
|
||||
"minimumScrobbleSeconds": "最小 scrobble 時間(秒)",
|
||||
"minimumScrobbleSeconds_description": "歌曲被記錄爲已播放(scrobble)所需的最小播放時間",
|
||||
"mpvExecutablePath": "mpv 二進制文件路徑",
|
||||
"playbackStyle_optionCrossFade": "交叉淡入淡出",
|
||||
"playbackStyle_optionNormal": "通常",
|
||||
"playButtonBehavior": "播放按鈕行爲",
|
||||
"playButtonBehavior_description": "設置將歌曲添加到隊列時播放按鈕的默認行爲",
|
||||
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||
"remotePort": "遠程服務器端口",
|
||||
"remoteUsername": "遠程服務器用戶名",
|
||||
"replayGainClipping": "{{ReplayGain}}削波",
|
||||
"replayGainFallback": "{{ReplayGain}}後備替代",
|
||||
"replayGainFallback_description": "樂曲沒有{{ReplayGain}}標簽時應用的增益(以分貝爲單位)",
|
||||
"replayGainMode": "{{ReplayGain}}模式",
|
||||
"replayGainMode_description": "根據樂曲元數據中存儲的{{ReplayGain}}值調整音量增益",
|
||||
"replayGainMode_optionAlbum": "$t(entity.album_one)",
|
||||
"replayGainMode_optionNone": "$t(common.none)",
|
||||
"replayGainMode_optionTrack": "$t(entity.track_one)",
|
||||
"replayGainPreamp": "{{ReplayGain}}前置放大(分貝)",
|
||||
"replayGainPreamp_description": "調整應用在{{ReplayGain}}值上的前置放大增益",
|
||||
"savePlayQueue": "保存播放列表",
|
||||
"sampleRate_description": "如果選擇的采樣頻率與當前媒體的采樣頻率不同,請選擇要使用的輸出采樣率。小于 8000 的值將使用默認頻率",
|
||||
"savePlayQueue_description": "當應用程序關閉時保存播放隊列,並在應用程序打開時恢複它",
|
||||
"scrobble": "記錄播放信息(Scrobble)",
|
||||
"scrobble_description": "在妳的社交媒體中記錄播放信息",
|
||||
"showSkipButton": "顯示跳過按鈕",
|
||||
"showSkipButton_description": "在播放條上顯示/隱藏跳過按鈕",
|
||||
"sidebarPlaylistList": "側邊欄歌單列表",
|
||||
"sidebarCollapsedNavigation": "側邊欄(已折疊)導航",
|
||||
"sidebarCollapsedNavigation_description": "在折疊的側邊欄中顯示或隱藏導航",
|
||||
"sidebarConfiguration": "側邊欄設定",
|
||||
"sidebarConfiguration_description": "選擇側邊欄包含的項目與順序",
|
||||
"sidebarPlaylistList_description": "顯示或隱藏側邊欄歌單列表",
|
||||
"sidePlayQueueStyle": "側邊播放列表樣式",
|
||||
"sidePlayQueueStyle_description": "設置側邊播放列表樣式",
|
||||
"sidePlayQueueStyle_optionAttached": "吸附",
|
||||
"sidePlayQueueStyle_optionDetached": "不吸附",
|
||||
"skipDuration": "跳過時長",
|
||||
"skipDuration_description": "設置每次按下跳過按鈕將會跳過的時長",
|
||||
"skipPlaylistPage": "跳過歌單頁面",
|
||||
"skipPlaylistPage_description": "打開歌單時,直接查看歌曲列表而非查看默認頁面",
|
||||
"theme": "主題",
|
||||
"themeDark": "主題(深色)",
|
||||
"useSystemTheme_description": "使用系統定義的淺色或深色主題",
|
||||
"useSystemTheme": "跟隨系統",
|
||||
"volumeWheelStep": "音量滾輪步長",
|
||||
"volumeWheelStep_description": "在音量滑塊上滾動鼠標滾輪時要更改的音量大小",
|
||||
"windowBarStyle": "窗口頂欄風格",
|
||||
"windowBarStyle_description": "選擇窗口頂欄的風格",
|
||||
"zoom": "縮放率",
|
||||
"zoom_description": "設置應用程序的縮放率",
|
||||
"hotkey_volumeUp": "音量增高",
|
||||
"sampleRate": "采樣率",
|
||||
"showSkipButtons_description": "在播放條顯示/隱藏播放按鈕",
|
||||
"playbackStyle": "播放風格",
|
||||
"exitToTray_description": "退出應用時最小化到系統托盤而非關閉",
|
||||
"floatingQueueArea": "顯示浮動隊列懸停區域",
|
||||
"followLyric_description": "滾動歌詞到當前播放位置",
|
||||
"font": "字體",
|
||||
"globalMediaHotkeys_description": "啓用或禁用系統媒體快捷鍵以控制播放",
|
||||
"hotkey_browserBack": "浏覽器後退",
|
||||
"hotkey_favoriteCurrentSong": "收藏 $t(common.currentSong)",
|
||||
"hotkey_playbackStop": "停止",
|
||||
"hotkey_rate0": "清除評分",
|
||||
"mpvExecutablePath_description": "設置 mpv 二進制文件的路徑。如果留空,則使用默認路徑",
|
||||
"mpvExtraParameters": "mpv 參數",
|
||||
"playbackStyle_description": "選擇播放器的播放風格",
|
||||
"playButtonBehavior_optionPlay": "$t(player.play)",
|
||||
"remotePassword": "遠程控制服務器密碼",
|
||||
"remotePassword_description": "設置遠程控制服務器的密碼。這些憑據默認以不安全的方式傳輸,因此您應該使用壹個您不在意的唯壹密碼",
|
||||
"remotePort_description": "設置遠程服務器端口",
|
||||
"remoteUsername_description": "設置遠程控制服務器的用戶名。如果用戶名和密碼都爲空,則身份驗證將被禁用",
|
||||
"replayGainClipping_description": "自動降低增益以防止{{ReplayGain}}造成削波",
|
||||
"showSkipButtons": "顯示跳過按鈕",
|
||||
"themeDark_description": "應用將使用深色主題",
|
||||
"clearQueryCache_description": "feishin的“軟清除”。這將會刷新播放列表、元數據並重置保存的歌詞。會保留設置、服務器憑據和緩存圖像",
|
||||
"clearCache": "清除浏覽器緩存",
|
||||
"clearCache_description": "feishin的“硬清除”。除了清除feishin的緩存,清空浏覽器緩存(保存的圖像和其他資源)。會保留服務器憑據和設置",
|
||||
"clearQueryCache": "清除feishin緩存",
|
||||
"buttonSize": "播放器欄按鈕大小",
|
||||
"buttonSize_description": "播放器欄按鈕大小"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
"general": {
|
||||
"displayType": "顯示風格",
|
||||
"gap": "$t(common.gap)",
|
||||
"size": "$t(common.size)",
|
||||
"tableColumns": "列",
|
||||
"autoFitColumns": "列寬自適應"
|
||||
},
|
||||
"label": {
|
||||
"actions": "$t(common.action_other)",
|
||||
"album": "$t(entity.album_one)",
|
||||
"albumArtist": "$t(entity.albumArtist_one)",
|
||||
"artist": "$t(entity.artist_one)",
|
||||
"bpm": "$t(common.bpm)",
|
||||
"biography": "$t(common.biography)",
|
||||
"bitrate": "$t(common.bitrate)",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"dateAdded": "添加日期",
|
||||
"discNumber": "碟片編號",
|
||||
"duration": "$t(common.duration)",
|
||||
"favorite": "$t(common.favorite)",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"lastPlayed": "最後播放",
|
||||
"note": "$t(common.note)",
|
||||
"owner": "$t(common.owner)",
|
||||
"path": "$t(common.path)",
|
||||
"playCount": "播放數",
|
||||
"releaseDate": "發布日期",
|
||||
"rowIndex": "行號",
|
||||
"size": "$t(common.size)",
|
||||
"title": "$t(common.title)",
|
||||
"titleCombined": "$t(common.title)(合並)",
|
||||
"trackNumber": "音軌編號",
|
||||
"year": "$t(common.year)",
|
||||
"rating": "$t(common.rating)"
|
||||
},
|
||||
"view": {
|
||||
"card": "卡片",
|
||||
"poster": "海報",
|
||||
"table": "表格"
|
||||
}
|
||||
},
|
||||
"column": {
|
||||
"album": "專輯",
|
||||
"albumArtist": "專輯藝術家",
|
||||
"albumCount": "$t(entity.album_other)",
|
||||
"artist": "$t(entity.artist_one)",
|
||||
"biography": "簡介",
|
||||
"bitrate": "比特率",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"comment": "評論",
|
||||
"dateAdded": "添加日期",
|
||||
"discNumber": "盤",
|
||||
"favorite": "收藏",
|
||||
"lastPlayed": "最後播放",
|
||||
"path": "路徑",
|
||||
"playCount": "播放次數",
|
||||
"rating": "評價",
|
||||
"releaseDate": "發布日期",
|
||||
"releaseYear": "年份",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"bpm": "bpm",
|
||||
"songCount": "$t(entity.track_other)",
|
||||
"title": "標題",
|
||||
"trackNumber": "音軌編號"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"addToFavorites": "添加到$t(entity.favorite_other)",
|
||||
"clearQueue": "清空播放隊列",
|
||||
"createPlaylist": "創建$t(entity.playlist_one)",
|
||||
"deletePlaylist": "刪除$t(entity.playlist_one)",
|
||||
"addToPlaylist": "添加到$t(entity.playlist_one)",
|
||||
"deselectAll": "取消全選",
|
||||
"editPlaylist": "編輯 $t(entity.playlist_one)",
|
||||
"goToPage": "轉到頁面",
|
||||
"moveToBottom": "跳至底部",
|
||||
"moveToTop": "跳至頂部",
|
||||
"refresh": "$t(common.refresh)",
|
||||
"removeFromFavorites": "從$t(entity.favorite_other)移除",
|
||||
"removeFromPlaylist": "從$t(entity.playlist_one)移除",
|
||||
"removeFromQueue": "從播放隊列中移除",
|
||||
"setRating": "評分",
|
||||
"toggleSmartPlaylistEditor": "切換$t(entity.smartPlaylist)編輯器",
|
||||
"viewPlaylists": "查看$t(entity.playlist_other)"
|
||||
},
|
||||
"entity": {
|
||||
"album_other": "專輯",
|
||||
"albumArtist_other": "專輯藝術家",
|
||||
"albumArtistCount_other": "{{count}} 位專輯藝術家",
|
||||
"artist_other": "藝術家",
|
||||
"artistWithCount_other": "{{count}} 位藝術家",
|
||||
"favorite_other": "收藏",
|
||||
"folder_other": "文件夾",
|
||||
"folderWithCount_other": "{{count}} 個文件夾",
|
||||
"genre_other": "流派",
|
||||
"genreWithCount_other": "{{count}} 種流派",
|
||||
"playlist_other": "播放列表",
|
||||
"playlistWithCount_other": "{{count}} 個播放列表",
|
||||
"smartPlaylist": "智能$t(entity.playlist_one)",
|
||||
"track_other": "樂曲",
|
||||
"trackWithCount_other": "{{count}} 首樂曲",
|
||||
"albumWithCount_other": "{{count}} 張專輯"
|
||||
},
|
||||
"filter": {
|
||||
"albumCount": "$t(entity.album_other)數",
|
||||
"artist": "$t(entity.artist_one)",
|
||||
"biography": "個人簡介",
|
||||
"bitrate": "比特率",
|
||||
"bpm": "bpm",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"comment": "評論",
|
||||
"communityRating": "社區評分",
|
||||
"criticRating": "評論家評分",
|
||||
"dateAdded": "已添加日期",
|
||||
"disc": "盤",
|
||||
"duration": "時長",
|
||||
"id": "id",
|
||||
"fromYear": "從年份",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"isCompilation": "爲合輯",
|
||||
"isFavorited": "已收藏",
|
||||
"isPublic": "已公開",
|
||||
"isRated": "已評分",
|
||||
"name": "名稱",
|
||||
"note": "注釋",
|
||||
"isRecentlyPlayed": "最近播放過",
|
||||
"lastPlayed": "上次播放過",
|
||||
"mostPlayed": "播放最多",
|
||||
"owner": "$t(common.owner)",
|
||||
"path": "路徑",
|
||||
"playCount": "播放次數",
|
||||
"random": "隨機",
|
||||
"rating": "評分",
|
||||
"recentlyPlayed": "最近播放",
|
||||
"recentlyUpdated": "最近更新",
|
||||
"releaseDate": "發布日期",
|
||||
"songCount": "曲目數",
|
||||
"album": "$t(entity.album_one)",
|
||||
"albumArtist": "$t(entity.albumArtist_one)",
|
||||
"favorited": "已收藏",
|
||||
"recentlyAdded": "最近添加",
|
||||
"releaseYear": "發布年份",
|
||||
"search": "搜索",
|
||||
"title": "標題",
|
||||
"toYear": "從年份",
|
||||
"trackNumber": "曲目"
|
||||
},
|
||||
"form": {
|
||||
"addServer": {
|
||||
"input_legacyAuthentication": "啓用舊版認證方式",
|
||||
"input_name": "服務器名",
|
||||
"input_password": "密碼",
|
||||
"input_savePassword": "保存密碼",
|
||||
"input_url": "url",
|
||||
"input_username": "用戶名",
|
||||
"success": "服務器添加成功",
|
||||
"title": "添加服務器",
|
||||
"error_savePassword": "保存密碼時出現錯誤",
|
||||
"ignoreCors": "忽略 cors $t(common.restartRequired)",
|
||||
"ignoreSsl": "忽略 ssl $t(common.restartRequired)"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"input_playlists": "$t(entity.playlist_other)",
|
||||
"input_skipDuplicates": "跳過重複",
|
||||
"success": "添加 $t(entity.trackWithCount, {\"count\": {{message}} }) 到 $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||
"title": "添加到$t(entity.playlist_one)"
|
||||
},
|
||||
"createPlaylist": {
|
||||
"input_description": "$t(common.description)",
|
||||
"input_name": "$t(common.name)",
|
||||
"input_owner": "$t(common.owner)",
|
||||
"input_public": "公開",
|
||||
"success": "已成功創建 $t(entity.playlist_one)",
|
||||
"title": "創建$t(entity.playlist_one)"
|
||||
},
|
||||
"lyricSearch": {
|
||||
"input_name": "$t(common.name)",
|
||||
"title": "搜索歌詞",
|
||||
"input_artist": "$t(entity.artist_one)"
|
||||
},
|
||||
"queryEditor": {
|
||||
"input_optionMatchAll": "匹配全部",
|
||||
"input_optionMatchAny": "匹配任何"
|
||||
},
|
||||
"updateServer": {
|
||||
"success": "服務器已更新成功",
|
||||
"title": "更新服務器"
|
||||
},
|
||||
"deletePlaylist": {
|
||||
"input_confirm": "輸入$t(entity.playlist_one)的名稱進行確認",
|
||||
"title": "刪除$t(entity.playlist_one)",
|
||||
"success": "$t(entity.playlist_one)已成功刪除"
|
||||
},
|
||||
"editPlaylist": {
|
||||
"title": "編輯$t(entity.playlist_one)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import console from 'console';
|
||||
import { ipcMain } from 'electron';
|
||||
import { getMpvInstance } from '../../../main';
|
||||
import { app, ipcMain } from 'electron';
|
||||
import uniq from 'lodash/uniq';
|
||||
import MpvAPI from 'node-mpv';
|
||||
import { getMainWindow, sendToastToRenderer } from '../../../main';
|
||||
import { PlayerData } from '/@/renderer/store';
|
||||
import { createLog, isWindows } from '../../../utils';
|
||||
import { store } from '../settings';
|
||||
|
||||
declare module 'node-mpv';
|
||||
|
||||
@@ -13,6 +17,208 @@ declare module 'node-mpv';
|
||||
// });
|
||||
// }
|
||||
|
||||
let mpvInstance: MpvAPI | null = null;
|
||||
|
||||
const NodeMpvErrorCode = {
|
||||
0: 'Unable to load file or stream',
|
||||
1: 'Invalid argument',
|
||||
2: 'Binary not found',
|
||||
3: 'IPC command invalid',
|
||||
4: 'Unable to bind IPC socket',
|
||||
5: 'Connection timeout',
|
||||
6: 'MPV is already running',
|
||||
7: 'Could not send IPC message',
|
||||
8: 'MPV is not running',
|
||||
9: 'Unsupported protocol',
|
||||
};
|
||||
|
||||
type NodeMpvError = {
|
||||
errcode: number;
|
||||
method: string;
|
||||
stackTrace: string;
|
||||
verbose: string;
|
||||
};
|
||||
|
||||
const mpvLog = (
|
||||
data: { action: string; toast?: 'info' | 'success' | 'warning' },
|
||||
err?: NodeMpvError,
|
||||
) => {
|
||||
const { action, toast } = data;
|
||||
|
||||
if (err) {
|
||||
const message = `[AUDIO PLAYER] ${action} - mpv errorcode ${err.errcode} - ${
|
||||
NodeMpvErrorCode[err.errcode as keyof typeof NodeMpvErrorCode]
|
||||
}`;
|
||||
|
||||
sendToastToRenderer({ message, type: 'error' });
|
||||
createLog({ message, type: 'error' });
|
||||
}
|
||||
|
||||
const message = `[AUDIO PLAYER] ${action}`;
|
||||
createLog({ message, type: 'error' });
|
||||
if (toast) {
|
||||
sendToastToRenderer({ message, type: toast });
|
||||
}
|
||||
};
|
||||
|
||||
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
|
||||
const isDevelopment = process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';
|
||||
|
||||
const prefetchPlaylistParams = [
|
||||
'--prefetch-playlist=no',
|
||||
'--prefetch-playlist=yes',
|
||||
'--prefetch-playlist',
|
||||
];
|
||||
|
||||
const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
|
||||
const parameters = ['--idle=yes', '--no-config', '--load-scripts=no'];
|
||||
|
||||
if (!extraParameters?.some((param) => prefetchPlaylistParams.includes(param))) {
|
||||
parameters.push('--prefetch-playlist=yes');
|
||||
}
|
||||
|
||||
return parameters;
|
||||
};
|
||||
|
||||
const createMpv = async (data: {
|
||||
binaryPath?: string;
|
||||
extraParameters?: string[];
|
||||
properties?: Record<string, any>;
|
||||
}): Promise<MpvAPI> => {
|
||||
const { extraParameters, properties, binaryPath } = data;
|
||||
|
||||
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
|
||||
|
||||
const extra = isDevelopment ? '-dev' : '';
|
||||
|
||||
const mpv = new MpvAPI(
|
||||
{
|
||||
audio_only: true,
|
||||
auto_restart: false,
|
||||
binary: binaryPath || MPV_BINARY_PATH || undefined,
|
||||
socket: isWindows() ? `\\\\.\\pipe\\mpvserver${extra}` : `/tmp/node-mpv${extra}.sock`,
|
||||
time_update: 1,
|
||||
},
|
||||
params,
|
||||
);
|
||||
|
||||
try {
|
||||
await mpv.start();
|
||||
} catch (error: any) {
|
||||
console.log('mpv failed to start', error);
|
||||
} finally {
|
||||
await mpv.setMultipleProperties(properties || {});
|
||||
}
|
||||
|
||||
mpv.on('status', (status) => {
|
||||
if (status.property === 'playlist-pos') {
|
||||
if (status.value === -1) {
|
||||
mpv?.stop();
|
||||
}
|
||||
|
||||
if (status.value !== 0) {
|
||||
getMainWindow()?.webContents.send('renderer-player-auto-next');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is playing
|
||||
mpv.on('resumed', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-play');
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is stopped
|
||||
mpv.on('stopped', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-stop');
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is paused
|
||||
mpv.on('paused', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-pause');
|
||||
});
|
||||
|
||||
// Event output every interval set by time_update, used to update the current time
|
||||
mpv.on('timeposition', (time: number) => {
|
||||
getMainWindow()?.webContents.send('renderer-player-current-time', time);
|
||||
});
|
||||
|
||||
return mpv;
|
||||
};
|
||||
|
||||
export const getMpvInstance = () => {
|
||||
return mpvInstance;
|
||||
};
|
||||
|
||||
const setAudioPlayerFallback = (isError: boolean) => {
|
||||
getMainWindow()?.webContents.send('renderer-player-fallback', isError);
|
||||
};
|
||||
|
||||
ipcMain.on('player-set-properties', async (_event, data: Record<string, any>) => {
|
||||
mpvLog({ action: `Setting properties: ${JSON.stringify(data)}` });
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (data.length === 1) {
|
||||
getMpvInstance()?.setProperty(Object.keys(data)[0], Object.values(data)[0]);
|
||||
} else {
|
||||
getMpvInstance()?.setMultipleProperties(data);
|
||||
}
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: `Failed to set properties: ${JSON.stringify(data)}` }, err);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'player-restart',
|
||||
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
try {
|
||||
mpvLog({
|
||||
action: `Attempting to initialize mpv with parameters: ${JSON.stringify(data)}`,
|
||||
});
|
||||
|
||||
// Clean up previous mpv instance
|
||||
getMpvInstance()?.stop();
|
||||
getMpvInstance()?.quit();
|
||||
mpvInstance = null;
|
||||
|
||||
mpvInstance = await createMpv(data);
|
||||
mpvLog({ action: 'Restarted mpv', toast: 'success' });
|
||||
setAudioPlayerFallback(false);
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: 'Failed to restart mpv, falling back to web player' }, err);
|
||||
setAudioPlayerFallback(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'player-initialize',
|
||||
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
try {
|
||||
mpvLog({
|
||||
action: `Attempting to initialize mpv with parameters: ${JSON.stringify(data)}`,
|
||||
});
|
||||
mpvInstance = await createMpv(data);
|
||||
setAudioPlayerFallback(false);
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: 'Failed to initialize mpv, falling back to web player' }, err);
|
||||
setAudioPlayerFallback(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.on('player-quit', async () => {
|
||||
try {
|
||||
getMpvInstance()?.stop();
|
||||
getMpvInstance()?.quit();
|
||||
mpvInstance = null;
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: 'Failed to quit mpv' }, err);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('player-is-running', async () => {
|
||||
return getMpvInstance()?.isRunning();
|
||||
});
|
||||
@@ -23,99 +229,93 @@ ipcMain.handle('player-clean-up', async () => {
|
||||
});
|
||||
|
||||
ipcMain.on('player-start', async () => {
|
||||
await getMpvInstance()
|
||||
?.play()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to play', err);
|
||||
});
|
||||
try {
|
||||
await getMpvInstance()?.play();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: 'Failed to start mpv playback' }, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Starts the player
|
||||
ipcMain.on('player-play', async () => {
|
||||
await getMpvInstance()
|
||||
?.play()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to play', err);
|
||||
});
|
||||
try {
|
||||
await getMpvInstance()?.play();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: 'Failed to start mpv playback' }, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Pauses the player
|
||||
ipcMain.on('player-pause', async () => {
|
||||
await getMpvInstance()
|
||||
?.pause()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to pause', err);
|
||||
});
|
||||
try {
|
||||
await getMpvInstance()?.pause();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: 'Failed to pause mpv playback' }, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Stops the player
|
||||
ipcMain.on('player-stop', async () => {
|
||||
await getMpvInstance()
|
||||
?.stop()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to stop', err);
|
||||
});
|
||||
try {
|
||||
await getMpvInstance()?.stop();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: 'Failed to stop mpv playback' }, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Goes to the next track in the playlist
|
||||
ipcMain.on('player-next', async () => {
|
||||
await getMpvInstance()
|
||||
?.next()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to go to next', err);
|
||||
});
|
||||
try {
|
||||
await getMpvInstance()?.next();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: 'Failed to go to next track' }, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Goes to the previous track in the playlist
|
||||
ipcMain.on('player-previous', async () => {
|
||||
await getMpvInstance()
|
||||
?.prev()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to go to previous', err);
|
||||
});
|
||||
try {
|
||||
await getMpvInstance()?.prev();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: 'Failed to go to previous track' }, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Seeks forward or backward by the given amount of seconds
|
||||
ipcMain.on('player-seek', async (_event, time: number) => {
|
||||
await getMpvInstance()
|
||||
?.seek(time)
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to seek', err);
|
||||
});
|
||||
try {
|
||||
await getMpvInstance()?.seek(time);
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: `Failed to seek by ${time} seconds` }, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Seeks to the given time in seconds
|
||||
ipcMain.on('player-seek-to', async (_event, time: number) => {
|
||||
await getMpvInstance()
|
||||
?.goToPosition(time)
|
||||
.catch((err) => {
|
||||
console.log(`MPV failed to seek to ${time}`, err);
|
||||
});
|
||||
try {
|
||||
await getMpvInstance()?.goToPosition(time);
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: `Failed to seek to ${time} seconds` }, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
|
||||
ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean) => {
|
||||
if (!data.queue.current && !data.queue.next) {
|
||||
await getMpvInstance()
|
||||
?.clearPlaylist()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to clear playlist', err);
|
||||
});
|
||||
|
||||
await getMpvInstance()
|
||||
?.pause()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to pause', err);
|
||||
});
|
||||
return;
|
||||
try {
|
||||
await getMpvInstance()?.clearPlaylist();
|
||||
await getMpvInstance()?.pause();
|
||||
return;
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: `Failed to clear play queue` }, err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (data.queue.current) {
|
||||
await getMpvInstance()
|
||||
?.load(data.queue.current.streamUrl, 'replace')
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to load song', err);
|
||||
.catch(() => {
|
||||
getMpvInstance()?.play();
|
||||
});
|
||||
|
||||
@@ -123,41 +323,36 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
|
||||
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
if (pause) {
|
||||
getMpvInstance()?.pause();
|
||||
if (pause) {
|
||||
await getMpvInstance()?.pause();
|
||||
} else if (pause === false) {
|
||||
// Only force play if pause is explicitly false
|
||||
await getMpvInstance()?.play();
|
||||
}
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: `Failed to set play queue` }, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Replaces the queue in position 1 to the given data
|
||||
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
|
||||
const size = await getMpvInstance()
|
||||
?.getPlaylistSize()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to get playlist size', err);
|
||||
});
|
||||
try {
|
||||
const size = await getMpvInstance()?.getPlaylistSize();
|
||||
|
||||
if (!size) {
|
||||
return;
|
||||
}
|
||||
if (!size) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (size > 1) {
|
||||
await getMpvInstance()
|
||||
?.playlistRemove(1)
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to remove song from playlist', err);
|
||||
});
|
||||
}
|
||||
if (size > 1) {
|
||||
await getMpvInstance()?.playlistRemove(1);
|
||||
}
|
||||
|
||||
if (data.queue.next) {
|
||||
await getMpvInstance()
|
||||
?.load(data.queue.next.streamUrl, 'append')
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to load next song', err);
|
||||
});
|
||||
if (data.queue.next) {
|
||||
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||
}
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: `Failed to set play queue` }, err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -166,40 +361,57 @@ ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
|
||||
// Always keep the current song as position 0 in the mpv queue
|
||||
// This allows us to easily set update the next song in the queue without
|
||||
// disturbing the currently playing song
|
||||
await getMpvInstance()
|
||||
?.playlistRemove(0)
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to remove song from playlist', err);
|
||||
getMpvInstance()?.pause();
|
||||
});
|
||||
|
||||
if (data.queue.next) {
|
||||
try {
|
||||
await getMpvInstance()
|
||||
?.load(data.queue.next.streamUrl, 'append')
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to load next song', err);
|
||||
?.playlistRemove(0)
|
||||
.catch(() => {
|
||||
getMpvInstance()?.pause();
|
||||
});
|
||||
|
||||
if (data.queue.next) {
|
||||
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||
}
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: `Failed to load next song` }, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Sets the volume to the given value (0-100)
|
||||
ipcMain.on('player-volume', async (_event, value: number) => {
|
||||
await getMpvInstance()
|
||||
?.volume(value)
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to set volume', err);
|
||||
});
|
||||
try {
|
||||
if (!value || value < 0 || value > 100) {
|
||||
return;
|
||||
}
|
||||
|
||||
await getMpvInstance()?.volume(value);
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: `Failed to set volume to ${value}` }, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Toggles the mute status
|
||||
ipcMain.on('player-mute', async (_event, mute: boolean) => {
|
||||
await getMpvInstance()
|
||||
?.mute(mute)
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to toggle mute', err);
|
||||
});
|
||||
try {
|
||||
await getMpvInstance()?.mute(mute);
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: `Failed to set mute status` }, err);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
|
||||
return getMpvInstance()?.getTimePosition();
|
||||
try {
|
||||
return getMpvInstance()?.getTimePosition();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
mpvLog({ action: `Failed to get current time` }, err);
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
getMpvInstance()?.stop();
|
||||
getMpvInstance()?.quit();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
getMpvInstance()?.quit();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
/* eslint-disable promise/always-return */
|
||||
import { BrowserWindow, globalShortcut } from 'electron';
|
||||
import { BrowserWindow, globalShortcut, systemPreferences } from 'electron';
|
||||
import { isMacOS } from '../../../utils';
|
||||
import { store } from '../settings';
|
||||
|
||||
export const enableMediaKeys = (window: BrowserWindow | null) => {
|
||||
if (isMacOS()) {
|
||||
const shouldPrompt = store.get('should_prompt_accessibility', true) as boolean;
|
||||
const trusted = systemPreferences.isTrustedAccessibilityClient(shouldPrompt);
|
||||
|
||||
if (shouldPrompt) {
|
||||
store.set('should_prompt_accessibility', false);
|
||||
}
|
||||
|
||||
if (!trusted) {
|
||||
window?.webContents.send('toast-from-main', {
|
||||
message:
|
||||
'Feishin is not a trusted accessibility client. Media keys will not work until this setting is changed',
|
||||
type: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
globalShortcut.register('MediaStop', () => {
|
||||
window?.webContents.send('renderer-player-stop');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ipcMain, safeStorage } from 'electron';
|
||||
import { ipcMain, nativeTheme, safeStorage } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import type { TitleTheme } from '/@/renderer/types';
|
||||
|
||||
export const store = new Store();
|
||||
|
||||
@@ -48,3 +49,8 @@ ipcMain.handle('password-set', (_event, password: string, server: string) => {
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
ipcMain.on('theme-set', (_event, theme: TitleTheme) => {
|
||||
store.set('theme', theme);
|
||||
nativeTheme.themeSource = theme;
|
||||
});
|
||||
|
||||
@@ -70,6 +70,8 @@ mprisPlayer.on('volume', (vol: number) => {
|
||||
getMainWindow()?.webContents.send('request-volume', {
|
||||
volume,
|
||||
});
|
||||
|
||||
mprisPlayer.volume = volume / 100;
|
||||
});
|
||||
|
||||
mprisPlayer.on('shuffle', (event: boolean) => {
|
||||
@@ -154,12 +156,19 @@ ipcMain.on('update-song', (_event, args: SongUpdate) => {
|
||||
? song.albumArtists.map((artist) => artist.name)
|
||||
: null,
|
||||
'xesam:artist': song.artists?.length ? song.artists.map((artist) => artist.name) : null,
|
||||
'xesam:audioBpm': song.bpm,
|
||||
// Comment is a `list of strings` type
|
||||
'xesam:comment': song.comment ? [song.comment] : null,
|
||||
'xesam:contentCreated': song.releaseDate,
|
||||
'xesam:discNumber': song.discNumber ? song.discNumber : null,
|
||||
'xesam:genre': song.genres?.length ? song.genres.map((genre: any) => genre.name) : null,
|
||||
'xesam:lastUsed': song.lastPlayedAt,
|
||||
'xesam:title': song.name || null,
|
||||
'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,
|
||||
'xesam:useCount':
|
||||
song.playCount !== null && song.playCount !== undefined ? song.playCount : null,
|
||||
// User ratings are only on Navidrome/Subsonic and are on a scale of 1-5
|
||||
'xesam:userRating': song.userRating ? song.userRating / 5 : null,
|
||||
};
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
|
||||
+70
-152
@@ -20,27 +20,35 @@ import {
|
||||
Tray,
|
||||
Menu,
|
||||
nativeImage,
|
||||
nativeTheme,
|
||||
BrowserWindowConstructorOptions,
|
||||
protocol,
|
||||
net,
|
||||
} from 'electron';
|
||||
import electronLocalShortcut from 'electron-localshortcut';
|
||||
import log from 'electron-log';
|
||||
import log from 'electron-log/main';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import uniq from 'lodash/uniq';
|
||||
import MpvAPI from 'node-mpv';
|
||||
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
|
||||
import { store } from './features/core/settings/index';
|
||||
import MenuBuilder from './menu';
|
||||
import { hotkeyToElectronAccelerator, isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils';
|
||||
import {
|
||||
hotkeyToElectronAccelerator,
|
||||
isLinux,
|
||||
isMacOS,
|
||||
isWindows,
|
||||
resolveHtmlPath,
|
||||
createLog,
|
||||
autoUpdaterLogInterface,
|
||||
} from './utils';
|
||||
import './features';
|
||||
import type { TitleTheme } from '/@/renderer/types';
|
||||
|
||||
declare module 'node-mpv';
|
||||
|
||||
export default class AppUpdater {
|
||||
constructor() {
|
||||
log.transports.file.level = 'info';
|
||||
autoUpdater.logger = log;
|
||||
autoUpdater.logger = autoUpdaterLogInterface;
|
||||
autoUpdater.checkForUpdatesAndNotify();
|
||||
}
|
||||
}
|
||||
@@ -55,6 +63,12 @@ if (store.get('ignore_ssl')) {
|
||||
app.commandLine.appendSwitch('ignore-certificate-errors');
|
||||
}
|
||||
|
||||
// From https://github.com/tutao/tutanota/commit/92c6ed27625fcf367f0fbcc755d83d7ff8fde94b
|
||||
if (isLinux() && !process.argv.some((a) => a.startsWith('--password-store='))) {
|
||||
const paswordStore = store.get('password_store', 'gnome-libsecret') as string;
|
||||
app.commandLine.appendSwitch('password-store', paswordStore);
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let exitFromTray = false;
|
||||
@@ -96,6 +110,19 @@ export const getMainWindow = () => {
|
||||
return mainWindow;
|
||||
};
|
||||
|
||||
export const sendToastToRenderer = ({
|
||||
message,
|
||||
type,
|
||||
}: {
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
}) => {
|
||||
getMainWindow()?.webContents.send('toast-from-main', {
|
||||
message,
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
const createWinThumbarButtons = () => {
|
||||
if (isWindows()) {
|
||||
getMainWindow()?.setThumbarButtons([
|
||||
@@ -179,7 +206,7 @@ const createTray = () => {
|
||||
tray.setContextMenu(contextMenu);
|
||||
};
|
||||
|
||||
const createWindow = async () => {
|
||||
const createWindow = async (first = true) => {
|
||||
if (isDevelopment) {
|
||||
await installExtensions();
|
||||
}
|
||||
@@ -194,8 +221,8 @@ const createWindow = async () => {
|
||||
},
|
||||
macOS: {
|
||||
autoHideMenuBar: true,
|
||||
frame: false,
|
||||
titleBarStyle: 'hidden',
|
||||
frame: true,
|
||||
titleBarStyle: 'default',
|
||||
trafficLightPosition: { x: 10, y: 10 },
|
||||
},
|
||||
windows: {
|
||||
@@ -258,6 +285,10 @@ const createWindow = async () => {
|
||||
app.exit();
|
||||
});
|
||||
|
||||
ipcMain.handle('window-clear-cache', async () => {
|
||||
return mainWindow?.webContents.session.clearCache();
|
||||
});
|
||||
|
||||
ipcMain.on('app-restart', () => {
|
||||
// Fix for .AppImage
|
||||
if (process.env.APPIMAGE) {
|
||||
@@ -304,34 +335,36 @@ const createWindow = async () => {
|
||||
}
|
||||
|
||||
const queue = JSON.parse(data.toString());
|
||||
getMainWindow()?.webContents.send('renderer-player-restore-queue', queue);
|
||||
getMainWindow()?.webContents.send('renderer-restore-queue', queue);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
|
||||
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
|
||||
|
||||
if (globalMediaKeysEnabled !== false) {
|
||||
if (globalMediaKeysEnabled) {
|
||||
enableMediaKeys(mainWindow);
|
||||
}
|
||||
|
||||
mainWindow.loadURL(resolveHtmlPath('index.html'));
|
||||
|
||||
const startWindowMinimized = store.get('window_start_minimized', false) as boolean;
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
if (!mainWindow) {
|
||||
throw new Error('"mainWindow" is not defined');
|
||||
}
|
||||
if (process.env.START_MINIMIZED) {
|
||||
mainWindow.minimize();
|
||||
} else {
|
||||
|
||||
if (!first || !startWindowMinimized) {
|
||||
mainWindow.show();
|
||||
createWinThumbarButtons();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
ipcMain.removeHandler('window-clear-cache');
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
@@ -350,7 +383,7 @@ const createWindow = async () => {
|
||||
event.preventDefault();
|
||||
saved = true;
|
||||
|
||||
getMainWindow()?.webContents.send('renderer-player-save-queue');
|
||||
getMainWindow()?.webContents.send('renderer-save-queue');
|
||||
|
||||
ipcMain.once('player-save-queue', async (_event, data: Record<string, any>) => {
|
||||
const queueLocation = join(app.getPath('userData'), 'queue');
|
||||
@@ -414,140 +447,13 @@ const createWindow = async () => {
|
||||
// eslint-disable-next-line
|
||||
new AppUpdater();
|
||||
}
|
||||
|
||||
const theme = store.get('theme') as TitleTheme | undefined;
|
||||
nativeTheme.themeSource = theme || 'dark';
|
||||
};
|
||||
|
||||
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
|
||||
|
||||
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
|
||||
|
||||
const prefetchPlaylistParams = [
|
||||
'--prefetch-playlist=no',
|
||||
'--prefetch-playlist=yes',
|
||||
'--prefetch-playlist',
|
||||
];
|
||||
|
||||
const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
|
||||
const parameters = ['--idle=yes', '--no-config', '--load-scripts=no'];
|
||||
|
||||
if (!extraParameters?.some((param) => prefetchPlaylistParams.includes(param))) {
|
||||
parameters.push('--prefetch-playlist=yes');
|
||||
}
|
||||
|
||||
return parameters;
|
||||
};
|
||||
|
||||
let mpvInstance: MpvAPI | null = null;
|
||||
|
||||
const createMpv = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
const { extraParameters, properties } = data;
|
||||
|
||||
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.on('status', (status, ...rest) => {
|
||||
console.log('MPV Event: status', status.property, status.value, rest);
|
||||
if (status.property === 'playlist-pos') {
|
||||
if (status.value === -1) {
|
||||
mpv?.stop();
|
||||
}
|
||||
|
||||
if (status.value !== 0) {
|
||||
getMainWindow()?.webContents.send('renderer-player-auto-next');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is playing
|
||||
mpv.on('resumed', () => {
|
||||
console.log('MPV Event: resumed');
|
||||
getMainWindow()?.webContents.send('renderer-player-play');
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is stopped
|
||||
mpv.on('stopped', () => {
|
||||
console.log('MPV Event: stopped');
|
||||
getMainWindow()?.webContents.send('renderer-player-stop');
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is paused
|
||||
mpv.on('paused', () => {
|
||||
console.log('MPV Event: paused');
|
||||
getMainWindow()?.webContents.send('renderer-player-pause');
|
||||
});
|
||||
|
||||
// Event output every interval set by time_update, used to update the current time
|
||||
mpv.on('timeposition', (time: number) => {
|
||||
getMainWindow()?.webContents.send('renderer-player-current-time', time);
|
||||
});
|
||||
|
||||
mpv.on('quit', () => {
|
||||
console.log('MPV Event: quit');
|
||||
});
|
||||
|
||||
return mpv;
|
||||
};
|
||||
|
||||
export const getMpvInstance = () => {
|
||||
return mpvInstance;
|
||||
};
|
||||
|
||||
ipcMain.on('player-set-properties', async (_event, data: Record<string, any>) => {
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.length === 1) {
|
||||
getMpvInstance()?.setProperty(Object.keys(data)[0], Object.values(data)[0]);
|
||||
} else {
|
||||
getMpvInstance()?.setMultipleProperties(data);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on(
|
||||
'player-restart',
|
||||
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
mpvInstance?.quit();
|
||||
mpvInstance = createMpv(data);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
'player-initialize',
|
||||
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
console.log('Initializing MPV with data: ', data);
|
||||
mpvInstance = createMpv(data);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.on('player-quit', async () => {
|
||||
mpvInstance?.stop();
|
||||
mpvInstance?.quit();
|
||||
mpvInstance = null;
|
||||
});
|
||||
|
||||
// Must duplicate with the one in renderer process settings.store.ts
|
||||
enum BindingActions {
|
||||
GLOBAL_SEARCH = 'globalSearch',
|
||||
@@ -622,7 +528,7 @@ ipcMain.on(
|
||||
}
|
||||
}
|
||||
|
||||
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
|
||||
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
|
||||
|
||||
if (globalMediaKeysEnabled) {
|
||||
enableMediaKeys(mainWindow);
|
||||
@@ -630,17 +536,25 @@ ipcMain.on(
|
||||
},
|
||||
);
|
||||
|
||||
app.on('before-quit', () => {
|
||||
getMpvInstance()?.stop();
|
||||
getMpvInstance()?.quit();
|
||||
});
|
||||
ipcMain.on(
|
||||
'logger',
|
||||
(
|
||||
_event,
|
||||
data: {
|
||||
message: string;
|
||||
type: 'debug' | 'verbose' | 'success' | 'error' | 'warning' | 'info';
|
||||
},
|
||||
) => {
|
||||
createLog(data);
|
||||
},
|
||||
);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
globalShortcut.unregisterAll();
|
||||
getMpvInstance()?.quit();
|
||||
// Respect the OSX convention of having the application in memory even
|
||||
// after all windows have been closed
|
||||
if (isMacOS()) {
|
||||
ipcMain.removeHandler('window-clear-cache');
|
||||
mainWindow = null;
|
||||
} else {
|
||||
app.quit();
|
||||
@@ -695,7 +609,11 @@ if (!singleInstance) {
|
||||
app.on('activate', () => {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (mainWindow === null) createWindow();
|
||||
if (mainWindow === null) createWindow(false);
|
||||
else if (!mainWindow.isVisible()) {
|
||||
mainWindow.show();
|
||||
createWinThumbarButtons();
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(console.log);
|
||||
|
||||
@@ -24,7 +24,12 @@ const devtools = () => {
|
||||
ipcRenderer.send('window-dev-tools');
|
||||
};
|
||||
|
||||
const clearCache = (): Promise<void> => {
|
||||
return ipcRenderer.invoke('window-clear-cache');
|
||||
};
|
||||
|
||||
export const browser = {
|
||||
clearCache,
|
||||
devtools,
|
||||
exit,
|
||||
maximize,
|
||||
@@ -32,3 +37,5 @@ export const browser = {
|
||||
quit,
|
||||
unmaximize,
|
||||
};
|
||||
|
||||
export type Browser = typeof browser;
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { IpcRendererEvent, ipcRenderer, webFrame } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import { toServerType, type TitleTheme } from '/@/renderer/types';
|
||||
|
||||
const store = new Store();
|
||||
|
||||
const set = (property: string, value: string | Record<string, unknown> | boolean | string[]) => {
|
||||
const set = (
|
||||
property: string,
|
||||
value: string | Record<string, unknown> | boolean | string[] | undefined,
|
||||
) => {
|
||||
if (value === undefined) {
|
||||
store.delete(property);
|
||||
return;
|
||||
}
|
||||
|
||||
store.set(`${property}`, value);
|
||||
};
|
||||
|
||||
@@ -43,9 +52,24 @@ const fontError = (cb: (event: IpcRendererEvent, file: string) => void) => {
|
||||
ipcRenderer.on('custom-font-error', cb);
|
||||
};
|
||||
|
||||
const themeSet = (theme: TitleTheme): void => {
|
||||
ipcRenderer.send('theme-set', theme);
|
||||
};
|
||||
|
||||
const SERVER_TYPE = toServerType(process.env.SERVER_TYPE);
|
||||
|
||||
const env = {
|
||||
SERVER_LOCK:
|
||||
SERVER_TYPE !== null ? process.env.SERVER_LOCK?.toLocaleLowerCase() === 'true' : false,
|
||||
SERVER_NAME: process.env.SERVER_NAME ?? '',
|
||||
SERVER_TYPE,
|
||||
SERVER_URL: process.env.SERVER_URL ?? 'http://',
|
||||
};
|
||||
|
||||
export const localSettings = {
|
||||
disableMediaKeys,
|
||||
enableMediaKeys,
|
||||
env,
|
||||
fontError,
|
||||
get,
|
||||
passwordGet,
|
||||
@@ -54,6 +78,7 @@ export const localSettings = {
|
||||
restart,
|
||||
set,
|
||||
setZoomFactor,
|
||||
themeSet,
|
||||
};
|
||||
|
||||
export type LocalSettings = typeof localSettings;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
import { PlayerData, PlayerState } from '/@/renderer/store';
|
||||
import { PlayerData } from '/@/renderer/store';
|
||||
|
||||
const initialize = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
ipcRenderer.send('player-initialize', data);
|
||||
return ipcRenderer.invoke('player-initialize', data);
|
||||
};
|
||||
|
||||
const restart = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
ipcRenderer.send('player-restart', data);
|
||||
const restart = (data: {
|
||||
binaryPath?: string;
|
||||
extraParameters?: string[];
|
||||
properties?: Record<string, any>;
|
||||
}) => {
|
||||
return ipcRenderer.invoke('player-restart', data);
|
||||
};
|
||||
|
||||
const isRunning = () => {
|
||||
@@ -18,7 +22,6 @@ const cleanup = () => {
|
||||
};
|
||||
|
||||
const setProperties = (data: Record<string, any>) => {
|
||||
console.log('Setting property :>>', data);
|
||||
ipcRenderer.send('player-set-properties', data);
|
||||
};
|
||||
|
||||
@@ -50,14 +53,6 @@ const previous = () => {
|
||||
ipcRenderer.send('player-previous');
|
||||
};
|
||||
|
||||
const restoreQueue = () => {
|
||||
ipcRenderer.send('player-restore-queue');
|
||||
};
|
||||
|
||||
const saveQueue = (data: Record<string, any>) => {
|
||||
ipcRenderer.send('player-save-queue', data);
|
||||
};
|
||||
|
||||
const seek = (seconds: number) => {
|
||||
ipcRenderer.send('player-seek', seconds);
|
||||
};
|
||||
@@ -154,20 +149,14 @@ const rendererQuit = (cb: (event: IpcRendererEvent) => void) => {
|
||||
ipcRenderer.on('renderer-player-quit', cb);
|
||||
};
|
||||
|
||||
const rendererSaveQueue = (cb: (event: IpcRendererEvent) => void) => {
|
||||
ipcRenderer.on('renderer-player-save-queue', cb);
|
||||
};
|
||||
|
||||
const rendererRestoreQueue = (
|
||||
cb: (event: IpcRendererEvent, data: Partial<PlayerState>) => void,
|
||||
) => {
|
||||
ipcRenderer.on('renderer-player-restore-queue', cb);
|
||||
};
|
||||
|
||||
const rendererError = (cb: (event: IpcRendererEvent, data: string) => void) => {
|
||||
ipcRenderer.on('renderer-player-error', cb);
|
||||
};
|
||||
|
||||
const rendererPlayerFallback = (cb: (event: IpcRendererEvent, data: boolean) => void) => {
|
||||
ipcRenderer.on('renderer-player-fallback', cb);
|
||||
};
|
||||
|
||||
export const mpvPlayer = {
|
||||
autoNext,
|
||||
cleanup,
|
||||
@@ -182,8 +171,6 @@ export const mpvPlayer = {
|
||||
previous,
|
||||
quit,
|
||||
restart,
|
||||
restoreQueue,
|
||||
saveQueue,
|
||||
seek,
|
||||
seekTo,
|
||||
setProperties,
|
||||
@@ -201,10 +188,9 @@ export const mpvPlayerListener = {
|
||||
rendererPause,
|
||||
rendererPlay,
|
||||
rendererPlayPause,
|
||||
rendererPlayerFallback,
|
||||
rendererPrevious,
|
||||
rendererQuit,
|
||||
rendererRestoreQueue,
|
||||
rendererSaveQueue,
|
||||
rendererSkipBackward,
|
||||
rendererSkipForward,
|
||||
rendererStop,
|
||||
|
||||
@@ -1,9 +1,59 @@
|
||||
import { IpcRendererEvent, ipcRenderer } from 'electron';
|
||||
import { isMacOS, isWindows, isLinux } from '../utils';
|
||||
import { PlayerState } from '/@/renderer/store';
|
||||
|
||||
const saveQueue = (data: Record<string, any>) => {
|
||||
ipcRenderer.send('player-save-queue', data);
|
||||
};
|
||||
|
||||
const restoreQueue = () => {
|
||||
ipcRenderer.send('player-restore-queue');
|
||||
};
|
||||
|
||||
const onSaveQueue = (cb: (event: IpcRendererEvent) => void) => {
|
||||
ipcRenderer.on('renderer-save-queue', cb);
|
||||
};
|
||||
|
||||
const onRestoreQueue = (cb: (event: IpcRendererEvent, data: Partial<PlayerState>) => void) => {
|
||||
ipcRenderer.on('renderer-restore-queue', cb);
|
||||
};
|
||||
|
||||
const playerErrorListener = (cb: (event: IpcRendererEvent, data: { code: number }) => void) => {
|
||||
ipcRenderer.on('player-error-listener', cb);
|
||||
};
|
||||
|
||||
const mainMessageListener = (
|
||||
cb: (
|
||||
event: IpcRendererEvent,
|
||||
data: { message: string; type: 'success' | 'error' | 'warning' | 'info' },
|
||||
) => void,
|
||||
) => {
|
||||
ipcRenderer.on('toast-from-main', cb);
|
||||
};
|
||||
|
||||
const logger = (
|
||||
cb: (
|
||||
event: IpcRendererEvent,
|
||||
data: {
|
||||
message: string;
|
||||
type: 'debug' | 'verbose' | 'error' | 'warning' | 'info';
|
||||
},
|
||||
) => void,
|
||||
) => {
|
||||
ipcRenderer.send('logger', cb);
|
||||
};
|
||||
|
||||
export const utils = {
|
||||
isLinux,
|
||||
isMacOS,
|
||||
isWindows,
|
||||
logger,
|
||||
mainMessageListener,
|
||||
onRestoreQueue,
|
||||
onSaveQueue,
|
||||
playerErrorListener,
|
||||
restoreQueue,
|
||||
saveQueue,
|
||||
};
|
||||
|
||||
export type Utils = typeof utils;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import path from 'path';
|
||||
import process from 'process';
|
||||
import { URL } from 'url';
|
||||
import log from 'electron-log/main';
|
||||
|
||||
export let resolveHtmlPath: (htmlFileName: string) => string;
|
||||
|
||||
@@ -50,3 +51,46 @@ export const hotkeyToElectronAccelerator = (hotkey: string) => {
|
||||
|
||||
return accelerator;
|
||||
};
|
||||
|
||||
const logMethod = {
|
||||
debug: log.debug,
|
||||
error: log.error,
|
||||
info: log.info,
|
||||
success: log.info,
|
||||
verbose: log.verbose,
|
||||
warning: log.warn,
|
||||
};
|
||||
|
||||
const logColor = {
|
||||
debug: 'blue',
|
||||
error: 'red',
|
||||
info: 'blue',
|
||||
success: 'green',
|
||||
verbose: 'blue',
|
||||
warning: 'yellow',
|
||||
};
|
||||
|
||||
export const createLog = (data: {
|
||||
message: string;
|
||||
type: 'debug' | 'verbose' | 'success' | 'error' | 'warning' | 'info';
|
||||
}) => {
|
||||
logMethod[data.type](`%c${data.message}`, `color: ${logColor[data.type]}`);
|
||||
};
|
||||
|
||||
export const autoUpdaterLogInterface = {
|
||||
debug: (message: string) => {
|
||||
createLog({ message: `[SYSTEM] ${message}`, type: 'debug' });
|
||||
},
|
||||
|
||||
error: (message: string) => {
|
||||
createLog({ message: `[SYSTEM] ${message}`, type: 'error' });
|
||||
},
|
||||
|
||||
info: (message: string) => {
|
||||
createLog({ message: `[SYSTEM] ${message}`, type: 'info' });
|
||||
},
|
||||
|
||||
warn: (message: string) => {
|
||||
createLog({ message: `[SYSTEM] ${message}`, type: 'warning' });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -48,8 +48,14 @@ import type {
|
||||
SearchResponse,
|
||||
LyricsArgs,
|
||||
LyricsResponse,
|
||||
ServerInfo,
|
||||
ServerInfoArgs,
|
||||
StructuredLyricsArgs,
|
||||
StructuredLyric,
|
||||
SimilarSongsArgs,
|
||||
Song,
|
||||
ServerType,
|
||||
} from '/@/renderer/api/types';
|
||||
import { ServerType } from '/@/renderer/types';
|
||||
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
|
||||
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
|
||||
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
|
||||
@@ -85,8 +91,11 @@ export type ControllerEndpoint = Partial<{
|
||||
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
|
||||
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
|
||||
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
|
||||
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
|
||||
getSimilarSongs: (args: SimilarSongsArgs) => Promise<Song[]>;
|
||||
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
||||
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
||||
getStructuredLyrics: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
|
||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||
@@ -129,8 +138,11 @@ const endpoints: ApiController = {
|
||||
getPlaylistList: jfController.getPlaylistList,
|
||||
getPlaylistSongList: jfController.getPlaylistSongList,
|
||||
getRandomSongList: jfController.getRandomSongList,
|
||||
getServerInfo: jfController.getServerInfo,
|
||||
getSimilarSongs: jfController.getSimilarSongs,
|
||||
getSongDetail: jfController.getSongDetail,
|
||||
getSongList: jfController.getSongList,
|
||||
getStructuredLyrics: undefined,
|
||||
getTopSongs: jfController.getTopSongList,
|
||||
getUserList: undefined,
|
||||
removeFromPlaylist: jfController.removeFromPlaylist,
|
||||
@@ -165,8 +177,11 @@ const endpoints: ApiController = {
|
||||
getPlaylistList: ndController.getPlaylistList,
|
||||
getPlaylistSongList: ndController.getPlaylistSongList,
|
||||
getRandomSongList: ssController.getRandomSongList,
|
||||
getServerInfo: ndController.getServerInfo,
|
||||
getSimilarSongs: ssController.getSimilarSongs,
|
||||
getSongDetail: ndController.getSongDetail,
|
||||
getSongList: ndController.getSongList,
|
||||
getStructuredLyrics: ssController.getStructuredLyrics,
|
||||
getTopSongs: ssController.getTopSongList,
|
||||
getUserList: ndController.getUserList,
|
||||
removeFromPlaylist: ndController.removeFromPlaylist,
|
||||
@@ -198,8 +213,11 @@ const endpoints: ApiController = {
|
||||
getMusicFolderList: ssController.getMusicFolderList,
|
||||
getPlaylistDetail: undefined,
|
||||
getPlaylistList: undefined,
|
||||
getServerInfo: ssController.getServerInfo,
|
||||
getSimilarSongs: ssController.getSimilarSongs,
|
||||
getSongDetail: undefined,
|
||||
getSongList: undefined,
|
||||
getStructuredLyrics: ssController.getStructuredLyrics,
|
||||
getTopSongs: ssController.getTopSongList,
|
||||
getUserList: undefined,
|
||||
scrobble: ssController.scrobble,
|
||||
@@ -481,6 +499,33 @@ const getLyrics = async (args: LyricsArgs) => {
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getServerInfo = async (args: ServerInfoArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getServerInfo',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getServerInfo']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getStructuredLyrics = async (args: StructuredLyricsArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getStructuredLyrics',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getStructuredLyrics']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getSimilarSongs = async (args: SimilarSongsArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getSimilarSongs',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getSimilarSongs']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
export const controller = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
@@ -500,8 +545,11 @@ export const controller = {
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getRandomSongList,
|
||||
getServerInfo,
|
||||
getSimilarSongs,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getStructuredLyrics,
|
||||
getTopSongList,
|
||||
getUserList,
|
||||
removeFromPlaylist,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Should follow a strict naming convention: "<FEATURE GROUP>_<FEATURE NAME>"
|
||||
// For example: <FEATURE GROUP>: "Playlists", <FEATURE NAME>: "Smart" = "PLAYLISTS_SMART"
|
||||
export enum ServerFeature {
|
||||
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
|
||||
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
|
||||
PLAYLISTS_SMART = 'playlistsSmart',
|
||||
}
|
||||
|
||||
export type ServerFeatures = Partial<Record<ServerFeature, boolean>>;
|
||||
@@ -3,7 +3,7 @@ import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||
import { initClient, initContract } from '@ts-rest/core';
|
||||
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
|
||||
import qs from 'qs';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
import { ServerListItem } from '/@/renderer/api/types';
|
||||
import omitBy from 'lodash/omitBy';
|
||||
import { z } from 'zod';
|
||||
import { authenticationFailure } from '/@/renderer/api/utils';
|
||||
@@ -150,6 +150,14 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getServerInfo: {
|
||||
method: 'GET',
|
||||
path: 'system/info',
|
||||
responses: {
|
||||
200: jfType._response.serverInfo,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getSimilarArtistList: {
|
||||
method: 'GET',
|
||||
path: 'artists/:id/similar',
|
||||
@@ -159,6 +167,15 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getSimilarSongs: {
|
||||
method: 'GET',
|
||||
path: 'items/:itemId/similar',
|
||||
query: jfType._parameters.similarSongs,
|
||||
responses: {
|
||||
200: jfType._response.similarSongs,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getSongDetail: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items/:id',
|
||||
|
||||
@@ -49,6 +49,10 @@ import {
|
||||
genreListSortMap,
|
||||
SongDetailArgs,
|
||||
SongDetailResponse,
|
||||
ServerInfo,
|
||||
ServerInfoArgs,
|
||||
SimilarSongsArgs,
|
||||
Song,
|
||||
} from '/@/renderer/api/types';
|
||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||
import { jfNormalize } from './jellyfin-normalize';
|
||||
@@ -57,6 +61,7 @@ import packageJson from '../../../../package.json';
|
||||
import { z } from 'zod';
|
||||
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
|
||||
import isElectron from 'is-electron';
|
||||
import { ServerFeatures } from '/@/renderer/api/features-types';
|
||||
|
||||
const formatCommaDelimitedString = (value: string[]) => {
|
||||
return value.join(',');
|
||||
@@ -102,9 +107,9 @@ const authenticate = async (
|
||||
Username: body.username,
|
||||
},
|
||||
headers: {
|
||||
'x-emby-authorization': `MediaBrowser Client="Feishin", Device="${getHostname()}", DeviceId="Feishin-${getHostname()}-${
|
||||
body.username
|
||||
}", Version="${packageJson.version}"`,
|
||||
'x-emby-authorization': `MediaBrowser Client="Feishin", Device="${getHostname()}", DeviceId="Feishin-${getHostname()}-${encodeURIComponent(
|
||||
body.username,
|
||||
)}", Version="${packageJson.version}"`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -374,7 +379,7 @@ const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse>
|
||||
IncludeItemTypes: 'Audio',
|
||||
Limit: query.limit,
|
||||
Recursive: true,
|
||||
SortBy: 'CommunityRating,SortName',
|
||||
SortBy: 'PlayCount,SortName',
|
||||
SortOrder: 'Descending',
|
||||
UserId: apiClientProps.server?.userId,
|
||||
},
|
||||
@@ -538,7 +543,7 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<SongList
|
||||
Limit: query.limit,
|
||||
SortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined,
|
||||
SortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined,
|
||||
StartIndex: 0,
|
||||
StartIndex: query.startIndex,
|
||||
UserId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
@@ -946,6 +951,53 @@ const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse>
|
||||
return jfNormalize.song(res.body, apiClientProps.server, '');
|
||||
};
|
||||
|
||||
const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getServerInfo();
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get server info');
|
||||
}
|
||||
|
||||
const features: ServerFeatures = {
|
||||
lyricsSingleStructured: true,
|
||||
};
|
||||
|
||||
return {
|
||||
features,
|
||||
id: apiClientProps.server?.id,
|
||||
version: res.body.Version,
|
||||
};
|
||||
};
|
||||
|
||||
const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getSimilarSongs({
|
||||
params: {
|
||||
itemId: query.songId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
Limit: query.count,
|
||||
UserId: apiClientProps.server?.userId || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get similar songs');
|
||||
}
|
||||
|
||||
return res.body.Items.reduce<Song[]>((acc, song) => {
|
||||
if (song.Id !== query.songId) {
|
||||
acc.push(jfNormalize.song(song, apiClientProps.server, ''));
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const jfController = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
@@ -965,6 +1017,8 @@ export const jfController = {
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getRandomSongList,
|
||||
getServerInfo,
|
||||
getSimilarSongs,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getTopSongList,
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
Playlist,
|
||||
MusicFolder,
|
||||
Genre,
|
||||
ServerListItem,
|
||||
ServerType,
|
||||
} from '/@/renderer/api/types';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
|
||||
const getStreamUrl = (args: {
|
||||
container?: string;
|
||||
@@ -202,6 +203,7 @@ const normalizeAlbum = (
|
||||
imageSize?: number,
|
||||
): Album => {
|
||||
return {
|
||||
albumArtist: item.AlbumArtist,
|
||||
albumArtists:
|
||||
item.AlbumArtists.map((entry) => ({
|
||||
id: entry.Id,
|
||||
@@ -214,6 +216,7 @@ const normalizeAlbum = (
|
||||
name: entry.Name,
|
||||
})),
|
||||
backdropImageUrl: null,
|
||||
comment: null,
|
||||
createdAt: item.DateCreated,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({
|
||||
@@ -232,6 +235,7 @@ const normalizeAlbum = (
|
||||
isCompilation: null,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: null,
|
||||
mbzId: item.ProviderIds?.MusicBrainzAlbum || null,
|
||||
name: item.Name,
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
releaseDate: item.PremiereDate?.split('T')[0] || null,
|
||||
@@ -287,6 +291,7 @@ const normalizeAlbumArtist = (
|
||||
}),
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: null,
|
||||
mbz: item.ProviderIds?.MusicBrainzArtist || null,
|
||||
name: item.Name,
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
serverId: server?.id || '',
|
||||
|
||||
@@ -422,6 +422,11 @@ const song = z.object({
|
||||
UserData: userData.optional(),
|
||||
});
|
||||
|
||||
const providerIds = z.object({
|
||||
MusicBrainzAlbum: z.string().optional(),
|
||||
MusicBrainzArtist: z.string().optional(),
|
||||
});
|
||||
|
||||
const albumArtist = z.object({
|
||||
BackdropImageTags: z.array(z.string()),
|
||||
ChannelId: z.null(),
|
||||
@@ -435,6 +440,7 @@ const albumArtist = z.object({
|
||||
LocationType: z.string(),
|
||||
Name: z.string(),
|
||||
Overview: z.string(),
|
||||
ProviderIds: providerIds.optional(),
|
||||
RunTimeTicks: z.number(),
|
||||
ServerId: z.string(),
|
||||
Type: z.string(),
|
||||
@@ -466,6 +472,7 @@ const album = z.object({
|
||||
ParentLogoItemId: z.string(),
|
||||
PremiereDate: z.string().optional(),
|
||||
ProductionYear: z.number(),
|
||||
ProviderIds: providerIds.optional(),
|
||||
RunTimeTicks: z.number(),
|
||||
ServerId: z.string(),
|
||||
Songs: z.array(song).optional(), // This is not a native Jellyfin property -- this is used for combined album detail
|
||||
@@ -654,6 +661,24 @@ const lyrics = z.object({
|
||||
Lyrics: z.array(lyricText),
|
||||
});
|
||||
|
||||
const serverInfo = z.object({
|
||||
Version: z.string(),
|
||||
});
|
||||
|
||||
const similarSongsParameters = z.object({
|
||||
Fields: z.string().optional(),
|
||||
Limit: z.number().optional(),
|
||||
UserId: z.string().optional(),
|
||||
});
|
||||
|
||||
const similarSongs = pagination.extend({
|
||||
Items: z.array(song),
|
||||
});
|
||||
|
||||
export enum JellyfinExtensions {
|
||||
SONG_LYRICS = 'songLyrics',
|
||||
}
|
||||
|
||||
export const jfType = {
|
||||
_enum: {
|
||||
albumArtistList: albumArtistListSort,
|
||||
@@ -683,6 +708,7 @@ export const jfType = {
|
||||
scrobble: scrobbleParameters,
|
||||
search: searchParameters,
|
||||
similarArtistList: similarArtistListParameters,
|
||||
similarSongs: similarSongsParameters,
|
||||
songList: songListParameters,
|
||||
updatePlaylist: updatePlaylistParameters,
|
||||
},
|
||||
@@ -707,6 +733,8 @@ export const jfType = {
|
||||
removeFromPlaylist,
|
||||
scrobble,
|
||||
search,
|
||||
serverInfo,
|
||||
similarSongs,
|
||||
song,
|
||||
songList,
|
||||
topSongsList,
|
||||
|
||||
@@ -7,7 +7,7 @@ import qs from 'qs';
|
||||
import { ndType } from './navidrome-types';
|
||||
import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
import { ServerListItem } from '/@/renderer/api/types';
|
||||
import { toast } from '/@/renderer/components';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
@@ -286,9 +286,10 @@ axiosClient.interceptors.response.use(
|
||||
});
|
||||
|
||||
const serverId = currentServer.id;
|
||||
useAuthStore
|
||||
.getState()
|
||||
.actions.updateServer(serverId, { ndCredential: undefined });
|
||||
useAuthStore.getState().actions.updateServer(serverId, {
|
||||
credential: undefined,
|
||||
ndCredential: undefined,
|
||||
});
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
|
||||
// special error to prevent sending a second message, and stop other messages that could be enqueued
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
|
||||
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
|
||||
import { NavidromeExtensions, ndType } from '/@/renderer/api/navidrome/navidrome-types';
|
||||
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||
import semverCoerce from 'semver/functions/coerce';
|
||||
import semverGte from 'semver/functions/gte';
|
||||
import {
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistDetailResponse,
|
||||
@@ -39,11 +45,12 @@ import {
|
||||
RemoveFromPlaylistResponse,
|
||||
RemoveFromPlaylistArgs,
|
||||
genreListSortMap,
|
||||
ServerInfo,
|
||||
ServerInfoArgs,
|
||||
} from '../types';
|
||||
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
|
||||
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
|
||||
import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
|
||||
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||
import { hasFeature } from '/@/renderer/api/utils';
|
||||
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types';
|
||||
import { SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
@@ -355,6 +362,16 @@ const deletePlaylist = async (args: DeletePlaylistArgs): Promise<DeletePlaylistR
|
||||
|
||||
const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const customQuery = query._custom?.navidrome;
|
||||
|
||||
// Smart playlists only became available in 0.48.0. Do not filter for previous versions
|
||||
if (
|
||||
customQuery &&
|
||||
customQuery.smart !== undefined &&
|
||||
!hasFeature(apiClientProps.server, ServerFeature.PLAYLISTS_SMART)
|
||||
) {
|
||||
customQuery.smart = undefined;
|
||||
}
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getPlaylistList({
|
||||
query: {
|
||||
@@ -363,7 +380,7 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
|
||||
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
|
||||
_start: query.startIndex,
|
||||
q: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
...customQuery,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -465,6 +482,71 @@ const removeFromPlaylist = async (
|
||||
return null;
|
||||
};
|
||||
|
||||
const VERSION_INFO: Array<[string, Record<string, number[]>]> = [
|
||||
['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],
|
||||
];
|
||||
|
||||
const getFeatures = (version: string): Record<string, number[]> => {
|
||||
const cleanVersion = semverCoerce(version);
|
||||
const features: Record<string, number[]> = {};
|
||||
let matched = cleanVersion === null;
|
||||
|
||||
for (const [version, supportedFeatures] of VERSION_INFO) {
|
||||
if (!matched) {
|
||||
matched = semverGte(cleanVersion!, version);
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
for (const [feature, feat] of Object.entries(supportedFeatures)) {
|
||||
if (feature in features) {
|
||||
features[feature].push(...feat);
|
||||
} else {
|
||||
features[feature] = feat;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return features;
|
||||
};
|
||||
|
||||
const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
// Navidrome will always populate serverVersion
|
||||
const ping = await ssApiClient(apiClientProps).ping();
|
||||
|
||||
if (ping.status !== 200) {
|
||||
throw new Error('Failed to ping server');
|
||||
}
|
||||
|
||||
const navidromeFeatures: Record<string, number[]> = getFeatures(ping.body.serverVersion!);
|
||||
|
||||
if (ping.body.openSubsonic) {
|
||||
const res = await ssApiClient(apiClientProps).getServerInfo();
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get server extensions');
|
||||
}
|
||||
|
||||
// The type here isn't necessarily an array (even though it's supposed to be). This is
|
||||
// an implementation detail of Navidrome 0.50. Do a type check to make sure it's actually
|
||||
// an array, and not an empty object.
|
||||
if (Array.isArray(res.body.openSubsonicExtensions)) {
|
||||
for (const extension of res.body.openSubsonicExtensions) {
|
||||
navidromeFeatures[extension.name] = extension.versions;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const features: ServerFeatures = {
|
||||
lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS],
|
||||
playlistsSmart: !!navidromeFeatures[NavidromeExtensions.SMART_PLAYLISTS],
|
||||
};
|
||||
|
||||
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! };
|
||||
};
|
||||
|
||||
export const ndController = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
@@ -478,6 +560,7 @@ export const ndController = {
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getServerInfo,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getUserList,
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
User,
|
||||
AlbumArtist,
|
||||
Genre,
|
||||
ServerListItem,
|
||||
ServerType,
|
||||
} 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';
|
||||
@@ -45,6 +46,14 @@ const getCoverArtUrl = (args: {
|
||||
);
|
||||
};
|
||||
|
||||
interface WithDate {
|
||||
playDate?: string;
|
||||
}
|
||||
|
||||
const normalizePlayDate = (item: WithDate): string | null => {
|
||||
return !item.playDate || item.playDate.includes('0001-') ? null : item.playDate;
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof ndType._response.song> | z.infer<typeof ndType._response.playlistSong>,
|
||||
server: ServerListItem | null,
|
||||
@@ -100,7 +109,7 @@ const normalizeSong = (
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
lastPlayedAt: normalizePlayDate(item),
|
||||
lyrics: item.lyrics ? item.lyrics : null,
|
||||
name: item.title,
|
||||
path: item.path,
|
||||
@@ -143,9 +152,11 @@ const normalizeAlbum = (
|
||||
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
|
||||
|
||||
return {
|
||||
albumArtist: item.albumArtist,
|
||||
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
|
||||
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
backdropImageUrl: imageBackdropUrl,
|
||||
comment: item.comment || null,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
duration: item.duration * 1000 || null,
|
||||
genres: item.genres?.map((genre) => ({
|
||||
@@ -159,7 +170,8 @@ const normalizeAlbum = (
|
||||
imageUrl,
|
||||
isCompilation: item.compilation,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
lastPlayedAt: normalizePlayDate(item),
|
||||
mbzId: item.mbzAlbumId || null,
|
||||
name: item.name,
|
||||
playCount: item.playCount,
|
||||
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
|
||||
@@ -189,7 +201,7 @@ const normalizeAlbumArtist = (
|
||||
baseUrl: server?.url,
|
||||
coverArtId: `ar-${item.id}`,
|
||||
credential: server?.credential,
|
||||
size: 100,
|
||||
size: 300,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -207,7 +219,8 @@ const normalizeAlbumArtist = (
|
||||
id: item.id,
|
||||
imageUrl: imageUrl || null,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
lastPlayedAt: normalizePlayDate(item),
|
||||
mbz: item.mbzArtistId || null,
|
||||
name: item.name,
|
||||
playCount: item.playCount,
|
||||
serverId: server?.id || 'unknown',
|
||||
|
||||
@@ -78,7 +78,7 @@ const albumArtist = z.object({
|
||||
name: z.string(),
|
||||
orderArtistName: z.string(),
|
||||
playCount: z.number(),
|
||||
playDate: z.string(),
|
||||
playDate: z.string().optional(),
|
||||
rating: z.number(),
|
||||
size: z.number(),
|
||||
smallImageUrl: z.string().optional(),
|
||||
@@ -111,6 +111,7 @@ const album = z.object({
|
||||
allArtistIds: z.string(),
|
||||
artist: z.string(),
|
||||
artistId: z.string(),
|
||||
comment: z.string().optional(),
|
||||
compilation: z.boolean(),
|
||||
coverArtId: z.string().optional(), // Removed after v0.48.0
|
||||
coverArtPath: z.string().optional(), // Removed after v0.48.0
|
||||
@@ -128,7 +129,7 @@ const album = z.object({
|
||||
orderAlbumArtistName: z.string(),
|
||||
orderAlbumName: z.string(),
|
||||
playCount: z.number(),
|
||||
playDate: z.string(),
|
||||
playDate: z.string().optional(),
|
||||
rating: z.number().optional(),
|
||||
size: z.number(),
|
||||
songCount: z.number(),
|
||||
@@ -211,7 +212,7 @@ const song = z.object({
|
||||
orderTitle: z.string(),
|
||||
path: z.string(),
|
||||
playCount: z.number(),
|
||||
playDate: z.string(),
|
||||
playDate: z.string().optional(),
|
||||
rating: z.number().optional(),
|
||||
rgAlbumGain: z.number().optional(),
|
||||
rgAlbumPeak: z.number().optional(),
|
||||
@@ -342,6 +343,10 @@ const removeFromPlaylistParameters = z.object({
|
||||
id: z.array(z.string()),
|
||||
});
|
||||
|
||||
export enum NavidromeExtensions {
|
||||
SMART_PLAYLISTS = 'smartPlaylists',
|
||||
}
|
||||
|
||||
export const ndType = {
|
||||
_enum: {
|
||||
albumArtistList: ndAlbumArtistListSort,
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
LyricsQuery,
|
||||
LyricSearchQuery,
|
||||
GenreListQuery,
|
||||
SimilarSongsQuery,
|
||||
} from './types';
|
||||
|
||||
export const splitPaginatedQuery = (key: any) => {
|
||||
@@ -239,6 +240,10 @@ export const queryKeys: Record<
|
||||
return [serverId, 'songs', 'randomSongList'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'songs'] as const,
|
||||
similar: (serverId: string, query?: SimilarSongsQuery) => {
|
||||
if (query) return [serverId, 'song', 'similar', query] as const;
|
||||
return [serverId, 'song', 'similar'] as const;
|
||||
},
|
||||
},
|
||||
users: {
|
||||
list: (serverId: string, query?: UserListQuery) => {
|
||||
|
||||
@@ -50,6 +50,29 @@ export const contract = c.router({
|
||||
200: ssType._response.randomSongList,
|
||||
},
|
||||
},
|
||||
getServerInfo: {
|
||||
method: 'GET',
|
||||
path: 'getOpenSubsonicExtensions.view',
|
||||
responses: {
|
||||
200: ssType._response.serverInfo,
|
||||
},
|
||||
},
|
||||
getSimilarSongs: {
|
||||
method: 'GET',
|
||||
path: 'getSimilarSongs',
|
||||
query: ssType._parameters.similarSongs,
|
||||
responses: {
|
||||
200: ssType._response.similarSongs,
|
||||
},
|
||||
},
|
||||
getStructuredLyrics: {
|
||||
method: 'GET',
|
||||
path: 'getLyricsBySongId.view',
|
||||
query: ssType._parameters.structuredLyrics,
|
||||
responses: {
|
||||
200: ssType._response.structuredLyrics,
|
||||
},
|
||||
},
|
||||
getTopSongsList: {
|
||||
method: 'GET',
|
||||
path: 'getTopSongs.view',
|
||||
@@ -58,6 +81,13 @@ export const contract = c.router({
|
||||
200: ssType._response.topSongsList,
|
||||
},
|
||||
},
|
||||
ping: {
|
||||
method: 'GET',
|
||||
path: 'ping.view',
|
||||
responses: {
|
||||
200: ssType._response.ping,
|
||||
},
|
||||
},
|
||||
removeFavorite: {
|
||||
method: 'GET',
|
||||
path: 'unstar.view',
|
||||
|
||||
@@ -2,7 +2,7 @@ import md5 from 'md5';
|
||||
import { z } from 'zod';
|
||||
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import { SubsonicExtensions, ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import {
|
||||
ArtistInfoArgs,
|
||||
AuthenticationResponse,
|
||||
@@ -21,8 +21,15 @@ import {
|
||||
SearchResponse,
|
||||
RandomSongListResponse,
|
||||
RandomSongListArgs,
|
||||
ServerInfo,
|
||||
ServerInfoArgs,
|
||||
StructuredLyricsArgs,
|
||||
StructuredLyric,
|
||||
SimilarSongsArgs,
|
||||
Song,
|
||||
} from '/@/renderer/api/types';
|
||||
import { randomString } from '/@/renderer/utils';
|
||||
import { ServerFeatures } from '/@/renderer/api/features-types';
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
@@ -368,12 +375,122 @@ const getRandomSongList = async (args: RandomSongListArgs): Promise<RandomSongLi
|
||||
};
|
||||
};
|
||||
|
||||
const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
const ping = await ssApiClient(apiClientProps).ping();
|
||||
|
||||
if (ping.status !== 200) {
|
||||
throw new Error('Failed to ping server');
|
||||
}
|
||||
|
||||
const features: ServerFeatures = {};
|
||||
|
||||
if (!ping.body.openSubsonic || !ping.body.serverVersion) {
|
||||
return { features, version: ping.body.version };
|
||||
}
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getServerInfo();
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get server extensions');
|
||||
}
|
||||
|
||||
const subsonicFeatures: Record<string, number[]> = {};
|
||||
if (Array.isArray(res.body.openSubsonicExtensions)) {
|
||||
for (const extension of res.body.openSubsonicExtensions) {
|
||||
subsonicFeatures[extension.name] = extension.versions;
|
||||
}
|
||||
}
|
||||
|
||||
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
|
||||
features.lyricsMultipleStructured = true;
|
||||
}
|
||||
|
||||
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
|
||||
};
|
||||
|
||||
export const getStructuredLyrics = async (
|
||||
args: StructuredLyricsArgs,
|
||||
): Promise<StructuredLyric[]> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getStructuredLyrics({
|
||||
query: {
|
||||
id: query.songId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get structured lyrics');
|
||||
}
|
||||
|
||||
const lyrics = res.body.lyricsList?.structuredLyrics;
|
||||
|
||||
if (!lyrics) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return lyrics.map((lyric) => {
|
||||
const baseLyric = {
|
||||
artist: lyric.displayArtist || '',
|
||||
lang: lyric.lang,
|
||||
name: lyric.displayTitle || '',
|
||||
remote: false,
|
||||
source: apiClientProps.server?.name || 'music server',
|
||||
};
|
||||
|
||||
if (lyric.synced) {
|
||||
return {
|
||||
...baseLyric,
|
||||
lyrics: lyric.line.map((line) => [line.start!, line.value]),
|
||||
synced: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...baseLyric,
|
||||
lyrics: lyric.line.map((line) => [line.value]).join('\n'),
|
||||
synced: false,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getSimilarSongs({
|
||||
query: {
|
||||
count: query.count,
|
||||
id: query.songId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get similar songs');
|
||||
}
|
||||
|
||||
if (!res.body.similarSongs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return res.body.similarSongs.song.reduce<Song[]>((acc, song) => {
|
||||
if (song.id !== query.songId) {
|
||||
acc.push(ssNormalize.song(song, apiClientProps.server, ''));
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const ssController = {
|
||||
authenticate,
|
||||
createFavorite,
|
||||
getArtistInfo,
|
||||
getMusicFolderList,
|
||||
getRandomSongList,
|
||||
getServerInfo,
|
||||
getSimilarSongs,
|
||||
getStructuredLyrics,
|
||||
getTopSongList,
|
||||
removeFavorite,
|
||||
scrobble,
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import { QueueSong, LibraryItem, AlbumArtist, Album } from '/@/renderer/api/types';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
import {
|
||||
QueueSong,
|
||||
LibraryItem,
|
||||
AlbumArtist,
|
||||
Album,
|
||||
ServerListItem,
|
||||
ServerType,
|
||||
} from '/@/renderer/api/types';
|
||||
|
||||
const getCoverArtUrl = (args: {
|
||||
baseUrl: string | undefined;
|
||||
@@ -126,6 +132,7 @@ const normalizeAlbumArtist = (
|
||||
imageUrl,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: null,
|
||||
mbz: null,
|
||||
name: item.name,
|
||||
playCount: null,
|
||||
serverId: server?.id || 'unknown',
|
||||
@@ -150,11 +157,13 @@ const normalizeAlbum = (
|
||||
}) || null;
|
||||
|
||||
return {
|
||||
albumArtist: 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,
|
||||
comment: null,
|
||||
createdAt: item.created,
|
||||
duration: item.duration,
|
||||
genres: item.genre
|
||||
@@ -173,6 +182,7 @@ const normalizeAlbum = (
|
||||
isCompilation: null,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: null,
|
||||
mbzId: null,
|
||||
name: item.name,
|
||||
playCount: null,
|
||||
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,
|
||||
|
||||
@@ -206,6 +206,66 @@ const randomSongList = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const ping = z.object({
|
||||
openSubsonic: z.boolean().optional(),
|
||||
serverVersion: z.string().optional(),
|
||||
version: z.string(),
|
||||
});
|
||||
|
||||
const extension = z.object({
|
||||
name: z.string(),
|
||||
versions: z.number().array(),
|
||||
});
|
||||
|
||||
const serverInfo = z.object({
|
||||
openSubsonicExtensions: z.array(extension).optional(),
|
||||
});
|
||||
|
||||
const structuredLyricsParameters = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const lyricLine = z.object({
|
||||
start: z.number().optional(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const structuredLyric = z.object({
|
||||
displayArtist: z.string().optional(),
|
||||
displayTitle: z.string().optional(),
|
||||
lang: z.string(),
|
||||
line: z.array(lyricLine),
|
||||
offset: z.number().optional(),
|
||||
synced: z.boolean(),
|
||||
});
|
||||
|
||||
const structuredLyrics = z.object({
|
||||
lyricsList: z
|
||||
.object({
|
||||
structuredLyrics: z.array(structuredLyric).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const similarSongsParameters = z.object({
|
||||
count: z.number().optional(),
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const similarSongs = z.object({
|
||||
similarSongs: z
|
||||
.object({
|
||||
song: z.array(song),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export enum SubsonicExtensions {
|
||||
FORM_POST = 'formPost',
|
||||
SONG_LYRICS = 'songLyrics',
|
||||
TRANSCODE_OFFSET = 'transcodeOffset',
|
||||
}
|
||||
|
||||
export const ssType = {
|
||||
_parameters: {
|
||||
albumList: albumListParameters,
|
||||
@@ -217,6 +277,8 @@ export const ssType = {
|
||||
scrobble: scrobbleParameters,
|
||||
search3: search3Parameters,
|
||||
setRating: setRatingParameters,
|
||||
similarSongs: similarSongsParameters,
|
||||
structuredLyrics: structuredLyricsParameters,
|
||||
topSongsList: topSongsListParameters,
|
||||
},
|
||||
_response: {
|
||||
@@ -229,12 +291,16 @@ export const ssType = {
|
||||
baseResponse,
|
||||
createFavorite,
|
||||
musicFolderList,
|
||||
ping,
|
||||
randomSongList,
|
||||
removeFavorite,
|
||||
scrobble,
|
||||
search3,
|
||||
serverInfo,
|
||||
setRating,
|
||||
similarSongs,
|
||||
song,
|
||||
structuredLyrics,
|
||||
topSongsList,
|
||||
},
|
||||
};
|
||||
|
||||
+49
-11
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { ServerFeatures } from './features-types';
|
||||
import { jfType } from './jellyfin/jellyfin-types';
|
||||
import {
|
||||
JFSortOrder,
|
||||
@@ -57,13 +58,16 @@ export type User = {
|
||||
|
||||
export type ServerListItem = {
|
||||
credential: string;
|
||||
features?: ServerFeatures;
|
||||
id: string;
|
||||
name: string;
|
||||
ndCredential?: string;
|
||||
savePassword?: boolean;
|
||||
type: ServerType;
|
||||
url: string;
|
||||
userId: string | null;
|
||||
username: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export enum ServerType {
|
||||
@@ -144,9 +148,11 @@ export type Genre = {
|
||||
};
|
||||
|
||||
export type Album = {
|
||||
albumArtist: string;
|
||||
albumArtists: RelatedArtist[];
|
||||
artists: RelatedArtist[];
|
||||
backdropImageUrl: string | null;
|
||||
comment: string | null;
|
||||
createdAt: string;
|
||||
duration: number | null;
|
||||
genres: Genre[];
|
||||
@@ -156,6 +162,7 @@ export type Album = {
|
||||
isCompilation: boolean | null;
|
||||
itemType: LibraryItem.ALBUM;
|
||||
lastPlayedAt: string | null;
|
||||
mbzId: string | null;
|
||||
name: string;
|
||||
playCount: number | null;
|
||||
releaseDate: string | null;
|
||||
@@ -228,6 +235,7 @@ export type AlbumArtist = {
|
||||
imageUrl: string | null;
|
||||
itemType: LibraryItem.ALBUM_ARTIST;
|
||||
lastPlayedAt: string | null;
|
||||
mbz: string | null;
|
||||
name: string;
|
||||
playCount: number | null;
|
||||
serverId: string;
|
||||
@@ -417,7 +425,8 @@ export const albumListSortMap: AlbumListSortMap = {
|
||||
rating: NDAlbumListSort.RATING,
|
||||
recentlyAdded: NDAlbumListSort.RECENTLY_ADDED,
|
||||
recentlyPlayed: NDAlbumListSort.PLAY_DATE,
|
||||
releaseDate: undefined,
|
||||
// Recent versions of Navidrome support release date, but fallback to year for now
|
||||
releaseDate: NDAlbumListSort.YEAR,
|
||||
songCount: NDAlbumListSort.SONG_COUNT,
|
||||
year: NDAlbumListSort.YEAR,
|
||||
},
|
||||
@@ -1092,17 +1101,11 @@ export type InternetProviderLyricSearchResponse = {
|
||||
source: LyricSource;
|
||||
};
|
||||
|
||||
export type SynchronizedLyricMetadata = {
|
||||
lyrics: SynchronizedLyricsArray;
|
||||
export type FullLyricsMetadata = {
|
||||
lyrics: LyricsResponse;
|
||||
remote: boolean;
|
||||
} & Omit<InternetProviderLyricResponse, 'lyrics'>;
|
||||
|
||||
export type UnsynchronizedLyricMetadata = {
|
||||
lyrics: string;
|
||||
remote: boolean;
|
||||
} & Omit<InternetProviderLyricResponse, 'lyrics'>;
|
||||
|
||||
export type FullLyricsMetadata = SynchronizedLyricMetadata | UnsynchronizedLyricMetadata;
|
||||
source: string;
|
||||
} & Omit<InternetProviderLyricResponse, 'id' | 'lyrics' | 'source'>;
|
||||
|
||||
export type LyricOverride = Omit<InternetProviderLyricResponse, 'lyrics'>;
|
||||
|
||||
@@ -1139,3 +1142,38 @@ export type FontData = {
|
||||
postscriptName: string;
|
||||
style: string;
|
||||
};
|
||||
|
||||
export type ServerInfoArgs = BaseEndpointArgs;
|
||||
|
||||
export type ServerInfo = {
|
||||
features: ServerFeatures;
|
||||
id?: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type StructuredLyricsArgs = {
|
||||
query: LyricsQuery;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
export type StructuredUnsyncedLyric = {
|
||||
lyrics: string;
|
||||
synced: false;
|
||||
} & Omit<FullLyricsMetadata, 'lyrics'>;
|
||||
|
||||
export type StructuredSyncedLyric = {
|
||||
lyrics: SynchronizedLyricsArray;
|
||||
synced: true;
|
||||
} & Omit<FullLyricsMetadata, 'lyrics'>;
|
||||
|
||||
export type StructuredLyric = {
|
||||
lang: string;
|
||||
} & (StructuredUnsyncedLyric | StructuredSyncedLyric);
|
||||
|
||||
export type SimilarSongsQuery = {
|
||||
count?: number;
|
||||
songId: string;
|
||||
};
|
||||
|
||||
export type SimilarSongsArgs = {
|
||||
query: SimilarSongsQuery;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
@@ -2,7 +2,8 @@ import { AxiosHeaders } from 'axios';
|
||||
import { z } from 'zod';
|
||||
import { toast } from '/@/renderer/components';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
import { ServerListItem } from '/@/renderer/api/types';
|
||||
import { ServerFeature } from '/@/renderer/api/features-types';
|
||||
|
||||
// Since ts-rest client returns a strict response type, we need to add the headers to the body object
|
||||
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
|
||||
@@ -38,3 +39,11 @@ export const authenticationFailure = (currentServer: ServerListItem | null) => {
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
export const hasFeature = (server: ServerListItem | null, feature: ServerFeature): boolean => {
|
||||
if (!server || !server.features) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return server.features[feature] ?? false;
|
||||
};
|
||||
|
||||
+27
-22
@@ -27,15 +27,16 @@ import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types';
|
||||
import '@ag-grid-community/styles/ag-grid.css';
|
||||
import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
|
||||
|
||||
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
|
||||
|
||||
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;
|
||||
const utils = isElectron() ? window.electron.utils : null;
|
||||
|
||||
export const App = () => {
|
||||
const theme = useTheme();
|
||||
@@ -49,6 +50,7 @@ export const App = () => {
|
||||
const remoteSettings = useRemoteSettings();
|
||||
const textStyleRef = useRef<HTMLStyleElement>();
|
||||
useDiscordRpc();
|
||||
useServerVersion();
|
||||
|
||||
useEffect(() => {
|
||||
if (type === FontType.SYSTEM && system) {
|
||||
@@ -97,28 +99,31 @@ export const App = () => {
|
||||
// Start the mpv instance on startup
|
||||
useEffect(() => {
|
||||
const initializeMpv = async () => {
|
||||
const isRunning: boolean | undefined = await mpvPlayer?.isRunning();
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
const isRunning: boolean | undefined = await mpvPlayer?.isRunning();
|
||||
|
||||
mpvPlayer?.stop();
|
||||
mpvPlayer?.stop();
|
||||
|
||||
if (!isRunning) {
|
||||
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
|
||||
const properties: Record<string, any> = {
|
||||
speed: usePlayerStore.getState().current.speed,
|
||||
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
|
||||
};
|
||||
if (!isRunning) {
|
||||
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
|
||||
const properties: Record<string, any> = {
|
||||
speed: usePlayerStore.getState().current.speed,
|
||||
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
|
||||
};
|
||||
|
||||
mpvPlayer?.initialize({
|
||||
extraParameters,
|
||||
properties,
|
||||
});
|
||||
await mpvPlayer?.initialize({
|
||||
extraParameters,
|
||||
properties,
|
||||
});
|
||||
|
||||
mpvPlayer?.volume(properties.volume);
|
||||
mpvPlayer?.volume(properties.volume);
|
||||
}
|
||||
}
|
||||
mpvPlayer?.restoreQueue();
|
||||
|
||||
utils?.restoreQueue();
|
||||
};
|
||||
|
||||
if (isElectron() && playbackType === PlaybackType.LOCAL) {
|
||||
if (isElectron()) {
|
||||
initializeMpv();
|
||||
}
|
||||
|
||||
@@ -136,8 +141,8 @@ export const App = () => {
|
||||
}, [bindings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
mpvPlayerListener!.rendererSaveQueue(() => {
|
||||
if (utils) {
|
||||
utils.onSaveQueue(() => {
|
||||
const { current, queue } = usePlayerStore.getState();
|
||||
const stateToSave: Partial<Pick<PlayerState, 'current' | 'queue'>> = {
|
||||
current: {
|
||||
@@ -146,10 +151,10 @@ export const App = () => {
|
||||
},
|
||||
queue,
|
||||
};
|
||||
mpvPlayer!.saveQueue(stateToSave);
|
||||
utils.saveQueue(stateToSave);
|
||||
});
|
||||
|
||||
mpvPlayerListener!.rendererRestoreQueue((_event: any, data) => {
|
||||
utils.onRestoreQueue((_event: any, data) => {
|
||||
const playerData = restoreQueue(data);
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
mpvPlayer!.setQueue(playerData, true);
|
||||
@@ -158,8 +163,8 @@ export const App = () => {
|
||||
}
|
||||
|
||||
return () => {
|
||||
ipc?.removeAllListeners('renderer-player-restore-queue');
|
||||
ipc?.removeAllListeners('renderer-player-save-queue');
|
||||
ipc?.removeAllListeners('renderer-restore-queue');
|
||||
ipc?.removeAllListeners('renderer-save-queue');
|
||||
};
|
||||
}, [playbackType, restoreQueue]);
|
||||
|
||||
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
crossfadeHandler,
|
||||
gaplessHandler,
|
||||
} from '/@/renderer/components/audio-player/utils/list-handlers';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||
import type { CrossfadeStyle } from '/@/renderer/types';
|
||||
import { PlaybackStyle, PlayerStatus } from '/@/renderer/types';
|
||||
import { useSpeed } from '/@/renderer/store';
|
||||
import { toast } from '/@/renderer/components/toast';
|
||||
|
||||
interface AudioPlayerProps extends ReactPlayerProps {
|
||||
crossfadeDuration: number;
|
||||
@@ -39,6 +40,14 @@ type WebAudio = {
|
||||
gain: GainNode;
|
||||
};
|
||||
|
||||
// Credits: http://stackoverflow.com/questions/12150729/ddg
|
||||
// This is used so that the player will always have an <audio> element. This means that
|
||||
// player1Source and player2Source are connected BEFORE the user presses play for
|
||||
// the first time. This workaround is important for Safari, which seems to require the
|
||||
// source to be connected PRIOR to resuming audio context
|
||||
const EMPTY_SOURCE =
|
||||
'data:audio/wav;base64,UklGRjIAAABXQVZFZm10IBIAAAABAAEAQB8AAEAfAAABAAgAAABmYWN0BAAAAAAAAABkYXRhAAAAAA==';
|
||||
|
||||
export const AudioPlayer = forwardRef(
|
||||
(
|
||||
{
|
||||
@@ -60,6 +69,7 @@ export const AudioPlayer = forwardRef(
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
|
||||
const playback = useSettingsStore((state) => state.playback.mpvProperties);
|
||||
const { resetSampleRate } = useSettingsStoreActions();
|
||||
const playbackSpeed = useSpeed();
|
||||
|
||||
const [webAudio, setWebAudio] = useState<WebAudio | null>(null);
|
||||
@@ -69,6 +79,7 @@ export const AudioPlayer = forwardRef(
|
||||
const [player2Source, setPlayer2Source] = useState<MediaElementAudioSourceNode | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const calculateReplayGain = useCallback(
|
||||
(song: Song): number => {
|
||||
if (playback.replayGainMode === 'no') {
|
||||
@@ -119,10 +130,21 @@ export const AudioPlayer = forwardRef(
|
||||
|
||||
useEffect(() => {
|
||||
if ('AudioContext' in window) {
|
||||
const context = new AudioContext({
|
||||
latencyHint: 'playback',
|
||||
sampleRate: playback.audioSampleRateHz || undefined,
|
||||
});
|
||||
let context: AudioContext;
|
||||
|
||||
try {
|
||||
context = new AudioContext({
|
||||
latencyHint: 'playback',
|
||||
sampleRate: playback.audioSampleRateHz || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
// In practice, this should never be hit because the UI should validate
|
||||
// the range. However, the actual supported range is not guaranteed
|
||||
toast.error({ message: (error as Error).message });
|
||||
context = new AudioContext({ latencyHint: 'playback' });
|
||||
resetSampleRate();
|
||||
}
|
||||
|
||||
const gain = context.createGain();
|
||||
gain.connect(context.destination);
|
||||
|
||||
@@ -154,9 +176,18 @@ export const AudioPlayer = forwardRef(
|
||||
useEffect(() => {
|
||||
if (status === PlayerStatus.PLAYING) {
|
||||
if (currentPlayer === 1) {
|
||||
player1Ref.current?.getInternalPlayer()?.play();
|
||||
// calling play() is not necessarily a safe option (https://developer.chrome.com/blog/play-request-was-interrupted)
|
||||
// In practice, this failure is only likely to happen when using the 0-second wav:
|
||||
// play() + play() in rapid succession will cause problems as the frist one ends the track.
|
||||
player1Ref.current
|
||||
?.getInternalPlayer()
|
||||
?.play()
|
||||
.catch(() => {});
|
||||
} else {
|
||||
player2Ref.current?.getInternalPlayer()?.play();
|
||||
player2Ref.current
|
||||
?.getInternalPlayer()
|
||||
?.play()
|
||||
.catch(() => {});
|
||||
}
|
||||
} else {
|
||||
player1Ref.current?.getInternalPlayer()?.pause();
|
||||
@@ -243,32 +274,29 @@ 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);
|
||||
}
|
||||
if (webAudio && player1Source && player1 && currentPlayer === 1) {
|
||||
const newVolume = calculateReplayGain(player1) * volume;
|
||||
webAudio.gain.gain.setValueAtTime(newVolume, 0);
|
||||
}
|
||||
}, [calculateReplayGain, currentPlayer, player1, player1Source, webAudio]);
|
||||
}, [calculateReplayGain, currentPlayer, player1, player1Source, volume, webAudio]);
|
||||
|
||||
useEffect(() => {
|
||||
if (webAudio && player2Source) {
|
||||
if (player2 === undefined) {
|
||||
player2Source.disconnect();
|
||||
setPlayer2Source(null);
|
||||
} else if (currentPlayer === 2) {
|
||||
webAudio.gain.gain.setValueAtTime(calculateReplayGain(player2), 0);
|
||||
}
|
||||
if (webAudio && player2Source && player2 && currentPlayer === 2) {
|
||||
const newVolume = calculateReplayGain(player2) * volume;
|
||||
webAudio.gain.gain.setValueAtTime(newVolume, 0);
|
||||
}
|
||||
}, [calculateReplayGain, currentPlayer, player2, player2Source, webAudio]);
|
||||
}, [calculateReplayGain, currentPlayer, player2, player2Source, volume, webAudio]);
|
||||
|
||||
const handlePlayer1Start = useCallback(
|
||||
async (player: ReactPlayer) => {
|
||||
if (!webAudio || player1Source) return;
|
||||
if (webAudio.context.state !== 'running') {
|
||||
await webAudio.context.resume();
|
||||
if (!webAudio) return;
|
||||
if (player1Source) {
|
||||
// This should fire once, only if the source is real (meaning we
|
||||
// saw the dummy source) and the context is not ready
|
||||
if (webAudio.context.state !== 'running') {
|
||||
await webAudio.context.resume();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;
|
||||
@@ -284,9 +312,12 @@ export const AudioPlayer = forwardRef(
|
||||
|
||||
const handlePlayer2Start = useCallback(
|
||||
async (player: ReactPlayer) => {
|
||||
if (!webAudio || player2Source) return;
|
||||
if (webAudio.context.state !== 'running') {
|
||||
await webAudio.context.resume();
|
||||
if (!webAudio) return;
|
||||
if (player2Source) {
|
||||
if (webAudio.context.state !== 'running') {
|
||||
await webAudio.context.resume();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;
|
||||
@@ -300,6 +331,9 @@ export const AudioPlayer = forwardRef(
|
||||
[player2Source, webAudio],
|
||||
);
|
||||
|
||||
// Bugfix for Safari: rather than use the `<audio>` volume (which doesn't work),
|
||||
// use the GainNode to scale the volume. In this case, for compatibility with
|
||||
// other browsers, set the `<audio>` volume to 1
|
||||
return (
|
||||
<>
|
||||
<ReactPlayer
|
||||
@@ -312,10 +346,11 @@ export const AudioPlayer = forwardRef(
|
||||
playbackRate={playbackSpeed}
|
||||
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
|
||||
progressInterval={isTransitioning ? 10 : 250}
|
||||
url={player1?.streamUrl}
|
||||
volume={volume}
|
||||
url={player1?.streamUrl || EMPTY_SOURCE}
|
||||
volume={webAudio ? 1 : volume}
|
||||
width={0}
|
||||
onEnded={handleOnEnded}
|
||||
// If there is no stream url, we do not need to handle when the audio finishes
|
||||
onEnded={player1?.streamUrl ? handleOnEnded : undefined}
|
||||
onProgress={
|
||||
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1
|
||||
}
|
||||
@@ -331,10 +366,10 @@ export const AudioPlayer = forwardRef(
|
||||
playbackRate={playbackSpeed}
|
||||
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
|
||||
progressInterval={isTransitioning ? 10 : 250}
|
||||
url={player2?.streamUrl}
|
||||
volume={volume}
|
||||
url={player2?.streamUrl || EMPTY_SOURCE}
|
||||
volume={webAudio ? 1 : volume}
|
||||
width={0}
|
||||
onEnded={handleOnEnded}
|
||||
onEnded={player2?.streamUrl ? handleOnEnded : undefined}
|
||||
onProgress={
|
||||
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ export const gaplessHandler = (args: {
|
||||
|
||||
const durationPadding = isFlac ? 0.065 : 0.116;
|
||||
if (currentTime + durationPadding >= duration) {
|
||||
return nextPlayerRef.current.getInternalPlayer()?.play();
|
||||
return nextPlayerRef.current
|
||||
.getInternalPlayer()
|
||||
?.play()
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -61,7 +64,10 @@ export const crossfadeHandler = (args: {
|
||||
|
||||
if (shouldBeginTransition) {
|
||||
setIsTransitioning(true);
|
||||
return nextPlayerRef.current.getInternalPlayer().play();
|
||||
return nextPlayerRef.current
|
||||
.getInternalPlayer()
|
||||
?.play()
|
||||
.catch(() => {});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -114,9 +115,11 @@ export const SwiperGridCarousel = ({
|
||||
isLoading,
|
||||
uniqueId,
|
||||
}: SwiperGridCarouselProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const swiperRef = useRef<SwiperCore | any>(null);
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const [slideCount, setSlideCount] = useState(4);
|
||||
|
||||
useEffect(() => {
|
||||
swiperRef.current?.slideTo(0, 0);
|
||||
@@ -191,23 +194,24 @@ export const SwiperGridCarousel = ({
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
const activeIndex = swiperRef?.current?.activeIndex || 0;
|
||||
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4));
|
||||
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || slideCount));
|
||||
swiperRef?.current?.slideTo(activeIndex + slidesPerView);
|
||||
}, [swiperProps?.slidesPerView]);
|
||||
}, [slideCount, swiperProps?.slidesPerView]);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
const activeIndex = swiperRef?.current?.activeIndex || 0;
|
||||
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4));
|
||||
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || slideCount));
|
||||
swiperRef?.current?.slideTo(activeIndex - slidesPerView);
|
||||
}, [swiperProps?.slidesPerView]);
|
||||
}, [slideCount, swiperProps?.slidesPerView]);
|
||||
|
||||
const handleOnSlideChange = useCallback((e: SwiperCore) => {
|
||||
const { slides, isEnd, isBeginning, params } = e;
|
||||
if (isEnd || isBeginning) return;
|
||||
|
||||
const slideCount = (params.slidesPerView as number | undefined) || 4;
|
||||
setPagination({
|
||||
hasNextPage: (params?.slidesPerView || 4) < slides.length,
|
||||
hasPreviousPage: (params?.slidesPerView || 4) < slides.length,
|
||||
hasNextPage: slideCount < slides.length,
|
||||
hasPreviousPage: slideCount < slides.length,
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -215,42 +219,67 @@ export const SwiperGridCarousel = ({
|
||||
const { slides, isEnd, isBeginning, params } = e;
|
||||
if (isEnd || isBeginning) return;
|
||||
|
||||
const slideCount = (params.slidesPerView as number | undefined) || 4;
|
||||
setPagination({
|
||||
hasNextPage: (params.slidesPerView || 4) < slides.length,
|
||||
hasPreviousPage: (params.slidesPerView || 4) < slides.length,
|
||||
hasNextPage: slideCount < slides.length,
|
||||
hasPreviousPage: slideCount < slides.length,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOnReachEnd = useCallback((e: SwiperCore) => {
|
||||
const { slides, params } = e;
|
||||
|
||||
const slideCount = (params.slidesPerView as number | undefined) || 4;
|
||||
setPagination({
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: (params.slidesPerView || 4) < slides.length,
|
||||
hasPreviousPage: slideCount < slides.length,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOnReachBeginning = useCallback((e: SwiperCore) => {
|
||||
const { slides, params } = e;
|
||||
|
||||
const slideCount = (params.slidesPerView as number | undefined) || 4;
|
||||
setPagination({
|
||||
hasNextPage: (params.slidesPerView || 4) < slides.length,
|
||||
hasNextPage: slideCount < slides.length,
|
||||
hasPreviousPage: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOnResize = useCallback((e: SwiperCore) => {
|
||||
if (!e) return;
|
||||
const { width } = e;
|
||||
const slidesPerView = getSlidesPerView(width);
|
||||
if (!e.params) return;
|
||||
e.params.slidesPerView = slidesPerView;
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
const handleResize = () => {
|
||||
// Use the container div ref and not swiper width, as this value is more accurate
|
||||
const width = containerRef.current?.clientWidth;
|
||||
const { activeIndex, params, slides } =
|
||||
(swiperRef.current as SwiperCore | undefined) ?? {};
|
||||
|
||||
const throttledOnResize = throttle(handleOnResize, 200);
|
||||
if (width) {
|
||||
const slidesPerView = getSlidesPerView(width);
|
||||
setSlideCount(slidesPerView);
|
||||
}
|
||||
|
||||
if (activeIndex !== undefined && slides && params?.slidesPerView) {
|
||||
const slideCount = (params.slidesPerView as number | undefined) || 4;
|
||||
setPagination({
|
||||
hasNextPage: activeIndex + slideCount < slides.length,
|
||||
hasPreviousPage: activeIndex > 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleResize();
|
||||
|
||||
const throttledResize = throttle(handleResize, 200);
|
||||
window.addEventListener('resize', throttledResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', throttledResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CarouselContainer
|
||||
ref={containerRef}
|
||||
className="grid-carousel"
|
||||
spacing="md"
|
||||
>
|
||||
@@ -266,16 +295,14 @@ export const SwiperGridCarousel = ({
|
||||
ref={swiperRef}
|
||||
resizeObserver
|
||||
modules={[Virtual]}
|
||||
slidesPerView={4}
|
||||
slidesPerView={slideCount}
|
||||
spaceBetween={20}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
onBeforeInit={(swiper) => {
|
||||
swiperRef.current = swiper;
|
||||
}}
|
||||
onBeforeResize={handleOnResize}
|
||||
onReachBeginning={handleOnReachBeginning}
|
||||
onReachEnd={handleOnReachEnd}
|
||||
onResize={throttledOnResize}
|
||||
onSlideChange={handleOnSlideChange}
|
||||
onZoomChange={handleOnZoomChange}
|
||||
{...swiperProps}
|
||||
|
||||
@@ -27,6 +27,7 @@ export * from './select';
|
||||
export * from './skeleton';
|
||||
export * from './slider';
|
||||
export * from './spinner';
|
||||
export * from './spoiler';
|
||||
export * from './switch';
|
||||
export * from './tabs';
|
||||
export * from './text';
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import clsx from 'clsx';
|
||||
import { HTMLAttributes, ReactNode, useRef, useState } from 'react';
|
||||
import styles from './spoiler.module.scss';
|
||||
import { useIsOverflow } from '/@/renderer/hooks';
|
||||
|
||||
interface SpoilerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: ReactNode;
|
||||
defaultOpened?: boolean;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export const Spoiler = ({ maxHeight, defaultOpened, children, ...props }: SpoilerProps) => {
|
||||
const ref = useRef(null);
|
||||
const isOverflow = useIsOverflow(ref);
|
||||
const [isExpanded, setIsExpanded] = useState(!!defaultOpened);
|
||||
|
||||
const spoilerClassNames = clsx(styles.spoiler, {
|
||||
[styles.canExpand]: isOverflow,
|
||||
[styles.isExpanded]: isExpanded,
|
||||
});
|
||||
|
||||
const handleToggleExpand = () => {
|
||||
setIsExpanded((val) => !val);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={spoilerClassNames}
|
||||
role="button"
|
||||
style={{ maxHeight: maxHeight ?? '100px' }}
|
||||
tabIndex={-1}
|
||||
onClick={handleToggleExpand}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
.control:hover {
|
||||
color: var(--btn-subtle-fg-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.spoiler {
|
||||
position: relative;
|
||||
text-align: justify;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spoiler:not(.is-expanded).can-expand:after {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: '';
|
||||
background: linear-gradient(to top, var(--main-bg) 10%, transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.spoiler.can-expand {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spoiler.is-expanded {
|
||||
max-height: 2500px !important;
|
||||
}
|
||||
@@ -30,7 +30,7 @@ const showToast = ({ type, ...props }: NotificationProps) => {
|
||||
? 'Error'
|
||||
: 'Info';
|
||||
|
||||
const defaultDuration = type === 'error' ? 2000 : 1000;
|
||||
const defaultDuration = type === 'error' ? 5000 : 2000;
|
||||
|
||||
return showNotification({
|
||||
autoClose: defaultDuration,
|
||||
|
||||
@@ -72,7 +72,7 @@ export const VirtualInfiniteGrid = forwardRef(
|
||||
const [itemData, setItemData] = useState<any[]>(fetchInitialData?.() || []);
|
||||
|
||||
const { itemHeight, rowCount, columnCount } = useMemo(() => {
|
||||
const itemsPerRow = width ? Math.floor(width / itemSize) : 5;
|
||||
const itemsPerRow = width ? Math.floor(width / (itemSize + itemGap * 2)) : 5;
|
||||
const widthPerItem = Number(width) / itemsPerRow;
|
||||
const itemHeight = widthPerItem + cardRows.length * 26;
|
||||
|
||||
@@ -81,7 +81,7 @@ export const VirtualInfiniteGrid = forwardRef(
|
||||
itemHeight,
|
||||
rowCount: Math.ceil(itemCount / itemsPerRow),
|
||||
};
|
||||
}, [cardRows.length, itemCount, itemSize, width]);
|
||||
}, [cardRows.length, itemCount, itemGap, itemSize, width]);
|
||||
|
||||
const isItemLoaded = useCallback(
|
||||
(index: number) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import styled from 'styled-components';
|
||||
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
|
||||
import { Text } from '/@/renderer/components/text';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { ServerType } from '/@/renderer/types';
|
||||
import { ServerType } from '/@/renderer/api/types';
|
||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
|
||||
const CellContainer = styled(motion.div)<{ height: number }>`
|
||||
|
||||
@@ -3,17 +3,7 @@ import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
|
||||
import { useMemo } from 'react';
|
||||
import { Text } from '/@/renderer/components/text';
|
||||
|
||||
const URL_REGEX =
|
||||
/((?:https?:\/\/)?(?:[\w-]{1,32}(?:\.[\w-]{1,32})+)(?:\/[\w\-./?%&=][^.|^\s]*)?)/g;
|
||||
|
||||
const replaceURLWithHTMLLinks = (text: string) => {
|
||||
const urlRegex = new RegExp(URL_REGEX, 'g');
|
||||
return text.replaceAll(
|
||||
urlRegex,
|
||||
(url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
|
||||
);
|
||||
};
|
||||
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||
|
||||
export const NoteCell = ({ value }: ICellRendererParams) => {
|
||||
const formattedValue = useMemo(() => {
|
||||
@@ -39,9 +29,10 @@ export const NoteCell = ({ value }: ICellRendererParams) => {
|
||||
<CellContainer $position="left">
|
||||
<Text
|
||||
$secondary
|
||||
dangerouslySetInnerHTML={{ __html: formattedValue }}
|
||||
overflow="hidden"
|
||||
/>
|
||||
>
|
||||
{formattedValue}
|
||||
</Text>
|
||||
</CellContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
LibraryItem,
|
||||
AnyLibraryItems,
|
||||
RatingResponse,
|
||||
ServerType,
|
||||
} from '/@/renderer/api/types';
|
||||
import { useSetAlbumListItemDataById, useSetQueueRating, getServerById } from '/@/renderer/store';
|
||||
import { ServerType } from '/@/renderer/types';
|
||||
|
||||
export const useUpdateRating = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -16,12 +16,12 @@ import orderBy from 'lodash/orderBy';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { QueryPagination, queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { BasePaginatedResponse, LibraryItem } from '/@/renderer/api/types';
|
||||
import { BasePaginatedResponse, LibraryItem, ServerListItem } from '/@/renderer/api/types';
|
||||
import { getColumnDefs, VirtualTableProps } from '/@/renderer/components/virtual-table';
|
||||
import { SetContextMenuItems, useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useListStoreActions } from '/@/renderer/store';
|
||||
import { ListDisplayType, ServerListItem, TablePagination } from '/@/renderer/types';
|
||||
import { ListDisplayType, TablePagination } from '/@/renderer/types';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { ListKey, useListStoreByKey } from '../../../store/list.store';
|
||||
|
||||
|
||||
@@ -158,6 +158,14 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
params.data ? params.data.channels : undefined,
|
||||
width: 100,
|
||||
},
|
||||
codec: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.CODEC,
|
||||
headerName: i18n.t('table.column.codec'),
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.container : undefined,
|
||||
width: 60,
|
||||
},
|
||||
comment: {
|
||||
cellRenderer: NoteCell,
|
||||
colId: TableColumn.COMMENT,
|
||||
|
||||
@@ -60,6 +60,10 @@ export const SONG_TABLE_COLUMNS = [
|
||||
label: i18n.t('table.config.label.bitrate', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.BIT_RATE,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.codec', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.CODEC,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.LAST_PLAYED,
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import isElectron from 'is-electron';
|
||||
import { FileInput, Text, Button } from '/@/renderer/components';
|
||||
import { FileInput, Text, Button, Checkbox } from '/@/renderer/components';
|
||||
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store';
|
||||
import { PlaybackType } from '/@/renderer/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const localSettings = isElectron() ? window.electron.localSettings : null;
|
||||
|
||||
export const MpvRequired = () => {
|
||||
const [mpvPath, setMpvPath] = useState('');
|
||||
const settings = usePlaybackSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSetMpvPath = (e: File) => {
|
||||
localSettings?.set('mpv_path', e.path);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const getMpvPath = async () => {
|
||||
if (!localSettings) return setMpvPath('');
|
||||
const mpvPath = localSettings.get('mpv_path') as string;
|
||||
return setMpvPath(mpvPath);
|
||||
};
|
||||
const handleSetDisableMpv = (disabled: boolean) => {
|
||||
setDisabled(disabled);
|
||||
localSettings?.set('disable_mpv', disabled);
|
||||
|
||||
getMpvPath();
|
||||
setSettings({
|
||||
playback: { ...settings, type: disabled ? PlaybackType.WEB : PlaybackType.LOCAL },
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!localSettings) return setMpvPath('');
|
||||
const mpvPath = localSettings.get('mpv_path') as string;
|
||||
return setMpvPath(mpvPath);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -34,9 +47,15 @@ export const MpvRequired = () => {
|
||||
</a>
|
||||
</Text>
|
||||
<FileInput
|
||||
disabled={disabled}
|
||||
placeholder={mpvPath}
|
||||
onChange={handleSetMpvPath}
|
||||
/>
|
||||
<Text>{t('setting.disable_mpv', { context: 'description' })}</Text>
|
||||
<Checkbox
|
||||
label={t('setting.disableMpv')}
|
||||
onChange={(e) => handleSetDisableMpv(e.currentTarget.checked)}
|
||||
/>
|
||||
<Button onClick={() => localSettings?.restart()}>Restart</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,48 +1,22 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Center, Group, Stack } from '@mantine/core';
|
||||
import isElectron from 'is-electron';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiCheckFill } from 'react-icons/ri';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, PageHeader, Text } from '/@/renderer/components';
|
||||
import { ActionRequiredContainer } from '/@/renderer/features/action-required/components/action-required-container';
|
||||
import { MpvRequired } from '/@/renderer/features/action-required/components/mpv-required';
|
||||
import { ServerCredentialRequired } from '/@/renderer/features/action-required/components/server-credential-required';
|
||||
import { ServerRequired } from '/@/renderer/features/action-required/components/server-required';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
const localSettings = isElectron() ? window.electron.localSettings : null;
|
||||
|
||||
const ActionRequiredRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
const currentServer = useCurrentServer();
|
||||
const [isMpvRequired, setIsMpvRequired] = useState(false);
|
||||
const isServerRequired = !currentServer;
|
||||
const isCredentialRequired = false;
|
||||
|
||||
useEffect(() => {
|
||||
const getMpvPath = async () => {
|
||||
if (!localSettings) return setIsMpvRequired(false);
|
||||
const mpvPath = await localSettings.get('mpv_path');
|
||||
|
||||
if (mpvPath) {
|
||||
return setIsMpvRequired(false);
|
||||
}
|
||||
|
||||
return setIsMpvRequired(true);
|
||||
};
|
||||
|
||||
getMpvPath();
|
||||
}, []);
|
||||
const isCredentialRequired = currentServer && !currentServer.credential;
|
||||
|
||||
const checks = [
|
||||
{
|
||||
component: <MpvRequired />,
|
||||
title: t('error.mpvRequired', { postProcess: 'sentenceCase' }),
|
||||
valid: !isMpvRequired,
|
||||
},
|
||||
{
|
||||
component: <ServerCredentialRequired />,
|
||||
title: t('error.credentialsRequired', { postProcess: 'sentenceCase' }),
|
||||
@@ -76,7 +50,6 @@ const ActionRequiredRoute = () => {
|
||||
<Stack mt="2rem">
|
||||
{canReturnHome && (
|
||||
<>
|
||||
<Navigate to={AppRoute.HOME} />
|
||||
<Group
|
||||
noWrap
|
||||
position="center"
|
||||
|
||||
@@ -4,13 +4,15 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
||||
import { Box, Group, Stack } from '@mantine/core';
|
||||
import { useSetState } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaLastfmSquare } from 'react-icons/fa';
|
||||
import { RiHeartFill, RiHeartLine, RiMoreFill, RiSettings2Fill } from 'react-icons/ri';
|
||||
import { SiMusicbrainz } from 'react-icons/si';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types';
|
||||
import { Button, Popover } from '/@/renderer/components';
|
||||
import { Button, Popover, Spoiler } from '/@/renderer/components';
|
||||
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
|
||||
import {
|
||||
TableConfigDropdown,
|
||||
@@ -36,11 +38,13 @@ import { useAppFocus, useContainerQuery } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useCurrentSong, useCurrentStatus } from '/@/renderer/store';
|
||||
import {
|
||||
useGeneralSettings,
|
||||
usePlayButtonBehavior,
|
||||
useSettingsStoreActions,
|
||||
useTableSettings,
|
||||
} from '/@/renderer/store/settings.store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||
|
||||
const isFullWidthRow = (node: RowNode) => {
|
||||
return node.id?.startsWith('disc-');
|
||||
@@ -54,6 +58,7 @@ const ContentContainer = styled.div`
|
||||
const DetailContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
`;
|
||||
@@ -75,6 +80,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
const status = useCurrentStatus();
|
||||
const isFocused = useAppFocus();
|
||||
const currentSong = useCurrentSong();
|
||||
const { externalLinks } = useGeneralSettings();
|
||||
|
||||
const columnDefs = useMemo(
|
||||
() => getColumnDefs(tableConfig.columns, false, 'albumDetail'),
|
||||
@@ -279,6 +285,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
};
|
||||
|
||||
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
|
||||
const comment = detailQuery?.data?.comment;
|
||||
|
||||
const handleGeneralContextMenu = useHandleGeneralContextMenu(
|
||||
LibraryItem.ALBUM,
|
||||
@@ -313,6 +320,8 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
|
||||
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
|
||||
|
||||
const mbzId = detailQuery?.data?.mbzId;
|
||||
|
||||
return (
|
||||
<ContentContainer>
|
||||
<LibraryBackgroundOverlay $backgroundColor={background} />
|
||||
@@ -320,7 +329,6 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
<Box component="section">
|
||||
<Group
|
||||
position="apart"
|
||||
py="1rem"
|
||||
spacing="sm"
|
||||
>
|
||||
<Group>
|
||||
@@ -372,10 +380,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
</Group>
|
||||
</Box>
|
||||
{showGenres && (
|
||||
<Box
|
||||
component="section"
|
||||
py="1rem"
|
||||
>
|
||||
<Box component="section">
|
||||
<Group spacing="sm">
|
||||
{detailQuery?.data?.genres?.map((genre) => (
|
||||
<Button
|
||||
@@ -395,6 +400,51 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
{externalLinks ? (
|
||||
<Box component="section">
|
||||
<Group spacing="sm">
|
||||
<Button
|
||||
compact
|
||||
component="a"
|
||||
href={`https://www.last.fm/music/${encodeURIComponent(
|
||||
detailQuery?.data?.albumArtist || '',
|
||||
)}/${encodeURIComponent(detailQuery.data?.name || '')}`}
|
||||
radius="md"
|
||||
rel="noopener noreferrer"
|
||||
size="md"
|
||||
target="_blank"
|
||||
tooltip={{
|
||||
label: t('action.openIn.lastfm'),
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<FaLastfmSquare size={25} />
|
||||
</Button>
|
||||
{mbzId ? (
|
||||
<Button
|
||||
compact
|
||||
component="a"
|
||||
href={`https://musicbrainz.org/release/${mbzId}`}
|
||||
radius="md"
|
||||
rel="noopener noreferrer"
|
||||
size="md"
|
||||
target="_blank"
|
||||
tooltip={{
|
||||
label: t('action.openIn.musicbrainz'),
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<SiMusicbrainz size={25} />
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Box>
|
||||
) : null}
|
||||
{comment && (
|
||||
<Box component="section">
|
||||
<Spoiler maxHeight={75}>{replaceURLWithHTMLLinks(comment)}</Spoiler>
|
||||
</Box>
|
||||
)}
|
||||
<Box style={{ minHeight: '300px' }}>
|
||||
<VirtualTable
|
||||
key={`table-${tableConfig.rowHeight}`}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
RiSettings3Fill,
|
||||
} from 'react-icons/ri';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { AlbumListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
|
||||
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
useListStoreActions,
|
||||
useListStoreByKey,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types';
|
||||
import { ListDisplayType, Play, TableColumn } from '/@/renderer/types';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
const FILTERS = {
|
||||
@@ -73,7 +73,7 @@ const FILTERS = {
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
|
||||
name: i18n.t('filter.releaseDate', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RELEASE_DATE,
|
||||
},
|
||||
],
|
||||
@@ -538,7 +538,9 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
||||
Table (paginated)
|
||||
</DropdownMenu.Item> */}
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>Item size</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.itemSize', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight}
|
||||
@@ -549,7 +551,11 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
||||
</DropdownMenu.Item>
|
||||
{isGrid && (
|
||||
<>
|
||||
<DropdownMenu.Label>Item gap</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.itemGap', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={grid?.itemGap || 0}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||
import { Box, Group, Stack } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaLastfmSquare } from 'react-icons/fa';
|
||||
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
import { SiMusicbrainz } from 'react-icons/si';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { createSearchParams, Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
@@ -14,7 +17,7 @@ import {
|
||||
ServerType,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { Button, Text, TextTitle } from '/@/renderer/components';
|
||||
import { Button, Spoiler, TextTitle } from '/@/renderer/components';
|
||||
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
|
||||
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||
@@ -34,7 +37,7 @@ import { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { useGeneralSettings, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { CardRow, Play, TableColumn } from '/@/renderer/types';
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
@@ -45,7 +48,7 @@ const ContentContainer = styled.div`
|
||||
const DetailContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
gap: 2rem;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -59,6 +62,8 @@ interface AlbumArtistDetailContentProps {
|
||||
}
|
||||
|
||||
export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { externalLinks } = useGeneralSettings();
|
||||
const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||
const cq = useContainerQuery();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
@@ -208,7 +213,9 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
Recent releases
|
||||
{t('page.albumArtistDetail.recentReleases', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</TextTitle>
|
||||
<Button
|
||||
compact
|
||||
@@ -217,7 +224,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
to={artistDiscographyLink}
|
||||
variant="subtle"
|
||||
>
|
||||
View discography
|
||||
{t('page.albumArtistDetail.viewDiscography')}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
@@ -233,7 +240,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
Appears on
|
||||
{t('page.albumArtistDetail.appearsOn', { postProcess: 'sentenceCase' })}
|
||||
</TextTitle>
|
||||
),
|
||||
uniqueId: 'compilationAlbums',
|
||||
@@ -247,7 +254,9 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
Related artists
|
||||
{t('page.albumArtistDetail.relatedArtists', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</TextTitle>
|
||||
),
|
||||
uniqueId: 'similarArtists',
|
||||
@@ -262,6 +271,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
recentAlbumsQuery?.data?.items,
|
||||
recentAlbumsQuery.isFetching,
|
||||
recentAlbumsQuery?.isLoading,
|
||||
t,
|
||||
]);
|
||||
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
@@ -324,6 +334,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
detailQuery?.data?.biography !== undefined && detailQuery?.data?.biography !== null;
|
||||
const showTopSongs = topSongsQuery?.data?.items?.length;
|
||||
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
|
||||
const mbzId = detailQuery?.data?.mbz;
|
||||
|
||||
const isLoading =
|
||||
detailQuery?.isLoading ||
|
||||
@@ -335,61 +346,58 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
<ContentContainer ref={cq.ref}>
|
||||
<LibraryBackgroundOverlay $backgroundColor={background} />
|
||||
<DetailContainer>
|
||||
<Stack spacing="lg">
|
||||
<Group spacing="md">
|
||||
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<Group spacing="xs">
|
||||
<Button
|
||||
compact
|
||||
loading={
|
||||
createFavoriteMutation.isLoading ||
|
||||
deleteFavoriteMutation.isLoading
|
||||
}
|
||||
variant="subtle"
|
||||
onClick={handleFavorite}
|
||||
>
|
||||
{detailQuery?.data?.userFavorite ? (
|
||||
<RiHeartFill
|
||||
color="red"
|
||||
size={20}
|
||||
/>
|
||||
) : (
|
||||
<RiHeartLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Group spacing="md">
|
||||
<Group spacing="md">
|
||||
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<Group spacing="xs">
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
component={Link}
|
||||
to={artistDiscographyLink}
|
||||
loading={
|
||||
createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading
|
||||
}
|
||||
variant="subtle"
|
||||
onClick={handleFavorite}
|
||||
>
|
||||
View discography
|
||||
{detailQuery?.data?.userFavorite ? (
|
||||
<RiHeartFill
|
||||
color="red"
|
||||
size={20}
|
||||
/>
|
||||
) : (
|
||||
<RiHeartLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
component={Link}
|
||||
to={artistSongsLink}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
>
|
||||
View all songs
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group spacing="md">
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
component={Link}
|
||||
to={artistDiscographyLink}
|
||||
variant="subtle"
|
||||
>
|
||||
{t('page.albumArtistDetail.viewDiscography')}
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
component={Link}
|
||||
to={artistSongsLink}
|
||||
variant="subtle"
|
||||
>
|
||||
{t('page.albumArtistDetail.viewAllTracks')}
|
||||
</Button>
|
||||
</Group>
|
||||
{showGenres ? (
|
||||
<Box component="section">
|
||||
<Group spacing="sm">
|
||||
@@ -411,6 +419,46 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
</Group>
|
||||
</Box>
|
||||
) : null}
|
||||
{externalLinks ? (
|
||||
<Box component="section">
|
||||
<Group spacing="sm">
|
||||
<Button
|
||||
compact
|
||||
component="a"
|
||||
href={`https://www.last.fm/music/${encodeURIComponent(
|
||||
detailQuery?.data?.name || '',
|
||||
)}`}
|
||||
radius="md"
|
||||
rel="noopener noreferrer"
|
||||
size="md"
|
||||
target="_blank"
|
||||
tooltip={{
|
||||
label: t('action.openIn.lastfm'),
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<FaLastfmSquare size={25} />
|
||||
</Button>
|
||||
{mbzId ? (
|
||||
<Button
|
||||
compact
|
||||
component="a"
|
||||
href={`https://musicbrainz.org/artist/${mbzId}`}
|
||||
radius="md"
|
||||
rel="noopener noreferrer"
|
||||
size="md"
|
||||
target="_blank"
|
||||
tooltip={{
|
||||
label: t('action.openIn.musicbrainz'),
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<SiMusicbrainz size={25} />
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Box>
|
||||
) : null}
|
||||
{showBiography ? (
|
||||
<Box
|
||||
component="section"
|
||||
@@ -420,13 +468,12 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
About {detailQuery?.data?.name}
|
||||
{t('page.albumArtistDetail.about', {
|
||||
artist: detailQuery?.data?.name,
|
||||
})}
|
||||
</TextTitle>
|
||||
<Text
|
||||
$secondary
|
||||
component="p"
|
||||
<Spoiler
|
||||
dangerouslySetInnerHTML={{ __html: detailQuery?.data?.biography || '' }}
|
||||
sx={{ textAlign: 'justify' }}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
@@ -444,7 +491,9 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
Top Songs
|
||||
{t('page.albumArtistDetail.topSongs', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</TextTitle>
|
||||
<Button
|
||||
compact
|
||||
@@ -458,7 +507,9 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
)}
|
||||
variant="subtle"
|
||||
>
|
||||
View all
|
||||
{t('page.albumArtistDetail.viewAll', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
+4
-2
@@ -33,7 +33,9 @@ export const AlbumArtistDetailTopSongsListHeader = ({
|
||||
<PageHeader p="1rem">
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<LibraryHeaderBar.Title>Top songs from {title}</LibraryHeaderBar.Title>
|
||||
<LibraryHeaderBar.Title>
|
||||
{t('page.albumArtistDetail.topSongsFrom', { title })}
|
||||
</LibraryHeaderBar.Title>
|
||||
<Paper
|
||||
fw="600"
|
||||
px="1rem"
|
||||
@@ -57,7 +59,7 @@ export const AlbumArtistDetailTopSongsListHeader = ({
|
||||
icon={<RiPlayFill />}
|
||||
onClick={() => handlePlay(Play.NOW)}
|
||||
>
|
||||
{t('player.add', { postProcess: 'sentenceCase' })}
|
||||
{t('player.play', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddBoxFill />}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react
|
||||
import { useListContext } from '../../../context/list-context';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { AlbumArtistListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
|
||||
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
useListStoreActions,
|
||||
useListStoreByKey,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, ServerType, TableColumn } from '/@/renderer/types';
|
||||
import { ListDisplayType, TableColumn } from '/@/renderer/types';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
const FILTERS = {
|
||||
@@ -472,7 +472,9 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
Table (paginated)
|
||||
</DropdownMenu.Item> */}
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>Item size</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.itemSize', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
{display === ListDisplayType.CARD ||
|
||||
display === ListDisplayType.POSTER ? (
|
||||
@@ -493,7 +495,11 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
</DropdownMenu.Item>
|
||||
{isGrid && (
|
||||
<>
|
||||
<DropdownMenu.Label>Item gap</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.itemGap', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={grid?.itemGap || 0}
|
||||
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
usePlayerStore,
|
||||
useQueueControls,
|
||||
} from '/@/renderer/store';
|
||||
import { usePlayerType } from '/@/renderer/store/settings.store';
|
||||
import { usePlaybackType } from '/@/renderer/store/settings.store';
|
||||
import { Play, PlaybackType } from '/@/renderer/types';
|
||||
|
||||
type ContextMenuContextProps = {
|
||||
@@ -522,7 +522,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
|
||||
const handleUpdateRating = useCallback(
|
||||
(rating: number) => {
|
||||
if (!ctx.dataNodes || !ctx.data) return;
|
||||
if (!ctx.dataNodes && !ctx.data) return;
|
||||
|
||||
let uniqueServerIds: string[] = [];
|
||||
let items: AnyLibraryItems = [];
|
||||
@@ -575,7 +575,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
[ctx.data, ctx.dataNodes, updateRatingMutation],
|
||||
);
|
||||
|
||||
const playerType = usePlayerType();
|
||||
const playbackType = usePlaybackType();
|
||||
const { moveToBottomOfQueue, moveToTopOfQueue, removeFromQueue } = useQueueControls();
|
||||
|
||||
const handleMoveToBottom = useCallback(() => {
|
||||
@@ -584,10 +584,10 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
|
||||
const playerData = moveToBottomOfQueue(uniqueIds);
|
||||
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
mpvPlayer!.setQueueNext(playerData);
|
||||
}
|
||||
}, [ctx.dataNodes, moveToBottomOfQueue, playerType]);
|
||||
}, [ctx.dataNodes, moveToBottomOfQueue, playbackType]);
|
||||
|
||||
const handleMoveToTop = useCallback(() => {
|
||||
const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId);
|
||||
@@ -595,10 +595,10 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
|
||||
const playerData = moveToTopOfQueue(uniqueIds);
|
||||
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
mpvPlayer!.setQueueNext(playerData);
|
||||
}
|
||||
}, [ctx.dataNodes, moveToTopOfQueue, playerType]);
|
||||
}, [ctx.dataNodes, moveToTopOfQueue, playbackType]);
|
||||
|
||||
const handleRemoveSelected = useCallback(() => {
|
||||
const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId);
|
||||
@@ -608,7 +608,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
const playerData = removeFromQueue(uniqueIds);
|
||||
const isCurrentSongRemoved = currentSong && uniqueIds.includes(currentSong?.uniqueId);
|
||||
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
if (isCurrentSongRemoved) {
|
||||
mpvPlayer!.setQueue(playerData);
|
||||
} else {
|
||||
@@ -621,7 +621,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
if (isCurrentSongRemoved) {
|
||||
remote?.updateSong({ song: playerData.current.song });
|
||||
}
|
||||
}, [ctx.dataNodes, ctx.tableApi, playerType, removeFromQueue]);
|
||||
}, [ctx.dataNodes, ctx.tableApi, playbackType, removeFromQueue]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
ctx.tableApi?.deselectAll();
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
usePlayerStore,
|
||||
} from '/@/renderer/store';
|
||||
import { SetActivity } from '@xhayper/discord-rpc';
|
||||
import { PlayerStatus, ServerType } from '/@/renderer/types';
|
||||
import { PlayerStatus } from '/@/renderer/types';
|
||||
import { ServerType } from '/@/renderer/api/types';
|
||||
|
||||
const discordRpc = isElectron() ? window.electron.discordRpc : null;
|
||||
|
||||
@@ -40,7 +41,7 @@ export const useDiscordRpc = () => {
|
||||
largeImageText: currentSong?.album || 'Unknown album',
|
||||
smallImageKey: undefined,
|
||||
smallImageText: currentStatus,
|
||||
state: artists && `By ${artists}` || "Unknown artist",
|
||||
state: (artists && `By ${artists}`) || 'Unknown artist',
|
||||
};
|
||||
|
||||
if (currentStatus === PlayerStatus.PLAYING) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiFolder2Fill, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { GenreListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
|
||||
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
useListStoreActions,
|
||||
useListStoreByKey,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, ServerType, TableColumn } from '/@/renderer/types';
|
||||
import { ListDisplayType, TableColumn } from '/@/renderer/types';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
const FILTERS = {
|
||||
|
||||
@@ -12,7 +12,12 @@ import { useAlbumList } from '/@/renderer/features/albums';
|
||||
import { useRecentlyPlayed } from '/@/renderer/features/home/queries/recently-played-query';
|
||||
import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useWindowSettings } from '/@/renderer/store';
|
||||
import {
|
||||
HomeItem,
|
||||
useCurrentServer,
|
||||
useGeneralSettings,
|
||||
useWindowSettings,
|
||||
} from '/@/renderer/store';
|
||||
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
|
||||
import { Platform } from '/@/renderer/types';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
@@ -28,6 +33,7 @@ const HomeRoute = () => {
|
||||
const server = useCurrentServer();
|
||||
const itemsPerPage = 15;
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const { homeItems } = useGeneralSettings();
|
||||
|
||||
const feature = useAlbumList({
|
||||
options: {
|
||||
@@ -129,16 +135,15 @@ const HomeRoute = () => {
|
||||
return <Spinner container />;
|
||||
}
|
||||
|
||||
const carousels = [
|
||||
{
|
||||
const carousels = {
|
||||
[HomeItem.RANDOM]: {
|
||||
data: random?.data?.items,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
title: t('page.home.explore', { postProcess: 'sentenceCase' }),
|
||||
uniqueId: 'random',
|
||||
},
|
||||
{
|
||||
[HomeItem.RECENTLY_PLAYED]: {
|
||||
data: recentlyPlayed?.data?.items,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
pagination: {
|
||||
@@ -147,9 +152,8 @@ const HomeRoute = () => {
|
||||
sortBy: AlbumListSort.RECENTLY_PLAYED,
|
||||
sortOrder: SortOrder.DESC,
|
||||
title: t('page.home.recentlyPlayed', { postProcess: 'sentenceCase' }),
|
||||
uniqueId: 'recentlyPlayed',
|
||||
},
|
||||
{
|
||||
[HomeItem.RECENTLY_ADDED]: {
|
||||
data: recentlyAdded?.data?.items,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
pagination: {
|
||||
@@ -158,9 +162,8 @@ const HomeRoute = () => {
|
||||
sortBy: AlbumListSort.RECENTLY_ADDED,
|
||||
sortOrder: SortOrder.DESC,
|
||||
title: t('page.home.newlyAdded', { postProcess: 'sentenceCase' }),
|
||||
uniqueId: 'recentlyAdded',
|
||||
},
|
||||
{
|
||||
[HomeItem.MOST_PLAYED]: {
|
||||
data:
|
||||
server?.type === ServerType.JELLYFIN
|
||||
? mostPlayedSongs?.data?.items
|
||||
@@ -175,9 +178,24 @@ const HomeRoute = () => {
|
||||
: AlbumListSort.PLAY_COUNT,
|
||||
sortOrder: SortOrder.DESC,
|
||||
title: t('page.home.mostPlayed', { postProcess: 'sentenceCase' }),
|
||||
uniqueId: 'mostPlayed',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const sortedCarousel = homeItems
|
||||
.filter((item) => {
|
||||
if (item.disabled) {
|
||||
return false;
|
||||
}
|
||||
if (server?.type === ServerType.JELLYFIN && item.id === HomeItem.RECENTLY_PLAYED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((item) => ({
|
||||
...carousels[item.id],
|
||||
uniqueId: item.id,
|
||||
}));
|
||||
|
||||
const invalidateCarouselQuery = (carousel: {
|
||||
itemType: LibraryItem;
|
||||
@@ -232,69 +250,76 @@ const HomeRoute = () => {
|
||||
spacing="lg"
|
||||
>
|
||||
<FeatureCarousel data={featureItemsWithImage} />
|
||||
{carousels
|
||||
.filter((carousel) => {
|
||||
if (
|
||||
server?.type === ServerType.JELLYFIN &&
|
||||
carousel.uniqueId === 'recentlyPlayed'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return carousel;
|
||||
})
|
||||
.map((carousel) => (
|
||||
<MemoizedSwiperGridCarousel
|
||||
key={`carousel-${carousel.uniqueId}`}
|
||||
cardRows={[
|
||||
{
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
},
|
||||
{sortedCarousel.map((carousel) => (
|
||||
<MemoizedSwiperGridCarousel
|
||||
key={`carousel-${carousel.uniqueId}`}
|
||||
cardRows={[
|
||||
{
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [
|
||||
{
|
||||
idProperty:
|
||||
server?.type === ServerType.JELLYFIN &&
|
||||
carousel.itemType === LibraryItem.SONG
|
||||
? 'albumId'
|
||||
: 'id',
|
||||
slugProperty: 'albumId',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
arrayProperty: 'name',
|
||||
property: 'albumArtists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [
|
||||
{
|
||||
idProperty: 'id',
|
||||
slugProperty: 'albumArtistId',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
arrayProperty: 'name',
|
||||
property: 'albumArtists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [
|
||||
{
|
||||
idProperty: 'id',
|
||||
slugProperty: 'albumArtistId',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
data={carousel.data}
|
||||
itemType={carousel.itemType}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
}}
|
||||
title={{
|
||||
label: (
|
||||
<Group>
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
{carousel.title}
|
||||
</TextTitle>
|
||||
},
|
||||
]}
|
||||
data={carousel.data}
|
||||
itemType={carousel.itemType}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [
|
||||
{
|
||||
idProperty:
|
||||
server?.type === ServerType.JELLYFIN &&
|
||||
carousel.itemType === LibraryItem.SONG
|
||||
? 'albumId'
|
||||
: 'id',
|
||||
slugProperty: 'albumId',
|
||||
},
|
||||
],
|
||||
}}
|
||||
title={{
|
||||
label: (
|
||||
<Group>
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
{carousel.title}
|
||||
</TextTitle>
|
||||
|
||||
<ActionIcon
|
||||
onClick={() => invalidateCarouselQuery(carousel)}
|
||||
>
|
||||
<RiRefreshLine />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
}}
|
||||
uniqueId={carousel.uniqueId}
|
||||
/>
|
||||
))}
|
||||
<ActionIcon
|
||||
onClick={() => invalidateCarouselQuery(carousel)}
|
||||
>
|
||||
<RiRefreshLine />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
}}
|
||||
uniqueId={carousel.uniqueId}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</NativeScrollArea>
|
||||
</AnimatedPage>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Group } from '@mantine/core';
|
||||
import { Box, Center, Group, Select, SelectItem } from '@mantine/core';
|
||||
import isElectron from 'is-electron';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiAddFill, RiSubtractFill } from 'react-icons/ri';
|
||||
@@ -13,15 +13,22 @@ import {
|
||||
} from '/@/renderer/store';
|
||||
|
||||
interface LyricsActionsProps {
|
||||
index: number;
|
||||
languages: SelectItem[];
|
||||
|
||||
onRemoveLyric: () => void;
|
||||
onResetLyric: () => void;
|
||||
onSearchOverride: (params: LyricsOverride) => void;
|
||||
setIndex: (idx: number) => void;
|
||||
}
|
||||
|
||||
export const LyricsActions = ({
|
||||
index,
|
||||
languages,
|
||||
onRemoveLyric,
|
||||
onResetLyric,
|
||||
onSearchOverride,
|
||||
setIndex,
|
||||
}: LyricsActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const currentSong = useCurrentSong();
|
||||
@@ -42,6 +49,18 @@ export const LyricsActions = ({
|
||||
|
||||
return (
|
||||
<Box style={{ position: 'relative', width: '100%' }}>
|
||||
{languages.length > 1 && (
|
||||
<Center>
|
||||
<Select
|
||||
clearable={false}
|
||||
data={languages}
|
||||
style={{ bottom: 30, position: 'absolute' }}
|
||||
value={index.toString()}
|
||||
onChange={(value) => setIndex(parseInt(value!, 10))}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
<Group position="center">
|
||||
{isDesktop && sources.length ? (
|
||||
<Button
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Center, Group } from '@mantine/core';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { RiInformationFill } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { useSongLyricsByRemoteId, useSongLyricsBySong } from './queries/lyric-query';
|
||||
import { SynchronizedLyrics } from './synchronized-lyrics';
|
||||
import { SynchronizedLyrics, SynchronizedLyricsProps } from './synchronized-lyrics';
|
||||
import { Spinner, TextTitle } from '/@/renderer/components';
|
||||
import { ErrorFallback } from '/@/renderer/features/action-required';
|
||||
import { UnsynchronizedLyrics } from '/@/renderer/features/lyrics/unsynchronized-lyrics';
|
||||
import { useCurrentSong, usePlayerStore } from '/@/renderer/store';
|
||||
import {
|
||||
FullLyricsMetadata,
|
||||
LyricsOverride,
|
||||
SynchronizedLyricMetadata,
|
||||
UnsynchronizedLyricMetadata,
|
||||
} from '/@/renderer/api/types';
|
||||
UnsynchronizedLyrics,
|
||||
UnsynchronizedLyricsProps,
|
||||
} from '/@/renderer/features/lyrics/unsynchronized-lyrics';
|
||||
import { useCurrentSong, usePlayerStore } from '/@/renderer/store';
|
||||
import { FullLyricsMetadata, LyricSource, LyricsOverride } from '/@/renderer/api/types';
|
||||
import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
@@ -84,17 +82,9 @@ const ScrollContainer = styled(motion.div)`
|
||||
}
|
||||
`;
|
||||
|
||||
function isSynchronized(
|
||||
data: Partial<FullLyricsMetadata> | undefined,
|
||||
): data is SynchronizedLyricMetadata {
|
||||
// Type magic. The only difference between Synchronized and Unsynchhronized is
|
||||
// the datatype of lyrics. This makes Typescript happier later...
|
||||
if (!data) return false;
|
||||
return Array.isArray(data.lyrics);
|
||||
}
|
||||
|
||||
export const Lyrics = () => {
|
||||
const currentSong = useCurrentSong();
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
const { data, isInitialLoading } = useSongLyricsBySong(
|
||||
{
|
||||
@@ -139,7 +129,7 @@ export const Lyrics = () => {
|
||||
},
|
||||
query: {
|
||||
remoteSongId: override?.id,
|
||||
remoteSource: override?.source,
|
||||
remoteSource: override?.source as LyricSource | undefined,
|
||||
song: currentSong,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
@@ -150,6 +140,7 @@ export const Lyrics = () => {
|
||||
(state) => state.current.song,
|
||||
() => {
|
||||
setOverride(undefined);
|
||||
setIndex(0);
|
||||
},
|
||||
{ equalityFn: (a, b) => a?.id === b?.id },
|
||||
);
|
||||
@@ -159,16 +150,29 @@ export const Lyrics = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [lyrics, synced] = useMemo(() => {
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length > 0) {
|
||||
const selectedLyric = data[Math.min(index, data.length)];
|
||||
return [selectedLyric, selectedLyric.synced];
|
||||
}
|
||||
} else if (data?.lyrics) {
|
||||
return [data, Array.isArray(data.lyrics)];
|
||||
}
|
||||
|
||||
return [undefined, false];
|
||||
}, [data, index]);
|
||||
|
||||
const languages = useMemo(() => {
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((lyric, idx) => ({ label: lyric.lang, value: idx.toString() }));
|
||||
}
|
||||
return [];
|
||||
}, [data]);
|
||||
|
||||
const isLoadingLyrics = isInitialLoading || isOverrideLoading;
|
||||
|
||||
const hasNoLyrics = !data?.lyrics;
|
||||
|
||||
const lyricsMetadata:
|
||||
| Partial<SynchronizedLyricMetadata>
|
||||
| Partial<UnsynchronizedLyricMetadata>
|
||||
| undefined = data;
|
||||
|
||||
const isSynchronizedLyrics = isSynchronized(lyricsMetadata);
|
||||
const hasNoLyrics = !lyrics;
|
||||
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
@@ -198,11 +202,11 @@ export const Lyrics = () => {
|
||||
initial={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{isSynchronizedLyrics ? (
|
||||
<SynchronizedLyrics {...lyricsMetadata} />
|
||||
{synced ? (
|
||||
<SynchronizedLyrics {...(lyrics as SynchronizedLyricsProps)} />
|
||||
) : (
|
||||
<UnsynchronizedLyrics
|
||||
{...(lyricsMetadata as UnsynchronizedLyricMetadata)}
|
||||
{...(lyrics as UnsynchronizedLyricsProps)}
|
||||
/>
|
||||
)}
|
||||
</ScrollContainer>
|
||||
@@ -211,6 +215,9 @@ export const Lyrics = () => {
|
||||
)}
|
||||
<ActionsContainer>
|
||||
<LyricsActions
|
||||
index={index}
|
||||
languages={languages}
|
||||
setIndex={setIndex}
|
||||
onRemoveLyric={handleOnRemoveLyric}
|
||||
onResetLyric={handleOnResetLyric}
|
||||
onSearchOverride={handleOnSearchOverride}
|
||||
|
||||
@@ -6,13 +6,16 @@ import {
|
||||
InternetProviderLyricResponse,
|
||||
FullLyricsMetadata,
|
||||
LyricGetQuery,
|
||||
StructuredLyric,
|
||||
ServerType,
|
||||
} from '/@/renderer/api/types';
|
||||
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById, useLyricsSettings } from '/@/renderer/store';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { ServerType } from '/@/renderer/types';
|
||||
import { api } from '/@/renderer/api';
|
||||
import isElectron from 'is-electron';
|
||||
import { hasFeature } from '/@/renderer/api/utils';
|
||||
import { ServerFeature } from '/@/renderer/api/features-types';
|
||||
|
||||
const lyricsIpc = isElectron() ? window.electron.lyrics : null;
|
||||
|
||||
@@ -80,7 +83,7 @@ export const useServerLyrics = (
|
||||
export const useSongLyricsBySong = (
|
||||
args: QueryHookArgs<LyricsQuery>,
|
||||
song: QueueSong | undefined,
|
||||
): UseQueryResult<FullLyricsMetadata> => {
|
||||
): UseQueryResult<FullLyricsMetadata | StructuredLyric[]> => {
|
||||
const { query } = args;
|
||||
const { fetch } = useLyricsSettings();
|
||||
const server = getServerById(song?.serverId);
|
||||
@@ -89,21 +92,22 @@ export const useSongLyricsBySong = (
|
||||
cacheTime: Infinity,
|
||||
enabled: !!song && !!server,
|
||||
onError: () => {},
|
||||
queryFn: async ({ signal }) => {
|
||||
queryFn: async ({ signal }): Promise<FullLyricsMetadata | StructuredLyric[] | null> => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
if (!song) return null;
|
||||
|
||||
if (song.lyrics) {
|
||||
return {
|
||||
artist: song.artists?.[0]?.name,
|
||||
lyrics: formatLyrics(song.lyrics),
|
||||
name: song.name,
|
||||
remote: false,
|
||||
source: server?.name ?? 'music server',
|
||||
};
|
||||
}
|
||||
if (hasFeature(server, ServerFeature.LYRICS_MULTIPLE_STRUCTURED)) {
|
||||
const subsonicLyrics = await api.controller
|
||||
.getStructuredLyrics({
|
||||
apiClientProps: { server, signal },
|
||||
query: { songId: song.id },
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
if (server.type === ServerType.JELLYFIN) {
|
||||
if (subsonicLyrics) {
|
||||
return subsonicLyrics;
|
||||
}
|
||||
} else if (hasFeature(server, ServerFeature.LYRICS_SINGLE_STRUCTURED)) {
|
||||
const jfLyrics = await api.controller
|
||||
.getLyrics({
|
||||
apiClientProps: { server, signal },
|
||||
@@ -120,6 +124,14 @@ export const useSongLyricsBySong = (
|
||||
source: server?.name ?? 'music server',
|
||||
};
|
||||
}
|
||||
} else if (song.lyrics) {
|
||||
return {
|
||||
artist: song.artists?.[0]?.name,
|
||||
lyrics: formatLyrics(song.lyrics),
|
||||
name: song.name,
|
||||
remote: false,
|
||||
source: server?.name ?? 'music server',
|
||||
};
|
||||
}
|
||||
|
||||
if (fetch) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
useCurrentStatus,
|
||||
useCurrentTime,
|
||||
useLyricsSettings,
|
||||
usePlayerType,
|
||||
usePlaybackType,
|
||||
useSeeked,
|
||||
} from '/@/renderer/store';
|
||||
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
|
||||
@@ -21,7 +21,7 @@ const SynchronizedLyricsContainer = styled.div<{ $gap: number }>`
|
||||
gap: ${(props) => props.$gap || 5}px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10vh 0 6vh;
|
||||
padding: 10vh 0 50vh;
|
||||
overflow: scroll;
|
||||
transform: translateY(-2rem);
|
||||
|
||||
@@ -46,7 +46,7 @@ const SynchronizedLyricsContainer = styled.div<{ $gap: number }>`
|
||||
}
|
||||
`;
|
||||
|
||||
interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
|
||||
export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
|
||||
lyrics: SynchronizedLyricsArray;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export const SynchronizedLyrics = ({
|
||||
}: SynchronizedLyricsProps) => {
|
||||
const playersRef = PlayersRef;
|
||||
const status = useCurrentStatus();
|
||||
const playerType = usePlayerType();
|
||||
const playbackType = usePlaybackType();
|
||||
const now = useCurrentTime();
|
||||
const settings = useLyricsSettings();
|
||||
|
||||
@@ -96,7 +96,7 @@ export const SynchronizedLyrics = ({
|
||||
};
|
||||
|
||||
const getCurrentTime = useCallback(async () => {
|
||||
if (isElectron() && playerType !== PlaybackType.WEB) {
|
||||
if (isElectron() && playbackType !== PlaybackType.WEB) {
|
||||
if (mpvPlayer) {
|
||||
return mpvPlayer.getCurrentTime();
|
||||
}
|
||||
@@ -116,7 +116,7 @@ export const SynchronizedLyrics = ({
|
||||
if (!player) return 0;
|
||||
|
||||
return player.currentTime;
|
||||
}, [playerType, playersRef]);
|
||||
}, [playbackType, playersRef]);
|
||||
|
||||
const setCurrentLyric = useCallback(
|
||||
(timeInMs: number, epoch?: number, targetIndex?: number) => {
|
||||
@@ -222,7 +222,7 @@ export const SynchronizedLyrics = ({
|
||||
}
|
||||
|
||||
return () => {};
|
||||
}, [getCurrentTime, lyrics, playerType, setCurrentLyric, status]);
|
||||
}, [getCurrentTime, lyrics, playbackType, setCurrentLyric, status]);
|
||||
|
||||
useEffect(() => {
|
||||
// This handler is used to deal with changes to the current delay. If the offset
|
||||
|
||||
@@ -4,7 +4,7 @@ import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
|
||||
import { FullLyricsMetadata } from '/@/renderer/api/types';
|
||||
import { useLyricsSettings } from '/@/renderer/store';
|
||||
|
||||
interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
|
||||
export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
|
||||
lyrics: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { Song } from '/@/renderer/api/types';
|
||||
import { usePlayerControls, useQueueControls } from '/@/renderer/store';
|
||||
import { PlaybackType, TableType } from '/@/renderer/types';
|
||||
import { usePlayerType } from '/@/renderer/store/settings.store';
|
||||
import { usePlaybackType } from '/@/renderer/store/settings.store';
|
||||
import { usePlayerStore, useSetCurrentTime } from '../../../store/player.store';
|
||||
import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
|
||||
|
||||
@@ -34,7 +34,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
||||
|
||||
const { pause } = usePlayerControls();
|
||||
|
||||
const playerType = usePlayerType();
|
||||
const playbackType = usePlaybackType();
|
||||
const setCurrentTime = useSetCurrentTime();
|
||||
|
||||
const handleMoveToBottom = () => {
|
||||
@@ -44,7 +44,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
||||
|
||||
const playerData = moveToBottomOfQueue(uniqueIds);
|
||||
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
mpvPlayer!.setQueueNext(playerData);
|
||||
}
|
||||
};
|
||||
@@ -56,7 +56,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
||||
|
||||
const playerData = moveToTopOfQueue(uniqueIds);
|
||||
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
mpvPlayer!.setQueueNext(playerData);
|
||||
}
|
||||
};
|
||||
@@ -70,7 +70,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
||||
const playerData = removeFromQueue(uniqueIds);
|
||||
const isCurrentSongRemoved = currentSong && uniqueIds.includes(currentSong.uniqueId);
|
||||
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
if (isCurrentSongRemoved) {
|
||||
mpvPlayer!.setQueue(playerData);
|
||||
} else {
|
||||
@@ -86,7 +86,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
||||
const handleClearQueue = () => {
|
||||
const playerData = clearQueue();
|
||||
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
mpvPlayer!.setQueue(playerData);
|
||||
mpvPlayer!.pause();
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
||||
const handleShuffleQueue = () => {
|
||||
const playerData = shuffleQueue();
|
||||
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
mpvPlayer!.setQueueNext(playerData);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
useVolume,
|
||||
} from '/@/renderer/store';
|
||||
import {
|
||||
usePlayerType,
|
||||
usePlaybackType,
|
||||
useSettingsStore,
|
||||
useSettingsStoreActions,
|
||||
useTableSettings,
|
||||
@@ -56,10 +56,11 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
const { setAppStore } = useAppStoreActions();
|
||||
const tableConfig = useTableSettings(type);
|
||||
const [gridApi, setGridApi] = useState<AgGridReactType | undefined>();
|
||||
const playerType = usePlayerType();
|
||||
const playbackType = usePlaybackType();
|
||||
const { play } = usePlayerControls();
|
||||
const volume = useVolume();
|
||||
const isFocused = useAppFocus();
|
||||
const isFocusedRef = useRef<boolean>(isFocused);
|
||||
|
||||
useEffect(() => {
|
||||
if (tableRef.current) {
|
||||
@@ -86,10 +87,9 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
status: PlayerStatus.PLAYING,
|
||||
});
|
||||
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
mpvPlayer!.volume(volume);
|
||||
mpvPlayer!.setQueue(playerData);
|
||||
mpvPlayer!.play();
|
||||
mpvPlayer!.setQueue(playerData, false);
|
||||
}
|
||||
|
||||
play();
|
||||
@@ -110,7 +110,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
|
||||
const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId);
|
||||
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
mpvPlayer!.setQueueNext(playerData);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
|
||||
const handleGridSizeChange = () => {
|
||||
if (tableConfig.autoFit) {
|
||||
tableRef?.current?.api.sizeColumnsToFit();
|
||||
tableRef?.current?.api?.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -211,7 +211,29 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentSong, previousSong, tableConfig.followCurrentSong, status, isFocused]);
|
||||
}, [currentSong, previousSong, tableConfig.followCurrentSong, status]);
|
||||
|
||||
// As a separate rule, update the current row when focus changes. This is
|
||||
// to prevent queue scrolling when the application loses and then gains focus.
|
||||
// The body should only fire when focus changes, even though it depends on current song
|
||||
useEffect(() => {
|
||||
if (isFocused !== isFocusedRef.current && tableRef?.current) {
|
||||
const { api, columnApi } = tableRef.current;
|
||||
if (api == null || columnApi == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentNode = currentSong?.uniqueId
|
||||
? api.getRowNode(currentSong.uniqueId)
|
||||
: undefined;
|
||||
|
||||
if (currentNode) {
|
||||
api.redrawRows({ rowNodes: [currentNode] });
|
||||
}
|
||||
|
||||
isFocusedRef.current = isFocused;
|
||||
}
|
||||
}, [currentSong, isFocused]);
|
||||
|
||||
const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, QUEUE_CONTEXT_MENU_ITEMS);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import isElectron from 'is-electron';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IoIosPause } from 'react-icons/io';
|
||||
import {
|
||||
RiMenuAddFill,
|
||||
RiPlayFill,
|
||||
RiRepeat2Line,
|
||||
RiRepeatOneLine,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
RiSpeedFill,
|
||||
RiStopFill,
|
||||
} from 'react-icons/ri';
|
||||
import { BsDice3 } from 'react-icons/bs';
|
||||
import styled from 'styled-components';
|
||||
import { Text } from '/@/renderer/components';
|
||||
import { useCenterControls } from '../hooks/use-center-controls';
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
} from '/@/renderer/store';
|
||||
import {
|
||||
useHotkeySettings,
|
||||
usePlayerType,
|
||||
usePlaybackType,
|
||||
useSettingsStore,
|
||||
} from '/@/renderer/store/settings.store';
|
||||
import { PlayerStatus, PlaybackType, PlayerShuffle, PlayerRepeat } from '/@/renderer/types';
|
||||
@@ -98,7 +98,8 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const currentSong = useCurrentSong();
|
||||
const skip = useSettingsStore((state) => state.general.skipButtons);
|
||||
const playerType = usePlayerType();
|
||||
const buttonSize = useSettingsStore((state) => state.general.buttonSize);
|
||||
const playbackType = usePlaybackType();
|
||||
const player1 = playersRef?.current?.player1;
|
||||
const player2 = playersRef?.current?.player2;
|
||||
const status = useCurrentStatus();
|
||||
@@ -133,7 +134,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
let interval: any;
|
||||
|
||||
if (status === PlayerStatus.PLAYING && !isSeeking) {
|
||||
if (!isElectron() || playerType === PlaybackType.WEB) {
|
||||
if (!isElectron() || playbackType === PlaybackType.WEB) {
|
||||
interval = setInterval(() => {
|
||||
setCurrentTime(currentPlayerRef.getCurrentTime());
|
||||
}, 1000);
|
||||
@@ -143,7 +144,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
}
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentPlayerRef, isSeeking, setCurrentTime, playerType, status]);
|
||||
}, [currentPlayerRef, isSeeking, setCurrentTime, playbackType, status]);
|
||||
|
||||
const [seekValue, setSeekValue] = useState(0);
|
||||
|
||||
@@ -171,17 +172,16 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
<ControlsContainer>
|
||||
<ButtonsContainer>
|
||||
<PlayerButton
|
||||
icon={<RiStopFill size={15} />}
|
||||
icon={<RiStopFill size={buttonSize} />}
|
||||
tooltip={{
|
||||
label: t('player.stop', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
onClick={handleStop}
|
||||
/>
|
||||
<PlayerButton
|
||||
$isActive={shuffle !== PlayerShuffle.NONE}
|
||||
icon={<RiShuffleFill size={15} />}
|
||||
icon={<RiShuffleFill size={buttonSize} />}
|
||||
tooltip={{
|
||||
label:
|
||||
shuffle === PlayerShuffle.NONE
|
||||
@@ -190,40 +190,38 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
postProcess: 'sentenceCase',
|
||||
})
|
||||
: t('player.shuffle', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
onClick={handleToggleShuffle}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<RiSkipBackFill size={15} />}
|
||||
icon={<RiSkipBackFill size={buttonSize} />}
|
||||
tooltip={{
|
||||
label: t('player.previous', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={handlePrevTrack}
|
||||
/>
|
||||
{skip?.enabled && (
|
||||
<PlayerButton
|
||||
icon={<RiRewindFill size={15} />}
|
||||
icon={<RiRewindFill size={buttonSize} />}
|
||||
tooltip={{
|
||||
label: t('player.skip', {
|
||||
context: 'back',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={() => handleSkipBackward(skip?.skipBackwardSeconds)}
|
||||
/>
|
||||
)}
|
||||
<PlayerButton
|
||||
disabled={currentSong?.id === undefined}
|
||||
icon={
|
||||
status === PlayerStatus.PAUSED ? (
|
||||
<RiPlayFill size={20} />
|
||||
<RiPlayFill size={buttonSize} />
|
||||
) : (
|
||||
<IoIosPause size={20} />
|
||||
<IoIosPause size={buttonSize} />
|
||||
)
|
||||
}
|
||||
tooltip={{
|
||||
@@ -231,30 +229,27 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
status === PlayerStatus.PAUSED
|
||||
? t('player.play', { postProcess: 'sentenceCase' })
|
||||
: t('player.pause', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="main"
|
||||
onClick={handlePlayPause}
|
||||
/>
|
||||
{skip?.enabled && (
|
||||
<PlayerButton
|
||||
icon={<RiSpeedFill size={15} />}
|
||||
icon={<RiSpeedFill size={buttonSize} />}
|
||||
tooltip={{
|
||||
label: t('player.skip', {
|
||||
context: 'forward',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={() => handleSkipForward(skip?.skipForwardSeconds)}
|
||||
/>
|
||||
)}
|
||||
<PlayerButton
|
||||
icon={<RiSkipForwardFill size={15} />}
|
||||
icon={<RiSkipForwardFill size={buttonSize} />}
|
||||
tooltip={{
|
||||
label: t('player.next', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={handleNextTrack}
|
||||
@@ -263,9 +258,9 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
$isActive={repeat !== PlayerRepeat.NONE}
|
||||
icon={
|
||||
repeat === PlayerRepeat.ONE ? (
|
||||
<RiRepeatOneLine size={15} />
|
||||
<RiRepeatOneLine size={buttonSize} />
|
||||
) : (
|
||||
<RiRepeat2Line size={15} />
|
||||
<RiRepeat2Line size={buttonSize} />
|
||||
)
|
||||
}
|
||||
tooltip={{
|
||||
@@ -285,17 +280,15 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
postProcess: 'sentenceCase',
|
||||
})
|
||||
}`,
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
onClick={handleToggleRepeat}
|
||||
/>
|
||||
|
||||
<PlayerButton
|
||||
icon={<RiMenuAddFill size={15} />}
|
||||
icon={<BsDice3 size={buttonSize} />}
|
||||
tooltip={{
|
||||
label: t('player.playRandom', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
onClick={() =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Flex, Stack, Group, Center } from '@mantine/core';
|
||||
import { useSetState } from '@mantine/hooks';
|
||||
import { AnimatePresence, HTMLMotionProps, motion, Variants } from 'framer-motion';
|
||||
import { useEffect, useRef, useLayoutEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useRef, useLayoutEffect, useState, useCallback, Fragment } from 'react';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
usePlayerData,
|
||||
usePlayerStore,
|
||||
} from '/@/renderer/store';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
|
||||
const Image = styled(motion.img)<{ $useAspectRatio: boolean }>`
|
||||
position: absolute;
|
||||
@@ -130,8 +131,10 @@ export const FullScreenPlayerImage = () => {
|
||||
const mainImageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [mainImageDimensions, setMainImageDimensions] = useState({ idealSize: 1 });
|
||||
|
||||
const albumArtRes = useSettingsStore((store) => store.general.albumArtRes);
|
||||
|
||||
const { queue } = usePlayerData();
|
||||
const { opacity, useImageAspectRatio } = useFullScreenPlayerStore();
|
||||
const { useImageAspectRatio } = useFullScreenPlayerStore();
|
||||
const currentSong = queue.current;
|
||||
const { color: background } = useFastAverageColor({
|
||||
algorithm: 'dominant',
|
||||
@@ -149,6 +152,7 @@ export const FullScreenPlayerImage = () => {
|
||||
if (mainImageRef.current) {
|
||||
setMainImageDimensions({
|
||||
idealSize:
|
||||
albumArtRes ||
|
||||
Math.ceil((mainImageRef.current as HTMLDivElement).offsetHeight / 100) * 100,
|
||||
});
|
||||
|
||||
@@ -158,7 +162,7 @@ export const FullScreenPlayerImage = () => {
|
||||
topImage: scaleImageUrl(mainImageDimensions.idealSize, queue.current?.imageUrl),
|
||||
});
|
||||
}
|
||||
}, [mainImageDimensions.idealSize, queue, setImageState]);
|
||||
}, [mainImageDimensions.idealSize, queue, setImageState, albumArtRes]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateImageSize();
|
||||
@@ -246,14 +250,12 @@ export const FullScreenPlayerImage = () => {
|
||||
<MetadataContainer
|
||||
className="full-screen-player-image-metadata"
|
||||
maw="100%"
|
||||
opacity={opacity}
|
||||
spacing="xs"
|
||||
>
|
||||
<TextTitle
|
||||
align="center"
|
||||
order={1}
|
||||
overflow="hidden"
|
||||
pb="0.5rem"
|
||||
style={{
|
||||
textShadow: 'var(--fullscreen-player-text-shadow)',
|
||||
}}
|
||||
@@ -274,49 +276,53 @@ export const FullScreenPlayerImage = () => {
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: currentSong?.albumId || '',
|
||||
})}
|
||||
transform="uppercase"
|
||||
w="100%"
|
||||
weight={600}
|
||||
>
|
||||
{currentSong?.album}{' '}
|
||||
</TextTitle>
|
||||
{currentSong?.artists?.map((artist, index) => (
|
||||
<TextTitle
|
||||
key={`fs-artist-${artist.id}`}
|
||||
align="center"
|
||||
order={3}
|
||||
style={{
|
||||
textShadow: 'var(--fullscreen-player-text-shadow)',
|
||||
}}
|
||||
transform="uppercase"
|
||||
>
|
||||
{index > 0 && (
|
||||
<TextTitle
|
||||
key="fs-artists"
|
||||
align="center"
|
||||
order={3}
|
||||
style={{
|
||||
textShadow: 'var(--fullscreen-player-text-shadow)',
|
||||
}}
|
||||
>
|
||||
{currentSong?.artists?.map((artist, index) => (
|
||||
<Fragment key={`fs-artist-${artist.id}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$secondary
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
padding: '0 0.5rem',
|
||||
}}
|
||||
>
|
||||
•
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
padding: '0 0.5rem',
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
style={{
|
||||
textShadow: 'var(--fullscreen-player-text-shadow)',
|
||||
}}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
weight={600}
|
||||
>
|
||||
•
|
||||
{artist.name}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
style={{
|
||||
textShadow: 'var(--fullscreen-player-text-shadow)',
|
||||
}}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
transform="uppercase"
|
||||
weight={600}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
</TextTitle>
|
||||
))}
|
||||
<Group position="center">
|
||||
</Fragment>
|
||||
))}
|
||||
</TextTitle>
|
||||
<Group
|
||||
mt="sm"
|
||||
position="center"
|
||||
>
|
||||
{currentSong?.container && (
|
||||
<Badge size="lg">
|
||||
{currentSong?.container} {currentSong?.bitRate}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Group, Center } from '@mantine/core';
|
||||
import { Group } from '@mantine/core';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HiOutlineQueueList } from 'react-icons/hi2';
|
||||
import { RiFileMusicLine, RiFileTextLine, RiInformationFill } from 'react-icons/ri';
|
||||
import { RiFileMusicLine, RiFileTextLine } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { Button, TextTitle } from '/@/renderer/components';
|
||||
import { Button } from '/@/renderer/components';
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing';
|
||||
import {
|
||||
useFullScreenPlayerStore,
|
||||
useFullScreenPlayerStoreActions,
|
||||
} from '/@/renderer/store/full-screen-player.store';
|
||||
import { Lyrics } from '/@/renderer/features/lyrics/lyrics';
|
||||
import { FullScreenSimilarSongs } from '/@/renderer/features/player/components/full-screen-similar-songs';
|
||||
|
||||
const QueueContainer = styled.div`
|
||||
position: relative;
|
||||
@@ -42,11 +43,11 @@ const HeaderItemWrapper = styled.div`
|
||||
z-index: 2;
|
||||
`;
|
||||
|
||||
interface TransparendGridContainerProps {
|
||||
interface TransparentGridContainerProps {
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
const GridContainer = styled.div<TransparendGridContainerProps>`
|
||||
const GridContainer = styled.div<TransparentGridContainerProps>`
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
grid-template-columns: 1fr;
|
||||
@@ -82,8 +83,6 @@ export const FullScreenPlayerQueue = () => {
|
||||
},
|
||||
];
|
||||
|
||||
console.log('opacity', opacity);
|
||||
|
||||
return (
|
||||
<GridContainer
|
||||
className="full-screen-player-queue-container"
|
||||
@@ -123,17 +122,9 @@ export const FullScreenPlayerQueue = () => {
|
||||
<PlayQueue type="fullScreen" />
|
||||
</QueueContainer>
|
||||
) : activeTab === 'related' ? (
|
||||
<Center>
|
||||
<Group>
|
||||
<RiInformationFill size="2rem" />
|
||||
<TextTitle
|
||||
order={3}
|
||||
weight={700}
|
||||
>
|
||||
{t('common.comingSoon', { postProcess: 'upperCase' })}
|
||||
</TextTitle>
|
||||
</Group>
|
||||
</Center>
|
||||
<QueueContainer>
|
||||
<FullScreenSimilarSongs />
|
||||
</QueueContainer>
|
||||
) : activeTab === 'lyrics' ? (
|
||||
<Lyrics />
|
||||
) : null}
|
||||
|
||||
@@ -60,7 +60,11 @@ const ResponsiveContainer = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const BackgroundImageOverlay = styled.div`
|
||||
interface BackgroundImageOverlayProps {
|
||||
$blur: number;
|
||||
}
|
||||
|
||||
const BackgroundImageOverlay = styled.div<BackgroundImageOverlayProps>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -68,12 +72,21 @@ const BackgroundImageOverlay = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-header-overlay);
|
||||
backdrop-filter: blur(${({ $blur }) => $blur}rem);
|
||||
`;
|
||||
|
||||
const mainBackground = 'var(--main-bg)';
|
||||
|
||||
const Controls = () => {
|
||||
const { t } = useTranslation();
|
||||
const { dynamicBackground, expanded, opacity, useImageAspectRatio } =
|
||||
useFullScreenPlayerStore();
|
||||
const {
|
||||
dynamicBackground,
|
||||
dynamicImageBlur,
|
||||
dynamicIsImage,
|
||||
expanded,
|
||||
opacity,
|
||||
useImageAspectRatio,
|
||||
} = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const lyricConfig = useLyricsSettings();
|
||||
@@ -141,6 +154,45 @@ const Controls = () => {
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
{dynamicBackground && (
|
||||
<Option>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.config.dynamicIsImage', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={dynamicIsImage}
|
||||
onChange={(e) =>
|
||||
setStore({
|
||||
dynamicIsImage: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
)}
|
||||
{dynamicBackground && dynamicIsImage && (
|
||||
<Option>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.config.dynamicImageBlur', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Slider
|
||||
defaultValue={dynamicImageBlur}
|
||||
label={(e) => `${e} rem`}
|
||||
max={6}
|
||||
min={0}
|
||||
step={0.5}
|
||||
w="100%"
|
||||
onChangeEnd={(e) => setStore({ dynamicImageBlur: Number(e) })}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
)}
|
||||
{dynamicBackground && (
|
||||
<Option>
|
||||
<Option.Label>
|
||||
@@ -153,7 +205,7 @@ const Controls = () => {
|
||||
defaultValue={opacity}
|
||||
label={(e) => `${e} %`}
|
||||
max={100}
|
||||
min={1}
|
||||
min={0}
|
||||
w="100%"
|
||||
onChangeEnd={(e) => setStore({ opacity: Number(e) })}
|
||||
/>
|
||||
@@ -225,7 +277,7 @@ const Controls = () => {
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.config.lyric', {
|
||||
{t('page.fullscreenPlayer.config.lyricSize', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
@@ -368,9 +420,13 @@ const containerVariants: Variants = {
|
||||
};
|
||||
},
|
||||
open: (custom) => {
|
||||
const { dynamicBackground, background, windowBarStyle } = custom;
|
||||
const { background, backgroundImage, dynamicBackground, windowBarStyle } = custom;
|
||||
return {
|
||||
background: dynamicBackground ? background : 'var(--main-bg)',
|
||||
background: dynamicBackground ? backgroundImage : mainBackground,
|
||||
backgroundColor: dynamicBackground ? background : mainBackground,
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'cover',
|
||||
height:
|
||||
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
|
||||
? 'calc(100vh - 120px)'
|
||||
@@ -394,7 +450,7 @@ const containerVariants: Variants = {
|
||||
};
|
||||
|
||||
export const FullScreenPlayer = () => {
|
||||
const { dynamicBackground } = useFullScreenPlayerStore();
|
||||
const { dynamicBackground, dynamicImageBlur, dynamicIsImage } = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
|
||||
@@ -416,17 +472,25 @@ export const FullScreenPlayer = () => {
|
||||
srcLoaded: true,
|
||||
});
|
||||
|
||||
const imageUrl = currentSong?.imageUrl;
|
||||
const backgroundImage =
|
||||
imageUrl && dynamicIsImage
|
||||
? `url("${imageUrl
|
||||
.replace(/size=\d+/g, 'size=500')
|
||||
.replace(currentSong.id, currentSong.albumId)}`
|
||||
: mainBackground;
|
||||
|
||||
return (
|
||||
<Container
|
||||
animate="open"
|
||||
custom={{ background, dynamicBackground, windowBarStyle }}
|
||||
custom={{ background, backgroundImage, dynamicBackground, windowBarStyle }}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
transition={{ duration: 2 }}
|
||||
variants={containerVariants}
|
||||
>
|
||||
<Controls />
|
||||
{dynamicBackground && <BackgroundImageOverlay />}
|
||||
{dynamicBackground && <BackgroundImageOverlay $blur={dynamicImageBlur} />}
|
||||
<ResponsiveContainer>
|
||||
<FullScreenPlayerImage />
|
||||
<FullScreenPlayerQueue />
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { SimilarSongsList } from '/@/renderer/features/similar-songs/components/similar-songs-list';
|
||||
import { useCurrentSong } from '/@/renderer/store';
|
||||
|
||||
export const FullScreenSimilarSongs = () => {
|
||||
const currentSong = useCurrentSong();
|
||||
|
||||
return currentSong?.id ? (
|
||||
<SimilarSongsList
|
||||
fullScreen
|
||||
song={currentSong}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import isElectron from 'is-electron';
|
||||
import styled from 'styled-components';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { usePlaybackType, useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { PlaybackType } from '/@/renderer/types';
|
||||
import { AudioPlayer } from '/@/renderer/components';
|
||||
import {
|
||||
@@ -64,6 +64,7 @@ const remote = isElectron() ? window.electron.remote : null;
|
||||
export const Playerbar = () => {
|
||||
const playersRef = PlayersRef;
|
||||
const settings = useSettingsStore((state) => state.playback);
|
||||
const playbackType = usePlaybackType();
|
||||
const volume = useVolume();
|
||||
const player1 = usePlayer1Data();
|
||||
const player2 = usePlayer2Data();
|
||||
@@ -96,7 +97,7 @@ export const Playerbar = () => {
|
||||
<RightControls />
|
||||
</RightGridItem>
|
||||
</PlayerbarControlsGrid>
|
||||
{settings.type === PlaybackType.WEB && (
|
||||
{playbackType === PlaybackType.WEB && (
|
||||
<AudioPlayer
|
||||
ref={playersRef}
|
||||
autoNext={autoNextFn}
|
||||
|
||||
@@ -28,12 +28,11 @@ import { LibraryItem, QueueSong, ServerType, Song } from '/@/renderer/api/types'
|
||||
import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared';
|
||||
import { DropdownMenu, Rating } from '/@/renderer/components';
|
||||
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
|
||||
import { Slider } from '/@/renderer/components/slider';
|
||||
|
||||
const ipc = isElectron() ? window.electron.ipc : null;
|
||||
const remote = isElectron() ? window.electron.remote : null;
|
||||
|
||||
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
|
||||
|
||||
export const RightControls = () => {
|
||||
const { t } = useTranslation();
|
||||
const isMinWidth = useMediaQuery('(max-width: 480px)');
|
||||
@@ -110,6 +109,14 @@ export const RightControls = () => {
|
||||
setSideBar({ rightExpanded: !isQueueExpanded });
|
||||
};
|
||||
|
||||
const formatPlaybackSpeedSliderLabel = (value: number) => {
|
||||
const bpm = Number(currentSong?.bpm);
|
||||
if (bpm > 0) {
|
||||
return `${value} x / ${(bpm * value).toFixed(1)} BPM`;
|
||||
}
|
||||
return `${value} x`;
|
||||
};
|
||||
|
||||
const isSongDefined = Boolean(currentSong?.id);
|
||||
const showRating = isSongDefined && server?.type === ServerType.NAVIDROME;
|
||||
|
||||
@@ -210,7 +217,13 @@ export const RightControls = () => {
|
||||
align="center"
|
||||
spacing="xs"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
withArrow
|
||||
arrowOffset={12}
|
||||
offset={0}
|
||||
position="top-end"
|
||||
width={425}
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<PlayerButton
|
||||
icon={<>{speed} x</>}
|
||||
@@ -222,14 +235,30 @@ export const RightControls = () => {
|
||||
/>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{PLAYBACK_SPEEDS.map((speed) => (
|
||||
<DropdownMenu.Item
|
||||
key={`speed-select-${speed}`}
|
||||
onClick={() => handleSpeed(Number(speed))}
|
||||
>
|
||||
{speed}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
<Slider
|
||||
label={formatPlaybackSpeedSliderLabel}
|
||||
marks={[
|
||||
{ label: '0.5', value: 0.5 },
|
||||
{ label: '0.75', value: 0.75 },
|
||||
{ label: '1', value: 1 },
|
||||
{ label: '1.25', value: 1.25 },
|
||||
{ label: '1.5', value: 1.5 },
|
||||
]}
|
||||
max={1.5}
|
||||
min={0.5}
|
||||
step={0.01}
|
||||
styles={{
|
||||
markLabel: {
|
||||
paddingTop: '0.5rem',
|
||||
},
|
||||
root: {
|
||||
margin: '1rem 1rem 2rem 1rem',
|
||||
},
|
||||
}}
|
||||
value={speed}
|
||||
onChange={handleSpeed}
|
||||
onDoubleClick={() => handleSpeed(1)}
|
||||
/>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<PlayerButton
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user