mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 12:30:12 +02:00
Compare commits
39 Commits
v1.5.0
...
refactor/logs
| Author | SHA1 | Date | |
|---|---|---|---|
| 31bcc70498 | |||
| 41c21b94c1 | |||
| bca14176fb | |||
| 02a5395453 | |||
| 7ba2f6b827 | |||
| f1b5dc8ef3 | |||
| 78875572e9 | |||
| f487560ec5 | |||
| f752090c78 | |||
| 96f5b2b82a | |||
| 80292ae579 | |||
| 1d156ac506 | |||
| dc5586f859 | |||
| 1a9f36ce9e | |||
| 203c8a6588 | |||
| 2e6cf8d869 | |||
| b1827dd352 | |||
| 0d2dddddbc | |||
| 1d8e1957ba | |||
| dc957cb3cc | |||
| c314fa0bf3 | |||
| c5ebfac647 | |||
| 4adea11a93 | |||
| e6f49b9f1f | |||
| 9cde569c7d | |||
| 91e7c7434c | |||
| ffef5dfdee | |||
| 409dd69fcb | |||
| 064cf5103a | |||
| 7e3a613a93 | |||
| e7c49f6d67 | |||
| 022b83ab32 | |||
| 551d705ee1 | |||
| 83f73c7fa9 | |||
| cc8cb4f4f1 | |||
| 496eab7d09 | |||
| 5197c967c2 | |||
| 74b615dba7 | |||
| b67ee797cb |
@@ -155,17 +155,6 @@ jobs:
|
|||||||
pnpm run publish:win:alpha
|
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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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.
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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": "отримати інформацію"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 导出和导入设置",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
Regular → Executable
+2
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+132
-13
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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' });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user