mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 04:50:12 +02:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee33720fcd | |||
| 7d34511039 | |||
| 8b4bbc1ede | |||
| 833d4d3aac | |||
| 7e353c4723 | |||
| ae2ce0866e | |||
| 27c42dd9f4 | |||
| 52dea17d14 | |||
| baf4e7bc0b | |||
| 74c44558fe | |||
| 4033619421 | |||
| 5d206bbb1f | |||
| 3db801f2de | |||
| 0d3cf912d3 | |||
| d81f30a8b5 | |||
| a5c3b454f4 | |||
| 68e6e3cf65 | |||
| 86e6b88555 | |||
| 5cdc45836f | |||
| d438c802a4 | |||
| a838bdebb7 | |||
| 8ff2f4dfb4 | |||
| ede47fbf8f | |||
| 9eb64079f7 | |||
| 3b955bb319 | |||
| 816adfa6c7 | |||
| f91dcc6af6 | |||
| 6dc58a3ff8 | |||
| 09fa10a4e9 | |||
| 6f45e1a814 | |||
| 62ba721f26 | |||
| 67231753e4 | |||
| c16eccaecb | |||
| 0bdf1dcb75 | |||
| 598e9ca5c2 | |||
| 615f9c3515 | |||
| b7cbdb4d6c | |||
| 3c562c1398 | |||
| 3eafa73217 | |||
| 74864d9621 | |||
| cb5562d32e | |||
| e40a175e12 | |||
| f996b111b9 | |||
| 0cb5c49924 | |||
| c636029003 | |||
| db88a6bc22 | |||
| 8ccd97b574 |
@@ -59,7 +59,11 @@ For media keys to work, you will be prompted to allow Feishin to be a Trusted Ac
|
|||||||
|
|
||||||
#### Linux Notes
|
#### Linux Notes
|
||||||
|
|
||||||
We provide a small install script to download the latest `.AppImage`, make it executable, and also download the icons required by Desktop Environments. Finally, it generates a `.desktop` file to add Feishin to your Application Launcher.
|
Feishin is available in [Flathub](https://flathub.org/en/apps/org.jeffvli.feishin).
|
||||||
|
|
||||||
|
Alternatively, you can install it as an Appimage.
|
||||||
|
We provide a small install script to download the latest `.AppImage`, make it executable, and also download the icons required by Desktop Environments.
|
||||||
|
Finally, it generates a `.desktop` file to add Feishin to your Application Launcher.
|
||||||
|
|
||||||
Simply run the installer like this:
|
Simply run the installer like this:
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,14 @@ These variables override app settings **on first run** when no persisted setting
|
|||||||
| `general.themeLight` | `defaultLight` | `FS_GENERAL_THEME_LIGHT` | Same as theme (used when system is light). |
|
| `general.themeLight` | `defaultLight` | `FS_GENERAL_THEME_LIGHT` | Same as theme (used when system is light). |
|
||||||
| `general.lastfmApiKey` | *(empty)* | `FS_GENERAL_LASTFM_API_KEY` | Last.fm API key. |
|
| `general.lastfmApiKey` | *(empty)* | `FS_GENERAL_LASTFM_API_KEY` | Last.fm API key. |
|
||||||
| `general.lastFM` | `true` | `FS_GENERAL_LAST_FM` | `true` / `false` — Enable Last.fm. |
|
| `general.lastFM` | `true` | `FS_GENERAL_LAST_FM` | `true` / `false` — Enable Last.fm. |
|
||||||
|
| `general.listenBrainz` | `true` | `FS_GENERAL_LISTEN_BRAINZ` | `true` / `false` — ListenBrainz links. |
|
||||||
| `general.musicBrainz` | `true` | `FS_GENERAL_MUSIC_BRAINZ` | `true` / `false` — MusicBrainz links. |
|
| `general.musicBrainz` | `true` | `FS_GENERAL_MUSIC_BRAINZ` | `true` / `false` — MusicBrainz links. |
|
||||||
| `general.nativeAspectRatio` | `false` | `FS_GENERAL_NATIVE_ASPECT_RATIO` | `true` / `false` — Use native cover art aspect ratio. |
|
| `general.nativeAspectRatio` | `false` | `FS_GENERAL_NATIVE_ASPECT_RATIO` | `true` / `false` — Use native cover art aspect ratio. |
|
||||||
| `general.pathReplace` | *(empty)* | `FS_GENERAL_PATH_REPLACE` | Path pattern to replace (e.g. server path in Docker). |
|
| `general.pathReplace` | *(empty)* | `FS_GENERAL_PATH_REPLACE` | Path pattern to replace (e.g. server path in Docker). |
|
||||||
| `general.pathReplaceWith` | *(empty)* | `FS_GENERAL_PATH_REPLACE_WITH` | Replacement path. |
|
| `general.pathReplaceWith` | *(empty)* | `FS_GENERAL_PATH_REPLACE_WITH` | Replacement path. |
|
||||||
| `general.playerbarOpenDrawer` | `false` | `FS_GENERAL_PLAYERBAR_OPEN_DRAWER` | `true` / `false` — Open queue/lyrics as drawer from player bar. |
|
| `general.playerbarOpenDrawer` | `false` | `FS_GENERAL_PLAYERBAR_OPEN_DRAWER` | `true` / `false` — Open queue/lyrics as drawer from player bar. |
|
||||||
| `general.primaryShade` | `6` | `FS_GENERAL_PRIMARY_SHADE` | Mantine primary shade 0–9 (number). |
|
| `general.primaryShade` | `6` | `FS_GENERAL_PRIMARY_SHADE` | Mantine primary shade 0–9 (number). |
|
||||||
|
| `general.qobuz` | `true` | `FS_GENERAL_QOBUZ` | `true` / `false` — Qobuz links. |
|
||||||
| `general.resume` | `true` | `FS_GENERAL_RESUME` | `true` / `false` — Resume playback on load. |
|
| `general.resume` | `true` | `FS_GENERAL_RESUME` | `true` / `false` — Resume playback on load. |
|
||||||
| `general.showLyricsInSidebar` | `true` | `FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR` | `true` / `false` — Show lyrics in sidebar. |
|
| `general.showLyricsInSidebar` | `true` | `FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR` | `true` / `false` — Show lyrics in sidebar. |
|
||||||
| `general.showRatings` | `true` | `FS_GENERAL_SHOW_RATINGS` | `true` / `false` — Show star ratings. |
|
| `general.showRatings` | `true` | `FS_GENERAL_SHOW_RATINGS` | `true` / `false` — Show star ratings. |
|
||||||
@@ -44,6 +46,7 @@ These variables override app settings **on first run** when no persisted setting
|
|||||||
| `general.sidebarPlaylistList` | `true` | `FS_GENERAL_SIDEBAR_PLAYLIST_LIST` | `true` / `false` — Show playlist list in sidebar. |
|
| `general.sidebarPlaylistList` | `true` | `FS_GENERAL_SIDEBAR_PLAYLIST_LIST` | `true` / `false` — Show playlist list in sidebar. |
|
||||||
| `general.sidebarPlaylistSorting` | `false` | `FS_GENERAL_SIDEBAR_PLAYLIST_SORTING` | `true` / `false` — Enable playlist sorting in sidebar. |
|
| `general.sidebarPlaylistSorting` | `false` | `FS_GENERAL_SIDEBAR_PLAYLIST_SORTING` | `true` / `false` — Enable playlist sorting in sidebar. |
|
||||||
| `general.sideQueueType` | `sideQueue` | `FS_GENERAL_SIDE_QUEUE_TYPE` | `sideDrawerQueue` / `sideQueue` — Side play queue style. |
|
| `general.sideQueueType` | `sideQueue` | `FS_GENERAL_SIDE_QUEUE_TYPE` | `sideDrawerQueue` / `sideQueue` — Side play queue style. |
|
||||||
|
| `general.sideQueueLayout` | `horizontal` | `FS_GENERAL_SIDE_QUEUE_LAYOUT` | `horizontal` / `vertical` — Attached side queue layout orientation. |
|
||||||
| `general.useThemeAccentColor` | `false` | `FS_GENERAL_USE_THEME_ACCENT_COLOR` | `true` / `false` — Use theme’s accent color instead of custom. |
|
| `general.useThemeAccentColor` | `false` | `FS_GENERAL_USE_THEME_ACCENT_COLOR` | `true` / `false` — Use theme’s accent color instead of custom. |
|
||||||
| `general.useThemePrimaryShade` | `true` | `FS_GENERAL_USE_THEME_PRIMARY_SHADE` | `true` / `false` — Use theme’s primary shade. |
|
| `general.useThemePrimaryShade` | `true` | `FS_GENERAL_USE_THEME_PRIMARY_SHADE` | `true` / `false` — Use theme’s primary shade. |
|
||||||
| `general.zoomFactor` | `100` | `FS_GENERAL_ZOOM_FACTOR` | UI zoom percentage (number). |
|
| `general.zoomFactor` | `100` | `FS_GENERAL_ZOOM_FACTOR` | UI zoom percentage (number). |
|
||||||
|
|||||||
+1
-1
@@ -103,7 +103,7 @@
|
|||||||
"electron-store": "^8.2.0",
|
"electron-store": "^8.2.0",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"fast-average-color": "^9.5.0",
|
"fast-average-color": "^9.5.0",
|
||||||
"fast-xml-parser": "^5.3.6",
|
"fast-xml-parser": "^5.3.8",
|
||||||
"format-duration": "^3.0.2",
|
"format-duration": "^3.0.2",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"i18next": "^25.6.2",
|
"i18next": "^25.6.2",
|
||||||
|
|||||||
Generated
+55
-55
@@ -111,8 +111,8 @@ importers:
|
|||||||
specifier: ^9.5.0
|
specifier: ^9.5.0
|
||||||
version: 9.5.0
|
version: 9.5.0
|
||||||
fast-xml-parser:
|
fast-xml-parser:
|
||||||
specifier: ^5.3.6
|
specifier: ^5.3.8
|
||||||
version: 5.3.6
|
version: 5.3.8
|
||||||
format-duration:
|
format-duration:
|
||||||
specifier: ^3.0.2
|
specifier: ^3.0.2
|
||||||
version: 3.0.2
|
version: 3.0.2
|
||||||
@@ -423,8 +423,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': ^7.0.0
|
'@babel/core': ^7.0.0
|
||||||
|
|
||||||
'@babel/helper-define-polyfill-provider@0.6.6':
|
'@babel/helper-define-polyfill-provider@0.6.7':
|
||||||
resolution: {integrity: sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==}
|
resolution: {integrity: sha512-6Fqi8MtQ/PweQ9xvux65emkLQ83uB+qAVtfHkC9UodyHMIZdxNI01HjLCLUtybElp2KY2XNE0nOgyP1E1vXw9w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
|
'@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
|
||||||
|
|
||||||
@@ -1818,67 +1818,56 @@ packages:
|
|||||||
resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==}
|
resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.52.5':
|
'@rollup/rollup-linux-arm-musleabihf@4.52.5':
|
||||||
resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==}
|
resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.52.5':
|
'@rollup/rollup-linux-arm64-gnu@4.52.5':
|
||||||
resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==}
|
resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.52.5':
|
'@rollup/rollup-linux-arm64-musl@4.52.5':
|
||||||
resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==}
|
resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.52.5':
|
'@rollup/rollup-linux-loong64-gnu@4.52.5':
|
||||||
resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==}
|
resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.52.5':
|
'@rollup/rollup-linux-ppc64-gnu@4.52.5':
|
||||||
resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==}
|
resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.52.5':
|
'@rollup/rollup-linux-riscv64-gnu@4.52.5':
|
||||||
resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==}
|
resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.52.5':
|
'@rollup/rollup-linux-riscv64-musl@4.52.5':
|
||||||
resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==}
|
resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.52.5':
|
'@rollup/rollup-linux-s390x-gnu@4.52.5':
|
||||||
resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==}
|
resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.52.5':
|
'@rollup/rollup-linux-x64-gnu@4.52.5':
|
||||||
resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==}
|
resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.52.5':
|
'@rollup/rollup-linux-x64-musl@4.52.5':
|
||||||
resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==}
|
resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-openharmony-arm64@4.52.5':
|
'@rollup/rollup-openharmony-arm64@4.52.5':
|
||||||
resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==}
|
resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==}
|
||||||
@@ -2290,18 +2279,18 @@ packages:
|
|||||||
b4a@1.6.7:
|
b4a@1.6.7:
|
||||||
resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==}
|
resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==}
|
||||||
|
|
||||||
babel-plugin-polyfill-corejs2@0.4.15:
|
babel-plugin-polyfill-corejs2@0.4.16:
|
||||||
resolution: {integrity: sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==}
|
resolution: {integrity: sha512-xaVwwSfebXf0ooE11BJovZYKhFjIvQo7TsyVpETuIeH2JHv0k/T6Y5j22pPTvqYqmpkxdlPAJlyJ0tfOJAoMxw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
|
'@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
|
||||||
|
|
||||||
babel-plugin-polyfill-corejs3@0.14.0:
|
babel-plugin-polyfill-corejs3@0.14.1:
|
||||||
resolution: {integrity: sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==}
|
resolution: {integrity: sha512-ENp89vM9Pw4kv/koBb5N2f9bDZsR0hpf3BdPMOg/pkS3pwO4dzNnQZVXtBbeyAadgm865DmQG2jMMLqmZXvuCw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
|
'@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
|
||||||
|
|
||||||
babel-plugin-polyfill-regenerator@0.6.6:
|
babel-plugin-polyfill-regenerator@0.6.7:
|
||||||
resolution: {integrity: sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==}
|
resolution: {integrity: sha512-OTYbUlSwXhNgr4g6efMZgsO8//jA61P7ZbRX3iTT53VON8l+WQS8IAUEVo4a4cWknrg2W8Cj4gQhRYNCJ8GkAA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
|
'@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
|
||||||
|
|
||||||
@@ -2462,8 +2451,8 @@ packages:
|
|||||||
caniuse-lite@1.0.30001751:
|
caniuse-lite@1.0.30001751:
|
||||||
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
|
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001774:
|
caniuse-lite@1.0.30001777:
|
||||||
resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==}
|
resolution: {integrity: sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
@@ -2880,8 +2869,8 @@ packages:
|
|||||||
electron-to-chromium@1.5.242:
|
electron-to-chromium@1.5.242:
|
||||||
resolution: {integrity: sha512-msZ7SYGFpXkm/iUizlMrm/FPNeYo8uSltQccLVFO3fV4RN2JWGdG7Aatztxtw3uDWp3DkupfkrosLjUnhY+iOw==}
|
resolution: {integrity: sha512-msZ7SYGFpXkm/iUizlMrm/FPNeYo8uSltQccLVFO3fV4RN2JWGdG7Aatztxtw3uDWp3DkupfkrosLjUnhY+iOw==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.302:
|
electron-to-chromium@1.5.307:
|
||||||
resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==}
|
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
|
||||||
|
|
||||||
electron-updater@6.6.2:
|
electron-updater@6.6.2:
|
||||||
resolution: {integrity: sha512-Cr4GDOkbAUqRHP5/oeOmH/L2Bn6+FQPxVLZtPbcmKZC63a1F3uu5EefYOssgZXG3u/zBlubbJ5PJdITdMVggbw==}
|
resolution: {integrity: sha512-Cr4GDOkbAUqRHP5/oeOmH/L2Bn6+FQPxVLZtPbcmKZC63a1F3uu5EefYOssgZXG3u/zBlubbJ5PJdITdMVggbw==}
|
||||||
@@ -3131,8 +3120,8 @@ packages:
|
|||||||
fast-uri@3.0.6:
|
fast-uri@3.0.6:
|
||||||
resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==}
|
resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==}
|
||||||
|
|
||||||
fast-xml-parser@5.3.6:
|
fast-xml-parser@5.3.8:
|
||||||
resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==}
|
resolution: {integrity: sha512-53jIF4N6u/pxvaL1eb/hEZts/cFLWZ92eCfLrNyCI0k38lettCG/Bs40W9pPwoPXyHQlKu2OUbQtiEIZK/J6Vw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
fastest-levenshtein@1.0.16:
|
fastest-levenshtein@1.0.16:
|
||||||
@@ -3242,6 +3231,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==}
|
resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==}
|
||||||
engines: {node: '>=14.14'}
|
engines: {node: '>=14.14'}
|
||||||
|
|
||||||
|
fs-extra@11.3.4:
|
||||||
|
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
|
||||||
|
engines: {node: '>=14.14'}
|
||||||
|
|
||||||
fs-extra@7.0.1:
|
fs-extra@7.0.1:
|
||||||
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
|
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
|
||||||
engines: {node: '>=6 <7 || >=8'}
|
engines: {node: '>=6 <7 || >=8'}
|
||||||
@@ -3538,8 +3531,8 @@ packages:
|
|||||||
immer@10.2.0:
|
immer@10.2.0:
|
||||||
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||||
|
|
||||||
immutable@5.1.4:
|
immutable@5.1.5:
|
||||||
resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==}
|
resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
@@ -4155,8 +4148,8 @@ packages:
|
|||||||
node-releases@2.0.26:
|
node-releases@2.0.26:
|
||||||
resolution: {integrity: sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==}
|
resolution: {integrity: sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==}
|
||||||
|
|
||||||
node-releases@2.0.27:
|
node-releases@2.0.36:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
|
||||||
|
|
||||||
nopt@8.1.0:
|
nopt@8.1.0:
|
||||||
resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
|
resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
|
||||||
@@ -5208,8 +5201,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
strnum@2.1.2:
|
strnum@2.2.0:
|
||||||
resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==}
|
resolution: {integrity: sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==}
|
||||||
|
|
||||||
stylelint-config-css-modules@4.5.1:
|
stylelint-config-css-modules@4.5.1:
|
||||||
resolution: {integrity: sha512-xRMvAOVWa8h3Dw2NmanJHuPqMUInmMoBy14kkJDT2xs2xevxl7WnQOe/nDAMvgf9NkodzKrhKZ97E61yQOKkDA==}
|
resolution: {integrity: sha512-xRMvAOVWa8h3Dw2NmanJHuPqMUInmMoBy14kkJDT2xs2xevxl7WnQOe/nDAMvgf9NkodzKrhKZ97E61yQOKkDA==}
|
||||||
@@ -6037,7 +6030,7 @@ snapshots:
|
|||||||
regexpu-core: 6.4.0
|
regexpu-core: 6.4.0
|
||||||
semver: 6.3.1
|
semver: 6.3.1
|
||||||
|
|
||||||
'@babel/helper-define-polyfill-provider@0.6.6(@babel/core@7.28.5)':
|
'@babel/helper-define-polyfill-provider@0.6.7(@babel/core@7.28.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.28.5
|
||||||
'@babel/helper-compilation-targets': 7.28.6
|
'@babel/helper-compilation-targets': 7.28.6
|
||||||
@@ -6629,9 +6622,9 @@ snapshots:
|
|||||||
'@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.28.5)
|
'@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.28.5)
|
||||||
'@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.28.5)
|
'@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.28.5)
|
||||||
'@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.28.5)
|
'@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.28.5)
|
||||||
babel-plugin-polyfill-corejs2: 0.4.15(@babel/core@7.28.5)
|
babel-plugin-polyfill-corejs2: 0.4.16(@babel/core@7.28.5)
|
||||||
babel-plugin-polyfill-corejs3: 0.14.0(@babel/core@7.28.5)
|
babel-plugin-polyfill-corejs3: 0.14.1(@babel/core@7.28.5)
|
||||||
babel-plugin-polyfill-regenerator: 0.6.6(@babel/core@7.28.5)
|
babel-plugin-polyfill-regenerator: 0.6.7(@babel/core@7.28.5)
|
||||||
core-js-compat: 3.48.0
|
core-js-compat: 3.48.0
|
||||||
semver: 6.3.1
|
semver: 6.3.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -6875,7 +6868,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
cross-dirname: 0.1.0
|
cross-dirname: 0.1.0
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
fs-extra: 11.3.3
|
fs-extra: 11.3.4
|
||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
postject: 1.0.0-alpha.6
|
postject: 1.0.0-alpha.6
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -8067,27 +8060,27 @@ snapshots:
|
|||||||
|
|
||||||
b4a@1.6.7: {}
|
b4a@1.6.7: {}
|
||||||
|
|
||||||
babel-plugin-polyfill-corejs2@0.4.15(@babel/core@7.28.5):
|
babel-plugin-polyfill-corejs2@0.4.16(@babel/core@7.28.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/compat-data': 7.29.0
|
'@babel/compat-data': 7.29.0
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.28.5
|
||||||
'@babel/helper-define-polyfill-provider': 0.6.6(@babel/core@7.28.5)
|
'@babel/helper-define-polyfill-provider': 0.6.7(@babel/core@7.28.5)
|
||||||
semver: 6.3.1
|
semver: 6.3.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
babel-plugin-polyfill-corejs3@0.14.0(@babel/core@7.28.5):
|
babel-plugin-polyfill-corejs3@0.14.1(@babel/core@7.28.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.28.5
|
||||||
'@babel/helper-define-polyfill-provider': 0.6.6(@babel/core@7.28.5)
|
'@babel/helper-define-polyfill-provider': 0.6.7(@babel/core@7.28.5)
|
||||||
core-js-compat: 3.48.0
|
core-js-compat: 3.48.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
babel-plugin-polyfill-regenerator@0.6.6(@babel/core@7.28.5):
|
babel-plugin-polyfill-regenerator@0.6.7(@babel/core@7.28.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.28.5
|
||||||
'@babel/helper-define-polyfill-provider': 0.6.6(@babel/core@7.28.5)
|
'@babel/helper-define-polyfill-provider': 0.6.7(@babel/core@7.28.5)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -8182,9 +8175,9 @@ snapshots:
|
|||||||
browserslist@4.28.1:
|
browserslist@4.28.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
baseline-browser-mapping: 2.10.0
|
baseline-browser-mapping: 2.10.0
|
||||||
caniuse-lite: 1.0.30001774
|
caniuse-lite: 1.0.30001777
|
||||||
electron-to-chromium: 1.5.302
|
electron-to-chromium: 1.5.307
|
||||||
node-releases: 2.0.27
|
node-releases: 2.0.36
|
||||||
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
||||||
|
|
||||||
buffer-builder@0.2.0:
|
buffer-builder@0.2.0:
|
||||||
@@ -8310,7 +8303,7 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001751: {}
|
caniuse-lite@1.0.30001751: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001774: {}
|
caniuse-lite@1.0.30001777: {}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -8780,7 +8773,7 @@ snapshots:
|
|||||||
|
|
||||||
electron-to-chromium@1.5.242: {}
|
electron-to-chromium@1.5.242: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.302: {}
|
electron-to-chromium@1.5.307: {}
|
||||||
|
|
||||||
electron-updater@6.6.2:
|
electron-updater@6.6.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9179,9 +9172,9 @@ snapshots:
|
|||||||
|
|
||||||
fast-uri@3.0.6: {}
|
fast-uri@3.0.6: {}
|
||||||
|
|
||||||
fast-xml-parser@5.3.6:
|
fast-xml-parser@5.3.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
strnum: 2.1.2
|
strnum: 2.2.0
|
||||||
|
|
||||||
fastest-levenshtein@1.0.16: {}
|
fastest-levenshtein@1.0.16: {}
|
||||||
|
|
||||||
@@ -9288,6 +9281,13 @@ snapshots:
|
|||||||
jsonfile: 6.2.0
|
jsonfile: 6.2.0
|
||||||
universalify: 2.0.1
|
universalify: 2.0.1
|
||||||
|
|
||||||
|
fs-extra@11.3.4:
|
||||||
|
dependencies:
|
||||||
|
graceful-fs: 4.2.11
|
||||||
|
jsonfile: 6.2.0
|
||||||
|
universalify: 2.0.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
fs-extra@7.0.1:
|
fs-extra@7.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -9648,7 +9648,7 @@ snapshots:
|
|||||||
|
|
||||||
immer@10.2.0: {}
|
immer@10.2.0: {}
|
||||||
|
|
||||||
immutable@5.1.4:
|
immutable@5.1.5:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
@@ -10207,7 +10207,7 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.26: {}
|
node-releases@2.0.26: {}
|
||||||
|
|
||||||
node-releases@2.0.27: {}
|
node-releases@2.0.36: {}
|
||||||
|
|
||||||
nopt@8.1.0:
|
nopt@8.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -10972,7 +10972,7 @@ snapshots:
|
|||||||
'@bufbuild/protobuf': 2.11.0
|
'@bufbuild/protobuf': 2.11.0
|
||||||
buffer-builder: 0.2.0
|
buffer-builder: 0.2.0
|
||||||
colorjs.io: 0.5.2
|
colorjs.io: 0.5.2
|
||||||
immutable: 5.1.4
|
immutable: 5.1.5
|
||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
supports-color: 8.1.1
|
supports-color: 8.1.1
|
||||||
sync-child-process: 1.0.2
|
sync-child-process: 1.0.2
|
||||||
@@ -11275,7 +11275,7 @@ snapshots:
|
|||||||
|
|
||||||
strip-json-comments@3.1.1: {}
|
strip-json-comments@3.1.1: {}
|
||||||
|
|
||||||
strnum@2.1.2: {}
|
strnum@2.2.0: {}
|
||||||
|
|
||||||
stylelint-config-css-modules@4.5.1(stylelint@16.25.0(typescript@5.8.3)):
|
stylelint-config-css-modules@4.5.1(stylelint@16.25.0(typescript@5.8.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -24,12 +24,14 @@ window.FS_GENERAL_HOME_FEATURE_STYLE = "${FS_GENERAL_HOME_FEATURE_STYLE}";
|
|||||||
window.FS_GENERAL_LANGUAGE = "${FS_GENERAL_LANGUAGE}";
|
window.FS_GENERAL_LANGUAGE = "${FS_GENERAL_LANGUAGE}";
|
||||||
window.FS_GENERAL_LAST_FM = "${FS_GENERAL_LAST_FM}";
|
window.FS_GENERAL_LAST_FM = "${FS_GENERAL_LAST_FM}";
|
||||||
window.FS_GENERAL_LASTFM_API_KEY = "${FS_GENERAL_LASTFM_API_KEY}";
|
window.FS_GENERAL_LASTFM_API_KEY = "${FS_GENERAL_LASTFM_API_KEY}";
|
||||||
|
window.FS_GENERAL_LISTEN_BRAINZ = "${FS_GENERAL_LISTEN_BRAINZ}";
|
||||||
window.FS_GENERAL_MUSIC_BRAINZ = "${FS_GENERAL_MUSIC_BRAINZ}";
|
window.FS_GENERAL_MUSIC_BRAINZ = "${FS_GENERAL_MUSIC_BRAINZ}";
|
||||||
window.FS_GENERAL_NATIVE_ASPECT_RATIO = "${FS_GENERAL_NATIVE_ASPECT_RATIO}";
|
window.FS_GENERAL_NATIVE_ASPECT_RATIO = "${FS_GENERAL_NATIVE_ASPECT_RATIO}";
|
||||||
window.FS_GENERAL_PATH_REPLACE = "${FS_GENERAL_PATH_REPLACE}";
|
window.FS_GENERAL_PATH_REPLACE = "${FS_GENERAL_PATH_REPLACE}";
|
||||||
window.FS_GENERAL_PATH_REPLACE_WITH = "${FS_GENERAL_PATH_REPLACE_WITH}";
|
window.FS_GENERAL_PATH_REPLACE_WITH = "${FS_GENERAL_PATH_REPLACE_WITH}";
|
||||||
window.FS_GENERAL_PLAYERBAR_OPEN_DRAWER = "${FS_GENERAL_PLAYERBAR_OPEN_DRAWER}";
|
window.FS_GENERAL_PLAYERBAR_OPEN_DRAWER = "${FS_GENERAL_PLAYERBAR_OPEN_DRAWER}";
|
||||||
window.FS_GENERAL_PRIMARY_SHADE = "${FS_GENERAL_PRIMARY_SHADE}";
|
window.FS_GENERAL_PRIMARY_SHADE = "${FS_GENERAL_PRIMARY_SHADE}";
|
||||||
|
window.FS_GENERAL_QOBUZ = "${FS_GENERAL_QOBUZ}";
|
||||||
window.FS_GENERAL_RESUME = "${FS_GENERAL_RESUME}";
|
window.FS_GENERAL_RESUME = "${FS_GENERAL_RESUME}";
|
||||||
window.FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR = "${FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR}";
|
window.FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR = "${FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR}";
|
||||||
window.FS_GENERAL_SHOW_RATINGS = "${FS_GENERAL_SHOW_RATINGS}";
|
window.FS_GENERAL_SHOW_RATINGS = "${FS_GENERAL_SHOW_RATINGS}";
|
||||||
@@ -39,6 +41,7 @@ window.FS_GENERAL_SIDEBAR_COLLAPSE_SHARED = "${FS_GENERAL_SIDEBAR_COLLAPSE_SHARE
|
|||||||
window.FS_GENERAL_SIDEBAR_PLAYLIST_LIST = "${FS_GENERAL_SIDEBAR_PLAYLIST_LIST}";
|
window.FS_GENERAL_SIDEBAR_PLAYLIST_LIST = "${FS_GENERAL_SIDEBAR_PLAYLIST_LIST}";
|
||||||
window.FS_GENERAL_SIDEBAR_PLAYLIST_SORTING = "${FS_GENERAL_SIDEBAR_PLAYLIST_SORTING}";
|
window.FS_GENERAL_SIDEBAR_PLAYLIST_SORTING = "${FS_GENERAL_SIDEBAR_PLAYLIST_SORTING}";
|
||||||
window.FS_GENERAL_SIDE_QUEUE_TYPE = "${FS_GENERAL_SIDE_QUEUE_TYPE}";
|
window.FS_GENERAL_SIDE_QUEUE_TYPE = "${FS_GENERAL_SIDE_QUEUE_TYPE}";
|
||||||
|
window.FS_GENERAL_SIDE_QUEUE_LAYOUT = "${FS_GENERAL_SIDE_QUEUE_LAYOUT}";
|
||||||
window.FS_GENERAL_THEME = "${FS_GENERAL_THEME}";
|
window.FS_GENERAL_THEME = "${FS_GENERAL_THEME}";
|
||||||
window.FS_GENERAL_THEME_DARK = "${FS_GENERAL_THEME_DARK}";
|
window.FS_GENERAL_THEME_DARK = "${FS_GENERAL_THEME_DARK}";
|
||||||
window.FS_GENERAL_THEME_LIGHT = "${FS_GENERAL_THEME_LIGHT}";
|
window.FS_GENERAL_THEME_LIGHT = "${FS_GENERAL_THEME_LIGHT}";
|
||||||
|
|||||||
@@ -301,6 +301,7 @@
|
|||||||
"forward": "endavant",
|
"forward": "endavant",
|
||||||
"manage": "gestiona",
|
"manage": "gestiona",
|
||||||
"mbid": "ID de MusicBrainz",
|
"mbid": "ID de MusicBrainz",
|
||||||
|
"grouping": "agrupament",
|
||||||
"noResultsFromQuery": "la petició no ha produït resultats",
|
"noResultsFromQuery": "la petició no ha produït resultats",
|
||||||
"path": "ruta",
|
"path": "ruta",
|
||||||
"playerMustBePaused": "cal pausar el reproductor",
|
"playerMustBePaused": "cal pausar el reproductor",
|
||||||
@@ -423,8 +424,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) s'ha actualitzat amb èxit",
|
"success": "$t(entity.playlist, {\"count\": 1}) s'ha actualitzat amb èxit",
|
||||||
"title": "editar la $t(entity.playlist, {\"count\": 1})",
|
"title": "editar la $t(entity.playlist, {\"count\": 1})",
|
||||||
"publicJellyfinNote": "Per algun motiu, Jellyfin no exposa si una llista de reproducció és pública o no. Si voleu que es mantingui pública, seleccioneu la següent entrada",
|
"publicJellyfinNote": "Per algun motiu, Jellyfin no exposa si una llista de reproducció és pública o no. Si voleu que es mantingui pública, seleccioneu la següent entrada"
|
||||||
"editNote": "es recomana no editar manualment les llistes de reproducció grans. segur que accepteu el risc de perdre dades si sobreescriviu la llista de reproducció existent?"
|
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
||||||
|
|||||||
@@ -411,7 +411,21 @@
|
|||||||
"autosave": "automaticky ukládat frontu přehrávání",
|
"autosave": "automaticky ukládat frontu přehrávání",
|
||||||
"autosave_description": "zapnout automatické ukládání fronty přehrávání na server. toto je možné pouze při použití Navidrome/Subsonic a není možné mít kombinovanou frontu přehrávání.",
|
"autosave_description": "zapnout automatické ukládání fronty přehrávání na server. toto je možné pouze při použití Navidrome/Subsonic a není možné mít kombinovanou frontu přehrávání.",
|
||||||
"autosaveCount": "četnost automatického ukládání fronty přehrávání",
|
"autosaveCount": "četnost automatického ukládání fronty přehrávání",
|
||||||
"autosaveCount_description": "kolik změn skladeb se může provést před uložením fronty. 1 (minimum) znamená při každé změně skladby"
|
"autosaveCount_description": "kolik změn skladeb se může provést před uložením fronty. 1 (minimum) znamená při každé změně skladby",
|
||||||
|
"spotify_description": "na stránkách umělců a alb zobrazit odkazy na Spotify",
|
||||||
|
"spotify": "zobrazit odkazy na Spotify",
|
||||||
|
"nativeSpotify_description": "otevřít v aplikaci Spotify namísto vašeho prohlížeče",
|
||||||
|
"nativeSpotify": "použít aplikaci Spotify",
|
||||||
|
"listenbrainz_description": "na stránkách umělců a alb zobrazit odkazy na ListenBrainz",
|
||||||
|
"listenbrainz": "zobrazit odkazy na ListenBrainz",
|
||||||
|
"qobuz_description": "na stránkách umělců a alb zobrazit odkazy na Qobuz",
|
||||||
|
"qobuz": "zobrazit odkazy na Qobuz",
|
||||||
|
"sidePlayQueueLayout": "rozložení postranní fronty přehrávání",
|
||||||
|
"sidePlayQueueLayout_description": "nastaví rozložení postranní lišty přehrávání",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "na šířku",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "na výšku",
|
||||||
|
"waveformLoadingDelay": "zpoždění načítání vlnové křivky",
|
||||||
|
"waveformLoadingDelay_description": "zpoždění v sekundách před načtením vlnové křivky. zvyšte, pokud jste během používání webového přehrávače zaznamenali záseky."
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "upravit $t(entity.playlist, {\"count\": 1})",
|
"editPlaylist": "upravit $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -433,7 +447,10 @@
|
|||||||
"removeFromFavorites": "odebrat z $t(entity.favorite, {\"count\": 2})",
|
"removeFromFavorites": "odebrat z $t(entity.favorite, {\"count\": 2})",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Otevřít v Last.fm",
|
"lastfm": "Otevřít v Last.fm",
|
||||||
"musicbrainz": "Otevřít v MusicBrainz"
|
"musicbrainz": "Otevřít v MusicBrainz",
|
||||||
|
"spotify": "Otevřít na Spotify",
|
||||||
|
"listenbrainz": "Otevřít ve službě ListenBrainz",
|
||||||
|
"qobuz": "Otevřít ve službě Qobuz"
|
||||||
},
|
},
|
||||||
"moveToNext": "přesunout na další",
|
"moveToNext": "přesunout na další",
|
||||||
"downloadStarted": "spuštěno stahování {{count}} položek",
|
"downloadStarted": "spuštěno stahování {{count}} položek",
|
||||||
@@ -579,7 +596,9 @@
|
|||||||
"filter_single": "jeden",
|
"filter_single": "jeden",
|
||||||
"filter_multiple": "několik",
|
"filter_multiple": "několik",
|
||||||
"rename": "přejmenovat",
|
"rename": "přejmenovat",
|
||||||
"newVersionAvailable": "je dostupná nová verze"
|
"newVersionAvailable": "je dostupná nová verze",
|
||||||
|
"numberOfResults": "{{numberOfResults}} výsledků",
|
||||||
|
"grouping": "seskupování"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
@@ -1045,8 +1064,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "upravit $t(entity.playlist, {\"count\": 1})",
|
"title": "upravit $t(entity.playlist, {\"count\": 1})",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) úspěšně aktualizován",
|
"success": "$t(entity.playlist, {\"count\": 1}) úspěšně aktualizován",
|
||||||
"publicJellyfinNote": "Jellyfin z nějakého důvodu neukazuje, zda je seznam skladeb veřejný, nebo ne. Pokud si přejete, aby zůstal veřejný, zvolte prosím následující vstup",
|
"publicJellyfinNote": "Jellyfin z nějakého důvodu neukazuje, zda je seznam skladeb veřejný, nebo ne. Pokud si přejete, aby zůstal veřejný, zvolte prosím následující vstup"
|
||||||
"editNote": "ruční úpravy velkých seznamů skladeb nejsou doporučeny. opravdu přijímáte riziko ztráty dat, které může vzniknout přepsáním existujícího seznamu skladeb?"
|
|
||||||
},
|
},
|
||||||
"shareItem": {
|
"shareItem": {
|
||||||
"allowDownloading": "umožnit stahování",
|
"allowDownloading": "umožnit stahování",
|
||||||
|
|||||||
@@ -359,7 +359,6 @@
|
|||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"publicJellyfinNote": "Jellyfin viser af en eller anden grund ikke, om en playliste er offentlig eller ej. Hvis du ønsker, at den forbliver offentlig, skal du have følgende felt markeret",
|
"publicJellyfinNote": "Jellyfin viser af en eller anden grund ikke, om en playliste er offentlig eller ej. Hvis du ønsker, at den forbliver offentlig, skal du have følgende felt markeret",
|
||||||
"editNote": "manuelle ændringer anbefales ikke for store playlister. er du sikker på, at du accepterer risikoen for datatab ved at overskrive den eksisterende playliste?",
|
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) opdateret",
|
"success": "$t(entity.playlist, {\"count\": 1}) opdateret",
|
||||||
"title": "rediger $t(entity.playlist, {\"count\": 1})"
|
"title": "rediger $t(entity.playlist, {\"count\": 1})"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
"removeFromFavorites": "Aus $t(entity.favorite, {\"count\": 2}) entfernen",
|
"removeFromFavorites": "Aus $t(entity.favorite, {\"count\": 2}) entfernen",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Auf Last.fm öffnen",
|
"lastfm": "Auf Last.fm öffnen",
|
||||||
"musicbrainz": "Auf MusicBrainz öffnen"
|
"musicbrainz": "Auf MusicBrainz öffnen",
|
||||||
|
"listenbrainz": "In ListenBrainz öffnen",
|
||||||
|
"qobuz": "In Qobuz öffnen",
|
||||||
|
"spotify": "In Spotify öffnen"
|
||||||
},
|
},
|
||||||
"moveToNext": "Als nächstes",
|
"moveToNext": "Als nächstes",
|
||||||
"downloadStarted": "Download von {{count}} Elementen gestartet",
|
"downloadStarted": "Download von {{count}} Elementen gestartet",
|
||||||
@@ -124,6 +127,7 @@
|
|||||||
"preview": "Vorschau",
|
"preview": "Vorschau",
|
||||||
"reload": "Neu Laden",
|
"reload": "Neu Laden",
|
||||||
"mbid": "MusicBrainz ID",
|
"mbid": "MusicBrainz ID",
|
||||||
|
"grouping": "gruppierung",
|
||||||
"close": "schließen",
|
"close": "schließen",
|
||||||
"share": "Teilen",
|
"share": "Teilen",
|
||||||
"translation": "Übersetzung",
|
"translation": "Übersetzung",
|
||||||
@@ -162,7 +166,8 @@
|
|||||||
"filter_single": "einzeln",
|
"filter_single": "einzeln",
|
||||||
"filter_multiple": "mehrfach",
|
"filter_multiple": "mehrfach",
|
||||||
"retry": "Wiederholen",
|
"retry": "Wiederholen",
|
||||||
"newVersionAvailable": "Eine neue Version ist verfügbar"
|
"newVersionAvailable": "Eine neue Version ist verfügbar",
|
||||||
|
"numberOfResults": "{{numberOfResults}} Ergebnisse"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden",
|
"remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden",
|
||||||
@@ -301,8 +306,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "Bearbeite $t(entity.playlist, {\"count\": 1})",
|
"title": "Bearbeite $t(entity.playlist, {\"count\": 1})",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) erfolgreich aktualisiert",
|
"success": "$t(entity.playlist, {\"count\": 1}) erfolgreich aktualisiert",
|
||||||
"publicJellyfinNote": "Jellyfin legt aus irgendwelchen Gründen nicht offen ob eine Wiedergabeliste öffentlich ist oder nicht. Wenn du möchtest, dass sie öffentlich bleibt, wähle bitte diese Option aus",
|
"publicJellyfinNote": "Jellyfin legt aus irgendwelchen Gründen nicht offen ob eine Wiedergabeliste öffentlich ist oder nicht. Wenn du möchtest, dass sie öffentlich bleibt, wähle bitte diese Option aus"
|
||||||
"editNote": "Manuelles Bearbeiten wird für große Wiedergabelisten nicht empfohlen. Bist Du sicher, dass Du die aktuelle Wiedergabeliste unter dem Risiko von Datenverlust überschrieben möchtest?"
|
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"title": "Songtext Suche",
|
"title": "Songtext Suche",
|
||||||
@@ -1108,7 +1112,16 @@
|
|||||||
"useThemeAccentColor_description": "Verwendet die Primärfarbe des gewählten Themas anstatt einer ausgewählten Akzentfarbe",
|
"useThemeAccentColor_description": "Verwendet die Primärfarbe des gewählten Themas anstatt einer ausgewählten Akzentfarbe",
|
||||||
"useThemePrimaryShade": "Primärschatten des Themas nutzen",
|
"useThemePrimaryShade": "Primärschatten des Themas nutzen",
|
||||||
"useThemePrimaryShade_description": "Verwendet den Primärschatten des ausgewählten Themas als primäre Farbvarianten",
|
"useThemePrimaryShade_description": "Verwendet den Primärschatten des ausgewählten Themas als primäre Farbvarianten",
|
||||||
"primaryShade": "Primärschatten"
|
"primaryShade": "Primärschatten",
|
||||||
|
"listenbrainz": "ListenBrainz Links anzeigen",
|
||||||
|
"listenbrainz_description": "Zeige Links zu ListenBrainz auf den Interpreten/Alben Seiten",
|
||||||
|
"mpvExtraParameters": "Zusätzliche mpv Parameter",
|
||||||
|
"qobuz": "Qobuz Links anzeigen",
|
||||||
|
"spotify": "Spotify Links anzeigen",
|
||||||
|
"nativeSpotify": "Spotify App benutzen",
|
||||||
|
"qobuz_description": "Zeige Links zu Qobuz auf den Interpreten/Alben Seiten",
|
||||||
|
"spotify_description": "Zeige Links zu Spotify auf den Interpreten/Alben Seiten",
|
||||||
|
"artistReleaseTypeConfiguration": "Interpreten Release Typ Einstellung"
|
||||||
},
|
},
|
||||||
"dragDropZone": {
|
"dragDropZone": {
|
||||||
"error_oneFileOnly": "Bitte wähle nur 1 Datei",
|
"error_oneFileOnly": "Bitte wähle nur 1 Datei",
|
||||||
@@ -1269,6 +1282,8 @@
|
|||||||
"miscellaneousSettings": "Verschiedenes Einstellungen",
|
"miscellaneousSettings": "Verschiedenes Einstellungen",
|
||||||
"ansiBands": "ANSI Bänder",
|
"ansiBands": "ANSI Bänder",
|
||||||
"lowResolution": "Niedrige Auflösung",
|
"lowResolution": "Niedrige Auflösung",
|
||||||
"showFPS": "FPS anzeigen"
|
"showFPS": "FPS anzeigen",
|
||||||
|
"fadePeaks": "Spitzen abblenden",
|
||||||
|
"showPeaks": "Spitzen anzeigen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,9 @@
|
|||||||
"openApplicationDirectory": "open application directory",
|
"openApplicationDirectory": "open application directory",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Open in Last.fm",
|
"lastfm": "Open in Last.fm",
|
||||||
|
"listenbrainz": "Open in ListenBrainz",
|
||||||
"musicbrainz": "Open in MusicBrainz",
|
"musicbrainz": "Open in MusicBrainz",
|
||||||
|
"qobuz": "Open in Qobuz",
|
||||||
"spotify": "Open in Spotify"
|
"spotify": "Open in Spotify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -107,11 +109,13 @@
|
|||||||
"minimize": "minimize",
|
"minimize": "minimize",
|
||||||
"modified": "modified",
|
"modified": "modified",
|
||||||
"mbid": "MusicBrainz ID",
|
"mbid": "MusicBrainz ID",
|
||||||
|
"grouping": "grouping",
|
||||||
"mood": "mood",
|
"mood": "mood",
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"no": "no",
|
"no": "no",
|
||||||
"none": "none",
|
"none": "none",
|
||||||
"noResultsFromQuery": "the query returned no results",
|
"noResultsFromQuery": "the query returned no results",
|
||||||
|
"numberOfResults": "{{numberOfResults}} results",
|
||||||
"noFilters": "no filters configured",
|
"noFilters": "no filters configured",
|
||||||
"note": "note",
|
"note": "note",
|
||||||
"ok": "ok",
|
"ok": "ok",
|
||||||
@@ -367,7 +371,6 @@
|
|||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"publicJellyfinNote": "Jellyfin for some reason does not expose whether a playlist is public or not. If you wish for this to remain public, please have the following input selected",
|
"publicJellyfinNote": "Jellyfin for some reason does not expose whether a playlist is public or not. If you wish for this to remain public, please have the following input selected",
|
||||||
"editNote": "manual edits are not recommended for large playlists. are you sure you accept the risk of data loss incurred by overwriting the existing playlist?",
|
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) updated successfully",
|
"success": "$t(entity.playlist, {\"count\": 1}) updated successfully",
|
||||||
"title": "edit $t(entity.playlist, {\"count\": 1})"
|
"title": "edit $t(entity.playlist, {\"count\": 1})"
|
||||||
},
|
},
|
||||||
@@ -898,6 +901,8 @@
|
|||||||
"language_description": "sets the language for the application ($t(common.restartRequired))",
|
"language_description": "sets the language for the application ($t(common.restartRequired))",
|
||||||
"lastfm_description": "show links to Last.fm on artist/album pages",
|
"lastfm_description": "show links to Last.fm on artist/album pages",
|
||||||
"lastfm": "show last.fm links",
|
"lastfm": "show last.fm links",
|
||||||
|
"listenbrainz_description": "show links to ListenBrainz on artist/album pages",
|
||||||
|
"listenbrainz": "show ListenBrainz links",
|
||||||
"lastfmApiKey_description": "the API key for {{lastfm}}. required for cover art",
|
"lastfmApiKey_description": "the API key for {{lastfm}}. required for cover art",
|
||||||
"lastfmApiKey": "{{lastfm}} API key",
|
"lastfmApiKey": "{{lastfm}} API key",
|
||||||
"lyricFetch_description": "fetch lyrics from various internet sources",
|
"lyricFetch_description": "fetch lyrics from various internet sources",
|
||||||
@@ -925,6 +930,8 @@
|
|||||||
"mpvExtraParameters_help": "one per line",
|
"mpvExtraParameters_help": "one per line",
|
||||||
"musicbrainz_description": "show links to MusicBrainz on artist/album pages, where MusicBrainz ID exists",
|
"musicbrainz_description": "show links to MusicBrainz on artist/album pages, where MusicBrainz ID exists",
|
||||||
"musicbrainz": "show MusicBrainz links",
|
"musicbrainz": "show MusicBrainz links",
|
||||||
|
"qobuz_description": "show links to Qobuz on artist/album pages",
|
||||||
|
"qobuz": "show Qobuz links",
|
||||||
"spotify_description": "show links to Spotify on artist/album pages",
|
"spotify_description": "show links to Spotify on artist/album pages",
|
||||||
"spotify": "show Spotify links",
|
"spotify": "show Spotify links",
|
||||||
"nativeSpotify_description": "open in the Spotify app instead of your browser",
|
"nativeSpotify_description": "open in the Spotify app instead of your browser",
|
||||||
@@ -1036,6 +1043,10 @@
|
|||||||
"sidePlayQueueStyle_description": "sets the style of the side play queue",
|
"sidePlayQueueStyle_description": "sets the style of the side play queue",
|
||||||
"sidePlayQueueStyle_optionAttached": "attached",
|
"sidePlayQueueStyle_optionAttached": "attached",
|
||||||
"sidePlayQueueStyle_optionDetached": "detached",
|
"sidePlayQueueStyle_optionDetached": "detached",
|
||||||
|
"sidePlayQueueLayout": "side play queue layout",
|
||||||
|
"sidePlayQueueLayout_description": "sets the layout of the attached side play queue",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "horizontal",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "vertical",
|
||||||
"mediaSession_description": "enables Media Session integration, displaying media controls and metadata in the system volume overlay and lock screen",
|
"mediaSession_description": "enables Media Session integration, displaying media controls and metadata in the system volume overlay and lock screen",
|
||||||
"mediaSession": "enable media session",
|
"mediaSession": "enable media session",
|
||||||
"sidePlayQueueStyle": "side play queue style",
|
"sidePlayQueueStyle": "side play queue style",
|
||||||
@@ -1071,6 +1082,8 @@
|
|||||||
"volumeWheelStep": "volume wheel step",
|
"volumeWheelStep": "volume wheel step",
|
||||||
"volumeWidth_description": "the width of the volume slider",
|
"volumeWidth_description": "the width of the volume slider",
|
||||||
"volumeWidth": "volume slider width",
|
"volumeWidth": "volume slider width",
|
||||||
|
"waveformLoadingDelay": "waveform loading delay",
|
||||||
|
"waveformLoadingDelay_description": "delay in seconds before loading waveform. increase this value if you are experiencing stutters when using the web player.",
|
||||||
"webAudio_description": "use web audio. this enables advanced features like replaygain. disable if you experience otherwise",
|
"webAudio_description": "use web audio. this enables advanced features like replaygain. disable if you experience otherwise",
|
||||||
"webAudio": "use web audio",
|
"webAudio": "use web audio",
|
||||||
"windowBarStyle_description": "select the style of the window bar",
|
"windowBarStyle_description": "select the style of the window bar",
|
||||||
|
|||||||
+29
-11
@@ -216,7 +216,7 @@
|
|||||||
"passwordStore": "contraseñas/almacenamiento secreto",
|
"passwordStore": "contraseñas/almacenamiento secreto",
|
||||||
"homeConfiguration": "Configuración de la página de inicio",
|
"homeConfiguration": "Configuración de la página de inicio",
|
||||||
"mpvExtraParameters_help": "Uno por línea",
|
"mpvExtraParameters_help": "Uno por línea",
|
||||||
"externalLinks_description": "Permite mostrar enlaces externos (Last.fm, MusicBrainz) en las páginas del artista/álbum",
|
"externalLinks_description": "Permite mostrar enlaces externos (Last.fm, MusicBrainz) en las páginas de artistas/álbumes",
|
||||||
"homeConfiguration_description": "Configura qué elementos son mostrados y en qué orden en la página de inicio",
|
"homeConfiguration_description": "Configura qué elementos son mostrados y en qué orden en la página de inicio",
|
||||||
"clearCacheSuccess": "Caché limpiada correctamente",
|
"clearCacheSuccess": "Caché limpiada correctamente",
|
||||||
"externalLinks": "Mostrar enlaces externos",
|
"externalLinks": "Mostrar enlaces externos",
|
||||||
@@ -243,13 +243,13 @@
|
|||||||
"transcodeFormat": "formato a transcodificar",
|
"transcodeFormat": "formato a transcodificar",
|
||||||
"transcodeFormat_description": "selecciona el formato a transcodificar. dejar vacío para que el servidor decida",
|
"transcodeFormat_description": "selecciona el formato a transcodificar. dejar vacío para que el servidor decida",
|
||||||
"albumBackground": "imagen de fondo del álbum",
|
"albumBackground": "imagen de fondo del álbum",
|
||||||
"albumBackground_description": "Añade una imagen de fondo a las páginas del álbum que contienen la carátula del álbum",
|
"albumBackground_description": "Añade una imagen de fondo a las páginas de álbumes que contienen la carátula del álbum",
|
||||||
"albumBackgroundBlur": "Tamaño de desenfoque de la imagen de fondo del álbum",
|
"albumBackgroundBlur": "Tamaño de desenfoque de la imagen de fondo del álbum",
|
||||||
"albumBackgroundBlur_description": "Ajusta la cantidad de desenfoque aplicado a la imagen de fondo del álbum",
|
"albumBackgroundBlur_description": "Ajusta la cantidad de desenfoque aplicado a la imagen de fondo del álbum",
|
||||||
"playerbarOpenDrawer": "Cambiar la barra del reproductor a pantalla completa",
|
"playerbarOpenDrawer": "Cambiar la barra del reproductor a pantalla completa",
|
||||||
"playerbarOpenDrawer_description": "Permite hacer clic en la barra del reproductor para abrir el reproductor a pantalla completa",
|
"playerbarOpenDrawer_description": "Permite hacer clic en la barra del reproductor para abrir el reproductor a pantalla completa",
|
||||||
"artistConfiguration": "Configuración de la página del artista del álbum",
|
"artistConfiguration": "Configuración de la página de artistas del álbum",
|
||||||
"artistConfiguration_description": "Configura qué elementos se muestran y en qué orden en la página del artista del álbum",
|
"artistConfiguration_description": "Configura qué elementos se muestran y en qué orden en la página de artistas del álbum",
|
||||||
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
||||||
"trayEnabled": "Mostrar en el área de notificación",
|
"trayEnabled": "Mostrar en el área de notificación",
|
||||||
"trayEnabled_description": "muestra/oculta el icono/menú del área de notificación. si está deshabilitado, también deshabilita minimizar/salir a la bandeja",
|
"trayEnabled_description": "muestra/oculta el icono/menú del área de notificación. si está deshabilitado, también deshabilita minimizar/salir a la bandeja",
|
||||||
@@ -293,7 +293,7 @@
|
|||||||
"releaseChannel_optionBeta": "Beta",
|
"releaseChannel_optionBeta": "Beta",
|
||||||
"releaseChannel": "Canal de lanzamiento",
|
"releaseChannel": "Canal de lanzamiento",
|
||||||
"releaseChannel_description": "Elige entre lanzamientos estables, beta, o alpha (nightly) para las actualizaciones automáticas",
|
"releaseChannel_description": "Elige entre lanzamientos estables, beta, o alpha (nightly) para las actualizaciones automáticas",
|
||||||
"artistBackground_description": "Añade una imagen de fondo para las páginas de artista que contienen el arte del artista",
|
"artistBackground_description": "Añade una imagen de fondo para las páginas de artistas que contienen el arte de los artistas",
|
||||||
"mediaSession": "Activar sesión de medios",
|
"mediaSession": "Activar sesión de medios",
|
||||||
"mediaSession_description": "Activa la integración de la sesión de medios, mostrando los controles de medios y los metadatos en la superposición del volumen del sistema y en la pantalla de bloqueo",
|
"mediaSession_description": "Activa la integración de la sesión de medios, mostrando los controles de medios y los metadatos en la superposición del volumen del sistema y en la pantalla de bloqueo",
|
||||||
"exportImportSettings_control_description": "Exporta e importa la configuración a través de JSON",
|
"exportImportSettings_control_description": "Exporta e importa la configuración a través de JSON",
|
||||||
@@ -370,7 +370,7 @@
|
|||||||
"combinedLyricsAndVisualizer_description": "Combina letras y visualizador en el mismo panel",
|
"combinedLyricsAndVisualizer_description": "Combina letras y visualizador en el mismo panel",
|
||||||
"combinedLyricsAndVisualizer": "Combinar letras y visualizador en la barra lateral del reproductor",
|
"combinedLyricsAndVisualizer": "Combinar letras y visualizador en la barra lateral del reproductor",
|
||||||
"artistReleaseTypeConfiguration": "Configuración de tipo de lanzamiento de artista",
|
"artistReleaseTypeConfiguration": "Configuración de tipo de lanzamiento de artista",
|
||||||
"artistReleaseTypeConfiguration_description": "Configura qué tipos de lanzamiento son mostrados, y en qué orden, en la página del artista del álbum",
|
"artistReleaseTypeConfiguration_description": "Configura qué tipos de lanzamiento son mostrados, y en qué orden, en la página de artistas del álbum",
|
||||||
"mpvExtraParameters": "Parámetros adicionales de MPV",
|
"mpvExtraParameters": "Parámetros adicionales de MPV",
|
||||||
"mpvExtraParameters_description": "Argumentos adicionales a pasar a MPV",
|
"mpvExtraParameters_description": "Argumentos adicionales a pasar a MPV",
|
||||||
"hotkey_listPlayDefault": "Reproducir lista",
|
"hotkey_listPlayDefault": "Reproducir lista",
|
||||||
@@ -411,7 +411,21 @@
|
|||||||
"autosave": "Guardar automáticamente la cola de reproducción",
|
"autosave": "Guardar automáticamente la cola de reproducción",
|
||||||
"autosaveCount": "Frecuencia de guardado automática de la cola de reproducción",
|
"autosaveCount": "Frecuencia de guardado automática de la cola de reproducción",
|
||||||
"autosave_description": "Permite guardar automáticamente la cola de reproducción en tu servidor. Esto solo es posible cuando se usa Navidrome/Subsonic, y no puedes tener una cola de reproducción mezclada.",
|
"autosave_description": "Permite guardar automáticamente la cola de reproducción en tu servidor. Esto solo es posible cuando se usa Navidrome/Subsonic, y no puedes tener una cola de reproducción mezclada.",
|
||||||
"autosaveCount_description": "Cuántas pistas cambian antes de que la cola sea guardada. 1 (mínimo) quiere decir que todas las canciones cambian"
|
"autosaveCount_description": "Cuántas pistas cambian antes de que la cola sea guardada. 1 (mínimo) quiere decir que todas las canciones cambian",
|
||||||
|
"spotify_description": "Muestra enlaces a Spotify en las páginas de artistas/álbumes",
|
||||||
|
"spotify": "Mostrar enlaces de Spotify",
|
||||||
|
"nativeSpotify_description": "Abre en la aplicación de Spotify en lugar de tu navegador",
|
||||||
|
"nativeSpotify": "Usar la aplicación de Spotify",
|
||||||
|
"listenbrainz": "Mostrar enlaces a ListenBrainz",
|
||||||
|
"listenbrainz_description": "Muestra enlaces a ListenBrainz en las páginas de artistas/álbumes",
|
||||||
|
"qobuz_description": "Muestra enlaces a Qobuz en las páginas de artistas/álbumes",
|
||||||
|
"qobuz": "Mostrar enlaces a Qobuz",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "Vertical",
|
||||||
|
"sidePlayQueueLayout": "Diseño de la cola de reproducción lateral",
|
||||||
|
"sidePlayQueueLayout_description": "Establece el diseño de la cola de reproducción lateral adjunta",
|
||||||
|
"waveformLoadingDelay": "Retraso de carga de la forma de onda",
|
||||||
|
"waveformLoadingDelay_description": "Retraso en segundos antes de cargar la forma de onda. Incrementa este valor si estás experimentando tartamudeos al usar el reproductor web."
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "editar $t(entity.playlist, {\"count\": 1})",
|
"editPlaylist": "editar $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -433,7 +447,10 @@
|
|||||||
"removeFromFavorites": "eliminar de $t(entity.favorite, {\"count\": 2})",
|
"removeFromFavorites": "eliminar de $t(entity.favorite, {\"count\": 2})",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Abrir en Last.fm",
|
"lastfm": "Abrir en Last.fm",
|
||||||
"musicbrainz": "Abrir en MusicBrainz"
|
"musicbrainz": "Abrir en MusicBrainz",
|
||||||
|
"spotify": "Abrir en Spotify",
|
||||||
|
"listenbrainz": "Abrir en ListenBrainz",
|
||||||
|
"qobuz": "Abrir en Qobuz"
|
||||||
},
|
},
|
||||||
"moveToNext": "pasar al siguiente",
|
"moveToNext": "pasar al siguiente",
|
||||||
"downloadStarted": "Iniciada descarga de {{count}} elementos",
|
"downloadStarted": "Iniciada descarga de {{count}} elementos",
|
||||||
@@ -579,7 +596,9 @@
|
|||||||
"filter_single": "simple",
|
"filter_single": "simple",
|
||||||
"filter_multiple": "multi",
|
"filter_multiple": "multi",
|
||||||
"rename": "Renombrar",
|
"rename": "Renombrar",
|
||||||
"newVersionAvailable": "Una nueva versión está disponible"
|
"newVersionAvailable": "Una nueva versión está disponible",
|
||||||
|
"numberOfResults": "{{numberOfResults}} resultados",
|
||||||
|
"grouping": "Agrupar"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
|
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
|
||||||
@@ -927,8 +946,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "editar $t(entity.playlist, {\"count\": 1})",
|
"title": "editar $t(entity.playlist, {\"count\": 1})",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) actualizada correctamente",
|
"success": "$t(entity.playlist, {\"count\": 1}) actualizada correctamente",
|
||||||
"publicJellyfinNote": "Jellyfin por alguna razón no expone si una lista de reproducción es pública o no. Si deseas que ésta siga siendo pública, por favor ten seleccionada la siguiente entrada",
|
"publicJellyfinNote": "Jellyfin por alguna razón no expone si una lista de reproducción es pública o no. Si deseas que ésta siga siendo pública, por favor ten seleccionada la siguiente entrada"
|
||||||
"editNote": "No se recomiendan las ediciones manuales para grandes listas de reproducción. ¿Seguro que aceptas el riesgo de pérdida de información incurrido por sobrescribir la lista de reproducción existente?"
|
|
||||||
},
|
},
|
||||||
"queryEditor": {
|
"queryEditor": {
|
||||||
"input_optionMatchAll": "coincidir todos",
|
"input_optionMatchAll": "coincidir todos",
|
||||||
|
|||||||
+27
-12
@@ -15,7 +15,10 @@
|
|||||||
"viewPlaylists": "ikusi $t(entity.playlist, {\"count\": 2})",
|
"viewPlaylists": "ikusi $t(entity.playlist, {\"count\": 2})",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Ireki Last.fm-n",
|
"lastfm": "Ireki Last.fm-n",
|
||||||
"musicbrainz": "Ireki MusicBrainz-en"
|
"musicbrainz": "Ireki MusicBrainz-en",
|
||||||
|
"listenbrainz": "Ireki ListenBrainz-en",
|
||||||
|
"qobuz": "Ireki Qobuz-en",
|
||||||
|
"spotify": "Ireki Spotify-n"
|
||||||
},
|
},
|
||||||
"clearQueue": "garbitu ilara",
|
"clearQueue": "garbitu ilara",
|
||||||
"createPlaylist": "sortu $t(entity.playlist, {\"count\": 1})",
|
"createPlaylist": "sortu $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -33,7 +36,8 @@
|
|||||||
"shuffleAll": "nahastu dena",
|
"shuffleAll": "nahastu dena",
|
||||||
"shuffleSelected": "nahastu aukeratutak",
|
"shuffleSelected": "nahastu aukeratutak",
|
||||||
"moveItems": "elementuak mugitu",
|
"moveItems": "elementuak mugitu",
|
||||||
"openApplicationDirectory": "ireki aplikazioaren direktorioa"
|
"openApplicationDirectory": "ireki aplikazioaren direktorioa",
|
||||||
|
"goToCurrent": "joan uneko elementura"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"add": "gehitu",
|
"add": "gehitu",
|
||||||
@@ -67,8 +71,8 @@
|
|||||||
"filter_other": "iragazkiak",
|
"filter_other": "iragazkiak",
|
||||||
"filters": "iragazkiak",
|
"filters": "iragazkiak",
|
||||||
"forceRestartRequired": "berreabiarazi aldaketak aplikatzeko... itxi notifikazioa berreabiarazteko",
|
"forceRestartRequired": "berreabiarazi aldaketak aplikatzeko... itxi notifikazioa berreabiarazteko",
|
||||||
"setting_one": "ezarpenak",
|
"setting_one": "ezarpena",
|
||||||
"setting_other": "",
|
"setting_other": "ezarpenak",
|
||||||
"share": "partekatu",
|
"share": "partekatu",
|
||||||
"action_one": "ekintza",
|
"action_one": "ekintza",
|
||||||
"action_other": "ekintzak",
|
"action_other": "ekintzak",
|
||||||
@@ -150,7 +154,10 @@
|
|||||||
"recordLabel": "diskoetxea",
|
"recordLabel": "diskoetxea",
|
||||||
"example": "adibidea",
|
"example": "adibidea",
|
||||||
"tableColumns": "taulako zutabeak",
|
"tableColumns": "taulako zutabeak",
|
||||||
"doNotShowAgain": "ez erakutsi hau berriro"
|
"doNotShowAgain": "ez erakutsi hau berriro",
|
||||||
|
"numberOfResults": "{{numberOfResults}} emaitza",
|
||||||
|
"rename": "berrizendatu",
|
||||||
|
"newVersionAvailable": "bertsio berri bat eskuragarri dago"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"repeat": "errepikatu",
|
"repeat": "errepikatu",
|
||||||
@@ -351,7 +358,11 @@
|
|||||||
"noNetwork": "zerbitzaria ez dago erabilgarri",
|
"noNetwork": "zerbitzaria ez dago erabilgarri",
|
||||||
"noNetworkDescription": "ezin izan da zerbitzari honetara konektatu",
|
"noNetworkDescription": "ezin izan da zerbitzari honetara konektatu",
|
||||||
"saveQueueFailed": "huts egin du ilara gordetzean",
|
"saveQueueFailed": "huts egin du ilara gordetzean",
|
||||||
"multipleServerSaveQueueError": "erreprodukzio-ilarak zerbitzarikoak ez diren abesti bat edo gehiago ditu. hau ez da onartzen"
|
"multipleServerSaveQueueError": "erreprodukzio-ilarak zerbitzarikoak ez diren abesti bat edo gehiago ditu. hau ez da onartzen",
|
||||||
|
"invalidJson": "JSON baliogabea",
|
||||||
|
"playbackPausedDueToError": "erreprodukzioa eten egin da errore baten ondorioz",
|
||||||
|
"serverLockSingleServer": "zerbitzaria blokeatuta dagoenean, zerbitzari bakarra onartzen da",
|
||||||
|
"settingsSyncError": "desadostasunak aurkitu dira errendatzailearen ezarpenen eta prozesu nagusiaren artean. berrabiarazi aplikazioa aldaketak aplikatzeko"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"disc": "diskoa",
|
"disc": "diskoa",
|
||||||
@@ -371,7 +382,7 @@
|
|||||||
"biography": "biografia",
|
"biography": "biografia",
|
||||||
"bitrate": "bit-emaria",
|
"bitrate": "bit-emaria",
|
||||||
"bpm": "bpm-ak",
|
"bpm": "bpm-ak",
|
||||||
"channels": "$t(common.channel_other)",
|
"channels": "$t(common.channel, {\"count\": 2})",
|
||||||
"comment": "iruzkina",
|
"comment": "iruzkina",
|
||||||
"favorited": "gogoko gisa markatua",
|
"favorited": "gogoko gisa markatua",
|
||||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||||
@@ -396,7 +407,9 @@
|
|||||||
"releaseYear": "argitalpen urtea",
|
"releaseYear": "argitalpen urtea",
|
||||||
"toYear": "urtera arte",
|
"toYear": "urtera arte",
|
||||||
"fromYear": "urtetik aurrera",
|
"fromYear": "urtetik aurrera",
|
||||||
"explicitStatus": "$t(common.explicitStatus)"
|
"explicitStatus": "$t(common.explicitStatus)",
|
||||||
|
"matchAnd": "eta",
|
||||||
|
"matchOr": "edo"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"hotkey_playbackPause": "pausatu",
|
"hotkey_playbackPause": "pausatu",
|
||||||
@@ -731,15 +744,16 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) behar bezala eguneratu da",
|
"success": "$t(entity.playlist, {\"count\": 1}) behar bezala eguneratu da",
|
||||||
"title": "$t(entity.playlist, {\"count\": 1}) editatu",
|
"title": "$t(entity.playlist, {\"count\": 1}) editatu",
|
||||||
"publicJellyfinNote": "Arrazoiren batengatik, Jellyfin ez du erakusten erreprodukzio-zerrendak publikoak diren edo ez. Hau publiko izaten jarraitzea nahi baduzu, hautatu sarrera hau",
|
"publicJellyfinNote": "Arrazoiren batengatik, Jellyfin ez du erakusten erreprodukzio-zerrendak publikoak diren edo ez. Hau publiko izaten jarraitzea nahi baduzu, hautatu sarrera hau"
|
||||||
"editNote": "ez da gomendatzen eskuzko edizioak egitea erreprodukzio-zerrenda handietarako. ziur zaude onartzen duzula lehendik dagoen erreprodukzio-zerrenda gainidazteagatik datuak galtzeko arriskua?"
|
|
||||||
},
|
},
|
||||||
"queryEditor": {
|
"queryEditor": {
|
||||||
"title": "kontsulta editorea",
|
"title": "kontsulta editorea",
|
||||||
"input_optionMatchAll": "guztiak bat etorri",
|
"input_optionMatchAll": "guztiak bat etorri",
|
||||||
"input_optionMatchAny": "edozeinekin bat etorri",
|
"input_optionMatchAny": "edozeinekin bat etorri",
|
||||||
"resetToDefault": "lehenetsitako egoerara berrezarri",
|
"resetToDefault": "lehenetsitako egoerara berrezarri",
|
||||||
"clearFilters": "garbitu iragazkiak"
|
"clearFilters": "garbitu iragazkiak",
|
||||||
|
"addRuleGroup": "gehitu arau-taldea",
|
||||||
|
"removeRuleGroup": "kendu arau-taldea"
|
||||||
},
|
},
|
||||||
"updateServer": {
|
"updateServer": {
|
||||||
"success": "zerbitzaria behar bezala eguneratu da",
|
"success": "zerbitzaria behar bezala eguneratu da",
|
||||||
@@ -751,7 +765,8 @@
|
|||||||
"disabled": "modu pribatua desgaituta, erreprodukzio egoera ikusgai dago orain gaitutako kanpoko integrazioentzat"
|
"disabled": "modu pribatua desgaituta, erreprodukzio egoera ikusgai dago orain gaitutako kanpoko integrazioentzat"
|
||||||
},
|
},
|
||||||
"largeFetchConfirmation": {
|
"largeFetchConfirmation": {
|
||||||
"title": "gehitu elementuak ilaran"
|
"title": "gehitu elementuak ilaran",
|
||||||
|
"description": "Ekintza honek uneko iragazki-ikuspegian dauden elementu guztiak gehituko ditu"
|
||||||
},
|
},
|
||||||
"createRadioStation": {
|
"createRadioStation": {
|
||||||
"input_homepageUrl": "hasierako orriaren URLa",
|
"input_homepageUrl": "hasierako orriaren URLa",
|
||||||
|
|||||||
@@ -332,8 +332,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) päivitetty onnistuneesti",
|
"success": "$t(entity.playlist, {\"count\": 1}) päivitetty onnistuneesti",
|
||||||
"title": "muokkaa $t(entity.playlist, {\"count\": 1})",
|
"title": "muokkaa $t(entity.playlist, {\"count\": 1})",
|
||||||
"publicJellyfinNote": "Jellyfin ei jostain syystä kerro onko soittolista julkinen vai ei. Jos haluat sen pysyvän julkisena, pidä seuraava valinta valittuna",
|
"publicJellyfinNote": "Jellyfin ei jostain syystä kerro onko soittolista julkinen vai ei. Jos haluat sen pysyvän julkisena, pidä seuraava valinta valittuna"
|
||||||
"editNote": "manuaalisia muokkauksia ei suositella suurille soittolistoille. haluatko varmasti hyväksyä riskin, että nykyinen soittolista ylikirjoitetaan ja tietoja voi hävitä?"
|
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
||||||
|
|||||||
@@ -70,7 +70,10 @@
|
|||||||
"removeFromFavorites": "retirer des $t(entity.favorite, {\"count\": 2})",
|
"removeFromFavorites": "retirer des $t(entity.favorite, {\"count\": 2})",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Ouvrir dans Last.fm",
|
"lastfm": "Ouvrir dans Last.fm",
|
||||||
"musicbrainz": "Ouvrir dans MusicBrainz"
|
"musicbrainz": "Ouvrir dans MusicBrainz",
|
||||||
|
"listenbrainz": "Ouvrir dans ListenBrainz",
|
||||||
|
"qobuz": "Ouvrir dans Qobuz",
|
||||||
|
"spotify": "Ouvrir dans Spotify"
|
||||||
},
|
},
|
||||||
"moveToNext": "passer au suivant",
|
"moveToNext": "passer au suivant",
|
||||||
"downloadStarted": "téléchargement de {{count}} éléments en cours",
|
"downloadStarted": "téléchargement de {{count}} éléments en cours",
|
||||||
@@ -180,6 +183,7 @@
|
|||||||
"albumPeak": "crête de l'album",
|
"albumPeak": "crête de l'album",
|
||||||
"close": "fermer",
|
"close": "fermer",
|
||||||
"mbid": "Identifiant MusicBrainz",
|
"mbid": "Identifiant MusicBrainz",
|
||||||
|
"grouping": "regroupement",
|
||||||
"preview": "aperçu",
|
"preview": "aperçu",
|
||||||
"share": "partager",
|
"share": "partager",
|
||||||
"reload": "recharger",
|
"reload": "recharger",
|
||||||
@@ -217,7 +221,8 @@
|
|||||||
"filter_single": "unique",
|
"filter_single": "unique",
|
||||||
"filter_multiple": "multiple",
|
"filter_multiple": "multiple",
|
||||||
"rename": "renommer",
|
"rename": "renommer",
|
||||||
"newVersionAvailable": "une nouvelle version est disponible"
|
"newVersionAvailable": "une nouvelle version est disponible",
|
||||||
|
"numberOfResults": "{{numberOfResults}} résultats"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
|
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
|
||||||
@@ -238,7 +243,7 @@
|
|||||||
"mpvRequired": "MPV requis",
|
"mpvRequired": "MPV requis",
|
||||||
"audioDeviceFetchError": "une erreur s’est produite lors de la tentative d’obtenir les périphériques audio",
|
"audioDeviceFetchError": "une erreur s’est produite lors de la tentative d’obtenir les périphériques audio",
|
||||||
"invalidServer": "serveur invalide",
|
"invalidServer": "serveur invalide",
|
||||||
"loginRateError": "trop de tentative de connexion, merci de réessayer dans quelques secondes",
|
"loginRateError": "trop de tentatives de connexion, merci de réessayer dans quelques secondes",
|
||||||
"openError": "impossible d'ouvrir le fichier",
|
"openError": "impossible d'ouvrir le fichier",
|
||||||
"networkError": "une erreur de réseau est survenue",
|
"networkError": "une erreur de réseau est survenue",
|
||||||
"badAlbum": "vous voyez cette page parce que ce titre ne fait pas parti d'un album. vous rencontrez probablement cette erreur si vous avez un titre à la racine de votre dossier musique. Jellyfin gère les chansons uniquement si elles sont dans un sous-dossier, qui est lui-même dans un dossier \"Musique(s)\"",
|
"badAlbum": "vous voyez cette page parce que ce titre ne fait pas parti d'un album. vous rencontrez probablement cette erreur si vous avez un titre à la racine de votre dossier musique. Jellyfin gère les chansons uniquement si elles sont dans un sous-dossier, qui est lui-même dans un dossier \"Musique(s)\"",
|
||||||
@@ -874,7 +879,19 @@
|
|||||||
"hotkey_listPlayNext": "lire ensuite",
|
"hotkey_listPlayNext": "lire ensuite",
|
||||||
"hotkey_listPlayNow": "lire maintenant",
|
"hotkey_listPlayNow": "lire maintenant",
|
||||||
"playerItemConfiguration_description": "configurer les éléments affichés et leur ordre dans le lecteur plein écran",
|
"playerItemConfiguration_description": "configurer les éléments affichés et leur ordre dans le lecteur plein écran",
|
||||||
"playerItemConfiguration": "configuration des éléments du lecteur"
|
"playerItemConfiguration": "configuration des éléments du lecteur",
|
||||||
|
"listenbrainz_description": "afficher les liens vers ListenBrainz sur les pages d'artiste/album",
|
||||||
|
"listenbrainz": "afficher les liens ListenBrainz",
|
||||||
|
"qobuz_description": "afficher les liens vers Qobuz sur les pages d'artiste/album",
|
||||||
|
"qobuz": "afficher les liens Qobuz",
|
||||||
|
"spotify_description": "afficher les liens vers Spotify sur les pages d'artiste/album",
|
||||||
|
"spotify": "afficher les liens Spotify",
|
||||||
|
"nativeSpotify_description": "ouvrir dans l'application Spotify plutôt que le navigateur",
|
||||||
|
"nativeSpotify": "utiliser l'application Spotify",
|
||||||
|
"sidePlayQueueLayout": "disposition de la file d'attente",
|
||||||
|
"sidePlayQueueLayout_description": "définit la disposition de la file d'attente attaché",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "horizontal",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "vertical"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"deletePlaylist": {
|
"deletePlaylist": {
|
||||||
@@ -932,8 +949,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "modifier $t(entity.playlist, {\"count\": 1})",
|
"title": "modifier $t(entity.playlist, {\"count\": 1})",
|
||||||
"publicJellyfinNote": "Jellyfin n'indique pas si une liste de lecture est publique ou non. Si vous souhaitez que cette liste de lecture reste publique, veuillez sélectionner l'entrée suivante",
|
"publicJellyfinNote": "Jellyfin n'indique pas si une liste de lecture est publique ou non. Si vous souhaitez que cette liste de lecture reste publique, veuillez sélectionner l'entrée suivante",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) mis à jour avec succès",
|
"success": "$t(entity.playlist, {\"count\": 1}) mis à jour avec succès"
|
||||||
"editNote": "les modifications manuelles ne sont pas recommandées pour les listes de lecture volumineuses. êtes-vous sûre d'accepter le risque d'une perte de données en écrasant la liste de lecture existante ?"
|
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"title": "recherche de paroles",
|
"title": "recherche de paroles",
|
||||||
|
|||||||
@@ -313,8 +313,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) sikeresen módosítva",
|
"success": "$t(entity.playlist, {\"count\": 1}) sikeresen módosítva",
|
||||||
"publicJellyfinNote": "A Jellyfin valamiért nem teszi közzé, hogy egy lejátszási lista publikus-e vagy sem. Amennyiben azt szeretnéd, hogy publikus maradjon, válaszd ki az alábbi beviteli mezőt",
|
"publicJellyfinNote": "A Jellyfin valamiért nem teszi közzé, hogy egy lejátszási lista publikus-e vagy sem. Amennyiben azt szeretnéd, hogy publikus maradjon, válaszd ki az alábbi beviteli mezőt",
|
||||||
"title": "szerkesztés $t(entity.playlist, {\"count\": 1})",
|
"title": "szerkesztés $t(entity.playlist, {\"count\": 1})"
|
||||||
"editNote": "A kézi szerkesztés nem ajánlott nagy lejátszási listák esetén. Biztosan vállalod a meglévő lejátszási lista felülírásával járó adatvesztés kockázatát?"
|
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
||||||
|
|||||||
@@ -304,8 +304,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"publicJellyfinNote": "Jellyfin entah bagaimana tidak menampilkan apakah playlist ini publik atau tidak. Jika Anda ingin playlist ini tetap publik, harap pilih entri berikut",
|
"publicJellyfinNote": "Jellyfin entah bagaimana tidak menampilkan apakah playlist ini publik atau tidak. Jika Anda ingin playlist ini tetap publik, harap pilih entri berikut",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) berhasil diperbarui",
|
"success": "$t(entity.playlist, {\"count\": 1}) berhasil diperbarui",
|
||||||
"title": "ubah $t(entity.playlist, {\"count\": 1})",
|
"title": "ubah $t(entity.playlist, {\"count\": 1})"
|
||||||
"editNote": "pengeditan manual tidak disarankan untuk playlist besar. apakah Anda yakin menerima risiko kehilangan data yang timbul akibat menimpa playlist yang ada?"
|
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
||||||
|
|||||||
@@ -128,6 +128,7 @@
|
|||||||
"close": "chiudi",
|
"close": "chiudi",
|
||||||
"codec": "codec",
|
"codec": "codec",
|
||||||
"mbid": "MusicBrainz ID",
|
"mbid": "MusicBrainz ID",
|
||||||
|
"grouping": "raggruppamento",
|
||||||
"preview": "anteprima",
|
"preview": "anteprima",
|
||||||
"reload": "aggiorna",
|
"reload": "aggiorna",
|
||||||
"share": "condividi",
|
"share": "condividi",
|
||||||
|
|||||||
+79
-61
@@ -75,7 +75,7 @@
|
|||||||
"mpvExecutablePath_description": "MPV を実行するファイルパスを設定します。空のままにすると、デフォルトのパスが使用されます",
|
"mpvExecutablePath_description": "MPV を実行するファイルパスを設定します。空のままにすると、デフォルトのパスが使用されます",
|
||||||
"replayGainClipping_description": "自動的にゲインを下げて {{ReplayGain}} によるクリッピングを防ぎます",
|
"replayGainClipping_description": "自動的にゲインを下げて {{ReplayGain}} によるクリッピングを防ぎます",
|
||||||
"replayGainPreamp": "{{ReplayGain}} プリアンプ (dB)",
|
"replayGainPreamp": "{{ReplayGain}} プリアンプ (dB)",
|
||||||
"hotkey_favoriteCurrentSong": "$t(common.currentSong) をお気に入り",
|
"hotkey_favoriteCurrentSong": "$t(common.currentSong) をお気に入りに登録",
|
||||||
"sampleRate": "サンプルレート",
|
"sampleRate": "サンプルレート",
|
||||||
"sidePlayQueueStyle_optionAttached": "結合",
|
"sidePlayQueueStyle_optionAttached": "結合",
|
||||||
"sidebarConfiguration": "サイドバー設定",
|
"sidebarConfiguration": "サイドバー設定",
|
||||||
@@ -90,19 +90,19 @@
|
|||||||
"themeLight": "テーマ (ライト)",
|
"themeLight": "テーマ (ライト)",
|
||||||
"fontType_optionBuiltIn": "組み込みフォント",
|
"fontType_optionBuiltIn": "組み込みフォント",
|
||||||
"hotkey_playbackPlayPause": "再生 / 一時停止",
|
"hotkey_playbackPlayPause": "再生 / 一時停止",
|
||||||
"hotkey_rate1": "1つ星で評価",
|
"hotkey_rate1": "1 つ星で評価",
|
||||||
"hotkey_skipForward": "次へスキップ",
|
"hotkey_skipForward": "次へスキップ",
|
||||||
"disableLibraryUpdateOnStartup": "起動時の新バージョンチェックを無効にします",
|
"disableLibraryUpdateOnStartup": "起動時の新バージョンチェックを無効にします",
|
||||||
"discordApplicationId_description": "{{discord}} に Rich Presence ステータスを表示するためのアプリケーション ID (デフォルトは {{defaultId}} です)",
|
"discordApplicationId_description": "{{discord}} に Rich Presence ステータスを表示するためのアプリケーション ID (デフォルトは {{defaultId}} です)",
|
||||||
"sidePlayQueueStyle": "サイド再生キュースタイル",
|
"sidePlayQueueStyle": "サイド再生キューの形式",
|
||||||
"gaplessAudio": "ギャップレス再生",
|
"gaplessAudio": "ギャップレス再生",
|
||||||
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||||
"zoom": "ズーム率",
|
"zoom": "ズーム率",
|
||||||
"minimizeToTray_description": "最小化ボタンが押された際、システムトレイに格納します",
|
"minimizeToTray_description": "最小化ボタンが押された際、システムトレイに格納します",
|
||||||
"hotkey_playbackPlay": "再生",
|
"hotkey_playbackPlay": "再生",
|
||||||
"hotkey_togglePreviousSongFavorite": "$t(common.previousSong) をお気に入り登録/解除",
|
"hotkey_togglePreviousSongFavorite": "$t(common.previousSong) のお気に入りを切り替え",
|
||||||
"hotkey_volumeDown": "音量を下げる",
|
"hotkey_volumeDown": "音量を下げる",
|
||||||
"hotkey_unfavoritePreviousSong": "$t(common.previousSong) をお気に入り解除",
|
"hotkey_unfavoritePreviousSong": "$t(common.previousSong) をお気に入りから解除",
|
||||||
"audioPlayer_description": "再生に使用するオーディオプレーヤーを選択します",
|
"audioPlayer_description": "再生に使用するオーディオプレーヤーを選択します",
|
||||||
"globalMediaHotkeys": "グローバルメディアホットキー",
|
"globalMediaHotkeys": "グローバルメディアホットキー",
|
||||||
"hotkey_globalSearch": "グローバル検索",
|
"hotkey_globalSearch": "グローバル検索",
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
"remoteUsername_description": "リモートコントロール サーバーのユーザ名を設定します。 ユーザー名とパスワードの両方が空の場合、認証は無効になります",
|
"remoteUsername_description": "リモートコントロール サーバーのユーザ名を設定します。 ユーザー名とパスワードの両方が空の場合、認証は無効になります",
|
||||||
"exitToTray_description": "アプリケーション終了ボタンが押された際、システムトレイに格納します",
|
"exitToTray_description": "アプリケーション終了ボタンが押された際、システムトレイに格納します",
|
||||||
"followLyric_description": "現在の再生位置に歌詞をスクロールします",
|
"followLyric_description": "現在の再生位置に歌詞をスクロールします",
|
||||||
"hotkey_favoritePreviousSong": "$t(common.previousSong) をお気に入り",
|
"hotkey_favoritePreviousSong": "$t(common.previousSong) をお気に入りに登録",
|
||||||
"replayGainMode_optionAlbum": "$t(entity.album, {\"count\": 1})",
|
"replayGainMode_optionAlbum": "$t(entity.album, {\"count\": 1})",
|
||||||
"lyricOffset": "歌詞のオフセット (ミリ秒)",
|
"lyricOffset": "歌詞のオフセット (ミリ秒)",
|
||||||
"discordUpdateInterval_description": "更新間隔 (秒単位、最小 15 秒)",
|
"discordUpdateInterval_description": "更新間隔 (秒単位、最小 15 秒)",
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
"lyricFetchProvider": "歌詞取得先",
|
"lyricFetchProvider": "歌詞取得先",
|
||||||
"language_description": "アプリケーションの言語を設定します ($t(common.restartRequired))",
|
"language_description": "アプリケーションの言語を設定します ($t(common.restartRequired))",
|
||||||
"playbackStyle_optionCrossFade": "クロスフェード",
|
"playbackStyle_optionCrossFade": "クロスフェード",
|
||||||
"hotkey_rate3": "3つ星で評価",
|
"hotkey_rate3": "3 つ星で評価",
|
||||||
"font": "フォント",
|
"font": "フォント",
|
||||||
"replayGainMode_optionTrack": "$t(entity.track, {\"count\": 1})",
|
"replayGainMode_optionTrack": "$t(entity.track, {\"count\": 1})",
|
||||||
"themeLight_description": "アプリケーションに使用するライトテーマを設定します",
|
"themeLight_description": "アプリケーションに使用するライトテーマを設定します",
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
"hotkey_toggleQueue": "キューの切り替え",
|
"hotkey_toggleQueue": "キューの切り替え",
|
||||||
"zoom_description": "アプリケーションのズーム率を設定します",
|
"zoom_description": "アプリケーションのズーム率を設定します",
|
||||||
"remotePassword_description": "リモートコントロール サーバーのパスワードを設定します。 ログイン情報はデフォルトでセキュアな通信がされないため、個人情報と関係ないランダムなパスワードを利用してください",
|
"remotePassword_description": "リモートコントロール サーバーのパスワードを設定します。 ログイン情報はデフォルトでセキュアな通信がされないため、個人情報と関係ないランダムなパスワードを利用してください",
|
||||||
"hotkey_rate5": "5つ星で評価",
|
"hotkey_rate5": "5 つ星で評価",
|
||||||
"hotkey_playbackPrevious": "前のトラック",
|
"hotkey_playbackPrevious": "前のトラック",
|
||||||
"showSkipButtons_description": "プレーヤーバーのスキップボタンを表示または非表示にします",
|
"showSkipButtons_description": "プレーヤーバーのスキップボタンを表示または非表示にします",
|
||||||
"crossfadeDuration_description": "クロスフェード効果の時間を設定します",
|
"crossfadeDuration_description": "クロスフェード効果の時間を設定します",
|
||||||
@@ -141,11 +141,11 @@
|
|||||||
"discordRichPresence_description": "{{discord}} Rich Presence で再生ステータスを有効にします。画像キー: {{icon}}, {{playing}}, {{paused}}",
|
"discordRichPresence_description": "{{discord}} Rich Presence で再生ステータスを有効にします。画像キー: {{icon}}, {{playing}}, {{paused}}",
|
||||||
"mpvExecutablePath": "MPV 実行ファイルパス",
|
"mpvExecutablePath": "MPV 実行ファイルパス",
|
||||||
"audioDevice": "オーディオデバイス",
|
"audioDevice": "オーディオデバイス",
|
||||||
"hotkey_rate2": "2つ星で評価",
|
"hotkey_rate2": "2 つ星で評価",
|
||||||
"playButtonBehavior_description": "キューに曲を追加するときの再生ボタンのデフォルトの動作を設定します",
|
"playButtonBehavior_description": "キューに曲を追加するときの再生ボタンのデフォルトの動作を設定します",
|
||||||
"minimumScrobblePercentage_description": "Scrobble されるために必要な最短の再生時間 (%)",
|
"minimumScrobblePercentage_description": "Scrobble されるために必要な最短の再生時間 (%)",
|
||||||
"exitToTray": "終了時にシステムトレイに格納",
|
"exitToTray": "終了時にシステムトレイに格納",
|
||||||
"hotkey_rate4": "4つ星で評価",
|
"hotkey_rate4": "4 つ星で評価",
|
||||||
"enableRemote": "リモートコントロール サーバーを有効化",
|
"enableRemote": "リモートコントロール サーバーを有効化",
|
||||||
"showSkipButton_description": "プレーヤーバーのスキップボタンを表示または非表示にします",
|
"showSkipButton_description": "プレーヤーバーのスキップボタンを表示または非表示にします",
|
||||||
"savePlayQueue": "再生キューを保存",
|
"savePlayQueue": "再生キューを保存",
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
"volumeWheelStep": "音量ホイールステップ",
|
"volumeWheelStep": "音量ホイールステップ",
|
||||||
"sidebarPlaylistList_description": "サイドバーのプレイリストを表示または非表示にします",
|
"sidebarPlaylistList_description": "サイドバーのプレイリストを表示または非表示にします",
|
||||||
"accentColor": "アクセントカラー",
|
"accentColor": "アクセントカラー",
|
||||||
"sidePlayQueueStyle_description": "サイド再生キューのスタイルを設定します",
|
"sidePlayQueueStyle_description": "サイド再生キューの形式を設定します",
|
||||||
"accentColor_description": "アプリケーションが利用するアクセントカラーを設定します",
|
"accentColor_description": "アプリケーションが利用するアクセントカラーを設定します",
|
||||||
"replayGainMode": "{{ReplayGain}} モード",
|
"replayGainMode": "{{ReplayGain}} モード",
|
||||||
"playbackStyle_optionNormal": "通常",
|
"playbackStyle_optionNormal": "通常",
|
||||||
@@ -182,12 +182,12 @@
|
|||||||
"sidePlayQueueStyle_optionDetached": "分離",
|
"sidePlayQueueStyle_optionDetached": "分離",
|
||||||
"audioPlayer": "オーディオプレーヤー",
|
"audioPlayer": "オーディオプレーヤー",
|
||||||
"hotkey_zoomOut": "縮小",
|
"hotkey_zoomOut": "縮小",
|
||||||
"hotkey_unfavoriteCurrentSong": "$t(common.currentSong) をお気に入り解除",
|
"hotkey_unfavoriteCurrentSong": "$t(common.currentSong) をお気に入りから解除",
|
||||||
"hotkey_rate0": "評価をクリア",
|
"hotkey_rate0": "評価をクリア",
|
||||||
"discordApplicationId": "{{discord}} アプリケーション ID",
|
"discordApplicationId": "{{discord}} アプリケーション ID",
|
||||||
"applicationHotkeys_description": "アプリケーションのホットキーを設定します。チェックボックスを切り替えて、グローバルホットキーとして設定します (デスクトップのみ)",
|
"applicationHotkeys_description": "アプリケーションのホットキーを設定します。チェックボックスを切り替えて、グローバルホットキーとして設定します (デスクトップのみ)",
|
||||||
"hotkey_volumeMute": "音量をミュート",
|
"hotkey_volumeMute": "音量をミュート",
|
||||||
"hotkey_toggleCurrentSongFavorite": "$t(common.currentSong) をお気に入り登録/解除",
|
"hotkey_toggleCurrentSongFavorite": "$t(common.currentSong) のお気に入りを切り替え",
|
||||||
"remoteUsername": "リモートコントロールサーバーのユーザー名",
|
"remoteUsername": "リモートコントロールサーバーのユーザー名",
|
||||||
"hotkey_browserBack": "ブラウザ 戻る",
|
"hotkey_browserBack": "ブラウザ 戻る",
|
||||||
"showSkipButton": "スキップボタンを表示",
|
"showSkipButton": "スキップボタンを表示",
|
||||||
@@ -221,8 +221,8 @@
|
|||||||
"volumeWidth": "音量スライダーの幅",
|
"volumeWidth": "音量スライダーの幅",
|
||||||
"webAudio_description": "Web Audio を使用します。これにより、リプレイゲインなどの高度な機能が有効になります。問題が発生した場合は無効にしてください",
|
"webAudio_description": "Web Audio を使用します。これにより、リプレイゲインなどの高度な機能が有効になります。問題が発生した場合は無効にしてください",
|
||||||
"mpvExtraParameters_help": "1 行に 1 つずつ",
|
"mpvExtraParameters_help": "1 行に 1 つずつ",
|
||||||
"musicbrainz_description": "MusicBrainz ID が存在するアーティスト/アルバムページに MusicBrainz へのリンクを表示します",
|
"musicbrainz_description": "MusicBrainz ID が存在するアーティストとアルバムページに MusicBrainz へのリンクを表示します",
|
||||||
"musicbrainz": "MusicBrainz リンクを表示する",
|
"musicbrainz": "MusicBrainz のリンクを表示",
|
||||||
"neteaseTranslation_description": "有効にすると、利用可能な場合は NetEase から翻訳された歌詞を取得して表示します",
|
"neteaseTranslation_description": "有効にすると、利用可能な場合は NetEase から翻訳された歌詞を取得して表示します",
|
||||||
"neteaseTranslation": "NetEase 翻訳歌詞を有効にする",
|
"neteaseTranslation": "NetEase 翻訳歌詞を有効にする",
|
||||||
"passwordStore_description": "使用するパスワード / シークレットストア。パスワードの保存に問題がある場合はこれを変更してください",
|
"passwordStore_description": "使用するパスワード / シークレットストア。パスワードの保存に問題がある場合はこれを変更してください",
|
||||||
@@ -238,8 +238,8 @@
|
|||||||
"imageAspectRatio": "ネイティブのカバーアートの縦横比を使用する",
|
"imageAspectRatio": "ネイティブのカバーアートの縦横比を使用する",
|
||||||
"language": "言語",
|
"language": "言語",
|
||||||
"imageAspectRatio_description": "有効にすると、カバーアートはネイティブの縦横比で表示されます。縦横比が 1:1 でない場合、残りのスペースは空白になります",
|
"imageAspectRatio_description": "有効にすると、カバーアートはネイティブの縦横比で表示されます。縦横比が 1:1 でない場合、残りのスペースは空白になります",
|
||||||
"lastfm_description": "アーティスト/アルバムページに Last.fm へのリンクを表示します",
|
"lastfm_description": "アーティストとアルバムページに Last.fm へのリンクを表示します",
|
||||||
"lastfm": "Last.fm リンクを表示する",
|
"lastfm": "Last.fm のリンクを表示",
|
||||||
"lastfmApiKey": "{{lastfm}} API キー",
|
"lastfmApiKey": "{{lastfm}} API キー",
|
||||||
"homeConfiguration_description": "ホーム画面に表示する項目と表示順序を設定します",
|
"homeConfiguration_description": "ホーム画面に表示する項目と表示順序を設定します",
|
||||||
"homeConfiguration": "ホーム画面の設定",
|
"homeConfiguration": "ホーム画面の設定",
|
||||||
@@ -287,7 +287,7 @@
|
|||||||
"exportImportSettings_control_title": "設定をインポート/エクスポート",
|
"exportImportSettings_control_title": "設定をインポート/エクスポート",
|
||||||
"exportImportSettings_control_description": "JSON 経由で設定をエクスポートおよびインポートする",
|
"exportImportSettings_control_description": "JSON 経由で設定をエクスポートおよびインポートする",
|
||||||
"exportImportSettings_destructiveWarning": "設定のインポートは破壊的です。下の「インポート」をクリックする前に、上記の内容を必ずご確認ください!",
|
"exportImportSettings_destructiveWarning": "設定のインポートは破壊的です。下の「インポート」をクリックする前に、上記の内容を必ずご確認ください!",
|
||||||
"hotkey_navigateHome": "ホームに移動",
|
"hotkey_navigateHome": "ホーム画面へ移動",
|
||||||
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
||||||
"playerbarOpenDrawer": "プレーヤーバーの全画面表示切り替え",
|
"playerbarOpenDrawer": "プレーヤーバーの全画面表示切り替え",
|
||||||
"transcode": "トランスコーディングを有効にする",
|
"transcode": "トランスコーディングを有効にする",
|
||||||
@@ -329,11 +329,11 @@
|
|||||||
"followCurrentSong": "現在の曲をフォロー",
|
"followCurrentSong": "現在の曲をフォロー",
|
||||||
"followCurrentSong_description": "再生キューを現在再生中の曲まで自動的にスクロールします",
|
"followCurrentSong_description": "再生キューを現在再生中の曲まで自動的にスクロールします",
|
||||||
"logLevel": "ログレベル",
|
"logLevel": "ログレベル",
|
||||||
"logLevel_description": "表示するログの最小レベルを設定します。debug はすべてのログを表示し、error はエラーのみを表示します",
|
"logLevel_description": "表示するログの最小レベルを設定します。Debug はすべてのログを表示し、Error はエラーのみを表示します",
|
||||||
"logLevel_optionDebug": "debug",
|
"logLevel_optionDebug": "Debug",
|
||||||
"logLevel_optionError": "error",
|
"logLevel_optionError": "Error",
|
||||||
"logLevel_optionInfo": "info",
|
"logLevel_optionInfo": "Info",
|
||||||
"logLevel_optionWarn": "警告する",
|
"logLevel_optionWarn": "Warn",
|
||||||
"playerFilters": "キューから曲をフィルタリング",
|
"playerFilters": "キューから曲をフィルタリング",
|
||||||
"playerFilters_description": "以下の基準に基づいて曲をキューに追加しないようにします",
|
"playerFilters_description": "以下の基準に基づいて曲をキューに追加しないようにします",
|
||||||
"artistRadioCount": "アーティスト / トラックのラジオカウント",
|
"artistRadioCount": "アーティスト / トラックのラジオカウント",
|
||||||
@@ -407,11 +407,25 @@
|
|||||||
"playerbarWaveformBarWidth": "波形バーの幅",
|
"playerbarWaveformBarWidth": "波形バーの幅",
|
||||||
"playerbarWaveformGap": "波形ギャップ",
|
"playerbarWaveformGap": "波形ギャップ",
|
||||||
"playerbarWaveformRadius": "波形半径",
|
"playerbarWaveformRadius": "波形半径",
|
||||||
"hotkey_listNavigateToPage": "リストのアイテムページへ移動",
|
"hotkey_listNavigateToPage": "項目の詳細ページへ移動",
|
||||||
"hotkey_listPlayDefault": "リスト再生",
|
"hotkey_listPlayDefault": "リストを再生 (デフォルト)",
|
||||||
"hotkey_listPlayLast": "リストの最後を再生",
|
"hotkey_listPlayLast": "最後に再生",
|
||||||
"hotkey_listPlayNext": "リスト 再生 次へ",
|
"hotkey_listPlayNext": "次に再生",
|
||||||
"hotkey_listPlayNow": "今すぐリストを再生"
|
"hotkey_listPlayNow": "今すぐ再生",
|
||||||
|
"spotify_description": "アーティストとアルバムページに Spotify へのリンクを表示します",
|
||||||
|
"spotify": "Spotify のリンクを表示",
|
||||||
|
"nativeSpotify_description": "ブラウザーの代わりに Spotify アプリで開きます",
|
||||||
|
"nativeSpotify": "Spotify アプリを使用",
|
||||||
|
"listenbrainz_description": "アーティストとアルバムページに ListenBrainz へのリンクを表示します",
|
||||||
|
"listenbrainz": "ListenBrainz のリンクを表示",
|
||||||
|
"qobuz_description": "アーティストとアルバムページに Qobuz へのリンクを表示します",
|
||||||
|
"qobuz": "Qobuz のリンクを表示",
|
||||||
|
"sidePlayQueueLayout": "サイド再生キューのレイアウト",
|
||||||
|
"sidePlayQueueLayout_description": "結合されたサイド再生キューのレイアウトを設定します",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "水平",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "垂直",
|
||||||
|
"waveformLoadingDelay": "波形読み込みの遅延",
|
||||||
|
"waveformLoadingDelay_description": "波形を読み込むまでの遅延時間(秒単位)を設定します。Web プレーヤー使用時にカクつきが発生する場合は、この値を増やしてください。"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
|
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
|
||||||
@@ -433,7 +447,10 @@
|
|||||||
"removeFromFavorites": "$t(entity.favorite, {\"count\": 2}) から削除",
|
"removeFromFavorites": "$t(entity.favorite, {\"count\": 2}) から削除",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Last.fm で開く",
|
"lastfm": "Last.fm で開く",
|
||||||
"musicbrainz": "MusicBrainz で開く"
|
"musicbrainz": "MusicBrainz で開く",
|
||||||
|
"spotify": "Spotify で開く",
|
||||||
|
"listenbrainz": "ListenBrainz で開く",
|
||||||
|
"qobuz": "Qobuz で開く"
|
||||||
},
|
},
|
||||||
"moveToNext": "次",
|
"moveToNext": "次",
|
||||||
"downloadStarted": "{{count}} 曲のダウンロードを開始しました",
|
"downloadStarted": "{{count}} 曲のダウンロードを開始しました",
|
||||||
@@ -537,7 +554,7 @@
|
|||||||
"bitDepth": "ビット深度",
|
"bitDepth": "ビット深度",
|
||||||
"close": "閉じる",
|
"close": "閉じる",
|
||||||
"codec": "コーデック",
|
"codec": "コーデック",
|
||||||
"mbid": "MusicBrainz識別子",
|
"mbid": "MusicBrainz ID",
|
||||||
"sampleRate": "サンプルレート",
|
"sampleRate": "サンプルレート",
|
||||||
"preview": "プレビュー",
|
"preview": "プレビュー",
|
||||||
"private": "プライベート",
|
"private": "プライベート",
|
||||||
@@ -571,7 +588,9 @@
|
|||||||
"filter_single": "シングル",
|
"filter_single": "シングル",
|
||||||
"filter_multiple": "複数枚組",
|
"filter_multiple": "複数枚組",
|
||||||
"rename": "名前を変更",
|
"rename": "名前を変更",
|
||||||
"newVersionAvailable": "新しいバージョンが利用可能です"
|
"newVersionAvailable": "新しいバージョンが利用可能です",
|
||||||
|
"numberOfResults": "{{numberOfResults}} 件の結果",
|
||||||
|
"grouping": "グループ化"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
@@ -584,7 +603,7 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"displayType": "表示タイプ",
|
"displayType": "表示タイプ",
|
||||||
"gap": "$t(common.gap)",
|
"gap": "$t(common.gap)",
|
||||||
"tableColumns": "テーブル カラム",
|
"tableColumns": "テーブル列",
|
||||||
"autoFitColumns": "カラム長を自動調整",
|
"autoFitColumns": "カラム長を自動調整",
|
||||||
"size": "$t(common.size)",
|
"size": "$t(common.size)",
|
||||||
"itemSize": "項目のサイズ (px)",
|
"itemSize": "項目のサイズ (px)",
|
||||||
@@ -1037,8 +1056,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "$t(entity.playlist, {\"count\": 1}) を編集",
|
"title": "$t(entity.playlist, {\"count\": 1}) を編集",
|
||||||
"publicJellyfinNote": "Jellyfin では、何らかの理由でプレイリストが公開されているかどうかが表示されません。公開されたままにしたい場合は、以下の項目を選択してください",
|
"publicJellyfinNote": "Jellyfin では、何らかの理由でプレイリストが公開されているかどうかが表示されません。公開されたままにしたい場合は、以下の項目を選択してください",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) が正常に更新されました",
|
"success": "$t(entity.playlist, {\"count\": 1}) が正常に更新されました"
|
||||||
"editNote": "大規模なプレイリストの場合、手動編集は推奨されません。既存のプレイリストを上書きすることでデータ損失が発生するリスクを許容しますか?"
|
|
||||||
},
|
},
|
||||||
"shareItem": {
|
"shareItem": {
|
||||||
"allowDownloading": "ダウンロードを許可",
|
"allowDownloading": "ダウンロードを許可",
|
||||||
@@ -1064,7 +1082,7 @@
|
|||||||
"title": "ラジオ局を作成",
|
"title": "ラジオ局を作成",
|
||||||
"input_homepageUrl": "ホームページ URL",
|
"input_homepageUrl": "ホームページ URL",
|
||||||
"input_name": "名前",
|
"input_name": "名前",
|
||||||
"input_streamUrl": "ストリームURL"
|
"input_streamUrl": "ストリーム URL"
|
||||||
},
|
},
|
||||||
"lyricsExport": {
|
"lyricsExport": {
|
||||||
"export": "歌詞をエクスポート",
|
"export": "歌詞をエクスポート",
|
||||||
@@ -1125,12 +1143,12 @@
|
|||||||
"audiobook": "オーディオブック",
|
"audiobook": "オーディオブック",
|
||||||
"audioDrama": "オーディオドラマ",
|
"audioDrama": "オーディオドラマ",
|
||||||
"compilation": "コンピレーション",
|
"compilation": "コンピレーション",
|
||||||
"djMix": "DJミックス",
|
"djMix": "DJ ミックス",
|
||||||
"demo": "デモ",
|
"demo": "デモ",
|
||||||
"soundtrack": "サウンドトラック",
|
"soundtrack": "サウンドトラック",
|
||||||
"fieldRecording": "フィールドレコーディング",
|
"fieldRecording": "フィールドレコーディング",
|
||||||
"interview": "インタビュー",
|
"interview": "インタビュー",
|
||||||
"live": "生で",
|
"live": "ライブ",
|
||||||
"mixtape": "ミックステープ",
|
"mixtape": "ミックステープ",
|
||||||
"remix": "リミックス",
|
"remix": "リミックス",
|
||||||
"spokenWord": "スポークン・ワード"
|
"spokenWord": "スポークン・ワード"
|
||||||
@@ -1184,20 +1202,20 @@
|
|||||||
"updatePreset": "プリセットを更新",
|
"updatePreset": "プリセットを更新",
|
||||||
"copyConfiguration": "設定をコピーする",
|
"copyConfiguration": "設定をコピーする",
|
||||||
"pasteConfiguration": "設定を貼り付け",
|
"pasteConfiguration": "設定を貼り付け",
|
||||||
"pasteConfigurationPlaceholder": "ここにJSON設定を貼り付けてください...",
|
"pasteConfigurationPlaceholder": "ここに JSON 設定を貼り付けてください...",
|
||||||
"pasteFromClipboard": "クリップボードから貼り付け",
|
"pasteFromClipboard": "クリップボードから貼り付け",
|
||||||
"applyConfiguration": "設定を適用",
|
"applyConfiguration": "設定を適用",
|
||||||
"configCopied": "設定をクリップボードにコピーしました",
|
"configCopied": "設定をクリップボードにコピーしました",
|
||||||
"configCopyFailed": "設定のコピーに失敗しました",
|
"configCopyFailed": "設定のコピーに失敗しました",
|
||||||
"configPasted": "加えられた構成 首尾よく",
|
"configPasted": "設定が正常に適用されました",
|
||||||
"configPasteFailed": "設定の適用に失敗しました。形式を確認してください。",
|
"configPasteFailed": "設定の適用に失敗しました。形式を確認してください。",
|
||||||
"configPasteReadFailed": "クリップボードからの読み取りに失敗しました",
|
"configPasteReadFailed": "クリップボードからの読み取りに失敗しました",
|
||||||
"presetName": "プリセット名",
|
"presetName": "プリセット名",
|
||||||
"presetNamePlaceholder": "プリセット名を入力",
|
"presetNamePlaceholder": "プリセット名を入力",
|
||||||
"general": "一将",
|
"general": "全般",
|
||||||
"mode": "モード",
|
"mode": "モード",
|
||||||
"mode1To8": "モード1~8",
|
"mode1To8": "モード 1 - 8",
|
||||||
"mode10": "モード10",
|
"mode10": "モード 10",
|
||||||
"barSpace": "バースペース",
|
"barSpace": "バースペース",
|
||||||
"lineWidth": "線幅",
|
"lineWidth": "線幅",
|
||||||
"fillAlpha": "アルファ塗りつぶしを設定",
|
"fillAlpha": "アルファ塗りつぶしを設定",
|
||||||
@@ -1216,15 +1234,15 @@
|
|||||||
"level": "レベル",
|
"level": "レベル",
|
||||||
"remove": "取り除く",
|
"remove": "取り除く",
|
||||||
"pasteGradient": "グラデーションを貼り付け",
|
"pasteGradient": "グラデーションを貼り付け",
|
||||||
"pasteGradientPlaceholder": "グラデーションのJSONをここに貼り付けてください...",
|
"pasteGradientPlaceholder": "グラデーションの JSON をここに貼り付けてください...",
|
||||||
"custom": "カスタム",
|
"custom": "カスタム",
|
||||||
"builtIn": "組み込み",
|
"builtIn": "組み込み",
|
||||||
"colorMode": "カラーモード",
|
"colorMode": "カラーモード",
|
||||||
"gradient": "勾配",
|
"gradient": "勾配",
|
||||||
"gradientLeft": "左グラデーション",
|
"gradientLeft": "左へのグラデーション",
|
||||||
"gradientRight": "右方向のグラデーション",
|
"gradientRight": "右へのグラデーション",
|
||||||
"fft": "高速フーリエ変換",
|
"fft": "高速フーリエ変換",
|
||||||
"fftSize": "FFTサイズ",
|
"fftSize": "FFT サイズ",
|
||||||
"smoothing": "平滑化",
|
"smoothing": "平滑化",
|
||||||
"frequencyRangeAndScaling": "周波数範囲とスケーリング",
|
"frequencyRangeAndScaling": "周波数範囲とスケーリング",
|
||||||
"minimumFrequency": "最小周波数",
|
"minimumFrequency": "最小周波数",
|
||||||
@@ -1256,30 +1274,30 @@
|
|||||||
"mirror": "鏡",
|
"mirror": "鏡",
|
||||||
"miscellaneousSettings": "その他の設定",
|
"miscellaneousSettings": "その他の設定",
|
||||||
"alphaBars": "アルファバー",
|
"alphaBars": "アルファバー",
|
||||||
"ansiBands": "ANSIバンド",
|
"ansiBands": "ANSI バンド",
|
||||||
"ledBars": "LEDバー",
|
"ledBars": "LED バー",
|
||||||
"trueLeds": "真のLED",
|
"trueLeds": "真の LED",
|
||||||
"lumiBars": "ルミ・バー",
|
"lumiBars": "ルミ・バー",
|
||||||
"outlineBars": "アウトラインバー",
|
"outlineBars": "アウトラインバー",
|
||||||
"roundBars": "丸棒",
|
"roundBars": "丸棒",
|
||||||
"lowResolution": "低解像度",
|
"lowResolution": "低解像度",
|
||||||
"splitGradient": "分割グラデーション",
|
"splitGradient": "分割グラデーション",
|
||||||
"showFPS": "FPSを表示",
|
"showFPS": "FPS を表示",
|
||||||
"showScaleX": "X軸スケールを表示",
|
"showScaleX": "X 軸スケールを表示",
|
||||||
"noteLabels": "注釈ラベル",
|
"noteLabels": "注釈ラベル",
|
||||||
"showScaleY": "Y軸スケールを表示",
|
"showScaleY": "Y 軸スケールを表示",
|
||||||
"options": {
|
"options": {
|
||||||
"mode": {
|
"mode": {
|
||||||
"0": "[0] 離散周波数",
|
"0": "[0] 離散周波数",
|
||||||
"1": "[1] 1/24オクターブ / 240バンド",
|
"1": "[1] 1/24 オクターブ / 240 バンド",
|
||||||
"2": "[2] 1/12オクターブ / 120バンド",
|
"2": "[2] 1/12 オクターブ / 120 バンド",
|
||||||
"3": "[3] 1/8オクターブ / 80バンド",
|
"3": "[3] 1/8 オクターブ / 80 バンド",
|
||||||
"4": "[4] 1/6オクターブ / 60バンド",
|
"4": "[4] 1/6 オクターブ / 60 バンド",
|
||||||
"5": "[5] 1/4オクターブ / 40バンド",
|
"5": "[5] 1/4 オクターブ / 40 バンド",
|
||||||
"6": "[6] 1/3オクターブ / 30バンド",
|
"6": "[6] 1/3 オクターブ / 30 バンド",
|
||||||
"7": "[7] 半オクターブ / 20バンド",
|
"7": "[7] 半オクターブ / 20 バンド",
|
||||||
"8": "[8] フルオクターブ / 10バンド",
|
"8": "[8] フルオクターブ / 10 バンド",
|
||||||
"10": "[10] 折れ線グラフ/面グラフ"
|
"10": "[10] 折れ線グラフ / 面グラフ"
|
||||||
},
|
},
|
||||||
"colorMode": {
|
"colorMode": {
|
||||||
"gradient": "勾配",
|
"gradient": "勾配",
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"action": {
|
"action": {
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Åpne i Last.fm",
|
"lastfm": "Åpne i Last.fm",
|
||||||
"musicbrainz": "Åpne i MusicBrainz"
|
"musicbrainz": "Åpne i MusicBrainz",
|
||||||
|
"spotify": "Åpne i Spotify"
|
||||||
},
|
},
|
||||||
"moveToBottom": "flytt til bunnen",
|
"moveToBottom": "flytt til bunnen",
|
||||||
"deletePlaylist": "slett $t(entity.playlist, {\"count\": 1})",
|
"deletePlaylist": "slett $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -159,7 +160,8 @@
|
|||||||
"gridRows": "rutenettrader",
|
"gridRows": "rutenettrader",
|
||||||
"tableColumns": "tabellkolonner",
|
"tableColumns": "tabellkolonner",
|
||||||
"itemsMore": "{{count}} fler",
|
"itemsMore": "{{count}} fler",
|
||||||
"explicitStatus": "grovhetsstatus"
|
"explicitStatus": "grovhetsstatus",
|
||||||
|
"newVersionAvailable": "en ny version er tilgjengelig"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"smartPlaylist": "smart $t(entity.playlist, {\"count\": 1})",
|
"smartPlaylist": "smart $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -233,7 +235,8 @@
|
|||||||
"saveQueueFailed": "kunne ikke lagre kø",
|
"saveQueueFailed": "kunne ikke lagre kø",
|
||||||
"multipleServerSaveQueueError": "Spillekøen har en eller flere sanger som ikke finnes på gjeldene tjener. Dette er ikke støttet",
|
"multipleServerSaveQueueError": "Spillekøen har en eller flere sanger som ikke finnes på gjeldene tjener. Dette er ikke støttet",
|
||||||
"serverLockSingleServer": "kun én tjener er tillatt når tjener er låst",
|
"serverLockSingleServer": "kun én tjener er tillatt når tjener er låst",
|
||||||
"settingsSyncError": "avvik ble funnet mellom innstillinger i avspilleren og hovedprosessen. ta en omstart av applikasjonen for å aktivere endringene"
|
"settingsSyncError": "avvik ble funnet mellom innstillinger i avspilleren og hovedprosessen. ta en omstart av applikasjonen for å aktivere endringene",
|
||||||
|
"playbackPausedDueToError": "avspilling ble paused på grunn av en feil"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"bpm": "bpm",
|
"bpm": "bpm",
|
||||||
@@ -319,7 +322,8 @@
|
|||||||
"success": "la $t(entity.trackWithCount, {\"count\": {{message}} }) til $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
"success": "la $t(entity.trackWithCount, {\"count\": {{message}} }) til $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||||
"title": "legg til i $t(entity.playlist, {\"count\": 1})",
|
"title": "legg til i $t(entity.playlist, {\"count\": 1})",
|
||||||
"input_skipDuplicates": "hopp over duplikater",
|
"input_skipDuplicates": "hopp over duplikater",
|
||||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})"
|
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||||
|
"searchOrCreate": "søk $t(entity.playlist, {\"count\": 2}) eller skriv for å opprette en"
|
||||||
},
|
},
|
||||||
"deletePlaylist": {
|
"deletePlaylist": {
|
||||||
"title": "slett $t(entity.playlist, {\"count\": 1})",
|
"title": "slett $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -328,7 +332,8 @@
|
|||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "rediger $t(entity.playlist, {\"count\": 1})",
|
"title": "rediger $t(entity.playlist, {\"count\": 1})",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) er oppdatert"
|
"success": "$t(entity.playlist, {\"count\": 1}) er oppdatert",
|
||||||
|
"publicJellyfinNote": "Jellyfin av en grunn kan ikke oppgi om en spilleliste er offentlig eller ikke. Hvis du ønsker at denne skal beholdes offentlig, vennligst ha følgende inndata valgt"
|
||||||
},
|
},
|
||||||
"shareItem": {
|
"shareItem": {
|
||||||
"allowDownloading": "tillat nedlasting",
|
"allowDownloading": "tillat nedlasting",
|
||||||
@@ -336,7 +341,9 @@
|
|||||||
"createFailed": "opprettelse av delt ressurs feilet (er deling aktivert?)",
|
"createFailed": "opprettelse av delt ressurs feilet (er deling aktivert?)",
|
||||||
"setExpiration": "angi utløpstid",
|
"setExpiration": "angi utløpstid",
|
||||||
"success": "del lenke som er kopiert til utklippstavlen (eller klikk her for å åpne)",
|
"success": "del lenke som er kopiert til utklippstavlen (eller klikk her for å åpne)",
|
||||||
"expireInvalid": "utløpstid må være et fremtidig tidspunkt"
|
"expireInvalid": "utløpstid må være et fremtidig tidspunkt",
|
||||||
|
"copyToClipboard": "Kopier til kopitavle: Ctrl+C, Enter",
|
||||||
|
"successMustClick": "opprettet deling. trykk her for å åpne"
|
||||||
},
|
},
|
||||||
"updateServer": {
|
"updateServer": {
|
||||||
"success": "vellykket oppdatering av serveren",
|
"success": "vellykket oppdatering av serveren",
|
||||||
@@ -367,7 +374,19 @@
|
|||||||
},
|
},
|
||||||
"lyricsExport": {
|
"lyricsExport": {
|
||||||
"export": "eksporter sangtekster",
|
"export": "eksporter sangtekster",
|
||||||
"input_synced": "eksporter sunkroniserte sangtekster"
|
"input_synced": "eksporter sunkroniserte sangtekster",
|
||||||
|
"input_offset": "$t(setting.lyricOffset)"
|
||||||
|
},
|
||||||
|
"shuffleAll": {
|
||||||
|
"title": "spill av tilfeldig",
|
||||||
|
"input_genre": "$t(entity.genre, {\"count\": 1})",
|
||||||
|
"input_limit": "hvor mange sanger?",
|
||||||
|
"input_minYear": "fra år",
|
||||||
|
"input_maxYear": "til år",
|
||||||
|
"input_played": "avspillingsfilter",
|
||||||
|
"input_played_optionAll": "alle sanger",
|
||||||
|
"input_played_optionUnplayed": "bare uavspilte sanger",
|
||||||
|
"input_played_optionPlayed": "bare avspilte sanger"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
@@ -512,7 +531,8 @@
|
|||||||
"advanced": "avansert",
|
"advanced": "avansert",
|
||||||
"hotkeysTab": "hurtigtaster",
|
"hotkeysTab": "hurtigtaster",
|
||||||
"playbackTab": "avspilling",
|
"playbackTab": "avspilling",
|
||||||
"windowTab": "vindu"
|
"windowTab": "vindu",
|
||||||
|
"theme": "tema"
|
||||||
},
|
},
|
||||||
"playlistList": {
|
"playlistList": {
|
||||||
"title": "$t(entity.playlist, {\"count\": 2})"
|
"title": "$t(entity.playlist, {\"count\": 2})"
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
"clearQueue": "verwijder lijst",
|
"clearQueue": "verwijder lijst",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Open in Last.fm",
|
"lastfm": "Open in Last.fm",
|
||||||
"musicbrainz": "Open in MusicBrainz"
|
"musicbrainz": "Open in MusicBrainz",
|
||||||
|
"listenbrainz": "Openen in ListenBrainz",
|
||||||
|
"qobuz": "Openen in Qobuz",
|
||||||
|
"spotify": "Openen in Spotify"
|
||||||
},
|
},
|
||||||
"moveToNext": "ga naar volgende",
|
"moveToNext": "ga naar volgende",
|
||||||
"downloadStarted": "begonnen met downloaden van {{count}} items",
|
"downloadStarted": "begonnen met downloaden van {{count}} items",
|
||||||
@@ -37,7 +40,8 @@
|
|||||||
"moveDown": "verplaats omlaag",
|
"moveDown": "verplaats omlaag",
|
||||||
"holdToMoveToTop": "ingedrukt houden om naar begin te verplaatsen",
|
"holdToMoveToTop": "ingedrukt houden om naar begin te verplaatsen",
|
||||||
"holdToMoveToBottom": "ingedrukt houden om naar einde te verplaatsen",
|
"holdToMoveToBottom": "ingedrukt houden om naar einde te verplaatsen",
|
||||||
"openApplicationDirectory": "applicatiemap openen"
|
"openApplicationDirectory": "applicatiemap openen",
|
||||||
|
"goToCurrent": "ga naar huidige item"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"backward": "achteruit",
|
"backward": "achteruit",
|
||||||
@@ -130,6 +134,7 @@
|
|||||||
"bitDepth": "bitdiepte",
|
"bitDepth": "bitdiepte",
|
||||||
"codec": "codec",
|
"codec": "codec",
|
||||||
"mbid": "MusicBrainz ID",
|
"mbid": "MusicBrainz ID",
|
||||||
|
"grouping": "groepering",
|
||||||
"share": "deel",
|
"share": "deel",
|
||||||
"explicit": "expliciet",
|
"explicit": "expliciet",
|
||||||
"sampleRate": "sample rate",
|
"sampleRate": "sample rate",
|
||||||
@@ -159,7 +164,9 @@
|
|||||||
"retry": "opnieuw proberen",
|
"retry": "opnieuw proberen",
|
||||||
"filter_single": "single",
|
"filter_single": "single",
|
||||||
"rename": "hernoemen",
|
"rename": "hernoemen",
|
||||||
"filter_multiple": "meerdere"
|
"filter_multiple": "meerdere",
|
||||||
|
"numberOfResults": "{{numberOfResults}} resultaten",
|
||||||
|
"newVersionAvailable": "een nieuwe versie is beschikbaar"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"rating": "rating",
|
"rating": "rating",
|
||||||
@@ -452,7 +459,8 @@
|
|||||||
"saveQueueFailed": "kan wachtrij niet opslaan",
|
"saveQueueFailed": "kan wachtrij niet opslaan",
|
||||||
"settingsSyncError": "Er zijn verschillen gevonden tussen de instellingen in de renderer en het hoofdproces. Start de applicatie opnieuw op om de wijzigingen toe te passen",
|
"settingsSyncError": "Er zijn verschillen gevonden tussen de instellingen in de renderer en het hoofdproces. Start de applicatie opnieuw op om de wijzigingen toe te passen",
|
||||||
"invalidJson": "ongeldige JSON",
|
"invalidJson": "ongeldige JSON",
|
||||||
"serverLockSingleServer": "slechts één server is toegestaan als server op slot is gezet"
|
"serverLockSingleServer": "slechts één server is toegestaan als server op slot is gezet",
|
||||||
|
"playbackPausedDueToError": "afspelen gepauzeerd vanwege een fout"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"genre_one": "genre",
|
"genre_one": "genre",
|
||||||
@@ -561,7 +569,8 @@
|
|||||||
"title": "$t(common.title)",
|
"title": "$t(common.title)",
|
||||||
"titleArtist": "$t(common.title) (artiest)",
|
"titleArtist": "$t(common.title) (artiest)",
|
||||||
"titleCombined": "$t(common.title) (gecombineerd)",
|
"titleCombined": "$t(common.title) (gecombineerd)",
|
||||||
"year": "$t(common.year)"
|
"year": "$t(common.year)",
|
||||||
|
"albumGroup": "albumgroep"
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"advancedSettings": "geavanceerde instellingen",
|
"advancedSettings": "geavanceerde instellingen",
|
||||||
@@ -954,7 +963,29 @@
|
|||||||
"automaticUpdates_description": "Zoek en installeer updates automatisch",
|
"automaticUpdates_description": "Zoek en installeer updates automatisch",
|
||||||
"releaseChannel_optionAlpha": "alfa (nachtelijk)",
|
"releaseChannel_optionAlpha": "alfa (nachtelijk)",
|
||||||
"discordStateIcon": "toon afspeelicoon",
|
"discordStateIcon": "toon afspeelicoon",
|
||||||
"discordStateIcon_description": "toon een klein afspeelicoon in de rich-presence-status. het gepauzeerde icoon wordt altijd getoond als \"rich presence tonen wanneer gepauzeerd\" is ingeschakeld"
|
"discordStateIcon_description": "toon een klein afspeelicoon in de rich-presence-status. het gepauzeerde icoon wordt altijd getoond als \"rich presence tonen wanneer gepauzeerd\" is ingeschakeld",
|
||||||
|
"autosave": "afspeelwachtrij automatisch opslaan",
|
||||||
|
"autosave_description": "schakel in dat de afspeelwachtrij automatisch wordt opgeslagen op je server. dit is enkel mogelijk bij gebruik van Navidrome/Subsonic met een niet-gemengde afspeelwachtrij.",
|
||||||
|
"autosaveCount": "frequentie automatisch opslaan afspeelwachtrij",
|
||||||
|
"autosaveCount_description": "aantal nummerwisselingen voordat de wachtrij wordt opgeslagen. bij 1 (het minimum) treedt dit bij elke nummerwissel op",
|
||||||
|
"useThemePrimaryShade": "gebruikt primaire tint van thema",
|
||||||
|
"useThemePrimaryShade_description": "gebruik de primaire tint die in het geselecteerde thema is ingesteld voor hoofdkleurvarianten",
|
||||||
|
"primaryShade": "primaire tint",
|
||||||
|
"primaryShade_description": "overschrijf de primaire tint (0-9) die wordt gebruikt voor knoppen, links en andere elementen die de hoofdkleur gebruiken",
|
||||||
|
"listenbrainz_description": "toon links naar ListenBrainz op artiest- en albumpagina's",
|
||||||
|
"listenbrainz": "toon ListenBrainz-links",
|
||||||
|
"qobuz_description": "toon links naar Qobuz op artiest- en albumpagina's",
|
||||||
|
"qobuz": "toon Qobuz-links",
|
||||||
|
"spotify_description": "toon links naar Spotify op artiest- en albumpagina's",
|
||||||
|
"spotify": "toon Spotify-links",
|
||||||
|
"nativeSpotify_description": "open de Spotify app in plaats van de webbrowser",
|
||||||
|
"nativeSpotify": "gebruik Spotify app",
|
||||||
|
"playerItemConfiguration_description": "stel in welke items en in welke volgorde te tonen in de volledigschermspeler",
|
||||||
|
"playerItemConfiguration": "configuratie van afspeel-item",
|
||||||
|
"sidePlayQueueLayout": "uitlijning afspeelwachtrij aan zijkant",
|
||||||
|
"sidePlayQueueLayout_description": "stel de uitlijning in voor de afspeelwachtrij die aan de zijkant is gekoppeld",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "horizontaal",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "verticaal"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"addServer": {
|
"addServer": {
|
||||||
@@ -1013,8 +1044,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "$t(entity.playlist, {\"count\": 1}) aanpassen",
|
"title": "$t(entity.playlist, {\"count\": 1}) aanpassen",
|
||||||
"publicJellyfinNote": "Jellyfin laat niet weten of een playlist publiek of privaat is. Als u wilt dat dit publiek blijft, selecteer de volgende invoer",
|
"publicJellyfinNote": "Jellyfin laat niet weten of een playlist publiek of privaat is. Als u wilt dat dit publiek blijft, selecteer de volgende invoer",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) succesvol geüpdatet",
|
"success": "$t(entity.playlist, {\"count\": 1}) succesvol geüpdatet"
|
||||||
"editNote": "Handmatige bewerking wordt afgeraden voor grote afspeellijsten. Weet je zeker dat je het risico op dataverlies wilt accepteren door de bestaande afspeellijst te overschrijven?"
|
|
||||||
},
|
},
|
||||||
"updateServer": {
|
"updateServer": {
|
||||||
"title": "update server",
|
"title": "update server",
|
||||||
@@ -1114,7 +1144,8 @@
|
|||||||
"sleepTimer_off": "uit",
|
"sleepTimer_off": "uit",
|
||||||
"sleepTimer_timeRemaining": "{{time}} resterend",
|
"sleepTimer_timeRemaining": "{{time}} resterend",
|
||||||
"sleepTimer_setCustom": "timer instellen",
|
"sleepTimer_setCustom": "timer instellen",
|
||||||
"sleepTimer_cancel": "timer annuleren"
|
"sleepTimer_cancel": "timer annuleren",
|
||||||
|
"albumRadio": "albumradio"
|
||||||
},
|
},
|
||||||
"datetime": {
|
"datetime": {
|
||||||
"minuteShort": "m",
|
"minuteShort": "m",
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
"setRating": "oceń",
|
"setRating": "oceń",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "Otwórz w Last.fm",
|
"lastfm": "Otwórz w Last.fm",
|
||||||
"musicbrainz": "Otwórz w MusicBrainz"
|
"musicbrainz": "Otwórz w MusicBrainz",
|
||||||
|
"listenbrainz": "Otwórz w ListenBrainz",
|
||||||
|
"qobuz": "Otwórz w Qobuz",
|
||||||
|
"spotify": "Otwórz w Spotify"
|
||||||
},
|
},
|
||||||
"moveToNext": "przesuń na następne",
|
"moveToNext": "przesuń na następne",
|
||||||
"downloadStarted": "rozpoczęto pobieranie {{count}} elementów",
|
"downloadStarted": "rozpoczęto pobieranie {{count}} elementów",
|
||||||
@@ -165,7 +168,8 @@
|
|||||||
"filter_multiple": "multi",
|
"filter_multiple": "multi",
|
||||||
"filter_single": "single",
|
"filter_single": "single",
|
||||||
"rename": "zmień nazwę",
|
"rename": "zmień nazwę",
|
||||||
"newVersionAvailable": "nowa wersja jest dostępna"
|
"newVersionAvailable": "nowa wersja jest dostępna",
|
||||||
|
"numberOfResults": "{{numberOfResults}} wyników"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"genre_one": "gatunek",
|
"genre_one": "gatunek",
|
||||||
@@ -370,8 +374,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "edytuj $t(entity.playlist, {\"count\": 1})",
|
"title": "edytuj $t(entity.playlist, {\"count\": 1})",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) zaktualizowana pomyślnie",
|
"success": "$t(entity.playlist, {\"count\": 1}) zaktualizowana pomyślnie",
|
||||||
"publicJellyfinNote": "Z jakiegoś powodu Jellyfin nie udostępnia informacji na temat publiczności playlisty. Jeżeli chcesz, aby ta pozostała publiczna, mniej wybraną poniższą opcję",
|
"publicJellyfinNote": "Z jakiegoś powodu Jellyfin nie udostępnia informacji na temat publiczności playlisty. Jeżeli chcesz, aby ta pozostała publiczna, mniej wybraną poniższą opcję"
|
||||||
"editNote": "manualne edytowanie nie jest zalecane dla dużych playlist. czy na pewno zgadzasz się na ryzyko utraty danych wywołane przez nadpisanie istniejącej playlisty?"
|
|
||||||
},
|
},
|
||||||
"shareItem": {
|
"shareItem": {
|
||||||
"allowDownloading": "zezwól na pobieranie",
|
"allowDownloading": "zezwól na pobieranie",
|
||||||
@@ -1043,7 +1046,19 @@
|
|||||||
"autosave": "automatycznie zapisuj kolejkę odtwarzania",
|
"autosave": "automatycznie zapisuj kolejkę odtwarzania",
|
||||||
"autosave_description": "włącz automatyczne zapisywanie kolejki odtwarzania na twój serwer. to jest możliwe tylko gdy używane jest Navidrome/Subsonic, i nie masz zmixowanej kolejki odtwarzania.",
|
"autosave_description": "włącz automatyczne zapisywanie kolejki odtwarzania na twój serwer. to jest możliwe tylko gdy używane jest Navidrome/Subsonic, i nie masz zmixowanej kolejki odtwarzania.",
|
||||||
"autosaveCount": "częstotliwość automatycznego zapisywania kolejki odtwarzania",
|
"autosaveCount": "częstotliwość automatycznego zapisywania kolejki odtwarzania",
|
||||||
"autosaveCount_description": "ile razy piosenka zostanie zmieniona przed zapisaniem kolejki. 1 (najmniejsze) oznacza zapisywanie kolejki po każdej zmianie piosenki"
|
"autosaveCount_description": "ile razy piosenka zostanie zmieniona przed zapisaniem kolejki. 1 (najmniejsze) oznacza zapisywanie kolejki po każdej zmianie piosenki",
|
||||||
|
"listenbrainz_description": "pokaż linki do ListenBrainz na stronach wykonawców/albumów",
|
||||||
|
"listenbrainz": "pokaż linki ListenBrainz",
|
||||||
|
"qobuz_description": "pokaż linki do Qobuz na stronach wykonawców/albumów",
|
||||||
|
"qobuz": "pokaż linki Qobuz",
|
||||||
|
"spotify_description": "pokaż linki do Spotify na stronach wykonawców/albumów",
|
||||||
|
"spotify": "pokaż linki Spotify",
|
||||||
|
"nativeSpotify_description": "otwieraj w aplikacji Spotify zamiast w twojej przeglądarce",
|
||||||
|
"nativeSpotify": "używaj aplikacji Spotify",
|
||||||
|
"sidePlayQueueLayout": "układ boczny kolejki odtwarzania",
|
||||||
|
"sidePlayQueueLayout_description": "ustawia układ przyczepionej z boku kolejki odtwarzania",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "poziomy",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "pionowy"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
"size": "tamanho",
|
"size": "tamanho",
|
||||||
"note": "observação",
|
"note": "observação",
|
||||||
"mbid": "ID no MusicBrainz",
|
"mbid": "ID no MusicBrainz",
|
||||||
|
"grouping": "agrupamento",
|
||||||
"reload": "recarregar",
|
"reload": "recarregar",
|
||||||
"codec": "codec",
|
"codec": "codec",
|
||||||
"preview": "pré-visualizar",
|
"preview": "pré-visualizar",
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
"forceRestartRequired": "reinicie para aplicar as alterações… feche a notificação para reiniciar",
|
"forceRestartRequired": "reinicie para aplicar as alterações… feche a notificação para reiniciar",
|
||||||
"forward": "para frente",
|
"forward": "para frente",
|
||||||
"gap": "intervalo",
|
"gap": "intervalo",
|
||||||
|
"grouping": "agrupamento",
|
||||||
"home": "início",
|
"home": "início",
|
||||||
"increase": "incrementar",
|
"increase": "incrementar",
|
||||||
"left": "esquerda",
|
"left": "esquerda",
|
||||||
|
|||||||
@@ -126,6 +126,7 @@
|
|||||||
"note": "заметка",
|
"note": "заметка",
|
||||||
"none": "нет",
|
"none": "нет",
|
||||||
"mbid": "MusicBrainz ID",
|
"mbid": "MusicBrainz ID",
|
||||||
|
"grouping": "группировка",
|
||||||
"reload": "перезагрузить",
|
"reload": "перезагрузить",
|
||||||
"preview": "просмотр",
|
"preview": "просмотр",
|
||||||
"codec": "кодек",
|
"codec": "кодек",
|
||||||
@@ -678,8 +679,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "редактировать $t(entity.playlist, {\"count\": 1})",
|
"title": "редактировать $t(entity.playlist, {\"count\": 1})",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) обновлён успешно",
|
"success": "$t(entity.playlist, {\"count\": 1}) обновлён успешно",
|
||||||
"publicJellyfinNote": "Jellyfin по какой-то причине не предоставляет информацию о том, публичный плейлист или нет. Если вы хотите, чтобы он остался публичным, выберите следующую опцию",
|
"publicJellyfinNote": "Jellyfin по какой-то причине не предоставляет информацию о том, публичный плейлист или нет. Если вы хотите, чтобы он остался публичным, выберите следующую опцию"
|
||||||
"editNote": "редактирование больших плейлистов вручную не рекомендуется. Вы уверены, что готовы принять риск потери данных, который может возникнуть в результате перезаписи существующего плейлиста?"
|
|
||||||
},
|
},
|
||||||
"shareItem": {
|
"shareItem": {
|
||||||
"success": "ссылка скопирована в буфер обмена (нажмите здесь, чтобы открыть)",
|
"success": "ссылка скопирована в буфер обмена (нажмите здесь, чтобы открыть)",
|
||||||
|
|||||||
@@ -310,8 +310,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "திருத்து $t(entity.playlist, {\"count\": 1})",
|
"title": "திருத்து $t(entity.playlist, {\"count\": 1})",
|
||||||
"publicJellyfinNote": "சில காரணங்களால் செல்லிஃபின் ஒரு பிளேலிச்ட் பொதுவில் இல்லையா என்பதை அம்பலப்படுத்தவில்லை. இது பொதுவில் இருக்க விரும்பினால், தயவுசெய்து பின்வரும் உள்ளீட்டைத் தேர்ந்தெடுக்கவும்",
|
"publicJellyfinNote": "சில காரணங்களால் செல்லிஃபின் ஒரு பிளேலிச்ட் பொதுவில் இல்லையா என்பதை அம்பலப்படுத்தவில்லை. இது பொதுவில் இருக்க விரும்பினால், தயவுசெய்து பின்வரும் உள்ளீட்டைத் தேர்ந்தெடுக்கவும்",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) வெற்றிகரமாகப் புதுப்பிக்கப்பட்டது",
|
"success": "$t(entity.playlist, {\"count\": 1}) வெற்றிகரமாகப் புதுப்பிக்கப்பட்டது"
|
||||||
"editNote": "பெரிய பிளேலிச்ட்களுக்கு கைமுறை திருத்தங்கள் பரிந்துரைக்கப்படவில்லை. ஏற்கனவே உள்ள பிளேலிச்ட்டை மேலெழுதுவதால் ஏற்படும் தரவு இழப்பின் அபாயத்தை நிச்சயமாக ஏற்றுக்கொள்கிறீர்களா?"
|
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
"input_artist": "$t(entity.artist, {\"count\": 1})",
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
"forceRestartRequired": "перезапустіть, щоб застосувати зміни… закрийте повідомлення, щоб перезапустити",
|
"forceRestartRequired": "перезапустіть, щоб застосувати зміни… закрийте повідомлення, щоб перезапустити",
|
||||||
"forward": "уперед",
|
"forward": "уперед",
|
||||||
"gap": "прогалина",
|
"gap": "прогалина",
|
||||||
|
"grouping": "групування",
|
||||||
"home": "додому",
|
"home": "додому",
|
||||||
"increase": "збільшити",
|
"increase": "збільшити",
|
||||||
"left": "ліво",
|
"left": "ліво",
|
||||||
@@ -382,7 +383,6 @@
|
|||||||
},
|
},
|
||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"publicJellyfinNote": "Jellyfin з якоїсь причини не показує, чи є плейлист публічним чи ні. Якщо ви хочете, щоб він залишався публічним, виберіть варіант нижче",
|
"publicJellyfinNote": "Jellyfin з якоїсь причини не показує, чи є плейлист публічним чи ні. Якщо ви хочете, щоб він залишався публічним, виберіть варіант нижче",
|
||||||
"editNote": "ручне редагування не рекомендується для великих плейлистів. ви впевнені, що готові прийняти ризик втрати даних, який виникає при перезапису існуючого плейлисту?",
|
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) успішно оновлено",
|
"success": "$t(entity.playlist, {\"count\": 1}) успішно оновлено",
|
||||||
"title": "змінити $t(entity.playlist, {\"count\": 1})"
|
"title": "змінити $t(entity.playlist, {\"count\": 1})"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
"goToPage": "前往页面",
|
"goToPage": "前往页面",
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "在 Last.fm 中打开",
|
"lastfm": "在 Last.fm 中打开",
|
||||||
"musicbrainz": "在 MusicBrainz 中打开"
|
"musicbrainz": "在 MusicBrainz 中打开",
|
||||||
|
"listenbrainz": "在 ListenBrainz 中打开",
|
||||||
|
"qobuz": "在 Qobuz 中打开",
|
||||||
|
"spotify": "在 Spotify 中打开"
|
||||||
},
|
},
|
||||||
"moveToNext": "移至下一首",
|
"moveToNext": "移至下一首",
|
||||||
"downloadStarted": "开始下载 {{count}} 个项目",
|
"downloadStarted": "开始下载 {{count}} 个项目",
|
||||||
@@ -157,7 +160,8 @@
|
|||||||
"mood": "氛围",
|
"mood": "氛围",
|
||||||
"rename": "重命名",
|
"rename": "重命名",
|
||||||
"filter_multiple": "多项",
|
"filter_multiple": "多项",
|
||||||
"newVersionAvailable": "新版本现已可用"
|
"newVersionAvailable": "新版本现已可用",
|
||||||
|
"numberOfResults": "{{numberOfResults}} 结果"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"albumArtist_other": "专辑艺术家",
|
"albumArtist_other": "专辑艺术家",
|
||||||
@@ -448,7 +452,7 @@
|
|||||||
"discordServeImage": "从服务器提供 {{discord}} 图像",
|
"discordServeImage": "从服务器提供 {{discord}} 图像",
|
||||||
"discordServeImage_description": "从服务器本身分享 {{discord}} rich presence 的封面艺术,仅适用于 Jellyfin 和 Navidrome。 {{discord}} 使用机器人来获取图像,因此您的服务器必须可通过公共互联网访问",
|
"discordServeImage_description": "从服务器本身分享 {{discord}} rich presence 的封面艺术,仅适用于 Jellyfin 和 Navidrome。 {{discord}} 使用机器人来获取图像,因此您的服务器必须可通过公共互联网访问",
|
||||||
"musicbrainz": "显示 MusicBrainz 链接",
|
"musicbrainz": "显示 MusicBrainz 链接",
|
||||||
"musicbrainz_description": "在存在 MusicBrainz ID 的艺术家/专辑页面上显示 MusicBrainz 的链接",
|
"musicbrainz_description": "在艺术家/专辑页面上显示 MusicBrainz 链接(如果存在 MusicBrainz ID)",
|
||||||
"lastfm": "显示 last.fm 链接",
|
"lastfm": "显示 last.fm 链接",
|
||||||
"lastfm_description": "在艺术家/专辑页面上显示 Last.fm 的链接",
|
"lastfm_description": "在艺术家/专辑页面上显示 Last.fm 的链接",
|
||||||
"preferLocalLyrics_description": "优先选择本地歌词(如有),而不是远程歌词",
|
"preferLocalLyrics_description": "优先选择本地歌词(如有),而不是远程歌词",
|
||||||
@@ -593,7 +597,19 @@
|
|||||||
"primaryShade": "主色调",
|
"primaryShade": "主色调",
|
||||||
"primaryShade_description": "覆盖按钮、链接和其他主色元素使用的主色调(0-9)",
|
"primaryShade_description": "覆盖按钮、链接和其他主色元素使用的主色调(0-9)",
|
||||||
"playerItemConfiguration_description": "配置全屏播放器上显示的项目及其显示顺序",
|
"playerItemConfiguration_description": "配置全屏播放器上显示的项目及其显示顺序",
|
||||||
"playerItemConfiguration": "播放器项目配置"
|
"playerItemConfiguration": "播放器项目配置",
|
||||||
|
"listenbrainz_description": "在艺术家/专辑页面上显示 ListenBrainz 链接",
|
||||||
|
"listenbrainz": "显示 ListenBrainz 链接",
|
||||||
|
"qobuz_description": "在艺术家/专辑页面上显示 Qobuz 链接",
|
||||||
|
"qobuz": "显示 Qobuz 链接",
|
||||||
|
"spotify_description": "在艺术家/专辑页面上显示 Spotify 链接",
|
||||||
|
"spotify": "显示 Spotify 链接",
|
||||||
|
"nativeSpotify_description": "在 Spotify 应用中打开,而不是在浏览器中打开",
|
||||||
|
"nativeSpotify": "使用 Spotify 应用",
|
||||||
|
"sidePlayQueueLayout": "侧边播放队列布局",
|
||||||
|
"sidePlayQueueLayout_description": "设置附加侧边播放队列的布局",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "水平",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "垂直"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "重启服务器使新端口生效",
|
"remotePortWarning": "重启服务器使新端口生效",
|
||||||
@@ -945,8 +961,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "编辑$t(entity.playlist, {\"count\": 1})",
|
"title": "编辑$t(entity.playlist, {\"count\": 1})",
|
||||||
"publicJellyfinNote": "Jellyfin 出于某种原因不会显示播放列表是否公开。如果您希望保持公开,请选择以下输入",
|
"publicJellyfinNote": "Jellyfin 出于某种原因不会显示播放列表是否公开。如果您希望保持公开,请选择以下输入",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1})更新成功",
|
"success": "$t(entity.playlist, {\"count\": 1})更新成功"
|
||||||
"editNote": "不建议对大型播放列表进行手动编辑,你确定接受新播放列表覆盖已有播放列表可能导致的数据丢失风险吗?"
|
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"title": "搜索歌词",
|
"title": "搜索歌词",
|
||||||
|
|||||||
@@ -116,7 +116,9 @@
|
|||||||
"itemsMore": "{{count}} 更多",
|
"itemsMore": "{{count}} 更多",
|
||||||
"filter_single": "單選",
|
"filter_single": "單選",
|
||||||
"filter_multiple": "複選",
|
"filter_multiple": "複選",
|
||||||
"newVersionAvailable": "有新的版本可供使用"
|
"newVersionAvailable": "有新的版本可供使用",
|
||||||
|
"numberOfResults": "{{numberOfResults}} 項結果",
|
||||||
|
"grouping": "分組"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
|
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
|
||||||
@@ -777,10 +779,20 @@
|
|||||||
"autosave_description": "啟用自動將播放佇列儲存到您的伺服器。這只有在使用Navidrome/Subsonic時才可使用,並且您不能有混合播放佇列。",
|
"autosave_description": "啟用自動將播放佇列儲存到您的伺服器。這只有在使用Navidrome/Subsonic時才可使用,並且您不能有混合播放佇列。",
|
||||||
"autosaveCount": "自動播放佇列儲存頻率",
|
"autosaveCount": "自動播放佇列儲存頻率",
|
||||||
"autosaveCount_description": "在儲存佇列之前,有多少曲目更改。1(最小)表示每次歌曲更改",
|
"autosaveCount_description": "在儲存佇列之前,有多少曲目更改。1(最小)表示每次歌曲更改",
|
||||||
"spotify_description": "在藝人與專輯頁面顯示Spotify的連結",
|
"spotify_description": "在藝人與專輯頁面顯示 Spotify 的連結",
|
||||||
"spotify": "顯示Spotify的連結",
|
"spotify": "顯示 Spotify 的連結",
|
||||||
"nativeSpotify_description": "在Spotify應用程式中開啟,而非在瀏覽器中開啟",
|
"nativeSpotify_description": "在 Spotify 應用程式中開啟,而非在瀏覽器中開啟",
|
||||||
"nativeSpotify": "使用Spotify應用程式"
|
"nativeSpotify": "使用 Spotify 應用程式",
|
||||||
|
"sidePlayQueueLayout": "側邊播放佇列佈局",
|
||||||
|
"sidePlayQueueLayout_description": "設定吸附側邊播放佇列的佈局",
|
||||||
|
"sidePlayQueueLayout_optionHorizontal": "水平",
|
||||||
|
"sidePlayQueueLayout_optionVertical": "垂直",
|
||||||
|
"listenbrainz_description": "在藝術家/專輯頁面上顯示 ListenBrainz 的連結",
|
||||||
|
"listenbrainz": "顯示 ListenBrainz 連結",
|
||||||
|
"qobuz_description": "在藝術家/專輯頁面上顯示 Qobuz 的連結",
|
||||||
|
"qobuz": "顯示 Qobuz 連結",
|
||||||
|
"waveformLoadingDelay": "波形載入延遲",
|
||||||
|
"waveformLoadingDelay_description": "載入波形前的延遲(以秒為單位)。如果您在使用網頁播放器時遇到卡頓,請增加此值。"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
@@ -913,7 +925,9 @@
|
|||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "在Last.fm開啟",
|
"lastfm": "在Last.fm開啟",
|
||||||
"musicbrainz": "在MusicBrainz開啟",
|
"musicbrainz": "在MusicBrainz開啟",
|
||||||
"spotify": "在Spotify中開啟"
|
"spotify": "在 Spotify 中開啟",
|
||||||
|
"listenbrainz": "在 ListenBrainz 中開啟",
|
||||||
|
"qobuz": "在 Qobuz 中開啟"
|
||||||
},
|
},
|
||||||
"downloadStarted": "已開始下載 {{count}} 項內容",
|
"downloadStarted": "已開始下載 {{count}} 項內容",
|
||||||
"moveItems": "移動項目",
|
"moveItems": "移動項目",
|
||||||
@@ -1064,8 +1078,7 @@
|
|||||||
"editPlaylist": {
|
"editPlaylist": {
|
||||||
"title": "編輯$t(entity.playlist, {\"count\": 1})",
|
"title": "編輯$t(entity.playlist, {\"count\": 1})",
|
||||||
"publicJellyfinNote": "Jellyfin 出於某種原因,不會顯示播放清單是否公開。如果您希望保持公開狀態,請選擇以下輸入",
|
"publicJellyfinNote": "Jellyfin 出於某種原因,不會顯示播放清單是否公開。如果您希望保持公開狀態,請選擇以下輸入",
|
||||||
"success": "$t(entity.playlist, {\"count\": 1}) 更新成功",
|
"success": "$t(entity.playlist, {\"count\": 1}) 更新成功"
|
||||||
"editNote": "不建議手動編輯大型播放清單,你確定要承擔覆寫現有播放清單可能造成的資料遺失風險嗎?"
|
|
||||||
},
|
},
|
||||||
"shareItem": {
|
"shareItem": {
|
||||||
"allowDownloading": "允許下載",
|
"allowDownloading": "允許下載",
|
||||||
|
|||||||
@@ -58,14 +58,16 @@ export async function getSearchResults(
|
|||||||
): Promise<InternetProviderLyricSearchResponse[] | null> {
|
): Promise<InternetProviderLyricSearchResponse[] | null> {
|
||||||
let result: AxiosResponse<LrcLibSearchResponse[]>;
|
let result: AxiosResponse<LrcLibSearchResponse[]>;
|
||||||
|
|
||||||
if (!params.name) {
|
if (!params.name && !params.artist) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchQuery = [params.name, params.artist].join(' ');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
result = await axios.get<LrcLibSearchResponse[]>(SEEARCH_URL, {
|
result = await axios.get<LrcLibSearchResponse[]>(SEEARCH_URL, {
|
||||||
params: {
|
params: {
|
||||||
q: params.name,
|
q: searchQuery,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+21
-18
@@ -272,11 +272,6 @@ if (isLinux() && !process.argv.some((a) => a.startsWith('--password-store='))) {
|
|||||||
app.commandLine.appendSwitch('password-store', passwordStore);
|
app.commandLine.appendSwitch('password-store', passwordStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle fractional scaling issue from Wayland https://github.com/jeffvli/feishin/issues/1271#issuecomment-4063326712
|
|
||||||
if (isLinux()) {
|
|
||||||
app.commandLine.appendSwitch('disable-features', 'WaylandFractionalScaleV1');
|
|
||||||
}
|
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let tray: null | Tray = null;
|
let tray: null | Tray = null;
|
||||||
let exitFromTray = false;
|
let exitFromTray = false;
|
||||||
@@ -436,19 +431,21 @@ const createTray = () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
tray.on('click', () => {
|
if (!isMacOS()) {
|
||||||
if (store.get('window_minimize_to_tray')) {
|
tray.on('click', () => {
|
||||||
if (mainWindow?.isVisible()) {
|
if (store.get('window_minimize_to_tray')) {
|
||||||
mainWindow?.hide();
|
if (mainWindow?.isVisible()) {
|
||||||
|
mainWindow?.hide();
|
||||||
|
} else {
|
||||||
|
mainWindow?.show();
|
||||||
|
createWinThumbarButtons();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
mainWindow?.show();
|
mainWindow?.show();
|
||||||
createWinThumbarButtons();
|
createWinThumbarButtons();
|
||||||
}
|
}
|
||||||
} else {
|
});
|
||||||
mainWindow?.show();
|
}
|
||||||
createWinThumbarButtons();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tray.setToolTip('Feishin');
|
tray.setToolTip('Feishin');
|
||||||
tray.setContextMenu(contextMenu);
|
tray.setContextMenu(contextMenu);
|
||||||
@@ -745,11 +742,17 @@ const playbackType = store.get('playbackType', PlayerType.WEB) as PlayerType;
|
|||||||
const shouldDisableMediaFeatures =
|
const shouldDisableMediaFeatures =
|
||||||
isLinux() || !enableMediaSession || playbackType !== PlayerType.WEB;
|
isLinux() || !enableMediaSession || playbackType !== PlayerType.WEB;
|
||||||
|
|
||||||
|
const chromiumDisabledFeatures: string[] = [];
|
||||||
|
// Fractional scaling on Wayland: https://github.com/jeffvli/feishin/issues/1271#issuecomment-4063326712
|
||||||
|
if (isLinux()) {
|
||||||
|
chromiumDisabledFeatures.push('WaylandFractionalScaleV1');
|
||||||
|
}
|
||||||
if (shouldDisableMediaFeatures) {
|
if (shouldDisableMediaFeatures) {
|
||||||
app.commandLine.appendSwitch(
|
chromiumDisabledFeatures.push('HardwareMediaKeyHandling', 'MediaSessionService');
|
||||||
'disable-features',
|
}
|
||||||
'HardwareMediaKeyHandling,MediaSessionService',
|
|
||||||
);
|
if (chromiumDisabledFeatures.length > 0) {
|
||||||
|
app.commandLine.appendSwitch('disable-features', chromiumDisabledFeatures.join(','));
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/electron/electron/issues/46538#issuecomment-2808806722
|
// https://github.com/electron/electron/issues/46538#issuecomment-2808806722
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
ControllerEndpoint,
|
ControllerEndpoint,
|
||||||
InternalControllerEndpoint,
|
InternalControllerEndpoint,
|
||||||
ServerType,
|
ServerType,
|
||||||
|
SetPlaylistSongsArgs,
|
||||||
|
SetPlaylistSongsResponse,
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
type ApiController = {
|
type ApiController = {
|
||||||
@@ -67,6 +69,7 @@ const getPathReplaceSettings = () => {
|
|||||||
|
|
||||||
const addContext = <T extends { apiClientProps: any; context?: any }>(args: T): T => {
|
const addContext = <T extends { apiClientProps: any; context?: any }>(args: T): T => {
|
||||||
const pathSettings = getPathReplaceSettings();
|
const pathSettings = getPathReplaceSettings();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...args,
|
...args,
|
||||||
context: {
|
context: {
|
||||||
@@ -717,7 +720,9 @@ export const controller: GeneralController = {
|
|||||||
const server = getServerById(args.apiClientProps.serverId);
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return '';
|
throw new Error(
|
||||||
|
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getStreamUrl`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiController(
|
return apiController(
|
||||||
@@ -885,6 +890,20 @@ export const controller: GeneralController = {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
setPlaylistSongs: function (args: SetPlaylistSongsArgs): Promise<SetPlaylistSongsResponse> {
|
||||||
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new Error(
|
||||||
|
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: setPlaylistSongs`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiController(
|
||||||
|
'setPlaylistSongs',
|
||||||
|
server.type,
|
||||||
|
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||||
|
},
|
||||||
setRating(args) {
|
setRating(args) {
|
||||||
const server = getServerById(args.apiClientProps.serverId);
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
|||||||
@@ -1283,7 +1283,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
apiClientProps,
|
apiClientProps,
|
||||||
query: { ...query, limit: 1, startIndex: 0 },
|
query: { ...query, limit: 1, startIndex: 0 },
|
||||||
}).then((result) => result!.totalRecordCount!),
|
}).then((result) => result!.totalRecordCount!),
|
||||||
getStreamUrl: ({ apiClientProps: { server }, query }) => {
|
getStreamUrl: async ({ apiClientProps: { server }, query }) => {
|
||||||
const { bitrate, format, id, transcode } = query;
|
const { bitrate, format, id, transcode } = query;
|
||||||
const deviceId = '';
|
const deviceId = '';
|
||||||
|
|
||||||
@@ -1769,6 +1769,24 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
setPlaylistSongs: async (args) => {
|
||||||
|
const { apiClientProps, body } = args;
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).updatePlaylist({
|
||||||
|
body: {
|
||||||
|
Ids: body.songIds,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
id: body.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 204) {
|
||||||
|
throw new Error('Failed to update playlist songs');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
updateInternetRadioStation: async (args) => {
|
updateInternetRadioStation: async (args) => {
|
||||||
const { apiClientProps, body, query } = args;
|
const { apiClientProps, body, query } = args;
|
||||||
|
|
||||||
@@ -1798,14 +1816,8 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
|
|
||||||
const res = await jfApiClient(apiClientProps).updatePlaylist({
|
const res = await jfApiClient(apiClientProps).updatePlaylist({
|
||||||
body: {
|
body: {
|
||||||
Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [],
|
|
||||||
IsPublic: body.public,
|
IsPublic: body.public,
|
||||||
MediaType: 'Audio',
|
|
||||||
Name: body.name,
|
Name: body.name,
|
||||||
PremiereDate: null,
|
|
||||||
ProviderIds: {},
|
|
||||||
Tags: [],
|
|
||||||
UserId: apiClientProps.server?.userId, // Required
|
|
||||||
},
|
},
|
||||||
params: {
|
params: {
|
||||||
id: query.id,
|
id: query.id,
|
||||||
@@ -1820,31 +1832,6 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// const getArtistList = async (args: ArtistListArgs): Promise<AlbumArtistListResponse> => {
|
|
||||||
// const { query, apiClientProps } = args;
|
|
||||||
|
|
||||||
// const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
|
||||||
// query: {
|
|
||||||
// Limit: query.limit,
|
|
||||||
// ParentId: query.musicFolderId,
|
|
||||||
// Recursive: true,
|
|
||||||
// SortBy: artistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',
|
|
||||||
// SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
|
||||||
// StartIndex: query.startIndex,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
|
|
||||||
// if (res.status !== 200) {
|
|
||||||
// throw new Error('Failed to get artist list');
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return {
|
|
||||||
// items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
|
|
||||||
// startIndex: query.startIndex,
|
|
||||||
// totalRecordCount: res.body.TotalRecordCount,
|
|
||||||
// };
|
|
||||||
// };
|
|
||||||
|
|
||||||
function getLibraryId(musicFolderId?: string | string[]) {
|
function getLibraryId(musicFolderId?: string | string[]) {
|
||||||
return Array.isArray(musicFolderId) ? musicFolderId[0] : musicFolderId;
|
return Array.isArray(musicFolderId) ? musicFolderId[0] : musicFolderId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -604,6 +604,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
query: {
|
query: {
|
||||||
_end: -1,
|
_end: -1,
|
||||||
_order: 'ASC',
|
_order: 'ASC',
|
||||||
|
_sort: NDSongListSort.ID,
|
||||||
_start: 0,
|
_start: 0,
|
||||||
...excludeMissing(apiClientProps.server),
|
...excludeMissing(apiClientProps.server),
|
||||||
},
|
},
|
||||||
@@ -744,7 +745,6 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
args.context?.pathReplaceWith,
|
args.context?.pathReplaceWith,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
getSongList: async (args) => {
|
getSongList: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
@@ -818,6 +818,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
totalRecordCount: albums.totalRecordCount,
|
totalRecordCount: albums.totalRecordCount,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
getSongListCount: async ({ apiClientProps, query }) =>
|
getSongListCount: async ({ apiClientProps, query }) =>
|
||||||
NavidromeController.getSongList({
|
NavidromeController.getSongList({
|
||||||
apiClientProps,
|
apiClientProps,
|
||||||
@@ -1010,6 +1011,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
query: {
|
query: {
|
||||||
_end: -1,
|
_end: -1,
|
||||||
_order: 'ASC',
|
_order: 'ASC',
|
||||||
|
_sort: NDSongListSort.ID,
|
||||||
_start: 0,
|
_start: 0,
|
||||||
...excludeMissing(apiClientProps.server),
|
...excludeMissing(apiClientProps.server),
|
||||||
},
|
},
|
||||||
@@ -1120,6 +1122,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
},
|
},
|
||||||
scrobble: SubsonicController.scrobble,
|
scrobble: SubsonicController.scrobble,
|
||||||
search: SubsonicController.search,
|
search: SubsonicController.search,
|
||||||
|
setPlaylistSongs: SubsonicController.setPlaylistSongs,
|
||||||
setRating: SubsonicController.setRating,
|
setRating: SubsonicController.setRating,
|
||||||
shareItem: async (args) => {
|
shareItem: async (args) => {
|
||||||
const { apiClientProps, body } = args;
|
const { apiClientProps, body } = args;
|
||||||
|
|||||||
@@ -347,6 +347,11 @@ export const queryKeys: Record<
|
|||||||
list: (serverId: string) => [serverId, 'roles'] as const,
|
list: (serverId: string) => [serverId, 'roles'] as const,
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
|
infiniteList: (
|
||||||
|
serverId: string,
|
||||||
|
type: 'albumArtists' | 'albums' | 'songs',
|
||||||
|
searchTerm: string,
|
||||||
|
) => [serverId, 'search', 'infiniteList', type, searchTerm] as const,
|
||||||
list: (serverId: string, query?: SearchQuery) => {
|
list: (serverId: string, query?: SearchQuery) => {
|
||||||
if (query) return [serverId, 'search', 'list', query] as const;
|
if (query) return [serverId, 'search', 'list', query] as const;
|
||||||
return [serverId, 'search', 'list'] as const;
|
return [serverId, 'search', 'list'] as const;
|
||||||
|
|||||||
@@ -250,6 +250,23 @@ export const contract = c.router({
|
|||||||
200: ssType._response.topSongsList,
|
200: ssType._response.topSongsList,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getTranscodeDecision: {
|
||||||
|
body: ssType._body.getTranscodeDecision,
|
||||||
|
method: 'POST',
|
||||||
|
path: 'getTranscodeDecision.view',
|
||||||
|
query: ssType._parameters.getTranscodeDecision,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.getTranscodeDecision,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getTranscodeStream: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'getTranscodeStream.view',
|
||||||
|
query: ssType._parameters.getTranscodeStream,
|
||||||
|
responses: {
|
||||||
|
200: z.string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
getUser: {
|
getUser: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'getUser.view',
|
path: 'getUser.view',
|
||||||
@@ -392,7 +409,7 @@ export const ssApiClient = (args: {
|
|||||||
const { server, signal, silent, url } = args;
|
const { server, signal, silent, url } = args;
|
||||||
|
|
||||||
return initClient(contract, {
|
return initClient(contract, {
|
||||||
api: async ({ headers, method, path }) => {
|
api: async ({ body, headers, method, path, rawQuery }) => {
|
||||||
let baseUrl: string | undefined;
|
let baseUrl: string | undefined;
|
||||||
const authParams: Record<string, any> = {};
|
const authParams: Record<string, any> = {};
|
||||||
|
|
||||||
@@ -423,19 +440,44 @@ export const ssApiClient = (args: {
|
|||||||
url: `${baseUrl}/${api}`,
|
url: `${baseUrl}/${api}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = {
|
const isGetTranscodeDecisionPost =
|
||||||
c: 'Feishin',
|
method === 'POST' && api === 'getTranscodeDecision.view';
|
||||||
f: 'json',
|
|
||||||
v: '1.13.0',
|
|
||||||
...authParams,
|
|
||||||
...params,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (hasFeature(server, ServerFeature.OS_FORM_POST)) {
|
if (isGetTranscodeDecisionPost && body != null) {
|
||||||
|
request.method = 'POST';
|
||||||
|
request.headers = {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
request.data = body;
|
||||||
|
request.params = {
|
||||||
|
c: 'Feishin',
|
||||||
|
f: 'json',
|
||||||
|
v: '1.13.0',
|
||||||
|
...authParams,
|
||||||
|
...(typeof rawQuery === 'object' && rawQuery !== null
|
||||||
|
? (rawQuery as Record<string, unknown>)
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
} else if (hasFeature(server, ServerFeature.OS_FORM_POST)) {
|
||||||
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||||
request.method = 'POST';
|
request.method = 'POST';
|
||||||
|
const data = {
|
||||||
|
c: 'Feishin',
|
||||||
|
f: 'json',
|
||||||
|
v: '1.13.0',
|
||||||
|
...authParams,
|
||||||
|
...params,
|
||||||
|
};
|
||||||
request.data = qs.stringify(data, { arrayFormat: 'repeat' });
|
request.data = qs.stringify(data, { arrayFormat: 'repeat' });
|
||||||
} else {
|
} else {
|
||||||
|
const data = {
|
||||||
|
c: 'Feishin',
|
||||||
|
f: 'json',
|
||||||
|
v: '1.13.0',
|
||||||
|
...authParams,
|
||||||
|
...params,
|
||||||
|
};
|
||||||
request.method = method;
|
request.method = method;
|
||||||
request.params = data;
|
request.params = data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import md5 from 'md5';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||||
|
import {
|
||||||
|
getDefaultTranscodingProfiles,
|
||||||
|
getDirectPlayProfiles,
|
||||||
|
} from '/@/renderer/features/player/components/audio-players';
|
||||||
import { randomString } from '/@/renderer/utils';
|
import { randomString } from '/@/renderer/utils';
|
||||||
|
import { logFn } from '/@/renderer/utils/logger';
|
||||||
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
|
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
|
||||||
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
|
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
|
||||||
import {
|
import {
|
||||||
@@ -87,6 +92,151 @@ const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefin
|
|||||||
const MAX_SUBSONIC_ITEMS = 500;
|
const MAX_SUBSONIC_ITEMS = 500;
|
||||||
const SUBSONIC_FAST_BATCH_SIZE = MAX_SUBSONIC_ITEMS * 10;
|
const SUBSONIC_FAST_BATCH_SIZE = MAX_SUBSONIC_ITEMS * 10;
|
||||||
|
|
||||||
|
// const TRANSCODE_DIRECT_PLAY_PROFILES = [
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['mp3'],
|
||||||
|
// containers: ['mp3'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['aac'],
|
||||||
|
// containers: ['m4a', 'mp4'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['vorbis'],
|
||||||
|
// containers: ['ogg'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['opus'],
|
||||||
|
// containers: ['ogg', 'webm'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['pcm'],
|
||||||
|
// containers: ['wav'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// audioCodecs: ['flac'],
|
||||||
|
// containers: ['flac'],
|
||||||
|
// maxAudioChannels: 2,
|
||||||
|
// protocols: ['http'],
|
||||||
|
// },
|
||||||
|
// ];
|
||||||
|
|
||||||
|
// const TRANSCODE_UNSUPPORTED_DIRECT_PLAY_PROFILES = [
|
||||||
|
// {
|
||||||
|
// containers: ["m4a", "mp4"],
|
||||||
|
// audioCodecs: ["alac"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["m4a", "mp4"],
|
||||||
|
// audioCodecs: ["ac3", "eac3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 6
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["ogg"],
|
||||||
|
// audioCodecs: ["flac", "speex"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["wav"],
|
||||||
|
// audioCodecs: ["adpcm", "gsm", "aac", "mp3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["mkv"],
|
||||||
|
// audioCodecs: ["aac", "mp3", "flac", "opus", "vorbis", "ac3", "eac3", "dts"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 8
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["avi"],
|
||||||
|
// audioCodecs: ["mp3", "ac3", "pcm", "aac"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 6
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["asf", "wma"],
|
||||||
|
// audioCodecs: ["wma", "pcm", "mp3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["caf"],
|
||||||
|
// audioCodecs: ["pcm", "aac", "alac", "mp3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 8
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["3gp"],
|
||||||
|
// audioCodecs: ["aac", "amr"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["amr"],
|
||||||
|
// audioCodecs: ["amr"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 1
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["ape"],
|
||||||
|
// audioCodecs: ["ape"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["wv"],
|
||||||
|
// audioCodecs: ["wavpack"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 2
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["ac3"],
|
||||||
|
// audioCodecs: ["ac3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 6
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["eac3"],
|
||||||
|
// audioCodecs: ["eac3"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 8
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// containers: ["dts"],
|
||||||
|
// audioCodecs: ["dts"],
|
||||||
|
// protocols: ["http"],
|
||||||
|
// maxAudioChannels: 8
|
||||||
|
// }
|
||||||
|
// ];
|
||||||
|
|
||||||
|
function appendTranscodeParams(url: string, format?: string, bitrate?: number) {
|
||||||
|
let streamUrl = url;
|
||||||
|
|
||||||
|
if (format) {
|
||||||
|
streamUrl += `&format=${format}`;
|
||||||
|
}
|
||||||
|
if (bitrate !== undefined) {
|
||||||
|
streamUrl += `&maxBitRate=${bitrate}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamUrl;
|
||||||
|
}
|
||||||
|
|
||||||
function sortAndPaginate<T>(
|
function sortAndPaginate<T>(
|
||||||
items: T[],
|
items: T[],
|
||||||
options: {
|
options: {
|
||||||
@@ -1273,6 +1423,10 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subsonicFeatures[SubsonicExtensions.TRANSCODING]) {
|
||||||
|
features.osTranscodeDecision = [1];
|
||||||
|
}
|
||||||
|
|
||||||
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
|
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
|
||||||
features.lyricsMultipleStructured = [1];
|
features.lyricsMultipleStructured = [1];
|
||||||
}
|
}
|
||||||
@@ -1801,20 +1955,81 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
|
|
||||||
return totalRecordCount;
|
return totalRecordCount;
|
||||||
},
|
},
|
||||||
getStreamUrl: ({ apiClientProps: { server }, query }) => {
|
getStreamUrl: async ({ apiClientProps, query }) => {
|
||||||
const { bitrate, format, id, transcode } = query;
|
const { server } = apiClientProps;
|
||||||
let url = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`;
|
const { bitrate, format, id, mediaType = 'song', skipAutoTranscode, transcode } = query;
|
||||||
|
|
||||||
|
const streamUrl = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`;
|
||||||
|
|
||||||
|
// If transcoding is explicitly enabled, just return the direct transcoded stream URL
|
||||||
if (transcode) {
|
if (transcode) {
|
||||||
if (format) {
|
return appendTranscodeParams(streamUrl, format, bitrate);
|
||||||
url += `&format=${format}`;
|
|
||||||
}
|
|
||||||
if (bitrate !== undefined) {
|
|
||||||
url += `&maxBitRate=${bitrate}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return url;
|
// Used in cases where MPV is the default player, since mpv handles basically every audio format
|
||||||
|
if (skipAutoTranscode) {
|
||||||
|
return streamUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the server supports transcoding decision, always use it to determine if we need to transcode
|
||||||
|
if (hasFeature(server, ServerFeature.OS_TRANSCODE_DECISION)) {
|
||||||
|
const maxTranscodingAudioBitrate = 0;
|
||||||
|
|
||||||
|
const directPlayProfiles = getDirectPlayProfiles();
|
||||||
|
const transcodingProfiles = getDefaultTranscodingProfiles();
|
||||||
|
|
||||||
|
const transcodeDecision = await ssApiClient(apiClientProps).getTranscodeDecision({
|
||||||
|
body: {
|
||||||
|
codecProfiles: [],
|
||||||
|
directPlayProfiles,
|
||||||
|
maxAudioBitrate: 0,
|
||||||
|
maxTranscodingAudioBitrate,
|
||||||
|
name: 'Feishin',
|
||||||
|
platform: navigator.userAgent,
|
||||||
|
transcodingProfiles,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
mediaId: id,
|
||||||
|
mediaType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (transcodeDecision.status !== 200) {
|
||||||
|
throw new Error('Failed to get transcode decision');
|
||||||
|
}
|
||||||
|
|
||||||
|
const td = transcodeDecision.body.transcodeDecision;
|
||||||
|
const requiresTranscoding = !td?.canDirectPlay;
|
||||||
|
|
||||||
|
// If the server does not require transcoding, just return the direct stream URL
|
||||||
|
if (!requiresTranscoding) {
|
||||||
|
return streamUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
logFn.info(`Song ${id} requires transcoding: ${[td.transcodeReason].join(', ')}`);
|
||||||
|
|
||||||
|
// If the server does not return transcode params, manually create the transcode params
|
||||||
|
if (!td.transcodeParams) {
|
||||||
|
return appendTranscodeParams(streamUrl, format, bitrate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcodeStreamUrl = await ssApiClient(apiClientProps).getTranscodeStream({
|
||||||
|
query: {
|
||||||
|
mediaId: id,
|
||||||
|
mediaType,
|
||||||
|
offset: 0,
|
||||||
|
transcodeParams: td.transcodeParams,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (transcodeStreamUrl.status !== 200) {
|
||||||
|
throw new Error('Failed to get transcode stream');
|
||||||
|
}
|
||||||
|
|
||||||
|
return transcodeStreamUrl.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamUrl;
|
||||||
},
|
},
|
||||||
getStructuredLyrics: async (args) => {
|
getStructuredLyrics: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
@@ -2118,6 +2333,22 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
setPlaylistSongs: async (args) => {
|
||||||
|
const { apiClientProps, body } = args;
|
||||||
|
|
||||||
|
const res = await ssApiClient(apiClientProps).createPlaylist({
|
||||||
|
query: {
|
||||||
|
playlistId: body.id,
|
||||||
|
songId: body.songIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to update playlist songs');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
setRating: async (args) => {
|
setRating: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
|||||||
@@ -67,10 +67,19 @@
|
|||||||
padding: var(--theme-spacing-md);
|
padding: var(--theme-spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.single-carousel-container .carousel {
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-carousel-container .carousel-item {
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
.single-carousel-container .carousel-item .content {
|
.single-carousel-container .carousel-item .content {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: var(--theme-spacing-lg);
|
gap: var(--theme-spacing-md);
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
min-height: 240px;
|
||||||
padding: var(--theme-spacing-xl);
|
padding: var(--theme-spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ interface AlbumMetadataTagsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MOOD_TAG = 'mood';
|
const MOOD_TAG = 'mood';
|
||||||
|
const GROUPING_TAG = 'grouping';
|
||||||
const RELEASE_COUNTRY_TAG = 'releasecountry';
|
const RELEASE_COUNTRY_TAG = 'releasecountry';
|
||||||
const RELEASE_STATUS_TAG = 'releasestatus';
|
const RELEASE_STATUS_TAG = 'releasestatus';
|
||||||
|
|
||||||
@@ -155,6 +156,30 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
|
|||||||
}));
|
}));
|
||||||
}, [album]);
|
}, [album]);
|
||||||
|
|
||||||
|
const groupingItems = useMemo(() => {
|
||||||
|
if (!album) return [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
album.tags?.[GROUPING_TAG]?.map((tag) => {
|
||||||
|
if (album._serverType !== ServerType.NAVIDROME) {
|
||||||
|
return { id: tag, label: tag, url: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
const paramsWithCustom = setJsonSearchParam(
|
||||||
|
searchParams,
|
||||||
|
FILTER_KEYS.ALBUM._CUSTOM,
|
||||||
|
{ grouping: [tag] },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: tag,
|
||||||
|
label: tag,
|
||||||
|
url: `${AppRoute.LIBRARY_ALBUMS}?${paramsWithCustom.toString()}`,
|
||||||
|
};
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
}, [album]);
|
||||||
|
|
||||||
const recordLabels = useMemo(() => {
|
const recordLabels = useMemo(() => {
|
||||||
if (!album?.recordLabels || album.recordLabels.length === 0) return [];
|
if (!album?.recordLabels || album.recordLabels.length === 0) return [];
|
||||||
|
|
||||||
@@ -221,6 +246,29 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
|
|||||||
items={moodTagItems}
|
items={moodTagItems}
|
||||||
title={t('common.mood', { postProcess: 'sentenceCase' })}
|
title={t('common.mood', { postProcess: 'sentenceCase' })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{groupingItems.length > 0 && (
|
||||||
|
<Stack align="center" className={styles.metadataPillGroup} gap="xs">
|
||||||
|
<Text fw={600} isNoSelect size="sm" tt="uppercase">
|
||||||
|
{t('common.grouping', { postProcess: 'sentenceCase' })}
|
||||||
|
</Text>
|
||||||
|
<div className={styles['pill-group-wrapper']}>
|
||||||
|
<Pill.Group>
|
||||||
|
{groupingItems.map((item) =>
|
||||||
|
item.url ? (
|
||||||
|
<PillLink key={`grouping-${item.id}`} size="md" to={item.url}>
|
||||||
|
{item.label}
|
||||||
|
</PillLink>
|
||||||
|
) : (
|
||||||
|
<Pill key={`grouping-${item.id}`} size="md">
|
||||||
|
{item.label}
|
||||||
|
</Pill>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Pill.Group>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -296,25 +344,60 @@ interface AlbumMetadataExternalLinksProps {
|
|||||||
albumName?: string;
|
albumName?: string;
|
||||||
externalLinks: boolean;
|
externalLinks: boolean;
|
||||||
lastFM: boolean;
|
lastFM: boolean;
|
||||||
|
listenBrainz: boolean;
|
||||||
mbzId?: null | string;
|
mbzId?: null | string;
|
||||||
|
mbzReleaseGroupId?: null | string;
|
||||||
musicBrainz: boolean;
|
musicBrainz: boolean;
|
||||||
nativeSpotify: boolean;
|
nativeSpotify: boolean;
|
||||||
|
qobuz: boolean;
|
||||||
spotify: boolean;
|
spotify: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getListenBrainzUrl = (
|
||||||
|
mbzReleaseGroupId: null | string,
|
||||||
|
albumArtist?: string,
|
||||||
|
albumName?: string,
|
||||||
|
) => {
|
||||||
|
if (mbzReleaseGroupId) {
|
||||||
|
return `https://listenbrainz.org/album/${mbzReleaseGroupId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (albumArtist || albumName) {
|
||||||
|
return `https://listenbrainz.org/search/?search_term=${encodeURIComponent([albumArtist, albumName].filter(Boolean).join(' ').trim())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQobuzUrl = (albumArtist?: string, albumName?: string) => {
|
||||||
|
if (albumArtist || albumName) {
|
||||||
|
return `https://www.qobuz.com/us-en/search/albums/${encodeURIComponent([albumArtist, albumName].filter(Boolean).join(' ').trim())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const AlbumMetadataExternalLinks = ({
|
const AlbumMetadataExternalLinks = ({
|
||||||
albumArtist,
|
albumArtist,
|
||||||
albumName,
|
albumName,
|
||||||
externalLinks,
|
externalLinks,
|
||||||
lastFM,
|
lastFM,
|
||||||
|
listenBrainz,
|
||||||
mbzId,
|
mbzId,
|
||||||
|
mbzReleaseGroupId,
|
||||||
musicBrainz,
|
musicBrainz,
|
||||||
nativeSpotify,
|
nativeSpotify,
|
||||||
|
qobuz,
|
||||||
spotify,
|
spotify,
|
||||||
}: AlbumMetadataExternalLinksProps) => {
|
}: AlbumMetadataExternalLinksProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!externalLinks || (!lastFM && !musicBrainz && !spotify)) return null;
|
const listenBrainzUrl = getListenBrainzUrl(mbzReleaseGroupId || null, albumArtist, albumName);
|
||||||
|
const qobuzUrl = getQobuzUrl(albumArtist, albumName);
|
||||||
|
|
||||||
|
if (!externalLinks || (!lastFM && !listenBrainz && !musicBrainz && !qobuz && !spotify)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
@@ -323,7 +406,7 @@ const AlbumMetadataExternalLinks = ({
|
|||||||
postProcess: 'sentenceCase',
|
postProcess: 'sentenceCase',
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Group className={styles.externalLinksGroup} gap="sm">
|
<Group className={styles.externalLinksGroup} gap="xs">
|
||||||
{lastFM && (
|
{lastFM && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component="a"
|
component="a"
|
||||||
@@ -332,8 +415,7 @@ const AlbumMetadataExternalLinks = ({
|
|||||||
)}/${encodeURIComponent(albumName || '')}`}
|
)}/${encodeURIComponent(albumName || '')}`}
|
||||||
icon="brandLastfm"
|
icon="brandLastfm"
|
||||||
iconProps={{
|
iconProps={{
|
||||||
fill: 'default',
|
size: '2xl',
|
||||||
size: 'xl',
|
|
||||||
}}
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -350,8 +432,7 @@ const AlbumMetadataExternalLinks = ({
|
|||||||
href={`https://musicbrainz.org/release/${mbzId}`}
|
href={`https://musicbrainz.org/release/${mbzId}`}
|
||||||
icon="brandMusicBrainz"
|
icon="brandMusicBrainz"
|
||||||
iconProps={{
|
iconProps={{
|
||||||
fill: 'default',
|
size: '2xl',
|
||||||
size: 'xl',
|
|
||||||
}}
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -362,6 +443,40 @@ const AlbumMetadataExternalLinks = ({
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{listenBrainz && listenBrainzUrl && (
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={listenBrainzUrl}
|
||||||
|
icon="brandListenBrainz"
|
||||||
|
iconProps={{
|
||||||
|
size: '2xl',
|
||||||
|
}}
|
||||||
|
radius="md"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
tooltip={{
|
||||||
|
label: t('action.openIn.listenbrainz'),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{qobuz && qobuzUrl && (
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={qobuzUrl}
|
||||||
|
icon="brandQobuz"
|
||||||
|
iconProps={{
|
||||||
|
size: '2xl',
|
||||||
|
}}
|
||||||
|
radius="md"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
tooltip={{
|
||||||
|
label: t('action.openIn.qobuz'),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{spotify && (
|
{spotify && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component="a"
|
component="a"
|
||||||
@@ -372,8 +487,7 @@ const AlbumMetadataExternalLinks = ({
|
|||||||
}
|
}
|
||||||
icon="brandSpotify"
|
icon="brandSpotify"
|
||||||
iconProps={{
|
iconProps={{
|
||||||
fill: 'default',
|
size: '2xl',
|
||||||
size: 'xl',
|
|
||||||
}}
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -396,7 +510,8 @@ export const AlbumDetailContent = () => {
|
|||||||
albumQueries.detail({ query: { id: albumId }, serverId: server.id }),
|
albumQueries.detail({ query: { id: albumId }, serverId: server.id }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { externalLinks, lastFM, musicBrainz, nativeSpotify, spotify } = useExternalLinks();
|
const { externalLinks, lastFM, listenBrainz, musicBrainz, nativeSpotify, qobuz, spotify } =
|
||||||
|
useExternalLinks();
|
||||||
|
|
||||||
const comment = detailQuery?.data?.comment;
|
const comment = detailQuery?.data?.comment;
|
||||||
|
|
||||||
@@ -427,9 +542,12 @@ export const AlbumDetailContent = () => {
|
|||||||
albumName={detailQuery?.data?.name}
|
albumName={detailQuery?.data?.name}
|
||||||
externalLinks={externalLinks}
|
externalLinks={externalLinks}
|
||||||
lastFM={lastFM}
|
lastFM={lastFM}
|
||||||
|
listenBrainz={listenBrainz}
|
||||||
mbzId={mbzId || undefined}
|
mbzId={mbzId || undefined}
|
||||||
|
mbzReleaseGroupId={detailQuery?.data?.mbzReleaseGroupId}
|
||||||
musicBrainz={musicBrainz}
|
musicBrainz={musicBrainz}
|
||||||
nativeSpotify={nativeSpotify}
|
nativeSpotify={nativeSpotify}
|
||||||
|
qobuz={qobuz}
|
||||||
spotify={spotify}
|
spotify={spotify}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -888,26 +888,54 @@ interface AlbumArtistMetadataExternalLinksProps {
|
|||||||
artistName?: string;
|
artistName?: string;
|
||||||
externalLinks: boolean;
|
externalLinks: boolean;
|
||||||
lastFM: boolean;
|
lastFM: boolean;
|
||||||
|
listenBrainz: boolean;
|
||||||
mbzId?: null | string;
|
mbzId?: null | string;
|
||||||
musicBrainz: boolean;
|
musicBrainz: boolean;
|
||||||
nativeSpotify: boolean;
|
nativeSpotify: boolean;
|
||||||
order?: number;
|
order?: number;
|
||||||
|
qobuz: boolean;
|
||||||
spotify: boolean;
|
spotify: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getListenBrainzUrl = (mbzId: null | string, artistName?: string) => {
|
||||||
|
if (mbzId) {
|
||||||
|
return `https://listenbrainz.org/artist/${mbzId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artistName) {
|
||||||
|
return `https://listenbrainz.org/search/?search_term=${encodeURIComponent(artistName)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQobuzUrl = (artistName?: string) => {
|
||||||
|
if (artistName) {
|
||||||
|
return `https://www.qobuz.com/us-en/search/artists/${encodeURIComponent(artistName)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const AlbumArtistMetadataExternalLinks = ({
|
const AlbumArtistMetadataExternalLinks = ({
|
||||||
artistName,
|
artistName,
|
||||||
externalLinks,
|
externalLinks,
|
||||||
lastFM,
|
lastFM,
|
||||||
|
listenBrainz,
|
||||||
mbzId,
|
mbzId,
|
||||||
musicBrainz,
|
musicBrainz,
|
||||||
nativeSpotify,
|
nativeSpotify,
|
||||||
order,
|
order,
|
||||||
|
qobuz,
|
||||||
spotify,
|
spotify,
|
||||||
}: AlbumArtistMetadataExternalLinksProps) => {
|
}: AlbumArtistMetadataExternalLinksProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const listenBrainzUrl = getListenBrainzUrl(mbzId || null, artistName);
|
||||||
|
const qobuzUrl = getQobuzUrl(artistName);
|
||||||
|
|
||||||
if (!externalLinks || (!lastFM && !musicBrainz && !spotify)) return null;
|
if (!externalLinks || (!lastFM && !listenBrainz && !musicBrainz && !qobuz && !spotify)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid.Col order={order} span={12}>
|
<Grid.Col order={order} span={12}>
|
||||||
@@ -917,15 +945,14 @@ const AlbumArtistMetadataExternalLinks = ({
|
|||||||
postProcess: 'sentenceCase',
|
postProcess: 'sentenceCase',
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="sm">
|
<Group gap="xs">
|
||||||
{lastFM && (
|
{lastFM && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component="a"
|
component="a"
|
||||||
href={`https://www.last.fm/music/${encodeURIComponent(artistName || '')}`}
|
href={`https://www.last.fm/music/${encodeURIComponent(artistName || '')}`}
|
||||||
icon="brandLastfm"
|
icon="brandLastfm"
|
||||||
iconProps={{
|
iconProps={{
|
||||||
fill: 'default',
|
size: '2xl',
|
||||||
size: 'xl',
|
|
||||||
}}
|
}}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -941,8 +968,7 @@ const AlbumArtistMetadataExternalLinks = ({
|
|||||||
href={`https://musicbrainz.org/artist/${mbzId}`}
|
href={`https://musicbrainz.org/artist/${mbzId}`}
|
||||||
icon="brandMusicBrainz"
|
icon="brandMusicBrainz"
|
||||||
iconProps={{
|
iconProps={{
|
||||||
fill: 'default',
|
size: '2xl',
|
||||||
size: 'xl',
|
|
||||||
}}
|
}}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -952,6 +978,38 @@ const AlbumArtistMetadataExternalLinks = ({
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{listenBrainz && listenBrainzUrl && (
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={listenBrainzUrl}
|
||||||
|
icon="brandListenBrainz"
|
||||||
|
iconProps={{
|
||||||
|
size: '2xl',
|
||||||
|
}}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
tooltip={{
|
||||||
|
label: t('action.openIn.listenbrainz'),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{qobuz && qobuzUrl && (
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={qobuzUrl}
|
||||||
|
icon="brandQobuz"
|
||||||
|
iconProps={{
|
||||||
|
size: '2xl',
|
||||||
|
}}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
tooltip={{
|
||||||
|
label: t('action.openIn.qobuz'),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{spotify && (
|
{spotify && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component="a"
|
component="a"
|
||||||
@@ -962,8 +1020,7 @@ const AlbumArtistMetadataExternalLinks = ({
|
|||||||
}
|
}
|
||||||
icon="brandSpotify"
|
icon="brandSpotify"
|
||||||
iconProps={{
|
iconProps={{
|
||||||
fill: 'default',
|
size: '2xl',
|
||||||
size: 'xl',
|
|
||||||
}}
|
}}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
target={nativeSpotify ? undefined : '_blank'}
|
target={nativeSpotify ? undefined : '_blank'}
|
||||||
@@ -1075,7 +1132,8 @@ export const AlbumArtistDetailContent = ({
|
|||||||
}: AlbumArtistDetailContentProps) => {
|
}: AlbumArtistDetailContentProps) => {
|
||||||
const artistItems = useArtistItems();
|
const artistItems = useArtistItems();
|
||||||
const artistRadioCount = useArtistRadioCount();
|
const artistRadioCount = useArtistRadioCount();
|
||||||
const { externalLinks, lastFM, musicBrainz, nativeSpotify, spotify } = useExternalLinks();
|
const { externalLinks, lastFM, listenBrainz, musicBrainz, nativeSpotify, qobuz, spotify } =
|
||||||
|
useExternalLinks();
|
||||||
const { albumArtistId, artistId } = useParams() as {
|
const { albumArtistId, artistId } = useParams() as {
|
||||||
albumArtistId?: string;
|
albumArtistId?: string;
|
||||||
artistId?: string;
|
artistId?: string;
|
||||||
@@ -1161,18 +1219,21 @@ export const AlbumArtistDetailContent = ({
|
|||||||
genres={detailQuery.data?.genres}
|
genres={detailQuery.data?.genres}
|
||||||
order={genresOrder}
|
order={genresOrder}
|
||||||
/>
|
/>
|
||||||
{externalLinks && (lastFM || musicBrainz || spotify) && (
|
{externalLinks &&
|
||||||
<AlbumArtistMetadataExternalLinks
|
(lastFM || listenBrainz || musicBrainz || qobuz || spotify) && (
|
||||||
artistName={detailQuery.data?.name}
|
<AlbumArtistMetadataExternalLinks
|
||||||
externalLinks={externalLinks}
|
artistName={detailQuery.data?.name}
|
||||||
lastFM={lastFM}
|
externalLinks={externalLinks}
|
||||||
mbzId={mbzId}
|
lastFM={lastFM}
|
||||||
musicBrainz={musicBrainz}
|
listenBrainz={listenBrainz}
|
||||||
nativeSpotify={nativeSpotify}
|
mbzId={mbzId}
|
||||||
order={externalLinksOrder}
|
musicBrainz={musicBrainz}
|
||||||
spotify={spotify}
|
nativeSpotify={nativeSpotify}
|
||||||
/>
|
order={externalLinksOrder}
|
||||||
)}
|
qobuz={qobuz}
|
||||||
|
spotify={spotify}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{enabledItem.biography && (
|
{enabledItem.biography && (
|
||||||
<AlbumArtistMetadataBiography
|
<AlbumArtistMetadataBiography
|
||||||
artistName={detailQuery.data?.name}
|
artistName={detailQuery.data?.name}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from '/@/renderer/features/lyrics/unsynchronized-lyrics';
|
} from '/@/renderer/features/lyrics/unsynchronized-lyrics';
|
||||||
import { openLyricsSettingsModal } from '/@/renderer/features/lyrics/utils/open-lyrics-settings-modal';
|
import { openLyricsSettingsModal } from '/@/renderer/features/lyrics/utils/open-lyrics-settings-modal';
|
||||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||||
|
import { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||||
import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';
|
import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';
|
||||||
import { queryClient } from '/@/renderer/lib/react-query';
|
import { queryClient } from '/@/renderer/lib/react-query';
|
||||||
import { useLyricsSettings, usePlayerSong } from '/@/renderer/store';
|
import { useLyricsSettings, usePlayerSong } from '/@/renderer/store';
|
||||||
@@ -42,6 +43,10 @@ type LyricsProps = {
|
|||||||
|
|
||||||
export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' }: LyricsProps) => {
|
export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' }: LyricsProps) => {
|
||||||
const currentSong = usePlayerSong();
|
const currentSong = usePlayerSong();
|
||||||
|
const isRadioActive = useIsRadioActive();
|
||||||
|
|
||||||
|
const isLyricsDisabled = isRadioActive;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
enableAutoTranslation,
|
enableAutoTranslation,
|
||||||
preferLocalLyrics,
|
preferLocalLyrics,
|
||||||
@@ -91,7 +96,8 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
lyricsQueries.songLyrics(
|
lyricsQueries.songLyrics(
|
||||||
{
|
{
|
||||||
options: {
|
options: {
|
||||||
enabled: !!pendingSongId && pendingSongId === currentSong?.id,
|
enabled:
|
||||||
|
!!pendingSongId && pendingSongId === currentSong?.id && !isLyricsDisabled,
|
||||||
},
|
},
|
||||||
query: { songId: currentSong?.id || '' },
|
query: { songId: currentSong?.id || '' },
|
||||||
serverId: currentSong?._serverId || '',
|
serverId: currentSong?._serverId || '',
|
||||||
@@ -110,11 +116,15 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
return computeSelectedFromResult(data, preferLocalLyrics, indexToUse);
|
return computeSelectedFromResult(data, preferLocalLyrics, indexToUse);
|
||||||
}, [data, indexToUse, preferLocalLyrics]);
|
}, [data, indexToUse, preferLocalLyrics]);
|
||||||
|
|
||||||
|
const displayLyrics = isLyricsDisabled ? null : lyrics;
|
||||||
|
|
||||||
const currentOffsetMs = useMemo(() => {
|
const currentOffsetMs = useMemo(() => {
|
||||||
if (!data) return 0;
|
if (!data) return 0;
|
||||||
return getDisplayOffset(lyrics, data.selectedOffsetMs, indexToUse, data.local);
|
return getDisplayOffset(lyrics, data.selectedOffsetMs, indexToUse, data.local);
|
||||||
}, [data, indexToUse, lyrics]);
|
}, [data, indexToUse, lyrics]);
|
||||||
|
|
||||||
|
const displayOffsetMs = isLyricsDisabled ? 0 : currentOffsetMs;
|
||||||
|
|
||||||
const handleOnSearchOverride = useCallback(
|
const handleOnSearchOverride = useCallback(
|
||||||
(params: LyricsOverride) => {
|
(params: LyricsOverride) => {
|
||||||
if (!lyricsKey) return;
|
if (!lyricsKey) return;
|
||||||
@@ -192,7 +202,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
}, [currentSong, lyricsKey]);
|
}, [currentSong, lyricsKey]);
|
||||||
|
|
||||||
const fetchTranslation = useCallback(async () => {
|
const fetchTranslation = useCallback(async () => {
|
||||||
if (!lyrics) return;
|
if (!lyrics || isLyricsDisabled) return;
|
||||||
const originalLyrics = Array.isArray(lyrics.lyrics)
|
const originalLyrics = Array.isArray(lyrics.lyrics)
|
||||||
? lyrics.lyrics.map(([, line]) => line).join('\n')
|
? lyrics.lyrics.map(([, line]) => line).join('\n')
|
||||||
: lyrics.lyrics;
|
: lyrics.lyrics;
|
||||||
@@ -204,7 +214,13 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
);
|
);
|
||||||
setTranslatedLyrics(TranslatedText);
|
setTranslatedLyrics(TranslatedText);
|
||||||
setShowTranslation(true);
|
setShowTranslation(true);
|
||||||
}, [lyrics, translationApiKey, translationApiProvider, translationTargetLanguage]);
|
}, [
|
||||||
|
isLyricsDisabled,
|
||||||
|
lyrics,
|
||||||
|
translationApiKey,
|
||||||
|
translationApiProvider,
|
||||||
|
translationTargetLanguage,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleOnTranslateLyric = useCallback(async () => {
|
const handleOnTranslateLyric = useCallback(async () => {
|
||||||
if (translatedLyrics) {
|
if (translatedLyrics) {
|
||||||
@@ -226,10 +242,10 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lyrics && !translatedLyrics && enableAutoTranslation) {
|
if (displayLyrics && !translatedLyrics && enableAutoTranslation) {
|
||||||
fetchTranslation();
|
fetchTranslation();
|
||||||
}
|
}
|
||||||
}, [lyrics, translatedLyrics, enableAutoTranslation, fetchTranslation]);
|
}, [displayLyrics, translatedLyrics, enableAutoTranslation, fetchTranslation]);
|
||||||
|
|
||||||
const languages = useMemo(() => {
|
const languages = useMemo(() => {
|
||||||
const local = data?.local;
|
const local = data?.local;
|
||||||
@@ -242,8 +258,8 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
return [];
|
return [];
|
||||||
}, [data?.local]);
|
}, [data?.local]);
|
||||||
|
|
||||||
const isLoadingLyrics = isLoading;
|
const isLoadingLyrics = isLoading && !isLyricsDisabled;
|
||||||
const hasNoLyrics = !lyrics;
|
const hasNoLyrics = !displayLyrics;
|
||||||
const [shouldFadeOut, setShouldFadeOut] = useState(false);
|
const [shouldFadeOut, setShouldFadeOut] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -267,10 +283,10 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
}, [isLoadingLyrics, hasNoLyrics, fadeOutNoLyricsMessage]);
|
}, [isLoadingLyrics, hasNoLyrics, fadeOutNoLyricsMessage]);
|
||||||
|
|
||||||
const handleExportLyrics = useCallback(() => {
|
const handleExportLyrics = useCallback(() => {
|
||||||
if (lyrics) {
|
if (displayLyrics) {
|
||||||
openLyricsExportModal({ lyrics, offsetMs: currentOffsetMs, synced });
|
openLyricsExportModal({ lyrics: displayLyrics, offsetMs: currentOffsetMs, synced });
|
||||||
}
|
}
|
||||||
}, [currentOffsetMs, lyrics, synced]);
|
}, [currentOffsetMs, displayLyrics, synced]);
|
||||||
|
|
||||||
const handleOpenSettings = () => {
|
const handleOpenSettings = () => {
|
||||||
openLyricsSettingsModal(settingsKey);
|
openLyricsSettingsModal(settingsKey);
|
||||||
@@ -318,14 +334,14 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
>
|
>
|
||||||
{synced ? (
|
{synced ? (
|
||||||
<SynchronizedLyrics
|
<SynchronizedLyrics
|
||||||
{...(lyrics as SynchronizedLyricsProps)}
|
{...(displayLyrics as SynchronizedLyricsProps)}
|
||||||
offsetMs={currentOffsetMs}
|
offsetMs={displayOffsetMs}
|
||||||
settingsKey={settingsKey}
|
settingsKey={settingsKey}
|
||||||
translatedLyrics={showTranslation ? translatedLyrics : null}
|
translatedLyrics={showTranslation ? translatedLyrics : null}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<UnsynchronizedLyrics
|
<UnsynchronizedLyrics
|
||||||
{...(lyrics as UnsynchronizedLyricsProps)}
|
{...(displayLyrics as UnsynchronizedLyricsProps)}
|
||||||
settingsKey={settingsKey}
|
settingsKey={settingsKey}
|
||||||
translatedLyrics={showTranslation ? translatedLyrics : null}
|
translatedLyrics={showTranslation ? translatedLyrics : null}
|
||||||
/>
|
/>
|
||||||
@@ -336,10 +352,10 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
|||||||
)}
|
)}
|
||||||
<div className={styles.actionsContainer}>
|
<div className={styles.actionsContainer}>
|
||||||
<LyricsActions
|
<LyricsActions
|
||||||
hasLyrics={!!lyrics}
|
hasLyrics={!!displayLyrics}
|
||||||
index={indexToUse}
|
index={indexToUse}
|
||||||
languages={languages}
|
languages={languages}
|
||||||
offsetMs={currentOffsetMs}
|
offsetMs={displayOffsetMs}
|
||||||
onExportLyrics={handleExportLyrics}
|
onExportLyrics={handleExportLyrics}
|
||||||
onRemoveLyric={handleOnRemoveLyric}
|
onRemoveLyric={handleOnRemoveLyric}
|
||||||
onSearchOverride={handleOnSearchOverride}
|
onSearchOverride={handleOnSearchOverride}
|
||||||
|
|||||||
@@ -124,10 +124,10 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
if (!radioState.currentStreamUrl) {
|
if (!radioState.currentStreamUrl) {
|
||||||
const playerData = usePlayerStore.getState().getPlayerData();
|
const playerData = usePlayerStore.getState().getPlayerData();
|
||||||
const currentSongUrl = playerData.currentSong
|
const currentSongUrl = playerData.currentSong
|
||||||
? getSongUrl(playerData.currentSong, transcode)
|
? await getSongUrl(playerData.currentSong, transcode, true)
|
||||||
: undefined;
|
: undefined;
|
||||||
const nextSongUrl = playerData.nextSong
|
const nextSongUrl = playerData.nextSong
|
||||||
? getSongUrl(playerData.nextSong, transcode)
|
? await getSongUrl(playerData.nextSong, transcode, true)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) {
|
if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) {
|
||||||
@@ -274,14 +274,14 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
onMediaPrev: () => {
|
onMediaPrev: () => {
|
||||||
replaceMpvQueue(transcode);
|
replaceMpvQueue(transcode);
|
||||||
},
|
},
|
||||||
onNextSongInsertion: (song) => {
|
onNextSongInsertion: async (song) => {
|
||||||
const radioState = useRadioStore.getState();
|
const radioState = useRadioStore.getState();
|
||||||
|
|
||||||
if (radioState.currentStreamUrl) {
|
if (radioState.currentStreamUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSongUrl = song ? getSongUrl(song, transcode) : undefined;
|
const nextSongUrl = song ? await getSongUrl(song, transcode, true) : undefined;
|
||||||
mpvPlayer?.setQueueNext(nextSongUrl);
|
mpvPlayer?.setQueueNext(nextSongUrl);
|
||||||
},
|
},
|
||||||
onPlayerPlay: () => {
|
onPlayerPlay: () => {
|
||||||
@@ -339,19 +339,19 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
|
|
||||||
MpvPlayerEngine.displayName = 'MpvPlayerEngine';
|
MpvPlayerEngine.displayName = 'MpvPlayerEngine';
|
||||||
|
|
||||||
function handleMpvAutoNext(transcode: {
|
async function handleMpvAutoNext(transcode: {
|
||||||
bitrate?: number | undefined;
|
bitrate?: number | undefined;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
format?: string | undefined;
|
format?: string | undefined;
|
||||||
}) {
|
}) {
|
||||||
const playerData = usePlayerStore.getState().getPlayerData();
|
const playerData = usePlayerStore.getState().getPlayerData();
|
||||||
const nextSongUrl = playerData.nextSong
|
const nextSongUrl = playerData.nextSong
|
||||||
? getSongUrl(playerData.nextSong, transcode)
|
? await getSongUrl(playerData.nextSong, transcode, true)
|
||||||
: undefined;
|
: undefined;
|
||||||
mpvPlayer?.autoNext(nextSongUrl);
|
mpvPlayer?.autoNext(nextSongUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceMpvQueue(transcode: {
|
async function replaceMpvQueue(transcode: {
|
||||||
bitrate?: number | undefined;
|
bitrate?: number | undefined;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
format?: string | undefined;
|
format?: string | undefined;
|
||||||
@@ -365,10 +365,10 @@ function replaceMpvQueue(transcode: {
|
|||||||
|
|
||||||
const playerData = usePlayerStore.getState().getPlayerData();
|
const playerData = usePlayerStore.getState().getPlayerData();
|
||||||
const currentSongUrl = playerData.currentSong
|
const currentSongUrl = playerData.currentSong
|
||||||
? getSongUrl(playerData.currentSong, transcode)
|
? await getSongUrl(playerData.currentSong, transcode, true)
|
||||||
: undefined;
|
: undefined;
|
||||||
const nextSongUrl = playerData.nextSong
|
const nextSongUrl = playerData.nextSong
|
||||||
? getSongUrl(playerData.nextSong, transcode)
|
? await getSongUrl(playerData.nextSong, transcode, true)
|
||||||
: undefined;
|
: undefined;
|
||||||
mpvPlayer?.setQueue(currentSongUrl, nextSongUrl, false);
|
mpvPlayer?.setQueue(currentSongUrl, nextSongUrl, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useRef } from 'react';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { TranscodingConfig } from '/@/renderer/store';
|
import { TranscodingConfig } from '/@/renderer/store';
|
||||||
@@ -10,52 +11,71 @@ export function useSongUrl(
|
|||||||
transcode: TranscodingConfig,
|
transcode: TranscodingConfig,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const prior = useRef(['', '']);
|
const prior = useRef(['', '']);
|
||||||
|
const shouldReusePrior = Boolean(
|
||||||
|
song?._serverId && current && prior.current[0] === song._uniqueId && prior.current[1],
|
||||||
|
);
|
||||||
|
|
||||||
return useMemo(() => {
|
const { data: queryStreamUrl } = useQuery({
|
||||||
if (song?._serverId) {
|
enabled: Boolean(song?._serverId) && !shouldReusePrior,
|
||||||
// If we are the current track, we do not want a transcoding
|
queryFn: () =>
|
||||||
// reconfiguration to force a restart.
|
api.controller.getStreamUrl({
|
||||||
if (current && prior.current[0] === song._uniqueId) {
|
apiClientProps: { serverId: song!._serverId },
|
||||||
return prior.current[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = api.controller.getStreamUrl({
|
|
||||||
apiClientProps: { serverId: song._serverId },
|
|
||||||
query: {
|
query: {
|
||||||
bitrate: transcode.bitrate,
|
bitrate: transcode.bitrate,
|
||||||
format: transcode.format,
|
format: transcode.format,
|
||||||
id: song.id,
|
id: song!.id,
|
||||||
transcode: transcode.enabled,
|
transcode: transcode.enabled,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
queryKey: [
|
||||||
|
song?._serverId,
|
||||||
|
'stream-url',
|
||||||
|
song?.id,
|
||||||
|
shouldReusePrior ? 'reuse-prior' : transcode.bitrate,
|
||||||
|
shouldReusePrior ? 'reuse-prior' : transcode.format,
|
||||||
|
shouldReusePrior ? 'reuse-prior' : transcode.enabled,
|
||||||
|
] as const,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
// transcoding enabled; save the updated result
|
useEffect(() => {
|
||||||
prior.current = [song._uniqueId, url];
|
if (!song?._serverId) {
|
||||||
return url;
|
prior.current = ['', ''];
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// no track; clear result
|
if (!queryStreamUrl) {
|
||||||
prior.current = ['', ''];
|
return;
|
||||||
return undefined;
|
}
|
||||||
}, [
|
|
||||||
song?._serverId,
|
// Save resolved URL to avoid restarting current track on transcode setting changes.
|
||||||
song?._uniqueId,
|
prior.current = [song._uniqueId, queryStreamUrl];
|
||||||
song?.id,
|
}, [song?._serverId, song?._uniqueId, queryStreamUrl]);
|
||||||
current,
|
|
||||||
transcode.bitrate,
|
useEffect(() => {
|
||||||
transcode.format,
|
if (!song?._serverId) {
|
||||||
transcode.enabled,
|
prior.current = ['', ''];
|
||||||
]);
|
}
|
||||||
|
}, [song?._serverId]);
|
||||||
|
|
||||||
|
return shouldReusePrior ? prior.current[1] : queryStreamUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig) => {
|
export const getSongUrl = async (
|
||||||
return api.controller.getStreamUrl({
|
song: QueueSong,
|
||||||
|
transcode: TranscodingConfig,
|
||||||
|
skipAutoTranscode?: boolean,
|
||||||
|
) => {
|
||||||
|
const url = await api.controller.getStreamUrl({
|
||||||
apiClientProps: { serverId: song._serverId },
|
apiClientProps: { serverId: song._serverId },
|
||||||
query: {
|
query: {
|
||||||
bitrate: transcode.bitrate,
|
bitrate: transcode.bitrate,
|
||||||
format: transcode.format,
|
format: transcode.format,
|
||||||
id: song.id,
|
id: song.id,
|
||||||
|
skipAutoTranscode,
|
||||||
transcode: transcode.enabled,
|
transcode: transcode.enabled,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return url;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,52 @@ import { toast } from '/@/shared/components/toast/toast';
|
|||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
import { PlayerType } from '/@/shared/types/types';
|
import { PlayerType } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
const CODEC_PROBES = [
|
||||||
|
{ codec: 'mp3', container: 'mp3', mime: 'audio/mpeg' },
|
||||||
|
{ codec: 'aac', container: 'mp4', mime: 'audio/mp4; codecs="mp4a.40.2"' },
|
||||||
|
{ codec: 'opus', container: 'ogg', mime: 'audio/ogg; codecs="opus"' },
|
||||||
|
{ codec: 'vorbis', container: 'ogg', mime: 'audio/ogg; codecs="vorbis"' },
|
||||||
|
{ codec: 'flac', container: 'flac', mime: 'audio/flac' },
|
||||||
|
{ codec: 'wav', container: 'wav', mime: 'audio/wav' },
|
||||||
|
{ codec: 'alac', container: 'mp4', mime: 'audio/mp4; codecs="alac"' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_TRANSCODING_PROFILES = [
|
||||||
|
{ audioCodec: 'opus', container: 'ogg', protocol: 'http' },
|
||||||
|
{ audioCodec: 'mp3', container: 'mp3', protocol: 'http' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DIRECT_PLAY_PROFILES: {
|
||||||
|
audioCodecs: string[];
|
||||||
|
containers: string[];
|
||||||
|
protocols: string[];
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
export function getDefaultTranscodingProfiles() {
|
||||||
|
return DEFAULT_TRANSCODING_PROFILES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDirectPlayProfiles() {
|
||||||
|
return DIRECT_PLAY_PROFILES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shamelessly taken from NavidromeUI
|
||||||
|
function detectBrowserProfile() {
|
||||||
|
const audio = new Audio();
|
||||||
|
|
||||||
|
for (const { codec, container, mime } of CODEC_PROBES) {
|
||||||
|
if (audio.canPlayType(mime) === 'probably') {
|
||||||
|
DIRECT_PLAY_PROFILES.push({
|
||||||
|
audioCodecs: [codec],
|
||||||
|
containers: [container],
|
||||||
|
protocols: ['http'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DIRECT_PLAY_PROFILES;
|
||||||
|
}
|
||||||
|
|
||||||
export const AudioPlayers = () => {
|
export const AudioPlayers = () => {
|
||||||
const playbackType = usePlaybackType();
|
const playbackType = usePlaybackType();
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
@@ -49,6 +95,11 @@ export const AudioPlayers = () => {
|
|||||||
} = usePlaybackSettings();
|
} = usePlaybackSettings();
|
||||||
const { setWebAudio, webAudio: audioContext } = useWebAudio();
|
const { setWebAudio, webAudio: audioContext } = useWebAudio();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('getDirectPlayProfiles');
|
||||||
|
detectBrowserProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SleepTimerHook />
|
<SleepTimerHook />
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ import { LibraryItem } from '/@/shared/types/domain-types';
|
|||||||
export const LeftControls = () => {
|
export const LeftControls = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setSideBar } = useAppStoreActions();
|
const { setSideBar } = useAppStoreActions();
|
||||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
const {
|
||||||
|
expanded: isFullScreenPlayerExpanded,
|
||||||
|
visualizerExpanded: isFullScreenVisualizerExpanded,
|
||||||
|
} = useFullScreenPlayerStore();
|
||||||
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||||
|
|
||||||
const { collapsed, image } = useAppStore(
|
const { collapsed, image } = useAppStore(
|
||||||
@@ -62,7 +65,14 @@ export const LeftControls = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
e?.stopPropagation();
|
e?.stopPropagation();
|
||||||
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
|
|
||||||
|
const shouldClose = isFullScreenPlayerExpanded || isFullScreenVisualizerExpanded;
|
||||||
|
|
||||||
|
if (shouldClose) {
|
||||||
|
setFullScreenPlayerStore({ expanded: false, visualizerExpanded: false });
|
||||||
|
} else {
|
||||||
|
setFullScreenPlayerStore({ expanded: true });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleSidebarImage = (e?: MouseEvent<HTMLButtonElement>) => {
|
const handleToggleSidebarImage = (e?: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { CustomPlayerbarSlider } from './playerbar-slider';
|
|||||||
import styles from './playerbar-waveform.module.css';
|
import styles from './playerbar-waveform.module.css';
|
||||||
|
|
||||||
import { useSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url';
|
import { useSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url';
|
||||||
|
import { PlayerbarSeekSlider } from '/@/renderer/features/player/components/playerbar-seek-slider';
|
||||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
import { BarAlign, usePlayerbarSlider, usePlayerSong, usePlayerTimestamp } from '/@/renderer/store';
|
import { BarAlign, usePlayerbarSlider, usePlayerSong, usePlayerTimestamp } from '/@/renderer/store';
|
||||||
import { useAppThemeColors, useColorScheme } from '/@/renderer/themes/use-app-theme';
|
import { useAppThemeColors, useColorScheme } from '/@/renderer/themes/use-app-theme';
|
||||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
|
||||||
export const PlayerbarWaveform = () => {
|
export const PlayerbarWaveform = () => {
|
||||||
@@ -18,6 +18,7 @@ export const PlayerbarWaveform = () => {
|
|||||||
const playerbarSlider = usePlayerbarSlider();
|
const playerbarSlider = usePlayerbarSlider();
|
||||||
const currentTime = usePlayerTimestamp();
|
const currentTime = usePlayerTimestamp();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const audioElementRef = useRef<HTMLAudioElement>(document.createElement('audio'));
|
||||||
const { mediaSeekToTimestamp } = usePlayer();
|
const { mediaSeekToTimestamp } = usePlayer();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@@ -29,7 +30,7 @@ export const PlayerbarWaveform = () => {
|
|||||||
|
|
||||||
const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;
|
const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;
|
||||||
|
|
||||||
const streamUrl = useSongUrl(currentSong, true, { bitrate: 64, enabled: true, format: 'mp3' });
|
const streamUrl = useSongUrl(currentSong, true, { bitrate: 64, enabled: false, format: 'mp3' });
|
||||||
|
|
||||||
const { color } = useAppThemeColors();
|
const { color } = useAppThemeColors();
|
||||||
const primaryColor = (color['--theme-colors-primary'] as string) || 'rgb(53, 116, 252)';
|
const primaryColor = (color['--theme-colors-primary'] as string) || 'rgb(53, 116, 252)';
|
||||||
@@ -56,28 +57,20 @@ export const PlayerbarWaveform = () => {
|
|||||||
fillParent: true,
|
fillParent: true,
|
||||||
height: 18,
|
height: 18,
|
||||||
interact: false,
|
interact: false,
|
||||||
|
media: audioElementRef.current,
|
||||||
normalize: false,
|
normalize: false,
|
||||||
progressColor: primaryColor,
|
progressColor: primaryColor,
|
||||||
url: streamUrl || undefined,
|
|
||||||
waveColor,
|
waveColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset loading state when stream URL changes and ensure media is muted
|
// Reset loading state when stream URL changes and ensure media is muted
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
if (wavesurfer) {
|
}, [streamUrl]);
|
||||||
wavesurfer.setVolume(0);
|
|
||||||
const mediaElement = wavesurfer.getMediaElement();
|
|
||||||
if (mediaElement) {
|
|
||||||
mediaElement.muted = true;
|
|
||||||
mediaElement.volume = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [streamUrl, wavesurfer]);
|
|
||||||
|
|
||||||
// Handle waveform ready state
|
// Handle waveform ready state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!wavesurfer) return;
|
if (!wavesurfer || !streamUrl) return;
|
||||||
|
|
||||||
const handleReady = () => {
|
const handleReady = () => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -90,20 +83,18 @@ export const PlayerbarWaveform = () => {
|
|||||||
|
|
||||||
wavesurfer.on('ready', handleReady);
|
wavesurfer.on('ready', handleReady);
|
||||||
|
|
||||||
// Check if already loaded
|
const waveformTimeout = setTimeout(
|
||||||
if (wavesurfer.getDuration() > 0) {
|
() => {
|
||||||
setIsLoading(false);
|
wavesurfer.load(streamUrl);
|
||||||
const mediaElement = wavesurfer.getMediaElement();
|
},
|
||||||
if (mediaElement) {
|
playerbarSlider?.loadingDelay ? playerbarSlider.loadingDelay * 1000 : 2000,
|
||||||
mediaElement.muted = true;
|
);
|
||||||
mediaElement.volume = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
wavesurfer.un('ready', handleReady);
|
wavesurfer.un('ready', handleReady);
|
||||||
|
clearTimeout(waveformTimeout);
|
||||||
};
|
};
|
||||||
}, [wavesurfer]);
|
}, [wavesurfer, streamUrl, playerbarSlider.loadingDelay]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!wavesurfer) return;
|
if (!wavesurfer) return;
|
||||||
@@ -363,12 +354,12 @@ export const PlayerbarWaveform = () => {
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
left: 0,
|
left: 0,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 3,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<Spinner container />
|
<PlayerbarSeekSlider max={songDuration} min={0} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -302,9 +302,9 @@ const PlaylistDetailTrackViewContent = ({ data }: { data: PlaylistSongListRespon
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => {
|
const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => {
|
||||||
const { displayMode } = useListContext();
|
const { displayMode, mode } = useListContext();
|
||||||
|
|
||||||
if (displayMode === LibraryItem.ALBUM) {
|
if (mode !== 'edit' && displayMode === LibraryItem.ALBUM) {
|
||||||
return <PlaylistDetailAlbumView data={data} />;
|
return <PlaylistDetailAlbumView data={data} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-107
@@ -44,13 +44,7 @@ import { Modal } from '/@/shared/components/modal/modal';
|
|||||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||||
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
|
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
|
||||||
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||||
import {
|
import { LibraryItem, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||||
LibraryItem,
|
|
||||||
Playlist,
|
|
||||||
SongListSort,
|
|
||||||
SortOrder,
|
|
||||||
UpdatePlaylistBody,
|
|
||||||
} from '/@/shared/types/domain-types';
|
|
||||||
import { ItemListKey } from '/@/shared/types/types';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
interface PlaylistDetailSongListHeaderFiltersProps {
|
interface PlaylistDetailSongListHeaderFiltersProps {
|
||||||
@@ -124,7 +118,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||||||
isSmartPlaylist,
|
isSmartPlaylist,
|
||||||
}: PlaylistDetailSongListHeaderFiltersProps) => {
|
}: PlaylistDetailSongListHeaderFiltersProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { listKey: listKeyFromContext, mode, setMode } = useListContext();
|
const { listData, listKey: listKeyFromContext, mode, setMode } = useListContext();
|
||||||
const { playlistId } = useParams() as { playlistId: string };
|
const { playlistId } = useParams() as { playlistId: string };
|
||||||
const playlistTarget = usePlaylistTarget();
|
const playlistTarget = usePlaylistTarget();
|
||||||
const { setPlaylistBehavior } = useSettingsStoreActions();
|
const { setPlaylistBehavior } = useSettingsStoreActions();
|
||||||
@@ -170,10 +164,19 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||||||
key: 'playlist-header-collapsed',
|
key: 'playlist-header-collapsed',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tracks = useMemo(() => {
|
||||||
|
if (!listData?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (listData as Song[]).map((song) => song.id);
|
||||||
|
}, [listData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex justify="space-between" ref={containerRef}>
|
<Flex justify="space-between" ref={containerRef}>
|
||||||
<Group gap="sm" w="100%">
|
<Group gap="sm" w="100%">
|
||||||
<Button
|
<Button
|
||||||
|
disabled={isEditMode}
|
||||||
leftSection={<Icon icon="arrowLeftRight" />}
|
leftSection={<Icon icon="arrowLeftRight" />}
|
||||||
onClick={handleToggleDisplayMode}
|
onClick={handleToggleDisplayMode}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -199,15 +202,15 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||||||
<MoreButton onClick={handleMore} />
|
<MoreButton onClick={handleMore} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
{isViewEditMode && <SaveAndReplaceButton mode={mode} playlist={detailQuery.data} />}
|
{isViewEditMode && <SaveAndReplaceButton mode={mode} songIds={tracks} />}
|
||||||
{isViewEditMode && (
|
{isViewEditMode && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setMode?.(mode === 'edit' ? 'view' : 'edit')}
|
onClick={() => setMode?.(mode === 'edit' ? 'view' : 'edit')}
|
||||||
uppercase
|
uppercase
|
||||||
variant="subtle"
|
variant={mode === 'edit' ? 'state-error' : 'subtle'}
|
||||||
>
|
>
|
||||||
{mode === 'edit'
|
{mode === 'edit'
|
||||||
? t('common.view', { postProcess: 'titleCase' })
|
? t('common.cancel', { postProcess: 'titleCase' })
|
||||||
: t('common.edit', { postProcess: 'titleCase' })}
|
: t('common.edit', { postProcess: 'titleCase' })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -248,39 +251,33 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const openSaveAndReplaceModal = (playlistId: string, updateBody: UpdatePlaylistBody) => {
|
export const openSaveAndReplaceModal = (
|
||||||
|
playlistId: string,
|
||||||
|
songIds: string[],
|
||||||
|
onSuccess: () => void,
|
||||||
|
) => {
|
||||||
openContextModal({
|
openContextModal({
|
||||||
innerProps: { playlistId, updateBody },
|
innerProps: { onSuccess, playlistId, songIds },
|
||||||
modalKey: 'saveAndReplace',
|
modalKey: 'saveAndReplace',
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string,
|
title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const SaveAndReplaceButton = ({
|
const SaveAndReplaceButton = ({ mode, songIds }: { mode?: 'edit' | 'view'; songIds: string[] }) => {
|
||||||
mode,
|
|
||||||
playlist,
|
|
||||||
}: {
|
|
||||||
mode: 'edit' | 'view' | undefined;
|
|
||||||
playlist: Playlist | undefined;
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { playlistId } = useParams() as { playlistId: string };
|
const { playlistId } = useParams() as { playlistId: string };
|
||||||
|
const { setMode } = useListContext();
|
||||||
|
|
||||||
|
const onSuccess = useCallback(() => {
|
||||||
|
setMode?.('view');
|
||||||
|
}, [setMode]);
|
||||||
|
|
||||||
const handleOpenModal = useCallback(() => {
|
const handleOpenModal = useCallback(() => {
|
||||||
if (!playlistId || !playlist) return;
|
if (!playlistId) return;
|
||||||
|
|
||||||
const updateBody: UpdatePlaylistBody = {
|
openSaveAndReplaceModal(playlistId, songIds, onSuccess);
|
||||||
comment: playlist.description ?? '',
|
}, [playlistId, songIds, onSuccess]);
|
||||||
name: playlist.name,
|
|
||||||
ownerId: playlist.ownerId ?? '',
|
|
||||||
public: playlist.public ?? false,
|
|
||||||
queryBuilderRules: playlist.rules ?? undefined,
|
|
||||||
sync: playlist.sync ?? false,
|
|
||||||
};
|
|
||||||
|
|
||||||
openSaveAndReplaceModal(playlistId, updateBody);
|
|
||||||
}, [playlistId, playlist]);
|
|
||||||
|
|
||||||
if (mode === 'view') {
|
if (mode === 'view') {
|
||||||
return null;
|
return null;
|
||||||
@@ -297,78 +294,3 @@ const SaveAndReplaceButton = ({
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
// const GenreFilterSelection = () => {
|
|
||||||
// const { t } = useTranslation();
|
|
||||||
// const { playlistId } = useParams() as { playlistId: string };
|
|
||||||
// const serverId = useCurrentServerId();
|
|
||||||
|
|
||||||
// const { data } = useQuery(playlistsQueries.songList({ query: { id: playlistId }, serverId }));
|
|
||||||
|
|
||||||
// const genres = useMemo(() => {
|
|
||||||
// const uniqueGenres = new Map<string, string>();
|
|
||||||
|
|
||||||
// data?.items.forEach((song) => {
|
|
||||||
// song.genres.forEach((genre) => {
|
|
||||||
// if (genre.id) {
|
|
||||||
// uniqueGenres.set(genre.id, genre.name);
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
|
|
||||||
// return Array.from(uniqueGenres.entries()).map(([id, name]) => ({
|
|
||||||
// label: name,
|
|
||||||
// value: id,
|
|
||||||
// }));
|
|
||||||
// }, [data?.items]);
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <Stack p="md" style={{ background: 'var(--theme-colors-surface)', height: '12rem' }}>
|
|
||||||
// <Text>{t('filter.genre', { postProcess: 'titleCase' })}</Text>
|
|
||||||
// <ScrollArea>
|
|
||||||
// <ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
|
||||||
// {genres.map((genre) => (
|
|
||||||
// <li key={genre.value}>{genre.label}</li>
|
|
||||||
// ))}
|
|
||||||
// </ul>
|
|
||||||
// </ScrollArea>
|
|
||||||
// </Stack>
|
|
||||||
// );
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const ArtistFilterSelection = () => {
|
|
||||||
// const { t } = useTranslation();
|
|
||||||
// const { playlistId } = useParams() as { playlistId: string };
|
|
||||||
// const serverId = useCurrentServerId();
|
|
||||||
|
|
||||||
// const { data } = useQuery(playlistsQueries.songList({ query: { id: playlistId }, serverId }));
|
|
||||||
|
|
||||||
// const artists = useMemo(() => {
|
|
||||||
// const uniqueArtists = new Map<string, string>();
|
|
||||||
|
|
||||||
// data?.items.forEach((song) => {
|
|
||||||
// song.artists.forEach((artist) => {
|
|
||||||
// if (artist.id) {
|
|
||||||
// uniqueArtists.set(artist.id, artist.name);
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
|
|
||||||
// return Array.from(uniqueArtists.entries()).map(([id, name]) => ({
|
|
||||||
// label: name,
|
|
||||||
// value: id,
|
|
||||||
// }));
|
|
||||||
// }, [data?.items]);
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <Stack style={{ height: '12rem' }}>
|
|
||||||
// <Text>{t('filter.artist', { postProcess: 'titleCase' })}</Text>
|
|
||||||
// <ScrollArea>
|
|
||||||
// <ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
|
||||||
// {artists.map((artist) => (
|
|
||||||
// <li key={artist.value}>{artist.label}</li>
|
|
||||||
// ))}
|
|
||||||
// </ul>
|
|
||||||
// </ScrollArea>
|
|
||||||
// </Stack>
|
|
||||||
// );
|
|
||||||
// };
|
|
||||||
|
|||||||
@@ -2,21 +2,20 @@ import { closeAllModals, ContextModalProps } from '@mantine/modals';
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
|
import { useUpdatePlaylistTracks } from '/@/renderer/features/playlists/mutations/update-playlist-tracks-mutation';
|
||||||
import { useCurrentServerId } from '/@/renderer/store';
|
import { useCurrentServerId } from '/@/renderer/store';
|
||||||
import { ConfirmModal } from '/@/shared/components/modal/modal';
|
import { ConfirmModal } from '/@/shared/components/modal/modal';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
import { UpdatePlaylistBody } from '/@/shared/types/domain-types';
|
|
||||||
|
|
||||||
export const SaveAndReplaceContextModal = ({
|
export const SaveAndReplaceContextModal = ({
|
||||||
innerProps,
|
innerProps,
|
||||||
}: ContextModalProps<{ playlistId: string; updateBody: UpdatePlaylistBody }>) => {
|
}: ContextModalProps<{ onSuccess: () => void; playlistId: string; songIds: string[] }>) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { playlistId, updateBody } = innerProps;
|
const { onSuccess, playlistId, songIds } = innerProps;
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
|
|
||||||
const updatePlaylistMutation = useUpdatePlaylist({});
|
const updatePlaylistMutation = useUpdatePlaylistTracks({});
|
||||||
|
|
||||||
const handleConfirm = useCallback(() => {
|
const handleConfirm = useCallback(() => {
|
||||||
if (!serverId || !playlistId) {
|
if (!serverId || !playlistId) {
|
||||||
@@ -27,8 +26,10 @@ export const SaveAndReplaceContextModal = ({
|
|||||||
updatePlaylistMutation.mutate(
|
updatePlaylistMutation.mutate(
|
||||||
{
|
{
|
||||||
apiClientProps: { serverId },
|
apiClientProps: { serverId },
|
||||||
body: updateBody,
|
body: {
|
||||||
query: { id: playlistId },
|
id: playlistId,
|
||||||
|
songIds,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
@@ -41,6 +42,7 @@ export const SaveAndReplaceContextModal = ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
onSuccess();
|
||||||
closeAllModals();
|
closeAllModals();
|
||||||
toast.success({
|
toast.success({
|
||||||
message: t('form.editPlaylist.success', {
|
message: t('form.editPlaylist.success', {
|
||||||
@@ -50,11 +52,11 @@ export const SaveAndReplaceContextModal = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [t, serverId, playlistId, updateBody, updatePlaylistMutation]);
|
}, [serverId, playlistId, updatePlaylistMutation, songIds, t, onSuccess]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmModal loading={updatePlaylistMutation.isPending} onConfirm={handleConfirm}>
|
<ConfirmModal loading={updatePlaylistMutation.isPending} onConfirm={handleConfirm}>
|
||||||
<Text>{t('form.editPlaylist.editNote', { postProcess: 'sentenceCase' })}</Text>
|
<Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
|
||||||
</ConfirmModal>
|
</ConfirmModal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
||||||
|
import { SetPlaylistSongsArgs } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
export const useUpdatePlaylistTracks = (args: MutationHookArgs) => {
|
||||||
|
const { options } = args || {};
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<null, AxiosError, SetPlaylistSongsArgs, null>({
|
||||||
|
mutationFn: (args) =>
|
||||||
|
api.controller.setPlaylistSongs({
|
||||||
|
...args,
|
||||||
|
apiClientProps: { serverId: args.apiClientProps.serverId },
|
||||||
|
}),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
const { apiClientProps, body } = variables;
|
||||||
|
const serverId = apiClientProps.serverId;
|
||||||
|
|
||||||
|
if (!serverId) return;
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.playlists.list(serverId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (body?.id) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.playlists.detail(serverId, body.id),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.playlists.songList(serverId, body.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query';
|
import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||||
import { SearchQuery } from '/@/shared/types/domain-types';
|
import { SearchQuery, SearchResponse } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
const SEARCH_PAGE_SIZE = 4;
|
||||||
|
|
||||||
export const searchQueries = {
|
export const searchQueries = {
|
||||||
search: (args: QueryHookArgs<SearchQuery>) => {
|
search: (args: QueryHookArgs<SearchQuery>) => {
|
||||||
@@ -18,4 +20,103 @@ export const searchQueries = {
|
|||||||
...args.options,
|
...args.options,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
searchAlbumArtistsInfinite: (args: {
|
||||||
|
enabled?: boolean;
|
||||||
|
searchTerm: string;
|
||||||
|
serverId: string | undefined;
|
||||||
|
}) => {
|
||||||
|
const { enabled = true, searchTerm, serverId } = args;
|
||||||
|
return infiniteQueryOptions({
|
||||||
|
enabled: Boolean(serverId && searchTerm && enabled),
|
||||||
|
getNextPageParam: (lastPage: SearchResponse, allPages: SearchResponse[]) => {
|
||||||
|
const len = lastPage.albumArtists.length;
|
||||||
|
if (len < SEARCH_PAGE_SIZE) return undefined;
|
||||||
|
return allPages.length * SEARCH_PAGE_SIZE;
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
queryFn: ({ pageParam, signal }) => {
|
||||||
|
if (!serverId) throw new Error('serverId required');
|
||||||
|
const startIndex = (pageParam ?? 0) as number;
|
||||||
|
return api.controller.search({
|
||||||
|
apiClientProps: { serverId, signal },
|
||||||
|
query: {
|
||||||
|
albumArtistLimit: SEARCH_PAGE_SIZE,
|
||||||
|
albumArtistStartIndex: startIndex,
|
||||||
|
albumLimit: 0,
|
||||||
|
albumStartIndex: 0,
|
||||||
|
query: searchTerm,
|
||||||
|
songLimit: 0,
|
||||||
|
songStartIndex: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
queryKey: queryKeys.search.infiniteList(serverId ?? '', 'albumArtists', searchTerm),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
searchAlbumsInfinite: (args: {
|
||||||
|
enabled?: boolean;
|
||||||
|
searchTerm: string;
|
||||||
|
serverId: string | undefined;
|
||||||
|
}) => {
|
||||||
|
const { enabled = true, searchTerm, serverId } = args;
|
||||||
|
return infiniteQueryOptions({
|
||||||
|
enabled: Boolean(serverId && searchTerm && enabled),
|
||||||
|
getNextPageParam: (lastPage: SearchResponse, allPages: SearchResponse[]) => {
|
||||||
|
const len = lastPage.albums.length;
|
||||||
|
if (len < SEARCH_PAGE_SIZE) return undefined;
|
||||||
|
return allPages.length * SEARCH_PAGE_SIZE;
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
queryFn: ({ pageParam, signal }) => {
|
||||||
|
if (!serverId) throw new Error('serverId required');
|
||||||
|
const startIndex = (pageParam ?? 0) as number;
|
||||||
|
return api.controller.search({
|
||||||
|
apiClientProps: { serverId, signal },
|
||||||
|
query: {
|
||||||
|
albumArtistLimit: 0,
|
||||||
|
albumArtistStartIndex: 0,
|
||||||
|
albumLimit: SEARCH_PAGE_SIZE,
|
||||||
|
albumStartIndex: startIndex,
|
||||||
|
query: searchTerm,
|
||||||
|
songLimit: 0,
|
||||||
|
songStartIndex: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
queryKey: queryKeys.search.infiniteList(serverId ?? '', 'albums', searchTerm),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
searchSongsInfinite: (args: {
|
||||||
|
enabled?: boolean;
|
||||||
|
searchTerm: string;
|
||||||
|
serverId: string | undefined;
|
||||||
|
}) => {
|
||||||
|
const { enabled = true, searchTerm, serverId } = args;
|
||||||
|
return infiniteQueryOptions({
|
||||||
|
enabled: Boolean(serverId && searchTerm && enabled),
|
||||||
|
getNextPageParam: (lastPage: SearchResponse, allPages: SearchResponse[]) => {
|
||||||
|
const len = lastPage.songs.length;
|
||||||
|
if (len < SEARCH_PAGE_SIZE) return undefined;
|
||||||
|
return allPages.length * SEARCH_PAGE_SIZE;
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
queryFn: ({ pageParam, signal }) => {
|
||||||
|
if (!serverId) throw new Error('serverId required');
|
||||||
|
const startIndex = (pageParam ?? 0) as number;
|
||||||
|
return api.controller.search({
|
||||||
|
apiClientProps: { serverId, signal },
|
||||||
|
query: {
|
||||||
|
albumArtistLimit: 0,
|
||||||
|
albumArtistStartIndex: 0,
|
||||||
|
albumLimit: 0,
|
||||||
|
albumStartIndex: 0,
|
||||||
|
query: searchTerm,
|
||||||
|
songLimit: SEARCH_PAGE_SIZE,
|
||||||
|
songStartIndex: startIndex,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
queryKey: queryKeys.search.infiniteList(serverId ?? '', 'songs', searchTerm),
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--theme-spacing-xs);
|
||||||
|
align-items: center;
|
||||||
|
font-size: var(--theme-font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading:focus-visible {
|
||||||
|
outline: 2px solid var(--theme-colors-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { ReactNode, useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import styles from './collapsible-command-group.module.css';
|
||||||
|
|
||||||
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
|
import { Paper } from '/@/shared/components/paper/paper';
|
||||||
|
|
||||||
|
interface CollapsibleCommandGroupProps {
|
||||||
|
children: ReactNode;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
expanded?: boolean;
|
||||||
|
heading: string;
|
||||||
|
onToggle?: () => void;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CollapsibleCommandGroup({
|
||||||
|
children,
|
||||||
|
defaultExpanded = true,
|
||||||
|
expanded: controlledExpanded,
|
||||||
|
heading,
|
||||||
|
onToggle,
|
||||||
|
subtitle,
|
||||||
|
}: CollapsibleCommandGroupProps) {
|
||||||
|
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
|
||||||
|
|
||||||
|
const isControlled = controlledExpanded !== undefined && onToggle !== undefined;
|
||||||
|
const expanded = isControlled ? controlledExpanded : internalExpanded;
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
if (isControlled) {
|
||||||
|
onToggle?.();
|
||||||
|
} else {
|
||||||
|
setInternalExpanded((prev) => !prev);
|
||||||
|
}
|
||||||
|
}, [isControlled, onToggle]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toggle],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root}>
|
||||||
|
<Paper p="sm" radius="sm" withBorder>
|
||||||
|
<div
|
||||||
|
className={styles.heading}
|
||||||
|
onClick={toggle}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<Icon className={styles.chevron} icon={expanded ? 'dropdown' : 'arrowRightS'} />
|
||||||
|
<Group justify="space-between" w="100%">
|
||||||
|
<span>{heading}</span>
|
||||||
|
{subtitle && <span className={styles.subtitle}>{subtitle}</span>}
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
{expanded && <div className={styles.items}>{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,45 +1,141 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import { Fragment, useCallback, useRef, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { generatePath, useNavigate } from 'react-router';
|
|
||||||
|
|
||||||
import { searchQueries } from '/@/renderer/features/search/api/search-api';
|
|
||||||
import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';
|
import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';
|
||||||
import { CommandItemSelectable } from '/@/renderer/features/search/components/command-item-selectable';
|
|
||||||
import { GoToCommands } from '/@/renderer/features/search/components/go-to-commands';
|
import { GoToCommands } from '/@/renderer/features/search/components/go-to-commands';
|
||||||
import { HomeCommands } from '/@/renderer/features/search/components/home-commands';
|
import { HomeCommands } from '/@/renderer/features/search/components/home-commands';
|
||||||
import { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item';
|
import { SearchAlbumArtistsSection } from '/@/renderer/features/search/components/search-album-artists-section';
|
||||||
|
import { SearchAlbumsSection } from '/@/renderer/features/search/components/search-albums-section';
|
||||||
|
import { SearchSongsSection } from '/@/renderer/features/search/components/search-songs-section';
|
||||||
import { ServerCommands } from '/@/renderer/features/search/components/server-commands';
|
import { ServerCommands } from '/@/renderer/features/search/components/server-commands';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { useAppStore } from '/@/renderer/store';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
|
||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Box } from '/@/shared/components/box/box';
|
import { Breadcrumb } from '/@/shared/components/breadcrumb/breadcrumb';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
import { Button } from '/@/shared/components/button/button';
|
||||||
|
import { Divider } from '/@/shared/components/divider/divider';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Kbd } from '/@/shared/components/kbd/kbd';
|
import { Kbd } from '/@/shared/components/kbd/kbd';
|
||||||
import { Modal } from '/@/shared/components/modal/modal';
|
import { Modal } from '/@/shared/components/modal/modal';
|
||||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||||
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
||||||
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
|
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
|
||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
|
||||||
|
|
||||||
interface CommandPaletteProps {
|
interface CommandPaletteProps {
|
||||||
modalProps: (typeof useDisclosure)['arguments'];
|
modalProps: (typeof useDisclosure)['arguments'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SEARCH_SECTION_IDS = {
|
||||||
|
albums: 'albums',
|
||||||
|
artists: 'artists',
|
||||||
|
tracks: 'tracks',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface CommandPaletteSearchProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
isHome: boolean;
|
||||||
|
onSelectResult: () => void;
|
||||||
|
query: string;
|
||||||
|
searchInputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
setQuery: (query: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandPaletteSearch({
|
||||||
|
children,
|
||||||
|
isHome,
|
||||||
|
onSelectResult,
|
||||||
|
query,
|
||||||
|
searchInputRef,
|
||||||
|
setQuery,
|
||||||
|
}: CommandPaletteSearchProps) {
|
||||||
|
const [debouncedQuery] = useDebouncedValue(query, 400);
|
||||||
|
const searchSectionsExpanded = useAppStore(
|
||||||
|
(state) => state.commandPaletteSearchSectionsExpanded,
|
||||||
|
);
|
||||||
|
const setSearchSectionExpanded = useAppStore(
|
||||||
|
(state) => state.actions.setCommandPaletteSearchSectionExpanded,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
data-autofocus
|
||||||
|
leftSection={<Icon icon="search" />}
|
||||||
|
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||||
|
ref={searchInputRef}
|
||||||
|
rightSection={
|
||||||
|
query && (
|
||||||
|
<ActionIcon
|
||||||
|
onClick={() => {
|
||||||
|
setQuery('');
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
variant="transparent"
|
||||||
|
>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</ActionIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
value={query}
|
||||||
|
/>
|
||||||
|
<Divider my="sm" />
|
||||||
|
<Command.List>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<SearchAlbumsSection
|
||||||
|
debouncedQuery={debouncedQuery ?? ''}
|
||||||
|
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.albums] ?? true}
|
||||||
|
isHome={isHome}
|
||||||
|
onSelectResult={onSelectResult}
|
||||||
|
onToggle={() =>
|
||||||
|
setSearchSectionExpanded(
|
||||||
|
SEARCH_SECTION_IDS.albums,
|
||||||
|
!(searchSectionsExpanded[SEARCH_SECTION_IDS.albums] ?? true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
query={query}
|
||||||
|
/>
|
||||||
|
<SearchAlbumArtistsSection
|
||||||
|
debouncedQuery={debouncedQuery ?? ''}
|
||||||
|
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.artists] ?? true}
|
||||||
|
isHome={isHome}
|
||||||
|
onSelectResult={onSelectResult}
|
||||||
|
onToggle={() =>
|
||||||
|
setSearchSectionExpanded(
|
||||||
|
SEARCH_SECTION_IDS.artists,
|
||||||
|
!(searchSectionsExpanded[SEARCH_SECTION_IDS.artists] ?? true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
query={query}
|
||||||
|
/>
|
||||||
|
<SearchSongsSection
|
||||||
|
debouncedQuery={debouncedQuery ?? ''}
|
||||||
|
expanded={searchSectionsExpanded[SEARCH_SECTION_IDS.tracks] ?? true}
|
||||||
|
isHome={isHome}
|
||||||
|
onSelectResult={onSelectResult}
|
||||||
|
onToggle={() =>
|
||||||
|
setSearchSectionExpanded(
|
||||||
|
SEARCH_SECTION_IDS.tracks,
|
||||||
|
!(searchSectionsExpanded[SEARCH_SECTION_IDS.tracks] ?? true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
query={query}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
{children}
|
||||||
|
</Command.List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
||||||
const navigate = useNavigate();
|
|
||||||
const server = useCurrentServer();
|
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [debouncedQuery] = useDebouncedValue(query, 400);
|
|
||||||
const [pages, setPages] = useState<CommandPalettePages[]>([CommandPalettePages.HOME]);
|
const [pages, setPages] = useState<CommandPalettePages[]>([CommandPalettePages.HOME]);
|
||||||
const activePage = pages[pages.length - 1];
|
const activePage = pages[pages.length - 1];
|
||||||
const isHome = activePage === CommandPalettePages.HOME;
|
const isHome = activePage === CommandPalettePages.HOME;
|
||||||
|
const commandRootRef = useRef<HTMLDivElement>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const popPage = useCallback(() => {
|
const popPage = useCallback(() => {
|
||||||
setPages((pages) => {
|
setPages((pages) => {
|
||||||
@@ -49,25 +145,10 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery(
|
const handleSelectResult = useCallback(() => {
|
||||||
searchQueries.search({
|
modalProps.handlers.close();
|
||||||
options: { enabled: isHome && debouncedQuery !== '' && query !== '' },
|
setQuery('');
|
||||||
query: {
|
}, [modalProps.handlers]);
|
||||||
albumArtistLimit: 4,
|
|
||||||
albumArtistStartIndex: 0,
|
|
||||||
albumLimit: 4,
|
|
||||||
albumStartIndex: 0,
|
|
||||||
query: debouncedQuery,
|
|
||||||
songLimit: 4,
|
|
||||||
songStartIndex: 0,
|
|
||||||
},
|
|
||||||
serverId: server?.id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const showAlbumGroup = isHome && Boolean(query && data && data?.albums?.length > 0);
|
|
||||||
const showArtistGroup = isHome && Boolean(query && data && data?.albumArtists?.length > 0);
|
|
||||||
const showTrackGroup = isHome && Boolean(query && data && data?.songs?.length > 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -94,19 +175,10 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
|||||||
}}
|
}}
|
||||||
size="lg"
|
size="lg"
|
||||||
styles={{
|
styles={{
|
||||||
|
body: { padding: '0' },
|
||||||
header: { display: 'none' },
|
header: { display: 'none' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap="sm" mb="1rem">
|
|
||||||
{pages.map((page, index) => (
|
|
||||||
<Fragment key={page}>
|
|
||||||
{index > 0 && ' > '}
|
|
||||||
<Button disabled size="compact-md" variant="default">
|
|
||||||
{page?.toLocaleUpperCase()}
|
|
||||||
</Button>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
<Command
|
<Command
|
||||||
filter={(value, search) => {
|
filter={(value, search) => {
|
||||||
if (value.includes(search)) return 1;
|
if (value.includes(search)) return 1;
|
||||||
@@ -115,147 +187,45 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
|||||||
}}
|
}}
|
||||||
label="Global Command Menu"
|
label="Global Command Menu"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Focus the search input when navigating with arrow keys
|
|
||||||
// to prevent the focus from staying on the command-item ActionIcon
|
|
||||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
searchInputRef.current?.focus();
|
searchInputRef.current?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Tab' && !e.shiftKey) {
|
||||||
|
const root = commandRootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const selectedItem = root.querySelector(
|
||||||
|
'[cmdk-item][aria-selected="true"]',
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
if (!selectedItem) return;
|
||||||
|
|
||||||
|
const focusTarget = selectedItem.querySelector(
|
||||||
|
'button:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
if (!focusTarget) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
focusTarget.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onValueChange={setValue}
|
onValueChange={setValue}
|
||||||
|
ref={commandRootRef}
|
||||||
value={value}
|
value={value}
|
||||||
>
|
>
|
||||||
<TextInput
|
<CommandPaletteSearch
|
||||||
data-autofocus
|
isHome={isHome}
|
||||||
leftSection={<Icon icon="search" />}
|
onSelectResult={handleSelectResult}
|
||||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
query={query}
|
||||||
ref={searchInputRef}
|
searchInputRef={searchInputRef}
|
||||||
rightSection={
|
setQuery={setQuery}
|
||||||
query && (
|
>
|
||||||
<ActionIcon
|
|
||||||
onClick={() => {
|
|
||||||
setQuery('');
|
|
||||||
searchInputRef.current?.focus();
|
|
||||||
}}
|
|
||||||
variant="transparent"
|
|
||||||
>
|
|
||||||
<Icon icon="x" />
|
|
||||||
</ActionIcon>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
value={query}
|
|
||||||
/>
|
|
||||||
<Command.Separator />
|
|
||||||
<Command.List>
|
|
||||||
<Command.Empty>No results found.</Command.Empty>
|
|
||||||
{showAlbumGroup && (
|
|
||||||
<Command.Group heading="Albums">
|
|
||||||
{data?.albums?.map((album) => (
|
|
||||||
<CommandItemSelectable
|
|
||||||
key={`search-album-${album.id}`}
|
|
||||||
onSelect={() => {
|
|
||||||
navigate(
|
|
||||||
generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
|
||||||
albumId: album.id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
modalProps.handlers.close();
|
|
||||||
setQuery('');
|
|
||||||
}}
|
|
||||||
value={`search-${album.id}`}
|
|
||||||
>
|
|
||||||
{({ isHighlighted }) => (
|
|
||||||
<LibraryCommandItem
|
|
||||||
explicitStatus={album.explicitStatus}
|
|
||||||
id={album.id}
|
|
||||||
imageId={album.imageId}
|
|
||||||
imageUrl={album.imageUrl}
|
|
||||||
isHighlighted={isHighlighted}
|
|
||||||
itemType={LibraryItem.ALBUM}
|
|
||||||
subtitle={album.albumArtists
|
|
||||||
.map((artist) => artist.name)
|
|
||||||
.join(', ')}
|
|
||||||
title={album.name}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CommandItemSelectable>
|
|
||||||
))}
|
|
||||||
</Command.Group>
|
|
||||||
)}
|
|
||||||
{showArtistGroup && (
|
|
||||||
<Command.Group heading="Artists">
|
|
||||||
{data?.albumArtists.map((artist) => (
|
|
||||||
<CommandItemSelectable
|
|
||||||
key={`artist-${artist.id}`}
|
|
||||||
onSelect={() => {
|
|
||||||
navigate(
|
|
||||||
generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
|
||||||
albumArtistId: artist.id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
modalProps.handlers.close();
|
|
||||||
setQuery('');
|
|
||||||
}}
|
|
||||||
value={`search-${artist.id}`}
|
|
||||||
>
|
|
||||||
{({ isHighlighted }) => (
|
|
||||||
<LibraryCommandItem
|
|
||||||
disabled={artist?.albumCount === 0}
|
|
||||||
id={artist.id}
|
|
||||||
imageId={artist.imageId}
|
|
||||||
imageUrl={artist.imageUrl}
|
|
||||||
isHighlighted={isHighlighted}
|
|
||||||
itemType={LibraryItem.ALBUM_ARTIST}
|
|
||||||
subtitle={
|
|
||||||
artist?.albumCount !== undefined &&
|
|
||||||
artist?.albumCount !== null
|
|
||||||
? t('entity.albumWithCount', {
|
|
||||||
count: artist.albumCount,
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
title={artist.name}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CommandItemSelectable>
|
|
||||||
))}
|
|
||||||
</Command.Group>
|
|
||||||
)}
|
|
||||||
{showTrackGroup && (
|
|
||||||
<Command.Group heading="Tracks">
|
|
||||||
{data?.songs.map((song) => (
|
|
||||||
<CommandItemSelectable
|
|
||||||
key={`artist-${song.id}`}
|
|
||||||
onSelect={() => {
|
|
||||||
navigate(
|
|
||||||
generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
|
||||||
albumId: song.albumId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
modalProps.handlers.close();
|
|
||||||
setQuery('');
|
|
||||||
}}
|
|
||||||
value={`search-${song.id}`}
|
|
||||||
>
|
|
||||||
{({ isHighlighted }) => (
|
|
||||||
<LibraryCommandItem
|
|
||||||
explicitStatus={song.explicitStatus}
|
|
||||||
id={song.id}
|
|
||||||
imageId={song.imageId}
|
|
||||||
imageUrl={song.imageUrl}
|
|
||||||
isHighlighted={isHighlighted}
|
|
||||||
itemType={LibraryItem.SONG}
|
|
||||||
song={song}
|
|
||||||
subtitle={song.artists
|
|
||||||
.map((artist) => artist.name)
|
|
||||||
.join(', ')}
|
|
||||||
title={song.name}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CommandItemSelectable>
|
|
||||||
))}
|
|
||||||
</Command.Group>
|
|
||||||
)}
|
|
||||||
{activePage === CommandPalettePages.HOME && (
|
{activePage === CommandPalettePages.HOME && (
|
||||||
<HomeCommands
|
<HomeCommands
|
||||||
handleClose={modalProps.handlers.close}
|
handleClose={modalProps.handlers.close}
|
||||||
@@ -279,21 +249,30 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
|||||||
setQuery={setQuery}
|
setQuery={setQuery}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Command.List>
|
</CommandPaletteSearch>
|
||||||
</Command>
|
</Command>
|
||||||
<Box mt="0.5rem" p="0.5rem">
|
<Divider my="sm" />
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Command.Loading>
|
<Breadcrumb separator={<Icon icon="arrowRight" />}>
|
||||||
{isHome && isLoading && query !== '' && <Spinner />}
|
{pages.map((page, index) => (
|
||||||
</Command.Loading>
|
<Button
|
||||||
<Group gap="sm">
|
key={page}
|
||||||
<Kbd size="md">ESC</Kbd>
|
onClick={() => setPages((prev) => prev.slice(0, index + 1))}
|
||||||
<Kbd size="md">↑</Kbd>
|
size="compact-xs"
|
||||||
<Kbd size="md">↓</Kbd>
|
variant="subtle"
|
||||||
<Kbd size="md">⏎</Kbd>
|
>
|
||||||
</Group>
|
{page?.toLocaleUpperCase()}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Breadcrumb>
|
||||||
|
|
||||||
|
<Group gap="sm">
|
||||||
|
<Kbd size="md">ESC</Kbd>
|
||||||
|
<Kbd size="md">↑</Kbd>
|
||||||
|
<Kbd size="md">↓</Kbd>
|
||||||
|
<Kbd size="md">⏎</Kbd>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Group>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ input[cmdk-input] {
|
|||||||
[cmdk-group-items] {
|
[cmdk-group-items] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--theme-spacing-xs);
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[cmdk-item] {
|
[cmdk-item] {
|
||||||
|
|||||||
@@ -32,3 +32,14 @@
|
|||||||
background: alpha(var(--theme-colors-foreground-muted), 0.3);
|
background: alpha(var(--theme-colors-foreground-muted), 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: var(--theme-font-size-sm);
|
||||||
|
height: var(--theme-font-size-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,24 @@ import { Text } from '/@/shared/components/text/text';
|
|||||||
import { ExplicitStatus, LibraryItem, Song } from '/@/shared/types/domain-types';
|
import { ExplicitStatus, LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||||
import { Play } from '/@/shared/types/types';
|
import { Play } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
const createPlayKeyDownHandler = (
|
||||||
|
playType: Play,
|
||||||
|
disabled: boolean,
|
||||||
|
onPlay: (type: Play) => void,
|
||||||
|
) => {
|
||||||
|
return (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!disabled) {
|
||||||
|
onPlay(playType);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
interface LibraryCommandItemProps {
|
interface LibraryCommandItemProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
explicitStatus?: ExplicitStatus | null;
|
explicitStatus?: ExplicitStatus | null;
|
||||||
@@ -113,35 +131,53 @@ export const LibraryCommandItem = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.metadataWrapper}>
|
<div className={styles.metadataWrapper}>
|
||||||
<Text overflow="hidden">{title}</Text>
|
<Text overflow="hidden">{title}</Text>
|
||||||
<Text isMuted overflow="hidden">
|
<Text isMuted overflow="hidden" size="sm">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showControls && (
|
{showControls && (
|
||||||
<ActionIconGroup>
|
<ActionIconGroup className={styles.controls}>
|
||||||
<PlayTooltip disabled={disabled} type={Play.NOW}>
|
<PlayTooltip disabled={disabled} type={Play.NOW}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="mediaPlay"
|
icon="mediaPlay"
|
||||||
variant="subtle"
|
size="xs"
|
||||||
|
variant="default"
|
||||||
{...handlePlayNow.handlers}
|
{...handlePlayNow.handlers}
|
||||||
{...handlePlayNow.props}
|
{...handlePlayNow.props}
|
||||||
|
onKeyDown={createPlayKeyDownHandler(
|
||||||
|
Play.NOW,
|
||||||
|
Boolean(disabled ?? handlePlayNow.props.disabled),
|
||||||
|
handlePlay,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</PlayTooltip>
|
</PlayTooltip>
|
||||||
<PlayTooltip disabled={disabled} type={Play.NEXT}>
|
<PlayTooltip disabled={disabled} type={Play.NEXT}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="mediaPlayNext"
|
icon="mediaPlayNext"
|
||||||
variant="subtle"
|
size="xs"
|
||||||
|
variant="default"
|
||||||
{...handlePlayNext.handlers}
|
{...handlePlayNext.handlers}
|
||||||
{...handlePlayNext.props}
|
{...handlePlayNext.props}
|
||||||
|
onKeyDown={createPlayKeyDownHandler(
|
||||||
|
Play.NEXT,
|
||||||
|
Boolean(disabled ?? handlePlayNext.props.disabled),
|
||||||
|
handlePlay,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</PlayTooltip>
|
</PlayTooltip>
|
||||||
<PlayTooltip disabled={disabled} type={Play.LAST}>
|
<PlayTooltip disabled={disabled} type={Play.LAST}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="mediaPlayLast"
|
icon="mediaPlayLast"
|
||||||
variant="subtle"
|
size="xs"
|
||||||
|
variant="default"
|
||||||
{...handlePlayLast.handlers}
|
{...handlePlayLast.handlers}
|
||||||
{...handlePlayLast.props}
|
{...handlePlayLast.props}
|
||||||
|
onKeyDown={createPlayKeyDownHandler(
|
||||||
|
Play.LAST,
|
||||||
|
Boolean(disabled ?? handlePlayLast.props.disabled),
|
||||||
|
handlePlay,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</PlayTooltip>
|
</PlayTooltip>
|
||||||
</ActionIconGroup>
|
</ActionIconGroup>
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { nanoid } from 'nanoid/non-secure';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { createSearchParams, generatePath, useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { searchQueries } from '/@/renderer/features/search/api/search-api';
|
||||||
|
import { CollapsibleCommandGroup } from '/@/renderer/features/search/components/collapsible-command-group';
|
||||||
|
import { CommandItemSelectable } from '/@/renderer/features/search/components/command-item-selectable';
|
||||||
|
import { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item';
|
||||||
|
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||||
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { Box } from '/@/shared/components/box/box';
|
||||||
|
import { Button } from '/@/shared/components/button/button';
|
||||||
|
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||||
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
interface SearchAlbumArtistsSectionProps {
|
||||||
|
debouncedQuery: string;
|
||||||
|
expanded: boolean;
|
||||||
|
isHome: boolean;
|
||||||
|
onSelectResult: () => void;
|
||||||
|
onToggle: () => void;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchAlbumArtistsSection({
|
||||||
|
debouncedQuery,
|
||||||
|
expanded,
|
||||||
|
isHome,
|
||||||
|
onSelectResult,
|
||||||
|
onToggle,
|
||||||
|
query,
|
||||||
|
}: SearchAlbumArtistsSectionProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const server = useCurrentServer();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, fetchNextPage, hasNextPage, isFetched, isFetchingNextPage, isLoading } =
|
||||||
|
useInfiniteQuery(
|
||||||
|
searchQueries.searchAlbumArtistsInfinite({
|
||||||
|
enabled: isHome && debouncedQuery !== '' && query !== '',
|
||||||
|
searchTerm: debouncedQuery,
|
||||||
|
serverId: server?.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const artists = data?.pages.flatMap((p) => p.albumArtists) ?? [];
|
||||||
|
const showSection = isHome;
|
||||||
|
const numberOfResults = hasNextPage ? `${artists.length}+` : artists.length;
|
||||||
|
|
||||||
|
const handleGoToPage = useCallback(() => {
|
||||||
|
navigate(
|
||||||
|
{
|
||||||
|
pathname: AppRoute.LIBRARY_ALBUM_ARTISTS,
|
||||||
|
search: createSearchParams({
|
||||||
|
[FILTER_KEYS.SHARED.SEARCH_TERM]: debouncedQuery || query,
|
||||||
|
}).toString(),
|
||||||
|
},
|
||||||
|
{ state: { navigationId: nanoid() } },
|
||||||
|
);
|
||||||
|
onSelectResult();
|
||||||
|
}, [debouncedQuery, navigate, onSelectResult, query]);
|
||||||
|
|
||||||
|
if (!showSection) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleCommandGroup
|
||||||
|
expanded={expanded}
|
||||||
|
heading={t('entity.albumArtist', { count: 2, postProcess: 'titleCase' })}
|
||||||
|
onToggle={onToggle}
|
||||||
|
subtitle={
|
||||||
|
isFetched ? (
|
||||||
|
<>
|
||||||
|
{query ? (
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleGoToPage();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="filled"
|
||||||
|
w="8rem"
|
||||||
|
>
|
||||||
|
{t('common.numberOfResults', { numberOfResults })}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Box p="md">
|
||||||
|
<Spinner container />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{artists.map((artist) => (
|
||||||
|
<CommandItemSelectable
|
||||||
|
key={`search-artist-${artist.id}`}
|
||||||
|
onSelect={() => {
|
||||||
|
navigate(
|
||||||
|
generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||||
|
albumArtistId: artist.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
onSelectResult();
|
||||||
|
}}
|
||||||
|
value={`search-artist-${artist.id}`}
|
||||||
|
>
|
||||||
|
{({ isHighlighted }) => (
|
||||||
|
<LibraryCommandItem
|
||||||
|
disabled={artist?.albumCount === 0}
|
||||||
|
id={artist.id}
|
||||||
|
imageId={artist.imageId}
|
||||||
|
imageUrl={artist.imageUrl}
|
||||||
|
isHighlighted={isHighlighted}
|
||||||
|
itemType={LibraryItem.ALBUM_ARTIST}
|
||||||
|
subtitle={
|
||||||
|
artist?.albumCount !== undefined &&
|
||||||
|
artist?.albumCount !== null
|
||||||
|
? t('entity.albumWithCount', {
|
||||||
|
count: artist.albumCount,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
title={artist.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CommandItemSelectable>
|
||||||
|
))}
|
||||||
|
{hasNextPage && (
|
||||||
|
<CommandItemSelectable
|
||||||
|
disabled={isFetchingNextPage}
|
||||||
|
onSelect={() => fetchNextPage()}
|
||||||
|
value="search-artists-load-more"
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<Text>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Text size="sm">
|
||||||
|
{t('action.viewMore', { postProcess: 'titleCase' })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</CommandItemSelectable>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CollapsibleCommandGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { nanoid } from 'nanoid/non-secure';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { createSearchParams, generatePath, useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { searchQueries } from '/@/renderer/features/search/api/search-api';
|
||||||
|
import { CollapsibleCommandGroup } from '/@/renderer/features/search/components/collapsible-command-group';
|
||||||
|
import { CommandItemSelectable } from '/@/renderer/features/search/components/command-item-selectable';
|
||||||
|
import { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item';
|
||||||
|
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||||
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { Box } from '/@/shared/components/box/box';
|
||||||
|
import { Button } from '/@/shared/components/button/button';
|
||||||
|
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||||
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
interface SearchAlbumsSectionProps {
|
||||||
|
debouncedQuery: string;
|
||||||
|
expanded: boolean;
|
||||||
|
isHome: boolean;
|
||||||
|
onSelectResult: () => void;
|
||||||
|
onToggle: () => void;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchAlbumsSection({
|
||||||
|
debouncedQuery,
|
||||||
|
expanded,
|
||||||
|
isHome,
|
||||||
|
onSelectResult,
|
||||||
|
onToggle,
|
||||||
|
query,
|
||||||
|
}: SearchAlbumsSectionProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const server = useCurrentServer();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, fetchNextPage, hasNextPage, isFetched, isFetchingNextPage, isLoading } =
|
||||||
|
useInfiniteQuery(
|
||||||
|
searchQueries.searchAlbumsInfinite({
|
||||||
|
enabled: isHome && debouncedQuery !== '' && query !== '',
|
||||||
|
searchTerm: debouncedQuery,
|
||||||
|
serverId: server?.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const albums = data?.pages.flatMap((p) => p.albums) ?? [];
|
||||||
|
const showSection = isHome;
|
||||||
|
const numberOfResults = hasNextPage ? `${albums.length}+` : albums.length;
|
||||||
|
|
||||||
|
const handleGoToPage = useCallback(() => {
|
||||||
|
navigate(
|
||||||
|
{
|
||||||
|
pathname: AppRoute.LIBRARY_ALBUMS,
|
||||||
|
search: createSearchParams({
|
||||||
|
[FILTER_KEYS.SHARED.SEARCH_TERM]: debouncedQuery || query,
|
||||||
|
}).toString(),
|
||||||
|
},
|
||||||
|
{ state: { navigationId: nanoid() } },
|
||||||
|
);
|
||||||
|
onSelectResult();
|
||||||
|
}, [debouncedQuery, navigate, onSelectResult, query]);
|
||||||
|
|
||||||
|
if (!showSection) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleCommandGroup
|
||||||
|
expanded={expanded}
|
||||||
|
heading={t('entity.album', { count: 2, postProcess: 'titleCase' })}
|
||||||
|
onToggle={onToggle}
|
||||||
|
subtitle={
|
||||||
|
isFetched ? (
|
||||||
|
<>
|
||||||
|
{query ? (
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleGoToPage();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="filled"
|
||||||
|
w="8rem"
|
||||||
|
>
|
||||||
|
{t('common.numberOfResults', { numberOfResults })}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Box p="md">
|
||||||
|
<Spinner container />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{albums.map((album) => (
|
||||||
|
<CommandItemSelectable
|
||||||
|
key={`search-album-${album.id}`}
|
||||||
|
onSelect={() => {
|
||||||
|
navigate(
|
||||||
|
generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||||
|
albumId: album.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
onSelectResult();
|
||||||
|
}}
|
||||||
|
value={`search-album-${album.id}`}
|
||||||
|
>
|
||||||
|
{({ isHighlighted }) => (
|
||||||
|
<LibraryCommandItem
|
||||||
|
explicitStatus={album.explicitStatus}
|
||||||
|
id={album.id}
|
||||||
|
imageId={album.imageId}
|
||||||
|
imageUrl={album.imageUrl}
|
||||||
|
isHighlighted={isHighlighted}
|
||||||
|
itemType={LibraryItem.ALBUM}
|
||||||
|
subtitle={album.albumArtists
|
||||||
|
.map((artist) => artist.name)
|
||||||
|
.join(', ')}
|
||||||
|
title={album.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CommandItemSelectable>
|
||||||
|
))}
|
||||||
|
{hasNextPage && (
|
||||||
|
<CommandItemSelectable
|
||||||
|
disabled={isFetchingNextPage}
|
||||||
|
onSelect={() => fetchNextPage()}
|
||||||
|
value="search-albums-load-more"
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Text size="sm">
|
||||||
|
{t('action.viewMore', { postProcess: 'titleCase' })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CommandItemSelectable>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CollapsibleCommandGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { nanoid } from 'nanoid/non-secure';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { createSearchParams, generatePath, useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { searchQueries } from '/@/renderer/features/search/api/search-api';
|
||||||
|
import { CollapsibleCommandGroup } from '/@/renderer/features/search/components/collapsible-command-group';
|
||||||
|
import { CommandItemSelectable } from '/@/renderer/features/search/components/command-item-selectable';
|
||||||
|
import { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item';
|
||||||
|
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||||
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { Box } from '/@/shared/components/box/box';
|
||||||
|
import { Button } from '/@/shared/components/button/button';
|
||||||
|
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||||
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
interface SearchSongsSectionProps {
|
||||||
|
debouncedQuery: string;
|
||||||
|
expanded: boolean;
|
||||||
|
isHome: boolean;
|
||||||
|
onSelectResult: () => void;
|
||||||
|
onToggle: () => void;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchSongsSection({
|
||||||
|
debouncedQuery,
|
||||||
|
expanded,
|
||||||
|
isHome,
|
||||||
|
onSelectResult,
|
||||||
|
onToggle,
|
||||||
|
query,
|
||||||
|
}: SearchSongsSectionProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const server = useCurrentServer();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, fetchNextPage, hasNextPage, isFetched, isFetchingNextPage, isLoading } =
|
||||||
|
useInfiniteQuery(
|
||||||
|
searchQueries.searchSongsInfinite({
|
||||||
|
enabled: isHome && debouncedQuery !== '' && query !== '',
|
||||||
|
searchTerm: debouncedQuery,
|
||||||
|
serverId: server?.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const songs = data?.pages.flatMap((p) => p.songs) ?? [];
|
||||||
|
const showSection = isHome;
|
||||||
|
const numberOfResults = hasNextPage ? `${songs.length}+` : songs.length;
|
||||||
|
|
||||||
|
const handleGoToPage = useCallback(() => {
|
||||||
|
navigate(
|
||||||
|
{
|
||||||
|
pathname: AppRoute.LIBRARY_SONGS,
|
||||||
|
search: createSearchParams({
|
||||||
|
[FILTER_KEYS.SHARED.SEARCH_TERM]: debouncedQuery || query,
|
||||||
|
}).toString(),
|
||||||
|
},
|
||||||
|
{ state: { navigationId: nanoid() } },
|
||||||
|
);
|
||||||
|
onSelectResult();
|
||||||
|
}, [debouncedQuery, navigate, onSelectResult, query]);
|
||||||
|
|
||||||
|
if (!showSection) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleCommandGroup
|
||||||
|
expanded={expanded}
|
||||||
|
heading={t('entity.track', { count: 2, postProcess: 'titleCase' })}
|
||||||
|
onToggle={onToggle}
|
||||||
|
subtitle={
|
||||||
|
isFetched ? (
|
||||||
|
<>
|
||||||
|
{query ? (
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleGoToPage();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="filled"
|
||||||
|
w="8rem"
|
||||||
|
>
|
||||||
|
{t('common.numberOfResults', { numberOfResults })}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Box p="md">
|
||||||
|
<Spinner container />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{songs.map((song) => (
|
||||||
|
<CommandItemSelectable
|
||||||
|
key={`search-song-${song.id}`}
|
||||||
|
onSelect={() => {
|
||||||
|
navigate(
|
||||||
|
generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||||
|
albumId: song.albumId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
onSelectResult();
|
||||||
|
}}
|
||||||
|
value={`search-song-${song.id}`}
|
||||||
|
>
|
||||||
|
{({ isHighlighted }) => (
|
||||||
|
<LibraryCommandItem
|
||||||
|
explicitStatus={song.explicitStatus}
|
||||||
|
id={song.id}
|
||||||
|
imageId={song.imageId}
|
||||||
|
imageUrl={song.imageUrl}
|
||||||
|
isHighlighted={isHighlighted}
|
||||||
|
itemType={LibraryItem.SONG}
|
||||||
|
song={song}
|
||||||
|
subtitle={song.artists.map((artist) => artist.name).join(', ')}
|
||||||
|
title={song.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CommandItemSelectable>
|
||||||
|
))}
|
||||||
|
{hasNextPage && (
|
||||||
|
<CommandItemSelectable
|
||||||
|
disabled={isFetchingNextPage}
|
||||||
|
onSelect={() => fetchNextPage()}
|
||||||
|
value="search-songs-load-more"
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Text size="sm">
|
||||||
|
{t('action.viewMore', { postProcess: 'titleCase' })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CommandItemSelectable>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CollapsibleCommandGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '/@/renderer/features/settings/components/settings-section';
|
} from '/@/renderer/features/settings/components/settings-section';
|
||||||
import {
|
import {
|
||||||
HomeFeatureStyle,
|
HomeFeatureStyle,
|
||||||
|
SideQueueLayout,
|
||||||
SideQueueType,
|
SideQueueType,
|
||||||
useFontSettings,
|
useFontSettings,
|
||||||
useGeneralSettings,
|
useGeneralSettings,
|
||||||
@@ -74,6 +75,23 @@ const SIDE_QUEUE_OPTIONS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SIDE_QUEUE_LAYOUT_OPTIONS = [
|
||||||
|
{
|
||||||
|
label: t('setting.sidePlayQueueLayout', {
|
||||||
|
context: 'optionHorizontal',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
value: 'horizontal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('setting.sidePlayQueueLayout', {
|
||||||
|
context: 'optionVertical',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
value: 'vertical',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const FONT_TYPES: Font[] = [
|
const FONT_TYPES: Font[] = [
|
||||||
{
|
{
|
||||||
label: i18n.t('setting.fontType', {
|
label: i18n.t('setting.fontType', {
|
||||||
@@ -541,107 +559,26 @@ export const ApplicationSettings = memo(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
control: (
|
control: (
|
||||||
<Switch
|
<SegmentedControl
|
||||||
defaultChecked={settings.externalLinks}
|
aria-label={t('setting.sidePlayQueueLayout', { postProcess: 'sentenceCase' })}
|
||||||
onChange={(e) => {
|
data={SIDE_QUEUE_LAYOUT_OPTIONS}
|
||||||
|
defaultValue={settings.sideQueueLayout}
|
||||||
|
onChange={(e) =>
|
||||||
setSettings({
|
setSettings({
|
||||||
general: {
|
general: {
|
||||||
...settings,
|
...settings,
|
||||||
externalLinks: e.currentTarget.checked,
|
sideQueueLayout: e as SideQueueLayout,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}}
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
description: t('setting.externalLinks', {
|
description: t('setting.sidePlayQueueLayout', {
|
||||||
context: 'description',
|
context: 'description',
|
||||||
postProcess: 'sentenceCase',
|
postProcess: 'sentenceCase',
|
||||||
}),
|
}),
|
||||||
title: t('setting.externalLinks', { postProcess: 'sentenceCase' }),
|
isHidden: settings.sideQueueType !== 'sideQueue',
|
||||||
},
|
title: t('setting.sidePlayQueueLayout', { postProcess: 'sentenceCase' }),
|
||||||
{
|
|
||||||
control: (
|
|
||||||
<Switch
|
|
||||||
defaultChecked={settings.lastFM}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSettings({
|
|
||||||
general: {
|
|
||||||
...settings,
|
|
||||||
lastFM: e.currentTarget.checked,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
description: t('setting.lastfm', {
|
|
||||||
context: 'description',
|
|
||||||
postProcess: 'sentenceCase',
|
|
||||||
}),
|
|
||||||
isHidden: !settings.externalLinks,
|
|
||||||
title: t('setting.lastfm', { postProcess: 'sentenceCase' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
control: (
|
|
||||||
<Switch
|
|
||||||
defaultChecked={settings.musicBrainz}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSettings({
|
|
||||||
general: {
|
|
||||||
...settings,
|
|
||||||
musicBrainz: e.currentTarget.checked,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
description: t('setting.musicbrainz', {
|
|
||||||
context: 'description',
|
|
||||||
postProcess: 'sentenceCase',
|
|
||||||
}),
|
|
||||||
isHidden: !settings.externalLinks,
|
|
||||||
title: t('setting.musicbrainz', { postProcess: 'sentenceCase' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
control: (
|
|
||||||
<Switch
|
|
||||||
defaultChecked={settings.spotify}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSettings({
|
|
||||||
general: {
|
|
||||||
...settings,
|
|
||||||
spotify: e.currentTarget.checked,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
description: t('setting.spotify', {
|
|
||||||
context: 'description',
|
|
||||||
postProcess: 'sentenceCase',
|
|
||||||
}),
|
|
||||||
isHidden: !settings.externalLinks,
|
|
||||||
title: t('setting.spotify', { postProcess: 'sentenceCase' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
control: (
|
|
||||||
<Switch
|
|
||||||
defaultChecked={settings.nativeSpotify}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSettings({
|
|
||||||
general: {
|
|
||||||
...settings,
|
|
||||||
nativeSpotify: e.currentTarget.checked,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
description: t('setting.nativeSpotify', {
|
|
||||||
context: 'description',
|
|
||||||
postProcess: 'sentenceCase',
|
|
||||||
}),
|
|
||||||
isHidden: !settings.externalLinks || !settings.spotify,
|
|
||||||
title: t('setting.nativeSpotify', { postProcess: 'sentenceCase' }),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
control: (
|
control: (
|
||||||
|
|||||||
@@ -477,6 +477,36 @@ export const ControlSettings = memo(() => {
|
|||||||
postProcess: 'sentenceCase',
|
postProcess: 'sentenceCase',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<NumberInput
|
||||||
|
defaultValue={playerbarSlider?.loadingDelay ?? 2}
|
||||||
|
max={30}
|
||||||
|
min={0}
|
||||||
|
onBlur={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
playerbarSlider: {
|
||||||
|
...playerbarSlider,
|
||||||
|
loadingDelay: e.currentTarget.value
|
||||||
|
? Number(e.currentTarget.value)
|
||||||
|
: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
rightSection={<Text size="sm">s</Text>}
|
||||||
|
width={75}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.waveformLoadingDelay', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
isHidden: false,
|
||||||
|
title: t('setting.waveformLoadingDelay', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingOption,
|
||||||
|
SettingsSection,
|
||||||
|
} from '/@/renderer/features/settings/components/settings-section';
|
||||||
|
import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||||
|
import { Switch } from '/@/shared/components/switch/switch';
|
||||||
|
|
||||||
|
export const ExternalLinksSettings = memo(() => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const settings = useGeneralSettings();
|
||||||
|
const { setSettings } = useSettingsStoreActions();
|
||||||
|
|
||||||
|
const options: SettingOption[] = [
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={settings.externalLinks}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
externalLinks: e.currentTarget.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.externalLinks', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
title: t('setting.externalLinks', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={settings.lastFM}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
lastFM: e.currentTarget.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.lastfm', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
isHidden: !settings.externalLinks,
|
||||||
|
title: t('setting.lastfm', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={settings.listenBrainz}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
listenBrainz: e.currentTarget.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.listenbrainz', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
isHidden: !settings.externalLinks,
|
||||||
|
title: t('setting.listenbrainz', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={settings.musicBrainz}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
musicBrainz: e.currentTarget.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.musicbrainz', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
isHidden: !settings.externalLinks,
|
||||||
|
title: t('setting.musicbrainz', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={settings.qobuz}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
qobuz: e.currentTarget.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.qobuz', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
isHidden: !settings.externalLinks,
|
||||||
|
title: t('setting.qobuz', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={settings.spotify}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
spotify: e.currentTarget.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.spotify', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
isHidden: !settings.externalLinks,
|
||||||
|
title: t('setting.spotify', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={settings.nativeSpotify}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
nativeSpotify: e.currentTarget.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.nativeSpotify', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
isHidden: !settings.externalLinks || !settings.spotify,
|
||||||
|
title: t('setting.nativeSpotify', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
options={options}
|
||||||
|
title={t('common.externalLinks', { postProcess: 'sentenceCase' })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { Fragment } from 'react/jsx-runtime';
|
|||||||
|
|
||||||
import { ApplicationSettings } from '/@/renderer/features/settings/components/general/application-settings';
|
import { ApplicationSettings } from '/@/renderer/features/settings/components/general/application-settings';
|
||||||
import { ControlSettings } from '/@/renderer/features/settings/components/general/control-settings';
|
import { ControlSettings } from '/@/renderer/features/settings/components/general/control-settings';
|
||||||
|
import { ExternalLinksSettings } from '/@/renderer/features/settings/components/general/external-links-settings';
|
||||||
import { LyricSettings } from '/@/renderer/features/settings/components/general/lyric-settings';
|
import { LyricSettings } from '/@/renderer/features/settings/components/general/lyric-settings';
|
||||||
import { QueryBuilderSettings } from '/@/renderer/features/settings/components/general/query-builder-settings';
|
import { QueryBuilderSettings } from '/@/renderer/features/settings/components/general/query-builder-settings';
|
||||||
import { ScrobbleSettings } from '/@/renderer/features/settings/components/general/scrobble-settings';
|
import { ScrobbleSettings } from '/@/renderer/features/settings/components/general/scrobble-settings';
|
||||||
@@ -22,6 +23,7 @@ export const GeneralTab = memo(() => {
|
|||||||
const baseSections = [
|
const baseSections = [
|
||||||
{ component: ThemeSettings, key: 'theme' },
|
{ component: ThemeSettings, key: 'theme' },
|
||||||
{ component: ApplicationSettings, key: 'application' },
|
{ component: ApplicationSettings, key: 'application' },
|
||||||
|
{ component: ExternalLinksSettings, key: 'externalLinks' },
|
||||||
{ component: ControlSettings, key: 'control' },
|
{ component: ControlSettings, key: 'control' },
|
||||||
{ component: SidebarSettings, key: 'sidebar' },
|
{ component: SidebarSettings, key: 'sidebar' },
|
||||||
{ component: ScrobbleSettings, key: 'scrobble' },
|
{ component: ScrobbleSettings, key: 'scrobble' },
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
|
import { useLocation } from 'react-router';
|
||||||
|
|
||||||
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
||||||
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
|
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
|
||||||
|
|
||||||
|
function navigationIdFromState(state: unknown): string | undefined {
|
||||||
|
if (state && typeof state === 'object' && 'navigationId' in state) {
|
||||||
|
const id = (state as { navigationId: unknown }).navigationId;
|
||||||
|
return typeof id === 'string' ? id : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export const ListSearchInput = () => {
|
export const ListSearchInput = () => {
|
||||||
const { searchTerm, setSearchTerm } = useSearchTermFilter();
|
const { searchTerm, setSearchTerm } = useSearchTermFilter();
|
||||||
|
const { state } = useLocation();
|
||||||
|
const navigationId = navigationIdFromState(state);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchInput
|
<SearchInput
|
||||||
defaultValue={searchTerm}
|
defaultValue={searchTerm}
|
||||||
|
key={navigationId ?? 'list-search-input'}
|
||||||
onChange={(e) => setSearchTerm(e.target.value || null)}
|
onChange={(e) => setSearchTerm(e.target.value || null)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
|
|
||||||
.handle-top {
|
.handle-top {
|
||||||
top: 0;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
cursor: ns-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handle-right {
|
.handle-right {
|
||||||
@@ -29,6 +33,10 @@
|
|||||||
|
|
||||||
.handle-bottom {
|
.handle-bottom {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
cursor: ns-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handle-left {
|
.handle-left {
|
||||||
|
|||||||
@@ -10,8 +10,16 @@ import { isServerLock } from '/@/renderer/features/action-required/utils/window-
|
|||||||
import { ServerList } from '/@/renderer/features/servers/components/server-list';
|
import { ServerList } from '/@/renderer/features/servers/components/server-list';
|
||||||
import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';
|
import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';
|
||||||
import { openReleaseNotesModal } from '/@/renderer/release-notes-modal';
|
import { openReleaseNotesModal } from '/@/renderer/release-notes-modal';
|
||||||
import { useAppStore, useAppStoreActions, useCommandPalette } from '/@/renderer/store';
|
import {
|
||||||
|
useAppStore,
|
||||||
|
useAppStoreActions,
|
||||||
|
useCommandPalette,
|
||||||
|
useGeneralSettings,
|
||||||
|
useSettingsStoreActions,
|
||||||
|
} from '/@/renderer/store';
|
||||||
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { DropdownMenu, MenuItemProps } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
import { DropdownMenu, MenuItemProps } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||||
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
|
|
||||||
@@ -74,6 +82,8 @@ export const AppMenu = () => {
|
|||||||
const collapsed = useAppStore((state) => state.sidebar.collapsed);
|
const collapsed = useAppStore((state) => state.sidebar.collapsed);
|
||||||
const privateMode = useAppStore((state) => state.privateMode);
|
const privateMode = useAppStore((state) => state.privateMode);
|
||||||
const { setPrivateMode, setSideBar } = useAppStoreActions();
|
const { setPrivateMode, setSideBar } = useAppStoreActions();
|
||||||
|
const { setSettings } = useSettingsStoreActions();
|
||||||
|
const settings = useGeneralSettings();
|
||||||
const { open: openCommandPalette } = useCommandPalette();
|
const { open: openCommandPalette } = useCommandPalette();
|
||||||
|
|
||||||
const handleBrowserDevTools = () => {
|
const handleBrowserDevTools = () => {
|
||||||
@@ -115,6 +125,15 @@ export const AppMenu = () => {
|
|||||||
browser?.quit();
|
browser?.quit();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSetSideQueueLayout = (sideQueueLayout: 'horizontal' | 'vertical') => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
sideQueueLayout,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const menuConfig: MenuItem[] = [
|
const menuConfig: MenuItem[] = [
|
||||||
{
|
{
|
||||||
icon: 'search',
|
icon: 'search',
|
||||||
@@ -265,6 +284,65 @@ export const AppMenu = () => {
|
|||||||
},
|
},
|
||||||
type: 'conditional-item',
|
type: 'conditional-item',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'divider-5',
|
||||||
|
type: 'divider',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: settings.sideQueueType === 'sideQueue',
|
||||||
|
id: 'layout-toggle-group',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
component: (
|
||||||
|
<Group gap="xs" grow pb="xs" pt="sm" px="xs" w="100%">
|
||||||
|
<ActionIcon
|
||||||
|
icon="layoutPanelRight"
|
||||||
|
iconProps={{
|
||||||
|
size: 'xl',
|
||||||
|
}}
|
||||||
|
onClick={() => handleSetSideQueueLayout('horizontal')}
|
||||||
|
tooltip={{
|
||||||
|
label: t('setting.sidePlayQueueLayout', {
|
||||||
|
context: 'optionHorizontal',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
openDelay: 0,
|
||||||
|
position: 'bottom',
|
||||||
|
}}
|
||||||
|
variant={
|
||||||
|
settings.sideQueueLayout === 'horizontal'
|
||||||
|
? 'default'
|
||||||
|
: 'transparent'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
icon="layoutPanelBottom"
|
||||||
|
iconProps={{
|
||||||
|
size: 'xl',
|
||||||
|
}}
|
||||||
|
onClick={() => handleSetSideQueueLayout('vertical')}
|
||||||
|
tooltip={{
|
||||||
|
label: t('setting.sidePlayQueueLayout', {
|
||||||
|
context: 'optionVertical',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
openDelay: 0,
|
||||||
|
position: 'bottom',
|
||||||
|
}}
|
||||||
|
variant={
|
||||||
|
settings.sideQueueLayout === 'vertical'
|
||||||
|
? 'default'
|
||||||
|
: 'transparent'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
id: 'layout-toggle',
|
||||||
|
type: 'custom',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type: 'conditional-group',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderMenuItem = (item: MenuItem): ReactNode => {
|
const renderMenuItem = (item: MenuItem): ReactNode => {
|
||||||
|
|||||||
@@ -4,9 +4,14 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta http-equiv="Content-Security-Policy" />
|
<meta http-equiv="Content-Security-Policy" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>Feishin</title>
|
||||||
|
<% if (web) { %>
|
||||||
|
<link rel="icon" href="./assets/favicon.ico" />
|
||||||
|
<script src="settings.js"></script>
|
||||||
|
<% } %>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
if (localStorage.getItem('umami.disabled') !== '1') {
|
if (localStorage.getItem('umami.disabled') !== '1' && window.ANALYTICS_DISABLED !== true && window.ANALYTICS_DISABLED !== 'true') {
|
||||||
var s = document.createElement('script');
|
var s = document.createElement('script');
|
||||||
s.defer = true;
|
s.defer = true;
|
||||||
s.src = 'https://umami.jeffvli.org/script.js';
|
s.src = 'https://umami.jeffvli.org/script.js';
|
||||||
@@ -18,11 +23,6 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<title>Feishin</title>
|
|
||||||
<% if (web) { %>
|
|
||||||
<link rel="icon" href="./assets/favicon.ico" />
|
|
||||||
<script src="settings.js"></script>
|
|
||||||
<% } %>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body style="background-color: #000">
|
<body style="background-color: #000">
|
||||||
|
|||||||
@@ -28,6 +28,23 @@
|
|||||||
grid-template-columns: 80px 1fr var(--right-sidebar-width);
|
grid-template-columns: 80px 1fr var(--right-sidebar-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main-content-container.vertical-layout {
|
||||||
|
grid-template-areas:
|
||||||
|
'sidebar .'
|
||||||
|
'sidebar right-sidebar';
|
||||||
|
grid-template-rows: minmax(0, 1fr) var(--right-sidebar-height);
|
||||||
|
grid-template-columns: var(--sidebar-width) 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content-container.sidebar-collapsed.vertical-layout {
|
||||||
|
grid-template-columns: 80px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content-container.vertical-layout #sidebar-queue {
|
||||||
|
border-top: 1px solid alpha(var(--theme-colors-border), 0.5);
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.main-content-body {
|
.main-content-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
useAppStore,
|
useAppStore,
|
||||||
useAppStoreActions,
|
useAppStoreActions,
|
||||||
useGlobalExpanded,
|
useGlobalExpanded,
|
||||||
|
useSideQueueLayout,
|
||||||
useSideQueueType,
|
useSideQueueType,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
import { constrainRightSidebarWidth, constrainSidebarWidth } from '/@/renderer/utils';
|
import { constrainRightSidebarWidth, constrainSidebarWidth } from '/@/renderer/utils';
|
||||||
@@ -24,56 +25,77 @@ import { Spinner } from '/@/shared/components/spinner/spinner';
|
|||||||
const MINIMUM_SIDEBAR_WIDTH = 260;
|
const MINIMUM_SIDEBAR_WIDTH = 260;
|
||||||
|
|
||||||
export const MainContent = ({ shell }: { shell?: boolean }) => {
|
export const MainContent = ({ shell }: { shell?: boolean }) => {
|
||||||
const { collapsed, leftWidth, rightExpanded, rightWidth } = useAppStore(
|
const { collapsed, leftWidth, rightExpanded, rightHeight, rightWidth } = useAppStore(
|
||||||
(state) => ({
|
(state) => ({
|
||||||
collapsed: state.sidebar.collapsed,
|
collapsed: state.sidebar.collapsed,
|
||||||
leftWidth: state.sidebar.leftWidth,
|
leftWidth: state.sidebar.leftWidth,
|
||||||
rightExpanded: state.sidebar.rightExpanded,
|
rightExpanded: state.sidebar.rightExpanded,
|
||||||
|
rightHeight: state.sidebar.rightHeight,
|
||||||
rightWidth: state.sidebar.rightWidth,
|
rightWidth: state.sidebar.rightWidth,
|
||||||
}),
|
}),
|
||||||
shallow,
|
shallow,
|
||||||
);
|
);
|
||||||
const { setSideBar } = useAppStoreActions();
|
const { setSideBar } = useAppStoreActions();
|
||||||
const sideQueueType = useSideQueueType();
|
const sideQueueType = useSideQueueType();
|
||||||
|
const sideQueueLayout = useSideQueueLayout();
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [isResizingRight, setIsResizingRight] = useState(false);
|
const [isResizingRight, setIsResizingRight] = useState(false);
|
||||||
|
|
||||||
const rightSidebarRef = useRef<HTMLDivElement | null>(null);
|
const rightSidebarRef = useRef<HTMLDivElement | null>(null);
|
||||||
const mainContentRef = useRef<HTMLDivElement | null>(null);
|
const mainContentRef = useRef<HTMLDivElement | null>(null);
|
||||||
const initialRightWidthRef = useRef<string>(rightWidth);
|
const initialRightWidthRef = useRef<string>(rightWidth);
|
||||||
|
const initialRightHeightRef = useRef<string>(rightHeight);
|
||||||
const initialMouseXRef = useRef<number>(0);
|
const initialMouseXRef = useRef<number>(0);
|
||||||
|
const initialMouseYRef = useRef<number>(0);
|
||||||
const wasCollapsedDuringDragRef = useRef<boolean>(false);
|
const wasCollapsedDuringDragRef = useRef<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mainContentRef.current && !isResizing && !isResizingRight) {
|
if (mainContentRef.current && !isResizing && !isResizingRight) {
|
||||||
mainContentRef.current.style.setProperty('--sidebar-width', leftWidth);
|
mainContentRef.current.style.setProperty('--sidebar-width', leftWidth);
|
||||||
mainContentRef.current.style.setProperty('--right-sidebar-width', rightWidth);
|
mainContentRef.current.style.setProperty('--right-sidebar-width', rightWidth);
|
||||||
|
mainContentRef.current.style.setProperty('--right-sidebar-height', rightHeight);
|
||||||
initialRightWidthRef.current = rightWidth;
|
initialRightWidthRef.current = rightWidth;
|
||||||
|
initialRightHeightRef.current = rightHeight;
|
||||||
}
|
}
|
||||||
}, [leftWidth, rightWidth, isResizing, isResizingRight]);
|
}, [leftWidth, rightWidth, rightHeight, isResizing, isResizingRight]);
|
||||||
|
|
||||||
const startResizing = useCallback(
|
const startResizing = useCallback(
|
||||||
(position: 'left' | 'right', mouseEvent?: MouseEvent) => {
|
(position: 'left' | 'right' | 'top', mouseEvent?: MouseEvent) => {
|
||||||
if (position === 'left') {
|
if (position === 'left') {
|
||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
wasCollapsedDuringDragRef.current = false;
|
wasCollapsedDuringDragRef.current = false;
|
||||||
} else {
|
} else {
|
||||||
setIsResizingRight(true);
|
setIsResizingRight(true);
|
||||||
if (mainContentRef.current && rightSidebarRef.current && mouseEvent) {
|
if (mainContentRef.current && rightSidebarRef.current && mouseEvent) {
|
||||||
const currentWidth =
|
if (position === 'top') {
|
||||||
mainContentRef.current.style.getPropertyValue('--right-sidebar-width');
|
const currentHeight =
|
||||||
if (currentWidth) {
|
mainContentRef.current.style.getPropertyValue('--right-sidebar-height');
|
||||||
initialRightWidthRef.current = currentWidth;
|
if (currentHeight) {
|
||||||
|
initialRightHeightRef.current = currentHeight;
|
||||||
|
} else {
|
||||||
|
initialRightHeightRef.current = rightHeight;
|
||||||
|
}
|
||||||
|
initialMouseYRef.current = mouseEvent.clientY;
|
||||||
|
} else {
|
||||||
|
const currentWidth =
|
||||||
|
mainContentRef.current.style.getPropertyValue('--right-sidebar-width');
|
||||||
|
if (currentWidth) {
|
||||||
|
initialRightWidthRef.current = currentWidth;
|
||||||
|
} else {
|
||||||
|
initialRightWidthRef.current = rightWidth;
|
||||||
|
}
|
||||||
|
initialMouseXRef.current = mouseEvent.clientX;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (position === 'top') {
|
||||||
|
initialRightHeightRef.current = rightHeight;
|
||||||
} else {
|
} else {
|
||||||
initialRightWidthRef.current = rightWidth;
|
initialRightWidthRef.current = rightWidth;
|
||||||
}
|
}
|
||||||
initialMouseXRef.current = mouseEvent.clientX;
|
|
||||||
} else {
|
|
||||||
initialRightWidthRef.current = rightWidth;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[rightWidth],
|
[rightHeight, rightWidth],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stopResizing = useCallback(() => {
|
const stopResizing = useCallback(() => {
|
||||||
@@ -87,14 +109,22 @@ export const MainContent = ({ shell }: { shell?: boolean }) => {
|
|||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
wasCollapsedDuringDragRef.current = false;
|
wasCollapsedDuringDragRef.current = false;
|
||||||
} else if (isResizingRight && mainContentRef.current) {
|
} else if (isResizingRight && mainContentRef.current) {
|
||||||
const finalWidth =
|
if (sideQueueLayout === 'vertical') {
|
||||||
mainContentRef.current.style.getPropertyValue('--right-sidebar-width');
|
const finalHeight =
|
||||||
if (finalWidth) {
|
mainContentRef.current.style.getPropertyValue('--right-sidebar-height');
|
||||||
setSideBar({ rightWidth: finalWidth });
|
if (finalHeight) {
|
||||||
|
setSideBar({ rightHeight: finalHeight });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const finalWidth =
|
||||||
|
mainContentRef.current.style.getPropertyValue('--right-sidebar-width');
|
||||||
|
if (finalWidth) {
|
||||||
|
setSideBar({ rightWidth: finalWidth });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setIsResizingRight(false);
|
setIsResizingRight(false);
|
||||||
}
|
}
|
||||||
}, [isResizing, isResizingRight, setSideBar]);
|
}, [isResizing, isResizingRight, setSideBar, sideQueueLayout]);
|
||||||
|
|
||||||
const resize = useCallback(
|
const resize = useCallback(
|
||||||
(mouseMoveEvent: any) => {
|
(mouseMoveEvent: any) => {
|
||||||
@@ -118,15 +148,30 @@ export const MainContent = ({ shell }: { shell?: boolean }) => {
|
|||||||
mainContentRef.current.style.setProperty('--sidebar-width', constrainedWidth);
|
mainContentRef.current.style.setProperty('--sidebar-width', constrainedWidth);
|
||||||
}
|
}
|
||||||
} else if (isResizingRight) {
|
} else if (isResizingRight) {
|
||||||
const initialWidth = Number(initialRightWidthRef.current.split('px')[0]);
|
if (sideQueueLayout === 'vertical') {
|
||||||
const initialMouseX = initialMouseXRef.current;
|
const initialHeight = Number(initialRightHeightRef.current.split('px')[0]);
|
||||||
const deltaX = mouseMoveEvent.clientX - initialMouseX;
|
const initialMouseY = initialMouseYRef.current;
|
||||||
const newWidth = initialWidth - deltaX;
|
const deltaY = mouseMoveEvent.clientY - initialMouseY;
|
||||||
const width = `${constrainRightSidebarWidth(newWidth)}px`;
|
const containerHeight = mainContentRef.current.clientHeight;
|
||||||
mainContentRef.current.style.setProperty('--right-sidebar-width', width);
|
const minHeight = 220;
|
||||||
|
const maxHeight = Math.max(minHeight, containerHeight - 200);
|
||||||
|
const newHeight = initialHeight - deltaY;
|
||||||
|
const clampedHeight = Math.min(Math.max(newHeight, minHeight), maxHeight);
|
||||||
|
mainContentRef.current.style.setProperty(
|
||||||
|
'--right-sidebar-height',
|
||||||
|
`${clampedHeight}px`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const initialWidth = Number(initialRightWidthRef.current.split('px')[0]);
|
||||||
|
const initialMouseX = initialMouseXRef.current;
|
||||||
|
const deltaX = mouseMoveEvent.clientX - initialMouseX;
|
||||||
|
const newWidth = initialWidth - deltaX;
|
||||||
|
const width = `${constrainRightSidebarWidth(newWidth)}px`;
|
||||||
|
mainContentRef.current.style.setProperty('--right-sidebar-width', width);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isResizing, isResizingRight, setSideBar],
|
[isResizing, isResizingRight, setSideBar, sideQueueLayout],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -145,6 +190,10 @@ export const MainContent = ({ shell }: { shell?: boolean }) => {
|
|||||||
[styles.shell]: shell,
|
[styles.shell]: shell,
|
||||||
[styles.sidebarCollapsed]: collapsed,
|
[styles.sidebarCollapsed]: collapsed,
|
||||||
[styles.sidebarExpanded]: !collapsed,
|
[styles.sidebarExpanded]: !collapsed,
|
||||||
|
[styles.verticalLayout]:
|
||||||
|
rightExpanded &&
|
||||||
|
sideQueueType === 'sideQueue' &&
|
||||||
|
sideQueueLayout === 'vertical',
|
||||||
})}
|
})}
|
||||||
id="main-content"
|
id="main-content"
|
||||||
ref={mainContentRef}
|
ref={mainContentRef}
|
||||||
|
|||||||
@@ -14,6 +14,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.right-sidebar-container.vertical-layout {
|
||||||
|
border-top: 1px solid alpha(var(--theme-colors-border), 0.5);
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.queue-drawer {
|
.queue-drawer {
|
||||||
border-radius: var(--theme-radius-lg);
|
border-radius: var(--theme-radius-lg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
import { forwardRef, Ref } from 'react';
|
import { forwardRef, Ref } from 'react';
|
||||||
|
|
||||||
import styles from './right-sidebar.module.css';
|
import styles from './right-sidebar.module.css';
|
||||||
|
|
||||||
import { SidebarPlayQueue } from '/@/renderer/features/now-playing/components/sidebar-play-queue';
|
import { SidebarPlayQueue } from '/@/renderer/features/now-playing/components/sidebar-play-queue';
|
||||||
import { ResizeHandle } from '/@/renderer/features/shared/components/resize-handle';
|
import { ResizeHandle } from '/@/renderer/features/shared/components/resize-handle';
|
||||||
import { useAppStore, useSideQueueType } from '/@/renderer/store';
|
import { useAppStore, useSideQueueLayout, useSideQueueType } from '/@/renderer/store';
|
||||||
|
|
||||||
// const queueDrawerVariants: Variants = {
|
// const queueDrawerVariants: Variants = {
|
||||||
// closed: (windowBarStyle) => ({
|
// closed: (windowBarStyle) => ({
|
||||||
@@ -46,7 +47,7 @@ import { useAppStore, useSideQueueType } from '/@/renderer/store';
|
|||||||
|
|
||||||
interface RightSidebarProps {
|
interface RightSidebarProps {
|
||||||
isResizing: boolean;
|
isResizing: boolean;
|
||||||
startResizing: (direction: 'left' | 'right', mouseEvent?: MouseEvent) => void;
|
startResizing: (direction: 'left' | 'right' | 'top', mouseEvent?: MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RightSidebar = forwardRef(
|
export const RightSidebar = forwardRef(
|
||||||
@@ -56,12 +57,16 @@ export const RightSidebar = forwardRef(
|
|||||||
) => {
|
) => {
|
||||||
const rightExpanded = useAppStore((state) => state.sidebar.rightExpanded);
|
const rightExpanded = useAppStore((state) => state.sidebar.rightExpanded);
|
||||||
const sideQueueType = useSideQueueType();
|
const sideQueueType = useSideQueueType();
|
||||||
|
const sideQueueLayout = useSideQueueLayout();
|
||||||
|
const isVerticalLayout = sideQueueLayout === 'vertical';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{rightExpanded && sideQueueType === 'sideQueue' && (
|
{rightExpanded && sideQueueType === 'sideQueue' && (
|
||||||
<aside
|
<aside
|
||||||
className={styles.rightSidebarContainer}
|
className={clsx(styles.rightSidebarContainer, {
|
||||||
|
[styles.verticalLayout]: isVerticalLayout,
|
||||||
|
})}
|
||||||
id="sidebar-queue"
|
id="sidebar-queue"
|
||||||
key="queue-sidebar"
|
key="queue-sidebar"
|
||||||
>
|
>
|
||||||
@@ -69,9 +74,9 @@ export const RightSidebar = forwardRef(
|
|||||||
isResizing={isResizingRight}
|
isResizing={isResizingRight}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
startResizing('right', e.nativeEvent);
|
startResizing(isVerticalLayout ? 'top' : 'right', e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
placement="left"
|
placement={isVerticalLayout ? 'top' : 'left'}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
<SidebarPlayQueue />
|
<SidebarPlayQueue />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface AppSlice extends AppState {
|
|||||||
setAppStore: (data: Partial<AppSlice>) => void;
|
setAppStore: (data: Partial<AppSlice>) => void;
|
||||||
setArtistIdsMode: (mode: 'and' | 'or') => void;
|
setArtistIdsMode: (mode: 'and' | 'or') => void;
|
||||||
setArtistSelectMode: (mode: 'multi' | 'single') => void;
|
setArtistSelectMode: (mode: 'multi' | 'single') => void;
|
||||||
|
setCommandPaletteSearchSectionExpanded: (sectionId: string, expanded: boolean) => void;
|
||||||
setGenreIdsMode: (mode: 'and' | 'or') => void;
|
setGenreIdsMode: (mode: 'and' | 'or') => void;
|
||||||
setGenreSelectMode: (mode: 'multi' | 'single') => void;
|
setGenreSelectMode: (mode: 'multi' | 'single') => void;
|
||||||
setGlobalExpanded: (value: GlobalExpandedState | null) => void;
|
setGlobalExpanded: (value: GlobalExpandedState | null) => void;
|
||||||
@@ -45,6 +46,7 @@ export interface AppState {
|
|||||||
artistIdsMode: 'and' | 'or';
|
artistIdsMode: 'and' | 'or';
|
||||||
artistSelectMode: 'multi' | 'single';
|
artistSelectMode: 'multi' | 'single';
|
||||||
commandPalette: CommandPaletteProps;
|
commandPalette: CommandPaletteProps;
|
||||||
|
commandPaletteSearchSectionsExpanded: Record<string, boolean>;
|
||||||
genreIdsMode: 'and' | 'or';
|
genreIdsMode: 'and' | 'or';
|
||||||
genreSelectMode: 'multi' | 'single';
|
genreSelectMode: 'multi' | 'single';
|
||||||
globalExpanded: GlobalExpandedState | null;
|
globalExpanded: GlobalExpandedState | null;
|
||||||
@@ -75,6 +77,7 @@ type SidebarProps = {
|
|||||||
image: boolean;
|
image: boolean;
|
||||||
leftWidth: string;
|
leftWidth: string;
|
||||||
rightExpanded: boolean;
|
rightExpanded: boolean;
|
||||||
|
rightHeight: string;
|
||||||
rightWidth: string;
|
rightWidth: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,6 +136,11 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
|||||||
state.artistSelectMode = mode;
|
state.artistSelectMode = mode;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
setCommandPaletteSearchSectionExpanded: (sectionId, expanded) => {
|
||||||
|
set((state) => {
|
||||||
|
state.commandPaletteSearchSectionsExpanded[sectionId] = expanded;
|
||||||
|
});
|
||||||
|
},
|
||||||
setGenreIdsMode: (mode) => {
|
setGenreIdsMode: (mode) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
state.genreIdsMode = mode;
|
state.genreIdsMode = mode;
|
||||||
@@ -205,6 +213,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
commandPaletteSearchSectionsExpanded: {},
|
||||||
genreIdsMode: 'and',
|
genreIdsMode: 'and',
|
||||||
genreSelectMode: 'multi',
|
genreSelectMode: 'multi',
|
||||||
globalExpanded: null,
|
globalExpanded: null,
|
||||||
@@ -222,6 +231,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
|||||||
image: false,
|
image: false,
|
||||||
leftWidth: '400px',
|
leftWidth: '400px',
|
||||||
rightExpanded: false,
|
rightExpanded: false,
|
||||||
|
rightHeight: '320px',
|
||||||
rightWidth: '600px',
|
rightWidth: '600px',
|
||||||
},
|
},
|
||||||
titlebar: {
|
titlebar: {
|
||||||
@@ -237,10 +247,15 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
|||||||
},
|
},
|
||||||
migrate: (persistedState, version) => {
|
migrate: (persistedState, version) => {
|
||||||
if (version <= 2) {
|
if (version <= 2) {
|
||||||
return {} as AppState;
|
return {} as AppSlice;
|
||||||
}
|
}
|
||||||
|
|
||||||
return persistedState;
|
const state = persistedState as AppSlice;
|
||||||
|
if (version <= 4 && !state.sidebar.rightHeight) {
|
||||||
|
state.sidebar.rightHeight = '320px';
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
},
|
},
|
||||||
name: 'store_app',
|
name: 'store_app',
|
||||||
partialize: (state) => {
|
partialize: (state) => {
|
||||||
@@ -248,7 +263,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
|||||||
const { globalExpanded: _, ...rest } = state;
|
const { globalExpanded: _, ...rest } = state;
|
||||||
return rest;
|
return rest;
|
||||||
},
|
},
|
||||||
version: 4,
|
version: 5,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const LYRICS_ALIGNMENTS = new Set(['center', 'left', 'right']);
|
|||||||
const FONT_TYPES = new Set(['builtIn', 'custom', 'system']);
|
const FONT_TYPES = new Set(['builtIn', 'custom', 'system']);
|
||||||
const HOME_FEATURE_STYLES = new Set(['multiple', 'single']);
|
const HOME_FEATURE_STYLES = new Set(['multiple', 'single']);
|
||||||
const SIDE_QUEUE_TYPES = new Set(['sideDrawerQueue', 'sideQueue']);
|
const SIDE_QUEUE_TYPES = new Set(['sideDrawerQueue', 'sideQueue']);
|
||||||
|
const SIDE_QUEUE_LAYOUTS = new Set(['horizontal', 'vertical']);
|
||||||
|
|
||||||
export type EnvSettingsOverrides = DeepPartial<
|
export type EnvSettingsOverrides = DeepPartial<
|
||||||
Pick<SettingsState, 'autoDJ' | 'css' | 'discord' | 'font' | 'general' | 'lyrics' | 'playback'>
|
Pick<SettingsState, 'autoDJ' | 'css' | 'discord' | 'font' | 'general' | 'lyrics' | 'playback'>
|
||||||
@@ -153,7 +154,9 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [
|
|||||||
{ key: 'FS_GENERAL_PATH_REPLACE_WITH', path: ['general', 'pathReplaceWith'], type: 'string' },
|
{ key: 'FS_GENERAL_PATH_REPLACE_WITH', path: ['general', 'pathReplaceWith'], type: 'string' },
|
||||||
{ key: 'FS_GENERAL_LASTFM_API_KEY', path: ['general', 'lastfmApiKey'], type: 'string' },
|
{ key: 'FS_GENERAL_LASTFM_API_KEY', path: ['general', 'lastfmApiKey'], type: 'string' },
|
||||||
{ key: 'FS_GENERAL_LAST_FM', path: ['general', 'lastFM'], type: 'bool' },
|
{ key: 'FS_GENERAL_LAST_FM', path: ['general', 'lastFM'], type: 'bool' },
|
||||||
|
{ key: 'FS_GENERAL_LISTEN_BRAINZ', path: ['general', 'listenBrainz'], type: 'bool' },
|
||||||
{ key: 'FS_GENERAL_MUSIC_BRAINZ', path: ['general', 'musicBrainz'], type: 'bool' },
|
{ key: 'FS_GENERAL_MUSIC_BRAINZ', path: ['general', 'musicBrainz'], type: 'bool' },
|
||||||
|
{ key: 'FS_GENERAL_QOBUZ', path: ['general', 'qobuz'], type: 'bool' },
|
||||||
{ key: 'FS_GENERAL_SPOTIFY', path: ['general', 'spotify'], type: 'bool' },
|
{ key: 'FS_GENERAL_SPOTIFY', path: ['general', 'spotify'], type: 'bool' },
|
||||||
{ key: 'FS_GENERAL_SPOTIFY_NATIVE_APP', path: ['general', 'nativeSpotify'], type: 'bool' },
|
{ key: 'FS_GENERAL_SPOTIFY_NATIVE_APP', path: ['general', 'nativeSpotify'], type: 'bool' },
|
||||||
{ key: 'FS_GENERAL_NATIVE_ASPECT_RATIO', path: ['general', 'nativeAspectRatio'], type: 'bool' },
|
{ key: 'FS_GENERAL_NATIVE_ASPECT_RATIO', path: ['general', 'nativeAspectRatio'], type: 'bool' },
|
||||||
@@ -200,6 +203,12 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [
|
|||||||
path: ['general', 'sideQueueType'],
|
path: ['general', 'sideQueueType'],
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
enumSet: SIDE_QUEUE_LAYOUTS,
|
||||||
|
key: 'FS_GENERAL_SIDE_QUEUE_LAYOUT',
|
||||||
|
path: ['general', 'sideQueueLayout'],
|
||||||
|
type: 'enum',
|
||||||
|
},
|
||||||
{ key: 'FS_GENERAL_RESUME', path: ['general', 'resume'], type: 'bool' },
|
{ key: 'FS_GENERAL_RESUME', path: ['general', 'resume'], type: 'bool' },
|
||||||
{
|
{
|
||||||
key: 'FS_GENERAL_USE_THEME_ACCENT_COLOR',
|
key: 'FS_GENERAL_USE_THEME_ACCENT_COLOR',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import merge from 'lodash/merge';
|
import merge from 'lodash/merge';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { createJSONStorage, persist, subscribeWithSelector } from 'zustand/middleware';
|
import { persist, subscribeWithSelector } from 'zustand/middleware';
|
||||||
import { immer } from 'zustand/middleware/immer';
|
import { immer } from 'zustand/middleware/immer';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { createWithEqualityFn } from 'zustand/traditional';
|
import { createWithEqualityFn } from 'zustand/traditional';
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
setTimestamp as setTimestampStore,
|
setTimestamp as setTimestampStore,
|
||||||
useTimestampStoreBase,
|
useTimestampStoreBase,
|
||||||
} from '/@/renderer/store/timestamp.store';
|
} from '/@/renderer/store/timestamp.store';
|
||||||
import { idbStateStorage } from '/@/renderer/store/utils';
|
import { migratePlayerStorePersist, playerStoreStorage } from '/@/renderer/store/utils';
|
||||||
import { shuffleInPlace } from '/@/renderer/utils/shuffle';
|
import { shuffleInPlace } from '/@/renderer/utils/shuffle';
|
||||||
import { PlayerData, QueueData, QueueSong, Song } from '/@/shared/types/domain-types';
|
import { PlayerData, QueueData, QueueSong, Song } from '/@/shared/types/domain-types';
|
||||||
import {
|
import {
|
||||||
@@ -1543,12 +1543,17 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
merge: (persistedState: any, currentState: any) => {
|
merge: (persistedState: any, currentState: any) => {
|
||||||
return merge(currentState, persistedState);
|
return merge(currentState, persistedState);
|
||||||
},
|
},
|
||||||
migrate: (persistedState, version) => {
|
migrate: async (persistedState, oldVersion) => {
|
||||||
if (version <= 3) {
|
if (oldVersion < 3) {
|
||||||
return {} as PlayerState;
|
return {} as PlayerState;
|
||||||
}
|
}
|
||||||
|
|
||||||
return persistedState;
|
if (oldVersion === 3) {
|
||||||
|
await migratePlayerStorePersist('player-store');
|
||||||
|
return persistedState as Partial<PlayerState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return persistedState as Partial<PlayerState>;
|
||||||
},
|
},
|
||||||
name: 'player-store',
|
name: 'player-store',
|
||||||
partialize: (state) => {
|
partialize: (state) => {
|
||||||
@@ -1564,53 +1569,22 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
excludedPlayerKeys.push('index');
|
excludedPlayerKeys.push('index');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter top-level state entries
|
const player = Object.fromEntries(
|
||||||
const filteredStateEntries = Object.entries(state).filter(([key]) => {
|
Object.entries(state.player).filter(
|
||||||
// Exclude queue if shouldRestorePlayQueue is false
|
([key]) => !excludedPlayerKeys.includes(key),
|
||||||
if (!shouldRestorePlayQueue && key === 'queue') {
|
),
|
||||||
return false;
|
) as typeof state.player;
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredState = Object.fromEntries(
|
if (!shouldRestorePlayQueue) {
|
||||||
filteredStateEntries,
|
return { player };
|
||||||
) as Partial<PlayerState>;
|
|
||||||
|
|
||||||
// Filter player object
|
|
||||||
if (filteredState.player) {
|
|
||||||
filteredState.player = Object.fromEntries(
|
|
||||||
Object.entries(filteredState.player).filter(
|
|
||||||
([key]) => !excludedPlayerKeys.includes(key),
|
|
||||||
),
|
|
||||||
) as typeof filteredState.player;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredState.queue) {
|
// Queue pruning and IDB writes are handled in `playerStoreStorage` so we only
|
||||||
const allQueueIds = new Set([
|
// serialize the large queue when the queue slice reference actually changes.
|
||||||
...(filteredState.queue.default || []),
|
return { player, queue: state.queue };
|
||||||
// shuffled now contains indexes, not uniqueIds, so we don't include it here
|
|
||||||
]);
|
|
||||||
|
|
||||||
const songs = filteredState.queue.songs || {};
|
|
||||||
const cleanedSongs: Record<string, QueueSong> = {};
|
|
||||||
|
|
||||||
for (const [id, song] of Object.entries(songs)) {
|
|
||||||
if (allQueueIds.has(id)) {
|
|
||||||
cleanedSongs[id] = song;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredState.queue = {
|
|
||||||
...filteredState.queue,
|
|
||||||
songs: cleanedSongs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredState;
|
|
||||||
},
|
},
|
||||||
storage: createJSONStorage(() => idbStateStorage),
|
storage: playerStoreStorage,
|
||||||
version: 3,
|
version: 4,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ const GenreTargetSchema = z.enum(['album', 'track']);
|
|||||||
const PlaylistTargetSchema = z.enum(['album', 'track']);
|
const PlaylistTargetSchema = z.enum(['album', 'track']);
|
||||||
|
|
||||||
const SideQueueTypeSchema = z.enum(['sideDrawerQueue', 'sideQueue']);
|
const SideQueueTypeSchema = z.enum(['sideDrawerQueue', 'sideQueue']);
|
||||||
|
const SideQueueLayoutSchema = z.enum(['horizontal', 'vertical']);
|
||||||
|
|
||||||
const SidebarPanelTypeSchema = z.enum(['queue', 'lyrics', 'visualizer']);
|
const SidebarPanelTypeSchema = z.enum(['queue', 'lyrics', 'visualizer']);
|
||||||
|
|
||||||
@@ -304,6 +305,7 @@ const PlayerbarSliderSchema = z.object({
|
|||||||
barGap: z.number(),
|
barGap: z.number(),
|
||||||
barRadius: z.number(),
|
barRadius: z.number(),
|
||||||
barWidth: z.number(),
|
barWidth: z.number(),
|
||||||
|
loadingDelay: z.number(),
|
||||||
type: PlayerbarSliderTypeSchema,
|
type: PlayerbarSliderTypeSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -475,6 +477,7 @@ export const GeneralSettingsSchema = z.object({
|
|||||||
language: z.string(),
|
language: z.string(),
|
||||||
lastFM: z.boolean(),
|
lastFM: z.boolean(),
|
||||||
lastfmApiKey: z.string(),
|
lastfmApiKey: z.string(),
|
||||||
|
listenBrainz: z.boolean(),
|
||||||
musicBrainz: z.boolean(),
|
musicBrainz: z.boolean(),
|
||||||
nativeAspectRatio: z.boolean(),
|
nativeAspectRatio: z.boolean(),
|
||||||
nativeSpotify: z.boolean(),
|
nativeSpotify: z.boolean(),
|
||||||
@@ -487,6 +490,7 @@ export const GeneralSettingsSchema = z.object({
|
|||||||
playerItems: z.array(SortableItemSchema(PlayerItemSchema)),
|
playerItems: z.array(SortableItemSchema(PlayerItemSchema)),
|
||||||
playlistTarget: PlaylistTargetSchema,
|
playlistTarget: PlaylistTargetSchema,
|
||||||
primaryShade: z.number().min(0).max(9),
|
primaryShade: z.number().min(0).max(9),
|
||||||
|
qobuz: z.boolean(),
|
||||||
resume: z.boolean(),
|
resume: z.boolean(),
|
||||||
showLyricsInSidebar: z.boolean(),
|
showLyricsInSidebar: z.boolean(),
|
||||||
showRatings: z.boolean(),
|
showRatings: z.boolean(),
|
||||||
@@ -498,6 +502,7 @@ export const GeneralSettingsSchema = z.object({
|
|||||||
sidebarPlaylistList: z.boolean(),
|
sidebarPlaylistList: z.boolean(),
|
||||||
sidebarPlaylistListFilterRegex: z.string(),
|
sidebarPlaylistListFilterRegex: z.string(),
|
||||||
sidebarPlaylistSorting: z.boolean(),
|
sidebarPlaylistSorting: z.boolean(),
|
||||||
|
sideQueueLayout: SideQueueLayoutSchema,
|
||||||
sideQueueType: SideQueueTypeSchema,
|
sideQueueType: SideQueueTypeSchema,
|
||||||
skipButtons: SkipButtonsSchema,
|
skipButtons: SkipButtonsSchema,
|
||||||
spotify: z.boolean(),
|
spotify: z.boolean(),
|
||||||
@@ -893,6 +898,7 @@ export interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {
|
|||||||
export interface SettingsState extends z.infer<typeof SettingsStateSchema> {}
|
export interface SettingsState extends z.infer<typeof SettingsStateSchema> {}
|
||||||
export type SidebarItemType = z.infer<typeof SidebarItemTypeSchema>;
|
export type SidebarItemType = z.infer<typeof SidebarItemTypeSchema>;
|
||||||
|
|
||||||
|
export type SideQueueLayout = z.infer<typeof SideQueueLayoutSchema>;
|
||||||
export type SideQueueType = z.infer<typeof SideQueueTypeSchema>;
|
export type SideQueueType = z.infer<typeof SideQueueTypeSchema>;
|
||||||
|
|
||||||
export type SortableItem<T extends string> = {
|
export type SortableItem<T extends string> = {
|
||||||
@@ -1129,6 +1135,7 @@ const initialState: SettingsState = {
|
|||||||
language: 'en',
|
language: 'en',
|
||||||
lastFM: true,
|
lastFM: true,
|
||||||
lastfmApiKey: '',
|
lastfmApiKey: '',
|
||||||
|
listenBrainz: true,
|
||||||
musicBrainz: true,
|
musicBrainz: true,
|
||||||
nativeAspectRatio: false,
|
nativeAspectRatio: false,
|
||||||
nativeSpotify: false,
|
nativeSpotify: false,
|
||||||
@@ -1142,11 +1149,13 @@ const initialState: SettingsState = {
|
|||||||
barGap: 1,
|
barGap: 1,
|
||||||
barRadius: 4,
|
barRadius: 4,
|
||||||
barWidth: 2,
|
barWidth: 2,
|
||||||
|
loadingDelay: 2,
|
||||||
type: PlayerbarSliderType.SLIDER,
|
type: PlayerbarSliderType.SLIDER,
|
||||||
},
|
},
|
||||||
playerItems,
|
playerItems,
|
||||||
playlistTarget: PlaylistTarget.TRACK,
|
playlistTarget: PlaylistTarget.TRACK,
|
||||||
primaryShade: 6,
|
primaryShade: 6,
|
||||||
|
qobuz: true,
|
||||||
resume: true,
|
resume: true,
|
||||||
showLyricsInSidebar: true,
|
showLyricsInSidebar: true,
|
||||||
showRatings: true,
|
showRatings: true,
|
||||||
@@ -1158,6 +1167,7 @@ const initialState: SettingsState = {
|
|||||||
sidebarPlaylistList: true,
|
sidebarPlaylistList: true,
|
||||||
sidebarPlaylistListFilterRegex: '',
|
sidebarPlaylistListFilterRegex: '',
|
||||||
sidebarPlaylistSorting: false,
|
sidebarPlaylistSorting: false,
|
||||||
|
sideQueueLayout: 'horizontal',
|
||||||
sideQueueType: 'sideQueue',
|
sideQueueType: 'sideQueue',
|
||||||
skipButtons: {
|
skipButtons: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -2381,10 +2391,16 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version <= 27) {
|
||||||
|
if (!state.general.sideQueueLayout) {
|
||||||
|
state.general.sideQueueLayout = initialState.general.sideQueueLayout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return persistedState;
|
return persistedState;
|
||||||
},
|
},
|
||||||
name: 'store_settings',
|
name: 'store_settings',
|
||||||
version: 26,
|
version: 27,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -2492,6 +2508,9 @@ export const useThemeSettings = () =>
|
|||||||
export const useSideQueueType = () =>
|
export const useSideQueueType = () =>
|
||||||
useSettingsStore((state) => state.general.sideQueueType, shallow);
|
useSettingsStore((state) => state.general.sideQueueType, shallow);
|
||||||
|
|
||||||
|
export const useSideQueueLayout = () =>
|
||||||
|
useSettingsStore((state) => state.general.sideQueueLayout, shallow);
|
||||||
|
|
||||||
export const useVolumeWheelStep = () =>
|
export const useVolumeWheelStep = () =>
|
||||||
useSettingsStore((state) => state.general.volumeWheelStep, shallow);
|
useSettingsStore((state) => state.general.volumeWheelStep, shallow);
|
||||||
|
|
||||||
@@ -2552,8 +2571,10 @@ export const useExternalLinks = () =>
|
|||||||
(state) => ({
|
(state) => ({
|
||||||
externalLinks: state.general.externalLinks,
|
externalLinks: state.general.externalLinks,
|
||||||
lastFM: state.general.lastFM,
|
lastFM: state.general.lastFM,
|
||||||
|
listenBrainz: state.general.listenBrainz,
|
||||||
musicBrainz: state.general.musicBrainz,
|
musicBrainz: state.general.musicBrainz,
|
||||||
nativeSpotify: state.general.nativeSpotify,
|
nativeSpotify: state.general.nativeSpotify,
|
||||||
|
qobuz: state.general.qobuz,
|
||||||
spotify: state.general.spotify,
|
spotify: state.general.spotify,
|
||||||
}),
|
}),
|
||||||
shallow,
|
shallow,
|
||||||
|
|||||||
+141
-1
@@ -1,6 +1,146 @@
|
|||||||
|
import type { QueueData, QueueSong } from '/@/shared/types/domain-types';
|
||||||
|
import type { PersistStorage, StateStorage } from 'zustand/middleware';
|
||||||
|
|
||||||
import { del, get, set } from 'idb-keyval';
|
import { del, get, set } from 'idb-keyval';
|
||||||
import mergeWith from 'lodash/mergeWith';
|
import mergeWith from 'lodash/mergeWith';
|
||||||
import { StateStorage } from 'zustand/middleware';
|
|
||||||
|
type PlayerStorePersistedSlice = {
|
||||||
|
player?: unknown;
|
||||||
|
queue?: QueueData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function cleanQueueForPersistence(queue: QueueData): QueueData {
|
||||||
|
const allQueueIds = new Set(queue.default || []);
|
||||||
|
const songs = queue.songs || {};
|
||||||
|
const cleanedSongs: Record<string, QueueSong> = {};
|
||||||
|
|
||||||
|
for (const [id, song] of Object.entries(songs)) {
|
||||||
|
if (allQueueIds.has(id)) {
|
||||||
|
cleanedSongs[id] = song;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...queue,
|
||||||
|
songs: cleanedSongs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate from v3 to v4 to handle queue migration
|
||||||
|
export async function migratePlayerStorePersist(storeName: string): Promise<void> {
|
||||||
|
const mainRaw = await get(storeName);
|
||||||
|
if (!mainRaw) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: { state?: { player?: unknown; queue?: QueueData }; version?: number };
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(mainRaw as string);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embeddedQueue = parsed.state?.queue;
|
||||||
|
if (embeddedQueue === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueKey = `${storeName}-queue`;
|
||||||
|
const queueSeparateRaw = await get(queueKey);
|
||||||
|
|
||||||
|
if (!queueSeparateRaw) {
|
||||||
|
const cleaned = cleanQueueForPersistence(embeddedQueue);
|
||||||
|
await set(queueKey, JSON.stringify(cleaned));
|
||||||
|
}
|
||||||
|
|
||||||
|
await set(
|
||||||
|
storeName,
|
||||||
|
JSON.stringify({
|
||||||
|
state: { player: parsed.state?.player },
|
||||||
|
version: parsed.version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerStoreQueueKey(storeName: string): string {
|
||||||
|
return `${storeName}-queue`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastPersistedPlayerQueueRef: QueueData | undefined;
|
||||||
|
|
||||||
|
export const playerStoreStorage: PersistStorage<unknown> = {
|
||||||
|
getItem: async (name) => {
|
||||||
|
const mainRaw = await get(name);
|
||||||
|
if (!mainRaw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: { state?: { player?: unknown; queue?: QueueData }; version?: number };
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(mainRaw as string);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = parsed.version;
|
||||||
|
let queue: QueueData | undefined;
|
||||||
|
const queueRaw = await get(playerStoreQueueKey(name));
|
||||||
|
|
||||||
|
if (queueRaw) {
|
||||||
|
try {
|
||||||
|
queue = JSON.parse(queueRaw as string) as QueueData;
|
||||||
|
} catch {
|
||||||
|
queue = undefined;
|
||||||
|
}
|
||||||
|
} else if (parsed.state?.queue) {
|
||||||
|
// Fallback to legacy format if queue is not found
|
||||||
|
queue = parsed.state.queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
player: parsed.state?.player,
|
||||||
|
queue,
|
||||||
|
} satisfies PlayerStorePersistedSlice,
|
||||||
|
version,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
removeItem: async (name) => {
|
||||||
|
lastPersistedPlayerQueueRef = undefined;
|
||||||
|
await del(name);
|
||||||
|
await del(playerStoreQueueKey(name));
|
||||||
|
},
|
||||||
|
|
||||||
|
setItem: async (name, value) => {
|
||||||
|
const { state: rawState, version } = value;
|
||||||
|
const state = rawState as PlayerStorePersistedSlice;
|
||||||
|
const player = state.player;
|
||||||
|
|
||||||
|
await set(
|
||||||
|
name,
|
||||||
|
JSON.stringify({
|
||||||
|
state: { player },
|
||||||
|
version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (state.queue === undefined) {
|
||||||
|
lastPersistedPlayerQueueRef = undefined;
|
||||||
|
await del(playerStoreQueueKey(name));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.queue === lastPersistedPlayerQueueRef) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleaned = cleanQueueForPersistence(state.queue);
|
||||||
|
await set(playerStoreQueueKey(name), JSON.stringify(cleaned));
|
||||||
|
lastPersistedPlayerQueueRef = state.queue;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom deep merger that will replace all 'columns' items with the persistent
|
* A custom deep merger that will replace all 'columns' items with the persistent
|
||||||
* state, instead of the default merge behavior. This is important to preserve the user's
|
* state, instead of the default merge behavior. This is important to preserve the user's
|
||||||
|
|||||||
@@ -128,7 +128,12 @@ const getArtists = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (participants?.['Remixer']) {
|
if (participants?.['Remixer']) {
|
||||||
result.push(...participants['Remixer']);
|
const existingIds = new Set(result.map((artist) => artist.id));
|
||||||
|
for (const participant of participants['Remixer']) {
|
||||||
|
if (!existingIds.has(participant.id)) {
|
||||||
|
result.push(participant);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -683,14 +683,9 @@ const createPlaylist = z.object({
|
|||||||
const updatePlaylist = z.null();
|
const updatePlaylist = z.null();
|
||||||
|
|
||||||
const updatePlaylistParameters = z.object({
|
const updatePlaylistParameters = z.object({
|
||||||
Genres: z.array(genreItem),
|
Ids: z.string().array().optional(),
|
||||||
IsPublic: z.boolean().optional(),
|
IsPublic: z.boolean().optional(),
|
||||||
MediaType: z.literal('Audio'),
|
Name: z.string().optional(),
|
||||||
Name: z.string(),
|
|
||||||
PremiereDate: z.null(),
|
|
||||||
ProviderIds: z.object({}),
|
|
||||||
Tags: z.array(genericItem),
|
|
||||||
UserId: z.string(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const addToPlaylist = z.object({
|
const addToPlaylist = z.object({
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ const getArtists = (
|
|||||||
albumArtists = roleList;
|
albumArtists = roleList;
|
||||||
} else if (role === 'remixer' && includeRemixers) {
|
} else if (role === 'remixer' && includeRemixers) {
|
||||||
remixers = roleList;
|
remixers = roleList;
|
||||||
|
participants['remixer'] = remixers;
|
||||||
} else {
|
} else {
|
||||||
artists = roleList;
|
artists = roleList;
|
||||||
}
|
}
|
||||||
@@ -200,7 +201,7 @@ const getArtists = (
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (artists === undefined && (includeRemixers ? remixers === undefined : true)) {
|
if (artists === undefined) {
|
||||||
artists = [
|
artists = [
|
||||||
{
|
{
|
||||||
id: item.artistId,
|
id: item.artistId,
|
||||||
@@ -213,11 +214,16 @@ const getArtists = (
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
if (remixers?.length && includeRemixers) {
|
||||||
albumArtists,
|
const existingIds = new Set(artists.map((artist) => artist.id));
|
||||||
artists: [...(artists || []), ...(includeRemixers ? remixers || [] : [])],
|
for (const remixer of remixers) {
|
||||||
participants,
|
if (!existingIds.has(remixer.id)) {
|
||||||
};
|
artists.push(remixer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { albumArtists, artists, participants };
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeSong = (
|
const normalizeSong = (
|
||||||
|
|||||||
@@ -50,7 +50,12 @@ const getArtistList = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (participants?.['remixer']) {
|
if (participants?.['remixer']) {
|
||||||
result.push(...participants['remixer']);
|
const existingIds = new Set(result.map((artist) => artist.id));
|
||||||
|
for (const participant of participants['remixer']) {
|
||||||
|
if (!existingIds.has(participant.id)) {
|
||||||
|
result.push(participant);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -11,6 +11,80 @@ const userParameters = z.object({
|
|||||||
username: z.string(),
|
username: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const transcodeDecisionParameters = z.object({
|
||||||
|
mediaId: z.string(),
|
||||||
|
mediaType: z.enum(['song', 'podcast']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTranscodeStreamParameters = z.object({
|
||||||
|
mediaId: z.string(),
|
||||||
|
mediaType: z.enum(['song', 'podcast']),
|
||||||
|
offset: z.number().optional(),
|
||||||
|
transcodeParams: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const codecProfileLimitation = z.object({
|
||||||
|
comparison: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
required: z.boolean().optional(),
|
||||||
|
values: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const directPlayProfile = z.object({
|
||||||
|
audioCodecs: z.array(z.string()),
|
||||||
|
containers: z.array(z.string()),
|
||||||
|
maxAudioChannels: z.number().optional(),
|
||||||
|
protocols: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcodingProfile = z.object({
|
||||||
|
audioCodec: z.string(),
|
||||||
|
container: z.string(),
|
||||||
|
maxAudioChannels: z.number().optional(),
|
||||||
|
protocol: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const codecProfile = z.object({
|
||||||
|
limitations: z.array(codecProfileLimitation).optional(),
|
||||||
|
name: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcodeDecisionRequestBody = z.object({
|
||||||
|
codecProfiles: z.array(codecProfile).optional(),
|
||||||
|
directPlayProfiles: z.array(directPlayProfile).optional(),
|
||||||
|
maxAudioBitrate: z.number().optional(),
|
||||||
|
maxTranscodingAudioBitrate: z.number().optional(),
|
||||||
|
name: z.string(),
|
||||||
|
platform: z.string(),
|
||||||
|
transcodingProfiles: z.array(transcodingProfile).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const streamDetails = z.object({
|
||||||
|
audioBitdepth: z.number().optional(),
|
||||||
|
audioBitrate: z.number().optional(),
|
||||||
|
audioChannels: z.number().optional(),
|
||||||
|
audioProfile: z.string().optional(),
|
||||||
|
audioSamplerate: z.number().optional(),
|
||||||
|
codec: z.string().optional(),
|
||||||
|
container: z.string().optional(),
|
||||||
|
protocol: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcodeDecision = z.object({
|
||||||
|
canDirectPlay: z.boolean(),
|
||||||
|
canTranscode: z.boolean(),
|
||||||
|
errorReason: z.string().optional(),
|
||||||
|
sourceStream: streamDetails.optional(),
|
||||||
|
transcodeParams: z.string().optional(),
|
||||||
|
transcodeReason: z.array(z.string()).optional(),
|
||||||
|
transcodeStream: streamDetails.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTranscodeDecision = z.object({
|
||||||
|
transcodeDecision,
|
||||||
|
});
|
||||||
|
|
||||||
const user = z.object({
|
const user = z.object({
|
||||||
user: z.object({
|
user: z.object({
|
||||||
adminRole: z.boolean(),
|
adminRole: z.boolean(),
|
||||||
@@ -382,6 +456,7 @@ export enum SubsonicExtensions {
|
|||||||
INDEX_BASED_QUEUE = 'indexBasedQueue',
|
INDEX_BASED_QUEUE = 'indexBasedQueue',
|
||||||
SONG_LYRICS = 'songLyrics',
|
SONG_LYRICS = 'songLyrics',
|
||||||
TRANSCODE_OFFSET = 'transcodeOffset',
|
TRANSCODE_OFFSET = 'transcodeOffset',
|
||||||
|
TRANSCODING = 'transcoding',
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePlaylistParameters = z.object({
|
const updatePlaylistParameters = z.object({
|
||||||
@@ -467,7 +542,7 @@ const deletePlaylistParameters = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createPlaylistParameters = z.object({
|
const createPlaylistParameters = z.object({
|
||||||
name: z.string(),
|
name: z.string().optional(),
|
||||||
playlistId: z.string().optional(),
|
playlistId: z.string().optional(),
|
||||||
songId: z.array(z.string()).optional(),
|
songId: z.array(z.string()).optional(),
|
||||||
});
|
});
|
||||||
@@ -718,6 +793,9 @@ const getInternetRadioStations = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const ssType = {
|
export const ssType = {
|
||||||
|
_body: {
|
||||||
|
getTranscodeDecision: transcodeDecisionRequestBody,
|
||||||
|
},
|
||||||
_parameters: {
|
_parameters: {
|
||||||
albumInfo: albumInfoParameters,
|
albumInfo: albumInfoParameters,
|
||||||
albumList: albumListParameters,
|
albumList: albumListParameters,
|
||||||
@@ -741,6 +819,8 @@ export const ssType = {
|
|||||||
getSong: getSongParameters,
|
getSong: getSongParameters,
|
||||||
getSongsByGenre: getSongsByGenreParameters,
|
getSongsByGenre: getSongsByGenreParameters,
|
||||||
getStarred: getStarredParameters,
|
getStarred: getStarredParameters,
|
||||||
|
getTranscodeDecision: transcodeDecisionParameters,
|
||||||
|
getTranscodeStream: getTranscodeStreamParameters,
|
||||||
randomSongList: randomSongListParameters,
|
randomSongList: randomSongListParameters,
|
||||||
removeFavorite: removeFavoriteParameters,
|
removeFavorite: removeFavoriteParameters,
|
||||||
savePlayQueueByIndex: savePlayQueueByIndexParameters,
|
savePlayQueueByIndex: savePlayQueueByIndexParameters,
|
||||||
@@ -786,6 +866,7 @@ export const ssType = {
|
|||||||
getSong,
|
getSong,
|
||||||
getSongsByGenre,
|
getSongsByGenre,
|
||||||
getStarred,
|
getStarred,
|
||||||
|
getTranscodeDecision,
|
||||||
internetRadioStation,
|
internetRadioStation,
|
||||||
musicFolderList,
|
musicFolderList,
|
||||||
ping,
|
ping,
|
||||||
|
|||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
declare module '*.png' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.svg' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
@@ -34,6 +34,19 @@
|
|||||||
font-size: var(--theme-font-size-5xl);
|
font-size: var(--theme-font-size-5xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img.size-xs,
|
||||||
|
img.size-sm,
|
||||||
|
img.size-md,
|
||||||
|
img.size-lg,
|
||||||
|
img.size-xl,
|
||||||
|
img.size-2xl,
|
||||||
|
img.size-3xl,
|
||||||
|
img.size-4xl,
|
||||||
|
img.size-5xl {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
.color-default {
|
.color-default {
|
||||||
color: var(--theme-colors-foreground);
|
color: var(--theme-colors-foreground);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { type ComponentType, forwardRef, memo, useMemo } from 'react';
|
import {
|
||||||
|
type ComponentType,
|
||||||
|
type CSSProperties,
|
||||||
|
forwardRef,
|
||||||
|
ImgHTMLAttributes,
|
||||||
|
memo,
|
||||||
|
useMemo,
|
||||||
|
} from 'react';
|
||||||
import { IconBaseProps } from 'react-icons';
|
import { IconBaseProps } from 'react-icons';
|
||||||
import { FaLastfmSquare } from 'react-icons/fa';
|
|
||||||
import {
|
import {
|
||||||
LuAlignCenter,
|
LuAlignCenter,
|
||||||
LuAlignLeft,
|
LuAlignLeft,
|
||||||
@@ -79,6 +85,8 @@ import {
|
|||||||
LuMusic,
|
LuMusic,
|
||||||
LuMusic2,
|
LuMusic2,
|
||||||
LuPackage2,
|
LuPackage2,
|
||||||
|
LuPanelBottom,
|
||||||
|
LuPanelRight,
|
||||||
LuPanelRightClose,
|
LuPanelRightClose,
|
||||||
LuPanelRightOpen,
|
LuPanelRightOpen,
|
||||||
LuPause,
|
LuPause,
|
||||||
@@ -125,12 +133,89 @@ import {
|
|||||||
import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';
|
import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';
|
||||||
import { PiMouseLeftClickFill, PiMouseRightClickFill } from 'react-icons/pi';
|
import { PiMouseLeftClickFill, PiMouseRightClickFill } from 'react-icons/pi';
|
||||||
import { RiPlayListAddLine, RiRepeat2Line, RiRepeatOneLine } from 'react-icons/ri';
|
import { RiPlayListAddLine, RiRepeat2Line, RiRepeatOneLine } from 'react-icons/ri';
|
||||||
import { SiMusicbrainz, SiSpotify } from 'react-icons/si';
|
|
||||||
|
|
||||||
import styles from './icon.module.css';
|
import styles from './icon.module.css';
|
||||||
|
import lastfmLogoIcon from './lastfm_logo_icon.png';
|
||||||
|
import listenbrainzLogoIcon from './listenbrainz_logo_icon.svg';
|
||||||
|
import musicbrainzLogoIcon from './musicbrainz_logo_icon.svg';
|
||||||
|
import qobuzLogoIcon from './qobuz_logo_icon.png';
|
||||||
|
import spotifyLogoIcon from './spotify_logo_icon.svg';
|
||||||
|
|
||||||
export type AppIconSelection = keyof typeof AppIcon;
|
export type AppIconSelection = keyof typeof AppIcon;
|
||||||
|
|
||||||
|
type LogoImgProps = ImgHTMLAttributes<HTMLImageElement> & { size?: number | string };
|
||||||
|
|
||||||
|
function logoImgStyle(size: number | string | undefined): CSSProperties | undefined {
|
||||||
|
if (size === undefined) return undefined;
|
||||||
|
const dim = typeof size === 'number' ? `${size}px` : size;
|
||||||
|
return { height: dim, width: dim };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListenBrainzLogoIcon = forwardRef<HTMLImageElement, LogoImgProps>(
|
||||||
|
({ className, size, style, ...props }, ref) => (
|
||||||
|
<img
|
||||||
|
alt="ListenBrainz"
|
||||||
|
className={className}
|
||||||
|
ref={ref}
|
||||||
|
src={listenbrainzLogoIcon}
|
||||||
|
style={logoImgStyle(size) ?? style}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const SpotifyLogoIcon = forwardRef<HTMLImageElement, LogoImgProps>(
|
||||||
|
({ className, size, style, ...props }, ref) => (
|
||||||
|
<img
|
||||||
|
alt="Spotify"
|
||||||
|
className={className}
|
||||||
|
ref={ref}
|
||||||
|
src={spotifyLogoIcon}
|
||||||
|
style={logoImgStyle(size) ?? style}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const MusicBrainzLogoIcon = forwardRef<HTMLImageElement, LogoImgProps>(
|
||||||
|
({ className, size, style, ...props }, ref) => (
|
||||||
|
<img
|
||||||
|
alt="MusicBrainz"
|
||||||
|
className={className}
|
||||||
|
ref={ref}
|
||||||
|
src={musicbrainzLogoIcon}
|
||||||
|
style={logoImgStyle(size) ?? style}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const QobuzLogoIcon = forwardRef<HTMLImageElement, LogoImgProps>(
|
||||||
|
({ className, size, style, ...props }, ref) => (
|
||||||
|
<img
|
||||||
|
alt="Qobuz"
|
||||||
|
className={className}
|
||||||
|
ref={ref}
|
||||||
|
src={qobuzLogoIcon}
|
||||||
|
style={logoImgStyle(size) ?? style}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const LastfmLogoIcon = forwardRef<HTMLImageElement, LogoImgProps>(
|
||||||
|
({ className, size, style, ...props }, ref) => (
|
||||||
|
<img
|
||||||
|
alt="Last.fm"
|
||||||
|
className={className}
|
||||||
|
ref={ref}
|
||||||
|
src={lastfmLogoIcon}
|
||||||
|
style={logoImgStyle(size) ?? style}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
export const AppIcon = {
|
export const AppIcon = {
|
||||||
add: LuPlus,
|
add: LuPlus,
|
||||||
album: LuDisc3,
|
album: LuDisc3,
|
||||||
@@ -154,9 +239,11 @@ export const AppIcon = {
|
|||||||
arrowUpToLine: LuArrowUpToLine,
|
arrowUpToLine: LuArrowUpToLine,
|
||||||
artist: LuUserPen,
|
artist: LuUserPen,
|
||||||
brandGitHub: LuGithub,
|
brandGitHub: LuGithub,
|
||||||
brandLastfm: FaLastfmSquare,
|
brandLastfm: LastfmLogoIcon,
|
||||||
brandMusicBrainz: SiMusicbrainz,
|
brandListenBrainz: ListenBrainzLogoIcon,
|
||||||
brandSpotify: SiSpotify,
|
brandMusicBrainz: MusicBrainzLogoIcon,
|
||||||
|
brandQobuz: QobuzLogoIcon,
|
||||||
|
brandSpotify: SpotifyLogoIcon,
|
||||||
cache: LuCloudDownload,
|
cache: LuCloudDownload,
|
||||||
check: LuCheck,
|
check: LuCheck,
|
||||||
clipboardCopy: LuClipboardCopy,
|
clipboardCopy: LuClipboardCopy,
|
||||||
@@ -198,6 +285,8 @@ export const AppIcon = {
|
|||||||
layoutDetail: LuLayoutList,
|
layoutDetail: LuLayoutList,
|
||||||
layoutGrid: LuLayoutGrid,
|
layoutGrid: LuLayoutGrid,
|
||||||
layoutList: LuList,
|
layoutList: LuList,
|
||||||
|
layoutPanelBottom: LuPanelBottom,
|
||||||
|
layoutPanelRight: LuPanelRight,
|
||||||
layoutTable: LuTable,
|
layoutTable: LuTable,
|
||||||
library: LuLibrary,
|
library: LuLibrary,
|
||||||
list: LuList,
|
list: LuList,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 821 B |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 27 30"><defs><style>.b{fill:#353070;}.c{fill:#eb743b;}</style></defs><polygon class="b" points="13 1 1 8 1 22 13 29 13 1"/><polygon class="c" points="14 1 26 8 26 22 14 29 14 1"/></svg>
|
||||||
|
After Width: | Height: | Size: 283 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg enable-background="new 0 0 27 30" height="30" viewBox="0 0 30 30" width="30" xmlns="http://www.w3.org/2000/svg"><title>MusicBrainz Simple Icon</title><g transform="translate(1.5)"><path d="m13 1-12 7v14l12 7z" fill="#ba478f"/><path d="m14 1 12 7v14l-12 7z" fill="#eb743b"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 289 B |
Binary file not shown.
|
After Width: | Height: | Size: 626 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512">
|
||||||
|
<path fill="#1ed760" d="M248 8C111.1 8 0 119.1 0 256s111.1 248 248 248 248-111.1 248-248S384.9 8 248 8Z"/>
|
||||||
|
<path d="M406.6 231.1c-5.2 0-8.4-1.3-12.9-3.9-71.2-42.5-198.5-52.7-280.9-29.7-3.6 1-8.1 2.6-12.9 2.6-13.2 0-23.3-10.3-23.3-23.6 0-13.6 8.4-21.3 17.4-23.9 35.2-10.3 74.6-15.2 117.5-15.2 73 0 149.5 15.2 205.4 47.8 7.8 4.5 12.9 10.7 12.9 22.6 0 13.6-11 23.3-23.2 23.3zm-31 76.2c-5.2 0-8.7-2.3-12.3-4.2-62.5-37-155.7-51.9-238.6-29.4-4.8 1.3-7.4 2.6-11.9 2.6-10.7 0-19.4-8.7-19.4-19.4s5.2-17.8 15.5-20.7c27.8-7.8 56.2-13.6 97.8-13.6 64.9 0 127.6 16.1 177 45.5 8.1 4.8 11.3 11 11.3 19.7-.1 10.8-8.5 19.5-19.4 19.5zm-26.9 65.6c-4.2 0-6.8-1.3-10.7-3.6-62.4-37.6-135-39.2-206.7-24.5-3.9 1-9 2.6-11.9 2.6-9.7 0-15.8-7.7-15.8-15.8 0-10.3 6.1-15.2 13.6-16.8 81.9-18.1 165.6-16.5 237 26.2 6.1 3.9 9.7 7.4 9.7 16.5s-7.1 15.4-15.2 15.4z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 960 B |
@@ -12,7 +12,7 @@ export const glassyDark: AppThemeConfiguration = {
|
|||||||
'scrollbar-handle-hover-background': 'rgba(88, 166, 255, 40%)',
|
'scrollbar-handle-hover-background': 'rgba(88, 166, 255, 40%)',
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
background: 'rgb(2, 2, 6)',
|
background: 'rgb(2, 2, 3)',
|
||||||
'background-alternate': 'rgb(0, 0, 0)',
|
'background-alternate': 'rgb(0, 0, 0)',
|
||||||
black: 'rgb(0, 0, 0)',
|
black: 'rgb(0, 0, 0)',
|
||||||
foreground: 'rgb(225, 225, 225)',
|
foreground: 'rgb(225, 225, 225)',
|
||||||
@@ -21,7 +21,7 @@ export const glassyDark: AppThemeConfiguration = {
|
|||||||
'state-info': 'rgb(53, 116, 252)',
|
'state-info': 'rgb(53, 116, 252)',
|
||||||
'state-success': 'rgb(50, 204, 50)',
|
'state-success': 'rgb(50, 204, 50)',
|
||||||
'state-warning': 'rgb(255, 120, 120)',
|
'state-warning': 'rgb(255, 120, 120)',
|
||||||
surface: 'rgb(4, 4, 9)',
|
surface: 'rgb(4, 4, 5)',
|
||||||
'surface-foreground': 'rgb(215, 215, 215)',
|
'surface-foreground': 'rgb(215, 215, 215)',
|
||||||
white: 'rgb(255, 255, 255)',
|
white: 'rgb(255, 255, 255)',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* stylelint-disable selector-class-pattern */
|
||||||
.fs-player-bar-module-container {
|
.fs-player-bar-module-container {
|
||||||
background: rgb(0 0 0 / 40%) !important;
|
background: rgb(0 0 0 / 40%) !important;
|
||||||
backdrop-filter: blur(2rem);
|
backdrop-filter: blur(2rem);
|
||||||
@@ -25,11 +26,9 @@
|
|||||||
border-radius: 18px !important;
|
border-radius: 18px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* stylelint-disable selector-class-pattern */
|
|
||||||
.mantine-Modal-overlay {
|
.mantine-Modal-overlay {
|
||||||
backdrop-filter: blur(3px);
|
backdrop-filter: blur(3px);
|
||||||
}
|
}
|
||||||
/* stylelint-enable selector-class-pattern */
|
|
||||||
|
|
||||||
.fs-modal-module-content,
|
.fs-modal-module-content,
|
||||||
.fs-select-module-dropdown,
|
.fs-select-module-dropdown,
|
||||||
@@ -39,6 +38,7 @@
|
|||||||
.fs-dropdown-menu-module-menu-dropdown,
|
.fs-dropdown-menu-module-menu-dropdown,
|
||||||
.fs-accordion-module-panel {
|
.fs-accordion-module-panel {
|
||||||
background: rgb(4 4 9 / 50%) !important;
|
background: rgb(4 4 9 / 50%) !important;
|
||||||
|
border: 0;
|
||||||
backdrop-filter: blur(2rem);
|
backdrop-filter: blur(2rem);
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@@ -73,11 +73,9 @@
|
|||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* stylelint-disable selector-class-pattern */
|
|
||||||
.mantine-Center-root img {
|
.mantine-Center-root img {
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
/* stylelint-enable selector-class-pattern */
|
|
||||||
|
|
||||||
.ag-header {
|
.ag-header {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
@@ -88,11 +86,9 @@
|
|||||||
border-radius: 8px !important;
|
border-radius: 8px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* stylelint-disable selector-class-pattern */
|
|
||||||
.favorite_icon .mantine-ActionIcon-icon {
|
.favorite_icon .mantine-ActionIcon-icon {
|
||||||
justify-content: left;
|
justify-content: left;
|
||||||
}
|
}
|
||||||
/* stylelint-enable selector-class-pattern */
|
|
||||||
|
|
||||||
.fork-header svg {
|
.fork-header svg {
|
||||||
padding-left: 2px;
|
padding-left: 2px;
|
||||||
@@ -107,11 +103,9 @@
|
|||||||
border-radius: 8px !important;
|
border-radius: 8px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* stylelint-disable selector-class-pattern */
|
|
||||||
.mantine-Table-th {
|
.mantine-Table-th {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
/* stylelint-enable selector-class-pattern */
|
|
||||||
|
|
||||||
table {
|
table {
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
@@ -121,11 +115,20 @@ table {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
.fs-main-content-module-main-content-container {
|
.fs-main-content-module-main-content-container {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
.fs-main-content-module-main-content-body {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
:has(.fs-window-bar-module-window-bar) .fs-main-content-module-main-content-container {
|
||||||
|
height: calc(100vh - 30px);
|
||||||
|
}
|
||||||
|
|
||||||
/* stylelint-disable selector-class-pattern */
|
|
||||||
.mantine-Tabs-root {
|
.mantine-Tabs-root {
|
||||||
input {
|
input {
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -135,20 +138,18 @@ table {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* stylelint-enable selector-class-pattern */
|
|
||||||
|
|
||||||
/* stylelint-disable selector-class-pattern */
|
|
||||||
.mantine-Slider-track::before {
|
.mantine-Slider-track::before {
|
||||||
background-color: var(--theme-colors-surface);
|
background-color: var(--theme-colors-surface);
|
||||||
}
|
}
|
||||||
/* stylelint-enable selector-class-pattern */
|
|
||||||
|
|
||||||
/* stylelint-disable selector-not-notation */
|
/* stylelint-disable selector-not-notation */
|
||||||
.fs-image-module-image:not(.ag-cell *)
|
.fs-image-module-image:not(.ag-cell *):not(.fs-left-controls-module-image *):not(
|
||||||
:not(.fs-left-controls-module-image *)
|
.fs-sidebar-playlist-list-module-row-group *
|
||||||
:not(.fs-sidebar-playlist-list-module-row-group *) {
|
) {
|
||||||
border-radius: 18px !important;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* stylelint-enable selector-not-notation */
|
/* stylelint-enable selector-not-notation */
|
||||||
|
|
||||||
.fs-left-controls-module-image {
|
.fs-left-controls-module-image {
|
||||||
@@ -156,9 +157,7 @@ table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fork-server-selector {
|
.fork-server-selector {
|
||||||
/* stylelint-disable selector-class-pattern */
|
|
||||||
.mantine-SegmentedControl-indicator,
|
.mantine-SegmentedControl-indicator,
|
||||||
/* stylelint-enable selector-class-pattern */
|
|
||||||
.fs-segmented-control-module-root,
|
.fs-segmented-control-module-root,
|
||||||
input,
|
input,
|
||||||
button {
|
button {
|
||||||
@@ -182,20 +181,16 @@ table {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* stylelint-disable selector-class-pattern */
|
|
||||||
.mantine-Accordion-label {
|
.mantine-Accordion-label {
|
||||||
button,
|
button,
|
||||||
a {
|
a {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* stylelint-enable selector-class-pattern */
|
|
||||||
|
|
||||||
/* stylelint-disable selector-class-pattern */
|
|
||||||
.mantine-Grid-col button {
|
.mantine-Grid-col button {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
/* stylelint-enable selector-class-pattern */
|
|
||||||
|
|
||||||
/* share dialog */
|
/* share dialog */
|
||||||
.fs-modal-module-body {
|
.fs-modal-module-body {
|
||||||
@@ -212,11 +207,9 @@ table {
|
|||||||
align-items: normal !important;
|
align-items: normal !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* stylelint-disable selector-class-pattern */
|
|
||||||
.mantine-Badge-root {
|
.mantine-Badge-root {
|
||||||
background: rgb(1 1 5 / 45%);
|
background: rgb(1 1 5 / 45%);
|
||||||
}
|
}
|
||||||
/* stylelint-enable selector-class-pattern */
|
|
||||||
|
|
||||||
.fs-sidebar-module-image-container img {
|
.fs-sidebar-module-image-container img {
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -240,11 +233,9 @@ table {
|
|||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* stylelint-disable selector-class-pattern */
|
|
||||||
.fs-full-screen-player-module-container .mantine-Group-root button {
|
.fs-full-screen-player-module-container .mantine-Group-root button {
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
}
|
}
|
||||||
/* stylelint-enable selector-class-pattern */
|
|
||||||
|
|
||||||
.fs-full-screen-player-image-module-image {
|
.fs-full-screen-player-image-module-image {
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -255,11 +246,9 @@ table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fs-segmented-control-module-label[data-active='true'],
|
.fs-segmented-control-module-label[data-active='true'],
|
||||||
/* stylelint-disable selector-class-pattern */
|
|
||||||
.mantine-SegmentedControl-control {
|
.mantine-SegmentedControl-control {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
/* stylelint-enable selector-class-pattern */
|
|
||||||
|
|
||||||
.fs-table-config-module-group {
|
.fs-table-config-module-group {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -268,3 +257,65 @@ table {
|
|||||||
.fs-server-selector-module-button-group {
|
.fs-server-selector-module-button-group {
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fs-full-screen-player-module-container {
|
||||||
|
height: 100vh !important;
|
||||||
|
|
||||||
|
.fs-full-screen-player-module-responsive-container {
|
||||||
|
height: calc(100% - 250px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-skeleton-module-skeleton,
|
||||||
|
.fs-select-module-root div input {
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mantine-Modal-content {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-item-card-controls-module-container {
|
||||||
|
background: #03010186;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: none;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
transition: linear backdrop-filter 0.25s;
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-item-card-module-image-container::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-action-bar-module-container .mantine-Input-input {
|
||||||
|
background-color: var(--theme-colors-surface) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-library-background-overlay-module-background-overlay {
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* i really wanted to make this transparent but does not seem to be possible :( */
|
||||||
|
.query-editor-container {
|
||||||
|
padding-bottom: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-full-screen-player-queue-module-queue-container img {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* attempt to patch the server selector, possible unintended consequences */
|
||||||
|
|
||||||
|
.mantine-Accordion-root .mantine-Accordion-item {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-item-table-list-module-height-100 {
|
||||||
|
padding-bottom: 65px !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -410,16 +410,18 @@ export type Song = {
|
|||||||
userRating: null | number;
|
userRating: null | number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ApiContext = {
|
||||||
|
pathReplace?: string;
|
||||||
|
pathReplaceWith?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type BaseEndpointArgs = {
|
type BaseEndpointArgs = {
|
||||||
apiClientProps: {
|
apiClientProps: {
|
||||||
server?: null | ServerListItemWithCredential;
|
server?: null | ServerListItemWithCredential;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
};
|
};
|
||||||
context?: {
|
context?: ApiContext;
|
||||||
pathReplace?: string;
|
|
||||||
pathReplaceWith?: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type GenreListSortMap = {
|
type GenreListSortMap = {
|
||||||
@@ -1084,7 +1086,6 @@ export type UpdatePlaylistArgs = BaseEndpointArgs & {
|
|||||||
export type UpdatePlaylistBody = {
|
export type UpdatePlaylistBody = {
|
||||||
_custom?: Record<string, any>;
|
_custom?: Record<string, any>;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
genres?: Genre[];
|
|
||||||
name: string;
|
name: string;
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
public?: boolean;
|
public?: boolean;
|
||||||
@@ -1417,11 +1418,10 @@ export type ControllerEndpoint = {
|
|||||||
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
||||||
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
||||||
getSongListCount: (args: SongListCountArgs) => Promise<number>;
|
getSongListCount: (args: SongListCountArgs) => Promise<number>;
|
||||||
getStreamUrl: (args: StreamArgs) => string;
|
getStreamUrl: (args: StreamArgs) => Promise<string>;
|
||||||
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
||||||
getTagList?: (args: TagListArgs) => Promise<TagListResponse>;
|
getTagList?: (args: TagListArgs) => Promise<TagListResponse>;
|
||||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||||
// getArtistInfo?: (args: any) => void;
|
|
||||||
getUserInfo: (args: UserInfoArgs) => Promise<UserInfoResponse>;
|
getUserInfo: (args: UserInfoArgs) => Promise<UserInfoResponse>;
|
||||||
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
||||||
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
||||||
@@ -1430,6 +1430,7 @@ export type ControllerEndpoint = {
|
|||||||
savePlayQueue: (args: SaveQueueArgs) => Promise<void>;
|
savePlayQueue: (args: SaveQueueArgs) => Promise<void>;
|
||||||
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
||||||
search: (args: SearchArgs) => Promise<SearchResponse>;
|
search: (args: SearchArgs) => Promise<SearchResponse>;
|
||||||
|
setPlaylistSongs: (args: SetPlaylistSongsArgs) => Promise<SetPlaylistSongsResponse>;
|
||||||
setRating?: (args: SetRatingArgs) => Promise<RatingResponse>;
|
setRating?: (args: SetRatingArgs) => Promise<RatingResponse>;
|
||||||
shareItem?: (args: ShareItemArgs) => Promise<ShareItemResponse>;
|
shareItem?: (args: ShareItemArgs) => Promise<ShareItemResponse>;
|
||||||
updateInternetRadioStation: (
|
updateInternetRadioStation: (
|
||||||
@@ -1563,7 +1564,7 @@ export type InternalControllerEndpoint = {
|
|||||||
getSongDetail: (args: ReplaceApiClientProps<SongDetailArgs>) => Promise<SongDetailResponse>;
|
getSongDetail: (args: ReplaceApiClientProps<SongDetailArgs>) => Promise<SongDetailResponse>;
|
||||||
getSongList: (args: ReplaceApiClientProps<SongListArgs>) => Promise<SongListResponse>;
|
getSongList: (args: ReplaceApiClientProps<SongListArgs>) => Promise<SongListResponse>;
|
||||||
getSongListCount: (args: ReplaceApiClientProps<SongListCountArgs>) => Promise<number>;
|
getSongListCount: (args: ReplaceApiClientProps<SongListCountArgs>) => Promise<number>;
|
||||||
getStreamUrl: (args: ReplaceApiClientProps<StreamArgs>) => string;
|
getStreamUrl: (args: ReplaceApiClientProps<StreamArgs>) => Promise<string>;
|
||||||
getStructuredLyrics?: (
|
getStructuredLyrics?: (
|
||||||
args: ReplaceApiClientProps<StructuredLyricsArgs>,
|
args: ReplaceApiClientProps<StructuredLyricsArgs>,
|
||||||
) => Promise<StructuredLyric[]>;
|
) => Promise<StructuredLyric[]>;
|
||||||
@@ -1581,6 +1582,9 @@ export type InternalControllerEndpoint = {
|
|||||||
savePlayQueue: (args: ReplaceApiClientProps<SaveQueueArgs>) => Promise<void>;
|
savePlayQueue: (args: ReplaceApiClientProps<SaveQueueArgs>) => Promise<void>;
|
||||||
scrobble: (args: ReplaceApiClientProps<ScrobbleArgs>) => Promise<ScrobbleResponse>;
|
scrobble: (args: ReplaceApiClientProps<ScrobbleArgs>) => Promise<ScrobbleResponse>;
|
||||||
search: (args: ReplaceApiClientProps<SearchArgs>) => Promise<SearchResponse>;
|
search: (args: ReplaceApiClientProps<SearchArgs>) => Promise<SearchResponse>;
|
||||||
|
setPlaylistSongs: (
|
||||||
|
args: ReplaceApiClientProps<SetPlaylistSongsArgs>,
|
||||||
|
) => Promise<SetPlaylistSongsResponse>;
|
||||||
setRating?: (args: ReplaceApiClientProps<SetRatingArgs>) => Promise<RatingResponse>;
|
setRating?: (args: ReplaceApiClientProps<SetRatingArgs>) => Promise<RatingResponse>;
|
||||||
shareItem?: (args: ReplaceApiClientProps<ShareItemArgs>) => Promise<ShareItemResponse>;
|
shareItem?: (args: ReplaceApiClientProps<ShareItemArgs>) => Promise<ShareItemResponse>;
|
||||||
updateInternetRadioStation: (
|
updateInternetRadioStation: (
|
||||||
@@ -1637,6 +1641,15 @@ export type ServerInfo = {
|
|||||||
|
|
||||||
export type ServerInfoArgs = BaseEndpointArgs;
|
export type ServerInfoArgs = BaseEndpointArgs;
|
||||||
|
|
||||||
|
export type SetPlaylistSongsArgs = BaseEndpointArgs & { body: SetPlaylistSongsQuery };
|
||||||
|
|
||||||
|
export type SetPlaylistSongsQuery = {
|
||||||
|
id: string;
|
||||||
|
songIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetPlaylistSongsResponse = null;
|
||||||
|
|
||||||
export type SimilarSongsArgs = BaseEndpointArgs & {
|
export type SimilarSongsArgs = BaseEndpointArgs & {
|
||||||
query: SimilarSongsQuery;
|
query: SimilarSongsQuery;
|
||||||
};
|
};
|
||||||
@@ -1655,6 +1668,9 @@ export type StreamQuery = {
|
|||||||
bitrate?: number;
|
bitrate?: number;
|
||||||
format?: string;
|
format?: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
mediaType?: 'podcast' | 'song';
|
||||||
|
offset?: number;
|
||||||
|
skipAutoTranscode?: boolean;
|
||||||
transcode: boolean;
|
transcode: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1699,6 +1715,50 @@ export type TagListResponse = {
|
|||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TranscodeDecisionArgs = BaseEndpointArgs & {
|
||||||
|
body?: TranscodeDecisionRequestBody;
|
||||||
|
query: TranscodeDecisionQuery;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranscodeDecisionQuery = {
|
||||||
|
id: string;
|
||||||
|
type: 'song';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranscodeDecisionRequestBody = {
|
||||||
|
codecProfiles?: Array<{
|
||||||
|
limitations?: Array<{
|
||||||
|
comparison: string;
|
||||||
|
name: string;
|
||||||
|
required?: boolean;
|
||||||
|
values: string[];
|
||||||
|
}>;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
directPlayProfiles?: Array<{
|
||||||
|
audioCodecs: string[];
|
||||||
|
containers: string[];
|
||||||
|
maxAudioChannels?: number;
|
||||||
|
protocols: string[];
|
||||||
|
}>;
|
||||||
|
maxAudioBitrate?: number;
|
||||||
|
maxTranscodingAudioBitrate?: number;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
transcodingProfiles?: Array<{
|
||||||
|
audioCodec: string;
|
||||||
|
container: string;
|
||||||
|
maxAudioChannels?: number;
|
||||||
|
protocol: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranscodeDecisionResponse = {
|
||||||
|
decision: 'direct' | 'transcode';
|
||||||
|
transcodeParams?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserInfoArgs = BaseEndpointArgs & { query: UserInfoQuery };
|
export type UserInfoArgs = BaseEndpointArgs & { query: UserInfoQuery };
|
||||||
|
|
||||||
export type UserInfoQuery = {
|
export type UserInfoQuery = {
|
||||||
@@ -1718,8 +1778,5 @@ type BaseEndpointArgsWithServer = {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
};
|
};
|
||||||
context?: {
|
context?: ApiContext;
|
||||||
pathReplace?: string;
|
|
||||||
pathReplaceWith?: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export enum ServerFeature {
|
|||||||
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
|
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
|
||||||
MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',
|
MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',
|
||||||
OS_FORM_POST = 'osFormPost',
|
OS_FORM_POST = 'osFormPost',
|
||||||
|
OS_TRANSCODE_DECISION = 'osTranscodeDecision',
|
||||||
PLAYLISTS_SMART = 'playlistsSmart',
|
PLAYLISTS_SMART = 'playlistsSmart',
|
||||||
PUBLIC_PLAYLIST = 'publicPlaylist',
|
PUBLIC_PLAYLIST = 'publicPlaylist',
|
||||||
SERVER_PLAY_QUEUE = 'serverPlayQueue',
|
SERVER_PLAY_QUEUE = 'serverPlayQueue',
|
||||||
|
|||||||
Reference in New Issue
Block a user