mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-01 16:19:57 +02:00
Compare commits
38 Commits
v1.13.0
...
0ed68e8ebb
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ed68e8ebb | |||
| 417365f091 | |||
| ff8a21af08 | |||
| ff426bda6d | |||
| 0664b0ad02 | |||
| 61cc87e0b7 | |||
| 36624350f6 | |||
| dbe46e03a4 | |||
| 16e00a0f9f | |||
| 00d9929568 | |||
| c301b14cb3 | |||
| 10332fdeaf | |||
| 2107d1c928 | |||
| f7adcb8533 | |||
| ba4664e797 | |||
| b14eb1c423 | |||
| 4297d0d5b3 | |||
| 64615a1701 | |||
| 2a0e414d8f | |||
| f2c455f23b | |||
| 0a0027f245 | |||
| 4f687c155f | |||
| f1f415daa8 | |||
| 1ee767352a | |||
| 66a123c10d | |||
| de0ddfe226 | |||
| d23f7619ec | |||
| 44de6f2207 | |||
| f6f25154a1 | |||
| 880516069d | |||
| 95970183db | |||
| 3a2c952d2a | |||
| 46b94a83f1 | |||
| 40a1d1438d | |||
| 905088cae7 | |||
| 705b375dab | |||
| 0b537b07ee | |||
| dfa6198bdd |
@@ -44,8 +44,6 @@ jobs:
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
@@ -129,8 +127,6 @@ jobs:
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -19,8 +19,6 @@ jobs:
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
@@ -123,8 +121,6 @@ jobs:
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -16,8 +16,6 @@ jobs:
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -16,8 +16,6 @@ jobs:
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -38,8 +38,6 @@ jobs:
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -16,8 +16,6 @@ jobs:
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -16,8 +16,6 @@ jobs:
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -43,6 +43,6 @@ jobs:
|
||||
|
||||
|
||||
stale-issue-label: 'stale'
|
||||
exempt-issue-labels: 'keep,security'
|
||||
exempt-issue-labels: 'keep,security,enhancement'
|
||||
stale-pr-label: 'stale'
|
||||
exempt-pr-labels: 'keep,security'
|
||||
|
||||
@@ -12,8 +12,6 @@ jobs:
|
||||
|
||||
- name: Install Node.js and PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
+1
-4
@@ -3,10 +3,7 @@ FROM node:23-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json first to cache node_modules
|
||||
COPY package.json pnpm-lock.yaml .
|
||||
|
||||
# Match CI (pnpm/action-setup version: 10). Latest pnpm 11 fails install without approve-builds.
|
||||
RUN corepack enable && corepack prepare pnpm@10 --activate
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ linux:
|
||||
artifactName: ${productName}-${os}-${arch}.${ext}
|
||||
|
||||
toolsets:
|
||||
appimage: '1.0.2'
|
||||
appimage: '1.0.3'
|
||||
|
||||
npmRebuild: false
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ linux:
|
||||
artifactName: ${productName}-${os}-${arch}.${ext}
|
||||
|
||||
toolsets:
|
||||
appimage: '1.0.2'
|
||||
appimage: '1.0.3'
|
||||
|
||||
npmRebuild: false
|
||||
publish:
|
||||
|
||||
@@ -63,7 +63,7 @@ linux:
|
||||
artifactName: ${productName}-${os}-${arch}.${ext}
|
||||
|
||||
toolsets:
|
||||
appimage: '1.0.2'
|
||||
appimage: '1.0.3'
|
||||
|
||||
npmRebuild: false
|
||||
afterAllArtifactBuild: scripts/after-all-artifact-build.mjs
|
||||
|
||||
Regular → Executable
+1
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env xdg-open
|
||||
[Desktop Entry]
|
||||
Name=Feishin
|
||||
GenericName=Music player
|
||||
|
||||
+44
-49
@@ -69,79 +69,80 @@
|
||||
"postversion": "node ./scripts/update-app-stream.mjs"
|
||||
},
|
||||
"resolutions": {
|
||||
"react-router": "7.14.0",
|
||||
"xml2js": "0.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "1.7.7",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.2.0",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@mantine/colors-generator": "^9.1.1",
|
||||
"@mantine/core": "^9.1.1",
|
||||
"@mantine/dates": "^9.1.1",
|
||||
"@mantine/form": "^9.1.1",
|
||||
"@mantine/hooks": "^9.1.1",
|
||||
"@mantine/modals": "^9.1.1",
|
||||
"@mantine/notifications": "^9.1.1",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"@tanstack/react-query-devtools": "^5.96.2",
|
||||
"@tanstack/react-query-persist-client": "^5.96.2",
|
||||
"@mantine/colors-generator": "^9.3.0",
|
||||
"@mantine/core": "^9.3.0",
|
||||
"@mantine/dates": "^9.3.0",
|
||||
"@mantine/form": "^9.3.0",
|
||||
"@mantine/hooks": "^9.3.0",
|
||||
"@mantine/modals": "^9.3.0",
|
||||
"@mantine/notifications": "^9.3.0",
|
||||
"@radix-ui/react-context-menu": "^2.3.0",
|
||||
"@tanstack/react-query": "5.96.2",
|
||||
"@tanstack/react-query-devtools": "5.96.2",
|
||||
"@tanstack/react-query-persist-client": "5.96.2",
|
||||
"@ts-rest/core": "^3.52.1",
|
||||
"@wavesurfer/react": "^1.0.12",
|
||||
"@xhayper/discord-rpc": "^1.3.3",
|
||||
"@xhayper/discord-rpc": "^1.3.4",
|
||||
"audiomotion-analyzer": "^4.5.4",
|
||||
"axios": "^1.14.0",
|
||||
"axios": "^1.17.0",
|
||||
"butterchurn": "3.0.0-beta.5",
|
||||
"butterchurn-presets": "3.0.0-beta.4",
|
||||
"cheerio": "^1.2.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"dompurify": "^3.3.3",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.8",
|
||||
"electron-debug": "^3.2.0",
|
||||
"electron-localshortcut": "^3.2.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-log": "^5.4.4",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.8.3",
|
||||
"electron-updater": "^6.8.9",
|
||||
"fast-average-color": "9.5.0",
|
||||
"fast-xml-parser": "^5.5.10",
|
||||
"fast-xml-parser": "^5.8.0",
|
||||
"format-duration": "^3.0.2",
|
||||
"fuse.js": "^7.2.0",
|
||||
"fuse.js": "^7.4.2",
|
||||
"i18next": "^25.10.10",
|
||||
"icecast-metadata-stats": "^0.1.12",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"idb-keyval": "^6.2.5",
|
||||
"immer": "^10.2.0",
|
||||
"is-electron": "^2.2.2",
|
||||
"kuroshiro": "^1.2.0",
|
||||
"kuroshiro-analyzer-kuromoji": "^1.1.0",
|
||||
"lodash": "^4.18.1",
|
||||
"md5": "^2.3.0",
|
||||
"motion": "^12.38.0",
|
||||
"motion": "^12.40.0",
|
||||
"mpris-service": "^2.1.2",
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.12",
|
||||
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
|
||||
"overlayscrollbars": "^2.14.0",
|
||||
"overlayscrollbars": "^2.16.0",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"qs": "^6.15.0",
|
||||
"react": "^19.2.4",
|
||||
"qs": "^6.15.2",
|
||||
"react": "^19.2.7",
|
||||
"react-call": "^1.8.2",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-i18next": "^16.6.6",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-player": "^2.16.1",
|
||||
"react-router": "^7.14.0",
|
||||
"react-router": "^7.17.0",
|
||||
"react-split-pane": "^3.2.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.26",
|
||||
"react-window": "1.8.11",
|
||||
"react-window-v2": "npm:react-window@^2.2.7",
|
||||
"semver": "^7.7.4",
|
||||
"semver": "^7.8.2",
|
||||
"string-to-color": "^2.2.2",
|
||||
"wavesurfer.js": "^7.12.5",
|
||||
"ws": "^8.20.0",
|
||||
"wavesurfer.js": "^7.12.7",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "^3.25.76",
|
||||
"zustand": "^5.0.12"
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
@@ -150,8 +151,8 @@
|
||||
"@types/electron-localshortcut": "^3.1.3",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/md5": "^2.3.6",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/node": "^24.13.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
@@ -160,38 +161,32 @@
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^41.7.0",
|
||||
"electron-builder": "^26.8.2",
|
||||
"electron": "^41.7.1",
|
||||
"electron-builder": "^26.15.0",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electron-vite": "^4.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-perfectionist": "^4.15.1",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"i18next-parser": "^9.4.0",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-packagejson": "^2.5.22",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-css-modules": "^4.6.0",
|
||||
"stylelint-config-recess-order": "^7.7.0",
|
||||
"stylelint-config-standard": "^39.0.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^7.3.5",
|
||||
"vite-plugin-conditional-import": "^0.1.7",
|
||||
"vite-plugin-dynamic-import": "^1.6.0",
|
||||
"vite-plugin-ejs": "^1.7.0",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"electron-winstaller",
|
||||
"esbuild"
|
||||
]
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.5.2",
|
||||
"productName": "feishin"
|
||||
}
|
||||
|
||||
Generated
+2233
-2388
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
allowBuilds:
|
||||
abstract-socket: true
|
||||
electron: true
|
||||
electron-winstaller: true
|
||||
esbuild: true
|
||||
minimumReleaseAge: 1440
|
||||
overrides:
|
||||
'xml2js': '0.5.0'
|
||||
'react-router': '7.14.0'
|
||||
@@ -1182,7 +1182,8 @@
|
||||
"sleepTimer_setCustom": "Configura el temporitzador",
|
||||
"sleepTimer_cancel": "Cancel·la el temporitzador",
|
||||
"albumRadio": "Ràdio d'àlbums",
|
||||
"scrobbleForceSubmit": "Força l'scrobble"
|
||||
"scrobbleForceSubmit": "Força l'scrobble",
|
||||
"sleepTimer_endOfAlbum": "Final de l'àlbum actual"
|
||||
},
|
||||
"error": {
|
||||
"credentialsRequired": "Credencials requerides",
|
||||
|
||||
+24
-21
@@ -49,7 +49,8 @@
|
||||
"sleepTimer_setCustom": "Nastavit časovač",
|
||||
"sleepTimer_cancel": "Zrušit časovač",
|
||||
"albumRadio": "Rádio alba",
|
||||
"scrobbleForceSubmit": "Vynutit scrobble"
|
||||
"scrobbleForceSubmit": "Vynutit scrobble",
|
||||
"sleepTimer_endOfAlbum": "Konec aktuálního alba"
|
||||
},
|
||||
"setting": {
|
||||
"crossfadeStyle_description": "Vyberte způsob prolnutí u přehrávače zvuku",
|
||||
@@ -264,7 +265,7 @@
|
||||
"lastfmApiKey_description": "Klíč API pro {{lastfm}}. Vyžadováno pro obaly alb",
|
||||
"discordServeImage": "Načítat obrázky {{discord}} ze serveru",
|
||||
"discordServeImage_description": "Sdílet obaly alb pro {{discord}} rich presence ze samotného serveru, dostupné pouze pro Jellyfin a Navidrome. {{discord}} používá bota pro získávání obrázků, váš server tudíž musí být dosažitelný z veřejného internetu",
|
||||
"lastfm": "Zobrazit odkazy na last.fm",
|
||||
"lastfm": "Zobrazit odkazy na Last.fm",
|
||||
"lastfm_description": "Na stránkách umělců a alb zobrazit odkazy na Last.fm",
|
||||
"musicbrainz": "Zobrazit odkazy na MusicBrainz",
|
||||
"musicbrainz_description": "Na stránkách umělců a alb, kde existuje MusicBrainz ID, zobrazit odkazy na MusicBrainz",
|
||||
@@ -548,19 +549,19 @@
|
||||
"cancel": "Zrušit",
|
||||
"forceRestartRequired": "Restartujte pro použití změn… zavřete oznámení pro restartování",
|
||||
"setting_one": "Nastavení",
|
||||
"setting_few": "nastavení",
|
||||
"setting_few": "Nastavení",
|
||||
"setting_other": "Nastavení",
|
||||
"version": "Verze",
|
||||
"title": "Název",
|
||||
"filter_one": "Filtr",
|
||||
"filter_few": "filtry",
|
||||
"filter_few": "Filtry",
|
||||
"filter_other": "Filtrů",
|
||||
"filters": "Filtry",
|
||||
"create": "Vytvořit",
|
||||
"bitrate": "Datový tok",
|
||||
"saveAndReplace": "Uložit a nahradit",
|
||||
"action_one": "Akce",
|
||||
"action_few": "akce",
|
||||
"action_few": "Akce",
|
||||
"action_other": "Akcí",
|
||||
"playerMustBePaused": "Přehrávač musí být pozastaven",
|
||||
"confirm": "Potvrdit",
|
||||
@@ -569,7 +570,7 @@
|
||||
"comingSoon": "Již brzy…",
|
||||
"reset": "Resetovat",
|
||||
"channel_one": "Kanál",
|
||||
"channel_few": "kanály",
|
||||
"channel_few": "Kanály",
|
||||
"channel_other": "Kanálů",
|
||||
"disable": "Vypnout",
|
||||
"sortOrder": "Pořadí",
|
||||
@@ -1160,16 +1161,16 @@
|
||||
},
|
||||
"entity": {
|
||||
"genre_one": "Žánr",
|
||||
"genre_few": "žánry",
|
||||
"genre_few": "Žánry",
|
||||
"genre_other": "Žánry",
|
||||
"playlistWithCount_one": "{{count}} playlist",
|
||||
"playlistWithCount_few": "{{count}} playlisty",
|
||||
"playlistWithCount_other": "{{count}} playlistů",
|
||||
"playlist_one": "Playlist",
|
||||
"playlist_few": "playlisty",
|
||||
"playlist_few": "Playlisty",
|
||||
"playlist_other": "Playlisty",
|
||||
"artist_one": "Umělec",
|
||||
"artist_few": "umělci",
|
||||
"artist_few": "Umělci",
|
||||
"artist_other": "Umělci",
|
||||
"folderWithCount_one": "{{count}} složka",
|
||||
"folderWithCount_few": "{{count}} složky",
|
||||
@@ -1178,7 +1179,7 @@
|
||||
"albumArtist_few": "Umělci alb",
|
||||
"albumArtist_other": "Umělci alb",
|
||||
"track_one": "Skladba",
|
||||
"track_few": "skladby",
|
||||
"track_few": "Skladby",
|
||||
"track_other": "Skladby",
|
||||
"albumArtistCount_one": "{{count}} umělec alba",
|
||||
"albumArtistCount_few": "{{count}} umělci alba",
|
||||
@@ -1187,17 +1188,17 @@
|
||||
"albumWithCount_few": "{{count}} alba",
|
||||
"albumWithCount_other": "{{count}} alb",
|
||||
"favorite_one": "Oblíbený",
|
||||
"favorite_few": "oblíbené",
|
||||
"favorite_few": "Oblíbené",
|
||||
"favorite_other": "Oblíbené",
|
||||
"artistWithCount_one": "{{count}} umělec",
|
||||
"artistWithCount_few": "{{count}} umělci",
|
||||
"artistWithCount_other": "{{count}} umělců",
|
||||
"folder_one": "Složka",
|
||||
"folder_few": "složky",
|
||||
"folder_few": "Složky",
|
||||
"folder_other": "Složky",
|
||||
"smartPlaylist": "Chytrý $t(entity.playlist, {\"count\": 1})",
|
||||
"album_one": "Album",
|
||||
"album_few": "alba",
|
||||
"album_few": "Alba",
|
||||
"album_other": "Alba",
|
||||
"genreWithCount_one": "{{count}} žánr",
|
||||
"genreWithCount_few": "{{count}} žánry",
|
||||
@@ -1208,9 +1209,9 @@
|
||||
"play_one": "{{count}} přehrání",
|
||||
"play_few": "{{count}} přehrání",
|
||||
"play_other": "{{count}} přehrání",
|
||||
"song_one": "Píseň",
|
||||
"song_few": "písničky",
|
||||
"song_other": "Písní",
|
||||
"song_one": "Skladba",
|
||||
"song_few": "Skladby",
|
||||
"song_other": "Skladby",
|
||||
"radioStation_one": "Stanice rádia",
|
||||
"radioStation_few": "Stanice rádia",
|
||||
"radioStation_other": "Stanice rádia",
|
||||
@@ -1269,13 +1270,15 @@
|
||||
"notContains": "Neobsahuje",
|
||||
"notInPlaylist": "Není v",
|
||||
"notInTheLast": "Není v posledním",
|
||||
"startsWith": "Začíná na"
|
||||
"startsWith": "Začíná na",
|
||||
"isMissing": "Chybí",
|
||||
"isPresent": "Je přítomen"
|
||||
},
|
||||
"datetime": {
|
||||
"minuteShort": "Min.",
|
||||
"secondShort": "S",
|
||||
"hourShort": "H.",
|
||||
"dayShort": "D."
|
||||
"minuteShort": "min.",
|
||||
"secondShort": "s",
|
||||
"hourShort": "h.",
|
||||
"dayShort": "d."
|
||||
},
|
||||
"visualizer": {
|
||||
"visualizerType": "Typ vizualizéru",
|
||||
|
||||
@@ -314,6 +314,8 @@
|
||||
"inTheRangeDate": "Is in the range (date)",
|
||||
"is": "Is",
|
||||
"isNot": "Is not",
|
||||
"isMissing": "Is missing",
|
||||
"isPresent": "Is present",
|
||||
"isGreaterThan": "Is greater than",
|
||||
"isLessThan": "Is less than",
|
||||
"matchesRegex": "Matches regex",
|
||||
@@ -841,6 +843,8 @@
|
||||
"discordUpdateInterval_description": "The time in seconds between each update (minimum 15 seconds)",
|
||||
"enableAutoTranslation_description": "Enable translation automatically when lyrics are loaded",
|
||||
"enableAutoTranslation": "Enable auto translation",
|
||||
"enableFurigana_description": "Display pronunciation guides (furigana) over Japanese kanji lyrics.",
|
||||
"enableFurigana": "Enable furigana generation",
|
||||
"enableRemote_description": "Enables the remote control server to allow other devices to control the application",
|
||||
"enableRemote": "Enable remote control server",
|
||||
"exitToTray_description": "Exit the application to the system tray",
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
"sleepTimer_endOfSong": "Fin de la canción actual",
|
||||
"sleepTimer": "Temporizador de apagado",
|
||||
"albumRadio": "Radio del álbum",
|
||||
"scrobbleForceSubmit": "Forzar scrobble"
|
||||
"scrobbleForceSubmit": "Forzar scrobble",
|
||||
"sleepTimer_endOfAlbum": "Fin del álbum actual"
|
||||
},
|
||||
"setting": {
|
||||
"crossfadeStyle_description": "Selecciona el estilo de crossfade a usar por el reproductor de audio",
|
||||
@@ -1269,7 +1270,9 @@
|
||||
"notInPlaylist": "No está en",
|
||||
"notInTheLast": "No está en el último",
|
||||
"startsWith": "Empieza con",
|
||||
"matchesRegex": "Coincide con expresión regular"
|
||||
"matchesRegex": "Coincide con expresión regular",
|
||||
"isPresent": "Está presente",
|
||||
"isMissing": "Falta"
|
||||
},
|
||||
"datetime": {
|
||||
"minuteShort": "M",
|
||||
|
||||
+1115
-15
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,8 @@
|
||||
"sleepTimer_setCustom": "Définir le minuteur",
|
||||
"sleepTimer_cancel": "Annuler le minuteur",
|
||||
"albumRadio": "Radio d'album",
|
||||
"scrobbleForceSubmit": "Forcer le scrobble"
|
||||
"scrobbleForceSubmit": "Forcer le scrobble",
|
||||
"sleepTimer_endOfAlbum": "Fin de l'album actuel"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "Éditer $t(entity.playlist, {\"count\": 1})",
|
||||
@@ -226,7 +227,8 @@
|
||||
"rename": "Renommer",
|
||||
"newVersionAvailable": "Une nouvelle version est disponible",
|
||||
"numberOfResults": "{{numberOfResults}} résultats",
|
||||
"back": "Retour"
|
||||
"back": "Retour",
|
||||
"openFolder": "Ouvrir le dossier"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "Redémarrer le serveur pour appliquer le nouveau port",
|
||||
@@ -728,7 +730,7 @@
|
||||
"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",
|
||||
"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. Application de Bureau uniquement: feishin lit et écrit le fichier custom.css dans le répertoire de configuration de l'application et le recharge lorsque celui-ci est modifié",
|
||||
"translationApiKey": "Clé API de traduction",
|
||||
"translationTargetLanguage_description": "Langue cible pour la traduction",
|
||||
"trayEnabled_description": "Afficher/masquer l’icô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",
|
||||
@@ -813,7 +815,7 @@
|
||||
"queryBuilderCustomFields_description": "Ajouter des champs personnalisés à utiliser dans les constructeurs de requêtes",
|
||||
"autoDJ": "DJ auto",
|
||||
"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_itemCount_description": "Le nombre d'entrées tentées d'être ajoutées à la file d'attente",
|
||||
"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 la file d'attente jusqu'au titre en cours",
|
||||
@@ -915,7 +917,16 @@
|
||||
"sidebarPlaylistFolderTreeIndent": "Indentation de l'arbre",
|
||||
"sidebarPlaylistMode_description": "Comment chaque liste de lecture est affichée dans la barre latérale",
|
||||
"sidebarPlaylistMode": "Mode de liste de lecture de la barre latérale",
|
||||
"sidebarPlaylistMode_optionCompact": "Compacte"
|
||||
"sidebarPlaylistMode_optionCompact": "Compacte",
|
||||
"autoDJ_mode": "Mode",
|
||||
"autoDJ_mode_albums": "Albums",
|
||||
"autoDJ_mode_description": "Choisissez d'ajouter des titres ou des albums entiers à la file d'attente",
|
||||
"autoDJ_mode_songs": "Titres",
|
||||
"autoDJ_enabled": "Activer le DJ auto",
|
||||
"autoDJ_albumStrategy": "Mode de sélection d'album",
|
||||
"autoDJ_songStrategy": "Mode de sélection de titre",
|
||||
"autoDJ_strategy_option_library_random": "Aléatoire",
|
||||
"autoDJ_strategy_option_similar": "Similaire"
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
@@ -1009,7 +1020,12 @@
|
||||
"input_played": "Filtre de lecture",
|
||||
"input_played_optionAll": "Toutes les pistes",
|
||||
"input_played_optionUnplayed": "Seulement les pistes non jouées",
|
||||
"input_played_optionPlayed": "Seulement les pistes jouées"
|
||||
"input_played_optionPlayed": "Seulement les pistes jouées",
|
||||
"input_kind_songs": "Titres",
|
||||
"input_kind_albums": "Albums",
|
||||
"input_kind": "Sélections aléatoires",
|
||||
"input_limit_albums": "Combien d'albums ?",
|
||||
"input_limit_songs": "Combien de titres ?"
|
||||
},
|
||||
"createRadioStation": {
|
||||
"success": "Station radio créée avec succès",
|
||||
|
||||
+39
-23
@@ -49,7 +49,8 @@
|
||||
"albumRadio": "アルバム・ラジオ",
|
||||
"artistRadio": "アーティストラジオ",
|
||||
"trackRadio": "ラジオを追跡する",
|
||||
"scrobbleForceSubmit": "強制 Scrobble"
|
||||
"scrobbleForceSubmit": "強制 Scrobble",
|
||||
"sleepTimer_endOfAlbum": "現在のアルバムの終了"
|
||||
},
|
||||
"setting": {
|
||||
"crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します",
|
||||
@@ -430,12 +431,21 @@
|
||||
"playerbarWaveformStretch_description": "波形を伸縮させて、利用可能なスペースを埋めます",
|
||||
"preventSuspendOnPlayback_description": "音楽再生中にアプリケーションが停止しないようにします",
|
||||
"preventSuspendOnPlayback": "再生の中断を防止する",
|
||||
"hotkey_listShowPlayingSong": "再生中の曲をリストに表示"
|
||||
"hotkey_listShowPlayingSong": "再生中の曲をリストに表示",
|
||||
"autoDJ_mode": "モード",
|
||||
"autoDJ_mode_albums": "アルバム",
|
||||
"autoDJ_mode_description": "キューに曲を追加するか、アルバム全体を追加するかを選択してください。",
|
||||
"autoDJ_mode_songs": "曲",
|
||||
"autoDJ_enabled": "Auto DJを有効にする",
|
||||
"autoDJ_albumStrategy": "アルバム選択モード",
|
||||
"autoDJ_songStrategy": "選曲モード",
|
||||
"autoDJ_strategy_option_library_random": "ランダム",
|
||||
"autoDJ_strategy_option_similar": "類似"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
|
||||
"goToPage": "ページへ移動",
|
||||
"moveToTop": "先頭に移動",
|
||||
"moveToTop": "一番上へ移動",
|
||||
"clearQueue": "キューをクリア",
|
||||
"addToFavorites": "$t(entity.favorite, {\"count\": 2}) に追加",
|
||||
"addToPlaylist": "$t(entity.playlist, {\"count\": 1}) に追加",
|
||||
@@ -446,9 +456,9 @@
|
||||
"deletePlaylist": "$t(entity.playlist, {\"count\": 1}) を削除",
|
||||
"removeFromQueue": "キューから削除",
|
||||
"deselectAll": "すべて選択解除",
|
||||
"moveToBottom": "末尾に移動",
|
||||
"setRating": "評価を設定する",
|
||||
"toggleSmartPlaylistEditor": "$t(entity.smartPlaylist) エディタの切り替え",
|
||||
"moveToBottom": "一番下へ移動",
|
||||
"setRating": "評価を設定",
|
||||
"toggleSmartPlaylistEditor": "$t(entity.smartPlaylist) エディターを切り替え",
|
||||
"removeFromFavorites": "$t(entity.favorite, {\"count\": 2}) から削除",
|
||||
"openIn": {
|
||||
"lastfm": "Last.fm で開く",
|
||||
@@ -457,9 +467,9 @@
|
||||
"listenbrainz": "ListenBrainz で開く",
|
||||
"qobuz": "Qobuz で開く"
|
||||
},
|
||||
"moveToNext": "次",
|
||||
"moveToNext": "次へ進む",
|
||||
"downloadStarted": "{{count}} 曲のダウンロードを開始しました",
|
||||
"moveItems": "曲を移動",
|
||||
"moveItems": "項目を移動",
|
||||
"shuffle": "シャッフル",
|
||||
"shuffleAll": "すべてシャッフル",
|
||||
"shuffleSelected": "選択した曲をシャッフル",
|
||||
@@ -471,28 +481,28 @@
|
||||
"moveDown": "下に移動",
|
||||
"holdToMoveToTop": "押し続けると一番上に移動します",
|
||||
"holdToMoveToBottom": "押し続けると一番下に移動します",
|
||||
"openApplicationDirectory": "アプリケーションディレクトリを開く",
|
||||
"openApplicationDirectory": "アプリディレクトリを開く",
|
||||
"selectRangeOfItems": "項目の範囲を選択",
|
||||
"addOrRemoveFromSelection": "選択に追加または削除",
|
||||
"addOrRemoveFromSelection": "選択に追加または選択から除外",
|
||||
"goToCurrent": "現在の項目へ移動",
|
||||
"collapseAllFolders": "すべてのフォルダーを折りたたむ",
|
||||
"expandAllFolders": "すべてのフォルダーを展開する"
|
||||
},
|
||||
"common": {
|
||||
"backward": "戻る",
|
||||
"backward": "逆行",
|
||||
"increase": "増加",
|
||||
"rating": "評価",
|
||||
"bpm": "BPM",
|
||||
"refresh": "再読み込み",
|
||||
"unknown": "不明",
|
||||
"areYouSure": "実行しますか?",
|
||||
"areYouSure": "実行してもよろしいですか?",
|
||||
"edit": "編集",
|
||||
"favorite": "お気に入り",
|
||||
"left": "左側",
|
||||
"save": "保存",
|
||||
"right": "右側",
|
||||
"currentSong": "現在の $t(entity.track, {\"count\": 1})",
|
||||
"collapse": "折りたたみ",
|
||||
"collapse": "折りたたむ",
|
||||
"trackNumber": "トラック",
|
||||
"descending": "降順",
|
||||
"add": "追加",
|
||||
@@ -534,7 +544,7 @@
|
||||
"confirm": "確認",
|
||||
"resetToDefault": "デフォルトにリセット",
|
||||
"home": "ホーム",
|
||||
"comingSoon": "近日利用可能になる予定です…",
|
||||
"comingSoon": "近日公開…",
|
||||
"reset": "リセット",
|
||||
"channel_other": "チャンネル",
|
||||
"disable": "無効",
|
||||
@@ -543,7 +553,7 @@
|
||||
"menu": "メニュー",
|
||||
"restartRequired": "再起動が必要です",
|
||||
"previousSong": "前の $t(entity.track, {\"count\": 1})",
|
||||
"noResultsFromQuery": "条件にマッチするものがありません",
|
||||
"noResultsFromQuery": "クエリに一致する結果がありません",
|
||||
"quit": "終了",
|
||||
"expand": "展開",
|
||||
"search": "検索",
|
||||
@@ -553,11 +563,11 @@
|
||||
"random": "ランダム",
|
||||
"size": "サイズ",
|
||||
"biography": "バイオグラフィー",
|
||||
"note": "ノート",
|
||||
"note": "注記",
|
||||
"explicitStatus": "明示的なステータス",
|
||||
"additionalParticipants": "追加参加者",
|
||||
"newVersion": "新しいバージョン ({{version}}) がインストールされました",
|
||||
"viewReleaseNotes": "リリースノートを表示する",
|
||||
"viewReleaseNotes": "リリースノートを表示",
|
||||
"bitDepth": "ビット深度",
|
||||
"close": "閉じる",
|
||||
"codec": "コーデック",
|
||||
@@ -565,7 +575,7 @@
|
||||
"sampleRate": "サンプルレート",
|
||||
"preview": "プレビュー",
|
||||
"private": "プライベート",
|
||||
"public": "パブリック",
|
||||
"public": "公開",
|
||||
"share": "共有",
|
||||
"tags": "タグ",
|
||||
"trackGain": "トラックゲイン",
|
||||
@@ -598,7 +608,8 @@
|
||||
"newVersionAvailable": "新しいバージョンが利用可能です",
|
||||
"numberOfResults": "{{numberOfResults}} 件の結果",
|
||||
"grouping": "グループ化",
|
||||
"back": "戻る"
|
||||
"back": "戻る",
|
||||
"openFolder": "フォルダーを開く"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -710,7 +721,7 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "新たなポート設定を適用するためサーバーを再起動してください",
|
||||
"remotePortWarning": "新しいポートの設定を反映させるには、サーバーを再起動してください",
|
||||
"systemFontError": "システムフォントを取得する際にエラーが発生しました",
|
||||
"playbackError": "メディアの再生開始時にエラーが発生しました",
|
||||
"remotePortError": "リモートサーバーのポート設定時にエラーが発生しました",
|
||||
@@ -725,7 +736,7 @@
|
||||
"serverNotSelectedError": "サーバーが選択されていません",
|
||||
"remoteDisableError": "リモートサーバーを$t(common.disable)にする際にエラーが発生しました",
|
||||
"mpvRequired": "MPV が必要です",
|
||||
"audioDeviceFetchError": "オーディオデバイスの取得時にエラーが発生しました",
|
||||
"audioDeviceFetchError": "オーディオデバイスを取得しようとした際にエラーが発生しました",
|
||||
"invalidServer": "無効なサーバー",
|
||||
"loginRateError": "ログイン試行回数が多すぎます。数秒後に再試行してください",
|
||||
"endpointNotImplementedError": "{{serverType}} にはエンドポイント {{endpoint}} が実装されていません",
|
||||
@@ -733,7 +744,7 @@
|
||||
"networkError": "ネットワークエラーが発生しました",
|
||||
"notificationDenied": "通知の許可が拒否されました。この設定は効果がありません",
|
||||
"openError": "ファイルを開けませんでした",
|
||||
"badValue": "無効なオプション「{{value}}」。この値は存在しません",
|
||||
"badValue": "無効なオプション「{{value}}」です。この値は存在しません",
|
||||
"multipleServerSaveQueueError": "再生キューに現在のサーバーに存在しない曲が 1 曲以上あります。これはサポートされていません",
|
||||
"noNetwork": "サーバーが利用できません",
|
||||
"noNetworkDescription": "このサーバーに接続できませんでした",
|
||||
@@ -1109,7 +1120,12 @@
|
||||
"input_played_optionAll": "すべてのトラック",
|
||||
"input_played_optionUnplayed": "未再生のトラックのみ",
|
||||
"input_played_optionPlayed": "再生されたトラックのみ",
|
||||
"input_played": "再生フィルター"
|
||||
"input_played": "再生フィルター",
|
||||
"input_kind_albums": "アルバム",
|
||||
"input_kind_songs": "曲",
|
||||
"input_kind": "ランダムピック",
|
||||
"input_limit_albums": "アルバムは何枚ですか?",
|
||||
"input_limit_songs": "何曲ですか?"
|
||||
},
|
||||
"saveQueue": {
|
||||
"success": "プレイキューをサーバーに保存しました"
|
||||
|
||||
+244
-24
@@ -17,7 +17,10 @@
|
||||
"removeFromPlaylist": "$t(entity.playlist, {\"count\": 1})에서 제거",
|
||||
"openIn": {
|
||||
"musicbrainz": "MusicBrainz에서 보기",
|
||||
"lastfm": "Last.fm에서 보기"
|
||||
"lastfm": "Last.fm에서 보기",
|
||||
"listenbrainz": "ListenBrainz에서 열기",
|
||||
"qobuz": "Qobuz에서 열기",
|
||||
"spotify": "Spotify에서 열기"
|
||||
},
|
||||
"viewPlaylists": "$t(entity.playlist, {\"count\": 2}) 보기",
|
||||
"setRating": "평점 지정",
|
||||
@@ -37,7 +40,10 @@
|
||||
"shuffleAll": "모두 섞기",
|
||||
"shuffleSelected": "선택항목 섞기",
|
||||
"viewMore": "더 보기",
|
||||
"openApplicationDirectory": "앱 디렉토리 열기"
|
||||
"openApplicationDirectory": "앱 디렉토리 열기",
|
||||
"goToCurrent": "현재 항목으로 이동",
|
||||
"collapseAllFolders": "모든 폴더 접기",
|
||||
"expandAllFolders": "모든 폴더 확장"
|
||||
},
|
||||
"common": {
|
||||
"translation": "번역",
|
||||
@@ -149,7 +155,18 @@
|
||||
"sort": "정렬",
|
||||
"gridRows": "행 그리드",
|
||||
"tableColumns": "테이블 열",
|
||||
"itemsMore": "{{count}}개 더"
|
||||
"itemsMore": "{{count}}개 더",
|
||||
"back": "뒤로",
|
||||
"example": "예",
|
||||
"openFolder": "폴더 열기",
|
||||
"filter_single": "미혼",
|
||||
"filter_multiple": "다중",
|
||||
"grouping": "그룹화",
|
||||
"mood": "기분",
|
||||
"numberOfResults": "결과 {{numberOfResults}}개",
|
||||
"retry": "다시 해 보다",
|
||||
"rename": "이름 변경",
|
||||
"newVersionAvailable": "새로운 버전이 나왔습니다"
|
||||
},
|
||||
"entity": {
|
||||
"albumWithCount_other": "{{count}} 앨범",
|
||||
@@ -197,7 +214,15 @@
|
||||
"localFontAccessDenied": "로컬 글꼴에 접근 거부되었습니다",
|
||||
"apiRouteError": "요청 보내기 실패",
|
||||
"badValue": "옵션이 없습니다 {{value}}. 이 값은 더이상 존재하지 않습니다",
|
||||
"notificationDenied": "알림에 대한 권한이 거부되었습니다. 이 설정은 변경되지 않습니다"
|
||||
"notificationDenied": "알림에 대한 권한이 거부되었습니다. 이 설정은 변경되지 않습니다",
|
||||
"invalidJson": "유효하지 않은 JSON",
|
||||
"multipleServerSaveQueueError": "재생 대기열에 현재 서버에 속하지 않은 곡이 하나 이상 포함되어 있습니다. 이는 지원되지 않습니다",
|
||||
"noNetwork": "서버를 이용할 수 없음",
|
||||
"noNetworkDescription": "이 서버에 연결할 수 없습니다",
|
||||
"playbackPausedDueToError": "오류로 인해 재생이 일시 중지되었습니다",
|
||||
"saveQueueFailed": "큐 저장 실패",
|
||||
"serverLockSingleServer": "서버가 잠겨 있을 때는 서버를 하나만 허용합니다",
|
||||
"settingsSyncError": "렌더러와 메인 프로세스의 설정 간에 불일치가 발견되었습니다. 변경 사항을 적용하려면 애플리케이션을 다시 시작하십시오"
|
||||
},
|
||||
"filter": {
|
||||
"title": "곡명",
|
||||
@@ -222,7 +247,7 @@
|
||||
"disc": "디스크",
|
||||
"bitrate": "비트 전송률",
|
||||
"biography": "바이오그래피",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"duration": "길이",
|
||||
"bpm": "BPM",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2}) 앨범수",
|
||||
@@ -242,7 +267,10 @@
|
||||
"songCount": "곡 갯수",
|
||||
"toYear": "년도까지",
|
||||
"trackNumber": "트랙",
|
||||
"explicitStatus": "$t(common.explicitStatus)"
|
||||
"explicitStatus": "$t(common.explicitStatus)",
|
||||
"matchAnd": "그리고",
|
||||
"matchOr": "또는",
|
||||
"sortName": "이름 정렬"
|
||||
},
|
||||
"form": {
|
||||
"addServer": {
|
||||
@@ -258,7 +286,10 @@
|
||||
"input_legacyAuthentication": "레거시 인증 사용",
|
||||
"input_username": "유저 이름",
|
||||
"input_preferInstantMix": "즉석 믹스 선호",
|
||||
"input_preferInstantMixDescription": "비슷한 곳을 찾기 위해 즉석 믹스를 사용합니다. 이 명령을 수정하기 위한 플러그인을 설치한 경우 유용합니다"
|
||||
"input_preferInstantMixDescription": "비슷한 곳을 찾기 위해 즉석 믹스를 사용합니다. 이 명령을 수정하기 위한 플러그인을 설치한 경우 유용합니다",
|
||||
"input_preferRemoteUrl": "공개 URL 선호",
|
||||
"input_remoteUrl": "공개 URL",
|
||||
"input_remoteUrlPlaceholder": "선택 사항: 외부 기능을 위한 공개 URL"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"input_skipDuplicates": "중복 건너뛰기",
|
||||
@@ -266,7 +297,8 @@
|
||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||
"success": "$t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })에 $t(entity.trackWithCount, {\"count\": {{message}} })가 추가되었습니다",
|
||||
"create": "$t(entity.playlist, {\"count\": 1}) {{playlist}} 생성",
|
||||
"searchOrCreate": "$t(entity.playlist, {\"count\": 2}) 검색 또는 입력하여 새로 만들기"
|
||||
"searchOrCreate": "$t(entity.playlist, {\"count\": 2}) 검색 또는 입력하여 새로 만들기",
|
||||
"noneAdded": "$t(entity.playlist, {\"count\": 1}) '{{playlist}}'에 트랙이 추가되지 않았습니다"
|
||||
},
|
||||
"lyricSearch": {
|
||||
"title": "가사 검색",
|
||||
@@ -276,7 +308,11 @@
|
||||
"queryEditor": {
|
||||
"input_optionMatchAll": "모두 일치",
|
||||
"input_optionMatchAny": "무엇이든 일치",
|
||||
"title": "쿼리 편집기"
|
||||
"title": "쿼리 편집기",
|
||||
"addRuleGroup": "규칙 그룹 추가",
|
||||
"removeRuleGroup": "규칙 그룹 제거",
|
||||
"resetToDefault": "기본값으로 초기화",
|
||||
"clearFilters": "필터 초기화"
|
||||
},
|
||||
"editPlaylist": {
|
||||
"title": "$t(entity.playlist, {\"count\": 1}) 편집",
|
||||
@@ -289,7 +325,9 @@
|
||||
"success": "클립보드에 공유 링크를 복사했습니다 (또는 열어보려면 클릭하세요)",
|
||||
"expireInvalid": "만료 날짜는 미래 날짜여야만 합니다",
|
||||
"createFailed": "공유 링크를 생성하는데 실패하였습니다 (혹시 공유하기 설정되어 있나요?)",
|
||||
"setExpiration": "만료 기간 설정하기"
|
||||
"setExpiration": "만료 기간 설정하기",
|
||||
"copyToClipboard": "클립보드로 복사: Ctrl+C, Enter",
|
||||
"successMustClick": "공유가 성공적으로 생성되었습니다. 여기를 클릭하여 여세요"
|
||||
},
|
||||
"updateServer": {
|
||||
"title": "서버 업데이트",
|
||||
@@ -312,6 +350,44 @@
|
||||
"enabled": "프라이빗 모드가 활성화되었습니다. 재생상태가 외부 서비스에 지금부터 노출되지 않습니다",
|
||||
"disabled": "프라이빗 모드가 비활성화되었습니다. 재생상태가 외부서비스에서 지금부터 표시됩니다",
|
||||
"title": "프라이빗 모드"
|
||||
},
|
||||
"largeFetchConfirmation": {
|
||||
"title": "대기열에 항목을 추가하세요",
|
||||
"description": "이 작업은 현재 필터링된 보기의 모든 항목을 추가합니다"
|
||||
},
|
||||
"createRadioStation": {
|
||||
"success": "라디오 방송국이 성공적으로 생성되었습니다",
|
||||
"title": "라디오 방송국 만들기",
|
||||
"input_homepageUrl": "홈페이지 URL",
|
||||
"input_name": "명의",
|
||||
"input_streamUrl": "스트림 URL"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "라디오 방송국이 성공적으로 업데이트되었습니다"
|
||||
},
|
||||
"lyricsExport": {
|
||||
"export": "가사 내보내기",
|
||||
"input_synced": "동기화된 가사 내보내기",
|
||||
"input_offset": "$t(setting.lyricOffset)"
|
||||
},
|
||||
"saveQueue": {
|
||||
"success": "재생 대기열을 서버에 저장했습니다"
|
||||
},
|
||||
"shuffleAll": {
|
||||
"title": "무작위 재생",
|
||||
"input_kind_albums": "앨범",
|
||||
"input_kind_songs": "노래들",
|
||||
"input_kind": "무작위 선택",
|
||||
"input_limit_albums": "앨범이 몇 장인가요?",
|
||||
"input_limit_songs": "몇 곡인가요?",
|
||||
"input_genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"input_limit": "몇 곡인가요?",
|
||||
"input_minYear": "연도부터",
|
||||
"input_maxYear": "연도까지",
|
||||
"input_played": "재생 필터",
|
||||
"input_played_optionAll": "모든 트랙",
|
||||
"input_played_optionUnplayed": "재생하지 않은 트랙만",
|
||||
"input_played_optionPlayed": "재생된 트랙만"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
@@ -325,7 +401,13 @@
|
||||
"collapseSidebar": "사이드바 줄이기",
|
||||
"expandSidebar": "사이드바 확장",
|
||||
"privateModeOff": "프라이빗 모드 끄기",
|
||||
"privateModeOn": "프라이빗 모드 켜기"
|
||||
"privateModeOn": "프라이빗 모드 켜기",
|
||||
"commandPalette": "명령 팔레트 열기",
|
||||
"quit": "$t(common.quit)",
|
||||
"selectMusicFolder": "음악 폴더 선택",
|
||||
"noMusicFolder": "음악 폴더가 선택되지 않았습니다",
|
||||
"multipleMusicFolders": "{{count}}개의 음악 폴더가 선택되었습니다",
|
||||
"settings": "$t(common.setting, {\"count\": 2})"
|
||||
},
|
||||
"manageServers": {
|
||||
"title": "서버 설정하기",
|
||||
@@ -350,7 +432,9 @@
|
||||
"lyricGap": "가사 간격",
|
||||
"lyricSize": "가사 크기",
|
||||
"showLyricMatch": "가사 일치 표시",
|
||||
"showLyricProvider": "가사 제공자 표시"
|
||||
"showLyricProvider": "가사 제공자 표시",
|
||||
"lyricOpacityNonActive": "비활성 가사 불투명도",
|
||||
"lyricScaleNonActive": "비활성 서정적 척도"
|
||||
},
|
||||
"lyrics": "가사",
|
||||
"related": "관련",
|
||||
@@ -364,7 +448,27 @@
|
||||
"shareItem": "공유",
|
||||
"goToAlbum": "$t(entity.album, {\"count\": 1})으로 이동",
|
||||
"goToAlbumArtist": "$t(entity.albumArtist, {\"count\": 1})으로 이동",
|
||||
"showDetails": "추가정보"
|
||||
"showDetails": "추가정보",
|
||||
"addFavorite": "$t(action.addToFavorites)",
|
||||
"addLast": "$t(player.addLast)",
|
||||
"addNext": "$t(player.addNext)",
|
||||
"addToFavorites": "$t(action.addToFavorites)",
|
||||
"addToPlaylist": "$t(action.addToPlaylist)",
|
||||
"createPlaylist": "$t(action.createPlaylist)",
|
||||
"deletePlaylist": "$t(action.deletePlaylist)",
|
||||
"deselectAll": "$t(action.deselectAll)",
|
||||
"moveItems": "$t(action.moveItems)",
|
||||
"moveToNext": "$t(action.moveToNext)",
|
||||
"moveToBottom": "$t(action.moveToBottom)",
|
||||
"moveToTop": "$t(action.moveToTop)",
|
||||
"play": "$t(player.play)",
|
||||
"playSimilarSongs": "$t(player.playSimilarSongs)",
|
||||
"removeFromFavorites": "$t(action.removeFromFavorites)",
|
||||
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
|
||||
"removeFromQueue": "$t(action.removeFromQueue)",
|
||||
"setRating": "$t(action.setRating)",
|
||||
"playShuffled": "$t(player.shuffle)",
|
||||
"goTo": "이동"
|
||||
},
|
||||
"albumArtistDetail": {
|
||||
"about": "{{artist}}에 대해",
|
||||
@@ -375,7 +479,13 @@
|
||||
"topSongs": "최고의 곡들",
|
||||
"topSongsFrom": "{{title}}이 포함된 최고의 곡들",
|
||||
"viewAll": "전부 보이기",
|
||||
"viewAllTracks": "$t(entity.track, {\"count\": 2}) 전부 보이기"
|
||||
"viewAllTracks": "$t(entity.track, {\"count\": 2}) 전부 보이기",
|
||||
"favoriteSongs": "좋아하는 노래들",
|
||||
"groupingTypeAll": "모든 릴리스 유형",
|
||||
"groupingTypePrimary": "주요 릴리스 유형",
|
||||
"topSongsCommunity": "공동체",
|
||||
"topSongsPersonal": "개인의",
|
||||
"favoriteSongsFrom": "{{title}}에서 가장 좋아하는 곡들"
|
||||
},
|
||||
"albumArtistList": {
|
||||
"title": "$t(entity.albumArtist, {\"count\": 2})"
|
||||
@@ -386,11 +496,14 @@
|
||||
"released": "발매"
|
||||
},
|
||||
"albumList": {
|
||||
"artistAlbums": "{{artist}}의 앨범"
|
||||
"artistAlbums": "{{artist}}의 앨범",
|
||||
"genreAlbums": "\"{{genre}}\" $t(entity.album, {\"count\": 2})",
|
||||
"title": "$t(entity.album, {\"count\": 2})"
|
||||
},
|
||||
"genreList": {
|
||||
"showAlbums": "$t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2}) 표시",
|
||||
"showTracks": "$t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2}) 표시"
|
||||
"showTracks": "$t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2}) 표시",
|
||||
"title": "$t(entity.genre, {\"count\": 2})"
|
||||
},
|
||||
"globalSearch": {
|
||||
"commands": {
|
||||
@@ -405,7 +518,9 @@
|
||||
"mostPlayed": "자주 플레이된 곡",
|
||||
"newlyAdded": "최근에 추가된 곡",
|
||||
"recentlyPlayed": "최근에 플레이된 곡",
|
||||
"recentlyReleased": "최근에 발매된 곡"
|
||||
"recentlyReleased": "최근에 발매된 곡",
|
||||
"genres": "$t(entity.genre, {\"count\": 2})",
|
||||
"title": "$t(common.home)"
|
||||
},
|
||||
"itemDetail": {
|
||||
"copyPath": "클립보드에 경로를 복사",
|
||||
@@ -420,15 +535,71 @@
|
||||
"generalTab": "일반",
|
||||
"hotkeysTab": "단축키",
|
||||
"playbackTab": "재생",
|
||||
"windowTab": "윈도우"
|
||||
"windowTab": "윈도우",
|
||||
"analytics": "해석학",
|
||||
"updates": "업데이트",
|
||||
"cache": "은닉처",
|
||||
"application": "애플리케이션",
|
||||
"queryBuilder": "쿼리 빌더",
|
||||
"theme": "테마",
|
||||
"controls": "통제 수단",
|
||||
"sidebar": "사이드바",
|
||||
"exportImport": "가져오기/내보내기",
|
||||
"audio": "오디오",
|
||||
"lyrics": "가사",
|
||||
"lyricsDisplay": "가사 표시",
|
||||
"transcoding": "트랜스코딩",
|
||||
"discord": "Discord",
|
||||
"logger": "로거",
|
||||
"playerFilters": "선수 필터"
|
||||
},
|
||||
"sidebar": {
|
||||
"myLibrary": "내 라이브러리",
|
||||
"nowPlaying": "재생중",
|
||||
"shared": "공유 $t(entity.playlist, {\"count\": 2})"
|
||||
"shared": "공유 $t(entity.playlist, {\"count\": 2})",
|
||||
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
|
||||
"albums": "$t(entity.album, {\"count\": 2})",
|
||||
"collections": "컬렉션",
|
||||
"artists": "$t(entity.artist, {\"count\": 2})",
|
||||
"favorites": "$t(entity.favorite, {\"count\": 2})",
|
||||
"folders": "$t(entity.folder, {\"count\": 2})",
|
||||
"genres": "$t(entity.genre, {\"count\": 2})",
|
||||
"home": "$t(common.home)",
|
||||
"radio": "$t(entity.radioStation, {\"count\": 2})",
|
||||
"playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||
"search": "$t(common.search)",
|
||||
"settings": "$t(common.setting, {\"count\": 2})",
|
||||
"tracks": "$t(entity.track, {\"count\": 2})"
|
||||
},
|
||||
"trackList": {
|
||||
"artistTracks": "{{artist}}의 음악"
|
||||
"artistTracks": "{{artist}}의 음악",
|
||||
"genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})",
|
||||
"title": "$t(entity.track, {\"count\": 2})"
|
||||
},
|
||||
"radioList": {
|
||||
"title": "라디오 방송국"
|
||||
},
|
||||
"releasenotes": {
|
||||
"commitsSinceStable": "{{stable}} 이후 커밋",
|
||||
"noNewCommits": "이 범위에 새로운 커밋이 없습니다",
|
||||
"noStableReleaseToCompare": "비교할 수 있는 안정화 릴리스가 없습니다"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "$t(entity.favorite, {\"count\": 2})"
|
||||
},
|
||||
"windowBar": {
|
||||
"paused": "(일시 정지됨) ",
|
||||
"privateMode": "(비공개 모드)"
|
||||
},
|
||||
"folderList": {
|
||||
"title": "$t(entity.folder, {\"count\": 2})"
|
||||
},
|
||||
"playlistList": {
|
||||
"title": "$t(entity.playlist, {\"count\": 2})"
|
||||
},
|
||||
"collections": {
|
||||
"overrideExisting": "기존 항목 덮어쓰기",
|
||||
"saveAsCollection": "컬렉션으로 저장"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -473,7 +644,25 @@
|
||||
"toggleFullscreenPlayer": "전체화면으로 전환",
|
||||
"unfavorite": "즐겨찾기 취소",
|
||||
"pause": "멈춤",
|
||||
"viewQueue": "대기열 보기"
|
||||
"viewQueue": "대기열 보기",
|
||||
"addLastShuffled": "마지막 (섞인)",
|
||||
"addNextShuffled": "다음 (무작위)",
|
||||
"albumRadio": "앨범 라디오",
|
||||
"artistRadio": "아티스트 라디오",
|
||||
"holdToShuffle": "길게 눌러 섞기",
|
||||
"lyrics": "가사",
|
||||
"restoreQueueFromServer": "서버에서 큐 복원",
|
||||
"saveQueueToServer": "대기열을 서버에 저장",
|
||||
"trackRadio": "라디오 추적",
|
||||
"sleepTimer": "취침 타이머",
|
||||
"sleepTimer_endOfSong": "현재 곡 종료",
|
||||
"sleepTimer_endOfAlbum": "현재 앨범의 끝",
|
||||
"sleepTimer_minutes": "{{count}}분",
|
||||
"sleepTimer_hours": "{{count}}시간",
|
||||
"sleepTimer_off": "끄다",
|
||||
"sleepTimer_timeRemaining": "{{time}} 남음",
|
||||
"sleepTimer_setCustom": "타이머 설정",
|
||||
"sleepTimer_cancel": "타이머 취소"
|
||||
},
|
||||
"setting": {
|
||||
"accentColor_description": "앱의 강조색상 설정",
|
||||
@@ -482,7 +671,7 @@
|
||||
"albumBackground": "앨범 배경이미지",
|
||||
"albumBackgroundBlur_description": "앨범 배경이미지의 흐려짐 정도 조정",
|
||||
"albumBackgroundBlur": "앨범배경이미지 흐려짐 크기",
|
||||
"applicationHotkeys_description": "앱의 단축키 설정. 앱 전체에 적용되는 단축키를 설정하기 위해서는 체크박스에 체크하세요(PC만 가능)",
|
||||
"applicationHotkeys_description": "애플리케이션 단축키를 설정합니다. 체크박스를 전환하여 전역 단축키로 설정하세요(데스크톱 전용)",
|
||||
"applicationHotkeys": "앱 단축키",
|
||||
"artistBackground": "아티스트 배경이미지",
|
||||
"artistBackground_description": "아티스트 페이지에 아티스트가 포함된 배경이미지를 추가",
|
||||
@@ -492,7 +681,7 @@
|
||||
"artistConfiguration_description": "앨범아티스트 페이지에 표시할 정보 및 순서 설정",
|
||||
"audioDevice_description": "음악재생에 사용할 장치 선택(웹플레이어만 가능)",
|
||||
"audioDevice": "오디오 장치",
|
||||
"audioExclusiveMode_description": "단독재생모드 켜기. 이 모드에서는 일반적으로 시스템의 재생장치가 고정되며 MPV로만 오디오가 재생됩니다",
|
||||
"audioExclusiveMode_description": "독점 출력 모드를 활성화합니다. 이 모드에서는 일반적으로 시스템의 오디오 출력이 차단되며, 오직 mpv만이 오디오를 출력할 수 있습니다. 이 모드가 활성화된 동안에는 비주얼라이저의 시스템 오디오 캡처 기능이 작동하지 않습니다",
|
||||
"audioExclusiveMode": "오디오 단독재생모드",
|
||||
"audioPlayer_description": "재생을 위한 오디오 플레이어 선택",
|
||||
"audioPlayer": "오디오 플레이어",
|
||||
@@ -505,7 +694,8 @@
|
||||
"broadcast": "방송",
|
||||
"ep": "ep앨범",
|
||||
"other": "기타",
|
||||
"single": "싱글"
|
||||
"single": "싱글",
|
||||
"album": "$t(entity.album, {\"count\": 1})"
|
||||
},
|
||||
"secondary": {
|
||||
"audiobook": "오디오북",
|
||||
@@ -521,5 +711,35 @@
|
||||
"soundtrack": "사운드트랙",
|
||||
"spokenWord": "보컬사운드"
|
||||
}
|
||||
},
|
||||
"datetime": {
|
||||
"minuteShort": "분",
|
||||
"secondShort": "초",
|
||||
"hourShort": "시간",
|
||||
"dayShort": "일"
|
||||
},
|
||||
"filterOperator": {
|
||||
"after": "~ 뒤에 있나요",
|
||||
"afterDate": "(날짜) 이후입니까",
|
||||
"before": "~보다 앞서 있다",
|
||||
"beforeDate": "(날짜) 이전인가요",
|
||||
"contains": "포함",
|
||||
"endsWith": "~로 끝남",
|
||||
"inPlaylist": "~ 안에 있다",
|
||||
"inTheLast": "마지막에 있습니다",
|
||||
"inTheRange": "범위 내에 있습니다",
|
||||
"inTheRangeDate": "범위 내에 있음 (날짜)",
|
||||
"is": "~이다",
|
||||
"isNot": "~이 아닙니까",
|
||||
"isGreaterThan": "~보다 크다",
|
||||
"isLessThan": "~보다 작다",
|
||||
"matchesRegex": "정규식과 일치",
|
||||
"notContains": "함유하지 않음",
|
||||
"notInPlaylist": "~ 안에 있지 않다",
|
||||
"notInTheLast": "마지막에 있지 않다",
|
||||
"startsWith": "~로 시작함"
|
||||
},
|
||||
"queryBuilder": {
|
||||
"customTags": "사용자 정의 태그"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"openIn": {
|
||||
"lastfm": "Åpne i Last.fm",
|
||||
"musicbrainz": "Åpne i MusicBrainz",
|
||||
"spotify": "Åpne i Spotify"
|
||||
"spotify": "Åpne i Spotify",
|
||||
"listenbrainz": "Åpne i ListenBrainz",
|
||||
"qobuz": "Åpne i Qobuz"
|
||||
},
|
||||
"moveToBottom": "Flytt til bunnen",
|
||||
"deletePlaylist": "Slett $t(entity.playlist, {\"count\": 1})",
|
||||
@@ -38,7 +40,10 @@
|
||||
"shuffleAll": "Tilfelding avspilling av alt",
|
||||
"shuffleSelected": "Tilfelding avspilling av utvalgte",
|
||||
"viewMore": "Se mer",
|
||||
"openApplicationDirectory": "Åpne applikasjonskatalogen"
|
||||
"openApplicationDirectory": "Åpne applikasjonskatalogen",
|
||||
"goToCurrent": "Gå til gjeldende element",
|
||||
"collapseAllFolders": "Skjul alle mapper",
|
||||
"expandAllFolders": "Utvid alle mapper"
|
||||
},
|
||||
"common": {
|
||||
"bpm": "Bpm",
|
||||
@@ -161,7 +166,11 @@
|
||||
"tableColumns": "Tabellkolonner",
|
||||
"itemsMore": "{{count}} fler",
|
||||
"explicitStatus": "Grovhetsstatus",
|
||||
"newVersionAvailable": "En ny version er tilgjengelig"
|
||||
"newVersionAvailable": "En ny version er tilgjengelig",
|
||||
"back": "Tilbake",
|
||||
"openFolder": "Åpne mappe",
|
||||
"grouping": "Grupper",
|
||||
"numberOfResults": "{{numberOfResults}} resultater"
|
||||
},
|
||||
"entity": {
|
||||
"smartPlaylist": "Smart $t(entity.playlist, {\"count\": 1})",
|
||||
@@ -667,7 +676,19 @@
|
||||
"b": "B",
|
||||
"c": "C",
|
||||
"d": "D",
|
||||
"z": "Z"
|
||||
"z": "Z",
|
||||
"none": "Ingen"
|
||||
},
|
||||
"frequencyScale": {
|
||||
"linear": "Lineær skala",
|
||||
"log": "Logaritmisk skala"
|
||||
},
|
||||
"channelLayout": {
|
||||
"single": "Enkel"
|
||||
},
|
||||
"gradient": {
|
||||
"rainbow": "Regnbue",
|
||||
"prism": "Prisme"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
{
|
||||
"action": {
|
||||
"addToFavorites": "Legg til i $t(entity.favorite, {\"count\": 2})",
|
||||
"addToPlaylist": "Legg til i $t(entity.playlist, {\"count\": 1})",
|
||||
"addOrRemoveFromSelection": "Legg til eller fjern fra val",
|
||||
"selectRangeOfItems": "Vel eit utval av element",
|
||||
"clearQueue": "Tøm kø",
|
||||
"goToCurrent": "Gå til noverande element",
|
||||
"collapseAllFolders": "Lukk alle mapper",
|
||||
"expandAllFolders": "Opne alle mapper",
|
||||
"createPlaylist": "Lag $t(entity.playlist, {\"count\": 1})",
|
||||
"createRadioStation": "Lag $t(entity.radioStation, {\"count\": 1})",
|
||||
"deletePlaylist": "Slett $t(entity.playlist, {\"count\": 1})",
|
||||
"deleteRadioStation": "Slett $t(entity.radioStation, {\"count\": 1})",
|
||||
"selectAll": "Vel alle",
|
||||
"deselectAll": "Opphev alle val",
|
||||
"downloadStarted": "Starta nedlasting av {{count}} element",
|
||||
"editPlaylist": "Rediger $t(entity.playlist, {\"count\": 1})",
|
||||
"goToPage": "Gå til side",
|
||||
"moveToNext": "Flytt til neste",
|
||||
"moveToBottom": "Flytt til botnen",
|
||||
"moveToTop": "Flytt til toppen",
|
||||
"moveUp": "Flytt opp",
|
||||
"moveDown": "Flytt ned",
|
||||
"holdToMoveToTop": "Held inne for å flytte til toppen",
|
||||
"holdToMoveToBottom": "Held inne for å flytte til botnen",
|
||||
"moveItems": "Flytt element",
|
||||
"shuffle": "Stokking",
|
||||
"shuffleAll": "Stokk alle",
|
||||
"shuffleSelected": "Stokk valde",
|
||||
"refresh": "$t(common.refresh)",
|
||||
"removeFromFavorites": "Fjern frå $t(entity.favorite, {\"count\": 2})",
|
||||
"removeFromPlaylist": "Fjern frå $t(entity.playlist, {\"count\": 1})",
|
||||
"removeFromQueue": "Fjern frå kø",
|
||||
"setRating": "Set vurdering",
|
||||
"toggleSmartPlaylistEditor": "Slå $t(entity.smartPlaylist) editor av/på",
|
||||
"viewPlaylists": "Vis $t(entity.playlist, {\"count\": 2})",
|
||||
"viewMore": "Vis fleire",
|
||||
"openApplicationDirectory": "Opne applikasjonsmappa",
|
||||
"openIn": {
|
||||
"lastfm": "Opne i Last.fm",
|
||||
"listenbrainz": "Opne i ListenBrainz",
|
||||
"musicbrainz": "Opne i MusicBrainz",
|
||||
"qobuz": "Opne i Qobuz",
|
||||
"spotify": "Opne i Spotify"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"countSelected": "{{count}} valt",
|
||||
"explicitStatus": "Eksplisittstatus",
|
||||
"action_one": "Handling",
|
||||
"action_other": "Handlingar",
|
||||
"add": "Legg til",
|
||||
"additionalParticipants": "Ytterlegare deltakarar",
|
||||
"newVersion": "Ein ny versjon vart installert ({{version}})",
|
||||
"viewReleaseNotes": "Sjå utgivelsesnotata",
|
||||
"albumGain": "Albumforsterkning",
|
||||
"albumPeak": "Albumtopp",
|
||||
"areYouSure": "Er du sikker?",
|
||||
"ascending": "Stigande",
|
||||
"back": "Tilbake",
|
||||
"backward": "Bakover",
|
||||
"biography": "Biografi",
|
||||
"bitDepth": "Bit-dybde",
|
||||
"bitrate": "Bitrate",
|
||||
"bpm": "BPM",
|
||||
"cancel": "Avbryt",
|
||||
"center": "Sentrer",
|
||||
"channel_one": "Kanal",
|
||||
"channel_other": "Kanalar",
|
||||
"clear": "Tøm",
|
||||
"close": "Lukk",
|
||||
"codec": "Codec",
|
||||
"collapse": "Lukk",
|
||||
"comingSoon": "Kjem snart…",
|
||||
"configure": "Konfigurer",
|
||||
"confirm": "Bekreft",
|
||||
"create": "Lag",
|
||||
"currentSong": "Noverande $t(entity.track, {\"count\": 1})",
|
||||
"decrease": "Reduser",
|
||||
"delete": "Slett",
|
||||
"descending": "Søkkande",
|
||||
"description": "Beskriving",
|
||||
"disable": "Skru av",
|
||||
"disc": "Disk",
|
||||
"dismiss": "Avvis",
|
||||
"doNotShowAgain": "Ikkje vis dette igjen",
|
||||
"duration": "Varigheit",
|
||||
"view": "Vis",
|
||||
"edit": "Rediger",
|
||||
"enable": "Skru på",
|
||||
"expand": "Utvid",
|
||||
"example": "Døme",
|
||||
"externalLinks": "Eksterne lenker",
|
||||
"openFolder": "Open mappe",
|
||||
"faster": "Raskare",
|
||||
"favorite": "Favoriser",
|
||||
"filter_one": "Filter",
|
||||
"filter_other": "Filter",
|
||||
"filters": "Filter",
|
||||
"filter_single": "Single",
|
||||
"filter_multiple": "Multi",
|
||||
"forceRestartRequired": "Start på nytt for at endringane vert teke i bruk… lukk varslina for å starte på nytt",
|
||||
"forward": "Framover",
|
||||
"gap": "Mellomrom",
|
||||
"home": "Heim",
|
||||
"increase": "Auk",
|
||||
"left": "Venstre",
|
||||
"limit": "Grense",
|
||||
"manage": "Administrer",
|
||||
"maximize": "Maksimer",
|
||||
"menu": "Meny",
|
||||
"minimize": "Minimer",
|
||||
"modified": "Modifisert",
|
||||
"mbid": "MusicBrainz ID",
|
||||
"grouping": "Gruppering",
|
||||
"mood": "Humør",
|
||||
"name": "Namn",
|
||||
"no": "Nei",
|
||||
"none": "Ingen",
|
||||
"noResultsFromQuery": "Søket gav ingen resultat",
|
||||
"numberOfResults": "{{numberOfResults}} resultat",
|
||||
"noFilters": "Ingen filter konfigurert",
|
||||
"note": "Notat",
|
||||
"ok": "Ok",
|
||||
"owner": "Eigar",
|
||||
"path": "Bane",
|
||||
"playerMustBePaused": "Spelaren må vere på pause",
|
||||
"preview": "Førehandsvisning",
|
||||
"previousSong": "Forrige $t(entity.track, {\"count\": 1})",
|
||||
"private": "Privat",
|
||||
"public": "Offentleg",
|
||||
"quit": "Lukk",
|
||||
"random": "Tilfeldig",
|
||||
"rating": "Vurdering",
|
||||
"retry": "Prøv på nytt",
|
||||
"recordLabel": "Plateselskap",
|
||||
"releaseType": "Utgjevingstype",
|
||||
"refresh": "Oppdater",
|
||||
"reload": "Oppdater",
|
||||
"rename": "Gje nytt namn",
|
||||
"reset": "Tilbakestill",
|
||||
"resetToDefault": "Tilbakestill til standard",
|
||||
"restartRequired": "Omstart nødvendig",
|
||||
"right": "Høgre",
|
||||
"sampleRate": "Samplingsfrekvens",
|
||||
"save": "Lagre",
|
||||
"saveAndReplace": "Lagre og erstatt",
|
||||
"saveAs": "Lagre som",
|
||||
"search": "Søk",
|
||||
"setting_one": "Instilling",
|
||||
"setting_other": "Instillingar",
|
||||
"slower": "Saktare",
|
||||
"share": "Del",
|
||||
"size": "Storleik",
|
||||
"sort": "Sorter",
|
||||
"sortOrder": "Rekkjefølgje",
|
||||
"tags": "Taggar",
|
||||
"title": "Tittel",
|
||||
"trackNumber": "Spor",
|
||||
"trackGain": "Sporforsterkning",
|
||||
"trackPeak": "Sporetopp",
|
||||
"translation": "Omsetjing",
|
||||
"unknown": "Ukjend",
|
||||
"version": "Versjon",
|
||||
"year": "År",
|
||||
"yes": "Ja",
|
||||
"explicit": "Eksplisitt",
|
||||
"clean": "Rein",
|
||||
"gridRows": "Rutenettrader",
|
||||
"tableColumns": "Tabellkolonnar",
|
||||
"itemsMore": "{{count}} fleire",
|
||||
"newVersionAvailable": "Ein ny versjon er tilgjengeleg"
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Album",
|
||||
"album_other": "Album",
|
||||
"albumArtist_one": "Albumartist",
|
||||
"albumArtist_other": "Albumartistar",
|
||||
"albumArtistCount_one": "{{count}} albumartist",
|
||||
"albumArtistCount_other": "{{count}} albumartistar",
|
||||
"albumWithCount_one": "{{count}} album",
|
||||
"albumWithCount_other": "{{count}} album",
|
||||
"radioStation_one": "Radiostasjon",
|
||||
"radioStation_other": "Radiostasjonar",
|
||||
"radioStationWithCount_one": "{{count}} radiostasjon",
|
||||
"radioStationWithCount_other": "{{count}} radiostasjonar",
|
||||
"artist_one": "Artist",
|
||||
"artist_other": "Artistar",
|
||||
"artistWithCount_one": "{{count}} artist",
|
||||
"artistWithCount_other": "{{count}} artistar",
|
||||
"favorite_one": "Favoritt",
|
||||
"favorite_other": "Favorittar",
|
||||
"folder_one": "Mappe",
|
||||
"folder_other": "Mapper",
|
||||
"folderWithCount_one": "{{count}} mappe",
|
||||
"folderWithCount_other": "{{count}} mapper",
|
||||
"genre_one": "Sjanger",
|
||||
"genre_other": "Sjangrar",
|
||||
"genreWithCount_one": "{{count}} sjanger",
|
||||
"genreWithCount_other": "{{count}} sjangrar",
|
||||
"playlist_one": "Speleliste",
|
||||
"playlist_other": "Spelelister",
|
||||
"play_one": "{{count}} avspeling",
|
||||
"play_other": "{{count}} avspelingar",
|
||||
"playlistWithCount_one": "{{count}} speleliste",
|
||||
"playlistWithCount_other": "{{count}} spelelister",
|
||||
"smartPlaylist": "Smart $t(entity.playlist, {\"count\": 1})",
|
||||
"track_one": "Spor",
|
||||
"track_other": "Spor",
|
||||
"song_one": "Song",
|
||||
"song_other": "Songar",
|
||||
"trackWithCount_one": "{{count}} spor",
|
||||
"trackWithCount_other": "{{count}} spor"
|
||||
},
|
||||
"error": {
|
||||
"apiRouteError": "Kunne ikkje rute førespurnaden",
|
||||
"audioDeviceFetchError": "Det oppstod ein feil under forsøk på å hente lydeiningar",
|
||||
"authenticationFailed": "Autentisering feila",
|
||||
"badAlbum": "Du ser denne sida fordi denne songen ikkje er ein del av eit album. Mest sannsynleg ser du dette fordi du har songen på toppen av musikkmappa di. Jellyfin grupperar berre spor viss dei er i ei mappe",
|
||||
"badValue": "Ugyldig val \"{{value}}\". Denne verdien finst ikkje lenger",
|
||||
"credentialsRequired": "Legitimasjon krevjast",
|
||||
"endpointNotImplementedError": "Endepunktet {{endpoint}} er ikkje implementert for {{serverType}}",
|
||||
"genericError": "Ein feil skjedde",
|
||||
"invalidJson": "Ugyldig JSON",
|
||||
"invalidServer": "Ugyldig sørvar",
|
||||
"localFontAccessDenied": "Ingen tilgang til lokale skrifttypar",
|
||||
"loginRateError": "For mange innloggingsforsøk, venlegast prøv på nytt om nokon sekundar",
|
||||
"mpvRequired": "MPV nødvendig",
|
||||
"multipleServerSaveQueueError": "Spelekøen har ein eller fleire songar som ikkje er frå den noverande sørvaren. Dette er ikkje støtta",
|
||||
"networkError": "Ein nettverksfeil skjedde",
|
||||
"noNetwork": "Sørvar utilgjengeleg",
|
||||
"noNetworkDescription": "Kunne ikkje kople til denne sørvaren"
|
||||
}
|
||||
}
|
||||
+29
-26
@@ -80,20 +80,20 @@
|
||||
"cancel": "Anuluj",
|
||||
"forceRestartRequired": "Zrestartuj aby zastosować zmiany... Zamknij powiadomienie aby zrestartować",
|
||||
"setting_one": "Ustawienie",
|
||||
"setting_few": "ustawienia",
|
||||
"setting_many": "ustawień",
|
||||
"setting_few": "Ustawienia",
|
||||
"setting_many": "Ustawień",
|
||||
"version": "Wersja",
|
||||
"title": "Tytuł",
|
||||
"filter_one": "Filtr",
|
||||
"filter_few": "filtry",
|
||||
"filter_many": "filtrów",
|
||||
"filter_few": "Filtry",
|
||||
"filter_many": "Filtrów",
|
||||
"filters": "Filtry",
|
||||
"create": "Stwórz",
|
||||
"bitrate": "Bitrate",
|
||||
"saveAndReplace": "Zapisz i zamień",
|
||||
"action_one": "Akcja",
|
||||
"action_few": "akcje",
|
||||
"action_many": "akcji",
|
||||
"action_few": "Akcje",
|
||||
"action_many": "Akcji",
|
||||
"playerMustBePaused": "Odtwarzacz musi być zapauzowany",
|
||||
"confirm": "Potwierdź",
|
||||
"resetToDefault": "Przywróć do domyślnych",
|
||||
@@ -101,8 +101,8 @@
|
||||
"comingSoon": "Już wkrótce…",
|
||||
"reset": "Zresetuj",
|
||||
"channel_one": "Kanał",
|
||||
"channel_few": "kanałów",
|
||||
"channel_many": "kanałów",
|
||||
"channel_few": "Kanałów",
|
||||
"channel_many": "Kanałów",
|
||||
"disable": "Wyłącz",
|
||||
"sortOrder": "Kolejność",
|
||||
"none": "Żaden",
|
||||
@@ -178,17 +178,17 @@
|
||||
},
|
||||
"entity": {
|
||||
"genre_one": "Gatunek",
|
||||
"genre_few": "gatunki",
|
||||
"genre_many": "gatunków",
|
||||
"genre_few": "Gatunki",
|
||||
"genre_many": "Gatunków",
|
||||
"playlistWithCount_one": "{{count}} playlista",
|
||||
"playlistWithCount_few": "{{count}} playlisty",
|
||||
"playlistWithCount_many": "{{count}} playlist",
|
||||
"playlist_one": "Playlista",
|
||||
"playlist_few": "playlisty",
|
||||
"playlist_many": "playlist",
|
||||
"playlist_few": "Playlisty",
|
||||
"playlist_many": "Playlist",
|
||||
"artist_one": "Wykonawca",
|
||||
"artist_few": "wykonawcy",
|
||||
"artist_many": "wykonawców",
|
||||
"artist_few": "Wykonawców",
|
||||
"artist_many": "Wykonawców",
|
||||
"folderWithCount_one": "{{count}} katalog",
|
||||
"folderWithCount_few": "{{count}} katalogi",
|
||||
"folderWithCount_many": "{{count}} katalogów",
|
||||
@@ -196,8 +196,8 @@
|
||||
"albumArtist_few": "Wykonawców albumów",
|
||||
"albumArtist_many": "Wykonawców albumów",
|
||||
"track_one": "Utwór",
|
||||
"track_few": "utwory",
|
||||
"track_many": "utworów",
|
||||
"track_few": "Utwory",
|
||||
"track_many": "Utworów",
|
||||
"albumArtistCount_one": "{{count}} wykonawca albumu",
|
||||
"albumArtistCount_few": "{{count}} wykonawców albumu",
|
||||
"albumArtistCount_many": "{{count}} wykonawców albumu",
|
||||
@@ -205,18 +205,18 @@
|
||||
"albumWithCount_few": "{{count}} albumy",
|
||||
"albumWithCount_many": "{{count}} albumów",
|
||||
"favorite_one": "Ulubiony",
|
||||
"favorite_few": "ulubione",
|
||||
"favorite_many": "ulubionych",
|
||||
"favorite_few": "Ulubione",
|
||||
"favorite_many": "Ulubionych",
|
||||
"artistWithCount_one": "{{count}} wykonawca",
|
||||
"artistWithCount_few": "{{count}} wykonawców",
|
||||
"artistWithCount_many": "{{count}} wykonawców",
|
||||
"folder_one": "Katalog",
|
||||
"folder_few": "katalogi",
|
||||
"folder_many": "katalogów",
|
||||
"folder_few": "Katalogi",
|
||||
"folder_many": "Katalogów",
|
||||
"smartPlaylist": "Inteligentna $t(entity.playlist, {\"count\": 1})",
|
||||
"album_one": "Album",
|
||||
"album_few": "albumy",
|
||||
"album_many": "albumów",
|
||||
"album_few": "Albumy",
|
||||
"album_many": "Albumów",
|
||||
"genreWithCount_one": "{{count}} gatunek",
|
||||
"genreWithCount_few": "{{count}} gatunki",
|
||||
"genreWithCount_many": "{{count}} gatunków",
|
||||
@@ -227,8 +227,8 @@
|
||||
"play_few": "{{count}} odtworzenia",
|
||||
"play_many": "{{count}} odtworzeń",
|
||||
"song_one": "Piosenka",
|
||||
"song_few": "piosenki",
|
||||
"song_many": "piosenek",
|
||||
"song_few": "Piosenki",
|
||||
"song_many": "Piosenek",
|
||||
"radioStation_one": "Stacja radiowa",
|
||||
"radioStation_few": "Stacje radiowe",
|
||||
"radioStation_many": "Stacji radiowych",
|
||||
@@ -700,7 +700,8 @@
|
||||
"sleepTimer_setCustom": "Ustaw wyłącznik",
|
||||
"sleepTimer_cancel": "Anuluj wyłączanie",
|
||||
"albumRadio": "Radio albumu",
|
||||
"scrobbleForceSubmit": "Wymuś scrobble"
|
||||
"scrobbleForceSubmit": "Wymuś scrobble",
|
||||
"sleepTimer_endOfAlbum": "Koniec aktualnego albumu"
|
||||
},
|
||||
"setting": {
|
||||
"crossfadeStyle_description": "Wybierz styl przenikania, który ma być używany do odtwarzania dźwięku",
|
||||
@@ -1269,7 +1270,9 @@
|
||||
"notContains": "Nie zawiera",
|
||||
"notInPlaylist": "Nie jest w",
|
||||
"notInTheLast": "Nie jest w ostatnim",
|
||||
"startsWith": "Zaczyna się od"
|
||||
"startsWith": "Zaczyna się od",
|
||||
"isMissing": "brakuje",
|
||||
"isPresent": "jest"
|
||||
},
|
||||
"datetime": {
|
||||
"minuteShort": "Min",
|
||||
|
||||
+172
-9
@@ -174,7 +174,8 @@
|
||||
"explicitStatus": "Признак нецензурного контента",
|
||||
"newVersionAvailable": "Доступна новая версия",
|
||||
"numberOfResults": "{{numberOfResults}} результатов",
|
||||
"back": "Назад"
|
||||
"back": "Назад",
|
||||
"openFolder": "Открыть папку"
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Альбом",
|
||||
@@ -240,7 +241,10 @@
|
||||
"table": {
|
||||
"config": {
|
||||
"view": {
|
||||
"table": "Таблица"
|
||||
"table": "Таблица",
|
||||
"detail": "Детали",
|
||||
"grid": "Сетка",
|
||||
"list": "Список"
|
||||
},
|
||||
"general": {
|
||||
"displayType": "Тип отображения",
|
||||
@@ -250,7 +254,29 @@
|
||||
"followCurrentSong": "Следовать за исполняемым треком",
|
||||
"size": "$t(common.size)",
|
||||
"itemSize": "Размер элементов (px)",
|
||||
"itemGap": "Отступ между элементами (px)"
|
||||
"itemGap": "Отступ между элементами (px)",
|
||||
"advancedSettings": "Расширенные настройки",
|
||||
"autosize": "Автоматический выбор размера",
|
||||
"moveUp": "Переместить выше",
|
||||
"moveDown": "Переместить ниже",
|
||||
"pinToLeft": "Закрепить слева",
|
||||
"pinToRight": "Закрепить права",
|
||||
"alignLeft": "Выровнять по левой стороне",
|
||||
"alignCenter": "Выровнять по центру",
|
||||
"alignRight": "Выровнять по правой стороне",
|
||||
"itemsPerRow": "Элементов в строке",
|
||||
"size_default": "По-умолчанию",
|
||||
"size_compact": "Компактный",
|
||||
"size_large": "Большой",
|
||||
"pagination": "Пагинация",
|
||||
"pagination_itemsPerPage": "Элементов на странице",
|
||||
"pagination_infinite": "Бесконечно",
|
||||
"pagination_paginate": "Разбитый по страницам",
|
||||
"alternateRowColors": "Переменный цвет строк",
|
||||
"horizontalBorders": "Границы строки",
|
||||
"rowHoverHighlight": "Подсветка строки при наведении",
|
||||
"showHeader": "Показать заголовок",
|
||||
"verticalBorders": "Границы колонки"
|
||||
},
|
||||
"label": {
|
||||
"releaseDate": "Дата выхода",
|
||||
@@ -276,7 +302,10 @@
|
||||
"favorite": "$t(common.favorite)",
|
||||
"year": "$t(common.year)",
|
||||
"codec": "$t(common.codec)",
|
||||
"titleArtist": "$t(common.title) (артист)"
|
||||
"titleArtist": "$t(common.title) (артист)",
|
||||
"albumGroup": "Группа альбома",
|
||||
"composer": "Композитор",
|
||||
"image": "Изображение"
|
||||
}
|
||||
},
|
||||
"column": {
|
||||
@@ -299,7 +328,14 @@
|
||||
"comment": "Комментарий",
|
||||
"bitrate": "Битрейт",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"bpm": "BPM"
|
||||
"bpm": "BPM",
|
||||
"albumCount": "Альбомы",
|
||||
"artist": "Исполнители",
|
||||
"bitDepth": "Битовая глубина",
|
||||
"genre": "Жанр",
|
||||
"sampleRate": "Частота дискретизации",
|
||||
"songCount": "Треки",
|
||||
"owner": "Правообладатель"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -434,7 +470,8 @@
|
||||
"sleepTimer_setCustom": "Установить таймер",
|
||||
"sleepTimer_custom": "Пользовательский",
|
||||
"sleepTimer_cancel": "Отменить таймер",
|
||||
"scrobbleForceSubmit": "Принудительная скробблинг"
|
||||
"scrobbleForceSubmit": "Принудительная скробблинг",
|
||||
"sleepTimer_endOfAlbum": "Конец этого альбома"
|
||||
},
|
||||
"page": {
|
||||
"sidebar": {
|
||||
@@ -756,7 +793,12 @@
|
||||
"input_played_optionAll": "Все треки",
|
||||
"input_played_optionUnplayed": "Только не игранные треки",
|
||||
"input_played_optionPlayed": "Только воспроизведённые треки",
|
||||
"input_genre": "$t(entity.genre, {\"count\": 1})"
|
||||
"input_genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"input_kind_albums": "Альбомы",
|
||||
"input_kind_songs": "Песни",
|
||||
"input_kind": "Случайный выбор",
|
||||
"input_limit_albums": "Сколько альбомов?",
|
||||
"input_limit_songs": "Сколько песен?"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "Радиостанция успешно обновлена"
|
||||
@@ -1087,7 +1129,78 @@
|
||||
"audioFadeOnStatusChange": "плавное изменение звука",
|
||||
"audioFadeOnStatusChange_description": "включает эффекты затухания и появления звука при изменении статуса (пауза/проигрывание)",
|
||||
"preventSleepOnPlayback_description": "запрещает спящий режим экрана, пока играет музыка",
|
||||
"preventSleepOnPlayback": "не переходить в спящий режим"
|
||||
"preventSleepOnPlayback": "не переходить в спящий режим",
|
||||
"autoDJ_mode": "Режим",
|
||||
"autoDJ_mode_albums": "Альбомы",
|
||||
"autoDJ_mode_description": "Добавь песни или целые альбомы в очередь",
|
||||
"autoDJ_mode_songs": "Песни",
|
||||
"autoDJ_enabled": "Включить Auto DJ",
|
||||
"autoDJ_albumStrategy": "Режим выбора альбома",
|
||||
"autoDJ_songStrategy": "Режим выбора песни",
|
||||
"autoDJ_strategy_option_library_random": "Случайно",
|
||||
"autoDJ_strategy_option_similar": "Похожие",
|
||||
"hotkey_listShowPlayingSong": "Показать текущую песню в списке",
|
||||
"listenbrainz_description": "Показать ссылки на ListenBrains на страницах исполнителя/альбома",
|
||||
"listenbrainz": "Показать ссылки на ListenBrainz",
|
||||
"qobuz_description": "Показать ссылки на Qobuz на страницах исполнителя/альбома",
|
||||
"qobuz": "Показать ссылки на Qobuz",
|
||||
"spotify_description": "Показать ссылки на Spotify на странице исполнителя/альбома",
|
||||
"spotify": "Показать ссылки на Spotify",
|
||||
"nativeSpotify_description": "Открывать в приложении Spotify вместо браузера",
|
||||
"nativeSpotify": "Использовать приложение Spotify",
|
||||
"imageResolution_optionTable": "Таблица",
|
||||
"preventSuspendOnPlayback_description": "Не приостанавливать приложение во время проигрывания музыки",
|
||||
"preventSuspendOnPlayback": "Не приостанавливать во время проигрывания",
|
||||
"playerItemConfiguration_description": "Настроить какие элементы и в каком порядке видны в полноэкранном плеере",
|
||||
"playerItemConfiguration": "Настройка плеера",
|
||||
"sidebarPlaylistFolders": "Включить папки",
|
||||
"sidebarPlaylistFolderSeparator_description": "Символ (или строка), который разделяет уровни папок в названии плейлиста",
|
||||
"sidebarPlaylistFolderSeparator": "Разделитель папок",
|
||||
"sidebarPlaylistFolderView_description": "Как отображать папки в боковой панели",
|
||||
"sidebarPlaylistFolderView": "Вид папок",
|
||||
"sidebarPlaylistFolderView_optionSingle": "Единстванная папка",
|
||||
"sidebarPlaylistFolderView_optionTree": "Вид дерева",
|
||||
"sidebarPlaylistFolderView_optionNavigation": "Вид навигации",
|
||||
"sidebarPlaylistFolderTreeIndent_description": "Отступ в пикселях на каждом уровне дерева",
|
||||
"sidebarPlaylistFolderTreeIndent": "Отступ в дереве",
|
||||
"sidebarPlaylistFolderTreeLineColor_description": "Цвет линий соединения в дереве (оставь пустым, чтобы использовать настройки темы)",
|
||||
"sidebarPlaylistFolderTreeLineColor": "Цвет линии в дереве",
|
||||
"sidebarPlaylistMode_description": "Как отображать каждый плейлист в списке в боковой панели",
|
||||
"sidebarPlaylistMode": "Режим плейлиста в боковой панели",
|
||||
"sidebarPlaylistMode_optionCompact": "Компактный",
|
||||
"sidebarPlaylistMode_optionExpanded": "Просторный",
|
||||
"sidebarPlaylistSorting_description": "Разрешить ручную сортировку плейлистов в боковой панели с помощью перетаскивания вместо сортировки со стороны сервера",
|
||||
"sidebarPlaylistSorting": "Сортировка плейлистов в боковой панели",
|
||||
"sidebarPlaylistListFilterRegex_description": "Скрывать плейлисты в боковой панели, которые соответствуют этому регулярному выражению",
|
||||
"sidebarPlaylistListFilterRegex_placeholder": "Например ^daily mix.*",
|
||||
"sidebarPlaylistListFilterRegex": "Регулярное выражение для фильтрации плейлистов",
|
||||
"sidePlayQueueLayout": "Макет очереди проигрывания сбоку",
|
||||
"sidePlayQueueLayout_description": "Задает макет прикрепленной очереди проигрывания сбоку",
|
||||
"sidePlayQueueLayout_optionHorizontal": "Горизонтальный",
|
||||
"sidePlayQueueLayout_optionVertical": "Вертикальный",
|
||||
"mediaSession_description": "Включает интеграцию сессии медиа, отображая элементы управления и метаданные медиа в системном оверлее управления громкостью и на экране блокировки. Требуется Web Audio Player.",
|
||||
"mediaSession": "Включить сессию медиа",
|
||||
"skipPlaylistPage_description": "Когда переходишь в плейлист, откроется страница со списком песен плейлиста, вместо страницы по-умолчанию",
|
||||
"transcode": "Включить транскодирование",
|
||||
"transcodeFormat_description": "Выбирает форматы для транскодирования. Оставь пустым, чтобы решение принимал сервер",
|
||||
"translationApiKey_description": "Ключ API для перевода (только эндпойнт глобального сервиса)",
|
||||
"translationApiKey": "Ключ API перевода",
|
||||
"translationApiProvider_description": "Поставщик API для перевода",
|
||||
"translationApiProvider": "Поставщик API перевода",
|
||||
"translationTargetLanguage_description": "На какой язык выполнять перевод",
|
||||
"translationTargetLanguage": "На какой язык переводить",
|
||||
"trayEnabled_description": "Показать/скрыть иконку/меню в трее. Если скрыто, то также отключается сворачивать в трей/свернуть в трей при выходе",
|
||||
"trayEnabled": "Показать в трее",
|
||||
"queryBuilder": "Создатель очереди",
|
||||
"queryBuilderCustomFields_inputLabel": "Метка",
|
||||
"queryBuilderCustomFields_inputTag": "Тег",
|
||||
"queryBuilderCustomFields": "Пользовательские поля",
|
||||
"queryBuilderCustomFields_description": "Добавь пользовательские поля для использования создателями очереди",
|
||||
"hotkey_listNavigateToPage": "Перейти к странице элемента",
|
||||
"hotkey_listPlayDefault": "Воспроизвести список",
|
||||
"hotkey_listPlayLast": "Воспроизвести последний в списке",
|
||||
"hotkey_listPlayNext": "Воспроизвести следующий в списке",
|
||||
"sidebarPlaylistFolders_description": "Создать вид папки для плейлистов, которые включают настраиваемый разделитель в имени"
|
||||
},
|
||||
"releaseType": {
|
||||
"secondary": {
|
||||
@@ -1162,6 +1275,56 @@
|
||||
"presetName": "Название пресета",
|
||||
"presetNamePlaceholder": "Введите название пресета",
|
||||
"general": "Главная",
|
||||
"lineWidth": "Ширина линии"
|
||||
"lineWidth": "Ширина линии",
|
||||
"systemAudioConsentAllow": "Разрешить",
|
||||
"systemAudioConsentBody": "Для работы визуализатора требуется доступ к аудио в системе",
|
||||
"systemAudioConsentDecline": "Запретить",
|
||||
"systemAudioConsentTitle": "Разрешить доступ к аудио в системе?",
|
||||
"systemAudioCaptureFailed": "Не удается начать захват: {{message}}",
|
||||
"visualizerType": "Тип визуализатора",
|
||||
"cyclePresets": "Переключаться между наборами настроек",
|
||||
"cycleTime": "Время между переключениями (в секундах)",
|
||||
"includeAllPresets": "Включить все наборы настроек",
|
||||
"ignoredPresets": "Игнорируемые наборы настроек",
|
||||
"selectedPresets": "Выбранные наборы настроек",
|
||||
"randomizeNextPreset": "Выбирать следующий набор настроек случайным образом",
|
||||
"blendTime": "Время смешивания",
|
||||
"mode": "Режим",
|
||||
"mode1To8": "Режимы 1-8",
|
||||
"mode10": "Режим 10",
|
||||
"maxFPS": "Максимум кадров в секунду",
|
||||
"opacity": "Прозрачность",
|
||||
"customGradients": "Пользовательские градиенты",
|
||||
"addCustomGradient": "Добавить пользовательский градиент",
|
||||
"gradientName": "Название градиента",
|
||||
"gradientNamePlaceholder": "Название градиента",
|
||||
"vertical": "Вертикальный",
|
||||
"horizontal": "Горизонтальный",
|
||||
"addColor": "Добавить цвет",
|
||||
"position": "Расположение",
|
||||
"level": "Уровень",
|
||||
"remove": "Удалить",
|
||||
"pasteGradient": "Вставить градиент",
|
||||
"pasteGradientPlaceholder": "Вставить JSON с градиентом сюда...",
|
||||
"custom": "Пользовательский",
|
||||
"builtIn": "Встроенный",
|
||||
"colors": "Цвета",
|
||||
"colorMode": "Цветовой режим",
|
||||
"gradient": "Градиент",
|
||||
"gradientLeft": "Градиент слева",
|
||||
"gradientRight": "Градиент справа",
|
||||
"smoothing": "Сглаживание",
|
||||
"minimumFrequency": "Минимальная частота",
|
||||
"maximumFrequency": "Максимальная частота",
|
||||
"sensitivity": "Чуствительность",
|
||||
"minimumDecibels": "Минимум децибел",
|
||||
"maximumDecibels": "Максимум децибел",
|
||||
"linearAmplitude": "Линейная амплитуда",
|
||||
"showPeaks": "Показывать пики"
|
||||
},
|
||||
"dragDropZone": {
|
||||
"error_oneFileOnly": "Выбери только 1 файл",
|
||||
"error_readingFile": "Проблема при чтении файла: {{errorMessage}}",
|
||||
"mainText": "Перемести файл сюда"
|
||||
}
|
||||
}
|
||||
|
||||
+210
-34
@@ -9,7 +9,10 @@
|
||||
"viewPlaylists": "$t(entity.playlist, {\"count\": 2}) listesini görüntüle",
|
||||
"openIn": {
|
||||
"lastfm": "Last.fm'de aç",
|
||||
"musicbrainz": "MusicBrainz'da aç"
|
||||
"musicbrainz": "MusicBrainz'da aç",
|
||||
"spotify": "Spotify'da aç",
|
||||
"listenbrainz": "ListenBrainz'de aç",
|
||||
"qobuz": "Qobuz'da aç"
|
||||
},
|
||||
"addToFavorites": "$t(entity.favorite, {\"count\": 2}) listesine ekle",
|
||||
"addToPlaylist": "$t(entity.playlist, {\"count\": 1}) listesine ekle",
|
||||
@@ -29,7 +32,17 @@
|
||||
"selectAll": "Tümünü seç",
|
||||
"downloadStarted": "{{count}} öğenin indirilmesine başlandı",
|
||||
"moveUp": "Yukarı kaydır",
|
||||
"moveDown": "Aşağı kaydır"
|
||||
"moveDown": "Aşağı kaydır",
|
||||
"collapseAllFolders": "Tüm klasörleri kapat",
|
||||
"expandAllFolders": "Tüm klasörleri genişlet",
|
||||
"holdToMoveToTop": "En üste taşımak için basılı tut",
|
||||
"holdToMoveToBottom": "En aşağıya taşımak için basılı tut",
|
||||
"moveItems": "Öğeleri taşı",
|
||||
"shuffle": "Karışık çal",
|
||||
"shuffleAll": "Tümünü karıştır",
|
||||
"shuffleSelected": "Seçilileri karıştır",
|
||||
"viewMore": "Daha fazlasını görüntüle",
|
||||
"openApplicationDirectory": "Uygulama dizinini aç"
|
||||
},
|
||||
"common": {
|
||||
"action_one": "Eylem",
|
||||
@@ -51,7 +64,7 @@
|
||||
"clear": "Temizle",
|
||||
"close": "Kapat",
|
||||
"codec": "Codec",
|
||||
"comingSoon": "ÇOK yakında…",
|
||||
"comingSoon": "Çok yakında…",
|
||||
"configure": "Yapılandır",
|
||||
"confirm": "Onayla",
|
||||
"create": "Oluştur",
|
||||
@@ -108,8 +121,8 @@
|
||||
"saveAndReplace": "Kaydet ve değiştir",
|
||||
"saveAs": "Farklı kaydet",
|
||||
"search": "Arama",
|
||||
"setting_one": "Ayarlar",
|
||||
"setting_other": "",
|
||||
"setting_one": "Ayar",
|
||||
"setting_other": "Ayarlar",
|
||||
"share": "Paylaş",
|
||||
"size": "Boyut",
|
||||
"sortOrder": "Sıralama düzeni",
|
||||
@@ -131,15 +144,38 @@
|
||||
"private": "Gizli",
|
||||
"clean": "Temiz",
|
||||
"countSelected": "{{count}} adet seçildi",
|
||||
"public": "Herkese açık"
|
||||
"public": "Herkese açık",
|
||||
"doNotShowAgain": "Tekrar gösterme",
|
||||
"view": "Görüntüle",
|
||||
"example": "Örnek",
|
||||
"externalLinks": "Dış bağlantılar",
|
||||
"openFolder": "Klasörü aç",
|
||||
"filter_single": "Tekli",
|
||||
"filter_multiple": "Çoklu",
|
||||
"numberOfResults": "{{numberOfResults}} sonuç",
|
||||
"retry": "Tekrar dene",
|
||||
"releaseType": "Yayın türü",
|
||||
"rename": "Adını değiştir",
|
||||
"slower": "Daha yavaş",
|
||||
"sort": "Sıralama",
|
||||
"explicit": "Sansürsüz",
|
||||
"tableColumns": "Tablo sütunları",
|
||||
"itemsMore": "{{count}} fazla",
|
||||
"newVersionAvailable": "Yeni bir sürüm mevcut",
|
||||
"explicitStatus": "Sansür durumu",
|
||||
"back": "Geri",
|
||||
"faster": "Daha hızlı",
|
||||
"grouping": "Gruplandırma",
|
||||
"mood": "Ruh hali",
|
||||
"noFilters": "Hiçbir filtre yapılandırılmadı"
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Albüm",
|
||||
"album_other": "Albümler",
|
||||
"albumArtist_one": "Albüm sanatçısı",
|
||||
"albumArtist_other": "Albüm sanatçıları",
|
||||
"albumArtist_one": "Albüm Sanatçısı",
|
||||
"albumArtist_other": "Albüm Sanatçıları",
|
||||
"albumArtistCount_one": "{{count}} albüm sanatçısı",
|
||||
"albumArtistCount_other": "{{count}} albüm sanatçıları",
|
||||
"albumArtistCount_other": "{{count}} albüm sanatçısı",
|
||||
"albumWithCount_one": "{{count}} albüm",
|
||||
"albumWithCount_other": "{{count}} albüm",
|
||||
"artist_one": "Sanatçı",
|
||||
@@ -155,10 +191,10 @@
|
||||
"genre_one": "Tür",
|
||||
"genre_other": "Türler",
|
||||
"genreWithCount_one": "{{count}} tür",
|
||||
"genreWithCount_other": "{{count}} türler",
|
||||
"genreWithCount_other": "{{count}} tür",
|
||||
"playlist_one": "Çalma listesi",
|
||||
"playlist_other": "Çalma listeleri",
|
||||
"play_one": "{{count}} oynat",
|
||||
"play_one": "{{count}} oynatma",
|
||||
"play_other": "{{count}} oynatma",
|
||||
"playlistWithCount_one": "{{count}} oynatma listesi",
|
||||
"playlistWithCount_other": "{{count}} oynatma listesi",
|
||||
@@ -169,8 +205,10 @@
|
||||
"song_other": "Şarkılar",
|
||||
"trackWithCount_one": "{{count}} parça",
|
||||
"trackWithCount_other": "{{count}} parça",
|
||||
"radioStation_one": "Radyo istasyonu",
|
||||
"radioStation_other": "Radyo istasyonları"
|
||||
"radioStation_one": "Radyo İstasyonu",
|
||||
"radioStation_other": "Radyo İstasyonları",
|
||||
"radioStationWithCount_one": "{{count}} radyo istasyonu",
|
||||
"radioStationWithCount_other": "{{count}} radyo istasyonu"
|
||||
},
|
||||
"error": {
|
||||
"apiRouteError": "İstek yönlendirilemiyor",
|
||||
@@ -196,7 +234,11 @@
|
||||
"playbackError": "Medya oynatmayı çalışırken bir hata meydana geldi",
|
||||
"credentialsRequired": "Ki̇mli̇k bi̇lgi̇leri̇ gerekli",
|
||||
"remoteDisableError": "Uzak sunucuyu $t(common.disable) yapmaya çalışırken bir hata oluştu",
|
||||
"remoteEnableError": "Uzak sunucuyu $t(common.enable) yapmaya çalışırken bir hata oluştu"
|
||||
"remoteEnableError": "Uzak sunucuyu $t(common.enable) yapmaya çalışırken bir hata oluştu",
|
||||
"invalidJson": "Geçersiz JSON",
|
||||
"noNetwork": "Sunucu erişilemez durumda",
|
||||
"noNetworkDescription": "Sunucuya bağlanılamadı",
|
||||
"playbackPausedDueToError": "Bir hata nedeniyle oynatma duraklatıldı"
|
||||
},
|
||||
"filter": {
|
||||
"albumCount": "$t(entity.album, {\"count\": 2}) sayısı",
|
||||
@@ -218,7 +260,7 @@
|
||||
"isRated": "Oylandı",
|
||||
"isRecentlyPlayed": "Yakın zamanda çalındı",
|
||||
"lastPlayed": "Son çalınan",
|
||||
"mostPlayed": "En çOK çalınan",
|
||||
"mostPlayed": "En çok çalınan",
|
||||
"name": "İsim",
|
||||
"note": "Not",
|
||||
"owner": "$t(common.owner)",
|
||||
@@ -240,7 +282,9 @@
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"channels": "$t(common.channel_other)"
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"sortName": "Adı sırala",
|
||||
"explicitStatus": "$t(common.explicitStatus)"
|
||||
},
|
||||
"form": {
|
||||
"addServer": {
|
||||
@@ -256,13 +300,18 @@
|
||||
"success": "Sunucu başarıyla eklendi",
|
||||
"title": "Sunucu ekle",
|
||||
"input_preferInstantMix": "Anında mix tercih et",
|
||||
"input_preferInstantMixDescription": "Sadece benzer şarkılari bulmak icin anında mix kullan. Bu davranışı değiştiren eklentilere sahipseniz faydalı"
|
||||
"input_preferInstantMixDescription": "Sadece benzer şarkılari bulmak icin anında mix kullan. Bu davranışı değiştiren eklentilere sahipseniz faydalı",
|
||||
"input_preferRemoteUrl": "Herkese Açık URL'yi Tercih Et",
|
||||
"input_remoteUrl": "Herkese Açık URL",
|
||||
"input_remoteUrlPlaceholder": "İsteğe bağlı: Dışardan erişim özellikleri için genel URL"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||
"input_skipDuplicates": "Kopyaları atla",
|
||||
"title": "$t(entity.playlist, {\"count\": 1}) listesine ekle",
|
||||
"success": "$t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} }) $t(entity.trackWithCount, {\"count\": {{message}} }) eklendi"
|
||||
"success": "$t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} }) $t(entity.trackWithCount, {\"count\": {{message}} }) eklendi",
|
||||
"create": "$t(entity.playlist, {\"count\": 1}) {{playlist}} oluştur",
|
||||
"noneAdded": "$t(entity.playlist, {\"count\": 1}) {{playlist}} çalma listesine parça eklenmedi"
|
||||
},
|
||||
"createPlaylist": {
|
||||
"input_description": "$t(common.description)",
|
||||
@@ -290,7 +339,10 @@
|
||||
"queryEditor": {
|
||||
"title": "Sorgu düzenleyici",
|
||||
"input_optionMatchAll": "Hepsini eşleştir",
|
||||
"input_optionMatchAny": "Herhangi biriyle eşleştir"
|
||||
"input_optionMatchAny": "Herhangi biriyle eşleştir",
|
||||
"removeRuleGroup": "Kural grubunu kaldır",
|
||||
"resetToDefault": "Varsayılan ayarlara dön",
|
||||
"clearFilters": "Filtreleri temizle"
|
||||
},
|
||||
"shareItem": {
|
||||
"allowDownloading": "İndirmeye izin ver",
|
||||
@@ -298,7 +350,9 @@
|
||||
"setExpiration": "Sona erme tarihi ayarla",
|
||||
"success": "Paylaşma bağlantısı panoya kopyalandı (veya açmak için buraya tıklayın)",
|
||||
"expireInvalid": "Son kullanma tarihi gelecekte olmalı",
|
||||
"createFailed": "Paylaşım oluşturulamadı (paylaşım etkin mi?)"
|
||||
"createFailed": "Paylaşım oluşturulamadı (paylaşım etkin mi?)",
|
||||
"copyToClipboard": "Panoya kopyala: Ctrl+C, Enter",
|
||||
"successMustClick": "Paylaşım başarıyla oluşturuldu. Görüntülemek için tıklayın"
|
||||
},
|
||||
"updateServer": {
|
||||
"success": "Sunucu başarıyla güncellendi",
|
||||
@@ -308,6 +362,24 @@
|
||||
"enabled": "Gizli mod etkinleştirildi, oynatma durumu artık harici eklentilerden gizlendi",
|
||||
"disabled": "Gizli mod devre dışı bırakıldı, oynatma durumu artık etkinleştirilmiş harici eklentiler tarafından görülebilir",
|
||||
"title": "Gizli mod"
|
||||
},
|
||||
"largeFetchConfirmation": {
|
||||
"title": "Öğeleri oynatma kuyruğuna ekle",
|
||||
"description": "Bu işlem, mevcut filtrelenmiş görünümdeki tüm öğeleri kuyruğa ekler"
|
||||
},
|
||||
"lyricsExport": {
|
||||
"input_synced": "Senkronize şarkı sözlerini dışa aktar"
|
||||
},
|
||||
"saveQueue": {
|
||||
"success": "Oynatma kuyruğu sunucuya kaydedildi"
|
||||
},
|
||||
"shuffleAll": {
|
||||
"title": "Rastgele çal",
|
||||
"input_kind_albums": "Albümler",
|
||||
"input_kind_songs": "Şarkılar",
|
||||
"input_kind": "Karışık seçimler",
|
||||
"input_limit_albums": "Kaç tane albüm?",
|
||||
"input_limit_songs": "Kaç tane şarkı?"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
@@ -320,7 +392,8 @@
|
||||
"topSongs": "En iyi şarkılar",
|
||||
"viewAll": "Tümünü görüntüle",
|
||||
"viewAllTracks": "Tüm $t(entity.track, {\"count\": 2}) görüntüle",
|
||||
"topSongsFrom": "{{title}} tarafından en iyi şarkılar"
|
||||
"topSongsFrom": "{{title}} tarafından en iyi şarkılar",
|
||||
"favoriteSongsFrom": "{{title}}’dan favori şarkılar"
|
||||
},
|
||||
"contextMenu": {
|
||||
"addLast": "$t(player.addLast)",
|
||||
@@ -397,7 +470,8 @@
|
||||
"mostPlayed": "En çok çalınan",
|
||||
"newlyAdded": "Yeni eklenenler",
|
||||
"recentlyPlayed": "Yakınlarda çalınanlar",
|
||||
"title": "$t(common.home)"
|
||||
"title": "$t(common.home)",
|
||||
"recentlyReleased": "Son çıkanlar"
|
||||
},
|
||||
"itemDetail": {
|
||||
"copyPath": "Yolu panoya kopyala",
|
||||
@@ -415,7 +489,13 @@
|
||||
"generalTab": "Genel",
|
||||
"hotkeysTab": "Kısayol tuşları",
|
||||
"playbackTab": "Oynatma",
|
||||
"windowTab": "Pencere"
|
||||
"windowTab": "Pencere",
|
||||
"analytics": "Analitik",
|
||||
"updates": "Güncelleme",
|
||||
"cache": "Önbellek",
|
||||
"application": "Uygulama",
|
||||
"exportImport": "İçe/dışa aktarma",
|
||||
"lyrics": "Sözler"
|
||||
},
|
||||
"sidebar": {
|
||||
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
|
||||
@@ -430,7 +510,8 @@
|
||||
"search": "$t(common.search)",
|
||||
"settings": "$t(common.setting, {\"count\": 2})",
|
||||
"shared": "Paylaşılan $t(entity.playlist, {\"count\": 2})",
|
||||
"tracks": "$t(entity.track, {\"count\": 2})"
|
||||
"tracks": "$t(entity.track, {\"count\": 2})",
|
||||
"collections": "Koleksiyonlar"
|
||||
},
|
||||
"trackList": {
|
||||
"artistTracks": "{{artist}} parçaları",
|
||||
@@ -463,6 +544,17 @@
|
||||
"version": "{{version}} sürümü",
|
||||
"privateModeOff": "Gizli modu kapat",
|
||||
"privateModeOn": "Gizli modu aç"
|
||||
},
|
||||
"radioList": {
|
||||
"title": "Radyo istasyonları"
|
||||
},
|
||||
"releasenotes": {
|
||||
"commitsSinceStable": "{{stable}}’dan sonraki commit’ler",
|
||||
"noNewCommits": "Bu aralıkta yeni commit yok",
|
||||
"noStableReleaseToCompare": "Karşılaştırılacak stabil sürüm bulunamadı"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "$t(entity.favorite, {\"count\": 2})"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -496,7 +588,15 @@
|
||||
"toggleFullscreenPlayer": "Tam ekran oynatıcıya geç",
|
||||
"unfavorite": "Favoriden kaldır",
|
||||
"pause": "Durdur",
|
||||
"viewQueue": "Kuyruğu görüntüle"
|
||||
"viewQueue": "Kuyruğu görüntüle",
|
||||
"albumRadio": "Aldüm radyosu",
|
||||
"artistRadio": "Sanatçı radyosu",
|
||||
"holdToShuffle": "Karıştırmak için basılı tut",
|
||||
"lyrics": "Sözler",
|
||||
"sleepTimer_endOfAlbum": "Mevcut albümün sonu",
|
||||
"sleepTimer_minutes": "{{count}} dakika",
|
||||
"sleepTimer_hours": "{{count}} saat",
|
||||
"sleepTimer_cancel": "Zamanlayıcıyı iptal et"
|
||||
},
|
||||
"setting": {
|
||||
"accentColor": "Vurgu rengi",
|
||||
@@ -545,7 +645,7 @@
|
||||
"discordListening_description": "Durumu çalma yerine dinleme olarak göster",
|
||||
"discordRichPresence_description": "{{discord}} \"Rich Presence\" oynatma durumunu etkinleştirin. Görüntü tuşları şunlardır: {{icon}}, {{playing}} ve {{paused}}",
|
||||
"discordServeImage": "Sunucudan {{discord}} resimleri servis et",
|
||||
"discordServeImage_description": "Sunucudan {{discord}} rich presence için kapak resmi paylaşın, yalnızca Jellyfin ve Navidrome için kullanılabilir",
|
||||
"discordServeImage_description": "{{discord}} zengin durum bilgisi için kapak görselini sunucudan paylaşır. Yalnızca Jellyfin ve Navidrome için kullanılabilir. {{discord}}, görselleri almak için bir bot kullanır, bu nedenle sunucunuzun internet üzerinden erişilebilir olması gerekir",
|
||||
"discordUpdateInterval": "{{discord}} rich presence güncelleme aralığı",
|
||||
"discordUpdateInterval_description": "Her güncelleme arasındaki saniye cinsinden süre (minimum 15 saniye)",
|
||||
"gaplessAudio": "Aralıksız ses",
|
||||
@@ -733,31 +833,87 @@
|
||||
"releaseChannel_optionBeta": "Beta",
|
||||
"releaseChannel_optionLatest": "En son",
|
||||
"language": "Dil",
|
||||
"notify": "Müzik bildirimi aktivleştir"
|
||||
"notify": "Müzik bildirimi aktivleştir",
|
||||
"autoDJ": "Otomatik DJ",
|
||||
"autoDJ_itemCount": "Öğe sayısı",
|
||||
"autoDJ_itemCount_description": "Kuyruğa eklenmeye çalışılan öğe sayısı",
|
||||
"autoDJ_timing": "Zamanlama",
|
||||
"autoDJ_timing_description": "Otomatik DJ başlamadan önce kuyrukta kalan şarkı sayısı",
|
||||
"autoDJ_songStrategy": "Şarkı seçim modu",
|
||||
"autoDJ_strategy_option_library_random": "Rastgele",
|
||||
"autoDJ_strategy_option_similar": "Benzer",
|
||||
"autosave": "Oynatma kuyruğunu otomatik kaydet",
|
||||
"autosave_description": "Oynatma kuyruğunu sunucunuza otomatik olarak kaydetmeyi etkinleştirin. Bu özellik yalnızca Navidrome/Subsonic kullanırken mümkündür ve karışık bir oynatma kuyruğu kullanamazsınız.",
|
||||
"autosaveCount": "Oynatma kuyruğu otomatik kaydetme sıklığı",
|
||||
"autosaveCount_description": "Kuyruğun kaydedilmesinden önce kaç parça değişikliği yapılmalı. 1 (minimum) her şarkı değişiminde kaydeder",
|
||||
"useThemeAccentColor": "Tema ana rengini kullan",
|
||||
"useThemeAccentColor_description": "Özel vurgu rengi yerine seçili temada tanımlı ana rengi kullanın",
|
||||
"useThemePrimaryShade": "Tema ana renginin tonunu kullan",
|
||||
"artistReleaseTypeConfiguration_description": "Albüm sanatçısı sayfasında hangi yayın türlerinin gösterileceğini ve hangi sırayla listeleneceğini yapılandırın",
|
||||
"automaticUpdates_description": "Güncellemeleri otomatik olarak kontrol et ve yükle",
|
||||
"releaseChannel_description": "Otomatik güncellemeler için stable, beta veya alfa (nightly) sürümler arasından seçim yapın",
|
||||
"discordLinkType_description": "Şarkı ve sanatçı alanlarına {{discord}} zengin durum bilgisinde {{lastfm}} veya {{musicbrainz}} için harici bağlantılar ekler. {{musicbrainz}} en doğru seçenektir ancak etiketlere ihtiyaç duyar ve sanatçı bağlantısı sağlamaz; buna karşılık {{lastfm}} her zaman bir bağlantı sunar. Ekstra ağ isteği oluşturmaz",
|
||||
"discordLinkType_mbz_lastfm": "{{musicbrainz}} kullan, {{lastfm}} yedek olarak",
|
||||
"discordLinkType_none": "$t(common.none)",
|
||||
"discordLinkType": "{{discord}} zengin durum bağlantıları",
|
||||
"exportImportSettings_control_exportText": "Ayarları dışa aktar",
|
||||
"exportImportSettings_control_importText": "Ayarları içe aktar",
|
||||
"exportImportSettings_control_title": "Ayarları içe / dışa aktar",
|
||||
"exportImportSettings_destructiveWarning": "Ayarları içe aktarmak geri alınamaz bir işlemdir, lütfen yukarıdakileri gözden geçirin ve ardından aşağıdaki \"İçe Aktar\" butonuna tıklayın!",
|
||||
"exportImportSettings_importBtn": "Ayarları içe aktar",
|
||||
"exportImportSettings_importModalTitle": "Feishin ayarlarını içe aktar",
|
||||
"exportImportSettings_importSuccess": "Ayarlar başarı ile içeri aktarıldı!",
|
||||
"exportImportSettings_notValidJSON": "Yüklenen dosya geçerli JSON formatında değil",
|
||||
"listenbrainz": "ListenBrainz bağlantılarını göster",
|
||||
"logLevel": "Log seviyesi",
|
||||
"logLevel_description": "Görüntülenecek minimum log seviyesini ayarlar. Debug tüm logları gösterir, error yalnızca hataları gösterir",
|
||||
"logLevel_optionDebug": "Hata ayıklama",
|
||||
"logLevel_optionError": "Hata",
|
||||
"logLevel_optionInfo": "Bilgilendirme",
|
||||
"logLevel_optionWarn": "Uyarı",
|
||||
"pathReplace_description": "Sunucunun varsayılan dosya yolunu değiştir",
|
||||
"pathReplace_optionRemovePrefix": "Öneki kaldır",
|
||||
"pathReplace_optionAddPrefix": "Ön ek ekle",
|
||||
"imageResolution": "Resim çözünürlüğü",
|
||||
"imageResolution_description": "Uygulama genelinde kullanılan görsellerin çözünürlüğü. 0 değeri kullanılırsa varsayılan olarak görselin orijinal çözünürlüğü kullanılır",
|
||||
"imageResolution_optionTable": "Tablo",
|
||||
"playerbarWaveformAlign_optionCenter": "Orta",
|
||||
"showLyricsInSidebar": "Oynatıcı yan panelinde şarkı sözlerini göster",
|
||||
"blurExplicitImages": "Müstehcen görselleri bulanıklaştır",
|
||||
"blurExplicitImages_description": "Sansürsüz olarak işaretlenen albüm ve şarkı kapakları bulanıklaştırılır",
|
||||
"enableGridMultiSelect": "Izgara çoklu seçimi etkinleştir",
|
||||
"enableGridMultiSelect_description": "Etkinleştirildiğinde, ızgara görünümünde birden fazla öğe seçmeye izin verir. Devre dışı bırakıldığında, ızgara öğelerinin görsellerine tıklamak öğe sayfasına yönlendirir",
|
||||
"showVisualizerInSidebar_description": "Oynatıcı yan paneline görselleştiriciyi gösteren bir panel eklenecektir",
|
||||
"showVisualizerInSidebar": "Oynatıcı yan panelinde görselleştiriciyi göster",
|
||||
"combinedLyricsAndVisualizer_description": "Şarkı sözleri ve görselleştiriciyi aynı panelde birleştir",
|
||||
"combinedLyricsAndVisualizer": "Şarkı sözleri ve görselleştiriciyi oynatıcı yan panelinde birleştir",
|
||||
"sidebarPlaylistFolderSeparator": "Klasör ayırıcı",
|
||||
"sidebarPlaylistFolderView_optionSingle": "Tek klasör",
|
||||
"sidebarPlaylistFolderView_optionTree": "Ağaç liste görünümü"
|
||||
},
|
||||
"table": {
|
||||
"column": {
|
||||
"album": "Albüm",
|
||||
"albumArtist": "Albüm sanatçısı",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"albumCount": "Albümler",
|
||||
"artist": "Sanatçı",
|
||||
"biography": "Biyografi",
|
||||
"bitrate": "Bit hızı",
|
||||
"bpm": "BPM (dakika başına vuruş)",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"codec": "$t(common.codec)",
|
||||
"channels": "Kanallar",
|
||||
"codec": "Kodek",
|
||||
"comment": "Yorum",
|
||||
"dateAdded": "Tarih eklendi",
|
||||
"discNumber": "Disk",
|
||||
"favorite": "Favori",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"genre": "Tür",
|
||||
"lastPlayed": "Son çalınan",
|
||||
"path": "Yol",
|
||||
"playCount": "Oynatılıyor",
|
||||
"rating": "Derecelendirme",
|
||||
"releaseDate": "Çıkış tarihi",
|
||||
"releaseYear": "Yıl",
|
||||
"size": "$t(common.size)",
|
||||
"size": "Boyut",
|
||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
||||
"title": "Başlık",
|
||||
"trackNumber": "Parça"
|
||||
@@ -814,11 +970,31 @@
|
||||
"secondary": {
|
||||
"demo": "Demo",
|
||||
"live": "Canlı",
|
||||
"remix": "Remix"
|
||||
"remix": "Remix",
|
||||
"audiobook": "Sesli kitap"
|
||||
},
|
||||
"primary": {
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
"broadcast": "Yayın",
|
||||
"ep": "EP",
|
||||
"other": "Diğer",
|
||||
"single": "Single"
|
||||
}
|
||||
},
|
||||
"dragDropZone": {
|
||||
"error_oneFileOnly": "Lütfen sadece 1 dosya seç",
|
||||
"error_readingFile": "Bu dosyayi okurken bir sorun oluştu :{{errorMessage}}"
|
||||
},
|
||||
"datetime": {
|
||||
"minuteShort": "dk",
|
||||
"secondShort": "sn",
|
||||
"hourShort": "sa",
|
||||
"dayShort": "g"
|
||||
},
|
||||
"filterOperator": {
|
||||
"notContains": "İçermez"
|
||||
},
|
||||
"queryBuilder": {
|
||||
"customTags": "Özel etiketler"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,22 +5,22 @@
|
||||
"bitrate": "位元率",
|
||||
"bpm": "BPM",
|
||||
"clear": "清空",
|
||||
"collapse": "折疊",
|
||||
"collapse": "摺疊",
|
||||
"comingSoon": "即將推出…",
|
||||
"confirm": "確認",
|
||||
"decrease": "降低",
|
||||
"delete": "刪除",
|
||||
"descending": "降冪",
|
||||
"description": "描述",
|
||||
"forceRestartRequired": "重新啟動應用程式以使更改生效…關閉通知後即可重啟",
|
||||
"forceRestartRequired": "重啟以套用變更… 關閉通知後即可重啟",
|
||||
"menu": "選單",
|
||||
"action_other": "操作",
|
||||
"add": "新增",
|
||||
"areYouSure": "你確定嗎?",
|
||||
"ascending": "升冪",
|
||||
"disable": "禁用",
|
||||
"disable": "停用",
|
||||
"disc": "光碟",
|
||||
"dismiss": "不再顯示",
|
||||
"dismiss": "不理會",
|
||||
"duration": "時長",
|
||||
"edit": "編輯",
|
||||
"enable": "啟用",
|
||||
@@ -31,7 +31,7 @@
|
||||
"forward": "前進",
|
||||
"gap": "空隙",
|
||||
"home": "首頁",
|
||||
"increase": "增高",
|
||||
"increase": "提升",
|
||||
"left": "左",
|
||||
"limit": "限制",
|
||||
"manage": "管理",
|
||||
@@ -40,14 +40,14 @@
|
||||
"owner": "所有者",
|
||||
"path": "路徑",
|
||||
"playerMustBePaused": "播放器必須先暫停",
|
||||
"previousSong": "上一首$t(entity.track, {\"count\": 1})",
|
||||
"previousSong": "上一首 $t(entity.track, {\"count\": 1})",
|
||||
"quit": "退出",
|
||||
"random": "隨機",
|
||||
"rating": "評分",
|
||||
"refresh": "重新整理",
|
||||
"reset": "重置",
|
||||
"resetToDefault": "恢復為預設值",
|
||||
"restartRequired": "需要重新啟動應用程式",
|
||||
"resetToDefault": "重置為預設",
|
||||
"restartRequired": "需要重新啟動",
|
||||
"right": "右",
|
||||
"save": "儲存",
|
||||
"saveAndReplace": "儲存並取代",
|
||||
@@ -55,7 +55,7 @@
|
||||
"search": "搜尋",
|
||||
"sortOrder": "順序",
|
||||
"title": "標題",
|
||||
"trackNumber": "音軌編號",
|
||||
"trackNumber": "曲目",
|
||||
"unknown": "未知",
|
||||
"size": "大小",
|
||||
"version": "版本",
|
||||
@@ -64,24 +64,24 @@
|
||||
"cancel": "取消",
|
||||
"center": "中央",
|
||||
"channel_other": "聲道",
|
||||
"configure": "設定",
|
||||
"configure": "配置",
|
||||
"create": "建立",
|
||||
"currentSong": "目前$t(entity.track, {\"count\": 1})",
|
||||
"currentSong": "當前 $t(entity.track, {\"count\": 1})",
|
||||
"minimize": "最小化",
|
||||
"modified": "已修改",
|
||||
"name": "名稱",
|
||||
"no": "否",
|
||||
"none": "無",
|
||||
"noResultsFromQuery": "未查詢到匹配結果",
|
||||
"noResultsFromQuery": "查詢回傳了無結果",
|
||||
"note": "注釋",
|
||||
"additionalParticipants": "額外參與者",
|
||||
"newVersion": "已安裝新版本 ({{version}})",
|
||||
"newVersion": "新版本 ({{version}}) 已被安裝",
|
||||
"viewReleaseNotes": "查看發行註記",
|
||||
"albumGain": "專輯增益",
|
||||
"albumPeak": "專輯峰值",
|
||||
"bitDepth": "位元深度",
|
||||
"close": "關閉",
|
||||
"codec": "編碼",
|
||||
"codec": "編解碼器",
|
||||
"mbid": "MusicBrainz ID",
|
||||
"preview": "預覽",
|
||||
"reload": "重新載入",
|
||||
@@ -105,9 +105,9 @@
|
||||
"clean": "清除",
|
||||
"explicitStatus": "露骨狀態",
|
||||
"explicit": "露骨",
|
||||
"gridRows": "網格行",
|
||||
"noFilters": "未設定任何過濾器",
|
||||
"countSelected": "{{count}}個已選取",
|
||||
"gridRows": "網格列",
|
||||
"noFilters": "未配置篩選器",
|
||||
"countSelected": "{{count}} 個已選取",
|
||||
"retry": "重試",
|
||||
"example": "範例",
|
||||
"mood": "情緒",
|
||||
@@ -116,45 +116,45 @@
|
||||
"itemsMore": "{{count}} 更多",
|
||||
"filter_single": "單選",
|
||||
"filter_multiple": "複選",
|
||||
"newVersionAvailable": "有新的版本可供使用",
|
||||
"newVersionAvailable": "有新版本可用",
|
||||
"numberOfResults": "{{numberOfResults}} 項結果",
|
||||
"grouping": "分組",
|
||||
"back": "返回",
|
||||
"openFolder": "開啟資料夾"
|
||||
},
|
||||
"error": {
|
||||
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
|
||||
"apiRouteError": "請求失敗:無法路由",
|
||||
"audioDeviceFetchError": "無法取得音訊設備",
|
||||
"endpointNotImplementedError": "{{serverType}} 尚未實作端點 {{endpoint}}",
|
||||
"apiRouteError": "無法路由請求",
|
||||
"audioDeviceFetchError": "嘗試取得音訊裝置時發生了錯誤",
|
||||
"authenticationFailed": "驗證失敗",
|
||||
"credentialsRequired": "需要憑證",
|
||||
"genericError": "發生了錯誤",
|
||||
"invalidServer": "無效的伺服器",
|
||||
"localFontAccessDenied": "無法取得本地字型",
|
||||
"localFontAccessDenied": "存取本地字型被拒絕",
|
||||
"loginRateError": "登入請求嘗試次數過多,請稍後再試",
|
||||
"remoteDisableError": "$t(common.disable)遠端伺服器時出現錯誤",
|
||||
"remoteEnableError": "$t(common.enable)遠端伺服器時出現錯誤",
|
||||
"remotePortError": "設定遠端伺服器連接埠時發生錯誤",
|
||||
"remotePortWarning": "重啟伺服器使新連接埠生效",
|
||||
"remoteDisableError": "嘗試 $t(common.disable) 遠端伺服器時發生了錯誤",
|
||||
"remoteEnableError": "嘗試 $t(common.enable) 遠端伺服器時發生了錯誤",
|
||||
"remotePortError": "嘗試設定遠端伺服器連接埠時發生了錯誤",
|
||||
"remotePortWarning": "重啟伺服器以套用新連接埠",
|
||||
"serverRequired": "需要伺服器",
|
||||
"sessionExpiredError": "工作階段已過期",
|
||||
"systemFontError": "嘗試取得系統字型時出現錯誤",
|
||||
"sessionExpiredError": "您的工作階段已過期",
|
||||
"systemFontError": "嘗試取得系統字型時發生了錯誤",
|
||||
"serverNotSelectedError": "未選擇伺服器",
|
||||
"mpvRequired": "需要 MPV",
|
||||
"playbackError": "無法播放媒體",
|
||||
"badAlbum": "您看到此頁面是因為這首歌不是專輯的一部分。如果您的音樂資料夾頂層有一首歌,則很可能會看到此問題。 Jellyfin 僅將資料夾中的曲目分組",
|
||||
"playbackError": "嘗試播放媒體時發生了錯誤",
|
||||
"badAlbum": "您看到此頁面是因為這首歌不是專輯的一部分。如果您的音樂資料夾頂層有一首歌,則很可能會看到此問題。 Jellyfin 僅會將資料夾中的曲目分組",
|
||||
"badValue": "無效選項「{{value}}」。該值不再存在",
|
||||
"networkError": "發生網路錯誤",
|
||||
"notificationDenied": "通知權限被拒絕。此設定無效",
|
||||
"networkError": "發生了網路錯誤",
|
||||
"notificationDenied": "通知權限被拒絕。此設定無影響",
|
||||
"openError": "無法開啟檔案",
|
||||
"multipleServerSaveQueueError": "播放佇列中包含不是來自目前伺服器的歌曲,此操作不受支援",
|
||||
"multipleServerSaveQueueError": "播放佇列中包含了並非來自當前伺服器的歌曲。此操作不受支援",
|
||||
"saveQueueFailed": "儲存播放佇列失敗",
|
||||
"settingsSyncError": "偵測到渲染器與主程式之間的設定不一致,請重新啟動應用程式以套用變更",
|
||||
"noNetwork": "伺服器無法連線",
|
||||
"noNetworkDescription": "無法連接到此伺服器",
|
||||
"settingsSyncError": "偵測到渲染器與主程式之間的設定不一致。請重新啟動應用程式以套用變更",
|
||||
"noNetwork": "伺服器不可用",
|
||||
"noNetworkDescription": "無法連線至此伺服器",
|
||||
"invalidJson": "無效的 JSON",
|
||||
"serverLockSingleServer": "當伺服器鎖定時只允許一個伺服器",
|
||||
"playbackPausedDueToError": "發生錯誤,已停止播放"
|
||||
"playbackPausedDueToError": "播放因錯誤而暫停"
|
||||
},
|
||||
"page": {
|
||||
"contextMenu": {
|
||||
@@ -204,7 +204,7 @@
|
||||
},
|
||||
"appMenu": {
|
||||
"openBrowserDevtools": "開啟瀏覽器開發者工具",
|
||||
"collapseSidebar": "折疊側邊欄",
|
||||
"collapseSidebar": "摺疊側邊欄",
|
||||
"expandSidebar": "展開側邊欄",
|
||||
"goBack": "返回",
|
||||
"goForward": "前進",
|
||||
@@ -269,7 +269,7 @@
|
||||
"transcoding": "轉碼",
|
||||
"discord": "Discord",
|
||||
"queryBuilder": "查詢建構器",
|
||||
"playerFilters": "播放過濾器",
|
||||
"playerFilters": "播放篩選器",
|
||||
"logger": "日誌記錄器",
|
||||
"lyricsDisplay": "歌詞顯示"
|
||||
},
|
||||
@@ -381,7 +381,7 @@
|
||||
"playbackSpeed": "播放速度",
|
||||
"playRandom": "隨機播放",
|
||||
"previous": "上一首",
|
||||
"queue_clear": "清空播放佇列",
|
||||
"queue_clear": "清空佇列",
|
||||
"queue_remove": "移除所選",
|
||||
"repeat": "循環",
|
||||
"repeat_all": "全部循環",
|
||||
@@ -446,7 +446,7 @@
|
||||
"crossfadeStyle_description": "選擇用於音訊播放器的淡入淡出風格",
|
||||
"customFontPath": "自訂字型路徑",
|
||||
"customFontPath_description": "設定應用程式要使用的自訂字型路徑",
|
||||
"disableLibraryUpdateOnStartup": "禁用啟動時檢查新版本",
|
||||
"disableLibraryUpdateOnStartup": "停用啟動時檢查新版本",
|
||||
"discordApplicationId": "{{discord}} 應用程式 ID",
|
||||
"discordApplicationId_description": "{{discord}} Rich Presence 應用程式 ID(預設為 {{defaultId}})",
|
||||
"discordIdleStatus": "顯示 Rich Presence 閒置狀態",
|
||||
@@ -532,8 +532,8 @@
|
||||
"showSkipButton": "顯示跳過按鈕",
|
||||
"showSkipButton_description": "在播放條上顯示/隱藏跳過按鈕",
|
||||
"sidebarPlaylistList": "側邊欄播放清單列表",
|
||||
"sidebarCollapsedNavigation": "側邊欄(已折疊)導航",
|
||||
"sidebarCollapsedNavigation_description": "在折疊的側邊欄中顯示或隱藏導航",
|
||||
"sidebarCollapsedNavigation": "側邊欄(已摺疊)導航",
|
||||
"sidebarCollapsedNavigation_description": "在摺疊的側邊欄中顯示或隱藏導航",
|
||||
"sidebarConfiguration": "側邊欄設定",
|
||||
"sidebarConfiguration_description": "選擇側邊欄包含的項目與順序",
|
||||
"sidebarPlaylistList_description": "顯示或隱藏側邊欄歌單清單",
|
||||
@@ -562,7 +562,7 @@
|
||||
"exitToTray_description": "退出應用程式時最小化到系統匣而非關閉",
|
||||
"followLyric_description": "滾動歌詞到目前播放位置",
|
||||
"font": "字型",
|
||||
"globalMediaHotkeys_description": "啟用或禁用系統媒體快捷鍵以控制播放",
|
||||
"globalMediaHotkeys_description": "啟用或停用系統媒體快捷鍵以控制播放",
|
||||
"hotkey_browserBack": "瀏覽器返回",
|
||||
"hotkey_favoriteCurrentSong": "收藏 $t(common.currentSong)",
|
||||
"hotkey_playbackStop": "停止",
|
||||
@@ -573,7 +573,7 @@
|
||||
"remotePassword": "遠端控制伺服器密碼",
|
||||
"remotePassword_description": "設定遠端控制伺服器的密碼。這些憑證預設以不安全的方式傳輸,因此您應該使用一個您不在意的唯一密碼",
|
||||
"remotePort_description": "設定遠端控制伺服器的連接埠",
|
||||
"remoteUsername_description": "設定遠端控制伺服器的使用者名稱。如果使用者名稱和密碼都為空,則身分驗證將被禁用",
|
||||
"remoteUsername_description": "設定遠端控制伺服器的使用者名稱。如果使用者名稱和密碼都為空,則身分驗證將被停用",
|
||||
"replayGainClipping_description": "自動降低增益以防止{{ReplayGain}}造成削波",
|
||||
"showSkipButtons": "顯示跳過按鈕",
|
||||
"themeDark_description": "應用程式將使用深色主題",
|
||||
@@ -713,7 +713,7 @@
|
||||
"followCurrentSong_description": "自動將播放佇列捲動至當前播放的歌曲",
|
||||
"followCurrentSong": "跟隨當前歌曲",
|
||||
"playerbarSlider_description": "不建議在速度緩慢或計費的網路下使用波形",
|
||||
"playerFilters": "從佇列中過濾歌曲",
|
||||
"playerFilters": "從佇列中篩選歌曲",
|
||||
"playerFilters_description": "根據以下條件,排除要新增至佇列中的歌曲",
|
||||
"autoDJ": "Auto DJ",
|
||||
"autoDJ_itemCount": "項目數量",
|
||||
@@ -764,7 +764,7 @@
|
||||
"sidebarPlaylistSorting_description": "允許在側邊欄中使用拖放手動對播放清單進行排序,而不是預設的伺服器排序",
|
||||
"sidebarPlaylistListFilterRegex_description": "在側邊欄中隱藏與此正規表達式匹配的播放清單",
|
||||
"sidebarPlaylistListFilterRegex_placeholder": "範例: ^daily mix.*",
|
||||
"sidebarPlaylistListFilterRegex": "播放清單過濾器正規表達式",
|
||||
"sidebarPlaylistListFilterRegex": "播放清單篩選器正規表達式",
|
||||
"blurExplicitImages": "模糊露骨圖片",
|
||||
"blurExplicitImages_description": "標記為露骨的專輯和歌曲封面將被模糊",
|
||||
"releaseChannel_optionAlpha": "Alpha (每日建構版)",
|
||||
@@ -888,7 +888,7 @@
|
||||
"size": "$t(common.size)",
|
||||
"title": "$t(common.title)",
|
||||
"titleCombined": "$t(common.title)(合併)",
|
||||
"trackNumber": "曲目編號",
|
||||
"trackNumber": "曲目",
|
||||
"year": "$t(common.year)",
|
||||
"rating": "$t(common.rating)",
|
||||
"codec": "$t(common.codec)",
|
||||
@@ -931,36 +931,36 @@
|
||||
"bpm": "BPM",
|
||||
"songCount": "曲目",
|
||||
"title": "標題",
|
||||
"trackNumber": "曲目編號",
|
||||
"trackNumber": "曲目",
|
||||
"size": "大小",
|
||||
"codec": "編碼",
|
||||
"codec": "編解碼器",
|
||||
"owner": "擁有者",
|
||||
"bitDepth": "位元深度",
|
||||
"sampleRate": "取樣率"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"addToFavorites": "新增到$t(entity.favorite, {\"count\": 2})",
|
||||
"clearQueue": "清空播放佇列",
|
||||
"createPlaylist": "建立$t(entity.playlist, {\"count\": 1})",
|
||||
"deletePlaylist": "刪除$t(entity.playlist, {\"count\": 1})",
|
||||
"addToPlaylist": "新增到$t(entity.playlist, {\"count\": 1})",
|
||||
"addToFavorites": "新增至 $t(entity.favorite, {\"count\": 2})",
|
||||
"clearQueue": "清空佇列",
|
||||
"createPlaylist": "建立 $t(entity.playlist, {\"count\": 1})",
|
||||
"deletePlaylist": "刪除 $t(entity.playlist, {\"count\": 1})",
|
||||
"addToPlaylist": "新增至 $t(entity.playlist, {\"count\": 1})",
|
||||
"deselectAll": "取消全選",
|
||||
"editPlaylist": "編輯 $t(entity.playlist, {\"count\": 1})",
|
||||
"goToPage": "前往頁面",
|
||||
"moveToBottom": "移至底部",
|
||||
"moveToTop": "移至頂部",
|
||||
"refresh": "$t(common.refresh)",
|
||||
"removeFromFavorites": "從$t(entity.favorite, {\"count\": 2})移除",
|
||||
"removeFromPlaylist": "從$t(entity.playlist, {\"count\": 1})移除",
|
||||
"removeFromQueue": "從播放佇列中移除",
|
||||
"removeFromFavorites": "從 $t(entity.favorite, {\"count\": 2}) 移除",
|
||||
"removeFromPlaylist": "從 $t(entity.playlist, {\"count\": 1}) 移除",
|
||||
"removeFromQueue": "從佇列中移除",
|
||||
"setRating": "評分",
|
||||
"toggleSmartPlaylistEditor": "切換$t(entity.smartPlaylist)編輯器",
|
||||
"viewPlaylists": "查看$t(entity.playlist, {\"count\": 2})",
|
||||
"toggleSmartPlaylistEditor": "切換 $t(entity.smartPlaylist) 編輯器",
|
||||
"viewPlaylists": "查看 $t(entity.playlist, {\"count\": 2})",
|
||||
"moveToNext": "移至下一項",
|
||||
"openIn": {
|
||||
"lastfm": "在Last.fm開啟",
|
||||
"musicbrainz": "在MusicBrainz開啟",
|
||||
"lastfm": "在 Last.fm 中開啟",
|
||||
"musicbrainz": "在 MusicBrainz 開啟",
|
||||
"spotify": "在 Spotify 中開啟",
|
||||
"listenbrainz": "在 ListenBrainz 中開啟",
|
||||
"qobuz": "在 Qobuz 中開啟"
|
||||
@@ -998,19 +998,19 @@
|
||||
"genreWithCount_other": "{{count}} 種曲風",
|
||||
"playlist_other": "播放清單",
|
||||
"playlistWithCount_other": "{{count}} 個播放清單",
|
||||
"smartPlaylist": "智慧$t(entity.playlist, {\"count\": 1})",
|
||||
"smartPlaylist": "智慧 $t(entity.playlist, {\"count\": 1})",
|
||||
"track_other": "曲目",
|
||||
"trackWithCount_other": "{{count}} 首曲目",
|
||||
"trackWithCount_other": "{{count}} 個曲目",
|
||||
"albumWithCount_other": "{{count}} 張專輯",
|
||||
"play_other": "{{count}}次播放",
|
||||
"play_other": "{{count}} 次播放",
|
||||
"song_other": "歌曲",
|
||||
"radioStation_other": "電台",
|
||||
"radioStationWithCount_other": "{{count}} 個電台"
|
||||
},
|
||||
"filter": {
|
||||
"albumCount": "$t(entity.album, {\"count\": 2})數",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2}) 數",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"biography": "個人簡介",
|
||||
"biography": "簡介",
|
||||
"bitrate": "位元率",
|
||||
"bpm": "BPM",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
@@ -1023,14 +1023,14 @@
|
||||
"id": "ID",
|
||||
"fromYear": "從年份",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"isCompilation": "為合輯",
|
||||
"isFavorited": "已收藏",
|
||||
"isPublic": "已公開",
|
||||
"isRated": "已評分",
|
||||
"isCompilation": "是否為合輯",
|
||||
"isFavorited": "是否為收藏",
|
||||
"isPublic": "是否為公開",
|
||||
"isRated": "是否已評分",
|
||||
"name": "名稱",
|
||||
"note": "注釋",
|
||||
"isRecentlyPlayed": "最近播放過",
|
||||
"lastPlayed": "上次播放過",
|
||||
"isRecentlyPlayed": "是否最近播放過",
|
||||
"lastPlayed": "上次播放",
|
||||
"mostPlayed": "播放最多",
|
||||
"owner": "$t(common.owner)",
|
||||
"path": "路徑",
|
||||
@@ -1048,7 +1048,7 @@
|
||||
"releaseYear": "發行年份",
|
||||
"search": "搜尋",
|
||||
"title": "標題",
|
||||
"toYear": "從年份",
|
||||
"toYear": "至年份",
|
||||
"trackNumber": "曲目",
|
||||
"explicitStatus": "$t(common.explicitStatus)",
|
||||
"sortName": "排序名稱",
|
||||
@@ -1102,7 +1102,7 @@
|
||||
"title": "查詢編輯器",
|
||||
"addRuleGroup": "新增規則群組",
|
||||
"removeRuleGroup": "移除規則群組",
|
||||
"resetToDefault": "恢復為預設值",
|
||||
"resetToDefault": "重置為預設",
|
||||
"clearFilters": "清除篩選"
|
||||
},
|
||||
"updateServer": {
|
||||
@@ -1143,8 +1143,8 @@
|
||||
"input_genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"input_limit": "多少曲目?",
|
||||
"input_minYear": "起始年份",
|
||||
"input_maxYear": "結束年份",
|
||||
"input_played": "播放過濾器",
|
||||
"input_maxYear": "至年份",
|
||||
"input_played": "播放篩選器",
|
||||
"input_played_optionAll": "所有曲目",
|
||||
"input_played_optionUnplayed": "僅未播放的曲目",
|
||||
"input_played_optionPlayed": "僅播放過的曲目",
|
||||
@@ -1224,7 +1224,9 @@
|
||||
"notInPlaylist": "不在…之中",
|
||||
"startsWith": "以…開頭",
|
||||
"inTheLast": "在最後",
|
||||
"notInTheLast": "不在最後"
|
||||
"notInTheLast": "不在最後",
|
||||
"isMissing": "不存在",
|
||||
"isPresent": "存在"
|
||||
},
|
||||
"datetime": {
|
||||
"minuteShort": "分",
|
||||
@@ -1381,7 +1383,7 @@
|
||||
}
|
||||
},
|
||||
"systemAudioCaptureFailed": "無法開始擷取:{{message}}",
|
||||
"systemAudioNoAudioTrack": "沒有回傳任何音軌。確保在提示時啟用音訊擷取。",
|
||||
"systemAudioNoAudioTrack": "沒有回傳任何曲目。確保在提示時啟用音訊擷取。",
|
||||
"systemAudioConsentAllow": "允許",
|
||||
"systemAudioConsentBody": "此視覺化器需要存取系統音訊才能運作",
|
||||
"systemAudioConsentDecline": "拒絕",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import Kuroshiro from 'kuroshiro';
|
||||
import KuromojiAnalyzer from 'kuroshiro-analyzer-kuromoji';
|
||||
|
||||
// doc: https://kuroshiro.org
|
||||
|
||||
let kuroshiroInstance: any = null;
|
||||
let initPromise: null | Promise<void> = null;
|
||||
|
||||
const getKuroshiro = async () => {
|
||||
if (kuroshiroInstance) return kuroshiroInstance;
|
||||
if (initPromise) {
|
||||
await initPromise;
|
||||
return kuroshiroInstance;
|
||||
}
|
||||
|
||||
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
|
||||
kuroshiroInstance = new KuroshiroClass();
|
||||
initPromise = kuroshiroInstance.init(new KuromojiAnalyzer());
|
||||
await initPromise;
|
||||
return kuroshiroInstance;
|
||||
};
|
||||
|
||||
export const convertFurigana = async (text: string): Promise<string> => {
|
||||
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
|
||||
|
||||
// check if the text contains any Japanese kana (to distinguish Japanese from Chinese text, which shares Kanji)
|
||||
// If no Japanese kana is detected, skip processing
|
||||
if (!KuroshiroClass.Util.hasKana(text)) return text;
|
||||
|
||||
try {
|
||||
const kuroshiro = await getKuroshiro();
|
||||
return await kuroshiro.convert(text, { mode: 'furigana', to: 'hiragana' });
|
||||
} catch (e) {
|
||||
console.error('Furigana conversion error: ', e);
|
||||
return text;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
import { store } from '../settings';
|
||||
import { convertFurigana } from './furigana';
|
||||
import { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from './genius';
|
||||
import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib';
|
||||
import { getLyricsBySongId as getNetease, getSearchResults as searchNetease } from './netease';
|
||||
@@ -231,3 +232,7 @@ ipcMain.handle('lyric-by-remote-id', async (_event, params: LyricGetQuery) => {
|
||||
const lyricResults = await getRemoteLyricsById(params);
|
||||
return lyricResults;
|
||||
});
|
||||
|
||||
ipcMain.handle('lyric-convert-furigana', async (_event, text: string) => {
|
||||
return await convertFurigana(text);
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ export const orderSearchResults = (args: {
|
||||
|
||||
searchResults = Array.from(combinedResults.values());
|
||||
} else {
|
||||
searchResults = fuse.search<InternetProviderLyricSearchResponse>({
|
||||
searchResults = fuse.search({
|
||||
...(params.artist && { artist: params.artist }),
|
||||
...(params.name && { name: params.name }),
|
||||
});
|
||||
|
||||
@@ -26,7 +26,12 @@ const getRemoteLyricsByRemoteId = (id: LyricGetQuery) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const convertFurigana = (text: string): Promise<string> => {
|
||||
return ipcRenderer.invoke('lyric-convert-furigana', text);
|
||||
};
|
||||
|
||||
export const lyrics = {
|
||||
convertFurigana,
|
||||
getRemoteLyricsByRemoteId,
|
||||
getRemoteLyricsBySong,
|
||||
searchRemoteLyrics,
|
||||
|
||||
@@ -531,12 +531,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
const albumIdSet = new Set([query.id]);
|
||||
const songs = songsRes.body.Items.filter((item) => albumIdSet.has(item.AlbumId!));
|
||||
|
||||
return jfNormalize.album(
|
||||
{ ...res.body, Songs: songs },
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
);
|
||||
return jfNormalize.album({ ...res.body, Songs: songs }, apiClientProps.server);
|
||||
},
|
||||
getAlbumList: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
@@ -630,14 +625,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to get album radio songs');
|
||||
}
|
||||
|
||||
return res.body.Items.map((song) =>
|
||||
jfNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
);
|
||||
return res.body.Items.map((song) => jfNormalize.song(song, apiClientProps.server));
|
||||
},
|
||||
getArtistList: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
@@ -693,14 +681,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to get artist radio songs');
|
||||
}
|
||||
|
||||
return res.body.Items.map((song) =>
|
||||
jfNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
);
|
||||
return res.body.Items.map((song) => jfNormalize.song(song, apiClientProps.server));
|
||||
},
|
||||
getDownloadUrl: (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
@@ -870,8 +851,6 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
jfNormalize.song(
|
||||
item as unknown as z.infer<typeof jfType._response.song>,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1053,7 +1032,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
Fields: JF_FIELDS.PLAYLIST_LIST,
|
||||
IncludeItemTypes: 'Playlist',
|
||||
Limit: query.limit,
|
||||
MediaTypes: 'Audio',
|
||||
MediaTypes: 'Audio, Unknown',
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: playlistListSortMap.jellyfin[query.sortBy],
|
||||
@@ -1100,14 +1079,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.Items.map((item) =>
|
||||
jfNormalize.song(
|
||||
item,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
),
|
||||
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server)),
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.TotalRecordCount,
|
||||
};
|
||||
@@ -1160,14 +1132,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.Items.map((item) =>
|
||||
jfNormalize.song(
|
||||
item,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
),
|
||||
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server)),
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.Items.length || 0,
|
||||
};
|
||||
@@ -1219,14 +1184,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
if (res.status === 200 && res.body.Items.length) {
|
||||
const results = res.body.Items.reduce<Song[]>((acc, song) => {
|
||||
if (song.Id !== query.songId) {
|
||||
acc.push(
|
||||
jfNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
);
|
||||
acc.push(jfNormalize.song(song, apiClientProps.server));
|
||||
}
|
||||
|
||||
return acc;
|
||||
@@ -1255,14 +1213,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
|
||||
return mix.body.Items.reduce<Song[]>((acc, song) => {
|
||||
if (song.Id !== query.songId) {
|
||||
acc.push(
|
||||
jfNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
);
|
||||
acc.push(jfNormalize.song(song, apiClientProps.server));
|
||||
}
|
||||
|
||||
return acc;
|
||||
@@ -1282,12 +1233,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to get song detail');
|
||||
}
|
||||
|
||||
return jfNormalize.song(
|
||||
res.body,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
);
|
||||
return jfNormalize.song(res.body, apiClientProps.server);
|
||||
},
|
||||
getSongList: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
@@ -1399,14 +1345,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
}
|
||||
|
||||
return {
|
||||
items: items.map((item) =>
|
||||
jfNormalize.song(
|
||||
item,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
),
|
||||
items: items.map((item) => jfNormalize.song(item, apiClientProps.server)),
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount,
|
||||
};
|
||||
@@ -1538,14 +1477,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to get top song list');
|
||||
}
|
||||
|
||||
const items = res.body.Items.map((item) =>
|
||||
jfNormalize.song(
|
||||
item,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
);
|
||||
const items = res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server));
|
||||
|
||||
if (type === 'personal') {
|
||||
const sorted = orderBy(
|
||||
@@ -1647,12 +1579,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
}
|
||||
|
||||
const existingSongs = existingSongsRes.body.Items.map((item) =>
|
||||
jfNormalize.song(
|
||||
item,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
jfNormalize.song(item, apiClientProps.server),
|
||||
);
|
||||
|
||||
// 2. Get playlist detail to get the name
|
||||
@@ -1793,6 +1720,17 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.event === 'stop') {
|
||||
jfApiClient(apiClientProps).scrobbleStopped({
|
||||
body: {
|
||||
ItemId: query.id,
|
||||
PositionTicks: position,
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
jfApiClient(apiClientProps).scrobbleProgress({
|
||||
body: {
|
||||
ItemId: query.id,
|
||||
@@ -1892,14 +1830,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
jfNormalize.albumArtist(item, apiClientProps.server),
|
||||
),
|
||||
albums: albums.map((item) => jfNormalize.album(item, apiClientProps.server)),
|
||||
songs: songs.map((item) =>
|
||||
jfNormalize.song(
|
||||
item,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
),
|
||||
songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server)),
|
||||
};
|
||||
},
|
||||
setPlaylistSongs: async (args) => {
|
||||
|
||||
@@ -367,7 +367,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
query: { ...query, limit: 1, startIndex: 0 },
|
||||
}).then((result) => result!.totalRecordCount!),
|
||||
getAlbumDetail: async (args) => {
|
||||
const { apiClientProps, context, query } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({
|
||||
params: {
|
||||
@@ -393,8 +393,6 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
return ndNormalize.album(
|
||||
{ ...albumRes.body.data, songs: songsData.body.data },
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
);
|
||||
},
|
||||
getAlbumInfo: async (args) => {
|
||||
@@ -418,7 +416,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
};
|
||||
},
|
||||
getAlbumList: async (args) => {
|
||||
const { apiClientProps, context, query } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const genres = hasFeature(apiClientProps.server, ServerFeature.BFR)
|
||||
? query.genreIds
|
||||
@@ -453,14 +451,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data.map((album) =>
|
||||
ndNormalize.album(
|
||||
album,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
),
|
||||
items: res.body.data.map((album) => ndNormalize.album(album, apiClientProps.server)),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
@@ -493,12 +484,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
}
|
||||
|
||||
return res.body.similarSongs.song.map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
ssNormalize.song(song, apiClientProps.server),
|
||||
);
|
||||
},
|
||||
getArtistList: async (args) => {
|
||||
@@ -568,12 +554,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
}
|
||||
|
||||
return res.body.similarSongs2.song.map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
ssNormalize.song(song, apiClientProps.server),
|
||||
);
|
||||
},
|
||||
getDownloadUrl: SubsonicController.getDownloadUrl,
|
||||
@@ -723,14 +704,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data.map((item) =>
|
||||
ndNormalize.song(
|
||||
item,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
),
|
||||
items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server)),
|
||||
startIndex: 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
@@ -747,14 +721,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
|
||||
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(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
);
|
||||
const entries = items.map((song) => ndNormalize.song(song, apiClientProps.server));
|
||||
|
||||
return {
|
||||
changed: updatedAt,
|
||||
@@ -830,14 +797,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
return (
|
||||
(res.body.similarSongs?.song || [])
|
||||
.filter((song) => song.id !== query.songId)
|
||||
.map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
) || []
|
||||
.map((song) => ssNormalize.song(song, apiClientProps.server)) || []
|
||||
);
|
||||
},
|
||||
getSongDetail: async (args) => {
|
||||
@@ -853,12 +813,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to get song detail');
|
||||
}
|
||||
|
||||
return ndNormalize.song(
|
||||
res.body.data,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
);
|
||||
return ndNormalize.song(res.body.data, apiClientProps.server);
|
||||
},
|
||||
getSongList: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
@@ -898,14 +853,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data.map((song) =>
|
||||
ndNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
),
|
||||
items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server)),
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
@@ -1022,12 +970,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
|
||||
return {
|
||||
items: (res.body.topSongs?.song || []).map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
ssNormalize.song(song, apiClientProps.server),
|
||||
),
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.topSongs?.song?.length || 0,
|
||||
@@ -1036,7 +979,6 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
|
||||
const res = await NavidromeController.getSongList({
|
||||
apiClientProps,
|
||||
context: args.context,
|
||||
query: {
|
||||
artistIds: [query.artistId],
|
||||
sortBy: SongListSort.PLAY_COUNT,
|
||||
@@ -1138,12 +1080,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
}
|
||||
|
||||
const existingSongs = existingSongsRes.body.data.map((item) =>
|
||||
ndNormalize.song(
|
||||
item,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
ndNormalize.song(item, apiClientProps.server),
|
||||
);
|
||||
|
||||
// 2. Get playlist detail to get the name
|
||||
|
||||
@@ -482,14 +482,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
return {
|
||||
...ssNormalize.albumArtist(artist, apiClientProps.server),
|
||||
albums: artist.album?.map((album) =>
|
||||
ssNormalize.album(
|
||||
album,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
),
|
||||
albums: artist.album?.map((album) => ssNormalize.album(album, apiClientProps.server)),
|
||||
similarArtists: null,
|
||||
};
|
||||
},
|
||||
@@ -564,7 +557,6 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
getAlbumArtistListCount: (args) =>
|
||||
SubsonicController.getAlbumArtistList({
|
||||
...args,
|
||||
context: args.context,
|
||||
query: { ...args.query, startIndex: 0 },
|
||||
}).then((res) => res!.totalRecordCount!),
|
||||
getAlbumDetail: async (args) => {
|
||||
@@ -580,12 +572,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to get album detail');
|
||||
}
|
||||
|
||||
return ssNormalize.album(
|
||||
res.body.album,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
);
|
||||
return ssNormalize.album(res.body.album, apiClientProps.server);
|
||||
},
|
||||
getAlbumList: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
@@ -610,12 +597,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
const results =
|
||||
res.body.searchResult3?.album?.map((album) =>
|
||||
ssNormalize.album(
|
||||
album,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
ssNormalize.album(album, apiClientProps.server),
|
||||
) || [];
|
||||
|
||||
return {
|
||||
@@ -650,14 +632,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
return artist.body.artist.album ?? [];
|
||||
});
|
||||
|
||||
const items = albums.map((album) =>
|
||||
ssNormalize.album(
|
||||
album,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
);
|
||||
const items = albums.map((album) => ssNormalize.album(album, apiClientProps.server));
|
||||
|
||||
return {
|
||||
items: sortAlbumList(items, query.sortBy, query.sortOrder),
|
||||
@@ -679,12 +654,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
const allResults =
|
||||
res.body.starred?.album?.map((album) =>
|
||||
ssNormalize.album(
|
||||
album,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
ssNormalize.album(album, apiClientProps.server),
|
||||
) || [];
|
||||
|
||||
return sortAndPaginate(allResults, {
|
||||
@@ -749,12 +719,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
return {
|
||||
items:
|
||||
res.body.albumList2.album?.map((album) =>
|
||||
ssNormalize.album(
|
||||
album,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
ssNormalize.album(album, apiClientProps.server),
|
||||
) || [],
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: null,
|
||||
@@ -905,7 +870,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
return totalRecordCount;
|
||||
},
|
||||
getAlbumRadio: async (args) => {
|
||||
const { apiClientProps, context, query } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getSimilarSongs({
|
||||
query: {
|
||||
@@ -923,12 +888,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
}
|
||||
|
||||
return res.body.similarSongs.song.map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
ssNormalize.song(song, apiClientProps.server),
|
||||
);
|
||||
},
|
||||
getArtistList: async (args) => {
|
||||
@@ -974,11 +934,10 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
getArtistListCount: async (args) =>
|
||||
SubsonicController.getArtistList({
|
||||
...args,
|
||||
context: args.context,
|
||||
query: { ...args.query, startIndex: 0 },
|
||||
}).then((res) => res!.totalRecordCount!),
|
||||
getArtistRadio: async (args) => {
|
||||
const { apiClientProps, context, query } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getSimilarSongs2({
|
||||
query: {
|
||||
@@ -996,12 +955,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
}
|
||||
|
||||
return res.body.similarSongs2.song.map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
ssNormalize.song(song, apiClientProps.server),
|
||||
);
|
||||
},
|
||||
getDownloadUrl: (args) => {
|
||||
@@ -1015,7 +969,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
'&c=Feishin'
|
||||
);
|
||||
},
|
||||
getFolder: async ({ apiClientProps, context, query }) => {
|
||||
getFolder: async ({ apiClientProps, query }) => {
|
||||
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
|
||||
|
||||
const isRootFolderId = query.id === '0';
|
||||
@@ -1048,14 +1002,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
});
|
||||
}
|
||||
|
||||
let folders = items.map((item) =>
|
||||
ssNormalize.folder(
|
||||
item,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
);
|
||||
let folders = items.map((item) => ssNormalize.folder(item, apiClientProps.server));
|
||||
|
||||
folders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]);
|
||||
|
||||
@@ -1083,12 +1030,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to get folder');
|
||||
}
|
||||
|
||||
const folder = ssNormalize.folder(
|
||||
directoryRes.body.directory,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
);
|
||||
const folder = ssNormalize.folder(directoryRes.body.directory, apiClientProps.server);
|
||||
|
||||
let filteredFolders = folder.children?.folders || [];
|
||||
let filteredSongs = folder.children?.songs || [];
|
||||
@@ -1281,7 +1223,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
return results.length;
|
||||
},
|
||||
getPlaylistSongList: async ({ apiClientProps, context, query }) => {
|
||||
getPlaylistSongList: async ({ apiClientProps, query }) => {
|
||||
const res = await ssApiClient(apiClientProps).getPlaylist({
|
||||
query: {
|
||||
id: query.id,
|
||||
@@ -1294,13 +1236,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
const items =
|
||||
res.body.playlist.entry?.map((song, index) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
index,
|
||||
),
|
||||
ssNormalize.song(song, apiClientProps.server, index),
|
||||
) || [];
|
||||
|
||||
return {
|
||||
@@ -1309,7 +1245,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
totalRecordCount: items.length,
|
||||
};
|
||||
},
|
||||
getPlayQueue: async ({ apiClientProps, context }) => {
|
||||
getPlayQueue: async ({ apiClientProps }) => {
|
||||
if (hasFeature(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE)) {
|
||||
const res = await ssApiClient(apiClientProps).getPlayQueueByIndex();
|
||||
|
||||
@@ -1324,15 +1260,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
changed: changed ?? '',
|
||||
changedBy: changedBy ?? '',
|
||||
currentIndex: currentIndex ?? 0,
|
||||
entry:
|
||||
entry?.map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
) || [],
|
||||
entry: entry?.map((song) => ssNormalize.song(song, apiClientProps.server)) || [],
|
||||
positionMs: position ?? 0,
|
||||
username: username ?? '',
|
||||
};
|
||||
@@ -1349,22 +1277,14 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
changed,
|
||||
changedBy,
|
||||
currentIndex: current ? entry.findIndex((item) => item.id === current) : 0,
|
||||
entry:
|
||||
entry?.map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
) || [],
|
||||
entry: entry?.map((song) => ssNormalize.song(song, apiClientProps.server)) || [],
|
||||
positionMs: position ?? 0,
|
||||
username,
|
||||
};
|
||||
}
|
||||
},
|
||||
getRandomSongList: async (args) => {
|
||||
const { apiClientProps, context, query } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getRandomSongList({
|
||||
query: {
|
||||
@@ -1382,12 +1302,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
const results = res.body.randomSongs?.song || [];
|
||||
const normalizedResults = results.map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
ssNormalize.song(song, apiClientProps.server),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -1473,7 +1388,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
|
||||
},
|
||||
getSimilarSongs: async (args) => {
|
||||
const { apiClientProps, context, query } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getSimilarSongs({
|
||||
query: {
|
||||
@@ -1492,21 +1407,14 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
return res.body.similarSongs.song.reduce<Song[]>((acc, song) => {
|
||||
if (song.id !== query.songId) {
|
||||
acc.push(
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
);
|
||||
acc.push(ssNormalize.song(song, apiClientProps.server));
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
},
|
||||
getSongDetail: async (args) => {
|
||||
const { apiClientProps, context, query } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getSong({
|
||||
query: {
|
||||
@@ -1518,14 +1426,9 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to get song detail');
|
||||
}
|
||||
|
||||
return ssNormalize.song(
|
||||
res.body.song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
);
|
||||
return ssNormalize.song(res.body.song, apiClientProps.server);
|
||||
},
|
||||
getSongList: async ({ apiClientProps, context, query }) => {
|
||||
getSongList: async ({ apiClientProps, query }) => {
|
||||
const fromAlbumPromises: Promise<ServerInferResponses<typeof contract.getAlbum>>[] = [];
|
||||
const artistDetailPromises: Promise<ServerInferResponses<typeof contract.getArtist>>[] = [];
|
||||
|
||||
@@ -1550,12 +1453,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
return {
|
||||
items:
|
||||
res.body.searchResult3?.song?.map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
ssNormalize.song(song, apiClientProps.server),
|
||||
) || [],
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: null,
|
||||
@@ -1579,15 +1477,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
const results = res.body.songsByGenre?.song || [];
|
||||
|
||||
return {
|
||||
items:
|
||||
results.map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
) || [],
|
||||
items: results.map((song) => ssNormalize.song(song, apiClientProps.server)) || [],
|
||||
startIndex: 0,
|
||||
totalRecordCount: null,
|
||||
};
|
||||
@@ -1606,12 +1496,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
let allResults =
|
||||
(res.body.starred?.song || []).map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
ssNormalize.song(song, apiClientProps.server),
|
||||
) || [];
|
||||
|
||||
const filterArtistIds = query.albumArtistIds || query.artistIds;
|
||||
@@ -1696,15 +1581,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
}
|
||||
|
||||
return {
|
||||
items:
|
||||
results.map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
) || [],
|
||||
items: results.map((song) => ssNormalize.song(song, apiClientProps.server)) || [],
|
||||
startIndex: 0,
|
||||
totalRecordCount: results.length,
|
||||
};
|
||||
@@ -1730,12 +1607,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
return {
|
||||
items:
|
||||
res.body.searchResult3?.song?.map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
ssNormalize.song(song, apiClientProps.server),
|
||||
) || [],
|
||||
startIndex: 0,
|
||||
totalRecordCount: null,
|
||||
@@ -2103,7 +1975,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
});
|
||||
},
|
||||
getTopSongs: async (args) => {
|
||||
const { apiClientProps, context, query } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const type = query.type === 'personal' ? 'personal' : 'community';
|
||||
|
||||
@@ -2121,12 +1993,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
return {
|
||||
items: (res.body.topSongs?.song || []).map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
ssNormalize.song(song, apiClientProps.server),
|
||||
),
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.topSongs?.song?.length || 0,
|
||||
@@ -2135,7 +2002,6 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
const res = await SubsonicController.getSongList({
|
||||
apiClientProps,
|
||||
context,
|
||||
query: {
|
||||
artistIds: [query.artistId],
|
||||
sortBy: SongListSort.PLAY_COUNT,
|
||||
@@ -2190,7 +2056,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
return null;
|
||||
},
|
||||
replacePlaylist: async (args) => {
|
||||
const { apiClientProps, body, context, query } = args;
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
// 1. Fetch existing songs from the playlist
|
||||
const existingSongsRes = await ssApiClient(apiClientProps).getPlaylist({
|
||||
@@ -2205,12 +2071,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
const existingSongs =
|
||||
existingSongsRes.body.playlist.entry?.map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
ssNormalize.song(song, apiClientProps.server),
|
||||
) || [];
|
||||
|
||||
// 2. Get playlist detail to get the name
|
||||
@@ -2309,7 +2170,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (hasFeature(apiClientProps.server, ServerFeature.REPORT_PLAYBACK)) {
|
||||
if (query.submission) {
|
||||
if (query.submission || query.event === 'start') {
|
||||
const res = await ssApiClient(apiClientProps).scrobble({
|
||||
query: {
|
||||
id: query.id,
|
||||
@@ -2321,38 +2182,54 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to scrobble');
|
||||
}
|
||||
|
||||
return null;
|
||||
if (query.submission) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let state: 'paused' | 'playing' | 'starting' | 'stopped' = 'playing';
|
||||
const defaultParams = {
|
||||
ignoreScrobble: true,
|
||||
mediaId: query.id,
|
||||
mediaType: query.mediaType,
|
||||
playbackRate: query.playbackRate,
|
||||
positionMs: query.position ?? 0,
|
||||
};
|
||||
|
||||
const reportPlayback = (state: 'paused' | 'playing' | 'starting' | 'stopped') => {
|
||||
return ssApiClient(apiClientProps).reportPlayback({
|
||||
query: {
|
||||
...defaultParams,
|
||||
state,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const promises: Promise<any>[] = [];
|
||||
|
||||
switch (query.event) {
|
||||
case 'pause':
|
||||
state = 'paused';
|
||||
promises.push(reportPlayback('paused'));
|
||||
break;
|
||||
case 'start':
|
||||
state = 'starting';
|
||||
promises.push(reportPlayback('starting'));
|
||||
promises.push(reportPlayback('playing'));
|
||||
break;
|
||||
case 'stop':
|
||||
promises.push(reportPlayback('stopped'));
|
||||
break;
|
||||
case 'unpause':
|
||||
state = 'playing';
|
||||
promises.push(reportPlayback('playing'));
|
||||
break;
|
||||
default:
|
||||
state = 'playing';
|
||||
break;
|
||||
}
|
||||
|
||||
const res = await ssApiClient(apiClientProps).reportPlayback({
|
||||
query: {
|
||||
ignoreScrobble: true,
|
||||
mediaId: query.id,
|
||||
mediaType: query.mediaType,
|
||||
playbackRate: query.playbackRate,
|
||||
positionMs: query.position ?? 0,
|
||||
state,
|
||||
},
|
||||
});
|
||||
for (const promise of promises) {
|
||||
const res = await promise;
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to report playback');
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to report playback');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -2372,7 +2249,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
return null;
|
||||
},
|
||||
search: async (args) => {
|
||||
const { apiClientProps, context, query } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).search3({
|
||||
query: {
|
||||
@@ -2396,20 +2273,10 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
ssNormalize.albumArtist(artist, apiClientProps.server),
|
||||
),
|
||||
albums: (res.body.searchResult3?.album || []).map((album) =>
|
||||
ssNormalize.album(
|
||||
album,
|
||||
apiClientProps.server,
|
||||
args.context?.pathReplace,
|
||||
args.context?.pathReplaceWith,
|
||||
),
|
||||
ssNormalize.album(album, apiClientProps.server),
|
||||
),
|
||||
songs: (res.body.searchResult3?.song || []).map((song) =>
|
||||
ssNormalize.song(
|
||||
song,
|
||||
apiClientProps.server,
|
||||
context?.pathReplace,
|
||||
context?.pathReplaceWith,
|
||||
),
|
||||
ssNormalize.song(song, apiClientProps.server),
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
|
||||
import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { LibraryItem, SortKeyRandom } from '/@/shared/types/domain-types';
|
||||
|
||||
export const getListQueryKeyName = (itemType: LibraryItem): string => {
|
||||
switch (itemType) {
|
||||
@@ -108,8 +108,19 @@ export const useItemListInfiniteLoader = ({
|
||||
[serverId, itemType, query],
|
||||
);
|
||||
|
||||
const isRandomSort = query?.sortBy === SortKeyRandom;
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (pageNumber: number) => {
|
||||
if (isRandomSort) {
|
||||
const existingData =
|
||||
queryClient.getQueryData<InfiniteLoaderCacheData>(dataQueryKey);
|
||||
if (existingData?.pagesLoaded?.[pageNumber]) {
|
||||
lastFetchedPageRef.current = Math.max(lastFetchedPageRef.current, pageNumber);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const startIndex = pageNumber * itemsPerPage;
|
||||
const queryParams = {
|
||||
limit: itemsPerPage,
|
||||
@@ -118,6 +129,7 @@ export const useItemListInfiniteLoader = ({
|
||||
};
|
||||
|
||||
const result = await queryClient.fetchQuery({
|
||||
gcTime: isRandomSort ? 1000 * 60 * 10 : 1000 * 15,
|
||||
queryFn: async ({ signal }) => {
|
||||
const result = await listQueryFn({
|
||||
apiClientProps: { serverId, signal },
|
||||
@@ -127,6 +139,7 @@ export const useItemListInfiniteLoader = ({
|
||||
return result;
|
||||
},
|
||||
queryKey: queryKeys[getListQueryKeyName(itemType)].list(serverId, queryParams),
|
||||
staleTime: isRandomSort ? 1000 * 60 * 10 : 1000 * 15,
|
||||
});
|
||||
|
||||
// Update the query data with the fetched page
|
||||
@@ -154,13 +167,32 @@ export const useItemListInfiniteLoader = ({
|
||||
// Track the last fetched page
|
||||
lastFetchedPageRef.current = Math.max(lastFetchedPageRef.current, pageNumber);
|
||||
},
|
||||
[itemsPerPage, query, queryClient, serverId, dataQueryKey, listQueryFn, itemType],
|
||||
[
|
||||
itemsPerPage,
|
||||
query,
|
||||
queryClient,
|
||||
serverId,
|
||||
dataQueryKey,
|
||||
listQueryFn,
|
||||
itemType,
|
||||
isRandomSort,
|
||||
],
|
||||
);
|
||||
|
||||
// Reset the loaded pages and refetch current page when the query changes
|
||||
useEffect(() => {
|
||||
const currentDataQueryKey = JSON.stringify(dataQueryKey);
|
||||
|
||||
if (isRandomSort) {
|
||||
const existingData = queryClient.getQueryData<InfiniteLoaderCacheData | undefined>(
|
||||
dataQueryKey,
|
||||
);
|
||||
if (existingData?.dataMap && existingData.dataMap.size > 0) {
|
||||
previousDataQueryKeyRef.current = currentDataQueryKey;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (previousDataQueryKeyRef.current === currentDataQueryKey || isRefetchingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
|
||||
import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { LibraryItem, SortKeyRandom } from '/@/shared/types/domain-types';
|
||||
|
||||
const getQueryKeyName = (itemType: LibraryItem): string => {
|
||||
switch (itemType) {
|
||||
@@ -76,6 +76,8 @@ export const useItemListPaginatedLoader = ({
|
||||
const fetchRange = getFetchRange(currentPage, itemsPerPage);
|
||||
const startIndex = fetchRange.startIndex;
|
||||
|
||||
const isRandomSort = query?.sortBy === SortKeyRandom;
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
limit: itemsPerPage,
|
||||
@@ -86,7 +88,7 @@ export const useItemListPaginatedLoader = ({
|
||||
);
|
||||
|
||||
const { data } = useQuery({
|
||||
gcTime: 1000 * 15,
|
||||
gcTime: isRandomSort ? 1000 * 60 * 10 : 1000 * 15,
|
||||
placeholderData: { items: getInitialData(itemsPerPage) },
|
||||
queryFn: async ({ signal }) => {
|
||||
const result = await listQueryFn({
|
||||
@@ -97,7 +99,7 @@ export const useItemListPaginatedLoader = ({
|
||||
return result;
|
||||
},
|
||||
queryKey: queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams),
|
||||
staleTime: 1000 * 15,
|
||||
staleTime: isRandomSort ? 1000 * 60 * 10 : 1000 * 15,
|
||||
});
|
||||
|
||||
const refreshMutation = useMutation({
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
export const PathColumn = ({ song }: ItemDetailListCellProps) => song.path ?? <> </>;
|
||||
import { resolveSongPath } from '/@/renderer/utils/resolve-song-path';
|
||||
|
||||
export const PathColumn = ({ song }: ItemDetailListCellProps) =>
|
||||
resolveSongPath(song.path) ?? <> </>;
|
||||
|
||||
@@ -4,15 +4,17 @@ import {
|
||||
ItemTableListInnerColumn,
|
||||
TableColumnTextContainer,
|
||||
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { resolveSongPath } from '/@/renderer/utils/resolve-song-path';
|
||||
|
||||
export const PathColumn = (props: ItemTableListInnerColumn) => {
|
||||
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
|
||||
const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
|
||||
const resolvedPath = typeof row === 'string' ? resolveSongPath(row) : null;
|
||||
|
||||
if (typeof row === 'string' && row) {
|
||||
if (resolvedPath) {
|
||||
return (
|
||||
<TableColumnTextContainer {...props}>
|
||||
<span>{row}</span>
|
||||
<span>{resolvedPath}</span>
|
||||
</TableColumnTextContainer>
|
||||
);
|
||||
}
|
||||
|
||||
+2
@@ -58,6 +58,8 @@ a.title {
|
||||
color: var(--theme-colors-foreground-muted);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
|
||||
--text-text-wrap: nowrap;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { RefObject, useEffect, useLayoutEffect } from 'react';
|
||||
import { useLocation, useNavigationType } from 'react-router';
|
||||
|
||||
import { useScrollStore } from '/@/renderer/store/scroll.store';
|
||||
|
||||
interface UseNativeScrollPersistProps {
|
||||
enabled: boolean;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
// OverlayScrollbars initializes on the NativeScrollArea container and moves the
|
||||
// content into a viewport child element; that child is what actually scrolls,
|
||||
// so scrollTop must be read from and written to it rather than the container
|
||||
// the ref points at.
|
||||
const getScrollNode = (scrollRef: RefObject<HTMLDivElement | null>): HTMLElement | null => {
|
||||
const node = scrollRef.current?.children[0];
|
||||
return node instanceof HTMLElement ? node : null;
|
||||
};
|
||||
|
||||
// Persists vertical scroll offset for a NativeScrollArea, keyed by react-router
|
||||
// location.key. Restores the saved offset only on POP navigation; PUSH/REPLACE
|
||||
// continue to start at the top.
|
||||
export const useNativeScrollPersist = ({ enabled, scrollRef }: UseNativeScrollPersistProps) => {
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
const setOffset = useScrollStore((s) => s.setOffset);
|
||||
const getOffset = useScrollStore((s) => s.getOffset);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const saved = getOffset(location.key);
|
||||
if (!enabled || navigationType !== 'POP' || typeof saved !== 'number') {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyOffset = () => {
|
||||
const node = getScrollNode(scrollRef);
|
||||
if (node) {
|
||||
node.scrollTop = saved;
|
||||
}
|
||||
};
|
||||
|
||||
applyOffset();
|
||||
const raf = requestAnimationFrame(applyOffset);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [enabled, getOffset, location.key, navigationType, scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = getScrollNode(scrollRef);
|
||||
if (!enabled || !node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
setOffset(location.key, node.scrollTop);
|
||||
};
|
||||
|
||||
node.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => {
|
||||
node.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [enabled, location.key, scrollRef, setOffset]);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Filters } from '/@/renderer/components/query-builder';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
@@ -102,19 +102,28 @@ const QueryValueInput = ({
|
||||
const isDatePickerOperator =
|
||||
operator === 'beforeDate' || operator === 'afterDate' || operator === 'inTheRangeDate';
|
||||
|
||||
const BooleanSelectComponent = useMemo(
|
||||
() => (
|
||||
<Select
|
||||
data={[
|
||||
{ label: 'true', value: 'true' },
|
||||
{ label: 'false', value: 'false' },
|
||||
]}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
[onChange, props, value],
|
||||
);
|
||||
|
||||
if (operator === 'isMissing' || operator === 'isPresent') {
|
||||
return BooleanSelectComponent;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<Select
|
||||
data={[
|
||||
{ label: 'true', value: 'true' },
|
||||
{ label: 'false', value: 'false' },
|
||||
]}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return BooleanSelectComponent;
|
||||
case 'date':
|
||||
if (isDatePickerOperator && operator !== 'inTheRangeDate') {
|
||||
const dateValue = value ? parseDateValue(value) : null;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ServerListItemWithCredential } from '/@/shared/types/domain-types';
|
||||
import { ServerType } from '/@/shared/types/types';
|
||||
|
||||
export const normalizeServerUrl = (url: string) => url.replace(/\/$/, '');
|
||||
|
||||
export const findExistingServerLockServer = (
|
||||
serverList: Record<string, ServerListItemWithCredential>,
|
||||
configuredUrl: string,
|
||||
serverType?: null | ServerType,
|
||||
): ServerListItemWithCredential | undefined => {
|
||||
const servers = Object.values(serverList);
|
||||
|
||||
if (servers.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizeServerUrl(configuredUrl);
|
||||
const byUrl = servers.find((server) => normalizeServerUrl(server.url) === normalizedUrl);
|
||||
|
||||
if (byUrl) {
|
||||
return byUrl;
|
||||
}
|
||||
|
||||
// Server lock allows only one server — reuse the existing entry even if the URL changed.
|
||||
if (servers.length === 1) {
|
||||
return servers[0];
|
||||
}
|
||||
|
||||
if (serverType) {
|
||||
return servers.find((server) => server.type === serverType);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { useParams } from 'react-router';
|
||||
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
|
||||
import { useNativeScrollPersist } from '/@/renderer/components/native-scroll-area/use-native-scroll-persist';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||
import { AlbumArtistDetailContent } from '/@/renderer/features/artists/components/album-artist-detail-content';
|
||||
@@ -28,6 +29,8 @@ const AlbumArtistDetailRouteContent = () => {
|
||||
const serverId = useCurrentServerId();
|
||||
const { artistBackground, artistBackgroundBlur } = useArtistBackground();
|
||||
|
||||
useNativeScrollPersist({ enabled: true, scrollRef: scrollAreaRef });
|
||||
|
||||
const { albumArtistId, artistId } = useParams() as {
|
||||
albumArtistId?: string;
|
||||
artistId?: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import isElectron from 'is-electron';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { resolveSongPath } from '/@/renderer/utils/resolve-song-path';
|
||||
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { QueueSong, Song } from '/@/shared/types/domain-types';
|
||||
@@ -21,12 +22,13 @@ export const ShowInFileExplorerAction = ({ items }: ShowInFileExplorerActionProp
|
||||
}
|
||||
|
||||
const firstItem = items[0];
|
||||
if (!firstItem?.path) {
|
||||
const resolvedPath = resolveSongPath(firstItem?.path);
|
||||
if (!resolvedPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await utils.openItem(firstItem.path);
|
||||
await utils.openItem(resolvedPath);
|
||||
} catch (error) {
|
||||
toast.error({
|
||||
message: (error as Error).message,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import isElectron from 'is-electron';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useResolvedSongPath } from '/@/renderer/utils/resolve-song-path';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { CopyButton } from '/@/shared/components/copy-button/copy-button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
@@ -17,12 +18,13 @@ export type SongPathProps = {
|
||||
|
||||
export const SongPath = ({ path }: SongPathProps) => {
|
||||
const { t } = useTranslation();
|
||||
const resolvedPath = useResolvedSongPath(path);
|
||||
|
||||
if (!path) return null;
|
||||
if (!resolvedPath) return null;
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<CopyButton timeout={2000} value={path}>
|
||||
<CopyButton timeout={2000} value={resolvedPath}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
label={t(
|
||||
@@ -42,7 +44,7 @@ export const SongPath = ({ path }: SongPathProps) => {
|
||||
<ActionIcon
|
||||
icon="externalLink"
|
||||
onClick={() => {
|
||||
util.openItem(path).catch((error) => {
|
||||
util.openItem(resolvedPath).catch((error) => {
|
||||
toast.error({
|
||||
message: (error as Error).message,
|
||||
title: t('error.openError'),
|
||||
@@ -53,7 +55,7 @@ export const SongPath = ({ path }: SongPathProps) => {
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text style={{ userSelect: 'all' }}>{path}</Text>
|
||||
<Text style={{ userSelect: 'all' }}>{resolvedPath}</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,10 @@ import { Navigate } from 'react-router';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import {
|
||||
findExistingServerLockServer,
|
||||
normalizeServerUrl,
|
||||
} from '/@/renderer/features/action-required/utils/server-lock';
|
||||
import {
|
||||
isLegacyAuth,
|
||||
isServerLock,
|
||||
@@ -19,6 +23,7 @@ import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-e
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import {
|
||||
getServerById,
|
||||
useAuthStore,
|
||||
useAuthStoreActions,
|
||||
useCurrentServer,
|
||||
useServerList,
|
||||
@@ -51,12 +56,10 @@ const SERVER_NAMES: Record<ServerType, string> = {
|
||||
[ServerType.SUBSONIC]: 'OpenSubsonic',
|
||||
};
|
||||
|
||||
const normalizeUrl = (url: string) => url.replace(/\/$/, '');
|
||||
|
||||
const LoginRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { addServer, setCurrentServer, updateServer } = useAuthStoreActions();
|
||||
const { addServer, deleteServer, setCurrentServer, updateServer } = useAuthStoreActions();
|
||||
const currentServer = useCurrentServer();
|
||||
const serverList = useServerList();
|
||||
|
||||
@@ -151,15 +154,16 @@ const LoginRoute = () => {
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizeUrl(serverUrl);
|
||||
const normalizedRemoteURL = normalizeUrl(remoteUrl);
|
||||
const existingServer =
|
||||
serverLock &&
|
||||
Object.values(serverList).find((s) => normalizeUrl(s.url) === normalizedUrl);
|
||||
const normalizedUrl = normalizeServerUrl(serverUrl);
|
||||
const normalizedRemoteURL = normalizeServerUrl(remoteUrl);
|
||||
const existingServer = serverLock
|
||||
? findExistingServerLockServer(serverList, normalizedUrl, serverType)
|
||||
: undefined;
|
||||
|
||||
const serverId = existingServer?.id ?? nanoid();
|
||||
const serverItem: ServerListItemWithCredential = {
|
||||
credential: data.credential,
|
||||
id: nanoid(),
|
||||
id: serverId,
|
||||
isAdmin: data.isAdmin,
|
||||
name: serverName,
|
||||
remoteUrl: normalizedRemoteURL,
|
||||
@@ -173,6 +177,9 @@ const LoginRoute = () => {
|
||||
const updates: Partial<ServerListItemWithCredential> = {
|
||||
credential: data.credential,
|
||||
isAdmin: data.isAdmin,
|
||||
name: serverName,
|
||||
remoteUrl: normalizedRemoteURL,
|
||||
url: normalizedUrl,
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
};
|
||||
@@ -190,12 +197,20 @@ const LoginRoute = () => {
|
||||
setCurrentServer(serverItem);
|
||||
}
|
||||
|
||||
if (serverLock) {
|
||||
Object.values(useAuthStore.getState().serverList).forEach((server) => {
|
||||
if (server.id !== serverId) {
|
||||
deleteServer(server.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toast.success({
|
||||
message: t('form.addServer.success'),
|
||||
});
|
||||
|
||||
if (localSettings && values.password) {
|
||||
const saved = await localSettings.passwordSet(values.password, serverItem.id);
|
||||
const saved = await localSettings.passwordSet(values.password, serverId);
|
||||
if (!saved) {
|
||||
toast.error({
|
||||
message: t('form.addServer.error', {
|
||||
|
||||
@@ -291,6 +291,22 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
|
||||
isHidden: !isElectron(),
|
||||
title: t('setting.lyricFetchProvider'),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
aria-label="Enable furigana"
|
||||
defaultChecked={lyricsSettings.enableFurigana}
|
||||
onChange={(e) =>
|
||||
updateLyricsSetting({ enableFurigana: e.currentTarget.checked })
|
||||
}
|
||||
/>
|
||||
),
|
||||
description: t('setting.enableFurigana', {
|
||||
context: 'description',
|
||||
}),
|
||||
isHidden: !isElectron(),
|
||||
title: t('setting.enableFurigana'),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import isElectron from 'is-electron';
|
||||
|
||||
import { LyricsResponse, SynchronizedLyricsArray } from '/@/shared/types/domain-types';
|
||||
|
||||
const lyricsApi = isElectron() ? window.api.lyrics : null;
|
||||
|
||||
export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, enabled: boolean) => {
|
||||
return useQuery({
|
||||
enabled: enabled && !!lyrics && !!lyricsApi,
|
||||
queryFn: async () => {
|
||||
if (!lyrics || !lyricsApi || !enabled) return lyrics;
|
||||
|
||||
if (typeof lyrics === 'string') {
|
||||
return await lyricsApi.convertFurigana(lyrics);
|
||||
} else if (Array.isArray(lyrics)) {
|
||||
const text = lyrics.map(([, line]) => line).join('\n');
|
||||
const converted = await lyricsApi.convertFurigana(text);
|
||||
const convertedLines = converted.split('\n');
|
||||
return lyrics.map(([time], i) => [
|
||||
time,
|
||||
convertedLines[i] ?? lyrics[i][1],
|
||||
]) as SynchronizedLyricsArray;
|
||||
}
|
||||
return lyrics;
|
||||
},
|
||||
queryKey: ['furigana', lyrics],
|
||||
staleTime: Infinity,
|
||||
});
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { ComponentPropsWithoutRef, memo, useMemo } from 'react';
|
||||
|
||||
import styles from './lyric-line.module.css';
|
||||
|
||||
import { sanitize } from '/@/renderer/utils/sanitize';
|
||||
import { Box } from '/@/shared/components/box/box';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
|
||||
@@ -28,7 +29,7 @@ export const LyricLine = memo(
|
||||
<Box className={clsx(styles.lyricLine, className)} style={style} {...props}>
|
||||
<Stack gap={0}>
|
||||
{lines.map((line, index) => (
|
||||
<span key={index}>{line}</span>
|
||||
<span dangerouslySetInnerHTML={{ __html: sanitize(line) }} key={index} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type LyricsQueryResult,
|
||||
} from '/@/renderer/features/lyrics/api/lyrics-api';
|
||||
import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form';
|
||||
import { useFuriganaLyrics } from '/@/renderer/features/lyrics/hooks/use-furigana-lyrics';
|
||||
import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
|
||||
import {
|
||||
SynchronizedLyrics,
|
||||
@@ -49,6 +50,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
||||
|
||||
const {
|
||||
enableAutoTranslation,
|
||||
enableFurigana,
|
||||
preferLocalLyrics,
|
||||
translationApiKey,
|
||||
translationApiProvider,
|
||||
@@ -116,7 +118,15 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
||||
return computeSelectedFromResult(data, preferLocalLyrics, indexToUse);
|
||||
}, [data, indexToUse, preferLocalLyrics]);
|
||||
|
||||
const displayLyrics = isLyricsDisabled ? null : lyrics;
|
||||
const { data: furiganaConvertedLyrics } = useFuriganaLyrics(lyrics?.lyrics, !!enableFurigana);
|
||||
|
||||
const displayLyrics = useMemo(() => {
|
||||
if (isLyricsDisabled || !lyrics) return null;
|
||||
if (enableFurigana && furiganaConvertedLyrics) {
|
||||
return { ...lyrics, lyrics: furiganaConvertedLyrics };
|
||||
}
|
||||
return lyrics;
|
||||
}, [enableFurigana, isLyricsDisabled, lyrics, furiganaConvertedLyrics]);
|
||||
|
||||
const currentOffsetMs = useMemo(() => {
|
||||
if (!data) return 0;
|
||||
|
||||
@@ -117,6 +117,15 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
properties,
|
||||
});
|
||||
|
||||
// Apply EQ and compressor filters after MPV has initialized
|
||||
const { compressor, equalizer } = useSettingsStore.getState().playback;
|
||||
const { buildMpvAudioFilters } =
|
||||
await import('/@/renderer/features/settings/components/playback/mpv-audio-filters');
|
||||
const filterStr = buildMpvAudioFilters(equalizer, compressor);
|
||||
if (filterStr) {
|
||||
mpvPlayer?.setProperties({ af: filterStr });
|
||||
}
|
||||
|
||||
// After initialization, populate the queue if currentSrc is available
|
||||
// Don't override queue if radio is active
|
||||
const radioState = useRadioStore.getState();
|
||||
|
||||
@@ -450,33 +450,36 @@ export function WebPlayer() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webAudio) return;
|
||||
if (!webAudio || !player1 || !player1Source) return;
|
||||
|
||||
if (player1 && player1Source && num === 1) {
|
||||
const newGain = calculateReplayGain(player1);
|
||||
const newGain = calculateReplayGain(player1);
|
||||
|
||||
// This error SHOULD never happen, as calculateReplayGain is expected to
|
||||
// always return a real value. However, to prevent app crash, check this just in case
|
||||
try {
|
||||
webAudio.gains[0].gain.setValueAtTime(Math.max(0, newGain), 0);
|
||||
} catch (error) {
|
||||
console.error('Error setting gain', error);
|
||||
}
|
||||
// Apply per player slot whenever its song/source is ready so pre-started
|
||||
// inactive players have correct gain before gapless/crossfade transitions.
|
||||
try {
|
||||
webAudio.gains[0].gain.setValueAtTime(
|
||||
Math.max(0, newGain),
|
||||
webAudio.context.currentTime,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error setting gain', error);
|
||||
}
|
||||
}, [calculateReplayGain, num, player1, player1Source, volume, webAudio]);
|
||||
}, [calculateReplayGain, player1, player1Source, webAudio]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webAudio) return;
|
||||
if (!webAudio || !player2 || !player2Source) return;
|
||||
|
||||
if (player2 && player2Source && num === 2) {
|
||||
const newGain = calculateReplayGain(player2);
|
||||
try {
|
||||
webAudio.gains[1].gain.setValueAtTime(Math.max(0, newGain), 0);
|
||||
} catch (error) {
|
||||
console.error('Error setting gain', error);
|
||||
}
|
||||
const newGain = calculateReplayGain(player2);
|
||||
|
||||
try {
|
||||
webAudio.gains[1].gain.setValueAtTime(
|
||||
Math.max(0, newGain),
|
||||
webAudio.context.currentTime,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error setting gain', error);
|
||||
}
|
||||
}, [calculateReplayGain, num, player1, player2Source, player2, volume, webAudio]);
|
||||
}, [calculateReplayGain, player2, player2Source, webAudio]);
|
||||
|
||||
const player1Url = useSongUrl(player1, num === 1, transcode);
|
||||
const player2Url = useSongUrl(player2, num === 2, transcode);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import { RemoteHook } from '/@/renderer/features/remote/hooks/use-remote';
|
||||
import { VisualizerSystemAudioBridgeHook } from '/@/renderer/features/visualizer/components/visualizer-system-audio-bridge';
|
||||
import { useSettingsStore } from '/@/renderer/store';
|
||||
import {
|
||||
updateQueueFavorites,
|
||||
updateQueueRatings,
|
||||
@@ -196,11 +197,66 @@ const AudioPlayersContent = ({
|
||||
}
|
||||
|
||||
const gains = [context.createGain(), context.createGain()];
|
||||
for (const gain of gains) {
|
||||
gain.connect(context.destination);
|
||||
|
||||
// Build DSP chain from persisted settings so EQ/compressor
|
||||
// are active immediately on first playback, not just after
|
||||
// the user opens the settings panel.
|
||||
const { compressor, equalizer } = useSettingsStore.getState().playback;
|
||||
|
||||
// Preamp gain — converts dB to linear
|
||||
const preampGain = context.createGain();
|
||||
preampGain.gain.value = equalizer.enabled ? Math.pow(10, equalizer.preamp / 20) : 1;
|
||||
|
||||
// One peaking BiquadFilterNode per EQ band
|
||||
const eqFilters: BiquadFilterNode[] = equalizer.bands.map((band) => {
|
||||
const filter = context.createBiquadFilter();
|
||||
filter.type = 'peaking';
|
||||
filter.frequency.value = band.freq;
|
||||
// Q of 1.41 gives roughly 1-octave bandwidth per band
|
||||
filter.Q.value = 1.41;
|
||||
filter.gain.value = equalizer.enabled ? band.gain : 0;
|
||||
return filter;
|
||||
});
|
||||
|
||||
// DynamicsCompressorNode — always present, pass-through when disabled
|
||||
// (ratio=1, threshold=0 = mathematically transparent)
|
||||
const compressorNode = context.createDynamicsCompressor();
|
||||
if (compressor.enabled) {
|
||||
compressorNode.threshold.value = compressor.threshold;
|
||||
compressorNode.ratio.value = compressor.ratio;
|
||||
compressorNode.attack.value = compressor.attack / 1000;
|
||||
compressorNode.release.value = compressor.release / 1000;
|
||||
compressorNode.knee.value = compressor.knee;
|
||||
} else {
|
||||
compressorNode.threshold.value = 0;
|
||||
compressorNode.ratio.value = 1;
|
||||
compressorNode.attack.value = 0;
|
||||
compressorNode.release.value = 0.25;
|
||||
compressorNode.knee.value = 0;
|
||||
}
|
||||
|
||||
setWebAudio!({ context, gains });
|
||||
// Wire: each gain → preamp → eq[0] → eq[1] → ... → compressor → destination
|
||||
for (const gain of gains) {
|
||||
gain.connect(preampGain);
|
||||
}
|
||||
|
||||
if (eqFilters.length > 0) {
|
||||
preampGain.connect(eqFilters[0]);
|
||||
for (let i = 0; i < eqFilters.length - 1; i++) {
|
||||
eqFilters[i].connect(eqFilters[i + 1]);
|
||||
}
|
||||
eqFilters[eqFilters.length - 1].connect(compressorNode);
|
||||
} else {
|
||||
preampGain.connect(compressorNode);
|
||||
}
|
||||
|
||||
compressorNode.connect(context.destination);
|
||||
|
||||
setWebAudio!({
|
||||
context,
|
||||
dsp: { compressor: compressorNode, eqFilters, preampGain },
|
||||
gains,
|
||||
});
|
||||
}
|
||||
|
||||
// Intentionally ignore the sample rate dependency, as it makes things really messy
|
||||
|
||||
@@ -67,8 +67,9 @@ Jellyfin progress APIs still use playback position (ticks), not listen time:
|
||||
- pause / unpause
|
||||
|
||||
Other events:
|
||||
- When the song changes: sends 'start' when the new track is playing;
|
||||
clears submission flag and listen accumulator for the new track.
|
||||
- When the song changes: sends 'stop' for the previous track; sends 'start'
|
||||
when the new track is playing; clears submission flag and listen accumulator
|
||||
for the new track.
|
||||
|
||||
- When the song is restarted (near 0 after 10s+): clears submission flag
|
||||
and listen accumulator.
|
||||
@@ -129,6 +130,7 @@ export const useScrobble = () => {
|
||||
|
||||
const previousSongRef = useRef<QueueSong | undefined>(undefined);
|
||||
const previousTimestampRef = useRef<number>(0);
|
||||
const stopPositionRef = useRef<number>(0);
|
||||
const lastProgressEventRef = useRef<number>(0);
|
||||
const lastSeekEventRef = useRef<number>(0);
|
||||
const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
@@ -316,7 +318,10 @@ export const useScrobble = () => {
|
||||
) => {
|
||||
const currentSong = properties.song;
|
||||
const previousSong = previousSongRef.current;
|
||||
const previousPositionSec = stopPositionRef.current;
|
||||
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||
const previousMediaType = previousSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||
const useTicksForPrevious = previousSong?._serverType === ServerType.JELLYFIN;
|
||||
|
||||
// Handle notifications
|
||||
if (scrobbleSettings?.notify && currentSong?.id) {
|
||||
@@ -352,6 +357,7 @@ export const useScrobble = () => {
|
||||
if (!isScrobbleEnabled || isPrivateModeEnabled) {
|
||||
previousSongRef.current = currentSong;
|
||||
previousTimestampRef.current = 0;
|
||||
stopPositionRef.current = 0;
|
||||
listenedMsRef.current = 0;
|
||||
lastListenSampleTimeRef.current = null;
|
||||
flushScrobbleDebug();
|
||||
@@ -395,10 +401,42 @@ export const useScrobble = () => {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Send stop scrobble for the track that was playing before the change
|
||||
if (previousSong?.id) {
|
||||
sendScrobble.mutate(
|
||||
{
|
||||
apiClientProps: { serverId: previousSong._serverId || '' },
|
||||
query: {
|
||||
albumId: previousSong.albumId,
|
||||
event: 'stop',
|
||||
id: previousSong.id,
|
||||
mediaType: previousMediaType,
|
||||
playbackRate: playbackRate,
|
||||
position: getPositionValue(
|
||||
previousPositionSec,
|
||||
useTicksForPrevious,
|
||||
),
|
||||
submission: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStop, {
|
||||
category: LogCategory.SCROBBLE,
|
||||
meta: {
|
||||
id: previousSong.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
previousSongRef.current = currentSong;
|
||||
previousTimestampRef.current = 0;
|
||||
stopPositionRef.current = 0;
|
||||
flushScrobbleDebug();
|
||||
},
|
||||
[
|
||||
@@ -591,6 +629,7 @@ export const useScrobble = () => {
|
||||
isCurrentSongScrobbledRef.current = false;
|
||||
lastProgressEventRef.current = 0;
|
||||
previousTimestampRef.current = 0;
|
||||
stopPositionRef.current = 0;
|
||||
listenedMsRef.current = 0;
|
||||
lastListenSampleTimeRef.current = null;
|
||||
|
||||
@@ -625,6 +664,17 @@ export const useScrobble = () => {
|
||||
// Update previous timestamp on progress for use in status change handler
|
||||
const handleProgressUpdate = useCallback(
|
||||
(properties: { timestamp: number }, prev: { timestamp: number }) => {
|
||||
// Preserve last playback position when the playhead resets to the start
|
||||
// (song change can fire after progress already reports 0 for the new track).
|
||||
if (
|
||||
properties.timestamp < SCROBBLE_TRACK_BEGIN_SEC &&
|
||||
prev.timestamp >= SCROBBLE_TRACK_BEGIN_SEC
|
||||
) {
|
||||
stopPositionRef.current = prev.timestamp;
|
||||
} else {
|
||||
stopPositionRef.current = properties.timestamp;
|
||||
}
|
||||
|
||||
previousTimestampRef.current = properties.timestamp;
|
||||
handleScrobbleFromProgress(properties, prev);
|
||||
flushScrobbleDebug();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { folderQueries } from '/@/renderer/features/folders/api/folder-api';
|
||||
import { PlayerFilter, useSettingsStore } from '/@/renderer/store';
|
||||
import { LogCategory, logFn } from '/@/renderer/utils/logger';
|
||||
import { logMsg } from '/@/renderer/utils/logger-message';
|
||||
import { resolveSongPath } from '/@/renderer/utils/resolve-song-path';
|
||||
import { sortSongList } from '/@/shared/api/utils';
|
||||
import {
|
||||
PlaylistSongListQuery,
|
||||
@@ -351,7 +352,7 @@ const getSongFieldValue = (song: Song, field: string): boolean | null | number |
|
||||
case 'note':
|
||||
return song.comment || '';
|
||||
case 'path':
|
||||
return song.path || '';
|
||||
return resolveSongPath(song.path) || '';
|
||||
case 'playCount':
|
||||
return song.playCount;
|
||||
case 'rating':
|
||||
|
||||
@@ -224,25 +224,26 @@ export const PlaylistQueryEditor = ({
|
||||
return detailQuery?.data?.rules?.order || 'asc';
|
||||
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
|
||||
|
||||
const appliedQuery = appliedJsonState?.query;
|
||||
const detailQueryRules = detailQuery?.data?.rules;
|
||||
const effectiveQuery = useMemo(
|
||||
() =>
|
||||
appliedJsonState?.query ??
|
||||
(detailQuery?.data?.rules?.all
|
||||
? { all: detailQuery.data.rules.all }
|
||||
: detailQuery?.data?.rules?.any
|
||||
? { any: detailQuery.data.rules.any }
|
||||
: detailQuery?.data?.rules),
|
||||
[appliedJsonState?.query, detailQuery?.data?.rules],
|
||||
appliedQuery ??
|
||||
(detailQueryRules?.all
|
||||
? { all: detailQueryRules.all }
|
||||
: detailQueryRules?.any
|
||||
? { any: detailQueryRules.any }
|
||||
: detailQueryRules),
|
||||
[appliedQuery, detailQueryRules],
|
||||
);
|
||||
const effectiveLimit = appliedJsonState?.limit ?? detailQuery?.data?.rules?.limit;
|
||||
const effectiveLimitPercent =
|
||||
appliedJsonState?.limitPercent ?? detailQuery?.data?.rules?.limitPercent;
|
||||
|
||||
const appliedSort = appliedJsonState?.sort;
|
||||
const effectiveSortBy = useMemo(
|
||||
() =>
|
||||
(appliedJsonState?.sort ? [appliedJsonState.sort] : parseSortBy()) as
|
||||
| SongListSort
|
||||
| SongListSort[],
|
||||
[appliedJsonState?.sort, parseSortBy],
|
||||
() => (appliedSort ? [appliedSort] : parseSortBy()) as SongListSort | SongListSort[],
|
||||
[appliedSort, parseSortBy],
|
||||
);
|
||||
const effectiveSortOrder = appliedJsonState?.sort
|
||||
? appliedJsonState.sort.startsWith('-')
|
||||
|
||||
@@ -93,6 +93,20 @@ export const LyricSettings = memo(() => {
|
||||
isHidden: !isElectron(),
|
||||
title: t('setting.lyricFetchProvider'),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
aria-label="Enable furigana generation"
|
||||
defaultChecked={settings.enableFurigana}
|
||||
onChange={(e) => updateSetting({ enableFurigana: e.currentTarget.checked })}
|
||||
/>
|
||||
),
|
||||
description: t('setting.enableFurigana', {
|
||||
context: 'description',
|
||||
}),
|
||||
isHidden: !isElectron(),
|
||||
title: t('setting.enableFurigana'),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import { useCurrentServerId, useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store';
|
||||
import { useResolvedSongPath } from '/@/renderer/utils/resolve-song-path';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Code } from '/@/shared/components/code/code';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
@@ -27,6 +28,7 @@ export const PathSettings = memo(() => {
|
||||
|
||||
const { pathReplace, pathReplaceWith } = useGeneralSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const resolvedPreviewPath = useResolvedSongPath(randomSong.data?.items[0]?.path);
|
||||
|
||||
const [localPathReplace, setLocalPathReplace] = useState(pathReplace);
|
||||
const [localPathReplaceWith, setLocalPathReplaceWith] = useState(pathReplaceWith);
|
||||
@@ -45,8 +47,6 @@ export const PathSettings = memo(() => {
|
||||
pathReplace: value,
|
||||
},
|
||||
});
|
||||
|
||||
randomSong.refetch();
|
||||
}, 500);
|
||||
|
||||
const debouncedSetPathReplaceWith = useDebouncedCallback((value: string) => {
|
||||
@@ -55,8 +55,6 @@ export const PathSettings = memo(() => {
|
||||
pathReplaceWith: value,
|
||||
},
|
||||
});
|
||||
|
||||
randomSong.refetch();
|
||||
}, 500);
|
||||
|
||||
return (
|
||||
@@ -73,7 +71,7 @@ export const PathSettings = memo(() => {
|
||||
</Group>
|
||||
<Code>
|
||||
<Text isMuted size="md">
|
||||
{randomSong.data?.items[0]?.path || ''}
|
||||
{resolvedPreviewPath || ''}
|
||||
</Text>
|
||||
</Code>
|
||||
<Group grow>
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
import { useMove } from '@mantine/hooks';
|
||||
import isElectron from 'is-electron';
|
||||
import { memo, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildMpvAudioFilters,
|
||||
type CompressorSettings,
|
||||
type EqSettings as EqSettingsType,
|
||||
} from './mpv-audio-filters';
|
||||
|
||||
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
|
||||
import {
|
||||
SettingOption,
|
||||
SettingsSection,
|
||||
} from '/@/renderer/features/settings/components/settings-section';
|
||||
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Slider } from '/@/shared/components/slider/slider';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { PlayerType } from '/@/shared/types/types';
|
||||
|
||||
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
|
||||
|
||||
const BAND_LABELS = [
|
||||
'31.5',
|
||||
'63',
|
||||
'125',
|
||||
'250',
|
||||
'500',
|
||||
'1k',
|
||||
'2k',
|
||||
'3k',
|
||||
'4k',
|
||||
'6.3k',
|
||||
'10k',
|
||||
'16k',
|
||||
];
|
||||
|
||||
const EQ_MIN = -12;
|
||||
const EQ_MAX = 12;
|
||||
const EQ_STEP = 0.5;
|
||||
|
||||
// ─── Built-in EQ presets ──────────────────────────────────────────────────────
|
||||
const EQ_PRESETS: Record<string, number[]> = {
|
||||
Acoustic: [2, 2, 3, 2, 1, 0, 1, 2, 2, 2, 2, 1],
|
||||
'Bass Boost': [6, 5, 4, 2, 1, 0, 0, 0, 0, 0, 0, 0],
|
||||
'Bass Cut': [-6, -5, -4, -2, -1, 0, 0, 0, 0, 0, 0, 0],
|
||||
Classical: [0, 0, 0, 0, 0, 0, -1, -1, 0, 0, 0, -3],
|
||||
Electronic: [4, 3, 1, 0, -1, 0, 1, 0, 0, 2, 3, 4],
|
||||
Flat: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
'Hip-Hop': [5, 4, 2, 1, 0, -1, 0, 1, 0, 1, 2, 3],
|
||||
Jazz: [2, 1, 0, 1, 2, 2, 1, 0, 0, 1, 2, 2],
|
||||
Loudness: [5, 3, 1, 0, -1, -2, -2, -1, 0, 1, 3, 6],
|
||||
Pop: [-1, 0, 2, 3, 3, 2, 0, -1, -1, 0, 0, 0],
|
||||
Rock: [3, 2, 1, 0, -1, 0, 1, 2, 2, 2, 3, 3],
|
||||
'Treble Boost': [0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6],
|
||||
'Treble Cut': [0, 0, 0, 0, 0, 0, -1, -2, -3, -4, -5, -6],
|
||||
'V-Shape': [5, 3, 1, 0, -1, -2, -2, -1, 0, 1, 3, 5],
|
||||
'Vocal Boost': [-1, 0, 1, 2, 3, 4, 4, 3, 2, 1, 0, -1],
|
||||
};
|
||||
|
||||
// ─── Built-in compressor presets ─────────────────────────────────────────────
|
||||
type CompressorPreset = Omit<CompressorSettings, 'enabled'>;
|
||||
const COMP_PRESETS: Record<string, CompressorPreset> = {
|
||||
Broadcast: { attack: 15, knee: 3, makeup: 6, ratio: 5, release: 200, threshold: -20 },
|
||||
Default: { attack: 20, knee: 2.83, makeup: 6, ratio: 4, release: 250, threshold: -24 },
|
||||
Gentle: { attack: 50, knee: 6, makeup: 2, ratio: 1.5, release: 500, threshold: -15 },
|
||||
Heavy: { attack: 10, knee: 2, makeup: 8, ratio: 8, release: 150, threshold: -30 },
|
||||
Light: { attack: 30, knee: 4, makeup: 3, ratio: 2, release: 400, threshold: -18 },
|
||||
Limiter: { attack: 1, knee: 1, makeup: 0, ratio: 20, release: 100, threshold: -3 },
|
||||
'Loud Master': { attack: 5, knee: 2, makeup: 10, ratio: 6, release: 100, threshold: -28 },
|
||||
Moderate: { attack: 20, knee: 3, makeup: 5, ratio: 4, release: 300, threshold: -24 },
|
||||
};
|
||||
|
||||
// ─── Storage helpers ──────────────────────────────────────────────────────────
|
||||
const LS_EQ_PRESETS = 'feishin_eq_custom_presets';
|
||||
const LS_COMP_PRESETS = 'feishin_comp_custom_presets';
|
||||
|
||||
function loadCustomPresets<T>(key: string): Record<string, T> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(key) || '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveCustomPresets<T>(key: string, presets: Record<string, T>) {
|
||||
localStorage.setItem(key, JSON.stringify(presets));
|
||||
}
|
||||
|
||||
// ─── Vertical EQ band slider ──────────────────────────────────────────────────
|
||||
// Mantine v8 does not include orientation="vertical" on Slider.
|
||||
// We use useMove from @mantine/hooks (the Mantine-recommended approach for
|
||||
// vertical sliders in v8) so drag direction is correct — dragging up
|
||||
// increases the value, dragging down decreases it.
|
||||
// Styling uses the same CSS variables as the existing Slider module CSS
|
||||
// so it inherits the app theme correctly.
|
||||
const TRACK_H = 120; // px — rendered height of the vertical track
|
||||
const THUMB_R = 6; // px — thumb radius
|
||||
|
||||
function EqBandSlider({
|
||||
gain,
|
||||
label,
|
||||
onChangeEnd,
|
||||
}: {
|
||||
freq: number;
|
||||
gain: number;
|
||||
label: string;
|
||||
onChangeEnd: (v: number) => void;
|
||||
}) {
|
||||
// currentGain drives the live visual during dragging.
|
||||
// It is synced from the `gain` prop when external changes arrive
|
||||
// (preset applied, reset).
|
||||
const [currentGain, setCurrentGain] = useState(gain);
|
||||
const currentGainRef = useRef(currentGain);
|
||||
|
||||
// Stable ref so onScrubEnd always calls the latest onChangeEnd even
|
||||
// though useMove's refCallback closes over the initial handlers object.
|
||||
const onChangeEndRef = useRef(onChangeEnd);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentGain(gain);
|
||||
currentGainRef.current = gain;
|
||||
}, [gain]);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeEndRef.current = onChangeEnd;
|
||||
}, [onChangeEnd]);
|
||||
|
||||
// handleMove must be stable (empty deps) so useMove's internal
|
||||
// refCallback is only created once and listeners are not re-bound
|
||||
// on every render.
|
||||
const handleMove = useCallback(({ y }: { x: number; y: number }) => {
|
||||
// useMove gives y=0 at the top of the element and y=1 at the bottom.
|
||||
// Invert so dragging upward increases the value.
|
||||
const raw = EQ_MAX - y * (EQ_MAX - EQ_MIN);
|
||||
const stepped = Math.round(raw / EQ_STEP) * EQ_STEP;
|
||||
const clamped = Math.max(EQ_MIN, Math.min(EQ_MAX, stepped));
|
||||
setCurrentGain(clamped);
|
||||
currentGainRef.current = clamped;
|
||||
}, []);
|
||||
|
||||
const { active, ref } = useMove(handleMove, {
|
||||
onScrubEnd: () => {
|
||||
// Access ref so the latest onChangeEnd is called even though
|
||||
// this handler was captured in the initial closure.
|
||||
onChangeEndRef.current(currentGainRef.current);
|
||||
},
|
||||
});
|
||||
|
||||
// Percentage from bottom: 0 = min (-12dB), 100 = max (+12dB)
|
||||
const thumbPct = ((currentGain - EQ_MIN) / (EQ_MAX - EQ_MIN)) * 100;
|
||||
const zeroPct = ((0 - EQ_MIN) / (EQ_MAX - EQ_MIN)) * 100; // 50 for ±12 range
|
||||
|
||||
// Fill spans between zero line and thumb, regardless of direction
|
||||
const fillBottomPct = Math.min(thumbPct, zeroPct);
|
||||
const fillTopPct = 100 - Math.max(thumbPct, zeroPct);
|
||||
|
||||
return (
|
||||
<Stack align="center" gap={4}>
|
||||
{/* Manual value input — keyed on gain prop so it remounts
|
||||
when an external change arrives (preset, reset) */}
|
||||
<NumberInput
|
||||
defaultValue={gain}
|
||||
hideControls
|
||||
key={gain}
|
||||
max={EQ_MAX}
|
||||
min={EQ_MIN}
|
||||
onBlur={(e) => {
|
||||
const val = parseFloat(e.currentTarget.value);
|
||||
if (!isNaN(val)) {
|
||||
const clamped = Math.max(EQ_MIN, Math.min(EQ_MAX, val));
|
||||
setCurrentGain(clamped);
|
||||
currentGainRef.current = clamped;
|
||||
onChangeEndRef.current(clamped);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
}}
|
||||
size="xs"
|
||||
step={EQ_STEP}
|
||||
w={52}
|
||||
/>
|
||||
|
||||
{/* Vertical track — useMove attaches pointer listeners here */}
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
background: 'var(--mantine-color-default-border)',
|
||||
borderRadius: 4,
|
||||
cursor: active ? 'grabbing' : 'grab',
|
||||
height: TRACK_H,
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
width: 8,
|
||||
}}
|
||||
>
|
||||
{/* Coloured fill between the zero line and the thumb */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--mantine-color-blue-filled)',
|
||||
borderRadius: 2,
|
||||
bottom: `${fillBottomPct}%`,
|
||||
left: 1,
|
||||
position: 'absolute',
|
||||
right: 1,
|
||||
top: `${fillTopPct}%`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Zero-line tick mark */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--mantine-color-gray-5)',
|
||||
bottom: `${zeroPct}%`,
|
||||
height: 1,
|
||||
left: -2,
|
||||
position: 'absolute',
|
||||
right: -2,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Thumb — centre is at thumbPct% from the bottom */}
|
||||
<div
|
||||
style={{
|
||||
// bottom: calc(thumbPct% - THUMB_R) places the
|
||||
// thumb centre exactly at thumbPct% of track height
|
||||
background: 'var(--theme-colors-foreground)',
|
||||
border: '2px solid var(--mantine-color-default-border)',
|
||||
borderRadius: '50%',
|
||||
bottom: `calc(${thumbPct}% - ${THUMB_R}px)`,
|
||||
height: THUMB_R * 2,
|
||||
left: '50%',
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
transform: 'translateX(-50%)',
|
||||
width: THUMB_R * 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Frequency label */}
|
||||
<Text isMuted size="xs" style={{ textAlign: 'center' }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
export const EqSettings = memo(() => {
|
||||
const settings = usePlaybackSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
|
||||
// Ref pattern to avoid stale closure when reading webAudio DSP nodes.
|
||||
// webAudio?.dsp is undefined at callback creation time; the closure
|
||||
// would capture that undefined even after AudioContext initialises.
|
||||
const webAudioContext = useContext(WebAudioContext);
|
||||
const webAudioContextRef = useRef(webAudioContext);
|
||||
useEffect(() => {
|
||||
webAudioContextRef.current = webAudioContext;
|
||||
}, [webAudioContext]);
|
||||
|
||||
// Custom preset state — stored in localStorage separately from main store
|
||||
const [customEqPresets, setCustomEqPresets] = useState<Record<string, number[]>>(() =>
|
||||
loadCustomPresets<number[]>(LS_EQ_PRESETS),
|
||||
);
|
||||
const [customCompPresets, setCustomCompPresets] = useState<Record<string, CompressorPreset>>(
|
||||
() => loadCustomPresets<CompressorPreset>(LS_COMP_PRESETS),
|
||||
);
|
||||
const [saveEqName, setSaveEqName] = useState('');
|
||||
const [saveCompName, setSaveCompName] = useState('');
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(eq: EqSettingsType, compressor: CompressorSettings) => {
|
||||
// ── MPV player ────────────────────────────────────────────────
|
||||
if (settings.type === PlayerType.LOCAL) {
|
||||
const filterStr = buildMpvAudioFilters(eq, compressor);
|
||||
mpvPlayer?.setProperties({ af: filterStr });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Web Audio player ──────────────────────────────────────────
|
||||
// Read from ref so we always get the current AudioContext state,
|
||||
// not the stale value captured when this callback was created.
|
||||
const dsp = webAudioContextRef.current.webAudio?.dsp;
|
||||
if (!dsp) return;
|
||||
|
||||
// Mutations to Web Audio API AudioParam values are intentional
|
||||
// side effects on the live audio graph, not React state mutations.
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
dsp.preampGain.gain.value = eq.enabled ? Math.pow(10, eq.preamp / 20) : 1;
|
||||
|
||||
dsp.eqFilters.forEach((filter, i) => {
|
||||
const band = eq.bands[i];
|
||||
if (band) {
|
||||
filter.gain.value = eq.enabled ? band.gain : 0;
|
||||
}
|
||||
});
|
||||
|
||||
if (compressor.enabled) {
|
||||
dsp.compressor.threshold.value = compressor.threshold;
|
||||
dsp.compressor.ratio.value = compressor.ratio;
|
||||
dsp.compressor.attack.value = compressor.attack / 1000;
|
||||
dsp.compressor.release.value = compressor.release / 1000;
|
||||
dsp.compressor.knee.value = compressor.knee;
|
||||
} else {
|
||||
dsp.compressor.threshold.value = 0;
|
||||
dsp.compressor.ratio.value = 1;
|
||||
dsp.compressor.attack.value = 0;
|
||||
dsp.compressor.release.value = 0.25;
|
||||
dsp.compressor.knee.value = 0;
|
||||
}
|
||||
},
|
||||
// settings.type is the only reactive dep — webAudioContextRef is a
|
||||
// stable ref that always holds the latest context value.
|
||||
[settings.type],
|
||||
);
|
||||
|
||||
// Re-apply filters when switching to Web Audio so DSP nodes reflect
|
||||
// persisted settings immediately without requiring a slider interaction.
|
||||
useEffect(() => {
|
||||
if (settings.type === PlayerType.WEB) {
|
||||
applyFilters(settings.equalizer, settings.compressor);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings.type]);
|
||||
|
||||
// ── EQ handlers ──────────────────────────────────────────────────────────
|
||||
const handleEqToggle = (enabled: boolean) => {
|
||||
const newEq = { ...settings.equalizer, enabled };
|
||||
setSettings({ playback: { equalizer: newEq } });
|
||||
applyFilters(newEq, settings.compressor);
|
||||
};
|
||||
|
||||
const handlePreampChangeEnd = (preamp: number) => {
|
||||
const newEq = { ...settings.equalizer, preamp };
|
||||
setSettings({ playback: { equalizer: newEq } });
|
||||
applyFilters(newEq, settings.compressor);
|
||||
};
|
||||
|
||||
const handleBandChangeEnd = (index: number, gain: number) => {
|
||||
const newBands = settings.equalizer.bands.map((b, i) => (i === index ? { ...b, gain } : b));
|
||||
const newEq = { ...settings.equalizer, bands: newBands };
|
||||
setSettings({ playback: { equalizer: newEq } });
|
||||
applyFilters(newEq, settings.compressor);
|
||||
};
|
||||
|
||||
const applyEqPreset = (gains: number[]) => {
|
||||
const newBands = settings.equalizer.bands.map((b, i) => ({ ...b, gain: gains[i] ?? 0 }));
|
||||
const newEq = { ...settings.equalizer, bands: newBands, preamp: 0 };
|
||||
setSettings({ playback: { equalizer: newEq } });
|
||||
applyFilters(newEq, settings.compressor);
|
||||
};
|
||||
|
||||
const handleSaveEqPreset = () => {
|
||||
const name = saveEqName.trim();
|
||||
if (!name) return;
|
||||
const gains = settings.equalizer.bands.map((b) => b.gain);
|
||||
const updated = { ...customEqPresets, [name]: gains };
|
||||
setCustomEqPresets(updated);
|
||||
saveCustomPresets(LS_EQ_PRESETS, updated);
|
||||
setSaveEqName('');
|
||||
};
|
||||
|
||||
const handleDeleteEqPreset = (name: string) => {
|
||||
const updated = { ...customEqPresets };
|
||||
delete updated[name];
|
||||
setCustomEqPresets(updated);
|
||||
saveCustomPresets(LS_EQ_PRESETS, updated);
|
||||
};
|
||||
|
||||
const handleResetEq = () => {
|
||||
const newEq = {
|
||||
...settings.equalizer,
|
||||
bands: settings.equalizer.bands.map((b) => ({ ...b, gain: 0 })),
|
||||
preamp: 0,
|
||||
};
|
||||
setSettings({ playback: { equalizer: newEq } });
|
||||
applyFilters(newEq, settings.compressor);
|
||||
};
|
||||
|
||||
// ── Compressor handlers ───────────────────────────────────────────────────
|
||||
const handleCompToggle = (enabled: boolean) => {
|
||||
const newComp = { ...settings.compressor, enabled };
|
||||
setSettings({ playback: { compressor: newComp } });
|
||||
applyFilters(settings.equalizer, newComp);
|
||||
};
|
||||
|
||||
const handleCompChangeEnd = (key: keyof CompressorSettings, value: number) => {
|
||||
const newComp = { ...settings.compressor, [key]: value };
|
||||
setSettings({ playback: { compressor: newComp } });
|
||||
applyFilters(settings.equalizer, newComp);
|
||||
};
|
||||
|
||||
const applyCompPreset = (preset: CompressorPreset) => {
|
||||
const newComp = { ...settings.compressor, ...preset };
|
||||
setSettings({ playback: { compressor: newComp } });
|
||||
applyFilters(settings.equalizer, newComp);
|
||||
};
|
||||
|
||||
const handleSaveCompPreset = () => {
|
||||
const name = saveCompName.trim();
|
||||
if (!name) return;
|
||||
const rest = Object.fromEntries(
|
||||
Object.entries(settings.compressor).filter(([key]) => key !== 'enabled'),
|
||||
) as CompressorPreset;
|
||||
const updated = { ...customCompPresets, [name]: rest };
|
||||
setCustomCompPresets(updated);
|
||||
saveCustomPresets(LS_COMP_PRESETS, updated);
|
||||
setSaveCompName('');
|
||||
};
|
||||
|
||||
const handleDeleteCompPreset = (name: string) => {
|
||||
const updated = { ...customCompPresets };
|
||||
delete updated[name];
|
||||
setCustomCompPresets(updated);
|
||||
saveCustomPresets(LS_COMP_PRESETS, updated);
|
||||
};
|
||||
|
||||
const handleResetComp = () => {
|
||||
const newComp = {
|
||||
attack: 20,
|
||||
enabled: settings.compressor.enabled,
|
||||
knee: 2.83,
|
||||
makeup: 6,
|
||||
ratio: 4,
|
||||
release: 250,
|
||||
threshold: -24,
|
||||
};
|
||||
setSettings({ playback: { compressor: newComp } });
|
||||
applyFilters(settings.equalizer, newComp);
|
||||
};
|
||||
|
||||
// ── Preset select data ────────────────────────────────────────────────────
|
||||
const eqPresetSelectData = [
|
||||
{
|
||||
group: 'Built-in',
|
||||
items: Object.keys(EQ_PRESETS).map((name) => ({ label: name, value: name })),
|
||||
},
|
||||
...(Object.keys(customEqPresets).length > 0
|
||||
? [
|
||||
{
|
||||
group: 'Custom',
|
||||
items: Object.keys(customEqPresets).map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const compPresetSelectData = [
|
||||
{
|
||||
group: 'Built-in',
|
||||
items: Object.keys(COMP_PRESETS).map((name) => ({ label: name, value: name })),
|
||||
},
|
||||
...(Object.keys(customCompPresets).length > 0
|
||||
? [
|
||||
{
|
||||
group: 'Custom',
|
||||
items: Object.keys(customCompPresets).map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
// ── EQ SettingsSection options ────────────────────────────────────────────
|
||||
const eqOptions: SettingOption[] = [
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
defaultChecked={settings.equalizer.enabled}
|
||||
onChange={(e) => handleEqToggle(e.currentTarget.checked)}
|
||||
/>
|
||||
),
|
||||
description:
|
||||
settings.type === PlayerType.LOCAL
|
||||
? 'Parametric equalizer via FFmpeg lavfi (MPV)'
|
||||
: 'Parametric equalizer via Web Audio API',
|
||||
title: 'Equalizer',
|
||||
},
|
||||
...(settings.equalizer.enabled
|
||||
? ([
|
||||
{
|
||||
control: (
|
||||
<Group gap="xs">
|
||||
<Select
|
||||
clearable
|
||||
data={eqPresetSelectData}
|
||||
onChange={(name) => {
|
||||
if (!name) return;
|
||||
const preset = customEqPresets[name] ?? EQ_PRESETS[name];
|
||||
if (preset) applyEqPreset(preset);
|
||||
}}
|
||||
placeholder="Select preset"
|
||||
searchable
|
||||
value={null}
|
||||
w={180}
|
||||
/>
|
||||
{Object.keys(customEqPresets).length > 0 && (
|
||||
<Select
|
||||
clearable
|
||||
data={Object.keys(customEqPresets).map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
}))}
|
||||
onChange={(name) => {
|
||||
if (!name) return;
|
||||
handleDeleteEqPreset(name);
|
||||
}}
|
||||
placeholder="Delete custom..."
|
||||
value={null}
|
||||
w={160}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
description: 'Apply a built-in or saved custom EQ curve',
|
||||
title: 'Preset',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Group gap="xs">
|
||||
<TextInput
|
||||
onChange={(e) => setSaveEqName(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveEqPreset();
|
||||
}}
|
||||
placeholder="Preset name..."
|
||||
value={saveEqName}
|
||||
w={180}
|
||||
/>
|
||||
<Button
|
||||
disabled={!saveEqName.trim()}
|
||||
onClick={handleSaveEqPreset}
|
||||
variant="subtle"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
description: 'Save the current EQ settings as a named preset',
|
||||
title: 'Save preset',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Group gap="xs">
|
||||
<Slider
|
||||
label={(v) => `${v > 0 ? '+' : ''}${v} dB`}
|
||||
max={EQ_MAX}
|
||||
min={EQ_MIN}
|
||||
onChange={(v) => {
|
||||
setSettings({
|
||||
playback: {
|
||||
equalizer: { ...settings.equalizer, preamp: v },
|
||||
},
|
||||
});
|
||||
}}
|
||||
onChangeEnd={handlePreampChangeEnd}
|
||||
step={EQ_STEP}
|
||||
value={settings.equalizer.preamp}
|
||||
w={200}
|
||||
/>
|
||||
{/* Manual preamp input */}
|
||||
<NumberInput
|
||||
hideControls
|
||||
max={EQ_MAX}
|
||||
min={EQ_MIN}
|
||||
onBlur={(e) => {
|
||||
const val = parseFloat(e.currentTarget.value);
|
||||
if (!isNaN(val)) {
|
||||
const clamped = Math.max(EQ_MIN, Math.min(EQ_MAX, val));
|
||||
handlePreampChangeEnd(clamped);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
}}
|
||||
rightSection={
|
||||
<Text isMuted size="xs">
|
||||
dB
|
||||
</Text>
|
||||
}
|
||||
size="sm"
|
||||
step={EQ_STEP}
|
||||
value={settings.equalizer.preamp}
|
||||
w={70}
|
||||
/>
|
||||
<Button onClick={handleResetEq} variant="subtle">
|
||||
Reset all
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
description:
|
||||
'Input gain before EQ bands. Set negative when boosting bands to prevent clipping (MPV).',
|
||||
title: 'Preamp',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
// EqBandSlider uses useMove for correct vertical drag direction
|
||||
// (up = higher value, down = lower value). The NumberInput above
|
||||
// each band allows precise manual entry.
|
||||
<Group align="flex-end" gap={2} wrap="nowrap">
|
||||
{settings.equalizer.bands.map((band, i) => (
|
||||
<EqBandSlider
|
||||
freq={band.freq}
|
||||
gain={band.gain}
|
||||
key={band.freq}
|
||||
label={BAND_LABELS[i] ?? String(band.freq)}
|
||||
onChangeEnd={(v) => handleBandChangeEnd(i, v)}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
),
|
||||
description:
|
||||
'Per-band gain. Drag up/down or type a value. Range: -12 to +12 dB.',
|
||||
title: 'Bands',
|
||||
},
|
||||
] as SettingOption[])
|
||||
: []),
|
||||
];
|
||||
|
||||
// ── Compressor param definitions ──────────────────────────────────────────
|
||||
const compParams: {
|
||||
description: string;
|
||||
key: keyof CompressorSettings;
|
||||
max: number;
|
||||
min: number;
|
||||
step: number;
|
||||
title: string;
|
||||
unit: string;
|
||||
}[] = [
|
||||
{
|
||||
description: 'Signal level above which compression begins.',
|
||||
key: 'threshold',
|
||||
max: 0,
|
||||
min: -60,
|
||||
step: 1,
|
||||
title: 'Threshold',
|
||||
unit: 'dB',
|
||||
},
|
||||
{
|
||||
description: 'Compression ratio, e.g. 4 = 4:1.',
|
||||
key: 'ratio',
|
||||
max: 20,
|
||||
min: 1,
|
||||
step: 0.5,
|
||||
title: 'Ratio',
|
||||
unit: ':1',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'How quickly the compressor engages after the signal exceeds the threshold.',
|
||||
key: 'attack',
|
||||
max: 2000,
|
||||
min: 0.1,
|
||||
step: 1,
|
||||
title: 'Attack',
|
||||
unit: 'ms',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'How quickly the compressor releases after the signal drops below the threshold.',
|
||||
key: 'release',
|
||||
max: 9000,
|
||||
min: 1,
|
||||
step: 10,
|
||||
title: 'Release',
|
||||
unit: 'ms',
|
||||
},
|
||||
{
|
||||
description: 'Output gain applied after compression to restore loudness.',
|
||||
key: 'makeup',
|
||||
max: 30,
|
||||
min: 0,
|
||||
step: 0.5,
|
||||
title: 'Makeup Gain',
|
||||
unit: 'dB',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Soft-knee width. Higher values make the transition into compression more gradual.',
|
||||
key: 'knee',
|
||||
max: 10,
|
||||
min: 1,
|
||||
step: 0.5,
|
||||
title: 'Knee',
|
||||
unit: 'dB',
|
||||
},
|
||||
];
|
||||
|
||||
// ── Compressor SettingsSection options ────────────────────────────────────
|
||||
const compressorOptions: SettingOption[] = [
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
defaultChecked={settings.compressor.enabled}
|
||||
onChange={(e) => handleCompToggle(e.currentTarget.checked)}
|
||||
/>
|
||||
),
|
||||
description:
|
||||
settings.type === PlayerType.LOCAL
|
||||
? 'Dynamic range compressor via FFmpeg acompressor (MPV)'
|
||||
: 'Dynamic range compressor via Web Audio API',
|
||||
title: 'Compressor',
|
||||
},
|
||||
...(settings.compressor.enabled
|
||||
? ([
|
||||
{
|
||||
control: (
|
||||
<Group gap="xs">
|
||||
<Select
|
||||
clearable
|
||||
data={compPresetSelectData}
|
||||
onChange={(name) => {
|
||||
if (!name) return;
|
||||
const preset = customCompPresets[name] ?? COMP_PRESETS[name];
|
||||
if (preset) applyCompPreset(preset);
|
||||
}}
|
||||
placeholder="Select preset"
|
||||
searchable
|
||||
value={null}
|
||||
w={180}
|
||||
/>
|
||||
{Object.keys(customCompPresets).length > 0 && (
|
||||
<Select
|
||||
clearable
|
||||
data={Object.keys(customCompPresets).map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
}))}
|
||||
onChange={(name) => {
|
||||
if (!name) return;
|
||||
handleDeleteCompPreset(name);
|
||||
}}
|
||||
placeholder="Delete custom..."
|
||||
value={null}
|
||||
w={160}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
description: 'Apply a built-in or saved custom compressor setting',
|
||||
title: 'Preset',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Group gap="xs">
|
||||
<TextInput
|
||||
onChange={(e) => setSaveCompName(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveCompPreset();
|
||||
}}
|
||||
placeholder="Preset name..."
|
||||
value={saveCompName}
|
||||
w={180}
|
||||
/>
|
||||
<Button
|
||||
disabled={!saveCompName.trim()}
|
||||
onClick={handleSaveCompPreset}
|
||||
variant="subtle"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
description: 'Save the current compressor settings as a named preset',
|
||||
title: 'Save preset',
|
||||
},
|
||||
// One SettingOption per compressor parameter — Slider + NumberInput
|
||||
...compParams.map(({ description, key, max, min, step, title, unit }) => ({
|
||||
control: (
|
||||
<Group align="center" gap="xs">
|
||||
<Slider
|
||||
label={(v) => `${v}${unit}`}
|
||||
max={max}
|
||||
min={min}
|
||||
onChange={(v) => {
|
||||
setSettings({
|
||||
playback: {
|
||||
compressor: { ...settings.compressor, [key]: v },
|
||||
},
|
||||
});
|
||||
}}
|
||||
onChangeEnd={(v) => handleCompChangeEnd(key, v)}
|
||||
step={step}
|
||||
value={settings.compressor[key] as number}
|
||||
w={200}
|
||||
/>
|
||||
{/* Manual value input — remounts with new defaultValue
|
||||
when settings change (preset applied, slider moved) */}
|
||||
<NumberInput
|
||||
hideControls
|
||||
max={max}
|
||||
min={min}
|
||||
onBlur={(e) => {
|
||||
const val = parseFloat(e.currentTarget.value);
|
||||
if (!isNaN(val)) {
|
||||
const clamped = Math.max(min, Math.min(max, val));
|
||||
handleCompChangeEnd(key, clamped);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
}}
|
||||
rightSection={
|
||||
<Text isMuted size="xs">
|
||||
{unit}
|
||||
</Text>
|
||||
}
|
||||
size="sm"
|
||||
step={step}
|
||||
value={settings.compressor[key] as number}
|
||||
w={80}
|
||||
/>
|
||||
</Group>
|
||||
),
|
||||
description,
|
||||
title,
|
||||
})),
|
||||
{
|
||||
control: (
|
||||
<Button onClick={handleResetComp} variant="subtle">
|
||||
Reset to defaults
|
||||
</Button>
|
||||
),
|
||||
description: 'Restore all compressor parameters to their default values',
|
||||
title: 'Reset',
|
||||
},
|
||||
] as SettingOption[])
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<SettingsSection options={eqOptions} />
|
||||
<Divider />
|
||||
<SettingsSection options={compressorOptions} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// Builds the MPV `af` audio filter chain string for EQ and compressor.
|
||||
// Uses FFmpeg lavfi filters, which MPV exposes natively.
|
||||
|
||||
export interface CompressorSettings {
|
||||
attack: number; // ms
|
||||
enabled: boolean;
|
||||
knee: number; // dB soft-knee width
|
||||
makeup: number; // dB post-compression gain
|
||||
ratio: number; // e.g. 4 (means 4:1)
|
||||
release: number; // ms
|
||||
threshold: number; // dB, e.g. -24
|
||||
}
|
||||
|
||||
export interface EqBand {
|
||||
freq: number;
|
||||
gain: number; // dB, clamped to [-12, 12]
|
||||
}
|
||||
|
||||
export interface EqSettings {
|
||||
bands: EqBand[];
|
||||
enabled: boolean;
|
||||
preamp: number; // dB pre-gain before bands, clamped to [-12, 12]
|
||||
}
|
||||
|
||||
// Octave widths for each band — tuned so 10 bands cover 20Hz–20kHz
|
||||
// with no gaps and gentle overlap.
|
||||
const BAND_WIDTHS: Record<number, number> = {
|
||||
31.5: 1.9,
|
||||
63: 1.3,
|
||||
125: 1.0,
|
||||
250: 1.0,
|
||||
500: 1.0,
|
||||
1000: 1.0,
|
||||
2000: 1.0,
|
||||
3000: 1.0,
|
||||
4000: 1.0,
|
||||
6300: 1.2,
|
||||
10000: 1.2,
|
||||
16000: 1.5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the MPV `af` property value for the given EQ + compressor settings.
|
||||
* An empty string clears all filters (pass-through).
|
||||
*/
|
||||
export function buildMpvAudioFilters(eq: EqSettings, compressor: CompressorSettings): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (eq.enabled) {
|
||||
// Apply preamp as a straight input gain before the band filters.
|
||||
// The user is responsible for setting a negative preamp value when
|
||||
// boosting bands to avoid clipping — matching the behaviour of VLC,
|
||||
// foobar2000, and hardware EQs. The UI preamp slider exists for this purpose.
|
||||
if (eq.preamp !== 0) {
|
||||
parts.push(`volume=${eq.preamp}dB`);
|
||||
}
|
||||
|
||||
// One parametric EQ filter per non-zero band
|
||||
for (const band of eq.bands) {
|
||||
if (band.gain === 0) continue;
|
||||
const w = BAND_WIDTHS[band.freq] ?? 1.0;
|
||||
parts.push(`lavfi=[equalizer=f=${band.freq}:width_type=o:w=${w}:g=${band.gain}]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (compressor.enabled) {
|
||||
const threshLinear = Math.pow(10, compressor.threshold / 20);
|
||||
const makeupLinear = Math.pow(10, compressor.makeup / 20);
|
||||
parts.push(
|
||||
`lavfi=[acompressor=` +
|
||||
`threshold=${threshLinear.toFixed(6)}:` +
|
||||
`ratio=${compressor.ratio}:` +
|
||||
`attack=${compressor.attack}:` +
|
||||
`release=${compressor.release}:` +
|
||||
`makeup=${makeupLinear.toFixed(6)}:` +
|
||||
`knee=${compressor.knee}` +
|
||||
`]`,
|
||||
);
|
||||
}
|
||||
|
||||
return parts.join(',');
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { AudioSettings } from '/@/renderer/features/settings/components/playback/audio-settings';
|
||||
import { AutoDJSettings } from '/@/renderer/features/settings/components/playback/auto-dj-settings';
|
||||
import { EqSettings } from '/@/renderer/features/settings/components/playback/eq-settings';
|
||||
import { PlayerFilterSettings } from '/@/renderer/features/settings/components/playback/player-filter-settings';
|
||||
import { TranscodeSettings } from '/@/renderer/features/settings/components/playback/transcode-settings';
|
||||
import { useSettingsStore } from '/@/renderer/store';
|
||||
@@ -37,6 +38,7 @@ export const PlaybackTab = memo(() => {
|
||||
<Stack gap="md">
|
||||
<AudioSettings />
|
||||
<Suspense fallback={<></>}>{hasFancyAudio && <MpvSettings />}</Suspense>
|
||||
<EqSettings />
|
||||
<Divider />
|
||||
<TranscodeSettings />
|
||||
<Divider />
|
||||
|
||||
@@ -298,6 +298,7 @@ export const useServerAuthenticated = () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, NETWORK_RETRY_DELAY_MS));
|
||||
|
||||
// Retry authentication
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
return authenticateServer(serverWithAuth, nextRetry);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@ import { useEffect, useMemo } from 'react';
|
||||
import { Navigate, Outlet } from 'react-router';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { normalizeServerUrl } from '/@/renderer/features/action-required/utils/server-lock';
|
||||
import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useAuthStore, useAuthStoreActions } from '/@/renderer/store';
|
||||
|
||||
const normalizeUrl = (url: string) => url.replace(/\/$/, '');
|
||||
|
||||
export const AppOutlet = () => {
|
||||
const currentServer = useAuthStore(
|
||||
(state) =>
|
||||
@@ -19,25 +18,27 @@ export const AppOutlet = () => {
|
||||
: null,
|
||||
shallow,
|
||||
);
|
||||
const { deleteServer, setCurrentServer } = useAuthStoreActions();
|
||||
const { setCurrentServer, updateServer } = useAuthStoreActions();
|
||||
|
||||
const hasServerLockMismatch = useMemo(() => {
|
||||
if (!isServerLock() || !currentServer || !window.SERVER_URL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const configuredUrl = normalizeUrl(window.SERVER_URL);
|
||||
const persistedUrl = normalizeUrl(currentServer.url);
|
||||
const configuredUrl = normalizeServerUrl(window.SERVER_URL);
|
||||
const persistedUrl = normalizeServerUrl(currentServer.url);
|
||||
|
||||
return configuredUrl !== persistedUrl;
|
||||
}, [currentServer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasServerLockMismatch && currentServer) {
|
||||
deleteServer(currentServer.id);
|
||||
if (hasServerLockMismatch && currentServer && window.SERVER_URL) {
|
||||
updateServer(currentServer.id, {
|
||||
url: normalizeServerUrl(window.SERVER_URL),
|
||||
});
|
||||
setCurrentServer(null);
|
||||
}
|
||||
}, [currentServer, deleteServer, hasServerLockMismatch, setCurrentServer]);
|
||||
}, [currentServer, hasServerLockMismatch, setCurrentServer, updateServer]);
|
||||
|
||||
const isActionsRequired = !currentServer || hasServerLockMismatch;
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ const appRouterModals = {
|
||||
|
||||
export const AppRouter = () => {
|
||||
const router = (
|
||||
<HashRouter unstable_useTransitions={false}>
|
||||
<HashRouter>
|
||||
<ModalsProvider modals={appRouterModals}>
|
||||
<RouterErrorBoundary>
|
||||
<Routes>
|
||||
|
||||
@@ -271,6 +271,26 @@ const MpvSettingsSchema = z.object({
|
||||
replayGainMode: z.enum(['album', 'no', 'track']),
|
||||
replayGainPreampDB: z.number().optional(),
|
||||
});
|
||||
const EqSettingsSchema = z.object({
|
||||
bands: z.array(
|
||||
z.object({
|
||||
freq: z.number(),
|
||||
gain: z.number(),
|
||||
}),
|
||||
),
|
||||
enabled: z.boolean(),
|
||||
preamp: z.number(),
|
||||
});
|
||||
|
||||
const CompressorSettingsSchema = z.object({
|
||||
attack: z.number(),
|
||||
enabled: z.boolean(),
|
||||
knee: z.number(),
|
||||
makeup: z.number(),
|
||||
ratio: z.number(),
|
||||
release: z.number(),
|
||||
threshold: z.number(),
|
||||
});
|
||||
|
||||
const CssSettingsSchema = z.object({
|
||||
content: z.string().transform((val) => sanitizeCss(`<style>${val}`)),
|
||||
@@ -556,6 +576,7 @@ const LyricsSettingsSchema = z.object({
|
||||
alignment: z.enum(['center', 'left', 'right']),
|
||||
delayMs: z.number(),
|
||||
enableAutoTranslation: z.boolean(),
|
||||
enableFurigana: z.boolean().optional(),
|
||||
enableNeteaseTranslation: z.boolean(),
|
||||
fetch: z.boolean(),
|
||||
follow: z.boolean(),
|
||||
@@ -625,6 +646,8 @@ const PlayerFilterSchema = z.object({
|
||||
const PlaybackSettingsSchema = z.object({
|
||||
audioDeviceId: z.string().nullable().optional(),
|
||||
audioFadeOnStatusChange: z.boolean(),
|
||||
compressor: CompressorSettingsSchema,
|
||||
equalizer: EqSettingsSchema,
|
||||
filters: z.array(PlayerFilterSchema),
|
||||
mediaSession: z.boolean(),
|
||||
mpvAudioDeviceId: z.string().nullable().optional(),
|
||||
@@ -1823,6 +1846,7 @@ const initialState: SettingsState = {
|
||||
alignment: 'center',
|
||||
delayMs: 0,
|
||||
enableAutoTranslation: false,
|
||||
enableFurigana: false,
|
||||
enableNeteaseTranslation: false,
|
||||
fetch: true,
|
||||
follow: true,
|
||||
@@ -1847,6 +1871,33 @@ const initialState: SettingsState = {
|
||||
playback: {
|
||||
audioDeviceId: undefined,
|
||||
audioFadeOnStatusChange: true,
|
||||
compressor: {
|
||||
attack: 20,
|
||||
enabled: false,
|
||||
knee: 2.83,
|
||||
makeup: 6,
|
||||
ratio: 4,
|
||||
release: 250,
|
||||
threshold: -24,
|
||||
},
|
||||
equalizer: {
|
||||
bands: [
|
||||
{ freq: 31.5, gain: 0 },
|
||||
{ freq: 63, gain: 0 },
|
||||
{ freq: 125, gain: 0 },
|
||||
{ freq: 250, gain: 0 },
|
||||
{ freq: 500, gain: 0 },
|
||||
{ freq: 1000, gain: 0 },
|
||||
{ freq: 2000, gain: 0 },
|
||||
{ freq: 3000, gain: 0 },
|
||||
{ freq: 4000, gain: 0 },
|
||||
{ freq: 6300, gain: 0 },
|
||||
{ freq: 10000, gain: 0 },
|
||||
{ freq: 16000, gain: 0 },
|
||||
],
|
||||
enabled: false,
|
||||
preamp: 0,
|
||||
},
|
||||
filters: [],
|
||||
mediaSession: false,
|
||||
mpvAudioDeviceId: undefined,
|
||||
|
||||
@@ -47,6 +47,7 @@ export const THEME_DATA = [
|
||||
{ label: 'Rosé Pine', type: 'dark', value: AppTheme.ROSE_PINE },
|
||||
{ label: 'Rosé Pine Moon', type: 'dark', value: AppTheme.ROSE_PINE_MOON },
|
||||
{ label: 'Rosé Pine Dawn', type: 'light', value: AppTheme.ROSE_PINE_DAWN },
|
||||
{ label: 'Zenburn', type: 'dark', value: AppTheme.ZENBURN },
|
||||
];
|
||||
|
||||
export const useAppTheme = (overrideTheme?: AppTheme) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './get-header-color';
|
||||
export * from './normalize-server-url';
|
||||
export * from './parse-search-params';
|
||||
export * from './random-string';
|
||||
export * from './resolve-song-path';
|
||||
export * from './rgb-to-rgba';
|
||||
export * from './sentence-case';
|
||||
export * from './set-local-storage-setttings';
|
||||
|
||||
@@ -107,6 +107,7 @@ export const logMsg = {
|
||||
[LogCategory.SCROBBLE]: {
|
||||
scrobbledPause: 'Scrobbled a pause event',
|
||||
scrobbledStart: 'Scrobbled a start event',
|
||||
scrobbledStop: 'Scrobbled a stop event',
|
||||
scrobbledSubmission: 'Scrobbled a submission event',
|
||||
scrobbledTimeupdate: 'Scrobbled a timeupdate event',
|
||||
scrobbledUnpause: 'Scrobbled an unpause event',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { usePathReplace, useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { replacePathPrefix } from '/@/shared/api/utils';
|
||||
|
||||
export const resolveSongPath = (path: null | string | undefined): null | string => {
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { pathReplace, pathReplaceWith } = useSettingsStore.getState().general;
|
||||
|
||||
return replacePathPrefix(path, pathReplace, pathReplaceWith);
|
||||
};
|
||||
|
||||
export const useResolvedSongPath = (path: null | string | undefined): null | string => {
|
||||
const { pathReplace, pathReplaceWith } = usePathReplace();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return replacePathPrefix(path, pathReplace, pathReplaceWith);
|
||||
}, [path, pathReplace, pathReplaceWith]);
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import DomPurify, { Config } from 'dompurify';
|
||||
|
||||
const SANITIZE_OPTIONS: Config = {
|
||||
ALLOWED_ATTR: ['href'],
|
||||
ALLOWED_TAGS: ['a', 'b', 'div', 'em', 'i', 'p', 'span', 'strong'],
|
||||
ALLOWED_TAGS: ['a', 'b', 'div', 'em', 'i', 'p', 'span', 'strong', 'ruby', 'rt', 'rp'],
|
||||
// allow http://, https://, and // (mapped to https)
|
||||
ALLOWED_URI_REGEXP: /^(http(s?):)?\/\/.+/i,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import { z } from 'zod';
|
||||
|
||||
import { jfType } from '/@/shared/api/jellyfin/jellyfin-types';
|
||||
import { coerceYear, parsePartialIsoDateFromApi } from '/@/shared/api/partial-iso-date';
|
||||
import { replacePathPrefix } from '/@/shared/api/utils';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
@@ -156,8 +155,6 @@ const jellyfinPremiereFields = (item: {
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof jfType._response.song>,
|
||||
server: null | ServerListItem,
|
||||
pathReplace?: string,
|
||||
pathReplaceWith?: string,
|
||||
): Song => {
|
||||
let bitDepth: null | number = null;
|
||||
let bitRate = 0;
|
||||
@@ -257,7 +254,7 @@ const normalizeSong = (
|
||||
mbzTrackId: item.ProviderIds?.MusicBrainzTrack || null,
|
||||
name: item.Name,
|
||||
participants,
|
||||
path: replacePathPrefix(path || '', pathReplace, pathReplaceWith),
|
||||
path: path || '',
|
||||
peak: null,
|
||||
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
||||
playlistItemId: item.PlaylistItemId,
|
||||
@@ -278,8 +275,6 @@ const normalizeSong = (
|
||||
const normalizeAlbum = (
|
||||
item: z.infer<typeof jfType._response.album>,
|
||||
server: null | ServerListItem,
|
||||
pathReplace?: string,
|
||||
pathReplaceWith?: string,
|
||||
): Album => {
|
||||
const { originalYear, releaseDate, releaseYear } = jellyfinPremiereFields(item);
|
||||
|
||||
@@ -342,7 +337,7 @@ const normalizeAlbum = (
|
||||
releaseYear,
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
songs: item.Songs?.map((song) => normalizeSong(song, server, pathReplace, pathReplaceWith)),
|
||||
songs: item.Songs?.map((song) => normalizeSong(song, server)),
|
||||
sortName: item.SortName || item.Name,
|
||||
tags: getTags(item),
|
||||
updatedAt: item?.DateLastMediaAdded || item.DateCreated,
|
||||
|
||||
@@ -3,7 +3,6 @@ import z from 'zod';
|
||||
import { ndType } from '/@/shared/api/navidrome/navidrome-types';
|
||||
import { coerceYear, parsePartialIsoDate } from '/@/shared/api/partial-iso-date';
|
||||
import { ssType } from '/@/shared/api/subsonic/subsonic-types';
|
||||
import { replacePathPrefix } from '/@/shared/api/utils';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
@@ -199,8 +198,6 @@ const getArtists = (
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof ndType._response.playlistSong> | z.infer<typeof ndType._response.song>,
|
||||
server?: null | ServerListItem,
|
||||
pathReplace?: string,
|
||||
pathReplaceWith?: string,
|
||||
): Song => {
|
||||
let id;
|
||||
let playlistItemId;
|
||||
@@ -270,7 +267,7 @@ const normalizeSong = (
|
||||
name: item.title,
|
||||
// Thankfully, Windows is merciful and allows a mix of separators. So, we can use the
|
||||
// POSIX separator here instead
|
||||
path: item.path ? replacePathPrefix(item.path, pathReplace, pathReplaceWith) : null,
|
||||
path: item.path ? `${item.libraryPath}/${item.path}` : null,
|
||||
peak:
|
||||
item.rgAlbumPeak || item.rgTrackPeak
|
||||
? { album: item.rgAlbumPeak, track: item.rgTrackPeak }
|
||||
@@ -337,8 +334,6 @@ const normalizeAlbum = (
|
||||
songs?: z.infer<typeof ndType._response.songList>;
|
||||
},
|
||||
server?: null | ServerListItem,
|
||||
pathReplace?: string,
|
||||
pathReplaceWith?: string,
|
||||
): Album => {
|
||||
const releaseDate = normalizeNavidromeReleaseDate(item);
|
||||
const originalDate = normalizeNavidromeOriginalDate(item);
|
||||
@@ -386,9 +381,7 @@ const normalizeAlbum = (
|
||||
releaseYear: releaseDate.year > 0 ? releaseDate.year : null,
|
||||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
songs: item.songs
|
||||
? item.songs.map((song) => normalizeSong(song, server, pathReplace, pathReplaceWith))
|
||||
: undefined,
|
||||
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server)) : undefined,
|
||||
sortName: item.orderAlbumName,
|
||||
tags: item.tags || null,
|
||||
updatedAt: item.updatedAt,
|
||||
|
||||
@@ -216,6 +216,17 @@ export const NDSongQueryPlaylistOperators = [
|
||||
},
|
||||
];
|
||||
|
||||
const NDPresenceOperators = [
|
||||
{
|
||||
label: i18n.t('filterOperator.isMissing'),
|
||||
value: 'isMissing',
|
||||
},
|
||||
{
|
||||
label: i18n.t('filterOperator.isPresent'),
|
||||
value: 'isPresent',
|
||||
},
|
||||
];
|
||||
|
||||
export const NDSongQueryDateOperators = [
|
||||
{
|
||||
label: i18n.t('filterOperator.is'),
|
||||
@@ -225,6 +236,7 @@ export const NDSongQueryDateOperators = [
|
||||
label: i18n.t('filterOperator.isNot'),
|
||||
value: 'isNot',
|
||||
},
|
||||
...NDPresenceOperators,
|
||||
{
|
||||
label: i18n.t('filterOperator.before'),
|
||||
value: 'before',
|
||||
@@ -268,6 +280,7 @@ export const NDSongQueryStringOperators = [
|
||||
label: i18n.t('filterOperator.isNot'),
|
||||
value: 'isNot',
|
||||
},
|
||||
...NDPresenceOperators,
|
||||
{
|
||||
label: i18n.t('filterOperator.contains'),
|
||||
value: 'contains',
|
||||
@@ -295,6 +308,7 @@ export const NDSongQueryBooleanOperators = [
|
||||
label: i18n.t('filterOperator.isNot'),
|
||||
value: 'isNot',
|
||||
},
|
||||
...NDPresenceOperators,
|
||||
];
|
||||
|
||||
export const NDSongQueryNumberOperators = [
|
||||
@@ -306,6 +320,7 @@ export const NDSongQueryNumberOperators = [
|
||||
label: i18n.t('filterOperator.isNot'),
|
||||
value: 'isNot',
|
||||
},
|
||||
...NDPresenceOperators,
|
||||
{
|
||||
label: i18n.t('filterOperator.contains'),
|
||||
value: 'contains',
|
||||
|
||||
@@ -2,7 +2,6 @@ import { z } from 'zod';
|
||||
|
||||
import { coerceYear, parsePartialIsoDate } from '/@/shared/api/partial-iso-date';
|
||||
import { ssType } from '/@/shared/api/subsonic/subsonic-types';
|
||||
import { replacePathPrefix } from '/@/shared/api/utils';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
@@ -163,8 +162,6 @@ const subsonicReleaseFields = (item: {
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof ssType._response.song>,
|
||||
server?: null | ServerListItemWithCredential,
|
||||
pathReplace?: string,
|
||||
pathReplaceWith?: string,
|
||||
playlistIndex?: number,
|
||||
discTitleMap?: Map<number, string>,
|
||||
): Song => {
|
||||
@@ -221,7 +218,7 @@ const normalizeSong = (
|
||||
mbzTrackId: null,
|
||||
name: item.title,
|
||||
participants,
|
||||
path: replacePathPrefix(item.path || '', pathReplace, pathReplaceWith),
|
||||
path: item.path || '',
|
||||
peak:
|
||||
item.replayGain && (item.replayGain.albumPeak || item.replayGain.trackPeak)
|
||||
? {
|
||||
@@ -305,8 +302,6 @@ const getReleaseType = (
|
||||
const normalizeAlbum = (
|
||||
item: z.infer<typeof ssType._response.album> | z.infer<typeof ssType._response.albumListEntry>,
|
||||
server?: null | ServerListItemWithCredential,
|
||||
pathReplace?: string,
|
||||
pathReplaceWith?: string,
|
||||
): Album => {
|
||||
const discTitleMap = new Map<number, string>();
|
||||
|
||||
@@ -354,7 +349,7 @@ const normalizeAlbum = (
|
||||
songCount: item.songCount,
|
||||
songs:
|
||||
(item as z.infer<typeof ssType._response.album>).song?.map((song) =>
|
||||
normalizeSong(song, server, pathReplace, pathReplaceWith, undefined, discTitleMap),
|
||||
normalizeSong(song, server, undefined, discTitleMap),
|
||||
) || [],
|
||||
sortName: item.title,
|
||||
tags: null,
|
||||
@@ -410,8 +405,6 @@ const normalizeGenre = (
|
||||
const normalizeFolder = (
|
||||
item: z.infer<typeof ssType._response.directory>,
|
||||
server?: null | ServerListItemWithCredential,
|
||||
pathReplace?: string,
|
||||
pathReplaceWith?: string,
|
||||
): Folder => {
|
||||
const results = item.child?.reduce(
|
||||
(acc: { folders: Folder[]; songs: Song[] }, item) => {
|
||||
@@ -421,7 +414,7 @@ const normalizeFolder = (
|
||||
const folder = normalizeFolder(item, server);
|
||||
acc.folders.push(folder);
|
||||
} else {
|
||||
const song = normalizeSong(item, server, pathReplace, pathReplaceWith);
|
||||
const song = normalizeSong(item, server);
|
||||
acc.songs.push(song);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export enum AppTheme {
|
||||
TOKYO_NIGHT = 'tokyoNight',
|
||||
VSCODE_DARK_PLUS = 'vscodeDarkPlus',
|
||||
VSCODE_LIGHT_PLUS = 'vscodeLightPlus',
|
||||
ZENBURN = 'zenburn',
|
||||
}
|
||||
|
||||
export type AppThemeConfiguration = Partial<BaseAppThemeConfiguration>;
|
||||
|
||||
@@ -35,6 +35,7 @@ import { solarizedLight } from '/@/shared/themes/solarized-light/solarized-light
|
||||
import { tokyoNight } from '/@/shared/themes/tokyo-night/tokyo-night';
|
||||
import { vscodeDarkPlus } from '/@/shared/themes/vscode-dark-plus/vscode-dark-plus';
|
||||
import { vscodeLightPlus } from '/@/shared/themes/vscode-light-plus/vscode-light-plus';
|
||||
import { zenburn } from '/@/shared/themes/zenburn/zenburn';
|
||||
|
||||
export const appTheme: Record<AppTheme, AppThemeConfiguration> = {
|
||||
[AppTheme.AYU_DARK]: ayuDark,
|
||||
@@ -68,6 +69,7 @@ export const appTheme: Record<AppTheme, AppThemeConfiguration> = {
|
||||
[AppTheme.TOKYO_NIGHT]: tokyoNight,
|
||||
[AppTheme.VSCODE_DARK_PLUS]: vscodeDarkPlus,
|
||||
[AppTheme.VSCODE_LIGHT_PLUS]: vscodeLightPlus,
|
||||
[AppTheme.ZENBURN]: zenburn,
|
||||
};
|
||||
|
||||
export const getAppTheme = (theme: AppTheme): AppThemeConfiguration => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';
|
||||
|
||||
export const zenburn: AppThemeConfiguration = {
|
||||
app: {
|
||||
'overlay-header':
|
||||
'linear-gradient(transparent 0%, rgb(40 44 52 / 85%) 100%), var(--theme-background-noise)',
|
||||
'overlay-subheader':
|
||||
'linear-gradient(180deg, rgb(40 44 52 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',
|
||||
'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',
|
||||
'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',
|
||||
},
|
||||
colors: {
|
||||
background: '#3f3f3f',
|
||||
'background-alternate': '#313131',
|
||||
black: '#313131',
|
||||
foreground: '#dcdccc',
|
||||
'foreground-muted': '#d9d9d9',
|
||||
primary: '#95a4b2',
|
||||
'state-error': '#dca3a3',
|
||||
'state-info': '#95a4b2',
|
||||
'state-success': '#7f9f7f',
|
||||
'state-warning': '#efdcbc',
|
||||
surface: '#636363',
|
||||
'surface-foreground': '#95a4b2',
|
||||
white: '#dcdccc',
|
||||
},
|
||||
mode: 'dark',
|
||||
};
|
||||
@@ -414,10 +414,8 @@ export type Song = {
|
||||
userRating: null | number;
|
||||
};
|
||||
|
||||
type ApiContext = {
|
||||
pathReplace?: string;
|
||||
pathReplaceWith?: string;
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
type ApiContext = {};
|
||||
|
||||
type BaseEndpointArgs = {
|
||||
apiClientProps: {
|
||||
@@ -465,6 +463,8 @@ export const tagListSortMap: TagListSortMap = {
|
||||
},
|
||||
};
|
||||
|
||||
export const SortKeyRandom = 'random';
|
||||
|
||||
export enum AlbumListSort {
|
||||
ALBUM_ARTIST = 'albumArtist',
|
||||
ARTIST = 'artist',
|
||||
@@ -476,7 +476,7 @@ export enum AlbumListSort {
|
||||
ID = 'id',
|
||||
NAME = 'name',
|
||||
PLAY_COUNT = 'playCount',
|
||||
RANDOM = 'random',
|
||||
RANDOM = SortKeyRandom,
|
||||
RATING = 'rating',
|
||||
RECENTLY_ADDED = 'recentlyAdded',
|
||||
RECENTLY_PLAYED = 'recentlyPlayed',
|
||||
@@ -598,7 +598,7 @@ export enum SongListSort {
|
||||
ID = 'id',
|
||||
NAME = 'name',
|
||||
PLAY_COUNT = 'playCount',
|
||||
RANDOM = 'random',
|
||||
RANDOM = SortKeyRandom,
|
||||
RATING = 'rating',
|
||||
RECENTLY_ADDED = 'recentlyAdded',
|
||||
RECENTLY_PLAYED = 'recentlyPlayed',
|
||||
@@ -725,7 +725,7 @@ export enum AlbumArtistListSort {
|
||||
FAVORITED = 'favorited',
|
||||
NAME = 'name',
|
||||
PLAY_COUNT = 'playCount',
|
||||
RANDOM = 'random',
|
||||
RANDOM = SortKeyRandom,
|
||||
RATING = 'rating',
|
||||
RECENTLY_ADDED = 'recentlyAdded',
|
||||
RELEASE_DATE = 'releaseDate',
|
||||
@@ -814,7 +814,7 @@ export enum ArtistListSort {
|
||||
FAVORITED = 'favorited',
|
||||
NAME = 'name',
|
||||
PLAY_COUNT = 'playCount',
|
||||
RANDOM = 'random',
|
||||
RANDOM = SortKeyRandom,
|
||||
RATING = 'rating',
|
||||
RECENTLY_ADDED = 'recentlyAdded',
|
||||
RELEASE_DATE = 'releaseDate',
|
||||
@@ -1363,7 +1363,7 @@ export type ScrobbleArgs = BaseEndpointArgs & {
|
||||
|
||||
export type ScrobbleQuery = {
|
||||
albumId?: string;
|
||||
event?: 'pause' | 'start' | 'unpause';
|
||||
event?: 'pause' | 'start' | 'stop' | 'unpause';
|
||||
id: string;
|
||||
mediaType: 'podcast' | 'song';
|
||||
playbackRate: number;
|
||||
|
||||
@@ -288,6 +288,11 @@ export interface UniqueId {
|
||||
|
||||
export type WebAudio = {
|
||||
context: AudioContext;
|
||||
dsp: null | {
|
||||
compressor: DynamicsCompressorNode;
|
||||
eqFilters: BiquadFilterNode[];
|
||||
preampGain: GainNode;
|
||||
};
|
||||
gains: GainNode[];
|
||||
visualizerInputs?: AudioNode[];
|
||||
};
|
||||
|
||||
@@ -61,6 +61,10 @@ export const keyboardCodeToHotkeyKey = (code: string): null | string => {
|
||||
return code.slice(5);
|
||||
}
|
||||
|
||||
if (/^F([1-9]|1\d|2[0-4])$/.test(code)) {
|
||||
return code.toLowerCase();
|
||||
}
|
||||
|
||||
if (code.startsWith('Numpad')) {
|
||||
const suffix = code.slice(6);
|
||||
const numpadMapped = NUMPAD_CODE_TO_HOTKEY_KEY[suffix];
|
||||
|
||||
Reference in New Issue
Block a user