Compare commits

...

47 Commits

Author SHA1 Message Date
jeffvli 289f307a52 update to v1.6.0 2026-02-13 23:08:33 -08:00
jeffvli 91ac36c835 fix Subsonic root folder condition (#1686)
- some older subsonic servers used numeric ids which causes the rootFolderId regex to fail which resulted in the getFolder endpoint to always attempt to fetch the root
2026-02-13 23:03:24 -08:00
jeffvli 503e4b2bac fix list refresh not working on discography/genre pages 2026-02-13 21:38:12 -08:00
jeffvli c39ddc3b45 refactor PlaylistQueryEditor to new file 2026-02-13 21:05:37 -08:00
jeffvli 1163c4ad5e add JSON editor for playlist query builder (#1711) 2026-02-13 21:05:34 -08:00
jeffvli e497734c07 allow all rule groups to be empty (#1710) 2026-02-13 20:26:58 -08:00
Kendall Garner 77fef33cbf improve album artist favorite performance and search (#1709)
* improve album artist favorite performance and search

* adjust top songs / favorite songs sections

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2026-02-13 20:13:28 -08:00
Hosted Weblate 81189db1e1 Translated using Weblate
Currently translated at 100.0% (1156 of 1156 strings) (Catalan)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/

Co-authored-by: HUMET <ressaguer@proton.me>
2026-02-14 03:46:02 +00:00
Maevi 054a3d005e Listen on IPv6 by default in docker image (#1707) 2026-02-13 19:45:55 -08:00
jeffvli dfbff64430 fix duplicate server add when SERVER_LOCK is configured (#1623) 2026-02-13 19:42:36 -08:00
jeffvli 2b4046a82e fix double click propagation on the table expand button 2026-02-13 17:49:49 -08:00
jeffvli 9eb879fc37 hide favorite / rating from playlist album view items 2026-02-13 17:39:35 -08:00
jeffvli 9e63ee2735 add loading spinners for some lazy loaded content 2026-02-13 15:04:54 -08:00
jeffvli 9950e51d45 remove lazy loading from context menu initialization 2026-02-13 15:01:18 -08:00
jeffvli 70fdd4bdc3 refactor album expansion to global scope 2026-02-13 14:59:15 -08:00
jeffvli e855f7dd01 remove invalid detail view type for song list config 2026-02-13 12:19:14 -08:00
jeffvli 123842dfda attempt to optimize the tagList query
- this query in some cases can return a very large amount of data, depending on the size of the user's library
- increasing the cacheTime reduces the frequency of fetches while disabling structuralSharing reduces the need for react-query to do a deep equality comparison for the cache
2026-02-13 11:53:42 -08:00
Hosted Weblate 1338513f82 Translated using Weblate
Currently translated at 100.0% (1156 of 1156 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Co-authored-by: Fjuro <fjuro@alius.cz>
2026-02-13 19:09:45 +01:00
Hosted Weblate c9c88dd82d Translated using Weblate
Currently translated at 100.0% (1156 of 1156 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

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

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: York <goog10216922@gmail.com>
2026-02-13 14:09:48 +01:00
York 02a5395453 fix: regenerate macOS icon (.icns) to fix glitched small icons (#1705) 2026-02-13 00:15:12 -08:00
jeffvli 7ba2f6b827 fix removed filter functions 2026-02-12 22:04:08 -08:00
jeffvli f1b5dc8ef3 add additional client-side filters to playlist songs 2026-02-12 22:00:07 -08:00
Kendall Garner 78875572e9 add explicit blurring to left expanded image and full screen (#1701)
* add explicit blurring to left expanded image and full screen
2026-02-12 18:49:57 -08:00
jeffvli f487560ec5 fix vite web build again for hashed assets and PWA cache clear 2026-02-12 18:21:27 -08:00
jeffvli f752090c78 Revert "attempt fix for web/docker cache busting on new release"
This reverts commit 91e7c7434c.
2026-02-12 18:17:49 -08:00
jeffvli 96f5b2b82a Revert "fix vite web build to work with subpath"
This reverts commit 1a9f36ce9e.
2026-02-12 18:17:44 -08:00
jeffvli 80292ae579 fix alpha autoupdater logic to use correct config for latest 2026-02-12 18:17:08 -08:00
Hosted Weblate 1d156ac506 Translated using Weblate
Currently translated at 100.0% (1154 of 1154 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Co-authored-by: Fjuro <fjuro@alius.cz>
2026-02-13 00:09:58 +00:00
jeffvli dc5586f859 adjuat audioDevice description to remove web player disclaimer 2026-02-12 11:18:55 -08:00
jeffvli 1a9f36ce9e fix vite web build to work with subpath 2026-02-12 11:18:55 -08:00
York 203c8a6588 fix: restore original macOS squircle icon and regenerate icns properly (#1703) 2026-02-12 10:03:23 -08:00
Hosted Weblate 2e6cf8d869 Translated using Weblate
Currently translated at 100.0% (1154 of 1154 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Co-authored-by: Fordas <fordas15@gmail.com>
2026-02-12 17:09:48 +00:00
Hosted Weblate b1827dd352 Translated using Weblate
Currently translated at 100.0% (1154 of 1154 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Translated using Weblate

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

Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-02-12 11:09:50 +01:00
jeffvli 0d2dddddbc fix invalid comparison on detail rating column hide condition 2026-02-12 01:40:01 -08:00
jeffvli 1d8e1957ba handle image drag from item detail list 2026-02-12 01:37:59 -08:00
jeffvli dc957cb3cc hide detail rating column on zero value 2026-02-12 00:57:24 -08:00
jeffvli c314fa0bf3 properly handle context menu in playlist album view 2026-02-12 00:54:45 -08:00
jeffvli c5ebfac647 fix playlist grid view itemType to PLAYLIST_SONG to support remove from playlist 2026-02-12 00:28:39 -08:00
jeffvli 4adea11a93 support select all hotkey in detail list view 2026-02-12 00:13:32 -08:00
Jeff e6f49b9f1f Add album view for playlists (#1700)
* update client side song ordering to include album order

* add compact styling to LibraryHeader

* move search button to top right of LibraryHeader
2026-02-11 21:48:25 -08:00
York 9cde569c7d Add option to show playing icon in Discord RPC (#1699)
* feat: add option to show playing/paused icon in Discord RPC
2026-02-11 21:14:58 -08:00
jeffvli 91e7c7434c attempt fix for web/docker cache busting on new release
- remove static asset filenames
- add cache clear config to PWA
- move PWA to base dir instead of assets
2026-02-11 20:52:28 -08:00
Yoshua Wakeham ffef5dfdee fix: actually show Jellyfin recently played songs carousel (#1697) 2026-02-11 20:35:23 -08:00
Kendall Garner 409dd69fcb reduce explicit indicator spacing slightly 2026-02-11 19:34:20 -08:00
Hosted Weblate 064cf5103a Translated using Weblate
Currently translated at 100.0% (1152 of 1152 strings)

Co-authored-by: Ondo <SparkyOndo@proton.me>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/
Translation: feishin/Translation
2026-02-11 23:09:47 +01:00
Hosted Weblate 7e3a613a93 Translated using Weblate
Currently translated at 100.0% (1152 of 1152 strings)

Translated using Weblate

Currently translated at 100.0% (1152 of 1152 strings)

Translated using Weblate

Currently translated at 99.9% (1151 of 1152 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translation: feishin/Translation
2026-02-11 16:09:52 +01:00
Hosted Weblate e7c49f6d67 Translated using Weblate
Currently translated at 36.6% (422 of 1152 strings)

Translated using Weblate

Currently translated at 33.5% (387 of 1152 strings)

Translated using Weblate

Currently translated at 100.0% (1152 of 1152 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 96.3% (1101 of 1143 strings)

Translated using Weblate (Ukrainian)

Currently translated at 32.1% (368 of 1143 strings)

Translated using Weblate (French)

Currently translated at 91.3% (1044 of 1143 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1143 of 1143 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (1143 of 1143 strings)

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Raphael <raphael.margueron@gmail.com>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: Yurii <04_hours.lambing@icloud.com>
Co-authored-by: linger <linger0517@gmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/uk/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/
Translation: feishin/Translation
2026-02-11 11:02:22 +01:00
97 changed files with 3498 additions and 908 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 B

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.
+1
View File
@@ -1,5 +1,6 @@
server {
listen 9180;
listen [::]:9180;
sendfile on;
default_type application/octet-stream;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.5.0",
"version": "1.6.0",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
+40 -10
View File
@@ -12,7 +12,7 @@
"search": "$t(common.search)",
"settings": "$t(common.setting, {\"count\": 2})",
"tracks": "$t(entity.track, {\"count\": 2})",
"nowPlaying": "ara sona",
"nowPlaying": "s'està reproduint",
"shared": "$t(entity.playlist, {\"count\": 2}) compartides",
"favorites": "$t(entity.favorite, {\"count\": 2})",
"radio": "$t(entity.radioStation, {\"count\": 2})",
@@ -29,7 +29,11 @@
"topSongsFrom": "les millors cançons de {{title}}",
"viewAll": "mostra-ho tot",
"groupingTypeAll": "tots els tipus de llançaments",
"groupingTypePrimary": "tipus principals de llançament"
"groupingTypePrimary": "tipus principals de llançament",
"favoriteSongs": "Cançons preferides",
"topSongsCommunity": "comunitat",
"topSongsPersonal": "personal",
"favoriteSongsFrom": "cançons preferides de {{title}}"
},
"albumArtistList": {
"title": "$t(entity.albumArtist, {\"count\": 2})"
@@ -200,6 +204,11 @@
"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": {
@@ -559,7 +568,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ó (només pel reproductor web)",
"audioDevice_description": "seleccioneu el dispositiu d'àudio que voleu utilitzar per a la reproducció",
"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",
@@ -605,9 +614,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": "mosta l'estat d'activitat quan està en pausa",
"discordPausedStatus": "mostra 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 en inactivitat",
"discordIdleStatus": "mosta l'estat d'activitat quan està inactiu",
"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",
@@ -779,7 +788,7 @@
"releaseChannel_optionLatest": "última versió",
"releaseChannel_optionBeta": "beta",
"releaseChannel": "canal de versions",
"releaseChannel_description": "tria entre versions estables i versions beta per les actualitzacions automàtiques",
"releaseChannel_description": "trieu entre versions estables i beta o alfa (diàries) 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",
@@ -878,7 +887,16 @@
"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"
"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"
},
"table": {
"column": {
@@ -983,7 +1001,8 @@
"view": {
"table": "taula",
"grid": "quadrícula",
"list": "llista"
"list": "llista",
"detail": "detall"
}
}
},
@@ -1031,7 +1050,9 @@
"path": "ruta",
"songCount": "nombre de cançons",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "ordena per nom"
"sortName": "ordena per nom",
"matchAnd": "i",
"matchOr": "o"
},
"player": {
"muted": "silenciat",
@@ -1072,7 +1093,16 @@
"restoreQueueFromServer": "restaura la cua del servidor",
"saveQueueToServer": "desa la cua al servidor",
"artistRadio": "ràdio de l'artista",
"trackRadio": "ràdio de la pista"
"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"
},
"error": {
"credentialsRequired": "credencials requerides",
+24 -6
View File
@@ -38,7 +38,16 @@
"restoreQueueFromServer": "obnovit frontu ze serveru",
"saveQueueToServer": "uložit frontu na server",
"artistRadio": "rádio umělce",
"trackRadio": "rádio skladby"
"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č"
},
"setting": {
"crossfadeStyle_description": "vyberte způsob prolnutí u přehrávače zvuku",
@@ -46,7 +55,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í (pouze webový přehrávač)",
"audioDevice_description": "vyberte zvukové zařízení k přehrávání",
"theme_description": "nastavení motivu použitého v aplikaci",
"hotkey_playbackPause": "pozastavení",
"replayGainFallback": "fallback {{ReplayGain}}",
@@ -261,7 +270,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 rich presence při pozastavení",
"discordPausedStatus": "zobrazit stav 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í",
@@ -385,7 +394,13 @@
"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í)"
"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í“"
},
"action": {
"editPlaylist": "upravit $t(entity.playlist, {\"count\": 1})",
@@ -558,7 +573,8 @@
"view": {
"table": "tabulka",
"list": "seznam",
"grid": "mřížka"
"grid": "mřížka",
"detail": "podrobnosti"
},
"general": {
"displayType": "typ zobrazení",
@@ -735,7 +751,9 @@
"album": "$t(entity.album, {\"count\": 1})",
"trackNumber": "skladba",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "název v řazení"
"sortName": "název v řazení",
"matchAnd": "a",
"matchOr": "nebo"
},
"page": {
"sidebar": {
+7 -1
View File
@@ -211,6 +211,7 @@
"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",
@@ -227,6 +228,7 @@
"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",
@@ -236,6 +238,8 @@
"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",
@@ -734,7 +738,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 (web player only)",
"audioDevice_description": "select the audio device to use for playback",
"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",
@@ -788,6 +792,8 @@
"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",
+41 -23
View File
@@ -32,20 +32,29 @@
"playSimilarSongs": "Reproducir canciones similares",
"viewQueue": "ver cola",
"addLastShuffled": "Al final (mezclado)",
"addNextShuffled": "Al siguiente (mezclado)",
"addNextShuffled": "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"
"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"
},
"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 (solo reproductor web)",
"audioDevice_description": "selecciona el dispositivo de audio a usar durante la reproducción",
"theme_description": "establece el tema a usar por la aplicación",
"hotkey_playbackPause": "pausa",
"replayGainFallback": "{{ReplayGain}} alternativa",
@@ -159,7 +168,7 @@
"customFontPath": "ruta de fuente personalizada",
"followLyric": "seguir la letra actual",
"crossfadeDuration": "duración del crossfade",
"discordIdleStatus": "mostrar el estado inactivo en el estado de actividad",
"discordIdleStatus": "mostrar estado inactivo en el estado de actividad",
"sidePlayQueueStyle_optionDetached": "separada",
"audioPlayer": "reproductor de audio",
"hotkey_zoomOut": "reducir",
@@ -318,8 +327,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 de reproducción que muestra el visualizador",
"showVisualizerInSidebar": "Mostrar visualizador en la barra lateral de reproducción",
"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",
"queryBuilder": "Generador de consultas",
"queryBuilderCustomFields_inputTag": "Etiqueta",
"queryBuilderCustomFields": "Campos personalizados",
@@ -385,7 +394,13 @@
"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)"
"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"
},
"action": {
"editPlaylist": "editar $t(entity.playlist, {\"count\": 1})",
@@ -431,7 +446,7 @@
"backward": "hacia atrás",
"increase": "aumentar",
"rating": "calificación",
"bpm": "lpm",
"bpm": "bpm",
"refresh": "actualizar",
"unknown": "desconocido",
"areYouSure": "seguro?",
@@ -443,7 +458,7 @@
"currentSong": "$t(entity.track, {\"count\": 1}) actual",
"collapse": "contraer",
"trackNumber": "pista",
"descending": "descendiente",
"descending": "descendente",
"add": "añadir",
"ascending": "ascendente",
"dismiss": "descartar",
@@ -470,8 +485,8 @@
"cancel": "cancelar",
"forceRestartRequired": "reiniciar para aplicar cambios... cerrar la notificación para reiniciar",
"setting_one": "configuración",
"setting_many": "configuraciones",
"setting_other": "configuraciones",
"setting_many": "configuración",
"setting_other": "configuración",
"version": "versión",
"title": "título",
"filters": "filtros",
@@ -585,10 +600,10 @@
"noNetworkDescription": "No se pudo conectar a este servidor"
},
"filter": {
"mostPlayed": "más reproducido",
"mostPlayed": "más reproducidos",
"isCompilation": "es una compilación",
"recentlyPlayed": "recientemente reproducido",
"isRated": "es clasificado",
"isRated": "Está calificado",
"title": "título",
"rating": "calificación",
"search": "buscar",
@@ -604,7 +619,7 @@
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"isRecentlyPlayed": "reproducido recientemente",
"isFavorited": "es favorito",
"bpm": "lpm",
"bpm": "bpm",
"releaseYear": "año de lanzamiento",
"disc": "disco",
"biography": "biografía",
@@ -623,12 +638,14 @@
"owner": "$t(common.owner)",
"genre": "$t(entity.genre, {\"count\": 1})",
"id": "id",
"songCount": "número de canción",
"songCount": "número de canciones",
"isPublic": "es público",
"album": "$t(entity.album, {\"count\": 1})",
"albumCount": "Contar $t(entity.album, {\"count\": 2})",
"albumCount": "Número de $t(entity.album, {\"count\": 2})",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "Ordenar por nombre"
"sortName": "Ordenar por nombre",
"matchAnd": "y",
"matchOr": "o"
},
"page": {
"sidebar": {
@@ -865,8 +882,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",
@@ -964,7 +981,7 @@
"releaseDate": "fecha de lanzamiento",
"bitrate": "tasa de bits",
"title": "título",
"bpm": "lpm",
"bpm": "bpm",
"dateAdded": "fecha de adición",
"artist": "$t(entity.artist, {\"count\": 1})",
"songCount": "$t(entity.track, {\"count\": 2})",
@@ -1029,8 +1046,8 @@
"followCurrentSong": "seguir la canción actual",
"advancedSettings": "Opciones avanzadas",
"autosize": "Autodimensionar",
"moveUp": "Ascender",
"moveDown": "Descender",
"moveUp": "Subir",
"moveDown": "Bajar",
"pinToLeft": "Anclar a la izquierda",
"pinToRight": "Anclar a la derecha",
"alignLeft": "Alinear a la izquierda",
@@ -1053,7 +1070,8 @@
"view": {
"table": "tabla",
"list": "Lista",
"grid": "Cuadrícula"
"grid": "Cuadrícula",
"detail": "Detalle"
}
}
},
+18 -3
View File
@@ -204,7 +204,8 @@
"mood": "humeur",
"retry": "réessayer",
"filter_single": "unique",
"filter_multiple": "multiple"
"filter_multiple": "multiple",
"rename": "renommer"
},
"error": {
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
@@ -280,7 +281,8 @@
"artist": "$t(entity.artist, {\"count\": 1})",
"isPublic": "est public",
"album": "$t(entity.album, {\"count\": 1})",
"explicitStatus": "$t(common.explicitStatus)"
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "tri par nom"
},
"page": {
"sidebar": {
@@ -447,7 +449,12 @@
"viewDiscography": "voir la discographie",
"relatedArtists": "$t(entity.artist, {\"count\": 2}) similaires",
"topSongs": "meilleurs titres",
"groupingTypeAll": "toutes les types de sortie"
"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}}"
},
"itemDetail": {
"copyPath": "copier le chemin dans le presse-papiers",
@@ -473,6 +480,14 @@
},
"radioList": {
"title": "stations radio"
},
"releasenotes": {
"commitsSinceStable": "commits depuis {{stable}}",
"noNewCommits": "pas de nouveaux commits dans cette plage"
},
"windowBar": {
"paused": "(Pause) ",
"privateMode": "(Mode Privé)"
}
},
"setting": {
+20 -4
View File
@@ -661,7 +661,16 @@
"restoreQueueFromServer": "przywróć kolejkę z serwera",
"saveQueueToServer": "zapisz kolejkę na serwerze",
"artistRadio": "radio wykonawcy",
"trackRadio": "radio utworu"
"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"
},
"setting": {
"crossfadeStyle_description": "wybierz styl przenikania, który ma być używany do odtwarzania dźwięku",
@@ -894,7 +903,7 @@
"releaseChannel_optionBeta": "beta",
"releaseChannel_optionLatest": "najnowsza",
"releaseChannel": "kanał wydań",
"releaseChannel_description": "wybieraj pomiędzy stabilnymi wydaniami a wydaniami beta dla automatycznych aktualizacji",
"releaseChannel_description": "wybieraj pomiędzy wydaniami stabilnymi, beta lub alpha (nightly) 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",
@@ -1008,14 +1017,21 @@
"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)"
"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"
},
"table": {
"config": {
"view": {
"table": "tabela",
"grid": "siatka",
"list": "lista"
"list": "lista",
"detail": "szczegół"
},
"general": {
"displayType": "typ wyświetlania",
+122 -1
View File
@@ -22,7 +22,7 @@
"holdToMoveToTop": "утримуйте, щоб перемістити вгору",
"holdToMoveToBottom": "утримувати, щоб перемістити вниз",
"moveItems": "перемістити елементи",
"shuffle": "відтворити випадково",
"shuffle": "перемішати",
"shuffleAll": "все випадково",
"shuffleSelected": "вибране випадково",
"refresh": "$t(common.refresh)",
@@ -415,9 +415,130 @@
"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": "отримати інформацію"
}
}
}
+1 -1
View File
@@ -463,7 +463,7 @@
"releaseChannel_optionLatest": "最新的",
"releaseChannel_optionBeta": "测试版",
"releaseChannel": "发布通道",
"releaseChannel_description": "选择稳定版本或测试版以进行自动更新",
"releaseChannel_description": "选择稳定版测试版或 Alpha(夜间构建版)以启用自动更新",
"mediaSession": "启用媒体会话",
"mediaSession_description": "启用媒体会话集成,在系统音量叠加层和锁屏界面显示媒体控件和元数据",
"exportImportSettings_control_description": "通过 JSON 导出和导入设置",
+23 -5
View File
@@ -399,7 +399,16 @@
"restoreQueueFromServer": "從伺服器還原播放佇列",
"saveQueueToServer": "將播放佇列儲存至伺服器",
"artistRadio": "藝人電台",
"trackRadio": "曲目電台"
"trackRadio": "曲目電台",
"sleepTimer": "睡眠定時器",
"sleepTimer_endOfSong": "歌曲播完時",
"sleepTimer_minutes": "{{count}} 分鐘",
"sleepTimer_hours": "{{count}} 小時",
"sleepTimer_custom": "自訂",
"sleepTimer_off": "關閉",
"sleepTimer_timeRemaining": "剩餘 {{time}}",
"sleepTimer_setCustom": "設定定時器",
"sleepTimer_cancel": "取消定時器"
},
"setting": {
"audioPlayer_description": "選擇用於播放的音訊播放器",
@@ -415,7 +424,7 @@
"applicationHotkeys": "應用程式快捷鍵",
"applicationHotkeys_description": "設定應用程式快捷鍵。切換勾選框來設為全域快捷鍵(僅桌面端)",
"audioDevice": "音訊設備",
"audioDevice_description": "選擇用於播放的音訊設備(僅 web 播放器)",
"audioDevice_description": "選擇用於播放的音訊設備",
"audioExclusiveMode": "音訊獨占模式",
"audioExclusiveMode_description": "啟用獨占輸出模式。在此模式下,系統通常被鎖定,只有 mpv 能夠輸出音訊",
"audioPlayer": "音訊播放器",
@@ -746,7 +755,13 @@
"sidebarPlaylistListFilterRegex": "播放清單過濾器正規表達式",
"blurExplicitImages": "模糊露骨圖片",
"blurExplicitImages_description": "標記為露骨的專輯和歌曲封面將被模糊",
"releaseChannel_optionAlpha": "alpha (每日建構版)"
"releaseChannel_optionAlpha": "alpha (每日建構版)",
"analyticsEnable": "傳送基於使用情況的分析報告",
"analyticsEnable_description": "匿名化的使用情況資料會傳送給開發者,以協助改進應用程式",
"automaticUpdates": "自動更新",
"automaticUpdates_description": "自動檢查並安裝更新",
"discordStateIcon": "顯示播放中圖示",
"discordStateIcon_description": "在 rich presence 狀態中顯示一個小的播放圖示。啟用「暫停時顯示 rich presence」時,會始終顯示暫停的圖示"
},
"table": {
"config": {
@@ -822,7 +837,8 @@
"view": {
"table": "表格",
"grid": "網格",
"list": "列表"
"list": "列表",
"detail": "詳情"
}
},
"column": {
@@ -961,7 +977,9 @@
"toYear": "從年份",
"trackNumber": "曲目",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "排序名稱"
"sortName": "排序名稱",
"matchAnd": "和",
"matchOr": "或"
},
"form": {
"addServer": {
+9
View File
@@ -55,6 +55,12 @@ 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 {
@@ -97,6 +103,7 @@ 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 &&
@@ -111,7 +118,9 @@ 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 &&
@@ -38,6 +38,7 @@ const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefin
[AlbumListSort.DURATION]: undefined,
[AlbumListSort.EXPLICIT_STATUS]: undefined,
[AlbumListSort.FAVORITED]: AlbumListSortType.STARRED,
[AlbumListSort.ID]: undefined,
[AlbumListSort.NAME]: AlbumListSortType.ALPHABETICAL_BY_NAME,
[AlbumListSort.PLAY_COUNT]: AlbumListSortType.FREQUENT,
[AlbumListSort.RANDOM]: AlbumListSortType.RANDOM,
@@ -762,7 +763,7 @@ export const SubsonicController: InternalControllerEndpoint = {
getFolder: async ({ apiClientProps, context, query }) => {
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
const isRootFolderId = /^\d+$/.test(query.id);
const isRootFolderId = query.id === '0';
if (isRootFolderId) {
const res = await ssApiClient(apiClientProps).getIndexes({
@@ -1,3 +1,3 @@
.container {
height: 500px;
.list-expanded-container {
overflow: auto;
}
@@ -1,32 +1,23 @@
import { motion, Variants } from 'motion/react';
import { ReactNode } from 'react';
import styles from './expanded-list-container.module.css';
const expandedAnimationVariants: Variants = {
hidden: {
height: 0,
minHeight: 0,
},
show: {
minHeight: '300px',
transition: {
duration: 0.3,
ease: 'easeInOut',
},
},
};
const EXPANDED_HEIGHT = 300;
export const ExpandedListContainer = ({ children }: { children: ReactNode }) => {
export interface ExpandedListContainerProps {
children: ReactNode;
}
export const ExpandedListContainer = ({ children }: ExpandedListContainerProps) => {
return (
<motion.div
animate="show"
<div
className={styles.listExpandedContainer}
exit="hidden"
initial="hidden"
variants={expandedAnimationVariants}
style={{
height: EXPANDED_HEIGHT,
overflow: 'auto',
}}
>
{children}
</motion.div>
</div>
);
};
@@ -2,27 +2,18 @@ import { Suspense } from 'react';
import styles from './expanded-list-item.module.css';
import {
ItemListStateActions,
ItemListStateItem,
useItemListStateSubscription,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemListStateItem } 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 {
internalState: ItemListStateActions;
item?: ItemListStateItem;
itemType: LibraryItem;
}
export const ExpandedListItem = ({ internalState, itemType }: ExpandedListItemProps) => {
const expandedItems = useItemListStateSubscription(internalState, () =>
internalState ? internalState.getExpandedItemsCached() : [],
);
const currentItem = expandedItems[0];
if (!currentItem) {
export const ExpandedListItem = ({ item, itemType }: ExpandedListItemProps) => {
if (!item) {
return null;
}
@@ -30,11 +21,7 @@ export const ExpandedListItem = ({ internalState, itemType }: ExpandedListItemPr
<div className={styles.container}>
<div className={styles.inner}>
<Suspense fallback={<Spinner container />}>
<SelectedItem
internalState={internalState}
item={currentItem as ItemListStateItem}
itemType={itemType}
/>
<SelectedItem item={item} itemType={itemType} />
</Suspense>
</div>
</div>
@@ -42,15 +29,14 @@ export const ExpandedListItem = ({ internalState, itemType }: ExpandedListItemPr
};
interface SelectedItemProps {
internalState: ItemListStateActions;
item: ItemListStateItem;
itemType: LibraryItem;
}
const SelectedItem = ({ internalState, item, itemType }: SelectedItemProps) => {
const SelectedItem = ({ item, itemType }: SelectedItemProps) => {
switch (itemType) {
case LibraryItem.ALBUM:
return <ExpandedAlbumListItem internalState={internalState} item={item} />;
return <ExpandedAlbumListItem item={item} />;
default:
return null;
}
@@ -8,6 +8,7 @@ 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';
@@ -244,8 +245,6 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
const playType = (meta?.playType as Play) || Play.NOW;
const singleSongOnly = meta?.singleSongOnly === true;
// For single-song actions (e.g. image play button), or NEXT/LAST/..., only add the clicked song
// For row double-click with NOW/SHUFFLE, add a range of songs around the clicked song
let songsToAdd: Song[];
if (
singleSongOnly ||
@@ -279,19 +278,27 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
}
},
onExpand: ({ internalState, item }: DefaultItemControlProps) => {
if (!item || !internalState) {
return;
}
onExpand: ({ item, itemType }: DefaultItemControlProps) => {
if (!item) 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;
const setGlobalExpanded = useAppStore.getState().actions.setGlobalExpanded;
const globalExpanded = useAppStore.getState().globalExpanded;
return internalState?.toggleExpanded(itemListItem);
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,
});
}
},
onFavorite: ({
@@ -196,6 +196,17 @@
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,14 +32,17 @@ 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,
@@ -61,6 +64,7 @@ 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';
@@ -68,6 +72,8 @@ 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';
@@ -84,6 +90,7 @@ interface ItemDetailListProps {
internalState?: ItemListStateActions;
itemCount?: number;
items?: unknown[];
listKey?: ItemListKey;
onColumnReordered?: (
columnIdFrom: TableColumn,
columnIdTo: TableColumn,
@@ -92,8 +99,15 @@ interface ItemDetailListProps {
onColumnResized?: (columnId: TableColumn, width: number) => void;
onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => Promise<void> | void;
onScrollEnd?: (rowIndex: number) => void;
onSongRowDoubleClick?: (params: {
index: number;
internalState: ItemListStateActions;
item: Song;
}) => void;
overrideControls?: Partial<ItemControls>;
rowHeight?: number;
scrollOffset?: number;
songsByAlbumId?: Record<string, Song[]>;
tableId?: string;
}
@@ -109,7 +123,13 @@ interface RowData {
getItem?: (index: number) => unknown;
internalState: ItemListStateActions;
isMutatingFavorite: boolean;
onSongRowDoubleClick?: (params: {
index: number;
internalState: ItemListStateActions;
item: Song;
}) => void;
registerSongs: (albumId: string, songs: Song[]) => void;
songsByAlbumId?: Record<string, Song[]>;
trackColumns: ItemTableListColumnConfig[];
trackTableSize: 'compact' | 'default' | 'large';
}
@@ -126,6 +146,11 @@ interface TrackRowProps {
internalState: ItemListStateActions;
isMutatingFavorite: boolean;
isSongsLoading?: boolean;
onSongRowDoubleClick?: (params: {
index: number;
internalState: ItemListStateActions;
item: Song;
}) => void;
rowIndex: number;
size: 'compact' | 'default' | 'large';
song: Song;
@@ -147,6 +172,7 @@ const TrackRow = memo(
internalState,
isMutatingFavorite,
isSongsLoading,
onSongRowDoubleClick,
rowIndex,
size,
song,
@@ -167,11 +193,37 @@ const TrackRow = memo(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (onSongRowDoubleClick) {
onSongRowDoubleClick({
index: internalState.findItemIndex(song.id),
internalState,
item: song,
});
return;
}
if (controls?.onDoubleClick) {
controls.onDoubleClick({
event: e,
index: internalState.findItemIndex(song.id),
internalState,
item: song,
itemType: LibraryItem.SONG,
});
return;
}
if (isSongsLoading || albumSongs.length === 0) return;
internalState.setSelected([song]);
playerContext.addToQueueByData(albumSongs, Play.NOW, song.id);
},
[albumSongs, internalState, isSongsLoading, playerContext, song],
[
albumSongs,
controls,
internalState,
isSongsLoading,
onSongRowDoubleClick,
playerContext,
song,
],
);
const handleRowClick = useCallback(
@@ -373,6 +425,61 @@ 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;
@@ -434,39 +541,48 @@ const MetadataSection = memo(
onMouseEnter={() => setIsMetadataHovered(true)}
onMouseLeave={() => setIsMetadataHovered(false)}
>
<Link
className={styles.imageWrapper}
onMouseEnter={() => setIsImageHovered(true)}
onMouseLeave={() => setIsImageHovered(false)}
state={{ item }}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: item.id,
<div
className={clsx(styles.imageWrapperOuter, {
[styles.imageWrapperDragging]: isDragging,
})}
ref={dragRef ?? undefined}
>
<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.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>
<Link
className={styles.title}
state={{ item }}
@@ -610,7 +726,9 @@ const RowContent = memo(
index,
internalState,
isMutatingFavorite,
onSongRowDoubleClick,
registerSongs,
songsByAlbumId,
trackColumns,
trackTableSize,
}: RowContentProps) => {
@@ -622,8 +740,10 @@ const RowContent = memo(
return (data?.[index] as Album | undefined) || undefined;
}, [data, getItem, index]);
const useClientSideSongs = Boolean(songsByAlbumId);
const songListQuery = useMemo(() => {
if (!item?.id || !item?._serverId) return null;
if (useClientSideSongs || !item?.id || !item?._serverId) return null;
return {
query: {
albumIds: [item.id],
@@ -634,7 +754,7 @@ const RowContent = memo(
},
serverId: item?._serverId || '',
};
}, [item]);
}, [item, useClientSideSongs]);
const { data: songListData, isLoading: isSongsQueryLoading } = useQuery({
enabled: !!songListQuery,
@@ -646,8 +766,17 @@ const RowContent = memo(
}),
});
const songItems = songListData?.items;
const isSongsLoading = !!item && isSongsQueryLoading && !songItems?.length;
const songItemsFromQuery = songListData?.items;
const songItemsFromClient = useMemo(() => {
const rowSongs = (item as { _playlistSongs?: Song[] })?._playlistSongs;
if (rowSongs?.length) return rowSongs;
if (!songsByAlbumId || !item?.id) return undefined;
return songsByAlbumId[item.id];
}, [item, songsByAlbumId]);
const songItems = useClientSideSongs ? songItemsFromClient : songItemsFromQuery;
const isSongsLoading =
!useClientSideSongs && !!item && isSongsQueryLoading && !songItemsFromQuery?.length;
const songs = useMemo(() => {
return (
@@ -705,6 +834,7 @@ const RowContent = memo(
isMutatingFavorite={isMutatingFavorite}
isSongsLoading={isSongsLoading}
key={song.id}
onSongRowDoubleClick={onSongRowDoubleClick}
rowIndex={rowIndex}
size={trackTableSize}
song={song as Song}
@@ -729,6 +859,7 @@ const RowContent = memo(
prev.isMutatingFavorite === next.isMutatingFavorite &&
prev.controls === next.controls &&
prev.registerSongs === next.registerSongs &&
prev.songsByAlbumId === next.songsByAlbumId &&
prev.trackColumns === next.trackColumns &&
prev.trackTableSize === next.trackTableSize,
);
@@ -1113,20 +1244,27 @@ export const ItemDetailList = ({
getItem,
itemCount: externalItemCount,
items,
listKey = ItemListKey.ALBUM,
onColumnReordered,
onColumnResized,
onRangeChanged,
onScrollEnd,
onSongRowDoubleClick,
overrideControls,
songsByAlbumId,
tableId = DEFAULT_DETAIL_TABLE_ID,
}: 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();
const controls = useDefaultItemListControls({
onColumnReordered,
onColumnResized,
overrides: overrideControls,
});
const isMutatingCreateFavorite = useIsMutatingCreateFavorite();
const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();
@@ -1172,7 +1310,7 @@ export const ItemDetailList = ({
const internalState = useItemListState(getDataFn, extractRowIdSong);
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.ALBUM]?.detail);
const tableConfig = useSettingsStore((state) => state.lists[listKey]?.detail);
const trackColumns = useMemo((): ItemTableListColumnConfig[] => {
const raw = tableConfig?.columns;
if (raw && raw.length > 0) {
@@ -1263,8 +1401,10 @@ export const ItemDetailList = ({
getItem,
internalState,
isMutatingFavorite,
onSongRowDoubleClick,
queryClient,
registerSongs,
songsByAlbumId,
trackColumns,
trackTableSize,
}),
@@ -1279,8 +1419,10 @@ export const ItemDetailList = ({
getItem,
internalState,
isMutatingFavorite,
onSongRowDoubleClick,
queryClient,
registerSongs,
songsByAlbumId,
trackColumns,
trackTableSize,
],
@@ -1307,6 +1449,13 @@ export const ItemDetailList = ({
},
});
useListHotkeys({
controls,
focused,
internalState,
itemType: LibraryItem.SONG,
});
useEffect(() => {
const { current: container } = containerRef;
@@ -1363,7 +1512,7 @@ export const ItemDetailList = ({
trackTableSize={trackTableSize}
/>
)}
<div className={styles.container} ref={containerRef}>
<div className={styles.container} ref={mergedContainerRef}>
<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)
(columnId === TableColumn.USER_RATING && song.userRating !== null && song.userRating !== 0)
);
}
@@ -1,6 +1,6 @@
import clsx from 'clsx';
import throttle from 'lodash/throttle';
import { AnimatePresence, motion } from 'motion/react';
import { motion } from 'motion/react';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import React, {
CSSProperties,
@@ -31,15 +31,12 @@ 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';
@@ -829,10 +826,6 @@ const BaseItemGridList = ({
/>
)}
</AutoSizer>
<AnimatePresence presenceAffectsLayout>
<ExpandedContainer internalState={internalState} itemType={itemType} />
{/* {enableSelectionDialog && <SelectionDialog internalState={internalState} />} */}
</AnimatePresence>
</motion.div>
);
};
@@ -903,25 +896,3 @@ 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,6 +62,7 @@ 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 { AnimatePresence, motion } from 'motion/react';
import { motion } from 'motion/react';
import React, {
type JSXElementConstructor,
memo,
@@ -18,15 +18,12 @@ 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';
@@ -1651,8 +1648,6 @@ const BaseItemTableList = ({
totalColumnCount={totalColumnCount}
totalRowCount={totalRowCount}
/>
<ExpandedContainer internalState={internalState} itemType={itemType} />
{/* {enableSelectionDialog && <SelectionDialog internalState={internalState} />} */}
</motion.div>
</ItemTableListConfigProvider>
</ItemTableListStoreProvider>
@@ -1661,26 +1656,4 @@ 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={data?.rules?.length === 1}
noRemove={false}
onChangeField={onChangeField}
onChangeOperator={onChangeOperator}
onChangeValue={onChangeValue}
+6
View File
@@ -1,16 +1,22 @@
import { createContext, useContext } from 'react';
import { LibraryItem } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export type ListDisplayMode = LibraryItem.ALBUM | LibraryItem.SONG;
interface ListContextProps {
customFilters?: Record<string, unknown>;
displayMode?: ListDisplayMode;
id?: string;
isSidebarOpen?: boolean;
isSmartPlaylist?: boolean;
itemCount?: number;
listData?: unknown[];
listKey?: ItemListKey;
mode?: 'edit' | 'view';
pageKey: ItemListKey | string;
setDisplayMode?: (displayMode: ListDisplayMode) => void;
setIsSidebarOpen?: (isSidebarOpen: boolean) => void;
setItemCount?: (itemCount: number) => void;
setListData?: (items: unknown[]) => void;
@@ -21,7 +21,10 @@ 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 } from '/@/renderer/features/shared/components/list-config-menu';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import {
CLIENT_SIDE_SONG_FILTERS,
ListSortByDropdownControlled,
@@ -755,7 +758,10 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
sortOrder={sortOrder}
/>
<ListConfigMenu
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
listKey={ItemListKey.ALBUM_DETAIL}
optionsConfig={{
table: {
@@ -20,7 +20,6 @@ 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;
@@ -31,6 +30,7 @@ export function AlbumGridCarousel(props: AlbumGridCarouselProps) {
controls={controls}
data={album}
enableDrag
enableExpansion
itemType={LibraryItem.ALBUM}
rows={rows}
type="poster"
@@ -58,7 +58,6 @@ 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))
@@ -70,6 +69,7 @@ const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps & { rows: DataRow[]
controls={controls}
data={album}
enableDrag
enableExpansion
itemType={LibraryItem.ALBUM}
rows={rows}
type="poster"
@@ -6,6 +6,7 @@ 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,
@@ -34,6 +35,7 @@ export const AlbumListInfiniteDetail = ({
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
const listQueryFn = api.controller.getAlbumList;
const { pageKey } = useListContext();
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.ALBUM,
@@ -46,7 +48,7 @@ export const AlbumListInfiniteDetail = ({
});
const { getItem, itemCount, loadedItems, onRangeChanged } = useItemListInfiniteLoader({
eventKey: ItemListKey.ALBUM,
eventKey: pageKey || ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -6,6 +6,7 @@ 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 {
@@ -37,9 +38,11 @@ export const AlbumListInfiniteGrid = ({
const listQueryFn = api.controller.getAlbumList;
const { pageKey } = useListContext();
const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =
useItemListInfiniteLoader({
eventKey: ItemListKey.ALBUM,
eventKey: pageKey || ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -8,6 +8,7 @@ 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,
@@ -43,10 +44,11 @@ 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: ItemListKey.ALBUM,
eventKey: pageKey || ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -8,6 +8,7 @@ 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,
@@ -36,6 +37,7 @@ export const AlbumListPaginatedDetail = ({
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
const listQueryFn = api.controller.getAlbumList;
const { pageKey } = useListContext();
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.ALBUM,
@@ -51,7 +53,7 @@ export const AlbumListPaginatedDetail = ({
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
currentPage,
eventKey: ItemListKey.ALBUM,
eventKey: pageKey || ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -8,6 +8,7 @@ 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 {
@@ -32,6 +33,7 @@ export const AlbumListPaginatedGrid = ({
serverId,
size,
}: AlbumListPaginatedGridProps) => {
const { pageKey } = useListContext();
const { currentPage, onChange } = useItemListPagination();
const listCountQuery = albumQueries.listCount({
@@ -43,7 +45,7 @@ export const AlbumListPaginatedGrid = ({
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
currentPage,
eventKey: ItemListKey.ALBUM,
eventKey: pageKey || ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -10,6 +10,7 @@ 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,
@@ -39,6 +40,7 @@ export const AlbumListPaginatedTable = ({
serverId,
size = 'default',
}: AlbumListPaginatedTableProps) => {
const { pageKey } = useListContext();
const { currentPage, onChange } = useItemListPagination();
const listCountQuery = albumQueries.listCount({
@@ -50,7 +52,7 @@ export const AlbumListPaginatedTable = ({
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
currentPage,
eventKey: ItemListKey.ALBUM,
eventKey: pageKey || ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -22,6 +22,7 @@ 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';
@@ -30,10 +31,24 @@ 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, Song } from '/@/shared/types/domain-types';
import { LibraryItem, RelatedArtist, 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;
@@ -46,11 +61,6 @@ interface AlbumTracksTableProps {
}>;
}
interface ExpandedAlbumListItemProps {
internalState?: ItemListStateActions;
item: ItemListStateItem;
}
interface TrackRowProps {
controls: ReturnType<typeof useDefaultItemListControls>;
internalState: ItemListStateActions;
@@ -60,6 +70,23 @@ 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);
@@ -188,136 +215,165 @@ const AlbumTracksTable = ({ isDark, serverId, songs }: AlbumTracksTableProps) =>
);
};
export const ExpandedAlbumListItem = ({ internalState, item }: ExpandedAlbumListItemProps) => {
const { data, isLoading } = useSuspenseQuery(
albumQueries.detail({
query: { id: item.id },
serverId: item._serverId,
}),
);
interface ExpandedAlbumListItemContentProps {
albumData: ExpandedAlbumData;
}
const ExpandedAlbumListItemContent = ({ albumData }: ExpandedAlbumListItemContentProps) => {
const player = usePlayer();
const imageUrl = useItemImageUrl({
id: item.imageId || undefined,
id: albumData.imageId || undefined,
itemType: LibraryItem.ALBUM,
type: 'itemCard',
});
const color = useFastAverageColor({
algorithm: 'sqrt',
id: item.id,
id: albumData.id,
src: imageUrl,
srcLoaded: true,
});
const handlePlay = useCallback(
(playType: Play) => {
if (!data) {
return;
}
if (data.songs) {
player.addToQueueByData(data.songs, playType);
if (albumData.songs?.length) {
player.addToQueueByData(albumData.songs, playType);
}
},
[data, player],
[albumData.songs, player],
);
if (color.isLoading) {
return null;
return <Spinner container />;
}
const songs = albumData.songs ?? null;
return (
<motion.div
animate={{
opacity: 1,
}}
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"
<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?.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>
{albumData.name}
</TextTitle>
<CloseExpandedButton />
</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>
)}
<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>
</Suspense>
<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(
albumQueries.detail({
query: { id: item.id },
serverId: item._serverId,
}),
);
const albumData: ExpandedAlbumData = {
_serverId: item._serverId,
albumArtists: data?.albumArtists ?? [],
id: item.id,
imageId: item.imageId ?? data?.imageId ?? null,
name: data?.name ?? '',
songs: data?.songs ?? null,
};
return <ExpandedAlbumListItemContent albumData={albumData} />;
};
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,
};
}
export const ExpandedAlbumListItem = (props: ExpandedAlbumListItemProps) => {
if (props.album != null) {
return <ExpandedAlbumListItemContent albumData={props.album} />;
}
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} />
</Suspense>
);
}
return null;
};
@@ -25,7 +25,10 @@ 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 } from '/@/renderer/features/shared/components/list-config-menu';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import {
CLIENT_SIDE_ALBUM_FILTERS,
ListSortByDropdownControlled,
@@ -225,6 +228,39 @@ 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;
@@ -237,7 +273,6 @@ 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',
@@ -269,13 +304,8 @@ const AlbumArtistMetadataTopSongsContent = ({
}, [tableConfig?.columns]);
const filteredSongs = useMemo(() => {
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]);
return searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG);
}, [songs, debouncedSearchTerm]);
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.SONG,
@@ -448,7 +478,10 @@ const AlbumArtistMetadataTopSongsContent = ({
value={topSongsQueryType}
/>
<ListConfigMenu
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
listKey={ItemListKey.SONG}
optionsConfig={{
table: {
@@ -459,35 +492,35 @@ const AlbumArtistMetadataTopSongsContent = ({
tableColumnsData={SONG_TABLE_COLUMNS}
/>
</Group>
<ItemTableList
activeRowId={currentSongId}
autoFitColumns={tableConfig.autoFitColumns}
CellComponent={ItemTableListColumn}
columns={columns}
data={filteredSongs}
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
enableDrag
enableDragScroll={false}
enableExpansion={false}
<SongTableListContainer
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}
/>
{!searchTerm.trim() && songs.length > 5 && !showAll && (
<Group justify="center" w="100%">
<Button onClick={() => setShowAll(true)} variant="subtle">
{t('action.viewMore', { postProcess: 'sentenceCase' })}
</Button>
</Group>
)}
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>
</>
) : null}
</Stack>
@@ -523,7 +556,6 @@ 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();
@@ -548,13 +580,8 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori
}, [tableConfig?.columns]);
const filteredSongs = useMemo(() => {
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]);
return searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG);
}, [songs, debouncedSearchTerm]);
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.SONG,
@@ -706,7 +733,10 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori
value={searchTerm}
/>
<ListConfigMenu
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
listKey={ItemListKey.SONG}
optionsConfig={{
table: {
@@ -717,35 +747,35 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori
tableColumnsData={SONG_TABLE_COLUMNS}
/>
</Group>
<ItemTableList
activeRowId={currentSongId}
autoFitColumns={tableConfig.autoFitColumns}
CellComponent={ItemTableListColumn}
columns={columns}
data={filteredSongs}
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
enableDrag
enableDragScroll={false}
enableExpansion={false}
<SongTableListContainer
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}
/>
{!searchTerm.trim() && songs.length > 5 && !showAll && (
<Group justify="center" w="100%">
<Button onClick={() => setShowAll(true)} variant="subtle">
{t('action.viewMore', { postProcess: 'sentenceCase' })}
</Button>
</Group>
)}
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>
</>
) : null}
</Stack>
@@ -1046,6 +1076,7 @@ interface AlbumSectionProps {
albums: Album[];
controls: ItemControls;
cq: ReturnType<typeof useContainerQuery>;
enableExpansion?: boolean;
releaseType: string;
rows: DataRow[] | undefined;
title: React.ReactNode | string;
@@ -1065,7 +1096,15 @@ const getItemsPerRow = (cq: ReturnType<typeof useContainerQuery>) => {
return 2;
};
const AlbumSection = ({ albums, controls, cq, releaseType, rows, title }: AlbumSectionProps) => {
const AlbumSection = ({
albums,
controls,
cq,
enableExpansion,
releaseType,
rows,
title,
}: AlbumSectionProps) => {
const { t } = useTranslation();
const itemsPerRow = getItemsPerRow(cq);
@@ -1190,6 +1229,7 @@ const AlbumSection = ({ albums, controls, cq, releaseType, rows, title }: AlbumS
controls={controls}
data={album}
enableDrag
enableExpansion={enableExpansion ?? true}
itemType={LibraryItem.ALBUM}
rows={rows}
type="poster"
@@ -1367,7 +1407,6 @@ 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 || [];
@@ -1375,6 +1414,8 @@ 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]);
@@ -1643,6 +1684,7 @@ const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
albums={albums}
controls={controls}
cq={cq}
enableExpansion
key={releaseType}
releaseType={releaseType}
rows={rows}
@@ -20,7 +20,6 @@ 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,63 +1,18 @@
import { useQueryClient } from '@tanstack/react-query';
import { lazy, Suspense, useEffect, useRef } from 'react';
import { 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,
@@ -125,17 +80,15 @@ export const ContextMenuController = createCallable<ContextMenuControllerProps,
}}
/>
</ContextMenu.Target>
<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>
{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} />}
</ContextMenu>
);
},
@@ -109,8 +109,18 @@ export const useDiscordRpc = () => {
instance: false,
largeImageKey: 'icon',
largeImageText: truncate(stationName || 'Radio'),
smallImageKey: current[2] === PlayerStatus.PLAYING ? 'playing' : 'paused',
smallImageText: sentenceCase(current[2]),
smallImageKey:
current[2] === PlayerStatus.PLAYING
? discordSettings.showStateIcon
? 'playing'
: undefined
: 'paused',
smallImageText:
current[2] === PlayerStatus.PLAYING
? discordSettings.showStateIcon
? sentenceCase(current[2])
: undefined
: sentenceCase(current[2]),
state: truncate(artist),
statusDisplayType: StatusDisplayType.STATE,
type: discordSettings.showAsListening ? 2 : 0,
@@ -199,7 +209,7 @@ export const useDiscordRpc = () => {
(song?.album && song.album.padEnd(2, ' ')) || 'Unknown album',
),
smallImageKey: undefined,
smallImageText: sentenceCase(current[2]),
smallImageText: undefined,
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,
@@ -247,9 +257,13 @@ export const useDiscordRpc = () => {
activity.endTimestamp = end;
}
activity.smallImageKey = 'playing';
if (discordSettings.showStateIcon) {
activity.smallImageKey = 'playing';
activity.smallImageText = sentenceCase(current[2]);
}
} else {
activity.smallImageKey = 'paused';
activity.smallImageText = sentenceCase(current[2]);
}
if (discordSettings.showServerImage && song) {
@@ -349,6 +363,7 @@ export const useDiscordRpc = () => {
[
discordSettings.showAsListening,
discordSettings.showServerImage,
discordSettings.showStateIcon,
discordSettings.showPaused,
lastfmApiKey,
discordSettings.clientId,
@@ -3,7 +3,10 @@ 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 } from '/@/renderer/features/shared/components/list-config-menu';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} 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';
@@ -240,7 +243,10 @@ export const FolderListHeaderFilters = () => {
</Group>
<Group gap="sm" wrap="nowrap">
<ListConfigMenu
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
listKey={ItemListKey.SONG}
optionsConfig={{
grid: {
@@ -82,16 +82,7 @@ const HomeRoute = () => {
},
};
const sortedItems = homeItems.filter((item) => {
if (item.disabled) {
return false;
}
if (isJellyfin && item.id === HomeItem.RECENTLY_PLAYED) {
return false;
}
return true;
});
const sortedItems = homeItems.filter((item) => !item.disabled);
const sortedCarousel = sortedItems
.filter((item) => item.id !== HomeItem.GENRES)
@@ -17,7 +17,12 @@ 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 { useAuthStoreActions, useCurrentServer } from '/@/renderer/store';
import {
getServerById,
useAuthStoreActions,
useCurrentServer,
useServerList,
} from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center';
import { Code } from '/@/shared/components/code/code';
@@ -46,11 +51,14 @@ 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 } = useAuthStoreActions();
const { addServer, setCurrentServer, updateServer } = useAuthStoreActions();
const currentServer = useCurrentServer();
const serverList = useServerList();
// Check if server lock is configured
const serverLock = isServerLock();
@@ -141,24 +149,43 @@ 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: serverUrl.replace(/\/$/, ''),
url: normalizedUrl,
userId: data.userId,
username: data.username,
};
if (data.ndCredential !== undefined) {
serverItem.ndCredential = data.ndCredential;
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);
}
addServer(serverItem);
setCurrentServer(serverItem);
toast.success({
message: t('form.addServer.success', { postProcess: 'sentenceCase' }),
});
@@ -6,7 +6,10 @@ 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 } from '/@/renderer/features/shared/components/list-config-menu';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} 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';
@@ -64,10 +67,8 @@ export const PlayQueueListControls = ({
/>
<ListConfigMenu
displayTypes={[
{
hidden: true,
value: ListDisplayType.GRID,
},
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
listKey={type}
optionsConfig={{
@@ -6,6 +6,10 @@
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,7 +11,12 @@ import {
useRadioPlayer,
} from '/@/renderer/features/radio/hooks/use-radio-player';
import { AppRoute } from '/@/renderer/router/routes';
import { useNativeAspectRatio, usePlayerData, usePlayerSong } from '/@/renderer/store';
import {
useGeneralSettings,
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';
@@ -20,7 +25,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 { LibraryItem } from '/@/shared/types/domain-types';
import { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';
const imageVariants: Variants = {
closed: {
@@ -49,9 +54,14 @@ const MotionImage = motion.img;
const ImageWithPlaceholder = ({
className,
explicit,
placeholderIcon = 'itemAlbum',
...props
}: HTMLMotionProps<'img'> & { placeholder?: string; placeholderIcon?: 'itemAlbum' | 'radio' }) => {
}: HTMLMotionProps<'img'> & {
explicit?: boolean;
placeholder?: string;
placeholderIcon?: 'itemAlbum' | 'radio';
}) => {
const nativeAspectRatio = useNativeAspectRatio();
if (!props.src) {
@@ -71,7 +81,9 @@ const ImageWithPlaceholder = ({
return (
<MotionImage
className={clsx(styles.image, className)}
className={clsx(styles.image, className, {
[styles.censored]: explicit,
})}
style={{
objectFit: nativeAspectRatio ? 'contain' : 'cover',
width: nativeAspectRatio ? 'auto' : '100%',
@@ -89,6 +101,7 @@ export const FullScreenPlayerImage = () => {
const currentSong = usePlayerSong();
const { nextSong } = usePlayerData();
const { blurExplicitImages } = useGeneralSettings();
const isPlayingRadio = isRadioActive && isRadioPlaying;
@@ -107,8 +120,10 @@ export const FullScreenPlayerImage = () => {
});
const [imageState, setImageState] = useSetState({
bottomExplicit: nextSong?.explicitStatus === ExplicitStatus.EXPLICIT,
bottomImage: nextImageUrl,
current: 0,
topExplicit: currentSong?.explicitStatus === ExplicitStatus.EXPLICIT,
topImage: currentImageUrl,
});
@@ -133,8 +148,14 @@ 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,
});
@@ -146,6 +167,8 @@ export const FullScreenPlayerImage = () => {
nextSong?._uniqueId,
nextImageUrl,
setImageState,
currentSong?.explicitStatus,
nextSong?.explicitStatus,
]);
return (
@@ -165,6 +188,7 @@ 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)"
@@ -180,6 +204,7 @@ 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,7 +21,10 @@ import {
useIsRadioActive,
useRadioPlayer,
} from '/@/renderer/features/radio/hooks/use-radio-player';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { useFastAverageColor } from '/@/renderer/hooks';
import {
useFullScreenPlayerStore,
@@ -559,7 +562,10 @@ const Controls = () => {
buttonProps={{
variant: 'subtle',
}}
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
listKey={ItemListKey.FULL_SCREEN}
optionsConfig={{
table: {
@@ -4,7 +4,10 @@ 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 } from '/@/renderer/features/shared/components/list-config-menu';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import {
useFullScreenPlayerStore,
useFullScreenPlayerStoreActions,
@@ -365,7 +368,10 @@ export const MobileFullscreenPlayerHeader = memo(
buttonProps={{
variant: isPageHovered ? 'default' : 'subtle',
}}
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
listKey={ItemListKey.FULL_SCREEN}
optionsConfig={{
table: {
@@ -0,0 +1,635 @@
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>
);
};
@@ -0,0 +1,251 @@
import { useEffect, useMemo } from 'react';
import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list';
import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';
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 { 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 { 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';
import { useGeneralSettings, useListSettings } from '/@/renderer/store';
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';
export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListResponse }) => {
const player = usePlayer();
const { setItemCount, setListData } = useListContext();
const { detail, display, grid, itemsPerPage, pagination, table } = useListSettings(
ItemListKey.PLAYLIST_ALBUM,
);
const { enableGridMultiSelect } = useGeneralSettings();
const { currentPage, onChange: onPageChange } = useItemListPagination();
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,
(query.sortBy as SongListSort) ?? SongListSort.ID,
(query.sortOrder as SortOrder) ?? SortOrder.ASC,
);
}, [data?.items, query, searchTerm]);
const sortedAlbums = useMemo(
() => playlistSongsToAlbums(filteredAndSortedSongs),
[filteredAndSortedSongs],
);
const isPaginated = pagination === ListPaginationType.PAGINATED;
const totalAlbumCount = sortedAlbums.length;
const albumPageCount = Math.max(1, Math.ceil(totalAlbumCount / itemsPerPage));
const paginatedAlbums = useMemo(() => {
if (!isPaginated) return sortedAlbums;
const start = currentPage * itemsPerPage;
return sortedAlbums.slice(start, start + itemsPerPage);
}, [isPaginated, currentPage, itemsPerPage, sortedAlbums]);
const albumsToRender = isPaginated ? paginatedAlbums : sortedAlbums;
const playlistSongs = useMemo(() => data?.items ?? [], [data?.items]);
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);
return;
}
player.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
},
onRating: undefined,
};
}, [player]);
useEffect(() => {
setItemCount?.(totalAlbumCount);
}, [setItemCount, totalAlbumCount]);
useEffect(() => {
setListData?.(filteredAndSortedSongs);
}, [filteredAndSortedSongs, setListData]);
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ enabled: true });
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.PLAYLIST_ALBUM,
});
const { handleColumnResized } = useItemListColumnResize({
itemListKey: ItemListKey.PLAYLIST_ALBUM,
});
const { handleColumnReordered: handleDetailColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.PLAYLIST_ALBUM,
tableKey: 'detail',
});
const { handleColumnResized: handleDetailColumnResized } = useItemListColumnResize({
itemListKey: ItemListKey.PLAYLIST_ALBUM,
tableKey: 'detail',
});
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:
return (
<ItemDetailList
enableHeader={detail?.enableHeader}
items={albumsToRender}
listKey={ItemListKey.PLAYLIST_ALBUM}
onColumnReordered={handleDetailColumnReordered}
onColumnResized={handleDetailColumnResized}
onScrollEnd={handleOnScrollEnd}
onSongRowDoubleClick={({ internalState, item }) => {
if (playlistSongs.length === 0) return;
internalState?.setSelected([item]);
player.addToQueueByData(playlistSongs, Play.NOW, item.id);
}}
overrideControls={albumControlOverrides}
scrollOffset={scrollOffset ?? 0}
songsByAlbumId={{}}
tableId="album-detail"
/>
);
case ListDisplayType.GRID:
return (
<ItemGridList
data={albumsToRender}
enableExpansion
enableMultiSelect={enableGridMultiSelect}
gap={grid.itemGap}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
itemType={LibraryItem.ALBUM}
onScrollEnd={handleOnScrollEnd}
overrideControls={albumControlOverrides}
rows={rows}
size={grid.size}
/>
);
case ListDisplayType.TABLE:
return (
<ItemTableList
autoFitColumns={table.autoFitColumns}
CellComponent={ItemTableListColumn}
columns={tableColumns}
data={albumsToRender}
enableAlternateRowColors={table.enableAlternateRowColors}
enableHeader={table.enableHeader}
enableHorizontalBorders={table.enableHorizontalBorders}
enableRowHoverHighlight={table.enableRowHoverHighlight}
enableSelection
enableVerticalBorders={table.enableVerticalBorders}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemType={LibraryItem.ALBUM}
onColumnReordered={handleColumnReordered}
onColumnResized={handleColumnResized}
onScrollEnd={handleOnScrollEnd}
overrideControls={albumControlOverrides}
size={table.size}
/>
);
default:
return null;
}
};
if (isPaginated) {
return (
<ItemListWithPagination
currentPage={currentPage}
itemsPerPage={itemsPerPage}
onChange={onPageChange}
pageCount={albumPageCount}
totalItemCount={totalAlbumCount}
>
{renderAlbumList()}
</ItemListWithPagination>
);
}
return renderAlbumList();
};
@@ -2,14 +2,27 @@ import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
import { ItemListHandle } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { PlaylistDetailAlbumView } from '/@/renderer/features/playlists/components/playlist-detail-album-view';
import { usePlaylistTrackList } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';
import { useCurrentServer, useListSettings } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { PlaylistSongListQuery, PlaylistSongListResponse } from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType, TableColumn } from '/@/shared/types/types';
import {
LibraryItem,
PlaylistSongListQuery,
PlaylistSongListResponse,
Song,
} from '/@/shared/types/domain-types';
import {
ItemListKey,
ListDisplayType,
ListPaginationType,
TableColumn,
} from '/@/shared/types/types';
const PlaylistDetailSongListTable = lazy(() =>
import('/@/renderer/features/playlists/components/playlist-detail-song-list-table').then(
@@ -38,7 +51,6 @@ const PlaylistDetailSongListGrid = lazy(() =>
export const PlaylistDetailSongListContent = () => {
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const { setItemCount } = useListContext();
const queryClient = useQueryClient();
const playlistSongsQuery = useSuspenseQuery(
@@ -50,18 +62,12 @@ export const PlaylistDetailSongListContent = () => {
}),
);
useEffect(() => {
if (
playlistSongsQuery.data?.totalRecordCount !== undefined &&
playlistSongsQuery.data.totalRecordCount !== null
) {
setItemCount?.(playlistSongsQuery.data.totalRecordCount);
}
}, [playlistSongsQuery.data?.totalRecordCount, setItemCount]);
useEffect(() => {
const handleRefresh = async (payload: { key: string }) => {
if (payload.key !== ItemListKey.PLAYLIST_SONG) {
if (
payload.key !== ItemListKey.PLAYLIST_SONG &&
payload.key !== ItemListKey.PLAYLIST_ALBUM
) {
return;
}
@@ -81,7 +87,7 @@ export const PlaylistDetailSongListContent = () => {
return () => {
eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh);
};
}, [playlistId, queryClient, server.id]);
}, [playlistId, queryClient, server?.id]);
return (
<Suspense fallback={<Spinner container />}>
@@ -92,13 +98,35 @@ export const PlaylistDetailSongListContent = () => {
export type OverridePlaylistSongListQuery = Omit<Partial<PlaylistSongListQuery>, 'id'>;
export const PlaylistDetailSongListView = ({ data }: { data: PlaylistSongListResponse }) => {
interface PlaylistDetailSongListViewProps {
data: PlaylistSongListResponse;
items?: Song[];
}
export const PlaylistDetailSongListView = ({ data, items }: PlaylistDetailSongListViewProps) => {
const server = useCurrentServer();
const { display, table } = useListSettings(ItemListKey.PLAYLIST_SONG);
const { display, itemsPerPage, pagination, table } = useListSettings(ItemListKey.PLAYLIST_SONG);
const { currentPage, onChange: onPageChange } = useItemListPagination();
const isPaginated = pagination === ListPaginationType.PAGINATED;
const paginationProps = isPaginated
? {
currentPage,
itemsPerPage,
onPageChange,
}
: undefined;
switch (display) {
case ListDisplayType.GRID: {
return <PlaylistDetailSongListGrid data={data} serverId={server.id} />;
return (
<PlaylistDetailSongListGrid
data={data}
items={items}
serverId={server.id}
{...paginationProps}
/>
);
}
case ListDisplayType.TABLE: {
return (
@@ -111,8 +139,10 @@ export const PlaylistDetailSongListView = ({ data }: { data: PlaylistSongListRes
enableHorizontalBorders={table.enableHorizontalBorders}
enableRowHoverHighlight={table.enableRowHoverHighlight}
enableVerticalBorders={table.enableVerticalBorders}
items={items}
serverId={server.id}
size={table.size}
{...paginationProps}
/>
);
}
@@ -252,19 +282,31 @@ export const PlaylistDetailSongListEdit = ({ data }: { data: PlaylistSongListRes
}
};
const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => {
const PlaylistDetailTrackView = ({ data }: { data: PlaylistSongListResponse }) => {
const { isSmartPlaylist, mode } = useListContext();
if (isSmartPlaylist) {
return <PlaylistDetailSongListView data={data} />;
return <PlaylistDetailTrackViewContent data={data} />;
}
switch (mode) {
case 'edit':
return <PlaylistDetailSongListEdit data={data} />;
case 'view':
return <PlaylistDetailSongListView data={data} />;
default:
return null;
if (mode === 'edit') {
return <PlaylistDetailSongListEdit data={data} />;
}
return <PlaylistDetailTrackViewContent data={data} />;
};
const PlaylistDetailTrackViewContent = ({ data }: { data: PlaylistSongListResponse }) => {
const { sortedAndFilteredSongs } = usePlaylistTrackList(data);
return <PlaylistDetailSongListView data={data} items={sortedAndFilteredSongs} />;
};
const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => {
const { displayMode } = useListContext();
if (displayMode === LibraryItem.ALBUM) {
return <PlaylistDetailAlbumView data={data} />;
}
return <PlaylistDetailTrackView data={data} />;
};
@@ -4,6 +4,7 @@ import { useEffect } from 'react';
import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';
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 { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
@@ -15,40 +16,52 @@ import {
LibraryItem,
PlaylistSongListQuery,
PlaylistSongListResponse,
Song,
} from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface PlaylistDetailSongListGridProps
extends Omit<ItemListGridComponentProps<PlaylistSongListQuery>, 'query'> {
currentPage?: number;
data: PlaylistSongListResponse;
items?: Song[];
itemsPerPage?: number;
onPageChange?: (page: number) => void;
}
export const PlaylistDetailSongListGrid = forwardRef<any, PlaylistDetailSongListGridProps>(
({ data, saveScrollOffset = true }) => {
({
currentPage,
data,
items: itemsProp,
itemsPerPage,
onPageChange,
saveScrollOffset = true,
}) => {
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
enabled: saveScrollOffset,
});
const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters();
const { setListData } = useListContext();
const songData = useMemo(() => {
let items = data?.items || [];
const songDataFromData = useMemo(() => {
let list = data?.items || [];
if (searchTerm) {
items = searchLibraryItems(items, searchTerm, LibraryItem.SONG);
return items;
list = searchLibraryItems(list, searchTerm, LibraryItem.SONG);
return list;
}
return sortSongList(items, query.sortBy, query.sortOrder);
return sortSongList(list, query.sortBy, query.sortOrder);
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
const { setListData } = useListContext();
const songData = itemsProp ?? songDataFromData;
useEffect(() => {
if (setListData) {
setListData(songData);
if (itemsProp == null && setListData) {
setListData(songDataFromData);
}
}, [songData, setListData]);
}, [itemsProp, songDataFromData, setListData]);
const gridProps = useListSettings(ItemListKey.PLAYLIST_SONG).grid;
@@ -59,9 +72,22 @@ export const PlaylistDetailSongListGrid = forwardRef<any, PlaylistDetailSongList
);
const { enableGridMultiSelect } = useGeneralSettings();
return (
const isPaginated =
typeof currentPage === 'number' &&
typeof itemsPerPage === 'number' &&
typeof onPageChange === 'function';
const totalCount = songData.length;
const pageCount = Math.max(1, Math.ceil(totalCount / (itemsPerPage ?? 1)));
const paginatedData = useMemo(() => {
if (!isPaginated || currentPage == null || itemsPerPage == null) return songData;
const start = currentPage * itemsPerPage;
return songData.slice(start, start + itemsPerPage);
}, [currentPage, isPaginated, itemsPerPage, songData]);
const dataToRender = isPaginated ? paginatedData : songData;
const grid = (
<ItemGridList
data={songData}
data={dataToRender}
enableMultiSelect={enableGridMultiSelect}
gap={gridProps.itemGap}
initialTop={{
@@ -69,11 +95,27 @@ export const PlaylistDetailSongListGrid = forwardRef<any, PlaylistDetailSongList
type: 'offset',
}}
itemsPerRow={gridProps.itemsPerRowEnabled ? gridProps.itemsPerRow : undefined}
itemType={LibraryItem.SONG}
itemType={LibraryItem.PLAYLIST_SONG}
onScrollEnd={handleOnScrollEnd}
rows={rows}
size={gridProps.size}
/>
);
if (isPaginated && itemsPerPage != null) {
return (
<ItemListWithPagination
currentPage={currentPage!}
itemsPerPage={itemsPerPage}
onChange={onPageChange!}
pageCount={pageCount}
totalItemCount={totalCount}
>
{grid}
</ItemListWithPagination>
);
}
return grid;
},
);
@@ -1,30 +1,48 @@
import { openContextModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import i18n from '/@/i18n/i18n';
import { PLAYLIST_SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import {
ALBUM_TABLE_COLUMNS,
PLAYLIST_SONG_TABLE_COLUMNS,
SONG_TABLE_COLUMNS,
} from '/@/renderer/components/item-list/item-table-list/default-columns';
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 { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
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 { 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 { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';
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 { useCurrentServerId } from '/@/renderer/store';
import {
PlaylistTarget,
useCurrentServerId,
usePlaylistTarget,
useSettingsStoreActions,
} from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
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';
@@ -33,12 +51,77 @@ 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) => {
const { t } = useTranslation();
const { mode, setMode } = useListContext();
const { listKey: listKeyFromContext, mode, setMode } = useListContext();
const { playlistId } = useParams() as { playlistId: string };
const playlistTarget = usePlaylistTarget();
const { setPlaylistBehavior } = useSettingsStoreActions();
const serverId = useCurrentServerId();
const detailQuery = useQuery(playlistsQueries.detail({ query: { id: playlistId }, serverId }));
@@ -55,9 +138,25 @@ export const PlaylistDetailSongListHeaderFilters = ({
});
};
const listKey =
listKeyFromContext ??
(playlistTarget === PlaylistTarget.ALBUM
? ItemListKey.PLAYLIST_ALBUM
: ItemListKey.PLAYLIST_SONG);
const isAlbumMode = listKey === ItemListKey.PLAYLIST_ALBUM;
const toggleChoice = isAlbumMode
? t('entity.album', { count: 2, postProcess: 'titleCase' })
: t('entity.track', { count: 2, postProcess: 'titleCase' });
const handleToggleDisplayMode = useCallback(() => {
setPlaylistBehavior(
playlistTarget === PlaylistTarget.ALBUM ? PlaylistTarget.TRACK : PlaylistTarget.ALBUM,
);
}, [playlistTarget, setPlaylistBehavior]);
const { ref: containerRef, ...breakpoints } = useContainerQuery();
const isViewEditMode = !isSmartPlaylist && breakpoints.isSm;
const isViewEditMode = !isSmartPlaylist && (breakpoints.isSm || isAlbumMode);
const isEditMode = mode === 'edit';
const [collapsed, setCollapsed] = useLocalStorage<boolean>({
@@ -68,6 +167,14 @@ export const PlaylistDetailSongListHeaderFilters = ({
return (
<Flex justify="space-between" ref={containerRef}>
<Group gap="sm" w="100%">
<Button
leftSection={<Icon icon="arrowLeftRight" />}
onClick={handleToggleDisplayMode}
variant="subtle"
>
{toggleChoice}
</Button>
<Divider orientation="vertical" />
<ListSortByDropdown
defaultSortByValue={SongListSort.ID}
disabled={isEditMode}
@@ -80,8 +187,9 @@ export const PlaylistDetailSongListHeaderFilters = ({
disabled={isEditMode}
listKey={ItemListKey.PLAYLIST_SONG}
/>
{!collapsed && <ListSearchInput />}
<ListRefreshButton disabled={isEditMode} listKey={ItemListKey.PLAYLIST_SONG} />
<Divider orientation="vertical" />
<PlaylistSongListFiltersModal />
<ListRefreshButton disabled={isEditMode} listKey={listKey} />
<MoreButton onClick={handleMore} />
</Group>
<Group gap="sm" wrap="nowrap">
@@ -109,11 +217,26 @@ export const PlaylistDetailSongListHeaderFilters = ({
variant="subtle"
/>
</Tooltip>
<ListDisplayTypeToggleButton listKey={ItemListKey.PLAYLIST_SONG} />
<ListConfigMenu
listKey={ItemListKey.PLAYLIST_SONG}
tableColumnsData={PLAYLIST_SONG_TABLE_COLUMNS}
/>
<ListDisplayTypeToggleButton enableDetail={isAlbumMode} listKey={listKey} />
{isAlbumMode ? (
<ListConfigMenu
detailConfig={{
optionsConfig: {
autoFitColumns: { hidden: true },
},
tableColumnsData: SONG_TABLE_COLUMNS,
tableKey: 'detail',
}}
listKey={listKey}
tableColumnsData={ALBUM_TABLE_COLUMNS}
/>
) : (
<ListConfigMenu
displayTypes={SONG_DISPLAY_TYPES}
listKey={listKey}
tableColumnsData={PLAYLIST_SONG_TABLE_COLUMNS}
/>
)}
</Group>
</Flex>
);
@@ -93,6 +93,7 @@ export const PlaylistDetailSongListHeader = ({
</PageHeader>
) : (
<LibraryHeader
compact
imageUrl={imageUrl}
item={{
imageId: detailQuery?.data?.imageId,
@@ -101,6 +102,7 @@ export const PlaylistDetailSongListHeader = ({
type: LibraryItem.PLAYLIST,
}}
title={detailQuery?.data?.name || ''}
topRight={<ListSearchInput />}
>
<LibraryHeaderMenu
onPlay={(type) => handlePlay(type)}
@@ -4,6 +4,7 @@ import { useEffect } from 'react';
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
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 { ItemControls, ItemListTableComponentProps } from '/@/renderer/components/item-list/types';
@@ -24,7 +25,11 @@ import { ItemListKey, Play } from '/@/shared/types/types';
interface PlaylistDetailSongListTableProps
extends Omit<ItemListTableComponentProps<PlaylistSongListQuery>, 'query'> {
currentPage?: number;
data: PlaylistSongListResponse;
items?: Song[];
itemsPerPage?: number;
onPageChange?: (page: number) => void;
}
export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongListTableProps>(
@@ -32,6 +37,7 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
{
autoFitColumns = false,
columns,
currentPage,
data,
enableAlternateRowColors = false,
enableHeader = true,
@@ -39,6 +45,9 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
enableRowHoverHighlight = true,
enableSelection = true,
enableVerticalBorders = false,
items: itemsProp,
itemsPerPage,
onPageChange,
saveScrollOffset = true,
size = 'default',
},
@@ -58,24 +67,24 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters();
const { setListData } = useListContext();
const songData = useMemo(() => {
let items = data?.items || [];
const songDataFromData = useMemo(() => {
let list = data?.items || [];
if (searchTerm) {
items = searchLibraryItems(items, searchTerm, LibraryItem.SONG);
return items;
list = searchLibraryItems(list, searchTerm, LibraryItem.SONG);
return list;
}
return sortSongList(items, query.sortBy, query.sortOrder);
return sortSongList(list, query.sortBy, query.sortOrder);
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
const { setListData } = useListContext();
const songData = itemsProp ?? songDataFromData;
useEffect(() => {
if (setListData) {
setListData(songData);
if (itemsProp == null && setListData) {
setListData(songDataFromData);
}
}, [songData, setListData]);
}, [itemsProp, songDataFromData, setListData]);
const player = usePlayer();
@@ -108,13 +117,26 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
};
}, []);
return (
const isPaginated =
typeof currentPage === 'number' &&
typeof itemsPerPage === 'number' &&
typeof onPageChange === 'function';
const totalCount = songData.length;
const pageCount = Math.max(1, Math.ceil(totalCount / (itemsPerPage ?? 1)));
const paginatedData = useMemo(() => {
if (!isPaginated || currentPage == null || itemsPerPage == null) return songData;
const start = currentPage * itemsPerPage;
return songData.slice(start, start + itemsPerPage);
}, [isPaginated, currentPage, itemsPerPage, songData]);
const dataToRender = isPaginated ? paginatedData : songData;
const table = (
<ItemTableList
activeRowId={currentSong?.id}
autoFitColumns={autoFitColumns}
CellComponent={ItemTableListColumn}
columns={columns}
data={songData}
data={dataToRender}
enableAlternateRowColors={enableAlternateRowColors}
enableExpansion={false}
enableHeader={enableHeader}
@@ -136,6 +158,22 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
size={size}
/>
);
if (isPaginated && itemsPerPage != null) {
return (
<ItemListWithPagination
currentPage={currentPage!}
itemsPerPage={itemsPerPage}
onChange={onPageChange!}
pageCount={pageCount}
totalItemCount={totalCount}
>
{table}
</ItemListWithPagination>
);
}
return table;
},
);
@@ -0,0 +1,393 @@
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,17 +5,25 @@ 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);
@@ -24,8 +32,8 @@ export const usePlaylistSongListFilters = () => {
const [searchParams, setSearchParams] = useSearchParams();
const albumIds = useMemo(
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_IDS),
const albumArtistIds = useMemo(
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS),
[searchParams],
);
@@ -54,16 +62,22 @@ 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 setAlbumIds = useCallback(
const setAlbumArtistIds = useCallback(
(value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_IDS, value), {
replace: true,
});
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS, value),
{ replace: true },
);
},
[setSearchParams],
);
@@ -113,6 +127,30 @@ 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(
@@ -141,26 +179,74 @@ export const usePlaylistSongListFilters = () => {
[setSearchParams],
);
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,
};
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,
],
);
return {
clear,
query,
setAlbumIds,
setAlbumArtistIds,
setAlbumArtistIdsMode,
setArtistIds,
setArtistIdsMode,
setCustom,
setFavorite,
setGenreId,
setGenreIdsMode,
setHasRating,
setMaxYear,
setMinYear,
setSearchTerm,
@@ -0,0 +1,118 @@
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;
}
export function usePlaylistTrackList(data: PlaylistSongListResponse | undefined): {
sortedAndFilteredSongs: Song[];
totalCount: number;
} {
const { setItemCount, setListData } = useListContext();
const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters();
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]);
const totalCount = sortedAndFilteredSongs.length;
useEffect(() => {
setListData?.(sortedAndFilteredSongs);
setItemCount?.(totalCount);
}, [query, searchTerm, setListData, setItemCount, sortedAndFilteredSongs, totalCount]);
return { sortedAndFilteredSongs, totalCount };
}
@@ -1,235 +1,71 @@
import { closeAllModals, openModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { Suspense, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath, useLocation, useNavigate, useParams } from 'react-router';
import { ListContext } from '/@/renderer/context/list-context';
import { ListContext, useListContext } 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 {
PlaylistQueryBuilder,
PlaylistQueryBuilderRef,
} from '/@/renderer/features/playlists/components/playlist-query-builder';
import { PlaylistQueryBuilderRef } from '/@/renderer/features/playlists/components/playlist-query-builder';
import { PlaylistQueryEditor } from '/@/renderer/features/playlists/components/playlist-query-editor';
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 { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import {
PlaylistTarget,
useCurrentServer,
usePageSidebar,
usePlaylistTarget,
} from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
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 { ServerType, SongListSort } from '/@/shared/types/domain-types';
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
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 PlaylistSongListFiltersSidebar = () => {
const { t } = useTranslation();
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]);
const { setIsSidebarOpen } = useListContext();
const { clear } = usePlaylistSongListFilters();
return (
<div className="query-editor-container">
<Stack gap={0} h="100%" mah="30dvh" p="md" w="100%">
<Group justify="space-between" pb="md" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<Button
leftSection={
<Icon
icon={isQueryBuilderExpanded ? 'arrowUpS' : 'arrowDownS'}
size="lg"
/>
}
onClick={onToggleExpand}
size="sm"
<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"
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>
<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>
</Group>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<ClientSideSongFilters />
</ScrollArea>
</Stack>
);
};
@@ -396,24 +232,45 @@ const PlaylistDetailSongListRoute = () => {
setIsQueryBuilderExpanded(true);
};
const playlistTarget = usePlaylistTarget();
const displayMode: LibraryItem.ALBUM | LibraryItem.SONG =
playlistTarget === PlaylistTarget.ALBUM ? LibraryItem.ALBUM : LibraryItem.SONG;
const listKey =
displayMode === LibraryItem.ALBUM ? ItemListKey.PLAYLIST_ALBUM : ItemListKey.PLAYLIST_SONG;
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: ItemListKey.PLAYLIST_SONG,
pageKey: listKey,
setIsSidebarOpen,
setItemCount,
setListData,
setMode,
};
}, [playlistId, isSmartPlaylist, itemCount, listData, mode]);
}, [
playlistId,
isSmartPlaylist,
displayMode,
listKey,
isSidebarOpen,
itemCount,
listData,
mode,
setIsSidebarOpen,
]);
return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
@@ -429,6 +286,15 @@ const PlaylistDetailSongListRoute = () => {
onDelete={() => openDeletePlaylistModal()}
onToggleQueryBuilder={handleToggleShowQueryBuilder}
/>
<ListWithSidebarContainer>
<ListWithSidebarContainer.SidebarPortal>
<PlaylistSongListFiltersSidebar />
</ListWithSidebarContainer.SidebarPortal>
<Suspense fallback={<Spinner container />}>
<PlaylistDetailSongListContent />
</Suspense>
</ListWithSidebarContainer>
{(isSmartPlaylist || showQueryBuilder) && (
<PlaylistQueryEditor
createPlaylistMutation={createPlaylistMutation}
@@ -441,9 +307,6 @@ const PlaylistDetailSongListRoute = () => {
queryBuilderRef={queryBuilderRef}
/>
)}
<Suspense fallback={<Spinner container />}>
<PlaylistDetailSongListContent />
</Suspense>
</ListContext.Provider>
</AnimatedPage>
);
+67
View File
@@ -1,8 +1,75 @@
import { nanoid } from 'nanoid/non-secure';
import { NDSongQueryFields } from '/@/shared/api/navidrome/navidrome-types';
import { Album, LibraryItem, Song } from '/@/shared/types/domain-types';
import { QueryBuilderGroup } from '/@/shared/types/types';
export type PlaylistAlbumRow = Album & { _playlistSongs?: Song[] };
export function playlistSongsToAlbums(songs: Song[]): PlaylistAlbumRow[] {
if (songs.length === 0) return [];
const rows: PlaylistAlbumRow[] = [];
let group: Song[] = [songs[0]];
let prevAlbumId = songs[0].albumId;
const pushRow = (song: Song, groupSongs: Song[]) => {
rows.push({
_itemType: LibraryItem.ALBUM,
_playlistSongs: groupSongs,
_serverId: song._serverId,
_serverType: song._serverType,
albumArtistName: song.albumArtistName,
albumArtists: song.albumArtists,
artists: song.artists,
comment: song.comment,
createdAt: song.createdAt,
duration: null,
explicitStatus: song.explicitStatus,
genres: song.genres,
id: song.albumId,
imageId: song.imageId,
imageUrl: song.imageUrl,
isCompilation: song.compilation,
lastPlayedAt: song.lastPlayedAt,
mbzId: null,
mbzReleaseGroupId: null,
name: song.album ?? '',
originalDate: null,
originalYear: null,
participants: song.participants,
playCount: null,
recordLabels: [],
releaseDate: song.releaseDate,
releaseType: null,
releaseTypes: [],
releaseYear: song.releaseYear,
size: null,
songCount: null,
sortName: song.album ?? '',
tags: song.tags,
updatedAt: song.updatedAt,
userFavorite: false,
userRating: null,
version: null,
});
};
for (let i = 1; i < songs.length; i++) {
const song = songs[i];
if (song.albumId === prevAlbumId) {
group.push(song);
} else {
pushRow(group[0], group);
group = [song];
prevAlbumId = song.albumId;
}
}
pushRow(group[0], group);
return rows;
}
export const parseQueryBuilderChildren = (groups: QueryBuilderGroup[], data: any[]) => {
if (groups.length === 0) {
return data;
@@ -11,7 +11,10 @@ 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 } from '/@/renderer/features/shared/components/list-config-menu';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} 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';
@@ -44,6 +47,7 @@ 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 } from '/@/renderer/store';
import { useAuthStoreActions, useServerList } 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,6 +98,7 @@ 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();
@@ -128,6 +129,13 @@ 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,6 +2,7 @@ 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';
@@ -23,6 +24,7 @@ export const ServerList = () => {
const { t } = useTranslation();
const currentServer = useCurrentServer();
const serverListQuery = useServerList();
const serverLock = isServerLock();
const handleAddServerModal = () => {
openContextModal({
@@ -70,15 +72,17 @@ export const ServerList = () => {
</Accordion.Item>
);
})}
<Group grow pt="md">
<Button
autoFocus
leftSection={<Icon icon="add" />}
onClick={handleAddServerModal}
>
{t('form.addServer.title', { postProcess: 'titleCase' })}
</Button>
</Group>
{!serverLock && (
<Group grow pt="md">
<Button
autoFocus
leftSection={<Icon icon="add" />}
onClick={handleAddServerModal}
>
{t('form.addServer.title', { postProcess: 'titleCase' })}
</Button>
</Group>
)}
</Accordion>
{isElectron() && (
<>
@@ -4,6 +4,7 @@ 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(() =>
@@ -71,29 +72,29 @@ export const SettingsContent = () => {
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="general">
<Suspense fallback={null}>
<Suspense fallback={<Spinner container />}>
<GeneralTab />
</Suspense>
</Tabs.Panel>
<Tabs.Panel value="playback">
<Suspense fallback={null}>
<Suspense fallback={<Spinner container />}>
<PlaybackTab />
</Suspense>
</Tabs.Panel>
<Tabs.Panel value="hotkeys">
<Suspense fallback={null}>
<Suspense fallback={<Spinner container />}>
<HotkeysTab />
</Suspense>
</Tabs.Panel>
{isElectron() && (
<Tabs.Panel value="window">
<Suspense fallback={null}>
<Suspense fallback={<Spinner container />}>
<WindowTab />
</Suspense>
</Tabs.Panel>
)}
<Tabs.Panel value="advanced">
<Suspense fallback={null}>
<Suspense fallback={<Spinner container />}>
<AdvancedTab />
</Suspense>
</Tabs.Panel>
@@ -98,6 +98,28 @@ 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,
gcTime: 1000 * 60 * 24,
queryFn: ({ signal }) => {
return api.controller.getTagList({
apiClientProps: { serverId: args.serverId, signal },
@@ -38,7 +38,8 @@ export const sharedQueries = {
});
},
queryKey: queryKeys.tags.list(args.serverId || '', args.query.type),
staleTime: 1000 * 60,
staleTime: 1000 * 60 * 24,
structuralSharing: false,
...args.options,
});
},
@@ -1,3 +1,10 @@
.top-right {
position: absolute;
top: var(--theme-spacing-lg);
right: var(--theme-spacing-md);
z-index: 20;
}
.library-header {
position: relative;
display: grid;
@@ -56,6 +63,52 @@
height: 250px;
}
}
&.compact {
min-height: unset;
padding: var(--theme-spacing-md) var(--theme-spacing-xs);
:global(.item-image-placeholder) {
width: 250px !important;
height: 250px;
}
.image {
width: 250px !important;
height: 250px;
}
@container (min-width: $mantine-breakpoint-sm) {
grid-template-columns: 200px minmax(0, 1fr);
min-height: unset;
padding: var(--theme-spacing-md) var(--theme-spacing-sm);
.image {
width: 200px !important;
height: 200px;
}
:global(.item-image-placeholder) {
width: 200px !important;
height: 200px;
}
}
@container (min-width: $mantine-breakpoint-lg) {
grid-template-columns: 200px minmax(0, 1fr);
padding: var(--theme-spacing-md) var(--theme-spacing-md);
.image {
width: 200px !important;
height: 200px;
}
:global(.item-image-placeholder) {
width: 200px !important;
height: 200px;
}
}
}
}
.image-section {
@@ -32,6 +32,7 @@ import { Play } from '/@/shared/types/types';
interface LibraryHeaderProps {
children?: ReactNode;
compact?: boolean;
containerClassName?: string;
imagePlaceholderUrl?: null | string;
imageUrl?: null | string;
@@ -45,11 +46,20 @@ interface LibraryHeaderProps {
};
loading?: boolean;
title: string;
topRight?: ReactNode;
}
export const LibraryHeader = forwardRef(
(
{ children, containerClassName, imageUrl, item, title }: LibraryHeaderProps,
{
children,
compact,
containerClassName,
imageUrl,
item,
title,
topRight,
}: LibraryHeaderProps,
ref: Ref<HTMLDivElement>,
) => {
const { t } = useTranslation();
@@ -125,7 +135,15 @@ export const LibraryHeader = forwardRef(
}, [item.explicitStatus, item.imageId, item.type]);
return (
<div className={clsx(styles.libraryHeader, containerClassName)} ref={ref}>
<div
className={clsx(
styles.libraryHeader,
containerClassName,
compact && styles.compact,
)}
ref={ref}
>
{topRight && <div className={styles.topRight}>{topRight}</div>}
<div
className={styles.imageSection}
onClick={() => {
@@ -18,6 +18,10 @@ 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: (
@@ -224,6 +224,11 @@ export const CLIENT_SIDE_ALBUM_FILTERS = [
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: AlbumListSort.ID,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
@@ -295,6 +300,11 @@ const ALBUM_LIST_FILTERS: Partial<
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: AlbumListSort.ID,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.communityRating', { postProcess: 'titleCase' }),
@@ -337,6 +347,11 @@ const ALBUM_LIST_FILTERS: Partial<
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: AlbumListSort.ID,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.artist', { postProcess: 'titleCase' }),
@@ -399,6 +414,11 @@ const ALBUM_LIST_FILTERS: Partial<
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: AlbumListSort.ID,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),
+12 -17
View File
@@ -61,10 +61,14 @@ enum SharedFilterKeys {
enum SongFilterKeys {
_CUSTOM = '_custom',
ALBUM_IDS = 'albumIds',
ALBUM_ARTIST_IDS = 'albumArtistIds',
ALBUM_ARTIST_IDS_MODE = 'albumArtistIdsMode',
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',
}
@@ -124,23 +128,12 @@ export const createFuseForLibraryItem = <T extends FuseSearchableItem>(
});
}
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 stringKeys: string[] = [];
const nestedKeys: Array<{ getFn: (item: T) => string; name: string }> = [];
switch (itemType) {
case LibraryItem.ALBUM: {
stringKeys.push('name', 'releaseType');
nestedKeys.push(
{
getFn: (item) => {
@@ -168,6 +161,7 @@ export const createFuseForLibraryItem = <T extends FuseSearchableItem>(
}
case LibraryItem.ALBUM_ARTIST: {
stringKeys.push('name');
nestedKeys.push({
getFn: (item) => {
const aa = item as AlbumArtist;
@@ -181,9 +175,10 @@ 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;
@@ -196,7 +191,8 @@ export const createFuseForLibraryItem = <T extends FuseSearchableItem>(
case LibraryItem.PLAYLIST_SONG:
case LibraryItem.QUEUE_SONG:
case LibraryItem.SONG: {
case LibraryItem.SONG:
stringKeys.push('album', 'name');
nestedKeys.push(
{
getFn: (item) => {
@@ -214,7 +210,6 @@ export const createFuseForLibraryItem = <T extends FuseSearchableItem>(
},
);
break;
}
}
return new Fuse(items, {
@@ -53,6 +53,10 @@
border-radius: var(--theme-radius-md);
}
.censored.sidebar-image {
filter: blur(20px);
}
.accordion-root {
height: 100%;
}
@@ -24,6 +24,7 @@ import {
useAppStore,
useAppStoreActions,
useFullScreenPlayerStore,
useGeneralSettings,
usePlayerSong,
useSetFullScreenPlayerStore,
} from '/@/renderer/store';
@@ -42,7 +43,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 { LibraryItem } from '/@/shared/types/domain-types';
import { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';
import { Platform } from '/@/shared/types/types';
export const Sidebar = () => {
@@ -167,6 +168,7 @@ const SidebarImage = () => {
const currentSong = usePlayerSong();
const isRadioActive = useIsRadioActive();
const { isPlaying: isRadioPlaying } = useRadioPlayer();
const { blurExplicitImages } = useGeneralSettings();
const imageUrl = useItemImageUrl({
id: currentSong?.imageId || undefined,
@@ -235,7 +237,15 @@ const SidebarImage = () => {
<Icon color="muted" icon="radio" size="40%" />
</Center>
) : imageUrl ? (
<img className={styles.sidebarImage} loading="eager" src={imageUrl} />
<img
className={clsx(styles.sidebarImage, {
[styles.censored]:
currentSong?.explicitStatus === ExplicitStatus.EXPLICIT &&
blurExplicitImages,
})}
loading="eager"
src={imageUrl}
/>
) : (
<ImageUnloader icon="emptySongImage" />
)}
@@ -4,7 +4,10 @@ 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 } from '/@/renderer/features/shared/components/list-config-menu';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';
import {
isFilterValueSet,
@@ -52,7 +55,6 @@ 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]) ||
@@ -92,7 +94,11 @@ export const SongListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarget
</Group>
<Group gap="sm" wrap="nowrap">
<ListDisplayTypeToggleButton listKey={ItemListKey.SONG} />
<ListConfigMenu listKey={ItemListKey.SONG} tableColumnsData={SONG_TABLE_COLUMNS} />
<ListConfigMenu
displayTypes={SONG_DISPLAY_TYPES}
listKey={ItemListKey.SONG}
tableColumnsData={SONG_TABLE_COLUMNS}
/>
</Group>
</Flex>
);
@@ -6,6 +6,7 @@ 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';
@@ -31,10 +32,11 @@ 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: ItemListKey.SONG,
eventKey: pageKey || ItemListKey.SONG,
itemsPerPage,
itemType: LibraryItem.SONG,
listCountQuery,
@@ -8,6 +8,7 @@ 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';
@@ -39,10 +40,11 @@ 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: ItemListKey.SONG,
eventKey: pageKey || ItemListKey.SONG,
itemsPerPage,
itemType: LibraryItem.SONG,
listCountQuery,
@@ -7,6 +7,7 @@ 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';
@@ -25,6 +26,7 @@ export const SongListPaginatedGrid = ({
serverId,
size,
}: SongListPaginatedGridProps) => {
const { pageKey } = useListContext();
const { currentPage, onChange } = useItemListPagination();
const listCountQuery = songsQueries.listCount({
@@ -36,7 +38,7 @@ export const SongListPaginatedGrid = ({
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
currentPage,
eventKey: ItemListKey.SONG,
eventKey: pageKey || ItemListKey.SONG,
itemsPerPage,
itemType: LibraryItem.SONG,
listCountQuery,
@@ -10,6 +10,7 @@ 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';
@@ -35,6 +36,7 @@ export const SongListPaginatedTable = ({
serverId,
size = 'default',
}: SongListPaginatedTableProps) => {
const { pageKey } = useListContext();
const { currentPage, onChange } = useItemListPagination();
const listCountQuery = songsQueries.listCount({
@@ -46,7 +48,7 @@ export const SongListPaginatedTable = ({
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
currentPage,
eventKey: ItemListKey.SONG,
eventKey: pageKey || ItemListKey.SONG,
itemsPerPage,
itemType: LibraryItem.SONG,
listCountQuery,
@@ -28,11 +28,6 @@ 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],
@@ -63,15 +58,6 @@ 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), {
@@ -153,7 +139,6 @@ 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,
@@ -172,31 +157,18 @@ 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,
albumIds,
artistIds,
favorite,
genreId,
maxYear,
minYear,
],
[searchTerm, sortBy, sortOrder, custom, artistIds, favorite, genreId, maxYear, minYear],
);
return {
clear,
query,
setAlbumIds,
setArtistIds,
setCustom,
setFavorite,
@@ -27,3 +27,17 @@
.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,11 +6,18 @@ 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, useSideQueueType } from '/@/renderer/store';
import {
useAppStore,
useAppStoreActions,
useGlobalExpanded,
useSideQueueType,
} from '/@/renderer/store';
import { constrainRightSidebarWidth, constrainSidebarWidth } from '/@/renderer/utils';
import { Spinner } from '/@/shared/components/spinner/spinner';
@@ -159,10 +166,27 @@ export const MainContent = ({ shell }: { shell?: boolean }) => {
);
};
function MainContentBody() {
function GlobalExpandedPanel() {
const globalExpanded = useGlobalExpanded();
if (!globalExpanded) return null;
return (
<Suspense fallback={<Spinner container />}>
<Outlet />
</Suspense>
<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>
);
}
+9 -8
View File
@@ -8,6 +8,7 @@ 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'),
@@ -90,7 +91,7 @@ const LazyLyricsSettingsContextModal = lazy(() =>
);
const LyricsSettingsContextModal = (props: any) => (
<Suspense fallback={<></>}>
<Suspense fallback={<Spinner container />}>
<LazyLyricsSettingsContextModal {...props} />
</Suspense>
);
@@ -102,7 +103,7 @@ const LazyShuffleAllContextModal = lazy(() =>
);
const ShuffleAllContextModal = (props: any) => (
<Suspense fallback={<></>}>
<Suspense fallback={<Spinner container />}>
<LazyShuffleAllContextModal {...props} />
</Suspense>
);
@@ -116,7 +117,7 @@ const LazyAddToPlaylistContextModal = lazy(() =>
);
const AddToPlaylistContextModal = (props: any) => (
<Suspense fallback={<></>}>
<Suspense fallback={<Spinner container />}>
<LazyAddToPlaylistContextModal {...props} />
</Suspense>
);
@@ -130,7 +131,7 @@ const LazySaveAndReplaceContextModal = lazy(() =>
);
const SaveAndReplaceContextModal = (props: any) => (
<Suspense fallback={<></>}>
<Suspense fallback={<Spinner container />}>
<LazySaveAndReplaceContextModal {...props} />
</Suspense>
);
@@ -142,7 +143,7 @@ const LazyUpdatePlaylistContextModal = lazy(() =>
);
const UpdatePlaylistContextModal = (props: any) => (
<Suspense fallback={<></>}>
<Suspense fallback={<Spinner container />}>
<LazyUpdatePlaylistContextModal {...props} />
</Suspense>
);
@@ -154,7 +155,7 @@ const LazySettingsContextModal = lazy(() =>
);
const SettingsContextModal = (props: any) => (
<Suspense fallback={<></>}>
<Suspense fallback={<Spinner container />}>
<LazySettingsContextModal {...props} />
</Suspense>
);
@@ -166,7 +167,7 @@ const LazyShareItemContextModal = lazy(() =>
);
const ShareItemContextModal = (props: any) => (
<Suspense fallback={<></>}>
<Suspense fallback={<Spinner container />}>
<LazyShareItemContextModal {...props} />
</Suspense>
);
@@ -180,7 +181,7 @@ const LazyVisualizerSettingsContextModal = lazy(() =>
);
const VisualizerSettingsContextModal = (props: any) => (
<Suspense fallback={<></>}>
<Suspense fallback={<Spinner container />}>
<LazyVisualizerSettingsContextModal {...props} />
</Suspense>
);
+67 -1
View File
@@ -1,3 +1,6 @@
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';
@@ -10,9 +13,14 @@ 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;
@@ -27,9 +35,14 @@ 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;
@@ -39,6 +52,11 @@ export interface AppState {
titlebar: TitlebarProps;
}
export interface GlobalExpandedState {
item: ItemListStateItem;
itemType: LibraryItem;
}
type CommandPaletteProps = {
close: () => void;
open: () => void;
@@ -79,19 +97,44 @@ 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;
@@ -123,6 +166,9 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
},
albumArtistIdsMode: 'and',
albumArtistSelectMode: 'multi',
artistIdsMode: 'and',
artistSelectMode: 'multi',
commandPalette: {
close: () => {
@@ -142,7 +188,9 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
});
},
},
genreIdsMode: 'and',
genreSelectMode: 'multi',
globalExpanded: null,
isReorderingQueue: false,
pageSidebar: {
album: true,
@@ -178,7 +226,12 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
return persistedState;
},
name: 'store_app',
version: 3,
partialize: (state) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- ignore non-persisted state
const { globalExpanded: _, ...rest } = state;
return rest;
},
version: 4,
},
),
);
@@ -205,3 +258,16 @@ 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 };
};
+97
View File
@@ -152,6 +152,8 @@ const DiscordLinkTypeSchema = z.enum(['last_fm', 'musicbrainz', 'musicbrainz_las
const GenreTargetSchema = z.enum(['album', 'track']);
const PlaylistTargetSchema = z.enum(['album', 'track']);
const SideQueueTypeSchema = z.enum(['sideDrawerQueue', 'sideQueue']);
const SidebarPanelTypeSchema = z.enum(['queue', 'lyrics', 'visualizer']);
@@ -261,6 +263,7 @@ const DiscordSettingsSchema = z.object({
showAsListening: z.boolean(),
showPaused: z.boolean(),
showServerImage: z.boolean(),
showStateIcon: z.boolean(),
});
const FontSettingsSchema = z.object({
@@ -458,6 +461,7 @@ export const GeneralSettingsSchema = z.object({
playButtonBehavior: z.nativeEnum(Play),
playerbarOpenDrawer: z.boolean(),
playerbarSlider: PlayerbarSliderSchema,
playlistTarget: PlaylistTargetSchema,
resume: z.boolean(),
showLyricsInSidebar: z.boolean(),
showRatings: z.boolean(),
@@ -775,6 +779,11 @@ export enum PlayerbarSliderType {
WAVEFORM = 'waveform',
}
export enum PlaylistTarget {
ALBUM = 'album',
TRACK = 'track',
}
export enum SidebarItem {
ALBUMS = 'Albums',
ARTISTS = 'Artists',
@@ -829,6 +838,7 @@ export interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {
setHomeItems: (item: SortableItem<HomeItem>[]) => void;
setList: (type: ItemListKey, data: DeepPartial<ItemListSettings>) => void;
setPlaybackFilters: (filters: PlayerFilter[]) => void;
setPlaylistBehavior: (target: PlaylistTarget) => void;
setSettings: (data: DeepPartial<SettingsState>) => void;
setSidebarItems: (items: SidebarItemType[]) => void;
setTable: (type: ItemListKey, data: DataTableProps) => void;
@@ -986,6 +996,7 @@ const initialState: SettingsState = {
showAsListening: false,
showPaused: true,
showServerImage: false,
showStateIcon: true,
},
font: {
builtIn: 'Inter',
@@ -1039,6 +1050,7 @@ const initialState: SettingsState = {
barWidth: 2,
type: PlayerbarSliderType.SLIDER,
},
playlistTarget: PlaylistTarget.TRACK,
resume: true,
showLyricsInSidebar: true,
showRatings: true,
@@ -1175,6 +1187,83 @@ const initialState: SettingsState = {
size: 'default',
},
},
[ItemListKey.PLAYLIST_ALBUM]: {
detail: {
columns: pickTableColumns({
autoSizeColumns: [],
columns: SONG_TABLE_COLUMNS,
columnWidths: {
[TableColumn.ACTIONS]: 60,
[TableColumn.DURATION]: 100,
[TableColumn.TITLE]: 400,
[TableColumn.TRACK_NUMBER]: 50,
[TableColumn.USER_FAVORITE]: 60,
},
enabledColumns: [
TableColumn.TRACK_NUMBER,
TableColumn.TITLE,
TableColumn.DURATION,
TableColumn.USER_FAVORITE,
TableColumn.ACTIONS,
],
}),
enableAlternateRowColors: false,
enableHeader: true,
enableHorizontalBorders: false,
enableRowHoverHighlight: true,
enableVerticalBorders: false,
size: 'compact',
},
display: ListDisplayType.GRID,
grid: {
itemGap: 'sm',
itemsPerRow: 6,
itemsPerRowEnabled: false,
rows: pickGridRows({
alignLeftColumns: [
TableColumn.TITLE,
TableColumn.ALBUM_ARTIST,
TableColumn.YEAR,
],
columns: ALBUM_TABLE_COLUMNS,
enabledColumns: [TableColumn.TITLE, TableColumn.ALBUM_ARTIST, TableColumn.YEAR],
pickColumns: [
TableColumn.TITLE,
TableColumn.DURATION,
TableColumn.ALBUM_ARTIST,
TableColumn.BIT_RATE,
TableColumn.BPM,
TableColumn.DATE_ADDED,
TableColumn.GENRE,
TableColumn.PLAY_COUNT,
TableColumn.SONG_COUNT,
TableColumn.RELEASE_DATE,
TableColumn.LAST_PLAYED,
TableColumn.YEAR,
],
}),
size: 'default',
},
itemsPerPage: 100,
pagination: ListPaginationType.INFINITE,
table: {
autoFitColumns: true,
columns: ALBUM_TABLE_COLUMNS.map((column) => ({
align: column.align,
autoSize: column.autoSize,
id: column.value,
isEnabled: column.isEnabled,
pinned: column.pinned,
width: column.width,
})),
enableAlternateRowColors: false,
enableHeader: true,
enableHorizontalBorders: false,
enableRowHoverHighlight: true,
enableVerticalBorders: false,
size: 'default',
},
},
[LibraryItem.ALBUM]: {
detail: {
columns: pickTableColumns({
@@ -1808,6 +1897,11 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
state.playback.filters = filters;
});
},
setPlaylistBehavior: (target: PlaylistTarget) => {
set((state) => {
state.general.playlistTarget = target;
});
},
setSettings: (data) => {
set((state) => {
deepMergeIntoState(state, data);
@@ -2218,6 +2312,9 @@ export const usePlayerbarSlider = () =>
export const useGenreTarget = () => useSettingsStore((store) => store.general.genreTarget, shallow);
export const usePlaylistTarget = () =>
useSettingsStore((store) => store.general.playlistTarget, shallow);
export const useLanguage = () => useSettingsStore((state) => state.general.language, shallow);
export const useAccent = () => useSettingsStore((state) => state.general.accent, shallow);
+82 -17
View File
@@ -151,7 +151,7 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor
results = orderBy(
results,
[(v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, 'asc', 'asc'],
[order, order, order],
);
break;
@@ -159,7 +159,7 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor
results = orderBy(
results,
[(v) => v.albumArtists[0]?.name.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, 'asc', 'asc'],
[order, order, order, order],
);
break;
@@ -167,32 +167,54 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor
results = orderBy(
results,
[(v) => v.artistName?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, 'asc', 'asc'],
[order, order, order, order],
);
break;
case SongListSort.BPM:
results = orderBy(results, ['bpm'], [order]);
results = orderBy(
results,
['bpm', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, order, order],
);
break;
case SongListSort.CHANNELS:
results = orderBy(results, ['channels'], [order]);
results = orderBy(
results,
['channels', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, order, order],
);
break;
case SongListSort.COMMENT:
results = orderBy(
results,
['comment', 'discNumber', 'trackNumber'],
[order, order, 'asc', 'asc'],
['comment', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, order, order],
);
break;
case SongListSort.DURATION:
results = orderBy(results, ['duration'], [order]);
results = orderBy(
results,
['duration', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, order, order],
);
break;
case SongListSort.FAVORITED:
results = orderBy(results, ['userFavorite', (v) => v.name.toLowerCase()], [order]);
results = orderBy(
results,
[
'userFavorite',
(v) => v.name.toLowerCase(),
(v) => v.album?.toLowerCase(),
'discNumber',
'trackNumber',
],
[order, order, order, order, order],
);
break;
case SongListSort.GENRE:
@@ -204,7 +226,7 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor
'discNumber',
'trackNumber',
],
[order, order, 'asc', 'asc'],
[order, order, order, order],
);
break;
@@ -217,11 +239,19 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor
break;
case SongListSort.NAME:
results = orderBy(results, [(v) => v.name.toLowerCase()], [order]);
results = orderBy(
results,
[(v) => v.name.toLowerCase(), (v) => v.album?.toLowerCase()],
[order, order],
);
break;
case SongListSort.PLAY_COUNT:
results = orderBy(results, ['playCount'], [order]);
results = orderBy(
results,
['playCount', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, order, order],
);
break;
case SongListSort.RANDOM:
@@ -229,19 +259,51 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor
break;
case SongListSort.RATING:
results = orderBy(results, ['userRating', (v) => v.name.toLowerCase()], [order]);
results = orderBy(
results,
[
'userRating',
(v) => v.name.toLowerCase(),
(v) => v.album?.toLowerCase(),
'discNumber',
'trackNumber',
],
[order, order, order, order, order],
);
break;
case SongListSort.RECENTLY_ADDED:
results = orderBy(results, ['createdAt'], [order]);
results = orderBy(
results,
[
(v) => {
const x = v.createdAt;
if (x == null) return null;
const d = new Date(x);
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
},
(v) => v.album?.toLowerCase(),
'discNumber',
'trackNumber',
],
[order, order, order, order],
);
break;
case SongListSort.RECENTLY_PLAYED:
results = orderBy(results, ['lastPlayedAt'], [order]);
results = orderBy(
results,
['lastPlayedAt', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, order, order],
);
break;
case SongListSort.RELEASE_DATE:
results = orderBy(results, ['releaseDate'], [order]);
results = orderBy(
results,
['releaseDate', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, order, order],
);
break;
case SongListSort.SORT_NAME:
@@ -252,7 +314,7 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor
results = orderBy(
results,
['releaseYear', (v) => v.album?.toLowerCase(), 'discNumber', 'track'],
[order, 'asc', 'asc', 'asc'],
[order, order, order, order],
);
break;
@@ -404,6 +466,9 @@ export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder:
case AlbumListSort.FAVORITED:
results = orderBy(results, ['starred'], [order]);
break;
case AlbumListSort.ID:
results = sortOrder === SortOrder.DESC ? [...results].reverse() : results;
break;
case AlbumListSort.NAME:
results = orderBy(results, [(v) => v.name.toLowerCase()], [order]);
break;
@@ -39,3 +39,7 @@
.muted {
opacity: 0.85;
}
.with-space {
padding-right: var(--theme-spacing-sm);
}
@@ -32,10 +32,12 @@ export const ExplicitIndicator = ({
return (
<span
aria-label={explicitStatus === ExplicitStatus.EXPLICIT ? 'Explicit' : 'Clean'}
className={clsx(styles.root, styles[`size-${size}`], className)}
className={clsx(styles.root, styles[`size-${size}`], className, {
[styles.withSpace]: withSpace,
})}
{...rest}
>
{withSpace ? `${symbol} ` : symbol}
{symbol}
</span>
);
};
+4
View File
@@ -21,6 +21,7 @@ import {
LuArrowUpNarrowWide,
LuArrowUpToLine,
LuBookOpen,
LuBraces,
LuCheck,
LuChevronDown,
LuChevronLast,
@@ -117,6 +118,7 @@ import {
LuVolumeX,
LuWifi,
LuWifiOff,
LuWrench,
LuX,
} from 'react-icons/lu';
import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';
@@ -187,6 +189,7 @@ export const AppIcon = {
info: LuInfo,
itemAlbum: LuDisc3,
itemSong: LuMusic,
json: LuBraces,
keyboard: LuKeyboard,
lastPlayed: LuHeadphones,
layoutDetail: LuLayoutList,
@@ -227,6 +230,7 @@ export const AppIcon = {
playlistAdd: LuListPlus,
playlistDelete: LuListMinus,
plus: LuPlus,
queryBuilder: LuWrench,
queue: LuList,
radio: LuRadio,
refresh: LuRotateCw,
+4
View File
@@ -466,6 +466,7 @@ export enum AlbumListSort {
DURATION = 'duration',
EXPLICIT_STATUS = 'explicitStatus',
FAVORITED = 'favorited',
ID = 'id',
NAME = 'name',
PLAY_COUNT = 'playCount',
RANDOM = 'random',
@@ -521,6 +522,7 @@ export const albumListSortMap: AlbumListSortMap = {
duration: undefined,
explicitStatus: undefined,
favorited: undefined,
id: undefined,
name: JFAlbumListSort.NAME,
playCount: JFAlbumListSort.PLAY_COUNT,
random: JFAlbumListSort.RANDOM,
@@ -540,6 +542,7 @@ export const albumListSortMap: AlbumListSortMap = {
duration: NDAlbumListSort.DURATION,
explicitStatus: NDAlbumListSort.EXPLICIT_STATUS,
favorited: NDAlbumListSort.STARRED,
id: undefined,
name: NDAlbumListSort.NAME,
playCount: NDAlbumListSort.PLAY_COUNT,
random: NDAlbumListSort.RANDOM,
@@ -560,6 +563,7 @@ export const albumListSortMap: AlbumListSortMap = {
duration: undefined,
explicitStatus: undefined,
favorited: undefined,
id: undefined,
name: undefined,
playCount: undefined,
random: undefined,
+1
View File
@@ -26,6 +26,7 @@ export enum ItemListKey {
GENRE_ALBUM = 'genreAlbum',
GENRE_SONG = 'genreSong',
PLAYLIST = LibraryItem.PLAYLIST,
PLAYLIST_ALBUM = 'playlistAlbum',
PLAYLIST_SONG = LibraryItem.PLAYLIST_SONG,
QUEUE_SONG = LibraryItem.QUEUE_SONG,
RADIO = 'radio',
+21 -1
View File
@@ -24,7 +24,24 @@ export default defineConfig({
),
},
output: {
assetFileNames: 'assets/[name].[ext]',
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]';
},
sourcemapExcludeSources: false,
},
},
@@ -114,7 +131,10 @@ export default defineConfig({
registerType: 'autoUpdate',
scope: '/assets/',
workbox: {
cleanupOutdatedCaches: true,
clientsClaim: true,
maximumFileSizeToCacheInBytes: 1000000 * 5, // 5 MB
skipWaiting: true,
},
}),
],