mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-02 00:29:56 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31f12a39b2 | |||
| a6d82374dd | |||
| 42bd8d34d9 | |||
| b397790402 | |||
| 7231f73ba7 | |||
| 37ada07ee2 | |||
| a221a84792 | |||
| aa3c9251f5 | |||
| 751ec7f835 | |||
| 14bad5dbd7 | |||
| 94aa34f6b2 | |||
| da445b815d | |||
| c875146779 | |||
| 9806d2f553 | |||
| 18a7fd0731 | |||
| 062617bb40 |
@@ -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": {
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 s’est produite lors de la tentative de sauvegarde du mot de passe",
|
||||
"error_savePassword": "Une erreur s’est 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
@@ -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 IDőtartam",
|
||||
"crossfadeDuration": "Áthúzás Idő tartam",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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})อัจฉริยะ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Основний відтінок"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "分",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -128,7 +128,7 @@ export const RemoteContainer = () => {
|
||||
onClick={() => {
|
||||
if (status === PlayerStatus.PLAYING) {
|
||||
send({ event: 'pause' });
|
||||
} else if (status === PlayerStatus.PAUSED) {
|
||||
} else {
|
||||
send({ event: 'play' });
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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(() => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -149,6 +149,7 @@ export enum PlayerShuffle {
|
||||
export enum PlayerStatus {
|
||||
PAUSED = 'paused',
|
||||
PLAYING = 'playing',
|
||||
STOPPED = 'stopped',
|
||||
}
|
||||
|
||||
export enum PlayerStyle {
|
||||
|
||||
Reference in New Issue
Block a user