mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 04:50:12 +02:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d51ca37e1b | |||
| 9d8dcc7ade | |||
| 8430b1ec95 | |||
| 11863fd4c1 | |||
| cf9c7e2640 | |||
| 9d780e0342 | |||
| 4ec981df83 | |||
| e5564c2ac2 | |||
| 7a580c2c65 | |||
| ac84088c69 | |||
| 3c2e4d40ec | |||
| fdff79496a | |||
| ccfadda729 | |||
| f21b8d6bbd | |||
| 244c00c4c6 | |||
| 2664a80851 | |||
| 742b13d65e | |||
| 8dcd49d574 | |||
| 02c8cbcad6 | |||
| 86fb52f6d4 | |||
| 452ef783f2 | |||
| 74cab01013 | |||
| e6ed9229c2 | |||
| 3a144ab821 | |||
| 913e89b01b | |||
| 768a88de8f | |||
| 8e2a107d4a | |||
| e77efcf836 | |||
| 818f155993 | |||
| b28fe4cbc9 | |||
| 8a53fab751 | |||
| 9964f95d5d | |||
| fe298d3232 | |||
| 03e582f301 | |||
| d7b3d5c0bd | |||
| 5fdf4c06f9 | |||
| c7aa5d09c9 | |||
| f4f73289c9 | |||
| ac7ec133db | |||
| 1a948ab86b | |||
| f6667a39a0 | |||
| cbeb4ab7d8 | |||
| 3675146f1f | |||
| 946f4ff306 | |||
| 277669c413 | |||
| 49b6478b72 | |||
| ca39409cc3 | |||
| cca6fa21db | |||
| 5e1059870c | |||
| 6bac172bbe | |||
| 118a9f73d1 | |||
| c464be8cea | |||
| 3bbe696f4c | |||
| f7cacd2b73 | |||
| 62794623a3 |
@@ -9,112 +9,119 @@ import checkNodeEnv from '../scripts/check-node-env';
|
||||
import baseConfig from './webpack.config.base';
|
||||
import webpackPaths from './webpack.paths';
|
||||
|
||||
const { version } = require('../../package.json');
|
||||
|
||||
// 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;
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
devtool: 'inline-source-map',
|
||||
devtool: 'inline-source-map',
|
||||
|
||||
mode: 'development',
|
||||
mode: 'development',
|
||||
|
||||
target: ['web'],
|
||||
target: ['web'],
|
||||
|
||||
entry: [path.join(webpackPaths.srcRemotePath, 'index.tsx')],
|
||||
|
||||
output: {
|
||||
path: webpackPaths.dllPath,
|
||||
publicPath: '/',
|
||||
filename: 'remote.js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
entry: {
|
||||
remote: path.join(webpackPaths.srcRemotePath, 'index.tsx'),
|
||||
worker: path.join(webpackPaths.srcRemotePath, 'service-worker.ts'),
|
||||
},
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.s?css$/,
|
||||
use: [
|
||||
'style-loader',
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: true,
|
||||
sourceMap: true,
|
||||
importLoaders: 1,
|
||||
output: {
|
||||
path: webpackPaths.dllPath,
|
||||
publicPath: '/',
|
||||
filename: '[name].js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
},
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.s?css$/,
|
||||
use: [
|
||||
'style-loader',
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: true,
|
||||
sourceMap: true,
|
||||
importLoaders: 1,
|
||||
},
|
||||
},
|
||||
'sass-loader',
|
||||
],
|
||||
include: /\.module\.s?(c|a)ss$/,
|
||||
},
|
||||
{
|
||||
test: /\.s?css$/,
|
||||
use: ['style-loader', 'css-loader', 'sass-loader'],
|
||||
exclude: /\.module\.s?(c|a)ss$/,
|
||||
},
|
||||
// Fonts
|
||||
{
|
||||
test: /\.(woff|woff2|eot|ttf|otf)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
// Images
|
||||
{
|
||||
test: /\.(png|svg|jpg|jpeg|gif)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
},
|
||||
'sass-loader',
|
||||
],
|
||||
include: /\.module\.s?(c|a)ss$/,
|
||||
},
|
||||
{
|
||||
test: /\.s?css$/,
|
||||
use: ['style-loader', 'css-loader', 'sass-loader'],
|
||||
exclude: /\.module\.s?(c|a)ss$/,
|
||||
},
|
||||
// Fonts
|
||||
{
|
||||
test: /\.(woff|woff2|eot|ttf|otf)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
// Images
|
||||
{
|
||||
test: /\.(png|svg|jpg|jpeg|gif)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
new webpack.NoEmitOnErrorsPlugin(),
|
||||
|
||||
/**
|
||||
* Create global constants which can be configured at compile time.
|
||||
*
|
||||
* Useful for allowing different behaviour between development builds and
|
||||
* release builds
|
||||
*
|
||||
* NODE_ENV should be production so that modules do not perform certain
|
||||
* development checks
|
||||
*
|
||||
* By default, use 'development' as NODE_ENV. This can be overriden with
|
||||
* 'staging', for example, by changing the ENV variables in the npm scripts
|
||||
*/
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'development',
|
||||
}),
|
||||
|
||||
new webpack.LoaderOptionsPlugin({
|
||||
debug: true,
|
||||
}),
|
||||
|
||||
new HtmlWebpackPlugin({
|
||||
filename: path.join('index.html'),
|
||||
template: path.join(webpackPaths.srcRemotePath, 'index.ejs'),
|
||||
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true,
|
||||
},
|
||||
isBrowser: true,
|
||||
env: process.env.NODE_ENV,
|
||||
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||
nodeModules: webpackPaths.appNodeModulesPath,
|
||||
templateParameters: {
|
||||
version,
|
||||
prod: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new webpack.NoEmitOnErrorsPlugin(),
|
||||
|
||||
/**
|
||||
* Create global constants which can be configured at compile time.
|
||||
*
|
||||
* Useful for allowing different behaviour between development builds and
|
||||
* release builds
|
||||
*
|
||||
* NODE_ENV should be production so that modules do not perform certain
|
||||
* development checks
|
||||
*
|
||||
* By default, use 'development' as NODE_ENV. This can be overriden with
|
||||
* 'staging', for example, by changing the ENV variables in the npm scripts
|
||||
*/
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'development',
|
||||
}),
|
||||
node: {
|
||||
__dirname: false,
|
||||
__filename: false,
|
||||
},
|
||||
|
||||
new webpack.LoaderOptionsPlugin({
|
||||
debug: true,
|
||||
}),
|
||||
|
||||
new HtmlWebpackPlugin({
|
||||
filename: path.join('index.html'),
|
||||
template: path.join(webpackPaths.srcRemotePath, 'index.ejs'),
|
||||
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true,
|
||||
},
|
||||
isBrowser: true,
|
||||
env: process.env.NODE_ENV,
|
||||
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||
nodeModules: webpackPaths.appNodeModulesPath,
|
||||
}),
|
||||
],
|
||||
|
||||
node: {
|
||||
__dirname: false,
|
||||
__filename: false,
|
||||
},
|
||||
|
||||
watch: true,
|
||||
watch: true,
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
|
||||
@@ -17,116 +17,126 @@ import deleteSourceMaps from '../scripts/delete-source-maps';
|
||||
import baseConfig from './webpack.config.base';
|
||||
import webpackPaths from './webpack.paths';
|
||||
|
||||
const { version } = require('../../package.json');
|
||||
|
||||
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'],
|
||||
target: ['web'],
|
||||
|
||||
entry: [path.join(webpackPaths.srcRemotePath, 'index.tsx')],
|
||||
|
||||
output: {
|
||||
path: webpackPaths.distRemotePath,
|
||||
publicPath: './',
|
||||
filename: 'remote.js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
entry: {
|
||||
remote: path.join(webpackPaths.srcRemotePath, 'index.tsx'),
|
||||
worker: path.join(webpackPaths.srcRemotePath, 'service-worker.ts'),
|
||||
},
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.s?(a|c)ss$/,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: true,
|
||||
sourceMap: true,
|
||||
importLoaders: 1,
|
||||
output: {
|
||||
path: webpackPaths.distRemotePath,
|
||||
publicPath: './',
|
||||
filename: '[name].js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
},
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.s?(a|c)ss$/,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: true,
|
||||
sourceMap: true,
|
||||
importLoaders: 1,
|
||||
},
|
||||
},
|
||||
'sass-loader',
|
||||
],
|
||||
include: /\.module\.s?(c|a)ss$/,
|
||||
},
|
||||
{
|
||||
test: /\.s?(a|c)ss$/,
|
||||
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
|
||||
exclude: /\.module\.s?(c|a)ss$/,
|
||||
},
|
||||
// Fonts
|
||||
{
|
||||
test: /\.(woff|woff2|eot|ttf|otf)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
// Images
|
||||
{
|
||||
test: /\.(png|svg|jpg|jpeg|gif)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
},
|
||||
'sass-loader',
|
||||
],
|
||||
include: /\.module\.s?(c|a)ss$/,
|
||||
},
|
||||
{
|
||||
test: /\.s?(a|c)ss$/,
|
||||
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
|
||||
exclude: /\.module\.s?(c|a)ss$/,
|
||||
},
|
||||
// Fonts
|
||||
{
|
||||
test: /\.(woff|woff2|eot|ttf|otf)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
// Images
|
||||
{
|
||||
test: /\.(png|svg|jpg|jpeg|gif)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
},
|
||||
|
||||
optimization: {
|
||||
minimize: true,
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
parallel: true,
|
||||
}),
|
||||
new CssMinimizerPlugin(),
|
||||
],
|
||||
},
|
||||
|
||||
plugins: [
|
||||
/**
|
||||
* Create global constants which can be configured at compile time.
|
||||
*
|
||||
* Useful for allowing different behaviour between development builds and
|
||||
* release builds
|
||||
*
|
||||
* NODE_ENV should be production so that modules do not perform certain
|
||||
* development checks
|
||||
*/
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'production',
|
||||
DEBUG_PROD: false,
|
||||
}),
|
||||
|
||||
new MiniCssExtractPlugin({
|
||||
filename: 'remote.css',
|
||||
}),
|
||||
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
|
||||
}),
|
||||
|
||||
new HtmlWebpackPlugin({
|
||||
filename: 'index.html',
|
||||
template: path.join(webpackPaths.srcRemotePath, 'index.ejs'),
|
||||
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true,
|
||||
},
|
||||
isBrowser: true,
|
||||
env: process.env.NODE_ENV,
|
||||
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||
templateParameters: {
|
||||
version,
|
||||
prod: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
optimization: {
|
||||
minimize: true,
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
parallel: true,
|
||||
}),
|
||||
new CssMinimizerPlugin(),
|
||||
],
|
||||
},
|
||||
|
||||
plugins: [
|
||||
/**
|
||||
* Create global constants which can be configured at compile time.
|
||||
*
|
||||
* Useful for allowing different behaviour between development builds and
|
||||
* release builds
|
||||
*
|
||||
* NODE_ENV should be production so that modules do not perform certain
|
||||
* development checks
|
||||
*/
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'production',
|
||||
DEBUG_PROD: false,
|
||||
}),
|
||||
|
||||
new MiniCssExtractPlugin({
|
||||
filename: 'remote.css',
|
||||
}),
|
||||
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
|
||||
}),
|
||||
|
||||
new HtmlWebpackPlugin({
|
||||
filename: 'index.html',
|
||||
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
|
||||
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true,
|
||||
},
|
||||
isBrowser: false,
|
||||
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
|
||||
@@ -38,7 +38,7 @@ const configuration: webpack.Configuration = {
|
||||
|
||||
output: {
|
||||
path: webpackPaths.distWebPath,
|
||||
publicPath: '/',
|
||||
publicPath: 'auto',
|
||||
filename: 'renderer.js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
|
||||
@@ -37,6 +37,10 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
with:
|
||||
@@ -44,3 +48,7 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm/v7
|
||||
linux/arm64/v8
|
||||
|
||||
@@ -29,6 +29,10 @@ jobs:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
with:
|
||||
@@ -36,3 +40,7 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm/v7
|
||||
linux/arm64/v8
|
||||
|
||||
+3
-2
@@ -10,8 +10,9 @@ RUN npm run build:web
|
||||
# --- Production stage
|
||||
FROM nginx:alpine-slim
|
||||
|
||||
COPY --from=builder /app/release/app/dist/web /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --chown=nginx:nginx --from=builder /app/release/app/dist/web /usr/share/nginx/html
|
||||
COPY ng.conf.template /etc/nginx/templates/default.conf.template
|
||||
|
||||
ENV PUBLIC_PATH="/"
|
||||
EXPOSE 9180
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -74,6 +74,8 @@ docker run --name feishin --port 9180:9180 feishin
|
||||
|
||||
- **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).
|
||||
|
||||
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`.
|
||||
|
||||
## FAQ
|
||||
|
||||
### MPV is either not working or is rapidly switching between pause/play states
|
||||
@@ -95,6 +97,10 @@ Built and tested using Node `v16.15.0`.
|
||||
|
||||
This project is built off of [electron-react-boilerplate](https://github.com/electron-react-boilerplate/electron-react-boilerplate) v4.6.0.
|
||||
|
||||
## Translation
|
||||
|
||||
This project uses [Weblate](https://hosted.weblate.org/projects/feishin/) for translations. If you would like to contribute, please visit the link and submit a translation.
|
||||
|
||||
## License
|
||||
|
||||
[GNU General Public License v3.0 ©](https://github.com/jeffvli/feishin/blob/dev/LICENSE)
|
||||
|
||||
@@ -12,9 +12,8 @@ server {
|
||||
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_comp_level 9;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location / {
|
||||
location ${PUBLIC_PATH} {
|
||||
alias /usr/share/nginx/html/;
|
||||
try_files $uri $uri/ /index.html =404;
|
||||
}
|
||||
}
|
||||
Generated
+106
-56
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishin",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -27,6 +27,7 @@
|
||||
"@tanstack/react-query-devtools": "^4.32.1",
|
||||
"@tanstack/react-query-persist-client": "^4.32.1",
|
||||
"@ts-rest/core": "^3.23.0",
|
||||
"@xhayper/discord-rpc": "^1.0.24",
|
||||
"axios": "^1.4.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
@@ -41,7 +42,7 @@
|
||||
"framer-motion": "^10.13.0",
|
||||
"fuse.js": "^6.6.2",
|
||||
"history": "^5.3.0",
|
||||
"i18next": "^21.6.16",
|
||||
"i18next": "^21.10.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^9.0.21",
|
||||
"is-electron": "^2.2.2",
|
||||
@@ -56,11 +57,11 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-i18next": "^11.16.7",
|
||||
"react-i18next": "^11.18.6",
|
||||
"react-icons": "^4.10.1",
|
||||
"react-player": "^2.11.0",
|
||||
"react-router": "^6.5.0",
|
||||
"react-router-dom": "^6.5.0",
|
||||
"react-router": "^6.16.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"react-simple-img": "^3.0.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.17",
|
||||
"react-window": "^1.8.9",
|
||||
@@ -124,7 +125,7 @@
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"husky": "^7.0.4",
|
||||
"i18next-parser": "^6.3.0",
|
||||
"i18next-parser": "^6.6.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^27.5.1",
|
||||
"lint-staged": "^12.3.7",
|
||||
@@ -4336,11 +4337,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.1.0.tgz",
|
||||
"integrity": "sha512-rGl+jH/7x1KBCQScz9p54p0dtPLNeKGb3e0wD2H5/oZj41bwQUnXdzbj2TbUAFhvD7cp9EyEQA4dEgpUFa1O7Q==",
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.9.0.tgz",
|
||||
"integrity": "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA==",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/is": {
|
||||
@@ -5613,6 +5614,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xhayper/discord-rpc": {
|
||||
"version": "1.0.24",
|
||||
"resolved": "https://registry.npmjs.org/@xhayper/discord-rpc/-/discord-rpc-1.0.24.tgz",
|
||||
"integrity": "sha512-gzC8OaOSz7cGALSHyyq6nANQvBfyfntbSq+Qh+cNanoKX8ybOj+jWKmDP6PbLVDWoBftTU3JYsWXrLml2df2Hw==",
|
||||
"dependencies": {
|
||||
"axios": "^1.5.1",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xhayper/discord-rpc/node_modules/ws": {
|
||||
"version": "8.14.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz",
|
||||
"integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.10",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
|
||||
@@ -6292,9 +6325,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz",
|
||||
"integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==",
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz",
|
||||
"integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.0",
|
||||
"form-data": "^4.0.0",
|
||||
@@ -11838,7 +11871,8 @@
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/html-minifier-terser": {
|
||||
"version": "6.1.0",
|
||||
@@ -12096,9 +12130,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "21.6.16",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.6.16.tgz",
|
||||
"integrity": "sha512-xJlzrVxG9CyAGsbMP1aKuiNr1Ed2m36KiTB7hjGMG2Zo4idfw3p9THUEu+GjBwIgEZ7F11ZbCzJcfv4uyfKNuw==",
|
||||
"version": "21.10.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz",
|
||||
"integrity": "sha512-YeuIBmFsGjUfO3qBmMOc0rQaun4mIpGKET5WDwvu8lU7gvwpcariZLNtL0Fzj+zazcHUrlXHiptcFhBMFaxzfg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -16935,12 +16969,11 @@
|
||||
"integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "11.16.7",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.16.7.tgz",
|
||||
"integrity": "sha512-7yotILJLnKfvUfrl/nt9eK9vFpVFjZPLWAwBzWL6XppSZZEvlmlKk0GBGDCAPfLfs8oND7WAbry8wGzdoiW5Nw==",
|
||||
"version": "11.18.6",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.6.tgz",
|
||||
"integrity": "sha512-yHb2F9BiT0lqoQDt8loZ5gWP331GwctHz9tYQ8A2EIEUu+CcEdjBLQWli1USG3RdWQt3W+jqQLg/d4rrQR96LA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.14.5",
|
||||
"html-escaper": "^2.0.2",
|
||||
"html-parse-stringify": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -17055,29 +17088,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.5.0.tgz",
|
||||
"integrity": "sha512-fqqUSU0NC0tSX0sZbyuxzuAzvGqbjiZItBQnyicWlOUmzhAU8YuLgRbaCL2hf3sJdtRy4LP/WBrWtARkMvdGPQ==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.16.0.tgz",
|
||||
"integrity": "sha512-VT4Mmc4jj5YyjpOi5jOf0I+TYzGpvzERy4ckNSvSh2RArv8LLoCxlsZ2D+tc7zgjxcY34oTz2hZaeX5RVprKqA==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.1.0"
|
||||
"@remix-run/router": "1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.5.0.tgz",
|
||||
"integrity": "sha512-/XzRc5fq80gW1ctiIGilyKFZC/j4kfe75uivMsTChFbkvrK4ZrF3P3cGIc1f/SSkQ4JiJozPrf+AwUHHWVehVg==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.16.0.tgz",
|
||||
"integrity": "sha512-aTfBLv3mk/gaKLxgRDUPbPw+s4Y/O+ma3rEN1u8EgEpLpPe6gNjIsWt9rxushMHHMb7mSwxRGdGlGdvmFsyPIg==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.1.0",
|
||||
"react-router": "6.5.0"
|
||||
"@remix-run/router": "1.9.0",
|
||||
"react-router": "6.16.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
@@ -24366,9 +24399,9 @@
|
||||
}
|
||||
},
|
||||
"@remix-run/router": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.1.0.tgz",
|
||||
"integrity": "sha512-rGl+jH/7x1KBCQScz9p54p0dtPLNeKGb3e0wD2H5/oZj41bwQUnXdzbj2TbUAFhvD7cp9EyEQA4dEgpUFa1O7Q=="
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.9.0.tgz",
|
||||
"integrity": "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA=="
|
||||
},
|
||||
"@sindresorhus/is": {
|
||||
"version": "4.6.0",
|
||||
@@ -25433,6 +25466,23 @@
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@xhayper/discord-rpc": {
|
||||
"version": "1.0.24",
|
||||
"resolved": "https://registry.npmjs.org/@xhayper/discord-rpc/-/discord-rpc-1.0.24.tgz",
|
||||
"integrity": "sha512-gzC8OaOSz7cGALSHyyq6nANQvBfyfntbSq+Qh+cNanoKX8ybOj+jWKmDP6PbLVDWoBftTU3JYsWXrLml2df2Hw==",
|
||||
"requires": {
|
||||
"axios": "^1.5.1",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": {
|
||||
"version": "8.14.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz",
|
||||
"integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==",
|
||||
"requires": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@xmldom/xmldom": {
|
||||
"version": "0.8.10",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
|
||||
@@ -25951,9 +26001,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"axios": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz",
|
||||
"integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==",
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz",
|
||||
"integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==",
|
||||
"requires": {
|
||||
"follow-redirects": "^1.15.0",
|
||||
"form-data": "^4.0.0",
|
||||
@@ -30183,7 +30233,8 @@
|
||||
"html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true
|
||||
},
|
||||
"html-minifier-terser": {
|
||||
"version": "6.1.0",
|
||||
@@ -30369,9 +30420,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"i18next": {
|
||||
"version": "21.6.16",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.6.16.tgz",
|
||||
"integrity": "sha512-xJlzrVxG9CyAGsbMP1aKuiNr1Ed2m36KiTB7hjGMG2Zo4idfw3p9THUEu+GjBwIgEZ7F11ZbCzJcfv4uyfKNuw==",
|
||||
"version": "21.10.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz",
|
||||
"integrity": "sha512-YeuIBmFsGjUfO3qBmMOc0rQaun4mIpGKET5WDwvu8lU7gvwpcariZLNtL0Fzj+zazcHUrlXHiptcFhBMFaxzfg==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.17.2"
|
||||
}
|
||||
@@ -33902,12 +33953,11 @@
|
||||
"integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
|
||||
},
|
||||
"react-i18next": {
|
||||
"version": "11.16.7",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.16.7.tgz",
|
||||
"integrity": "sha512-7yotILJLnKfvUfrl/nt9eK9vFpVFjZPLWAwBzWL6XppSZZEvlmlKk0GBGDCAPfLfs8oND7WAbry8wGzdoiW5Nw==",
|
||||
"version": "11.18.6",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.6.tgz",
|
||||
"integrity": "sha512-yHb2F9BiT0lqoQDt8loZ5gWP331GwctHz9tYQ8A2EIEUu+CcEdjBLQWli1USG3RdWQt3W+jqQLg/d4rrQR96LA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.14.5",
|
||||
"html-escaper": "^2.0.2",
|
||||
"html-parse-stringify": "^3.0.1"
|
||||
}
|
||||
},
|
||||
@@ -33977,20 +34027,20 @@
|
||||
}
|
||||
},
|
||||
"react-router": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.5.0.tgz",
|
||||
"integrity": "sha512-fqqUSU0NC0tSX0sZbyuxzuAzvGqbjiZItBQnyicWlOUmzhAU8YuLgRbaCL2hf3sJdtRy4LP/WBrWtARkMvdGPQ==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.16.0.tgz",
|
||||
"integrity": "sha512-VT4Mmc4jj5YyjpOi5jOf0I+TYzGpvzERy4ckNSvSh2RArv8LLoCxlsZ2D+tc7zgjxcY34oTz2hZaeX5RVprKqA==",
|
||||
"requires": {
|
||||
"@remix-run/router": "1.1.0"
|
||||
"@remix-run/router": "1.9.0"
|
||||
}
|
||||
},
|
||||
"react-router-dom": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.5.0.tgz",
|
||||
"integrity": "sha512-/XzRc5fq80gW1ctiIGilyKFZC/j4kfe75uivMsTChFbkvrK4ZrF3P3cGIc1f/SSkQ4JiJozPrf+AwUHHWVehVg==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.16.0.tgz",
|
||||
"integrity": "sha512-aTfBLv3mk/gaKLxgRDUPbPw+s4Y/O+ma3rEN1u8EgEpLpPe6gNjIsWt9rxushMHHMb7mSwxRGdGlGdvmFsyPIg==",
|
||||
"requires": {
|
||||
"@remix-run/router": "1.1.0",
|
||||
"react-router": "6.5.0"
|
||||
"@remix-run/router": "1.9.0",
|
||||
"react-router": "6.16.0"
|
||||
}
|
||||
},
|
||||
"react-shallow-renderer": {
|
||||
|
||||
+41
-7
@@ -2,7 +2,7 @@
|
||||
"name": "feishin",
|
||||
"productName": "Feishin",
|
||||
"description": "Feishin music server",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"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",
|
||||
@@ -26,7 +26,7 @@
|
||||
"start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts",
|
||||
"test": "jest",
|
||||
"prepare": "husky install",
|
||||
"i18next": "i18next -c src/renderer/i18n/i18next-parser.config.js",
|
||||
"i18next": "i18next -c src/i18n/i18next-parser.config.js",
|
||||
"prod:buildserver": "pwsh -c \"./scripts/server-build.ps1\"",
|
||||
"prod:publishserver": "pwsh -c \"./scripts/server-publish.ps1\""
|
||||
},
|
||||
@@ -93,6 +93,39 @@
|
||||
],
|
||||
"icon": "assets/icons/icon.ico"
|
||||
},
|
||||
"deb": {
|
||||
"depends": [
|
||||
"libgssapi_krb5.so.2",
|
||||
"libavahi-common.so.3",
|
||||
"libavahi-client.so.3",
|
||||
"libkrb5.so.3",
|
||||
"libkrb5support.so.0",
|
||||
"libkeyutils.so.1",
|
||||
"libcups.so.2"
|
||||
]
|
||||
},
|
||||
"rpm": {
|
||||
"depends": [
|
||||
"libgssapi_krb5.so.2",
|
||||
"libavahi-common.so.3",
|
||||
"libavahi-client.so.3",
|
||||
"libkrb5.so.3",
|
||||
"libkrb5support.so.0",
|
||||
"libkeyutils.so.1",
|
||||
"libcups.so.2"
|
||||
]
|
||||
},
|
||||
"freebsd": {
|
||||
"depends": [
|
||||
"libgssapi_krb5.so.2",
|
||||
"libavahi-common.so.3",
|
||||
"libavahi-client.so.3",
|
||||
"libkrb5.so.3",
|
||||
"libkrb5support.so.0",
|
||||
"libkeyutils.so.1",
|
||||
"libcups.so.2"
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
@@ -219,7 +252,7 @@
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"husky": "^7.0.4",
|
||||
"i18next-parser": "^6.3.0",
|
||||
"i18next-parser": "^6.6.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^27.5.1",
|
||||
"lint-staged": "^12.3.7",
|
||||
@@ -273,6 +306,7 @@
|
||||
"@tanstack/react-query-devtools": "^4.32.1",
|
||||
"@tanstack/react-query-persist-client": "^4.32.1",
|
||||
"@ts-rest/core": "^3.23.0",
|
||||
"@xhayper/discord-rpc": "^1.0.24",
|
||||
"axios": "^1.4.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
@@ -287,7 +321,7 @@
|
||||
"framer-motion": "^10.13.0",
|
||||
"fuse.js": "^6.6.2",
|
||||
"history": "^5.3.0",
|
||||
"i18next": "^21.6.16",
|
||||
"i18next": "^21.10.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^9.0.21",
|
||||
"is-electron": "^2.2.2",
|
||||
@@ -302,11 +336,11 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-i18next": "^11.16.7",
|
||||
"react-i18next": "^11.18.6",
|
||||
"react-icons": "^4.10.1",
|
||||
"react-player": "^2.11.0",
|
||||
"react-router": "^6.5.0",
|
||||
"react-router-dom": "^6.5.0",
|
||||
"react-router": "^6.16.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"react-simple-img": "^3.0.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.17",
|
||||
"react-window": "^1.8.9",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishin",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"description": "",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
const en = require('./locales/en.json');
|
||||
|
||||
const resources = {
|
||||
en: { translation: en },
|
||||
};
|
||||
|
||||
export const Languages = [
|
||||
{
|
||||
label: 'English',
|
||||
value: 'en',
|
||||
},
|
||||
];
|
||||
|
||||
i18n
|
||||
.use(initReactI18next) // passes i18n down to react-i18next
|
||||
.init({
|
||||
fallbackLng: 'en',
|
||||
// language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
|
||||
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
|
||||
// if you're using a language detector, do not define the lng option
|
||||
interpolation: {
|
||||
escapeValue: false, // react already safes from xss
|
||||
},
|
||||
|
||||
lng: 'en',
|
||||
|
||||
resources,
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { PostProcessorModule } from 'i18next';
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import en from './locales/en.json';
|
||||
|
||||
const resources = {
|
||||
en: { translation: en },
|
||||
};
|
||||
|
||||
export const languages = [
|
||||
{
|
||||
label: 'English',
|
||||
value: 'en',
|
||||
},
|
||||
];
|
||||
|
||||
const lowerCasePostProcessor: PostProcessorModule = {
|
||||
type: 'postProcessor',
|
||||
name: 'lowerCase',
|
||||
process: (value: string) => {
|
||||
return value.toLocaleLowerCase();
|
||||
},
|
||||
};
|
||||
|
||||
const upperCasePostProcessor: PostProcessorModule = {
|
||||
type: 'postProcessor',
|
||||
name: 'upperCase',
|
||||
process: (value: string) => {
|
||||
return value.toLocaleUpperCase();
|
||||
},
|
||||
};
|
||||
|
||||
const titleCasePostProcessor: PostProcessorModule = {
|
||||
type: 'postProcessor',
|
||||
name: 'titleCase',
|
||||
process: (value: string) => {
|
||||
return value.replace(/\w\S*/g, (txt) => {
|
||||
return txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const sentenceCasePostProcessor: PostProcessorModule = {
|
||||
type: 'postProcessor',
|
||||
name: 'sentenceCase',
|
||||
process: (value: string) => {
|
||||
const sentences = value.split('. ');
|
||||
|
||||
return sentences
|
||||
.map((sentence) => {
|
||||
return sentence.charAt(0).toUpperCase() + sentence.slice(1).toLocaleLowerCase();
|
||||
})
|
||||
.join('. ');
|
||||
},
|
||||
};
|
||||
i18n.use(lowerCasePostProcessor)
|
||||
.use(upperCasePostProcessor)
|
||||
.use(titleCasePostProcessor)
|
||||
.use(sentenceCasePostProcessor)
|
||||
.use(initReactI18next) // passes i18n down to react-i18next
|
||||
.init({
|
||||
fallbackLng: 'en',
|
||||
// language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
|
||||
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
|
||||
// if you're using a language detector, do not define the lng option
|
||||
interpolation: {
|
||||
escapeValue: false, // react already safes from xss
|
||||
},
|
||||
resources,
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -1,117 +1,44 @@
|
||||
// i18next-parser.config.js
|
||||
// Reference: https://github.com/i18next/i18next-parser#options
|
||||
|
||||
module.exports = {
|
||||
contextSeparator: '_',
|
||||
// Key separator used in your translation keys
|
||||
|
||||
createOldCatalogs: true,
|
||||
|
||||
// Exit with an exit code of 1 when translations are updated (for CI purpose)
|
||||
customValueTemplate: null,
|
||||
|
||||
// Save the \_old files
|
||||
defaultNamespace: 'translation',
|
||||
|
||||
// Default namespace used in your i18next config
|
||||
defaultValue: '',
|
||||
|
||||
// Exit with an exit code of 1 on warnings
|
||||
failOnUpdate: false,
|
||||
|
||||
// Display info about the parsing including some stats
|
||||
failOnWarnings: false,
|
||||
|
||||
// The locale to compare with default values to determine whether a default value has been changed.
|
||||
// If this is set and a default value differs from a translation in the specified locale, all entries
|
||||
// for that key across locales are reset to the default value, and existing translations are moved to
|
||||
// the `_old` file.
|
||||
i18nextOptions: null,
|
||||
|
||||
// Default value to give to empty keys
|
||||
// You may also specify a function accepting the locale, namespace, and key as arguments
|
||||
indentation: 2,
|
||||
|
||||
// Plural separator used in your translation keys
|
||||
// If you want to use plain english keys, separators such as `_` might conflict. You might want to set `pluralSeparator` to a different string that does not occur in your keys.
|
||||
input: [
|
||||
'../components/**/*.{js,jsx,ts,tsx}',
|
||||
'../features/**/*.{js,jsx,ts,tsx}',
|
||||
'../layouts/**/*.{js,jsx,ts,tsx}',
|
||||
'!../../src/node_modules/**',
|
||||
'!../../src/**/*.prod.js',
|
||||
],
|
||||
|
||||
// Indentation of the catalog files
|
||||
keepRemoved: false,
|
||||
|
||||
// Keep keys from the catalog that are no longer in code
|
||||
keySeparator: '.',
|
||||
|
||||
// Key separator used in your translation keys
|
||||
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
|
||||
// see below for more details
|
||||
lexers: {
|
||||
default: ['JavascriptLexer'],
|
||||
handlebars: ['HandlebarsLexer'],
|
||||
|
||||
hbs: ['HandlebarsLexer'],
|
||||
htm: ['HTMLLexer'],
|
||||
|
||||
html: ['HTMLLexer'],
|
||||
js: ['JavascriptLexer'],
|
||||
jsx: ['JsxLexer'],
|
||||
|
||||
mjs: ['JavascriptLexer'],
|
||||
// if you're writing jsx inside .js files, change this to JsxLexer
|
||||
ts: ['JavascriptLexer'],
|
||||
|
||||
tsx: ['JsxLexer'],
|
||||
},
|
||||
|
||||
lineEnding: 'auto',
|
||||
|
||||
// Control the line ending. See options at https://github.com/ryanve/eol
|
||||
locales: ['en'],
|
||||
|
||||
// An array of the locales in your applications
|
||||
namespaceSeparator: false,
|
||||
|
||||
// Namespace separator used in your translation keys
|
||||
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
|
||||
output: 'src/renderer/i18n/locales/$LOCALE.json',
|
||||
|
||||
// Supports $LOCALE and $NAMESPACE injection
|
||||
// Supports JSON (.json) and YAML (.yml) file formats
|
||||
// Where to write the locale files relative to process.cwd()
|
||||
pluralSeparator: '_',
|
||||
|
||||
// If you wish to customize the value output the value as an object, you can set your own format.
|
||||
// ${defaultValue} is the default value you set in your translation function.
|
||||
// Any other custom property will be automatically extracted.
|
||||
//
|
||||
// Example:
|
||||
// {
|
||||
// message: "${defaultValue}",
|
||||
// description: "${maxLength}", //
|
||||
// }
|
||||
resetDefaultValueLocale: 'en',
|
||||
|
||||
// Whether or not to sort the catalog. Can also be a [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#parameters)
|
||||
skipDefaultValues: false,
|
||||
|
||||
// An array of globs that describe where to look for source files
|
||||
// relative to the location of the configuration file
|
||||
sort: true,
|
||||
|
||||
// Whether to ignore default values
|
||||
// You may also specify a function accepting the locale and namespace as arguments
|
||||
useKeysAsDefaultValue: true,
|
||||
|
||||
// Whether to use the keys as the default value; ex. "Hello": "Hello", "World": "World"
|
||||
// This option takes precedence over the `defaultValue` and `skipDefaultValues` options
|
||||
// You may also specify a function accepting the locale and namespace as arguments
|
||||
verbose: false,
|
||||
// If you wish to customize options in internally used i18next instance, you can define an object with any
|
||||
// configuration property supported by i18next (https://www.i18next.com/overview/configuration-options).
|
||||
// { compatibilityJSON: 'v3' } can be used to generate v3 compatible plurals.
|
||||
contextSeparator: '_',
|
||||
createOldCatalogs: true,
|
||||
customValueTemplate: null,
|
||||
defaultNamespace: 'translation',
|
||||
defaultValue: '',
|
||||
failOnUpdate: false,
|
||||
failOnWarnings: false,
|
||||
i18nextOptions: null,
|
||||
indentation: 4,
|
||||
input: [
|
||||
'../renderer/components/**/*.{js,jsx,ts,tsx}',
|
||||
'../renderer/features/**/*.{js,jsx,ts,tsx}',
|
||||
'../renderer/layouts/**/*.{js,jsx,ts,tsx}',
|
||||
'!../src/node_modules/**',
|
||||
'!../src/**/*.prod.js',
|
||||
],
|
||||
keepRemoved: false,
|
||||
keySeparator: '.',
|
||||
lexers: {
|
||||
default: ['JavascriptLexer'],
|
||||
handlebars: ['HandlebarsLexer'],
|
||||
hbs: ['HandlebarsLexer'],
|
||||
htm: ['HTMLLexer'],
|
||||
html: ['HTMLLexer'],
|
||||
js: ['JavascriptLexer'],
|
||||
jsx: ['JsxLexer'],
|
||||
mjs: ['JavascriptLexer'],
|
||||
ts: ['JavascriptLexer'],
|
||||
tsx: ['JsxLexer'],
|
||||
},
|
||||
lineEnding: 'auto',
|
||||
locales: ['en'],
|
||||
namespaceSeparator: false,
|
||||
output: 'src/renderer/i18n/locales/$LOCALE.json',
|
||||
pluralSeparator: '_',
|
||||
resetDefaultValueLocale: 'en',
|
||||
skipDefaultValues: false,
|
||||
sort: true,
|
||||
useKeysAsDefaultValue: true,
|
||||
verbose: false,
|
||||
};
|
||||
|
||||
+604
-7
@@ -1,9 +1,606 @@
|
||||
{
|
||||
"player": {
|
||||
"next": "player.next",
|
||||
"play": "player.play",
|
||||
"prev": "player.prev",
|
||||
"seekBack": "player.seekBack",
|
||||
"seekForward": "player.seekForward"
|
||||
}
|
||||
"action": {
|
||||
"addToFavorites": "add to $t(entity.favorite_other)",
|
||||
"addToPlaylist": "add to $t(entity.playlist_one)",
|
||||
"clearQueue": "clear queue",
|
||||
"createPlaylist": "create $t(entity.playlist_one)",
|
||||
"deletePlaylist": "delete $t(entity.playlist_one)",
|
||||
"deselectAll": "deselect all",
|
||||
"editPlaylist": "edit $t(entity.playlist_one)",
|
||||
"goToPage": "go to page",
|
||||
"moveToBottom": "move to bottom",
|
||||
"moveToTop": "move to top",
|
||||
"refresh": "$t(common.refresh)",
|
||||
"removeFromFavorites": "remove from $t(entity.favorite_other)",
|
||||
"removeFromPlaylist": "remove from $t(entity.playlist_one)",
|
||||
"removeFromQueue": "remove from queue",
|
||||
"setRating": "set rating",
|
||||
"toggleSmartPlaylistEditor": "toggle $t(entity.smartPlaylist) editor",
|
||||
"viewPlaylists": "view $t(entity.playlist_other)"
|
||||
},
|
||||
"common": {
|
||||
"action_one": "action",
|
||||
"action_other": "actions",
|
||||
"add": "add",
|
||||
"areYouSure": "are you sure?",
|
||||
"ascending": "ascending",
|
||||
"backward": "backward",
|
||||
"biography": "biography",
|
||||
"bitrate": "bitrate",
|
||||
"bpm": "bpm",
|
||||
"cancel": "cancel",
|
||||
"center": "center",
|
||||
"channel_one": "channel",
|
||||
"channel_other": "channels",
|
||||
"clear": "clear",
|
||||
"collapse": "collapse",
|
||||
"comingSoon": "coming soon...",
|
||||
"configure": "configure",
|
||||
"confirm": "confirm",
|
||||
"create": "create",
|
||||
"currentSong": "current $t(entity.track_one)",
|
||||
"decrease": "decrease",
|
||||
"delete": "delete",
|
||||
"descending": "descending",
|
||||
"description": "description",
|
||||
"disable": "disable",
|
||||
"disc": "disc",
|
||||
"dismiss": "dismiss",
|
||||
"duration": "duration",
|
||||
"edit": "edit",
|
||||
"enable": "enable",
|
||||
"expand": "expand",
|
||||
"favorite": "favorite",
|
||||
"filter_one": "filter",
|
||||
"filter_other": "filters",
|
||||
"filters": "filters",
|
||||
"forceRestartRequired": "restart to apply changes... close the notification to restart",
|
||||
"forward": "forward",
|
||||
"gap": "gap",
|
||||
"home": "home",
|
||||
"increase": "increase",
|
||||
"left": "left",
|
||||
"limit": "limit",
|
||||
"manage": "manage",
|
||||
"maximize": "maximize",
|
||||
"menu": "menu",
|
||||
"minimize": "minimize",
|
||||
"modified": "modified",
|
||||
"name": "name",
|
||||
"no": "no",
|
||||
"none": "none",
|
||||
"noResultsFromQuery": "the query returned no results",
|
||||
"note": "note",
|
||||
"ok": "ok",
|
||||
"owner": "owner",
|
||||
"path": "path",
|
||||
"playerMustBePaused": "player must be paused",
|
||||
"previousSong": "previous $t(entity.track_one)",
|
||||
"quit": "quit",
|
||||
"random": "random",
|
||||
"rating": "rating",
|
||||
"refresh": "refresh",
|
||||
"reset": "reset",
|
||||
"resetToDefault": "reset to default",
|
||||
"restartRequired": "restart required",
|
||||
"right": "right",
|
||||
"save": "save",
|
||||
"saveAndReplace": "save and replace",
|
||||
"saveAs": "save as",
|
||||
"search": "search",
|
||||
"setting": "setting",
|
||||
"setting_other": "settings",
|
||||
"size": "size",
|
||||
"sortOrder": "order",
|
||||
"title": "title",
|
||||
"trackNumber": "track",
|
||||
"unknown": "unknown",
|
||||
"version": "version",
|
||||
"year": "year",
|
||||
"yes": "yes"
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "album",
|
||||
"album_other": "albums",
|
||||
"albumArtist_one": "album artist",
|
||||
"albumArtist_other": "album artists",
|
||||
"albumArtistCount_one": "{{count}} album artist",
|
||||
"albumArtistCount_other": "{{count}} album artists",
|
||||
"albumWithCount_one": "{{count}} album",
|
||||
"albumWithCount_other": "{{count}} albums",
|
||||
"artist_one": "artist",
|
||||
"artist_other": "artists",
|
||||
"artistWithCount_one": "{{count}} artist",
|
||||
"artistWithCount_other": "{{count}} artists",
|
||||
"favorite_one": "favorite",
|
||||
"favorite_other": "favorites",
|
||||
"folder_one": "folder",
|
||||
"folder_other": "folders",
|
||||
"folderWithCount_one": "{{count}} folder",
|
||||
"folderWithCount_other": "{{count}} folders",
|
||||
"genre_one": "genre",
|
||||
"genre_other": "genres",
|
||||
"genreWithCount_one": "{{count}} genre",
|
||||
"genreWithCount_other": "{{count}} genres",
|
||||
"playlist_one": "playlist",
|
||||
"playlist_other": "playlists",
|
||||
"playlistWithCount_one": "{{count}} playlist",
|
||||
"playlistWithCount_other": "{{count}} playlists",
|
||||
"smartPlaylist": "smart $t(entity.playlist_one)",
|
||||
"track_one": "track",
|
||||
"track_other": "tracks",
|
||||
"trackWithCount_one": "{{count}} track",
|
||||
"trackWithCount_other": "{{count}} tracks"
|
||||
},
|
||||
"error": {
|
||||
"apiRouteError": "unable to route request",
|
||||
"audioDeviceFetchError": "an error occurred when trying to get audio devices",
|
||||
"authenticationFailed": "authentication failed",
|
||||
"credentialsRequired": "credentials required",
|
||||
"endpointNotImplementedError": "endpoint {{endpoint} is not implemented for {{serverType}}",
|
||||
"genericError": "an error occurred",
|
||||
"invalidServer": "invalid server",
|
||||
"localFontAccessDenied": "access denied to local fonts",
|
||||
"loginRateError": "too many login attempts, please try again in a few seconds",
|
||||
"mpvRequired": "MPV required",
|
||||
"playbackError": "an error occurred when trying to play the media",
|
||||
"remoteDisableError": "an error occurred when trying to $t(common.disable) the remote server",
|
||||
"remoteEnableError": "an error occurred when trying to $t(common.enable) the remote server",
|
||||
"remotePortError": "an error occurred when trying to set the remote server port",
|
||||
"remotePortWarning": "restart the server to apply the new port",
|
||||
"serverNotSelectedError": "no server selected",
|
||||
"serverRequired": "server required",
|
||||
"sessionExpiredError": "your session has expired",
|
||||
"systemFontError": "an error occurred when trying to get system fonts"
|
||||
},
|
||||
"filter": {
|
||||
"albumArtist": "$t(entity.albumArtist_one)",
|
||||
"artist": "$t(entity.artist_one)",
|
||||
"biography": "biography",
|
||||
"bitrate": "bitrate",
|
||||
"bpm": "bpm",
|
||||
"communityRating": "community rating",
|
||||
"criticRating": "critic rating",
|
||||
"dateAdded": "date added",
|
||||
"disc": "disc",
|
||||
"duration": "duration",
|
||||
"favorited": "favorited",
|
||||
"fromYear": "from year",
|
||||
"isCompilation": "is compilation",
|
||||
"isFavorited": "is favorited",
|
||||
"isRated": "is rated",
|
||||
"isRecentlyPlayed": "is recently played",
|
||||
"lastPlayed": "last played",
|
||||
"mostPlayed": "most played",
|
||||
"name": "name",
|
||||
"note": "note",
|
||||
"path": "path",
|
||||
"playCount": "play count",
|
||||
"random": "random",
|
||||
"rating": "rating",
|
||||
"recentlyAdded": "recently added",
|
||||
"recentlyPlayed": "recently played",
|
||||
"releaseDate": "release date",
|
||||
"releaseYear": "release year",
|
||||
"search": "search",
|
||||
"songCount": "song count",
|
||||
"title": "title",
|
||||
"toYear": "to year",
|
||||
"trackNumber": "track"
|
||||
},
|
||||
"form": {
|
||||
"addServer": {
|
||||
"error_savePassword": "an error occurred when trying to save the password",
|
||||
"ignoreCors": "ignore cors ($t(common.restartRequired))",
|
||||
"ignoreSsl": "ignore ssl ($t(common.restartRequired))",
|
||||
"input_legacyAuthentication": "enable legacy authentication",
|
||||
"input_name": "server name",
|
||||
"input_password": "password",
|
||||
"input_savePassword": "save password",
|
||||
"input_url": "url",
|
||||
"input_username": "username",
|
||||
"success": "server added successfully",
|
||||
"title": "add server"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"input_playlists": "$t(entity.playlist_other)",
|
||||
"input_skipDuplicates": "skip duplicates",
|
||||
"success": "added {{message}} $t(entity.song_other) to {{numOfPlaylists}} $t(entity.playlist_other)",
|
||||
"title": "add to $t(entity.playlist_one)"
|
||||
},
|
||||
"createPlaylist": {
|
||||
"input_description": "$t(common.description)",
|
||||
"input_name": "$t(common.name)",
|
||||
"input_owner": "$t(common.owner)",
|
||||
"input_public": "public",
|
||||
"success": "$t(entity.playlist_one) created successfully",
|
||||
"title": "create $t(entity.playlist_one)"
|
||||
},
|
||||
"deletePlaylist": {
|
||||
"input_confirm": "type the name of the $t(entity.playlist_one) to confirm",
|
||||
"success": "$t(entity.playlist_one) deleted successfully",
|
||||
"title": "delete $t(entity.playlist_one)"
|
||||
},
|
||||
"editPlaylist": {
|
||||
"title": "edit $t(entity.playlist_one)"
|
||||
},
|
||||
"lyricSearch": {
|
||||
"input_artist": "$t(entity.artist_one)",
|
||||
"input_name": "$t(common.name)",
|
||||
"title": "lyric search"
|
||||
},
|
||||
"queryEditor": {
|
||||
"input_optionMatchAll": "match all",
|
||||
"input_optionMatchAny": "match any"
|
||||
},
|
||||
"updateServer": {
|
||||
"success": "server updated successfully",
|
||||
"title": "update server"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"albumArtistList": {
|
||||
"title": "$t(entity.albumArtist_other)"
|
||||
},
|
||||
"albumDetail": {
|
||||
"moreFromArtist": "more from this $t(entity.genre_one)",
|
||||
"moreFromGeneric": "more from {{item}}"
|
||||
},
|
||||
"albumList": {
|
||||
"title": "$t(entity.album_other)"
|
||||
},
|
||||
"appMenu": {
|
||||
"collapseSidebar": "collapse sidebar",
|
||||
"expandSidebar": "expand sidebar",
|
||||
"goBack": "go back",
|
||||
"goForward": "go forward",
|
||||
"manageServers": "manage servers",
|
||||
"openBrowserDevtools": "open browser devtools",
|
||||
"quit": "$t(common.quit)",
|
||||
"selectServer": "select server",
|
||||
"settings": "$t(common.setting_other)",
|
||||
"version": "version {{version}}"
|
||||
},
|
||||
"contextMenu": {
|
||||
"addFavorite": "$t(action.addToFavorites)",
|
||||
"addLast": "$t(player.addLast)",
|
||||
"addNext": "$t(player.addNext)",
|
||||
"addToFavorites": "$t(action.addToFavorites)",
|
||||
"addToPlaylist": "$t(action.addToPlaylist)",
|
||||
"createPlaylist": "$t(action.createPlaylist)",
|
||||
"deletePlaylist": "$t(action.deletePlaylist)",
|
||||
"deselectAll": "$t(action.deselectAll)",
|
||||
"moveToBottom": "$t(action.moveToBottom)",
|
||||
"moveToTop": "$t(action.moveToTop)",
|
||||
"numberSelected": "{{count}} selected",
|
||||
"play": "$t(player.play)",
|
||||
"removeFromFavorites": "$t(action.removeFromFavorites)",
|
||||
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
|
||||
"removeFromQueue": "$t(action.removeFromQueue)",
|
||||
"setRating": "$t(action.setRating)"
|
||||
},
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
"dynamicBackground": "dynamic background",
|
||||
"followCurrentLyric": "follow current lyric",
|
||||
"lyricAlignment": "lyric alignment",
|
||||
"lyricGap": "lyric gap",
|
||||
"lyricSize": "lyric size",
|
||||
"opacity": "opacity",
|
||||
"showLyricMatch": "show lyric match",
|
||||
"showLyricProvider": "show lyric provider",
|
||||
"synchronized": "synchronized",
|
||||
"unsynchronized": "unsynchronized",
|
||||
"useImageAspectRatio": "use image aspect ratio"
|
||||
},
|
||||
"lyrics": "lyrics",
|
||||
"related": "related",
|
||||
"upNext": "up next"
|
||||
},
|
||||
"genreList": {
|
||||
"title": "$t(entity.genre_other)"
|
||||
},
|
||||
"globalSearch": {
|
||||
"commands": {
|
||||
"goToPage": "go to page",
|
||||
"searchFor": "search for {{query}}",
|
||||
"serverCommands": "server commands"
|
||||
},
|
||||
"title": "commands"
|
||||
},
|
||||
"home": {
|
||||
"explore": "explore from your library",
|
||||
"mostPlayed": "most played",
|
||||
"newlyAdded": "newly added releases",
|
||||
"recentlyPlayed": "recently played",
|
||||
"title": "$t(common.home)"
|
||||
},
|
||||
"playlistList": {
|
||||
"title": "$t(entity.playlist_other)"
|
||||
},
|
||||
"setting": {
|
||||
"generalTab": "general",
|
||||
"hotkeysTab": "hotkeys",
|
||||
"playbackTab": "playback",
|
||||
"windowTab": "window"
|
||||
},
|
||||
"sidebar": {
|
||||
"albumArtists": "$t(entity.albumArtist_other)",
|
||||
"albums": "$t(entity.album_other)",
|
||||
"artists": "$t(entity.artist_other)",
|
||||
"folders": "$t(entity.folder_other)",
|
||||
"genres": "$t(entity.genre_other)",
|
||||
"home": "$t(common.home)",
|
||||
"nowPlaying": "now playing",
|
||||
"playlists": "$t(entity.playlist_other)",
|
||||
"search": "$t(common.search)",
|
||||
"settings": "$t(entity.setting_other)",
|
||||
"tracks": "$t(entity.track_other)"
|
||||
},
|
||||
"trackList": {
|
||||
"title": "$t(entity.track_other)"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"addLast": "add last",
|
||||
"addNext": "add next",
|
||||
"favorite": "favorite",
|
||||
"mute": "mute",
|
||||
"muted": "muted",
|
||||
"next": "next",
|
||||
"play": "play",
|
||||
"playbackFetchCancel": "this is taking a while... close the notification to cancel",
|
||||
"playbackFetchInProgress": "loading songs...",
|
||||
"playbackFetchNoResults": "no songs found",
|
||||
"playbackSpeed": "playback speed",
|
||||
"playRandom": "play random",
|
||||
"previous": "previous",
|
||||
"queue_clear": "clear queue",
|
||||
"queue_moveToBottom": "move selected to top",
|
||||
"queue_moveToTop": "move selected to bottom",
|
||||
"queue_remove": "remove selected",
|
||||
"repeat": "repeat",
|
||||
"repeat_all": "repeat all",
|
||||
"repeat_off": "repeat disabled",
|
||||
"repeat_one": "repeat one",
|
||||
"shuffle": "shuffle",
|
||||
"shuffle_off": "shuffle disabled",
|
||||
"skip": "skip",
|
||||
"skip_back": "skip backwards",
|
||||
"skip_forward": "skip forwards",
|
||||
"stop": "stop",
|
||||
"toggleFullscreenPlayer": "toggle fullscreen player",
|
||||
"unfavorite": "unfavorite"
|
||||
},
|
||||
"setting": {
|
||||
"accentColor": "accent color",
|
||||
"accentColor_description": "sets the accent color for the application",
|
||||
"applicationHotkeys": "application hotkeys",
|
||||
"applicationHotkeys_description": "configure application hotkeys. toggle the checkbox to set as a global hotkey (desktop only)",
|
||||
"audioDevice": "audio device",
|
||||
"audioDevice_description": "select the audio device to use for playback (web player only)",
|
||||
"audioExclusiveMode": "audio exclusive mode",
|
||||
"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",
|
||||
"crossfadeDuration": "crossfade duration",
|
||||
"crossfadeDuration_description": "sets the duration of the crossfade effect",
|
||||
"crossfadeStyle": "crossfade style",
|
||||
"crossfadeStyle_description": "select the crossfade style to use for the audio player",
|
||||
"customFontPath": "custom font path",
|
||||
"customFontPath_description": "sets the path to the custom font to use for the application",
|
||||
"disableAutomaticUpdates": "disable automatic updates",
|
||||
"disableLibraryUpdateOnStartup": "disable checking for new versions on startup",
|
||||
"discordApplicationId": "{{discord}} application id",
|
||||
"discordApplicationId_description": "the application id for {{discord}} rich presence (defaults to {{defaultId}}",
|
||||
"discordIdleStatus": "show rich presence idle status",
|
||||
"discordIdleStatus_description": "when enabled, update status while player is idle",
|
||||
"discordRichPresence": "{{discord}} rich presence",
|
||||
"discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}} ",
|
||||
"discordUpdateInterval": "{{discord}} rich presence update interval",
|
||||
"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",
|
||||
"exitToTray": "exit to tray",
|
||||
"exitToTray_description": "exit the application to the system tray",
|
||||
"floatingQueueArea": "show floating queue hover area",
|
||||
"floatingQueueArea_description": "display a hover icon on the right side of the screen to view the play queue",
|
||||
"followLyric": "follow current lyric",
|
||||
"followLyric_description": "scroll the lyric to the current playing position",
|
||||
"font": "font",
|
||||
"font_description": "sets the font to use for the application",
|
||||
"fontType": "font type",
|
||||
"fontType_description": "built-in font selects one of the fonts provided by Feishin. system font allows you to select any font provided by your operating system. custom allows you to provide your own font",
|
||||
"fontType_optionBuiltIn": "built-in font",
|
||||
"fontType_optionCustom": "custom font",
|
||||
"fontType_optionSystem": "system font",
|
||||
"gaplessAudio": "gapless audio",
|
||||
"gaplessAudio_description": "sets the gapless audio setting for mpv",
|
||||
"gaplessAudio_optionWeak": "weak (recommended)",
|
||||
"globalMediaHotkeys": "global media hotkeys",
|
||||
"globalMediaHotkeys_description": "enable or disable the usage of your system media hotkeys to control playback",
|
||||
"hotkey_browserBack": "browser back",
|
||||
"hotkey_browserForward": "browser forward",
|
||||
"hotkey_favoriteCurrentSong": "favorite $t(common.currentSong)",
|
||||
"hotkey_favoritePreviousSong": "favorite $t(common.previousSong)",
|
||||
"hotkey_globalSearch": "global search",
|
||||
"hotkey_localSearch": "in-page search",
|
||||
"hotkey_playbackNext": "next track",
|
||||
"hotkey_playbackPause": "pause",
|
||||
"hotkey_playbackPlay": "play",
|
||||
"hotkey_playbackPlayPause": "play / pause",
|
||||
"hotkey_playbackPrevious": "previous track",
|
||||
"hotkey_playbackStop": "stop",
|
||||
"hotkey_rate0": "rating clear",
|
||||
"hotkey_rate1": "rating 1 star",
|
||||
"hotkey_rate2": "rating 2 stars",
|
||||
"hotkey_rate3": "rating 3 stars",
|
||||
"hotkey_rate4": "rating 4 stars",
|
||||
"hotkey_rate5": "rating 5 stars",
|
||||
"hotkey_skipBackward": "skip backward",
|
||||
"hotkey_skipForward": "skip forward",
|
||||
"hotkey_toggleCurrentSongFavorite": "toggle $t(common.currentSong) favorite",
|
||||
"hotkey_toggleFullScreenPlayer": "toggle full screen player",
|
||||
"hotkey_togglePreviousSongFavorite": "toggle $t(common.previousSong) favorite",
|
||||
"hotkey_toggleQueue": "toggle queue",
|
||||
"hotkey_toggleRepeat": "toggle repeat",
|
||||
"hotkey_toggleShuffle": "toggle shuffle",
|
||||
"hotkey_unfavoriteCurrentSong": "unfavorite $t(common.currentSong)",
|
||||
"hotkey_unfavoritePreviousSong": "unfavorite $t(common.previousSong)",
|
||||
"hotkey_volumeDown": "volume down",
|
||||
"hotkey_volumeMute": "volume mute",
|
||||
"hotkey_volumeUp": "volume up",
|
||||
"hotkey_zoomIn": "zoom in",
|
||||
"hotkey_zoomOut": "zoom out",
|
||||
"language": "language",
|
||||
"language_description": "sets the language for the application ($t(common.restartRequired))",
|
||||
"lyricFetch": "fetch lyrics from the internet",
|
||||
"lyricFetch_description": "fetch lyrics from various internet sources",
|
||||
"lyricFetchProvider": "providers to fetch lyrics from",
|
||||
"lyricFetchProvider_description": "select the providers to fetch lyrics from. the order of the providers is the order in which they will be queried",
|
||||
"lyricOffset": "lyric offset (ms)",
|
||||
"lyricOffset_description": "offset the lyric by the specified amount of milliseconds",
|
||||
"minimizeToTray": "minimize to tray",
|
||||
"minimizeToTray_description": "minimize the application to the system tray",
|
||||
"minimumScrobblePercentage": "minimum scrobble duration (percentage)",
|
||||
"minimumScrobblePercentage_description": "the minimum percentage of the song that must be played before it is scrobbled",
|
||||
"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",
|
||||
"mpvExtraParameters": "mpv parameters",
|
||||
"playbackStyle": "playback style",
|
||||
"playbackStyle_description": "select the playback style to use for the audio player",
|
||||
"playbackStyle_optionCrossFade": "crossfade",
|
||||
"playbackStyle_optionNormal": "normal",
|
||||
"playButtonBehavior": "play button behavior",
|
||||
"playButtonBehavior_description": "sets the default behavior of the play button when adding songs to the queue",
|
||||
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||
"playButtonBehavior_optionPlay": "$t(player.play)",
|
||||
"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",
|
||||
"remotePort_description": "sets the port for the remote control server",
|
||||
"remoteUsername": "remote control server username",
|
||||
"remoteUsername_description": "sets the username for the remote control server. if both username and password are empty, authentication will be disabled",
|
||||
"replayGainClipping": "{{ReplayGain}} clipping",
|
||||
"replayGainClipping_description": "Prevent clipping caused by {{ReplayGain}} by automatically lowering the gain",
|
||||
"replayGainFallback": "{{ReplayGain}} fallback",
|
||||
"replayGainFallback_description": "gain in db to apply if the file has no {{ReplayGain}} tags",
|
||||
"replayGainMode": "{{ReplayGain}} mode",
|
||||
"replayGainMode_description": "adjust volume gain according to {{ReplayGain}} values stored in the file metadata",
|
||||
"replayGainMode_optionAlbum": "$t(entity.album_one)",
|
||||
"replayGainMode_optionNone": "$t(common.none)",
|
||||
"replayGainMode_optionTrack": "$t(entity.track_one)",
|
||||
"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",
|
||||
"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",
|
||||
"scrobble_description": "scrobble plays to your media server",
|
||||
"showSkipButton": "show skip buttons",
|
||||
"showSkipButton_description": "show or hide the skip buttons on the player bar",
|
||||
"showSkipButtons": "show skip buttons",
|
||||
"showSkipButtons_description": "show or hide the skip buttons on the player bar",
|
||||
"sidebarCollapsedNavigation": "sidebar (collapsed) navigation",
|
||||
"sidebarCollapsedNavigation_description": "show or hide the navigation in the collapsed sidebar",
|
||||
"sidebarConfiguration": "sidebar configuration",
|
||||
"sidebarConfiguration_description": "select the items and order in which they appear in the sidebar",
|
||||
"sidebarPlaylistList": "sidebar playlist list",
|
||||
"sidebarPlaylistList_description": "show or hide the playlist list in the sidebar",
|
||||
"sidePlayQueueStyle": "side play queue style",
|
||||
"sidePlayQueueStyle_description": "sets the style of the side play queue",
|
||||
"sidePlayQueueStyle_optionAttached": "attached",
|
||||
"sidePlayQueueStyle_optionDetached": "detached",
|
||||
"skipDuration": "skip duration",
|
||||
"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",
|
||||
"theme": "theme",
|
||||
"theme_description": "sets the theme to use for the application",
|
||||
"themeDark": "theme (dark)",
|
||||
"themeDark_description": "sets the dark theme to use for the application",
|
||||
"themeLight": "theme (light)",
|
||||
"themeLight_description": "sets the light theme to use for the application",
|
||||
"useSystemTheme": "use system theme",
|
||||
"useSystemTheme_description": "follow the system-defined light or dark preference",
|
||||
"volumeWheelStep": "volume wheel step",
|
||||
"volumeWheelStep_description": "the amount of volume to change when scrolling the mouse wheel on the volume slider",
|
||||
"windowBarStyle": "window bar style",
|
||||
"windowBarStyle_description": "select the style of the window bar",
|
||||
"zoom": "zoom percentage",
|
||||
"zoom_description": "sets the zoom percentage for the application"
|
||||
},
|
||||
"table": {
|
||||
"column": {
|
||||
"album": "album",
|
||||
"albumArtist": "album artist",
|
||||
"albumCount": "$t(entity.album_other)",
|
||||
"artist": "$t(entity.artist_one)",
|
||||
"biography": "biography",
|
||||
"bitrate": "bitrate",
|
||||
"bpm": "bpm",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"comment": "comment",
|
||||
"dateAdded": "date added",
|
||||
"discNumber": "disc",
|
||||
"favorite": "favorite",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"lastPlayed": "last played",
|
||||
"path": "path",
|
||||
"playCount": "plays",
|
||||
"rating": "rating",
|
||||
"releaseDate": "release date",
|
||||
"releaseYear": "year",
|
||||
"songCount": "$t(entity.track_other)",
|
||||
"title": "title",
|
||||
"trackNumber": "track"
|
||||
},
|
||||
"config": {
|
||||
"general": {
|
||||
"autoFitColumns": "auto fit columns",
|
||||
"displayType": "display type",
|
||||
"gap": "$t(common.gap)",
|
||||
"size": "$t(common.size)",
|
||||
"tableColumns": "table columns"
|
||||
},
|
||||
"label": {
|
||||
"actions": "$t(common.action_other)",
|
||||
"album": "$t(entity.album_one)",
|
||||
"albumArtist": "$t(entity.albumArtist_one)",
|
||||
"artist": "$t(entity.artist_one)",
|
||||
"biography": "$t(common.biography)",
|
||||
"bitrate": "$t(common.bitrate)",
|
||||
"bpm": "$t(common.bpm)",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"dateAdded": "date added",
|
||||
"discNumber": "disc number",
|
||||
"duration": "$t(common.duration)",
|
||||
"favorite": "$t(common.favorite)",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"lastPlayed": "last played",
|
||||
"note": "$t(common.note)",
|
||||
"owner": "$t(common.owner)",
|
||||
"path": "$t(common.path)",
|
||||
"playCount": "play count",
|
||||
"rating": "$t(common.rating)",
|
||||
"releaseDate": "release date",
|
||||
"rowIndex": "row index",
|
||||
"size": "$t(common.size)",
|
||||
"title": "$t(common.title)",
|
||||
"titleCombined": "$t(common.title) (combined)",
|
||||
"trackNumber": "track number",
|
||||
"year": "$t(common.year)"
|
||||
},
|
||||
"view": {
|
||||
"card": "card",
|
||||
"poster": "poster",
|
||||
"table": "table"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Client, SetActivity } from '@xhayper/discord-rpc';
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
const FEISHIN_DISCORD_APPLICATION_ID = '1165957668758900787';
|
||||
|
||||
let client: Client | null = null;
|
||||
|
||||
const createClient = (clientId?: string) => {
|
||||
client = new Client({
|
||||
clientId: clientId || FEISHIN_DISCORD_APPLICATION_ID,
|
||||
});
|
||||
|
||||
client.login();
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
const setActivity = (activity: SetActivity) => {
|
||||
if (client) {
|
||||
client.user?.setActivity({
|
||||
...activity,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const clearActivity = () => {
|
||||
if (client) {
|
||||
client.user?.clearActivity();
|
||||
}
|
||||
};
|
||||
|
||||
const quit = () => {
|
||||
if (client) {
|
||||
client?.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
ipcMain.handle('discord-rpc-initialize', (_event, clientId?: string) => {
|
||||
createClient(clientId);
|
||||
});
|
||||
|
||||
ipcMain.handle('discord-rpc-set-activity', (_event, activity: SetActivity) => {
|
||||
if (client) {
|
||||
setActivity(activity);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('discord-rpc-clear-activity', () => {
|
||||
if (client) {
|
||||
clearActivity();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('discord-rpc-quit', () => {
|
||||
quit();
|
||||
});
|
||||
|
||||
export const discordRpc = {
|
||||
clearActivity,
|
||||
createClient,
|
||||
quit,
|
||||
setActivity,
|
||||
};
|
||||
@@ -2,3 +2,4 @@ import './lyrics';
|
||||
import './player';
|
||||
import './remote';
|
||||
import './settings';
|
||||
import './discord-rpc';
|
||||
|
||||
@@ -112,18 +112,16 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
|
||||
|
||||
try {
|
||||
if (data.queue.current) {
|
||||
getMpvInstance()
|
||||
await getMpvInstance()
|
||||
?.load(data.queue.current.streamUrl, 'replace')
|
||||
.then(() => {
|
||||
// eslint-disable-next-line promise/always-return
|
||||
if (data.queue.next) {
|
||||
getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to load song', err);
|
||||
getMpvInstance()?.play();
|
||||
});
|
||||
|
||||
if (data.queue.next) {
|
||||
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { deflate, gzip } from 'zlib';
|
||||
import axios from 'axios';
|
||||
import { app, ipcMain } from 'electron';
|
||||
import { Server as WsServer, WebSocketServer, WebSocket } from 'ws';
|
||||
import manifest from './manifest.json';
|
||||
import { ClientEvent, ServerEvent } from '../../../../remote/types';
|
||||
import { PlayerRepeat, SongUpdate } from '../../../../renderer/types';
|
||||
import { getMainWindow } from '../../../main';
|
||||
@@ -297,6 +298,12 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
|
||||
await serveFile(req, 'remote', 'js', res);
|
||||
break;
|
||||
}
|
||||
case '/manifest.json': {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(manifest));
|
||||
break;
|
||||
}
|
||||
case '/credentials': {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
@@ -304,9 +311,13 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
res.statusCode = 404;
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.end('Not FOund');
|
||||
if (req.url?.startsWith('/worker.js')) {
|
||||
await serveFile(req, 'worker', 'js', res);
|
||||
} else {
|
||||
res.statusCode = 404;
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.end('Not Found');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Feishin Remote",
|
||||
"short_name": "Feishin Remote",
|
||||
"start_url": "/",
|
||||
"background_color": "#000100",
|
||||
"theme_color": "#E7E7E7",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
}
|
||||
],
|
||||
"display": "standalone",
|
||||
"orientation": "portrait"
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
Menu,
|
||||
nativeImage,
|
||||
BrowserWindowConstructorOptions,
|
||||
protocol,
|
||||
net,
|
||||
} from 'electron';
|
||||
import electronLocalShortcut from 'electron-localshortcut';
|
||||
import log from 'electron-log';
|
||||
@@ -43,6 +45,8 @@ export default class AppUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]);
|
||||
|
||||
process.on('uncaughtException', (error: any) => {
|
||||
console.log('Error in main process', error);
|
||||
});
|
||||
@@ -259,6 +263,11 @@ const createWindow = async () => {
|
||||
mainWindow?.close();
|
||||
});
|
||||
|
||||
ipcMain.on('window-quit', () => {
|
||||
mainWindow?.close();
|
||||
app.exit();
|
||||
});
|
||||
|
||||
ipcMain.on('app-restart', () => {
|
||||
// Fix for .AppImage
|
||||
if (process.env.APPIMAGE) {
|
||||
@@ -648,8 +657,34 @@ app.on('window-all-closed', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const FONT_HEADERS = [
|
||||
'font/collection',
|
||||
'font/otf',
|
||||
'font/sfnt',
|
||||
'font/ttf',
|
||||
'font/woff',
|
||||
'font/woff2',
|
||||
];
|
||||
|
||||
app.whenReady()
|
||||
.then(() => {
|
||||
protocol.handle('feishin', async (request) => {
|
||||
const filePath = `file://${request.url.slice('feishin://'.length)}`;
|
||||
const response = await net.fetch(filePath);
|
||||
const contentType = response.headers.get('content-type');
|
||||
|
||||
if (!contentType || !FONT_HEADERS.includes(contentType)) {
|
||||
getMainWindow()?.webContents.send('custom-font-error', filePath);
|
||||
|
||||
return new Response(null, {
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
createWindow();
|
||||
createTray();
|
||||
app.on('activate', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { contextBridge } from 'electron';
|
||||
import { browser } from './preload/browser';
|
||||
import { discordRpc } from './preload/discord-rpc';
|
||||
import { ipc } from './preload/ipc';
|
||||
import { localSettings } from './preload/local-settings';
|
||||
import { lyrics } from './preload/lyrics';
|
||||
@@ -10,6 +11,7 @@ import { utils } from './preload/utils';
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
browser,
|
||||
discordRpc,
|
||||
ipc,
|
||||
localSettings,
|
||||
lyrics,
|
||||
|
||||
@@ -3,16 +3,23 @@ import { ipcRenderer } from 'electron';
|
||||
const exit = () => {
|
||||
ipcRenderer.send('window-close');
|
||||
};
|
||||
|
||||
const maximize = () => {
|
||||
ipcRenderer.send('window-maximize');
|
||||
};
|
||||
|
||||
const minimize = () => {
|
||||
ipcRenderer.send('window-minimize');
|
||||
};
|
||||
|
||||
const unmaximize = () => {
|
||||
ipcRenderer.send('window-unmaximize');
|
||||
};
|
||||
|
||||
const quit = () => {
|
||||
ipcRenderer.send('window-quit');
|
||||
};
|
||||
|
||||
const devtools = () => {
|
||||
ipcRenderer.send('window-dev-tools');
|
||||
};
|
||||
@@ -22,5 +29,6 @@ export const browser = {
|
||||
exit,
|
||||
maximize,
|
||||
minimize,
|
||||
quit,
|
||||
unmaximize,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SetActivity } from '@xhayper/discord-rpc';
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
const initialize = (clientId: string) => {
|
||||
const client = ipcRenderer.invoke('discord-rpc-initialize', clientId);
|
||||
return client;
|
||||
};
|
||||
|
||||
const clearActivity = () => {
|
||||
ipcRenderer.invoke('discord-rpc-clear-activity');
|
||||
};
|
||||
|
||||
const setActivity = (activity: SetActivity) => {
|
||||
ipcRenderer.invoke('discord-rpc-set-activity', activity);
|
||||
};
|
||||
|
||||
const quit = () => {
|
||||
ipcRenderer.invoke('discord-rpc-quit');
|
||||
};
|
||||
|
||||
export const discordRpc = {
|
||||
clearActivity,
|
||||
initialize,
|
||||
quit,
|
||||
setActivity,
|
||||
};
|
||||
|
||||
export type DiscordRpc = typeof discordRpc;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ipcRenderer, webFrame } from 'electron';
|
||||
import { IpcRendererEvent, ipcRenderer, webFrame } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
|
||||
const store = new Store();
|
||||
@@ -39,9 +39,14 @@ const setZoomFactor = (zoomFactor: number) => {
|
||||
webFrame.setZoomFactor(zoomFactor / 100);
|
||||
};
|
||||
|
||||
const fontError = (cb: (event: IpcRendererEvent, file: string) => void) => {
|
||||
ipcRenderer.on('custom-font-error', cb);
|
||||
};
|
||||
|
||||
export const localSettings = {
|
||||
disableMediaKeys,
|
||||
enableMediaKeys,
|
||||
fontError,
|
||||
get,
|
||||
passwordGet,
|
||||
passwordRemove,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Group, Image, Rating, Text, Title } from '@mantine/core';
|
||||
import { Group, Image, Text, Title } from '@mantine/core';
|
||||
import { useInfo, useSend, useShowImage } from '/@/remote/store';
|
||||
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
|
||||
import formatDuration from 'format-duration';
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { PlayerRepeat, PlayerStatus } from '/@/renderer/types';
|
||||
import { WrapperSlider } from '/@/remote/components/wrapped-slider';
|
||||
import { Tooltip } from '/@/renderer/components/tooltip';
|
||||
import { Rating } from '/@/renderer/components';
|
||||
|
||||
export const RemoteContainer = () => {
|
||||
const { repeat, shuffle, song, status, volume } = useInfo();
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
<meta http-equiv="Content-Security-Policy" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Feishin Remote</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
const version = encodeURIComponent("<%= version %>");
|
||||
const prod = encodeURIComponent("<%= prod %>");
|
||||
navigator.serviceWorker.register(`/worker.js?version=${version}&prod=${prod}`);
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Feishin Remote",
|
||||
"short_name": "Feishin Remote",
|
||||
"start_url": "/",
|
||||
"background_color": "#FFDCB5",
|
||||
"theme_color": "#1E003D",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
}
|
||||
],
|
||||
"display": "standalone",
|
||||
"orientation": "portrait"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/// <reference lib="WebWorker" />
|
||||
|
||||
export type {};
|
||||
// eslint-disable-next-line no-undef
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
const url = new URL(location.toString());
|
||||
const version = url.searchParams.get('version');
|
||||
const prod = url.searchParams.get('prod') === 'true';
|
||||
const cacheName = `Feishin-remote-${version}`;
|
||||
|
||||
const resourcesToCache = ['./', './remote.js', './favicon.ico'];
|
||||
|
||||
if (prod) {
|
||||
resourcesToCache.push('./remote.css');
|
||||
}
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(
|
||||
caches.open(cacheName).then((cache) => {
|
||||
return cache.addAll(resourcesToCache);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
e.respondWith(
|
||||
caches.match(e.request).then((response) => {
|
||||
return response || fetch(e.request);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys().then((keyList) => {
|
||||
return Promise.all(
|
||||
keyList.map((key) => {
|
||||
if (key !== cacheName) {
|
||||
return caches.delete(key);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -194,7 +194,6 @@ export const useRemoteStore = create<SettingsSlice>()(
|
||||
});
|
||||
},
|
||||
send: (data: ClientEvent) => {
|
||||
console.log(data, get().socket);
|
||||
get().socket?.send(JSON.stringify(data));
|
||||
},
|
||||
toggleIsDark: () => {
|
||||
|
||||
@@ -54,6 +54,7 @@ import { DeletePlaylistResponse, RandomSongListArgs } from './types';
|
||||
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
|
||||
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
|
||||
import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
export type ControllerEndpoint = Partial<{
|
||||
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
|
||||
@@ -128,7 +129,7 @@ const endpoints: ApiController = {
|
||||
getPlaylistList: jfController.getPlaylistList,
|
||||
getPlaylistSongList: jfController.getPlaylistSongList,
|
||||
getRandomSongList: jfController.getRandomSongList,
|
||||
getSongDetail: undefined,
|
||||
getSongDetail: jfController.getSongDetail,
|
||||
getSongList: jfController.getSongList,
|
||||
getTopSongs: jfController.getTopSongList,
|
||||
getUserList: undefined,
|
||||
@@ -212,7 +213,12 @@ const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) =>
|
||||
const serverType = type || useAuthStore.getState().currentServer?.type;
|
||||
|
||||
if (!serverType) {
|
||||
toast.error({ message: 'No server selected', title: 'Unable to route request' });
|
||||
toast.error({
|
||||
message: i18n.t('error.serverNotSelectedError', {
|
||||
postProcess: 'sentenceCase',
|
||||
}) as string,
|
||||
title: i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' }) as string,
|
||||
});
|
||||
throw new Error(`No server selected`);
|
||||
}
|
||||
|
||||
@@ -221,10 +227,16 @@ const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) =>
|
||||
if (typeof controllerFn !== 'function') {
|
||||
toast.error({
|
||||
message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
|
||||
title: 'Unable to route request',
|
||||
title: i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' }) as string,
|
||||
});
|
||||
|
||||
throw new Error(`Endpoint ${endpoint} is not implemented for ${serverType}`);
|
||||
throw new Error(
|
||||
i18n.t('error.endpointNotImplementedError', {
|
||||
endpoint,
|
||||
postProcess: 'sentenceCase',
|
||||
serverType,
|
||||
}) as string,
|
||||
);
|
||||
}
|
||||
|
||||
return endpoints[serverType][endpoint];
|
||||
|
||||
@@ -547,6 +547,7 @@ export enum JFAlbumListSort {
|
||||
COMMUNITY_RATING = 'CommunityRating,SortName',
|
||||
CRITIC_RATING = 'CriticRating,SortName',
|
||||
NAME = 'SortName',
|
||||
PLAY_COUNT = 'PlayCount',
|
||||
RANDOM = 'Random,SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
RELEASE_DATE = 'ProductionYear,PremiereDate,SortName',
|
||||
|
||||
@@ -160,7 +160,7 @@ export const contract = c.router({
|
||||
},
|
||||
getSongDetail: {
|
||||
method: 'GET',
|
||||
path: 'song/:id',
|
||||
path: 'users/:userId/items/:id',
|
||||
responses: {
|
||||
200: jfType._response.song,
|
||||
400: jfType._response.error,
|
||||
|
||||
@@ -47,6 +47,8 @@ import {
|
||||
LyricsArgs,
|
||||
LyricsResponse,
|
||||
genreListSortMap,
|
||||
SongDetailArgs,
|
||||
SongDetailResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||
import { jfNormalize } from './jellyfin-normalize';
|
||||
@@ -100,9 +102,9 @@ const authenticate = async (
|
||||
Username: body.username,
|
||||
},
|
||||
headers: {
|
||||
'x-emby-authorization': `MediaBrowser Client="Feishin", Device="${getHostname()}", DeviceId="Feishin", Version="${
|
||||
packageJson.version
|
||||
}"`,
|
||||
'x-emby-authorization': `MediaBrowser Client="Feishin", Device="${getHostname()}", DeviceId="Feishin-${getHostname()}-${
|
||||
body.username
|
||||
}", Version="${packageJson.version}"`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -559,20 +561,6 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const musicFoldersRes = await jfApiClient(apiClientProps).getMusicFolderList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (musicFoldersRes.status !== 200) {
|
||||
throw new Error('Failed playlist folder');
|
||||
}
|
||||
|
||||
const playlistFolder = musicFoldersRes.body.Items.filter(
|
||||
(folder) => folder.CollectionType === jfType._enum.collection.PLAYLISTS,
|
||||
)?.[0];
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getPlaylistList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
@@ -581,7 +569,8 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
|
||||
Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
|
||||
IncludeItemTypes: 'Playlist',
|
||||
Limit: query.limit,
|
||||
ParentId: playlistFolder?.Id,
|
||||
MediaTypes: 'Audio',
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: playlistListSortMap.jellyfin[query.sortBy],
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
@@ -934,12 +923,29 @@ const getLyrics = async (args: LyricsArgs): Promise<LyricsResponse> => {
|
||||
}
|
||||
|
||||
if (res.body.Lyrics.length > 0 && res.body.Lyrics[0].Start === undefined) {
|
||||
return res.body.Lyrics[0].Text;
|
||||
return res.body.Lyrics.map((lyric) => lyric.Text).join('\n');
|
||||
}
|
||||
|
||||
return res.body.Lyrics.map((lyric) => [lyric.Start! / 1e4, lyric.Text]);
|
||||
};
|
||||
|
||||
const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getSongDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
userId: apiClientProps.server?.userId ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song detail');
|
||||
}
|
||||
|
||||
return jfNormalize.song(res.body, apiClientProps.server, '');
|
||||
};
|
||||
|
||||
export const jfController = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
@@ -959,6 +965,7 @@ export const jfController = {
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getRandomSongList,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getTopSongList,
|
||||
removeFromPlaylist,
|
||||
|
||||
@@ -478,6 +478,7 @@ const albumListSort = {
|
||||
COMMUNITY_RATING: 'CommunityRating,SortName',
|
||||
CRITIC_RATING: 'CriticRating,SortName',
|
||||
NAME: 'SortName',
|
||||
PLAY_COUNT: 'PlayCount',
|
||||
RANDOM: 'Random,SortName',
|
||||
RECENTLY_ADDED: 'DateCreated,SortName',
|
||||
RELEASE_DATE: 'ProductionYear,PremiereDate,SortName',
|
||||
|
||||
@@ -9,6 +9,7 @@ import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils'
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
import { toast } from '/@/renderer/components';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
const localSettings = isElectron() ? window.electron.localSettings : null;
|
||||
|
||||
@@ -276,9 +277,12 @@ axiosClient.interceptors.response.use(
|
||||
|
||||
if (res.status === 429) {
|
||||
toast.error({
|
||||
message:
|
||||
'you have exceeded the number of allowed login requests. Please wait before logging, or consider tweaking AuthRequestLimit',
|
||||
title: 'Your session has expired.',
|
||||
message: i18n.t('error.loginRateError', {
|
||||
postProcess: 'sentenceCase',
|
||||
}) as string,
|
||||
title: i18n.t('error.sessionExpiredError', {
|
||||
postProcess: 'sentenceCase',
|
||||
}) as string,
|
||||
});
|
||||
|
||||
const serverId = currentServer.id;
|
||||
@@ -292,7 +296,11 @@ axiosClient.interceptors.response.use(
|
||||
throw TIMEOUT_ERROR;
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to authenticate');
|
||||
throw new Error(
|
||||
i18n.t('error.authenticatedFailed', {
|
||||
postProcess: 'sentenceCase',
|
||||
}) as string,
|
||||
);
|
||||
}
|
||||
|
||||
const newCredential = res.data.token;
|
||||
|
||||
@@ -20,10 +20,6 @@ const getImageUrl = (args: { url: string | null }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url?.match('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
@@ -186,7 +182,16 @@ const normalizeAlbumArtist = (
|
||||
},
|
||||
server: ServerListItem | null,
|
||||
): AlbumArtist => {
|
||||
const imageUrl = getImageUrl({ url: item?.largeImageUrl || null });
|
||||
let imageUrl = getImageUrl({ url: item?.largeImageUrl || null });
|
||||
|
||||
if (!imageUrl) {
|
||||
imageUrl = getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: `ar-${item.id}`,
|
||||
credential: server?.credential,
|
||||
size: 100,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
albumCount: item.albumCount,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { z } from 'zod';
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import { ServerListItem } from '/@/renderer/api/types';
|
||||
import { toast } from '/@/renderer/components/toast/index';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
const c = initContract();
|
||||
|
||||
@@ -106,7 +107,7 @@ axiosClient.interceptors.response.use(
|
||||
if (data['subsonic-response'].error.code !== 0) {
|
||||
toast.error({
|
||||
message: data['subsonic-response'].error.message,
|
||||
title: 'Issue from Subsonic API',
|
||||
title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,7 +395,7 @@ export const albumListSortMap: AlbumListSortMap = {
|
||||
duration: undefined,
|
||||
favorited: undefined,
|
||||
name: JFAlbumListSort.NAME,
|
||||
playCount: undefined,
|
||||
playCount: JFAlbumListSort.PLAY_COUNT,
|
||||
random: JFAlbumListSort.RANDOM,
|
||||
rating: undefined,
|
||||
recentlyAdded: JFAlbumListSort.RECENTLY_ADDED,
|
||||
@@ -1130,3 +1130,12 @@ export enum LyricSource {
|
||||
}
|
||||
|
||||
export type LyricsOverride = Omit<FullLyricsMetadata, 'lyrics'> & { id: string };
|
||||
|
||||
// This type from https://wicg.github.io/local-font-access/#fontdata
|
||||
// NOTE: it is still experimental, so this should be updates as appropriate
|
||||
export type FontData = {
|
||||
family: string;
|
||||
fullName: string;
|
||||
postscriptName: string;
|
||||
style: string;
|
||||
};
|
||||
|
||||
+54
-6
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
|
||||
import { ModuleRegistry } from '@ag-grid-community/core';
|
||||
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
|
||||
@@ -23,8 +23,10 @@ import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
||||
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
|
||||
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
|
||||
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store';
|
||||
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
|
||||
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';
|
||||
|
||||
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
|
||||
|
||||
@@ -37,17 +39,56 @@ const remote = isElectron() ? window.electron.remote : null;
|
||||
|
||||
export const App = () => {
|
||||
const theme = useTheme();
|
||||
const contentFont = useSettingsStore((state) => state.general.fontContent);
|
||||
const accent = useSettingsStore((store) => store.general.accent);
|
||||
const language = useSettingsStore((store) => store.general.language);
|
||||
const { builtIn, custom, system, type } = useSettingsStore((state) => state.font);
|
||||
const { type: playbackType } = usePlaybackSettings();
|
||||
const { bindings } = useHotkeySettings();
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
const { clearQueue, restoreQueue } = useQueueControls();
|
||||
const remoteSettings = useRemoteSettings();
|
||||
const textStyleRef = useRef<HTMLStyleElement>();
|
||||
useDiscordRpc();
|
||||
|
||||
useEffect(() => {
|
||||
if (type === FontType.SYSTEM && system) {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--content-font-family', 'dynamic-font');
|
||||
|
||||
if (!textStyleRef.current) {
|
||||
textStyleRef.current = document.createElement('style');
|
||||
document.body.appendChild(textStyleRef.current);
|
||||
}
|
||||
|
||||
textStyleRef.current.textContent = `
|
||||
@font-face {
|
||||
font-family: "dynamic-font";
|
||||
src: local("${system}");
|
||||
}`;
|
||||
} else if (type === FontType.CUSTOM && custom) {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--content-font-family', 'dynamic-font');
|
||||
|
||||
if (!textStyleRef.current) {
|
||||
textStyleRef.current = document.createElement('style');
|
||||
document.body.appendChild(textStyleRef.current);
|
||||
}
|
||||
|
||||
textStyleRef.current.textContent = `
|
||||
@font-face {
|
||||
font-family: "dynamic-font";
|
||||
src: url("feishin://${custom}");
|
||||
}`;
|
||||
} else {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--content-font-family', builtIn);
|
||||
}
|
||||
}, [builtIn, custom, system, type]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--content-font-family', contentFont);
|
||||
}, [contentFont]);
|
||||
root.style.setProperty('--primary-color', accent);
|
||||
}, [accent]);
|
||||
|
||||
const providerValue = useMemo(() => {
|
||||
return { handlePlayQueueAdd };
|
||||
@@ -62,7 +103,8 @@ export const App = () => {
|
||||
|
||||
if (!isRunning) {
|
||||
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
|
||||
const properties = {
|
||||
const properties: Record<string, any> = {
|
||||
speed: usePlayerStore.getState().current.speed,
|
||||
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
|
||||
};
|
||||
|
||||
@@ -138,6 +180,12 @@ export const App = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (language) {
|
||||
i18n.changeLanguage(language);
|
||||
}
|
||||
}, [language]);
|
||||
|
||||
return (
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import type { CrossfadeStyle } from '/@/renderer/types';
|
||||
import { PlaybackStyle, PlayerStatus } from '/@/renderer/types';
|
||||
import { useSpeed } from '/@/renderer/store';
|
||||
|
||||
interface AudioPlayerProps extends ReactPlayerProps {
|
||||
crossfadeDuration: number;
|
||||
@@ -59,6 +60,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 playbackSpeed = useSpeed();
|
||||
|
||||
const [webAudio, setWebAudio] = useState<WebAudio | null>(null);
|
||||
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(
|
||||
@@ -307,6 +309,7 @@ export const AudioPlayer = forwardRef(
|
||||
}}
|
||||
height={0}
|
||||
muted={muted}
|
||||
playbackRate={playbackSpeed}
|
||||
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
|
||||
progressInterval={isTransitioning ? 10 : 250}
|
||||
url={player1?.streamUrl}
|
||||
@@ -325,6 +328,7 @@ export const AudioPlayer = forwardRef(
|
||||
}}
|
||||
height={0}
|
||||
muted={muted}
|
||||
playbackRate={playbackSpeed}
|
||||
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
|
||||
progressInterval={isTransitioning ? 10 : 250}
|
||||
url={player2?.streamUrl}
|
||||
|
||||
@@ -23,7 +23,7 @@ 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();
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -39,8 +39,9 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
|
||||
}
|
||||
|
||||
&:not([data-disabled])&:hover {
|
||||
color: ${(props) => `var(--btn-${props.variant}-fg-hover) !important`};
|
||||
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
|
||||
color: ${(props) => `var(--btn-${props.variant}-fg) !important`};
|
||||
background: ${(props) => `var(--btn-${props.variant}-bg)`};
|
||||
filter: brightness(85%);
|
||||
border: ${(props) => `var(--btn-${props.variant}-border-hover)`};
|
||||
|
||||
svg {
|
||||
@@ -50,11 +51,8 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
|
||||
|
||||
&:not([data-disabled])&:focus-visible {
|
||||
color: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
|
||||
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: none;
|
||||
background: ${(props) => `var(--btn-${props.variant}-bg)`};
|
||||
filter: brightness(85%);
|
||||
}
|
||||
|
||||
& .mantine-Button-centerLoader {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
||||
import { Group, Image, Stack } from '@mantine/core';
|
||||
import type { Variants } from 'framer-motion';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
|
||||
import { Link, generatePath } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
@@ -109,6 +110,7 @@ interface FeatureCarouselProps {
|
||||
}
|
||||
|
||||
export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
const { t } = useTranslation();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const [itemIndex, setItemIndex] = useState(0);
|
||||
const [direction, setDirection] = useState(0);
|
||||
@@ -224,7 +226,7 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
Play
|
||||
{t('player.play', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<Group spacing="sm">
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { isValidElement, memo, ReactNode, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
isValidElement,
|
||||
memo,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
|
||||
@@ -109,6 +118,10 @@ export const SwiperGridCarousel = ({
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
useEffect(() => {
|
||||
swiperRef.current?.slideTo(0, 0);
|
||||
}, [data]);
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
hasNextPage: (data?.length || 0) > Math.round(3),
|
||||
hasPreviousPage: false,
|
||||
|
||||
@@ -6,14 +6,21 @@ import { Button } from '/@/renderer/components/button';
|
||||
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
|
||||
import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-builder-option';
|
||||
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
const FILTER_GROUP_OPTIONS_DATA = [
|
||||
{
|
||||
label: 'Match all',
|
||||
label: i18n.t('form.queryEditor.input', {
|
||||
context: 'optionMatchAll',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
label: 'Match any',
|
||||
label: i18n.t('form.queryEditor.input', {
|
||||
context: 'optionMatchAny',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
value: 'any',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import { MouseEvent } from 'react';
|
||||
import { Rating as MantineRating, RatingProps as MantineRatingProps } from '@mantine/core';
|
||||
import { useCallback } from 'react';
|
||||
import { Rating as MantineRating, RatingProps } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import styled from 'styled-components';
|
||||
import { Tooltip } from '/@/renderer/components/tooltip';
|
||||
|
||||
interface RatingProps extends Omit<MantineRatingProps, 'onClick'> {
|
||||
onClick: (e: MouseEvent<HTMLDivElement>, value: number | undefined) => void;
|
||||
}
|
||||
|
||||
const StyledRating = styled(MantineRating)`
|
||||
& .mantine-Rating-symbolBody {
|
||||
@@ -16,18 +12,32 @@ const StyledRating = styled(MantineRating)`
|
||||
}
|
||||
`;
|
||||
|
||||
export const Rating = ({ onClick, ...props }: RatingProps) => {
|
||||
// const debouncedOnClick = debounce(onClick, 100);
|
||||
export const Rating = ({ onChange, ...props }: RatingProps) => {
|
||||
const valueChange = useCallback(
|
||||
(rating: number) => {
|
||||
if (onChange) {
|
||||
if (rating === props.value) {
|
||||
onChange(0);
|
||||
} else {
|
||||
onChange(rating);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onChange, props.value],
|
||||
);
|
||||
|
||||
const debouncedOnChange = debounce(valueChange, 100);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label="Double click to clear"
|
||||
openDelay={1000}
|
||||
>
|
||||
<StyledRating
|
||||
{...props}
|
||||
onDoubleClick={(e) => onClick(e, props.value)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<StyledRating
|
||||
{...props}
|
||||
onChange={(e) => {
|
||||
debouncedOnChange(e);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ const StyledTabs = styled(MantineTabs)`
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { MouseEvent } from 'react';
|
||||
import type { ICellRendererParams } from '@ag-grid-community/core';
|
||||
import { Rating } from '/@/renderer/components/rating';
|
||||
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
|
||||
@@ -9,8 +8,6 @@ export const RatingCell = ({ value, node }: ICellRendererParams) => {
|
||||
const updateRatingMutation = useSetRating({});
|
||||
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
if (!value) return;
|
||||
|
||||
updateRatingMutation.mutate(
|
||||
{
|
||||
query: {
|
||||
@@ -27,32 +24,12 @@ export const RatingCell = ({ value, node }: ICellRendererParams) => {
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearRating = (e: MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateRatingMutation.mutate(
|
||||
{
|
||||
query: {
|
||||
item: [value],
|
||||
rating: 0,
|
||||
},
|
||||
serverId: value?.serverId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
node.setData({ ...node.data, userRating: 0 });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<CellContainer $position="center">
|
||||
<Rating
|
||||
size="xs"
|
||||
value={value?.userRating}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
</CellContainer>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { ICellRendererParams } from '@ag-grid-community/core';
|
||||
import { Text } from '/@/renderer/components/text';
|
||||
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
|
||||
|
||||
const AnimatedSvg = () => {
|
||||
return (
|
||||
<div style={{ height: '1rem', transform: 'rotate(180deg)', width: '1rem' }}>
|
||||
<svg
|
||||
viewBox="100 130 57 80"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<rect
|
||||
fill="var(--primary-color)"
|
||||
height="80"
|
||||
id="bar-1"
|
||||
width="12"
|
||||
x="100"
|
||||
y="130"
|
||||
>
|
||||
<animate
|
||||
attributeName="height"
|
||||
begin="0.1s"
|
||||
calcMode="spline"
|
||||
dur="0.95s"
|
||||
keySplines="0.42 0 0.58 1; 0.42 0 0.58 1"
|
||||
keyTimes="0; 0.47368; 1"
|
||||
repeatCount="indefinite"
|
||||
values="80;15;80"
|
||||
/>
|
||||
</rect>
|
||||
<rect
|
||||
fill="var(--primary-color)"
|
||||
height="80"
|
||||
id="bar-2"
|
||||
width="12"
|
||||
x="115"
|
||||
y="130"
|
||||
>
|
||||
<animate
|
||||
attributeName="height"
|
||||
begin="0.1s"
|
||||
calcMode="spline"
|
||||
dur="0.95s"
|
||||
keySplines="0.45 0 0.55 1; 0.45 0 0.55 1"
|
||||
keyTimes="0; 0.44444; 1"
|
||||
repeatCount="indefinite"
|
||||
values="25;80;25"
|
||||
/>
|
||||
</rect>
|
||||
<rect
|
||||
fill="var(--primary-color)"
|
||||
height="80"
|
||||
id="bar-3"
|
||||
width="12"
|
||||
x="130"
|
||||
y="130"
|
||||
>
|
||||
<animate
|
||||
attributeName="height"
|
||||
begin="0.1s"
|
||||
calcMode="spline"
|
||||
dur="0.85s"
|
||||
keySplines="0.65 0 0.35 1; 0.65 0 0.35 1"
|
||||
keyTimes="0; 0.42105; 1"
|
||||
repeatCount="indefinite"
|
||||
values="80;10;80"
|
||||
/>
|
||||
</rect>
|
||||
<rect
|
||||
fill="var(--primary-color)"
|
||||
height="80"
|
||||
id="bar-4"
|
||||
width="12"
|
||||
x="145"
|
||||
y="130"
|
||||
>
|
||||
<animate
|
||||
attributeName="height"
|
||||
begin="0.1s"
|
||||
calcMode="spline"
|
||||
dur="1.05s"
|
||||
keySplines="0.42 0 0.58 1; 0.42 0 0.58 1"
|
||||
keyTimes="0; 0.31579; 1"
|
||||
repeatCount="indefinite"
|
||||
values="30;80;30"
|
||||
/>
|
||||
</rect>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StaticSvg = () => {
|
||||
return (
|
||||
<div style={{ height: '1rem', transform: 'rotate(180deg)', width: '1rem' }}>
|
||||
<svg
|
||||
viewBox="100 130 57 80"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
fill="var(--primary-color)"
|
||||
height="20"
|
||||
width="12"
|
||||
x="100"
|
||||
y="130"
|
||||
/>
|
||||
<rect
|
||||
fill="var(--primary-color)"
|
||||
height="60"
|
||||
width="12"
|
||||
x="115"
|
||||
y="130"
|
||||
/>
|
||||
<rect
|
||||
fill="var(--primary-color)"
|
||||
height="80"
|
||||
width="12"
|
||||
x="130"
|
||||
y="130"
|
||||
/>
|
||||
<rect
|
||||
fill="var(--primary-color)"
|
||||
height="45"
|
||||
width="12"
|
||||
x="145"
|
||||
y="130"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const RowIndexCell = ({ value, eGridCell }: ICellRendererParams) => {
|
||||
const classList = eGridCell.classList;
|
||||
const isFocused = classList.contains('focused');
|
||||
const isPlaying = classList.contains('playing');
|
||||
const isCurrentSong =
|
||||
classList.contains('current-song-cell') || classList.contains('current-playlist-song-cell');
|
||||
|
||||
return (
|
||||
<CellContainer $position="right">
|
||||
{isPlaying &&
|
||||
(isFocused && isCurrentSong ? (
|
||||
<AnimatedSvg />
|
||||
) : isCurrentSong ? (
|
||||
<StaticSvg />
|
||||
) : null)}
|
||||
<Text
|
||||
$secondary
|
||||
align="right"
|
||||
className="current-song-child current-song-index"
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</CellContainer>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import { MutableRefObject, useEffect, useMemo, useRef } from 'react';
|
||||
import { RowClassRules, RowNode } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { MutableRefObject, useEffect, useMemo } from 'react';
|
||||
import { Song } from '/@/renderer/api/types';
|
||||
import { useAppFocus } from '/@/renderer/hooks';
|
||||
import { useCurrentSong, usePlayerStore } from '/@/renderer/store';
|
||||
import { PlayerStatus } from '/@/renderer/types';
|
||||
|
||||
interface UseCurrentSongRowStylesProps {
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
@@ -10,17 +12,44 @@ interface UseCurrentSongRowStylesProps {
|
||||
|
||||
export const useCurrentSongRowStyles = ({ tableRef }: UseCurrentSongRowStylesProps) => {
|
||||
const currentSong = useCurrentSong();
|
||||
const isFocused = useAppFocus();
|
||||
const isFocusedRef = useRef<boolean>(isFocused);
|
||||
|
||||
useEffect(() => {
|
||||
// Redraw rows if the app focus changes
|
||||
if (isFocusedRef.current !== isFocused) {
|
||||
isFocusedRef.current = isFocused;
|
||||
if (tableRef?.current) {
|
||||
const { api, columnApi } = tableRef?.current || {};
|
||||
if (api == null || columnApi == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentNode = currentSong?.id ? api.getRowNode(currentSong.id) : undefined;
|
||||
|
||||
const rowNodes = [currentNode].filter((e) => e !== undefined) as RowNode<any>[];
|
||||
|
||||
if (rowNodes) {
|
||||
api.redrawRows({ rowNodes });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentSong?.id, isFocused, tableRef]);
|
||||
|
||||
const rowClassRules = useMemo<RowClassRules<Song> | undefined>(() => {
|
||||
return {
|
||||
'current-song': (params) => {
|
||||
return params?.data?.id === currentSong?.id;
|
||||
return (
|
||||
currentSong?.id !== undefined &&
|
||||
params?.data?.id === currentSong?.id &&
|
||||
params?.data?.albumId === currentSong?.albumId
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [currentSong?.id]);
|
||||
}, [currentSong?.albumId, currentSong?.id]);
|
||||
|
||||
// Redraw song rows when current song changes
|
||||
useEffect(() => {
|
||||
// Redraw song rows when current song changes
|
||||
const unsubSongChange = usePlayerStore.subscribe(
|
||||
(state) => state.current.song,
|
||||
(song, previousSong) => {
|
||||
@@ -46,8 +75,32 @@ export const useCurrentSongRowStyles = ({ tableRef }: UseCurrentSongRowStylesPro
|
||||
{ equalityFn: (a, b) => a?.id === b?.id },
|
||||
);
|
||||
|
||||
// Redraw song rows when the status changes
|
||||
const unsubStatusChange = usePlayerStore.subscribe(
|
||||
(state) => [state.current.status, state.current.song],
|
||||
(current: (PlayerStatus | Song | undefined)[]) => {
|
||||
const song = current[1] as Song;
|
||||
|
||||
if (tableRef?.current) {
|
||||
const { api, columnApi } = tableRef?.current || {};
|
||||
if (api == null || columnApi == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentNode = song?.id ? api.getRowNode(song.id) : undefined;
|
||||
const rowNodes = [currentNode].filter((e) => e !== undefined) as RowNode<any>[];
|
||||
|
||||
api.redrawRows({ rowNodes });
|
||||
}
|
||||
},
|
||||
{
|
||||
equalityFn: (a, b) => (a[0] as PlayerStatus) === (b[0] as PlayerStatus),
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubSongChange();
|
||||
unsubStatusChange();
|
||||
};
|
||||
}, [tableRef]);
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export type AgGridFetchFn<TResponse, TFilter> = (
|
||||
) => Promise<TResponse>;
|
||||
|
||||
interface UseAgGridProps<TFilter> {
|
||||
columnType?: 'albumDetail' | 'generic';
|
||||
contextMenu: SetContextMenuItems;
|
||||
customFilters?: Partial<TFilter>;
|
||||
isClientSideSort?: boolean;
|
||||
@@ -52,6 +53,7 @@ export const useVirtualTable = <TFilter>({
|
||||
customFilters,
|
||||
isSearchParams,
|
||||
isClientSideSort,
|
||||
columnType,
|
||||
}: UseAgGridProps<TFilter>) => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
@@ -75,8 +77,8 @@ export const useVirtualTable = <TFilter>({
|
||||
const isPaginationEnabled = properties.display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(() => {
|
||||
return getColumnDefs(properties.table.columns, true);
|
||||
}, [properties.table.columns]);
|
||||
return getColumnDefs(properties.table.columns, true, columnType);
|
||||
}, [columnType, properties.table.columns]);
|
||||
|
||||
const defaultColumnDefs: ColDef = useMemo(() => {
|
||||
return {
|
||||
|
||||
@@ -29,7 +29,11 @@ import { GenreCell } from '/@/renderer/components/virtual-table/cells/genre-cell
|
||||
import { GenericTableHeader } from '/@/renderer/components/virtual-table/headers/generic-table-header';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { PersistedTableColumn } from '/@/renderer/store/settings.store';
|
||||
import { TableColumn, TablePagination as TablePaginationType } from '/@/renderer/types';
|
||||
import {
|
||||
PlayerStatus,
|
||||
TableColumn,
|
||||
TablePagination as TablePaginationType,
|
||||
} from '/@/renderer/types';
|
||||
import { FavoriteCell } from '/@/renderer/components/virtual-table/cells/favorite-cell';
|
||||
import { RatingCell } from '/@/renderer/components/virtual-table/cells/rating-cell';
|
||||
import { TablePagination } from '/@/renderer/components/virtual-table/table-pagination';
|
||||
@@ -37,6 +41,8 @@ import { ActionsCell } from '/@/renderer/components/virtual-table/cells/actions-
|
||||
import { TitleCell } from '/@/renderer/components/virtual-table/cells/title-cell';
|
||||
import { useFixedTableHeader } from '/@/renderer/components/virtual-table/hooks/use-fixed-table-header';
|
||||
import { NoteCell } from '/@/renderer/components/virtual-table/cells/note-cell';
|
||||
import { RowIndexCell } from '/@/renderer/components/virtual-table/cells/row-index-cell';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
export * from './table-config-dropdown';
|
||||
export * from './table-pagination';
|
||||
@@ -72,7 +78,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
cellRenderer: (params: ICellRendererParams) =>
|
||||
GenericCell(params, { isLink: true, position: 'left' }),
|
||||
colId: TableColumn.ALBUM,
|
||||
headerName: 'Album',
|
||||
headerName: i18n.t('table.column.album'),
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data
|
||||
? {
|
||||
@@ -87,7 +93,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
albumArtist: {
|
||||
cellRenderer: AlbumArtistCell,
|
||||
colId: TableColumn.ALBUM_ARTIST,
|
||||
headerName: 'Album Artist',
|
||||
headerName: i18n.t('table.column.albumArtist'),
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.albumArtists : undefined,
|
||||
width: 150,
|
||||
@@ -98,7 +104,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'albumCount',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Albums',
|
||||
headerName: i18n.t('table.column.albumCount'),
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.albumCount : undefined,
|
||||
@@ -107,7 +113,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
artist: {
|
||||
cellRenderer: ArtistCell,
|
||||
colId: TableColumn.ARTIST,
|
||||
headerName: 'Artist',
|
||||
headerName: i18n.t('table.column.artist'),
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.artists : undefined),
|
||||
width: 150,
|
||||
},
|
||||
@@ -115,7 +121,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
|
||||
colId: TableColumn.BIOGRAPHY,
|
||||
field: 'biography',
|
||||
headerName: 'Biography',
|
||||
headerName: i18n.t('table.column.biography'),
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.biography : ''),
|
||||
width: 200,
|
||||
},
|
||||
@@ -125,6 +131,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'bitRate',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: i18n.t('table.column.bitrate'),
|
||||
suppressSizeToFit: true,
|
||||
valueFormatter: (params: ValueFormatterParams) => `${params.value} kbps`,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.bitRate : undefined),
|
||||
@@ -135,7 +142,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
colId: TableColumn.BPM,
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'BPM',
|
||||
headerName: i18n.t('table.column.bpm'),
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.bpm : undefined),
|
||||
width: 60,
|
||||
@@ -146,6 +153,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'channels',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: i18n.t('table.column.channels'),
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.channels : undefined,
|
||||
width: 100,
|
||||
@@ -153,7 +161,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
comment: {
|
||||
cellRenderer: NoteCell,
|
||||
colId: TableColumn.COMMENT,
|
||||
headerName: 'Note',
|
||||
headerName: i18n.t('table.column.comment'),
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.comment : undefined),
|
||||
width: 150,
|
||||
},
|
||||
@@ -163,7 +171,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'createdAt',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Date Added',
|
||||
headerName: i18n.t('table.column.dateAdded'),
|
||||
suppressSizeToFit: true,
|
||||
valueFormatter: (params: ValueFormatterParams) =>
|
||||
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
|
||||
@@ -177,7 +185,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'discNumber',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Disc',
|
||||
headerName: i18n.t('table.column.discNumber'),
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.discNumber : undefined,
|
||||
@@ -198,7 +206,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
genre: {
|
||||
cellRenderer: GenreCell,
|
||||
colId: TableColumn.GENRE,
|
||||
headerName: 'Genre',
|
||||
headerName: i18n.t('table.column.genre'),
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.genres : undefined),
|
||||
width: 100,
|
||||
},
|
||||
@@ -207,7 +215,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
colId: TableColumn.LAST_PLAYED,
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Last Played',
|
||||
headerName: i18n.t('table.column.lastPlayed'),
|
||||
valueFormatter: (params: ValueFormatterParams) =>
|
||||
params.value ? dayjs(params.value).fromNow() : '',
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
@@ -217,7 +225,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
path: {
|
||||
cellRenderer: GenericCell,
|
||||
colId: TableColumn.PATH,
|
||||
headerName: 'Path',
|
||||
headerName: i18n.t('table.column.path'),
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.path : undefined),
|
||||
width: 200,
|
||||
},
|
||||
@@ -227,7 +235,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'playCount',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Plays',
|
||||
headerName: i18n.t('table.column.playCount'),
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.playCount : undefined,
|
||||
@@ -239,7 +247,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'releaseDate',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Release Date',
|
||||
headerName: i18n.t('table.column.releaseDate'),
|
||||
suppressSizeToFit: true,
|
||||
valueFormatter: (params: ValueFormatterParams) =>
|
||||
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
|
||||
@@ -253,13 +261,14 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'releaseYear',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Year',
|
||||
headerName: i18n.t('table.column.releaseYear'),
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.releaseYear : undefined,
|
||||
width: 80,
|
||||
},
|
||||
rowIndex: {
|
||||
cellClass: 'row-index',
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'right' }),
|
||||
colId: TableColumn.ROW_INDEX,
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
@@ -270,13 +279,46 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
},
|
||||
width: 65,
|
||||
},
|
||||
rowIndexGeneric: {
|
||||
cellClass: 'row-index',
|
||||
cellClassRules: {
|
||||
'current-playlist-song-cell': (params) => {
|
||||
return (
|
||||
params.context?.currentSong?.uniqueId !== undefined &&
|
||||
params.data?.uniqueId === params.context?.currentSong?.uniqueId
|
||||
);
|
||||
},
|
||||
'current-song-cell': (params) => {
|
||||
return (
|
||||
params.context?.currentSong?.id !== undefined &&
|
||||
params.data?.id === params.context?.currentSong?.id &&
|
||||
params.data?.albumId === params.context?.currentSong?.albumId
|
||||
);
|
||||
},
|
||||
focused: (params) => {
|
||||
return params.context?.isFocused;
|
||||
},
|
||||
playing: (params) => {
|
||||
return params.context?.status === PlayerStatus.PLAYING;
|
||||
},
|
||||
},
|
||||
cellRenderer: RowIndexCell,
|
||||
colId: TableColumn.ROW_INDEX,
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'right', preset: 'rowIndex' }),
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params) => {
|
||||
return (params.node?.rowIndex || 0) + 1;
|
||||
},
|
||||
width: 65,
|
||||
},
|
||||
songCount: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.SONG_COUNT,
|
||||
field: 'songCount',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Songs',
|
||||
headerName: i18n.t('table.column.songCount'),
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.songCount : undefined,
|
||||
@@ -286,14 +328,14 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
cellRenderer: TitleCell,
|
||||
colId: TableColumn.TITLE,
|
||||
field: 'name',
|
||||
headerName: 'Title',
|
||||
headerName: i18n.t('table.column.title'),
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.name : undefined),
|
||||
width: 250,
|
||||
},
|
||||
titleCombined: {
|
||||
cellRenderer: CombinedTitleCell,
|
||||
colId: TableColumn.TITLE_COMBINED,
|
||||
headerName: 'Title',
|
||||
headerName: i18n.t('table.column.title'),
|
||||
initialWidth: 500,
|
||||
minWidth: 150,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
@@ -311,12 +353,41 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
width: 250,
|
||||
},
|
||||
trackNumber: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
cellClass: 'track-number',
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'right' }),
|
||||
colId: TableColumn.TRACK_NUMBER,
|
||||
field: 'trackNumber',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Track',
|
||||
headerName: i18n.t('table.column.trackNumber'),
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.trackNumber : undefined,
|
||||
width: 80,
|
||||
},
|
||||
trackNumberDetail: {
|
||||
cellClass: 'row-index',
|
||||
cellClassRules: {
|
||||
'current-song-cell': (params) => {
|
||||
return (
|
||||
params.context?.currentSong?.id !== undefined &&
|
||||
params.data?.id === params.context?.currentSong?.id &&
|
||||
params.data?.albumId === params.context?.currentSong?.albumId
|
||||
);
|
||||
},
|
||||
focused: (params) => {
|
||||
return params.context?.isFocused;
|
||||
},
|
||||
playing: (params) => {
|
||||
return params.context?.status === PlayerStatus.PLAYING;
|
||||
},
|
||||
},
|
||||
cellRenderer: RowIndexCell,
|
||||
colId: TableColumn.TRACK_NUMBER,
|
||||
field: 'trackNumber',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: i18n.t('table.column.trackNumber'),
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.trackNumber : undefined,
|
||||
@@ -329,7 +400,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'userFavorite',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center', preset: 'userFavorite' }),
|
||||
headerName: 'Favorite',
|
||||
headerName: i18n.t('table.column.favorite'),
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.userFavorite : undefined,
|
||||
@@ -343,7 +414,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
field: 'userRating',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center', preset: 'userRating' }),
|
||||
headerName: 'Rating',
|
||||
headerName: i18n.t('table.column.rating'),
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data : undefined),
|
||||
width: 95,
|
||||
@@ -354,10 +425,23 @@ export const getColumnDef = (column: TableColumn) => {
|
||||
return tableColumns[column as keyof typeof tableColumns];
|
||||
};
|
||||
|
||||
export const getColumnDefs = (columns: PersistedTableColumn[], useWidth?: boolean) => {
|
||||
export const getColumnDefs = (
|
||||
columns: PersistedTableColumn[],
|
||||
useWidth?: boolean,
|
||||
type?: 'albumDetail' | 'generic',
|
||||
) => {
|
||||
const columnDefs: ColDef[] = [];
|
||||
for (const column of columns) {
|
||||
const presetColumn = tableColumns[column.column as keyof typeof tableColumns];
|
||||
let presetColumn = tableColumns[column.column as keyof typeof tableColumns];
|
||||
|
||||
if (column.column === TableColumn.TRACK_NUMBER && type === 'albumDetail') {
|
||||
presetColumn = tableColumns['trackNumberDetail' as keyof typeof tableColumns];
|
||||
}
|
||||
|
||||
if (column.column === TableColumn.ROW_INDEX && type === 'generic') {
|
||||
presetColumn = tableColumns['rowIndexGeneric' as keyof typeof tableColumns];
|
||||
}
|
||||
|
||||
if (presetColumn) {
|
||||
columnDefs.push({
|
||||
...presetColumn,
|
||||
|
||||
@@ -5,88 +5,278 @@ import { Switch } from '/@/renderer/components/switch';
|
||||
import { useSettingsStoreActions, useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { TableColumn, TableType } from '/@/renderer/types';
|
||||
import { Option } from '/@/renderer/components/option';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
export const SONG_TABLE_COLUMNS = [
|
||||
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
|
||||
{ label: 'Title', value: TableColumn.TITLE },
|
||||
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
|
||||
{ label: 'Duration', value: TableColumn.DURATION },
|
||||
{ label: 'Album', value: TableColumn.ALBUM },
|
||||
{ label: 'Album Artist', value: TableColumn.ALBUM_ARTIST },
|
||||
{ label: 'Artist', value: TableColumn.ARTIST },
|
||||
{ label: 'Genre', value: TableColumn.GENRE },
|
||||
{ label: 'Year', value: TableColumn.YEAR },
|
||||
{ label: 'Release Date', value: TableColumn.RELEASE_DATE },
|
||||
{ label: 'Disc Number', value: TableColumn.DISC_NUMBER },
|
||||
{ label: 'Track Number', value: TableColumn.TRACK_NUMBER },
|
||||
{ label: 'Bitrate', value: TableColumn.BIT_RATE },
|
||||
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
|
||||
{ label: 'Note', value: TableColumn.COMMENT },
|
||||
{ label: 'Channels', value: TableColumn.CHANNELS },
|
||||
{ label: 'BPM', value: TableColumn.BPM },
|
||||
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
|
||||
{ label: 'Path', value: TableColumn.PATH },
|
||||
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
|
||||
{ label: 'Size', value: TableColumn.SIZE },
|
||||
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
|
||||
{ label: 'Rating', value: TableColumn.USER_RATING },
|
||||
{ label: 'Actions', value: TableColumn.ACTIONS },
|
||||
{
|
||||
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.ROW_INDEX,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.TITLE,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.TITLE_COMBINED,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.DURATION,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.album', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.ALBUM,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.albumArtist', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.ALBUM_ARTIST,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.artist', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.ARTIST,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.GENRE,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.year', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.YEAR,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.releaseDate', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.RELEASE_DATE,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.discNumber', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.DISC_NUMBER,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.trackNumber', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.TRACK_NUMBER,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.bitrate', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.BIT_RATE,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.LAST_PLAYED,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.note', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.COMMENT,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.channels', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.CHANNELS,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.bpm', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.BPM,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.dateAdded', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.DATE_ADDED,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.path', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.PATH,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.PLAY_COUNT,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.size', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.SIZE,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.USER_FAVORITE,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.rating', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.USER_RATING,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.ACTIONS,
|
||||
},
|
||||
// { label: 'Skip', value: TableColumn.SKIP },
|
||||
];
|
||||
|
||||
export const ALBUM_TABLE_COLUMNS = [
|
||||
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
|
||||
{ label: 'Title', value: TableColumn.TITLE },
|
||||
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
|
||||
{ label: 'Duration', value: TableColumn.DURATION },
|
||||
{ label: 'Album Artist', value: TableColumn.ALBUM_ARTIST },
|
||||
{ label: 'Artist', value: TableColumn.ARTIST },
|
||||
{ label: 'Song Count', value: TableColumn.SONG_COUNT },
|
||||
{ label: 'Genre', value: TableColumn.GENRE },
|
||||
{ label: 'Year', value: TableColumn.YEAR },
|
||||
{ label: 'Release Date', value: TableColumn.RELEASE_DATE },
|
||||
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
|
||||
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
|
||||
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
|
||||
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
|
||||
{ label: 'Rating', value: TableColumn.USER_RATING },
|
||||
{ label: 'Actions', value: TableColumn.ACTIONS },
|
||||
{
|
||||
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.ROW_INDEX,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.TITLE,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.TITLE_COMBINED,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.DURATION,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.albumArtist', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.ALBUM_ARTIST,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.artist', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.ARTIST,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.SONG_COUNT,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.GENRE,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.year', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.YEAR,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.releaseDate', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.RELEASE_DATE,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.LAST_PLAYED,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.dateAdded', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.DATE_ADDED,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.PLAY_COUNT,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.USER_FAVORITE,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.rating', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.USER_RATING,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.ACTIONS,
|
||||
},
|
||||
];
|
||||
|
||||
export const ALBUMARTIST_TABLE_COLUMNS = [
|
||||
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
|
||||
{ label: 'Title', value: TableColumn.TITLE },
|
||||
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
|
||||
{ label: 'Duration', value: TableColumn.DURATION },
|
||||
{ label: 'Biography', value: TableColumn.BIOGRAPHY },
|
||||
{ label: 'Genre', value: TableColumn.GENRE },
|
||||
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
|
||||
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
|
||||
{ label: 'Album Count', value: TableColumn.ALBUM_COUNT },
|
||||
{ label: 'Song Count', value: TableColumn.SONG_COUNT },
|
||||
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
|
||||
{ label: 'Rating', value: TableColumn.USER_RATING },
|
||||
{ label: 'Actions', value: TableColumn.ACTIONS },
|
||||
{
|
||||
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.ROW_INDEX,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.TITLE,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.TITLE_COMBINED,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.DURATION,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.biography', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.BIOGRAPHY,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.GENRE,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.LAST_PLAYED,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.PLAY_COUNT,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.albumCount', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.ALBUM_COUNT,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.SONG_COUNT,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.USER_FAVORITE,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.rating', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.USER_RATING,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.ACTIONS,
|
||||
},
|
||||
];
|
||||
|
||||
export const PLAYLIST_TABLE_COLUMNS = [
|
||||
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
|
||||
{ label: 'Title', value: TableColumn.TITLE },
|
||||
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
|
||||
{ label: 'Duration', value: TableColumn.DURATION },
|
||||
{ label: 'Owner', value: TableColumn.OWNER },
|
||||
// { label: 'Genre', value: TableColumn.GENRE },
|
||||
{ label: 'Song Count', value: TableColumn.SONG_COUNT },
|
||||
{ label: 'Actions', value: TableColumn.ACTIONS },
|
||||
{
|
||||
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.ROW_INDEX,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.TITLE,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.TITLE_COMBINED,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.DURATION,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.owner', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.OWNER,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.SONG_COUNT,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.ACTIONS,
|
||||
},
|
||||
];
|
||||
|
||||
export const GENRE_TABLE_COLUMNS = [
|
||||
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
|
||||
{ label: 'Title', value: TableColumn.TITLE },
|
||||
{ label: 'Actions', value: TableColumn.ACTIONS },
|
||||
{
|
||||
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.ROW_INDEX,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.TITLE,
|
||||
},
|
||||
{
|
||||
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
|
||||
value: TableColumn.ACTIONS,
|
||||
},
|
||||
];
|
||||
|
||||
interface TableConfigDropdownProps {
|
||||
// tableRef?: MutableRefObject<AgGridReactType<any> | null>;
|
||||
type: TableType;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Group } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { MutableRefObject } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiHashtag } from 'react-icons/ri';
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
import { MotionFlex } from '../motion';
|
||||
@@ -29,6 +30,7 @@ export const TablePagination = ({
|
||||
setPagination,
|
||||
setIdPagination,
|
||||
}: TablePaginationProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isGoToPageOpen, handlers] = useDisclosure(false);
|
||||
const containerQuery = useContainerQuery();
|
||||
|
||||
@@ -115,7 +117,9 @@ export const TablePagination = ({
|
||||
radius="sm"
|
||||
size="sm"
|
||||
sx={{ height: '26px', padding: '0', width: '26px' }}
|
||||
tooltip={{ label: 'Go to page' }}
|
||||
tooltip={{
|
||||
label: t('action.goToPage', { postProcess: 'sentenceCase' }),
|
||||
}}
|
||||
variant="default"
|
||||
onClick={() => handlers.toggle()}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { Button, PageHeader, Text } from '/@/renderer/components';
|
||||
@@ -15,6 +16,7 @@ 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;
|
||||
@@ -38,17 +40,17 @@ const ActionRequiredRoute = () => {
|
||||
const checks = [
|
||||
{
|
||||
component: <MpvRequired />,
|
||||
title: 'MPV required',
|
||||
title: t('error.mpvRequired', { postProcess: 'sentenceCase' }),
|
||||
valid: !isMpvRequired,
|
||||
},
|
||||
{
|
||||
component: <ServerCredentialRequired />,
|
||||
title: 'Credentials required',
|
||||
title: t('error.credentialsRequired', { postProcess: 'sentenceCase' }),
|
||||
valid: !isCredentialRequired,
|
||||
},
|
||||
{
|
||||
component: <ServerRequired />,
|
||||
title: 'Server required',
|
||||
title: t('error.serverRequired', { postProcess: 'serverRequired' }),
|
||||
valid: !isServerRequired,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import { RowDoubleClickedEvent, RowHeightParams, RowNode } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Box, Group, Stack } from '@mantine/core';
|
||||
import { useSetState } from '@mantine/hooks';
|
||||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiHeartFill, RiHeartLine, RiMoreFill, RiSettings2Fill } from 'react-icons/ri';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -12,9 +13,9 @@ import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/ap
|
||||
import { Button, Popover } from '/@/renderer/components';
|
||||
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
|
||||
import {
|
||||
getColumnDefs,
|
||||
TableConfigDropdown,
|
||||
VirtualTable,
|
||||
getColumnDefs,
|
||||
} from '/@/renderer/components/virtual-table';
|
||||
import { FullWidthDiscCell } from '/@/renderer/components/virtual-table/cells/full-width-disc-cell';
|
||||
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
|
||||
@@ -31,10 +32,14 @@ import {
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
import { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useAppFocus, useContainerQuery } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { usePlayButtonBehavior, useTableSettings } from '/@/renderer/store/settings.store';
|
||||
import { useCurrentServer, useCurrentSong, useCurrentStatus } from '/@/renderer/store';
|
||||
import {
|
||||
usePlayButtonBehavior,
|
||||
useSettingsStoreActions,
|
||||
useTableSettings,
|
||||
} from '/@/renderer/store/settings.store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
|
||||
const isFullWidthRow = (node: RowNode) => {
|
||||
@@ -59,22 +64,33 @@ interface AlbumDetailContentProps {
|
||||
}
|
||||
|
||||
export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
|
||||
const cq = useContainerQuery();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const tableConfig = useTableSettings('albumDetail');
|
||||
const { setTable } = useSettingsStoreActions();
|
||||
const status = useCurrentStatus();
|
||||
const isFocused = useAppFocus();
|
||||
const currentSong = useCurrentSong();
|
||||
|
||||
const columnDefs = useMemo(() => getColumnDefs(tableConfig.columns), [tableConfig.columns]);
|
||||
const columnDefs = useMemo(
|
||||
() => getColumnDefs(tableConfig.columns, false, 'albumDetail'),
|
||||
[tableConfig.columns],
|
||||
);
|
||||
|
||||
const getRowHeight = useCallback((params: RowHeightParams) => {
|
||||
if (isFullWidthRow(params.node)) {
|
||||
return 45;
|
||||
}
|
||||
const getRowHeight = useCallback(
|
||||
(params: RowHeightParams) => {
|
||||
if (isFullWidthRow(params.node)) {
|
||||
return 45;
|
||||
}
|
||||
|
||||
return 60;
|
||||
}, []);
|
||||
return tableConfig.rowHeight;
|
||||
},
|
||||
[tableConfig.rowHeight],
|
||||
);
|
||||
|
||||
const songsRowData = useMemo(() => {
|
||||
if (!detailQuery.data?.songs) {
|
||||
@@ -192,7 +208,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
handlePreviousPage: () => handlePreviousPage('artist'),
|
||||
hasPreviousPage: pagination.artist > 0,
|
||||
},
|
||||
title: 'More from this artist',
|
||||
title: t('page.albumDetail.moreFromArtist', { postProcess: 'sentenceCase' }),
|
||||
uniqueId: 'mostPlayed',
|
||||
},
|
||||
{
|
||||
@@ -203,7 +219,10 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
(a) => a.id !== detailQuery?.data?.id,
|
||||
).length,
|
||||
loading: relatedAlbumGenresQuery?.isLoading || relatedAlbumGenresQuery.isFetching,
|
||||
title: `More from ${detailQuery?.data?.genres?.[0]?.name}`,
|
||||
title: t('page.albumDetail.moreFromGeneric', {
|
||||
item: detailQuery?.data?.genres?.[0]?.name,
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
uniqueId: 'relatedGenres',
|
||||
},
|
||||
];
|
||||
@@ -216,7 +235,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
});
|
||||
};
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
|
||||
const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data || e.node.isFullWidthCell()) return;
|
||||
@@ -266,6 +285,32 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
const onColumnMoved = useCallback(() => {
|
||||
const { columnApi } = tableRef?.current || {};
|
||||
const columnsOrder = columnApi?.getAllGridColumns();
|
||||
|
||||
if (!columnsOrder) return;
|
||||
|
||||
const columnsInSettings = tableConfig.columns;
|
||||
const updatedColumns = [];
|
||||
for (const column of columnsOrder) {
|
||||
const columnInSettings = columnsInSettings.find(
|
||||
(c) => c.column === column.getColDef().colId,
|
||||
);
|
||||
|
||||
if (columnInSettings) {
|
||||
updatedColumns.push({
|
||||
...columnInSettings,
|
||||
...(!tableConfig.autoFit && {
|
||||
width: column.getActualWidth(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTable('albumDetail', { ...tableConfig, columns: updatedColumns });
|
||||
}, [setTable, tableConfig, tableRef]);
|
||||
|
||||
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
|
||||
|
||||
return (
|
||||
@@ -352,6 +397,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
)}
|
||||
<Box style={{ minHeight: '300px' }}>
|
||||
<VirtualTable
|
||||
key={`table-${tableConfig.rowHeight}`}
|
||||
ref={tableRef}
|
||||
autoHeight
|
||||
stickyHeader
|
||||
@@ -360,6 +406,12 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
suppressRowDrag
|
||||
autoFitColumns={tableConfig.autoFit}
|
||||
columnDefs={columnDefs}
|
||||
context={{
|
||||
currentSong,
|
||||
isFocused,
|
||||
onCellContextMenu,
|
||||
status,
|
||||
}}
|
||||
enableCellChangeFlash={false}
|
||||
fullWidthCellRenderer={FullWidthDiscCell}
|
||||
getRowHeight={getRowHeight}
|
||||
@@ -374,7 +426,8 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
rowClassRules={rowClassRules}
|
||||
rowData={songsRowData}
|
||||
rowSelection="multiple"
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onCellContextMenu={onCellContextMenu}
|
||||
onColumnMoved={onColumnMoved}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -55,18 +55,6 @@ export const AlbumDetailHeader = forwardRef(
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearRating = () => {
|
||||
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating: 0,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
|
||||
|
||||
return (
|
||||
@@ -96,7 +84,6 @@ export const AlbumDetailHeader = forwardRef(
|
||||
}
|
||||
value={detailQuery?.data?.userRating || 0}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Divider, Flex, Group, Stack } from '@mantine/core';
|
||||
import { openModal } from '@mantine/modals';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RiAddBoxFill,
|
||||
RiAddCircleFill,
|
||||
@@ -31,46 +32,112 @@ import {
|
||||
useListStoreByKey,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.ALBUM_ARTIST,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: 'Community Rating',
|
||||
name: i18n.t('filter.communityRating', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.COMMUNITY_RATING,
|
||||
},
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Critic Rating', value: AlbumListSort.CRITIC_RATING },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumListSort.RANDOM },
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: 'Recently Added',
|
||||
name: i18n.t('filter.criticRating', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.CRITIC_RATING,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.NAME,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.playCount', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.PLAY_COUNT,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RANDOM,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RECENTLY_ADDED,
|
||||
},
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Release Date', value: AlbumListSort.RELEASE_DATE },
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RELEASE_DATE,
|
||||
},
|
||||
],
|
||||
navidrome: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: AlbumListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: AlbumListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Most Played', value: AlbumListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumListSort.RANDOM },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: AlbumListSort.RATING },
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.ALBUM_ARTIST,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.artist', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.ARTIST,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: 'Recently Added',
|
||||
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.DURATION,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.PLAY_COUNT,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.NAME,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RANDOM,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RATING,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RECENTLY_ADDED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: 'Recently Played',
|
||||
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RECENTLY_PLAYED,
|
||||
},
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: AlbumListSort.SONG_COUNT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: AlbumListSort.FAVORITED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Year', value: AlbumListSort.YEAR },
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.SONG_COUNT,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.favorited', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.FAVORITED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.YEAR,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -80,6 +147,7 @@ interface AlbumListHeaderFiltersProps {
|
||||
}
|
||||
|
||||
export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { pageKey, customFilters, handlePlay } = useListContext();
|
||||
const server = useCurrentServer();
|
||||
@@ -361,7 +429,9 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
||||
fill: isFilterApplied ? 'var(--primary-color) !important' : undefined,
|
||||
},
|
||||
}}
|
||||
tooltip={{ label: 'Filters' }}
|
||||
tooltip={{
|
||||
label: t('common.filter', { count: 2, postProcess: 'sentenceCase' }),
|
||||
}}
|
||||
variant="subtle"
|
||||
onClick={handleOpenFiltersModal}
|
||||
>
|
||||
@@ -371,7 +441,7 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{ label: 'Refresh' }}
|
||||
tooltip={{ label: t('common.refresh', { postProcess: 'sentenceCase' }) }}
|
||||
variant="subtle"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
@@ -393,26 +463,26 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
||||
icon={<RiPlayFill />}
|
||||
onClick={() => handlePlay?.({ playType: Play.NOW })}
|
||||
>
|
||||
Play
|
||||
{t('player.play', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddBoxFill />}
|
||||
onClick={() => handlePlay?.({ playType: Play.LAST })}
|
||||
>
|
||||
Add to queue
|
||||
{t('player.addLast', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddCircleFill />}
|
||||
onClick={() => handlePlay?.({ playType: Play.NEXT })}
|
||||
>
|
||||
Add to queue next
|
||||
{t('player.addNext', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
icon={<RiRefreshLine />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
{t('common.refresh', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
@@ -429,7 +499,9 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{ label: 'Configure' }}
|
||||
tooltip={{
|
||||
label: t('common.configure', { postProcess: 'sentenceCase' }),
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Fill size="1.3rem" />
|
||||
@@ -442,21 +514,21 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
||||
value={ListDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Card
|
||||
{t('table.config.view.card', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.POSTER}
|
||||
value={ListDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Poster
|
||||
{t('table.config.view.poster', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table
|
||||
{t('table.config.view.table', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
{/* <DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.TABLE_PAGINATED}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ChangeEvent, MutableRefObject } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import type { ChangeEvent, MutableRefObject } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useListFilterRefresh } from '../../../hooks/use-list-filter-refresh';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
usePlayButtonBehavior,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { titleCase } from '/@/renderer/utils';
|
||||
|
||||
interface AlbumListHeaderProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
@@ -27,6 +29,7 @@ interface AlbumListHeaderProps {
|
||||
}
|
||||
|
||||
export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumListHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
const { setFilter, setTablePagination } = useListStoreActions();
|
||||
const cq = useContainerQuery();
|
||||
@@ -69,7 +72,10 @@ export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumLi
|
||||
<LibraryHeaderBar.PlayButton
|
||||
onClick={() => handlePlay?.({ playType: playButtonBehavior })}
|
||||
/>
|
||||
<LibraryHeaderBar.Title>{title || 'Albums'}</LibraryHeaderBar.Title>
|
||||
<LibraryHeaderBar.Title>
|
||||
{title ||
|
||||
titleCase(t('page.albumList.title', { postProcess: 'titleCase' }))}
|
||||
</LibraryHeaderBar.Title>
|
||||
<LibraryHeaderBar.Badge
|
||||
isLoading={itemCount === null || itemCount === undefined}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChangeEvent, useMemo, useState } from 'react';
|
||||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useListFilterByKey } from '../../../store/list.store';
|
||||
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
|
||||
@@ -23,6 +24,7 @@ export const JellyfinAlbumFilters = ({
|
||||
pageKey,
|
||||
serverId,
|
||||
}: JellyfinAlbumFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const filter = useListFilterByKey({ key: pageKey });
|
||||
const { setFilter } = useListStoreActions();
|
||||
|
||||
@@ -51,7 +53,7 @@ export const JellyfinAlbumFilters = ({
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: 'Is favorited',
|
||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
@@ -193,7 +195,7 @@ export const JellyfinAlbumFilters = ({
|
||||
<NumberInput
|
||||
defaultValue={filter?._custom?.jellyfin?.minYear}
|
||||
hideControls={false}
|
||||
label="From year"
|
||||
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
|
||||
max={2300}
|
||||
min={1700}
|
||||
required={!!filter?._custom?.jellyfin?.maxYear}
|
||||
@@ -202,7 +204,7 @@ export const JellyfinAlbumFilters = ({
|
||||
<NumberInput
|
||||
defaultValue={filter?._custom?.jellyfin?.maxYear}
|
||||
hideControls={false}
|
||||
label="To year"
|
||||
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
|
||||
max={2300}
|
||||
min={1700}
|
||||
required={!!filter?._custom?.jellyfin?.minYear}
|
||||
@@ -215,7 +217,7 @@ export const JellyfinAlbumFilters = ({
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={selectedGenres}
|
||||
label="Genres"
|
||||
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
</Group>
|
||||
@@ -227,7 +229,7 @@ export const JellyfinAlbumFilters = ({
|
||||
data={selectableAlbumArtists}
|
||||
defaultValue={filter?._custom?.jellyfin?.AlbumArtistIds?.split(',')}
|
||||
disabled={disableArtistFilter}
|
||||
label="Artist"
|
||||
label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
|
||||
limit={300}
|
||||
placeholder="Type to search for an artist"
|
||||
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
|
||||
|
||||
@@ -6,6 +6,7 @@ import debounce from 'lodash/debounce';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface NavidromeAlbumFiltersProps {
|
||||
customFilters?: Partial<AlbumListFilter>;
|
||||
@@ -22,6 +23,7 @@ export const NavidromeAlbumFilters = ({
|
||||
pageKey,
|
||||
serverId,
|
||||
}: NavidromeAlbumFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { filter } = useListStoreByKey({ key: pageKey });
|
||||
const { setFilter } = useListStoreActions();
|
||||
|
||||
@@ -62,7 +64,7 @@ export const NavidromeAlbumFilters = ({
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: 'Is rated',
|
||||
label: t('filter.isRated', { postProcess: 'sentenceCase' }),
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
@@ -83,7 +85,7 @@ export const NavidromeAlbumFilters = ({
|
||||
value: filter._custom?.navidrome?.has_rating,
|
||||
},
|
||||
{
|
||||
label: 'Is favorited',
|
||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
@@ -104,7 +106,7 @@ export const NavidromeAlbumFilters = ({
|
||||
value: filter._custom?.navidrome?.starred,
|
||||
},
|
||||
{
|
||||
label: 'Is compilation',
|
||||
label: t('filter.isCompilation', { postProcess: 'sentenceCase' }),
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
@@ -125,7 +127,7 @@ export const NavidromeAlbumFilters = ({
|
||||
value: filter._custom?.navidrome?.compilation,
|
||||
},
|
||||
{
|
||||
label: 'Is recently played',
|
||||
label: t('filter.isRecentlyPlayed', { postProcess: 'sentenceCase' }),
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
@@ -152,11 +154,11 @@ export const NavidromeAlbumFilters = ({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
year: e === '' ? undefined : (e as number),
|
||||
},
|
||||
...filter._custom,
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
@@ -226,7 +228,7 @@ export const NavidromeAlbumFilters = ({
|
||||
<NumberInput
|
||||
defaultValue={filter._custom?.navidrome?.year}
|
||||
hideControls={false}
|
||||
label="Year"
|
||||
label={t('common.year', { postProcess: 'titleCase' })}
|
||||
max={5000}
|
||||
min={0}
|
||||
onChange={(e) => handleYearFilter(e)}
|
||||
@@ -236,7 +238,7 @@ export const NavidromeAlbumFilters = ({
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={filter._custom?.navidrome?.genre_id}
|
||||
label="Genre"
|
||||
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
</Group>
|
||||
@@ -247,9 +249,8 @@ export const NavidromeAlbumFilters = ({
|
||||
data={selectableAlbumArtists}
|
||||
defaultValue={filter._custom?.navidrome?.artist_id}
|
||||
disabled={disableArtistFilter}
|
||||
label="Artist"
|
||||
label={t('entity.artist', { count: 1, postProcess: 'titleCase' })}
|
||||
limit={300}
|
||||
placeholder="Type to search for an artist"
|
||||
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
|
||||
searchValue={albumArtistSearchTerm}
|
||||
onChange={handleAlbumArtistFilter}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, Fragment, Ref, MouseEvent } from 'react';
|
||||
import { forwardRef, Fragment, Ref } from 'react';
|
||||
import { Group, Rating, Stack } from '@mantine/core';
|
||||
import { useParams } from 'react-router';
|
||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
@@ -55,21 +55,6 @@ export const AlbumArtistDetailHeader = forwardRef(
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearRating = (_e: MouseEvent<HTMLDivElement>, rating?: number) => {
|
||||
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
|
||||
|
||||
const isSameRatingAsPrevious = rating === detailQuery.data.userRating;
|
||||
if (!isSameRatingAsPrevious) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating: 0,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
|
||||
|
||||
return (
|
||||
@@ -99,7 +84,6 @@ export const AlbumArtistDetailHeader = forwardRef(
|
||||
}
|
||||
value={detailQuery?.data?.userRating || 0}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
+5
-3
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiAddBoxFill, RiAddCircleFill, RiMoreFill, RiPlayFill } from 'react-icons/ri';
|
||||
import { QueueSong } from '/@/renderer/api/types';
|
||||
import { Button, DropdownMenu, PageHeader, SpinnerIcon, Paper } from '/@/renderer/components';
|
||||
@@ -17,6 +18,7 @@ export const AlbumArtistDetailTopSongsListHeader = ({
|
||||
itemCount,
|
||||
data,
|
||||
}: AlbumArtistDetailTopSongsListHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
@@ -55,19 +57,19 @@ export const AlbumArtistDetailTopSongsListHeader = ({
|
||||
icon={<RiPlayFill />}
|
||||
onClick={() => handlePlay(Play.NOW)}
|
||||
>
|
||||
Play
|
||||
{t('player.add', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddBoxFill />}
|
||||
onClick={() => handlePlay(Play.LAST)}
|
||||
>
|
||||
Add to queue
|
||||
{t('player.addLast', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddCircleFill />}
|
||||
onClick={() => handlePlay(Play.NEXT)}
|
||||
>
|
||||
Add to queue next
|
||||
{t('player.addNext', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import { IDatasource } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Divider, Flex, Group, Stack } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
|
||||
import { useListContext } from '../../../context/list-context';
|
||||
import { api } from '/@/renderer/api';
|
||||
@@ -62,6 +63,7 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
gridRef,
|
||||
tableRef,
|
||||
}: AlbumArtistListHeaderFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const { pageKey } = useListContext();
|
||||
@@ -77,7 +79,7 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
(server?.type &&
|
||||
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filter.sortBy)
|
||||
?.name) ||
|
||||
'Unknown';
|
||||
t('common.unknown', { postProcess: 'titleCase' });
|
||||
|
||||
const handleItemSize = (e: number) => {
|
||||
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
|
||||
@@ -359,7 +361,7 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{ label: 'Refresh' }}
|
||||
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
|
||||
variant="subtle"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
@@ -407,21 +409,27 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
value={ListDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Card
|
||||
{t('table.config.view.card', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.POSTER}
|
||||
value={ListDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Poster
|
||||
{t('table.config.view.poster', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table
|
||||
{t('table.config.view.table', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Item>
|
||||
{/* <DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.TABLE_PAGINATED}
|
||||
@@ -465,7 +473,11 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
)}
|
||||
{!isGrid && (
|
||||
<>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.tableColumns', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
@@ -482,7 +494,11 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
onChange={handleTableColumns}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Text>
|
||||
{t('table.config.general.autoFitColumns', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Text>
|
||||
<Switch
|
||||
defaultChecked={table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ChangeEvent, MutableRefObject } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import type { ChangeEvent, MutableRefObject } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useListContext } from '../../../context/list-context';
|
||||
import { useListStoreByKey } from '../../../store/list.store';
|
||||
import { FilterBar } from '../../shared/components/filter-bar';
|
||||
@@ -26,6 +27,7 @@ export const AlbumArtistListHeader = ({
|
||||
gridRef,
|
||||
tableRef,
|
||||
}: AlbumArtistListHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
const { pageKey } = useListContext();
|
||||
const { display, filter } = useListStoreByKey({ key: pageKey });
|
||||
@@ -64,7 +66,9 @@ export const AlbumArtistListHeader = ({
|
||||
w="100%"
|
||||
>
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.Title>Album Artists</LibraryHeaderBar.Title>
|
||||
<LibraryHeaderBar.Title>
|
||||
{t('page.albumArtistList.title', { postProcess: 'titleCase' })}
|
||||
</LibraryHeaderBar.Title>
|
||||
<LibraryHeaderBar.Badge
|
||||
isLoading={itemCount === null || itemCount === undefined}
|
||||
>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { closeAllModals, openContextModal, openModal } from '@mantine/modals';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import isElectron from 'is-electron';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RiAddBoxFill,
|
||||
RiAddCircleFill,
|
||||
@@ -87,6 +88,7 @@ export interface ContextMenuProviderProps {
|
||||
}
|
||||
|
||||
export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const clickOutsideRef = useClickOutside(() => setOpened(false));
|
||||
|
||||
@@ -229,7 +231,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: 'Error deleting playlist',
|
||||
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -245,14 +247,14 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
}
|
||||
|
||||
closeAllModals();
|
||||
}, [ctx, deletePlaylistMutation]);
|
||||
}, [ctx, deletePlaylistMutation, t]);
|
||||
|
||||
const openDeletePlaylistModal = useCallback(() => {
|
||||
openModal({
|
||||
children: (
|
||||
<ConfirmModal onConfirm={handleDeletePlaylist}>
|
||||
<Stack>
|
||||
<Text>Are you sure you want to delete the following playlist(s)?</Text>
|
||||
<Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
|
||||
<ul>
|
||||
{ctx.data.map((item) => (
|
||||
<li key={item.id}>
|
||||
@@ -265,9 +267,9 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
</Stack>
|
||||
</ConfirmModal>
|
||||
),
|
||||
title: 'Delete playlist(s)',
|
||||
title: t('page.contextMenu.deletePlaylist', { postProcess: 'titleCase' }),
|
||||
});
|
||||
}, [ctx.data, handleDeletePlaylist]);
|
||||
}, [ctx.data, handleDeletePlaylist, t]);
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
@@ -301,7 +303,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: 'Error adding to favorites',
|
||||
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -337,14 +339,14 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: 'Error adding to favorites',
|
||||
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [createFavoriteMutation, ctx.data, ctx.dataNodes, ctx.type]);
|
||||
}, [createFavoriteMutation, ctx.data, ctx.dataNodes, ctx.type, t]);
|
||||
|
||||
const handleRemoveFromFavorites = useCallback(() => {
|
||||
if (!ctx.dataNodes && !ctx.data) return;
|
||||
@@ -434,6 +436,9 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
case LibraryItem.ALBUM:
|
||||
albumId.push(item.id);
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
artistId.push(item.id);
|
||||
break;
|
||||
case LibraryItem.ARTIST:
|
||||
artistId.push(item.id);
|
||||
break;
|
||||
@@ -456,9 +461,9 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
},
|
||||
modal: 'addToPlaylist',
|
||||
size: 'md',
|
||||
title: 'Add to playlist',
|
||||
title: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
}, [ctx.data, ctx.dataNodes]);
|
||||
}, [ctx.data, ctx.dataNodes, t]);
|
||||
|
||||
const removeFromPlaylistMutation = useRemoveFromPlaylist();
|
||||
|
||||
@@ -481,13 +486,10 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: 'Error removing song(s) from playlist',
|
||||
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({
|
||||
message: `${songId.length} song(s) were removed from the playlist`,
|
||||
});
|
||||
ctx.context?.tableRef?.current?.api?.refreshInfiniteCache();
|
||||
closeAllModals();
|
||||
},
|
||||
@@ -501,10 +503,10 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
loading={removeFromPlaylistMutation.isLoading}
|
||||
onConfirm={confirm}
|
||||
>
|
||||
Are you sure you want to remove the following song(s) from the playlist?
|
||||
{t('common.areYouSure', { postProcess: 'sentenceCase' })}
|
||||
</ConfirmModal>
|
||||
),
|
||||
title: 'Remove song(s) from playlist',
|
||||
title: t('page.contextMenu.removeFromPlaylist', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
}, [
|
||||
ctx.context?.playlistId,
|
||||
@@ -513,6 +515,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
ctx.dataNodes,
|
||||
removeFromPlaylistMutation,
|
||||
serverType,
|
||||
t,
|
||||
]);
|
||||
|
||||
const updateRatingMutation = useSetRating({});
|
||||
@@ -613,10 +616,12 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
ctx.tableApi?.redrawRows();
|
||||
|
||||
if (isCurrentSongRemoved) {
|
||||
remote?.updateSong({ song: playerData.current.song });
|
||||
}
|
||||
}, [ctx.dataNodes, playerType, removeFromQueue]);
|
||||
}, [ctx.dataNodes, ctx.tableApi, playerType, removeFromQueue]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
ctx.tableApi?.deselectAll();
|
||||
@@ -626,74 +631,78 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
return {
|
||||
addToFavorites: {
|
||||
id: 'addToFavorites',
|
||||
label: 'Add favorite',
|
||||
label: t('page.contextMenu.addToFavorites', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiHeartFill size="1.1rem" />,
|
||||
onClick: handleAddToFavorites,
|
||||
},
|
||||
addToPlaylist: {
|
||||
id: 'addToPlaylist',
|
||||
label: 'Add to playlist',
|
||||
label: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiPlayListAddFill size="1.1rem" />,
|
||||
onClick: handleAddToPlaylist,
|
||||
},
|
||||
createPlaylist: { id: 'createPlaylist', label: 'Create playlist', onClick: () => {} },
|
||||
createPlaylist: {
|
||||
id: 'createPlaylist',
|
||||
label: t('page.contextMenu.createPlaylist', { postProcess: 'sentenceCase' }),
|
||||
onClick: () => {},
|
||||
},
|
||||
deletePlaylist: {
|
||||
id: 'deletePlaylist',
|
||||
label: 'Delete playlist',
|
||||
label: t('page.contextMenu.deletePlaylist', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiDeleteBinFill size="1.1rem" />,
|
||||
onClick: openDeletePlaylistModal,
|
||||
},
|
||||
deselectAll: {
|
||||
id: 'deselectAll',
|
||||
label: 'Deselect all',
|
||||
label: t('page.contextMenu.deselectAll', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiCloseCircleLine size="1.1rem" />,
|
||||
onClick: handleDeselectAll,
|
||||
},
|
||||
moveToBottomOfQueue: {
|
||||
id: 'moveToBottomOfQueue',
|
||||
label: 'Move to bottom',
|
||||
label: t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiArrowDownLine size="1.1rem" />,
|
||||
onClick: handleMoveToBottom,
|
||||
},
|
||||
moveToTopOfQueue: {
|
||||
id: 'moveToTopOfQueue',
|
||||
label: 'Move to top',
|
||||
label: t('page.contextMenu.moveToTop', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiArrowUpLine size="1.1rem" />,
|
||||
onClick: handleMoveToTop,
|
||||
},
|
||||
play: {
|
||||
id: 'play',
|
||||
label: 'Play',
|
||||
label: t('page.contextMenu.play', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiPlayFill size="1.1rem" />,
|
||||
onClick: () => handlePlay(Play.NOW),
|
||||
},
|
||||
playLast: {
|
||||
id: 'playLast',
|
||||
label: 'Add to queue',
|
||||
label: t('page.contextMenu.addLast', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiAddBoxFill size="1.1rem" />,
|
||||
onClick: () => handlePlay(Play.LAST),
|
||||
},
|
||||
playNext: {
|
||||
id: 'playNext',
|
||||
label: 'Add to queue next',
|
||||
label: t('page.contextMenu.addNext', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiAddCircleFill size="1.1rem" />,
|
||||
onClick: () => handlePlay(Play.NEXT),
|
||||
},
|
||||
removeFromFavorites: {
|
||||
id: 'removeFromFavorites',
|
||||
label: 'Remove favorite',
|
||||
label: t('page.contextMenu.removeFromFavorites', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiDislikeFill size="1.1rem" />,
|
||||
onClick: handleRemoveFromFavorites,
|
||||
},
|
||||
removeFromPlaylist: {
|
||||
id: 'removeFromPlaylist',
|
||||
label: 'Remove from playlist',
|
||||
label: t('page.contextMenu.removeFromPlaylist', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiDeleteBinFill size="1.1rem" />,
|
||||
onClick: handleRemoveFromPlaylist,
|
||||
},
|
||||
removeFromQueue: {
|
||||
id: 'moveToBottomOfQueue',
|
||||
label: 'Remove songs',
|
||||
id: 'removeSongs',
|
||||
label: t('page.contextMenu.removeFromQueue', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiDeleteBinFill size="1.1rem" />,
|
||||
onClick: handleRemoveSelected,
|
||||
},
|
||||
@@ -705,7 +714,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
<Rating
|
||||
readOnly
|
||||
value={0}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
),
|
||||
onClick: () => handleUpdateRating(0),
|
||||
@@ -716,7 +724,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
<Rating
|
||||
readOnly
|
||||
value={1}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
),
|
||||
onClick: () => handleUpdateRating(1),
|
||||
@@ -727,7 +734,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
<Rating
|
||||
readOnly
|
||||
value={2}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
),
|
||||
onClick: () => handleUpdateRating(2),
|
||||
@@ -738,7 +744,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
<Rating
|
||||
readOnly
|
||||
value={3}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
),
|
||||
onClick: () => handleUpdateRating(3),
|
||||
@@ -749,7 +754,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
<Rating
|
||||
readOnly
|
||||
value={4}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
),
|
||||
onClick: () => handleUpdateRating(4),
|
||||
@@ -760,7 +764,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
<Rating
|
||||
readOnly
|
||||
value={5}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
),
|
||||
onClick: () => handleUpdateRating(5),
|
||||
@@ -785,6 +788,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
handleRemoveSelected,
|
||||
handleUpdateRating,
|
||||
openDeletePlaylistModal,
|
||||
t,
|
||||
]);
|
||||
|
||||
const mergedRef = useMergedRef(ref, clickOutsideRef);
|
||||
@@ -889,7 +893,10 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
size="sm"
|
||||
/>
|
||||
<ContextMenuButton disabled>
|
||||
{ctx.data?.length} selected
|
||||
{t('page.contextMenu.numberSelected', {
|
||||
count: ctx.data?.length || 0,
|
||||
postProcess: 'lowerCase',
|
||||
})}
|
||||
</ContextMenuButton>
|
||||
</Stack>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/* eslint-disable consistent-return */
|
||||
import isElectron from 'is-electron';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
useCurrentSong,
|
||||
useCurrentStatus,
|
||||
useDiscordSetttings,
|
||||
usePlayerStore,
|
||||
} from '/@/renderer/store';
|
||||
import { SetActivity } from '@xhayper/discord-rpc';
|
||||
import { PlayerStatus, ServerType } from '/@/renderer/types';
|
||||
|
||||
const discordRpc = isElectron() ? window.electron.discordRpc : null;
|
||||
|
||||
export const useDiscordRpc = () => {
|
||||
const intervalRef = useRef(0);
|
||||
const discordSettings = useDiscordSetttings();
|
||||
const currentSong = useCurrentSong();
|
||||
const currentStatus = useCurrentStatus();
|
||||
|
||||
const setActivity = useCallback(async () => {
|
||||
if (!discordSettings.enableIdle && currentStatus === PlayerStatus.PAUSED) {
|
||||
discordRpc?.clearActivity();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = usePlayerStore.getState().current.time;
|
||||
|
||||
const now = Date.now();
|
||||
const start = currentTime ? Math.round(now - currentTime * 1000) : null;
|
||||
const end =
|
||||
currentSong?.duration && start ? Math.round(start + currentSong.duration) : null;
|
||||
|
||||
const artists = currentSong?.artists.map((artist) => artist.name).join(', ');
|
||||
|
||||
const activity: SetActivity = {
|
||||
details: currentSong?.name.padEnd(2, ' ') || 'Idle',
|
||||
instance: false,
|
||||
largeImageKey: undefined,
|
||||
largeImageText: currentSong?.album || 'Unknown album',
|
||||
smallImageKey: undefined,
|
||||
smallImageText: currentStatus,
|
||||
state: artists && `By ${artists}`,
|
||||
};
|
||||
|
||||
if (currentStatus === PlayerStatus.PLAYING) {
|
||||
if (start && end) {
|
||||
activity.startTimestamp = start;
|
||||
activity.endTimestamp = end;
|
||||
}
|
||||
|
||||
activity.smallImageKey = 'playing';
|
||||
} else {
|
||||
activity.smallImageKey = 'paused';
|
||||
}
|
||||
|
||||
if (
|
||||
currentSong?.serverType === ServerType.JELLYFIN &&
|
||||
discordSettings.showServerImage &&
|
||||
currentSong?.imageUrl
|
||||
) {
|
||||
activity.largeImageKey = currentSong?.imageUrl;
|
||||
}
|
||||
|
||||
// Fall back to default icon if not set
|
||||
if (!activity.largeImageKey) {
|
||||
activity.largeImageKey = 'icon';
|
||||
}
|
||||
|
||||
discordRpc?.setActivity(activity);
|
||||
}, [currentSong, currentStatus, discordSettings.enableIdle, discordSettings.showServerImage]);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeDiscordRpc = async () => {
|
||||
discordRpc?.initialize(discordSettings.clientId);
|
||||
};
|
||||
|
||||
if (discordSettings.enabled) {
|
||||
initializeDiscordRpc();
|
||||
} else {
|
||||
discordRpc?.quit();
|
||||
}
|
||||
|
||||
return () => {
|
||||
discordRpc?.quit();
|
||||
};
|
||||
}, [discordSettings.clientId, discordSettings.enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (discordSettings.enabled) {
|
||||
let intervalSeconds = discordSettings.updateInterval;
|
||||
if (intervalSeconds < 15) {
|
||||
intervalSeconds = 15;
|
||||
}
|
||||
|
||||
intervalRef.current = window.setInterval(setActivity, intervalSeconds * 1000);
|
||||
return () => clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
return () => {};
|
||||
}, [discordSettings.enabled, discordSettings.updateInterval, setActivity]);
|
||||
|
||||
// useEffect(() => {
|
||||
// console.log(
|
||||
// 'currentStatus, discordSettings.enableIdle',
|
||||
// currentStatus,
|
||||
// discordSettings.enableIdle,
|
||||
// );
|
||||
|
||||
// if (discordSettings.enableIdle === false && currentStatus === PlayerStatus.PAUSED) {
|
||||
// console.log('removing activity');
|
||||
// clearActivity();
|
||||
// clearInterval(intervalRef.current);
|
||||
// }
|
||||
// }, [
|
||||
// clearActivity,
|
||||
// currentStatus,
|
||||
// discordSettings.enableIdle,
|
||||
// discordSettings.enabled,
|
||||
// setActivity,
|
||||
// ]);
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Divider, Flex, Group, Stack } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
|
||||
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';
|
||||
@@ -31,6 +32,7 @@ interface GenreListHeaderFiltersProps {
|
||||
}
|
||||
|
||||
export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { pageKey, customFilters } = useListContext();
|
||||
const server = useCurrentServer();
|
||||
@@ -269,7 +271,7 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{ label: 'Refresh' }}
|
||||
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
|
||||
variant="subtle"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
@@ -291,7 +293,7 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
|
||||
icon={<RiRefreshLine />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
{t('common.refresh', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
@@ -308,37 +310,43 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{ label: 'Configure' }}
|
||||
tooltip={{
|
||||
label: t('common.configure', { postProcess: 'titleCase' }),
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Fill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.displayType', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.CARD}
|
||||
value={ListDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Card
|
||||
{t('table.config.view.card', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.POSTER}
|
||||
value={ListDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Poster
|
||||
{t('table.config.view.poster', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table
|
||||
{t('table.config.view.table', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>Item size</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.size', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight}
|
||||
@@ -363,7 +371,11 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
|
||||
{(display === ListDisplayType.TABLE ||
|
||||
display === ListDisplayType.TABLE_PAGINATED) && (
|
||||
<>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.tableColumns', {
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
@@ -380,7 +392,11 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
|
||||
onChange={handleTableColumns}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Text>
|
||||
{t('table.config.general.autoFitColumns', {
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
</Text>
|
||||
<Switch
|
||||
defaultChecked={table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ChangeEvent, MutableRefObject } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, MutableRefObject } from 'react';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
useListStoreByKey,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface GenreListHeaderProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
@@ -25,6 +26,7 @@ interface GenreListHeaderProps {
|
||||
}
|
||||
|
||||
export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const cq = useContainerQuery();
|
||||
const server = useCurrentServer();
|
||||
const { pageKey } = useListContext();
|
||||
@@ -66,7 +68,9 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
|
||||
w="100%"
|
||||
>
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.Title>Genres</LibraryHeaderBar.Title>
|
||||
<LibraryHeaderBar.Title>
|
||||
{t('page.genreList.title', { postProcess: 'titleCase' })}
|
||||
</LibraryHeaderBar.Title>
|
||||
<LibraryHeaderBar.Badge
|
||||
isLoading={itemCount === null || itemCount === undefined}
|
||||
>
|
||||
|
||||
@@ -11,9 +11,11 @@ import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel
|
||||
import { Platform } from '/@/renderer/types';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiRefreshLine } from 'react-icons/ri';
|
||||
|
||||
const HomeRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const server = useCurrentServer();
|
||||
@@ -103,32 +105,9 @@ const HomeRoute = () => {
|
||||
const carousels = [
|
||||
{
|
||||
data: random?.data?.items,
|
||||
title: (
|
||||
<Group>
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
Explore from your library
|
||||
</TextTitle>
|
||||
|
||||
<ActionIcon
|
||||
onClick={() =>
|
||||
queryClient.invalidateQueries({
|
||||
exact: false,
|
||||
queryKey: queryKeys.albums.list(server?.id, {
|
||||
limit: itemsPerPage,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
}),
|
||||
})
|
||||
}
|
||||
>
|
||||
<RiRefreshLine />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
title: t('page.home.explore', { postProcess: 'sentenceCase' }),
|
||||
uniqueId: 'random',
|
||||
},
|
||||
{
|
||||
@@ -136,7 +115,9 @@ const HomeRoute = () => {
|
||||
pagination: {
|
||||
itemsPerPage,
|
||||
},
|
||||
title: 'Recently played',
|
||||
sortBy: AlbumListSort.RECENTLY_PLAYED,
|
||||
sortOrder: SortOrder.DESC,
|
||||
title: t('page.home.recentlyPlayed', { postProcess: 'sentenceCase' }),
|
||||
uniqueId: 'recentlyPlayed',
|
||||
},
|
||||
{
|
||||
@@ -144,7 +125,9 @@ const HomeRoute = () => {
|
||||
pagination: {
|
||||
itemsPerPage,
|
||||
},
|
||||
title: 'Newly added releases',
|
||||
sortBy: AlbumListSort.RECENTLY_ADDED,
|
||||
sortOrder: SortOrder.DESC,
|
||||
title: t('page.home.newlyAdded', { postProcess: 'sentenceCase' }),
|
||||
uniqueId: 'recentlyAdded',
|
||||
},
|
||||
{
|
||||
@@ -152,7 +135,9 @@ const HomeRoute = () => {
|
||||
pagination: {
|
||||
itemsPerPage,
|
||||
},
|
||||
title: 'Most played',
|
||||
sortBy: AlbumListSort.PLAY_COUNT,
|
||||
sortOrder: SortOrder.DESC,
|
||||
title: t('page.home.mostPlayed', { postProcess: 'sentenceCase' }),
|
||||
uniqueId: 'mostPlayed',
|
||||
},
|
||||
];
|
||||
@@ -165,7 +150,9 @@ const HomeRoute = () => {
|
||||
backgroundColor: 'var(--titlebar-bg)',
|
||||
children: (
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.Title>Home</LibraryHeaderBar.Title>
|
||||
<LibraryHeaderBar.Title>
|
||||
{t('page.home.title', { postProcess: 'titleCase' })}
|
||||
</LibraryHeaderBar.Title>
|
||||
</LibraryHeaderBar>
|
||||
),
|
||||
offset: 200,
|
||||
@@ -220,7 +207,37 @@ const HomeRoute = () => {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
}}
|
||||
title={{ label: carousel.title }}
|
||||
title={{
|
||||
label: (
|
||||
<Group>
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
{carousel.title}
|
||||
</TextTitle>
|
||||
|
||||
<ActionIcon
|
||||
onClick={() =>
|
||||
queryClient.invalidateQueries({
|
||||
exact: false,
|
||||
queryKey: queryKeys.albums.list(
|
||||
server?.id,
|
||||
{
|
||||
limit: itemsPerPage,
|
||||
sortBy: carousel.sortBy,
|
||||
sortOrder: carousel.sortOrder,
|
||||
startIndex: 0,
|
||||
},
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<RiRefreshLine />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
}}
|
||||
uniqueId={carousel.uniqueId}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useForm } from '@mantine/form';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { openModal } from '@mantine/modals';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
InternetProviderLyricSearchResponse,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from '../../../api/types';
|
||||
import { useLyricSearch } from '../queries/lyric-search-query';
|
||||
import { ScrollArea, Spinner, Text, TextInput } from '/@/renderer/components';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
const SearchItem = styled.button`
|
||||
all: unset;
|
||||
@@ -84,6 +86,7 @@ interface LyricSearchFormProps {
|
||||
}
|
||||
|
||||
export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearchFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
artist: artist || '',
|
||||
@@ -117,11 +120,17 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
|
||||
<Group grow>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
label="Name"
|
||||
label={t('form.lyricSearch.input', {
|
||||
context: 'name',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Artist"
|
||||
label={t('form.lyricSearch.input', {
|
||||
context: 'artist',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('artist')}
|
||||
/>
|
||||
</Group>
|
||||
@@ -170,6 +179,6 @@ export const openLyricSearchModal = ({ artist, name, onSearchOverride }: LyricSe
|
||||
/>
|
||||
),
|
||||
size: 'lg',
|
||||
title: 'Lyrics Search',
|
||||
title: i18n.t('form.lyricSearch.title', { postProcess: 'titleCase' }) as string,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box, Group } from '@mantine/core';
|
||||
import isElectron from 'is-electron';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiAddFill, RiSubtractFill } from 'react-icons/ri';
|
||||
import { LyricsOverride } from '/@/renderer/api/types';
|
||||
import { Button, NumberInput, Tooltip } from '/@/renderer/components';
|
||||
@@ -22,6 +23,7 @@ export const LyricsActions = ({
|
||||
onResetLyric,
|
||||
onSearchOverride,
|
||||
}: LyricsActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const currentSong = useCurrentSong();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const { delayMs, sources } = useLyricsSettings();
|
||||
@@ -54,7 +56,7 @@ export const LyricsActions = ({
|
||||
})
|
||||
}
|
||||
>
|
||||
Search
|
||||
{t('common.search', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -65,7 +67,7 @@ export const LyricsActions = ({
|
||||
<RiSubtractFill />
|
||||
</Button>
|
||||
<Tooltip
|
||||
label="Offset (ms)"
|
||||
label={t('setting.lyricOffset', { postProcess: 'sentenceCase' })}
|
||||
openDelay={500}
|
||||
>
|
||||
<NumberInput
|
||||
@@ -90,7 +92,7 @@ export const LyricsActions = ({
|
||||
variant="subtle"
|
||||
onClick={onResetLyric}
|
||||
>
|
||||
Reset
|
||||
{t('common.reset', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
@@ -104,7 +106,7 @@ export const LyricsActions = ({
|
||||
variant="subtle"
|
||||
onClick={onRemoveLyric}
|
||||
>
|
||||
Clear
|
||||
{t('common.clear', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
@@ -18,6 +18,14 @@ const UnsynchronizedLyricsContainer = styled.div<{ $gap: number }>`
|
||||
overflow: scroll;
|
||||
transform: translateY(-2rem);
|
||||
|
||||
-webkit-mask-image: linear-gradient(
|
||||
180deg,
|
||||
transparent 5%,
|
||||
rgb(0 0 0 / 100%) 20%,
|
||||
rgb(0 0 0 / 100%) 85%,
|
||||
transparent 95%
|
||||
);
|
||||
|
||||
mask-image: linear-gradient(
|
||||
180deg,
|
||||
transparent 5%,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
||||
import { Group } from '@mantine/core';
|
||||
import { Button, Popover } from '/@/renderer/components';
|
||||
import isElectron from 'is-electron';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RiArrowDownLine,
|
||||
RiArrowUpLine,
|
||||
@@ -27,6 +28,7 @@ interface PlayQueueListOptionsProps {
|
||||
}
|
||||
|
||||
export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { clearQueue, moveToBottomOfQueue, moveToTopOfQueue, shuffleQueue, removeFromQueue } =
|
||||
useQueueControls();
|
||||
|
||||
@@ -115,7 +117,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{ label: 'Shuffle queue' }}
|
||||
tooltip={{ label: t('player.shuffle', { postProcess: 'sentenceCase' }) }}
|
||||
variant="default"
|
||||
onClick={handleShuffleQueue}
|
||||
>
|
||||
@@ -124,7 +126,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{ label: 'Move selected to bottom' }}
|
||||
tooltip={{ label: t('action.moveToBottom', { postProcess: 'sentenceCase' }) }}
|
||||
variant="default"
|
||||
onClick={handleMoveToBottom}
|
||||
>
|
||||
@@ -133,7 +135,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{ label: 'Move selected to top' }}
|
||||
tooltip={{ label: t('action.moveToTop', { postProcess: 'sentenceCase' }) }}
|
||||
variant="default"
|
||||
onClick={handleMoveToTop}
|
||||
>
|
||||
@@ -142,7 +144,9 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{ label: 'Remove selected' }}
|
||||
tooltip={{
|
||||
label: t('action.removeFromQueue', { postProcess: 'sentenceCase' }),
|
||||
}}
|
||||
variant="default"
|
||||
onClick={handleRemoveSelected}
|
||||
>
|
||||
@@ -151,7 +155,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{ label: 'Clear queue' }}
|
||||
tooltip={{ label: t('action.clearQueue', { postProcess: 'sentenceCase' }) }}
|
||||
variant="default"
|
||||
onClick={handleClearQueue}
|
||||
>
|
||||
@@ -167,7 +171,9 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{ label: 'Configure' }}
|
||||
tooltip={{
|
||||
label: t('common.configure', { postProcess: 'sentenceCase' }),
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiListSettingsLine size="1.1rem" />
|
||||
|
||||
@@ -11,6 +11,7 @@ import '@ag-grid-community/styles/ag-theme-alpine.css';
|
||||
import {
|
||||
useAppStoreActions,
|
||||
useCurrentSong,
|
||||
useCurrentStatus,
|
||||
useDefaultQueue,
|
||||
usePlayerControls,
|
||||
usePreviousSong,
|
||||
@@ -34,6 +35,7 @@ import { LibraryItem, QueueSong } from '/@/renderer/api/types';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { QUEUE_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
|
||||
import { useAppFocus } from '/@/renderer/hooks';
|
||||
|
||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||
const remote = isElectron() ? window.electron.remote : null;
|
||||
@@ -49,6 +51,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
const { reorderQueue, setCurrentTrack } = useQueueControls();
|
||||
const currentSong = useCurrentSong();
|
||||
const previousSong = usePreviousSong();
|
||||
const status = useCurrentStatus();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const { setAppStore } = useAppStoreActions();
|
||||
const tableConfig = useTableSettings(type);
|
||||
@@ -56,6 +59,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
const playerType = usePlayerType();
|
||||
const { play } = usePlayerControls();
|
||||
const volume = useVolume();
|
||||
const isFocused = useAppFocus();
|
||||
|
||||
useEffect(() => {
|
||||
if (tableRef.current) {
|
||||
@@ -69,7 +73,10 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
},
|
||||
}));
|
||||
|
||||
const columnDefs = useMemo(() => getColumnDefs(tableConfig.columns), [tableConfig.columns]);
|
||||
const columnDefs = useMemo(
|
||||
() => getColumnDefs(tableConfig.columns, false, 'generic'),
|
||||
[tableConfig.columns],
|
||||
);
|
||||
|
||||
const handleDoubleClick = (e: CellDoubleClickedEvent) => {
|
||||
const playerData = setCurrentTrack(e.data.uniqueId);
|
||||
@@ -204,9 +211,9 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentSong, previousSong, tableConfig.followCurrentSong]);
|
||||
}, [currentSong, previousSong, tableConfig.followCurrentSong, status, isFocused]);
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, QUEUE_CONTEXT_MENU_ITEMS);
|
||||
const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, QUEUE_CONTEXT_MENU_ITEMS);
|
||||
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
@@ -218,6 +225,12 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
rowDragMultiRow
|
||||
autoFitColumns={tableConfig.autoFit}
|
||||
columnDefs={columnDefs}
|
||||
context={{
|
||||
currentSong,
|
||||
isFocused,
|
||||
onCellContextMenu,
|
||||
status,
|
||||
}}
|
||||
deselectOnClickOutside={type === 'fullScreen'}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
rowBuffer={50}
|
||||
@@ -225,7 +238,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
rowData={queue}
|
||||
rowHeight={tableConfig.rowHeight || 40}
|
||||
suppressCellFocus={type === 'fullScreen'}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onCellContextMenu={onCellContextMenu}
|
||||
onCellDoubleClicked={handleDoubleClick}
|
||||
onColumnMoved={handleColumnChange}
|
||||
onColumnResized={debouncedColumnChange}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useHotkeys } from '@mantine/hooks';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import formatDuration from 'format-duration';
|
||||
import isElectron from 'is-electron';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IoIosPause } from 'react-icons/io';
|
||||
import {
|
||||
RiMenuAddFill,
|
||||
@@ -92,6 +93,7 @@ const ControlsContainer = styled.div`
|
||||
`;
|
||||
|
||||
export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const currentSong = useCurrentSong();
|
||||
@@ -171,7 +173,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
<PlayerButton
|
||||
icon={<RiStopFill size={15} />}
|
||||
tooltip={{
|
||||
label: 'Stop',
|
||||
label: t('player.stop', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
@@ -183,10 +185,11 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
tooltip={{
|
||||
label:
|
||||
shuffle === PlayerShuffle.NONE
|
||||
? 'Shuffle disabled'
|
||||
: shuffle === PlayerShuffle.TRACK
|
||||
? 'Shuffle tracks'
|
||||
: 'Shuffle albums',
|
||||
? t('player.shuffle', {
|
||||
context: 'off',
|
||||
postProcess: 'sentenceCase',
|
||||
})
|
||||
: t('player.shuffle', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
@@ -194,7 +197,10 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<RiSkipBackFill size={15} />}
|
||||
tooltip={{ label: 'Previous track', openDelay: 500 }}
|
||||
tooltip={{
|
||||
label: t('player.previous', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={handlePrevTrack}
|
||||
/>
|
||||
@@ -202,7 +208,10 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
<PlayerButton
|
||||
icon={<RiRewindFill size={15} />}
|
||||
tooltip={{
|
||||
label: `Skip backwards ${skip?.skipBackwardSeconds} seconds`,
|
||||
label: t('player.skip', {
|
||||
context: 'back',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
@@ -218,7 +227,10 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
)
|
||||
}
|
||||
tooltip={{
|
||||
label: status === PlayerStatus.PAUSED ? 'Play' : 'Pause',
|
||||
label:
|
||||
status === PlayerStatus.PAUSED
|
||||
? t('player.play', { postProcess: 'sentenceCase' })
|
||||
: t('player.pause', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="main"
|
||||
@@ -228,7 +240,10 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
<PlayerButton
|
||||
icon={<RiSpeedFill size={15} />}
|
||||
tooltip={{
|
||||
label: `Skip forwards ${skip?.skipForwardSeconds} seconds`,
|
||||
label: t('player.stop', {
|
||||
context: 'forward',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
@@ -237,7 +252,10 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
)}
|
||||
<PlayerButton
|
||||
icon={<RiSkipForwardFill size={15} />}
|
||||
tooltip={{ label: 'Next track', openDelay: 500 }}
|
||||
tooltip={{
|
||||
label: t('player.next', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={handleNextTrack}
|
||||
/>
|
||||
@@ -253,10 +271,19 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
tooltip={{
|
||||
label: `${
|
||||
repeat === PlayerRepeat.NONE
|
||||
? 'Repeat disabled'
|
||||
? t('player.repeat', {
|
||||
context: 'off',
|
||||
postProcess: 'sentenceCase',
|
||||
})
|
||||
: repeat === PlayerRepeat.ALL
|
||||
? 'Repeat all'
|
||||
: 'Repeat one'
|
||||
? t('player.repeat', {
|
||||
context: 'all',
|
||||
postProcess: 'sentenceCase',
|
||||
})
|
||||
: t('player.repeat', {
|
||||
context: 'one',
|
||||
postProcess: 'sentenceCase',
|
||||
})
|
||||
}`,
|
||||
openDelay: 500,
|
||||
}}
|
||||
@@ -267,7 +294,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
<PlayerButton
|
||||
icon={<RiMenuAddFill size={15} />}
|
||||
tooltip={{
|
||||
label: 'Shuffle all',
|
||||
label: t('player.playRandom', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
|
||||
@@ -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 } from 'react';
|
||||
import { useEffect, useRef, useLayoutEffect, useState, useCallback } from 'react';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -89,11 +89,11 @@ const imageVariants: Variants = {
|
||||
},
|
||||
};
|
||||
|
||||
const scaleImageUrl = (url?: string | null) => {
|
||||
const scaleImageUrl = (imageSize: number, url?: string | null) => {
|
||||
return url
|
||||
?.replace(/&size=\d+/, '&size=500')
|
||||
.replace(/\?width=\d+/, '?width=500')
|
||||
.replace(/&height=\d+/, '&height=500');
|
||||
?.replace(/&size=\d+/, `&size=${imageSize}`)
|
||||
.replace(/\?width=\d+/, `?width=${imageSize}`)
|
||||
.replace(/&height=\d+/, `&height=${imageSize}`);
|
||||
};
|
||||
|
||||
const ImageWithPlaceholder = ({
|
||||
@@ -127,6 +127,9 @@ const ImageWithPlaceholder = ({
|
||||
};
|
||||
|
||||
export const FullScreenPlayerImage = () => {
|
||||
const mainImageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [mainImageDimensions, setMainImageDimensions] = useState({ idealSize: 1 });
|
||||
|
||||
const { queue } = usePlayerData();
|
||||
const { opacity, useImageAspectRatio } = useFullScreenPlayerStore();
|
||||
const currentSong = queue.current;
|
||||
@@ -136,13 +139,31 @@ export const FullScreenPlayerImage = () => {
|
||||
srcLoaded: true,
|
||||
});
|
||||
const imageKey = `image-${background}`;
|
||||
|
||||
const [imageState, setImageState] = useSetState({
|
||||
bottomImage: scaleImageUrl(queue.next?.imageUrl),
|
||||
bottomImage: scaleImageUrl(mainImageDimensions.idealSize, queue.next?.imageUrl),
|
||||
current: 0,
|
||||
topImage: scaleImageUrl(queue.current?.imageUrl),
|
||||
topImage: scaleImageUrl(mainImageDimensions.idealSize, queue.current?.imageUrl),
|
||||
});
|
||||
|
||||
const updateImageSize = useCallback(() => {
|
||||
if (mainImageRef.current) {
|
||||
setMainImageDimensions({
|
||||
idealSize:
|
||||
Math.ceil((mainImageRef.current as HTMLDivElement).offsetHeight / 100) * 100,
|
||||
});
|
||||
|
||||
setImageState({
|
||||
bottomImage: scaleImageUrl(mainImageDimensions.idealSize, queue.next?.imageUrl),
|
||||
current: 0,
|
||||
topImage: scaleImageUrl(mainImageDimensions.idealSize, queue.current?.imageUrl),
|
||||
});
|
||||
}
|
||||
}, [mainImageDimensions.idealSize, queue, setImageState]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateImageSize();
|
||||
}, [updateImageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubSongChange = usePlayerStore.subscribe(
|
||||
(state) => [state.current.song, state.actions.getPlayerData().queue],
|
||||
@@ -150,8 +171,14 @@ export const FullScreenPlayerImage = () => {
|
||||
const isTop = imageState.current === 0;
|
||||
const queue = state[1] as PlayerData['queue'];
|
||||
|
||||
const currentImageUrl = scaleImageUrl(queue.current?.imageUrl);
|
||||
const nextImageUrl = scaleImageUrl(queue.next?.imageUrl);
|
||||
const currentImageUrl = scaleImageUrl(
|
||||
mainImageDimensions.idealSize,
|
||||
queue.current?.imageUrl,
|
||||
);
|
||||
const nextImageUrl = scaleImageUrl(
|
||||
mainImageDimensions.idealSize,
|
||||
queue.next?.imageUrl,
|
||||
);
|
||||
|
||||
setImageState({
|
||||
bottomImage: isTop ? currentImageUrl : nextImageUrl,
|
||||
@@ -165,7 +192,7 @@ export const FullScreenPlayerImage = () => {
|
||||
return () => {
|
||||
unsubSongChange();
|
||||
};
|
||||
}, [imageState, queue, setImageState]);
|
||||
}, [imageState, mainImageDimensions.idealSize, queue, setImageState]);
|
||||
|
||||
return (
|
||||
<PlayerContainer
|
||||
@@ -175,7 +202,10 @@ export const FullScreenPlayerImage = () => {
|
||||
justify="flex-start"
|
||||
p="1rem"
|
||||
>
|
||||
<ImageContainer>
|
||||
<ImageContainer
|
||||
ref={mainImageRef}
|
||||
onLoad={updateImageSize}
|
||||
>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="sync"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Group, Center } 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 styled from 'styled-components';
|
||||
@@ -50,11 +51,12 @@ const GridContainer = styled.div<TransparendGridContainerProps>`
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
grid-template-columns: 1fr;
|
||||
padding: 1rem;
|
||||
background: rgb(var(--main-bg-transparent), ${({ opacity }) => opacity}%);
|
||||
background: rgb(var(--main-bg-transparent) ${({ opacity }) => opacity}%);
|
||||
border-radius: 5px;
|
||||
`;
|
||||
|
||||
export const FullScreenPlayerQueue = () => {
|
||||
const { t } = useTranslation();
|
||||
const { activeTab, opacity } = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
|
||||
@@ -62,19 +64,19 @@ export const FullScreenPlayerQueue = () => {
|
||||
{
|
||||
active: activeTab === 'queue',
|
||||
icon: <RiFileMusicLine size="1.5rem" />,
|
||||
label: 'Up Next',
|
||||
label: t('page.fullScreenPlayer.upNext'),
|
||||
onClick: () => setStore({ activeTab: 'queue' }),
|
||||
},
|
||||
{
|
||||
active: activeTab === 'related',
|
||||
icon: <HiOutlineQueueList size="1.5rem" />,
|
||||
label: 'Related',
|
||||
label: t('page.fullScreenPlayer.related'),
|
||||
onClick: () => setStore({ activeTab: 'related' }),
|
||||
},
|
||||
{
|
||||
active: activeTab === 'lyrics',
|
||||
icon: <RiFileTextLine size="1.5rem" />,
|
||||
label: 'Lyrics',
|
||||
label: t('page.fullScreenPlayer.lyrics'),
|
||||
onClick: () => setStore({ activeTab: 'lyrics' }),
|
||||
},
|
||||
];
|
||||
@@ -125,7 +127,7 @@ export const FullScreenPlayerQueue = () => {
|
||||
order={3}
|
||||
weight={700}
|
||||
>
|
||||
COMING SOON
|
||||
{t('common.comingSoon', { postProcess: 'upperCase' })}
|
||||
</TextTitle>
|
||||
</Group>
|
||||
</Center>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useLayoutEffect, useRef } from 'react';
|
||||
import { Divider, Group } from '@mantine/core';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { Variants, motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiArrowDownSLine, RiSettings3Line } from 'react-icons/ri';
|
||||
import { useLocation } from 'react-router';
|
||||
import styled from 'styled-components';
|
||||
@@ -70,6 +71,7 @@ const BackgroundImageOverlay = styled.div`
|
||||
`;
|
||||
|
||||
const Controls = () => {
|
||||
const { t } = useTranslation();
|
||||
const { dynamicBackground, expanded, opacity, useImageAspectRatio } =
|
||||
useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
@@ -104,7 +106,7 @@ const Controls = () => {
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
tooltip={{ label: 'Minimize' }}
|
||||
tooltip={{ label: t('common.minimize', { postProcess: 'titleCase' }) }}
|
||||
variant="subtle"
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
>
|
||||
@@ -115,7 +117,7 @@ const Controls = () => {
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
tooltip={{ label: 'Configure' }}
|
||||
tooltip={{ label: t('common.configure', { postProcess: 'titleCase' }) }}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Line size="1.5rem" />
|
||||
@@ -123,7 +125,11 @@ const Controls = () => {
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Option>
|
||||
<Option.Label>Dynamic Background</Option.Label>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.dynamicBackground', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={dynamicBackground}
|
||||
@@ -137,7 +143,11 @@ const Controls = () => {
|
||||
</Option>
|
||||
{dynamicBackground && (
|
||||
<Option>
|
||||
<Option.Label>Opacity</Option.Label>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.opacity', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Slider
|
||||
defaultValue={opacity}
|
||||
@@ -151,7 +161,11 @@ const Controls = () => {
|
||||
</Option>
|
||||
)}
|
||||
<Option>
|
||||
<Option.Label>Use image aspect ratio</Option.Label>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.useImageAspectRatio', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
checked={useImageAspectRatio}
|
||||
@@ -165,7 +179,11 @@ const Controls = () => {
|
||||
</Option>
|
||||
<Divider my="sm" />
|
||||
<Option>
|
||||
<Option.Label>Follow current lyrics</Option.Label>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.followCurrentLyric', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
checked={lyricConfig.follow}
|
||||
@@ -176,7 +194,11 @@ const Controls = () => {
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Show lyrics provider</Option.Label>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.showLyricProvider', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
checked={lyricConfig.showProvider}
|
||||
@@ -187,7 +209,11 @@ const Controls = () => {
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Show lyrics match</Option.Label>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.showLyricMatch', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
checked={lyricConfig.showMatch}
|
||||
@@ -198,7 +224,11 @@ const Controls = () => {
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Lyrics size</Option.Label>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.lyric', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Group
|
||||
noWrap
|
||||
@@ -206,7 +236,11 @@ const Controls = () => {
|
||||
>
|
||||
<Slider
|
||||
defaultValue={lyricConfig.fontSize}
|
||||
label={(e) => `Synchronized: ${e}px`}
|
||||
label={(e) =>
|
||||
`${t('page.fullscreenPlayer.synchronized', {
|
||||
postProcess: 'titleCase',
|
||||
})}: ${e}px`
|
||||
}
|
||||
max={72}
|
||||
min={8}
|
||||
w="100%"
|
||||
@@ -214,7 +248,11 @@ const Controls = () => {
|
||||
/>
|
||||
<Slider
|
||||
defaultValue={lyricConfig.fontSize}
|
||||
label={(e) => `Unsynchronized: ${e}px`}
|
||||
label={(e) =>
|
||||
`${t('page.fullscreenPlayer.unsynchronized', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}: ${e}px`
|
||||
}
|
||||
max={72}
|
||||
min={8}
|
||||
w="100%"
|
||||
@@ -226,7 +264,11 @@ const Controls = () => {
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Lyrics gap</Option.Label>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.lyricGap', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Group
|
||||
noWrap
|
||||
@@ -254,13 +296,32 @@ const Controls = () => {
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Lyrics alignment</Option.Label>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.lyricAlignment', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Select
|
||||
data={[
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Center', value: 'center' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
{
|
||||
label: t('common.left', {
|
||||
postProcess: 'titleCase',
|
||||
}),
|
||||
value: 'left',
|
||||
},
|
||||
{
|
||||
label: t('common.center', {
|
||||
postProcess: 'titleCase',
|
||||
}),
|
||||
value: 'center',
|
||||
},
|
||||
{
|
||||
label: t('common.right', {
|
||||
postProcess: 'titleCase',
|
||||
}),
|
||||
value: 'right',
|
||||
},
|
||||
]}
|
||||
value={lyricConfig.alignment}
|
||||
onChange={(e) => handleLyricsSettings('alignment', e)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { MouseEvent } from 'react';
|
||||
import { Center, Group } from '@mantine/core';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { motion, AnimatePresence, LayoutGroup } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiArrowUpSLine, RiDiscLine, RiMore2Fill } from 'react-icons/ri';
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
@@ -92,6 +93,7 @@ const LeftControlsContainer = styled.div`
|
||||
`;
|
||||
|
||||
export const LeftControls = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setSideBar } = useAppStoreActions();
|
||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
||||
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||
@@ -147,7 +149,9 @@ export const LeftControls = () => {
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
>
|
||||
<Tooltip
|
||||
label="Toggle fullscreen player"
|
||||
label={t('player.toggleFullscreenPlayer', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
openDelay={500}
|
||||
>
|
||||
{currentSong?.imageUrl ? (
|
||||
@@ -182,7 +186,12 @@ export const LeftControls = () => {
|
||||
right: 2,
|
||||
top: 2,
|
||||
}}
|
||||
tooltip={{ label: 'Expand', openDelay: 500 }}
|
||||
tooltip={{
|
||||
label: t('common.expand', {
|
||||
postProcess: 'titleCase',
|
||||
}),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="default"
|
||||
onClick={handleToggleSidebarImage}
|
||||
>
|
||||
|
||||
@@ -103,6 +103,7 @@ const StyledPlayerButton = styled(UnstyledButton)<StyledPlayerButtonProps>`
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--playerbar-btn-fg-hover);
|
||||
background: var(--playerbar-btn-bg-hover);
|
||||
|
||||
svg {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { MouseEvent, useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Flex, Group } from '@mantine/core';
|
||||
import { useHotkeys, useMediaQuery } from '@mantine/hooks';
|
||||
import isElectron from 'is-electron';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HiOutlineQueueList } from 'react-icons/hi2';
|
||||
import {
|
||||
RiVolumeUpFill,
|
||||
@@ -16,44 +17,58 @@ import {
|
||||
useCurrentSong,
|
||||
useHotkeySettings,
|
||||
useMuted,
|
||||
usePreviousSong,
|
||||
useSidebarStore,
|
||||
useSpeed,
|
||||
useVolume,
|
||||
} from '/@/renderer/store';
|
||||
import { useRightControls } from '../hooks/use-right-controls';
|
||||
import { PlayerButton } from './player-button';
|
||||
import { LibraryItem, ServerType, Song } from '/@/renderer/api/types';
|
||||
import { LibraryItem, QueueSong, ServerType, Song } from '/@/renderer/api/types';
|
||||
import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared';
|
||||
import { Rating } from '/@/renderer/components';
|
||||
import { DropdownMenu, Rating } from '/@/renderer/components';
|
||||
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
|
||||
|
||||
const ipc = isElectron() ? window.electron.ipc : null;
|
||||
const remote = isElectron() ? window.electron.remote : null;
|
||||
|
||||
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)');
|
||||
const volume = useVolume();
|
||||
const muted = useMuted();
|
||||
const server = useCurrentServer();
|
||||
const currentSong = useCurrentSong();
|
||||
const previousSong = usePreviousSong();
|
||||
const { setSideBar } = useAppStoreActions();
|
||||
const { rightExpanded: isQueueExpanded } = useSidebarStore();
|
||||
const { bindings } = useHotkeySettings();
|
||||
const { handleVolumeSlider, handleVolumeWheel, handleMute, handleVolumeDown, handleVolumeUp } =
|
||||
useRightControls();
|
||||
const {
|
||||
handleVolumeSlider,
|
||||
handleVolumeWheel,
|
||||
handleMute,
|
||||
handleVolumeDown,
|
||||
handleVolumeUp,
|
||||
handleSpeed,
|
||||
} = useRightControls();
|
||||
|
||||
const speed = useSpeed();
|
||||
|
||||
const updateRatingMutation = useSetRating({});
|
||||
const addToFavoritesMutation = useCreateFavorite({});
|
||||
const removeFromFavoritesMutation = useDeleteFavorite({});
|
||||
|
||||
const handleAddToFavorites = () => {
|
||||
if (!currentSong) return;
|
||||
const handleAddToFavorites = (song: QueueSong | undefined) => {
|
||||
if (!song?.id) return;
|
||||
|
||||
addToFavoritesMutation.mutate({
|
||||
query: {
|
||||
id: [currentSong.id],
|
||||
id: [song.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
serverId: song?.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -69,37 +84,25 @@ export const RightControls = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearRating = (_e: MouseEvent<HTMLDivElement> | null, rating?: number) => {
|
||||
if (!currentSong || !rating) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
query: {
|
||||
item: [currentSong],
|
||||
rating: 0,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = () => {
|
||||
if (!currentSong) return;
|
||||
const handleRemoveFromFavorites = (song: QueueSong | undefined) => {
|
||||
if (!song?.id) return;
|
||||
|
||||
removeFromFavoritesMutation.mutate({
|
||||
query: {
|
||||
id: [currentSong.id],
|
||||
id: [song.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
serverId: song?.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
if (!currentSong) return;
|
||||
const handleToggleFavorite = (song: QueueSong | undefined) => {
|
||||
if (!song?.id) return;
|
||||
|
||||
if (currentSong.userFavorite) {
|
||||
handleRemoveFromFavorites();
|
||||
if (song.userFavorite) {
|
||||
handleRemoveFromFavorites(song);
|
||||
} else {
|
||||
handleAddToFavorites();
|
||||
handleAddToFavorites(song);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -115,7 +118,31 @@ export const RightControls = () => {
|
||||
[bindings.volumeUp.isGlobal ? '' : bindings.volumeUp.hotkey, handleVolumeUp],
|
||||
[bindings.volumeMute.isGlobal ? '' : bindings.volumeMute.hotkey, handleMute],
|
||||
[bindings.toggleQueue.isGlobal ? '' : bindings.toggleQueue.hotkey, handleToggleQueue],
|
||||
[bindings.rate0.isGlobal ? '' : bindings.rate0.hotkey, () => handleClearRating(null, 0)],
|
||||
[
|
||||
bindings.favoriteCurrentAdd.isGlobal ? '' : bindings.favoriteCurrentAdd.hotkey,
|
||||
() => handleAddToFavorites(currentSong),
|
||||
],
|
||||
[
|
||||
bindings.favoriteCurrentRemove.isGlobal ? '' : bindings.favoriteCurrentRemove.hotkey,
|
||||
() => handleRemoveFromFavorites(currentSong),
|
||||
],
|
||||
[
|
||||
bindings.favoriteCurrentToggle.isGlobal ? '' : bindings.favoriteCurrentToggle.hotkey,
|
||||
() => handleToggleFavorite(currentSong),
|
||||
],
|
||||
[
|
||||
bindings.favoritePreviousAdd.isGlobal ? '' : bindings.favoritePreviousAdd.hotkey,
|
||||
() => handleAddToFavorites(previousSong),
|
||||
],
|
||||
[
|
||||
bindings.favoritePreviousRemove.isGlobal ? '' : bindings.favoritePreviousRemove.hotkey,
|
||||
() => handleRemoveFromFavorites(previousSong),
|
||||
],
|
||||
[
|
||||
bindings.favoritePreviousToggle.isGlobal ? '' : bindings.favoritePreviousToggle.hotkey,
|
||||
() => handleToggleFavorite(previousSong),
|
||||
],
|
||||
[bindings.rate0.isGlobal ? '' : bindings.rate0.hotkey, () => handleUpdateRating(0)],
|
||||
[bindings.rate1.isGlobal ? '' : bindings.rate1.hotkey, () => handleUpdateRating(1)],
|
||||
[bindings.rate2.isGlobal ? '' : bindings.rate2.hotkey, () => handleUpdateRating(2)],
|
||||
[bindings.rate3.isGlobal ? '' : bindings.rate3.hotkey, () => handleUpdateRating(3)],
|
||||
@@ -175,7 +202,6 @@ export const RightControls = () => {
|
||||
size="sm"
|
||||
value={currentSong?.userRating || 0}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
@@ -184,6 +210,28 @@ export const RightControls = () => {
|
||||
align="center"
|
||||
spacing="xs"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Target>
|
||||
<PlayerButton
|
||||
icon={<>{speed} x</>}
|
||||
tooltip={{
|
||||
label: t('player.playbackSpeed', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
/>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{PLAYBACK_SPEEDS.map((speed) => (
|
||||
<DropdownMenu.Item
|
||||
key={`speed-select-${speed}`}
|
||||
onClick={() => handleSpeed(Number(speed))}
|
||||
>
|
||||
{speed}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<PlayerButton
|
||||
icon={
|
||||
currentSong?.userFavorite ? (
|
||||
@@ -203,18 +251,22 @@ export const RightControls = () => {
|
||||
},
|
||||
}}
|
||||
tooltip={{
|
||||
label: currentSong?.userFavorite ? 'Unfavorite' : 'Favorite',
|
||||
label: currentSong?.userFavorite
|
||||
? t('player.unfavorite', { postProcess: 'titleCase' })
|
||||
: t('player.favorite', { postProcess: 'titleCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={handleToggleFavorite}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<HiOutlineQueueList size="1.1rem" />}
|
||||
tooltip={{ label: 'View queue', openDelay: 500 }}
|
||||
variant="secondary"
|
||||
onClick={handleToggleQueue}
|
||||
onClick={() => handleToggleFavorite(currentSong)}
|
||||
/>
|
||||
{!isMinWidth ? (
|
||||
<PlayerButton
|
||||
icon={<HiOutlineQueueList size="1.1rem" />}
|
||||
tooltip={{ label: 'View queue', openDelay: 500 }}
|
||||
variant="secondary"
|
||||
onClick={handleToggleQueue}
|
||||
/>
|
||||
) : null}
|
||||
<Group
|
||||
noWrap
|
||||
spacing="xs"
|
||||
@@ -229,7 +281,10 @@ export const RightControls = () => {
|
||||
<RiVolumeDownFill size="1.2rem" />
|
||||
)
|
||||
}
|
||||
tooltip={{ label: muted ? 'Muted' : volume, openDelay: 500 }}
|
||||
tooltip={{
|
||||
label: muted ? t('player.muted', { postProcess: 'titleCase' }) : volume,
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={handleMute}
|
||||
onWheel={handleVolumeWheel}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { api } from '/@/renderer/api';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Play, PlayQueueAddOptions, ServerListItem } from '/@/renderer/types';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
interface ShuffleAllSlice extends RandomSongListQuery {
|
||||
actions: {
|
||||
@@ -260,6 +261,6 @@ export const openShuffleAllModal = async (
|
||||
/>
|
||||
),
|
||||
size: 'sm',
|
||||
title: 'Shuffle all',
|
||||
title: i18n.t('player.playRandom', { postProcess: 'sentenceCase' }) as string,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { QueueSong } from '/@/renderer/api/types';
|
||||
import { toast } from '/@/renderer/components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
|
||||
@@ -28,6 +29,7 @@ const remote = isElectron() ? window.electron.remote : null;
|
||||
const mediaSession = !isElectron() || !utils?.isLinux() ? navigator.mediaSession : null;
|
||||
|
||||
export const useCenterControls = (args: { playersRef: any }) => {
|
||||
const { t } = useTranslation();
|
||||
const { playersRef } = args;
|
||||
|
||||
const settings = useSettingsStore((state) => state.playback);
|
||||
@@ -191,8 +193,9 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
return mpvPlayer?.setQueueNext(playerData);
|
||||
}
|
||||
|
||||
const playerData = setRepeat(PlayerRepeat.NONE);
|
||||
remote?.updateRepeat(PlayerRepeat.NONE);
|
||||
return setRepeat(PlayerRepeat.NONE);
|
||||
return mpvPlayer?.setQueueNext(playerData);
|
||||
}, [repeatStatus, setRepeat]);
|
||||
|
||||
const checkIsLastTrack = useCallback(() => {
|
||||
@@ -612,11 +615,15 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
|
||||
const handleError = useCallback(
|
||||
(message: string) => {
|
||||
toast.error({ id: 'mpv-error', message, title: 'An error occurred during playback' });
|
||||
toast.error({
|
||||
id: 'mpv-error',
|
||||
message,
|
||||
title: t('error.playbackError', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
pause();
|
||||
mpvPlayer!.pause();
|
||||
},
|
||||
[pause],
|
||||
[pause, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
getGenreSongsById,
|
||||
} from '/@/renderer/features/player/utils';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const getRootQueryKey = (itemType: LibraryItem, serverId: string) => {
|
||||
let queryKey;
|
||||
@@ -62,6 +63,7 @@ const remote = isElectron() ? window.electron.remote : null;
|
||||
const addToQueue = usePlayerStore.getState().actions.addToQueue;
|
||||
|
||||
export const useHandlePlayQueueAdd = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const playerType = usePlayerType();
|
||||
const server = useCurrentServer();
|
||||
@@ -86,15 +88,18 @@ export const useHandlePlayQueueAdd = () => {
|
||||
toast.info({
|
||||
autoClose: false,
|
||||
id: fetchId,
|
||||
message:
|
||||
'This is taking a while... close the notification to cancel the request',
|
||||
message: t('player.playbackFetchCancel', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
onClose: () => {
|
||||
queryClient.cancelQueries({
|
||||
exact: false,
|
||||
queryKey: getRootQueryKey(itemType, server?.id),
|
||||
});
|
||||
},
|
||||
title: 'Adding to queue',
|
||||
title: t('player.playbackFetchInProgress', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
});
|
||||
}, 2000),
|
||||
};
|
||||
@@ -140,7 +145,7 @@ export const useHandlePlayQueueAdd = () => {
|
||||
|
||||
return toast.error({
|
||||
message: err.message,
|
||||
title: 'Play queue add failed',
|
||||
title: t('error.genericError', { postProcess: 'sentenceCase' }) as string,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -152,8 +157,8 @@ export const useHandlePlayQueueAdd = () => {
|
||||
|
||||
if (!songs || songs?.length === 0)
|
||||
return toast.warn({
|
||||
message: 'The query returned no results',
|
||||
title: 'No tracks added',
|
||||
message: t('common.noResultsFromQuery', { postProcess: 'sentenceCase' }),
|
||||
title: t('player.playbackFetchNoResults'),
|
||||
});
|
||||
|
||||
if (initialIndex) {
|
||||
@@ -190,7 +195,7 @@ export const useHandlePlayQueueAdd = () => {
|
||||
|
||||
return null;
|
||||
},
|
||||
[play, playerType, queryClient, server],
|
||||
[play, playerType, queryClient, server, t],
|
||||
);
|
||||
|
||||
return handlePlayQueueAdd;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useCallback, useEffect, WheelEvent } from 'react';
|
||||
import isElectron from 'is-electron';
|
||||
import { useMuted, usePlayerControls, useVolume } from '/@/renderer/store';
|
||||
import {
|
||||
useMuted,
|
||||
usePlayerControls,
|
||||
useSetCurrentSpeed,
|
||||
useSpeed,
|
||||
useVolume,
|
||||
} from '/@/renderer/store';
|
||||
import { useGeneralSettings } from '/@/renderer/store/settings.store';
|
||||
|
||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||
@@ -37,6 +43,8 @@ export const useRightControls = () => {
|
||||
const volume = useVolume();
|
||||
const muted = useMuted();
|
||||
const { volumeWheelStep } = useGeneralSettings();
|
||||
const speed = useSpeed();
|
||||
const setCurrentSpeed = useSetCurrentSpeed();
|
||||
|
||||
// Ensure that the mpv player volume is set on startup
|
||||
useEffect(() => {
|
||||
@@ -44,6 +52,7 @@ export const useRightControls = () => {
|
||||
|
||||
if (mpvPlayer) {
|
||||
mpvPlayer.volume(volume);
|
||||
mpvPlayer.setProperties({ speed });
|
||||
|
||||
if (muted) {
|
||||
mpvPlayer.mute(muted);
|
||||
@@ -53,6 +62,16 @@ export const useRightControls = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSpeed = useCallback(
|
||||
(e: number) => {
|
||||
setCurrentSpeed(e);
|
||||
if (mpvPlayer) {
|
||||
mpvPlayer?.setProperties({ speed: e });
|
||||
}
|
||||
},
|
||||
[setCurrentSpeed],
|
||||
);
|
||||
|
||||
const handleVolumeSlider = (e: number) => {
|
||||
mpvPlayer?.volume(e);
|
||||
remote?.updateVolume(e);
|
||||
@@ -123,6 +142,7 @@ export const useRightControls = () => {
|
||||
|
||||
return {
|
||||
handleMute,
|
||||
handleSpeed,
|
||||
handleVolumeDown,
|
||||
handleVolumeSlider,
|
||||
handleVolumeSliderState,
|
||||
|
||||
@@ -34,20 +34,21 @@ Progress Events (Jellyfin only):
|
||||
*/
|
||||
|
||||
const checkScrobbleConditions = (args: {
|
||||
scrobbleAtDuration: number;
|
||||
scrobbleAtDurationMs: number;
|
||||
scrobbleAtPercentage: number;
|
||||
songCompletedDuration: number;
|
||||
songDuration: number;
|
||||
songCompletedDurationMs: number;
|
||||
songDurationMs: number;
|
||||
}) => {
|
||||
const { scrobbleAtDuration, scrobbleAtPercentage, songCompletedDuration, songDuration } = args;
|
||||
const percentageOfSongCompleted = songDuration
|
||||
? (songCompletedDuration / songDuration) * 100
|
||||
const { scrobbleAtDurationMs, scrobbleAtPercentage, songCompletedDurationMs, songDurationMs } =
|
||||
args;
|
||||
const percentageOfSongCompleted = songDurationMs
|
||||
? (songCompletedDurationMs / songDurationMs) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
percentageOfSongCompleted >= scrobbleAtPercentage ||
|
||||
songCompletedDuration >= scrobbleAtDuration
|
||||
);
|
||||
const shouldScrobbleBasedOnPercetange = percentageOfSongCompleted >= scrobbleAtPercentage;
|
||||
const shouldScrobbleBasedOnDuration = songCompletedDurationMs >= scrobbleAtDurationMs;
|
||||
|
||||
return shouldScrobbleBasedOnPercetange || shouldScrobbleBasedOnDuration;
|
||||
};
|
||||
|
||||
export const useScrobble = () => {
|
||||
@@ -97,15 +98,15 @@ export const useScrobble = () => {
|
||||
|
||||
// const currentSong = current[0] as QueueSong | undefined;
|
||||
const previousSong = previous[0] as QueueSong;
|
||||
const previousSongTime = previous[1] as number;
|
||||
const previousSongTimeSec = previous[1] as number;
|
||||
|
||||
// Send completion scrobble when song changes and a previous song exists
|
||||
if (previousSong?.id) {
|
||||
const shouldSubmitScrobble = checkScrobbleConditions({
|
||||
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
|
||||
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
||||
songCompletedDuration: previousSongTime,
|
||||
songDuration: previousSong.duration,
|
||||
songCompletedDurationMs: previousSongTimeSec * 1000,
|
||||
songDurationMs: previousSong.duration,
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -114,7 +115,7 @@ export const useScrobble = () => {
|
||||
) {
|
||||
const position =
|
||||
previousSong?.serverType === ServerType.JELLYFIN
|
||||
? previousSongTime * 1e7
|
||||
? previousSongTimeSec * 1e7
|
||||
: undefined;
|
||||
|
||||
sendScrobble.mutate({
|
||||
@@ -168,7 +169,10 @@ export const useScrobble = () => {
|
||||
);
|
||||
|
||||
const handleScrobbleFromStatusChange = useCallback(
|
||||
(status: PlayerStatus | undefined) => {
|
||||
(
|
||||
current: (PlayerStatus | number | undefined)[],
|
||||
previous: (PlayerStatus | number | undefined)[],
|
||||
) => {
|
||||
if (!isScrobbleEnabled) return;
|
||||
|
||||
const currentSong = usePlayerStore.getState().current.song;
|
||||
@@ -180,8 +184,11 @@ export const useScrobble = () => {
|
||||
? usePlayerStore.getState().current.time * 1e7
|
||||
: undefined;
|
||||
|
||||
const currentStatus = current[0] as PlayerStatus;
|
||||
const currentTimeSec = current[1] as number;
|
||||
|
||||
// Whenever the player is restarted, send a 'start' scrobble
|
||||
if (status === PlayerStatus.PLAYING) {
|
||||
if (currentStatus === PlayerStatus.PLAYING) {
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
event: 'unpause',
|
||||
@@ -194,7 +201,7 @@ export const useScrobble = () => {
|
||||
|
||||
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
progressIntervalId.current = setInterval(() => {
|
||||
const currentTime = usePlayerStore.getState().current.time;
|
||||
const currentTime = currentTimeSec;
|
||||
handleScrobbleFromSeek(currentTime);
|
||||
}, 10000);
|
||||
}
|
||||
@@ -215,12 +222,17 @@ export const useScrobble = () => {
|
||||
clearInterval(progressIntervalId.current as ReturnType<typeof setInterval>);
|
||||
}
|
||||
} else {
|
||||
const isLastTrackInQueue = usePlayerStore.getState().actions.checkIsLastTrack();
|
||||
const previousTimeSec = previous[1] as number;
|
||||
|
||||
// If not already scrobbled, send a 'submission' scrobble if conditions are met
|
||||
const shouldSubmitScrobble = checkScrobbleConditions({
|
||||
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
|
||||
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
||||
songCompletedDuration: usePlayerStore.getState().current.time,
|
||||
songDuration: currentSong.duration,
|
||||
// If scrobbling the last song in the queue, use the previous time instead of the current time since otherwise time value will be 0
|
||||
songCompletedDurationMs:
|
||||
(isLastTrackInQueue ? previousTimeSec : currentTimeSec) * 1000,
|
||||
songDurationMs: currentSong.duration,
|
||||
});
|
||||
|
||||
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
|
||||
@@ -261,10 +273,10 @@ export const useScrobble = () => {
|
||||
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
|
||||
|
||||
const shouldSubmitScrobble = checkScrobbleConditions({
|
||||
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
|
||||
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
||||
songCompletedDuration: currentTime,
|
||||
songDuration: currentSong.duration,
|
||||
songCompletedDurationMs: currentTime,
|
||||
songDurationMs: currentSong.duration,
|
||||
});
|
||||
|
||||
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
|
||||
@@ -313,8 +325,11 @@ export const useScrobble = () => {
|
||||
);
|
||||
|
||||
const unsubStatusChange = usePlayerStore.subscribe(
|
||||
(state) => state.current.status,
|
||||
(state) => [state.current.status, state.current.time],
|
||||
handleScrobbleFromStatusChange,
|
||||
{
|
||||
equalityFn: (a, b) => (a[0] as PlayerStatus) === (b[0] as PlayerStatus),
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Box, Group, Stack } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { closeModal, ContextModalProps } from '@mantine/modals';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { PlaylistListSort, SongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types';
|
||||
@@ -11,6 +11,7 @@ import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-t
|
||||
import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const AddToPlaylistContextModal = ({
|
||||
id,
|
||||
@@ -21,6 +22,7 @@ export const AddToPlaylistContextModal = ({
|
||||
genreId?: string[];
|
||||
songId?: string[];
|
||||
}>) => {
|
||||
const { t } = useTranslation();
|
||||
const { albumId, artistId, genreId, songId } = innerProps;
|
||||
const server = useCurrentServer();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -98,7 +100,7 @@ export const AddToPlaylistContextModal = ({
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
setIsLoading(true);
|
||||
const allSongIds: string[] = [];
|
||||
const uniqueSongIds: string[] = [];
|
||||
let totalUniquesAdded = 0;
|
||||
|
||||
if (albumId && albumId.length > 0) {
|
||||
for (const id of albumId) {
|
||||
@@ -129,6 +131,8 @@ export const AddToPlaylistContextModal = ({
|
||||
}
|
||||
|
||||
for (const playlistId of values.playlistId) {
|
||||
const uniqueSongIds: string[] = [];
|
||||
|
||||
if (values.skipDuplicates) {
|
||||
const query = {
|
||||
id: playlistId,
|
||||
@@ -138,7 +142,10 @@ export const AddToPlaylistContextModal = ({
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query);
|
||||
|
||||
const playlistSongsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
|
||||
if (!server) throw new Error('No server');
|
||||
if (!server)
|
||||
throw new Error(
|
||||
t('error.serverNotSelectedError', { postProcess: 'sentenceCase' }),
|
||||
);
|
||||
return api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
@@ -155,6 +162,7 @@ export const AddToPlaylistContextModal = ({
|
||||
uniqueSongIds.push(songId);
|
||||
}
|
||||
}
|
||||
totalUniquesAdded += uniqueSongIds.length;
|
||||
}
|
||||
|
||||
if (values.skipDuplicates ? uniqueSongIds.length > 0 : allSongIds.length > 0) {
|
||||
@@ -172,7 +180,7 @@ export const AddToPlaylistContextModal = ({
|
||||
playlistSelect.find((playlist) => playlist.value === playlistId)
|
||||
?.label
|
||||
}] ${err.message}`,
|
||||
title: 'Failed to add songs to playlist',
|
||||
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -180,11 +188,19 @@ export const AddToPlaylistContextModal = ({
|
||||
}
|
||||
}
|
||||
|
||||
const addMessage =
|
||||
values.skipDuplicates &&
|
||||
allSongIds.length * values.playlistId.length !== totalUniquesAdded
|
||||
? `${Math.floor(totalUniquesAdded / values.playlistId.length)}`
|
||||
: allSongIds.length;
|
||||
|
||||
setIsLoading(false);
|
||||
toast.success({
|
||||
message: `Added ${
|
||||
values.skipDuplicates ? uniqueSongIds.length : allSongIds.length
|
||||
} songs to ${values.playlistId.length} playlist(s)`,
|
||||
message: t('form.addToPlaylist', {
|
||||
message: addMessage,
|
||||
numOfPlaylists: values.playlistId.length,
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
});
|
||||
closeModal(id);
|
||||
return null;
|
||||
@@ -199,12 +215,18 @@ export const AddToPlaylistContextModal = ({
|
||||
searchable
|
||||
data={playlistSelect}
|
||||
disabled={playlistList.isLoading}
|
||||
label="Playlists"
|
||||
label={t('form.addToPlaylist.input', {
|
||||
context: 'playlists',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
size="md"
|
||||
{...form.getInputProps('playlistId')}
|
||||
/>
|
||||
<Switch
|
||||
label="Skip duplicates"
|
||||
label={t('form.addToPlaylist.input', {
|
||||
context: 'skipDuplicates',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('skipDuplicates', { type: 'checkbox' })}
|
||||
/>
|
||||
<Group position="right">
|
||||
@@ -215,7 +237,7 @@ export const AddToPlaylistContextModal = ({
|
||||
variant="subtle"
|
||||
onClick={() => closeModal(id)}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
@@ -224,7 +246,7 @@ export const AddToPlaylistContextModal = ({
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Add
|
||||
{t('common.add', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useRef, useState } from 'react';
|
||||
import { CreatePlaylistBody, ServerType, SongListSort } from '/@/renderer/api/types';
|
||||
import { Button, Switch, Text, TextInput, toast } from '/@/renderer/components';
|
||||
import {
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
|
||||
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CreatePlaylistFormProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const mutation = useCreatePlaylist({});
|
||||
const server = useCurrentServer();
|
||||
const queryBuilderRef = useRef<PlaylistQueryBuilderRef>(null);
|
||||
@@ -69,10 +71,15 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({ message: err.message, title: 'Error creating playlist' });
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({ message: `Playlist has been created` });
|
||||
toast.success({
|
||||
message: t('form.createPlaylist.success', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
@@ -88,17 +95,26 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
||||
<TextInput
|
||||
data-autofocus
|
||||
required
|
||||
label="Name"
|
||||
label={t('form.createPlaylist.input', {
|
||||
context: 'name',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
label={t('form.createPlaylist.input', {
|
||||
context: 'description',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('comment')}
|
||||
/>
|
||||
<Group>
|
||||
{isPublicDisplayed && (
|
||||
<Switch
|
||||
label="Is public?"
|
||||
label={t('form.createPlaylist.input', {
|
||||
context: 'public',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('_custom.navidrome.public', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
@@ -130,7 +146,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
||||
variant="subtle"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
@@ -138,7 +154,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Save
|
||||
{t('common.create', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { MutableRefObject, useMemo, useRef } from 'react';
|
||||
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Box, Group } from '@mantine/core';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { MutableRefObject, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiMoreFill } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -45,6 +46,7 @@ interface PlaylistDetailContentProps {
|
||||
}
|
||||
|
||||
export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const { table } = useListStoreByKey({ key: LibraryItem.SONG });
|
||||
@@ -102,13 +104,10 @@ export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps)
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: 'Error deleting playlist',
|
||||
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({
|
||||
message: `Playlist has been deleted`,
|
||||
});
|
||||
closeAllModals();
|
||||
navigate(AppRoute.PLAYLISTS);
|
||||
},
|
||||
@@ -126,7 +125,7 @@ export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps)
|
||||
Are you sure you want to delete this playlist?
|
||||
</ConfirmModal>
|
||||
),
|
||||
title: 'Delete playlist',
|
||||
title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
|
||||
import { getColumnDefs, TablePagination, VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
import { TablePagination, VirtualTable, getColumnDefs } from '/@/renderer/components/virtual-table';
|
||||
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import {
|
||||
@@ -34,6 +34,8 @@ import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playli
|
||||
import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useCurrentSong,
|
||||
useCurrentStatus,
|
||||
usePlaylistDetailStore,
|
||||
usePlaylistDetailTablePagination,
|
||||
useSetPlaylistDetailTable,
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
} from '/@/renderer/store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { useAppFocus } from '/@/renderer/hooks';
|
||||
|
||||
interface PlaylistDetailContentProps {
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
@@ -49,6 +52,9 @@ interface PlaylistDetailContentProps {
|
||||
export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailContentProps) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const queryClient = useQueryClient();
|
||||
const status = useCurrentStatus();
|
||||
const isFocused = useAppFocus();
|
||||
const currentSong = useCurrentSong();
|
||||
const server = useCurrentServer();
|
||||
const page = usePlaylistDetailStore();
|
||||
const filters: Partial<PlaylistSongListQuery> = useMemo(() => {
|
||||
@@ -86,7 +92,7 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
||||
});
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() => getColumnDefs(page.table.columns),
|
||||
() => getColumnDefs(page.table.columns, false, 'generic'),
|
||||
[page.table.columns],
|
||||
);
|
||||
|
||||
@@ -236,6 +242,12 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
||||
alwaysShowHorizontalScroll
|
||||
autoFitColumns={page.table.autoFit}
|
||||
columnDefs={columnDefs}
|
||||
context={{
|
||||
currentSong,
|
||||
isFocused,
|
||||
onCellContextMenu: handleContextMenu,
|
||||
status,
|
||||
}}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100}
|
||||
pagination={isPaginationEnabled}
|
||||
|
||||
+14
-13
@@ -4,6 +4,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
||||
import { Divider, Flex, Group, Stack } from '@mantine/core';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RiMoreFill,
|
||||
RiSettings3Fill,
|
||||
@@ -101,6 +102,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
tableRef,
|
||||
handleToggleShowQueryBuilder,
|
||||
}: PlaylistDetailSongListHeaderFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -267,19 +269,16 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: 'Error deleting playlist',
|
||||
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({
|
||||
message: `Playlist has been deleted`,
|
||||
});
|
||||
navigate(AppRoute.PLAYLISTS, { replace: true });
|
||||
},
|
||||
},
|
||||
);
|
||||
closeAllModals();
|
||||
}, [deletePlaylistMutation, detailQuery.data, navigate]);
|
||||
}, [deletePlaylistMutation, detailQuery.data, navigate, t]);
|
||||
|
||||
const openDeletePlaylistModal = () => {
|
||||
openModal({
|
||||
@@ -288,7 +287,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
<Text>Are you sure you want to delete this playlist?</Text>
|
||||
</ConfirmModal>
|
||||
),
|
||||
title: 'Delete playlist(s)',
|
||||
title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -345,19 +344,19 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
icon={<RiPlayFill />}
|
||||
onClick={() => handlePlay(Play.NOW)}
|
||||
>
|
||||
Play
|
||||
{t('player.play', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddBoxFill />}
|
||||
onClick={() => handlePlay(Play.LAST)}
|
||||
>
|
||||
Add to queue
|
||||
{t('player.addLast', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddCircleFill />}
|
||||
onClick={() => handlePlay(Play.NEXT)}
|
||||
>
|
||||
Add to queue next
|
||||
{t('player.addNext', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
@@ -369,20 +368,20 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit playlist
|
||||
{t('action.editPlaylist', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiDeleteBinFill />}
|
||||
onClick={openDeletePlaylistModal}
|
||||
>
|
||||
Delete playlist
|
||||
{t('action.deletePlaylist', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
icon={<RiRefreshLine />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
{t('action.refresh', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
{server?.type === ServerType.NAVIDROME && !isSmartPlaylist && (
|
||||
<>
|
||||
@@ -391,7 +390,9 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
$danger
|
||||
onClick={handleToggleShowQueryBuilder}
|
||||
>
|
||||
Toggle smart playlist editor
|
||||
{t('action.toggleSmartPlaylistEditor', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { MutableRefObject } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { Badge, PageHeader, Paper, SpinnerIcon } from '/@/renderer/components';
|
||||
@@ -23,6 +24,7 @@ export const PlaylistDetailSongListHeader = ({
|
||||
itemCount,
|
||||
handleToggleShowQueryBuilder,
|
||||
}: PlaylistDetailHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
@@ -58,7 +60,7 @@ export const PlaylistDetailSongListHeader = ({
|
||||
itemCount
|
||||
)}
|
||||
</Paper>
|
||||
{isSmartPlaylist && <Badge size="lg">Smart playlist</Badge>}
|
||||
{isSmartPlaylist && <Badge size="lg">{t('entity.smartPlaylist')}</Badge>}
|
||||
</LibraryHeaderBar>
|
||||
</PageHeader>
|
||||
<Paper p="1rem">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import { IDatasource } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Divider, Flex, Group, Stack } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
|
||||
import { useListContext } from '../../../context/list-context';
|
||||
import { useListStoreByKey } from '../../../store/list.store';
|
||||
@@ -42,6 +43,7 @@ export const PlaylistListHeaderFilters = ({
|
||||
gridRef,
|
||||
tableRef,
|
||||
}: PlaylistListHeaderFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { pageKey } = useListContext();
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
@@ -285,7 +287,7 @@ export const PlaylistListHeaderFilters = ({
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{ label: 'Refresh' }}
|
||||
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
|
||||
variant="subtle"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
@@ -308,7 +310,7 @@ export const PlaylistListHeaderFilters = ({
|
||||
icon={<RiRefreshLine />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
{t('common.refresh', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
@@ -328,27 +330,29 @@ export const PlaylistListHeaderFilters = ({
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.displayType', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.CARD}
|
||||
value={ListDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Card
|
||||
{t('table.config.view.card', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.POSTER}
|
||||
value={ListDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Poster
|
||||
{t('table.config.view.poster', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table
|
||||
{t('table.config.view.table', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
{/* <DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.TABLE_PAGINATED}
|
||||
@@ -382,7 +386,11 @@ export const PlaylistListHeaderFilters = ({
|
||||
</DropdownMenu.Item>
|
||||
{!isGrid && (
|
||||
<>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.generaltableColumns', {
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
@@ -399,7 +407,11 @@ export const PlaylistListHeaderFilters = ({
|
||||
onChange={handleTableColumns}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Text>
|
||||
{t('table.config.general.autoFitColumns', {
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
</Text>
|
||||
<Switch
|
||||
defaultChecked={table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { PlaylistListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
|
||||
import { ListDisplayType, ServerType } from '/@/renderer/types';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiFileAddFill } from 'react-icons/ri';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { useListFilterRefresh } from '../../../hooks/use-list-filter-refresh';
|
||||
@@ -24,6 +25,7 @@ interface PlaylistListHeaderProps {
|
||||
}
|
||||
|
||||
export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistListHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { pageKey } = useListContext();
|
||||
const cq = useContainerQuery();
|
||||
const server = useCurrentServer();
|
||||
@@ -37,7 +39,7 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
|
||||
tableRef?.current?.api?.purgeInfiniteCache();
|
||||
},
|
||||
size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm',
|
||||
title: 'Create Playlist',
|
||||
title: t('form.createPlaylist.title', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -74,7 +76,9 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
|
||||
w="100%"
|
||||
>
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.Title>Playlists</LibraryHeaderBar.Title>
|
||||
<LibraryHeaderBar.Title>
|
||||
{t('page.playlistList.title', { postProcess: 'titleCase' })}
|
||||
</LibraryHeaderBar.Title>
|
||||
<Paper
|
||||
fw="600"
|
||||
px="1rem"
|
||||
@@ -88,7 +92,10 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
|
||||
)}
|
||||
</Paper>
|
||||
<Button
|
||||
tooltip={{ label: 'Create playlist', openDelay: 500 }}
|
||||
tooltip={{
|
||||
label: t('action.createPlaylist', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="filled"
|
||||
onClick={handleCreatePlaylistModal}
|
||||
>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
convertQueryGroupToNDQuery,
|
||||
} from '/@/renderer/features/playlists/utils';
|
||||
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiMore2Fill, RiSaveLine } from 'react-icons/ri';
|
||||
import { SongListSort } from '/@/renderer/api/types';
|
||||
import {
|
||||
@@ -86,6 +87,7 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
{ sortOrder, sortBy, limit, isSaving, query, onSave, onSaveAs }: PlaylistQueryBuilderProps,
|
||||
ref: Ref<PlaylistQueryBuilderRef>,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [filters, setFilters] = useState<QueryBuilderGroup>(
|
||||
query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY,
|
||||
);
|
||||
@@ -354,7 +356,11 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
};
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'Random', type: 'string', value: 'random' },
|
||||
{
|
||||
label: t('filter.random', { postProcess: 'titleCase' }),
|
||||
type: 'string',
|
||||
value: 'random',
|
||||
},
|
||||
...NDSongQueryFields,
|
||||
];
|
||||
|
||||
@@ -414,21 +420,21 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
<Select
|
||||
data={[
|
||||
{
|
||||
label: 'Ascending',
|
||||
label: t('common.ascending', { postProcess: 'titleCase' }),
|
||||
value: 'asc',
|
||||
},
|
||||
{
|
||||
label: 'Descending',
|
||||
label: t('common.descending', { postProcess: 'titleCase' }),
|
||||
value: 'desc',
|
||||
},
|
||||
]}
|
||||
label="Order"
|
||||
label={t('common.order', { postProcess: 'titleCase' })}
|
||||
maxWidth="20%"
|
||||
width={125}
|
||||
{...extraFiltersForm.getInputProps('sortOrder')}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Limit"
|
||||
label={t('common.limit', { postProcess: 'titleCase' })}
|
||||
maxWidth="20%"
|
||||
width={75}
|
||||
{...extraFiltersForm.getInputProps('limit')}
|
||||
@@ -444,7 +450,7 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
variant="filled"
|
||||
onClick={handleSaveAs}
|
||||
>
|
||||
Save as
|
||||
{t('common.saveAs', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-end">
|
||||
<DropdownMenu.Target>
|
||||
@@ -462,7 +468,7 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
icon={<RiSaveLine color="var(--danger-color)" />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save and replace
|
||||
{t('common.saveAndReplace', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CreatePlaylistBody, CreatePlaylistResponse, ServerType } from '/@/rende
|
||||
import { Button, Switch, TextInput, toast } from '/@/renderer/components';
|
||||
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SaveAsPlaylistFormProps {
|
||||
body: Partial<CreatePlaylistBody>;
|
||||
@@ -18,6 +19,7 @@ export const SaveAsPlaylistForm = ({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: SaveAsPlaylistFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const mutation = useCreatePlaylist({});
|
||||
const server = useCurrentServer();
|
||||
|
||||
@@ -40,10 +42,15 @@ export const SaveAsPlaylistForm = ({
|
||||
{ body: values, serverId },
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({ message: err.message, title: 'Error creating playlist' });
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success({ message: `Playlist has been created` });
|
||||
toast.success({
|
||||
message: t('form.createPlaylist.success', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
onSuccess(data);
|
||||
onCancel();
|
||||
},
|
||||
@@ -60,16 +67,25 @@ export const SaveAsPlaylistForm = ({
|
||||
<TextInput
|
||||
data-autofocus
|
||||
required
|
||||
label="Name"
|
||||
label={t('form.createPlaylist.input', {
|
||||
context: 'name',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
label={t('form.createPlaylist.input', {
|
||||
context: 'description',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('comment')}
|
||||
/>
|
||||
{isPublicDisplayed && (
|
||||
<Switch
|
||||
label="Is Public?"
|
||||
label={t('form.createPlaylist.input', {
|
||||
context: 'public',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('_custom.navidrome.public', { type: 'checkbox' })}
|
||||
/>
|
||||
)}
|
||||
@@ -78,7 +94,7 @@ export const SaveAsPlaylistForm = ({
|
||||
variant="subtle"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
@@ -86,7 +102,7 @@ export const SaveAsPlaylistForm = ({
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Save
|
||||
{t('common.save', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user