mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6b77e5883 | |||
| d54baae3d9 | |||
| 78875572e9 | |||
| f487560ec5 | |||
| f752090c78 | |||
| 96f5b2b82a | |||
| 80292ae579 | |||
| 1d156ac506 | |||
| dc5586f859 | |||
| 1a9f36ce9e | |||
| 203c8a6588 | |||
| 2e6cf8d869 | |||
| b1827dd352 | |||
| 0d2dddddbc | |||
| 1d8e1957ba | |||
| dc957cb3cc | |||
| c314fa0bf3 | |||
| c5ebfac647 | |||
| 4adea11a93 | |||
| e6f49b9f1f | |||
| 9cde569c7d | |||
| 91e7c7434c | |||
| ffef5dfdee | |||
| 409dd69fcb | |||
| 064cf5103a | |||
| 7e3a613a93 | |||
| e7c49f6d67 | |||
| 022b83ab32 | |||
| 551d705ee1 | |||
| 83f73c7fa9 | |||
| cc8cb4f4f1 | |||
| 496eab7d09 | |||
| 5197c967c2 | |||
| 74b615dba7 | |||
| b67ee797cb |
@@ -155,17 +155,6 @@ jobs:
|
||||
pnpm run publish:win:alpha
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish to R2 (Windows ARM64)
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run publish:win-arm64:alpha
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish to R2 (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
|
||||
@@ -155,19 +155,6 @@ jobs:
|
||||
pnpm run publish:win:beta
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish releases (Windows ARM64)
|
||||
if: matrix.os == 'windows-latest'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run publish:win-arm64:beta
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish releases (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
|
||||
@@ -50,16 +50,6 @@ jobs:
|
||||
command: |
|
||||
pnpm run package:win:pr
|
||||
|
||||
- name: Build for Windows (ARM64)
|
||||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run package:win-arm64:pr
|
||||
|
||||
- name: Build for Linux
|
||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
|
||||
@@ -33,15 +33,3 @@ jobs:
|
||||
command: |
|
||||
pnpm run publish:win
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish releases (ARM64)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run publish:win-arm64
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
@@ -35,19 +35,6 @@ jobs:
|
||||
pnpm run publish:win
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish releases (Windows ARM64)
|
||||
if: matrix.os == 'windows-latest'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
pnpm run publish:win-arm64
|
||||
on_retry_command: pnpm cache delete
|
||||
|
||||
- name: Build and Publish releases (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
|
||||
Binary file not shown.
@@ -13,9 +13,15 @@ asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
target:
|
||||
- zip
|
||||
- nsis
|
||||
icon: assets/icons/icon.png
|
||||
- target: zip
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
- target: nsis
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
icon: assets/icons/icon.ico
|
||||
|
||||
nsis:
|
||||
allowToChangeInstallationDirectory: true
|
||||
|
||||
@@ -13,9 +13,15 @@ asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
target:
|
||||
- zip
|
||||
- nsis
|
||||
icon: assets/icons/icon.png
|
||||
- target: zip
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
- target: nsis
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
icon: assets/icons/icon.ico
|
||||
|
||||
nsis:
|
||||
allowToChangeInstallationDirectory: true
|
||||
|
||||
@@ -13,8 +13,14 @@ asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
target:
|
||||
- zip
|
||||
- nsis
|
||||
- target: zip
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
- target: nsis
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
icon: assets/icons/icon.ico
|
||||
|
||||
nsis:
|
||||
|
||||
@@ -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": {
|
||||
@@ -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,14 @@
|
||||
"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"
|
||||
},
|
||||
"table": {
|
||||
"column": {
|
||||
@@ -983,7 +999,8 @@
|
||||
"view": {
|
||||
"table": "taula",
|
||||
"grid": "quadrícula",
|
||||
"list": "llista"
|
||||
"list": "llista",
|
||||
"detail": "detall"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1072,7 +1089,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",
|
||||
|
||||
@@ -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í",
|
||||
|
||||
Regular → Executable
+13
-2
@@ -667,7 +667,16 @@
|
||||
"trackRadio": "track radio",
|
||||
"unfavorite": "unfavorite",
|
||||
"pause": "pause",
|
||||
"viewQueue": "view queue"
|
||||
"viewQueue": "view queue",
|
||||
"sleepTimer": "sleep timer",
|
||||
"sleepTimer_endOfSong": "end of current song",
|
||||
"sleepTimer_minutes": "{{count}} min",
|
||||
"sleepTimer_hours": "{{count}} hr",
|
||||
"sleepTimer_custom": "custom",
|
||||
"sleepTimer_off": "off",
|
||||
"sleepTimer_timeRemaining": "{{time}} remaining",
|
||||
"sleepTimer_setCustom": "set timer",
|
||||
"sleepTimer_cancel": "cancel timer"
|
||||
},
|
||||
"queryBuilder": {
|
||||
"standardTags": "standard tags",
|
||||
@@ -725,7 +734,7 @@
|
||||
"artistConfiguration_description": "configure what items are shown, and in what order, on the album artist page",
|
||||
"artistReleaseTypeConfiguration": "artist release type configuration",
|
||||
"artistReleaseTypeConfiguration_description": "configure what release types are shown, and in what order, on the album artist page",
|
||||
"audioDevice_description": "select the audio device to use for playback (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",
|
||||
@@ -779,6 +788,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",
|
||||
|
||||
+37
-21
@@ -32,13 +32,22 @@
|
||||
"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",
|
||||
@@ -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,10 +638,10 @@
|
||||
"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"
|
||||
},
|
||||
@@ -865,8 +880,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 +979,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 +1044,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 +1068,8 @@
|
||||
"view": {
|
||||
"table": "tabla",
|
||||
"list": "Lista",
|
||||
"grid": "Cuadrícula"
|
||||
"grid": "Cuadrícula",
|
||||
"detail": "Detalle"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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": "отримати інформацію"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,7 +463,7 @@
|
||||
"releaseChannel_optionLatest": "最新的",
|
||||
"releaseChannel_optionBeta": "测试版",
|
||||
"releaseChannel": "发布通道",
|
||||
"releaseChannel_description": "选择稳定版本或测试版以进行自动更新",
|
||||
"releaseChannel_description": "选择稳定版、测试版或 Alpha(夜间构建版)以启用自动更新。",
|
||||
"mediaSession": "启用媒体会话",
|
||||
"mediaSession_description": "启用媒体会话集成,在系统音量叠加层和锁屏界面显示媒体控件和元数据",
|
||||
"exportImportSettings_control_description": "通过 JSON 导出和导入设置",
|
||||
|
||||
@@ -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": "選擇用於播放的音訊播放器",
|
||||
@@ -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": {
|
||||
|
||||
+117
-19
@@ -1,3 +1,5 @@
|
||||
import type { UpdateCheckResult } from 'electron-updater';
|
||||
|
||||
import { is } from '@electron-toolkit/utils';
|
||||
import {
|
||||
app,
|
||||
@@ -21,6 +23,7 @@ import log from 'electron-log/main';
|
||||
import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';
|
||||
import { access, constants } from 'fs';
|
||||
import path, { join } from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import packageJson from '../../package.json';
|
||||
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
|
||||
@@ -52,29 +55,25 @@ const ALPHA_UPDATER_CONFIG: {
|
||||
provider: 's3',
|
||||
};
|
||||
|
||||
type UpdaterInstance = AppImageUpdater | MacUpdater | NsisUpdater | typeof autoUpdater;
|
||||
const GITHUB_UPDATER_CONFIG = {
|
||||
owner: 'jeffvli',
|
||||
provider: 'github' as const,
|
||||
repo: 'feishin',
|
||||
};
|
||||
|
||||
class AlphaAppUpdater {
|
||||
constructor() {
|
||||
const updater = createAlphaUpdaterInstance();
|
||||
log.transports.file.level = 'info';
|
||||
updater.logger = autoUpdaterLogInterface;
|
||||
updater.channel = ALPHA_UPDATER_CONFIG.channel;
|
||||
updater.allowPrerelease = true;
|
||||
updater.disableDifferentialDownload = true;
|
||||
updater.allowDowngrade = true;
|
||||
updater.autoInstallOnAppQuit = true;
|
||||
updater.autoRunAppAfterInstall = true;
|
||||
updater.checkForUpdatesAndNotify();
|
||||
}
|
||||
}
|
||||
type UpdaterInstance = AppImageUpdater | MacUpdater | NsisUpdater | typeof autoUpdater;
|
||||
|
||||
class AppUpdater {
|
||||
constructor() {
|
||||
const effectiveChannel = store.get('release_channel') as string;
|
||||
console.log('Effective update channel:', effectiveChannel);
|
||||
if (effectiveChannel === 'alpha') {
|
||||
return new AlphaAppUpdater();
|
||||
checkAllChannelsAndGetBest().then(({ updater: updaterInstance }) => {
|
||||
updaterInstance.autoInstallOnAppQuit = true;
|
||||
updaterInstance.autoRunAppAfterInstall = true;
|
||||
updaterInstance.checkForUpdatesAndNotify();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
configureAndGetUpdater();
|
||||
@@ -82,6 +81,74 @@ class AppUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
// When release channel is alpha, check alpha and latest for updates and return
|
||||
// the updater + result for the newest version found (so alpha users can receive
|
||||
// latest updates when they are newer than the current alpha).
|
||||
async function checkAllChannelsAndGetBest(): Promise<{
|
||||
result: null | UpdateCheckResult;
|
||||
updater: UpdaterInstance;
|
||||
}> {
|
||||
const currentVersion = packageJson.version;
|
||||
const candidates: Array<{
|
||||
channel: 'alpha' | 'beta' | 'latest';
|
||||
result: UpdateCheckResult;
|
||||
updater: UpdaterInstance;
|
||||
}> = [];
|
||||
|
||||
const alphaUpdater = createAlphaUpdaterInstance();
|
||||
alphaUpdater.logger = autoUpdaterLogInterface;
|
||||
alphaUpdater.channel = ALPHA_UPDATER_CONFIG.channel;
|
||||
alphaUpdater.allowPrerelease = true;
|
||||
alphaUpdater.disableDifferentialDownload = true;
|
||||
alphaUpdater.allowDowngrade = true;
|
||||
|
||||
try {
|
||||
console.log('Checking for updates on alpha channel');
|
||||
const alphaResult = await alphaUpdater.checkForUpdates();
|
||||
if (
|
||||
alphaResult?.updateInfo?.version &&
|
||||
alphaResult.isUpdateAvailable &&
|
||||
semver.valid(alphaResult.updateInfo.version) &&
|
||||
semver.gt(alphaResult.updateInfo.version, currentVersion)
|
||||
) {
|
||||
candidates.push({ channel: 'alpha', result: alphaResult, updater: alphaUpdater });
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('Alpha channel check failed', e);
|
||||
}
|
||||
|
||||
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 &&
|
||||
latestResult.isUpdateAvailable &&
|
||||
semver.valid(latestResult.updateInfo.version) &&
|
||||
semver.gt(latestResult.updateInfo.version, currentVersion)
|
||||
) {
|
||||
candidates.push({ channel: 'latest', result: latestResult, updater: autoUpdater });
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('Latest channel check failed', e);
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { result: null, updater: alphaUpdater };
|
||||
}
|
||||
|
||||
const best = candidates.reduce((a, b) =>
|
||||
semver.gt(a.result.updateInfo.version, b.result.updateInfo.version) ? a : b,
|
||||
);
|
||||
|
||||
if (best.channel === 'latest') {
|
||||
configureAutoUpdaterForChannel('latest');
|
||||
}
|
||||
|
||||
return { result: best.result, updater: best.updater };
|
||||
}
|
||||
|
||||
function configureAndGetUpdater(): UpdaterInstance {
|
||||
const isBetaVersion = packageJson.version.includes('-beta');
|
||||
const isAlphaVersion = packageJson.version.includes('-alpha');
|
||||
@@ -122,17 +189,37 @@ function configureAndGetUpdater(): UpdaterInstance {
|
||||
|
||||
if (effectiveChannel === 'beta') {
|
||||
autoUpdater.channel = 'beta';
|
||||
autoUpdater.allowDowngrade = true;
|
||||
autoUpdater.allowPrerelease = true;
|
||||
autoUpdater.disableDifferentialDownload = true;
|
||||
} else {
|
||||
autoUpdater.channel = 'latest';
|
||||
autoUpdater.allowDowngrade = true;
|
||||
autoUpdater.allowPrerelease = false;
|
||||
}
|
||||
|
||||
return autoUpdater;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the global autoUpdater for a specific GitHub channel (beta or latest).
|
||||
* Used when checking multiple channels or when the winning channel is beta/latest.
|
||||
*/
|
||||
function configureAutoUpdaterForChannel(channel: 'beta' | 'latest'): void {
|
||||
log.transports.file.level = 'info';
|
||||
autoUpdater.logger = autoUpdaterLogInterface;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
autoUpdater.autoRunAppAfterInstall = true;
|
||||
if (channel === 'beta') {
|
||||
autoUpdater.channel = 'beta';
|
||||
autoUpdater.allowDowngrade = true;
|
||||
autoUpdater.allowPrerelease = true;
|
||||
autoUpdater.disableDifferentialDownload = true;
|
||||
} else {
|
||||
autoUpdater.channel = 'latest';
|
||||
autoUpdater.allowPrerelease = false;
|
||||
}
|
||||
}
|
||||
|
||||
function createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdater {
|
||||
if (isMacOS()) {
|
||||
return new MacUpdater(ALPHA_UPDATER_CONFIG);
|
||||
@@ -440,8 +527,19 @@ async function createWindow(first = true): Promise<void> {
|
||||
|
||||
try {
|
||||
console.log('Checking for updates');
|
||||
const updater = configureAndGetUpdater();
|
||||
const result = await updater.checkForUpdates();
|
||||
const effectiveChannel = store.get('release_channel') as string;
|
||||
let result: null | UpdateCheckResult;
|
||||
let updater: UpdaterInstance;
|
||||
|
||||
if (effectiveChannel === 'alpha') {
|
||||
const best = await checkAllChannelsAndGetBest();
|
||||
result = best.result;
|
||||
updater = best.updater;
|
||||
} else {
|
||||
updater = configureAndGetUpdater();
|
||||
result = await updater.checkForUpdates();
|
||||
}
|
||||
|
||||
const updateAvailable = result?.isUpdateAvailable ?? false;
|
||||
console.log('Update available:', updateAvailable);
|
||||
if (updateAvailable && store.get('disable_auto_updates') !== true) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -244,8 +244,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 ||
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
|
||||
.tracks-table-header {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
@@ -80,12 +81,14 @@
|
||||
.track-header-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 60%;
|
||||
padding-right: var(--theme-spacing-sm);
|
||||
padding-left: var(--theme-spacing-sm);
|
||||
overflow: visible;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-header-cell-no-h-padding {
|
||||
@@ -193,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}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { TableColumn } from '/@/shared/types/types';
|
||||
|
||||
const FIXED_TRACK_COLUMN_WIDTHS: Partial<Record<TableColumn, number>> = {
|
||||
[TableColumn.ACTIONS]: 32,
|
||||
[TableColumn.BIT_DEPTH]: 80,
|
||||
[TableColumn.BIT_RATE]: 80,
|
||||
[TableColumn.BIT_DEPTH]: 88,
|
||||
[TableColumn.BIT_RATE]: 88,
|
||||
[TableColumn.BPM]: 56,
|
||||
[TableColumn.CHANNELS]: 80,
|
||||
[TableColumn.CODEC]: 80,
|
||||
@@ -11,8 +11,8 @@ const FIXED_TRACK_COLUMN_WIDTHS: Partial<Record<TableColumn, number>> = {
|
||||
[TableColumn.DISC_NUMBER]: 36,
|
||||
[TableColumn.DURATION]: 72,
|
||||
[TableColumn.RELEASE_DATE]: 128,
|
||||
[TableColumn.SAMPLE_RATE]: 90,
|
||||
[TableColumn.TRACK_NUMBER]: 56,
|
||||
[TableColumn.SAMPLE_RATE]: 112,
|
||||
[TableColumn.TRACK_NUMBER]: 64,
|
||||
[TableColumn.USER_FAVORITE]: 32,
|
||||
[TableColumn.USER_RATING]: 64,
|
||||
[TableColumn.YEAR]: 56,
|
||||
@@ -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,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;
|
||||
|
||||
@@ -225,6 +225,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 +270,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 +301,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,
|
||||
@@ -459,35 +486,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 +550,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 +574,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,
|
||||
@@ -717,35 +738,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>
|
||||
|
||||
@@ -345,8 +345,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
|
||||
|
||||
openContextModal({
|
||||
innerProps: {
|
||||
itemIds: items,
|
||||
resourceType: itemType,
|
||||
...modalProps,
|
||||
},
|
||||
modalKey: 'addToPlaylist',
|
||||
size: 'lg',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DiscordRpcHook } from '/@/renderer/features/discord-rpc/use-discord-rpc
|
||||
import { MainPlayerListenerHook } from '/@/renderer/features/player/audio-player/hooks/use-main-player-listener';
|
||||
import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';
|
||||
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';
|
||||
import { SleepTimerHook } from '/@/renderer/features/player/components/sleep-timer-button';
|
||||
import { AutoDJHook } from '/@/renderer/features/player/hooks/use-auto-dj';
|
||||
import { MediaSessionHook } from '/@/renderer/features/player/hooks/use-media-session';
|
||||
import { MPRISHook } from '/@/renderer/features/player/hooks/use-mpris';
|
||||
@@ -48,6 +49,7 @@ export const AudioPlayers = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SleepTimerHook />
|
||||
<ScrobbleHook />
|
||||
<PowerSaveBlockerHook />
|
||||
<DiscordRpcHook />
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Regular → Executable
+2
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { PopoverPlayQueue } from '/@/renderer/features/now-playing/components/popover-play-queue';
|
||||
import { PlayerConfig } from '/@/renderer/features/player/components/player-config';
|
||||
import { CustomPlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
|
||||
import { SleepTimerButton } from '/@/renderer/features/player/components/sleep-timer-button';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
|
||||
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
||||
@@ -72,6 +73,7 @@ export const RightControls = () => {
|
||||
<AutoDJButton />
|
||||
</Group>
|
||||
<Group align="center" gap="xs" wrap="nowrap">
|
||||
<SleepTimerButton />
|
||||
<PlayerConfig />
|
||||
<LyricsButton />
|
||||
<FavoriteButton />
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { usePlayerStatus, usePlayerStoreBase } from '/@/renderer/store/player.store';
|
||||
import {
|
||||
useSleepTimerActions,
|
||||
useSleepTimerActive,
|
||||
useSleepTimerMode,
|
||||
useSleepTimerRemaining,
|
||||
useSleepTimerStore,
|
||||
} from '/@/renderer/store/sleep-timer.store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Popover } from '/@/shared/components/popover/popover';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
const PRESET_OPTIONS = [
|
||||
{ minutes: 0, mode: 'endOfSong' as const },
|
||||
{ minutes: 5, mode: 'timed' as const },
|
||||
{ minutes: 10, mode: 'timed' as const },
|
||||
{ minutes: 15, mode: 'timed' as const },
|
||||
{ minutes: 30, mode: 'timed' as const },
|
||||
{ minutes: 45, mode: 'timed' as const },
|
||||
{ minutes: 60, mode: 'timed' as const },
|
||||
{ minutes: 120, mode: 'timed' as const },
|
||||
];
|
||||
|
||||
function formatRemaining(totalSeconds: number): string {
|
||||
const h = Math.floor(totalSeconds / 3600);
|
||||
const m = Math.floor((totalSeconds % 3600) / 60);
|
||||
const s = Math.floor(totalSeconds % 60);
|
||||
|
||||
if (h > 0) {
|
||||
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const useSleepTimer = () => {
|
||||
const active = useSleepTimerActive();
|
||||
const mode = useSleepTimerMode();
|
||||
const { cancelTimer, setRemaining } = useSleepTimerActions();
|
||||
const { mediaPause } = usePlayer();
|
||||
|
||||
const mediaPauseRef = useRef(mediaPause);
|
||||
mediaPauseRef.current = mediaPause;
|
||||
|
||||
const handleOnCurrentSongChange = useCallback(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel and pause on song change in end-of-song mode
|
||||
if (mode === 'endOfSong') {
|
||||
cancelTimer();
|
||||
mediaPauseRef.current();
|
||||
}
|
||||
}, [active, mode, cancelTimer, mediaPauseRef]);
|
||||
|
||||
const status = usePlayerStatus();
|
||||
|
||||
const handleOnPlayerProgress = useCallback(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status !== PlayerStatus.PLAYING) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Count down in timed mode
|
||||
if (mode === 'timed') {
|
||||
const remaining = useSleepTimerStore.getState().remaining;
|
||||
|
||||
if (remaining <= 0) {
|
||||
cancelTimer();
|
||||
mediaPauseRef.current();
|
||||
} else {
|
||||
setRemaining(Math.max(0, remaining - 1));
|
||||
}
|
||||
}
|
||||
}, [active, cancelTimer, mode, setRemaining, status]);
|
||||
|
||||
usePlayerEvents(
|
||||
{
|
||||
onCurrentSongChange: handleOnCurrentSongChange,
|
||||
onPlayerProgress: handleOnPlayerProgress,
|
||||
},
|
||||
[handleOnCurrentSongChange, handleOnPlayerProgress],
|
||||
);
|
||||
|
||||
// End-of-song mode: subscribe to player index changes
|
||||
useEffect(() => {
|
||||
if (!active || mode !== 'endOfSong') return;
|
||||
|
||||
const initialIndex = usePlayerStoreBase.getState().player.index;
|
||||
|
||||
const unsub = usePlayerStoreBase.subscribe(
|
||||
(state) => state.player.index,
|
||||
(index) => {
|
||||
if (index !== initialIndex) {
|
||||
cancelTimer();
|
||||
mediaPauseRef.current();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => unsub();
|
||||
}, [active, mode, cancelTimer]);
|
||||
};
|
||||
|
||||
export const SleepTimerHookInner = () => {
|
||||
useSleepTimer();
|
||||
return null;
|
||||
};
|
||||
|
||||
export const SleepTimerHook = () => {
|
||||
const active = useSleepTimerActive();
|
||||
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return React.createElement(SleepTimerHookInner);
|
||||
};
|
||||
|
||||
export const SleepTimerButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const active = useSleepTimerActive();
|
||||
const mode = useSleepTimerMode();
|
||||
const remaining = useSleepTimerRemaining();
|
||||
const { cancelTimer, startEndOfSongTimer, startTimedTimer } = useSleepTimerActions();
|
||||
const { mediaPause } = usePlayer();
|
||||
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const [customHours, setCustomHours] = useState<number>(0);
|
||||
const [customMinutes, setCustomMinutes] = useState<number>(20);
|
||||
const [customSeconds, setCustomSeconds] = useState<number>(0);
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const mediaPauseRef = useRef(mediaPause);
|
||||
mediaPauseRef.current = mediaPause;
|
||||
|
||||
const handlePreset = useCallback(
|
||||
(option: (typeof PRESET_OPTIONS)[number]) => {
|
||||
if (option.mode === 'endOfSong') {
|
||||
startEndOfSongTimer();
|
||||
} else {
|
||||
startTimedTimer(option.minutes * 60);
|
||||
}
|
||||
setShowCustom(false);
|
||||
setOpened(false);
|
||||
},
|
||||
[startEndOfSongTimer, startTimedTimer],
|
||||
);
|
||||
|
||||
const handleCustomStart = useCallback(() => {
|
||||
const totalSeconds = customHours * 3600 + customMinutes * 60 + customSeconds;
|
||||
if (totalSeconds > 0) {
|
||||
startTimedTimer(totalSeconds);
|
||||
setShowCustom(false);
|
||||
setOpened(false);
|
||||
}
|
||||
}, [customHours, customMinutes, customSeconds, startTimedTimer]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
cancelTimer();
|
||||
setShowCustom(false);
|
||||
}, [cancelTimer]);
|
||||
|
||||
const getPresetLabel = (option: (typeof PRESET_OPTIONS)[number]) => {
|
||||
if (option.mode === 'endOfSong') {
|
||||
return t('player.sleepTimer_endOfSong', { postProcess: 'sentenceCase' });
|
||||
}
|
||||
if (option.minutes >= 60) {
|
||||
return t('player.sleepTimer_hours', {
|
||||
count: option.minutes / 60,
|
||||
postProcess: 'sentenceCase',
|
||||
});
|
||||
}
|
||||
return t('player.sleepTimer_minutes', {
|
||||
count: option.minutes,
|
||||
postProcess: 'sentenceCase',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover onChange={setOpened} opened={opened} position="top" width={260}>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
icon={active ? 'sleepTimer' : 'sleepTimerOff'}
|
||||
iconProps={{
|
||||
color: active ? 'primary' : undefined,
|
||||
size: 'lg',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpened((prev) => !prev);
|
||||
}}
|
||||
size="sm"
|
||||
tooltip={{
|
||||
label: t('player.sleepTimer', { postProcess: 'titleCase' }),
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs" p="xs">
|
||||
<Text fw="600" size="sm" ta="center">
|
||||
{t('player.sleepTimer', { postProcess: 'titleCase' })}
|
||||
</Text>
|
||||
|
||||
{active && (
|
||||
<Flex
|
||||
align="center"
|
||||
direction="column"
|
||||
gap={4}
|
||||
mb="xs"
|
||||
style={{
|
||||
background: 'var(--theme-colors-surface)',
|
||||
borderRadius: 'var(--theme-radius-md)',
|
||||
padding: 'var(--theme-spacing-sm) var(--theme-spacing-md)',
|
||||
}}
|
||||
>
|
||||
{mode === 'endOfSong' ? (
|
||||
<Text c="primary" size="sm">
|
||||
{t('player.sleepTimer_endOfSong', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Text>
|
||||
) : (
|
||||
<Text c="primary" fw="600" size="lg">
|
||||
{formatRemaining(remaining)}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancel();
|
||||
}}
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
>
|
||||
{t('player.sleepTimer_cancel', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{PRESET_OPTIONS.map((option, index) => (
|
||||
<Button
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
key={index}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreset(option);
|
||||
}}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
>
|
||||
{getPresetLabel(option)}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{!showCustom ? (
|
||||
<Button
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowCustom(true);
|
||||
}}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
>
|
||||
{t('player.sleepTimer_custom', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<NumberInput
|
||||
max={23}
|
||||
min={0}
|
||||
onChange={(val) => setCustomHours(Number(val) || 0)}
|
||||
placeholder="hr"
|
||||
size="xs"
|
||||
value={customHours}
|
||||
/>
|
||||
<Text>:</Text>
|
||||
<NumberInput
|
||||
max={59}
|
||||
min={0}
|
||||
onChange={(val) => setCustomMinutes(Number(val) || 0)}
|
||||
placeholder="min"
|
||||
size="xs"
|
||||
value={customMinutes}
|
||||
/>
|
||||
<Text>:</Text>
|
||||
<NumberInput
|
||||
max={59}
|
||||
min={0}
|
||||
onChange={(val) => setCustomSeconds(Number(val) || 0)}
|
||||
placeholder="sec"
|
||||
size="xs"
|
||||
value={customSeconds}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs" grow>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCustomStart();
|
||||
}}
|
||||
size="xs"
|
||||
variant="filled"
|
||||
>
|
||||
{t('player.sleepTimer_setCustom', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowCustom(false);
|
||||
}}
|
||||
size="xs"
|
||||
variant="default"
|
||||
>
|
||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
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 { 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 } 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 sortedAlbums = useMemo(() => {
|
||||
let songs = data?.items ?? [];
|
||||
if (searchTerm?.trim()) {
|
||||
songs = searchLibraryItems(songs, searchTerm, LibraryItem.SONG);
|
||||
}
|
||||
const sortedSongs = sortSongList(
|
||||
songs,
|
||||
(query.sortBy as SongListSort) ?? SongListSort.ID,
|
||||
(query.sortOrder as SortOrder) ?? SortOrder.ASC,
|
||||
);
|
||||
return playlistSongsToAlbums(sortedSongs);
|
||||
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
|
||||
|
||||
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 {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}, [player]);
|
||||
|
||||
useEffect(() => {
|
||||
setItemCount?.(totalAlbumCount);
|
||||
}, [setItemCount, totalAlbumCount]);
|
||||
|
||||
useEffect(() => {
|
||||
setListData?.(data?.items ?? []);
|
||||
}, [data?.items, 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 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={table.columns}
|
||||
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,36 @@ export const PlaylistDetailSongListContent = () => {
|
||||
|
||||
export type OverridePlaylistSongListQuery = Omit<Partial<PlaylistSongListQuery>, 'id'>;
|
||||
|
||||
export const PlaylistDetailSongListView = ({ data }: { data: PlaylistSongListResponse }) => {
|
||||
interface PlaylistDetailSongListViewProps {
|
||||
data: PlaylistSongListResponse;
|
||||
/** When provided, table/grid use this instead of computing from data (avoids duplicate filter/sort). */
|
||||
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 +140,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 +283,33 @@ export const PlaylistDetailSongListEdit = ({ data }: { data: PlaylistSongListRes
|
||||
}
|
||||
};
|
||||
|
||||
const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => {
|
||||
/** Track view: view mode uses centralized list derivation; edit mode uses local reorder state. */
|
||||
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} />;
|
||||
};
|
||||
|
||||
/** Uses usePlaylistTrackList once and passes derived items to the list view. */
|
||||
const PlaylistDetailTrackViewContent = ({ data }: { data: PlaylistSongListResponse }) => {
|
||||
const { sortedAndFilteredSongs } = usePlaylistTrackList(data);
|
||||
return <PlaylistDetailSongListView data={data} items={sortedAndFilteredSongs} />;
|
||||
};
|
||||
|
||||
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;
|
||||
},
|
||||
);
|
||||
|
||||
+59
-12
@@ -5,19 +5,27 @@ 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 { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';
|
||||
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 { 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';
|
||||
@@ -37,8 +45,10 @@ 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 +65,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 +94,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 +114,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
disabled={isEditMode}
|
||||
listKey={ItemListKey.PLAYLIST_SONG}
|
||||
/>
|
||||
{!collapsed && <ListSearchInput />}
|
||||
<ListRefreshButton disabled={isEditMode} listKey={ItemListKey.PLAYLIST_SONG} />
|
||||
<ListRefreshButton disabled={isEditMode} listKey={listKey} />
|
||||
<MoreButton onClick={handleMore} />
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
@@ -109,11 +142,25 @@ 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
|
||||
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,36 @@
|
||||
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 { searchLibraryItems } from '/@/renderer/features/shared/utils';
|
||||
import { sortSongList } from '/@/shared/api/utils';
|
||||
import { LibraryItem, PlaylistSongListResponse, Song } from '/@/shared/types/domain-types';
|
||||
|
||||
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 ?? [];
|
||||
|
||||
if (searchTerm) {
|
||||
return searchLibraryItems(raw, searchTerm, LibraryItem.SONG);
|
||||
}
|
||||
|
||||
return sortSongList(raw, query.sortBy, query.sortOrder);
|
||||
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
|
||||
|
||||
const totalCount = sortedAndFilteredSongs.length;
|
||||
|
||||
useEffect(() => {
|
||||
setListData?.(sortedAndFilteredSongs);
|
||||
setItemCount?.(totalCount);
|
||||
}, [sortedAndFilteredSongs, totalCount, setListData, setItemCount]);
|
||||
|
||||
return { sortedAndFilteredSongs, totalCount };
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { AnimatedPage } from '/@/renderer/features/shared/components/animated-pa
|
||||
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
|
||||
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { PlaylistTarget, useCurrentServer, usePlaylistTarget } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
@@ -29,7 +29,7 @@ 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, SongListSort } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
interface PlaylistQueryEditorProps {
|
||||
@@ -154,14 +154,17 @@ const PlaylistQueryEditor = ({
|
||||
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
|
||||
|
||||
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">
|
||||
<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 ? 'arrowUpS' : 'arrowDownS'}
|
||||
icon={isQueryBuilderExpanded ? 'arrowDownS' : 'arrowUpS'}
|
||||
size="lg"
|
||||
/>
|
||||
}
|
||||
@@ -396,6 +399,12 @@ 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');
|
||||
@@ -403,17 +412,19 @@ const PlaylistDetailSongListRoute = () => {
|
||||
const providerValue = useMemo(() => {
|
||||
return {
|
||||
customFilters: undefined,
|
||||
displayMode,
|
||||
id: playlistId,
|
||||
isSmartPlaylist,
|
||||
itemCount,
|
||||
listData,
|
||||
listKey,
|
||||
mode,
|
||||
pageKey: ItemListKey.PLAYLIST_SONG,
|
||||
pageKey: listKey,
|
||||
setItemCount,
|
||||
setListData,
|
||||
setMode,
|
||||
};
|
||||
}, [playlistId, isSmartPlaylist, itemCount, listData, mode]);
|
||||
}, [playlistId, isSmartPlaylist, displayMode, listKey, itemCount, listData, mode]);
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
|
||||
@@ -429,6 +440,10 @@ const PlaylistDetailSongListRoute = () => {
|
||||
onDelete={() => openDeletePlaylistModal()}
|
||||
onToggleQueryBuilder={handleToggleShowQueryBuilder}
|
||||
/>
|
||||
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<PlaylistDetailSongListContent />
|
||||
</Suspense>
|
||||
{(isSmartPlaylist || showQueryBuilder) && (
|
||||
<PlaylistQueryEditor
|
||||
createPlaylistMutation={createPlaylistMutation}
|
||||
@@ -441,9 +456,6 @@ const PlaylistDetailSongListRoute = () => {
|
||||
queryBuilderRef={queryBuilderRef}
|
||||
/>
|
||||
)}
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<PlaylistDetailSongListContent />
|
||||
</Suspense>
|
||||
</ListContext.Provider>
|
||||
</AnimatedPage>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -109,9 +109,7 @@ export const ThemeSettings = memo(() => {
|
||||
localSettings.themeSet(
|
||||
e.currentTarget.checked
|
||||
? 'system'
|
||||
: settings.theme === AppTheme.DEFAULT_DARK
|
||||
? 'dark'
|
||||
: 'light',
|
||||
: (getAppTheme(settings.theme).mode ?? 'dark'),
|
||||
);
|
||||
}
|
||||
}}
|
||||
@@ -138,7 +136,7 @@ export const ThemeSettings = memo(() => {
|
||||
},
|
||||
});
|
||||
|
||||
const colorScheme = theme === AppTheme.DEFAULT_DARK ? 'dark' : 'light';
|
||||
const colorScheme = getAppTheme(theme).mode ?? 'dark';
|
||||
|
||||
setColorScheme(colorScheme);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -124,23 +124,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 +157,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 +171,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 +187,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 +206,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" />
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { createWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
export type SleepTimerMode = 'endOfSong' | 'timed';
|
||||
|
||||
interface SleepTimerActions {
|
||||
cancelTimer: () => void;
|
||||
setRemaining: (remaining: number) => void;
|
||||
startEndOfSongTimer: () => void;
|
||||
startTimedTimer: (durationSeconds: number) => void;
|
||||
}
|
||||
|
||||
interface SleepTimerState {
|
||||
/** Whether the timer is currently active */
|
||||
active: boolean;
|
||||
/** The mode of the timer */
|
||||
mode: SleepTimerMode;
|
||||
/** Remaining seconds (only ticks while playing) */
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & SleepTimerState>()(
|
||||
(set) => ({
|
||||
active: false,
|
||||
cancelTimer: () => {
|
||||
set({
|
||||
active: false,
|
||||
mode: 'timed',
|
||||
remaining: 0,
|
||||
});
|
||||
},
|
||||
mode: 'timed',
|
||||
remaining: 0,
|
||||
|
||||
setRemaining: (remaining: number) => {
|
||||
set({ remaining });
|
||||
},
|
||||
|
||||
startEndOfSongTimer: () => {
|
||||
set({
|
||||
active: true,
|
||||
mode: 'endOfSong',
|
||||
remaining: 0,
|
||||
});
|
||||
},
|
||||
|
||||
startTimedTimer: (durationSeconds: number) => {
|
||||
set({
|
||||
active: true,
|
||||
mode: 'timed',
|
||||
remaining: durationSeconds,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Selectors
|
||||
export const useSleepTimerActive = () => useSleepTimerStore((s) => s.active);
|
||||
export const useSleepTimerMode = () => useSleepTimerStore((s) => s.mode);
|
||||
export const useSleepTimerRemaining = () => useSleepTimerStore((s) => s.remaining);
|
||||
export const useSleepTimerActions = () =>
|
||||
useSleepTimerStore(
|
||||
useShallow((s) => ({
|
||||
cancelTimer: s.cancelTimer,
|
||||
setRemaining: s.setRemaining,
|
||||
startEndOfSongTimer: s.startEndOfSongTimer,
|
||||
startTimedTimer: s.startTimedTimer,
|
||||
})),
|
||||
);
|
||||
+82
-17
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Regular → Executable
+4
@@ -105,6 +105,8 @@ import {
|
||||
LuStepForward,
|
||||
LuSun,
|
||||
LuTable,
|
||||
LuTimer,
|
||||
LuTimerOff,
|
||||
LuTriangleAlert,
|
||||
LuUpload,
|
||||
LuUser,
|
||||
@@ -237,6 +239,8 @@ export const AppIcon = {
|
||||
share: LuShare2,
|
||||
signIn: LuLogIn,
|
||||
signOut: LuLogOut,
|
||||
sleepTimer: LuTimer,
|
||||
sleepTimerOff: LuTimerOff,
|
||||
sort: LuArrowUpDown,
|
||||
sortAsc: LuArrowUpNarrowWide,
|
||||
sortDesc: LuArrowDownWideNarrow,
|
||||
|
||||
@@ -34,7 +34,6 @@ export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 's
|
||||
|
||||
interface ImageContainerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
enableAnimation?: boolean;
|
||||
isExplicit?: boolean;
|
||||
}
|
||||
|
||||
@@ -105,7 +104,6 @@ export function BaseImage({
|
||||
return (
|
||||
<ImageContainer
|
||||
className={clsx(containerClassName, containerPropsClassName)}
|
||||
enableAnimation={enableAnimation}
|
||||
isExplicit={isExplicit}
|
||||
{...restContainerProps}
|
||||
>
|
||||
@@ -182,7 +180,6 @@ function ImageWithDebounce({
|
||||
return (
|
||||
<ImageContainer
|
||||
className={clsx(containerClassName, containerPropsClassName)}
|
||||
enableAnimation={enableAnimation}
|
||||
isExplicit={isExplicit}
|
||||
ref={ref}
|
||||
{...restContainerProps}
|
||||
@@ -216,7 +213,6 @@ function ImageWithDebounce({
|
||||
return (
|
||||
<ImageContainer
|
||||
className={clsx(containerClassName, containerPropsClassName)}
|
||||
enableAnimation={enableAnimation}
|
||||
isExplicit={isExplicit}
|
||||
{...restContainerProps}
|
||||
>
|
||||
@@ -284,7 +280,6 @@ function ImageWithViewport({
|
||||
return (
|
||||
<ImageContainer
|
||||
className={clsx(containerClassName, containerPropsClassName)}
|
||||
enableAnimation={enableAnimation}
|
||||
isExplicit={isExplicit}
|
||||
ref={ref}
|
||||
{...restContainerProps}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user