mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b0c57998b | |||
| 6587e9cac8 | |||
| 2e3c69e61c | |||
| 4a8cd63046 | |||
| 549b53b4a4 | |||
| f33d13f574 | |||
| 4da51a16c9 | |||
| 8d138ff974 | |||
| 9373937436 | |||
| 46bbe6b95f | |||
| 56c229a5e0 | |||
| 9d44f0fc08 | |||
| 903d1479a4 | |||
| 7299bcefb2 | |||
| 6b7c69e90a | |||
| 4601838afe | |||
| f7dd634f67 | |||
| eb50c69a35 | |||
| b93ad40571 | |||
| 748db032c7 | |||
| 80931d1b19 | |||
| 93377dcc4f | |||
| 528bef01f0 | |||
| da95a644c8 | |||
| f5a04980a4 | |||
| 93055b3bf1 | |||
| e68847f50a | |||
| 43fe1a235e | |||
| 62c372d0c7 | |||
| 279842b894 | |||
| 6125901023 | |||
| 004c9a8d06 | |||
| f746114041 | |||
| 9f4861a78a | |||
| 32b984a18b | |||
| 8a8542ddb1 | |||
| b41a1a8b15 | |||
| 9923c021fa | |||
| 8c929d0dc3 | |||
| fb1e33fad5 | |||
| c4677a63f6 | |||
| 10fca2dc12 | |||
| 0b383b758e | |||
| ccb6f2c8b0 | |||
| a44071fedd | |||
| b527d579fd | |||
| 5b2977e5e8 | |||
| b347b794b9 | |||
| ad81790c90 | |||
| 906ffee8bc | |||
| 284db988c9 | |||
| 271be93a96 | |||
| 121b036aaf | |||
| 028ccfb1cd | |||
| 37b0407188 | |||
| 616fd45734 | |||
| a537642990 | |||
| 9c6abcb32c | |||
| af69a58418 | |||
| ebebdc1e03 | |||
| 7d185f6563 | |||
| 88741a8616 | |||
| 94edda1856 | |||
| 886786d428 | |||
| e4ca0164fa | |||
| 08db18359a | |||
| f2beeef0e9 | |||
| 6daba77bae | |||
| fd893224b3 | |||
| 8815246221 | |||
| 5e353649a4 | |||
| 4ca7b4221c | |||
| 6c61ea898f | |||
| 0b786b025f | |||
| e106fb324f | |||
| 3edc6bab04 | |||
| 0113ef2582 | |||
| 493b81875a | |||
| ed8e5e69ba | |||
| d27a656568 | |||
| 582739a091 | |||
| fb930f1197 | |||
| 849aa97e63 | |||
| bc5abe3ec1 | |||
| 46cd783fa3 | |||
| a93173f55a | |||
| bd04168209 | |||
| 76ffdb6627 | |||
| f674260df3 | |||
| e9315886b7 | |||
| 4741dd0d77 | |||
| 7a1c4f5082 | |||
| abf339bb58 | |||
| bb8f67c4c1 | |||
| e416c2a3b6 | |||
| 5af4344168 | |||
| 55e958b5da | |||
| 2a231ed7af | |||
| a26f7feb31 | |||
| 90e267d9c7 | |||
| 1ab09c5488 | |||
| eadbf3ad7b | |||
| 42bcc4190c | |||
| 3c44db377b | |||
| ba64f4c467 | |||
| 596bf3a378 | |||
| 91e6119afa | |||
| b053538f94 | |||
| 0c14427bdb | |||
| 110a1a63f0 | |||
| d57b4b4b68 | |||
| 4191edb88c | |||
| b38930a277 | |||
| ea865f44b1 | |||
| 0768ce80a7 | |||
| c960cc44b7 | |||
| a84276579b | |||
| b30fadd149 | |||
| aa89c5e80e | |||
| 38ed083693 | |||
| 961d1838c0 | |||
| 67deb3e3d8 | |||
| 79384fa4ed | |||
| bb2f8461ed | |||
| 168153b211 | |||
| c5e8472746 | |||
| a9e0689619 |
@@ -17,7 +17,7 @@ jobs:
|
|||||||
- name: Install Node and NPM
|
- name: Install Node and NPM
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 18
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
- name: Install Node and NPM
|
- name: Install Node and NPM
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 18
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
- name: Install Node and NPM
|
- name: Install Node and NPM
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 18
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
- name: Install Node and NPM
|
- name: Install Node and NPM
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 18
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -70,6 +70,29 @@ docker build -t feishin .
|
|||||||
docker run --name feishin -p 9180:9180 feishin
|
docker run --name feishin -p 9180:9180 feishin
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Docker Compose
|
||||||
|
To install via Docker Compose use the following snippit. This also works on Portainer.
|
||||||
|
```
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
feishin:
|
||||||
|
container_name: feishin
|
||||||
|
image: 'ghcr.io/jeffvli/feishin:latest'
|
||||||
|
environment:
|
||||||
|
- SERVER_NAME=jellyfin # pre defined server name
|
||||||
|
- SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled
|
||||||
|
- SERVER_TYPE=jellyfin # navidrome also works
|
||||||
|
- SERVER_URL= # http://address:port
|
||||||
|
- PUID=1000
|
||||||
|
- PGID=1000
|
||||||
|
- UMASK=002
|
||||||
|
- TZ=America/Los_Angeles
|
||||||
|
ports:
|
||||||
|
- 9180:9180
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
1. Upon startup you will be greeted with a prompt to select the path to your MPV binary. If you do not have MPV installed, you can download it [here](https://mpv.io/installation/) or install it using any package manager supported by your OS. After inputting the path, restart the app.
|
1. Upon startup you will be greeted with a prompt to select the path to your MPV binary. If you do not have MPV installed, you can download it [here](https://mpv.io/installation/) or install it using any package manager supported by your OS. After inputting the path, restart the app.
|
||||||
|
|||||||
Generated
+6286
-3852
File diff suppressed because it is too large
Load Diff
+21
-19
@@ -2,7 +2,7 @@
|
|||||||
"name": "feishin",
|
"name": "feishin",
|
||||||
"productName": "Feishin",
|
"productName": "Feishin",
|
||||||
"description": "Feishin music server",
|
"description": "Feishin music server",
|
||||||
"version": "0.7.1",
|
"version": "0.8.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\" \"npm run build:remote\"",
|
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\" \"npm run build:remote\"",
|
||||||
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
||||||
@@ -14,12 +14,12 @@
|
|||||||
"lint": "concurrently \"npm run lint:code\" \"npm run lint:styles\"",
|
"lint": "concurrently \"npm run lint:code\" \"npm run lint:styles\"",
|
||||||
"lint:code": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
|
"lint:code": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
|
||||||
"lint:styles": "npx stylelint **/*.tsx --fix",
|
"lint:styles": "npx stylelint **/*.tsx --fix",
|
||||||
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
|
"package": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
|
||||||
"package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
|
"package:pr": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
|
||||||
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
|
"package:dev": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
|
||||||
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
|
"postinstall": "node --import tsx .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
|
||||||
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
|
"start": "node --import tsx ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
|
||||||
"start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
|
"start:main": "cross-env NODE_ENV=development NODE_OPTIONS=\"--import tsx\" electron -r ts-node/register/transpile-only ./src/main/main.ts",
|
||||||
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
|
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
|
||||||
"start:remote": "cross-env NODE_ENV=developemnt TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.remote.dev.ts",
|
"start:remote": "cross-env NODE_ENV=developemnt TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.remote.dev.ts",
|
||||||
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
|
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"afterSign": ".erb/scripts/notarize.js",
|
"afterSign": ".erb/scripts/notarize.js",
|
||||||
"electronVersion": "27.1.0",
|
"electronVersion": "31.2.0",
|
||||||
"mac": {
|
"mac": {
|
||||||
"target": {
|
"target": {
|
||||||
"target": "default",
|
"target": "default",
|
||||||
@@ -199,12 +199,13 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/rebuild": "^3.2.10",
|
"@electron/rebuild": "^3.6.0",
|
||||||
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
|
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
|
||||||
"@stylelint/postcss-css-in-js": "^0.38.0",
|
"@stylelint/postcss-css-in-js": "^0.38.0",
|
||||||
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
|
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
|
||||||
"@testing-library/jest-dom": "^5.16.4",
|
"@testing-library/jest-dom": "^5.16.4",
|
||||||
"@testing-library/react": "^13.0.0",
|
"@testing-library/react": "^13.0.0",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/electron-localshortcut": "^3.1.0",
|
"@types/electron-localshortcut": "^3.1.0",
|
||||||
"@types/jest": "^27.4.1",
|
"@types/jest": "^27.4.1",
|
||||||
"@types/lodash": "^4.14.188",
|
"@types/lodash": "^4.14.188",
|
||||||
@@ -216,7 +217,6 @@
|
|||||||
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
||||||
"@types/react-window": "^1.8.5",
|
"@types/react-window": "^1.8.5",
|
||||||
"@types/react-window-infinite-loader": "^1.0.6",
|
"@types/react-window-infinite-loader": "^1.0.6",
|
||||||
"@types/sanitize-html": "^2.11.0",
|
|
||||||
"@types/styled-components": "^5.1.26",
|
"@types/styled-components": "^5.1.26",
|
||||||
"@types/terser-webpack-plugin": "^5.0.4",
|
"@types/terser-webpack-plugin": "^5.0.4",
|
||||||
"@types/webpack-bundle-analyzer": "^4.4.1",
|
"@types/webpack-bundle-analyzer": "^4.4.1",
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
"css-loader": "^6.7.1",
|
"css-loader": "^6.7.1",
|
||||||
"css-minimizer-webpack-plugin": "^3.4.1",
|
"css-minimizer-webpack-plugin": "^3.4.1",
|
||||||
"detect-port": "^1.3.0",
|
"detect-port": "^1.3.0",
|
||||||
"electron": "^26.6.10",
|
"electron": "^31.2.0",
|
||||||
"electron-builder": "^24.13.3",
|
"electron-builder": "^24.13.3",
|
||||||
"electron-devtools-installer": "^3.2.0",
|
"electron-devtools-installer": "^3.2.0",
|
||||||
"electron-notarize": "^1.2.1",
|
"electron-notarize": "^1.2.1",
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
"html-webpack-plugin": "^5.5.0",
|
"html-webpack-plugin": "^5.5.0",
|
||||||
"husky": "^7.0.4",
|
"husky": "^7.0.4",
|
||||||
"i18next-parser": "^6.6.0",
|
"i18next-parser": "^9.0.2",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^27.5.1",
|
"jest": "^27.5.1",
|
||||||
"lint-staged": "^12.3.7",
|
"lint-staged": "^12.3.7",
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
"postcss-scss": "^4.0.4",
|
"postcss-scss": "^4.0.4",
|
||||||
"postcss-styled-syntax": "^0.5.0",
|
"postcss-styled-syntax": "^0.5.0",
|
||||||
"postcss-syntax": "^0.36.2",
|
"postcss-syntax": "^0.36.2",
|
||||||
"prettier": "^2.6.2",
|
"prettier": "^3.3.3",
|
||||||
"react-refresh": "^0.12.0",
|
"react-refresh": "^0.12.0",
|
||||||
"react-refresh-typescript": "^2.0.4",
|
"react-refresh-typescript": "^2.0.4",
|
||||||
"react-test-renderer": "^18.0.0",
|
"react-test-renderer": "^18.0.0",
|
||||||
@@ -278,12 +278,13 @@
|
|||||||
"terser-webpack-plugin": "^5.3.1",
|
"terser-webpack-plugin": "^5.3.1",
|
||||||
"ts-jest": "^27.1.4",
|
"ts-jest": "^27.1.4",
|
||||||
"ts-loader": "^9.2.8",
|
"ts-loader": "^9.2.8",
|
||||||
"ts-node": "^10.7.0",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths-webpack-plugin": "^4.0.0",
|
"tsconfig-paths-webpack-plugin": "^4.0.0",
|
||||||
|
"tsx": "^4.16.2",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"typescript-plugin-styled-components": "^3.0.0",
|
"typescript-plugin-styled-components": "^3.0.0",
|
||||||
"url-loader": "^4.1.1",
|
"url-loader": "^4.1.1",
|
||||||
"webpack": "^5.71.0",
|
"webpack": "^5.94.0",
|
||||||
"webpack-bundle-analyzer": "^4.5.0",
|
"webpack-bundle-analyzer": "^4.5.0",
|
||||||
"webpack-cli": "^4.9.2",
|
"webpack-cli": "^4.9.2",
|
||||||
"webpack-dev-server": "^4.8.0",
|
"webpack-dev-server": "^4.8.0",
|
||||||
@@ -308,15 +309,17 @@
|
|||||||
"@tanstack/react-query-persist-client": "^4.32.1",
|
"@tanstack/react-query-persist-client": "^4.32.1",
|
||||||
"@ts-rest/core": "^3.23.0",
|
"@ts-rest/core": "^3.23.0",
|
||||||
"@xhayper/discord-rpc": "^1.0.24",
|
"@xhayper/discord-rpc": "^1.0.24",
|
||||||
|
"auto-text-size": "^0.2.3",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"cmdk": "^0.2.0",
|
"cmdk": "^0.2.0",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
|
"dompurify": "^3.1.6",
|
||||||
"electron-debug": "^3.2.0",
|
"electron-debug": "^3.2.0",
|
||||||
"electron-localshortcut": "^3.2.1",
|
"electron-localshortcut": "^3.2.1",
|
||||||
"electron-log": "^5.1.1",
|
"electron-log": "^5.1.1",
|
||||||
"electron-store": "^8.1.0",
|
"electron-store": "^8.1.0",
|
||||||
"electron-updater": "^4.6.5",
|
"electron-updater": "^6.3.1",
|
||||||
"fast-average-color": "^9.3.0",
|
"fast-average-color": "^9.3.0",
|
||||||
"format-duration": "^2.0.0",
|
"format-duration": "^2.0.0",
|
||||||
"framer-motion": "^11.0.0",
|
"framer-motion": "^11.0.0",
|
||||||
@@ -331,7 +334,7 @@
|
|||||||
"memoize-one": "^6.0.0",
|
"memoize-one": "^6.0.0",
|
||||||
"nanoid": "^3.3.3",
|
"nanoid": "^3.3.3",
|
||||||
"net": "^1.0.2",
|
"net": "^1.0.2",
|
||||||
"node-mpv": "github:jeffvli/Node-MPV",
|
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
|
||||||
"overlayscrollbars": "^2.2.1",
|
"overlayscrollbars": "^2.2.1",
|
||||||
"overlayscrollbars-react": "^0.5.1",
|
"overlayscrollbars-react": "^0.5.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -346,7 +349,6 @@
|
|||||||
"react-virtualized-auto-sizer": "^1.0.17",
|
"react-virtualized-auto-sizer": "^1.0.17",
|
||||||
"react-window": "^1.8.9",
|
"react-window": "^1.8.9",
|
||||||
"react-window-infinite-loader": "^1.0.9",
|
"react-window-infinite-loader": "^1.0.9",
|
||||||
"sanitize-html": "^2.13.0",
|
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
"styled-components": "^6.0.8",
|
"styled-components": "^6.0.8",
|
||||||
"swiper": "^9.3.1",
|
"swiper": "^9.3.1",
|
||||||
@@ -357,7 +359,7 @@
|
|||||||
"styled-components": "^6"
|
"styled-components": "^6"
|
||||||
},
|
},
|
||||||
"devEngines": {
|
"devEngines": {
|
||||||
"node": ">=14.x",
|
"node": ">=18.x",
|
||||||
"npm": ">=7.x"
|
"npm": ">=7.x"
|
||||||
},
|
},
|
||||||
"browserslist": [],
|
"browserslist": [],
|
||||||
|
|||||||
Generated
+44
-27
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "feishin",
|
"name": "feishin",
|
||||||
"version": "0.7.1",
|
"version": "0.8.1",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "feishin",
|
"name": "feishin",
|
||||||
"version": "0.7.1",
|
"version": "0.8.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"mpris-service": "^2.1.2",
|
"mpris-service": "^2.1.2",
|
||||||
"ws": "^8.13.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "25.8.4"
|
"electron": "31.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@electron/get": {
|
"node_modules/@electron/get": {
|
||||||
@@ -99,10 +99,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "18.16.19",
|
"version": "20.14.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.19.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz",
|
||||||
"integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==",
|
"integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~5.26.4"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/responselike": {
|
"node_modules/@types/responselike": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -453,14 +456,14 @@
|
|||||||
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
||||||
},
|
},
|
||||||
"node_modules/electron": {
|
"node_modules/electron": {
|
||||||
"version": "25.8.4",
|
"version": "31.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/electron/-/electron-25.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/electron/-/electron-31.1.0.tgz",
|
||||||
"integrity": "sha512-hUYS3RGdaa6E1UWnzeGnsdsBYOggwMMg4WGxNGvAoWtmRrr6J1BsjFW/yRq4WsJHJce2HdzQXtz4OGXV6yUCLg==",
|
"integrity": "sha512-TBOwqLxSxnx6+pH6GMri7R3JPH2AkuGJHfWZS0p1HsmN+Qr1T9b0IRJnnehSd/3NZAmAre4ft9Ljec7zjyKFJA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron/get": "^2.0.0",
|
"@electron/get": "^2.0.0",
|
||||||
"@types/node": "^18.11.18",
|
"@types/node": "^20.9.0",
|
||||||
"extract-zip": "^2.0.1"
|
"extract-zip": "^2.0.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -1270,6 +1273,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "5.26.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/universalify": {
|
"node_modules/universalify": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||||
@@ -1286,9 +1295,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.13.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||||
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
|
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1408,10 +1417,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@types/node": {
|
"@types/node": {
|
||||||
"version": "18.16.19",
|
"version": "20.14.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.19.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz",
|
||||||
"integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==",
|
"integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"undici-types": "~5.26.4"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"@types/responselike": {
|
"@types/responselike": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -1672,13 +1684,13 @@
|
|||||||
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
||||||
},
|
},
|
||||||
"electron": {
|
"electron": {
|
||||||
"version": "25.8.4",
|
"version": "31.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/electron/-/electron-25.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/electron/-/electron-31.1.0.tgz",
|
||||||
"integrity": "sha512-hUYS3RGdaa6E1UWnzeGnsdsBYOggwMMg4WGxNGvAoWtmRrr6J1BsjFW/yRq4WsJHJce2HdzQXtz4OGXV6yUCLg==",
|
"integrity": "sha512-TBOwqLxSxnx6+pH6GMri7R3JPH2AkuGJHfWZS0p1HsmN+Qr1T9b0IRJnnehSd/3NZAmAre4ft9Ljec7zjyKFJA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"@electron/get": "^2.0.0",
|
"@electron/get": "^2.0.0",
|
||||||
"@types/node": "^18.11.18",
|
"@types/node": "^20.9.0",
|
||||||
"extract-zip": "^2.0.1"
|
"extract-zip": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2278,6 +2290,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"undici-types": {
|
||||||
|
"version": "5.26.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"universalify": {
|
"universalify": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||||
@@ -2291,10 +2309,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"ws": {
|
"ws": {
|
||||||
"version": "8.13.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||||
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
|
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="
|
||||||
"requires": {}
|
|
||||||
},
|
},
|
||||||
"xml2js": {
|
"xml2js": {
|
||||||
"version": "0.4.23",
|
"version": "0.4.23",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "feishin",
|
"name": "feishin",
|
||||||
"version": "0.7.1",
|
"version": "0.8.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "./dist/main/main.js",
|
"main": "./dist/main/main.js",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -15,10 +15,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"mpris-service": "^2.1.2",
|
"mpris-service": "^2.1.2",
|
||||||
"ws": "^8.13.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "25.8.4"
|
"electron": "31.1.0"
|
||||||
},
|
},
|
||||||
"license": "GPL-3.0"
|
"license": "GPL-3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import nbNO from './locales/nb-NO.json';
|
|||||||
import nl from './locales/nl.json';
|
import nl from './locales/nl.json';
|
||||||
import zhHant from './locales/zh-Hant.json';
|
import zhHant from './locales/zh-Hant.json';
|
||||||
import fa from './locales/fa.json';
|
import fa from './locales/fa.json';
|
||||||
|
import ko from './locales/ko.json';
|
||||||
|
|
||||||
const resources = {
|
const resources = {
|
||||||
en: { translation: en },
|
en: { translation: en },
|
||||||
@@ -29,6 +30,7 @@ const resources = {
|
|||||||
fa: { translation: fa },
|
fa: { translation: fa },
|
||||||
fr: { translation: fr },
|
fr: { translation: fr },
|
||||||
ja: { translation: ja },
|
ja: { translation: ja },
|
||||||
|
ko: { translation: ko },
|
||||||
pl: { translation: pl },
|
pl: { translation: pl },
|
||||||
'zh-Hans': { translation: zhHans },
|
'zh-Hans': { translation: zhHans },
|
||||||
'zh-Hant': { translation: zhHant },
|
'zh-Hant': { translation: zhHant },
|
||||||
@@ -68,6 +70,10 @@ export const languages = [
|
|||||||
label: '日本語',
|
label: '日本語',
|
||||||
value: 'ja',
|
value: 'ja',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: '한국어',
|
||||||
|
value: 'ko',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Nederlands',
|
label: 'Nederlands',
|
||||||
value: 'nl',
|
value: 'nl',
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ module.exports = {
|
|||||||
createOldCatalogs: true,
|
createOldCatalogs: true,
|
||||||
customValueTemplate: null,
|
customValueTemplate: null,
|
||||||
defaultNamespace: 'translation',
|
defaultNamespace: 'translation',
|
||||||
defaultValue: '',
|
defaultValue: function (locale, namespace, key, value) {
|
||||||
|
return key;
|
||||||
|
},
|
||||||
failOnUpdate: false,
|
failOnUpdate: false,
|
||||||
failOnWarnings: false,
|
failOnWarnings: false,
|
||||||
i18nextOptions: null,
|
i18nextOptions: null,
|
||||||
@@ -37,8 +39,6 @@ module.exports = {
|
|||||||
output: 'src/renderer/i18n/locales/$LOCALE.json',
|
output: 'src/renderer/i18n/locales/$LOCALE.json',
|
||||||
pluralSeparator: '_',
|
pluralSeparator: '_',
|
||||||
resetDefaultValueLocale: 'en',
|
resetDefaultValueLocale: 'en',
|
||||||
skipDefaultValues: false,
|
|
||||||
sort: true,
|
sort: true,
|
||||||
useKeysAsDefaultValue: true,
|
|
||||||
verbose: false,
|
verbose: false,
|
||||||
};
|
};
|
||||||
|
|||||||
+59
-11
@@ -28,7 +28,8 @@
|
|||||||
"shuffle_off": "náhodně zakázáno",
|
"shuffle_off": "náhodně zakázáno",
|
||||||
"addLast": "přidat poslední",
|
"addLast": "přidat poslední",
|
||||||
"mute": "ztlumit",
|
"mute": "ztlumit",
|
||||||
"skip_forward": "přeskočit dopředu"
|
"skip_forward": "přeskočit dopředu",
|
||||||
|
"playSimilarSongs": "přehrát podobné skladby"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"crossfadeStyle_description": "vyberte způsob prolnutí u přehrávače zvuku",
|
"crossfadeStyle_description": "vyberte způsob prolnutí u přehrávače zvuku",
|
||||||
@@ -210,7 +211,41 @@
|
|||||||
"clearCacheSuccess": "mezipaměť úspěšně vymazána",
|
"clearCacheSuccess": "mezipaměť úspěšně vymazána",
|
||||||
"externalLinks": "zobrazit externí odkazy",
|
"externalLinks": "zobrazit externí odkazy",
|
||||||
"startMinimized_description": "spustit aplikaci do systémové lišty",
|
"startMinimized_description": "spustit aplikaci do systémové lišty",
|
||||||
"passwordStore_description": "který způsob ukládání hesel / tajných klíčů použít. změňte tuto možnost, pokud máte problémy s ukládáním hesel."
|
"passwordStore_description": "který způsob ukládání hesel / tajných klíčů použít. změňte tuto možnost, pokud máte problémy s ukládáním hesel.",
|
||||||
|
"homeFeature": "carousel doporučení na domovské stránce",
|
||||||
|
"homeFeature_description": "ovládá, zda se má zobrazovat velký carousel s doporučenými alby na domovské stránce",
|
||||||
|
"imageAspectRatio": "použít nativní poměr stran obalů alb",
|
||||||
|
"imageAspectRatio_description": "pokud je povoleno, budou obaly alb zobrazeny s jejich nativním poměrem stran. u obalů, které nemají poměr 1:1, bude zbývající místo prázdné",
|
||||||
|
"doubleClickBehavior": "dvojitým kliknutím zařadit všechny vyhledané skladby do fronty",
|
||||||
|
"doubleClickBehavior_description": "pokud je zapnuto, budou všechny odpovídající skladby ve vyhledávání zařazeny do fronty. v opačném případě bude zařazena pouze ta, na kterou kliknete",
|
||||||
|
"volumeWidth": "šířka posuvníku hlasitosti",
|
||||||
|
"volumeWidth_description": "horizontální velikost posuvníku hlasitosti",
|
||||||
|
"discordListening": "zobrazit stav jako „Poslouchá“",
|
||||||
|
"discordListening_description": "zobrazit stav jako „Poslouchá“ namísto „Hraje“. tato funkce v současné době není kompatibilní s lištou s časem",
|
||||||
|
"contextMenu": "nastavení kontextové nabídky (kliknutí pravým)",
|
||||||
|
"contextMenu_description": "umožňuje skrýt položky, které se zobrazí v nabídce po kliknutí pravým tlačítkem myši na položku. položky, které nejsou zaškrtnuté, se skryjí",
|
||||||
|
"customCssEnable": "povolit vlastní css",
|
||||||
|
"customCssEnable_description": "povolti psaní vlastního css.",
|
||||||
|
"customCssNotice": "Varování: i když provádíme určitou sanitizaci (zakázáním url() a content:), může používání CSS stále představovat riziko změnami rozhraní.",
|
||||||
|
"customCss_description": "vlastní css obsah. Upozornění: vlastnosti content a vzdálené url jsou zakázané. Níže je zobrazen náhled vašeho obsahu. Další pole, která jste nenastavili, jsou přítomna z důvodu sanitizace.",
|
||||||
|
"customCss": "vlastní css",
|
||||||
|
"webAudio": "použít webový zvuk",
|
||||||
|
"webAudio_description": "použít webový zvuk. tím povolíte pokročilé funkce jako replaygain. zakažte, pokud se objeví problémy",
|
||||||
|
"transcodeNote": "projeví se po 1 (web) - 2 (mpv) skladbách",
|
||||||
|
"transcode": "povolit překódování",
|
||||||
|
"transcode_description": "zapnout překódování do různých formátů",
|
||||||
|
"transcodeFormat_description": "vybere formát k překódování. pokud chcete nechat rozhodnout server, ponechte prázdné",
|
||||||
|
"transcodeFormat": "formát k překódování",
|
||||||
|
"transcodeBitrate": "datový tok k překódování",
|
||||||
|
"transcodeBitrate_description": "vybere datový tok k překódování. 0 znamená, že necháte server vybrat",
|
||||||
|
"albumBackground": "obrázek alba na pozadí",
|
||||||
|
"albumBackground_description": "přidá obrázek alba na pozadí pro stránky alba obsahující obrázky alba",
|
||||||
|
"albumBackgroundBlur": "velikost rozostření obrázku alba na pozadí",
|
||||||
|
"albumBackgroundBlur_description": "upraví množství rozostření použité na obrázek alba na pozadí",
|
||||||
|
"playerbarOpenDrawer": "lišta přehrávače jako přepínač celé obrazovky",
|
||||||
|
"playerbarOpenDrawer_description": "umožňuje kliknutí na lištu přehrávače pro otevření celoobrazovkového přehrávače",
|
||||||
|
"artistConfiguration": "nastavení stránky umělce alba",
|
||||||
|
"artistConfiguration_description": "nastavit, které položky na stránce umělce alba budou zobrazeny a v jakém pořadí"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "upravit $t(entity.playlist_one)",
|
"editPlaylist": "upravit $t(entity.playlist_one)",
|
||||||
@@ -371,7 +406,8 @@
|
|||||||
"favorite": "$t(common.favorite)",
|
"favorite": "$t(common.favorite)",
|
||||||
"year": "$t(common.year)",
|
"year": "$t(common.year)",
|
||||||
"albumArtist": "$t(entity.albumArtist_one)",
|
"albumArtist": "$t(entity.albumArtist_one)",
|
||||||
"codec": "$t(common.codec)"
|
"codec": "$t(common.codec)",
|
||||||
|
"songCount": "$t(entity.track_other)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"column": {
|
"column": {
|
||||||
@@ -534,7 +570,9 @@
|
|||||||
"numberSelected": "vybráno {{count}}",
|
"numberSelected": "vybráno {{count}}",
|
||||||
"removeFromQueue": "$t(action.removeFromQueue)",
|
"removeFromQueue": "$t(action.removeFromQueue)",
|
||||||
"showDetails": "získat informace",
|
"showDetails": "získat informace",
|
||||||
"shareItem": "sdílet položku"
|
"shareItem": "sdílet položku",
|
||||||
|
"playSimilarSongs": "$t(player.playSimilarSongs)",
|
||||||
|
"download": "stáhnout"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"mostPlayed": "nejpřehrávanější",
|
"mostPlayed": "nejpřehrávanější",
|
||||||
@@ -545,13 +583,15 @@
|
|||||||
},
|
},
|
||||||
"albumDetail": {
|
"albumDetail": {
|
||||||
"moreFromArtist": "více od tohoto umělce",
|
"moreFromArtist": "více od tohoto umělce",
|
||||||
"moreFromGeneric": "více od {{item}}"
|
"moreFromGeneric": "více od {{item}}",
|
||||||
|
"released": "vydáno"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"playbackTab": "přehrávání",
|
"playbackTab": "přehrávání",
|
||||||
"generalTab": "obecné",
|
"generalTab": "obecné",
|
||||||
"hotkeysTab": "klávesové zkratky",
|
"hotkeysTab": "klávesové zkratky",
|
||||||
"windowTab": "okno"
|
"windowTab": "okno",
|
||||||
|
"advanced": "pokročilé"
|
||||||
},
|
},
|
||||||
"albumArtistList": {
|
"albumArtistList": {
|
||||||
"title": "$t(entity.albumArtist_other)"
|
"title": "$t(entity.albumArtist_other)"
|
||||||
@@ -563,7 +603,7 @@
|
|||||||
},
|
},
|
||||||
"trackList": {
|
"trackList": {
|
||||||
"title": "$t(entity.track_other)",
|
"title": "$t(entity.track_other)",
|
||||||
"artistTracks": "Skladby od umělce {{artist}}",
|
"artistTracks": "skladby od umělce {{artist}}",
|
||||||
"genreTracks": "$t(entity.track_other) s žánrem „{{genre}}“"
|
"genreTracks": "$t(entity.track_other) s žánrem „{{genre}}“"
|
||||||
},
|
},
|
||||||
"globalSearch": {
|
"globalSearch": {
|
||||||
@@ -579,7 +619,7 @@
|
|||||||
},
|
},
|
||||||
"albumList": {
|
"albumList": {
|
||||||
"title": "$t(entity.album_other)",
|
"title": "$t(entity.album_other)",
|
||||||
"artistAlbums": "Alba od umělce {{artist}}",
|
"artistAlbums": "alba od umělce {{artist}}",
|
||||||
"genreAlbums": "$t(entity.album_other) s žánrem „{{genre}}“"
|
"genreAlbums": "$t(entity.album_other) s žánrem „{{genre}}“"
|
||||||
},
|
},
|
||||||
"albumArtistDetail": {
|
"albumArtistDetail": {
|
||||||
@@ -588,7 +628,7 @@
|
|||||||
"about": "O umělci {{artist}}",
|
"about": "O umělci {{artist}}",
|
||||||
"appearsOn": "také v",
|
"appearsOn": "také v",
|
||||||
"topSongs": "nejlepší skladby",
|
"topSongs": "nejlepší skladby",
|
||||||
"topSongsFrom": "Nejlepší skladby od umělce {{title}}",
|
"topSongsFrom": "nejlepší skladby od umělce {{title}}",
|
||||||
"relatedArtists": "podobní $t(entity.artist_other)",
|
"relatedArtists": "podobní $t(entity.artist_other)",
|
||||||
"viewAllTracks": "zobrazit všechny $t(entity.track_other)",
|
"viewAllTracks": "zobrazit všechny $t(entity.track_other)",
|
||||||
"viewAll": "zobrazit vše"
|
"viewAll": "zobrazit vše"
|
||||||
@@ -597,6 +637,9 @@
|
|||||||
"copiedPath": "cesta úspěšně zkopírována",
|
"copiedPath": "cesta úspěšně zkopírována",
|
||||||
"copyPath": "kopírovat cestu do schránky",
|
"copyPath": "kopírovat cestu do schránky",
|
||||||
"openFile": "zobrazit skladbu ve správci souborů"
|
"openFile": "zobrazit skladbu ve správci souborů"
|
||||||
|
},
|
||||||
|
"playlist": {
|
||||||
|
"reorder": "změna pořadí povolena pouze při řazení podle id"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
@@ -646,7 +689,9 @@
|
|||||||
"title": "Hledat texty"
|
"title": "Hledat texty"
|
||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "upravit $t(entity.playlist_one)"
|
"title": "upravit $t(entity.playlist_one)",
|
||||||
|
"success": "$t(entity.playlist_one) úspěšně aktualizován",
|
||||||
|
"publicJellyfinNote": "Jellyfin z nějakého důvodu neukazuje, zda je seznam skladeb veřejný, nebo ne. Pokud si přejete, aby zůstal veřejný, zvolte prosím následující vstup"
|
||||||
},
|
},
|
||||||
"shareItem": {
|
"shareItem": {
|
||||||
"allowDownloading": "umožnit stahování",
|
"allowDownloading": "umožnit stahování",
|
||||||
@@ -703,6 +748,9 @@
|
|||||||
"genreWithCount_other": "{{count}} žánrů",
|
"genreWithCount_other": "{{count}} žánrů",
|
||||||
"trackWithCount_one": "{{count}} skladba",
|
"trackWithCount_one": "{{count}} skladba",
|
||||||
"trackWithCount_few": "{{count}} skladby",
|
"trackWithCount_few": "{{count}} skladby",
|
||||||
"trackWithCount_other": "{{count}} skladeb"
|
"trackWithCount_other": "{{count}} skladeb",
|
||||||
|
"play_one": "{{count}} přehrání",
|
||||||
|
"play_few": "{{count}} přehrání",
|
||||||
|
"play_other": "{{count}} přehrání"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,11 @@
|
|||||||
"removeFromQueue": "Von Warteschlange entfernen",
|
"removeFromQueue": "Von Warteschlange entfernen",
|
||||||
"setRating": "Bewertung festlegen",
|
"setRating": "Bewertung festlegen",
|
||||||
"toggleSmartPlaylistEditor": "Editor $t(entity.smartPlaylist) umschalten",
|
"toggleSmartPlaylistEditor": "Editor $t(entity.smartPlaylist) umschalten",
|
||||||
"removeFromFavorites": "Entfernen von $t(entity.favorite_other)"
|
"removeFromFavorites": "Entfernen von $t(entity.favorite_other)",
|
||||||
|
"openIn": {
|
||||||
|
"lastfm": "In Last.fm öffnen",
|
||||||
|
"musicbrainz": "In MusicBrainz öffnen"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"backward": "rückwärts",
|
"backward": "rückwärts",
|
||||||
@@ -98,7 +102,11 @@
|
|||||||
"random": "zufällig",
|
"random": "zufällig",
|
||||||
"size": "Größe",
|
"size": "Größe",
|
||||||
"biography": "Biografie",
|
"biography": "Biografie",
|
||||||
"note": "Hinweis"
|
"note": "Hinweis",
|
||||||
|
"preview": "Vorschau",
|
||||||
|
"reload": "Neu Laden",
|
||||||
|
"mbid": "MusicBrainz ID",
|
||||||
|
"close": "schliessen"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden",
|
"remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden",
|
||||||
@@ -119,7 +127,10 @@
|
|||||||
"mpvRequired": "MPV benötigt",
|
"mpvRequired": "MPV benötigt",
|
||||||
"audioDeviceFetchError": "Beim Versuch, Audiogeräte abzurufen, ist ein Fehler aufgetreten",
|
"audioDeviceFetchError": "Beim Versuch, Audiogeräte abzurufen, ist ein Fehler aufgetreten",
|
||||||
"invalidServer": "Ungültiger Server",
|
"invalidServer": "Ungültiger Server",
|
||||||
"loginRateError": "Zu viele Anmeldeversuche, bitte versuche es in einigen Sekunden erneut"
|
"loginRateError": "Zu viele Anmeldeversuche, bitte versuche es in einigen Sekunden erneut",
|
||||||
|
"badAlbum": "Sie sehen diese Seite, weil dieses Lied nicht Teil eines Albums ist. Wahrscheinlich sehen Sie dieses Problem, wenn Sie einen Song in Ihrem Musikordner auf oberster Ebene haben. Jellyfin gruppiert nur Songs, wenn sie sich in einem Ordner befinden.",
|
||||||
|
"networkError": "ein Netzwerkfehler ist aufgetreten",
|
||||||
|
"openError": "datei kann nicht geöffnet werden"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"mostPlayed": "Meistgespielt",
|
"mostPlayed": "Meistgespielt",
|
||||||
@@ -213,6 +224,9 @@
|
|||||||
"title": "Songtext Suche",
|
"title": "Songtext Suche",
|
||||||
"input_name": "$t(common.name)",
|
"input_name": "$t(common.name)",
|
||||||
"input_artist": "$t(entity.artist_one)"
|
"input_artist": "$t(entity.artist_one)"
|
||||||
|
},
|
||||||
|
"shareItem": {
|
||||||
|
"description": "Beschreibung"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
|
|||||||
@@ -139,6 +139,8 @@
|
|||||||
"genreWithCount_other": "{{count}} genres",
|
"genreWithCount_other": "{{count}} genres",
|
||||||
"playlist_one": "playlist",
|
"playlist_one": "playlist",
|
||||||
"playlist_other": "playlists",
|
"playlist_other": "playlists",
|
||||||
|
"play_one": "{{count}} play",
|
||||||
|
"play_other": "{{count}} plays",
|
||||||
"playlistWithCount_one": "{{count}} playlist",
|
"playlistWithCount_one": "{{count}} playlist",
|
||||||
"playlistWithCount_other": "{{count}} playlists",
|
"playlistWithCount_other": "{{count}} playlists",
|
||||||
"smartPlaylist": "smart $t(entity.playlist_one)",
|
"smartPlaylist": "smart $t(entity.playlist_one)",
|
||||||
@@ -249,6 +251,8 @@
|
|||||||
"title": "delete $t(entity.playlist_one)"
|
"title": "delete $t(entity.playlist_one)"
|
||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
|
"publicJellyfinNote": "Jellyfin for some reason does not expose whether a playlist is public or not. If you wish for this to remain public, please have the following input selected",
|
||||||
|
"success": "$t(entity.playlist_one) updated successfully",
|
||||||
"title": "edit $t(entity.playlist_one)"
|
"title": "edit $t(entity.playlist_one)"
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
@@ -281,7 +285,7 @@
|
|||||||
"viewDiscography": "view discography",
|
"viewDiscography": "view discography",
|
||||||
"relatedArtists": "related $t(entity.artist_other)",
|
"relatedArtists": "related $t(entity.artist_other)",
|
||||||
"topSongs": "top songs",
|
"topSongs": "top songs",
|
||||||
"topSongsFrom": "Top songs from {{title}}",
|
"topSongsFrom": "top songs from {{title}}",
|
||||||
"viewAll": "view all",
|
"viewAll": "view all",
|
||||||
"viewAllTracks": "view all $t(entity.track_other)"
|
"viewAllTracks": "view all $t(entity.track_other)"
|
||||||
},
|
},
|
||||||
@@ -290,10 +294,11 @@
|
|||||||
},
|
},
|
||||||
"albumDetail": {
|
"albumDetail": {
|
||||||
"moreFromArtist": "more from this $t(entity.artist_one)",
|
"moreFromArtist": "more from this $t(entity.artist_one)",
|
||||||
"moreFromGeneric": "more from {{item}}"
|
"moreFromGeneric": "more from {{item}}",
|
||||||
|
"released": "released"
|
||||||
},
|
},
|
||||||
"albumList": {
|
"albumList": {
|
||||||
"artistAlbums": "Albums by {{artist}}",
|
"artistAlbums": "albums by {{artist}}",
|
||||||
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)",
|
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)",
|
||||||
"title": "$t(entity.album_other)"
|
"title": "$t(entity.album_other)"
|
||||||
},
|
},
|
||||||
@@ -318,10 +323,12 @@
|
|||||||
"createPlaylist": "$t(action.createPlaylist)",
|
"createPlaylist": "$t(action.createPlaylist)",
|
||||||
"deletePlaylist": "$t(action.deletePlaylist)",
|
"deletePlaylist": "$t(action.deletePlaylist)",
|
||||||
"deselectAll": "$t(action.deselectAll)",
|
"deselectAll": "$t(action.deselectAll)",
|
||||||
|
"download": "download",
|
||||||
"moveToBottom": "$t(action.moveToBottom)",
|
"moveToBottom": "$t(action.moveToBottom)",
|
||||||
"moveToTop": "$t(action.moveToTop)",
|
"moveToTop": "$t(action.moveToTop)",
|
||||||
"numberSelected": "{{count}} selected",
|
"numberSelected": "{{count}} selected",
|
||||||
"play": "$t(player.play)",
|
"play": "$t(player.play)",
|
||||||
|
"playSimilarSongs": "$t(player.playSimilarSongs)",
|
||||||
"removeFromFavorites": "$t(action.removeFromFavorites)",
|
"removeFromFavorites": "$t(action.removeFromFavorites)",
|
||||||
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
|
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
|
||||||
"removeFromQueue": "$t(action.removeFromQueue)",
|
"removeFromQueue": "$t(action.removeFromQueue)",
|
||||||
@@ -374,10 +381,14 @@
|
|||||||
"copiedPath": "path copied successfully",
|
"copiedPath": "path copied successfully",
|
||||||
"openFile": "show track in file manager"
|
"openFile": "show track in file manager"
|
||||||
},
|
},
|
||||||
|
"playlist": {
|
||||||
|
"reorder": "reordering only enabled when sorting by id"
|
||||||
|
},
|
||||||
"playlistList": {
|
"playlistList": {
|
||||||
"title": "$t(entity.playlist_other)"
|
"title": "$t(entity.playlist_other)"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
|
"advanced": "advanced",
|
||||||
"generalTab": "general",
|
"generalTab": "general",
|
||||||
"hotkeysTab": "hotkeys",
|
"hotkeysTab": "hotkeys",
|
||||||
"playbackTab": "playback",
|
"playbackTab": "playback",
|
||||||
@@ -398,7 +409,7 @@
|
|||||||
"tracks": "$t(entity.track_other)"
|
"tracks": "$t(entity.track_other)"
|
||||||
},
|
},
|
||||||
"trackList": {
|
"trackList": {
|
||||||
"artistTracks": "Tracks by {{artist}}",
|
"artistTracks": "tracks by {{artist}}",
|
||||||
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
|
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
|
||||||
"title": "$t(entity.track_other)"
|
"title": "$t(entity.track_other)"
|
||||||
}
|
}
|
||||||
@@ -416,6 +427,7 @@
|
|||||||
"playbackFetchNoResults": "no songs found",
|
"playbackFetchNoResults": "no songs found",
|
||||||
"playbackSpeed": "playback speed",
|
"playbackSpeed": "playback speed",
|
||||||
"playRandom": "play random",
|
"playRandom": "play random",
|
||||||
|
"playSimilarSongs": "play similar songs",
|
||||||
"previous": "previous",
|
"previous": "previous",
|
||||||
"queue_clear": "clear queue",
|
"queue_clear": "clear queue",
|
||||||
"queue_moveToBottom": "move selected to top",
|
"queue_moveToBottom": "move selected to top",
|
||||||
@@ -439,8 +451,14 @@
|
|||||||
"setting": {
|
"setting": {
|
||||||
"accentColor": "accent color",
|
"accentColor": "accent color",
|
||||||
"accentColor_description": "sets the accent color for the application",
|
"accentColor_description": "sets the accent color for the application",
|
||||||
|
"albumBackground": "album background image",
|
||||||
|
"albumBackground_description": "adds a background image for album pages containing the album art",
|
||||||
|
"albumBackgroundBlur": "album background image blur size",
|
||||||
|
"albumBackgroundBlur_description": "adjusts the amount of blur applied to the album background image",
|
||||||
"applicationHotkeys": "application hotkeys",
|
"applicationHotkeys": "application hotkeys",
|
||||||
"applicationHotkeys_description": "configure application hotkeys. toggle the checkbox to set as a global hotkey (desktop only)",
|
"applicationHotkeys_description": "configure application hotkeys. toggle the checkbox to set as a global hotkey (desktop only)",
|
||||||
|
"artistConfiguration": "album artist page configuration",
|
||||||
|
"artistConfiguration_description": "configure what items are shown, and in what order, on the album artist page",
|
||||||
"audioDevice": "audio device",
|
"audioDevice": "audio device",
|
||||||
"audioDevice_description": "select the audio device to use for playback (web player only)",
|
"audioDevice_description": "select the audio device to use for playback (web player only)",
|
||||||
"audioExclusiveMode": "audio exclusive mode",
|
"audioExclusiveMode": "audio exclusive mode",
|
||||||
@@ -454,10 +472,17 @@
|
|||||||
"clearQueryCache": "clear feishin cache",
|
"clearQueryCache": "clear feishin cache",
|
||||||
"clearQueryCache_description": "a 'soft clear' of feishin. this will refresh playlists, track metadata, and reset saved lyrics. settings, server credentials and cached images are preserved",
|
"clearQueryCache_description": "a 'soft clear' of feishin. this will refresh playlists, track metadata, and reset saved lyrics. settings, server credentials and cached images are preserved",
|
||||||
"clearCacheSuccess": "cache cleared successfully",
|
"clearCacheSuccess": "cache cleared successfully",
|
||||||
|
"contextMenu": "context menu (right click) configuration",
|
||||||
|
"contextMenu_description": "allows you to hide items that are shown in the menu when you right click on an item. items that are unchecked will be hidden",
|
||||||
"crossfadeDuration": "crossfade duration",
|
"crossfadeDuration": "crossfade duration",
|
||||||
"crossfadeDuration_description": "sets the duration of the crossfade effect",
|
"crossfadeDuration_description": "sets the duration of the crossfade effect",
|
||||||
"crossfadeStyle": "crossfade style",
|
"crossfadeStyle": "crossfade style",
|
||||||
"crossfadeStyle_description": "select the crossfade style to use for the audio player",
|
"crossfadeStyle_description": "select the crossfade style to use for the audio player",
|
||||||
|
"customCssEnable": "enable custom css",
|
||||||
|
"customCssEnable_description": "allow for writing custom css.",
|
||||||
|
"customCssNotice": "Warning: while there is some sanitization (disallowing url() and content:), using custom CSS can still pose risks by changing the interface.",
|
||||||
|
"customCss": "custom css",
|
||||||
|
"customCss_description": "custom css content. Note: content and remote urls are disallowed properties. A preview of your content is shown below. Additional fields you didn't set are present due to sanitization.",
|
||||||
"customFontPath": "custom font path",
|
"customFontPath": "custom font path",
|
||||||
"customFontPath_description": "sets the path to the custom font to use for the application",
|
"customFontPath_description": "sets the path to the custom font to use for the application",
|
||||||
"disableAutomaticUpdates": "disable automatic updates",
|
"disableAutomaticUpdates": "disable automatic updates",
|
||||||
@@ -466,10 +491,14 @@
|
|||||||
"discordApplicationId_description": "the application id for {{discord}} rich presence (defaults to {{defaultId}})",
|
"discordApplicationId_description": "the application id for {{discord}} rich presence (defaults to {{defaultId}})",
|
||||||
"discordIdleStatus": "show rich presence idle status",
|
"discordIdleStatus": "show rich presence idle status",
|
||||||
"discordIdleStatus_description": "when enabled, update status while player is idle",
|
"discordIdleStatus_description": "when enabled, update status while player is idle",
|
||||||
|
"discordListening": "show status as listening",
|
||||||
|
"discordListening_description": "show status as listening instead of playing. note that this currently breaks timer bar",
|
||||||
"discordRichPresence": "{{discord}} rich presence",
|
"discordRichPresence": "{{discord}} rich presence",
|
||||||
"discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}} ",
|
"discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}} ",
|
||||||
"discordUpdateInterval": "{{discord}} rich presence update interval",
|
"discordUpdateInterval": "{{discord}} rich presence update interval",
|
||||||
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
|
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
|
||||||
|
"doubleClickBehavior": "queue all searched tracks when double clicking",
|
||||||
|
"doubleClickBehavior_description": "if true, all matching tracks in a track search will be queued. otherwise, only the clicked one will be queued",
|
||||||
"enableRemote": "enable remote control server",
|
"enableRemote": "enable remote control server",
|
||||||
"enableRemote_description": "enables the remote control server to allow other devices to control the application",
|
"enableRemote_description": "enables the remote control server to allow other devices to control the application",
|
||||||
"externalLinks": "show external links",
|
"externalLinks": "show external links",
|
||||||
@@ -496,6 +525,8 @@
|
|||||||
"globalMediaHotkeys_description": "enable or disable the usage of your system media hotkeys to control playback",
|
"globalMediaHotkeys_description": "enable or disable the usage of your system media hotkeys to control playback",
|
||||||
"homeConfiguration": "home page configuration",
|
"homeConfiguration": "home page configuration",
|
||||||
"homeConfiguration_description": "configure what items are shown, and in what order, on the home page",
|
"homeConfiguration_description": "configure what items are shown, and in what order, on the home page",
|
||||||
|
"homeFeature": "home featured carousel",
|
||||||
|
"homeFeature_description": "controls whether to show the large featured carousel on the home page",
|
||||||
"hotkey_browserBack": "browser back",
|
"hotkey_browserBack": "browser back",
|
||||||
"hotkey_browserForward": "browser forward",
|
"hotkey_browserForward": "browser forward",
|
||||||
"hotkey_favoriteCurrentSong": "favorite $t(common.currentSong)",
|
"hotkey_favoriteCurrentSong": "favorite $t(common.currentSong)",
|
||||||
@@ -529,6 +560,8 @@
|
|||||||
"hotkey_volumeUp": "volume up",
|
"hotkey_volumeUp": "volume up",
|
||||||
"hotkey_zoomIn": "zoom in",
|
"hotkey_zoomIn": "zoom in",
|
||||||
"hotkey_zoomOut": "zoom out",
|
"hotkey_zoomOut": "zoom out",
|
||||||
|
"imageAspectRatio": "use native cover art aspect ratio",
|
||||||
|
"imageAspectRatio_description": "if enabled, cover art will be shown using their native aspect ratio. for art that is not 1:1, the remaining space will be empty",
|
||||||
"language": "language",
|
"language": "language",
|
||||||
"language_description": "sets the language for the application ($t(common.restartRequired))",
|
"language_description": "sets the language for the application ($t(common.restartRequired))",
|
||||||
"lyricFetch": "fetch lyrics from the internet",
|
"lyricFetch": "fetch lyrics from the internet",
|
||||||
@@ -560,6 +593,8 @@
|
|||||||
"playButtonBehavior_optionPlay": "$t(player.play)",
|
"playButtonBehavior_optionPlay": "$t(player.play)",
|
||||||
"playerAlbumArtResolution": "player album art resolution",
|
"playerAlbumArtResolution": "player album art resolution",
|
||||||
"playerAlbumArtResolution_description": "the resolution for the large player's album art preview. larger makes it look more crisp, but may slow loading down. defaults to 0, meaning auto",
|
"playerAlbumArtResolution_description": "the resolution for the large player's album art preview. larger makes it look more crisp, but may slow loading down. defaults to 0, meaning auto",
|
||||||
|
"playerbarOpenDrawer": "playerbar fullscreen toggle",
|
||||||
|
"playerbarOpenDrawer_description": "allows clicking of the playerbar to open the full screen player",
|
||||||
"remotePassword": "remote control server password",
|
"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",
|
"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": "remote control server port",
|
||||||
@@ -609,10 +644,21 @@
|
|||||||
"themeDark_description": "sets the dark theme to use for the application",
|
"themeDark_description": "sets the dark theme to use for the application",
|
||||||
"themeLight": "theme (light)",
|
"themeLight": "theme (light)",
|
||||||
"themeLight_description": "sets the light theme to use for the application",
|
"themeLight_description": "sets the light theme to use for the application",
|
||||||
|
"transcodeNote": "takes effect after 1 (web) - 2 (mpv) songs",
|
||||||
|
"transcode": "enable transcoding",
|
||||||
|
"transcode_description": "enables transcoding to different formats",
|
||||||
|
"transcodeBitrate": "bitrate to transcode",
|
||||||
|
"transcodeBitrate_description": "selects the bitrate to transcode. 0 means let the server pick",
|
||||||
|
"transcodeFormat": "format to transcode",
|
||||||
|
"transcodeFormat_description": "selects the format to transcode. leave empty to let the server decide",
|
||||||
"useSystemTheme": "use system theme",
|
"useSystemTheme": "use system theme",
|
||||||
"useSystemTheme_description": "follow the system-defined light or dark preference",
|
"useSystemTheme_description": "follow the system-defined light or dark preference",
|
||||||
"volumeWheelStep": "volume wheel step",
|
"volumeWheelStep": "volume wheel step",
|
||||||
"volumeWheelStep_description": "the amount of volume to change when scrolling the mouse wheel on the volume slider",
|
"volumeWheelStep_description": "the amount of volume to change when scrolling the mouse wheel on the volume slider",
|
||||||
|
"volumeWidth": "volume slider width",
|
||||||
|
"volumeWidth_description": "the width of the volume slider",
|
||||||
|
"webAudio": "use web audio",
|
||||||
|
"webAudio_description": "use web audio. this enables advanced features like replaygain. disable if you experience otherwise",
|
||||||
"windowBarStyle": "window bar style",
|
"windowBarStyle": "window bar style",
|
||||||
"windowBarStyle_description": "select the style of the window bar",
|
"windowBarStyle_description": "select the style of the window bar",
|
||||||
"zoom": "zoom percentage",
|
"zoom": "zoom percentage",
|
||||||
@@ -679,6 +725,7 @@
|
|||||||
"releaseDate": "release date",
|
"releaseDate": "release date",
|
||||||
"rowIndex": "row index",
|
"rowIndex": "row index",
|
||||||
"size": "$t(common.size)",
|
"size": "$t(common.size)",
|
||||||
|
"songCount": "$t(entity.track_other)",
|
||||||
"title": "$t(common.title)",
|
"title": "$t(common.title)",
|
||||||
"titleCombined": "$t(common.title) (combined)",
|
"titleCombined": "$t(common.title) (combined)",
|
||||||
"trackNumber": "track number",
|
"trackNumber": "track number",
|
||||||
|
|||||||
+71
-23
@@ -8,7 +8,7 @@
|
|||||||
"skip": "saltar",
|
"skip": "saltar",
|
||||||
"previous": "anterior",
|
"previous": "anterior",
|
||||||
"toggleFullscreenPlayer": "activar el reproductor a pantalla completa",
|
"toggleFullscreenPlayer": "activar el reproductor a pantalla completa",
|
||||||
"skip_back": "saltar hacia atrás",
|
"skip_back": "retroceder",
|
||||||
"favorite": "favorito",
|
"favorite": "favorito",
|
||||||
"next": "siguiente",
|
"next": "siguiente",
|
||||||
"shuffle": "mezclar",
|
"shuffle": "mezclar",
|
||||||
@@ -28,12 +28,13 @@
|
|||||||
"addLast": "añadir último",
|
"addLast": "añadir último",
|
||||||
"mute": "silencio",
|
"mute": "silencio",
|
||||||
"skip_forward": "saltar hacia delante",
|
"skip_forward": "saltar hacia delante",
|
||||||
"pause": "pausa"
|
"pause": "pausa",
|
||||||
|
"playSimilarSongs": "Reproducir canciones similares"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"crossfadeStyle_description": "selecciona el estilo de crossfade a usar por el reproductor de audio",
|
"crossfadeStyle_description": "selecciona el estilo de crossfade a usar por el reproductor de audio",
|
||||||
"remotePort_description": "establece el puerto para el control remoto del servidor",
|
"remotePort_description": "establece el puerto para el control remoto del servidor",
|
||||||
"hotkey_skipBackward": "saltar hacia atrás",
|
"hotkey_skipBackward": "retroceder",
|
||||||
"replayGainMode_description": "ajusta el volumen de ganancia acorde a los valores de {{ReplayGain}} almacenados en los metadatos del archivo",
|
"replayGainMode_description": "ajusta el volumen de ganancia acorde a los valores de {{ReplayGain}} almacenados en los metadatos del archivo",
|
||||||
"audioDevice_description": "selecciona el dispositivo de audio para usar en la reproducción (solo reproductor web)",
|
"audioDevice_description": "selecciona el dispositivo de audio para usar en la reproducción (solo reproductor web)",
|
||||||
"theme_description": "establece el tema a usar por la aplicación",
|
"theme_description": "establece el tema a usar por la aplicación",
|
||||||
@@ -159,7 +160,7 @@
|
|||||||
"audioPlayer": "reproductor de audio",
|
"audioPlayer": "reproductor de audio",
|
||||||
"hotkey_zoomOut": "reducir",
|
"hotkey_zoomOut": "reducir",
|
||||||
"hotkey_unfavoriteCurrentSong": "$t(common.currentSong) no favorito",
|
"hotkey_unfavoriteCurrentSong": "$t(common.currentSong) no favorito",
|
||||||
"hotkey_rate0": "limpiar calificación",
|
"hotkey_rate0": "Limpiar calificación",
|
||||||
"discordApplicationId": "id de aplicación {{discord}}",
|
"discordApplicationId": "id de aplicación {{discord}}",
|
||||||
"applicationHotkeys_description": "configura las teclas de acceso rápido de la aplicación. marca la casilla para establecerlas como teclas de acceso rápido globales (solo escritorio)",
|
"applicationHotkeys_description": "configura las teclas de acceso rápido de la aplicación. marca la casilla para establecerlas como teclas de acceso rápido globales (solo escritorio)",
|
||||||
"floatingQueueArea_description": "muestra un icono flotante en el lado derecho de la pantalla para ver la cola de reproducción",
|
"floatingQueueArea_description": "muestra un icono flotante en el lado derecho de la pantalla para ver la cola de reproducción",
|
||||||
@@ -191,11 +192,11 @@
|
|||||||
"skipPlaylistPage": "saltar página de lista de reproducción",
|
"skipPlaylistPage": "saltar página de lista de reproducción",
|
||||||
"hotkey_browserForward": "avance",
|
"hotkey_browserForward": "avance",
|
||||||
"hotkey_browserBack": "retroceso",
|
"hotkey_browserBack": "retroceso",
|
||||||
"clearCache": "limpiar la caché del navegador",
|
"clearCache": "Limpiar la caché del navegador",
|
||||||
"clearQueryCache": "limpiar la caché de feishin",
|
"clearQueryCache": "Limpiar la caché de Feishin",
|
||||||
"clearQueryCache_description": "una 'limpieza suave' de feishin. esto refrescará las listas de reproducción, metadatos de pistas y restablece las letras guardadas. se mantienen los ajustes, credenciales del servidor y las imágenes en caché",
|
"clearQueryCache_description": "Una 'limpieza suave' de Feishin. Esto refrescará las listas de reproducción, metadatos de pistas y restablecerá las letras guardadas. Se mantienen los ajustes, credenciales del servidor y las imágenes en caché",
|
||||||
"buttonSize": "tamaño del botón de la barra de reproducción",
|
"buttonSize": "tamaño del botón de la barra de reproducción",
|
||||||
"clearCache_description": "una 'limpieza fuerte' de feishin. para limpiar la caché de feishin, vacía la caché del navegador (imágenes guardadas y otros elementos). se mantienen las credenciales y ajustes del servidor",
|
"clearCache_description": "Una 'limpieza fuerte' de Feishin. Para limpiar la caché de Feishin, vacía la caché del navegador (imágenes guardadas y otros elementos). Se mantienen las credenciales y ajustes del servidor",
|
||||||
"buttonSize_description": "el tamaño de los botones de la barra de reproducción",
|
"buttonSize_description": "el tamaño de los botones de la barra de reproducción",
|
||||||
"passwordStore_description": "qué método de almacenamiento de contraseñas/claves secretas utilizar. cambie esta opción si tiene problemas para guardar contraseñas.",
|
"passwordStore_description": "qué método de almacenamiento de contraseñas/claves secretas utilizar. cambie esta opción si tiene problemas para guardar contraseñas.",
|
||||||
"startMinimized_description": "iniciar la aplicación en la bandeja del sistema",
|
"startMinimized_description": "iniciar la aplicación en la bandeja del sistema",
|
||||||
@@ -210,7 +211,41 @@
|
|||||||
"genreBehavior_description": "Determina si al pulsar en un género se abre por defecto la lista de pistas o de álbumes",
|
"genreBehavior_description": "Determina si al pulsar en un género se abre por defecto la lista de pistas o de álbumes",
|
||||||
"homeConfiguration_description": "Configura qué elementos son mostrados y en qué orden en la página de inicio",
|
"homeConfiguration_description": "Configura qué elementos son mostrados y en qué orden en la página de inicio",
|
||||||
"clearCacheSuccess": "Caché limpiada correctamente",
|
"clearCacheSuccess": "Caché limpiada correctamente",
|
||||||
"externalLinks": "Mostrar enlaces externos"
|
"externalLinks": "Mostrar enlaces externos",
|
||||||
|
"homeFeature": "Carrusel destacado de inicio",
|
||||||
|
"homeFeature_description": "Controla si se muestra el gran carrusel destacado en la página de inicio",
|
||||||
|
"imageAspectRatio_description": "Si está habilitado, la portada será mostrada usando su relación de aspecto nativa. Para arte que no es 1:1, el espacio restante estará vacío",
|
||||||
|
"imageAspectRatio": "Usar relación de aspecto nativa de portada",
|
||||||
|
"doubleClickBehavior": "poner en cola todas las pistas buscadas al hacer doble clic",
|
||||||
|
"doubleClickBehavior_description": "si es true, se pondrán en cola todas las pistas que coincidan en una búsqueda de pistas. De lo contrario, solo se pondrá en cola la pista seleccionada",
|
||||||
|
"volumeWidth": "Ancho del deslizador de volumen",
|
||||||
|
"volumeWidth_description": "La anchura del deslizador de volumen",
|
||||||
|
"discordListening_description": "Muestra el estado como escuchando en lugar de reproduciendo. Ten en cuenta que esto actualmente rompe la barra de tiempo",
|
||||||
|
"discordListening": "Mostrar estado como escuchando",
|
||||||
|
"contextMenu": "Configuración del menú de contexto (clic derecho)",
|
||||||
|
"contextMenu_description": "Te permite esconder elementos que son mostrados en el menú cuando haces clic derecho en un elemento. Los elementos que no estén seleccionados serán escondidos",
|
||||||
|
"customCssEnable": "Habilitar CSS personalizado",
|
||||||
|
"customCssEnable_description": "Permite la escritura de CSS personalizado.",
|
||||||
|
"customCss": "CSS personalizado",
|
||||||
|
"customCssNotice": "Aviso: mientras hay alguna sanitización (rechazar url() y content:), usar CSS personalizado puede aún entrañar riesgos cambiando la interfaz.",
|
||||||
|
"customCss_description": "Content CSS personalizado. Nota: content y urls remotas son propiedades rechazadas. Una vista previa de tu content se muestra debajo. Las entradas adicionales que no estableciste están presentes debido a la sanitización.",
|
||||||
|
"webAudio": "usar audio web",
|
||||||
|
"webAudio_description": "Utilizar audio web. Esto habilita funciones avanzadas como Replaygain. Desactiva esta opción si tienes problemas",
|
||||||
|
"transcode": "activar la transcodificación",
|
||||||
|
"transcode_description": "permite la transcodificación a distintos formatos",
|
||||||
|
"transcodeBitrate": "tasa de bits a transcodificar",
|
||||||
|
"transcodeBitrate_description": "selecciona el bitrate a transcodificar. 0 significa dejar que el servidor elija",
|
||||||
|
"transcodeNote": "Se mostrará después de 1 (web) - 2 (mpv) pistas",
|
||||||
|
"transcodeFormat": "formato a transcodificar",
|
||||||
|
"transcodeFormat_description": "selecciona el formato a transcodificar. dejar vacío para que el servidor decida",
|
||||||
|
"albumBackground": "imagen de fondo del álbum",
|
||||||
|
"albumBackground_description": "Agregar una imagen de fondo a las páginas del álbum que contienen la carátula del álbum",
|
||||||
|
"albumBackgroundBlur": "Tamaño de desenfoque de la imagen de fondo del álbum",
|
||||||
|
"albumBackgroundBlur_description": "Ajustar el nivel de desenfoque de la imagen de fondo del álbum",
|
||||||
|
"playerbarOpenDrawer": "Cambiar la barra del reproductor a pantalla completa",
|
||||||
|
"playerbarOpenDrawer_description": "Permitir hacer clic en la barra del reproductor para abrir el reproductor en pantalla completa",
|
||||||
|
"artistConfiguration": "Configuración de la página del artista del álbum",
|
||||||
|
"artistConfiguration_description": "Configurar qué elementos se muestran y en qué orden en la página del artista del álbum"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "editar $t(entity.playlist_one)",
|
"editPlaylist": "editar $t(entity.playlist_one)",
|
||||||
@@ -239,7 +274,7 @@
|
|||||||
"backward": "hacia atrás",
|
"backward": "hacia atrás",
|
||||||
"increase": "aumentar",
|
"increase": "aumentar",
|
||||||
"rating": "calificación",
|
"rating": "calificación",
|
||||||
"bpm": "bpm",
|
"bpm": "lpm",
|
||||||
"refresh": "actualizar",
|
"refresh": "actualizar",
|
||||||
"unknown": "desconocido",
|
"unknown": "desconocido",
|
||||||
"areYouSure": "estás seguro?",
|
"areYouSure": "estás seguro?",
|
||||||
@@ -298,7 +333,7 @@
|
|||||||
"previousSong": "anterior $t(entity.track_one)",
|
"previousSong": "anterior $t(entity.track_one)",
|
||||||
"noResultsFromQuery": "la petición no devolvió resultados",
|
"noResultsFromQuery": "la petición no devolvió resultados",
|
||||||
"quit": "salir",
|
"quit": "salir",
|
||||||
"expand": "ampliar",
|
"expand": "expandir",
|
||||||
"search": "buscar",
|
"search": "buscar",
|
||||||
"saveAs": "guardar como",
|
"saveAs": "guardar como",
|
||||||
"disc": "disco",
|
"disc": "disco",
|
||||||
@@ -372,7 +407,7 @@
|
|||||||
"albumArtist": "$t(entity.albumArtist_one)",
|
"albumArtist": "$t(entity.albumArtist_one)",
|
||||||
"isRecentlyPlayed": "reproducido recientemente",
|
"isRecentlyPlayed": "reproducido recientemente",
|
||||||
"isFavorited": "es favorito",
|
"isFavorited": "es favorito",
|
||||||
"bpm": "bpm",
|
"bpm": "lpm",
|
||||||
"releaseYear": "año de lanzamiento",
|
"releaseYear": "año de lanzamiento",
|
||||||
"disc": "disco",
|
"disc": "disco",
|
||||||
"biography": "biografía",
|
"biography": "biografía",
|
||||||
@@ -441,7 +476,9 @@
|
|||||||
"numberSelected": "{{count}} seleccionado",
|
"numberSelected": "{{count}} seleccionado",
|
||||||
"removeFromQueue": "$t(action.removeFromQueue)",
|
"removeFromQueue": "$t(action.removeFromQueue)",
|
||||||
"shareItem": "Compartir elemento",
|
"shareItem": "Compartir elemento",
|
||||||
"showDetails": "Obtener información"
|
"showDetails": "Obtener información",
|
||||||
|
"playSimilarSongs": "$t(player.playSimilarSongs)",
|
||||||
|
"download": "descargar"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"mostPlayed": "más reproducidos",
|
"mostPlayed": "más reproducidos",
|
||||||
@@ -472,13 +509,15 @@
|
|||||||
},
|
},
|
||||||
"albumDetail": {
|
"albumDetail": {
|
||||||
"moreFromArtist": "más de este $t(entity.artist_one)",
|
"moreFromArtist": "más de este $t(entity.artist_one)",
|
||||||
"moreFromGeneric": "más de {{item}}"
|
"moreFromGeneric": "más de {{item}}",
|
||||||
|
"released": "publicado"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"playbackTab": "reproducción",
|
"playbackTab": "reproducción",
|
||||||
"generalTab": "general",
|
"generalTab": "general",
|
||||||
"hotkeysTab": "teclas de acceso rápido",
|
"hotkeysTab": "teclas de acceso rápido",
|
||||||
"windowTab": "ventana"
|
"windowTab": "ventana",
|
||||||
|
"advanced": "Avanzado"
|
||||||
},
|
},
|
||||||
"albumArtistList": {
|
"albumArtistList": {
|
||||||
"title": "$t(entity.albumArtist_other)"
|
"title": "$t(entity.albumArtist_other)"
|
||||||
@@ -491,7 +530,7 @@
|
|||||||
"trackList": {
|
"trackList": {
|
||||||
"title": "$t(entity.track_other)",
|
"title": "$t(entity.track_other)",
|
||||||
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
|
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
|
||||||
"artistTracks": "Pistas de {{artist}}"
|
"artistTracks": "pistas por {{artist}}"
|
||||||
},
|
},
|
||||||
"globalSearch": {
|
"globalSearch": {
|
||||||
"commands": {
|
"commands": {
|
||||||
@@ -507,13 +546,13 @@
|
|||||||
"albumList": {
|
"albumList": {
|
||||||
"title": "$t(entity.album_other)",
|
"title": "$t(entity.album_other)",
|
||||||
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)",
|
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)",
|
||||||
"artistAlbums": "Álbumes de {{artist}}"
|
"artistAlbums": "álbumes de {{artist}}"
|
||||||
},
|
},
|
||||||
"albumArtistDetail": {
|
"albumArtistDetail": {
|
||||||
"viewAllTracks": "ver todo de $t(entity.track_other)",
|
"viewAllTracks": "ver todo de $t(entity.track_other)",
|
||||||
"relatedArtists": "similar a $t(entity.artist_other)",
|
"relatedArtists": "$t(entity.artist_other) similar",
|
||||||
"topSongs": "mejores canciones",
|
"topSongs": "mejores canciones",
|
||||||
"topSongsFrom": "Las mejores canciones de {{title}}",
|
"topSongsFrom": "las mejores canciones de {{title}}",
|
||||||
"viewAll": "Ver todo",
|
"viewAll": "Ver todo",
|
||||||
"recentReleases": "Lanzamientos recientes",
|
"recentReleases": "Lanzamientos recientes",
|
||||||
"viewDiscography": "Ver discografía",
|
"viewDiscography": "Ver discografía",
|
||||||
@@ -524,6 +563,9 @@
|
|||||||
"copiedPath": "Ruta copiada correctamente",
|
"copiedPath": "Ruta copiada correctamente",
|
||||||
"openFile": "Mostrar pista en el gestor de archivos",
|
"openFile": "Mostrar pista en el gestor de archivos",
|
||||||
"copyPath": "Copiar ruta al portapapeles"
|
"copyPath": "Copiar ruta al portapapeles"
|
||||||
|
},
|
||||||
|
"playlist": {
|
||||||
|
"reorder": "la reordenación solo se activa al ordenar por id"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
@@ -569,7 +611,9 @@
|
|||||||
"title": "buscar letras"
|
"title": "buscar letras"
|
||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "editar $t(entity.playlist_one)"
|
"title": "editar $t(entity.playlist_one)",
|
||||||
|
"success": "$t(entity.playlist_one) actualizada correctamente",
|
||||||
|
"publicJellyfinNote": "Jellyfin por alguna razón no expone si una lista de reproducción es pública o no. Si deseas que ésta siga siendo pública, por favor ten seleccionada la siguiente entrada"
|
||||||
},
|
},
|
||||||
"queryEditor": {
|
"queryEditor": {
|
||||||
"input_optionMatchAll": "coincidir todos",
|
"input_optionMatchAll": "coincidir todos",
|
||||||
@@ -598,7 +642,7 @@
|
|||||||
"releaseDate": "fecha de lanzamiento",
|
"releaseDate": "fecha de lanzamiento",
|
||||||
"bitrate": "tasa de bits",
|
"bitrate": "tasa de bits",
|
||||||
"title": "título",
|
"title": "título",
|
||||||
"bpm": "bpm",
|
"bpm": "lpm",
|
||||||
"dateAdded": "fecha de adición",
|
"dateAdded": "fecha de adición",
|
||||||
"artist": "$t(entity.artist_one)",
|
"artist": "$t(entity.artist_one)",
|
||||||
"songCount": "$t(entity.track_other)",
|
"songCount": "$t(entity.track_other)",
|
||||||
@@ -639,7 +683,8 @@
|
|||||||
"genre": "$t(entity.genre_one)",
|
"genre": "$t(entity.genre_one)",
|
||||||
"favorite": "$t(common.favorite)",
|
"favorite": "$t(common.favorite)",
|
||||||
"year": "$t(common.year)",
|
"year": "$t(common.year)",
|
||||||
"codec": "$t(common.codec)"
|
"codec": "$t(common.codec)",
|
||||||
|
"songCount": "$t(entity.track_other)"
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"gap": "$t(common.gap)",
|
"gap": "$t(common.gap)",
|
||||||
@@ -703,6 +748,9 @@
|
|||||||
"genreWithCount_other": "{{count}} géneros",
|
"genreWithCount_other": "{{count}} géneros",
|
||||||
"trackWithCount_one": "{{count}} pista",
|
"trackWithCount_one": "{{count}} pista",
|
||||||
"trackWithCount_many": "{{count}} pistas",
|
"trackWithCount_many": "{{count}} pistas",
|
||||||
"trackWithCount_other": "{{count}} pistas"
|
"trackWithCount_other": "{{count}} pistas",
|
||||||
|
"play_one": "Reproducir {{count}}",
|
||||||
|
"play_many": "Reproducir {{count}}",
|
||||||
|
"play_other": "Reproducir {{count}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"size": "koko",
|
||||||
|
"search": "etsi",
|
||||||
|
"sortOrder": "järjestys",
|
||||||
|
"setting": "asetus",
|
||||||
|
"title": "otsikko",
|
||||||
|
"trackNumber": "raita",
|
||||||
|
"action_one": "toiminto",
|
||||||
|
"action_other": "toiminnot",
|
||||||
|
"add": "lisää",
|
||||||
|
"areYouSure": "oletko varma?",
|
||||||
|
"ascending": "nouseva",
|
||||||
|
"backward": "takaperin",
|
||||||
|
"bitrate": "bittinopeus",
|
||||||
|
"channel_one": "kanava",
|
||||||
|
"channel_other": "kanavat",
|
||||||
|
"collapse": "luhista",
|
||||||
|
"comingSoon": "tulossa pian…",
|
||||||
|
"configure": "konfiguroi",
|
||||||
|
"confirm": "hyväksy",
|
||||||
|
"disable": "poista käytöstä",
|
||||||
|
"disc": "levy",
|
||||||
|
"dismiss": "hylkää",
|
||||||
|
"favorite": "suosikki",
|
||||||
|
"filter_one": "suodatin",
|
||||||
|
"filter_other": "suodatinta",
|
||||||
|
"filters": "suodattimet",
|
||||||
|
"forceRestartRequired": "käynnistä uudelleen ottaaksesi muutokset käyttöön… sulje ilmoitus käynnistääksesi uudelleen",
|
||||||
|
"gap": "väli",
|
||||||
|
"home": "koti",
|
||||||
|
"left": "vasen",
|
||||||
|
"limit": "raja",
|
||||||
|
"manage": "hallitse",
|
||||||
|
"menu": "valikko",
|
||||||
|
"minimize": "minimoi",
|
||||||
|
"modified": "muokattu",
|
||||||
|
"name": "nimi",
|
||||||
|
"no": "ei",
|
||||||
|
"none": "ei mitään",
|
||||||
|
"noResultsFromQuery": "kysely palautti ei tuloksia",
|
||||||
|
"note": "huomautus",
|
||||||
|
"ok": "ok",
|
||||||
|
"owner": "omistaja",
|
||||||
|
"path": "reitti",
|
||||||
|
"preview": "esikatsele",
|
||||||
|
"previousSong": "edellinen $t(entity.track_one)",
|
||||||
|
"resetToDefault": "palauta oletusarvoihin",
|
||||||
|
"restartRequired": "uudelleen käynnistys vaaditaan",
|
||||||
|
"right": "oikea",
|
||||||
|
"save": "tallenna",
|
||||||
|
"saveAndReplace": "tallenna ja korvaa",
|
||||||
|
"saveAs": "tallenna nimellä",
|
||||||
|
"unknown": "tuntematon",
|
||||||
|
"version": "versio",
|
||||||
|
"year": "vuosi",
|
||||||
|
"yes": "kyllä",
|
||||||
|
"close": "sulje",
|
||||||
|
"descending": "laskeva",
|
||||||
|
"biography": "elämänkerta",
|
||||||
|
"cancel": "peruuta",
|
||||||
|
"bpm": "bpm",
|
||||||
|
"decrease": "pienennä",
|
||||||
|
"center": "keskitä",
|
||||||
|
"clear": "tyhjennä",
|
||||||
|
"codec": "koodekki",
|
||||||
|
"create": "luo",
|
||||||
|
"description": "kuvaus",
|
||||||
|
"currentSong": "nykyinen $t(entity.track_one)",
|
||||||
|
"delete": "poista",
|
||||||
|
"duration": "kesto",
|
||||||
|
"edit": "muokkaa",
|
||||||
|
"enable": "ota käyttöön",
|
||||||
|
"expand": "laajenna",
|
||||||
|
"increase": "lisää",
|
||||||
|
"forward": "eteenpäin",
|
||||||
|
"maximize": "maksimoi",
|
||||||
|
"mbid": "MusicBrainz ID",
|
||||||
|
"share": "jaa",
|
||||||
|
"random": "satunnainen",
|
||||||
|
"reload": "lataa uudelleen",
|
||||||
|
"quit": "poistu",
|
||||||
|
"rating": "arvostelu",
|
||||||
|
"refresh": "virkistä",
|
||||||
|
"reset": "nollaa",
|
||||||
|
"playerMustBePaused": "soitin täytyy olla pysäytetty"
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"album_one": "albumi",
|
||||||
|
"album_other": "albumia",
|
||||||
|
"albumArtist_one": "albumi artisti",
|
||||||
|
"albumArtist_other": "albumi artistia",
|
||||||
|
"artistWithCount_one": "{{count}} artisti",
|
||||||
|
"artistWithCount_other": "{{count}} artistia",
|
||||||
|
"playlist_one": "soittolista",
|
||||||
|
"playlist_other": "soittolistaa",
|
||||||
|
"playlistWithCount_one": "{{count}} soittolista",
|
||||||
|
"playlistWithCount_other": "{{count}} soittolistaa",
|
||||||
|
"albumArtistCount_one": "{{count}} albumi artisti",
|
||||||
|
"albumArtistCount_other": "{{count}} albumi artistia",
|
||||||
|
"albumWithCount_one": "{{count}} albumi",
|
||||||
|
"albumWithCount_other": "{{count}} albumia",
|
||||||
|
"artist_one": "artisti",
|
||||||
|
"artist_other": "artistia",
|
||||||
|
"favorite_one": "suosikki",
|
||||||
|
"favorite_other": "suosikkia",
|
||||||
|
"folder_one": "kansio",
|
||||||
|
"folder_other": "kansiota",
|
||||||
|
"folderWithCount_one": "{{count}} kansio",
|
||||||
|
"folderWithCount_other": "{{count}} kansiota",
|
||||||
|
"genre_one": "genre",
|
||||||
|
"genre_other": "genreä",
|
||||||
|
"genreWithCount_one": "{{count}} genre",
|
||||||
|
"genreWithCount_other": "{{count}} genreä",
|
||||||
|
"smartPlaylist": "älykäs $t(entity.playlist_one)",
|
||||||
|
"track_one": "raita",
|
||||||
|
"track_other": "raitaa",
|
||||||
|
"trackWithCount_one": "{{count}} raita",
|
||||||
|
"trackWithCount_other": "{{count}} raitaa"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"clearQueue": "tyhjennä jono",
|
||||||
|
"createPlaylist": "luo $t(entity.playlist_one)",
|
||||||
|
"deselectAll": "poista valinta kaikista",
|
||||||
|
"editPlaylist": "muokkaa $t(entity.playlist_one)",
|
||||||
|
"removeFromQueue": "poista jonosta",
|
||||||
|
"viewPlaylists": "katsele $t(entity.playlist_other)",
|
||||||
|
"openIn": {
|
||||||
|
"lastfm": "Avaa Last.fm:ssä",
|
||||||
|
"musicbrainz": "Avaa MusicBrainz:ssä"
|
||||||
|
},
|
||||||
|
"goToPage": "mene sivulle",
|
||||||
|
"moveToBottom": "siirry pohjalle",
|
||||||
|
"moveToTop": "siirry ylös",
|
||||||
|
"addToFavorites": "lisää $t(entity.favorite_other)",
|
||||||
|
"addToPlaylist": "lisää $t(entity.playlist_one)",
|
||||||
|
"refresh": "$t(common.refresh)",
|
||||||
|
"removeFromFavorites": "poista kohteesta $t(entity.favorite_other)",
|
||||||
|
"toggleSmartPlaylistEditor": "kytke $t(entity.smartPlaylist) editori",
|
||||||
|
"deletePlaylist": "poista $t(entity.playlist_one)",
|
||||||
|
"removeFromPlaylist": "poista kohteesta $t(entity.playlist_one)",
|
||||||
|
"setRating": "aseta arvostelu"
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
-27
@@ -28,7 +28,8 @@
|
|||||||
"mute": "muet",
|
"mute": "muet",
|
||||||
"skip_forward": "avancer",
|
"skip_forward": "avancer",
|
||||||
"pause": "pause",
|
"pause": "pause",
|
||||||
"unfavorite": "dé-favori"
|
"unfavorite": "retirer des favoris",
|
||||||
|
"playSimilarSongs": "jouer des chansons similaires"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "éditer $t(entity.playlist_one)",
|
"editPlaylist": "éditer $t(entity.playlist_one)",
|
||||||
@@ -47,16 +48,20 @@
|
|||||||
"moveToBottom": "déplacer en bas",
|
"moveToBottom": "déplacer en bas",
|
||||||
"setRating": "noter",
|
"setRating": "noter",
|
||||||
"toggleSmartPlaylistEditor": "basculer l'éditeur de $t(entity.smartPlaylist)",
|
"toggleSmartPlaylistEditor": "basculer l'éditeur de $t(entity.smartPlaylist)",
|
||||||
"removeFromFavorites": "retirer des $t(entity.favorite_other)"
|
"removeFromFavorites": "retirer des $t(entity.favorite_other)",
|
||||||
|
"openIn": {
|
||||||
|
"lastfm": "Ouvrir dans Last.fm",
|
||||||
|
"musicbrainz": "Ouvrir dans MusicBrainz"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"backward": "reculer",
|
"backward": "en arrière",
|
||||||
"increase": "augmenter",
|
"increase": "augmenter",
|
||||||
"rating": "note",
|
"rating": "note",
|
||||||
"bpm": "bpm",
|
"bpm": "bpm",
|
||||||
"refresh": "rafraichir",
|
"refresh": "rafraichir",
|
||||||
"unknown": "inconnu",
|
"unknown": "inconnu",
|
||||||
"areYouSure": "êtes vous sûr ?",
|
"areYouSure": "êtes-vous sûr ?",
|
||||||
"edit": "éditer",
|
"edit": "éditer",
|
||||||
"favorite": "favoris",
|
"favorite": "favoris",
|
||||||
"left": "gauche",
|
"left": "gauche",
|
||||||
@@ -130,7 +135,17 @@
|
|||||||
"random": "aléatoire",
|
"random": "aléatoire",
|
||||||
"size": "taille",
|
"size": "taille",
|
||||||
"biography": "biographie",
|
"biography": "biographie",
|
||||||
"note": "note"
|
"note": "note",
|
||||||
|
"albumGain": "gain de l'album",
|
||||||
|
"albumPeak": "crête de l'album",
|
||||||
|
"close": "fermer",
|
||||||
|
"mbid": "Identifiants MusicBrainz",
|
||||||
|
"preview": "aperçu",
|
||||||
|
"share": "partager",
|
||||||
|
"reload": "recharger",
|
||||||
|
"trackGain": "gain de la piste",
|
||||||
|
"trackPeak": "crête de la piste",
|
||||||
|
"codec": "codec"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
|
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
|
||||||
@@ -151,7 +166,10 @@
|
|||||||
"mpvRequired": "MPV requis",
|
"mpvRequired": "MPV requis",
|
||||||
"audioDeviceFetchError": "une erreur s’est produite lors de la tentative d’obtenir les périphériques audio",
|
"audioDeviceFetchError": "une erreur s’est produite lors de la tentative d’obtenir les périphériques audio",
|
||||||
"invalidServer": "serveur invalide",
|
"invalidServer": "serveur invalide",
|
||||||
"loginRateError": "trop de tentative de connexion, merci d'essayer dans quelque secondes"
|
"loginRateError": "trop de tentative de connexion, merci d'essayer dans quelque secondes",
|
||||||
|
"openError": "impossible d'ouvrir le fichier",
|
||||||
|
"networkError": "une erreur de réseau est survenue",
|
||||||
|
"badAlbum": "vous voyez cette page parce que cette chanson ne fait pas parti d'un album. vous rencontrez probablement cette erreur si vous avez une chanson qui n'est pas dans votre répertoire de musique. jellyfin gère les chansons uniquement si elles sont dans un sous-dossier, qui est lui-même dans un dossier \"Musique(s)\"."
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"mostPlayed": "plus joués",
|
"mostPlayed": "plus joués",
|
||||||
@@ -209,7 +227,8 @@
|
|||||||
"settings": "$t(common.setting_other)",
|
"settings": "$t(common.setting_other)",
|
||||||
"home": "$t(common.home)",
|
"home": "$t(common.home)",
|
||||||
"artists": "$t(entity.artist_other)",
|
"artists": "$t(entity.artist_other)",
|
||||||
"albumArtists": "$t(entity.albumArtist_other)"
|
"albumArtists": "$t(entity.albumArtist_other)",
|
||||||
|
"shared": "partagé $t(entity.playlist_other)"
|
||||||
},
|
},
|
||||||
"fullscreenPlayer": {
|
"fullscreenPlayer": {
|
||||||
"config": {
|
"config": {
|
||||||
@@ -221,9 +240,11 @@
|
|||||||
"unsynchronized": "désynchronisé",
|
"unsynchronized": "désynchronisé",
|
||||||
"lyricAlignment": "alignement des paroles",
|
"lyricAlignment": "alignement des paroles",
|
||||||
"useImageAspectRatio": "utiliser le ratio de l'image",
|
"useImageAspectRatio": "utiliser le ratio de l'image",
|
||||||
"opacity": "opacitée",
|
"opacity": "opacité",
|
||||||
"lyricSize": "Taille des paroles",
|
"lyricSize": "Taille des paroles",
|
||||||
"lyricGap": "espacement des lettres"
|
"lyricGap": "espacement des lettres",
|
||||||
|
"dynamicIsImage": "activer l'image d'arrière-plan",
|
||||||
|
"dynamicImageBlur": "intensité de flou sur image d'arrière-plan"
|
||||||
},
|
},
|
||||||
"upNext": "à suivre",
|
"upNext": "à suivre",
|
||||||
"lyrics": "paroles",
|
"lyrics": "paroles",
|
||||||
@@ -254,7 +275,7 @@
|
|||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"generalTab": "général",
|
"generalTab": "général",
|
||||||
"hotkeysTab": "raccourci",
|
"hotkeysTab": "raccourcis",
|
||||||
"windowTab": "fenêtre",
|
"windowTab": "fenêtre",
|
||||||
"playbackTab": "lecteur"
|
"playbackTab": "lecteur"
|
||||||
},
|
},
|
||||||
@@ -282,22 +303,47 @@
|
|||||||
"addLast": "$t(player.addLast)",
|
"addLast": "$t(player.addLast)",
|
||||||
"addFavorite": "$t(action.addToFavorites)",
|
"addFavorite": "$t(action.addToFavorites)",
|
||||||
"play": "$t(player.play)",
|
"play": "$t(player.play)",
|
||||||
"removeFromQueue": "$t(action.removeFromQueue)"
|
"removeFromQueue": "$t(action.removeFromQueue)",
|
||||||
|
"shareItem": "partager un élément",
|
||||||
|
"playSimilarSongs": "$t(player.playSimilarSongs)",
|
||||||
|
"showDetails": "obtenir des informations"
|
||||||
},
|
},
|
||||||
"albumArtistList": {
|
"albumArtistList": {
|
||||||
"title": "$t(entity.albumArtist_other)"
|
"title": "$t(entity.albumArtist_other)"
|
||||||
},
|
},
|
||||||
"genreList": {
|
"genreList": {
|
||||||
"title": "$t(entity.genre_other)"
|
"title": "$t(entity.genre_other)",
|
||||||
|
"showAlbums": "afficher $t(entity.genre_one) $t(entity.album_other)",
|
||||||
|
"showTracks": "afficher $t(entity.genre_one) $t(entity.track_other)"
|
||||||
},
|
},
|
||||||
"trackList": {
|
"trackList": {
|
||||||
"title": "$t(entity.track_other)"
|
"title": "$t(entity.track_other)",
|
||||||
|
"artistTracks": "pistes par {{artist}}",
|
||||||
|
"genreTracks": "'{{genre}}' $t(entity.track_other)"
|
||||||
},
|
},
|
||||||
"playlistList": {
|
"playlistList": {
|
||||||
"title": "$t(entity.playlist_other)"
|
"title": "$t(entity.playlist_other)"
|
||||||
},
|
},
|
||||||
"albumList": {
|
"albumList": {
|
||||||
"title": "$t(entity.album_other)"
|
"title": "$t(entity.album_other)",
|
||||||
|
"artistAlbums": "albums par {{artist}}",
|
||||||
|
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)"
|
||||||
|
},
|
||||||
|
"albumArtistDetail": {
|
||||||
|
"about": "À propos de {{artist}}",
|
||||||
|
"appearsOn": "apparaît sur",
|
||||||
|
"topSongsFrom": "meilleures chansons de {{title}}",
|
||||||
|
"viewAll": "voir tout",
|
||||||
|
"viewAllTracks": "voir tout $t(entity.track_other)",
|
||||||
|
"recentReleases": "sorties récentes",
|
||||||
|
"viewDiscography": "voir la discographie",
|
||||||
|
"relatedArtists": "en rapport avec $t(entity.artist_other)",
|
||||||
|
"topSongs": "meilleures chansons"
|
||||||
|
},
|
||||||
|
"itemDetail": {
|
||||||
|
"copyPath": "copier le chemin dans le presse-papiers",
|
||||||
|
"openFile": "afficher la piste dans le gestionnaire de fichiers",
|
||||||
|
"copiedPath": "chemin copié avec succès"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
@@ -331,7 +377,7 @@
|
|||||||
"mpvExecutablePath_description": "définit le chemin vers l'exécutable mpv, si vide, le chemin par défaut sera utilisé",
|
"mpvExecutablePath_description": "définit le chemin vers l'exécutable mpv, si vide, le chemin par défaut sera utilisé",
|
||||||
"hotkey_favoriteCurrentSong": "favori $t(common.currentSong)",
|
"hotkey_favoriteCurrentSong": "favori $t(common.currentSong)",
|
||||||
"sampleRate": "taux d'échantillonnage",
|
"sampleRate": "taux d'échantillonnage",
|
||||||
"sampleRate_description": "sélectionner le taux d'échantillonnage de sortie utilisé si la fréquence d'échantillonnage sélectionnée est différente de celle du média actuel. une valeur en inférieur à 8000 utilisera la fréquence par défaut",
|
"sampleRate_description": "sélectionne le taux d'échantillonnage de sortie utilisé si la fréquence d'échantillonnage sélectionnée est différente de celle du média actuel. une valeur inférieure à 8000 utilisera la fréquence par défaut",
|
||||||
"hotkey_zoomIn": "zoom avant",
|
"hotkey_zoomIn": "zoom avant",
|
||||||
"scrobble_description": "scrobble les lectures à votre serveur multimédia",
|
"scrobble_description": "scrobble les lectures à votre serveur multimédia",
|
||||||
"hotkey_browserForward": "avancer",
|
"hotkey_browserForward": "avancer",
|
||||||
@@ -359,7 +405,7 @@
|
|||||||
"fontType_optionCustom": "police personnalisée",
|
"fontType_optionCustom": "police personnalisée",
|
||||||
"remotePassword": "mot de passe du serveur de contrôle à distance",
|
"remotePassword": "mot de passe du serveur de contrôle à distance",
|
||||||
"lyricFetchProvider": "fournisseur depuis lequel récupérer les paroles",
|
"lyricFetchProvider": "fournisseur depuis lequel récupérer les paroles",
|
||||||
"language_description": "définit la langue de l'application $t(common.restartRequired)",
|
"language_description": "définit la langue de l'application ($t(common.restartRequired))",
|
||||||
"playbackStyle_optionCrossFade": "fondu enchaîné",
|
"playbackStyle_optionCrossFade": "fondu enchaîné",
|
||||||
"hotkey_rate3": "noter 3 étoiles",
|
"hotkey_rate3": "noter 3 étoiles",
|
||||||
"font": "police",
|
"font": "police",
|
||||||
@@ -371,7 +417,7 @@
|
|||||||
"hotkey_rate5": "noter 5 étoiles",
|
"hotkey_rate5": "noter 5 étoiles",
|
||||||
"hotkey_playbackPrevious": "piste précédente",
|
"hotkey_playbackPrevious": "piste précédente",
|
||||||
"showSkipButtons_description": "affiche ou cache les boutons suivants et précédents de la barre de lecture",
|
"showSkipButtons_description": "affiche ou cache les boutons suivants et précédents de la barre de lecture",
|
||||||
"language": "language",
|
"language": "langage",
|
||||||
"playbackStyle": "style de lecture",
|
"playbackStyle": "style de lecture",
|
||||||
"hotkey_toggleShuffle": "basculer la lecture aléatoire",
|
"hotkey_toggleShuffle": "basculer la lecture aléatoire",
|
||||||
"playbackStyle_description": "sélectionnez le style de lecture à utiliser pour le lecteur audio",
|
"playbackStyle_description": "sélectionnez le style de lecture à utiliser pour le lecteur audio",
|
||||||
@@ -416,7 +462,7 @@
|
|||||||
"savePlayQueue_description": "sauvegarde la liste de lecture quand l'application est fermée et la restaure quand l'application est ouverte",
|
"savePlayQueue_description": "sauvegarde la liste de lecture quand l'application est fermée et la restaure quand l'application est ouverte",
|
||||||
"sidebarCollapsedNavigation_description": "affiche ou cache la navigation dans la barre latérale réduite",
|
"sidebarCollapsedNavigation_description": "affiche ou cache la navigation dans la barre latérale réduite",
|
||||||
"sidebarConfiguration": "configuration de la barre latérale",
|
"sidebarConfiguration": "configuration de la barre latérale",
|
||||||
"sidebarConfiguration_description": "sélectionnez les items et l'ordre dans lesquels ils seront affichaient dans la barre latérale",
|
"sidebarConfiguration_description": "sélectionnez les éléments et l'ordre dans lequel ils seront affichés dans la barre latérale",
|
||||||
"sidebarPlaylistList": "liste de playlist de la barre latérale",
|
"sidebarPlaylistList": "liste de playlist de la barre latérale",
|
||||||
"sidebarCollapsedNavigation": "navigation de la barre latéral (réduite)",
|
"sidebarCollapsedNavigation": "navigation de la barre latéral (réduite)",
|
||||||
"skipDuration": "durée de l'avance rapide",
|
"skipDuration": "durée de l'avance rapide",
|
||||||
@@ -434,13 +480,13 @@
|
|||||||
"themeLight_description": "définit le thème clair à utiliser pour l'application",
|
"themeLight_description": "définit le thème clair à utiliser pour l'application",
|
||||||
"zoom_description": "définit le pourcentage de zoom de l'application",
|
"zoom_description": "définit le pourcentage de zoom de l'application",
|
||||||
"theme": "thème",
|
"theme": "thème",
|
||||||
"skipPlaylistPage_description": "lors de la navigation dans une playlist, aller directement vers le liste des morceaux, au lieu de la page par défaut",
|
"skipPlaylistPage_description": "lors de la navigation dans une playlist, aller directement vers la liste des morceaux, au lieu de la page par défaut",
|
||||||
"volumeWheelStep": "valeur du pas de volume",
|
"volumeWheelStep": "valeur du pas de volume",
|
||||||
"windowBarStyle": "style de la barre de la fenêtre",
|
"windowBarStyle": "style de la barre de la fenêtre",
|
||||||
"useSystemTheme_description": "suivre les préférences du système (sombre ou clair)",
|
"useSystemTheme_description": "suivre les préférences du système (mode clair ou sombre)",
|
||||||
"skipPlaylistPage": "sauter la page de playlist",
|
"skipPlaylistPage": "sauter la page de playlist",
|
||||||
"themeDark": "thème (sombre)",
|
"themeDark": "thème (sombre)",
|
||||||
"windowBarStyle_description": "sélectionner le style de la barre de la fenêtre",
|
"windowBarStyle_description": "ajuster le style de la barre de la fenêtre",
|
||||||
"useSystemTheme": "utiliser le thème du système",
|
"useSystemTheme": "utiliser le thème du système",
|
||||||
"discordApplicationId_description": "l'identifiant de l'application pour le statut d'activité {{discord}} (par défaut à {{defaultId}})",
|
"discordApplicationId_description": "l'identifiant de l'application pour le statut d'activité {{discord}} (par défaut à {{defaultId}})",
|
||||||
"audioExclusiveMode": "mode de sortie audio exclusif",
|
"audioExclusiveMode": "mode de sortie audio exclusif",
|
||||||
@@ -455,18 +501,36 @@
|
|||||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||||
"replayGainMode_description": "ajuste le gain de volume accordement à la valeur de {{ReplayGain}} sauvegardé dans les métadonnées du fichier",
|
"replayGainMode_description": "ajuste le gain de volume accordement à la valeur de {{ReplayGain}} sauvegardé dans les métadonnées du fichier",
|
||||||
"replayGainFallback": "{{ReplayGain}} fallback",
|
"replayGainFallback": "{{ReplayGain}} fallback",
|
||||||
"replayGainClipping_description": "Préviens le clipping causé par {{ReplayGain}} en baissant automatiquement le gain",
|
"replayGainClipping_description": "Prévient le clipping causé par {{ReplayGain}} en baissant automatiquement le gain",
|
||||||
"replayGainPreamp": "préamplificateur (dB) de {{ReplayGain}}",
|
"replayGainPreamp": "préamplificateur (dB) de {{ReplayGain}}",
|
||||||
"replayGainClipping": "{{ReplayGain}} clipping",
|
"replayGainClipping": "{{ReplayGain}} clipping",
|
||||||
"replayGainMode": "mode de {{ReplayGain}}",
|
"replayGainMode": "mode de {{ReplayGain}}",
|
||||||
"replayGainFallback_description": "gain en dB à appliquer si le fichier n'a pas de tag {{ReplayGain}}",
|
"replayGainFallback_description": "gain en dB à appliquer si le fichier n'a pas de tag {{ReplayGain}}",
|
||||||
"replayGainPreamp_description": "ajuste le gain de préampli appliqué a la valeur de {{ReplayGain}}",
|
"replayGainPreamp_description": "ajuste le gain de préampli appliqué a la valeur de {{ReplayGain}}",
|
||||||
"clearQueryCache": "vide le cache de feishin",
|
"clearQueryCache": "vide le cache de feishin",
|
||||||
"clearCache": "Vider le cache navigateur",
|
"clearCache": "vider le cache navigateur",
|
||||||
"buttonSize_description": "la taille des boutons de la barre de lecture",
|
"buttonSize_description": "la taille des boutons de la barre de lecture",
|
||||||
"clearQueryCache_description": "un 'soft clear' de feishin. cela actualisera les playlists, les métadonnées des pistes, et réinitialisera les paroles enregistrées. les paramètres, identifiants serveurs et les images mises en cache sont conservés",
|
"clearQueryCache_description": "un 'soft clear' de feishin. cela actualisera les playlists, les métadonnées des pistes, et réinitialisera les paroles enregistrées. les paramètres, identifiants serveurs et les images mises en cache sont conservés",
|
||||||
"clearCache_description": "un 'hard clear' de feishin. en plus de vider le cache de feishin, vide le cache du navigateur (images sauvegardées et autres ressources). les identifiants serveurs et paramètres sont conservés",
|
"clearCache_description": "un 'hard clear' de feishin. en plus de vider le cache de feishin, vide le cache du navigateur (images sauvegardées et autres ressources). les identifiants serveurs et paramètres sont conservés",
|
||||||
"buttonSize": "taille des boutons de la barre de lecture"
|
"buttonSize": "taille des boutons du lecteur",
|
||||||
|
"clearCacheSuccess": "le cache a été vidé",
|
||||||
|
"externalLinks_description": "activer l'affichage de liens externes (Last.fm, MusicBrainz) sur la page de l'artiste/album",
|
||||||
|
"genreBehavior": "comportement par défaut de la page des genres",
|
||||||
|
"startMinimized_description": "démarrer l'application dans la barre des tâches",
|
||||||
|
"externalLinks": "afficher les liens externes",
|
||||||
|
"homeConfiguration": "configuration de la page d'accueil",
|
||||||
|
"homeFeature": "carrousel de la page d'accueil",
|
||||||
|
"homeFeature_description": "active ou désactive le carrousel sur la page d'accueil",
|
||||||
|
"imageAspectRatio": "utiliser le rapport hauteur/largeur natif de la pochette",
|
||||||
|
"imageAspectRatio_description": "si cette option est activée, les pochettes seront affichées en utilisant leur rapport hauteur/largeur natif. pour les pochettes qui n'ont pas un rapport 1:1 (carré), l'espace restant sera vide",
|
||||||
|
"mpvExtraParameters_help": "un par ligne",
|
||||||
|
"passwordStore_description": "quel mot de passe utiliser. changez cela si vous rencontrez des problèmes pour stocker les mots de passe.",
|
||||||
|
"playerAlbumArtResolution": "résolution de la pochette de l'album du lecteur",
|
||||||
|
"passwordStore": "mots de passe",
|
||||||
|
"playerAlbumArtResolution_description": "la résolution pour l'aperçu de la pochette d'album agrandie du lecteur. plus grand le rend plus net, mais peut ralentir le chargement. la valeur par défaut est 0 (automatique)",
|
||||||
|
"homeConfiguration_description": "configurer quels éléments sont affichés sur la page d'accueil, et dans quel ordre",
|
||||||
|
"startMinimized": "démarrer l'application en mode réduit",
|
||||||
|
"genreBehavior_description": "détermine si cliquer sur un genre ouvre par défaut la liste des pistes ou des albums"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"deletePlaylist": {
|
"deletePlaylist": {
|
||||||
@@ -516,6 +580,14 @@
|
|||||||
"title": "rechercher parole",
|
"title": "rechercher parole",
|
||||||
"input_name": "$t(common.name)",
|
"input_name": "$t(common.name)",
|
||||||
"input_artist": "$t(entity.artist_one)"
|
"input_artist": "$t(entity.artist_one)"
|
||||||
|
},
|
||||||
|
"shareItem": {
|
||||||
|
"allowDownloading": "autoriser le téléchargement",
|
||||||
|
"description": "description",
|
||||||
|
"setExpiration": "définir une expiration",
|
||||||
|
"success": "lien de partage copié dans le presse-papier (ou cliquez ici pour ouvrir)",
|
||||||
|
"expireInvalid": "l'expiration doit être définie à une date ultérieure",
|
||||||
|
"createFailed": "échec de la création du lien de partage (le partage est-il activé ?)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
@@ -573,7 +645,9 @@
|
|||||||
"tableColumns": "colonnes de la liste",
|
"tableColumns": "colonnes de la liste",
|
||||||
"autoFitColumns": "colonnes à ajustement automatique",
|
"autoFitColumns": "colonnes à ajustement automatique",
|
||||||
"gap": "$t(common.gap)",
|
"gap": "$t(common.gap)",
|
||||||
"size": "$t(common.size)"
|
"size": "$t(common.size)",
|
||||||
|
"itemGap": "écart entre les éléments (en pixel)",
|
||||||
|
"itemSize": "taille des élements (en pixel)"
|
||||||
},
|
},
|
||||||
"view": {
|
"view": {
|
||||||
"table": "liste",
|
"table": "liste",
|
||||||
@@ -606,7 +680,9 @@
|
|||||||
"title": "$t(common.title)",
|
"title": "$t(common.title)",
|
||||||
"size": "$t(common.size)",
|
"size": "$t(common.size)",
|
||||||
"genre": "$t(entity.genre_one)",
|
"genre": "$t(entity.genre_one)",
|
||||||
"year": "$t(common.year)"
|
"year": "$t(common.year)",
|
||||||
|
"songCount": "$t(entity.track_other)",
|
||||||
|
"codec": "$t(common.codec)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"column": {
|
"column": {
|
||||||
@@ -632,7 +708,8 @@
|
|||||||
"genre": "$t(entity.genre_one)",
|
"genre": "$t(entity.genre_one)",
|
||||||
"songCount": "$t(entity.track_other)",
|
"songCount": "$t(entity.track_other)",
|
||||||
"channels": "$t(common.channel_other)",
|
"channels": "$t(common.channel_other)",
|
||||||
"size": "$t(common.size)"
|
"size": "$t(common.size)",
|
||||||
|
"codec": "$t(common.codec)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"action": {
|
||||||
|
"createPlaylist": "$t(entity.playlist_one) 생성",
|
||||||
|
"addToFavorites": "$t(entity.favorite_other)에 추가",
|
||||||
|
"addToPlaylist": "$t(entity.playlist_one)에 추가",
|
||||||
|
"clearQueue": "대기열 지우기",
|
||||||
|
"deletePlaylist": "$t(entity.playlist_one) 삭제",
|
||||||
|
"deselectAll": "모두 선택 해제",
|
||||||
|
"editPlaylist": "$t(entity.playlist_one) 편집",
|
||||||
|
"goToPage": "페이지 이동",
|
||||||
|
"moveToBottom": "맨 아래로 이동",
|
||||||
|
"moveToTop": "맨 위로 이동"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "pas $t(entity.playlist_one) aan",
|
"editPlaylist": "pas $t(entity.playlist_one) aan",
|
||||||
"goToPage": "ga naar pagina",
|
"goToPage": "ga naar pagina",
|
||||||
"moveToTop": "verplaats naar top",
|
"moveToTop": "verplaats naar boven",
|
||||||
"addToFavorites": "toevoegen aan $t(entity.favorite_other)",
|
"addToFavorites": "toevoegen aan $t(entity.favorite_other)",
|
||||||
"addToPlaylist": "toevoegen aan $t(entity.playlist_one)",
|
"addToPlaylist": "toevoegen aan $t(entity.playlist_one)",
|
||||||
"createPlaylist": "maak $t(entity.playlist_one)",
|
"createPlaylist": "maak $t(entity.playlist_one)",
|
||||||
@@ -16,7 +16,11 @@
|
|||||||
"setRating": "selecteer rating",
|
"setRating": "selecteer rating",
|
||||||
"toggleSmartPlaylistEditor": "editor $t(entity.smartPlaylist) schakelen",
|
"toggleSmartPlaylistEditor": "editor $t(entity.smartPlaylist) schakelen",
|
||||||
"removeFromFavorites": "verwijder van $t(entity.favorite_other)",
|
"removeFromFavorites": "verwijder van $t(entity.favorite_other)",
|
||||||
"clearQueue": "lijst leegmaken"
|
"clearQueue": "verwijder lijst",
|
||||||
|
"openIn": {
|
||||||
|
"lastfm": "Open in Last.fm",
|
||||||
|
"musicbrainz": "Open in MusicBrainz"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"backward": "achteruit",
|
"backward": "achteruit",
|
||||||
@@ -95,7 +99,10 @@
|
|||||||
"search": "zoeken",
|
"search": "zoeken",
|
||||||
"saveAs": "opslaan als",
|
"saveAs": "opslaan als",
|
||||||
"yes": "ja",
|
"yes": "ja",
|
||||||
"size": "grootte"
|
"size": "grootte",
|
||||||
|
"reload": "herlaad",
|
||||||
|
"setting": "instelling",
|
||||||
|
"close": "sluiten"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"rating": "rating",
|
"rating": "rating",
|
||||||
|
|||||||
+87
-17
@@ -16,7 +16,11 @@
|
|||||||
"createPlaylist": "utwórz $t(entity.playlist_one)",
|
"createPlaylist": "utwórz $t(entity.playlist_one)",
|
||||||
"deletePlaylist": "usuń $t(entity.playlist_one)",
|
"deletePlaylist": "usuń $t(entity.playlist_one)",
|
||||||
"moveToBottom": "przesuń na dół",
|
"moveToBottom": "przesuń na dół",
|
||||||
"setRating": "oceń"
|
"setRating": "oceń",
|
||||||
|
"openIn": {
|
||||||
|
"lastfm": "Otwórz w Last.fm",
|
||||||
|
"musicbrainz": "Otwórz w MusicBrainz"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"increase": "zwiększ",
|
"increase": "zwiększ",
|
||||||
@@ -99,7 +103,17 @@
|
|||||||
"decrease": "obniż",
|
"decrease": "obniż",
|
||||||
"path": "ścieżka",
|
"path": "ścieżka",
|
||||||
"center": "środkowy",
|
"center": "środkowy",
|
||||||
"note": "notatka"
|
"note": "notatka",
|
||||||
|
"albumPeak": "spadek albumu",
|
||||||
|
"albumGain": "wzrost albumu",
|
||||||
|
"mbid": "ID MusicBrainz",
|
||||||
|
"reload": "przeładuj",
|
||||||
|
"share": "udostępnij",
|
||||||
|
"trackGain": "gain utworu",
|
||||||
|
"trackPeak": "peak utworu",
|
||||||
|
"codec": "kodek",
|
||||||
|
"preview": "podgląd",
|
||||||
|
"close": "zamknij"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"genre_one": "gatunek",
|
"genre_one": "gatunek",
|
||||||
@@ -168,7 +182,10 @@
|
|||||||
"mpvRequired": "wymagane MPV",
|
"mpvRequired": "wymagane MPV",
|
||||||
"audioDeviceFetchError": "wystąpił błąd podczas próby znalezienia urządzeń dźwiękowych",
|
"audioDeviceFetchError": "wystąpił błąd podczas próby znalezienia urządzeń dźwiękowych",
|
||||||
"invalidServer": "nieprawidłowy serwer",
|
"invalidServer": "nieprawidłowy serwer",
|
||||||
"loginRateError": "zbyt dużo prób logowania, poczekaj chwilę i spróbuj ponownie"
|
"loginRateError": "zbyt dużo prób logowania, poczekaj chwilę i spróbuj ponownie",
|
||||||
|
"badAlbum": "ta strona jest wyświetlana, ponieważ ten utwór nie jest częścią albumu. najprawdopodobniej ten problem występuje, jeśli utwór znajduje się w nadrzędnym folderze plików z muzyką. jellyfin grupuje utwory tylko wtedy, gdy znajdują się one w folderze.",
|
||||||
|
"networkError": "wystąpił błąd sieciowy",
|
||||||
|
"openError": "nie można otworzyć pliku"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"mostPlayed": "najczęściej odtwarzane",
|
"mostPlayed": "najczęściej odtwarzane",
|
||||||
@@ -262,6 +279,14 @@
|
|||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "edytuj $t(entity.playlist_one)"
|
"title": "edytuj $t(entity.playlist_one)"
|
||||||
|
},
|
||||||
|
"shareItem": {
|
||||||
|
"allowDownloading": "zezwól na pobieranie",
|
||||||
|
"description": "opis",
|
||||||
|
"setExpiration": "ustaw czas wygaśnięcia",
|
||||||
|
"success": "link do udostępniania skopiowany do schowka (lub kliknij tutaj, aby otworzyć)",
|
||||||
|
"createFailed": "nie udało się utworzyć linku do udostępniania (czy udostępnianie jest włączone?)",
|
||||||
|
"expireInvalid": "ustawiony czas wygaśnięcia musi być w przyszłości"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
@@ -277,7 +302,9 @@
|
|||||||
"unsynchronized": "niezsynchronizowane",
|
"unsynchronized": "niezsynchronizowane",
|
||||||
"lyricAlignment": "wyrównaj tekst",
|
"lyricAlignment": "wyrównaj tekst",
|
||||||
"useImageAspectRatio": "użyj współczynnika proporcji obrazu",
|
"useImageAspectRatio": "użyj współczynnika proporcji obrazu",
|
||||||
"lyricGap": "odstępy tekstu"
|
"lyricGap": "odstępy tekstu",
|
||||||
|
"dynamicImageBlur": "rozmiar rozmycia obrazu",
|
||||||
|
"dynamicIsImage": "włącz obraz w tle"
|
||||||
},
|
},
|
||||||
"upNext": "następny",
|
"upNext": "następny",
|
||||||
"lyrics": "tekst",
|
"lyrics": "tekst",
|
||||||
@@ -311,7 +338,9 @@
|
|||||||
"addFavorite": "$t(action.addToFavorites)",
|
"addFavorite": "$t(action.addToFavorites)",
|
||||||
"play": "$t(player.play)",
|
"play": "$t(player.play)",
|
||||||
"numberSelected": "zaznaczono {{count}}",
|
"numberSelected": "zaznaczono {{count}}",
|
||||||
"removeFromQueue": "$t(action.removeFromQueue)"
|
"removeFromQueue": "$t(action.removeFromQueue)",
|
||||||
|
"shareItem": "udostępnij pozycję",
|
||||||
|
"showDetails": "zobacz informacje"
|
||||||
},
|
},
|
||||||
"albumDetail": {
|
"albumDetail": {
|
||||||
"moreFromArtist": "więcej od $t(entity.artist_one)",
|
"moreFromArtist": "więcej od $t(entity.artist_one)",
|
||||||
@@ -321,10 +350,14 @@
|
|||||||
"title": "$t(entity.albumArtist_other)"
|
"title": "$t(entity.albumArtist_other)"
|
||||||
},
|
},
|
||||||
"genreList": {
|
"genreList": {
|
||||||
"title": "$t(entity.genre_other)"
|
"title": "$t(entity.genre_other)",
|
||||||
|
"showAlbums": "pokaż $t(entity.genre_one) $t(entity.album_other)",
|
||||||
|
"showTracks": "pokaż $t(entity.genre_one) $t(entity.track_other)"
|
||||||
},
|
},
|
||||||
"albumList": {
|
"albumList": {
|
||||||
"title": "$t(entity.album_other)"
|
"title": "$t(entity.album_other)",
|
||||||
|
"artistAlbums": "albumy artysty {{artist}}",
|
||||||
|
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"nowPlaying": "teraz odtwarzane",
|
"nowPlaying": "teraz odtwarzane",
|
||||||
@@ -337,7 +370,8 @@
|
|||||||
"settings": "$t(common.setting_other)",
|
"settings": "$t(common.setting_other)",
|
||||||
"home": "$t(common.home)",
|
"home": "$t(common.home)",
|
||||||
"artists": "$t(entity.artist_other)",
|
"artists": "$t(entity.artist_other)",
|
||||||
"albumArtists": "$t(entity.albumArtist_other)"
|
"albumArtists": "$t(entity.albumArtist_other)",
|
||||||
|
"shared": "udostępnione $t(entity.playlist_other)"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"mostPlayed": "najczęściej odtwarzane",
|
"mostPlayed": "najczęściej odtwarzane",
|
||||||
@@ -353,7 +387,9 @@
|
|||||||
"windowTab": "okno"
|
"windowTab": "okno"
|
||||||
},
|
},
|
||||||
"trackList": {
|
"trackList": {
|
||||||
"title": "$t(entity.track_other)"
|
"title": "$t(entity.track_other)",
|
||||||
|
"artistTracks": "utwory przez {{artist}}",
|
||||||
|
"genreTracks": "\"{{genre}}\" $t(entity.track_other)"
|
||||||
},
|
},
|
||||||
"globalSearch": {
|
"globalSearch": {
|
||||||
"commands": {
|
"commands": {
|
||||||
@@ -365,6 +401,22 @@
|
|||||||
},
|
},
|
||||||
"playlistList": {
|
"playlistList": {
|
||||||
"title": "$t(entity.playlist_other)"
|
"title": "$t(entity.playlist_other)"
|
||||||
|
},
|
||||||
|
"albumArtistDetail": {
|
||||||
|
"topSongs": "popularne utwory",
|
||||||
|
"topSongsFrom": "popularne utwory z {{title}}",
|
||||||
|
"about": "O {{artist}}",
|
||||||
|
"recentReleases": "ostatnie wydania",
|
||||||
|
"viewAll": "zobacz wszystko",
|
||||||
|
"viewDiscography": "przeglądaj dyskografię",
|
||||||
|
"relatedArtists": "powiązane z $t(entity.artist_other)",
|
||||||
|
"appearsOn": "pojawia się na",
|
||||||
|
"viewAllTracks": "zobacz wszystko $t(entity.track_other)"
|
||||||
|
},
|
||||||
|
"itemDetail": {
|
||||||
|
"copyPath": "kopiuj ścieżkę do schowka",
|
||||||
|
"copiedPath": "ścieżka została skopiowana pomyślnie",
|
||||||
|
"openFile": "pokaż utwór w menedżerze plików"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
@@ -413,7 +465,7 @@
|
|||||||
"hotkey_zoomIn": "przybliż",
|
"hotkey_zoomIn": "przybliż",
|
||||||
"hotkey_browserForward": "przeglądarka w przód",
|
"hotkey_browserForward": "przeglądarka w przód",
|
||||||
"audioExclusiveMode_description": "włącz wyłączny tryb wyjścia. W tym trybie, system zwykle jest zablokowany i może odtwarzać tylko poprzez mpv",
|
"audioExclusiveMode_description": "włącz wyłączny tryb wyjścia. W tym trybie, system zwykle jest zablokowany i może odtwarzać tylko poprzez mpv",
|
||||||
"discordUpdateInterval": "{{discord}} interwał aktualizacji obszernej obecności",
|
"discordUpdateInterval": "{{discord}} interwał aktualizacji rich presence",
|
||||||
"fontType_optionBuiltIn": "wbudowana czcionka",
|
"fontType_optionBuiltIn": "wbudowana czcionka",
|
||||||
"hotkey_playbackPlayPause": "odtwarzaj / wstrzymaj",
|
"hotkey_playbackPlayPause": "odtwarzaj / wstrzymaj",
|
||||||
"hotkey_rate1": "oceń na 1 gwiazdkę",
|
"hotkey_rate1": "oceń na 1 gwiazdkę",
|
||||||
@@ -449,7 +501,7 @@
|
|||||||
"crossfadeDuration_description": "ustaw czas trwania efektu przenikania",
|
"crossfadeDuration_description": "ustaw czas trwania efektu przenikania",
|
||||||
"language": "język",
|
"language": "język",
|
||||||
"hotkey_toggleShuffle": "przełącz kolejność losową",
|
"hotkey_toggleShuffle": "przełącz kolejność losową",
|
||||||
"discordRichPresence_description": "włącz status odtwarzania w {{discord}} obszernie obecny. Klucze obrazów to {{icon}}, {{playing}} i {{paused}}. ",
|
"discordRichPresence_description": "włącz status odtwarzania w {{discord}} rich presence. Dzięki temu będą wyświetlane informacje takie jak: {{icon}}, {{playing}} i {{paused}}. ",
|
||||||
"audioDevice": "urządzenia dźwiękowe",
|
"audioDevice": "urządzenia dźwiękowe",
|
||||||
"hotkey_rate2": "oceń na 2 gwiazdki",
|
"hotkey_rate2": "oceń na 2 gwiazdki",
|
||||||
"exitToTray": "zamknij do zasobnika",
|
"exitToTray": "zamknij do zasobnika",
|
||||||
@@ -475,7 +527,7 @@
|
|||||||
"hotkey_zoomOut": "oddal",
|
"hotkey_zoomOut": "oddal",
|
||||||
"hotkey_unfavoriteCurrentSong": "usuń $t(common.currentSong) z ulubionych",
|
"hotkey_unfavoriteCurrentSong": "usuń $t(common.currentSong) z ulubionych",
|
||||||
"hotkey_rate0": "wyczyść oceny",
|
"hotkey_rate0": "wyczyść oceny",
|
||||||
"discordApplicationId": "id aplikacji {{discord}}",
|
"discordApplicationId": "ID aplikacji {{discord}}",
|
||||||
"applicationHotkeys_description": "ustaw skróty klawiszowe aplikacji. przełącz pole wyboru aby ustawić skrót globalny (tylko komputery)",
|
"applicationHotkeys_description": "ustaw skróty klawiszowe aplikacji. przełącz pole wyboru aby ustawić skrót globalny (tylko komputery)",
|
||||||
"floatingQueueArea_description": "wyświetl ikonę najechania kursorem po prawej stronie ekranu, aby wyświetlić kolejkę odtwarzania",
|
"floatingQueueArea_description": "wyświetl ikonę najechania kursorem po prawej stronie ekranu, aby wyświetlić kolejkę odtwarzania",
|
||||||
"hotkey_volumeMute": "wycisz",
|
"hotkey_volumeMute": "wycisz",
|
||||||
@@ -521,7 +573,7 @@
|
|||||||
"sampleRate_description": "wybierz wyjściową częstotliwość próbkowania, która ma być używana, jeśli wybrana częstotliwość próbkowania różni się od częstotliwości bieżącego utworu. wartość mniejsza niż 8000 spowoduje użycie częstotliwości domyślnej",
|
"sampleRate_description": "wybierz wyjściową częstotliwość próbkowania, która ma być używana, jeśli wybrana częstotliwość próbkowania różni się od częstotliwości bieżącego utworu. wartość mniejsza niż 8000 spowoduje użycie częstotliwości domyślnej",
|
||||||
"replayGainMode_optionNone": "$t(common.none)",
|
"replayGainMode_optionNone": "$t(common.none)",
|
||||||
"replayGainClipping": "wzmocnienie {{ReplayGain}}",
|
"replayGainClipping": "wzmocnienie {{ReplayGain}}",
|
||||||
"scrobble_description": "odtwarzanie scrobble na serwerze multimediów",
|
"scrobble_description": "przekazywanie informacji o odtwarzaniu (scrobbling) do twojego serwera multimediów",
|
||||||
"sidePlayQueueStyle": "boczny styl kolejki odtwarzania",
|
"sidePlayQueueStyle": "boczny styl kolejki odtwarzania",
|
||||||
"remoteUsername_description": "ustaw nazwę użytkownika dla serwera zdalnej kontroli. Jeśli nazwa użytkownika i hasło są puste, autoryzacja będzie wyłączona",
|
"remoteUsername_description": "ustaw nazwę użytkownika dla serwera zdalnej kontroli. Jeśli nazwa użytkownika i hasło są puste, autoryzacja będzie wyłączona",
|
||||||
"replayGainMode_optionAlbum": "$t(entity.album_one)",
|
"replayGainMode_optionAlbum": "$t(entity.album_one)",
|
||||||
@@ -564,7 +616,21 @@
|
|||||||
"clearCache_description": "\"twarde wyczyszczenie\" feishin. oprócz wyczyszczenia pamięci podręcznej feishin, opróżnij pamięć podręczną przeglądarki (zapisane obrazy i inne zasoby). dane i ustawienia serwera zostaną zachowane",
|
"clearCache_description": "\"twarde wyczyszczenie\" feishin. oprócz wyczyszczenia pamięci podręcznej feishin, opróżnij pamięć podręczną przeglądarki (zapisane obrazy i inne zasoby). dane i ustawienia serwera zostaną zachowane",
|
||||||
"clearQueryCache_description": "\"miękkie wyczyszczenie\" feishin. spowoduje to odświeżenie list odtwarzania, metadanych utworów i zresetowanie zapisanych tekstów. ustawienia, dane uwierzytelniające serwera i obrazy w pamięci podręcznej zostaną zachowane",
|
"clearQueryCache_description": "\"miękkie wyczyszczenie\" feishin. spowoduje to odświeżenie list odtwarzania, metadanych utworów i zresetowanie zapisanych tekstów. ustawienia, dane uwierzytelniające serwera i obrazy w pamięci podręcznej zostaną zachowane",
|
||||||
"buttonSize_description": "rozmiar przycisków paska odtwarzacza",
|
"buttonSize_description": "rozmiar przycisków paska odtwarzacza",
|
||||||
"clearCache": "wyczyść pamięć podręczną przeglądarki"
|
"clearCache": "wyczyść pamięć podręczną przeglądarki",
|
||||||
|
"playerAlbumArtResolution": "rozdzielczość okładki albumu odtwarzacza",
|
||||||
|
"externalLinks": "pokaż zewnętrzne linki",
|
||||||
|
"genreBehavior_description": "określa, czy kliknięcie gatunku domyślnie otwiera listę utworów czy albumów",
|
||||||
|
"mpvExtraParameters_help": "po jednym na linię",
|
||||||
|
"passwordStore": "hasła",
|
||||||
|
"passwordStore_description": "jakie hasło ma być używane. zmień to, jeśli masz problemy z przechowywaniem haseł.",
|
||||||
|
"playerAlbumArtResolution_description": "rozdzielczość podglądu okładki albumu w dużym odtwarzaczu. większa sprawia, że wygląda bardziej wyraziście, ale może spowolnić ładowanie. domyślnie 0, czyli auto",
|
||||||
|
"startMinimized": "uruchom zminimalizowany",
|
||||||
|
"startMinimized_description": "uruchom aplikację w zasobniku systemowym",
|
||||||
|
"clearCacheSuccess": "pamięć podręczna została wyczyszczona pomyślnie",
|
||||||
|
"genreBehavior": "domyślne zachowanie strony gatunek",
|
||||||
|
"externalLinks_description": "umożliwia wyświetlanie linków zewnętrznych (Last.fm, MusicBrainz) na stronach artystów/albumów",
|
||||||
|
"homeConfiguration": "konfiguracja strony głównej",
|
||||||
|
"homeConfiguration_description": "konfiguracja elementów wyświetlanych na stronie głównej i ich kolejności"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
@@ -578,7 +644,9 @@
|
|||||||
"gap": "$t(common.gap)",
|
"gap": "$t(common.gap)",
|
||||||
"tableColumns": "kolumny tabeli",
|
"tableColumns": "kolumny tabeli",
|
||||||
"autoFitColumns": "automatyczne dopasowanie kolumn",
|
"autoFitColumns": "automatyczne dopasowanie kolumn",
|
||||||
"size": "$t(common.size)"
|
"size": "$t(common.size)",
|
||||||
|
"itemSize": "rozmiar elementu (px)",
|
||||||
|
"itemGap": "odstęp między elementami (px)"
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"releaseDate": "data premiery",
|
"releaseDate": "data premiery",
|
||||||
@@ -606,7 +674,8 @@
|
|||||||
"discNumber": "numer płyty",
|
"discNumber": "numer płyty",
|
||||||
"favorite": "$t(common.favorite)",
|
"favorite": "$t(common.favorite)",
|
||||||
"year": "$t(common.year)",
|
"year": "$t(common.year)",
|
||||||
"albumArtist": "$t(entity.albumArtist_one)"
|
"albumArtist": "$t(entity.albumArtist_one)",
|
||||||
|
"codec": "$t(common.codec)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"column": {
|
"column": {
|
||||||
@@ -632,7 +701,8 @@
|
|||||||
"path": "ścieżka",
|
"path": "ścieżka",
|
||||||
"discNumber": "płyta",
|
"discNumber": "płyta",
|
||||||
"channels": "$t(common.channel_other)",
|
"channels": "$t(common.channel_other)",
|
||||||
"size": "$t(common.size)"
|
"size": "$t(common.size)",
|
||||||
|
"codec": "$t(common.codec)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,7 +212,23 @@
|
|||||||
"songCount": "contador de músicas",
|
"songCount": "contador de músicas",
|
||||||
"toYear": "até o ano",
|
"toYear": "até o ano",
|
||||||
"random": "aleatório",
|
"random": "aleatório",
|
||||||
"search": "buscar"
|
"search": "buscar",
|
||||||
|
"lastPlayed": "última tocada",
|
||||||
|
"isCompilation": "é compilação",
|
||||||
|
"trackNumber": "faixa",
|
||||||
|
"communityRating": "Nota da comunidade",
|
||||||
|
"isPublic": "é público",
|
||||||
|
"playCount": "contador de execuções",
|
||||||
|
"recentlyUpdated": "atualizado recentemente",
|
||||||
|
"dateAdded": "data de adição",
|
||||||
|
"isRecentlyPlayed": "foi tocado recentemente",
|
||||||
|
"albumArtist": "$t(entity.albumArtist_one)",
|
||||||
|
"recentlyAdded": "adicionado recentemente",
|
||||||
|
"releaseDate": "data de lançamento",
|
||||||
|
"recentlyPlayed": "tocado recentemente",
|
||||||
|
"criticRating": "Nota da crítica",
|
||||||
|
"isFavorited": "é favoritado",
|
||||||
|
"releaseYear": "ano de lançamento"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"playbackFetchNoResults": "nenhuma música encontrada",
|
"playbackFetchNoResults": "nenhuma música encontrada",
|
||||||
@@ -257,7 +273,13 @@
|
|||||||
"folderWithCount_other": "{{count}} pastas",
|
"folderWithCount_other": "{{count}} pastas",
|
||||||
"genreWithCount_one": "{{count}} gênero",
|
"genreWithCount_one": "{{count}} gênero",
|
||||||
"genreWithCount_many": "{{count}} gêneros",
|
"genreWithCount_many": "{{count}} gêneros",
|
||||||
"genreWithCount_other": "{{count}} gêneros"
|
"genreWithCount_other": "{{count}} gêneros",
|
||||||
|
"trackWithCount_one": "faixa",
|
||||||
|
"trackWithCount_many": "faixas",
|
||||||
|
"trackWithCount_other": "faixas",
|
||||||
|
"track_one": "faixa",
|
||||||
|
"track_many": "faixas",
|
||||||
|
"track_other": "faixas"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "reinicie o servidor para aplicar a nova porta",
|
"remotePortWarning": "reinicie o servidor para aplicar a nova porta",
|
||||||
|
|||||||
+269
-60
@@ -15,14 +15,18 @@
|
|||||||
"deselectAll": "снять выделение",
|
"deselectAll": "снять выделение",
|
||||||
"moveToBottom": "вниз",
|
"moveToBottom": "вниз",
|
||||||
"setRating": "оценить",
|
"setRating": "оценить",
|
||||||
"toggleSmartPlaylistEditor": "вкл/выкл $t(entity.smartPlaylist) редактор",
|
"toggleSmartPlaylistEditor": "вкл./откл. редактор $t(entity.smartPlaylist)",
|
||||||
"removeFromFavorites": "удалить из $t(entity.favorite_other)"
|
"removeFromFavorites": "удалить из $t(entity.favorite_other)",
|
||||||
|
"openIn": {
|
||||||
|
"lastfm": "открыть на Last.fm",
|
||||||
|
"musicbrainz": "открыть на MusicBrainz"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"backward": "назад",
|
"backward": "назад",
|
||||||
"increase": "увеличить",
|
"increase": "увеличить",
|
||||||
"rating": "рейтинг",
|
"rating": "рейтинг",
|
||||||
"bpm": "ударов в мин.",
|
"bpm": "уд./мин.",
|
||||||
"refresh": "обновить",
|
"refresh": "обновить",
|
||||||
"unknown": "неизвестно",
|
"unknown": "неизвестно",
|
||||||
"areYouSure": "вы уверены?",
|
"areYouSure": "вы уверены?",
|
||||||
@@ -34,19 +38,19 @@
|
|||||||
"currentSong": "текущий $t(entity.track_one)",
|
"currentSong": "текущий $t(entity.track_one)",
|
||||||
"collapse": "закрыть",
|
"collapse": "закрыть",
|
||||||
"trackNumber": "трек",
|
"trackNumber": "трек",
|
||||||
"descending": "убывающий",
|
"descending": "убывание",
|
||||||
"add": "добавить",
|
"add": "добавить",
|
||||||
"gap": "промежуток",
|
"gap": "промежуток",
|
||||||
"ascending": "возрастающий",
|
"ascending": "возрастанию",
|
||||||
"dismiss": "отклонить",
|
"dismiss": "отклонить",
|
||||||
"year": "год",
|
"year": "год",
|
||||||
"manage": "управлять",
|
"manage": "управлять",
|
||||||
"limit": "лимит",
|
"limit": "ограничение",
|
||||||
"minimize": "минимизировать",
|
"minimize": "минимизировать",
|
||||||
"modified": "изменено",
|
"modified": "изменено",
|
||||||
"duration": "продолжительность",
|
"duration": "длительность",
|
||||||
"name": "имя",
|
"name": "имя",
|
||||||
"maximize": "максимизировать",
|
"maximize": "развернуть",
|
||||||
"decrease": "уменьшить",
|
"decrease": "уменьшить",
|
||||||
"ok": "ок",
|
"ok": "ок",
|
||||||
"description": "описание",
|
"description": "описание",
|
||||||
@@ -60,7 +64,7 @@
|
|||||||
"forward": "вперёд",
|
"forward": "вперёд",
|
||||||
"delete": "удалить",
|
"delete": "удалить",
|
||||||
"cancel": "отменить",
|
"cancel": "отменить",
|
||||||
"forceRestartRequired": "перезапустите приложение, чтобы применить изменения... закройте уведомление, чтобы перезапустить приложение",
|
"forceRestartRequired": "перезапустите приложение, чтобы применить изменения... закройте уведомление для перезапуска",
|
||||||
"setting": "настройка",
|
"setting": "настройка",
|
||||||
"version": "версия",
|
"version": "версия",
|
||||||
"title": "название",
|
"title": "название",
|
||||||
@@ -76,14 +80,14 @@
|
|||||||
"action_many": "действий",
|
"action_many": "действий",
|
||||||
"playerMustBePaused": "воспроизведение должно быть остановлено",
|
"playerMustBePaused": "воспроизведение должно быть остановлено",
|
||||||
"confirm": "подтвердить",
|
"confirm": "подтвердить",
|
||||||
"resetToDefault": "сбросить к настройкам по умолчанию",
|
"resetToDefault": "по умолчанию",
|
||||||
"home": "Главная страница",
|
"home": "главная страница",
|
||||||
"comingSoon": "скоро будет…",
|
"comingSoon": "скоро будет…",
|
||||||
"reset": "сбросить",
|
"reset": "сбросить",
|
||||||
"channel_one": "канал",
|
"channel_one": "канал",
|
||||||
"channel_few": "канала",
|
"channel_few": "канала",
|
||||||
"channel_many": "каналов",
|
"channel_many": "каналов",
|
||||||
"disable": "выключить",
|
"disable": "отключить",
|
||||||
"sortOrder": "порядок",
|
"sortOrder": "порядок",
|
||||||
"menu": "меню",
|
"menu": "меню",
|
||||||
"restartRequired": "необходим перезапуск приложения",
|
"restartRequired": "необходим перезапуск приложения",
|
||||||
@@ -91,7 +95,7 @@
|
|||||||
"noResultsFromQuery": "нет результатов",
|
"noResultsFromQuery": "нет результатов",
|
||||||
"quit": "выйти",
|
"quit": "выйти",
|
||||||
"expand": "расширить",
|
"expand": "расширить",
|
||||||
"search": "Поиск",
|
"search": "поиск",
|
||||||
"saveAs": "сохранить как",
|
"saveAs": "сохранить как",
|
||||||
"disc": "диск",
|
"disc": "диск",
|
||||||
"yes": "да",
|
"yes": "да",
|
||||||
@@ -99,7 +103,13 @@
|
|||||||
"size": "размер",
|
"size": "размер",
|
||||||
"biography": "биография",
|
"biography": "биография",
|
||||||
"note": "заметка",
|
"note": "заметка",
|
||||||
"none": "нет"
|
"none": "нет",
|
||||||
|
"mbid": "MusicBrainz ID",
|
||||||
|
"reload": "перезагрузить",
|
||||||
|
"preview": "просмотр",
|
||||||
|
"codec": "кодек",
|
||||||
|
"share": "поделиться",
|
||||||
|
"close": "закрыть"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"album_one": "альбом",
|
"album_one": "альбом",
|
||||||
@@ -161,7 +171,9 @@
|
|||||||
"gap": "$t(common.gap)",
|
"gap": "$t(common.gap)",
|
||||||
"tableColumns": "столбцы таблицы",
|
"tableColumns": "столбцы таблицы",
|
||||||
"autoFitColumns": "автоматически расставить столбцы",
|
"autoFitColumns": "автоматически расставить столбцы",
|
||||||
"size": "$t(common.size)"
|
"size": "$t(common.size)",
|
||||||
|
"itemSize": "рамер элементов (px)",
|
||||||
|
"itemGap": "отступ между элементами (px)"
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"releaseDate": "дата выхода",
|
"releaseDate": "дата выхода",
|
||||||
@@ -189,7 +201,9 @@
|
|||||||
"discNumber": "номер диска",
|
"discNumber": "номер диска",
|
||||||
"favorite": "$t(common.favorite)",
|
"favorite": "$t(common.favorite)",
|
||||||
"year": "$t(common.year)",
|
"year": "$t(common.year)",
|
||||||
"albumArtist": "$t(entity.albumArtist_one)"
|
"albumArtist": "$t(entity.albumArtist_one)",
|
||||||
|
"codec": "$t(common.codec)",
|
||||||
|
"songCount": "$t(entity.track_other)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"column": {
|
"column": {
|
||||||
@@ -205,29 +219,43 @@
|
|||||||
"genre": "$t(entity.genre_one)",
|
"genre": "$t(entity.genre_one)",
|
||||||
"path": "путь",
|
"path": "путь",
|
||||||
"discNumber": "диск",
|
"discNumber": "диск",
|
||||||
"size": "$t(common.size)"
|
"size": "$t(common.size)",
|
||||||
|
"dateAdded": "дата добавления",
|
||||||
|
"album": "альбом",
|
||||||
|
"albumArtist": "исполнитель альбома",
|
||||||
|
"biography": "биография",
|
||||||
|
"codec": "$t(common.codec)",
|
||||||
|
"comment": "комментарий",
|
||||||
|
"albumCount": "$t(entity.album_other)",
|
||||||
|
"artist": "$t(entity.artist_one)",
|
||||||
|
"bitrate": "битрейт",
|
||||||
|
"channels": "$t(common.channel_other)",
|
||||||
|
"bpm": "bpm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "перезапустить сервер для применения нового порта",
|
"remotePortWarning": "перезапустить сервер для применения нового порта",
|
||||||
"systemFontError": "произошла ошибка при попытке получить системные шрифты",
|
"systemFontError": "произошла ошибка при попытке получить системные шрифты",
|
||||||
"playbackError": "произошла ошибка при попытке проиграть медиа",
|
"playbackError": "произошла ошибка при попытке проиграть медиа",
|
||||||
"endpointNotImplementedError": "запрос {{endpoint}} is not implemented for {{serverType}}",
|
"endpointNotImplementedError": "запрос {{endpoint}} не реализован для {{serverType}}",
|
||||||
"remotePortError": "произошла ошибка при попытке установить порт удаленного сервера",
|
"remotePortError": "произошла ошибка при попытке установить порт удаленного сервера",
|
||||||
"serverRequired": "необходим сервер",
|
"serverRequired": "необходим сервер",
|
||||||
"authenticationFailed": "аутентификация завершилась с ошибкой",
|
"authenticationFailed": "авторизация завершилась с ошибкой",
|
||||||
"apiRouteError": "невозможно выполнить запрос",
|
"apiRouteError": "невозможно выполнить запрос",
|
||||||
"genericError": "произошла ошибка",
|
"genericError": "произошла ошибка",
|
||||||
"credentialsRequired": "необходимы учётные данные",
|
"credentialsRequired": "необходимы учётные данные",
|
||||||
"sessionExpiredError": "ваш сеанс истек",
|
"sessionExpiredError": "ваш сеанс истёк",
|
||||||
"remoteEnableError": "ошибка произошла при попытке $t(common.enable) удаленного сервера",
|
"remoteEnableError": "ошибка произошла при попытке $t(common.enable) удалённый сервер",
|
||||||
"localFontAccessDenied": "не получилось получить доступ к шрифтам",
|
"localFontAccessDenied": "не получилось получить доступ к шрифтам",
|
||||||
"serverNotSelectedError": "не выбран сервер",
|
"serverNotSelectedError": "не выбран сервер",
|
||||||
"remoteDisableError": "ошибка произошла при попытке $t(common.disable) удаленного сервера",
|
"remoteDisableError": "ошибка произошла при попытке $t(common.disable) удалённый сервер",
|
||||||
"mpvRequired": "Необходим MPV",
|
"mpvRequired": "необходим MPV",
|
||||||
"audioDeviceFetchError": "произошла ошибка с аудиоустройством",
|
"audioDeviceFetchError": "произошла ошибка с аудиоустройством",
|
||||||
"invalidServer": "недействительный сервер",
|
"invalidServer": "недействительный сервер",
|
||||||
"loginRateError": "слишком много попыток входа, пожалуйста, попробуйте еще раз через несколько секунд"
|
"loginRateError": "слишком много попыток входа, пожалуйста, попробуйте ещё раз через несколько секунд",
|
||||||
|
"openError": "не удалось открыть файл",
|
||||||
|
"badAlbum": "вы видите эту страницу из-за того, что эта песня не входит в альбом. скорее всего, вы видите эту ошибку, так как песня находится в корневой директории папки с музыкой. jellyfin группирует треки только по папкам.",
|
||||||
|
"networkError": "возникла ошибка сети"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"isCompilation": "сборник",
|
"isCompilation": "сборник",
|
||||||
@@ -238,14 +266,14 @@
|
|||||||
"favorited": "любимый",
|
"favorited": "любимый",
|
||||||
"albumArtist": "$t(entity.albumArtist_one)",
|
"albumArtist": "$t(entity.albumArtist_one)",
|
||||||
"isFavorited": "любимый",
|
"isFavorited": "любимый",
|
||||||
"bpm": "ударов в мин.",
|
"bpm": "уд./мин.",
|
||||||
"disc": "диск",
|
"disc": "диск",
|
||||||
"biography": "биография",
|
"biography": "биография",
|
||||||
"artist": "$t(entity.artist_one)",
|
"artist": "$t(entity.artist_one)",
|
||||||
"duration": "продолжительность",
|
"duration": "длительность",
|
||||||
"fromYear": "из года",
|
"fromYear": "из года",
|
||||||
"criticRating": "рейтинг критиков",
|
"criticRating": "рейтинг критиков",
|
||||||
"mostPlayed": "наибольшое кол-во воспроизведений",
|
"mostPlayed": "самое воспроизводимое",
|
||||||
"comment": "комментировать",
|
"comment": "комментировать",
|
||||||
"playCount": "кол-во воспроизведений",
|
"playCount": "кол-во воспроизведений",
|
||||||
"recentlyUpdated": "недавно обновлено",
|
"recentlyUpdated": "недавно обновлено",
|
||||||
@@ -254,17 +282,17 @@
|
|||||||
"owner": "$t(common.owner)",
|
"owner": "$t(common.owner)",
|
||||||
"title": "название",
|
"title": "название",
|
||||||
"rating": "рейтинг",
|
"rating": "рейтинг",
|
||||||
"search": "Поиск",
|
"search": "поиск",
|
||||||
"genre": "$t(entity.genre_one)",
|
"genre": "$t(entity.genre_one)",
|
||||||
"recentlyAdded": "недавно добавлено",
|
"recentlyAdded": "недавно добавлено",
|
||||||
"note": "заметка",
|
"note": "заметка",
|
||||||
"name": "название",
|
"name": "название",
|
||||||
"releaseDate": "дата выхода",
|
"releaseDate": "дата выхода",
|
||||||
"albumCount": "$t(entity.album_other) кол-во",
|
"albumCount": "кол-во $t(entity.album_other)",
|
||||||
"path": "путь",
|
"path": "путь",
|
||||||
"isRecentlyPlayed": "недавно проигрывалась",
|
"isRecentlyPlayed": "недавно проигрывалась",
|
||||||
"releaseYear": "год выхода",
|
"releaseYear": "год выхода",
|
||||||
"id": "#",
|
"id": "№",
|
||||||
"songCount": "кол-во песен",
|
"songCount": "кол-во песен",
|
||||||
"isPublic": "публичный",
|
"isPublic": "публичный",
|
||||||
"random": "случайный",
|
"random": "случайный",
|
||||||
@@ -277,16 +305,16 @@
|
|||||||
"repeat_all": "повтор всех",
|
"repeat_all": "повтор всех",
|
||||||
"stop": "остановить",
|
"stop": "остановить",
|
||||||
"repeat": "повтор",
|
"repeat": "повтор",
|
||||||
"queue_remove": "удалить выделенные",
|
"queue_remove": "удалить выбранное",
|
||||||
"playRandom": "случайные песни",
|
"playRandom": "случайные песни",
|
||||||
"skip": "пропустить",
|
"skip": "пропустить",
|
||||||
"previous": "предыдущий",
|
"previous": "предыдущий",
|
||||||
"toggleFullscreenPlayer": "включить полноэкранный режим",
|
"toggleFullscreenPlayer": "включить полноэкранный режим",
|
||||||
"skip_back": "назад",
|
"skip_back": "назад",
|
||||||
"favorite": "любимый",
|
"favorite": "любимый",
|
||||||
"next": "следующее",
|
"next": "следующий",
|
||||||
"shuffle": "перемешать",
|
"shuffle": "перемешать",
|
||||||
"playbackFetchNoResults": "нет песен",
|
"playbackFetchNoResults": "песни не найдены",
|
||||||
"playbackFetchInProgress": "загрузка песен..",
|
"playbackFetchInProgress": "загрузка песен..",
|
||||||
"addNext": "добавить следующий",
|
"addNext": "добавить следующий",
|
||||||
"playbackSpeed": "скорость воспроизведения",
|
"playbackSpeed": "скорость воспроизведения",
|
||||||
@@ -297,8 +325,8 @@
|
|||||||
"queue_clear": "очистить очередь",
|
"queue_clear": "очистить очередь",
|
||||||
"muted": "звук отключён",
|
"muted": "звук отключён",
|
||||||
"unfavorite": "убрать из любимых",
|
"unfavorite": "убрать из любимых",
|
||||||
"queue_moveToTop": "переместить выделение вниз",
|
"queue_moveToTop": "переместить выделенное вниз",
|
||||||
"queue_moveToBottom": "переместить выделение вверх",
|
"queue_moveToBottom": "переместить выделенное вверх",
|
||||||
"shuffle_off": "перемешивание выключено",
|
"shuffle_off": "перемешивание выключено",
|
||||||
"addLast": "добавить последний",
|
"addLast": "добавить последний",
|
||||||
"mute": "отключить звук",
|
"mute": "отключить звук",
|
||||||
@@ -330,7 +358,9 @@
|
|||||||
"unsynchronized": "несинхронизировано",
|
"unsynchronized": "несинхронизировано",
|
||||||
"lyricAlignment": "выравнивание слов песни",
|
"lyricAlignment": "выравнивание слов песни",
|
||||||
"useImageAspectRatio": "использовать соотношение сторон изображения",
|
"useImageAspectRatio": "использовать соотношение сторон изображения",
|
||||||
"lyricGap": "пробел между словами"
|
"lyricGap": "пробел между словами",
|
||||||
|
"dynamicIsImage": "включить фоновое изображение",
|
||||||
|
"dynamicImageBlur": "сила размытия изображения"
|
||||||
},
|
},
|
||||||
"upNext": "следующее",
|
"upNext": "следующее",
|
||||||
"lyrics": "слова песни",
|
"lyrics": "слова песни",
|
||||||
@@ -364,7 +394,9 @@
|
|||||||
"addFavorite": "$t(action.addToFavorites)",
|
"addFavorite": "$t(action.addToFavorites)",
|
||||||
"play": "$t(player.play)",
|
"play": "$t(player.play)",
|
||||||
"numberSelected": "{{count}} выбрано",
|
"numberSelected": "{{count}} выбрано",
|
||||||
"removeFromQueue": "$t(action.removeFromQueue)"
|
"removeFromQueue": "$t(action.removeFromQueue)",
|
||||||
|
"showDetails": "получить информацию",
|
||||||
|
"shareItem": "поделиться"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"mostPlayed": "наибольшее кол-во воспроизведений",
|
"mostPlayed": "наибольшее кол-во воспроизведений",
|
||||||
@@ -374,7 +406,7 @@
|
|||||||
"recentlyPlayed": "недавно прослушано"
|
"recentlyPlayed": "недавно прослушано"
|
||||||
},
|
},
|
||||||
"albumDetail": {
|
"albumDetail": {
|
||||||
"moreFromArtist": "больше из жанра $t(entity.genre_one)",
|
"moreFromArtist": "больше от $t(entity.artist_one)",
|
||||||
"moreFromGeneric": "больше из {{item}}"
|
"moreFromGeneric": "больше из {{item}}"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
@@ -387,10 +419,13 @@
|
|||||||
"title": "$t(entity.albumArtist_other)"
|
"title": "$t(entity.albumArtist_other)"
|
||||||
},
|
},
|
||||||
"genreList": {
|
"genreList": {
|
||||||
"title": "$t(entity.genre_other)"
|
"title": "$t(entity.genre_other)",
|
||||||
|
"showAlbums": "показать $t(entity.genre_one) $t(entity.album_other)",
|
||||||
|
"showTracks": "показать $t(entity.genre_one) $t(entity.track_other)"
|
||||||
},
|
},
|
||||||
"trackList": {
|
"trackList": {
|
||||||
"title": "$t(entity.track_other)"
|
"title": "$t(entity.track_other)",
|
||||||
|
"genreTracks": "\"{{genre}}\" $t(entity.track_other)"
|
||||||
},
|
},
|
||||||
"globalSearch": {
|
"globalSearch": {
|
||||||
"commands": {
|
"commands": {
|
||||||
@@ -404,14 +439,32 @@
|
|||||||
"title": "$t(entity.playlist_other)"
|
"title": "$t(entity.playlist_other)"
|
||||||
},
|
},
|
||||||
"albumList": {
|
"albumList": {
|
||||||
"title": "$t(entity.album_other)"
|
"title": "$t(entity.album_other)",
|
||||||
|
"artistAlbums": "альбомы {{artist}}",
|
||||||
|
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)"
|
||||||
|
},
|
||||||
|
"albumArtistDetail": {
|
||||||
|
"topSongs": "популярные треки",
|
||||||
|
"viewAll": "посмотреть всё",
|
||||||
|
"appearsOn": "появляется в",
|
||||||
|
"viewDiscography": "посмотреть дискографию",
|
||||||
|
"relatedArtists": "похож на $t(entity.artist_other)",
|
||||||
|
"viewAllTracks": "посмотреть все $t(entity.track_other)",
|
||||||
|
"recentReleases": "недавние релизы",
|
||||||
|
"about": "О {{artist}}",
|
||||||
|
"topSongsFrom": "популярные треки из {{title}}"
|
||||||
|
},
|
||||||
|
"itemDetail": {
|
||||||
|
"copyPath": "скопировать путь в буфер обмена",
|
||||||
|
"openFile": "открыть трек в менеджере файлов",
|
||||||
|
"copiedPath": "путь успешно скопирован"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"deletePlaylist": {
|
"deletePlaylist": {
|
||||||
"title": "удалить $t(entity.playlist_one)",
|
"title": "удалить $t(entity.playlist_one)",
|
||||||
"success": "$t(entity.playlist_one) успешно удалён",
|
"success": "$t(entity.playlist_one) успешно удалён",
|
||||||
"input_confirm": "напишите название $t(entity.playlist_one), чтобы подтвердить действие"
|
"input_confirm": "напишите название $t(entity.playlist_one)а для подтверждения"
|
||||||
},
|
},
|
||||||
"createPlaylist": {
|
"createPlaylist": {
|
||||||
"input_description": "$t(common.description)",
|
"input_description": "$t(common.description)",
|
||||||
@@ -426,16 +479,16 @@
|
|||||||
"input_username": "пользователь",
|
"input_username": "пользователь",
|
||||||
"input_url": "url",
|
"input_url": "url",
|
||||||
"input_password": "пароль",
|
"input_password": "пароль",
|
||||||
"input_legacyAuthentication": "включить старую аутентификацию",
|
"input_legacyAuthentication": "включить старую авторизацию",
|
||||||
"input_name": "название сервера",
|
"input_name": "название сервера",
|
||||||
"success": "сервер добавлен успешно",
|
"success": "сервер добавлен успешно",
|
||||||
"input_savePassword": "сохранить пароль",
|
"input_savePassword": "сохранить пароль",
|
||||||
"ignoreSsl": "ignore ssl $t(common.restartRequired)",
|
"ignoreSsl": "игнорирование ssl ($t(common.restartRequired))",
|
||||||
"ignoreCors": "$t(common.restartRequired)",
|
"ignoreCors": "игнорирование корсетов ($t(common.restartRequired))",
|
||||||
"error_savePassword": "произошла ошибка во время попытки сохранения пароля"
|
"error_savePassword": "произошла ошибка во время попытки сохранения пароля"
|
||||||
},
|
},
|
||||||
"addToPlaylist": {
|
"addToPlaylist": {
|
||||||
"success": "добавлено(а) {{message}} $t(entity.track_other) в {{numOfPlaylists}} $t(entity.playlist_other)",
|
"success": "добавлено: $t(entity.trackWithCount, {\"count\": {{message}} }) в $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||||
"title": "добавить в $t(entity.playlist_one)",
|
"title": "добавить в $t(entity.playlist_one)",
|
||||||
"input_skipDuplicates": "пропустить дубликаты",
|
"input_skipDuplicates": "пропустить дубликаты",
|
||||||
"input_playlists": "$t(entity.playlist_other)"
|
"input_playlists": "$t(entity.playlist_other)"
|
||||||
@@ -455,35 +508,191 @@
|
|||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "редактировать $t(entity.playlist_one)"
|
"title": "редактировать $t(entity.playlist_one)"
|
||||||
|
},
|
||||||
|
"shareItem": {
|
||||||
|
"success": "ссылка скопирована в буфер обмена (нажмите здесь, чтобы открыть)",
|
||||||
|
"expireInvalid": "время истечения срока действия должно быть в будущем",
|
||||||
|
"createFailed": "не удалось создать ссылку для общего доступа (общий доступ включён?)",
|
||||||
|
"allowDownloading": "разрешить скачивание",
|
||||||
|
"setExpiration": "установить срок действия",
|
||||||
|
"description": "описание"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"accentColor": "цвет акцента",
|
"accentColor": "цвет акцента",
|
||||||
"accentColor_description": "устанавливает цвет акцента для приложения",
|
"accentColor_description": "устанавливает цвет акцента для приложения",
|
||||||
"applicationHotkeys": "горячие клавиши приложения",
|
"applicationHotkeys": "горячие клавиши приложения",
|
||||||
"crossfadeStyle_description": "Выберите вид эффекта crossfade для аудиоплеера",
|
"crossfadeStyle_description": "выберите вид эффекта crossfade для аудиоплеера",
|
||||||
"enableRemote_description": "Включает сервер удалённого управления для управления воспроизведением с помощью других устройств",
|
"enableRemote_description": "включает сервер удалённого управления для управления воспроизведением с помощью других устройств",
|
||||||
"fontType_optionSystem": "Системный шрифт",
|
"fontType_optionSystem": "системный",
|
||||||
"mpvExecutablePath_description": "Укажите папку, в которой находится исполняющий файл аудиоплеера MPV",
|
"mpvExecutablePath_description": "укажите папку, в которой находится исполняющий файл аудиоплеера MPV. если оставить пустым, будет использоваться путь по умолчанию",
|
||||||
"crossfadeStyle": "Вид эффекта crossfade",
|
"crossfadeStyle": "Вид эффекта crossfade",
|
||||||
"fontType_optionBuiltIn": "Встроенный в приложение",
|
"fontType_optionBuiltIn": "встроенный",
|
||||||
"disableLibraryUpdateOnStartup": "Отключить проверку новых версий при запуске приложения",
|
"disableLibraryUpdateOnStartup": "Отключить проверку новых версий при запуске приложения",
|
||||||
"minimizeToTray_description": "Сворачивать приложение в панель уведомлений",
|
"minimizeToTray_description": "Сворачивать приложение в панель уведомлений",
|
||||||
"audioPlayer_description": "Укажите - какой аудиоплеер использовать для воспроизведения",
|
"audioPlayer_description": "Укажите - какой аудиоплеер использовать для воспроизведения",
|
||||||
"disableAutomaticUpdates": "Отключить проверку обновлений",
|
"disableAutomaticUpdates": "отключить проверку обновлений",
|
||||||
"exitToTray_description": "При закрытии приложения - оно останется в панели уведомлений",
|
"exitToTray_description": "При закрытии приложения - оно останется в панели уведомлений",
|
||||||
"fontType_optionCustom": "Пользовательский шрифт",
|
"fontType_optionCustom": "пользовательский",
|
||||||
"remotePassword": "Пароль к серверу удалённого управления",
|
"remotePassword": "пароль к серверу удалённого управления",
|
||||||
"font": "Шрифт",
|
"font": "Шрифт",
|
||||||
"crossfadeDuration_description": "Укажите длительность эффекта crossfade",
|
"crossfadeDuration_description": "Укажите длительность эффекта crossfade",
|
||||||
"mpvExecutablePath": "Папка с аудиоплеером MPV",
|
"mpvExecutablePath": "папка с аудиоплеером MPV",
|
||||||
"exitToTray": "Сворачивать в панель уведомлений при закрытии",
|
"exitToTray": "сворачивать в панель уведомлений при закрытии",
|
||||||
"enableRemote": "Включить сервер удалённого управления",
|
"enableRemote": "включить сервер удалённого управления",
|
||||||
"fontType": "Источник шрифта",
|
"fontType": "тип шрифта",
|
||||||
"crossfadeDuration": "Длительность эффекта crossfade",
|
"crossfadeDuration": "Длительность эффекта crossfade",
|
||||||
"audioPlayer": "Аудиоплеер",
|
"audioPlayer": "Аудиоплеер",
|
||||||
"minimizeToTray": "Сворачивать в панель уведомлений",
|
"minimizeToTray": "сворачивать в панель уведомлений",
|
||||||
"font_description": "Выберите - какой шрифт использовать в приложении",
|
"font_description": "Выберите - какой шрифт использовать в приложении",
|
||||||
"remoteUsername": "Имя пользователя для доступа к серверу удалённого управления"
|
"remoteUsername": "имя пользователя для доступа к серверу удалённого управления",
|
||||||
|
"buttonSize_description": "размер кнопок в панели управления воспроизведением",
|
||||||
|
"clearCache": "очистить кэш браузера",
|
||||||
|
"clearQueryCache": "очистить кэш feishin",
|
||||||
|
"audioDevice": "устройство воспроизведения",
|
||||||
|
"audioDevice_description": "выберите устройство воспроизведения (только в режиме аудиоплеера web)",
|
||||||
|
"buttonSize": "размер кнопок панели управления воспроизведением",
|
||||||
|
"hotkey_volumeDown": "уменьшить громкость",
|
||||||
|
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||||
|
"theme_description": "устанавливает тему, которая будет использоваться приложением",
|
||||||
|
"passwordStore": "хранилище паролей/секретов",
|
||||||
|
"sidebarPlaylistList": "список плейлистов в боковой панели",
|
||||||
|
"windowBarStyle_description": "выберите стиль заголовка окна",
|
||||||
|
"followLyric": "следовать тексту трека",
|
||||||
|
"volumeWheelStep": "шаг регулировки громкости колёсиком мыши",
|
||||||
|
"windowBarStyle": "стиль заголовка окна",
|
||||||
|
"hotkey_zoomOut": "уменьшить масштаб",
|
||||||
|
"playbackStyle_optionCrossFade": "затухание",
|
||||||
|
"replayGainMode": "режим {{ReplayGain}}",
|
||||||
|
"replayGainMode_optionAlbum": "$t(entity.album_one)",
|
||||||
|
"replayGainMode_optionNone": "$t(common.none)",
|
||||||
|
"replayGainMode_optionTrack": "$t(entity.track_one)",
|
||||||
|
"clearQueryCache_description": "\"мягкая очистка\" feishin. при выполнении обновляются плейлисты, метаданные треков, но сохранённые тексты треков сбрасываются. настройки, учётные данные и кэшированные изображения сохраняются",
|
||||||
|
"hotkey_favoriteCurrentSong": "добавить $t(common.currentSong) в избранное",
|
||||||
|
"genreBehavior": "поведения страницы жанров",
|
||||||
|
"globalMediaHotkeys": "глобальные мультимедийные горячие клавиши",
|
||||||
|
"hotkey_browserForward": "кнопка браузера \"вперёд\"",
|
||||||
|
"hotkey_favoritePreviousSong": "добавить $t(common.previousSong) в избранное",
|
||||||
|
"hotkey_globalSearch": "глобальный поиск",
|
||||||
|
"hotkey_playbackNext": "следующий трек",
|
||||||
|
"hotkey_playbackPause": "пауза",
|
||||||
|
"hotkey_playbackPlay": "прослушать",
|
||||||
|
"hotkey_playbackPlayPause": "прослушать / пауза",
|
||||||
|
"hotkey_playbackPrevious": "предыдущий трек",
|
||||||
|
"hotkey_playbackStop": "остановить",
|
||||||
|
"hotkey_rate0": "очистить оценку",
|
||||||
|
"hotkey_rate1": "оценить в 1 звезду",
|
||||||
|
"hotkey_rate2": "оценить в 2 звезды",
|
||||||
|
"hotkey_rate3": "оценить в 3 звезды",
|
||||||
|
"hotkey_rate4": "оценить в 4 звезды",
|
||||||
|
"hotkey_rate5": "оценить в 5 звёзд",
|
||||||
|
"hotkey_skipForward": "перемотать вперёд",
|
||||||
|
"hotkey_toggleCurrentSongFavorite": "добавить/удалить $t(common.currentSong) в избранное",
|
||||||
|
"hotkey_toggleFullScreenPlayer": "включение/выключение полноэкранного плеера",
|
||||||
|
"hotkey_togglePreviousSongFavorite": "добавить/удалить $t(common.previousSong) в избранное",
|
||||||
|
"hotkey_toggleRepeat": "переключить режим повтора",
|
||||||
|
"hotkey_toggleShuffle": "переключить перемешивание",
|
||||||
|
"hotkey_unfavoriteCurrentSong": "удалить $t(common.currentSong) из избранного",
|
||||||
|
"hotkey_unfavoritePreviousSong": "удалить $t(common.previousSong) из избранного",
|
||||||
|
"hotkey_volumeUp": "увеличить громкость",
|
||||||
|
"hotkey_zoomIn": "увеличить масштаб",
|
||||||
|
"language": "язык",
|
||||||
|
"lyricFetchProvider_description": "выберите источники для получения текстов песен. порядок источников соответствует очередности их запросов",
|
||||||
|
"minimumScrobblePercentage_description": "минимальный процент прослушивания трека, прежде чем он будет засчитан как прослушанный",
|
||||||
|
"minimumScrobbleSeconds_description": "минимальное время прослушивания трека в секундах, прежде чем он будет засчитан как прослушанный",
|
||||||
|
"playbackStyle_optionNormal": "нормальный",
|
||||||
|
"mpvExtraParameters": "параметры mpv",
|
||||||
|
"mpvExtraParameters_help": "по одному на строчку",
|
||||||
|
"playbackStyle_description": "выберите стиль воспроизведения, который будет использоваться аудиоплеером",
|
||||||
|
"playButtonBehavior_description": "устанавливает поведение кнопки воспроизведения при добавлении треков в очередь",
|
||||||
|
"playButtonBehavior": "поведение кнопки воспроизведения",
|
||||||
|
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||||
|
"playButtonBehavior_optionPlay": "$t(player.play)",
|
||||||
|
"playerAlbumArtResolution_description": "разрешение большой версии обложки альбома в проигрывателе. при большем разрешении она выглядит более четкой, но может замедлить загрузку. по умолчанию равно 0 - устанавливает разрешение автоматически",
|
||||||
|
"remotePort": "порт сервера удалённого управления",
|
||||||
|
"remotePort_description": "устанавливает порт для сервера удалённого управления",
|
||||||
|
"replayGainClipping": "{{ReplayGain}} клиппинг",
|
||||||
|
"replayGainFallback": "{{ReplayGain}} по умолчанию",
|
||||||
|
"sampleRate_description": "выберите выходную частоту дискретизации, которая будет использоваться, если выбранная частота дискретизации отличается от частоты дискретизации текущего трека. при значении меньше 8000 будет использоваться частота по умолчанию",
|
||||||
|
"savePlayQueue_description": "сохранять очередь воспроизведения при закрытии приложения и восстанавливать при запуске приложения",
|
||||||
|
"showSkipButton_description": "показывать или скрывать кнопки перемотки на панели управления воспроизведением",
|
||||||
|
"sidebarConfiguration": "конфигурация боковой панели",
|
||||||
|
"sidebarConfiguration_description": "выбрать элементы и порядок их отображения на боковой панели",
|
||||||
|
"sidebarCollapsedNavigation": "кнопки навигации в боковой панели (в свёрнутом режиме)",
|
||||||
|
"showSkipButtons": "показывать кнопки перемотки",
|
||||||
|
"showSkipButtons_description": "показывать или скрывать кнопки перемотки на панели управления воспроизведением",
|
||||||
|
"sidebarPlaylistList_description": "показать или скрыть список плейлистов на боковой панели",
|
||||||
|
"skipDuration": "время перемотки",
|
||||||
|
"skipDuration_description": "задает время перемотки при использовании кнопок перемотки на панели проигрывателя",
|
||||||
|
"useSystemTheme": "использовать тему системы",
|
||||||
|
"themeLight": "тема (светлая)",
|
||||||
|
"themeLight_description": "устанавливает светлую тему приложения",
|
||||||
|
"useSystemTheme_description": "использует тему, заданную в системе (светлую/тёмную)",
|
||||||
|
"zoom": "процент масштабирования",
|
||||||
|
"zoom_description": "устанавливает процент масштабирования приложения",
|
||||||
|
"floatingQueueArea": "показать область наведения для всплывающей очереди",
|
||||||
|
"genreBehavior_description": "определяет, что отобразится при открытии на жанр — список треков или альбомов",
|
||||||
|
"globalMediaHotkeys_description": "включить или отключить использование системных мультимедийных горячих клавиш для управления воспроизведением",
|
||||||
|
"homeConfiguration_description": "позволяет настроить видимость и порядок элементов на домашней странице",
|
||||||
|
"hotkey_toggleQueue": "показать/скрыть очередь воспроизведения",
|
||||||
|
"imageAspectRatio": "использовать оригинальное соотношение сторон обложки",
|
||||||
|
"imageAspectRatio_description": "если эта опция включена, обложки будут отображаться в соответствии с их собственным соотношением сторон. для обложек не 1:1 оставшееся пространство будет пустым",
|
||||||
|
"minimumScrobblePercentage": "минимальное время для скробблинга (в процентах)",
|
||||||
|
"playbackStyle": "стиль воспроизведения",
|
||||||
|
"playerAlbumArtResolution": "разрешение обложки альбома",
|
||||||
|
"remotePassword_description": "задает пароль для сервера удалённого управления. По умолчанию эти учетные данные передаются небезопасным способом, поэтому следует использовать уникальный пароль, который вам не важен",
|
||||||
|
"replayGainClipping_description": "Предотвращение клиппинга, вызванного {{ReplayGain}}, путём автоматического снижения усиления",
|
||||||
|
"replayGainFallback_description": "усиление в db для применения, если у файла нет тегов {{ReplayGain}}",
|
||||||
|
"replayGainMode_description": "регулировать усиление громкости в соответствии со значениями {{ReplayGain}}, хранящимися в метаданных файла",
|
||||||
|
"savePlayQueue": "сохранять очередь воспроизведения",
|
||||||
|
"showSkipButton": "показывать кнопки перемотки",
|
||||||
|
"theme": "тема",
|
||||||
|
"themeDark": "тема (тёмная)",
|
||||||
|
"externalLinks": "показывать ссылки на внешние ресурсы",
|
||||||
|
"gaplessAudio": "бесшовное воспроизведение",
|
||||||
|
"gaplessAudio_optionWeak": "слабое (рекомендуется)",
|
||||||
|
"gaplessAudio_description": "устанавливает настройку mpv для бесшовного воспроизведение",
|
||||||
|
"hotkey_browserBack": "кнопка браузера \"назад\"",
|
||||||
|
"hotkey_localSearch": "поиск на странице",
|
||||||
|
"hotkey_skipBackward": "перемотать назад",
|
||||||
|
"startMinimized": "запуск в свёрнутом режиме",
|
||||||
|
"themeDark_description": "устанавливает тёмную тему приложения",
|
||||||
|
"hotkey_volumeMute": "отключить звук",
|
||||||
|
"clearCache_description": "\"жесткая очистка\" feishin. кроме очистки кэша feishin, также очищает кэш браузера (сохранённые картинки и другие ресурсы). учётные данные и настройки сохраняются",
|
||||||
|
"clearCacheSuccess": "кэш успешно удалён",
|
||||||
|
"customFontPath": "путь к пользовательскому шрифту",
|
||||||
|
"customFontPath_description": "укажите путь к пользовательскому шрифту, который будет использоваться в приложении",
|
||||||
|
"externalLinks_description": "включает отображение внешних ссылок (Last.fm, MusicBrainz) на страницах альбомов и артистов",
|
||||||
|
"floatingQueueArea_description": "включить отображение иконки наведения на правой части экрана, чтобы показать очередь воспроизведения",
|
||||||
|
"followLyric_description": "прокручивать текст трека до текущей позиции воспроизведения",
|
||||||
|
"language_description": "устанавливает язык приложения ($t(common.restartRequired))",
|
||||||
|
"lyricFetch_description": "получать тексты треков из различных интернет-источников",
|
||||||
|
"lyricFetchProvider": "источник текстов треков",
|
||||||
|
"minimumScrobbleSeconds": "минимальное время для скробблинга (в секундах)",
|
||||||
|
"replayGainPreamp": "предусиление {{ReplayGain}} (дБ)",
|
||||||
|
"sidebarCollapsedNavigation_description": "показать или скрыть кнопки навигации в свёрнутой боковой панели",
|
||||||
|
"homeConfiguration": "конфигурация домашней страницы",
|
||||||
|
"remoteUsername_description": "задает имя пользователя для сервера удалённого управления. если имя пользователя и пароль пусты, аутентификация будет отключена",
|
||||||
|
"scrobble": "скробблинг",
|
||||||
|
"replayGainPreamp_description": "настройка усиления предусилителя, применяемого к значениям {{ReplayGain}}",
|
||||||
|
"passwordStore_description": "какое хранилище паролей/секретов использовать. измените это значение, если у вас есть проблемы с хранением паролей.",
|
||||||
|
"lyricFetch": "получать тексты треков из интернета",
|
||||||
|
"sampleRate": "частота дискретизации",
|
||||||
|
"scrobble_description": "скробблинг треков на вашем медиасервере",
|
||||||
|
"startMinimized_description": "запуск приложения в области уведомлений",
|
||||||
|
"volumeWheelStep_description": "количество громкости, изменяемое при прокрутке колёсика мыши над ползунком громкости",
|
||||||
|
"discordRichPresence": "состояние профиля {{discord}}",
|
||||||
|
"discordApplicationId": "{{discord}} application id",
|
||||||
|
"discordApplicationId_description": "application id приложения {{discord}} которое будет отображаться в статусе профиля (по умолчанию {{defaultId}})",
|
||||||
|
"discordIdleStatus": "показывать состояние простоя",
|
||||||
|
"discordIdleStatus_description": "если включено, то обновляет статус, когда пользователь бездействует",
|
||||||
|
"discordUpdateInterval": "интервал обновления статуса профиля {{discord}}",
|
||||||
|
"discordUpdateInterval_description": "время в секундах между каждым обновлением (минимум 15 секунд)",
|
||||||
|
"lyricOffset_description": "Смещение появления текста треков на указанное количество миллисекунд",
|
||||||
|
"skipPlaylistPage": "пропустить страницу плейлиста",
|
||||||
|
"applicationHotkeys_description": "настройка горячих клавиш приложения. включите чекбокс, чтобы сделать горячую клавишу глобальной (только для ПК)",
|
||||||
|
"fontType_description": "встроенный позволяет выбрать один из шрифтов, предоставляемых Feishin. системный позволяет выбрать любой шрифт, предоставляемый вашей операционной системой. пользовательский позволяет выбрать свой собственный шрифт",
|
||||||
|
"discordRichPresence_description": "включить статус воспроизведения в статус профиля в {{discord}}. Ключи изображений: {{icon}}, {{playing}} и {{paused}} ",
|
||||||
|
"lyricOffset": "синхронизация текста треков (мс)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+132
-86
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "编辑 $t(entity.playlist_one)",
|
"editPlaylist": "编辑$t(entity.playlist_one)",
|
||||||
"moveToTop": "跳至顶部",
|
"moveToTop": "移至顶部",
|
||||||
"clearQueue": "清空播放队列",
|
"clearQueue": "清空播放队列",
|
||||||
"addToFavorites": "添加到$t(entity.favorite_other)",
|
"addToFavorites": "添加到$t(entity.favorite_other)",
|
||||||
"addToPlaylist": "添加到$t(entity.playlist_one)",
|
"addToPlaylist": "添加到$t(entity.playlist_one)",
|
||||||
@@ -12,11 +12,11 @@
|
|||||||
"deletePlaylist": "删除$t(entity.playlist_one)",
|
"deletePlaylist": "删除$t(entity.playlist_one)",
|
||||||
"removeFromQueue": "从播放队列中移除",
|
"removeFromQueue": "从播放队列中移除",
|
||||||
"deselectAll": "取消全选",
|
"deselectAll": "取消全选",
|
||||||
"moveToBottom": "跳至底部",
|
"moveToBottom": "移至底部",
|
||||||
"setRating": "评分",
|
"setRating": "评分",
|
||||||
"toggleSmartPlaylistEditor": "切换$t(entity.smartPlaylist)编辑器",
|
"toggleSmartPlaylistEditor": "切换$t(entity.smartPlaylist)编辑器",
|
||||||
"removeFromFavorites": "从$t(entity.favorite_other)移除",
|
"removeFromFavorites": "从$t(entity.favorite_other)移除",
|
||||||
"goToPage": "转到页面",
|
"goToPage": "前往页面",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "在 Last.fm 中打开",
|
"lastfm": "在 Last.fm 中打开",
|
||||||
"musicbrainz": "在 MusicBrainz 中打开"
|
"musicbrainz": "在 MusicBrainz 中打开"
|
||||||
@@ -67,8 +67,8 @@
|
|||||||
"bitrate": "比特率",
|
"bitrate": "比特率",
|
||||||
"saveAndReplace": "保存并替换",
|
"saveAndReplace": "保存并替换",
|
||||||
"action_other": "操作",
|
"action_other": "操作",
|
||||||
"confirm": "确认",
|
"confirm": "确定",
|
||||||
"resetToDefault": "重置为默认",
|
"resetToDefault": "重置为默认状态",
|
||||||
"home": "主页",
|
"home": "主页",
|
||||||
"comingSoon": "即将上线…",
|
"comingSoon": "即将上线…",
|
||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
@@ -80,28 +80,28 @@
|
|||||||
"quit": "退出",
|
"quit": "退出",
|
||||||
"expand": "展开",
|
"expand": "展开",
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
"saveAs": "保存为",
|
"saveAs": "另存为",
|
||||||
"random": "随机",
|
"random": "随机",
|
||||||
"biography": "简介",
|
"biography": "简介",
|
||||||
"sortOrder": "顺序",
|
"sortOrder": "顺序",
|
||||||
"backward": "返回",
|
"backward": "后退",
|
||||||
"gap": "空隙",
|
"gap": "空隙",
|
||||||
"limit": "限制",
|
"limit": "限制",
|
||||||
"duration": "时长",
|
"duration": "时长",
|
||||||
"ok": "好",
|
"ok": "好",
|
||||||
"no": "否",
|
"no": "否",
|
||||||
"playerMustBePaused": "播放器须被暂停",
|
"playerMustBePaused": "播放器必须暂停",
|
||||||
"channel_other": "频道",
|
"channel_other": "频道",
|
||||||
"none": "无",
|
"none": "无",
|
||||||
"disc": "盘",
|
"disc": "碟片",
|
||||||
"yes": "是",
|
"yes": "是",
|
||||||
"size": "大小",
|
"size": "大小",
|
||||||
"areYouSure": "是否继续?",
|
"areYouSure": "是否确定?",
|
||||||
"note": "注释",
|
"note": "注释",
|
||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
"albumPeak": "专辑峰值",
|
"albumPeak": "专辑峰值",
|
||||||
"mbid": "MusicBrainz ID",
|
"mbid": "MusicBrainz ID",
|
||||||
"reload": "重新加载",
|
"reload": "重载",
|
||||||
"trackGain": "音轨增益",
|
"trackGain": "音轨增益",
|
||||||
"trackPeak": "音轨峰值",
|
"trackPeak": "音轨峰值",
|
||||||
"albumGain": "专辑增益",
|
"albumGain": "专辑增益",
|
||||||
@@ -125,13 +125,14 @@
|
|||||||
"folder_other": "文件夹",
|
"folder_other": "文件夹",
|
||||||
"smartPlaylist": "智能$t(entity.playlist_one)",
|
"smartPlaylist": "智能$t(entity.playlist_one)",
|
||||||
"genreWithCount_other": "{{count}} 种流派",
|
"genreWithCount_other": "{{count}} 种流派",
|
||||||
"trackWithCount_other": "{{count}} 首乐曲"
|
"trackWithCount_other": "{{count}} 首乐曲",
|
||||||
|
"play_other": "{{count}} 次播放"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"repeat_all": "全部循环",
|
"repeat_all": "循环全部",
|
||||||
"stop": "停止",
|
"stop": "停止",
|
||||||
"repeat": "循环",
|
"repeat": "循环",
|
||||||
"queue_remove": "移除所选",
|
"queue_remove": "移除所选项",
|
||||||
"playRandom": "随机播放",
|
"playRandom": "随机播放",
|
||||||
"skip": "跳过",
|
"skip": "跳过",
|
||||||
"previous": "上一首",
|
"previous": "上一首",
|
||||||
@@ -139,31 +140,32 @@
|
|||||||
"skip_back": "向后跳过",
|
"skip_back": "向后跳过",
|
||||||
"favorite": "收藏",
|
"favorite": "收藏",
|
||||||
"next": "下一首",
|
"next": "下一首",
|
||||||
"shuffle": "随机播放",
|
"shuffle": "随机",
|
||||||
"playbackFetchNoResults": "未找到歌曲",
|
"playbackFetchNoResults": "未找到歌曲",
|
||||||
"playbackFetchInProgress": "正在加载歌曲…",
|
"playbackFetchInProgress": "正在加载歌曲…",
|
||||||
"addNext": "添加为播放列表下一首",
|
"addNext": "添加为播放列表下一首",
|
||||||
"playbackFetchCancel": "请稍等…关闭通知以取消操作",
|
"playbackFetchCancel": "请稍等…关闭通知以取消操作",
|
||||||
"play": "播放",
|
"play": "播放",
|
||||||
"repeat_off": "不循环",
|
"repeat_off": "循环关闭",
|
||||||
"queue_clear": "清空播放队列",
|
"queue_clear": "清空播放队列",
|
||||||
"muted": "已静音",
|
"muted": "已静音",
|
||||||
"unfavorite": "取消收藏",
|
"unfavorite": "取消收藏",
|
||||||
"queue_moveToTop": "使所选置底",
|
"queue_moveToTop": "将所选项移至底部",
|
||||||
"queue_moveToBottom": "使所选置顶",
|
"queue_moveToBottom": "将所选项移至顶部",
|
||||||
"shuffle_off": "未启用随机播放",
|
"shuffle_off": "随机关闭",
|
||||||
"addLast": "添加到播放列表末尾",
|
"addLast": "添加至播放列表末尾",
|
||||||
"mute": "静音",
|
"mute": "静音",
|
||||||
"skip_forward": "向前跳过",
|
"skip_forward": "向前跳过",
|
||||||
"playbackSpeed": "播放速度",
|
"playbackSpeed": "播放速度",
|
||||||
"pause": "暂停"
|
"pause": "暂停",
|
||||||
|
"playSimilarSongs": "播放类似的曲目"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"crossfadeStyle_description": "选择用于音频播放器的淡入淡出风格",
|
"crossfadeStyle_description": "选择用于音频播放器的淡入淡出风格",
|
||||||
"hotkey_favoriteCurrentSong": "收藏 $t(common.currentSong)",
|
"hotkey_favoriteCurrentSong": "收藏$t(common.currentSong)",
|
||||||
"crossfadeStyle": "淡入淡出风格",
|
"crossfadeStyle": "淡入淡出风格",
|
||||||
"audioExclusiveMode_description": "启用独占输出模式。在此模式下,系统通常被锁定,只有 mpv 能够输出音频",
|
"audioExclusiveMode_description": "启用独占输出模式。在此模式下,系统通常被锁定为只有 mpv 能够输出音频",
|
||||||
"disableLibraryUpdateOnStartup": "禁用启动时查找新版本",
|
"disableLibraryUpdateOnStartup": "禁用启动时查询新版本",
|
||||||
"gaplessAudio": "无缝音频",
|
"gaplessAudio": "无缝音频",
|
||||||
"audioPlayer_description": "选择用于播放的音频播放器",
|
"audioPlayer_description": "选择用于播放的音频播放器",
|
||||||
"globalMediaHotkeys": "全局媒体快捷键",
|
"globalMediaHotkeys": "全局媒体快捷键",
|
||||||
@@ -190,48 +192,48 @@
|
|||||||
"audioDevice_description": "选择用于播放的音频设备(仅 web 播放器)",
|
"audioDevice_description": "选择用于播放的音频设备(仅 web 播放器)",
|
||||||
"enableRemote_description": "启用远程控制服务器,以允许其他设备控制此应用",
|
"enableRemote_description": "启用远程控制服务器,以允许其他设备控制此应用",
|
||||||
"remotePort_description": "设置远程服务器端口",
|
"remotePort_description": "设置远程服务器端口",
|
||||||
"hotkey_skipBackward": "向回跳过",
|
"hotkey_skipBackward": "向后跳过",
|
||||||
"replayGainMode_description": "根据乐曲元数据中存储的{{ReplayGain}}值调整音量增益",
|
"replayGainMode_description": "根据乐曲元数据中存储的{{ReplayGain}}值调整音量增益",
|
||||||
"volumeWheelStep_description": "在音量滑块上滚动鼠标滚轮时要更改的音量大小",
|
"volumeWheelStep_description": "在音量滑块上滚动鼠标滚轮时要更改的音量大小",
|
||||||
"theme_description": "设置应用的主题",
|
"theme_description": "设置应用的主题",
|
||||||
"hotkey_playbackPause": "暂停",
|
"hotkey_playbackPause": "暂停",
|
||||||
"replayGainFallback": "{{ReplayGain}}后备替代",
|
"replayGainFallback": "{{ReplayGain}}后备方案",
|
||||||
"sidebarCollapsedNavigation_description": "在折叠的侧边栏中显示或隐藏导航",
|
"sidebarCollapsedNavigation_description": "在折叠的侧边栏中显示或隐藏导航",
|
||||||
"hotkey_volumeUp": "音量增高",
|
"hotkey_volumeUp": "音量增高",
|
||||||
"skipDuration": "跳过时长",
|
"skipDuration": "跳过时长",
|
||||||
"showSkipButtons": "显示跳过按钮",
|
"showSkipButtons": "显示跳过按钮",
|
||||||
"playButtonBehavior_optionPlay": "$t(player.play)",
|
"playButtonBehavior_optionPlay": "$t(player.play)",
|
||||||
"minimumScrobblePercentage": "最小 scrobble 时长(百分比)",
|
"minimumScrobblePercentage": "最小记录时长(百分比)",
|
||||||
"lyricFetch": "从互联网获取歌词",
|
"lyricFetch": "从互联网获取歌词",
|
||||||
"scrobble": "记录播放信息(Scrobble)",
|
"scrobble": "记录播放信息",
|
||||||
"skipDuration_description": "设置每次按下跳过按钮将会跳过的时长",
|
"skipDuration_description": "设置每次按下跳过按钮将会跳过的时长",
|
||||||
"fontType_optionSystem": "系统字体",
|
"fontType_optionSystem": "系统字体",
|
||||||
"mpvExecutablePath_description": "设置 mpv 二进制文件的路径。如果留空,则使用默认路径",
|
"mpvExecutablePath_description": "设置 mpv 可执行文件的路径。如果留空,则使用默认路径",
|
||||||
"sampleRate": "采样率",
|
"sampleRate": "采样率",
|
||||||
"sidePlayQueueStyle_optionAttached": "吸附",
|
"sidePlayQueueStyle_optionAttached": "吸附",
|
||||||
"sidebarConfiguration": "侧边栏设定",
|
"sidebarConfiguration": "侧边栏设定",
|
||||||
"sampleRate_description": "如果选择的采样频率与当前媒体的采样频率不同,请选择要使用的输出采样率。小于 8000 的值将使用默认频率",
|
"sampleRate_description": "如果选择的采样频率与当前媒体的采样频率不同,请选择要使用的输出采样率。小于 8000 的值将使用默认频率",
|
||||||
"replayGainMode_optionNone": "$t(common.none)",
|
"replayGainMode_optionNone": "$t(common.none)",
|
||||||
"hotkey_zoomIn": "放大",
|
"hotkey_zoomIn": "放大",
|
||||||
"scrobble_description": "在你的社交媒体中记录播放信息",
|
"scrobble_description": "在你的媒体服务器中记录播放信息",
|
||||||
"hotkey_browserForward": "浏览器前进",
|
"hotkey_browserForward": "浏览器前进",
|
||||||
"themeLight": "主题(浅色)",
|
"themeLight": "主题(浅色)",
|
||||||
"fontType_optionBuiltIn": "内置字体",
|
"fontType_optionBuiltIn": "内置字体",
|
||||||
"hotkey_playbackPlayPause": "播放/暂停",
|
"hotkey_playbackPlayPause": "播放/暂停",
|
||||||
"hotkey_rate1": "评为 1 星",
|
"hotkey_rate1": "评为 1 星",
|
||||||
"hotkey_skipForward": "向后跳过",
|
"hotkey_skipForward": "向前跳过",
|
||||||
"sidePlayQueueStyle": "侧边播放列表样式",
|
"sidePlayQueueStyle": "侧边播放列表样式",
|
||||||
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||||
"zoom": "缩放率",
|
"zoom": "缩放率",
|
||||||
"minimizeToTray_description": "将应用程序最小化到系统托盘",
|
"minimizeToTray_description": "将应用程序最小化到系统托盘",
|
||||||
"hotkey_playbackPlay": "播放",
|
"hotkey_playbackPlay": "播放",
|
||||||
"hotkey_togglePreviousSongFavorite": "收藏 / 取消收藏$t(common.previousSong)",
|
"hotkey_togglePreviousSongFavorite": "切换收藏$t(common.previousSong)",
|
||||||
"hotkey_volumeDown": "音量降低",
|
"hotkey_volumeDown": "音量降低",
|
||||||
"hotkey_unfavoritePreviousSong": "取消收藏$t(common.previousSong)",
|
"hotkey_unfavoritePreviousSong": "取消收藏$t(common.previousSong)",
|
||||||
"hotkey_globalSearch": "全局搜索",
|
"hotkey_globalSearch": "全局搜索",
|
||||||
"remoteUsername_description": "设置远程控制服务器的用户名。如果用户名和密码都为空,则身份验证将被禁用",
|
"remoteUsername_description": "设置远程控制服务器的用户名。如果用户名和密码都为空,则身份验证将被禁用",
|
||||||
"exitToTray_description": "退出应用时最小化到系统托盘而非关闭",
|
"exitToTray_description": "退出应用时最小化到系统托盘",
|
||||||
"hotkey_favoritePreviousSong": "收藏 $t(common.previousSong)",
|
"hotkey_favoritePreviousSong": "收藏$t(common.previousSong)",
|
||||||
"replayGainMode_optionAlbum": "$t(entity.album_one)",
|
"replayGainMode_optionAlbum": "$t(entity.album_one)",
|
||||||
"lyricOffset": "歌词偏移(毫秒)",
|
"lyricOffset": "歌词偏移(毫秒)",
|
||||||
"fontType_optionCustom": "自定义字体",
|
"fontType_optionCustom": "自定义字体",
|
||||||
@@ -239,49 +241,49 @@
|
|||||||
"remotePassword": "远程控制服务器密码",
|
"remotePassword": "远程控制服务器密码",
|
||||||
"lyricFetchProvider": "歌词源",
|
"lyricFetchProvider": "歌词源",
|
||||||
"language_description": "设置应用的语言($t(common.restartRequired))",
|
"language_description": "设置应用的语言($t(common.restartRequired))",
|
||||||
"playbackStyle_optionCrossFade": "交叉淡入淡出",
|
"playbackStyle_optionCrossFade": "淡入淡出",
|
||||||
"hotkey_rate3": "评为 3 星",
|
"hotkey_rate3": "评为 3 星",
|
||||||
"mpvExtraParameters": "mpv 参数",
|
"mpvExtraParameters": "mpv 参数",
|
||||||
"replayGainMode_optionTrack": "$t(entity.track_one)",
|
"replayGainMode_optionTrack": "$t(entity.track_one)",
|
||||||
"themeLight_description": "应用将使用浅色主题",
|
"themeLight_description": "应用将使用浅色主题",
|
||||||
"hotkey_toggleFullScreenPlayer": "全屏播放",
|
"hotkey_toggleFullScreenPlayer": "全屏播放",
|
||||||
"hotkey_localSearch": "页面内搜索",
|
"hotkey_localSearch": "页面内搜索",
|
||||||
"hotkey_toggleQueue": "显示 / 隐藏播放队列",
|
"hotkey_toggleQueue": "切换播放队列",
|
||||||
"zoom_description": "设置应用程序的缩放率",
|
"zoom_description": "设置应用程序的缩放率",
|
||||||
"remotePassword_description": "设置远程控制服务器的密码。这些凭据默认以不安全的方式传输,因此您应该使用一个您不在意的唯一密码",
|
"remotePassword_description": "设置远程控制服务器的密码。这些凭据默认以不安全的方式传输,因此您应该使用一个您不在意的唯一密码",
|
||||||
"hotkey_rate5": "评为 5 星",
|
"hotkey_rate5": "评为 5 星",
|
||||||
"hotkey_playbackPrevious": "上一曲",
|
"hotkey_playbackPrevious": "上一首",
|
||||||
"showSkipButtons_description": "在播放条显示/隐藏播放按钮",
|
"showSkipButtons_description": "在播放条显示或隐藏播放按钮",
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
"playbackStyle": "播放风格",
|
"playbackStyle": "播放风格",
|
||||||
"hotkey_toggleShuffle": "切换随机播放设定",
|
"hotkey_toggleShuffle": "切换随机",
|
||||||
"theme": "主题",
|
"theme": "主题",
|
||||||
"playbackStyle_description": "选择播放器的播放风格",
|
"playbackStyle_description": "选择音频播放器的播放风格",
|
||||||
"mpvExecutablePath": "mpv 二进制文件路径",
|
"mpvExecutablePath": "mpv 可执行文件路径",
|
||||||
"hotkey_rate2": "评为 2 星",
|
"hotkey_rate2": "评为 2 星",
|
||||||
"playButtonBehavior_description": "设置将歌曲添加到队列时播放按钮的默认行为",
|
"playButtonBehavior_description": "设置将歌曲添加到播放队列时播放按钮的默认行为",
|
||||||
"minimumScrobblePercentage_description": "歌曲被记录为已播放(scrobble)所需的最小播放百分比",
|
"minimumScrobblePercentage_description": "歌曲被记录为已播放所需的最小播放百分比",
|
||||||
"exitToTray": "退出时最小化到托盘",
|
"exitToTray": "退出时最小化到托盘",
|
||||||
"hotkey_rate4": "评为 4 星",
|
"hotkey_rate4": "评为 4 星",
|
||||||
"showSkipButton_description": "在播放条上显示/隐藏跳过按钮",
|
"showSkipButton_description": "在播放条上显示或隐藏跳过按钮",
|
||||||
"savePlayQueue": "保存播放列表",
|
"savePlayQueue": "保存播放队列",
|
||||||
"minimumScrobbleSeconds_description": "歌曲被记录为已播放(scrobble)所需的最小播放时间",
|
"minimumScrobbleSeconds_description": "歌曲被记录为已播放所需的最小播放时间",
|
||||||
"skipPlaylistPage_description": "打开歌单时,直接查看歌曲列表而非查看默认页面",
|
"skipPlaylistPage_description": "打开歌单时,直接查看歌曲列表而非查看默认页面",
|
||||||
"fontType_description": "内置字体可以选择 Feishin 提供的字体之一。系统字体允许您选择操作系统提供的任何字体。自定义选项允许您使用自己的字体",
|
"fontType_description": "内置字体可以选择 Feishin 提供的字体之一。系统字体允许您选择操作系统提供的任何字体。自定义选项允许您使用自己的字体",
|
||||||
"playButtonBehavior": "播放按钮行为",
|
"playButtonBehavior": "播放按钮行为",
|
||||||
"volumeWheelStep": "音量滚轮步长",
|
"volumeWheelStep": "音量滚轮分度",
|
||||||
"sidebarPlaylistList_description": "显示或隐藏侧边栏歌单列表",
|
"sidebarPlaylistList_description": "显示或隐藏侧边栏歌单列表",
|
||||||
"sidePlayQueueStyle_description": "设置侧边播放列表样式",
|
"sidePlayQueueStyle_description": "设置侧边播放列表样式",
|
||||||
"replayGainMode": "{{ReplayGain}}模式",
|
"replayGainMode": "{{ReplayGain}}模式",
|
||||||
"playbackStyle_optionNormal": "通常",
|
"playbackStyle_optionNormal": "正常",
|
||||||
"windowBarStyle": "窗口顶栏风格",
|
"windowBarStyle": "窗口顶栏风格",
|
||||||
"floatingQueueArea": "显示浮动队列悬停区域",
|
"floatingQueueArea": "显示浮动队列悬停区域",
|
||||||
"replayGainFallback_description": "乐曲没有{{ReplayGain}}标签时应用的增益(以分贝为单位)",
|
"replayGainFallback_description": "乐曲没有{{ReplayGain}}标签时应用的增益(以分贝为单位)",
|
||||||
"hotkey_toggleRepeat": "切换循环播放设定",
|
"hotkey_toggleRepeat": "切换循环",
|
||||||
"lyricOffset_description": "将歌词偏移指定的毫秒数",
|
"lyricOffset_description": "将歌词偏移指定的毫秒数",
|
||||||
"sidebarConfiguration_description": "选择侧边栏包含的项目与顺序",
|
"sidebarConfiguration_description": "选择侧边栏包含的项目与顺序",
|
||||||
"remotePort": "远程服务器端口",
|
"remotePort": "远程服务器端口",
|
||||||
"hotkey_playbackNext": "下一曲",
|
"hotkey_playbackNext": "下一首",
|
||||||
"useSystemTheme_description": "使用系统定义的浅色或深色主题",
|
"useSystemTheme_description": "使用系统定义的浅色或深色主题",
|
||||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||||
"lyricFetch_description": "从多个互联网源获取歌词",
|
"lyricFetch_description": "从多个互联网源获取歌词",
|
||||||
@@ -292,16 +294,16 @@
|
|||||||
"hotkey_rate0": "清除评分",
|
"hotkey_rate0": "清除评分",
|
||||||
"floatingQueueArea_description": "在屏幕右侧显示一个悬停图标,以查看播放队列",
|
"floatingQueueArea_description": "在屏幕右侧显示一个悬停图标,以查看播放队列",
|
||||||
"hotkey_volumeMute": "静音",
|
"hotkey_volumeMute": "静音",
|
||||||
"hotkey_toggleCurrentSongFavorite": "收藏 / 取消收藏$t(common.currentSong)",
|
"hotkey_toggleCurrentSongFavorite": "切换收藏$t(common.currentSong)",
|
||||||
"remoteUsername": "远程服务器用户名",
|
"remoteUsername": "远程控制服务器用户名",
|
||||||
"hotkey_browserBack": "浏览器后退",
|
"hotkey_browserBack": "浏览器后退",
|
||||||
"showSkipButton": "显示跳过按钮",
|
"showSkipButton": "显示跳过按钮",
|
||||||
"sidebarPlaylistList": "侧边栏歌单列表",
|
"sidebarPlaylistList": "侧边栏歌单列表",
|
||||||
"minimizeToTray": "最小化到托盘",
|
"minimizeToTray": "最小化到托盘",
|
||||||
"skipPlaylistPage": "跳过歌单页面",
|
"skipPlaylistPage": "跳过播放列表页面",
|
||||||
"themeDark": "主题(深色)",
|
"themeDark": "主题(深色)",
|
||||||
"sidebarCollapsedNavigation": "侧边栏(已折叠)导航",
|
"sidebarCollapsedNavigation": "侧边栏(已折叠)导航",
|
||||||
"minimumScrobbleSeconds": "最小 scrobble 时间(秒)",
|
"minimumScrobbleSeconds": "最小记录时间(秒)",
|
||||||
"hotkey_playbackStop": "停止",
|
"hotkey_playbackStop": "停止",
|
||||||
"windowBarStyle_description": "选择窗口顶栏的风格",
|
"windowBarStyle_description": "选择窗口顶栏的风格",
|
||||||
"savePlayQueue_description": "当应用程序关闭时保存播放队列,并在应用程序打开时恢复它",
|
"savePlayQueue_description": "当应用程序关闭时保存播放队列,并在应用程序打开时恢复它",
|
||||||
@@ -322,15 +324,15 @@
|
|||||||
"clearCache": "清除浏览器缓存",
|
"clearCache": "清除浏览器缓存",
|
||||||
"buttonSize": "播放器栏按钮大小",
|
"buttonSize": "播放器栏按钮大小",
|
||||||
"buttonSize_description": "播放器栏按钮大小",
|
"buttonSize_description": "播放器栏按钮大小",
|
||||||
"clearCache_description": "feishin的“硬清除”。除了清除feishin的缓存,清空浏览器缓存(保存的图像和其他资源)。会保留服务器凭据和设置",
|
"clearCache_description": "feishin的“硬清除”。除了清除feishin的缓存,清空浏览器缓存(保存的图像和其他资源)。服务器凭据和设置会被保留",
|
||||||
"clearQueryCache_description": "feishin的“软清除”。这将会刷新播放列表、元数据并重置保存的歌词。会保留设置、服务器凭据和缓存图像",
|
"clearQueryCache_description": "feishin的“软清除”。这将会刷新播放列表、元数据并重置保存的歌词。设置、服务器凭据和缓存图像会被保留",
|
||||||
"clearQueryCache": "清除feishin缓存",
|
"clearQueryCache": "清除feishin缓存",
|
||||||
"externalLinks": "显示外部链接",
|
"externalLinks": "显示外部链接",
|
||||||
"externalLinks_description": "允许在艺术家/专辑页面上显示外部链接(Last.fm、MusicBrainz)",
|
"externalLinks_description": "允许在艺术家/专辑页面上显示外部链接(Last.fm、MusicBrainz)",
|
||||||
"mpvExtraParameters_help": "每行一个",
|
"mpvExtraParameters_help": "每行一个",
|
||||||
"startMinimized": "启动最小化",
|
"startMinimized": "启动最小化",
|
||||||
"startMinimized_description": "在系统托盘中启动应用程序",
|
"startMinimized_description": "在系统托盘中启动应用程序",
|
||||||
"passwordStore_description": "使用什么密码/秘密存储。如果您在存储密码时遇到问题,请更改此设置。",
|
"passwordStore_description": "使用什么密码/密钥存储。如果您在存储密码时遇到问题,请更改此设置。",
|
||||||
"clearCacheSuccess": "缓存清除成功",
|
"clearCacheSuccess": "缓存清除成功",
|
||||||
"playerAlbumArtResolution": "播放器专辑封面分辨率",
|
"playerAlbumArtResolution": "播放器专辑封面分辨率",
|
||||||
"playerAlbumArtResolution_description": "大型播放器专辑封面预览的分辨率。较大使其看起来更清晰,但可能会减慢加载速度。默认为0,表示自动",
|
"playerAlbumArtResolution_description": "大型播放器专辑封面预览的分辨率。较大使其看起来更清晰,但可能会减慢加载速度。默认为0,表示自动",
|
||||||
@@ -338,7 +340,41 @@
|
|||||||
"genreBehavior_description": "确定单击流派是否默认在曲目或专辑列表中打开",
|
"genreBehavior_description": "确定单击流派是否默认在曲目或专辑列表中打开",
|
||||||
"homeConfiguration": "主页配置",
|
"homeConfiguration": "主页配置",
|
||||||
"homeConfiguration_description": "配置主页上显示的项目以及显示顺序",
|
"homeConfiguration_description": "配置主页上显示的项目以及显示顺序",
|
||||||
"passwordStore": "密码/秘密存储"
|
"passwordStore": "密码/密钥存储",
|
||||||
|
"homeFeature_description": "控制是否在主页上显示大型特色轮播",
|
||||||
|
"homeFeature": "首页 精选 轮播",
|
||||||
|
"imageAspectRatio": "保留封面图像纵横比",
|
||||||
|
"imageAspectRatio_description": "如果启用,封面图像将保留纵横比显示。对于不是1:1的图像,剩余的空间将是空的",
|
||||||
|
"doubleClickBehavior_description": "如果为真,则曲目搜索中所有匹配的曲目都将被加入播放队列。否则,只有单击的曲目才会被加入播放队列",
|
||||||
|
"doubleClickBehavior": "双击时将所有搜索到的曲目加入播放队列",
|
||||||
|
"volumeWidth": "音量滑块宽度",
|
||||||
|
"volumeWidth_description": "音量滑块的宽度",
|
||||||
|
"discordListening": "显示状态为正在监听",
|
||||||
|
"discordListening_description": "将状态显示为 “正在监听”,而不是 “正在播放”。请注意,这当前会破坏计时器栏",
|
||||||
|
"contextMenu_description": "允许您隐藏右键单击项目时显示在菜单中的项目。未选中的项目将被隐藏",
|
||||||
|
"customCssEnable_description": "允许编写自定义 css。",
|
||||||
|
"customCss": "自定义css",
|
||||||
|
"customCss_description": "自定义css内容。注意:内容和远程url是不允许的属性。内容预览展示如下。出于安全考虑,您未设置的其它字段也会显示。",
|
||||||
|
"contextMenu": "上下文菜单(右键单击)配置",
|
||||||
|
"customCssEnable": "启用自定义 css",
|
||||||
|
"customCssNotice": "警告:虽然预设了一些安全限制(不允许 url() 和 content:),但使用自定义 CSS 仍然会因更改界面而带来风险。",
|
||||||
|
"transcodeNote": "1(web)-2(mpv)首歌曲后生效",
|
||||||
|
"transcode": "启用转码",
|
||||||
|
"transcode_description": "可以转码为不同的格式",
|
||||||
|
"transcodeBitrate": "转码比特率",
|
||||||
|
"albumBackground": "专辑背景图片",
|
||||||
|
"albumBackground_description": "为包含专辑封面的专辑页面添加背景图像",
|
||||||
|
"albumBackgroundBlur": "专辑背景图像模糊大小",
|
||||||
|
"albumBackgroundBlur_description": "调整相册背景图片的模糊程度",
|
||||||
|
"playerbarOpenDrawer": "播放器栏全屏切换",
|
||||||
|
"playerbarOpenDrawer_description": "允许点击播放器栏打开全屏播放器",
|
||||||
|
"transcodeBitrate_description": "选择要转码的比特率。0 表示让服务器选择",
|
||||||
|
"transcodeFormat": "转码格式",
|
||||||
|
"transcodeFormat_description": "选择要转码的格式。留空让服务器决定",
|
||||||
|
"webAudio_description": "使用 web 音频。这将启用重播增益等高级功能。如果您遇到其他情况,请禁用",
|
||||||
|
"artistConfiguration_description": "配置专辑艺术家页面上显示的项目及其显示顺序",
|
||||||
|
"webAudio": "使用 web 音频",
|
||||||
|
"artistConfiguration": "专辑艺术家页面配置"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "重启服务器使新端口生效",
|
"remotePortWarning": "重启服务器使新端口生效",
|
||||||
@@ -348,7 +384,7 @@
|
|||||||
"remotePortError": "设置远程服务器端口时发生错误",
|
"remotePortError": "设置远程服务器端口时发生错误",
|
||||||
"serverRequired": "需要服务器",
|
"serverRequired": "需要服务器",
|
||||||
"authenticationFailed": "认证失败",
|
"authenticationFailed": "认证失败",
|
||||||
"apiRouteError": "请求失败:无法路由",
|
"apiRouteError": "无法路由请求",
|
||||||
"genericError": "发生了错误",
|
"genericError": "发生了错误",
|
||||||
"credentialsRequired": "需要凭证",
|
"credentialsRequired": "需要凭证",
|
||||||
"sessionExpiredError": "会话已过期",
|
"sessionExpiredError": "会话已过期",
|
||||||
@@ -365,7 +401,7 @@
|
|||||||
"openError": "无法打开文件"
|
"openError": "无法打开文件"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"mostPlayed": "播放最多",
|
"mostPlayed": "最多播放过",
|
||||||
"playCount": "播放次数",
|
"playCount": "播放次数",
|
||||||
"recentlyPlayed": "最近播放",
|
"recentlyPlayed": "最近播放",
|
||||||
"title": "标题",
|
"title": "标题",
|
||||||
@@ -382,7 +418,7 @@
|
|||||||
"albumArtist": "$t(entity.albumArtist_one)",
|
"albumArtist": "$t(entity.albumArtist_one)",
|
||||||
"releaseYear": "发布年份",
|
"releaseYear": "发布年份",
|
||||||
"biography": "个人简介",
|
"biography": "个人简介",
|
||||||
"songCount": "曲目数",
|
"songCount": "曲目数量",
|
||||||
"random": "随机",
|
"random": "随机",
|
||||||
"lastPlayed": "上次播放过",
|
"lastPlayed": "上次播放过",
|
||||||
"toYear": "从年份",
|
"toYear": "从年份",
|
||||||
@@ -404,7 +440,7 @@
|
|||||||
"note": "注释",
|
"note": "注释",
|
||||||
"albumCount": "$t(entity.album_other)数",
|
"albumCount": "$t(entity.album_other)数",
|
||||||
"id": "id",
|
"id": "id",
|
||||||
"disc": "盘",
|
"disc": "碟片",
|
||||||
"duration": "时长",
|
"duration": "时长",
|
||||||
"album": "$t(entity.album_one)"
|
"album": "$t(entity.album_one)"
|
||||||
},
|
},
|
||||||
@@ -421,7 +457,7 @@
|
|||||||
"home": "$t(common.home)",
|
"home": "$t(common.home)",
|
||||||
"artists": "$t(entity.artist_other)",
|
"artists": "$t(entity.artist_other)",
|
||||||
"albumArtists": "$t(entity.albumArtist_other)",
|
"albumArtists": "$t(entity.albumArtist_other)",
|
||||||
"shared": "共享 $t(entity.playlist_other)"
|
"shared": "共享$t(entity.playlist_other)"
|
||||||
},
|
},
|
||||||
"fullscreenPlayer": {
|
"fullscreenPlayer": {
|
||||||
"config": {
|
"config": {
|
||||||
@@ -464,19 +500,21 @@
|
|||||||
},
|
},
|
||||||
"albumDetail": {
|
"albumDetail": {
|
||||||
"moreFromArtist": "更多该$t(entity.artist_one)作品",
|
"moreFromArtist": "更多该$t(entity.artist_one)作品",
|
||||||
"moreFromGeneric": "更多{{item}}作品"
|
"moreFromGeneric": "更多{{item}}作品",
|
||||||
|
"released": "已发布"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"playbackTab": "播放",
|
"playbackTab": "播放",
|
||||||
"generalTab": "通用",
|
"generalTab": "通用",
|
||||||
"hotkeysTab": "快捷键",
|
"hotkeysTab": "快捷键",
|
||||||
"windowTab": "窗口"
|
"windowTab": "窗口",
|
||||||
|
"advanced": "高级"
|
||||||
},
|
},
|
||||||
"globalSearch": {
|
"globalSearch": {
|
||||||
"commands": {
|
"commands": {
|
||||||
"serverCommands": "服务器命令",
|
"serverCommands": "服务器命令",
|
||||||
"goToPage": "跳至页面",
|
"goToPage": "跳至页面",
|
||||||
"searchFor": "搜索 {{query}}"
|
"searchFor": "搜索{{query}}"
|
||||||
},
|
},
|
||||||
"title": "命令"
|
"title": "命令"
|
||||||
},
|
},
|
||||||
@@ -498,25 +536,27 @@
|
|||||||
"addLast": "$t(player.addLast)",
|
"addLast": "$t(player.addLast)",
|
||||||
"addFavorite": "$t(action.addToFavorites)",
|
"addFavorite": "$t(action.addToFavorites)",
|
||||||
"showDetails": "获取信息",
|
"showDetails": "获取信息",
|
||||||
"shareItem": "分享项目"
|
"shareItem": "分享项目",
|
||||||
|
"playSimilarSongs": "$t(player.playSimilarSongs)",
|
||||||
|
"download": "下载"
|
||||||
},
|
},
|
||||||
"trackList": {
|
"trackList": {
|
||||||
"title": "$t(entity.track_other)",
|
"title": "$t(entity.track_other)",
|
||||||
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
|
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
|
||||||
"artistTracks": "{{artist}} 的曲目"
|
"artistTracks": "{{artist}}的曲目"
|
||||||
},
|
},
|
||||||
"albumArtistList": {
|
"albumArtistList": {
|
||||||
"title": "$t(entity.albumArtist_other)"
|
"title": "$t(entity.albumArtist_other)"
|
||||||
},
|
},
|
||||||
"albumList": {
|
"albumList": {
|
||||||
"title": "$t(entity.album_other)",
|
"title": "$t(entity.album_other)",
|
||||||
"artistAlbums": "{{artist}} 的专辑",
|
"artistAlbums": "{{artist}}的专辑",
|
||||||
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)"
|
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)"
|
||||||
},
|
},
|
||||||
"genreList": {
|
"genreList": {
|
||||||
"title": "$t(entity.genre_other)",
|
"title": "$t(entity.genre_other)",
|
||||||
"showAlbums": "显示 $t(entity.genre_one) $t(entity.album_other)",
|
"showAlbums": "显示$t(entity.genre_one) $t(entity.album_other)",
|
||||||
"showTracks": "显示 $t(entity.genre_one) $t(entity.track_other)"
|
"showTracks": "显示$t(entity.genre_one) $t(entity.track_other)"
|
||||||
},
|
},
|
||||||
"playlistList": {
|
"playlistList": {
|
||||||
"title": "$t(entity.playlist_other)"
|
"title": "$t(entity.playlist_other)"
|
||||||
@@ -524,11 +564,11 @@
|
|||||||
"albumArtistDetail": {
|
"albumArtistDetail": {
|
||||||
"recentReleases": "最近发布",
|
"recentReleases": "最近发布",
|
||||||
"viewDiscography": "查看唱片目录",
|
"viewDiscography": "查看唱片目录",
|
||||||
"relatedArtists": "相关 $t(entity.artist_other)",
|
"relatedArtists": "相关$t(entity.artist_other)",
|
||||||
"topSongs": "热门歌曲",
|
"topSongs": "热门歌曲",
|
||||||
"topSongsFrom": "{{title}} 的热门歌曲",
|
"topSongsFrom": "{{title}}的热门歌曲",
|
||||||
"viewAllTracks": "查看所有 $t(entity.track_other)",
|
"viewAllTracks": "查看所有$t(entity.track_other)",
|
||||||
"about": "关于 {{artist}}",
|
"about": "关于{{artist}}",
|
||||||
"appearsOn": "出现在",
|
"appearsOn": "出现在",
|
||||||
"viewAll": "查看全部"
|
"viewAll": "查看全部"
|
||||||
},
|
},
|
||||||
@@ -536,6 +576,9 @@
|
|||||||
"copyPath": "将路径复制到剪贴板",
|
"copyPath": "将路径复制到剪贴板",
|
||||||
"copiedPath": "路径复制成功",
|
"copiedPath": "路径复制成功",
|
||||||
"openFile": "在文件管理器中显示曲目"
|
"openFile": "在文件管理器中显示曲目"
|
||||||
|
},
|
||||||
|
"playlist": {
|
||||||
|
"reorder": "仅在按 ID 排序时启用重排序"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
@@ -549,7 +592,7 @@
|
|||||||
"input_username": "用户名",
|
"input_username": "用户名",
|
||||||
"input_password": "密码",
|
"input_password": "密码",
|
||||||
"input_legacyAuthentication": "启用旧版认证方式",
|
"input_legacyAuthentication": "启用旧版认证方式",
|
||||||
"input_name": "服务器名",
|
"input_name": "服务器名称",
|
||||||
"success": "服务器添加成功",
|
"success": "服务器添加成功",
|
||||||
"input_savePassword": "保存密码",
|
"input_savePassword": "保存密码",
|
||||||
"ignoreSsl": "忽略 ssl $t(common.restartRequired)",
|
"ignoreSsl": "忽略 ssl $t(common.restartRequired)",
|
||||||
@@ -558,7 +601,7 @@
|
|||||||
"input_url": "url"
|
"input_url": "url"
|
||||||
},
|
},
|
||||||
"addToPlaylist": {
|
"addToPlaylist": {
|
||||||
"success": "添加 $t(entity.trackWithCount, {\"count\": {{message}} }) 到 $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
"success": "添加$t(entity.trackWithCount, {\"count\": {{message}} })到$t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||||
"title": "添加到$t(entity.playlist_one)",
|
"title": "添加到$t(entity.playlist_one)",
|
||||||
"input_skipDuplicates": "跳过重复",
|
"input_skipDuplicates": "跳过重复",
|
||||||
"input_playlists": "$t(entity.playlist_other)"
|
"input_playlists": "$t(entity.playlist_other)"
|
||||||
@@ -580,7 +623,9 @@
|
|||||||
"input_optionMatchAny": "匹配任何"
|
"input_optionMatchAny": "匹配任何"
|
||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "编辑$t(entity.playlist_one)"
|
"title": "编辑$t(entity.playlist_one)",
|
||||||
|
"publicJellyfinNote": "Jellyfin 出于某种原因不会显示播放列表是否公开。如果您希望保持公开,请选择以下输入",
|
||||||
|
"success": "$t(entity.playlist_one)更新成功"
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"title": "搜索歌词",
|
"title": "搜索歌词",
|
||||||
@@ -589,7 +634,7 @@
|
|||||||
},
|
},
|
||||||
"shareItem": {
|
"shareItem": {
|
||||||
"expireInvalid": "过期时间必须是将来的时间",
|
"expireInvalid": "过期时间必须是将来的时间",
|
||||||
"createFailed": "创建共享失败(是否启用共享?)",
|
"createFailed": "创建共享失败(是否已启用共享?)",
|
||||||
"allowDownloading": "允许下载",
|
"allowDownloading": "允许下载",
|
||||||
"description": "描述",
|
"description": "描述",
|
||||||
"setExpiration": "设置过期时间",
|
"setExpiration": "设置过期时间",
|
||||||
@@ -599,7 +644,7 @@
|
|||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
"general": {
|
"general": {
|
||||||
"displayType": "显示风格",
|
"displayType": "显示类型",
|
||||||
"gap": "$t(common.gap)",
|
"gap": "$t(common.gap)",
|
||||||
"tableColumns": "列",
|
"tableColumns": "列",
|
||||||
"autoFitColumns": "列宽自适应",
|
"autoFitColumns": "列宽自适应",
|
||||||
@@ -621,7 +666,7 @@
|
|||||||
"bpm": "$t(common.bpm)",
|
"bpm": "$t(common.bpm)",
|
||||||
"lastPlayed": "最后播放",
|
"lastPlayed": "最后播放",
|
||||||
"trackNumber": "音轨编号",
|
"trackNumber": "音轨编号",
|
||||||
"rowIndex": "行号",
|
"rowIndex": "行索引",
|
||||||
"rating": "$t(common.rating)",
|
"rating": "$t(common.rating)",
|
||||||
"artist": "$t(entity.artist_one)",
|
"artist": "$t(entity.artist_one)",
|
||||||
"album": "$t(entity.album_one)",
|
"album": "$t(entity.album_one)",
|
||||||
@@ -630,7 +675,7 @@
|
|||||||
"owner": "$t(common.owner)",
|
"owner": "$t(common.owner)",
|
||||||
"path": "$t(common.path)",
|
"path": "$t(common.path)",
|
||||||
"channels": "$t(common.channel_other)",
|
"channels": "$t(common.channel_other)",
|
||||||
"playCount": "播放数",
|
"playCount": "播放次数",
|
||||||
"bitrate": "$t(common.bitrate)",
|
"bitrate": "$t(common.bitrate)",
|
||||||
"actions": "$t(common.action_other)",
|
"actions": "$t(common.action_other)",
|
||||||
"genre": "$t(entity.genre_one)",
|
"genre": "$t(entity.genre_one)",
|
||||||
@@ -639,13 +684,14 @@
|
|||||||
"year": "$t(common.year)",
|
"year": "$t(common.year)",
|
||||||
"albumArtist": "$t(entity.albumArtist_one)",
|
"albumArtist": "$t(entity.albumArtist_one)",
|
||||||
"titleCombined": "$t(common.title)(合并)",
|
"titleCombined": "$t(common.title)(合并)",
|
||||||
"codec": "$t(common.codec)"
|
"codec": "$t(common.codec)",
|
||||||
|
"songCount": "$t(entity.track_other)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"column": {
|
"column": {
|
||||||
"comment": "评论",
|
"comment": "评论",
|
||||||
"album": "专辑",
|
"album": "专辑",
|
||||||
"rating": "评价",
|
"rating": "评分",
|
||||||
"favorite": "收藏",
|
"favorite": "收藏",
|
||||||
"playCount": "播放次数",
|
"playCount": "播放次数",
|
||||||
"albumCount": "$t(entity.album_other)",
|
"albumCount": "$t(entity.album_other)",
|
||||||
@@ -664,7 +710,7 @@
|
|||||||
"albumArtist": "专辑艺术家",
|
"albumArtist": "专辑艺术家",
|
||||||
"path": "路径",
|
"path": "路径",
|
||||||
"channels": "$t(common.channel_other)",
|
"channels": "$t(common.channel_other)",
|
||||||
"discNumber": "盘",
|
"discNumber": "碟片",
|
||||||
"size": "$t(common.size)",
|
"size": "$t(common.size)",
|
||||||
"codec": "$t(common.codec)"
|
"codec": "$t(common.codec)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import console from 'console';
|
import console from 'console';
|
||||||
|
import { rm } from 'fs/promises';
|
||||||
|
import { pid } from 'node:process';
|
||||||
import { app, ipcMain } from 'electron';
|
import { app, ipcMain } from 'electron';
|
||||||
import uniq from 'lodash/uniq';
|
import uniq from 'lodash/uniq';
|
||||||
import MpvAPI from 'node-mpv';
|
import MpvAPI from 'node-mpv';
|
||||||
import { getMainWindow, sendToastToRenderer } from '../../../main';
|
import { getMainWindow, sendToastToRenderer } from '../../../main';
|
||||||
import { PlayerData } from '/@/renderer/store';
|
|
||||||
import { createLog, isWindows } from '../../../utils';
|
import { createLog, isWindows } from '../../../utils';
|
||||||
import { store } from '../settings';
|
import { store } from '../settings';
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ declare module 'node-mpv';
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
let mpvInstance: MpvAPI | null = null;
|
let mpvInstance: MpvAPI | null = null;
|
||||||
|
const socketPath = isWindows() ? `\\\\.\\pipe\\mpvserver-${pid}` : `/tmp/node-mpv-${pid}.sock`;
|
||||||
|
|
||||||
const NodeMpvErrorCode = {
|
const NodeMpvErrorCode = {
|
||||||
0: 'Unable to load file or stream',
|
0: 'Unable to load file or stream',
|
||||||
@@ -62,7 +64,6 @@ const mpvLog = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
|
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
|
||||||
const isDevelopment = process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';
|
|
||||||
|
|
||||||
const prefetchPlaylistParams = [
|
const prefetchPlaylistParams = [
|
||||||
'--prefetch-playlist=no',
|
'--prefetch-playlist=no',
|
||||||
@@ -89,14 +90,12 @@ const createMpv = async (data: {
|
|||||||
|
|
||||||
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
|
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
|
||||||
|
|
||||||
const extra = isDevelopment ? '-dev' : '';
|
|
||||||
|
|
||||||
const mpv = new MpvAPI(
|
const mpv = new MpvAPI(
|
||||||
{
|
{
|
||||||
audio_only: true,
|
audio_only: true,
|
||||||
auto_restart: false,
|
auto_restart: false,
|
||||||
binary: binaryPath || MPV_BINARY_PATH || undefined,
|
binary: binaryPath || MPV_BINARY_PATH || undefined,
|
||||||
socket: isWindows() ? `\\\\.\\pipe\\mpvserver${extra}` : `/tmp/node-mpv${extra}.sock`,
|
socket: socketPath,
|
||||||
time_update: 1,
|
time_update: 1,
|
||||||
},
|
},
|
||||||
params,
|
params,
|
||||||
@@ -149,6 +148,16 @@ export const getMpvInstance = () => {
|
|||||||
return mpvInstance;
|
return mpvInstance;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const quit = async () => {
|
||||||
|
const instance = getMpvInstance();
|
||||||
|
if (instance) {
|
||||||
|
await instance.quit();
|
||||||
|
if (!isWindows()) {
|
||||||
|
await rm(socketPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setAudioPlayerFallback = (isError: boolean) => {
|
const setAudioPlayerFallback = (isError: boolean) => {
|
||||||
getMainWindow()?.webContents.send('renderer-player-fallback', isError);
|
getMainWindow()?.webContents.send('renderer-player-fallback', isError);
|
||||||
};
|
};
|
||||||
@@ -216,7 +225,7 @@ ipcMain.handle(
|
|||||||
ipcMain.on('player-quit', async () => {
|
ipcMain.on('player-quit', async () => {
|
||||||
try {
|
try {
|
||||||
await getMpvInstance()?.stop();
|
await getMpvInstance()?.stop();
|
||||||
await getMpvInstance()?.quit();
|
await quit();
|
||||||
} catch (err: NodeMpvError | any) {
|
} catch (err: NodeMpvError | any) {
|
||||||
mpvLog({ action: 'Failed to quit mpv' }, err);
|
mpvLog({ action: 'Failed to quit mpv' }, err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -305,8 +314,8 @@ ipcMain.on('player-seek-to', async (_event, time: number) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
|
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
|
||||||
ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean) => {
|
ipcMain.on('player-set-queue', async (_event, current?: string, next?: string, pause?: boolean) => {
|
||||||
if (!data.queue.current?.id && !data.queue.next?.id) {
|
if (!current && !next) {
|
||||||
try {
|
try {
|
||||||
await getMpvInstance()?.clearPlaylist();
|
await getMpvInstance()?.clearPlaylist();
|
||||||
await getMpvInstance()?.pause();
|
await getMpvInstance()?.pause();
|
||||||
@@ -317,15 +326,15 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (data.queue.current?.streamUrl) {
|
if (current) {
|
||||||
await getMpvInstance()
|
try {
|
||||||
?.load(data.queue.current.streamUrl, 'replace')
|
await getMpvInstance()?.load(current, 'replace');
|
||||||
.catch(() => {
|
} catch (error) {
|
||||||
getMpvInstance()?.play();
|
await getMpvInstance()?.play();
|
||||||
});
|
}
|
||||||
|
|
||||||
if (data.queue.next?.streamUrl) {
|
if (next) {
|
||||||
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
await getMpvInstance()?.load(next, 'append');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +350,7 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Replaces the queue in position 1 to the given data
|
// Replaces the queue in position 1 to the given data
|
||||||
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
|
ipcMain.on('player-set-queue-next', async (_event, url?: string) => {
|
||||||
try {
|
try {
|
||||||
const size = await getMpvInstance()?.getPlaylistSize();
|
const size = await getMpvInstance()?.getPlaylistSize();
|
||||||
|
|
||||||
@@ -353,8 +362,8 @@ ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
|
|||||||
await getMpvInstance()?.playlistRemove(1);
|
await getMpvInstance()?.playlistRemove(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.queue.next?.streamUrl) {
|
if (url) {
|
||||||
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
await getMpvInstance()?.load(url, 'append');
|
||||||
}
|
}
|
||||||
} catch (err: NodeMpvError | any) {
|
} catch (err: NodeMpvError | any) {
|
||||||
mpvLog({ action: `Failed to set play queue` }, err);
|
mpvLog({ action: `Failed to set play queue` }, err);
|
||||||
@@ -362,7 +371,7 @@ ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Sets the next song in the queue when reaching the end of the queue
|
// Sets the next song in the queue when reaching the end of the queue
|
||||||
ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
|
ipcMain.on('player-auto-next', async (_event, url?: string) => {
|
||||||
// Always keep the current song as position 0 in the mpv queue
|
// Always keep the current song as position 0 in the mpv queue
|
||||||
// This allows us to easily set update the next song in the queue without
|
// This allows us to easily set update the next song in the queue without
|
||||||
// disturbing the currently playing song
|
// disturbing the currently playing song
|
||||||
@@ -373,8 +382,8 @@ ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
|
|||||||
getMpvInstance()?.pause();
|
getMpvInstance()?.pause();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.queue.next?.streamUrl) {
|
if (url) {
|
||||||
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
await getMpvInstance()?.load(url, 'append');
|
||||||
}
|
}
|
||||||
} catch (err: NodeMpvError | any) {
|
} catch (err: NodeMpvError | any) {
|
||||||
mpvLog({ action: `Failed to load next song` }, err);
|
mpvLog({ action: `Failed to load next song` }, err);
|
||||||
@@ -412,19 +421,34 @@ ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', async () => {
|
enum MpvState {
|
||||||
try {
|
STARTED,
|
||||||
await getMpvInstance()?.stop();
|
IN_PROGRESS,
|
||||||
await getMpvInstance()?.quit();
|
DONE,
|
||||||
} catch (err: NodeMpvError | any) {
|
}
|
||||||
mpvLog({ action: `Failed to cleanly before-quit` }, err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on('window-all-closed', async () => {
|
let mpvState = MpvState.STARTED;
|
||||||
try {
|
|
||||||
await getMpvInstance()?.quit();
|
app.on('before-quit', async (event) => {
|
||||||
} catch (err: NodeMpvError | any) {
|
switch (mpvState) {
|
||||||
mpvLog({ action: `Failed to cleanly exit` }, err);
|
case MpvState.DONE:
|
||||||
|
return;
|
||||||
|
case MpvState.IN_PROGRESS:
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
case MpvState.STARTED: {
|
||||||
|
try {
|
||||||
|
mpvState = MpvState.IN_PROGRESS;
|
||||||
|
event.preventDefault();
|
||||||
|
await getMpvInstance()?.stop();
|
||||||
|
await quit();
|
||||||
|
} catch (err: NodeMpvError | any) {
|
||||||
|
mpvLog({ action: `Failed to cleanly before-quit` }, err);
|
||||||
|
} finally {
|
||||||
|
mpvState = MpvState.DONE;
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { app, ipcMain } from 'electron';
|
|||||||
import { Server as WsServer, WebSocketServer, WebSocket } from 'ws';
|
import { Server as WsServer, WebSocketServer, WebSocket } from 'ws';
|
||||||
import manifest from './manifest.json';
|
import manifest from './manifest.json';
|
||||||
import { ClientEvent, ServerEvent } from '../../../../remote/types';
|
import { ClientEvent, ServerEvent } from '../../../../remote/types';
|
||||||
import { PlayerRepeat, SongUpdate } from '../../../../renderer/types';
|
import { PlayerRepeat, PlayerStatus, SongState } from '../../../../renderer/types';
|
||||||
import { getMainWindow } from '../../../main';
|
import { getMainWindow } from '../../../main';
|
||||||
import { isLinux } from '../../../utils';
|
import { isLinux } from '../../../utils';
|
||||||
|
import type { QueueSong } from '/@/renderer/api/types';
|
||||||
|
|
||||||
let mprisPlayer: any | undefined;
|
let mprisPlayer: any | undefined;
|
||||||
|
|
||||||
@@ -33,13 +34,14 @@ interface MimeType {
|
|||||||
js: string;
|
js: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatefulWebSocket extends WebSocket {
|
declare class StatefulWebSocket extends WebSocket {
|
||||||
alive: boolean;
|
alive: boolean;
|
||||||
|
|
||||||
auth: boolean;
|
auth: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let server: Server | undefined;
|
let server: Server | undefined;
|
||||||
let wsServer: WsServer<StatefulWebSocket> | undefined;
|
let wsServer: WsServer<typeof StatefulWebSocket> | undefined;
|
||||||
|
|
||||||
const settings: RemoteConfig = {
|
const settings: RemoteConfig = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -100,9 +102,7 @@ enum Encoding {
|
|||||||
const GZIP_REGEX = /\bgzip\b/;
|
const GZIP_REGEX = /\bgzip\b/;
|
||||||
const ZLIB_REGEX = /bdeflate\b/;
|
const ZLIB_REGEX = /bdeflate\b/;
|
||||||
|
|
||||||
let currentSong: SongUpdate = {
|
const currentState: SongState = {};
|
||||||
currentTime: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEncoding = (encoding: string | string[]): Encoding => {
|
const getEncoding = (encoding: string | string[]): Encoding => {
|
||||||
const encodingArray = Array.isArray(encoding) ? encoding : [encoding];
|
const encodingArray = Array.isArray(encoding) ? encoding : [encoding];
|
||||||
@@ -328,9 +328,9 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
server.listen(config.port, resolve);
|
server.listen(config.port, resolve);
|
||||||
wsServer = new WebSocketServer({ server });
|
wsServer = new WebSocketServer<typeof StatefulWebSocket>({ server });
|
||||||
|
|
||||||
wsServer.on('connection', (ws) => {
|
wsServer!.on('connection', (ws: StatefulWebSocket) => {
|
||||||
let authFail: number | undefined;
|
let authFail: number | undefined;
|
||||||
ws.alive = true;
|
ws.alive = true;
|
||||||
|
|
||||||
@@ -388,7 +388,7 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'proxy': {
|
case 'proxy': {
|
||||||
const toFetch = currentSong.song?.imageUrl?.replaceAll(
|
const toFetch = currentState.song?.imageUrl?.replaceAll(
|
||||||
/&(size|width|height=\d+)/g,
|
/&(size|width|height=\d+)/g,
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
@@ -438,9 +438,9 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
|
|||||||
volume = 0;
|
volume = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentSong.volume = volume;
|
currentState.volume = volume;
|
||||||
|
|
||||||
broadcast({ data: { volume }, event: 'song' });
|
broadcast({ data: volume, event: 'volume' });
|
||||||
getMainWindow()?.webContents.send('request-volume', {
|
getMainWindow()?.webContents.send('request-volume', {
|
||||||
volume,
|
volume,
|
||||||
});
|
});
|
||||||
@@ -452,26 +452,35 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
|
|||||||
}
|
}
|
||||||
case 'favorite': {
|
case 'favorite': {
|
||||||
const { favorite, id } = json;
|
const { favorite, id } = json;
|
||||||
if (id && id === currentSong.song?.id) {
|
if (id && id === currentState.song?.id) {
|
||||||
getMainWindow()?.webContents.send('request-favorite', {
|
getMainWindow()?.webContents.send('request-favorite', {
|
||||||
favorite,
|
favorite,
|
||||||
id,
|
id,
|
||||||
serverId: currentSong.song.serverId,
|
serverId: currentState.song.serverId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'rating': {
|
case 'rating': {
|
||||||
const { rating, id } = json;
|
const { rating, id } = json;
|
||||||
if (id && id === currentSong.song?.id) {
|
if (id && id === currentState.song?.id) {
|
||||||
getMainWindow()?.webContents.send('request-rating', {
|
getMainWindow()?.webContents.send('request-rating', {
|
||||||
id,
|
id,
|
||||||
rating,
|
rating,
|
||||||
serverId: currentSong.song.serverId,
|
serverId: currentState.song.serverId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'position': {
|
||||||
|
const { position } = json;
|
||||||
|
if (mprisPlayer) {
|
||||||
|
mprisPlayer.getPosition = () => position * 1e6;
|
||||||
|
}
|
||||||
|
getMainWindow()?.webContents.send('request-position', {
|
||||||
|
position,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -482,7 +491,7 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
|
|||||||
ws.alive = true;
|
ws.alive = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.send(JSON.stringify({ data: currentSong, event: 'song' }));
|
ws.send(JSON.stringify({ data: currentState, event: 'state' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
const heartBeat = setInterval(() => {
|
const heartBeat = setInterval(() => {
|
||||||
@@ -497,7 +506,7 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
|
|||||||
});
|
});
|
||||||
}, PING_TIMEOUT_MS);
|
}, PING_TIMEOUT_MS);
|
||||||
|
|
||||||
wsServer.on('close', () => {
|
wsServer!.on('close', () => {
|
||||||
clearInterval(heartBeat);
|
clearInterval(heartBeat);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -564,13 +573,13 @@ ipcMain.on('remote-username', (_event, username: string) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids: string[]) => {
|
ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids: string[]) => {
|
||||||
if (currentSong.song?.serverId !== serverId) return;
|
if (currentState.song?.serverId !== serverId) return;
|
||||||
|
|
||||||
const id = currentSong.song.id;
|
const id = currentState.song.id;
|
||||||
|
|
||||||
for (const songId of ids) {
|
for (const songId of ids) {
|
||||||
if (songId === id) {
|
if (songId === id) {
|
||||||
currentSong.song.userFavorite = favorite;
|
currentState.song.userFavorite = favorite;
|
||||||
broadcast({ data: { favorite, id: songId }, event: 'favorite' });
|
broadcast({ data: { favorite, id: songId }, event: 'favorite' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -578,13 +587,13 @@ ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids:
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: string[]) => {
|
ipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: string[]) => {
|
||||||
if (currentSong.song?.serverId !== serverId) return;
|
if (currentState.song?.serverId !== serverId) return;
|
||||||
|
|
||||||
const id = currentSong.song.id;
|
const id = currentState.song.id;
|
||||||
|
|
||||||
for (const songId of ids) {
|
for (const songId of ids) {
|
||||||
if (songId === id) {
|
if (songId === id) {
|
||||||
currentSong.song.userRating = rating;
|
currentState.song.userRating = rating;
|
||||||
broadcast({ data: { id: songId, rating }, event: 'rating' });
|
broadcast({ data: { id: songId, rating }, event: 'rating' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -592,42 +601,32 @@ ipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: stri
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => {
|
ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => {
|
||||||
currentSong.repeat = repeat;
|
currentState.repeat = repeat;
|
||||||
broadcast({ data: { repeat }, event: 'song' });
|
broadcast({ data: repeat, event: 'repeat' });
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
|
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
|
||||||
currentSong.shuffle = shuffle;
|
currentState.shuffle = shuffle;
|
||||||
broadcast({ data: { shuffle }, event: 'song' });
|
broadcast({ data: shuffle, event: 'shuffle' });
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('update-song', (_event, data: SongUpdate) => {
|
ipcMain.on('update-playback', (_event, status: PlayerStatus) => {
|
||||||
const { song, ...rest } = data;
|
currentState.status = status;
|
||||||
const songChanged = song?.id !== currentSong.song?.id;
|
broadcast({ data: status, event: 'playback' });
|
||||||
|
});
|
||||||
|
|
||||||
if (!song?.id) {
|
ipcMain.on('update-song', (_event, song: QueueSong | undefined) => {
|
||||||
currentSong = {
|
const songChanged = song?.id !== currentState.song?.id;
|
||||||
...currentSong,
|
currentState.song = song;
|
||||||
...data,
|
|
||||||
song: undefined,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
currentSong = {
|
|
||||||
...currentSong,
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (songChanged) {
|
if (songChanged) {
|
||||||
broadcast({ data: { ...rest, song: song || null }, event: 'song' });
|
broadcast({ data: song || null, event: 'song' });
|
||||||
} else {
|
|
||||||
broadcast({ data: rest, event: 'song' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('update-volume', (_event, volume: number) => {
|
ipcMain.on('update-volume', (_event, volume: number) => {
|
||||||
currentSong.volume = volume;
|
currentState.volume = volume;
|
||||||
broadcast({ data: { volume }, event: 'song' });
|
broadcast({ data: volume, event: 'volume' });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mprisPlayer) {
|
if (mprisPlayer) {
|
||||||
@@ -636,16 +635,16 @@ if (mprisPlayer) {
|
|||||||
event === 'Playlist'
|
event === 'Playlist'
|
||||||
? PlayerRepeat.ALL
|
? PlayerRepeat.ALL
|
||||||
: event === 'Track'
|
: event === 'Track'
|
||||||
? PlayerRepeat.ONE
|
? PlayerRepeat.ONE
|
||||||
: PlayerRepeat.NONE;
|
: PlayerRepeat.NONE;
|
||||||
|
|
||||||
currentSong.repeat = repeat;
|
currentState.repeat = repeat;
|
||||||
broadcast({ data: { repeat }, event: 'song' });
|
broadcast({ data: repeat, event: 'repeat' });
|
||||||
});
|
});
|
||||||
|
|
||||||
mprisPlayer.on('shuffle', (shuffle: boolean) => {
|
mprisPlayer.on('shuffle', (shuffle: boolean) => {
|
||||||
currentSong.shuffle = shuffle;
|
currentState.shuffle = shuffle;
|
||||||
broadcast({ data: { shuffle }, event: 'song' });
|
broadcast({ data: shuffle, event: 'shuffle' });
|
||||||
});
|
});
|
||||||
|
|
||||||
mprisPlayer.on('volume', (vol: number) => {
|
mprisPlayer.on('volume', (vol: number) => {
|
||||||
@@ -656,7 +655,12 @@ if (mprisPlayer) {
|
|||||||
} else if (volume < 0) {
|
} else if (volume < 0) {
|
||||||
volume = 0;
|
volume = 0;
|
||||||
}
|
}
|
||||||
currentSong.volume = volume;
|
currentState.volume = volume;
|
||||||
broadcast({ data: { volume }, event: 'song' });
|
broadcast({ data: volume, event: 'volume' });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ipcMain.on('update-position', (_event, position: number) => {
|
||||||
|
currentState.position = position;
|
||||||
|
broadcast({ data: position, event: 'position' });
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ipcMain } from 'electron';
|
import { ipcMain } from 'electron';
|
||||||
import Player from 'mpris-service';
|
import Player from 'mpris-service';
|
||||||
import { PlayerRepeat, PlayerStatus, SongUpdate } from '../../../renderer/types';
|
import { PlayerRepeat, PlayerStatus } from '../../../renderer/types';
|
||||||
import { getMainWindow } from '../../main';
|
import { getMainWindow } from '../../main';
|
||||||
|
import { QueueSong } from '/@/renderer/api/types';
|
||||||
|
|
||||||
const mprisPlayer = Player({
|
const mprisPlayer = Player({
|
||||||
identity: 'Feishin',
|
identity: 'Feishin',
|
||||||
@@ -105,7 +106,7 @@ mprisPlayer.on('seek', (event: number) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('mpris-update-position', (_event, arg) => {
|
ipcMain.on('update-position', (_event, arg: number) => {
|
||||||
mprisPlayer.getPosition = () => arg * 1e6;
|
mprisPlayer.getPosition = () => arg * 1e6;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,6 +118,10 @@ ipcMain.on('update-volume', (_event, volume) => {
|
|||||||
mprisPlayer.volume = Number(volume) / 100;
|
mprisPlayer.volume = Number(volume) / 100;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.on('update-playback', (_event, status: PlayerStatus) => {
|
||||||
|
mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused';
|
||||||
|
});
|
||||||
|
|
||||||
const REPEAT_TO_MPRIS: Record<PlayerRepeat, string> = {
|
const REPEAT_TO_MPRIS: Record<PlayerRepeat, string> = {
|
||||||
[PlayerRepeat.ALL]: 'Playlist',
|
[PlayerRepeat.ALL]: 'Playlist',
|
||||||
[PlayerRepeat.ONE]: 'Track',
|
[PlayerRepeat.ONE]: 'Track',
|
||||||
@@ -131,21 +136,9 @@ ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
|
|||||||
mprisPlayer.shuffle = shuffle;
|
mprisPlayer.shuffle = shuffle;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('update-song', (_event, args: SongUpdate) => {
|
ipcMain.on('update-song', (_event, song: QueueSong | undefined) => {
|
||||||
const { song, status, repeat, shuffle } = args || {};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused';
|
if (!song?.id) {
|
||||||
|
|
||||||
if (repeat) {
|
|
||||||
mprisPlayer.loopStatus = REPEAT_TO_MPRIS[repeat];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shuffle) {
|
|
||||||
mprisPlayer.shuffle = shuffle;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!song) {
|
|
||||||
mprisPlayer.metadata = {};
|
mprisPlayer.metadata = {};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-17
@@ -67,8 +67,8 @@ if (store.get('ignore_ssl')) {
|
|||||||
|
|
||||||
// From https://github.com/tutao/tutanota/commit/92c6ed27625fcf367f0fbcc755d83d7ff8fde94b
|
// From https://github.com/tutao/tutanota/commit/92c6ed27625fcf367f0fbcc755d83d7ff8fde94b
|
||||||
if (isLinux() && !process.argv.some((a) => a.startsWith('--password-store='))) {
|
if (isLinux() && !process.argv.some((a) => a.startsWith('--password-store='))) {
|
||||||
const paswordStore = store.get('password_store', 'gnome-libsecret') as string;
|
const passwordStore = store.get('password_store', 'gnome-libsecret') as string;
|
||||||
app.commandLine.appendSwitch('password-store', paswordStore);
|
app.commandLine.appendSwitch('password-store', passwordStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
@@ -199,7 +199,7 @@ const createTray = () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
tray.on('double-click', () => {
|
tray.on('click', () => {
|
||||||
mainWindow?.show();
|
mainWindow?.show();
|
||||||
createWinThumbarButtons();
|
createWinThumbarButtons();
|
||||||
});
|
});
|
||||||
@@ -210,7 +210,7 @@ const createTray = () => {
|
|||||||
|
|
||||||
const createWindow = async (first = true) => {
|
const createWindow = async (first = true) => {
|
||||||
if (isDevelopment) {
|
if (isDevelopment) {
|
||||||
await installExtensions();
|
await installExtensions().catch(console.log);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nativeFrame = store.get('window_window_bar_style') === 'linux';
|
const nativeFrame = store.get('window_window_bar_style') === 'linux';
|
||||||
@@ -364,18 +364,8 @@ const createWindow = async (first = true) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('open-item', async (_event, path: string) => {
|
ipcMain.on('download-url', (_event, url: string) => {
|
||||||
return new Promise<void>((resolve, reject) => {
|
mainWindow?.webContents.downloadURL(url);
|
||||||
access(path, constants.F_OK, (error) => {
|
|
||||||
if (error) {
|
|
||||||
reject(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
shell.showItemInFolder(path);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
|
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
|
||||||
@@ -487,7 +477,7 @@ const createWindow = async (first = true) => {
|
|||||||
const menuBuilder = new MenuBuilder(mainWindow);
|
const menuBuilder = new MenuBuilder(mainWindow);
|
||||||
menuBuilder.buildMenu();
|
menuBuilder.buildMenu();
|
||||||
|
|
||||||
// Open urls in the user's browser
|
// Open URLs in the user's browser
|
||||||
mainWindow.webContents.setWindowOpenHandler((edata) => {
|
mainWindow.webContents.setWindowOpenHandler((edata) => {
|
||||||
shell.openExternal(edata.url);
|
shell.openExternal(edata.url);
|
||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
@@ -629,6 +619,8 @@ if (!singleInstance) {
|
|||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
if (mainWindow.isMinimized()) {
|
if (mainWindow.isMinimized()) {
|
||||||
mainWindow.restore();
|
mainWindow.restore();
|
||||||
|
} else if (!mainWindow.isVisible()) {
|
||||||
|
mainWindow.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
@@ -668,3 +660,20 @@ if (!singleInstance) {
|
|||||||
})
|
})
|
||||||
.catch(console.log);
|
.catch(console.log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register 'open-item' handler globally, ensuring it is only registered once
|
||||||
|
if (!ipcMain.eventNames().includes('open-item')) {
|
||||||
|
ipcMain.handle('open-item', async (_event, path: string) => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
access(path, constants.F_OK, (error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.showItemInFolder(path);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ const setProperties = (data: Record<string, any>) => {
|
|||||||
ipcRenderer.send('player-set-properties', data);
|
ipcRenderer.send('player-set-properties', data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const autoNext = (data: PlayerData) => {
|
const autoNext = (url?: string) => {
|
||||||
ipcRenderer.send('player-auto-next', data);
|
ipcRenderer.send('player-auto-next', url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentTime = () => {
|
const currentTime = () => {
|
||||||
@@ -61,12 +61,12 @@ const seekTo = (seconds: number) => {
|
|||||||
ipcRenderer.send('player-seek-to', seconds);
|
ipcRenderer.send('player-seek-to', seconds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setQueue = (data: PlayerData, pause?: boolean) => {
|
const setQueue = (current?: string, next?: string, pause?: boolean) => {
|
||||||
ipcRenderer.send('player-set-queue', data, pause);
|
ipcRenderer.send('player-set-queue', current, next, pause);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setQueueNext = (data: PlayerData) => {
|
const setQueueNext = (url?: string) => {
|
||||||
ipcRenderer.send('player-set-queue-next', data);
|
ipcRenderer.send('player-set-queue-next', url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IpcRendererEvent, ipcRenderer } from 'electron';
|
import { IpcRendererEvent, ipcRenderer } from 'electron';
|
||||||
import { SongUpdate } from '/@/renderer/types';
|
import { QueueSong } from '/@/renderer/api/types';
|
||||||
|
import { PlayerStatus } from '/@/renderer/types';
|
||||||
|
|
||||||
const requestFavorite = (
|
const requestFavorite = (
|
||||||
cb: (
|
cb: (
|
||||||
@@ -46,6 +47,10 @@ const updatePassword = (password: string) => {
|
|||||||
ipcRenderer.send('remote-password', password);
|
ipcRenderer.send('remote-password', password);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updatePlayback = (playback: PlayerStatus) => {
|
||||||
|
ipcRenderer.send('update-playback', playback);
|
||||||
|
};
|
||||||
|
|
||||||
const updateSetting = (
|
const updateSetting = (
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
port: number,
|
port: number,
|
||||||
@@ -67,7 +72,7 @@ const updateShuffle = (shuffle: boolean) => {
|
|||||||
ipcRenderer.send('update-shuffle', shuffle);
|
ipcRenderer.send('update-shuffle', shuffle);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSong = (args: SongUpdate) => {
|
const updateSong = (args: QueueSong | undefined) => {
|
||||||
ipcRenderer.send('update-song', args);
|
ipcRenderer.send('update-song', args);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,6 +84,10 @@ const updateVolume = (volume: number) => {
|
|||||||
ipcRenderer.send('update-volume', volume);
|
ipcRenderer.send('update-volume', volume);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updatePosition = (timeSec: number) => {
|
||||||
|
ipcRenderer.send('update-position', timeSec);
|
||||||
|
};
|
||||||
|
|
||||||
export const remote = {
|
export const remote = {
|
||||||
requestFavorite,
|
requestFavorite,
|
||||||
requestPosition,
|
requestPosition,
|
||||||
@@ -89,6 +98,8 @@ export const remote = {
|
|||||||
setRemotePort,
|
setRemotePort,
|
||||||
updateFavorite,
|
updateFavorite,
|
||||||
updatePassword,
|
updatePassword,
|
||||||
|
updatePlayback,
|
||||||
|
updatePosition,
|
||||||
updateRating,
|
updateRating,
|
||||||
updateRepeat,
|
updateRepeat,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
|
|||||||
@@ -47,7 +47,12 @@ const logger = (
|
|||||||
ipcRenderer.send('logger', cb);
|
ipcRenderer.send('logger', cb);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const download = (url: string) => {
|
||||||
|
ipcRenderer.send('download-url', url);
|
||||||
|
};
|
||||||
|
|
||||||
export const utils = {
|
export const utils = {
|
||||||
|
download,
|
||||||
isLinux,
|
isLinux,
|
||||||
isMacOS,
|
isMacOS,
|
||||||
isWindows,
|
isWindows,
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ import {
|
|||||||
import { PlayerRepeat, PlayerStatus } from '/@/renderer/types';
|
import { PlayerRepeat, PlayerStatus } from '/@/renderer/types';
|
||||||
import { WrapperSlider } from '/@/remote/components/wrapped-slider';
|
import { WrapperSlider } from '/@/remote/components/wrapped-slider';
|
||||||
import { Tooltip } from '/@/renderer/components/tooltip';
|
import { Tooltip } from '/@/renderer/components/tooltip';
|
||||||
import { Rating } from '/@/renderer/components';
|
import { Rating } from '/@/renderer/components/rating';
|
||||||
|
|
||||||
export const RemoteContainer = () => {
|
export const RemoteContainer = () => {
|
||||||
const { repeat, shuffle, song, status, volume } = useInfo();
|
const { position, repeat, shuffle, song, status, volume } = useInfo();
|
||||||
const send = useSend();
|
const send = useSend();
|
||||||
const showImage = useShowImage();
|
const showImage = useShowImage();
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export const RemoteContainer = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{song && (
|
{id && (
|
||||||
<>
|
<>
|
||||||
<Title order={1}>{song.name}</Title>
|
<Title order={1}>{song.name}</Title>
|
||||||
<Group align="flex-end">
|
<Group align="flex-end">
|
||||||
@@ -61,7 +61,7 @@ export const RemoteContainer = () => {
|
|||||||
spacing={0}
|
spacing={0}
|
||||||
>
|
>
|
||||||
<RemoteButton
|
<RemoteButton
|
||||||
disabled={!song}
|
disabled={!id}
|
||||||
tooltip="Previous track"
|
tooltip="Previous track"
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => send({ event: 'previous' })}
|
onClick={() => send({ event: 'previous' })}
|
||||||
@@ -69,8 +69,8 @@ export const RemoteContainer = () => {
|
|||||||
<RiSkipBackFill size={25} />
|
<RiSkipBackFill size={25} />
|
||||||
</RemoteButton>
|
</RemoteButton>
|
||||||
<RemoteButton
|
<RemoteButton
|
||||||
disabled={!song}
|
disabled={!id}
|
||||||
tooltip={song && status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
|
tooltip={id && status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (status === PlayerStatus.PLAYING) {
|
if (status === PlayerStatus.PLAYING) {
|
||||||
@@ -80,14 +80,14 @@ export const RemoteContainer = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{song && status === PlayerStatus.PLAYING ? (
|
{id && status === PlayerStatus.PLAYING ? (
|
||||||
<RiPauseFill size={25} />
|
<RiPauseFill size={25} />
|
||||||
) : (
|
) : (
|
||||||
<RiPlayFill size={25} />
|
<RiPlayFill size={25} />
|
||||||
)}
|
)}
|
||||||
</RemoteButton>
|
</RemoteButton>
|
||||||
<RemoteButton
|
<RemoteButton
|
||||||
disabled={!song}
|
disabled={!id}
|
||||||
tooltip="Next track"
|
tooltip="Next track"
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => send({ event: 'next' })}
|
onClick={() => send({ event: 'next' })}
|
||||||
@@ -113,8 +113,8 @@ export const RemoteContainer = () => {
|
|||||||
repeat === PlayerRepeat.ONE
|
repeat === PlayerRepeat.ONE
|
||||||
? 'One'
|
? 'One'
|
||||||
: repeat === PlayerRepeat.ALL
|
: repeat === PlayerRepeat.ALL
|
||||||
? 'all'
|
? 'all'
|
||||||
: 'none'
|
: 'none'
|
||||||
}`}
|
}`}
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => send({ event: 'repeat' })}
|
onClick={() => send({ event: 'repeat' })}
|
||||||
@@ -127,7 +127,7 @@ export const RemoteContainer = () => {
|
|||||||
</RemoteButton>
|
</RemoteButton>
|
||||||
<RemoteButton
|
<RemoteButton
|
||||||
$active={song?.userFavorite}
|
$active={song?.userFavorite}
|
||||||
disabled={!song}
|
disabled={!id}
|
||||||
tooltip={song?.userFavorite ? 'Unfavorite' : 'Favorite'}
|
tooltip={song?.userFavorite ? 'Unfavorite' : 'Favorite'}
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -154,6 +154,16 @@ export const RemoteContainer = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
{id && position !== undefined && (
|
||||||
|
<WrapperSlider
|
||||||
|
label={(value) => formatDuration(value * 1e3)}
|
||||||
|
leftLabel={formatDuration(position * 1e3)}
|
||||||
|
max={song.duration / 1e3}
|
||||||
|
rightLabel={formatDuration(song.duration)}
|
||||||
|
value={position}
|
||||||
|
onChangeEnd={(e) => send({ event: 'position', position: e })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<WrapperSlider
|
<WrapperSlider
|
||||||
leftLabel={<RiVolumeUpFill size={20} />}
|
leftLabel={<RiVolumeUpFill size={20} />}
|
||||||
max={100}
|
max={100}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const WrapperSlider = ({ leftLabel, rightLabel, value, ...props }: Wrappe
|
|||||||
{...props}
|
{...props}
|
||||||
min={0}
|
min={0}
|
||||||
size={6}
|
size={6}
|
||||||
value={!isSeeking ? value ?? 0 : seek}
|
value={!isSeeking ? (value ?? 0) : seek}
|
||||||
w="100%"
|
w="100%"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setIsSeeking(true);
|
setIsSeeking(true);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import merge from 'lodash/merge';
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { devtools, persist } from 'zustand/middleware';
|
import { devtools, persist } from 'zustand/middleware';
|
||||||
import { immer } from 'zustand/middleware/immer';
|
import { immer } from 'zustand/middleware/immer';
|
||||||
import { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/remote/types';
|
import type { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/remote/types';
|
||||||
|
|
||||||
interface StatefulWebSocket extends WebSocket {
|
interface StatefulWebSocket extends WebSocket {
|
||||||
natural: boolean;
|
natural: boolean;
|
||||||
@@ -133,6 +133,18 @@ export const useRemoteStore = create<SettingsSlice>()(
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'playback': {
|
||||||
|
set((state) => {
|
||||||
|
state.info.status = data;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'position': {
|
||||||
|
set((state) => {
|
||||||
|
state.info.position = data;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'proxy': {
|
case 'proxy': {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (state.info.song) {
|
if (state.info.song) {
|
||||||
@@ -149,9 +161,33 @@ export const useRemoteStore = create<SettingsSlice>()(
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'repeat': {
|
||||||
|
set((state) => {
|
||||||
|
state.info.repeat = data;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'shuffle': {
|
||||||
|
set((state) => {
|
||||||
|
state.info.shuffle = data;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'song': {
|
case 'song': {
|
||||||
set((nested) => {
|
set((state) => {
|
||||||
nested.info = { ...nested.info, ...data };
|
state.info.song = data;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'state': {
|
||||||
|
set((state) => {
|
||||||
|
state.info = data;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'volume': {
|
||||||
|
set((state) => {
|
||||||
|
state.info.volume = data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,11 +248,9 @@ export const useRemoteStore = create<SettingsSlice>()(
|
|||||||
{ name: 'store_settings' },
|
{ name: 'store_settings' },
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
merge: (persistedState, currentState) => {
|
merge: (persistedState, currentState) => merge(currentState, persistedState),
|
||||||
return merge(currentState, persistedState);
|
|
||||||
},
|
|
||||||
name: 'store_settings',
|
name: 'store_settings',
|
||||||
version: 6,
|
version: 7,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+51
-4
@@ -1,7 +1,8 @@
|
|||||||
import type { QueueSong } from '/@/renderer/api/types';
|
import type { QueueSong } from '/@/renderer/api/types';
|
||||||
import type { SongUpdate } from '/@/renderer/types';
|
import type { PlayerRepeat, PlayerStatus, SongState } from '/@/renderer/types';
|
||||||
|
|
||||||
export interface SongUpdateSocket extends Omit<SongUpdate, 'song'> {
|
export interface SongUpdateSocket extends Omit<SongState, 'song'> {
|
||||||
|
position?: number;
|
||||||
song?: QueueSong | null;
|
song?: QueueSong | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,6 +16,15 @@ export interface ServerFavorite {
|
|||||||
event: 'favorite';
|
event: 'favorite';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerPlayStatus {
|
||||||
|
data: PlayerStatus;
|
||||||
|
event: 'playback';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerPosition {
|
||||||
|
data: number;
|
||||||
|
event: 'position';
|
||||||
|
}
|
||||||
export interface ServerProxy {
|
export interface ServerProxy {
|
||||||
data: string;
|
data: string;
|
||||||
event: 'proxy';
|
event: 'proxy';
|
||||||
@@ -25,12 +35,43 @@ export interface ServerRating {
|
|||||||
event: 'rating';
|
event: 'rating';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerRepeat {
|
||||||
|
data: PlayerRepeat;
|
||||||
|
event: 'repeat';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerShuffle {
|
||||||
|
data: boolean;
|
||||||
|
event: 'shuffle';
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerSong {
|
export interface ServerSong {
|
||||||
data: SongUpdateSocket;
|
data: QueueSong | null;
|
||||||
event: 'song';
|
event: 'song';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerEvent = ServerError | ServerFavorite | ServerRating | ServerSong | ServerProxy;
|
export interface ServerState {
|
||||||
|
data: SongState;
|
||||||
|
event: 'state';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerVolume {
|
||||||
|
data: number;
|
||||||
|
event: 'volume';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerEvent =
|
||||||
|
| ServerError
|
||||||
|
| ServerFavorite
|
||||||
|
| ServerPlayStatus
|
||||||
|
| ServerPosition
|
||||||
|
| ServerRating
|
||||||
|
| ServerRepeat
|
||||||
|
| ServerShuffle
|
||||||
|
| ServerSong
|
||||||
|
| ServerState
|
||||||
|
| ServerProxy
|
||||||
|
| ServerVolume;
|
||||||
|
|
||||||
export interface ClientSimpleEvent {
|
export interface ClientSimpleEvent {
|
||||||
event: 'next' | 'pause' | 'play' | 'previous' | 'proxy' | 'repeat' | 'shuffle';
|
event: 'next' | 'pause' | 'play' | 'previous' | 'proxy' | 'repeat' | 'shuffle';
|
||||||
@@ -58,8 +99,14 @@ export interface ClientAuth {
|
|||||||
header: string;
|
header: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClientPosition {
|
||||||
|
event: 'position';
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type ClientEvent =
|
export type ClientEvent =
|
||||||
| ClientAuth
|
| ClientAuth
|
||||||
|
| ClientPosition
|
||||||
| ClientSimpleEvent
|
| ClientSimpleEvent
|
||||||
| ClientFavorite
|
| ClientFavorite
|
||||||
| ClientRating
|
| ClientRating
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ import type {
|
|||||||
Song,
|
Song,
|
||||||
ServerType,
|
ServerType,
|
||||||
ShareItemResponse,
|
ShareItemResponse,
|
||||||
|
MoveItemArgs,
|
||||||
|
DownloadArgs,
|
||||||
|
TranscodingArgs,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
|
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
|
||||||
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
|
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
|
||||||
@@ -82,6 +85,7 @@ export type ControllerEndpoint = Partial<{
|
|||||||
getArtistDetail: () => void;
|
getArtistDetail: () => void;
|
||||||
getArtistInfo: (args: any) => void;
|
getArtistInfo: (args: any) => void;
|
||||||
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
||||||
|
getDownloadUrl: (args: DownloadArgs) => string;
|
||||||
getFavoritesList: () => void;
|
getFavoritesList: () => void;
|
||||||
getFolderItemList: () => void;
|
getFolderItemList: () => void;
|
||||||
getFolderList: () => void;
|
getFolderList: () => void;
|
||||||
@@ -99,7 +103,9 @@ export type ControllerEndpoint = Partial<{
|
|||||||
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
||||||
getStructuredLyrics: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
getStructuredLyrics: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
||||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||||
|
getTranscodingUrl: (args: TranscodingArgs) => string;
|
||||||
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
|
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
|
||||||
|
movePlaylistItem: (args: MoveItemArgs) => Promise<void>;
|
||||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||||
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
||||||
search: (args: SearchArgs) => Promise<SearchResponse>;
|
search: (args: SearchArgs) => Promise<SearchResponse>;
|
||||||
@@ -130,6 +136,7 @@ const endpoints: ApiController = {
|
|||||||
getArtistDetail: undefined,
|
getArtistDetail: undefined,
|
||||||
getArtistInfo: undefined,
|
getArtistInfo: undefined,
|
||||||
getArtistList: undefined,
|
getArtistList: undefined,
|
||||||
|
getDownloadUrl: jfController.getDownloadUrl,
|
||||||
getFavoritesList: undefined,
|
getFavoritesList: undefined,
|
||||||
getFolderItemList: undefined,
|
getFolderItemList: undefined,
|
||||||
getFolderList: undefined,
|
getFolderList: undefined,
|
||||||
@@ -147,7 +154,9 @@ const endpoints: ApiController = {
|
|||||||
getSongList: jfController.getSongList,
|
getSongList: jfController.getSongList,
|
||||||
getStructuredLyrics: undefined,
|
getStructuredLyrics: undefined,
|
||||||
getTopSongs: jfController.getTopSongList,
|
getTopSongs: jfController.getTopSongList,
|
||||||
|
getTranscodingUrl: jfController.getTranscodingUrl,
|
||||||
getUserList: undefined,
|
getUserList: undefined,
|
||||||
|
movePlaylistItem: jfController.movePlaylistItem,
|
||||||
removeFromPlaylist: jfController.removeFromPlaylist,
|
removeFromPlaylist: jfController.removeFromPlaylist,
|
||||||
scrobble: jfController.scrobble,
|
scrobble: jfController.scrobble,
|
||||||
search: jfController.search,
|
search: jfController.search,
|
||||||
@@ -170,6 +179,7 @@ const endpoints: ApiController = {
|
|||||||
getArtistDetail: undefined,
|
getArtistDetail: undefined,
|
||||||
getArtistInfo: undefined,
|
getArtistInfo: undefined,
|
||||||
getArtistList: undefined,
|
getArtistList: undefined,
|
||||||
|
getDownloadUrl: ssController.getDownloadUrl,
|
||||||
getFavoritesList: undefined,
|
getFavoritesList: undefined,
|
||||||
getFolderItemList: undefined,
|
getFolderItemList: undefined,
|
||||||
getFolderList: undefined,
|
getFolderList: undefined,
|
||||||
@@ -187,7 +197,9 @@ const endpoints: ApiController = {
|
|||||||
getSongList: ndController.getSongList,
|
getSongList: ndController.getSongList,
|
||||||
getStructuredLyrics: ssController.getStructuredLyrics,
|
getStructuredLyrics: ssController.getStructuredLyrics,
|
||||||
getTopSongs: ssController.getTopSongList,
|
getTopSongs: ssController.getTopSongList,
|
||||||
|
getTranscodingUrl: ssController.getTranscodingUrl,
|
||||||
getUserList: ndController.getUserList,
|
getUserList: ndController.getUserList,
|
||||||
|
movePlaylistItem: ndController.movePlaylistItem,
|
||||||
removeFromPlaylist: ndController.removeFromPlaylist,
|
removeFromPlaylist: ndController.removeFromPlaylist,
|
||||||
scrobble: ssController.scrobble,
|
scrobble: ssController.scrobble,
|
||||||
search: ssController.search3,
|
search: ssController.search3,
|
||||||
@@ -209,6 +221,7 @@ const endpoints: ApiController = {
|
|||||||
getArtistDetail: undefined,
|
getArtistDetail: undefined,
|
||||||
getArtistInfo: undefined,
|
getArtistInfo: undefined,
|
||||||
getArtistList: undefined,
|
getArtistList: undefined,
|
||||||
|
getDownloadUrl: ssController.getDownloadUrl,
|
||||||
getFavoritesList: undefined,
|
getFavoritesList: undefined,
|
||||||
getFolderItemList: undefined,
|
getFolderItemList: undefined,
|
||||||
getFolderList: undefined,
|
getFolderList: undefined,
|
||||||
@@ -224,6 +237,7 @@ const endpoints: ApiController = {
|
|||||||
getSongList: undefined,
|
getSongList: undefined,
|
||||||
getStructuredLyrics: ssController.getStructuredLyrics,
|
getStructuredLyrics: ssController.getStructuredLyrics,
|
||||||
getTopSongs: ssController.getTopSongList,
|
getTopSongs: ssController.getTopSongList,
|
||||||
|
getTranscodingUrl: ssController.getTranscodingUrl,
|
||||||
getUserList: undefined,
|
getUserList: undefined,
|
||||||
scrobble: ssController.scrobble,
|
scrobble: ssController.scrobble,
|
||||||
search: ssController.search3,
|
search: ssController.search3,
|
||||||
@@ -541,6 +555,33 @@ const getSimilarSongs = async (args: SimilarSongsArgs) => {
|
|||||||
)?.(args);
|
)?.(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const movePlaylistItem = async (args: MoveItemArgs) => {
|
||||||
|
return (
|
||||||
|
apiController(
|
||||||
|
'movePlaylistItem',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['movePlaylistItem']
|
||||||
|
)?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDownloadUrl = (args: DownloadArgs) => {
|
||||||
|
return (
|
||||||
|
apiController(
|
||||||
|
'getDownloadUrl',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['getDownloadUrl']
|
||||||
|
)?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTranscodingUrl = (args: TranscodingArgs) => {
|
||||||
|
return (
|
||||||
|
apiController(
|
||||||
|
'getTranscodingUrl',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['getTranscodingUrl']
|
||||||
|
)?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
export const controller = {
|
export const controller = {
|
||||||
addToPlaylist,
|
addToPlaylist,
|
||||||
authenticate,
|
authenticate,
|
||||||
@@ -553,6 +594,7 @@ export const controller = {
|
|||||||
getAlbumDetail,
|
getAlbumDetail,
|
||||||
getAlbumList,
|
getAlbumList,
|
||||||
getArtistList,
|
getArtistList,
|
||||||
|
getDownloadUrl,
|
||||||
getGenreList,
|
getGenreList,
|
||||||
getLyrics,
|
getLyrics,
|
||||||
getMusicFolderList,
|
getMusicFolderList,
|
||||||
@@ -566,7 +608,9 @@ export const controller = {
|
|||||||
getSongList,
|
getSongList,
|
||||||
getStructuredLyrics,
|
getStructuredLyrics,
|
||||||
getTopSongList,
|
getTopSongList,
|
||||||
|
getTranscodingUrl,
|
||||||
getUserList,
|
getUserList,
|
||||||
|
movePlaylistItem,
|
||||||
removeFromPlaylist,
|
removeFromPlaylist,
|
||||||
scrobble,
|
scrobble,
|
||||||
search,
|
search,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export enum ServerFeature {
|
|||||||
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
|
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
|
||||||
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
|
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
|
||||||
PLAYLISTS_SMART = 'playlistsSmart',
|
PLAYLISTS_SMART = 'playlistsSmart',
|
||||||
|
PUBLIC_PLAYLIST = 'publicPlaylist',
|
||||||
SHARING_ALBUM_SONG = 'sharingAlbumSong',
|
SHARING_ALBUM_SONG = 'sharingAlbumSong',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import qs from 'qs';
|
|||||||
import { ServerListItem } from '/@/renderer/api/types';
|
import { ServerListItem } from '/@/renderer/api/types';
|
||||||
import omitBy from 'lodash/omitBy';
|
import omitBy from 'lodash/omitBy';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { authenticationFailure } from '/@/renderer/api/utils';
|
import { authenticationFailure, getClientType } from '/@/renderer/api/utils';
|
||||||
import i18n from '/@/i18n/i18n';
|
import i18n from '/@/i18n/i18n';
|
||||||
|
import packageJson from '../../../../package.json';
|
||||||
|
|
||||||
const c = initContract();
|
const c = initContract();
|
||||||
|
|
||||||
@@ -24,9 +25,6 @@ export const contract = c.router({
|
|||||||
},
|
},
|
||||||
authenticate: {
|
authenticate: {
|
||||||
body: jfType._parameters.authenticate,
|
body: jfType._parameters.authenticate,
|
||||||
headers: z.object({
|
|
||||||
'X-Emby-Authorization': z.string(),
|
|
||||||
}),
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: 'users/authenticatebyname',
|
path: 'users/authenticatebyname',
|
||||||
responses: {
|
responses: {
|
||||||
@@ -228,6 +226,15 @@ export const contract = c.router({
|
|||||||
400: jfType._response.error,
|
400: jfType._response.error,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
movePlaylistItem: {
|
||||||
|
body: null,
|
||||||
|
method: 'POST',
|
||||||
|
path: 'playlists/:playlistId/items/:itemId/move/:newIdx',
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.moveItem,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
removeFavorite: {
|
removeFavorite: {
|
||||||
body: jfType._parameters.favorite,
|
body: jfType._parameters.favorite,
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -285,8 +292,8 @@ export const contract = c.router({
|
|||||||
},
|
},
|
||||||
updatePlaylist: {
|
updatePlaylist: {
|
||||||
body: jfType._parameters.updatePlaylist,
|
body: jfType._parameters.updatePlaylist,
|
||||||
method: 'PUT',
|
method: 'POST',
|
||||||
path: 'items/:id',
|
path: 'playlists/:id',
|
||||||
responses: {
|
responses: {
|
||||||
200: jfType._response.updatePlaylist,
|
200: jfType._response.updatePlaylist,
|
||||||
400: jfType._response.error,
|
400: jfType._response.error,
|
||||||
@@ -333,6 +340,12 @@ const parsePath = (fullPath: string) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createAuthHeader = (): string => {
|
||||||
|
return `MediaBrowser Client="Feishin", Device="${getClientType()}", DeviceId="${
|
||||||
|
useAuthStore.getState().deviceId
|
||||||
|
}", Version="${packageJson.version}"`;
|
||||||
|
};
|
||||||
|
|
||||||
export const jfApiClient = (args: {
|
export const jfApiClient = (args: {
|
||||||
server: ServerListItem | null;
|
server: ServerListItem | null;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
@@ -359,7 +372,9 @@ export const jfApiClient = (args: {
|
|||||||
data: body,
|
data: body,
|
||||||
headers: {
|
headers: {
|
||||||
...headers,
|
...headers,
|
||||||
...(token && { 'X-MediaBrowser-Token': token }),
|
...(token
|
||||||
|
? { Authorization: createAuthHeader().concat(`, Token="${token}"`) }
|
||||||
|
: { Authorization: createAuthHeader() }),
|
||||||
},
|
},
|
||||||
method: method as Method,
|
method: method as Method,
|
||||||
params,
|
params,
|
||||||
|
|||||||
@@ -53,14 +53,16 @@ import {
|
|||||||
ServerInfoArgs,
|
ServerInfoArgs,
|
||||||
SimilarSongsArgs,
|
SimilarSongsArgs,
|
||||||
Song,
|
Song,
|
||||||
|
MoveItemArgs,
|
||||||
|
DownloadArgs,
|
||||||
|
TranscodingArgs,
|
||||||
|
Played,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||||
import { jfNormalize } from './jellyfin-normalize';
|
import { jfNormalize } from './jellyfin-normalize';
|
||||||
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||||
import packageJson from '../../../../package.json';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
|
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
|
||||||
import isElectron from 'is-electron';
|
|
||||||
import { ServerFeature } from '/@/renderer/api/features-types';
|
import { ServerFeature } from '/@/renderer/api/features-types';
|
||||||
import { VersionInfo, getFeatures } from '/@/renderer/api/utils';
|
import { VersionInfo, getFeatures } from '/@/renderer/api/utils';
|
||||||
import chunk from 'lodash/chunk';
|
import chunk from 'lodash/chunk';
|
||||||
@@ -69,31 +71,6 @@ const formatCommaDelimitedString = (value: string[]) => {
|
|||||||
return value.join(',');
|
return value.join(',');
|
||||||
};
|
};
|
||||||
|
|
||||||
function getHostname(): string {
|
|
||||||
if (isElectron()) {
|
|
||||||
return 'Desktop Client';
|
|
||||||
}
|
|
||||||
const agent = navigator.userAgent;
|
|
||||||
switch (true) {
|
|
||||||
case agent.toLowerCase().indexOf('edge') > -1:
|
|
||||||
return 'Microsoft Edge';
|
|
||||||
case agent.toLowerCase().indexOf('edg/') > -1:
|
|
||||||
return 'Edge Chromium'; // Match also / to avoid matching for the older Edge
|
|
||||||
case agent.toLowerCase().indexOf('opr') > -1:
|
|
||||||
return 'Opera';
|
|
||||||
case agent.toLowerCase().indexOf('chrome') > -1:
|
|
||||||
return 'Chrome';
|
|
||||||
case agent.toLowerCase().indexOf('trident') > -1:
|
|
||||||
return 'Internet Explorer';
|
|
||||||
case agent.toLowerCase().indexOf('firefox') > -1:
|
|
||||||
return 'Firefox';
|
|
||||||
case agent.toLowerCase().indexOf('safari') > -1:
|
|
||||||
return 'Safari';
|
|
||||||
default:
|
|
||||||
return 'PC';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const authenticate = async (
|
const authenticate = async (
|
||||||
url: string,
|
url: string,
|
||||||
body: {
|
body: {
|
||||||
@@ -108,11 +85,6 @@ const authenticate = async (
|
|||||||
Pw: body.password,
|
Pw: body.password,
|
||||||
Username: body.username,
|
Username: body.username,
|
||||||
},
|
},
|
||||||
headers: {
|
|
||||||
'x-emby-authorization': `MediaBrowser Client="Feishin", Device="${getHostname()}", DeviceId="Feishin-${getHostname()}-${encodeURIComponent(
|
|
||||||
body.username,
|
|
||||||
)}", Version="${packageJson.version}"`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
@@ -636,9 +608,9 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistR
|
|||||||
|
|
||||||
const res = await jfApiClient(apiClientProps).createPlaylist({
|
const res = await jfApiClient(apiClientProps).createPlaylist({
|
||||||
body: {
|
body: {
|
||||||
|
IsPublic: body.public,
|
||||||
MediaType: 'Audio',
|
MediaType: 'Audio',
|
||||||
Name: body.name,
|
Name: body.name,
|
||||||
Overview: body.comment || '',
|
|
||||||
UserId: apiClientProps.server.userId,
|
UserId: apiClientProps.server.userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -662,9 +634,9 @@ const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistR
|
|||||||
const res = await jfApiClient(apiClientProps).updatePlaylist({
|
const res = await jfApiClient(apiClientProps).updatePlaylist({
|
||||||
body: {
|
body: {
|
||||||
Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [],
|
Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [],
|
||||||
|
IsPublic: body.public,
|
||||||
MediaType: 'Audio',
|
MediaType: 'Audio',
|
||||||
Name: body.name,
|
Name: body.name,
|
||||||
Overview: body.comment || '',
|
|
||||||
PremiereDate: null,
|
PremiereDate: null,
|
||||||
ProviderIds: {},
|
ProviderIds: {},
|
||||||
Tags: [],
|
Tags: [],
|
||||||
@@ -675,7 +647,7 @@ const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistR
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status !== 200) {
|
if (res.status !== 204) {
|
||||||
throw new Error('Failed to update playlist');
|
throw new Error('Failed to update playlist');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,6 +893,12 @@ const getRandomSongList = async (args: RandomSongListArgs): Promise<RandomSongLi
|
|||||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||||
GenreIds: query.genre ? query.genre : undefined,
|
GenreIds: query.genre ? query.genre : undefined,
|
||||||
IncludeItemTypes: 'Audio',
|
IncludeItemTypes: 'Audio',
|
||||||
|
IsPlayed:
|
||||||
|
query.played === Played.Never
|
||||||
|
? false
|
||||||
|
: query.played === Played.Played
|
||||||
|
? true
|
||||||
|
: undefined,
|
||||||
Limit: query.limit,
|
Limit: query.limit,
|
||||||
ParentId: query.musicFolderId,
|
ParentId: query.musicFolderId,
|
||||||
Recursive: true,
|
Recursive: true,
|
||||||
@@ -983,7 +961,12 @@ const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse>
|
|||||||
return jfNormalize.song(res.body, apiClientProps.server, '');
|
return jfNormalize.song(res.body, apiClientProps.server, '');
|
||||||
};
|
};
|
||||||
|
|
||||||
const VERSION_INFO: VersionInfo = [['10.9.0', { [ServerFeature.LYRICS_SINGLE_STRUCTURED]: [1] }]];
|
const VERSION_INFO: VersionInfo = [
|
||||||
|
[
|
||||||
|
'10.9.0',
|
||||||
|
{ [ServerFeature.LYRICS_SINGLE_STRUCTURED]: [1], [ServerFeature.PUBLIC_PLAYLIST]: [1] },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
|
const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
|
||||||
const { apiClientProps } = args;
|
const { apiClientProps } = args;
|
||||||
@@ -1057,6 +1040,43 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
|
|||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const movePlaylistItem = async (args: MoveItemArgs): Promise<void> => {
|
||||||
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).movePlaylistItem({
|
||||||
|
body: null,
|
||||||
|
params: {
|
||||||
|
itemId: query.trackId,
|
||||||
|
newIdx: query.endingIndex.toString(),
|
||||||
|
playlistId: query.playlistId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 204) {
|
||||||
|
throw new Error('Failed to move item in playlist');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDownloadUrl = (args: DownloadArgs) => {
|
||||||
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTranscodingUrl = (args: TranscodingArgs) => {
|
||||||
|
const { base, format, bitrate } = args.query;
|
||||||
|
let url = base.replace('transcodingProtocol=hls', 'transcodingProtocol=http');
|
||||||
|
if (format) {
|
||||||
|
url = url.replace('audioCodec=aac', `audioCodec=${format}`);
|
||||||
|
url = url.replace('transcodingContainer=ts', `transcodingContainer=${format}`);
|
||||||
|
}
|
||||||
|
if (bitrate !== undefined) {
|
||||||
|
url += `&maxStreamingBitrate=${bitrate * 1000}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
export const jfController = {
|
export const jfController = {
|
||||||
addToPlaylist,
|
addToPlaylist,
|
||||||
authenticate,
|
authenticate,
|
||||||
@@ -1069,6 +1089,7 @@ export const jfController = {
|
|||||||
getAlbumDetail,
|
getAlbumDetail,
|
||||||
getAlbumList,
|
getAlbumList,
|
||||||
getArtistList,
|
getArtistList,
|
||||||
|
getDownloadUrl,
|
||||||
getGenreList,
|
getGenreList,
|
||||||
getLyrics,
|
getLyrics,
|
||||||
getMusicFolderList,
|
getMusicFolderList,
|
||||||
@@ -1081,6 +1102,8 @@ export const jfController = {
|
|||||||
getSongDetail,
|
getSongDetail,
|
||||||
getSongList,
|
getSongList,
|
||||||
getTopSongList,
|
getTopSongList,
|
||||||
|
getTranscodingUrl,
|
||||||
|
movePlaylistItem,
|
||||||
removeFromPlaylist,
|
removeFromPlaylist,
|
||||||
scrobble,
|
scrobble,
|
||||||
search,
|
search,
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ const getStreamUrl = (args: {
|
|||||||
`?userId=${server?.userId}` +
|
`?userId=${server?.userId}` +
|
||||||
`&deviceId=${deviceId}` +
|
`&deviceId=${deviceId}` +
|
||||||
'&audioCodec=aac' +
|
'&audioCodec=aac' +
|
||||||
`&api_key=${server?.credential}` +
|
`&apiKey=${server?.credential}` +
|
||||||
`&playSessionId=${deviceId}` +
|
`&playSessionId=${deviceId}` +
|
||||||
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' +
|
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' +
|
||||||
'&transcodingContainer=ts' +
|
'&transcodingContainer=ts' +
|
||||||
'&transcodingProtocol=hls'
|
'&transcodingProtocol=http'
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ const getAlbumArtistCoverArtUrl = (args: {
|
|||||||
`${args.baseUrl}/Items` +
|
`${args.baseUrl}/Items` +
|
||||||
`/${args.item.Id}` +
|
`/${args.item.Id}` +
|
||||||
'/Images/Primary' +
|
'/Images/Primary' +
|
||||||
`?width=${size}&height=${size}` +
|
`?width=${size}&` +
|
||||||
'&quality=96'
|
'&quality=96'
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -175,8 +175,11 @@ const normalizeSong = (
|
|||||||
peak: null,
|
peak: null,
|
||||||
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
||||||
playlistItemId: item.PlaylistItemId,
|
playlistItemId: item.PlaylistItemId,
|
||||||
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
|
releaseDate: item.PremiereDate
|
||||||
releaseDate: null,
|
? new Date(item.PremiereDate).toISOString()
|
||||||
|
: item.ProductionYear
|
||||||
|
? new Date(item.ProductionYear, 0, 1).toISOString()
|
||||||
|
: null,
|
||||||
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
|
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
|
||||||
serverId: server?.id || '',
|
serverId: server?.id || '',
|
||||||
serverType: ServerType.JELLYFIN,
|
serverType: ServerType.JELLYFIN,
|
||||||
@@ -237,6 +240,7 @@ const normalizeAlbum = (
|
|||||||
lastPlayedAt: null,
|
lastPlayedAt: null,
|
||||||
mbzId: item.ProviderIds?.MusicBrainzAlbum || null,
|
mbzId: item.ProviderIds?.MusicBrainzAlbum || null,
|
||||||
name: item.Name,
|
name: item.Name,
|
||||||
|
originalDate: null,
|
||||||
playCount: item.UserData?.PlayCount || 0,
|
playCount: item.UserData?.PlayCount || 0,
|
||||||
releaseDate: item.PremiereDate?.split('T')[0] || null,
|
releaseDate: item.PremiereDate?.split('T')[0] || null,
|
||||||
releaseYear: item.ProductionYear || null,
|
releaseYear: item.ProductionYear || null,
|
||||||
|
|||||||
@@ -561,6 +561,7 @@ const songListParameters = paginationParameters.merge(
|
|||||||
GenreIds: z.string().optional(),
|
GenreIds: z.string().optional(),
|
||||||
Genres: z.string().optional(),
|
Genres: z.string().optional(),
|
||||||
IsFavorite: z.boolean().optional(),
|
IsFavorite: z.boolean().optional(),
|
||||||
|
IsPlayed: z.boolean().optional(),
|
||||||
SearchTerm: z.string().optional(),
|
SearchTerm: z.string().optional(),
|
||||||
SortBy: z.nativeEnum(songListSort).optional(),
|
SortBy: z.nativeEnum(songListSort).optional(),
|
||||||
Tags: z.string().optional(),
|
Tags: z.string().optional(),
|
||||||
@@ -581,9 +582,9 @@ const playlistDetailParameters = baseParameters.extend({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createPlaylistParameters = z.object({
|
const createPlaylistParameters = z.object({
|
||||||
|
IsPublic: z.boolean().optional(),
|
||||||
MediaType: z.literal('Audio'),
|
MediaType: z.literal('Audio'),
|
||||||
Name: z.string(),
|
Name: z.string(),
|
||||||
Overview: z.string(),
|
|
||||||
UserId: z.string(),
|
UserId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -595,9 +596,9 @@ const updatePlaylist = z.null();
|
|||||||
|
|
||||||
const updatePlaylistParameters = z.object({
|
const updatePlaylistParameters = z.object({
|
||||||
Genres: z.array(genreItem),
|
Genres: z.array(genreItem),
|
||||||
|
IsPublic: z.boolean().optional(),
|
||||||
MediaType: z.literal('Audio'),
|
MediaType: z.literal('Audio'),
|
||||||
Name: z.string(),
|
Name: z.string(),
|
||||||
Overview: z.string(),
|
|
||||||
PremiereDate: z.null(),
|
PremiereDate: z.null(),
|
||||||
ProviderIds: z.object({}),
|
ProviderIds: z.object({}),
|
||||||
Tags: z.array(genericItem),
|
Tags: z.array(genericItem),
|
||||||
@@ -681,6 +682,8 @@ export enum JellyfinExtensions {
|
|||||||
SONG_LYRICS = 'songLyrics',
|
SONG_LYRICS = 'songLyrics',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const moveItem = z.null();
|
||||||
|
|
||||||
export const jfType = {
|
export const jfType = {
|
||||||
_enum: {
|
_enum: {
|
||||||
albumArtistList: albumArtistListSort,
|
albumArtistList: albumArtistListSort,
|
||||||
@@ -729,6 +732,7 @@ export const jfType = {
|
|||||||
genre,
|
genre,
|
||||||
genreList,
|
genreList,
|
||||||
lyrics,
|
lyrics,
|
||||||
|
moveItem,
|
||||||
musicFolderList,
|
musicFolderList,
|
||||||
playlist,
|
playlist,
|
||||||
playlistList,
|
playlistList,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ndType } from './navidrome-types';
|
|||||||
import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils';
|
import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils';
|
||||||
import { useAuthStore } from '/@/renderer/store';
|
import { useAuthStore } from '/@/renderer/store';
|
||||||
import { ServerListItem } from '/@/renderer/api/types';
|
import { ServerListItem } from '/@/renderer/api/types';
|
||||||
import { toast } from '/@/renderer/components';
|
import { toast } from '/@/renderer/components/toast';
|
||||||
import i18n from '/@/i18n/i18n';
|
import i18n from '/@/i18n/i18n';
|
||||||
|
|
||||||
const localSettings = isElectron() ? window.electron.localSettings : null;
|
const localSettings = isElectron() ? window.electron.localSettings : null;
|
||||||
@@ -147,6 +147,15 @@ export const contract = c.router({
|
|||||||
500: resultWithHeaders(ndType._response.error),
|
500: resultWithHeaders(ndType._response.error),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
movePlaylistItem: {
|
||||||
|
body: ndType._parameters.moveItem,
|
||||||
|
method: 'PUT',
|
||||||
|
path: 'playlist/:playlistId/tracks/:trackNumber',
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.moveItem),
|
||||||
|
400: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
removeFromPlaylist: {
|
removeFromPlaylist: {
|
||||||
body: null,
|
body: null,
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
ShareItemResponse,
|
ShareItemResponse,
|
||||||
SimilarSongsArgs,
|
SimilarSongsArgs,
|
||||||
Song,
|
Song,
|
||||||
|
MoveItemArgs,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils';
|
import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils';
|
||||||
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types';
|
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types';
|
||||||
@@ -193,7 +194,16 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtis
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
items: res.body.data.map((albumArtist) =>
|
items: res.body.data.map((albumArtist) =>
|
||||||
ndNormalize.albumArtist(albumArtist, apiClientProps.server),
|
// Navidrome native API will return only external URL small/medium/large
|
||||||
|
// image URL. Set large image to undefined to force `albumArtist` to use
|
||||||
|
// /rest/getCoverArt.view?id=ar-...
|
||||||
|
ndNormalize.albumArtist(
|
||||||
|
{
|
||||||
|
...albumArtist,
|
||||||
|
largeImageUrl: undefined,
|
||||||
|
},
|
||||||
|
apiClientProps.server,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
startIndex: query.startIndex,
|
startIndex: query.startIndex,
|
||||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||||
@@ -307,7 +317,7 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistR
|
|||||||
body: {
|
body: {
|
||||||
comment: body.comment,
|
comment: body.comment,
|
||||||
name: body.name,
|
name: body.name,
|
||||||
public: body._custom?.navidrome?.public,
|
public: body.public,
|
||||||
rules: body._custom?.navidrome?.rules,
|
rules: body._custom?.navidrome?.rules,
|
||||||
sync: body._custom?.navidrome?.sync,
|
sync: body._custom?.navidrome?.sync,
|
||||||
},
|
},
|
||||||
@@ -329,7 +339,7 @@ const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistR
|
|||||||
body: {
|
body: {
|
||||||
comment: body.comment || '',
|
comment: body.comment || '',
|
||||||
name: body.name,
|
name: body.name,
|
||||||
public: body._custom?.navidrome?.public || false,
|
public: body?.public || false,
|
||||||
rules: body._custom?.navidrome?.rules ? body._custom.navidrome.rules : undefined,
|
rules: body._custom?.navidrome?.rules ? body._custom.navidrome.rules : undefined,
|
||||||
sync: body._custom?.navidrome?.sync || undefined,
|
sync: body._custom?.navidrome?.sync || undefined,
|
||||||
},
|
},
|
||||||
@@ -524,6 +534,7 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
|
|||||||
const features: ServerFeatures = {
|
const features: ServerFeatures = {
|
||||||
lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS],
|
lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS],
|
||||||
playlistsSmart: !!navidromeFeatures[ServerFeature.PLAYLISTS_SMART],
|
playlistsSmart: !!navidromeFeatures[ServerFeature.PLAYLISTS_SMART],
|
||||||
|
publicPlaylist: true,
|
||||||
sharingAlbumSong: !!navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG],
|
sharingAlbumSong: !!navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -604,6 +615,24 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
|
|||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const movePlaylistItem = async (args: MoveItemArgs): Promise<void> => {
|
||||||
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
const res = await ndApiClient(apiClientProps).movePlaylistItem({
|
||||||
|
body: {
|
||||||
|
insert_before: (query.endingIndex + 1).toString(),
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
playlistId: query.playlistId,
|
||||||
|
trackNumber: query.startingIndex.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to move item in playlist');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const ndController = {
|
export const ndController = {
|
||||||
addToPlaylist,
|
addToPlaylist,
|
||||||
authenticate,
|
authenticate,
|
||||||
@@ -622,6 +651,7 @@ export const ndController = {
|
|||||||
getSongDetail,
|
getSongDetail,
|
||||||
getSongList,
|
getSongList,
|
||||||
getUserList,
|
getUserList,
|
||||||
|
movePlaylistItem,
|
||||||
removeFromPlaylist,
|
removeFromPlaylist,
|
||||||
shareItem,
|
shareItem,
|
||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
|
|||||||
@@ -119,7 +119,10 @@ const normalizeSong = (
|
|||||||
: null,
|
: null,
|
||||||
playCount: item.playCount,
|
playCount: item.playCount,
|
||||||
playlistItemId,
|
playlistItemId,
|
||||||
releaseDate: new Date(item.year, 0, 1).toISOString(),
|
releaseDate: (item.releaseDate
|
||||||
|
? new Date(item.releaseDate)
|
||||||
|
: new Date(item.year, 0, 1)
|
||||||
|
).toISOString(),
|
||||||
releaseYear: String(item.year),
|
releaseYear: String(item.year),
|
||||||
serverId: server?.id || 'unknown',
|
serverId: server?.id || 'unknown',
|
||||||
serverType: ServerType.NAVIDROME,
|
serverType: ServerType.NAVIDROME,
|
||||||
@@ -173,8 +176,16 @@ const normalizeAlbum = (
|
|||||||
lastPlayedAt: normalizePlayDate(item),
|
lastPlayedAt: normalizePlayDate(item),
|
||||||
mbzId: item.mbzAlbumId || null,
|
mbzId: item.mbzAlbumId || null,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
|
originalDate: item.originalDate
|
||||||
|
? new Date(item.originalDate).toISOString()
|
||||||
|
: item.originalYear
|
||||||
|
? new Date(item.originalYear, 0, 1).toISOString()
|
||||||
|
: null,
|
||||||
playCount: item.playCount,
|
playCount: item.playCount,
|
||||||
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
|
releaseDate: (item.releaseDate
|
||||||
|
? new Date(item.releaseDate)
|
||||||
|
: new Date(item.minYear, 0, 1)
|
||||||
|
).toISOString(),
|
||||||
releaseYear: item.minYear,
|
releaseYear: item.minYear,
|
||||||
serverId: server?.id || 'unknown',
|
serverId: server?.id || 'unknown',
|
||||||
serverType: ServerType.NAVIDROME,
|
serverType: ServerType.NAVIDROME,
|
||||||
|
|||||||
@@ -128,9 +128,12 @@ const album = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
orderAlbumArtistName: z.string(),
|
orderAlbumArtistName: z.string(),
|
||||||
orderAlbumName: z.string(),
|
orderAlbumName: z.string(),
|
||||||
|
originalDate: z.string().optional(),
|
||||||
|
originalYear: z.number().optional(),
|
||||||
playCount: z.number(),
|
playCount: z.number(),
|
||||||
playDate: z.string().optional(),
|
playDate: z.string().optional(),
|
||||||
rating: z.number().optional(),
|
rating: z.number().optional(),
|
||||||
|
releaseDate: z.string().optional(),
|
||||||
size: z.number(),
|
size: z.number(),
|
||||||
songCount: z.number(),
|
songCount: z.number(),
|
||||||
sortAlbumArtistName: z.string(),
|
sortAlbumArtistName: z.string(),
|
||||||
@@ -214,6 +217,7 @@ const song = z.object({
|
|||||||
playCount: z.number(),
|
playCount: z.number(),
|
||||||
playDate: z.string().optional(),
|
playDate: z.string().optional(),
|
||||||
rating: z.number().optional(),
|
rating: z.number().optional(),
|
||||||
|
releaseDate: z.string().optional(),
|
||||||
rgAlbumGain: z.number().optional(),
|
rgAlbumGain: z.number().optional(),
|
||||||
rgAlbumPeak: z.number().optional(),
|
rgAlbumPeak: z.number().optional(),
|
||||||
rgTrackGain: z.number().optional(),
|
rgTrackGain: z.number().optional(),
|
||||||
@@ -355,6 +359,12 @@ const shareItemParameters = z.object({
|
|||||||
resourceType: z.string(),
|
resourceType: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const moveItemParameters = z.object({
|
||||||
|
insert_before: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveItem = z.null();
|
||||||
|
|
||||||
export const ndType = {
|
export const ndType = {
|
||||||
_enum: {
|
_enum: {
|
||||||
albumArtistList: ndAlbumArtistListSort,
|
albumArtistList: ndAlbumArtistListSort,
|
||||||
@@ -371,6 +381,7 @@ export const ndType = {
|
|||||||
authenticate: authenticateParameters,
|
authenticate: authenticateParameters,
|
||||||
createPlaylist: createPlaylistParameters,
|
createPlaylist: createPlaylistParameters,
|
||||||
genreList: genreListParameters,
|
genreList: genreListParameters,
|
||||||
|
moveItem: moveItemParameters,
|
||||||
playlistList: playlistListParameters,
|
playlistList: playlistListParameters,
|
||||||
removeFromPlaylist: removeFromPlaylistParameters,
|
removeFromPlaylist: removeFromPlaylistParameters,
|
||||||
shareItem: shareItemParameters,
|
shareItem: shareItemParameters,
|
||||||
@@ -390,6 +401,7 @@ export const ndType = {
|
|||||||
error,
|
error,
|
||||||
genre,
|
genre,
|
||||||
genreList,
|
genreList,
|
||||||
|
moveItem,
|
||||||
playlist,
|
playlist,
|
||||||
playlistList,
|
playlistList,
|
||||||
playlistSong,
|
playlistSong,
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
StructuredLyric,
|
StructuredLyric,
|
||||||
SimilarSongsArgs,
|
SimilarSongsArgs,
|
||||||
Song,
|
Song,
|
||||||
|
DownloadArgs,
|
||||||
|
TranscodingArgs,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { randomString } from '/@/renderer/utils';
|
import { randomString } from '/@/renderer/utils';
|
||||||
import { ServerFeatures } from '/@/renderer/api/features-types';
|
import { ServerFeatures } from '/@/renderer/api/features-types';
|
||||||
@@ -482,16 +484,43 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
|
|||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDownloadUrl = (args: DownloadArgs) => {
|
||||||
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
return (
|
||||||
|
`${apiClientProps.server?.url}/rest/download.view` +
|
||||||
|
`?id=${query.id}` +
|
||||||
|
`&${apiClientProps.server?.credential}` +
|
||||||
|
'&v=1.13.0' +
|
||||||
|
'&c=feishin'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTranscodingUrl = (args: TranscodingArgs) => {
|
||||||
|
const { base, format, bitrate } = args.query;
|
||||||
|
let url = base;
|
||||||
|
if (format) {
|
||||||
|
url += `&format=${format}`;
|
||||||
|
}
|
||||||
|
if (bitrate !== undefined) {
|
||||||
|
url += `&maxBitRate=${bitrate}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
export const ssController = {
|
export const ssController = {
|
||||||
authenticate,
|
authenticate,
|
||||||
createFavorite,
|
createFavorite,
|
||||||
getArtistInfo,
|
getArtistInfo,
|
||||||
|
getDownloadUrl,
|
||||||
getMusicFolderList,
|
getMusicFolderList,
|
||||||
getRandomSongList,
|
getRandomSongList,
|
||||||
getServerInfo,
|
getServerInfo,
|
||||||
getSimilarSongs,
|
getSimilarSongs,
|
||||||
getStructuredLyrics,
|
getStructuredLyrics,
|
||||||
getTopSongList,
|
getTopSongList,
|
||||||
|
getTranscodingUrl,
|
||||||
removeFavorite,
|
removeFavorite,
|
||||||
scrobble,
|
scrobble,
|
||||||
search3,
|
search3,
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ const normalizeAlbum = (
|
|||||||
lastPlayedAt: null,
|
lastPlayedAt: null,
|
||||||
mbzId: null,
|
mbzId: null,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
|
originalDate: null,
|
||||||
playCount: null,
|
playCount: null,
|
||||||
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,
|
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,
|
||||||
releaseYear: item.year ? Number(item.year) : null,
|
releaseYear: item.year ? Number(item.year) : null,
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ export type Album = {
|
|||||||
lastPlayedAt: string | null;
|
lastPlayedAt: string | null;
|
||||||
mbzId: string | null;
|
mbzId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
originalDate: string | null;
|
||||||
playCount: number | null;
|
playCount: number | null;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
releaseYear: number | null;
|
releaseYear: number | null;
|
||||||
@@ -817,13 +818,13 @@ export type CreatePlaylistBody = {
|
|||||||
navidrome?: {
|
navidrome?: {
|
||||||
owner?: string;
|
owner?: string;
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
public?: boolean;
|
|
||||||
rules?: Record<string, any>;
|
rules?: Record<string, any>;
|
||||||
sync?: boolean;
|
sync?: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
comment?: string;
|
comment?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
public?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreatePlaylistArgs = { body: CreatePlaylistBody; serverId?: string } & BaseEndpointArgs;
|
export type CreatePlaylistArgs = { body: CreatePlaylistBody; serverId?: string } & BaseEndpointArgs;
|
||||||
@@ -840,7 +841,6 @@ export type UpdatePlaylistBody = {
|
|||||||
navidrome?: {
|
navidrome?: {
|
||||||
owner?: string;
|
owner?: string;
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
public?: boolean;
|
|
||||||
rules?: Record<string, any>;
|
rules?: Record<string, any>;
|
||||||
sync?: boolean;
|
sync?: boolean;
|
||||||
};
|
};
|
||||||
@@ -848,6 +848,7 @@ export type UpdatePlaylistBody = {
|
|||||||
comment?: string;
|
comment?: string;
|
||||||
genres?: Genre[];
|
genres?: Genre[];
|
||||||
name: string;
|
name: string;
|
||||||
|
public?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdatePlaylistArgs = {
|
export type UpdatePlaylistArgs = {
|
||||||
@@ -1072,12 +1073,19 @@ export type SearchResponse = {
|
|||||||
songs: Song[];
|
songs: Song[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export enum Played {
|
||||||
|
All = 'all',
|
||||||
|
Never = 'never',
|
||||||
|
Played = 'played',
|
||||||
|
}
|
||||||
|
|
||||||
export type RandomSongListQuery = {
|
export type RandomSongListQuery = {
|
||||||
genre?: string;
|
genre?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
maxYear?: number;
|
maxYear?: number;
|
||||||
minYear?: number;
|
minYear?: number;
|
||||||
musicFolderId?: string;
|
musicFolderId?: string;
|
||||||
|
played: Played;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RandomSongListArgs = {
|
export type RandomSongListArgs = {
|
||||||
@@ -1191,3 +1199,32 @@ export type SimilarSongsQuery = {
|
|||||||
export type SimilarSongsArgs = {
|
export type SimilarSongsArgs = {
|
||||||
query: SimilarSongsQuery;
|
query: SimilarSongsQuery;
|
||||||
} & BaseEndpointArgs;
|
} & BaseEndpointArgs;
|
||||||
|
|
||||||
|
export type MoveItemQuery = {
|
||||||
|
endingIndex: number;
|
||||||
|
playlistId: string;
|
||||||
|
startingIndex: number;
|
||||||
|
trackId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MoveItemArgs = {
|
||||||
|
query: MoveItemQuery;
|
||||||
|
} & BaseEndpointArgs;
|
||||||
|
|
||||||
|
export type DownloadQuery = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DownloadArgs = {
|
||||||
|
query: DownloadQuery;
|
||||||
|
} & BaseEndpointArgs;
|
||||||
|
|
||||||
|
export type TranscodingQuery = {
|
||||||
|
base: string;
|
||||||
|
bitrate?: number;
|
||||||
|
format?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranscodingArgs = {
|
||||||
|
query: TranscodingQuery;
|
||||||
|
} & BaseEndpointArgs;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { AxiosHeaders } from 'axios';
|
import { AxiosHeaders } from 'axios';
|
||||||
|
import isElectron from 'is-electron';
|
||||||
import semverCoerce from 'semver/functions/coerce';
|
import semverCoerce from 'semver/functions/coerce';
|
||||||
import semverGte from 'semver/functions/gte';
|
import semverGte from 'semver/functions/gte';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { toast } from '/@/renderer/components';
|
import { toast } from '/@/renderer/components/toast';
|
||||||
import { useAuthStore } from '/@/renderer/store';
|
import { useAuthStore } from '/@/renderer/store';
|
||||||
import { ServerListItem } from '/@/renderer/api/types';
|
import { ServerListItem } from '/@/renderer/api/types';
|
||||||
import { ServerFeature } from '/@/renderer/api/features-types';
|
import { ServerFeature } from '/@/renderer/api/features-types';
|
||||||
@@ -99,4 +100,29 @@ export const getFeatures = (
|
|||||||
return features;
|
return features;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getClientType = (): string => {
|
||||||
|
if (isElectron()) {
|
||||||
|
return 'Desktop Client';
|
||||||
|
}
|
||||||
|
const agent = navigator.userAgent;
|
||||||
|
switch (true) {
|
||||||
|
case agent.toLowerCase().indexOf('edge') > -1:
|
||||||
|
return 'Microsoft Edge';
|
||||||
|
case agent.toLowerCase().indexOf('edg/') > -1:
|
||||||
|
return 'Edge Chromium'; // Match also / to avoid matching for the older Edge
|
||||||
|
case agent.toLowerCase().indexOf('opr') > -1:
|
||||||
|
return 'Opera';
|
||||||
|
case agent.toLowerCase().indexOf('chrome') > -1:
|
||||||
|
return 'Chrome';
|
||||||
|
case agent.toLowerCase().indexOf('trident') > -1:
|
||||||
|
return 'Internet Explorer';
|
||||||
|
case agent.toLowerCase().indexOf('firefox') > -1:
|
||||||
|
return 'Firefox';
|
||||||
|
case agent.toLowerCase().indexOf('safari') > -1:
|
||||||
|
return 'Safari';
|
||||||
|
default:
|
||||||
|
return 'PC';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const SEPARATOR_STRING = ' · ';
|
export const SEPARATOR_STRING = ' · ';
|
||||||
|
|||||||
+34
-2
@@ -20,12 +20,15 @@ import { ContextMenuProvider } from '/@/renderer/features/context-menu';
|
|||||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||||
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
||||||
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
|
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
|
||||||
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store';
|
import { PlayerState, useCssSettings, usePlayerStore, useQueueControls } from '/@/renderer/store';
|
||||||
import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types';
|
import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types';
|
||||||
import '@ag-grid-community/styles/ag-grid.css';
|
import '@ag-grid-community/styles/ag-grid.css';
|
||||||
import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc';
|
import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc';
|
||||||
import i18n from '/@/i18n/i18n';
|
import i18n from '/@/i18n/i18n';
|
||||||
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
|
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
|
||||||
|
import { updateSong } from '/@/renderer/features/player/update-remote-song';
|
||||||
|
import { sanitizeCss } from '/@/renderer/utils/sanitize';
|
||||||
|
import { setQueue } from '/@/renderer/utils/set-transcoded-queue-data';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
|
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
|
||||||
|
|
||||||
@@ -40,13 +43,16 @@ export const App = () => {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const accent = useSettingsStore((store) => store.general.accent);
|
const accent = useSettingsStore((store) => store.general.accent);
|
||||||
const language = useSettingsStore((store) => store.general.language);
|
const language = useSettingsStore((store) => store.general.language);
|
||||||
|
const nativeImageAspect = useSettingsStore((store) => store.general.nativeAspectRatio);
|
||||||
const { builtIn, custom, system, type } = useSettingsStore((state) => state.font);
|
const { builtIn, custom, system, type } = useSettingsStore((state) => state.font);
|
||||||
|
const { enabled, content } = useCssSettings();
|
||||||
const { type: playbackType } = usePlaybackSettings();
|
const { type: playbackType } = usePlaybackSettings();
|
||||||
const { bindings } = useHotkeySettings();
|
const { bindings } = useHotkeySettings();
|
||||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||||
const { clearQueue, restoreQueue } = useQueueControls();
|
const { clearQueue, restoreQueue } = useQueueControls();
|
||||||
const remoteSettings = useRemoteSettings();
|
const remoteSettings = useRemoteSettings();
|
||||||
const textStyleRef = useRef<HTMLStyleElement>();
|
const textStyleRef = useRef<HTMLStyleElement>();
|
||||||
|
const cssRef = useRef<HTMLStyleElement>();
|
||||||
useDiscordRpc();
|
useDiscordRpc();
|
||||||
useServerVersion();
|
useServerVersion();
|
||||||
|
|
||||||
@@ -85,11 +91,36 @@ export const App = () => {
|
|||||||
}
|
}
|
||||||
}, [builtIn, custom, system, type]);
|
}, [builtIn, custom, system, type]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled && content) {
|
||||||
|
// Yes, CSS is sanitized here as well. Prevent a suer from changing the
|
||||||
|
// localStorage to bypass sanitizing.
|
||||||
|
const sanitized = sanitizeCss(content);
|
||||||
|
if (!cssRef.current) {
|
||||||
|
cssRef.current = document.createElement('style');
|
||||||
|
document.body.appendChild(cssRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
cssRef.current.textContent = sanitized;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cssRef.current!.textContent = '';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {};
|
||||||
|
}, [content, enabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.style.setProperty('--primary-color', accent);
|
root.style.setProperty('--primary-color', accent);
|
||||||
}, [accent]);
|
}, [accent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty('--image-fit', nativeImageAspect ? 'contain' : 'cover');
|
||||||
|
}, [nativeImageAspect]);
|
||||||
|
|
||||||
const providerValue = useMemo(() => {
|
const providerValue = useMemo(() => {
|
||||||
return { handlePlayQueueAdd };
|
return { handlePlayQueueAdd };
|
||||||
}, [handlePlayQueueAdd]);
|
}, [handlePlayQueueAdd]);
|
||||||
@@ -155,8 +186,9 @@ export const App = () => {
|
|||||||
utils.onRestoreQueue((_event: any, data) => {
|
utils.onRestoreQueue((_event: any, data) => {
|
||||||
const playerData = restoreQueue(data);
|
const playerData = restoreQueue(data);
|
||||||
if (playbackType === PlaybackType.LOCAL) {
|
if (playbackType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer!.setQueue(playerData, true);
|
setQueue(playerData, true);
|
||||||
}
|
}
|
||||||
|
updateSong(playerData.current.song);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { useImperativeHandle, forwardRef, useRef, useState, useCallback, useEffect } from 'react';
|
import {
|
||||||
|
useImperativeHandle,
|
||||||
|
forwardRef,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
} from 'react';
|
||||||
import isElectron from 'is-electron';
|
import isElectron from 'is-electron';
|
||||||
import type { ReactPlayerProps } from 'react-player';
|
import type { ReactPlayerProps } from 'react-player';
|
||||||
import ReactPlayer from 'react-player/lazy';
|
import ReactPlayer from 'react-player/lazy';
|
||||||
@@ -10,16 +18,17 @@ import {
|
|||||||
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||||
import type { CrossfadeStyle } from '/@/renderer/types';
|
import type { CrossfadeStyle } from '/@/renderer/types';
|
||||||
import { PlaybackStyle, PlayerStatus } from '/@/renderer/types';
|
import { PlaybackStyle, PlayerStatus } from '/@/renderer/types';
|
||||||
import { useSpeed } from '/@/renderer/store';
|
import { getServerById, TranscodingConfig, usePlaybackSettings, useSpeed } from '/@/renderer/store';
|
||||||
import { toast } from '/@/renderer/components/toast';
|
import { toast } from '/@/renderer/components/toast';
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
|
||||||
interface AudioPlayerProps extends ReactPlayerProps {
|
interface AudioPlayerProps extends ReactPlayerProps {
|
||||||
crossfadeDuration: number;
|
crossfadeDuration: number;
|
||||||
crossfadeStyle: CrossfadeStyle;
|
crossfadeStyle: CrossfadeStyle;
|
||||||
currentPlayer: 1 | 2;
|
currentPlayer: 1 | 2;
|
||||||
playbackStyle: PlaybackStyle;
|
playbackStyle: PlaybackStyle;
|
||||||
player1: Song;
|
player1?: Song;
|
||||||
player2: Song;
|
player2?: Song;
|
||||||
status: PlayerStatus;
|
status: PlayerStatus;
|
||||||
volume: number;
|
volume: number;
|
||||||
}
|
}
|
||||||
@@ -40,13 +49,51 @@ type WebAudio = {
|
|||||||
gain: GainNode;
|
gain: GainNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Credits: http://stackoverflow.com/questions/12150729/ddg
|
// Credits: https://gist.github.com/novwhisky/8a1a0168b94f3b6abfaa?permalink_comment_id=1551393#gistcomment-1551393
|
||||||
// This is used so that the player will always have an <audio> element. This means that
|
// This is used so that the player will always have an <audio> element. This means that
|
||||||
// player1Source and player2Source are connected BEFORE the user presses play for
|
// player1Source and player2Source are connected BEFORE the user presses play for
|
||||||
// the first time. This workaround is important for Safari, which seems to require the
|
// the first time. This workaround is important for Safari, which seems to require the
|
||||||
// source to be connected PRIOR to resuming audio context
|
// source to be connected PRIOR to resuming audio context
|
||||||
const EMPTY_SOURCE =
|
const EMPTY_SOURCE =
|
||||||
'data:audio/wav;base64,UklGRjIAAABXQVZFZm10IBIAAAABAAEAQB8AAEAfAAABAAgAAABmYWN0BAAAAAAAAABkYXRhAAAAAA==';
|
'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV';
|
||||||
|
|
||||||
|
const useSongUrl = (transcode: TranscodingConfig, current: boolean, song?: Song): string | null => {
|
||||||
|
const prior = useRef(['', '']);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (song?.serverId) {
|
||||||
|
// If we are the current track, we do not want a transcoding
|
||||||
|
// reconfiguration to force a restart.
|
||||||
|
if (current && prior.current[0] === song.uniqueId) {
|
||||||
|
return prior.current[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transcode.enabled) {
|
||||||
|
// transcoding disabled; save the result
|
||||||
|
prior.current = [song.uniqueId, song.streamUrl];
|
||||||
|
return song.streamUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = api.controller.getTranscodingUrl({
|
||||||
|
apiClientProps: {
|
||||||
|
server: getServerById(song.serverId),
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
base: song.streamUrl,
|
||||||
|
...transcode,
|
||||||
|
},
|
||||||
|
})!;
|
||||||
|
|
||||||
|
// transcoding enabled; save the updated result
|
||||||
|
prior.current = [song.uniqueId, result];
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no track; clear result
|
||||||
|
prior.current = ['', ''];
|
||||||
|
return null;
|
||||||
|
}, [current, song?.uniqueId, song?.serverId, song?.streamUrl, transcode]);
|
||||||
|
};
|
||||||
|
|
||||||
export const AudioPlayer = forwardRef(
|
export const AudioPlayer = forwardRef(
|
||||||
(
|
(
|
||||||
@@ -69,8 +116,13 @@ export const AudioPlayer = forwardRef(
|
|||||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||||
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
|
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
|
||||||
const playback = useSettingsStore((state) => state.playback.mpvProperties);
|
const playback = useSettingsStore((state) => state.playback.mpvProperties);
|
||||||
|
const useWebAudio = useSettingsStore((state) => state.playback.webAudio);
|
||||||
const { resetSampleRate } = useSettingsStoreActions();
|
const { resetSampleRate } = useSettingsStoreActions();
|
||||||
const playbackSpeed = useSpeed();
|
const playbackSpeed = useSpeed();
|
||||||
|
const { transcode } = usePlaybackSettings();
|
||||||
|
|
||||||
|
const stream1 = useSongUrl(transcode, currentPlayer === 1, player1);
|
||||||
|
const stream2 = useSongUrl(transcode, currentPlayer === 2, player2);
|
||||||
|
|
||||||
const [webAudio, setWebAudio] = useState<WebAudio | null>(null);
|
const [webAudio, setWebAudio] = useState<WebAudio | null>(null);
|
||||||
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(
|
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(
|
||||||
@@ -129,7 +181,7 @@ export const AudioPlayer = forwardRef(
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ('AudioContext' in window) {
|
if (useWebAudio && 'AudioContext' in window) {
|
||||||
let context: AudioContext;
|
let context: AudioContext;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -262,30 +314,57 @@ export const AudioPlayer = forwardRef(
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isElectron()) {
|
// Not standard, just used in chromium-based browsers. See
|
||||||
if (audioDeviceId) {
|
// https://developer.chrome.com/blog/audiocontext-setsinkid/.
|
||||||
player1Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId);
|
// If the isElectron() check is every removed, fix this.
|
||||||
player2Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId);
|
if (isElectron() && webAudio && 'setSinkId' in webAudio.context && audioDeviceId) {
|
||||||
} else {
|
const setSink = async () => {
|
||||||
player1Ref.current?.getInternalPlayer()?.setSinkId('');
|
try {
|
||||||
player2Ref.current?.getInternalPlayer()?.setSinkId('');
|
if (audioDeviceId !== 'default') {
|
||||||
}
|
// @ts-ignore
|
||||||
|
await webAudio.context.setSinkId(audioDeviceId);
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
await webAudio.context.setSinkId('');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error({ message: `Error setting sink: ${(error as Error).message}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setSink();
|
||||||
}
|
}
|
||||||
}, [audioDeviceId]);
|
}, [audioDeviceId, webAudio]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (webAudio && player1Source && player1 && currentPlayer === 1) {
|
if (!webAudio) return;
|
||||||
const newVolume = calculateReplayGain(player1) * volume;
|
|
||||||
webAudio.gain.gain.setValueAtTime(newVolume, 0);
|
|
||||||
}
|
|
||||||
}, [calculateReplayGain, currentPlayer, player1, player1Source, volume, webAudio]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const sources = [player1Source ? player1 : null, player2Source ? player2 : null];
|
||||||
if (webAudio && player2Source && player2 && currentPlayer === 2) {
|
const current = sources[currentPlayer - 1];
|
||||||
const newVolume = calculateReplayGain(player2) * volume;
|
|
||||||
|
// Set the current replaygain
|
||||||
|
if (current) {
|
||||||
|
const newVolume = calculateReplayGain(current) * volume;
|
||||||
webAudio.gain.gain.setValueAtTime(newVolume, 0);
|
webAudio.gain.gain.setValueAtTime(newVolume, 0);
|
||||||
}
|
}
|
||||||
}, [calculateReplayGain, currentPlayer, player2, player2Source, volume, webAudio]);
|
|
||||||
|
// Set the next track replaygain right before the end of this track
|
||||||
|
// Attempt to prevent pop-in for web audio.
|
||||||
|
const next = sources[3 - currentPlayer];
|
||||||
|
if (next && current) {
|
||||||
|
const newVolume = calculateReplayGain(next) * volume;
|
||||||
|
webAudio.gain.gain.setValueAtTime(newVolume, (current.duration - 1) / 1000);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
calculateReplayGain,
|
||||||
|
currentPlayer,
|
||||||
|
player1,
|
||||||
|
player1Source,
|
||||||
|
player2,
|
||||||
|
player2Source,
|
||||||
|
volume,
|
||||||
|
webAudio,
|
||||||
|
]);
|
||||||
|
|
||||||
const handlePlayer1Start = useCallback(
|
const handlePlayer1Start = useCallback(
|
||||||
async (player: ReactPlayer) => {
|
async (player: ReactPlayer) => {
|
||||||
@@ -346,11 +425,11 @@ export const AudioPlayer = forwardRef(
|
|||||||
playbackRate={playbackSpeed}
|
playbackRate={playbackSpeed}
|
||||||
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
|
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
|
||||||
progressInterval={isTransitioning ? 10 : 250}
|
progressInterval={isTransitioning ? 10 : 250}
|
||||||
url={player1?.streamUrl || EMPTY_SOURCE}
|
url={stream1 || EMPTY_SOURCE}
|
||||||
volume={webAudio ? 1 : volume}
|
volume={webAudio ? 1 : volume}
|
||||||
width={0}
|
width={0}
|
||||||
// If there is no stream url, we do not need to handle when the audio finishes
|
// If there is no stream url, we do not need to handle when the audio finishes
|
||||||
onEnded={player1?.streamUrl ? handleOnEnded : undefined}
|
onEnded={stream1 ? handleOnEnded : undefined}
|
||||||
onProgress={
|
onProgress={
|
||||||
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1
|
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1
|
||||||
}
|
}
|
||||||
@@ -366,10 +445,10 @@ export const AudioPlayer = forwardRef(
|
|||||||
playbackRate={playbackSpeed}
|
playbackRate={playbackSpeed}
|
||||||
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
|
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
|
||||||
progressInterval={isTransitioning ? 10 : 250}
|
progressInterval={isTransitioning ? 10 : 250}
|
||||||
url={player2?.streamUrl || EMPTY_SOURCE}
|
url={stream2 || EMPTY_SOURCE}
|
||||||
volume={webAudio ? 1 : volume}
|
volume={webAudio ? 1 : volume}
|
||||||
width={0}
|
width={0}
|
||||||
onEnded={player2?.streamUrl ? handleOnEnded : undefined}
|
onEnded={stream2 ? handleOnEnded : undefined}
|
||||||
onProgress={
|
onProgress={
|
||||||
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2
|
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ export const crossfadeHandler = (args: {
|
|||||||
} = args;
|
} = args;
|
||||||
|
|
||||||
if (!isTransitioning || currentPlayer !== player) {
|
if (!isTransitioning || currentPlayer !== player) {
|
||||||
const shouldBeginTransition = currentTime >= duration - fadeDuration;
|
// check for a large-enough duration, as the default audio element has some dummy audio
|
||||||
|
const shouldBeginTransition = duration > 0.5 && currentTime >= duration - fadeDuration;
|
||||||
|
|
||||||
if (shouldBeginTransition) {
|
if (shouldBeginTransition) {
|
||||||
setIsTransitioning(true);
|
setIsTransitioning(true);
|
||||||
@@ -100,10 +101,10 @@ export const crossfadeHandler = (args: {
|
|||||||
fadeType === 'constantPower'
|
fadeType === 'constantPower'
|
||||||
? 0
|
? 0
|
||||||
: fadeType === 'constantPowerSlowFade'
|
: fadeType === 'constantPowerSlowFade'
|
||||||
? 1
|
? 1
|
||||||
: fadeType === 'constantPowerSlowCut'
|
: fadeType === 'constantPowerSlowCut'
|
||||||
? 3
|
? 3
|
||||||
: 10;
|
: 10;
|
||||||
|
|
||||||
percentageOfFadeLeft = timeLeft / fadeDuration;
|
percentageOfFadeLeft = timeLeft / fadeDuration;
|
||||||
currentPlayerVolumeCalculation =
|
currentPlayerVolumeCalculation =
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
|
|||||||
background: ${(props) => `var(--btn-${props.variant}-bg)`};
|
background: ${(props) => `var(--btn-${props.variant}-bg)`};
|
||||||
border: ${(props) => `var(--btn-${props.variant}-border)`};
|
border: ${(props) => `var(--btn-${props.variant}-border)`};
|
||||||
border-radius: ${(props) => `var(--btn-${props.variant}-radius)`};
|
border-radius: ${(props) => `var(--btn-${props.variant}-radius)`};
|
||||||
transition: background 0.2s ease-in-out, color 0.2s ease-in-out, border 0.2s ease-in-out;
|
transition:
|
||||||
|
background 0.2s ease-in-out,
|
||||||
|
color 0.2s ease-in-out,
|
||||||
|
border 0.2s ease-in-out;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
fill: ${(props) => `var(--btn-${props.variant}-fg)`};
|
fill: ${(props) => `var(--btn-${props.variant}-fg)`};
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ const CardWrapper = styled.div<{
|
|||||||
cursor: ${({ link }) => link && 'pointer'};
|
cursor: ${({ link }) => link && 'pointer'};
|
||||||
background: var(--card-default-bg);
|
background: var(--card-default-bg);
|
||||||
border-radius: var(--card-default-radius);
|
border-radius: var(--card-default-radius);
|
||||||
transition: border 0.2s ease-in-out, background 0.2s ease-in-out;
|
transition:
|
||||||
|
border 0.2s ease-in-out,
|
||||||
|
background 0.2s ease-in-out;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--card-default-bg-hover);
|
background: var(--card-default-bg-hover);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import formatDuration from 'format-duration';
|
||||||
import { generatePath } from 'react-router';
|
import { generatePath } from 'react-router';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
@@ -6,6 +7,7 @@ import { Album, AlbumArtist, Artist, Playlist, Song } from '/@/renderer/api/type
|
|||||||
import { Text } from '/@/renderer/components/text';
|
import { Text } from '/@/renderer/components/text';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import { CardRow } from '/@/renderer/types';
|
import { CardRow } from '/@/renderer/types';
|
||||||
|
import { formatDateAbsolute, formatDateRelative, formatRating } from '/@/renderer/utils/format';
|
||||||
|
|
||||||
const Row = styled.div<{ $secondary?: boolean }>`
|
const Row = styled.div<{ $secondary?: boolean }>`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -69,7 +71,10 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
|||||||
)}
|
)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{row.arrayProperty && item[row.arrayProperty]}
|
{row.arrayProperty &&
|
||||||
|
(row.format
|
||||||
|
? row.format(item)
|
||||||
|
: item[row.arrayProperty])}
|
||||||
</Text>
|
</Text>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
@@ -88,7 +93,8 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
|||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
size={index > 0 ? 'sm' : 'md'}
|
size={index > 0 ? 'sm' : 'md'}
|
||||||
>
|
>
|
||||||
{row.arrayProperty && item[row.arrayProperty]}
|
{row.arrayProperty &&
|
||||||
|
(row.format ? row.format(item) : item[row.arrayProperty])}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
@@ -114,7 +120,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
|||||||
)}
|
)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{data && data[row.property]}
|
{data && (row.format ? row.format(data) : data[row.property])}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
@@ -123,7 +129,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
|||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
size={index > 0 ? 'sm' : 'md'}
|
size={index > 0 ? 'sm' : 'md'}
|
||||||
>
|
>
|
||||||
{data && data[row.property]}
|
{data && (row.format ? row.format(data) : data[row.property])}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
@@ -151,12 +157,15 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
createdAt: {
|
createdAt: {
|
||||||
|
format: (song) => formatDateAbsolute(song.createdAt),
|
||||||
property: 'createdAt',
|
property: 'createdAt',
|
||||||
},
|
},
|
||||||
duration: {
|
duration: {
|
||||||
|
format: (album) => (album.duration === null ? null : formatDuration(album.duration)),
|
||||||
property: 'duration',
|
property: 'duration',
|
||||||
},
|
},
|
||||||
lastPlayedAt: {
|
lastPlayedAt: {
|
||||||
|
format: (album) => formatDateRelative(album.lastPlayedAt),
|
||||||
property: 'lastPlayedAt',
|
property: 'lastPlayedAt',
|
||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
@@ -170,6 +179,7 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
|
|||||||
property: 'playCount',
|
property: 'playCount',
|
||||||
},
|
},
|
||||||
rating: {
|
rating: {
|
||||||
|
format: (album) => formatRating(album),
|
||||||
property: 'userRating',
|
property: 'userRating',
|
||||||
},
|
},
|
||||||
releaseDate: {
|
releaseDate: {
|
||||||
@@ -208,12 +218,15 @@ export const SONG_CARD_ROWS: { [key: string]: CardRow<Song> } = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
createdAt: {
|
createdAt: {
|
||||||
|
format: (song) => formatDateAbsolute(song.createdAt),
|
||||||
property: 'createdAt',
|
property: 'createdAt',
|
||||||
},
|
},
|
||||||
duration: {
|
duration: {
|
||||||
|
format: (song) => (song.duration === null ? null : formatDuration(song.duration)),
|
||||||
property: 'duration',
|
property: 'duration',
|
||||||
},
|
},
|
||||||
lastPlayedAt: {
|
lastPlayedAt: {
|
||||||
|
format: (song) => formatDateRelative(song.lastPlayedAt),
|
||||||
property: 'lastPlayedAt',
|
property: 'lastPlayedAt',
|
||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
@@ -227,6 +240,7 @@ export const SONG_CARD_ROWS: { [key: string]: CardRow<Song> } = {
|
|||||||
property: 'playCount',
|
property: 'playCount',
|
||||||
},
|
},
|
||||||
rating: {
|
rating: {
|
||||||
|
format: (song) => formatRating(song),
|
||||||
property: 'userRating',
|
property: 'userRating',
|
||||||
},
|
},
|
||||||
releaseDate: {
|
releaseDate: {
|
||||||
@@ -242,12 +256,14 @@ export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
|
|||||||
property: 'albumCount',
|
property: 'albumCount',
|
||||||
},
|
},
|
||||||
duration: {
|
duration: {
|
||||||
|
format: (artist) => (artist.duration === null ? null : formatDuration(artist.duration)),
|
||||||
property: 'duration',
|
property: 'duration',
|
||||||
},
|
},
|
||||||
genres: {
|
genres: {
|
||||||
property: 'genres',
|
property: 'genres',
|
||||||
},
|
},
|
||||||
lastPlayedAt: {
|
lastPlayedAt: {
|
||||||
|
format: (artist) => formatDateRelative(artist.lastPlayedAt),
|
||||||
property: 'lastPlayedAt',
|
property: 'lastPlayedAt',
|
||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
@@ -261,6 +277,7 @@ export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
|
|||||||
property: 'playCount',
|
property: 'playCount',
|
||||||
},
|
},
|
||||||
rating: {
|
rating: {
|
||||||
|
format: (artist) => formatRating(artist),
|
||||||
property: 'userRating',
|
property: 'userRating',
|
||||||
},
|
},
|
||||||
songCount: {
|
songCount: {
|
||||||
@@ -270,6 +287,8 @@ export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
|
|||||||
|
|
||||||
export const PLAYLIST_CARD_ROWS: { [key: string]: CardRow<Playlist> } = {
|
export const PLAYLIST_CARD_ROWS: { [key: string]: CardRow<Playlist> } = {
|
||||||
duration: {
|
duration: {
|
||||||
|
format: (playlist) =>
|
||||||
|
playlist.duration === null ? null : formatDuration(playlist.duration),
|
||||||
property: 'duration',
|
property: 'duration',
|
||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
@@ -295,7 +314,4 @@ export const PLAYLIST_CARD_ROWS: { [key: string]: CardRow<Playlist> } = {
|
|||||||
songCount: {
|
songCount: {
|
||||||
property: 'songCount',
|
property: 'songCount',
|
||||||
},
|
},
|
||||||
updatedAt: {
|
|
||||||
property: 'songCount',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ const Image = styled(SimpleImg)`
|
|||||||
|
|
||||||
img {
|
img {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: var(--image-fit);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const BackgroundImage = styled.img`
|
|||||||
height: 150%;
|
height: 150%;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
filter: blur(24px);
|
filter: blur(24px);
|
||||||
object-fit: cover;
|
object-fit: var(--image-fit);
|
||||||
object-position: 0 30%;
|
object-position: 0 30%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -232,8 +232,8 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
|||||||
playType === Play.NOW
|
playType === Play.NOW
|
||||||
? 'player.play'
|
? 'player.play'
|
||||||
: playType === Play.NEXT
|
: playType === Play.NEXT
|
||||||
? 'player.addNext'
|
? 'player.addNext'
|
||||||
: 'player.addLast',
|
: 'player.addLast',
|
||||||
{ postProcess: 'titleCase' },
|
{ postProcess: 'titleCase' },
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ const StyledPagination = styled(MantinePagination)<PaginationProps>`
|
|||||||
color: var(--btn-default-fg);
|
color: var(--btn-default-fg);
|
||||||
background-color: var(--btn-default-bg);
|
background-color: var(--btn-default-bg);
|
||||||
border: none;
|
border: none;
|
||||||
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
|
transition:
|
||||||
|
background 0.2s ease-in-out,
|
||||||
|
color 0.2s ease-in-out;
|
||||||
|
|
||||||
&[data-active] {
|
&[data-active] {
|
||||||
color: var(--btn-primary-fg);
|
color: var(--btn-primary-fg);
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ const StyledTabs = styled(MantineTabs)`
|
|||||||
background: var(--btn-subtle-bg-hover);
|
background: var(--btn-subtle-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
|
transition:
|
||||||
|
background 0.2s ease-in-out,
|
||||||
|
color 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
button[data-active] {
|
button[data-active] {
|
||||||
|
|||||||
@@ -16,19 +16,19 @@ const showToast = ({ type, ...props }: NotificationProps) => {
|
|||||||
type === 'success'
|
type === 'success'
|
||||||
? 'var(--success-color)'
|
? 'var(--success-color)'
|
||||||
: type === 'warning'
|
: type === 'warning'
|
||||||
? 'var(--warning-color)'
|
? 'var(--warning-color)'
|
||||||
: type === 'error'
|
: type === 'error'
|
||||||
? 'var(--danger-color)'
|
? 'var(--danger-color)'
|
||||||
: 'var(--primary-color)';
|
: 'var(--primary-color)';
|
||||||
|
|
||||||
const defaultTitle =
|
const defaultTitle =
|
||||||
type === 'success'
|
type === 'success'
|
||||||
? 'Success'
|
? 'Success'
|
||||||
: type === 'warning'
|
: type === 'warning'
|
||||||
? 'Warning'
|
? 'Warning'
|
||||||
: type === 'error'
|
: type === 'error'
|
||||||
? 'Error'
|
? 'Error'
|
||||||
: 'Info';
|
: 'Info';
|
||||||
|
|
||||||
const defaultDuration = type === 'error' ? 5000 : 2000;
|
const defaultDuration = type === 'error' ? 5000 : 2000;
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const Image = styled(SimpleImg)`
|
|||||||
|
|
||||||
img {
|
img {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: var(--image-fit);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -160,8 +160,8 @@ export const GridCardControls = ({
|
|||||||
itemType === LibraryItem.ALBUM
|
itemType === LibraryItem.ALBUM
|
||||||
? ALBUM_CONTEXT_MENU_ITEMS
|
? ALBUM_CONTEXT_MENU_ITEMS
|
||||||
: itemType === LibraryItem.PLAYLIST
|
: itemType === LibraryItem.PLAYLIST
|
||||||
? PLAYLIST_CONTEXT_MENU_ITEMS
|
? PLAYLIST_CONTEXT_MENU_ITEMS
|
||||||
: ARTIST_CONTEXT_MENU_ITEMS,
|
: ARTIST_CONTEXT_MENU_ITEMS,
|
||||||
resetInfiniteLoaderCache,
|
resetInfiniteLoaderCache,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ const Image = styled(SimpleImg)`
|
|||||||
|
|
||||||
img {
|
img {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: var(--image-fit);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ import InfiniteLoader from 'react-window-infinite-loader';
|
|||||||
import { VirtualGridWrapper } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper';
|
import { VirtualGridWrapper } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper';
|
||||||
import type { CardRoute, CardRow, PlayQueueAddOptions } from '/@/renderer/types';
|
import type { CardRoute, CardRow, PlayQueueAddOptions } from '/@/renderer/types';
|
||||||
import { ListDisplayType } from '/@/renderer/types';
|
import { ListDisplayType } from '/@/renderer/types';
|
||||||
import { LibraryItem } from '/@/renderer/api/types';
|
import { AnyLibraryItem, Genre, LibraryItem } from '/@/renderer/api/types';
|
||||||
|
|
||||||
|
type LibraryItemOrGenre = AnyLibraryItem | Genre;
|
||||||
|
|
||||||
export type VirtualInfiniteGridRef = {
|
export type VirtualInfiniteGridRef = {
|
||||||
resetLoadMoreItemsCache: () => void;
|
resetLoadMoreItemsCache: () => void;
|
||||||
scrollTo: (index: number) => void;
|
scrollTo: (index: number) => void;
|
||||||
setItemData: (data: any[]) => void;
|
setItemData: (data: LibraryItemOrGenre[]) => void;
|
||||||
updateItemData: (rule: (item: any) => any) => void;
|
updateItemData: (rule: (item: LibraryItemOrGenre) => LibraryItemOrGenre) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface VirtualGridProps
|
interface VirtualGridProps
|
||||||
@@ -27,7 +29,7 @@ interface VirtualGridProps
|
|||||||
cardRows: CardRow<any>[];
|
cardRows: CardRow<any>[];
|
||||||
display?: ListDisplayType;
|
display?: ListDisplayType;
|
||||||
fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>;
|
fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>;
|
||||||
fetchInitialData?: () => any;
|
fetchInitialData?: () => LibraryItemOrGenre[];
|
||||||
handleFavorite?: (options: {
|
handleFavorite?: (options: {
|
||||||
id: string[];
|
id: string[];
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
@@ -69,8 +71,12 @@ export const VirtualInfiniteGrid = forwardRef(
|
|||||||
) => {
|
) => {
|
||||||
const listRef = useRef<any>(null);
|
const listRef = useRef<any>(null);
|
||||||
const loader = useRef<InfiniteLoader>(null);
|
const loader = useRef<InfiniteLoader>(null);
|
||||||
|
const minItemCount = useRef(0);
|
||||||
|
|
||||||
const [itemData, setItemData] = useState<any[]>(fetchInitialData?.() || []);
|
// itemData can be a sparse array. Treat the intermediate elements as being undefined
|
||||||
|
const [itemData, setItemData] = useState<Array<LibraryItemOrGenre | undefined>>(
|
||||||
|
fetchInitialData?.() || [],
|
||||||
|
);
|
||||||
|
|
||||||
const { itemHeight, rowCount, columnCount } = useMemo(() => {
|
const { itemHeight, rowCount, columnCount } = useMemo(() => {
|
||||||
const itemsPerRow = width ? Math.floor(width / (itemSize + itemGap * 2)) : 5;
|
const itemsPerRow = width ? Math.floor(width / (itemSize + itemGap * 2)) : 5;
|
||||||
@@ -95,8 +101,17 @@ export const VirtualInfiniteGrid = forwardRef(
|
|||||||
|
|
||||||
const loadMoreItems = useCallback(
|
const loadMoreItems = useCallback(
|
||||||
async (startIndex: number, stopIndex: number) => {
|
async (startIndex: number, stopIndex: number) => {
|
||||||
// Fixes a caching bug(?) when switching between filters and the itemCount increases
|
if (
|
||||||
if (startIndex === 1) return;
|
// Fixes a caching bug(?) when switching between filters and the itemCount increases
|
||||||
|
startIndex === 1 ||
|
||||||
|
// Fixes a caching bug when refreshing items. Prevents a second
|
||||||
|
// refetch from happening if:
|
||||||
|
// 1: we are already in a refresh (-1)
|
||||||
|
// 2: we just had a refresh, and we are index 0
|
||||||
|
minItemCount.current === -1 ||
|
||||||
|
(minItemCount.current > 0 && startIndex === 0)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
// Need to multiply by columnCount due to the grid layout
|
// Need to multiply by columnCount due to the grid layout
|
||||||
const start = startIndex * columnCount;
|
const start = startIndex * columnCount;
|
||||||
@@ -109,7 +124,7 @@ export const VirtualInfiniteGrid = forwardRef(
|
|||||||
});
|
});
|
||||||
|
|
||||||
setItemData((itemData) => {
|
setItemData((itemData) => {
|
||||||
const newData: any[] = [...itemData];
|
const newData = [...itemData];
|
||||||
|
|
||||||
let itemIndex = 0;
|
let itemIndex = 0;
|
||||||
for (let rowIndex = start; rowIndex < itemCount; rowIndex += 1) {
|
for (let rowIndex = start; rowIndex < itemCount; rowIndex += 1) {
|
||||||
@@ -129,17 +144,19 @@ export const VirtualInfiniteGrid = forwardRef(
|
|||||||
resetLoadMoreItemsCache: () => {
|
resetLoadMoreItemsCache: () => {
|
||||||
if (loader.current) {
|
if (loader.current) {
|
||||||
loader.current.resetloadMoreItemsCache(false);
|
loader.current.resetloadMoreItemsCache(false);
|
||||||
|
minItemCount.current = -1;
|
||||||
setItemData([]);
|
setItemData([]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scrollTo: (index: number) => {
|
scrollTo: (index: number) => {
|
||||||
listRef?.current?.scrollToItem(index);
|
listRef?.current?.scrollToItem(index);
|
||||||
},
|
},
|
||||||
setItemData: (data: any[]) => {
|
setItemData: (data: LibraryItemOrGenre[]) => {
|
||||||
setItemData(data);
|
setItemData(data);
|
||||||
|
minItemCount.current = data.length;
|
||||||
},
|
},
|
||||||
updateItemData: (rule) => {
|
updateItemData: (rule) => {
|
||||||
setItemData((data) => data.map(rule));
|
setItemData((data) => data.map((item) => item && rule(item)));
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const MetadataWrapper = styled.div`
|
|||||||
|
|
||||||
const StyledImage = styled(SimpleImg)`
|
const StyledImage = styled(SimpleImg)`
|
||||||
img {
|
img {
|
||||||
object-fit: cover;
|
object-fit: var(--image-fit);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ export const FullWidthDiscCell = ({ node, data, api }: ICellRendererParams) => {
|
|||||||
|
|
||||||
const handleToggleDiscNodes = () => {
|
const handleToggleDiscNodes = () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const discNumber = Number(node.data.id.split('-')[1]);
|
const split: string[] = node.data.id.split('-');
|
||||||
const nodes = getNodesByDiscNumber({ api, discNumber });
|
const discNumber = Number(split[1]);
|
||||||
|
// the subtitle could have '-' in it; make sure to have all remaining items
|
||||||
|
const subtitle = split.length === 3 ? split.slice(2).join('-') : null;
|
||||||
|
const nodes = getNodesByDiscNumber({ api, discNumber, subtitle });
|
||||||
|
|
||||||
setNodeSelection({ isSelected: !isSelected, nodes });
|
setNodeSelection({ isSelected: !isSelected, nodes });
|
||||||
setIsSelected((prev) => !prev);
|
setIsSelected((prev) => !prev);
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export const CellContainer = styled.div<{ $position?: 'left' | 'center' | 'right
|
|||||||
props.$position === 'right'
|
props.$position === 'right'
|
||||||
? 'flex-end'
|
? 'flex-end'
|
||||||
: props.$position === 'center'
|
: props.$position === 'center'
|
||||||
? 'center'
|
? 'center'
|
||||||
: 'flex-start'};
|
: 'flex-start'};
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export const HeaderWrapper = styled.div<{ $position: Options['position'] }>`
|
|||||||
props.$position === 'right'
|
props.$position === 'right'
|
||||||
? 'flex-end'
|
? 'flex-end'
|
||||||
: props.$position === 'center'
|
: props.$position === 'center'
|
||||||
? 'center'
|
? 'center'
|
||||||
: 'flex-start'};
|
: 'flex-start'};
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: var(--content-font-family);
|
font-family: var(--content-font-family);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -37,8 +37,8 @@ const HeaderText = styled(_Text)<{ $position: Options['position'] }>`
|
|||||||
props.$position === 'right'
|
props.$position === 'right'
|
||||||
? 'flex-end'
|
? 'flex-end'
|
||||||
: props.$position === 'center'
|
: props.$position === 'center'
|
||||||
? 'center'
|
? 'center'
|
||||||
: 'flex-start'};
|
: 'flex-start'};
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
import { useQueryClient, useMutation } from '@tanstack/react-query';
|
|
||||||
import { AxiosError } from 'axios';
|
|
||||||
import { api } from '/@/renderer/api';
|
|
||||||
import { NDAlbumDetail, NDAlbumArtistDetail } from '/@/renderer/api/navidrome.types';
|
|
||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
|
||||||
import { SSAlbumDetail, SSAlbumArtistDetail } from '/@/renderer/api/subsonic.types';
|
|
||||||
import {
|
|
||||||
SetRatingArgs,
|
|
||||||
Album,
|
|
||||||
AlbumArtist,
|
|
||||||
LibraryItem,
|
|
||||||
AnyLibraryItems,
|
|
||||||
RatingResponse,
|
|
||||||
ServerType,
|
|
||||||
} from '/@/renderer/api/types';
|
|
||||||
import { useSetAlbumListItemDataById, useSetQueueRating, getServerById } from '/@/renderer/store';
|
|
||||||
|
|
||||||
export const useUpdateRating = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const setAlbumListData = useSetAlbumListItemDataById();
|
|
||||||
const setQueueRating = useSetQueueRating();
|
|
||||||
|
|
||||||
return useMutation<
|
|
||||||
RatingResponse,
|
|
||||||
AxiosError,
|
|
||||||
Omit<SetRatingArgs, 'server' | 'apiClientProps'>,
|
|
||||||
{ previous: { items: AnyLibraryItems } | undefined }
|
|
||||||
>({
|
|
||||||
mutationFn: (args) => {
|
|
||||||
const server = getServerById(args.serverId);
|
|
||||||
if (!server) throw new Error('Server not found');
|
|
||||||
return api.controller.updateRating({ ...args, apiClientProps: { server } });
|
|
||||||
},
|
|
||||||
onError: (_error, _variables, context) => {
|
|
||||||
for (const item of context?.previous?.items || []) {
|
|
||||||
switch (item.itemType) {
|
|
||||||
case LibraryItem.ALBUM:
|
|
||||||
setAlbumListData(item.id, { userRating: item.userRating });
|
|
||||||
break;
|
|
||||||
case LibraryItem.SONG:
|
|
||||||
setQueueRating([item.id], item.userRating);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMutate: (variables) => {
|
|
||||||
for (const item of variables.query.item) {
|
|
||||||
switch (item.itemType) {
|
|
||||||
case LibraryItem.ALBUM:
|
|
||||||
setAlbumListData(item.id, { userRating: variables.query.rating });
|
|
||||||
break;
|
|
||||||
case LibraryItem.SONG:
|
|
||||||
setQueueRating([item.id], variables.query.rating);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { previous: { items: variables.query.item } };
|
|
||||||
},
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
// We only need to set if we're already on the album detail page
|
|
||||||
const isAlbumDetailPage =
|
|
||||||
variables.query.item.length === 1 &&
|
|
||||||
variables.query.item[0].itemType === LibraryItem.ALBUM;
|
|
||||||
|
|
||||||
if (isAlbumDetailPage) {
|
|
||||||
const { serverType, id: albumId, serverId } = variables.query.item[0] as Album;
|
|
||||||
|
|
||||||
const queryKey = queryKeys.albums.detail(serverId || '', { id: albumId });
|
|
||||||
const previous = queryClient.getQueryData<any>(queryKey);
|
|
||||||
if (previous) {
|
|
||||||
switch (serverType) {
|
|
||||||
case ServerType.NAVIDROME:
|
|
||||||
queryClient.setQueryData<NDAlbumDetail>(queryKey, {
|
|
||||||
...previous,
|
|
||||||
userRating: variables.query.rating,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case ServerType.SUBSONIC:
|
|
||||||
queryClient.setQueryData<SSAlbumDetail>(queryKey, {
|
|
||||||
...previous,
|
|
||||||
userRating: variables.query.rating,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case ServerType.JELLYFIN:
|
|
||||||
// Jellyfin does not support ratings
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We only need to set if we're already on the album detail page
|
|
||||||
const isAlbumArtistDetailPage =
|
|
||||||
variables.query.item.length === 1 &&
|
|
||||||
variables.query.item[0].itemType === LibraryItem.ALBUM_ARTIST;
|
|
||||||
|
|
||||||
if (isAlbumArtistDetailPage) {
|
|
||||||
const {
|
|
||||||
serverType,
|
|
||||||
id: albumArtistId,
|
|
||||||
serverId,
|
|
||||||
} = variables.query.item[0] as AlbumArtist;
|
|
||||||
|
|
||||||
const queryKey = queryKeys.albumArtists.detail(serverId || '', {
|
|
||||||
id: albumArtistId,
|
|
||||||
});
|
|
||||||
const previous = queryClient.getQueryData<any>(queryKey);
|
|
||||||
if (previous) {
|
|
||||||
switch (serverType) {
|
|
||||||
case ServerType.NAVIDROME:
|
|
||||||
queryClient.setQueryData<NDAlbumArtistDetail>(queryKey, {
|
|
||||||
...previous,
|
|
||||||
userRating: variables.query.rating,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case ServerType.SUBSONIC:
|
|
||||||
queryClient.setQueryData<SSAlbumArtistDetail>(queryKey, {
|
|
||||||
...previous,
|
|
||||||
userRating: variables.query.rating,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case ServerType.JELLYFIN:
|
|
||||||
// Jellyfin does not support ratings
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -15,8 +15,6 @@ import type { AgGridReactProps } from '@ag-grid-community/react';
|
|||||||
import { AgGridReact } from '@ag-grid-community/react';
|
import { AgGridReact } from '@ag-grid-community/react';
|
||||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||||
import { useClickOutside, useMergedRef } from '@mantine/hooks';
|
import { useClickOutside, useMergedRef } from '@mantine/hooks';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
|
||||||
import formatDuration from 'format-duration';
|
import formatDuration from 'format-duration';
|
||||||
import { AnimatePresence } from 'framer-motion';
|
import { AnimatePresence } from 'framer-motion';
|
||||||
import { generatePath } from 'react-router';
|
import { generatePath } from 'react-router';
|
||||||
@@ -43,7 +41,8 @@ import { useFixedTableHeader } from '/@/renderer/components/virtual-table/hooks/
|
|||||||
import { NoteCell } from '/@/renderer/components/virtual-table/cells/note-cell';
|
import { NoteCell } from '/@/renderer/components/virtual-table/cells/note-cell';
|
||||||
import { RowIndexCell } from '/@/renderer/components/virtual-table/cells/row-index-cell';
|
import { RowIndexCell } from '/@/renderer/components/virtual-table/cells/row-index-cell';
|
||||||
import i18n from '/@/i18n/i18n';
|
import i18n from '/@/i18n/i18n';
|
||||||
import { formatSizeString } from '/@/renderer/utils/format-size-string';
|
import { formatDateAbsolute, formatDateRelative, formatSizeString } from '/@/renderer/utils/format';
|
||||||
|
import { useTableChange } from '/@/renderer/hooks/use-song-change';
|
||||||
|
|
||||||
export * from './table-config-dropdown';
|
export * from './table-config-dropdown';
|
||||||
export * from './table-pagination';
|
export * from './table-pagination';
|
||||||
@@ -64,8 +63,6 @@ const DummyHeader = styled.div<{ height?: number }>`
|
|||||||
height: ${({ height }) => height || 36}px;
|
height: ${({ height }) => height || 36}px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
|
||||||
|
|
||||||
const tableColumns: { [key: string]: ColDef } = {
|
const tableColumns: { [key: string]: ColDef } = {
|
||||||
actions: {
|
actions: {
|
||||||
cellClass: 'ag-cell-favorite',
|
cellClass: 'ag-cell-favorite',
|
||||||
@@ -182,8 +179,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
|||||||
GenericTableHeader(params, { position: 'center' }),
|
GenericTableHeader(params, { position: 'center' }),
|
||||||
headerName: i18n.t('table.column.dateAdded'),
|
headerName: i18n.t('table.column.dateAdded'),
|
||||||
suppressSizeToFit: true,
|
suppressSizeToFit: true,
|
||||||
valueFormatter: (params: ValueFormatterParams) =>
|
valueFormatter: (params: ValueFormatterParams) => formatDateAbsolute(params.value),
|
||||||
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
|
|
||||||
valueGetter: (params: ValueGetterParams) =>
|
valueGetter: (params: ValueGetterParams) =>
|
||||||
params.data ? params.data.createdAt : undefined,
|
params.data ? params.data.createdAt : undefined,
|
||||||
width: 130,
|
width: 130,
|
||||||
@@ -225,8 +221,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
|||||||
headerComponent: (params: IHeaderParams) =>
|
headerComponent: (params: IHeaderParams) =>
|
||||||
GenericTableHeader(params, { position: 'center' }),
|
GenericTableHeader(params, { position: 'center' }),
|
||||||
headerName: i18n.t('table.column.lastPlayed'),
|
headerName: i18n.t('table.column.lastPlayed'),
|
||||||
valueFormatter: (params: ValueFormatterParams) =>
|
valueFormatter: (params: ValueFormatterParams) => formatDateRelative(params.value),
|
||||||
params.value ? dayjs(params.value).fromNow() : '',
|
|
||||||
valueGetter: (params: ValueGetterParams) =>
|
valueGetter: (params: ValueGetterParams) =>
|
||||||
params.data ? params.data.lastPlayedAt : undefined,
|
params.data ? params.data.lastPlayedAt : undefined,
|
||||||
width: 130,
|
width: 130,
|
||||||
@@ -258,8 +253,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
|||||||
GenericTableHeader(params, { position: 'center' }),
|
GenericTableHeader(params, { position: 'center' }),
|
||||||
headerName: i18n.t('table.column.releaseDate'),
|
headerName: i18n.t('table.column.releaseDate'),
|
||||||
suppressSizeToFit: true,
|
suppressSizeToFit: true,
|
||||||
valueFormatter: (params: ValueFormatterParams) =>
|
valueFormatter: (params: ValueFormatterParams) => formatDateAbsolute(params.value),
|
||||||
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
|
|
||||||
valueGetter: (params: ValueGetterParams) =>
|
valueGetter: (params: ValueGetterParams) =>
|
||||||
params.data ? params.data.releaseDate : undefined,
|
params.data ? params.data.releaseDate : undefined,
|
||||||
width: 130,
|
width: 130,
|
||||||
@@ -482,6 +476,7 @@ export interface VirtualTableProps extends AgGridReactProps {
|
|||||||
pagination: TablePaginationType;
|
pagination: TablePaginationType;
|
||||||
setPagination: any;
|
setPagination: any;
|
||||||
};
|
};
|
||||||
|
shouldUpdateSong?: boolean;
|
||||||
stickyHeader?: boolean;
|
stickyHeader?: boolean;
|
||||||
transparentHeader?: boolean;
|
transparentHeader?: boolean;
|
||||||
}
|
}
|
||||||
@@ -499,6 +494,7 @@ export const VirtualTable = forwardRef(
|
|||||||
onGridReady,
|
onGridReady,
|
||||||
onGridSizeChanged,
|
onGridSizeChanged,
|
||||||
paginationProps,
|
paginationProps,
|
||||||
|
shouldUpdateSong,
|
||||||
...rest
|
...rest
|
||||||
}: VirtualTableProps,
|
}: VirtualTableProps,
|
||||||
ref: Ref<AgGridReactType | null>,
|
ref: Ref<AgGridReactType | null>,
|
||||||
@@ -513,6 +509,8 @@ export const VirtualTable = forwardRef(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useTableChange(tableRef, shouldUpdateSong === true);
|
||||||
|
|
||||||
const defaultColumnDefs: ColDef = useMemo(() => {
|
const defaultColumnDefs: ColDef = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
lockPinned: true,
|
lockPinned: true,
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { GridApi, RowNode } from '@ag-grid-community/core';
|
import { GridApi, RowNode } from '@ag-grid-community/core';
|
||||||
|
|
||||||
export const getNodesByDiscNumber = (args: { api: GridApi; discNumber: number }) => {
|
export const getNodesByDiscNumber = (args: {
|
||||||
const { api, discNumber } = args;
|
api: GridApi;
|
||||||
|
discNumber: number;
|
||||||
|
subtitle: string | null;
|
||||||
|
}) => {
|
||||||
|
const { api, discNumber, subtitle } = args;
|
||||||
|
|
||||||
const nodes: RowNode<any>[] = [];
|
const nodes: RowNode<any>[] = [];
|
||||||
api.forEachNode((node) => {
|
api.forEachNode((node) => {
|
||||||
if (node.data.discNumber === discNumber) nodes.push(node);
|
if (node.data.discNumber === discNumber && node.data.discSubtitle === subtitle)
|
||||||
|
nodes.push(node);
|
||||||
});
|
});
|
||||||
|
|
||||||
return nodes;
|
return nodes;
|
||||||
|
|||||||
@@ -105,28 +105,32 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueDiscNumbers = new Set(detailQuery.data?.songs.map((s) => s.discNumber));
|
let discNumber = -1;
|
||||||
|
let discSubtitle: string | null = null;
|
||||||
|
|
||||||
const rowData: (QueueSong | { id: string; name: string })[] = [];
|
const rowData: (QueueSong | { id: string; name: string })[] = [];
|
||||||
|
const discTranslated = t('common.disc', { postProcess: 'upperCase' });
|
||||||
|
|
||||||
for (const discNumber of uniqueDiscNumbers.values()) {
|
for (const song of detailQuery.data.songs) {
|
||||||
const songsByDiscNumber = detailQuery.data?.songs.filter(
|
if (song.discNumber !== discNumber || song.discSubtitle !== discSubtitle) {
|
||||||
(s) => s.discNumber === discNumber,
|
discNumber = song.discNumber;
|
||||||
);
|
discSubtitle = song.discSubtitle;
|
||||||
|
|
||||||
const discSubtitle = songsByDiscNumber?.[0]?.discSubtitle;
|
let id = `disc-${discNumber}`;
|
||||||
const discName = [`Disc ${discNumber}`.toLocaleUpperCase(), discSubtitle]
|
let name = `${discTranslated} ${discNumber}`;
|
||||||
.filter(Boolean)
|
|
||||||
.join(': ');
|
|
||||||
|
|
||||||
rowData.push({
|
if (discSubtitle) {
|
||||||
id: `disc-${discNumber}`,
|
id += `-${discSubtitle}`;
|
||||||
name: discName,
|
name += `: ${discSubtitle}`;
|
||||||
});
|
}
|
||||||
rowData.push(...songsByDiscNumber);
|
|
||||||
|
rowData.push({ id, name });
|
||||||
|
}
|
||||||
|
rowData.push(song);
|
||||||
}
|
}
|
||||||
|
|
||||||
return rowData;
|
return rowData;
|
||||||
}, [detailQuery.data?.songs]);
|
}, [detailQuery.data?.songs, t]);
|
||||||
|
|
||||||
const [pagination, setPagination] = useSetState({
|
const [pagination, setPagination] = useSetState({
|
||||||
artist: 0,
|
artist: 0,
|
||||||
@@ -452,6 +456,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
|||||||
key={`table-${tableConfig.rowHeight}`}
|
key={`table-${tableConfig.rowHeight}`}
|
||||||
ref={tableRef}
|
ref={tableRef}
|
||||||
autoHeight
|
autoHeight
|
||||||
|
shouldUpdateSong
|
||||||
stickyHeader
|
stickyHeader
|
||||||
suppressCellFocus
|
suppressCellFocus
|
||||||
suppressLoadingOverlay
|
suppressLoadingOverlay
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Group, Stack } from '@mantine/core';
|
|
||||||
import { forwardRef, Fragment, Ref } from 'react';
|
import { forwardRef, Fragment, Ref } from 'react';
|
||||||
|
import { Group, Stack } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { generatePath, useParams } from 'react-router';
|
import { generatePath, useParams } from 'react-router';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||||
@@ -9,10 +10,13 @@ import { LibraryHeader, useSetRating } from '/@/renderer/features/shared';
|
|||||||
import { useContainerQuery } from '/@/renderer/hooks';
|
import { useContainerQuery } from '/@/renderer/hooks';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
import { formatDurationString } from '/@/renderer/utils';
|
import { formatDateAbsolute, formatDurationString } from '/@/renderer/utils';
|
||||||
|
|
||||||
interface AlbumDetailHeaderProps {
|
interface AlbumDetailHeaderProps {
|
||||||
background: string;
|
background: {
|
||||||
|
background: string;
|
||||||
|
blur: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AlbumDetailHeader = forwardRef(
|
export const AlbumDetailHeader = forwardRef(
|
||||||
@@ -21,26 +25,50 @@ export const AlbumDetailHeader = forwardRef(
|
|||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
|
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
|
||||||
const cq = useContainerQuery();
|
const cq = useContainerQuery();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
|
||||||
|
|
||||||
|
const originalDifferentFromRelease =
|
||||||
|
detailQuery.data?.originalDate &&
|
||||||
|
detailQuery.data.originalDate !== detailQuery.data.releaseDate;
|
||||||
|
|
||||||
|
const releasePrefix = originalDifferentFromRelease
|
||||||
|
? t('page.albumDetail.released', { postProcess: 'sentenceCase' })
|
||||||
|
: '♫';
|
||||||
|
|
||||||
const metadataItems = [
|
const metadataItems = [
|
||||||
{
|
{
|
||||||
id: 'releaseYear',
|
id: 'releaseDate',
|
||||||
secondary: false,
|
value:
|
||||||
value: detailQuery?.data?.releaseYear,
|
detailQuery?.data?.releaseDate &&
|
||||||
|
`${releasePrefix} ${formatDateAbsolute(detailQuery?.data?.releaseDate)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'songCount',
|
id: 'songCount',
|
||||||
secondary: false,
|
|
||||||
value: `${detailQuery?.data?.songCount} songs`,
|
value: `${detailQuery?.data?.songCount} songs`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'duration',
|
id: 'duration',
|
||||||
secondary: false,
|
|
||||||
value:
|
value:
|
||||||
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'playCount',
|
||||||
|
value: t('entity.play', {
|
||||||
|
count: detailQuery?.data?.playCount as number,
|
||||||
|
}),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (originalDifferentFromRelease) {
|
||||||
|
const formatted = `♫ ${formatDateAbsolute(detailQuery!.data!.originalDate)}`;
|
||||||
|
metadataItems.splice(0, 0, {
|
||||||
|
id: 'originalDate',
|
||||||
|
value: formatted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const updateRatingMutation = useSetRating({});
|
const updateRatingMutation = useSetRating({});
|
||||||
|
|
||||||
const handleUpdateRating = (rating: number) => {
|
const handleUpdateRating = (rating: number) => {
|
||||||
@@ -55,23 +83,21 @@ export const AlbumDetailHeader = forwardRef(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack ref={cq.ref}>
|
<Stack ref={cq.ref}>
|
||||||
<LibraryHeader
|
<LibraryHeader
|
||||||
ref={ref}
|
ref={ref}
|
||||||
background={background}
|
|
||||||
imageUrl={detailQuery?.data?.imageUrl}
|
imageUrl={detailQuery?.data?.imageUrl}
|
||||||
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
|
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
|
||||||
title={detailQuery?.data?.name || ''}
|
title={detailQuery?.data?.name || ''}
|
||||||
|
{...background}
|
||||||
>
|
>
|
||||||
<Stack spacing="sm">
|
<Stack spacing="sm">
|
||||||
<Group spacing="sm">
|
<Group spacing="sm">
|
||||||
{metadataItems.map((item, index) => (
|
{metadataItems.map((item, index) => (
|
||||||
<Fragment key={`item-${item.id}-${index}`}>
|
<Fragment key={`item-${item.id}-${index}`}>
|
||||||
{index > 0 && <Text $noSelect>•</Text>}
|
{index > 0 && <Text $noSelect>•</Text>}
|
||||||
<Text $secondary={item.secondary}>{item.value}</Text>
|
<Text>{item.value}</Text>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
{showRating && (
|
{showRating && (
|
||||||
@@ -103,7 +129,6 @@ export const AlbumDetailHeader = forwardRef(
|
|||||||
$link
|
$link
|
||||||
component={Link}
|
component={Link}
|
||||||
fw={600}
|
fw={600}
|
||||||
size="md"
|
|
||||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||||
albumArtistId: artist.id,
|
albumArtistId: artist.id,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -176,12 +176,18 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
|||||||
const onFilterChange = useCallback(
|
const onFilterChange = useCallback(
|
||||||
(filter: AlbumListFilter) => {
|
(filter: AlbumListFilter) => {
|
||||||
if (isGrid) {
|
if (isGrid) {
|
||||||
handleRefreshGrid(gridRef, filter);
|
handleRefreshGrid(gridRef, {
|
||||||
|
...filter,
|
||||||
|
...customFilters,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
handleRefreshTable(tableRef, {
|
||||||
|
...filter,
|
||||||
|
...customFilters,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRefreshTable(tableRef, filter);
|
|
||||||
},
|
},
|
||||||
[gridRef, handleRefreshGrid, handleRefreshTable, isGrid, tableRef],
|
[customFilters, gridRef, handleRefreshGrid, handleRefreshTable, isGrid, tableRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenFiltersModal = () => {
|
const handleOpenFiltersModal = () => {
|
||||||
|
|||||||
@@ -10,17 +10,18 @@ import { AlbumDetailHeader } from '/@/renderer/features/albums/components/album-
|
|||||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||||
import { LibraryItem } from '/@/renderer/api/types';
|
import { LibraryItem } from '/@/renderer/api/types';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer, useGeneralSettings } from '/@/renderer/store';
|
||||||
|
|
||||||
const AlbumDetailRoute = () => {
|
const AlbumDetailRoute = () => {
|
||||||
const tableRef = useRef<AgGridReactType | null>(null);
|
const tableRef = useRef<AgGridReactType | null>(null);
|
||||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
const headerRef = useRef<HTMLDivElement>(null);
|
const headerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { albumBackground, albumBackgroundBlur } = useGeneralSettings();
|
||||||
|
|
||||||
const { albumId } = useParams() as { albumId: string };
|
const { albumId } = useParams() as { albumId: string };
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
|
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
|
||||||
const { color: background, colorId } = useFastAverageColor({
|
const { color: backgroundColor, colorId } = useFastAverageColor({
|
||||||
id: albumId,
|
id: albumId,
|
||||||
src: detailQuery.data?.imageUrl,
|
src: detailQuery.data?.imageUrl,
|
||||||
srcLoaded: !detailQuery.isLoading,
|
srcLoaded: !detailQuery.isLoading,
|
||||||
@@ -38,16 +39,19 @@ const AlbumDetailRoute = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!background || colorId !== albumId) {
|
if (!backgroundColor || colorId !== albumId) {
|
||||||
return <Spinner container />;
|
return <Spinner container />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const backgroundUrl = detailQuery.data?.imageUrl || '';
|
||||||
|
const background = (albumBackground && `url(${backgroundUrl})`) || backgroundColor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedPage key={`album-detail-${albumId}`}>
|
<AnimatedPage key={`album-detail-${albumId}`}>
|
||||||
<NativeScrollArea
|
<NativeScrollArea
|
||||||
ref={scrollAreaRef}
|
ref={scrollAreaRef}
|
||||||
pageHeaderProps={{
|
pageHeaderProps={{
|
||||||
backgroundColor: background,
|
backgroundColor: backgroundColor || undefined,
|
||||||
children: (
|
children: (
|
||||||
<LibraryHeaderBar>
|
<LibraryHeaderBar>
|
||||||
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
|
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
|
||||||
@@ -62,7 +66,10 @@ const AlbumDetailRoute = () => {
|
|||||||
>
|
>
|
||||||
<AlbumDetailHeader
|
<AlbumDetailHeader
|
||||||
ref={headerRef}
|
ref={headerRef}
|
||||||
background={background}
|
background={{
|
||||||
|
background,
|
||||||
|
blur: (albumBackground && albumBackgroundBlur) || 0,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<AlbumDetailContent
|
<AlbumDetailContent
|
||||||
background={background}
|
background={background}
|
||||||
|
|||||||
@@ -143,8 +143,8 @@ const AlbumListRoute = () => {
|
|||||||
const title = artist
|
const title = artist
|
||||||
? t('page.albumList.artistAlbums', { artist })
|
? t('page.albumList.artistAlbums', { artist })
|
||||||
: genreId
|
: genreId
|
||||||
? t('page.albumList.genreAlbums', { genre: titleCase(genreTitle) })
|
? t('page.albumList.genreAlbums', { genre: titleCase(genreTitle) })
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedPage>
|
<AnimatedPage>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||||
import { Box, Group, Stack } from '@mantine/core';
|
import { Box, Grid, Group, Stack } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { FaLastfmSquare } from 'react-icons/fa';
|
import { FaLastfmSquare } from 'react-icons/fa';
|
||||||
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||||
@@ -36,7 +36,7 @@ import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/fe
|
|||||||
import { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';
|
import { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';
|
||||||
import { useContainerQuery } from '/@/renderer/hooks';
|
import { useContainerQuery } from '/@/renderer/hooks';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { ArtistItem, useCurrentServer } from '/@/renderer/store';
|
||||||
import { useGeneralSettings, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
import { useGeneralSettings, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||||
import { CardRow, Play, TableColumn } from '/@/renderer/types';
|
import { CardRow, Play, TableColumn } from '/@/renderer/types';
|
||||||
import { sanitize } from '/@/renderer/utils/sanitize';
|
import { sanitize } from '/@/renderer/utils/sanitize';
|
||||||
@@ -65,13 +65,25 @@ interface AlbumArtistDetailContentProps {
|
|||||||
|
|
||||||
export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailContentProps) => {
|
export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailContentProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { externalLinks } = useGeneralSettings();
|
const { artistItems, externalLinks } = useGeneralSettings();
|
||||||
const { albumArtistId } = useParams() as { albumArtistId: string };
|
const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||||
const cq = useContainerQuery();
|
const cq = useContainerQuery();
|
||||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
const genrePath = useGenreRoute();
|
const genrePath = useGenreRoute();
|
||||||
|
|
||||||
|
const [enabledItem, itemOrder] = useMemo(() => {
|
||||||
|
const enabled: { [key in ArtistItem]?: boolean } = {};
|
||||||
|
const order: { [key in ArtistItem]?: number } = {};
|
||||||
|
|
||||||
|
for (const [idx, item] of artistItems.entries()) {
|
||||||
|
enabled[item.id] = !item.disabled;
|
||||||
|
order[item.id] = idx + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [enabled, order];
|
||||||
|
}, [artistItems]);
|
||||||
|
|
||||||
const detailQuery = useAlbumArtistDetail({
|
const detailQuery = useAlbumArtistDetail({
|
||||||
query: { id: albumArtistId },
|
query: { id: albumArtistId },
|
||||||
serverId: server?.id,
|
serverId: server?.id,
|
||||||
@@ -95,11 +107,14 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||||||
})}`;
|
})}`;
|
||||||
|
|
||||||
const recentAlbumsQuery = useAlbumList({
|
const recentAlbumsQuery = useAlbumList({
|
||||||
|
options: {
|
||||||
|
enabled: enabledItem.recentAlbums,
|
||||||
|
},
|
||||||
query: {
|
query: {
|
||||||
_custom: {
|
_custom: {
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
...(server?.type === ServerType.JELLYFIN
|
...(server?.type === ServerType.JELLYFIN
|
||||||
? { ArtistIds: albumArtistId }
|
? { AlbumArtistIds: albumArtistId }
|
||||||
: undefined),
|
: undefined),
|
||||||
},
|
},
|
||||||
navidrome: {
|
navidrome: {
|
||||||
@@ -117,6 +132,9 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||||||
});
|
});
|
||||||
|
|
||||||
const compilationAlbumsQuery = useAlbumList({
|
const compilationAlbumsQuery = useAlbumList({
|
||||||
|
options: {
|
||||||
|
enabled: enabledItem.compilations,
|
||||||
|
},
|
||||||
query: {
|
query: {
|
||||||
_custom: {
|
_custom: {
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
@@ -140,7 +158,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||||||
|
|
||||||
const topSongsQuery = useTopSongsList({
|
const topSongsQuery = useTopSongsList({
|
||||||
options: {
|
options: {
|
||||||
enabled: !!detailQuery?.data?.name,
|
enabled: !!detailQuery?.data?.name && enabledItem.topSongs,
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
artist: detailQuery?.data?.name || '',
|
artist: detailQuery?.data?.name || '',
|
||||||
@@ -207,9 +225,10 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
data: recentAlbumsQuery?.data?.items,
|
data: recentAlbumsQuery?.data?.items,
|
||||||
isHidden: !recentAlbumsQuery?.data?.items?.length,
|
isHidden: !recentAlbumsQuery?.data?.items?.length || !enabledItem.recentAlbums,
|
||||||
itemType: LibraryItem.ALBUM,
|
itemType: LibraryItem.ALBUM,
|
||||||
loading: recentAlbumsQuery?.isLoading || recentAlbumsQuery.isFetching,
|
loading: recentAlbumsQuery?.isLoading || recentAlbumsQuery.isFetching,
|
||||||
|
order: itemOrder.recentAlbums,
|
||||||
title: (
|
title: (
|
||||||
<Group align="flex-end">
|
<Group align="flex-end">
|
||||||
<TextTitle
|
<TextTitle
|
||||||
@@ -235,9 +254,10 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
data: compilationAlbumsQuery?.data?.items,
|
data: compilationAlbumsQuery?.data?.items,
|
||||||
isHidden: !compilationAlbumsQuery?.data?.items?.length,
|
isHidden: !compilationAlbumsQuery?.data?.items?.length || !enabledItem.compilations,
|
||||||
itemType: LibraryItem.ALBUM,
|
itemType: LibraryItem.ALBUM,
|
||||||
loading: compilationAlbumsQuery?.isLoading || compilationAlbumsQuery.isFetching,
|
loading: compilationAlbumsQuery?.isLoading || compilationAlbumsQuery.isFetching,
|
||||||
|
order: itemOrder.compilations,
|
||||||
title: (
|
title: (
|
||||||
<TextTitle
|
<TextTitle
|
||||||
order={2}
|
order={2}
|
||||||
@@ -250,8 +270,9 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
data: detailQuery?.data?.similarArtists || [],
|
data: detailQuery?.data?.similarArtists || [],
|
||||||
isHidden: !detailQuery?.data?.similarArtists,
|
isHidden: !detailQuery?.data?.similarArtists || !enabledItem.similarArtists,
|
||||||
itemType: LibraryItem.ALBUM_ARTIST,
|
itemType: LibraryItem.ALBUM_ARTIST,
|
||||||
|
order: itemOrder.similarArtists,
|
||||||
title: (
|
title: (
|
||||||
<TextTitle
|
<TextTitle
|
||||||
order={2}
|
order={2}
|
||||||
@@ -271,6 +292,12 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||||||
compilationAlbumsQuery.isFetching,
|
compilationAlbumsQuery.isFetching,
|
||||||
compilationAlbumsQuery?.isLoading,
|
compilationAlbumsQuery?.isLoading,
|
||||||
detailQuery?.data?.similarArtists,
|
detailQuery?.data?.similarArtists,
|
||||||
|
enabledItem.compilations,
|
||||||
|
enabledItem.recentAlbums,
|
||||||
|
enabledItem.similarArtists,
|
||||||
|
itemOrder.compilations,
|
||||||
|
itemOrder.recentAlbums,
|
||||||
|
itemOrder.similarArtists,
|
||||||
recentAlbumsQuery?.data?.items,
|
recentAlbumsQuery?.data?.items,
|
||||||
recentAlbumsQuery.isFetching,
|
recentAlbumsQuery.isFetching,
|
||||||
recentAlbumsQuery?.isLoading,
|
recentAlbumsQuery?.isLoading,
|
||||||
@@ -336,17 +363,17 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||||||
const biography = useMemo(() => {
|
const biography = useMemo(() => {
|
||||||
const bio = detailQuery?.data?.biography;
|
const bio = detailQuery?.data?.biography;
|
||||||
|
|
||||||
if (!bio) return null;
|
if (!bio || !enabledItem.biography) return null;
|
||||||
return sanitize(bio);
|
return sanitize(bio);
|
||||||
}, [detailQuery?.data?.biography]);
|
}, [detailQuery?.data?.biography, enabledItem.biography]);
|
||||||
|
|
||||||
const showTopSongs = topSongsQuery?.data?.items?.length;
|
const showTopSongs = topSongsQuery?.data?.items?.length && enabledItem.topSongs;
|
||||||
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
|
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
|
||||||
const mbzId = detailQuery?.data?.mbz;
|
const mbzId = detailQuery?.data?.mbz;
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
detailQuery?.isLoading ||
|
detailQuery?.isLoading ||
|
||||||
(server?.type === ServerType.NAVIDROME && topSongsQuery?.isLoading);
|
(server?.type === ServerType.NAVIDROME && enabledItem.topSongs && topSongsQuery?.isLoading);
|
||||||
|
|
||||||
if (isLoading) return <ContentContainer ref={cq.ref} />;
|
if (isLoading) return <ContentContainer ref={cq.ref} />;
|
||||||
|
|
||||||
@@ -467,103 +494,128 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
{biography ? (
|
<Grid>
|
||||||
<Box
|
{biography ? (
|
||||||
component="section"
|
<Grid.Col
|
||||||
maw="1280px"
|
order={itemOrder.biography}
|
||||||
>
|
span={12}
|
||||||
<TextTitle
|
|
||||||
order={2}
|
|
||||||
weight={700}
|
|
||||||
>
|
>
|
||||||
{t('page.albumArtistDetail.about', {
|
<Box
|
||||||
artist: detailQuery?.data?.name,
|
component="section"
|
||||||
})}
|
maw="1280px"
|
||||||
</TextTitle>
|
|
||||||
<Spoiler dangerouslySetInnerHTML={{ __html: biography }} />
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
{showTopSongs ? (
|
|
||||||
<Box component="section">
|
|
||||||
<Group
|
|
||||||
noWrap
|
|
||||||
position="apart"
|
|
||||||
>
|
|
||||||
<Group
|
|
||||||
noWrap
|
|
||||||
align="flex-end"
|
|
||||||
>
|
>
|
||||||
<TextTitle
|
<TextTitle
|
||||||
order={2}
|
order={2}
|
||||||
weight={700}
|
weight={700}
|
||||||
>
|
>
|
||||||
{t('page.albumArtistDetail.topSongs', {
|
{t('page.albumArtistDetail.about', {
|
||||||
postProcess: 'sentenceCase',
|
artist: detailQuery?.data?.name,
|
||||||
})}
|
})}
|
||||||
</TextTitle>
|
</TextTitle>
|
||||||
<Button
|
<Spoiler dangerouslySetInnerHTML={{ __html: biography }} />
|
||||||
compact
|
</Box>
|
||||||
uppercase
|
</Grid.Col>
|
||||||
component={Link}
|
) : null}
|
||||||
to={generatePath(
|
{showTopSongs ? (
|
||||||
AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS,
|
<Grid.Col
|
||||||
{
|
order={itemOrder.topSongs}
|
||||||
albumArtistId,
|
span={12}
|
||||||
},
|
>
|
||||||
)}
|
<Box component="section">
|
||||||
variant="subtle"
|
<Group
|
||||||
|
noWrap
|
||||||
|
position="apart"
|
||||||
>
|
>
|
||||||
{t('page.albumArtistDetail.viewAll', {
|
<Group
|
||||||
postProcess: 'sentenceCase',
|
noWrap
|
||||||
})}
|
align="flex-end"
|
||||||
</Button>
|
>
|
||||||
</Group>
|
<TextTitle
|
||||||
</Group>
|
order={2}
|
||||||
<VirtualTable
|
weight={700}
|
||||||
autoFitColumns
|
>
|
||||||
autoHeight
|
{t('page.albumArtistDetail.topSongs', {
|
||||||
deselectOnClickOutside
|
postProcess: 'sentenceCase',
|
||||||
stickyHeader
|
})}
|
||||||
suppressCellFocus
|
</TextTitle>
|
||||||
suppressHorizontalScroll
|
<Button
|
||||||
suppressLoadingOverlay
|
compact
|
||||||
suppressRowDrag
|
uppercase
|
||||||
columnDefs={topSongsColumnDefs}
|
component={Link}
|
||||||
enableCellChangeFlash={false}
|
to={generatePath(
|
||||||
getRowId={(data) => data.data.uniqueId}
|
AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS,
|
||||||
rowData={topSongs}
|
{
|
||||||
rowHeight={60}
|
albumArtistId,
|
||||||
rowSelection="multiple"
|
},
|
||||||
onCellContextMenu={handleContextMenu}
|
)}
|
||||||
onRowDoubleClicked={handleRowDoubleClick}
|
variant="subtle"
|
||||||
/>
|
>
|
||||||
</Box>
|
{t('page.albumArtistDetail.viewAll', {
|
||||||
) : null}
|
postProcess: 'sentenceCase',
|
||||||
<Box component="section">
|
})}
|
||||||
<Stack spacing="xl">
|
</Button>
|
||||||
{carousels
|
</Group>
|
||||||
.filter((c) => !c.isHidden)
|
</Group>
|
||||||
.map((carousel) => (
|
<VirtualTable
|
||||||
<MemoizedSwiperGridCarousel
|
autoFitColumns
|
||||||
key={`carousel-${carousel.uniqueId}`}
|
autoHeight
|
||||||
cardRows={cardRows[carousel.itemType as keyof typeof cardRows]}
|
deselectOnClickOutside
|
||||||
data={carousel.data}
|
shouldUpdateSong
|
||||||
isLoading={carousel.loading}
|
stickyHeader
|
||||||
itemType={carousel.itemType}
|
suppressCellFocus
|
||||||
route={cardRoutes[carousel.itemType as keyof typeof cardRoutes]}
|
suppressHorizontalScroll
|
||||||
swiperProps={{
|
suppressLoadingOverlay
|
||||||
grid: {
|
suppressRowDrag
|
||||||
rows: 2,
|
columnDefs={topSongsColumnDefs}
|
||||||
},
|
enableCellChangeFlash={false}
|
||||||
}}
|
getRowId={(data) => data.data.id}
|
||||||
title={{
|
rowData={topSongs}
|
||||||
label: carousel.title,
|
rowHeight={60}
|
||||||
}}
|
rowSelection="multiple"
|
||||||
uniqueId={carousel.uniqueId}
|
onCellContextMenu={handleContextMenu}
|
||||||
|
onRowDoubleClicked={handleRowDoubleClick}
|
||||||
/>
|
/>
|
||||||
))}
|
</Box>
|
||||||
</Stack>
|
</Grid.Col>
|
||||||
</Box>
|
) : null}
|
||||||
|
|
||||||
|
{carousels
|
||||||
|
.filter((c) => !c.isHidden)
|
||||||
|
.map((carousel) => (
|
||||||
|
<Grid.Col
|
||||||
|
key={`carousel-${carousel.uniqueId}`}
|
||||||
|
order={carousel.order}
|
||||||
|
span={12}
|
||||||
|
>
|
||||||
|
<Box component="section">
|
||||||
|
<Stack spacing="xl">
|
||||||
|
<MemoizedSwiperGridCarousel
|
||||||
|
cardRows={
|
||||||
|
cardRows[carousel.itemType as keyof typeof cardRows]
|
||||||
|
}
|
||||||
|
data={carousel.data}
|
||||||
|
isLoading={carousel.loading}
|
||||||
|
itemType={carousel.itemType}
|
||||||
|
route={
|
||||||
|
cardRoutes[
|
||||||
|
carousel.itemType as keyof typeof cardRoutes
|
||||||
|
]
|
||||||
|
}
|
||||||
|
swiperProps={{
|
||||||
|
grid: {
|
||||||
|
rows: 2,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
title={{
|
||||||
|
label: carousel.title,
|
||||||
|
}}
|
||||||
|
uniqueId={carousel.uniqueId}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
</DetailContainer>
|
</DetailContainer>
|
||||||
</ContentContainer>
|
</ContentContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { forwardRef, Fragment, Ref } from 'react';
|
import { forwardRef, Fragment, Ref } from 'react';
|
||||||
import { Group, Rating, Stack } from '@mantine/core';
|
import { Group, Rating, Stack } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||||
import { Text } from '/@/renderer/components';
|
import { Text } from '/@/renderer/components';
|
||||||
@@ -17,6 +18,7 @@ export const AlbumArtistDetailHeader = forwardRef(
|
|||||||
({ background }: AlbumArtistDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
|
({ background }: AlbumArtistDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
|
||||||
const { albumArtistId } = useParams() as { albumArtistId: string };
|
const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
|
const { t } = useTranslation();
|
||||||
const detailQuery = useAlbumArtistDetail({
|
const detailQuery = useAlbumArtistDetail({
|
||||||
query: { id: albumArtistId },
|
query: { id: albumArtistId },
|
||||||
serverId: server?.id,
|
serverId: server?.id,
|
||||||
@@ -26,12 +28,12 @@ export const AlbumArtistDetailHeader = forwardRef(
|
|||||||
{
|
{
|
||||||
id: 'albumCount',
|
id: 'albumCount',
|
||||||
secondary: false,
|
secondary: false,
|
||||||
value: detailQuery?.data?.albumCount && `${detailQuery?.data?.albumCount} albums`,
|
value: t('entity.albumWithCount', { count: detailQuery?.data?.albumCount || 0 }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'songCount',
|
id: 'songCount',
|
||||||
secondary: false,
|
secondary: false,
|
||||||
value: detailQuery?.data?.songCount && `${detailQuery?.data?.songCount} songs`,
|
value: t('entity.trackWithCount', { count: detailQuery?.data?.songCount || 0 }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'duration',
|
id: 'duration',
|
||||||
|
|||||||
+2
-1
@@ -64,8 +64,9 @@ export const AlbumArtistDetailTopSongsListContent = ({
|
|||||||
<VirtualTable
|
<VirtualTable
|
||||||
key={`table-${tableProps.rowHeight}-${server?.id}`}
|
key={`table-${tableProps.rowHeight}-${server?.id}`}
|
||||||
ref={tableRef}
|
ref={tableRef}
|
||||||
|
shouldUpdateSong
|
||||||
{...tableProps}
|
{...tableProps}
|
||||||
getRowId={(data) => data.data.uniqueId}
|
getRowId={(data) => data.data.id}
|
||||||
rowClassRules={rowClassRules}
|
rowClassRules={rowClassRules}
|
||||||
rowData={data}
|
rowData={data}
|
||||||
rowModelType="clientSide"
|
rowModelType="clientSide"
|
||||||
|
|||||||
@@ -8,18 +8,22 @@ export const QUEUE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
|||||||
{ id: 'addToFavorites' },
|
{ id: 'addToFavorites' },
|
||||||
{ divider: true, id: 'removeFromFavorites' },
|
{ divider: true, id: 'removeFromFavorites' },
|
||||||
{ children: true, disabled: false, id: 'setRating' },
|
{ children: true, disabled: false, id: 'setRating' },
|
||||||
{ disabled: false, id: 'deselectAll' },
|
{ disabled: false, divider: true, id: 'deselectAll' },
|
||||||
|
{ id: 'download' },
|
||||||
|
{ divider: true, id: 'shareItem' },
|
||||||
{ divider: true, id: 'showDetails' },
|
{ divider: true, id: 'showDetails' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||||
{ id: 'play' },
|
{ id: 'play' },
|
||||||
{ id: 'playLast' },
|
{ id: 'playLast' },
|
||||||
{ divider: true, id: 'playNext' },
|
{ id: 'playNext' },
|
||||||
|
{ divider: true, id: 'playSimilarSongs' },
|
||||||
{ divider: true, id: 'addToPlaylist' },
|
{ divider: true, id: 'addToPlaylist' },
|
||||||
{ id: 'addToFavorites' },
|
{ id: 'addToFavorites' },
|
||||||
{ divider: true, id: 'removeFromFavorites' },
|
{ divider: true, id: 'removeFromFavorites' },
|
||||||
{ children: true, disabled: false, divider: true, id: 'setRating' },
|
{ children: true, disabled: false, divider: true, id: 'setRating' },
|
||||||
|
{ id: 'download' },
|
||||||
{ divider: true, id: 'shareItem' },
|
{ divider: true, id: 'shareItem' },
|
||||||
{ divider: true, id: 'showDetails' },
|
{ divider: true, id: 'showDetails' },
|
||||||
];
|
];
|
||||||
@@ -34,23 +38,29 @@ export const SONG_ALBUM_PAGE: SetContextMenuItems = [
|
|||||||
export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||||
{ id: 'play' },
|
{ id: 'play' },
|
||||||
{ id: 'playLast' },
|
{ id: 'playLast' },
|
||||||
{ divider: true, id: 'playNext' },
|
{ id: 'playNext' },
|
||||||
|
{ divider: true, id: 'playSimilarSongs' },
|
||||||
{ id: 'addToPlaylist' },
|
{ id: 'addToPlaylist' },
|
||||||
{ divider: true, id: 'removeFromPlaylist' },
|
{ divider: true, id: 'removeFromPlaylist' },
|
||||||
{ id: 'addToFavorites' },
|
{ id: 'addToFavorites' },
|
||||||
{ divider: true, id: 'removeFromFavorites' },
|
{ divider: true, id: 'removeFromFavorites' },
|
||||||
{ children: true, disabled: false, id: 'setRating' },
|
{ children: true, disabled: false, id: 'setRating' },
|
||||||
|
{ id: 'download' },
|
||||||
|
{ divider: true, id: 'shareItem' },
|
||||||
{ divider: true, id: 'showDetails' },
|
{ divider: true, id: 'showDetails' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||||
{ id: 'play' },
|
{ id: 'play' },
|
||||||
{ id: 'playLast' },
|
{ id: 'playLast' },
|
||||||
{ divider: true, id: 'playNext' },
|
{ id: 'playNext' },
|
||||||
|
{ divider: true, id: 'playSimilarSongs' },
|
||||||
{ divider: true, id: 'addToPlaylist' },
|
{ divider: true, id: 'addToPlaylist' },
|
||||||
{ id: 'addToFavorites' },
|
{ id: 'addToFavorites' },
|
||||||
{ divider: true, id: 'removeFromFavorites' },
|
{ divider: true, id: 'removeFromFavorites' },
|
||||||
{ children: true, disabled: false, id: 'setRating' },
|
{ children: true, disabled: false, id: 'setRating' },
|
||||||
|
{ id: 'download' },
|
||||||
|
{ divider: true, id: 'shareItem' },
|
||||||
{ divider: true, id: 'showDetails' },
|
{ divider: true, id: 'showDetails' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -81,6 +91,7 @@ export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
|||||||
{ id: 'addToFavorites' },
|
{ id: 'addToFavorites' },
|
||||||
{ divider: true, id: 'removeFromFavorites' },
|
{ divider: true, id: 'removeFromFavorites' },
|
||||||
{ children: true, disabled: false, id: 'setRating' },
|
{ children: true, disabled: false, id: 'setRating' },
|
||||||
|
{ divider: true, id: 'shareItem' },
|
||||||
{ divider: true, id: 'showDetails' },
|
{ divider: true, id: 'showDetails' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -88,5 +99,6 @@ export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
|||||||
{ id: 'play' },
|
{ id: 'play' },
|
||||||
{ id: 'playLast' },
|
{ id: 'playLast' },
|
||||||
{ divider: true, id: 'playNext' },
|
{ divider: true, id: 'playNext' },
|
||||||
|
{ divider: true, id: 'shareItem' },
|
||||||
{ id: 'deletePlaylist' },
|
{ id: 'deletePlaylist' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import {
|
|||||||
RiCloseCircleLine,
|
RiCloseCircleLine,
|
||||||
RiShareForwardFill,
|
RiShareForwardFill,
|
||||||
RiInformationFill,
|
RiInformationFill,
|
||||||
|
RiRadio2Fill,
|
||||||
|
RiDownload2Line,
|
||||||
} from 'react-icons/ri';
|
} from 'react-icons/ri';
|
||||||
import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types';
|
import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types';
|
||||||
import {
|
import {
|
||||||
@@ -50,14 +52,20 @@ import { useDeletePlaylist } from '/@/renderer/features/playlists';
|
|||||||
import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation';
|
import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation';
|
||||||
import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared';
|
import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared';
|
||||||
import {
|
import {
|
||||||
|
getServerById,
|
||||||
useAuthStore,
|
useAuthStore,
|
||||||
useCurrentServer,
|
useCurrentServer,
|
||||||
usePlayerStore,
|
usePlayerStore,
|
||||||
useQueueControls,
|
useQueueControls,
|
||||||
|
useSettingsStore,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
import { usePlaybackType } from '/@/renderer/store/settings.store';
|
import { usePlaybackType } from '/@/renderer/store/settings.store';
|
||||||
import { Play, PlaybackType } from '/@/renderer/types';
|
import { Play, PlaybackType } from '/@/renderer/types';
|
||||||
import { ItemDetailsModal } from '/@/renderer/features/item-details/components/item-details-modal';
|
import { ItemDetailsModal } from '/@/renderer/features/item-details/components/item-details-modal';
|
||||||
|
import { updateSong } from '/@/renderer/features/player/update-remote-song';
|
||||||
|
import { controller } from '/@/renderer/api/controller';
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
|
||||||
|
|
||||||
type ContextMenuContextProps = {
|
type ContextMenuContextProps = {
|
||||||
closeContextMenu: () => void;
|
closeContextMenu: () => void;
|
||||||
@@ -85,14 +93,14 @@ const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating', 'shareI
|
|||||||
// const NAVIDROME_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
|
// const NAVIDROME_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
|
||||||
// const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
|
// const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
|
||||||
|
|
||||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
const utils = isElectron() ? window.electron.utils : null;
|
||||||
const remote = isElectron() ? window.electron.remote : null;
|
|
||||||
|
|
||||||
export interface ContextMenuProviderProps {
|
export interface ContextMenuProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||||
|
const disabledItems = useSettingsStore((state) => state.general.disabledContextMenu);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const clickOutsideRef = useClickOutside(() => setOpened(false));
|
const clickOutsideRef = useClickOutside(() => setOpened(false));
|
||||||
@@ -129,7 +137,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
} = args;
|
} = args;
|
||||||
|
|
||||||
const serverType = data[0]?.serverType || useAuthStore.getState().currentServer?.type;
|
const serverType = data[0]?.serverType || useAuthStore.getState().currentServer?.type;
|
||||||
let validMenuItems = menuItems;
|
let validMenuItems = menuItems.filter((item) => !disabledItems[item.id]);
|
||||||
|
|
||||||
if (serverType === ServerType.JELLYFIN) {
|
if (serverType === ServerType.JELLYFIN) {
|
||||||
validMenuItems = menuItems.filter(
|
validMenuItems = menuItems.filter(
|
||||||
@@ -139,7 +147,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
|
|
||||||
// If the context menu dimension can't be automatically calculated, calculate it manually
|
// If the context menu dimension can't be automatically calculated, calculate it manually
|
||||||
// This is a hacky way since resize observer may not automatically recalculate when not rendered
|
// This is a hacky way since resize observer may not automatically recalculate when not rendered
|
||||||
const menuHeight = menuRect.height || (menuItems.length + 1) * 50;
|
const menuHeight = menuRect.height || (validMenuItems.length + 1) * 40;
|
||||||
const menuWidth = menuRect.width || 220;
|
const menuWidth = menuRect.width || 220;
|
||||||
|
|
||||||
const shouldReverseY = yPos + menuHeight > viewport.height;
|
const shouldReverseY = yPos + menuHeight > viewport.height;
|
||||||
@@ -161,7 +169,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
});
|
});
|
||||||
setOpened(true);
|
setOpened(true);
|
||||||
},
|
},
|
||||||
[menuRect.height, menuRect.width, setCtx, viewport.height, viewport.width],
|
[disabledItems, menuRect.height, menuRect.width, setCtx, viewport.height, viewport.width],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeContextMenu = useCallback(() => {
|
const closeContextMenu = useCallback(() => {
|
||||||
@@ -284,13 +292,16 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
if (ctx.dataNodes) {
|
if (ctx.dataNodes) {
|
||||||
const nodesToFavorite = ctx.dataNodes.filter((item) => !item.data.userFavorite);
|
const nodesToFavorite = ctx.dataNodes.filter((item) => !item.data.userFavorite);
|
||||||
|
|
||||||
const nodesByServerId = nodesToFavorite.reduce((acc, node) => {
|
const nodesByServerId = nodesToFavorite.reduce(
|
||||||
if (!acc[node.data.serverId]) {
|
(acc, node) => {
|
||||||
acc[node.data.serverId] = [];
|
if (!acc[node.data.serverId]) {
|
||||||
}
|
acc[node.data.serverId] = [];
|
||||||
acc[node.data.serverId].push(node);
|
}
|
||||||
return acc;
|
acc[node.data.serverId].push(node);
|
||||||
}, {} as Record<string, RowNode<any>[]>);
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, RowNode<any>[]>,
|
||||||
|
);
|
||||||
|
|
||||||
for (const serverId of Object.keys(nodesByServerId)) {
|
for (const serverId of Object.keys(nodesByServerId)) {
|
||||||
const nodes = nodesByServerId[serverId];
|
const nodes = nodesByServerId[serverId];
|
||||||
@@ -321,13 +332,16 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const itemsToFavorite = ctx.data.filter((item) => !item.userFavorite);
|
const itemsToFavorite = ctx.data.filter((item) => !item.userFavorite);
|
||||||
const itemsByServerId = (itemsToFavorite as any[]).reduce((acc, item) => {
|
const itemsByServerId = (itemsToFavorite as any[]).reduce(
|
||||||
if (!acc[item.serverId]) {
|
(acc, item) => {
|
||||||
acc[item.serverId] = [];
|
if (!acc[item.serverId]) {
|
||||||
}
|
acc[item.serverId] = [];
|
||||||
acc[item.serverId].push(item);
|
}
|
||||||
return acc;
|
acc[item.serverId].push(item);
|
||||||
}, {} as Record<string, AnyLibraryItems>);
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, AnyLibraryItems>,
|
||||||
|
);
|
||||||
|
|
||||||
for (const serverId of Object.keys(itemsByServerId)) {
|
for (const serverId of Object.keys(itemsByServerId)) {
|
||||||
const items = itemsByServerId[serverId];
|
const items = itemsByServerId[serverId];
|
||||||
@@ -358,13 +372,16 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
|
|
||||||
if (ctx.dataNodes) {
|
if (ctx.dataNodes) {
|
||||||
const nodesToUnfavorite = ctx.dataNodes.filter((item) => item.data.userFavorite);
|
const nodesToUnfavorite = ctx.dataNodes.filter((item) => item.data.userFavorite);
|
||||||
const nodesByServerId = nodesToUnfavorite.reduce((acc, node) => {
|
const nodesByServerId = nodesToUnfavorite.reduce(
|
||||||
if (!acc[node.data.serverId]) {
|
(acc, node) => {
|
||||||
acc[node.data.serverId] = [];
|
if (!acc[node.data.serverId]) {
|
||||||
}
|
acc[node.data.serverId] = [];
|
||||||
acc[node.data.serverId].push(node);
|
}
|
||||||
return acc;
|
acc[node.data.serverId].push(node);
|
||||||
}, {} as Record<string, RowNode<any>[]>);
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, RowNode<any>[]>,
|
||||||
|
);
|
||||||
|
|
||||||
for (const serverId of Object.keys(nodesByServerId)) {
|
for (const serverId of Object.keys(nodesByServerId)) {
|
||||||
const idsToUnfavorite = nodesByServerId[serverId].map((node) => node.data.id);
|
const idsToUnfavorite = nodesByServerId[serverId].map((node) => node.data.id);
|
||||||
@@ -387,13 +404,16 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const itemsToUnfavorite = ctx.data.filter((item) => item.userFavorite);
|
const itemsToUnfavorite = ctx.data.filter((item) => item.userFavorite);
|
||||||
const itemsByServerId = (itemsToUnfavorite as any[]).reduce((acc, item) => {
|
const itemsByServerId = (itemsToUnfavorite as any[]).reduce(
|
||||||
if (!acc[item.serverId]) {
|
(acc, item) => {
|
||||||
acc[item.serverId] = [];
|
if (!acc[item.serverId]) {
|
||||||
}
|
acc[item.serverId] = [];
|
||||||
acc[item.serverId].push(item);
|
}
|
||||||
return acc;
|
acc[item.serverId].push(item);
|
||||||
}, {} as Record<string, AnyLibraryItems>);
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, AnyLibraryItems>,
|
||||||
|
);
|
||||||
|
|
||||||
for (const serverId of Object.keys(itemsByServerId)) {
|
for (const serverId of Object.keys(itemsByServerId)) {
|
||||||
const idsToUnfavorite = itemsByServerId[serverId].map(
|
const idsToUnfavorite = itemsByServerId[serverId].map(
|
||||||
@@ -590,7 +610,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
const playerData = moveToBottomOfQueue(uniqueIds);
|
const playerData = moveToBottomOfQueue(uniqueIds);
|
||||||
|
|
||||||
if (playbackType === PlaybackType.LOCAL) {
|
if (playbackType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer!.setQueueNext(playerData);
|
setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
}, [ctx.dataNodes, moveToBottomOfQueue, playbackType]);
|
}, [ctx.dataNodes, moveToBottomOfQueue, playbackType]);
|
||||||
|
|
||||||
@@ -601,7 +621,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
const playerData = moveToTopOfQueue(uniqueIds);
|
const playerData = moveToTopOfQueue(uniqueIds);
|
||||||
|
|
||||||
if (playbackType === PlaybackType.LOCAL) {
|
if (playbackType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer!.setQueueNext(playerData);
|
setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
}, [ctx.dataNodes, moveToTopOfQueue, playbackType]);
|
}, [ctx.dataNodes, moveToTopOfQueue, playbackType]);
|
||||||
|
|
||||||
@@ -631,16 +651,16 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
|
|
||||||
if (playbackType === PlaybackType.LOCAL) {
|
if (playbackType === PlaybackType.LOCAL) {
|
||||||
if (isCurrentSongRemoved) {
|
if (isCurrentSongRemoved) {
|
||||||
mpvPlayer!.setQueue(playerData);
|
setQueue(playerData);
|
||||||
} else {
|
} else {
|
||||||
mpvPlayer!.setQueueNext(playerData);
|
setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.tableApi?.redrawRows();
|
ctx.tableApi?.redrawRows();
|
||||||
|
|
||||||
if (isCurrentSongRemoved) {
|
if (isCurrentSongRemoved) {
|
||||||
remote?.updateSong({ song: playerData.current.song });
|
updateSong(playerData.current.song);
|
||||||
}
|
}
|
||||||
}, [ctx.dataNodes, ctx.tableApi, playbackType, removeFromQueue]);
|
}, [ctx.dataNodes, ctx.tableApi, playbackType, removeFromQueue]);
|
||||||
|
|
||||||
@@ -658,6 +678,34 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
});
|
});
|
||||||
}, [ctx.data, t]);
|
}, [ctx.data, t]);
|
||||||
|
|
||||||
|
const handleSimilar = useCallback(async () => {
|
||||||
|
const item = ctx.data[0];
|
||||||
|
const songs = await controller.getSimilarSongs({
|
||||||
|
apiClientProps: {
|
||||||
|
server: getServerById(item.serverId),
|
||||||
|
signal: undefined,
|
||||||
|
},
|
||||||
|
query: { albumArtistIds: item.albumArtistIds, songId: item.id },
|
||||||
|
});
|
||||||
|
if (songs) {
|
||||||
|
handlePlayQueueAdd?.({ byData: [ctx.data[0], ...songs], playType: Play.NOW });
|
||||||
|
}
|
||||||
|
}, [ctx, handlePlayQueueAdd]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
const item = ctx.data[0];
|
||||||
|
const url = api.controller.getDownloadUrl({
|
||||||
|
apiClientProps: { server },
|
||||||
|
query: { id: item.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (utils) {
|
||||||
|
utils.download(url!);
|
||||||
|
} else {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
}, [ctx.data, server]);
|
||||||
|
|
||||||
const contextMenuItems: Record<ContextMenuItemType, ContextMenuItem> = useMemo(() => {
|
const contextMenuItems: Record<ContextMenuItemType, ContextMenuItem> = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
addToFavorites: {
|
addToFavorites: {
|
||||||
@@ -689,6 +737,13 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
leftIcon: <RiCloseCircleLine size="1.1rem" />,
|
leftIcon: <RiCloseCircleLine size="1.1rem" />,
|
||||||
onClick: handleDeselectAll,
|
onClick: handleDeselectAll,
|
||||||
},
|
},
|
||||||
|
download: {
|
||||||
|
disabled: ctx.data?.length !== 1,
|
||||||
|
id: 'download',
|
||||||
|
label: t('page.contextMenu.download', { postProcess: 'sentenceCase' }),
|
||||||
|
leftIcon: <RiDownload2Line size="1.1rem" />,
|
||||||
|
onClick: handleDownload,
|
||||||
|
},
|
||||||
moveToBottomOfQueue: {
|
moveToBottomOfQueue: {
|
||||||
id: 'moveToBottomOfQueue',
|
id: 'moveToBottomOfQueue',
|
||||||
label: t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' }),
|
label: t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' }),
|
||||||
@@ -719,6 +774,12 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
leftIcon: <RiAddCircleFill size="1.1rem" />,
|
leftIcon: <RiAddCircleFill size="1.1rem" />,
|
||||||
onClick: () => handlePlay(Play.NEXT),
|
onClick: () => handlePlay(Play.NEXT),
|
||||||
},
|
},
|
||||||
|
playSimilarSongs: {
|
||||||
|
id: 'playSimilarSongs',
|
||||||
|
label: t('page.contextMenu.playSimilarSongs', { postProcess: 'sentenceCase' }),
|
||||||
|
leftIcon: <RiRadio2Fill size="1.1rem" />,
|
||||||
|
onClick: handleSimilar,
|
||||||
|
},
|
||||||
removeFromFavorites: {
|
removeFromFavorites: {
|
||||||
id: 'removeFromFavorites',
|
id: 'removeFromFavorites',
|
||||||
label: t('page.contextMenu.removeFromFavorites', { postProcess: 'sentenceCase' }),
|
label: t('page.contextMenu.removeFromFavorites', { postProcess: 'sentenceCase' }),
|
||||||
@@ -827,17 +888,19 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||||||
handleAddToPlaylist,
|
handleAddToPlaylist,
|
||||||
openDeletePlaylistModal,
|
openDeletePlaylistModal,
|
||||||
handleDeselectAll,
|
handleDeselectAll,
|
||||||
|
ctx.data,
|
||||||
|
handleDownload,
|
||||||
handleMoveToBottom,
|
handleMoveToBottom,
|
||||||
handleMoveToTop,
|
handleMoveToTop,
|
||||||
|
handleSimilar,
|
||||||
handleRemoveFromFavorites,
|
handleRemoveFromFavorites,
|
||||||
handleRemoveFromPlaylist,
|
handleRemoveFromPlaylist,
|
||||||
handleRemoveSelected,
|
handleRemoveSelected,
|
||||||
ctx.data,
|
server,
|
||||||
|
handleShareItem,
|
||||||
handleOpenItemDetails,
|
handleOpenItemDetails,
|
||||||
handlePlay,
|
handlePlay,
|
||||||
handleUpdateRating,
|
handleUpdateRating,
|
||||||
handleShareItem,
|
|
||||||
server,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const mergedRef = useMergedRef(ref, clickOutsideRef);
|
const mergedRef = useMergedRef(ref, clickOutsideRef);
|
||||||
|
|||||||
@@ -35,7 +35,33 @@ export type ContextMenuItemType =
|
|||||||
| 'moveToTopOfQueue'
|
| 'moveToTopOfQueue'
|
||||||
| 'removeFromQueue'
|
| 'removeFromQueue'
|
||||||
| 'deselectAll'
|
| 'deselectAll'
|
||||||
| 'showDetails';
|
| 'showDetails'
|
||||||
|
| 'playSimilarSongs'
|
||||||
|
| 'download';
|
||||||
|
|
||||||
|
export const CONFIGURABLE_CONTEXT_MENU_ITEMS: ContextMenuItemType[] = [
|
||||||
|
'moveToBottomOfQueue',
|
||||||
|
'moveToTopOfQueue',
|
||||||
|
'play',
|
||||||
|
'playLast',
|
||||||
|
'playNext',
|
||||||
|
'playSimilarSongs',
|
||||||
|
'addToPlaylist',
|
||||||
|
'removeFromPlaylist',
|
||||||
|
'addToFavorites',
|
||||||
|
'removeFromFavorites',
|
||||||
|
'setRating',
|
||||||
|
'download',
|
||||||
|
'shareItem',
|
||||||
|
'showDetails',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CONTEXT_MENU_ITEM_MAPPING: { [k in ContextMenuItemType]?: string } = {
|
||||||
|
moveToBottomOfQueue: 'moveToBottom',
|
||||||
|
moveToTopOfQueue: 'moveToTop',
|
||||||
|
playLast: 'addLast',
|
||||||
|
playNext: 'addNext',
|
||||||
|
};
|
||||||
|
|
||||||
export type SetContextMenuItems = {
|
export type SetContextMenuItems = {
|
||||||
children?: boolean;
|
children?: boolean;
|
||||||
|
|||||||
@@ -25,23 +25,27 @@ export const useDiscordRpc = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const song = currentSong?.id ? currentSong : null;
|
||||||
|
|
||||||
const currentTime = usePlayerStore.getState().current.time;
|
const currentTime = usePlayerStore.getState().current.time;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const start = currentTime ? Math.round(now - currentTime * 1000) : null;
|
const start = currentTime ? Math.round(now - currentTime * 1000) : null;
|
||||||
const end =
|
const end = song?.duration && start ? Math.round(start + song.duration) : null;
|
||||||
currentSong?.duration && start ? Math.round(start + currentSong.duration) : null;
|
|
||||||
|
|
||||||
const artists = currentSong?.artists.map((artist) => artist.name).join(', ');
|
const artists = song?.artists.map((artist) => artist.name).join(', ');
|
||||||
|
|
||||||
const activity: SetActivity = {
|
const activity: SetActivity = {
|
||||||
details: currentSong?.name.padEnd(2, ' ') || 'Idle',
|
details: song?.name.padEnd(2, ' ') || 'Idle',
|
||||||
instance: false,
|
instance: false,
|
||||||
largeImageKey: undefined,
|
largeImageKey: undefined,
|
||||||
largeImageText: currentSong?.album || 'Unknown album',
|
largeImageText: song?.album || 'Unknown album',
|
||||||
smallImageKey: undefined,
|
smallImageKey: undefined,
|
||||||
smallImageText: currentStatus,
|
smallImageText: currentStatus,
|
||||||
state: (artists && `By ${artists}`) || 'Unknown artist',
|
state: (artists && `By ${artists}`) || 'Unknown artist',
|
||||||
|
// I would love to use the actual type as opposed to hardcoding to 2,
|
||||||
|
// but manually installing the discord-types package appears to break things
|
||||||
|
type: discordSettings.showAsListening ? 2 : 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (currentStatus === PlayerStatus.PLAYING) {
|
if (currentStatus === PlayerStatus.PLAYING) {
|
||||||
@@ -56,11 +60,11 @@ export const useDiscordRpc = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
currentSong?.serverType === ServerType.JELLYFIN &&
|
song?.serverType === ServerType.JELLYFIN &&
|
||||||
discordSettings.showServerImage &&
|
discordSettings.showServerImage &&
|
||||||
currentSong?.imageUrl
|
song?.imageUrl
|
||||||
) {
|
) {
|
||||||
activity.largeImageKey = currentSong?.imageUrl;
|
activity.largeImageKey = song?.imageUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to default icon if not set
|
// Fall back to default icon if not set
|
||||||
@@ -69,7 +73,13 @@ export const useDiscordRpc = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
discordRpc?.setActivity(activity);
|
discordRpc?.setActivity(activity);
|
||||||
}, [currentSong, currentStatus, discordSettings.enableIdle, discordSettings.showServerImage]);
|
}, [
|
||||||
|
currentSong,
|
||||||
|
currentStatus,
|
||||||
|
discordSettings.enableIdle,
|
||||||
|
discordSettings.showAsListening,
|
||||||
|
discordSettings.showServerImage,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initializeDiscordRpc = async () => {
|
const initializeDiscordRpc = async () => {
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ const HomeRoute = () => {
|
|||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
const itemsPerPage = 15;
|
const itemsPerPage = 15;
|
||||||
const { windowBarStyle } = useWindowSettings();
|
const { windowBarStyle } = useWindowSettings();
|
||||||
const { homeItems } = useGeneralSettings();
|
const { homeFeature, homeItems } = useGeneralSettings();
|
||||||
|
|
||||||
const feature = useAlbumList({
|
const feature = useAlbumList({
|
||||||
options: {
|
options: {
|
||||||
cacheTime: 1000 * 60,
|
cacheTime: 1000 * 60,
|
||||||
|
enabled: homeFeature,
|
||||||
staleTime: 1000 * 60,
|
staleTime: 1000 * 60,
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
@@ -249,7 +250,7 @@ const HomeRoute = () => {
|
|||||||
px="2rem"
|
px="2rem"
|
||||||
spacing="lg"
|
spacing="lg"
|
||||||
>
|
>
|
||||||
<FeatureCarousel data={featureItemsWithImage} />
|
{homeFeature && <FeatureCarousel data={featureItemsWithImage} />}
|
||||||
{sortedCarousel.map((carousel) => (
|
{sortedCarousel.map((carousel) => (
|
||||||
<MemoizedSwiperGridCarousel
|
<MemoizedSwiperGridCarousel
|
||||||
key={`carousel-${carousel.uniqueId}`}
|
key={`carousel-${carousel.uniqueId}`}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { Group, Table } from '@mantine/core';
|
import { Group, Table } from '@mantine/core';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { RiCheckFill, RiCloseFill } from 'react-icons/ri';
|
import { RiCheckFill, RiCloseFill } from 'react-icons/ri';
|
||||||
import { TFunction, useTranslation } from 'react-i18next';
|
import { TFunction, useTranslation } from 'react-i18next';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { Album, AlbumArtist, AnyLibraryItem, LibraryItem, Song } from '/@/renderer/api/types';
|
import { Album, AlbumArtist, AnyLibraryItem, LibraryItem, Song } from '/@/renderer/api/types';
|
||||||
import { formatDurationString } from '/@/renderer/utils';
|
import { formatDurationString, formatSizeString } from '/@/renderer/utils';
|
||||||
import { formatSizeString } from '/@/renderer/utils/format-size-string';
|
|
||||||
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||||
import { Rating, Spoiler, Text } from '/@/renderer/components';
|
import { Spoiler, Text } from '/@/renderer/components';
|
||||||
import { sanitize } from '/@/renderer/utils/sanitize';
|
import { sanitize } from '/@/renderer/utils/sanitize';
|
||||||
import { SongPath } from '/@/renderer/features/item-details/components/song-path';
|
import { SongPath } from '/@/renderer/features/item-details/components/song-path';
|
||||||
import { generatePath } from 'react-router';
|
import { generatePath } from 'react-router';
|
||||||
@@ -15,6 +13,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import { Separator } from '/@/renderer/components/separator';
|
import { Separator } from '/@/renderer/components/separator';
|
||||||
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
||||||
|
import { formatDateRelative, formatRating } from '/@/renderer/utils/format';
|
||||||
|
|
||||||
export type ItemDetailsModalProps = {
|
export type ItemDetailsModalProps = {
|
||||||
item: Album | AlbumArtist | Song;
|
item: Album | AlbumArtist | Song;
|
||||||
@@ -82,8 +81,6 @@ const formatArtists = (isAlbumArtist: boolean) => (item: Album | Song) =>
|
|||||||
const formatComment = (item: Album | Song) =>
|
const formatComment = (item: Album | Song) =>
|
||||||
item.comment ? <Spoiler maxHeight={50}>{replaceURLWithHTMLLinks(item.comment)}</Spoiler> : null;
|
item.comment ? <Spoiler maxHeight={50}>{replaceURLWithHTMLLinks(item.comment)}</Spoiler> : null;
|
||||||
|
|
||||||
const formatDate = (key: string | null) => (key ? dayjs(key).fromNow() : '');
|
|
||||||
|
|
||||||
const FormatGenre = (item: Album | AlbumArtist | Song) => {
|
const FormatGenre = (item: Album | AlbumArtist | Song) => {
|
||||||
const genreRoute = useGenreRoute();
|
const genreRoute = useGenreRoute();
|
||||||
|
|
||||||
@@ -104,14 +101,6 @@ const FormatGenre = (item: Album | AlbumArtist | Song) => {
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatRating = (item: Album | AlbumArtist | Song) =>
|
|
||||||
item.userRating !== null ? (
|
|
||||||
<Rating
|
|
||||||
readOnly
|
|
||||||
value={item.userRating}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const BoolField = (key: boolean) =>
|
const BoolField = (key: boolean) =>
|
||||||
key ? <RiCheckFill size="1.1rem" /> : <RiCloseFill size="1.1rem" />;
|
key ? <RiCheckFill size="1.1rem" /> : <RiCloseFill size="1.1rem" />;
|
||||||
|
|
||||||
@@ -139,11 +128,11 @@ const AlbumPropertyMapping: ItemDetailRow<Album>[] = [
|
|||||||
{ key: 'playCount', label: 'filter.playCount' },
|
{ key: 'playCount', label: 'filter.playCount' },
|
||||||
{
|
{
|
||||||
label: 'filter.lastPlayed',
|
label: 'filter.lastPlayed',
|
||||||
render: (song) => formatDate(song.lastPlayedAt),
|
render: (song) => formatDateRelative(song.lastPlayedAt),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'common.modified',
|
label: 'common.modified',
|
||||||
render: (song) => formatDate(song.updatedAt),
|
render: (song) => formatDateRelative(song.updatedAt),
|
||||||
},
|
},
|
||||||
{ label: 'filter.comment', render: formatComment },
|
{ label: 'filter.comment', render: formatComment },
|
||||||
{
|
{
|
||||||
@@ -178,7 +167,7 @@ const AlbumArtistPropertyMapping: ItemDetailRow<AlbumArtist>[] = [
|
|||||||
{ key: 'playCount', label: 'filter.playCount' },
|
{ key: 'playCount', label: 'filter.playCount' },
|
||||||
{
|
{
|
||||||
label: 'filter.lastPlayed',
|
label: 'filter.lastPlayed',
|
||||||
render: (song) => formatDate(song.lastPlayedAt),
|
render: (song) => formatDateRelative(song.lastPlayedAt),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'common.mbid',
|
label: 'common.mbid',
|
||||||
@@ -256,11 +245,11 @@ const SongPropertyMapping: ItemDetailRow<Song>[] = [
|
|||||||
{ key: 'playCount', label: 'filter.playCount' },
|
{ key: 'playCount', label: 'filter.playCount' },
|
||||||
{
|
{
|
||||||
label: 'filter.lastPlayed',
|
label: 'filter.lastPlayed',
|
||||||
render: (song) => formatDate(song.lastPlayedAt),
|
render: (song) => formatDateRelative(song.lastPlayedAt),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'common.modified',
|
label: 'common.modified',
|
||||||
render: (song) => formatDate(song.updatedAt),
|
render: (song) => formatDateRelative(song.updatedAt),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'common.albumGain',
|
label: 'common.albumGain',
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ const StyledText = styled(TextTitle)<TitleProps & { $alignment: string; $fontSiz
|
|||||||
text-align: ${(props) => props.$alignment};
|
text-align: ${(props) => props.$alignment};
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|
||||||
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
|
transition:
|
||||||
|
opacity 0.3s ease-in-out,
|
||||||
|
transform 0.3s ease-in-out;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -26,6 +28,10 @@ const StyledText = styled(TextTitle)<TitleProps & { $alignment: string; $fontSiz
|
|||||||
&.unsynchronized {
|
&.unsynchronized {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.synchronized {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const LyricLine = ({ text, alignment, fontSize, ...props }: LyricLineProps) => {
|
export const LyricLine = ({ text, alignment, fontSize, ...props }: LyricLineProps) => {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
|
useCurrentPlayer,
|
||||||
useCurrentStatus,
|
useCurrentStatus,
|
||||||
useCurrentTime,
|
useCurrentTime,
|
||||||
useLyricsSettings,
|
useLyricsSettings,
|
||||||
usePlaybackType,
|
usePlaybackType,
|
||||||
|
usePlayerData,
|
||||||
useSeeked,
|
useSeeked,
|
||||||
|
useSetCurrentTime,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
|
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
|
||||||
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
|
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
|
||||||
@@ -12,8 +15,11 @@ import isElectron from 'is-electron';
|
|||||||
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
|
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
|
||||||
import { FullLyricsMetadata, SynchronizedLyricsArray } from '/@/renderer/api/types';
|
import { FullLyricsMetadata, SynchronizedLyricsArray } from '/@/renderer/api/types';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
|
||||||
|
|
||||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||||
|
const utils = isElectron() ? window.electron.utils : null;
|
||||||
|
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
|
||||||
|
|
||||||
const SynchronizedLyricsContainer = styled.div<{ $gap: number }>`
|
const SynchronizedLyricsContainer = styled.div<{ $gap: number }>`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -23,6 +29,7 @@ const SynchronizedLyricsContainer = styled.div<{ $gap: number }>`
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 10vh 0 50vh;
|
padding: 10vh 0 50vh;
|
||||||
overflow: scroll;
|
overflow: scroll;
|
||||||
|
word-break: break-word;
|
||||||
transform: translateY(-2rem);
|
transform: translateY(-2rem);
|
||||||
|
|
||||||
-webkit-mask-image: linear-gradient(
|
-webkit-mask-image: linear-gradient(
|
||||||
@@ -60,8 +67,28 @@ export const SynchronizedLyrics = ({
|
|||||||
const playersRef = PlayersRef;
|
const playersRef = PlayersRef;
|
||||||
const status = useCurrentStatus();
|
const status = useCurrentStatus();
|
||||||
const playbackType = usePlaybackType();
|
const playbackType = usePlaybackType();
|
||||||
|
const playerData = usePlayerData();
|
||||||
const now = useCurrentTime();
|
const now = useCurrentTime();
|
||||||
const settings = useLyricsSettings();
|
const settings = useLyricsSettings();
|
||||||
|
const currentPlayer = useCurrentPlayer();
|
||||||
|
const currentPlayerRef =
|
||||||
|
currentPlayer === 1 ? playersRef.current?.player1 : playersRef.current?.player2;
|
||||||
|
const setCurrentTime = useSetCurrentTime();
|
||||||
|
const { handleScrobbleFromSeek } = useScrobble();
|
||||||
|
|
||||||
|
const handleSeek = useCallback(
|
||||||
|
(time: number) => {
|
||||||
|
if (playbackType === PlaybackType.LOCAL && mpvPlayer) {
|
||||||
|
mpvPlayer.seekTo(time);
|
||||||
|
} else {
|
||||||
|
setCurrentTime(time, true);
|
||||||
|
handleScrobbleFromSeek(time);
|
||||||
|
mpris?.updateSeek(time);
|
||||||
|
currentPlayerRef?.seekTo(time);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentPlayerRef, handleScrobbleFromSeek, playbackType, setCurrentTime],
|
||||||
|
);
|
||||||
|
|
||||||
const seeked = useSeeked();
|
const seeked = useSeeked();
|
||||||
|
|
||||||
@@ -107,16 +134,18 @@ export const SynchronizedLyrics = ({
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const player = (
|
const player =
|
||||||
playersRef.current.player1 ?? playersRef.current.player2
|
playerData.current.player === 1
|
||||||
).getInternalPlayer();
|
? playersRef.current.player1
|
||||||
|
: playersRef.current.player2;
|
||||||
|
const underlying = player?.getInternalPlayer();
|
||||||
|
|
||||||
// If it is null, this probably means we added a new song while the lyrics tab is open
|
// If it is null, this probably means we added a new song while the lyrics tab is open
|
||||||
// and the queue was previously empty
|
// and the queue was previously empty
|
||||||
if (!player) return 0;
|
if (!underlying) return 0;
|
||||||
|
|
||||||
return player.currentTime;
|
return underlying.currentTime;
|
||||||
}, [playbackType, playersRef]);
|
}, [playbackType, playersRef, playerData]);
|
||||||
|
|
||||||
const setCurrentLyric = useCallback(
|
const setCurrentLyric = useCallback(
|
||||||
(timeInMs: number, epoch?: number, targetIndex?: number) => {
|
(timeInMs: number, epoch?: number, targetIndex?: number) => {
|
||||||
@@ -173,9 +202,12 @@ export const SynchronizedLyrics = ({
|
|||||||
|
|
||||||
const elapsed = performance.now() - start;
|
const elapsed = performance.now() - start;
|
||||||
|
|
||||||
lyricTimer.current = setTimeout(() => {
|
lyricTimer.current = setTimeout(
|
||||||
setCurrentLyric(nextTime, nextEpoch, index + 1);
|
() => {
|
||||||
}, nextTime - timeInMs - elapsed);
|
setCurrentLyric(nextTime, nextEpoch, index + 1);
|
||||||
|
},
|
||||||
|
nextTime - timeInMs - elapsed,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -331,7 +363,7 @@ export const SynchronizedLyrics = ({
|
|||||||
text={`"${name} by ${artist}"`}
|
text={`"${name} by ${artist}"`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{lyrics.map(([, text], idx) => (
|
{lyrics.map(([time, text], idx) => (
|
||||||
<LyricLine
|
<LyricLine
|
||||||
key={idx}
|
key={idx}
|
||||||
alignment={settings.alignment}
|
alignment={settings.alignment}
|
||||||
@@ -339,6 +371,7 @@ export const SynchronizedLyrics = ({
|
|||||||
fontSize={settings.fontSize}
|
fontSize={settings.fontSize}
|
||||||
id={`lyric-${idx}`}
|
id={`lyric-${idx}`}
|
||||||
text={text}
|
text={text}
|
||||||
|
onClick={() => handleSeek(time / 1000)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SynchronizedLyricsContainer>
|
</SynchronizedLyricsContainer>
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ import {
|
|||||||
} from 'react-icons/ri';
|
} from 'react-icons/ri';
|
||||||
import { Song } from '/@/renderer/api/types';
|
import { Song } from '/@/renderer/api/types';
|
||||||
import { usePlayerControls, useQueueControls } from '/@/renderer/store';
|
import { usePlayerControls, useQueueControls } from '/@/renderer/store';
|
||||||
import { PlaybackType, PlayerStatus, TableType } from '/@/renderer/types';
|
import { PlaybackType, TableType } from '/@/renderer/types';
|
||||||
import { usePlaybackType } from '/@/renderer/store/settings.store';
|
import { usePlaybackType } from '/@/renderer/store/settings.store';
|
||||||
import { usePlayerStore, useSetCurrentTime } from '../../../store/player.store';
|
import { usePlayerStore, useSetCurrentTime } from '../../../store/player.store';
|
||||||
import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
|
import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
|
||||||
|
import { updateSong } from '/@/renderer/features/player/update-remote-song';
|
||||||
|
import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
|
||||||
|
|
||||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||||
const remote = isElectron() ? window.electron.remote : null;
|
|
||||||
|
|
||||||
interface PlayQueueListOptionsProps {
|
interface PlayQueueListOptionsProps {
|
||||||
tableRef: MutableRefObject<{ grid: AgGridReactType<Song> } | null>;
|
tableRef: MutableRefObject<{ grid: AgGridReactType<Song> } | null>;
|
||||||
@@ -45,7 +46,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
|||||||
const playerData = moveToBottomOfQueue(uniqueIds);
|
const playerData = moveToBottomOfQueue(uniqueIds);
|
||||||
|
|
||||||
if (playbackType === PlaybackType.LOCAL) {
|
if (playbackType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer!.setQueueNext(playerData);
|
setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
|||||||
const playerData = moveToTopOfQueue(uniqueIds);
|
const playerData = moveToTopOfQueue(uniqueIds);
|
||||||
|
|
||||||
if (playbackType === PlaybackType.LOCAL) {
|
if (playbackType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer!.setQueueNext(playerData);
|
setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,14 +73,14 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
|||||||
|
|
||||||
if (playbackType === PlaybackType.LOCAL) {
|
if (playbackType === PlaybackType.LOCAL) {
|
||||||
if (isCurrentSongRemoved) {
|
if (isCurrentSongRemoved) {
|
||||||
mpvPlayer!.setQueue(playerData);
|
setQueue(playerData);
|
||||||
} else {
|
} else {
|
||||||
mpvPlayer!.setQueueNext(playerData);
|
setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCurrentSongRemoved) {
|
if (isCurrentSongRemoved) {
|
||||||
remote?.updateSong({ song: playerData.current.song });
|
updateSong(playerData.current.song);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,11 +88,11 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
|||||||
const playerData = clearQueue();
|
const playerData = clearQueue();
|
||||||
|
|
||||||
if (playbackType === PlaybackType.LOCAL) {
|
if (playbackType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer!.setQueue(playerData);
|
setQueue(playerData);
|
||||||
mpvPlayer!.pause();
|
mpvPlayer!.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
remote?.updateSong({ song: undefined, status: PlayerStatus.PAUSED });
|
updateSong(undefined);
|
||||||
|
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
pause();
|
pause();
|
||||||
@@ -101,7 +102,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
|||||||
const playerData = shuffleQueue();
|
const playerData = shuffleQueue();
|
||||||
|
|
||||||
if (playbackType === PlaybackType.LOCAL) {
|
if (playbackType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer!.setQueueNext(playerData);
|
setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,16 +30,17 @@ import debounce from 'lodash/debounce';
|
|||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
|
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
|
||||||
import { ErrorFallback } from '/@/renderer/features/action-required';
|
import { ErrorFallback } from '/@/renderer/features/action-required';
|
||||||
import { PlaybackType, PlayerStatus, TableType } from '/@/renderer/types';
|
import { PlaybackType, TableType } from '/@/renderer/types';
|
||||||
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
|
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
|
||||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||||
import { QUEUE_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
import { QUEUE_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||||
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
|
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
|
||||||
import { useAppFocus } from '/@/renderer/hooks';
|
import { useAppFocus } from '/@/renderer/hooks';
|
||||||
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
|
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
|
||||||
|
import { updateSong } from '/@/renderer/features/player/update-remote-song';
|
||||||
|
import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
|
||||||
|
|
||||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||||
const remote = isElectron() ? window.electron.remote : null;
|
|
||||||
|
|
||||||
type QueueProps = {
|
type QueueProps = {
|
||||||
type: TableType;
|
type: TableType;
|
||||||
@@ -82,15 +83,11 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
|||||||
|
|
||||||
const handleDoubleClick = (e: CellDoubleClickedEvent) => {
|
const handleDoubleClick = (e: CellDoubleClickedEvent) => {
|
||||||
const playerData = setCurrentTrack(e.data.uniqueId);
|
const playerData = setCurrentTrack(e.data.uniqueId);
|
||||||
remote?.updateSong({
|
updateSong(playerData.current.song);
|
||||||
currentTime: 0,
|
|
||||||
song: playerData.current.song,
|
|
||||||
status: PlayerStatus.PLAYING,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (playbackType === PlaybackType.LOCAL) {
|
if (playbackType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer!.volume(volume);
|
mpvPlayer!.volume(volume);
|
||||||
mpvPlayer!.setQueue(playerData, false);
|
setQueue(playerData, false);
|
||||||
} else {
|
} else {
|
||||||
const player =
|
const player =
|
||||||
playerData.current.player === 1
|
playerData.current.player === 1
|
||||||
@@ -121,7 +118,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
|||||||
const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId);
|
const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId);
|
||||||
|
|
||||||
if (playbackType === PlaybackType.LOCAL) {
|
if (playbackType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer!.setQueueNext(playerData);
|
setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'sideDrawerQueue') {
|
if (type === 'sideDrawerQueue') {
|
||||||
|
|||||||
@@ -131,16 +131,17 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
|||||||
const formattedTime = formatDuration(currentTime * 1000 || 0);
|
const formattedTime = formatDuration(currentTime * 1000 || 0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let interval: any;
|
let interval: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
if (status === PlayerStatus.PLAYING && !isSeeking) {
|
if (status === PlayerStatus.PLAYING && !isSeeking) {
|
||||||
if (!isElectron() || playbackType === PlaybackType.WEB) {
|
if (!isElectron() || playbackType === PlaybackType.WEB) {
|
||||||
|
// Update twice a second for slightly better performance
|
||||||
interval = setInterval(() => {
|
interval = setInterval(() => {
|
||||||
setCurrentTime(currentPlayerRef.getCurrentTime());
|
if (currentPlayerRef) {
|
||||||
}, 1000);
|
setCurrentTime(currentPlayerRef.getCurrentTime());
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
clearInterval(interval);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
@@ -271,14 +272,14 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
|||||||
postProcess: 'sentenceCase',
|
postProcess: 'sentenceCase',
|
||||||
})
|
})
|
||||||
: repeat === PlayerRepeat.ALL
|
: repeat === PlayerRepeat.ALL
|
||||||
? t('player.repeat', {
|
? t('player.repeat', {
|
||||||
context: 'all',
|
context: 'all',
|
||||||
postProcess: 'sentenceCase',
|
postProcess: 'sentenceCase',
|
||||||
})
|
})
|
||||||
: t('player.repeat', {
|
: t('player.repeat', {
|
||||||
context: 'one',
|
context: 'one',
|
||||||
postProcess: 'sentenceCase',
|
postProcess: 'sentenceCase',
|
||||||
})
|
})
|
||||||
}`,
|
}`,
|
||||||
}}
|
}}
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
|
|||||||
@@ -112,8 +112,9 @@ const Controls = () => {
|
|||||||
pos="absolute"
|
pos="absolute"
|
||||||
spacing="sm"
|
spacing="sm"
|
||||||
sx={{
|
sx={{
|
||||||
|
background: `rgb(var(--main-bg-transparent), ${opacity}%)`,
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 10,
|
top: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -62,12 +62,12 @@ const Image = styled(motion.div)`
|
|||||||
const PlayerbarImage = styled.img`
|
const PlayerbarImage = styled.img`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: var(--image-fit);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const LineItem = styled.div<{ $secondary?: boolean }>`
|
const LineItem = styled.div<{ $secondary?: boolean }>`
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 95%;
|
width: fit-content;
|
||||||
max-width: 20vw;
|
max-width: 20vw;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
@@ -122,6 +122,8 @@ export const LeftControls = () => {
|
|||||||
setSideBar({ image: true });
|
setSideBar({ image: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopPropagation = (e?: MouseEvent) => e?.stopPropagation();
|
||||||
|
|
||||||
useHotkeys([
|
useHotkeys([
|
||||||
[
|
[
|
||||||
bindings.toggleFullscreenPlayer.allowGlobal
|
bindings.toggleFullscreenPlayer.allowGlobal
|
||||||
@@ -207,7 +209,7 @@ export const LeftControls = () => {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<MetadataStack layout="position">
|
<MetadataStack layout="position">
|
||||||
<LineItem>
|
<LineItem onClick={stopPropagation}>
|
||||||
<Group
|
<Group
|
||||||
noWrap
|
noWrap
|
||||||
align="flex-start"
|
align="flex-start"
|
||||||
@@ -234,7 +236,10 @@ export const LeftControls = () => {
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</LineItem>
|
</LineItem>
|
||||||
<LineItem $secondary>
|
<LineItem
|
||||||
|
$secondary
|
||||||
|
onClick={stopPropagation}
|
||||||
|
>
|
||||||
{artists?.map((artist, index) => (
|
{artists?.map((artist, index) => (
|
||||||
<React.Fragment key={`bar-${artist.id}`}>
|
<React.Fragment key={`bar-${artist.id}`}>
|
||||||
{index > 0 && <Separator />}
|
{index > 0 && <Separator />}
|
||||||
@@ -257,7 +262,10 @@ export const LeftControls = () => {
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</LineItem>
|
</LineItem>
|
||||||
<LineItem $secondary>
|
<LineItem
|
||||||
|
$secondary
|
||||||
|
onClick={stopPropagation}
|
||||||
|
>
|
||||||
<Text
|
<Text
|
||||||
$link
|
$link
|
||||||
component={Link}
|
component={Link}
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ const StyledPlayerButton = styled(UnstyledButton)<StyledPlayerButtonProps>`
|
|||||||
variant === 'main'
|
variant === 'main'
|
||||||
? ButtonMainVariant
|
? ButtonMainVariant
|
||||||
: variant === 'secondary'
|
: variant === 'secondary'
|
||||||
? ButtonSecondaryVariant
|
? ButtonSecondaryVariant
|
||||||
: ButtonTertiaryVariant};
|
: ButtonTertiaryVariant};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const PlayerButton = forwardRef<HTMLDivElement, PlayerButtonProps>(
|
export const PlayerButton = forwardRef<HTMLDivElement, PlayerButtonProps>(
|
||||||
@@ -132,6 +132,10 @@ export const PlayerButton = forwardRef<HTMLDivElement, PlayerButtonProps>(
|
|||||||
<StyledPlayerButton
|
<StyledPlayerButton
|
||||||
variant={variant}
|
variant={variant}
|
||||||
{...rest}
|
{...rest}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
rest.onClick?.(e);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</StyledPlayerButton>
|
</StyledPlayerButton>
|
||||||
@@ -148,6 +152,10 @@ export const PlayerButton = forwardRef<HTMLDivElement, PlayerButtonProps>(
|
|||||||
<StyledPlayerButton
|
<StyledPlayerButton
|
||||||
variant={variant}
|
variant={variant}
|
||||||
{...rest}
|
{...rest}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
rest.onClick?.(e);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</StyledPlayerButton>
|
</StyledPlayerButton>
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export const PlayerbarSlider = ({ ...props }: SliderProps) => {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
|
onClick={(e) => {
|
||||||
|
e?.stopPropagation();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, MouseEvent } from 'react';
|
||||||
import isElectron from 'is-electron';
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { usePlaybackType, useSettingsStore } from '/@/renderer/store/settings.store';
|
import {
|
||||||
|
usePlaybackType,
|
||||||
|
useSettingsStore,
|
||||||
|
useGeneralSettings,
|
||||||
|
} from '/@/renderer/store/settings.store';
|
||||||
import { PlaybackType } from '/@/renderer/types';
|
import { PlaybackType } from '/@/renderer/types';
|
||||||
import { AudioPlayer } from '/@/renderer/components';
|
import { AudioPlayer } from '/@/renderer/components';
|
||||||
import {
|
import {
|
||||||
@@ -12,11 +15,14 @@ import {
|
|||||||
usePlayer2Data,
|
usePlayer2Data,
|
||||||
usePlayerControls,
|
usePlayerControls,
|
||||||
useVolume,
|
useVolume,
|
||||||
|
useSetFullScreenPlayerStore,
|
||||||
|
useFullScreenPlayerStore,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
import { CenterControls } from './center-controls';
|
import { CenterControls } from './center-controls';
|
||||||
import { LeftControls } from './left-controls';
|
import { LeftControls } from './left-controls';
|
||||||
import { RightControls } from './right-controls';
|
import { RightControls } from './right-controls';
|
||||||
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
|
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
|
||||||
|
import { updateSong } from '/@/renderer/features/player/update-remote-song';
|
||||||
|
|
||||||
const PlayerbarContainer = styled.div`
|
const PlayerbarContainer = styled.div`
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
@@ -59,11 +65,10 @@ const CenterGridItem = styled.div`
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const remote = isElectron() ? window.electron.remote : null;
|
|
||||||
|
|
||||||
export const Playerbar = () => {
|
export const Playerbar = () => {
|
||||||
const playersRef = PlayersRef;
|
const playersRef = PlayersRef;
|
||||||
const settings = useSettingsStore((state) => state.playback);
|
const settings = useSettingsStore((state) => state.playback);
|
||||||
|
const { playerbarOpenDrawer } = useGeneralSettings();
|
||||||
const playbackType = usePlaybackType();
|
const playbackType = usePlaybackType();
|
||||||
const volume = useVolume();
|
const volume = useVolume();
|
||||||
const player1 = usePlayer1Data();
|
const player1 = usePlayer1Data();
|
||||||
@@ -72,20 +77,23 @@ export const Playerbar = () => {
|
|||||||
const player = useCurrentPlayer();
|
const player = useCurrentPlayer();
|
||||||
const muted = useMuted();
|
const muted = useMuted();
|
||||||
const { autoNext } = usePlayerControls();
|
const { autoNext } = usePlayerControls();
|
||||||
|
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
||||||
|
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||||
|
|
||||||
|
const handleToggleFullScreenPlayer = (e?: MouseEvent<HTMLDivElement> | KeyboardEvent) => {
|
||||||
|
e?.stopPropagation();
|
||||||
|
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
|
||||||
|
};
|
||||||
|
|
||||||
const autoNextFn = useCallback(() => {
|
const autoNextFn = useCallback(() => {
|
||||||
const playerData = autoNext();
|
const playerData = autoNext();
|
||||||
|
updateSong(playerData.current.song);
|
||||||
if (remote) {
|
|
||||||
remote.updateSong({
|
|
||||||
currentTime: 0,
|
|
||||||
song: playerData.current.song,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [autoNext]);
|
}, [autoNext]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlayerbarContainer>
|
<PlayerbarContainer
|
||||||
|
onClick={playerbarOpenDrawer ? handleToggleFullScreenPlayer : undefined}
|
||||||
|
>
|
||||||
<PlayerbarControlsGrid>
|
<PlayerbarControlsGrid>
|
||||||
<LeftGridItem>
|
<LeftGridItem>
|
||||||
<LeftControls />
|
<LeftControls />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
useHotkeySettings,
|
useHotkeySettings,
|
||||||
useMuted,
|
useMuted,
|
||||||
usePreviousSong,
|
usePreviousSong,
|
||||||
|
useSettingsStore,
|
||||||
useSidebarStore,
|
useSidebarStore,
|
||||||
useSpeed,
|
useSpeed,
|
||||||
useVolume,
|
useVolume,
|
||||||
@@ -54,6 +55,7 @@ export const RightControls = () => {
|
|||||||
} = useRightControls();
|
} = useRightControls();
|
||||||
|
|
||||||
const speed = useSpeed();
|
const speed = useSpeed();
|
||||||
|
const volumeWidth = useSettingsStore((state) => state.general.volumeWidth);
|
||||||
|
|
||||||
const updateRatingMutation = useSetRating({});
|
const updateRatingMutation = useSetRating({});
|
||||||
const addToFavoritesMutation = useCreateFavorite({});
|
const addToFavoritesMutation = useCreateFavorite({});
|
||||||
@@ -324,7 +326,7 @@ export const RightControls = () => {
|
|||||||
min={0}
|
min={0}
|
||||||
size={6}
|
size={6}
|
||||||
value={volume}
|
value={volume}
|
||||||
w="60px"
|
w={volumeWidth}
|
||||||
onChange={handleVolumeSlider}
|
onChange={handleVolumeSlider}
|
||||||
onWheel={handleVolumeWheel}
|
onWheel={handleVolumeWheel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Divider, Group, Stack } from '@mantine/core';
|
import { Divider, Group, SelectItem, Stack } from '@mantine/core';
|
||||||
import { closeAllModals, openModal } from '@mantine/modals';
|
import { closeAllModals, openModal } from '@mantine/modals';
|
||||||
import { QueryClient } from '@tanstack/react-query';
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
import merge from 'lodash/merge';
|
import merge from 'lodash/merge';
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
GenreListSort,
|
GenreListSort,
|
||||||
SortOrder,
|
SortOrder,
|
||||||
ServerListItem,
|
ServerListItem,
|
||||||
|
Played,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { useAuthStore } from '/@/renderer/store';
|
import { useAuthStore } from '/@/renderer/store';
|
||||||
@@ -45,6 +46,7 @@ const useShuffleAllStore = create<ShuffleAllSlice>()(
|
|||||||
maxYear: 2020,
|
maxYear: 2020,
|
||||||
minYear: 2000,
|
minYear: 2000,
|
||||||
musicFolder: '',
|
musicFolder: '',
|
||||||
|
played: Played.All,
|
||||||
songCount: 100,
|
songCount: 100,
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
@@ -55,6 +57,12 @@ const useShuffleAllStore = create<ShuffleAllSlice>()(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const PLAYED_DATA: SelectItem[] = [
|
||||||
|
{ label: 'all tracks', value: Played.All },
|
||||||
|
{ label: 'only unplayed tracks', value: Played.Never },
|
||||||
|
{ label: 'only played tracks', value: Played.Played },
|
||||||
|
];
|
||||||
|
|
||||||
export const useShuffleAllStoreActions = () => useShuffleAllStore((state) => state.actions);
|
export const useShuffleAllStoreActions = () => useShuffleAllStore((state) => state.actions);
|
||||||
|
|
||||||
interface ShuffleAllModalProps {
|
interface ShuffleAllModalProps {
|
||||||
@@ -72,7 +80,7 @@ export const ShuffleAllModal = ({
|
|||||||
genres,
|
genres,
|
||||||
musicFolders,
|
musicFolders,
|
||||||
}: ShuffleAllModalProps) => {
|
}: ShuffleAllModalProps) => {
|
||||||
const { genre, limit, maxYear, minYear, enableMaxYear, enableMinYear, musicFolderId } =
|
const { genre, limit, maxYear, minYear, enableMaxYear, enableMinYear, musicFolderId, played } =
|
||||||
useShuffleAllStore();
|
useShuffleAllStore();
|
||||||
const { setStore } = useShuffleAllStoreActions();
|
const { setStore } = useShuffleAllStoreActions();
|
||||||
|
|
||||||
@@ -91,6 +99,7 @@ export const ShuffleAllModal = ({
|
|||||||
maxYear: enableMaxYear ? maxYear || undefined : undefined,
|
maxYear: enableMaxYear ? maxYear || undefined : undefined,
|
||||||
minYear: enableMinYear ? minYear || undefined : undefined,
|
minYear: enableMinYear ? minYear || undefined : undefined,
|
||||||
musicFolderId: musicFolderId || undefined,
|
musicFolderId: musicFolderId || undefined,
|
||||||
|
played,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
queryKey: queryKeys.songs.randomSongList(server?.id),
|
queryKey: queryKeys.songs.randomSongList(server?.id),
|
||||||
@@ -185,6 +194,17 @@ export const ShuffleAllModal = ({
|
|||||||
setStore({ musicFolderId: e ? String(e) : '' });
|
setStore({ musicFolderId: e ? String(e) : '' });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{server?.type === ServerType.JELLYFIN && (
|
||||||
|
<Select
|
||||||
|
clearable
|
||||||
|
data={PLAYED_DATA}
|
||||||
|
label="Play filter"
|
||||||
|
value={played}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStore({ played: e as Played });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Divider />
|
<Divider />
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ import {
|
|||||||
import { usePlaybackType } from '/@/renderer/store/settings.store';
|
import { usePlaybackType } from '/@/renderer/store/settings.store';
|
||||||
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
|
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import { QueueSong } from '/@/renderer/api/types';
|
|
||||||
import { toast } from '/@/renderer/components';
|
import { toast } from '/@/renderer/components';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { updateSong } from '/@/renderer/features/player/update-remote-song';
|
||||||
|
import { setAutoNext, setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
|
||||||
|
|
||||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||||
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
|
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
|
||||||
@@ -24,7 +25,7 @@ const ipc = isElectron() ? window.electron.ipc : null;
|
|||||||
const utils = isElectron() ? window.electron.utils : null;
|
const utils = isElectron() ? window.electron.utils : null;
|
||||||
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
|
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
|
||||||
const remote = isElectron() ? window.electron.remote : null;
|
const remote = isElectron() ? window.electron.remote : null;
|
||||||
const mediaSession = !isElectron() || !utils?.isLinux() ? navigator.mediaSession : null;
|
const mediaSession = navigator.mediaSession;
|
||||||
|
|
||||||
export const useCenterControls = (args: { playersRef: any }) => {
|
export const useCenterControls = (args: { playersRef: any }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -46,6 +47,23 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
|
|
||||||
const { handleScrobbleFromSongRestart, handleScrobbleFromSeek } = useScrobble();
|
const { handleScrobbleFromSongRestart, handleScrobbleFromSeek } = useScrobble();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mediaSession) {
|
||||||
|
mediaSession.playbackState =
|
||||||
|
playerStatus === PlayerStatus.PLAYING ? 'playing' : 'paused';
|
||||||
|
}
|
||||||
|
|
||||||
|
remote?.updatePlayback(playerStatus);
|
||||||
|
}, [playerStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
remote?.updateRepeat(repeatStatus);
|
||||||
|
}, [repeatStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
remote?.updateShuffle(shuffleStatus !== PlayerShuffle.NONE);
|
||||||
|
}, [shuffleStatus]);
|
||||||
|
|
||||||
const resetPlayers = useCallback(() => {
|
const resetPlayers = useCallback(() => {
|
||||||
if (player1Ref.getInternalPlayer()) {
|
if (player1Ref.getInternalPlayer()) {
|
||||||
player1Ref.getInternalPlayer().currentTime = 0;
|
player1Ref.getInternalPlayer().currentTime = 0;
|
||||||
@@ -76,61 +94,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
|
|
||||||
const isMpvPlayer = isElectron() && playbackType === PlaybackType.LOCAL;
|
const isMpvPlayer = isElectron() && playbackType === PlaybackType.LOCAL;
|
||||||
|
|
||||||
const mprisUpdateSong = (args?: {
|
|
||||||
currentTime?: number;
|
|
||||||
song?: QueueSong;
|
|
||||||
status?: PlayerStatus;
|
|
||||||
}) => {
|
|
||||||
const { song, currentTime, status } = args || {};
|
|
||||||
|
|
||||||
const time = currentTime || usePlayerStore.getState().current.time;
|
|
||||||
const playStatus = status || usePlayerStore.getState().current.status;
|
|
||||||
const track = song || usePlayerStore.getState().current.song;
|
|
||||||
|
|
||||||
remote?.updateSong({
|
|
||||||
currentTime: time,
|
|
||||||
repeat: usePlayerStore.getState().repeat,
|
|
||||||
shuffle: usePlayerStore.getState().shuffle !== PlayerShuffle.NONE,
|
|
||||||
song: track,
|
|
||||||
status: playStatus,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mediaSession) {
|
|
||||||
mediaSession.playbackState = playStatus === PlayerStatus.PLAYING ? 'playing' : 'paused';
|
|
||||||
|
|
||||||
let metadata: MediaMetadata;
|
|
||||||
|
|
||||||
if (track) {
|
|
||||||
let artwork: MediaImage[];
|
|
||||||
|
|
||||||
if (track.imageUrl) {
|
|
||||||
const image300 = track.imageUrl
|
|
||||||
?.replace(/&size=\d+/, '&size=300')
|
|
||||||
.replace(/\?width=\d+/, '?width=300')
|
|
||||||
.replace(/&height=\d+/, '&height=300');
|
|
||||||
|
|
||||||
artwork = [{ sizes: '300x300', src: image300, type: 'image/png' }];
|
|
||||||
} else {
|
|
||||||
artwork = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata = new MediaMetadata({
|
|
||||||
album: track.album ?? '',
|
|
||||||
artist: track.artistName,
|
|
||||||
artwork,
|
|
||||||
title: track.name,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
metadata = new MediaMetadata();
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaSession.metadata = metadata;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlay = useCallback(() => {
|
const handlePlay = useCallback(() => {
|
||||||
mprisUpdateSong({ status: PlayerStatus.PLAYING });
|
|
||||||
|
|
||||||
if (isMpvPlayer) {
|
if (isMpvPlayer) {
|
||||||
mpvPlayer?.volume(usePlayerStore.getState().volume);
|
mpvPlayer?.volume(usePlayerStore.getState().volume);
|
||||||
mpvPlayer!.play();
|
mpvPlayer!.play();
|
||||||
@@ -145,8 +109,6 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
}, [currentPlayerRef, isMpvPlayer, play]);
|
}, [currentPlayerRef, isMpvPlayer, play]);
|
||||||
|
|
||||||
const handlePause = useCallback(() => {
|
const handlePause = useCallback(() => {
|
||||||
mprisUpdateSong({ status: PlayerStatus.PAUSED });
|
|
||||||
|
|
||||||
if (isMpvPlayer) {
|
if (isMpvPlayer) {
|
||||||
mpvPlayer!.pause();
|
mpvPlayer!.pause();
|
||||||
}
|
}
|
||||||
@@ -155,8 +117,6 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
}, [isMpvPlayer, pause]);
|
}, [isMpvPlayer, pause]);
|
||||||
|
|
||||||
const handleStop = useCallback(() => {
|
const handleStop = useCallback(() => {
|
||||||
mprisUpdateSong({ status: PlayerStatus.PAUSED });
|
|
||||||
|
|
||||||
if (isMpvPlayer) {
|
if (isMpvPlayer) {
|
||||||
mpvPlayer!.pause();
|
mpvPlayer!.pause();
|
||||||
mpvPlayer!.seekTo(0);
|
mpvPlayer!.seekTo(0);
|
||||||
@@ -172,30 +132,30 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
if (shuffleStatus === PlayerShuffle.NONE) {
|
if (shuffleStatus === PlayerShuffle.NONE) {
|
||||||
const playerData = setShuffle(PlayerShuffle.TRACK);
|
const playerData = setShuffle(PlayerShuffle.TRACK);
|
||||||
remote?.updateShuffle(true);
|
remote?.updateShuffle(true);
|
||||||
return mpvPlayer?.setQueueNext(playerData);
|
return setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerData = setShuffle(PlayerShuffle.NONE);
|
const playerData = setShuffle(PlayerShuffle.NONE);
|
||||||
remote?.updateShuffle(false);
|
remote?.updateShuffle(false);
|
||||||
return mpvPlayer?.setQueueNext(playerData);
|
return setQueueNext(playerData);
|
||||||
}, [setShuffle, shuffleStatus]);
|
}, [setShuffle, shuffleStatus]);
|
||||||
|
|
||||||
const handleToggleRepeat = useCallback(() => {
|
const handleToggleRepeat = useCallback(() => {
|
||||||
if (repeatStatus === PlayerRepeat.NONE) {
|
if (repeatStatus === PlayerRepeat.NONE) {
|
||||||
const playerData = setRepeat(PlayerRepeat.ALL);
|
const playerData = setRepeat(PlayerRepeat.ALL);
|
||||||
remote?.updateRepeat(PlayerRepeat.ALL);
|
remote?.updateRepeat(PlayerRepeat.ALL);
|
||||||
return mpvPlayer?.setQueueNext(playerData);
|
return setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (repeatStatus === PlayerRepeat.ALL) {
|
if (repeatStatus === PlayerRepeat.ALL) {
|
||||||
const playerData = setRepeat(PlayerRepeat.ONE);
|
const playerData = setRepeat(PlayerRepeat.ONE);
|
||||||
remote?.updateRepeat(PlayerRepeat.ONE);
|
remote?.updateRepeat(PlayerRepeat.ONE);
|
||||||
return mpvPlayer?.setQueueNext(playerData);
|
return setQueueNext(playerData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerData = setRepeat(PlayerRepeat.NONE);
|
const playerData = setRepeat(PlayerRepeat.NONE);
|
||||||
remote?.updateRepeat(PlayerRepeat.NONE);
|
remote?.updateRepeat(PlayerRepeat.NONE);
|
||||||
return mpvPlayer?.setQueueNext(playerData);
|
return setQueueNext(playerData);
|
||||||
}, [repeatStatus, setRepeat]);
|
}, [repeatStatus, setRepeat]);
|
||||||
|
|
||||||
const checkIsLastTrack = useCallback(() => {
|
const checkIsLastTrack = useCallback(() => {
|
||||||
@@ -212,13 +172,13 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
const handleRepeatAll = {
|
const handleRepeatAll = {
|
||||||
local: () => {
|
local: () => {
|
||||||
const playerData = autoNext();
|
const playerData = autoNext();
|
||||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
|
updateSong(playerData.current.song);
|
||||||
mpvPlayer!.autoNext(playerData);
|
setAutoNext(playerData);
|
||||||
play();
|
play();
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
const playerData = autoNext();
|
const playerData = autoNext();
|
||||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
|
updateSong(playerData.current.song);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -226,30 +186,23 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
local: () => {
|
local: () => {
|
||||||
if (isLastTrack) {
|
if (isLastTrack) {
|
||||||
const playerData = setCurrentIndex(0);
|
const playerData = setCurrentIndex(0);
|
||||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PAUSED });
|
updateSong(playerData.current.song);
|
||||||
mpvPlayer!.setQueue(playerData, true);
|
setQueue(playerData, true);
|
||||||
pause();
|
pause();
|
||||||
} else {
|
} else {
|
||||||
const playerData = autoNext();
|
const playerData = autoNext();
|
||||||
mprisUpdateSong({
|
updateSong(playerData.current.song);
|
||||||
song: playerData.current.song,
|
setAutoNext(playerData);
|
||||||
status: PlayerStatus.PLAYING,
|
|
||||||
});
|
|
||||||
mpvPlayer!.autoNext(playerData);
|
|
||||||
play();
|
play();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
if (isLastTrack) {
|
if (isLastTrack) {
|
||||||
resetPlayers();
|
resetPlayers();
|
||||||
mprisUpdateSong({ status: PlayerStatus.PAUSED });
|
|
||||||
pause();
|
pause();
|
||||||
} else {
|
} else {
|
||||||
const playerData = autoNext();
|
const playerData = autoNext();
|
||||||
mprisUpdateSong({
|
updateSong(playerData.current.song);
|
||||||
song: playerData.current.song,
|
|
||||||
status: PlayerStatus.PLAYING,
|
|
||||||
});
|
|
||||||
resetPlayers();
|
resetPlayers();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -258,20 +211,15 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
const handleRepeatOne = {
|
const handleRepeatOne = {
|
||||||
local: () => {
|
local: () => {
|
||||||
const playerData = autoNext();
|
const playerData = autoNext();
|
||||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
|
updateSong(playerData.current.song);
|
||||||
mpvPlayer!.autoNext(playerData);
|
setAutoNext(playerData);
|
||||||
play();
|
play();
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
if (isLastTrack) {
|
if (isLastTrack) {
|
||||||
mprisUpdateSong({ status: PlayerStatus.PAUSED });
|
|
||||||
resetPlayers();
|
resetPlayers();
|
||||||
} else {
|
} else {
|
||||||
const playerData = autoNext();
|
autoNext();
|
||||||
mprisUpdateSong({
|
|
||||||
song: playerData.current.song,
|
|
||||||
status: PlayerStatus.PLAYING,
|
|
||||||
});
|
|
||||||
resetPlayers();
|
resetPlayers();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -309,12 +257,12 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
const handleRepeatAll = {
|
const handleRepeatAll = {
|
||||||
local: () => {
|
local: () => {
|
||||||
const playerData = next();
|
const playerData = next();
|
||||||
mprisUpdateSong({ song: playerData.current.song });
|
updateSong(playerData.current.song);
|
||||||
mpvPlayer!.setQueue(playerData);
|
setQueue(playerData);
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
const playerData = next();
|
const playerData = next();
|
||||||
mprisUpdateSong({ song: playerData.current.song });
|
updateSong(playerData.current.song);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -322,27 +270,24 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
local: () => {
|
local: () => {
|
||||||
if (isLastTrack) {
|
if (isLastTrack) {
|
||||||
const playerData = setCurrentIndex(0);
|
const playerData = setCurrentIndex(0);
|
||||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PAUSED });
|
updateSong(playerData.current.song);
|
||||||
mpvPlayer!.setQueue(playerData, true);
|
setQueue(playerData, true);
|
||||||
pause();
|
pause();
|
||||||
} else {
|
} else {
|
||||||
const playerData = next();
|
const playerData = next();
|
||||||
mprisUpdateSong({ song: playerData.current.song });
|
updateSong(playerData.current.song);
|
||||||
mpvPlayer!.setQueue(playerData);
|
setQueue(playerData);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
if (isLastTrack) {
|
if (isLastTrack) {
|
||||||
const playerData = setCurrentIndex(0);
|
const playerData = setCurrentIndex(0);
|
||||||
mprisUpdateSong({
|
updateSong(playerData.current.song);
|
||||||
song: playerData.current.song,
|
|
||||||
status: PlayerStatus.PAUSED,
|
|
||||||
});
|
|
||||||
resetPlayers();
|
resetPlayers();
|
||||||
pause();
|
pause();
|
||||||
} else {
|
} else {
|
||||||
const playerData = next();
|
const playerData = next();
|
||||||
mprisUpdateSong({ song: playerData.current.song });
|
updateSong(playerData.current.song);
|
||||||
resetPlayers();
|
resetPlayers();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -352,14 +297,14 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
local: () => {
|
local: () => {
|
||||||
if (!isLastTrack) {
|
if (!isLastTrack) {
|
||||||
const playerData = next();
|
const playerData = next();
|
||||||
mprisUpdateSong({ song: playerData.current.song });
|
updateSong(playerData.current.song);
|
||||||
mpvPlayer!.setQueue(playerData);
|
setQueue(playerData);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
if (!isLastTrack) {
|
if (!isLastTrack) {
|
||||||
const playerData = next();
|
const playerData = next();
|
||||||
mprisUpdateSong({ song: playerData.current.song });
|
updateSong(playerData.current.song);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -413,22 +358,22 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
local: () => {
|
local: () => {
|
||||||
if (!isFirstTrack) {
|
if (!isFirstTrack) {
|
||||||
const playerData = previous();
|
const playerData = previous();
|
||||||
mprisUpdateSong({ song: playerData.current.song });
|
updateSong(playerData.current.song);
|
||||||
mpvPlayer!.setQueue(playerData);
|
setQueue(playerData);
|
||||||
} else {
|
} else {
|
||||||
const playerData = setCurrentIndex(queue.length - 1);
|
const playerData = setCurrentIndex(queue.length - 1);
|
||||||
mprisUpdateSong({ song: playerData.current.song });
|
updateSong(playerData.current.song);
|
||||||
mpvPlayer!.setQueue(playerData);
|
setQueue(playerData);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
if (isFirstTrack) {
|
if (isFirstTrack) {
|
||||||
const playerData = setCurrentIndex(queue.length - 1);
|
const playerData = setCurrentIndex(queue.length - 1);
|
||||||
mprisUpdateSong({ song: playerData.current.song });
|
updateSong(playerData.current.song);
|
||||||
resetPlayers();
|
resetPlayers();
|
||||||
} else {
|
} else {
|
||||||
const playerData = previous();
|
const playerData = previous();
|
||||||
mprisUpdateSong({ song: playerData.current.song });
|
updateSong(playerData.current.song);
|
||||||
resetPlayers();
|
resetPlayers();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -438,26 +383,22 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
local: () => {
|
local: () => {
|
||||||
if (isFirstTrack) {
|
if (isFirstTrack) {
|
||||||
const playerData = setCurrentIndex(0);
|
const playerData = setCurrentIndex(0);
|
||||||
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PAUSED });
|
updateSong(playerData.current.song);
|
||||||
mpvPlayer!.setQueue(playerData, true);
|
setQueue(playerData, true);
|
||||||
pause();
|
pause();
|
||||||
} else {
|
} else {
|
||||||
const playerData = previous();
|
const playerData = previous();
|
||||||
mprisUpdateSong({
|
updateSong(playerData.current.song);
|
||||||
currentTime: usePlayerStore.getState().current.time,
|
setQueue(playerData);
|
||||||
song: playerData.current.song,
|
|
||||||
});
|
|
||||||
mpvPlayer!.setQueue(playerData);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
if (isFirstTrack) {
|
if (isFirstTrack) {
|
||||||
resetPlayers();
|
resetPlayers();
|
||||||
mprisUpdateSong({ status: PlayerStatus.PAUSED });
|
|
||||||
pause();
|
pause();
|
||||||
} else {
|
} else {
|
||||||
const playerData = previous();
|
const playerData = previous();
|
||||||
mprisUpdateSong({ song: playerData.current.song });
|
updateSong(playerData.current.song);
|
||||||
resetPlayers();
|
resetPlayers();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -466,12 +407,12 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
const handleRepeatOne = {
|
const handleRepeatOne = {
|
||||||
local: () => {
|
local: () => {
|
||||||
const playerData = previous();
|
const playerData = previous();
|
||||||
mprisUpdateSong({ song: playerData.current.song });
|
updateSong(playerData.current.song);
|
||||||
mpvPlayer!.setQueue(playerData);
|
setQueue(playerData);
|
||||||
},
|
},
|
||||||
web: () => {
|
web: () => {
|
||||||
const playerData = previous();
|
const playerData = previous();
|
||||||
mprisUpdateSong({ song: playerData.current.song });
|
updateSong(playerData.current.song);
|
||||||
resetPlayers();
|
resetPlayers();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -533,7 +474,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
mpvPlayer!.seek(-seconds);
|
mpvPlayer!.seek(-seconds);
|
||||||
} else {
|
} else {
|
||||||
resetNextPlayer();
|
resetNextPlayer();
|
||||||
currentPlayerRef.seekTo(newTime);
|
currentPlayerRef.seekTo(newTime, 'seconds');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -556,7 +497,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
|
|
||||||
resetNextPlayer();
|
resetNextPlayer();
|
||||||
setCurrentTime(newTime, true);
|
setCurrentTime(newTime, true);
|
||||||
currentPlayerRef.seekTo(newTime);
|
currentPlayerRef.seekTo(newTime, 'seconds');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -564,7 +505,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
if (isMpvPlayer) {
|
if (isMpvPlayer) {
|
||||||
mpvPlayer!.seekTo(e);
|
mpvPlayer!.seekTo(e);
|
||||||
} else {
|
} else {
|
||||||
currentPlayerRef.seekTo(e);
|
currentPlayerRef.seekTo(e, 'seconds');
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
@@ -729,11 +670,11 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (utils?.isLinux()) {
|
if (remote) {
|
||||||
const unsubCurrentTime = usePlayerStore.subscribe(
|
const unsubCurrentTime = usePlayerStore.subscribe(
|
||||||
(state) => state.current.time,
|
(state) => state.current.time,
|
||||||
(time) => {
|
(time) => {
|
||||||
mpris?.updatePosition(time);
|
remote.updatePosition(time);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useCurrentServer, usePlayerControls, usePlayerStore } from '/@/renderer/store';
|
import { useCurrentServer, usePlayerControls, usePlayerStore } from '/@/renderer/store';
|
||||||
import { usePlaybackType } from '/@/renderer/store/settings.store';
|
import { useGeneralSettings, usePlaybackType } from '/@/renderer/store/settings.store';
|
||||||
import {
|
import { PlayQueueAddOptions, Play, PlaybackType } from '/@/renderer/types';
|
||||||
PlayQueueAddOptions,
|
|
||||||
Play,
|
|
||||||
PlaybackType,
|
|
||||||
PlayerStatus,
|
|
||||||
PlayerShuffle,
|
|
||||||
} from '/@/renderer/types';
|
|
||||||
import { toast } from '/@/renderer/components/toast/index';
|
import { toast } from '/@/renderer/components/toast/index';
|
||||||
import isElectron from 'is-electron';
|
import isElectron from 'is-electron';
|
||||||
import { nanoid } from 'nanoid/non-secure';
|
import { nanoid } from 'nanoid/non-secure';
|
||||||
@@ -29,6 +23,9 @@ import {
|
|||||||
} from '/@/renderer/features/player/utils';
|
} from '/@/renderer/features/player/utils';
|
||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
|
||||||
|
import { updateSong } from '/@/renderer/features/player/update-remote-song';
|
||||||
|
import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
|
||||||
|
|
||||||
const getRootQueryKey = (itemType: LibraryItem, serverId: string) => {
|
const getRootQueryKey = (itemType: LibraryItem, serverId: string) => {
|
||||||
let queryKey;
|
let queryKey;
|
||||||
@@ -58,7 +55,6 @@ const getRootQueryKey = (itemType: LibraryItem, serverId: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||||
const remote = isElectron() ? window.electron.remote : null;
|
|
||||||
|
|
||||||
const addToQueue = usePlayerStore.getState().actions.addToQueue;
|
const addToQueue = usePlayerStore.getState().actions.addToQueue;
|
||||||
|
|
||||||
@@ -70,6 +66,8 @@ export const useHandlePlayQueueAdd = () => {
|
|||||||
const { play } = usePlayerControls();
|
const { play } = usePlayerControls();
|
||||||
const timeoutIds = useRef<Record<string, ReturnType<typeof setTimeout>> | null>({});
|
const timeoutIds = useRef<Record<string, ReturnType<typeof setTimeout>> | null>({});
|
||||||
|
|
||||||
|
const { doubleClickQueueAll } = useGeneralSettings();
|
||||||
|
|
||||||
const handlePlayQueueAdd = useCallback(
|
const handlePlayQueueAdd = useCallback(
|
||||||
async (options: PlayQueueAddOptions) => {
|
async (options: PlayQueueAddOptions) => {
|
||||||
if (!server) return toast.error({ message: 'No server selected', type: 'error' });
|
if (!server) return toast.error({ message: 'No server selected', type: 'error' });
|
||||||
@@ -126,6 +124,12 @@ export const useHandlePlayQueueAdd = () => {
|
|||||||
} else if (itemType === LibraryItem.SONG) {
|
} else if (itemType === LibraryItem.SONG) {
|
||||||
if (id?.length === 1) {
|
if (id?.length === 1) {
|
||||||
songList = await getSongById({ id: id?.[0], queryClient, server });
|
songList = await getSongById({ id: id?.[0], queryClient, server });
|
||||||
|
} else if (!doubleClickQueueAll && initialSongId) {
|
||||||
|
songList = await getSongById({
|
||||||
|
id: initialSongId,
|
||||||
|
queryClient,
|
||||||
|
server,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
songList = await getSongsByQuery({ query, queryClient, server });
|
songList = await getSongsByQuery({ query, queryClient, server });
|
||||||
}
|
}
|
||||||
@@ -170,14 +174,25 @@ export const useHandlePlayQueueAdd = () => {
|
|||||||
const hadSong = usePlayerStore.getState().queue.default.length > 0;
|
const hadSong = usePlayerStore.getState().queue.default.length > 0;
|
||||||
const playerData = addToQueue({ initialIndex: initialSongIndex, playType, songs });
|
const playerData = addToQueue({ initialIndex: initialSongIndex, playType, songs });
|
||||||
|
|
||||||
|
updateSong(playerData.current.song);
|
||||||
|
|
||||||
if (playbackType === PlaybackType.LOCAL) {
|
if (playbackType === PlaybackType.LOCAL) {
|
||||||
mpvPlayer!.volume(usePlayerStore.getState().volume);
|
mpvPlayer!.volume(usePlayerStore.getState().volume);
|
||||||
|
|
||||||
if (playType === Play.NOW || !hadSong) {
|
if (playType === Play.NOW || !hadSong) {
|
||||||
mpvPlayer!.pause();
|
mpvPlayer!.pause();
|
||||||
mpvPlayer!.setQueue(playerData, false);
|
setQueue(playerData, false);
|
||||||
} else {
|
} else {
|
||||||
mpvPlayer!.setQueueNext(playerData);
|
setQueueNext(playerData);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const player =
|
||||||
|
playerData.current.player === 1
|
||||||
|
? PlayersRef.current?.player1
|
||||||
|
: PlayersRef.current?.player2;
|
||||||
|
const underlying = player?.getInternalPlayer();
|
||||||
|
if (underlying && playType === Play.NOW) {
|
||||||
|
underlying.currentTime = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,17 +202,9 @@ export const useHandlePlayQueueAdd = () => {
|
|||||||
play();
|
play();
|
||||||
}
|
}
|
||||||
|
|
||||||
remote?.updateSong({
|
|
||||||
currentTime: usePlayerStore.getState().current.time,
|
|
||||||
repeat: usePlayerStore.getState().repeat,
|
|
||||||
shuffle: usePlayerStore.getState().shuffle !== PlayerShuffle.NONE,
|
|
||||||
song: playerData.current.song,
|
|
||||||
status: PlayerStatus.PLAYING,
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[play, playbackType, queryClient, server, t],
|
[doubleClickQueueAll, play, playbackType, queryClient, server, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
return handlePlayQueueAdd;
|
return handlePlayQueueAdd;
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { api } from '/@/renderer/api';
|
|||||||
import { ScrobbleResponse, ScrobbleArgs } from '/@/renderer/api/types';
|
import { ScrobbleResponse, ScrobbleArgs } from '/@/renderer/api/types';
|
||||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||||
import { getServerById, useIncrementQueuePlayCount } from '/@/renderer/store';
|
import { getServerById, useIncrementQueuePlayCount } from '/@/renderer/store';
|
||||||
|
import { usePlayEvent } from '/@/renderer/store/event.store';
|
||||||
|
|
||||||
export const useSendScrobble = (options?: MutationOptions) => {
|
export const useSendScrobble = (options?: MutationOptions) => {
|
||||||
const incrementPlayCount = useIncrementQueuePlayCount();
|
const incrementPlayCount = useIncrementQueuePlayCount();
|
||||||
|
const sendPlayEvent = usePlayEvent();
|
||||||
|
|
||||||
return useMutation<
|
return useMutation<
|
||||||
ScrobbleResponse,
|
ScrobbleResponse,
|
||||||
@@ -23,6 +25,7 @@ export const useSendScrobble = (options?: MutationOptions) => {
|
|||||||
// Manually increment the play count for the song in the queue if scrobble was submitted
|
// Manually increment the play count for the song in the queue if scrobble was submitted
|
||||||
if (variables.query.submission) {
|
if (variables.query.submission) {
|
||||||
incrementPlayCount([variables.query.id]);
|
incrementPlayCount([variables.query.id]);
|
||||||
|
sendPlayEvent([variables.query.id]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import isElectron from 'is-electron';
|
||||||
|
import { QueueSong } from '/@/renderer/api/types';
|
||||||
|
|
||||||
|
const remote = isElectron() ? window.electron.remote : null;
|
||||||
|
const mediaSession = navigator.mediaSession;
|
||||||
|
|
||||||
|
export const updateSong = (song: QueueSong | undefined) => {
|
||||||
|
if (mediaSession) {
|
||||||
|
let metadata: MediaMetadata;
|
||||||
|
|
||||||
|
if (song?.id) {
|
||||||
|
let artwork: MediaImage[];
|
||||||
|
|
||||||
|
if (song.imageUrl) {
|
||||||
|
const image300 = song.imageUrl
|
||||||
|
?.replace(/&size=\d+/, '&size=300')
|
||||||
|
.replace(/\?width=\d+/, '?width=300')
|
||||||
|
.replace(/&height=\d+/, '&height=300');
|
||||||
|
|
||||||
|
artwork = [{ sizes: '300x300', src: image300, type: 'image/png' }];
|
||||||
|
} else {
|
||||||
|
artwork = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = new MediaMetadata({
|
||||||
|
album: song.album ?? '',
|
||||||
|
artist: song.artistName,
|
||||||
|
artwork,
|
||||||
|
title: song.name,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
metadata = new MediaMetadata();
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaSession.metadata = metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
remote?.updateSong(song);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user