Compare commits

...

39 Commits

Author SHA1 Message Date
jeffvli 31bcc70498 lint 2026-02-13 11:22:32 -08:00
jeffvli 41c21b94c1 add additional logging to controller and mutations 2026-02-13 11:05:40 -08:00
jeffvli bca14176fb remove logMsg and refactor messages inline 2026-02-13 02:34:22 -08:00
York 02a5395453 fix: regenerate macOS icon (.icns) to fix glitched small icons (#1705) 2026-02-13 00:15:12 -08:00
jeffvli 7ba2f6b827 fix removed filter functions 2026-02-12 22:04:08 -08:00
jeffvli f1b5dc8ef3 add additional client-side filters to playlist songs 2026-02-12 22:00:07 -08:00
Kendall Garner 78875572e9 add explicit blurring to left expanded image and full screen (#1701)
* add explicit blurring to left expanded image and full screen
2026-02-12 18:49:57 -08:00
jeffvli f487560ec5 fix vite web build again for hashed assets and PWA cache clear 2026-02-12 18:21:27 -08:00
jeffvli f752090c78 Revert "attempt fix for web/docker cache busting on new release"
This reverts commit 91e7c7434c.
2026-02-12 18:17:49 -08:00
jeffvli 96f5b2b82a Revert "fix vite web build to work with subpath"
This reverts commit 1a9f36ce9e.
2026-02-12 18:17:44 -08:00
jeffvli 80292ae579 fix alpha autoupdater logic to use correct config for latest 2026-02-12 18:17:08 -08:00
Hosted Weblate 1d156ac506 Translated using Weblate
Currently translated at 100.0% (1154 of 1154 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

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

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

Translated using Weblate

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

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

* add compact styling to LibraryHeader

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

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

Translated using Weblate

Currently translated at 100.0% (1152 of 1152 strings)

Translated using Weblate

Currently translated at 99.9% (1151 of 1152 strings)

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

Translated using Weblate

Currently translated at 33.5% (387 of 1152 strings)

Translated using Weblate

Currently translated at 100.0% (1152 of 1152 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 96.3% (1101 of 1143 strings)

Translated using Weblate (Ukrainian)

Currently translated at 32.1% (368 of 1143 strings)

Translated using Weblate (French)

Currently translated at 91.3% (1044 of 1143 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1143 of 1143 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (1143 of 1143 strings)

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Raphael <raphael.margueron@gmail.com>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: Yurii <04_hours.lambing@icloud.com>
Co-authored-by: linger <linger0517@gmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/uk/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/
Translation: feishin/Translation
2026-02-11 11:02:22 +01:00
jeffvli 022b83ab32 fix playlist add returning zero results on modal menu (#1695) 2026-02-11 00:35:22 -08:00
jeffvli 551d705ee1 adjust fixed-width columns on the Item Detail list and prevent text wrapping 2026-02-10 21:52:22 -08:00
jeffvli 83f73c7fa9 remove unused enableAnimation from ImageContainer 2026-02-10 21:46:54 -08:00
York cc8cb4f4f1 Add sleep timer to player bar (#1671)
* feat: add sleep timer to player bar

- Add sleep timer button in player bar right controls
- Preset options: End of song, 5/10/15/30/45 min, 1 hr, 2 hrs
- Custom timer with HH:MM:SS input fields
- Timer only counts down while music is playing
- Timer pauses playback when it expires
- End-of-song mode pauses at the next track change
- Uses theme-aware styling (--theme-colors-surface)
- Add sleepTimer/sleepTimerOff icons (LuTimer/LuTimerOff)
- Add i18n strings for sleep timer UI

---------

Co-authored-by: York <york@BonecharMac.local>
Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2026-02-10 21:19:37 -08:00
York 496eab7d09 fix: regenerate macOS icon (.icns) to fix glitched small icons (#1688)
Co-authored-by: York <york@BonecharMac.local>
2026-02-10 21:11:10 -08:00
York 5197c967c2 fix: use theme mode property for macOS native window theme (#1685)
Co-authored-by: York <york@BonecharMac.local>
2026-02-10 21:09:32 -08:00
jeffvli 74b615dba7 include stable version check on alpha update 2026-02-10 20:20:37 -08:00
jeffvli b67ee797cb move arm64 build configuration to electron-builder config (#1689) 2026-02-10 19:25:26 -08:00
111 changed files with 3605 additions and 716 deletions
-11
View File
@@ -155,17 +155,6 @@ jobs:
pnpm run publish:win:alpha pnpm run publish:win:alpha
on_retry_command: pnpm cache delete 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) - name: Build and Publish to R2 (macOS)
if: matrix.os == 'macos-latest' if: matrix.os == 'macos-latest'
uses: nick-invision/retry@v2.8.2 uses: nick-invision/retry@v2.8.2
-13
View File
@@ -155,19 +155,6 @@ jobs:
pnpm run publish:win:beta pnpm run publish:win:beta
on_retry_command: pnpm cache delete 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) - name: Build and Publish releases (macOS)
if: matrix.os == 'macos-latest' if: matrix.os == 'macos-latest'
env: env:
-10
View File
@@ -50,16 +50,6 @@ jobs:
command: | command: |
pnpm run package:win:pr 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 - name: Build for Linux
if: ${{ matrix.os == 'ubuntu-latest' }} if: ${{ matrix.os == 'ubuntu-latest' }}
uses: nick-invision/retry@v2.8.2 uses: nick-invision/retry@v2.8.2
-12
View File
@@ -33,15 +33,3 @@ jobs:
command: | command: |
pnpm run publish:win pnpm run publish:win
on_retry_command: pnpm cache delete 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
-13
View File
@@ -35,19 +35,6 @@ jobs:
pnpm run publish:win pnpm run publish:win
on_retry_command: pnpm cache delete 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) - name: Build and Publish releases (macOS)
if: matrix.os == 'macos-latest' if: matrix.os == 'macos-latest'
env: env:
Binary file not shown.
+9 -3
View File
@@ -13,9 +13,15 @@ asarUnpack:
- resources/** - resources/**
win: win:
target: target:
- zip - target: zip
- nsis arch:
icon: assets/icons/icon.png - x64
- arm64
- target: nsis
arch:
- x64
- arm64
icon: assets/icons/icon.ico
nsis: nsis:
allowToChangeInstallationDirectory: true allowToChangeInstallationDirectory: true
+9 -3
View File
@@ -13,9 +13,15 @@ asarUnpack:
- resources/** - resources/**
win: win:
target: target:
- zip - target: zip
- nsis arch:
icon: assets/icons/icon.png - x64
- arm64
- target: nsis
arch:
- x64
- arm64
icon: assets/icons/icon.ico
nsis: nsis:
allowToChangeInstallationDirectory: true allowToChangeInstallationDirectory: true
+8 -2
View File
@@ -13,8 +13,14 @@ asarUnpack:
- resources/** - resources/**
win: win:
target: target:
- zip - target: zip
- nsis arch:
- x64
- arm64
- target: nsis
arch:
- x64
- arm64
icon: assets/icons/icon.ico icon: assets/icons/icon.ico
nsis: nsis:
+31 -5
View File
@@ -29,7 +29,11 @@
"topSongsFrom": "les millors cançons de {{title}}", "topSongsFrom": "les millors cançons de {{title}}",
"viewAll": "mostra-ho tot", "viewAll": "mostra-ho tot",
"groupingTypeAll": "tots els tipus de llançaments", "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": { "albumArtistList": {
"title": "$t(entity.albumArtist, {\"count\": 2})" "title": "$t(entity.albumArtist, {\"count\": 2})"
@@ -200,6 +204,11 @@
"collections": { "collections": {
"overrideExisting": "sobreescriu existents", "overrideExisting": "sobreescriu existents",
"saveAsCollection": "desa com a col·lecció" "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": { "common": {
@@ -779,7 +788,7 @@
"releaseChannel_optionLatest": "última versió", "releaseChannel_optionLatest": "última versió",
"releaseChannel_optionBeta": "beta", "releaseChannel_optionBeta": "beta",
"releaseChannel": "canal de versions", "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": "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", "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", "crossfadeStyle": "estil de fosa encadenada",
@@ -878,7 +887,14 @@
"sidebarPlaylistSorting": "ordenació de llistes de reproducció de la barra lateral", "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_description": "amaga les llistes de reproducció de la barra lateral que coincideixin amb aquesta expressió regular",
"sidebarPlaylistListFilterRegex_placeholder": "ex. ^Mescla diària.*", "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": { "table": {
"column": { "column": {
@@ -983,7 +999,8 @@
"view": { "view": {
"table": "taula", "table": "taula",
"grid": "quadrícula", "grid": "quadrícula",
"list": "llista" "list": "llista",
"detail": "detall"
} }
} }
}, },
@@ -1072,7 +1089,16 @@
"restoreQueueFromServer": "restaura la cua del servidor", "restoreQueueFromServer": "restaura la cua del servidor",
"saveQueueToServer": "desa la cua al servidor", "saveQueueToServer": "desa la cua al servidor",
"artistRadio": "ràdio de l'artista", "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": { "error": {
"credentialsRequired": "credencials requerides", "credentialsRequired": "credencials requerides",
+21 -5
View File
@@ -38,7 +38,16 @@
"restoreQueueFromServer": "obnovit frontu ze serveru", "restoreQueueFromServer": "obnovit frontu ze serveru",
"saveQueueToServer": "uložit frontu na server", "saveQueueToServer": "uložit frontu na server",
"artistRadio": "rádio umělce", "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": { "setting": {
"crossfadeStyle_description": "vyberte způsob prolnutí u přehrávače zvuku", "crossfadeStyle_description": "vyberte způsob prolnutí u přehrávače zvuku",
@@ -46,7 +55,7 @@
"hotkey_skipBackward": "přeskočení zpět", "hotkey_skipBackward": "přeskočení zpět",
"replayGainMode_description": "úprava zesílení hlasitosti podle hodnot {{ReplayGain}} uložených v metadatech souborů", "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", "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", "theme_description": "nastavení motivu použitého v aplikaci",
"hotkey_playbackPause": "pozastavení", "hotkey_playbackPause": "pozastavení",
"replayGainFallback": "fallback {{ReplayGain}}", "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é", "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": "preferovat místní texty",
"preferLocalLyrics_description": "preferovat místní texty před vzdálenými, pokud jsou dostupné", "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", "discordPausedStatus_description": "pokud je povoleno, bude při pozastavení přehrávače zobrazen stav",
"preservePitch": "zachovat výšku", "preservePitch": "zachovat výšku",
"preservePitch_description": "zachová výšku při úpravě rychlosti přehrávání", "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_description": "v postranní liště skrýt seznamy skladeb, které odpovídají tomuto regulárnímu výrazu",
"sidebarPlaylistListFilterRegex_placeholder": "např. ^Denní mix.*", "sidebarPlaylistListFilterRegex_placeholder": "např. ^Denní mix.*",
"sidebarPlaylistListFilterRegex": "regulární výraz filtru seznamů skladeb", "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": { "action": {
"editPlaylist": "upravit $t(entity.playlist, {\"count\": 1})", "editPlaylist": "upravit $t(entity.playlist, {\"count\": 1})",
@@ -558,7 +573,8 @@
"view": { "view": {
"table": "tabulka", "table": "tabulka",
"list": "seznam", "list": "seznam",
"grid": "mřížka" "grid": "mřížka",
"detail": "podrobnosti"
}, },
"general": { "general": {
"displayType": "typ zobrazení", "displayType": "typ zobrazení",
Regular → Executable
+15 -2
View File
@@ -236,6 +236,8 @@
"filter": { "filter": {
"album": "$t(entity.album, {\"count\": 1})", "album": "$t(entity.album, {\"count\": 1})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})", "albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"matchAnd": "and",
"matchOr": "or",
"albumCount": "$t(entity.album, {\"count\": 2}) count", "albumCount": "$t(entity.album, {\"count\": 2}) count",
"artist": "$t(entity.artist, {\"count\": 1})", "artist": "$t(entity.artist, {\"count\": 1})",
"biography": "biography", "biography": "biography",
@@ -667,7 +669,16 @@
"trackRadio": "track radio", "trackRadio": "track radio",
"unfavorite": "unfavorite", "unfavorite": "unfavorite",
"pause": "pause", "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": { "queryBuilder": {
"standardTags": "standard tags", "standardTags": "standard tags",
@@ -725,7 +736,7 @@
"artistConfiguration_description": "configure what items are shown, and in what order, on the album artist page", "artistConfiguration_description": "configure what items are shown, and in what order, on the album artist page",
"artistReleaseTypeConfiguration": "artist release type configuration", "artistReleaseTypeConfiguration": "artist release type configuration",
"artistReleaseTypeConfiguration_description": "configure what release types are shown, and in what order, on the album artist page", "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", "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_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", "audioExclusiveMode": "audio exclusive mode",
@@ -779,6 +790,8 @@
"discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}}", "discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}}",
"discordServeImage": "serve {{discord}} images from server", "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", "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": "{{discord}} rich presence update interval",
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)", "discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
"enableAutoTranslation_description": "enable translation automatically when lyrics are loaded", "enableAutoTranslation_description": "enable translation automatically when lyrics are loaded",
+37 -21
View File
@@ -32,13 +32,22 @@
"playSimilarSongs": "Reproducir canciones similares", "playSimilarSongs": "Reproducir canciones similares",
"viewQueue": "ver cola", "viewQueue": "ver cola",
"addLastShuffled": "Al final (mezclado)", "addLastShuffled": "Al final (mezclado)",
"addNextShuffled": "Al siguiente (mezclado)", "addNextShuffled": "Siguiente (mezclado)",
"holdToShuffle": "Mantener para mezclar", "holdToShuffle": "Mantener para mezclar",
"lyrics": "Letras", "lyrics": "Letras",
"restoreQueueFromServer": "Restaurar cola del servidor", "restoreQueueFromServer": "Restaurar cola del servidor",
"saveQueueToServer": "Guardar cola en el servidor", "saveQueueToServer": "Guardar cola en el servidor",
"artistRadio": "Radio de artista", "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": { "setting": {
"crossfadeStyle_description": "selecciona el estilo de crossfade a usar por el reproductor de audio", "crossfadeStyle_description": "selecciona el estilo de crossfade a usar por el reproductor de audio",
@@ -159,7 +168,7 @@
"customFontPath": "ruta de fuente personalizada", "customFontPath": "ruta de fuente personalizada",
"followLyric": "seguir la letra actual", "followLyric": "seguir la letra actual",
"crossfadeDuration": "duración del crossfade", "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", "sidePlayQueueStyle_optionDetached": "separada",
"audioPlayer": "reproductor de audio", "audioPlayer": "reproductor de audio",
"hotkey_zoomOut": "reducir", "hotkey_zoomOut": "reducir",
@@ -318,8 +327,8 @@
"playerbarWaveformRadius": "Radio de la forma de onda", "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_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", "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_description": "Se añadirá un panel a la barra lateral del reproductor que muestra el visualizador",
"showVisualizerInSidebar": "Mostrar visualizador en la barra lateral de reproducción", "showVisualizerInSidebar": "Mostrar visualizador en la barra lateral del reproductor",
"queryBuilder": "Generador de consultas", "queryBuilder": "Generador de consultas",
"queryBuilderCustomFields_inputTag": "Etiqueta", "queryBuilderCustomFields_inputTag": "Etiqueta",
"queryBuilderCustomFields": "Campos personalizados", "queryBuilderCustomFields": "Campos personalizados",
@@ -385,7 +394,13 @@
"sidebarPlaylistListFilterRegex_placeholder": "p. ej. ^Mezcla diaria.*", "sidebarPlaylistListFilterRegex_placeholder": "p. ej. ^Mezcla diaria.*",
"blurExplicitImages": "Desenfocar imágenes explícitas", "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", "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": { "action": {
"editPlaylist": "editar $t(entity.playlist, {\"count\": 1})", "editPlaylist": "editar $t(entity.playlist, {\"count\": 1})",
@@ -431,7 +446,7 @@
"backward": "hacia atrás", "backward": "hacia atrás",
"increase": "aumentar", "increase": "aumentar",
"rating": "calificación", "rating": "calificación",
"bpm": "lpm", "bpm": "bpm",
"refresh": "actualizar", "refresh": "actualizar",
"unknown": "desconocido", "unknown": "desconocido",
"areYouSure": "seguro?", "areYouSure": "seguro?",
@@ -443,7 +458,7 @@
"currentSong": "$t(entity.track, {\"count\": 1}) actual", "currentSong": "$t(entity.track, {\"count\": 1}) actual",
"collapse": "contraer", "collapse": "contraer",
"trackNumber": "pista", "trackNumber": "pista",
"descending": "descendiente", "descending": "descendente",
"add": "añadir", "add": "añadir",
"ascending": "ascendente", "ascending": "ascendente",
"dismiss": "descartar", "dismiss": "descartar",
@@ -470,8 +485,8 @@
"cancel": "cancelar", "cancel": "cancelar",
"forceRestartRequired": "reiniciar para aplicar cambios... cerrar la notificación para reiniciar", "forceRestartRequired": "reiniciar para aplicar cambios... cerrar la notificación para reiniciar",
"setting_one": "configuración", "setting_one": "configuración",
"setting_many": "configuraciones", "setting_many": "configuración",
"setting_other": "configuraciones", "setting_other": "configuración",
"version": "versión", "version": "versión",
"title": "título", "title": "título",
"filters": "filtros", "filters": "filtros",
@@ -585,10 +600,10 @@
"noNetworkDescription": "No se pudo conectar a este servidor" "noNetworkDescription": "No se pudo conectar a este servidor"
}, },
"filter": { "filter": {
"mostPlayed": "más reproducido", "mostPlayed": "más reproducidos",
"isCompilation": "es una compilación", "isCompilation": "es una compilación",
"recentlyPlayed": "recientemente reproducido", "recentlyPlayed": "recientemente reproducido",
"isRated": "es clasificado", "isRated": "Está calificado",
"title": "título", "title": "título",
"rating": "calificación", "rating": "calificación",
"search": "buscar", "search": "buscar",
@@ -604,7 +619,7 @@
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})", "albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"isRecentlyPlayed": "reproducido recientemente", "isRecentlyPlayed": "reproducido recientemente",
"isFavorited": "es favorito", "isFavorited": "es favorito",
"bpm": "lpm", "bpm": "bpm",
"releaseYear": "año de lanzamiento", "releaseYear": "año de lanzamiento",
"disc": "disco", "disc": "disco",
"biography": "biografía", "biography": "biografía",
@@ -623,10 +638,10 @@
"owner": "$t(common.owner)", "owner": "$t(common.owner)",
"genre": "$t(entity.genre, {\"count\": 1})", "genre": "$t(entity.genre, {\"count\": 1})",
"id": "id", "id": "id",
"songCount": "número de canción", "songCount": "número de canciones",
"isPublic": "es público", "isPublic": "es público",
"album": "$t(entity.album, {\"count\": 1})", "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)", "explicitStatus": "$t(common.explicitStatus)",
"sortName": "Ordenar por nombre" "sortName": "Ordenar por nombre"
}, },
@@ -865,8 +880,8 @@
"input_name": "nombre del servidor", "input_name": "nombre del servidor",
"success": "servidor añadido correctamente", "success": "servidor añadido correctamente",
"input_savePassword": "guardar contraseña", "input_savePassword": "guardar contraseña",
"ignoreSsl": "ignorar ssl ($t(common.restartRequired))", "ignoreSsl": "Ignorar SSL ($t(common.restartRequired))",
"ignoreCors": "ignorar cors ($t(common.restartRequired))", "ignoreCors": "Ignorar CORS ($t(common.restartRequired))",
"error_savePassword": "un error ocurrió cuando se intentó guardar la contraseña", "error_savePassword": "un error ocurrió cuando se intentó guardar la contraseña",
"input_preferInstantMix": "Preferir mix instantáneo", "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", "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", "releaseDate": "fecha de lanzamiento",
"bitrate": "tasa de bits", "bitrate": "tasa de bits",
"title": "título", "title": "título",
"bpm": "lpm", "bpm": "bpm",
"dateAdded": "fecha de adición", "dateAdded": "fecha de adición",
"artist": "$t(entity.artist, {\"count\": 1})", "artist": "$t(entity.artist, {\"count\": 1})",
"songCount": "$t(entity.track, {\"count\": 2})", "songCount": "$t(entity.track, {\"count\": 2})",
@@ -1029,8 +1044,8 @@
"followCurrentSong": "seguir la canción actual", "followCurrentSong": "seguir la canción actual",
"advancedSettings": "Opciones avanzadas", "advancedSettings": "Opciones avanzadas",
"autosize": "Autodimensionar", "autosize": "Autodimensionar",
"moveUp": "Ascender", "moveUp": "Subir",
"moveDown": "Descender", "moveDown": "Bajar",
"pinToLeft": "Anclar a la izquierda", "pinToLeft": "Anclar a la izquierda",
"pinToRight": "Anclar a la derecha", "pinToRight": "Anclar a la derecha",
"alignLeft": "Alinear a la izquierda", "alignLeft": "Alinear a la izquierda",
@@ -1053,7 +1068,8 @@
"view": { "view": {
"table": "tabla", "table": "tabla",
"list": "Lista", "list": "Lista",
"grid": "Cuadrícula" "grid": "Cuadrícula",
"detail": "Detalle"
} }
} }
}, },
+18 -3
View File
@@ -204,7 +204,8 @@
"mood": "humeur", "mood": "humeur",
"retry": "réessayer", "retry": "réessayer",
"filter_single": "unique", "filter_single": "unique",
"filter_multiple": "multiple" "filter_multiple": "multiple",
"rename": "renommer"
}, },
"error": { "error": {
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port", "remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
@@ -280,7 +281,8 @@
"artist": "$t(entity.artist, {\"count\": 1})", "artist": "$t(entity.artist, {\"count\": 1})",
"isPublic": "est public", "isPublic": "est public",
"album": "$t(entity.album, {\"count\": 1})", "album": "$t(entity.album, {\"count\": 1})",
"explicitStatus": "$t(common.explicitStatus)" "explicitStatus": "$t(common.explicitStatus)",
"sortName": "tri par nom"
}, },
"page": { "page": {
"sidebar": { "sidebar": {
@@ -447,7 +449,12 @@
"viewDiscography": "voir la discographie", "viewDiscography": "voir la discographie",
"relatedArtists": "$t(entity.artist, {\"count\": 2}) similaires", "relatedArtists": "$t(entity.artist, {\"count\": 2}) similaires",
"topSongs": "meilleurs titres", "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": { "itemDetail": {
"copyPath": "copier le chemin dans le presse-papiers", "copyPath": "copier le chemin dans le presse-papiers",
@@ -473,6 +480,14 @@
}, },
"radioList": { "radioList": {
"title": "stations radio" "title": "stations radio"
},
"releasenotes": {
"commitsSinceStable": "commits depuis {{stable}}",
"noNewCommits": "pas de nouveaux commits dans cette plage"
},
"windowBar": {
"paused": "(Pause) ",
"privateMode": "(Mode Privé)"
} }
}, },
"setting": { "setting": {
+20 -4
View File
@@ -661,7 +661,16 @@
"restoreQueueFromServer": "przywróć kolejkę z serwera", "restoreQueueFromServer": "przywróć kolejkę z serwera",
"saveQueueToServer": "zapisz kolejkę na serwerze", "saveQueueToServer": "zapisz kolejkę na serwerze",
"artistRadio": "radio wykonawcy", "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": { "setting": {
"crossfadeStyle_description": "wybierz styl przenikania, który ma być używany do odtwarzania dźwięku", "crossfadeStyle_description": "wybierz styl przenikania, który ma być używany do odtwarzania dźwięku",
@@ -894,7 +903,7 @@
"releaseChannel_optionBeta": "beta", "releaseChannel_optionBeta": "beta",
"releaseChannel_optionLatest": "najnowsza", "releaseChannel_optionLatest": "najnowsza",
"releaseChannel": "kanał wydań", "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_artistname": "nazwa(y) wykonawców",
"discordDisplayType_description": "zmienia co jest pokazywane jako słuchane w twoim statusie", "discordDisplayType_description": "zmienia co jest pokazywane jako słuchane w twoim statusie",
"discordDisplayType_songname": "nazwa piosenki", "discordDisplayType_songname": "nazwa piosenki",
@@ -1008,14 +1017,21 @@
"sidebarPlaylistListFilterRegex": "filtr playlist regex", "sidebarPlaylistListFilterRegex": "filtr playlist regex",
"blurExplicitImages": "rozmazuj nieodpowiednie obrazy", "blurExplicitImages": "rozmazuj nieodpowiednie obrazy",
"blurExplicitImages_description": "obrazy piosenek oraz albumów oznaczone jako nieodpowiednie będą rozmazywane", "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": { "table": {
"config": { "config": {
"view": { "view": {
"table": "tabela", "table": "tabela",
"grid": "siatka", "grid": "siatka",
"list": "lista" "list": "lista",
"detail": "szczegół"
}, },
"general": { "general": {
"displayType": "typ wyświetlania", "displayType": "typ wyświetlania",
+122 -1
View File
@@ -22,7 +22,7 @@
"holdToMoveToTop": "утримуйте, щоб перемістити вгору", "holdToMoveToTop": "утримуйте, щоб перемістити вгору",
"holdToMoveToBottom": "утримувати, щоб перемістити вниз", "holdToMoveToBottom": "утримувати, щоб перемістити вниз",
"moveItems": "перемістити елементи", "moveItems": "перемістити елементи",
"shuffle": "відтворити випадково", "shuffle": "перемішати",
"shuffleAll": "все випадково", "shuffleAll": "все випадково",
"shuffleSelected": "вибране випадково", "shuffleSelected": "вибране випадково",
"refresh": "$t(common.refresh)", "refresh": "$t(common.refresh)",
@@ -415,9 +415,130 @@
"success": "посилання для спільного використання скопійовано в буфер обміну (натисніть тут, щоб відкрити)", "success": "посилання для спільного використання скопійовано в буфер обміну (натисніть тут, щоб відкрити)",
"expireInvalid": "термін дії повинен бути в майбутньому", "expireInvalid": "термін дії повинен бути в майбутньому",
"createFailed": "не вдалося створити спільний доступ (чи ввімкнено спільний доступ?)" "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": { "player": {
"skip": "пропустити" "skip": "пропустити"
},
"page": {
"albumArtistDetail": {
"about": "Про {{artist}}",
"appearsOn": "з'являється на",
"favoriteSongs": "улюблені пісні",
"groupingTypeAll": "всі типи випуску",
"groupingTypePrimary": "основні типи випуску",
"recentReleases": "останні випуски",
"viewDiscography": "переглянути дискографію",
"relatedArtists": "подібні $t(entity.artist, {\"count\": 2})",
"topSongs": "найкращі пісні",
"topSongsCommunity": "спільнота",
"topSongsFrom": "найкращі пісні від {{title}}",
"topSongsPersonal": "особисте",
"favoriteSongsFrom": "улюблені пісні від {{title}}",
"viewAll": "показати все",
"viewAllTracks": "показати усі $t(entity.track, {\"count\": 2})"
},
"albumArtistList": {
"title": "$t(entity.albumArtist, {\"count\": 2})"
},
"albumDetail": {
"moreFromArtist": "більше від цього $t(entity.artist, {\"count\": 1})",
"moreFromGeneric": "більше від {{item}}",
"released": "видано"
},
"albumList": {
"artistAlbums": "альбоми виконавця {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album, {\"count\": 2})",
"title": "$t(entity.album, {\"count\": 2})"
},
"radioList": {
"title": "радіостанції"
},
"releasenotes": {
"commitsSinceStable": "комміти від {{stable}}",
"noNewCommits": "немає нових коммітів у цьому періоді",
"noStableReleaseToCompare": "немає доступної стабільної версії для порівняння"
},
"favorites": {
"title": "$t(entity.favorite, {\"count\": 2})"
},
"windowBar": {
"paused": "(Призупинено) ",
"privateMode": "(Приватний режим)"
},
"appMenu": {
"collapseSidebar": "згорнути бічну панель",
"commandPalette": "відкрити палітру команд",
"expandSidebar": "розгорнути бічну панель",
"goBack": "повернутися назад",
"goForward": "перейти вперед",
"manageServers": "управління серверами",
"privateModeOff": "вимкнути приватний режим",
"privateModeOn": "увімкнути приватний режим",
"openBrowserDevtools": "відкрити інструменти розробника",
"quit": "$t(common.quit)",
"selectServer": "вибрати сервер",
"selectMusicFolder": "вибрати папку з музикою",
"noMusicFolder": "не вибрано папку з музикою",
"multipleMusicFolders": "Вибрано {{count}} папок з музикою",
"settings": "$t(common.setting, {\"count\": 2})",
"version": "версія {{version}}"
},
"manageServers": {
"title": "управління серверами",
"serverDetails": "інформація про сервер",
"url": "URL-адреса",
"username": "Ім'я користувача",
"editServerDetailsTooltip": "редагувати дані сервера",
"removeServer": "видалити сервер"
},
"contextMenu": {
"addFavorite": "$t(action.addToFavorites)",
"addLast": "$t(player.addLast)",
"addNext": "$t(player.addNext)",
"addToFavorites": "$t(action.addToFavorites)",
"addToPlaylist": "$t(action.addToPlaylist)",
"createPlaylist": "$t(action.createPlaylist)",
"deletePlaylist": "$t(action.deletePlaylist)",
"deselectAll": "$t(action.deselectAll)",
"download": "завантажити",
"moveItems": "$t(action.moveItems)",
"moveToNext": "$t(action.moveToNext)",
"moveToBottom": "$t(action.moveToBottom)",
"moveToTop": "$t(action.moveToTop)",
"numberSelected": "{{count}} вибрано",
"play": "$t(player.play)",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"removeFromFavorites": "$t(action.removeFromFavorites)",
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromQueue": "$t(action.removeFromQueue)",
"setRating": "$t(action.setRating)",
"playShuffled": "$t(player.shuffle)",
"shareItem": "поділитися елементом",
"goTo": "перейти до",
"goToAlbum": "перейти до $t(entity.album, {\"count\": 1})",
"goToAlbumArtist": "перейти до $t(entity.albumArtist, {\"count\": 1})",
"showDetails": "отримати інформацію"
}
} }
} }
+1 -1
View File
@@ -463,7 +463,7 @@
"releaseChannel_optionLatest": "最新的", "releaseChannel_optionLatest": "最新的",
"releaseChannel_optionBeta": "测试版", "releaseChannel_optionBeta": "测试版",
"releaseChannel": "发布通道", "releaseChannel": "发布通道",
"releaseChannel_description": "选择稳定版本或测试版以进行自动更新", "releaseChannel_description": "选择稳定版测试版或 Alpha(夜间构建版)以启用自动更新",
"mediaSession": "启用媒体会话", "mediaSession": "启用媒体会话",
"mediaSession_description": "启用媒体会话集成,在系统音量叠加层和锁屏界面显示媒体控件和元数据", "mediaSession_description": "启用媒体会话集成,在系统音量叠加层和锁屏界面显示媒体控件和元数据",
"exportImportSettings_control_description": "通过 JSON 导出和导入设置", "exportImportSettings_control_description": "通过 JSON 导出和导入设置",
+19 -3
View File
@@ -399,7 +399,16 @@
"restoreQueueFromServer": "從伺服器還原播放佇列", "restoreQueueFromServer": "從伺服器還原播放佇列",
"saveQueueToServer": "將播放佇列儲存至伺服器", "saveQueueToServer": "將播放佇列儲存至伺服器",
"artistRadio": "藝人電台", "artistRadio": "藝人電台",
"trackRadio": "曲目電台" "trackRadio": "曲目電台",
"sleepTimer": "睡眠定時器",
"sleepTimer_endOfSong": "歌曲播完時",
"sleepTimer_minutes": "{{count}} 分鐘",
"sleepTimer_hours": "{{count}} 小時",
"sleepTimer_custom": "自訂",
"sleepTimer_off": "關閉",
"sleepTimer_timeRemaining": "剩餘 {{time}}",
"sleepTimer_setCustom": "設定定時器",
"sleepTimer_cancel": "取消定時器"
}, },
"setting": { "setting": {
"audioPlayer_description": "選擇用於播放的音訊播放器", "audioPlayer_description": "選擇用於播放的音訊播放器",
@@ -746,7 +755,13 @@
"sidebarPlaylistListFilterRegex": "播放清單過濾器正規表達式", "sidebarPlaylistListFilterRegex": "播放清單過濾器正規表達式",
"blurExplicitImages": "模糊露骨圖片", "blurExplicitImages": "模糊露骨圖片",
"blurExplicitImages_description": "標記為露骨的專輯和歌曲封面將被模糊", "blurExplicitImages_description": "標記為露骨的專輯和歌曲封面將被模糊",
"releaseChannel_optionAlpha": "alpha (每日建構版)" "releaseChannel_optionAlpha": "alpha (每日建構版)",
"analyticsEnable": "傳送基於使用情況的分析報告",
"analyticsEnable_description": "匿名化的使用情況資料會傳送給開發者,以協助改進應用程式",
"automaticUpdates": "自動更新",
"automaticUpdates_description": "自動檢查並安裝更新",
"discordStateIcon": "顯示播放中圖示",
"discordStateIcon_description": "在 rich presence 狀態中顯示一個小的播放圖示。啟用「暫停時顯示 rich presence」時,會始終顯示暫停的圖示"
}, },
"table": { "table": {
"config": { "config": {
@@ -822,7 +837,8 @@
"view": { "view": {
"table": "表格", "table": "表格",
"grid": "網格", "grid": "網格",
"list": "列表" "list": "列表",
"detail": "詳情"
} }
}, },
"column": { "column": {
+3 -2
View File
@@ -1,6 +1,7 @@
import { createSocket } from 'dgram'; import { createSocket } from 'dgram';
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import { mainLogger } from '/@/main/logger';
import { DiscoveredServerItem, ServerType } from '/@/shared/types/types'; import { DiscoveredServerItem, ServerType } from '/@/shared/types/types';
type JellyfinResponse = { type JellyfinResponse = {
@@ -26,7 +27,7 @@ function discoverJellyfin(reply: (server: DiscoveredServerItem) => void) {
}); });
} catch (e) { } catch (e) {
// Got a spurious response, ignore? // Got a spurious response, ignore?
console.error(e); mainLogger.error('Autodiscover Jellyfin parse error', e);
} }
}); });
@@ -51,5 +52,5 @@ ipcMain.on('autodiscover-ping', (ev) => {
discoverAll((result) => port.postMessage(result)) discoverAll((result) => port.postMessage(result))
.then(() => port.close()) .then(() => port.close())
.catch((err) => console.error(err)); .catch((err) => mainLogger.error('Autodiscover failed', err));
}); });
+4 -3
View File
@@ -7,6 +7,7 @@ import {
LyricSearchQuery, LyricSearchQuery,
LyricSource, LyricSource,
} from '.'; } from '.';
import { mainLogger } from '../../../logger';
import { orderSearchResults } from './shared'; import { orderSearchResults } from './shared';
const SEARCH_URL = 'https://genius.com/api/search/song'; const SEARCH_URL = 'https://genius.com/api/search/song';
@@ -100,7 +101,7 @@ export async function getLyricsBySongId(url: string): Promise<null | string> {
try { try {
result = await axios.get<string>(url, { responseType: 'text' }); result = await axios.get<string>(url, { responseType: 'text' });
} catch (e) { } catch (e) {
console.error('Genius lyrics request got an error!', (e as Error)?.message); mainLogger.error('Genius lyrics request failed', (e as Error)?.message);
return null; return null;
} }
@@ -138,7 +139,7 @@ export async function getSearchResults(
}, },
}); });
} catch (e) { } catch (e) {
console.error('Genius search request got an error!', (e as Error)?.message); mainLogger.error('Genius search request failed', (e as Error)?.message);
return null; return null;
} }
@@ -193,7 +194,7 @@ async function getSongId(
}, },
}); });
} catch (e) { } catch (e) {
console.error('Genius search request got an error!', (e as Error)?.message); mainLogger.error('Genius search request failed', (e as Error)?.message);
return null; return null;
} }
+3 -2
View File
@@ -1,5 +1,6 @@
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import { mainLogger } from '../../../logger';
import { store } from '../settings'; import { store } from '../settings';
import { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from './genius'; import { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from './genius';
import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib'; import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib';
@@ -96,7 +97,7 @@ const searchAllSources = async (
allSearchResults.push(...result.value.searchResults); allSearchResults.push(...result.value.searchResults);
} else if (result.status === 'rejected') { } else if (result.status === 'rejected') {
const index = settled.indexOf(result); const index = settled.indexOf(result);
console.error(`Error searching ${sources[index]} for lyrics:`, result.reason); mainLogger.error(`Error searching ${sources[index]} for lyrics`, result.reason);
} }
} }
return allSearchResults; return allSearchResults;
@@ -160,7 +161,7 @@ const getRemoteLyrics = async (song: Song) => {
}; };
} }
} catch (error) { } catch (error) {
console.error(`Error fetching lyrics from ${bestMatch.source}:`, error); mainLogger.error(`Error fetching lyrics from ${bestMatch.source}`, error);
} }
if (lyricsFromSource) { if (lyricsFromSource) {
+4 -3
View File
@@ -7,6 +7,7 @@ import {
LyricSearchQuery, LyricSearchQuery,
LyricSource, LyricSource,
} from '.'; } from '.';
import { mainLogger } from '../../../logger';
import { orderSearchResults } from './shared'; import { orderSearchResults } from './shared';
const FETCH_URL = 'https://lrclib.net/api/get'; const FETCH_URL = 'https://lrclib.net/api/get';
@@ -46,7 +47,7 @@ export async function getLyricsBySongId(songId: string): Promise<null | string>
try { try {
result = await axios.get<LrcLibTrackResponse>(`${FETCH_URL}/${songId}`); result = await axios.get<LrcLibTrackResponse>(`${FETCH_URL}/${songId}`);
} catch (e) { } catch (e) {
console.error('LrcLib lyrics request got an error!', (e as Error)?.message); mainLogger.error('LrcLib lyrics request failed', (e as Error)?.message);
return null; return null;
} }
@@ -69,7 +70,7 @@ export async function getSearchResults(
}, },
}); });
} catch (e) { } catch (e) {
console.error('LrcLib search request got an error!', (e as Error)?.message); mainLogger.error('LrcLib search request failed', (e as Error)?.message);
return null; return null;
} }
@@ -107,7 +108,7 @@ export async function query(
timeout: TIMEOUT_MS, timeout: TIMEOUT_MS,
}); });
} catch (e) { } catch (e) {
console.error('LrcLib search request got an error!', (e as Error).message); mainLogger.error('LrcLib search request failed', (e as Error).message);
return null; return null;
} }
+3 -2
View File
@@ -6,6 +6,7 @@ import {
LyricSearchQuery, LyricSearchQuery,
LyricSource, LyricSource,
} from '.'; } from '.';
import { mainLogger } from '../../../logger';
import { store } from '../settings'; import { store } from '../settings';
import { orderSearchResults } from './shared'; import { orderSearchResults } from './shared';
@@ -81,7 +82,7 @@ export async function getLyricsBySongId(songId: string): Promise<null | string>
}, },
}); });
} catch (e) { } catch (e) {
console.error('NetEase lyrics request got an error!', e); mainLogger.error('NetEase lyrics request failed', e);
return null; return null;
} }
const enableTranslation = store.get('enableNeteaseTranslation', false) as boolean; const enableTranslation = store.get('enableNeteaseTranslation', false) as boolean;
@@ -114,7 +115,7 @@ export async function getSearchResults(
}, },
}); });
} catch (e) { } catch (e) {
console.error('NetEase search request got an error!', e); mainLogger.error('NetEase search request failed', e);
return null; return null;
} }
+4 -4
View File
@@ -1,4 +1,3 @@
import console from 'console';
import { app, ipcMain } from 'electron'; import { app, ipcMain } from 'electron';
import { rm } from 'fs/promises'; import { rm } from 'fs/promises';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
@@ -7,6 +6,7 @@ import { pid } from 'node:process';
import process from 'process'; import process from 'process';
import { getMainWindow, sendToastToRenderer } from '../../../index'; import { getMainWindow, sendToastToRenderer } from '../../../index';
import { mainLogger } from '../../../logger';
import { createLog, isWindows } from '../../../utils'; import { createLog, isWindows } from '../../../utils';
import { store } from '../settings'; import { store } from '../settings';
@@ -109,7 +109,7 @@ const createMpv = async (data: {
try { try {
await mpv.start(); await mpv.start();
} catch (error: any) { } catch (error: any) {
console.error('mpv failed to start', error); mainLogger.error('mpv failed to start', error);
} finally { } finally {
await mpv.setMultipleProperties(properties || {}); await mpv.setMultipleProperties(properties || {});
} }
@@ -672,7 +672,7 @@ process.on('SIGTERM', async () => {
// Handle uncaught exceptions - cleanup mpv before crashing // Handle uncaught exceptions - cleanup mpv before crashing
process.on('uncaughtException', async (error) => { process.on('uncaughtException', async (error) => {
console.error('Uncaught exception:', error); mainLogger.error('Uncaught exception', error);
await cleanupMpv(true).catch(() => { await cleanupMpv(true).catch(() => {
// Ignore cleanup errors during crash // Ignore cleanup errors during crash
}); });
@@ -680,7 +680,7 @@ process.on('uncaughtException', async (error) => {
// Handle unhandled rejections - cleanup mpv // Handle unhandled rejections - cleanup mpv
process.on('unhandledRejection', async (reason) => { process.on('unhandledRejection', async (reason) => {
console.error('Unhandled rejection:', reason); mainLogger.error('Unhandled rejection', reason);
await cleanupMpv(true).catch(() => { await cleanupMpv(true).catch(() => {
// Ignore cleanup errors // Ignore cleanup errors
}); });
+3 -2
View File
@@ -10,6 +10,7 @@ import { deflate, gzip } from 'zlib';
import manifest from './manifest.json'; import manifest from './manifest.json';
import { getMainWindow } from '/@/main/index'; import { getMainWindow } from '/@/main/index';
import { mainLogger } from '/@/main/logger';
import { isLinux } from '/@/main/utils'; import { isLinux } from '/@/main/utils';
import { QueueSong } from '/@/shared/types/domain-types'; import { QueueSong } from '/@/shared/types/domain-types';
import { ClientEvent, ServerEvent } from '/@/shared/types/remote-types'; import { ClientEvent, ServerEvent } from '/@/shared/types/remote-types';
@@ -349,7 +350,7 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
}, 10000) as unknown as number; }, 10000) as unknown as number;
} }
ws.on('error', console.error); ws.on('error', (err) => mainLogger.error('Remote WebSocket error', err));
ws.on('message', (data) => { ws.on('message', (data) => {
try { try {
@@ -488,7 +489,7 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
} }
} }
} catch (error) { } catch (error) {
console.error(error); mainLogger.error('Remote message handler error', error);
} }
}); });
+130 -31
View File
@@ -1,3 +1,5 @@
import type { UpdateCheckResult } from 'electron-updater';
import { is } from '@electron-toolkit/utils'; import { is } from '@electron-toolkit/utils';
import { import {
app, app,
@@ -21,11 +23,13 @@ import log from 'electron-log/main';
import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater'; import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';
import { access, constants } from 'fs'; import { access, constants } from 'fs';
import path, { join } from 'path'; import path, { join } from 'path';
import semver from 'semver';
import packageJson from '../../package.json'; import packageJson from '../../package.json';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys'; import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { shutdownServer } from './features/core/remote'; import { shutdownServer } from './features/core/remote';
import { store } from './features/core/settings'; import { store } from './features/core/settings';
import { mainLogger } from './logger';
import MenuBuilder from './menu'; import MenuBuilder from './menu';
import { import {
autoUpdaterLogInterface, autoUpdaterLogInterface,
@@ -52,29 +56,25 @@ const ALPHA_UPDATER_CONFIG: {
provider: 's3', provider: 's3',
}; };
type UpdaterInstance = AppImageUpdater | MacUpdater | NsisUpdater | typeof autoUpdater; const GITHUB_UPDATER_CONFIG = {
owner: 'jeffvli',
provider: 'github' as const,
repo: 'feishin',
};
class AlphaAppUpdater { type UpdaterInstance = AppImageUpdater | MacUpdater | NsisUpdater | typeof autoUpdater;
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();
}
}
class AppUpdater { class AppUpdater {
constructor() { constructor() {
const effectiveChannel = store.get('release_channel') as string; const effectiveChannel = store.get('release_channel') as string;
console.log('Effective update channel:', effectiveChannel); mainLogger.info('Effective update channel:', effectiveChannel);
if (effectiveChannel === 'alpha') { if (effectiveChannel === 'alpha') {
return new AlphaAppUpdater(); checkAllChannelsAndGetBest().then(({ updater: updaterInstance }) => {
updaterInstance.autoInstallOnAppQuit = true;
updaterInstance.autoRunAppAfterInstall = true;
updaterInstance.checkForUpdatesAndNotify();
});
return;
} }
configureAndGetUpdater(); configureAndGetUpdater();
@@ -82,19 +82,87 @@ 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 {
mainLogger.info('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');
mainLogger.info('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 { function configureAndGetUpdater(): UpdaterInstance {
const isBetaVersion = packageJson.version.includes('-beta'); const isBetaVersion = packageJson.version.includes('-beta');
const isAlphaVersion = packageJson.version.includes('-alpha'); const isAlphaVersion = packageJson.version.includes('-alpha');
let releaseChannel = store.get('release_channel'); let releaseChannel = store.get('release_channel');
const isNotConfigured = !releaseChannel; const isNotConfigured = !releaseChannel;
console.log('Release channel:', releaseChannel); mainLogger.info('Release channel:', releaseChannel);
console.log('Is beta version:', isBetaVersion); mainLogger.info('Is beta version:', isBetaVersion);
console.log('Is alpha version:', isAlphaVersion); mainLogger.info('Is alpha version:', isAlphaVersion);
console.log('Is not configured:', isNotConfigured); mainLogger.info('Is not configured:', isNotConfigured);
if (isNotConfigured) { if (isNotConfigured) {
console.log('Release channel not configured, setting default channel'); mainLogger.info('Release channel not configured, setting default channel');
const defaultChannel = isAlphaVersion ? 'alpha' : isBetaVersion ? 'beta' : 'latest'; const defaultChannel = isAlphaVersion ? 'alpha' : isBetaVersion ? 'beta' : 'latest';
store.set('release_channel', defaultChannel); store.set('release_channel', defaultChannel);
releaseChannel = defaultChannel; releaseChannel = defaultChannel;
@@ -122,17 +190,37 @@ function configureAndGetUpdater(): UpdaterInstance {
if (effectiveChannel === 'beta') { if (effectiveChannel === 'beta') {
autoUpdater.channel = 'beta'; autoUpdater.channel = 'beta';
autoUpdater.allowDowngrade = true;
autoUpdater.allowPrerelease = true; autoUpdater.allowPrerelease = true;
autoUpdater.disableDifferentialDownload = true; autoUpdater.disableDifferentialDownload = true;
} else { } else {
autoUpdater.channel = 'latest'; autoUpdater.channel = 'latest';
autoUpdater.allowDowngrade = true;
autoUpdater.allowPrerelease = false; autoUpdater.allowPrerelease = false;
} }
return autoUpdater; 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 { function createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdater {
if (isMacOS()) { if (isMacOS()) {
return new MacUpdater(ALPHA_UPDATER_CONFIG); return new MacUpdater(ALPHA_UPDATER_CONFIG);
@@ -148,7 +236,7 @@ function createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdate
protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]); protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]);
process.on('uncaughtException', (error: any) => { process.on('uncaughtException', (error: any) => {
console.error('Error in main process', error); mainLogger.error('Uncaught exception in main process', error);
}); });
if (store.get('ignore_ssl')) { if (store.get('ignore_ssl')) {
@@ -434,18 +522,29 @@ async function createWindow(first = true): Promise<void> {
'app-check-for-updates', 'app-check-for-updates',
async (): Promise<{ updateAvailable: boolean; version?: string }> => { async (): Promise<{ updateAvailable: boolean; version?: string }> => {
if (disableAutoUpdates()) { if (disableAutoUpdates()) {
console.log('Auto updates are disabled'); mainLogger.info('Auto updates are disabled');
return { updateAvailable: false }; return { updateAvailable: false };
} }
try { try {
console.log('Checking for updates'); mainLogger.info('Checking for updates');
const updater = configureAndGetUpdater(); const effectiveChannel = store.get('release_channel') as string;
const result = await updater.checkForUpdates(); 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; const updateAvailable = result?.isUpdateAvailable ?? false;
console.log('Update available:', updateAvailable); mainLogger.info('Update available:', updateAvailable);
if (updateAvailable && store.get('disable_auto_updates') !== true) { if (updateAvailable && store.get('disable_auto_updates') !== true) {
console.log('Downloading update'); mainLogger.info('Downloading update');
updater.downloadUpdate(); updater.downloadUpdate();
} }
@@ -454,7 +553,7 @@ async function createWindow(first = true): Promise<void> {
version: result?.updateInfo?.version, version: result?.updateInfo?.version,
}; };
} catch { } catch {
console.log('Error checking for updates'); mainLogger.error('Error checking for updates');
return { updateAvailable: false }; return { updateAvailable: false };
} }
}, },
+36
View File
@@ -0,0 +1,36 @@
const pad = (n: number) => String(n).padStart(2, '0');
const timestamp = () => {
const d = new Date();
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
const format = (level: string, message: string, ...args: unknown[]) => {
const prefix = `[${timestamp()}] [${level}] ${message}`;
if (args.length > 0) {
console.log(prefix, ...args);
} else {
console.log(prefix);
}
};
export const mainLogger = {
debug: (message: string, ...args: unknown[]) => format('DEBUG', message, ...args),
error: (message: string, ...args: unknown[]) => {
const prefix = `[${timestamp()}] [ERROR] ${message}`;
if (args.length > 0) {
console.error(prefix, ...args);
} else {
console.error(prefix);
}
},
info: (message: string, ...args: unknown[]) => format('INFO', message, ...args),
warn: (message: string, ...args: unknown[]) => {
const prefix = `[${timestamp()}] [WARN] ${message}`;
if (args.length > 0) {
console.warn(prefix, ...args);
} else {
console.warn(prefix);
}
},
};
+59 -87
View File
@@ -4,7 +4,6 @@ import { immer } from 'zustand/middleware/immer';
import { createWithEqualityFn } from 'zustand/traditional'; import { createWithEqualityFn } from 'zustand/traditional';
import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/shared/types/remote-types'; import { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/shared/types/remote-types';
@@ -42,7 +41,7 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
immer((set, get) => ({ immer((set, get) => ({
actions: { actions: {
reconnect: async () => { reconnect: async () => {
logFn.debug(logMsg[LogCategory.REMOTE].reconnectInitiated, { logFn.debug('Reconnect initiated', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
}); });
const existing = get().socket; const existing = get().socket;
@@ -52,7 +51,7 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.OPEN ||
existing.readyState === WebSocket.CONNECTING existing.readyState === WebSocket.CONNECTING
) { ) {
logFn.debug(logMsg[LogCategory.REMOTE].closingExistingSocket, { logFn.debug('Closing existing socket', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { readyState: existing.readyState }, meta: { readyState: existing.readyState },
}); });
@@ -64,17 +63,17 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
let authHeader: string | undefined; let authHeader: string | undefined;
try { try {
logFn.debug(logMsg[LogCategory.REMOTE].fetchingCredentials, { logFn.debug('Fetching credentials', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
}); });
const credentials = await fetch('/credentials'); const credentials = await fetch('/credentials');
authHeader = await credentials.text(); authHeader = await credentials.text();
logFn.debug(logMsg[LogCategory.REMOTE].credentialsFetched, { logFn.debug('Credentials fetched', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { hasAuthHeader: !!authHeader }, meta: { hasAuthHeader: !!authHeader },
}); });
} catch (error) { } catch (error) {
logFn.error(logMsg[LogCategory.REMOTE].failedToGetCredentials, { logFn.error('Failed to get credentials', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { error }, meta: { error },
}); });
@@ -82,7 +81,7 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
set((state) => { set((state) => {
const wsUrl = location.href.replace('http', 'ws'); const wsUrl = location.href.replace('http', 'ws');
logFn.debug(logMsg[LogCategory.REMOTE].creatingWebSocket, { logFn.debug('Creating new WebSocket', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { url: wsUrl }, meta: { url: wsUrl },
}); });
@@ -93,34 +92,28 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
socket.addEventListener('message', (message) => { socket.addEventListener('message', (message) => {
const { data, event } = JSON.parse(message.data) as ServerEvent; const { data, event } = JSON.parse(message.data) as ServerEvent;
logFn.debug(logMsg[LogCategory.REMOTE].webSocketMessageReceived, { logFn.debug('WebSocket message received', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { data, event }, meta: { data, event },
}); });
switch (event) { switch (event) {
case 'error': { case 'error': {
logFn.error( logFn.error('WebSocket error event', {
logMsg[LogCategory.REMOTE].webSocketErrorEvent, category: LogCategory.REMOTE,
{ meta: { data },
category: LogCategory.REMOTE, });
meta: { data },
},
);
toast.error({ message: data, title: 'Socket error' }); toast.error({ message: data, title: 'Socket error' });
break; break;
} }
case 'favorite': { case 'favorite': {
logFn.debug( logFn.debug('Favorite event received', {
logMsg[LogCategory.REMOTE].favoriteEventReceived, category: LogCategory.REMOTE,
{ meta: {
category: LogCategory.REMOTE, favorite: data.favorite,
meta: { id: data.id,
favorite: data.favorite,
id: data.id,
},
}, },
); });
set((state) => { set((state) => {
if (state.info.song?.id === data.id) { if (state.info.song?.id === data.id) {
state.info.song.userFavorite = data.favorite; state.info.song.userFavorite = data.favorite;
@@ -129,33 +122,27 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
break; break;
} }
case 'playback': { case 'playback': {
logFn.debug( logFn.debug('Playback event received', {
logMsg[LogCategory.REMOTE].playbackEventReceived, category: LogCategory.REMOTE,
{ meta: { status: data },
category: LogCategory.REMOTE, });
meta: { status: data },
},
);
set((state) => { set((state) => {
state.info.status = data; state.info.status = data;
}); });
break; break;
} }
case 'position': { case 'position': {
logFn.debug( logFn.debug('Position event received', {
logMsg[LogCategory.REMOTE].positionEventReceived, category: LogCategory.REMOTE,
{ meta: { position: data },
category: LogCategory.REMOTE, });
meta: { position: data },
},
);
set((state) => { set((state) => {
state.info.position = data; state.info.position = data;
}); });
break; break;
} }
case 'proxy': { case 'proxy': {
logFn.debug(logMsg[LogCategory.REMOTE].proxyEventReceived, { logFn.debug('Proxy event received (image update)', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { meta: {
dataLength: data?.length, dataLength: data?.length,
@@ -170,16 +157,13 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
break; break;
} }
case 'rating': { case 'rating': {
logFn.debug( logFn.debug('Rating event received', {
logMsg[LogCategory.REMOTE].ratingEventReceived, category: LogCategory.REMOTE,
{ meta: {
category: LogCategory.REMOTE, id: data.id,
meta: { rating: data.rating,
id: data.id,
rating: data.rating,
},
}, },
); });
set((state) => { set((state) => {
if (state.info.song?.id === data.id) { if (state.info.song?.id === data.id) {
state.info.song.userRating = data.rating; state.info.song.userRating = data.rating;
@@ -188,33 +172,27 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
break; break;
} }
case 'repeat': { case 'repeat': {
logFn.debug( logFn.debug('Repeat event received', {
logMsg[LogCategory.REMOTE].repeatEventReceived, category: LogCategory.REMOTE,
{ meta: { repeat: data },
category: LogCategory.REMOTE, });
meta: { repeat: data },
},
);
set((state) => { set((state) => {
state.info.repeat = data; state.info.repeat = data;
}); });
break; break;
} }
case 'shuffle': { case 'shuffle': {
logFn.debug( logFn.debug('Shuffle event received', {
logMsg[LogCategory.REMOTE].shuffleEventReceived, category: LogCategory.REMOTE,
{ meta: { shuffle: data },
category: LogCategory.REMOTE, });
meta: { shuffle: data },
},
);
set((state) => { set((state) => {
state.info.shuffle = data; state.info.shuffle = data;
}); });
break; break;
} }
case 'song': { case 'song': {
logFn.debug(logMsg[LogCategory.REMOTE].songEventReceived, { logFn.debug('Song event received', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { meta: {
artistName: data?.artistName, artistName: data?.artistName,
@@ -228,7 +206,7 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
break; break;
} }
case 'state': { case 'state': {
logFn.debug(logMsg[LogCategory.REMOTE].stateEventReceived, { logFn.debug('State event received (full state update)', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { meta: {
hasSong: !!data.song, hasSong: !!data.song,
@@ -243,13 +221,10 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
break; break;
} }
case 'volume': { case 'volume': {
logFn.debug( logFn.debug('Volume event received', {
logMsg[LogCategory.REMOTE].volumeEventReceived, category: LogCategory.REMOTE,
{ meta: { volume: data },
category: LogCategory.REMOTE, });
meta: { volume: data },
},
);
set((state) => { set((state) => {
state.info.volume = data; state.info.volume = data;
}); });
@@ -258,7 +233,7 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
}); });
socket.addEventListener('open', () => { socket.addEventListener('open', () => {
logFn.debug(logMsg[LogCategory.REMOTE].webSocketOpened, { logFn.debug('WebSocket opened', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { meta: {
hasAuthHeader: !!authHeader, hasAuthHeader: !!authHeader,
@@ -266,7 +241,7 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
}, },
}); });
if (authHeader) { if (authHeader) {
logFn.debug(logMsg[LogCategory.REMOTE].sendingAuthentication, { logFn.debug('Sending authentication', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
}); });
socket.send( socket.send(
@@ -280,7 +255,7 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
}); });
socket.addEventListener('close', (reason) => { socket.addEventListener('close', (reason) => {
logFn.debug(logMsg[LogCategory.REMOTE].webSocketClosed, { logFn.debug('WebSocket closed', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { meta: {
code: reason.code, code: reason.code,
@@ -290,13 +265,13 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
}, },
}); });
if (reason.code === 4002 || reason.code === 4003) { if (reason.code === 4002 || reason.code === 4003) {
logFn.debug(logMsg[LogCategory.REMOTE].reloadingPage, { logFn.debug('Reloading page due to close code', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { code: reason.code }, meta: { code: reason.code },
}); });
location.reload(); location.reload();
} else if (reason.code === 4000) { } else if (reason.code === 4000) {
logFn.warn(logMsg[LogCategory.REMOTE].serverIsDown, { logFn.warn('Server is down', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
}); });
toast.warn({ toast.warn({
@@ -304,16 +279,13 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
title: 'Connection closed', title: 'Connection closed',
}); });
} else if (reason.code !== 4001 && !socket.natural) { } else if (reason.code !== 4001 && !socket.natural) {
logFn.error( logFn.error('Socket closed unexpectedly', {
logMsg[LogCategory.REMOTE].socketClosedUnexpectedly, category: LogCategory.REMOTE,
{ meta: {
category: LogCategory.REMOTE, code: reason.code,
meta: { reason: reason.reason,
code: reason.code,
reason: reason.reason,
},
}, },
); });
toast.error({ toast.error({
message: 'Socket closed for unexpected reason', message: 'Socket closed for unexpected reason',
title: 'Connection closed', title: 'Connection closed',
@@ -331,7 +303,7 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
send: (data: ClientEvent) => { send: (data: ClientEvent) => {
const socket = get().socket; const socket = get().socket;
if (socket) { if (socket) {
logFn.debug(logMsg[LogCategory.REMOTE].sendingEventToServer, { logFn.debug('Sending event to server', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { meta: {
data: data, data: data,
@@ -341,7 +313,7 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
}); });
socket.send(JSON.stringify(data)); socket.send(JSON.stringify(data));
} else { } else {
logFn.warn(logMsg[LogCategory.REMOTE].cannotSendEvent, { logFn.warn('Cannot send event - socket not available', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { event: data.event }, meta: { event: data.event },
}); });
+10
View File
@@ -4,6 +4,7 @@ import { NavidromeController } from '/@/renderer/api/navidrome/navidrome-control
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
import { mergeMusicFolderId } from '/@/renderer/api/utils-music-folder'; import { mergeMusicFolderId } from '/@/renderer/api/utils-music-folder';
import { getServerById, useAuthStore, useSettingsStore } from '/@/renderer/store'; import { getServerById, useAuthStore, useSettingsStore } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { import {
AuthenticationResponse, AuthenticationResponse,
@@ -31,6 +32,7 @@ const apiController = <K extends keyof ControllerEndpoint>(
const serverType = type || useAuthStore.getState().currentServer?.type; const serverType = type || useAuthStore.getState().currentServer?.type;
if (!serverType) { if (!serverType) {
logFn.warn('No server selected', { category: LogCategory.API });
toast.error({ toast.error({
message: i18n.t('error.serverNotSelectedError', { message: i18n.t('error.serverNotSelectedError', {
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
@@ -43,6 +45,10 @@ const apiController = <K extends keyof ControllerEndpoint>(
const controllerFn = endpoints?.[serverType]?.[endpoint]; const controllerFn = endpoints?.[serverType]?.[endpoint];
if (typeof controllerFn !== 'function') { if (typeof controllerFn !== 'function') {
logFn.warn('Endpoint not implemented', {
category: LogCategory.API,
meta: { endpoint, serverType },
});
toast.error({ toast.error({
message: `Endpoint ${endpoint} is not implemented for ${serverType}`, message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
title: i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' }) as string, title: i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' }) as string,
@@ -57,6 +63,10 @@ const apiController = <K extends keyof ControllerEndpoint>(
); );
} }
logFn.debug('API controller call', {
category: LogCategory.API,
meta: { endpoint, serverType },
});
return controllerFn; return controllerFn;
}; };
+17 -3
View File
@@ -8,6 +8,7 @@ import qs from 'qs';
import i18n from '/@/i18n/i18n'; import i18n from '/@/i18n/i18n';
import { authenticationFailure } from '/@/renderer/api/utils'; import { authenticationFailure } from '/@/renderer/api/utils';
import { useAuthStore } from '/@/renderer/store'; import { useAuthStore } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
import { ndType } from '/@/shared/api/navidrome/navidrome-types'; import { ndType } from '/@/shared/api/navidrome/navidrome-types';
import { resultWithHeaders } from '/@/shared/api/utils'; import { resultWithHeaders } from '/@/shared/api/utils';
@@ -367,11 +368,21 @@ axiosClient.interceptors.response.use(
}) })
.catch((newError: any) => { .catch((newError: any) => {
if (newError !== TIMEOUT_ERROR) { if (newError !== TIMEOUT_ERROR) {
console.error('Error when trying to reauthenticate: ', newError); logFn.error('Reauthentication failed', {
category: LogCategory.API,
meta: {
message: (newError as Error)?.message,
serverId: currentServer.id,
},
});
if (isAxiosError(newError) && newError.code === 'ERR_NETWORK') { if (isAxiosError(newError) && newError.code === 'ERR_NETWORK') {
console.log( logFn.info(
'Network error during reauthentication - preserving credentials', 'Network error during reauthentication - preserving credentials',
{
category: LogCategory.API,
meta: { serverId: currentServer.id },
},
); );
} else { } else {
limitedFail(currentServer); limitedFail(currentServer);
@@ -387,7 +398,10 @@ axiosClient.interceptors.response.use(
} }
if (isAxiosError(error) && error.code === 'ERR_NETWORK') { if (isAxiosError(error) && error.code === 'ERR_NETWORK') {
console.log('Network error during authentication - preserving credentials'); logFn.info('Network error during authentication - preserving credentials', {
category: LogCategory.API,
meta: { serverId: useAuthStore.getState().currentServer?.id },
});
} else { } else {
limitedFail(currentServer); limitedFail(currentServer);
} }
@@ -38,6 +38,7 @@ const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefin
[AlbumListSort.DURATION]: undefined, [AlbumListSort.DURATION]: undefined,
[AlbumListSort.EXPLICIT_STATUS]: undefined, [AlbumListSort.EXPLICIT_STATUS]: undefined,
[AlbumListSort.FAVORITED]: AlbumListSortType.STARRED, [AlbumListSort.FAVORITED]: AlbumListSortType.STARRED,
[AlbumListSort.ID]: undefined,
[AlbumListSort.NAME]: AlbumListSortType.ALPHABETICAL_BY_NAME, [AlbumListSort.NAME]: AlbumListSortType.ALPHABETICAL_BY_NAME,
[AlbumListSort.PLAY_COUNT]: AlbumListSortType.FREQUENT, [AlbumListSort.PLAY_COUNT]: AlbumListSortType.FREQUENT,
[AlbumListSort.RANDOM]: AlbumListSortType.RANDOM, [AlbumListSort.RANDOM]: AlbumListSortType.RANDOM,
+5 -2
View File
@@ -1,16 +1,19 @@
import { useAuthStore } from '/@/renderer/store'; import { useAuthStore } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { ServerListItem } from '/@/shared/types/types'; import { ServerListItem } from '/@/shared/types/types';
export const authenticationFailure = (currentServer: null | ServerListItem) => { export const authenticationFailure = (currentServer: null | ServerListItem) => {
logFn.error('Token expired', {
category: LogCategory.API,
meta: { serverId: currentServer?.id },
});
toast.error({ toast.error({
message: 'Your session has expired.', message: 'Your session has expired.',
}); });
if (currentServer) { if (currentServer) {
const serverId = currentServer.id; const serverId = currentServer.id;
const token = currentServer.ndCredential;
console.error(`token is expired: ${token}`);
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined }); useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
useAuthStore.getState().actions.setCurrentServer(null); useAuthStore.getState().actions.setCurrentServer(null);
} }
@@ -244,8 +244,6 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
const playType = (meta?.playType as Play) || Play.NOW; const playType = (meta?.playType as Play) || Play.NOW;
const singleSongOnly = meta?.singleSongOnly === true; 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[]; let songsToAdd: Song[];
if ( if (
singleSongOnly || singleSongOnly ||
@@ -56,6 +56,7 @@
.tracks-table-header { .tracks-table-header {
display: flex; display: flex;
flex-shrink: 0;
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: center; align-items: center;
width: 100%; width: 100%;
@@ -80,12 +81,14 @@
.track-header-cell { .track-header-cell {
position: relative; position: relative;
display: flex; display: flex;
flex-wrap: nowrap;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
min-height: 60%; min-height: 60%;
padding-right: var(--theme-spacing-sm); padding-right: var(--theme-spacing-sm);
padding-left: var(--theme-spacing-sm); padding-left: var(--theme-spacing-sm);
overflow: visible; overflow: visible;
white-space: nowrap;
} }
.track-header-cell-no-h-padding { .track-header-cell-no-h-padding {
@@ -193,6 +196,17 @@
min-width: 0; 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 { .image-wrapper {
position: relative; position: relative;
display: block; 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 { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
import { ItemImage } from '/@/renderer/components/item-image/item-image'; 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 { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import { import {
ItemListStateActions, ItemListStateActions,
ItemListStateItemWithRequiredProperties, ItemListStateItemWithRequiredProperties,
useItemDraggingState,
useItemListState, useItemListState,
useItemSelectionState, useItemSelectionState,
} from '/@/renderer/components/item-list/helpers/item-list-state'; } from '/@/renderer/components/item-list/helpers/item-list-state';
import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns'; 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 { getDetailListCellComponent } from '/@/renderer/components/item-list/item-detail-list/columns';
import { import {
getTrackColumnFixed, 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 { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useSettingsStore, useShowRatings } from '/@/renderer/store'; import { useSettingsStore, useShowRatings } from '/@/renderer/store';
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils'; 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 { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { useDoubleClick } from '/@/shared/hooks/use-double-click'; 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 { Album, LibraryItem, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop'; import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
import { ItemListKey, Play, TableColumn } from '/@/shared/types/types'; import { ItemListKey, Play, TableColumn } from '/@/shared/types/types';
@@ -84,6 +90,7 @@ interface ItemDetailListProps {
internalState?: ItemListStateActions; internalState?: ItemListStateActions;
itemCount?: number; itemCount?: number;
items?: unknown[]; items?: unknown[];
listKey?: ItemListKey;
onColumnReordered?: ( onColumnReordered?: (
columnIdFrom: TableColumn, columnIdFrom: TableColumn,
columnIdTo: TableColumn, columnIdTo: TableColumn,
@@ -92,8 +99,15 @@ interface ItemDetailListProps {
onColumnResized?: (columnId: TableColumn, width: number) => void; onColumnResized?: (columnId: TableColumn, width: number) => void;
onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => Promise<void> | void; onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => Promise<void> | void;
onScrollEnd?: (rowIndex: number) => void; onScrollEnd?: (rowIndex: number) => void;
onSongRowDoubleClick?: (params: {
index: number;
internalState: ItemListStateActions;
item: Song;
}) => void;
overrideControls?: Partial<ItemControls>;
rowHeight?: number; rowHeight?: number;
scrollOffset?: number; scrollOffset?: number;
songsByAlbumId?: Record<string, Song[]>;
tableId?: string; tableId?: string;
} }
@@ -109,7 +123,13 @@ interface RowData {
getItem?: (index: number) => unknown; getItem?: (index: number) => unknown;
internalState: ItemListStateActions; internalState: ItemListStateActions;
isMutatingFavorite: boolean; isMutatingFavorite: boolean;
onSongRowDoubleClick?: (params: {
index: number;
internalState: ItemListStateActions;
item: Song;
}) => void;
registerSongs: (albumId: string, songs: Song[]) => void; registerSongs: (albumId: string, songs: Song[]) => void;
songsByAlbumId?: Record<string, Song[]>;
trackColumns: ItemTableListColumnConfig[]; trackColumns: ItemTableListColumnConfig[];
trackTableSize: 'compact' | 'default' | 'large'; trackTableSize: 'compact' | 'default' | 'large';
} }
@@ -126,6 +146,11 @@ interface TrackRowProps {
internalState: ItemListStateActions; internalState: ItemListStateActions;
isMutatingFavorite: boolean; isMutatingFavorite: boolean;
isSongsLoading?: boolean; isSongsLoading?: boolean;
onSongRowDoubleClick?: (params: {
index: number;
internalState: ItemListStateActions;
item: Song;
}) => void;
rowIndex: number; rowIndex: number;
size: 'compact' | 'default' | 'large'; size: 'compact' | 'default' | 'large';
song: Song; song: Song;
@@ -147,6 +172,7 @@ const TrackRow = memo(
internalState, internalState,
isMutatingFavorite, isMutatingFavorite,
isSongsLoading, isSongsLoading,
onSongRowDoubleClick,
rowIndex, rowIndex,
size, size,
song, song,
@@ -167,11 +193,37 @@ const TrackRow = memo(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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; if (isSongsLoading || albumSongs.length === 0) return;
internalState.setSelected([song]); internalState.setSelected([song]);
playerContext.addToQueueByData(albumSongs, Play.NOW, song.id); playerContext.addToQueueByData(albumSongs, Play.NOW, song.id);
}, },
[albumSongs, internalState, isSongsLoading, playerContext, song], [
albumSongs,
controls,
internalState,
isSongsLoading,
onSongRowDoubleClick,
playerContext,
song,
],
); );
const handleRowClick = useCallback( const handleRowClick = useCallback(
@@ -373,6 +425,61 @@ const MetadataSection = memo(
const [isImageHovered, setIsImageHovered] = useState(false); const [isImageHovered, setIsImageHovered] = useState(false);
const [isMetadataHovered, setIsMetadataHovered] = 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 isFavorite = item.userFavorite ?? false;
const userRating = item.userRating ?? null; const userRating = item.userRating ?? null;
const hasRating = showRatings && userRating !== null && userRating > 0; const hasRating = showRatings && userRating !== null && userRating > 0;
@@ -434,39 +541,48 @@ const MetadataSection = memo(
onMouseEnter={() => setIsMetadataHovered(true)} onMouseEnter={() => setIsMetadataHovered(true)}
onMouseLeave={() => setIsMetadataHovered(false)} onMouseLeave={() => setIsMetadataHovered(false)}
> >
<Link <div
className={styles.imageWrapper} className={clsx(styles.imageWrapperOuter, {
onMouseEnter={() => setIsImageHovered(true)} [styles.imageWrapperDragging]: isDragging,
onMouseLeave={() => setIsImageHovered(false)}
state={{ item }}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: item.id,
})} })}
ref={dragRef ?? undefined}
> >
<ItemImage <Link
className={styles.image} className={styles.imageWrapper}
explicitStatus={item.explicitStatus} draggable={false}
id={item.imageId} onDragStart={handleLinkDragStart}
itemType={item._itemType} onMouseEnter={() => setIsImageHovered(true)}
serverId={item._serverId} onMouseLeave={() => setIsImageHovered(false)}
type="itemCard" state={{ item }}
/> to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
{isFavorite && <div className={styles.favoriteBadge} />} albumId: item.id,
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>} })}
<AnimatePresence> >
{controls && isImageHovered && ( <ItemImage
<ItemCardControls className={styles.image}
controls={controls} explicitStatus={item.explicitStatus}
enableExpansion={false} id={item.imageId}
internalState={internalState} itemType={item._itemType}
item={item} serverId={item._serverId}
itemType={item._itemType} type="itemCard"
showRating={true} />
type="compact" {isFavorite && <div className={styles.favoriteBadge} />}
/> {hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
)} <AnimatePresence>
</AnimatePresence> {controls && isImageHovered && (
</Link> <ItemCardControls
controls={controls}
enableExpansion={false}
internalState={internalState}
item={item}
itemType={item._itemType}
showRating={true}
type="compact"
/>
)}
</AnimatePresence>
</Link>
</div>
<Link <Link
className={styles.title} className={styles.title}
state={{ item }} state={{ item }}
@@ -610,7 +726,9 @@ const RowContent = memo(
index, index,
internalState, internalState,
isMutatingFavorite, isMutatingFavorite,
onSongRowDoubleClick,
registerSongs, registerSongs,
songsByAlbumId,
trackColumns, trackColumns,
trackTableSize, trackTableSize,
}: RowContentProps) => { }: RowContentProps) => {
@@ -622,8 +740,10 @@ const RowContent = memo(
return (data?.[index] as Album | undefined) || undefined; return (data?.[index] as Album | undefined) || undefined;
}, [data, getItem, index]); }, [data, getItem, index]);
const useClientSideSongs = Boolean(songsByAlbumId);
const songListQuery = useMemo(() => { const songListQuery = useMemo(() => {
if (!item?.id || !item?._serverId) return null; if (useClientSideSongs || !item?.id || !item?._serverId) return null;
return { return {
query: { query: {
albumIds: [item.id], albumIds: [item.id],
@@ -634,7 +754,7 @@ const RowContent = memo(
}, },
serverId: item?._serverId || '', serverId: item?._serverId || '',
}; };
}, [item]); }, [item, useClientSideSongs]);
const { data: songListData, isLoading: isSongsQueryLoading } = useQuery({ const { data: songListData, isLoading: isSongsQueryLoading } = useQuery({
enabled: !!songListQuery, enabled: !!songListQuery,
@@ -646,8 +766,17 @@ const RowContent = memo(
}), }),
}); });
const songItems = songListData?.items; const songItemsFromQuery = songListData?.items;
const isSongsLoading = !!item && isSongsQueryLoading && !songItems?.length; 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(() => { const songs = useMemo(() => {
return ( return (
@@ -705,6 +834,7 @@ const RowContent = memo(
isMutatingFavorite={isMutatingFavorite} isMutatingFavorite={isMutatingFavorite}
isSongsLoading={isSongsLoading} isSongsLoading={isSongsLoading}
key={song.id} key={song.id}
onSongRowDoubleClick={onSongRowDoubleClick}
rowIndex={rowIndex} rowIndex={rowIndex}
size={trackTableSize} size={trackTableSize}
song={song as Song} song={song as Song}
@@ -729,6 +859,7 @@ const RowContent = memo(
prev.isMutatingFavorite === next.isMutatingFavorite && prev.isMutatingFavorite === next.isMutatingFavorite &&
prev.controls === next.controls && prev.controls === next.controls &&
prev.registerSongs === next.registerSongs && prev.registerSongs === next.registerSongs &&
prev.songsByAlbumId === next.songsByAlbumId &&
prev.trackColumns === next.trackColumns && prev.trackColumns === next.trackColumns &&
prev.trackTableSize === next.trackTableSize, prev.trackTableSize === next.trackTableSize,
); );
@@ -1113,20 +1244,27 @@ export const ItemDetailList = ({
getItem, getItem,
itemCount: externalItemCount, itemCount: externalItemCount,
items, items,
listKey = ItemListKey.ALBUM,
onColumnReordered, onColumnReordered,
onColumnResized, onColumnResized,
onRangeChanged, onRangeChanged,
onScrollEnd, onScrollEnd,
onSongRowDoubleClick,
overrideControls,
songsByAlbumId,
tableId = DEFAULT_DETAIL_TABLE_ID, tableId = DEFAULT_DETAIL_TABLE_ID,
}: ItemDetailListProps) => { }: ItemDetailListProps) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const listRef = useListRef(null); const listRef = useListRef(null);
const { focused, ref: focusRef } = useFocusWithin();
const mergedContainerRef = useMergedRef(containerRef, focusRef);
const lastVisibleStartIndexRef = useRef(0); const lastVisibleStartIndexRef = useRef(0);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const controls = useDefaultItemListControls({ const controls = useDefaultItemListControls({
onColumnReordered, onColumnReordered,
onColumnResized, onColumnResized,
overrides: overrideControls,
}); });
const isMutatingCreateFavorite = useIsMutatingCreateFavorite(); const isMutatingCreateFavorite = useIsMutatingCreateFavorite();
const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite(); const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();
@@ -1172,7 +1310,7 @@ export const ItemDetailList = ({
const internalState = useItemListState(getDataFn, extractRowIdSong); 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 trackColumns = useMemo((): ItemTableListColumnConfig[] => {
const raw = tableConfig?.columns; const raw = tableConfig?.columns;
if (raw && raw.length > 0) { if (raw && raw.length > 0) {
@@ -1263,8 +1401,10 @@ export const ItemDetailList = ({
getItem, getItem,
internalState, internalState,
isMutatingFavorite, isMutatingFavorite,
onSongRowDoubleClick,
queryClient, queryClient,
registerSongs, registerSongs,
songsByAlbumId,
trackColumns, trackColumns,
trackTableSize, trackTableSize,
}), }),
@@ -1279,8 +1419,10 @@ export const ItemDetailList = ({
getItem, getItem,
internalState, internalState,
isMutatingFavorite, isMutatingFavorite,
onSongRowDoubleClick,
queryClient, queryClient,
registerSongs, registerSongs,
songsByAlbumId,
trackColumns, trackColumns,
trackTableSize, trackTableSize,
], ],
@@ -1307,6 +1449,13 @@ export const ItemDetailList = ({
}, },
}); });
useListHotkeys({
controls,
focused,
internalState,
itemType: LibraryItem.SONG,
});
useEffect(() => { useEffect(() => {
const { current: container } = containerRef; const { current: container } = containerRef;
@@ -1363,7 +1512,7 @@ export const ItemDetailList = ({
trackTableSize={trackTableSize} trackTableSize={trackTableSize}
/> />
)} )}
<div className={styles.container} ref={containerRef}> <div className={styles.container} ref={mergedContainerRef}>
<List <List
listRef={listRef} listRef={listRef}
onRowsRendered={throttledHandleRowsRendered} onRowsRendered={throttledHandleRowsRendered}
@@ -2,8 +2,8 @@ import { TableColumn } from '/@/shared/types/types';
const FIXED_TRACK_COLUMN_WIDTHS: Partial<Record<TableColumn, number>> = { const FIXED_TRACK_COLUMN_WIDTHS: Partial<Record<TableColumn, number>> = {
[TableColumn.ACTIONS]: 32, [TableColumn.ACTIONS]: 32,
[TableColumn.BIT_DEPTH]: 80, [TableColumn.BIT_DEPTH]: 88,
[TableColumn.BIT_RATE]: 80, [TableColumn.BIT_RATE]: 88,
[TableColumn.BPM]: 56, [TableColumn.BPM]: 56,
[TableColumn.CHANNELS]: 80, [TableColumn.CHANNELS]: 80,
[TableColumn.CODEC]: 80, [TableColumn.CODEC]: 80,
@@ -11,8 +11,8 @@ const FIXED_TRACK_COLUMN_WIDTHS: Partial<Record<TableColumn, number>> = {
[TableColumn.DISC_NUMBER]: 36, [TableColumn.DISC_NUMBER]: 36,
[TableColumn.DURATION]: 72, [TableColumn.DURATION]: 72,
[TableColumn.RELEASE_DATE]: 128, [TableColumn.RELEASE_DATE]: 128,
[TableColumn.SAMPLE_RATE]: 90, [TableColumn.SAMPLE_RATE]: 112,
[TableColumn.TRACK_NUMBER]: 56, [TableColumn.TRACK_NUMBER]: 64,
[TableColumn.USER_FAVORITE]: 32, [TableColumn.USER_FAVORITE]: 32,
[TableColumn.USER_RATING]: 64, [TableColumn.USER_RATING]: 64,
[TableColumn.YEAR]: 56, [TableColumn.YEAR]: 56,
@@ -60,6 +60,6 @@ export function shouldShowHoverOnlyColumnContent(
return ( return (
isRowHovered || isRowHovered ||
(columnId === TableColumn.USER_FAVORITE && song.userFavorite !== false) || (columnId === TableColumn.USER_FAVORITE && song.userFavorite !== false) ||
(columnId === TableColumn.USER_RATING && song.userRating != null) (columnId === TableColumn.USER_RATING && song.userRating !== null && song.userRating !== 0)
); );
} }
+6
View File
@@ -1,16 +1,22 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import { LibraryItem } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
export type ListDisplayMode = LibraryItem.ALBUM | LibraryItem.SONG;
interface ListContextProps { interface ListContextProps {
customFilters?: Record<string, unknown>; customFilters?: Record<string, unknown>;
displayMode?: ListDisplayMode;
id?: string; id?: string;
isSidebarOpen?: boolean; isSidebarOpen?: boolean;
isSmartPlaylist?: boolean; isSmartPlaylist?: boolean;
itemCount?: number; itemCount?: number;
listData?: unknown[]; listData?: unknown[];
listKey?: ItemListKey;
mode?: 'edit' | 'view'; mode?: 'edit' | 'view';
pageKey: ItemListKey | string; pageKey: ItemListKey | string;
setDisplayMode?: (displayMode: ListDisplayMode) => void;
setIsSidebarOpen?: (isSidebarOpen: boolean) => void; setIsSidebarOpen?: (isSidebarOpen: boolean) => void;
setItemCount?: (itemCount: number) => void; setItemCount?: (itemCount: number) => void;
setListData?: (items: unknown[]) => void; setListData?: (items: unknown[]) => void;
@@ -17,7 +17,6 @@ import {
useSettingsStore, useSettingsStore,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { LyricSource, ServerType } from '/@/shared/types/domain-types'; import { LyricSource, ServerType } from '/@/shared/types/domain-types';
import { FontType, Platform, PlayerStyle, PlayerType } from '/@/shared/types/types'; import { FontType, Platform, PlayerStyle, PlayerType } from '/@/shared/types/types';
@@ -270,7 +269,7 @@ export const useAppTracker = () => {
if (lastTrackedDate !== todayUTC) { if (lastTrackedDate !== todayUTC) {
appTrackerInFlight = true; appTrackerInFlight = true;
const properties = getProperties(); const properties = getProperties();
logFn.info(logMsg[LogCategory.ANALYTICS].appTracked, { logFn.info('Analytics sent', {
category: LogCategory.ANALYTICS, category: LogCategory.ANALYTICS,
meta: { properties, todayUTC }, meta: { properties, todayUTC },
}); });
@@ -290,7 +289,7 @@ export const useAppTracker = () => {
appTrackerLastSentDate = utcDate; appTrackerLastSentDate = utcDate;
localStorage.setItem('analytics_app_tracker_timestamp', utcDate); localStorage.setItem('analytics_app_tracker_timestamp', utcDate);
logFn.debug(logMsg[LogCategory.ANALYTICS].appTracked, { logFn.debug('Analytics sent', {
category: LogCategory.ANALYTICS, category: LogCategory.ANALYTICS,
meta: { properties }, meta: { properties },
}); });
@@ -5,7 +5,6 @@ import { useLocation } from 'react-router';
import { isAnalyticsDisabled } from '/@/renderer/features/analytics/hooks/use-analytics-disabled'; import { isAnalyticsDisabled } from '/@/renderer/features/analytics/hooks/use-analytics-disabled';
import { getRoutePattern } from '/@/renderer/features/analytics/utils/get-route-pattern'; import { getRoutePattern } from '/@/renderer/features/analytics/utils/get-route-pattern';
import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
const trackPageView = (routePattern: string) => { const trackPageView = (routePattern: string) => {
window.umami?.track((props) => ({ window.umami?.track((props) => ({
@@ -28,7 +27,7 @@ export const usePageTracker = () => {
trackPageViewMutation(routePattern, { trackPageViewMutation(routePattern, {
onSettled: () => { onSettled: () => {
logFn.debug(logMsg[LogCategory.ANALYTICS].pageViewTracked, { logFn.debug('Page view tracked', {
category: LogCategory.ANALYTICS, category: LogCategory.ANALYTICS,
meta: { route: routePattern }, meta: { route: routePattern },
}); });
@@ -345,8 +345,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
openContextModal({ openContextModal({
innerProps: { innerProps: {
itemIds: items, ...modalProps,
resourceType: itemType,
}, },
modalKey: 'addToPlaylist', modalKey: 'addToPlaylist',
size: 'lg', size: 'lg',
@@ -21,7 +21,6 @@ import {
} from '/@/renderer/store'; } from '/@/renderer/store';
import { sentenceCase } from '/@/renderer/utils'; import { sentenceCase } from '/@/renderer/utils';
import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types'; import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
import { PlayerStatus } from '/@/shared/types/types'; import { PlayerStatus } from '/@/shared/types/types';
@@ -90,7 +89,7 @@ export const useDiscordRpc = () => {
reason = 'paused_with_show_paused_disabled'; reason = 'paused_with_show_paused_disabled';
} }
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcActivityCleared, { logFn.debug('Activity was cleared for Discord RPC', {
category: LogCategory.EXTERNAL, category: LogCategory.EXTERNAL,
meta: { meta: {
reason, reason,
@@ -109,8 +108,18 @@ export const useDiscordRpc = () => {
instance: false, instance: false,
largeImageKey: 'icon', largeImageKey: 'icon',
largeImageText: truncate(stationName || 'Radio'), largeImageText: truncate(stationName || 'Radio'),
smallImageKey: current[2] === PlayerStatus.PLAYING ? 'playing' : 'paused', smallImageKey:
smallImageText: sentenceCase(current[2]), 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), state: truncate(artist),
statusDisplayType: StatusDisplayType.STATE, statusDisplayType: StatusDisplayType.STATE,
type: discordSettings.showAsListening ? 2 : 0, type: discordSettings.showAsListening ? 2 : 0,
@@ -118,7 +127,7 @@ export const useDiscordRpc = () => {
const isConnected = await discordRpc?.isConnected(); const isConnected = await discordRpc?.isConnected();
if (!isConnected) { if (!isConnected) {
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcInitialized, { logFn.debug('Discord RPC was initialized', {
category: LogCategory.EXTERNAL, category: LogCategory.EXTERNAL,
meta: { clientId: discordSettings.clientId }, meta: { clientId: discordSettings.clientId },
}); });
@@ -126,7 +135,7 @@ export const useDiscordRpc = () => {
await discordRpc?.initialize(discordSettings.clientId); await discordRpc?.initialize(discordSettings.clientId);
} }
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcSetActivity, { logFn.debug('Activity was set for Discord RPC', {
category: LogCategory.EXTERNAL, category: LogCategory.EXTERNAL,
meta: { meta: {
currentStatus: current[2], currentStatus: current[2],
@@ -158,7 +167,7 @@ export const useDiscordRpc = () => {
current[2] !== previous[2] current[2] !== previous[2]
) { ) {
if (trackChangedByState || trackChanged) { if (trackChangedByState || trackChanged) {
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcTrackChanged, { logFn.debug('Track was changed for Discord RPC', {
category: LogCategory.EXTERNAL, category: LogCategory.EXTERNAL,
meta: { meta: {
artistName: song.artists?.[0]?.name, artistName: song.artists?.[0]?.name,
@@ -199,7 +208,7 @@ export const useDiscordRpc = () => {
(song?.album && song.album.padEnd(2, ' ')) || 'Unknown album', (song?.album && song.album.padEnd(2, ' ')) || 'Unknown album',
), ),
smallImageKey: undefined, smallImageKey: undefined,
smallImageText: sentenceCase(current[2]), smallImageText: undefined,
state: truncate((artists && artists.padEnd(2, ' ')) || 'Unknown artist'), state: truncate((artists && artists.padEnd(2, ' ')) || 'Unknown artist'),
statusDisplayType: statusDisplayMap[discordSettings.displayType], statusDisplayType: statusDisplayMap[discordSettings.displayType],
// I would love to use the actual type as opposed to hardcoding to 2, // I would love to use the actual type as opposed to hardcoding to 2,
@@ -247,9 +256,13 @@ export const useDiscordRpc = () => {
activity.endTimestamp = end; activity.endTimestamp = end;
} }
activity.smallImageKey = 'playing'; if (discordSettings.showStateIcon) {
activity.smallImageKey = 'playing';
activity.smallImageText = sentenceCase(current[2]);
}
} else { } else {
activity.smallImageKey = 'paused'; activity.smallImageKey = 'paused';
activity.smallImageText = sentenceCase(current[2]);
} }
if (discordSettings.showServerImage && song) { if (discordSettings.showServerImage && song) {
@@ -301,7 +314,7 @@ export const useDiscordRpc = () => {
// Initialize if needed // Initialize if needed
const isConnected = await discordRpc?.isConnected(); const isConnected = await discordRpc?.isConnected();
if (!isConnected) { if (!isConnected) {
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcInitialized, { logFn.debug('Discord RPC was initialized', {
category: LogCategory.EXTERNAL, category: LogCategory.EXTERNAL,
meta: { meta: {
clientId: discordSettings.clientId, clientId: discordSettings.clientId,
@@ -313,7 +326,7 @@ export const useDiscordRpc = () => {
await discordRpc?.initialize(discordSettings.clientId); await discordRpc?.initialize(discordSettings.clientId);
} }
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcSetActivity, { logFn.debug('Activity was set for Discord RPC', {
category: LogCategory.EXTERNAL, category: LogCategory.EXTERNAL,
meta: { meta: {
albumName: song.album, albumName: song.album,
@@ -333,7 +346,7 @@ export const useDiscordRpc = () => {
}); });
discordRpc?.setActivity(activity); discordRpc?.setActivity(activity);
} else { } else {
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcUpdateSkipped, { logFn.debug('Activity was not updated for Discord RPC', {
category: LogCategory.EXTERNAL, category: LogCategory.EXTERNAL,
meta: { meta: {
currentStatus: current[2], currentStatus: current[2],
@@ -349,6 +362,7 @@ export const useDiscordRpc = () => {
[ [
discordSettings.showAsListening, discordSettings.showAsListening,
discordSettings.showServerImage, discordSettings.showServerImage,
discordSettings.showStateIcon,
discordSettings.showPaused, discordSettings.showPaused,
lastfmApiKey, lastfmApiKey,
discordSettings.clientId, discordSettings.clientId,
@@ -369,7 +383,7 @@ export const useDiscordRpc = () => {
// Quit Discord RPC if it was enabled and is now disabled // Quit Discord RPC if it was enabled and is now disabled
useEffect(() => { useEffect(() => {
if ((!discordSettings.enabled || privateMode) && Boolean(previousEnabledRef.current)) { if ((!discordSettings.enabled || privateMode) && Boolean(previousEnabledRef.current)) {
logFn.info(logMsg[LogCategory.EXTERNAL].discordRpcQuit, { logFn.info('Discord RPC was quit', {
category: LogCategory.EXTERNAL, category: LogCategory.EXTERNAL,
meta: { meta: {
enabled: discordSettings.enabled, enabled: discordSettings.enabled,
@@ -82,16 +82,7 @@ const HomeRoute = () => {
}, },
}; };
const sortedItems = homeItems.filter((item) => { const sortedItems = homeItems.filter((item) => !item.disabled);
if (item.disabled) {
return false;
}
if (isJellyfin && item.id === HomeItem.RECENTLY_PLAYED) {
return false;
}
return true;
});
const sortedCarousel = sortedItems const sortedCarousel = sortedItems
.filter((item) => item.id !== HomeItem.GENRES) .filter((item) => item.id !== HomeItem.GENRES)
@@ -18,6 +18,7 @@ import { AnimatedPage } from '/@/renderer/features/shared/components/animated-pa
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useAuthStoreActions, useCurrentServer } from '/@/renderer/store'; import { useAuthStoreActions, useCurrentServer } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center'; import { Center } from '/@/shared/components/center/center';
import { Code } from '/@/shared/components/code/code'; import { Code } from '/@/shared/components/code/code';
@@ -136,6 +137,10 @@ const LoginRoute = () => {
); );
if (!data) { if (!data) {
logFn.error('Login failed (no data returned)', {
category: LogCategory.SYSTEM,
meta: { serverName, serverType, serverUrl },
});
return toast.error({ return toast.error({
message: t('error.authenticationFailed', { postProcess: 'sentenceCase' }), message: t('error.authenticationFailed', { postProcess: 'sentenceCase' }),
}); });
@@ -159,6 +164,10 @@ const LoginRoute = () => {
addServer(serverItem); addServer(serverItem);
setCurrentServer(serverItem); setCurrentServer(serverItem);
logFn.info('Login successful', {
category: LogCategory.SYSTEM,
meta: { serverName, serverType, serverUrl, userId: data.userId },
});
toast.success({ toast.success({
message: t('form.addServer.success', { postProcess: 'sentenceCase' }), message: t('form.addServer.success', { postProcess: 'sentenceCase' }),
}); });
@@ -175,6 +184,10 @@ const LoginRoute = () => {
} }
} }
} catch (err: any) { } catch (err: any) {
logFn.error('Login failed', {
category: LogCategory.SYSTEM,
meta: { message: err?.message, serverName, serverType, serverUrl },
});
setIsLoading(false); setIsLoading(false);
return toast.error({ message: err?.message }); return toast.error({ message: err?.message });
} }
@@ -6,7 +6,6 @@ import { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'r
import { AudioPlayer, PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types'; import { AudioPlayer, PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types';
import { convertToLogVolume } from '/@/renderer/features/player/audio-player/utils/player-utils'; import { convertToLogVolume } from '/@/renderer/features/player/audio-player/utils/player-utils';
import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { PlayerStatus } from '/@/shared/types/types'; import { PlayerStatus } from '/@/shared/types/types';
export interface WebPlayerEngineHandle extends AudioPlayer { export interface WebPlayerEngineHandle extends AudioPlayer {
@@ -160,7 +159,7 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
const { error } = target; const { error } = target;
logFn.error(logMsg[LogCategory.PLAYER].playbackError, { logFn.error('An error occurred during playback', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { error }, meta: { error },
}); });
@@ -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 { MainPlayerListenerHook } from '/@/renderer/features/player/audio-player/hooks/use-main-player-listener';
import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player'; import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-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 { AutoDJHook } from '/@/renderer/features/player/hooks/use-auto-dj';
import { MediaSessionHook } from '/@/renderer/features/player/hooks/use-media-session'; import { MediaSessionHook } from '/@/renderer/features/player/hooks/use-media-session';
import { MPRISHook } from '/@/renderer/features/player/hooks/use-mpris'; import { MPRISHook } from '/@/renderer/features/player/hooks/use-mpris';
@@ -48,6 +49,7 @@ export const AudioPlayers = () => {
return ( return (
<> <>
<SleepTimerHook />
<ScrobbleHook /> <ScrobbleHook />
<PowerSaveBlockerHook /> <PowerSaveBlockerHook />
<DiscordRpcHook /> <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%)); 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 { .image-container {
position: relative; position: relative;
display: flex; display: flex;
@@ -11,7 +11,12 @@ import {
useRadioPlayer, useRadioPlayer,
} from '/@/renderer/features/radio/hooks/use-radio-player'; } from '/@/renderer/features/radio/hooks/use-radio-player';
import { AppRoute } from '/@/renderer/router/routes'; 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 { Badge } from '/@/shared/components/badge/badge';
import { Center } from '/@/shared/components/center/center'; import { Center } from '/@/shared/components/center/center';
import { Flex } from '/@/shared/components/flex/flex'; 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 { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { useSetState } from '/@/shared/hooks/use-set-state'; 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 = { const imageVariants: Variants = {
closed: { closed: {
@@ -49,9 +54,14 @@ const MotionImage = motion.img;
const ImageWithPlaceholder = ({ const ImageWithPlaceholder = ({
className, className,
explicit,
placeholderIcon = 'itemAlbum', placeholderIcon = 'itemAlbum',
...props ...props
}: HTMLMotionProps<'img'> & { placeholder?: string; placeholderIcon?: 'itemAlbum' | 'radio' }) => { }: HTMLMotionProps<'img'> & {
explicit?: boolean;
placeholder?: string;
placeholderIcon?: 'itemAlbum' | 'radio';
}) => {
const nativeAspectRatio = useNativeAspectRatio(); const nativeAspectRatio = useNativeAspectRatio();
if (!props.src) { if (!props.src) {
@@ -71,7 +81,9 @@ const ImageWithPlaceholder = ({
return ( return (
<MotionImage <MotionImage
className={clsx(styles.image, className)} className={clsx(styles.image, className, {
[styles.censored]: explicit,
})}
style={{ style={{
objectFit: nativeAspectRatio ? 'contain' : 'cover', objectFit: nativeAspectRatio ? 'contain' : 'cover',
width: nativeAspectRatio ? 'auto' : '100%', width: nativeAspectRatio ? 'auto' : '100%',
@@ -89,6 +101,7 @@ export const FullScreenPlayerImage = () => {
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const { nextSong } = usePlayerData(); const { nextSong } = usePlayerData();
const { blurExplicitImages } = useGeneralSettings();
const isPlayingRadio = isRadioActive && isRadioPlaying; const isPlayingRadio = isRadioActive && isRadioPlaying;
@@ -107,8 +120,10 @@ export const FullScreenPlayerImage = () => {
}); });
const [imageState, setImageState] = useSetState({ const [imageState, setImageState] = useSetState({
bottomExplicit: nextSong?.explicitStatus === ExplicitStatus.EXPLICIT,
bottomImage: nextImageUrl, bottomImage: nextImageUrl,
current: 0, current: 0,
topExplicit: currentSong?.explicitStatus === ExplicitStatus.EXPLICIT,
topImage: currentImageUrl, topImage: currentImageUrl,
}); });
@@ -133,8 +148,14 @@ export const FullScreenPlayerImage = () => {
const isTop = imageStateRef.current.current === 0; const isTop = imageStateRef.current.current === 0;
setImageState({ setImageState({
bottomExplicit:
(isTop ? currentSong?.explicitStatus : nextSong?.explicitStatus) ===
ExplicitStatus.EXPLICIT,
bottomImage: isTop ? currentImageUrl : nextImageUrl, bottomImage: isTop ? currentImageUrl : nextImageUrl,
current: isTop ? 1 : 0, current: isTop ? 1 : 0,
topExplicit:
(isTop ? nextSong?.explicitStatus : currentSong?.explicitStatus) ===
ExplicitStatus.EXPLICIT,
topImage: isTop ? nextImageUrl : currentImageUrl, topImage: isTop ? nextImageUrl : currentImageUrl,
}); });
@@ -146,6 +167,8 @@ export const FullScreenPlayerImage = () => {
nextSong?._uniqueId, nextSong?._uniqueId,
nextImageUrl, nextImageUrl,
setImageState, setImageState,
currentSong?.explicitStatus,
nextSong?.explicitStatus,
]); ]);
return ( return (
@@ -165,6 +188,7 @@ export const FullScreenPlayerImage = () => {
custom={{ isOpen: imageState.current === 0 }} custom={{ isOpen: imageState.current === 0 }}
draggable={false} draggable={false}
exit="closed" exit="closed"
explicit={blurExplicitImages && imageState.topExplicit}
initial="closed" initial="closed"
key={`top-${currentSong?._uniqueId || 'none'}`} key={`top-${currentSong?._uniqueId || 'none'}`}
placeholder="var(--theme-colors-foreground-muted)" placeholder="var(--theme-colors-foreground-muted)"
@@ -180,6 +204,7 @@ export const FullScreenPlayerImage = () => {
custom={{ isOpen: imageState.current === 1 }} custom={{ isOpen: imageState.current === 1 }}
draggable={false} draggable={false}
exit="closed" exit="closed"
explicit={blurExplicitImages && imageState.bottomExplicit}
initial="closed" initial="closed"
key={`bottom-${currentSong?._uniqueId || 'none'}`} key={`bottom-${currentSong?._uniqueId || 'none'}`}
placeholder="var(--theme-colors-foreground-muted)" placeholder="var(--theme-colors-foreground-muted)"
+2
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { PopoverPlayQueue } from '/@/renderer/features/now-playing/components/popover-play-queue'; import { PopoverPlayQueue } from '/@/renderer/features/now-playing/components/popover-play-queue';
import { PlayerConfig } from '/@/renderer/features/player/components/player-config'; import { PlayerConfig } from '/@/renderer/features/player/components/player-config';
import { CustomPlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider'; 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 { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating'; import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
@@ -72,6 +73,7 @@ export const RightControls = () => {
<AutoDJButton /> <AutoDJButton />
</Group> </Group>
<Group align="center" gap="xs" wrap="nowrap"> <Group align="center" gap="xs" wrap="nowrap">
<SleepTimerButton />
<PlayerConfig /> <PlayerConfig />
<LyricsButton /> <LyricsButton />
<FavoriteButton /> <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>
);
};
@@ -19,7 +19,6 @@ import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-a
import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { AddToQueueType, usePlayerActions, useSettingsStore } from '/@/renderer/store'; import { AddToQueueType, usePlayerActions, useSettingsStore } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { shuffle as shuffleArray } from '/@/renderer/utils/shuffle'; import { shuffle as shuffleArray } from '/@/renderer/utils/shuffle';
import { sortSongsByFetchedOrder } from '/@/shared/api/utils'; import { sortSongsByFetchedOrder } from '/@/shared/api/utils';
import { Checkbox } from '/@/shared/components/checkbox/checkbox'; import { Checkbox } from '/@/shared/components/checkbox/checkbox';
@@ -202,7 +201,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
if (typeof type === 'object' && 'edge' in type && type.edge !== null) { if (typeof type === 'object' && 'edge' in type && type.edge !== null) {
const edge = type.edge === 'top' ? 'top' : 'bottom'; const edge = type.edge === 'top' ? 'top' : 'bottom';
logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByData, { logFn.debug('Added to queue by data', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { meta: {
data: data.length, data: data.length,
@@ -215,7 +214,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
storeActions.addToQueueByUniqueId(filteredData, type.uniqueId, edge, playSongId); storeActions.addToQueueByUniqueId(filteredData, type.uniqueId, edge, playSongId);
} else { } else {
logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByType, { logFn.debug('Added to queue by type', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { data: data.length, filtered: filteredData.length, type }, meta: { data: data.length, filtered: filteredData.length, type },
}); });
@@ -258,7 +257,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
}; };
try { try {
logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByFetch, { logFn.debug('Added to queue by fetch', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { ids: id, itemType, serverId, type }, meta: { ids: id, itemType, serverId, type },
}); });
@@ -324,7 +323,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
let toastId: null | string = null; let toastId: null | string = null;
let fetchId: null | string = null; let fetchId: null | string = null;
logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByListQuery, { logFn.debug('Added to queue by list query', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { itemType, query, serverId, type }, meta: { itemType, query, serverId, type },
}); });
@@ -405,7 +404,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
onClose: () => { onClose: () => {
logFn.debug(logMsg[LogCategory.PLAYER].cancelledFetch, { logFn.debug('Cancelled fetch', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { itemType, serverId }, meta: { itemType, serverId },
}); });
@@ -505,7 +504,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
); );
const clearQueue = useCallback(() => { const clearQueue = useCallback(() => {
logFn.debug(logMsg[LogCategory.PLAYER].clearQueue, { logFn.debug('Cleared queue', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
}); });
@@ -514,7 +513,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const clearSelected = useCallback( const clearSelected = useCallback(
(items: QueueSong[]) => { (items: QueueSong[]) => {
logFn.debug(logMsg[LogCategory.PLAYER].clearSelected, { logFn.debug('Cleared selected', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { items: items.length }, meta: { items: items.length },
}); });
@@ -526,7 +525,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const decreaseVolume = useCallback( const decreaseVolume = useCallback(
(amount: number) => { (amount: number) => {
logFn.debug(logMsg[LogCategory.PLAYER].decreaseVolume, { logFn.debug('Decreased volume', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { amount }, meta: { amount },
}); });
@@ -538,7 +537,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const increaseVolume = useCallback( const increaseVolume = useCallback(
(amount: number) => { (amount: number) => {
logFn.debug(logMsg[LogCategory.PLAYER].increaseVolume, { logFn.debug('Increased volume', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { amount }, meta: { amount },
}); });
@@ -549,7 +548,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
); );
const mediaNext = useCallback(() => { const mediaNext = useCallback(() => {
logFn.debug(logMsg[LogCategory.PLAYER].mediaNext, { logFn.debug('Media next', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
}); });
@@ -557,7 +556,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
}, [storeActions]); }, [storeActions]);
const mediaPause = useCallback(() => { const mediaPause = useCallback(() => {
logFn.debug(logMsg[LogCategory.PLAYER].mediaPause, { logFn.debug('Media pause', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
}); });
@@ -566,7 +565,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const mediaPlay = useCallback( const mediaPlay = useCallback(
(id?: string) => { (id?: string) => {
logFn.debug(logMsg[LogCategory.PLAYER].mediaPlay, { logFn.debug('Media play', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { id }, meta: { id },
}); });
@@ -578,7 +577,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const mediaPlayByIndex = useCallback( const mediaPlayByIndex = useCallback(
(index: number) => { (index: number) => {
logFn.debug(logMsg[LogCategory.PLAYER].mediaPlayByIndex, { logFn.debug('Media play by index', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { index }, meta: { index },
}); });
@@ -589,7 +588,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
); );
const mediaPrevious = useCallback(() => { const mediaPrevious = useCallback(() => {
logFn.debug(logMsg[LogCategory.PLAYER].mediaPrevious, { logFn.debug('Media previous', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
}); });
@@ -597,7 +596,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
}, [storeActions]); }, [storeActions]);
const mediaStop = useCallback(() => { const mediaStop = useCallback(() => {
logFn.debug(logMsg[LogCategory.PLAYER].mediaStop, { logFn.debug('Media stop', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
}); });
@@ -606,7 +605,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const mediaSeekToTimestamp = useCallback( const mediaSeekToTimestamp = useCallback(
(timestamp: number) => { (timestamp: number) => {
logFn.debug(logMsg[LogCategory.PLAYER].mediaSeekToTimestamp, { logFn.debug('Media seek to timestamp', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { timestamp }, meta: { timestamp },
}); });
@@ -617,7 +616,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
); );
const mediaSkipBackward = useCallback(() => { const mediaSkipBackward = useCallback(() => {
logFn.debug(logMsg[LogCategory.PLAYER].mediaSkipBackward, { logFn.debug('Media skip backward', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
}); });
@@ -625,7 +624,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
}, [storeActions]); }, [storeActions]);
const mediaSkipForward = useCallback(() => { const mediaSkipForward = useCallback(() => {
logFn.debug(logMsg[LogCategory.PLAYER].mediaSkipForward, { logFn.debug('Media skip forward', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
}); });
@@ -634,7 +633,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const setQueue = useCallback( const setQueue = useCallback(
(data: Song[], index?: number, position?: number) => { (data: Song[], index?: number, position?: number) => {
logFn.debug(logMsg[LogCategory.PLAYER].setQueue, { logFn.debug('Set queue', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { meta: {
data: data.length, data: data.length,
@@ -650,7 +649,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const setSpeed = useCallback( const setSpeed = useCallback(
(speed: number) => { (speed: number) => {
logFn.debug(logMsg[LogCategory.PLAYER].setSpeed, { logFn.debug('Set speed', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { speed }, meta: { speed },
}); });
@@ -661,7 +660,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
); );
const mediaToggleMute = useCallback(() => { const mediaToggleMute = useCallback(() => {
logFn.debug(logMsg[LogCategory.PLAYER].mediaToggleMute, { logFn.debug('Media toggle mute', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
}); });
@@ -669,7 +668,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
}, [storeActions]); }, [storeActions]);
const mediaTogglePlayPause = useCallback(() => { const mediaTogglePlayPause = useCallback(() => {
logFn.debug(logMsg[LogCategory.PLAYER].mediaTogglePlayPause, { logFn.debug('Media toggle play pause', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
}); });
@@ -678,7 +677,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const moveSelectedTo = useCallback( const moveSelectedTo = useCallback(
(items: QueueSong[], edge: 'bottom' | 'top', uniqueId: string) => { (items: QueueSong[], edge: 'bottom' | 'top', uniqueId: string) => {
logFn.debug(logMsg[LogCategory.PLAYER].moveSelectedTo, { logFn.debug('Moved selected to', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { edge, items, uniqueId }, meta: { edge, items, uniqueId },
}); });
@@ -690,7 +689,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const moveSelectedToBottom = useCallback( const moveSelectedToBottom = useCallback(
(items: QueueSong[]) => { (items: QueueSong[]) => {
logFn.debug(logMsg[LogCategory.PLAYER].moveSelectedToBottom, { logFn.debug('Moved selected to bottom', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { items }, meta: { items },
}); });
@@ -702,7 +701,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const moveSelectedToNext = useCallback( const moveSelectedToNext = useCallback(
(items: QueueSong[]) => { (items: QueueSong[]) => {
logFn.debug(logMsg[LogCategory.PLAYER].moveSelectedToNext, { logFn.debug('Moved selected to next', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { items }, meta: { items },
}); });
@@ -714,7 +713,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const moveSelectedToTop = useCallback( const moveSelectedToTop = useCallback(
(items: QueueSong[]) => { (items: QueueSong[]) => {
logFn.debug(logMsg[LogCategory.PLAYER].moveSelectedToTop, { logFn.debug('Moved selected to top', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { items }, meta: { items },
}); });
@@ -726,7 +725,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const setVolume = useCallback( const setVolume = useCallback(
(volume: number) => { (volume: number) => {
logFn.debug(logMsg[LogCategory.PLAYER].setVolume, { logFn.debug('Set volume', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { volume }, meta: { volume },
}); });
@@ -738,7 +737,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const setRepeat = useCallback( const setRepeat = useCallback(
(repeat: PlayerRepeat) => { (repeat: PlayerRepeat) => {
logFn.debug(logMsg[LogCategory.PLAYER].setRepeat, { logFn.debug('Set repeat', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { repeat }, meta: { repeat },
}); });
@@ -750,7 +749,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const setShuffle = useCallback( const setShuffle = useCallback(
(shuffle: PlayerShuffle) => { (shuffle: PlayerShuffle) => {
logFn.debug(logMsg[LogCategory.PLAYER].setShuffle, { logFn.debug('Set shuffle', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { shuffle }, meta: { shuffle },
}); });
@@ -761,7 +760,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
); );
const shuffle = useCallback(() => { const shuffle = useCallback(() => {
logFn.debug(logMsg[LogCategory.PLAYER].shuffle, { logFn.debug('Shuffle', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
}); });
@@ -769,7 +768,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
}, [storeActions]); }, [storeActions]);
const shuffleAll = useCallback(() => { const shuffleAll = useCallback(() => {
logFn.debug(logMsg[LogCategory.PLAYER].shuffleAll, { logFn.debug('Shuffle all', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
}); });
@@ -778,7 +777,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const shuffleSelected = useCallback( const shuffleSelected = useCallback(
(items: QueueSong[]) => { (items: QueueSong[]) => {
logFn.debug(logMsg[LogCategory.PLAYER].shuffleSelected, { logFn.debug('Shuffle selected', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { items }, meta: { items },
}); });
@@ -789,7 +788,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
); );
const toggleRepeat = useCallback(() => { const toggleRepeat = useCallback(() => {
logFn.debug(logMsg[LogCategory.PLAYER].toggleRepeat, { logFn.debug('Toggle repeat', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
}); });
@@ -797,7 +796,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
}, [storeActions]); }, [storeActions]);
const toggleShuffle = useCallback(() => { const toggleShuffle = useCallback(() => {
logFn.debug(logMsg[LogCategory.PLAYER].toggleShuffle, { logFn.debug('Toggle shuffle', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
}); });
@@ -16,7 +16,6 @@ import {
useSettingsStore, useSettingsStore,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { shuffleInPlace } from '/@/renderer/utils/shuffle'; import { shuffleInPlace } from '/@/renderer/utils/shuffle';
import { hasFeature } from '/@/shared/api/utils'; import { hasFeature } from '/@/shared/api/utils';
import { Played, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { Played, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';
@@ -63,7 +62,7 @@ export const useAutoDJ = () => {
return; return;
} }
logFn.debug(logMsg[LogCategory.PLAYER].autoPlayTriggered, { logFn.debug('Auto play triggered', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { remaining: properties.remaining, songId: properties.song?.id }, meta: { remaining: properties.remaining, songId: properties.song?.id },
}); });
@@ -207,7 +206,7 @@ export const useAutoDJ = () => {
songCount: songsToAdd.length, songCount: songsToAdd.length,
}); });
} catch (error) { } catch (error) {
logFn.error(logMsg[LogCategory.PLAYER].autoPlayFailed, { logFn.error('Auto play failed', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { error: (error as Error).message, songId: properties.song?.id }, meta: { error: (error as Error).message, songId: properties.song?.id },
}); });
@@ -12,7 +12,6 @@ import {
useTimestampStoreBase, useTimestampStoreBase,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types'; import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
import { PlayerStatus } from '/@/shared/types/types'; import { PlayerStatus } from '/@/shared/types/types';
@@ -131,7 +130,7 @@ export const useScrobble = () => {
}, },
{ {
onSuccess: () => { onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, { logFn.debug('Scrobbled a timeupdate event', {
category: LogCategory.SCROBBLE, category: LogCategory.SCROBBLE,
meta: { meta: {
id: currentSong.id, id: currentSong.id,
@@ -173,7 +172,7 @@ export const useScrobble = () => {
}, },
{ {
onSuccess: () => { onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledSubmission, { logFn.debug('Scrobbled a submission event', {
category: LogCategory.SCROBBLE, category: LogCategory.SCROBBLE,
meta: { meta: {
id: currentSong.id, id: currentSong.id,
@@ -257,7 +256,7 @@ export const useScrobble = () => {
}, },
{ {
onSuccess: () => { onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStart, { logFn.debug('Scrobbled a start event', {
category: LogCategory.SCROBBLE, category: LogCategory.SCROBBLE,
meta: { meta: {
id: currentSong.id, id: currentSong.id,
@@ -319,7 +318,7 @@ export const useScrobble = () => {
}, },
{ {
onSuccess: () => { onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, { logFn.debug('Scrobbled a timeupdate event', {
category: LogCategory.SCROBBLE, category: LogCategory.SCROBBLE,
meta: { meta: {
id: currentSong.id, id: currentSong.id,
@@ -367,7 +366,7 @@ export const useScrobble = () => {
}, },
{ {
onSuccess: () => { onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledPause, { logFn.debug('Scrobbled a pause event', {
category: LogCategory.SCROBBLE, category: LogCategory.SCROBBLE,
meta: { meta: {
id: currentSong.id, id: currentSong.id,
@@ -393,7 +392,7 @@ export const useScrobble = () => {
}, },
{ {
onSuccess: () => { onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledUnpause, { logFn.debug('Scrobbled an unpause event', {
category: LogCategory.SCROBBLE, category: LogCategory.SCROBBLE,
meta: { meta: {
id: currentSong.id, id: currentSong.id,
@@ -436,7 +435,7 @@ export const useScrobble = () => {
}, },
{ {
onSuccess: () => { onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStart, { logFn.debug('Scrobbled a start event', {
category: LogCategory.SCROBBLE, category: LogCategory.SCROBBLE,
meta: { meta: {
id: currentSong.id, id: currentSong.id,
+1 -2
View File
@@ -5,7 +5,6 @@ import { queryKeys } from '/@/renderer/api/query-keys';
import { folderQueries } from '/@/renderer/features/folders/api/folder-api'; import { folderQueries } from '/@/renderer/features/folders/api/folder-api';
import { PlayerFilter, useSettingsStore } from '/@/renderer/store'; import { PlayerFilter, useSettingsStore } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { sortSongList } from '/@/shared/api/utils'; import { sortSongList } from '/@/shared/api/utils';
import { import {
PlaylistSongListQuery, PlaylistSongListQuery,
@@ -434,7 +433,7 @@ export const filterSongsByPlayerFilters = (songs: Song[], filters: PlayerFilter[
}); });
if (filteredSongs.length > 0) { if (filteredSongs.length > 0) {
logFn.debug(logMsg[LogCategory.PLAYER].playerFiltersApplied, { logFn.debug('Player filters applied', {
category: LogCategory.PLAYER, category: LogCategory.PLAYER,
meta: { meta: {
filteredCount: filteredSongs.length, filteredCount: filteredSongs.length,
@@ -0,0 +1,635 @@
import type { RowComponentProps } from 'react-window-v2';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { applyClientSideSongFilters } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';
import {
ArtistMultiSelectRow,
GenreMultiSelectRow,
} from '/@/renderer/features/shared/components/multi-select-rows';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { useCurrentServer } from '/@/renderer/store';
import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import {
VirtualMultiSelect,
type VirtualMultiSelectOption,
} from '/@/shared/components/multi-select/virtual-multi-select';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
interface BooleanSegmentFilterProps {
label: string;
onChange: (value: boolean | null) => void;
segmentData: Array<{ label: string; value: string }>;
value: boolean | null | undefined;
}
function booleanToSegmentValue(value: boolean | null | undefined): string {
if (value === true) return 'true';
if (value === false) return 'false';
return 'none';
}
function segmentValueToBoolean(value: string): boolean | null {
if (value === 'true') return true;
if (value === 'false') return false;
return null;
}
const BooleanSegmentFilter = ({
label,
onChange,
segmentData,
value,
}: BooleanSegmentFilterProps) => (
<Stack gap="xs">
<Text size="sm" weight={500}>
{label}
</Text>
<SegmentedControl
data={segmentData}
onChange={(v) => onChange(segmentValueToBoolean(v))}
size="sm"
value={booleanToSegmentValue(value)}
w="100%"
/>
</Stack>
);
interface MultiSelectFilterOption {
albumCount: null | number;
imageUrl: string | undefined;
label: string;
songCount: number;
value: string;
}
interface MultiSelectFilterProps {
displayCountType?: 'song';
height: number;
label: React.ReactNode;
onChange: (value: null | string[]) => void;
options: MultiSelectFilterOption[];
RowComponent: (props: RowComponentProps<MultiSelectRowContext>) => React.ReactElement;
singleSelect: boolean;
value: string[];
}
type MultiSelectRowContext = {
disabled?: boolean;
displayCountType?: 'album' | 'song';
focusedIndex: null | number;
onToggle: (value: string) => void;
options: VirtualMultiSelectOption<MultiSelectFilterOption>[];
value: string[];
};
const MultiSelectFilter = ({
displayCountType = 'song',
height,
label,
onChange,
options,
RowComponent,
singleSelect,
value,
}: MultiSelectFilterProps) => (
<VirtualMultiSelect
displayCountType={displayCountType}
height={height}
label={label}
onChange={onChange}
options={options}
RowComponent={RowComponent}
singleSelect={singleSelect}
value={value}
/>
);
interface YearRangeFilterProps {
fromYearLabel: string;
maxYear: number | undefined;
minYear: number | undefined;
onMaxYear: (e: number | string) => void;
onMinYear: (e: number | string) => void;
toYearLabel: string;
}
const YearRangeFilter = ({
fromYearLabel,
maxYear,
minYear,
onMaxYear,
onMinYear,
toYearLabel,
}: YearRangeFilterProps) => (
<Group gap="sm" wrap="nowrap">
<NumberInput
hideControls={false}
label={fromYearLabel}
max={5000}
min={0}
onChange={(e) => onMinYear(e)}
style={{ flex: 1 }}
value={minYear != null ? minYear : ''}
/>
<NumberInput
hideControls={false}
label={toYearLabel}
max={5000}
min={0}
onChange={(e) => onMaxYear(e)}
style={{ flex: 1 }}
value={maxYear != null ? maxYear : ''}
/>
</Group>
);
interface MultiSelectFilterLabelProps {
andOrValue: 'and' | 'or';
entityLabel: string;
filterMultipleLabel: string;
filterSingleLabel: string;
matchAndLabel: string;
matchOrLabel: string;
onAndOrChange: (value: 'and' | 'or') => void;
onSingleMultiChange: (value: string) => void;
showAndOr: boolean;
singleMultiValue: 'multi' | 'single';
}
const MultiSelectFilterLabel = ({
andOrValue,
entityLabel,
filterMultipleLabel,
filterSingleLabel,
matchAndLabel,
matchOrLabel,
onAndOrChange,
onSingleMultiChange,
showAndOr,
singleMultiValue,
}: MultiSelectFilterLabelProps) => (
<Group gap="xs" justify="space-between" w="100%">
<Text fw={500} size="sm">
{entityLabel}
</Text>
<Group gap="xs">
{showAndOr && (
<SegmentedControl
data={[
{ label: matchAndLabel, value: 'and' },
{ label: matchOrLabel, value: 'or' },
]}
onChange={(value) => onAndOrChange(value === 'or' ? 'or' : 'and')}
size="xs"
value={andOrValue}
/>
)}
<SegmentedControl
data={[
{ label: filterSingleLabel, value: 'single' },
{ label: filterMultipleLabel, value: 'multi' },
]}
onChange={onSingleMultiChange}
size="xs"
value={singleMultiValue}
/>
</Group>
</Group>
);
export const ClientSideSongFilters = () => {
const { t } = useTranslation();
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const {
query,
setAlbumArtistIds,
setAlbumArtistIdsMode,
setArtistIds,
setArtistIdsMode,
setFavorite,
setGenreId,
setGenreIdsMode,
setHasRating,
setMaxYear,
setMinYear,
} = usePlaylistSongListFilters();
const playlistSongsQuery = useSuspenseQuery(
playlistsQueries.songList({
query: { id: playlistId },
serverId: server?.id,
}),
);
const albumArtistSelectMode = useAppStore((state) => state.albumArtistSelectMode);
const artistSelectMode = useAppStore((state) => state.artistSelectMode);
const genreSelectMode = useAppStore((state) => state.genreSelectMode);
const { setAlbumArtistSelectMode, setArtistSelectMode, setGenreSelectMode } =
useAppStoreActions();
const songs = useMemo(() => {
return (playlistSongsQuery.data?.items ?? []) as Song[];
}, [playlistSongsQuery.data]);
const filteredSongs = useMemo(
() => applyClientSideSongFilters(songs, query as Record<string, unknown>),
[songs, query],
);
const songsForAlbumArtistOptions = useMemo(() => {
const idsMode =
(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const useFilteredResult = albumArtistSelectMode === 'multi' && idsMode === 'and';
if (!useFilteredResult) {
const queryWithoutAlbumArtist = {
...query,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: undefined,
} as Record<string, unknown>;
return applyClientSideSongFilters(songs, queryWithoutAlbumArtist);
}
return filteredSongs;
}, [albumArtistSelectMode, filteredSongs, query, songs]);
const songsForArtistOptions = useMemo(() => {
const idsMode =
(query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const useFilteredResult = artistSelectMode === 'multi' && idsMode === 'and';
if (!useFilteredResult) {
const queryWithoutArtist = {
...query,
[FILTER_KEYS.SONG.ARTIST_IDS]: undefined,
} as Record<string, unknown>;
return applyClientSideSongFilters(songs, queryWithoutArtist);
}
return filteredSongs;
}, [artistSelectMode, filteredSongs, query, songs]);
const songsForGenreOptions = useMemo(() => {
const idsMode =
(query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';
const useFilteredResult = genreSelectMode === 'multi' && idsMode === 'and';
if (!useFilteredResult) {
const queryWithoutGenre = {
...query,
[FILTER_KEYS.SONG.GENRE_ID]: undefined,
} as Record<string, unknown>;
return applyClientSideSongFilters(songs, queryWithoutGenre);
}
return filteredSongs;
}, [filteredSongs, genreSelectMode, query, songs]);
const albumArtistOptions = useMemo(() => {
const byId = new Map<
string,
{ id: string; imageUrl: string | undefined; name: string; songCount: number }
>();
for (const song of songsForAlbumArtistOptions) {
for (const artist of song.albumArtists ?? []) {
if (!artist.id) continue;
const existing = byId.get(artist.id);
if (existing) {
existing.songCount += 1;
} else {
byId.set(artist.id, {
id: artist.id,
imageUrl:
artist.imageUrl ??
getItemImageUrl({
id: artist.id,
itemType: LibraryItem.ALBUM_ARTIST,
type: 'table',
}),
name: artist.name,
songCount: 1,
});
}
}
}
return Array.from(byId.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((a) => ({
albumCount: null as null | number,
imageUrl: a.imageUrl,
label: a.name,
songCount: a.songCount,
value: a.id,
}));
}, [songsForAlbumArtistOptions]);
const artistOptions = useMemo(() => {
const byId = new Map<
string,
{ id: string; imageUrl: string | undefined; name: string; songCount: number }
>();
for (const song of songsForArtistOptions) {
for (const artist of song.artists ?? []) {
if (!artist.id) continue;
const existing = byId.get(artist.id);
if (existing) {
existing.songCount += 1;
} else {
byId.set(artist.id, {
id: artist.id,
imageUrl:
artist.imageUrl ??
getItemImageUrl({
id: artist.id,
itemType: LibraryItem.ARTIST,
type: 'table',
}),
name: artist.name,
songCount: 1,
});
}
}
}
return Array.from(byId.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((a) => ({
albumCount: null as null | number,
imageUrl: a.imageUrl,
label: a.name,
songCount: a.songCount,
value: a.id,
}));
}, [songsForArtistOptions]);
const genreOptions = useMemo(() => {
const byId = new Map<string, { id: string; name: string; songCount: number }>();
for (const song of songsForGenreOptions) {
for (const genre of song.genres ?? []) {
if (!genre.id) continue;
const existing = byId.get(genre.id);
if (existing) {
existing.songCount += 1;
} else {
byId.set(genre.id, {
id: genre.id,
name: genre.name,
songCount: 1,
});
}
}
}
return Array.from(byId.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((g) => ({
albumCount: null as null | number,
imageUrl: undefined,
label: g.name,
songCount: g.songCount,
value: g.id,
}));
}, [songsForGenreOptions]);
const segmentedControlData = useMemo(
() => [
{ label: t('common.none', { postProcess: 'titleCase' }), value: 'none' },
{ label: t('common.yes', { postProcess: 'titleCase' }), value: 'true' },
{ label: t('common.no', { postProcess: 'titleCase' }), value: 'false' },
],
[t],
);
const handleMinYear = useMemo(
() => (e: number | string) => {
if (e === '' || e === null || e === undefined) {
setMinYear(null);
return;
}
const year = typeof e === 'number' ? e : Number(e);
setMinYear(!isNaN(year) && isFinite(year) && year > 0 ? year : null);
},
[setMinYear],
);
const handleMaxYear = useMemo(
() => (e: number | string) => {
if (e === '' || e === null || e === undefined) {
setMaxYear(null);
return;
}
const year = typeof e === 'number' ? e : Number(e);
setMaxYear(!isNaN(year) && isFinite(year) && year > 0 ? year : null);
},
[setMaxYear],
);
const debouncedHandleMinYear = useDebouncedCallback(handleMinYear, 300);
const debouncedHandleMaxYear = useDebouncedCallback(handleMaxYear, 300);
const selectedGenreIds = useMemo(
() => (query[FILTER_KEYS.SONG.GENRE_ID] as string[] | undefined) ?? [],
[query],
);
const handleGenreSelectModeChange = useCallback(
(value: string) => {
const newMode = value as 'multi' | 'single';
setGenreSelectMode(newMode);
if (newMode === 'single' && selectedGenreIds.length > 1) {
setGenreId([selectedGenreIds[0]]);
}
},
[selectedGenreIds, setGenreId, setGenreSelectMode],
);
const genreIdsMode =
(query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';
const handleGenreChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
setGenreId(e);
} else {
setGenreId(null);
}
},
[setGenreId],
);
const selectedArtistIds = useMemo(
() => (query[FILTER_KEYS.SONG.ARTIST_IDS] as string[] | undefined) ?? [],
[query],
);
const handleArtistSelectModeChange = useCallback(
(value: string) => {
const newMode = value as 'multi' | 'single';
setArtistSelectMode(newMode);
if (newMode === 'single' && selectedArtistIds.length > 1) {
setArtistIds([selectedArtistIds[0]]);
}
},
[selectedArtistIds, setArtistIds, setArtistSelectMode],
);
const artistIdsMode =
(query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const handleArtistChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
setArtistIds(e);
} else {
setArtistIds(null);
}
},
[setArtistIds],
);
const selectedAlbumArtistIds = useMemo(
() => (query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS] as string[] | undefined) ?? [],
[query],
);
const handleAlbumArtistSelectModeChange = useCallback(
(value: string) => {
const newMode = value as 'multi' | 'single';
setAlbumArtistSelectMode(newMode);
if (newMode === 'single' && selectedAlbumArtistIds.length > 1) {
setAlbumArtistIds([selectedAlbumArtistIds[0]]);
}
},
[selectedAlbumArtistIds, setAlbumArtistIds, setAlbumArtistSelectMode],
);
const albumArtistIdsMode =
(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const handleAlbumArtistChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
setAlbumArtistIds(e);
} else {
setAlbumArtistIds(null);
}
},
[setAlbumArtistIds],
);
const queryFavorite = query[FILTER_KEYS.SONG.FAVORITE] as boolean | undefined;
const queryHasRating = query[FILTER_KEYS.SONG.HAS_RATING] as boolean | undefined;
const queryMinYear = query[FILTER_KEYS.SONG.MIN_YEAR] as number | undefined;
const queryMaxYear = query[FILTER_KEYS.SONG.MAX_YEAR] as number | undefined;
const matchAndLabel = t('filter.matchAnd', { postProcess: 'titleCase' });
const matchOrLabel = t('filter.matchOr', { postProcess: 'titleCase' });
const filterSingleLabel = t('common.filter_single', { postProcess: 'titleCase' });
const filterMultipleLabel = t('common.filter_multiple', { postProcess: 'titleCase' });
return (
<Stack px="md" py="md">
<BooleanSegmentFilter
label={t('filter.isFavorited', { postProcess: 'sentenceCase' })}
onChange={setFavorite}
segmentData={segmentedControlData}
value={queryFavorite}
/>
<Stack gap="xs" mt="md">
<BooleanSegmentFilter
label={t('filter.isRated', { postProcess: 'sentenceCase' })}
onChange={setHasRating}
segmentData={segmentedControlData}
value={queryHasRating}
/>
</Stack>
<Divider my="md" />
<MultiSelectFilter
height={300}
label={
<MultiSelectFilterLabel
andOrValue={artistIdsMode}
entityLabel={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
filterMultipleLabel={filterMultipleLabel}
filterSingleLabel={filterSingleLabel}
matchAndLabel={matchAndLabel}
matchOrLabel={matchOrLabel}
onAndOrChange={setArtistIdsMode}
onSingleMultiChange={handleArtistSelectModeChange}
showAndOr={artistSelectMode === 'multi'}
singleMultiValue={artistSelectMode}
/>
}
onChange={handleArtistChange}
options={artistOptions}
RowComponent={ArtistMultiSelectRow}
singleSelect={artistSelectMode === 'single'}
value={selectedArtistIds}
/>
<Divider my="md" />
<MultiSelectFilter
height={300}
label={
<MultiSelectFilterLabel
andOrValue={albumArtistIdsMode}
entityLabel={t('entity.albumArtist', {
count: 2,
postProcess: 'sentenceCase',
})}
filterMultipleLabel={filterMultipleLabel}
filterSingleLabel={filterSingleLabel}
matchAndLabel={matchAndLabel}
matchOrLabel={matchOrLabel}
onAndOrChange={setAlbumArtistIdsMode}
onSingleMultiChange={handleAlbumArtistSelectModeChange}
showAndOr={albumArtistSelectMode === 'multi'}
singleMultiValue={albumArtistSelectMode}
/>
}
onChange={handleAlbumArtistChange}
options={albumArtistOptions}
RowComponent={ArtistMultiSelectRow}
singleSelect={albumArtistSelectMode === 'single'}
value={selectedAlbumArtistIds}
/>
<Divider my="md" />
<MultiSelectFilter
height={220}
label={
<MultiSelectFilterLabel
andOrValue={genreIdsMode}
entityLabel={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
filterMultipleLabel={filterMultipleLabel}
filterSingleLabel={filterSingleLabel}
matchAndLabel={matchAndLabel}
matchOrLabel={matchOrLabel}
onAndOrChange={setGenreIdsMode}
onSingleMultiChange={handleGenreSelectModeChange}
showAndOr={genreSelectMode === 'multi'}
singleMultiValue={genreSelectMode}
/>
}
onChange={handleGenreChange}
options={genreOptions}
RowComponent={GenreMultiSelectRow}
singleSelect={genreSelectMode === 'single'}
value={selectedGenreIds}
/>
<Divider my="md" />
<YearRangeFilter
fromYearLabel={t('filter.fromYear', { postProcess: 'titleCase' })}
maxYear={queryMaxYear}
minYear={queryMinYear}
onMaxYear={debouncedHandleMaxYear}
onMinYear={debouncedHandleMinYear}
toYearLabel={t('filter.toYear', { postProcess: 'titleCase' })}
/>
</Stack>
);
};
@@ -0,0 +1,236 @@
import { useEffect, useMemo } from 'react';
import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list';
import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { DefaultItemControlProps, ItemControls } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { applyClientSideSongFilters } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';
import { type PlaylistAlbumRow, playlistSongsToAlbums } from '/@/renderer/features/playlists/utils';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
import { useGeneralSettings, useListSettings } from '/@/renderer/store';
import { sortSongList } from '/@/shared/api/utils';
import {
LibraryItem,
PlaylistSongListResponse,
Song,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType, ListPaginationType, Play } from '/@/shared/types/types';
export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListResponse }) => {
const player = usePlayer();
const { setItemCount, setListData } = useListContext();
const { detail, display, grid, itemsPerPage, pagination, table } = useListSettings(
ItemListKey.PLAYLIST_ALBUM,
);
const { enableGridMultiSelect } = useGeneralSettings();
const { currentPage, onChange: onPageChange } = useItemListPagination();
const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters();
const filteredAndSortedSongs = useMemo(() => {
const raw = data?.items ?? [];
const filtered = applyClientSideSongFilters(raw, query as Record<string, unknown>);
const searched = searchTerm?.trim()
? searchLibraryItems(filtered, searchTerm, LibraryItem.SONG)
: filtered;
return sortSongList(
searched,
(query.sortBy as SongListSort) ?? SongListSort.ID,
(query.sortOrder as SortOrder) ?? SortOrder.ASC,
);
}, [data?.items, query, searchTerm]);
const sortedAlbums = useMemo(
() => playlistSongsToAlbums(filteredAndSortedSongs),
[filteredAndSortedSongs],
);
const isPaginated = pagination === ListPaginationType.PAGINATED;
const totalAlbumCount = sortedAlbums.length;
const albumPageCount = Math.max(1, Math.ceil(totalAlbumCount / itemsPerPage));
const paginatedAlbums = useMemo(() => {
if (!isPaginated) return sortedAlbums;
const start = currentPage * itemsPerPage;
return sortedAlbums.slice(start, start + itemsPerPage);
}, [isPaginated, currentPage, itemsPerPage, sortedAlbums]);
const albumsToRender = isPaginated ? paginatedAlbums : sortedAlbums;
const playlistSongs = useMemo(() => data?.items ?? [], [data?.items]);
const albumControlOverrides = useMemo<Partial<ItemControls>>(() => {
return {
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?.(filteredAndSortedSongs);
}, [filteredAndSortedSongs, setListData]);
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ enabled: true });
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.PLAYLIST_ALBUM,
});
const { handleColumnResized } = useItemListColumnResize({
itemListKey: ItemListKey.PLAYLIST_ALBUM,
});
const { handleColumnReordered: handleDetailColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.PLAYLIST_ALBUM,
tableKey: 'detail',
});
const { handleColumnResized: handleDetailColumnResized } = useItemListColumnResize({
itemListKey: ItemListKey.PLAYLIST_ALBUM,
tableKey: 'detail',
});
const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.PLAYLIST_ALBUM, grid.size);
const 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 { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router'; 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 { ItemListHandle } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context'; import { useListContext } from '/@/renderer/context/list-context';
import { eventEmitter } from '/@/renderer/events/event-emitter'; import { eventEmitter } from '/@/renderer/events/event-emitter';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; 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 { useCurrentServer, useListSettings } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner'; import { Spinner } from '/@/shared/components/spinner/spinner';
import { PlaylistSongListQuery, PlaylistSongListResponse } from '/@/shared/types/domain-types'; import {
import { ItemListKey, ListDisplayType, TableColumn } from '/@/shared/types/types'; LibraryItem,
PlaylistSongListQuery,
PlaylistSongListResponse,
Song,
} from '/@/shared/types/domain-types';
import {
ItemListKey,
ListDisplayType,
ListPaginationType,
TableColumn,
} from '/@/shared/types/types';
const PlaylistDetailSongListTable = lazy(() => const PlaylistDetailSongListTable = lazy(() =>
import('/@/renderer/features/playlists/components/playlist-detail-song-list-table').then( import('/@/renderer/features/playlists/components/playlist-detail-song-list-table').then(
@@ -38,7 +51,6 @@ const PlaylistDetailSongListGrid = lazy(() =>
export const PlaylistDetailSongListContent = () => { export const PlaylistDetailSongListContent = () => {
const { playlistId } = useParams() as { playlistId: string }; const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer(); const server = useCurrentServer();
const { setItemCount } = useListContext();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const playlistSongsQuery = useSuspenseQuery( 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(() => { useEffect(() => {
const handleRefresh = async (payload: { key: string }) => { const handleRefresh = async (payload: { key: string }) => {
if (payload.key !== ItemListKey.PLAYLIST_SONG) { if (
payload.key !== ItemListKey.PLAYLIST_SONG &&
payload.key !== ItemListKey.PLAYLIST_ALBUM
) {
return; return;
} }
@@ -81,7 +87,7 @@ export const PlaylistDetailSongListContent = () => {
return () => { return () => {
eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh); eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh);
}; };
}, [playlistId, queryClient, server.id]); }, [playlistId, queryClient, server?.id]);
return ( return (
<Suspense fallback={<Spinner container />}> <Suspense fallback={<Spinner container />}>
@@ -92,13 +98,36 @@ export const PlaylistDetailSongListContent = () => {
export type OverridePlaylistSongListQuery = Omit<Partial<PlaylistSongListQuery>, 'id'>; 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 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) { switch (display) {
case ListDisplayType.GRID: { case ListDisplayType.GRID: {
return <PlaylistDetailSongListGrid data={data} serverId={server.id} />; return (
<PlaylistDetailSongListGrid
data={data}
items={items}
serverId={server.id}
{...paginationProps}
/>
);
} }
case ListDisplayType.TABLE: { case ListDisplayType.TABLE: {
return ( return (
@@ -111,8 +140,10 @@ export const PlaylistDetailSongListView = ({ data }: { data: PlaylistSongListRes
enableHorizontalBorders={table.enableHorizontalBorders} enableHorizontalBorders={table.enableHorizontalBorders}
enableRowHoverHighlight={table.enableRowHoverHighlight} enableRowHoverHighlight={table.enableRowHoverHighlight}
enableVerticalBorders={table.enableVerticalBorders} enableVerticalBorders={table.enableVerticalBorders}
items={items}
serverId={server.id} serverId={server.id}
size={table.size} 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(); const { isSmartPlaylist, mode } = useListContext();
if (isSmartPlaylist) { if (isSmartPlaylist) {
return <PlaylistDetailSongListView data={data} />; return <PlaylistDetailTrackViewContent data={data} />;
} }
switch (mode) { if (mode === 'edit') {
case 'edit': return <PlaylistDetailSongListEdit data={data} />;
return <PlaylistDetailSongListEdit data={data} />;
case 'view':
return <PlaylistDetailSongListView data={data} />;
default:
return null;
} }
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 { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; 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 { 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 { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context'; import { useListContext } from '/@/renderer/context/list-context';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters'; import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
@@ -15,40 +16,52 @@ import {
LibraryItem, LibraryItem,
PlaylistSongListQuery, PlaylistSongListQuery,
PlaylistSongListResponse, PlaylistSongListResponse,
Song,
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
interface PlaylistDetailSongListGridProps interface PlaylistDetailSongListGridProps
extends Omit<ItemListGridComponentProps<PlaylistSongListQuery>, 'query'> { extends Omit<ItemListGridComponentProps<PlaylistSongListQuery>, 'query'> {
currentPage?: number;
data: PlaylistSongListResponse; data: PlaylistSongListResponse;
items?: Song[];
itemsPerPage?: number;
onPageChange?: (page: number) => void;
} }
export const PlaylistDetailSongListGrid = forwardRef<any, PlaylistDetailSongListGridProps>( export const PlaylistDetailSongListGrid = forwardRef<any, PlaylistDetailSongListGridProps>(
({ data, saveScrollOffset = true }) => { ({
currentPage,
data,
items: itemsProp,
itemsPerPage,
onPageChange,
saveScrollOffset = true,
}) => {
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
enabled: saveScrollOffset, enabled: saveScrollOffset,
}); });
const { searchTerm } = useSearchTermFilter(); const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters(); const { query } = usePlaylistSongListFilters();
const { setListData } = useListContext();
const songData = useMemo(() => {
let items = data?.items || [];
const songDataFromData = useMemo(() => {
let list = data?.items || [];
if (searchTerm) { if (searchTerm) {
items = searchLibraryItems(items, searchTerm, LibraryItem.SONG); list = searchLibraryItems(list, searchTerm, LibraryItem.SONG);
return items; return list;
} }
return sortSongList(list, query.sortBy, query.sortOrder);
return sortSongList(items, query.sortBy, query.sortOrder);
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]); }, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
const { setListData } = useListContext();
const songData = itemsProp ?? songDataFromData;
useEffect(() => { useEffect(() => {
if (setListData) { if (itemsProp == null && setListData) {
setListData(songData); setListData(songDataFromData);
} }
}, [songData, setListData]); }, [itemsProp, songDataFromData, setListData]);
const gridProps = useListSettings(ItemListKey.PLAYLIST_SONG).grid; const gridProps = useListSettings(ItemListKey.PLAYLIST_SONG).grid;
@@ -59,9 +72,22 @@ export const PlaylistDetailSongListGrid = forwardRef<any, PlaylistDetailSongList
); );
const { enableGridMultiSelect } = useGeneralSettings(); 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 <ItemGridList
data={songData} data={dataToRender}
enableMultiSelect={enableGridMultiSelect} enableMultiSelect={enableGridMultiSelect}
gap={gridProps.itemGap} gap={gridProps.itemGap}
initialTop={{ initialTop={{
@@ -69,11 +95,27 @@ export const PlaylistDetailSongListGrid = forwardRef<any, PlaylistDetailSongList
type: 'offset', type: 'offset',
}} }}
itemsPerRow={gridProps.itemsPerRowEnabled ? gridProps.itemsPerRow : undefined} itemsPerRow={gridProps.itemsPerRowEnabled ? gridProps.itemsPerRow : undefined}
itemType={LibraryItem.SONG} itemType={LibraryItem.PLAYLIST_SONG}
onScrollEnd={handleOnScrollEnd} onScrollEnd={handleOnScrollEnd}
rows={rows} rows={rows}
size={gridProps.size} size={gridProps.size}
/> />
); );
if (isPaginated && itemsPerPage != null) {
return (
<ItemListWithPagination
currentPage={currentPage!}
itemsPerPage={itemsPerPage}
onChange={onPageChange!}
pageCount={pageCount}
totalItemCount={totalCount}
>
{grid}
</ItemListWithPagination>
);
}
return grid;
}, },
); );
@@ -1,30 +1,45 @@
import { openContextModal } from '@mantine/modals'; import { openContextModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import i18n from '/@/i18n/i18n'; 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 { useListContext } from '/@/renderer/context/list-context';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { ClientSideSongFilters } from '/@/renderer/features/playlists/components/client-side-song-filters';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { FilterButton } from '/@/renderer/features/shared/components/filter-button';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button'; import { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';
import { isFilterValueSet } from '/@/renderer/features/shared/components/list-filters';
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; import { 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 { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
import { MoreButton } from '/@/renderer/features/shared/components/more-button'; import { MoreButton } from '/@/renderer/features/shared/components/more-button';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { useContainerQuery } from '/@/renderer/hooks'; 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 { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Divider } from '/@/shared/components/divider/divider'; import { Divider } from '/@/shared/components/divider/divider';
import { Flex } from '/@/shared/components/flex/flex'; import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { Modal } from '/@/shared/components/modal/modal';
import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage'; import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
@@ -33,12 +48,77 @@ interface PlaylistDetailSongListHeaderFiltersProps {
isSmartPlaylist?: boolean; isSmartPlaylist?: boolean;
} }
const PlaylistSongListFiltersModal = () => {
const { t } = useTranslation();
const { isSidebarOpen, setIsSidebarOpen } = useListContext();
const { clear, query } = usePlaylistSongListFilters();
const [isOpen, handlers] = useDisclosure(false);
const hasActiveFilters = useMemo(() => {
return Boolean(
isFilterValueSet(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]) ||
isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) ||
query[FILTER_KEYS.SONG.FAVORITE] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) ||
query[FILTER_KEYS.SONG.HAS_RATING] !== undefined ||
query[FILTER_KEYS.SONG.MAX_YEAR] !== undefined ||
query[FILTER_KEYS.SONG.MIN_YEAR] !== undefined,
);
}, [query]);
const handlePin = () => {
setIsSidebarOpen?.(!isSidebarOpen);
};
const canPin = Boolean(setIsSidebarOpen);
return (
<>
<FilterButton isActive={hasActiveFilters} onClick={handlers.toggle} />
<Modal
handlers={handlers}
opened={isOpen}
size="lg"
styles={{
content: {
height: '100%',
maxHeight: '640px',
maxWidth: 'var(--theme-content-max-width)',
width: '100%',
},
}}
title={
<Group justify="space-between" style={{ paddingRight: '3rem', width: '100%' }}>
<Group>
{canPin && (
<ActionIcon
icon={isSidebarOpen ? 'unpin' : 'pin'}
onClick={handlePin}
variant="subtle"
/>
)}
{t('common.filters', { postProcess: 'sentenceCase' })}
</Group>
<Button onClick={clear} size="compact-sm" variant="subtle">
{t('common.reset', { postProcess: 'sentenceCase' })}
</Button>
</Group>
}
>
<ClientSideSongFilters />
</Modal>
</>
);
};
export const PlaylistDetailSongListHeaderFilters = ({ export const PlaylistDetailSongListHeaderFilters = ({
isSmartPlaylist, isSmartPlaylist,
}: PlaylistDetailSongListHeaderFiltersProps) => { }: PlaylistDetailSongListHeaderFiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { mode, setMode } = useListContext(); const { listKey: listKeyFromContext, mode, setMode } = useListContext();
const { playlistId } = useParams() as { playlistId: string }; const { playlistId } = useParams() as { playlistId: string };
const playlistTarget = usePlaylistTarget();
const { setPlaylistBehavior } = useSettingsStoreActions();
const serverId = useCurrentServerId(); const serverId = useCurrentServerId();
const detailQuery = useQuery(playlistsQueries.detail({ query: { id: playlistId }, serverId })); const detailQuery = useQuery(playlistsQueries.detail({ query: { id: playlistId }, serverId }));
@@ -55,9 +135,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 { ref: containerRef, ...breakpoints } = useContainerQuery();
const isViewEditMode = !isSmartPlaylist && breakpoints.isSm; const isViewEditMode = !isSmartPlaylist && (breakpoints.isSm || isAlbumMode);
const isEditMode = mode === 'edit'; const isEditMode = mode === 'edit';
const [collapsed, setCollapsed] = useLocalStorage<boolean>({ const [collapsed, setCollapsed] = useLocalStorage<boolean>({
@@ -68,6 +164,14 @@ export const PlaylistDetailSongListHeaderFilters = ({
return ( return (
<Flex justify="space-between" ref={containerRef}> <Flex justify="space-between" ref={containerRef}>
<Group gap="sm" w="100%"> <Group gap="sm" w="100%">
<Button
leftSection={<Icon icon="arrowLeftRight" />}
onClick={handleToggleDisplayMode}
variant="subtle"
>
{toggleChoice}
</Button>
<Divider orientation="vertical" />
<ListSortByDropdown <ListSortByDropdown
defaultSortByValue={SongListSort.ID} defaultSortByValue={SongListSort.ID}
disabled={isEditMode} disabled={isEditMode}
@@ -80,8 +184,9 @@ export const PlaylistDetailSongListHeaderFilters = ({
disabled={isEditMode} disabled={isEditMode}
listKey={ItemListKey.PLAYLIST_SONG} listKey={ItemListKey.PLAYLIST_SONG}
/> />
{!collapsed && <ListSearchInput />} <Divider orientation="vertical" />
<ListRefreshButton disabled={isEditMode} listKey={ItemListKey.PLAYLIST_SONG} /> <PlaylistSongListFiltersModal />
<ListRefreshButton disabled={isEditMode} listKey={listKey} />
<MoreButton onClick={handleMore} /> <MoreButton onClick={handleMore} />
</Group> </Group>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
@@ -109,11 +214,25 @@ export const PlaylistDetailSongListHeaderFilters = ({
variant="subtle" variant="subtle"
/> />
</Tooltip> </Tooltip>
<ListDisplayTypeToggleButton listKey={ItemListKey.PLAYLIST_SONG} /> <ListDisplayTypeToggleButton enableDetail={isAlbumMode} listKey={listKey} />
<ListConfigMenu {isAlbumMode ? (
listKey={ItemListKey.PLAYLIST_SONG} <ListConfigMenu
tableColumnsData={PLAYLIST_SONG_TABLE_COLUMNS} 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> </Group>
</Flex> </Flex>
); );
@@ -93,6 +93,7 @@ export const PlaylistDetailSongListHeader = ({
</PageHeader> </PageHeader>
) : ( ) : (
<LibraryHeader <LibraryHeader
compact
imageUrl={imageUrl} imageUrl={imageUrl}
item={{ item={{
imageId: detailQuery?.data?.imageId, imageId: detailQuery?.data?.imageId,
@@ -101,6 +102,7 @@ export const PlaylistDetailSongListHeader = ({
type: LibraryItem.PLAYLIST, type: LibraryItem.PLAYLIST,
}} }}
title={detailQuery?.data?.name || ''} title={detailQuery?.data?.name || ''}
topRight={<ListSearchInput />}
> >
<LibraryHeaderMenu <LibraryHeaderMenu
onPlay={(type) => handlePlay(type)} 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 { 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 { 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 { 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 { 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 { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemControls, ItemListTableComponentProps } from '/@/renderer/components/item-list/types'; import { ItemControls, ItemListTableComponentProps } from '/@/renderer/components/item-list/types';
@@ -24,7 +25,11 @@ import { ItemListKey, Play } from '/@/shared/types/types';
interface PlaylistDetailSongListTableProps interface PlaylistDetailSongListTableProps
extends Omit<ItemListTableComponentProps<PlaylistSongListQuery>, 'query'> { extends Omit<ItemListTableComponentProps<PlaylistSongListQuery>, 'query'> {
currentPage?: number;
data: PlaylistSongListResponse; data: PlaylistSongListResponse;
items?: Song[];
itemsPerPage?: number;
onPageChange?: (page: number) => void;
} }
export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongListTableProps>( export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongListTableProps>(
@@ -32,6 +37,7 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
{ {
autoFitColumns = false, autoFitColumns = false,
columns, columns,
currentPage,
data, data,
enableAlternateRowColors = false, enableAlternateRowColors = false,
enableHeader = true, enableHeader = true,
@@ -39,6 +45,9 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
enableRowHoverHighlight = true, enableRowHoverHighlight = true,
enableSelection = true, enableSelection = true,
enableVerticalBorders = false, enableVerticalBorders = false,
items: itemsProp,
itemsPerPage,
onPageChange,
saveScrollOffset = true, saveScrollOffset = true,
size = 'default', size = 'default',
}, },
@@ -58,24 +67,24 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
const { searchTerm } = useSearchTermFilter(); const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters(); const { query } = usePlaylistSongListFilters();
const { setListData } = useListContext();
const songData = useMemo(() => {
let items = data?.items || [];
const songDataFromData = useMemo(() => {
let list = data?.items || [];
if (searchTerm) { if (searchTerm) {
items = searchLibraryItems(items, searchTerm, LibraryItem.SONG); list = searchLibraryItems(list, searchTerm, LibraryItem.SONG);
return items; return list;
} }
return sortSongList(list, query.sortBy, query.sortOrder);
return sortSongList(items, query.sortBy, query.sortOrder);
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]); }, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
const { setListData } = useListContext();
const songData = itemsProp ?? songDataFromData;
useEffect(() => { useEffect(() => {
if (setListData) { if (itemsProp == null && setListData) {
setListData(songData); setListData(songDataFromData);
} }
}, [songData, setListData]); }, [itemsProp, songDataFromData, setListData]);
const player = usePlayer(); 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 <ItemTableList
activeRowId={currentSong?.id} activeRowId={currentSong?.id}
autoFitColumns={autoFitColumns} autoFitColumns={autoFitColumns}
CellComponent={ItemTableListColumn} CellComponent={ItemTableListColumn}
columns={columns} columns={columns}
data={songData} data={dataToRender}
enableAlternateRowColors={enableAlternateRowColors} enableAlternateRowColors={enableAlternateRowColors}
enableExpansion={false} enableExpansion={false}
enableHeader={enableHeader} enableHeader={enableHeader}
@@ -136,6 +158,22 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
size={size} size={size}
/> />
); );
if (isPaginated && itemsPerPage != null) {
return (
<ItemListWithPagination
currentPage={currentPage!}
itemsPerPage={itemsPerPage}
onChange={onPageChange!}
pageCount={pageCount}
totalItemCount={totalCount}
>
{table}
</ItemListWithPagination>
);
}
return table;
}, },
); );
@@ -5,17 +5,25 @@ import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-searc
import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter'; import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter'; import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { useAppStore } from '/@/renderer/store/app.store';
import { import {
parseArrayParam, parseArrayParam,
parseBooleanParam, parseBooleanParam,
parseCustomFiltersParam, parseCustomFiltersParam,
parseIntParam, parseIntParam,
setMultipleSearchParams,
setSearchParam, setSearchParam,
} from '/@/renderer/utils/query-params'; } from '/@/renderer/utils/query-params';
import { SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
export const usePlaylistSongListFilters = () => { export const usePlaylistSongListFilters = () => {
const albumArtistIdsMode = useAppStore((state) => state.albumArtistIdsMode);
const artistIdsMode = useAppStore((state) => state.artistIdsMode);
const genreIdsMode = useAppStore((state) => state.genreIdsMode);
const setAlbumArtistIdsModeStore = useAppStore((state) => state.actions.setAlbumArtistIdsMode);
const setArtistIdsModeStore = useAppStore((state) => state.actions.setArtistIdsMode);
const setGenreIdsModeStore = useAppStore((state) => state.actions.setGenreIdsMode);
const { sortBy } = useSortByFilter<SongListSort>(SongListSort.ID, ItemListKey.PLAYLIST_SONG); const { sortBy } = useSortByFilter<SongListSort>(SongListSort.ID, ItemListKey.PLAYLIST_SONG);
const { sortOrder } = useSortOrderFilter(SortOrder.ASC, ItemListKey.PLAYLIST_SONG); const { sortOrder } = useSortOrderFilter(SortOrder.ASC, ItemListKey.PLAYLIST_SONG);
@@ -24,8 +32,8 @@ export const usePlaylistSongListFilters = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const albumIds = useMemo( const albumArtistIds = useMemo(
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_IDS), () => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS),
[searchParams], [searchParams],
); );
@@ -54,16 +62,22 @@ export const usePlaylistSongListFilters = () => {
[searchParams], [searchParams],
); );
const hasRating = useMemo(
() => parseBooleanParam(searchParams, FILTER_KEYS.SONG.HAS_RATING),
[searchParams],
);
const custom = useMemo( const custom = useMemo(
() => parseCustomFiltersParam(searchParams, FILTER_KEYS.SONG._CUSTOM), () => parseCustomFiltersParam(searchParams, FILTER_KEYS.SONG._CUSTOM),
[searchParams], [searchParams],
); );
const setAlbumIds = useCallback( const setAlbumArtistIds = useCallback(
(value: null | string[]) => { (value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_IDS, value), { setSearchParams(
replace: true, (prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS, value),
}); { replace: true },
);
}, },
[setSearchParams], [setSearchParams],
); );
@@ -113,6 +127,30 @@ export const usePlaylistSongListFilters = () => {
[setSearchParams], [setSearchParams],
); );
const setHasRating = useCallback(
(value: boolean | null) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.HAS_RATING, value), {
replace: true,
});
},
[setSearchParams],
);
const setAlbumArtistIdsMode = useCallback(
(value: 'and' | 'or') => setAlbumArtistIdsModeStore(value),
[setAlbumArtistIdsModeStore],
);
const setArtistIdsMode = useCallback(
(value: 'and' | 'or') => setArtistIdsModeStore(value),
[setArtistIdsModeStore],
);
const setGenreIdsMode = useCallback(
(value: 'and' | 'or') => setGenreIdsModeStore(value),
[setGenreIdsModeStore],
);
const setCustom = useCallback( const setCustom = useCallback(
(value: null | Record<string, any>) => { (value: null | Record<string, any>) => {
setSearchParams( setSearchParams(
@@ -141,26 +179,74 @@ export const usePlaylistSongListFilters = () => {
[setSearchParams], [setSearchParams],
); );
const query = { const clear = useCallback(() => {
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined, setSearchParams(
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined, (prev) =>
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined, setMultipleSearchParams(
[FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined, prev,
[FILTER_KEYS.SONG.ALBUM_IDS]: albumIds ?? undefined, {
[FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined, [FILTER_KEYS.SONG._CUSTOM]: null,
[FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined, [FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: null,
[FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined, [FILTER_KEYS.SONG.ARTIST_IDS]: null,
[FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined, [FILTER_KEYS.SONG.FAVORITE]: null,
[FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined, [FILTER_KEYS.SONG.GENRE_ID]: null,
}; [FILTER_KEYS.SONG.HAS_RATING]: null,
[FILTER_KEYS.SONG.MAX_YEAR]: null,
[FILTER_KEYS.SONG.MIN_YEAR]: null,
},
new Set([FILTER_KEYS.SONG._CUSTOM]),
),
{ replace: true },
);
}, [setSearchParams]);
const query = useMemo(
() => ({
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
[FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: albumArtistIds ?? undefined,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE]: albumArtistIdsMode,
[FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined,
[FILTER_KEYS.SONG.ARTIST_IDS_MODE]: artistIdsMode,
[FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined,
[FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined,
[FILTER_KEYS.SONG.GENRE_ID_MODE]: genreIdsMode,
[FILTER_KEYS.SONG.HAS_RATING]: hasRating ?? undefined,
[FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined,
[FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined,
}),
[
searchTerm,
sortBy,
sortOrder,
custom,
albumArtistIds,
albumArtistIdsMode,
artistIds,
artistIdsMode,
favorite,
genreId,
genreIdsMode,
hasRating,
maxYear,
minYear,
],
);
return { return {
clear,
query, query,
setAlbumIds, setAlbumArtistIds,
setAlbumArtistIdsMode,
setArtistIds, setArtistIds,
setArtistIdsMode,
setCustom, setCustom,
setFavorite, setFavorite,
setGenreId, setGenreId,
setGenreIdsMode,
setHasRating,
setMaxYear, setMaxYear,
setMinYear, setMinYear,
setSearchTerm, setSearchTerm,
@@ -0,0 +1,118 @@
import { useEffect, useMemo } from 'react';
import { useListContext } from '/@/renderer/context/list-context';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
import { sortSongList } from '/@/shared/api/utils';
import {
LibraryItem,
PlaylistSongListResponse,
Song,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
export function applyClientSideSongFilters(songs: Song[], query: Record<string, unknown>): Song[] {
let result = songs;
const favorite = query[FILTER_KEYS.SONG.FAVORITE] as boolean | undefined;
if (favorite === true) {
result = result.filter((s) => s.userFavorite === true);
} else if (favorite === false) {
result = result.filter((s) => s.userFavorite === false);
}
const hasRating = query[FILTER_KEYS.SONG.HAS_RATING] as boolean | undefined;
if (hasRating === true) {
result = result.filter((s) => s.userRating != null && s.userRating > 0);
} else if (hasRating === false) {
result = result.filter((s) => s.userRating == null || s.userRating === 0);
}
const albumArtistIdsMode =
(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const albumArtistIds = query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS] as string[] | undefined;
if (albumArtistIds?.length) {
if (albumArtistIdsMode === 'and') {
result = result.filter((s) =>
albumArtistIds!.every((id) => s.albumArtists?.some((a) => a.id === id)),
);
} else {
const set = new Set(albumArtistIds);
result = result.filter((s) => s.albumArtists?.some((a) => a.id && set.has(a.id)));
}
}
const artistIdsMode =
(query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const artistIds = query[FILTER_KEYS.SONG.ARTIST_IDS] as string[] | undefined;
if (artistIds?.length) {
if (artistIdsMode === 'and') {
result = result.filter((s) =>
artistIds!.every((id) => s.artists?.some((a) => a.id === id)),
);
} else {
const set = new Set(artistIds);
result = result.filter((s) => s.artists?.some((a) => a.id && set.has(a.id)));
}
}
const genreIdsMode =
(query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';
const genreIds = query[FILTER_KEYS.SONG.GENRE_ID] as string[] | undefined;
if (genreIds?.length) {
if (genreIdsMode === 'and') {
result = result.filter((s) =>
genreIds!.every((id) => s.genres?.some((g) => g.id === id)),
);
} else {
const set = new Set(genreIds);
result = result.filter((s) => s.genres?.some((g) => g.id && set.has(g.id)));
}
}
const minYear = query[FILTER_KEYS.SONG.MIN_YEAR] as number | undefined;
if (minYear != null) {
result = result.filter((s) => s.releaseYear != null && s.releaseYear >= minYear);
}
const maxYear = query[FILTER_KEYS.SONG.MAX_YEAR] as number | undefined;
if (maxYear != null) {
result = result.filter((s) => s.releaseYear != null && s.releaseYear <= maxYear);
}
return result;
}
export function usePlaylistTrackList(data: PlaylistSongListResponse | undefined): {
sortedAndFilteredSongs: Song[];
totalCount: number;
} {
const { setItemCount, setListData } = useListContext();
const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters();
const sortedAndFilteredSongs = useMemo(() => {
const raw = data?.items ?? [];
const filtered = applyClientSideSongFilters(raw, query as Record<string, unknown>);
const searched = searchTerm
? searchLibraryItems(filtered, searchTerm, LibraryItem.SONG)
: filtered;
return sortSongList(
searched,
(query.sortBy as SongListSort) ?? SongListSort.ID,
(query.sortOrder as SortOrder) ?? SortOrder.ASC,
);
}, [data?.items, query, searchTerm]);
const totalCount = sortedAndFilteredSongs.length;
useEffect(() => {
setListData?.(sortedAndFilteredSongs);
setItemCount?.(totalCount);
}, [query, searchTerm, setListData, setItemCount, sortedAndFilteredSongs, totalCount]);
return { sortedAndFilteredSongs, totalCount };
}
@@ -6,6 +6,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
import { useRecentPlaylists } from '/@/renderer/features/playlists/hooks/use-recent-playlists'; import { useRecentPlaylists } from '/@/renderer/features/playlists/hooks/use-recent-playlists';
import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { useCurrentServerId } from '/@/renderer/store'; import { useCurrentServerId } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { AddToPlaylistArgs, AddToPlaylistResponse } from '/@/shared/types/domain-types'; import { AddToPlaylistArgs, AddToPlaylistResponse } from '/@/shared/types/domain-types';
export const useAddToPlaylist = (args: MutationHookArgs) => { export const useAddToPlaylist = (args: MutationHookArgs) => {
@@ -22,6 +23,17 @@ export const useAddToPlaylist = (args: MutationHookArgs) => {
apiClientProps: { serverId: args.apiClientProps.serverId }, apiClientProps: { serverId: args.apiClientProps.serverId },
}); });
}, },
onError: (error, variables) => {
logFn.error('Add to playlist failed', {
category: LogCategory.API,
meta: {
message: error?.message,
playlistId: variables.query.id,
serverId: variables.apiClientProps.serverId,
},
});
options?.onError?.(error);
},
onSuccess: (_data, variables, context) => { onSuccess: (_data, variables, context) => {
const { apiClientProps } = variables; const { apiClientProps } = variables;
const serverId = apiClientProps.serverId; const serverId = apiClientProps.serverId;
@@ -4,6 +4,7 @@ import { AxiosError } from 'axios';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { CreatePlaylistArgs, CreatePlaylistResponse } from '/@/shared/types/domain-types'; import { CreatePlaylistArgs, CreatePlaylistResponse } from '/@/shared/types/domain-types';
export const useCreatePlaylist = (args: MutationHookArgs) => { export const useCreatePlaylist = (args: MutationHookArgs) => {
@@ -17,6 +18,16 @@ export const useCreatePlaylist = (args: MutationHookArgs) => {
apiClientProps: { serverId: args.apiClientProps.serverId }, apiClientProps: { serverId: args.apiClientProps.serverId },
}); });
}, },
onError: (error, variables) => {
logFn.error('Create playlist failed', {
category: LogCategory.API,
meta: {
message: error?.message,
serverId: variables.apiClientProps.serverId,
},
});
options?.onError?.(error);
},
onSuccess: (_args, variables) => { onSuccess: (_args, variables) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
exact: false, exact: false,
@@ -9,6 +9,7 @@ import {
restorePlaylistQueryData, restorePlaylistQueryData,
} from '/@/renderer/features/playlists/mutations/playlist-optimistic-updates'; } from '/@/renderer/features/playlists/mutations/playlist-optimistic-updates';
import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { DeletePlaylistArgs, DeletePlaylistResponse } from '/@/shared/types/domain-types'; import { DeletePlaylistArgs, DeletePlaylistResponse } from '/@/shared/types/domain-types';
export const useDeletePlaylist = (args: MutationHookArgs) => { export const useDeletePlaylist = (args: MutationHookArgs) => {
@@ -24,6 +25,14 @@ export const useDeletePlaylist = (args: MutationHookArgs) => {
}); });
}, },
onError: (_error, _variables, context) => { onError: (_error, _variables, context) => {
logFn.error('Delete playlist failed', {
category: LogCategory.API,
meta: {
message: _error?.message,
playlistId: _variables.query.id,
serverId: _variables.apiClientProps.serverId,
},
});
if (context) { if (context) {
restorePlaylistQueryData(queryClient, context); restorePlaylistQueryData(queryClient, context);
} }
@@ -4,6 +4,7 @@ import { AxiosError } from 'axios';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationOptions } from '/@/renderer/lib/react-query'; import { MutationOptions } from '/@/renderer/lib/react-query';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { RemoveFromPlaylistArgs, RemoveFromPlaylistResponse } from '/@/shared/types/domain-types'; import { RemoveFromPlaylistArgs, RemoveFromPlaylistResponse } from '/@/shared/types/domain-types';
export const useRemoveFromPlaylist = (options?: MutationOptions) => { export const useRemoveFromPlaylist = (options?: MutationOptions) => {
@@ -16,6 +17,17 @@ export const useRemoveFromPlaylist = (options?: MutationOptions) => {
apiClientProps: { serverId: args.apiClientProps.serverId }, apiClientProps: { serverId: args.apiClientProps.serverId },
}); });
}, },
onError: (error, variables) => {
logFn.error('Remove from playlist failed', {
category: LogCategory.API,
meta: {
message: error?.message,
playlistId: variables.query.id,
serverId: variables.apiClientProps.serverId,
},
});
options?.onError?.(error);
},
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
const { apiClientProps } = variables; const { apiClientProps } = variables;
const serverId = apiClientProps.serverId; const serverId = apiClientProps.serverId;
@@ -6,6 +6,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
import { useRecentPlaylists } from '/@/renderer/features/playlists/hooks/use-recent-playlists'; import { useRecentPlaylists } from '/@/renderer/features/playlists/hooks/use-recent-playlists';
import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { useCurrentServerId } from '/@/renderer/store'; import { useCurrentServerId } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { ReplacePlaylistArgs, ReplacePlaylistResponse } from '/@/shared/types/domain-types'; import { ReplacePlaylistArgs, ReplacePlaylistResponse } from '/@/shared/types/domain-types';
export const useReplacePlaylist = (args: MutationHookArgs) => { export const useReplacePlaylist = (args: MutationHookArgs) => {
@@ -22,6 +23,17 @@ export const useReplacePlaylist = (args: MutationHookArgs) => {
apiClientProps: { serverId: args.apiClientProps.serverId }, apiClientProps: { serverId: args.apiClientProps.serverId },
}); });
}, },
onError: (error, variables) => {
logFn.error('Replace playlist failed', {
category: LogCategory.API,
meta: {
message: error?.message,
playlistId: variables.query.id,
serverId: variables.apiClientProps.serverId,
},
});
options?.onError?.(error);
},
onSuccess: (_data, variables, context) => { onSuccess: (_data, variables, context) => {
const { apiClientProps } = variables; const { apiClientProps } = variables;
const serverId = apiClientProps.serverId; const serverId = apiClientProps.serverId;
@@ -4,6 +4,7 @@ import { AxiosError } from 'axios';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { UpdatePlaylistArgs, UpdatePlaylistResponse } from '/@/shared/types/domain-types'; import { UpdatePlaylistArgs, UpdatePlaylistResponse } from '/@/shared/types/domain-types';
export const useUpdatePlaylist = (args: MutationHookArgs) => { export const useUpdatePlaylist = (args: MutationHookArgs) => {
@@ -17,6 +18,17 @@ export const useUpdatePlaylist = (args: MutationHookArgs) => {
apiClientProps: { serverId: args.apiClientProps.serverId }, apiClientProps: { serverId: args.apiClientProps.serverId },
}); });
}, },
onError: (error, variables) => {
logFn.error('Update playlist failed', {
category: LogCategory.API,
meta: {
message: error?.message,
playlistId: variables.query?.id,
serverId: variables.apiClientProps.serverId,
},
});
options?.onError?.(error);
},
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
const { apiClientProps, query } = variables; const { apiClientProps, query } = variables;
const serverId = apiClientProps.serverId; const serverId = apiClientProps.serverId;
@@ -4,8 +4,9 @@ import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { generatePath, useLocation, useNavigate, useParams } from 'react-router'; import { generatePath, useLocation, useNavigate, useParams } from 'react-router';
import { ListContext } from '/@/renderer/context/list-context'; import { ListContext, useListContext } from '/@/renderer/context/list-context';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { ClientSideSongFilters } from '/@/renderer/features/playlists/components/client-side-song-filters';
import { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content'; import { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content';
import { PlaylistDetailSongListHeader } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header'; import { PlaylistDetailSongListHeader } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header';
import { import {
@@ -13,23 +14,32 @@ import {
PlaylistQueryBuilderRef, PlaylistQueryBuilderRef,
} from '/@/renderer/features/playlists/components/playlist-query-builder'; } from '/@/renderer/features/playlists/components/playlist-query-builder';
import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form'; import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation'; import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation'; import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils'; import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview'; import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store'; import {
PlaylistTarget,
useCurrentServer,
usePageSidebar,
usePlaylistTarget,
} from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { ConfirmModal } from '/@/shared/components/modal/modal'; import { ConfirmModal } from '/@/shared/components/modal/modal';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { Spinner } from '/@/shared/components/spinner/spinner'; import { Spinner } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast'; 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'; import { ItemListKey } from '/@/shared/types/types';
interface PlaylistQueryEditorProps { interface PlaylistQueryEditorProps {
@@ -154,14 +164,17 @@ const PlaylistQueryEditor = ({
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]); }, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
return ( return (
<div className="query-editor-container"> <div
<Stack gap={0} h="100%" mah="30dvh" p="md" w="100%"> className="query-editor-container"
<Group justify="space-between" pb="md" wrap="nowrap"> 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"> <Group gap="sm" wrap="nowrap">
<Button <Button
leftSection={ leftSection={
<Icon <Icon
icon={isQueryBuilderExpanded ? 'arrowUpS' : 'arrowDownS'} icon={isQueryBuilderExpanded ? 'arrowDownS' : 'arrowUpS'}
size="lg" size="lg"
/> />
} }
@@ -233,6 +246,38 @@ const PlaylistQueryEditor = ({
); );
}; };
const PlaylistSongListFiltersSidebar = () => {
const { t } = useTranslation();
const { setIsSidebarOpen } = useListContext();
const { clear } = usePlaylistSongListFilters();
return (
<Stack h="100%" style={{ minHeight: 0 }}>
<Group justify="space-between" pb={0} pl="md" pr="md" pt="md">
<Text fw={500} size="xl">
{t('common.filters', { postProcess: 'sentenceCase' })}
</Text>
<Group gap="xs">
<Button onClick={clear} size="compact-sm" variant="subtle">
{t('common.reset', { postProcess: 'sentenceCase' })}
</Button>
{setIsSidebarOpen && (
<ActionIcon
icon="unpin"
onClick={() => setIsSidebarOpen(false)}
size="compact-sm"
variant="subtle"
/>
)}
</Group>
</Group>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<ClientSideSongFilters />
</ScrollArea>
</Stack>
);
};
const PlaylistDetailSongListRoute = () => { const PlaylistDetailSongListRoute = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -396,24 +441,45 @@ const PlaylistDetailSongListRoute = () => {
setIsQueryBuilderExpanded(true); 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 [itemCount, setItemCount] = useState<number | undefined>(undefined);
const [listData, setListData] = useState<unknown[]>([]); const [listData, setListData] = useState<unknown[]>([]);
const [mode, setMode] = useState<'edit' | 'view'>('view'); const [mode, setMode] = useState<'edit' | 'view'>('view');
const [isSidebarOpen, setIsSidebarOpen] = usePageSidebar(listKey);
const providerValue = useMemo(() => { const providerValue = useMemo(() => {
return { return {
customFilters: undefined, customFilters: undefined,
displayMode,
id: playlistId, id: playlistId,
isSidebarOpen,
isSmartPlaylist, isSmartPlaylist,
itemCount, itemCount,
listData, listData,
listKey,
mode, mode,
pageKey: ItemListKey.PLAYLIST_SONG, pageKey: listKey,
setIsSidebarOpen,
setItemCount, setItemCount,
setListData, setListData,
setMode, setMode,
}; };
}, [playlistId, isSmartPlaylist, itemCount, listData, mode]); }, [
playlistId,
isSmartPlaylist,
displayMode,
listKey,
isSidebarOpen,
itemCount,
listData,
mode,
setIsSidebarOpen,
]);
return ( return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}> <AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
@@ -429,6 +495,15 @@ const PlaylistDetailSongListRoute = () => {
onDelete={() => openDeletePlaylistModal()} onDelete={() => openDeletePlaylistModal()}
onToggleQueryBuilder={handleToggleShowQueryBuilder} onToggleQueryBuilder={handleToggleShowQueryBuilder}
/> />
<ListWithSidebarContainer>
<ListWithSidebarContainer.SidebarPortal>
<PlaylistSongListFiltersSidebar />
</ListWithSidebarContainer.SidebarPortal>
<Suspense fallback={<Spinner container />}>
<PlaylistDetailSongListContent />
</Suspense>
</ListWithSidebarContainer>
{(isSmartPlaylist || showQueryBuilder) && ( {(isSmartPlaylist || showQueryBuilder) && (
<PlaylistQueryEditor <PlaylistQueryEditor
createPlaylistMutation={createPlaylistMutation} createPlaylistMutation={createPlaylistMutation}
@@ -441,9 +516,6 @@ const PlaylistDetailSongListRoute = () => {
queryBuilderRef={queryBuilderRef} queryBuilderRef={queryBuilderRef}
/> />
)} )}
<Suspense fallback={<Spinner container />}>
<PlaylistDetailSongListContent />
</Suspense>
</ListContext.Provider> </ListContext.Provider>
</AnimatedPage> </AnimatedPage>
); );
+67
View File
@@ -1,8 +1,75 @@
import { nanoid } from 'nanoid/non-secure'; import { nanoid } from 'nanoid/non-secure';
import { NDSongQueryFields } from '/@/shared/api/navidrome/navidrome-types'; import { NDSongQueryFields } from '/@/shared/api/navidrome/navidrome-types';
import { Album, LibraryItem, Song } from '/@/shared/types/domain-types';
import { QueryBuilderGroup } from '/@/shared/types/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[]) => { export const parseQueryBuilderChildren = (groups: QueryBuilderGroup[], data: any[]) => {
if (groups.length === 0) { if (groups.length === 0) {
return data; return data;
@@ -4,8 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useUpdateRadioStation } from '/@/renderer/features/radio/mutations/update-radio-station-mutation'; import { useUpdateRadioStation } from '/@/renderer/features/radio/mutations/update-radio-station-mutation';
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { closeAllModals, openModal } from '/@/shared/components/modal/modal'; import { closeAllModals, openModal } from '/@/shared/components/modal/modal';
import { ModalButton } from '/@/shared/components/modal/model-shared'; import { ModalButton } from '/@/shared/components/modal/model-shared';
@@ -48,7 +47,8 @@ export const EditRadioStationForm = ({ onCancel, station }: EditRadioStationForm
}, },
{ {
onError: (error) => { onError: (error) => {
logFn.error(logMsg.other.error, { logFn.error('An error occurred', {
category: LogCategory.OTHER,
meta: { error: error as Error }, meta: { error: error as Error },
}); });
@@ -4,6 +4,7 @@ import { AxiosError } from 'axios';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { import {
CreateInternetRadioStationArgs, CreateInternetRadioStationArgs,
CreateInternetRadioStationResponse, CreateInternetRadioStationResponse,
@@ -25,6 +26,16 @@ export const useCreateRadioStation = (args: MutationHookArgs) => {
apiClientProps: { serverId: args.apiClientProps.serverId }, apiClientProps: { serverId: args.apiClientProps.serverId },
}); });
}, },
onError: (error, variables) => {
logFn.error('Create radio station failed', {
category: LogCategory.API,
meta: {
message: error?.message,
serverId: variables.apiClientProps.serverId,
},
});
options?.onError?.(error);
},
onSuccess: (_args, variables) => { onSuccess: (_args, variables) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
exact: false, exact: false,
@@ -4,6 +4,7 @@ import { AxiosError } from 'axios';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { import {
DeleteInternetRadioStationArgs, DeleteInternetRadioStationArgs,
DeleteInternetRadioStationResponse, DeleteInternetRadioStationResponse,
@@ -25,6 +26,17 @@ export const useDeleteRadioStation = (args: MutationHookArgs) => {
apiClientProps: { serverId: args.apiClientProps.serverId }, apiClientProps: { serverId: args.apiClientProps.serverId },
}); });
}, },
onError: (error, variables) => {
logFn.error('Delete radio station failed', {
category: LogCategory.API,
meta: {
message: error?.message,
serverId: variables.apiClientProps.serverId,
stationId: variables.query?.id,
},
});
options?.onError?.(error);
},
onSuccess: (_args, variables) => { onSuccess: (_args, variables) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
exact: false, exact: false,
@@ -4,6 +4,7 @@ import { AxiosError } from 'axios';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { import {
UpdateInternetRadioStationArgs, UpdateInternetRadioStationArgs,
UpdateInternetRadioStationResponse, UpdateInternetRadioStationResponse,
@@ -25,6 +26,17 @@ export const useUpdateRadioStation = (args: MutationHookArgs) => {
apiClientProps: { serverId: args.apiClientProps.serverId }, apiClientProps: { serverId: args.apiClientProps.serverId },
}); });
}, },
onError: (error, variables) => {
logFn.error('Update radio station failed', {
category: LogCategory.API,
meta: {
message: error?.message,
serverId: variables.apiClientProps.serverId,
stationId: variables.query?.id,
},
});
options?.onError?.(error);
},
onSuccess: (_args, variables) => { onSuccess: (_args, variables) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
exact: false, exact: false,
@@ -8,7 +8,6 @@ import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { usePlayerActions, usePlayerStore, useRemoteSettings } from '/@/renderer/store'; import { usePlayerActions, usePlayerStore, useRemoteSettings } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
import { PlayerShuffle } from '/@/shared/types/types'; import { PlayerShuffle } from '/@/shared/types/types';
@@ -33,7 +32,7 @@ export const useRemote = () => {
return; return;
} }
logFn.debug(logMsg[LogCategory.REMOTE].initializingRemoteSettings, { logFn.debug('Initializing remote settings', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { meta: {
enabled: remoteSettings.enabled, enabled: remoteSettings.enabled,
@@ -50,7 +49,7 @@ export const useRemote = () => {
remoteSettings.password, remoteSettings.password,
) )
.catch((error) => { .catch((error) => {
logFn.error(logMsg[LogCategory.REMOTE].failedToEnableRemote, { logFn.error('Failed to enable remote', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { error }, meta: { error },
}); });
@@ -66,7 +65,7 @@ export const useRemote = () => {
} }
remote.requestPosition((_e: unknown, data: { position: number }) => { remote.requestPosition((_e: unknown, data: { position: number }) => {
logFn.debug(logMsg[LogCategory.REMOTE].requestPositionReceived, { logFn.debug('Request position received', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { position: data.position }, meta: { position: data.position },
}); });
@@ -75,7 +74,7 @@ export const useRemote = () => {
}); });
remote.requestSeek((_e: unknown, data: { offset: number }) => { remote.requestSeek((_e: unknown, data: { offset: number }) => {
logFn.debug(logMsg[LogCategory.REMOTE].requestSeekReceived, { logFn.debug('Request seek received', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { offset: data.offset }, meta: { offset: data.offset },
}); });
@@ -84,7 +83,7 @@ export const useRemote = () => {
remote.requestRating( remote.requestRating(
(_e: unknown, data: { id: string; rating: number; serverId: string }) => { (_e: unknown, data: { id: string; rating: number; serverId: string }) => {
logFn.debug(logMsg[LogCategory.REMOTE].requestRatingReceived, { logFn.debug('Request rating received', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { id: data.id, rating: data.rating, serverId: data.serverId }, meta: { id: data.id, rating: data.rating, serverId: data.serverId },
}); });
@@ -93,7 +92,7 @@ export const useRemote = () => {
); );
remote.requestVolume((_e: unknown, data: { volume: number }) => { remote.requestVolume((_e: unknown, data: { volume: number }) => {
logFn.debug(logMsg[LogCategory.REMOTE].requestVolumeReceived, { logFn.debug('Request volume received', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { volume: data.volume }, meta: { volume: data.volume },
}); });
@@ -102,7 +101,7 @@ export const useRemote = () => {
remote.requestFavorite( remote.requestFavorite(
(_e: unknown, data: { favorite: boolean; id: string; serverId: string }) => { (_e: unknown, data: { favorite: boolean; id: string; serverId: string }) => {
logFn.debug(logMsg[LogCategory.REMOTE].requestFavoriteReceived, { logFn.debug('Request favorite received', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { favorite: data.favorite, id: data.id, serverId: data.serverId }, meta: { favorite: data.favorite, id: data.id, serverId: data.serverId },
}); });
@@ -148,7 +147,7 @@ export const useRemote = () => {
const currentSong = player.getCurrentSong(); const currentSong = player.getCurrentSong();
if (currentSong) { if (currentSong) {
logFn.debug(logMsg[LogCategory.REMOTE].sendingInitialSong, { logFn.debug('Sending initial song', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { meta: {
artistName: currentSong.artistName, artistName: currentSong.artistName,
@@ -178,7 +177,7 @@ export const useRemote = () => {
return; return;
} }
logFn.debug(logMsg[LogCategory.REMOTE].updateSongSent, { logFn.debug('Update song sent', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { meta: {
artistName: properties.song?.artistName, artistName: properties.song?.artistName,
@@ -209,7 +208,7 @@ export const useRemote = () => {
return; return;
} }
logFn.debug(logMsg[LogCategory.REMOTE].updatePositionSent, { logFn.debug('Update position sent', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { timestamp: properties.timestamp }, meta: { timestamp: properties.timestamp },
}); });
@@ -220,7 +219,7 @@ export const useRemote = () => {
return; return;
} }
logFn.debug(logMsg[LogCategory.REMOTE].updateRepeatSent, { logFn.debug('Update repeat sent', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { repeat: properties.repeat }, meta: { repeat: properties.repeat },
}); });
@@ -232,7 +231,7 @@ export const useRemote = () => {
} }
const isShuffleEnabled = properties.shuffle !== PlayerShuffle.NONE; const isShuffleEnabled = properties.shuffle !== PlayerShuffle.NONE;
logFn.debug(logMsg[LogCategory.REMOTE].updateShuffleSent, { logFn.debug('Update shuffle sent', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { isShuffleEnabled, shuffle: properties.shuffle }, meta: { isShuffleEnabled, shuffle: properties.shuffle },
}); });
@@ -243,7 +242,7 @@ export const useRemote = () => {
return; return;
} }
logFn.debug(logMsg[LogCategory.REMOTE].updatePlaybackSent, { logFn.debug('Update playback sent', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { status: properties.status }, meta: { status: properties.status },
}); });
@@ -254,7 +253,7 @@ export const useRemote = () => {
return; return;
} }
logFn.debug(logMsg[LogCategory.REMOTE].updateVolumeSent, { logFn.debug('Update volume sent', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { volume: properties.volume }, meta: { volume: properties.volume },
}); });
@@ -265,7 +264,7 @@ export const useRemote = () => {
return; return;
} }
logFn.debug(logMsg[LogCategory.REMOTE].updateFavoriteSent, { logFn.debug('Update favorite sent', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { meta: {
favorite: properties.favorite, favorite: properties.favorite,
@@ -280,7 +279,7 @@ export const useRemote = () => {
return; return;
} }
logFn.debug(logMsg[LogCategory.REMOTE].updateRatingSent, { logFn.debug('Update rating sent', {
category: LogCategory.REMOTE, category: LogCategory.REMOTE,
meta: { meta: {
id: properties.id, id: properties.id,
@@ -14,6 +14,7 @@ import NavidromeIcon from '/@/renderer/features/servers/assets/navidrome.png';
import SubsonicIcon from '/@/renderer/features/servers/assets/opensubsonic.png'; import SubsonicIcon from '/@/renderer/features/servers/assets/opensubsonic.png';
import { IgnoreCorsSslSwitches } from '/@/renderer/features/servers/components/ignore-cors-ssl-switches'; import { IgnoreCorsSslSwitches } from '/@/renderer/features/servers/components/ignore-cors-ssl-switches';
import { useAuthStoreActions } from '/@/renderer/store'; import { useAuthStoreActions } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { Checkbox } from '/@/shared/components/checkbox/checkbox'; import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Divider } from '/@/shared/components/divider/divider'; import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
@@ -149,6 +150,10 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
); );
if (!data) { if (!data) {
logFn.error('Add server failed (no data returned)', {
category: LogCategory.SYSTEM,
meta: { name: values.name, serverType: values.type, url: values.url },
});
return toast.error({ return toast.error({
message: t('error.authenticationFailed', { postProcess: 'sentenceCase' }), message: t('error.authenticationFailed', { postProcess: 'sentenceCase' }),
}); });
@@ -189,6 +194,15 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
setCurrentServer(serverItem); setCurrentServer(serverItem);
closeAllModals(); closeAllModals();
logFn.info('Add server successful', {
category: LogCategory.SYSTEM,
meta: {
name: values.name,
serverId: serverItem.id,
serverType: values.type,
url: values.url,
},
});
toast.success({ toast.success({
message: t('form.addServer.success', { postProcess: 'sentenceCase' }), message: t('form.addServer.success', { postProcess: 'sentenceCase' }),
}); });
@@ -205,6 +219,15 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
} }
} }
} catch (err: any) { } catch (err: any) {
logFn.error('Add server failed', {
category: LogCategory.SYSTEM,
meta: {
message: err?.message,
name: values.name,
serverType: values.type,
url: values.url,
},
});
setIsLoading(false); setIsLoading(false);
return toast.error({ message: err?.message }); return toast.error({ message: err?.message });
} }
@@ -109,9 +109,7 @@ export const ThemeSettings = memo(() => {
localSettings.themeSet( localSettings.themeSet(
e.currentTarget.checked e.currentTarget.checked
? 'system' ? 'system'
: settings.theme === AppTheme.DEFAULT_DARK : (getAppTheme(settings.theme).mode ?? 'dark'),
? 'dark'
: 'light',
); );
} }
}} }}
@@ -138,7 +136,7 @@ export const ThemeSettings = memo(() => {
}, },
}); });
const colorScheme = theme === AppTheme.DEFAULT_DARK ? 'dark' : 'light'; const colorScheme = getAppTheme(theme).mode ?? 'dark';
setColorScheme(colorScheme); setColorScheme(colorScheme);
@@ -98,6 +98,28 @@ export const DiscordSettings = memo(() => {
postProcess: 'sentenceCase', 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: ( control: (
<Switch <Switch
@@ -1,6 +1,7 @@
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { Box } from '/@/shared/components/box/box'; import { Box } from '/@/shared/components/box/box';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center'; import { Center } from '/@/shared/components/center/center';
@@ -43,5 +44,22 @@ interface ComponentErrorBoundaryProps {
} }
export const ComponentErrorBoundary = ({ children }: ComponentErrorBoundaryProps) => { export const ComponentErrorBoundary = ({ children }: ComponentErrorBoundaryProps) => {
return <ErrorBoundary FallbackComponent={ComponentErrorFallback}>{children}</ErrorBoundary>; return (
<ErrorBoundary
FallbackComponent={ComponentErrorFallback}
onError={(error, errorInfo) => {
logFn.error('Component error boundary caught an error', {
category: LogCategory.OTHER,
meta: {
componentStack: errorInfo?.componentStack,
message: error?.message,
name: error?.name,
stack: error?.stack,
},
});
}}
>
{children}
</ErrorBoundary>
);
}; };
@@ -1,3 +1,10 @@
.top-right {
position: absolute;
top: var(--theme-spacing-lg);
right: var(--theme-spacing-md);
z-index: 20;
}
.library-header { .library-header {
position: relative; position: relative;
display: grid; display: grid;
@@ -56,6 +63,52 @@
height: 250px; 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 { .image-section {
@@ -32,6 +32,7 @@ import { Play } from '/@/shared/types/types';
interface LibraryHeaderProps { interface LibraryHeaderProps {
children?: ReactNode; children?: ReactNode;
compact?: boolean;
containerClassName?: string; containerClassName?: string;
imagePlaceholderUrl?: null | string; imagePlaceholderUrl?: null | string;
imageUrl?: null | string; imageUrl?: null | string;
@@ -45,11 +46,20 @@ interface LibraryHeaderProps {
}; };
loading?: boolean; loading?: boolean;
title: string; title: string;
topRight?: ReactNode;
} }
export const LibraryHeader = forwardRef( export const LibraryHeader = forwardRef(
( (
{ children, containerClassName, imageUrl, item, title }: LibraryHeaderProps, {
children,
compact,
containerClassName,
imageUrl,
item,
title,
topRight,
}: LibraryHeaderProps,
ref: Ref<HTMLDivElement>, ref: Ref<HTMLDivElement>,
) => { ) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -125,7 +135,15 @@ export const LibraryHeader = forwardRef(
}, [item.explicitStatus, item.imageId, item.type]); }, [item.explicitStatus, item.imageId, item.type]);
return ( 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 <div
className={styles.imageSection} className={styles.imageSection}
onClick={() => { onClick={() => {
@@ -224,6 +224,11 @@ export const CLIENT_SIDE_ALBUM_FILTERS = [
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }), name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST, value: AlbumListSort.ALBUM_ARTIST,
}, },
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: AlbumListSort.ID,
},
{ {
defaultOrder: SortOrder.DESC, defaultOrder: SortOrder.DESC,
name: i18n.t('filter.duration', { postProcess: 'titleCase' }), name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
@@ -295,6 +300,11 @@ const ALBUM_LIST_FILTERS: Partial<
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }), name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST, value: AlbumListSort.ALBUM_ARTIST,
}, },
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: AlbumListSort.ID,
},
{ {
defaultOrder: SortOrder.DESC, defaultOrder: SortOrder.DESC,
name: i18n.t('filter.communityRating', { postProcess: 'titleCase' }), name: i18n.t('filter.communityRating', { postProcess: 'titleCase' }),
@@ -337,6 +347,11 @@ const ALBUM_LIST_FILTERS: Partial<
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }), name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST, value: AlbumListSort.ALBUM_ARTIST,
}, },
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: AlbumListSort.ID,
},
{ {
defaultOrder: SortOrder.ASC, defaultOrder: SortOrder.ASC,
name: i18n.t('filter.artist', { postProcess: 'titleCase' }), name: i18n.t('filter.artist', { postProcess: 'titleCase' }),
@@ -399,6 +414,11 @@ const ALBUM_LIST_FILTERS: Partial<
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }), name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST, value: AlbumListSort.ALBUM_ARTIST,
}, },
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: AlbumListSort.ID,
},
{ {
defaultOrder: SortOrder.DESC, defaultOrder: SortOrder.DESC,
name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }), name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),
@@ -2,6 +2,7 @@ import { ErrorBoundary } from 'react-error-boundary';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector'; import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { Box } from '/@/shared/components/box/box'; import { Box } from '/@/shared/components/box/box';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center'; import { Center } from '/@/shared/components/center/center';
@@ -85,9 +86,15 @@ export const PageErrorBoundary = ({ children }: PageErrorBoundaryProps) => {
<ErrorBoundary <ErrorBoundary
FallbackComponent={PageErrorFallback} FallbackComponent={PageErrorFallback}
onError={(error, errorInfo) => { onError={(error, errorInfo) => {
if (process.env.NODE_ENV === 'development') { logFn.error('Page error boundary caught an error', {
console.error('Page error boundary caught an error:', error, errorInfo); category: LogCategory.OTHER,
} meta: {
componentStack: errorInfo?.componentStack,
message: error?.message,
name: error?.name,
stack: error?.stack,
},
});
}} }}
onReset={() => {}} onReset={() => {}}
> >
@@ -2,6 +2,7 @@ import { ErrorBoundary } from 'react-error-boundary';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector'; import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { Box } from '/@/shared/components/box/box'; import { Box } from '/@/shared/components/box/box';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center'; import { Center } from '/@/shared/components/center/center';
@@ -91,9 +92,15 @@ export const RouterErrorBoundary = ({ children }: RouterErrorBoundaryProps) => {
<ErrorBoundary <ErrorBoundary
FallbackComponent={RouterErrorFallback} FallbackComponent={RouterErrorFallback}
onError={(error, errorInfo) => { onError={(error, errorInfo) => {
if (process.env.NODE_ENV === 'development') { logFn.error('Router error boundary caught an error', {
console.error('Root error boundary caught an error:', error, errorInfo); category: LogCategory.OTHER,
} meta: {
componentStack: errorInfo?.componentStack,
message: error?.message,
name: error?.name,
stack: error?.stack,
},
});
}} }}
onReset={() => {}} onReset={() => {}}
> >
@@ -12,6 +12,7 @@ import {
restoreFavoriteQueryData, restoreFavoriteQueryData,
} from '/@/renderer/features/shared/mutations/favorite-optimistic-updates'; } from '/@/renderer/features/shared/mutations/favorite-optimistic-updates';
import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { FavoriteArgs, FavoriteResponse, LibraryItem } from '/@/shared/types/domain-types'; import { FavoriteArgs, FavoriteResponse, LibraryItem } from '/@/shared/types/domain-types';
@@ -33,6 +34,15 @@ export const useCreateFavorite = (args: MutationHookArgs) => {
}, },
mutationKey: createFavoriteMutationKey, mutationKey: createFavoriteMutationKey,
onError: (_error, variables, context) => { onError: (_error, variables, context) => {
logFn.error('Create favorite failed', {
category: LogCategory.API,
meta: {
id: variables.query.id,
message: _error?.message,
serverId: variables.apiClientProps.serverId,
type: variables.query.type,
},
});
if (context) { if (context) {
restoreFavoriteQueryData(queryClient, context); restoreFavoriteQueryData(queryClient, context);
} }
@@ -12,6 +12,7 @@ import {
restoreFavoriteQueryData, restoreFavoriteQueryData,
} from '/@/renderer/features/shared/mutations/favorite-optimistic-updates'; } from '/@/renderer/features/shared/mutations/favorite-optimistic-updates';
import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { FavoriteArgs, FavoriteResponse, LibraryItem } from '/@/shared/types/domain-types'; import { FavoriteArgs, FavoriteResponse, LibraryItem } from '/@/shared/types/domain-types';
@@ -33,6 +34,15 @@ export const useDeleteFavorite = (args: MutationHookArgs) => {
}, },
mutationKey: deleteFavoriteMutationKey, mutationKey: deleteFavoriteMutationKey,
onError: (_error, _variables, context) => { onError: (_error, _variables, context) => {
logFn.error('Delete favorite failed', {
category: LogCategory.API,
meta: {
id: _variables.query.id,
message: _error?.message,
serverId: _variables.apiClientProps.serverId,
type: _variables.query.type,
},
});
if (context) { if (context) {
restoreFavoriteQueryData(queryClient, context); restoreFavoriteQueryData(queryClient, context);
} }
@@ -11,6 +11,7 @@ import {
restoreRatingQueryData, restoreRatingQueryData,
} from '/@/renderer/features/shared/mutations/rating-optimistic-updates'; } from '/@/renderer/features/shared/mutations/rating-optimistic-updates';
import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { LibraryItem, RatingResponse, SetRatingArgs } from '/@/shared/types/domain-types'; import { LibraryItem, RatingResponse, SetRatingArgs } from '/@/shared/types/domain-types';
@@ -30,6 +31,16 @@ export const useSetRatingMutation = (args: MutationHookArgs) => {
}, },
mutationKey: setRatingMutationKey, mutationKey: setRatingMutationKey,
onError: (_error, _variables, context) => { onError: (_error, _variables, context) => {
logFn.error('Set rating failed', {
category: LogCategory.API,
meta: {
id: _variables.query.id,
message: _error?.message,
rating: _variables.query.rating,
serverId: _variables.apiClientProps.serverId,
type: _variables.query.type,
},
});
if (context) { if (context) {
restoreRatingQueryData(queryClient, context); restoreRatingQueryData(queryClient, context);
} }
+5 -1
View File
@@ -61,10 +61,14 @@ enum SharedFilterKeys {
enum SongFilterKeys { enum SongFilterKeys {
_CUSTOM = '_custom', _CUSTOM = '_custom',
ALBUM_IDS = 'albumIds', ALBUM_ARTIST_IDS = 'albumArtistIds',
ALBUM_ARTIST_IDS_MODE = 'albumArtistIdsMode',
ARTIST_IDS = 'artistIds', ARTIST_IDS = 'artistIds',
ARTIST_IDS_MODE = 'artistIdsMode',
FAVORITE = 'favorite', FAVORITE = 'favorite',
GENRE_ID = 'genreIds', GENRE_ID = 'genreIds',
GENRE_ID_MODE = 'genreIdsMode',
HAS_RATING = 'hasRating',
MAX_YEAR = 'maxYear', MAX_YEAR = 'maxYear',
MIN_YEAR = 'minYear', MIN_YEAR = 'minYear',
} }
@@ -3,6 +3,7 @@ import { AxiosError } from 'axios';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { AnyLibraryItems, ShareItemArgs, ShareItemResponse } from '/@/shared/types/domain-types'; import { AnyLibraryItems, ShareItemArgs, ShareItemResponse } from '/@/shared/types/domain-types';
export const useShareItem = (args: MutationHookArgs) => { export const useShareItem = (args: MutationHookArgs) => {
@@ -20,6 +21,17 @@ export const useShareItem = (args: MutationHookArgs) => {
apiClientProps: { serverId: args.apiClientProps.serverId }, apiClientProps: { serverId: args.apiClientProps.serverId },
}); });
}, },
onError: (error, variables) => {
logFn.error('Share item failed', {
category: LogCategory.API,
meta: {
itemType: variables.body?.resourceType,
message: error?.message,
serverId: variables.apiClientProps.serverId,
},
});
options?.onError?.(error);
},
retry: false, retry: false,
...options, ...options,
}); });
@@ -53,6 +53,10 @@
border-radius: var(--theme-radius-md); border-radius: var(--theme-radius-md);
} }
.censored.sidebar-image {
filter: blur(20px);
}
.accordion-root { .accordion-root {
height: 100%; height: 100%;
} }
@@ -24,6 +24,7 @@ import {
useAppStore, useAppStore,
useAppStoreActions, useAppStoreActions,
useFullScreenPlayerStore, useFullScreenPlayerStore,
useGeneralSettings,
usePlayerSong, usePlayerSong,
useSetFullScreenPlayerStore, useSetFullScreenPlayerStore,
} from '/@/renderer/store'; } from '/@/renderer/store';
@@ -42,7 +43,7 @@ import { ImageUnloader } from '/@/shared/components/image/image';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip'; 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'; import { Platform } from '/@/shared/types/types';
export const Sidebar = () => { export const Sidebar = () => {
@@ -167,6 +168,7 @@ const SidebarImage = () => {
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const isRadioActive = useIsRadioActive(); const isRadioActive = useIsRadioActive();
const { isPlaying: isRadioPlaying } = useRadioPlayer(); const { isPlaying: isRadioPlaying } = useRadioPlayer();
const { blurExplicitImages } = useGeneralSettings();
const imageUrl = useItemImageUrl({ const imageUrl = useItemImageUrl({
id: currentSong?.imageId || undefined, id: currentSong?.imageId || undefined,
@@ -235,7 +237,15 @@ const SidebarImage = () => {
<Icon color="muted" icon="radio" size="40%" /> <Icon color="muted" icon="radio" size="40%" />
</Center> </Center>
) : imageUrl ? ( ) : 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" /> <ImageUnloader icon="emptySongImage" />
)} )}
@@ -52,7 +52,6 @@ export const SongListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarget
const query = songFilters.query; const query = songFilters.query;
return Boolean( return Boolean(
isFilterValueSet(query[FILTER_KEYS.SONG._CUSTOM]) || isFilterValueSet(query[FILTER_KEYS.SONG._CUSTOM]) ||
isFilterValueSet(query[FILTER_KEYS.SONG.ALBUM_IDS]) ||
isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) || isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) ||
query[FILTER_KEYS.SONG.FAVORITE] !== undefined || query[FILTER_KEYS.SONG.FAVORITE] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) || isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) ||
@@ -28,11 +28,6 @@ export const useSongListFilters = (listKey?: ItemListKey) => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const albumIds = useMemo(
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_IDS),
[searchParams],
);
const genreId = useMemo( const genreId = useMemo(
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.GENRE_ID), () => parseArrayParam(searchParams, FILTER_KEYS.SONG.GENRE_ID),
[searchParams], [searchParams],
@@ -63,15 +58,6 @@ export const useSongListFilters = (listKey?: ItemListKey) => {
[searchParams], [searchParams],
); );
const setAlbumIds = useCallback(
(value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_IDS, value), {
replace: true,
});
},
[setSearchParams],
);
const setGenreId = useCallback( const setGenreId = useCallback(
(value: null | string[]) => { (value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.GENRE_ID, value), { setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.GENRE_ID, value), {
@@ -153,7 +139,6 @@ export const useSongListFilters = (listKey?: ItemListKey) => {
{ {
[FILTER_KEYS.SHARED.SEARCH_TERM]: null, [FILTER_KEYS.SHARED.SEARCH_TERM]: null,
[FILTER_KEYS.SONG._CUSTOM]: null, [FILTER_KEYS.SONG._CUSTOM]: null,
[FILTER_KEYS.SONG.ALBUM_IDS]: null,
[FILTER_KEYS.SONG.ARTIST_IDS]: null, [FILTER_KEYS.SONG.ARTIST_IDS]: null,
[FILTER_KEYS.SONG.FAVORITE]: null, [FILTER_KEYS.SONG.FAVORITE]: null,
[FILTER_KEYS.SONG.GENRE_ID]: null, [FILTER_KEYS.SONG.GENRE_ID]: null,
@@ -172,31 +157,18 @@ export const useSongListFilters = (listKey?: ItemListKey) => {
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined, [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined, [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
[FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined, [FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined,
[FILTER_KEYS.SONG.ALBUM_IDS]: albumIds ?? undefined,
[FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined, [FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined,
[FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined, [FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined,
[FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined, [FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined,
[FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined, [FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined,
[FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined, [FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined,
}), }),
[ [searchTerm, sortBy, sortOrder, custom, artistIds, favorite, genreId, maxYear, minYear],
searchTerm,
sortBy,
sortOrder,
custom,
albumIds,
artistIds,
favorite,
genreId,
maxYear,
minYear,
],
); );
return { return {
clear, clear,
query, query,
setAlbumIds,
setArtistIds, setArtistIds,
setCustom, setCustom,
setFavorite, setFavorite,
+13 -1
View File
@@ -2,6 +2,8 @@ import { useQuery } from '@tanstack/react-query';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
const CHECK_FOR_UPDATES_INTERVAL_MS = 6 * 60 * 60 * 1000; const CHECK_FOR_UPDATES_INTERVAL_MS = 6 * 60 * 60 * 1000;
const utils = isElectron() ? window.api?.utils : null; const utils = isElectron() ? window.api?.utils : null;
@@ -21,7 +23,17 @@ export const useCheckForUpdates = () => {
return useQuery({ return useQuery({
enabled: isEnabled, enabled: isEnabled,
queryFn: () => utils?.checkForUpdates?.(), queryFn: async () => {
const result = await utils?.checkForUpdates?.();
logFn.info('Check for updates completed', {
category: LogCategory.SYSTEM,
meta: {
updateAvailable: result?.updateAvailable ?? false,
version: result?.version,
},
});
return result;
},
queryKey: ['app-check-for-updates'], queryKey: ['app-check-for-updates'],
refetchInterval: CHECK_FOR_UPDATES_INTERVAL_MS, refetchInterval: CHECK_FOR_UPDATES_INTERVAL_MS,
refetchIntervalInBackground: true, refetchIntervalInBackground: true,
+21 -13
View File
@@ -10,7 +10,6 @@ import { controller } from '/@/renderer/api/controller';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { getServerById, useAuthStoreActions, useCurrentServer } from '/@/renderer/store'; import { getServerById, useAuthStoreActions, useCurrentServer } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { AuthState } from '/@/shared/types/types'; import { AuthState } from '/@/shared/types/types';
@@ -62,7 +61,7 @@ export const useServerAuthenticated = () => {
} }
// First, try getUserInfo to check if current credentials are still valid // First, try getUserInfo to check if current credentials are still valid
logFn.info(logMsg[LogCategory.SYSTEM].authenticatingServer, { logFn.info('Authenticating server', {
category: LogCategory.SYSTEM, category: LogCategory.SYSTEM,
meta: { meta: {
method: 'getUserInfo', method: 'getUserInfo',
@@ -117,7 +116,7 @@ export const useServerAuthenticated = () => {
} }
} catch (serverInfoError) { } catch (serverInfoError) {
// Log but don't fail authentication if server info fetch fails // Log but don't fail authentication if server info fetch fails
logFn.warn(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, { logFn.warn('Server authentication successful', {
category: LogCategory.SYSTEM, category: LogCategory.SYSTEM,
meta: { meta: {
action: 'server_info_fetch_failed', action: 'server_info_fetch_failed',
@@ -128,7 +127,7 @@ export const useServerAuthenticated = () => {
}); });
} }
logFn.info(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, { logFn.info('Server authentication successful', {
category: LogCategory.SYSTEM, category: LogCategory.SYSTEM,
meta: { meta: {
isAdmin: userInfo.isAdmin, isAdmin: userInfo.isAdmin,
@@ -162,7 +161,7 @@ export const useServerAuthenticated = () => {
const password = await localSettings.passwordGet(serverWithAuth.id); const password = await localSettings.passwordGet(serverWithAuth.id);
if (password) { if (password) {
logFn.info(logMsg[LogCategory.SYSTEM].authenticatingServer, { logFn.info('Authenticating server', {
category: LogCategory.SYSTEM, category: LogCategory.SYSTEM,
meta: { meta: {
method: 'authenticate', method: 'authenticate',
@@ -227,7 +226,7 @@ export const useServerAuthenticated = () => {
} }
} catch (serverInfoError) { } catch (serverInfoError) {
// Log but don't fail authentication if server info fetch fails // Log but don't fail authentication if server info fetch fails
logFn.warn(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, { logFn.warn('Server authentication successful', {
category: LogCategory.SYSTEM, category: LogCategory.SYSTEM,
meta: { meta: {
action: 'server_info_fetch_failed', action: 'server_info_fetch_failed',
@@ -238,7 +237,7 @@ export const useServerAuthenticated = () => {
}); });
} }
logFn.info(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, { logFn.info('Server authentication successful', {
category: LogCategory.SYSTEM, category: LogCategory.SYSTEM,
meta: { meta: {
isAdmin: authData.isAdmin, isAdmin: authData.isAdmin,
@@ -275,7 +274,7 @@ export const useServerAuthenticated = () => {
if (isNetwork && retryAttempt < MAX_NETWORK_RETRIES) { if (isNetwork && retryAttempt < MAX_NETWORK_RETRIES) {
const nextRetry = retryAttempt + 1; const nextRetry = retryAttempt + 1;
logFn.warn(logMsg[LogCategory.SYSTEM].serverAuthenticationFailed, { logFn.warn('Server authentication failed', {
category: LogCategory.SYSTEM, category: LogCategory.SYSTEM,
meta: { meta: {
action: 'network_error_retry', action: 'network_error_retry',
@@ -298,7 +297,7 @@ export const useServerAuthenticated = () => {
// If network error and retries exhausted, redirect to no-network page // If network error and retries exhausted, redirect to no-network page
if (isNetwork && retryAttempt >= MAX_NETWORK_RETRIES) { if (isNetwork && retryAttempt >= MAX_NETWORK_RETRIES) {
logFn.error(logMsg[LogCategory.SYSTEM].serverAuthenticationFailed, { logFn.error('Server authentication failed', {
category: LogCategory.SYSTEM, category: LogCategory.SYSTEM,
meta: { meta: {
action: 'network_error_max_retries_exceeded', action: 'network_error_max_retries_exceeded',
@@ -317,7 +316,7 @@ export const useServerAuthenticated = () => {
} }
// For non-network errors, handle normally // For non-network errors, handle normally
logFn.error(logMsg[LogCategory.SYSTEM].serverAuthenticationFailed, { logFn.error('Server authentication failed', {
category: LogCategory.SYSTEM, category: LogCategory.SYSTEM,
meta: { meta: {
error: errorMessage, error: errorMessage,
@@ -346,14 +345,23 @@ export const useServerAuthenticated = () => {
const debouncedAuth = debounce( const debouncedAuth = debounce(
(serverWithAuth: NonNullable<ReturnType<typeof getServerById>>) => { (serverWithAuth: NonNullable<ReturnType<typeof getServerById>>) => {
authenticateServer(serverWithAuth).catch(console.error); authenticateServer(serverWithAuth).catch((err) => {
logFn.error('Server authentication failed (debounced)', {
category: LogCategory.SYSTEM,
meta: {
message: (err as Error)?.message,
serverId: serverWithAuth.id,
serverName: serverWithAuth.name,
},
});
});
}, },
300, 300,
); );
useEffect(() => { useEffect(() => {
if (!server) { if (!server) {
logFn.debug(logMsg[LogCategory.SYSTEM].serverAuthenticationInvalid, { logFn.debug('Server authentication invalid', {
category: LogCategory.SYSTEM, category: LogCategory.SYSTEM,
meta: { meta: {
reason: 'No server selected', reason: 'No server selected',
@@ -369,7 +377,7 @@ export const useServerAuthenticated = () => {
retryCountRef.current = 0; // Reset retry count when server changes retryCountRef.current = 0; // Reset retry count when server changes
if (!serverWithAuth) { if (!serverWithAuth) {
logFn.error(logMsg[LogCategory.SYSTEM].serverAuthenticationError, { logFn.error('Server authentication error', {
category: LogCategory.SYSTEM, category: LogCategory.SYSTEM,
meta: { meta: {
reason: 'Server not found in store', reason: 'Server not found in store',
@@ -4,8 +4,7 @@ import { useEffect, useRef } from 'react';
import i18n from '/@/i18n/i18n'; import i18n from '/@/i18n/i18n';
import { openRestartRequiredToast } from '/@/renderer/features/settings/restart-toast'; import { openRestartRequiredToast } from '/@/renderer/features/settings/restart-toast';
import { useSettingsStore } from '/@/renderer/store/settings.store'; import { useSettingsStore } from '/@/renderer/store/settings.store';
import { logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
// Synchronizes settings from the renderer store to the main process electron store // Synchronizes settings from the renderer store to the main process electron store
// on app initialization. If there are differences, it updates the main store and shows // on app initialization. If there are differences, it updates the main store and shows
@@ -120,7 +119,8 @@ export const useSyncSettingsToMain = () => {
JSON.stringify(rendererValueNormalized) JSON.stringify(rendererValueNormalized)
) { ) {
hasDifferences = true; hasDifferences = true;
logFn.warn(logMsg.system.settingsSynchronized, { logFn.warn('Differences found between renderer and main process settings', {
category: LogCategory.SYSTEM,
meta: { meta: {
mainStoreKey: mapping.mainStoreKey, mainStoreKey: mapping.mainStoreKey,
mainValue: mainValueNormalized, mainValue: mainValueNormalized,
+8 -1
View File
@@ -8,12 +8,19 @@ import type {
import { QueryCache, QueryClient } from '@tanstack/react-query'; import { QueryCache, QueryClient } from '@tanstack/react-query';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
const queryCache = new QueryCache({ const queryCache = new QueryCache({
onError: (error: any, query) => { onError: (error: any, query) => {
logFn.error('Query failed', {
category: LogCategory.API,
meta: {
message: error?.message,
queryKey: query.queryKey,
},
});
if (query.state.data !== undefined) { if (query.state.data !== undefined) {
console.error(error);
toast.show({ message: `${error.message}`, type: 'error' }); toast.show({ message: `${error.message}`, type: 'error' });
} }
}, },
+17 -4
View File
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import packageJson from '../../package.json'; import packageJson from '../../package.json';
import { formatHrDateTime } from '/@/renderer/utils/format'; import { formatHrDateTime } from '/@/renderer/utils/format';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center'; import { Center } from '/@/shared/components/center/center';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
@@ -70,10 +71,22 @@ const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) =
// Fetch list of recent releases for the selector // Fetch list of recent releases for the selector
const { data: releasesList = [] } = useQuery({ const { data: releasesList = [] } = useQuery({
queryFn: async () => { queryFn: async () => {
const response = await axios.get<GitHubRelease[]>(GITHUB_RELEASES_URL, { try {
params: { per_page: RELEASES_TO_FETCH }, const response = await axios.get<GitHubRelease[]>(GITHUB_RELEASES_URL, {
}); params: { per_page: RELEASES_TO_FETCH },
return response.data; });
logFn.info('Release notes fetched', {
category: LogCategory.GENERAL,
meta: { count: response.data?.length ?? 0 },
});
return response.data;
} catch (error) {
logFn.error('Release notes fetch failed', {
category: LogCategory.GENERAL,
meta: { message: (error as Error)?.message },
});
throw error;
}
}, },
queryKey: ['github-releases-list'], queryKey: ['github-releases-list'],
retry: 2, retry: 2,
+32
View File
@@ -10,8 +10,12 @@ export interface AppSlice extends AppState {
actions: { actions: {
setAlbumArtistDetailGroupingType: (groupingType: 'all' | 'primary') => void; setAlbumArtistDetailGroupingType: (groupingType: 'all' | 'primary') => void;
setAlbumArtistDetailSort: (sortBy: AlbumListSort, sortOrder: SortOrder) => void; setAlbumArtistDetailSort: (sortBy: AlbumListSort, sortOrder: SortOrder) => void;
setAlbumArtistIdsMode: (mode: 'and' | 'or') => void;
setAlbumArtistSelectMode: (mode: 'multi' | 'single') => void;
setAppStore: (data: Partial<AppSlice>) => void; setAppStore: (data: Partial<AppSlice>) => void;
setArtistIdsMode: (mode: 'and' | 'or') => void;
setArtistSelectMode: (mode: 'multi' | 'single') => void; setArtistSelectMode: (mode: 'multi' | 'single') => void;
setGenreIdsMode: (mode: 'and' | 'or') => void;
setGenreSelectMode: (mode: 'multi' | 'single') => void; setGenreSelectMode: (mode: 'multi' | 'single') => void;
setPageSidebar: (key: string, value: boolean) => void; setPageSidebar: (key: string, value: boolean) => void;
setPrivateMode: (enabled: boolean) => void; setPrivateMode: (enabled: boolean) => void;
@@ -27,8 +31,12 @@ export interface AppState {
sortBy: AlbumListSort; sortBy: AlbumListSort;
sortOrder: SortOrder; sortOrder: SortOrder;
}; };
albumArtistIdsMode: 'and' | 'or';
albumArtistSelectMode: 'multi' | 'single';
artistIdsMode: 'and' | 'or';
artistSelectMode: 'multi' | 'single'; artistSelectMode: 'multi' | 'single';
commandPalette: CommandPaletteProps; commandPalette: CommandPaletteProps;
genreIdsMode: 'and' | 'or';
genreSelectMode: 'multi' | 'single'; genreSelectMode: 'multi' | 'single';
isReorderingQueue: boolean; isReorderingQueue: boolean;
pageSidebar: Record<string, boolean>; pageSidebar: Record<string, boolean>;
@@ -79,14 +87,34 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
}; };
}); });
}, },
setAlbumArtistIdsMode: (mode) => {
set((state) => {
state.albumArtistIdsMode = mode;
});
},
setAlbumArtistSelectMode: (mode) => {
set((state) => {
state.albumArtistSelectMode = mode;
});
},
setAppStore: (data) => { setAppStore: (data) => {
set({ ...get(), ...data }); set({ ...get(), ...data });
}, },
setArtistIdsMode: (mode) => {
set((state) => {
state.artistIdsMode = mode;
});
},
setArtistSelectMode: (mode) => { setArtistSelectMode: (mode) => {
set((state) => { set((state) => {
state.artistSelectMode = mode; state.artistSelectMode = mode;
}); });
}, },
setGenreIdsMode: (mode) => {
set((state) => {
state.genreIdsMode = mode;
});
},
setGenreSelectMode: (mode) => { setGenreSelectMode: (mode) => {
set((state) => { set((state) => {
state.genreSelectMode = mode; state.genreSelectMode = mode;
@@ -123,6 +151,9 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
sortBy: AlbumListSort.RELEASE_DATE, sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC, sortOrder: SortOrder.DESC,
}, },
albumArtistIdsMode: 'and',
albumArtistSelectMode: 'multi',
artistIdsMode: 'and',
artistSelectMode: 'multi', artistSelectMode: 'multi',
commandPalette: { commandPalette: {
close: () => { close: () => {
@@ -142,6 +173,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
}); });
}, },
}, },
genreIdsMode: 'and',
genreSelectMode: 'multi', genreSelectMode: 'multi',
isReorderingQueue: false, isReorderingQueue: false,
pageSidebar: { pageSidebar: {
+26
View File
@@ -5,6 +5,7 @@ import { immer } from 'zustand/middleware/immer';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
import { createWithEqualityFn } from 'zustand/traditional'; import { createWithEqualityFn } from 'zustand/traditional';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { ServerListItem, ServerListItemWithCredential } from '/@/shared/types/domain-types'; import { ServerListItem, ServerListItemWithCredential } from '/@/shared/types/domain-types';
export interface AuthSlice extends AuthState { export interface AuthSlice extends AuthState {
@@ -30,6 +31,16 @@ export const useAuthStore = createWithEqualityFn<AuthSlice>()(
immer((set, get) => ({ immer((set, get) => ({
actions: { actions: {
addServer: (args) => { addServer: (args) => {
if (process.env.NODE_ENV === 'development') {
logFn.debug('Auth store: add server', {
category: LogCategory.SYSTEM,
meta: {
serverId: args.id,
serverName: args.name,
serverType: args.type,
},
});
}
set((state) => { set((state) => {
state.serverList[args.id] = args; state.serverList[args.id] = args;
}); });
@@ -49,6 +60,15 @@ export const useAuthStore = createWithEqualityFn<AuthSlice>()(
return null; return null;
}, },
setCurrentServer: (server) => { setCurrentServer: (server) => {
if (process.env.NODE_ENV === 'development') {
logFn.debug('Auth store: set current server', {
category: LogCategory.SYSTEM,
meta: {
serverId: server?.id ?? null,
serverName: server?.name ?? null,
},
});
}
set((state) => { set((state) => {
state.currentServer = server; state.currentServer = server;
}); });
@@ -65,6 +85,12 @@ export const useAuthStore = createWithEqualityFn<AuthSlice>()(
}); });
}, },
updateServer: (id: string, args: Partial<ServerListItemWithCredential>) => { updateServer: (id: string, args: Partial<ServerListItemWithCredential>) => {
if (process.env.NODE_ENV === 'development') {
logFn.debug('Auth store: update server', {
category: LogCategory.SYSTEM,
meta: { keys: Object.keys(args || {}), serverId: id },
});
}
set((state) => { set((state) => {
const updatedServer = { const updatedServer = {
...state.serverList[id], ...state.serverList[id],

Some files were not shown because too many files have changed in this diff Show More