mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fac1d3fb62 | |||
| 93fbe1f49a | |||
| 59f17a4faa | |||
| d9e41720c8 | |||
| 8452780602 | |||
| 96b5b660fb | |||
| 610138c05c | |||
| 6a619240fa | |||
| b65c972da1 | |||
| 8ec4551b46 | |||
| 21f4a78dd7 | |||
| 61d7e7c390 | |||
| 993841ddbf | |||
| 98b8409592 | |||
| d3480a86c3 | |||
| 3a63ee4b95 | |||
| 876376d65f | |||
| 215abf615d | |||
| afad2843c6 | |||
| 958ab1f31f | |||
| 0ca325aac2 | |||
| 12b66e5fa0 | |||
| 7e78478fbe | |||
| f783a6360e | |||
| 8eb6c6a213 | |||
| ea5f0268cb | |||
| 427272f8c8 | |||
| 40d09404b3 | |||
| f3ee198833 | |||
| 5416d6e596 | |||
| a0639cbd27 | |||
| 790961f29a | |||
| 18027d4292 | |||
| a8b3944c66 | |||
| a00385e78f | |||
| 5e628d96c7 | |||
| ad34d8553e | |||
| a89b6640a9 | |||
| b3b810c62c | |||
| ecef9bea5e | |||
| c7214fc7ce | |||
| 84bcfb6eb9 | |||
| 0ca7a0efc9 | |||
| cb76436a2a | |||
| 88a951e2e7 | |||
| 6f1b78c2d6 | |||
| 107074b240 | |||
| 6e8ca7e035 | |||
| 3c99a662e8 | |||
| fc8110ca79 | |||
| 715f800788 | |||
| 244aee45cd | |||
| c96f5b207d | |||
| 0e8b2aed72 | |||
| f2accd63fd | |||
| c024e975fb | |||
| a3f725b0ef | |||
| f137f487aa | |||
| 8e59514524 | |||
| 7bcfe30a8e | |||
| 8cddbef701 | |||
| 31492fa9ef | |||
| e3946a9413 | |||
| 28c12496f1 | |||
| f8c2ff735b | |||
| 22e4974191 | |||
| 6eecc3c0fd | |||
| b628b684ae | |||
| 4c49e403ab | |||
| 730683fe25 | |||
| 96b4f8dd89 | |||
| f82889e5ec | |||
| 8d8826a9b7 | |||
| 660c9744bf | |||
| 8221af9a8f |
@@ -1,5 +1,5 @@
|
||||
name: Feature request
|
||||
description: Request a feature to be added to Feishin 🎉
|
||||
name: Feature request - NOT ACCEPTING NEW FEATURE REQUESTS
|
||||
description: Feature requests are currently closed. The application is actively being rewritten https://github.com/audioling/audioling.
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: textarea
|
||||
@@ -18,5 +18,3 @@ body:
|
||||
options:
|
||||
- label: 'Yes'
|
||||
required: false
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -27,6 +27,16 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## MAINTENANCE NOTICE
|
||||
|
||||
Feishin is currently undergoing a major rewrite. New feature requests will not be accepted. The rewrite is being actively developed at the [audioling](https://github.com/audioling/audioling) repository.
|
||||
|
||||
Follow the repository or join the discord/matrix server for updates.
|
||||
|
||||
---
|
||||
|
||||
Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
|
||||
|
||||
## Features
|
||||
@@ -49,7 +59,7 @@ Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
|
||||
|
||||
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases). The desktop client is the recommended way to use Feishin. It supports both the MPV and web player backends, as well as includes built-in fetching for lyrics.
|
||||
|
||||
#### MacOS Notes
|
||||
#### macOS Notes
|
||||
|
||||
If you're using a device running macOS 12 (Monterey) or higher, [check here](https://github.com/jeffvli/feishin/issues/104#issuecomment-1553914730) for instructions on how to remove the app from quarantine.
|
||||
|
||||
@@ -74,24 +84,23 @@ docker run --name feishin -p 9180:9180 feishin
|
||||
|
||||
To install via Docker Compose use the following snippit. This also works on Portainer.
|
||||
|
||||
```
|
||||
version: '3'
|
||||
```yaml
|
||||
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
|
||||
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
|
||||
@@ -119,8 +128,16 @@ Feishin supports any music server that implements a [Navidrome](https://www.navi
|
||||
|
||||
- [Navidrome](https://github.com/navidrome/navidrome)
|
||||
- [Jellyfin](https://github.com/jellyfin/jellyfin)
|
||||
- [Funkwhale](https://funkwhale.audio/) - TBD
|
||||
- Subsonic-compatible servers - TBD
|
||||
- Subsonic-compatible servers
|
||||
- [Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)
|
||||
- [Ampache](https://ampache.org)
|
||||
- [Astiga](https://asti.ga/)
|
||||
- [Funkwhale](https://www.funkwhale.audio/)
|
||||
- [Gonic](https://github.com/sentriz/gonic)
|
||||
- [LMS](https://github.com/epoupon/lms)
|
||||
- [Nextcloud Music](https://apps.nextcloud.com/apps/music)
|
||||
- [Supysonic](https://github.com/spl0k/supysonic)
|
||||
- More (?)
|
||||
|
||||
### I have the issue "The SUID sandbox helper binary was found, but is not configured correctly" on Linux
|
||||
|
||||
|
||||
Generated
+168
-108
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.9.0",
|
||||
"version": "0.12.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishin",
|
||||
"version": "0.9.0",
|
||||
"version": "0.12.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -7052,10 +7052,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz",
|
||||
"integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==",
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
@@ -7065,7 +7066,7 @@
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"on-finished": "2.4.1",
|
||||
"qs": "6.11.0",
|
||||
"qs": "6.13.0",
|
||||
"raw-body": "2.5.2",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "1.0.0"
|
||||
@@ -7080,6 +7081,7 @@
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -7089,6 +7091,7 @@
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -7098,6 +7101,7 @@
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -7107,6 +7111,7 @@
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
@@ -7118,7 +7123,8 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bonjour-service": {
|
||||
"version": "1.0.11",
|
||||
@@ -8519,6 +8525,7 @@
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -9382,6 +9389,7 @@
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
@@ -9756,8 +9764,9 @@
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=",
|
||||
"dev": true
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ejs": {
|
||||
"version": "3.1.10",
|
||||
@@ -10217,10 +10226,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -11884,8 +11894,9 @@
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -11972,37 +11983,38 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.19.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
|
||||
"integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==",
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz",
|
||||
"integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.2",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.6.0",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~1.0.2",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.2.0",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"merge-descriptors": "1.0.1",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.7",
|
||||
"path-to-regexp": "0.1.10",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.11.0",
|
||||
"qs": "6.13.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.18.0",
|
||||
"serve-static": "1.15.0",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
@@ -12293,13 +12305,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
|
||||
"integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~1.0.2",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
@@ -12315,6 +12328,7 @@
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -12322,14 +12336,16 @@
|
||||
"node_modules/finalhandler/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
|
||||
"dev": true
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -12489,8 +12505,9 @@
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -13368,6 +13385,7 @@
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "2.0.0",
|
||||
"inherits": "2.0.4",
|
||||
@@ -13384,6 +13402,7 @@
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -13393,6 +13412,7 @@
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -16595,6 +16615,7 @@
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -16716,10 +16737,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
|
||||
"integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=",
|
||||
"dev": true
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
@@ -17454,6 +17479,7 @@
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
@@ -17892,10 +17918,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
|
||||
"integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=",
|
||||
"dev": true
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz",
|
||||
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "4.0.0",
|
||||
@@ -18899,12 +18926,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.11.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
|
||||
"integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.4"
|
||||
"side-channel": "^1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -19003,6 +19031,7 @@
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
||||
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
@@ -19018,6 +19047,7 @@
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -19027,6 +19057,7 @@
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
@@ -20134,10 +20165,11 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
|
||||
"integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==",
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
@@ -20162,6 +20194,7 @@
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -20169,14 +20202,26 @@
|
||||
"node_modules/send/node_modules/debug/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
|
||||
"dev": true
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send/node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -20186,6 +20231,7 @@
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
@@ -20197,13 +20243,15 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -20238,10 +20286,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz",
|
||||
"integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"randombytes": "^2.1.0"
|
||||
}
|
||||
@@ -20307,15 +20356,16 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz",
|
||||
"integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==",
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~1.0.2",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "0.18.0"
|
||||
"send": "0.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
@@ -20374,7 +20424,8 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shallow-clone": {
|
||||
"version": "3.0.1",
|
||||
@@ -21983,6 +22034,7 @@
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
@@ -22374,6 +22426,7 @@
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
@@ -22632,8 +22685,9 @@
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -28915,9 +28969,9 @@
|
||||
}
|
||||
},
|
||||
"body-parser": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz",
|
||||
"integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==",
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"bytes": "3.1.2",
|
||||
@@ -28928,7 +28982,7 @@
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"on-finished": "2.4.1",
|
||||
"qs": "6.11.0",
|
||||
"qs": "6.13.0",
|
||||
"raw-body": "2.5.2",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "1.0.0"
|
||||
@@ -30897,7 +30951,7 @@
|
||||
"ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"dev": true
|
||||
},
|
||||
"ejs": {
|
||||
@@ -31267,9 +31321,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"dev": true
|
||||
},
|
||||
"encoding": {
|
||||
@@ -32425,7 +32479,7 @@
|
||||
"etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"dev": true
|
||||
},
|
||||
"eventemitter3": {
|
||||
@@ -32490,37 +32544,37 @@
|
||||
"dev": true
|
||||
},
|
||||
"express": {
|
||||
"version": "4.19.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
|
||||
"integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==",
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz",
|
||||
"integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.2",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.6.0",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~1.0.2",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.2.0",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"merge-descriptors": "1.0.1",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.7",
|
||||
"path-to-regexp": "0.1.10",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.11.0",
|
||||
"qs": "6.13.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.18.0",
|
||||
"serve-static": "1.15.0",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
@@ -32746,13 +32800,13 @@
|
||||
}
|
||||
},
|
||||
"finalhandler": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
|
||||
"integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~1.0.2",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
@@ -32772,7 +32826,7 @@
|
||||
"ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"dev": true
|
||||
},
|
||||
"statuses": {
|
||||
@@ -32879,7 +32933,7 @@
|
||||
"fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"dev": true
|
||||
},
|
||||
"fs-extra": {
|
||||
@@ -35838,9 +35892,9 @@
|
||||
}
|
||||
},
|
||||
"merge-descriptors": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
|
||||
"integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"dev": true
|
||||
},
|
||||
"merge-stream": {
|
||||
@@ -36684,9 +36738,9 @@
|
||||
}
|
||||
},
|
||||
"path-to-regexp": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
|
||||
"integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=",
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz",
|
||||
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==",
|
||||
"dev": true
|
||||
},
|
||||
"path-type": {
|
||||
@@ -37346,12 +37400,12 @@
|
||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
|
||||
},
|
||||
"qs": {
|
||||
"version": "6.11.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
|
||||
"integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"side-channel": "^1.0.4"
|
||||
"side-channel": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"querystringify": {
|
||||
@@ -38211,9 +38265,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"send": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
|
||||
"integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==",
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"debug": "2.6.9",
|
||||
@@ -38243,7 +38297,7 @@
|
||||
"ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
@@ -38254,6 +38308,12 @@
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"dev": true
|
||||
},
|
||||
"encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
|
||||
"dev": true
|
||||
},
|
||||
"mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
@@ -38294,9 +38354,9 @@
|
||||
}
|
||||
},
|
||||
"serialize-javascript": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz",
|
||||
"integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"randombytes": "^2.1.0"
|
||||
@@ -38359,15 +38419,15 @@
|
||||
}
|
||||
},
|
||||
"serve-static": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz",
|
||||
"integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==",
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"encodeurl": "~1.0.2",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "0.18.0"
|
||||
"send": "0.19.0"
|
||||
}
|
||||
},
|
||||
"set-blocking": {
|
||||
@@ -40077,7 +40137,7 @@
|
||||
"unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"dev": true
|
||||
},
|
||||
"unzip-crx-3": {
|
||||
|
||||
+12
-4
@@ -2,7 +2,7 @@
|
||||
"name": "feishin",
|
||||
"productName": "Feishin",
|
||||
"description": "Feishin music server",
|
||||
"version": "0.9.0",
|
||||
"version": "0.12.0",
|
||||
"scripts": {
|
||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\" \"npm run build:remote\"",
|
||||
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
||||
@@ -309,8 +309,8 @@
|
||||
"@tanstack/react-query-persist-client": "^4.32.1",
|
||||
"@ts-rest/core": "^3.23.0",
|
||||
"@xhayper/discord-rpc": "^1.0.24",
|
||||
"auto-text-size": "^0.2.3",
|
||||
"audiomotion-analyzer": "^4.5.0",
|
||||
"auto-text-size": "^0.2.3",
|
||||
"axios": "^1.6.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
@@ -360,8 +360,16 @@
|
||||
"styled-components": "^6"
|
||||
},
|
||||
"devEngines": {
|
||||
"node": ">=18.x",
|
||||
"npm": ">=7.x"
|
||||
"runtime": {
|
||||
"name": "node",
|
||||
"version": ">=18.x",
|
||||
"onFail": "error"
|
||||
},
|
||||
"packageManager": {
|
||||
"name": "npm",
|
||||
"version": ">=7.x",
|
||||
"onFail": "error"
|
||||
}
|
||||
},
|
||||
"browserslist": [],
|
||||
"electronmon": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.9.0",
|
||||
"version": "0.12.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishin",
|
||||
"version": "0.9.0",
|
||||
"version": "0.12.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.9.0",
|
||||
"version": "0.12.0",
|
||||
"description": "",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
|
||||
+41
-14
@@ -29,7 +29,8 @@
|
||||
"addLast": "přidat poslední",
|
||||
"mute": "ztlumit",
|
||||
"skip_forward": "přeskočit dopředu",
|
||||
"playSimilarSongs": "přehrát podobné skladby"
|
||||
"playSimilarSongs": "přehrát podobné skladby",
|
||||
"viewQueue": "zobrazit frontu"
|
||||
},
|
||||
"setting": {
|
||||
"crossfadeStyle_description": "vyberte způsob prolnutí u přehrávače zvuku",
|
||||
@@ -221,14 +222,14 @@
|
||||
"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",
|
||||
"discordListening_description": "zobrazit stav jako „Poslouchá“ namísto „Hraje“",
|
||||
"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.",
|
||||
"customCssEnable": "povolit vlastní CSS",
|
||||
"customCssEnable_description": "povolit vlastní 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",
|
||||
"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",
|
||||
@@ -248,7 +249,15 @@
|
||||
"artistConfiguration_description": "nastavit, které položky na stránce umělce alba budou zobrazeny a v jakém pořadí",
|
||||
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
||||
"trayEnabled": "zobrazit v oznamovací oblasti",
|
||||
"trayEnabled_description": "zobrazit/skrýt ikonu/nabídku v oznamovací oblasti. pokud je zakázáno, vypne také minimalizaci/ukončení do oznamovací oblasti"
|
||||
"trayEnabled_description": "zobrazit/skrýt ikonu/nabídku v oznamovací oblasti. pokud je zakázáno, vypne také minimalizaci/ukončení do oznamovací oblasti",
|
||||
"translationApiProvider": "poskytovatel api překladů",
|
||||
"translationApiProvider_description": "poskytovatel api pro překlady",
|
||||
"translationApiKey": "klíč api překladů",
|
||||
"translationApiKey_description": "klíč api pro překlady (podporuje pouze koncový bod globální služby)",
|
||||
"translationTargetLanguage": "cílový jazyk překladu",
|
||||
"translationTargetLanguage_description": "cílový jazyk pro překlad",
|
||||
"lastfmApiKey": "klíč API {{lastfm}}",
|
||||
"lastfmApiKey_description": "klíč API pro {{lastfm}}. vyžadováno pro obaly alb"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "upravit $t(entity.playlist_one)",
|
||||
@@ -271,7 +280,8 @@
|
||||
"openIn": {
|
||||
"lastfm": "Otevřít v Last.fm",
|
||||
"musicbrainz": "Otevřít v MusicBrainz"
|
||||
}
|
||||
},
|
||||
"moveToNext": "přesunout na další"
|
||||
},
|
||||
"common": {
|
||||
"backward": "zpátky",
|
||||
@@ -364,7 +374,8 @@
|
||||
"share": "sdílet",
|
||||
"codec": "kodek",
|
||||
"trackPeak": "vrchol skladby",
|
||||
"preview": "náhled"
|
||||
"preview": "náhled",
|
||||
"translation": "překlad"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -380,7 +391,8 @@
|
||||
"autoFitColumns": "automaticky přizpůsobit sloupce",
|
||||
"size": "$t(common.size)",
|
||||
"itemGap": "mezera mezi položkami (px)",
|
||||
"itemSize": "velikost položek (px)"
|
||||
"itemSize": "velikost položek (px)",
|
||||
"followCurrentSong": "následovat aktuální skladbu"
|
||||
},
|
||||
"label": {
|
||||
"releaseDate": "datum vydání",
|
||||
@@ -537,11 +549,14 @@
|
||||
"useImageAspectRatio": "použít poměr stran obrázku",
|
||||
"lyricGap": "mezera textů",
|
||||
"dynamicImageBlur": "velikost rozostření obrázku",
|
||||
"dynamicIsImage": "povolit obrázek na pozadí"
|
||||
"dynamicIsImage": "povolit obrázek na pozadí",
|
||||
"lyricOffset": "posunutí textů (ms)"
|
||||
},
|
||||
"upNext": "další",
|
||||
"lyrics": "texty",
|
||||
"related": "související"
|
||||
"related": "související",
|
||||
"visualizer": "vizualizér",
|
||||
"noLyrics": "nenalezeny žádné texty"
|
||||
},
|
||||
"appMenu": {
|
||||
"selectServer": "vybrat server",
|
||||
@@ -576,7 +591,8 @@
|
||||
"shareItem": "sdílet položku",
|
||||
"playSimilarSongs": "$t(player.playSimilarSongs)",
|
||||
"download": "stáhnout",
|
||||
"playShuffled": "$t(player.shuffle)"
|
||||
"playShuffled": "$t(player.shuffle)",
|
||||
"moveToNext": "$t(action.moveToNext)"
|
||||
},
|
||||
"home": {
|
||||
"mostPlayed": "nejpřehrávanější",
|
||||
@@ -644,6 +660,14 @@
|
||||
},
|
||||
"playlist": {
|
||||
"reorder": "změna pořadí povolena pouze při řazení podle id"
|
||||
},
|
||||
"manageServers": {
|
||||
"url": "URL",
|
||||
"username": "uživatelské jméno",
|
||||
"editServerDetailsTooltip": "upravit podrobnosti o serveru",
|
||||
"removeServer": "odstranit server",
|
||||
"serverDetails": "podrobnosti o serveru",
|
||||
"title": "správa serverů"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -755,6 +779,9 @@
|
||||
"trackWithCount_other": "{{count}} skladeb",
|
||||
"play_one": "{{count}} přehrání",
|
||||
"play_few": "{{count}} přehrání",
|
||||
"play_other": "{{count}} přehrání"
|
||||
"play_other": "{{count}} přehrání",
|
||||
"song_one": "píseň",
|
||||
"song_few": "písničky",
|
||||
"song_other": "písní"
|
||||
}
|
||||
}
|
||||
|
||||
+58
-18
@@ -107,7 +107,13 @@
|
||||
"reload": "Neu Laden",
|
||||
"mbid": "MusicBrainz ID",
|
||||
"close": "schliessen",
|
||||
"share": "Teilen"
|
||||
"share": "Teilen",
|
||||
"translation": "Übersetzung",
|
||||
"trackGain": "Track-Pegelverstärkung",
|
||||
"trackPeak": "Track-Spitzenpegel",
|
||||
"codec": "Codec",
|
||||
"albumPeak": "Album-Spitzenpegel",
|
||||
"albumGain": "Album-Pegelverstärkung"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden",
|
||||
@@ -179,13 +185,13 @@
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
"title": "Lösche $t(entity.playlist_one)",
|
||||
"title": "$t(entity.playlist_one) löschen",
|
||||
"success": "$t(entity.playlist_one) erfolgreich gelöscht",
|
||||
"input_confirm": "Geben Sie zur Bestätigung den Namen von $t(entity.playlist_one) ein"
|
||||
},
|
||||
"createPlaylist": {
|
||||
"input_description": "$t(common.description)",
|
||||
"title": "Erstellen $t(entity.playlist_one)",
|
||||
"title": "$t(entity.playlist_one) erstellen",
|
||||
"input_public": "öffentlich",
|
||||
"success": "$t(entity.playlist_one) erfolgreich erstellt",
|
||||
"input_name": "$t(common.name)",
|
||||
@@ -267,7 +273,9 @@
|
||||
"trackWithCount_other": "{{count}} Tracks",
|
||||
"smartPlaylist": "Smart $t(entity.playlist_one)",
|
||||
"play_one": "{{count}} Wiedergabe",
|
||||
"play_other": "{{count}} Wiedergaben"
|
||||
"play_other": "{{count}} Wiedergaben",
|
||||
"song_one": "Lied",
|
||||
"song_other": "Lieder"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -348,11 +356,13 @@
|
||||
"unsynchronized": "nicht synchronisiert",
|
||||
"lyricAlignment": "Songtext-Ausrichtung",
|
||||
"useImageAspectRatio": "Bildseitenverhältnis verwenden",
|
||||
"lyricGap": "Songtext-Lücke"
|
||||
"lyricGap": "Songtext-Lücke",
|
||||
"dynamicIsImage": "Hintergrundbild aktivieren"
|
||||
},
|
||||
"upNext": "als nächstes",
|
||||
"lyrics": "Songtexte",
|
||||
"related": "Ähnliche"
|
||||
"related": "Ähnliche",
|
||||
"noLyrics": "Keine Liedtexte gefunden"
|
||||
},
|
||||
"appMenu": {
|
||||
"selectServer": "Server auswählen",
|
||||
@@ -375,18 +385,19 @@
|
||||
},
|
||||
"albumDetail": {
|
||||
"moreFromArtist": "Mehr von diesem $t(entity.artist_one)",
|
||||
"moreFromGeneric": "Mehr von {{item}}"
|
||||
"moreFromGeneric": "Mehr von {{item}}",
|
||||
"released": "erschienen"
|
||||
},
|
||||
"globalSearch": {
|
||||
"commands": {
|
||||
"serverCommands": "Serverbefehle",
|
||||
"goToPage": "Gehe zur Seite",
|
||||
"searchFor": "Suche nach {{query}}"
|
||||
"searchFor": "Nach {{query}} suchen"
|
||||
},
|
||||
"title": "Befehle"
|
||||
},
|
||||
"contextMenu": {
|
||||
"numberSelected": "{{count}} Ausgewählte",
|
||||
"numberSelected": "{{count}} ausgewählt",
|
||||
"addToPlaylist": "$t(action.addToPlaylist)",
|
||||
"addToFavorites": "$t(action.addToFavorites)",
|
||||
"setRating": "$t(action.setRating)",
|
||||
@@ -401,7 +412,10 @@
|
||||
"addLast": "$t(player.addLast)",
|
||||
"addFavorite": "$t(action.addToFavorites)",
|
||||
"play": "$t(player.play)",
|
||||
"removeFromQueue": "$t(action.removeFromQueue)"
|
||||
"removeFromQueue": "$t(action.removeFromQueue)",
|
||||
"playShuffled": "$t(player.shuffle)",
|
||||
"download": "Download",
|
||||
"playSimilarSongs": "$t(player.playSimilarSongs)"
|
||||
},
|
||||
"sidebar": {
|
||||
"nowPlaying": "läuft gerade",
|
||||
@@ -414,34 +428,59 @@
|
||||
"settings": "$t(common.setting_other)",
|
||||
"home": "$t(common.home)",
|
||||
"artists": "$t(entity.artist_other)",
|
||||
"albumArtists": "$t(entity.albumArtist_other)"
|
||||
"albumArtists": "$t(entity.albumArtist_other)",
|
||||
"shared": "$t(entity.playlist_other) geteilt"
|
||||
},
|
||||
"setting": {
|
||||
"playbackTab": "Wiedergabe",
|
||||
"generalTab": "allgemein",
|
||||
"generalTab": "Allgemein",
|
||||
"hotkeysTab": "Kurzbefehle",
|
||||
"windowTab": "Fenster"
|
||||
"windowTab": "Fenster",
|
||||
"advanced": "Erweitert"
|
||||
},
|
||||
"albumArtistList": {
|
||||
"title": "$t(entity.albumArtist_other)"
|
||||
},
|
||||
"genreList": {
|
||||
"title": "$t(entity.genre_other)"
|
||||
"title": "$t(entity.genre_other)",
|
||||
"showTracks": "$t(entity.genre_one) $t(entity.track_other) anzeigen",
|
||||
"showAlbums": "$t(entity.genre_one) $t(entity.album_other) anzeigen"
|
||||
},
|
||||
"trackList": {
|
||||
"title": "$t(entity.track_other)"
|
||||
"title": "$t(entity.track_other)",
|
||||
"artistTracks": "Tracks von {{artist}}",
|
||||
"genreTracks": "\"{{genre}}\" $t(entity.track_other)"
|
||||
},
|
||||
"playlistList": {
|
||||
"title": "$t(entity.playlist_other)"
|
||||
},
|
||||
"albumList": {
|
||||
"title": "$t(entity.album_other)"
|
||||
"title": "$t(entity.album_other)",
|
||||
"artistAlbums": "Alben von {{artist}}",
|
||||
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)"
|
||||
},
|
||||
"albumArtistDetail": {
|
||||
"about": "Über {{artist}}",
|
||||
"appearsOn": "erscheint auf",
|
||||
"recentReleases": "Kürzliche Veröffentlichungen",
|
||||
"viewDiscography": "Diskographie ansehen"
|
||||
"viewDiscography": "Diskographie ansehen",
|
||||
"viewAllTracks": "Alle $t(entity.track_other) ansehen",
|
||||
"topSongsFrom": "Toplieder von {{title}}",
|
||||
"viewAll": "Alles ansehen",
|
||||
"topSongs": "Toplieder"
|
||||
},
|
||||
"manageServers": {
|
||||
"title": "Servers verwalten",
|
||||
"editServerDetailsTooltip": "Serverdetails editieren",
|
||||
"removeServer": "Server entfernen",
|
||||
"url": "URL",
|
||||
"serverDetails": "Serverdetails",
|
||||
"username": "Benutzername"
|
||||
},
|
||||
"itemDetail": {
|
||||
"copyPath": "Pfad in Zwischenablage kopieren",
|
||||
"copiedPath": "Pfad erfolgreich kopiert",
|
||||
"openFile": "Track im Dateiexplorer anzeigen"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -473,7 +512,8 @@
|
||||
"pause": "Pause",
|
||||
"unfavorite": "Aus Favoriten entfernen",
|
||||
"skip_forward": "Vorspulen",
|
||||
"skip": "Überspringen"
|
||||
"skip": "Überspringen",
|
||||
"playSimilarSongs": "Ähnliche Lieder abspielen"
|
||||
},
|
||||
"setting": {
|
||||
"audioDevice_description": "Wählen Sie das Audiogerät aus, das für die Wiedergabe verwendet werden soll (nur Webplayer).",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"deselectAll": "deselect all",
|
||||
"editPlaylist": "edit $t(entity.playlist_one)",
|
||||
"goToPage": "go to page",
|
||||
"moveToNext": "move to next",
|
||||
"moveToBottom": "move to bottom",
|
||||
"moveToTop": "move to top",
|
||||
"refresh": "$t(common.refresh)",
|
||||
@@ -109,6 +110,7 @@
|
||||
"trackNumber": "track",
|
||||
"trackGain": "track gain",
|
||||
"trackPeak": "track peak",
|
||||
"translation": "translation",
|
||||
"unknown": "unknown",
|
||||
"version": "version",
|
||||
"year": "year",
|
||||
@@ -146,6 +148,8 @@
|
||||
"smartPlaylist": "smart $t(entity.playlist_one)",
|
||||
"track_one": "track",
|
||||
"track_other": "tracks",
|
||||
"song_one": "song",
|
||||
"song_other": "songs",
|
||||
"trackWithCount_one": "{{count}} track",
|
||||
"trackWithCount_other": "{{count}} tracks"
|
||||
},
|
||||
@@ -314,6 +318,14 @@
|
||||
"settings": "$t(common.setting_other)",
|
||||
"version": "version {{version}}"
|
||||
},
|
||||
"manageServers": {
|
||||
"title": "manage servers",
|
||||
"serverDetails": "server details",
|
||||
"url": "URL",
|
||||
"username": "username",
|
||||
"editServerDetailsTooltip": "edit server details",
|
||||
"removeServer": "remove server"
|
||||
},
|
||||
"contextMenu": {
|
||||
"addFavorite": "$t(action.addToFavorites)",
|
||||
"addLast": "$t(player.addLast)",
|
||||
@@ -324,6 +336,7 @@
|
||||
"deletePlaylist": "$t(action.deletePlaylist)",
|
||||
"deselectAll": "$t(action.deselectAll)",
|
||||
"download": "download",
|
||||
"moveToNext": "$t(action.moveToNext)",
|
||||
"moveToBottom": "$t(action.moveToBottom)",
|
||||
"moveToTop": "$t(action.moveToTop)",
|
||||
"numberSelected": "{{count}} selected",
|
||||
@@ -344,6 +357,7 @@
|
||||
"dynamicIsImage": "enable background image",
|
||||
"followCurrentLyric": "follow current lyric",
|
||||
"lyricAlignment": "lyric alignment",
|
||||
"lyricOffset": "lyrics offset (ms)",
|
||||
"lyricGap": "lyric gap",
|
||||
"lyricSize": "lyric size",
|
||||
"opacity": "opacity",
|
||||
@@ -355,7 +369,9 @@
|
||||
},
|
||||
"lyrics": "lyrics",
|
||||
"related": "related",
|
||||
"upNext": "up next"
|
||||
"upNext": "up next",
|
||||
"visualizer": "visualizer",
|
||||
"noLyrics": "no lyrics found"
|
||||
},
|
||||
"genreList": {
|
||||
"showAlbums": "show $t(entity.genre_one) $t(entity.album_other)",
|
||||
@@ -447,7 +463,8 @@
|
||||
"stop": "stop",
|
||||
"toggleFullscreenPlayer": "toggle fullscreen player",
|
||||
"unfavorite": "unfavorite",
|
||||
"pause": "pause"
|
||||
"pause": "pause",
|
||||
"viewQueue": "view queue"
|
||||
},
|
||||
"setting": {
|
||||
"accentColor": "accent color",
|
||||
@@ -493,7 +510,7 @@
|
||||
"discordIdleStatus": "show rich presence idle status",
|
||||
"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",
|
||||
"discordListening_description": "show status as listening instead of playing",
|
||||
"discordRichPresence": "{{discord}} rich presence",
|
||||
"discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}} ",
|
||||
"discordUpdateInterval": "{{discord}} rich presence update interval",
|
||||
@@ -565,6 +582,8 @@
|
||||
"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_description": "sets the language for the application ($t(common.restartRequired))",
|
||||
"lastfmApiKey": "{{lastfm}} API key",
|
||||
"lastfmApiKey_description": "the API key for {{lastfm}}. required for cover art",
|
||||
"lyricFetch": "fetch lyrics from the internet",
|
||||
"lyricFetch_description": "fetch lyrics from various internet sources",
|
||||
"lyricFetchProvider": "providers to fetch lyrics from",
|
||||
@@ -653,6 +672,12 @@
|
||||
"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",
|
||||
"translationApiProvider": "translation api provider",
|
||||
"translationApiProvider_description": "api provider for translation",
|
||||
"translationApiKey": "translation api key",
|
||||
"translationApiKey_description": "api key for translation (Support global service endpoint only)",
|
||||
"translationTargetLanguage": "translation target language",
|
||||
"translationTargetLanguage_description": "target language for translation",
|
||||
"trayEnabled": "show tray",
|
||||
"trayEnabled_description": "show/hide tray icon/menu. if disabled, also disables minimize/exit to tray",
|
||||
"useSystemTheme": "use system theme",
|
||||
@@ -698,6 +723,7 @@
|
||||
"config": {
|
||||
"general": {
|
||||
"autoFitColumns": "auto fit columns",
|
||||
"followCurrentSong": "follow current song",
|
||||
"displayType": "display type",
|
||||
"gap": "$t(common.gap)",
|
||||
"itemGap": "item gap (px)",
|
||||
|
||||
+37
-10
@@ -29,7 +29,8 @@
|
||||
"mute": "silencio",
|
||||
"skip_forward": "saltar hacia delante",
|
||||
"pause": "pausa",
|
||||
"playSimilarSongs": "Reproducir canciones similares"
|
||||
"playSimilarSongs": "Reproducir canciones similares",
|
||||
"viewQueue": "ver cola"
|
||||
},
|
||||
"setting": {
|
||||
"crossfadeStyle_description": "selecciona el estilo de crossfade a usar por el reproductor de audio",
|
||||
@@ -220,7 +221,7 @@
|
||||
"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_description": "mostrar el estado como escuchando en lugar de jugando",
|
||||
"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",
|
||||
@@ -248,7 +249,15 @@
|
||||
"artistConfiguration_description": "Configurar qué elementos se muestran y en qué orden en la página del artista del álbum",
|
||||
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
||||
"trayEnabled": "Mostrar en el área de notificación",
|
||||
"trayEnabled_description": "mostrar/ocultar el icono/menú del área de notificación. si está deshabilitado, también deshabilita minimizar/salir a la bandeja"
|
||||
"trayEnabled_description": "mostrar/ocultar el icono/menú del área de notificación. si está deshabilitado, también deshabilita minimizar/salir a la bandeja",
|
||||
"translationApiProvider": "Proveedor de API de traducción",
|
||||
"translationApiProvider_description": "Proveedor de API para traducción",
|
||||
"translationApiKey": "clave api de traducción",
|
||||
"translationApiKey_description": "Clave API para la traducción (solo para el punto final del servicio global)",
|
||||
"translationTargetLanguage": "idioma final de la traducción",
|
||||
"translationTargetLanguage_description": "lengua de destino de la traducción",
|
||||
"lastfmApiKey_description": "la clave API para {{lastfm}}. Requerida para la portada",
|
||||
"lastfmApiKey": "Clave API para {{lastfm}}"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "editar $t(entity.playlist_one)",
|
||||
@@ -271,7 +280,8 @@
|
||||
"openIn": {
|
||||
"lastfm": "Abrir en Last.fm",
|
||||
"musicbrainz": "Abrir en MusicBrainz"
|
||||
}
|
||||
},
|
||||
"moveToNext": "pasar al siguiente"
|
||||
},
|
||||
"common": {
|
||||
"backward": "hacia atrás",
|
||||
@@ -364,7 +374,8 @@
|
||||
"reload": "Recargar",
|
||||
"share": "Compartir",
|
||||
"trackGain": "Ganancia de pista",
|
||||
"preview": "Vista previa"
|
||||
"preview": "Vista previa",
|
||||
"translation": "traducción"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
|
||||
@@ -482,7 +493,8 @@
|
||||
"showDetails": "Obtener información",
|
||||
"playSimilarSongs": "$t(player.playSimilarSongs)",
|
||||
"download": "descargar",
|
||||
"playShuffled": "$t(player.shuffle)"
|
||||
"playShuffled": "$t(player.shuffle)",
|
||||
"moveToNext": "$t(action.moveToNext)"
|
||||
},
|
||||
"home": {
|
||||
"mostPlayed": "más reproducidos",
|
||||
@@ -506,10 +518,13 @@
|
||||
"showLyricMatch": "mostrar coincidencia de letras",
|
||||
"lyricGap": "desfase de letra",
|
||||
"dynamicImageBlur": "tamaño de desenfoque de imagen",
|
||||
"dynamicIsImage": "habilitar imagen de fondo"
|
||||
"dynamicIsImage": "habilitar imagen de fondo",
|
||||
"lyricOffset": "compensación de letras (ms)"
|
||||
},
|
||||
"lyrics": "letras",
|
||||
"related": "relacionado"
|
||||
"related": "relacionado",
|
||||
"visualizer": "visualizador",
|
||||
"noLyrics": "sin letras"
|
||||
},
|
||||
"albumDetail": {
|
||||
"moreFromArtist": "más de este $t(entity.artist_one)",
|
||||
@@ -570,6 +585,14 @@
|
||||
},
|
||||
"playlist": {
|
||||
"reorder": "la reordenación solo se activa al ordenar por id"
|
||||
},
|
||||
"manageServers": {
|
||||
"removeServer": "eliminar servidor",
|
||||
"title": "administrar servidores",
|
||||
"serverDetails": "detalles del servidor",
|
||||
"username": "nombre de usuario",
|
||||
"editServerDetailsTooltip": "editar detalles del servidor",
|
||||
"url": "URL"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -697,7 +720,8 @@
|
||||
"size": "$t(common.size)",
|
||||
"displayType": "tipo de visualización",
|
||||
"itemGap": "espacio entre elementos (px)",
|
||||
"itemSize": "tamaño del elemento (px)"
|
||||
"itemSize": "tamaño del elemento (px)",
|
||||
"followCurrentSong": "seguir la canción actual"
|
||||
},
|
||||
"view": {
|
||||
"card": "tarjeta",
|
||||
@@ -755,6 +779,9 @@
|
||||
"trackWithCount_other": "{{count}} pistas",
|
||||
"play_one": "Reproducir {{count}}",
|
||||
"play_many": "Reproducir {{count}}",
|
||||
"play_other": "Reproducir {{count}}"
|
||||
"play_other": "Reproducir {{count}}",
|
||||
"song_one": "canción",
|
||||
"song_many": "canciones",
|
||||
"song_other": "canciones"
|
||||
}
|
||||
}
|
||||
|
||||
+75
-24
@@ -11,7 +11,7 @@
|
||||
"skip_back": "reculer",
|
||||
"favorite": "favori",
|
||||
"next": "suivant",
|
||||
"shuffle": "aléatoire",
|
||||
"shuffle": "lecture aléatoire",
|
||||
"playbackFetchNoResults": "aucune chansons trouvées",
|
||||
"playbackFetchInProgress": "chargement des chansons…",
|
||||
"addNext": "ajouter ensuite",
|
||||
@@ -29,13 +29,14 @@
|
||||
"skip_forward": "avancer",
|
||||
"pause": "pause",
|
||||
"unfavorite": "retirer des favoris",
|
||||
"playSimilarSongs": "jouer des chansons similaires"
|
||||
"playSimilarSongs": "jouer des chansons similaires",
|
||||
"viewQueue": "voir la file d'attente"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "éditer $t(entity.playlist_one)",
|
||||
"goToPage": "aller à la page",
|
||||
"moveToTop": "déplacer en haut",
|
||||
"clearQueue": "effacer la liste de lecture",
|
||||
"clearQueue": "vider la file d'attente",
|
||||
"addToFavorites": "ajouter aux $t(entity.favorite_other)",
|
||||
"addToPlaylist": "ajouter à $t(entity.playlist_one)",
|
||||
"createPlaylist": "créer $t(entity.playlist_one)",
|
||||
@@ -65,7 +66,7 @@
|
||||
"edit": "éditer",
|
||||
"favorite": "favoris",
|
||||
"left": "gauche",
|
||||
"save": "sauvegarder",
|
||||
"save": "enregistrer",
|
||||
"right": "droite",
|
||||
"currentSong": "$t(entity.track_one) actuelle",
|
||||
"collapse": "réduire",
|
||||
@@ -92,7 +93,7 @@
|
||||
"no": "non",
|
||||
"owner": "propriétaire",
|
||||
"enable": "activer",
|
||||
"clear": "effacer",
|
||||
"clear": "vider",
|
||||
"forward": "avancer",
|
||||
"delete": "supprimer",
|
||||
"cancel": "annuler",
|
||||
@@ -106,7 +107,7 @@
|
||||
"filters": "filtres",
|
||||
"create": "créer",
|
||||
"bitrate": "bitrate",
|
||||
"saveAndReplace": "sauvegarder et remplacer",
|
||||
"saveAndReplace": "enregistrer et remplacer",
|
||||
"action_one": "action",
|
||||
"action_many": "actions",
|
||||
"action_other": "actions",
|
||||
@@ -124,12 +125,12 @@
|
||||
"none": "aucun",
|
||||
"menu": "menu",
|
||||
"restartRequired": "redémarrage requis",
|
||||
"previousSong": "précédant $t(entity.track_one)",
|
||||
"previousSong": "$t(entity.track_one) précédente",
|
||||
"noResultsFromQuery": "la requête n'a retourné aucun résultat",
|
||||
"quit": "quitter",
|
||||
"expand": "étendre",
|
||||
"search": "recherche",
|
||||
"saveAs": "sauvegarder en tant que",
|
||||
"saveAs": "enregistrer en tant que",
|
||||
"disc": "disque",
|
||||
"yes": "oui",
|
||||
"random": "aléatoire",
|
||||
@@ -145,35 +146,36 @@
|
||||
"reload": "recharger",
|
||||
"trackGain": "gain de la piste",
|
||||
"trackPeak": "crête de la piste",
|
||||
"codec": "codec"
|
||||
"codec": "codec",
|
||||
"translation": "traduction"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
|
||||
"systemFontError": "une erreur s’est produite lors de la tentative d’obtenir les polices système",
|
||||
"playbackError": "une erreur s'est produite lors de la tentative de lecture du média",
|
||||
"endpointNotImplementedError": "endpoint {{endpoint}} n'est pas implémenté pour {{serverType}}",
|
||||
"endpointNotImplementedError": "l'endpoint {{endpoint}} n'est pas implémenté pour {{serverType}}",
|
||||
"remotePortError": "une erreur s'est produite lors de la tentative de définir le port du serveur distant",
|
||||
"serverRequired": "serveur requis",
|
||||
"authenticationFailed": "l'authentification à échoué",
|
||||
"authenticationFailed": "l'authentification a échoué",
|
||||
"apiRouteError": "incapable d’acheminer la demande",
|
||||
"genericError": "une erreur s'est produite",
|
||||
"credentialsRequired": "identifiants requis",
|
||||
"sessionExpiredError": "votre session a expiré",
|
||||
"remoteEnableError": "une erreur s'est produite lors de la tentative de $t(common.enable) le serveur distant",
|
||||
"localFontAccessDenied": "accès refusé aux polices locales",
|
||||
"serverNotSelectedError": "aucun serveur sélectionner",
|
||||
"serverNotSelectedError": "aucun serveur sélectionné",
|
||||
"remoteDisableError": "une erreur s'est produite lors de la tentative de $t(common.disable) le serveur distant",
|
||||
"mpvRequired": "MPV requis",
|
||||
"audioDeviceFetchError": "une erreur s’est produite lors de la tentative d’obtenir les périphériques audio",
|
||||
"invalidServer": "serveur invalide",
|
||||
"loginRateError": "trop de tentative de connexion, merci d'essayer dans quelque secondes",
|
||||
"loginRateError": "trop de tentative de connexion, merci de réessayer dans quelques 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": {
|
||||
"mostPlayed": "plus joués",
|
||||
"playCount": "nombre d'écoutes",
|
||||
"playCount": "nombre d'écoute",
|
||||
"isCompilation": "est une compilation",
|
||||
"recentlyPlayed": "récemment joué",
|
||||
"isRated": "est noté",
|
||||
@@ -190,7 +192,7 @@
|
||||
"path": "chemin",
|
||||
"favorited": "favoris",
|
||||
"isRecentlyPlayed": "est récemment joué",
|
||||
"isFavorited": "est favoris",
|
||||
"isFavorited": "est favori",
|
||||
"bpm": "bpm",
|
||||
"releaseYear": "année de sortie",
|
||||
"disc": "disque",
|
||||
@@ -198,7 +200,7 @@
|
||||
"songCount": "nombre de chansons",
|
||||
"duration": "durée",
|
||||
"random": "aléatoire",
|
||||
"lastPlayed": "dernière joué",
|
||||
"lastPlayed": "dernier joué",
|
||||
"toYear": "à l'année",
|
||||
"fromYear": "depuis l'année",
|
||||
"criticRating": "note des critiques",
|
||||
@@ -244,11 +246,14 @@
|
||||
"lyricSize": "Taille des paroles",
|
||||
"lyricGap": "espacement des lettres",
|
||||
"dynamicIsImage": "activer l'image d'arrière-plan",
|
||||
"dynamicImageBlur": "intensité de flou sur image d'arrière-plan"
|
||||
"dynamicImageBlur": "intensité de flou sur image d'arrière-plan",
|
||||
"lyricOffset": "paroles décalées (ms)"
|
||||
},
|
||||
"upNext": "à suivre",
|
||||
"lyrics": "paroles",
|
||||
"related": "similaire"
|
||||
"related": "similaire",
|
||||
"visualizer": "visualisateur",
|
||||
"noLyrics": "aucune parole trouvée"
|
||||
},
|
||||
"appMenu": {
|
||||
"selectServer": "sélectionner le serveur",
|
||||
@@ -271,7 +276,8 @@
|
||||
},
|
||||
"albumDetail": {
|
||||
"moreFromArtist": "plus de $t(entity.artist_one)",
|
||||
"moreFromGeneric": "plus de {{item}}"
|
||||
"moreFromGeneric": "plus de {{item}}",
|
||||
"released": "publié"
|
||||
},
|
||||
"setting": {
|
||||
"generalTab": "général",
|
||||
@@ -308,7 +314,8 @@
|
||||
"shareItem": "partager un élément",
|
||||
"playSimilarSongs": "$t(player.playSimilarSongs)",
|
||||
"showDetails": "obtenir des informations",
|
||||
"download": "télécharger"
|
||||
"download": "télécharger",
|
||||
"playShuffled": "$t(player.shuffle)"
|
||||
},
|
||||
"albumArtistList": {
|
||||
"title": "$t(entity.albumArtist_other)"
|
||||
@@ -346,6 +353,17 @@
|
||||
"copyPath": "copier le chemin dans le presse-papiers",
|
||||
"openFile": "afficher la piste dans le gestionnaire de fichiers",
|
||||
"copiedPath": "chemin copié avec succès"
|
||||
},
|
||||
"playlist": {
|
||||
"reorder": "le tri n'est possible que lorsque l'on trie par identifiant"
|
||||
},
|
||||
"manageServers": {
|
||||
"serverDetails": "détails du serveur",
|
||||
"removeServer": "supprimer le serveur",
|
||||
"url": "URL du serveur",
|
||||
"title": "gérer les serveurs",
|
||||
"username": "nom d'utilisateur",
|
||||
"editServerDetailsTooltip": "modifier les détails du serveur"
|
||||
}
|
||||
},
|
||||
"setting": {
|
||||
@@ -546,7 +564,32 @@
|
||||
"webAudio": "utiliser l'audio web",
|
||||
"transcodeBitrate": "débit binaire du transcodage",
|
||||
"transcodeFormat": "format de transcodage",
|
||||
"webAudio_description": "utiliser l'audio web. cela permet d'utiliser des fonctions avancées comme le replaygain. désactivez si vous rencontrez d'autres problèmes"
|
||||
"webAudio_description": "utiliser l'audio web. cela permet d'utiliser des fonctions avancées comme le replaygain. désactivez si vous rencontrez d'autres problèmes",
|
||||
"artistConfiguration": "page de configuration de l'artiste de l'album",
|
||||
"artistConfiguration_description": "configurer les éléments et l'ordre à afficher, sur la page de l'artiste de l'album",
|
||||
"doubleClickBehavior": "mettre en file d'attente toutes les pistes recherchées lors d'un double clic",
|
||||
"contextMenu": "configuration du menu contexte (clic droit)",
|
||||
"contextMenu_description": "permet de masquer les éléments qui s'affichent dans le menu lorsque vous cliquez avec le bouton droit de la souris sur un élément. les éléments qui ne sont pas cochés seront masqués",
|
||||
"albumBackground": "image d'arrière-plan de l'album",
|
||||
"albumBackground_description": "ajoute une image d'arrière-plan pour les pages de l'album contenant les illustrations de l'album",
|
||||
"albumBackgroundBlur_description": "ajuste le niveau de flou appliqué à l'image d'arrière-plan de l'album",
|
||||
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
||||
"playerbarOpenDrawer": "basculement plein écran de la barre de lecteur",
|
||||
"playerbarOpenDrawer_description": "permet de cliquer sur la barre du lecteur pour ouvrir le lecteur plein écran",
|
||||
"translationApiProvider": "fournisseur d'api de traduction",
|
||||
"discordListening": "afficher le statut d'écoute",
|
||||
"discordListening_description": "afficher le statut comme étant en écoute au lieu de lecture",
|
||||
"translationApiKey_description": "clé api à utiliser pour traduire les paroles (ne prend en charge que les points de terminaison de service globaux)",
|
||||
"translationTargetLanguage": "traduction langue cible",
|
||||
"trayEnabled": "montrer le plateau",
|
||||
"translationApiProvider_description": "le fournisseur d'api à utiliser pour la traduction des paroles",
|
||||
"customCss_description": "contenu css personnalisé. Remarque : le contenu et les URL distantes sont des propriétés non autorisées. Un aperçu de votre contenu est affiché ci-dessous. Des champs supplémentaires que vous n'avez pas définis sont présents en raison de la vérification.",
|
||||
"translationApiKey": "clé api de traduction",
|
||||
"translationTargetLanguage_description": "langue cible pour la traduction des paroles",
|
||||
"transcodeNote": "prend effet après 1 (web) - 2 (mpv) chansons",
|
||||
"trayEnabled_description": "afficher ou masquer l'icône et le menu de la barre d'état système. si désactivé, désactive également la réduction et la sortie vers la barre d'état système",
|
||||
"doubleClickBehavior_description": "si vrai, toutes les pistes correspondantes dans une recherche de piste seront mises en file d'attente. sinon, seule celle sur laquelle vous avez cliqué sera mise en file d'attente",
|
||||
"albumBackgroundBlur": "taille du flou de l'image d'arrière-plan de l'album"
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
@@ -591,7 +634,8 @@
|
||||
},
|
||||
"editPlaylist": {
|
||||
"title": "modifier $t(entity.playlist_one)",
|
||||
"publicJellyfinNote": "Jellyfin n'indique pas si une playlist est publique ou non. Si vous souhaitez que cette playlist reste publique, veuillez sélectionner l'entrée suivante"
|
||||
"publicJellyfinNote": "Jellyfin n'indique pas si une playlist est publique ou non. Si vous souhaitez que cette playlist reste publique, veuillez sélectionner l'entrée suivante",
|
||||
"success": "$t(entity.playlist_one) mis à jour avec succès"
|
||||
},
|
||||
"lyricSearch": {
|
||||
"title": "rechercher parole",
|
||||
@@ -653,7 +697,13 @@
|
||||
"genreWithCount_other": "{{count}} genres",
|
||||
"trackWithCount_one": "{{count}} piste",
|
||||
"trackWithCount_many": "{{count}} pistes",
|
||||
"trackWithCount_other": "{{count}} pistes"
|
||||
"trackWithCount_other": "{{count}} pistes",
|
||||
"play_one": "{{count}} écouter",
|
||||
"play_many": "{{count}} écoute",
|
||||
"play_other": "{{count}} écoute",
|
||||
"song_one": "chanson",
|
||||
"song_many": "chansons",
|
||||
"song_other": "chansons"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -664,7 +714,8 @@
|
||||
"gap": "$t(common.gap)",
|
||||
"size": "$t(common.size)",
|
||||
"itemGap": "écart entre les éléments (en pixel)",
|
||||
"itemSize": "taille des élements (en pixel)"
|
||||
"itemSize": "taille des élements (en pixel)",
|
||||
"followCurrentSong": "suivre la chanson actuelle"
|
||||
},
|
||||
"view": {
|
||||
"table": "liste",
|
||||
|
||||
+273
-1
@@ -9,6 +9,278 @@
|
||||
"editPlaylist": "$t(entity.playlist_one) 편집",
|
||||
"goToPage": "페이지 이동",
|
||||
"moveToBottom": "맨 아래로 이동",
|
||||
"moveToTop": "맨 위로 이동"
|
||||
"moveToTop": "맨 위로 이동",
|
||||
"moveToNext": "다음으로 이동",
|
||||
"removeFromQueue": "대기열에서 제거",
|
||||
"refresh": "$t(common.refresh)",
|
||||
"removeFromFavorites": "$t(entity.favorite_other)에서 제거",
|
||||
"removeFromPlaylist": "$t(entity.playlist_one)에서 제거",
|
||||
"openIn": {
|
||||
"musicbrainz": "MusicBrainz에서 보기",
|
||||
"lastfm": "Last.fm에서 보기"
|
||||
},
|
||||
"viewPlaylists": "$t(entity.playlist_other) 보기",
|
||||
"setRating": "평점 지정"
|
||||
},
|
||||
"common": {
|
||||
"translation": "번역",
|
||||
"resetToDefault": "기본 설정으로 되돌리기",
|
||||
"right": "오른쪽",
|
||||
"save": "저장",
|
||||
"increase": "증가",
|
||||
"version": "버전",
|
||||
"year": "년",
|
||||
"reset": "초기화",
|
||||
"random": "랜덤",
|
||||
"close": "닫기",
|
||||
"codec": "코덱",
|
||||
"create": "만들기",
|
||||
"disc": "디스크",
|
||||
"gap": "갭",
|
||||
"left": "왼쪽",
|
||||
"add": "추가",
|
||||
"backward": "뒤로",
|
||||
"saveAs": "(으)로 저장하기",
|
||||
"search": "검색",
|
||||
"setting": "설정",
|
||||
"share": "공유",
|
||||
"size": "크기",
|
||||
"sortOrder": "순서",
|
||||
"title": "곡명",
|
||||
"trackNumber": "트랙번호",
|
||||
"trackGain": "트랙 게인",
|
||||
"trackPeak": "트랙 피크",
|
||||
"unknown": "알 수 없음",
|
||||
"cancel": "취소",
|
||||
"clear": "지우기",
|
||||
"collapse": "접기",
|
||||
"comingSoon": "조만간…",
|
||||
"configure": "설정",
|
||||
"confirm": "확인",
|
||||
"currentSong": "현재 $t(entity.track_one)",
|
||||
"decrease": "감소",
|
||||
"delete": "삭제",
|
||||
"descending": "내림차순",
|
||||
"description": "설명",
|
||||
"disable": "비활성",
|
||||
"edit": "편집",
|
||||
"enable": "활성",
|
||||
"expand": "확장",
|
||||
"favorite": "즐겨찾기",
|
||||
"forceRestartRequired": "변경 사항을 적용하려면 재실행 하세요... 알림을 닫으면 재실행합니다",
|
||||
"forward": "앞으로",
|
||||
"limit": "제한",
|
||||
"manage": "관리하다",
|
||||
"maximize": "최대화",
|
||||
"menu": "메뉴",
|
||||
"minimize": "최소화",
|
||||
"modified": "수정된",
|
||||
"name": "이름",
|
||||
"path": "경로",
|
||||
"playerMustBePaused": "플레이어가 일시정지 되어야 합니다",
|
||||
"preview": "미리보기",
|
||||
"previousSong": "이전곡 $t(entity.track_one)",
|
||||
"quit": "종료",
|
||||
"refresh": "새로고침",
|
||||
"reload": "리로드",
|
||||
"restartRequired": "반드시 재실행되어야 합니다",
|
||||
"saveAndReplace": "저장하고 변경하기",
|
||||
"yes": "네",
|
||||
"ascending": "오름차순",
|
||||
"areYouSure": "확실한가요?",
|
||||
"bitrate": "비트 전송률",
|
||||
"bpm": "bpm",
|
||||
"biography": "바이오그래피",
|
||||
"center": "중앙",
|
||||
"channel_other": "채널",
|
||||
"filter_other": "필터",
|
||||
"mbid": "MusicBrainz ID",
|
||||
"dismiss": "닫기",
|
||||
"duration": "길이",
|
||||
"home": "홈",
|
||||
"no": "아니오",
|
||||
"none": "없음",
|
||||
"rating": "평점"
|
||||
},
|
||||
"entity": {
|
||||
"albumWithCount_other": "{{count}} 앨범",
|
||||
"artist_other": "아티스트",
|
||||
"artistWithCount_other": "{{count}} 아티스트",
|
||||
"favorite_other": "즐겨찾기",
|
||||
"folder_other": "폴더",
|
||||
"genre_other": "장르",
|
||||
"genreWithCount_other": "{{count}} 장르",
|
||||
"playlist_other": "플레이리스트",
|
||||
"album_other": "앨범",
|
||||
"albumArtist_other": "앨범 아티스트",
|
||||
"albumArtistCount_other": "{{count}} 앨범 아티스트",
|
||||
"folderWithCount_other": "{{count}} 폴더",
|
||||
"trackWithCount_other": "{{count}} 트랙",
|
||||
"song_other": "곡",
|
||||
"play_other": "{{count}} 재생",
|
||||
"playlistWithCount_other": "{{count}} 재생목록",
|
||||
"smartPlaylist": "스마트 $t(entity.playlist_one)",
|
||||
"track_other": "트랙"
|
||||
},
|
||||
"error": {
|
||||
"systemFontError": "시스템 폰트를 가져오는데 실패하였습니다",
|
||||
"loginRateError": "너무 많은 로그인 시도하였습니다 잠시 후 다시 시도해 주세요",
|
||||
"mpvRequired": "MPV 필요",
|
||||
"openError": "파일을 열 수 없습니다",
|
||||
"remoteDisableError": "원격 서버를 $t(common.disable) 하는데 실패하였습니다",
|
||||
"playbackError": "미디어를 재생하는 도중에 에러가 발생하였습니다",
|
||||
"remoteEnableError": "원격 서버를 $t(common.enable) 하는데 실패하였습니다",
|
||||
"serverNotSelectedError": "선택된 서버가 없습니다",
|
||||
"serverRequired": "서버가 필요합니다",
|
||||
"sessionExpiredError": "세션이 만료되었습니다",
|
||||
"networkError": "네트워크 에러가 발생하였습니다",
|
||||
"remotePortError": "원격 서버의 포트 설정하는데 실패하였습니다",
|
||||
"remotePortWarning": "새로 설정한 포트를 적용하기 위해 서버를 재실행 해 주세요",
|
||||
"audioDeviceFetchError": "오디오 장치를 불러올 수 없습니다",
|
||||
"authenticationFailed": "인증 실패",
|
||||
"badAlbum": "이 곡은 앨범의 일부가 아니기 때문에 표시되는 것입니다. 음악 폴더의 최상위에 곡이 있는 경우 이런 문제가 발생할 가능성이 높습니다. Jellyfin은 폴더 내 그룹만 추적합니다.",
|
||||
"credentialsRequired": "인증서가 필요함",
|
||||
"endpointNotImplementedError": "엔드포인트 {{endpoint}} 는 {{serverType}} 에 대해 구현되지 않았습니다",
|
||||
"genericError": "에러가 발생했습니다",
|
||||
"invalidServer": "잘못된 서버",
|
||||
"localFontAccessDenied": "로컬 글꼴에 접근 거부되었습니다"
|
||||
},
|
||||
"filter": {
|
||||
"title": "곡명",
|
||||
"isRecentlyPlayed": "최근에 재생한",
|
||||
"name": "이름",
|
||||
"path": "경로",
|
||||
"playCount": "재생 횟수",
|
||||
"random": "무작위",
|
||||
"recentlyAdded": "최근에 추가된",
|
||||
"releaseDate": "발매일",
|
||||
"recentlyPlayed": "최근에 재생된",
|
||||
"recentlyUpdated": "최근에 업데이트된",
|
||||
"search": "검색",
|
||||
"dateAdded": "추가된 날짜",
|
||||
"lastPlayed": "마지막으로 재생한",
|
||||
"mostPlayed": "가장 많이 재생한",
|
||||
"album": "$t(entity.album_one)",
|
||||
"albumArtist": "$t(entity.albumArtist_one)",
|
||||
"artist": "$t(entity.artist_one)",
|
||||
"communityRating": "커뮤니티 평점",
|
||||
"criticRating": "비평가 평점",
|
||||
"disc": "디스크",
|
||||
"bitrate": "비트 전송률",
|
||||
"biography": "바이오그래피",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"duration": "길이",
|
||||
"bpm": "bpm"
|
||||
},
|
||||
"form": {
|
||||
"addServer": {
|
||||
"title": "서버 추가하기",
|
||||
"success": "서버 추가하였습니다",
|
||||
"input_name": "서버 이름",
|
||||
"input_password": "비밀번호",
|
||||
"input_savePassword": "비밀번호 저장하기",
|
||||
"input_url": "url",
|
||||
"error_savePassword": "비밀번호를 저장하는 도중 오류가 발생했습니다",
|
||||
"ignoreCors": "CORS 무시 ($t(common.restartRequired))",
|
||||
"ignoreSsl": "SSL 무시 ($t(common.restartRequired))",
|
||||
"input_legacyAuthentication": "레거시 인증 사용",
|
||||
"input_username": "유저 이름"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"input_skipDuplicates": "중복 건너뛰기",
|
||||
"title": "$t(entity.playlist_one) 에 추가",
|
||||
"input_playlists": "$t(entity.playlist_other)"
|
||||
},
|
||||
"lyricSearch": {
|
||||
"title": "가사 검색",
|
||||
"input_name": "$t(common.name)",
|
||||
"input_artist": "$t(entity.artist_one)"
|
||||
},
|
||||
"queryEditor": {
|
||||
"input_optionMatchAll": "모두 일치",
|
||||
"input_optionMatchAny": "무엇이든 일치"
|
||||
},
|
||||
"editPlaylist": {
|
||||
"title": "$t(entity.playlist_one) 편집",
|
||||
"publicJellyfinNote": "Jellyfin은 재생목록 공개 여부를 노출하지 않습니다. 만약 공개되길 원한다면 다음을 선택하세요",
|
||||
"success": "$t(entity.playlist_one) 업데이트 되었습니다"
|
||||
},
|
||||
"shareItem": {
|
||||
"allowDownloading": "다운로드 허용",
|
||||
"description": "설명",
|
||||
"success": "클립보드에 공유 링크를 복사했습니다 (또는 열어보려면 클릭하세요)",
|
||||
"expireInvalid": "만료 날짜는 미래 날짜여야만 합니다",
|
||||
"createFailed": "공유 링크를 생성하는데 실패하였습니다 (혹시 공유하기 설정되어 있나요?)",
|
||||
"setExpiration": "만료 기간 설정하기"
|
||||
},
|
||||
"updateServer": {
|
||||
"title": "서버 업데이트",
|
||||
"success": "서버 업데이트 되었습니다"
|
||||
},
|
||||
"createPlaylist": {
|
||||
"input_description": "$t(common.description)",
|
||||
"input_name": "$t(common.name)",
|
||||
"success": "$t(entity.playlist_one)를 생성했습니다",
|
||||
"input_owner": "$t(common.owner)",
|
||||
"input_public": "공개",
|
||||
"title": "$t(entity.playlist_one) 생성"
|
||||
},
|
||||
"deletePlaylist": {
|
||||
"input_confirm": "확인을 위해 $t(entity.playlist_one)의 이름을 적어주세요",
|
||||
"success": "$t(entity.playlist_one)가 삭제되었습니다",
|
||||
"title": "$t(entity.playlist_one) 삭제"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"appMenu": {
|
||||
"goBack": "뒤로",
|
||||
"selectServer": "서버를 선택하세요",
|
||||
"goForward": "앞으로",
|
||||
"manageServers": "서버 설정하기",
|
||||
"openBrowserDevtools": "브라우저 개발자 도구 열기",
|
||||
"version": "버전 {{version}}"
|
||||
},
|
||||
"manageServers": {
|
||||
"title": "서버 설정하기",
|
||||
"serverDetails": "서버 세부설정",
|
||||
"editServerDetailsTooltip": "서버 세부설정 편집하기",
|
||||
"url": "URL",
|
||||
"username": "username",
|
||||
"removeServer": "서버 제거하기"
|
||||
},
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
"opacity": "투명도",
|
||||
"lyricAlignment": "가사 정렬",
|
||||
"useImageAspectRatio": "이미지 종횡비 사용",
|
||||
"synchronized": "동기화",
|
||||
"unsynchronized": "비동기화"
|
||||
},
|
||||
"lyrics": "가사"
|
||||
},
|
||||
"contextMenu": {
|
||||
"download": "다운로드",
|
||||
"numberSelected": "{{count}}개 선택됨"
|
||||
},
|
||||
"albumArtistDetail": {
|
||||
"about": "{{artist}}에 대해",
|
||||
"viewDiscography": "디스코그래피 보기",
|
||||
"appearsOn": "참여 앨범",
|
||||
"recentReleases": "최근 앨범",
|
||||
"relatedArtists": "연관 $t(entity.artist_other)"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
"label": {
|
||||
"playCount": "재생 횟수",
|
||||
"dateAdded": "추가된 날짜"
|
||||
},
|
||||
"view": {
|
||||
"card": "카드",
|
||||
"poster": "포스터",
|
||||
"table": "표"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,8 @@
|
||||
"codec": "codec",
|
||||
"preview": "pré-visualizar",
|
||||
"share": "compartilhar",
|
||||
"close": "fechar"
|
||||
"close": "fechar",
|
||||
"translation": "tradução"
|
||||
},
|
||||
"action": {
|
||||
"goToPage": "vá para página",
|
||||
@@ -108,7 +109,9 @@
|
||||
"openIn": {
|
||||
"lastfm": "Abrir em Last.fm",
|
||||
"musicbrainz": "Abrir em MusicBrainz"
|
||||
}
|
||||
},
|
||||
"toggleSmartPlaylistEditor": "alternar editor $t(entity.smartPlaylist)",
|
||||
"moveToNext": "mover para o próximo"
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
|
||||
+151
-88
@@ -7,8 +7,8 @@
|
||||
"addToFavorites": "добавить в $t(entity.favorite_other)",
|
||||
"addToPlaylist": "добавить в $t(entity.playlist_one)",
|
||||
"createPlaylist": "создать $t(entity.playlist_one)",
|
||||
"removeFromPlaylist": "удалить из $t(entity.playlist_one)",
|
||||
"viewPlaylists": "просмотреть $t(entity.playlist_other)",
|
||||
"removeFromPlaylist": "удалить из $t(entity.playlist_few)",
|
||||
"viewPlaylists": "показать $t(entity.playlist_other)",
|
||||
"refresh": "$t(common.refresh)",
|
||||
"deletePlaylist": "удалить $t(entity.playlist_one)",
|
||||
"removeFromQueue": "удалить из очереди",
|
||||
@@ -16,7 +16,7 @@
|
||||
"moveToBottom": "вниз",
|
||||
"setRating": "оценить",
|
||||
"toggleSmartPlaylistEditor": "вкл./откл. редактор $t(entity.smartPlaylist)",
|
||||
"removeFromFavorites": "удалить из $t(entity.favorite_other)",
|
||||
"removeFromFavorites": "удалить из $t(entity.favorite_few)",
|
||||
"openIn": {
|
||||
"lastfm": "открыть на Last.fm",
|
||||
"musicbrainz": "открыть на MusicBrainz"
|
||||
@@ -38,15 +38,15 @@
|
||||
"currentSong": "текущий $t(entity.track_one)",
|
||||
"collapse": "закрыть",
|
||||
"trackNumber": "трек",
|
||||
"descending": "убывание",
|
||||
"descending": "по убыванию",
|
||||
"add": "добавить",
|
||||
"gap": "промежуток",
|
||||
"ascending": "возрастанию",
|
||||
"ascending": "по возрастанию",
|
||||
"dismiss": "отклонить",
|
||||
"year": "год",
|
||||
"manage": "управлять",
|
||||
"manage": "управление",
|
||||
"limit": "ограничение",
|
||||
"minimize": "минимизировать",
|
||||
"minimize": "свернуть",
|
||||
"modified": "изменено",
|
||||
"duration": "длительность",
|
||||
"name": "имя",
|
||||
@@ -66,6 +66,9 @@
|
||||
"cancel": "отменить",
|
||||
"forceRestartRequired": "перезапустите приложение, чтобы применить изменения... закройте уведомление для перезапуска",
|
||||
"setting": "настройка",
|
||||
"setting_one": "настройка",
|
||||
"setting_few": "",
|
||||
"setting_many": "",
|
||||
"version": "версия",
|
||||
"title": "название",
|
||||
"filter_one": "фильтр",
|
||||
@@ -78,11 +81,11 @@
|
||||
"action_one": "действие",
|
||||
"action_few": "действия",
|
||||
"action_many": "действий",
|
||||
"playerMustBePaused": "воспроизведение должно быть остановлено",
|
||||
"playerMustBePaused": "необходимо остановить воспроизведение",
|
||||
"confirm": "подтвердить",
|
||||
"resetToDefault": "по умолчанию",
|
||||
"home": "главная страница",
|
||||
"comingSoon": "скоро будет…",
|
||||
"resetToDefault": "сбросить настройки",
|
||||
"home": "главная",
|
||||
"comingSoon": "скоро...",
|
||||
"reset": "сбросить",
|
||||
"channel_one": "канал",
|
||||
"channel_few": "канала",
|
||||
@@ -92,14 +95,14 @@
|
||||
"menu": "меню",
|
||||
"restartRequired": "необходим перезапуск приложения",
|
||||
"previousSong": "предыдущий $t(entity.track_one)",
|
||||
"noResultsFromQuery": "нет результатов",
|
||||
"noResultsFromQuery": "ничего не найдено",
|
||||
"quit": "выйти",
|
||||
"expand": "расширить",
|
||||
"expand": "раскрыть",
|
||||
"search": "поиск",
|
||||
"saveAs": "сохранить как",
|
||||
"disc": "диск",
|
||||
"yes": "да",
|
||||
"random": "случайный",
|
||||
"random": "случайно",
|
||||
"size": "размер",
|
||||
"biography": "биография",
|
||||
"note": "заметка",
|
||||
@@ -109,7 +112,12 @@
|
||||
"preview": "просмотр",
|
||||
"codec": "кодек",
|
||||
"share": "поделиться",
|
||||
"close": "закрыть"
|
||||
"close": "закрыть",
|
||||
"albumGain": "альбом усиление",
|
||||
"trackGain": "усиление трека",
|
||||
"translation": "перевод",
|
||||
"albumPeak": "пик альбома",
|
||||
"trackPeak": "пик трека"
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "альбом",
|
||||
@@ -124,18 +132,25 @@
|
||||
"playlist_one": "плейлист",
|
||||
"playlist_few": "плейлиста",
|
||||
"playlist_many": "плейлистов",
|
||||
"play": "{{count}} прослушиваний",
|
||||
"play_one": "{{count}} прослушивание",
|
||||
"play_few": "",
|
||||
"play_many": "",
|
||||
"artist_one": "автор",
|
||||
"artist_few": "автора",
|
||||
"artist_many": "авторов",
|
||||
"artist_many": "исполнителей",
|
||||
"folderWithCount_one": "{{count}} папка",
|
||||
"folderWithCount_few": "{{count}} папки",
|
||||
"folderWithCount_many": "{{count}} папок",
|
||||
"albumArtist_one": "автор альбома",
|
||||
"albumArtist_few": "автора альбома",
|
||||
"albumArtist_many": "авторов альбома",
|
||||
"albumArtist_one": "исполнитель альбома",
|
||||
"albumArtist_few": "исполнители альбома",
|
||||
"albumArtist_many": "исполнителей альбома",
|
||||
"track_one": "трек",
|
||||
"track_few": "трека",
|
||||
"track_many": "треков",
|
||||
"song_one": "песня",
|
||||
"song_few": "{{count}} песни",
|
||||
"song_many": "{{count}} песен",
|
||||
"albumArtistCount_one": "{{count}} автор альбома",
|
||||
"albumArtistCount_few": "{{count}} автора альбома",
|
||||
"albumArtistCount_many": "{{count}} авторов альбома",
|
||||
@@ -162,7 +177,7 @@
|
||||
"table": {
|
||||
"config": {
|
||||
"view": {
|
||||
"card": "карта",
|
||||
"card": "карточки",
|
||||
"table": "таблица",
|
||||
"poster": "постер"
|
||||
},
|
||||
@@ -171,8 +186,9 @@
|
||||
"gap": "$t(common.gap)",
|
||||
"tableColumns": "столбцы таблицы",
|
||||
"autoFitColumns": "автоматически расставить столбцы",
|
||||
"followCurrentSong": "следовать за исполняемым треком",
|
||||
"size": "$t(common.size)",
|
||||
"itemSize": "рамер элементов (px)",
|
||||
"itemSize": "размер элементов (px)",
|
||||
"itemGap": "отступ между элементами (px)"
|
||||
},
|
||||
"label": {
|
||||
@@ -185,7 +201,7 @@
|
||||
"bpm": "$t(common.bpm)",
|
||||
"lastPlayed": "последний",
|
||||
"trackNumber": "номер трека",
|
||||
"rowIndex": "индекс ряда",
|
||||
"rowIndex": "номер строки",
|
||||
"rating": "$t(common.rating)",
|
||||
"artist": "$t(entity.artist_one)",
|
||||
"album": "$t(entity.album_one)",
|
||||
@@ -234,25 +250,25 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "перезапустить сервер для применения нового порта",
|
||||
"remotePortWarning": "необходимо перезапустить сервер для применения нового порта",
|
||||
"systemFontError": "произошла ошибка при попытке получить системные шрифты",
|
||||
"playbackError": "произошла ошибка при попытке проиграть медиа",
|
||||
"playbackError": "произошла ошибка при попытке проигрывания медиа",
|
||||
"endpointNotImplementedError": "запрос {{endpoint}} не реализован для {{serverType}}",
|
||||
"remotePortError": "произошла ошибка при попытке установить порт удаленного сервера",
|
||||
"serverRequired": "необходим сервер",
|
||||
"authenticationFailed": "авторизация завершилась с ошибкой",
|
||||
"serverRequired": "сервер не выбран",
|
||||
"authenticationFailed": "не удалось авторизироваться",
|
||||
"apiRouteError": "невозможно выполнить запрос",
|
||||
"genericError": "произошла ошибка",
|
||||
"credentialsRequired": "необходимы учётные данные",
|
||||
"credentialsRequired": "введите данные для входа",
|
||||
"sessionExpiredError": "ваш сеанс истёк",
|
||||
"remoteEnableError": "ошибка произошла при попытке $t(common.enable) удалённый сервер",
|
||||
"remoteEnableError": "произошла ошибка при попытке $t(common.enable) удалённый сервер",
|
||||
"localFontAccessDenied": "не получилось получить доступ к шрифтам",
|
||||
"serverNotSelectedError": "не выбран сервер",
|
||||
"remoteDisableError": "ошибка произошла при попытке $t(common.disable) удалённый сервер",
|
||||
"remoteDisableError": "произошла ошибка при попытке $t(common.disable) удалённый сервер",
|
||||
"mpvRequired": "необходим MPV",
|
||||
"audioDeviceFetchError": "произошла ошибка с аудиоустройством",
|
||||
"invalidServer": "недействительный сервер",
|
||||
"loginRateError": "слишком много попыток входа, пожалуйста, попробуйте ещё раз через несколько секунд",
|
||||
"loginRateError": "превышено максимальное количество попыток входа, пожалуйста, попробуйте ещё раз через несколько секунд",
|
||||
"openError": "не удалось открыть файл",
|
||||
"badAlbum": "вы видите эту страницу из-за того, что эта песня не входит в альбом. скорее всего, вы видите эту ошибку, так как песня находится в корневой директории папки с музыкой. jellyfin группирует треки только по папкам.",
|
||||
"networkError": "возникла ошибка сети"
|
||||
@@ -265,48 +281,49 @@
|
||||
"communityRating": "рейтинг сообщества",
|
||||
"favorited": "любимый",
|
||||
"albumArtist": "$t(entity.albumArtist_one)",
|
||||
"isFavorited": "любимый",
|
||||
"isFavorited": "любимые",
|
||||
"bpm": "уд./мин.",
|
||||
"disc": "диск",
|
||||
"biography": "биография",
|
||||
"artist": "$t(entity.artist_one)",
|
||||
"duration": "длительность",
|
||||
"fromYear": "из года",
|
||||
"fromYear": "год",
|
||||
"criticRating": "рейтинг критиков",
|
||||
"mostPlayed": "самое воспроизводимое",
|
||||
"mostPlayed": "слушают чаще всего",
|
||||
"comment": "комментировать",
|
||||
"playCount": "кол-во воспроизведений",
|
||||
"recentlyUpdated": "недавно обновлено",
|
||||
"playCount": "количество воспроизведений",
|
||||
"recentlyUpdated": "обновленные недавно",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"recentlyPlayed": "недавно проиграно",
|
||||
"recentlyPlayed": "проигрывались недавно",
|
||||
"owner": "$t(common.owner)",
|
||||
"title": "название",
|
||||
"rating": "рейтинг",
|
||||
"search": "поиск",
|
||||
"genre": "$t(entity.genre_one)",
|
||||
"recentlyAdded": "недавно добавлено",
|
||||
"recentlyAdded": "недавно добавленные",
|
||||
"note": "заметка",
|
||||
"name": "название",
|
||||
"releaseDate": "дата выхода",
|
||||
"albumCount": "кол-во $t(entity.album_other)",
|
||||
"albumCount": "количество $t(entity.album_many)",
|
||||
"path": "путь",
|
||||
"isRecentlyPlayed": "недавно проигрывалась",
|
||||
"isRecentlyPlayed": "недавно проигрывался",
|
||||
"releaseYear": "год выхода",
|
||||
"id": "№",
|
||||
"songCount": "кол-во песен",
|
||||
"songCount": "количество песен",
|
||||
"isPublic": "публичный",
|
||||
"random": "случайный",
|
||||
"random": "случайно",
|
||||
"lastPlayed": "последний раз проигрывалась",
|
||||
"toYear": "до года",
|
||||
"album": "$t(entity.album_one)",
|
||||
"trackNumber": "трек"
|
||||
},
|
||||
"player": {
|
||||
"repeat_all": "повтор всех",
|
||||
"repeat_all": "повторять все",
|
||||
"stop": "остановить",
|
||||
"repeat": "повтор",
|
||||
"repeat": "повторять текущий",
|
||||
"queue_remove": "удалить выбранное",
|
||||
"playRandom": "случайные песни",
|
||||
"playRandom": "играть случайные песни",
|
||||
"playSimilarSongs": "играть похожие песни",
|
||||
"skip": "пропустить",
|
||||
"previous": "предыдущий",
|
||||
"toggleFullscreenPlayer": "включить полноэкранный режим",
|
||||
@@ -316,10 +333,10 @@
|
||||
"shuffle": "перемешать",
|
||||
"playbackFetchNoResults": "песни не найдены",
|
||||
"playbackFetchInProgress": "загрузка песен..",
|
||||
"addNext": "добавить следующий",
|
||||
"addNext": "воспроизвести следующим",
|
||||
"playbackSpeed": "скорость воспроизведения",
|
||||
"playbackFetchCancel": "это занимает некоторое время... закрыть уведомление для отмены",
|
||||
"play": "прослушать",
|
||||
"playbackFetchCancel": "пожалуйста, подождите немного... закройте уведомление для отмены",
|
||||
"play": "играть",
|
||||
"repeat_off": "повтор выключен",
|
||||
"pause": "пауза",
|
||||
"queue_clear": "очистить очередь",
|
||||
@@ -328,13 +345,14 @@
|
||||
"queue_moveToTop": "переместить выделенное вниз",
|
||||
"queue_moveToBottom": "переместить выделенное вверх",
|
||||
"shuffle_off": "перемешивание выключено",
|
||||
"addLast": "добавить последний",
|
||||
"addLast": "воспроизвести после всех",
|
||||
"mute": "отключить звук",
|
||||
"skip_forward": "вперёд"
|
||||
"skip_forward": "вперёд",
|
||||
"viewQueue": "показать очередь"
|
||||
},
|
||||
"page": {
|
||||
"sidebar": {
|
||||
"nowPlaying": "Cейчас проигрывается",
|
||||
"nowPlaying": "сейчас играет",
|
||||
"playlists": "$t(entity.playlist_other)",
|
||||
"search": "$t(common.search)",
|
||||
"tracks": "$t(entity.track_other)",
|
||||
@@ -354,30 +372,41 @@
|
||||
"followCurrentLyric": "следовать за текущими словами песни",
|
||||
"opacity": "непрозрачность",
|
||||
"lyricSize": "размер слов",
|
||||
"showLyricProvider": "показать провайдера слов",
|
||||
"unsynchronized": "несинхронизировано",
|
||||
"showLyricProvider": "показать источник слов",
|
||||
"unsynchronized": "не синхронизировано",
|
||||
"lyricAlignment": "выравнивание слов песни",
|
||||
"lyricOffset": "задержка слов (мсек)",
|
||||
"useImageAspectRatio": "использовать соотношение сторон изображения",
|
||||
"lyricGap": "пробел между словами",
|
||||
"dynamicIsImage": "включить фоновое изображение",
|
||||
"dynamicImageBlur": "сила размытия изображения"
|
||||
},
|
||||
"upNext": "следующее",
|
||||
"lyrics": "слова песни",
|
||||
"related": "схожие"
|
||||
"upNext": "играет",
|
||||
"lyrics": "слова",
|
||||
"related": "похожие",
|
||||
"visualizer": "визуализатор",
|
||||
"noLyrics": "слова для песни не найдены"
|
||||
},
|
||||
"appMenu": {
|
||||
"selectServer": "выбрать сервер",
|
||||
"selectServer": "список серверов",
|
||||
"version": "версия {{version}}",
|
||||
"settings": "$t(common.setting_other)",
|
||||
"manageServers": "настроить список серверов",
|
||||
"expandSidebar": "развернуть",
|
||||
"manageServers": "редактировать список серверов",
|
||||
"expandSidebar": "развернуть боковую панель",
|
||||
"collapseSidebar": "Скрыть боковую панель",
|
||||
"openBrowserDevtools": "открыть инструменты разработчика",
|
||||
"quit": "$t(common.quit)",
|
||||
"goBack": "назад",
|
||||
"goForward": "вперёд"
|
||||
},
|
||||
"manageServers": {
|
||||
"title": "сервера",
|
||||
"serverDetails": "информация о сервере",
|
||||
"url": "адрес",
|
||||
"username": "пользователь",
|
||||
"editServerDetailsTooltip": "изменить настройки сервера",
|
||||
"removeServer": "удалить сервер"
|
||||
},
|
||||
"contextMenu": {
|
||||
"addToPlaylist": "$t(action.addToPlaylist)",
|
||||
"addToFavorites": "$t(action.addToFavorites)",
|
||||
@@ -390,6 +419,7 @@
|
||||
"removeFromFavorites": "$t(action.removeFromFavorites)",
|
||||
"addNext": "$t(player.addNext)",
|
||||
"deselectAll": "$t(action.deselectAll)",
|
||||
"download": "скачать",
|
||||
"addLast": "$t(player.addLast)",
|
||||
"addFavorite": "$t(action.addToFavorites)",
|
||||
"play": "$t(player.play)",
|
||||
@@ -399,11 +429,11 @@
|
||||
"shareItem": "поделиться"
|
||||
},
|
||||
"home": {
|
||||
"mostPlayed": "наибольшее кол-во воспроизведений",
|
||||
"mostPlayed": "слушают чаще всего",
|
||||
"newlyAdded": "недавно добавленные релизы",
|
||||
"title": "$t(common.home)",
|
||||
"explore": "изучите вашу медиатеку",
|
||||
"recentlyPlayed": "недавно прослушано"
|
||||
"explore": "откройте новое",
|
||||
"recentlyPlayed": "игралось недавно"
|
||||
},
|
||||
"albumDetail": {
|
||||
"moreFromArtist": "больше от $t(entity.artist_one)",
|
||||
@@ -420,8 +450,8 @@
|
||||
},
|
||||
"genreList": {
|
||||
"title": "$t(entity.genre_other)",
|
||||
"showAlbums": "показать $t(entity.genre_one) $t(entity.album_other)",
|
||||
"showTracks": "показать $t(entity.genre_one) $t(entity.track_other)"
|
||||
"showAlbums": "показать $t(entity.genre_one) $t(entity.album_many)",
|
||||
"showTracks": "показать $t(entity.genre_one) $t(entity.track_many)"
|
||||
},
|
||||
"trackList": {
|
||||
"title": "$t(entity.track_other)",
|
||||
@@ -435,6 +465,9 @@
|
||||
},
|
||||
"title": "комманды"
|
||||
},
|
||||
"playlist": {
|
||||
"reorder": "сортировка доступна только по ID"
|
||||
},
|
||||
"playlistList": {
|
||||
"title": "$t(entity.playlist_other)"
|
||||
},
|
||||
@@ -448,7 +481,7 @@
|
||||
"viewAll": "посмотреть всё",
|
||||
"appearsOn": "появляется в",
|
||||
"viewDiscography": "посмотреть дискографию",
|
||||
"relatedArtists": "похож на $t(entity.artist_other)",
|
||||
"relatedArtists": "похож на $t(entity.artist_many)",
|
||||
"viewAllTracks": "посмотреть все $t(entity.track_other)",
|
||||
"recentReleases": "недавние релизы",
|
||||
"about": "О {{artist}}",
|
||||
@@ -464,7 +497,7 @@
|
||||
"deletePlaylist": {
|
||||
"title": "удалить $t(entity.playlist_one)",
|
||||
"success": "$t(entity.playlist_one) успешно удалён",
|
||||
"input_confirm": "напишите название $t(entity.playlist_one)а для подтверждения"
|
||||
"input_confirm": "напишите название $t(entity.playlist_few) для подтверждения"
|
||||
},
|
||||
"createPlaylist": {
|
||||
"input_description": "$t(common.description)",
|
||||
@@ -477,24 +510,24 @@
|
||||
"addServer": {
|
||||
"title": "добавить сервер",
|
||||
"input_username": "пользователь",
|
||||
"input_url": "url",
|
||||
"input_url": "адрес",
|
||||
"input_password": "пароль",
|
||||
"input_legacyAuthentication": "включить старую авторизацию",
|
||||
"input_name": "название сервера",
|
||||
"success": "сервер добавлен успешно",
|
||||
"success": "сервер успешно добавлен",
|
||||
"input_savePassword": "сохранить пароль",
|
||||
"ignoreSsl": "игнорирование ssl ($t(common.restartRequired))",
|
||||
"ignoreCors": "игнорирование корсетов ($t(common.restartRequired))",
|
||||
"error_savePassword": "произошла ошибка во время попытки сохранения пароля"
|
||||
"ignoreSsl": "игнорировать ssl ($t(common.restartRequired))",
|
||||
"ignoreCors": "игнорировать CORS ($t(common.restartRequired))",
|
||||
"error_savePassword": "произошла ошибка при сохранении пароля"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "добавлено: $t(entity.trackWithCount, {\"count\": {{message}} }) в $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||
"title": "добавить в $t(entity.playlist_one)",
|
||||
"input_skipDuplicates": "пропустить дубликаты",
|
||||
"input_skipDuplicates": "не добавлять дубликаты",
|
||||
"input_playlists": "$t(entity.playlist_other)"
|
||||
},
|
||||
"updateServer": {
|
||||
"title": "обновить сервер",
|
||||
"title": "обновление сервера",
|
||||
"success": "сервер успешно обновлён"
|
||||
},
|
||||
"queryEditor": {
|
||||
@@ -512,7 +545,7 @@
|
||||
"shareItem": {
|
||||
"success": "ссылка скопирована в буфер обмена (нажмите здесь, чтобы открыть)",
|
||||
"expireInvalid": "время истечения срока действия должно быть в будущем",
|
||||
"createFailed": "не удалось создать ссылку для общего доступа (общий доступ включён?)",
|
||||
"createFailed": "не удалось создать ссылку для общего доступа (проверьте, включен ли общий доступ?)",
|
||||
"allowDownloading": "разрешить скачивание",
|
||||
"setExpiration": "установить срок действия",
|
||||
"description": "описание"
|
||||
@@ -521,16 +554,22 @@
|
||||
"setting": {
|
||||
"accentColor": "цвет акцента",
|
||||
"accentColor_description": "устанавливает цвет акцента для приложения",
|
||||
"albumBackground": "фоновое изображение альбомов",
|
||||
"albumBackground_description": "добавляет фоновое изображение для страниц альбомов, содержащих обложку",
|
||||
"albumBackgroundBlur": "размытие фонового изображения альбома",
|
||||
"albumBackgroundBlur_description": "определяет степень размытия фонового изображения на странице альбомов",
|
||||
"applicationHotkeys": "горячие клавиши приложения",
|
||||
"crossfadeStyle_description": "выберите вид эффекта crossfade для аудиоплеера",
|
||||
"customCssEnable": "использовать кастомные css",
|
||||
"customCssEnable_description": "разрешить использование кастомных css.",
|
||||
"enableRemote_description": "включает сервер удалённого управления для управления воспроизведением с помощью других устройств",
|
||||
"fontType_optionSystem": "системный",
|
||||
"mpvExecutablePath_description": "укажите папку, в которой находится исполняющий файл аудиоплеера MPV. если оставить пустым, будет использоваться путь по умолчанию",
|
||||
"crossfadeStyle": "Вид эффекта crossfade",
|
||||
"crossfadeStyle": "вид эффекта crossfade",
|
||||
"fontType_optionBuiltIn": "встроенный",
|
||||
"disableLibraryUpdateOnStartup": "Отключить проверку новых версий при запуске приложения",
|
||||
"minimizeToTray_description": "Сворачивать приложение в панель уведомлений",
|
||||
"audioPlayer_description": "Укажите - какой аудиоплеер использовать для воспроизведения",
|
||||
"disableLibraryUpdateOnStartup": "отключить проверку новых версий при запуске приложения",
|
||||
"minimizeToTray_description": "сворачивать приложение в панель уведомлений",
|
||||
"audioPlayer_description": "укажите, какой аудиоплеер использовать для воспроизведения",
|
||||
"disableAutomaticUpdates": "отключить проверку обновлений",
|
||||
"exitToTray_description": "При закрытии приложения - оно останется в панели уведомлений",
|
||||
"fontType_optionCustom": "пользовательский",
|
||||
@@ -544,7 +583,7 @@
|
||||
"crossfadeDuration": "Длительность эффекта crossfade",
|
||||
"audioPlayer": "Аудиоплеер",
|
||||
"minimizeToTray": "сворачивать в панель уведомлений",
|
||||
"font_description": "Выберите - какой шрифт использовать в приложении",
|
||||
"font_description": "Выберите, какой шрифт использовать в приложении",
|
||||
"remoteUsername": "имя пользователя для доступа к серверу удалённого управления",
|
||||
"buttonSize_description": "размер кнопок в панели управления воспроизведением",
|
||||
"clearCache": "очистить кэш браузера",
|
||||
@@ -554,11 +593,11 @@
|
||||
"buttonSize": "размер кнопок панели управления воспроизведением",
|
||||
"hotkey_volumeDown": "уменьшить громкость",
|
||||
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||
"theme_description": "устанавливает тему, которая будет использоваться приложением",
|
||||
"theme_description": "устанавливает тему, которая будет использоваться в приложении",
|
||||
"passwordStore": "хранилище паролей/секретов",
|
||||
"sidebarPlaylistList": "список плейлистов в боковой панели",
|
||||
"windowBarStyle_description": "выберите стиль заголовка окна",
|
||||
"followLyric": "следовать тексту трека",
|
||||
"followLyric": "следовать за текстом трека",
|
||||
"volumeWheelStep": "шаг регулировки громкости колёсиком мыши",
|
||||
"windowBarStyle": "стиль заголовка окна",
|
||||
"hotkey_zoomOut": "уменьшить масштаб",
|
||||
@@ -567,7 +606,7 @@
|
||||
"replayGainMode_optionAlbum": "$t(entity.album_one)",
|
||||
"replayGainMode_optionNone": "$t(common.none)",
|
||||
"replayGainMode_optionTrack": "$t(entity.track_one)",
|
||||
"clearQueryCache_description": "\"мягкая очистка\" feishin. при выполнении обновляются плейлисты, метаданные треков, но сохранённые тексты треков сбрасываются. настройки, учётные данные и кэшированные изображения сохраняются",
|
||||
"clearQueryCache_description": "так называемая \"мягкая очистка\" feishin: обновляются плейлисты, метаданные треков, но сохранённые тексты треков сбрасываются. настройки, учётные данные и кэшированные изображения сохраняются",
|
||||
"hotkey_favoriteCurrentSong": "добавить $t(common.currentSong) в избранное",
|
||||
"genreBehavior": "поведения страницы жанров",
|
||||
"globalMediaHotkeys": "глобальные мультимедийные горячие клавиши",
|
||||
@@ -576,11 +615,11 @@
|
||||
"hotkey_globalSearch": "глобальный поиск",
|
||||
"hotkey_playbackNext": "следующий трек",
|
||||
"hotkey_playbackPause": "пауза",
|
||||
"hotkey_playbackPlay": "прослушать",
|
||||
"hotkey_playbackPlayPause": "прослушать / пауза",
|
||||
"hotkey_playbackPlay": "играть",
|
||||
"hotkey_playbackPlayPause": "играть / пауза",
|
||||
"hotkey_playbackPrevious": "предыдущий трек",
|
||||
"hotkey_playbackStop": "остановить",
|
||||
"hotkey_rate0": "очистить оценку",
|
||||
"hotkey_rate0": "убрать оценку",
|
||||
"hotkey_rate1": "оценить в 1 звезду",
|
||||
"hotkey_rate2": "оценить в 2 звезды",
|
||||
"hotkey_rate3": "оценить в 3 звезды",
|
||||
@@ -609,6 +648,8 @@
|
||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||
"playButtonBehavior_optionPlay": "$t(player.play)",
|
||||
"playerAlbumArtResolution_description": "разрешение большой версии обложки альбома в проигрывателе. при большем разрешении она выглядит более четкой, но может замедлить загрузку. по умолчанию равно 0 - устанавливает разрешение автоматически",
|
||||
"playerbarOpenDrawer": "полноэкранный переключатель по панели проигрывателя",
|
||||
"playerbarOpenDrawer_description": "позволяет перейти в полноэкранный режим воспроизведения нажатием на панель проигрывателя",
|
||||
"remotePort": "порт сервера удалённого управления",
|
||||
"remotePort_description": "устанавливает порт для сервера удалённого управления",
|
||||
"replayGainClipping": "{{ReplayGain}} клиппинг",
|
||||
@@ -622,11 +663,21 @@
|
||||
"showSkipButtons": "показывать кнопки перемотки",
|
||||
"showSkipButtons_description": "показывать или скрывать кнопки перемотки на панели управления воспроизведением",
|
||||
"sidebarPlaylistList_description": "показать или скрыть список плейлистов на боковой панели",
|
||||
"sidePlayQueueStyle": "вид отображения боковой очереди",
|
||||
"sidePlayQueueStyle_description": "определяет вид отображения боковой очереди",
|
||||
"sidePlayQueueStyle_optionAttached": "закрепленная",
|
||||
"sidePlayQueueStyle_optionDetached": "плавающая",
|
||||
"skipDuration": "время перемотки",
|
||||
"skipDuration_description": "задает время перемотки при использовании кнопок перемотки на панели проигрывателя",
|
||||
"useSystemTheme": "использовать тему системы",
|
||||
"themeLight": "тема (светлая)",
|
||||
"themeLight_description": "устанавливает светлую тему приложения",
|
||||
"transcodeNote": "эффект применяется после 1 (для веб) - 2 (для mpv) песни",
|
||||
"transcode": "включить транскодинг",
|
||||
"transcode_description": "активирует транскодинг в другие форматы",
|
||||
"transcodeBitrate": "битрейт транскодинга",
|
||||
"transcodeBitrate_description": "выберите битрейт транскодинга. 0 - автоматическое определение сервером",
|
||||
"transcodeFormat": "формат транкодинга",
|
||||
"useSystemTheme_description": "использует тему, заданную в системе (светлую/тёмную)",
|
||||
"zoom": "процент масштабирования",
|
||||
"zoom_description": "устанавливает процент масштабирования приложения",
|
||||
@@ -634,13 +685,15 @@
|
||||
"genreBehavior_description": "определяет, что отобразится при открытии на жанр — список треков или альбомов",
|
||||
"globalMediaHotkeys_description": "включить или отключить использование системных мультимедийных горячих клавиш для управления воспроизведением",
|
||||
"homeConfiguration_description": "позволяет настроить видимость и порядок элементов на домашней странице",
|
||||
"homeFeature": "улучшенная карусель на главной",
|
||||
"homeFeature_description": "определяет, показывать ли улучшенную карусель на главной странице",
|
||||
"hotkey_toggleQueue": "показать/скрыть очередь воспроизведения",
|
||||
"imageAspectRatio": "использовать оригинальное соотношение сторон обложки",
|
||||
"imageAspectRatio_description": "если эта опция включена, обложки будут отображаться в соответствии с их собственным соотношением сторон. для обложек не 1:1 оставшееся пространство будет пустым",
|
||||
"minimumScrobblePercentage": "минимальное время для скробблинга (в процентах)",
|
||||
"playbackStyle": "стиль воспроизведения",
|
||||
"playerAlbumArtResolution": "разрешение обложки альбома",
|
||||
"remotePassword_description": "задает пароль для сервера удалённого управления. По умолчанию эти учетные данные передаются небезопасным способом, поэтому следует использовать уникальный пароль, который вам не важен",
|
||||
"remotePassword_description": "задает пароль для сервера удалённого управления. По умолчанию эти учетные данные передаются небезопасным способом, поэтому следует использовать уникальный пароль, который вам неважен",
|
||||
"replayGainClipping_description": "Предотвращение клиппинга, вызванного {{ReplayGain}}, путём автоматического снижения усиления",
|
||||
"replayGainFallback_description": "усиление в db для применения, если у файла нет тегов {{ReplayGain}}",
|
||||
"replayGainMode_description": "регулировать усиление громкости в соответствии со значениями {{ReplayGain}}, хранящимися в метаданных файла",
|
||||
@@ -658,8 +711,10 @@
|
||||
"startMinimized": "запуск в свёрнутом режиме",
|
||||
"themeDark_description": "устанавливает тёмную тему приложения",
|
||||
"hotkey_volumeMute": "отключить звук",
|
||||
"clearCache_description": "\"жесткая очистка\" feishin. кроме очистки кэша feishin, также очищает кэш браузера (сохранённые картинки и другие ресурсы). учётные данные и настройки сохраняются",
|
||||
"clearCache_description": "\"жесткая очистка\" feishin: кроме очистки кэша feishin, также очищает кэш браузера (сохранённые картинки и другие ресурсы). учётные данные и настройки сохраняются",
|
||||
"clearCacheSuccess": "кэш успешно удалён",
|
||||
"contextMenu": "конфигурация контекстного меню (нажатие правой кнопкой мыши)",
|
||||
"contextMenu_description": "позволяет скрыть элементы, отображаемые в меню, появляющемся при нажатии правой кнопки мыши на элемент. все, что не отмечено, будет скрыто",
|
||||
"customFontPath": "путь к пользовательскому шрифту",
|
||||
"customFontPath_description": "укажите путь к пользовательскому шрифту, который будет использоваться в приложении",
|
||||
"externalLinks_description": "включает отображение внешних ссылок (Last.fm, MusicBrainz) на страницах альбомов и артистов",
|
||||
@@ -681,6 +736,10 @@
|
||||
"scrobble_description": "скробблинг треков на вашем медиасервере",
|
||||
"startMinimized_description": "запуск приложения в области уведомлений",
|
||||
"volumeWheelStep_description": "количество громкости, изменяемое при прокрутке колёсика мыши над ползунком громкости",
|
||||
"volumeWidth": "ширина слайдера звука",
|
||||
"volumeWidth_description": "ширина слайдера звука (в px)",
|
||||
"webAudio": "использовать веб аудио",
|
||||
"webAudio_description": "использование веб аудио. включение активирует продвинутые возможности (например, replaygain). отключите, если вам это не нужно",
|
||||
"discordRichPresence": "состояние профиля {{discord}}",
|
||||
"discordApplicationId": "{{discord}} application id",
|
||||
"discordApplicationId_description": "application id приложения {{discord}} которое будет отображаться в статусе профиля (по умолчанию {{defaultId}})",
|
||||
@@ -688,9 +747,13 @@
|
||||
"discordIdleStatus_description": "если включено, то обновляет статус, когда пользователь бездействует",
|
||||
"discordUpdateInterval": "интервал обновления статуса профиля {{discord}}",
|
||||
"discordUpdateInterval_description": "время в секундах между каждым обновлением (минимум 15 секунд)",
|
||||
"doubleClickBehavior": "добавить в очередь все найденные треки при двойном клике",
|
||||
"doubleClickBehavior_description": "есть включено: все найденные в поиске треки будут добавлены в очередь при двойном клике (иначе - только выбранный)",
|
||||
"lyricOffset_description": "Смещение появления текста треков на указанное количество миллисекунд",
|
||||
"skipPlaylistPage": "пропустить страницу плейлиста",
|
||||
"applicationHotkeys_description": "настройка горячих клавиш приложения. включите чекбокс, чтобы сделать горячую клавишу глобальной (только для ПК)",
|
||||
"skipPlaylistPage": "пропускать страницу плейлиста",
|
||||
"applicationHotkeys_description": "настройка горячих клавиш приложения. поставьте галочку, чтобы сделать горячую клавишу глобальной (только для ПК)",
|
||||
"artistConfiguration": "конфигурация страницы альбомов исполнителей",
|
||||
"artistConfiguration_description": "позволяет настроить видимость и порядок элементов на странице альбомов исполнителей",
|
||||
"fontType_description": "встроенный позволяет выбрать один из шрифтов, предоставляемых Feishin. системный позволяет выбрать любой шрифт, предоставляемый вашей операционной системой. пользовательский позволяет выбрать свой собственный шрифт",
|
||||
"discordRichPresence_description": "включить статус воспроизведения в статус профиля в {{discord}}. Ключи изображений: {{icon}}, {{playing}} и {{paused}} ",
|
||||
"lyricOffset": "синхронизация текста треков (мс)"
|
||||
|
||||
@@ -209,7 +209,11 @@
|
||||
"moveToBottom": "idi na dno",
|
||||
"setRating": "oceni",
|
||||
"toggleSmartPlaylistEditor": "pokreni $t(entity.smartPlaylist) editor",
|
||||
"removeFromFavorites": "ukloni iz $t(entity.favorite_other)"
|
||||
"removeFromFavorites": "ukloni iz $t(entity.favorite_other)",
|
||||
"openIn": {
|
||||
"lastfm": "Otvori u Last.fm",
|
||||
"musicbrainz": "Otvori u MusicBrainz"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"backward": "nazad",
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"openIn": {
|
||||
"lastfm": "在 Last.fm 中打开",
|
||||
"musicbrainz": "在 MusicBrainz 中打开"
|
||||
}
|
||||
},
|
||||
"moveToNext": "移至下一首"
|
||||
},
|
||||
"common": {
|
||||
"increase": "增高",
|
||||
@@ -107,7 +108,8 @@
|
||||
"albumGain": "专辑增益",
|
||||
"codec": "编解码器",
|
||||
"share": "分享",
|
||||
"preview": "预览"
|
||||
"preview": "预览",
|
||||
"translation": "翻译"
|
||||
},
|
||||
"entity": {
|
||||
"albumArtist_other": "专辑艺术家",
|
||||
@@ -126,7 +128,8 @@
|
||||
"smartPlaylist": "智能$t(entity.playlist_one)",
|
||||
"genreWithCount_other": "{{count}} 种流派",
|
||||
"trackWithCount_other": "{{count}} 首乐曲",
|
||||
"play_other": "{{count}} 次播放"
|
||||
"play_other": "{{count}} 次播放",
|
||||
"song_other": "歌曲"
|
||||
},
|
||||
"player": {
|
||||
"repeat_all": "循环全部",
|
||||
@@ -158,7 +161,8 @@
|
||||
"skip_forward": "向前跳过",
|
||||
"playbackSpeed": "播放速度",
|
||||
"pause": "暂停",
|
||||
"playSimilarSongs": "播放类似的曲目"
|
||||
"playSimilarSongs": "播放类似的曲目",
|
||||
"viewQueue": "查看播放队列"
|
||||
},
|
||||
"setting": {
|
||||
"crossfadeStyle_description": "选择用于音频播放器的淡入淡出风格",
|
||||
@@ -350,7 +354,7 @@
|
||||
"volumeWidth": "音量滑块宽度",
|
||||
"volumeWidth_description": "音量滑块的宽度",
|
||||
"discordListening": "显示状态为正在监听",
|
||||
"discordListening_description": "将状态显示为 “正在监听”,而不是 “正在播放”。请注意,这当前会破坏计时器栏",
|
||||
"discordListening_description": "将状态显示为正在监听,而不是正在播放",
|
||||
"contextMenu_description": "允许您隐藏右键单击项目时显示在菜单中的项目。未选中的项目将被隐藏",
|
||||
"customCssEnable_description": "允许编写自定义 css。",
|
||||
"customCss": "自定义css",
|
||||
@@ -377,7 +381,15 @@
|
||||
"artistConfiguration": "专辑艺术家页面配置",
|
||||
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
||||
"trayEnabled_description": "显示/隐藏托盘图标/菜单。如果禁用,也会禁用最小化/退出到托盘",
|
||||
"trayEnabled": "显示托盘"
|
||||
"trayEnabled": "显示托盘",
|
||||
"translationApiProvider": "翻译api提供商",
|
||||
"translationApiProvider_description": "翻译api提供商",
|
||||
"translationApiKey": "翻译api密钥",
|
||||
"translationApiKey_description": "翻译api密钥(仅支持全球服务节点)",
|
||||
"translationTargetLanguage": "目标翻译语言",
|
||||
"translationTargetLanguage_description": "目标翻译语言",
|
||||
"lastfmApiKey": "{{lastfm}} API 密钥",
|
||||
"lastfmApiKey_description": "{{lastfm}} 的 API 密钥。封面艺术图所需"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "重启服务器使新端口生效",
|
||||
@@ -476,11 +488,14 @@
|
||||
"lyricGap": "歌词间距",
|
||||
"followCurrentLyric": "跟随当前歌词",
|
||||
"dynamicImageBlur": "图像模糊大小",
|
||||
"dynamicIsImage": "启用背景图像"
|
||||
"dynamicIsImage": "启用背景图像",
|
||||
"lyricOffset": "歌词延迟补偿(毫秒)"
|
||||
},
|
||||
"lyrics": "歌词",
|
||||
"related": "相关",
|
||||
"upNext": "即将播放"
|
||||
"upNext": "即将播放",
|
||||
"visualizer": "可视化",
|
||||
"noLyrics": "未找到歌词"
|
||||
},
|
||||
"appMenu": {
|
||||
"selectServer": "选择服务器",
|
||||
@@ -542,7 +557,8 @@
|
||||
"shareItem": "分享项目",
|
||||
"playSimilarSongs": "$t(player.playSimilarSongs)",
|
||||
"download": "下载",
|
||||
"playShuffled": "$t(player.shuffle)"
|
||||
"playShuffled": "$t(player.shuffle)",
|
||||
"moveToNext": "$t(action.moveToNext)"
|
||||
},
|
||||
"trackList": {
|
||||
"title": "$t(entity.track_other)",
|
||||
@@ -583,6 +599,14 @@
|
||||
},
|
||||
"playlist": {
|
||||
"reorder": "仅在按 ID 排序时启用重排序"
|
||||
},
|
||||
"manageServers": {
|
||||
"url": "URL",
|
||||
"title": "管理服务器",
|
||||
"serverDetails": "服务器详细信息",
|
||||
"username": "用户名",
|
||||
"editServerDetailsTooltip": "编辑服务器详细信息",
|
||||
"removeServer": "移除服务器"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -654,7 +678,8 @@
|
||||
"autoFitColumns": "列宽自适应",
|
||||
"size": "$t(common.size)",
|
||||
"itemGap": "项目间隙(px)",
|
||||
"itemSize": "项目大小 (px)"
|
||||
"itemSize": "项目大小 (px)",
|
||||
"followCurrentSong": "关注当前播放的歌曲"
|
||||
},
|
||||
"view": {
|
||||
"table": "表格",
|
||||
|
||||
+10
-10
@@ -48,7 +48,7 @@ export default class MenuBuilder {
|
||||
label: 'Electron',
|
||||
submenu: [
|
||||
{
|
||||
label: 'About ElectronReact',
|
||||
label: 'About Feishin',
|
||||
selector: 'orderFrontStandardAboutPanel:',
|
||||
},
|
||||
{ type: 'separator' },
|
||||
@@ -56,7 +56,7 @@ export default class MenuBuilder {
|
||||
{ type: 'separator' },
|
||||
{
|
||||
accelerator: 'Command+H',
|
||||
label: 'Hide ElectronReact',
|
||||
label: 'Hide Feishin',
|
||||
selector: 'hide:',
|
||||
},
|
||||
{
|
||||
@@ -147,27 +147,27 @@ export default class MenuBuilder {
|
||||
submenu: [
|
||||
{
|
||||
click() {
|
||||
shell.openExternal('https://electronjs.org');
|
||||
shell.openExternal('https://github.com/jeffvli/feishin');
|
||||
},
|
||||
label: 'Learn More',
|
||||
},
|
||||
{
|
||||
click() {
|
||||
shell.openExternal(
|
||||
'https://github.com/electron/electron/tree/main/docs#readme',
|
||||
'https://github.com/jeffvli/feishin?tab=readme-ov-file#getting-started',
|
||||
);
|
||||
},
|
||||
label: 'Documentation',
|
||||
},
|
||||
{
|
||||
click() {
|
||||
shell.openExternal('https://www.electronjs.org/community');
|
||||
shell.openExternal('https://github.com/jeffvli/feishin/discussions');
|
||||
},
|
||||
label: 'Community Discussions',
|
||||
},
|
||||
{
|
||||
click() {
|
||||
shell.openExternal('https://github.com/electron/electron/issues');
|
||||
shell.openExternal('https://github.com/jeffvli/feishin/issues');
|
||||
},
|
||||
label: 'Search Issues',
|
||||
},
|
||||
@@ -246,27 +246,27 @@ export default class MenuBuilder {
|
||||
submenu: [
|
||||
{
|
||||
click() {
|
||||
shell.openExternal('https://electronjs.org');
|
||||
shell.openExternal('https://github.com/jeffvli/feishin');
|
||||
},
|
||||
label: 'Learn More',
|
||||
},
|
||||
{
|
||||
click() {
|
||||
shell.openExternal(
|
||||
'https://github.com/electron/electron/tree/main/docs#readme',
|
||||
'https://github.com/jeffvli/feishin?tab=readme-ov-file#getting-started',
|
||||
);
|
||||
},
|
||||
label: 'Documentation',
|
||||
},
|
||||
{
|
||||
click() {
|
||||
shell.openExternal('https://www.electronjs.org/community');
|
||||
shell.openExternal('https://github.com/jeffvli/feishin/discussions');
|
||||
},
|
||||
label: 'Community Discussions',
|
||||
},
|
||||
{
|
||||
click() {
|
||||
shell.openExternal('https://github.com/electron/electron/issues');
|
||||
shell.openExternal('https://github.com/jeffvli/feishin/issues');
|
||||
},
|
||||
label: 'Search Issues',
|
||||
},
|
||||
|
||||
+131
-574
@@ -1,119 +1,11 @@
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { toast } from '/@/renderer/components/toast/index';
|
||||
import type {
|
||||
AlbumDetailArgs,
|
||||
AlbumListArgs,
|
||||
SongListArgs,
|
||||
SongDetailArgs,
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistListArgs,
|
||||
SetRatingArgs,
|
||||
ShareItemArgs,
|
||||
GenreListArgs,
|
||||
CreatePlaylistArgs,
|
||||
DeletePlaylistArgs,
|
||||
PlaylistDetailArgs,
|
||||
PlaylistListArgs,
|
||||
MusicFolderListArgs,
|
||||
PlaylistSongListArgs,
|
||||
ArtistListArgs,
|
||||
UpdatePlaylistArgs,
|
||||
UserListArgs,
|
||||
FavoriteArgs,
|
||||
TopSongListArgs,
|
||||
AddToPlaylistArgs,
|
||||
AddToPlaylistResponse,
|
||||
RemoveFromPlaylistArgs,
|
||||
RemoveFromPlaylistResponse,
|
||||
ScrobbleArgs,
|
||||
ScrobbleResponse,
|
||||
AlbumArtistDetailResponse,
|
||||
FavoriteResponse,
|
||||
CreatePlaylistResponse,
|
||||
AlbumArtistListResponse,
|
||||
AlbumDetailResponse,
|
||||
AlbumListResponse,
|
||||
ArtistListResponse,
|
||||
GenreListResponse,
|
||||
MusicFolderListResponse,
|
||||
PlaylistDetailResponse,
|
||||
PlaylistListResponse,
|
||||
RatingResponse,
|
||||
SongDetailResponse,
|
||||
SongListResponse,
|
||||
TopSongListResponse,
|
||||
UpdatePlaylistResponse,
|
||||
UserListResponse,
|
||||
AuthenticationResponse,
|
||||
SearchArgs,
|
||||
SearchResponse,
|
||||
LyricsArgs,
|
||||
LyricsResponse,
|
||||
ServerInfo,
|
||||
ServerInfoArgs,
|
||||
StructuredLyricsArgs,
|
||||
StructuredLyric,
|
||||
SimilarSongsArgs,
|
||||
Song,
|
||||
ServerType,
|
||||
ShareItemResponse,
|
||||
MoveItemArgs,
|
||||
DownloadArgs,
|
||||
TranscodingArgs,
|
||||
} from '/@/renderer/api/types';
|
||||
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
|
||||
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
|
||||
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
|
||||
import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
|
||||
import type { ServerType, ControllerEndpoint, AuthenticationResponse } from '/@/renderer/api/types';
|
||||
import { NavidromeController } from '/@/renderer/api/navidrome/navidrome-controller';
|
||||
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
|
||||
import { JellyfinController } from '/@/renderer/api/jellyfin/jellyfin-controller';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
export type ControllerEndpoint = Partial<{
|
||||
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
|
||||
authenticate: (
|
||||
url: string,
|
||||
body: { password: string; username: string },
|
||||
) => Promise<AuthenticationResponse>;
|
||||
clearPlaylist: () => void;
|
||||
createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
|
||||
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
|
||||
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
|
||||
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
|
||||
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
|
||||
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
|
||||
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
|
||||
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
|
||||
getArtistDetail: () => void;
|
||||
getArtistInfo: (args: any) => void;
|
||||
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
||||
getDownloadUrl: (args: DownloadArgs) => string;
|
||||
getFavoritesList: () => void;
|
||||
getFolderItemList: () => void;
|
||||
getFolderList: () => void;
|
||||
getFolderSongs: () => void;
|
||||
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
||||
getLyrics: (args: LyricsArgs) => Promise<LyricsResponse>;
|
||||
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
|
||||
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
|
||||
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
|
||||
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
|
||||
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
|
||||
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
|
||||
getSimilarSongs: (args: SimilarSongsArgs) => Promise<Song[]>;
|
||||
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
||||
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
||||
getStructuredLyrics: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||
getTranscodingUrl: (args: TranscodingArgs) => string;
|
||||
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
|
||||
movePlaylistItem: (args: MoveItemArgs) => Promise<void>;
|
||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
||||
search: (args: SearchArgs) => Promise<SearchResponse>;
|
||||
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
|
||||
shareItem: (args: ShareItemArgs) => Promise<ShareItemResponse>;
|
||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
|
||||
}>;
|
||||
|
||||
type ApiController = {
|
||||
jellyfin: ControllerEndpoint;
|
||||
navidrome: ControllerEndpoint;
|
||||
@@ -121,133 +13,15 @@ type ApiController = {
|
||||
};
|
||||
|
||||
const endpoints: ApiController = {
|
||||
jellyfin: {
|
||||
addToPlaylist: jfController.addToPlaylist,
|
||||
authenticate: jfController.authenticate,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: jfController.createFavorite,
|
||||
createPlaylist: jfController.createPlaylist,
|
||||
deleteFavorite: jfController.deleteFavorite,
|
||||
deletePlaylist: jfController.deletePlaylist,
|
||||
getAlbumArtistDetail: jfController.getAlbumArtistDetail,
|
||||
getAlbumArtistList: jfController.getAlbumArtistList,
|
||||
getAlbumDetail: jfController.getAlbumDetail,
|
||||
getAlbumList: jfController.getAlbumList,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
getDownloadUrl: jfController.getDownloadUrl,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: jfController.getGenreList,
|
||||
getLyrics: jfController.getLyrics,
|
||||
getMusicFolderList: jfController.getMusicFolderList,
|
||||
getPlaylistDetail: jfController.getPlaylistDetail,
|
||||
getPlaylistList: jfController.getPlaylistList,
|
||||
getPlaylistSongList: jfController.getPlaylistSongList,
|
||||
getRandomSongList: jfController.getRandomSongList,
|
||||
getServerInfo: jfController.getServerInfo,
|
||||
getSimilarSongs: jfController.getSimilarSongs,
|
||||
getSongDetail: jfController.getSongDetail,
|
||||
getSongList: jfController.getSongList,
|
||||
getStructuredLyrics: undefined,
|
||||
getTopSongs: jfController.getTopSongList,
|
||||
getTranscodingUrl: jfController.getTranscodingUrl,
|
||||
getUserList: undefined,
|
||||
movePlaylistItem: jfController.movePlaylistItem,
|
||||
removeFromPlaylist: jfController.removeFromPlaylist,
|
||||
scrobble: jfController.scrobble,
|
||||
search: jfController.search,
|
||||
setRating: undefined,
|
||||
shareItem: undefined,
|
||||
updatePlaylist: jfController.updatePlaylist,
|
||||
},
|
||||
navidrome: {
|
||||
addToPlaylist: ndController.addToPlaylist,
|
||||
authenticate: ndController.authenticate,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: ssController.createFavorite,
|
||||
createPlaylist: ndController.createPlaylist,
|
||||
deleteFavorite: ssController.removeFavorite,
|
||||
deletePlaylist: ndController.deletePlaylist,
|
||||
getAlbumArtistDetail: ndController.getAlbumArtistDetail,
|
||||
getAlbumArtistList: ndController.getAlbumArtistList,
|
||||
getAlbumDetail: ndController.getAlbumDetail,
|
||||
getAlbumList: ndController.getAlbumList,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
getDownloadUrl: ssController.getDownloadUrl,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: ndController.getGenreList,
|
||||
getLyrics: undefined,
|
||||
getMusicFolderList: ssController.getMusicFolderList,
|
||||
getPlaylistDetail: ndController.getPlaylistDetail,
|
||||
getPlaylistList: ndController.getPlaylistList,
|
||||
getPlaylistSongList: ndController.getPlaylistSongList,
|
||||
getRandomSongList: ssController.getRandomSongList,
|
||||
getServerInfo: ndController.getServerInfo,
|
||||
getSimilarSongs: ndController.getSimilarSongs,
|
||||
getSongDetail: ndController.getSongDetail,
|
||||
getSongList: ndController.getSongList,
|
||||
getStructuredLyrics: ssController.getStructuredLyrics,
|
||||
getTopSongs: ssController.getTopSongList,
|
||||
getTranscodingUrl: ssController.getTranscodingUrl,
|
||||
getUserList: ndController.getUserList,
|
||||
movePlaylistItem: ndController.movePlaylistItem,
|
||||
removeFromPlaylist: ndController.removeFromPlaylist,
|
||||
scrobble: ssController.scrobble,
|
||||
search: ssController.search3,
|
||||
setRating: ssController.setRating,
|
||||
shareItem: ndController.shareItem,
|
||||
updatePlaylist: ndController.updatePlaylist,
|
||||
},
|
||||
subsonic: {
|
||||
authenticate: ssController.authenticate,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: ssController.createFavorite,
|
||||
createPlaylist: undefined,
|
||||
deleteFavorite: ssController.removeFavorite,
|
||||
deletePlaylist: undefined,
|
||||
getAlbumArtistDetail: undefined,
|
||||
getAlbumArtistList: undefined,
|
||||
getAlbumDetail: undefined,
|
||||
getAlbumList: undefined,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
getDownloadUrl: ssController.getDownloadUrl,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: undefined,
|
||||
getLyrics: undefined,
|
||||
getMusicFolderList: ssController.getMusicFolderList,
|
||||
getPlaylistDetail: undefined,
|
||||
getPlaylistList: undefined,
|
||||
getServerInfo: ssController.getServerInfo,
|
||||
getSimilarSongs: ssController.getSimilarSongs,
|
||||
getSongDetail: undefined,
|
||||
getSongList: undefined,
|
||||
getStructuredLyrics: ssController.getStructuredLyrics,
|
||||
getTopSongs: ssController.getTopSongList,
|
||||
getTranscodingUrl: ssController.getTranscodingUrl,
|
||||
getUserList: undefined,
|
||||
scrobble: ssController.scrobble,
|
||||
search: ssController.search3,
|
||||
setRating: undefined,
|
||||
shareItem: undefined,
|
||||
updatePlaylist: undefined,
|
||||
},
|
||||
jellyfin: JellyfinController,
|
||||
navidrome: NavidromeController,
|
||||
subsonic: SubsonicController,
|
||||
};
|
||||
|
||||
const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => {
|
||||
const apiController = <K extends keyof ControllerEndpoint>(
|
||||
endpoint: K,
|
||||
type?: ServerType,
|
||||
): NonNullable<ControllerEndpoint[K]> => {
|
||||
const serverType = type || useAuthStore.getState().currentServer?.type;
|
||||
|
||||
if (!serverType) {
|
||||
@@ -277,344 +51,127 @@ const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) =>
|
||||
);
|
||||
}
|
||||
|
||||
return endpoints[serverType][endpoint];
|
||||
return controllerFn;
|
||||
};
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
body: { legacy?: boolean; password: string; username: string },
|
||||
type: ServerType,
|
||||
) => {
|
||||
return (apiController('authenticate', type) as ControllerEndpoint['authenticate'])?.(url, body);
|
||||
};
|
||||
export interface GeneralController extends Omit<Required<ControllerEndpoint>, 'authenticate'> {
|
||||
authenticate: (
|
||||
url: string,
|
||||
body: { legacy?: boolean; password: string; username: string },
|
||||
type: ServerType,
|
||||
) => Promise<AuthenticationResponse>;
|
||||
}
|
||||
|
||||
const getAlbumList = async (args: AlbumListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getAlbumDetail = async (args: AlbumDetailArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumDetail',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumDetail']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getSongList = async (args: SongListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getSongList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getSongList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getSongDetail = async (args: SongDetailArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getSongDetail',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getSongDetail']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getMusicFolderList = async (args: MusicFolderListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getMusicFolderList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getMusicFolderList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getGenreList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getGenreList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumArtistDetail',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumArtistDetail']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getAlbumArtistList = async (args: AlbumArtistListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumArtistList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumArtistList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getArtistList = async (args: ArtistListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getArtistList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getArtistList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getPlaylistList = async (args: PlaylistListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getPlaylistList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getPlaylistList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const createPlaylist = async (args: CreatePlaylistArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'createPlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['createPlaylist']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const updatePlaylist = async (args: UpdatePlaylistArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'updatePlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['updatePlaylist']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const deletePlaylist = async (args: DeletePlaylistArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'deletePlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['deletePlaylist']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const addToPlaylist = async (args: AddToPlaylistArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'addToPlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['addToPlaylist']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'removeFromPlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['removeFromPlaylist']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getPlaylistDetail = async (args: PlaylistDetailArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getPlaylistDetail',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getPlaylistDetail']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getPlaylistSongList = async (args: PlaylistSongListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getPlaylistSongList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getPlaylistSongList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getUserList = async (args: UserListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getUserList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getUserList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const createFavorite = async (args: FavoriteArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'createFavorite',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['createFavorite']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const deleteFavorite = async (args: FavoriteArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'deleteFavorite',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['deleteFavorite']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const updateRating = async (args: SetRatingArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'setRating',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['setRating']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const shareItem = async (args: ShareItemArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'shareItem',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['shareItem']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getTopSongList = async (args: TopSongListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getTopSongs',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getTopSongs']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const scrobble = async (args: ScrobbleArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'scrobble',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['scrobble']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const search = async (args: SearchArgs) => {
|
||||
return (
|
||||
apiController('search', args.apiClientProps.server?.type) as ControllerEndpoint['search']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getRandomSongList = async (args: RandomSongListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getRandomSongList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getRandomSongList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getLyrics = async (args: LyricsArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getLyrics',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getLyrics']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getServerInfo = async (args: ServerInfoArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getServerInfo',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getServerInfo']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getStructuredLyrics = async (args: StructuredLyricsArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getStructuredLyrics',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getStructuredLyrics']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getSimilarSongs = async (args: SimilarSongsArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getSimilarSongs',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getSimilarSongs']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
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 = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
createFavorite,
|
||||
createPlaylist,
|
||||
deleteFavorite,
|
||||
deletePlaylist,
|
||||
getAlbumArtistDetail,
|
||||
getAlbumArtistList,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getArtistList,
|
||||
getDownloadUrl,
|
||||
getGenreList,
|
||||
getLyrics,
|
||||
getMusicFolderList,
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getRandomSongList,
|
||||
getServerInfo,
|
||||
getSimilarSongs,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getStructuredLyrics,
|
||||
getTopSongList,
|
||||
getTranscodingUrl,
|
||||
getUserList,
|
||||
movePlaylistItem,
|
||||
removeFromPlaylist,
|
||||
scrobble,
|
||||
search,
|
||||
shareItem,
|
||||
updatePlaylist,
|
||||
updateRating,
|
||||
export const controller: GeneralController = {
|
||||
addToPlaylist(args) {
|
||||
return apiController('addToPlaylist', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
authenticate(url, body, type) {
|
||||
return apiController('authenticate', type)(url, body);
|
||||
},
|
||||
createFavorite(args) {
|
||||
return apiController('createFavorite', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
createPlaylist(args) {
|
||||
return apiController('createPlaylist', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
deleteFavorite(args) {
|
||||
return apiController('deleteFavorite', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
deletePlaylist(args) {
|
||||
return apiController('deletePlaylist', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getAlbumArtistDetail(args) {
|
||||
return apiController('getAlbumArtistDetail', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getAlbumArtistList(args) {
|
||||
return apiController('getAlbumArtistList', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getAlbumArtistListCount(args) {
|
||||
return apiController('getAlbumArtistListCount', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getAlbumDetail(args) {
|
||||
return apiController('getAlbumDetail', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getAlbumList(args) {
|
||||
return apiController('getAlbumList', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getAlbumListCount(args) {
|
||||
return apiController('getAlbumListCount', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getDownloadUrl(args) {
|
||||
return apiController('getDownloadUrl', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getGenreList(args) {
|
||||
return apiController('getGenreList', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getLyrics(args) {
|
||||
return apiController('getLyrics', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getMusicFolderList(args) {
|
||||
return apiController('getMusicFolderList', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getPlaylistDetail(args) {
|
||||
return apiController('getPlaylistDetail', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getPlaylistList(args) {
|
||||
return apiController('getPlaylistList', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getPlaylistListCount(args) {
|
||||
return apiController('getPlaylistListCount', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getPlaylistSongList(args) {
|
||||
return apiController('getPlaylistSongList', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getRandomSongList(args) {
|
||||
return apiController('getRandomSongList', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getServerInfo(args) {
|
||||
return apiController('getServerInfo', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getSimilarSongs(args) {
|
||||
return apiController('getSimilarSongs', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getSongDetail(args) {
|
||||
return apiController('getSongDetail', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getSongList(args) {
|
||||
return apiController('getSongList', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getSongListCount(args) {
|
||||
return apiController('getSongListCount', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getStructuredLyrics(args) {
|
||||
return apiController('getStructuredLyrics', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getTopSongs(args) {
|
||||
return apiController('getTopSongs', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getTranscodingUrl(args) {
|
||||
return apiController('getTranscodingUrl', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
getUserList(args) {
|
||||
return apiController('getUserList', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
movePlaylistItem(args) {
|
||||
return apiController('movePlaylistItem', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
removeFromPlaylist(args) {
|
||||
return apiController('removeFromPlaylist', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
scrobble(args) {
|
||||
return apiController('scrobble', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
search(args) {
|
||||
return apiController('search', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
setRating(args) {
|
||||
return apiController('setRating', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
shareItem(args) {
|
||||
return apiController('shareItem', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
updatePlaylist(args) {
|
||||
return apiController('updatePlaylist', args.apiClientProps.server?.type)?.(args);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -574,7 +574,7 @@ export enum JFSongListSort {
|
||||
ARTIST = 'Artist,Album,SortName',
|
||||
COMMUNITY_RATING = 'CommunityRating,SortName',
|
||||
DURATION = 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME = 'SortName,Name',
|
||||
NAME = 'Name',
|
||||
PLAY_COUNT = 'PlayCount,SortName',
|
||||
RANDOM = 'Random,SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -545,7 +545,7 @@ const songListSort = {
|
||||
ARTIST: 'Artist,Album,SortName',
|
||||
COMMUNITY_RATING: 'CommunityRating,SortName',
|
||||
DURATION: 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME: 'SortName,Name',
|
||||
NAME: 'Name',
|
||||
PLAY_COUNT: 'PlayCount,SortName',
|
||||
RANDOM: 'Random,SortName',
|
||||
RECENTLY_ADDED: 'DateCreated,SortName',
|
||||
|
||||
@@ -199,17 +199,17 @@ export type NDGenreListParams = {
|
||||
NDOrder;
|
||||
|
||||
export enum NDAlbumListSort {
|
||||
ALBUM_ARTIST = 'albumArtist',
|
||||
ALBUM_ARTIST = 'album_artist',
|
||||
ARTIST = 'artist',
|
||||
DURATION = 'duration',
|
||||
NAME = 'name',
|
||||
PLAY_COUNT = 'playCount',
|
||||
PLAY_COUNT = 'play_count',
|
||||
PLAY_DATE = 'play_date',
|
||||
RANDOM = 'random',
|
||||
RATING = 'rating',
|
||||
RECENTLY_ADDED = 'recently_added',
|
||||
SONG_COUNT = 'songCount',
|
||||
STARRED = 'starred',
|
||||
STARRED = 'starred_at',
|
||||
YEAR = 'max_year',
|
||||
}
|
||||
|
||||
@@ -229,15 +229,15 @@ export type NDAlbumListParams = {
|
||||
NDOrder;
|
||||
|
||||
export enum NDSongListSort {
|
||||
ALBUM = 'album, order_album_artist_name, disc_number, track_number, title',
|
||||
ALBUM_ARTIST = 'order_album_artist_name, album, disc_number, track_number, title',
|
||||
ALBUM_SONGS = 'album, discNumber, trackNumber',
|
||||
ALBUM = 'album',
|
||||
ALBUM_ARTIST = 'order_album_artist_name',
|
||||
ALBUM_SONGS = 'album',
|
||||
ARTIST = 'artist',
|
||||
BPM = 'bpm',
|
||||
CHANNELS = 'channels',
|
||||
COMMENT = 'comment',
|
||||
DURATION = 'duration',
|
||||
FAVORITED = 'starred ASC, starredAt ASC',
|
||||
FAVORITED = 'starred_at',
|
||||
GENRE = 'genre',
|
||||
ID = 'id',
|
||||
PLAY_COUNT = 'playCount',
|
||||
@@ -247,7 +247,7 @@ export enum NDSongListSort {
|
||||
RECENTLY_ADDED = 'createdAt',
|
||||
TITLE = 'title',
|
||||
TRACK = 'track',
|
||||
YEAR = 'year, album, discNumber, trackNumber',
|
||||
YEAR = 'year',
|
||||
}
|
||||
|
||||
export type NDSongListParams = {
|
||||
@@ -261,7 +261,7 @@ export type NDSongListParams = {
|
||||
|
||||
export enum NDAlbumArtistListSort {
|
||||
ALBUM_COUNT = 'albumCount',
|
||||
FAVORITED = 'starred ASC, starredAt ASC',
|
||||
FAVORITED = 'starred_at',
|
||||
NAME = 'name',
|
||||
PLAY_COUNT = 'playCount',
|
||||
RATING = 'rating',
|
||||
@@ -353,7 +353,7 @@ export type NDPlaylistListResponse = NDPlaylist[];
|
||||
export enum NDPlaylistListSort {
|
||||
DURATION = 'duration',
|
||||
NAME = 'name',
|
||||
OWNER = 'ownerName',
|
||||
OWNER = 'owner_name',
|
||||
PUBLIC = 'public',
|
||||
SONG_COUNT = 'songCount',
|
||||
UPDATED_AT = 'updatedAt',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,7 +99,7 @@ const normalizeSong = (
|
||||
item.rgAlbumGain || item.rgTrackGain
|
||||
? { album: item.rgAlbumGain, track: item.rgTrackGain }
|
||||
: null,
|
||||
genres: item.genres?.map((genre) => ({
|
||||
genres: (item.genres || []).map((genre) => ({
|
||||
id: genre.id,
|
||||
imageUrl: null,
|
||||
itemType: LibraryItem.GENRE,
|
||||
@@ -121,7 +121,7 @@ const normalizeSong = (
|
||||
playlistItemId,
|
||||
releaseDate: (item.releaseDate
|
||||
? new Date(item.releaseDate)
|
||||
: new Date(item.year, 0, 1)
|
||||
: new Date(Date.UTC(item.year, 0, 1))
|
||||
).toISOString(),
|
||||
releaseYear: String(item.year),
|
||||
serverId: server?.id || 'unknown',
|
||||
@@ -162,7 +162,7 @@ const normalizeAlbum = (
|
||||
comment: item.comment || null,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
duration: item.duration * 1000 || null,
|
||||
genres: item.genres?.map((genre) => ({
|
||||
genres: (item.genres || []).map((genre) => ({
|
||||
id: genre.id,
|
||||
imageUrl: null,
|
||||
itemType: LibraryItem.GENRE,
|
||||
@@ -221,7 +221,7 @@ const normalizeAlbumArtist = (
|
||||
backgroundImageUrl: null,
|
||||
biography: item.biography || null,
|
||||
duration: null,
|
||||
genres: item.genres?.map((genre) => ({
|
||||
genres: (item.genres || []).map((genre) => ({
|
||||
id: genre.id,
|
||||
imageUrl: null,
|
||||
itemType: LibraryItem.GENRE,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
NDAlbumArtistListSort,
|
||||
NDAlbumListSort,
|
||||
NDPlaylistListSort,
|
||||
NDSongListSort,
|
||||
} from '/@/renderer/api/navidrome.types';
|
||||
|
||||
const sortOrderValues = ['ASC', 'DESC'] as const;
|
||||
|
||||
@@ -70,7 +76,7 @@ const albumArtist = z.object({
|
||||
externalInfoUpdatedAt: z.string(),
|
||||
externalUrl: z.string(),
|
||||
fullText: z.string(),
|
||||
genres: z.array(genre),
|
||||
genres: z.array(genre).nullable(),
|
||||
id: z.string(),
|
||||
largeImageUrl: z.string().optional(),
|
||||
mbzArtistId: z.string().optional(),
|
||||
@@ -89,17 +95,8 @@ const albumArtist = z.object({
|
||||
|
||||
const albumArtistList = z.array(albumArtist);
|
||||
|
||||
const ndAlbumArtistListSort = {
|
||||
ALBUM_COUNT: 'albumCount',
|
||||
FAVORITED: 'starred ASC, starredAt ASC',
|
||||
NAME: 'name',
|
||||
PLAY_COUNT: 'playCount',
|
||||
RATING: 'rating',
|
||||
SONG_COUNT: 'songCount',
|
||||
} as const;
|
||||
|
||||
const albumArtistListParameters = paginationParameters.extend({
|
||||
_sort: z.nativeEnum(ndAlbumArtistListSort).optional(),
|
||||
_sort: z.nativeEnum(NDAlbumArtistListSort).optional(),
|
||||
genre_id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
starred: z.boolean().optional(),
|
||||
@@ -119,7 +116,7 @@ const album = z.object({
|
||||
duration: z.number(),
|
||||
fullText: z.string(),
|
||||
genre: z.string(),
|
||||
genres: z.array(genre),
|
||||
genres: z.array(genre).nullable(),
|
||||
id: z.string(),
|
||||
maxYear: z.number(),
|
||||
mbzAlbumArtistId: z.string().optional(),
|
||||
@@ -145,23 +142,8 @@ const album = z.object({
|
||||
|
||||
const albumList = z.array(album);
|
||||
|
||||
const ndAlbumListSort = {
|
||||
ALBUM_ARTIST: 'albumArtist',
|
||||
ARTIST: 'artist',
|
||||
DURATION: 'duration',
|
||||
NAME: 'name',
|
||||
PLAY_COUNT: 'playCount',
|
||||
PLAY_DATE: 'play_date',
|
||||
RANDOM: 'random',
|
||||
RATING: 'rating',
|
||||
RECENTLY_ADDED: 'recently_added',
|
||||
SONG_COUNT: 'songCount',
|
||||
STARRED: 'starred',
|
||||
YEAR: 'max_year',
|
||||
} as const;
|
||||
|
||||
const albumListParameters = paginationParameters.extend({
|
||||
_sort: z.nativeEnum(ndAlbumListSort).optional(),
|
||||
_sort: z.nativeEnum(NDAlbumListSort).optional(),
|
||||
album_id: z.string().optional(),
|
||||
artist_id: z.string().optional(),
|
||||
compilation: z.boolean().optional(),
|
||||
@@ -198,7 +180,7 @@ const song = z.object({
|
||||
externalUrl: z.string().optional(),
|
||||
fullText: z.string(),
|
||||
genre: z.string(),
|
||||
genres: z.array(genre),
|
||||
genres: z.array(genre).nullable(),
|
||||
hasCoverArt: z.boolean(),
|
||||
id: z.string(),
|
||||
imageFiles: z.string().optional(),
|
||||
@@ -237,33 +219,12 @@ const song = z.object({
|
||||
|
||||
const songList = z.array(song);
|
||||
|
||||
const ndSongListSort = {
|
||||
ALBUM: 'album, order_album_artist_name, disc_number, track_number, title',
|
||||
ALBUM_ARTIST: 'order_album_artist_name, album, disc_number, track_number, title',
|
||||
ALBUM_SONGS: 'album, discNumber, trackNumber',
|
||||
ARTIST: 'artist',
|
||||
BPM: 'bpm',
|
||||
CHANNELS: 'channels',
|
||||
COMMENT: 'comment',
|
||||
DURATION: 'duration',
|
||||
FAVORITED: 'starred ASC, starredAt ASC',
|
||||
GENRE: 'genre',
|
||||
ID: 'id',
|
||||
PLAY_COUNT: 'playCount',
|
||||
PLAY_DATE: 'playDate',
|
||||
RATING: 'rating',
|
||||
RECENTLY_ADDED: 'createdAt',
|
||||
TITLE: 'title',
|
||||
TRACK: 'track',
|
||||
YEAR: 'year, album, discNumber, trackNumber',
|
||||
};
|
||||
|
||||
const songListParameters = paginationParameters.extend({
|
||||
_sort: z.nativeEnum(ndSongListSort).optional(),
|
||||
_sort: z.nativeEnum(NDSongListSort).optional(),
|
||||
album_artist_id: z.array(z.string()).optional(),
|
||||
album_id: z.array(z.string()).optional(),
|
||||
artist_id: z.array(z.string()).optional(),
|
||||
genre_id: z.string().optional(),
|
||||
genre_id: z.array(z.string()).optional(),
|
||||
path: z.string().optional(),
|
||||
starred: z.boolean().optional(),
|
||||
title: z.string().optional(),
|
||||
@@ -290,17 +251,8 @@ const playlist = z.object({
|
||||
|
||||
const playlistList = z.array(playlist);
|
||||
|
||||
const ndPlaylistListSort = {
|
||||
DURATION: 'duration',
|
||||
NAME: 'name',
|
||||
OWNER: 'ownerName',
|
||||
PUBLIC: 'public',
|
||||
SONG_COUNT: 'songCount',
|
||||
UPDATED_AT: 'updatedAt',
|
||||
} as const;
|
||||
|
||||
const playlistListParameters = paginationParameters.extend({
|
||||
_sort: z.nativeEnum(ndPlaylistListSort).optional(),
|
||||
_sort: z.nativeEnum(NDPlaylistListSort).optional(),
|
||||
owner_id: z.string().optional(),
|
||||
q: z.string().optional(),
|
||||
smart: z.boolean().optional(),
|
||||
@@ -367,11 +319,11 @@ const moveItem = z.null();
|
||||
|
||||
export const ndType = {
|
||||
_enum: {
|
||||
albumArtistList: ndAlbumArtistListSort,
|
||||
albumList: ndAlbumListSort,
|
||||
albumArtistList: NDAlbumArtistListSort,
|
||||
albumList: NDAlbumListSort,
|
||||
genreList: genreListSort,
|
||||
playlistList: ndPlaylistListSort,
|
||||
songList: ndSongListSort,
|
||||
playlistList: NDPlaylistListSort,
|
||||
songList: NDSongListSort,
|
||||
userList: ndUserListSort,
|
||||
},
|
||||
_parameters: {
|
||||
|
||||
@@ -50,6 +50,19 @@ export const queryKeys: Record<
|
||||
Record<string, (...props: any) => QueryFunctionContext['queryKey']>
|
||||
> = {
|
||||
albumArtists: {
|
||||
count: (serverId: string, query?: AlbumArtistListQuery) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
|
||||
if (query && pagination) {
|
||||
return [serverId, 'albumArtists', 'count', filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
return [serverId, 'albumArtists', 'count', filter] as const;
|
||||
}
|
||||
|
||||
return [serverId, 'albumArtists', 'count'] as const;
|
||||
},
|
||||
detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
|
||||
if (query) return [serverId, 'albumArtists', 'detail', query] as const;
|
||||
return [serverId, 'albumArtists', 'detail'] as const;
|
||||
@@ -73,6 +86,27 @@ export const queryKeys: Record<
|
||||
},
|
||||
},
|
||||
albums: {
|
||||
count: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
|
||||
if (query && pagination && artistId) {
|
||||
return [serverId, 'albums', 'count', artistId, filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query && pagination) {
|
||||
return [serverId, 'albums', 'count', filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query && artistId) {
|
||||
return [serverId, 'albums', 'count', artistId, filter] as const;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
return [serverId, 'albums', 'count', filter] as const;
|
||||
}
|
||||
|
||||
return [serverId, 'albums', 'count'] as const;
|
||||
},
|
||||
detail: (serverId: string, query?: AlbumDetailQuery) =>
|
||||
[serverId, 'albums', 'detail', query] as const,
|
||||
list: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
|
||||
@@ -208,6 +242,18 @@ export const queryKeys: Record<
|
||||
root: (serverId: string) => [serverId] as const,
|
||||
},
|
||||
songs: {
|
||||
count: (serverId: string, query?: SongListQuery) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
if (query && pagination) {
|
||||
return [serverId, 'songs', 'count', filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
return [serverId, 'songs', 'count', filter] as const;
|
||||
}
|
||||
|
||||
return [serverId, 'songs', 'count'] as const;
|
||||
},
|
||||
detail: (serverId: string, query?: SongDetailQuery) => {
|
||||
if (query) return [serverId, 'songs', 'detail', query] as const;
|
||||
return [serverId, 'songs', 'detail'] as const;
|
||||
|
||||
@@ -27,6 +27,46 @@ export const contract = c.router({
|
||||
200: ssType._response.createFavorite,
|
||||
},
|
||||
},
|
||||
createPlaylist: {
|
||||
method: 'GET',
|
||||
path: 'createPlaylist.view',
|
||||
query: ssType._parameters.createPlaylist,
|
||||
responses: {
|
||||
200: ssType._response.createPlaylist,
|
||||
},
|
||||
},
|
||||
deletePlaylist: {
|
||||
method: 'GET',
|
||||
path: 'deletePlaylist.view',
|
||||
query: ssType._parameters.deletePlaylist,
|
||||
responses: {
|
||||
200: ssType._response.baseResponse,
|
||||
},
|
||||
},
|
||||
getAlbum: {
|
||||
method: 'GET',
|
||||
path: 'getAlbum.view',
|
||||
query: ssType._parameters.getAlbum,
|
||||
responses: {
|
||||
200: ssType._response.getAlbum,
|
||||
},
|
||||
},
|
||||
getAlbumList2: {
|
||||
method: 'GET',
|
||||
path: 'getAlbumList2.view',
|
||||
query: ssType._parameters.getAlbumList2,
|
||||
responses: {
|
||||
200: ssType._response.getAlbumList2,
|
||||
},
|
||||
},
|
||||
getArtist: {
|
||||
method: 'GET',
|
||||
path: 'getArtist.view',
|
||||
query: ssType._parameters.getArtist,
|
||||
responses: {
|
||||
200: ssType._response.getArtist,
|
||||
},
|
||||
},
|
||||
getArtistInfo: {
|
||||
method: 'GET',
|
||||
path: 'getArtistInfo.view',
|
||||
@@ -35,6 +75,22 @@ export const contract = c.router({
|
||||
200: ssType._response.artistInfo,
|
||||
},
|
||||
},
|
||||
getArtists: {
|
||||
method: 'GET',
|
||||
path: 'getArtists.view',
|
||||
query: ssType._parameters.getArtists,
|
||||
responses: {
|
||||
200: ssType._response.getArtists,
|
||||
},
|
||||
},
|
||||
getGenres: {
|
||||
method: 'GET',
|
||||
path: 'getGenres.view',
|
||||
query: ssType._parameters.getGenres,
|
||||
responses: {
|
||||
200: ssType._response.getGenres,
|
||||
},
|
||||
},
|
||||
getMusicFolderList: {
|
||||
method: 'GET',
|
||||
path: 'getMusicFolders.view',
|
||||
@@ -42,6 +98,22 @@ export const contract = c.router({
|
||||
200: ssType._response.musicFolderList,
|
||||
},
|
||||
},
|
||||
getPlaylist: {
|
||||
method: 'GET',
|
||||
path: 'getPlaylist.view',
|
||||
query: ssType._parameters.getPlaylist,
|
||||
responses: {
|
||||
200: ssType._response.getPlaylist,
|
||||
},
|
||||
},
|
||||
getPlaylists: {
|
||||
method: 'GET',
|
||||
path: 'getPlaylists.view',
|
||||
query: ssType._parameters.getPlaylists,
|
||||
responses: {
|
||||
200: ssType._response.getPlaylists,
|
||||
},
|
||||
},
|
||||
getRandomSongList: {
|
||||
method: 'GET',
|
||||
path: 'getRandomSongs.view',
|
||||
@@ -65,6 +137,30 @@ export const contract = c.router({
|
||||
200: ssType._response.similarSongs,
|
||||
},
|
||||
},
|
||||
getSong: {
|
||||
method: 'GET',
|
||||
path: 'getSong.view',
|
||||
query: ssType._parameters.getSong,
|
||||
responses: {
|
||||
200: ssType._response.getSong,
|
||||
},
|
||||
},
|
||||
getSongsByGenre: {
|
||||
method: 'GET',
|
||||
path: 'getSongsByGenre.view',
|
||||
query: ssType._parameters.getSongsByGenre,
|
||||
responses: {
|
||||
200: ssType._response.getSongsByGenre,
|
||||
},
|
||||
},
|
||||
getStarred: {
|
||||
method: 'GET',
|
||||
path: 'getStarred.view',
|
||||
query: ssType._parameters.getStarred,
|
||||
responses: {
|
||||
200: ssType._response.getStarred,
|
||||
},
|
||||
},
|
||||
getStructuredLyrics: {
|
||||
method: 'GET',
|
||||
path: 'getLyricsBySongId.view',
|
||||
@@ -120,6 +216,14 @@ export const contract = c.router({
|
||||
200: ssType._response.setRating,
|
||||
},
|
||||
},
|
||||
updatePlaylist: {
|
||||
method: 'GET',
|
||||
path: 'updatePlaylist.view',
|
||||
query: ssType._parameters.updatePlaylist,
|
||||
responses: {
|
||||
200: ssType._response.baseResponse,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const axiosClient = axios.create({});
|
||||
@@ -242,7 +346,7 @@ export const ssApiClient = (args: {
|
||||
|
||||
return {
|
||||
body: response?.data,
|
||||
headers: response.headers as any,
|
||||
headers: response?.headers as any,
|
||||
status: response?.status,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ import {
|
||||
Album,
|
||||
ServerListItem,
|
||||
ServerType,
|
||||
Playlist,
|
||||
Genre,
|
||||
} from '/@/renderer/api/types';
|
||||
|
||||
const getCoverArtUrl = (args: {
|
||||
@@ -22,51 +24,53 @@ const getCoverArtUrl = (args: {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
const url =
|
||||
`${args.baseUrl}/rest/getCoverArt.view` +
|
||||
`?id=${args.coverArtId}` +
|
||||
`&${args.credential}` +
|
||||
`&${encodeURIComponent(args.credential || '')}` +
|
||||
'&v=1.13.0' +
|
||||
'&c=feishin' +
|
||||
`&size=${size}`
|
||||
);
|
||||
`&size=${size}`;
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof ssType._response.song>,
|
||||
server: ServerListItem | null,
|
||||
deviceId: string,
|
||||
size?: number,
|
||||
): QueueSong => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt,
|
||||
coverArtId: item.coverArt?.toString(),
|
||||
credential: server?.credential,
|
||||
size: 100,
|
||||
size: size || 300,
|
||||
}) || null;
|
||||
|
||||
const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`;
|
||||
const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${encodeURIComponent(server?.credential || '')}`;
|
||||
|
||||
return {
|
||||
album: item.album || '',
|
||||
albumArtists: [
|
||||
{
|
||||
id: item.artistId || '',
|
||||
id: item.artistId?.toString() || '',
|
||||
imageUrl: null,
|
||||
name: item.artist || '',
|
||||
},
|
||||
],
|
||||
albumId: item.albumId || '',
|
||||
albumId: item.albumId?.toString() || '',
|
||||
artistName: item.artist || '',
|
||||
artists: [
|
||||
{
|
||||
id: item.artistId || '',
|
||||
id: item.artistId?.toString() || '',
|
||||
imageUrl: null,
|
||||
name: item.artist || '',
|
||||
},
|
||||
],
|
||||
bitRate: item.bitRate || 0,
|
||||
bpm: null,
|
||||
bpm: item.bpm || null,
|
||||
channels: null,
|
||||
comment: null,
|
||||
compilation: null,
|
||||
@@ -92,7 +96,7 @@ const normalizeSong = (
|
||||
},
|
||||
]
|
||||
: [],
|
||||
id: item.id,
|
||||
id: item.id.toString(),
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
@@ -123,15 +127,18 @@ const normalizeSong = (
|
||||
};
|
||||
|
||||
const normalizeAlbumArtist = (
|
||||
item: z.infer<typeof ssType._response.albumArtist>,
|
||||
item:
|
||||
| z.infer<typeof ssType._response.albumArtist>
|
||||
| z.infer<typeof ssType._response.artistListEntry>,
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
): AlbumArtist => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt,
|
||||
coverArtId: item.coverArt?.toString(),
|
||||
credential: server?.credential,
|
||||
size: 100,
|
||||
size: imageSize || 100,
|
||||
}) || null;
|
||||
|
||||
return {
|
||||
@@ -140,7 +147,7 @@ const normalizeAlbumArtist = (
|
||||
biography: null,
|
||||
duration: null,
|
||||
genres: [],
|
||||
id: item.id,
|
||||
id: item.id.toString(),
|
||||
imageUrl,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: null,
|
||||
@@ -157,27 +164,30 @@ const normalizeAlbumArtist = (
|
||||
};
|
||||
|
||||
const normalizeAlbum = (
|
||||
item: z.infer<typeof ssType._response.album>,
|
||||
item: z.infer<typeof ssType._response.album> | z.infer<typeof ssType._response.albumListEntry>,
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
): Album => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt,
|
||||
coverArtId: item.coverArt?.toString(),
|
||||
credential: server?.credential,
|
||||
size: 300,
|
||||
size: imageSize || 300,
|
||||
}) || null;
|
||||
|
||||
return {
|
||||
albumArtist: item.artist,
|
||||
albumArtists: item.artistId
|
||||
? [{ id: item.artistId, imageUrl: null, name: item.artist }]
|
||||
? [{ id: item.artistId.toString(), imageUrl: null, name: item.artist }]
|
||||
: [],
|
||||
artists: item.artistId
|
||||
? [{ id: item.artistId.toString(), imageUrl: null, name: item.artist }]
|
||||
: [],
|
||||
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
|
||||
backdropImageUrl: null,
|
||||
comment: null,
|
||||
createdAt: item.created,
|
||||
duration: item.duration,
|
||||
duration: item.duration * 1000,
|
||||
genres: item.genre
|
||||
? [
|
||||
{
|
||||
@@ -188,7 +198,7 @@ const normalizeAlbum = (
|
||||
},
|
||||
]
|
||||
: [],
|
||||
id: item.id,
|
||||
id: item.id.toString(),
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl,
|
||||
isCompilation: null,
|
||||
@@ -198,13 +208,16 @@ const normalizeAlbum = (
|
||||
name: item.name,
|
||||
originalDate: null,
|
||||
playCount: null,
|
||||
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,
|
||||
releaseDate: item.year ? new Date(Date.UTC(item.year, 0, 1)).toISOString() : null,
|
||||
releaseYear: item.year ? Number(item.year) : null,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.SUBSONIC,
|
||||
size: null,
|
||||
songCount: item.songCount,
|
||||
songs: [],
|
||||
songs:
|
||||
(item as z.infer<typeof ssType._response.album>).song?.map((song) =>
|
||||
normalizeSong(song, server, ''),
|
||||
) || [],
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.created,
|
||||
userFavorite: item.starred || false,
|
||||
@@ -212,8 +225,51 @@ const normalizeAlbum = (
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePlaylist = (
|
||||
item:
|
||||
| z.infer<typeof ssType._response.playlist>
|
||||
| z.infer<typeof ssType._response.playlistListEntry>,
|
||||
server: ServerListItem | null,
|
||||
): Playlist => {
|
||||
return {
|
||||
description: item.comment || null,
|
||||
duration: item.duration,
|
||||
genres: [],
|
||||
id: item.id.toString(),
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl: getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt?.toString(),
|
||||
credential: server?.credential,
|
||||
size: 300,
|
||||
}),
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
name: item.name,
|
||||
owner: item.owner,
|
||||
ownerId: item.owner,
|
||||
public: item.public,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.SUBSONIC,
|
||||
size: null,
|
||||
songCount: item.songCount,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeGenre = (item: z.infer<typeof ssType._response.genre>): Genre => {
|
||||
return {
|
||||
albumCount: item.albumCount,
|
||||
id: item.value,
|
||||
imageUrl: null,
|
||||
itemType: LibraryItem.GENRE,
|
||||
name: item.value,
|
||||
songCount: item.songCount,
|
||||
};
|
||||
};
|
||||
|
||||
export const ssNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
genre: normalizeGenre,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
};
|
||||
|
||||
@@ -19,6 +19,8 @@ const authenticateParameters = z.object({
|
||||
v: z.string(),
|
||||
});
|
||||
|
||||
const id = z.number().or(z.string());
|
||||
|
||||
const createFavoriteParameters = z.object({
|
||||
albumId: z.array(z.string()).optional(),
|
||||
artistId: z.array(z.string()).optional(),
|
||||
@@ -43,7 +45,7 @@ const setRatingParameters = z.object({
|
||||
const setRating = z.null();
|
||||
|
||||
const musicFolder = z.object({
|
||||
id: z.string(),
|
||||
id,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
@@ -60,22 +62,29 @@ const songGain = z.object({
|
||||
trackPeak: z.number().optional(),
|
||||
});
|
||||
|
||||
const genreItem = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
const song = z.object({
|
||||
album: z.string().optional(),
|
||||
albumId: z.string().optional(),
|
||||
albumId: id.optional(),
|
||||
artist: z.string().optional(),
|
||||
artistId: z.string().optional(),
|
||||
artistId: id.optional(),
|
||||
averageRating: z.number().optional(),
|
||||
bitRate: z.number().optional(),
|
||||
bpm: z.number().optional(),
|
||||
contentType: z.string(),
|
||||
coverArt: z.string().optional(),
|
||||
created: z.string(),
|
||||
discNumber: z.number(),
|
||||
duration: z.number().optional(),
|
||||
genre: z.string().optional(),
|
||||
id: z.string(),
|
||||
genres: z.array(genreItem).optional(),
|
||||
id,
|
||||
isDir: z.boolean(),
|
||||
isVideo: z.boolean(),
|
||||
musicBrainzId: z.string().optional(),
|
||||
parent: z.string(),
|
||||
path: z.string(),
|
||||
playCount: z.number().optional(),
|
||||
@@ -93,12 +102,13 @@ const song = z.object({
|
||||
const album = z.object({
|
||||
album: z.string(),
|
||||
artist: z.string(),
|
||||
artistId: z.string(),
|
||||
artistId: id,
|
||||
coverArt: z.string(),
|
||||
created: z.string(),
|
||||
duration: z.number(),
|
||||
genre: z.string().optional(),
|
||||
id: z.string(),
|
||||
id,
|
||||
isCompilation: z.boolean().optional(),
|
||||
isDir: z.boolean(),
|
||||
isVideo: z.boolean(),
|
||||
name: z.string(),
|
||||
@@ -111,6 +121,10 @@ const album = z.object({
|
||||
year: z.number().optional(),
|
||||
});
|
||||
|
||||
const albumListEntry = album.omit({
|
||||
song: true,
|
||||
});
|
||||
|
||||
const albumListParameters = z.object({
|
||||
fromYear: z.number().optional(),
|
||||
genre: z.string().optional(),
|
||||
@@ -124,11 +138,13 @@ const albumListParameters = z.object({
|
||||
const albumList = z.array(album.omit({ song: true }));
|
||||
|
||||
const albumArtist = z.object({
|
||||
album: z.array(album),
|
||||
albumCount: z.string(),
|
||||
artistImageUrl: z.string().optional(),
|
||||
coverArt: z.string().optional(),
|
||||
id: z.string(),
|
||||
id,
|
||||
name: z.string(),
|
||||
starred: z.string().optional(),
|
||||
});
|
||||
|
||||
const albumArtistList = z.object({
|
||||
@@ -136,6 +152,14 @@ const albumArtistList = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
const artistListEntry = albumArtist.pick({
|
||||
albumCount: true,
|
||||
coverArt: true,
|
||||
id: true,
|
||||
name: true,
|
||||
starred: true,
|
||||
});
|
||||
|
||||
const artistInfoParameters = z.object({
|
||||
count: z.number().optional(),
|
||||
id: z.string(),
|
||||
@@ -168,9 +192,11 @@ const topSongsListParameters = z.object({
|
||||
});
|
||||
|
||||
const topSongsList = z.object({
|
||||
topSongs: z.object({
|
||||
song: z.array(song),
|
||||
}),
|
||||
topSongs: z
|
||||
.object({
|
||||
song: z.array(song),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const scrobbleParameters = z.object({
|
||||
@@ -182,11 +208,13 @@ const scrobbleParameters = z.object({
|
||||
const scrobble = z.null();
|
||||
|
||||
const search3 = z.object({
|
||||
searchResult3: z.object({
|
||||
album: z.array(album),
|
||||
artist: z.array(albumArtist),
|
||||
song: z.array(song),
|
||||
}),
|
||||
searchResult3: z
|
||||
.object({
|
||||
album: z.array(album).optional(),
|
||||
artist: z.array(albumArtist).optional(),
|
||||
song: z.array(song).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const search3Parameters = z.object({
|
||||
@@ -209,9 +237,11 @@ const randomSongListParameters = z.object({
|
||||
});
|
||||
|
||||
const randomSongList = z.object({
|
||||
randomSongs: z.object({
|
||||
song: z.array(song),
|
||||
}),
|
||||
randomSongs: z
|
||||
.object({
|
||||
song: z.array(song),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const ping = z.object({
|
||||
@@ -274,12 +304,223 @@ export enum SubsonicExtensions {
|
||||
TRANSCODE_OFFSET = 'transcodeOffset',
|
||||
}
|
||||
|
||||
const updatePlaylistParameters = z.object({
|
||||
comment: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
playlistId: z.string(),
|
||||
public: z.boolean().optional(),
|
||||
songIdToAdd: z.array(z.string()).optional(),
|
||||
songIndexToRemove: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const getStarredParameters = z.object({
|
||||
musicFolderId: z.string().optional(),
|
||||
});
|
||||
|
||||
const getStarred = z.object({
|
||||
starred: z
|
||||
.object({
|
||||
album: z.array(albumListEntry),
|
||||
artist: z.array(artistListEntry),
|
||||
song: z.array(song),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const getSongsByGenreParameters = z.object({
|
||||
count: z.number().optional(),
|
||||
genre: z.string(),
|
||||
musicFolderId: z.string().optional(),
|
||||
offset: z.number().optional(),
|
||||
});
|
||||
|
||||
const getSongsByGenre = z.object({
|
||||
songsByGenre: z
|
||||
.object({
|
||||
song: z.array(song),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const getAlbumParameters = z.object({
|
||||
id: z.string(),
|
||||
musicFolderId: z.string().optional(),
|
||||
});
|
||||
|
||||
const getAlbum = z.object({
|
||||
album,
|
||||
});
|
||||
|
||||
const getArtistParameters = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const getArtist = z.object({
|
||||
artist: albumArtist,
|
||||
});
|
||||
|
||||
const getSongParameters = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const getSong = z.object({
|
||||
song,
|
||||
});
|
||||
|
||||
const getArtistsParameters = z.object({
|
||||
musicFolderId: z.string().optional(),
|
||||
});
|
||||
|
||||
const getArtists = z.object({
|
||||
artists: z.object({
|
||||
ignoredArticles: z.string(),
|
||||
index: z.array(
|
||||
z.object({
|
||||
artist: z.array(artistListEntry),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const deletePlaylistParameters = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const createPlaylistParameters = z.object({
|
||||
name: z.string(),
|
||||
playlistId: z.string().optional(),
|
||||
songId: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const playlist = z.object({
|
||||
changed: z.string().optional(),
|
||||
comment: z.string().optional(),
|
||||
coverArt: z.string().optional(),
|
||||
created: z.string(),
|
||||
duration: z.number(),
|
||||
entry: z.array(song).optional(),
|
||||
id,
|
||||
name: z.string(),
|
||||
owner: z.string(),
|
||||
public: z.boolean(),
|
||||
songCount: z.number(),
|
||||
});
|
||||
|
||||
const createPlaylist = z.object({
|
||||
playlist,
|
||||
});
|
||||
|
||||
const getPlaylistsParameters = z.object({
|
||||
username: z.string().optional(),
|
||||
});
|
||||
|
||||
const playlistListEntry = playlist.omit({
|
||||
entry: true,
|
||||
});
|
||||
|
||||
const getPlaylists = z.object({
|
||||
playlists: z
|
||||
.object({
|
||||
playlist: z.array(playlistListEntry),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const getPlaylistParameters = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const getPlaylist = z.object({
|
||||
playlist,
|
||||
});
|
||||
|
||||
const genre = z.object({
|
||||
albumCount: z.number(),
|
||||
songCount: z.number(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const getGenresParameters = z.object({});
|
||||
|
||||
const getGenres = z.object({
|
||||
genres: z
|
||||
.object({
|
||||
genre: z.array(genre),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export enum AlbumListSortType {
|
||||
ALPHABETICAL_BY_ARTIST = 'alphabeticalByArtist',
|
||||
ALPHABETICAL_BY_NAME = 'alphabeticalByName',
|
||||
BY_GENRE = 'byGenre',
|
||||
BY_YEAR = 'byYear',
|
||||
FREQUENT = 'frequent',
|
||||
NEWEST = 'newest',
|
||||
RANDOM = 'random',
|
||||
RECENT = 'recent',
|
||||
STARRED = 'starred',
|
||||
}
|
||||
|
||||
const getAlbumList2Parameters = z
|
||||
.object({
|
||||
fromYear: z.number().optional(),
|
||||
genre: z.string().optional(),
|
||||
musicFolderId: z.string().optional(),
|
||||
offset: z.number().optional(),
|
||||
size: z.number().optional(),
|
||||
toYear: z.number().optional(),
|
||||
type: z.nativeEnum(AlbumListSortType),
|
||||
})
|
||||
.refine(
|
||||
(val) => {
|
||||
if (val.type === AlbumListSortType.BY_YEAR) {
|
||||
return val.fromYear !== undefined && val.toYear !== undefined;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Parameters "fromYear" and "toYear" are required when using sort "byYear"',
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(val) => {
|
||||
if (val.type === AlbumListSortType.BY_GENRE) {
|
||||
return val.genre !== undefined;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{ message: 'Parameter "genre" is required when using sort "byGenre"' },
|
||||
);
|
||||
|
||||
const getAlbumList2 = z.object({
|
||||
albumList2: z.object({
|
||||
album: z.array(albumListEntry),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ssType = {
|
||||
_parameters: {
|
||||
albumList: albumListParameters,
|
||||
artistInfo: artistInfoParameters,
|
||||
authenticate: authenticateParameters,
|
||||
createFavorite: createFavoriteParameters,
|
||||
createPlaylist: createPlaylistParameters,
|
||||
deletePlaylist: deletePlaylistParameters,
|
||||
getAlbum: getAlbumParameters,
|
||||
getAlbumList2: getAlbumList2Parameters,
|
||||
getArtist: getArtistParameters,
|
||||
getArtists: getArtistsParameters,
|
||||
getGenre: getGenresParameters,
|
||||
getGenres: getGenresParameters,
|
||||
getPlaylist: getPlaylistParameters,
|
||||
getPlaylists: getPlaylistsParameters,
|
||||
getSong: getSongParameters,
|
||||
getSongsByGenre: getSongsByGenreParameters,
|
||||
getStarred: getStarredParameters,
|
||||
randomSongList: randomSongListParameters,
|
||||
removeFavorite: removeFavoriteParameters,
|
||||
scrobble: scrobbleParameters,
|
||||
@@ -288,18 +529,35 @@ export const ssType = {
|
||||
similarSongs: similarSongsParameters,
|
||||
structuredLyrics: structuredLyricsParameters,
|
||||
topSongsList: topSongsListParameters,
|
||||
updatePlaylist: updatePlaylistParameters,
|
||||
},
|
||||
_response: {
|
||||
album,
|
||||
albumArtist,
|
||||
albumArtistList,
|
||||
albumList,
|
||||
albumListEntry,
|
||||
artistInfo,
|
||||
artistListEntry,
|
||||
authenticate,
|
||||
baseResponse,
|
||||
createFavorite,
|
||||
createPlaylist,
|
||||
genre,
|
||||
getAlbum,
|
||||
getAlbumList2,
|
||||
getArtist,
|
||||
getArtists,
|
||||
getGenres,
|
||||
getPlaylist,
|
||||
getPlaylists,
|
||||
getSong,
|
||||
getSongsByGenre,
|
||||
getStarred,
|
||||
musicFolderList,
|
||||
ping,
|
||||
playlist,
|
||||
playlistListEntry,
|
||||
randomSongList,
|
||||
removeFavorite,
|
||||
scrobble,
|
||||
|
||||
+254
-37
@@ -1,3 +1,6 @@
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import reverse from 'lodash/reverse';
|
||||
import shuffle from 'lodash/shuffle';
|
||||
import { z } from 'zod';
|
||||
import { ServerFeatures } from './features-types';
|
||||
import { jfType } from './jellyfin/jellyfin-types';
|
||||
@@ -128,7 +131,7 @@ export interface BasePaginatedResponse<T> {
|
||||
error?: string | any;
|
||||
items: T;
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
totalRecordCount: number | null;
|
||||
}
|
||||
|
||||
export type AuthenticationResponse = {
|
||||
@@ -309,6 +312,11 @@ type BaseEndpointArgs = {
|
||||
};
|
||||
};
|
||||
|
||||
export interface BaseQuery<T> {
|
||||
sortBy: T;
|
||||
sortOrder: SortOrder;
|
||||
}
|
||||
|
||||
// Genre List
|
||||
export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefined;
|
||||
|
||||
@@ -318,7 +326,7 @@ export enum GenreListSort {
|
||||
NAME = 'name',
|
||||
}
|
||||
|
||||
export type GenreListQuery = {
|
||||
export interface GenreListQuery extends BaseQuery<GenreListSort> {
|
||||
_custom?: {
|
||||
jellyfin?: null;
|
||||
navidrome?: null;
|
||||
@@ -326,10 +334,8 @@ export type GenreListQuery = {
|
||||
limit?: number;
|
||||
musicFolderId?: string;
|
||||
searchTerm?: string;
|
||||
sortBy: GenreListSort;
|
||||
sortOrder: SortOrder;
|
||||
startIndex: number;
|
||||
};
|
||||
}
|
||||
|
||||
type GenreListSortMap = {
|
||||
jellyfin: Record<GenreListSort, JFGenreListSort | undefined>;
|
||||
@@ -370,22 +376,22 @@ export enum AlbumListSort {
|
||||
YEAR = 'year',
|
||||
}
|
||||
|
||||
export type AlbumListQuery = {
|
||||
export interface AlbumListQuery extends BaseQuery<AlbumListSort> {
|
||||
_custom?: {
|
||||
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumList>> & {
|
||||
maxYear?: number;
|
||||
minYear?: number;
|
||||
};
|
||||
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumList>>;
|
||||
navidrome?: Partial<z.infer<typeof ndType._parameters.albumList>>;
|
||||
};
|
||||
artistIds?: string[];
|
||||
compilation?: boolean;
|
||||
favorite?: boolean;
|
||||
genres?: string[];
|
||||
limit?: number;
|
||||
maxYear?: number;
|
||||
minYear?: number;
|
||||
musicFolderId?: string;
|
||||
searchTerm?: string;
|
||||
sortBy: AlbumListSort;
|
||||
sortOrder: SortOrder;
|
||||
startIndex: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type AlbumListArgs = { query: AlbumListQuery } & BaseEndpointArgs;
|
||||
|
||||
@@ -481,24 +487,23 @@ export enum SongListSort {
|
||||
YEAR = 'year',
|
||||
}
|
||||
|
||||
export type SongListQuery = {
|
||||
export interface SongListQuery extends BaseQuery<SongListSort> {
|
||||
_custom?: {
|
||||
jellyfin?: Partial<z.infer<typeof jfType._parameters.songList>> & {
|
||||
maxYear?: number;
|
||||
minYear?: number;
|
||||
};
|
||||
jellyfin?: Partial<z.infer<typeof jfType._parameters.songList>>;
|
||||
navidrome?: Partial<z.infer<typeof ndType._parameters.songList>>;
|
||||
};
|
||||
albumIds?: string[];
|
||||
artistIds?: string[];
|
||||
favorite?: boolean;
|
||||
genreIds?: string[];
|
||||
imageSize?: number;
|
||||
limit?: number;
|
||||
maxYear?: number;
|
||||
minYear?: number;
|
||||
musicFolderId?: string;
|
||||
searchTerm?: string;
|
||||
sortBy: SongListSort;
|
||||
sortOrder: SortOrder;
|
||||
startIndex: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type SongListArgs = { query: SongListQuery } & BaseEndpointArgs;
|
||||
|
||||
@@ -595,7 +600,7 @@ export enum AlbumArtistListSort {
|
||||
SONG_COUNT = 'songCount',
|
||||
}
|
||||
|
||||
export type AlbumArtistListQuery = {
|
||||
export interface AlbumArtistListQuery extends BaseQuery<AlbumArtistListSort> {
|
||||
_custom?: {
|
||||
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumArtistList>>;
|
||||
navidrome?: Partial<z.infer<typeof ndType._parameters.albumArtistList>>;
|
||||
@@ -603,10 +608,8 @@ export type AlbumArtistListQuery = {
|
||||
limit?: number;
|
||||
musicFolderId?: string;
|
||||
searchTerm?: string;
|
||||
sortBy: AlbumArtistListSort;
|
||||
sortOrder: SortOrder;
|
||||
startIndex: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type AlbumArtistListArgs = { query: AlbumArtistListQuery } & BaseEndpointArgs;
|
||||
|
||||
@@ -683,17 +686,15 @@ export enum ArtistListSort {
|
||||
SONG_COUNT = 'songCount',
|
||||
}
|
||||
|
||||
export type ArtistListQuery = {
|
||||
export interface ArtistListQuery extends BaseQuery<ArtistListSort> {
|
||||
_custom?: {
|
||||
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumArtistList>>;
|
||||
navidrome?: Partial<z.infer<typeof ndType._parameters.albumArtistList>>;
|
||||
};
|
||||
limit?: number;
|
||||
musicFolderId?: string;
|
||||
sortBy: ArtistListSort;
|
||||
sortOrder: SortOrder;
|
||||
startIndex: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type ArtistListArgs = { query: ArtistListQuery } & BaseEndpointArgs;
|
||||
|
||||
@@ -879,17 +880,15 @@ export enum PlaylistListSort {
|
||||
UPDATED_AT = 'updatedAt',
|
||||
}
|
||||
|
||||
export type PlaylistListQuery = {
|
||||
export interface PlaylistListQuery extends BaseQuery<PlaylistListSort> {
|
||||
_custom?: {
|
||||
jellyfin?: Partial<z.infer<typeof jfType._parameters.playlistList>>;
|
||||
navidrome?: Partial<z.infer<typeof ndType._parameters.playlistList>>;
|
||||
};
|
||||
limit?: number;
|
||||
searchTerm?: string;
|
||||
sortBy: PlaylistListSort;
|
||||
sortOrder: SortOrder;
|
||||
startIndex: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type PlaylistListArgs = { query: PlaylistListQuery } & BaseEndpointArgs;
|
||||
|
||||
@@ -963,7 +962,7 @@ export enum UserListSort {
|
||||
NAME = 'name',
|
||||
}
|
||||
|
||||
export type UserListQuery = {
|
||||
export interface UserListQuery extends BaseQuery<UserListSort> {
|
||||
_custom?: {
|
||||
navidrome?: {
|
||||
owner_id?: string;
|
||||
@@ -971,10 +970,8 @@ export type UserListQuery = {
|
||||
};
|
||||
limit?: number;
|
||||
searchTerm?: string;
|
||||
sortBy: UserListSort;
|
||||
sortOrder: SortOrder;
|
||||
startIndex: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type UserListArgs = { query: UserListQuery } & BaseEndpointArgs;
|
||||
|
||||
@@ -1228,3 +1225,223 @@ export type TranscodingQuery = {
|
||||
export type TranscodingArgs = {
|
||||
query: TranscodingQuery;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
export type ControllerEndpoint = {
|
||||
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
|
||||
authenticate: (
|
||||
url: string,
|
||||
body: { legacy?: boolean; password: string; username: string },
|
||||
) => Promise<AuthenticationResponse>;
|
||||
createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
|
||||
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
|
||||
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
|
||||
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
|
||||
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
|
||||
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
|
||||
getAlbumArtistListCount: (args: AlbumArtistListArgs) => Promise<number>;
|
||||
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
|
||||
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
|
||||
getAlbumListCount: (args: AlbumListArgs) => Promise<number>;
|
||||
// getArtistInfo?: (args: any) => void;
|
||||
// getArtistList?: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
||||
getDownloadUrl: (args: DownloadArgs) => string;
|
||||
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
||||
getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>;
|
||||
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
|
||||
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
|
||||
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
|
||||
getPlaylistListCount: (args: PlaylistListArgs) => Promise<number>;
|
||||
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
|
||||
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
|
||||
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
|
||||
getSimilarSongs: (args: SimilarSongsArgs) => Promise<Song[]>;
|
||||
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
||||
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
||||
getSongListCount: (args: SongListArgs) => Promise<number>;
|
||||
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||
getTranscodingUrl: (args: TranscodingArgs) => string;
|
||||
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
||||
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
||||
search: (args: SearchArgs) => Promise<SearchResponse>;
|
||||
setRating?: (args: SetRatingArgs) => Promise<RatingResponse>;
|
||||
shareItem?: (args: ShareItemArgs) => Promise<ShareItemResponse>;
|
||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
|
||||
};
|
||||
|
||||
export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder: SortOrder) => {
|
||||
let results = albums;
|
||||
|
||||
const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';
|
||||
|
||||
switch (sortBy) {
|
||||
case AlbumListSort.ALBUM_ARTIST:
|
||||
results = orderBy(
|
||||
results,
|
||||
['albumArtist', (v) => v.name.toLowerCase()],
|
||||
[order, 'asc'],
|
||||
);
|
||||
break;
|
||||
case AlbumListSort.DURATION:
|
||||
results = orderBy(results, ['duration'], [order]);
|
||||
break;
|
||||
case AlbumListSort.FAVORITED:
|
||||
results = orderBy(results, ['starred'], [order]);
|
||||
break;
|
||||
case AlbumListSort.NAME:
|
||||
results = orderBy(results, [(v) => v.name.toLowerCase()], [order]);
|
||||
break;
|
||||
case AlbumListSort.PLAY_COUNT:
|
||||
results = orderBy(results, ['playCount'], [order]);
|
||||
break;
|
||||
case AlbumListSort.RANDOM:
|
||||
results = shuffle(results);
|
||||
break;
|
||||
case AlbumListSort.RECENTLY_ADDED:
|
||||
results = orderBy(results, ['createdAt'], [order]);
|
||||
break;
|
||||
case AlbumListSort.RECENTLY_PLAYED:
|
||||
results = orderBy(results, ['lastPlayedAt'], [order]);
|
||||
break;
|
||||
case AlbumListSort.RATING:
|
||||
results = orderBy(results, ['userRating'], [order]);
|
||||
break;
|
||||
case AlbumListSort.YEAR:
|
||||
results = orderBy(results, ['releaseYear'], [order]);
|
||||
break;
|
||||
case AlbumListSort.SONG_COUNT:
|
||||
results = orderBy(results, ['songCount'], [order]);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder: SortOrder) => {
|
||||
let results = songs;
|
||||
|
||||
const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';
|
||||
|
||||
switch (sortBy) {
|
||||
case SongListSort.ALBUM:
|
||||
results = orderBy(
|
||||
results,
|
||||
[(v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
|
||||
[order, 'asc', 'asc'],
|
||||
);
|
||||
break;
|
||||
|
||||
case SongListSort.ALBUM_ARTIST:
|
||||
results = orderBy(
|
||||
results,
|
||||
['albumArtist', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
|
||||
[order, order, 'asc', 'asc'],
|
||||
);
|
||||
break;
|
||||
|
||||
case SongListSort.ARTIST:
|
||||
results = orderBy(
|
||||
results,
|
||||
['artist', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
|
||||
[order, order, 'asc', 'asc'],
|
||||
);
|
||||
break;
|
||||
|
||||
case SongListSort.DURATION:
|
||||
results = orderBy(results, ['duration'], [order]);
|
||||
break;
|
||||
|
||||
case SongListSort.FAVORITED:
|
||||
results = orderBy(results, ['userFavorite', (v) => v.name.toLowerCase()], [order]);
|
||||
break;
|
||||
|
||||
case SongListSort.GENRE:
|
||||
results = orderBy(
|
||||
results,
|
||||
[
|
||||
(v) => v.genres?.[0].name.toLowerCase(),
|
||||
(v) => v.album?.toLowerCase(),
|
||||
'discNumber',
|
||||
'trackNumber',
|
||||
],
|
||||
[order, order, 'asc', 'asc'],
|
||||
);
|
||||
break;
|
||||
|
||||
case SongListSort.ID:
|
||||
if (order === 'desc') {
|
||||
results = reverse(results);
|
||||
}
|
||||
break;
|
||||
|
||||
case SongListSort.NAME:
|
||||
results = orderBy(results, [(v) => v.name.toLowerCase()], [order]);
|
||||
break;
|
||||
|
||||
case SongListSort.PLAY_COUNT:
|
||||
results = orderBy(results, ['playCount'], [order]);
|
||||
break;
|
||||
|
||||
case SongListSort.RANDOM:
|
||||
results = shuffle(results);
|
||||
break;
|
||||
|
||||
case SongListSort.RATING:
|
||||
results = orderBy(results, ['userRating', (v) => v.name.toLowerCase()], [order]);
|
||||
break;
|
||||
|
||||
case SongListSort.RECENTLY_ADDED:
|
||||
results = orderBy(results, ['created'], [order]);
|
||||
break;
|
||||
|
||||
case SongListSort.YEAR:
|
||||
results = orderBy(
|
||||
results,
|
||||
['year', (v) => v.album?.toLowerCase(), 'discNumber', 'track'],
|
||||
[order, 'asc', 'asc', 'asc'],
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const sortAlbumArtistList = (
|
||||
artists: AlbumArtist[],
|
||||
sortBy: AlbumArtistListSort,
|
||||
sortOrder: SortOrder,
|
||||
) => {
|
||||
const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';
|
||||
|
||||
let results = artists;
|
||||
|
||||
switch (sortBy) {
|
||||
case AlbumArtistListSort.ALBUM_COUNT:
|
||||
results = orderBy(artists, ['albumCount', (v) => v.name.toLowerCase()], [order, 'asc']);
|
||||
break;
|
||||
|
||||
case AlbumArtistListSort.NAME:
|
||||
results = orderBy(artists, [(v) => v.name.toLowerCase()], [order]);
|
||||
break;
|
||||
|
||||
case AlbumArtistListSort.FAVORITED:
|
||||
results = orderBy(artists, ['starred'], [order]);
|
||||
break;
|
||||
|
||||
case AlbumArtistListSort.RATING:
|
||||
results = orderBy(artists, ['userRating'], [order]);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
@@ -143,7 +143,7 @@ export const App = () => {
|
||||
if (!isRunning) {
|
||||
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
|
||||
const properties: Record<string, any> = {
|
||||
speed: usePlayerStore.getState().current.speed,
|
||||
speed: usePlayerStore.getState().speed,
|
||||
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
|
||||
};
|
||||
|
||||
|
||||
@@ -341,7 +341,7 @@ export const AudioPlayer = forwardRef(
|
||||
// Set the current replaygain
|
||||
if (current) {
|
||||
const newVolume = calculateReplayGain(current) * volume;
|
||||
webAudio.gain.gain.setValueAtTime(newVolume, 0);
|
||||
webAudio.gain.gain.setValueAtTime(Math.max(0, newVolume), 0);
|
||||
}
|
||||
|
||||
// Set the next track replaygain right before the end of this track
|
||||
@@ -349,7 +349,10 @@ export const AudioPlayer = forwardRef(
|
||||
const next = sources[3 - currentPlayer];
|
||||
if (next && current) {
|
||||
const newVolume = calculateReplayGain(next) * volume;
|
||||
webAudio.gain.gain.setValueAtTime(newVolume, (current.duration - 1) / 1000);
|
||||
webAudio.gain.gain.setValueAtTime(
|
||||
Math.max(0, newVolume),
|
||||
Math.max(0, (current.duration - 1) / 1000),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
calculateReplayGain,
|
||||
|
||||
@@ -202,7 +202,7 @@ export const AlbumCard = ({
|
||||
<ImageSection />
|
||||
</Skeleton>
|
||||
<DetailSection style={{ width: '100%' }}>
|
||||
{cardRows.map((_row: CardRow<Album>, index: number) => (
|
||||
{(cardRows || []).map((_row: CardRow<Album>, index: number) => (
|
||||
<Skeleton
|
||||
visible
|
||||
height={15}
|
||||
|
||||
@@ -294,7 +294,7 @@ export const PLAYLIST_CARD_ROWS: { [key: string]: CardRow<Playlist> } = {
|
||||
name: {
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.PLAYLISTS_DETAIL,
|
||||
route: AppRoute.PLAYLISTS_DETAIL_SONGS,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -191,7 +191,7 @@ export const PosterCard = ({
|
||||
</Skeleton>
|
||||
<DetailContainer>
|
||||
<Stack spacing="sm">
|
||||
{controls.cardRows.map((row, index) => (
|
||||
{(controls?.cardRows || []).map((row, index) => (
|
||||
<Skeleton
|
||||
key={`${index}-${row.arrayProperty}`}
|
||||
visible
|
||||
|
||||
@@ -207,7 +207,11 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
</Badge>
|
||||
))}
|
||||
<Badge size="lg">{currentItem?.releaseYear}</Badge>
|
||||
<Badge size="lg">{currentItem?.songCount} tracks</Badge>
|
||||
<Badge size="lg">
|
||||
{t('entity.trackWithCount', {
|
||||
count: currentItem?.songCount || 0,
|
||||
})}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group position="apart">
|
||||
<Button
|
||||
|
||||
@@ -28,7 +28,7 @@ export const Spoiler = ({ maxHeight, defaultOpened, children, ...props }: Spoile
|
||||
ref={ref}
|
||||
className={spoilerClassNames}
|
||||
role="button"
|
||||
style={{ maxHeight: maxHeight ?? '100px' }}
|
||||
style={{ maxHeight: maxHeight ?? '100px', whiteSpace: 'pre-wrap' }}
|
||||
tabIndex={-1}
|
||||
onClick={handleToggleExpand}
|
||||
{...props}
|
||||
|
||||
@@ -234,7 +234,7 @@ export const DefaultCard = ({
|
||||
</ImageContainer>
|
||||
<DetailContainer>
|
||||
<Stack spacing="sm">
|
||||
{controls.cardRows.map((row, index) => (
|
||||
{(controls?.cardRows || []).map((row, index) => (
|
||||
<Skeleton
|
||||
key={`${index}-${columnIndex}-${row.arrayProperty}`}
|
||||
visible
|
||||
|
||||
@@ -219,7 +219,7 @@ export const PosterCard = ({
|
||||
</Skeleton>
|
||||
<DetailContainer>
|
||||
<Stack spacing="sm">
|
||||
{controls.cardRows.map((row, index) => (
|
||||
{(controls?.cardRows || []).map((row, index) => (
|
||||
<Skeleton
|
||||
key={`${index}-${columnIndex}-${row.arrayProperty}`}
|
||||
visible
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import React, { MouseEvent } from 'react';
|
||||
import type { UnstyledButtonProps } from '@mantine/core';
|
||||
import { RiPlayFill } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
|
||||
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
|
||||
|
||||
const PlayButton = styled.button<PlayButtonType>`
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-color: rgb(255 255 255);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
opacity: 0.8;
|
||||
transition: scale 0.1s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
scale: 1.1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: rgb(0 0 0);
|
||||
stroke: rgb(0 0 0);
|
||||
}
|
||||
`;
|
||||
|
||||
const ListConverControlsContainer = styled.div`
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export const ListCoverControls = ({
|
||||
itemData,
|
||||
itemType,
|
||||
context,
|
||||
uniqueId,
|
||||
}: {
|
||||
context: Record<string, any>;
|
||||
itemData: any;
|
||||
itemType: LibraryItem;
|
||||
uniqueId?: string;
|
||||
}) => {
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const isQueue = Boolean(context?.isQueue);
|
||||
|
||||
const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [itemData.id],
|
||||
type: itemType,
|
||||
},
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlayFromQueue = () => {
|
||||
context.handleDoubleClick({
|
||||
data: {
|
||||
uniqueId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListConverControlsContainer className="card-controls">
|
||||
<PlayButton onClick={isQueue ? handlePlayFromQueue : handlePlay}>
|
||||
<RiPlayFill size={20} />
|
||||
</PlayButton>
|
||||
</ListConverControlsContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -7,11 +7,12 @@ import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SimpleImg } from 'react-simple-img';
|
||||
import styled from 'styled-components';
|
||||
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
|
||||
import { AlbumArtist, Artist } from '/@/renderer/api/types';
|
||||
import { Text } from '/@/renderer/components/text';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
import { SEPARATOR_STRING } from '/@/renderer/api/utils';
|
||||
import { ListCoverControls } from '/@/renderer/components/virtual-table/cells/combined-title-cell-controls';
|
||||
|
||||
const CellContainer = styled(motion.div)<{ height: number }>`
|
||||
display: grid;
|
||||
@@ -24,9 +25,20 @@ const CellContainer = styled(motion.div)<{ height: number }>`
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
.card-controls {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.card-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ImageWrapper = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
grid-area: image;
|
||||
align-items: center;
|
||||
@@ -48,7 +60,13 @@ const StyledImage = styled(SimpleImg)`
|
||||
}
|
||||
`;
|
||||
|
||||
export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams) => {
|
||||
export const CombinedTitleCell = ({
|
||||
value,
|
||||
rowIndex,
|
||||
node,
|
||||
context,
|
||||
data,
|
||||
}: ICellRendererParams) => {
|
||||
const artists = useMemo(() => {
|
||||
if (!value) return null;
|
||||
return value.artists?.length ? value.artists : value.albumArtists;
|
||||
@@ -102,6 +120,12 @@ export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<ListCoverControls
|
||||
context={context}
|
||||
itemData={value}
|
||||
itemType={context.itemType}
|
||||
uniqueId={data?.uniqueId}
|
||||
/>
|
||||
</ImageWrapper>
|
||||
<MetadataWrapper>
|
||||
<Text
|
||||
|
||||
@@ -2,95 +2,95 @@ import type { ICellRendererParams } from '@ag-grid-community/core';
|
||||
import { Text } from '/@/renderer/components/text';
|
||||
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
|
||||
|
||||
const AnimatedSvg = () => {
|
||||
return (
|
||||
<div style={{ height: '1rem', transform: 'rotate(180deg)', width: '1rem' }}>
|
||||
<svg
|
||||
viewBox="100 130 57 80"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<rect
|
||||
fill="var(--primary-color)"
|
||||
height="80"
|
||||
id="bar-1"
|
||||
width="12"
|
||||
x="100"
|
||||
y="130"
|
||||
>
|
||||
<animate
|
||||
attributeName="height"
|
||||
begin="0.1s"
|
||||
calcMode="spline"
|
||||
dur="0.95s"
|
||||
keySplines="0.42 0 0.58 1; 0.42 0 0.58 1"
|
||||
keyTimes="0; 0.47368; 1"
|
||||
repeatCount="indefinite"
|
||||
values="80;15;80"
|
||||
/>
|
||||
</rect>
|
||||
<rect
|
||||
fill="var(--primary-color)"
|
||||
height="80"
|
||||
id="bar-2"
|
||||
width="12"
|
||||
x="115"
|
||||
y="130"
|
||||
>
|
||||
<animate
|
||||
attributeName="height"
|
||||
begin="0.1s"
|
||||
calcMode="spline"
|
||||
dur="0.95s"
|
||||
keySplines="0.45 0 0.55 1; 0.45 0 0.55 1"
|
||||
keyTimes="0; 0.44444; 1"
|
||||
repeatCount="indefinite"
|
||||
values="25;80;25"
|
||||
/>
|
||||
</rect>
|
||||
<rect
|
||||
fill="var(--primary-color)"
|
||||
height="80"
|
||||
id="bar-3"
|
||||
width="12"
|
||||
x="130"
|
||||
y="130"
|
||||
>
|
||||
<animate
|
||||
attributeName="height"
|
||||
begin="0.1s"
|
||||
calcMode="spline"
|
||||
dur="0.85s"
|
||||
keySplines="0.65 0 0.35 1; 0.65 0 0.35 1"
|
||||
keyTimes="0; 0.42105; 1"
|
||||
repeatCount="indefinite"
|
||||
values="80;10;80"
|
||||
/>
|
||||
</rect>
|
||||
<rect
|
||||
fill="var(--primary-color)"
|
||||
height="80"
|
||||
id="bar-4"
|
||||
width="12"
|
||||
x="145"
|
||||
y="130"
|
||||
>
|
||||
<animate
|
||||
attributeName="height"
|
||||
begin="0.1s"
|
||||
calcMode="spline"
|
||||
dur="1.05s"
|
||||
keySplines="0.42 0 0.58 1; 0.42 0 0.58 1"
|
||||
keyTimes="0; 0.31579; 1"
|
||||
repeatCount="indefinite"
|
||||
values="30;80;30"
|
||||
/>
|
||||
</rect>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// const AnimatedSvg = () => {
|
||||
// return (
|
||||
// <div style={{ height: '1rem', transform: 'rotate(180deg)', width: '1rem' }}>
|
||||
// <svg
|
||||
// viewBox="100 130 57 80"
|
||||
// xmlns="http://www.w3.org/2000/svg"
|
||||
// >
|
||||
// <g>
|
||||
// <rect
|
||||
// fill="var(--primary-color)"
|
||||
// height="80"
|
||||
// id="bar-1"
|
||||
// width="12"
|
||||
// x="100"
|
||||
// y="130"
|
||||
// >
|
||||
// <animate
|
||||
// attributeName="height"
|
||||
// begin="0.1s"
|
||||
// calcMode="spline"
|
||||
// dur="0.95s"
|
||||
// keySplines="0.42 0 0.58 1; 0.42 0 0.58 1"
|
||||
// keyTimes="0; 0.47368; 1"
|
||||
// repeatCount="indefinite"
|
||||
// values="80;15;80"
|
||||
// />
|
||||
// </rect>
|
||||
// <rect
|
||||
// fill="var(--primary-color)"
|
||||
// height="80"
|
||||
// id="bar-2"
|
||||
// width="12"
|
||||
// x="115"
|
||||
// y="130"
|
||||
// >
|
||||
// <animate
|
||||
// attributeName="height"
|
||||
// begin="0.1s"
|
||||
// calcMode="spline"
|
||||
// dur="0.95s"
|
||||
// keySplines="0.45 0 0.55 1; 0.45 0 0.55 1"
|
||||
// keyTimes="0; 0.44444; 1"
|
||||
// repeatCount="indefinite"
|
||||
// values="25;80;25"
|
||||
// />
|
||||
// </rect>
|
||||
// <rect
|
||||
// fill="var(--primary-color)"
|
||||
// height="80"
|
||||
// id="bar-3"
|
||||
// width="12"
|
||||
// x="130"
|
||||
// y="130"
|
||||
// >
|
||||
// <animate
|
||||
// attributeName="height"
|
||||
// begin="0.1s"
|
||||
// calcMode="spline"
|
||||
// dur="0.85s"
|
||||
// keySplines="0.65 0 0.35 1; 0.65 0 0.35 1"
|
||||
// keyTimes="0; 0.42105; 1"
|
||||
// repeatCount="indefinite"
|
||||
// values="80;10;80"
|
||||
// />
|
||||
// </rect>
|
||||
// <rect
|
||||
// fill="var(--primary-color)"
|
||||
// height="80"
|
||||
// id="bar-4"
|
||||
// width="12"
|
||||
// x="145"
|
||||
// y="130"
|
||||
// >
|
||||
// <animate
|
||||
// attributeName="height"
|
||||
// begin="0.1s"
|
||||
// calcMode="spline"
|
||||
// dur="1.05s"
|
||||
// keySplines="0.42 0 0.58 1; 0.42 0 0.58 1"
|
||||
// keyTimes="0; 0.31579; 1"
|
||||
// repeatCount="indefinite"
|
||||
// values="30;80;30"
|
||||
// />
|
||||
// </rect>
|
||||
// </g>
|
||||
// </svg>
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
const StaticSvg = () => {
|
||||
return (
|
||||
@@ -134,19 +134,14 @@ const StaticSvg = () => {
|
||||
|
||||
export const RowIndexCell = ({ value, eGridCell }: ICellRendererParams) => {
|
||||
const classList = eGridCell.classList;
|
||||
const isFocused = classList.contains('focused');
|
||||
// const isFocused = classList.contains('focused');
|
||||
const isPlaying = classList.contains('playing');
|
||||
const isCurrentSong =
|
||||
classList.contains('current-song-cell') || classList.contains('current-playlist-song-cell');
|
||||
|
||||
return (
|
||||
<CellContainer $position="right">
|
||||
{isPlaying &&
|
||||
(isFocused && isCurrentSong ? (
|
||||
<AnimatedSvg />
|
||||
) : isCurrentSong ? (
|
||||
<StaticSvg />
|
||||
) : null)}
|
||||
{isPlaying && (isCurrentSong ? <StaticSvg /> : null)}
|
||||
<Text
|
||||
$secondary
|
||||
align="right"
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
IDatasource,
|
||||
PaginationChangedEvent,
|
||||
RowDoubleClickedEvent,
|
||||
RowModelType,
|
||||
} from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { QueryKey, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -16,7 +15,12 @@ import orderBy from 'lodash/orderBy';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { QueryPagination, queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { BasePaginatedResponse, LibraryItem, ServerListItem } from '/@/renderer/api/types';
|
||||
import {
|
||||
BasePaginatedResponse,
|
||||
BaseQuery,
|
||||
LibraryItem,
|
||||
ServerListItem,
|
||||
} from '/@/renderer/api/types';
|
||||
import { getColumnDefs, VirtualTableProps } from '/@/renderer/components/virtual-table';
|
||||
import { SetContextMenuItems, useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
@@ -34,6 +38,7 @@ interface UseAgGridProps<TFilter> {
|
||||
columnType?: 'albumDetail' | 'generic';
|
||||
contextMenu: SetContextMenuItems;
|
||||
customFilters?: Partial<TFilter>;
|
||||
isClientSide?: boolean;
|
||||
isClientSideSort?: boolean;
|
||||
isSearchParams?: boolean;
|
||||
itemCount?: number;
|
||||
@@ -43,7 +48,9 @@ interface UseAgGridProps<TFilter> {
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const useVirtualTable = <TFilter>({
|
||||
const BLOCK_SIZE = 500;
|
||||
|
||||
export const useVirtualTable = <TFilter extends BaseQuery<any>>({
|
||||
server,
|
||||
tableRef,
|
||||
pageKey,
|
||||
@@ -52,13 +59,14 @@ export const useVirtualTable = <TFilter>({
|
||||
itemCount,
|
||||
customFilters,
|
||||
isSearchParams,
|
||||
isClientSide,
|
||||
isClientSideSort,
|
||||
columnType,
|
||||
}: UseAgGridProps<TFilter>) => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { setTable, setTablePagination } = useListStoreActions();
|
||||
const properties = useListStoreByKey({ filter: customFilters, key: pageKey });
|
||||
const properties = useListStoreByKey<TFilter>({ filter: customFilters, key: pageKey });
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const scrollOffset = searchParams.get('scrollOffset');
|
||||
@@ -182,6 +190,19 @@ export const useVirtualTable = <TFilter>({
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.totalRecordCount === null) {
|
||||
const hasMoreRows = results?.items?.length === BLOCK_SIZE;
|
||||
const lastRowIndex = hasMoreRows
|
||||
? undefined
|
||||
: params.startRow + results.items.length;
|
||||
|
||||
params.successCallback(
|
||||
results?.items || [],
|
||||
hasMoreRows ? undefined : lastRowIndex,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
params.successCallback(results?.items || [], results?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
@@ -313,6 +334,7 @@ export const useVirtualTable = <TFilter>({
|
||||
const onCellContextMenu = useHandleTableContextMenu(itemType, contextMenu);
|
||||
|
||||
const context = {
|
||||
itemType,
|
||||
onCellContextMenu,
|
||||
};
|
||||
|
||||
@@ -321,6 +343,7 @@ export const useVirtualTable = <TFilter>({
|
||||
alwaysShowHorizontalScroll: true,
|
||||
autoFitColumns: properties.table.autoFit,
|
||||
blockLoadDebounceMillis: 200,
|
||||
cacheBlockSize: BLOCK_SIZE,
|
||||
getRowId: (data: GetRowIdParams<any>) => data.data.id,
|
||||
infiniteInitialRowCount: itemCount || 100,
|
||||
pagination: isPaginationEnabled,
|
||||
@@ -335,10 +358,11 @@ export const useVirtualTable = <TFilter>({
|
||||
: undefined,
|
||||
rowBuffer: 20,
|
||||
rowHeight: properties.table.rowHeight || 40,
|
||||
rowModelType: 'infinite' as RowModelType,
|
||||
rowModelType: isClientSide ? 'clientSide' : 'infinite',
|
||||
suppressRowDrag: true,
|
||||
};
|
||||
}, [
|
||||
isClientSide,
|
||||
isPaginationEnabled,
|
||||
isSearchParams,
|
||||
itemCount,
|
||||
@@ -370,7 +394,9 @@ export const useVirtualTable = <TFilter>({
|
||||
);
|
||||
break;
|
||||
case LibraryItem.PLAYLIST:
|
||||
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id }));
|
||||
navigate(
|
||||
generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id }),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -41,7 +41,12 @@ import { useFixedTableHeader } from '/@/renderer/components/virtual-table/hooks/
|
||||
import { NoteCell } from '/@/renderer/components/virtual-table/cells/note-cell';
|
||||
import { RowIndexCell } from '/@/renderer/components/virtual-table/cells/row-index-cell';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { formatDateAbsolute, formatDateRelative, formatSizeString } from '/@/renderer/utils/format';
|
||||
import {
|
||||
formatDateAbsolute,
|
||||
formatDateAbsoluteUTC,
|
||||
formatDateRelative,
|
||||
formatSizeString,
|
||||
} from '/@/renderer/utils/format';
|
||||
import { useTableChange } from '/@/renderer/hooks/use-song-change';
|
||||
|
||||
export * from './table-config-dropdown';
|
||||
@@ -253,7 +258,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: i18n.t('table.column.releaseDate'),
|
||||
suppressSizeToFit: true,
|
||||
valueFormatter: (params: ValueFormatterParams) => formatDateAbsolute(params.value),
|
||||
valueFormatter: (params: ValueFormatterParams) => formatDateAbsoluteUTC(params.value),
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.releaseDate : undefined,
|
||||
width: 130,
|
||||
@@ -356,6 +361,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
? {
|
||||
albumArtists: params.data?.albumArtists,
|
||||
artists: params.data?.artists,
|
||||
id: params.data?.id,
|
||||
imagePlaceholderUrl: params.data?.imagePlaceholderUrl,
|
||||
imageUrl: params.data?.imageUrl,
|
||||
name: params.data?.name,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettingsStoreActions, useSettingsStore } from '/@/renderer/store/set
|
||||
import { TableColumn, TableType } from '/@/renderer/types';
|
||||
import { Option } from '/@/renderer/components/option';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const SONG_TABLE_COLUMNS = [
|
||||
{
|
||||
@@ -285,6 +286,7 @@ interface TableConfigDropdownProps {
|
||||
}
|
||||
|
||||
export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const tableConfig = useSettingsStore((state) => state.tables);
|
||||
|
||||
@@ -374,7 +376,9 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
|
||||
return (
|
||||
<>
|
||||
<Option>
|
||||
<Option.Label>Auto-fit Columns</Option.Label>
|
||||
<Option.Label>
|
||||
{t('table.config.general.autoFitColumns', { postProcess: 'sentenceCase' })}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={tableConfig[type]?.autoFit}
|
||||
@@ -384,7 +388,11 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
|
||||
</Option>
|
||||
{type !== 'albumDetail' && (
|
||||
<Option>
|
||||
<Option.Label>Follow current song</Option.Label>
|
||||
<Option.Label>
|
||||
{t('table.config.general.followCurrentSong', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={tableConfig[type]?.followCurrentSong}
|
||||
|
||||
@@ -37,7 +37,7 @@ const ActionRequiredRoute = () => {
|
||||
const handleManageServersModal = () => {
|
||||
openModal({
|
||||
children: <ServerList />,
|
||||
title: 'Manage Servers',
|
||||
title: t('page.appMenu.manageServers', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,13 @@ import { generatePath, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
AlbumListQuery,
|
||||
AlbumListSort,
|
||||
LibraryItem,
|
||||
QueueSong,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { Button, Popover, Spoiler } from '/@/renderer/components';
|
||||
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
|
||||
import {
|
||||
@@ -164,13 +170,12 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
query: {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
AlbumArtistIds: detailQuery?.data?.albumArtists[0]?.id,
|
||||
ExcludeItemIds: detailQuery?.data?.id,
|
||||
},
|
||||
navidrome: {
|
||||
artist_id: detailQuery?.data?.albumArtists[0]?.id,
|
||||
},
|
||||
},
|
||||
artistIds: detailQuery?.data?.albumArtists.length
|
||||
? [detailQuery?.data?.albumArtists[0].id]
|
||||
: undefined,
|
||||
limit: 15,
|
||||
sortBy: AlbumListSort.YEAR,
|
||||
sortOrder: SortOrder.DESC,
|
||||
@@ -179,15 +184,8 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const relatedAlbumGenresRequest = {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
GenreIds: detailQuery?.data?.genres?.[0]?.id,
|
||||
},
|
||||
navidrome: {
|
||||
genre_id: detailQuery?.data?.genres?.[0]?.id,
|
||||
},
|
||||
},
|
||||
const relatedAlbumGenresRequest: AlbumListQuery = {
|
||||
genres: detailQuery.data?.genres.length ? [detailQuery.data.genres[0].id] : undefined,
|
||||
limit: 15,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
@@ -466,6 +464,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
context={{
|
||||
currentSong,
|
||||
isFocused,
|
||||
itemType: LibraryItem.SONG,
|
||||
onCellContextMenu,
|
||||
status,
|
||||
}}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { forwardRef, Fragment, Ref } from 'react';
|
||||
import { forwardRef, Fragment, Ref, useCallback, useMemo } from 'react';
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
import { AlbumDetailResponse, LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
import { Rating, Text } from '/@/renderer/components';
|
||||
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
||||
import { LibraryHeader, useSetRating } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { formatDateAbsolute, formatDurationString } from '/@/renderer/utils';
|
||||
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils';
|
||||
import { useSongChange } from '/@/renderer/hooks/use-song-change';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
|
||||
interface AlbumDetailHeaderProps {
|
||||
background: {
|
||||
@@ -37,16 +40,48 @@ export const AlbumDetailHeader = forwardRef(
|
||||
? t('page.albumDetail.released', { postProcess: 'sentenceCase' })
|
||||
: '♫';
|
||||
|
||||
const songIds = useMemo(() => {
|
||||
return new Set(detailQuery.data?.songs?.map((song) => song.id));
|
||||
}, [detailQuery.data?.songs]);
|
||||
|
||||
const handleSongChange = useCallback(
|
||||
(id: string) => {
|
||||
if (songIds.has(id)) {
|
||||
const queryKey = queryKeys.albums.detail(server?.id, { id: albumId });
|
||||
queryClient.setQueryData<AlbumDetailResponse | undefined>(
|
||||
queryKey,
|
||||
(previous) => {
|
||||
if (!previous) return undefined;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
playCount: previous.playCount ? previous.playCount + 1 : 1,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
[albumId, server?.id, songIds],
|
||||
);
|
||||
|
||||
useSongChange((ids, event) => {
|
||||
if (event.event === 'play') {
|
||||
handleSongChange(ids[0]);
|
||||
}
|
||||
}, detailQuery.data !== undefined);
|
||||
|
||||
const metadataItems = [
|
||||
{
|
||||
id: 'releaseDate',
|
||||
value:
|
||||
detailQuery?.data?.releaseDate &&
|
||||
`${releasePrefix} ${formatDateAbsolute(detailQuery?.data?.releaseDate)}`,
|
||||
`${releasePrefix} ${formatDateAbsoluteUTC(detailQuery?.data?.releaseDate)}`,
|
||||
},
|
||||
{
|
||||
id: 'songCount',
|
||||
value: `${detailQuery?.data?.songCount} songs`,
|
||||
value: `${detailQuery?.data?.songCount} ${t('entity.track_other', {
|
||||
count: detailQuery?.data?.songCount as number,
|
||||
})}`,
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
@@ -62,7 +97,7 @@ export const AlbumDetailHeader = forwardRef(
|
||||
];
|
||||
|
||||
if (originalDifferentFromRelease) {
|
||||
const formatted = `♫ ${formatDateAbsolute(detailQuery!.data!.originalDate)}`;
|
||||
const formatted = `♫ ${formatDateAbsoluteUTC(detailQuery!.data!.originalDate)}`;
|
||||
metadataItems.splice(0, 0, {
|
||||
id: 'originalDate',
|
||||
value: formatted,
|
||||
|
||||
@@ -29,7 +29,7 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
|
||||
const server = useCurrentServer();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { pageKey, customFilters, id } = useListContext();
|
||||
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { grid, display, filter } = useListStoreByKey<AlbumListQuery>({ key: pageKey });
|
||||
const { setGrid } = useListStoreActions();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -162,9 +162,9 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
|
||||
|
||||
const query: AlbumListQuery = {
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filter,
|
||||
...customFilters,
|
||||
startIndex: skip,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query, id);
|
||||
|
||||
@@ -15,13 +15,20 @@ import {
|
||||
RiSettings3Fill,
|
||||
} from 'react-icons/ri';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
AlbumListQuery,
|
||||
AlbumListSort,
|
||||
LibraryItem,
|
||||
ServerType,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
|
||||
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
|
||||
import { SubsonicAlbumFilters } from '/@/renderer/features/albums/components/subsonic-album-filters';
|
||||
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
|
||||
@@ -139,26 +146,74 @@ const FILTERS = {
|
||||
value: AlbumListSort.YEAR,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.ALBUM_ARTIST,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.PLAY_COUNT,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.NAME,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RANDOM,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RECENTLY_ADDED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RECENTLY_PLAYED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.favorited', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.FAVORITED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.YEAR,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface AlbumListHeaderFiltersProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount: number | undefined;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFiltersProps) => {
|
||||
export const AlbumListHeaderFilters = ({
|
||||
gridRef,
|
||||
itemCount,
|
||||
tableRef,
|
||||
}: AlbumListHeaderFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { pageKey, customFilters, handlePlay } = useListContext();
|
||||
const server = useCurrentServer();
|
||||
const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions();
|
||||
const { display, filter, table, grid } = useListStoreByKey({
|
||||
const { display, filter, table, grid } = useListStoreByKey<AlbumListQuery>({
|
||||
filter: customFilters,
|
||||
key: pageKey,
|
||||
});
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
|
||||
itemCount,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
server,
|
||||
});
|
||||
@@ -191,27 +246,35 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
||||
);
|
||||
|
||||
const handleOpenFiltersModal = () => {
|
||||
let FilterComponent;
|
||||
|
||||
switch (server?.type) {
|
||||
case ServerType.NAVIDROME:
|
||||
FilterComponent = NavidromeAlbumFilters;
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
FilterComponent = JellyfinAlbumFilters;
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
FilterComponent = SubsonicAlbumFilters;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (!FilterComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
openModal({
|
||||
children: (
|
||||
<>
|
||||
{server?.type === ServerType.NAVIDROME ? (
|
||||
<NavidromeAlbumFilters
|
||||
customFilters={customFilters}
|
||||
disableArtistFilter={!!customFilters}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
) : (
|
||||
<JellyfinAlbumFilters
|
||||
customFilters={customFilters}
|
||||
disableArtistFilter={!!customFilters}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<FilterComponent
|
||||
customFilters={customFilters}
|
||||
disableArtistFilter={!!customFilters}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
),
|
||||
title: 'Album Filters',
|
||||
});
|
||||
@@ -347,8 +410,20 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
||||
filter?._custom?.jellyfin &&
|
||||
Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined);
|
||||
|
||||
return isNavidromeFilterApplied || isJellyfinFilterApplied;
|
||||
}, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]);
|
||||
const isSubsonicFilterApplied =
|
||||
server?.type === ServerType.SUBSONIC &&
|
||||
(filter.maxYear || filter.minYear || filter.genres?.length || filter.favorite);
|
||||
|
||||
return isNavidromeFilterApplied || isJellyfinFilterApplied || isSubsonicFilterApplied;
|
||||
}, [
|
||||
filter?._custom?.jellyfin,
|
||||
filter?._custom?.navidrome,
|
||||
filter.favorite,
|
||||
filter.genres?.length,
|
||||
filter.maxYear,
|
||||
filter.minYear,
|
||||
server?.type,
|
||||
]);
|
||||
|
||||
const isFolderFilterApplied = useMemo(() => {
|
||||
return filter.musicFolderId !== undefined;
|
||||
@@ -436,7 +511,7 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
||||
},
|
||||
}}
|
||||
tooltip={{
|
||||
label: t('common.filter', { count: 2, postProcess: 'sentenceCase' }),
|
||||
label: t('common.filters', { count: 2, postProcess: 'sentenceCase' }),
|
||||
}}
|
||||
variant="subtle"
|
||||
onClick={handleOpenFiltersModal}
|
||||
@@ -514,7 +589,9 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.CARD}
|
||||
value={ListDisplayType.CARD}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { AlbumListQuery, LibraryItem } from '/@/renderer/api/types';
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
|
||||
@@ -33,8 +33,9 @@ export const AlbumListHeader = ({
|
||||
const cq = useContainerQuery();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const genreRef = useRef<string>();
|
||||
const { filter, handlePlay, refresh, search } = useDisplayRefresh({
|
||||
const { filter, handlePlay, refresh, search } = useDisplayRefresh<AlbumListQuery>({
|
||||
gridRef,
|
||||
itemCount,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
server,
|
||||
tableRef,
|
||||
@@ -90,6 +91,7 @@ export const AlbumListHeader = ({
|
||||
<FilterBar>
|
||||
<AlbumListHeaderFilters
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</FilterBar>
|
||||
|
||||
@@ -3,7 +3,13 @@ import { Divider, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useListFilterByKey } from '../../../store/list.store';
|
||||
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
AlbumArtistListSort,
|
||||
AlbumListQuery,
|
||||
GenreListSort,
|
||||
LibraryItem,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
@@ -25,7 +31,7 @@ export const JellyfinAlbumFilters = ({
|
||||
serverId,
|
||||
}: JellyfinAlbumFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const filter = useListFilterByKey({ key: pageKey });
|
||||
const filter = useListFilterByKey<AlbumListQuery>({ key: pageKey });
|
||||
const { setFilter } = useListStoreActions();
|
||||
|
||||
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
|
||||
@@ -47,10 +53,6 @@ export const JellyfinAlbumFilters = ({
|
||||
}));
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const selectedGenres = useMemo(() => {
|
||||
return filter?._custom?.jellyfin?.GenreIds?.split(',');
|
||||
}, [filter?._custom?.jellyfin?.GenreIds]);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||
@@ -58,20 +60,15 @@ export const JellyfinAlbumFilters = ({
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
IsFavorite: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
_custom: filter?._custom,
|
||||
favorite: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter?._custom?.jellyfin?.IsFavorite,
|
||||
value: filter?.favorite,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -80,13 +77,8 @@ export const JellyfinAlbumFilters = ({
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
minYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
},
|
||||
_custom: filter?._custom,
|
||||
minYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
@@ -99,13 +91,8 @@ export const JellyfinAlbumFilters = ({
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
maxYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
},
|
||||
_custom: filter?._custom,
|
||||
maxYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
@@ -114,17 +101,11 @@ export const JellyfinAlbumFilters = ({
|
||||
}, 500);
|
||||
|
||||
const handleGenresFilter = debounce((e: string[] | undefined) => {
|
||||
const genreFilterString = e?.length ? e.join(',') : undefined;
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
GenreIds: genreFilterString,
|
||||
},
|
||||
},
|
||||
_custom: filter?._custom,
|
||||
genres: e,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
@@ -157,17 +138,11 @@ export const JellyfinAlbumFilters = ({
|
||||
}, [albumArtistListQuery?.data?.items]);
|
||||
|
||||
const handleAlbumArtistFilter = (e: string[] | null) => {
|
||||
const albumArtistFilterString = e?.length ? e.join(',') : undefined;
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
AlbumArtistIds: albumArtistFilterString,
|
||||
},
|
||||
},
|
||||
_custom: filter?._custom,
|
||||
artistIds: e || undefined,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
@@ -193,21 +168,21 @@ export const JellyfinAlbumFilters = ({
|
||||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
defaultValue={filter?._custom?.jellyfin?.minYear}
|
||||
defaultValue={filter?.minYear}
|
||||
hideControls={false}
|
||||
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
|
||||
max={2300}
|
||||
min={1700}
|
||||
required={!!filter?._custom?.jellyfin?.maxYear}
|
||||
required={!!filter?.maxYear}
|
||||
onChange={(e) => handleMinYearFilter(e)}
|
||||
/>
|
||||
<NumberInput
|
||||
defaultValue={filter?._custom?.jellyfin?.maxYear}
|
||||
defaultValue={filter?.maxYear}
|
||||
hideControls={false}
|
||||
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
|
||||
max={2300}
|
||||
min={1700}
|
||||
required={!!filter?._custom?.jellyfin?.minYear}
|
||||
required={!!filter?.minYear}
|
||||
onChange={(e) => handleMaxYearFilter(e)}
|
||||
/>
|
||||
</Group>
|
||||
@@ -216,7 +191,7 @@ export const JellyfinAlbumFilters = ({
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={selectedGenres}
|
||||
defaultValue={filter.genres}
|
||||
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,13 @@ import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/rend
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
AlbumArtistListSort,
|
||||
AlbumListQuery,
|
||||
GenreListSort,
|
||||
LibraryItem,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface NavidromeAlbumFiltersProps {
|
||||
@@ -24,7 +30,7 @@ export const NavidromeAlbumFilters = ({
|
||||
serverId,
|
||||
}: NavidromeAlbumFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { filter } = useListStoreByKey({ key: pageKey });
|
||||
const { filter } = useListStoreByKey<AlbumListQuery>({ key: pageKey });
|
||||
const { setFilter } = useListStoreActions();
|
||||
|
||||
const genreListQuery = useGenreList({
|
||||
@@ -48,13 +54,8 @@ export const NavidromeAlbumFilters = ({
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
genre_id: e || undefined,
|
||||
},
|
||||
},
|
||||
_custom: filter._custom,
|
||||
genres: e ? [e] : undefined,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
@@ -90,20 +91,15 @@ export const NavidromeAlbumFilters = ({
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
starred: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
_custom: filter._custom,
|
||||
favorite: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter._custom?.navidrome?.starred,
|
||||
value: filter.favorite,
|
||||
},
|
||||
{
|
||||
label: t('filter.isCompilation', { postProcess: 'sentenceCase' }),
|
||||
@@ -111,20 +107,15 @@ export const NavidromeAlbumFilters = ({
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
compilation: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
_custom: filter._custom,
|
||||
compilation: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter._custom?.navidrome?.compilation,
|
||||
value: filter.compilation,
|
||||
},
|
||||
{
|
||||
label: t('filter.isRecentlyPlayed', { postProcess: 'sentenceCase' }),
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlbumListQuery, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
|
||||
|
||||
interface SubsonicAlbumFiltersProps {
|
||||
onFilterChange: (filters: AlbumListFilter) => void;
|
||||
pageKey: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const SubsonicAlbumFilters = ({
|
||||
onFilterChange,
|
||||
pageKey,
|
||||
serverId,
|
||||
}: SubsonicAlbumFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { filter } = useListStoreByKey<AlbumListQuery>({ key: pageKey });
|
||||
const { setFilter } = useListStoreActions();
|
||||
|
||||
const genreListQuery = useGenreList({
|
||||
query: {
|
||||
sortBy: GenreListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
|
||||
const genreList = useMemo(() => {
|
||||
if (!genreListQuery?.data) return [];
|
||||
return genreListQuery.data.items.map((genre) => ({
|
||||
label: genre.name,
|
||||
value: genre.id,
|
||||
}));
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const handleGenresFilter = debounce((e: string | null) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
genres: e ? [e] : undefined,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
|
||||
onFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
favorite: e.target.checked ? true : undefined,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter.favorite,
|
||||
},
|
||||
];
|
||||
|
||||
const handleYearFilter = debounce((e: number | string, type: 'min' | 'max') => {
|
||||
let data: Partial<AlbumListQuery> = {};
|
||||
|
||||
if (type === 'min') {
|
||||
data = {
|
||||
minYear: e ? Number(e) : undefined,
|
||||
};
|
||||
} else {
|
||||
data = {
|
||||
maxYear: e ? Number(e) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedFilters = setFilter({
|
||||
data,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
|
||||
onFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
return (
|
||||
<Stack p="0.8rem">
|
||||
{toggleFilters.map((filter) => (
|
||||
<Group
|
||||
key={`nd-filter-${filter.label}`}
|
||||
position="apart"
|
||||
>
|
||||
<Text>{filter.label}</Text>
|
||||
<Switch
|
||||
checked={filter?.value || false}
|
||||
onChange={filter.onChange}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
defaultValue={filter.minYear}
|
||||
disabled={filter.genres?.length !== undefined}
|
||||
hideControls={false}
|
||||
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
|
||||
max={5000}
|
||||
min={0}
|
||||
onChange={(e) => handleYearFilter(e, 'min')}
|
||||
/>
|
||||
<NumberInput
|
||||
defaultValue={filter.maxYear}
|
||||
disabled={filter.genres?.length !== undefined}
|
||||
hideControls={false}
|
||||
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
|
||||
max={5000}
|
||||
min={0}
|
||||
onChange={(e) => handleYearFilter(e, 'max')}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={filter.genres?.length ? filter.genres[0] : undefined}
|
||||
disabled={Boolean(filter.minYear || filter.maxYear)}
|
||||
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { AlbumListQuery } from '/@/renderer/api/types';
|
||||
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
|
||||
export const useAlbumListCount = (args: QueryHookArgs<AlbumListQuery>) => {
|
||||
const { options, query, serverId } = args;
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!serverId,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getAlbumListCount({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.albums.count(
|
||||
serverId || '',
|
||||
Object.keys(query).length === 0 ? undefined : query,
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -5,12 +5,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { AlbumListQuery, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ListContext } from '/@/renderer/context/list-context';
|
||||
import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';
|
||||
import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header';
|
||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
@@ -18,6 +17,7 @@ import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { titleCase } from '/@/renderer/utils';
|
||||
import { useAlbumListCount } from '/@/renderer/features/albums/queries/album-list-count-query';
|
||||
|
||||
const AlbumListRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -33,14 +33,7 @@ const AlbumListRoute = () => {
|
||||
const value = {
|
||||
...(albumArtistId && { artistIds: [albumArtistId] }),
|
||||
...(genreId && {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
GenreIds: genreId,
|
||||
},
|
||||
navidrome: {
|
||||
genre_id: genreId,
|
||||
},
|
||||
},
|
||||
genres: [genreId],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -51,7 +44,7 @@ const AlbumListRoute = () => {
|
||||
return value;
|
||||
}, [albumArtistId, genreId]);
|
||||
|
||||
const albumListFilter = useListFilterByKey({
|
||||
const albumListFilter = useListFilterByKey<AlbumListQuery>({
|
||||
filter: customFilters,
|
||||
key: pageKey,
|
||||
});
|
||||
@@ -78,32 +71,27 @@ const AlbumListRoute = () => {
|
||||
return genre?.name;
|
||||
}, [genreId, genreList.data]);
|
||||
|
||||
const itemCountCheck = useAlbumList({
|
||||
const itemCountCheck = useAlbumListCount({
|
||||
options: {
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: {
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...albumListFilter,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
? undefined
|
||||
: itemCountCheck.data?.totalRecordCount;
|
||||
const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
|
||||
|
||||
const handlePlay = useCallback(
|
||||
async (args: { initialSongId?: string; playType: Play }) => {
|
||||
if (!itemCount || itemCount === 0) return;
|
||||
const { playType } = args;
|
||||
const query = {
|
||||
startIndex: 0,
|
||||
...albumListFilter,
|
||||
...customFilters,
|
||||
startIndex: 0,
|
||||
};
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
|
||||
@@ -111,18 +111,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
enabled: enabledItem.recentAlbums,
|
||||
},
|
||||
query: {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...(server?.type === ServerType.JELLYFIN
|
||||
? { AlbumArtistIds: albumArtistId }
|
||||
: undefined),
|
||||
},
|
||||
navidrome: {
|
||||
...(server?.type === ServerType.NAVIDROME
|
||||
? { artist_id: albumArtistId, compilation: false }
|
||||
: undefined),
|
||||
},
|
||||
},
|
||||
artistIds: [albumArtistId],
|
||||
limit: 15,
|
||||
sortBy: AlbumListSort.RELEASE_DATE,
|
||||
sortOrder: SortOrder.DESC,
|
||||
@@ -133,21 +122,11 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
|
||||
const compilationAlbumsQuery = useAlbumList({
|
||||
options: {
|
||||
enabled: enabledItem.compilations,
|
||||
enabled: enabledItem.compilations && server?.type !== ServerType.SUBSONIC,
|
||||
},
|
||||
query: {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...(server?.type === ServerType.JELLYFIN
|
||||
? { ContributingArtistIds: albumArtistId }
|
||||
: undefined),
|
||||
},
|
||||
navidrome: {
|
||||
...(server?.type === ServerType.NAVIDROME
|
||||
? { artist_id: albumArtistId, compilation: true }
|
||||
: undefined),
|
||||
},
|
||||
},
|
||||
artistIds: [albumArtistId],
|
||||
compilation: true,
|
||||
limit: 15,
|
||||
sortBy: AlbumListSort.RELEASE_DATE,
|
||||
sortOrder: SortOrder.DESC,
|
||||
@@ -254,7 +233,10 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
},
|
||||
{
|
||||
data: compilationAlbumsQuery?.data?.items,
|
||||
isHidden: !compilationAlbumsQuery?.data?.items?.length || !enabledItem.compilations,
|
||||
isHidden:
|
||||
!compilationAlbumsQuery?.data?.items?.length ||
|
||||
!enabledItem.compilations ||
|
||||
server?.type === ServerType.SUBSONIC,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
loading: compilationAlbumsQuery?.isLoading || compilationAlbumsQuery.isFetching,
|
||||
order: itemOrder.compilations,
|
||||
@@ -301,6 +283,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
recentAlbumsQuery?.data?.items,
|
||||
recentAlbumsQuery.isFetching,
|
||||
recentAlbumsQuery?.isLoading,
|
||||
server?.type,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -567,6 +550,9 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
suppressLoadingOverlay
|
||||
suppressRowDrag
|
||||
columnDefs={topSongsColumnDefs}
|
||||
context={{
|
||||
itemType: LibraryItem.SONG,
|
||||
}}
|
||||
enableCellChangeFlash={false}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
rowData={topSongs}
|
||||
|
||||
@@ -26,16 +26,19 @@ export const AlbumArtistDetailHeader = forwardRef(
|
||||
|
||||
const metadataItems = [
|
||||
{
|
||||
enabled: detailQuery?.data?.albumCount,
|
||||
id: 'albumCount',
|
||||
secondary: false,
|
||||
value: t('entity.albumWithCount', { count: detailQuery?.data?.albumCount || 0 }),
|
||||
},
|
||||
{
|
||||
enabled: detailQuery?.data?.songCount,
|
||||
id: 'songCount',
|
||||
secondary: false,
|
||||
value: t('entity.trackWithCount', { count: detailQuery?.data?.songCount || 0 }),
|
||||
},
|
||||
{
|
||||
enabled: detailQuery.data?.duration,
|
||||
id: 'duration',
|
||||
secondary: true,
|
||||
value:
|
||||
@@ -70,7 +73,7 @@ export const AlbumArtistDetailHeader = forwardRef(
|
||||
<Stack>
|
||||
<Group>
|
||||
{metadataItems
|
||||
.filter((i) => i.value)
|
||||
.filter((i) => i.enabled)
|
||||
.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text $noSelect>•</Text>}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
AlbumArtistListQuery,
|
||||
AlbumArtistListResponse,
|
||||
AlbumArtistListSort,
|
||||
ArtistListQuery,
|
||||
LibraryItem,
|
||||
} from '/@/renderer/api/types';
|
||||
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components';
|
||||
@@ -34,7 +33,7 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const { pageKey } = useListContext();
|
||||
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { grid, display, filter } = useListStoreByKey<AlbumArtistListQuery>({ key: pageKey });
|
||||
const { setGrid } = useListStoreActions();
|
||||
const handleFavorite = useHandleFavorite({ gridRef, server });
|
||||
|
||||
@@ -73,7 +72,7 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
|
||||
|
||||
const fetch = useCallback(
|
||||
async ({ skip: startIndex, take: limit }: { skip: number; take: number }) => {
|
||||
const query: ArtistListQuery = {
|
||||
const query: AlbumArtistListQuery = {
|
||||
...filter,
|
||||
limit,
|
||||
startIndex,
|
||||
@@ -91,7 +90,6 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
|
||||
},
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...filter,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -9,7 +9,13 @@ import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react
|
||||
import { useListContext } from '../../../context/list-context';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumArtistListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
AlbumArtistListQuery,
|
||||
AlbumArtistListSort,
|
||||
LibraryItem,
|
||||
ServerType,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
@@ -85,6 +91,28 @@ const FILTERS = {
|
||||
value: AlbumArtistListSort.SONG_COUNT,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }),
|
||||
value: AlbumArtistListSort.ALBUM_COUNT,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),
|
||||
value: AlbumArtistListSort.FAVORITED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: AlbumArtistListSort.NAME,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
|
||||
value: AlbumArtistListSort.RATING,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface AlbumArtistListHeaderFiltersProps {
|
||||
@@ -100,7 +128,9 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const { pageKey } = useListContext();
|
||||
const { display, table, grid, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { display, table, grid, filter } = useListStoreByKey<AlbumArtistListQuery>({
|
||||
key: pageKey,
|
||||
});
|
||||
const { setFilter, setTable, setTablePagination, setDisplayType, setGrid } =
|
||||
useListStoreActions();
|
||||
const cq = useContainerQuery();
|
||||
@@ -416,7 +446,9 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
icon={<RiRefreshLine />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
{t('common.refresh', {
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
@@ -436,7 +468,9 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.CARD}
|
||||
value={ListDisplayType.CARD}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Flex, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FilterBar } from '../../shared/components/filter-bar';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { AlbumArtistListQuery, LibraryItem } from '/@/renderer/api/types';
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';
|
||||
@@ -28,8 +28,9 @@ export const AlbumArtistListHeader = ({
|
||||
const server = useCurrentServer();
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const { filter, refresh, search } = useDisplayRefresh({
|
||||
const { filter, refresh, search } = useDisplayRefresh<AlbumArtistListQuery>({
|
||||
gridRef,
|
||||
itemCount,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
server,
|
||||
tableRef,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumArtistListQuery } from '/@/renderer/api/types';
|
||||
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
|
||||
export const useAlbumArtistListCount = (args: QueryHookArgs<AlbumArtistListQuery>) => {
|
||||
const { options, query, serverId } = args;
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!serverId,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getAlbumArtistListCount({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.albumArtists.count(
|
||||
serverId || '',
|
||||
Object.keys(query).length === 0 ? undefined : query,
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -13,7 +13,7 @@ export const useTopSongsList = (args: QueryHookArgs<TopSongListQuery>) => {
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getTopSongList({ apiClientProps: { server, signal }, query });
|
||||
return api.controller.getTopSongs({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.albumArtists.topSongs(server?.id || '', query),
|
||||
...options,
|
||||
|
||||
@@ -2,13 +2,13 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useCurrentServer } from '../../../store/auth.store';
|
||||
import { useListFilterByKey } from '../../../store/list.store';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { AlbumArtistListQuery, LibraryItem } from '/@/renderer/api/types';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ListContext } from '/@/renderer/context/list-context';
|
||||
import { AlbumArtistListContent } from '/@/renderer/features/artists/components/album-artist-list-content';
|
||||
import { AlbumArtistListHeader } from '/@/renderer/features/artists/components/album-artist-list-header';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { useAlbumArtistListCount } from '/@/renderer/features/artists/queries/album-artist-list-count-query';
|
||||
|
||||
const AlbumArtistListRoute = () => {
|
||||
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
|
||||
@@ -16,25 +16,18 @@ const AlbumArtistListRoute = () => {
|
||||
const pageKey = LibraryItem.ALBUM_ARTIST;
|
||||
const server = useCurrentServer();
|
||||
|
||||
const albumArtistListFilter = useListFilterByKey({ key: pageKey });
|
||||
const albumArtistListFilter = useListFilterByKey<AlbumArtistListQuery>({ key: pageKey });
|
||||
|
||||
const itemCountCheck = useAlbumArtistList({
|
||||
const itemCountCheck = useAlbumArtistListCount({
|
||||
options: {
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: {
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...albumArtistListFilter,
|
||||
},
|
||||
query: albumArtistListFilter,
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
? undefined
|
||||
: itemCountCheck.data?.totalRecordCount;
|
||||
const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
|
||||
|
||||
const providerValue = useMemo(() => {
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { SetContextMenuItems } from '/@/renderer/features/context-menu/events';
|
||||
|
||||
export const QUEUE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
{ divider: true, id: 'removeFromQueue' },
|
||||
{ id: 'moveToNextOfQueue' },
|
||||
{ id: 'moveToBottomOfQueue' },
|
||||
{ divider: true, id: 'moveToTopOfQueue' },
|
||||
{ divider: true, id: 'addToPlaylist' },
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
RiAddBoxFill,
|
||||
RiAddCircleFill,
|
||||
RiArrowDownLine,
|
||||
RiArrowGoForwardLine,
|
||||
RiArrowRightSFill,
|
||||
RiArrowUpLine,
|
||||
RiDeleteBinFill,
|
||||
@@ -494,17 +495,24 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
const removeFromPlaylistMutation = useRemoveFromPlaylist();
|
||||
|
||||
const handleRemoveFromPlaylist = useCallback(() => {
|
||||
const songId =
|
||||
(serverType === ServerType.NAVIDROME || ServerType.JELLYFIN
|
||||
? ctx.dataNodes?.map((node) => node.data.playlistItemId)
|
||||
: ctx.dataNodes?.map((node) => node.data.id)) || [];
|
||||
let songId: string[] | undefined;
|
||||
|
||||
switch (serverType) {
|
||||
case ServerType.NAVIDROME:
|
||||
case ServerType.JELLYFIN:
|
||||
songId = ctx.dataNodes?.map((node) => node.data.playlistItemId);
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
songId = ctx.dataNodes?.map((node) => node.rowIndex!.toString());
|
||||
break;
|
||||
}
|
||||
|
||||
const confirm = () => {
|
||||
removeFromPlaylistMutation.mutate(
|
||||
{
|
||||
query: {
|
||||
id: ctx.context.playlistId,
|
||||
songId,
|
||||
songId: songId || [],
|
||||
},
|
||||
serverId: ctx.data?.[0]?.serverId,
|
||||
},
|
||||
@@ -602,7 +610,19 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
);
|
||||
|
||||
const playbackType = usePlaybackType();
|
||||
const { moveToBottomOfQueue, moveToTopOfQueue, removeFromQueue } = useQueueControls();
|
||||
const { moveToNextOfQueue, moveToBottomOfQueue, moveToTopOfQueue, removeFromQueue } =
|
||||
useQueueControls();
|
||||
|
||||
const handleMoveToNext = useCallback(() => {
|
||||
const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId);
|
||||
if (!uniqueIds?.length) return;
|
||||
|
||||
const playerData = moveToNextOfQueue(uniqueIds);
|
||||
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
setQueueNext(playerData);
|
||||
}
|
||||
}, [ctx.dataNodes, moveToNextOfQueue, playbackType]);
|
||||
|
||||
const handleMoveToBottom = useCallback(() => {
|
||||
const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId);
|
||||
@@ -751,6 +771,12 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
leftIcon: <RiArrowDownLine size="1.1rem" />,
|
||||
onClick: handleMoveToBottom,
|
||||
},
|
||||
moveToNextOfQueue: {
|
||||
id: 'moveToNext',
|
||||
label: t('page.contextMenu.moveToNext', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiArrowGoForwardLine size="1.1rem" />,
|
||||
onClick: handleMoveToNext,
|
||||
},
|
||||
moveToTopOfQueue: {
|
||||
id: 'moveToTopOfQueue',
|
||||
label: t('page.contextMenu.moveToTop', { postProcess: 'sentenceCase' }),
|
||||
@@ -869,7 +895,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
},
|
||||
],
|
||||
id: 'setRating',
|
||||
label: 'Set rating',
|
||||
label: t('action.setRating', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiStarFill size="1.1rem" />,
|
||||
onClick: () => {},
|
||||
rightIcon: <RiArrowRightSFill size="1.2rem" />,
|
||||
@@ -897,6 +923,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
handleDeselectAll,
|
||||
ctx.data,
|
||||
handleDownload,
|
||||
handleMoveToNext,
|
||||
handleMoveToBottom,
|
||||
handleMoveToTop,
|
||||
handleSimilar,
|
||||
|
||||
@@ -32,6 +32,7 @@ export type ContextMenuItemType =
|
||||
| 'shareItem'
|
||||
| 'deletePlaylist'
|
||||
| 'createPlaylist'
|
||||
| 'moveToNextOfQueue'
|
||||
| 'moveToBottomOfQueue'
|
||||
| 'moveToTopOfQueue'
|
||||
| 'removeFromQueue'
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useCurrentSong,
|
||||
useCurrentStatus,
|
||||
useDiscordSetttings,
|
||||
useGeneralSettings,
|
||||
usePlayerStore,
|
||||
} from '/@/renderer/store';
|
||||
import { SetActivity } from '@xhayper/discord-rpc';
|
||||
@@ -16,6 +17,7 @@ const discordRpc = isElectron() ? window.electron.discordRpc : null;
|
||||
export const useDiscordRpc = () => {
|
||||
const intervalRef = useRef(0);
|
||||
const discordSettings = useDiscordSetttings();
|
||||
const generalSettings = useGeneralSettings();
|
||||
const currentSong = useCurrentSong();
|
||||
const currentStatus = useCurrentStatus();
|
||||
|
||||
@@ -67,6 +69,19 @@ export const useDiscordRpc = () => {
|
||||
activity.largeImageKey = song?.imageUrl;
|
||||
}
|
||||
|
||||
if (generalSettings.lastfmApiKey && song?.album && song?.artists.length) {
|
||||
console.log('Fetching album info for', song.album, song.artists[0].name);
|
||||
const albumInfo = await fetch(
|
||||
`https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=${generalSettings.lastfmApiKey}&artist=${encodeURIComponent(song.artistName)}&album=${encodeURIComponent(song.album)}&format=json`,
|
||||
);
|
||||
|
||||
const albumInfoJson = await albumInfo.json();
|
||||
|
||||
if (albumInfoJson.album?.image?.[3]['#text']) {
|
||||
activity.largeImageKey = albumInfoJson.album.image[3]['#text'];
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to default icon if not set
|
||||
if (!activity.largeImageKey) {
|
||||
activity.largeImageKey = 'icon';
|
||||
@@ -79,6 +94,7 @@ export const useDiscordRpc = () => {
|
||||
discordSettings.enableIdle,
|
||||
discordSettings.showAsListening,
|
||||
discordSettings.showServerImage,
|
||||
generalSettings.lastfmApiKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -22,7 +22,7 @@ export const GenreListGridView = ({ gridRef, itemCount }: any) => {
|
||||
const server = useCurrentServer();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { pageKey, id } = useListContext();
|
||||
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { grid, display, filter } = useListStoreByKey<GenreListQuery>({ key: pageKey });
|
||||
const { setGrid } = useListStoreActions();
|
||||
const genrePath = useGenreRoute();
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
RiSettings3Fill,
|
||||
} from 'react-icons/ri';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { GenreListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
GenreListQuery,
|
||||
GenreListSort,
|
||||
LibraryItem,
|
||||
ServerType,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
@@ -47,25 +53,38 @@ const FILTERS = {
|
||||
value: GenreListSort.NAME,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: GenreListSort.NAME,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface GenreListHeaderFiltersProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount: number | undefined;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFiltersProps) => {
|
||||
export const GenreListHeaderFilters = ({
|
||||
gridRef,
|
||||
itemCount,
|
||||
tableRef,
|
||||
}: GenreListHeaderFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { pageKey, customFilters } = useListContext();
|
||||
const server = useCurrentServer();
|
||||
const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions();
|
||||
const { display, filter, table, grid } = useListStoreByKey({ key: pageKey });
|
||||
const { display, filter, table, grid } = useListStoreByKey<GenreListQuery>({ key: pageKey });
|
||||
const cq = useContainerQuery();
|
||||
const { genreTarget } = useGeneralSettings();
|
||||
const { setGenreBehavior } = useSettingsStoreActions();
|
||||
|
||||
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
|
||||
itemCount,
|
||||
itemType: LibraryItem.GENRE,
|
||||
server,
|
||||
});
|
||||
@@ -367,7 +386,7 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.displayType', { postProcess: 'titleCase' })}
|
||||
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.CARD}
|
||||
@@ -404,7 +423,11 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
|
||||
</DropdownMenu.Item>
|
||||
{isGrid && (
|
||||
<>
|
||||
<DropdownMenu.Label>Item gap</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.itemGap', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={grid?.itemGap || 0}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ChangeEvent, MutableRefObject } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { GenreListQuery, LibraryItem } from '/@/renderer/api/types';
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { GenreListHeaderFilters } from '/@/renderer/features/genres/components/genre-list-header-filters';
|
||||
@@ -22,7 +22,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
|
||||
const { t } = useTranslation();
|
||||
const cq = useContainerQuery();
|
||||
const server = useCurrentServer();
|
||||
const { filter, refresh, search } = useDisplayRefresh({
|
||||
const { filter, refresh, search } = useDisplayRefresh<GenreListQuery>({
|
||||
gridRef,
|
||||
itemType: LibraryItem.GENRE,
|
||||
server,
|
||||
@@ -66,6 +66,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
|
||||
<FilterBar>
|
||||
<GenreListHeaderFilters
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</FilterBar>
|
||||
|
||||
@@ -8,19 +8,20 @@ import { useGenreList } from '/@/renderer/features/genres/queries/genre-list-que
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { useListStoreByKey } from '../../../store/list.store';
|
||||
import { GenreListQuery } from '/@/renderer/api/types';
|
||||
|
||||
const GenreListRoute = () => {
|
||||
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const server = useCurrentServer();
|
||||
const pageKey = 'genre';
|
||||
const { filter } = useListStoreByKey({ key: pageKey });
|
||||
const { filter } = useListStoreByKey<GenreListQuery>({ key: pageKey });
|
||||
|
||||
const itemCountCheck = useGenreList({
|
||||
query: {
|
||||
...filter,
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...filter,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ interface LyricsActionsProps {
|
||||
onRemoveLyric: () => void;
|
||||
onResetLyric: () => void;
|
||||
onSearchOverride: (params: LyricsOverride) => void;
|
||||
onTranslateLyric: () => void;
|
||||
setIndex: (idx: number) => void;
|
||||
}
|
||||
|
||||
@@ -28,6 +29,7 @@ export const LyricsActions = ({
|
||||
onRemoveLyric,
|
||||
onResetLyric,
|
||||
onSearchOverride,
|
||||
onTranslateLyric,
|
||||
setIndex,
|
||||
}: LyricsActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -120,7 +122,6 @@ export const LyricsActions = ({
|
||||
{isDesktop && sources.length ? (
|
||||
<Button
|
||||
uppercase
|
||||
color="red"
|
||||
disabled={isActionsDisabled}
|
||||
variant="subtle"
|
||||
onClick={onRemoveLyric}
|
||||
@@ -129,6 +130,19 @@ export const LyricsActions = ({
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<Box style={{ position: 'absolute', right: 0, top: -50 }}>
|
||||
{isDesktop && sources.length ? (
|
||||
<Button
|
||||
uppercase
|
||||
disabled={isActionsDisabled}
|
||||
variant="subtle"
|
||||
onClick={onTranslateLyric}
|
||||
>
|
||||
{t('common.translation', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,9 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Center, Group } from '@mantine/core';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiInformationFill } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { useSongLyricsByRemoteId, useSongLyricsBySong } from './queries/lyric-query';
|
||||
import { translateLyrics } from './queries/lyric-translate';
|
||||
import { SynchronizedLyrics, SynchronizedLyricsProps } from './synchronized-lyrics';
|
||||
import { Spinner, TextTitle } from '/@/renderer/components';
|
||||
import { ErrorFallback } from '/@/renderer/features/action-required';
|
||||
@@ -12,7 +14,7 @@ import {
|
||||
UnsynchronizedLyrics,
|
||||
UnsynchronizedLyricsProps,
|
||||
} from '/@/renderer/features/lyrics/unsynchronized-lyrics';
|
||||
import { useCurrentSong, usePlayerStore } from '/@/renderer/store';
|
||||
import { useCurrentSong, usePlayerStore, useLyricsSettings } from '/@/renderer/store';
|
||||
import { FullLyricsMetadata, LyricSource, LyricsOverride } from '/@/renderer/api/types';
|
||||
import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
@@ -84,7 +86,11 @@ const ScrollContainer = styled(motion.div)`
|
||||
|
||||
export const Lyrics = () => {
|
||||
const currentSong = useCurrentSong();
|
||||
const lyricsSettings = useLyricsSettings();
|
||||
const { t } = useTranslation();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [translatedLyrics, setTranslatedLyrics] = useState<string | null>(null);
|
||||
const [showTranslation, setShowTranslation] = useState(false);
|
||||
|
||||
const { data, isInitialLoading } = useSongLyricsBySong(
|
||||
{
|
||||
@@ -96,6 +102,19 @@ export const Lyrics = () => {
|
||||
|
||||
const [override, setOverride] = useState<LyricsOverride | undefined>(undefined);
|
||||
|
||||
const [lyrics, synced] = useMemo(() => {
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length > 0) {
|
||||
const selectedLyric = data[Math.min(index, data.length)];
|
||||
return [selectedLyric, selectedLyric.synced];
|
||||
}
|
||||
} else if (data?.lyrics) {
|
||||
return [data, Array.isArray(data.lyrics)];
|
||||
}
|
||||
|
||||
return [undefined, false];
|
||||
}, [data, index]);
|
||||
|
||||
const handleOnSearchOverride = useCallback((params: LyricsOverride) => {
|
||||
setOverride(params);
|
||||
}, []);
|
||||
@@ -123,6 +142,27 @@ export const Lyrics = () => {
|
||||
);
|
||||
}, [currentSong?.id, currentSong?.serverId]);
|
||||
|
||||
const handleOnTranslateLyric = useCallback(async () => {
|
||||
if (translatedLyrics) {
|
||||
setShowTranslation(!showTranslation);
|
||||
return;
|
||||
}
|
||||
if (!lyrics) return;
|
||||
const originalLyrics = Array.isArray(lyrics.lyrics)
|
||||
? lyrics.lyrics.map(([, line]) => line).join('\n')
|
||||
: lyrics.lyrics;
|
||||
const { translationApiKey, translationApiProvider, translationTargetLanguage } =
|
||||
lyricsSettings;
|
||||
const TranslatedText: string | null = await translateLyrics(
|
||||
originalLyrics,
|
||||
translationApiKey,
|
||||
translationApiProvider,
|
||||
translationTargetLanguage,
|
||||
);
|
||||
setTranslatedLyrics(TranslatedText);
|
||||
setShowTranslation(true);
|
||||
}, [lyrics, lyricsSettings, translatedLyrics, showTranslation]);
|
||||
|
||||
const { isInitialLoading: isOverrideLoading } = useSongLyricsByRemoteId({
|
||||
options: {
|
||||
enabled: !!override,
|
||||
@@ -150,19 +190,6 @@ export const Lyrics = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [lyrics, synced] = useMemo(() => {
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length > 0) {
|
||||
const selectedLyric = data[Math.min(index, data.length)];
|
||||
return [selectedLyric, selectedLyric.synced];
|
||||
}
|
||||
} else if (data?.lyrics) {
|
||||
return [data, Array.isArray(data.lyrics)];
|
||||
}
|
||||
|
||||
return [undefined, false];
|
||||
}, [data, index]);
|
||||
|
||||
const languages = useMemo(() => {
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((lyric, idx) => ({ label: lyric.lang, value: idx.toString() }));
|
||||
@@ -192,7 +219,9 @@ export const Lyrics = () => {
|
||||
order={3}
|
||||
weight={700}
|
||||
>
|
||||
No lyrics found
|
||||
{t('page.fullscreenPlayer.noLyrics', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</TextTitle>
|
||||
</Group>
|
||||
</Center>
|
||||
@@ -203,10 +232,14 @@ export const Lyrics = () => {
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{synced ? (
|
||||
<SynchronizedLyrics {...(lyrics as SynchronizedLyricsProps)} />
|
||||
<SynchronizedLyrics
|
||||
{...(lyrics as SynchronizedLyricsProps)}
|
||||
translatedLyrics={showTranslation ? translatedLyrics : null}
|
||||
/>
|
||||
) : (
|
||||
<UnsynchronizedLyrics
|
||||
{...(lyrics as UnsynchronizedLyricsProps)}
|
||||
translatedLyrics={showTranslation ? translatedLyrics : null}
|
||||
/>
|
||||
)}
|
||||
</ScrollContainer>
|
||||
@@ -221,6 +254,7 @@ export const Lyrics = () => {
|
||||
onRemoveLyric={handleOnRemoveLyric}
|
||||
onResetLyric={handleOnResetLyric}
|
||||
onSearchOverride={handleOnSearchOverride}
|
||||
onTranslateLyric={handleOnTranslateLyric}
|
||||
/>
|
||||
</ActionsContainer>
|
||||
</LyricsContainer>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const translateLyrics = async (
|
||||
originalLyrics: string,
|
||||
translationApiKey: string,
|
||||
translationApiProvider: string | null,
|
||||
translationTargetLanguage: string | null,
|
||||
) => {
|
||||
let TranslatedText = '';
|
||||
if (translationApiProvider === 'Microsoft Azure') {
|
||||
try {
|
||||
const response = await axios({
|
||||
data: [
|
||||
{
|
||||
Text: originalLyrics,
|
||||
},
|
||||
],
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Ocp-Apim-Subscription-Key': translationApiKey,
|
||||
},
|
||||
method: 'post',
|
||||
url: `https://api.cognitive.microsofttranslator.com/translate?api-version=3.0&to=${translationTargetLanguage as string}`,
|
||||
});
|
||||
TranslatedText = response.data[0].translations[0].text;
|
||||
} catch (e) {
|
||||
console.error('Microsoft Azure translate request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
} else if (translationApiProvider === 'Google Cloud') {
|
||||
try {
|
||||
const response = await axios({
|
||||
data: {
|
||||
format: 'text',
|
||||
q: originalLyrics,
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'post',
|
||||
url: `https://translation.googleapis.com/language/translate/v2?target=${translationTargetLanguage as string}&key=${translationApiKey}`,
|
||||
});
|
||||
TranslatedText = response.data.data.translations[0].translatedText;
|
||||
} catch (e) {
|
||||
console.error('Google Cloud translate request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return TranslatedText;
|
||||
};
|
||||
@@ -55,6 +55,7 @@ const SynchronizedLyricsContainer = styled.div<{ $gap: number }>`
|
||||
|
||||
export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
|
||||
lyrics: SynchronizedLyricsArray;
|
||||
translatedLyrics?: string | null;
|
||||
}
|
||||
|
||||
export const SynchronizedLyrics = ({
|
||||
@@ -63,6 +64,7 @@ export const SynchronizedLyrics = ({
|
||||
name,
|
||||
remote,
|
||||
source,
|
||||
translatedLyrics,
|
||||
}: SynchronizedLyricsProps) => {
|
||||
const playersRef = PlayersRef;
|
||||
const status = useCurrentStatus();
|
||||
@@ -364,15 +366,25 @@ export const SynchronizedLyrics = ({
|
||||
/>
|
||||
)}
|
||||
{lyrics.map(([time, text], idx) => (
|
||||
<LyricLine
|
||||
key={idx}
|
||||
alignment={settings.alignment}
|
||||
className="lyric-line synchronized"
|
||||
fontSize={settings.fontSize}
|
||||
id={`lyric-${idx}`}
|
||||
text={text}
|
||||
onClick={() => handleSeek(time / 1000)}
|
||||
/>
|
||||
<div key={idx}>
|
||||
<LyricLine
|
||||
alignment={settings.alignment}
|
||||
className="lyric-line synchronized"
|
||||
fontSize={settings.fontSize}
|
||||
id={`lyric-${idx}`}
|
||||
text={text}
|
||||
onClick={() => handleSeek(time / 1000)}
|
||||
/>
|
||||
{translatedLyrics && (
|
||||
<LyricLine
|
||||
alignment={settings.alignment}
|
||||
className="lyric-line synchronized translation"
|
||||
fontSize={settings.fontSize * 0.8}
|
||||
text={translatedLyrics.split('\n')[idx]}
|
||||
onClick={() => handleSeek(time / 1000)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SynchronizedLyricsContainer>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLyricsSettings } from '/@/renderer/store';
|
||||
|
||||
export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
|
||||
lyrics: string;
|
||||
translatedLyrics?: string | null;
|
||||
}
|
||||
|
||||
const UnsynchronizedLyricsContainer = styled.div<{ $gap: number }>`
|
||||
@@ -45,12 +46,17 @@ export const UnsynchronizedLyrics = ({
|
||||
name,
|
||||
remote,
|
||||
source,
|
||||
translatedLyrics,
|
||||
}: UnsynchronizedLyricsProps) => {
|
||||
const settings = useLyricsSettings();
|
||||
const lines = useMemo(() => {
|
||||
return lyrics.split('\n');
|
||||
}, [lyrics]);
|
||||
|
||||
const translatedLines = useMemo(() => {
|
||||
return translatedLyrics ? translatedLyrics.split('\n') : [];
|
||||
}, [translatedLyrics]);
|
||||
|
||||
return (
|
||||
<UnsynchronizedLyricsContainer
|
||||
$gap={settings.gapUnsync}
|
||||
@@ -73,14 +79,23 @@ export const UnsynchronizedLyrics = ({
|
||||
/>
|
||||
)}
|
||||
{lines.map((text, idx) => (
|
||||
<LyricLine
|
||||
key={idx}
|
||||
alignment={settings.alignment}
|
||||
className="lyric-line unsynchronized"
|
||||
fontSize={settings.fontSizeUnsync}
|
||||
id={`lyric-${idx}`}
|
||||
text={text}
|
||||
/>
|
||||
<div key={idx}>
|
||||
<LyricLine
|
||||
alignment={settings.alignment}
|
||||
className="lyric-line unsynchronized"
|
||||
fontSize={settings.fontSizeUnsync}
|
||||
id={`lyric-${idx}`}
|
||||
text={text}
|
||||
/>
|
||||
{translatedLines[idx] && (
|
||||
<LyricLine
|
||||
alignment={settings.alignment}
|
||||
className="lyric-line unsynchronized translation"
|
||||
fontSize={settings.fontSizeUnsync * 0.8}
|
||||
text={translatedLines[idx]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</UnsynchronizedLyricsContainer>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import isElectron from 'is-electron';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RiArrowDownLine,
|
||||
RiArrowGoForwardLine,
|
||||
RiArrowUpLine,
|
||||
RiShuffleLine,
|
||||
RiDeleteBinLine,
|
||||
@@ -30,14 +31,32 @@ interface PlayQueueListOptionsProps {
|
||||
|
||||
export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { clearQueue, moveToBottomOfQueue, moveToTopOfQueue, shuffleQueue, removeFromQueue } =
|
||||
useQueueControls();
|
||||
const {
|
||||
clearQueue,
|
||||
moveToBottomOfQueue,
|
||||
moveToNextOfQueue,
|
||||
moveToTopOfQueue,
|
||||
shuffleQueue,
|
||||
removeFromQueue,
|
||||
} = useQueueControls();
|
||||
|
||||
const { pause } = usePlayerControls();
|
||||
|
||||
const playbackType = usePlaybackType();
|
||||
const setCurrentTime = useSetCurrentTime();
|
||||
|
||||
const handleMoveToNext = () => {
|
||||
const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
|
||||
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
|
||||
if (!uniqueIds?.length) return;
|
||||
|
||||
const playerData = moveToNextOfQueue(uniqueIds);
|
||||
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
setQueueNext(playerData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveToBottom = () => {
|
||||
const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
|
||||
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
|
||||
@@ -124,6 +143,15 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
||||
>
|
||||
<RiShuffleLine size="1.1rem" />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{ label: t('action.moveToNext', { postProcess: 'sentenceCase' }) }}
|
||||
variant="default"
|
||||
onClick={handleMoveToNext}
|
||||
>
|
||||
<RiArrowGoForwardLine size="1.1rem" />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
|
||||
@@ -256,7 +256,10 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
columnDefs={columnDefs}
|
||||
context={{
|
||||
currentSong,
|
||||
handleDoubleClick,
|
||||
isFocused,
|
||||
isQueue: true,
|
||||
itemType: LibraryItem.SONG,
|
||||
onCellContextMenu,
|
||||
status,
|
||||
}}
|
||||
|
||||
@@ -98,7 +98,7 @@ export const FullScreenPlayerQueue = () => {
|
||||
items.push({
|
||||
active: activeTab === 'visualizer',
|
||||
icon: <RiFileTextLine size="1.5rem" />,
|
||||
label: 'Visualizer',
|
||||
label: t('page.fullscreenPlayer.visualizer', { postProcess: 'titleCase' }),
|
||||
onClick: () => setStore({ activeTab: 'visualizer' }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -382,7 +382,11 @@ const Controls = () => {
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Lyrics offset (ms)</Option.Label>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.config.lyricOffset', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<NumberInput
|
||||
defaultValue={lyricConfig.delayMs}
|
||||
|
||||
@@ -293,7 +293,10 @@ export const RightControls = () => {
|
||||
{!isMinWidth ? (
|
||||
<PlayerButton
|
||||
icon={<HiOutlineQueueList size="1.1rem" />}
|
||||
tooltip={{ label: 'View queue', openDelay: 500 }}
|
||||
tooltip={{
|
||||
label: t('player.viewQueue', { postProcess: 'titleCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={handleToggleQueue}
|
||||
/>
|
||||
|
||||
@@ -145,7 +145,7 @@ export const ShuffleAllModal = ({
|
||||
max={500}
|
||||
min={1}
|
||||
value={limit}
|
||||
onChange={(e) => setStore({ limit: e ? Number(e) : 0 })}
|
||||
onChange={(e) => setStore({ limit: e ? Number(e) : 500 })}
|
||||
/>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
@@ -208,6 +208,7 @@ export const ShuffleAllModal = ({
|
||||
<Divider />
|
||||
<Group grow>
|
||||
<Button
|
||||
disabled={!limit}
|
||||
leftIcon={<RiAddBoxFill size="1rem" />}
|
||||
type="submit"
|
||||
variant="default"
|
||||
@@ -216,6 +217,7 @@ export const ShuffleAllModal = ({
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!limit}
|
||||
leftIcon={<RiAddCircleFill size="1rem" />}
|
||||
type="submit"
|
||||
variant="default"
|
||||
@@ -225,6 +227,7 @@ export const ShuffleAllModal = ({
|
||||
</Button>
|
||||
</Group>
|
||||
<Button
|
||||
disabled={!limit}
|
||||
leftIcon={<RiPlayFill size="1rem" />}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
|
||||
@@ -5,12 +5,12 @@ import styled from 'styled-components';
|
||||
import { useSettingsStore } from '/@/renderer/store';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
margin: auto;
|
||||
max-width: 100%;
|
||||
margin: auto;
|
||||
|
||||
canvas {
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -176,10 +176,12 @@ export const useHandlePlayQueueAdd = () => {
|
||||
|
||||
updateSong(playerData.current.song);
|
||||
|
||||
const replacesQueue = playType === Play.NOW || playType === Play.SHUFFLE;
|
||||
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
mpvPlayer!.volume(usePlayerStore.getState().volume);
|
||||
|
||||
if (playType === Play.NOW || !hadSong) {
|
||||
if (replacesQueue || !hadSong) {
|
||||
mpvPlayer!.pause();
|
||||
setQueue(playerData, false);
|
||||
} else {
|
||||
@@ -191,14 +193,14 @@ export const useHandlePlayQueueAdd = () => {
|
||||
? PlayersRef.current?.player1
|
||||
: PlayersRef.current?.player2;
|
||||
const underlying = player?.getInternalPlayer();
|
||||
if (underlying && playType === Play.NOW) {
|
||||
if (underlying && replacesQueue) {
|
||||
underlying.currentTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// We should only play if the queue was empty, or we are doing play NOW
|
||||
// (override the queue).
|
||||
if (playType === Play.NOW || !hadSong) {
|
||||
if (replacesQueue || !hadSong) {
|
||||
play();
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ export const useRightControls = () => {
|
||||
const handleVolumeWheel = useCallback(
|
||||
(e: WheelEvent<HTMLDivElement | HTMLButtonElement>) => {
|
||||
let volumeToSet;
|
||||
if (e.deltaY > 0) {
|
||||
if (e.deltaY > 0 || e.deltaX > 0) {
|
||||
volumeToSet = calculateVolumeDown(volume, volumeWheelStep);
|
||||
} else {
|
||||
volumeToSet = calculateVolumeUp(volume, volumeWheelStep);
|
||||
|
||||
@@ -25,7 +25,7 @@ export const useSendScrobble = (options?: MutationOptions) => {
|
||||
// Manually increment the play count for the song in the queue if scrobble was submitted
|
||||
if (variables.query.submission) {
|
||||
incrementPlayCount([variables.query.id]);
|
||||
sendPlayEvent([variables.query.id]);
|
||||
sendPlayEvent(variables.query.id);
|
||||
}
|
||||
},
|
||||
...options,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
ServerListItem,
|
||||
ServerType,
|
||||
} from '/@/renderer/api/types';
|
||||
|
||||
export const getPlaylistSongsById = async (args: {
|
||||
@@ -103,18 +102,7 @@ export const getGenreSongsById = async (args: {
|
||||
};
|
||||
for (const genreId of id) {
|
||||
const queryFilter: SongListQuery = {
|
||||
_custom: {
|
||||
...(server?.type === ServerType.JELLYFIN && {
|
||||
jellyfin: {
|
||||
GenreIds: genreId,
|
||||
},
|
||||
}),
|
||||
...(server?.type === ServerType.NAVIDROME && {
|
||||
navidrome: {
|
||||
genre_id: genreId,
|
||||
},
|
||||
}),
|
||||
},
|
||||
genreIds: [genreId],
|
||||
sortBy: SongListSort.GENRE,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
@@ -140,7 +128,9 @@ export const getGenreSongsById = async (args: {
|
||||
);
|
||||
|
||||
data.items.push(...res!.items);
|
||||
data.totalRecordCount += res!.totalRecordCount;
|
||||
if (data.totalRecordCount) {
|
||||
data.totalRecordCount += res!.totalRecordCount || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
@@ -202,14 +192,15 @@ export const getSongsByQuery = async (args: {
|
||||
|
||||
const res = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
async ({ signal }) => {
|
||||
return api.controller.getSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: queryFilter,
|
||||
}),
|
||||
});
|
||||
},
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
|
||||
@@ -151,7 +151,12 @@ export const AddToPlaylistContextModal = ({
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: { id: playlistId, startIndex: 0 },
|
||||
query: {
|
||||
id: playlistId,
|
||||
sortBy: SongListSort.ID,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
import { MutableRefObject, useMemo, useRef } from 'react';
|
||||
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Box, Group } from '@mantine/core';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiMoreFill } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { useListStoreByKey } from '../../../store/list.store';
|
||||
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
|
||||
import { Button, ConfirmModal, DropdownMenu, MotionGroup, toast } from '/@/renderer/components';
|
||||
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import {
|
||||
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||
SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
|
||||
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { usePlaylistSongListInfinite } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
|
||||
import { PlayButton, PLAY_TYPES } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-header-background-color: rgb(0 0 0 / 0%) !important;
|
||||
}
|
||||
`;
|
||||
|
||||
interface PlaylistDetailContentProps {
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const { table } = useListStoreByKey({ key: LibraryItem.SONG });
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const playlistSongsQueryInfinite = usePlaylistSongListInfinite({
|
||||
options: {
|
||||
cacheTime: 0,
|
||||
keepPreviousData: false,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 50,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const handleLoadMore = () => {
|
||||
playlistSongsQueryInfinite.fetchNextPage();
|
||||
};
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() =>
|
||||
getColumnDefs(table.columns).filter((c) => c.colId !== 'album' && c.colId !== 'artist'),
|
||||
[table.columns],
|
||||
);
|
||||
|
||||
const contextMenuItems = useMemo(() => {
|
||||
if (detailQuery?.data?.rules) {
|
||||
return SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
|
||||
}
|
||||
|
||||
return PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
|
||||
}, [detailQuery?.data?.rules]);
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, contextMenuItems, {
|
||||
playlistId,
|
||||
});
|
||||
|
||||
const playlistSongData = useMemo(
|
||||
() => playlistSongsQueryInfinite.data?.pages.flatMap((p) => p?.items),
|
||||
[playlistSongsQueryInfinite.data?.pages],
|
||||
);
|
||||
|
||||
const deletePlaylistMutation = useDeletePlaylist({});
|
||||
|
||||
const handleDeletePlaylist = () => {
|
||||
deletePlaylistMutation.mutate(
|
||||
{ query: { id: playlistId }, serverId: server?.id },
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: t('error.genericError', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
closeAllModals();
|
||||
navigate(AppRoute.PLAYLISTS);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const openDeletePlaylist = () => {
|
||||
openModal({
|
||||
children: (
|
||||
<ConfirmModal
|
||||
loading={deletePlaylistMutation.isLoading}
|
||||
onConfirm={handleDeletePlaylist}
|
||||
>
|
||||
Are you sure you want to delete this playlist?
|
||||
</ConfirmModal>
|
||||
),
|
||||
title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlay = (playType?: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [playlistId],
|
||||
type: LibraryItem.PLAYLIST,
|
||||
},
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data) return;
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [playlistId],
|
||||
type: LibraryItem.PLAYLIST,
|
||||
},
|
||||
initialSongId: e.data.id,
|
||||
playType: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
|
||||
|
||||
const loadMoreRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
return (
|
||||
<ContentContainer>
|
||||
<Group
|
||||
p="1rem"
|
||||
position="apart"
|
||||
>
|
||||
<Group>
|
||||
<PlayButton onClick={() => handlePlay()} />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map(
|
||||
(type) => (
|
||||
<DropdownMenu.Item
|
||||
key={`playtype-${type.play}`}
|
||||
onClick={() => handlePlay(type.play)}
|
||||
>
|
||||
{type.label}
|
||||
</DropdownMenu.Item>
|
||||
),
|
||||
)}
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
onClick={() => {
|
||||
if (!detailQuery.data || !server) return;
|
||||
openUpdatePlaylistModal({ playlist: detailQuery.data, server });
|
||||
}}
|
||||
>
|
||||
Edit playlist
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={openDeletePlaylist}>
|
||||
Delete playlist
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
component={Link}
|
||||
to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId })}
|
||||
variant="subtle"
|
||||
>
|
||||
View full playlist
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Box>
|
||||
<VirtualTable
|
||||
ref={tableRef}
|
||||
autoFitColumns
|
||||
autoHeight
|
||||
deselectOnClickOutside
|
||||
shouldUpdateSong
|
||||
stickyHeader
|
||||
suppressCellFocus
|
||||
suppressHorizontalScroll
|
||||
suppressLoadingOverlay
|
||||
suppressRowDrag
|
||||
columnDefs={columnDefs}
|
||||
getRowId={(data) => `${data.data.uniqueId}-${data.data.pageIndex}`}
|
||||
rowClassRules={rowClassRules}
|
||||
rowData={playlistSongData}
|
||||
rowHeight={60}
|
||||
rowSelection="multiple"
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</Box>
|
||||
<MotionGroup
|
||||
p="2rem"
|
||||
position="center"
|
||||
onViewportEnter={handleLoadMore}
|
||||
>
|
||||
<Button
|
||||
ref={loadMoreRef}
|
||||
compact
|
||||
disabled={!playlistSongsQueryInfinite.hasNextPage}
|
||||
loading={playlistSongsQueryInfinite.isFetchingNextPage}
|
||||
variant="subtle"
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
{playlistSongsQueryInfinite.hasNextPage ? 'Load more' : 'End of playlist'}
|
||||
</Button>
|
||||
</MotionGroup>
|
||||
</ContentContainer>
|
||||
);
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
import { forwardRef, Fragment, Ref } from 'react';
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { useParams } from 'react-router';
|
||||
import { Badge, Text } from '/@/renderer/components';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { LibraryHeader } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { useCurrentServer } from '../../../store/auth.store';
|
||||
|
||||
interface PlaylistDetailHeaderProps {
|
||||
background: string;
|
||||
imagePlaceholderUrl?: string | null;
|
||||
imageUrl?: string | null;
|
||||
}
|
||||
|
||||
export const PlaylistDetailHeader = forwardRef(
|
||||
(
|
||||
{ background, imageUrl, imagePlaceholderUrl }: PlaylistDetailHeaderProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
|
||||
const metadataItems = [
|
||||
{
|
||||
id: 'songCount',
|
||||
secondary: false,
|
||||
value: `${detailQuery?.data?.songCount || 0} songs`,
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
secondary: true,
|
||||
value:
|
||||
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
||||
},
|
||||
];
|
||||
|
||||
const isSmartPlaylist = detailQuery?.data?.rules;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<LibraryHeader
|
||||
ref={ref}
|
||||
background={background}
|
||||
imagePlaceholderUrl={imagePlaceholderUrl}
|
||||
imageUrl={imageUrl}
|
||||
item={{ route: AppRoute.PLAYLISTS, type: LibraryItem.PLAYLIST }}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
>
|
||||
<Stack>
|
||||
<Group spacing="sm">
|
||||
{metadataItems.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text $noSelect>•</Text>}
|
||||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{isSmartPlaylist && (
|
||||
<>
|
||||
<Text $noSelect>•</Text>
|
||||
<Badge
|
||||
radius="sm"
|
||||
size="md"
|
||||
>
|
||||
Smart Playlist
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Text lineClamp={3}>{detailQuery?.data?.description}</Text>
|
||||
</Stack>
|
||||
</LibraryHeader>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -44,15 +44,16 @@ import {
|
||||
useSetPlaylistDetailTablePagination,
|
||||
} from '/@/renderer/store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { ListDisplayType, ServerType } from '/@/renderer/types';
|
||||
import { useAppFocus } from '/@/renderer/hooks';
|
||||
import { toast } from '/@/renderer/components';
|
||||
|
||||
interface PlaylistDetailContentProps {
|
||||
songs?: Song[];
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailContentProps) => {
|
||||
export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetailContentProps) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const queryClient = useQueryClient();
|
||||
const status = useCurrentStatus();
|
||||
@@ -85,7 +86,12 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
||||
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const iSClientSide = server?.type === ServerType.SUBSONIC;
|
||||
|
||||
const checkPlaylistList = usePlaylistSongList({
|
||||
options: {
|
||||
enabled: !iSClientSide,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 1,
|
||||
@@ -101,44 +107,51 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
||||
|
||||
const onGridReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
if (!iSClientSide) {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const query: PlaylistSongListQuery = {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
};
|
||||
const query: PlaylistSongListQuery = {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(
|
||||
server?.id || '',
|
||||
playlistId,
|
||||
query,
|
||||
);
|
||||
|
||||
if (!server) return;
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
const queryKey = queryKeys.playlists.songList(
|
||||
server?.id || '',
|
||||
playlistId,
|
||||
query,
|
||||
}),
|
||||
);
|
||||
);
|
||||
|
||||
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
if (!server) return;
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
}),
|
||||
);
|
||||
|
||||
params.successCallback(
|
||||
songsRes?.items || [],
|
||||
songsRes?.totalRecordCount || 0,
|
||||
);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
}
|
||||
params.api?.ensureIndexVisible(pagination.scrollOffset, 'top');
|
||||
},
|
||||
[filters, pagination.scrollOffset, playlistId, queryClient, server],
|
||||
[filters, iSClientSide, pagination.scrollOffset, playlistId, queryClient, server],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
@@ -270,6 +283,9 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
||||
|
||||
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
|
||||
|
||||
const canDrag =
|
||||
filters.sortBy === SongListSort.ID && !detailQuery?.data?.rules && !iSClientSide;
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
@@ -285,20 +301,22 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
||||
context={{
|
||||
currentSong,
|
||||
isFocused,
|
||||
itemType: LibraryItem.SONG,
|
||||
onCellContextMenu: handleContextMenu,
|
||||
status,
|
||||
}}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100}
|
||||
infiniteInitialRowCount={
|
||||
iSClientSide ? undefined : checkPlaylistList.data?.totalRecordCount || 100
|
||||
}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={pagination.itemsPerPage || 100}
|
||||
rowClassRules={rowClassRules}
|
||||
rowDragEntireRow={
|
||||
filters.sortBy === SongListSort.ID && !detailQuery?.data?.rules
|
||||
}
|
||||
rowData={songs}
|
||||
rowDragEntireRow={canDrag}
|
||||
rowHeight={page.table.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
rowModelType={iSClientSide ? 'clientSide' : 'infinite'}
|
||||
onBodyScrollEnd={handleScroll}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnMoved={handleColumnChange}
|
||||
|
||||
+111
-35
@@ -195,6 +195,68 @@ const FILTERS = {
|
||||
value: SongListSort.YEAR,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ID,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.album', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ALBUM,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ALBUM_ARTIST,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.artist', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ARTIST,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.DURATION,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.FAVORITED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.genre', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.GENRE,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.NAME,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RATING,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RECENTLY_ADDED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RECENTLY_PLAYED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.YEAR,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface PlaylistDetailSongListHeaderFiltersProps {
|
||||
@@ -241,43 +303,55 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters: SongListFilter) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
if (server?.type !== ServerType.SUBSONIC) {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
const queryKey = queryKeys.playlists.songList(
|
||||
server?.id || '',
|
||||
playlistId,
|
||||
{
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
);
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
params.successCallback(
|
||||
songsRes?.items || [],
|
||||
songsRes?.totalRecordCount || 0,
|
||||
);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
} else {
|
||||
tableRef.current?.api.redrawRows();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
}
|
||||
|
||||
if (page.display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
@@ -522,7 +596,9 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from '/@/renderer/components/virtual-grid';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useGeneralSettings, useListStoreByKey } from '/@/renderer/store';
|
||||
import { useCurrentServer, useListStoreByKey } from '/@/renderer/store';
|
||||
import { CardRow, ListDisplayType } from '/@/renderer/types';
|
||||
import { useHandleFavorite } from '/@/renderer/features/shared/hooks/use-handle-favorite';
|
||||
|
||||
@@ -35,15 +35,12 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { display, grid, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { display, grid, filter } = useListStoreByKey<PlaylistListQuery>({ key: pageKey });
|
||||
const { setGrid } = useListStoreActions();
|
||||
const { defaultFullPlaylist } = useGeneralSettings();
|
||||
const handleFavorite = useHandleFavorite({ gridRef, server });
|
||||
|
||||
const cardRows = useMemo(() => {
|
||||
const rows: CardRow<Playlist>[] = defaultFullPlaylist
|
||||
? [PLAYLIST_CARD_ROWS.nameFull]
|
||||
: [PLAYLIST_CARD_ROWS.name];
|
||||
const rows: CardRow<Playlist>[] = [PLAYLIST_CARD_ROWS.nameFull];
|
||||
|
||||
switch (filter.sortBy) {
|
||||
case PlaylistListSort.DURATION:
|
||||
@@ -66,7 +63,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [defaultFullPlaylist, filter.sortBy]);
|
||||
}, [filter.sortBy]);
|
||||
|
||||
const handleGridScroll = useCallback(
|
||||
(e: ListOnScrollProps) => {
|
||||
@@ -116,9 +113,9 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
|
||||
|
||||
const query: PlaylistListQuery = {
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filter,
|
||||
_custom: {},
|
||||
startIndex: skip,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.playlists.list(server?.id || '', query);
|
||||
@@ -160,9 +157,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
|
||||
loading={itemCount === undefined || itemCount === null}
|
||||
minimumBatchSize={40}
|
||||
route={{
|
||||
route: defaultFullPlaylist
|
||||
? AppRoute.PLAYLISTS_DETAIL_SONGS
|
||||
: AppRoute.PLAYLISTS_DETAIL,
|
||||
route: AppRoute.PLAYLISTS_DETAIL_SONGS,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }],
|
||||
}}
|
||||
width={width}
|
||||
|
||||
@@ -69,6 +69,38 @@ const FILTERS = {
|
||||
value: PlaylistListSort.UPDATED_AT,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.DURATION,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.NAME,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.owner', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.OWNER,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.isPublic', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.PUBLIC,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.SONG_COUNT,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyUpdated', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.UPDATED_AT,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface PlaylistListHeaderFiltersProps {
|
||||
@@ -86,7 +118,7 @@ export const PlaylistListHeaderFilters = ({
|
||||
const server = useCurrentServer();
|
||||
const { setFilter, setTable, setTablePagination, setGrid, setDisplayType } =
|
||||
useListStoreActions();
|
||||
const { display, filter, table, grid } = useListStoreByKey({ key: pageKey });
|
||||
const { display, filter, table, grid } = useListStoreByKey<PlaylistListQuery>({ key: pageKey });
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
|
||||
@@ -368,7 +400,7 @@ export const PlaylistListHeaderFilters = ({
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.displayType', { postProcess: 'titleCase' })}
|
||||
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.CARD}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PlaylistListFilter, useCurrentServer } from '/@/renderer/store';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiFileAddFill } from 'react-icons/ri';
|
||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
import { LibraryItem, PlaylistListQuery, ServerType } from '/@/renderer/api/types';
|
||||
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
|
||||
|
||||
interface PlaylistListHeaderProps {
|
||||
@@ -37,8 +37,9 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
|
||||
});
|
||||
};
|
||||
|
||||
const { filter, refresh, search } = useDisplayRefresh({
|
||||
const { filter, refresh, search } = useDisplayRefresh<PlaylistListQuery>({
|
||||
gridRef,
|
||||
itemCount,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
server,
|
||||
tableRef,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
import { useVirtualTable } from '/@/renderer/components/virtual-table/hooks/use-virtual-table';
|
||||
import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useGeneralSettings } from '/@/renderer/store';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
interface PlaylistListTableViewProps {
|
||||
itemCount?: number;
|
||||
@@ -18,16 +18,11 @@ interface PlaylistListTableViewProps {
|
||||
export const PlaylistListTableView = ({ tableRef, itemCount }: PlaylistListTableViewProps) => {
|
||||
const navigate = useNavigate();
|
||||
const server = useCurrentServer();
|
||||
const { defaultFullPlaylist } = useGeneralSettings();
|
||||
const pageKey = 'playlist';
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
|
||||
if (!e.data) return;
|
||||
if (defaultFullPlaylist) {
|
||||
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id }));
|
||||
} else {
|
||||
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id }));
|
||||
}
|
||||
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id }));
|
||||
};
|
||||
|
||||
const tableProps = useVirtualTable({
|
||||
|
||||
@@ -467,11 +467,11 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
<Select
|
||||
data={[
|
||||
{
|
||||
label: t('common.ascending', { postProcess: 'titleCase' }),
|
||||
label: t('common.ascending', { postProcess: 'sentenceCase' }),
|
||||
value: 'asc',
|
||||
},
|
||||
{
|
||||
label: t('common.descending', { postProcess: 'titleCase' }),
|
||||
label: t('common.descending', { postProcess: 'sentenceCase' }),
|
||||
value: 'desc',
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { PlaylistSongListQuery, PlaylistSongListResponse } from '/@/renderer/api/types';
|
||||
import type { PlaylistSongListQuery } from '/@/renderer/api/types';
|
||||
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
@@ -22,32 +22,3 @@ export const usePlaylistSongList = (args: QueryHookArgs<PlaylistSongListQuery>)
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const usePlaylistSongListInfinite = (args: QueryHookArgs<PlaylistSongListQuery>) => {
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useInfiniteQuery({
|
||||
enabled: !!server,
|
||||
getNextPageParam: (lastPage: PlaylistSongListResponse | undefined, pages) => {
|
||||
if (!lastPage?.items) return undefined;
|
||||
if (lastPage?.items?.length >= (query?.limit || 50)) {
|
||||
return pages?.length;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
queryFn: ({ pageParam = 0, signal }) => {
|
||||
return api.controller.getPlaylistSongList({
|
||||
apiClientProps: { server, signal },
|
||||
query: {
|
||||
...query,
|
||||
limit: query.limit || 50,
|
||||
startIndex: pageParam * (query.limit || 50),
|
||||
},
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.playlists.detailSongList(server?.id || '', query.id, query),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user