Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f02307ff2a | |||
| 2647c36326 | |||
| 16a9d6e702 | |||
| 0a4d789f08 | |||
| 7f5742119b | |||
| 04d8e013e1 |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 645 B After Width: | Height: | Size: 820 B |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.1 KiB |
@@ -1,6 +1,5 @@
|
||||
server {
|
||||
listen 9180;
|
||||
listen [::]:9180;
|
||||
sendfile on;
|
||||
default_type application/octet-stream;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "1.6.0",
|
||||
"version": "1.5.0",
|
||||
"description": "A modern self-hosted music player.",
|
||||
"keywords": [
|
||||
"subsonic",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"search": "$t(common.search)",
|
||||
"settings": "$t(common.setting, {\"count\": 2})",
|
||||
"tracks": "$t(entity.track, {\"count\": 2})",
|
||||
"nowPlaying": "s'està reproduint",
|
||||
"nowPlaying": "ara sona",
|
||||
"shared": "$t(entity.playlist, {\"count\": 2}) compartides",
|
||||
"favorites": "$t(entity.favorite, {\"count\": 2})",
|
||||
"radio": "$t(entity.radioStation, {\"count\": 2})",
|
||||
@@ -29,11 +29,7 @@
|
||||
"topSongsFrom": "les millors cançons de {{title}}",
|
||||
"viewAll": "mostra-ho tot",
|
||||
"groupingTypeAll": "tots els tipus de llançaments",
|
||||
"groupingTypePrimary": "tipus principals de llançament",
|
||||
"favoriteSongs": "Cançons preferides",
|
||||
"topSongsCommunity": "comunitat",
|
||||
"topSongsPersonal": "personal",
|
||||
"favoriteSongsFrom": "cançons preferides de {{title}}"
|
||||
"groupingTypePrimary": "tipus principals de llançament"
|
||||
},
|
||||
"albumArtistList": {
|
||||
"title": "$t(entity.albumArtist, {\"count\": 2})"
|
||||
@@ -204,11 +200,6 @@
|
||||
"collections": {
|
||||
"overrideExisting": "sobreescriu existents",
|
||||
"saveAsCollection": "desa com a col·lecció"
|
||||
},
|
||||
"releasenotes": {
|
||||
"commitsSinceStable": "commits des de {{stable}}",
|
||||
"noNewCommits": "no hi ha hagut commits en aquest període",
|
||||
"noStableReleaseToCompare": "no hi ha actualitzacions disponibles amb les quals comparar"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -568,7 +559,7 @@
|
||||
"sidePlayQueueStyle_optionAttached": "unida",
|
||||
"sidePlayQueueStyle_optionDetached": "separada",
|
||||
"audioDevice": "dispositiu d'àudio",
|
||||
"audioDevice_description": "seleccioneu el dispositiu d'àudio que voleu utilitzar per a la reproducció",
|
||||
"audioDevice_description": "seleccioneu el dispositiu d'àudio que voleu utilitzar per a la reproducció (només pel reproductor web)",
|
||||
"audioPlayer": "reproductor d'àudio",
|
||||
"audioPlayer_description": "seleccioneu el reproductor d'àudio que voleu utilitzar per a la reproducció",
|
||||
"sidebarConfiguration_description": "selecciona els elements i l'ordre en què apareixen a la barra lateral",
|
||||
@@ -614,9 +605,9 @@
|
||||
"customFontPath_description": "estableix la ruta a una font personalitzada per utilitzar-la a l'aplicació",
|
||||
"discordApplicationId": "id d'aplicació de {{discord}}",
|
||||
"discordApplicationId_description": "l'id d'aplicació per l'estat d'activitat de {{discord}} (per defecte, {{defaultId}})",
|
||||
"discordPausedStatus": "mostra l'estat d'activitat quan està en pausa",
|
||||
"discordPausedStatus": "mosta l'estat d'activitat quan està en pausa",
|
||||
"discordPausedStatus_description": "si està activat, l'estat es mostrarà quan el reproductor estigui pausat",
|
||||
"discordIdleStatus": "mosta l'estat d'activitat quan està inactiu",
|
||||
"discordIdleStatus": "mosta l'estat d'activitat en inactivitat",
|
||||
"discordIdleStatus_description": "si està activat, s'actualitzarà l'estat mentre el reproductor estigui inactiu",
|
||||
"discordListening": "mosta l'estat com escoltant",
|
||||
"discordListening_description": "mosta l'estat com escoltant en comptes de jugant",
|
||||
@@ -788,7 +779,7 @@
|
||||
"releaseChannel_optionLatest": "última versió",
|
||||
"releaseChannel_optionBeta": "beta",
|
||||
"releaseChannel": "canal de versions",
|
||||
"releaseChannel_description": "trieu entre versions estables i beta o alfa (diàries) per les actualitzacions automàtiques",
|
||||
"releaseChannel_description": "tria entre versions estables i versions beta per les actualitzacions automàtiques",
|
||||
"mediaSession": "activa Media Session",
|
||||
"mediaSession_description": "activa la integració amb Media Session per mostrar els controls multimèdia i les metadades a l'indicador de volum del sistema i la pantalla de bloqueig",
|
||||
"crossfadeStyle": "estil de fosa encadenada",
|
||||
@@ -887,16 +878,7 @@
|
||||
"sidebarPlaylistSorting": "ordenació de llistes de reproducció de la barra lateral",
|
||||
"sidebarPlaylistListFilterRegex_description": "amaga les llistes de reproducció de la barra lateral que coincideixin amb aquesta expressió regular",
|
||||
"sidebarPlaylistListFilterRegex_placeholder": "ex. ^Mescla diària.*",
|
||||
"sidebarPlaylistListFilterRegex": "regex pel filtre de llistes",
|
||||
"analyticsEnable": "envia analítiques basades en l'ús",
|
||||
"analyticsEnable_description": "s'envien dades d'ús anonimitzades al desenvolupar per ajudar a millorar l'aplicació",
|
||||
"automaticUpdates": "actualitzacions automàtiques",
|
||||
"automaticUpdates_description": "cerca i instal·la actualitzacions automàticament",
|
||||
"releaseChannel_optionAlpha": "alfa (diària)",
|
||||
"blurExplicitImages": "desenfoca imatges explícites",
|
||||
"blurExplicitImages_description": "les caràtules d'àlbums i cançons marcades com a explícites quedaran desenfocades",
|
||||
"discordStateIcon": "mostra la icona de reproducció",
|
||||
"discordStateIcon_description": "mostra una petita icona de reproducció a l'estat d'activitat. l'icona de pausa es mostra quan \"mostra l'estat d'activitat quan està en pausa\" està activat"
|
||||
"sidebarPlaylistListFilterRegex": "regex pel filtre de llistes"
|
||||
},
|
||||
"table": {
|
||||
"column": {
|
||||
@@ -1001,8 +983,7 @@
|
||||
"view": {
|
||||
"table": "taula",
|
||||
"grid": "quadrícula",
|
||||
"list": "llista",
|
||||
"detail": "detall"
|
||||
"list": "llista"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1050,9 +1031,7 @@
|
||||
"path": "ruta",
|
||||
"songCount": "nombre de cançons",
|
||||
"explicitStatus": "$t(common.explicitStatus)",
|
||||
"sortName": "ordena per nom",
|
||||
"matchAnd": "i",
|
||||
"matchOr": "o"
|
||||
"sortName": "ordena per nom"
|
||||
},
|
||||
"player": {
|
||||
"muted": "silenciat",
|
||||
@@ -1093,16 +1072,7 @@
|
||||
"restoreQueueFromServer": "restaura la cua del servidor",
|
||||
"saveQueueToServer": "desa la cua al servidor",
|
||||
"artistRadio": "ràdio de l'artista",
|
||||
"trackRadio": "ràdio de la pista",
|
||||
"sleepTimer": "temporitzador d'adormir",
|
||||
"sleepTimer_endOfSong": "final de la cançó actual",
|
||||
"sleepTimer_minutes": "{{count}} min",
|
||||
"sleepTimer_hours": "{{count}} h",
|
||||
"sleepTimer_custom": "personalitzat",
|
||||
"sleepTimer_off": "apagat",
|
||||
"sleepTimer_timeRemaining": "queden {{time}}",
|
||||
"sleepTimer_setCustom": "configura el temporitzador",
|
||||
"sleepTimer_cancel": "cancel·la el temporitzador"
|
||||
"trackRadio": "ràdio de la pista"
|
||||
},
|
||||
"error": {
|
||||
"credentialsRequired": "credencials requerides",
|
||||
|
||||
@@ -38,16 +38,7 @@
|
||||
"restoreQueueFromServer": "obnovit frontu ze serveru",
|
||||
"saveQueueToServer": "uložit frontu na server",
|
||||
"artistRadio": "rádio umělce",
|
||||
"trackRadio": "rádio skladby",
|
||||
"sleepTimer": "časovač spánku",
|
||||
"sleepTimer_endOfSong": "konec aktuální skladby",
|
||||
"sleepTimer_minutes": "{{count}} min.",
|
||||
"sleepTimer_hours": "{{count}} hod.",
|
||||
"sleepTimer_custom": "vlastní",
|
||||
"sleepTimer_off": "vypnuto",
|
||||
"sleepTimer_timeRemaining": "zbývá {{time}}",
|
||||
"sleepTimer_setCustom": "nastavit časovač",
|
||||
"sleepTimer_cancel": "zrušit časovač"
|
||||
"trackRadio": "rádio skladby"
|
||||
},
|
||||
"setting": {
|
||||
"crossfadeStyle_description": "vyberte způsob prolnutí u přehrávače zvuku",
|
||||
@@ -55,7 +46,7 @@
|
||||
"hotkey_skipBackward": "přeskočení zpět",
|
||||
"replayGainMode_description": "úprava zesílení hlasitosti podle hodnot {{ReplayGain}} uložených v metadatech souborů",
|
||||
"volumeWheelStep_description": "počet procent, o které má být hlasitost posunuta při přejetí kolečkem myši na posuvníku hlasitosti",
|
||||
"audioDevice_description": "vyberte zvukové zařízení k přehrávání",
|
||||
"audioDevice_description": "vyberte zvukové zařízení k přehrávání (pouze webový přehrávač)",
|
||||
"theme_description": "nastavení motivu použitého v aplikaci",
|
||||
"hotkey_playbackPause": "pozastavení",
|
||||
"replayGainFallback": "fallback {{ReplayGain}}",
|
||||
@@ -270,7 +261,7 @@
|
||||
"neteaseTranslation_description": "Pokud je povoleno, načte a zobrazí přeložené texty ze služby NetEase, pokud jsou dostupné",
|
||||
"preferLocalLyrics": "preferovat místní texty",
|
||||
"preferLocalLyrics_description": "preferovat místní texty před vzdálenými, pokud jsou dostupné",
|
||||
"discordPausedStatus": "zobrazit stav při pozastavení",
|
||||
"discordPausedStatus": "zobrazit rich presence při pozastavení",
|
||||
"discordPausedStatus_description": "pokud je povoleno, bude při pozastavení přehrávače zobrazen stav",
|
||||
"preservePitch": "zachovat výšku",
|
||||
"preservePitch_description": "zachová výšku při úpravě rychlosti přehrávání",
|
||||
@@ -394,13 +385,7 @@
|
||||
"sidebarPlaylistListFilterRegex_description": "v postranní liště skrýt seznamy skladeb, které odpovídají tomuto regulárnímu výrazu",
|
||||
"sidebarPlaylistListFilterRegex_placeholder": "např. ^Denní mix.*",
|
||||
"sidebarPlaylistListFilterRegex": "regulární výraz filtru seznamů skladeb",
|
||||
"releaseChannel_optionAlpha": "alpha (noční)",
|
||||
"analyticsEnable": "Posílat analytiku založenou na využití",
|
||||
"analyticsEnable_description": "Anonymizovaná data o používání jsou odesílána vývojáři za účelem zlepšení aplikace",
|
||||
"automaticUpdates": "Automatické aktualizace",
|
||||
"automaticUpdates_description": "Kontrolovat a automaticky instalovat aktualizace",
|
||||
"discordStateIcon": "zobrazit ikonu přehrávání",
|
||||
"discordStateIcon_description": "zobrazit malou ikonu přehrávání ve stavu na Discordu. ikona pozastavení bude zobrazena vždy, když je povolena možnost „Zobrazit stav při pozastavení“"
|
||||
"releaseChannel_optionAlpha": "alpha (noční)"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "upravit $t(entity.playlist, {\"count\": 1})",
|
||||
@@ -573,8 +558,7 @@
|
||||
"view": {
|
||||
"table": "tabulka",
|
||||
"list": "seznam",
|
||||
"grid": "mřížka",
|
||||
"detail": "podrobnosti"
|
||||
"grid": "mřížka"
|
||||
},
|
||||
"general": {
|
||||
"displayType": "typ zobrazení",
|
||||
@@ -751,9 +735,7 @@
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
"trackNumber": "skladba",
|
||||
"explicitStatus": "$t(common.explicitStatus)",
|
||||
"sortName": "název v řazení",
|
||||
"matchAnd": "a",
|
||||
"matchOr": "nebo"
|
||||
"sortName": "název v řazení"
|
||||
},
|
||||
"page": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -211,7 +211,6 @@
|
||||
"credentialsRequired": "credentials required",
|
||||
"endpointNotImplementedError": "endpoint {{endpoint}} is not implemented for {{serverType}}",
|
||||
"genericError": "an error occurred",
|
||||
"invalidJson": "invalid JSON",
|
||||
"invalidServer": "invalid server",
|
||||
"localFontAccessDenied": "access denied to local fonts",
|
||||
"loginRateError": "too many login attempts, please try again in a few seconds",
|
||||
@@ -228,7 +227,6 @@
|
||||
"remotePortError": "an error occurred when trying to set the remote server port",
|
||||
"remotePortWarning": "restart the server to apply the new port",
|
||||
"saveQueueFailed": "failed to save queue",
|
||||
"serverLockSingleServer": "only one server is allowed when server is locked",
|
||||
"serverNotSelectedError": "no server selected",
|
||||
"serverRequired": "server required",
|
||||
"sessionExpiredError": "your session has expired",
|
||||
@@ -238,8 +236,6 @@
|
||||
"filter": {
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
||||
"matchAnd": "and",
|
||||
"matchOr": "or",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2}) count",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"biography": "biography",
|
||||
@@ -738,7 +734,7 @@
|
||||
"artistConfiguration_description": "configure what items are shown, and in what order, on the album artist page",
|
||||
"artistReleaseTypeConfiguration": "artist release type configuration",
|
||||
"artistReleaseTypeConfiguration_description": "configure what release types are shown, and in what order, on the album artist page",
|
||||
"audioDevice_description": "select the audio device to use for playback",
|
||||
"audioDevice_description": "select the audio device to use for playback (web player only)",
|
||||
"audioDevice": "audio device",
|
||||
"audioExclusiveMode_description": "enable exclusive output mode. In this mode, the system is usually locked out, and only mpv will be able to output audio",
|
||||
"audioExclusiveMode": "audio exclusive mode",
|
||||
@@ -792,8 +788,6 @@
|
||||
"discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}}",
|
||||
"discordServeImage": "serve {{discord}} images from server",
|
||||
"discordServeImage_description": "share cover art for {{discord}} rich presence from server itself, only available for Jellyfin and Navidrome. {{discord}} uses a bot to fetch images, so your server must be reachable from the public internet",
|
||||
"discordStateIcon": "show playing icon",
|
||||
"discordStateIcon_description": "show a small playing icon in the rich presence status. the paused icon is always shown when \"Show rich presence when paused\" is enabled",
|
||||
"discordUpdateInterval": "{{discord}} rich presence update interval",
|
||||
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
|
||||
"enableAutoTranslation_description": "enable translation automatically when lyrics are loaded",
|
||||
|
||||
@@ -32,29 +32,20 @@
|
||||
"playSimilarSongs": "Reproducir canciones similares",
|
||||
"viewQueue": "ver cola",
|
||||
"addLastShuffled": "Al final (mezclado)",
|
||||
"addNextShuffled": "Siguiente (mezclado)",
|
||||
"addNextShuffled": "Al siguiente (mezclado)",
|
||||
"holdToShuffle": "Mantener para mezclar",
|
||||
"lyrics": "Letras",
|
||||
"restoreQueueFromServer": "Restaurar cola del servidor",
|
||||
"saveQueueToServer": "Guardar cola en el servidor",
|
||||
"artistRadio": "Radio de artista",
|
||||
"trackRadio": "Radio de pista",
|
||||
"sleepTimer_minutes": "{{count}} min",
|
||||
"sleepTimer_hours": "{{count}} h",
|
||||
"sleepTimer_custom": "Personalizado",
|
||||
"sleepTimer_setCustom": "Configurar temporizador",
|
||||
"sleepTimer_cancel": "Cancelar temporizador",
|
||||
"sleepTimer_timeRemaining": "{{time}} restante",
|
||||
"sleepTimer_off": "Apagado",
|
||||
"sleepTimer_endOfSong": "Fin de la canción actual",
|
||||
"sleepTimer": "Temporizador de apagado"
|
||||
"trackRadio": "Radio de pista"
|
||||
},
|
||||
"setting": {
|
||||
"crossfadeStyle_description": "selecciona el estilo de crossfade a usar por el reproductor de audio",
|
||||
"remotePort_description": "establece el puerto para el control remoto del servidor",
|
||||
"hotkey_skipBackward": "retroceder",
|
||||
"replayGainMode_description": "ajusta el volumen de ganancia acorde a los valores de {{ReplayGain}} almacenados en los metadatos del archivo",
|
||||
"audioDevice_description": "selecciona el dispositivo de audio a usar durante la reproducción",
|
||||
"audioDevice_description": "selecciona el dispositivo de audio a usar durante la reproducción (solo reproductor web)",
|
||||
"theme_description": "establece el tema a usar por la aplicación",
|
||||
"hotkey_playbackPause": "pausa",
|
||||
"replayGainFallback": "{{ReplayGain}} alternativa",
|
||||
@@ -168,7 +159,7 @@
|
||||
"customFontPath": "ruta de fuente personalizada",
|
||||
"followLyric": "seguir la letra actual",
|
||||
"crossfadeDuration": "duración del crossfade",
|
||||
"discordIdleStatus": "mostrar estado inactivo en el estado de actividad",
|
||||
"discordIdleStatus": "mostrar el estado inactivo en el estado de actividad",
|
||||
"sidePlayQueueStyle_optionDetached": "separada",
|
||||
"audioPlayer": "reproductor de audio",
|
||||
"hotkey_zoomOut": "reducir",
|
||||
@@ -327,8 +318,8 @@
|
||||
"playerbarWaveformRadius": "Radio de la forma de onda",
|
||||
"showLyricsInSidebar_description": "Se añadirá un panel a la cola de reproducción acoplada que muestra las letras",
|
||||
"showLyricsInSidebar": "Mostrar letras en la barra lateral del reproductor",
|
||||
"showVisualizerInSidebar_description": "Se añadirá un panel a la barra lateral del reproductor que muestra el visualizador",
|
||||
"showVisualizerInSidebar": "Mostrar visualizador en la barra lateral del reproductor",
|
||||
"showVisualizerInSidebar_description": "Se añadirá un panel a la barra lateral de reproducción que muestra el visualizador",
|
||||
"showVisualizerInSidebar": "Mostrar visualizador en la barra lateral de reproducción",
|
||||
"queryBuilder": "Generador de consultas",
|
||||
"queryBuilderCustomFields_inputTag": "Etiqueta",
|
||||
"queryBuilderCustomFields": "Campos personalizados",
|
||||
@@ -394,13 +385,7 @@
|
||||
"sidebarPlaylistListFilterRegex_placeholder": "p. ej. ^Mezcla diaria.*",
|
||||
"blurExplicitImages": "Desenfocar imágenes explícitas",
|
||||
"blurExplicitImages_description": "El álbum y la carátula de la canción etiquetados como explícitos serán desenfocados",
|
||||
"releaseChannel_optionAlpha": "Alpha (nightly)",
|
||||
"analyticsEnable": "Enviar analíticas basadas en el uso",
|
||||
"analyticsEnable_description": "Se envían datos de uso anonimizados al desarrollador para ayudar a mejorar la aplicación",
|
||||
"automaticUpdates": "Actualizaciones automáticas",
|
||||
"automaticUpdates_description": "Busca e instala actualizaciones automáticamente",
|
||||
"discordStateIcon": "Mostrar icono de reproducción",
|
||||
"discordStateIcon_description": "Muestra un icono pequeño de reproducción en el estado de actividad. El icono de pausa se muestra siempre cuando \"Mostrar estado de actividad cuando esté en pausa\" esté activado"
|
||||
"releaseChannel_optionAlpha": "Alpha (nightly)"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "editar $t(entity.playlist, {\"count\": 1})",
|
||||
@@ -446,7 +431,7 @@
|
||||
"backward": "hacia atrás",
|
||||
"increase": "aumentar",
|
||||
"rating": "calificación",
|
||||
"bpm": "bpm",
|
||||
"bpm": "lpm",
|
||||
"refresh": "actualizar",
|
||||
"unknown": "desconocido",
|
||||
"areYouSure": "seguro?",
|
||||
@@ -458,7 +443,7 @@
|
||||
"currentSong": "$t(entity.track, {\"count\": 1}) actual",
|
||||
"collapse": "contraer",
|
||||
"trackNumber": "pista",
|
||||
"descending": "descendente",
|
||||
"descending": "descendiente",
|
||||
"add": "añadir",
|
||||
"ascending": "ascendente",
|
||||
"dismiss": "descartar",
|
||||
@@ -485,8 +470,8 @@
|
||||
"cancel": "cancelar",
|
||||
"forceRestartRequired": "reiniciar para aplicar cambios... cerrar la notificación para reiniciar",
|
||||
"setting_one": "configuración",
|
||||
"setting_many": "configuración",
|
||||
"setting_other": "configuración",
|
||||
"setting_many": "configuraciones",
|
||||
"setting_other": "configuraciones",
|
||||
"version": "versión",
|
||||
"title": "título",
|
||||
"filters": "filtros",
|
||||
@@ -600,10 +585,10 @@
|
||||
"noNetworkDescription": "No se pudo conectar a este servidor"
|
||||
},
|
||||
"filter": {
|
||||
"mostPlayed": "más reproducidos",
|
||||
"mostPlayed": "más reproducido",
|
||||
"isCompilation": "es una compilación",
|
||||
"recentlyPlayed": "recientemente reproducido",
|
||||
"isRated": "Está calificado",
|
||||
"isRated": "es clasificado",
|
||||
"title": "título",
|
||||
"rating": "calificación",
|
||||
"search": "buscar",
|
||||
@@ -619,7 +604,7 @@
|
||||
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
||||
"isRecentlyPlayed": "reproducido recientemente",
|
||||
"isFavorited": "es favorito",
|
||||
"bpm": "bpm",
|
||||
"bpm": "lpm",
|
||||
"releaseYear": "año de lanzamiento",
|
||||
"disc": "disco",
|
||||
"biography": "biografía",
|
||||
@@ -638,14 +623,12 @@
|
||||
"owner": "$t(common.owner)",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"id": "id",
|
||||
"songCount": "número de canciones",
|
||||
"songCount": "número de canción",
|
||||
"isPublic": "es público",
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
"albumCount": "Número de $t(entity.album, {\"count\": 2})",
|
||||
"albumCount": "Contar $t(entity.album, {\"count\": 2})",
|
||||
"explicitStatus": "$t(common.explicitStatus)",
|
||||
"sortName": "Ordenar por nombre",
|
||||
"matchAnd": "y",
|
||||
"matchOr": "o"
|
||||
"sortName": "Ordenar por nombre"
|
||||
},
|
||||
"page": {
|
||||
"sidebar": {
|
||||
@@ -882,8 +865,8 @@
|
||||
"input_name": "nombre del servidor",
|
||||
"success": "servidor añadido correctamente",
|
||||
"input_savePassword": "guardar contraseña",
|
||||
"ignoreSsl": "Ignorar SSL ($t(common.restartRequired))",
|
||||
"ignoreCors": "Ignorar CORS ($t(common.restartRequired))",
|
||||
"ignoreSsl": "ignorar ssl ($t(common.restartRequired))",
|
||||
"ignoreCors": "ignorar cors ($t(common.restartRequired))",
|
||||
"error_savePassword": "un error ocurrió cuando se intentó guardar la contraseña",
|
||||
"input_preferInstantMix": "Preferir mix instantáneo",
|
||||
"input_preferInstantMixDescription": "Usa solo el mix instantáneo para obtener canciones similares. Útil si tienes complementos que modifican este comportamiento",
|
||||
@@ -981,7 +964,7 @@
|
||||
"releaseDate": "fecha de lanzamiento",
|
||||
"bitrate": "tasa de bits",
|
||||
"title": "título",
|
||||
"bpm": "bpm",
|
||||
"bpm": "lpm",
|
||||
"dateAdded": "fecha de adición",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
||||
@@ -1046,8 +1029,8 @@
|
||||
"followCurrentSong": "seguir la canción actual",
|
||||
"advancedSettings": "Opciones avanzadas",
|
||||
"autosize": "Autodimensionar",
|
||||
"moveUp": "Subir",
|
||||
"moveDown": "Bajar",
|
||||
"moveUp": "Ascender",
|
||||
"moveDown": "Descender",
|
||||
"pinToLeft": "Anclar a la izquierda",
|
||||
"pinToRight": "Anclar a la derecha",
|
||||
"alignLeft": "Alinear a la izquierda",
|
||||
@@ -1070,8 +1053,7 @@
|
||||
"view": {
|
||||
"table": "tabla",
|
||||
"list": "Lista",
|
||||
"grid": "Cuadrícula",
|
||||
"detail": "Detalle"
|
||||
"grid": "Cuadrícula"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -204,8 +204,7 @@
|
||||
"mood": "humeur",
|
||||
"retry": "réessayer",
|
||||
"filter_single": "unique",
|
||||
"filter_multiple": "multiple",
|
||||
"rename": "renommer"
|
||||
"filter_multiple": "multiple"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
|
||||
@@ -281,8 +280,7 @@
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"isPublic": "est public",
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
"explicitStatus": "$t(common.explicitStatus)",
|
||||
"sortName": "tri par nom"
|
||||
"explicitStatus": "$t(common.explicitStatus)"
|
||||
},
|
||||
"page": {
|
||||
"sidebar": {
|
||||
@@ -449,12 +447,7 @@
|
||||
"viewDiscography": "voir la discographie",
|
||||
"relatedArtists": "$t(entity.artist, {\"count\": 2}) similaires",
|
||||
"topSongs": "meilleurs titres",
|
||||
"groupingTypeAll": "toutes les types de sortie",
|
||||
"favoriteSongs": "titres préférées",
|
||||
"groupingTypePrimary": "types de parution principale",
|
||||
"topSongsCommunity": "communauté",
|
||||
"topSongsPersonal": "personnel",
|
||||
"favoriteSongsFrom": "meilleurs titres de {{title}}"
|
||||
"groupingTypeAll": "toutes les types de sortie"
|
||||
},
|
||||
"itemDetail": {
|
||||
"copyPath": "copier le chemin dans le presse-papiers",
|
||||
@@ -480,14 +473,6 @@
|
||||
},
|
||||
"radioList": {
|
||||
"title": "stations radio"
|
||||
},
|
||||
"releasenotes": {
|
||||
"commitsSinceStable": "commits depuis {{stable}}",
|
||||
"noNewCommits": "pas de nouveaux commits dans cette plage"
|
||||
},
|
||||
"windowBar": {
|
||||
"paused": "(Pause) ",
|
||||
"privateMode": "(Mode Privé)"
|
||||
}
|
||||
},
|
||||
"setting": {
|
||||
|
||||
@@ -661,16 +661,7 @@
|
||||
"restoreQueueFromServer": "przywróć kolejkę z serwera",
|
||||
"saveQueueToServer": "zapisz kolejkę na serwerze",
|
||||
"artistRadio": "radio wykonawcy",
|
||||
"trackRadio": "radio utworu",
|
||||
"sleepTimer": "wyłącznik czasowy",
|
||||
"sleepTimer_endOfSong": "do końca aktualnej piosenki",
|
||||
"sleepTimer_minutes": "{{count}} min",
|
||||
"sleepTimer_hours": "{{count}} godz",
|
||||
"sleepTimer_custom": "niestandardowy",
|
||||
"sleepTimer_off": "wyłączony",
|
||||
"sleepTimer_timeRemaining": "pozostało {{time}}",
|
||||
"sleepTimer_setCustom": "ustaw wyłącznik",
|
||||
"sleepTimer_cancel": "anuluj wyłączanie"
|
||||
"trackRadio": "radio utworu"
|
||||
},
|
||||
"setting": {
|
||||
"crossfadeStyle_description": "wybierz styl przenikania, który ma być używany do odtwarzania dźwięku",
|
||||
@@ -903,7 +894,7 @@
|
||||
"releaseChannel_optionBeta": "beta",
|
||||
"releaseChannel_optionLatest": "najnowsza",
|
||||
"releaseChannel": "kanał wydań",
|
||||
"releaseChannel_description": "wybieraj pomiędzy wydaniami stabilnymi, beta lub alpha (nightly) dla automatycznych aktualizacji",
|
||||
"releaseChannel_description": "wybieraj pomiędzy stabilnymi wydaniami a wydaniami beta dla automatycznych aktualizacji",
|
||||
"discordDisplayType_artistname": "nazwa(y) wykonawców",
|
||||
"discordDisplayType_description": "zmienia co jest pokazywane jako słuchane w twoim statusie",
|
||||
"discordDisplayType_songname": "nazwa piosenki",
|
||||
@@ -1017,21 +1008,14 @@
|
||||
"sidebarPlaylistListFilterRegex": "filtr playlist regex",
|
||||
"blurExplicitImages": "rozmazuj nieodpowiednie obrazy",
|
||||
"blurExplicitImages_description": "obrazy piosenek oraz albumów oznaczone jako nieodpowiednie będą rozmazywane",
|
||||
"releaseChannel_optionAlpha": "alpha (nightly)",
|
||||
"analyticsEnable": "Wysyłaj statystyki na podstawie użytkowania",
|
||||
"analyticsEnable_description": "Zanonimizowane statystki użytkowania będą wysyłane do twórcy, aby pomóc w poprawie aplikacji",
|
||||
"automaticUpdates": "Aktualizacje automatyczne",
|
||||
"automaticUpdates_description": "Sprawdzaj i instaluj aktualizacje automatycznie",
|
||||
"discordStateIcon": "pokaż ikonę odtwarzania",
|
||||
"discordStateIcon_description": "pokazuje małą ikonę odtwarzania w statusie. ikona pauzy jest zawsze pokazywana gdy \"Pokaż status podczas pauzy\" jest włączone"
|
||||
"releaseChannel_optionAlpha": "alpha (nightly)"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
"view": {
|
||||
"table": "tabela",
|
||||
"grid": "siatka",
|
||||
"list": "lista",
|
||||
"detail": "szczegół"
|
||||
"list": "lista"
|
||||
},
|
||||
"general": {
|
||||
"displayType": "typ wyświetlania",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"holdToMoveToTop": "утримуйте, щоб перемістити вгору",
|
||||
"holdToMoveToBottom": "утримувати, щоб перемістити вниз",
|
||||
"moveItems": "перемістити елементи",
|
||||
"shuffle": "перемішати",
|
||||
"shuffle": "відтворити випадково",
|
||||
"shuffleAll": "все випадково",
|
||||
"shuffleSelected": "вибране випадково",
|
||||
"refresh": "$t(common.refresh)",
|
||||
@@ -415,130 +415,9 @@
|
||||
"success": "посилання для спільного використання скопійовано в буфер обміну (натисніть тут, щоб відкрити)",
|
||||
"expireInvalid": "термін дії повинен бути в майбутньому",
|
||||
"createFailed": "не вдалося створити спільний доступ (чи ввімкнено спільний доступ?)"
|
||||
},
|
||||
"shuffleAll": {
|
||||
"title": "відтворити випадково",
|
||||
"input_genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"input_limit": "скільки пісень?",
|
||||
"input_minYear": "від року",
|
||||
"input_maxYear": "до року",
|
||||
"input_played": "відтворити фільтр",
|
||||
"input_played_optionAll": "всі треки",
|
||||
"input_played_optionUnplayed": "тільки не відтворені треки",
|
||||
"input_played_optionPlayed": "тільки відтворені треки"
|
||||
},
|
||||
"updateServer": {
|
||||
"success": "сервер успішно оновлено",
|
||||
"title": "оновити сервер"
|
||||
},
|
||||
"privateMode": {
|
||||
"enabled": "приватний режим увімкнено, стан відтворення тепер приховано від зовнішніх інтеграцій",
|
||||
"disabled": "приватний режим вимкнено, стан відтворення тепер видно для увімкнених зовнішніх інтеграцій",
|
||||
"title": "приватний режим"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"skip": "пропустити"
|
||||
},
|
||||
"page": {
|
||||
"albumArtistDetail": {
|
||||
"about": "Про {{artist}}",
|
||||
"appearsOn": "з'являється на",
|
||||
"favoriteSongs": "улюблені пісні",
|
||||
"groupingTypeAll": "всі типи випуску",
|
||||
"groupingTypePrimary": "основні типи випуску",
|
||||
"recentReleases": "останні випуски",
|
||||
"viewDiscography": "переглянути дискографію",
|
||||
"relatedArtists": "подібні $t(entity.artist, {\"count\": 2})",
|
||||
"topSongs": "найкращі пісні",
|
||||
"topSongsCommunity": "спільнота",
|
||||
"topSongsFrom": "найкращі пісні від {{title}}",
|
||||
"topSongsPersonal": "особисте",
|
||||
"favoriteSongsFrom": "улюблені пісні від {{title}}",
|
||||
"viewAll": "показати все",
|
||||
"viewAllTracks": "показати усі $t(entity.track, {\"count\": 2})"
|
||||
},
|
||||
"albumArtistList": {
|
||||
"title": "$t(entity.albumArtist, {\"count\": 2})"
|
||||
},
|
||||
"albumDetail": {
|
||||
"moreFromArtist": "більше від цього $t(entity.artist, {\"count\": 1})",
|
||||
"moreFromGeneric": "більше від {{item}}",
|
||||
"released": "видано"
|
||||
},
|
||||
"albumList": {
|
||||
"artistAlbums": "альбоми виконавця {{artist}}",
|
||||
"genreAlbums": "\"{{genre}}\" $t(entity.album, {\"count\": 2})",
|
||||
"title": "$t(entity.album, {\"count\": 2})"
|
||||
},
|
||||
"radioList": {
|
||||
"title": "радіостанції"
|
||||
},
|
||||
"releasenotes": {
|
||||
"commitsSinceStable": "комміти від {{stable}}",
|
||||
"noNewCommits": "немає нових коммітів у цьому періоді",
|
||||
"noStableReleaseToCompare": "немає доступної стабільної версії для порівняння"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "$t(entity.favorite, {\"count\": 2})"
|
||||
},
|
||||
"windowBar": {
|
||||
"paused": "(Призупинено) ",
|
||||
"privateMode": "(Приватний режим)"
|
||||
},
|
||||
"appMenu": {
|
||||
"collapseSidebar": "згорнути бічну панель",
|
||||
"commandPalette": "відкрити палітру команд",
|
||||
"expandSidebar": "розгорнути бічну панель",
|
||||
"goBack": "повернутися назад",
|
||||
"goForward": "перейти вперед",
|
||||
"manageServers": "управління серверами",
|
||||
"privateModeOff": "вимкнути приватний режим",
|
||||
"privateModeOn": "увімкнути приватний режим",
|
||||
"openBrowserDevtools": "відкрити інструменти розробника",
|
||||
"quit": "$t(common.quit)",
|
||||
"selectServer": "вибрати сервер",
|
||||
"selectMusicFolder": "вибрати папку з музикою",
|
||||
"noMusicFolder": "не вибрано папку з музикою",
|
||||
"multipleMusicFolders": "Вибрано {{count}} папок з музикою",
|
||||
"settings": "$t(common.setting, {\"count\": 2})",
|
||||
"version": "версія {{version}}"
|
||||
},
|
||||
"manageServers": {
|
||||
"title": "управління серверами",
|
||||
"serverDetails": "інформація про сервер",
|
||||
"url": "URL-адреса",
|
||||
"username": "Ім'я користувача",
|
||||
"editServerDetailsTooltip": "редагувати дані сервера",
|
||||
"removeServer": "видалити сервер"
|
||||
},
|
||||
"contextMenu": {
|
||||
"addFavorite": "$t(action.addToFavorites)",
|
||||
"addLast": "$t(player.addLast)",
|
||||
"addNext": "$t(player.addNext)",
|
||||
"addToFavorites": "$t(action.addToFavorites)",
|
||||
"addToPlaylist": "$t(action.addToPlaylist)",
|
||||
"createPlaylist": "$t(action.createPlaylist)",
|
||||
"deletePlaylist": "$t(action.deletePlaylist)",
|
||||
"deselectAll": "$t(action.deselectAll)",
|
||||
"download": "завантажити",
|
||||
"moveItems": "$t(action.moveItems)",
|
||||
"moveToNext": "$t(action.moveToNext)",
|
||||
"moveToBottom": "$t(action.moveToBottom)",
|
||||
"moveToTop": "$t(action.moveToTop)",
|
||||
"numberSelected": "{{count}} вибрано",
|
||||
"play": "$t(player.play)",
|
||||
"playSimilarSongs": "$t(player.playSimilarSongs)",
|
||||
"removeFromFavorites": "$t(action.removeFromFavorites)",
|
||||
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
|
||||
"removeFromQueue": "$t(action.removeFromQueue)",
|
||||
"setRating": "$t(action.setRating)",
|
||||
"playShuffled": "$t(player.shuffle)",
|
||||
"shareItem": "поділитися елементом",
|
||||
"goTo": "перейти до",
|
||||
"goToAlbum": "перейти до $t(entity.album, {\"count\": 1})",
|
||||
"goToAlbumArtist": "перейти до $t(entity.albumArtist, {\"count\": 1})",
|
||||
"showDetails": "отримати інформацію"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,7 +463,7 @@
|
||||
"releaseChannel_optionLatest": "最新的",
|
||||
"releaseChannel_optionBeta": "测试版",
|
||||
"releaseChannel": "发布通道",
|
||||
"releaseChannel_description": "选择稳定版、测试版或 Alpha(夜间构建版)以启用自动更新。",
|
||||
"releaseChannel_description": "选择稳定版本或测试版以进行自动更新",
|
||||
"mediaSession": "启用媒体会话",
|
||||
"mediaSession_description": "启用媒体会话集成,在系统音量叠加层和锁屏界面显示媒体控件和元数据",
|
||||
"exportImportSettings_control_description": "通过 JSON 导出和导入设置",
|
||||
|
||||
@@ -399,16 +399,7 @@
|
||||
"restoreQueueFromServer": "從伺服器還原播放佇列",
|
||||
"saveQueueToServer": "將播放佇列儲存至伺服器",
|
||||
"artistRadio": "藝人電台",
|
||||
"trackRadio": "曲目電台",
|
||||
"sleepTimer": "睡眠定時器",
|
||||
"sleepTimer_endOfSong": "歌曲播完時",
|
||||
"sleepTimer_minutes": "{{count}} 分鐘",
|
||||
"sleepTimer_hours": "{{count}} 小時",
|
||||
"sleepTimer_custom": "自訂",
|
||||
"sleepTimer_off": "關閉",
|
||||
"sleepTimer_timeRemaining": "剩餘 {{time}}",
|
||||
"sleepTimer_setCustom": "設定定時器",
|
||||
"sleepTimer_cancel": "取消定時器"
|
||||
"trackRadio": "曲目電台"
|
||||
},
|
||||
"setting": {
|
||||
"audioPlayer_description": "選擇用於播放的音訊播放器",
|
||||
@@ -424,7 +415,7 @@
|
||||
"applicationHotkeys": "應用程式快捷鍵",
|
||||
"applicationHotkeys_description": "設定應用程式快捷鍵。切換勾選框來設為全域快捷鍵(僅桌面端)",
|
||||
"audioDevice": "音訊設備",
|
||||
"audioDevice_description": "選擇用於播放的音訊設備",
|
||||
"audioDevice_description": "選擇用於播放的音訊設備(僅 web 播放器)",
|
||||
"audioExclusiveMode": "音訊獨占模式",
|
||||
"audioExclusiveMode_description": "啟用獨占輸出模式。在此模式下,系統通常被鎖定,只有 mpv 能夠輸出音訊",
|
||||
"audioPlayer": "音訊播放器",
|
||||
@@ -755,13 +746,7 @@
|
||||
"sidebarPlaylistListFilterRegex": "播放清單過濾器正規表達式",
|
||||
"blurExplicitImages": "模糊露骨圖片",
|
||||
"blurExplicitImages_description": "標記為露骨的專輯和歌曲封面將被模糊",
|
||||
"releaseChannel_optionAlpha": "alpha (每日建構版)",
|
||||
"analyticsEnable": "傳送基於使用情況的分析報告",
|
||||
"analyticsEnable_description": "匿名化的使用情況資料會傳送給開發者,以協助改進應用程式",
|
||||
"automaticUpdates": "自動更新",
|
||||
"automaticUpdates_description": "自動檢查並安裝更新",
|
||||
"discordStateIcon": "顯示播放中圖示",
|
||||
"discordStateIcon_description": "在 rich presence 狀態中顯示一個小的播放圖示。啟用「暫停時顯示 rich presence」時,會始終顯示暫停的圖示"
|
||||
"releaseChannel_optionAlpha": "alpha (每日建構版)"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -837,8 +822,7 @@
|
||||
"view": {
|
||||
"table": "表格",
|
||||
"grid": "網格",
|
||||
"list": "列表",
|
||||
"detail": "詳情"
|
||||
"list": "列表"
|
||||
}
|
||||
},
|
||||
"column": {
|
||||
@@ -977,9 +961,7 @@
|
||||
"toYear": "從年份",
|
||||
"trackNumber": "曲目",
|
||||
"explicitStatus": "$t(common.explicitStatus)",
|
||||
"sortName": "排序名稱",
|
||||
"matchAnd": "和",
|
||||
"matchOr": "或"
|
||||
"sortName": "排序名稱"
|
||||
},
|
||||
"form": {
|
||||
"addServer": {
|
||||
|
||||
@@ -55,12 +55,6 @@ const ALPHA_UPDATER_CONFIG: {
|
||||
provider: 's3',
|
||||
};
|
||||
|
||||
const GITHUB_UPDATER_CONFIG = {
|
||||
owner: 'jeffvli',
|
||||
provider: 'github' as const,
|
||||
repo: 'feishin',
|
||||
};
|
||||
|
||||
type UpdaterInstance = AppImageUpdater | MacUpdater | NsisUpdater | typeof autoUpdater;
|
||||
|
||||
class AppUpdater {
|
||||
@@ -103,7 +97,6 @@ async function checkAllChannelsAndGetBest(): Promise<{
|
||||
alphaUpdater.allowDowngrade = true;
|
||||
|
||||
try {
|
||||
console.log('Checking for updates on alpha channel');
|
||||
const alphaResult = await alphaUpdater.checkForUpdates();
|
||||
if (
|
||||
alphaResult?.updateInfo?.version &&
|
||||
@@ -118,9 +111,7 @@ async function checkAllChannelsAndGetBest(): Promise<{
|
||||
}
|
||||
|
||||
try {
|
||||
autoUpdater.setFeedURL(GITHUB_UPDATER_CONFIG);
|
||||
configureAutoUpdaterForChannel('latest');
|
||||
console.log('Checking for updates on latest channel (GitHub)');
|
||||
const latestResult = await autoUpdater.checkForUpdates();
|
||||
if (
|
||||
latestResult?.updateInfo?.version &&
|
||||
|
||||
@@ -763,7 +763,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
getFolder: async ({ apiClientProps, context, query }) => {
|
||||
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
|
||||
|
||||
const isRootFolderId = query.id === '0';
|
||||
const isRootFolderId = /^\d+$/.test(query.id);
|
||||
|
||||
if (isRootFolderId) {
|
||||
const res = await ssApiClient(apiClientProps).getIndexes({
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
.list-expanded-container {
|
||||
overflow: auto;
|
||||
.container {
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import { motion, Variants } from 'motion/react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import styles from './expanded-list-container.module.css';
|
||||
|
||||
const EXPANDED_HEIGHT = 300;
|
||||
const expandedAnimationVariants: Variants = {
|
||||
hidden: {
|
||||
height: 0,
|
||||
minHeight: 0,
|
||||
},
|
||||
show: {
|
||||
minHeight: '300px',
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export interface ExpandedListContainerProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ExpandedListContainer = ({ children }: ExpandedListContainerProps) => {
|
||||
export const ExpandedListContainer = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
animate="show"
|
||||
className={styles.listExpandedContainer}
|
||||
style={{
|
||||
height: EXPANDED_HEIGHT,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
exit="hidden"
|
||||
initial="hidden"
|
||||
variants={expandedAnimationVariants}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,18 +2,27 @@ import { Suspense } from 'react';
|
||||
|
||||
import styles from './expanded-list-item.module.css';
|
||||
|
||||
import { ItemListStateItem } from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||
import {
|
||||
ItemListStateActions,
|
||||
ItemListStateItem,
|
||||
useItemListStateSubscription,
|
||||
} from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||
import { ExpandedAlbumListItem } from '/@/renderer/features/albums/components/expanded-album-list-item';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
interface ExpandedListItemProps {
|
||||
item?: ItemListStateItem;
|
||||
internalState: ItemListStateActions;
|
||||
itemType: LibraryItem;
|
||||
}
|
||||
|
||||
export const ExpandedListItem = ({ item, itemType }: ExpandedListItemProps) => {
|
||||
if (!item) {
|
||||
export const ExpandedListItem = ({ internalState, itemType }: ExpandedListItemProps) => {
|
||||
const expandedItems = useItemListStateSubscription(internalState, () =>
|
||||
internalState ? internalState.getExpandedItemsCached() : [],
|
||||
);
|
||||
const currentItem = expandedItems[0];
|
||||
|
||||
if (!currentItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -21,7 +30,11 @@ export const ExpandedListItem = ({ item, itemType }: ExpandedListItemProps) => {
|
||||
<div className={styles.container}>
|
||||
<div className={styles.inner}>
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<SelectedItem item={item} itemType={itemType} />
|
||||
<SelectedItem
|
||||
internalState={internalState}
|
||||
item={currentItem as ItemListStateItem}
|
||||
itemType={itemType}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,14 +42,15 @@ export const ExpandedListItem = ({ item, itemType }: ExpandedListItemProps) => {
|
||||
};
|
||||
|
||||
interface SelectedItemProps {
|
||||
internalState: ItemListStateActions;
|
||||
item: ItemListStateItem;
|
||||
itemType: LibraryItem;
|
||||
}
|
||||
|
||||
const SelectedItem = ({ item, itemType }: SelectedItemProps) => {
|
||||
const SelectedItem = ({ internalState, item, itemType }: SelectedItemProps) => {
|
||||
switch (itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
return <ExpandedAlbumListItem item={item} />;
|
||||
return <ExpandedAlbumListItem internalState={internalState} item={item} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { ContextMenuController } from '/@/renderer/features/context-menu/context
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';
|
||||
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
|
||||
import { useAppStore } from '/@/renderer/store';
|
||||
import { LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
|
||||
import { Play, TableColumn } from '/@/shared/types/types';
|
||||
|
||||
@@ -278,27 +277,19 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
||||
}
|
||||
},
|
||||
|
||||
onExpand: ({ item, itemType }: DefaultItemControlProps) => {
|
||||
if (!item) return;
|
||||
|
||||
const itemListItem = item as ItemListStateItemWithRequiredProperties;
|
||||
const setGlobalExpanded = useAppStore.getState().actions.setGlobalExpanded;
|
||||
const globalExpanded = useAppStore.getState().globalExpanded;
|
||||
|
||||
if (globalExpanded?.item?.id === item.id) {
|
||||
setGlobalExpanded(null);
|
||||
} else {
|
||||
const itemForStore: ItemListStateItemWithRequiredProperties & {
|
||||
imageId: null | string;
|
||||
} = {
|
||||
...itemListItem,
|
||||
imageId: (itemListItem as { imageId?: null | string }).imageId ?? null,
|
||||
};
|
||||
setGlobalExpanded({
|
||||
item: itemForStore,
|
||||
itemType,
|
||||
});
|
||||
onExpand: ({ internalState, item }: DefaultItemControlProps) => {
|
||||
if (!item || !internalState) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract rowId from the item
|
||||
const rowId = internalState.extractRowId(item);
|
||||
if (!rowId) return;
|
||||
|
||||
// Use the item directly (rowId is separate, used only as key in state)
|
||||
const itemListItem = item as ItemListStateItemWithRequiredProperties;
|
||||
|
||||
return internalState?.toggleExpanded(itemListItem);
|
||||
},
|
||||
|
||||
onFavorite: ({
|
||||
|
||||
@@ -196,17 +196,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.image-wrapper-outer {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.image-wrapper-outer.image-wrapper-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
@@ -32,17 +32,14 @@ import styles from './item-detail-list.module.css';
|
||||
|
||||
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
|
||||
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
||||
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
|
||||
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
|
||||
import {
|
||||
ItemListStateActions,
|
||||
ItemListStateItemWithRequiredProperties,
|
||||
useItemDraggingState,
|
||||
useItemListState,
|
||||
useItemSelectionState,
|
||||
} from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||
import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns';
|
||||
import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys';
|
||||
import { getDetailListCellComponent } from '/@/renderer/components/item-list/item-detail-list/columns';
|
||||
import {
|
||||
getTrackColumnFixed,
|
||||
@@ -64,7 +61,6 @@ import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
||||
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
||||
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useSettingsStore, useShowRatings } from '/@/renderer/store';
|
||||
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils';
|
||||
@@ -72,8 +68,6 @@ import { SEPARATOR_STRING } from '/@/shared/api/utils';
|
||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
||||
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
|
||||
import { useDoubleClick } from '/@/shared/hooks/use-double-click';
|
||||
import { useFocusWithin } from '/@/shared/hooks/use-focus-within';
|
||||
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
|
||||
import { Album, LibraryItem, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
|
||||
import { ItemListKey, Play, TableColumn } from '/@/shared/types/types';
|
||||
@@ -425,61 +419,6 @@ const MetadataSection = memo(
|
||||
const [isImageHovered, setIsImageHovered] = useState(false);
|
||||
const [isMetadataHovered, setIsMetadataHovered] = useState(false);
|
||||
|
||||
const getId = useCallback(() => {
|
||||
const draggedItems = getDraggedItems(item, internalState, false);
|
||||
return draggedItems.map((i) => i.id);
|
||||
}, [item, internalState]);
|
||||
|
||||
const getItem = useCallback(() => {
|
||||
return getDraggedItems(item, internalState, false);
|
||||
}, [item, internalState]);
|
||||
|
||||
const onDragStart = useCallback(() => {
|
||||
const draggedItems = getDraggedItems(item, internalState, false);
|
||||
internalState?.setDragging(draggedItems);
|
||||
}, [item, internalState]);
|
||||
|
||||
const onDrop = useCallback(() => {
|
||||
internalState?.setDragging([]);
|
||||
}, [internalState]);
|
||||
|
||||
const drag = useMemo(() => {
|
||||
const playlistSongs = (item as { _playlistSongs?: Song[] })._playlistSongs;
|
||||
if (playlistSongs && playlistSongs.length > 0) {
|
||||
return {
|
||||
getId,
|
||||
getItem: () => playlistSongs,
|
||||
itemType: LibraryItem.SONG,
|
||||
onDragStart,
|
||||
onDrop,
|
||||
operation: [DragOperation.ADD],
|
||||
target: DragTarget.SONG,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getId,
|
||||
getItem,
|
||||
itemType: item._itemType,
|
||||
onDragStart,
|
||||
onDrop,
|
||||
operation: [DragOperation.ADD],
|
||||
target: DragTarget.ALBUM,
|
||||
};
|
||||
}, [getId, getItem, item, onDragStart, onDrop]);
|
||||
|
||||
const { isDragging: isDraggingLocal, ref: dragRef } = useDragDrop<HTMLDivElement>({
|
||||
drag,
|
||||
isEnabled: !!item,
|
||||
});
|
||||
const isDraggingState = useItemDraggingState(internalState, item.id);
|
||||
const isDragging = isDraggingState || isDraggingLocal;
|
||||
|
||||
const handleLinkDragStart = useCallback((e: React.DragEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const isFavorite = item.userFavorite ?? false;
|
||||
const userRating = item.userRating ?? null;
|
||||
const hasRating = showRatings && userRating !== null && userRating > 0;
|
||||
@@ -541,48 +480,39 @@ const MetadataSection = memo(
|
||||
onMouseEnter={() => setIsMetadataHovered(true)}
|
||||
onMouseLeave={() => setIsMetadataHovered(false)}
|
||||
>
|
||||
<div
|
||||
className={clsx(styles.imageWrapperOuter, {
|
||||
[styles.imageWrapperDragging]: isDragging,
|
||||
<Link
|
||||
className={styles.imageWrapper}
|
||||
onMouseEnter={() => setIsImageHovered(true)}
|
||||
onMouseLeave={() => setIsImageHovered(false)}
|
||||
state={{ item }}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: item.id,
|
||||
})}
|
||||
ref={dragRef ?? undefined}
|
||||
>
|
||||
<Link
|
||||
className={styles.imageWrapper}
|
||||
draggable={false}
|
||||
onDragStart={handleLinkDragStart}
|
||||
onMouseEnter={() => setIsImageHovered(true)}
|
||||
onMouseLeave={() => setIsImageHovered(false)}
|
||||
state={{ item }}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: item.id,
|
||||
})}
|
||||
>
|
||||
<ItemImage
|
||||
className={styles.image}
|
||||
explicitStatus={item.explicitStatus}
|
||||
id={item.imageId}
|
||||
itemType={item._itemType}
|
||||
serverId={item._serverId}
|
||||
type="itemCard"
|
||||
/>
|
||||
{isFavorite && <div className={styles.favoriteBadge} />}
|
||||
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
||||
<AnimatePresence>
|
||||
{controls && isImageHovered && (
|
||||
<ItemCardControls
|
||||
controls={controls}
|
||||
enableExpansion={false}
|
||||
internalState={internalState}
|
||||
item={item}
|
||||
itemType={item._itemType}
|
||||
showRating={true}
|
||||
type="compact"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Link>
|
||||
</div>
|
||||
<ItemImage
|
||||
className={styles.image}
|
||||
explicitStatus={item.explicitStatus}
|
||||
id={item.imageId}
|
||||
itemType={item._itemType}
|
||||
serverId={item._serverId}
|
||||
type="itemCard"
|
||||
/>
|
||||
{isFavorite && <div className={styles.favoriteBadge} />}
|
||||
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
||||
<AnimatePresence>
|
||||
{controls && isImageHovered && (
|
||||
<ItemCardControls
|
||||
controls={controls}
|
||||
enableExpansion={false}
|
||||
internalState={internalState}
|
||||
item={item}
|
||||
itemType={item._itemType}
|
||||
showRating={true}
|
||||
type="compact"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Link>
|
||||
<Link
|
||||
className={styles.title}
|
||||
state={{ item }}
|
||||
@@ -1256,8 +1186,6 @@ export const ItemDetailList = ({
|
||||
}: ItemDetailListProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useListRef(null);
|
||||
const { focused, ref: focusRef } = useFocusWithin();
|
||||
const mergedContainerRef = useMergedRef(containerRef, focusRef);
|
||||
const lastVisibleStartIndexRef = useRef(0);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -1449,13 +1377,6 @@ export const ItemDetailList = ({
|
||||
},
|
||||
});
|
||||
|
||||
useListHotkeys({
|
||||
controls,
|
||||
focused,
|
||||
internalState,
|
||||
itemType: LibraryItem.SONG,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const { current: container } = containerRef;
|
||||
|
||||
@@ -1512,7 +1433,7 @@ export const ItemDetailList = ({
|
||||
trackTableSize={trackTableSize}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.container} ref={mergedContainerRef}>
|
||||
<div className={styles.container} ref={containerRef}>
|
||||
<List
|
||||
listRef={listRef}
|
||||
onRowsRendered={throttledHandleRowsRendered}
|
||||
|
||||
@@ -60,6 +60,6 @@ export function shouldShowHoverOnlyColumnContent(
|
||||
return (
|
||||
isRowHovered ||
|
||||
(columnId === TableColumn.USER_FAVORITE && song.userFavorite !== false) ||
|
||||
(columnId === TableColumn.USER_RATING && song.userRating !== null && song.userRating !== 0)
|
||||
(columnId === TableColumn.USER_RATING && song.userRating != null)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import clsx from 'clsx';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { motion } from 'motion/react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
import React, {
|
||||
CSSProperties,
|
||||
@@ -31,12 +31,15 @@ import {
|
||||
ItemCard,
|
||||
ItemCardProps,
|
||||
} from '/@/renderer/components/item-card/item-card';
|
||||
import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container';
|
||||
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
|
||||
import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';
|
||||
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
|
||||
import {
|
||||
ItemListStateActions,
|
||||
ItemListStateItemWithRequiredProperties,
|
||||
useItemListState,
|
||||
useItemListStateSubscription,
|
||||
} from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||
import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys';
|
||||
import { ItemControls, ItemListHandle } from '/@/renderer/components/item-list/types';
|
||||
@@ -826,6 +829,10 @@ const BaseItemGridList = ({
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
<AnimatePresence presenceAffectsLayout>
|
||||
<ExpandedContainer internalState={internalState} itemType={itemType} />
|
||||
{/* {enableSelectionDialog && <SelectionDialog internalState={internalState} />} */}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -896,3 +903,25 @@ const ListComponent = memo((props: ListChildComponentProps<GridItemProps>) => {
|
||||
export const ItemGridList = memo(BaseItemGridList);
|
||||
|
||||
ItemGridList.displayName = 'ItemGridList';
|
||||
|
||||
const ExpandedContainer = ({
|
||||
internalState,
|
||||
itemType,
|
||||
}: {
|
||||
internalState: ItemListStateActions;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
const hasExpanded = useItemListStateSubscription(internalState, (state) =>
|
||||
state ? state.expanded.size > 0 : false,
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{hasExpanded && (
|
||||
<ExpandedListContainer>
|
||||
<ExpandedListItem internalState={internalState} itemType={itemType} />
|
||||
</ExpandedListContainer>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -62,7 +62,6 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
|
||||
icon="arrowDownS"
|
||||
iconProps={{ color: 'muted', size: 'md' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const item = (props.getRowItem?.(rowIndex) ??
|
||||
data[rowIndex]) as ItemListItem;
|
||||
const rowId = internalState.extractRowId(item);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Component adapted from https://github.com/bvaughn/react-window/issues/826
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { motion } from 'motion/react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import React, {
|
||||
type JSXElementConstructor,
|
||||
memo,
|
||||
@@ -18,12 +18,15 @@ import { type CellComponentProps, Grid } from 'react-window-v2';
|
||||
|
||||
import styles from './item-table-list.module.css';
|
||||
|
||||
import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container';
|
||||
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
|
||||
import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';
|
||||
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
|
||||
import {
|
||||
ItemListStateActions,
|
||||
ItemListStateItemWithRequiredProperties,
|
||||
useItemListState,
|
||||
useItemListStateSubscription,
|
||||
} from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||
import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns';
|
||||
import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys';
|
||||
@@ -1648,6 +1651,8 @@ const BaseItemTableList = ({
|
||||
totalColumnCount={totalColumnCount}
|
||||
totalRowCount={totalRowCount}
|
||||
/>
|
||||
<ExpandedContainer internalState={internalState} itemType={itemType} />
|
||||
{/* {enableSelectionDialog && <SelectionDialog internalState={internalState} />} */}
|
||||
</motion.div>
|
||||
</ItemTableListConfigProvider>
|
||||
</ItemTableListStoreProvider>
|
||||
@@ -1656,4 +1661,26 @@ const BaseItemTableList = ({
|
||||
|
||||
export const ItemTableList = memo(BaseItemTableList);
|
||||
|
||||
const ExpandedContainer = ({
|
||||
internalState,
|
||||
itemType,
|
||||
}: {
|
||||
internalState: ItemListStateActions;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
const hasExpanded = useItemListStateSubscription(internalState, (state) =>
|
||||
state ? state.expanded.size > 0 : false,
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{hasExpanded && (
|
||||
<ExpandedListContainer>
|
||||
<ExpandedListItem internalState={internalState} itemType={itemType} />
|
||||
</ExpandedListContainer>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
ItemTableList.displayName = 'ItemTableList';
|
||||
|
||||
@@ -196,7 +196,7 @@ export const QueryBuilder = ({
|
||||
filters={filters}
|
||||
groupIndex={groupIndex || []}
|
||||
level={level}
|
||||
noRemove={false}
|
||||
noRemove={data?.rules?.length === 1}
|
||||
onChangeField={onChangeField}
|
||||
onChangeOperator={onChangeOperator}
|
||||
onChangeValue={onChangeValue}
|
||||
|
||||
@@ -21,10 +21,7 @@ import { ItemControls } from '/@/renderer/components/item-list/types';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import {
|
||||
ListConfigMenu,
|
||||
SONG_DISPLAY_TYPES,
|
||||
} from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import {
|
||||
CLIENT_SIDE_SONG_FILTERS,
|
||||
ListSortByDropdownControlled,
|
||||
@@ -758,10 +755,7 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
|
||||
sortOrder={sortOrder}
|
||||
/>
|
||||
<ListConfigMenu
|
||||
displayTypes={[
|
||||
{ hidden: true, value: ListDisplayType.GRID },
|
||||
...SONG_DISPLAY_TYPES,
|
||||
]}
|
||||
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
|
||||
listKey={ItemListKey.ALBUM_DETAIL}
|
||||
optionsConfig={{
|
||||
table: {
|
||||
|
||||
@@ -20,6 +20,7 @@ export function AlbumGridCarousel(props: AlbumGridCarouselProps) {
|
||||
const controls = useDefaultItemListControls();
|
||||
|
||||
const cards = useMemo(() => {
|
||||
// Filter out excluded IDs if provided
|
||||
const filteredItems = excludeIds
|
||||
? data.filter((album) => !excludeIds.includes(album.id))
|
||||
: data;
|
||||
@@ -30,7 +31,6 @@ export function AlbumGridCarousel(props: AlbumGridCarouselProps) {
|
||||
controls={controls}
|
||||
data={album}
|
||||
enableDrag
|
||||
enableExpansion
|
||||
itemType={LibraryItem.ALBUM}
|
||||
rows={rows}
|
||||
type="poster"
|
||||
|
||||
@@ -58,6 +58,7 @@ const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps & { rows: DataRow[]
|
||||
const controls = useDefaultItemListControls();
|
||||
|
||||
const cards = useMemo(() => {
|
||||
// Flatten all pages and filter excluded IDs
|
||||
const allItems = albums?.pages.flatMap((page: AlbumListResponse) => page.items) || [];
|
||||
const filteredItems = excludeIds
|
||||
? allItems.filter((album) => !excludeIds.includes(album.id))
|
||||
@@ -69,7 +70,6 @@ const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps & { rows: DataRow[]
|
||||
controls={controls}
|
||||
data={album}
|
||||
enableDrag
|
||||
enableExpansion
|
||||
itemType={LibraryItem.ALBUM}
|
||||
rows={rows}
|
||||
type="poster"
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpe
|
||||
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
||||
import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list';
|
||||
import { ItemListComponentProps } from '/@/renderer/components/item-list/types';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import {
|
||||
AlbumListQuery,
|
||||
@@ -35,7 +34,6 @@ export const AlbumListInfiniteDetail = ({
|
||||
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
||||
|
||||
const listQueryFn = api.controller.getAlbumList;
|
||||
const { pageKey } = useListContext();
|
||||
|
||||
const { handleColumnReordered } = useItemListColumnReorder({
|
||||
itemListKey: ItemListKey.ALBUM,
|
||||
@@ -48,7 +46,7 @@ export const AlbumListInfiniteDetail = ({
|
||||
});
|
||||
|
||||
const { getItem, itemCount, loadedItems, onRangeChanged } = useItemListInfiniteLoader({
|
||||
eventKey: pageKey || ItemListKey.ALBUM,
|
||||
eventKey: ItemListKey.ALBUM,
|
||||
itemsPerPage,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
listCountQuery,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-r
|
||||
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
|
||||
import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';
|
||||
import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import { useGeneralSettings } from '/@/renderer/store';
|
||||
import {
|
||||
@@ -38,11 +37,9 @@ export const AlbumListInfiniteGrid = ({
|
||||
|
||||
const listQueryFn = api.controller.getAlbumList;
|
||||
|
||||
const { pageKey } = useListContext();
|
||||
|
||||
const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =
|
||||
useItemListInfiniteLoader({
|
||||
eventKey: pageKey || ItemListKey.ALBUM,
|
||||
eventKey: ItemListKey.ALBUM,
|
||||
itemsPerPage,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
listCountQuery,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpe
|
||||
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
|
||||
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import {
|
||||
AlbumListQuery,
|
||||
@@ -44,11 +43,10 @@ export const AlbumListInfiniteTable = ({
|
||||
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
||||
|
||||
const listQueryFn = api.controller.getAlbumList;
|
||||
const { pageKey } = useListContext();
|
||||
|
||||
const { getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =
|
||||
useItemListInfiniteLoader({
|
||||
eventKey: pageKey || ItemListKey.ALBUM,
|
||||
eventKey: ItemListKey.ALBUM,
|
||||
itemsPerPage,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
listCountQuery,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-lis
|
||||
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
|
||||
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
|
||||
import { ItemListComponentProps } from '/@/renderer/components/item-list/types';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import {
|
||||
AlbumListQuery,
|
||||
@@ -37,7 +36,6 @@ export const AlbumListPaginatedDetail = ({
|
||||
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
||||
|
||||
const listQueryFn = api.controller.getAlbumList;
|
||||
const { pageKey } = useListContext();
|
||||
|
||||
const { handleColumnReordered } = useItemListColumnReorder({
|
||||
itemListKey: ItemListKey.ALBUM,
|
||||
@@ -53,7 +51,7 @@ export const AlbumListPaginatedDetail = ({
|
||||
|
||||
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
|
||||
currentPage,
|
||||
eventKey: pageKey || ItemListKey.ALBUM,
|
||||
eventKey: ItemListKey.ALBUM,
|
||||
itemsPerPage,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
listCountQuery,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/it
|
||||
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
|
||||
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
|
||||
import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import { useGeneralSettings } from '/@/renderer/store';
|
||||
import {
|
||||
@@ -33,7 +32,6 @@ export const AlbumListPaginatedGrid = ({
|
||||
serverId,
|
||||
size,
|
||||
}: AlbumListPaginatedGridProps) => {
|
||||
const { pageKey } = useListContext();
|
||||
const { currentPage, onChange } = useItemListPagination();
|
||||
|
||||
const listCountQuery = albumQueries.listCount({
|
||||
@@ -45,7 +43,7 @@ export const AlbumListPaginatedGrid = ({
|
||||
|
||||
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
|
||||
currentPage,
|
||||
eventKey: pageKey || ItemListKey.ALBUM,
|
||||
eventKey: ItemListKey.ALBUM,
|
||||
itemsPerPage,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
listCountQuery,
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useItemListPagination } from '/@/renderer/components/item-list/item-lis
|
||||
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
|
||||
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import {
|
||||
AlbumListQuery,
|
||||
@@ -40,7 +39,6 @@ export const AlbumListPaginatedTable = ({
|
||||
serverId,
|
||||
size = 'default',
|
||||
}: AlbumListPaginatedTableProps) => {
|
||||
const { pageKey } = useListContext();
|
||||
const { currentPage, onChange } = useItemListPagination();
|
||||
|
||||
const listCountQuery = albumQueries.listCount({
|
||||
@@ -52,7 +50,7 @@ export const AlbumListPaginatedTable = ({
|
||||
|
||||
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
|
||||
currentPage,
|
||||
eventKey: pageKey || ItemListKey.ALBUM,
|
||||
eventKey: ItemListKey.ALBUM,
|
||||
itemsPerPage,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
listCountQuery,
|
||||
|
||||
@@ -22,7 +22,6 @@ import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';
|
||||
import { useFastAverageColor } from '/@/renderer/hooks';
|
||||
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||
import { useSetGlobalExpanded } from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
@@ -31,24 +30,10 @@ import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { TextTitle } from '/@/shared/components/text-title/text-title';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
|
||||
import { LibraryItem, RelatedArtist, Song } from '/@/shared/types/domain-types';
|
||||
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||
import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
export interface ExpandedAlbumData {
|
||||
_serverId: string;
|
||||
albumArtists: RelatedArtist[];
|
||||
id: string;
|
||||
imageId: null | string;
|
||||
name: string;
|
||||
songs?: null | Song[];
|
||||
}
|
||||
|
||||
export interface ExpandedAlbumListItemProps {
|
||||
album?: ExpandedAlbumData;
|
||||
item?: ItemListStateItem;
|
||||
}
|
||||
|
||||
interface AlbumTracksTableProps {
|
||||
isDark?: boolean;
|
||||
serverId: string;
|
||||
@@ -61,6 +46,11 @@ interface AlbumTracksTableProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ExpandedAlbumListItemProps {
|
||||
internalState?: ItemListStateActions;
|
||||
item: ItemListStateItem;
|
||||
}
|
||||
|
||||
interface TrackRowProps {
|
||||
controls: ReturnType<typeof useDefaultItemListControls>;
|
||||
internalState: ItemListStateActions;
|
||||
@@ -70,23 +60,6 @@ interface TrackRowProps {
|
||||
songs: Song[];
|
||||
}
|
||||
|
||||
const CloseExpandedButton = () => {
|
||||
const setGlobalExpanded = useSetGlobalExpanded();
|
||||
return (
|
||||
<ActionIcon
|
||||
className={clsx(styles.closeButton)}
|
||||
icon="x"
|
||||
iconProps={{
|
||||
size: 'xl',
|
||||
}}
|
||||
onClick={() => setGlobalExpanded(null)}
|
||||
radius="50%"
|
||||
size="sm"
|
||||
variant="default"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const TrackRow = ({ controls, internalState, player, serverId, song, songs }: TrackRowProps) => {
|
||||
const rowId = internalState.extractRowId(song);
|
||||
const isSelected = useItemSelectionState(internalState, rowId);
|
||||
@@ -215,165 +188,136 @@ const AlbumTracksTable = ({ isDark, serverId, songs }: AlbumTracksTableProps) =>
|
||||
);
|
||||
};
|
||||
|
||||
interface ExpandedAlbumListItemContentProps {
|
||||
albumData: ExpandedAlbumData;
|
||||
}
|
||||
|
||||
const ExpandedAlbumListItemContent = ({ albumData }: ExpandedAlbumListItemContentProps) => {
|
||||
const player = usePlayer();
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: albumData.imageId || undefined,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
type: 'itemCard',
|
||||
});
|
||||
|
||||
const color = useFastAverageColor({
|
||||
algorithm: 'sqrt',
|
||||
id: albumData.id,
|
||||
src: imageUrl,
|
||||
srcLoaded: true,
|
||||
});
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(playType: Play) => {
|
||||
if (albumData.songs?.length) {
|
||||
player.addToQueueByData(albumData.songs, playType);
|
||||
}
|
||||
},
|
||||
[albumData.songs, player],
|
||||
);
|
||||
|
||||
if (color.isLoading) {
|
||||
return <Spinner container />;
|
||||
}
|
||||
|
||||
const songs = albumData.songs ?? null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className={styles.container}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
style={{ backgroundColor: color.background }}
|
||||
>
|
||||
<div className={styles.expanded}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerTitle}>
|
||||
<TextTitle
|
||||
className={clsx(styles.itemTitle, { [styles.dark]: color.isDark })}
|
||||
fw={700}
|
||||
order={4}
|
||||
>
|
||||
{albumData.name}
|
||||
</TextTitle>
|
||||
<CloseExpandedButton />
|
||||
</div>
|
||||
<Group
|
||||
className={clsx(styles.itemSubtitle, { [styles.dark]: color.isDark })}
|
||||
gap="xs"
|
||||
>
|
||||
{albumData.albumArtists?.map((artist, index) => (
|
||||
<Fragment key={artist.id}>
|
||||
<Text
|
||||
className={clsx(styles.itemSubtitle, {
|
||||
[styles.dark]: color.isDark,
|
||||
})}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
{index < (albumData.albumArtists?.length ?? 0) - 1 && (
|
||||
<Separator />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
<AlbumTracksTable
|
||||
isDark={color.isDark}
|
||||
serverId={albumData._serverId}
|
||||
songs={songs ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.imageContainer}>
|
||||
<div
|
||||
className={styles.backgroundImage}
|
||||
style={{
|
||||
['--bg-color' as string]: color?.background,
|
||||
backgroundImage: `url(${imageUrl})`,
|
||||
}}
|
||||
/>
|
||||
{songs && songs.length > 0 && (
|
||||
<div className={styles.playButtonGroup}>
|
||||
<PlayButtonGroup onPlay={handlePlay} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const ExpandedAlbumListItemWithFetch = ({ item }: { item: ItemListStateItem }) => {
|
||||
const { data } = useSuspenseQuery(
|
||||
export const ExpandedAlbumListItem = ({ internalState, item }: ExpandedAlbumListItemProps) => {
|
||||
const { data, isLoading } = useSuspenseQuery(
|
||||
albumQueries.detail({
|
||||
query: { id: item.id },
|
||||
serverId: item._serverId,
|
||||
}),
|
||||
);
|
||||
|
||||
const albumData: ExpandedAlbumData = {
|
||||
_serverId: item._serverId,
|
||||
albumArtists: data?.albumArtists ?? [],
|
||||
const player = usePlayer();
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: item.imageId || undefined,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
type: 'itemCard',
|
||||
});
|
||||
|
||||
const color = useFastAverageColor({
|
||||
algorithm: 'sqrt',
|
||||
id: item.id,
|
||||
imageId: item.imageId ?? data?.imageId ?? null,
|
||||
name: data?.name ?? '',
|
||||
songs: data?.songs ?? null,
|
||||
};
|
||||
src: imageUrl,
|
||||
srcLoaded: true,
|
||||
});
|
||||
|
||||
return <ExpandedAlbumListItemContent albumData={albumData} />;
|
||||
};
|
||||
const handlePlay = useCallback(
|
||||
(playType: Play) => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
function itemToExpandedAlbumData(
|
||||
item: ItemListStateItem & {
|
||||
_playlistSongs?: Song[];
|
||||
albumArtists?: RelatedArtist[];
|
||||
name?: string;
|
||||
},
|
||||
): ExpandedAlbumData | null {
|
||||
const songs =
|
||||
(item as { songs?: Song[] }).songs ?? (item as { _playlistSongs?: Song[] })._playlistSongs;
|
||||
if (songs == null) return null;
|
||||
return {
|
||||
_serverId: item._serverId,
|
||||
albumArtists: item.albumArtists ?? [],
|
||||
id: item.id,
|
||||
imageId: (item as { imageId?: null | string }).imageId ?? null,
|
||||
name: (item as { name?: string }).name ?? '',
|
||||
songs,
|
||||
};
|
||||
}
|
||||
if (data.songs) {
|
||||
player.addToQueueByData(data.songs, playType);
|
||||
}
|
||||
},
|
||||
[data, player],
|
||||
);
|
||||
|
||||
export const ExpandedAlbumListItem = (props: ExpandedAlbumListItemProps) => {
|
||||
if (props.album != null) {
|
||||
return <ExpandedAlbumListItemContent albumData={props.album} />;
|
||||
if (color.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (props.item != null) {
|
||||
const albumData = itemToExpandedAlbumData(props.item);
|
||||
|
||||
if (albumData != null) {
|
||||
return <ExpandedAlbumListItemContent albumData={albumData} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<ExpandedAlbumListItemWithFetch item={props.item} />
|
||||
return (
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
className={styles.container}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
style={{ backgroundColor: color.background }}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className={styles.loading}>
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
<Suspense>
|
||||
<div className={styles.expanded}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerTitle}>
|
||||
<TextTitle
|
||||
className={clsx(styles.itemTitle, {
|
||||
[styles.dark]: color.isDark,
|
||||
})}
|
||||
fw={700}
|
||||
order={4}
|
||||
>
|
||||
{data?.name}
|
||||
</TextTitle>
|
||||
{internalState && (
|
||||
<ActionIcon
|
||||
className={clsx(styles.closeButton)}
|
||||
icon="x"
|
||||
iconProps={{
|
||||
size: 'xl',
|
||||
}}
|
||||
onClick={() => {
|
||||
const rowId = internalState.extractRowId(item);
|
||||
if (rowId) {
|
||||
internalState.clearExpanded();
|
||||
}
|
||||
}}
|
||||
radius="50%"
|
||||
size="sm"
|
||||
variant="default"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Group
|
||||
className={clsx(styles.itemSubtitle, {
|
||||
[styles.dark]: color.isDark,
|
||||
})}
|
||||
gap="xs"
|
||||
>
|
||||
{data?.albumArtists.map((artist, index) => (
|
||||
<Fragment key={artist.id}>
|
||||
<Text
|
||||
className={clsx(styles.itemSubtitle, {
|
||||
[styles.dark]: color.isDark,
|
||||
})}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
{index < data?.albumArtists.length - 1 && <Separator />}
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
<AlbumTracksTable
|
||||
isDark={color.isDark}
|
||||
serverId={item._serverId}
|
||||
songs={data?.songs}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.imageContainer}>
|
||||
<div
|
||||
className={styles.backgroundImage}
|
||||
style={{
|
||||
['--bg-color' as string]: color?.background,
|
||||
backgroundImage: `url(${imageUrl})`,
|
||||
}}
|
||||
/>
|
||||
{data?.songs && data.songs.length > 0 && (
|
||||
<div className={styles.playButtonGroup}>
|
||||
<PlayButtonGroup onPlay={handlePlay} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,10 +25,7 @@ import { ItemControls } from '/@/renderer/components/item-list/types';
|
||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||
import { AlbumArtistGridCarousel } from '/@/renderer/features/artists/components/album-artist-grid-carousel';
|
||||
import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import {
|
||||
ListConfigMenu,
|
||||
SONG_DISPLAY_TYPES,
|
||||
} from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import {
|
||||
CLIENT_SIDE_ALBUM_FILTERS,
|
||||
ListSortByDropdownControlled,
|
||||
@@ -228,39 +225,6 @@ const AlbumArtistMetadataBiography = ({
|
||||
);
|
||||
};
|
||||
|
||||
const TABLE_ROW_HEIGHT = {
|
||||
compact: 40,
|
||||
default: 64,
|
||||
large: 88,
|
||||
} as const;
|
||||
|
||||
const TABLE_HEADER_HEIGHT = 40;
|
||||
|
||||
interface SongTableListContainerProps {
|
||||
children: React.ReactNode;
|
||||
enableHeader?: boolean;
|
||||
itemCount: number;
|
||||
maxRows?: number;
|
||||
tableSize?: 'compact' | 'default' | 'large';
|
||||
}
|
||||
|
||||
function getTableRowHeight(size: 'compact' | 'default' | 'large' | undefined): number {
|
||||
return size ? TABLE_ROW_HEIGHT[size] : TABLE_ROW_HEIGHT.default;
|
||||
}
|
||||
|
||||
const SongTableListContainer = ({
|
||||
children,
|
||||
enableHeader = true,
|
||||
itemCount,
|
||||
maxRows = 5,
|
||||
tableSize = 'default',
|
||||
}: SongTableListContainerProps) => {
|
||||
const rowHeight = getTableRowHeight(tableSize);
|
||||
const headerOffset = enableHeader ? TABLE_HEADER_HEIGHT : 0;
|
||||
const height = headerOffset + rowHeight * Math.min(itemCount, maxRows);
|
||||
return <div style={{ height }}>{children}</div>;
|
||||
};
|
||||
|
||||
interface AlbumArtistMetadataTopSongsProps {
|
||||
detailQuery: ReturnType<typeof useSuspenseQuery<AlbumArtistDetailResponse>>;
|
||||
routeId: string;
|
||||
@@ -273,6 +237,7 @@ const AlbumArtistMetadataTopSongsContent = ({
|
||||
const { t } = useTranslation();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [topSongsQueryType, setTopSongsQueryType] = useLocalStorage<'community' | 'personal'>({
|
||||
defaultValue: 'community',
|
||||
key: 'album-artist-top-songs-query-type',
|
||||
@@ -304,8 +269,13 @@ const AlbumArtistMetadataTopSongsContent = ({
|
||||
}, [tableConfig?.columns]);
|
||||
|
||||
const filteredSongs = useMemo(() => {
|
||||
return searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG);
|
||||
}, [songs, debouncedSearchTerm]);
|
||||
const filtered = searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG);
|
||||
// When searching, show all results. Otherwise, limit to 5 if not showing all
|
||||
if (debouncedSearchTerm?.trim() || showAll) {
|
||||
return filtered;
|
||||
}
|
||||
return filtered.slice(0, 5);
|
||||
}, [songs, debouncedSearchTerm, showAll]);
|
||||
|
||||
const { handleColumnReordered } = useItemListColumnReorder({
|
||||
itemListKey: ItemListKey.SONG,
|
||||
@@ -478,10 +448,7 @@ const AlbumArtistMetadataTopSongsContent = ({
|
||||
value={topSongsQueryType}
|
||||
/>
|
||||
<ListConfigMenu
|
||||
displayTypes={[
|
||||
{ hidden: true, value: ListDisplayType.GRID },
|
||||
...SONG_DISPLAY_TYPES,
|
||||
]}
|
||||
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
|
||||
listKey={ItemListKey.SONG}
|
||||
optionsConfig={{
|
||||
table: {
|
||||
@@ -492,35 +459,35 @@ const AlbumArtistMetadataTopSongsContent = ({
|
||||
tableColumnsData={SONG_TABLE_COLUMNS}
|
||||
/>
|
||||
</Group>
|
||||
<SongTableListContainer
|
||||
<ItemTableList
|
||||
activeRowId={currentSongId}
|
||||
autoFitColumns={tableConfig.autoFitColumns}
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={columns}
|
||||
data={filteredSongs}
|
||||
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
|
||||
enableDrag
|
||||
enableDragScroll={false}
|
||||
enableExpansion={false}
|
||||
enableHeader={tableConfig.enableHeader}
|
||||
itemCount={filteredSongs.length}
|
||||
maxRows={5}
|
||||
tableSize={tableConfig.size}
|
||||
>
|
||||
<ItemTableList
|
||||
activeRowId={currentSongId}
|
||||
autoFitColumns={tableConfig.autoFitColumns}
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={columns}
|
||||
data={filteredSongs}
|
||||
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
|
||||
enableDrag
|
||||
enableDragScroll={false}
|
||||
enableExpansion={false}
|
||||
enableHeader={tableConfig.enableHeader}
|
||||
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
|
||||
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
|
||||
enableSelection
|
||||
enableSelectionDialog={false}
|
||||
enableVerticalBorders={tableConfig.enableVerticalBorders}
|
||||
itemType={LibraryItem.SONG}
|
||||
onColumnReordered={handleColumnReordered}
|
||||
onColumnResized={handleColumnResized}
|
||||
overrideControls={overrideControls}
|
||||
size={tableConfig.size}
|
||||
/>
|
||||
</SongTableListContainer>
|
||||
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
|
||||
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
|
||||
enableSelection
|
||||
enableSelectionDialog={false}
|
||||
enableVerticalBorders={tableConfig.enableVerticalBorders}
|
||||
itemType={LibraryItem.SONG}
|
||||
onColumnReordered={handleColumnReordered}
|
||||
onColumnResized={handleColumnResized}
|
||||
overrideControls={overrideControls}
|
||||
size={tableConfig.size}
|
||||
/>
|
||||
{!searchTerm.trim() && songs.length > 5 && !showAll && (
|
||||
<Group justify="center" w="100%">
|
||||
<Button onClick={() => setShowAll(true)} variant="subtle">
|
||||
{t('action.viewMore', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
@@ -556,6 +523,7 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori
|
||||
const { t } = useTranslation();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table);
|
||||
const currentSong = usePlayerSong();
|
||||
const player = usePlayer();
|
||||
@@ -580,8 +548,13 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori
|
||||
}, [tableConfig?.columns]);
|
||||
|
||||
const filteredSongs = useMemo(() => {
|
||||
return searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG);
|
||||
}, [songs, debouncedSearchTerm]);
|
||||
const filtered = searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG);
|
||||
// When searching, show all results. Otherwise, limit to 5 if not showing all
|
||||
if (debouncedSearchTerm?.trim() || showAll) {
|
||||
return filtered;
|
||||
}
|
||||
return filtered.slice(0, 5);
|
||||
}, [songs, debouncedSearchTerm, showAll]);
|
||||
|
||||
const { handleColumnReordered } = useItemListColumnReorder({
|
||||
itemListKey: ItemListKey.SONG,
|
||||
@@ -733,10 +706,7 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori
|
||||
value={searchTerm}
|
||||
/>
|
||||
<ListConfigMenu
|
||||
displayTypes={[
|
||||
{ hidden: true, value: ListDisplayType.GRID },
|
||||
...SONG_DISPLAY_TYPES,
|
||||
]}
|
||||
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
|
||||
listKey={ItemListKey.SONG}
|
||||
optionsConfig={{
|
||||
table: {
|
||||
@@ -747,35 +717,35 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori
|
||||
tableColumnsData={SONG_TABLE_COLUMNS}
|
||||
/>
|
||||
</Group>
|
||||
<SongTableListContainer
|
||||
<ItemTableList
|
||||
activeRowId={currentSongId}
|
||||
autoFitColumns={tableConfig.autoFitColumns}
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={columns}
|
||||
data={filteredSongs}
|
||||
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
|
||||
enableDrag
|
||||
enableDragScroll={false}
|
||||
enableExpansion={false}
|
||||
enableHeader={tableConfig.enableHeader}
|
||||
itemCount={filteredSongs.length}
|
||||
maxRows={5}
|
||||
tableSize={tableConfig.size}
|
||||
>
|
||||
<ItemTableList
|
||||
activeRowId={currentSongId}
|
||||
autoFitColumns={tableConfig.autoFitColumns}
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={columns}
|
||||
data={filteredSongs}
|
||||
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
|
||||
enableDrag
|
||||
enableDragScroll={false}
|
||||
enableExpansion={false}
|
||||
enableHeader={tableConfig.enableHeader}
|
||||
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
|
||||
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
|
||||
enableSelection
|
||||
enableSelectionDialog={false}
|
||||
enableVerticalBorders={tableConfig.enableVerticalBorders}
|
||||
itemType={LibraryItem.SONG}
|
||||
onColumnReordered={handleColumnReordered}
|
||||
onColumnResized={handleColumnResized}
|
||||
overrideControls={overrideControls}
|
||||
size={tableConfig.size}
|
||||
/>
|
||||
</SongTableListContainer>
|
||||
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
|
||||
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
|
||||
enableSelection
|
||||
enableSelectionDialog={false}
|
||||
enableVerticalBorders={tableConfig.enableVerticalBorders}
|
||||
itemType={LibraryItem.SONG}
|
||||
onColumnReordered={handleColumnReordered}
|
||||
onColumnResized={handleColumnResized}
|
||||
overrideControls={overrideControls}
|
||||
size={tableConfig.size}
|
||||
/>
|
||||
{!searchTerm.trim() && songs.length > 5 && !showAll && (
|
||||
<Group justify="center" w="100%">
|
||||
<Button onClick={() => setShowAll(true)} variant="subtle">
|
||||
{t('action.viewMore', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
@@ -1076,7 +1046,6 @@ interface AlbumSectionProps {
|
||||
albums: Album[];
|
||||
controls: ItemControls;
|
||||
cq: ReturnType<typeof useContainerQuery>;
|
||||
enableExpansion?: boolean;
|
||||
releaseType: string;
|
||||
rows: DataRow[] | undefined;
|
||||
title: React.ReactNode | string;
|
||||
@@ -1096,15 +1065,7 @@ const getItemsPerRow = (cq: ReturnType<typeof useContainerQuery>) => {
|
||||
return 2;
|
||||
};
|
||||
|
||||
const AlbumSection = ({
|
||||
albums,
|
||||
controls,
|
||||
cq,
|
||||
enableExpansion,
|
||||
releaseType,
|
||||
rows,
|
||||
title,
|
||||
}: AlbumSectionProps) => {
|
||||
const AlbumSection = ({ albums, controls, cq, releaseType, rows, title }: AlbumSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const itemsPerRow = getItemsPerRow(cq);
|
||||
@@ -1229,7 +1190,6 @@ const AlbumSection = ({
|
||||
controls={controls}
|
||||
data={album}
|
||||
enableDrag
|
||||
enableExpansion={enableExpansion ?? true}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
rows={rows}
|
||||
type="poster"
|
||||
@@ -1407,6 +1367,7 @@ const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
|
||||
const routeId = (artistId || albumArtistId) as string;
|
||||
|
||||
const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM);
|
||||
const controls = useDefaultItemListControls();
|
||||
|
||||
const filteredAndSortedAlbums = useMemo(() => {
|
||||
const albums = albumsQuery.data?.items || [];
|
||||
@@ -1414,8 +1375,6 @@ const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
|
||||
return sortAlbumList(searched, sortBy, sortOrder);
|
||||
}, [albumsQuery.data?.items, debouncedSearchTerm, sortBy, sortOrder]);
|
||||
|
||||
const controls = useDefaultItemListControls();
|
||||
|
||||
const albumsByReleaseType = useMemo(() => {
|
||||
return groupAlbumsByReleaseType(filteredAndSortedAlbums, routeId, groupingType);
|
||||
}, [filteredAndSortedAlbums, routeId, groupingType]);
|
||||
@@ -1684,7 +1643,6 @@ const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
|
||||
albums={albums}
|
||||
controls={controls}
|
||||
cq={cq}
|
||||
enableExpansion
|
||||
key={releaseType}
|
||||
releaseType={releaseType}
|
||||
rows={rows}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function AlbumArtistGridCarousel(props: AlbumArtistGridCarouselProps) {
|
||||
const controls = useDefaultItemListControls();
|
||||
|
||||
const cards = useMemo(() => {
|
||||
// Filter out excluded IDs if provided
|
||||
const filteredItems = excludeIds
|
||||
? data.filter((albumArtist) => !excludeIds.includes(albumArtist.id))
|
||||
: data;
|
||||
|
||||
@@ -1,18 +1,63 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { lazy, Suspense, useEffect, useRef } from 'react';
|
||||
import { createCallable } from 'react-call';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { AlbumArtistContextMenu } from '/@/renderer/features/context-menu/menus/album-artist-context-menu';
|
||||
import { AlbumContextMenu } from '/@/renderer/features/context-menu/menus/album-context-menu';
|
||||
import { ArtistContextMenu } from '/@/renderer/features/context-menu/menus/artist-context-menu';
|
||||
import { FolderContextMenu } from '/@/renderer/features/context-menu/menus/folder-context-menu';
|
||||
import { GenreContextMenu } from '/@/renderer/features/context-menu/menus/genre-context-menu';
|
||||
import { PlaylistContextMenu } from '/@/renderer/features/context-menu/menus/playlist-context-menu';
|
||||
import { PlaylistSongContextMenu } from '/@/renderer/features/context-menu/menus/playlist-song-context-menu';
|
||||
import { QueueContextMenu } from '/@/renderer/features/context-menu/menus/queue-context-menu';
|
||||
import { SongContextMenu } from '/@/renderer/features/context-menu/menus/song-context-menu';
|
||||
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
|
||||
|
||||
const AlbumArtistContextMenu = lazy(() =>
|
||||
import('/@/renderer/features/context-menu/menus/album-artist-context-menu').then((module) => ({
|
||||
default: module.AlbumArtistContextMenu,
|
||||
})),
|
||||
);
|
||||
|
||||
const AlbumContextMenu = lazy(() =>
|
||||
import('/@/renderer/features/context-menu/menus/album-context-menu').then((module) => ({
|
||||
default: module.AlbumContextMenu,
|
||||
})),
|
||||
);
|
||||
|
||||
const ArtistContextMenu = lazy(() =>
|
||||
import('/@/renderer/features/context-menu/menus/artist-context-menu').then((module) => ({
|
||||
default: module.ArtistContextMenu,
|
||||
})),
|
||||
);
|
||||
|
||||
const FolderContextMenu = lazy(() =>
|
||||
import('/@/renderer/features/context-menu/menus/folder-context-menu').then((module) => ({
|
||||
default: module.FolderContextMenu,
|
||||
})),
|
||||
);
|
||||
|
||||
const GenreContextMenu = lazy(() =>
|
||||
import('/@/renderer/features/context-menu/menus/genre-context-menu').then((module) => ({
|
||||
default: module.GenreContextMenu,
|
||||
})),
|
||||
);
|
||||
|
||||
const PlaylistContextMenu = lazy(() =>
|
||||
import('/@/renderer/features/context-menu/menus/playlist-context-menu').then((module) => ({
|
||||
default: module.PlaylistContextMenu,
|
||||
})),
|
||||
);
|
||||
|
||||
const PlaylistSongContextMenu = lazy(() =>
|
||||
import('/@/renderer/features/context-menu/menus/playlist-song-context-menu').then((module) => ({
|
||||
default: module.PlaylistSongContextMenu,
|
||||
})),
|
||||
);
|
||||
|
||||
const QueueContextMenu = lazy(() =>
|
||||
import('/@/renderer/features/context-menu/menus/queue-context-menu').then((module) => ({
|
||||
default: module.QueueContextMenu,
|
||||
})),
|
||||
);
|
||||
|
||||
const SongContextMenu = lazy(() =>
|
||||
import('/@/renderer/features/context-menu/menus/song-context-menu').then((module) => ({
|
||||
default: module.SongContextMenu,
|
||||
})),
|
||||
);
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
@@ -80,15 +125,17 @@ export const ContextMenuController = createCallable<ContextMenuControllerProps,
|
||||
}}
|
||||
/>
|
||||
</ContextMenu.Target>
|
||||
{cmd.type === LibraryItem.QUEUE_SONG && <QueueContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.ALBUM && <AlbumContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.ALBUM_ARTIST && <AlbumArtistContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.ARTIST && <ArtistContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.FOLDER && <FolderContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.GENRE && <GenreContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.PLAYLIST && <PlaylistContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.PLAYLIST_SONG && <PlaylistSongContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.SONG && <SongContextMenu {...cmd} />}
|
||||
<Suspense fallback={null}>
|
||||
{cmd.type === LibraryItem.QUEUE_SONG && <QueueContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.ALBUM && <AlbumContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.ALBUM_ARTIST && <AlbumArtistContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.ARTIST && <ArtistContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.FOLDER && <FolderContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.GENRE && <GenreContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.PLAYLIST && <PlaylistContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.PLAYLIST_SONG && <PlaylistSongContextMenu {...cmd} />}
|
||||
{cmd.type === LibraryItem.SONG && <SongContextMenu {...cmd} />}
|
||||
</Suspense>
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -109,18 +109,8 @@ export const useDiscordRpc = () => {
|
||||
instance: false,
|
||||
largeImageKey: 'icon',
|
||||
largeImageText: truncate(stationName || 'Radio'),
|
||||
smallImageKey:
|
||||
current[2] === PlayerStatus.PLAYING
|
||||
? discordSettings.showStateIcon
|
||||
? 'playing'
|
||||
: undefined
|
||||
: 'paused',
|
||||
smallImageText:
|
||||
current[2] === PlayerStatus.PLAYING
|
||||
? discordSettings.showStateIcon
|
||||
? sentenceCase(current[2])
|
||||
: undefined
|
||||
: sentenceCase(current[2]),
|
||||
smallImageKey: current[2] === PlayerStatus.PLAYING ? 'playing' : 'paused',
|
||||
smallImageText: sentenceCase(current[2]),
|
||||
state: truncate(artist),
|
||||
statusDisplayType: StatusDisplayType.STATE,
|
||||
type: discordSettings.showAsListening ? 2 : 0,
|
||||
@@ -209,7 +199,7 @@ export const useDiscordRpc = () => {
|
||||
(song?.album && song.album.padEnd(2, ' ')) || 'Unknown album',
|
||||
),
|
||||
smallImageKey: undefined,
|
||||
smallImageText: undefined,
|
||||
smallImageText: sentenceCase(current[2]),
|
||||
state: truncate((artists && artists.padEnd(2, ' ')) || 'Unknown artist'),
|
||||
statusDisplayType: statusDisplayMap[discordSettings.displayType],
|
||||
// I would love to use the actual type as opposed to hardcoding to 2,
|
||||
@@ -257,13 +247,9 @@ export const useDiscordRpc = () => {
|
||||
activity.endTimestamp = end;
|
||||
}
|
||||
|
||||
if (discordSettings.showStateIcon) {
|
||||
activity.smallImageKey = 'playing';
|
||||
activity.smallImageText = sentenceCase(current[2]);
|
||||
}
|
||||
activity.smallImageKey = 'playing';
|
||||
} else {
|
||||
activity.smallImageKey = 'paused';
|
||||
activity.smallImageText = sentenceCase(current[2]);
|
||||
}
|
||||
|
||||
if (discordSettings.showServerImage && song) {
|
||||
@@ -363,7 +349,6 @@ export const useDiscordRpc = () => {
|
||||
[
|
||||
discordSettings.showAsListening,
|
||||
discordSettings.showServerImage,
|
||||
discordSettings.showStateIcon,
|
||||
discordSettings.showPaused,
|
||||
lastfmApiKey,
|
||||
discordSettings.clientId,
|
||||
|
||||
@@ -3,10 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||
import { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';
|
||||
import {
|
||||
ListConfigMenu,
|
||||
SONG_DISPLAY_TYPES,
|
||||
} from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
|
||||
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
||||
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
||||
@@ -243,10 +240,7 @@ export const FolderListHeaderFilters = () => {
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ListConfigMenu
|
||||
displayTypes={[
|
||||
{ hidden: true, value: ListDisplayType.GRID },
|
||||
...SONG_DISPLAY_TYPES,
|
||||
]}
|
||||
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
|
||||
listKey={ItemListKey.SONG}
|
||||
optionsConfig={{
|
||||
grid: {
|
||||
|
||||
@@ -82,7 +82,16 @@ const HomeRoute = () => {
|
||||
},
|
||||
};
|
||||
|
||||
const sortedItems = homeItems.filter((item) => !item.disabled);
|
||||
const sortedItems = homeItems.filter((item) => {
|
||||
if (item.disabled) {
|
||||
return false;
|
||||
}
|
||||
if (isJellyfin && item.id === HomeItem.RECENTLY_PLAYED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const sortedCarousel = sortedItems
|
||||
.filter((item) => item.id !== HomeItem.GENRES)
|
||||
|
||||
@@ -17,12 +17,7 @@ import { IgnoreCorsSslSwitches } from '/@/renderer/features/servers/components/i
|
||||
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import {
|
||||
getServerById,
|
||||
useAuthStoreActions,
|
||||
useCurrentServer,
|
||||
useServerList,
|
||||
} from '/@/renderer/store';
|
||||
import { useAuthStoreActions, useCurrentServer } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
import { Code } from '/@/shared/components/code/code';
|
||||
@@ -51,14 +46,11 @@ const SERVER_NAMES: Record<ServerType, string> = {
|
||||
[ServerType.SUBSONIC]: 'OpenSubsonic',
|
||||
};
|
||||
|
||||
const normalizeUrl = (url: string) => url.replace(/\/$/, '');
|
||||
|
||||
const LoginRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { addServer, setCurrentServer, updateServer } = useAuthStoreActions();
|
||||
const { addServer, setCurrentServer } = useAuthStoreActions();
|
||||
const currentServer = useCurrentServer();
|
||||
const serverList = useServerList();
|
||||
|
||||
// Check if server lock is configured
|
||||
const serverLock = isServerLock();
|
||||
@@ -149,43 +141,24 @@ const LoginRoute = () => {
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizeUrl(serverUrl);
|
||||
const existingServer =
|
||||
serverLock &&
|
||||
Object.values(serverList).find((s) => normalizeUrl(s.url) === normalizedUrl);
|
||||
|
||||
const serverItem: ServerListItemWithCredential = {
|
||||
credential: data.credential,
|
||||
id: nanoid(),
|
||||
isAdmin: data.isAdmin,
|
||||
name: serverName,
|
||||
type: serverType as ServerType,
|
||||
url: normalizedUrl,
|
||||
url: serverUrl.replace(/\/$/, ''),
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
};
|
||||
|
||||
if (existingServer) {
|
||||
const updates: Partial<ServerListItemWithCredential> = {
|
||||
credential: data.credential,
|
||||
isAdmin: data.isAdmin,
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
};
|
||||
if (data.ndCredential !== undefined) {
|
||||
updates.ndCredential = data.ndCredential;
|
||||
}
|
||||
updateServer(existingServer.id, updates);
|
||||
const updated = getServerById(existingServer.id);
|
||||
if (updated) setCurrentServer(updated);
|
||||
} else {
|
||||
if (data.ndCredential !== undefined) {
|
||||
serverItem.ndCredential = data.ndCredential;
|
||||
}
|
||||
addServer(serverItem);
|
||||
setCurrentServer(serverItem);
|
||||
if (data.ndCredential !== undefined) {
|
||||
serverItem.ndCredential = data.ndCredential;
|
||||
}
|
||||
|
||||
addServer(serverItem);
|
||||
setCurrentServer(serverItem);
|
||||
|
||||
toast.success({
|
||||
message: t('form.addServer.success', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
|
||||
@@ -6,10 +6,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { useRestoreQueue, useSaveQueue } from '/@/renderer/features/player/hooks/use-queue-restore';
|
||||
import {
|
||||
ListConfigMenu,
|
||||
SONG_DISPLAY_TYPES,
|
||||
} from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { hasFeature } from '/@/shared/api/utils';
|
||||
@@ -67,8 +64,10 @@ export const PlayQueueListControls = ({
|
||||
/>
|
||||
<ListConfigMenu
|
||||
displayTypes={[
|
||||
{ hidden: true, value: ListDisplayType.GRID },
|
||||
...SONG_DISPLAY_TYPES,
|
||||
{
|
||||
hidden: true,
|
||||
value: ListDisplayType.GRID,
|
||||
},
|
||||
]}
|
||||
listKey={type}
|
||||
optionsConfig={{
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
filter: drop-shadow(0 0 5px rgb(0 0 0 / 40%)) drop-shadow(0 0 5px rgb(0 0 0 / 40%));
|
||||
}
|
||||
|
||||
.censored.image {
|
||||
filter: blur(30px);
|
||||
}
|
||||
|
||||
.image-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -11,12 +11,7 @@ import {
|
||||
useRadioPlayer,
|
||||
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import {
|
||||
useGeneralSettings,
|
||||
useNativeAspectRatio,
|
||||
usePlayerData,
|
||||
usePlayerSong,
|
||||
} from '/@/renderer/store';
|
||||
import { useNativeAspectRatio, usePlayerData, usePlayerSong } from '/@/renderer/store';
|
||||
import { Badge } from '/@/shared/components/badge/badge';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
@@ -25,7 +20,7 @@ import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useSetState } from '/@/shared/hooks/use-set-state';
|
||||
import { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
const imageVariants: Variants = {
|
||||
closed: {
|
||||
@@ -54,14 +49,9 @@ const MotionImage = motion.img;
|
||||
|
||||
const ImageWithPlaceholder = ({
|
||||
className,
|
||||
explicit,
|
||||
placeholderIcon = 'itemAlbum',
|
||||
...props
|
||||
}: HTMLMotionProps<'img'> & {
|
||||
explicit?: boolean;
|
||||
placeholder?: string;
|
||||
placeholderIcon?: 'itemAlbum' | 'radio';
|
||||
}) => {
|
||||
}: HTMLMotionProps<'img'> & { placeholder?: string; placeholderIcon?: 'itemAlbum' | 'radio' }) => {
|
||||
const nativeAspectRatio = useNativeAspectRatio();
|
||||
|
||||
if (!props.src) {
|
||||
@@ -81,9 +71,7 @@ const ImageWithPlaceholder = ({
|
||||
|
||||
return (
|
||||
<MotionImage
|
||||
className={clsx(styles.image, className, {
|
||||
[styles.censored]: explicit,
|
||||
})}
|
||||
className={clsx(styles.image, className)}
|
||||
style={{
|
||||
objectFit: nativeAspectRatio ? 'contain' : 'cover',
|
||||
width: nativeAspectRatio ? 'auto' : '100%',
|
||||
@@ -101,7 +89,6 @@ export const FullScreenPlayerImage = () => {
|
||||
|
||||
const currentSong = usePlayerSong();
|
||||
const { nextSong } = usePlayerData();
|
||||
const { blurExplicitImages } = useGeneralSettings();
|
||||
|
||||
const isPlayingRadio = isRadioActive && isRadioPlaying;
|
||||
|
||||
@@ -120,10 +107,8 @@ export const FullScreenPlayerImage = () => {
|
||||
});
|
||||
|
||||
const [imageState, setImageState] = useSetState({
|
||||
bottomExplicit: nextSong?.explicitStatus === ExplicitStatus.EXPLICIT,
|
||||
bottomImage: nextImageUrl,
|
||||
current: 0,
|
||||
topExplicit: currentSong?.explicitStatus === ExplicitStatus.EXPLICIT,
|
||||
topImage: currentImageUrl,
|
||||
});
|
||||
|
||||
@@ -148,14 +133,8 @@ export const FullScreenPlayerImage = () => {
|
||||
const isTop = imageStateRef.current.current === 0;
|
||||
|
||||
setImageState({
|
||||
bottomExplicit:
|
||||
(isTop ? currentSong?.explicitStatus : nextSong?.explicitStatus) ===
|
||||
ExplicitStatus.EXPLICIT,
|
||||
bottomImage: isTop ? currentImageUrl : nextImageUrl,
|
||||
current: isTop ? 1 : 0,
|
||||
topExplicit:
|
||||
(isTop ? nextSong?.explicitStatus : currentSong?.explicitStatus) ===
|
||||
ExplicitStatus.EXPLICIT,
|
||||
topImage: isTop ? nextImageUrl : currentImageUrl,
|
||||
});
|
||||
|
||||
@@ -167,8 +146,6 @@ export const FullScreenPlayerImage = () => {
|
||||
nextSong?._uniqueId,
|
||||
nextImageUrl,
|
||||
setImageState,
|
||||
currentSong?.explicitStatus,
|
||||
nextSong?.explicitStatus,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -188,7 +165,6 @@ export const FullScreenPlayerImage = () => {
|
||||
custom={{ isOpen: imageState.current === 0 }}
|
||||
draggable={false}
|
||||
exit="closed"
|
||||
explicit={blurExplicitImages && imageState.topExplicit}
|
||||
initial="closed"
|
||||
key={`top-${currentSong?._uniqueId || 'none'}`}
|
||||
placeholder="var(--theme-colors-foreground-muted)"
|
||||
@@ -204,7 +180,6 @@ export const FullScreenPlayerImage = () => {
|
||||
custom={{ isOpen: imageState.current === 1 }}
|
||||
draggable={false}
|
||||
exit="closed"
|
||||
explicit={blurExplicitImages && imageState.bottomExplicit}
|
||||
initial="closed"
|
||||
key={`bottom-${currentSong?._uniqueId || 'none'}`}
|
||||
placeholder="var(--theme-colors-foreground-muted)"
|
||||
|
||||
@@ -21,10 +21,7 @@ import {
|
||||
useIsRadioActive,
|
||||
useRadioPlayer,
|
||||
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import {
|
||||
ListConfigMenu,
|
||||
SONG_DISPLAY_TYPES,
|
||||
} from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { useFastAverageColor } from '/@/renderer/hooks';
|
||||
import {
|
||||
useFullScreenPlayerStore,
|
||||
@@ -562,10 +559,7 @@ const Controls = () => {
|
||||
buttonProps={{
|
||||
variant: 'subtle',
|
||||
}}
|
||||
displayTypes={[
|
||||
{ hidden: true, value: ListDisplayType.GRID },
|
||||
...SONG_DISPLAY_TYPES,
|
||||
]}
|
||||
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
|
||||
listKey={ItemListKey.FULL_SCREEN}
|
||||
optionsConfig={{
|
||||
table: {
|
||||
|
||||
@@ -4,10 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import styles from './mobile-fullscreen-player.module.css';
|
||||
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||
import {
|
||||
ListConfigMenu,
|
||||
SONG_DISPLAY_TYPES,
|
||||
} from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import {
|
||||
useFullScreenPlayerStore,
|
||||
useFullScreenPlayerStoreActions,
|
||||
@@ -368,10 +365,7 @@ export const MobileFullscreenPlayerHeader = memo(
|
||||
buttonProps={{
|
||||
variant: isPageHovered ? 'default' : 'subtle',
|
||||
}}
|
||||
displayTypes={[
|
||||
{ hidden: true, value: ListDisplayType.GRID },
|
||||
...SONG_DISPLAY_TYPES,
|
||||
]}
|
||||
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
|
||||
listKey={ItemListKey.FULL_SCREEN}
|
||||
optionsConfig={{
|
||||
table: {
|
||||
|
||||
@@ -1,635 +0,0 @@
|
||||
import type { RowComponentProps } from 'react-window-v2';
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
|
||||
import { applyClientSideSongFilters } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';
|
||||
import {
|
||||
ArtistMultiSelectRow,
|
||||
GenreMultiSelectRow,
|
||||
} from '/@/renderer/features/shared/components/multi-select-rows';
|
||||
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import {
|
||||
VirtualMultiSelect,
|
||||
type VirtualMultiSelectOption,
|
||||
} from '/@/shared/components/multi-select/virtual-multi-select';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
||||
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||
|
||||
interface BooleanSegmentFilterProps {
|
||||
label: string;
|
||||
onChange: (value: boolean | null) => void;
|
||||
segmentData: Array<{ label: string; value: string }>;
|
||||
value: boolean | null | undefined;
|
||||
}
|
||||
|
||||
function booleanToSegmentValue(value: boolean | null | undefined): string {
|
||||
if (value === true) return 'true';
|
||||
if (value === false) return 'false';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function segmentValueToBoolean(value: string): boolean | null {
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const BooleanSegmentFilter = ({
|
||||
label,
|
||||
onChange,
|
||||
segmentData,
|
||||
value,
|
||||
}: BooleanSegmentFilterProps) => (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" weight={500}>
|
||||
{label}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
data={segmentData}
|
||||
onChange={(v) => onChange(segmentValueToBoolean(v))}
|
||||
size="sm"
|
||||
value={booleanToSegmentValue(value)}
|
||||
w="100%"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
interface MultiSelectFilterOption {
|
||||
albumCount: null | number;
|
||||
imageUrl: string | undefined;
|
||||
label: string;
|
||||
songCount: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface MultiSelectFilterProps {
|
||||
displayCountType?: 'song';
|
||||
height: number;
|
||||
label: React.ReactNode;
|
||||
onChange: (value: null | string[]) => void;
|
||||
options: MultiSelectFilterOption[];
|
||||
RowComponent: (props: RowComponentProps<MultiSelectRowContext>) => React.ReactElement;
|
||||
singleSelect: boolean;
|
||||
value: string[];
|
||||
}
|
||||
|
||||
type MultiSelectRowContext = {
|
||||
disabled?: boolean;
|
||||
displayCountType?: 'album' | 'song';
|
||||
focusedIndex: null | number;
|
||||
onToggle: (value: string) => void;
|
||||
options: VirtualMultiSelectOption<MultiSelectFilterOption>[];
|
||||
value: string[];
|
||||
};
|
||||
|
||||
const MultiSelectFilter = ({
|
||||
displayCountType = 'song',
|
||||
height,
|
||||
label,
|
||||
onChange,
|
||||
options,
|
||||
RowComponent,
|
||||
singleSelect,
|
||||
value,
|
||||
}: MultiSelectFilterProps) => (
|
||||
<VirtualMultiSelect
|
||||
displayCountType={displayCountType}
|
||||
height={height}
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
RowComponent={RowComponent}
|
||||
singleSelect={singleSelect}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
|
||||
interface YearRangeFilterProps {
|
||||
fromYearLabel: string;
|
||||
maxYear: number | undefined;
|
||||
minYear: number | undefined;
|
||||
onMaxYear: (e: number | string) => void;
|
||||
onMinYear: (e: number | string) => void;
|
||||
toYearLabel: string;
|
||||
}
|
||||
|
||||
const YearRangeFilter = ({
|
||||
fromYearLabel,
|
||||
maxYear,
|
||||
minYear,
|
||||
onMaxYear,
|
||||
onMinYear,
|
||||
toYearLabel,
|
||||
}: YearRangeFilterProps) => (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<NumberInput
|
||||
hideControls={false}
|
||||
label={fromYearLabel}
|
||||
max={5000}
|
||||
min={0}
|
||||
onChange={(e) => onMinYear(e)}
|
||||
style={{ flex: 1 }}
|
||||
value={minYear != null ? minYear : ''}
|
||||
/>
|
||||
<NumberInput
|
||||
hideControls={false}
|
||||
label={toYearLabel}
|
||||
max={5000}
|
||||
min={0}
|
||||
onChange={(e) => onMaxYear(e)}
|
||||
style={{ flex: 1 }}
|
||||
value={maxYear != null ? maxYear : ''}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
|
||||
interface MultiSelectFilterLabelProps {
|
||||
andOrValue: 'and' | 'or';
|
||||
entityLabel: string;
|
||||
filterMultipleLabel: string;
|
||||
filterSingleLabel: string;
|
||||
matchAndLabel: string;
|
||||
matchOrLabel: string;
|
||||
onAndOrChange: (value: 'and' | 'or') => void;
|
||||
onSingleMultiChange: (value: string) => void;
|
||||
showAndOr: boolean;
|
||||
singleMultiValue: 'multi' | 'single';
|
||||
}
|
||||
|
||||
const MultiSelectFilterLabel = ({
|
||||
andOrValue,
|
||||
entityLabel,
|
||||
filterMultipleLabel,
|
||||
filterSingleLabel,
|
||||
matchAndLabel,
|
||||
matchOrLabel,
|
||||
onAndOrChange,
|
||||
onSingleMultiChange,
|
||||
showAndOr,
|
||||
singleMultiValue,
|
||||
}: MultiSelectFilterLabelProps) => (
|
||||
<Group gap="xs" justify="space-between" w="100%">
|
||||
<Text fw={500} size="sm">
|
||||
{entityLabel}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{showAndOr && (
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{ label: matchAndLabel, value: 'and' },
|
||||
{ label: matchOrLabel, value: 'or' },
|
||||
]}
|
||||
onChange={(value) => onAndOrChange(value === 'or' ? 'or' : 'and')}
|
||||
size="xs"
|
||||
value={andOrValue}
|
||||
/>
|
||||
)}
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{ label: filterSingleLabel, value: 'single' },
|
||||
{ label: filterMultipleLabel, value: 'multi' },
|
||||
]}
|
||||
onChange={onSingleMultiChange}
|
||||
size="xs"
|
||||
value={singleMultiValue}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
|
||||
export const ClientSideSongFilters = () => {
|
||||
const { t } = useTranslation();
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
const {
|
||||
query,
|
||||
setAlbumArtistIds,
|
||||
setAlbumArtistIdsMode,
|
||||
setArtistIds,
|
||||
setArtistIdsMode,
|
||||
setFavorite,
|
||||
setGenreId,
|
||||
setGenreIdsMode,
|
||||
setHasRating,
|
||||
setMaxYear,
|
||||
setMinYear,
|
||||
} = usePlaylistSongListFilters();
|
||||
|
||||
const playlistSongsQuery = useSuspenseQuery(
|
||||
playlistsQueries.songList({
|
||||
query: { id: playlistId },
|
||||
serverId: server?.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const albumArtistSelectMode = useAppStore((state) => state.albumArtistSelectMode);
|
||||
const artistSelectMode = useAppStore((state) => state.artistSelectMode);
|
||||
const genreSelectMode = useAppStore((state) => state.genreSelectMode);
|
||||
const { setAlbumArtistSelectMode, setArtistSelectMode, setGenreSelectMode } =
|
||||
useAppStoreActions();
|
||||
|
||||
const songs = useMemo(() => {
|
||||
return (playlistSongsQuery.data?.items ?? []) as Song[];
|
||||
}, [playlistSongsQuery.data]);
|
||||
|
||||
const filteredSongs = useMemo(
|
||||
() => applyClientSideSongFilters(songs, query as Record<string, unknown>),
|
||||
[songs, query],
|
||||
);
|
||||
|
||||
const songsForAlbumArtistOptions = useMemo(() => {
|
||||
const idsMode =
|
||||
(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
|
||||
const useFilteredResult = albumArtistSelectMode === 'multi' && idsMode === 'and';
|
||||
if (!useFilteredResult) {
|
||||
const queryWithoutAlbumArtist = {
|
||||
...query,
|
||||
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: undefined,
|
||||
} as Record<string, unknown>;
|
||||
return applyClientSideSongFilters(songs, queryWithoutAlbumArtist);
|
||||
}
|
||||
return filteredSongs;
|
||||
}, [albumArtistSelectMode, filteredSongs, query, songs]);
|
||||
|
||||
const songsForArtistOptions = useMemo(() => {
|
||||
const idsMode =
|
||||
(query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
|
||||
const useFilteredResult = artistSelectMode === 'multi' && idsMode === 'and';
|
||||
if (!useFilteredResult) {
|
||||
const queryWithoutArtist = {
|
||||
...query,
|
||||
[FILTER_KEYS.SONG.ARTIST_IDS]: undefined,
|
||||
} as Record<string, unknown>;
|
||||
return applyClientSideSongFilters(songs, queryWithoutArtist);
|
||||
}
|
||||
return filteredSongs;
|
||||
}, [artistSelectMode, filteredSongs, query, songs]);
|
||||
|
||||
const songsForGenreOptions = useMemo(() => {
|
||||
const idsMode =
|
||||
(query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';
|
||||
const useFilteredResult = genreSelectMode === 'multi' && idsMode === 'and';
|
||||
if (!useFilteredResult) {
|
||||
const queryWithoutGenre = {
|
||||
...query,
|
||||
[FILTER_KEYS.SONG.GENRE_ID]: undefined,
|
||||
} as Record<string, unknown>;
|
||||
return applyClientSideSongFilters(songs, queryWithoutGenre);
|
||||
}
|
||||
return filteredSongs;
|
||||
}, [filteredSongs, genreSelectMode, query, songs]);
|
||||
|
||||
const albumArtistOptions = useMemo(() => {
|
||||
const byId = new Map<
|
||||
string,
|
||||
{ id: string; imageUrl: string | undefined; name: string; songCount: number }
|
||||
>();
|
||||
for (const song of songsForAlbumArtistOptions) {
|
||||
for (const artist of song.albumArtists ?? []) {
|
||||
if (!artist.id) continue;
|
||||
const existing = byId.get(artist.id);
|
||||
if (existing) {
|
||||
existing.songCount += 1;
|
||||
} else {
|
||||
byId.set(artist.id, {
|
||||
id: artist.id,
|
||||
imageUrl:
|
||||
artist.imageUrl ??
|
||||
getItemImageUrl({
|
||||
id: artist.id,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
type: 'table',
|
||||
}),
|
||||
name: artist.name,
|
||||
songCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(byId.values())
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((a) => ({
|
||||
albumCount: null as null | number,
|
||||
imageUrl: a.imageUrl,
|
||||
label: a.name,
|
||||
songCount: a.songCount,
|
||||
value: a.id,
|
||||
}));
|
||||
}, [songsForAlbumArtistOptions]);
|
||||
|
||||
const artistOptions = useMemo(() => {
|
||||
const byId = new Map<
|
||||
string,
|
||||
{ id: string; imageUrl: string | undefined; name: string; songCount: number }
|
||||
>();
|
||||
for (const song of songsForArtistOptions) {
|
||||
for (const artist of song.artists ?? []) {
|
||||
if (!artist.id) continue;
|
||||
const existing = byId.get(artist.id);
|
||||
if (existing) {
|
||||
existing.songCount += 1;
|
||||
} else {
|
||||
byId.set(artist.id, {
|
||||
id: artist.id,
|
||||
imageUrl:
|
||||
artist.imageUrl ??
|
||||
getItemImageUrl({
|
||||
id: artist.id,
|
||||
itemType: LibraryItem.ARTIST,
|
||||
type: 'table',
|
||||
}),
|
||||
name: artist.name,
|
||||
songCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(byId.values())
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((a) => ({
|
||||
albumCount: null as null | number,
|
||||
imageUrl: a.imageUrl,
|
||||
label: a.name,
|
||||
songCount: a.songCount,
|
||||
value: a.id,
|
||||
}));
|
||||
}, [songsForArtistOptions]);
|
||||
|
||||
const genreOptions = useMemo(() => {
|
||||
const byId = new Map<string, { id: string; name: string; songCount: number }>();
|
||||
for (const song of songsForGenreOptions) {
|
||||
for (const genre of song.genres ?? []) {
|
||||
if (!genre.id) continue;
|
||||
const existing = byId.get(genre.id);
|
||||
if (existing) {
|
||||
existing.songCount += 1;
|
||||
} else {
|
||||
byId.set(genre.id, {
|
||||
id: genre.id,
|
||||
name: genre.name,
|
||||
songCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(byId.values())
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((g) => ({
|
||||
albumCount: null as null | number,
|
||||
imageUrl: undefined,
|
||||
label: g.name,
|
||||
songCount: g.songCount,
|
||||
value: g.id,
|
||||
}));
|
||||
}, [songsForGenreOptions]);
|
||||
|
||||
const segmentedControlData = useMemo(
|
||||
() => [
|
||||
{ label: t('common.none', { postProcess: 'titleCase' }), value: 'none' },
|
||||
{ label: t('common.yes', { postProcess: 'titleCase' }), value: 'true' },
|
||||
{ label: t('common.no', { postProcess: 'titleCase' }), value: 'false' },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleMinYear = useMemo(
|
||||
() => (e: number | string) => {
|
||||
if (e === '' || e === null || e === undefined) {
|
||||
setMinYear(null);
|
||||
return;
|
||||
}
|
||||
const year = typeof e === 'number' ? e : Number(e);
|
||||
setMinYear(!isNaN(year) && isFinite(year) && year > 0 ? year : null);
|
||||
},
|
||||
[setMinYear],
|
||||
);
|
||||
|
||||
const handleMaxYear = useMemo(
|
||||
() => (e: number | string) => {
|
||||
if (e === '' || e === null || e === undefined) {
|
||||
setMaxYear(null);
|
||||
return;
|
||||
}
|
||||
const year = typeof e === 'number' ? e : Number(e);
|
||||
setMaxYear(!isNaN(year) && isFinite(year) && year > 0 ? year : null);
|
||||
},
|
||||
[setMaxYear],
|
||||
);
|
||||
|
||||
const debouncedHandleMinYear = useDebouncedCallback(handleMinYear, 300);
|
||||
const debouncedHandleMaxYear = useDebouncedCallback(handleMaxYear, 300);
|
||||
|
||||
const selectedGenreIds = useMemo(
|
||||
() => (query[FILTER_KEYS.SONG.GENRE_ID] as string[] | undefined) ?? [],
|
||||
[query],
|
||||
);
|
||||
|
||||
const handleGenreSelectModeChange = useCallback(
|
||||
(value: string) => {
|
||||
const newMode = value as 'multi' | 'single';
|
||||
setGenreSelectMode(newMode);
|
||||
if (newMode === 'single' && selectedGenreIds.length > 1) {
|
||||
setGenreId([selectedGenreIds[0]]);
|
||||
}
|
||||
},
|
||||
[selectedGenreIds, setGenreId, setGenreSelectMode],
|
||||
);
|
||||
|
||||
const genreIdsMode =
|
||||
(query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';
|
||||
|
||||
const handleGenreChange = useCallback(
|
||||
(e: null | string[]) => {
|
||||
if (e && e.length > 0) {
|
||||
setGenreId(e);
|
||||
} else {
|
||||
setGenreId(null);
|
||||
}
|
||||
},
|
||||
[setGenreId],
|
||||
);
|
||||
|
||||
const selectedArtistIds = useMemo(
|
||||
() => (query[FILTER_KEYS.SONG.ARTIST_IDS] as string[] | undefined) ?? [],
|
||||
[query],
|
||||
);
|
||||
|
||||
const handleArtistSelectModeChange = useCallback(
|
||||
(value: string) => {
|
||||
const newMode = value as 'multi' | 'single';
|
||||
setArtistSelectMode(newMode);
|
||||
if (newMode === 'single' && selectedArtistIds.length > 1) {
|
||||
setArtistIds([selectedArtistIds[0]]);
|
||||
}
|
||||
},
|
||||
[selectedArtistIds, setArtistIds, setArtistSelectMode],
|
||||
);
|
||||
|
||||
const artistIdsMode =
|
||||
(query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
|
||||
|
||||
const handleArtistChange = useCallback(
|
||||
(e: null | string[]) => {
|
||||
if (e && e.length > 0) {
|
||||
setArtistIds(e);
|
||||
} else {
|
||||
setArtistIds(null);
|
||||
}
|
||||
},
|
||||
[setArtistIds],
|
||||
);
|
||||
|
||||
const selectedAlbumArtistIds = useMemo(
|
||||
() => (query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS] as string[] | undefined) ?? [],
|
||||
[query],
|
||||
);
|
||||
|
||||
const handleAlbumArtistSelectModeChange = useCallback(
|
||||
(value: string) => {
|
||||
const newMode = value as 'multi' | 'single';
|
||||
setAlbumArtistSelectMode(newMode);
|
||||
if (newMode === 'single' && selectedAlbumArtistIds.length > 1) {
|
||||
setAlbumArtistIds([selectedAlbumArtistIds[0]]);
|
||||
}
|
||||
},
|
||||
[selectedAlbumArtistIds, setAlbumArtistIds, setAlbumArtistSelectMode],
|
||||
);
|
||||
|
||||
const albumArtistIdsMode =
|
||||
(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
|
||||
|
||||
const handleAlbumArtistChange = useCallback(
|
||||
(e: null | string[]) => {
|
||||
if (e && e.length > 0) {
|
||||
setAlbumArtistIds(e);
|
||||
} else {
|
||||
setAlbumArtistIds(null);
|
||||
}
|
||||
},
|
||||
[setAlbumArtistIds],
|
||||
);
|
||||
|
||||
const queryFavorite = query[FILTER_KEYS.SONG.FAVORITE] as boolean | undefined;
|
||||
const queryHasRating = query[FILTER_KEYS.SONG.HAS_RATING] as boolean | undefined;
|
||||
const queryMinYear = query[FILTER_KEYS.SONG.MIN_YEAR] as number | undefined;
|
||||
const queryMaxYear = query[FILTER_KEYS.SONG.MAX_YEAR] as number | undefined;
|
||||
|
||||
const matchAndLabel = t('filter.matchAnd', { postProcess: 'titleCase' });
|
||||
const matchOrLabel = t('filter.matchOr', { postProcess: 'titleCase' });
|
||||
const filterSingleLabel = t('common.filter_single', { postProcess: 'titleCase' });
|
||||
const filterMultipleLabel = t('common.filter_multiple', { postProcess: 'titleCase' });
|
||||
|
||||
return (
|
||||
<Stack px="md" py="md">
|
||||
<BooleanSegmentFilter
|
||||
label={t('filter.isFavorited', { postProcess: 'sentenceCase' })}
|
||||
onChange={setFavorite}
|
||||
segmentData={segmentedControlData}
|
||||
value={queryFavorite}
|
||||
/>
|
||||
<Stack gap="xs" mt="md">
|
||||
<BooleanSegmentFilter
|
||||
label={t('filter.isRated', { postProcess: 'sentenceCase' })}
|
||||
onChange={setHasRating}
|
||||
segmentData={segmentedControlData}
|
||||
value={queryHasRating}
|
||||
/>
|
||||
</Stack>
|
||||
<Divider my="md" />
|
||||
<MultiSelectFilter
|
||||
height={300}
|
||||
label={
|
||||
<MultiSelectFilterLabel
|
||||
andOrValue={artistIdsMode}
|
||||
entityLabel={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
|
||||
filterMultipleLabel={filterMultipleLabel}
|
||||
filterSingleLabel={filterSingleLabel}
|
||||
matchAndLabel={matchAndLabel}
|
||||
matchOrLabel={matchOrLabel}
|
||||
onAndOrChange={setArtistIdsMode}
|
||||
onSingleMultiChange={handleArtistSelectModeChange}
|
||||
showAndOr={artistSelectMode === 'multi'}
|
||||
singleMultiValue={artistSelectMode}
|
||||
/>
|
||||
}
|
||||
onChange={handleArtistChange}
|
||||
options={artistOptions}
|
||||
RowComponent={ArtistMultiSelectRow}
|
||||
singleSelect={artistSelectMode === 'single'}
|
||||
value={selectedArtistIds}
|
||||
/>
|
||||
<Divider my="md" />
|
||||
<MultiSelectFilter
|
||||
height={300}
|
||||
label={
|
||||
<MultiSelectFilterLabel
|
||||
andOrValue={albumArtistIdsMode}
|
||||
entityLabel={t('entity.albumArtist', {
|
||||
count: 2,
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
filterMultipleLabel={filterMultipleLabel}
|
||||
filterSingleLabel={filterSingleLabel}
|
||||
matchAndLabel={matchAndLabel}
|
||||
matchOrLabel={matchOrLabel}
|
||||
onAndOrChange={setAlbumArtistIdsMode}
|
||||
onSingleMultiChange={handleAlbumArtistSelectModeChange}
|
||||
showAndOr={albumArtistSelectMode === 'multi'}
|
||||
singleMultiValue={albumArtistSelectMode}
|
||||
/>
|
||||
}
|
||||
onChange={handleAlbumArtistChange}
|
||||
options={albumArtistOptions}
|
||||
RowComponent={ArtistMultiSelectRow}
|
||||
singleSelect={albumArtistSelectMode === 'single'}
|
||||
value={selectedAlbumArtistIds}
|
||||
/>
|
||||
<Divider my="md" />
|
||||
<MultiSelectFilter
|
||||
height={220}
|
||||
label={
|
||||
<MultiSelectFilterLabel
|
||||
andOrValue={genreIdsMode}
|
||||
entityLabel={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
||||
filterMultipleLabel={filterMultipleLabel}
|
||||
filterSingleLabel={filterSingleLabel}
|
||||
matchAndLabel={matchAndLabel}
|
||||
matchOrLabel={matchOrLabel}
|
||||
onAndOrChange={setGenreIdsMode}
|
||||
onSingleMultiChange={handleGenreSelectModeChange}
|
||||
showAndOr={genreSelectMode === 'multi'}
|
||||
singleMultiValue={genreSelectMode}
|
||||
/>
|
||||
}
|
||||
onChange={handleGenreChange}
|
||||
options={genreOptions}
|
||||
RowComponent={GenreMultiSelectRow}
|
||||
singleSelect={genreSelectMode === 'single'}
|
||||
value={selectedGenreIds}
|
||||
/>
|
||||
<Divider my="md" />
|
||||
<YearRangeFilter
|
||||
fromYearLabel={t('filter.fromYear', { postProcess: 'titleCase' })}
|
||||
maxYear={queryMaxYear}
|
||||
minYear={queryMinYear}
|
||||
onMaxYear={debouncedHandleMaxYear}
|
||||
onMinYear={debouncedHandleMinYear}
|
||||
toYearLabel={t('filter.toYear', { postProcess: 'titleCase' })}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -12,10 +12,8 @@ import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/
|
||||
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { DefaultItemControlProps, ItemControls } from '/@/renderer/components/item-list/types';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
|
||||
import { applyClientSideSongFilters } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';
|
||||
import { type PlaylistAlbumRow, playlistSongsToAlbums } from '/@/renderer/features/playlists/utils';
|
||||
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
|
||||
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
|
||||
@@ -24,17 +22,10 @@ import { sortSongList } from '/@/shared/api/utils';
|
||||
import {
|
||||
LibraryItem,
|
||||
PlaylistSongListResponse,
|
||||
Song,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import {
|
||||
ItemListKey,
|
||||
ListDisplayType,
|
||||
ListPaginationType,
|
||||
Play,
|
||||
TableColumn,
|
||||
} from '/@/shared/types/types';
|
||||
import { ItemListKey, ListDisplayType, ListPaginationType, Play } from '/@/shared/types/types';
|
||||
|
||||
export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListResponse }) => {
|
||||
const player = usePlayer();
|
||||
@@ -47,25 +38,18 @@ export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListRespon
|
||||
const { searchTerm } = useSearchTermFilter();
|
||||
const { query } = usePlaylistSongListFilters();
|
||||
|
||||
const filteredAndSortedSongs = useMemo(() => {
|
||||
const raw = data?.items ?? [];
|
||||
const filtered = applyClientSideSongFilters(raw, query as Record<string, unknown>);
|
||||
|
||||
const searched = searchTerm?.trim()
|
||||
? searchLibraryItems(filtered, searchTerm, LibraryItem.SONG)
|
||||
: filtered;
|
||||
|
||||
return sortSongList(
|
||||
searched,
|
||||
const sortedAlbums = useMemo(() => {
|
||||
let songs = data?.items ?? [];
|
||||
if (searchTerm?.trim()) {
|
||||
songs = searchLibraryItems(songs, searchTerm, LibraryItem.SONG);
|
||||
}
|
||||
const sortedSongs = sortSongList(
|
||||
songs,
|
||||
(query.sortBy as SongListSort) ?? SongListSort.ID,
|
||||
(query.sortOrder as SortOrder) ?? SortOrder.ASC,
|
||||
);
|
||||
}, [data?.items, query, searchTerm]);
|
||||
|
||||
const sortedAlbums = useMemo(
|
||||
() => playlistSongsToAlbums(filteredAndSortedSongs),
|
||||
[filteredAndSortedSongs],
|
||||
);
|
||||
return playlistSongsToAlbums(sortedSongs);
|
||||
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
|
||||
|
||||
const isPaginated = pagination === ListPaginationType.PAGINATED;
|
||||
const totalAlbumCount = sortedAlbums.length;
|
||||
@@ -81,44 +65,12 @@ export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListRespon
|
||||
|
||||
const albumControlOverrides = useMemo<Partial<ItemControls>>(() => {
|
||||
return {
|
||||
onFavorite: undefined,
|
||||
onMore: ({ event, internalState, item }: DefaultItemControlProps) => {
|
||||
if (!event) return;
|
||||
|
||||
const selected = internalState?.getSelected();
|
||||
|
||||
if (selected?.length === 0 && !item) {
|
||||
return;
|
||||
}
|
||||
|
||||
let itemsToUse: (PlaylistAlbumRow | Song)[];
|
||||
if ((selected?.length ?? 0) > 0) {
|
||||
itemsToUse = selected as (PlaylistAlbumRow | Song)[];
|
||||
} else {
|
||||
itemsToUse = [item as PlaylistAlbumRow | Song];
|
||||
}
|
||||
|
||||
const songs: Song[] = [];
|
||||
for (const item of itemsToUse) {
|
||||
if (item._itemType === LibraryItem.ALBUM) {
|
||||
songs.push(...((item as PlaylistAlbumRow)._playlistSongs ?? []));
|
||||
} else if (item._itemType === LibraryItem.SONG) {
|
||||
songs.push(item as Song);
|
||||
}
|
||||
}
|
||||
|
||||
ContextMenuController.call({
|
||||
cmd: { items: songs, type: LibraryItem.PLAYLIST_SONG },
|
||||
event,
|
||||
});
|
||||
},
|
||||
onPlay: ({
|
||||
item,
|
||||
itemType,
|
||||
playType,
|
||||
}: DefaultItemControlProps & { playType: Play }) => {
|
||||
if (!item) return;
|
||||
|
||||
const rowSongs = (item as PlaylistAlbumRow)._playlistSongs;
|
||||
if (itemType === LibraryItem.ALBUM && rowSongs?.length) {
|
||||
player.addToQueueByData(rowSongs, playType);
|
||||
@@ -126,7 +78,6 @@ export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListRespon
|
||||
}
|
||||
player.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
|
||||
},
|
||||
onRating: undefined,
|
||||
};
|
||||
}, [player]);
|
||||
|
||||
@@ -135,8 +86,8 @@ export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListRespon
|
||||
}, [setItemCount, totalAlbumCount]);
|
||||
|
||||
useEffect(() => {
|
||||
setListData?.(filteredAndSortedSongs);
|
||||
}, [filteredAndSortedSongs, setListData]);
|
||||
setListData?.(data?.items ?? []);
|
||||
}, [data?.items, setListData]);
|
||||
|
||||
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ enabled: true });
|
||||
const { handleColumnReordered } = useItemListColumnReorder({
|
||||
@@ -155,13 +106,6 @@ export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListRespon
|
||||
});
|
||||
const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.PLAYLIST_ALBUM, grid.size);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
return table.columns.filter(
|
||||
(column) =>
|
||||
column.id !== TableColumn.USER_FAVORITE && column.id !== TableColumn.USER_RATING,
|
||||
);
|
||||
}, [table.columns]);
|
||||
|
||||
const renderAlbumList = () => {
|
||||
switch (display) {
|
||||
case ListDisplayType.DETAIL:
|
||||
@@ -208,7 +152,7 @@ export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListRespon
|
||||
<ItemTableList
|
||||
autoFitColumns={table.autoFitColumns}
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={tableColumns}
|
||||
columns={table.columns}
|
||||
data={albumsToRender}
|
||||
enableAlternateRowColors={table.enableAlternateRowColors}
|
||||
enableHeader={table.enableHeader}
|
||||
|
||||
@@ -100,6 +100,7 @@ export type OverridePlaylistSongListQuery = Omit<Partial<PlaylistSongListQuery>,
|
||||
|
||||
interface PlaylistDetailSongListViewProps {
|
||||
data: PlaylistSongListResponse;
|
||||
/** When provided, table/grid use this instead of computing from data (avoids duplicate filter/sort). */
|
||||
items?: Song[];
|
||||
}
|
||||
|
||||
@@ -282,6 +283,7 @@ export const PlaylistDetailSongListEdit = ({ data }: { data: PlaylistSongListRes
|
||||
}
|
||||
};
|
||||
|
||||
/** Track view: view mode uses centralized list derivation; edit mode uses local reorder state. */
|
||||
const PlaylistDetailTrackView = ({ data }: { data: PlaylistSongListResponse }) => {
|
||||
const { isSmartPlaylist, mode } = useListContext();
|
||||
|
||||
@@ -296,6 +298,7 @@ const PlaylistDetailTrackView = ({ data }: { data: PlaylistSongListResponse }) =
|
||||
return <PlaylistDetailTrackViewContent data={data} />;
|
||||
};
|
||||
|
||||
/** Uses usePlaylistTrackList once and passes derived items to the list view. */
|
||||
const PlaylistDetailTrackViewContent = ({ data }: { data: PlaylistSongListResponse }) => {
|
||||
const { sortedAndFilteredSongs } = usePlaylistTrackList(data);
|
||||
return <PlaylistDetailSongListView data={data} items={sortedAndFilteredSongs} />;
|
||||
|
||||
@@ -95,7 +95,7 @@ export const PlaylistDetailSongListGrid = forwardRef<any, PlaylistDetailSongList
|
||||
type: 'offset',
|
||||
}}
|
||||
itemsPerRow={gridProps.itemsPerRowEnabled ? gridProps.itemsPerRow : undefined}
|
||||
itemType={LibraryItem.PLAYLIST_SONG}
|
||||
itemType={LibraryItem.SONG}
|
||||
onScrollEnd={handleOnScrollEnd}
|
||||
rows={rows}
|
||||
size={gridProps.size}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { openContextModal } from '@mantine/modals';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
@@ -13,20 +13,12 @@ import {
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||
import { ClientSideSongFilters } from '/@/renderer/features/playlists/components/client-side-song-filters';
|
||||
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
|
||||
import { FilterButton } from '/@/renderer/features/shared/components/filter-button';
|
||||
import {
|
||||
ListConfigMenu,
|
||||
SONG_DISPLAY_TYPES,
|
||||
} from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';
|
||||
import { isFilterValueSet } from '/@/renderer/features/shared/components/list-filters';
|
||||
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
|
||||
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
||||
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
||||
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
|
||||
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
PlaylistTarget,
|
||||
@@ -40,9 +32,7 @@ import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Modal } from '/@/shared/components/modal/modal';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
|
||||
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
@@ -51,69 +41,6 @@ interface PlaylistDetailSongListHeaderFiltersProps {
|
||||
isSmartPlaylist?: boolean;
|
||||
}
|
||||
|
||||
const PlaylistSongListFiltersModal = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isSidebarOpen, setIsSidebarOpen } = useListContext();
|
||||
const { clear, query } = usePlaylistSongListFilters();
|
||||
const [isOpen, handlers] = useDisclosure(false);
|
||||
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return Boolean(
|
||||
isFilterValueSet(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]) ||
|
||||
isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) ||
|
||||
query[FILTER_KEYS.SONG.FAVORITE] !== undefined ||
|
||||
isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) ||
|
||||
query[FILTER_KEYS.SONG.HAS_RATING] !== undefined ||
|
||||
query[FILTER_KEYS.SONG.MAX_YEAR] !== undefined ||
|
||||
query[FILTER_KEYS.SONG.MIN_YEAR] !== undefined,
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
const handlePin = () => {
|
||||
setIsSidebarOpen?.(!isSidebarOpen);
|
||||
};
|
||||
|
||||
const canPin = Boolean(setIsSidebarOpen);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterButton isActive={hasActiveFilters} onClick={handlers.toggle} />
|
||||
<Modal
|
||||
handlers={handlers}
|
||||
opened={isOpen}
|
||||
size="lg"
|
||||
styles={{
|
||||
content: {
|
||||
height: '100%',
|
||||
maxHeight: '640px',
|
||||
maxWidth: 'var(--theme-content-max-width)',
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
title={
|
||||
<Group justify="space-between" style={{ paddingRight: '3rem', width: '100%' }}>
|
||||
<Group>
|
||||
{canPin && (
|
||||
<ActionIcon
|
||||
icon={isSidebarOpen ? 'unpin' : 'pin'}
|
||||
onClick={handlePin}
|
||||
variant="subtle"
|
||||
/>
|
||||
)}
|
||||
{t('common.filters', { postProcess: 'sentenceCase' })}
|
||||
</Group>
|
||||
<Button onClick={clear} size="compact-sm" variant="subtle">
|
||||
{t('common.reset', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<ClientSideSongFilters />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const PlaylistDetailSongListHeaderFilters = ({
|
||||
isSmartPlaylist,
|
||||
}: PlaylistDetailSongListHeaderFiltersProps) => {
|
||||
@@ -187,8 +114,6 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
disabled={isEditMode}
|
||||
listKey={ItemListKey.PLAYLIST_SONG}
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
<PlaylistSongListFiltersModal />
|
||||
<ListRefreshButton disabled={isEditMode} listKey={listKey} />
|
||||
<MoreButton onClick={handleMore} />
|
||||
</Group>
|
||||
@@ -232,7 +157,6 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
/>
|
||||
) : (
|
||||
<ListConfigMenu
|
||||
displayTypes={SONG_DISPLAY_TYPES}
|
||||
listKey={listKey}
|
||||
tableColumnsData={PLAYLIST_SONG_TABLE_COLUMNS}
|
||||
/>
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
PlaylistQueryBuilder,
|
||||
PlaylistQueryBuilderRef,
|
||||
} from '/@/renderer/features/playlists/components/playlist-query-builder';
|
||||
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
|
||||
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
|
||||
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
|
||||
import { Box } from '/@/shared/components/box/box';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { JsonInput } from '/@/shared/components/json-input/json-input';
|
||||
import { ConfirmModal } from '/@/shared/components/modal/modal';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { SongListSort } from '/@/shared/types/domain-types';
|
||||
|
||||
export interface PlaylistQueryEditorProps {
|
||||
createPlaylistMutation: ReturnType<typeof useCreatePlaylist>;
|
||||
detailQuery: ReturnType<typeof useQuery<any>>;
|
||||
handleSave: (
|
||||
filter: Record<string, any>,
|
||||
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
|
||||
) => void;
|
||||
handleSaveAs: (
|
||||
filter: Record<string, any>,
|
||||
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
|
||||
) => void;
|
||||
isQueryBuilderExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
playlistId: string;
|
||||
queryBuilderRef: React.RefObject<null | PlaylistQueryBuilderRef>;
|
||||
}
|
||||
|
||||
type AppliedJsonState = {
|
||||
limit?: number;
|
||||
query: Record<string, any>;
|
||||
sort?: string;
|
||||
};
|
||||
|
||||
type EditorMode = 'builder' | 'json';
|
||||
|
||||
const serializeFiltersToRulesJson = (filters: {
|
||||
extraFilters: { limit?: number; sortBy?: string[] };
|
||||
filters: any;
|
||||
}): Record<string, any> => {
|
||||
const queryValue = convertQueryGroupToNDQuery(filters.filters);
|
||||
const sortString = filters.extraFilters.sortBy?.[0];
|
||||
return {
|
||||
...queryValue,
|
||||
...(filters.extraFilters.limit != null && { limit: filters.extraFilters.limit }),
|
||||
...(sortString && { sort: sortString }),
|
||||
};
|
||||
};
|
||||
|
||||
const parseRulesJsonToSaveArgs = (
|
||||
parsed: Record<string, any>,
|
||||
): { extraFilters: { limit?: number; sortBy?: string[] }; filter: Record<string, any> } => {
|
||||
const rootKey = parsed.all ? 'all' : 'any';
|
||||
const filter = rootKey in parsed ? { [rootKey]: parsed[rootKey] } : { all: [] };
|
||||
return {
|
||||
extraFilters: {
|
||||
...(parsed.limit != null && { limit: parsed.limit }),
|
||||
...(parsed.sort != null && { sortBy: [parsed.sort] }),
|
||||
},
|
||||
filter,
|
||||
};
|
||||
};
|
||||
|
||||
export const PlaylistQueryEditor = ({
|
||||
createPlaylistMutation,
|
||||
detailQuery,
|
||||
handleSave,
|
||||
handleSaveAs,
|
||||
isQueryBuilderExpanded,
|
||||
onToggleExpand,
|
||||
playlistId,
|
||||
queryBuilderRef,
|
||||
}: PlaylistQueryEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [editorMode, setEditorMode] = useState<EditorMode>('builder');
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [appliedJsonState, setAppliedJsonState] = useState<AppliedJsonState | null>(null);
|
||||
|
||||
const getFiltersForSave = useCallback((): null | {
|
||||
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string };
|
||||
filter: Record<string, any>;
|
||||
} => {
|
||||
if (editorMode === 'json') {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText) as Record<string, any>;
|
||||
const { extraFilters, filter } = parseRulesJsonToSaveArgs(parsed);
|
||||
return { extraFilters, filter };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const filters = queryBuilderRef.current?.getFilters();
|
||||
if (!filters) return null;
|
||||
return {
|
||||
extraFilters: filters.extraFilters,
|
||||
filter: convertQueryGroupToNDQuery(filters.filters),
|
||||
};
|
||||
}, [editorMode, jsonText, queryBuilderRef]);
|
||||
|
||||
const openPreviewModal = useCallback(() => {
|
||||
const payload = getFiltersForSave();
|
||||
if (!payload) {
|
||||
if (editorMode === 'json') {
|
||||
toast.error({ message: t('error.invalidJson', { postProcess: 'sentenceCase' }) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const previewValue = {
|
||||
...payload.filter,
|
||||
...(payload.extraFilters.limit != null && { limit: payload.extraFilters.limit }),
|
||||
...(payload.extraFilters.sortBy?.[0] && { sort: payload.extraFilters.sortBy[0] }),
|
||||
};
|
||||
openModal({
|
||||
children: <JsonPreview value={previewValue} />,
|
||||
size: 'xl',
|
||||
title: t('common.preview', { postProcess: 'titleCase' }),
|
||||
});
|
||||
}, [editorMode, getFiltersForSave, t]);
|
||||
|
||||
const openSaveAndReplaceModal = useCallback(() => {
|
||||
if (!isQueryBuilderExpanded) return;
|
||||
const payload = getFiltersForSave();
|
||||
if (!payload) {
|
||||
if (editorMode === 'json') {
|
||||
toast.error({ message: t('error.invalidJson', { postProcess: 'sentenceCase' }) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
openModal({
|
||||
children: (
|
||||
<ConfirmModal
|
||||
onConfirm={() => {
|
||||
handleSave(payload.filter, payload.extraFilters);
|
||||
closeAllModals();
|
||||
}}
|
||||
>
|
||||
<Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
|
||||
</ConfirmModal>
|
||||
),
|
||||
title: t('common.saveAndReplace', { postProcess: 'titleCase' }),
|
||||
});
|
||||
}, [editorMode, getFiltersForSave, handleSave, isQueryBuilderExpanded, t]);
|
||||
|
||||
const parseSortBy = useCallback((): string[] => {
|
||||
const sort = detailQuery?.data?.rules?.sort;
|
||||
// Handle new syntax: comma-separated with +/- prefix
|
||||
// e.g., "+album,-year" -> return as single string in array
|
||||
if (typeof sort === 'string') {
|
||||
// Check if it's new syntax (has +/- prefix or commas)
|
||||
if (sort.includes(',') || sort.startsWith('+') || sort.startsWith('-')) {
|
||||
return [sort];
|
||||
}
|
||||
// Old syntax: single field, convert to new format with default order
|
||||
const order = detailQuery?.data?.rules?.order || 'asc';
|
||||
const prefix = order === 'desc' ? '-' : '+';
|
||||
return [`${prefix}${sort}`];
|
||||
}
|
||||
if (Array.isArray(sort)) {
|
||||
// If array, check if first item has +/- prefix
|
||||
if (
|
||||
sort.length > 0 &&
|
||||
typeof sort[0] === 'string' &&
|
||||
(sort[0].startsWith('+') || sort[0].startsWith('-'))
|
||||
) {
|
||||
return sort;
|
||||
}
|
||||
// Old array format, convert to new format
|
||||
const order = detailQuery?.data?.rules?.order || 'asc';
|
||||
const prefix = order === 'desc' ? '-' : '+';
|
||||
return sort.map((s) => `${prefix}${s}`);
|
||||
}
|
||||
return ['+dateAdded'];
|
||||
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
|
||||
|
||||
const parseSortOrder = useCallback((): 'asc' | 'desc' => {
|
||||
const sort = detailQuery?.data?.rules?.sort;
|
||||
if (typeof sort === 'string' && sort.startsWith('-')) {
|
||||
return 'desc';
|
||||
}
|
||||
// Fall back to old order field or default
|
||||
return detailQuery?.data?.rules?.order || 'asc';
|
||||
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
|
||||
|
||||
const effectiveQuery = useMemo(
|
||||
() =>
|
||||
appliedJsonState?.query ??
|
||||
(detailQuery?.data?.rules?.all
|
||||
? { all: detailQuery.data.rules.all }
|
||||
: detailQuery?.data?.rules?.any
|
||||
? { any: detailQuery.data.rules.any }
|
||||
: detailQuery?.data?.rules),
|
||||
[appliedJsonState?.query, detailQuery?.data?.rules],
|
||||
);
|
||||
const effectiveLimit = appliedJsonState?.limit ?? detailQuery?.data?.rules?.limit;
|
||||
const effectiveSortBy = useMemo(
|
||||
() =>
|
||||
(appliedJsonState?.sort ? [appliedJsonState.sort] : parseSortBy()) as
|
||||
| SongListSort
|
||||
| SongListSort[],
|
||||
[appliedJsonState?.sort, parseSortBy],
|
||||
);
|
||||
const effectiveSortOrder = appliedJsonState?.sort
|
||||
? appliedJsonState.sort.startsWith('-')
|
||||
? 'desc'
|
||||
: 'asc'
|
||||
: parseSortOrder();
|
||||
|
||||
const handleEditorModeChange = useCallback(
|
||||
(value: string) => {
|
||||
const nextMode = value as EditorMode;
|
||||
if (nextMode === 'json') {
|
||||
const filters = queryBuilderRef.current?.getFilters();
|
||||
if (filters) {
|
||||
setJsonText(JSON.stringify(serializeFiltersToRulesJson(filters), null, 2));
|
||||
} else {
|
||||
const fallback: Record<string, any> = effectiveQuery
|
||||
? { ...effectiveQuery }
|
||||
: { all: [] };
|
||||
if (effectiveLimit != null) fallback.limit = effectiveLimit;
|
||||
if (effectiveSortBy?.[0]) fallback.sort = effectiveSortBy[0];
|
||||
if (!fallback.sort) fallback.sort = '+dateAdded';
|
||||
setJsonText(JSON.stringify(fallback, null, 2));
|
||||
}
|
||||
setEditorMode('json');
|
||||
} else {
|
||||
if (editorMode === 'json') {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText) as Record<string, any>;
|
||||
const rootKey = parsed.all ? 'all' : 'any';
|
||||
if (!parsed[rootKey] || !Array.isArray(parsed[rootKey])) {
|
||||
throw new Error('Invalid rules structure');
|
||||
}
|
||||
setAppliedJsonState({
|
||||
limit: parsed.limit,
|
||||
query: { [rootKey]: parsed[rootKey] },
|
||||
sort: parsed.sort,
|
||||
});
|
||||
} catch {
|
||||
toast.error({
|
||||
message: t('error.invalidJson', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setEditorMode('builder');
|
||||
}
|
||||
},
|
||||
[editorMode, effectiveLimit, effectiveQuery, effectiveSortBy, jsonText, queryBuilderRef, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="query-editor-container"
|
||||
style={{ borderTop: '1px solid var(--theme-colors-border)' }}
|
||||
>
|
||||
<Stack gap={0} h="100%" mah="30dvh" p="sm" w="100%">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button
|
||||
leftSection={
|
||||
<Icon
|
||||
icon={isQueryBuilderExpanded ? 'arrowDownS' : 'arrowUpS'}
|
||||
size="lg"
|
||||
/>
|
||||
}
|
||||
onClick={onToggleExpand}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
{t('form.queryEditor.title', {
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
</Button>
|
||||
{isQueryBuilderExpanded && (
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{
|
||||
label: (
|
||||
<Flex>
|
||||
<Icon icon="queryBuilder" />
|
||||
</Flex>
|
||||
),
|
||||
value: 'builder',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Flex>
|
||||
<Icon icon="json" />
|
||||
</Flex>
|
||||
),
|
||||
value: 'json',
|
||||
},
|
||||
]}
|
||||
onChange={handleEditorModeChange}
|
||||
size="xs"
|
||||
value={editorMode}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Button onClick={openPreviewModal} size="sm" variant="subtle">
|
||||
{t('common.preview', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!isQueryBuilderExpanded}
|
||||
leftSection={<Icon icon="save" />}
|
||||
loading={createPlaylistMutation?.isPending}
|
||||
onClick={() => {
|
||||
if (!isQueryBuilderExpanded) return;
|
||||
const payload = getFiltersForSave();
|
||||
if (payload) {
|
||||
handleSaveAs(payload.filter, payload.extraFilters);
|
||||
} else if (editorMode === 'json') {
|
||||
toast.error({
|
||||
message: t('error.invalidJson', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
});
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.saveAs', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!isQueryBuilderExpanded}
|
||||
leftSection={<Icon color="error" icon="save" />}
|
||||
onClick={openSaveAndReplaceModal}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.saveAndReplace', {
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Box
|
||||
py="md"
|
||||
style={{
|
||||
display: isQueryBuilderExpanded ? 'flex' : 'none',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{editorMode === 'builder' ? (
|
||||
<PlaylistQueryBuilder
|
||||
key={JSON.stringify(appliedJsonState ?? detailQuery?.data?.rules)}
|
||||
limit={effectiveLimit}
|
||||
playlistId={playlistId}
|
||||
query={effectiveQuery}
|
||||
ref={queryBuilderRef}
|
||||
sortBy={effectiveSortBy}
|
||||
sortOrder={effectiveSortOrder}
|
||||
/>
|
||||
) : (
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||
<JsonInput
|
||||
autosize
|
||||
minRows={8}
|
||||
onChange={(value) => setJsonText(value)}
|
||||
placeholder='{ "all": [], "limit": 100, "sort": "+dateAdded" }'
|
||||
spellCheck={false}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
value={jsonText}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,25 +5,17 @@ import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-searc
|
||||
import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
|
||||
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
|
||||
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||
import { useAppStore } from '/@/renderer/store/app.store';
|
||||
import {
|
||||
parseArrayParam,
|
||||
parseBooleanParam,
|
||||
parseCustomFiltersParam,
|
||||
parseIntParam,
|
||||
setMultipleSearchParams,
|
||||
setSearchParam,
|
||||
} from '/@/renderer/utils/query-params';
|
||||
import { SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
export const usePlaylistSongListFilters = () => {
|
||||
const albumArtistIdsMode = useAppStore((state) => state.albumArtistIdsMode);
|
||||
const artistIdsMode = useAppStore((state) => state.artistIdsMode);
|
||||
const genreIdsMode = useAppStore((state) => state.genreIdsMode);
|
||||
const setAlbumArtistIdsModeStore = useAppStore((state) => state.actions.setAlbumArtistIdsMode);
|
||||
const setArtistIdsModeStore = useAppStore((state) => state.actions.setArtistIdsMode);
|
||||
const setGenreIdsModeStore = useAppStore((state) => state.actions.setGenreIdsMode);
|
||||
const { sortBy } = useSortByFilter<SongListSort>(SongListSort.ID, ItemListKey.PLAYLIST_SONG);
|
||||
|
||||
const { sortOrder } = useSortOrderFilter(SortOrder.ASC, ItemListKey.PLAYLIST_SONG);
|
||||
@@ -32,8 +24,8 @@ export const usePlaylistSongListFilters = () => {
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const albumArtistIds = useMemo(
|
||||
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS),
|
||||
const albumIds = useMemo(
|
||||
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_IDS),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
@@ -62,22 +54,16 @@ export const usePlaylistSongListFilters = () => {
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const hasRating = useMemo(
|
||||
() => parseBooleanParam(searchParams, FILTER_KEYS.SONG.HAS_RATING),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const custom = useMemo(
|
||||
() => parseCustomFiltersParam(searchParams, FILTER_KEYS.SONG._CUSTOM),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const setAlbumArtistIds = useCallback(
|
||||
const setAlbumIds = useCallback(
|
||||
(value: null | string[]) => {
|
||||
setSearchParams(
|
||||
(prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS, value),
|
||||
{ replace: true },
|
||||
);
|
||||
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_IDS, value), {
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
@@ -127,30 +113,6 @@ export const usePlaylistSongListFilters = () => {
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const setHasRating = useCallback(
|
||||
(value: boolean | null) => {
|
||||
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.HAS_RATING, value), {
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const setAlbumArtistIdsMode = useCallback(
|
||||
(value: 'and' | 'or') => setAlbumArtistIdsModeStore(value),
|
||||
[setAlbumArtistIdsModeStore],
|
||||
);
|
||||
|
||||
const setArtistIdsMode = useCallback(
|
||||
(value: 'and' | 'or') => setArtistIdsModeStore(value),
|
||||
[setArtistIdsModeStore],
|
||||
);
|
||||
|
||||
const setGenreIdsMode = useCallback(
|
||||
(value: 'and' | 'or') => setGenreIdsModeStore(value),
|
||||
[setGenreIdsModeStore],
|
||||
);
|
||||
|
||||
const setCustom = useCallback(
|
||||
(value: null | Record<string, any>) => {
|
||||
setSearchParams(
|
||||
@@ -179,74 +141,26 @@ export const usePlaylistSongListFilters = () => {
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setSearchParams(
|
||||
(prev) =>
|
||||
setMultipleSearchParams(
|
||||
prev,
|
||||
{
|
||||
[FILTER_KEYS.SONG._CUSTOM]: null,
|
||||
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: null,
|
||||
[FILTER_KEYS.SONG.ARTIST_IDS]: null,
|
||||
[FILTER_KEYS.SONG.FAVORITE]: null,
|
||||
[FILTER_KEYS.SONG.GENRE_ID]: null,
|
||||
[FILTER_KEYS.SONG.HAS_RATING]: null,
|
||||
[FILTER_KEYS.SONG.MAX_YEAR]: null,
|
||||
[FILTER_KEYS.SONG.MIN_YEAR]: null,
|
||||
},
|
||||
new Set([FILTER_KEYS.SONG._CUSTOM]),
|
||||
),
|
||||
{ replace: true },
|
||||
);
|
||||
}, [setSearchParams]);
|
||||
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
|
||||
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
|
||||
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
|
||||
[FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined,
|
||||
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: albumArtistIds ?? undefined,
|
||||
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE]: albumArtistIdsMode,
|
||||
[FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined,
|
||||
[FILTER_KEYS.SONG.ARTIST_IDS_MODE]: artistIdsMode,
|
||||
[FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined,
|
||||
[FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined,
|
||||
[FILTER_KEYS.SONG.GENRE_ID_MODE]: genreIdsMode,
|
||||
[FILTER_KEYS.SONG.HAS_RATING]: hasRating ?? undefined,
|
||||
[FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined,
|
||||
[FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined,
|
||||
}),
|
||||
[
|
||||
searchTerm,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
custom,
|
||||
albumArtistIds,
|
||||
albumArtistIdsMode,
|
||||
artistIds,
|
||||
artistIdsMode,
|
||||
favorite,
|
||||
genreId,
|
||||
genreIdsMode,
|
||||
hasRating,
|
||||
maxYear,
|
||||
minYear,
|
||||
],
|
||||
);
|
||||
const query = {
|
||||
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
|
||||
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
|
||||
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
|
||||
[FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined,
|
||||
[FILTER_KEYS.SONG.ALBUM_IDS]: albumIds ?? undefined,
|
||||
[FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined,
|
||||
[FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined,
|
||||
[FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined,
|
||||
[FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined,
|
||||
[FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
clear,
|
||||
query,
|
||||
setAlbumArtistIds,
|
||||
setAlbumArtistIdsMode,
|
||||
setAlbumIds,
|
||||
setArtistIds,
|
||||
setArtistIdsMode,
|
||||
setCustom,
|
||||
setFavorite,
|
||||
setGenreId,
|
||||
setGenreIdsMode,
|
||||
setHasRating,
|
||||
setMaxYear,
|
||||
setMinYear,
|
||||
setSearchTerm,
|
||||
|
||||
@@ -3,88 +3,9 @@ import { useEffect, useMemo } from 'react';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
|
||||
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
|
||||
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
|
||||
import { sortSongList } from '/@/shared/api/utils';
|
||||
import {
|
||||
LibraryItem,
|
||||
PlaylistSongListResponse,
|
||||
Song,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
|
||||
export function applyClientSideSongFilters(songs: Song[], query: Record<string, unknown>): Song[] {
|
||||
let result = songs;
|
||||
|
||||
const favorite = query[FILTER_KEYS.SONG.FAVORITE] as boolean | undefined;
|
||||
if (favorite === true) {
|
||||
result = result.filter((s) => s.userFavorite === true);
|
||||
} else if (favorite === false) {
|
||||
result = result.filter((s) => s.userFavorite === false);
|
||||
}
|
||||
|
||||
const hasRating = query[FILTER_KEYS.SONG.HAS_RATING] as boolean | undefined;
|
||||
if (hasRating === true) {
|
||||
result = result.filter((s) => s.userRating != null && s.userRating > 0);
|
||||
} else if (hasRating === false) {
|
||||
result = result.filter((s) => s.userRating == null || s.userRating === 0);
|
||||
}
|
||||
|
||||
const albumArtistIdsMode =
|
||||
(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
|
||||
const albumArtistIds = query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS] as string[] | undefined;
|
||||
if (albumArtistIds?.length) {
|
||||
if (albumArtistIdsMode === 'and') {
|
||||
result = result.filter((s) =>
|
||||
albumArtistIds!.every((id) => s.albumArtists?.some((a) => a.id === id)),
|
||||
);
|
||||
} else {
|
||||
const set = new Set(albumArtistIds);
|
||||
result = result.filter((s) => s.albumArtists?.some((a) => a.id && set.has(a.id)));
|
||||
}
|
||||
}
|
||||
|
||||
const artistIdsMode =
|
||||
(query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
|
||||
const artistIds = query[FILTER_KEYS.SONG.ARTIST_IDS] as string[] | undefined;
|
||||
if (artistIds?.length) {
|
||||
if (artistIdsMode === 'and') {
|
||||
result = result.filter((s) =>
|
||||
artistIds!.every((id) => s.artists?.some((a) => a.id === id)),
|
||||
);
|
||||
} else {
|
||||
const set = new Set(artistIds);
|
||||
result = result.filter((s) => s.artists?.some((a) => a.id && set.has(a.id)));
|
||||
}
|
||||
}
|
||||
|
||||
const genreIdsMode =
|
||||
(query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';
|
||||
const genreIds = query[FILTER_KEYS.SONG.GENRE_ID] as string[] | undefined;
|
||||
if (genreIds?.length) {
|
||||
if (genreIdsMode === 'and') {
|
||||
result = result.filter((s) =>
|
||||
genreIds!.every((id) => s.genres?.some((g) => g.id === id)),
|
||||
);
|
||||
} else {
|
||||
const set = new Set(genreIds);
|
||||
result = result.filter((s) => s.genres?.some((g) => g.id && set.has(g.id)));
|
||||
}
|
||||
}
|
||||
|
||||
const minYear = query[FILTER_KEYS.SONG.MIN_YEAR] as number | undefined;
|
||||
if (minYear != null) {
|
||||
result = result.filter((s) => s.releaseYear != null && s.releaseYear >= minYear);
|
||||
}
|
||||
|
||||
const maxYear = query[FILTER_KEYS.SONG.MAX_YEAR] as number | undefined;
|
||||
if (maxYear != null) {
|
||||
result = result.filter((s) => s.releaseYear != null && s.releaseYear <= maxYear);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
import { LibraryItem, PlaylistSongListResponse, Song } from '/@/shared/types/domain-types';
|
||||
|
||||
export function usePlaylistTrackList(data: PlaylistSongListResponse | undefined): {
|
||||
sortedAndFilteredSongs: Song[];
|
||||
@@ -96,23 +17,20 @@ export function usePlaylistTrackList(data: PlaylistSongListResponse | undefined)
|
||||
|
||||
const sortedAndFilteredSongs = useMemo(() => {
|
||||
const raw = data?.items ?? [];
|
||||
const filtered = applyClientSideSongFilters(raw, query as Record<string, unknown>);
|
||||
const searched = searchTerm
|
||||
? searchLibraryItems(filtered, searchTerm, LibraryItem.SONG)
|
||||
: filtered;
|
||||
return sortSongList(
|
||||
searched,
|
||||
(query.sortBy as SongListSort) ?? SongListSort.ID,
|
||||
(query.sortOrder as SortOrder) ?? SortOrder.ASC,
|
||||
);
|
||||
}, [data?.items, query, searchTerm]);
|
||||
|
||||
if (searchTerm) {
|
||||
return searchLibraryItems(raw, searchTerm, LibraryItem.SONG);
|
||||
}
|
||||
|
||||
return sortSongList(raw, query.sortBy, query.sortOrder);
|
||||
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
|
||||
|
||||
const totalCount = sortedAndFilteredSongs.length;
|
||||
|
||||
useEffect(() => {
|
||||
setListData?.(sortedAndFilteredSongs);
|
||||
setItemCount?.(totalCount);
|
||||
}, [query, searchTerm, setListData, setItemCount, sortedAndFilteredSongs, totalCount]);
|
||||
}, [sortedAndFilteredSongs, totalCount, setListData, setItemCount]);
|
||||
|
||||
return { sortedAndFilteredSongs, totalCount };
|
||||
}
|
||||
|
||||
@@ -1,71 +1,238 @@
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Suspense, useMemo, useRef, useState } from 'react';
|
||||
import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath, useLocation, useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { ListContext, useListContext } from '/@/renderer/context/list-context';
|
||||
import { ListContext } from '/@/renderer/context/list-context';
|
||||
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||
import { ClientSideSongFilters } from '/@/renderer/features/playlists/components/client-side-song-filters';
|
||||
import { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content';
|
||||
import { PlaylistDetailSongListHeader } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header';
|
||||
import { PlaylistQueryBuilderRef } from '/@/renderer/features/playlists/components/playlist-query-builder';
|
||||
import { PlaylistQueryEditor } from '/@/renderer/features/playlists/components/playlist-query-editor';
|
||||
import {
|
||||
PlaylistQueryBuilder,
|
||||
PlaylistQueryBuilderRef,
|
||||
} from '/@/renderer/features/playlists/components/playlist-query-builder';
|
||||
import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form';
|
||||
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
|
||||
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
|
||||
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
|
||||
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
|
||||
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
|
||||
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import {
|
||||
PlaylistTarget,
|
||||
useCurrentServer,
|
||||
usePageSidebar,
|
||||
usePlaylistTarget,
|
||||
} from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { PlaylistTarget, useCurrentServer, usePlaylistTarget } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { ConfirmModal } from '/@/shared/components/modal/modal';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
|
||||
import { LibraryItem, ServerType, SongListSort } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
const PlaylistSongListFiltersSidebar = () => {
|
||||
interface PlaylistQueryEditorProps {
|
||||
createPlaylistMutation: ReturnType<typeof useCreatePlaylist>;
|
||||
detailQuery: ReturnType<typeof useQuery<any>>;
|
||||
handleSave: (
|
||||
filter: Record<string, any>,
|
||||
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
|
||||
) => void;
|
||||
handleSaveAs: (
|
||||
filter: Record<string, any>,
|
||||
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
|
||||
) => void;
|
||||
isQueryBuilderExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
playlistId: string;
|
||||
queryBuilderRef: React.RefObject<null | PlaylistQueryBuilderRef>;
|
||||
}
|
||||
|
||||
const PlaylistQueryEditor = ({
|
||||
createPlaylistMutation,
|
||||
detailQuery,
|
||||
handleSave,
|
||||
handleSaveAs,
|
||||
isQueryBuilderExpanded,
|
||||
onToggleExpand,
|
||||
playlistId,
|
||||
queryBuilderRef,
|
||||
}: PlaylistQueryEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setIsSidebarOpen } = useListContext();
|
||||
const { clear } = usePlaylistSongListFilters();
|
||||
|
||||
const openPreviewModal = useCallback(() => {
|
||||
const filters = queryBuilderRef.current?.getFilters();
|
||||
|
||||
if (!filters) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryValue = convertQueryGroupToNDQuery(filters.filters);
|
||||
const sortString = filters.extraFilters.sortBy?.[0];
|
||||
|
||||
const previewValue = {
|
||||
...queryValue,
|
||||
...(filters.extraFilters.limit && { limit: filters.extraFilters.limit }),
|
||||
...(sortString && { sort: sortString }),
|
||||
};
|
||||
|
||||
openModal({
|
||||
children: <JsonPreview value={previewValue} />,
|
||||
size: 'xl',
|
||||
title: t('common.preview', { postProcess: 'titleCase' }),
|
||||
});
|
||||
}, [queryBuilderRef, t]);
|
||||
|
||||
const openSaveAndReplaceModal = useCallback(() => {
|
||||
if (!isQueryBuilderExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filters = queryBuilderRef.current?.getFilters();
|
||||
|
||||
if (!filters) {
|
||||
return;
|
||||
}
|
||||
|
||||
openModal({
|
||||
children: (
|
||||
<ConfirmModal
|
||||
onConfirm={() => {
|
||||
handleSave(
|
||||
convertQueryGroupToNDQuery(filters.filters),
|
||||
filters.extraFilters,
|
||||
);
|
||||
closeAllModals();
|
||||
}}
|
||||
>
|
||||
<Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
|
||||
</ConfirmModal>
|
||||
),
|
||||
title: t('common.saveAndReplace', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
}, [isQueryBuilderExpanded, queryBuilderRef, handleSave, t]);
|
||||
|
||||
const parseSortBy = useCallback((): string[] => {
|
||||
const sort = detailQuery?.data?.rules?.sort;
|
||||
// Handle new syntax: comma-separated with +/- prefix
|
||||
// e.g., "+album,-year" -> return as single string in array
|
||||
if (typeof sort === 'string') {
|
||||
// Check if it's new syntax (has +/- prefix or commas)
|
||||
if (sort.includes(',') || sort.startsWith('+') || sort.startsWith('-')) {
|
||||
return [sort];
|
||||
}
|
||||
// Old syntax: single field, convert to new format with default order
|
||||
const order = detailQuery?.data?.rules?.order || 'asc';
|
||||
const prefix = order === 'desc' ? '-' : '+';
|
||||
return [`${prefix}${sort}`];
|
||||
}
|
||||
if (Array.isArray(sort)) {
|
||||
// If array, check if first item has +/- prefix
|
||||
if (
|
||||
sort.length > 0 &&
|
||||
typeof sort[0] === 'string' &&
|
||||
(sort[0].startsWith('+') || sort[0].startsWith('-'))
|
||||
) {
|
||||
return sort;
|
||||
}
|
||||
// Old array format, convert to new format
|
||||
const order = detailQuery?.data?.rules?.order || 'asc';
|
||||
const prefix = order === 'desc' ? '-' : '+';
|
||||
return sort.map((s) => `${prefix}${s}`);
|
||||
}
|
||||
return ['+dateAdded'];
|
||||
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
|
||||
|
||||
const parseSortOrder = useCallback((): 'asc' | 'desc' => {
|
||||
const sort = detailQuery?.data?.rules?.sort;
|
||||
if (typeof sort === 'string' && sort.startsWith('-')) {
|
||||
return 'desc';
|
||||
}
|
||||
// Fall back to old order field or default
|
||||
return detailQuery?.data?.rules?.order || 'asc';
|
||||
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
|
||||
|
||||
return (
|
||||
<Stack h="100%" style={{ minHeight: 0 }}>
|
||||
<Group justify="space-between" pb={0} pl="md" pr="md" pt="md">
|
||||
<Text fw={500} size="xl">
|
||||
{t('common.filters', { postProcess: 'sentenceCase' })}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button onClick={clear} size="compact-sm" variant="subtle">
|
||||
{t('common.reset', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
{setIsSidebarOpen && (
|
||||
<ActionIcon
|
||||
icon="unpin"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
size="compact-sm"
|
||||
<div
|
||||
className="query-editor-container"
|
||||
style={{ borderTop: '1px solid var(--theme-colors-border)' }}
|
||||
>
|
||||
<Stack gap={0} h="100%" mah="30dvh" p="sm" w="100%">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button
|
||||
leftSection={
|
||||
<Icon
|
||||
icon={isQueryBuilderExpanded ? 'arrowDownS' : 'arrowUpS'}
|
||||
size="lg"
|
||||
/>
|
||||
}
|
||||
onClick={onToggleExpand}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{t('form.queryEditor.title', {
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
</Button>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Button onClick={openPreviewModal} size="sm" variant="subtle">
|
||||
{t('common.preview', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!isQueryBuilderExpanded}
|
||||
leftSection={<Icon icon="save" />}
|
||||
loading={createPlaylistMutation?.isPending}
|
||||
onClick={() => {
|
||||
if (!isQueryBuilderExpanded) return;
|
||||
const filters = queryBuilderRef.current?.getFilters();
|
||||
if (filters) {
|
||||
handleSaveAs(
|
||||
convertQueryGroupToNDQuery(filters.filters),
|
||||
filters.extraFilters,
|
||||
);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.saveAs', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!isQueryBuilderExpanded}
|
||||
leftSection={<Icon color="error" icon="save" />}
|
||||
onClick={openSaveAndReplaceModal}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.saveAndReplace', {
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||
<ClientSideSongFilters />
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
<div
|
||||
style={{
|
||||
display: isQueryBuilderExpanded ? 'flex' : 'none',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<PlaylistQueryBuilder
|
||||
key={JSON.stringify(detailQuery?.data?.rules)}
|
||||
limit={detailQuery?.data?.rules?.limit}
|
||||
playlistId={playlistId}
|
||||
query={detailQuery?.data?.rules}
|
||||
ref={queryBuilderRef}
|
||||
sortBy={parseSortBy() as SongListSort | SongListSort[]}
|
||||
sortOrder={parseSortOrder()}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -241,36 +408,23 @@ const PlaylistDetailSongListRoute = () => {
|
||||
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
|
||||
const [listData, setListData] = useState<unknown[]>([]);
|
||||
const [mode, setMode] = useState<'edit' | 'view'>('view');
|
||||
const [isSidebarOpen, setIsSidebarOpen] = usePageSidebar(listKey);
|
||||
|
||||
const providerValue = useMemo(() => {
|
||||
return {
|
||||
customFilters: undefined,
|
||||
displayMode,
|
||||
id: playlistId,
|
||||
isSidebarOpen,
|
||||
isSmartPlaylist,
|
||||
itemCount,
|
||||
listData,
|
||||
listKey,
|
||||
mode,
|
||||
pageKey: listKey,
|
||||
setIsSidebarOpen,
|
||||
setItemCount,
|
||||
setListData,
|
||||
setMode,
|
||||
};
|
||||
}, [
|
||||
playlistId,
|
||||
isSmartPlaylist,
|
||||
displayMode,
|
||||
listKey,
|
||||
isSidebarOpen,
|
||||
itemCount,
|
||||
listData,
|
||||
mode,
|
||||
setIsSidebarOpen,
|
||||
]);
|
||||
}, [playlistId, isSmartPlaylist, displayMode, listKey, itemCount, listData, mode]);
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
|
||||
@@ -287,14 +441,9 @@ const PlaylistDetailSongListRoute = () => {
|
||||
onToggleQueryBuilder={handleToggleShowQueryBuilder}
|
||||
/>
|
||||
|
||||
<ListWithSidebarContainer>
|
||||
<ListWithSidebarContainer.SidebarPortal>
|
||||
<PlaylistSongListFiltersSidebar />
|
||||
</ListWithSidebarContainer.SidebarPortal>
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<PlaylistDetailSongListContent />
|
||||
</Suspense>
|
||||
</ListWithSidebarContainer>
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<PlaylistDetailSongListContent />
|
||||
</Suspense>
|
||||
{(isSmartPlaylist || showQueryBuilder) && (
|
||||
<PlaylistQueryEditor
|
||||
createPlaylistMutation={createPlaylistMutation}
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { FilterBar } from '/@/renderer/features/shared/components/filter-bar';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
|
||||
import {
|
||||
ListConfigMenu,
|
||||
SONG_DISPLAY_TYPES,
|
||||
} from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { Button, ButtonGroup } from '/@/shared/components/button/button';
|
||||
@@ -47,7 +44,6 @@ export const SearchHeader = ({ navigationId }: SearchHeaderProps) => {
|
||||
tableColumnsData: ALBUM_ARTIST_TABLE_COLUMNS,
|
||||
},
|
||||
[LibraryItem.SONG]: {
|
||||
displayTypes: SONG_DISPLAY_TYPES,
|
||||
listKey: ItemListKey.SONG,
|
||||
tableColumnsData: SONG_TABLE_COLUMNS,
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ import JellyfinIcon from '/@/renderer/features/servers/assets/jellyfin.png';
|
||||
import NavidromeIcon from '/@/renderer/features/servers/assets/navidrome.png';
|
||||
import SubsonicIcon from '/@/renderer/features/servers/assets/opensubsonic.png';
|
||||
import { IgnoreCorsSslSwitches } from '/@/renderer/features/servers/components/ignore-cors-ssl-switches';
|
||||
import { useAuthStoreActions, useServerList } from '/@/renderer/store';
|
||||
import { useAuthStoreActions } from '/@/renderer/store';
|
||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
@@ -98,7 +98,6 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||
const focusTrapRef = useFocusTrap(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { addServer, setCurrentServer } = useAuthStoreActions();
|
||||
const serverList = useServerList();
|
||||
const { servers: discovered } = useAutodiscovery();
|
||||
|
||||
const serverLock = isServerLock();
|
||||
@@ -129,13 +128,6 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||
};
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
if (serverLock && Object.keys(serverList).length >= 1) {
|
||||
toast.error({
|
||||
message: t('error.serverLockSingleServer', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const authFunction = api.controller.authenticate;
|
||||
|
||||
if (!authFunction) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { openContextModal } from '@mantine/modals';
|
||||
import isElectron from 'is-electron';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';
|
||||
import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';
|
||||
import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';
|
||||
import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';
|
||||
@@ -24,7 +23,6 @@ export const ServerList = () => {
|
||||
const { t } = useTranslation();
|
||||
const currentServer = useCurrentServer();
|
||||
const serverListQuery = useServerList();
|
||||
const serverLock = isServerLock();
|
||||
|
||||
const handleAddServerModal = () => {
|
||||
openContextModal({
|
||||
@@ -72,17 +70,15 @@ export const ServerList = () => {
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
{!serverLock && (
|
||||
<Group grow pt="md">
|
||||
<Button
|
||||
autoFocus
|
||||
leftSection={<Icon icon="add" />}
|
||||
onClick={handleAddServerModal}
|
||||
>
|
||||
{t('form.addServer.title', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
<Group grow pt="md">
|
||||
<Button
|
||||
autoFocus
|
||||
leftSection={<Icon icon="add" />}
|
||||
onClick={handleAddServerModal}
|
||||
>
|
||||
{t('form.addServer.title', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
</Accordion>
|
||||
{isElectron() && (
|
||||
<>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
|
||||
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Tabs } from '/@/shared/components/tabs/tabs';
|
||||
|
||||
const GeneralTab = lazy(() =>
|
||||
@@ -72,29 +71,29 @@ export const SettingsContent = () => {
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="general">
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<Suspense fallback={null}>
|
||||
<GeneralTab />
|
||||
</Suspense>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="playback">
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<Suspense fallback={null}>
|
||||
<PlaybackTab />
|
||||
</Suspense>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="hotkeys">
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<Suspense fallback={null}>
|
||||
<HotkeysTab />
|
||||
</Suspense>
|
||||
</Tabs.Panel>
|
||||
{isElectron() && (
|
||||
<Tabs.Panel value="window">
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<Suspense fallback={null}>
|
||||
<WindowTab />
|
||||
</Suspense>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
<Tabs.Panel value="advanced">
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<Suspense fallback={null}>
|
||||
<AdvancedTab />
|
||||
</Suspense>
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -98,28 +98,6 @@ export const DiscordSettings = memo(() => {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
checked={settings.showStateIcon}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
discord: {
|
||||
showStateIcon: e.currentTarget.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: t('setting.discordStateIcon', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
isHidden: !isElectron(),
|
||||
title: t('setting.discordStateIcon', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
|
||||
@@ -30,7 +30,7 @@ export const sharedQueries = {
|
||||
},
|
||||
tagList: (args: QueryHookArgs<TagListQuery>) => {
|
||||
return queryOptions({
|
||||
gcTime: 1000 * 60 * 24,
|
||||
gcTime: 1000 * 60,
|
||||
queryFn: ({ signal }) => {
|
||||
return api.controller.getTagList({
|
||||
apiClientProps: { serverId: args.serverId, signal },
|
||||
@@ -38,8 +38,7 @@ export const sharedQueries = {
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.tags.list(args.serverId || '', args.query.type),
|
||||
staleTime: 1000 * 60 * 24,
|
||||
structuralSharing: false,
|
||||
staleTime: 1000 * 60,
|
||||
...args.options,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -18,10 +18,6 @@ import { Table } from '/@/shared/components/table/table';
|
||||
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
|
||||
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
export const SONG_DISPLAY_TYPES: ListConfigMenuDisplayTypeConfig[] = [
|
||||
{ hidden: true, value: ListDisplayType.DETAIL },
|
||||
];
|
||||
|
||||
const DISPLAY_TYPES = [
|
||||
{
|
||||
label: (
|
||||
|
||||
@@ -61,14 +61,10 @@ enum SharedFilterKeys {
|
||||
|
||||
enum SongFilterKeys {
|
||||
_CUSTOM = '_custom',
|
||||
ALBUM_ARTIST_IDS = 'albumArtistIds',
|
||||
ALBUM_ARTIST_IDS_MODE = 'albumArtistIdsMode',
|
||||
ALBUM_IDS = 'albumIds',
|
||||
ARTIST_IDS = 'artistIds',
|
||||
ARTIST_IDS_MODE = 'artistIdsMode',
|
||||
FAVORITE = 'favorite',
|
||||
GENRE_ID = 'genreIds',
|
||||
GENRE_ID_MODE = 'genreIdsMode',
|
||||
HAS_RATING = 'hasRating',
|
||||
MAX_YEAR = 'maxYear',
|
||||
MIN_YEAR = 'minYear',
|
||||
}
|
||||
@@ -128,12 +124,23 @@ export const createFuseForLibraryItem = <T extends FuseSearchableItem>(
|
||||
});
|
||||
}
|
||||
|
||||
const stringKeys: string[] = [];
|
||||
const sampleItem = items[0];
|
||||
|
||||
const stringKeys = Object.keys(sampleItem).filter(
|
||||
(key) =>
|
||||
typeof sampleItem[key as keyof T] === 'string' &&
|
||||
!key.startsWith('_') &&
|
||||
key !== 'id' &&
|
||||
key !== 'albumId' &&
|
||||
key !== 'streamUrl' &&
|
||||
key !== 'serverId' &&
|
||||
key !== 'ownerId',
|
||||
) as string[];
|
||||
|
||||
const nestedKeys: Array<{ getFn: (item: T) => string; name: string }> = [];
|
||||
|
||||
switch (itemType) {
|
||||
case LibraryItem.ALBUM: {
|
||||
stringKeys.push('name', 'releaseType');
|
||||
nestedKeys.push(
|
||||
{
|
||||
getFn: (item) => {
|
||||
@@ -161,7 +168,6 @@ export const createFuseForLibraryItem = <T extends FuseSearchableItem>(
|
||||
}
|
||||
|
||||
case LibraryItem.ALBUM_ARTIST: {
|
||||
stringKeys.push('name');
|
||||
nestedKeys.push({
|
||||
getFn: (item) => {
|
||||
const aa = item as AlbumArtist;
|
||||
@@ -175,10 +181,9 @@ export const createFuseForLibraryItem = <T extends FuseSearchableItem>(
|
||||
case LibraryItem.ARTIST:
|
||||
case LibraryItem.GENRE:
|
||||
case LibraryItem.RADIO_STATION:
|
||||
stringKeys.push('name');
|
||||
break;
|
||||
|
||||
case LibraryItem.PLAYLIST: {
|
||||
stringKeys.push('name');
|
||||
nestedKeys.push({
|
||||
getFn: (item) => {
|
||||
const p = item as Playlist;
|
||||
@@ -191,8 +196,7 @@ export const createFuseForLibraryItem = <T extends FuseSearchableItem>(
|
||||
|
||||
case LibraryItem.PLAYLIST_SONG:
|
||||
case LibraryItem.QUEUE_SONG:
|
||||
case LibraryItem.SONG:
|
||||
stringKeys.push('album', 'name');
|
||||
case LibraryItem.SONG: {
|
||||
nestedKeys.push(
|
||||
{
|
||||
getFn: (item) => {
|
||||
@@ -210,6 +214,7 @@ export const createFuseForLibraryItem = <T extends FuseSearchableItem>(
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new Fuse(items, {
|
||||
|
||||
@@ -53,10 +53,6 @@
|
||||
border-radius: var(--theme-radius-md);
|
||||
}
|
||||
|
||||
.censored.sidebar-image {
|
||||
filter: blur(20px);
|
||||
}
|
||||
|
||||
.accordion-root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
useAppStore,
|
||||
useAppStoreActions,
|
||||
useFullScreenPlayerStore,
|
||||
useGeneralSettings,
|
||||
usePlayerSong,
|
||||
useSetFullScreenPlayerStore,
|
||||
} from '/@/renderer/store';
|
||||
@@ -43,7 +42,7 @@ import { ImageUnloader } from '/@/shared/components/image/image';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
import { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { Platform } from '/@/shared/types/types';
|
||||
|
||||
export const Sidebar = () => {
|
||||
@@ -168,7 +167,6 @@ const SidebarImage = () => {
|
||||
const currentSong = usePlayerSong();
|
||||
const isRadioActive = useIsRadioActive();
|
||||
const { isPlaying: isRadioPlaying } = useRadioPlayer();
|
||||
const { blurExplicitImages } = useGeneralSettings();
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: currentSong?.imageId || undefined,
|
||||
@@ -237,15 +235,7 @@ const SidebarImage = () => {
|
||||
<Icon color="muted" icon="radio" size="40%" />
|
||||
</Center>
|
||||
) : imageUrl ? (
|
||||
<img
|
||||
className={clsx(styles.sidebarImage, {
|
||||
[styles.censored]:
|
||||
currentSong?.explicitStatus === ExplicitStatus.EXPLICIT &&
|
||||
blurExplicitImages,
|
||||
})}
|
||||
loading="eager"
|
||||
src={imageUrl}
|
||||
/>
|
||||
<img className={styles.sidebarImage} loading="eager" src={imageUrl} />
|
||||
) : (
|
||||
<ImageUnloader icon="emptySongImage" />
|
||||
)}
|
||||
|
||||
@@ -4,10 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
||||
import {
|
||||
ListConfigMenu,
|
||||
SONG_DISPLAY_TYPES,
|
||||
} from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';
|
||||
import {
|
||||
isFilterValueSet,
|
||||
@@ -55,6 +52,7 @@ export const SongListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarget
|
||||
const query = songFilters.query;
|
||||
return Boolean(
|
||||
isFilterValueSet(query[FILTER_KEYS.SONG._CUSTOM]) ||
|
||||
isFilterValueSet(query[FILTER_KEYS.SONG.ALBUM_IDS]) ||
|
||||
isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) ||
|
||||
query[FILTER_KEYS.SONG.FAVORITE] !== undefined ||
|
||||
isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) ||
|
||||
@@ -94,11 +92,7 @@ export const SongListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarget
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ListDisplayTypeToggleButton listKey={ItemListKey.SONG} />
|
||||
<ListConfigMenu
|
||||
displayTypes={SONG_DISPLAY_TYPES}
|
||||
listKey={ItemListKey.SONG}
|
||||
tableColumnsData={SONG_TABLE_COLUMNS}
|
||||
/>
|
||||
<ListConfigMenu listKey={ItemListKey.SONG} tableColumnsData={SONG_TABLE_COLUMNS} />
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-r
|
||||
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
|
||||
import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';
|
||||
import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import { useGeneralSettings } from '/@/renderer/store';
|
||||
import { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
@@ -32,11 +31,10 @@ export const SongListInfiniteGrid = ({
|
||||
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
||||
|
||||
const listQueryFn = api.controller.getSongList;
|
||||
const { pageKey } = useListContext();
|
||||
|
||||
const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =
|
||||
useItemListInfiniteLoader({
|
||||
eventKey: pageKey || ItemListKey.SONG,
|
||||
eventKey: ItemListKey.SONG,
|
||||
itemsPerPage,
|
||||
itemType: LibraryItem.SONG,
|
||||
listCountQuery,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpe
|
||||
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
|
||||
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import { usePlayerSong } from '/@/renderer/store';
|
||||
import { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
@@ -40,11 +39,10 @@ export const SongListInfiniteTable = ({
|
||||
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
||||
|
||||
const listQueryFn = api.controller.getSongList;
|
||||
const { pageKey } = useListContext();
|
||||
|
||||
const { getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =
|
||||
useItemListInfiniteLoader({
|
||||
eventKey: pageKey || ItemListKey.SONG,
|
||||
eventKey: ItemListKey.SONG,
|
||||
itemsPerPage,
|
||||
itemType: LibraryItem.SONG,
|
||||
listCountQuery,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/it
|
||||
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
|
||||
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
|
||||
import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import { useGeneralSettings } from '/@/renderer/store';
|
||||
import { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
@@ -26,7 +25,6 @@ export const SongListPaginatedGrid = ({
|
||||
serverId,
|
||||
size,
|
||||
}: SongListPaginatedGridProps) => {
|
||||
const { pageKey } = useListContext();
|
||||
const { currentPage, onChange } = useItemListPagination();
|
||||
|
||||
const listCountQuery = songsQueries.listCount({
|
||||
@@ -38,7 +36,7 @@ export const SongListPaginatedGrid = ({
|
||||
|
||||
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
|
||||
currentPage,
|
||||
eventKey: pageKey || ItemListKey.SONG,
|
||||
eventKey: ItemListKey.SONG,
|
||||
itemsPerPage,
|
||||
itemType: LibraryItem.SONG,
|
||||
listCountQuery,
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useItemListPagination } from '/@/renderer/components/item-list/item-lis
|
||||
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
|
||||
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import { usePlayerSong } from '/@/renderer/store';
|
||||
import { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
@@ -36,7 +35,6 @@ export const SongListPaginatedTable = ({
|
||||
serverId,
|
||||
size = 'default',
|
||||
}: SongListPaginatedTableProps) => {
|
||||
const { pageKey } = useListContext();
|
||||
const { currentPage, onChange } = useItemListPagination();
|
||||
|
||||
const listCountQuery = songsQueries.listCount({
|
||||
@@ -48,7 +46,7 @@ export const SongListPaginatedTable = ({
|
||||
|
||||
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
|
||||
currentPage,
|
||||
eventKey: pageKey || ItemListKey.SONG,
|
||||
eventKey: ItemListKey.SONG,
|
||||
itemsPerPage,
|
||||
itemType: LibraryItem.SONG,
|
||||
listCountQuery,
|
||||
|
||||
@@ -28,6 +28,11 @@ export const useSongListFilters = (listKey?: ItemListKey) => {
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const albumIds = useMemo(
|
||||
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_IDS),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const genreId = useMemo(
|
||||
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.GENRE_ID),
|
||||
[searchParams],
|
||||
@@ -58,6 +63,15 @@ export const useSongListFilters = (listKey?: ItemListKey) => {
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const setAlbumIds = useCallback(
|
||||
(value: null | string[]) => {
|
||||
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_IDS, value), {
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const setGenreId = useCallback(
|
||||
(value: null | string[]) => {
|
||||
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.GENRE_ID, value), {
|
||||
@@ -139,6 +153,7 @@ export const useSongListFilters = (listKey?: ItemListKey) => {
|
||||
{
|
||||
[FILTER_KEYS.SHARED.SEARCH_TERM]: null,
|
||||
[FILTER_KEYS.SONG._CUSTOM]: null,
|
||||
[FILTER_KEYS.SONG.ALBUM_IDS]: null,
|
||||
[FILTER_KEYS.SONG.ARTIST_IDS]: null,
|
||||
[FILTER_KEYS.SONG.FAVORITE]: null,
|
||||
[FILTER_KEYS.SONG.GENRE_ID]: null,
|
||||
@@ -157,18 +172,31 @@ export const useSongListFilters = (listKey?: ItemListKey) => {
|
||||
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
|
||||
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
|
||||
[FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined,
|
||||
[FILTER_KEYS.SONG.ALBUM_IDS]: albumIds ?? undefined,
|
||||
[FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined,
|
||||
[FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined,
|
||||
[FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined,
|
||||
[FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined,
|
||||
[FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined,
|
||||
}),
|
||||
[searchTerm, sortBy, sortOrder, custom, artistIds, favorite, genreId, maxYear, minYear],
|
||||
[
|
||||
searchTerm,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
custom,
|
||||
albumIds,
|
||||
artistIds,
|
||||
favorite,
|
||||
genreId,
|
||||
maxYear,
|
||||
minYear,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
clear,
|
||||
query,
|
||||
setAlbumIds,
|
||||
setArtistIds,
|
||||
setCustom,
|
||||
setFavorite,
|
||||
|
||||
@@ -27,17 +27,3 @@
|
||||
.main-content-container.sidebar-collapsed.right-expanded {
|
||||
grid-template-columns: 80px 1fr var(--right-sidebar-width);
|
||||
}
|
||||
|
||||
.main-content-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-content-body-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -6,18 +6,11 @@ import { shallow } from 'zustand/shallow';
|
||||
|
||||
import styles from './main-content.module.css';
|
||||
|
||||
import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container';
|
||||
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
|
||||
import { FullScreenOverlay } from '/@/renderer/layouts/default-layout/full-screen-overlay';
|
||||
import { FullScreenVisualizerOverlay } from '/@/renderer/layouts/default-layout/full-screen-visualizer-overlay';
|
||||
import { LeftSidebar } from '/@/renderer/layouts/default-layout/left-sidebar';
|
||||
import { RightSidebar } from '/@/renderer/layouts/default-layout/right-sidebar';
|
||||
import {
|
||||
useAppStore,
|
||||
useAppStoreActions,
|
||||
useGlobalExpanded,
|
||||
useSideQueueType,
|
||||
} from '/@/renderer/store';
|
||||
import { useAppStore, useAppStoreActions, useSideQueueType } from '/@/renderer/store';
|
||||
import { constrainRightSidebarWidth, constrainSidebarWidth } from '/@/renderer/utils';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
|
||||
@@ -166,27 +159,10 @@ export const MainContent = ({ shell }: { shell?: boolean }) => {
|
||||
);
|
||||
};
|
||||
|
||||
function GlobalExpandedPanel() {
|
||||
const globalExpanded = useGlobalExpanded();
|
||||
|
||||
if (!globalExpanded) return null;
|
||||
|
||||
return (
|
||||
<ExpandedListContainer>
|
||||
<ExpandedListItem item={globalExpanded.item} itemType={globalExpanded.itemType} />
|
||||
</ExpandedListContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function MainContentBody() {
|
||||
return (
|
||||
<div className={styles.mainContentBody}>
|
||||
<div className={styles.mainContentBodyScroll}>
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
<GlobalExpandedPanel />
|
||||
</div>
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AppOutlet } from '/@/renderer/router/app-outlet';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { TitlebarOutlet } from '/@/renderer/router/titlebar-outlet';
|
||||
import { BaseContextModal, ModalsProvider } from '/@/shared/components/modal/modal';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
|
||||
const NowPlayingRoute = lazy(
|
||||
() => import('/@/renderer/features/now-playing/routes/now-playing-route'),
|
||||
@@ -91,7 +90,7 @@ const LazyLyricsSettingsContextModal = lazy(() =>
|
||||
);
|
||||
|
||||
const LyricsSettingsContextModal = (props: any) => (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<Suspense fallback={<></>}>
|
||||
<LazyLyricsSettingsContextModal {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
@@ -103,7 +102,7 @@ const LazyShuffleAllContextModal = lazy(() =>
|
||||
);
|
||||
|
||||
const ShuffleAllContextModal = (props: any) => (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<Suspense fallback={<></>}>
|
||||
<LazyShuffleAllContextModal {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
@@ -117,7 +116,7 @@ const LazyAddToPlaylistContextModal = lazy(() =>
|
||||
);
|
||||
|
||||
const AddToPlaylistContextModal = (props: any) => (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<Suspense fallback={<></>}>
|
||||
<LazyAddToPlaylistContextModal {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
@@ -131,7 +130,7 @@ const LazySaveAndReplaceContextModal = lazy(() =>
|
||||
);
|
||||
|
||||
const SaveAndReplaceContextModal = (props: any) => (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<Suspense fallback={<></>}>
|
||||
<LazySaveAndReplaceContextModal {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
@@ -143,7 +142,7 @@ const LazyUpdatePlaylistContextModal = lazy(() =>
|
||||
);
|
||||
|
||||
const UpdatePlaylistContextModal = (props: any) => (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<Suspense fallback={<></>}>
|
||||
<LazyUpdatePlaylistContextModal {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
@@ -155,7 +154,7 @@ const LazySettingsContextModal = lazy(() =>
|
||||
);
|
||||
|
||||
const SettingsContextModal = (props: any) => (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<Suspense fallback={<></>}>
|
||||
<LazySettingsContextModal {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
@@ -167,7 +166,7 @@ const LazyShareItemContextModal = lazy(() =>
|
||||
);
|
||||
|
||||
const ShareItemContextModal = (props: any) => (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<Suspense fallback={<></>}>
|
||||
<LazyShareItemContextModal {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
@@ -181,7 +180,7 @@ const LazyVisualizerSettingsContextModal = lazy(() =>
|
||||
);
|
||||
|
||||
const VisualizerSettingsContextModal = (props: any) => (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<Suspense fallback={<></>}>
|
||||
<LazyVisualizerSettingsContextModal {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import type { ItemListStateItem } from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||
import type { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
import merge from 'lodash/merge';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
@@ -13,14 +10,9 @@ export interface AppSlice extends AppState {
|
||||
actions: {
|
||||
setAlbumArtistDetailGroupingType: (groupingType: 'all' | 'primary') => void;
|
||||
setAlbumArtistDetailSort: (sortBy: AlbumListSort, sortOrder: SortOrder) => void;
|
||||
setAlbumArtistIdsMode: (mode: 'and' | 'or') => void;
|
||||
setAlbumArtistSelectMode: (mode: 'multi' | 'single') => void;
|
||||
setAppStore: (data: Partial<AppSlice>) => void;
|
||||
setArtistIdsMode: (mode: 'and' | 'or') => void;
|
||||
setArtistSelectMode: (mode: 'multi' | 'single') => void;
|
||||
setGenreIdsMode: (mode: 'and' | 'or') => void;
|
||||
setGenreSelectMode: (mode: 'multi' | 'single') => void;
|
||||
setGlobalExpanded: (value: GlobalExpandedState | null) => void;
|
||||
setPageSidebar: (key: string, value: boolean) => void;
|
||||
setPrivateMode: (enabled: boolean) => void;
|
||||
setShowTimeRemaining: (enabled: boolean) => void;
|
||||
@@ -35,14 +27,9 @@ export interface AppState {
|
||||
sortBy: AlbumListSort;
|
||||
sortOrder: SortOrder;
|
||||
};
|
||||
albumArtistIdsMode: 'and' | 'or';
|
||||
albumArtistSelectMode: 'multi' | 'single';
|
||||
artistIdsMode: 'and' | 'or';
|
||||
artistSelectMode: 'multi' | 'single';
|
||||
commandPalette: CommandPaletteProps;
|
||||
genreIdsMode: 'and' | 'or';
|
||||
genreSelectMode: 'multi' | 'single';
|
||||
globalExpanded: GlobalExpandedState | null;
|
||||
isReorderingQueue: boolean;
|
||||
pageSidebar: Record<string, boolean>;
|
||||
platform: Platform;
|
||||
@@ -52,11 +39,6 @@ export interface AppState {
|
||||
titlebar: TitlebarProps;
|
||||
}
|
||||
|
||||
export interface GlobalExpandedState {
|
||||
item: ItemListStateItem;
|
||||
itemType: LibraryItem;
|
||||
}
|
||||
|
||||
type CommandPaletteProps = {
|
||||
close: () => void;
|
||||
open: () => void;
|
||||
@@ -97,44 +79,19 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
||||
};
|
||||
});
|
||||
},
|
||||
setAlbumArtistIdsMode: (mode) => {
|
||||
set((state) => {
|
||||
state.albumArtistIdsMode = mode;
|
||||
});
|
||||
},
|
||||
setAlbumArtistSelectMode: (mode) => {
|
||||
set((state) => {
|
||||
state.albumArtistSelectMode = mode;
|
||||
});
|
||||
},
|
||||
setAppStore: (data) => {
|
||||
set({ ...get(), ...data });
|
||||
},
|
||||
setArtistIdsMode: (mode) => {
|
||||
set((state) => {
|
||||
state.artistIdsMode = mode;
|
||||
});
|
||||
},
|
||||
setArtistSelectMode: (mode) => {
|
||||
set((state) => {
|
||||
state.artistSelectMode = mode;
|
||||
});
|
||||
},
|
||||
setGenreIdsMode: (mode) => {
|
||||
set((state) => {
|
||||
state.genreIdsMode = mode;
|
||||
});
|
||||
},
|
||||
setGenreSelectMode: (mode) => {
|
||||
set((state) => {
|
||||
state.genreSelectMode = mode;
|
||||
});
|
||||
},
|
||||
setGlobalExpanded: (value) => {
|
||||
set((state) => {
|
||||
state.globalExpanded = value;
|
||||
});
|
||||
},
|
||||
setPageSidebar: (key, value) => {
|
||||
set((state) => {
|
||||
state.pageSidebar[key] = value;
|
||||
@@ -166,9 +123,6 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
||||
sortBy: AlbumListSort.RELEASE_DATE,
|
||||
sortOrder: SortOrder.DESC,
|
||||
},
|
||||
albumArtistIdsMode: 'and',
|
||||
albumArtistSelectMode: 'multi',
|
||||
artistIdsMode: 'and',
|
||||
artistSelectMode: 'multi',
|
||||
commandPalette: {
|
||||
close: () => {
|
||||
@@ -188,9 +142,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
||||
});
|
||||
},
|
||||
},
|
||||
genreIdsMode: 'and',
|
||||
genreSelectMode: 'multi',
|
||||
globalExpanded: null,
|
||||
isReorderingQueue: false,
|
||||
pageSidebar: {
|
||||
album: true,
|
||||
@@ -226,12 +178,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
||||
return persistedState;
|
||||
},
|
||||
name: 'store_app',
|
||||
partialize: (state) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- ignore non-persisted state
|
||||
const { globalExpanded: _, ...rest } = state;
|
||||
return rest;
|
||||
},
|
||||
version: 4,
|
||||
version: 3,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -258,16 +205,3 @@ export const usePageSidebar = (key: string): [boolean, (value: boolean) => void]
|
||||
|
||||
return [isOpen, setIsOpen];
|
||||
};
|
||||
|
||||
export const useGlobalExpanded = () => useAppStore((state) => state.globalExpanded);
|
||||
|
||||
export const useSetGlobalExpanded = () => useAppStore((state) => state.actions.setGlobalExpanded);
|
||||
|
||||
export const useGlobalExpandedState = () => {
|
||||
const globalExpanded = useGlobalExpanded();
|
||||
const setGlobalExpanded = useSetGlobalExpanded();
|
||||
|
||||
const clearGlobalExpanded = () => setGlobalExpanded(null);
|
||||
|
||||
return { clearGlobalExpanded, globalExpanded, setGlobalExpanded };
|
||||
};
|
||||
|
||||
@@ -263,7 +263,6 @@ const DiscordSettingsSchema = z.object({
|
||||
showAsListening: z.boolean(),
|
||||
showPaused: z.boolean(),
|
||||
showServerImage: z.boolean(),
|
||||
showStateIcon: z.boolean(),
|
||||
});
|
||||
|
||||
const FontSettingsSchema = z.object({
|
||||
@@ -996,7 +995,6 @@ const initialState: SettingsState = {
|
||||
showAsListening: false,
|
||||
showPaused: true,
|
||||
showServerImage: false,
|
||||
showStateIcon: true,
|
||||
},
|
||||
font: {
|
||||
builtIn: 'Inter',
|
||||
|
||||
@@ -39,7 +39,3 @@
|
||||
.muted {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.with-space {
|
||||
padding-right: var(--theme-spacing-sm);
|
||||
}
|
||||
|
||||
@@ -32,12 +32,10 @@ export const ExplicitIndicator = ({
|
||||
return (
|
||||
<span
|
||||
aria-label={explicitStatus === ExplicitStatus.EXPLICIT ? 'Explicit' : 'Clean'}
|
||||
className={clsx(styles.root, styles[`size-${size}`], className, {
|
||||
[styles.withSpace]: withSpace,
|
||||
})}
|
||||
className={clsx(styles.root, styles[`size-${size}`], className)}
|
||||
{...rest}
|
||||
>
|
||||
{symbol}
|
||||
{withSpace ? `${symbol} ` : symbol}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
LuArrowUpNarrowWide,
|
||||
LuArrowUpToLine,
|
||||
LuBookOpen,
|
||||
LuBraces,
|
||||
LuCheck,
|
||||
LuChevronDown,
|
||||
LuChevronLast,
|
||||
@@ -118,7 +117,6 @@ import {
|
||||
LuVolumeX,
|
||||
LuWifi,
|
||||
LuWifiOff,
|
||||
LuWrench,
|
||||
LuX,
|
||||
} from 'react-icons/lu';
|
||||
import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';
|
||||
@@ -189,7 +187,6 @@ export const AppIcon = {
|
||||
info: LuInfo,
|
||||
itemAlbum: LuDisc3,
|
||||
itemSong: LuMusic,
|
||||
json: LuBraces,
|
||||
keyboard: LuKeyboard,
|
||||
lastPlayed: LuHeadphones,
|
||||
layoutDetail: LuLayoutList,
|
||||
@@ -230,7 +227,6 @@ export const AppIcon = {
|
||||
playlistAdd: LuListPlus,
|
||||
playlistDelete: LuListMinus,
|
||||
plus: LuPlus,
|
||||
queryBuilder: LuWrench,
|
||||
queue: LuList,
|
||||
radio: LuRadio,
|
||||
refresh: LuRotateCw,
|
||||
|
||||
@@ -24,24 +24,7 @@ export default defineConfig({
|
||||
),
|
||||
},
|
||||
output: {
|
||||
assetFileNames: (assetInfo) => {
|
||||
const stableNames = [
|
||||
'32x32',
|
||||
'64x64',
|
||||
'128x128',
|
||||
'256x256',
|
||||
'512x512',
|
||||
'1024x1024',
|
||||
'favicon',
|
||||
'preview_full_screen_player',
|
||||
];
|
||||
|
||||
if (assetInfo.name && stableNames.includes(assetInfo.name)) {
|
||||
return 'assets/[name][extname]';
|
||||
}
|
||||
|
||||
return 'assets/[name]-[hash][extname]';
|
||||
},
|
||||
assetFileNames: 'assets/[name].[ext]',
|
||||
sourcemapExcludeSources: false,
|
||||
},
|
||||
},
|
||||
@@ -131,10 +114,7 @@ export default defineConfig({
|
||||
registerType: 'autoUpdate',
|
||||
scope: '/assets/',
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
clientsClaim: true,
|
||||
maximumFileSizeToCacheInBytes: 1000000 * 5, // 5 MB
|
||||
skipWaiting: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||