Compare commits

...

16 Commits

Author SHA1 Message Date
Hosted Weblate 31f12a39b2 Translated using Weblate
Currently translated at 13.8% (178 of 1289 strings) (Thai)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/th/

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

Currently translated at 25.8% (333 of 1289 strings) (Chinese (Simplified Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: KosmoMoustache <kosmomoustache@users.noreply.hosted.weblate.org>
Co-authored-by: Strom.wang <811191336@qq.com>
Co-authored-by: man sun <masrton888@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-07-01 09:01:30 +00:00
Hosted Weblate a6d82374dd Translated using Weblate
Currently translated at 12.8% (166 of 1289 strings) (Thai)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/th/

Co-authored-by: man sun <masrton888@gmail.com>
2026-06-30 08:56:25 +02:00
Kendall Garner 42bd8d34d9 fix(sidebar): only re-expand sidebar when enabled 2026-06-29 21:06:32 -07:00
jeffvli b397790402 additional fix for furigana/romaji lyric handlers (#2188)
- Romaji conversion joined all synced lyric lines into one string. Because the block contained kana somewhere, hasKana passed for the entire array of lyrics.
2026-06-29 20:50:13 -07:00
Hosted Weblate 7231f73ba7 Translated using Weblate
Currently translated at 100.0% (1289 of 1289 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 48.3% (622 of 1287 strings) (Ukrainian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/uk/

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Ondo <SparkyOndo@proton.me>
Co-authored-by: albatrays <weblate.duct925@passmail.net>
Co-authored-by: linger <linger0517@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-06-30 05:29:06 +02:00
jeffvli 37ada07ee2 add "stopped" playback state and event handlers 2026-06-29 20:23:46 -07:00
York a221a84792 fix: romaji duplicate lines for non-Japanese lyrics (#2188) 2026-06-29 19:11:46 -07:00
Norman aa3c9251f5 feat: album group has a config and can set the image size (#2153)
* Created a new album group configuration which includes (for now) an option to set the image size of the album group artwork.
2026-06-29 19:00:20 -07:00
Benjamin 751ec7f835 fix lyric desync due to scroll issues (#2110) 2026-06-29 18:50:58 -07:00
Hosted Weblate 14bad5dbd7 Translated using Weblate
Currently translated at 100.0% (1287 of 1287 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 69.1% (889 of 1285 strings) (Hungarian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/hu/

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Soderes Sanyi <kennex@protonmail.com>
Co-authored-by: York <goog10216922@gmail.com>
2026-06-29 07:21:14 +02:00
BlackHoleFox 94aa34f6b2 Improve Jellyfin playlist loading and modification performance times (#2184)
* Remove unneeded Fields from getPlaylistSongList

* Add optimized controller function for playlist addition duplication checks

* Remove Jellyfin People data handling

* move artist map inline

---------

Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
2026-06-29 05:21:04 +00:00
Kendall Garner da445b815d feat(genre): support sorting by track/album count 2026-06-28 19:39:32 -07:00
Tarulia c875146779 feat: add setting for static window title (#2183) 2026-06-29 01:35:42 +00:00
Kendall Garner 9806d2f553 fix: require all sorts to have default value 2026-06-28 17:36:14 -07:00
Kendall Garner 18a7fd0731 disconnect rpc on discord unmount 2026-06-28 15:31:15 -07:00
Hosted Weblate 062617bb40 Translated using Weblate
Currently translated at 100.0% (1285 of 1285 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Translated using Weblate

Currently translated at 100.0% (1285 of 1285 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% (1285 of 1285 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: York <goog10216922@gmail.com>
2026-06-28 05:01:23 +00:00
55 changed files with 1070 additions and 279 deletions
+5 -1
View File
@@ -1009,7 +1009,11 @@
"compressorReset_description": "Restaura tots els paràmetres del compressor als seus valors per defecte",
"compressorSavePreset_description": "Desa la configuració actual del compressor com un ajust predefinit amb nom",
"compressorThreshold_description": "Nivell de senyal a partir del qual comença la compressió.",
"compressorThreshold": "Llindar"
"compressorThreshold": "Llindar",
"enableRomaji_description": "Mostra la pronunciació en romaji a sota de la lletra en japonès.",
"enableRomaji": "Activa la generació de romaji",
"windowBarTrackinfo": "Títol de la finestra d'informació de la pista",
"windowBarTrackinfo_description": "Mostra el títol i l'artista de la pista actual, la posició a la cua i l'estat de reproducció al títol de la finestra."
},
"table": {
"column": {
+3 -1
View File
@@ -493,7 +493,9 @@
"compressorReset_description": "Obnovit všechny parametry kompresoru na jejich výchozí hodnoty",
"compressorSavePreset_description": "Uložit aktuální nastavení kompresoru jako pojmenovanou předvolbu",
"compressorThreshold_description": "Úroveň signálu, nad kterou začne komprese.",
"compressorThreshold": "Hranice"
"compressorThreshold": "Hranice",
"enableRomaji_description": "Zobrazit rómadži výslovnost pod japonskými texty.",
"enableRomaji": "Povolit generování rómadži"
},
"action": {
"editPlaylist": "Upravit $t(entity.playlist, {\"count\": 1})",
+4
View File
@@ -1175,6 +1175,8 @@
"webAudio": "Use web audio",
"windowBarStyle_description": "Select the style of the window bar",
"windowBarStyle": "Window bar style",
"windowBarTrackinfo": "Track info in Window Title",
"windowBarTrackinfo_description": "Show current track's title and artist, queue position, and Playing/Paused state in the Window's Title.",
"zoom_description": "Sets the zoom percentage for the application",
"zoom": "Zoom percentage",
"queryBuilder": "Query builder",
@@ -1216,6 +1218,8 @@
"config": {
"general": {
"advancedSettings": "Advanced settings",
"albumGroupConfig": "Album Group configuration",
"albumImageSize": "Album image size",
"autoFitColumns": "Auto fit columns",
"autosize": "Autosize",
"moveUp": "Move up",
+8 -2
View File
@@ -493,7 +493,11 @@
"compressorMakeupGain_description": "Ganancia de salida aplicada tras la compresión para recuperar el volumen.",
"compressorMakeupGain": "Ganancia de compensación",
"compressorAttack_description": "La rapidez con la que el compresor entra en acción una vez que la señal supera el umbral.",
"compressorAttack": "Ataque"
"compressorAttack": "Ataque",
"enableRomaji_description": "Muestra una línea de pronunciación en romaji debajo de las letras japonesas.",
"enableRomaji": "Activar generación de romaji",
"windowBarTrackinfo": "Información de la pista en el título de la ventana",
"windowBarTrackinfo_description": "Muestra el título y artista de la pista actual, posición en la cola, y estado reproduciendo/pausado en el título de la ventana."
},
"action": {
"editPlaylist": "Editar $t(entity.playlist, {\"count\": 1})",
@@ -1185,7 +1189,9 @@
"horizontalBorders": "Bordes de fila",
"verticalBorders": "Bordes de columna",
"rowHoverHighlight": "Resaltar al pasar el cursor por la fila",
"showHeader": "Mostrar cabecera"
"showHeader": "Mostrar cabecera",
"albumImageSize": "Tamaño de la imagen del álbum",
"albumGroupConfig": "Configuración del grupo del álbum"
},
"view": {
"table": "Tabla",
+35 -7
View File
@@ -674,9 +674,9 @@
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"replayGainMode_description": "Ajuste le gain du volume selon les valeurs de {{ReplayGain}} enregistrées dans les métadonnées du fichier",
"replayGainFallback": "Valeur de repli de {{ReplayGain}}",
"replayGainClipping_description": "Empêcher la distorsion causée par {{ReplayGain}} en réduisant automatiquement le gain",
"replayGainClipping_description": "Prévient l'écrêtage causé par {{ReplayGain}} en réduisant automatiquement le gain",
"replayGainPreamp": "Préamplificateur (db) de {{ReplayGain}}",
"replayGainClipping": "Distorsion du {{ReplayGain}}",
"replayGainClipping": "Écrêtage du {{ReplayGain}}",
"replayGainMode": "Mode de {{ReplayGain}}",
"replayGainFallback_description": "Gain en dB à appliquer si le fichier n'a pas de tags de {{ReplayGain}}",
"replayGainPreamp_description": "Ajuste le gain de préampli appliqué aux valeurs {{ReplayGain}}",
@@ -684,7 +684,7 @@
"clearCache": "Vider le cache du navigateur",
"buttonSize_description": "La taille des boutons de la barre de lecture",
"clearQueryCache_description": "Un 'nettoyage léger' de Feishin. Cela actualisera les listes de lecture, les métadonnées des titres, et réinitialisera les paroles enregistrées. Les paramètres, identifiants du serveur et images mises en cache seront conservés",
"clearCache_description": "Un 'nettoyage complet' de Feishin. En plus de vider le cache de Feishin, vide le cache du navigateur (images sauvegardées et autres ressources). Les identifiants serveurs et paramètres sont conservés",
"clearCache_description": "Un 'nettoyage complet' de Feishin. En plus de vider le cache de Feishin, vide le cache du navigateur (images enregistrée et autres ressources). Les identifiants serveurs et paramètres sont conservés",
"buttonSize": "Taille des boutons de la barre de lecture",
"clearCacheSuccess": "Cache vidé avec succès",
"externalLinks_description": "Activer l'affichage de liens externes (Last.fm, MusicBrainz) sur les pages d'artiste/album",
@@ -926,7 +926,31 @@
"autoDJ_albumStrategy": "Mode de sélection d'album",
"autoDJ_songStrategy": "Mode de sélection de titre",
"autoDJ_strategy_option_library_random": "Aléatoire",
"autoDJ_strategy_option_similar": "Similaire"
"autoDJ_strategy_option_similar": "Similaire",
"enableFurigana_description": "Afficher les indications de prononciation (furigana) au-dessus des paroles en kanji Japonais.",
"enableFurigana": "Active la génération des furigana",
"enableRomaji_description": "Afficher une ligne de prononciation en romaji sous les paroles Japonaises.",
"enableRomaji": "Active la génération des romaji",
"equalizer_descriptionMpv": "Égaliseur paramétrique via FFmpeg lavfi (MPV)",
"equalizer_descriptionWebAudio": "Égaliseur paramétrique via Web Audio API",
"equalizer": "Égaliseur",
"equalizerBands_description": "Gain par bande. Faire glisser vers le haut/bas ou taper une valeur. Plage : -12 à +12 dB.",
"equalizerBands": "Bandes",
"equalizerPreamp_description": "Gain d'entrée avant les bandes d'égalisation. Réglez-le sur une valeur négative lors de l'amplification des bandes pour éviter l'écrêtage (MPV).",
"equalizerPreamp": "Préampli",
"equalizerPreset_description": "Appliquer une courbe d'EQ personnalisé intégrée ou personnalisée",
"equalizerPreset": "Préréglage",
"equalizerPresetDeletePlaceholder": "Supprimer les personnalisé...",
"equalizerPresetGroupBuiltIn": "Intégré",
"equalizerPresetGroupCustom": "Personnalisé",
"equalizerPresetNamePlaceholder": "Nom de préréglage...",
"equalizerPresetSelectPlaceholder": "Sélectionnée un préréglage",
"equalizerSavePreset_description": "Enregistrer les paramètres d'EQ actuels en tant que préréglage nommé",
"equalizerSavePreset": "Enregistrer le préréglage",
"sidebarPlaylistFolderTreeLineColor_description": "Couleur des lignes de l'arborescence (vide pour le thème par défaut)",
"sidebarPlaylistFolderTreeLineColor": "Couleur de la ligne de l'arboresence",
"windowBarTrackinfo": "Informations de la piste dans le titre de la fenêtre",
"windowBarTrackinfo_description": "Afficher le titre et l'artiste de la piste en cours, sa position dans la file d'attente et son état (lecture/pause) dans le titre de la fenêtre."
},
"form": {
"deletePlaylist": {
@@ -945,7 +969,7 @@
"input_savePassword": "Enregister le mot de passe",
"ignoreSsl": "Ignorer ssl $t(common.restartRequired)",
"ignoreCors": "Ignorer cors $t(common.restartRequired)",
"error_savePassword": "Une erreur sest produite lors de la tentative de sauvegarde du mot de passe",
"error_savePassword": "Une erreur sest produite lors de la tentative d'enregistrement du mot de passe",
"input_preferInstantMix": "Préférer le mix instantané",
"input_preferInstantMixDescription": "Utiliser uniquement le mix instantané pour jouer des titres similaires. utile si vous avez des plugins qui modifient ce comportement",
"input_preferRemoteUrl": "Préférer une URL publique",
@@ -1138,7 +1162,9 @@
"horizontalBorders": "Bordures de ligne",
"rowHoverHighlight": "Surligner les lignes au survol",
"verticalBorders": "Bordure de colonne",
"showHeader": "Affiche l'en-tête"
"showHeader": "Affiche l'en-tête",
"albumGroupConfig": "Configuration du groupe d'albums",
"albumImageSize": "Taille de la pochette d'album"
},
"view": {
"table": "Liste",
@@ -1266,7 +1292,9 @@
"notContains": "Ne contient pas",
"notInPlaylist": "N'est pas dans",
"notInTheLast": "N'est pas dans le dernier",
"startsWith": "Commence par"
"startsWith": "Commence par",
"isMissing": "Est manquant",
"isPresent": "Est présent"
},
"datetime": {
"minuteShort": "M",
+60 -21
View File
@@ -7,7 +7,10 @@
"viewPlaylists": "$t(entity.playlist, {\"count\": 2}) megtekintése",
"openIn": {
"lastfm": "Megnyitás Last.fm-ben",
"musicbrainz": "Megnyitás MusicBrainz-ben"
"musicbrainz": "Megnyitás MusicBrainz-ben",
"listenbrainz": "Megnyitás ListenBrainz-ben",
"qobuz": "Megnyitás Qobuz_ban",
"spotify": "Megnyitás Spotify-ban"
},
"clearQueue": "Műsorlista kiürítése",
"createPlaylist": "$t(entity.playlist, {\"count\": 1}) létrehozása",
@@ -117,7 +120,7 @@
"none": "Egyik sem",
"restartRequired": "Újraindítás szükséges",
"setting_one": "Beállítás",
"setting_other": "",
"setting_other": "Beállítások",
"translation": "Fordítás",
"rating": "Értékelés",
"reload": "Újratöltés",
@@ -154,7 +157,14 @@
"view": "Nézet",
"noFilters": "Nincs konfigurált szűrő",
"countSelected": "{{count}} kiválasztott",
"retry": "Újra"
"retry": "Újra",
"openFolder": "Mappa megnyitás",
"example": "Példa",
"filter_single": "Egy",
"filter_multiple": "Több",
"mood": "Hangulat",
"numberOfResults": "{{numberOfResults}} eredmény",
"grouping": "Csoportosítás"
},
"entity": {
"albumArtist_one": "Zenész",
@@ -207,8 +217,8 @@
"openError": "A fájl megnyitása sikertelen volt",
"playbackError": "Hiba történt a média lejátszásakor",
"remoteEnableError": "Hiba történt a távoli szerver műveletkor: $t(common.enable)",
"remotePortError": "Hiba történt a távoli szerver PORT-jának beállításakor",
"remotePortWarning": "Indítsd újra a szervert az új PORT használatához",
"remotePortError": "Hiba történt a távoli szerver port-jának beállításakor",
"remotePortWarning": "Indítsd újra a szervert az új port használatához",
"genericError": "Hiba történt",
"endpointNotImplementedError": "A(z) {{endpoint}} végpont nincs implementálva a következőhöz: {{serverType}}",
"badAlbum": "Azért látod ezt az oldalt mert ez a zeneszám nem része egy albumnak. ez általában akkor történik amikor egy szám a zenekönyvtárad gyökerébe kerül. a Jellyfin csak mappákba rendezett számokat csoportosít",
@@ -226,7 +236,10 @@
"noNetworkDescription": "Nem tudok csatlakozni a szerverhez",
"saveQueueFailed": "Műsorlista mentése sikertelen",
"settingsSyncError": "Eltéréseket találtam a leképző és a fő folyamat beállításai között. Indítsd újra az alkalmazást",
"multipleServerSaveQueueError": "A műsorlistában egy vagy több olyan dal található, amely nem az aktuális szerverről származik. Ez nem támogatott"
"multipleServerSaveQueueError": "A műsorlistában egy vagy több olyan dal található, amely nem az aktuális szerverről származik. Ez nem támogatott",
"invalidJson": "Érvénytelen JSON",
"playbackPausedDueToError": "Lejátszás szüneteltetve hiba miatt",
"serverLockSingleServer": "A szerver zárolása esetén csak egy szerver engedélyezett"
},
"filter": {
"albumCount": "$t(entity.album, {\"count\": 2}) darab",
@@ -269,9 +282,11 @@
"trackNumber": "Sáv",
"artist": "$t(entity.artist, {\"count\": 1})",
"bpm": "Bpm",
"channels": "$t(common.channel_other)",
"channels": "$t(common.channel, {\"count\": 2})",
"genre": "$t(entity.genre, {\"count\": 1})",
"explicitStatus": "$t(common.explicitStatus)"
"explicitStatus": "$t(common.explicitStatus)",
"matchAnd": "és",
"matchOr": "vagy"
},
"form": {
"addServer": {
@@ -671,7 +686,7 @@
"customFontPath_description": "Beállítja az alkalmazáshoz használandó egyéni betűtípus elérési útját",
"contextMenu": "Kontextusmenü (jobb klikk) beállítás",
"crossfadeDuration_description": "Beállítja áthúzás effekt időtartamát",
"crossfadeDuration": "Áthúzás Itartam",
"crossfadeDuration": "Áthúzás Itartam",
"crossfadeStyle": "Áthúzás stílus",
"crossfadeStyle_description": "Válaszd ki az audiolejátszóhoz használni kívánt áthúzás stílust",
"releaseChannel_description": "Válassz a stabil kiadás vagy a béta kiadás közül az automatikus frissítésekhez",
@@ -852,7 +867,7 @@
"sidebarPlaylistList_description": "Lejátszási lista megjelenítése vagy elrejtése az oldalsávban",
"sidebarPlaylistList": "Oldalsáv lejátszási lista",
"sidePlayQueueStyle_description": "Beállítja az oldalsó műsorlista stílusát",
"mediaSession_description": "Lehetővé teszi a Windows Media Session integrációját, a médiavezérlők és metaadatok megjelenítését a rendszer hangerő-átfedésben és a zárolási képernyőn (csak Windows)",
"mediaSession_description": "Bekapcsolja a media session integrációját, a médiavezérlők és metaadatok megjelenítését a rendszer hangerő-átfedésben és a zárolási képernyőn. (Web audiolejátszó szükséges.)",
"mediaSession": "Média munkamenet engedélyezése",
"sidePlayQueueStyle": "Oldalsó műsorlista stílus",
"skipDuration": "Átugrás hossza",
@@ -918,7 +933,7 @@
"autoDJ": "Auto DJ",
"autoDJ_timing": "Időzítés",
"autoDJ_itemCount": "Elem szám",
"autoDJ_itemCount_description": "Az auto DJ engedélyezésekor a műsorsorba felvenni kívánt elemek száma",
"autoDJ_itemCount_description": "A műsorsorba felvenni kívánt elemek száma",
"autoDJ_timing_description": "Az auto DJ elindulása előtt a műsorlistában maradt dalok száma",
"followCurrentSong_description": "Automatikusan görgesse a műsorlistát az aktuálisan lejátszott dalra",
"followCurrentSong": "Kövesd az aktuális dalt",
@@ -959,13 +974,13 @@
"biography": "$t(common.biography)",
"bitrate": "$t(common.bitrate)",
"bpm": "$t(common.bpm)",
"channels": "$t(common.channel_other)",
"channels": "$t(common.channel, {\"count\": 2})",
"codec": "$t(common.codec)",
"dateAdded": "Hozzáadva",
"discNumber": "Lemezszám",
"duration": "$t(common.duration)",
"favorite": "$t(common.favorite)",
"actions": "$t(common.action_other)",
"actions": "$t(common.action, {\"count\": 2})",
"album": "$t(entity.album, {\"count\": 1})",
"albumCount": "$t(entity.album, {\"count\": 2})",
"genreBadge": "$t(entity.genre, {\"count\": 1}) (jelvények)",
@@ -1011,33 +1026,33 @@
}
},
"column": {
"albumCount": "$t(entity.album, {\"count\": 2})",
"artist": "$t(entity.artist, {\"count\": 1})",
"albumCount": "Albumok",
"artist": "Előadó",
"biography": "Életrajz",
"bitrate": "Bitráta",
"bpm": "BPM",
"channels": "$t(common.channel_other)",
"channels": "Csatornák",
"codec": "$t(common.codec)",
"comment": "Komment",
"dateAdded": "Hozzáadva",
"discNumber": "Lemez",
"favorite": "Kedvenc",
"genre": "$t(entity.genre, {\"count\": 1})",
"genre": "Műfaj",
"lastPlayed": "Utoljára játszott",
"path": "Elérési út",
"playCount": "Lejátszások",
"rating": "Értékelés",
"releaseDate": "Megjelenés",
"releaseYear": "Év",
"size": "$t(common.size)",
"songCount": "$t(entity.track, {\"count\": 2})",
"size": "Méret",
"songCount": "Sávok",
"title": "Cím",
"trackNumber": "Sáv",
"album": "Album",
"albumArtist": "Album előadó",
"owner": "Tulajdonos",
"bitDepth": "$t(common.bitDepth)",
"sampleRate": "$t(common.sampleRate)"
"bitDepth": "Bitmélység",
"sampleRate": "Mintavételi frekvencia"
}
},
"queryBuilder": {
@@ -1070,5 +1085,29 @@
"secondShort": "Mp",
"hourShort": "Óra",
"dayShort": "Nap"
},
"visualizer": {
"options": {
"weightingFilter": {
"a": "A",
"b": "B",
"c": "C",
"d": "D",
"z": "Z",
"none": "Nincs"
},
"mode": {
"1": "[1] 1/24 oktáv / 240 sáv",
"2": "[2] 1/12 oktáv / 120 sáv",
"3": "[3] 1/8 oktáv / 80 sáv",
"4": "[4] 1/6 oktáv / 60 sáv",
"5": "[5] 1/4 oktáv / 40 sáv",
"6": "[6] 1/3 oktáv / 30 sáv",
"7": "[7] Fél oktáv / 20 sáv",
"8": "[8] Teljes oktáv / 10 sáv",
"10": "[10] Vonal / Területdiagram"
}
},
"showFPS": "Mutat FPS"
}
}
+8 -2
View File
@@ -1144,7 +1144,11 @@
"compressorReset_description": "Przywróć wszystkie parametry kompresora do wartości domyślnych",
"compressorSavePreset_description": "Zapisz aktualne ustawienia kompresora jako nazwany zestaw ustawień wstępnych",
"compressorThreshold_description": "Poziom sygnału nad którym rozpoczyna się kompresja.",
"compressorThreshold": "Próg"
"compressorThreshold": "Próg",
"enableRomaji_description": "Wyświetlaj linijkę z wymową romaji pod japońskim tekstem.",
"enableRomaji": "Włącz generowanie romaji",
"windowBarTrackinfo": "Informacje o utworze w tytule okna",
"windowBarTrackinfo_description": "Wyświetlaj tytuł, wykonawcę, pozycję w kolejce i stan Odtwarzania/Wstrzymania w tytule okna."
},
"table": {
"config": {
@@ -1184,7 +1188,9 @@
"horizontalBorders": "Obwódki wierszy",
"rowHoverHighlight": "Podświetlanie wierszy po najechaniu",
"verticalBorders": "Obwódki kolumn",
"showHeader": "Pokaż nagłówek"
"showHeader": "Pokaż nagłówek",
"albumImageSize": "Rozmiar obrazów albumów",
"albumGroupConfig": "Konfiguracja grupy albumów"
},
"label": {
"releaseDate": "Data premiery",
+188 -1
View File
@@ -1 +1,188 @@
{}
{
"action": {
"selectRangeOfItems": "เลือกช่วงของรายการ",
"addToFavorites": "เพิ่มลงใน $t(entity.favorite, {\"count\": 2})",
"addToPlaylist": "เพิ่มลงใน $t(entity.playlist, {\"count\": 1})",
"addOrRemoveFromSelection": "เพิ่มหรือเอาออกจากรายการที่เลือก",
"clearQueue": "ล้างคิว",
"goToCurrent": "ไปยังรายการปัจจุบัน",
"collapseAllFolders": "ยุบโฟลเดอร์ทั้งหมด",
"expandAllFolders": "ขยายโฟลเดอร์ทั้งหมด",
"createPlaylist": "สร้าง $t(entity.playlist, {\"count\": 1})",
"createRadioStation": "สร้าง $t(entity.radioStation, {\"count\": 1})",
"deletePlaylist": "ลบ $t(entity.playlist, {\"count\": 1})",
"deleteRadioStation": "ลบ $t(entity.radioStation, {\"count\": 1})",
"selectAll": "เลือกทั้งหมด",
"deselectAll": "ยกเลิกการเลือกทั้งหมด",
"downloadStarted": "เริ่มดาวน์โหลด {{count}} รายการ",
"editPlaylist": "แก้ไข $t(entity.playlist, {\"count\": 1})",
"goToPage": "ไปที่หน้า",
"moveToNext": "ย้ายไปถัดไป",
"moveToBottom": "ย้ายไปล่างสุด",
"moveToTop": "ย้ายไปบนสุด",
"moveUp": "เลื่อนขึ้น",
"moveDown": "เลื่อนลง",
"holdToMoveToTop": "กดค้างเพื่อย้ายไปบนสุด",
"holdToMoveToBottom": "กดค้างเพื่อย้ายไปล่างสุด",
"moveItems": "ย้ายรายการ",
"shuffle": "สุ่ม",
"shuffleAll": "สุ่มทั้งหมด",
"shuffleSelected": "สุ่มรายการที่เลือก",
"removeFromFavorites": "ลบออกจาก $t(entity.favorite, {\"count\": 2})",
"removeFromPlaylist": "ลบออกจาก $t(entity.playlist, {\"count\": 1})",
"removeFromQueue": "ลบออกจากคิว",
"setRating": "ให้คะแนน",
"toggleSmartPlaylistEditor": "เปิด/ปิดตัวแก้ไข $t(entity.smartPlaylist)",
"viewPlaylists": "ดู $t(entity.playlist, {\"count\": 2})",
"viewMore": "ดูเพิ่มเติม",
"openApplicationDirectory": "เปิดโฟลเดอร์โปรแกรม",
"openIn": {
"lastfm": "เปิดใน Last.fm",
"listenbrainz": "เปิดใน ListenBrainz",
"musicbrainz": "เปิดใน MusicBrainz",
"qobuz": "เปิดใน Qobuz",
"spotify": "เปิดใน Spotify"
}
},
"common": {
"countSelected": "เลือกแล้ว {{count}} รายการ",
"explicitStatus": "สถานะเนื้อหาชัดเจน",
"action_other": "การดำเนินการ",
"add": "เพิ่ม",
"additionalParticipants": "ผู้เข้าร่วมเพิ่มเติม",
"newVersion": "ติดตั้งเวอร์ชันใหม่แล้ว {{version}}",
"viewReleaseNotes": "ดูบันทึกการเปลี่ยนแปลง",
"albumGain": "ระดับความดังของอัลบั้ม",
"albumPeak": "ระดับเสียงสูงสุดของอัลบั้ม",
"areYouSure": "คุณแน่ใจหรือไม่?",
"ascending": "เรียงจากน้อยไปมาก",
"back": "ย้อนกลับ",
"backward": "ย้อนกลับ",
"biography": "ประวัติ",
"bitDepth": "ความลึกบิต",
"bitrate": "บิตเรต",
"bpm": "จังหวะต่อนาที",
"cancel": "ยกเลิก",
"center": "กึ่งกลาง",
"channel_other": "ช่อง",
"clear": "ล้าง",
"close": "ปิด",
"codec": "ตัวแปลงสัญญาณ",
"collapse": "ยุบ",
"comingSoon": "เร็ว ๆ นี้…",
"configure": "กำหนดค่า",
"confirm": "ยืนยัน",
"create": "สร้าง",
"currentSong": "$t(entity.track, {\"count\": 1}) ปัจจุบัน",
"decrease": "ลด",
"delete": "ลบ",
"descending": "เรียงจากมากไปน้อย",
"description": "คำอธิบาย",
"disable": "ปิดใช้งาน",
"disc": "แผ่น",
"dismiss": "ปิด",
"doNotShowAgain": "ไม่ต้องแสดงอีก",
"duration": "ระยะเวลา",
"view": "ดู",
"edit": "แก้ไข",
"enable": "เปิดใช้งาน",
"expand": "ขยาย",
"example": "ตัวอย่าง",
"externalLinks": "ลิงก์ภายนอก",
"openFolder": "เปิดโฟลเดอร์",
"faster": "เร็วขึ้น",
"favorite": "รายการโปรด",
"filter_other": "ตัวกรอง",
"filters": "ตัวกรอง",
"filter_single": "เดี่ยว",
"filter_multiple": "หลายรายการ",
"forceRestartRequired": "รีสตาร์ตเพื่อใช้การเปลี่ยนแปลง... ปิดการแจ้งเตือนนี้เพื่อรีสตาร์ต",
"forward": "ไปข้างหน้า",
"gap": "ระยะห่าง",
"home": "หน้าแรก",
"increase": "เพิ่ม",
"left": "ซ้าย",
"limit": "จำกัด",
"manage": "จัดการ",
"maximize": "ขยายใหญ่สุด",
"menu": "เมนู",
"minimize": "ย่อหน้าต่าง",
"modified": "แก้ไขล่าสุด",
"grouping": "การจัดกลุ่ม",
"mood": "อารมณ์",
"name": "ชื่อ",
"no": "ไม่",
"none": "ไม่มี",
"noResultsFromQuery": "ไม่พบผลลัพธ์ที่ตรงกับการค้นหา",
"numberOfResults": "พบ {{numberOfResults}} รายการ",
"noFilters": "ยังไม่ได้ตั้งค่าตัวกรอง",
"note": "หมายเหตุ",
"ok": "ตกลง",
"owner": "เจ้าของ",
"path": "เส้นทาง",
"playerMustBePaused": "ต้องหยุดการเล่นก่อน",
"preview": "แสดงตัวอย่าง",
"previousSong": "$t(entity.track, {\"count\": 1}) ก่อนหน้า",
"private": "ส่วนตัว",
"public": "สาธารณะ",
"quit": "ออก",
"random": "สุ่ม",
"rating": "คะแนน",
"retry": "ลองใหม่",
"recordLabel": "ค่ายเพลง",
"releaseType": "ประเภทการเผยแพร่",
"refresh": "รีเฟรชข้อมูล",
"reload": "โหลดใหม่",
"rename": "เปลี่ยนชื่อ",
"reset": "รีเซ็ต",
"resetToDefault": "รีเซ็ตเป็นค่าเริ่มต้น",
"restartRequired": "จำเป็นต้องเริ่มโปรแกรมใหม่",
"right": "ขวา",
"sampleRate": "อัตราการสุ่มตัวอย่าง",
"save": "บันทึก",
"saveAndReplace": "บันทึกและแทนที่",
"saveAs": "บันทึกเป็น...",
"search": "ค้นหา",
"setting_other": "การตั้งค่า",
"slower": "ช้าลง",
"share": "แบ่งปัน",
"size": "ขนาด",
"sort": "เรียงลำดับ",
"sortOrder": "ลำดับ",
"tags": "แท็ก",
"title": "ชื่อเรื่อง",
"trackNumber": "แทร็ก",
"trackGain": "ระดับขยายเสียงแทร็ก",
"trackPeak": "ระดับเสียงสูงสุดของแทร็ก",
"translation": "การแปล",
"unknown": "ไม่ทราบ",
"version": "เวอร์ชัน",
"year": "ปี",
"yes": "ใช่",
"explicit": "เนื้อหาชัดแจ้ง",
"clean": "เวอร์ชันสะอาด",
"gridRows": "แถวของตาราง",
"tableColumns": "คอลัมน์ของตาราง",
"itemsMore": "เพิ่มอีก {{count}}",
"newVersionAvailable": "มีเวอร์ชันใหม่ให้ใช้งาน"
},
"entity": {
"album_other": "อัลบั้ม",
"albumArtist_other": "ศิลปินอัลบั้ม",
"albumArtistCount_other": "{{count}} ศิลปินอัลบั้ม",
"albumWithCount_other": "{{count}} อัลบั้ม",
"radioStation_other": "สถานีวิทยุ",
"radioStationWithCount_other": "{{count}} สถานีวิทยุ",
"artist_other": "ศิลปิน",
"artistWithCount_other": "{{count}} ศิลปิน",
"favorite_other": "รายการโปรด",
"folder_other": "โฟลเดอร์",
"folderWithCount_other": "{{count}} โฟลเดอร์",
"genre_other": "แนวเพลง",
"genreWithCount_other": "{{count}} แนวเพลง",
"playlist_other": "เพลย์ลิสต์",
"play_other": "{{count}} เล่น",
"playlistWithCount_other": "{{count}} เพลย์ลิสต์",
"smartPlaylist": "$t(entity.playlist, {\"count\": 1})อัจฉริยะ"
}
}
+11 -8
View File
@@ -167,7 +167,7 @@
"version": "Версія",
"year": "Рік",
"yes": "Так",
"explicit": "Експліцитний зміст",
"explicit": "Відвертий зміст",
"gridRows": "Рядки сітки",
"tableColumns": "Стовпці таблиці",
"itemsMore": "{{count}} більше",
@@ -180,9 +180,9 @@
"album_one": "Альбом",
"album_few": "альбоми",
"album_many": "альбомів",
"albumArtist_one": "Виконавець альбому",
"albumArtist_few": "виконавці альбому",
"albumArtist_many": "виконавців альбому",
"albumArtist_one": "Виконавець Альбому",
"albumArtist_few": "Виконавці Альбому",
"albumArtist_many": "Виконавці Альбому",
"albumArtistCount_one": "{{count}} виконавець альбому",
"albumArtistCount_few": "{{count}} виконавці альбому",
"albumArtistCount_many": "{{count}} виконавців альбому",
@@ -190,8 +190,8 @@
"albumWithCount_few": "{{count}} альбоми",
"albumWithCount_many": "{{count}} альбомів",
"radioStation_one": "Радіостанція",
"radioStation_few": "радіостанції",
"radioStation_many": "радіостанцій",
"radioStation_few": "Радіостанції",
"radioStation_many": "Радіостанцій",
"radioStationWithCount_one": "{{count}} радіостанція",
"radioStationWithCount_few": "{{count}} радіостанції",
"radioStationWithCount_many": "{{count}} радіостанцій",
@@ -267,7 +267,8 @@
"systemFontError": "Сталася помилка під час спроби отримати системні шрифти",
"settingsSyncError": "Виявлено розбіжності між налаштуваннями в рендерері та основним процесом. перезапустіть програму, щоб застосувати зміни",
"invalidJson": "Недійсний JSON",
"playbackPausedDueToError": "Відтворення було призупинено через помилку"
"playbackPausedDueToError": "Відтворення було призупинено через помилку",
"serverLockSingleServer": "Коли сервер заблоковано можна використовувати тільки один сервер"
},
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
@@ -778,6 +779,8 @@
"accentColor": "Акцентний колір",
"useThemeAccentColor": "Використовувати акцентний колір теми",
"useThemeAccentColor_description": "Використовувати основний колір визначений у обраній темі замість користувацького акцентного коліру",
"useThemePrimaryShade": "Використовувати основний відтінок теми"
"useThemePrimaryShade": "Використовувати основний відтінок теми",
"useThemePrimaryShade_description": "Використовувати основний відтінок, визначений у обраній темі, для основних варіантів кольорів",
"primaryShade": "Основний відтінок"
}
}
+21 -7
View File
@@ -41,7 +41,9 @@
"createRadioStation": "创建$t(entity.radioStation, {\"count\": 1})",
"deleteRadioStation": "删除$t(entity.radioStation, {\"count\": 1})",
"openApplicationDirectory": "打开应用程序目录",
"goToCurrent": "转到当前项目"
"goToCurrent": "转到当前项目",
"collapseAllFolders": "折叠所有文件夹",
"expandAllFolders": "展开所有文件夹"
},
"common": {
"increase": "增高",
@@ -162,7 +164,9 @@
"filter_multiple": "多项",
"newVersionAvailable": "新版本现已可用",
"numberOfResults": "{{numberOfResults}} 结果",
"grouping": "分组"
"grouping": "分组",
"back": "返回",
"openFolder": "打开文件夹"
},
"entity": {
"albumArtist_other": "专辑艺术家",
@@ -611,7 +615,9 @@
"sidePlayQueueLayout_optionHorizontal": "水平",
"sidePlayQueueLayout_optionVertical": "垂直",
"waveformLoadingDelay": "波形加载延迟",
"waveformLoadingDelay_description": "加载波形前的延迟时间(秒)。如果在使用网页播放器时遇到卡顿现象,请增加此值。"
"waveformLoadingDelay_description": "加载波形前的延迟时间(秒)。如果在使用网页播放器时遇到卡顿现象,请增加此值。",
"autoDJ_strategy_option_library_random": "随机",
"autoDJ_strategy_option_similar": "相似"
},
"error": {
"remotePortWarning": "重启服务器使新端口生效",
@@ -729,7 +735,8 @@
"followCurrentLyric": "跟随当前歌词",
"dynamicImageBlur": "图像模糊大小",
"dynamicIsImage": "启用背景图像",
"lyricOffset": "歌词延迟补偿(毫秒)"
"lyricOffset": "歌词延迟补偿(毫秒)",
"lyricOpacityNonActive": "静态歌词不透明度"
},
"lyrics": "歌词",
"related": "相关",
@@ -937,7 +944,8 @@
"input_skipDuplicates": "跳过重复",
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"create": "创建 $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "搜索 $t(entity.playlist, {\"count\": 2}) 或键入以创建一个新的"
"searchOrCreate": "搜索 $t(entity.playlist, {\"count\": 2}) 或键入以创建一个新的",
"noneAdded": "没有音轨被添加到 $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
},
"createPlaylist": {
"title": "创建$t(entity.playlist, {\"count\": 1})",
@@ -1013,7 +1021,12 @@
"input_played_optionUnplayed": "仅未播放的曲目",
"input_played_optionPlayed": "仅已播放的曲目",
"input_limit": "有多少首歌?",
"input_played": "播放筛选器"
"input_played": "播放筛选器",
"input_kind_albums": "专辑",
"input_kind_songs": "歌曲",
"input_kind": "随机播放",
"input_limit_albums": "有多少专辑?",
"input_limit_songs": "有多少歌曲?"
},
"editRadioStation": {
"success": "电台更新成功"
@@ -1175,7 +1188,8 @@
"startsWith": "以…开头",
"inTheRangeDate": "在(日期)范围内",
"notInPlaylist": "不在…中",
"notInTheLast": "不在最后"
"notInTheLast": "不在最后",
"isMissing": "丢失的项目"
},
"datetime": {
"minuteShort": "分",
+8 -2
View File
@@ -864,7 +864,11 @@
"compressorReset_description": "將所有壓縮器參數恢復為預設值",
"compressorSavePreset_description": "將目前壓縮器設定儲存為具名預設",
"compressorThreshold_description": "開始進行壓縮的訊號電平。",
"compressorThreshold": "閥值"
"compressorThreshold": "閥值",
"enableRomaji_description": "在日文歌詞下方顯示羅馬拼音。",
"enableRomaji": "啟用羅馬拼音顯示",
"windowBarTrackinfo": "在視窗標題列顯示曲目資訊",
"windowBarTrackinfo_description": "在視窗標題列中顯示目前播放曲目的標題與藝人、播放佇列中的位置,以及播放/暫停狀態。"
},
"table": {
"config": {
@@ -898,7 +902,9 @@
"horizontalBorders": "行邊框線",
"rowHoverHighlight": "滑鼠懸停Highlight",
"verticalBorders": "列邊框線",
"showHeader": "顯示標題"
"showHeader": "顯示標題",
"albumGroupConfig": "專輯群組設定",
"albumImageSize": "專輯圖片大小"
},
"label": {
"actions": "$t(common.action, {\"count\": 2})",
+2 -2
View File
@@ -42,13 +42,13 @@ export const convertFurigana = async (text: string): Promise<string> => {
export const convertRomaji = async (text: string): Promise<string> => {
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
if (!KuroshiroClass.Util.hasKana(text)) return text;
if (!KuroshiroClass.Util.hasKana(text)) return '';
try {
const kuroshiro = await getKuroshiro();
return await kuroshiro.convert(text, { mode: 'spaced', to: 'romaji' });
} catch (e) {
console.error('Romaji conversion error: ', e);
return text;
return '';
}
};
+6 -1
View File
@@ -124,7 +124,12 @@ ipcMain.on('update-volume', (_event, volume) => {
});
ipcMain.on('update-playback', (_event, status: PlayerStatus) => {
mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused';
mprisPlayer.playbackStatus =
status === PlayerStatus.PLAYING
? 'Playing'
: status === PlayerStatus.STOPPED
? 'Stopped'
: 'Paused';
});
const REPEAT_TO_MPRIS: Record<PlayerRepeat, string> = {
+1 -1
View File
@@ -128,7 +128,7 @@ export const RemoteContainer = () => {
onClick={() => {
if (status === PlayerStatus.PLAYING) {
send({ event: 'pause' });
} else if (status === PlayerStatus.PAUSED) {
} else {
send({ event: 'play' });
}
}}
+12
View File
@@ -546,6 +546,18 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
getPlaylistSongIds(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getPlaylistSongIds`);
}
return apiController(
'getPlaylistSongIds',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
getPlaylistSongList(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -197,8 +197,8 @@ const JF_FIELDS = {
'SortName',
'ProviderIds',
],
ALBUM_DETAIL: ['Genres', 'DateCreated', 'ChildCount', 'People', 'Tags', 'ProviderIds'],
ALBUM_LIST: ['People', 'Tags', 'Studios', 'SortName', 'ProviderIds', 'ChildCount'],
ALBUM_DETAIL: ['Genres', 'DateCreated', 'ChildCount', 'Tags', 'ProviderIds'],
ALBUM_LIST: ['Tags', 'Studios', 'SortName', 'ProviderIds', 'ChildCount'],
FOLDER: ['Genres', 'DateCreated', 'MediaSources', 'ParentId'],
GENRE: ['ItemCounts'],
PLAYLIST_DETAIL: [
@@ -210,16 +210,7 @@ const JF_FIELDS = {
'SortName',
],
PLAYLIST_LIST: ['ChildCount', 'Genres', 'DateCreated', 'ParentId', 'Overview'],
SONG: [
'Genres',
'DateCreated',
'MediaSources',
'ParentId',
'People',
'Tags',
'SortName',
'ProviderIds',
],
SONG: ['Genres', 'DateCreated', 'MediaSources', 'ParentId', 'Tags', 'SortName', 'ProviderIds'],
} as const;
export const JellyfinController: InternalControllerEndpoint = {
@@ -1056,6 +1047,35 @@ export const JellyfinController: InternalControllerEndpoint = {
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getPlaylistSongIds: async (args) => {
const { apiClientProps, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const res = await jfApiClient(apiClientProps).getPlaylistSongList({
params: {
id: query.id,
},
query: {
// XXX: No fields are required for only IDs, which saves processing time between
// the Jellyfin server query, network (MBs vs KBs), and in-app parsing.
IncludeItemTypes: 'Audio',
UserId: apiClientProps.server?.userId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get playlist song list IDs');
}
return {
items: res.body.Items.map((item) => item.Id),
startIndex: 0,
totalRecordCount: res.body.TotalRecordCount,
};
},
getPlaylistSongList: async (args) => {
const { apiClientProps, query } = args;
@@ -1068,7 +1088,7 @@ export const JellyfinController: InternalControllerEndpoint = {
id: query.id,
},
query: {
Fields: JF_FIELDS.SONG,
Fields: JF_FIELDS.PLAYLIST_DETAIL,
IncludeItemTypes: 'Audio',
UserId: apiClientProps.server?.userId,
},
@@ -19,7 +19,6 @@ import {
DeleteInternetRadioStationImageResponse,
DeletePlaylistImageArgs,
DeletePlaylistImageResponse,
genreListSortMap,
InternalControllerEndpoint,
playlistListSortMap,
PlaylistSongListArgs,
@@ -596,26 +595,7 @@ export const NavidromeController: InternalControllerEndpoint = {
};
}
const res = await ndApiClient(apiClientProps).getGenreList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: genreListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
library_id: getLibraryId(query.musicFolderId),
name: query.searchTerm,
},
});
if (res.status !== 200) {
throw new Error('Failed to get genre list');
}
return {
items: res.body.data.map((genre) => ndNormalize.genre(genre, apiClientProps.server)),
startIndex: query.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
return SubsonicController.getGenreList(args);
},
getImageRequest: SubsonicController.getImageRequest,
getImageUrl: SubsonicController.getImageUrl,
@@ -683,6 +663,11 @@ export const NavidromeController: InternalControllerEndpoint = {
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getPlaylistSongIds: async (args) =>
NavidromeController.getPlaylistSongList(args).then((result) => ({
...result,
items: result.items.map((song) => song.id),
})),
getPlaylistSongList: async (args: PlaylistSongListArgs): Promise<PlaylistSongListResponse> => {
const { apiClientProps, query } = args;
+3
View File
@@ -338,6 +338,9 @@ export const queryKeys: Record<
return [serverId, 'playlists', 'songList'] as const;
},
songListIds: (serverId: string, id: string) => {
return [serverId, 'playlists', 'songListIds', id] as const;
},
},
radio: {
list: (serverId: string) => [serverId, 'radio', 'list'] as const,
@@ -1090,9 +1090,15 @@ export const SubsonicController: InternalControllerEndpoint = {
}
switch (query.sortBy) {
case GenreListSort.ALBUM_COUNT:
results = orderBy(results, [(v) => v.albumCount], [sortOrder]);
break;
case GenreListSort.NAME:
results = orderBy(results, [(v) => v.value.toLowerCase()], [sortOrder]);
break;
case GenreListSort.SONG_COUNT:
results = orderBy(results, [(v) => v.songCount], [sortOrder]);
break;
default:
break;
}
@@ -1223,6 +1229,11 @@ export const SubsonicController: InternalControllerEndpoint = {
return results.length;
},
getPlaylistSongIds: async (args) =>
SubsonicController.getPlaylistSongList(args).then((result) => ({
...result,
items: result.items.map((song) => song.id),
})),
getPlaylistSongList: async ({ apiClientProps, query }) => {
const res = await ssApiClient(apiClientProps).getPlaylist({
query: {
@@ -10,7 +10,7 @@ import {
LONG_PRESS_PLAY_BEHAVIOR,
PlayTooltip,
} from '/@/renderer/features/shared/components/play-button-group';
import { usePlayButtonBehavior } from '/@/renderer/store';
import { useAlbumGroupImageSize, usePlayButtonBehavior } from '/@/renderer/store';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
@@ -29,12 +29,33 @@ export const AlbumGroupHeader = ({
}: AlbumGroupHeaderProps): ReactElement => {
const [isHovered, setIsHovered] = useState(false);
const playButtonBehavior = usePlayButtonBehavior();
const albumImageSize = useAlbumGroupImageSize();
const rowHeight = {
compact: TableItemSize.COMPACT,
large: TableItemSize.LARGE,
normal: TableItemSize.DEFAULT,
}[size];
const infoHeight = groupRowCount !== undefined ? groupRowCount * rowHeight : undefined;
// The album group spans the combined row height, but when the image is
// enlarged the group's last row is grown so the total reaches the img size.
const infoHeight =
groupRowCount !== undefined
? albumImageSize > 0
? Math.max(albumImageSize, groupRowCount * rowHeight)
: groupRowCount * rowHeight
: undefined;
const imageContainerStyle =
albumImageSize > 0
? {
aspectRatio: 'auto',
height: `${albumImageSize}px`,
paddingBottom: 'var(--theme-spacing-xs)',
paddingTop: 'var(--theme-spacing-xs)',
position: 'relative' as const,
width: `${albumImageSize}px`,
zIndex: 1,
}
: undefined;
return (
<div className={styles.container}>
@@ -42,6 +63,7 @@ export const AlbumGroupHeader = ({
className={styles.imageContainer}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={imageContainerStyle}
>
<ItemImage
className={imageColumnStyles.compactImage}
@@ -64,6 +64,12 @@ export const AlbumGroupColumn = (props: ItemTableListInnerColumn) => {
...(needsBorder
? { borderBottom: '1px solid var(--theme-colors-border)' }
: {}),
// When the cover is enlarged it overflows down from the
// group's first row into these cells; let hover/click pass
// through to reach it.
...((props.albumGroupImageSize ?? 0) > 0
? { pointerEvents: 'none' as const }
: {}),
}}
/>
);
@@ -31,6 +31,11 @@
padding-left: 0;
}
.container.no-vertical-padding {
padding-top: 0;
padding-bottom: 0;
}
.container.center {
align-items: center;
text-align: center;
@@ -57,7 +57,10 @@ import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table
import { TrackNumberColumn } from '/@/renderer/components/item-list/item-table-list/columns/track-number-column';
import { YearColumn } from '/@/renderer/components/item-list/item-table-list/columns/year-column';
import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state';
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import {
TableItemProps,
TableItemSize,
} from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { useItemTableListColumnResizeLive } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { ItemControls, ItemListItem } from '/@/renderer/components/item-list/types';
import { Flex } from '/@/shared/components/flex/flex';
@@ -381,6 +384,36 @@ export const ItemTableListColumn = memo(ItemTableListColumnBase, (prevProps, nex
const NonMutedColumns = [TableColumn.TITLE, TableColumn.TITLE_ARTIST, TableColumn.TITLE_COMBINED];
// Counts how many consecutive rows belong to the same album group as `rowIndex`.
export function getAlbumGroupRowCount(
rowIndex: number,
getRowItem: ((index: number) => unknown) | undefined,
enableHeader: boolean | undefined,
dataLength: number,
): number {
const item = getRowItem?.(rowIndex) as null | undefined | { album?: string };
if (!item?.album) return 1;
const firstDataRow = enableHeader ? 1 : 0;
const maxRow = enableHeader ? dataLength + 1 : dataLength;
let start = rowIndex;
while (start > firstDataRow) {
const prevItem = getRowItem?.(start - 1) as null | undefined | { album?: string };
if (!prevItem || prevItem.album !== item.album) break;
start--;
}
let end = rowIndex;
while (end + 1 < maxRow) {
const nextItem = getRowItem?.(end + 1) as null | undefined | { album?: string };
if (!nextItem || nextItem.album !== item.album) break;
end++;
}
return end - start + 1;
}
export function isAlbumGroupingActive(columns: { id: string; isEnabled?: boolean }[]): boolean {
return columns.some((col) => col.id === TableColumn.ALBUM_GROUP && col.isEnabled);
}
@@ -402,6 +435,106 @@ export function isLastInAlbumGroup(
return !nextItem || nextItem.album !== item.album;
}
function baseRowHeightForSize(size: ItemTableListColumn['size']): number {
if (size === 'compact') return TableItemSize.COMPACT;
if (size === 'large') return TableItemSize.LARGE;
return TableItemSize.DEFAULT;
}
// Wraps a clamped cell with the spacer that fills the reserved (grown) height
// below it. The spacer carries the group's bottom/right borders so they align
// across all columns.
function ClampedCell({
cell,
clampHeight,
outerStyle,
showHorizontalBorder,
showVerticalBorder,
}: {
cell: ReactElement;
clampHeight: null | number;
outerStyle?: CSSProperties;
showHorizontalBorder: boolean;
showVerticalBorder: boolean;
}): ReactElement {
const grownHeight = typeof outerStyle?.height === 'number' ? outerStyle.height : 0;
const spacerHeight = clampHeight !== null ? grownHeight - clampHeight : 0;
if (clampHeight === null || spacerHeight <= 0) return cell;
return (
<div style={outerStyle}>
{cell}
<div
aria-hidden
style={{
borderBottom: showHorizontalBorder
? '1px solid var(--theme-colors-border)'
: undefined,
borderRight: showVerticalBorder
? '1px solid var(--theme-colors-border)'
: undefined,
height: spacerHeight,
}}
/>
</div>
);
}
// When an enlarged album image extends past the album group's combined row
// height, the last row of the group is grown (in getRowHeight) to reserve the
// leftover space. This returns the standard (un-grown) height to clamp that
// row's non-album cells to, so the track content + hover/selection stay at
// standard height and the reserved space below is left empty (uniform
// background) for the overflowing album image.
function getAlbumGroupClampHeight(props: ItemTableListInnerColumn): null | number {
const albumImageSize = props.albumGroupImageSize ?? 0;
if (albumImageSize <= 0) return null;
if (props.type === TableColumn.ALBUM_GROUP) return null;
if (!isAlbumGroupingActive(props.columns)) return null;
const isDataRow = props.enableHeader ? props.rowIndex > 0 : true;
if (!isDataRow) return null;
const item = props.getRowItem?.(props.rowIndex) as null | undefined | { album?: string };
if (!item?.album) return null;
if (
!isLastInAlbumGroup(props.rowIndex, props.getRowItem, props.enableHeader, props.data.length)
) {
return null;
}
const baseHeight = baseRowHeightForSize(props.size);
const groupRowCount = getAlbumGroupRowCount(
props.rowIndex,
props.getRowItem,
props.enableHeader,
props.data.length,
);
// Only clamp when the row was actually grown to fit the image.
if (albumImageSize <= groupRowCount * baseHeight) return null;
return baseHeight;
}
function showHorizontalBorderFor(props: ItemTableListInnerColumn, isLastRow: boolean): boolean {
if (!props.enableHorizontalBorders || !props.enableHeader || props.rowIndex <= 0) {
return false;
}
if (isAlbumGroupingActive(props.columns)) {
return isLastInAlbumGroup(
props.rowIndex,
props.getRowItem,
!!props.enableHeader,
props.data.length,
);
}
return props.rowIndex === 1 || !isLastRow;
}
export const TableColumnTextContainer = (
props: ItemTableListColumn & {
children: React.ReactNode;
@@ -425,6 +558,7 @@ export const TableColumnTextContainer = (
? props.internalState.extractRowId(item)
: undefined;
const isSelected = useItemSelectionState(props.internalState, itemRowId || undefined);
const clampHeight = getAlbumGroupClampHeight(props);
const isDragging = props.isDragging ?? false;
const mergedRef = useMergedRef(containerRef, props.dragRef ?? null);
@@ -507,7 +641,10 @@ export const TableColumnTextContainer = (
}
};
return (
const showHorizontalBorder = showHorizontalBorderFor(props, isLastRow);
const showVerticalBorder = !!props.enableVerticalBorders && !isLastColumn;
const cell = (
<div
className={clsx(styles.container, props.containerClassName, {
[styles.alternateRowEven]:
@@ -529,25 +666,16 @@ export const TableColumnTextContainer = (
[styles.right]: props.columns[props.columnIndex].align === 'end',
[styles.rowHoverHighlightEnabled]: isDataRow && props.enableRowHoverHighlight,
[styles.rowSelected]: isDataRow && isSelected,
[styles.withHorizontalBorder]:
props.enableHorizontalBorders &&
props.enableHeader &&
props.rowIndex > 0 &&
(isAlbumGroupingActive(props.columns)
? isLastInAlbumGroup(
props.rowIndex,
props.getRowItem,
!!props.enableHeader,
props.data.length,
)
: props.rowIndex === 1 || !isLastRow),
[styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn,
// When clamped, the bottom border is drawn on the spacer below
// instead.
[styles.withHorizontalBorder]: showHorizontalBorder && clampHeight === null,
[styles.withVerticalBorder]: showVerticalBorder,
})}
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
onClick={handleClick}
onContextMenu={handleContextMenu}
ref={mergedRef}
style={props.style}
style={clampHeight !== null ? { height: clampHeight } : props.style}
>
<Text
className={clsx(styles.content, props.className, {
@@ -561,6 +689,16 @@ export const TableColumnTextContainer = (
</Text>
</div>
);
return (
<ClampedCell
cell={cell}
clampHeight={clampHeight}
outerStyle={props.style}
showHorizontalBorder={showHorizontalBorder}
showVerticalBorder={showVerticalBorder}
/>
);
};
export const TableColumnContainer = (
@@ -586,6 +724,7 @@ export const TableColumnContainer = (
? props.internalState.extractRowId(item)
: undefined;
const isSelected = useItemSelectionState(props.internalState, itemRowId || undefined);
const clampHeight = getAlbumGroupClampHeight(props);
const isDragging = props.isDragging ?? false;
const mergedRef = useMergedRef(containerRef, props.dragRef ?? null);
@@ -668,7 +807,10 @@ export const TableColumnContainer = (
}
};
return (
const showHorizontalBorder = showHorizontalBorderFor(props, isLastRow);
const showVerticalBorder = !!props.enableVerticalBorders && !isLastColumn;
const cell = (
<div
className={clsx(styles.container, props.className, {
[styles.alternateRowEven]:
@@ -682,6 +824,8 @@ export const TableColumnContainer = (
[styles.large]: props.size === 'large',
[styles.left]: props.columns[props.columnIndex].align === 'start',
[styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),
[styles.noVerticalPadding]:
props.type === TableColumn.ALBUM_GROUP && (props.albumGroupImageSize ?? 0) > 0,
[styles.paddingLg]: props.cellPadding === 'lg',
[styles.paddingMd]: props.cellPadding === 'md',
[styles.paddingSm]: props.cellPadding === 'sm',
@@ -694,29 +838,33 @@ export const TableColumnContainer = (
props.type !== TableColumn.ALBUM_GROUP,
[styles.rowSelected]:
isDataRow && isSelected && props.type !== TableColumn.ALBUM_GROUP,
[styles.withHorizontalBorder]:
props.enableHorizontalBorders &&
props.enableHeader &&
props.rowIndex > 0 &&
(isAlbumGroupingActive(props.columns)
? isLastInAlbumGroup(
props.rowIndex,
props.getRowItem,
!!props.enableHeader,
props.data.length,
)
: props.rowIndex === 1 || !isLastRow),
[styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn,
// When clamped, the bottom border is drawn on the spacer below instead.
[styles.withHorizontalBorder]: showHorizontalBorder && clampHeight === null,
[styles.withVerticalBorder]: showVerticalBorder,
})}
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
onClick={handleClick}
onContextMenu={handleContextMenu}
ref={mergedRef}
style={{ ...props.containerStyle, ...props.style }}
style={
clampHeight !== null
? { ...props.containerStyle, height: clampHeight }
: { ...props.containerStyle, ...props.style }
}
>
{props.children}
</div>
);
return (
<ClampedCell
cell={cell}
clampHeight={clampHeight}
outerStyle={props.style}
showHorizontalBorder={showHorizontalBorder}
showVerticalBorder={showVerticalBorder}
/>
);
};
interface ColumnResizeHandleProps {
@@ -44,7 +44,11 @@ import { useTableKeyboardNavigation } from '/@/renderer/components/item-list/ite
import { useTablePaneSync } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-pane-sync';
import { useTableRowModel } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-row-model';
import { useTableScrollToIndex } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import {
getAlbumGroupRowCount,
isLastInAlbumGroup,
ItemTableListColumn,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import {
ItemTableListColumnResizeLiveProvider,
type ItemTableListConfig,
@@ -66,7 +70,7 @@ import {
ItemTableListColumnConfig,
} from '/@/renderer/components/item-list/types';
import { PlayerContext, usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlayerStore } from '/@/renderer/store';
import { useAlbumGroupImageSize, usePlayerStore } from '/@/renderer/store';
import { animationProps } from '/@/shared/components/animations/animation-props';
import { useFocusWithin } from '/@/shared/hooks/use-focus-within';
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
@@ -215,6 +219,7 @@ const VirtualizedTableGrid = ({
totalRowCount,
}: VirtualizedTableGridProps) => {
const { enableHeader, enableRowHoverHighlight, getRowHeight, groups } = tableConfig;
const albumGroupImageSize = useAlbumGroupImageSize();
const hoverDelegateRef = useRef<HTMLDivElement | null>(null);
useRowInteractionDelegate({
@@ -403,6 +408,7 @@ const VirtualizedTableGrid = ({
const itemProps: TableItemProps = useMemo(
() => ({
albumGroupImageSize,
cellPadding: tableConfig.cellPadding,
columns: tableConfig.columns,
controls: tableConfig.controls,
@@ -427,7 +433,7 @@ const VirtualizedTableGrid = ({
tableId: tableConfig.tableId,
...gridOnlyProps,
}),
[gridOnlyProps, tableConfig],
[albumGroupImageSize, gridOnlyProps, tableConfig],
);
const pinnedLeftGridMinWidthPx = useMemo(() => {
@@ -760,6 +766,7 @@ export interface TableGroupHeader {
export interface TableItemProps {
adjustedRowIndexMap?: Map<number, number>;
albumGroupImageSize?: number;
calculatedColumnWidths?: number[];
cellPadding?: ItemTableListProps['cellPadding'];
columns: ItemTableListColumnConfig[];
@@ -1275,6 +1282,7 @@ const BaseItemTableList = ({
}: ItemTableListProps) => {
const { playlistId: routePlaylistId } = useParams() as { playlistId?: string };
const tableId = useId();
const albumGroupImageSize = useAlbumGroupImageSize();
const baseItemCount = itemCount ?? data.length;
const totalItemCount = enableHeader ? baseItemCount + 1 : baseItemCount;
const [centerContainerWidth, setCenterContainerWidth] = useState(0);
@@ -1434,9 +1442,38 @@ const BaseItemTableList = ({
return headerHeight;
}
// When an album image is enlarged beyond the album group's combined
// row height, grow the group's LAST row to reserve the leftover
// space (so the following album isn't clipped). Other rows keep
// their standard height.
if (
albumGroupImageSize > baseHeight &&
cellProps?.hasAlbumGroupColumn &&
isLastInAlbumGroup(
index,
cellProps.getRowItem,
cellProps.enableHeader,
cellProps.data.length,
)
) {
const item = cellProps.getRowItem?.(index) as null | undefined | { album?: string };
if (item?.album) {
const groupRowCount = getAlbumGroupRowCount(
index,
cellProps.getRowItem,
cellProps.enableHeader,
cellProps.data.length,
);
const lastRowHeight = albumGroupImageSize - (groupRowCount - 1) * baseHeight;
if (lastRowHeight > baseHeight) {
return lastRowHeight;
}
}
}
return baseHeight;
},
[enableHeader, headerHeight, rowHeight, pinnedRowCount, size],
[albumGroupImageSize, enableHeader, headerHeight, rowHeight, pinnedRowCount, size],
);
// Create a wrapper for getRowHeight that doesn't require cellProps (for sticky group rows hook)
+7
View File
@@ -13,6 +13,7 @@ export type EventMap = {
MPV_RELOAD: MpvReloadEventPayload;
PLAYER_PLAY: PlayerPlayEventPayload;
PLAYER_REPEATED: PlayerRepeatedEventPayload;
PLAYER_STOP: PlayerStopEventPayload;
PLAYLIST_MOVE_DOWN: PlaylistMoveEventPayload;
PLAYLIST_MOVE_TO_BOTTOM: PlaylistMoveEventPayload;
PLAYLIST_MOVE_TO_TOP: PlaylistMoveEventPayload;
@@ -54,6 +55,12 @@ export type PlayerRepeatedEventPayload = {
index: number;
};
export type PlayerStopEventPayload = {
id?: string;
index?: number;
reset: boolean;
};
export type PlaylistMoveEventPayload = {
playlistId: string;
sourceIds: string[];
@@ -7,11 +7,14 @@ import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-o
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { setMultipleSearchParams } from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { AlbumArtistListSort } from '/@/shared/types/domain-types';
import { AlbumArtistListSort, ArtistListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export const useAlbumArtistListFilters = () => {
const { sortBy } = useSortByFilter<AlbumArtistListSort>(null, ItemListKey.ALBUM_ARTIST);
const { sortBy } = useSortByFilter<AlbumArtistListSort>(
ArtistListSort.NAME,
ItemListKey.ALBUM_ARTIST,
);
const { sortOrder } = useSortOrderFilter(null, ItemListKey.ALBUM_ARTIST);
@@ -7,7 +7,7 @@ import { ArtistListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export const useArtistListFilters = () => {
const { sortBy } = useSortByFilter<ArtistListSort>(null, ItemListKey.ARTIST);
const { sortBy } = useSortByFilter<ArtistListSort>(ArtistListSort.NAME, ItemListKey.ARTIST);
const { sortOrder } = useSortOrderFilter(null, ItemListKey.ARTIST);
@@ -211,11 +211,11 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
let songsToAdd: string[] = allSongIds;
if (skipDuplicates) {
const queryKey = queryKeys.playlists.songList(serverId, playlistId);
const queryKey = queryKeys.playlists.songListIds(serverId, playlistId);
const playlistSongsRes = await queryClient.fetchQuery({
queryFn: ({ signal }) => {
return api.controller.getPlaylistSongList({
return api.controller.getPlaylistSongIds({
apiClientProps: {
serverId,
signal,
@@ -228,7 +228,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
queryKey,
});
const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id);
const playlistSongIds = playlistSongsRes?.items;
const uniqueSongIds: string[] = [];
for (const songId of allSongIds) {
@@ -81,6 +81,15 @@ export const useDiscordRpc = () => {
privateModeRef.current = privateMode;
}, [privateMode]);
// If the component is unmounted while RPC is enabled, quit RPC
useEffect(() => {
return () => {
if (previousEnabledRef.current) {
discordRpc?.quit();
}
};
}, []);
const setActivity = useCallback(
async (current: ActivityState, trigger: ActivityTrigger) => {
const song = current[0];
@@ -6,7 +6,7 @@ import { GenreListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export const useGenreListFilters = () => {
const { sortBy } = useSortByFilter<GenreListSort>(null, ItemListKey.GENRE);
const { sortBy } = useSortByFilter<GenreListSort>(GenreListSort.NAME, ItemListKey.GENRE);
const { sortOrder } = useSortOrderFilter(null, ItemListKey.GENRE);
@@ -14,13 +14,13 @@ export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, ena
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;
const converted = await Promise.all(
lyrics.map(async ([time, line]) => [
time,
await lyricsApi.convertFurigana(line),
]),
);
return converted as SynchronizedLyricsArray;
}
return lyrics;
},
@@ -38,13 +38,10 @@ export const useRomajiLyrics = (lyrics: LyricsResponse | null | undefined, enabl
if (typeof lyrics === 'string') {
return await lyricsApi.convertRomaji(lyrics);
} else if (Array.isArray(lyrics)) {
const text = lyrics.map(([, line]) => line).join('\n');
const converted = await lyricsApi.convertRomaji(text);
const convertedLines = converted.split('\n');
return lyrics.map(([time], i) => [
time,
convertedLines[i] ?? lyrics[i][1],
]) as SynchronizedLyricsArray;
const converted = await Promise.all(
lyrics.map(async ([time, line]) => [time, await lyricsApi.convertRomaji(line)]),
);
return converted as SynchronizedLyricsArray;
}
return lyrics;
},
@@ -95,6 +95,7 @@ export const SynchronizedLyrics = ({
const scrollTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const programmaticScrollRef = useRef(false);
const programmaticScrollTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);
const getCurrentLyric = (timeInMs: number) => {
const activeLyrics = lyricRef.current;
@@ -178,9 +179,6 @@ export const SynchronizedLyrics = ({
if (followRef.current && !userScrollingRef.current) {
programmaticScrollRef.current = true;
doc?.scroll({ behavior: 'smooth', top: offsetTop });
setTimeout(() => {
programmaticScrollRef.current = false;
}, 600);
}
if (index !== lyricRef.current!.length - 1) {
@@ -287,6 +285,14 @@ export const SynchronizedLyrics = ({
const handleScroll = () => {
// Ignore programmatic scrolls (auto-scroll)
if (programmaticScrollRef.current) {
if (programmaticScrollTimeoutRef.current) {
clearTimeout(programmaticScrollTimeoutRef.current);
}
programmaticScrollTimeoutRef.current = setTimeout(() => {
programmaticScrollRef.current = false;
}, 150);
return;
}
@@ -309,6 +315,10 @@ export const SynchronizedLyrics = ({
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
if (programmaticScrollTimeoutRef.current) {
clearTimeout(programmaticScrollTimeoutRef.current);
}
};
}, []);
@@ -6,7 +6,7 @@ import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queu
import { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { useAppStoreActions } from '/@/renderer/store';
import { useAppStore, useAppStoreActions } from '/@/renderer/store';
import { ItemListKey } from '/@/shared/types/types';
const NowPlayingRoute = () => {
@@ -15,12 +15,16 @@ const NowPlayingRoute = () => {
const tableRef = useRef<ItemListHandle | null>(null);
useEffect(() => {
const wasExpanded = useAppStore.getState().sidebar.rightExpanded;
// On page enter, set rightExpanded to false
setSideBar({ rightExpanded: false });
return () => {
// On page exit, set rightExpanded to true
setSideBar({ rightExpanded: true });
if (wasExpanded) {
// On page exit, set rightExpanded to true if it was previously expanded
setSideBar({ rightExpanded: true });
}
};
}, [setSideBar]);
@@ -212,7 +212,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
if (playerStatus === PlayerStatus.PLAYING) {
mpvPlayer.play();
} else if (playerStatus === PlayerStatus.PAUSED) {
} else {
mpvPlayer.pause();
}
}, [playerStatus]);
@@ -47,6 +47,7 @@ interface PlayerEventsCallbacks {
) => void;
onPlayerSpeed?: (properties: { speed: number }, prev: { speed: number }) => void;
onPlayerStatus?: (properties: { status: PlayerStatus }, prev: { status: PlayerStatus }) => void;
onPlayerStop?: (properties: { id?: string; index?: number; reset: boolean }) => void;
onPlayerVolume?: (properties: { volume: number }, prev: { volume: number }) => void;
onQueueCleared?: () => void;
onQueueRestored?: (properties: { data: Song[]; index: number; position: number }) => void;
@@ -166,6 +167,10 @@ function createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents {
eventEmitter.on('PLAYER_REPEATED', callbacks.onPlayerRepeated);
}
if (callbacks.onPlayerStop) {
eventEmitter.on('PLAYER_STOP', callbacks.onPlayerStop);
}
if (callbacks.onQueueRestored) {
eventEmitter.on('QUEUE_RESTORED', callbacks.onQueueRestored);
}
@@ -193,6 +198,9 @@ function createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents {
if (callbacks.onPlayerRepeated) {
eventEmitter.off('PLAYER_REPEATED', callbacks.onPlayerRepeated);
}
if (callbacks.onPlayerStop) {
eventEmitter.off('PLAYER_STOP', callbacks.onPlayerStop);
}
if (callbacks.onQueueRestored) {
eventEmitter.off('QUEUE_RESTORED', callbacks.onQueueRestored);
}
@@ -69,12 +69,12 @@ export function MpvPlayer() {
}, PLAY_PAUSE_FADE_INTERVAL);
});
if (status === PlayerStatus.PAUSED) {
await promise;
setLocalPlayerStatus(status);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status);
await promise;
} else {
await promise;
setLocalPlayerStatus(status);
}
},
[],
@@ -111,18 +111,18 @@ export function MpvPlayer() {
const status = properties.status;
const volume = usePlayerStore.getState().player.volume;
if (audioFadeOnStatusChange) {
if (status === PlayerStatus.PAUSED) {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);
} else {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, status);
}
} else {
if (status === PlayerStatus.PAUSED) {
playerRef.current?.setVolume(0);
setLocalPlayerStatus(PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
playerRef.current?.setVolume(volume);
setLocalPlayerStatus(PlayerStatus.PLAYING);
} else {
playerRef.current?.setVolume(0);
setLocalPlayerStatus(status);
}
}
},
@@ -60,12 +60,12 @@ export function WaveSurferPlayer() {
}, PLAY_PAUSE_FADE_INTERVAL);
});
if (status === PlayerStatus.PAUSED) {
await promise;
setLocalPlayerStatus(status);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status);
await promise;
} else {
await promise;
setLocalPlayerStatus(status);
}
},
[isTransitioning],
@@ -190,10 +190,10 @@ export function WaveSurferPlayer() {
},
onPlayerStatus: async (properties) => {
const status = properties.status;
if (status === PlayerStatus.PAUSED) {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);
} else {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, status);
}
},
onPlayerVolume: (properties) => {
@@ -89,13 +89,13 @@ export function WebPlayer() {
}, PLAY_PAUSE_FADE_INTERVAL);
});
if (status === PlayerStatus.PAUSED) {
if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status);
await promise;
} else {
await promise;
setLocalPlayerStatus(status);
playerRef.current?.setVolume(startVolume);
} else if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status);
await promise;
}
},
[],
@@ -241,7 +241,7 @@ export function WebPlayer() {
// If mediaAutoNext resulted in a paused state (e.g. end of queue,
// or pauseOnNextSongEnd flag), stop all audio instead of restoring volume.
const currentStatus = usePlayerStoreBase.getState().player.status;
if (currentStatus === PlayerStatus.PAUSED) {
if (currentStatus !== PlayerStatus.PLAYING) {
playerRef.current?.pause();
} else {
playerRef.current?.setVolume(volume);
@@ -260,7 +260,7 @@ export function WebPlayer() {
playerRef.current?.player2()?.ref?.getInternalPlayer().pause();
const currentStatus = usePlayerStoreBase.getState().player.status;
if (currentStatus === PlayerStatus.PAUSED) {
if (currentStatus !== PlayerStatus.PLAYING) {
playerRef.current?.pause();
} else {
playerRef.current?.setVolume(volume);
@@ -313,9 +313,9 @@ export function WebPlayer() {
const status = properties.status;
// Reset crossfade transition if paused during a crossfade transition
// Reset crossfade transition if paused/stopped during a crossfade transition
if (
status === PlayerStatus.PAUSED &&
status !== PlayerStatus.PLAYING &&
isTransitioning &&
transitionType === PlayerStyle.CROSSFADE
) {
@@ -331,18 +331,18 @@ export function WebPlayer() {
}
if (audioFadeOnStatusChange) {
if (status === PlayerStatus.PAUSED) {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);
} else {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, status);
}
} else {
if (status === PlayerStatus.PAUSED) {
playerRef.current?.setVolume(volume);
setLocalPlayerStatus(PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
playerRef.current?.setVolume(volume);
setLocalPlayerStatus(PlayerStatus.PLAYING);
} else {
playerRef.current?.setVolume(volume);
setLocalPlayerStatus(status);
}
}
},
@@ -203,7 +203,7 @@ const CenterPlayButton = ({ disabled }: { disabled?: boolean }) => {
return (
<MainPlayButton
disabled={disabled || currentSongId === undefined}
isPaused={status === PlayerStatus.PAUSED}
isPaused={status !== PlayerStatus.PLAYING}
onClick={mediaTogglePlayPause}
/>
);
@@ -51,7 +51,7 @@ export const MobileFullscreenPlayerControls = memo(
/>
<MainPlayButton
disabled={currentSongId === undefined}
isPaused={status === PlayerStatus.PAUSED}
isPaused={status !== PlayerStatus.PLAYING}
onClick={mediaTogglePlayPause}
style={{
height: '50px',
@@ -213,7 +213,7 @@ export const MobilePlayerbar = () => {
/>
<MainPlayButton
disabled={currentSong?.id === undefined}
isPaused={status === PlayerStatus.PAUSED}
isPaused={status !== PlayerStatus.PLAYING}
onClick={(e) => {
e.stopPropagation();
mediaTogglePlayPause();
@@ -131,6 +131,7 @@ export const useScrobble = () => {
const previousSongRef = useRef<QueueSong | undefined>(undefined);
const previousTimestampRef = useRef<number>(0);
const stopPositionRef = useRef<number>(0);
const stoppedSongIdRef = useRef<string | undefined>(undefined);
const lastProgressEventRef = useRef<number>(0);
const lastSeekEventRef = useRef<number>(0);
const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
@@ -499,6 +500,12 @@ export const useScrobble = () => {
const currentStatus = usePlayerStore.getState().player.status;
// Stop resets seek position; the stop event is reported by handleScrobbleFromStatus.
if (currentStatus === PlayerStatus.STOPPED) {
flushScrobbleDebug();
return;
}
sendScrobble.mutate(
{
apiClientProps: { serverId: currentSong._serverId || '' },
@@ -608,6 +615,71 @@ export const useScrobble = () => {
);
}
// Send start event when resuming the same song that was stopped.
if (
properties.status === PlayerStatus.PLAYING &&
prev.status === PlayerStatus.STOPPED &&
stoppedSongIdRef.current === currentSong._uniqueId
) {
stoppedSongIdRef.current = undefined;
sendScrobble.mutate(
{
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
albumId: currentSong.albumId,
event: 'start',
id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentTimestamp, useTicks),
submission: false,
},
},
{
onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStart, {
category: LogCategory.SCROBBLE,
meta: {
id: currentSong.id,
},
});
},
},
);
}
// Send stop event when status changes to stopped (from an active state)
if (
properties.status === PlayerStatus.STOPPED &&
prev.status !== PlayerStatus.STOPPED
) {
stoppedSongIdRef.current = currentSong._uniqueId;
sendScrobble.mutate(
{
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
albumId: currentSong.albumId,
event: 'stop',
id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentTimestamp, useTicks),
submission: false,
},
},
{
onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStop, {
category: LogCategory.SCROBBLE,
meta: {
id: currentSong.id,
},
});
},
},
);
}
flushScrobbleDebug();
},
[isScrobbleEnabled, isPrivateModeEnabled, flushScrobbleDebug, sendScrobble, playbackRate],
@@ -231,11 +231,11 @@ export const AddToPlaylistContextModal = ({
const uniqueSongIds: string[] = [];
if (values.skipDuplicates) {
const queryKey = queryKeys.playlists.songList(serverId, playlistId);
const queryKey = queryKeys.playlists.songListIds(serverId, playlistId);
const playlistSongsRes = await queryClient.fetchQuery({
queryFn: ({ signal }) => {
return api.controller.getPlaylistSongList({
return api.controller.getPlaylistSongIds({
apiClientProps: {
serverId,
signal,
@@ -248,7 +248,7 @@ export const AddToPlaylistContextModal = ({
queryKey,
});
const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id);
const playlistSongIds = playlistSongsRes?.items;
for (const songId of allSongIds) {
if (!playlistSongIds?.includes(songId)) {
@@ -11,7 +11,10 @@ import { PlaylistListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export const usePlaylistListFilters = () => {
const sortByFilter = useSortByFilter<PlaylistListSort>(null, ItemListKey.PLAYLIST);
const sortByFilter = useSortByFilter<PlaylistListSort>(
PlaylistListSort.NAME,
ItemListKey.PLAYLIST,
);
const sortOrderFilter = useSortOrderFilter(null, ItemListKey.PLAYLIST);
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
@@ -63,6 +63,30 @@ export const WindowSettings = memo(() => {
isHidden: !isElectron(),
title: t('setting.windowBarStyle'),
},
{
control: (
<Switch
aria-label="Toggle track info in Window Bar"
defaultChecked={settings.windowBarTrackinfo}
onChange={(e) => {
if (!e) return;
setSettings({
window: {
windowBarTrackinfo: e.currentTarget.checked,
},
});
}}
/>
),
description: t('setting.windowBarTrackinfo', {
context: 'description',
}),
// tab is hidden entirely right now
// but if it was shown we would want to show this option
// as it also controls the tab title in web
isHidden: false,
title: t('setting.windowBarTrackinfo'),
},
{
control: (
<Switch
@@ -823,18 +823,38 @@ const GENRE_LIST_FILTERS: Partial<
},
],
[ServerType.NAVIDROME]: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumCount'),
value: GenreListSort.ALBUM_COUNT,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name'),
value: GenreListSort.NAME,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.songCount'),
value: GenreListSort.SONG_COUNT,
},
],
[ServerType.SUBSONIC]: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumCount'),
value: GenreListSort.ALBUM_COUNT,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name'),
value: GenreListSort.NAME,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumCount'),
value: GenreListSort.SONG_COUNT,
},
],
};
@@ -29,6 +29,7 @@ import {
} from '/@/renderer/store';
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
import { Badge } from '/@/shared/components/badge/badge';
import { Button } from '/@/shared/components/button/button';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
@@ -41,7 +42,7 @@ import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { useDebouncedState } from '/@/shared/hooks/use-debounced-state';
import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
import { ItemListKey, ListPaginationType } from '/@/shared/types/types';
import { ItemListKey, ListPaginationType, TableColumn } from '/@/shared/types/types';
interface TableConfigProps {
enablePinColumnButtons?: boolean;
@@ -72,10 +73,18 @@ export const TableConfig = ({
const { t } = useTranslation();
const list = useSettingsStore((state) => state.lists[listKey]) as ItemListSettings;
const { setList } = useSettingsStoreActions();
const albumGroupImageSize = useSettingsStore((state) => state.general.albumGroupImageSize);
const imageResTable = useSettingsStore((state) => state.general.imageRes.table);
const { setList, setSettings } = useSettingsStoreActions();
const [albumGroupOpen, setAlbumGroupOpen] = useState(false);
const table = tableKey === 'detail' ? (list?.detail ?? list?.table) : list?.table;
const hasAlbumGroupColumn = useMemo(
() => table.columns.some((column) => column.id === TableColumn.ALBUM_GROUP),
[table.columns],
);
const setTableUpdate = useCallback(
(patch: Partial<DataTableProps>) => {
if (tableKey === 'detail') {
@@ -90,6 +99,73 @@ export const TableConfig = ({
);
const advancedSettings = useMemo(() => {
const albumGroupOptions =
hasAlbumGroupColumn && tableKey === 'main'
? [
{
component: (
<Group justify="flex-end" w="100%">
<Button
onClick={() => setAlbumGroupOpen((prev) => !prev)}
size="compact-md"
variant={albumGroupOpen ? 'subtle' : 'filled'}
>
{t(albumGroupOpen ? 'common.close' : 'common.edit')}
</Button>
</Group>
),
id: 'albumGroupConfig',
label: t('table.config.general.albumGroupConfig'),
},
...(albumGroupOpen
? [
{
component: (
<Group justify="flex-end" w="100%">
<NumberInput
max={2000}
min={0}
onChange={(value) => {
const size = Math.max(
0,
Math.min(
2000,
typeof value === 'number' ? value : 0,
),
);
setSettings({
general: {
albumGroupImageSize: size,
// Source table art must be at least as
// large as the displayed album image.
...(size >= imageResTable
? { imageRes: { table: size } }
: {}),
},
});
}}
rightSection={
<Text isMuted isNoSelect pr="lg" size="sm">
px
</Text>
}
value={albumGroupImageSize}
width={90}
/>
</Group>
),
id: 'albumImageSize',
label: (
<Text pl="md">
{t('table.config.general.albumImageSize')}
</Text>
),
},
]
: []),
]
: [];
const allOptions = [
{
component: (
@@ -238,6 +314,7 @@ export const TableConfig = ({
id: 'autoFitColumns',
label: t('table.config.general.autoFitColumns'),
},
...albumGroupOptions,
...(extraOptions || []),
];
@@ -262,6 +339,11 @@ export const TableConfig = ({
listKey,
setTableUpdate,
optionsConfig,
hasAlbumGroupColumn,
albumGroupOpen,
albumGroupImageSize,
imageResTable,
setSettings,
]);
return (
@@ -8,7 +8,7 @@ import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { ItemListKey } from '/@/shared/types/types';
export const useSortByFilter = <TSortBy>(defaultValue: null | string, listKey: ItemListKey) => {
export const useSortByFilter = <TSortBy>(defaultValue: string, listKey: ItemListKey) => {
const server = useCurrentServer();
const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey);
const [searchParams, setSearchParams] = useSearchParams();
+14 -1
View File
@@ -14,7 +14,13 @@ import macMin from './assets/min-mac.png';
import styles from './window-bar.module.css';
import { useRadioPlayer } from '/@/renderer/features/radio/hooks/use-radio-player';
import { useAppStore, usePlayerData, usePlayerStatus, useWindowSettings } from '/@/renderer/store';
import {
useAppStore,
usePlayerData,
usePlayerStatus,
useWindowBarTrackinfo,
useWindowSettings,
} from '/@/renderer/store';
import { Text } from '/@/shared/components/text/text';
import { Platform, PlayerStatus } from '/@/shared/types/types';
@@ -130,6 +136,8 @@ const MacOsControls = ({ controls, title }: WindowBarControlsProps) => {
export const WindowBar = () => {
const { t } = useTranslation();
const { windowBarStyle } = useWindowSettings();
const windowBarTrackinfo = useWindowBarTrackinfo();
const playerStatus = usePlayerStatus();
const privateMode = useAppStore((state) => state.privateMode);
const handleMinimize = () => minimize();
@@ -153,6 +161,10 @@ export const WindowBar = () => {
const title = useMemo(() => {
const privateModeString = privateMode ? t('page.windowBar.privateMode') : '';
if (!windowBarTrackinfo) {
return `Feishin${privateMode ? ` ${privateModeString}` : ''}`;
}
// Show radio information if radio is active
if (isRadioActive) {
const radioStatusString = !isRadioPlaying ? t('page.windowBar.paused') : '';
@@ -194,6 +206,7 @@ export const WindowBar = () => {
queueLength,
stationName,
t,
windowBarTrackinfo,
]);
useEffect(() => {
+15 -1
View File
@@ -1235,12 +1235,26 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
mediaStop: (options?: { reset?: boolean }) => {
const reset = options?.reset !== false;
set((state) => {
state.player.status = PlayerStatus.PAUSED;
state.player.status = PlayerStatus.STOPPED;
setTimestampStore(0);
if (reset) {
state.player.seekToTimestamp = uniqueSeekToTimestamp(0);
}
});
const currentState = get();
const queue = currentState.getQueue();
const currentIndex = currentState.player.index;
const currentSong = queue.items[currentIndex];
eventEmitter.emit('PLAYER_STOP', {
id: currentSong?._uniqueId,
index:
currentIndex !== undefined && currentIndex >= 0
? currentIndex
: undefined,
reset,
});
},
mediaToggleMute: () => {
set((state) => {
+10
View File
@@ -474,6 +474,7 @@ export const GeneralSettingsSchema = z.object({
),
albumBackground: z.boolean(),
albumBackgroundBlur: z.number(),
albumGroupImageSize: z.number(),
artistBackground: z.boolean(),
artistBackgroundBlur: z.number(),
artistItems: z.array(SortableItemSchema(ArtistItemSchema)),
@@ -678,6 +679,7 @@ const WindowSettingsSchema = z.object({
startMinimized: z.boolean(),
tray: z.boolean(),
windowBarStyle: z.nativeEnum(Platform),
windowBarTrackinfo: z.boolean(),
});
const QueryValueInputTypeSchema = z.enum([
@@ -1165,6 +1167,7 @@ const initialState: SettingsState = {
accent: 'rgb(53, 116, 252)',
albumBackground: false,
albumBackgroundBlur: 3,
albumGroupImageSize: 0,
artistBackground: true,
artistBackgroundBlur: 3,
artistItems,
@@ -2014,6 +2017,7 @@ const initialState: SettingsState = {
startMinimized: false,
tray: true,
windowBarStyle: platformDefaultWindowBarStyle,
windowBarTrackinfo: true,
},
};
@@ -2560,6 +2564,9 @@ export const useWindowSettings = () => useSettingsStore((state) => state.window,
export const useWindowBarStyle = () =>
useSettingsStore((state) => state.window.windowBarStyle, shallow);
export const useWindowBarTrackinfo = () =>
useSettingsStore((state) => state.window.windowBarTrackinfo, shallow);
export const useHotkeySettings = () => useSettingsStore((state) => state.hotkeys, shallow);
export const useHotkeyBindings = () => useSettingsStore((state) => state.hotkeys.bindings, shallow);
@@ -2640,6 +2647,9 @@ export const useSkipButtons = () => useSettingsStore((state) => state.general.sk
export const useImageRes = () => useSettingsStore((state) => state.general.imageRes, shallow);
export const useAlbumGroupImageSize = () =>
useSettingsStore((state) => state.general.albumGroupImageSize);
export const useVolumeWidth = () => useSettingsStore((state) => state.general.volumeWidth, shallow);
export const useFollowCurrentSong = () =>
+12 -77
View File
@@ -10,7 +10,6 @@ import {
LibraryItem,
MusicFolder,
Playlist,
RelatedArtist,
Song,
} from '/@/shared/types/domain-types';
import { ServerListItem, ServerType } from '/@/shared/types/types';
@@ -19,42 +18,6 @@ const TICKS_PER_MS = 10000;
type AlbumOrSong = z.infer<typeof jfType._response.album> | z.infer<typeof jfType._response.song>;
const KEYS_TO_OMIT = new Set(['AlbumArtist', 'Artist']);
const getPeople = (item: AlbumOrSong): null | Record<string, RelatedArtist[]> => {
if (item.People) {
const participants: Record<string, RelatedArtist[]> = {};
for (const person of item.People) {
const key = person.Type || '';
if (KEYS_TO_OMIT.has(key)) {
continue;
}
const item: RelatedArtist = {
// for other roles, we just want to display this and not filter.
// filtering (and links) would require a separate field, PersonIds
id: '',
imageId: null,
imageUrl: null,
name: person.Name,
userFavorite: false,
userRating: null,
};
if (key in participants) {
participants[key].push(item);
} else {
participants[key] = [item];
}
}
return participants;
}
return null;
};
const getTags = (item: AlbumOrSong): null | Record<string, string[]> => {
if (item.Tags) {
const tags: Record<string, string[]> = {};
@@ -106,39 +69,6 @@ const getPlaylistImageId = (item: z.infer<typeof jfType._response.playlist>): nu
return null;
};
const getArtists = (
item: z.infer<typeof jfType._response.song>,
participants?: null | Record<string, RelatedArtist[]>,
): RelatedArtist[] => {
if (!item?.ArtistItems?.length && !item.AlbumArtists && !participants) {
return [];
}
const result: RelatedArtist[] = [];
(item?.ArtistItems?.length ? item.ArtistItems : item.AlbumArtists)?.forEach((entry) => {
result.push({
id: entry.Id,
imageId: null,
imageUrl: null,
name: entry.Name,
userFavorite: false,
userRating: null,
});
});
if (participants?.['Remixer']) {
const existingIds = new Set(result.map((artist) => artist.id));
for (const participant of participants['Remixer']) {
if (!existingIds.has(participant.id)) {
result.push(participant);
}
}
}
return result;
};
const jellyfinPremiereFields = (item: {
PremiereDate?: string;
ProductionYear?: number;
@@ -189,10 +119,6 @@ const normalizeSong = (
console.warn('Jellyfin song retrieved with no media sources', item);
}
const participants = getPeople(item);
const artists = getArtists(item, participants);
const { releaseDate, releaseYear } = jellyfinPremiereFields(item);
return {
@@ -211,7 +137,16 @@ const normalizeSong = (
})),
albumId: item.AlbumId || `dummy/${item.Id}`,
artistName: item?.ArtistItems?.map((entry) => entry.Name).join(', ') || '',
artists,
artists: (item?.ArtistItems?.length ? item.ArtistItems : item.AlbumArtists)?.map(
(entry) => ({
id: entry.Id,
imageId: null,
imageUrl: null,
name: entry.Name,
userFavorite: false,
userRating: null,
}),
),
bitDepth,
bitRate,
bpm: null,
@@ -253,7 +188,7 @@ const normalizeSong = (
mbzRecordingId: null,
mbzTrackId: item.ProviderIds?.MusicBrainzTrack || null,
name: item.Name,
participants,
participants: null,
path: path || '',
peak: null,
playCount: (item.UserData && item.UserData.PlayCount) || 0,
@@ -328,7 +263,7 @@ const normalizeAlbum = (
name: item.Name,
originalDate: releaseDate,
originalYear,
participants: getPeople(item),
participants: null,
playCount: item.UserData?.PlayCount || 0,
recordLabels: item.Studios?.map((entry) => entry.Name) || [],
releaseDate,
@@ -27,7 +27,9 @@ export enum NDAlbumListSort {
}
export enum NDGenreListSort {
ALBUM_COUNT = 'albumCount',
NAME = 'name',
SONG_COUNT = 'songCount',
}
export enum NDPlaylistListSort {
@@ -754,6 +756,8 @@ const tag = z.object({
const tagList = z.array(tag);
export enum NDTagListSort {
ALBUM_COUNT = 'albumCount',
SONG_COUNT = 'songCount',
TAG_VALUE = 'tagValue',
}
+24 -2
View File
@@ -155,7 +155,9 @@ export enum ExternalType {
}
export enum GenreListSort {
ALBUM_COUNT = 'albumCount',
NAME = 'name',
SONG_COUNT = 'songCount',
}
export enum ImageType {
@@ -166,7 +168,9 @@ export enum ImageType {
}
export enum TagListSort {
ALBUM_COUNT = 'albumCount',
NAME = 'name',
SONG_COUNT = 'songCount',
}
export type Album = {
@@ -430,19 +434,25 @@ type BaseEndpointArgs = {
type GenreListSortMap = {
jellyfin: Record<GenreListSort, JFGenreListSort | undefined>;
navidrome: Record<GenreListSort, NDGenreListSort | undefined>;
subsonic: Record<UserListSort, undefined>;
navidrome: Record<GenreListSort, NDGenreListSort>;
subsonic: Record<GenreListSort, undefined>;
};
export const genreListSortMap: GenreListSortMap = {
jellyfin: {
albumCount: undefined,
name: JFGenreListSort.NAME,
songCount: undefined,
},
navidrome: {
albumCount: NDGenreListSort.NAME,
name: NDGenreListSort.NAME,
songCount: NDGenreListSort.NAME,
},
subsonic: {
albumCount: undefined,
name: undefined,
songCount: undefined,
},
};
@@ -454,13 +464,19 @@ type TagListSortMap = {
export const tagListSortMap: TagListSortMap = {
jellyfin: {
albumCount: undefined,
name: undefined,
songCount: undefined,
},
navidrome: {
albumCount: NDTagListSort.ALBUM_COUNT,
name: NDTagListSort.TAG_VALUE,
songCount: NDTagListSort.SONG_COUNT,
},
subsonic: {
albumCount: undefined,
name: undefined,
songCount: undefined,
},
};
@@ -620,6 +636,8 @@ export type AlbumInfo = {
notes: null | string;
};
export type SongIdListResponse = BasePaginatedResponse<string[]>;
export type SongListArgs = BaseEndpointArgs & { query: SongListQuery };
export type SongListCountArgs = BaseEndpointArgs & { query: ListCountQuery<SongListQuery> };
@@ -1506,6 +1524,7 @@ export type ControllerEndpoint = {
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistListCount: (args: PlaylistListCountArgs) => Promise<number>;
getPlaylistSongIds: (args: PlaylistSongListArgs) => Promise<SongIdListResponse>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getPlayQueue: (args: GetQueueArgs) => Promise<GetQueueResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
@@ -1660,6 +1679,9 @@ export type InternalControllerEndpoint = {
args: ReplaceApiClientProps<PlaylistListArgs>,
) => Promise<PlaylistListResponse>;
getPlaylistListCount: (args: ReplaceApiClientProps<PlaylistListCountArgs>) => Promise<number>;
getPlaylistSongIds: (
args: ReplaceApiClientProps<PlaylistSongListArgs>,
) => Promise<SongIdListResponse>;
getPlaylistSongList: (
args: ReplaceApiClientProps<PlaylistSongListArgs>,
) => Promise<SongListResponse>;
+1
View File
@@ -149,6 +149,7 @@ export enum PlayerShuffle {
export enum PlayerStatus {
PAUSED = 'paused',
PLAYING = 'playing',
STOPPED = 'stopped',
}
export enum PlayerStyle {