Compare commits

...

26 Commits

Author SHA1 Message Date
York 0ed68e8ebb feat: add Japanese Furigana support to lyrics display (#2161) 2026-06-21 19:11:20 -07:00
jeffvli 417365f091 fix replaygain volume jump (#1576) 2026-06-21 18:49:43 -07:00
Hosted Weblate ff8a21af08 Translated using Weblate
Currently translated at 100.0% (1247 of 1247 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Translated using Weblate

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

Co-authored-by: rimasx <riks_12@hot.ee>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-06-21 12:01:22 +02:00
jeffvli ff426bda6d add unknown MediaType to Jellyfin playlist fetch (#2063) 2026-06-19 22:08:10 -07:00
jeffvli 0664b0ad02 fix lint 2026-06-19 22:07:42 -07:00
jeffvli 61cc87e0b7 refactor song path replacement
- path replacement during runtime instead of during API normalization
- fix Navidrome API path not appending libraryPath which caused inconsistency between ND and Subsonic paths
2026-06-19 22:02:25 -07:00
Sai Asish Y 36624350f6 feat(artists): preserve artist detail scroll position on back navigation (#2045)
* feat(artists): preserve artist detail scroll position on back navigation

* fix(artists): target OverlayScrollbars viewport child for scroll persistence

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>

---------

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>
2026-06-20 04:55:41 +00:00
Hosted Weblate dbe46e03a4 Translated using Weblate
Currently translated at 64.8% (809 of 1247 strings) (Turkish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/tr/

Translated using Weblate

Currently translated at 100.0% (1247 of 1247 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Translated using Weblate

Currently translated at 100.0% (1247 of 1247 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

Currently translated at 100.0% (1247 of 1247 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Co-authored-by: Berkay Dogan <berkaydog4n@gmail.com>
Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: linger <linger0517@gmail.com>
2026-06-19 23:01:32 +02:00
Bram Johnson 16e00a0f9f feat(playlists): add isMissing and isPresent operators to Navidrome smart playlist form (#2149) 2026-06-18 09:44:58 -07:00
Hosted Weblate 00d9929568 Translated using Weblate
Currently translated at 100.0% (1245 of 1245 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
2026-06-17 11:10:56 +02:00
Ryan Kupka c301b14cb3 fix: recognize function keys F1–F24 when assigning hotkeys (#2157)
keyboardCodeToHotkeyKey had branches for Key*, Digit*, and Numpad* codes
but none for function keys, so KeyboardEvent.code values "F1".."F24"
returned null and the hotkey capture handler silently dropped them. This
made every function key unassignable (not just F13+ such as F21/F22).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:54:39 -07:00
Hosted Weblate 10332fdeaf Translated using Weblate
Currently translated at 16.0% (200 of 1245 strings) (Norwegian Nynorsk)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/nn/

Co-authored-by: johron <johanhrong@icloud.com>
2026-06-16 23:01:22 +02:00
keebsxd 2107d1c928 Added Graphic EQ and Compressor (#1972)
* Adding Graphic Eq and Compressor with presets
2026-06-16 00:38:40 -07:00
jeffvli f7adcb8533 more potential fixes for server lock duplication 2026-06-15 20:49:56 -07:00
Hosted Weblate ba4664e797 Added translation using Weblate (Norwegian Nynorsk)
Co-authored-by: johron <johanhrong@icloud.com>
2026-06-15 22:57:14 +02:00
Hosted Weblate b14eb1c423 Translated using Weblate
Currently translated at 100.0% (1245 of 1245 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Co-authored-by: rimasx <riks_12@hot.ee>
2026-06-15 21:40:26 +02:00
Hosted Weblate 4297d0d5b3 Translated using Weblate
Currently translated at 100.0% (1245 of 1245 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Co-authored-by: rimasx <riks_12@hot.ee>
2026-06-14 23:53:00 +02:00
Hosted Weblate 64615a1701 Translated using Weblate
Currently translated at 100.0% (1245 of 1245 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Translated using Weblate

Currently translated at 100.0% (1245 of 1245 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: rimasx <riks_12@hot.ee>
2026-06-12 22:01:16 +02:00
Kendall Garner 2a0e414d8f fix lint for custom property 2026-06-11 17:47:29 -07:00
Kendall Garner f2c455f23b disable text wrap for combined-title artists 2026-06-11 17:42:05 -07:00
Hosted Weblate 0a0027f245 Translated using Weblate
Currently translated at 100.0% (1245 of 1245 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Co-authored-by: rimasx <riks_12@hot.ee>
2026-06-11 12:48:54 +02:00
jeffvli 4f687c155f relax minReleaseAge to 24 hours 2026-06-10 16:40:52 -07:00
Hosted Weblate f1f415daa8 Translated using Weblate
Currently translated at 100.0% (1245 of 1245 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Translated using Weblate

Currently translated at 100.0% (1245 of 1245 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Co-authored-by: rimasx <riks_12@hot.ee>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-06-10 22:25:59 +02:00
jeffvli 1ee767352a add 1 week minimumReleaseAge for packages 2026-06-10 10:15:46 -07:00
jeffvli 66a123c10d remove pnpm10 compatibility in package.json 2026-06-10 10:15:46 -07:00
Hosted Weblate de0ddfe226 Translated using Weblate
Currently translated at 100.0% (1245 of 1245 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

Currently translated at 100.0% (1245 of 1245 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

Currently translated at 100.0% (1245 of 1245 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Translated using Weblate

Currently translated at 100.0% (1245 of 1245 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: rimasx <riks_12@hot.ee>
Co-authored-by: 為什麼不加空格 <c++23@users.noreply.hosted.weblate.org>
2026-06-10 08:01:28 +00:00
53 changed files with 2155 additions and 639 deletions
Regular → Executable
+1
View File
@@ -1,3 +1,4 @@
#!/usr/bin/env xdg-open
[Desktop Entry]
Name=Feishin
GenericName=Music player
+2 -7
View File
@@ -114,6 +114,8 @@
"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.40.0",
@@ -186,12 +188,5 @@
"vite-plugin-pwa": "^1.3.0"
},
"packageManager": "pnpm@11.5.2",
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"electron-winstaller",
"esbuild"
]
},
"productName": "feishin"
}
+47
View File
@@ -138,6 +138,12 @@ importers:
is-electron:
specifier: ^2.2.2
version: 2.2.2
kuroshiro:
specifier: ^1.2.0
version: 1.2.0
kuroshiro-analyzer-kuromoji:
specifier: ^1.1.0
version: 1.1.0
lodash:
specifier: ^4.18.1
version: 4.18.1
@@ -2392,6 +2398,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
async@2.6.4:
resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
@@ -2926,6 +2935,9 @@ packages:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
doublearray@0.0.2:
resolution: {integrity: sha512-aw55FtZzT6AmiamEj2kvmR6BuFqvYgKZUkfQ7teqVRNqD5UE0rw8IeW/3gieHNKQ5sPuDKlljWEn4bzv5+1bHw==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -3959,6 +3971,16 @@ packages:
known-css-properties@0.37.0:
resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==}
kuromoji@0.1.2:
resolution: {integrity: sha512-V0dUf+C2LpcPEXhoHLMAop/bOht16Dyr+mDiIE39yX3vqau7p80De/koFqpiTcL1zzdZlc3xuHZ8u5gjYRfFaQ==}
kuroshiro-analyzer-kuromoji@1.1.0:
resolution: {integrity: sha512-BSJFhpsQdPwfFLfjKxfLA9iL+/PC6LCR9vgwgb5Jc7jZwk9ilX8SAV6CwhAQZY611tiuhbB52ONYKDO8hgY1bA==}
kuroshiro@1.2.0:
resolution: {integrity: sha512-yBGCK9oDOY3LGZ/KXaN9m7ADcAuSczOR2FoMRYwHLUlis3/o/uxdMVROAjENFO0NQJgALhIdWxI/vIBVrMCk9w==}
engines: {node: '>=6.5.0'}
lazy-val@1.0.5:
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
@@ -5756,6 +5778,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zlibjs@0.3.1:
resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==}
zod-validation-error@4.0.2:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'}
@@ -7948,6 +7973,10 @@ snapshots:
async-function@1.0.0: {}
async@2.6.4:
dependencies:
lodash: 4.18.1
async@3.2.6: {}
asynckit@0.4.0: {}
@@ -8544,6 +8573,8 @@ snapshots:
dotenv@16.6.1: {}
doublearray@0.0.2: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -9829,6 +9860,20 @@ snapshots:
known-css-properties@0.37.0: {}
kuromoji@0.1.2:
dependencies:
async: 2.6.4
doublearray: 0.0.2
zlibjs: 0.3.1
kuroshiro-analyzer-kuromoji@1.1.0:
dependencies:
kuromoji: 0.1.2
kuroshiro@1.2.0:
dependencies:
'@babel/runtime': 7.29.7
lazy-val@1.0.5: {}
lead@4.0.0: {}
@@ -11818,6 +11863,8 @@ snapshots:
yocto-queue@0.1.0: {}
zlibjs@0.3.1: {}
zod-validation-error@4.0.2(zod@3.25.76):
dependencies:
zod: 3.25.76
+7 -7
View File
@@ -1,9 +1,9 @@
allowBuilds:
abstract-socket: true
electron: true
electron-winstaller: true
esbuild: true
abstract-socket: true
electron: true
electron-winstaller: true
esbuild: true
minimumReleaseAge: 1440
overrides:
"xml2js": "0.5.0"
"react-router": "7.14.0"
'xml2js': '0.5.0'
'react-router': '7.14.0'
+19 -17
View File
@@ -265,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",
@@ -549,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",
@@ -570,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í",
@@ -1161,13 +1161,13 @@
},
"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",
@@ -1179,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",
@@ -1194,7 +1194,7 @@
"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",
@@ -1209,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",
@@ -1270,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",
+4
View File
@@ -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",
+3 -1
View File
@@ -1270,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",
+56 -54
View File
@@ -1,7 +1,7 @@
{
"action": {
"addToFavorites": "Lisa üksusesse $t(entity.favorite, {\"count\": 2})",
"addToPlaylist": "Lisa üksusesse $t(entity.playlist, {\"count\": 1})",
"addToFavorites": "Lisa > $t(entity.favorite, {\"count\": 2})",
"addToPlaylist": "Lisa > $t(entity.playlist, {\"count\": 1})",
"addOrRemoveFromSelection": "Lisa või eemalda valikust",
"selectRangeOfItems": "Vali mitu üksust korraga",
"clearQueue": "Tühjenda järjekord",
@@ -54,7 +54,7 @@
"additionalParticipants": "Teised osalejad",
"newVersion": "Uus versioon on paigaldatud ({{version}})",
"viewReleaseNotes": "Kuva väljalaskemärkmed",
"albumGain": "Albumi helitugevus (gain)",
"albumGain": "Albumi võimendus",
"albumPeak": "Albumi tippnivoo",
"areYouSure": "Kas oled kindel?",
"ascending": "Kasvav",
@@ -112,7 +112,7 @@
"menu": "Menüü",
"minimize": "Minimeeri",
"modified": "Muudetud",
"mbid": "MusicBrainz ID",
"mbid": "MusicBrainz tunnus",
"grouping": "Rühmitamine",
"mood": "Meeleolu",
"name": "Nimi",
@@ -156,8 +156,8 @@
"sortOrder": "Järjestus",
"tags": "Sildid",
"title": "Pealkiri",
"trackNumber": "Pala",
"trackGain": "Pala võimendus",
"trackNumber": "Lugu",
"trackGain": "Loo võimendus (gain)",
"trackPeak": "Pala tippväärtus",
"translation": "Tõlge",
"unknown": "Tundmatu",
@@ -206,12 +206,12 @@
"playlistWithCount_one": "{{count}} esitusloend",
"playlistWithCount_other": "{{count}} esitusloendit",
"smartPlaylist": "Nutikas $t(entity.playlist, {\"count\": 1})",
"track_one": "Rada",
"track_other": "Rajad",
"song_one": "Lugu",
"song_other": "Lood",
"trackWithCount_one": "{{count}} rada",
"trackWithCount_other": "{{count}} rada"
"track_one": "Lugu",
"track_other": "Lood",
"song_one": "Laul",
"song_other": "Laulu",
"trackWithCount_one": "{{count}} lugu",
"trackWithCount_other": "{{count}} lugu"
},
"error": {
"apiRouteError": "Päringut ei saanud edastada",
@@ -288,7 +288,7 @@
"releaseDate": "Ilmumiskuupäev",
"releaseYear": "Ilmumisaasta",
"search": "Otsing",
"songCount": "Lugude arv",
"songCount": "Laulude arv",
"explicitStatus": "$t(common.explicitStatus)",
"title": "Pealkiri",
"sortName": "Sortimisnimi",
@@ -314,7 +314,9 @@
"after": "On pärast",
"notInPlaylist": "Ei ole",
"startsWith": "Algab",
"notInTheLast": "Pole viimase"
"notInTheLast": "Pole viimase",
"isMissing": "Puudub",
"isPresent": "On olemas"
},
"form": {
"addServer": {
@@ -346,7 +348,7 @@
"input_skipDuplicates": "Jäta duplikaadid vahele",
"searchOrCreate": "Otsi $t(entity.playlist, {\"count\": 2}) või kirjuta uue loomiseks",
"success": "$t(entity.trackWithCount, {\"count\": {{message}} }) lisati $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "Lisa esitusloendisse $t(entity.playlist, {\"count\": 1})"
"title": "Lisa > $t(entity.playlist, {\"count\": 1})"
},
"createPlaylist": {
"input_description": "$t(common.description)",
@@ -414,9 +416,9 @@
"input_kind_songs": "Lood",
"input_kind": "Juhuvalikud",
"input_limit_albums": "Mitu albumit?",
"input_limit_songs": "Mitu lugu?",
"input_limit_songs": "Mitu laulu?",
"input_genre": "$t(entity.genre, {\"count\": 1})",
"input_limit": "Mitu lugu?",
"input_limit": "Mitu laulu?",
"input_minYear": "Aastast",
"input_maxYear": "Aastani",
"input_played": "Esita filter",
@@ -456,7 +458,7 @@
"title": "$t(entity.albumArtist, {\"count\": 2})"
},
"albumDetail": {
"moreFromArtist": "Veel esitajalt $t(entity.artist, {\"count\": 1})",
"moreFromArtist": "Veel $t(entity.artist, {\"count\": 1}) albumeid",
"moreFromGeneric": "Veel: {{item}}",
"released": "Avaldatud"
},
@@ -529,9 +531,9 @@
"setRating": "$t(action.setRating)",
"playShuffled": "$t(player.shuffle)",
"shareItem": "Jaga üksust",
"goTo": "Mine",
"goToAlbum": "Mine: $t(entity.album, {\"count\": 1})",
"goToAlbumArtist": "Mine: $t(entity.albumArtist, {\"count\": 1})",
"goTo": "Mine >",
"goToAlbum": "Mine > $t(entity.album, {\"count\": 1})",
"goToAlbumArtist": "Mine > $t(entity.albumArtist, {\"count\": 1})",
"showDetails": "Hangi teavet"
},
"fullscreenPlayer": {
@@ -579,7 +581,7 @@
"explore": "Avasta oma kogust",
"genres": "$t(entity.genre, {\"count\": 2})",
"mostPlayed": "Enim esitatud",
"newlyAdded": "Hiljuti lisatud väljaanded",
"newlyAdded": "Hiljuti lisatud albumid",
"recentlyPlayed": "Hiljuti esitatud",
"recentlyReleased": "Hiljuti avaldatud",
"title": "$t(common.home)"
@@ -614,11 +616,11 @@
"controls": "Juhtnupud",
"sidebar": "Külgriba",
"exportImport": "Import/eksport",
"scrobble": "Kraasi",
"scrobble": "Kraasimine",
"audio": "Heli",
"lyrics": "Laulusõnad",
"lyricsDisplay": "Laulusõnade kuvamine",
"transcoding": "Transkoodimine",
"transcoding": "Teisendamine",
"discord": "Discord",
"logger": "Logija",
"playerFilters": "Mängija filtrid",
@@ -663,11 +665,11 @@
"next": "Järgmine",
"play": "Esita",
"playbackFetchCancel": "See võtab veidi aega… tühistamiseks sulge teavitus",
"playbackFetchInProgress": "Lugude laadimine…",
"playbackFetchNoResults": "Ühtegi lugu ei leitud",
"playbackFetchInProgress": "Laulude laadimine…",
"playbackFetchNoResults": "Ühtegi laulu ei leitud",
"playbackSpeed": "Taasesituse kiirus",
"playRandom": "Esita juhuslikult",
"playSimilarSongs": "Esita sarnaseid lugusid",
"playSimilarSongs": "Esita sarnaseid laule",
"previous": "Eelmine",
"queue_clear": "Tühjenda järjekord",
"queue_moveToBottom": "Liiguta valitud lõppu",
@@ -733,7 +735,7 @@
"autoDJ_itemCount": "Üksuste arv",
"autoDJ_itemCount_description": "Järjekorda lisatavate üksuste arv",
"autoDJ_timing": "Ajastus",
"autoDJ_timing_description": "Lugude arv järjekorras enne automaatse DJ käivitumist",
"autoDJ_timing_description": "Laulude arv järjekorras enne automaatse DJ käivitumist",
"autoDJ_mode": "Režiim",
"autoDJ_mode_albums": "Albumid",
"autoDJ_mode_description": "Vali, kas lisada järjekorda üksikud lood või terved albumid",
@@ -767,7 +769,7 @@
"applicationHotkeys": "Rakenduse kiirklahvid",
"artistBackground": "Esitaja taustapilt",
"artistBackground_description": "Lisab esitaja lehtede taustaks esitaja pildi",
"artistBackgroundBlur": "Esitaja taustapildi hägususe määr",
"artistBackgroundBlur": "Esitaja taustapildi hägustuse määr",
"artistBackgroundBlur_description": "Reguleerib esitaja taustapildi hägususe määra",
"customCss_description": "Kohandatud CSS-i sisu. Märkus: atribuudid 'content' ja välised lingid pole lubatud. Sisu eelvaade on kuvatud allpool. Täiendavad väljad, mida sa ei määranud, on lisatud koodi puhastamise tõttu. Töölaud: Feishin loeb ja kirjutab faili 'custom.css' rakenduse seadistuste kaustas ning laadib selle uuesti, kui fail muutub",
"artistConfiguration": "Albumi esitaja lehe seadistamine",
@@ -807,8 +809,8 @@
"releaseChannel": "Uuenduskanal",
"releaseChannel_description": "Vali automaatsete uuenduste jaoks stabiilne, beeta- või alfaversioon (öised versioonid)",
"disableLibraryUpdateOnStartup": "Keela uuenduste otsimine rakenduse käivitumisel",
"discordApplicationId_description": "Rakenduse ID {{discord}} olekuteabe (rich presence) jaoks (vaikimisi {{defaultId}})",
"discordApplicationId": "{{discord}} rakenduse ID",
"discordApplicationId_description": "Rakenduse tunnus {{discord}} olekuteabe (rich presence) jaoks (vaikimisi {{defaultId}})",
"discordApplicationId": "Rakenduse {{discord}} tunnus",
"discordDisplayType_artistname": "Esitaja nimi",
"discordDisplayType_description": "Muudab sinu olekus seda, mida sa parajasti kuulad",
"discordDisplayType_songname": "Loo nimi",
@@ -850,7 +852,7 @@
"externalLinks_description": "Lubab esitaja ja albumi lehtedel näidata väliseid linke (Last.fm, MusicBrainz)",
"externalLinks": "Kuva välised lingid",
"followCurrentSong_description": "Keri esitusjärjekord automaatselt hetkel mängiva looni",
"followCurrentSong": "Jälgi praegust lugu",
"followCurrentSong": "Jälgi praegust laulu",
"followLyric_description": "Keri laulusõnad praegusesse kohta",
"followLyric": "Jälgi laulusõnu",
"font_description": "Määrab rakenduses kasutatava fondi",
@@ -866,7 +868,7 @@
"globalMediaHotkeys_description": "Luba või keela süsteemi meediaklahvide kasutamine esituse juhtimiseks",
"globalMediaHotkeys": "Globaalsed meedia kiirklahvid",
"homeConfiguration_description": "Seadista, milliseid elemente avalehel kuvatakse ja mis järjekorras",
"homeConfiguration": "Avalehe seadistus",
"homeConfiguration": "Avalehe seadistamine",
"homeFeature_description": "Määrab, kas avalehel kuvatakse suurt esitluskarusselli",
"homeFeature": "Avalehe esitluskarussell",
"homeFeatureStyle_description": "Määrab avalehe karusselli stiili",
@@ -884,7 +886,7 @@
"hotkey_listPlayLast": "Lisa järjekorra lõppu",
"hotkey_listPlayNext": "Esita järgmisena",
"hotkey_listPlayNow": "Esita kohe",
"hotkey_listShowPlayingSong": "Kuva esitatav lugu loendis",
"hotkey_listShowPlayingSong": "Kuva esitatav laul loendis",
"hotkey_navigateHome": "Liigu avalehele",
"hotkey_playbackNext": "Järgmine lugu",
"hotkey_playbackPause": "Paus",
@@ -942,7 +944,7 @@
"minimumScrobbleSeconds_description": "Minimaalne kuulamise kestus sekundites, mis peab olema läbitud, enne kui see kraasitakse",
"minimumScrobbleSeconds": "Minimaalne aeg kraasimiseks (sekundit)",
"scrobble_description": "Kraasi esitused meediaserverisse",
"scrobble": "Kraasi",
"scrobble": "Kraasimine",
"mpvExecutablePath_description": "Määrab mpv käivitusfaili asukoha. Kui väli jääb tühjaks, kasutatakse vaikeasukohta",
"mpvExecutablePath": "mpv käivitusfaili asukoht",
"mpvExtraParameters": "mpv lisaparameetrid",
@@ -959,26 +961,26 @@
"neteaseTranslation_description": "Sisselülitamisel otsitakse ja kuvatakse võimaluse korral NetEase'ist pärit tõlgitud laulusõnu",
"neteaseTranslation": "Luba NetEase'i tõlked",
"notify": "Luba lauluteavitused",
"notify_description": "Näita teavitust, kui lugu vahetub",
"notify_description": "Näita teavitust, kui laul vahetub",
"pathReplace": "Failitee asendamine",
"pathReplace_description": "Asenda oma serveri vaikimisi failitee",
"pathReplace_optionRemovePrefix": "Eemalda eesliide",
"pathReplace_optionAddPrefix": "Lisa eesliide",
"passwordStore_description": "Millist paroolihoidlat kasutada. Muuda seda juhul, kui paroolide salvestamisega on probleeme",
"passwordStore": "Paroolihoidla",
"playerFilters": "Filtreeri lugusid järjekorrast",
"playerFilters": "Filtreeri laule järjekorrast",
"playerFilters_description": "Jäta lood järjekorda lisamata järgmiste kriteeriumide põhjal",
"playbackStyle_description": "Vali pleieri esitusstiil",
"playbackStyle_optionCrossFade": "Ristvahetus",
"playbackStyle_optionNormal": "Tavaline",
"playbackStyle": "Taasesituse stiil",
"playButtonBehavior_description": "Määrab esitusnupu vaikimisi käitumise lugude järjekorda lisamisel",
"playButtonBehavior_description": "Määrab esitusnupu vaikimisi käitumise laulude järjekorda lisamisel",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlay": "$t(player.play)",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playButtonBehavior": "Esitusnupu käitumine",
"artistRadioCount_description": "Määrab lugude arvu, mis laetakse esitaja- ja looraadio jaoks",
"artistRadioCount_description": "Määrab laulude arvu, mis laetakse esitaja- ja looraadio jaoks",
"artistRadioCount": "Esitaja-/looraadio lugude arv",
"imageResolution": "Pildi resolutsioon",
"imageResolution_description": "Rakenduses kasutatavate piltide resolutsioon. Väärtuse 0 puhul kasutatakse pildi algset resolutsiooni",
@@ -1032,7 +1034,7 @@
"remotePort": "Kaugjuhtimisserveri port",
"remoteUsername_description": "Määrab kaugjuhtimisserveri kasutajanime. Kui nii kasutajanimi kui ka parool on tühjad, lülitatakse isikutuvastus välja",
"remoteUsername": "Kaugjuhtimisserveri kasutajanimi",
"replayGainClipping_description": "Takista {{ReplayGain}}i põhjustatud heli moonutamine, vähendades automaatselt helitugevust",
"replayGainClipping_description": "Hoia ära {{ReplayGain}}i põhjustatud helimoonutus, vähendades automaatselt helitugevust",
"replayGainClipping": "{{ReplayGain}}i moonutuste vältimine",
"replayGainFallback_description": "Rakendatav võimendus (dB), kui failil puuduvad {{ReplayGain}}i sildid",
"replayGainFallback": "{{ReplayGain}}i varuväärtus",
@@ -1052,18 +1054,18 @@
"showSkipButtons_description": "Kuva või peida esitusribal kerimise nuppe",
"showSkipButtons": "Kuva kerimisnupud",
"sidebarCollapsedNavigation_description": "Kuva või peida navigeerimine ahendatud külgribal",
"sidebarCollapsedNavigation": "Külgriba (ahendatud) navigeerimine",
"sidebarCollapsedNavigation": "Ahendatud külgriba navigeerimisnupud",
"sidebarConfiguration_description": "Vali külgriba elemendid ja nende järjekord",
"sidebarConfiguration": "Külgriba seadistus",
"sidebarConfiguration": "Külgriba seadistamine",
"playerItemConfiguration_description": "Vali täisekraanil kuvatava pleieri elemendid ja nende järjekord",
"playerItemConfiguration": "Pleieri elementide seadistus",
"playerItemConfiguration": "Pleieri elementide seadistamine",
"sidebarPlaylistFolders_description": "Loo kaustavaade esitusloenditele, mille nimes sisaldub seadistatud eraldusmärk",
"sidebarPlaylistFolders": "Luba kaustad",
"sidebarPlaylistFolderSeparator_description": "Tähemärk, mis eraldab esitusloendi nimes kaustatasemeid",
"sidebarPlaylistFolderSeparator": "Kaustade eraldusmärk",
"sidebarPlaylistFolderView_description": "Kuidas kuvada kaustu külgribal",
"sidebarPlaylistFolderView_optionTree": "Puuvaade",
"sidebarPlaylistFolderView_optionNavigation": "Navigatsioonivaade",
"sidebarPlaylistFolderView_optionNavigation": "Navigeerimisvaade",
"sidebarPlaylistFolderTreeIndent_description": "Puuvaate tasemete taane pikslites",
"sidebarPlaylistFolderTreeIndent": "Puuvaate taane",
"sidebarPlaylistFolderTreeLineColor_description": "Puuvaate ühendusjoonte värv (vaiketeema puhul jäta tühjaks)",
@@ -1075,7 +1077,7 @@
"sidebarPlaylistMode_optionCompact": "Kompaktne",
"sidebarPlaylistMode_optionExpanded": "Laiendatud",
"sidebarPlaylistSorting_description": "Lubab esitusloendeid külgribal lohistades käsitsi järjestada, asendades serveri vaikejärjestuse",
"sidebarPlaylistSorting": "Külgriba esitusloendite järjestus",
"sidebarPlaylistSorting": "Külgriba esitusloendite järjestamine",
"sidebarPlaylistListFilterRegex_description": "Peida külgribalt esitusloendid, mis ühtivad selle regulaaravaldisega",
"sidebarPlaylistListFilterRegex_placeholder": "Näiteks ^tänane miks.*",
"sidebarPlaylistListFilterRegex": "Esitusloendite filtri regulaaravaldis",
@@ -1101,12 +1103,12 @@
"themeDark": "Teema (tume)",
"themeLight_description": "Määrab rakenduses kasutatava heleda teema",
"themeLight": "Teema (hele)",
"transcode": "Luba transkoodimine",
"transcode_description": "Võimaldab transkoodimise erinevatesse vormingutesse",
"transcodeBitrate_description": "Valib transkoodimise bitikiiruse. 0 tähendab, et server valib ise",
"transcodeBitrate": "Transkoodimise bitikiirus",
"transcodeFormat_description": "Valib transkoodimise vormingu. Jäta tühjaks, et lasta serveril otsustada",
"transcodeFormat": "Transkoodimise vorming",
"transcode": "Luba teisendamine",
"transcode_description": "Võimaldab teisendamise erinevatesse vormingutesse",
"transcodeBitrate_description": "Valib teisendamise bitikiiruse. 0 tähendab, et server valib ise",
"transcodeBitrate": "Teisendamise bitikiirus",
"transcodeFormat_description": "Valib teisendamise vormingu. Jäta tühjaks, et lasta serveril otsustada",
"transcodeFormat": "Treisendamise vorming",
"translationApiKey_description": "API-võti tõlkimiseks (ainult globaalse teenuse lõpp-punkt)",
"translationApiKey": "Tõlketeenuse API-võti",
"translationApiProvider_description": "Tõlketeenuse API pakkuja",
@@ -1118,7 +1120,7 @@
"useSystemTheme_description": "Järgi süsteemis määratud heledat või tumedat kujundust",
"useSystemTheme": "Kasuta süsteemi teemat",
"volumeWheelStep_description": "Helitugevuse muutmise samm, kui kerida hiirerattaga heliribal",
"volumeWheelStep": "Hiirerattaga muutmise samm",
"volumeWheelStep": "Helitugevuse hiireratta samm",
"volumeWidth_description": "Helitugevuse riba laius",
"volumeWidth": "Heliriba laius",
"waveformLoadingDelay": "Helilaine laadimise viivitus",
@@ -1301,7 +1303,7 @@
"column": {
"album": "Album",
"albumArtist": "Albumi esitaja",
"albumCount": "Albumid",
"albumCount": "Albumit",
"artist": "Esitaja",
"biography": "Biograafia",
"bitDepth": "Bititihedus",
@@ -1339,7 +1341,7 @@
"alignLeft": "Joonda vasakule",
"alignCenter": "Joonda keskele",
"alignRight": "Joonda paremale",
"followCurrentSong": "Jälgi praegust lugu",
"followCurrentSong": "Jälgi praegust laulu",
"displayType": "Kuvamisviis",
"gap": "$t(common.gap)",
"itemGap": "Elementide vahe (px)",
+235
View File
@@ -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"
}
}
+15 -13
View File
@@ -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",
@@ -187,8 +187,8 @@
"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",
@@ -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",
@@ -1270,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",
+210 -34
View File
@@ -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 commitler",
"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"
}
}
+53 -51
View File
@@ -12,7 +12,7 @@
"delete": "刪除",
"descending": "降冪",
"description": "描述",
"forceRestartRequired": "重新啟動應用程式以使更改生效…關閉通知後即可重啟",
"forceRestartRequired": "重啟以套用變更… 關閉通知後即可重啟",
"menu": "選單",
"action_other": "操作",
"add": "新增",
@@ -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": "版本",
@@ -72,7 +72,7 @@
"name": "名稱",
"no": "否",
"none": "無",
"noResultsFromQuery": "查詢到匹配結果",
"noResultsFromQuery": "查詢回傳了無結果",
"note": "注釋",
"additionalParticipants": "額外參與者",
"newVersion": "新版本 ({{version}}) 已被安裝",
@@ -105,8 +105,8 @@
"clean": "清除",
"explicitStatus": "露骨狀態",
"explicit": "露骨",
"gridRows": "網格",
"noFilters": "未設定任何過濾器",
"gridRows": "網格",
"noFilters": "未配置篩選器",
"countSelected": "{{count}} 個已選取",
"retry": "重試",
"example": "範例",
@@ -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": {
@@ -269,7 +269,7 @@
"transcoding": "轉碼",
"discord": "Discord",
"queryBuilder": "查詢建構器",
"playerFilters": "播放過濾器",
"playerFilters": "播放篩選器",
"logger": "日誌記錄器",
"lyricsDisplay": "歌詞顯示"
},
@@ -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,7 +931,7 @@
"bpm": "BPM",
"songCount": "曲目",
"title": "標題",
"trackNumber": "曲目編號",
"trackNumber": "曲目",
"size": "大小",
"codec": "編解碼器",
"owner": "擁有者",
@@ -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": "拒絕",
+37
View File
@@ -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;
}
};
+5
View File
@@ -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);
});
+5
View File
@@ -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
@@ -1903,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
+37 -186
View File
@@ -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
@@ -2388,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: {
@@ -2412,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),
),
};
},
@@ -1,3 +1,6 @@
import { ItemDetailListCellProps } from './types';
export const PathColumn = ({ song }: ItemDetailListCellProps) => song.path ?? <>&nbsp;</>;
import { resolveSongPath } from '/@/renderer/utils/resolve-song-path';
export const PathColumn = ({ song }: ItemDetailListCellProps) =>
resolveSongPath(song.path) ?? <>&nbsp;</>;
@@ -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>
);
}
@@ -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,
});
};
+2 -1
View File
@@ -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>
+11 -1
View File
@@ -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
+2 -1
View File
@@ -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':
@@ -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 20Hz20kHz
// 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 />
+9 -8
View File
@@ -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;
+51
View File
@@ -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,
+1
View File
@@ -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';
+26
View File
@@ -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]);
};
+1 -1
View File
@@ -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',
+3 -10
View File
@@ -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);
}
+2 -4
View File
@@ -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: {
+5
View File
@@ -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];