mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 20:40:15 +02:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6cfafc442 | |||
| 4e45e60e29 | |||
| ecda918b46 | |||
| 93834788b5 | |||
| 66e7b44d75 | |||
| 8825fc1e84 | |||
| ac0cc19c04 | |||
| de29465b1f | |||
| 31a201ca32 | |||
| 3644ea2969 | |||
| e46c61e026 | |||
| 3873218e94 | |||
| b4a61cbd6e | |||
| accc6e53f0 | |||
| adf48decc4 |
@@ -1,14 +1,17 @@
|
|||||||
name: Publish (PR)
|
name: Publish (PR)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- development
|
- development
|
||||||
paths:
|
paths:
|
||||||
- 'src/**'
|
- 'src/**'
|
||||||
|
- 'electron-builder*.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
wait-for-lint:
|
wait-for-lint:
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Wait for Test workflow to complete
|
- name: Wait for Test workflow to complete
|
||||||
@@ -22,6 +25,7 @@ jobs:
|
|||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: wait-for-lint
|
needs: wait-for-lint
|
||||||
|
if: always() && (needs.wait-for-lint.result == 'success' || needs.wait-for-lint.result == 'skipped')
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ COPY --chown=nginx:nginx --from=builder /app/out/web /usr/share/nginx/html
|
|||||||
COPY ./settings.js.template /etc/nginx/templates/settings.js.template
|
COPY ./settings.js.template /etc/nginx/templates/settings.js.template
|
||||||
COPY ng.conf.template /etc/nginx/templates/default.conf.template
|
COPY ng.conf.template /etc/nginx/templates/default.conf.template
|
||||||
|
|
||||||
ENV SERVER_LOCK=false SERVER_NAME="" SERVER_TYPE="" SERVER_URL=""
|
ENV SERVER_LOCK=false SERVER_NAME="" SERVER_TYPE="" SERVER_URL="" REMOTE_URL=""
|
||||||
ENV LEGACY_AUTHENTICATION="" ANALYTICS_DISABLED="" PUBLIC_PATH="/"
|
ENV LEGACY_AUTHENTICATION="" ANALYTICS_DISABLED="" PUBLIC_PATH="/"
|
||||||
|
|
||||||
EXPOSE 9180
|
EXPOSE 9180
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ services:
|
|||||||
- SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled
|
- SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled
|
||||||
- SERVER_TYPE=jellyfin # the allowed types are: jellyfin, navidrome, subsonic. These values are case insensitive
|
- SERVER_TYPE=jellyfin # the allowed types are: jellyfin, navidrome, subsonic. These values are case insensitive
|
||||||
- SERVER_URL= # http://address:port or https://address:port
|
- SERVER_URL= # http://address:port or https://address:port
|
||||||
|
= REMOTE_URL= # http://address or https://address
|
||||||
- LEGACY_AUTHENTICATION=false # When SERVER_LOCK is true, sets the legacy (plaintext) authentication flag for Subsonic/OpenSubsonic servers
|
- LEGACY_AUTHENTICATION=false # When SERVER_LOCK is true, sets the legacy (plaintext) authentication flag for Subsonic/OpenSubsonic servers
|
||||||
- ANALYTICS_DISABLED=true # Set to true to disable Umami analytics tracking
|
- ANALYTICS_DISABLED=true # Set to true to disable Umami analytics tracking
|
||||||
ports:
|
ports:
|
||||||
@@ -134,9 +135,11 @@ services:
|
|||||||
|
|
||||||
4. _Optional_ - To hard code the server url, pass the following environment variables: `SERVER_NAME`, `SERVER_TYPE` (one of `jellyfin` or `navidrome` or `subsonic`), `SERVER_URL`. To prevent users from changing these settings, pass `SERVER_LOCK=true`. This can only be set if all three of the previous values are set. When `SERVER_LOCK=true`, you can also set `LEGACY_AUTHENTICATION=true` or `LEGACY_AUTHENTICATION=false` to configure the legacy authentication flag for the server (only applicable for Subsonic/OpenSubsonic servers).
|
4. _Optional_ - To hard code the server url, pass the following environment variables: `SERVER_NAME`, `SERVER_TYPE` (one of `jellyfin` or `navidrome` or `subsonic`), `SERVER_URL`. To prevent users from changing these settings, pass `SERVER_LOCK=true`. This can only be set if all three of the previous values are set. When `SERVER_LOCK=true`, you can also set `LEGACY_AUTHENTICATION=true` or `LEGACY_AUTHENTICATION=false` to configure the legacy authentication flag for the server (only applicable for Subsonic/OpenSubsonic servers).
|
||||||
|
|
||||||
5. _Optional_ - To disable Umami analytics tracking in the Docker/web version, set the environment variable `ANALYTICS_DISABLED=true`. When enabled, the analytics script will not be loaded and all tracking will be disabled.
|
5. _Optional_ - If your server uses a separate public-facing URL than what integrating applications use internally to communicate with your server, such as a separate Navidrome `ShareURL`, set `REMOTE_URL` to said public-facing URL.
|
||||||
|
|
||||||
6. _Optional_ - App settings (theme, language, sidebar options, etc.) can be overridden with environment variables on first run. The variables use the `FS_` prefix (e.g. `FS_GENERAL_THEME=defaultDark`, `FS_GENERAL_LANGUAGE=de`). See [the settings environment variable documentation](docs/ENV_SETTINGS.md) for the full list.
|
6. _Optional_ - To disable Umami analytics tracking in the Docker/web version, set the environment variable `ANALYTICS_DISABLED=true`. When enabled, the analytics script will not be loaded and all tracking will be disabled.
|
||||||
|
|
||||||
|
7. _Optional_ - App settings (theme, language, sidebar options, etc.) can be overridden with environment variables on first run. The variables use the `FS_` prefix (e.g. `FS_GENERAL_THEME=defaultDark`, `FS_GENERAL_LANGUAGE=de`). See [the settings environment variable documentation](docs/ENV_SETTINGS.md) for the full list.
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ services:
|
|||||||
- SERVER_LOCK=false # When true AND name/type/url are set, only username/password can be toggled
|
- SERVER_LOCK=false # When true AND name/type/url are set, only username/password can be toggled
|
||||||
- SERVER_TYPE=jellyfin # the allowed types are: jellyfin, navidrome, subsonic. These values are case insensitive
|
- SERVER_TYPE=jellyfin # the allowed types are: jellyfin, navidrome, subsonic. These values are case insensitive
|
||||||
- SERVER_URL=http://localhost:8096 # http://address:port or https://address:port
|
- SERVER_URL=http://localhost:8096 # http://address:port or https://address:port
|
||||||
|
# - REMOTE_URL=http://share.localhost # Used for compatibility with external functionality, such as custom sharing URLs on Navidrome
|
||||||
- LEGACY_AUTHENTICATION=false # When SERVER_LOCK is true, sets the legacyauth flag for server authentication (true or false)
|
- LEGACY_AUTHENTICATION=false # When SERVER_LOCK is true, sets the legacyauth flag for server authentication (true or false)
|
||||||
- ANALYTICS_DISABLED=false # Set to true to disable Umami analytics tracking
|
- ANALYTICS_DISABLED=false # Set to true to disable Umami analytics tracking
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -32,18 +32,22 @@ nsis:
|
|||||||
|
|
||||||
mac:
|
mac:
|
||||||
target:
|
target:
|
||||||
target: default
|
- target: dmg
|
||||||
arch:
|
arch:
|
||||||
- arm64
|
- arm64
|
||||||
- x64
|
- x64
|
||||||
|
- target: zip
|
||||||
|
arch:
|
||||||
|
- arm64
|
||||||
|
- x64
|
||||||
icon: assets/icons/icon.icns
|
icon: assets/icons/icon.icns
|
||||||
type: distribution
|
type: distribution
|
||||||
hardenedRuntime: true
|
hardenedRuntime: false
|
||||||
entitlements: assets/entitlements.mac.plist
|
identity: "-"
|
||||||
entitlementsInherit: assets/entitlements.mac.plist
|
|
||||||
gatekeeperAssess: false
|
gatekeeperAssess: false
|
||||||
notarize: false
|
notarize: false
|
||||||
|
|
||||||
|
|
||||||
dmg:
|
dmg:
|
||||||
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
|
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
|
||||||
|
|
||||||
@@ -56,6 +60,9 @@ linux:
|
|||||||
icon: assets/icons/icon.png
|
icon: assets/icons/icon.png
|
||||||
artifactName: ${productName}-${os}-${arch}.${ext}
|
artifactName: ${productName}-${os}-${arch}.${ext}
|
||||||
|
|
||||||
|
toolsets:
|
||||||
|
appimage: "1.0.2"
|
||||||
|
|
||||||
npmRebuild: false
|
npmRebuild: false
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
|
|||||||
@@ -32,15 +32,18 @@ nsis:
|
|||||||
|
|
||||||
mac:
|
mac:
|
||||||
target:
|
target:
|
||||||
target: default
|
- target: dmg
|
||||||
arch:
|
arch:
|
||||||
- arm64
|
- arm64
|
||||||
- x64
|
- x64
|
||||||
|
- target: zip
|
||||||
|
arch:
|
||||||
|
- arm64
|
||||||
|
- x64
|
||||||
icon: assets/icons/icon.icns
|
icon: assets/icons/icon.icns
|
||||||
type: distribution
|
type: distribution
|
||||||
hardenedRuntime: true
|
hardenedRuntime: false
|
||||||
entitlements: assets/entitlements.mac.plist
|
identity: "-"
|
||||||
entitlementsInherit: assets/entitlements.mac.plist
|
|
||||||
gatekeeperAssess: false
|
gatekeeperAssess: false
|
||||||
notarize: false
|
notarize: false
|
||||||
|
|
||||||
@@ -56,6 +59,9 @@ linux:
|
|||||||
icon: assets/icons/icon.png
|
icon: assets/icons/icon.png
|
||||||
artifactName: ${productName}-${os}-${arch}.${ext}
|
artifactName: ${productName}-${os}-${arch}.${ext}
|
||||||
|
|
||||||
|
toolsets:
|
||||||
|
appimage: "1.0.2"
|
||||||
|
|
||||||
npmRebuild: false
|
npmRebuild: false
|
||||||
publish:
|
publish:
|
||||||
provider: github
|
provider: github
|
||||||
|
|||||||
+13
-7
@@ -32,15 +32,18 @@ nsis:
|
|||||||
|
|
||||||
mac:
|
mac:
|
||||||
target:
|
target:
|
||||||
target: default
|
- target: dmg
|
||||||
arch:
|
arch:
|
||||||
- arm64
|
- arm64
|
||||||
- x64
|
- x64
|
||||||
|
- target: zip
|
||||||
|
arch:
|
||||||
|
- arm64
|
||||||
|
- x64
|
||||||
icon: assets/icons/icon.icns
|
icon: assets/icons/icon.icns
|
||||||
type: distribution
|
type: distribution
|
||||||
hardenedRuntime: true
|
hardenedRuntime: false
|
||||||
entitlements: assets/entitlements.mac.plist
|
identity: "-"
|
||||||
entitlementsInherit: assets/entitlements.mac.plist
|
|
||||||
gatekeeperAssess: false
|
gatekeeperAssess: false
|
||||||
notarize: false
|
notarize: false
|
||||||
|
|
||||||
@@ -56,6 +59,9 @@ linux:
|
|||||||
icon: assets/icons/icon.png
|
icon: assets/icons/icon.png
|
||||||
artifactName: ${productName}-${os}-${arch}.${ext}
|
artifactName: ${productName}-${os}-${arch}.${ext}
|
||||||
|
|
||||||
|
toolsets:
|
||||||
|
appimage: "1.0.2"
|
||||||
|
|
||||||
npmRebuild: false
|
npmRebuild: false
|
||||||
afterAllArtifactBuild: scripts/after-all-artifact-build.mjs
|
afterAllArtifactBuild: scripts/after-all-artifact-build.mjs
|
||||||
publish:
|
publish:
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "feishin",
|
"name": "feishin",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"description": "A modern self-hosted music player.",
|
"description": "A modern self-hosted music player.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"subsonic",
|
"subsonic",
|
||||||
@@ -127,7 +127,6 @@
|
|||||||
"react-error-boundary": "^5.0.0",
|
"react-error-boundary": "^5.0.0",
|
||||||
"react-i18next": "^16.3.3",
|
"react-i18next": "^16.3.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-image": "^4.1.0",
|
|
||||||
"react-player": "^2.16.0",
|
"react-player": "^2.16.0",
|
||||||
"react-router": "^7.13.1",
|
"react-router": "^7.13.1",
|
||||||
"react-split-pane": "^3.0.4",
|
"react-split-pane": "^3.0.4",
|
||||||
|
|||||||
Generated
-16
@@ -182,9 +182,6 @@ importers:
|
|||||||
react-icons:
|
react-icons:
|
||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.5.0(react@19.1.0)
|
version: 5.5.0(react@19.1.0)
|
||||||
react-image:
|
|
||||||
specifier: ^4.1.0
|
|
||||||
version: 4.1.0(@babel/runtime@7.28.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
|
||||||
react-player:
|
react-player:
|
||||||
specifier: ^2.16.0
|
specifier: ^2.16.0
|
||||||
version: 2.16.0(react@19.1.0)
|
version: 2.16.0(react@19.1.0)
|
||||||
@@ -4734,13 +4731,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '*'
|
react: '*'
|
||||||
|
|
||||||
react-image@4.1.0:
|
|
||||||
resolution: {integrity: sha512-qwPNlelQe9Zy14K2pGWSwoL+vHsAwmJKS6gkotekDgRpcnRuzXNap00GfibD3eEPYu3WCPlyIUUNzcyHOrLHjw==}
|
|
||||||
peerDependencies:
|
|
||||||
'@babel/runtime': '>=7'
|
|
||||||
react: '>=16.8'
|
|
||||||
react-dom: '>=16.8'
|
|
||||||
|
|
||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@@ -11094,12 +11084,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
|
|
||||||
react-image@4.1.0(@babel/runtime@7.28.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
|
||||||
dependencies:
|
|
||||||
'@babel/runtime': 7.28.4
|
|
||||||
react: 19.1.0
|
|
||||||
react-dom: 19.1.0(react@19.1.0)
|
|
||||||
|
|
||||||
react-is@16.13.1: {}
|
react-is@16.13.1: {}
|
||||||
|
|
||||||
react-number-format@5.4.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
react-number-format@5.4.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
window.SERVER_URL = "${SERVER_URL}";
|
window.SERVER_URL = "${SERVER_URL}";
|
||||||
|
window.REMOTE_URL = "${REMOTE_URL}";
|
||||||
window.SERVER_NAME = "${SERVER_NAME}";
|
window.SERVER_NAME = "${SERVER_NAME}";
|
||||||
window.SERVER_TYPE = "${SERVER_TYPE}";
|
window.SERVER_TYPE = "${SERVER_TYPE}";
|
||||||
window.SERVER_LOCK = "${SERVER_LOCK}";
|
window.SERVER_LOCK = "${SERVER_LOCK}";
|
||||||
|
|||||||
@@ -578,7 +578,8 @@
|
|||||||
"example": "příklad",
|
"example": "příklad",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
|
|||||||
@@ -578,7 +578,8 @@
|
|||||||
"example": "Ejemplo",
|
"example": "Ejemplo",
|
||||||
"filter_single": "simple",
|
"filter_single": "simple",
|
||||||
"filter_multiple": "multi",
|
"filter_multiple": "multi",
|
||||||
"rename": "Renombrar"
|
"rename": "Renombrar",
|
||||||
|
"newVersionAvailable": "Una nueva versión está disponible"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
|
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
|
||||||
|
|||||||
@@ -855,7 +855,11 @@
|
|||||||
"sidebarPlaylistSorting": "tri des listes de lecture dans la barre latérale",
|
"sidebarPlaylistSorting": "tri des listes de lecture dans la barre latérale",
|
||||||
"sidebarPlaylistListFilterRegex_description": "masquer les listes de lecture dans la barre latérale qui correspondent à cette expression régulière",
|
"sidebarPlaylistListFilterRegex_description": "masquer les listes de lecture dans la barre latérale qui correspondent à cette expression régulière",
|
||||||
"sidebarPlaylistListFilterRegex_placeholder": "ex. ^Mix Journalier*",
|
"sidebarPlaylistListFilterRegex_placeholder": "ex. ^Mix Journalier*",
|
||||||
"sidebarPlaylistListFilterRegex": "filtre d'expression régulière de liste de lecture"
|
"sidebarPlaylistListFilterRegex": "filtre d'expression régulière de liste de lecture",
|
||||||
|
"autosave": "sauvegarder automatiquement la file d'attente",
|
||||||
|
"autosave_description": "activez la sauvegarde automatique de la file d'attente de lecture sur votre serveur. Cette fonction est uniquement disponible avec Navidrome/Subsonic et ne permet pas d'utiliser une file d'attente mixte.",
|
||||||
|
"autosaveCount": "fréquence de sauvegarde automatique de la file d'attente",
|
||||||
|
"autosaveCount_description": "nombre de changement de piste avant la sauvegarde de la file d'attente. 1 (minimum) signifie chaque changement de titre"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"deletePlaylist": {
|
"deletePlaylist": {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
"forward": "前进",
|
"forward": "前进",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"forceRestartRequired": "重启应用使更改生效…关闭通知即可重启",
|
"forceRestartRequired": "重启应用以使更改生效…关闭通知即可重启",
|
||||||
"setting_other": "设置",
|
"setting_other": "设置",
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
"title": "标题",
|
"title": "标题",
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"duration": "时长",
|
"duration": "时长",
|
||||||
"ok": "好",
|
"ok": "好",
|
||||||
"no": "否",
|
"no": "否",
|
||||||
"playerMustBePaused": "播放器必须暂停",
|
"playerMustBePaused": "播放器必须先暂停",
|
||||||
"channel_other": "频道",
|
"channel_other": "频道",
|
||||||
"none": "无",
|
"none": "无",
|
||||||
"disc": "碟片",
|
"disc": "碟片",
|
||||||
@@ -156,7 +156,8 @@
|
|||||||
"filter_single": "单项",
|
"filter_single": "单项",
|
||||||
"mood": "氛围",
|
"mood": "氛围",
|
||||||
"rename": "重命名",
|
"rename": "重命名",
|
||||||
"filter_multiple": "多项"
|
"filter_multiple": "多项",
|
||||||
|
"newVersionAvailable": "a new version is available"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"albumArtist_other": "专辑艺术家",
|
"albumArtist_other": "专辑艺术家",
|
||||||
@@ -1210,13 +1211,27 @@
|
|||||||
"miscellaneousSettings": "杂项设置",
|
"miscellaneousSettings": "杂项设置",
|
||||||
"options": {
|
"options": {
|
||||||
"channelLayout": {
|
"channelLayout": {
|
||||||
"single": "单项"
|
"single": "单项",
|
||||||
|
"dualCombined": "Dual-Combined",
|
||||||
|
"dualHorizontal": "Dual-Horizontal",
|
||||||
|
"dualVertical": "Dual-Vertical"
|
||||||
},
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"0": "[0] 离散频率"
|
"0": "[0] 离散频率",
|
||||||
|
"1": "[1] 1/24th octave / 240 bands",
|
||||||
|
"2": "[2] 1/12th octave / 120 bands",
|
||||||
|
"3": "[3] 1/8th octave / 80 bands",
|
||||||
|
"4": "[4] 1/6th octave / 60 bands",
|
||||||
|
"5": "[5] 1/4th octave / 40 bands",
|
||||||
|
"6": "[6] 1/3rd octave / 30 bands",
|
||||||
|
"7": "[7] Half octave / 20 bands",
|
||||||
|
"8": "[8] Full octave / 10 bands",
|
||||||
|
"10": "[10] Line / Area graph"
|
||||||
},
|
},
|
||||||
"colorMode": {
|
"colorMode": {
|
||||||
"gradient": "渐变"
|
"gradient": "渐变",
|
||||||
|
"barIndex": "Bar-Index",
|
||||||
|
"barLevel": "Bar-Level"
|
||||||
},
|
},
|
||||||
"gradient": {
|
"gradient": {
|
||||||
"classic": "经典",
|
"classic": "经典",
|
||||||
@@ -1226,7 +1241,11 @@
|
|||||||
"orangered": "橙红色"
|
"orangered": "橙红色"
|
||||||
},
|
},
|
||||||
"frequencyScale": {
|
"frequencyScale": {
|
||||||
"none": "无"
|
"none": "无",
|
||||||
|
"bark": "Bark Scale",
|
||||||
|
"linear": "Linear Scale",
|
||||||
|
"log": "Log Scale",
|
||||||
|
"mel": "Mel Scale"
|
||||||
},
|
},
|
||||||
"weightingFilter": {
|
"weightingFilter": {
|
||||||
"none": "无",
|
"none": "无",
|
||||||
@@ -1284,7 +1303,14 @@
|
|||||||
"splitGradient": "渐变分割",
|
"splitGradient": "渐变分割",
|
||||||
"showScaleX": "显示比例尺 X",
|
"showScaleX": "显示比例尺 X",
|
||||||
"noteLabels": "笔记标签",
|
"noteLabels": "笔记标签",
|
||||||
"showScaleY": "显示比例尺 Y"
|
"showScaleY": "显示比例尺 Y",
|
||||||
|
"alphaBars": "Alpha Bars",
|
||||||
|
"ansiBands": "ANSI Bands",
|
||||||
|
"ledBars": "LED Bars",
|
||||||
|
"trueLeds": "True LEDs",
|
||||||
|
"lumiBars": "Lumi Bars",
|
||||||
|
"outlineBars": "Outline Bars",
|
||||||
|
"roundBars": "Round Bars"
|
||||||
},
|
},
|
||||||
"queryBuilder": {
|
"queryBuilder": {
|
||||||
"standardTags": "标准标签",
|
"standardTags": "标准标签",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"descending": "降冪",
|
"descending": "降冪",
|
||||||
"description": "描述",
|
"description": "描述",
|
||||||
"forceRestartRequired": "重新啟動應用程式使更改生效…關閉通知即可重啟",
|
"forceRestartRequired": "重新啟動應用程式以使更改生效…關閉通知後即可重啟",
|
||||||
"menu": "選單",
|
"menu": "選單",
|
||||||
"action_other": "操作",
|
"action_other": "操作",
|
||||||
"add": "新增",
|
"add": "新增",
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"ok": "好",
|
"ok": "好",
|
||||||
"owner": "所有者",
|
"owner": "所有者",
|
||||||
"path": "路徑",
|
"path": "路徑",
|
||||||
"playerMustBePaused": "播放器必須被暫停",
|
"playerMustBePaused": "播放器必須先暫停",
|
||||||
"previousSong": "上一首$t(entity.track, {\"count\": 1})",
|
"previousSong": "上一首$t(entity.track, {\"count\": 1})",
|
||||||
"quit": "退出",
|
"quit": "退出",
|
||||||
"random": "隨機",
|
"random": "隨機",
|
||||||
@@ -64,8 +64,8 @@
|
|||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"center": "中央",
|
"center": "中央",
|
||||||
"channel_other": "聲道",
|
"channel_other": "聲道",
|
||||||
"configure": "配置",
|
"configure": "設定",
|
||||||
"create": "創建",
|
"create": "建立",
|
||||||
"currentSong": "目前$t(entity.track, {\"count\": 1})",
|
"currentSong": "目前$t(entity.track, {\"count\": 1})",
|
||||||
"minimize": "最小化",
|
"minimize": "最小化",
|
||||||
"modified": "已修改",
|
"modified": "已修改",
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
"newVersion": "已安裝新版本 ({{version}})",
|
"newVersion": "已安裝新版本 ({{version}})",
|
||||||
"viewReleaseNotes": "查看發行註記",
|
"viewReleaseNotes": "查看發行註記",
|
||||||
"albumGain": "專輯增益",
|
"albumGain": "專輯增益",
|
||||||
"albumPeak": "專輯peak",
|
"albumPeak": "專輯峰值",
|
||||||
"bitDepth": "位元深度",
|
"bitDepth": "位元深度",
|
||||||
"close": "關閉",
|
"close": "關閉",
|
||||||
"codec": "編碼",
|
"codec": "編碼",
|
||||||
@@ -115,7 +115,8 @@
|
|||||||
"rename": "重新命名",
|
"rename": "重新命名",
|
||||||
"itemsMore": "{{count}} 更多",
|
"itemsMore": "{{count}} 更多",
|
||||||
"filter_single": "單選",
|
"filter_single": "單選",
|
||||||
"filter_multiple": "複選"
|
"filter_multiple": "複選",
|
||||||
|
"newVersionAvailable": "有新的版本可供使用"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
|
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
|
||||||
@@ -124,13 +125,13 @@
|
|||||||
"authenticationFailed": "驗證失敗",
|
"authenticationFailed": "驗證失敗",
|
||||||
"credentialsRequired": "需要憑證",
|
"credentialsRequired": "需要憑證",
|
||||||
"genericError": "發生了錯誤",
|
"genericError": "發生了錯誤",
|
||||||
"invalidServer": "無效的服務器",
|
"invalidServer": "無效的伺服器",
|
||||||
"localFontAccessDenied": "無法取得本地字體",
|
"localFontAccessDenied": "無法取得本地字體",
|
||||||
"loginRateError": "登錄請求嘗試次數過多,請稍後再試",
|
"loginRateError": "登入請求嘗試次數過多,請稍後再試",
|
||||||
"remoteDisableError": "$t(common.disable)遠端伺服器時出現錯誤",
|
"remoteDisableError": "$t(common.disable)遠端伺服器時出現錯誤",
|
||||||
"remoteEnableError": "$t(common.enable)遠端伺服器時出現錯誤",
|
"remoteEnableError": "$t(common.enable)遠端伺服器時出現錯誤",
|
||||||
"remotePortError": "設定遠端伺服器端口時發生錯誤",
|
"remotePortError": "設定遠端伺服器連接埠時發生錯誤",
|
||||||
"remotePortWarning": "重啟伺服器使新端口生效",
|
"remotePortWarning": "重啟伺服器使新連接埠生效",
|
||||||
"serverRequired": "需要伺服器",
|
"serverRequired": "需要伺服器",
|
||||||
"sessionExpiredError": "工作階段已過期",
|
"sessionExpiredError": "工作階段已過期",
|
||||||
"systemFontError": "嘗試取得系統字體時出現錯誤",
|
"systemFontError": "嘗試取得系統字體時出現錯誤",
|
||||||
@@ -138,13 +139,13 @@
|
|||||||
"mpvRequired": "需要 MPV",
|
"mpvRequired": "需要 MPV",
|
||||||
"playbackError": "無法播放媒體",
|
"playbackError": "無法播放媒體",
|
||||||
"badAlbum": "您看到此頁面是因為這首歌不是專輯的一部分。如果您的音樂資料夾頂層有一首歌,則很可能會看到此問題。 Jellyfin 僅將資料夾中的曲目分組",
|
"badAlbum": "您看到此頁面是因為這首歌不是專輯的一部分。如果您的音樂資料夾頂層有一首歌,則很可能會看到此問題。 Jellyfin 僅將資料夾中的曲目分組",
|
||||||
"badValue": "無效選項“{{value}}”。該值不再存在",
|
"badValue": "無效選項「{{value}}」。該值不再存在",
|
||||||
"networkError": "發生網路錯誤",
|
"networkError": "發生網路錯誤",
|
||||||
"notificationDenied": "通知權限被拒絕。此設定無效",
|
"notificationDenied": "通知權限被拒絕。此設定無效",
|
||||||
"openError": "無法開啟檔案",
|
"openError": "無法開啟檔案",
|
||||||
"multipleServerSaveQueueError": "播放佇列中包含不是來自目前伺服器的歌曲,此操作不受支援",
|
"multipleServerSaveQueueError": "播放佇列中包含不是來自目前伺服器的歌曲,此操作不受支援",
|
||||||
"saveQueueFailed": "儲存播放佇列失敗",
|
"saveQueueFailed": "儲存播放佇列失敗",
|
||||||
"settingsSyncError": "偵測到渲染器與主程序之間的設定不一致,請重新啟動應用程式以套用變更",
|
"settingsSyncError": "偵測到渲染器與主程式之間的設定不一致,請重新啟動應用程式以套用變更",
|
||||||
"noNetwork": "伺服器無法連線",
|
"noNetwork": "伺服器無法連線",
|
||||||
"noNetworkDescription": "無法連接到此伺服器",
|
"noNetworkDescription": "無法連接到此伺服器",
|
||||||
"invalidJson": "無效的 JSON",
|
"invalidJson": "無效的 JSON",
|
||||||
@@ -198,7 +199,7 @@
|
|||||||
"genres": "$t(entity.genre, {\"count\": 2})"
|
"genres": "$t(entity.genre, {\"count\": 2})"
|
||||||
},
|
},
|
||||||
"appMenu": {
|
"appMenu": {
|
||||||
"openBrowserDevtools": "打開瀏覽器開發者工具",
|
"openBrowserDevtools": "開啟瀏覽器開發者工具",
|
||||||
"collapseSidebar": "折疊側邊欄",
|
"collapseSidebar": "折疊側邊欄",
|
||||||
"expandSidebar": "展開側邊欄",
|
"expandSidebar": "展開側邊欄",
|
||||||
"goBack": "返回",
|
"goBack": "返回",
|
||||||
@@ -429,8 +430,8 @@
|
|||||||
"applicationHotkeys_description": "設定應用程式快捷鍵。切換勾選框來設為全域快捷鍵(僅桌面端)",
|
"applicationHotkeys_description": "設定應用程式快捷鍵。切換勾選框來設為全域快捷鍵(僅桌面端)",
|
||||||
"audioDevice": "音訊設備",
|
"audioDevice": "音訊設備",
|
||||||
"audioDevice_description": "選擇用於播放的音訊設備",
|
"audioDevice_description": "選擇用於播放的音訊設備",
|
||||||
"audioExclusiveMode": "音訊獨占模式",
|
"audioExclusiveMode": "音訊獨佔模式",
|
||||||
"audioExclusiveMode_description": "啟用獨占輸出模式。在此模式下,系統通常被鎖定,只有 mpv 能夠輸出音訊",
|
"audioExclusiveMode_description": "啟用獨佔輸出模式。在此模式下,系統通常被鎖定,只有 mpv 能夠輸出音訊",
|
||||||
"audioPlayer": "音訊播放器",
|
"audioPlayer": "音訊播放器",
|
||||||
"crossfadeDuration": "淡入淡出持續時間",
|
"crossfadeDuration": "淡入淡出持續時間",
|
||||||
"crossfadeDuration_description": "設定淡入淡出持續時間",
|
"crossfadeDuration_description": "設定淡入淡出持續時間",
|
||||||
@@ -503,7 +504,7 @@
|
|||||||
"playButtonBehavior_description": "設定歌曲新增到佇列時播放按鈕的預設動作",
|
"playButtonBehavior_description": "設定歌曲新增到佇列時播放按鈕的預設動作",
|
||||||
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||||
"remotePort": "遠端控制伺服器端口",
|
"remotePort": "遠端控制伺服器連接埠",
|
||||||
"remoteUsername": "遠端控制伺服器使用者名稱",
|
"remoteUsername": "遠端控制伺服器使用者名稱",
|
||||||
"replayGainClipping": "{{ReplayGain}}削波",
|
"replayGainClipping": "{{ReplayGain}}削波",
|
||||||
"replayGainFallback": "{{ReplayGain}}後備替代",
|
"replayGainFallback": "{{ReplayGain}}後備替代",
|
||||||
@@ -517,7 +518,7 @@
|
|||||||
"replayGainPreamp_description": "調整使用在{{ReplayGain}}值上的前置放大增益",
|
"replayGainPreamp_description": "調整使用在{{ReplayGain}}值上的前置放大增益",
|
||||||
"savePlayQueue": "儲存播放佇列",
|
"savePlayQueue": "儲存播放佇列",
|
||||||
"sampleRate_description": "如果選擇的取樣率與目前媒體的取樣率不同,請選擇要使用的輸出取樣率。小於 8000 的值將使用預設頻率",
|
"sampleRate_description": "如果選擇的取樣率與目前媒體的取樣率不同,請選擇要使用的輸出取樣率。小於 8000 的值將使用預設頻率",
|
||||||
"savePlayQueue_description": "當應用程式關閉時儲存播放佇列,並在應用程式打開時恢複它",
|
"savePlayQueue_description": "當應用程式關閉時儲存播放佇列,並在應用程式開啟時恢複它",
|
||||||
"scrobble": "記錄播放資訊(Scrobble)",
|
"scrobble": "記錄播放資訊(Scrobble)",
|
||||||
"scrobble_description": "在你的媒體伺服器中記錄播放資訊",
|
"scrobble_description": "在你的媒體伺服器中記錄播放資訊",
|
||||||
"showSkipButton": "顯示跳過按鈕",
|
"showSkipButton": "顯示跳過按鈕",
|
||||||
@@ -529,13 +530,13 @@
|
|||||||
"sidebarConfiguration_description": "選擇側邊欄包含的項目與順序",
|
"sidebarConfiguration_description": "選擇側邊欄包含的項目與順序",
|
||||||
"sidebarPlaylistList_description": "顯示或隱藏側邊欄歌單清單",
|
"sidebarPlaylistList_description": "顯示或隱藏側邊欄歌單清單",
|
||||||
"sidePlayQueueStyle": "側邊播放佇列樣式",
|
"sidePlayQueueStyle": "側邊播放佇列樣式",
|
||||||
"sidePlayQueueStyle_description": "設置側邊播放佇列樣式",
|
"sidePlayQueueStyle_description": "設定側邊播放佇列樣式",
|
||||||
"sidePlayQueueStyle_optionAttached": "吸附",
|
"sidePlayQueueStyle_optionAttached": "吸附",
|
||||||
"sidePlayQueueStyle_optionDetached": "分離",
|
"sidePlayQueueStyle_optionDetached": "分離",
|
||||||
"skipDuration": "跳過時長",
|
"skipDuration": "跳過時長",
|
||||||
"skipDuration_description": "設置每次按下跳過按鈕將會跳過的時長",
|
"skipDuration_description": "設定每次按下跳過按鈕將會跳過的時長",
|
||||||
"skipPlaylistPage": "跳過播放清單頁面",
|
"skipPlaylistPage": "跳過播放清單頁面",
|
||||||
"skipPlaylistPage_description": "打開播放清單時,直接查看歌曲列表而非查看預設頁面",
|
"skipPlaylistPage_description": "開啟播放清單時,直接查看歌曲列表而非查看預設頁面",
|
||||||
"theme": "主題",
|
"theme": "主題",
|
||||||
"themeDark": "主題(深色)",
|
"themeDark": "主題(深色)",
|
||||||
"useSystemTheme_description": "使用系統定義的淺色或深色主題",
|
"useSystemTheme_description": "使用系統定義的淺色或深色主題",
|
||||||
@@ -563,25 +564,25 @@
|
|||||||
"playButtonBehavior_optionPlay": "$t(player.play)",
|
"playButtonBehavior_optionPlay": "$t(player.play)",
|
||||||
"remotePassword": "遠端控制伺服器密碼",
|
"remotePassword": "遠端控制伺服器密碼",
|
||||||
"remotePassword_description": "設定遠端控制伺服器的密碼。這些憑證預設以不安全的方式傳輸,因此您應該使用一個您不在意的唯一密碼",
|
"remotePassword_description": "設定遠端控制伺服器的密碼。這些憑證預設以不安全的方式傳輸,因此您應該使用一個您不在意的唯一密碼",
|
||||||
"remotePort_description": "設定遠端控制伺服器的端口",
|
"remotePort_description": "設定遠端控制伺服器的連接埠",
|
||||||
"remoteUsername_description": "設定遠端控制伺服器的使用者名稱。如果使用者名稱和密碼都為空,則身份驗證將被禁用",
|
"remoteUsername_description": "設定遠端控制伺服器的使用者名稱。如果使用者名稱和密碼都為空,則身分驗證將被禁用",
|
||||||
"replayGainClipping_description": "自動降低增益以防止{{ReplayGain}}造成削波",
|
"replayGainClipping_description": "自動降低增益以防止{{ReplayGain}}造成削波",
|
||||||
"showSkipButtons": "顯示跳過按鈕",
|
"showSkipButtons": "顯示跳過按鈕",
|
||||||
"themeDark_description": "應用程式將使用深色主題",
|
"themeDark_description": "應用程式將使用深色主題",
|
||||||
"clearQueryCache_description": "Feishin的“軟清除”。這將會刷新播放清單、曲目標籤並重置儲存的歌詞。會保留設定、伺服器憑證和暫存圖片",
|
"clearQueryCache_description": "Feishin的「軟清除」。這將會刷新播放清單、曲目標籤並重置儲存的歌詞。會保留設定、伺服器憑證和暫存圖片",
|
||||||
"clearCache": "清除瀏覽器快取",
|
"clearCache": "清除瀏覽器快取",
|
||||||
"clearCache_description": "Feishin的“硬清除”。除了清除Feishin的快取、清除瀏覽器快取(儲存的圖片和其他資源)。會保留伺服器憑證和設定",
|
"clearCache_description": "Feishin的「硬清除」。除了清除Feishin的快取、清除瀏覽器快取(儲存的圖片和其他資源)。會保留伺服器憑證和設定",
|
||||||
"clearQueryCache": "清除Feishin快取",
|
"clearQueryCache": "清除Feishin快取",
|
||||||
"buttonSize": "播放器欄按鈕大小",
|
"buttonSize": "播放器欄按鈕大小",
|
||||||
"buttonSize_description": "播放器欄按鈕大小",
|
"buttonSize_description": "播放器欄按鈕大小",
|
||||||
"albumBackground": "專輯背景圖片",
|
"albumBackground": "專輯背景圖片",
|
||||||
"albumBackground_description": "為包含專輯封面的專輯頁面新增背景圖片",
|
"albumBackground_description": "為包含專輯封面的專輯頁面新增背景圖片",
|
||||||
"albumBackgroundBlur": "專輯背景圖片模糊大小",
|
"albumBackgroundBlur": "專輯背景圖片模糊大小",
|
||||||
"albumBackgroundBlur_description": "調整應用於專輯背景圖片的模糊量",
|
"albumBackgroundBlur_description": "調整應用程式於專輯背景圖片的模糊量",
|
||||||
"artistConfiguration": "專輯藝人頁面設定",
|
"artistConfiguration": "專輯藝人頁面設定",
|
||||||
"artistConfiguration_description": "設定專輯藝人頁面中顯示的項目及排序",
|
"artistConfiguration_description": "設定專輯藝人頁面中顯示的項目及排序",
|
||||||
"clearCacheSuccess": "成功清除快取",
|
"clearCacheSuccess": "成功清除快取",
|
||||||
"contextMenu": "右鍵選單配置",
|
"contextMenu": "右鍵選單設定",
|
||||||
"contextMenu_description": "允許您隱藏在右鍵選單項目時顯示的項目。未選取的項目將被隱藏",
|
"contextMenu_description": "允許您隱藏在右鍵選單項目時顯示的項目。未選取的項目將被隱藏",
|
||||||
"customCssEnable": "啟用自訂CSS",
|
"customCssEnable": "啟用自訂CSS",
|
||||||
"customCssEnable_description": "允許撰寫自訂CSS",
|
"customCssEnable_description": "允許撰寫自訂CSS",
|
||||||
@@ -598,8 +599,8 @@
|
|||||||
"externalLinks_description": "在藝人/專輯頁面顯示外部連結(Last.fm, MusicBrainz)",
|
"externalLinks_description": "在藝人/專輯頁面顯示外部連結(Last.fm, MusicBrainz)",
|
||||||
"preferLocalLyrics": "偏好本地歌詞",
|
"preferLocalLyrics": "偏好本地歌詞",
|
||||||
"preferLocalLyrics_description": "優先選擇本地歌詞,而不是遠端歌詞(如果可用)",
|
"preferLocalLyrics_description": "優先選擇本地歌詞,而不是遠端歌詞(如果可用)",
|
||||||
"homeConfiguration": "首頁配置",
|
"homeConfiguration": "首頁設定",
|
||||||
"homeConfiguration_description": "配置在首頁上顯示哪些項目以及顯示順序",
|
"homeConfiguration_description": "設定在首頁上顯示哪些項目以及顯示順序",
|
||||||
"homeFeature": "首頁特色輪播",
|
"homeFeature": "首頁特色輪播",
|
||||||
"homeFeature_description": "控制是否在首頁上顯示大型特色輪播",
|
"homeFeature_description": "控制是否在首頁上顯示大型特色輪播",
|
||||||
"imageAspectRatio": "使用原生封面照長寬比",
|
"imageAspectRatio": "使用原生封面照長寬比",
|
||||||
@@ -621,14 +622,14 @@
|
|||||||
"startMinimized": "啟動時最小化",
|
"startMinimized": "啟動時最小化",
|
||||||
"startMinimized_description": "在系統匣中啟動應用程式",
|
"startMinimized_description": "在系統匣中啟動應用程式",
|
||||||
"transcode_description": "啟用轉碼到不同格式",
|
"transcode_description": "啟用轉碼到不同格式",
|
||||||
"transcodeBitrate": "要轉碼的比特率",
|
"transcodeBitrate": "要轉碼的位元率",
|
||||||
"transcodeBitrate_description": "選擇要轉碼的比特率。 0 表示讓伺服器選擇",
|
"transcodeBitrate_description": "選擇要轉碼的位元率。 0 表示讓伺服器選擇",
|
||||||
"transcodeFormat": "轉碼的格式",
|
"transcodeFormat": "轉碼的格式",
|
||||||
"transcodeFormat_description": "選擇要轉碼的格式。留空來讓伺服器決定",
|
"transcodeFormat_description": "選擇要轉碼的格式。留空來讓伺服器決定",
|
||||||
"translationApiProvider": "翻譯API提供者",
|
"translationApiProvider": "翻譯API提供者",
|
||||||
"translationApiProvider_description": "翻譯API的提供者",
|
"translationApiProvider_description": "翻譯API的提供者",
|
||||||
"translationApiKey": "翻譯API金鑰",
|
"translationApiKey": "翻譯API金鑰",
|
||||||
"translationApiKey_description": "翻譯的API金鑰(僅限全域服務端點)",
|
"translationApiKey_description": "翻譯的API金鑰(僅限全域伺服端點)",
|
||||||
"translationTargetLanguage": "目標翻譯語言",
|
"translationTargetLanguage": "目標翻譯語言",
|
||||||
"translationTargetLanguage_description": "翻譯的目標語言",
|
"translationTargetLanguage_description": "翻譯的目標語言",
|
||||||
"trayEnabled": "顯示系統匣",
|
"trayEnabled": "顯示系統匣",
|
||||||
@@ -771,7 +772,7 @@
|
|||||||
"primaryShade": "主要色調",
|
"primaryShade": "主要色調",
|
||||||
"primaryShade_description": "覆蓋按鈕、連結及其他主色調元素所使用的主色調(0–9)",
|
"primaryShade_description": "覆蓋按鈕、連結及其他主色調元素所使用的主色調(0–9)",
|
||||||
"playerItemConfiguration_description": "設定全螢幕播放器顯示的項目及排列順序",
|
"playerItemConfiguration_description": "設定全螢幕播放器顯示的項目及排列順序",
|
||||||
"playerItemConfiguration": "播放器項目配置",
|
"playerItemConfiguration": "播放器項目設定",
|
||||||
"autosave": "自動儲存播放佇列",
|
"autosave": "自動儲存播放佇列",
|
||||||
"autosave_description": "啟用自動將播放佇列儲存到您的伺服器。這只有在使用Navidrome/Subsonic時才可使用,並且您不能有混合播放佇列。",
|
"autosave_description": "啟用自動將播放佇列儲存到您的伺服器。這只有在使用Navidrome/Subsonic時才可使用,並且您不能有混合播放佇列。",
|
||||||
"autosaveCount": "自動播放佇列儲存頻率",
|
"autosaveCount": "自動播放佇列儲存頻率",
|
||||||
@@ -784,7 +785,7 @@
|
|||||||
"gap": "$t(common.gap)",
|
"gap": "$t(common.gap)",
|
||||||
"size": "$t(common.size)",
|
"size": "$t(common.size)",
|
||||||
"tableColumns": "列",
|
"tableColumns": "列",
|
||||||
"autoFitColumns": "列寬自適應",
|
"autoFitColumns": "自動調整列寬",
|
||||||
"followCurrentSong": "跟隨目前歌曲",
|
"followCurrentSong": "跟隨目前歌曲",
|
||||||
"itemGap": "項目間隔 (px)",
|
"itemGap": "項目間隔 (px)",
|
||||||
"itemSize": "項目大小 (px)",
|
"itemSize": "項目大小 (px)",
|
||||||
@@ -862,7 +863,7 @@
|
|||||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||||
"biography": "簡介",
|
"biography": "簡介",
|
||||||
"bitrate": "比特率",
|
"bitrate": "位元率",
|
||||||
"channels": "$t(common.channel, {\"count\": 2})",
|
"channels": "$t(common.channel, {\"count\": 2})",
|
||||||
"comment": "評論",
|
"comment": "評論",
|
||||||
"dateAdded": "新增日期",
|
"dateAdded": "新增日期",
|
||||||
@@ -889,7 +890,7 @@
|
|||||||
"action": {
|
"action": {
|
||||||
"addToFavorites": "新增到$t(entity.favorite, {\"count\": 2})",
|
"addToFavorites": "新增到$t(entity.favorite, {\"count\": 2})",
|
||||||
"clearQueue": "清空播放佇列",
|
"clearQueue": "清空播放佇列",
|
||||||
"createPlaylist": "創建$t(entity.playlist, {\"count\": 1})",
|
"createPlaylist": "建立$t(entity.playlist, {\"count\": 1})",
|
||||||
"deletePlaylist": "刪除$t(entity.playlist, {\"count\": 1})",
|
"deletePlaylist": "刪除$t(entity.playlist, {\"count\": 1})",
|
||||||
"addToPlaylist": "新增到$t(entity.playlist, {\"count\": 1})",
|
"addToPlaylist": "新增到$t(entity.playlist, {\"count\": 1})",
|
||||||
"deselectAll": "取消全選",
|
"deselectAll": "取消全選",
|
||||||
@@ -919,7 +920,7 @@
|
|||||||
"moveDown": "向下移動",
|
"moveDown": "向下移動",
|
||||||
"holdToMoveToTop": "按住以移動至頂部",
|
"holdToMoveToTop": "按住以移動至頂部",
|
||||||
"holdToMoveToBottom": "按住以移動至底部",
|
"holdToMoveToBottom": "按住以移動至底部",
|
||||||
"createRadioStation": "創建 $t(entity.radioStation, {\"count\": 1})",
|
"createRadioStation": "建立 $t(entity.radioStation, {\"count\": 1})",
|
||||||
"deleteRadioStation": "刪除 $t(entity.radioStation, {\"count\": 1})",
|
"deleteRadioStation": "刪除 $t(entity.radioStation, {\"count\": 1})",
|
||||||
"openApplicationDirectory": "開啟應用程式目錄",
|
"openApplicationDirectory": "開啟應用程式目錄",
|
||||||
"addOrRemoveFromSelection": "新增或移除選取項目",
|
"addOrRemoveFromSelection": "新增或移除選取項目",
|
||||||
@@ -953,7 +954,7 @@
|
|||||||
"albumCount": "$t(entity.album, {\"count\": 2})數",
|
"albumCount": "$t(entity.album, {\"count\": 2})數",
|
||||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||||
"biography": "個人簡介",
|
"biography": "個人簡介",
|
||||||
"bitrate": "比特率",
|
"bitrate": "位元率",
|
||||||
"bpm": "bpm",
|
"bpm": "bpm",
|
||||||
"channels": "$t(common.channel, {\"count\": 2})",
|
"channels": "$t(common.channel, {\"count\": 2})",
|
||||||
"comment": "評論",
|
"comment": "評論",
|
||||||
@@ -1011,7 +1012,7 @@
|
|||||||
"ignoreCors": "忽略 cors $t(common.restartRequired)",
|
"ignoreCors": "忽略 cors $t(common.restartRequired)",
|
||||||
"ignoreSsl": "忽略 ssl $t(common.restartRequired)",
|
"ignoreSsl": "忽略 ssl $t(common.restartRequired)",
|
||||||
"input_preferInstantMix": "偏好即時混音",
|
"input_preferInstantMix": "偏好即時混音",
|
||||||
"input_preferInstantMixDescription": "僅使用即時混音功能來獲取相似歌曲。若您擁有能修改此行為的外掛,此功能將相當實用",
|
"input_preferInstantMixDescription": "僅使用即時混音功能來取得相似歌曲。若您擁有能修改此行為的外掛,此功能將相當實用",
|
||||||
"input_preferRemoteUrl": "優先使用公開網址",
|
"input_preferRemoteUrl": "優先使用公開網址",
|
||||||
"input_remoteUrl": "公開網址",
|
"input_remoteUrl": "公開網址",
|
||||||
"input_remoteUrlPlaceholder": "選用:對外功能的公開網址"
|
"input_remoteUrlPlaceholder": "選用:對外功能的公開網址"
|
||||||
@@ -1021,7 +1022,7 @@
|
|||||||
"input_skipDuplicates": "跳過重複",
|
"input_skipDuplicates": "跳過重複",
|
||||||
"success": "新增 $t(entity.trackWithCount, {\"count\": {{message}} }) 到 $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
"success": "新增 $t(entity.trackWithCount, {\"count\": {{message}} }) 到 $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||||
"title": "新增到$t(entity.playlist, {\"count\": 1})",
|
"title": "新增到$t(entity.playlist, {\"count\": 1})",
|
||||||
"create": "創建 $t(entity.playlist, {\"count\": 1}) {{playlist}}",
|
"create": "建立 $t(entity.playlist, {\"count\": 1}) {{playlist}}",
|
||||||
"searchOrCreate": "搜尋$t(entity.playlist, {\"count\": 2}) 或輸入內容以建立新項目"
|
"searchOrCreate": "搜尋$t(entity.playlist, {\"count\": 2}) 或輸入內容以建立新項目"
|
||||||
},
|
},
|
||||||
"createPlaylist": {
|
"createPlaylist": {
|
||||||
@@ -1029,8 +1030,8 @@
|
|||||||
"input_name": "$t(common.name)",
|
"input_name": "$t(common.name)",
|
||||||
"input_owner": "$t(common.owner)",
|
"input_owner": "$t(common.owner)",
|
||||||
"input_public": "公開",
|
"input_public": "公開",
|
||||||
"success": "已成功創建 $t(entity.playlist, {\"count\": 1})",
|
"success": "已成功建立 $t(entity.playlist, {\"count\": 1})",
|
||||||
"title": "創建$t(entity.playlist, {\"count\": 1})"
|
"title": "建立$t(entity.playlist, {\"count\": 1})"
|
||||||
},
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"input_name": "$t(common.name)",
|
"input_name": "$t(common.name)",
|
||||||
@@ -1092,8 +1093,8 @@
|
|||||||
"input_played_optionPlayed": "僅播放過的曲目"
|
"input_played_optionPlayed": "僅播放過的曲目"
|
||||||
},
|
},
|
||||||
"createRadioStation": {
|
"createRadioStation": {
|
||||||
"success": "電台創建成功",
|
"success": "電台建立成功",
|
||||||
"title": "創建電台",
|
"title": "建立電台",
|
||||||
"input_homepageUrl": "首頁連結",
|
"input_homepageUrl": "首頁連結",
|
||||||
"input_name": "名稱",
|
"input_name": "名稱",
|
||||||
"input_streamUrl": "串流網址"
|
"input_streamUrl": "串流網址"
|
||||||
@@ -1202,8 +1203,8 @@
|
|||||||
"channelLayout": "聲道佈局",
|
"channelLayout": "聲道佈局",
|
||||||
"maxFPS": "最大幀率",
|
"maxFPS": "最大幀率",
|
||||||
"opacity": "不透明度",
|
"opacity": "不透明度",
|
||||||
"customGradients": "自定義漸層",
|
"customGradients": "自訂漸層",
|
||||||
"addCustomGradient": "新增自定義漸層",
|
"addCustomGradient": "新增自訂漸層",
|
||||||
"gradientName": "漸層名稱",
|
"gradientName": "漸層名稱",
|
||||||
"gradientNamePlaceholder": "漸層名稱",
|
"gradientNamePlaceholder": "漸層名稱",
|
||||||
"vertical": "垂直",
|
"vertical": "垂直",
|
||||||
|
|||||||
Vendored
+1
@@ -8,6 +8,7 @@ declare global {
|
|||||||
electron: ElectronAPI;
|
electron: ElectronAPI;
|
||||||
LEGACY_AUTHENTICATION?: boolean;
|
LEGACY_AUTHENTICATION?: boolean;
|
||||||
queryLocalFonts?: () => Promise<Font[]>;
|
queryLocalFonts?: () => Promise<Font[]>;
|
||||||
|
REMOTE_URL?: string;
|
||||||
SERVER_LOCK?: boolean;
|
SERVER_LOCK?: boolean;
|
||||||
SERVER_NAME?: string;
|
SERVER_NAME?: string;
|
||||||
SERVER_TYPE?: ServerType;
|
SERVER_TYPE?: ServerType;
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ const env = {
|
|||||||
SERVER_TYPE !== null
|
SERVER_TYPE !== null
|
||||||
? process.env.LEGACY_AUTHENTICATION?.toLocaleLowerCase() === 'true'
|
? process.env.LEGACY_AUTHENTICATION?.toLocaleLowerCase() === 'true'
|
||||||
: false,
|
: false,
|
||||||
|
REMOTE_URL: process.env.REMOTE_URL ?? '',
|
||||||
SERVER_LOCK:
|
SERVER_LOCK:
|
||||||
SERVER_TYPE !== null ? process.env.SERVER_LOCK?.toLocaleLowerCase() === 'true' : false,
|
SERVER_TYPE !== null ? process.env.SERVER_LOCK?.toLocaleLowerCase() === 'true' : false,
|
||||||
SERVER_NAME: process.env.SERVER_NAME ?? '',
|
SERVER_NAME: process.env.SERVER_NAME ?? '',
|
||||||
|
|||||||
@@ -442,6 +442,25 @@ export const controller: GeneralController = {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
getImageRequest(args) {
|
||||||
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
apiController(
|
||||||
|
'getImageRequest',
|
||||||
|
server.type,
|
||||||
|
)?.(
|
||||||
|
addContext({
|
||||||
|
...args,
|
||||||
|
apiClientProps: { ...args.apiClientProps, server },
|
||||||
|
}),
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
},
|
||||||
getImageUrl(args) {
|
getImageUrl(args) {
|
||||||
const server = getServerById(args.apiClientProps.serverId);
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import filter from 'lodash/filter';
|
|||||||
import orderBy from 'lodash/orderBy';
|
import orderBy from 'lodash/orderBy';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
import { createAuthHeader, jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||||
import { useRadioStore } from '/@/renderer/features/radio/store/radio-store';
|
import { useRadioStore } from '/@/renderer/features/radio/store/radio-store';
|
||||||
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
|
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
|
||||||
import { jfNormalize } from '/@/shared/api/jellyfin/jellyfin-normalize';
|
import { jfNormalize } from '/@/shared/api/jellyfin/jellyfin-normalize';
|
||||||
@@ -15,10 +15,13 @@ import {
|
|||||||
albumListSortMap,
|
albumListSortMap,
|
||||||
Folder,
|
Folder,
|
||||||
genreListSortMap,
|
genreListSortMap,
|
||||||
|
ImageArgs,
|
||||||
|
ImageRequest,
|
||||||
InternalControllerEndpoint,
|
InternalControllerEndpoint,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
Played,
|
Played,
|
||||||
playlistListSortMap,
|
playlistListSortMap,
|
||||||
|
ReplaceApiClientProps,
|
||||||
ServerType,
|
ServerType,
|
||||||
Song,
|
Song,
|
||||||
SongListSort,
|
SongListSort,
|
||||||
@@ -29,6 +32,33 @@ import {
|
|||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ServerFeature } from '/@/shared/types/features-types';
|
import { ServerFeature } from '/@/shared/types/features-types';
|
||||||
|
|
||||||
|
const getJellyfinImageRequest = ({
|
||||||
|
apiClientProps: { server },
|
||||||
|
baseUrl,
|
||||||
|
query,
|
||||||
|
}: ReplaceApiClientProps<ImageArgs>): ImageRequest | null => {
|
||||||
|
const { id, size } = query;
|
||||||
|
const imageSize = size;
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = baseUrl || getServerUrl(server);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cacheKey: ['jellyfin', server.id, baseUrl || '', id, imageSize || ''].join(':'),
|
||||||
|
headers: server.credential
|
||||||
|
? { Authorization: createAuthHeader().concat(`, Token="${server.credential}"`) }
|
||||||
|
: { Authorization: createAuthHeader() },
|
||||||
|
url: `${url}/Items/${id}/Images/Primary?quality=96${imageSize ? `&width=${imageSize}` : ''}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const formatCommaDelimitedString = (value: string[]) => {
|
const formatCommaDelimitedString = (value: string[]) => {
|
||||||
return value.join(',');
|
return value.join(',');
|
||||||
};
|
};
|
||||||
@@ -789,23 +819,8 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
totalRecordCount: res.body?.TotalRecordCount || 0,
|
totalRecordCount: res.body?.TotalRecordCount || 0,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getImageUrl: ({ apiClientProps: { server }, baseUrl, query }) => {
|
getImageRequest: getJellyfinImageRequest,
|
||||||
const { id, size } = query;
|
getImageUrl: (args) => getJellyfinImageRequest(args)?.url || null,
|
||||||
const imageSize = size;
|
|
||||||
const url = baseUrl || getServerUrl(server);
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Jellyfin, we construct the URL pattern
|
|
||||||
// The server will return a 404 or placeholder if no image exists
|
|
||||||
const imageUrl = `${url}/Items/${id}/Images/Primary?quality=96${imageSize ? `&width=${imageSize}` : ''}`;
|
|
||||||
|
|
||||||
// For songs, we might want to fall back to album art, but we don't have albumId here
|
|
||||||
// The caller can handle this if needed
|
|
||||||
return imageUrl;
|
|
||||||
},
|
|
||||||
getInternetRadioStations: async (args) => {
|
getInternetRadioStations: async (args) => {
|
||||||
const { apiClientProps } = args;
|
const { apiClientProps } = args;
|
||||||
|
|
||||||
|
|||||||
@@ -545,6 +545,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
getImageRequest: SubsonicController.getImageRequest,
|
||||||
getImageUrl: SubsonicController.getImageUrl,
|
getImageUrl: SubsonicController.getImageUrl,
|
||||||
getInternetRadioStations: SubsonicController.getInternetRadioStations,
|
getInternetRadioStations: SubsonicController.getInternetRadioStations,
|
||||||
getLyrics: SubsonicController.getLyrics,
|
getLyrics: SubsonicController.getLyrics,
|
||||||
|
|||||||
@@ -20,9 +20,12 @@ import { hasFeature, sortAlbumArtistList, sortAlbumList, sortSongList } from '/@
|
|||||||
import {
|
import {
|
||||||
AlbumListSort,
|
AlbumListSort,
|
||||||
GenreListSort,
|
GenreListSort,
|
||||||
|
ImageArgs,
|
||||||
|
ImageRequest,
|
||||||
InternalControllerEndpoint,
|
InternalControllerEndpoint,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
PlaylistListSort,
|
PlaylistListSort,
|
||||||
|
ReplaceApiClientProps,
|
||||||
ServerType,
|
ServerType,
|
||||||
Song,
|
Song,
|
||||||
SongListSort,
|
SongListSort,
|
||||||
@@ -30,6 +33,36 @@ import {
|
|||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ServerFeature, ServerFeatures } from '/@/shared/types/features-types';
|
import { ServerFeature, ServerFeatures } from '/@/shared/types/features-types';
|
||||||
|
|
||||||
|
const getSubsonicImageRequest = ({
|
||||||
|
apiClientProps: { server },
|
||||||
|
baseUrl,
|
||||||
|
query,
|
||||||
|
}: ReplaceApiClientProps<ImageArgs>): ImageRequest | null => {
|
||||||
|
const { id, size } = query;
|
||||||
|
const imageSize = size;
|
||||||
|
const url = baseUrl || getServerUrl(server);
|
||||||
|
|
||||||
|
if (!url || !server?.credential) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for default placeholder image ID
|
||||||
|
if (id.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cacheKey: ['subsonic', server.id, baseUrl || '', id, imageSize || ''].join(':'),
|
||||||
|
url:
|
||||||
|
`${url}/rest/getCoverArt.view` +
|
||||||
|
`?id=${id}` +
|
||||||
|
`&${server.credential}` +
|
||||||
|
'&v=1.13.0' +
|
||||||
|
'&c=Feishin' +
|
||||||
|
(imageSize ? `&size=${imageSize}` : ''),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefined> = {
|
const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefined> = {
|
||||||
[AlbumListSort.ALBUM_ARTIST]: AlbumListSortType.ALPHABETICAL_BY_ARTIST,
|
[AlbumListSort.ALBUM_ARTIST]: AlbumListSortType.ALPHABETICAL_BY_ARTIST,
|
||||||
[AlbumListSort.ARTIST]: undefined,
|
[AlbumListSort.ARTIST]: undefined,
|
||||||
@@ -952,29 +985,8 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
startIndex: query.startIndex,
|
startIndex: query.startIndex,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getImageUrl: ({ apiClientProps: { server }, baseUrl, query }) => {
|
getImageRequest: getSubsonicImageRequest,
|
||||||
const { id, size } = query;
|
getImageUrl: (args) => getSubsonicImageRequest(args)?.url || null,
|
||||||
const imageSize = size;
|
|
||||||
const url = baseUrl || getServerUrl(server);
|
|
||||||
|
|
||||||
if (!url || !server?.credential) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for default placeholder image ID
|
|
||||||
if (id.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
`${url}/rest/getCoverArt.view` +
|
|
||||||
`?id=${id}` +
|
|
||||||
`&${server.credential}` +
|
|
||||||
'&v=1.13.0' +
|
|
||||||
'&c=Feishin' +
|
|
||||||
(imageSize ? `&size=${imageSize}` : '')
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getInternetRadioStations: async (args) => {
|
getInternetRadioStations: async (args) => {
|
||||||
const { apiClientProps } = args;
|
const { apiClientProps } = args;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
import { BaseImage, ImageProps } from '/@/shared/components/image/image';
|
import { BaseImage, ImageProps } from '/@/shared/components/image/image';
|
||||||
import { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';
|
import { ExplicitStatus, ImageRequest, LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
const getUnloaderIcon = (itemType: LibraryItem) => {
|
const getUnloaderIcon = (itemType: LibraryItem) => {
|
||||||
switch (itemType) {
|
switch (itemType) {
|
||||||
@@ -54,10 +54,19 @@ const BaseItemImage = (
|
|||||||
type: props.type,
|
type: props.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const imageRequest = useItemImageRequest({
|
||||||
|
id: props.id,
|
||||||
|
imageUrl: src,
|
||||||
|
itemType: props.itemType,
|
||||||
|
serverId: serverId || undefined,
|
||||||
|
type: props.type,
|
||||||
|
});
|
||||||
|
|
||||||
const isExplicit = blurExplicitImages && explicitStatus === ExplicitStatus.EXPLICIT;
|
const isExplicit = blurExplicitImages && explicitStatus === ExplicitStatus.EXPLICIT;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseImage
|
<BaseImage
|
||||||
|
imageRequest={imageRequest}
|
||||||
isExplicit={isExplicit}
|
isExplicit={isExplicit}
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
unloaderIcon={getUnloaderIcon(props.itemType)}
|
unloaderIcon={getUnloaderIcon(props.itemType)}
|
||||||
@@ -113,6 +122,79 @@ export const useItemImageUrl = (args: UseItemImageUrlProps) => {
|
|||||||
}, [args.serverId, id, imageUrl, itemType, serverId, size, sizeByType, useRemoteUrl]);
|
}, [args.serverId, id, imageUrl, itemType, serverId, size, sizeByType, useRemoteUrl]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useItemImageRequest = (args: UseItemImageUrlProps) => {
|
||||||
|
const { id, imageUrl, itemType, size, type, useRemoteUrl } = args;
|
||||||
|
const serverId = useCurrentServerId();
|
||||||
|
|
||||||
|
const imageRes = useImageRes();
|
||||||
|
const sizeByType: number | undefined = type ? imageRes[type] : undefined;
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (imageUrl) {
|
||||||
|
return {
|
||||||
|
cacheKey: imageUrl,
|
||||||
|
url: imageUrl,
|
||||||
|
} satisfies ImageRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetServerId = args.serverId || serverId;
|
||||||
|
let baseUrl: string | undefined;
|
||||||
|
|
||||||
|
if (useRemoteUrl) {
|
||||||
|
const server = getServerById(targetServerId);
|
||||||
|
baseUrl = server?.remoteUrl || server?.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
api.controller.getImageRequest({
|
||||||
|
apiClientProps: { serverId: targetServerId },
|
||||||
|
baseUrl,
|
||||||
|
query: { id, itemType, size: size ?? sizeByType },
|
||||||
|
}) || undefined
|
||||||
|
);
|
||||||
|
}, [args.serverId, id, imageUrl, itemType, serverId, size, sizeByType, useRemoteUrl]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getItemImageRequest(args: UseItemImageUrlProps) {
|
||||||
|
const { id, imageUrl, itemType, size, type, useRemoteUrl } = args;
|
||||||
|
const authStore = useAuthStore.getState();
|
||||||
|
const currentServerId = authStore.currentServer?.id;
|
||||||
|
const serverId = (args.serverId || currentServerId) as string;
|
||||||
|
|
||||||
|
const imageRes = useSettingsStore.getState().general.imageRes;
|
||||||
|
const sizeByType: number | undefined = type ? imageRes[type] : undefined;
|
||||||
|
|
||||||
|
if (imageUrl) {
|
||||||
|
return {
|
||||||
|
cacheKey: imageUrl,
|
||||||
|
url: imageUrl,
|
||||||
|
} satisfies ImageRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseUrl: string | undefined;
|
||||||
|
|
||||||
|
if (useRemoteUrl) {
|
||||||
|
const server = getServerById(serverId);
|
||||||
|
baseUrl = server?.remoteUrl || server?.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
api.controller.getImageRequest({
|
||||||
|
apiClientProps: { serverId },
|
||||||
|
baseUrl,
|
||||||
|
query: { id, itemType, size: size ?? sizeByType },
|
||||||
|
}) || undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getItemImageUrl(args: UseItemImageUrlProps) {
|
export function getItemImageUrl(args: UseItemImageUrlProps) {
|
||||||
const { id, imageUrl, itemType, size, type, useRemoteUrl } = args;
|
const { id, imageUrl, itemType, size, type, useRemoteUrl } = args;
|
||||||
const authStore = useAuthStore.getState();
|
const authStore = useAuthStore.getState();
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from '/@/renderer/features/shared/components/list-config-menu';
|
} from '/@/renderer/features/shared/components/list-config-menu';
|
||||||
import {
|
import {
|
||||||
CLIENT_SIDE_ALBUM_FILTERS,
|
CLIENT_SIDE_ALBUM_FILTERS,
|
||||||
|
CLIENT_SIDE_SONG_FILTERS,
|
||||||
ListSortByDropdownControlled,
|
ListSortByDropdownControlled,
|
||||||
} from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
} from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
||||||
import { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
import { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
||||||
@@ -57,7 +58,7 @@ import {
|
|||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
} from '/@/renderer/store/settings.store';
|
} from '/@/renderer/store/settings.store';
|
||||||
import { sanitize } from '/@/renderer/utils/sanitize';
|
import { sanitize } from '/@/renderer/utils/sanitize';
|
||||||
import { sortAlbumList } from '/@/shared/api/utils';
|
import { sortAlbumList, sortSongList } from '/@/shared/api/utils';
|
||||||
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
|
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Badge } from '/@/shared/components/badge/badge';
|
import { Badge } from '/@/shared/components/badge/badge';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
import { Button } from '/@/shared/components/button/button';
|
||||||
@@ -86,6 +87,7 @@ import {
|
|||||||
RelatedArtist,
|
RelatedArtist,
|
||||||
ServerType,
|
ServerType,
|
||||||
Song,
|
Song,
|
||||||
|
SongListSort,
|
||||||
SortOrder,
|
SortOrder,
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';
|
import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';
|
||||||
@@ -615,6 +617,14 @@ const AlbumArtistMetadataFavoriteSongs = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
|
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
|
||||||
|
const albumArtistDetailFavoriteSongsSort = useAppStore(
|
||||||
|
(state) => state.albumArtistDetailFavoriteSongsSort,
|
||||||
|
);
|
||||||
|
const setAlbumArtistDetailFavoriteSongsSort = useAppStore(
|
||||||
|
(state) => state.actions.setAlbumArtistDetailFavoriteSongsSort,
|
||||||
|
);
|
||||||
|
const sortBy = albumArtistDetailFavoriteSongsSort.sortBy;
|
||||||
|
const sortOrder = albumArtistDetailFavoriteSongsSort.sortOrder;
|
||||||
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table);
|
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table);
|
||||||
const currentSong = usePlayerSong();
|
const currentSong = usePlayerSong();
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
@@ -639,8 +649,12 @@ const AlbumArtistMetadataFavoriteSongs = ({
|
|||||||
}, [tableConfig?.columns]);
|
}, [tableConfig?.columns]);
|
||||||
|
|
||||||
const filteredSongs = useMemo(() => {
|
const filteredSongs = useMemo(() => {
|
||||||
return searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG);
|
return sortSongList(
|
||||||
}, [songs, debouncedSearchTerm]);
|
searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG),
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
);
|
||||||
|
}, [songs, debouncedSearchTerm, sortBy, sortOrder]);
|
||||||
|
|
||||||
const { handleColumnReordered } = useItemListColumnReorder({
|
const { handleColumnReordered } = useItemListColumnReorder({
|
||||||
itemListKey: ItemListKey.SONG,
|
itemListKey: ItemListKey.SONG,
|
||||||
@@ -798,6 +812,26 @@ const AlbumArtistMetadataFavoriteSongs = ({
|
|||||||
}}
|
}}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
/>
|
/>
|
||||||
|
<ListSortByDropdownControlled
|
||||||
|
filters={CLIENT_SIDE_SONG_FILTERS}
|
||||||
|
itemType={LibraryItem.SONG}
|
||||||
|
setSortBy={(value) =>
|
||||||
|
setAlbumArtistDetailFavoriteSongsSort(
|
||||||
|
value as SongListSort,
|
||||||
|
sortOrder,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sortBy={sortBy}
|
||||||
|
/>
|
||||||
|
<ListSortOrderToggleButtonControlled
|
||||||
|
setSortOrder={(value) =>
|
||||||
|
setAlbumArtistDetailFavoriteSongsSort(
|
||||||
|
sortBy,
|
||||||
|
value as SortOrder,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
/>
|
||||||
<ListConfigMenu
|
<ListConfigMenu
|
||||||
displayTypes={[
|
displayTypes={[
|
||||||
{ hidden: true, value: ListDisplayType.GRID },
|
{ hidden: true, value: ListDisplayType.GRID },
|
||||||
|
|||||||
@@ -192,10 +192,10 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LibraryHeader
|
<LibraryHeader
|
||||||
imageUrl={selectedImageUrl || alternateImageUrl}
|
imageUrl={alternateImageUrl || selectedImageUrl}
|
||||||
item={{
|
item={{
|
||||||
imageId: detailQuery.data?.imageId,
|
imageId: detailQuery.data?.imageId,
|
||||||
imageUrl: detailQuery.data?.imageUrl,
|
imageUrl: alternateImageUrl || detailQuery.data?.imageUrl,
|
||||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS,
|
route: AppRoute.LIBRARY_ALBUM_ARTISTS,
|
||||||
type: LibraryItem.ALBUM_ARTIST,
|
type: LibraryItem.ALBUM_ARTIST,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const LoginRoute = () => {
|
|||||||
const serverType = window.SERVER_TYPE ? toServerType(window.SERVER_TYPE) : null;
|
const serverType = window.SERVER_TYPE ? toServerType(window.SERVER_TYPE) : null;
|
||||||
const serverName = window.SERVER_NAME || '';
|
const serverName = window.SERVER_NAME || '';
|
||||||
const serverUrl = window.SERVER_URL || '';
|
const serverUrl = window.SERVER_URL || '';
|
||||||
|
const remoteUrl = window.REMOTE_URL || '';
|
||||||
const legacyAuth = serverLock && isLegacyAuth();
|
const legacyAuth = serverLock && isLegacyAuth();
|
||||||
|
|
||||||
const config = [
|
const config = [
|
||||||
@@ -88,6 +89,11 @@ const LoginRoute = () => {
|
|||||||
key: 'SERVER_URL',
|
key: 'SERVER_URL',
|
||||||
value: serverUrl,
|
value: serverUrl,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
isValid: remoteUrl !== '',
|
||||||
|
key: 'REMOTE_URL',
|
||||||
|
value: remoteUrl,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
@@ -150,6 +156,7 @@ const LoginRoute = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedUrl = normalizeUrl(serverUrl);
|
const normalizedUrl = normalizeUrl(serverUrl);
|
||||||
|
const normalizedRemoteURL = normalizeUrl(remoteUrl);
|
||||||
const existingServer =
|
const existingServer =
|
||||||
serverLock &&
|
serverLock &&
|
||||||
Object.values(serverList).find((s) => normalizeUrl(s.url) === normalizedUrl);
|
Object.values(serverList).find((s) => normalizeUrl(s.url) === normalizedUrl);
|
||||||
@@ -159,6 +166,7 @@ const LoginRoute = () => {
|
|||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
isAdmin: data.isAdmin,
|
isAdmin: data.isAdmin,
|
||||||
name: serverName,
|
name: serverName,
|
||||||
|
remoteUrl: normalizedRemoteURL,
|
||||||
type: serverType as ServerType,
|
type: serverType as ServerType,
|
||||||
url: normalizedUrl,
|
url: normalizedUrl,
|
||||||
userId: data.userId,
|
userId: data.userId,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { closeAllModals, openModal } from '@mantine/modals';
|
import { closeAllModals, openModal } from '@mantine/modals';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { forwardRef, ReactNode, Ref, useCallback, useState } from 'react';
|
import { forwardRef, ReactNode, Ref, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
@@ -64,13 +64,8 @@ export const LibraryHeader = forwardRef(
|
|||||||
ref: Ref<HTMLDivElement>,
|
ref: Ref<HTMLDivElement>,
|
||||||
) => {
|
) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isImageError, setIsImageError] = useState<boolean | null>(false);
|
|
||||||
const { blurExplicitImages } = useGeneralSettings();
|
const { blurExplicitImages } = useGeneralSettings();
|
||||||
|
|
||||||
const onImageError = () => {
|
|
||||||
setIsImageError(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemTypeString = (): string => {
|
const itemTypeString = (): string => {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case LibraryItem.ALBUM:
|
case LibraryItem.ALBUM:
|
||||||
@@ -161,21 +156,18 @@ export const LibraryHeader = forwardRef(
|
|||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{!isImageError && (
|
<ItemImage
|
||||||
<ItemImage
|
className={styles.image}
|
||||||
className={styles.image}
|
containerClassName={styles.image}
|
||||||
containerClassName={styles.image}
|
enableDebounce={false}
|
||||||
enableDebounce={false}
|
enableViewport={false}
|
||||||
enableViewport={false}
|
explicitStatus={item.explicitStatus ?? null}
|
||||||
explicitStatus={item.explicitStatus ?? null}
|
fetchPriority="high"
|
||||||
fetchPriority="high"
|
id={item.imageId}
|
||||||
id={item.imageId}
|
itemType={item.type as LibraryItem}
|
||||||
itemType={item.type as LibraryItem}
|
src={imageUrl || ''}
|
||||||
onError={onImageError}
|
type="header"
|
||||||
src={imageUrl || ''}
|
/>
|
||||||
type="header"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{title && (
|
{title && (
|
||||||
<div className={styles.metadataSection}>
|
<div className={styles.metadataSection}>
|
||||||
|
|||||||
Vendored
+1
@@ -74,6 +74,7 @@ declare global {
|
|||||||
FS_PLAYBACK_TRANSCODE_ENABLED?: string;
|
FS_PLAYBACK_TRANSCODE_ENABLED?: string;
|
||||||
FS_PLAYBACK_WEB_AUDIO?: string;
|
FS_PLAYBACK_WEB_AUDIO?: string;
|
||||||
LEGACY_AUTHENTICATION?: boolean | string;
|
LEGACY_AUTHENTICATION?: boolean | string;
|
||||||
|
REMOTE_URL?: string;
|
||||||
SERVER_LOCK?: boolean | string;
|
SERVER_LOCK?: boolean | string;
|
||||||
SERVER_NAME?: string;
|
SERVER_NAME?: string;
|
||||||
SERVER_TYPE?: string;
|
SERVER_TYPE?: string;
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import { devtools, persist } from 'zustand/middleware';
|
|||||||
import { immer } from 'zustand/middleware/immer';
|
import { immer } from 'zustand/middleware/immer';
|
||||||
import { createWithEqualityFn } from 'zustand/traditional';
|
import { createWithEqualityFn } from 'zustand/traditional';
|
||||||
|
|
||||||
import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types';
|
import { AlbumListSort, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||||
import { Platform } from '/@/shared/types/types';
|
import { Platform } from '/@/shared/types/types';
|
||||||
|
|
||||||
export interface AppSlice extends AppState {
|
export interface AppSlice extends AppState {
|
||||||
actions: {
|
actions: {
|
||||||
|
setAlbumArtistDetailFavoriteSongsSort: (sortBy: SongListSort, sortOrder: SortOrder) => void;
|
||||||
setAlbumArtistDetailGroupingType: (groupingType: 'all' | 'primary') => void;
|
setAlbumArtistDetailGroupingType: (groupingType: 'all' | 'primary') => void;
|
||||||
setAlbumArtistDetailSort: (sortBy: AlbumListSort, sortOrder: SortOrder) => void;
|
setAlbumArtistDetailSort: (sortBy: AlbumListSort, sortOrder: SortOrder) => void;
|
||||||
setAlbumArtistIdsMode: (mode: 'and' | 'or') => void;
|
setAlbumArtistIdsMode: (mode: 'and' | 'or') => void;
|
||||||
@@ -30,6 +31,10 @@ export interface AppSlice extends AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
|
albumArtistDetailFavoriteSongsSort: {
|
||||||
|
sortBy: SongListSort;
|
||||||
|
sortOrder: SortOrder;
|
||||||
|
};
|
||||||
albumArtistDetailSort: {
|
albumArtistDetailSort: {
|
||||||
groupingType: 'all' | 'primary';
|
groupingType: 'all' | 'primary';
|
||||||
sortBy: AlbumListSort;
|
sortBy: AlbumListSort;
|
||||||
@@ -83,6 +88,14 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
|||||||
devtools(
|
devtools(
|
||||||
immer((set, get) => ({
|
immer((set, get) => ({
|
||||||
actions: {
|
actions: {
|
||||||
|
setAlbumArtistDetailFavoriteSongsSort: (sortBy, sortOrder) => {
|
||||||
|
set((state) => {
|
||||||
|
state.albumArtistDetailFavoriteSongsSort = {
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
setAlbumArtistDetailGroupingType: (groupingType) => {
|
setAlbumArtistDetailGroupingType: (groupingType) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
state.albumArtistDetailSort.groupingType = groupingType;
|
state.albumArtistDetailSort.groupingType = groupingType;
|
||||||
@@ -161,6 +174,10 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
albumArtistDetailFavoriteSongsSort: {
|
||||||
|
sortBy: SongListSort.ID,
|
||||||
|
sortOrder: SortOrder.ASC,
|
||||||
|
},
|
||||||
albumArtistDetailSort: {
|
albumArtistDetailSort: {
|
||||||
groupingType: 'primary',
|
groupingType: 'primary',
|
||||||
sortBy: AlbumListSort.RELEASE_DATE,
|
sortBy: AlbumListSort.RELEASE_DATE,
|
||||||
|
|||||||
@@ -6,16 +6,19 @@ import {
|
|||||||
type ImgHTMLAttributes,
|
type ImgHTMLAttributes,
|
||||||
memo,
|
memo,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
useRef,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Img } from 'react-image';
|
|
||||||
|
|
||||||
import styles from './image.module.css';
|
import styles from './image.module.css';
|
||||||
|
import { useNativeImage } from './use-native-image';
|
||||||
|
|
||||||
import { AppIcon, Icon } from '/@/shared/components/icon/icon';
|
import { AppIcon, Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
|
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
|
||||||
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
||||||
import { useInViewport } from '/@/shared/hooks/use-in-viewport';
|
import { useInViewport } from '/@/shared/hooks/use-in-viewport';
|
||||||
|
import { ImageRequest } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
@@ -24,11 +27,11 @@ export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 's
|
|||||||
enableViewport?: boolean;
|
enableViewport?: boolean;
|
||||||
fetchPriority?: 'auto' | 'high' | 'low';
|
fetchPriority?: 'auto' | 'high' | 'low';
|
||||||
imageContainerProps?: Omit<ImageContainerProps, 'children'>;
|
imageContainerProps?: Omit<ImageContainerProps, 'children'>;
|
||||||
|
imageRequest?: ImageRequest;
|
||||||
includeLoader?: boolean;
|
includeLoader?: boolean;
|
||||||
includeUnloader?: boolean;
|
includeUnloader?: boolean;
|
||||||
isExplicit?: boolean;
|
isExplicit?: boolean;
|
||||||
src: string | undefined;
|
src: string | undefined;
|
||||||
thumbHash?: string;
|
|
||||||
unloaderIcon?: keyof typeof AppIcon;
|
unloaderIcon?: keyof typeof AppIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,230 +56,62 @@ export function BaseImage({
|
|||||||
className,
|
className,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
enableAnimation = false,
|
enableAnimation = false,
|
||||||
enableDebounce = true,
|
enableDebounce = false,
|
||||||
enableViewport = true,
|
enableViewport = true,
|
||||||
fetchPriority,
|
fetchPriority,
|
||||||
imageContainerProps,
|
imageContainerProps,
|
||||||
|
imageRequest,
|
||||||
includeLoader = true,
|
includeLoader = true,
|
||||||
includeUnloader = true,
|
includeUnloader = true,
|
||||||
isExplicit = false,
|
isExplicit = false,
|
||||||
|
onError,
|
||||||
|
onLoad,
|
||||||
src,
|
src,
|
||||||
unloaderIcon = 'emptyImage',
|
unloaderIcon = 'emptyImage',
|
||||||
...props
|
...props
|
||||||
}: ImageProps) {
|
}: ImageProps) {
|
||||||
if (enableDebounce) {
|
|
||||||
return (
|
|
||||||
<ImageWithDebounce
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
enableAnimation={enableAnimation}
|
|
||||||
enableViewport={enableViewport}
|
|
||||||
imageContainerProps={imageContainerProps}
|
|
||||||
includeLoader={includeLoader}
|
|
||||||
includeUnloader={includeUnloader}
|
|
||||||
isExplicit={isExplicit}
|
|
||||||
src={src}
|
|
||||||
unloaderIcon={unloaderIcon}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enableViewport) {
|
|
||||||
return (
|
|
||||||
<ImageWithViewport
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
enableAnimation={enableAnimation}
|
|
||||||
imageContainerProps={imageContainerProps}
|
|
||||||
includeLoader={includeLoader}
|
|
||||||
includeUnloader={includeUnloader}
|
|
||||||
isExplicit={isExplicit}
|
|
||||||
src={src}
|
|
||||||
unloaderIcon={unloaderIcon}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { className: containerPropsClassName, ...restContainerProps } = imageContainerProps || {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ImageContainer
|
|
||||||
className={clsx(containerClassName, containerPropsClassName)}
|
|
||||||
isExplicit={isExplicit}
|
|
||||||
{...restContainerProps}
|
|
||||||
>
|
|
||||||
{src ? (
|
|
||||||
<Img
|
|
||||||
className={clsx(styles.image, className, {
|
|
||||||
[styles.animated]: enableAnimation,
|
|
||||||
})}
|
|
||||||
decoding="async"
|
|
||||||
fetchPriority={fetchPriority}
|
|
||||||
loader={includeLoader ? <ImageLoader className={className} /> : null}
|
|
||||||
src={src}
|
|
||||||
unloader={
|
|
||||||
includeUnloader ? (
|
|
||||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
|
||||||
)}
|
|
||||||
</ImageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImageWithDebounce({
|
|
||||||
className,
|
|
||||||
containerClassName,
|
|
||||||
enableAnimation,
|
|
||||||
enableViewport,
|
|
||||||
fetchPriority,
|
|
||||||
imageContainerProps,
|
|
||||||
includeLoader,
|
|
||||||
includeUnloader,
|
|
||||||
isExplicit = false,
|
|
||||||
src,
|
|
||||||
unloaderIcon,
|
|
||||||
...props
|
|
||||||
}: ImageProps) {
|
|
||||||
const [debouncedSrc] = useDebouncedValue(src, 100, { waitForInitial: true });
|
|
||||||
const viewport = useInViewport();
|
const viewport = useInViewport();
|
||||||
const { inViewport, ref } = enableViewport ? viewport : { inViewport: true, ref: undefined };
|
const { inViewport, ref } = enableViewport ? viewport : { inViewport: true, ref: undefined };
|
||||||
const { className: containerPropsClassName, ...restContainerProps } = imageContainerProps || {};
|
const { className: containerPropsClassName, ...restContainerProps } = imageContainerProps || {};
|
||||||
|
|
||||||
const hasBeenInViewportRef = useRef(false);
|
const rawImageRequest = useMemo(
|
||||||
const prevDebouncedSrcRef = useRef(debouncedSrc);
|
() => imageRequest ?? (src ? { cacheKey: src, url: src } : undefined),
|
||||||
|
[imageRequest, src],
|
||||||
|
);
|
||||||
|
const [debouncedImageRequest] = useDebouncedValue(rawImageRequest, 100, {
|
||||||
|
waitForInitial: true,
|
||||||
|
});
|
||||||
|
const effectiveImageRequest = enableDebounce ? debouncedImageRequest : rawImageRequest;
|
||||||
|
|
||||||
const srcInDisplayedCache = isInDisplayedCache(src);
|
const [hasLoadedInInstance, setHasLoadedInInstance] = useState(false);
|
||||||
|
|
||||||
if (srcInDisplayedCache) {
|
useEffect(() => {
|
||||||
hasBeenInViewportRef.current = true;
|
setHasLoadedInInstance(false);
|
||||||
}
|
}, [effectiveImageRequest?.cacheKey]);
|
||||||
|
|
||||||
if (prevDebouncedSrcRef.current !== debouncedSrc) {
|
const shouldLoadImage = Boolean(
|
||||||
prevDebouncedSrcRef.current = debouncedSrc;
|
effectiveImageRequest && (!enableViewport || inViewport || hasLoadedInInstance),
|
||||||
if (!srcInDisplayedCache) hasBeenInViewportRef.current = false;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (inViewport && debouncedSrc) {
|
const nativeImage = useNativeImage({
|
||||||
hasBeenInViewportRef.current = true;
|
enabled: shouldLoadImage,
|
||||||
}
|
fetchPriority,
|
||||||
|
onFetchError: src
|
||||||
|
? () => {
|
||||||
|
(onError as ((event: undefined) => void) | undefined)?.(undefined);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
request: effectiveImageRequest,
|
||||||
|
});
|
||||||
|
|
||||||
const effectiveSrc = debouncedSrc ?? (srcInDisplayedCache ? src : undefined);
|
useEffect(() => {
|
||||||
const shouldShowImage = enableViewport
|
if (!nativeImage.isLoaded || !effectiveImageRequest?.cacheKey) {
|
||||||
? (inViewport || hasBeenInViewportRef.current) && effectiveSrc
|
return;
|
||||||
: effectiveSrc;
|
|
||||||
|
|
||||||
if (enableViewport) {
|
|
||||||
if (shouldShowImage && effectiveSrc) {
|
|
||||||
addToDisplayedCache(effectiveSrc);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
setHasLoadedInInstance(true);
|
||||||
<ImageContainer
|
}, [effectiveImageRequest?.cacheKey, nativeImage.isLoaded]);
|
||||||
className={clsx(containerClassName, containerPropsClassName)}
|
|
||||||
isExplicit={isExplicit}
|
|
||||||
ref={ref}
|
|
||||||
{...restContainerProps}
|
|
||||||
>
|
|
||||||
{shouldShowImage && effectiveSrc ? (
|
|
||||||
<Img
|
|
||||||
className={clsx(styles.image, className, {
|
|
||||||
[styles.animated]: enableAnimation,
|
|
||||||
})}
|
|
||||||
decoding="async"
|
|
||||||
fetchPriority={fetchPriority}
|
|
||||||
loader={includeLoader ? <ImageLoader className={className} /> : null}
|
|
||||||
src={effectiveSrc}
|
|
||||||
unloader={
|
|
||||||
includeUnloader ? (
|
|
||||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
) : !src ? (
|
|
||||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
|
||||||
) : (
|
|
||||||
<ImageLoader className={className} />
|
|
||||||
)}
|
|
||||||
</ImageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (effectiveSrc) addToDisplayedCache(effectiveSrc);
|
|
||||||
return (
|
|
||||||
<ImageContainer
|
|
||||||
className={clsx(containerClassName, containerPropsClassName)}
|
|
||||||
isExplicit={isExplicit}
|
|
||||||
{...restContainerProps}
|
|
||||||
>
|
|
||||||
{effectiveSrc ? (
|
|
||||||
<Img
|
|
||||||
className={clsx(styles.image, className, {
|
|
||||||
[styles.animated]: enableAnimation,
|
|
||||||
})}
|
|
||||||
decoding="async"
|
|
||||||
fetchPriority={fetchPriority}
|
|
||||||
loader={includeLoader ? <ImageLoader className={className} /> : null}
|
|
||||||
src={effectiveSrc}
|
|
||||||
unloader={
|
|
||||||
includeUnloader ? (
|
|
||||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
) : !src ? (
|
|
||||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
|
||||||
) : (
|
|
||||||
<ImageLoader className={className} />
|
|
||||||
)}
|
|
||||||
</ImageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImageWithViewport({
|
|
||||||
className,
|
|
||||||
containerClassName,
|
|
||||||
enableAnimation,
|
|
||||||
fetchPriority,
|
|
||||||
imageContainerProps,
|
|
||||||
includeLoader,
|
|
||||||
includeUnloader,
|
|
||||||
isExplicit = false,
|
|
||||||
src,
|
|
||||||
unloaderIcon,
|
|
||||||
...props
|
|
||||||
}: ImageProps) {
|
|
||||||
const { inViewport, ref } = useInViewport();
|
|
||||||
const { className: containerPropsClassName, ...restContainerProps } = imageContainerProps || {};
|
|
||||||
|
|
||||||
const hasBeenInViewportRef = useRef(false);
|
|
||||||
const prevSrcRef = useRef(src);
|
|
||||||
|
|
||||||
const srcInDisplayedCache = isInDisplayedCache(src);
|
|
||||||
if (srcInDisplayedCache) {
|
|
||||||
hasBeenInViewportRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prevSrcRef.current !== src) {
|
|
||||||
prevSrcRef.current = src;
|
|
||||||
if (!srcInDisplayedCache) hasBeenInViewportRef.current = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inViewport && src) {
|
|
||||||
hasBeenInViewportRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldShowImage = (inViewport || hasBeenInViewportRef.current) && src;
|
|
||||||
|
|
||||||
if (shouldShowImage && src) addToDisplayedCache(src);
|
|
||||||
return (
|
return (
|
||||||
<ImageContainer
|
<ImageContainer
|
||||||
className={clsx(containerClassName, containerPropsClassName)}
|
className={clsx(containerClassName, containerPropsClassName)}
|
||||||
@@ -284,71 +119,31 @@ function ImageWithViewport({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
{...restContainerProps}
|
{...restContainerProps}
|
||||||
>
|
>
|
||||||
{shouldShowImage ? (
|
{nativeImage.displaySrc ? (
|
||||||
<Img
|
<img
|
||||||
className={clsx(styles.image, className, {
|
className={clsx(styles.image, className, {
|
||||||
[styles.animated]: enableAnimation,
|
[styles.animated]: enableAnimation,
|
||||||
})}
|
})}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
fetchPriority={fetchPriority}
|
fetchPriority={fetchPriority}
|
||||||
loader={includeLoader ? <ImageLoader className={className} /> : null}
|
onError={onError}
|
||||||
src={src}
|
onLoad={onLoad}
|
||||||
unloader={
|
src={nativeImage.displaySrc}
|
||||||
includeUnloader ? (
|
|
||||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
) : !src ? (
|
) : !src ? (
|
||||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
<ImageUnloader className={className} icon={unloaderIcon} />
|
||||||
) : (
|
) : nativeImage.isError ? (
|
||||||
|
includeUnloader ? (
|
||||||
|
<ImageUnloader className={className} icon={unloaderIcon} />
|
||||||
|
) : null
|
||||||
|
) : includeLoader ? (
|
||||||
<ImageLoader className={className} />
|
<ImageLoader className={className} />
|
||||||
)}
|
) : null}
|
||||||
</ImageContainer>
|
</ImageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DISPLAYED_SRC_CACHE_KEY = 'feishin-displayed-src-cache';
|
|
||||||
const MAX_DISPLAYED_SRC_CACHE = 500;
|
|
||||||
|
|
||||||
function addToDisplayedCache(src: string | undefined) {
|
|
||||||
if (!src) return;
|
|
||||||
try {
|
|
||||||
const cache = getDisplayedSrcCache();
|
|
||||||
if (cache.includes(src)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (cache.length >= MAX_DISPLAYED_SRC_CACHE) {
|
|
||||||
cache.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.push(src);
|
|
||||||
sessionStorage.setItem(DISPLAYED_SRC_CACHE_KEY, JSON.stringify(cache));
|
|
||||||
} catch {
|
|
||||||
// ignore error if sessionStorage is unavailable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDisplayedSrcCache(): string[] {
|
|
||||||
try {
|
|
||||||
const raw = sessionStorage.getItem(DISPLAYED_SRC_CACHE_KEY);
|
|
||||||
return raw ? (JSON.parse(raw) as string[]) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInDisplayedCache(src: string | undefined): boolean {
|
|
||||||
if (!src) return false;
|
|
||||||
try {
|
|
||||||
return getDisplayedSrcCache().includes(src);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Image = memo(BaseImage);
|
export const Image = memo(BaseImage);
|
||||||
|
|
||||||
const ImageContainer = forwardRef(
|
const ImageContainer = forwardRef(
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { ImageRequest } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
type FetchPriority = 'auto' | 'high' | 'low';
|
||||||
|
|
||||||
|
interface NativeImageState {
|
||||||
|
displaySrc?: string;
|
||||||
|
status: 'error' | 'idle' | 'loaded' | 'loading';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseNativeImageArgs {
|
||||||
|
enabled: boolean;
|
||||||
|
fetchPriority?: FetchPriority;
|
||||||
|
onFetchError?: () => void;
|
||||||
|
request?: ImageRequest | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNativeImage({
|
||||||
|
enabled,
|
||||||
|
fetchPriority,
|
||||||
|
onFetchError,
|
||||||
|
request,
|
||||||
|
}: UseNativeImageArgs) {
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const loadedRequestSignatureRef = useRef<null | string>(null);
|
||||||
|
const objectUrlRef = useRef<null | string>(null);
|
||||||
|
const onFetchErrorRef = useRef(onFetchError);
|
||||||
|
const [state, setState] = useState<NativeImageState>({ status: 'idle' });
|
||||||
|
|
||||||
|
const requestSignature = useMemo(() => {
|
||||||
|
if (!request) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
cacheKey: request.cacheKey,
|
||||||
|
credentials: request.credentials,
|
||||||
|
headers: request.headers,
|
||||||
|
url: request.url,
|
||||||
|
});
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
onFetchErrorRef.current = onFetchError;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const abortCurrentRequest = () => {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const revokeObjectUrl = () => {
|
||||||
|
if (!objectUrlRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
URL.revokeObjectURL(objectUrlRef.current);
|
||||||
|
objectUrlRef.current = null;
|
||||||
|
loadedRequestSignatureRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!request || !requestSignature) {
|
||||||
|
abortCurrentRequest();
|
||||||
|
revokeObjectUrl();
|
||||||
|
setState({ status: 'idle' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
abortCurrentRequest();
|
||||||
|
setState((currentState) =>
|
||||||
|
currentState.displaySrc
|
||||||
|
? { ...currentState, status: 'loaded' }
|
||||||
|
: { status: 'idle' },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadedRequestSignatureRef.current === requestSignature && objectUrlRef.current) {
|
||||||
|
setState({ displaySrc: objectUrlRef.current, status: 'loaded' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
abortCurrentRequest();
|
||||||
|
revokeObjectUrl();
|
||||||
|
setState({ status: 'loading' });
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
abortControllerRef.current = abortController;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const init = {
|
||||||
|
credentials: request.credentials,
|
||||||
|
headers: request.headers,
|
||||||
|
signal: abortController.signal,
|
||||||
|
} as RequestInit & { priority?: FetchPriority };
|
||||||
|
|
||||||
|
if (fetchPriority) {
|
||||||
|
init.priority = fetchPriority;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(request.url, init);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load image: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
objectUrlRef.current = objectUrl;
|
||||||
|
loadedRequestSignatureRef.current = requestSignature;
|
||||||
|
setState({ displaySrc: objectUrl, status: 'loaded' });
|
||||||
|
} catch {
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeObjectUrl();
|
||||||
|
setState({ status: 'error' });
|
||||||
|
onFetchErrorRef.current?.();
|
||||||
|
} finally {
|
||||||
|
if (abortControllerRef.current === abortController) {
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
|
||||||
|
if (abortControllerRef.current === abortController) {
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [enabled, fetchPriority, request, requestSignature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
|
||||||
|
if (objectUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(objectUrlRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
displaySrc: state.displaySrc,
|
||||||
|
isError: state.status === 'error',
|
||||||
|
isLoaded: state.status === 'loaded',
|
||||||
|
isLoading: state.status === 'loading',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ export function BaseSkeleton({
|
|||||||
containerClassName,
|
containerClassName,
|
||||||
count = 1,
|
count = 1,
|
||||||
direction = 'ltr',
|
direction = 'ltr',
|
||||||
enableAnimation = false,
|
enableAnimation = true,
|
||||||
height,
|
height,
|
||||||
inline,
|
inline,
|
||||||
isCentered,
|
isCentered,
|
||||||
|
|||||||
@@ -1397,6 +1397,7 @@ export type ControllerEndpoint = {
|
|||||||
getDownloadUrl: (args: DownloadArgs) => string;
|
getDownloadUrl: (args: DownloadArgs) => string;
|
||||||
getFolder: (args: FolderArgs) => Promise<FolderResponse>;
|
getFolder: (args: FolderArgs) => Promise<FolderResponse>;
|
||||||
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
||||||
|
getImageRequest: (args: ImageArgs) => ImageRequest | null;
|
||||||
getImageUrl: (args: ImageArgs) => null | string;
|
getImageUrl: (args: ImageArgs) => null | string;
|
||||||
getInternetRadioStations: (
|
getInternetRadioStations: (
|
||||||
args: GetInternetRadioStationsArgs,
|
args: GetInternetRadioStationsArgs,
|
||||||
@@ -1477,6 +1478,13 @@ export type ImageQuery = {
|
|||||||
size?: number;
|
size?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ImageRequest = {
|
||||||
|
cacheKey: string;
|
||||||
|
credentials?: RequestCredentials;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type InternalControllerEndpoint = {
|
export type InternalControllerEndpoint = {
|
||||||
addToPlaylist: (
|
addToPlaylist: (
|
||||||
args: ReplaceApiClientProps<AddToPlaylistArgs>,
|
args: ReplaceApiClientProps<AddToPlaylistArgs>,
|
||||||
@@ -1523,6 +1531,7 @@ export type InternalControllerEndpoint = {
|
|||||||
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
|
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
|
||||||
getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>;
|
getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>;
|
||||||
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
|
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
|
||||||
|
getImageRequest: (args: ReplaceApiClientProps<ImageArgs>) => ImageRequest | null;
|
||||||
getImageUrl: (args: ReplaceApiClientProps<ImageArgs>) => null | string;
|
getImageUrl: (args: ReplaceApiClientProps<ImageArgs>) => null | string;
|
||||||
getInternetRadioStations: (
|
getInternetRadioStations: (
|
||||||
args: ReplaceApiClientProps<GetInternetRadioStationsArgs>,
|
args: ReplaceApiClientProps<GetInternetRadioStationsArgs>,
|
||||||
|
|||||||
Reference in New Issue
Block a user