Compare commits

...

34 Commits

Author SHA1 Message Date
jeffvli 3f99acf473 update to v1.9.0 2026-03-16 00:19:48 -07:00
Hosted Weblate 0cd37ce8ec Translated using Weblate
Currently translated at 100.0% (1180 of 1180 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Co-authored-by: linger <linger0517@gmail.com>
2026-03-16 08:19:17 +01:00
jeffvli ee04878580 set mpv audio device to auto if undefined 2026-03-15 20:17:59 -07:00
jeffvli e987049f20 improve sleep timer ui 2026-03-15 18:20:12 -07:00
Hosted Weblate 122552287a Translated using Weblate
Currently translated at 100.0% (1175 of 1175 strings) (Chinese (Simplified Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/

Translated using Weblate

Currently translated at 100.0% (1175 of 1175 strings) (Japanese)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ja/

Translated using Weblate

Currently translated at 100.0% (1175 of 1175 strings) (French)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/

Translated using Weblate

Currently translated at 10.1% (119 of 1175 strings) (Arabic)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ar/

Translated using Weblate

Currently translated at 89.0% (1046 of 1175 strings) (German)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/

Translated using Weblate

Currently translated at 100.0% (1175 of 1175 strings) (Catalan)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/

Co-authored-by: Benjamin <ben@iipython.dev>
Co-authored-by: KosmoMoustache <kosmomoustache@users.noreply.hosted.weblate.org>
Co-authored-by: PhillyMay <mein.alias@outlook.com>
2026-03-16 01:01:08 +00:00
jeffvli d318e6d341 ensure no concurrent playback on non-transition state on web player (#1829) 2026-03-15 18:00:51 -07:00
riccardo d96b282cae feat: "open in spotify" button (#1839)
* feat: open in spotify

* fix: disable native spotify URI by default
2026-03-15 11:49:33 -07:00
jeffvli f2ab01199f disable WaylandFractionScaleV1 (#1271) 2026-03-15 11:42:31 -07:00
Kendall Garner 04b22431f4 fix(settings): proper description for sidebar configuration 2026-03-15 09:16:12 -07:00
Kendall Garner 31fce705ab feat(docker): rootless container) 2026-03-14 18:39:38 -07:00
jeffvli a28fab0ff3 optimize skeleton animation (#1832) 2026-03-14 15:31:13 -07:00
Hosted Weblate 0a1d4788ee Translated using Weblate
Currently translated at 81.1% (954 of 1175 strings) (Russian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ru/

Translated using Weblate

Currently translated at 100.0% (1175 of 1175 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Translated using Weblate

Currently translated at 44.1% (519 of 1175 strings) (Norwegian Bokmål)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/nb_NO/

Co-authored-by: klodrik <klodrik@zoominn.no>
Co-authored-by: qvap <qvapelsin@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-03-14 14:09:54 +01:00
jeffvli fafb9d4f56 remove vercel.json due to incorrect edge request configuration 2026-03-13 17:23:03 -07:00
jeffvli 4fdc38caee remove duplicate only-built-dependencies 2026-03-13 17:15:12 -07:00
jeffvli 799cdb44d3 use pnpm v10 on runners 2026-03-13 17:11:24 -07:00
jeffvli 372892199f pin pnpm/action-setup to v4 2026-03-13 17:02:34 -07:00
jeffvli d16184fb25 bump actions dependencies to latest due to deprecated Node v20 2026-03-13 16:59:55 -07:00
jeffvli b8564f6d41 skip wait-for-lint on workflow dispatch 2026-03-13 16:38:32 -07:00
Hosted Weblate d474e60c51 Translated using Weblate
Currently translated at 100.0% (1175 of 1175 strings) (French)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/

Translated using Weblate

Currently translated at 87.5% (1029 of 1175 strings) (Japanese)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ja/

Co-authored-by: Mario Gervais <social.m@riogervais.ca>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
2026-03-13 09:09:54 +00:00
Lyall dfdac28f53 Fix server queue saving/restoring on Navidrome and OpenSubsonic (#1828)
* fix server queue saving

* fix error when attempting to restore empty queue

* queue items optional

* make playQueueByIndex optional

* fix incorrect error message
2026-03-12 13:41:50 +00:00
jeffvli 16b713bc85 add missing suspense boundary around playlist filter sidebar 2026-03-11 22:00:39 -07:00
Kendall Garner 81cd0722b1 fix(mpv): replace mpv queue when restoring queue 2026-03-11 21:42:16 -07:00
jeffvli 1526f9b8d6 re-add session cache for loaded images 2026-03-11 21:20:31 -07:00
jeffvli 5b4da3bc29 use batched fetching for nd song list
- albumIds get culled from query parameters after a certain number, resulting in not all items fetched
2026-03-11 20:46:49 -07:00
jeffvli d78ea440cc set low fetchPriority for carousel images 2026-03-11 19:41:04 -07:00
jeffvli 1595805b83 add additional render optimizations to ArtistAlbums 2026-03-11 19:12:11 -07:00
jeffvli 00fa45f15d isolate render of sticky elements on item table 2026-03-11 19:07:18 -07:00
Luna M ab05be30c0 Fix typo in docker-compose config (#1827) 2026-03-12 01:08:13 +00:00
Hosted Weblate 3d407e5f24 Update translation files
Updated by "Remove blank strings" hook in Weblate.

Translated using Weblate

Currently translated at 99.8% (1173 of 1175 strings) (Polish)
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Translated using Weblate

Currently translated at 74.1% (871 of 1175 strings) (Russian)
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ru/

Translated using Weblate

Currently translated at 74.1% (871 of 1175 strings) (Russian)
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ru/

Translated using Weblate

Currently translated at 85.7% (1007 of 1175 strings) (Japanese)
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ja/

Co-authored-by: DanisimoR <danisimogg22@gmail.com>
Co-authored-by: Gentor <gentor92@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translation: feishin/Translation
2026-03-11 21:10:02 +01:00
jeffvli 60776b5f02 fix missing list query invalidation on playlist create/delete 2026-03-11 02:04:51 -07:00
jeffvli 8699b1ffea add sort to artist favorite songs page (#1691) 2026-03-11 01:48:39 -07:00
riccardo 16ac536f93 feat(lyrics): simpmusic lyrics provider (#1820)
* feat(lyrics): simpmusic lyrics provider
2026-03-11 01:04:55 -07:00
jeffvli f51d3d5711 prevent no lyrics message fade out on fullscreen player (#1821) 2026-03-11 01:00:32 -07:00
jeffvli 17a4a14a4e fix isValid condition on REMOTE_URL for server lock (#1822) 2026-03-10 20:02:37 -07:00
63 changed files with 1854 additions and 887 deletions
+10 -10
View File
@@ -40,12 +40,12 @@ jobs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout git repo
uses: actions/checkout@v1
uses: actions/checkout@v6
- name: Install Node and PNPM
uses: pnpm/action-setup@v4.1.0
uses: pnpm/action-setup@v4
with:
version: 9
version: 10
- name: Install dependencies
run: pnpm install
@@ -125,12 +125,12 @@ jobs:
steps:
- name: Checkout git repo
uses: actions/checkout@v1
uses: actions/checkout@v6
- name: Install Node and PNPM
uses: pnpm/action-setup@v4.1.0
uses: pnpm/action-setup@v4
with:
version: 9
version: 10
- name: Install dependencies
run: pnpm install
@@ -146,7 +146,7 @@ jobs:
- name: Build and Publish to R2 (Windows)
if: matrix.os == 'windows-latest'
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
@@ -157,7 +157,7 @@ jobs:
- name: Build and Publish to R2 (macOS)
if: matrix.os == 'macos-latest'
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
@@ -168,7 +168,7 @@ jobs:
- name: Build and Publish to R2 (Linux)
if: matrix.os == 'ubuntu-latest'
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
@@ -179,7 +179,7 @@ jobs:
- name: Build and Publish to R2 (Linux ARM64)
if: matrix.os == 'ubuntu-latest'
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
+12 -12
View File
@@ -15,12 +15,12 @@ jobs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout git repo
uses: actions/checkout@v1
uses: actions/checkout@v6
- name: Install Node and PNPM
uses: pnpm/action-setup@v4.1.0
uses: pnpm/action-setup@v4
with:
version: 9
version: 10
- name: Install dependencies
run: pnpm install
@@ -119,12 +119,12 @@ jobs:
steps:
- name: Checkout git repo
uses: actions/checkout@v1
uses: actions/checkout@v6
- name: Install Node and PNPM
uses: pnpm/action-setup@v4.1.0
uses: pnpm/action-setup@v4
with:
version: 9
version: 10
- name: Install dependencies
run: pnpm install
@@ -146,7 +146,7 @@ jobs:
if: matrix.os == 'windows-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
@@ -159,7 +159,7 @@ jobs:
if: matrix.os == 'macos-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
@@ -172,7 +172,7 @@ jobs:
if: matrix.os == 'ubuntu-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
@@ -185,7 +185,7 @@ jobs:
if: matrix.os == 'ubuntu-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
@@ -199,7 +199,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout git repo
uses: actions/checkout@v1
uses: actions/checkout@v6
- name: Edit release with commits and title
shell: pwsh
@@ -346,7 +346,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout git repo
uses: actions/checkout@v1
uses: actions/checkout@v6
- name: Delete existing prereleases
shell: pwsh
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
+5 -5
View File
@@ -12,12 +12,12 @@ jobs:
steps:
- name: Checkout git repo
uses: actions/checkout@v1
uses: actions/checkout@v6
- name: Install Node and PNPM
uses: pnpm/action-setup@v4.1.0
uses: pnpm/action-setup@v4
with:
version: 9
version: 10
- name: Install dependencies
run: pnpm install
@@ -25,7 +25,7 @@ jobs:
- name: Build and Publish releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
@@ -37,7 +37,7 @@ jobs:
- name: Build and Publish releases (arm64)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
+4 -4
View File
@@ -12,12 +12,12 @@ jobs:
steps:
- name: Checkout git repo
uses: actions/checkout@v1
uses: actions/checkout@v6
- name: Install Node and PNPM
uses: pnpm/action-setup@v4.1.0
uses: pnpm/action-setup@v4
with:
version: 9
version: 10
- name: Install dependencies
run: pnpm install
@@ -25,7 +25,7 @@ jobs:
- name: Build and Publish releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
+11 -9
View File
@@ -11,6 +11,7 @@ on:
jobs:
wait-for-lint:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Wait for Test workflow to complete
@@ -24,6 +25,7 @@ jobs:
publish:
needs: wait-for-lint
if: always() && (needs.wait-for-lint.result == 'success' || needs.wait-for-lint.result == 'skipped')
runs-on: ${{ matrix.os }}
strategy:
@@ -32,19 +34,19 @@ jobs:
steps:
- name: Checkout git repo
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Install Node and PNPM
uses: pnpm/action-setup@v4.1.0
uses: pnpm/action-setup@v4
with:
version: 9
version: 10
- name: Install dependencies
run: pnpm install
- name: Build for Windows
if: ${{ matrix.os == 'windows-latest' }}
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
@@ -54,7 +56,7 @@ jobs:
- name: Build for Linux
if: ${{ matrix.os == 'ubuntu-latest' }}
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
@@ -64,7 +66,7 @@ jobs:
- name: Build for MacOS
if: ${{ matrix.os == 'macos-latest' }}
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
@@ -90,21 +92,21 @@ jobs:
- name: Upload Windows Binaries
if: ${{ matrix.os == 'windows-latest' }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: windows-binaries
path: dist/windows-binaries.zip
- name: Upload Linux Binaries
if: ${{ matrix.os == 'ubuntu-latest' }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: linux-binaries
path: dist/linux-binaries.zip
- name: Upload MacOS Binaries
if: ${{ matrix.os == 'macos-latest' }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: macos-binaries
path: dist/macos-binaries.zip
+4 -4
View File
@@ -12,12 +12,12 @@ jobs:
steps:
- name: Checkout git repo
uses: actions/checkout@v1
uses: actions/checkout@v6
- name: Install Node and PNPM
uses: pnpm/action-setup@v4.1.0
uses: pnpm/action-setup@v4
with:
version: 9
version: 10
- name: Install dependencies
run: pnpm install
@@ -25,7 +25,7 @@ jobs:
- name: Build and Publish releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
+7 -7
View File
@@ -12,12 +12,12 @@ jobs:
steps:
- name: Checkout git repo
uses: actions/checkout@v1
uses: actions/checkout@v6
- name: Install Node and PNPM
uses: pnpm/action-setup@v4.1.0
uses: pnpm/action-setup@v4
with:
version: 9
version: 10
- name: Install dependencies
run: pnpm install
@@ -26,7 +26,7 @@ jobs:
if: matrix.os == 'windows-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
@@ -39,7 +39,7 @@ jobs:
if: matrix.os == 'macos-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
@@ -52,7 +52,7 @@ jobs:
if: matrix.os == 'ubuntu-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
@@ -65,7 +65,7 @@ jobs:
if: matrix.os == 'ubuntu-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
max_attempts: 3
+3 -3
View File
@@ -8,12 +8,12 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v1
uses: actions/checkout@v6
- name: Install Node.js and PNPM
uses: pnpm/action-setup@v4.1.0
uses: pnpm/action-setup@v4
with:
version: 9
version: 10
- name: Install dependencies
run: pnpm install
-1
View File
@@ -1,2 +1 @@
legacy-peer-deps=true
only-built-dependencies=electron,esbuild
+4 -4
View File
@@ -1,5 +1,5 @@
# --- Builder stage
FROM node:23-alpine as builder
FROM node:23-alpine AS builder
WORKDIR /app
# Copy package.json first to cache node_modules
@@ -14,11 +14,11 @@ COPY . .
RUN pnpm run build:web
# --- Production stage
FROM nginx:alpine-slim
FROM nginxinc/nginx-unprivileged:alpine-slim
COPY --chown=nginx:nginx --from=builder /app/out/web /usr/share/nginx/html
COPY ./settings.js.template /etc/nginx/templates/settings.js.template
COPY ng.conf.template /etc/nginx/templates/default.conf.template
COPY --chown=nginx:nginx ./settings.js.template /etc/nginx/templates/settings.js.template
COPY --chown=nginx:nginx ng.conf.template /etc/nginx/templates/default.conf.template
ENV SERVER_LOCK=false SERVER_NAME="" SERVER_TYPE="" SERVER_URL="" REMOTE_URL=""
ENV LEGACY_AUTHENTICATION="" ANALYTICS_DISABLED="" PUBLIC_PATH="/"
+1 -1
View File
@@ -114,7 +114,7 @@ services:
- 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_URL= # http://address:port or https://address:port
= REMOTE_URL= # http://address or https://address
- 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
- ANALYTICS_DISABLED=true # Set to true to disable Umami analytics tracking
ports:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.8.0",
"version": "1.9.0",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
+7 -1
View File
@@ -21,7 +21,13 @@
"openIn": {
"lastfm": "فتح في Last.fm",
"musicbrainz": "فتح في MusicBrainz"
}
},
"addOrRemoveFromSelection": "إضافة أو إزالة من الإختيارات",
"selectRangeOfItems": "اختر مجموعة من العناصر",
"goToCurrent": "الانتقال إلى العنصر الحالي",
"createRadioStation": "يخلق $t(entity.radioStation, {\"count\": 1})",
"deleteRadioStation": "يمسح $t(entity.radioStation, {\"count\": 1})",
"selectAll": "تحديد الكل"
},
"common": {
"action_zero": "عملية",
+21 -6
View File
@@ -335,7 +335,8 @@
"mood": "estat d'ànim",
"filter_single": "senzill",
"filter_multiple": "multi",
"rename": "reanomena"
"rename": "reanomena",
"newVersionAvailable": "hi ha una nova versió disponible"
},
"entity": {
"album_one": "àlbum",
@@ -545,7 +546,8 @@
"addOrRemoveFromSelection": "afegeix o elimina de la selecció",
"selectRangeOfItems": "selecciona un interval d'elements",
"selectAll": "selecciona-ho tot",
"openApplicationDirectory": "obre el directori de l'aplicació"
"openApplicationDirectory": "obre el directori de l'aplicació",
"goToCurrent": "anar a l'element actual"
},
"setting": {
"language_description": "estableix l'idioma de l'aplicació ($t(common.restartRequired))",
@@ -898,7 +900,17 @@
"blurExplicitImages": "desenfoca imatges explícites",
"blurExplicitImages_description": "les caràtules d'àlbums i cançons marcades com a explícites quedaran desenfocades",
"discordStateIcon": "mostra la icona de reproducció",
"discordStateIcon_description": "mostra una petita icona de reproducció a l'estat d'activitat. l'icona de pausa es mostra quan \"mostra l'estat d'activitat quan està en pausa\" està activat"
"discordStateIcon_description": "mostra una petita icona de reproducció a l'estat d'activitat. l'icona de pausa es mostra quan \"mostra l'estat d'activitat quan està en pausa\" està activat",
"autosave": "desa automàticament la cua de reproducció",
"autosave_description": "activa el desament automàtic de la cua de reproducció al teu servidor. això només és possible quan s'utilitza Navidrome/Subsonic i no es pot tenir una cua de reproducció mixta.",
"autosaveCount": "freqüència de desament de cua de reproducció automàtica",
"autosaveCount_description": "quants canvis de pista abans que es desi la cua. 1 (mínim) significa cada canvi de cançó",
"useThemePrimaryShade": "utilitza l'ombra primària del tema",
"useThemePrimaryShade_description": "utilitza el to primari definit al tema seleccionat per a les variants de color primari",
"primaryShade": "ombra primària",
"primaryShade_description": "substitueix el to primari (09) utilitzat per a botons, enllaços i altres elements de color primari",
"playerItemConfiguration_description": "configurar quins elements es mostren i en quin ordre al reproductor de pantalla completa",
"playerItemConfiguration": "configuració d'elements del jugador"
},
"table": {
"column": {
@@ -998,7 +1010,8 @@
"image": "imatge",
"sampleRate": "$t(common.sampleRate)",
"composer": "compositor",
"titleArtist": "$t(common.title) (artista)"
"titleArtist": "$t(common.title) (artista)",
"albumGroup": "grup d'àlbums"
},
"view": {
"table": "taula",
@@ -1104,7 +1117,8 @@
"sleepTimer_off": "apagat",
"sleepTimer_timeRemaining": "queden {{time}}",
"sleepTimer_setCustom": "configura el temporitzador",
"sleepTimer_cancel": "cancel·la el temporitzador"
"sleepTimer_cancel": "cancel·la el temporitzador",
"albumRadio": "ràdio d'àlbums"
},
"error": {
"credentialsRequired": "credencials requerides",
@@ -1137,7 +1151,8 @@
"noNetwork": "servidor no disponible",
"noNetworkDescription": "no s'ha pogut connectar amb el servidor",
"invalidJson": "JSON invàlid",
"serverLockSingleServer": "només es permet un servidor quan el servidor està bloquejat"
"serverLockSingleServer": "només es permet un servidor quan el servidor està bloquejat",
"playbackPausedDueToError": "la reproducció s'ha pausat a causa d'un error"
},
"releaseType": {
"primary": {
+20 -6
View File
@@ -37,7 +37,8 @@
"addOrRemoveFromSelection": "Zur Auswahl hinzufügen oder entfernen",
"selectRangeOfItems": "Wählen sie eine Reihe von Elementen",
"holdToMoveToTop": "Halten um nach oben zu bewegen",
"holdToMoveToBottom": "Halten um nach unten zu bewegen"
"holdToMoveToBottom": "Halten um nach unten zu bewegen",
"goToCurrent": "Zu aktuellem Eintrag wechseln"
},
"common": {
"backward": "zurück",
@@ -160,7 +161,8 @@
"rename": "Umbenennen",
"filter_single": "einzeln",
"filter_multiple": "mehrfach",
"retry": "Wiederholen"
"retry": "Wiederholen",
"newVersionAvailable": "Eine neue Version ist verfügbar"
},
"error": {
"remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden",
@@ -193,7 +195,8 @@
"noNetworkDescription": "Verbindung zum Server konnte nicht hergestellt werden",
"invalidJson": "JSON ungültig",
"serverLockSingleServer": "Nur ein Server ist erlaubt, wenn der Server gesperrt ist",
"settingsSyncError": "Es wurden Unstimmigkeiten zwischen den Einstellungen im Renderer und dem Hauptprozess gefunden. Starte die Anwendung neu, um die Änderungen zu übernehmen"
"settingsSyncError": "Es wurden Unstimmigkeiten zwischen den Einstellungen im Renderer und dem Hauptprozess gefunden. Starte die Anwendung neu, um die Änderungen zu übernehmen",
"playbackPausedDueToError": "Die Wiedergabe wurde aufgrund eines Fehlers pausiert"
},
"filter": {
"mostPlayed": "Meistgespielt",
@@ -312,7 +315,9 @@
"expireInvalid": "Ablaufdatum muss in der Zukunft liegen",
"allowDownloading": "Herunterladen zulassen",
"success": "Link in die Zwischenablage kopiert (oder hier klicken, um zu öffnen)",
"createFailed": "fehler beim Teilen (Ist Teilen aktiviert?)"
"createFailed": "fehler beim Teilen (Ist Teilen aktiviert?)",
"copyToClipboard": "In Zwischenablage kopieren: Strg+C, Enter",
"successMustClick": "Freigabe erfolgreich erstellt. Hier klicken um diese zu öffnen"
},
"privateMode": {
"enabled": "Privatmodus aktiviert, Wiedergabe-Status wird externen Quellen nicht preisgegeben",
@@ -758,7 +763,8 @@
"sleepTimer_custom": "Benutzerdefiniert",
"sleepTimer_hours": "{{count}} std",
"sleepTimer_minutes": "{{count}} min",
"trackRadio": "Song Radio"
"trackRadio": "Song Radio",
"albumRadio": "Album Radio"
},
"setting": {
"audioDevice_description": "wählen Sie das Audiogerät aus, das für die Wiedergabe verwendet werden soll (nur Webplayer)",
@@ -1094,7 +1100,15 @@
"trayEnabled_description": "Tray-Symbol anzeigen/verbergen. Bei Deaktivierung werden auch Minimieren/Beenden zum Tray deaktiviert",
"queryBuilder": "Abfrage-Editor",
"queryBuilderCustomFields_inputLabel": "Label",
"queryBuilderCustomFields_description": "Füge benutzerdefinierte Felder für den Abfrage-Editor hinzu"
"queryBuilderCustomFields_description": "Füge benutzerdefinierte Felder für den Abfrage-Editor hinzu",
"autosave": "Automatisch aktuelle Wiedergabeliste speichern",
"autosave_description": "Aktiviere die automatische Speicherung der aktuellen Wiedergabe auf dem Server. Diese Funktion ist nur bei Navidrome/Subsonic Servern verfügbar und es darf sich nicht um eine gemischte Wiedergabeliste handeln.",
"autosaveCount": "Häufigkeit der automatischen Speicherung bei Wiedergabelisten",
"autosaveCount_description": "Wieviele Lieder gespielt werden, bevor die Wiedergabeliste gespeichert wird. 1 (Minimum) bedeutet die Speicherung nach jedem gespielten Lied",
"useThemeAccentColor_description": "Verwendet die Primärfarbe des gewählten Themas anstatt einer ausgewählten Akzentfarbe",
"useThemePrimaryShade": "Primärschatten des Themas nutzen",
"useThemePrimaryShade_description": "Verwendet den Primärschatten des ausgewählten Themas als primäre Farbvarianten",
"primaryShade": "Primärschatten"
},
"dragDropZone": {
"error_oneFileOnly": "Bitte wähle nur 1 Datei",
+6 -1
View File
@@ -37,7 +37,8 @@
"openApplicationDirectory": "open application directory",
"openIn": {
"lastfm": "Open in Last.fm",
"musicbrainz": "Open in MusicBrainz"
"musicbrainz": "Open in MusicBrainz",
"spotify": "Open in Spotify"
}
},
"common": {
@@ -924,6 +925,10 @@
"mpvExtraParameters_help": "one per line",
"musicbrainz_description": "show links to MusicBrainz on artist/album pages, where MusicBrainz ID exists",
"musicbrainz": "show MusicBrainz links",
"spotify_description": "show links to Spotify on artist/album pages",
"spotify": "show Spotify links",
"nativeSpotify_description": "open in the Spotify app instead of your browser",
"nativeSpotify": "use Spotify app",
"neteaseTranslation_description": "When enabled, fetches and displays translated lyrics from NetEase if available",
"neteaseTranslation": "Enable NetEase translations",
"notify": "enable song notifications",
+172 -153
View File
@@ -47,7 +47,8 @@
"sleepTimer_off": "éteint",
"sleepTimer_timeRemaining": "{{time}} restante(s)",
"sleepTimer_setCustom": "définir le minuteur",
"sleepTimer_cancel": "annuler le minuteur"
"sleepTimer_cancel": "annuler le minuteur",
"albumRadio": "radio d'album"
},
"action": {
"editPlaylist": "éditer $t(entity.playlist, {\"count\": 1})",
@@ -87,7 +88,8 @@
"addOrRemoveFromSelection": "ajouter ou supprimer de la sélection",
"selectRangeOfItems": "sélectionner une plage d'entrées",
"selectAll": "tout sélectionner",
"openApplicationDirectory": "ouvrir le répertoire de l'application"
"openApplicationDirectory": "ouvrir le répertoire de l'application",
"goToCurrent": "aller à la piste en cours"
},
"common": {
"backward": "en arrière",
@@ -214,7 +216,8 @@
"retry": "réessayer",
"filter_single": "unique",
"filter_multiple": "multiple",
"rename": "renommer"
"rename": "renommer",
"newVersionAvailable": "une nouvelle version est disponible"
},
"error": {
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
@@ -241,13 +244,14 @@
"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)\"",
"badValue": "option {{value}} invalide. cette valeur n'existe plus",
"notificationDenied": "les autorisations pour les notifications ont été refusées. ce paramètre n'a aucun effet",
"multipleServerSaveQueueError": "la file d'attente de lecture contient un ou plusieurs morceaux qui ne proviennent pas du serveur actuel. Ceci n'est pas prise en charge",
"multipleServerSaveQueueError": "la file d'attente contient un ou plusieurs morceaux qui ne proviennent pas du serveur actuel. Ceci n'est pas prise en charge",
"saveQueueFailed": "échec de l'enregistrement de la file d'attente",
"settingsSyncError": "des incohérences ont été détectées entre les paramètres du moteur de rendu et ceux du processus principal. redémarrez l'application pour appliquer les modifications",
"noNetwork": "serveur indisponible",
"noNetworkDescription": "impossible de se connecter à ce serveur",
"invalidJson": "JSON invalide",
"serverLockSingleServer": "un seul serveur est autorisé quand le serveur est verrouillé"
"serverLockSingleServer": "un seul serveur est autorisé quand le serveur est verrouillé",
"playbackPausedDueToError": "la lecture a été suspendue en raison d'une erreur"
},
"filter": {
"mostPlayed": "les plus joués",
@@ -258,7 +262,7 @@
"title": "titre",
"rating": "note",
"search": "recherche",
"bitrate": "bitrate",
"bitrate": "bitrate binaire",
"recentlyAdded": "ajout récent",
"note": "note",
"name": "nom",
@@ -392,7 +396,7 @@
"lyrics": "paroles",
"transcoding": "transcodage",
"discord": "discord",
"logger": "logger",
"logger": "journaliseur",
"playerFilters": "filtres du lecteur",
"lyricsDisplay": "affichage des paroles"
},
@@ -517,10 +521,10 @@
"audioDevice": "périphérique audio",
"accentColor": "couleur d'accentuation",
"accentColor_description": "définit la couleur d'accentuation de l'application",
"applicationHotkeys": "raccourcis clavier d'application",
"crossfadeDuration": "durée de fondu enchaîné",
"applicationHotkeys": "raccourcis clavier de l'application",
"crossfadeDuration": "durée du fondu enchaîné",
"audioPlayer": "lecteur audio",
"applicationHotkeys_description": "configurer les raccourcis clavier dapplication. activer la case à cocher pour définir comme raccourci clavier global (bureau uniquement)",
"applicationHotkeys_description": "configurer les raccourcis clavier de l'application. cocher la case pour définir comme raccourci clavier global (bureau uniquement)",
"crossfadeStyle_description": "sélectionnez le style du fondu enchaîné à utiliser pour le lecteur audio",
"customFontPath": "chemin de police personnalisé",
"customFontPath_description": "définit le chemin de police personnalisé pour l'application",
@@ -528,39 +532,39 @@
"hotkey_skipBackward": "reculer",
"hotkey_playbackPause": "pause",
"hotkey_volumeUp": "monter le volume",
"discordIdleStatus_description": "quand activé, mettre à jour le statut pendant que le lecteur est inactif",
"showSkipButtons": "affiche les boutons suivants et précédents",
"discordIdleStatus_description": "si activé, met à jour le statut pendant que le lecteur est inactif",
"showSkipButtons": "afficher les boutons suivants et précédents",
"minimumScrobblePercentage": "durée minimal du scobble (pourcentage)",
"lyricFetch": "récupérer les paroles depuis internet",
"scrobble": "scrobble",
"enableRemote_description": "activer le serveur de contrôle à distance, qui permet à d'autres appareils de contrôler l'application",
"enableRemote_description": "activer le serveur de contrôle à distance qui permet à d'autres appareils de contrôler l'application",
"fontType_optionSystem": "police système",
"mpvExecutablePath_description": "définit le chemin vers l'exécutable mpv, si vide, le chemin par défaut sera utilisé",
"hotkey_favoriteCurrentSong": "favori $t(common.currentSong)",
"hotkey_favoriteCurrentSong": "ajouter la $t(common.currentSong) aux favoris",
"sampleRate": "taux d'échantillonnage",
"sampleRate_description": "sélectionne le taux d'échantillonnage de sortie utilisé si la fréquence d'échantillonnage sélectionnée est différente de celle du média actuel. une valeur inférieure à 8000 utilisera la fréquence par défaut",
"sampleRate_description": "sélectionne le taux d'échantillonnage de sortie à utilisé si la fréquence d'échantillonnage choisie est différente de celle du média en cours. une valeur inférieure à 8000 utilisera la fréquence par défaut",
"hotkey_zoomIn": "zoom avant",
"scrobble_description": "scrobbler les lectures à votre serveur multimédia",
"hotkey_browserForward": "avancer",
"discordUpdateInterval": "intervalle de mise à jour de {{discord}} Rich Presence",
"hotkey_browserForward": "avancer (navigateur)",
"discordUpdateInterval": "intervalle de mise à jour du statut d'activité {{discord}}",
"fontType_optionBuiltIn": "police intégrée",
"hotkey_playbackPlayPause": "lecture / pause",
"hotkey_rate1": "noter 1 étoile",
"hotkey_skipForward": "avancer",
"disableLibraryUpdateOnStartup": "désactive la recherche de mise à jour au démarrage",
"gaplessAudio": "audio sans interruption",
"minimizeToTray_description": "réduit l'application vers la barre des tâches",
"minimizeToTray_description": "réduit l'application vers la barre d'état système",
"hotkey_playbackPlay": "lecture",
"hotkey_togglePreviousSongFavorite": "basculer $t(common.previousSong) favoris",
"hotkey_togglePreviousSongFavorite": "basculer $t(common.previousSong) dans les favoris",
"hotkey_volumeDown": "baisser le volume",
"hotkey_unfavoritePreviousSong": "défavorisé $t(common.previousSong)",
"globalMediaHotkeys": "raccourci clavier multimédia global",
"hotkey_unfavoritePreviousSong": "retirer $t(common.previousSong) des favoris",
"globalMediaHotkeys": "touches multimédias globales",
"hotkey_globalSearch": "recherche globale",
"gaplessAudio_description": "définit les paramètres d'audio sans interruption pour mpv",
"remoteUsername_description": "définit le nom d'utilisateur du serveur de contrôle à distance. si le nom d'utilisateur et le mot de passe sont vides, l'authentification sera désactivée",
"exitToTray_description": "quitte l'application vers la barre des tâches",
"followLyric_description": "faire défiler les paroles jusqu'à la position de lecture actuelle",
"hotkey_favoritePreviousSong": "favori $t(common.previousSong)",
"exitToTray_description": "quitte l'application vers la barre d'état système",
"followLyric_description": "faire défiler les paroles jusqu'à la position actuelle de lecture",
"hotkey_favoritePreviousSong": "ajouter la $t(common.previousSong) aux favoris",
"lyricOffset": "décalage des paroles (ms)",
"discordUpdateInterval_description": "temps en seconde entre chaque mise à jour (minimum de 15 secondes)",
"fontType_optionCustom": "police personnalisée",
@@ -570,64 +574,64 @@
"playbackStyle_optionCrossFade": "fondu enchaîné",
"hotkey_rate3": "noter 3 étoiles",
"font": "police",
"hotkey_toggleFullScreenPlayer": "basculer en plein écran",
"hotkey_toggleFullScreenPlayer": "basculer en lecture plein écran",
"hotkey_localSearch": "recherche dans la page",
"hotkey_toggleQueue": "basculer la liste de lecteur",
"hotkey_toggleQueue": "basculer la file d'attente",
"remotePassword_description": "définit le mot de passe du serveur de contrôle à distance. Ces identifiants sont par défaut transmises de façon non sécurisées, donc vous devriez utiliser un mot de passe unique dont vous n'avez pas grand-chose à faire",
"hotkey_rate5": "noter 5 étoiles",
"hotkey_playbackPrevious": "piste précédente",
"showSkipButtons_description": "affiche ou cache les boutons suivants et précédents de la barre de lecture",
"showSkipButtons_description": "affiche ou masque les boutons suivants et précédents de la barre de lecture",
"playbackStyle": "style de lecture",
"hotkey_toggleShuffle": "basculer la lecture aléatoire",
"hotkey_toggleShuffle": "activer/désactiver la lecture aléatoire",
"playbackStyle_description": "sélectionnez le style de lecture à utiliser pour le lecteur audio",
"discordRichPresence_description": "active l'état de lecteur dans le statut d'activité {{discord}}. Les images clés sont : {{icon}}, {{playing}}, et {{paused}}",
"discordRichPresence_description": "active l'état de lecture dans le statut d'activité {{discord}}. Les images clés sont : {{icon}}, {{playing}}, et {{paused}}",
"mpvExecutablePath": "chemin de l'exécutable mpv",
"hotkey_rate2": "noter 2 étoiles",
"playButtonBehavior_description": "définit le comportement par défaut du bouton Jouer/Pause, lors de l'ajout de titres à la file d'attente",
"minimumScrobblePercentage_description": "le pourcentage minimum de la chanson qui doit être joué avant qu'elle ne soit scrobblée",
"exitToTray": "quitter vers la barre des tâches",
"playButtonBehavior_description": "définit le comportement par défaut du bouton lecture, lors de l'ajout de titres à la file d'attente",
"minimumScrobblePercentage_description": "le pourcentage minimum du titre qui doit être écouté avant quelle ne soit scrobblée",
"exitToTray": "quitter vers la barre d'état système",
"hotkey_rate4": "noter 4 étoiles",
"enableRemote": "activer le serveur de contrôle à distance",
"showSkipButton_description": "affiche ou cache les boutons suivants et précédents de la barre de lecture",
"savePlayQueue": "sauvegarder la liste de lecture",
"minimumScrobbleSeconds_description": "la durée minimale en secondes de la chanson qui doit être jouée avant qu'elle ne soit scrobblée",
"fontType_description": "La police intégrée vous permet de sélectionner une des polices fourni par feishin. La police système vous permet de sélectionner une des polices fourni par votre système d'exploitation. L'option personnalisée vous permet d'importer votre propre police",
"playButtonBehavior": "comportement du bouton play",
"showSkipButton_description": "affiche ou masque les boutons suivants et précédents de la barre de lecture",
"savePlayQueue": "sauvegarder la file d'attente",
"minimumScrobbleSeconds_description": "la durée minimale en secondes du titre qui doit être écouté avant quelle ne soit scrobblée",
"fontType_description": "Police intégrée vous permet de sélectionner l'une des polices fournies par feishin. Police système vous permet de sélectionner une des polices fournies par votre système d'exploitation. Police personnalisée vous permet d'importer votre propre police",
"playButtonBehavior": "comportement du bouton lecture",
"playbackStyle_optionNormal": "normale",
"hotkey_toggleRepeat": "basculer la répétition",
"lyricOffset_description": "décale les paroles par le nombre de millisecondes spécifiées",
"hotkey_toggleRepeat": "activer/désactiver la répétition",
"lyricOffset_description": "décale les paroles du nombre de millisecondes spécifié",
"fontType": "type de police",
"remotePort": "port du serveur de contrôle à distance",
"hotkey_playbackNext": "piste suivante",
"lyricFetch_description": "récupère les paroles depuis divers source d'internet",
"lyricFetch_description": "récupère les paroles depuis diverses sources d'internet",
"lyricFetchProvider_description": "sélectionnez les fournisseurs auprès desquels récupérer les paroles",
"globalMediaHotkeys_description": "active ou désactive l'utilisation des raccourcis clavier multimédia système pour contrôler la lecture",
"followLyric": "suivre les paroles actuelles",
"globalMediaHotkeys_description": "active ou désactive l'utilisation des touches multimédias de votre système pour contrôler la lecture",
"followLyric": "suivre les paroles en cours",
"discordIdleStatus": "afficher l'état d'inactivité dans le statut de l'activité",
"hotkey_zoomOut": "zoom arrière",
"hotkey_unfavoriteCurrentSong": "retirer des favoris la $t(common.currentSong)",
"hotkey_rate0": "supprimer la note",
"hotkey_unfavoriteCurrentSong": "retirer $t(common.currentSong) des favoris",
"hotkey_rate0": "effacer la note",
"hotkey_volumeMute": "couper le son",
"hotkey_toggleCurrentSongFavorite": "basculer favori de la $t(common.currentSong)",
"hotkey_toggleCurrentSongFavorite": "basculer $t(common.currentSong) dans les favoris",
"remoteUsername": "nom d'utilisateur du serveur de contrôle à distance",
"hotkey_browserBack": "retour arrière",
"hotkey_browserBack": "revenir en arrière (navigateur)",
"showSkipButton": "afficher les boutons suivants et précédents",
"minimizeToTray": "réduire vers la barre des tâches",
"minimizeToTray": "réduire vers la barre d'état système",
"gaplessAudio_optionWeak": "faible (recommandée)",
"minimumScrobbleSeconds": "scrobble minimum (secondes)",
"hotkey_playbackStop": "stop",
"font_description": "définit la police à utiliser pour l'application",
"savePlayQueue_description": "sauvegarde la liste de lecture quand l'application est fermée et la restaure quand l'application est ouverte",
"sidebarCollapsedNavigation_description": "affiche ou cache la navigation dans la barre latérale réduite",
"savePlayQueue_description": "sauvegarde la file d'attente quand l'application est fermée et la restaure quand l'application est ouverte",
"sidebarCollapsedNavigation_description": "affiche ou masque les boutons de navigation dans la barre latérale réduite",
"sidebarConfiguration": "configuration de la barre latérale",
"sidebarConfiguration_description": "sélectionnez les éléments et l'ordre dans lequel ils seront affichés dans la barre latérale",
"sidebarPlaylistList": "liste des listes de lecture de la barre latérale",
"sidebarCollapsedNavigation": "navigation de la barre latérale (réduite)",
"sidebarPlaylistList": "listes de lecture de la barre latérale",
"sidebarCollapsedNavigation": "boutons de navigation de la barre latérale (réduite)",
"skipDuration": "durée de l'avance rapide",
"sidePlayQueueStyle_optionAttached": "attaché",
"sidePlayQueueStyle": "style de la liste de lecture latérale",
"sidebarPlaylistList_description": "affiche ou cache la liste des listes de lecture de la barre latérale",
"sidePlayQueueStyle_description": "définit le style de la liste de lecture latérale",
"sidePlayQueueStyle": "style de la file d'attente latérale",
"sidebarPlaylistList_description": "affiche ou masque les listes de lecture de la barre latérale",
"sidePlayQueueStyle_description": "définit le style de la file d'attente",
"sidePlayQueueStyle_optionDetached": "détaché",
"volumeWheelStep_description": "la valeur de volume à modifier lors du défilement de la molette de la souris sur le curseur de volume",
"theme_description": "définit le thème à utiliser pour l'application",
@@ -639,12 +643,12 @@
"zoom_description": "définit le pourcentage de zoom de l'application",
"theme": "thème",
"skipPlaylistPage_description": "lors de la navigation dans une liste de lecture, aller directement vers la liste des titres, au lieu de la page par défaut",
"volumeWheelStep": "valeur du pas de volume",
"volumeWheelStep": "pas de la molette de volume",
"windowBarStyle": "style de la barre de la fenêtre",
"useSystemTheme_description": "suivre les préférences du système (mode clair ou sombre)",
"skipPlaylistPage": "sauter la page de listes de lecture",
"themeDark": "thème (sombre)",
"windowBarStyle_description": "ajuster le style de la barre de la fenêtre",
"windowBarStyle_description": "sélectionner le style de la barre de la fenêtre",
"useSystemTheme": "utiliser le thème du système",
"discordApplicationId_description": "l'identifiant de l'application pour le statut d'activité {{discord}} (par défaut à {{defaultId}})",
"audioExclusiveMode": "mode de sortie audio exclusif",
@@ -655,106 +659,106 @@
"replayGainMode_optionAlbum": "$t(entity.album, {\"count\": 1})",
"replayGainMode_optionTrack": "$t(entity.track, {\"count\": 1})",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"replayGainMode_description": "ajuste le gain de volume accordement à la valeur de {{ReplayGain}} sauvegardé dans les métadonnées du fichier",
"replayGainFallback": "valeur de repli {{ReplayGain}}",
"replayGainClipping_description": "Prévient le clipping causé par {{ReplayGain}} en baissant automatiquement le gain",
"replayGainMode_description": "ajuste le gain du volume selon les valeurs de {{ReplayGain}} enregistrées dans les métadonnées du fichier",
"replayGainFallback": "valeur de repli de {{ReplayGain}}",
"replayGainClipping_description": "empêcher la distorsion causée par {{ReplayGain}} en réduisant automatiquement le gain",
"replayGainPreamp": "préamplificateur (dB) de {{ReplayGain}}",
"replayGainClipping": "écrêtage {{ReplayGain}}",
"replayGainClipping": "distorsion du {{ReplayGain}}",
"replayGainMode": "mode de {{ReplayGain}}",
"replayGainFallback_description": "gain en dB à appliquer si le fichier n'a pas de tag {{ReplayGain}}",
"replayGainPreamp_description": "ajuste le gain de préampli appliqué a la valeur de {{ReplayGain}}",
"replayGainFallback_description": "gain en dB à appliquer si le fichier n'a pas de tags de {{ReplayGain}}",
"replayGainPreamp_description": "ajuste le gain de préampli appliqué aux valeurs {{ReplayGain}}",
"clearQueryCache": "vide le cache de feishin",
"clearCache": "vider le cache navigateur",
"clearCache": "vider le cache du navigateur",
"buttonSize_description": "la taille des boutons de la barre de lecture",
"clearQueryCache_description": "un 'soft clear' de Feishin. cela actualisera les liste de lecture, les métadonnées des titres, et réinitialisera les paroles enregistrées. les paramètres, identifiants du serveur et images mises en cache seront conservés",
"clearCache_description": "un 'hard clear' de feishin. en plus de vider le cache de feishin, vide le cache du navigateur (images sauvegardées et autres ressources). les identifiants serveurs et paramètres sont conservés",
"buttonSize": "taille des boutons du lecteur",
"clearCacheSuccess": "le cache a été vidé",
"externalLinks_description": "activer l'affichage de liens externes (Last.fm, MusicBrainz) sur la page de l'artiste/album",
"startMinimized_description": "démarrer l'application dans la barre des tâches",
"clearQueryCache_description": "un 'nettoyage léger' de feishin. cela actualisera les listes de lecture, les métadonnées des titres, et réinitialisera les paroles enregistrées. les paramètres, identifiants du serveur et images mises en cache seront conservés",
"clearCache_description": "un 'nettoyage complet' de feishin. en plus de vider le cache de feishin, vide le cache du navigateur (images sauvegardées et autres ressources). les identifiants serveurs et paramètres sont conservés",
"buttonSize": "taille des boutons de la barre de lecture",
"clearCacheSuccess": "cache vidé avec succès",
"externalLinks_description": "activer l'affichage de liens externes (Last.fm, MusicBrainz) sur les pages d'artiste/album",
"startMinimized_description": "démarrer l'application dans la barre d'état système",
"externalLinks": "afficher les liens externes",
"homeConfiguration": "configuration de la page d'accueil",
"homeFeature": "carrousel de la page d'accueil",
"homeFeature_description": "active ou désactive le carrousel sur la page d'accueil",
"homeFeature_description": "contrôle laffichage du carrousel principal sur la page daccueil",
"imageAspectRatio": "utiliser le rapport hauteur/largeur natif de la pochette d'album",
"imageAspectRatio_description": "si cette option est activée, les pochettes d'album seront affichées en utilisant leur rapport hauteur/largeur natif. pour les pochettes qui n'ont pas un rapport 1:1 (carré), l'espace restant sera vide",
"mpvExtraParameters_help": "un par ligne",
"passwordStore_description": "quel mot de passe utiliser. changez cela si vous rencontrez des problèmes pour stocker les mots de passe",
"passwordStore": "mots de passe",
"passwordStore_description": "quel gestionnaire de mots de passe/secret utiliser. modifiez ceci si vous rencontrez des problèmes pour stocker les mots de passe",
"passwordStore": "gestionnaire de mots de passe/secrets",
"homeConfiguration_description": "configurer quels éléments sont affichés sur la page d'accueil, et dans quel ordre",
"startMinimized": "démarrer l'application en mode réduit",
"transcode_description": "permet le transcodage vers différents formats",
"transcodeBitrate_description": "sélectionne le débit du transcodage. 0 signifie que le serveur choisit",
"transcodeBitrate_description": "sélectionne le débit binaire du transcodage. 0 signifie que le serveur choisit",
"transcodeFormat_description": "sélectionne le format du transcodage. laisser vide pour laisser le serveur décider",
"volumeWidth": "largeur de la barre de volume",
"volumeWidth_description": "la largeur de la barre de volume",
"customCssEnable": "activer le css personnalisé",
"customCssEnable_description": "permet d'écrire du css personnalisé",
"customCssEnable": "active le css personnalisé",
"customCssEnable_description": "permet l'écriture de css personnalisé",
"customCssNotice": "Attention : bien qu'il y ait un certain assainissement (blocage de url() et de content :), l'utilisation de css personnalisé peut toujours présenter des risques en modifiant l'interface",
"customCss": "css personnalisé",
"webAudio": "utiliser l'audio web",
"transcodeBitrate": "débit binaire du transcodage",
"transcodeFormat": "format de transcodage",
"webAudio_description": "utiliser l'audio web. cela permet d'utiliser des fonctions avancées comme le replaygain. désactivez si vous rencontrez d'autres problèmes",
"artistConfiguration": "page de configuration de l'artiste de l'album",
"artistConfiguration_description": "configurer les éléments et l'ordre à afficher, sur la page de l'artiste de l'album",
"webAudio_description": "utiliser l'audio web. cela permet d'utiliser des fonctions avancées comme le replaygain. désactivez si cela cause des problèmes",
"artistConfiguration": "configuration de la page d'artiste d'album",
"artistConfiguration_description": "configurer les éléments et l'ordre à afficher, sur la page d'artiste d'album",
"contextMenu": "configuration du menu contextuel (clic droit)",
"contextMenu_description": "permet de masquer les éléments qui s'affichent dans le menu lorsque vous cliquez avec le bouton droit de la souris sur un élément. les éléments qui ne sont pas cochés seront masqués",
"contextMenu_description": "permet de masquer les éléments qui s'affichent dans le menu lorsque vous cliquez droit sur une entrée. les éléments qui ne sont pas cochés seront masqués",
"albumBackground": "image d'arrière-plan de l'album",
"albumBackground_description": "ajoute une image d'arrière-plan pour les pages de l'album contenant une pochette d'album",
"albumBackground_description": "ajoute une image d'arrière-plan pour les pages d'album contenant une pochette d'album",
"albumBackgroundBlur_description": "ajuste le niveau de flou appliqué à l'image d'arrière-plan de l'album",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playerbarOpenDrawer": "basculement plein écran de la barre de lecteur",
"playerbarOpenDrawer_description": "permet de cliquer sur la barre du lecteur pour ouvrir le lecteur plein écran",
"translationApiProvider": "fournisseur d'api de traduction",
"discordListening": "afficher le statut d'écoute",
"discordListening_description": "afficher le statut comme étant en écoute au lieu de lecture",
"translationApiKey_description": "clé api à utiliser pour traduire les paroles (ne prend en charge que les points de terminaison de service globaux)",
"translationTargetLanguage": "traduction langue cible",
"trayEnabled": "montrer le plateau",
"translationApiProvider_description": "le fournisseur d'api à utiliser pour la traduction des paroles",
"customCss_description": "contenu css personnalisé. Remarque : le contenu et les URL distantes sont des propriétés non autorisées. Un aperçu de votre contenu est affiché ci-dessous. Des champs supplémentaires que vous n'avez pas définis sont présents en raison de la vérification",
"translationApiKey": "clé api de traduction",
"translationTargetLanguage_description": "langue cible pour la traduction des paroles",
"trayEnabled_description": "afficher ou masquer l'icône et le menu de la barre d'état système. si désactivé, désactive également la réduction et la sortie vers la barre d'état système",
"playerbarOpenDrawer": "basculement en plein écran de la barre de lecture",
"playerbarOpenDrawer_description": "permet de cliquer sur la barre de lecture pour ouvrir le lecteur plein écran",
"translationApiProvider": "fournisseur d'API de traduction",
"discordListening": "afficher le status en \"écoute\"",
"discordListening_description": "afficher le statut comme étant en écoute au lieu de jouer",
"translationApiKey_description": "clé API pour la traduction (point de terminaison global uniquement)",
"translationTargetLanguage": "langue cible de traduction",
"trayEnabled": "afficher la barre d’état système",
"translationApiProvider_description": "fournisseur d'API pour la traduction",
"customCss_description": "contenu css personnalisé. Remarque : les propriétés 'content' et les URL distantes ne sont pas autorisées. Un aperçu de votre contenu est affiché ci-dessous. Des champs supplémentaires que vous n'avez pas définis sont présents en raison d'assainissement",
"translationApiKey": "clé API de traduction",
"translationTargetLanguage_description": "langue cible pour la traduction",
"trayEnabled_description": "afficher/masquer licône/le menu dans la barre détat système. si désactivé, désactive également la réduction/fermeture vers la barre détat système",
"albumBackgroundBlur": "intensité du flou de l'image d'arrière-plan de l'album",
"lastfmApiKey": "clé API {{lastfm}}",
"lastfmApiKey_description": "la clé API pour {{lastfm}}. requise pour la pochette d'album",
"discordServeImage": "servir l'image {{discord}} depuis le serveur",
"discordServeImage_description": "partage de la pochette d'album de Rich Presence {{discord}} depuis le serveur directement (disponible uniquement pour Jellyfin et Navidrome). {{discord}} utilise un bot pour récupérer les images, votre serveur doit donc être accessible depuis internet",
"lastfm": "affiche les liens de last.fm",
"musicbrainz_description": "affiche les liens vers MusicBrainz sur les pages des artistes/albums, quand l'identifiant MusicBrainz existe",
"lastfm_description": "affiche les liens vers Last.fm sur les pages des artistes/albums",
"discordServeImage_description": "partage la pochette d'album pour le statut d'activité {{discord}} depuis le serveur directement (disponible uniquement pour Jellyfin et Navidrome). {{discord}} utilise un bot pour récupérer les images, votre serveur doit donc être accessible depuis internet",
"lastfm": "afficher les liens last.fm",
"musicbrainz_description": "affiche les liens vers MusicBrainz sur les pages artiste/album, lorsque l'identifiant MusicBrainz existe",
"lastfm_description": "affiche les liens vers last.fm sur les pages artiste/album",
"musicbrainz": "affiche les liens MusicBrainz",
"neteaseTranslation": "Activer les traductions NetEase",
"neteaseTranslation_description": "Lorsque cette option est activée, récupère et affiche les paroles traduites de NetEase si elles sont disponibles",
"neteaseTranslation_description": "si activé, récupère et affiche les paroles traduites de NetEase si elles sont disponibles",
"preferLocalLyrics_description": "privilégier les paroles locales aux paroles distantes lorsqu'elles sont disponibles",
"preferLocalLyrics": "privilégier les paroles locales",
"discordPausedStatus_description": "quand activé, le status s'affichera lorsque le lecteur est en pause",
"discordPausedStatus": "afficher le statut dactivité en pause",
"discordPausedStatus_description": "si activé, le statut affichera lorsque la lecture est en pause",
"discordPausedStatus": "afficher le statut dactivité même en pause",
"preservePitch": "préserver la hauteur",
"preservePitch_description": "préserver la hauteur lors du changement de la vitesse de lecture",
"discordDisplayType": "type d'affichage du statut {{discord}}",
"discordDisplayType_description": "modifie ce que vous écoutez dans votre statut",
"discordDisplayType_songname": "nom du morceau",
"discordDisplayType_songname": "nom du titre",
"discordDisplayType_artistname": "nom(s) dartiste",
"hotkey_navigateHome": "aller à l'accueil",
"preventSleepOnPlayback_description": "Empêche la mise en veille du lecteur lorsque la musique est en cours de lecture",
"preventSleepOnPlayback": "Empêche la mise en veille lors de la lecture",
"discordLinkType": "lien de Rich Presence {{discord}}",
"discordLinkType_description": "Ajoute des liens externes vers {{lastfm}} ou {{musicbrainz}} aux champs piste et artiste de la Rich Presence de {{discord}}. {{musicbrainz}} est la méthode la plus précise, mais nécessite des balises et ne fournit pas de liens vers les artistes, tandis que {{lastfm}} doit toujours fournir un lien. Aucune requête réseau supplémentaire n'est effectuée",
"preventSleepOnPlayback_description": "empêcher l’écran de s’éteindre pendant la lecture de la musique",
"preventSleepOnPlayback": "empêche la mise en veille lors de la lecture",
"discordLinkType": "lien du statut d'activité {{discord}}",
"discordLinkType_description": "ajoute des liens externes vers {{lastfm}} ou {{musicbrainz}} aux champs titre et artiste du statut d'activité {{discord}}. {{musicbrainz}} est la méthode la plus précise, mais nécessite des balises et ne fournit pas de liens vers les artistes, tandis que {{lastfm}} devrait toujours fournir un lien. aucune requête réseau supplémentaire n'est effectuée",
"discordLinkType_none": "$t(common.none)",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} avec {{lastfm}} si le premier n'est pas disponible",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} avec {{lastfm}} comme solution de repli",
"artistBackground": "image d'arrière-plan de l'artiste",
"artistBackground_description": "ajoute une image d'arrière-plan pour les pages d'artiste contenant une image de l'artiste",
"artistBackgroundBlur": "intensité du flou sur l'image d'arrière-plan d'artiste",
"artistBackgroundBlur": "intensité du flou sur l'image d'arrière-plan de l'artiste",
"artistBackgroundBlur_description": "ajuste la quantité de flou appliquée à l'image d'arrière-plan de l'artiste",
"releaseChannel_optionLatest": "dernière",
"releaseChannel_optionBeta": "bêta",
"releaseChannel": "canal de diffusion",
"releaseChannel_description": "choisissez entre les versions stables, bêta, ou alpha (nightly) pour les mises à jour automatiques",
"releaseChannel_description": "choisissez entre les versions stable, bêta ou alpha (nocturne) pour les mises à jour automatiques",
"mediaSession": "activer media session",
"mediaSession_description": "active l'intégration Media Session, affichant les commandes multimédias et les métadonnées dans la superposition du volume du système et l'écran de verrouillage",
"mediaSession_description": "active l'intégration de Media Session, affichant les contrôles multimédias et les métadonnées dans la superposition du volume du système et sur l'écran de verrouillage",
"enableAutoTranslation_description": "activer la traduction automatiquement lorsque les paroles sont chargées",
"enableAutoTranslation": "activer la traduction automatique",
"exportImportSettings_control_description": "exporter et importer les paramètres en JSON",
@@ -766,46 +770,46 @@
"exportImportSettings_importSuccess": "les paramètres ont été importés avec succès!",
"exportImportSettings_notValidJSON": "le fichier transmis n'est pas un JSON valide",
"exportImportSettings_offendingKeyError": "la clé \"{{offendingKey}}\" est incorrecte - {{reason}}",
"exportImportSettings_importModalTitle": "paramètres d'importation feishin",
"crossfadeStyle": "style de fondu enchaîné",
"discordRichPresence": "{{discord}} Rich Presence",
"exportImportSettings_importModalTitle": "importer les paramètres de feishin",
"crossfadeStyle": "style du fondu enchaîné",
"discordRichPresence": "statut d'activité {{discord}} (rich presence)",
"language": "langage",
"notify_description": "affiche une notification lorsque la chanson en cours change",
"notify_description": "afficher des notifications lors du changement du titre en cours",
"transcode": "activer le transcodage",
"notify": "activer les notifications de chansons",
"notify": "activer les notifications de titre",
"analyticsDisable": "Désactiver l'analytique basée sur l'utilisation",
"analyticsDisable_description": "les données d'utilisation anonymisées sont envoyées au développeur afin de contribuer à l'amélioration de l'application",
"playerbarSlider": "barre de lecture",
"playerbarSlider": "barre de progression",
"playerbarSliderType_optionSlider": "pleine",
"playerbarSliderType_optionWaveform": "forme d'onde",
"playerbarWaveformAlign": "forme d'onde alignée",
"playerbarWaveformAlign": "alignement de l'onde",
"playerbarWaveformAlign_optionTop": "haut",
"playerbarWaveformAlign_optionCenter": "centre",
"playerbarWaveformAlign_optionBottom": "bas",
"playerbarWaveformBarWidth": "largeur de la barre en forme d'onde",
"playerbarWaveformGap": "écart de la forme d'onde",
"playerbarWaveformRadius": "rayon de la forme d'onde",
"showLyricsInSidebar_description": "un panneau sera attaché à la file d'attente de lecture, qui affichera les paroles",
"playerbarWaveformBarWidth": "largeur des barres de l'onde",
"playerbarWaveformGap": "espacement de l'onde",
"playerbarWaveformRadius": "rayon des barres de l'onde",
"showLyricsInSidebar_description": "un panneau sera attaché à la file d'attente, qui affichera les paroles",
"showLyricsInSidebar": "afficher les paroles dans la barre de lecture latérale",
"showVisualizerInSidebar_description": "un panneau sera ajouté à la barre de lecture latérale qui affiche le visualiseur",
"showVisualizerInSidebar": "afficher le visualiseur dans la barre de lecture latérale",
"audioFadeOnStatusChange": "diminution du volume sonore lors du changement d'état du statut",
"audioFadeOnStatusChange_description": "permet le fondu enchaîné et le fondu au noir quand la lecture/pause change d'états du statut",
"audioFadeOnStatusChange": "fondu audio lors du basculement lecture/pause",
"audioFadeOnStatusChange_description": "active le fondu sortant et entrant lors du changement de statut lecture/pause",
"queryBuilder": "constructeur de requêtes",
"queryBuilderCustomFields_inputLabel": "label",
"queryBuilderCustomFields_inputTag": "tag",
"queryBuilderCustomFields": "champs personnalisé",
"queryBuilderCustomFields_description": "ajouter un champ personnalisé à utiliser dans les constructeurs de requêtes",
"queryBuilderCustomFields_description": "ajouter des champs personnalisés à utiliser dans les constructeurs de requêtes",
"autoDJ": "DJ auto",
"autoDJ_description": "ajouter automatiquement des titres similaire à la file d'attente",
"autoDJ_itemCount": "nombre d'entrée",
"autoDJ_itemCount_description": "le nombre d'entrées tentées d'être ajoutées à la file d'attente lorsque le DJ auto est activé",
"autoDJ_timing": "timing",
"autoDJ_timing_description": "le nombre de titres restant dans la file d'attente avant le déclenchement du DJ auto",
"followCurrentSong_description": "défiler automatiquement jusqu'au titre en cours de lecture dans la file d'attente",
"followCurrentSong_description": "défiler automatiquement la file d'attente jusqu'au titre en cours",
"followCurrentSong": "suivre le titre en cours",
"logLevel": "niveau de log",
"logLevel_description": "définis le niveau minimum de log à afficher. débogage affiche tous les logs, erreur affiche seulement les erreurs",
"logLevel": "niveau de journalisation",
"logLevel_description": "définit le niveau minimum de journalisation à afficher. le mode debug affiche tous les logs, le mode error naffiche que les erreurs",
"logLevel_optionDebug": "débogage",
"logLevel_optionError": "erreur",
"logLevel_optionInfo": "info",
@@ -815,20 +819,20 @@
"playerbarSlider_description": "la forme d'onde n'est pas recommandée sur une connexion lente ou limitée",
"useThemeAccentColor": "utiliser la couleur d'accent du thème",
"useThemeAccentColor_description": "utiliser la couleur principale définie dans le thème sélectionné au lieu de la couleur d'accentuation personnalisée",
"artistReleaseTypeConfiguration": "configuration du type de sortie de l'artiste",
"artistReleaseTypeConfiguration_description": "configure quel type de sortie est affiché, et dans quel ordre, sur la page artiste de l'album",
"artistReleaseTypeConfiguration": "configuration des sorties de l'artiste",
"artistReleaseTypeConfiguration_description": "configure quels types de sortie sont affichés et dans quel ordre, sur la page d'artiste d'album",
"mpvExtraParameters": "paramètres supplémentaires de mpv",
"mpvExtraParameters_description": "arguments supplémentaires à transmettre à mpv",
"pathReplace": "remplacement du chemin de fichier",
"pathReplace_description": "remplacez le chemin de fichier par défaut de votre serveur",
"pathReplace_optionRemovePrefix": "supprimer un prefix",
"pathReplace_optionAddPrefix": "ajouter un prefix",
"artistRadioCount_description": "définit le nombre de titres à récupérer pour la radio d'artiste et la radio de titre",
"artistRadioCount": "nombre de radio d'artiste/titre",
"artistRadioCount_description": "définit le nombre de titres à récupérer pour les radio d'artiste/piste",
"artistRadioCount": "radio d'artiste/piste",
"imageResolution": "résolution d'image",
"imageResolution_description": "la résolution d'image utilisée dans l'application. définir une valeur à 0 utilisera la résolution native de l'image",
"imageResolution_optionTable": "tableau",
"imageResolution_optionItemCard": "entrée de carte",
"imageResolution_optionItemCard": "carte",
"imageResolution_optionSidebar": "barre latérale",
"imageResolution_optionHeader": "en-tête",
"imageResolution_optionFullScreenPlayer": "lecteur en plein écran",
@@ -839,27 +843,38 @@
"analyticsEnable": "Envoyer des métriques d'utilisation",
"analyticsEnable_description": "Des métriques d'utilisation anonymisées sont envoyées au développeur pour aider à améliorer l'application",
"automaticUpdates": "Mises à jour automatiques",
"automaticUpdates_description": "Vérifier l'existence de mises à jour et les installer automatiquement",
"releaseChannel_optionAlpha": "alpha (toutes les nuits)",
"automaticUpdates_description": "Vérifie et installe les mises à jour automatiquement",
"releaseChannel_optionAlpha": "alpha (nocturne)",
"discordStateIcon": "afficher licône de lecture",
"discordStateIcon_description": "affiche une petite icône de lecture dans le statut d'activité. l'icône de pause est toujours affichée lorsque \"Afficher le statut d'activité en pause\" est activé",
"homeFeatureStyle_description": "contrôle le style du carousel d'accueil à la une",
"homeFeatureStyle": "style de carousel à la une de l'accueil",
"discordStateIcon_description": "affiche une petite icône de lecture dans le statut d'activité. l'icône de pause est toujours affichée lorsque \"Afficher le statut dactivité même en pause\" est activé",
"homeFeatureStyle_description": "contrôle le style du carrousel de la page daccueil",
"homeFeatureStyle": "style du carrousel de la page daccueil",
"homeFeatureStyle_optionMultiple": "multiple",
"homeFeatureStyle_optionSingle": "simple",
"blurExplicitImages": "flouter les images explicites",
"blurExplicitImages_description": "les pochettes d'albums et de chansons étiquetées comme explicites seront floutées",
"blurExplicitImages_description": "les pochettes de titre et d'albums étiquetées comme explicites seront floutées",
"enableGridMultiSelect": "activer la sélection multiple dans la grille",
"enableGridMultiSelect_description": "quand activé, permet la sélection de plusieurs entrées dans la vue en grille. quand désactivé, cliquer sur un item de la grille mène vers la page de l'entrée",
"sidebarPlaylistSorting_description": "permet le tri manuel des listes de lecture dans la barre latérale en utilisant le drag and drop plutôt que l'ordre par défaut du serveur",
"enableGridMultiSelect_description": "si activé, permet la sélection de plusieurs entrées dans la vue en grille. si désactivé, cliquer sur un item de la grille mène vers la page de l'entrée",
"sidebarPlaylistSorting_description": "permet le tri manuel des listes de lecture dans la barre latérale en utilisant le glisser-déposer au lieu de l'ordre par défaut du serveur",
"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 correspondant à cette expression régulière",
"sidebarPlaylistListFilterRegex_placeholder": "ex. ^Mix Journalier*",
"sidebarPlaylistListFilterRegex": "filtre d'expression régulière de liste de lecture",
"sidebarPlaylistListFilterRegex": "filtre de liste de lecture en expression régulière",
"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.",
"autosave_description": "activez la sauvegarde automatique de la file d'attente 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"
"autosaveCount_description": "nombre de changement de piste avant la sauvegarde de la file d'attente. 1 (minimum) signifie chaque changement de titre",
"useThemePrimaryShade": "utiliser la teinte principale du thème",
"useThemePrimaryShade_description": "utiliser la teinte principale définie dans le thème sélectionné pour les variantes de couleur primaire",
"primaryShade": "teinte principale",
"primaryShade_description": "remplacer la teinte principale (09) utilisée pour les boutons, les liens et les autres éléments de couleur primaire",
"hotkey_listNavigateToPage": "naviguer vers la page de l'élément",
"hotkey_listPlayDefault": "lecture de la liste",
"hotkey_listPlayLast": "lire en dernier",
"hotkey_listPlayNext": "lire ensuite",
"hotkey_listPlayNow": "lire maintenant",
"playerItemConfiguration_description": "configurer les éléments affichés et leur ordre dans le lecteur plein écran",
"playerItemConfiguration": "configuration des éléments du lecteur"
},
"form": {
"deletePlaylist": {
@@ -963,7 +978,7 @@
"input_streamUrl": "lien du flux en direct"
},
"saveQueue": {
"success": "file d'attente de lecture enregistrée sur le serveur"
"success": "file d'attente enregistrée sur le serveur"
},
"lyricsExport": {
"export": "exporter les paroles",
@@ -987,7 +1002,7 @@
"folderWithCount_one": "{{count}} dossier",
"folderWithCount_many": "{{count}} dossiers",
"folderWithCount_other": "{{count}} dossiers",
"albumArtist_one": "artiste de l'album",
"albumArtist_one": "artiste d'album",
"albumArtist_many": "artistes d'albums",
"albumArtist_other": "artistes d'albums",
"track_one": "piste",
@@ -1034,14 +1049,14 @@
"table": {
"config": {
"general": {
"displayType": "Type d'affichage",
"displayType": "type d'affichage",
"tableColumns": "colonnes de la liste",
"autoFitColumns": "colonnes à ajustement automatique",
"autoFitColumns": "ajuster automatiquement la largeur des colonnes",
"gap": "$t(common.gap)",
"size": "$t(common.size)",
"itemGap": "écart entre les éléments (en pixel)",
"itemSize": "taille des élements (en pixel)",
"followCurrentSong": "suivre la chanson actuelle",
"followCurrentSong": "suivre le titre actuelle",
"advancedSettings": "paramètres avancés",
"autosize": "taille automatique",
"moveUp": "monter",
@@ -1106,7 +1121,8 @@
"bitDepth": "$t(common.bitDepth)",
"sampleRate": "$t(common.sampleRate)",
"composer": "compositeur",
"titleArtist": "$t(common.title) (artiste)"
"titleArtist": "$t(common.title) (artiste)",
"albumGroup": "groupe d'album"
}
},
"column": {
@@ -1114,13 +1130,13 @@
"album": "album",
"rating": "note",
"favorite": "favori",
"playCount": "écoutes",
"playCount": "lectures",
"releaseYear": "année",
"biography": "biographie",
"releaseDate": "date de sortie",
"bitrate": "bitrate",
"bitrate": "bit binaire",
"title": "titre",
"bpm": "bpm",
"bpm": "BPM",
"dateAdded": "date d'ajout",
"trackNumber": "piste",
"albumArtist": "artiste de l'album",
@@ -1342,6 +1358,9 @@
"d": "D",
"z": "Z"
}
}
},
"lumiBars": "Lumi Bars",
"outlineBars": "Outline Bars",
"splitGradient": "Split Gradient"
}
}
+235 -31
View File
@@ -7,7 +7,7 @@
"playRandom": "ランダム再生",
"skip": "スキップ",
"previous": "前へ",
"toggleFullscreenPlayer": "フルスクリーンプレーヤー切り替え",
"toggleFullscreenPlayer": "全画面プレーヤー切り替え",
"skip_back": "前へスキップ",
"favorite": "お気に入り",
"next": "次へ",
@@ -44,7 +44,11 @@
"sleepTimer_off": "オフ",
"sleepTimer_timeRemaining": "残り {{time}}",
"sleepTimer_setCustom": "タイマーを設定",
"sleepTimer_cancel": "タイマーをキャンセル"
"sleepTimer_cancel": "タイマーをキャンセル",
"holdToShuffle": "長押しでシャッフル",
"albumRadio": "アルバム・ラジオ",
"artistRadio": "アーティストラジオ",
"trackRadio": "ラジオを追跡する"
},
"setting": {
"crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します",
@@ -56,7 +60,7 @@
"theme_description": "アプリケーションに使用するテーマを設定します",
"hotkey_playbackPause": "一時停止",
"replayGainFallback": "{{ReplayGain}} フォールバック",
"sidebarCollapsedNavigation_description": "折りたたサイドバーのナビゲーションを表示/非表示にします",
"sidebarCollapsedNavigation_description": "折りたたまれたサイドバーのナビゲーションを表示または非表示にします",
"hotkey_volumeUp": "音量を上げる",
"skipDuration": "スキップの長さ",
"discordIdleStatus_description": "有効にすると、プレーヤーがアイドル状態でもステータスを更新します",
@@ -121,7 +125,7 @@
"font": "フォント",
"replayGainMode_optionTrack": "$t(entity.track, {\"count\": 1})",
"themeLight_description": "アプリケーションに使用するライトテーマを設定します",
"hotkey_toggleFullScreenPlayer": "フルスクリーンプレーヤー切り替え",
"hotkey_toggleFullScreenPlayer": "全画面プレーヤー切り替え",
"hotkey_localSearch": "ページ内検索",
"hotkey_toggleQueue": "キューの切り替え",
"zoom_description": "アプリケーションのズーム率を設定します",
@@ -150,7 +154,7 @@
"fontType_description": "組み込みフォントの場合、Feishin が提供するフォントの中から 1 つ選択します。 システムフォントの場合、OS が提供する任意のフォントを選択できます。 カスタムフォントの場合、フォントファイルを自身で選択できます",
"playButtonBehavior": "再生ボタンの動作",
"volumeWheelStep": "音量ホイールステップ",
"sidebarPlaylistList_description": "サイドバープレイリストのリストを表示/非表示にします",
"sidebarPlaylistList_description": "サイドバープレイリストを表示または非表示にします",
"accentColor": "アクセントカラー",
"sidePlayQueueStyle_description": "サイド再生キューのスタイルを設定します",
"accentColor_description": "アプリケーションが利用するアクセントカラーを設定します",
@@ -161,7 +165,7 @@
"replayGainPreamp_description": "{{ReplayGain}} の値に適用されるプリアンプゲインを調整します",
"hotkey_toggleRepeat": "リピートの切り替え",
"lyricOffset_description": "歌詞のオフセットをミリ秒単位で指定します",
"sidebarConfiguration_description": "サイドバーに表示されるアイテムと並び順を選択します",
"sidebarConfiguration_description": "サイドバーに表示する項目と順序を選択します",
"fontType": "フォントタイプ",
"remotePort": "リモートコントロールサーバーのポート",
"applicationHotkeys": "アプリケーションホットキー",
@@ -187,7 +191,7 @@
"remoteUsername": "リモートコントロールサーバーのユーザー名",
"hotkey_browserBack": "ブラウザ 戻る",
"showSkipButton": "スキップボタンを表示",
"sidebarPlaylistList": "サイドバー プレイリスト リスト",
"sidebarPlaylistList": "サイドバープレイリスト",
"minimizeToTray": "最小化時にシステムトレイに格納",
"skipPlaylistPage": "プレイリストページをスキップ",
"themeDark": "テーマ (ダーク)",
@@ -215,7 +219,7 @@
"trayEnabled": "トレイを表示する",
"volumeWidth_description": "音量スライダーの幅",
"volumeWidth": "音量スライダーの幅",
"webAudio_description": "Web Audio を使用します。これにより、リプレイゲインなどの高度な機能が有効になります。それ以外の場合は無効にしてください",
"webAudio_description": "Web Audio を使用します。これにより、リプレイゲインなどの高度な機能が有効になります。問題が発生した場合は無効にしてください",
"mpvExtraParameters_help": "1 行に 1 つずつ",
"musicbrainz_description": "MusicBrainz ID が存在するアーティスト/アルバムページに MusicBrainz へのリンクを表示します",
"musicbrainz": "MusicBrainz リンクを表示する",
@@ -329,7 +333,7 @@
"logLevel_optionDebug": "debug",
"logLevel_optionError": "error",
"logLevel_optionInfo": "info",
"logLevel_optionWarn": "warn",
"logLevel_optionWarn": "警告する",
"playerFilters": "キューから曲をフィルタリング",
"playerFilters_description": "以下の基準に基づいて曲をキューに追加しないようにします",
"artistRadioCount": "アーティスト / トラックのラジオカウント",
@@ -337,7 +341,7 @@
"imageResolution": "画像の解像度",
"imageResolution_description": "アプリ内で使用される画像の解像度。値を 0 に設定すると、デフォルトでネイティブ画像解像度が適用されます",
"showLyricsInSidebar_description": "添付の再生キューに歌詞を表示するパネルが追加されます",
"showLyricsInSidebar": "プレーヤーのサイドバーに歌詞を表示する",
"showLyricsInSidebar": "サイドバーのプレーヤーに歌詞を表示",
"showRatings": "星評価を表示する",
"imageResolution_optionSidebar": "サイドバー",
"imageResolution_optionHeader": "ヘッダー",
@@ -348,12 +352,12 @@
"playerbarSliderType_optionWaveform": "波形",
"playerbarWaveformAlign": "波形アライメント",
"showRatings_description": "インターフェースに星評価機能を表示するかどうかを制御します",
"showVisualizerInSidebar": "プレーヤーのサイドバーにビジュアライザーを表示する",
"combinedLyricsAndVisualizer": "プレーヤーのサイドバーに歌詞とビジュアライザーを統合する",
"showVisualizerInSidebar": "サイドバーのプレーヤーにビジュアライザーを表示",
"combinedLyricsAndVisualizer": "サイドバーのプレーヤーに歌詞とビジュアライザーを統合",
"audioFadeOnStatusChange_description": "再生 / 一時停止の状態が変わったときにフェードアウトとフェードインを有効にします",
"audioFadeOnStatusChange": "ステータス変更時の音声フェード",
"combinedLyricsAndVisualizer_description": "歌詞とビジュアライザーを同じパネルに統合します",
"showVisualizerInSidebar_description": "プレーヤーのサイドバーにビジュアライザーを表示するパネルが追加されます",
"showVisualizerInSidebar_description": "サイドバーのプレーヤーにビジュアライザーを表示するパネルが追加されます",
"queryBuilderCustomFields": "カスタムフィールド",
"queryBuilderCustomFields_inputLabel": "ラベル",
"queryBuilderCustomFields_inputTag": "タグ",
@@ -375,7 +379,39 @@
"automaticUpdates_description": "更新を自動的に確認してインストールします",
"releaseChannel_optionAlpha": "アルファ (nightly)",
"discordStateIcon": "再生中アイコンを表示",
"discordStateIcon_description": "Rich Presence ステータスに小さな再生アイコンを表示します。「一時停止時に Rich Presence を表示」が有効になっている場合は、常に一時停止アイコンが表示されます"
"discordStateIcon_description": "Rich Presence ステータスに小さな再生アイコンを表示します。「一時停止時に Rich Presence を表示」が有効になっている場合は、常に一時停止アイコンが表示されます",
"sidebarPlaylistListFilterRegex_description": "この正規表現に一致するプレイリストをサイドバーから非表示にします",
"sidebarPlaylistListFilterRegex_placeholder": "例: ^Daily Mix.*",
"sidebarPlaylistListFilterRegex": "プレイリストフィルターの正規表現",
"sidebarPlaylistSorting": "サイドバーでプレイリストを並べ替え",
"sidebarPlaylistSorting_description": "デフォルトのサーバー順ではなく、ドラッグアンドドロップを使用してサイドバーでプレイリストを手動で並べ替えることができます",
"playerItemConfiguration_description": "全画面プレーヤーに表示する項目と順序を設定します",
"playerItemConfiguration": "プレーヤーの項目設定",
"autosave": "再生キューを自動的に保存",
"autosave_description": "再生キューをサーバーに自動的に保存できるようにします。これは Navidrome/Subsonic を使用している場合にのみ可能であり、再生キューを混在させることはできません。",
"autosaveCount": "自動再生キューの保存頻度",
"autosaveCount_description": "キューが保存されるまでにトラックが変更される回数を設定します。1 (最小値) は曲が変わるたびに保存されることを意味します",
"useThemePrimaryShade_description": "選択したテーマで定義されたプライマリシェードをプライマリカラーのバリアントに使用します",
"useThemePrimaryShade": "テーマのプライマリシェードを使用",
"primaryShade": "プライマリシェード",
"primaryShade_description": "ボタン、リンク、およびその他の主要色要素に使用されるプライマリシェード (0–9) を上書きします",
"playerbarWaveformAlign_optionTop": "上部",
"playerbarWaveformAlign_optionCenter": "中央",
"playerbarWaveformAlign_optionBottom": "下部",
"imageResolution_optionTable": "表",
"imageResolution_optionItemCard": "アイテムカード",
"blurExplicitImages": "露骨な画像をぼかす",
"blurExplicitImages_description": "露骨な表現を含むタグが付けられたアルバムおよび楽曲のアートワークをぼかします",
"enableGridMultiSelect": "グリッドの複数選択を有効にする",
"enableGridMultiSelect_description": "有効にすると、グリッドビューで複数のアイテムを選択できます。無効にすると、グリッドアイテムの画像をクリックするとアイテムページに移動します",
"playerbarWaveformBarWidth": "波形バーの幅",
"playerbarWaveformGap": "波形ギャップ",
"playerbarWaveformRadius": "波形半径",
"hotkey_listNavigateToPage": "リストのアイテムページへ移動",
"hotkey_listPlayDefault": "リスト再生",
"hotkey_listPlayLast": "リストの最後を再生",
"hotkey_listPlayNext": "リスト 再生 次へ",
"hotkey_listPlayNow": "今すぐリストを再生"
},
"action": {
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
@@ -415,7 +451,8 @@
"holdToMoveToBottom": "押し続けると一番下に移動します",
"openApplicationDirectory": "アプリケーションディレクトリを開く",
"selectRangeOfItems": "項目の範囲を選択",
"addOrRemoveFromSelection": "選択に追加または削除"
"addOrRemoveFromSelection": "選択に追加または削除",
"goToCurrent": "現在の項目へ移動"
},
"common": {
"backward": "戻る",
@@ -463,8 +500,8 @@
"setting_other": "設定",
"version": "バージョン",
"title": "タイトル",
"filter_other": "フィルタ",
"filters": "フィルタ",
"filter_other": "フィルタ",
"filters": "フィルタ",
"create": "作成",
"bitrate": "ビットレート",
"saveAndReplace": "保存して変更",
@@ -500,7 +537,7 @@
"bitDepth": "ビット深度",
"close": "閉じる",
"codec": "コーデック",
"mbid": "MusicBrainz ID",
"mbid": "MusicBrainz識別子",
"sampleRate": "サンプルレート",
"preview": "プレビュー",
"private": "プライベート",
@@ -533,14 +570,16 @@
"clean": "クリーン",
"filter_single": "シングル",
"filter_multiple": "複数枚組",
"rename": "名前を変更"
"rename": "名前を変更",
"newVersionAvailable": "新しいバージョンが利用可能です"
},
"table": {
"config": {
"view": {
"table": "テーブル",
"table": "",
"grid": "グリッド",
"list": "リスト"
"list": "リスト",
"detail": "詳細"
},
"general": {
"displayType": "表示タイプ",
@@ -565,7 +604,14 @@
"size_compact": "コンパクト",
"size_large": "大きい",
"pagination_itemsPerPage": "ページあたりの項目数",
"pagination_infinite": "無限"
"pagination_infinite": "無限",
"pagination": "ページネーション",
"pagination_paginate": "ページ分割",
"showHeader": "ヘッダーを表示",
"verticalBorders": "列の境界線",
"rowHoverHighlight": "行ホバーハイライト",
"alternateRowColors": "交互の行の色",
"horizontalBorders": "行の境界線"
},
"label": {
"releaseDate": "発売日",
@@ -602,7 +648,8 @@
"image": "画像",
"sampleRate": "$t(common.sampleRate)",
"composer": "作曲家",
"titleArtist": "$t(common.title) (アーティスト)"
"titleArtist": "$t(common.title) (アーティスト)",
"albumGroup": "アルバムグループ"
}
},
"column": {
@@ -666,7 +713,8 @@
"saveQueueFailed": "キューを保存できませんでした",
"settingsSyncError": "レンダラーとメインプロセスの設定に矛盾が見つかりました。変更を適用するにはアプリケーションを再起動してください",
"invalidJson": "無効な JSON",
"serverLockSingleServer": "サーバーがロックされている場合、1 つのサーバーのみが許可されます"
"serverLockSingleServer": "サーバーがロックされている場合、1 つのサーバーのみが許可されます",
"playbackPausedDueToError": "エラーのため再生が一時停止されました"
},
"filter": {
"mostPlayed": "最も多く再生",
@@ -712,7 +760,9 @@
"id": "ID",
"album": "$t(entity.album, {\"count\": 1})",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "ソート名"
"sortName": "ソート名",
"matchAnd": "すべて",
"matchOr": "いずれか"
},
"page": {
"sidebar": {
@@ -884,7 +934,8 @@
"groupingTypePrimary": "主なリリースタイプ",
"favoriteSongs": "お気に入りの曲",
"topSongsCommunity": "コミュニティ",
"favoriteSongsFrom": "{{title}} のお気に入りの曲"
"favoriteSongsFrom": "{{title}} のお気に入りの曲",
"topSongsPersonal": "個人的"
},
"manageServers": {
"title": "サーバーの管理",
@@ -995,7 +1046,9 @@
"setExpiration": "有効期限を設定",
"success": "共有リンクがクリップボードにコピーされました (またはここをクリックして開きます)",
"expireInvalid": "有効期限は将来の日時である必要があります",
"createFailed": "共有リンクを作成できませんでした (共有は有効になっていますか?)"
"createFailed": "共有リンクを作成できませんでした (共有は有効になっていますか?)",
"copyToClipboard": "クリップボードにコピー: Ctrl+C、Enter",
"successMustClick": "共有リンクが正常に作成されました。開くにはここをクリックしてください"
},
"privateMode": {
"enabled": "プライベートモードが有効になりました。再生ステータスは外部連携から非表示になっています",
@@ -1011,7 +1064,7 @@
"title": "ラジオ局を作成",
"input_homepageUrl": "ホームページ URL",
"input_name": "名前",
"input_streamUrl": "Stream URL"
"input_streamUrl": "ストリームURL"
},
"lyricsExport": {
"export": "歌詞をエクスポート",
@@ -1072,9 +1125,15 @@
"audiobook": "オーディオブック",
"audioDrama": "オーディオドラマ",
"compilation": "コンピレーション",
"djMix": "DJ Mix",
"djMix": "DJミックス",
"demo": "デモ",
"soundtrack": "サウンドトラック"
"soundtrack": "サウンドトラック",
"fieldRecording": "フィールドレコーディング",
"interview": "インタビュー",
"live": "生で",
"mixtape": "ミックステープ",
"remix": "リミックス",
"spokenWord": "スポークン・ワード"
}
},
"datetime": {
@@ -1110,6 +1169,151 @@
},
"visualizer": {
"visualizerType": "ビジュアライザーの種類",
"colors": "色"
"colors": "色",
"cyclePresets": "サイクルプリセット",
"cycleTime": "サイクルタイム(秒)",
"includeAllPresets": "すべてのプリセットを含める",
"ignoredPresets": "無視されたプリセット",
"selectedPresets": "選択されたプリセット",
"randomizeNextPreset": "次のプリセットをランダム化",
"blendTime": "ブレンド時間",
"presets": "プリセット",
"selectPreset": "プリセットを選択",
"applyPreset": "プリセットを適用",
"saveAsPreset": "プリセットとして保存",
"updatePreset": "プリセットを更新",
"copyConfiguration": "設定をコピーする",
"pasteConfiguration": "設定を貼り付け",
"pasteConfigurationPlaceholder": "ここにJSON設定を貼り付けてください...",
"pasteFromClipboard": "クリップボードから貼り付け",
"applyConfiguration": "設定を適用",
"configCopied": "設定をクリップボードにコピーしました",
"configCopyFailed": "設定のコピーに失敗しました",
"configPasted": "加えられた構成 首尾よく",
"configPasteFailed": "設定の適用に失敗しました。形式を確認してください。",
"configPasteReadFailed": "クリップボードからの読み取りに失敗しました",
"presetName": "プリセット名",
"presetNamePlaceholder": "プリセット名を入力",
"general": "一将",
"mode": "モード",
"mode1To8": "モード18",
"mode10": "モード10",
"barSpace": "バースペース",
"lineWidth": "線幅",
"fillAlpha": "アルファ塗りつぶしを設定",
"channelLayout": "チャンネルレイアウト",
"maxFPS": "最大フレームレート",
"opacity": "不透明度",
"customGradients": "カスタムグラデーション",
"addCustomGradient": "カスタムグラデーションを追加",
"gradientName": "グラデーション名",
"gradientNamePlaceholder": "グラデーション名",
"vertical": "垂直",
"horizontal": "水平",
"colorStops": "カラー停止点の数",
"addColor": "色を加える",
"position": "位置",
"level": "レベル",
"remove": "取り除く",
"pasteGradient": "グラデーションを貼り付け",
"pasteGradientPlaceholder": "グラデーションのJSONをここに貼り付けてください...",
"custom": "カスタム",
"builtIn": "組み込み",
"colorMode": "カラーモード",
"gradient": "勾配",
"gradientLeft": "左グラデーション",
"gradientRight": "右方向のグラデーション",
"fft": "高速フーリエ変換",
"fftSize": "FFTサイズ",
"smoothing": "平滑化",
"frequencyRangeAndScaling": "周波数範囲とスケーリング",
"minimumFrequency": "最小周波数",
"maximumFrequency": "最大周波数",
"frequencyScale": "周波数スケール",
"sensitivity": "感度",
"weightingFilter": "重み付けフィルタ",
"minimumDecibels": "最小デシベル",
"maximumDecibels": "最大デシベル",
"linearAmplitude": "線形振幅",
"linearBoost": "リニアブースト",
"peakBehavior": "ピーク時の振る舞い",
"showPeaks": "ピークスを表示",
"fadePeaks": "フェードピークス",
"peakLine": "ピークライン",
"gravity": "重力",
"peakFadeTime": "ピークフェード時間(ミリ秒)",
"peakHoldTime": "ピークホールド時間(ミリ秒)",
"radialSpectrum": "放射状スペクトル",
"radial": "ラジアル",
"radialInvert": "放射状インバート",
"spinSpeed": "回転速度",
"radius": "半径",
"reflexMirror": "反射鏡",
"reflexFit": "リフレックス・フィット",
"reflexRatio": "反射比",
"reflexAlpha": "リフレックス・アルファ",
"reflexBrightness": "反射輝度",
"mirror": "鏡",
"miscellaneousSettings": "その他の設定",
"alphaBars": "アルファバー",
"ansiBands": "ANSIバンド",
"ledBars": "LEDバー",
"trueLeds": "真のLED",
"lumiBars": "ルミ・バー",
"outlineBars": "アウトラインバー",
"roundBars": "丸棒",
"lowResolution": "低解像度",
"splitGradient": "分割グラデーション",
"showFPS": "FPSを表示",
"showScaleX": "X軸スケールを表示",
"noteLabels": "注釈ラベル",
"showScaleY": "Y軸スケールを表示",
"options": {
"mode": {
"0": "[0] 離散周波数",
"1": "[1] 1/24オクターブ / 240バンド",
"2": "[2] 1/12オクターブ / 120バンド",
"3": "[3] 1/8オクターブ / 80バンド",
"4": "[4] 1/6オクターブ / 60バンド",
"5": "[5] 1/4オクターブ / 40バンド",
"6": "[6] 1/3オクターブ / 30バンド",
"7": "[7] 半オクターブ / 20バンド",
"8": "[8] フルオクターブ / 10バンド",
"10": "[10] 折れ線グラフ/面グラフ"
},
"colorMode": {
"gradient": "勾配",
"barIndex": "バー・インデックス",
"barLevel": "バーレベル"
},
"gradient": {
"classic": "クラシック",
"prism": "プリズム",
"rainbow": "虹",
"steelblue": "スチールブルー",
"orangered": "オレンジレッド"
},
"channelLayout": {
"single": "シングル",
"dualCombined": "デュアルコンバインド",
"dualHorizontal": "デュアル水平",
"dualVertical": "デュアルバーティカル"
},
"frequencyScale": {
"none": "なし",
"bark": "樹皮スケール",
"linear": "線形スケール",
"log": "対数スケール",
"mel": "メル尺度"
},
"weightingFilter": {
"none": "なし",
"a": "A",
"b": "B",
"c": "C",
"d": "D",
"z": "Z"
}
}
}
}
+1 -1
View File
@@ -503,7 +503,7 @@
"playlists": "$t(entity.playlist, {\"count\": 2})",
"search": "$t(common.search)",
"settings": "$t(common.setting, {\"count\": 2})",
"shared": "delt $t(entity.playlist, {\"count\": 2})",
"shared": "delte $t(entity.playlist, {\"count\": 2})",
"artists": "$t(entity.artist, {\"count\": 2})",
"myLibrary": "mitt bibliotek"
},
+7 -2
View File
@@ -164,7 +164,8 @@
"example": "przykład",
"filter_multiple": "multi",
"filter_single": "single",
"rename": "zmień nazwę"
"rename": "zmień nazwę",
"newVersionAvailable": "nowa wersja jest dostępna"
},
"entity": {
"genre_one": "gatunek",
@@ -1038,7 +1039,11 @@
"primaryShade": "główny odcień",
"primaryShade_description": "nadpisz główny odcień (0-9) używany dla przycisków, linków i innych głównie pokolorowanych elementów",
"playerItemConfiguration_description": "skonfiguruj jakie elementy są pokazywane i w jakiej kolejności, w odtwarzaczu pełnoekranowym",
"playerItemConfiguration": "konfiguracja elementów odtwarzacza"
"playerItemConfiguration": "konfiguracja elementów odtwarzacza",
"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.",
"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"
},
"table": {
"config": {
+167 -78
View File
@@ -25,19 +25,20 @@
"addOrRemoveFromSelection": "добавить или удалить из выделения",
"createRadioStation": "создать $t(entity.radioStation, {\"count\": 1})",
"deleteRadioStation": "удалить $t(entity.radioStation, {\"count\": 1})",
"selectAll": "выделить все",
"selectAll": "выбрать все",
"downloadStarted": "Начата загрузка {{count}} предметов",
"moveUp": "перейти наверх",
"moveDown": "Перейти вниз",
"moveUp": "перейти вверх",
"moveDown": "перейти вниз",
"holdToMoveToTop": "Удержать для перехода на верх",
"holdToMoveToBottom": "удержать для перехода вниз",
"moveItems": "переместить предметы",
"moveItems": "переместить элементы",
"shuffle": "Перемешать",
"shuffleAll": "перемешать все",
"shuffleSelected": "Смешать выбранное",
"shuffleSelected": "перемешать выбранные",
"viewMore": "Посмотреть больше",
"openApplicationDirectory": "открыть папку приложения",
"selectRangeOfItems": "выбрать диапазон элементов"
"selectRangeOfItems": "выбрать диапазон элементов",
"goToCurrent": "перейти к текущему элементу"
},
"common": {
"backward": "назад",
@@ -136,7 +137,7 @@
"albumPeak": "пик альбома",
"trackPeak": "пик трека",
"additionalParticipants": "Другие участники",
"newVersion": "новая версия приложения установлена ({{version}})",
"newVersion": "установлена новая версия ({{version}})",
"viewReleaseNotes": "Список изменений",
"bitDepth": "Разрядность",
"sampleRate": "частота дискретизации",
@@ -163,7 +164,9 @@
"example": "пример",
"rename": "переименовать",
"explicit": "нецензурная лексика",
"externalLinks": "внешние ссылки"
"externalLinks": "внешние ссылки",
"explicitStatus": "признак нецензурного контента",
"newVersionAvailable": "доступна новая версия"
},
"entity": {
"album_one": "альбом",
@@ -182,8 +185,8 @@
"play_one": "{{count}} прослушивание",
"play_few": "{{count}} прослушивание",
"play_many": "{{count}} прослушивание",
"artist_one": "автор",
"artist_few": "автора",
"artist_one": "исполнитель",
"artist_few": "исполнителя",
"artist_many": "исполнителей",
"folderWithCount_one": "{{count}} папка",
"folderWithCount_few": "{{count}} папки",
@@ -203,9 +206,9 @@
"albumWithCount_one": "{{count}} альбом",
"albumWithCount_few": "{{count}} альбома",
"albumWithCount_many": "{{count}} альбомов",
"favorite_one": "любимый",
"favorite_few": "любимых",
"favorite_many": "любимые",
"favorite_one": "избранное",
"favorite_few": "избранное",
"favorite_many": "избранные",
"artistWithCount_one": "{{count}} автор",
"artistWithCount_few": "{{count}} автора",
"artistWithCount_many": "{{count}} авторов",
@@ -222,9 +225,9 @@
"radioStation_one": "радиостанция",
"radioStation_few": "радиостанции",
"radioStation_many": "радиостанции",
"radioStationWithCount_one": "Радиостанция",
"radioStationWithCount_few": "Радиостанций",
"radioStationWithCount_many": "Радиостанции"
"radioStationWithCount_one": "{{count}} радиостанция",
"radioStationWithCount_few": "{{count}} радиостанции",
"radioStationWithCount_many": "{{count}} радиостанций"
},
"table": {
"config": {
@@ -253,8 +256,6 @@
"trackNumber": "номер трека",
"rowIndex": "номер строки",
"rating": "$t(common.rating)",
"artist": "$t(entity.artist, {\"count\": 1})",
"album": "$t(entity.album, {\"count\": 1})",
"note": "$t(common.note)",
"biography": "$t(common.biography)",
"owner": "$t(common.owner)",
@@ -263,13 +264,10 @@
"playCount": "количество воспроизведений",
"bitrate": "$t(common.bitrate)",
"actions": "$t(common.action_other)",
"genre": "$t(entity.genre, {\"count\": 1})",
"discNumber": "номер диска",
"favorite": "$t(common.favorite)",
"year": "$t(common.year)",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"codec": "$t(common.codec)",
"songCount": "$t(entity.track, {\"count\": 2})",
"titleArtist": "$t(common.title) (артист)"
}
},
@@ -281,9 +279,7 @@
"lastPlayed": "последний",
"releaseDate": "дата выхода",
"title": "название",
"songCount": "$t(entity.track, {\"count\": 2})",
"trackNumber": "трек",
"genre": "$t(entity.genre, {\"count\": 1})",
"path": "путь",
"discNumber": "диск",
"size": "$t(common.size)",
@@ -293,8 +289,6 @@
"biography": "биография",
"codec": "$t(common.codec)",
"comment": "комментарий",
"albumCount": "$t(entity.album, {\"count\": 2})",
"artist": "$t(entity.artist, {\"count\": 1})",
"bitrate": "битрейт",
"channels": "$t(common.channel_other)",
"bpm": "bpm"
@@ -308,7 +302,7 @@
"remotePortError": "произошла ошибка при попытке установить порт удаленного сервера",
"serverRequired": "сервер не выбран",
"authenticationFailed": "не удалось авторизироваться",
"apiRouteError": "невозможно выполнить запрос",
"apiRouteError": "не удалось выполнить запрос",
"genericError": "произошла ошибка",
"credentialsRequired": "введите данные для входа",
"sessionExpiredError": "ваш сеанс истёк",
@@ -331,7 +325,8 @@
"saveQueueFailed": "Не удалось сохранить очередь",
"settingsSyncError": "обнаружены несоответствия между настройками рендерера и основным процессом. перезапустите приложение, чтобы изменения вступили в силу",
"invalidJson": "невалидный JSON",
"serverLockSingleServer": "при заблокированном сервере разрешается использовать только один сервер"
"serverLockSingleServer": "при заблокированном сервере разрешается использовать только один сервер",
"playbackPausedDueToError": "воспроизведение было приостановлено из-за ошибки"
},
"filter": {
"isCompilation": "сборник",
@@ -340,12 +335,10 @@
"dateAdded": "дата добавления",
"communityRating": "рейтинг сообщества",
"favorited": "любимый",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"isFavorited": "любимые",
"bpm": "уд./мин.",
"disc": "диск",
"biography": "биография",
"artist": "$t(entity.artist, {\"count\": 1})",
"duration": "длительность",
"fromYear": "год",
"criticRating": "рейтинг критиков",
@@ -353,13 +346,11 @@
"comment": "комментировать",
"playCount": "количество воспроизведений",
"recentlyUpdated": "обновленные недавно",
"channels": "$t(common.channel_other)",
"recentlyPlayed": "проигрывались недавно",
"owner": "$t(common.owner)",
"title": "название",
"rating": "рейтинг",
"search": "поиск",
"genre": "$t(entity.genre, {\"count\": 1})",
"recentlyAdded": "недавно добавленные",
"note": "заметка",
"name": "название",
@@ -379,6 +370,10 @@
"matchAnd": "и",
"matchOr": "или",
"sortName": "сортировка по имени",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"artist": "$t(entity.artist, {\"count\": 1})",
"channels": "$t(common.channel, {\"count\": 2})",
"genre": "$t(entity.genre, {\"count\": 1})",
"explicitStatus": "$t(common.explicitStatus)"
},
"player": {
@@ -405,7 +400,7 @@
"pause": "пауза",
"queue_clear": "очистить очередь",
"muted": "звук отключён",
"unfavorite": "убрать из любимых",
"unfavorite": "убрать из избранного",
"queue_moveToTop": "переместить выделенное вниз",
"queue_moveToBottom": "переместить выделенное вверх",
"shuffle_off": "перемешивание выключено",
@@ -428,26 +423,18 @@
"sleepTimer_hours": "{{count}} часов",
"sleepTimer_off": "выключено",
"sleepTimer_timeRemaining": "{{time}} осталось",
"sleepTimer_setCustom": "установить таймер"
"sleepTimer_setCustom": "установить таймер",
"sleepTimer_custom": "пользовательский",
"sleepTimer_cancel": "отменить таймер"
},
"page": {
"sidebar": {
"nowPlaying": "сейчас играет",
"playlists": "$t(entity.playlist, {\"count\": 2})",
"search": "$t(common.search)",
"tracks": "$t(entity.track, {\"count\": 2})",
"albums": "$t(entity.album, {\"count\": 2})",
"genres": "$t(entity.genre, {\"count\": 2})",
"folders": "$t(entity.folder, {\"count\": 2})",
"settings": "$t(common.setting, {\"count\": 2})",
"home": "$t(common.home)",
"artists": "$t(entity.artist, {\"count\": 2})",
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
"myLibrary": "Моя библиотека",
"shared": "Публичные плейлисты $t(entity.playlist, {\"count\": 2})",
"collections": "коллекции",
"favorites": "$t(entity.favorite, {\"count\": 2})",
"radio": "$t(entity.radioStation, {\"count\": 2})"
"collections": "коллекции"
},
"fullscreenPlayer": {
"config": {
@@ -475,7 +462,6 @@
"appMenu": {
"selectServer": "список серверов",
"version": "версия {{version}}",
"settings": "$t(common.setting, {\"count\": 2})",
"manageServers": "редактировать список серверов",
"expandSidebar": "развернуть боковую панель",
"collapseSidebar": "Скрыть боковую панель",
@@ -521,10 +507,9 @@
"goToAlbum": "Перейти к $t(entity.album, {\"count\": 1})",
"goToAlbumArtist": "Перейти к $t(entity.albumArtist, {\"count\": 1})",
"goTo": "перейти в",
"moveItems": "$t(action.moveItems)",
"moveToNext": "$t(action.moveToNext)",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"playShuffled": "$t(player.shuffle)"
"playShuffled": "$t(player.shuffle)",
"moveItems": "$t(action.moveItems)"
},
"home": {
"mostPlayed": "слушают чаще всего",
@@ -532,8 +517,7 @@
"title": "$t(common.home)",
"explore": "откройте новое",
"recentlyPlayed": "игралось недавно",
"recentlyReleased": "Новинки",
"genres": "$t(entity.genre, {\"count\": 2})"
"recentlyReleased": "Новинки"
},
"albumDetail": {
"moreFromArtist": "больше от $t(entity.artist, {\"count\": 1})",
@@ -563,19 +547,13 @@
"logger": "Отладка",
"playerFilters": "фильтры проигрывателя",
"queryBuilder": "конструктор очереди",
"discord": "discord"
},
"albumArtistList": {
"title": "$t(entity.albumArtist, {\"count\": 2})"
"discord": "дискорд"
},
"genreList": {
"title": "$t(entity.genre, {\"count\": 2})",
"showAlbums": "показать $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})",
"showTracks": "показать $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})"
},
"trackList": {
"title": "$t(entity.track, {\"count\": 2})",
"genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})",
"artistTracks": "Треки {{artist}}"
},
"globalSearch": {
@@ -589,13 +567,10 @@
"playlist": {
"reorder": "сортировка доступна только по ID"
},
"playlistList": {
"title": "$t(entity.playlist, {\"count\": 2})"
},
"albumList": {
"title": "$t(entity.album, {\"count\": 2})",
"artistAlbums": "альбомы {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album, {\"count\": 2})"
"genreAlbums": "\"{{genre}}\"\n$t(entity.album, {\"count\": 2})",
"title": "$t(entity.album, {\"count\": 2})"
},
"albumArtistDetail": {
"topSongs": "популярные треки",
@@ -631,13 +606,15 @@
"overrideExisting": "переопределить существующий"
},
"releasenotes": {
"commitsSinceStable": "коммито после {{stable}}"
"commitsSinceStable": "коммито после {{stable}}",
"noStableReleaseToCompare": "нет стабильной версии, с которой можно было бы сравнить",
"noNewCommits": "изменения в этом диапазоне отсутствуют"
},
"albumArtistList": {
"title": "$t(entity.albumArtist, {\"count\": 2})"
},
"favorites": {
"title": "$t(entity.favorite, {\"count\": 2})"
},
"folderList": {
"title": "$t(entity.folder, {\"count\": 2})"
}
},
"form": {
@@ -676,9 +653,9 @@
"success": "добавлено: $t(entity.trackWithCount, {\"count\": {{message}} }) в $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "добавить в $t(entity.playlist, {\"count\": 1})",
"input_skipDuplicates": "не добавлять дубликаты",
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"create": "создать $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "для создания нового списка выполните поиск по $t(entity.playlist, {\"count\": 2}) или введите соответствующий текст"
"searchOrCreate": "для создания нового списка выполните поиск по $t(entity.playlist, {\"count\": 2}) или введите соответствующий текст",
"input_playlists": "$t(entity.playlist, {\"count\": 2})"
},
"updateServer": {
"title": "обновление сервера",
@@ -695,8 +672,8 @@
},
"lyricSearch": {
"input_name": "$t(common.name)",
"input_artist": "$t(entity.artist, {\"count\": 1})",
"title": "поиск слов песни"
"title": "поиск слов песни",
"input_artist": "$t(entity.artist, {\"count\": 1})"
},
"editPlaylist": {
"title": "редактировать $t(entity.playlist, {\"count\": 1})",
@@ -746,7 +723,7 @@
"input_played": "воспроизвести фильтр",
"input_played_optionAll": "все треки",
"input_played_optionUnplayed": "только не игранные треки",
"input_played_optionPlayed": "только игранные треки",
"input_played_optionPlayed": "только воспроизведённые треки",
"input_genre": "$t(entity.genre, {\"count\": 1})"
}
},
@@ -786,7 +763,7 @@
"clearCache": "очистить кэш браузера",
"clearQueryCache": "очистить кэш feishin",
"audioDevice": "устройство воспроизведения",
"audioDevice_description": "выберите устройство воспроизведения (только в режиме аудиоплеера web)",
"audioDevice_description": "выберите устройство воспроизведения",
"buttonSize": "размер кнопок панели управления воспроизведением",
"hotkey_volumeDown": "уменьшить громкость",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
@@ -800,9 +777,7 @@
"hotkey_zoomOut": "уменьшить масштаб",
"playbackStyle_optionCrossFade": "затухание",
"replayGainMode": "режим {{ReplayGain}}",
"replayGainMode_optionAlbum": "$t(entity.album, {\"count\": 1})",
"replayGainMode_optionNone": "$t(common.none)",
"replayGainMode_optionTrack": "$t(entity.track, {\"count\": 1})",
"clearQueryCache_description": "так называемая \"мягкая очистка\" feishin: обновляются плейлисты, метаданные треков, но сохранённые тексты треков сбрасываются. настройки, учётные данные и кэшированные изображения сохраняются",
"hotkey_favoriteCurrentSong": "добавить $t(common.currentSong) в избранное",
"globalMediaHotkeys": "глобальные мультимедийные горячие клавиши",
@@ -963,11 +938,122 @@
"releaseChannel_optionBeta": "Бета",
"releaseChannel_optionLatest": "последний",
"releaseChannel": "Тип релиза",
"releaseChannel_description": "Выберите между стабильной или бета версией для автоматического обновления",
"releaseChannel_description": "Выберите между стабильной, бета или альфа (ночной) версией для автоматического обновления",
"discordDisplayType_artistname": "Имя (имена) исполнителя",
"discordDisplayType_description": "это меняет то, что вы слушаете в своем статусе",
"discordDisplayType_songname": "имя песни",
"discordDisplayType": "{{discord}} тип отображения"
"discordDisplayType": "{{discord}} тип отображения",
"autosave": "автоматическое сохранение очереди воспроизведения",
"autosave_description": "включите автоматическое сохранение очереди воспроизведения на вашем сервере. это возможно только при использовании Navidrome/Subsonic, и у вас не может быть смешанной очереди воспроизведения.",
"autosaveCount_description": "количество изменений трека перед сохранением очереди. 1 (минимум) означает каждое изменение песни",
"useThemePrimaryShade": "используйте основной оттенок темы",
"useThemePrimaryShade_description": "используйте основной оттенок, определенный в выбранной теме, для выбора основного цвета",
"primaryShade": "основной оттенок",
"primaryShade_description": "переопределите основной оттенок (0-9), используемый для кнопок, ссылок и других элементов основного цвета",
"analyticsEnable": "Отправлять аналитику использования",
"analyticsEnable_description": "Анонимные данные использования отправляются разработчику с целью улучшения приложения",
"artistReleaseTypeConfiguration": "настройка типов релизов исполнителя",
"artistReleaseTypeConfiguration_description": "настройте, какие типы релизов отображаются и в каком порядке на странице исполнителя",
"automaticUpdates": "Автообновления",
"automaticUpdates_description": "Проверять и устанавливать обновления автоматически",
"discordLinkType_description": "добавляет ссылки на {{lastfm}} / {{musicbrainz}} в Rich Presence {{discord}} для треков и исполнителей. {{musicbrainz}} точнее, но зависит от тегов и не даёт ссылок на артистов {{lastfm}} почти всегда предоставляет ссылку. Без дополнительных сетевых запросов.",
"blurExplicitImages": "скрывать нецензурные изображения размытием",
"blurExplicitImages_description": "обложки с нецензурным контентом будут размываются",
"autosaveCount": "частота автоматического сохранения очереди воспроизведения",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} (запасной источник: {{lastfm}} )",
"discordLinkType": "интеграция {{discord}} статуса",
"discordListening_description": "Показывать статус \"Слушает\" вместо \"Играет\"",
"discordListening": "показывать статус \"Слушает\"",
"discordPausedStatus_description": "если включено, статус будет отображаться даже когда воспроизведение на паузе",
"discordPausedStatus": "показывать расширенный статус при паузе",
"discordRichPresence": "{{discord}}: расширенный статус",
"discordStateIcon": "показывать иконку воспроизведения",
"enableAutoTranslation_description": "включить автоматический перевод при получении текста",
"enableAutoTranslation": "включить автоперевод",
"exportImportSettings_control_description": "экспорт/импорт настроек в JSON",
"exportImportSettings_control_exportText": "экспорт настроек",
"exportImportSettings_control_importText": "импорт настроек",
"exportImportSettings_control_title": "импорт/экспорт настроек",
"exportImportSettings_destructiveWarning": "Импорт настроек полностью заменит ваши текущие настройки. Убедитесь, что все данные выше верны, перед тем как нажать кнопку «Импорт»!",
"exportImportSettings_importBtn": "Импорт настроек",
"exportImportSettings_importModalTitle": "Импорт настроек Feishin",
"exportImportSettings_importSuccess": "Настройки успешно импортированы!",
"exportImportSettings_notValidJSON": "Некорректный JSON-файл",
"exportImportSettings_offendingKeyError": "Неверный ключ \"{{offendingKey}}\": {{reason}}",
"followCurrentSong_description": "Автоматически прокручивать очередь до текущего трека",
"followCurrentSong": "следить за текущим треком",
"homeFeatureStyle_description": "настройка стиля карусели на главном экране",
"homeFeatureStyle": "стиль карусели на главной",
"homeFeatureStyle_optionMultiple": "несколько",
"language": "Язык интерфейса",
"autoDJ": "авто DJ",
"releaseChannel_optionAlpha": "альфа (ночная версия)",
"discordServeImage": "предоставить {{discord}} изображения с сервера",
"discordServeImage_description": "получать обложки треков для {{discord}} rich presence непосредственно с сервера, доступно только для Jellyfin и Navidrome. {{discord}} использует бота для получения картинок, поэтому ваш сервер должен быть доступен из общедоступной сети",
"discordStateIcon_description": "показывать иконку \"играет\" в статусе. иконка паузы показывается всегда когда опция \"Показывать расширенный статус при паузе\" включена",
"homeFeatureStyle_optionSingle": "одиночный",
"hotkey_navigateHome": "перейти на главную",
"lastfm_description": "показывать ссылки Last.fm на страницах артистов и альбомов",
"lastfm": "показывать ссылки last.fm",
"lastfmApiKey_description": "API ключ для {{lastfm}}. необходим для обложек",
"lastfmApiKey": "API ключ {{lastfm}}",
"logLevel": "детализация логов",
"logLevel_description": "определяет степень детализации логов. \"отладка\" отображает все логи, \"ошибка\" отображает только ошибки",
"logLevel_optionDebug": "отладка",
"logLevel_optionError": "ошибка",
"logLevel_optionInfo": "инфо",
"logLevel_optionWarn": "предупреждение",
"mpvExtraParameters": "дополнительные параметры mpv",
"mpvExtraParameters_description": "дополнительные аргументы, передаваемые mpv",
"musicbrainz_description": "показывать ссылки MusicBrainz на страницах артистов и альбомов, где есть ID MusicBrainz",
"musicbrainz": "показывать ссылки MusicBrainz",
"neteaseTranslation_description": "Если включено, получает и отображает переведённые текста песен с NetEase по возможности",
"neteaseTranslation": "Включить переводы NetEase",
"notify": "включить уведомления о песнях",
"notify_description": "показывать уведомления при смене песни",
"pathReplace": "замена пути к файлу",
"pathReplace_description": "заменяет стандартный путь сервера",
"pathReplace_optionRemovePrefix": "убрать префикс",
"pathReplace_optionAddPrefix": "добавить префикс",
"playerFilters": "Фильтр песен в очереди",
"playerFilters_description": "пропускает песни при добавлении в очередь, основываясь на заданном критерии",
"artistRadioCount_description": "определяет количество песен для добавления в радио по артисту/треку",
"artistRadioCount": "кол-во радио по артисту/треку",
"imageResolution": "разрешение изображения",
"imageResolution_description": "разрешение изображений, используемых в приложении. при значении \"0\" будет использоваться исходное разрешение",
"imageResolution_optionItemCard": "карточка элемента",
"imageResolution_optionSidebar": "боковая панель",
"imageResolution_optionHeader": "заголовок",
"imageResolution_optionFullScreenPlayer": "полноэкранный проигрыватель",
"playerbarSlider": "ползунок проигрывателя",
"playerbarSlider_description": "waveform не рекомендуется при слабом подключении к интернету",
"playerbarSliderType_optionSlider": "ползунок",
"playerbarSliderType_optionWaveform": "waveform",
"playerbarWaveformAlign": "положение waveform",
"playerbarWaveformAlign_optionTop": "верх",
"playerbarWaveformAlign_optionCenter": "центр",
"playerbarWaveformAlign_optionBottom": "низ",
"playerbarWaveformBarWidth": "ширина элемента waveform",
"playerbarWaveformGap": "промежутки waveform",
"playerbarWaveformRadius": "радиус waveform",
"preferLocalLyrics_description": "по возможности предпочитать локальные текста песен загружаемым",
"preferLocalLyrics": "предпочтитать локальные текста песен",
"showLyricsInSidebar_description": "к очереди воспроизведения будет добавлена панель, отображающая текст песни",
"showLyricsInSidebar": "показывать текст песни в боковой панели проигрывателя",
"showRatings_description": "определяет, отображается ли в интерфейсе функция звёздного рейтинга",
"showRatings": "показывать звёздный рейтинг",
"enableGridMultiSelect": "включить множественное выделение",
"enableGridMultiSelect_description": "если включено, то позволяет выделять несколько элементов в таблицах. если отключено, то нажатие на элемент таблицы откроет страницу элемента",
"showVisualizerInSidebar_description": "к боковой части проигрывателя будет добавлена панель, показывающая визуализатор",
"showVisualizerInSidebar": "показывать визуализатор в боковой панели",
"combinedLyricsAndVisualizer_description": "Объединяет текст песни и визуализатор в одну панель заместо двух",
"combinedLyricsAndVisualizer": "объединить текст и визуализатор в одну панель",
"preservePitch_description": "сохраняет тональность при изменении скорости воспроизведения",
"preservePitch": "сохранять тональность",
"audioFadeOnStatusChange": "плавное изменение звука",
"audioFadeOnStatusChange_description": "включает эффекты затухания и появления звука при изменении статуса (пауза/проигрывание)",
"preventSleepOnPlayback_description": "запрещает спящий режим экрана, пока играет музыка",
"preventSleepOnPlayback": "не переходить в спящий режим"
},
"releaseType": {
"secondary": {
@@ -979,7 +1065,10 @@
"live": "прямой эфир",
"soundtrack": "саундтрек",
"spokenWord": "Художественная декламация",
"audioDrama": "радиопостановка"
"audioDrama": "радиопостановка",
"fieldRecording": "запись вне студии",
"mixtape": "сборник",
"djMix": "dj микс"
},
"primary": {
"other": "другие",
@@ -1027,7 +1116,7 @@
"updatePreset": "Обновить пресет",
"copyConfiguration": "Копировать Конфигурацию",
"pasteConfiguration": "Вставить Конфигурацию",
"pasteConfigurationPlaceholder": "Вставить JSON конфигурацию",
"pasteConfigurationPlaceholder": "Вставить JSON конфигурацию...",
"pasteFromClipboard": "Вставить из буфера обмена",
"applyConfiguration": "Применить Конфигурацию",
"configCopied": "Конфигурация скопирована в буфер обмена",
+24 -24
View File
@@ -157,7 +157,7 @@
"mood": "氛围",
"rename": "重命名",
"filter_multiple": "多项",
"newVersionAvailable": "a new version is available"
"newVersionAvailable": "新版本现已可用"
},
"entity": {
"albumArtist_other": "专辑艺术家",
@@ -1212,21 +1212,21 @@
"options": {
"channelLayout": {
"single": "单项",
"dualCombined": "Dual-Combined",
"dualHorizontal": "Dual-Horizontal",
"dualVertical": "Dual-Vertical"
"dualCombined": "双重组合",
"dualHorizontal": "双水平",
"dualVertical": "双垂直"
},
"mode": {
"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"
"1": "[1] 1/24倍频程 / 240频段",
"2": "[2] 1/12 倍频程 / 120 频段",
"3": "[3] 1/8倍频程 / 80频段",
"4": "[4] 1/6倍频程 / 60频段",
"5": "[5] 1/4倍频程 / 40频段",
"6": "[6] 1/3倍频程 / 30频段",
"7": "[7] 半倍频程 / 20 频段",
"8": "[8] 全倍频程 / 10 频段",
"10": "[10] 折线图 / 面积图"
},
"colorMode": {
"gradient": "渐变",
@@ -1242,10 +1242,10 @@
},
"frequencyScale": {
"none": "无",
"bark": "Bark Scale",
"linear": "Linear Scale",
"log": "Log Scale",
"mel": "Mel Scale"
"bark": "树皮鳞片",
"linear": "线性刻度",
"log": "对数刻度",
"mel": "梅尔刻度"
},
"weightingFilter": {
"none": "无",
@@ -1304,13 +1304,13 @@
"showScaleX": "显示比例尺 X",
"noteLabels": "笔记标签",
"showScaleY": "显示比例尺 Y",
"alphaBars": "Alpha Bars",
"ansiBands": "ANSI Bands",
"ledBars": "LED Bars",
"trueLeds": "True LEDs",
"lumiBars": "Lumi Bars",
"outlineBars": "Outline Bars",
"roundBars": "Round Bars"
"alphaBars": "Alpha ",
"ansiBands": "ANSI 频段",
"ledBars": "LED 灯条",
"trueLeds": "真正的LED",
"lumiBars": "Lumi ",
"outlineBars": "轮廓栏",
"roundBars": "圆条"
},
"queryBuilder": {
"standardTags": "标准标签",
+7 -2
View File
@@ -776,7 +776,11 @@
"autosave": "自動儲存播放佇列",
"autosave_description": "啟用自動將播放佇列儲存到您的伺服器。這只有在使用Navidrome/Subsonic時才可使用,並且您不能有混合播放佇列。",
"autosaveCount": "自動播放佇列儲存頻率",
"autosaveCount_description": "在儲存佇列之前,有多少曲目更改。1(最小)表示每次歌曲更改"
"autosaveCount_description": "在儲存佇列之前,有多少曲目更改。1(最小)表示每次歌曲更改",
"spotify_description": "在藝人與專輯頁面顯示Spotify的連結",
"spotify": "顯示Spotify的連結",
"nativeSpotify_description": "在Spotify應用程式中開啟,而非在瀏覽器中開啟",
"nativeSpotify": "使用Spotify應用程式"
},
"table": {
"config": {
@@ -908,7 +912,8 @@
"moveToNext": "移至下一項",
"openIn": {
"lastfm": "在Last.fm開啟",
"musicbrainz": "在MusicBrainz開啟"
"musicbrainz": "在MusicBrainz開啟",
"spotify": "在Spotify中開啟"
},
"downloadStarted": "已開始下載 {{count}} 項內容",
"moveItems": "移動項目",
+8
View File
@@ -5,6 +5,10 @@ import { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from
import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib';
import { getLyricsBySongId as getNetease, getSearchResults as searchNetease } from './netease';
import { orderSearchResults } from './shared';
import {
getLyricsBySongId as getSimpMusic,
getSearchResults as searchSimpMusic,
} from './simpmusic';
import { Song } from '/@/shared/types/domain-types';
@@ -12,6 +16,7 @@ export enum LyricSource {
GENIUS = 'Genius',
LRCLIB = 'lrclib.net',
NETEASE = 'NetEase',
SIMPMUSIC = 'SimpMusic',
}
export type FullLyricsMetadata = Omit<InternetProviderLyricResponse, 'id' | 'lyrics' | 'source'> & {
@@ -66,12 +71,14 @@ const SEARCH_FETCHERS: Record<LyricSource, SearchFetcher> = {
[LyricSource.GENIUS]: searchGenius,
[LyricSource.LRCLIB]: searchLrcLib,
[LyricSource.NETEASE]: searchNetease,
[LyricSource.SIMPMUSIC]: searchSimpMusic,
};
const GET_FETCHERS: Record<LyricSource, GetFetcher> = {
[LyricSource.GENIUS]: getGenius,
[LyricSource.LRCLIB]: getLrcLib,
[LyricSource.NETEASE]: getNetease,
[LyricSource.SIMPMUSIC]: getSimpMusic,
};
const MAX_CACHED_ITEMS = 10;
@@ -191,6 +198,7 @@ const searchRemoteLyrics = async (params: LyricSearchQuery) => {
[LyricSource.GENIUS]: [],
[LyricSource.LRCLIB]: [],
[LyricSource.NETEASE]: [],
[LyricSource.SIMPMUSIC]: [],
};
for (const item of allSearchResults) {
results[item.source].push(item);
+126
View File
@@ -0,0 +1,126 @@
import axios, { AxiosResponse } from 'axios';
import {
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
LyricSource,
} from '.';
import { orderSearchResults } from './shared';
const API_URL = 'https://api-lyrics.simpmusic.org/v1';
const TIMEOUT_MS = 5000;
export interface SimpMusicLyric {
albumName?: string;
artistName: string;
durationSeconds?: number;
id: string;
plainLyric?: string;
richSyncLyrics?: string;
songTitle: string;
syncedLyrics?: string;
videoId: string;
vote?: number;
}
export interface SimpMusicSearchResponse {
data: SimpMusicLyric[];
success: boolean;
}
export async function getLyricsBySongId(songId: string): Promise<null | string> {
let result: AxiosResponse;
try {
result = await axios.get(`${API_URL}/${songId}`, {
timeout: TIMEOUT_MS,
});
} catch (e) {
console.error('SimpMusic lyrics request errored:', (e as Error)?.message);
return null;
}
const firstLyric = (result.data.data?.[0] ?? null) as null | SimpMusicLyric;
if (!firstLyric) return null;
return firstLyric.syncedLyrics || firstLyric.plainLyric || null;
}
export async function getSearchResults(
params: LyricSearchQuery,
): Promise<InternetProviderLyricSearchResponse[] | null> {
let result: AxiosResponse<SimpMusicSearchResponse>;
if (!params.name) return null;
try {
result = await axios.get<SimpMusicSearchResponse>(`${API_URL}/search`, {
params: {
q: params.name,
},
timeout: TIMEOUT_MS,
});
} catch (e) {
console.error('SimpMusic search errored:', (e as Error)?.message);
return null;
}
if (!result.data?.data) return null;
const songResults: InternetProviderLyricSearchResponse[] = result.data.data.map((song) => ({
artist: song.artistName,
id: song.videoId,
isSync: song.syncedLyrics ? true : false,
name: song.songTitle,
source: LyricSource.SIMPMUSIC,
}));
return orderSearchResults({ params, results: songResults });
}
export async function query(
params: LyricSearchQuery,
): Promise<InternetProviderLyricResponse | null> {
if (!params.name) return null;
let search: AxiosResponse<SimpMusicSearchResponse>;
try {
search = await axios.get<SimpMusicSearchResponse>(`${API_URL}/search`, {
params: {
q: params.name,
},
timeout: TIMEOUT_MS,
});
} catch (e) {
console.error('SimpMusic search errored:', (e as Error).message);
return null;
}
const first = search.data?.data?.[0];
if (!first) return null;
let lyric: AxiosResponse<SimpMusicLyric>;
try {
lyric = await axios.get<SimpMusicLyric>(`${API_URL}/${first.videoId}`, {
timeout: TIMEOUT_MS,
});
} catch (e) {
console.error('SimpMusic lyrics fetch errored:', (e as Error).message);
return null;
}
const lyrics = lyric.data.syncedLyrics || lyric.data.plainLyric || null;
if (!lyrics) return null;
return {
artist: lyric.data.artistName,
id: lyric.data.videoId,
lyrics,
name: lyric.data.songTitle,
source: LyricSource.SIMPMUSIC,
};
}
+5
View File
@@ -272,6 +272,11 @@ if (isLinux() && !process.argv.some((a) => a.startsWith('--password-store='))) {
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 tray: null | Tray = null;
let exitFromTray = false;
@@ -636,7 +636,7 @@ export const NavidromeController: InternalControllerEndpoint = {
throw new Error('Failed to get play queue');
}
const { changedBy, current, items, position, updatedAt } = res.body.data;
const { changedBy, current, items = [], position, updatedAt } = res.body.data; // if there is no queue saved, items is undefined
const entries = items.map((song) =>
ndNormalize.song(
@@ -744,46 +744,78 @@ export const NavidromeController: InternalControllerEndpoint = {
args.context?.pathReplaceWith,
);
},
getSongList: async (args) => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).getSongList({
query: {
_end: query.startIndex + (query.limit || -1),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: songListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
album_id: query.albumIds,
genre_id: query.genreIds,
[getArtistSongKey(apiClientProps.server)]: query.artistIds ?? query.albumArtistIds,
...(hasFeature(apiClientProps.server, ServerFeature.TRACK_YES_NO_RATING_FILTER) &&
query.hasRating !== undefined
? { has_rating: query.hasRating }
: {}),
library_id: getLibraryId(query.musicFolderId),
starred: query.favorite,
title: query.searchTerm,
year: query.maxYear || query.minYear,
...query._custom,
...excludeMissing(apiClientProps.server),
},
});
const ALBUM_IDS_BATCH_SIZE = 500;
const albumIds = query.albumIds;
const shouldBatch = albumIds && albumIds.length > ALBUM_IDS_BATCH_SIZE;
if (res.status !== 200) {
throw new Error('Failed to get song list');
const fetchAlbums = async (albumIdBatch: string[] | undefined) => {
const res = await ndApiClient(apiClientProps).getSongList({
query: {
_end: query.startIndex + (query.limit || -1),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: songListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
album_id: albumIdBatch ?? query.albumIds,
genre_id: query.genreIds,
[getArtistSongKey(apiClientProps.server)]:
query.artistIds ?? query.albumArtistIds,
...(hasFeature(
apiClientProps.server,
ServerFeature.TRACK_YES_NO_RATING_FILTER,
) && query.hasRating !== undefined
? { has_rating: query.hasRating }
: {}),
library_id: getLibraryId(query.musicFolderId),
starred: query.favorite,
title: query.searchTerm,
year: query.maxYear || query.minYear,
...query._custom,
...excludeMissing(apiClientProps.server),
},
});
if (res.status !== 200) {
throw new Error('Failed to get song list');
}
return {
items: res.body.data.map((song) =>
ndNormalize.song(
song,
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
),
),
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
};
if (shouldBatch && albumIds) {
const batches: string[][] = [];
for (let i = 0; i < albumIds.length; i += ALBUM_IDS_BATCH_SIZE) {
batches.push(albumIds.slice(i, i + ALBUM_IDS_BATCH_SIZE));
}
const results = await Promise.all(batches.map((batch) => fetchAlbums(batch)));
return {
items: results.flatMap((r) => r.items),
startIndex: query?.startIndex ?? 0,
totalRecordCount: results.reduce((sum, r) => sum + r.totalRecordCount, 0),
};
}
const albums = await fetchAlbums(undefined);
return {
items: res.body.data.map((song) =>
ndNormalize.song(
song,
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
),
),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
items: albums.items,
startIndex: query?.startIndex ?? 0,
totalRecordCount: albums.totalRecordCount,
};
},
getSongListCount: async ({ apiClientProps, query }) =>
@@ -1137,15 +1137,15 @@ export const SubsonicController: InternalControllerEndpoint = {
const res = await ssApiClient(apiClientProps).getPlayQueueByIndex();
if (res.status !== 200) {
throw new Error('Failed to get random songs');
throw new Error('Failed to get play queue');
}
const { changed, changedBy, currentIndex, entry, position, username } =
res.body.playQueueByIndex;
res.body.playQueueByIndex || {}; // if there is no queue saved, playQueueByIndex may be undefined from a bug in Navidrome
return {
changed,
changedBy,
changed: changed ?? '',
changedBy: changedBy ?? '',
currentIndex: currentIndex ?? 0,
entry:
entry?.map((song) =>
@@ -1157,13 +1157,13 @@ export const SubsonicController: InternalControllerEndpoint = {
),
) || [],
positionMs: position ?? 0,
username,
username: username ?? '',
};
} else {
const res = await ssApiClient(apiClientProps).getPlayQueue();
if (res.status !== 200) {
throw new Error('Failed to get random songs');
throw new Error('Failed to get play queue');
}
const { changed, changedBy, current, entry, position, username } = res.body.playQueue;
@@ -40,7 +40,14 @@
display: grid;
grid-template-columns: repeat(var(--cards-to-show, 2), minmax(0, 1fr));
gap: var(--theme-spacing-md);
contain: layout paint;
content-visibility: auto;
overflow: hidden;
will-change: transform;
}
.card {
min-height: 0;
}
.page-indicator {
@@ -64,6 +64,7 @@ export interface ItemCardProps {
enableMultiSelect?: boolean;
enableNavigation?: boolean;
imageAsLink?: boolean;
imageFetchPriority?: 'auto' | 'high' | 'low';
internalState?: ItemListStateActions;
isRound?: boolean;
itemType: LibraryItem;
@@ -80,6 +81,7 @@ export const ItemCard = ({
enableMultiSelect,
enableNavigation = true,
imageAsLink,
imageFetchPriority,
internalState,
isRound,
itemType,
@@ -102,6 +104,7 @@ export const ItemCard = ({
enableMultiSelect={enableMultiSelect}
enableNavigation={enableNavigation}
imageAsLink={imageAsLink}
imageFetchPriority={imageFetchPriority}
imageUrl={imageUrl}
internalState={internalState}
isRound={isRound}
@@ -121,6 +124,7 @@ export const ItemCard = ({
enableMultiSelect={enableMultiSelect}
enableNavigation={enableNavigation}
imageAsLink={imageAsLink}
imageFetchPriority={imageFetchPriority}
imageUrl={imageUrl}
internalState={internalState}
isRound={isRound}
@@ -140,6 +144,7 @@ export const ItemCard = ({
enableExpansion={enableExpansion}
enableNavigation={enableNavigation}
imageAsLink={imageAsLink}
imageFetchPriority={imageFetchPriority}
imageUrl={imageUrl}
internalState={internalState}
isRound={isRound}
@@ -157,6 +162,7 @@ export interface ItemCardDerivativeProps extends Omit<ItemCardProps, 'type'> {
enableExpansion?: boolean;
enableNavigation?: boolean;
imageAsLink?: boolean;
imageFetchPriority?: 'auto' | 'high' | 'low';
imageUrl: string | undefined;
internalState?: ItemListStateActions;
rows: DataRow[];
@@ -171,6 +177,7 @@ const CompactItemCard = ({
enableMultiSelect,
enableNavigation,
imageAsLink,
imageFetchPriority,
internalState,
isRound,
itemType,
@@ -365,6 +372,7 @@ const CompactItemCard = ({
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
fetchPriority={imageFetchPriority}
id={data?.imageId}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
@@ -475,6 +483,7 @@ const DefaultItemCard = ({
enableExpansion,
enableNavigation,
imageAsLink,
imageFetchPriority,
internalState,
isRound,
itemType,
@@ -602,6 +611,7 @@ const DefaultItemCard = ({
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
fetchPriority={imageFetchPriority}
id={data?.imageId}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
@@ -710,6 +720,7 @@ const PosterItemCard = ({
enableMultiSelect,
enableNavigation,
imageAsLink,
imageFetchPriority,
internalState,
isRound,
itemType,
@@ -902,6 +913,7 @@ const PosterItemCard = ({
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
fetchPriority={imageFetchPriority}
id={(data as { imageId: string })?.imageId}
itemType={itemType}
src={(data as { imageUrl: string })?.imageUrl}
@@ -7,6 +7,7 @@ import React, {
memo,
ReactElement,
Ref,
RefObject,
useCallback,
useEffect,
useId,
@@ -723,9 +724,21 @@ const VirtualizedTableGrid = ({
VirtualizedTableGrid.displayName = 'VirtualizedTableGrid';
function shallowEqualNumberArrays(a: number[], b: number[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, nextProps) => {
return (
prevProps.calculatedColumnWidths === nextProps.calculatedColumnWidths &&
shallowEqualNumberArrays(
prevProps.calculatedColumnWidths,
nextProps.calculatedColumnWidths,
) &&
prevProps.cellPadding === nextProps.cellPadding &&
prevProps.controls === nextProps.controls &&
prevProps.data === nextProps.data &&
@@ -741,6 +754,7 @@ const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, next
prevProps.enableScrollShadow === nextProps.enableScrollShadow &&
prevProps.enableSelection === nextProps.enableSelection &&
prevProps.enableVerticalBorders === nextProps.enableVerticalBorders &&
prevProps.getItem === nextProps.getItem &&
prevProps.getRowHeight === nextProps.getRowHeight &&
prevProps.groups === nextProps.groups &&
prevProps.headerHeight === nextProps.headerHeight &&
@@ -867,6 +881,396 @@ interface ItemTableListProps {
startRowIndex?: number;
}
const ItemTableListStickyUI = memo(
({
calculatedColumnWidths,
CellComponent,
containerRef,
data,
enableHeader,
enableStickyGroupRows,
enableStickyHeader,
getRowHeightWrapper,
groups,
headerHeight,
internalState,
parsedColumns,
pinnedLeftColumnCount,
pinnedLeftColumnRef,
pinnedRightColumnCount,
pinnedRightColumnRef,
pinnedRowRef,
rowHeight,
rowRef,
size,
stickyHeaderItemProps,
totalColumnCount,
}: {
calculatedColumnWidths: number[];
CellComponent: JSXElementConstructor<CellComponentProps<TableItemProps>>;
containerRef: RefObject<HTMLDivElement | null>;
data: unknown[];
enableHeader: boolean;
enableStickyGroupRows: boolean;
enableStickyHeader: boolean;
getRowHeightWrapper: (index: number) => number;
groups?: TableGroupHeader[];
headerHeight: number;
internalState: ItemListStateActions;
parsedColumns: ReturnType<typeof parseTableColumns>;
pinnedLeftColumnCount: number;
pinnedLeftColumnRef: RefObject<HTMLDivElement | null>;
pinnedRightColumnCount: number;
pinnedRightColumnRef: RefObject<HTMLDivElement | null>;
pinnedRowRef: RefObject<HTMLDivElement | null>;
rowHeight?: ((index: number, cellProps: TableItemProps) => number) | number;
rowRef: RefObject<HTMLDivElement | null>;
size: 'compact' | 'default' | 'large';
stickyHeaderItemProps: TableItemProps;
totalColumnCount: number;
}) => {
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const stickyGroupRowRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderLeftRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderMainRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRightRef = useRef<HTMLDivElement | null>(null);
const { shouldShowStickyHeader, stickyTop } = useStickyTableHeader({
containerRef,
enabled: enableHeader && enableStickyHeader,
headerRef: pinnedRowRef,
mainGridRef: rowRef,
pinnedLeftColumnRef,
pinnedRightColumnRef,
stickyHeaderMainRef,
});
useStickyHeaderPositioning({
containerRef,
shouldShowStickyHeader,
stickyHeaderRef,
});
const {
shouldShowStickyGroupRow,
stickyGroupIndex,
stickyTop: stickyGroupTop,
} = useStickyTableGroupRows({
containerRef,
enabled: enableStickyGroupRows && !!groups && groups.length > 0,
getRowHeight: getRowHeightWrapper,
groups,
headerHeight,
mainGridRef: rowRef,
shouldShowStickyHeader,
stickyHeaderTop: stickyTop,
});
const shouldRenderStickyGroupRow = shouldShowStickyGroupRow;
useStickyGroupRowPositioning({
containerRef,
shouldRenderStickyGroupRow,
stickyGroupRowRef,
});
const StickyHeader = useMemo(() => {
if (!shouldShowStickyHeader || !enableHeader) {
return null;
}
const pinnedLeftWidth = calculatedColumnWidths
.slice(0, pinnedLeftColumnCount)
.reduce((sum, width) => sum + width, 0);
const mainWidth = calculatedColumnWidths
.slice(pinnedLeftColumnCount, pinnedLeftColumnCount + totalColumnCount)
.reduce((sum, width) => sum + width, 0);
const pinnedRightWidth = calculatedColumnWidths
.slice(pinnedLeftColumnCount + totalColumnCount)
.reduce((sum, width) => sum + width, 0);
return (
<div
className={styles.stickyHeader}
ref={stickyHeaderRef}
style={{
top: `${stickyTop}px`,
}}
>
<div className={styles.stickyHeaderRow}>
{pinnedLeftColumnCount > 0 && (
<div
className={clsx(
styles.stickyHeaderSection,
styles.stickyHeaderPinnedLeft,
)}
ref={stickyHeaderLeftRef}
style={{
flex: '0 1 auto',
minWidth: `${pinnedLeftWidth}px`,
overflow: 'hidden',
}}
>
{parsedColumns
.filter((col) => col.pinned === 'left')
.map((col) => {
const columnIndex = parsedColumns.findIndex(
(c) => c === col,
);
return (
<CellComponent
ariaAttributes={{
'aria-colindex': columnIndex + 1,
role: 'gridcell',
}}
columnIndex={columnIndex}
key={col.id}
rowIndex={0}
style={{
height: headerHeight,
width: calculatedColumnWidths[columnIndex],
}}
{...stickyHeaderItemProps}
/>
);
})}
</div>
)}
<div
className={clsx(
styles.stickyHeaderSection,
styles.stickyHeaderMain,
styles.noScrollbar,
)}
ref={stickyHeaderMainRef}
style={{
flex: '1 1 auto',
minWidth: 0,
overflowX: 'auto',
overflowY: 'hidden',
}}
>
<div
style={{
display: 'flex',
minWidth: `${mainWidth}px`,
}}
>
{parsedColumns
.filter((col) => col.pinned === null)
.map((col) => {
const columnIndex = parsedColumns.findIndex(
(c) => c === col,
);
return (
<CellComponent
ariaAttributes={{
'aria-colindex': columnIndex + 1,
role: 'gridcell',
}}
columnIndex={columnIndex}
key={col.id}
rowIndex={0}
style={{
flexShrink: 0,
height: headerHeight,
width: calculatedColumnWidths[columnIndex],
}}
{...stickyHeaderItemProps}
/>
);
})}
</div>
</div>
{pinnedRightColumnCount > 0 && (
<div
className={clsx(
styles.stickyHeaderSection,
styles.stickyHeaderPinnedRight,
)}
ref={stickyHeaderRightRef}
style={{
flex: '0 1 auto',
minWidth: `${pinnedRightWidth}px`,
overflow: 'hidden',
}}
>
{parsedColumns
.filter((col) => col.pinned === 'right')
.map((col) => {
const columnIndex = parsedColumns.findIndex(
(c) => c === col,
);
return (
<CellComponent
ariaAttributes={{
'aria-colindex': columnIndex + 1,
role: 'gridcell',
}}
columnIndex={columnIndex}
key={col.id}
rowIndex={0}
style={{
height: headerHeight,
width: calculatedColumnWidths[columnIndex],
}}
{...stickyHeaderItemProps}
/>
);
})}
</div>
)}
</div>
</div>
);
}, [
shouldShowStickyHeader,
enableHeader,
stickyTop,
calculatedColumnWidths,
pinnedLeftColumnCount,
pinnedRightColumnCount,
totalColumnCount,
parsedColumns,
headerHeight,
CellComponent,
stickyHeaderItemProps,
]);
const groupRowHeight = useMemo(() => {
if (stickyGroupIndex === null || !groups) {
const height = size === 'compact' ? 40 : size === 'large' ? 88 : 64;
return typeof rowHeight === 'number' ? rowHeight : height;
}
let cumulativeDataIndex = 0;
const headerOffset = enableHeader ? 1 : 0;
for (let i = 0; i < stickyGroupIndex; i++) {
cumulativeDataIndex += groups[i].itemCount;
}
const groupHeaderIndex = headerOffset + cumulativeDataIndex + stickyGroupIndex;
return getRowHeightWrapper(groupHeaderIndex);
}, [stickyGroupIndex, groups, getRowHeightWrapper, enableHeader, rowHeight, size]);
const StickyGroupRow = useMemo(() => {
if (!shouldRenderStickyGroupRow || stickyGroupIndex === null || !groups) {
return null;
}
const group = groups[stickyGroupIndex];
const originalData = data.filter((item) => item !== null);
let cumulativeDataIndex = 0;
for (let i = 0; i < stickyGroupIndex; i++) {
cumulativeDataIndex += groups[i].itemCount;
}
const groupContent = group.render({
data: originalData,
groupIndex: stickyGroupIndex,
index: 0,
internalState,
startDataIndex: cumulativeDataIndex,
});
const pinnedLeftWidth = calculatedColumnWidths
.slice(0, pinnedLeftColumnCount)
.reduce((sum, width) => sum + width, 0);
const mainWidth = calculatedColumnWidths
.slice(pinnedLeftColumnCount, pinnedLeftColumnCount + totalColumnCount)
.reduce((sum, width) => sum + width, 0);
const pinnedRightWidth = calculatedColumnWidths
.slice(pinnedLeftColumnCount + totalColumnCount)
.reduce((sum, width) => sum + width, 0);
const totalTableWidth = calculatedColumnWidths.reduce((sum, width) => sum + width, 0);
const actualStickyTop = stickyGroupTop;
return (
<div
className={styles.stickyGroupRow}
ref={stickyGroupRowRef}
style={{
top: `${actualStickyTop}px`,
}}
>
<div className={styles.stickyGroupRowContent}>
{pinnedLeftColumnCount > 0 && (
<div
className={styles.stickyGroupRowSection}
style={{ width: `${pinnedLeftWidth}px` }}
>
<div
style={{
height: groupRowHeight,
width: `${pinnedLeftWidth}px`,
}}
>
{groupContent}
</div>
</div>
)}
<div
className={styles.stickyGroupRowSection}
style={{
marginLeft: pinnedLeftColumnCount > 0 ? 0 : '-2rem',
marginRight: '-2rem',
paddingLeft: pinnedLeftColumnCount > 0 ? 0 : '2rem',
paddingRight: '2rem',
width: `${mainWidth}px`,
}}
>
<div
style={{
height: groupRowHeight,
marginLeft: pinnedLeftWidth > 0 ? `-${pinnedLeftWidth}px` : 0,
width: `${totalTableWidth}px`,
}}
>
{groupContent}
</div>
</div>
{pinnedRightColumnCount > 0 && (
<div
className={styles.stickyGroupRowSection}
style={{ width: `${pinnedRightWidth}px` }}
>
<div
style={{
height: groupRowHeight,
width: `${pinnedRightWidth}px`,
}}
/>
</div>
)}
</div>
</div>
);
}, [
shouldRenderStickyGroupRow,
stickyGroupIndex,
groups,
data,
internalState,
calculatedColumnWidths,
pinnedLeftColumnCount,
pinnedRightColumnCount,
totalColumnCount,
groupRowHeight,
stickyGroupTop,
]);
return (
<>
{StickyHeader}
{StickyGroupRow}
</>
);
},
);
ItemTableListStickyUI.displayName = 'ItemTableListStickyUI';
const BaseItemTableList = ({
activeRowId,
autoFitColumns = false,
@@ -966,28 +1370,6 @@ const BaseItemTableList = ({
const containerRef = useRef<HTMLDivElement | null>(null);
const mergedContainerRef = useMergedRef(containerRef, focusRef);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const stickyGroupRowRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderLeftRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderMainRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRightRef = useRef<HTMLDivElement | null>(null);
const { shouldShowStickyHeader, stickyTop } = useStickyTableHeader({
containerRef: containerRef,
enabled: enableHeader && enableStickyHeader,
headerRef: pinnedRowRef,
mainGridRef: rowRef,
pinnedLeftColumnRef,
pinnedRightColumnRef,
stickyHeaderMainRef,
});
useStickyHeaderPositioning({
containerRef,
shouldShowStickyHeader,
stickyHeaderRef,
});
useContainerWidthTracking({
autoFitColumns,
containerRef,
@@ -1089,30 +1471,6 @@ const BaseItemTableList = ({
[enableHeader, headerHeight, rowHeight, pinnedRowCount, size],
);
const {
shouldShowStickyGroupRow,
stickyGroupIndex,
stickyTop: stickyGroupTop,
} = useStickyTableGroupRows({
containerRef: containerRef,
enabled: enableStickyGroupRows && !!groups && groups.length > 0,
getRowHeight: getRowHeightWrapper,
groups,
headerHeight,
mainGridRef: rowRef,
shouldShowStickyHeader,
stickyHeaderTop: stickyTop,
});
// Show sticky group row whenever it should be shown
const shouldRenderStickyGroupRow = shouldShowStickyGroupRow;
useStickyGroupRowPositioning({
containerRef,
shouldRenderStickyGroupRow,
stickyGroupRowRef,
});
const getDataFn = useCallback(() => {
return data;
}, [data]);
@@ -1247,291 +1605,6 @@ const BaseItemTableList = ({
],
);
const StickyHeader = useMemo(() => {
if (!shouldShowStickyHeader || !enableHeader) {
return null;
}
const pinnedLeftWidth = calculatedColumnWidths
.slice(0, pinnedLeftColumnCount)
.reduce((sum, width) => sum + width, 0);
const mainWidth = calculatedColumnWidths
.slice(pinnedLeftColumnCount, pinnedLeftColumnCount + totalColumnCount)
.reduce((sum, width) => sum + width, 0);
const pinnedRightWidth = calculatedColumnWidths
.slice(pinnedLeftColumnCount + totalColumnCount)
.reduce((sum, width) => sum + width, 0);
return (
<div
className={styles.stickyHeader}
ref={stickyHeaderRef}
style={{
top: `${stickyTop}px`,
}}
>
<div className={styles.stickyHeaderRow}>
{pinnedLeftColumnCount > 0 && (
<div
className={clsx(
styles.stickyHeaderSection,
styles.stickyHeaderPinnedLeft,
)}
ref={stickyHeaderLeftRef}
style={{
flex: '0 1 auto',
minWidth: `${pinnedLeftWidth}px`,
overflow: 'hidden',
}}
>
{parsedColumns
.filter((col) => col.pinned === 'left')
.map((col) => {
const columnIndex = parsedColumns.findIndex((c) => c === col);
return (
<CellComponent
ariaAttributes={{
'aria-colindex': columnIndex + 1,
role: 'gridcell',
}}
columnIndex={columnIndex}
key={col.id}
rowIndex={0}
style={{
height: headerHeight,
width: calculatedColumnWidths[columnIndex],
}}
{...stickyHeaderItemProps}
/>
);
})}
</div>
)}
<div
className={clsx(
styles.stickyHeaderSection,
styles.stickyHeaderMain,
styles.noScrollbar,
)}
ref={stickyHeaderMainRef}
style={{
flex: '1 1 auto',
minWidth: 0,
overflowX: 'auto',
overflowY: 'hidden',
}}
>
<div
style={{
display: 'flex',
minWidth: `${mainWidth}px`,
}}
>
{parsedColumns
.filter((col) => col.pinned === null)
.map((col) => {
const columnIndex = parsedColumns.findIndex((c) => c === col);
return (
<CellComponent
ariaAttributes={{
'aria-colindex': columnIndex + 1,
role: 'gridcell',
}}
columnIndex={columnIndex}
key={col.id}
rowIndex={0}
style={{
flexShrink: 0,
height: headerHeight,
width: calculatedColumnWidths[columnIndex],
}}
{...stickyHeaderItemProps}
/>
);
})}
</div>
</div>
{pinnedRightColumnCount > 0 && (
<div
className={clsx(
styles.stickyHeaderSection,
styles.stickyHeaderPinnedRight,
)}
ref={stickyHeaderRightRef}
style={{
flex: '0 1 auto',
minWidth: `${pinnedRightWidth}px`,
overflow: 'hidden',
}}
>
{parsedColumns
.filter((col) => col.pinned === 'right')
.map((col) => {
const columnIndex = parsedColumns.findIndex((c) => c === col);
return (
<CellComponent
ariaAttributes={{
'aria-colindex': columnIndex + 1,
role: 'gridcell',
}}
columnIndex={columnIndex}
key={col.id}
rowIndex={0}
style={{
height: headerHeight,
width: calculatedColumnWidths[columnIndex],
}}
{...stickyHeaderItemProps}
/>
);
})}
</div>
)}
</div>
</div>
);
}, [
shouldShowStickyHeader,
enableHeader,
stickyTop,
calculatedColumnWidths,
pinnedLeftColumnCount,
pinnedRightColumnCount,
totalColumnCount,
parsedColumns,
headerHeight,
CellComponent,
stickyHeaderItemProps,
]);
// Calculate group row height (use same as regular table row height)
const groupRowHeight = useMemo(() => {
if (stickyGroupIndex === null || !groups) {
const height = size === 'compact' ? 40 : size === 'large' ? 88 : 64;
return typeof rowHeight === 'number' ? rowHeight : height;
}
// Calculate the row index for this group header
let cumulativeDataIndex = 0;
const headerOffset = enableHeader ? 1 : 0;
for (let i = 0; i < stickyGroupIndex; i++) {
cumulativeDataIndex += groups[i].itemCount;
}
const groupHeaderIndex = headerOffset + cumulativeDataIndex + stickyGroupIndex;
// Use the regular row height for group rows
return getRowHeightWrapper(groupHeaderIndex);
}, [stickyGroupIndex, groups, getRowHeightWrapper, enableHeader, rowHeight, size]);
const StickyGroupRow = useMemo(() => {
if (!shouldRenderStickyGroupRow || stickyGroupIndex === null || !groups) {
return null;
}
const group = groups[stickyGroupIndex];
const originalData = data.filter((item) => item !== null);
let cumulativeDataIndex = 0;
for (let i = 0; i < stickyGroupIndex; i++) {
cumulativeDataIndex += groups[i].itemCount;
}
const groupContent = group.render({
data: originalData,
groupIndex: stickyGroupIndex,
index: 0,
internalState,
startDataIndex: cumulativeDataIndex,
});
const pinnedLeftWidth = calculatedColumnWidths
.slice(0, pinnedLeftColumnCount)
.reduce((sum, width) => sum + width, 0);
const mainWidth = calculatedColumnWidths
.slice(pinnedLeftColumnCount, pinnedLeftColumnCount + totalColumnCount)
.reduce((sum, width) => sum + width, 0);
const pinnedRightWidth = calculatedColumnWidths
.slice(pinnedLeftColumnCount + totalColumnCount)
.reduce((sum, width) => sum + width, 0);
const totalTableWidth = calculatedColumnWidths.reduce((sum, width) => sum + width, 0);
// Calculate the actual sticky position accounting for sticky header
const actualStickyTop = stickyGroupTop;
return (
<div
className={styles.stickyGroupRow}
ref={stickyGroupRowRef}
style={{
top: `${actualStickyTop}px`,
}}
>
<div className={styles.stickyGroupRowContent}>
{pinnedLeftColumnCount > 0 && (
<div
className={styles.stickyGroupRowSection}
style={{ width: `${pinnedLeftWidth}px` }}
>
<div
style={{
height: groupRowHeight,
width: `${pinnedLeftWidth}px`,
}}
>
{groupContent}
</div>
</div>
)}
<div
className={styles.stickyGroupRowSection}
style={{
marginLeft: pinnedLeftColumnCount > 0 ? 0 : '-2rem',
marginRight: '-2rem',
paddingLeft: pinnedLeftColumnCount > 0 ? 0 : '2rem',
paddingRight: '2rem',
width: `${mainWidth}px`,
}}
>
<div
style={{
height: groupRowHeight,
marginLeft: pinnedLeftWidth > 0 ? `-${pinnedLeftWidth}px` : 0,
width: `${totalTableWidth}px`,
}}
>
{groupContent}
</div>
</div>
{pinnedRightColumnCount > 0 && (
<div
className={styles.stickyGroupRowSection}
style={{ width: `${pinnedRightWidth}px` }}
>
<div
style={{
height: groupRowHeight,
width: `${pinnedRightWidth}px`,
}}
/>
</div>
)}
</div>
</div>
);
}, [
shouldRenderStickyGroupRow,
stickyGroupIndex,
groups,
data,
internalState,
calculatedColumnWidths,
pinnedLeftColumnCount,
pinnedRightColumnCount,
totalColumnCount,
groupRowHeight,
stickyGroupTop,
]);
useListHotkeys({
controls,
focused,
@@ -1607,8 +1680,30 @@ const BaseItemTableList = ({
{...animationProps.fadeIn}
transition={{ duration: enableEntranceAnimation ? 0.3 : 0, ease: 'anticipate' }}
>
{StickyHeader}
{StickyGroupRow}
<ItemTableListStickyUI
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={optimizedCellComponent}
containerRef={containerRef}
data={data}
enableHeader={!!enableHeader}
enableStickyGroupRows={!!enableStickyGroupRows}
enableStickyHeader={!!enableStickyHeader}
getRowHeightWrapper={getRowHeightWrapper}
groups={groups}
headerHeight={headerHeight}
internalState={internalState}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowRef={pinnedRowRef}
rowHeight={rowHeight}
rowRef={rowRef}
size={size}
stickyHeaderItemProps={stickyHeaderItemProps}
totalColumnCount={totalColumnCount}
/>
<MemoizedVirtualizedTableGrid
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={optimizedCellComponent}
@@ -298,6 +298,8 @@ interface AlbumMetadataExternalLinksProps {
lastFM: boolean;
mbzId?: null | string;
musicBrainz: boolean;
nativeSpotify: boolean;
spotify: boolean;
}
const AlbumMetadataExternalLinks = ({
@@ -307,10 +309,12 @@ const AlbumMetadataExternalLinks = ({
lastFM,
mbzId,
musicBrainz,
nativeSpotify,
spotify,
}: AlbumMetadataExternalLinksProps) => {
const { t } = useTranslation();
if (!externalLinks || (!lastFM && !musicBrainz)) return null;
if (!externalLinks || (!lastFM && !musicBrainz && !spotify)) return null;
return (
<Stack gap="xs">
@@ -358,6 +362,28 @@ const AlbumMetadataExternalLinks = ({
variant="subtle"
/>
) : null}
{spotify && (
<ActionIcon
component="a"
href={
nativeSpotify
? `spotify:search:${encodeURIComponent(albumArtist || '')}%20${encodeURIComponent(albumName || '')}`
: `https://open.spotify.com/search/${encodeURIComponent(albumArtist || '')}%20${encodeURIComponent(albumName || '')}`
}
icon="brandSpotify"
iconProps={{
fill: 'default',
size: 'xl',
}}
radius="md"
rel="noopener noreferrer"
target={nativeSpotify ? undefined : '_blank'}
tooltip={{
label: t('action.openIn.spotify'),
}}
variant="subtle"
/>
)}
</Group>
</Stack>
);
@@ -370,7 +396,7 @@ export const AlbumDetailContent = () => {
albumQueries.detail({ query: { id: albumId }, serverId: server.id }),
);
const { externalLinks, lastFM, musicBrainz } = useExternalLinks();
const { externalLinks, lastFM, musicBrainz, nativeSpotify, spotify } = useExternalLinks();
const comment = detailQuery?.data?.comment;
@@ -403,6 +429,8 @@ export const AlbumDetailContent = () => {
lastFM={lastFM}
mbzId={mbzId || undefined}
musicBrainz={musicBrainz}
nativeSpotify={nativeSpotify}
spotify={spotify}
/>
</div>
</div>
@@ -31,6 +31,7 @@ export function AlbumGridCarousel(props: AlbumGridCarouselProps) {
data={album}
enableDrag
enableExpansion
imageFetchPriority="low"
itemType={LibraryItem.ALBUM}
rows={rows}
type="poster"
@@ -70,6 +70,7 @@ const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps & { rows: DataRow[]
data={album}
enableDrag
enableExpansion
imageFetchPriority="low"
itemType={LibraryItem.ALBUM}
rows={rows}
type="poster"
@@ -167,6 +167,9 @@ const getSettingsProperties = (): SettingsProperties => {
'settings.lyrics.sources.netease': ignoreWeb(
settings.lyrics.sources.includes(LyricSource.NETEASE),
),
'settings.lyrics.sources.simpmusic': ignoreWeb(
settings.lyrics.sources.includes(LyricSource.SIMPMUSIC),
),
'settings.minimizeToTray': ignoreWeb(settings.window.minimizeToTray),
// 'settings.musicBrainz': settings.general.musicBrainz,
'settings.nativeAspectRatio': settings.general.nativeAspectRatio,
@@ -4,8 +4,8 @@ import {
useSuspenseQuery,
UseSuspenseQueryResult,
} from '@tanstack/react-query';
import { LayoutGroup, motion } from 'motion/react';
import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { motion } from 'motion/react';
import { memo, Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createSearchParams, generatePath, Link, useLocation, useParams } from 'react-router';
@@ -890,7 +890,9 @@ interface AlbumArtistMetadataExternalLinksProps {
lastFM: boolean;
mbzId?: null | string;
musicBrainz: boolean;
nativeSpotify: boolean;
order?: number;
spotify: boolean;
}
const AlbumArtistMetadataExternalLinks = ({
@@ -899,11 +901,13 @@ const AlbumArtistMetadataExternalLinks = ({
lastFM,
mbzId,
musicBrainz,
nativeSpotify,
order,
spotify,
}: AlbumArtistMetadataExternalLinksProps) => {
const { t } = useTranslation();
if (!externalLinks || (!lastFM && !musicBrainz)) return null;
if (!externalLinks || (!lastFM && !musicBrainz && !spotify)) return null;
return (
<Grid.Col order={order} span={12}>
@@ -948,6 +952,27 @@ const AlbumArtistMetadataExternalLinks = ({
variant="subtle"
/>
) : null}
{spotify && (
<ActionIcon
component="a"
href={
nativeSpotify
? `spotify:search:${encodeURIComponent(artistName || '')}`
: `https://open.spotify.com/search/${encodeURIComponent(artistName || '')}`
}
icon="brandSpotify"
iconProps={{
fill: 'default',
size: 'xl',
}}
rel="noopener noreferrer"
target={nativeSpotify ? undefined : '_blank'}
tooltip={{
label: t('action.openIn.spotify'),
}}
variant="subtle"
/>
)}
</Group>
</Stack>
</Grid.Col>
@@ -1050,7 +1075,7 @@ export const AlbumArtistDetailContent = ({
}: AlbumArtistDetailContentProps) => {
const artistItems = useArtistItems();
const artistRadioCount = useArtistRadioCount();
const { externalLinks, lastFM, musicBrainz } = useExternalLinks();
const { externalLinks, lastFM, musicBrainz, nativeSpotify, spotify } = useExternalLinks();
const { albumArtistId, artistId } = useParams() as {
albumArtistId?: string;
artistId?: string;
@@ -1136,14 +1161,16 @@ export const AlbumArtistDetailContent = ({
genres={detailQuery.data?.genres}
order={genresOrder}
/>
{externalLinks && (lastFM || musicBrainz) && (
{externalLinks && (lastFM || musicBrainz || spotify) && (
<AlbumArtistMetadataExternalLinks
artistName={detailQuery.data?.name}
externalLinks={externalLinks}
lastFM={lastFM}
mbzId={mbzId}
musicBrainz={musicBrainz}
nativeSpotify={nativeSpotify}
order={externalLinksOrder}
spotify={spotify}
/>
)}
{enabledItem.biography && (
@@ -1182,8 +1209,8 @@ export const AlbumArtistDetailContent = ({
interface AlbumSectionProps {
albums: Album[];
controls: ItemControls;
cq: ReturnType<typeof useContainerQuery>;
enableExpansion?: boolean;
itemsPerRow: number;
releaseType: string;
rows: DataRow[] | undefined;
title: React.ReactNode | string;
@@ -1203,18 +1230,16 @@ const getItemsPerRow = (cq: ReturnType<typeof useContainerQuery>) => {
return 2;
};
const AlbumSection = ({
const AlbumSection = memo(function AlbumSection({
albums,
controls,
cq,
enableExpansion,
itemsPerRow,
releaseType,
rows,
title,
}: AlbumSectionProps) => {
}: AlbumSectionProps) {
const { t } = useTranslation();
const itemsPerRow = getItemsPerRow(cq);
const albumCount = albums.length;
const [showAll, setShowAll] = useState(false);
const player = usePlayer();
@@ -1259,6 +1284,27 @@ const AlbumSection = ({
},
});
const DisplayedAlbumsMemo = useMemo(() => {
return displayedAlbums.map((album) => (
<motion.div
className={styles.albumGridItem}
key={album.id}
layoutId={`${releaseType}-${album.id}`}
>
<MemoizedItemCard
controls={controls}
data={album}
enableDrag
enableExpansion={enableExpansion ?? true}
itemType={LibraryItem.ALBUM}
rows={rows}
type="poster"
withControls
/>
</motion.div>
));
}, [controls, displayedAlbums, enableExpansion, releaseType, rows]);
return (
<Stack gap="md">
<div className={styles.albumSectionTitle}>
@@ -1320,30 +1366,7 @@ const AlbumSection = ({
} as React.CSSProperties
}
>
{displayedAlbums.map((album) => (
<motion.div
className={styles.albumGridItem}
key={album.id}
layout
layoutId={`${releaseType}-${album.id}`}
transition={{
duration: 0.5,
ease: 'easeInOut',
layout: { duration: 0.5, ease: 'easeInOut' },
}}
>
<MemoizedItemCard
controls={controls}
data={album}
enableDrag
enableExpansion={enableExpansion ?? true}
itemType={LibraryItem.ALBUM}
rows={rows}
type="poster"
withControls
/>
</motion.div>
))}
{DisplayedAlbumsMemo}
</div>
{hasMoreAlbums && !showAll && (
<Group justify="center" w="100%">
@@ -1354,7 +1377,7 @@ const AlbumSection = ({
)}
</Stack>
);
};
});
import { useArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped';
@@ -1412,6 +1435,23 @@ const ArtistAlbums = ({ albumsQuery, order }: ArtistAlbumsProps) => {
],
]);
const itemsPerRow = getItemsPerRow(cq);
const ReleaseTypeEntriesMemo = useMemo(() => {
return releaseTypeEntries.map(({ albums, displayName, releaseType }) => (
<AlbumSection
albums={albums}
controls={controls}
enableExpansion
itemsPerRow={itemsPerRow}
key={releaseType}
releaseType={releaseType}
rows={rows}
title={displayName}
/>
));
}, [releaseTypeEntries, itemsPerRow, controls, rows]);
return (
<Grid.Col order={order} span={12}>
<Stack gap="md">
@@ -1459,22 +1499,7 @@ const ArtistAlbums = ({ albumsQuery, order }: ArtistAlbumsProps) => {
</Group>
{releaseTypeEntries.length > 0 && (
<div className={styles.albumSectionContainer} ref={cq.ref}>
{cq.isCalculated && (
<LayoutGroup>
{releaseTypeEntries.map(({ albums, displayName, releaseType }) => (
<AlbumSection
albums={albums}
controls={controls}
cq={cq}
enableExpansion
key={releaseType}
releaseType={releaseType}
rows={rows}
title={displayName}
/>
))}
</LayoutGroup>
)}
{cq.isCalculated && <>{ReleaseTypeEntriesMemo}</>}
</div>
)}
</Stack>
@@ -0,0 +1,46 @@
import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';
import {
CLIENT_SIDE_SONG_FILTERS,
ListSortByDropdownControlled,
} from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
import { useAppStore } from '/@/renderer/store/app.store';
import { Divider } from '/@/shared/components/divider/divider';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
export const AlbumArtistDetailFavoriteSongsListHeaderFilters = () => {
const albumArtistDetailFavoriteSongsSort = useAppStore(
(state) => state.albumArtistDetailFavoriteSongsSort,
);
const setAlbumArtistDetailFavoriteSongsSort = useAppStore(
(state) => state.actions.setAlbumArtistDetailFavoriteSongsSort,
);
const sortBy = albumArtistDetailFavoriteSongsSort.sortBy;
const sortOrder = albumArtistDetailFavoriteSongsSort.sortOrder;
return (
<Flex justify="space-between">
<Group gap="sm" w="100%">
<ListSortByDropdownControlled
filters={CLIENT_SIDE_SONG_FILTERS}
itemType={LibraryItem.SONG}
setSortBy={(value) =>
setAlbumArtistDetailFavoriteSongsSort(value as SongListSort, sortOrder)
}
sortBy={sortBy}
/>
<Divider orientation="vertical" />
<ListSortOrderToggleButtonControlled
setSortOrder={(value) =>
setAlbumArtistDetailFavoriteSongsSort(sortBy, value as SortOrder)
}
sortOrder={sortOrder}
/>
<Divider orientation="vertical" />
<ListSearchInput />
</Group>
</Flex>
);
};
@@ -69,6 +69,7 @@ const BaseAlbumArtistInfiniteCarousel = (props: AlbumArtistCarouselProps & { row
controls={controls}
data={albumArtist}
enableDrag
imageFetchPriority="low"
itemType={LibraryItem.ALBUM_ARTIST}
rows={rows}
type="poster"
@@ -10,12 +10,19 @@ import { ItemControls } from '/@/renderer/components/item-list/types';
import { ListContext } from '/@/renderer/context/list-context';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { AlbumArtistDetailFavoriteSongsListHeader } from '/@/renderer/features/artists/components/album-artist-detail-favorite-songs-list-header';
import { AlbumArtistDetailFavoriteSongsListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-detail-favorite-songs-list-header-filters';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { applyClientSideSongFilters } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { FilterBar } from '/@/renderer/features/shared/components/filter-bar';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { FILTER_KEYS, searchLibraryItems } from '/@/renderer/features/shared/utils';
import { usePlayerSong } from '/@/renderer/store';
import { useAppStore } from '/@/renderer/store/app.store';
import { useCurrentServer } from '/@/renderer/store/auth.store';
import { useSettingsStore } from '/@/renderer/store/settings.store';
import { sortSongList } from '/@/shared/api/utils';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
import { ItemListKey, Play } from '/@/shared/types/types';
@@ -43,12 +50,31 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
}),
);
const itemCount = favoriteSongsQuery?.data?.items?.length || 0;
const songs = useMemo(
() => favoriteSongsQuery?.data?.items || [],
[favoriteSongsQuery?.data?.items],
);
const albumArtistDetailFavoriteSongsSort = useAppStore(
(state) => state.albumArtistDetailFavoriteSongsSort,
);
const sortBy = albumArtistDetailFavoriteSongsSort.sortBy;
const sortOrder = albumArtistDetailFavoriteSongsSort.sortOrder;
const { searchTerm } = useSearchTermFilter();
const sortedSongs = useMemo(() => {
const filtered = applyClientSideSongFilters(songs, {
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm,
});
const searched = searchTerm
? searchLibraryItems(filtered, searchTerm, LibraryItem.SONG)
: filtered;
return sortSongList(searched, sortBy, sortOrder);
}, [songs, sortBy, sortOrder, searchTerm]);
const itemCount = sortedSongs.length;
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table);
const currentSong = usePlayerSong();
const player = usePlayer();
@@ -96,7 +122,7 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
<AnimatedPage>
<ListContext.Provider value={providerValue}>
<AlbumArtistDetailFavoriteSongsListHeader
data={songs}
data={sortedSongs}
itemCount={itemCount}
title={detailQuery?.data?.name || 'Unknown'}
/>
@@ -109,16 +135,19 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
<AnimatedPage>
<ListContext.Provider value={providerValue}>
<AlbumArtistDetailFavoriteSongsListHeader
data={songs}
data={sortedSongs}
itemCount={itemCount}
title={detailQuery?.data?.name || 'Unknown'}
/>
<FilterBar>
<AlbumArtistDetailFavoriteSongsListHeaderFilters />
</FilterBar>
<ItemTableList
activeRowId={currentSongId}
autoFitColumns={tableConfig.autoFitColumns}
CellComponent={ItemTableListColumn}
columns={columns}
data={songs}
data={sortedSongs}
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
enableDrag
enableExpansion={false}
@@ -90,7 +90,7 @@ const LoginRoute = () => {
value: serverUrl,
},
{
isValid: remoteUrl !== '',
isValid: true,
key: 'REMOTE_URL',
value: remoteUrl,
},
@@ -109,9 +109,8 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
const extraParameters: string[] = [...mpvExtraParameters];
if (mpvAudioDeviceId) {
extraParameters.push(`--audio-device=${mpvAudioDeviceId}`);
}
const audioDevice = mpvAudioDeviceId?.trim() || 'auto';
extraParameters.push(`--audio-device=${audioDevice}`);
await mpvPlayer?.initialize({
extraParameters,
@@ -289,6 +288,9 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
replaceMpvQueue(transcode);
},
onQueueCleared: () => {},
onQueueRestored: () => {
replaceMpvQueue(transcode);
},
},
[transcode],
);
@@ -119,6 +119,8 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
player2Ref.current?.getInternalPlayer()?.pause();
},
play() {
player1Ref.current?.getInternalPlayer()?.pause();
player2Ref.current?.getInternalPlayer()?.pause();
if (playerNum === 1) {
player1Ref.current?.getInternalPlayer()?.play();
} else {
@@ -157,6 +159,11 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
const volume1 = convertToLogVolume(internalVolume1);
const volume2 = convertToLogVolume(internalVolume2);
const pauseBothPlayers = useCallback(() => {
player1Ref.current?.getInternalPlayer()?.pause();
player2Ref.current?.getInternalPlayer()?.pause();
}, []);
const handleOnError = (
playerRef: React.RefObject<null | ReactPlayer>,
onEnded: () => void,
@@ -186,6 +193,7 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
networkRetryCountRef.current += 1;
const audio = target;
setTimeout(() => {
pauseBothPlayers();
audio.load();
audio.play().catch(() => {
logFn.error(logMsg[LogCategory.PLAYER].playbackError, {
@@ -202,6 +210,7 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
return;
}
pauseBothPlayers();
if (error?.code === MediaError.MEDIA_ERR_DECODE) {
onEnded();
} else {
@@ -217,6 +226,20 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
networkRetryCount2.current = 0;
}, [src1, src2]);
// When not transitioning, ensure only the active player can play (e.g. after seek/prev during transition)
useEffect(() => {
if (isTransitioning) return;
if (playerStatus !== PlayerStatus.PLAYING) {
pauseBothPlayers();
return;
}
if (playerNum === 1) {
player2Ref.current?.getInternalPlayer()?.pause();
} else {
player1Ref.current?.getInternalPlayer()?.pause();
}
}, [isTransitioning, playerNum, playerStatus, pauseBothPlayers]);
useEffect(() => {
const player1 = player1Ref.current?.getInternalPlayer();
if (player1 && player1 instanceof HTMLAudioElement) {
@@ -118,7 +118,7 @@ export const FullScreenPlayerQueue = () => {
<FullScreenSimilarSongs />
</div>
) : activeTab === 'lyrics' ? (
<Lyrics />
<Lyrics fadeOutNoLyricsMessage={false} />
) : activeTab === 'visualizer' && type === PlayerType.WEB && webAudio ? (
<Suspense fallback={<></>}>
{visualizerType === 'butterchurn' ? (
@@ -556,7 +556,7 @@ export const MobileFullscreenPlayer = () => {
/>
</div>
<div className={styles.lyricsContent}>
<Lyrics />
<Lyrics fadeOutNoLyricsMessage={false} />
</div>
</motion.div>
)}
@@ -13,7 +13,9 @@ import {
} from '/@/renderer/store/sleep-timer.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Divider } from '/@/shared/components/divider/divider';
import { Flex } from '/@/shared/components/flex/flex';
import { Grid } from '/@/shared/components/grid/grid';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Popover } from '/@/shared/components/popover/popover';
@@ -30,6 +32,8 @@ const PRESET_OPTIONS = [
{ minutes: 45, mode: 'timed' as const },
{ minutes: 60, mode: 'timed' as const },
{ minutes: 120, mode: 'timed' as const },
{ minutes: 180, mode: 'timed' as const },
{ minutes: 240, mode: 'timed' as const },
];
function formatRemaining(totalSeconds: number): string {
@@ -209,7 +213,7 @@ export const SleepTimerButton = () => {
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs" p="xs">
<Text fw="600" size="sm" ta="center">
<Text fw="600" pb="md" size="sm" ta="center">
{t('player.sleepTimer', { postProcess: 'titleCase' })}
</Text>
@@ -249,21 +253,49 @@ export const SleepTimerButton = () => {
</Flex>
)}
{PRESET_OPTIONS.map((option, index) => (
<Button
fullWidth
justify="flex-start"
key={index}
onClick={(e) => {
e.stopPropagation();
handlePreset(option);
}}
size="xs"
variant="subtle"
>
{getPresetLabel(option)}
</Button>
))}
{PRESET_OPTIONS.filter((option) => option.mode === 'endOfSong').map(
(option, index) => (
<Button
fullWidth
justify="flex-start"
key={index}
onClick={(e) => {
e.stopPropagation();
handlePreset(option);
}}
size="xs"
variant="outline"
>
{getPresetLabel(option)}
</Button>
),
)}
<Divider my="md" />
<Grid gutter="xs">
{PRESET_OPTIONS.filter((option) => option.mode === 'timed').map(
(option, index) => (
<Grid.Col key={index} span={4}>
<Button
fullWidth
justify="flex-start"
key={index}
onClick={(e) => {
e.stopPropagation();
handlePreset(option);
}}
size="xs"
variant="outline"
>
{getPresetLabel(option)}
</Button>
</Grid.Col>
),
)}
</Grid>
<Divider my="md" />
{!showCustom ? (
<Button
@@ -274,7 +306,8 @@ export const SleepTimerButton = () => {
setShowCustom(true);
}}
size="xs"
variant="subtle"
ta="center"
variant="outline"
>
{t('player.sleepTimer_custom', { postProcess: 'sentenceCase' })}
</Button>
@@ -46,45 +46,29 @@ export const useSaveQueue = () => {
throw new Error(t('error.serverRequired', { postProcess: 'sentenceCase' }));
}
const { player, queue } = usePlayerStore.getState();
let uniqueIds: string[] = [];
const state = usePlayerStore.getState();
const queue = state.getQueue();
if (queue.shuffled.length > 0) {
for (const shuffledIndex of queue.shuffled) {
uniqueIds.push(queue.default[shuffledIndex]);
}
} else {
uniqueIds = queue.default;
}
if (queue.items.some((item) => item._serverId !== serverId)) {
toast.error({
message: t('error.multipleServerSaveQueueError', {
postProcess: 'sentenceCase',
}),
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
const songs: string[] = [];
if (uniqueIds.length > 0) {
for (const song of uniqueIds) {
if (queue.songs[song]._serverId !== serverId) {
toast.error({
message: t('error.multipleServerSaveQueueError', {
postProcess: 'sentenceCase',
}),
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
throw new Error(
`${t('error.multipleServerSaveQueueError', { postProcess: 'sentenceCase' })}`,
);
}
songs?.push(queue.songs[song].id);
}
throw new Error(
`${t('error.multipleServerSaveQueueError', { postProcess: 'sentenceCase' })}`,
);
}
try {
await api.controller.savePlayQueue({
apiClientProps: { serverId },
query: {
currentIndex: queue.default.length > 0 ? player.index : undefined,
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
songs,
songs: queue.items.map((item) => item.id),
},
});
@@ -3,8 +3,13 @@ import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { infiniteLoaderDataQueryKey } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { CreatePlaylistArgs, CreatePlaylistResponse } from '/@/shared/types/domain-types';
import {
CreatePlaylistArgs,
CreatePlaylistResponse,
LibraryItem,
} from '/@/shared/types/domain-types';
export const useCreatePlaylist = (args: MutationHookArgs) => {
const { options } = args || {};
@@ -17,12 +22,19 @@ export const useCreatePlaylist = (args: MutationHookArgs) => {
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_args, variables) => {
...options,
onSuccess: (data, variables, context) => {
const { serverId } = variables.apiClientProps;
queryClient.invalidateQueries({
exact: false,
queryKey: queryKeys.playlists.list(variables.apiClientProps.serverId),
queryKey: queryKeys.playlists.root(serverId),
});
queryClient.invalidateQueries({
exact: false,
queryKey: infiniteLoaderDataQueryKey(serverId, LibraryItem.PLAYLIST),
});
options?.onSuccess?.(data, variables, context);
},
...options,
});
};
@@ -3,13 +3,18 @@ import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { infiniteLoaderDataQueryKey } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';
import {
applyDeletePlaylistOptimisticUpdates,
PreviousQueryData,
restorePlaylistQueryData,
} from '/@/renderer/features/playlists/mutations/playlist-optimistic-updates';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { DeletePlaylistArgs, DeletePlaylistResponse } from '/@/shared/types/domain-types';
import {
DeletePlaylistArgs,
DeletePlaylistResponse,
LibraryItem,
} from '/@/shared/types/domain-types';
export const useDeletePlaylist = (args: MutationHookArgs) => {
const { options } = args || {};
@@ -34,13 +39,20 @@ export const useDeletePlaylist = (args: MutationHookArgs) => {
});
return applyDeletePlaylistOptimisticUpdates(queryClient, variables);
},
onSuccess: (_data, variables) => {
...options,
onSuccess: (data, variables, context) => {
const { serverId } = variables.apiClientProps;
queryClient.invalidateQueries({
exact: false,
queryKey: queryKeys.playlists.list(variables.apiClientProps.serverId),
queryKey: queryKeys.playlists.root(serverId),
});
queryClient.invalidateQueries({
exact: false,
queryKey: infiniteLoaderDataQueryKey(serverId, LibraryItem.PLAYLIST),
});
options?.onSuccess?.(data, variables, context);
},
...options,
},
);
};
@@ -275,7 +275,9 @@ const PlaylistDetailSongListRoute = () => {
<ListWithSidebarContainer>
<ListWithSidebarContainer.SidebarPortal>
<PlaylistSongListFiltersSidebar />
<Suspense fallback={<Spinner container />}>
<PlaylistSongListFiltersSidebar />
</Suspense>
</ListWithSidebarContainer.SidebarPortal>
<Suspense fallback={<Spinner container />}>
<PlaylistDetailSongListContent />
@@ -601,6 +601,48 @@ export const ApplicationSettings = memo(() => {
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: (
<Switch
@@ -61,7 +61,7 @@ export const SidebarReorder = () => {
return (
<DraggableItems
description="setting.sidebarCollapsedNavigation"
description="setting.sidebarConfiguration"
itemLabels={SIDEBAR_ITEMS}
items={mergedSidebarItems as unknown as SidebarItemType[]}
setItems={setSidebarItems}
@@ -86,6 +86,7 @@ const BaseSongInfiniteCarousel = (props: SongCarouselProps & { rows: DataRow[] }
controls={controls}
data={song}
enableDrag
imageFetchPriority="low"
itemType={LibraryItem.SONG}
rows={rows}
type="poster"
@@ -154,6 +154,8 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [
{ key: 'FS_GENERAL_LASTFM_API_KEY', path: ['general', 'lastfmApiKey'], type: 'string' },
{ key: 'FS_GENERAL_LAST_FM', path: ['general', 'lastFM'], type: 'bool' },
{ key: 'FS_GENERAL_MUSIC_BRAINZ', path: ['general', 'musicBrainz'], 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_NATIVE_ASPECT_RATIO', path: ['general', 'nativeAspectRatio'], type: 'bool' },
{
key: 'FS_GENERAL_PLAYERBAR_OPEN_DRAWER',
+6
View File
@@ -477,6 +477,7 @@ export const GeneralSettingsSchema = z.object({
lastfmApiKey: z.string(),
musicBrainz: z.boolean(),
nativeAspectRatio: z.boolean(),
nativeSpotify: z.boolean(),
passwordStore: z.string().optional(),
pathReplace: z.string(),
pathReplaceWith: z.string(),
@@ -499,6 +500,7 @@ export const GeneralSettingsSchema = z.object({
sidebarPlaylistSorting: z.boolean(),
sideQueueType: SideQueueTypeSchema,
skipButtons: SkipButtonsSchema,
spotify: z.boolean(),
theme: z.nativeEnum(AppTheme),
themeDark: z.nativeEnum(AppTheme),
themeLight: z.nativeEnum(AppTheme),
@@ -1129,6 +1131,7 @@ const initialState: SettingsState = {
lastfmApiKey: '',
musicBrainz: true,
nativeAspectRatio: false,
nativeSpotify: false,
passwordStore: undefined,
pathReplace: '',
pathReplaceWith: '',
@@ -1161,6 +1164,7 @@ const initialState: SettingsState = {
skipBackwardSeconds: 5,
skipForwardSeconds: 10,
},
spotify: true,
theme: AppTheme.DEFAULT_DARK,
themeDark: AppTheme.DEFAULT_DARK,
themeLight: AppTheme.DEFAULT_LIGHT,
@@ -2549,6 +2553,8 @@ export const useExternalLinks = () =>
externalLinks: state.general.externalLinks,
lastFM: state.general.lastFM,
musicBrainz: state.general.musicBrainz,
nativeSpotify: state.general.nativeSpotify,
spotify: state.general.spotify,
}),
shallow,
);
+1 -1
View File
@@ -709,7 +709,7 @@ const queue = z.object({
createdAt: z.string(),
current: z.number(),
id: z.string(),
items: z.array(song),
items: z.array(song).optional(),
position: z.number(),
updatedAt: z.string(),
userId: z.string(),
+10 -8
View File
@@ -667,14 +667,16 @@ const playQueue = z.object({
});
const playQueueByIndex = z.object({
playQueueByIndex: z.object({
changed: z.string(),
changedBy: z.string(),
currentIndex: z.number().optional(),
entry: song.array().optional(),
position: z.number().optional(),
username: z.string(),
}),
playQueueByIndex: z
.object({
changed: z.string(),
changedBy: z.string(),
currentIndex: z.number().optional(),
entry: song.array().optional(),
position: z.number().optional(),
username: z.string(),
})
.optional(),
});
const internetRadioStation = z.object({
+2 -1
View File
@@ -125,7 +125,7 @@ import {
import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';
import { PiMouseLeftClickFill, PiMouseRightClickFill } from 'react-icons/pi';
import { RiPlayListAddLine, RiRepeat2Line, RiRepeatOneLine } from 'react-icons/ri';
import { SiMusicbrainz } from 'react-icons/si';
import { SiMusicbrainz, SiSpotify } from 'react-icons/si';
import styles from './icon.module.css';
@@ -156,6 +156,7 @@ export const AppIcon = {
brandGitHub: LuGithub,
brandLastfm: FaLastfmSquare,
brandMusicBrainz: SiMusicbrainz,
brandSpotify: SiSpotify,
cache: LuCloudDownload,
check: LuCheck,
clipboardCopy: LuClipboardCopy,
+10 -2
View File
@@ -20,6 +20,8 @@ import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
import { useInViewport } from '/@/shared/hooks/use-in-viewport';
import { ImageRequest } from '/@/shared/types/domain-types';
const loadedImageCacheKeys = new Set<string>();
export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
containerClassName?: string;
enableAnimation?: boolean;
@@ -78,10 +80,14 @@ export function BaseImage({
() => imageRequest ?? (src ? { cacheKey: src, url: src } : undefined),
[imageRequest, src],
);
const isInSessionCache = Boolean(
rawImageRequest?.cacheKey && loadedImageCacheKeys.has(rawImageRequest.cacheKey),
);
const [debouncedImageRequest] = useDebouncedValue(rawImageRequest, 100, {
waitForInitial: true,
});
const effectiveImageRequest = enableDebounce ? debouncedImageRequest : rawImageRequest;
const effectiveImageRequest =
isInSessionCache || !enableDebounce ? rawImageRequest : debouncedImageRequest;
const [hasLoadedInInstance, setHasLoadedInInstance] = useState(false);
@@ -90,7 +96,8 @@ export function BaseImage({
}, [effectiveImageRequest?.cacheKey]);
const shouldLoadImage = Boolean(
effectiveImageRequest && (!enableViewport || inViewport || hasLoadedInInstance),
effectiveImageRequest &&
(!enableViewport || isInSessionCache || inViewport || hasLoadedInInstance),
);
const nativeImage = useNativeImage({
@@ -109,6 +116,7 @@ export function BaseImage({
return;
}
loadedImageCacheKeys.add(effectiveImageRequest.cacheKey);
setHasLoadedInInstance(true);
}, [effectiveImageRequest?.cacheKey, nativeImage.isLoaded]);
+66 -4
View File
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import { type CSSProperties, memo } from 'react';
import { type CSSProperties, memo, useEffect, useRef, useState } from 'react';
import styles from './skeleton.module.css';
@@ -32,6 +32,64 @@ export function BaseSkeleton({
style,
width,
}: SkeletonProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [isInViewport, setIsInViewport] = useState(false);
const [isDocumentVisible, setIsDocumentVisible] = useState(
typeof document === 'undefined' ? true : document.visibilityState === 'visible',
);
useEffect(() => {
if (!enableAnimation || typeof document === 'undefined') {
return;
}
const handleVisibilityChange = () => {
setIsDocumentVisible(document.visibilityState === 'visible');
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [enableAnimation]);
useEffect(() => {
if (!enableAnimation) {
setIsInViewport(false);
return;
}
const element = containerRef.current;
if (!element) {
return;
}
if (typeof IntersectionObserver === 'undefined') {
setIsInViewport(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
setIsInViewport(Boolean(entry?.isIntersecting));
},
{ threshold: 0.01 },
);
observer.observe(element);
return () => {
observer.disconnect();
};
}, [enableAnimation, count, inline, isCentered, direction]);
const shouldAnimate = enableAnimation && isDocumentVisible && isInViewport;
const skeletonStyle: CSSProperties = {
...style,
...(baseColor && { ['--base-color' as string]: baseColor }),
@@ -49,19 +107,23 @@ export function BaseSkeleton({
});
const skeletonClasses = clsx(styles.skeleton, className, {
[styles.animated]: enableAnimation,
[styles.animated]: shouldAnimate,
});
if (count <= 1) {
return (
<div className={containerClasses}>
<div className={containerClasses} ref={containerRef}>
<div className={skeletonClasses} style={skeletonStyle} />
</div>
);
}
return (
<div className={clsx(containerClasses, styles.skeletonWrapper)} dir={direction}>
<div
className={clsx(containerClasses, styles.skeletonWrapper)}
dir={direction}
ref={containerRef}
>
{Array.from({ length: count }, (_, i) => (
<div className={skeletonClasses} key={i} style={skeletonStyle} />
))}
+1
View File
@@ -1346,6 +1346,7 @@ export enum LyricSource {
GENIUS = 'Genius',
LRCLIB = 'lrclib.net',
NETEASE = 'NetEase',
SIMPMUSIC = 'SimpMusic',
}
export type AlbumRadioArgs = BaseEndpointArgs & {
-8
View File
@@ -1,8 +0,0 @@
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}