Compare commits

..

47 Commits

Author SHA1 Message Date
jeffvli b44e16708d add image column playback, revert to single song playback on song list click 2026-02-09 21:42:59 -08:00
jeffvli 946d9d92f9 properly handle genre columns 2026-02-09 21:26:41 -08:00
jeffvli 5e28dc597c add actions to detail default columns 2026-02-09 21:18:53 -08:00
jeffvli 6462d46c79 support dynamic header album name in paginated list 2026-02-09 21:17:47 -08:00
jeffvli 1a51d52047 remove custom monospace font columns 2026-02-09 21:15:12 -08:00
jeffvli d82ded479e rename item detail list component file 2026-02-09 21:04:55 -08:00
jeffvli 2e2233cb7e adjust header column heights, add duration as fixed width 2026-02-09 21:03:37 -08:00
jeffvli 7344758114 fix size configuration toggle 2026-02-09 20:54:16 -08:00
jeffvli ac40949572 add column resize / reorder dragging 2026-02-09 20:51:10 -08:00
jeffvli c7c72d27db cleanup 2026-02-09 20:22:20 -08:00
jeffvli b41a91c178 refactor list scroll persistence to use store instead of browser state 2026-02-09 20:18:56 -08:00
jeffvli 86f158ee5f reorder metadata section, use consistent separator 2026-02-09 16:11:42 -08:00
jeffvli 86f6cc9cef add readOnly prop to JoinedArtists 2026-02-09 16:00:58 -08:00
jeffvli f15e399ddc fix album artist name in Subsonic song normalization 2026-02-09 15:32:59 -08:00
jeffvli 8efb32407d add optimistic update for favorite/rating in song list queries 2026-02-09 15:28:33 -08:00
jeffvli c9223c402a add double click handler / current song highlights 2026-02-09 15:24:05 -08:00
jeffvli 78d6e5b1d1 refactor item detail to use song list instead of album detail query 2026-02-09 15:08:06 -08:00
jeffvli d0067c5dbf cleanup logs 2026-02-09 14:24:17 -08:00
jeffvli 9f73cfdda1 fix header name on unloaded row render 2026-02-09 14:11:07 -08:00
jeffvli 95dee5f4ee adjust spacing of items in metadata section 2026-02-09 14:00:05 -08:00
jeffvli c7509472c1 add explicit status and indicators in item detail 2026-02-09 13:59:48 -08:00
jeffvli feca53a53d adjust padding and add borders between virtualized rows 2026-02-09 12:56:54 -08:00
jeffvli ab52693092 add context menu 2026-02-09 12:51:22 -08:00
jeffvli 9a2540f954 improve loading state 2026-02-09 12:43:19 -08:00
jeffvli b4c45f0956 add detail display type to toggle 2026-02-09 11:20:49 -08:00
jeffvli 0ab2d89c58 show current album name in header 2026-02-09 11:12:16 -08:00
jeffvli 817e1dc7ba add more fixed column widths 2026-02-09 10:44:14 -08:00
jeffvli e34282338d adjust header styles 2026-02-09 10:44:07 -08:00
jeffvli ba4b07614c move detail list to its own config 2026-02-09 10:29:49 -08:00
jeffvli 72b2dca759 add detail table header 2026-02-09 10:08:25 -08:00
jeffvli 42b51f104c remove horizontal padding from favorite/rating/actions 2026-02-09 04:16:11 -08:00
jeffvli d99ecd485f fix row selection toggle on single 2026-02-09 04:01:47 -08:00
jeffvli bec1e35faf fix row selection specificty on alternate row colors 2026-02-09 03:59:55 -08:00
jeffvli cb6c2092e5 add links and additional data to metadata section 2026-02-09 03:46:29 -08:00
jeffvli 2d01b8e3f7 use JoinedArtists in columns 2026-02-09 03:12:00 -08:00
jeffvli 775cb6be07 disable pin column buttons 2026-02-09 03:01:42 -08:00
jeffvli de6cd7d0dc add configuration for alternate row colors 2026-02-09 02:14:12 -08:00
jeffvli 9e448f7266 add configuration for column/row borders 2026-02-09 02:11:08 -08:00
jeffvli 7bb54f9fa0 add configuration for row hover highlight 2026-02-09 02:04:23 -08:00
jeffvli 332fc5f9f9 optimize detail columns 2026-02-09 01:47:48 -08:00
jeffvli d4c0754bd2 fix import 2026-02-08 20:29:20 -08:00
jeffvli 177bb156cb use percentage based column widths to autofit 2026-02-08 20:28:32 -08:00
jeffvli 31c3f1b062 add row sizing configuration 2026-02-08 20:19:13 -08:00
jeffvli 5421182cc1 add detail columns 2026-02-08 20:06:55 -08:00
jeffvli 3d67b02724 refactor to reuse ItemTableListColumnConfig for detail columns 2026-02-08 19:48:57 -08:00
jeffvli b8aa006b1c add selection / dnd state 2026-02-08 19:29:50 -08:00
jeffvli a16f43c427 initial progress on item detail list 2026-02-08 19:29:44 -08:00
126 changed files with 1136 additions and 6419 deletions
+11
View File
@@ -155,6 +155,17 @@ jobs:
pnpm run publish:win:alpha
on_retry_command: pnpm cache delete
- name: Build and Publish to R2 (Windows ARM64)
if: matrix.os == 'windows-latest'
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run publish:win-arm64:alpha
on_retry_command: pnpm cache delete
- name: Build and Publish to R2 (macOS)
if: matrix.os == 'macos-latest'
uses: nick-invision/retry@v2.8.2
+13
View File
@@ -155,6 +155,19 @@ jobs:
pnpm run publish:win:beta
on_retry_command: pnpm cache delete
- name: Build and Publish releases (Windows ARM64)
if: matrix.os == 'windows-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run publish:win-arm64:beta
on_retry_command: pnpm cache delete
- name: Build and Publish releases (macOS)
if: matrix.os == 'macos-latest'
env:
+10
View File
@@ -50,6 +50,16 @@ jobs:
command: |
pnpm run package:win:pr
- name: Build for Windows (ARM64)
if: ${{ matrix.os == 'windows-latest' }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run package:win-arm64:pr
- name: Build for Linux
if: ${{ matrix.os == 'ubuntu-latest' }}
uses: nick-invision/retry@v2.8.2
+12
View File
@@ -33,3 +33,15 @@ jobs:
command: |
pnpm run publish:win
on_retry_command: pnpm cache delete
- name: Build and Publish releases (ARM64)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run publish:win-arm64
on_retry_command: pnpm cache delete
+13
View File
@@ -35,6 +35,19 @@ jobs:
pnpm run publish:win
on_retry_command: pnpm cache delete
- name: Build and Publish releases (Windows ARM64)
if: matrix.os == 'windows-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run publish:win-arm64
on_retry_command: pnpm cache delete
- name: Build and Publish releases (macOS)
if: matrix.os == 'macos-latest'
env:
Binary file not shown.
+3 -9
View File
@@ -13,15 +13,9 @@ asarUnpack:
- resources/**
win:
target:
- target: zip
arch:
- x64
- arm64
- target: nsis
arch:
- x64
- arm64
icon: assets/icons/icon.ico
- zip
- nsis
icon: assets/icons/icon.png
nsis:
allowToChangeInstallationDirectory: true
+3 -9
View File
@@ -13,15 +13,9 @@ asarUnpack:
- resources/**
win:
target:
- target: zip
arch:
- x64
- arm64
- target: nsis
arch:
- x64
- arm64
icon: assets/icons/icon.ico
- zip
- nsis
icon: assets/icons/icon.png
nsis:
allowToChangeInstallationDirectory: true
+2 -8
View File
@@ -13,14 +13,8 @@ asarUnpack:
- resources/**
win:
target:
- target: zip
arch:
- x64
- arm64
- target: nsis
arch:
- x64
- arm64
- zip
- nsis
icon: assets/icons/icon.ico
nsis:
-1
View File
@@ -1,6 +1,5 @@
server {
listen 9180;
listen [::]:9180;
sendfile on;
default_type application/octet-stream;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.6.0",
"version": "1.4.2",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
+16 -64
View File
@@ -12,11 +12,10 @@
"search": "$t(common.search)",
"settings": "$t(common.setting, {\"count\": 2})",
"tracks": "$t(entity.track, {\"count\": 2})",
"nowPlaying": "s'està reproduint",
"nowPlaying": "ara sona",
"shared": "$t(entity.playlist, {\"count\": 2}) compartides",
"favorites": "$t(entity.favorite, {\"count\": 2})",
"radio": "$t(entity.radioStation, {\"count\": 2})",
"collections": "col·leccions"
"radio": "$t(entity.radioStation, {\"count\": 2})"
},
"albumArtistDetail": {
"relatedArtists": "$t(entity.artist, {\"count\": 2}) similars",
@@ -29,11 +28,7 @@
"topSongsFrom": "les millors cançons de {{title}}",
"viewAll": "mostra-ho tot",
"groupingTypeAll": "tots els tipus de llançaments",
"groupingTypePrimary": "tipus principals de llançament",
"favoriteSongs": "Cançons preferides",
"topSongsCommunity": "comunitat",
"topSongsPersonal": "personal",
"favoriteSongsFrom": "cançons preferides de {{title}}"
"groupingTypePrimary": "tipus principals de llançament"
},
"albumArtistList": {
"title": "$t(entity.albumArtist, {\"count\": 2})"
@@ -196,19 +191,6 @@
},
"radioList": {
"title": "emissores de ràdio"
},
"windowBar": {
"paused": "(en pausa) ",
"privateMode": "(mode privat)"
},
"collections": {
"overrideExisting": "sobreescriu existents",
"saveAsCollection": "desa com a col·lecció"
},
"releasenotes": {
"commitsSinceStable": "commits des de {{stable}}",
"noNewCommits": "no hi ha hagut commits en aquest període",
"noStableReleaseToCompare": "no hi ha actualitzacions disponibles amb les quals comparar"
}
},
"common": {
@@ -307,8 +289,8 @@
"restartRequired": "cal reiniciar",
"sampleRate": "freqüència de mostreig",
"setting_one": "configuració",
"setting_many": "configuracions",
"setting_other": "configuracions",
"setting_many": "",
"setting_other": "",
"trackGain": "guany de pista",
"trackPeak": "pic de pista",
"gap": "espera",
@@ -334,8 +316,7 @@
"example": "exemple",
"mood": "estat d'ànim",
"filter_single": "senzill",
"filter_multiple": "multi",
"rename": "reanomena"
"filter_multiple": "multi"
},
"entity": {
"album_one": "àlbum",
@@ -559,6 +540,7 @@
"fontType_optionBuiltIn": "tipus de lletra integrats",
"fontType_optionCustom": "tipus de lletra personalitzats",
"fontType_optionSystem": "tipus de lletra del sistema",
"disableAutomaticUpdates": "desactivar les actualitzacions automàtiques",
"disableLibraryUpdateOnStartup": "desactiva la comprovació de noves versions a l'inici",
"homeConfiguration": "configuració de la pàgina d'inici",
"sidebarConfiguration": "configuració de la barra lateral",
@@ -568,7 +550,7 @@
"sidePlayQueueStyle_optionAttached": "unida",
"sidePlayQueueStyle_optionDetached": "separada",
"audioDevice": "dispositiu d'àudio",
"audioDevice_description": "seleccioneu el dispositiu d'àudio que voleu utilitzar per a la reproducció",
"audioDevice_description": "seleccioneu el dispositiu d'àudio que voleu utilitzar per a la reproducció (només pel reproductor web)",
"audioPlayer": "reproductor d'àudio",
"audioPlayer_description": "seleccioneu el reproductor d'àudio que voleu utilitzar per a la reproducció",
"sidebarConfiguration_description": "selecciona els elements i l'ordre en què apareixen a la barra lateral",
@@ -614,9 +596,9 @@
"customFontPath_description": "estableix la ruta a una font personalitzada per utilitzar-la a l'aplicació",
"discordApplicationId": "id d'aplicació de {{discord}}",
"discordApplicationId_description": "l'id d'aplicació per l'estat d'activitat de {{discord}} (per defecte, {{defaultId}})",
"discordPausedStatus": "mostra l'estat d'activitat quan està en pausa",
"discordPausedStatus": "mosta l'estat d'activitat quan està en pausa",
"discordPausedStatus_description": "si està activat, l'estat es mostrarà quan el reproductor estigui pausat",
"discordIdleStatus": "mosta l'estat d'activitat quan està inactiu",
"discordIdleStatus": "mosta l'estat d'activitat en inactivitat",
"discordIdleStatus_description": "si està activat, s'actualitzarà l'estat mentre el reproductor estigui inactiu",
"discordListening": "mosta l'estat com escoltant",
"discordListening_description": "mosta l'estat com escoltant en comptes de jugant",
@@ -788,7 +770,7 @@
"releaseChannel_optionLatest": "última versió",
"releaseChannel_optionBeta": "beta",
"releaseChannel": "canal de versions",
"releaseChannel_description": "trieu entre versions estables i beta o alfa (diàries) per les actualitzacions automàtiques",
"releaseChannel_description": "tria entre versions estables i versions beta per les actualitzacions automàtiques",
"mediaSession": "activa Media Session",
"mediaSession_description": "activa la integració amb Media Session per mostrar els controls multimèdia i les metadades a l'indicador de volum del sistema i la pantalla de bloqueig",
"crossfadeStyle": "estil de fosa encadenada",
@@ -880,23 +862,7 @@
"homeFeatureStyle_description": "controla l'estil del carrusel de destacats de l'inici",
"homeFeatureStyle": "estil del carrusel de destacats de l'inici",
"homeFeatureStyle_optionMultiple": "múltiple",
"homeFeatureStyle_optionSingle": "simple",
"enableGridMultiSelect": "activa la selecció múltiple de quadrícula",
"enableGridMultiSelect_description": "quan està activada, podeu seleccionar més d'un element en la vista de quadrícula; si feu clic en la imatge d'un element de la quadrícula, accedireu a la pàgina de l'element",
"sidebarPlaylistSorting_description": "permet ordenar manualment les llistes de reproducció a la barra lateral arrossegant amb el ratolí en comptes de seguir l'ordre predeterminat del servidor",
"sidebarPlaylistSorting": "ordenació de llistes de reproducció de la barra lateral",
"sidebarPlaylistListFilterRegex_description": "amaga les llistes de reproducció de la barra lateral que coincideixin amb aquesta expressió regular",
"sidebarPlaylistListFilterRegex_placeholder": "ex. ^Mescla diària.*",
"sidebarPlaylistListFilterRegex": "regex pel filtre de llistes",
"analyticsEnable": "envia analítiques basades en l'ús",
"analyticsEnable_description": "s'envien dades d'ús anonimitzades al desenvolupar per ajudar a millorar l'aplicació",
"automaticUpdates": "actualitzacions automàtiques",
"automaticUpdates_description": "cerca i instal·la actualitzacions automàticament",
"releaseChannel_optionAlpha": "alfa (diària)",
"blurExplicitImages": "desenfoca imatges explícites",
"blurExplicitImages_description": "les caràtules d'àlbums i cançons marcades com a explícites quedaran desenfocades",
"discordStateIcon": "mostra la icona de reproducció",
"discordStateIcon_description": "mostra una petita icona de reproducció a l'estat d'activitat. l'icona de pausa es mostra quan \"mostra l'estat d'activitat quan està en pausa\" està activat"
"homeFeatureStyle_optionSingle": "simple"
},
"table": {
"column": {
@@ -958,8 +924,7 @@
"alternateRowColors": "colors de fila alternants",
"horizontalBorders": "vores de fila",
"rowHoverHighlight": "ressalta en passar el cursor per la fila",
"verticalBorders": "vores de columna",
"showHeader": "mostra l'encapçalament"
"verticalBorders": "vores de columna"
},
"label": {
"actions": "$t(common.action, {\"count\": 2})",
@@ -1001,8 +966,7 @@
"view": {
"table": "taula",
"grid": "quadrícula",
"list": "llista",
"detail": "detall"
"list": "llista"
}
}
},
@@ -1049,10 +1013,7 @@
"lastPlayed": "última reproducció",
"path": "ruta",
"songCount": "nombre de cançons",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "ordena per nom",
"matchAnd": "i",
"matchOr": "o"
"explicitStatus": "$t(common.explicitStatus)"
},
"player": {
"muted": "silenciat",
@@ -1093,16 +1054,7 @@
"restoreQueueFromServer": "restaura la cua del servidor",
"saveQueueToServer": "desa la cua al servidor",
"artistRadio": "ràdio de l'artista",
"trackRadio": "ràdio de la pista",
"sleepTimer": "temporitzador d'adormir",
"sleepTimer_endOfSong": "final de la cançó actual",
"sleepTimer_minutes": "{{count}} min",
"sleepTimer_hours": "{{count}} h",
"sleepTimer_custom": "personalitzat",
"sleepTimer_off": "apagat",
"sleepTimer_timeRemaining": "queden {{time}}",
"sleepTimer_setCustom": "configura el temporitzador",
"sleepTimer_cancel": "cancel·la el temporitzador"
"trackRadio": "ràdio de la pista"
},
"error": {
"credentialsRequired": "credencials requerides",
+9 -41
View File
@@ -38,16 +38,7 @@
"restoreQueueFromServer": "obnovit frontu ze serveru",
"saveQueueToServer": "uložit frontu na server",
"artistRadio": "rádio umělce",
"trackRadio": "rádio skladby",
"sleepTimer": "časovač spánku",
"sleepTimer_endOfSong": "konec aktuální skladby",
"sleepTimer_minutes": "{{count}} min.",
"sleepTimer_hours": "{{count}} hod.",
"sleepTimer_custom": "vlastní",
"sleepTimer_off": "vypnuto",
"sleepTimer_timeRemaining": "zbývá {{time}}",
"sleepTimer_setCustom": "nastavit časovač",
"sleepTimer_cancel": "zrušit časovač"
"trackRadio": "rádio skladby"
},
"setting": {
"crossfadeStyle_description": "vyberte způsob prolnutí u přehrávače zvuku",
@@ -55,7 +46,7 @@
"hotkey_skipBackward": "přeskočení zpět",
"replayGainMode_description": "úprava zesílení hlasitosti podle hodnot {{ReplayGain}} uložených v metadatech souborů",
"volumeWheelStep_description": "počet procent, o které má být hlasitost posunuta při přejetí kolečkem myši na posuvníku hlasitosti",
"audioDevice_description": "vyberte zvukové zařízení k přehrávání",
"audioDevice_description": "vyberte zvukové zařízení k přehrávání (pouze webový přehrávač)",
"theme_description": "nastavení motivu použitého v aplikaci",
"hotkey_playbackPause": "pozastavení",
"replayGainFallback": "fallback {{ReplayGain}}",
@@ -107,6 +98,7 @@
"hotkey_globalSearch": "globální vyhledávání",
"gaplessAudio_description": "nastavení přehrávače mpv pro přehrávání bez mezer",
"remoteUsername_description": "nastavení uživatelského jména pro server vzdáleného ovládání. pokud je jméno i heslo prázdné, bude autentifikace zakázána",
"disableAutomaticUpdates": "vypnout automatické aktualizace",
"exitToTray_description": "ukončit aplikaci do systémové lišty",
"followLyric_description": "přesouvat texty s aktuální pozicí přehrávání",
"hotkey_favoritePreviousSong": "oblíbit $t(common.previousSong)",
@@ -270,7 +262,7 @@
"neteaseTranslation_description": "Pokud je povoleno, načte a zobrazí přeložené texty ze služby NetEase, pokud jsou dostupné",
"preferLocalLyrics": "preferovat místní texty",
"preferLocalLyrics_description": "preferovat místní texty před vzdálenými, pokud jsou dostupné",
"discordPausedStatus": "zobrazit stav při pozastavení",
"discordPausedStatus": "zobrazit rich presence při pozastavení",
"discordPausedStatus_description": "pokud je povoleno, bude při pozastavení přehrávače zobrazen stav",
"preservePitch": "zachovat výšku",
"preservePitch_description": "zachová výšku při úpravě rychlosti přehrávání",
@@ -292,7 +284,7 @@
"releaseChannel_optionLatest": "nejnovější",
"releaseChannel_optionBeta": "beta",
"releaseChannel": "kanál vydání",
"releaseChannel_description": "vyberte si mezi stabilními, beta nebo alpha (nočními) vydáními pro automatické aktualizace",
"releaseChannel_description": "vyberte si mezi stabilními vydáními nebo beta vydáními pro automatické aktualizace",
"mediaSession": "povolit relaci médií",
"mediaSession_description": "povolí integraci do služby Media Session, což zobrazí ovládání a metadata médií v překrytí systémové hlasitosti a na zamykací obrazovce",
"exportImportSettings_control_description": "exportovat a importovat nastavení pomocí souboru JSON",
@@ -388,19 +380,7 @@
"enableGridMultiSelect": "povolit vícenásobný výběr v mřížce",
"enableGridMultiSelect_description": "pokud je povoleno, umožňuje vybrat několik položek v zobrazení mřížky. pokud je zakázáno, kliknutím na obrázek položky mřížky přejdete na stránku položky",
"sidebarPlaylistSorting_description": "umožňuje ruční řazení seznamů skladeb v postranní liště pomocí přetažení namísto výchozího pořadí serveru",
"sidebarPlaylistSorting": "řazení seznamů skladeb v postranní liště",
"blurExplicitImages": "rozostřit explicitní obrázky",
"blurExplicitImages_description": "obaly alb a skladeb označené jako explicitní budou rozostřeny",
"sidebarPlaylistListFilterRegex_description": "v postranní liště skrýt seznamy skladeb, které odpovídají tomuto regulárnímu výrazu",
"sidebarPlaylistListFilterRegex_placeholder": "např. ^Denní mix.*",
"sidebarPlaylistListFilterRegex": "regulární výraz filtru seznamů skladeb",
"releaseChannel_optionAlpha": "alpha (noční)",
"analyticsEnable": "Posílat analytiku založenou na využití",
"analyticsEnable_description": "Anonymizovaná data o používání jsou odesílána vývojáři za účelem zlepšení aplikace",
"automaticUpdates": "Automatické aktualizace",
"automaticUpdates_description": "Kontrolovat a automaticky instalovat aktualizace",
"discordStateIcon": "zobrazit ikonu přehrávání",
"discordStateIcon_description": "zobrazit malou ikonu přehrávání ve stavu na Discordu. ikona pozastavení bude zobrazena vždy, když je povolena možnost „Zobrazit stav při pozastavení“"
"sidebarPlaylistSorting": "řazení seznamů skladeb v postranní liště"
},
"action": {
"editPlaylist": "upravit $t(entity.playlist, {\"count\": 1})",
@@ -573,8 +553,7 @@
"view": {
"table": "tabulka",
"list": "seznam",
"grid": "mřížka",
"detail": "podrobnosti"
"grid": "mřížka"
},
"general": {
"displayType": "typ zobrazení",
@@ -751,9 +730,7 @@
"album": "$t(entity.album, {\"count\": 1})",
"trackNumber": "skladba",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "název v řazení",
"matchAnd": "a",
"matchOr": "nebo"
"sortName": "název v řazení"
},
"page": {
"sidebar": {
@@ -922,11 +899,7 @@
"viewAllTracks": "zobrazit všechny $t(entity.track, {\"count\": 2})",
"viewAll": "zobrazit vše",
"groupingTypeAll": "všechny typy vydání",
"groupingTypePrimary": "primární typy vydání",
"favoriteSongs": "oblíbené skladby",
"topSongsCommunity": "komunita",
"topSongsPersonal": "osobní",
"favoriteSongsFrom": "oblíbené skladby od umělce {{title}}"
"groupingTypePrimary": "primární typy vydání"
},
"itemDetail": {
"copiedPath": "cesta úspěšně zkopírována",
@@ -960,11 +933,6 @@
"collections": {
"overrideExisting": "nahradit existující",
"saveAsCollection": "uložit jako sbírku"
},
"releasenotes": {
"commitsSinceStable": "revize od {{stable}}",
"noNewCommits": "žádné nové revize v tomto období",
"noStableReleaseToCompare": "není dostupné žádné stabilní vydání k porovnání"
}
},
"form": {
+2 -1299
View File
File diff suppressed because it is too large Load Diff
+6 -22
View File
@@ -33,8 +33,7 @@
"createRadioStation": "$t(entity.radioStation, {\"count\": 1}) erstellen",
"deleteRadioStation": "$t(entity.radioStation, {\"count\": 1}) löschen",
"selectAll": "alle auswählen",
"openApplicationDirectory": "Anwendungsverzeichnis öffnen",
"addOrRemoveFromSelection": "Zur Auswahl hinzufügen oder entfernen"
"openApplicationDirectory": "Anwendungsverzeichnis öffnen"
},
"common": {
"backward": "zurück",
@@ -572,8 +571,7 @@
"shared": "$t(entity.playlist, {\"count\": 2}) geteilt",
"myLibrary": "meine bibliothek",
"favorites": "$t(entity.favorite, {\"count\": 2})",
"radio": "$t(entity.radioStation, {\"count\": 2})",
"collections": "Sammlungen"
"radio": "$t(entity.radioStation, {\"count\": 2})"
},
"setting": {
"playbackTab": "Wiedergabe",
@@ -631,9 +629,7 @@
"topSongs": "Toplieder",
"relatedArtists": "ähnliche $t(entity.artist, {\"count\": 2})",
"groupingTypeAll": "alle Veröffentlichungsformate",
"groupingTypePrimary": "primäre Veröffentlichungsformate",
"favoriteSongs": "Lieblingssongs",
"favoriteSongsFrom": "Liebslingssongs von {{title}}"
"groupingTypePrimary": "primäre Veröffentlichungsformate"
},
"manageServers": {
"title": "Servers verwalten",
@@ -659,13 +655,6 @@
},
"radioList": {
"title": "Radiosender"
},
"windowBar": {
"paused": "(Pausiert) ",
"privateMode": "(Privater Modus)"
},
"collections": {
"saveAsCollection": "Als Sammlung speichern"
}
},
"player": {
@@ -704,8 +693,7 @@
"addNextShuffled": "als Nächstes (zufällige Wiedergabe)",
"holdToShuffle": "Halten für Zufallswiedergabe",
"restoreQueueFromServer": "Wiedergabeliste von Server wiederherstellen",
"saveQueueToServer": "Wiedergabeliste auf Server speichern",
"lyrics": "Songtexte"
"saveQueueToServer": "Wiedergabeliste auf Server speichern"
},
"setting": {
"audioDevice_description": "wählen Sie das Audiogerät aus, das für die Wiedergabe verwendet werden soll (nur Webplayer)",
@@ -721,6 +709,7 @@
"disableLibraryUpdateOnStartup": "beim Start nicht nach neuen Versionen suchen",
"discordApplicationId_description": "die Application-ID für {{discord}} Rich Presence (Standard: {{defaultId}})",
"audioPlayer_description": "Wählen Sie den Audioplayer aus, der für die Wiedergabe verwendet werden soll",
"disableAutomaticUpdates": "Automatische Updates deaktivieren",
"crossfadeDuration_description": "Legt die Dauer der Überblendung fest",
"customFontPath": "Benutzerdefinierter Pfad für Schriftarten",
"crossfadeDuration": "Dauer der Überblendung",
@@ -985,12 +974,7 @@
"translationTargetLanguage_description": "die gewünschte Sprache der Übersetzung",
"translationTargetLanguage": "Zielsprache der Übersetzung",
"queryBuilderCustomFields": "benutzerdefiniertes Feld",
"queryBuilderCustomFields_inputTag": "Tag",
"homeFeatureStyle_optionMultiple": "mehrere",
"imageResolution": "Bildauflösung",
"imageResolution_optionTable": "Tabelle",
"imageResolution_optionSidebar": "Seitenleiste",
"preservePitch": "Tonhöhe erhalten"
"queryBuilderCustomFields_inputTag": "Tag"
},
"dragDropZone": {
"error_oneFileOnly": "Bitte wähle nur 1 Datei",
Executable → Regular
+3 -21
View File
@@ -211,7 +211,6 @@
"credentialsRequired": "credentials required",
"endpointNotImplementedError": "endpoint {{endpoint}} is not implemented for {{serverType}}",
"genericError": "an error occurred",
"invalidJson": "invalid JSON",
"invalidServer": "invalid server",
"localFontAccessDenied": "access denied to local fonts",
"loginRateError": "too many login attempts, please try again in a few seconds",
@@ -228,7 +227,6 @@
"remotePortError": "an error occurred when trying to set the remote server port",
"remotePortWarning": "restart the server to apply the new port",
"saveQueueFailed": "failed to save queue",
"serverLockSingleServer": "only one server is allowed when server is locked",
"serverNotSelectedError": "no server selected",
"serverRequired": "server required",
"sessionExpiredError": "your session has expired",
@@ -238,8 +236,6 @@
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"matchAnd": "and",
"matchOr": "or",
"albumCount": "$t(entity.album, {\"count\": 2}) count",
"artist": "$t(entity.artist, {\"count\": 1})",
"biography": "biography",
@@ -671,16 +667,7 @@
"trackRadio": "track radio",
"unfavorite": "unfavorite",
"pause": "pause",
"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"
"viewQueue": "view queue"
},
"queryBuilder": {
"standardTags": "standard tags",
@@ -726,8 +713,6 @@
"albumBackgroundBlur": "album background image blur size",
"analyticsDisable": "Opt-out of usage based analytics",
"analyticsDisable_description": "Anonymized usage data is sent to the developer to help improve the application",
"analyticsEnable": "Send usage-based analytics",
"analyticsEnable_description": "Anonymized usage data is sent to the developer to help improve the application",
"applicationHotkeys_description": "configure application hotkeys. toggle the checkbox to set as a global hotkey (desktop only)",
"applicationHotkeys": "application hotkeys",
"artistBackground": "artist background image",
@@ -738,7 +723,7 @@
"artistConfiguration_description": "configure what items are shown, and in what order, on the album artist page",
"artistReleaseTypeConfiguration": "artist release type configuration",
"artistReleaseTypeConfiguration_description": "configure what release types are shown, and in what order, on the album artist page",
"audioDevice_description": "select the audio device to use for playback",
"audioDevice_description": "select the audio device to use for playback (web player only)",
"audioDevice": "audio device",
"audioExclusiveMode_description": "enable exclusive output mode. In this mode, the system is usually locked out, and only mpv will be able to output audio",
"audioExclusiveMode": "audio exclusive mode",
@@ -764,8 +749,7 @@
"customCssNotice": "Warning: while there is some sanitization (disallowing url() and content:), using custom css can still pose risks by changing the interface",
"customFontPath_description": "sets the path to the custom font to use for the application",
"customFontPath": "custom font path",
"automaticUpdates": "Automatic updates",
"automaticUpdates_description": "Check for and install updates automatically",
"disableAutomaticUpdates": "disable automatic updates",
"releaseChannel_optionAlpha": "alpha (nightly)",
"releaseChannel_optionBeta": "beta",
"releaseChannel_optionLatest": "latest",
@@ -792,8 +776,6 @@
"discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}}",
"discordServeImage": "serve {{discord}} images from server",
"discordServeImage_description": "share cover art for {{discord}} rich presence from server itself, only available for Jellyfin and Navidrome. {{discord}} uses a bot to fetch images, so your server must be reachable from the public internet",
"discordStateIcon": "show playing icon",
"discordStateIcon_description": "show a small playing icon in the rich presence status. the paused icon is always shown when \"Show rich presence when paused\" is enabled",
"discordUpdateInterval": "{{discord}} rich presence update interval",
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
"enableAutoTranslation_description": "enable translation automatically when lyrics are loaded",
+26 -58
View File
@@ -32,29 +32,20 @@
"playSimilarSongs": "Reproducir canciones similares",
"viewQueue": "ver cola",
"addLastShuffled": "Al final (mezclado)",
"addNextShuffled": "Siguiente (mezclado)",
"addNextShuffled": "Al siguiente (mezclado)",
"holdToShuffle": "Mantener para mezclar",
"lyrics": "Letras",
"restoreQueueFromServer": "Restaurar cola del servidor",
"saveQueueToServer": "Guardar cola en el servidor",
"artistRadio": "Radio de artista",
"trackRadio": "Radio de pista",
"sleepTimer_minutes": "{{count}} min",
"sleepTimer_hours": "{{count}} h",
"sleepTimer_custom": "Personalizado",
"sleepTimer_setCustom": "Configurar temporizador",
"sleepTimer_cancel": "Cancelar temporizador",
"sleepTimer_timeRemaining": "{{time}} restante",
"sleepTimer_off": "Apagado",
"sleepTimer_endOfSong": "Fin de la canción actual",
"sleepTimer": "Temporizador de apagado"
"trackRadio": "Radio de pista"
},
"setting": {
"crossfadeStyle_description": "selecciona el estilo de crossfade a usar por el reproductor de audio",
"remotePort_description": "establece el puerto para el control remoto del servidor",
"hotkey_skipBackward": "retroceder",
"replayGainMode_description": "ajusta el volumen de ganancia acorde a los valores de {{ReplayGain}} almacenados en los metadatos del archivo",
"audioDevice_description": "selecciona el dispositivo de audio a usar durante la reproducción",
"audioDevice_description": "selecciona el dispositivo de audio a usar durante la reproducción (solo reproductor web)",
"theme_description": "establece el tema a usar por la aplicación",
"hotkey_playbackPause": "pausa",
"replayGainFallback": "{{ReplayGain}} alternativa",
@@ -104,6 +95,7 @@
"hotkey_globalSearch": "búsqueda global",
"gaplessAudio_description": "establece la configuración de audio sin pausas para mpv",
"remoteUsername_description": "establece el nombre de usuario para el control remoto del servidor. si el usuario y la contraseña están vacíos, la autenticación será deshabilitada",
"disableAutomaticUpdates": "desactiva las actualizaciones automáticas",
"exitToTray_description": "sale de la aplicación a la bandeja del sistema",
"followLyric_description": "desplaza la letra a la posición de reproducción actual",
"hotkey_favoritePreviousSong": "$t(common.previousSong) favorita",
@@ -168,7 +160,7 @@
"customFontPath": "ruta de fuente personalizada",
"followLyric": "seguir la letra actual",
"crossfadeDuration": "duración del crossfade",
"discordIdleStatus": "mostrar estado inactivo en el estado de actividad",
"discordIdleStatus": "mostrar el estado inactivo en el estado de actividad",
"sidePlayQueueStyle_optionDetached": "separada",
"audioPlayer": "reproductor de audio",
"hotkey_zoomOut": "reducir",
@@ -291,7 +283,7 @@
"releaseChannel_optionLatest": "Última versión",
"releaseChannel_optionBeta": "Beta",
"releaseChannel": "Canal de lanzamiento",
"releaseChannel_description": "Elige entre lanzamientos estables, beta, o alpha (nightly) para las actualizaciones automáticas",
"releaseChannel_description": "Elige entre lanzamientos estables o beta para las actualizaciones automáticas",
"artistBackground_description": "Añade una imagen de fondo para las páginas de artista que contienen el arte del artista",
"mediaSession": "Activar sesión de medios",
"mediaSession_description": "Activa la integración de la sesión de medios, mostrando los controles de medios y los metadatos en la superposición del volumen del sistema y en la pantalla de bloqueo",
@@ -327,8 +319,8 @@
"playerbarWaveformRadius": "Radio de la forma de onda",
"showLyricsInSidebar_description": "Se añadirá un panel a la cola de reproducción acoplada que muestra las letras",
"showLyricsInSidebar": "Mostrar letras en la barra lateral del reproductor",
"showVisualizerInSidebar_description": "Se añadirá un panel a la barra lateral del reproductor que muestra el visualizador",
"showVisualizerInSidebar": "Mostrar visualizador en la barra lateral del reproductor",
"showVisualizerInSidebar_description": "Se añadirá un panel a la barra lateral de reproducción que muestra el visualizador",
"showVisualizerInSidebar": "Mostrar visualizador en la barra lateral de reproducción",
"queryBuilder": "Generador de consultas",
"queryBuilderCustomFields_inputTag": "Etiqueta",
"queryBuilderCustomFields": "Campos personalizados",
@@ -388,19 +380,7 @@
"enableGridMultiSelect": "Activar selección múltiple de rejilla",
"enableGridMultiSelect_description": "Cuando está activo, permite seleccionar múltiples elementos en las vistas de rejilla. Cuando está desactivado, hacer clic en las imágenes de los elementos de la rejilla navegará a la página del elemento",
"sidebarPlaylistSorting": "Ordenación de la lista de reproducción de la barra lateral",
"sidebarPlaylistSorting_description": "Permite la ordenación manual de la lista de reproducción en la barra lateral usando arrastrar y soltar en lugar del orden predeterminado del servidor",
"sidebarPlaylistListFilterRegex": "Expresión regular de filtrado de listas de reproducción",
"sidebarPlaylistListFilterRegex_description": "Esconde las listas de reproducción en la barra lateral que coincidan con esta expresión regular",
"sidebarPlaylistListFilterRegex_placeholder": "p. ej. ^Mezcla diaria.*",
"blurExplicitImages": "Desenfocar imágenes explícitas",
"blurExplicitImages_description": "El álbum y la carátula de la canción etiquetados como explícitos serán desenfocados",
"releaseChannel_optionAlpha": "Alpha (nightly)",
"analyticsEnable": "Enviar analíticas basadas en el uso",
"analyticsEnable_description": "Se envían datos de uso anonimizados al desarrollador para ayudar a mejorar la aplicación",
"automaticUpdates": "Actualizaciones automáticas",
"automaticUpdates_description": "Busca e instala actualizaciones automáticamente",
"discordStateIcon": "Mostrar icono de reproducción",
"discordStateIcon_description": "Muestra un icono pequeño de reproducción en el estado de actividad. El icono de pausa se muestra siempre cuando \"Mostrar estado de actividad cuando esté en pausa\" esté activado"
"sidebarPlaylistSorting_description": "Permite la ordenación manual de la lista de reproducción en la barra lateral usando arrastrar y soltar en lugar del orden predeterminado del servidor"
},
"action": {
"editPlaylist": "editar $t(entity.playlist, {\"count\": 1})",
@@ -446,7 +426,7 @@
"backward": "hacia atrás",
"increase": "aumentar",
"rating": "calificación",
"bpm": "bpm",
"bpm": "lpm",
"refresh": "actualizar",
"unknown": "desconocido",
"areYouSure": "seguro?",
@@ -458,7 +438,7 @@
"currentSong": "$t(entity.track, {\"count\": 1}) actual",
"collapse": "contraer",
"trackNumber": "pista",
"descending": "descendente",
"descending": "descendiente",
"add": "añadir",
"ascending": "ascendente",
"dismiss": "descartar",
@@ -485,8 +465,8 @@
"cancel": "cancelar",
"forceRestartRequired": "reiniciar para aplicar cambios... cerrar la notificación para reiniciar",
"setting_one": "configuración",
"setting_many": "configuración",
"setting_other": "configuración",
"setting_many": "configuraciones",
"setting_other": "configuraciones",
"version": "versión",
"title": "título",
"filters": "filtros",
@@ -600,10 +580,10 @@
"noNetworkDescription": "No se pudo conectar a este servidor"
},
"filter": {
"mostPlayed": "más reproducidos",
"mostPlayed": "más reproducido",
"isCompilation": "es una compilación",
"recentlyPlayed": "recientemente reproducido",
"isRated": "Está calificado",
"isRated": "es clasificado",
"title": "título",
"rating": "calificación",
"search": "buscar",
@@ -619,7 +599,7 @@
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"isRecentlyPlayed": "reproducido recientemente",
"isFavorited": "es favorito",
"bpm": "bpm",
"bpm": "lpm",
"releaseYear": "año de lanzamiento",
"disc": "disco",
"biography": "biografía",
@@ -638,14 +618,12 @@
"owner": "$t(common.owner)",
"genre": "$t(entity.genre, {\"count\": 1})",
"id": "id",
"songCount": "número de canciones",
"songCount": "número de canción",
"isPublic": "es público",
"album": "$t(entity.album, {\"count\": 1})",
"albumCount": "Número de $t(entity.album, {\"count\": 2})",
"albumCount": "Contar $t(entity.album, {\"count\": 2})",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "Ordenar por nombre",
"matchAnd": "y",
"matchOr": "o"
"sortName": "Ordenar por nombre"
},
"page": {
"sidebar": {
@@ -814,11 +792,7 @@
"about": "Sobre {{artist}}",
"appearsOn": "Aparece en",
"groupingTypeAll": "Todos los tipos de lanzamiento",
"groupingTypePrimary": "Tipos de lanzamiento principales",
"favoriteSongs": "Canciones favoritas",
"favoriteSongsFrom": "Canciones favoritas de {{title}}",
"topSongsPersonal": "Personal",
"topSongsCommunity": "Comunidad"
"groupingTypePrimary": "Tipos de lanzamiento principales"
},
"itemDetail": {
"copiedPath": "Ruta copiada correctamente",
@@ -852,11 +826,6 @@
"collections": {
"overrideExisting": "Sobreescribir existente",
"saveAsCollection": "Guardar como colección"
},
"releasenotes": {
"commitsSinceStable": "Actualizaciones desde {{stable}}",
"noNewCommits": "Ninguna nueva actualización en este rango",
"noStableReleaseToCompare": "Ningún lanzamiento estable disponible con el que comparar"
}
},
"form": {
@@ -882,8 +851,8 @@
"input_name": "nombre del servidor",
"success": "servidor añadido correctamente",
"input_savePassword": "guardar contraseña",
"ignoreSsl": "Ignorar SSL ($t(common.restartRequired))",
"ignoreCors": "Ignorar CORS ($t(common.restartRequired))",
"ignoreSsl": "ignorar ssl ($t(common.restartRequired))",
"ignoreCors": "ignorar cors ($t(common.restartRequired))",
"error_savePassword": "un error ocurrió cuando se intentó guardar la contraseña",
"input_preferInstantMix": "Preferir mix instantáneo",
"input_preferInstantMixDescription": "Usa solo el mix instantáneo para obtener canciones similares. Útil si tienes complementos que modifican este comportamiento",
@@ -981,7 +950,7 @@
"releaseDate": "fecha de lanzamiento",
"bitrate": "tasa de bits",
"title": "título",
"bpm": "bpm",
"bpm": "lpm",
"dateAdded": "fecha de adición",
"artist": "$t(entity.artist, {\"count\": 1})",
"songCount": "$t(entity.track, {\"count\": 2})",
@@ -1046,8 +1015,8 @@
"followCurrentSong": "seguir la canción actual",
"advancedSettings": "Opciones avanzadas",
"autosize": "Autodimensionar",
"moveUp": "Subir",
"moveDown": "Bajar",
"moveUp": "Ascender",
"moveDown": "Descender",
"pinToLeft": "Anclar a la izquierda",
"pinToRight": "Anclar a la derecha",
"alignLeft": "Alinear a la izquierda",
@@ -1070,8 +1039,7 @@
"view": {
"table": "tabla",
"list": "Lista",
"grid": "Cuadrícula",
"detail": "Detalle"
"grid": "Cuadrícula"
}
}
},
+1
View File
@@ -419,6 +419,7 @@
"customCss": "css pertsonalizatua",
"customFontPath": "letra-tipo pertsonalizatuaren bidea",
"customFontPath_description": "aplikazioan erabiliko den letra-tipo pertsonalizatuaren bidea ezartzen du",
"disableAutomaticUpdates": "desgaitu eguneratze automatikoak",
"discordApplicationId": "{{discord}} aplikazioaren IDa",
"followLyric": "jarraitu uneko letra",
"font_description": "aplikazioan erabiliko den letra-tipoa ezartzen du",
+1
View File
@@ -76,6 +76,7 @@
"hotkey_volumeDown": "کم کردن صدا",
"audioPlayer_description": "پخش‌کنندهٔ صدا را برای پخش انتخاب کنید",
"hotkey_globalSearch": "جست و جوی سراسری",
"disableAutomaticUpdates": "غیرفعال کردن به‌‌روزرسانی خودکار",
"exitToTray_description": "خروج از اپلیکیشن به system tray",
"replayGainMode_optionAlbum": "$t(entity.album, {\"count\": 1})",
"discordUpdateInterval_description": "فاصلهٔ بین هر به روزرسانی به ثانیه (حداقل ۱۵ ثانیه)",
+1
View File
@@ -375,6 +375,7 @@
"customCss_description": "mukautettu CSS-sisältö. Huomautus: content- ja etä-URL-osoitteet ovat estettyjä ominaisuuksia. Esikatselu sisällöstäsi on alla. Lisäkenttiä, joita et ole määrittänyt, on näkyvissä puhdistuksen vuoksi",
"customCssNotice": "Varoitus: vaikka jonkinlainen puhdistus onkin tehty (url()- ja content:-komentojen estäminen), mukautetun css:n käyttäminen voi silti aiheuttaa riskejä muuttamalla käyttöliittymää",
"disableLibraryUpdateOnStartup": "poista uusimman version tarkistus käynnistyksen yhteydessä käytöstä",
"disableAutomaticUpdates": "poista automaattiset päivitykset käytöstä",
"discordIdleStatus": "näytä rich presencen käyttämätön tila",
"discordIdleStatus_description": "kun käytössä, päivitä tila kun soitin on käyttämättömänä",
"discordUpdateInterval_description": "päivitysväli sekunnteina (vähintään 15 sekunttia)",
+11 -27
View File
@@ -125,7 +125,7 @@
"forceRestartRequired": "redémarrer pour appliquer les changements… fermer la notification pour redémarrer",
"setting": "paramètre",
"setting_one": "paramètre",
"setting_many": "paramètres",
"setting_many": "",
"setting_other": "paramètres",
"version": "version",
"title": "titre",
@@ -202,10 +202,7 @@
"countSelected": "{{count}} sélectionnée",
"example": "exemple",
"mood": "humeur",
"retry": "réessayer",
"filter_single": "unique",
"filter_multiple": "multiple",
"rename": "renommer"
"retry": "réessayer"
},
"error": {
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
@@ -273,7 +270,7 @@
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"comment": "commentaire",
"recentlyUpdated": "mis à jour récemment",
"channels": "$t(common.channel, {\"count\": 2})",
"channels": "$t(common.channel_other)",
"owner": "$t(common.owner)",
"genre": "$t(entity.genre, {\"count\": 1})",
"albumCount": "$t(entity.album, {\"count\": 2}) total",
@@ -281,8 +278,7 @@
"artist": "$t(entity.artist, {\"count\": 1})",
"isPublic": "est public",
"album": "$t(entity.album, {\"count\": 1})",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "tri par nom"
"explicitStatus": "$t(common.explicitStatus)"
},
"page": {
"sidebar": {
@@ -429,7 +425,7 @@
"trackList": {
"title": "$t(entity.track, {\"count\": 2})",
"artistTracks": "pistes par {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})"
"genreTracks": "'{{genre}}' $t(entity.track, {\"count\": 2})"
},
"playlistList": {
"title": "$t(entity.playlist, {\"count\": 2})"
@@ -449,12 +445,7 @@
"viewDiscography": "voir la discographie",
"relatedArtists": "$t(entity.artist, {\"count\": 2}) similaires",
"topSongs": "meilleurs titres",
"groupingTypeAll": "toutes les types de sortie",
"favoriteSongs": "titres préférées",
"groupingTypePrimary": "types de parution principale",
"topSongsCommunity": "communauté",
"topSongsPersonal": "personnel",
"favoriteSongsFrom": "meilleurs titres de {{title}}"
"groupingTypeAll": "toutes les types de sortie"
},
"itemDetail": {
"copyPath": "copier le chemin dans le presse-papiers",
@@ -480,14 +471,6 @@
},
"radioList": {
"title": "stations radio"
},
"releasenotes": {
"commitsSinceStable": "commits depuis {{stable}}",
"noNewCommits": "pas de nouveaux commits dans cette plage"
},
"windowBar": {
"paused": "(Pause) ",
"privateMode": "(Mode Privé)"
}
},
"setting": {
@@ -504,6 +487,7 @@
"applicationHotkeys_description": "configurer les raccourcis clavier dapplication. activer la case à cocher pour définir comme raccourci clavier global (bureau uniquement)",
"crossfadeStyle_description": "sélectionnez le style du fondu enchaîné à utiliser pour le lecteur audio",
"customFontPath": "chemin de police personnalisé",
"disableAutomaticUpdates": "désactiver les mises à jour automatiques",
"customFontPath_description": "définit le chemin de police personnalisé pour l'application",
"remotePort_description": "définit le port du serveur de contrôle à distance",
"hotkey_skipBackward": "reculer",
@@ -733,7 +717,7 @@
"releaseChannel_optionLatest": "dernière",
"releaseChannel_optionBeta": "bêta",
"releaseChannel": "canal de diffusion",
"releaseChannel_description": "choisissez entre les versions stables, bêta, ou alpha (nightly) pour les mises à jour automatiques",
"releaseChannel_description": "choisissez entre les versions stables ou les versions bêta pour les mises à jour automatiques",
"mediaSession": "activer media session",
"mediaSession_description": "active l'intégration Media Session, affichant les commandes multimédias et les métadonnées dans la superposition du volume du système et l'écran de verrouillage",
"enableAutoTranslation_description": "activer la traduction automatiquement lorsque les paroles sont chargées",
@@ -1038,9 +1022,9 @@
"artist": "$t(entity.artist, {\"count\": 1})",
"album": "$t(entity.album, {\"count\": 1})",
"biography": "$t(common.biography)",
"channels": "$t(common.channel, {\"count\": 2})",
"channels": "$t(common.channel_other)",
"bitrate": "$t(common.bitrate)",
"actions": "$t(common.action, {\"count\": 2})",
"actions": "$t(common.action_other)",
"favorite": "$t(common.favorite)",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"rating": "$t(common.rating)",
@@ -1082,7 +1066,7 @@
"artist": "$t(entity.artist, {\"count\": 1})",
"genre": "$t(entity.genre, {\"count\": 1})",
"songCount": "$t(entity.track, {\"count\": 2})",
"channels": "$t(common.channel, {\"count\": 2})",
"channels": "$t(common.channel_other)",
"size": "$t(common.size)",
"codec": "$t(common.codec)",
"owner": "propriétaire",
+1
View File
@@ -666,6 +666,7 @@
"customCss": "egyéni css",
"customCssEnable_description": "lehetővé teszi az egyéni css írását",
"customCssEnable": "egyéni css engedélyezése",
"disableAutomaticUpdates": "automatikus frissítés kikapcsolása",
"customFontPath": "egyéni betűtípus elérési út",
"customCss_description": "egyéni css tartalom. Megjegyzés: a tartalom és a távoli URL-ek nem megengedett tulajdonságok. A tartalom előnézete az alábbiakban látható. A tisztítás miatt további mezők is megjelennek, amelyeket te nem állítottál be",
"customCssNotice": "Figyelem: bár van némi tisztítás (az url() és a content: használata nem engedélyezett), az egyéni css használata továbbra is kockázatot jelenthet, mivel megváltoztatja a felületet",
+1
View File
@@ -648,6 +648,7 @@
"customCss_description": "konten CSS kustom. Catatan: properti content dan URL jarak jauh tidak diizinkan. Pratinjau konten Anda ditampilkan di bawah. Kolom tambahan yang tidak Anda atur ada karena sanitasi",
"customFontPath": "jalur font kustom",
"customFontPath_description": "tentukan jalur font kustom yang akan digunakan aplikasi",
"disableAutomaticUpdates": "nonaktifkan pembaruan otomatis",
"discordApplicationId": "ID aplikasi {{discord}}",
"discordApplicationId_description": "id aplikasi untuk rich presence {{discord}} (default: {{defaultId}})",
"discordIdleStatus": "tampilkan status tidak aktif dalam status aktivitas",
+1
View File
@@ -202,6 +202,7 @@
"hotkey_globalSearch": "ricerca globale",
"gaplessAudio_description": "imposta l'audio gapless per mpv",
"remoteUsername_description": "imposta l'username del server di controllo remoto. Se username e password sono vuoti, l'autenticazione sarà disattivata",
"disableAutomaticUpdates": "disabilita aggiornamenti automatici",
"exitToTray_description": "riduce a icona nella barra di sistema all'uscita",
"followLyric_description": "scorre il testo alla posizione di riproduzione corrente",
"hotkey_favoritePreviousSong": "$t(common.previousSong) preferita",
+5 -4
View File
@@ -95,6 +95,7 @@
"hotkey_globalSearch": "グローバル検索",
"gaplessAudio_description": "MPV 向けのギャップレス再生を設定します",
"remoteUsername_description": "リモートコントロール サーバーのユーザ名を設定します。 ユーザー名とパスワードの両方が空の場合、認証は無効になります",
"disableAutomaticUpdates": "自動更新を無効化",
"exitToTray_description": "アプリケーション終了ボタンが押された際、システムトレイに格納します",
"followLyric_description": "現在の再生位置に歌詞をスクロールします",
"hotkey_favoritePreviousSong": "$t(common.previousSong) をお気に入り",
@@ -630,10 +631,10 @@
"genericError": "エラーが発生しました",
"credentialsRequired": "ログイン情報が必要です",
"sessionExpiredError": "セッションの有効期限が切れました",
"remoteEnableError": "リモートサーバーを$t(common.enable)にする際にエラーが発生しました",
"remoteEnableError": "リモートサーバーを $t(common.enable) にする際にエラーが発生しました",
"localFontAccessDenied": "ローカルフォントへのアクセスが拒否されました",
"serverNotSelectedError": "サーバーが選択されていません",
"remoteDisableError": "リモートサーバーを$t(common.disable)にする際にエラーが発生しました",
"remoteDisableError": "リモートサーバーを $t(common.disable) にする際にエラーが発生しました",
"mpvRequired": "MPV が必要です",
"audioDeviceFetchError": "オーディオデバイスの取得時にエラーが発生しました",
"invalidServer": "無効なサーバー",
@@ -922,8 +923,8 @@
"input_name": "サーバー名",
"success": "サーバーが追加されました",
"input_savePassword": "パスワードを保存",
"ignoreSsl": "SSL を無視します ($t(common.restartRequired))",
"ignoreCors": "CORS を無視します ($t(common.restartRequired))",
"ignoreSsl": "SSL を無視 ($t(common.restartRequired))",
"ignoreCors": "CORSを無視 ($t(common.restartRequired))",
"error_savePassword": "パスワードを保存する際にエラーが発生しました",
"input_preferInstantMixDescription": "類似曲を取得するにはインスタントミックスのみを使用してください。この動作を変更するプラグインがある場合に役立ちます",
"input_preferInstantMix": "インスタントミックスを優先する",
+17 -348
View File
@@ -119,7 +119,7 @@
"size": "grootte",
"reload": "herlaad",
"setting_one": "instelling",
"setting_other": "instellingen",
"setting_other": "",
"close": "sluiten",
"additionalParticipants": "andere deelnemers",
"newVersion": "een nieuwe versie is geïnstalleerd ({{version}})",
@@ -157,9 +157,7 @@
"example": "voorbeeld",
"mood": "stemming",
"retry": "opnieuw proberen",
"filter_single": "single",
"rename": "hernoemen",
"filter_multiple": "meerdere"
"filter_single": "single"
},
"filter": {
"rating": "rating",
@@ -204,8 +202,7 @@
"songCount": "aantal nummers",
"toYear": "tot jaar",
"trackNumber": "track",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "sorteernaam"
"explicitStatus": "$t(common.explicitStatus)"
},
"page": {
"contextMenu": {
@@ -241,8 +238,8 @@
"version": "versie {{version}}",
"settings": "$t(common.setting, {\"count\": 2})",
"manageServers": "beheer servers",
"expandSidebar": "zijbalk uitklappen",
"collapseSidebar": "zijbalk inklappen",
"expandSidebar": "sidebar uitklappen",
"collapseSidebar": "sidebar inklappen",
"openBrowserDevtools": "open browser devtools",
"quit": "$t(common.quit)",
"goBack": "terug",
@@ -301,11 +298,7 @@
"viewAllTracks": "bekijk alle $t(entity.track, {\"count\": 2})",
"recentReleases": "recente uitgaven",
"groupingTypeAll": "alle soorten publicaties",
"groupingTypePrimary": "primaire publicatiesoorten",
"favoriteSongs": "favoriete nummers",
"topSongsCommunity": "community",
"topSongsPersonal": "persoonlijk",
"favoriteSongsFrom": "favoriete nummers van {{title}}"
"groupingTypePrimary": "primaire publicatiesoorten"
},
"manageServers": {
"title": "beheer servers",
@@ -391,8 +384,7 @@
"settings": "$t(common.setting, {\"count\": 2})",
"shared": "$t(entity.playlist, {\"count\": 2}) gedeeld",
"tracks": "$t(entity.track, {\"count\": 2})",
"radio": "$t(entity.radioStation, {\"count\": 2})",
"collections": "verzamelingen"
"radio": "$t(entity.radioStation, {\"count\": 2})"
},
"trackList": {
"artistTracks": "nummers van {{artist}}",
@@ -404,14 +396,6 @@
},
"folderList": {
"title": "$t(entity.folder, {\"count\": 2})"
},
"windowBar": {
"paused": "(Gepauzeerd) ",
"privateMode": "(Privémodus)"
},
"collections": {
"overrideExisting": "bestaande overschrijven",
"saveAsCollection": "sla op als verzameling"
}
},
"error": {
@@ -456,12 +440,12 @@
"artist_other": "artiesten",
"folderWithCount_one": "{{count}} folder",
"folderWithCount_other": "{{count}} folders",
"albumArtist_one": "albumartiest",
"albumArtist_other": "albumartiesten",
"albumArtist_one": "album artiest",
"albumArtist_other": "album artiesten",
"track_one": "track",
"track_other": "tracks",
"albumArtistCount_one": "{{count}} albumartiest",
"albumArtistCount_other": "{{count}} albumartiesten",
"albumArtistCount_one": "{{count}} album artiest",
"albumArtistCount_other": "{{count}} album artiesten",
"albumWithCount_one": "{{count}} album",
"albumWithCount_other": "{{count}} albums",
"favorite_one": "favoriet",
@@ -489,107 +473,11 @@
"table": {
"column": {
"rating": "rating",
"size": "$t(common.size)",
"albumArtist": "albumartiest",
"biography": "biografie",
"bitrate": "bitsnelheid",
"comment": "opmerking",
"dateAdded": "datum toegevoegd",
"favorite": "favoriet",
"discNumber": "disc",
"bpm": "bpm",
"album": "album",
"lastPlayed": "laatst gespeeld",
"path": "pad",
"playCount": "keren gespeeld",
"releaseDate": "uitgavedatum",
"releaseYear": "jaar",
"title": "titel",
"trackNumber": "nummer",
"owner": "eigenaar",
"albumCount": "$t(entity.album, {\"count\": 2})",
"artist": "$t(entity.artist, {\"count\": 1})",
"bitDepth": "$t(common.bitDepth)",
"channels": "$t(common.channel, {\"count\": 2})",
"codec": "$t(common.codec)",
"genre": "$t(entity.genre, {\"count\": 1})",
"sampleRate": "$t(common.sampleRate)",
"songCount": "$t(entity.track, {\"count\": 2})"
"size": "$t(common.size)"
},
"config": {
"label": {
"rating": "$t(common.rating)",
"composer": "componist",
"dateAdded": "datum toegevoegd",
"discNumber": "discnummer",
"image": "afbeelding",
"lastPlayed": "laatst gespeeld",
"playCount": "keren afgespeeld",
"releaseDate": "uitgavedatum",
"rowIndex": "rij-index",
"trackNumber": "nummer",
"actions": "$t(common.action, {\"count\": 2})",
"album": "$t(entity.album, {\"count\": 1})",
"albumCount": "$t(entity.album, {\"count\": 2})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"artist": "$t(entity.artist, {\"count\": 1})",
"biography": "$t(common.biography)",
"bitDepth": "$t(common.bitDepth)",
"bitrate": "$t(common.bitrate)",
"bpm": "$t(common.bpm)",
"channels": "$t(common.channel, {\"count\": 2})",
"codec": "$t(common.codec)",
"duration": "$t(common.duration)",
"favorite": "$t(common.favorite)",
"genre": "$t(entity.genre, {\"count\": 1})",
"genreBadge": "$t(entity.genre, {\"count\": 1}) (badges)",
"note": "$t(common.note)",
"owner": "$t(common.owner)",
"path": "$t(common.path)",
"sampleRate": "$t(common.sampleRate)",
"size": "$t(common.size)",
"songCount": "$t(entity.track, {\"count\": 2})",
"title": "$t(common.title)",
"titleArtist": "$t(common.title) (artiest)",
"titleCombined": "$t(common.title) (gecombineerd)",
"year": "$t(common.year)"
},
"general": {
"advancedSettings": "geavanceerde instellingen",
"autoFitColumns": "kolommen automatisch passend maken",
"autosize": "automatische afmetingen",
"moveUp": "omhoog verplaatsen",
"moveDown": "omlaag verplaatsen",
"pinToLeft": "links vastpinnen",
"pinToRight": "rechts vastpinnen",
"alignLeft": "links uitlijnen",
"alignCenter": "centreren",
"alignRight": "rechts uitlijnen",
"followCurrentSong": "huidige nummer volgen",
"displayType": "weergavesoort",
"itemGap": "ruimte tussen items (px)",
"itemSize": "grootte item (px)",
"itemsPerRow": "items per rij",
"size_default": "standaard",
"size_compact": "compact",
"size_large": "groot",
"tableColumns": "kolommen",
"pagination": "paginering",
"pagination_itemsPerPage": "items per pagina",
"pagination_infinite": "oneindig",
"pagination_paginate": "gepagineerd",
"alternateRowColors": "afwisselende rijkleuren",
"horizontalBorders": "randen om rijen",
"rowHoverHighlight": "oplichtende rijen bij zweven met de muis",
"showHeader": "toon kop",
"verticalBorders": "randen om kolommen",
"gap": "$t(common.gap)",
"size": "$t(common.size)"
},
"view": {
"grid": "grid",
"list": "lijst",
"table": "tabel"
"rating": "$t(common.rating)"
}
}
},
@@ -615,8 +503,8 @@
"exportImportSettings_importBtn": "importeer instellingen",
"exportImportSettings_importModalTitle": "importeer feishing-instellingen",
"exportImportSettings_importSuccess": "instellingen zijn succesvol geïmporteerd!",
"exportImportSettings_notValidJSON": "het ingevoerde bestand is geen geldige JSON",
"exportImportSettings_offendingKeyError": "\"{{offendingKey}}\" is onjuist - {{reason}}",
"exportImportSettings_notValidJSON": "het ingevoerde bestand is geen valide JSON",
"exportImportSettings_offendingKeyError": "\"{{offendingKey}}\" is incorrect - {{reason}}",
"externalLinks_description": "maakt het mogelijk om externe links (Last.fm, MusicBrainz) te tonen op artiesten-/albumpagina's",
"externalLinks": "toon externe links",
"followLyric_description": "scroll de songtekst naar de huidige positie",
@@ -685,6 +573,7 @@
"customCssNotice": "Waarschuwing: ondanks sanering (het niet toestaan van url() en content:) brengt aangepaste css nog steeds risico's met zich mee omdat de interface wordt gewijzigd",
"customFontPath_description": "bepaal het pad naar het aangepaste lettertype voor gebruik in de applicatie",
"customFontPath": "aangepaste lettertypelocatie",
"disableAutomaticUpdates": "automatische updates uitschakelen",
"releaseChannel_optionBeta": "beta",
"releaseChannel_optionLatest": "meest recente",
"releaseChannel": "releasekanaal",
@@ -871,73 +760,7 @@
"sidebarPlaylistList": "afspeellijsten zijbalk",
"sidePlayQueueStyle_description": "de stijl van de wachtrij aan de zijkant",
"sidePlayQueueStyle_optionAttached": "aangekoppeld",
"sidePlayQueueStyle_optionDetached": "afgekoppeld",
"homeFeatureStyle_description": "bepaalt de stijl van de uitgelicht-carrousel op de homepagina",
"homeFeatureStyle": "stijl uitgelicht-carrousel",
"homeFeatureStyle_optionMultiple": "meervoudig",
"homeFeatureStyle_optionSingle": "enkelvoudig",
"blurExplicitImages": "vervaag expliciete afbeeldingen",
"blurExplicitImages_description": "hoezen van albums en nummers die getagd zijn als expliciet zullen worden vervaagd",
"mediaSession": "mediasessie inschakelen",
"sidePlayQueueStyle": "stijl van zijwachtrij",
"skipDuration_description": "de tijdsduur die wordt doorgespoeld bij gebruik van de spoelknoppen in de afspeelbalk",
"skipDuration": "doorspoelduur",
"startMinimized_description": "start de applicatie in het systeemvak",
"startMinimized": "start geminimaliseerd",
"theme": "thema",
"theme_description": "het visuele thema dat de applicatie gebruikt",
"themeDark_description": "het donkere thema dat de applicatie gebruikt",
"themeDark": "thema (donker)",
"themeLight_description": "het lichte thema dat de applicatie gebruikt",
"themeLight": "thema (licht)",
"transcode": "transcoderen inschakelen",
"transcode_description": "schakel transcoderen naar andere formaten in",
"transcodeBitrate_description": "de bitsnelheid waarnaar wordt getranscodeerd. bij 0 bepaalt de server de waarde",
"transcodeBitrate": "transcodeerbitsnelheid",
"transcodeFormat_description": "het formaat waarnaar wordt getranscodeerd. laat leeg om de server te laten bepalen",
"transcodeFormat": "transcodeerformaat",
"translationApiKey_description": "api-sleutel voor vertaling (enkel globaal service-eindpunt)",
"translationApiKey": "vertalings-api-sleutel",
"translationApiProvider_description": "api-provider voor vertalingen",
"translationApiProvider": "vertalings-api-provider",
"translationTargetLanguage_description": "doeltaal voor vertalingen",
"translationTargetLanguage": "doeltaal vertaling",
"trayEnabled_description": "toon/verstop het systeemvakicoon/-menu. indien uitgeschakeld wordt het minimaliseren/sluiten naar het systeemvak ook uitgeschakeld",
"trayEnabled": "toon systeemvak",
"useSystemTheme_description": "volg de systeemvoorkeur voor licht of donker thema",
"useSystemTheme": "gebruik systeemthema",
"volumeWheelStep_description": "de hoeveelheid volume die gewijzigd wordt bij het scrollen met het muiswiel op de volumebalk",
"volumeWheelStep": "volumestap muiswiel",
"volumeWidth_description": "de breedte van de volumebalk",
"volumeWidth": "volumebalkbreedte",
"webAudio_description": "gebruik web-audio. dit schakeld geavanceerde mogelijkheden als replaygain in. schakel uit als dit niet werkt",
"webAudio": "gebruik web-audio",
"windowBarStyle": "vensterbalkstijl",
"windowBarStyle_description": "kies de stijl van de vensterbalk",
"zoom": "zoompercentage",
"zoom_description": "het zoompercentage van de applicatie",
"queryBuilder": "opdrachtbouwer",
"queryBuilderCustomFields": "aangepaste velden",
"queryBuilderCustomFields_description": "voeg aangepaste velden voor gebruik in opdrachtbouwers toe",
"enableGridMultiSelect": "meervoudig selecteren in grid",
"enableGridMultiSelect_description": "staat toe meerdere items in gridweergaven te selecteren. indien uitgeschakeld zal het klikken op een item in een gridweergave naar diens pagina navigeren",
"sidebarPlaylistSorting": "afspeellijstsortering in zijbalk",
"sidebarPlaylistSorting_description": "activeert het handmatig sorteren van de afspeellijst in de zijbalk door middel van slepen in plaats van het gebruiken van de servervolgorde",
"sidebarPlaylistListFilterRegex_description": "verberg afspeellijsten in de zijbalk die overeenkomen met deze reguliere expressie",
"sidebarPlaylistListFilterRegex_placeholder": "bijv. ^Daily Mix.*",
"sidebarPlaylistListFilterRegex": "regex afspeellijstfilter",
"mediaSession_description": "schakelt mediasessie-integratie in, waarbij mediabesturing en metadata in het volumeweergave en het lock-scherm worden weergegeven",
"skipPlaylistPage": "sla afspeellijstpagina over",
"skipPlaylistPage_description": "ga naar de nummerlijst in plaats van de standaard pagina bij het navigeren naar een afspeellijst",
"queryBuilderCustomFields_inputLabel": "label",
"queryBuilderCustomFields_inputTag": "tag",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlay": "$t(player.play)",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"replayGainMode_optionAlbum": "$t(entity.album, {\"count\": 1})",
"replayGainMode_optionNone": "$t(common.none)",
"replayGainMode_optionTrack": "$t(entity.track, {\"count\": 1})"
"sidePlayQueueStyle_optionDetached": "afgekoppeld"
},
"form": {
"addServer": {
@@ -1141,159 +964,5 @@
"soundtrack": "soundtrack",
"spokenWord": "gesproken woord"
}
},
"dragDropZone": {
"error_oneFileOnly": "Kies één bestand",
"error_readingFile": "probleem opgetreden bij het lezen van het bestand: {{errorMessage}}",
"mainText": "sleep hier een bestand naartoe"
},
"visualizer": {
"visualizerType": "Type Visualiseerder",
"cyclePresets": "Doorloop Voorinstellingen",
"cycleTime": "Cyclustijd (seconden)",
"ignoredPresets": "Genegeerde Voorinstellingen",
"selectedPresets": "Gekozen Voorinstellingen",
"includeAllPresets": "Alle Voorinstellingen Opnemen",
"randomizeNextPreset": "Willekeurige Volgende Voorinstelling",
"blendTime": "Mengtijd",
"presets": "Voorinstellingen",
"selectPreset": "Kies Voorinstelling",
"applyPreset": "Voorinstelling Toepassen",
"saveAsPreset": "Opslaan als Voorinstelling",
"updatePreset": "Voorinstelling Bijwerken",
"copyConfiguration": "Kopieer Configuratie",
"pasteConfiguration": "Plak Configuratie",
"pasteConfigurationPlaceholder": "Plak JSON-configuratie hier...",
"pasteFromClipboard": "Plakken vanaf Klembord",
"applyConfiguration": "Configuratie Toepassen",
"configCopied": "Configuratie gekopieerd naar het klembord",
"configCopyFailed": "Kopiëren van configuratie is mislukt",
"configPasted": "Configuratie succesvol toegepast",
"configPasteFailed": "Toepassen configuratie mislukt. Controleer het formaat.",
"configPasteReadFailed": "Lezen van het klembord mislukt",
"presetName": "Naam Voorinstelling",
"presetNamePlaceholder": "Voer de naam van de voorinstelling in",
"general": "Algemeen",
"mode": "Modus",
"mode1To8": "Modus 1-8",
"mode10": "Modus 10",
"barSpace": "Balkruimte",
"lineWidth": "Lijnbreedte",
"fillAlpha": "Alfavulling",
"channelLayout": "Kanaalindeling",
"maxFPS": "Max FPS",
"opacity": "Opaciteit",
"customGradients": "Aangepaste Kleurverlopen",
"addCustomGradient": "Voeg Aangepast Kleurverloop Toe",
"gradientName": "Naam Kleurverloop",
"gradientNamePlaceholder": "Naam Kleurverloop",
"vertical": "Verticaal",
"horizontal": "Horizontaal",
"colorStops": "Kleurstop",
"addColor": "Voeg Kleur Toe",
"position": "Positie",
"level": "Niveau",
"remove": "Verwijder",
"pasteGradient": "Plak Kleurverloop",
"pasteGradientPlaceholder": "Plak JSON van kleurverloop hier...",
"custom": "Aangepast",
"builtIn": "Ingebouwd",
"colors": "Kleuren",
"colorMode": "Kleurmodus",
"gradient": "Kleurverloop",
"gradientLeft": "Kleurverloop Links",
"gradientRight": "Kleurverloop Rechts",
"fft": "FFT",
"fftSize": "FFT-grootte",
"smoothing": "Gladstrijken",
"frequencyRangeAndScaling": "Frequentiebereik en -schaling",
"minimumFrequency": "Minimumfrequentie",
"maximumFrequency": "Maximumfrequentie",
"frequencyScale": "Frequentieschaal",
"sensitivity": "Gevoeligheid",
"weightingFilter": "Gewichtsfilter",
"minimumDecibels": "Minimum aantal decibel",
"maximumDecibels": "Maximum aantal decibel",
"linearAmplitude": "Lineaire Amplitude",
"linearBoost": "Lineaire Versterking",
"peakBehavior": "Piekgedrag",
"showPeaks": "Toon Pieken",
"fadePeaks": "Vervaag Pieken",
"peakLine": "Pieklijn",
"gravity": "Zwaartekracht",
"peakFadeTime": "Piekvervagingstijd (ms)",
"peakHoldTime": "Piekvasthoudtijd (ms)",
"radialSpectrum": "Radiaal Spectrum",
"radial": "Radiaal",
"radialInvert": "Geïnverteerde Radiaal",
"spinSpeed": "Draaisnelheid",
"radius": "Radius",
"reflexMirror": "Reflexspiegel",
"reflexRatio": "Reflexverhouding",
"reflexAlpha": "Reflex-alfa",
"reflexFit": "Reflex-inpassing",
"reflexBrightness": "Reflex-helderheid",
"mirror": "Spiegel",
"miscellaneousSettings": "Diverse Instellingen",
"alphaBars": "Alfabalken",
"ansiBands": "ANSI-banden",
"ledBars": "LED-balken",
"trueLeds": "Ware LEDs",
"lumiBars": "Lumi-balken",
"outlineBars": "Uitgelijnde balken",
"roundBars": "Ronde Balken",
"lowResolution": "Lage Resolutie",
"splitGradient": "Gescheiden Kleurverloop",
"showFPS": "Toon FPS",
"showScaleX": "Toon X-schaal",
"showScaleY": "Toon Y-schaal",
"noteLabels": "Nootlabels",
"options": {
"mode": {
"0": "[0] Discrete Frequenties",
"1": "[1] 1/24e octaaf / 240 bands",
"2": "[2] 1/12e octaaf / 120 bands",
"3": "[3] 1/8e octaaf / 80 bands",
"4": "[4] 1/6e octaaf / 60 bands",
"5": "[5] 1/4e octaaf / 40 bands",
"6": "[6] 1/3e octaaf / 30 bands",
"7": "[7] Half octaaf / 20 bands",
"8": "[8] Volledig octaaf / 10 bands",
"10": "[10] Lijn- / Gebiedsgrafiek"
},
"colorMode": {
"gradient": "Kleurverloop",
"barIndex": "Balk-index",
"barLevel": "Balkniveau"
},
"gradient": {
"classic": "Klassiek",
"prism": "Prisma",
"rainbow": "Regenboog",
"steelblue": "Staalblauw",
"orangered": "Oranjerood"
},
"channelLayout": {
"single": "Enkelvoudig",
"dualCombined": "Duaalgecombineerd",
"dualHorizontal": "Duaalhorizontaal",
"dualVertical": "Duaalvertikaal"
},
"frequencyScale": {
"none": "Geen",
"bark": "Bark-schaal",
"linear": "Lineaire Schaal",
"log": "Log-schaal",
"mel": "Mel-schaal"
},
"weightingFilter": {
"none": "Geen",
"a": "A",
"b": "B",
"c": "C",
"d": "D",
"z": "Z"
}
}
}
}
+13 -40
View File
@@ -73,9 +73,9 @@
"delete": "usuń",
"cancel": "anuluj",
"forceRestartRequired": "zrestartuj aby zastosować zmiany... zamknij powiadomienie aby zrestartować",
"setting_one": "ustawienie",
"setting_few": "ustawienia",
"setting_many": "ustawień",
"setting_one": "ustawienia",
"setting_few": "",
"setting_many": "",
"version": "wersja",
"title": "tytuł",
"filter_one": "filtr",
@@ -176,14 +176,14 @@
"playlist_few": "playlisty",
"playlist_many": "playlist",
"artist_one": "wykonawca",
"artist_few": "wykonawcy",
"artist_few": "wykonawców",
"artist_many": "wykonawców",
"folderWithCount_one": "{{count}} katalog",
"folderWithCount_few": "{{count}} katalogi",
"folderWithCount_many": "{{count}} katalogów",
"albumArtist_one": "wykonawca albumu",
"albumArtist_few": "wykonawcy albumu",
"albumArtist_many": "wykonawcy albumów",
"albumArtist_many": "wykonawców albumu",
"track_one": "utwór",
"track_few": "utwory",
"track_many": "utworów",
@@ -428,7 +428,7 @@
"dynamicIsImage": "włącz obraz w tle",
"lyricOffset": "opóźnienie tekstów (ms)"
},
"upNext": "następne",
"upNext": "następny",
"lyrics": "tekst",
"related": "powiązane",
"visualizer": "wizualizer",
@@ -577,11 +577,7 @@
"appearsOn": "pojawia się na",
"viewAllTracks": "zobacz wszystko $t(entity.track, {\"count\": 2})",
"groupingTypeAll": "wszystkie typy wydań",
"groupingTypePrimary": "główne typy wydań",
"favoriteSongs": "ulubione piosenki",
"topSongsCommunity": "społeczność",
"topSongsPersonal": "osobiste",
"favoriteSongsFrom": "ulubione piosenki z {{title}}"
"groupingTypePrimary": "główne typy wydań"
},
"itemDetail": {
"copyPath": "kopiuj ścieżkę do schowka",
@@ -615,11 +611,6 @@
"collections": {
"overrideExisting": "nadpisz istniejące",
"saveAsCollection": "zapisz jako kolekcję"
},
"releasenotes": {
"commitsSinceStable": "commity od {{stable}}",
"noNewCommits": "brak nowych commitów w tym zakresie",
"noStableReleaseToCompare": "brak dostępnego stabilnego wydania do porównania"
}
},
"player": {
@@ -661,16 +652,7 @@
"restoreQueueFromServer": "przywróć kolejkę z serwera",
"saveQueueToServer": "zapisz kolejkę na serwerze",
"artistRadio": "radio wykonawcy",
"trackRadio": "radio utworu",
"sleepTimer": "wyłącznik czasowy",
"sleepTimer_endOfSong": "do końca aktualnej piosenki",
"sleepTimer_minutes": "{{count}} min",
"sleepTimer_hours": "{{count}} godz",
"sleepTimer_custom": "niestandardowy",
"sleepTimer_off": "wyłączony",
"sleepTimer_timeRemaining": "pozostało {{time}}",
"sleepTimer_setCustom": "ustaw wyłącznik",
"sleepTimer_cancel": "anuluj wyłączanie"
"trackRadio": "radio utworu"
},
"setting": {
"crossfadeStyle_description": "wybierz styl przenikania, który ma być używany do odtwarzania dźwięku",
@@ -702,6 +684,7 @@
"globalMediaHotkeys": "globalne skróty klawiszowe multimediów",
"hotkey_globalSearch": "globalne wyszukiwanie",
"gaplessAudio_description": "ustaw dźwięk bez przerw dla mpv",
"disableAutomaticUpdates": "wyłącz automatyczne aktualizacje",
"exitToTray_description": "zamknij aplikację do zasobnika systemowego",
"followLyric_description": "przewiń tekst do obecnego momentu",
"hotkey_favoritePreviousSong": "ulubiona $t(common.previousSong)",
@@ -745,7 +728,7 @@
"hotkey_zoomOut": "oddal",
"hotkey_unfavoriteCurrentSong": "usuń $t(common.currentSong) z ulubionych",
"hotkey_rate0": "wyczyść oceny",
"discordApplicationId": "id aplikacji {{discord}}",
"discordApplicationId": "ID aplikacji {{discord}}",
"applicationHotkeys_description": "ustaw skróty klawiszowe aplikacji. przełącz pole wyboru aby ustawić skrót globalny (tylko komputery)",
"hotkey_volumeMute": "wycisz",
"hotkey_toggleCurrentSongFavorite": "dodaj $t(common.currentSong) do ulubionych",
@@ -903,7 +886,7 @@
"releaseChannel_optionBeta": "beta",
"releaseChannel_optionLatest": "najnowsza",
"releaseChannel": "kanał wydań",
"releaseChannel_description": "wybieraj pomiędzy wydaniami stabilnymi, beta lub alpha (nightly) dla automatycznych aktualizacji",
"releaseChannel_description": "wybieraj pomiędzy stabilnymi wydaniami a wydaniami beta dla automatycznych aktualizacji",
"discordDisplayType_artistname": "nazwa(y) wykonawców",
"discordDisplayType_description": "zmienia co jest pokazywane jako słuchane w twoim statusie",
"discordDisplayType_songname": "nazwa piosenki",
@@ -1014,24 +997,14 @@
"sidebarPlaylistSorting": "sortowanie playlist w bocznym pasku",
"sidebarPlaylistListFilterRegex_description": "ukryj playlisty w pasku bocznym pasujące do wyrażenia regularnego",
"sidebarPlaylistListFilterRegex_placeholder": "np. ^Miks codzienny.^",
"sidebarPlaylistListFilterRegex": "filtr playlist regex",
"blurExplicitImages": "rozmazuj nieodpowiednie obrazy",
"blurExplicitImages_description": "obrazy piosenek oraz albumów oznaczone jako nieodpowiednie będą rozmazywane",
"releaseChannel_optionAlpha": "alpha (nightly)",
"analyticsEnable": "Wysyłaj statystyki na podstawie użytkowania",
"analyticsEnable_description": "Zanonimizowane statystki użytkowania będą wysyłane do twórcy, aby pomóc w poprawie aplikacji",
"automaticUpdates": "Aktualizacje automatyczne",
"automaticUpdates_description": "Sprawdzaj i instaluj aktualizacje automatycznie",
"discordStateIcon": "pokaż ikonę odtwarzania",
"discordStateIcon_description": "pokazuje małą ikonę odtwarzania w statusie. ikona pauzy jest zawsze pokazywana gdy \"Pokaż status podczas pauzy\" jest włączone"
"sidebarPlaylistListFilterRegex": "filtr playlist regex"
},
"table": {
"config": {
"view": {
"table": "tabela",
"grid": "siatka",
"list": "lista",
"detail": "szczegół"
"list": "lista"
},
"general": {
"displayType": "typ wyświetlania",
+1
View File
@@ -230,6 +230,7 @@
"crossfadeDuration_description": "define a duração do efeito crossfade",
"customCssNotice": "Atenção: embora haja alguma sanitização (proibindo url() e content:), usar CSS personalizado ainda pode representar riscos ao alterar a interface",
"crossfadeStyle_description": "seleciona qual estilo de crossfade usado no player de áudio",
"disableAutomaticUpdates": "desabilitar atualizações automáticas",
"disableLibraryUpdateOnStartup": "desabilitar a verificação de novas versões na inicialização",
"artistBackground": "Imagem de fundo do artista",
"artistBackground_description": "Adiciona uma imagem de fundo às páginas do artista contendo a arte do artista",
+1
View File
@@ -523,6 +523,7 @@
"customCssEnable_description": "permite escrever css customizado",
"customCssNotice": "Aviso: apesar de existir alguma higienização (url() e content: não são permitidas), o uso de css personalizado ainda pode representar riscos ao alterar a interface",
"customCss": "css customizado",
"disableAutomaticUpdates": "desativar atualizações automáticas",
"disableLibraryUpdateOnStartup": "desativar a verificação de novas versões na inicialização",
"discordApplicationId": "{{discord}} ID da aplicação",
"discordIdleStatus_description": "quando ativado, atualiza o estado enquanto o player está ocioso",
+1
View File
@@ -727,6 +727,7 @@
"disableLibraryUpdateOnStartup": "отключить проверку новых версий при запуске приложения",
"minimizeToTray_description": "сворачивать приложение в панель уведомлений",
"audioPlayer_description": "укажите, какой аудиоплеер использовать для воспроизведения",
"disableAutomaticUpdates": "отключить проверку обновлений",
"exitToTray_description": "При закрытии приложения - оно останется в панели уведомлений",
"fontType_optionCustom": "пользовательский",
"remotePassword": "пароль к серверу удалённого управления",
+1
View File
@@ -541,6 +541,7 @@
"customCss_description": "vlastný css obsah. Poznámka: obsah a vzdialené url linky sú defaultne deaktivované.Náhľad vášho obsahu je zobrazený nižšie. Pridané polia, ktoré ste nenastavovali boli pridané pri sanitizácii",
"customFontPath": "cesta k vlastným fontom",
"customFontPath_description": "Nastaví cestu k vlastným fontom na použitie aplikáciou",
"disableAutomaticUpdates": "vypnúť automatické aktualizácie",
"disableLibraryUpdateOnStartup": "vypnúť kontrolu nových verzií pri štarte",
"discordApplicationId": "id aplikácie {{discord}}",
"discordApplicationId_description": "aplikačné id pre plnohodnotné prepojenie s {{discord}} (predvolená hodnota {{defaultId}})",
+1
View File
@@ -548,6 +548,7 @@
"customCss_description": "vsebina css po meri. Opomba: vsebina in oddaljeni url-ji so prepovedane lastnosti. Spodaj je prikazan predogled vaše vsebine. Dodatna polja, ki jih niste nastavili, so prisotna zaradi prečiščevanja",
"customFontPath": "pot za pisavo po meri",
"customFontPath_description": "nastavi pot do pisave po meri",
"disableAutomaticUpdates": "onemogoči samodejne posodobitve",
"disableLibraryUpdateOnStartup": "onemogoči prevejranje novih verzij ob zagonu",
"discordApplicationId": "{{discord}} identifikator aplikacije",
"discordApplicationId_description": "identifikator aplikacije za {{discord}} bogato prezenco (privzeto {{defaultId}})",
+1
View File
@@ -88,6 +88,7 @@
"hotkey_globalSearch": "globalno pretraživanje",
"gaplessAudio_description": "postavlja opciju bez pauze zvuka za mpv (preporučeno: slabo)",
"remoteUsername_description": "postavlja korisničko ime za daljinsku kontrolu servera. Ako su i korisničko ime i lozinka prazni, autentifikacija će biti onemogućena",
"disableAutomaticUpdates": "onemogući automatsko ažuriranje",
"exitToTray_description": "izlazak aplikacije u sistemsku traku",
"followLyric_description": "pomera tekst pesme na trenutnu poziciju reprodukcije",
"hotkey_favoritePreviousSong": "omiljena $t(common.previousSong)",
+1
View File
@@ -609,6 +609,7 @@
"customCssEnable": "தனிப்பயன் சிஎச்எச் ஐ இயக்கவும்",
"customCssNotice": "எச்சரிக்கை: சில சுத்திகரிப்பு (URL () மற்றும் உள்ளடக்கத்தை அனுமதிக்காதது :) இருக்கும்போது, தனிப்பயன் சிஎச்எச் ஐப் பயன்படுத்துவது இடைமுகத்தை மாற்றுவதன் மூலம் ஆபத்துக்களை ஏற்படுத்தக்கூடும்",
"contextMenu_description": "நீங்கள் ஒரு உருப்படியை வலது சொடுக்கு செய்யும் போது பட்டியலில் காட்டப்பட்டுள்ள உருப்படிகளை மறைக்க உங்களை அனுமதிக்கிறது. சரிபார்க்கப்படாத உருப்படிகள் மறைக்கப்படும்",
"disableAutomaticUpdates": "தானியங்கி புதுப்பிப்புகளை முடக்கு",
"discordApplicationId_description": "{{discord}} பணக்கார இருப்புக்கான பயன்பாட்டு ஐடி (இயல்புநிலை {{defaultId}})",
"discordIdleStatus": "பணக்கார இருப்பு செயலற்ற நிலையைக் காட்டுங்கள்",
"discordIdleStatus_description": "இயக்கப்பட்டால், பிளேயர் சும்மா இருக்கும்போது நிலையைப் புதுப்பிக்கவும்",
+1
View File
@@ -534,6 +534,7 @@
"customCss_description": "özel css içeriği. Not: içerik ve uzaktan URL'ler izin verilmeyen özelliklerdir. İçeriğinizin önizlemesi aşağıda gösterilmektedir. Ayarlamadığınız ek alanlar sterilleme nedeniyle mevcuttur",
"customFontPath": "özel yazı tipi yolu",
"customFontPath_description": "uygulama için kullanılacak özel yazı tipinin yolunu ayarlar",
"disableAutomaticUpdates": "otomatik güncellemeleri devre dışı bırak",
"disableLibraryUpdateOnStartup": "başlangıçta yeni sürümler için denetimi devre dışı bırak",
"discordApplicationId": "{{discord}} uygulama kimliği",
"discordApplicationId_description": "{{discord}} \"Rich Presence\" için uygulama kimliği (varsayılan olarak {{defaultId}})",
+1 -540
View File
@@ -1,544 +1,5 @@
{
"action": {
"addToFavorites": "додати до $t(entity.favorite, {\"count\": 2})",
"addOrRemoveFromSelection": "додати або видалити з вибору",
"selectRangeOfItems": "вибрати діапазон елементів",
"addToPlaylist": "додати до $t(entity.playlist, {\"count\": 1})",
"clearQueue": "очистити чергу",
"createPlaylist": "створити $t(entity.playlist, {\"count\": 1})",
"createRadioStation": "створити $t(entity.radioStation, {\"count\": 1})",
"deletePlaylist": "видалити $t(entity.playlist, {\"count\": 1})",
"deleteRadioStation": "видалити $t(entity.radioStation, {\"count\": 1})",
"selectAll": "вибрати все",
"deselectAll": "скасувати вибір усього",
"downloadStarted": "почато завантаження {{count}} елементів",
"editPlaylist": "редагувати $t(entity.playlist, {\"count\": 1})",
"goToPage": "перейти на сторінку",
"moveToNext": "перейти до наступного",
"moveToBottom": "перемістити вниз",
"moveToTop": "перемістити вгору",
"moveUp": "перемістити вище",
"moveDown": "перемістити нижче",
"holdToMoveToTop": "утримуйте, щоб перемістити вгору",
"holdToMoveToBottom": "утримувати, щоб перемістити вниз",
"moveItems": "перемістити елементи",
"shuffle": "перемішати",
"shuffleAll": "все випадково",
"shuffleSelected": "вибране випадково",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "видалити з $t(entity.favorite, {\"count\": 2})",
"removeFromPlaylist": "видалити з $t(entity.playlist, {\"count\": 1})",
"removeFromQueue": "видалити з черги",
"setRating": "встановити рейтинг",
"toggleSmartPlaylistEditor": "перемикати редактор $t(entity.smartPlaylist)",
"viewPlaylists": "показати $t(entity.playlist, {\"count\": 2})",
"viewMore": "переглянути більше",
"openApplicationDirectory": "відкрити каталог додатків",
"openIn": {
"lastfm": "Відкрити в Last.fm",
"musicbrainz": "Відкрити в MusicBrainz"
}
},
"common": {
"countSelected": "вибрано {{count}}",
"explicitStatus": "явний статус",
"action_one": "дія",
"action_few": "дії",
"action_many": "дій",
"add": "додати",
"additionalParticipants": "додаткові учасники",
"newVersion": "встановлено нову версію ({{version}})",
"viewReleaseNotes": "переглянути список змін",
"albumGain": "підсилення альбому",
"albumPeak": "піковий рівень альбому",
"areYouSure": "ви впевнені?",
"ascending": "зростаючи",
"backward": "назад",
"biography": "біографія",
"bitDepth": "розрядність",
"bitrate": "бітрейт",
"bpm": "уд/хв",
"cancel": "скасувати",
"center": "посередині",
"channel_one": "канал",
"channel_few": "канали",
"channel_many": "каналів",
"clear": "очистити",
"close": "закрити",
"codec": "кодек",
"collapse": "згорнути",
"comingSoon": "скоро…",
"configure": "налаштувати",
"confirm": "підтвердити",
"create": "створити",
"currentSong": "поточний $t(entity.track, {\"count\": 1})",
"decrease": "знизити",
"delete": "видалити",
"descending": "за спаданням",
"description": "опис",
"disable": "вимкнути",
"disc": "диск",
"dismiss": "відхилити",
"doNotShowAgain": "не показувати це знову",
"duration": "тривалість",
"view": "показати",
"edit": "змінити",
"enable": "увімкнути",
"expand": "розширити",
"example": "приклад",
"externalLinks": "зовнішні посилання",
"faster": "швидше",
"favorite": "улюблений",
"filter_one": "фільтр",
"filter_few": "фільтри",
"filter_many": "фільтрів",
"filters": "фільтри",
"filter_single": "одиночний",
"filter_multiple": "кілька",
"forceRestartRequired": "перезапустіть, щоб застосувати зміни… закрийте повідомлення, щоб перезапустити",
"forward": "уперед",
"gap": "прогалина",
"home": "додому",
"increase": "збільшити",
"left": "ліво",
"limit": "ліміт",
"manage": "управління",
"maximize": "максимізувати",
"menu": "меню",
"minimize": "мінімізувати",
"modified": "відредаговано",
"mbid": "MusicBrainz ID",
"mood": "настрій",
"name": "назва",
"no": "ні",
"none": "жоден",
"noResultsFromQuery": "запит не дав результатів",
"noFilters": "фільтри не налаштовані",
"note": "примітка",
"ok": "ок",
"owner": "власник",
"path": "шлях",
"playerMustBePaused": "плеєр повинен бути призупинений",
"preview": "перегляд",
"previousSong": "минулий $t(entity.track, {\"count\": 1})",
"private": "приватний",
"public": "публічний",
"quit": "вийти",
"random": "випадково",
"rating": "рейтинг",
"retry": "повторити спробу",
"recordLabel": "лейбл звукозапису",
"releaseType": "тип випуску",
"refresh": "оновити",
"reload": "перезавантажити",
"rename": "перейменувати",
"reset": "скинути",
"resetToDefault": "скинути до заводських налаштувань",
"restartRequired": "необхідний перезапуск",
"right": "право",
"clean": "чистo",
"sampleRate": "частота дискретизації",
"save": "зберегти",
"saveAndReplace": "зберегти та замінити",
"saveAs": "зберегти як",
"search": "пошук",
"setting_one": "налаштування",
"setting_few": "налаштування",
"setting_many": "налаштувань",
"slower": "повільніше",
"share": "поділитися",
"size": "розмір",
"sort": "впорядкувати",
"sortOrder": "порядок",
"tags": "теги",
"title": "назва",
"trackNumber": "трек",
"trackGain": "підсилення треку",
"trackPeak": "піковий рівень треку",
"translation": "переклад",
"unknown": "невідомий",
"version": "версія",
"year": "рік",
"yes": "так",
"explicit": "Експліцитний зміст",
"gridRows": "рядки сітки",
"tableColumns": "стовпці таблиці",
"itemsMore": "{{count}} більше"
},
"entity": {
"album_one": "альбом",
"album_few": "альбоми",
"album_many": "альбомів",
"albumArtist_one": "виконавець альбому",
"albumArtist_few": "виконавці альбому",
"albumArtist_many": "виконавців альбому",
"albumArtistCount_one": "{{count}} виконавець альбому",
"albumArtistCount_few": "{{count}} виконавці альбому",
"albumArtistCount_many": "{{count}} виконавців альбому",
"albumWithCount_one": "{{count}} альбом",
"albumWithCount_few": "{{count}} альбоми",
"albumWithCount_many": "{{count}} альбомів",
"radioStation_one": "радіостанція",
"radioStation_few": "радіостанції",
"radioStation_many": "радіостанцій",
"radioStationWithCount_one": "{{count}} радіостанція",
"radioStationWithCount_few": "{{count}} радіостанції",
"radioStationWithCount_many": "{{count}} радіостанцій",
"artist_one": "виконавець",
"artist_few": "виконавці",
"artist_many": "виконавців",
"artistWithCount_one": "{{count}} виконавець",
"artistWithCount_few": "{{count}} виконавці",
"artistWithCount_many": "{{count}} виконавців",
"favorite_one": "улюблений",
"favorite_few": "улюблені",
"favorite_many": "улюблених",
"folder_one": "папка",
"folder_few": "папки",
"folder_many": "папок",
"folderWithCount_one": "{{count}} папка",
"folderWithCount_few": "{{count}} папки",
"folderWithCount_many": "{{count}} папок",
"genre_one": "жанр",
"genre_few": "жанри",
"genre_many": "жанрів",
"genreWithCount_one": "{{count}} жанр",
"genreWithCount_few": "{{count}} жанри",
"genreWithCount_many": "{{count}} жанрів",
"playlist_one": "плейлист",
"playlist_few": "плейлисти",
"playlist_many": "плейлистів",
"play_one": "{{count}} відтворення",
"play_few": "{{count}} відтворення",
"play_many": "{{count}} відтворень",
"playlistWithCount_one": "{{count}} плейлист",
"playlistWithCount_few": "{{count}} плейлисти",
"playlistWithCount_many": "{{count}} плейлистів",
"smartPlaylist": "розумний $t(entity.playlist, {\"count\": 1})",
"track_one": "трек",
"track_few": "треки",
"track_many": "треків",
"song_one": "пісня",
"song_few": "пісні",
"song_many": "пісень",
"trackWithCount_one": "{{count}} трек",
"trackWithCount_few": "{{count}} треки",
"trackWithCount_many": "{{count}} треків"
},
"error": {
"apiRouteError": "неможливо виконати запит",
"audioDeviceFetchError": "сталася помилка під час спроби отримати аудіопристрої",
"authenticationFailed": "аутентифікація не вдалася",
"badAlbum": "ви бачите цю сторінку, тому що ця пісня не входить до альбому. найімовірніше, ця проблема виникає, якщо у верхньому рівні вашої музичної папки знаходиться пісня. Jellyfin групує треки тільки в тому випадку, якщо вони знаходяться в папці",
"badValue": "недійсний параметр \"{{value}}\". це значення більше не існує",
"credentialsRequired": "необхідні дані для входу",
"endpointNotImplementedError": "кінцева точка {{endpoint}} не реалізована для {{serverType}}",
"genericError": "сталася помилка",
"invalidServer": "недійсний сервер",
"localFontAccessDenied": "відмова в доступі до локальних шрифтів",
"loginRateError": "занадто багато спроб входу, спробуйте ще раз через кілька секунд",
"mpvRequired": "необхідний MPV",
"multipleServerSaveQueueError": "у черзі відтворення є одна або кілька пісень, які не належать до поточного сервера. це не підтримується",
"networkError": "сталася мережева помилка",
"noNetwork": "сервер недоступний",
"noNetworkDescription": "не вдалося підключитися до цього сервера",
"notificationDenied": "дозвіл на сповіщення було відхилено. це налаштування не має впливу",
"openError": "не вдалося відкрити файл",
"playbackError": "сталася помилка під час спроби відтворити медіафайл",
"remoteDisableError": "сталася помилка під час спроби $t(common.disable) віддаленого сервера",
"remoteEnableError": "сталася помилка під час спроби $t(common.enable) віддаленого сервера",
"remotePortError": "сталася помилка під час спроби налаштувати порт віддаленого сервера",
"remotePortWarning": "перезапустіть сервер щоб застосувати новий порт",
"saveQueueFailed": "не вдалося зберегти чергу",
"serverNotSelectedError": "не вибрано жодного сервера",
"serverRequired": "потрібен сервер",
"sessionExpiredError": "ваша сесія закінчилася",
"systemFontError": "сталася помилка під час спроби отримати системні шрифти",
"settingsSyncError": "виявлено розбіжності між налаштуваннями в рендерері та основним процесом. перезапустіть програму, щоб застосувати зміни"
},
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"albumCount": "кількість $t(entity.album, {\"count\": 2})",
"artist": "$t(entity.artist, {\"count\": 1})",
"biography": "біографія",
"bitrate": "бітрейт",
"bpm": "уд/хв",
"channels": "$t(common.channel, {\"count\": 2})",
"comment": "коментар",
"communityRating": "рейтинг спільноти",
"criticRating": "рейтинг критиків",
"dateAdded": "дата додавання",
"disc": "диск",
"duration": "тривалість",
"favorited": "улюблене",
"fromYear": "з року",
"genre": "$t(entity.genre, {\"count\": 1})",
"id": "id",
"isCompilation": "є компіляцією",
"isFavorited": "є улюбленим",
"isPublic": "є публічним",
"isRated": "є оціненим",
"isRecentlyPlayed": "нещодавно відтворено",
"lastPlayed": "нещодавно відтворені",
"mostPlayed": "найбільш відтворювані",
"name": "назва",
"note": "примітка",
"owner": "$t(common.owner)",
"path": "шлях",
"playCount": "кількість відтворень",
"random": "випадково",
"rating": "рейтинг",
"recentlyAdded": "нещодавно додано",
"recentlyPlayed": "нещодавно відтворено",
"recentlyUpdated": "нещодавно оновлено",
"releaseDate": "дата випуску",
"releaseYear": "рік випуску",
"search": "шукати",
"songCount": "кількість пісень",
"sortName": "сортування за назвою",
"title": "назва",
"toYear": "до року",
"trackNumber": "трек",
"explicitStatus": "$t(common.explicitStatus)"
},
"datetime": {
"minuteShort": "хв.",
"secondShort": "сек.",
"hourShort": "год",
"dayShort": "дн."
},
"filterOperator": {
"after": "є після",
"afterDate": "після (дата)",
"before": "є перед",
"beforeDate": "є перед (дата)",
"contains": "містить",
"endsWith": "закінчується на",
"inPlaylist": "є в",
"inTheLast": "є в останньому",
"inTheRange": "є в межах",
"inTheRangeDate": "є в межах (дата)",
"is": "є",
"isNot": "не є",
"isGreaterThan": "більше ніж",
"isLessThan": "менше ніж",
"matchesRegex": "відповідає регулярному виразу",
"notContains": "не містить",
"notInPlaylist": "немає в",
"notInTheLast": "не є в останньому",
"startsWith": "починається з"
},
"form": {
"addServer": {
"error_savePassword": "сталася помилка під час спроби зберегти пароль",
"ignoreCors": "ігнорувати cors ($t(common.restartRequired))",
"ignoreSsl": "ігнорувати ssl ($t(common.restartRequired)}",
"input_legacyAuthentication": "увімкнути застарілу автентифікацію",
"input_name": "назва сервера",
"input_password": "пароль",
"input_preferInstantMix": "віддавати перевагу миттєвому міксу",
"input_preferInstantMixDescription": "використовувати тільки миттєвий мікс щоб отримати подібні пісні. корисно, коли у вас є плагіни, які змінюють цю поведінку",
"input_preferRemoteUrl": "віддавати перевагу публічній URL-адресі",
"input_remoteUrl": "публічна URL-адреса",
"input_remoteUrlPlaceholder": "опціонально: публічна URL-адреса для зовнішніх функцій",
"input_savePassword": "зберегти пароль",
"input_url": "URL-адреса",
"input_username": "Ім'я користувача",
"success": "сервер додано успішно",
"title": "додати сервер"
},
"largeFetchConfirmation": {
"title": "додати елементи до черги",
"description": "Ця дія додасть усі елементи в поточний відфільтрований перегляд"
},
"addToPlaylist": {
"create": "створити $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"input_skipDuplicates": "пропустити дублікати",
"searchOrCreate": "шукайте $t(entity.playlist, {\"count\": 2}) або пишіть, щоб створити новий",
"success": "додано $t(entity.trackWithCount, {\"count\": {{message}} }) до $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "додати до $t(entity.playlist, {\"count\": 1})"
},
"createPlaylist": {
"input_description": "$t(common.description)",
"input_name": "$t(common.name)",
"input_owner": "$t(common.owner)",
"input_public": "публічний",
"success": "$t(entity.playlist, {\"count\": 1}) стрворено успішно",
"title": "створити $t(entity.playlist, {\"count\": 1})"
},
"createRadioStation": {
"success": "радіостанція створена успішно",
"title": "створити радіостанцію",
"input_homepageUrl": "адреса домашньої сторінки",
"input_name": "назва",
"input_streamUrl": "URL-адреса потоку"
},
"deletePlaylist": {
"input_confirm": "введіть ім'я $t(entity.playlist, {\"count\": 1}) для підтвердження",
"success": "$t(entity.playlist, {\"count\": 1}) успішно видалено",
"title": "видалити $t(entity.playlist, {\"count\": 1})"
},
"editPlaylist": {
"publicJellyfinNote": "Jellyfin з якоїсь причини не показує, чи є плейлист публічним чи ні. Якщо ви хочете, щоб він залишався публічним, виберіть варіант нижче",
"editNote": "ручне редагування не рекомендується для великих плейлистів. ви впевнені, що готові прийняти ризик втрати даних, який виникає при перезапису існуючого плейлисту?",
"success": "$t(entity.playlist, {\"count\": 1}) успішно оновлено",
"title": "змінити $t(entity.playlist, {\"count\": 1})"
},
"lyricsExport": {
"export": "експортувати тексти пісень",
"input_synced": "експортувати синхронізовані тексти пісень",
"input_offset": "$t(setting.lyricOffset)"
},
"lyricSearch": {
"input_artist": "$t(entity.artist, {\"count\": 1})",
"input_name": "$t(common.name)",
"title": "шукати тексти пісень"
},
"queryEditor": {
"title": "редактор запитів",
"input_optionMatchAll": "збіг за всіма",
"input_optionMatchAny": "збіг за будь-яким",
"addRuleGroup": "додати групу правил",
"removeRuleGroup": "видалити групу правил",
"resetToDefault": "скинути до заводських налаштувань",
"clearFilters": "очистити фільтри"
},
"saveQueue": {
"success": "черга відтворення збережена на сервері"
},
"shareItem": {
"allowDownloading": "дозволити завантаження",
"description": "опис",
"setExpiration": "встановити термін дії",
"success": "посилання для спільного використання скопійовано в буфер обміну (натисніть тут, щоб відкрити)",
"expireInvalid": "термін дії повинен бути в майбутньому",
"createFailed": "не вдалося створити спільний доступ (чи ввімкнено спільний доступ?)"
},
"shuffleAll": {
"title": "відтворити випадково",
"input_genre": "$t(entity.genre, {\"count\": 1})",
"input_limit": "скільки пісень?",
"input_minYear": "від року",
"input_maxYear": "до року",
"input_played": "відтворити фільтр",
"input_played_optionAll": "всі треки",
"input_played_optionUnplayed": "тільки не відтворені треки",
"input_played_optionPlayed": "тільки відтворені треки"
},
"updateServer": {
"success": "сервер успішно оновлено",
"title": "оновити сервер"
},
"privateMode": {
"enabled": "приватний режим увімкнено, стан відтворення тепер приховано від зовнішніх інтеграцій",
"disabled": "приватний режим вимкнено, стан відтворення тепер видно для увімкнених зовнішніх інтеграцій",
"title": "приватний режим"
}
},
"player": {
"skip": "пропустити"
},
"page": {
"albumArtistDetail": {
"about": "Про {{artist}}",
"appearsOn": "з'являється на",
"favoriteSongs": "улюблені пісні",
"groupingTypeAll": "всі типи випуску",
"groupingTypePrimary": "основні типи випуску",
"recentReleases": "останні випуски",
"viewDiscography": "переглянути дискографію",
"relatedArtists": "подібні $t(entity.artist, {\"count\": 2})",
"topSongs": "найкращі пісні",
"topSongsCommunity": "спільнота",
"topSongsFrom": "найкращі пісні від {{title}}",
"topSongsPersonal": "особисте",
"favoriteSongsFrom": "улюблені пісні від {{title}}",
"viewAll": "показати все",
"viewAllTracks": "показати усі $t(entity.track, {\"count\": 2})"
},
"albumArtistList": {
"title": "$t(entity.albumArtist, {\"count\": 2})"
},
"albumDetail": {
"moreFromArtist": "більше від цього $t(entity.artist, {\"count\": 1})",
"moreFromGeneric": "більше від {{item}}",
"released": "видано"
},
"albumList": {
"artistAlbums": "альбоми виконавця {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album, {\"count\": 2})",
"title": "$t(entity.album, {\"count\": 2})"
},
"radioList": {
"title": "радіостанції"
},
"releasenotes": {
"commitsSinceStable": "комміти від {{stable}}",
"noNewCommits": "немає нових коммітів у цьому періоді",
"noStableReleaseToCompare": "немає доступної стабільної версії для порівняння"
},
"favorites": {
"title": "$t(entity.favorite, {\"count\": 2})"
},
"windowBar": {
"paused": "(Призупинено) ",
"privateMode": "(Приватний режим)"
},
"appMenu": {
"collapseSidebar": "згорнути бічну панель",
"commandPalette": "відкрити палітру команд",
"expandSidebar": "розгорнути бічну панель",
"goBack": "повернутися назад",
"goForward": "перейти вперед",
"manageServers": "управління серверами",
"privateModeOff": "вимкнути приватний режим",
"privateModeOn": "увімкнути приватний режим",
"openBrowserDevtools": "відкрити інструменти розробника",
"quit": "$t(common.quit)",
"selectServer": "вибрати сервер",
"selectMusicFolder": "вибрати папку з музикою",
"noMusicFolder": "не вибрано папку з музикою",
"multipleMusicFolders": "Вибрано {{count}} папок з музикою",
"settings": "$t(common.setting, {\"count\": 2})",
"version": "версія {{version}}"
},
"manageServers": {
"title": "управління серверами",
"serverDetails": "інформація про сервер",
"url": "URL-адреса",
"username": "Ім'я користувача",
"editServerDetailsTooltip": "редагувати дані сервера",
"removeServer": "видалити сервер"
},
"contextMenu": {
"addFavorite": "$t(action.addToFavorites)",
"addLast": "$t(player.addLast)",
"addNext": "$t(player.addNext)",
"addToFavorites": "$t(action.addToFavorites)",
"addToPlaylist": "$t(action.addToPlaylist)",
"createPlaylist": "$t(action.createPlaylist)",
"deletePlaylist": "$t(action.deletePlaylist)",
"deselectAll": "$t(action.deselectAll)",
"download": "завантажити",
"moveItems": "$t(action.moveItems)",
"moveToNext": "$t(action.moveToNext)",
"moveToBottom": "$t(action.moveToBottom)",
"moveToTop": "$t(action.moveToTop)",
"numberSelected": "{{count}} вибрано",
"play": "$t(player.play)",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"removeFromFavorites": "$t(action.removeFromFavorites)",
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromQueue": "$t(action.removeFromQueue)",
"setRating": "$t(action.setRating)",
"playShuffled": "$t(player.shuffle)",
"shareItem": "поділитися елементом",
"goTo": "перейти до",
"goToAlbum": "перейти до $t(entity.album, {\"count\": 1})",
"goToAlbumArtist": "перейти до $t(entity.albumArtist, {\"count\": 1})",
"showDetails": "отримати інформацію"
}
"addToFavorites": "додати до $t(entity.favorite, {\"count\": 2})"
}
}
+2 -1
View File
@@ -229,6 +229,7 @@
"audioPlayer_description": "选择用于播放的音频播放器",
"globalMediaHotkeys": "全局媒体快捷键",
"gaplessAudio_description": "调整 mpv 无缝音频设置",
"disableAutomaticUpdates": "禁用自动更新",
"followLyric_description": "滚动歌词到当前播放位置",
"audioExclusiveMode": "音频独占模式",
"font": "字体",
@@ -463,7 +464,7 @@
"releaseChannel_optionLatest": "最新的",
"releaseChannel_optionBeta": "测试版",
"releaseChannel": "发布通道",
"releaseChannel_description": "选择稳定版测试版或 Alpha(夜间构建版)以启用自动更新",
"releaseChannel_description": "选择稳定版本或测试版以进行自动更新",
"mediaSession": "启用媒体会话",
"mediaSession_description": "启用媒体会话集成,在系统音量叠加层和锁屏界面显示媒体控件和元数据",
"exportImportSettings_control_description": "通过 JSON 导出和导入设置",
+19 -146
View File
@@ -296,8 +296,7 @@
"myLibrary": "我的媒體庫",
"shared": "已分享 $t(entity.playlist, {\"count\": 2})",
"favorites": "$t(entity.favorite, {\"count\": 2})",
"radio": "$t(entity.radioStation, {\"count\": 2})",
"collections": "收藏"
"radio": "$t(entity.radioStation, {\"count\": 2})"
},
"trackList": {
"title": "$t(entity.track, {\"count\": 2})",
@@ -315,11 +314,7 @@
"viewAll": "檢視所有",
"viewAllTracks": "檢視所有$t(entity.track, {\"count\": 2})",
"groupingTypeAll": "所有發佈類型",
"groupingTypePrimary": "主要發佈類型",
"favoriteSongs": "最愛歌曲",
"favoriteSongsFrom": "{{title}} 的最愛歌曲",
"topSongsCommunity": "社群",
"topSongsPersonal": "個人"
"groupingTypePrimary": "主要發佈類型"
},
"manageServers": {
"title": "管理伺服器",
@@ -347,17 +342,7 @@
"title": "電台"
},
"windowBar": {
"paused": "(暫停) ",
"privateMode": "(私人模式)"
},
"collections": {
"overrideExisting": "複寫現有的",
"saveAsCollection": "儲存為收藏"
},
"releasenotes": {
"commitsSinceStable": "提交自 {{stable}}",
"noNewCommits": "在此區間內沒有新的提交",
"noStableReleaseToCompare": "沒有穩定的發行可供比較"
"paused": "(暫停) "
}
},
"player": {
@@ -399,16 +384,7 @@
"restoreQueueFromServer": "從伺服器還原播放佇列",
"saveQueueToServer": "將播放佇列儲存至伺服器",
"artistRadio": "藝人電台",
"trackRadio": "曲目電台",
"sleepTimer": "睡眠定時器",
"sleepTimer_endOfSong": "歌曲播完時",
"sleepTimer_minutes": "{{count}} 分鐘",
"sleepTimer_hours": "{{count}} 小時",
"sleepTimer_custom": "自訂",
"sleepTimer_off": "關閉",
"sleepTimer_timeRemaining": "剩餘 {{time}}",
"sleepTimer_setCustom": "設定定時器",
"sleepTimer_cancel": "取消定時器"
"trackRadio": "曲目電台"
},
"setting": {
"audioPlayer_description": "選擇用於播放的音訊播放器",
@@ -422,9 +398,9 @@
"accentColor": "強調色",
"accentColor_description": "設定應用程式的強調色",
"applicationHotkeys": "應用程式快捷鍵",
"applicationHotkeys_description": "設定應用程式快捷鍵。切換勾選框來設為全快捷鍵(僅桌面端)",
"applicationHotkeys_description": "設定應用程式快捷鍵。切換勾選框來設為全快捷鍵(僅桌面端)",
"audioDevice": "音訊設備",
"audioDevice_description": "選擇用於播放的音訊設備",
"audioDevice_description": "選擇用於播放的音訊設備(僅 web 播放器)",
"audioExclusiveMode": "音訊獨占模式",
"audioExclusiveMode_description": "啟用獨占輸出模式。在此模式下,系統通常被鎖定,只有 mpv 能夠輸出音訊",
"audioPlayer": "音訊播放器",
@@ -433,6 +409,7 @@
"crossfadeStyle_description": "選擇用於音訊播放器的淡入淡出風格",
"customFontPath": "自定字體路徑",
"customFontPath_description": "設定應用程式使用的自定字體路徑",
"disableAutomaticUpdates": "禁用自動更新",
"disableLibraryUpdateOnStartup": "禁用啟動時檢查新版本",
"discordApplicationId": "{{discord}} 應用程式 id",
"discordApplicationId_description": "{{discord}} rich presence 應用程式 id(預設為 {{defaultId}}",
@@ -454,10 +431,10 @@
"gaplessAudio": "無間隔音訊",
"gaplessAudio_description": "調整 mpv 無間隔音訊設定",
"gaplessAudio_optionWeak": "弱(建議)",
"globalMediaHotkeys": "全媒體快捷鍵",
"globalMediaHotkeys": "全媒體快捷鍵",
"hotkey_browserForward": "瀏覽器往前",
"hotkey_favoritePreviousSong": "收藏 $t(common.previousSong)",
"hotkey_globalSearch": "全搜尋",
"hotkey_globalSearch": "全搜尋",
"hotkey_localSearch": "頁面內搜尋",
"hotkey_playbackNext": "下一首",
"hotkey_playbackPause": "暫停",
@@ -581,7 +558,7 @@
"contextMenu_description": "允許您隱藏在右鍵選單項目時顯示的項目。未選取的項目將被隱藏",
"customCssEnable": "啟用自訂CSS",
"customCssEnable_description": "允許撰寫自訂CSS",
"customCssNotice": "警告:即使已限制某些用法(不允許 url() 和 content:),但使用自訂 CSS 仍然會透過更改介面帶來風險",
"customCssNotice": "警告:雖然有一些清理措施(不允許 url() 和 content:),但使用自訂 CSS 仍然會透過更改介面帶來風險",
"customCss": "自訂CSS",
"customCss_description": "自訂 CSS 內容。注意:內容和遠端 URL 是不允許使用的屬性。您的內容預覽如下所示。由於需要進行清理,因此存在一些您未設定的其他欄位",
"discordPausedStatus": "暫停時顯示 rich presence",
@@ -641,7 +618,7 @@
"artistBackgroundBlur_description": "調整套用至藝人背景圖片的模糊程度",
"releaseChannel_optionLatest": "最新版本",
"releaseChannel_optionBeta": "測試版",
"releaseChannel_description": "選擇自動更新時要使用穩定、測試或是 alpha (每日建構版) 版本",
"releaseChannel_description": "選擇自動更新時要使用穩定版本或是測試版本",
"discordDisplayType": "{{discord}} presence 顯示類型",
"discordDisplayType_description": "變更您在狀態中正在聆聽的內容",
"discordDisplayType_songname": "歌曲名稱",
@@ -653,8 +630,8 @@
"hotkey_navigateHome": "導航至首頁",
"preventSleepOnPlayback": "防止播放時進入睡眠狀態",
"preventSleepOnPlayback_description": "在音樂播放時防止螢幕進入睡眠狀態",
"mediaSession": "啟用 Media Session",
"mediaSession_description": "啟用 Media Session 整合功能,於系統音量 Overlay 和鎖定畫面中顯示媒體資料與控制面板",
"mediaSession": "啟用Media Session",
"mediaSession_description": "啟用 Media Session 整合功能,於系統音量Overlay和鎖定畫面中顯示媒體資料與控制面板",
"releaseChannel": "發佈通道",
"analyticsDisable": "選擇退出使用情況分析",
"analyticsDisable_description": "經過匿名處理的使用情況資料將傳送給開發者,以協助改進應用程式",
@@ -738,30 +715,7 @@
"pathReplace_description": "替換您伺服器的預設檔案路徑",
"pathReplace_optionRemovePrefix": "移除前綴",
"pathReplace_optionAddPrefix": "增加前綴",
"sidebarPlaylistSorting": "側邊欄播放清單排序",
"homeFeatureStyle_description": "控制首頁輪播的樣式",
"homeFeatureStyle": "首頁特色輪播樣式",
"homeFeatureStyle_optionMultiple": "多重",
"homeFeatureStyle_optionSingle": "單一",
"hotkey_listPlayDefault": "清單播放",
"hotkey_listPlayLast": "清單尾端播放",
"hotkey_listPlayNext": "清單下一項播放",
"hotkey_listPlayNow": "清單立即播放",
"enableGridMultiSelect": "啟用網格多選",
"enableGridMultiSelect_description": "啟用時,允許在網格檢視中選擇多項。停用時,單擊網格項目圖片將導航到項目頁面",
"sidebarPlaylistSorting_description": "允許在側邊欄中使用拖放手動對播放清單進行排序,而不是預設的伺服器排序",
"sidebarPlaylistListFilterRegex_description": "在側邊欄中隱藏與此正規表達式匹配的播放清單",
"sidebarPlaylistListFilterRegex_placeholder": "範例: ^Daily Mix.*",
"sidebarPlaylistListFilterRegex": "播放清單過濾器正規表達式",
"blurExplicitImages": "模糊露骨圖片",
"blurExplicitImages_description": "標記為露骨的專輯和歌曲封面將被模糊",
"releaseChannel_optionAlpha": "alpha (每日建構版)",
"analyticsEnable": "傳送基於使用情況的分析報告",
"analyticsEnable_description": "匿名化的使用情況資料會傳送給開發者,以協助改進應用程式",
"automaticUpdates": "自動更新",
"automaticUpdates_description": "自動檢查並安裝更新",
"discordStateIcon": "顯示播放中圖示",
"discordStateIcon_description": "在 rich presence 狀態中顯示一個小的播放圖示。啟用「暫停時顯示 rich presence」時,會始終顯示暫停的圖示"
"sidebarPlaylistSorting": "側邊欄播放清單排序"
},
"table": {
"config": {
@@ -794,8 +748,7 @@
"alternateRowColors": "隔行上色",
"horizontalBorders": "行邊框線",
"rowHoverHighlight": "滑鼠懸停Highlight",
"verticalBorders": "列邊框線",
"showHeader": "顯示標題"
"verticalBorders": "列邊框線"
},
"label": {
"actions": "$t(common.action, {\"count\": 2})",
@@ -830,15 +783,12 @@
"genreBadge": "$t(entity.genre, {\"count\": 1}) (徽章)",
"image": "圖片",
"bitDepth": "$t(common.bitDepth)",
"sampleRate": "$t(common.sampleRate)",
"composer": "作曲者",
"titleArtist": "$t(common.title) (藝人)"
"sampleRate": "$t(common.sampleRate)"
},
"view": {
"table": "表格",
"grid": "網格",
"list": "列表",
"detail": "詳情"
"list": "列表"
}
},
"column": {
@@ -976,10 +926,7 @@
"title": "標題",
"toYear": "從年份",
"trackNumber": "曲目",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "排序名稱",
"matchAnd": "和",
"matchOr": "或"
"explicitStatus": "$t(common.explicitStatus)"
},
"form": {
"addServer": {
@@ -1221,80 +1168,6 @@
"gravity": "重力",
"peakFadeTime": "峰值淡出時間 (毫秒)",
"peakHoldTime": "峰值停留時間 (毫秒)",
"radialSpectrum": "圓形頻譜",
"level": "層級",
"pasteGradient": "貼上漸層",
"pasteGradientPlaceholder": "在這裡貼上漸層JSON...",
"radial": "放射",
"radialInvert": "反轉放射",
"spinSpeed": "旋轉速度",
"radius": "半徑",
"reflexMirror": "反射鏡像",
"reflexFit": "反射貼齊",
"reflexRatio": "反射比例",
"reflexAlpha": "反射 Alpha",
"reflexBrightness": "反射亮度",
"mirror": "鏡像",
"miscellaneousSettings": "雜項設定",
"alphaBars": "Alpha 條",
"ansiBands": "ASNI 波段",
"ledBars": "LED 條",
"trueLeds": "真實 LED",
"lumiBars": "輝光條",
"outlineBars": "外框條",
"roundBars": "圓角條",
"lowResolution": "低解析",
"splitGradient": "分割漸層",
"showFPS": "顯示 FPS",
"showScaleX": "顯示 X 軸比例",
"noteLabels": "音符標籤",
"showScaleY": "顯示Y軸比例",
"options": {
"mode": {
"0": "[0] 離散頻率",
"1": "[1] 1/24th 八度音 / 240 頻段",
"2": "[2] 1/12th 八度音 / 120 頻段",
"3": "[3] 1/8th 八度音 / 80 頻段",
"4": "[4] 1/6th 八度音 / 60 頻段",
"5": "[5] 1/4th 八度音 / 40 頻段",
"6": "[6] 1/3rd 八度音 / 30 頻段",
"7": "[7] 一半八度音 / 20 頻段",
"8": "[8] 完整八度音 / 10 頻段",
"10": "[10] 線 / 區域圖表"
},
"colorMode": {
"gradient": "梯度",
"barIndex": "條-指數",
"barLevel": "條-高度"
},
"gradient": {
"classic": "經典",
"prism": "菱鏡",
"rainbow": "彩虹",
"steelblue": "鋼藍",
"orangered": "橙紅色"
},
"channelLayout": {
"single": "單一",
"dualCombined": "雙重-合併",
"dualHorizontal": "雙重-水平",
"dualVertical": "雙重-垂直"
},
"frequencyScale": {
"none": "無",
"bark": "比例刻度",
"linear": "線性比例",
"log": "Log 比例",
"mel": "Mel 比例"
},
"weightingFilter": {
"none": "無",
"a": "A",
"b": "B",
"c": "C",
"d": "D",
"z": "Z"
}
}
"radialSpectrum": "圓形頻譜"
}
}
+19 -117
View File
@@ -1,5 +1,3 @@
import type { UpdateCheckResult } from 'electron-updater';
import { is } from '@electron-toolkit/utils';
import {
app,
@@ -23,7 +21,6 @@ import log from 'electron-log/main';
import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';
import { access, constants } from 'fs';
import path, { join } from 'path';
import semver from 'semver';
import packageJson from '../../package.json';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
@@ -55,25 +52,29 @@ const ALPHA_UPDATER_CONFIG: {
provider: 's3',
};
const GITHUB_UPDATER_CONFIG = {
owner: 'jeffvli',
provider: 'github' as const,
repo: 'feishin',
};
type UpdaterInstance = AppImageUpdater | MacUpdater | NsisUpdater | typeof autoUpdater;
class AlphaAppUpdater {
constructor() {
const updater = createAlphaUpdaterInstance();
log.transports.file.level = 'info';
updater.logger = autoUpdaterLogInterface;
updater.channel = ALPHA_UPDATER_CONFIG.channel;
updater.allowPrerelease = true;
updater.disableDifferentialDownload = true;
updater.allowDowngrade = true;
updater.autoInstallOnAppQuit = true;
updater.autoRunAppAfterInstall = true;
updater.checkForUpdatesAndNotify();
}
}
class AppUpdater {
constructor() {
const effectiveChannel = store.get('release_channel') as string;
console.log('Effective update channel:', effectiveChannel);
if (effectiveChannel === 'alpha') {
checkAllChannelsAndGetBest().then(({ updater: updaterInstance }) => {
updaterInstance.autoInstallOnAppQuit = true;
updaterInstance.autoRunAppAfterInstall = true;
updaterInstance.checkForUpdatesAndNotify();
});
return;
return new AlphaAppUpdater();
}
configureAndGetUpdater();
@@ -81,74 +82,6 @@ class AppUpdater {
}
}
// When release channel is alpha, check alpha and latest for updates and return
// the updater + result for the newest version found (so alpha users can receive
// latest updates when they are newer than the current alpha).
async function checkAllChannelsAndGetBest(): Promise<{
result: null | UpdateCheckResult;
updater: UpdaterInstance;
}> {
const currentVersion = packageJson.version;
const candidates: Array<{
channel: 'alpha' | 'beta' | 'latest';
result: UpdateCheckResult;
updater: UpdaterInstance;
}> = [];
const alphaUpdater = createAlphaUpdaterInstance();
alphaUpdater.logger = autoUpdaterLogInterface;
alphaUpdater.channel = ALPHA_UPDATER_CONFIG.channel;
alphaUpdater.allowPrerelease = true;
alphaUpdater.disableDifferentialDownload = true;
alphaUpdater.allowDowngrade = true;
try {
console.log('Checking for updates on alpha channel');
const alphaResult = await alphaUpdater.checkForUpdates();
if (
alphaResult?.updateInfo?.version &&
alphaResult.isUpdateAvailable &&
semver.valid(alphaResult.updateInfo.version) &&
semver.gt(alphaResult.updateInfo.version, currentVersion)
) {
candidates.push({ channel: 'alpha', result: alphaResult, updater: alphaUpdater });
}
} catch (e) {
log.warn('Alpha channel check failed', e);
}
try {
autoUpdater.setFeedURL(GITHUB_UPDATER_CONFIG);
configureAutoUpdaterForChannel('latest');
console.log('Checking for updates on latest channel (GitHub)');
const latestResult = await autoUpdater.checkForUpdates();
if (
latestResult?.updateInfo?.version &&
latestResult.isUpdateAvailable &&
semver.valid(latestResult.updateInfo.version) &&
semver.gt(latestResult.updateInfo.version, currentVersion)
) {
candidates.push({ channel: 'latest', result: latestResult, updater: autoUpdater });
}
} catch (e) {
log.warn('Latest channel check failed', e);
}
if (candidates.length === 0) {
return { result: null, updater: alphaUpdater };
}
const best = candidates.reduce((a, b) =>
semver.gt(a.result.updateInfo.version, b.result.updateInfo.version) ? a : b,
);
if (best.channel === 'latest') {
configureAutoUpdaterForChannel('latest');
}
return { result: best.result, updater: best.updater };
}
function configureAndGetUpdater(): UpdaterInstance {
const isBetaVersion = packageJson.version.includes('-beta');
const isAlphaVersion = packageJson.version.includes('-alpha');
@@ -189,37 +122,17 @@ function configureAndGetUpdater(): UpdaterInstance {
if (effectiveChannel === 'beta') {
autoUpdater.channel = 'beta';
autoUpdater.allowDowngrade = true;
autoUpdater.allowPrerelease = true;
autoUpdater.disableDifferentialDownload = true;
} else {
autoUpdater.channel = 'latest';
autoUpdater.allowDowngrade = true;
autoUpdater.allowPrerelease = false;
}
return autoUpdater;
}
/**
* Configures the global autoUpdater for a specific GitHub channel (beta or latest).
* Used when checking multiple channels or when the winning channel is beta/latest.
*/
function configureAutoUpdaterForChannel(channel: 'beta' | 'latest'): void {
log.transports.file.level = 'info';
autoUpdater.logger = autoUpdaterLogInterface;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.autoRunAppAfterInstall = true;
if (channel === 'beta') {
autoUpdater.channel = 'beta';
autoUpdater.allowDowngrade = true;
autoUpdater.allowPrerelease = true;
autoUpdater.disableDifferentialDownload = true;
} else {
autoUpdater.channel = 'latest';
autoUpdater.allowPrerelease = false;
}
}
function createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdater {
if (isMacOS()) {
return new MacUpdater(ALPHA_UPDATER_CONFIG);
@@ -527,19 +440,8 @@ async function createWindow(first = true): Promise<void> {
try {
console.log('Checking for updates');
const effectiveChannel = store.get('release_channel') as string;
let result: null | UpdateCheckResult;
let updater: UpdaterInstance;
if (effectiveChannel === 'alpha') {
const best = await checkAllChannelsAndGetBest();
result = best.result;
updater = best.updater;
} else {
updater = configureAndGetUpdater();
result = await updater.checkForUpdates();
}
const updater = configureAndGetUpdater();
const result = await updater.checkForUpdates();
const updateAvailable = result?.isUpdateAvailable ?? false;
console.log('Update available:', updateAvailable);
if (updateAvailable && store.get('disable_auto_updates') !== true) {
@@ -38,7 +38,6 @@ const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefin
[AlbumListSort.DURATION]: undefined,
[AlbumListSort.EXPLICIT_STATUS]: undefined,
[AlbumListSort.FAVORITED]: AlbumListSortType.STARRED,
[AlbumListSort.ID]: undefined,
[AlbumListSort.NAME]: AlbumListSortType.ALPHABETICAL_BY_NAME,
[AlbumListSort.PLAY_COUNT]: AlbumListSortType.FREQUENT,
[AlbumListSort.RANDOM]: AlbumListSortType.RANDOM,
@@ -763,7 +762,7 @@ export const SubsonicController: InternalControllerEndpoint = {
getFolder: async ({ apiClientProps, context, query }) => {
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
const isRootFolderId = query.id === '0';
const isRootFolderId = /^\d+$/.test(query.id);
if (isRootFolderId) {
const res = await ssApiClient(apiClientProps).getIndexes({
@@ -1,3 +1,3 @@
.list-expanded-container {
overflow: auto;
.container {
height: 500px;
}
@@ -1,23 +1,32 @@
import { motion, Variants } from 'motion/react';
import { ReactNode } from 'react';
import styles from './expanded-list-container.module.css';
const EXPANDED_HEIGHT = 300;
const expandedAnimationVariants: Variants = {
hidden: {
height: 0,
minHeight: 0,
},
show: {
minHeight: '300px',
transition: {
duration: 0.3,
ease: 'easeInOut',
},
},
};
export interface ExpandedListContainerProps {
children: ReactNode;
}
export const ExpandedListContainer = ({ children }: ExpandedListContainerProps) => {
export const ExpandedListContainer = ({ children }: { children: ReactNode }) => {
return (
<div
<motion.div
animate="show"
className={styles.listExpandedContainer}
style={{
height: EXPANDED_HEIGHT,
overflow: 'auto',
}}
exit="hidden"
initial="hidden"
variants={expandedAnimationVariants}
>
{children}
</div>
</motion.div>
);
};
@@ -2,18 +2,27 @@ import { Suspense } from 'react';
import styles from './expanded-list-item.module.css';
import { ItemListStateItem } from '/@/renderer/components/item-list/helpers/item-list-state';
import {
ItemListStateActions,
ItemListStateItem,
useItemListStateSubscription,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { ExpandedAlbumListItem } from '/@/renderer/features/albums/components/expanded-album-list-item';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { LibraryItem } from '/@/shared/types/domain-types';
interface ExpandedListItemProps {
item?: ItemListStateItem;
internalState: ItemListStateActions;
itemType: LibraryItem;
}
export const ExpandedListItem = ({ item, itemType }: ExpandedListItemProps) => {
if (!item) {
export const ExpandedListItem = ({ internalState, itemType }: ExpandedListItemProps) => {
const expandedItems = useItemListStateSubscription(internalState, () =>
internalState ? internalState.getExpandedItemsCached() : [],
);
const currentItem = expandedItems[0];
if (!currentItem) {
return null;
}
@@ -21,7 +30,11 @@ export const ExpandedListItem = ({ item, itemType }: ExpandedListItemProps) => {
<div className={styles.container}>
<div className={styles.inner}>
<Suspense fallback={<Spinner container />}>
<SelectedItem item={item} itemType={itemType} />
<SelectedItem
internalState={internalState}
item={currentItem as ItemListStateItem}
itemType={itemType}
/>
</Suspense>
</div>
</div>
@@ -29,14 +42,15 @@ export const ExpandedListItem = ({ item, itemType }: ExpandedListItemProps) => {
};
interface SelectedItemProps {
internalState: ItemListStateActions;
item: ItemListStateItem;
itemType: LibraryItem;
}
const SelectedItem = ({ item, itemType }: SelectedItemProps) => {
const SelectedItem = ({ internalState, item, itemType }: SelectedItemProps) => {
switch (itemType) {
case LibraryItem.ALBUM:
return <ExpandedAlbumListItem item={item} />;
return <ExpandedAlbumListItem internalState={internalState} item={item} />;
default:
return null;
}
@@ -8,7 +8,6 @@ import { ContextMenuController } from '/@/renderer/features/context-menu/context
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
import { useAppStore } from '/@/renderer/store';
import { LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
import { Play, TableColumn } from '/@/shared/types/types';
@@ -245,6 +244,8 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
const playType = (meta?.playType as Play) || Play.NOW;
const singleSongOnly = meta?.singleSongOnly === true;
// For single-song actions (e.g. image play button), or NEXT/LAST/..., only add the clicked song
// For row double-click with NOW/SHUFFLE, add a range of songs around the clicked song
let songsToAdd: Song[];
if (
singleSongOnly ||
@@ -278,27 +279,19 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
}
},
onExpand: ({ item, itemType }: DefaultItemControlProps) => {
if (!item) return;
const itemListItem = item as ItemListStateItemWithRequiredProperties;
const setGlobalExpanded = useAppStore.getState().actions.setGlobalExpanded;
const globalExpanded = useAppStore.getState().globalExpanded;
if (globalExpanded?.item?.id === item.id) {
setGlobalExpanded(null);
} else {
const itemForStore: ItemListStateItemWithRequiredProperties & {
imageId: null | string;
} = {
...itemListItem,
imageId: (itemListItem as { imageId?: null | string }).imageId ?? null,
};
setGlobalExpanded({
item: itemForStore,
itemType,
});
onExpand: ({ internalState, item }: DefaultItemControlProps) => {
if (!item || !internalState) {
return;
}
// Extract rowId from the item
const rowId = internalState.extractRowId(item);
if (!rowId) return;
// Use the item directly (rowId is separate, used only as key in state)
const itemListItem = item as ItemListStateItemWithRequiredProperties;
return internalState?.toggleExpanded(itemListItem);
},
onFavorite: ({
@@ -56,7 +56,6 @@
.tracks-table-header {
display: flex;
flex-shrink: 0;
flex-wrap: nowrap;
align-items: center;
width: 100%;
@@ -81,14 +80,12 @@
.track-header-cell {
position: relative;
display: flex;
flex-wrap: nowrap;
align-items: center;
min-width: 0;
min-height: 60%;
padding-right: var(--theme-spacing-sm);
padding-left: var(--theme-spacing-sm);
overflow: visible;
white-space: nowrap;
}
.track-header-cell-no-h-padding {
@@ -196,17 +193,6 @@
min-width: 0;
}
.image-wrapper-outer {
position: relative;
display: block;
width: 100%;
aspect-ratio: 1;
}
.image-wrapper-outer.image-wrapper-dragging {
opacity: 0.5;
}
.image-wrapper {
position: relative;
display: block;
@@ -32,17 +32,14 @@ import styles from './item-detail-list.module.css';
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import {
ItemListStateActions,
ItemListStateItemWithRequiredProperties,
useItemDraggingState,
useItemListState,
useItemSelectionState,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns';
import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys';
import { getDetailListCellComponent } from '/@/renderer/components/item-list/item-detail-list/columns';
import {
getTrackColumnFixed,
@@ -64,7 +61,6 @@ import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { AppRoute } from '/@/renderer/router/routes';
import { useSettingsStore, useShowRatings } from '/@/renderer/store';
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils';
@@ -72,8 +68,6 @@ import { SEPARATOR_STRING } from '/@/shared/api/utils';
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { useDoubleClick } from '/@/shared/hooks/use-double-click';
import { useFocusWithin } from '/@/shared/hooks/use-focus-within';
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
import { Album, LibraryItem, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
import { ItemListKey, Play, TableColumn } from '/@/shared/types/types';
@@ -90,7 +84,6 @@ interface ItemDetailListProps {
internalState?: ItemListStateActions;
itemCount?: number;
items?: unknown[];
listKey?: ItemListKey;
onColumnReordered?: (
columnIdFrom: TableColumn,
columnIdTo: TableColumn,
@@ -99,15 +92,8 @@ interface ItemDetailListProps {
onColumnResized?: (columnId: TableColumn, width: number) => void;
onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => Promise<void> | void;
onScrollEnd?: (rowIndex: number) => void;
onSongRowDoubleClick?: (params: {
index: number;
internalState: ItemListStateActions;
item: Song;
}) => void;
overrideControls?: Partial<ItemControls>;
rowHeight?: number;
scrollOffset?: number;
songsByAlbumId?: Record<string, Song[]>;
tableId?: string;
}
@@ -123,13 +109,7 @@ interface RowData {
getItem?: (index: number) => unknown;
internalState: ItemListStateActions;
isMutatingFavorite: boolean;
onSongRowDoubleClick?: (params: {
index: number;
internalState: ItemListStateActions;
item: Song;
}) => void;
registerSongs: (albumId: string, songs: Song[]) => void;
songsByAlbumId?: Record<string, Song[]>;
trackColumns: ItemTableListColumnConfig[];
trackTableSize: 'compact' | 'default' | 'large';
}
@@ -146,11 +126,6 @@ interface TrackRowProps {
internalState: ItemListStateActions;
isMutatingFavorite: boolean;
isSongsLoading?: boolean;
onSongRowDoubleClick?: (params: {
index: number;
internalState: ItemListStateActions;
item: Song;
}) => void;
rowIndex: number;
size: 'compact' | 'default' | 'large';
song: Song;
@@ -172,7 +147,6 @@ const TrackRow = memo(
internalState,
isMutatingFavorite,
isSongsLoading,
onSongRowDoubleClick,
rowIndex,
size,
song,
@@ -193,37 +167,11 @@ const TrackRow = memo(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (onSongRowDoubleClick) {
onSongRowDoubleClick({
index: internalState.findItemIndex(song.id),
internalState,
item: song,
});
return;
}
if (controls?.onDoubleClick) {
controls.onDoubleClick({
event: e,
index: internalState.findItemIndex(song.id),
internalState,
item: song,
itemType: LibraryItem.SONG,
});
return;
}
if (isSongsLoading || albumSongs.length === 0) return;
internalState.setSelected([song]);
playerContext.addToQueueByData(albumSongs, Play.NOW, song.id);
},
[
albumSongs,
controls,
internalState,
isSongsLoading,
onSongRowDoubleClick,
playerContext,
song,
],
[albumSongs, internalState, isSongsLoading, playerContext, song],
);
const handleRowClick = useCallback(
@@ -425,61 +373,6 @@ const MetadataSection = memo(
const [isImageHovered, setIsImageHovered] = useState(false);
const [isMetadataHovered, setIsMetadataHovered] = useState(false);
const getId = useCallback(() => {
const draggedItems = getDraggedItems(item, internalState, false);
return draggedItems.map((i) => i.id);
}, [item, internalState]);
const getItem = useCallback(() => {
return getDraggedItems(item, internalState, false);
}, [item, internalState]);
const onDragStart = useCallback(() => {
const draggedItems = getDraggedItems(item, internalState, false);
internalState?.setDragging(draggedItems);
}, [item, internalState]);
const onDrop = useCallback(() => {
internalState?.setDragging([]);
}, [internalState]);
const drag = useMemo(() => {
const playlistSongs = (item as { _playlistSongs?: Song[] })._playlistSongs;
if (playlistSongs && playlistSongs.length > 0) {
return {
getId,
getItem: () => playlistSongs,
itemType: LibraryItem.SONG,
onDragStart,
onDrop,
operation: [DragOperation.ADD],
target: DragTarget.SONG,
};
}
return {
getId,
getItem,
itemType: item._itemType,
onDragStart,
onDrop,
operation: [DragOperation.ADD],
target: DragTarget.ALBUM,
};
}, [getId, getItem, item, onDragStart, onDrop]);
const { isDragging: isDraggingLocal, ref: dragRef } = useDragDrop<HTMLDivElement>({
drag,
isEnabled: !!item,
});
const isDraggingState = useItemDraggingState(internalState, item.id);
const isDragging = isDraggingState || isDraggingLocal;
const handleLinkDragStart = useCallback((e: React.DragEvent<HTMLAnchorElement>) => {
e.preventDefault();
e.stopPropagation();
}, []);
const isFavorite = item.userFavorite ?? false;
const userRating = item.userRating ?? null;
const hasRating = showRatings && userRating !== null && userRating > 0;
@@ -541,48 +434,39 @@ const MetadataSection = memo(
onMouseEnter={() => setIsMetadataHovered(true)}
onMouseLeave={() => setIsMetadataHovered(false)}
>
<div
className={clsx(styles.imageWrapperOuter, {
[styles.imageWrapperDragging]: isDragging,
<Link
className={styles.imageWrapper}
onMouseEnter={() => setIsImageHovered(true)}
onMouseLeave={() => setIsImageHovered(false)}
state={{ item }}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: item.id,
})}
ref={dragRef ?? undefined}
>
<Link
className={styles.imageWrapper}
draggable={false}
onDragStart={handleLinkDragStart}
onMouseEnter={() => setIsImageHovered(true)}
onMouseLeave={() => setIsImageHovered(false)}
state={{ item }}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: item.id,
})}
>
<ItemImage
className={styles.image}
explicitStatus={item.explicitStatus}
id={item.imageId}
itemType={item._itemType}
serverId={item._serverId}
type="itemCard"
/>
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{controls && isImageHovered && (
<ItemCardControls
controls={controls}
enableExpansion={false}
internalState={internalState}
item={item}
itemType={item._itemType}
showRating={true}
type="compact"
/>
)}
</AnimatePresence>
</Link>
</div>
<ItemImage
className={styles.image}
explicitStatus={item.explicitStatus}
id={item.imageId}
itemType={item._itemType}
serverId={item._serverId}
type="itemCard"
/>
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{controls && isImageHovered && (
<ItemCardControls
controls={controls}
enableExpansion={false}
internalState={internalState}
item={item}
itemType={item._itemType}
showRating={true}
type="compact"
/>
)}
</AnimatePresence>
</Link>
<Link
className={styles.title}
state={{ item }}
@@ -726,9 +610,7 @@ const RowContent = memo(
index,
internalState,
isMutatingFavorite,
onSongRowDoubleClick,
registerSongs,
songsByAlbumId,
trackColumns,
trackTableSize,
}: RowContentProps) => {
@@ -740,10 +622,8 @@ const RowContent = memo(
return (data?.[index] as Album | undefined) || undefined;
}, [data, getItem, index]);
const useClientSideSongs = Boolean(songsByAlbumId);
const songListQuery = useMemo(() => {
if (useClientSideSongs || !item?.id || !item?._serverId) return null;
if (!item?.id || !item?._serverId) return null;
return {
query: {
albumIds: [item.id],
@@ -754,7 +634,7 @@ const RowContent = memo(
},
serverId: item?._serverId || '',
};
}, [item, useClientSideSongs]);
}, [item]);
const { data: songListData, isLoading: isSongsQueryLoading } = useQuery({
enabled: !!songListQuery,
@@ -766,17 +646,8 @@ const RowContent = memo(
}),
});
const songItemsFromQuery = songListData?.items;
const songItemsFromClient = useMemo(() => {
const rowSongs = (item as { _playlistSongs?: Song[] })?._playlistSongs;
if (rowSongs?.length) return rowSongs;
if (!songsByAlbumId || !item?.id) return undefined;
return songsByAlbumId[item.id];
}, [item, songsByAlbumId]);
const songItems = useClientSideSongs ? songItemsFromClient : songItemsFromQuery;
const isSongsLoading =
!useClientSideSongs && !!item && isSongsQueryLoading && !songItemsFromQuery?.length;
const songItems = songListData?.items;
const isSongsLoading = !!item && isSongsQueryLoading && !songItems?.length;
const songs = useMemo(() => {
return (
@@ -834,7 +705,6 @@ const RowContent = memo(
isMutatingFavorite={isMutatingFavorite}
isSongsLoading={isSongsLoading}
key={song.id}
onSongRowDoubleClick={onSongRowDoubleClick}
rowIndex={rowIndex}
size={trackTableSize}
song={song as Song}
@@ -859,7 +729,6 @@ const RowContent = memo(
prev.isMutatingFavorite === next.isMutatingFavorite &&
prev.controls === next.controls &&
prev.registerSongs === next.registerSongs &&
prev.songsByAlbumId === next.songsByAlbumId &&
prev.trackColumns === next.trackColumns &&
prev.trackTableSize === next.trackTableSize,
);
@@ -1244,27 +1113,20 @@ export const ItemDetailList = ({
getItem,
itemCount: externalItemCount,
items,
listKey = ItemListKey.ALBUM,
onColumnReordered,
onColumnResized,
onRangeChanged,
onScrollEnd,
onSongRowDoubleClick,
overrideControls,
songsByAlbumId,
tableId = DEFAULT_DETAIL_TABLE_ID,
}: ItemDetailListProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useListRef(null);
const { focused, ref: focusRef } = useFocusWithin();
const mergedContainerRef = useMergedRef(containerRef, focusRef);
const lastVisibleStartIndexRef = useRef(0);
const queryClient = useQueryClient();
const controls = useDefaultItemListControls({
onColumnReordered,
onColumnResized,
overrides: overrideControls,
});
const isMutatingCreateFavorite = useIsMutatingCreateFavorite();
const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();
@@ -1310,7 +1172,7 @@ export const ItemDetailList = ({
const internalState = useItemListState(getDataFn, extractRowIdSong);
const tableConfig = useSettingsStore((state) => state.lists[listKey]?.detail);
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.ALBUM]?.detail);
const trackColumns = useMemo((): ItemTableListColumnConfig[] => {
const raw = tableConfig?.columns;
if (raw && raw.length > 0) {
@@ -1401,10 +1263,8 @@ export const ItemDetailList = ({
getItem,
internalState,
isMutatingFavorite,
onSongRowDoubleClick,
queryClient,
registerSongs,
songsByAlbumId,
trackColumns,
trackTableSize,
}),
@@ -1419,10 +1279,8 @@ export const ItemDetailList = ({
getItem,
internalState,
isMutatingFavorite,
onSongRowDoubleClick,
queryClient,
registerSongs,
songsByAlbumId,
trackColumns,
trackTableSize,
],
@@ -1449,13 +1307,6 @@ export const ItemDetailList = ({
},
});
useListHotkeys({
controls,
focused,
internalState,
itemType: LibraryItem.SONG,
});
useEffect(() => {
const { current: container } = containerRef;
@@ -1512,7 +1363,7 @@ export const ItemDetailList = ({
trackTableSize={trackTableSize}
/>
)}
<div className={styles.container} ref={mergedContainerRef}>
<div className={styles.container} ref={containerRef}>
<List
listRef={listRef}
onRowsRendered={throttledHandleRowsRendered}
@@ -2,8 +2,8 @@ import { TableColumn } from '/@/shared/types/types';
const FIXED_TRACK_COLUMN_WIDTHS: Partial<Record<TableColumn, number>> = {
[TableColumn.ACTIONS]: 32,
[TableColumn.BIT_DEPTH]: 88,
[TableColumn.BIT_RATE]: 88,
[TableColumn.BIT_DEPTH]: 80,
[TableColumn.BIT_RATE]: 80,
[TableColumn.BPM]: 56,
[TableColumn.CHANNELS]: 80,
[TableColumn.CODEC]: 80,
@@ -11,8 +11,8 @@ const FIXED_TRACK_COLUMN_WIDTHS: Partial<Record<TableColumn, number>> = {
[TableColumn.DISC_NUMBER]: 36,
[TableColumn.DURATION]: 72,
[TableColumn.RELEASE_DATE]: 128,
[TableColumn.SAMPLE_RATE]: 112,
[TableColumn.TRACK_NUMBER]: 64,
[TableColumn.SAMPLE_RATE]: 90,
[TableColumn.TRACK_NUMBER]: 56,
[TableColumn.USER_FAVORITE]: 32,
[TableColumn.USER_RATING]: 64,
[TableColumn.YEAR]: 56,
@@ -60,6 +60,6 @@ export function shouldShowHoverOnlyColumnContent(
return (
isRowHovered ||
(columnId === TableColumn.USER_FAVORITE && song.userFavorite !== false) ||
(columnId === TableColumn.USER_RATING && song.userRating !== null && song.userRating !== 0)
(columnId === TableColumn.USER_RATING && song.userRating != null)
);
}
@@ -1,6 +1,6 @@
import clsx from 'clsx';
import throttle from 'lodash/throttle';
import { motion } from 'motion/react';
import { AnimatePresence, motion } from 'motion/react';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import React, {
CSSProperties,
@@ -31,12 +31,15 @@ import {
ItemCard,
ItemCardProps,
} from '/@/renderer/components/item-card/item-card';
import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container';
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import {
ItemListStateActions,
ItemListStateItemWithRequiredProperties,
useItemListState,
useItemListStateSubscription,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys';
import { ItemControls, ItemListHandle } from '/@/renderer/components/item-list/types';
@@ -826,6 +829,10 @@ const BaseItemGridList = ({
/>
)}
</AutoSizer>
<AnimatePresence presenceAffectsLayout>
<ExpandedContainer internalState={internalState} itemType={itemType} />
{/* {enableSelectionDialog && <SelectionDialog internalState={internalState} />} */}
</AnimatePresence>
</motion.div>
);
};
@@ -896,3 +903,25 @@ const ListComponent = memo((props: ListChildComponentProps<GridItemProps>) => {
export const ItemGridList = memo(BaseItemGridList);
ItemGridList.displayName = 'ItemGridList';
const ExpandedContainer = ({
internalState,
itemType,
}: {
internalState: ItemListStateActions;
itemType: LibraryItem;
}) => {
const hasExpanded = useItemListStateSubscription(internalState, (state) =>
state ? state.expanded.size > 0 : false,
);
return (
<AnimatePresence initial={false}>
{hasExpanded && (
<ExpandedListContainer>
<ExpandedListItem internalState={internalState} itemType={itemType} />
</ExpandedListContainer>
)}
</AnimatePresence>
);
};
@@ -62,7 +62,6 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
icon="arrowDownS"
iconProps={{ color: 'muted', size: 'md' }}
onClick={(e) => {
e.stopPropagation();
const item = (props.getRowItem?.(rowIndex) ??
data[rowIndex]) as ItemListItem;
const rowId = internalState.extractRowId(item);
@@ -1,7 +1,7 @@
// Component adapted from https://github.com/bvaughn/react-window/issues/826
import clsx from 'clsx';
import { motion } from 'motion/react';
import { AnimatePresence, motion } from 'motion/react';
import React, {
type JSXElementConstructor,
memo,
@@ -18,12 +18,15 @@ import { type CellComponentProps, Grid } from 'react-window-v2';
import styles from './item-table-list.module.css';
import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container';
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import {
ItemListStateActions,
ItemListStateItemWithRequiredProperties,
useItemListState,
useItemListStateSubscription,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns';
import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys';
@@ -1648,6 +1651,8 @@ const BaseItemTableList = ({
totalColumnCount={totalColumnCount}
totalRowCount={totalRowCount}
/>
<ExpandedContainer internalState={internalState} itemType={itemType} />
{/* {enableSelectionDialog && <SelectionDialog internalState={internalState} />} */}
</motion.div>
</ItemTableListConfigProvider>
</ItemTableListStoreProvider>
@@ -1656,4 +1661,26 @@ const BaseItemTableList = ({
export const ItemTableList = memo(BaseItemTableList);
const ExpandedContainer = ({
internalState,
itemType,
}: {
internalState: ItemListStateActions;
itemType: LibraryItem;
}) => {
const hasExpanded = useItemListStateSubscription(internalState, (state) =>
state ? state.expanded.size > 0 : false,
);
return (
<AnimatePresence initial={false}>
{hasExpanded && (
<ExpandedListContainer>
<ExpandedListItem internalState={internalState} itemType={itemType} />
</ExpandedListContainer>
)}
</AnimatePresence>
);
};
ItemTableList.displayName = 'ItemTableList';
@@ -196,7 +196,7 @@ export const QueryBuilder = ({
filters={filters}
groupIndex={groupIndex || []}
level={level}
noRemove={false}
noRemove={data?.rules?.length === 1}
onChangeField={onChangeField}
onChangeOperator={onChangeOperator}
onChangeValue={onChangeValue}
-6
View File
@@ -1,22 +1,16 @@
import { createContext, useContext } from 'react';
import { LibraryItem } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export type ListDisplayMode = LibraryItem.ALBUM | LibraryItem.SONG;
interface ListContextProps {
customFilters?: Record<string, unknown>;
displayMode?: ListDisplayMode;
id?: string;
isSidebarOpen?: boolean;
isSmartPlaylist?: boolean;
itemCount?: number;
listData?: unknown[];
listKey?: ItemListKey;
mode?: 'edit' | 'view';
pageKey: ItemListKey | string;
setDisplayMode?: (displayMode: ListDisplayMode) => void;
setIsSidebarOpen?: (isSidebarOpen: boolean) => void;
setItemCount?: (itemCount: number) => void;
setListData?: (items: unknown[]) => void;
@@ -21,10 +21,7 @@ import { ItemControls } from '/@/renderer/components/item-list/types';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import {
CLIENT_SIDE_SONG_FILTERS,
ListSortByDropdownControlled,
@@ -758,10 +755,7 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
sortOrder={sortOrder}
/>
<ListConfigMenu
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
listKey={ItemListKey.ALBUM_DETAIL}
optionsConfig={{
table: {
@@ -20,6 +20,7 @@ export function AlbumGridCarousel(props: AlbumGridCarouselProps) {
const controls = useDefaultItemListControls();
const cards = useMemo(() => {
// Filter out excluded IDs if provided
const filteredItems = excludeIds
? data.filter((album) => !excludeIds.includes(album.id))
: data;
@@ -30,7 +31,6 @@ export function AlbumGridCarousel(props: AlbumGridCarouselProps) {
controls={controls}
data={album}
enableDrag
enableExpansion
itemType={LibraryItem.ALBUM}
rows={rows}
type="poster"
@@ -58,6 +58,7 @@ const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps & { rows: DataRow[]
const controls = useDefaultItemListControls();
const cards = useMemo(() => {
// Flatten all pages and filter excluded IDs
const allItems = albums?.pages.flatMap((page: AlbumListResponse) => page.items) || [];
const filteredItems = excludeIds
? allItems.filter((album) => !excludeIds.includes(album.id))
@@ -69,7 +70,6 @@ const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps & { rows: DataRow[]
controls={controls}
data={album}
enableDrag
enableExpansion
itemType={LibraryItem.ALBUM}
rows={rows}
type="poster"
@@ -6,7 +6,6 @@ import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpe
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list';
import { ItemListComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import {
AlbumListQuery,
@@ -35,7 +34,6 @@ export const AlbumListInfiniteDetail = ({
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
const listQueryFn = api.controller.getAlbumList;
const { pageKey } = useListContext();
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.ALBUM,
@@ -48,7 +46,7 @@ export const AlbumListInfiniteDetail = ({
});
const { getItem, itemCount, loadedItems, onRangeChanged } = useItemListInfiniteLoader({
eventKey: pageKey || ItemListKey.ALBUM,
eventKey: ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -6,7 +6,6 @@ import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-r
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';
import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { useGeneralSettings } from '/@/renderer/store';
import {
@@ -38,11 +37,9 @@ export const AlbumListInfiniteGrid = ({
const listQueryFn = api.controller.getAlbumList;
const { pageKey } = useListContext();
const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =
useItemListInfiniteLoader({
eventKey: pageKey || ItemListKey.ALBUM,
eventKey: ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -8,7 +8,6 @@ import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpe
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import {
AlbumListQuery,
@@ -44,11 +43,10 @@ export const AlbumListInfiniteTable = ({
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
const listQueryFn = api.controller.getAlbumList;
const { pageKey } = useListContext();
const { getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =
useItemListInfiniteLoader({
eventKey: pageKey || ItemListKey.ALBUM,
eventKey: ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -8,7 +8,6 @@ import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-lis
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
import { ItemListComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import {
AlbumListQuery,
@@ -37,7 +36,6 @@ export const AlbumListPaginatedDetail = ({
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
const listQueryFn = api.controller.getAlbumList;
const { pageKey } = useListContext();
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.ALBUM,
@@ -53,7 +51,7 @@ export const AlbumListPaginatedDetail = ({
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
currentPage,
eventKey: pageKey || ItemListKey.ALBUM,
eventKey: ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -8,7 +8,6 @@ import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/it
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { useGeneralSettings } from '/@/renderer/store';
import {
@@ -33,7 +32,6 @@ export const AlbumListPaginatedGrid = ({
serverId,
size,
}: AlbumListPaginatedGridProps) => {
const { pageKey } = useListContext();
const { currentPage, onChange } = useItemListPagination();
const listCountQuery = albumQueries.listCount({
@@ -45,7 +43,7 @@ export const AlbumListPaginatedGrid = ({
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
currentPage,
eventKey: pageKey || ItemListKey.ALBUM,
eventKey: ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -10,7 +10,6 @@ import { useItemListPagination } from '/@/renderer/components/item-list/item-lis
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import {
AlbumListQuery,
@@ -40,7 +39,6 @@ export const AlbumListPaginatedTable = ({
serverId,
size = 'default',
}: AlbumListPaginatedTableProps) => {
const { pageKey } = useListContext();
const { currentPage, onChange } = useItemListPagination();
const listCountQuery = albumQueries.listCount({
@@ -52,7 +50,7 @@ export const AlbumListPaginatedTable = ({
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
currentPage,
eventKey: pageKey || ItemListKey.ALBUM,
eventKey: ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -22,7 +22,6 @@ import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';
import { useFastAverageColor } from '/@/renderer/hooks';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { useSetGlobalExpanded } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
@@ -31,24 +30,10 @@ import { Spinner } from '/@/shared/components/spinner/spinner';
import { TextTitle } from '/@/shared/components/text-title/text-title';
import { Text } from '/@/shared/components/text/text';
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
import { LibraryItem, RelatedArtist, Song } from '/@/shared/types/domain-types';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop';
import { Play } from '/@/shared/types/types';
export interface ExpandedAlbumData {
_serverId: string;
albumArtists: RelatedArtist[];
id: string;
imageId: null | string;
name: string;
songs?: null | Song[];
}
export interface ExpandedAlbumListItemProps {
album?: ExpandedAlbumData;
item?: ItemListStateItem;
}
interface AlbumTracksTableProps {
isDark?: boolean;
serverId: string;
@@ -61,6 +46,11 @@ interface AlbumTracksTableProps {
}>;
}
interface ExpandedAlbumListItemProps {
internalState?: ItemListStateActions;
item: ItemListStateItem;
}
interface TrackRowProps {
controls: ReturnType<typeof useDefaultItemListControls>;
internalState: ItemListStateActions;
@@ -70,23 +60,6 @@ interface TrackRowProps {
songs: Song[];
}
const CloseExpandedButton = () => {
const setGlobalExpanded = useSetGlobalExpanded();
return (
<ActionIcon
className={clsx(styles.closeButton)}
icon="x"
iconProps={{
size: 'xl',
}}
onClick={() => setGlobalExpanded(null)}
radius="50%"
size="sm"
variant="default"
/>
);
};
const TrackRow = ({ controls, internalState, player, serverId, song, songs }: TrackRowProps) => {
const rowId = internalState.extractRowId(song);
const isSelected = useItemSelectionState(internalState, rowId);
@@ -215,165 +188,136 @@ const AlbumTracksTable = ({ isDark, serverId, songs }: AlbumTracksTableProps) =>
);
};
interface ExpandedAlbumListItemContentProps {
albumData: ExpandedAlbumData;
}
const ExpandedAlbumListItemContent = ({ albumData }: ExpandedAlbumListItemContentProps) => {
const player = usePlayer();
const imageUrl = useItemImageUrl({
id: albumData.imageId || undefined,
itemType: LibraryItem.ALBUM,
type: 'itemCard',
});
const color = useFastAverageColor({
algorithm: 'sqrt',
id: albumData.id,
src: imageUrl,
srcLoaded: true,
});
const handlePlay = useCallback(
(playType: Play) => {
if (albumData.songs?.length) {
player.addToQueueByData(albumData.songs, playType);
}
},
[albumData.songs, player],
);
if (color.isLoading) {
return <Spinner container />;
}
const songs = albumData.songs ?? null;
return (
<motion.div
animate={{ opacity: 1 }}
className={styles.container}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
style={{ backgroundColor: color.background }}
>
<div className={styles.expanded}>
<div className={styles.content}>
<div className={styles.header}>
<div className={styles.headerTitle}>
<TextTitle
className={clsx(styles.itemTitle, { [styles.dark]: color.isDark })}
fw={700}
order={4}
>
{albumData.name}
</TextTitle>
<CloseExpandedButton />
</div>
<Group
className={clsx(styles.itemSubtitle, { [styles.dark]: color.isDark })}
gap="xs"
>
{albumData.albumArtists?.map((artist, index) => (
<Fragment key={artist.id}>
<Text
className={clsx(styles.itemSubtitle, {
[styles.dark]: color.isDark,
})}
>
{artist.name}
</Text>
{index < (albumData.albumArtists?.length ?? 0) - 1 && (
<Separator />
)}
</Fragment>
))}
</Group>
</div>
<AlbumTracksTable
isDark={color.isDark}
serverId={albumData._serverId}
songs={songs ?? undefined}
/>
</div>
<div className={styles.imageContainer}>
<div
className={styles.backgroundImage}
style={{
['--bg-color' as string]: color?.background,
backgroundImage: `url(${imageUrl})`,
}}
/>
{songs && songs.length > 0 && (
<div className={styles.playButtonGroup}>
<PlayButtonGroup onPlay={handlePlay} />
</div>
)}
</div>
</div>
</motion.div>
);
};
const ExpandedAlbumListItemWithFetch = ({ item }: { item: ItemListStateItem }) => {
const { data } = useSuspenseQuery(
export const ExpandedAlbumListItem = ({ internalState, item }: ExpandedAlbumListItemProps) => {
const { data, isLoading } = useSuspenseQuery(
albumQueries.detail({
query: { id: item.id },
serverId: item._serverId,
}),
);
const albumData: ExpandedAlbumData = {
_serverId: item._serverId,
albumArtists: data?.albumArtists ?? [],
const player = usePlayer();
const imageUrl = useItemImageUrl({
id: item.imageId || undefined,
itemType: LibraryItem.ALBUM,
type: 'itemCard',
});
const color = useFastAverageColor({
algorithm: 'sqrt',
id: item.id,
imageId: item.imageId ?? data?.imageId ?? null,
name: data?.name ?? '',
songs: data?.songs ?? null,
};
src: imageUrl,
srcLoaded: true,
});
return <ExpandedAlbumListItemContent albumData={albumData} />;
};
const handlePlay = useCallback(
(playType: Play) => {
if (!data) {
return;
}
function itemToExpandedAlbumData(
item: ItemListStateItem & {
_playlistSongs?: Song[];
albumArtists?: RelatedArtist[];
name?: string;
},
): ExpandedAlbumData | null {
const songs =
(item as { songs?: Song[] }).songs ?? (item as { _playlistSongs?: Song[] })._playlistSongs;
if (songs == null) return null;
return {
_serverId: item._serverId,
albumArtists: item.albumArtists ?? [],
id: item.id,
imageId: (item as { imageId?: null | string }).imageId ?? null,
name: (item as { name?: string }).name ?? '',
songs,
};
}
if (data.songs) {
player.addToQueueByData(data.songs, playType);
}
},
[data, player],
);
export const ExpandedAlbumListItem = (props: ExpandedAlbumListItemProps) => {
if (props.album != null) {
return <ExpandedAlbumListItemContent albumData={props.album} />;
if (color.isLoading) {
return null;
}
if (props.item != null) {
const albumData = itemToExpandedAlbumData(props.item);
if (albumData != null) {
return <ExpandedAlbumListItemContent albumData={albumData} />;
}
return (
<Suspense fallback={<Spinner container />}>
<ExpandedAlbumListItemWithFetch item={props.item} />
return (
<motion.div
animate={{
opacity: 1,
}}
className={styles.container}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
style={{ backgroundColor: color.background }}
>
{isLoading && (
<div className={styles.loading}>
<Spinner />
</div>
)}
<Suspense>
<div className={styles.expanded}>
<div className={styles.content}>
<div className={styles.header}>
<div className={styles.headerTitle}>
<TextTitle
className={clsx(styles.itemTitle, {
[styles.dark]: color.isDark,
})}
fw={700}
order={4}
>
{data?.name}
</TextTitle>
{internalState && (
<ActionIcon
className={clsx(styles.closeButton)}
icon="x"
iconProps={{
size: 'xl',
}}
onClick={() => {
const rowId = internalState.extractRowId(item);
if (rowId) {
internalState.clearExpanded();
}
}}
radius="50%"
size="sm"
variant="default"
/>
)}
</div>
<Group
className={clsx(styles.itemSubtitle, {
[styles.dark]: color.isDark,
})}
gap="xs"
>
{data?.albumArtists.map((artist, index) => (
<Fragment key={artist.id}>
<Text
className={clsx(styles.itemSubtitle, {
[styles.dark]: color.isDark,
})}
>
{artist.name}
</Text>
{index < data?.albumArtists.length - 1 && <Separator />}
</Fragment>
))}
</Group>
</div>
<AlbumTracksTable
isDark={color.isDark}
serverId={item._serverId}
songs={data?.songs}
/>
</div>
<div className={styles.imageContainer}>
<div
className={styles.backgroundImage}
style={{
['--bg-color' as string]: color?.background,
backgroundImage: `url(${imageUrl})`,
}}
/>
{data?.songs && data.songs.length > 0 && (
<div className={styles.playButtonGroup}>
<PlayButtonGroup onPlay={handlePlay} />
</div>
)}
</div>
</div>
</Suspense>
);
}
return null;
</motion.div>
);
};
@@ -25,10 +25,7 @@ import { ItemControls } from '/@/renderer/components/item-list/types';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { AlbumArtistGridCarousel } from '/@/renderer/features/artists/components/album-artist-grid-carousel';
import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import {
CLIENT_SIDE_ALBUM_FILTERS,
ListSortByDropdownControlled,
@@ -228,39 +225,6 @@ const AlbumArtistMetadataBiography = ({
);
};
const TABLE_ROW_HEIGHT = {
compact: 40,
default: 64,
large: 88,
} as const;
const TABLE_HEADER_HEIGHT = 40;
interface SongTableListContainerProps {
children: React.ReactNode;
enableHeader?: boolean;
itemCount: number;
maxRows?: number;
tableSize?: 'compact' | 'default' | 'large';
}
function getTableRowHeight(size: 'compact' | 'default' | 'large' | undefined): number {
return size ? TABLE_ROW_HEIGHT[size] : TABLE_ROW_HEIGHT.default;
}
const SongTableListContainer = ({
children,
enableHeader = true,
itemCount,
maxRows = 5,
tableSize = 'default',
}: SongTableListContainerProps) => {
const rowHeight = getTableRowHeight(tableSize);
const headerOffset = enableHeader ? TABLE_HEADER_HEIGHT : 0;
const height = headerOffset + rowHeight * Math.min(itemCount, maxRows);
return <div style={{ height }}>{children}</div>;
};
interface AlbumArtistMetadataTopSongsProps {
detailQuery: ReturnType<typeof useSuspenseQuery<AlbumArtistDetailResponse>>;
routeId: string;
@@ -273,6 +237,7 @@ const AlbumArtistMetadataTopSongsContent = ({
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
const [showAll, setShowAll] = useState(false);
const [topSongsQueryType, setTopSongsQueryType] = useLocalStorage<'community' | 'personal'>({
defaultValue: 'community',
key: 'album-artist-top-songs-query-type',
@@ -304,8 +269,13 @@ const AlbumArtistMetadataTopSongsContent = ({
}, [tableConfig?.columns]);
const filteredSongs = useMemo(() => {
return searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG);
}, [songs, debouncedSearchTerm]);
const filtered = searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG);
// When searching, show all results. Otherwise, limit to 5 if not showing all
if (debouncedSearchTerm?.trim() || showAll) {
return filtered;
}
return filtered.slice(0, 5);
}, [songs, debouncedSearchTerm, showAll]);
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.SONG,
@@ -478,10 +448,7 @@ const AlbumArtistMetadataTopSongsContent = ({
value={topSongsQueryType}
/>
<ListConfigMenu
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
listKey={ItemListKey.SONG}
optionsConfig={{
table: {
@@ -492,35 +459,35 @@ const AlbumArtistMetadataTopSongsContent = ({
tableColumnsData={SONG_TABLE_COLUMNS}
/>
</Group>
<SongTableListContainer
<ItemTableList
activeRowId={currentSongId}
autoFitColumns={tableConfig.autoFitColumns}
CellComponent={ItemTableListColumn}
columns={columns}
data={filteredSongs}
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
enableDrag
enableDragScroll={false}
enableExpansion={false}
enableHeader={tableConfig.enableHeader}
itemCount={filteredSongs.length}
maxRows={5}
tableSize={tableConfig.size}
>
<ItemTableList
activeRowId={currentSongId}
autoFitColumns={tableConfig.autoFitColumns}
CellComponent={ItemTableListColumn}
columns={columns}
data={filteredSongs}
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
enableDrag
enableDragScroll={false}
enableExpansion={false}
enableHeader={tableConfig.enableHeader}
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
enableSelection
enableSelectionDialog={false}
enableVerticalBorders={tableConfig.enableVerticalBorders}
itemType={LibraryItem.SONG}
onColumnReordered={handleColumnReordered}
onColumnResized={handleColumnResized}
overrideControls={overrideControls}
size={tableConfig.size}
/>
</SongTableListContainer>
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
enableSelection
enableSelectionDialog={false}
enableVerticalBorders={tableConfig.enableVerticalBorders}
itemType={LibraryItem.SONG}
onColumnReordered={handleColumnReordered}
onColumnResized={handleColumnResized}
overrideControls={overrideControls}
size={tableConfig.size}
/>
{!searchTerm.trim() && songs.length > 5 && !showAll && (
<Group justify="center" w="100%">
<Button onClick={() => setShowAll(true)} variant="subtle">
{t('action.viewMore', { postProcess: 'sentenceCase' })}
</Button>
</Group>
)}
</>
) : null}
</Stack>
@@ -556,6 +523,7 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
const [showAll, setShowAll] = useState(false);
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table);
const currentSong = usePlayerSong();
const player = usePlayer();
@@ -580,8 +548,13 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori
}, [tableConfig?.columns]);
const filteredSongs = useMemo(() => {
return searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG);
}, [songs, debouncedSearchTerm]);
const filtered = searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG);
// When searching, show all results. Otherwise, limit to 5 if not showing all
if (debouncedSearchTerm?.trim() || showAll) {
return filtered;
}
return filtered.slice(0, 5);
}, [songs, debouncedSearchTerm, showAll]);
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.SONG,
@@ -733,10 +706,7 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori
value={searchTerm}
/>
<ListConfigMenu
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
listKey={ItemListKey.SONG}
optionsConfig={{
table: {
@@ -747,35 +717,35 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori
tableColumnsData={SONG_TABLE_COLUMNS}
/>
</Group>
<SongTableListContainer
<ItemTableList
activeRowId={currentSongId}
autoFitColumns={tableConfig.autoFitColumns}
CellComponent={ItemTableListColumn}
columns={columns}
data={filteredSongs}
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
enableDrag
enableDragScroll={false}
enableExpansion={false}
enableHeader={tableConfig.enableHeader}
itemCount={filteredSongs.length}
maxRows={5}
tableSize={tableConfig.size}
>
<ItemTableList
activeRowId={currentSongId}
autoFitColumns={tableConfig.autoFitColumns}
CellComponent={ItemTableListColumn}
columns={columns}
data={filteredSongs}
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
enableDrag
enableDragScroll={false}
enableExpansion={false}
enableHeader={tableConfig.enableHeader}
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
enableSelection
enableSelectionDialog={false}
enableVerticalBorders={tableConfig.enableVerticalBorders}
itemType={LibraryItem.SONG}
onColumnReordered={handleColumnReordered}
onColumnResized={handleColumnResized}
overrideControls={overrideControls}
size={tableConfig.size}
/>
</SongTableListContainer>
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
enableSelection
enableSelectionDialog={false}
enableVerticalBorders={tableConfig.enableVerticalBorders}
itemType={LibraryItem.SONG}
onColumnReordered={handleColumnReordered}
onColumnResized={handleColumnResized}
overrideControls={overrideControls}
size={tableConfig.size}
/>
{!searchTerm.trim() && songs.length > 5 && !showAll && (
<Group justify="center" w="100%">
<Button onClick={() => setShowAll(true)} variant="subtle">
{t('action.viewMore', { postProcess: 'sentenceCase' })}
</Button>
</Group>
)}
</>
) : null}
</Stack>
@@ -1076,7 +1046,6 @@ interface AlbumSectionProps {
albums: Album[];
controls: ItemControls;
cq: ReturnType<typeof useContainerQuery>;
enableExpansion?: boolean;
releaseType: string;
rows: DataRow[] | undefined;
title: React.ReactNode | string;
@@ -1096,15 +1065,7 @@ const getItemsPerRow = (cq: ReturnType<typeof useContainerQuery>) => {
return 2;
};
const AlbumSection = ({
albums,
controls,
cq,
enableExpansion,
releaseType,
rows,
title,
}: AlbumSectionProps) => {
const AlbumSection = ({ albums, controls, cq, releaseType, rows, title }: AlbumSectionProps) => {
const { t } = useTranslation();
const itemsPerRow = getItemsPerRow(cq);
@@ -1229,7 +1190,6 @@ const AlbumSection = ({
controls={controls}
data={album}
enableDrag
enableExpansion={enableExpansion ?? true}
itemType={LibraryItem.ALBUM}
rows={rows}
type="poster"
@@ -1407,6 +1367,7 @@ const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
const routeId = (artistId || albumArtistId) as string;
const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM);
const controls = useDefaultItemListControls();
const filteredAndSortedAlbums = useMemo(() => {
const albums = albumsQuery.data?.items || [];
@@ -1414,8 +1375,6 @@ const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
return sortAlbumList(searched, sortBy, sortOrder);
}, [albumsQuery.data?.items, debouncedSearchTerm, sortBy, sortOrder]);
const controls = useDefaultItemListControls();
const albumsByReleaseType = useMemo(() => {
return groupAlbumsByReleaseType(filteredAndSortedAlbums, routeId, groupingType);
}, [filteredAndSortedAlbums, routeId, groupingType]);
@@ -1684,7 +1643,6 @@ const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
albums={albums}
controls={controls}
cq={cq}
enableExpansion
key={releaseType}
releaseType={releaseType}
rows={rows}
@@ -20,6 +20,7 @@ export function AlbumArtistGridCarousel(props: AlbumArtistGridCarouselProps) {
const controls = useDefaultItemListControls();
const cards = useMemo(() => {
// Filter out excluded IDs if provided
const filteredItems = excludeIds
? data.filter((albumArtist) => !excludeIds.includes(albumArtist.id))
: data;
@@ -345,7 +345,8 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
openContextModal({
innerProps: {
...modalProps,
itemIds: items,
resourceType: itemType,
},
modalKey: 'addToPlaylist',
size: 'lg',
@@ -1,18 +1,63 @@
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { lazy, Suspense, useEffect, useRef } from 'react';
import { createCallable } from 'react-call';
import { useParams } from 'react-router';
import { AlbumArtistContextMenu } from '/@/renderer/features/context-menu/menus/album-artist-context-menu';
import { AlbumContextMenu } from '/@/renderer/features/context-menu/menus/album-context-menu';
import { ArtistContextMenu } from '/@/renderer/features/context-menu/menus/artist-context-menu';
import { FolderContextMenu } from '/@/renderer/features/context-menu/menus/folder-context-menu';
import { GenreContextMenu } from '/@/renderer/features/context-menu/menus/genre-context-menu';
import { PlaylistContextMenu } from '/@/renderer/features/context-menu/menus/playlist-context-menu';
import { PlaylistSongContextMenu } from '/@/renderer/features/context-menu/menus/playlist-song-context-menu';
import { QueueContextMenu } from '/@/renderer/features/context-menu/menus/queue-context-menu';
import { SongContextMenu } from '/@/renderer/features/context-menu/menus/song-context-menu';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
const AlbumArtistContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/album-artist-context-menu').then((module) => ({
default: module.AlbumArtistContextMenu,
})),
);
const AlbumContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/album-context-menu').then((module) => ({
default: module.AlbumContextMenu,
})),
);
const ArtistContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/artist-context-menu').then((module) => ({
default: module.ArtistContextMenu,
})),
);
const FolderContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/folder-context-menu').then((module) => ({
default: module.FolderContextMenu,
})),
);
const GenreContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/genre-context-menu').then((module) => ({
default: module.GenreContextMenu,
})),
);
const PlaylistContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/playlist-context-menu').then((module) => ({
default: module.PlaylistContextMenu,
})),
);
const PlaylistSongContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/playlist-song-context-menu').then((module) => ({
default: module.PlaylistSongContextMenu,
})),
);
const QueueContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/queue-context-menu').then((module) => ({
default: module.QueueContextMenu,
})),
);
const SongContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/song-context-menu').then((module) => ({
default: module.SongContextMenu,
})),
);
import {
Album,
AlbumArtist,
@@ -80,15 +125,17 @@ export const ContextMenuController = createCallable<ContextMenuControllerProps,
}}
/>
</ContextMenu.Target>
{cmd.type === LibraryItem.QUEUE_SONG && <QueueContextMenu {...cmd} />}
{cmd.type === LibraryItem.ALBUM && <AlbumContextMenu {...cmd} />}
{cmd.type === LibraryItem.ALBUM_ARTIST && <AlbumArtistContextMenu {...cmd} />}
{cmd.type === LibraryItem.ARTIST && <ArtistContextMenu {...cmd} />}
{cmd.type === LibraryItem.FOLDER && <FolderContextMenu {...cmd} />}
{cmd.type === LibraryItem.GENRE && <GenreContextMenu {...cmd} />}
{cmd.type === LibraryItem.PLAYLIST && <PlaylistContextMenu {...cmd} />}
{cmd.type === LibraryItem.PLAYLIST_SONG && <PlaylistSongContextMenu {...cmd} />}
{cmd.type === LibraryItem.SONG && <SongContextMenu {...cmd} />}
<Suspense fallback={null}>
{cmd.type === LibraryItem.QUEUE_SONG && <QueueContextMenu {...cmd} />}
{cmd.type === LibraryItem.ALBUM && <AlbumContextMenu {...cmd} />}
{cmd.type === LibraryItem.ALBUM_ARTIST && <AlbumArtistContextMenu {...cmd} />}
{cmd.type === LibraryItem.ARTIST && <ArtistContextMenu {...cmd} />}
{cmd.type === LibraryItem.FOLDER && <FolderContextMenu {...cmd} />}
{cmd.type === LibraryItem.GENRE && <GenreContextMenu {...cmd} />}
{cmd.type === LibraryItem.PLAYLIST && <PlaylistContextMenu {...cmd} />}
{cmd.type === LibraryItem.PLAYLIST_SONG && <PlaylistSongContextMenu {...cmd} />}
{cmd.type === LibraryItem.SONG && <SongContextMenu {...cmd} />}
</Suspense>
</ContextMenu>
);
},
@@ -109,18 +109,8 @@ export const useDiscordRpc = () => {
instance: false,
largeImageKey: 'icon',
largeImageText: truncate(stationName || 'Radio'),
smallImageKey:
current[2] === PlayerStatus.PLAYING
? discordSettings.showStateIcon
? 'playing'
: undefined
: 'paused',
smallImageText:
current[2] === PlayerStatus.PLAYING
? discordSettings.showStateIcon
? sentenceCase(current[2])
: undefined
: sentenceCase(current[2]),
smallImageKey: current[2] === PlayerStatus.PLAYING ? 'playing' : 'paused',
smallImageText: sentenceCase(current[2]),
state: truncate(artist),
statusDisplayType: StatusDisplayType.STATE,
type: discordSettings.showAsListening ? 2 : 0,
@@ -209,7 +199,7 @@ export const useDiscordRpc = () => {
(song?.album && song.album.padEnd(2, ' ')) || 'Unknown album',
),
smallImageKey: undefined,
smallImageText: undefined,
smallImageText: sentenceCase(current[2]),
state: truncate((artists && artists.padEnd(2, ' ')) || 'Unknown artist'),
statusDisplayType: statusDisplayMap[discordSettings.displayType],
// I would love to use the actual type as opposed to hardcoding to 2,
@@ -257,13 +247,9 @@ export const useDiscordRpc = () => {
activity.endTimestamp = end;
}
if (discordSettings.showStateIcon) {
activity.smallImageKey = 'playing';
activity.smallImageText = sentenceCase(current[2]);
}
activity.smallImageKey = 'playing';
} else {
activity.smallImageKey = 'paused';
activity.smallImageText = sentenceCase(current[2]);
}
if (discordSettings.showServerImage && song) {
@@ -363,7 +349,6 @@ export const useDiscordRpc = () => {
[
discordSettings.showAsListening,
discordSettings.showServerImage,
discordSettings.showStateIcon,
discordSettings.showPaused,
lastfmApiKey,
discordSettings.clientId,
@@ -3,10 +3,7 @@ import { useTranslation } from 'react-i18next';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
@@ -243,10 +240,7 @@ export const FolderListHeaderFilters = () => {
</Group>
<Group gap="sm" wrap="nowrap">
<ListConfigMenu
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
listKey={ItemListKey.SONG}
optionsConfig={{
grid: {
@@ -82,7 +82,16 @@ const HomeRoute = () => {
},
};
const sortedItems = homeItems.filter((item) => !item.disabled);
const sortedItems = homeItems.filter((item) => {
if (item.disabled) {
return false;
}
if (isJellyfin && item.id === HomeItem.RECENTLY_PLAYED) {
return false;
}
return true;
});
const sortedCarousel = sortedItems
.filter((item) => item.id !== HomeItem.GENRES)
@@ -17,12 +17,7 @@ import { IgnoreCorsSslSwitches } from '/@/renderer/features/servers/components/i
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { AppRoute } from '/@/renderer/router/routes';
import {
getServerById,
useAuthStoreActions,
useCurrentServer,
useServerList,
} from '/@/renderer/store';
import { useAuthStoreActions, useCurrentServer } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center';
import { Code } from '/@/shared/components/code/code';
@@ -51,14 +46,11 @@ const SERVER_NAMES: Record<ServerType, string> = {
[ServerType.SUBSONIC]: 'OpenSubsonic',
};
const normalizeUrl = (url: string) => url.replace(/\/$/, '');
const LoginRoute = () => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const { addServer, setCurrentServer, updateServer } = useAuthStoreActions();
const { addServer, setCurrentServer } = useAuthStoreActions();
const currentServer = useCurrentServer();
const serverList = useServerList();
// Check if server lock is configured
const serverLock = isServerLock();
@@ -149,43 +141,24 @@ const LoginRoute = () => {
});
}
const normalizedUrl = normalizeUrl(serverUrl);
const existingServer =
serverLock &&
Object.values(serverList).find((s) => normalizeUrl(s.url) === normalizedUrl);
const serverItem: ServerListItemWithCredential = {
credential: data.credential,
id: nanoid(),
isAdmin: data.isAdmin,
name: serverName,
type: serverType as ServerType,
url: normalizedUrl,
url: serverUrl.replace(/\/$/, ''),
userId: data.userId,
username: data.username,
};
if (existingServer) {
const updates: Partial<ServerListItemWithCredential> = {
credential: data.credential,
isAdmin: data.isAdmin,
userId: data.userId,
username: data.username,
};
if (data.ndCredential !== undefined) {
updates.ndCredential = data.ndCredential;
}
updateServer(existingServer.id, updates);
const updated = getServerById(existingServer.id);
if (updated) setCurrentServer(updated);
} else {
if (data.ndCredential !== undefined) {
serverItem.ndCredential = data.ndCredential;
}
addServer(serverItem);
setCurrentServer(serverItem);
if (data.ndCredential !== undefined) {
serverItem.ndCredential = data.ndCredential;
}
addServer(serverItem);
setCurrentServer(serverItem);
toast.success({
message: t('form.addServer.success', { postProcess: 'sentenceCase' }),
});
@@ -6,10 +6,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useRestoreQueue, useSaveQueue } from '/@/renderer/features/player/hooks/use-queue-restore';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
import { useCurrentServer } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
@@ -67,8 +64,10 @@ export const PlayQueueListControls = ({
/>
<ListConfigMenu
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
{
hidden: true,
value: ListDisplayType.GRID,
},
]}
listKey={type}
optionsConfig={{
@@ -7,7 +7,6 @@ import { DiscordRpcHook } from '/@/renderer/features/discord-rpc/use-discord-rpc
import { MainPlayerListenerHook } from '/@/renderer/features/player/audio-player/hooks/use-main-player-listener';
import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';
import { SleepTimerHook } from '/@/renderer/features/player/components/sleep-timer-button';
import { AutoDJHook } from '/@/renderer/features/player/hooks/use-auto-dj';
import { MediaSessionHook } from '/@/renderer/features/player/hooks/use-media-session';
import { MPRISHook } from '/@/renderer/features/player/hooks/use-mpris';
@@ -49,7 +48,6 @@ export const AudioPlayers = () => {
return (
<>
<SleepTimerHook />
<ScrobbleHook />
<PowerSaveBlockerHook />
<DiscordRpcHook />
@@ -6,10 +6,6 @@
filter: drop-shadow(0 0 5px rgb(0 0 0 / 40%)) drop-shadow(0 0 5px rgb(0 0 0 / 40%));
}
.censored.image {
filter: blur(30px);
}
.image-container {
position: relative;
display: flex;
@@ -11,12 +11,7 @@ import {
useRadioPlayer,
} from '/@/renderer/features/radio/hooks/use-radio-player';
import { AppRoute } from '/@/renderer/router/routes';
import {
useGeneralSettings,
useNativeAspectRatio,
usePlayerData,
usePlayerSong,
} from '/@/renderer/store';
import { useNativeAspectRatio, usePlayerData, usePlayerSong } from '/@/renderer/store';
import { Badge } from '/@/shared/components/badge/badge';
import { Center } from '/@/shared/components/center/center';
import { Flex } from '/@/shared/components/flex/flex';
@@ -25,7 +20,7 @@ import { Icon } from '/@/shared/components/icon/icon';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { useSetState } from '/@/shared/hooks/use-set-state';
import { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';
import { LibraryItem } from '/@/shared/types/domain-types';
const imageVariants: Variants = {
closed: {
@@ -54,14 +49,9 @@ const MotionImage = motion.img;
const ImageWithPlaceholder = ({
className,
explicit,
placeholderIcon = 'itemAlbum',
...props
}: HTMLMotionProps<'img'> & {
explicit?: boolean;
placeholder?: string;
placeholderIcon?: 'itemAlbum' | 'radio';
}) => {
}: HTMLMotionProps<'img'> & { placeholder?: string; placeholderIcon?: 'itemAlbum' | 'radio' }) => {
const nativeAspectRatio = useNativeAspectRatio();
if (!props.src) {
@@ -81,9 +71,7 @@ const ImageWithPlaceholder = ({
return (
<MotionImage
className={clsx(styles.image, className, {
[styles.censored]: explicit,
})}
className={clsx(styles.image, className)}
style={{
objectFit: nativeAspectRatio ? 'contain' : 'cover',
width: nativeAspectRatio ? 'auto' : '100%',
@@ -101,7 +89,6 @@ export const FullScreenPlayerImage = () => {
const currentSong = usePlayerSong();
const { nextSong } = usePlayerData();
const { blurExplicitImages } = useGeneralSettings();
const isPlayingRadio = isRadioActive && isRadioPlaying;
@@ -120,10 +107,8 @@ export const FullScreenPlayerImage = () => {
});
const [imageState, setImageState] = useSetState({
bottomExplicit: nextSong?.explicitStatus === ExplicitStatus.EXPLICIT,
bottomImage: nextImageUrl,
current: 0,
topExplicit: currentSong?.explicitStatus === ExplicitStatus.EXPLICIT,
topImage: currentImageUrl,
});
@@ -148,14 +133,8 @@ export const FullScreenPlayerImage = () => {
const isTop = imageStateRef.current.current === 0;
setImageState({
bottomExplicit:
(isTop ? currentSong?.explicitStatus : nextSong?.explicitStatus) ===
ExplicitStatus.EXPLICIT,
bottomImage: isTop ? currentImageUrl : nextImageUrl,
current: isTop ? 1 : 0,
topExplicit:
(isTop ? nextSong?.explicitStatus : currentSong?.explicitStatus) ===
ExplicitStatus.EXPLICIT,
topImage: isTop ? nextImageUrl : currentImageUrl,
});
@@ -167,8 +146,6 @@ export const FullScreenPlayerImage = () => {
nextSong?._uniqueId,
nextImageUrl,
setImageState,
currentSong?.explicitStatus,
nextSong?.explicitStatus,
]);
return (
@@ -188,7 +165,6 @@ export const FullScreenPlayerImage = () => {
custom={{ isOpen: imageState.current === 0 }}
draggable={false}
exit="closed"
explicit={blurExplicitImages && imageState.topExplicit}
initial="closed"
key={`top-${currentSong?._uniqueId || 'none'}`}
placeholder="var(--theme-colors-foreground-muted)"
@@ -204,7 +180,6 @@ export const FullScreenPlayerImage = () => {
custom={{ isOpen: imageState.current === 1 }}
draggable={false}
exit="closed"
explicit={blurExplicitImages && imageState.bottomExplicit}
initial="closed"
key={`bottom-${currentSong?._uniqueId || 'none'}`}
placeholder="var(--theme-colors-foreground-muted)"
@@ -21,10 +21,7 @@ import {
useIsRadioActive,
useRadioPlayer,
} from '/@/renderer/features/radio/hooks/use-radio-player';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { useFastAverageColor } from '/@/renderer/hooks';
import {
useFullScreenPlayerStore,
@@ -562,10 +559,7 @@ const Controls = () => {
buttonProps={{
variant: 'subtle',
}}
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
listKey={ItemListKey.FULL_SCREEN}
optionsConfig={{
table: {
@@ -4,10 +4,7 @@ import { useTranslation } from 'react-i18next';
import styles from './mobile-fullscreen-player.module.css';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import {
useFullScreenPlayerStore,
useFullScreenPlayerStoreActions,
@@ -368,10 +365,7 @@ export const MobileFullscreenPlayerHeader = memo(
buttonProps={{
variant: isPageHovered ? 'default' : 'subtle',
}}
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
listKey={ItemListKey.FULL_SCREEN}
optionsConfig={{
table: {
-2
View File
@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next';
import { PopoverPlayQueue } from '/@/renderer/features/now-playing/components/popover-play-queue';
import { PlayerConfig } from '/@/renderer/features/player/components/player-config';
import { CustomPlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
import { SleepTimerButton } from '/@/renderer/features/player/components/sleep-timer-button';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
@@ -73,7 +72,6 @@ export const RightControls = () => {
<AutoDJButton />
</Group>
<Group align="center" gap="xs" wrap="nowrap">
<SleepTimerButton />
<PlayerConfig />
<LyricsButton />
<FavoriteButton />
@@ -1,344 +0,0 @@
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>
);
};
@@ -1,635 +0,0 @@
import type { RowComponentProps } from 'react-window-v2';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { applyClientSideSongFilters } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';
import {
ArtistMultiSelectRow,
GenreMultiSelectRow,
} from '/@/renderer/features/shared/components/multi-select-rows';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { useCurrentServer } from '/@/renderer/store';
import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import {
VirtualMultiSelect,
type VirtualMultiSelectOption,
} from '/@/shared/components/multi-select/virtual-multi-select';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
interface BooleanSegmentFilterProps {
label: string;
onChange: (value: boolean | null) => void;
segmentData: Array<{ label: string; value: string }>;
value: boolean | null | undefined;
}
function booleanToSegmentValue(value: boolean | null | undefined): string {
if (value === true) return 'true';
if (value === false) return 'false';
return 'none';
}
function segmentValueToBoolean(value: string): boolean | null {
if (value === 'true') return true;
if (value === 'false') return false;
return null;
}
const BooleanSegmentFilter = ({
label,
onChange,
segmentData,
value,
}: BooleanSegmentFilterProps) => (
<Stack gap="xs">
<Text size="sm" weight={500}>
{label}
</Text>
<SegmentedControl
data={segmentData}
onChange={(v) => onChange(segmentValueToBoolean(v))}
size="sm"
value={booleanToSegmentValue(value)}
w="100%"
/>
</Stack>
);
interface MultiSelectFilterOption {
albumCount: null | number;
imageUrl: string | undefined;
label: string;
songCount: number;
value: string;
}
interface MultiSelectFilterProps {
displayCountType?: 'song';
height: number;
label: React.ReactNode;
onChange: (value: null | string[]) => void;
options: MultiSelectFilterOption[];
RowComponent: (props: RowComponentProps<MultiSelectRowContext>) => React.ReactElement;
singleSelect: boolean;
value: string[];
}
type MultiSelectRowContext = {
disabled?: boolean;
displayCountType?: 'album' | 'song';
focusedIndex: null | number;
onToggle: (value: string) => void;
options: VirtualMultiSelectOption<MultiSelectFilterOption>[];
value: string[];
};
const MultiSelectFilter = ({
displayCountType = 'song',
height,
label,
onChange,
options,
RowComponent,
singleSelect,
value,
}: MultiSelectFilterProps) => (
<VirtualMultiSelect
displayCountType={displayCountType}
height={height}
label={label}
onChange={onChange}
options={options}
RowComponent={RowComponent}
singleSelect={singleSelect}
value={value}
/>
);
interface YearRangeFilterProps {
fromYearLabel: string;
maxYear: number | undefined;
minYear: number | undefined;
onMaxYear: (e: number | string) => void;
onMinYear: (e: number | string) => void;
toYearLabel: string;
}
const YearRangeFilter = ({
fromYearLabel,
maxYear,
minYear,
onMaxYear,
onMinYear,
toYearLabel,
}: YearRangeFilterProps) => (
<Group gap="sm" wrap="nowrap">
<NumberInput
hideControls={false}
label={fromYearLabel}
max={5000}
min={0}
onChange={(e) => onMinYear(e)}
style={{ flex: 1 }}
value={minYear != null ? minYear : ''}
/>
<NumberInput
hideControls={false}
label={toYearLabel}
max={5000}
min={0}
onChange={(e) => onMaxYear(e)}
style={{ flex: 1 }}
value={maxYear != null ? maxYear : ''}
/>
</Group>
);
interface MultiSelectFilterLabelProps {
andOrValue: 'and' | 'or';
entityLabel: string;
filterMultipleLabel: string;
filterSingleLabel: string;
matchAndLabel: string;
matchOrLabel: string;
onAndOrChange: (value: 'and' | 'or') => void;
onSingleMultiChange: (value: string) => void;
showAndOr: boolean;
singleMultiValue: 'multi' | 'single';
}
const MultiSelectFilterLabel = ({
andOrValue,
entityLabel,
filterMultipleLabel,
filterSingleLabel,
matchAndLabel,
matchOrLabel,
onAndOrChange,
onSingleMultiChange,
showAndOr,
singleMultiValue,
}: MultiSelectFilterLabelProps) => (
<Group gap="xs" justify="space-between" w="100%">
<Text fw={500} size="sm">
{entityLabel}
</Text>
<Group gap="xs">
{showAndOr && (
<SegmentedControl
data={[
{ label: matchAndLabel, value: 'and' },
{ label: matchOrLabel, value: 'or' },
]}
onChange={(value) => onAndOrChange(value === 'or' ? 'or' : 'and')}
size="xs"
value={andOrValue}
/>
)}
<SegmentedControl
data={[
{ label: filterSingleLabel, value: 'single' },
{ label: filterMultipleLabel, value: 'multi' },
]}
onChange={onSingleMultiChange}
size="xs"
value={singleMultiValue}
/>
</Group>
</Group>
);
export const ClientSideSongFilters = () => {
const { t } = useTranslation();
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const {
query,
setAlbumArtistIds,
setAlbumArtistIdsMode,
setArtistIds,
setArtistIdsMode,
setFavorite,
setGenreId,
setGenreIdsMode,
setHasRating,
setMaxYear,
setMinYear,
} = usePlaylistSongListFilters();
const playlistSongsQuery = useSuspenseQuery(
playlistsQueries.songList({
query: { id: playlistId },
serverId: server?.id,
}),
);
const albumArtistSelectMode = useAppStore((state) => state.albumArtistSelectMode);
const artistSelectMode = useAppStore((state) => state.artistSelectMode);
const genreSelectMode = useAppStore((state) => state.genreSelectMode);
const { setAlbumArtistSelectMode, setArtistSelectMode, setGenreSelectMode } =
useAppStoreActions();
const songs = useMemo(() => {
return (playlistSongsQuery.data?.items ?? []) as Song[];
}, [playlistSongsQuery.data]);
const filteredSongs = useMemo(
() => applyClientSideSongFilters(songs, query as Record<string, unknown>),
[songs, query],
);
const songsForAlbumArtistOptions = useMemo(() => {
const idsMode =
(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const useFilteredResult = albumArtistSelectMode === 'multi' && idsMode === 'and';
if (!useFilteredResult) {
const queryWithoutAlbumArtist = {
...query,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: undefined,
} as Record<string, unknown>;
return applyClientSideSongFilters(songs, queryWithoutAlbumArtist);
}
return filteredSongs;
}, [albumArtistSelectMode, filteredSongs, query, songs]);
const songsForArtistOptions = useMemo(() => {
const idsMode =
(query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const useFilteredResult = artistSelectMode === 'multi' && idsMode === 'and';
if (!useFilteredResult) {
const queryWithoutArtist = {
...query,
[FILTER_KEYS.SONG.ARTIST_IDS]: undefined,
} as Record<string, unknown>;
return applyClientSideSongFilters(songs, queryWithoutArtist);
}
return filteredSongs;
}, [artistSelectMode, filteredSongs, query, songs]);
const songsForGenreOptions = useMemo(() => {
const idsMode =
(query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';
const useFilteredResult = genreSelectMode === 'multi' && idsMode === 'and';
if (!useFilteredResult) {
const queryWithoutGenre = {
...query,
[FILTER_KEYS.SONG.GENRE_ID]: undefined,
} as Record<string, unknown>;
return applyClientSideSongFilters(songs, queryWithoutGenre);
}
return filteredSongs;
}, [filteredSongs, genreSelectMode, query, songs]);
const albumArtistOptions = useMemo(() => {
const byId = new Map<
string,
{ id: string; imageUrl: string | undefined; name: string; songCount: number }
>();
for (const song of songsForAlbumArtistOptions) {
for (const artist of song.albumArtists ?? []) {
if (!artist.id) continue;
const existing = byId.get(artist.id);
if (existing) {
existing.songCount += 1;
} else {
byId.set(artist.id, {
id: artist.id,
imageUrl:
artist.imageUrl ??
getItemImageUrl({
id: artist.id,
itemType: LibraryItem.ALBUM_ARTIST,
type: 'table',
}),
name: artist.name,
songCount: 1,
});
}
}
}
return Array.from(byId.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((a) => ({
albumCount: null as null | number,
imageUrl: a.imageUrl,
label: a.name,
songCount: a.songCount,
value: a.id,
}));
}, [songsForAlbumArtistOptions]);
const artistOptions = useMemo(() => {
const byId = new Map<
string,
{ id: string; imageUrl: string | undefined; name: string; songCount: number }
>();
for (const song of songsForArtistOptions) {
for (const artist of song.artists ?? []) {
if (!artist.id) continue;
const existing = byId.get(artist.id);
if (existing) {
existing.songCount += 1;
} else {
byId.set(artist.id, {
id: artist.id,
imageUrl:
artist.imageUrl ??
getItemImageUrl({
id: artist.id,
itemType: LibraryItem.ARTIST,
type: 'table',
}),
name: artist.name,
songCount: 1,
});
}
}
}
return Array.from(byId.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((a) => ({
albumCount: null as null | number,
imageUrl: a.imageUrl,
label: a.name,
songCount: a.songCount,
value: a.id,
}));
}, [songsForArtistOptions]);
const genreOptions = useMemo(() => {
const byId = new Map<string, { id: string; name: string; songCount: number }>();
for (const song of songsForGenreOptions) {
for (const genre of song.genres ?? []) {
if (!genre.id) continue;
const existing = byId.get(genre.id);
if (existing) {
existing.songCount += 1;
} else {
byId.set(genre.id, {
id: genre.id,
name: genre.name,
songCount: 1,
});
}
}
}
return Array.from(byId.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((g) => ({
albumCount: null as null | number,
imageUrl: undefined,
label: g.name,
songCount: g.songCount,
value: g.id,
}));
}, [songsForGenreOptions]);
const segmentedControlData = useMemo(
() => [
{ label: t('common.none', { postProcess: 'titleCase' }), value: 'none' },
{ label: t('common.yes', { postProcess: 'titleCase' }), value: 'true' },
{ label: t('common.no', { postProcess: 'titleCase' }), value: 'false' },
],
[t],
);
const handleMinYear = useMemo(
() => (e: number | string) => {
if (e === '' || e === null || e === undefined) {
setMinYear(null);
return;
}
const year = typeof e === 'number' ? e : Number(e);
setMinYear(!isNaN(year) && isFinite(year) && year > 0 ? year : null);
},
[setMinYear],
);
const handleMaxYear = useMemo(
() => (e: number | string) => {
if (e === '' || e === null || e === undefined) {
setMaxYear(null);
return;
}
const year = typeof e === 'number' ? e : Number(e);
setMaxYear(!isNaN(year) && isFinite(year) && year > 0 ? year : null);
},
[setMaxYear],
);
const debouncedHandleMinYear = useDebouncedCallback(handleMinYear, 300);
const debouncedHandleMaxYear = useDebouncedCallback(handleMaxYear, 300);
const selectedGenreIds = useMemo(
() => (query[FILTER_KEYS.SONG.GENRE_ID] as string[] | undefined) ?? [],
[query],
);
const handleGenreSelectModeChange = useCallback(
(value: string) => {
const newMode = value as 'multi' | 'single';
setGenreSelectMode(newMode);
if (newMode === 'single' && selectedGenreIds.length > 1) {
setGenreId([selectedGenreIds[0]]);
}
},
[selectedGenreIds, setGenreId, setGenreSelectMode],
);
const genreIdsMode =
(query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';
const handleGenreChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
setGenreId(e);
} else {
setGenreId(null);
}
},
[setGenreId],
);
const selectedArtistIds = useMemo(
() => (query[FILTER_KEYS.SONG.ARTIST_IDS] as string[] | undefined) ?? [],
[query],
);
const handleArtistSelectModeChange = useCallback(
(value: string) => {
const newMode = value as 'multi' | 'single';
setArtistSelectMode(newMode);
if (newMode === 'single' && selectedArtistIds.length > 1) {
setArtistIds([selectedArtistIds[0]]);
}
},
[selectedArtistIds, setArtistIds, setArtistSelectMode],
);
const artistIdsMode =
(query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const handleArtistChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
setArtistIds(e);
} else {
setArtistIds(null);
}
},
[setArtistIds],
);
const selectedAlbumArtistIds = useMemo(
() => (query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS] as string[] | undefined) ?? [],
[query],
);
const handleAlbumArtistSelectModeChange = useCallback(
(value: string) => {
const newMode = value as 'multi' | 'single';
setAlbumArtistSelectMode(newMode);
if (newMode === 'single' && selectedAlbumArtistIds.length > 1) {
setAlbumArtistIds([selectedAlbumArtistIds[0]]);
}
},
[selectedAlbumArtistIds, setAlbumArtistIds, setAlbumArtistSelectMode],
);
const albumArtistIdsMode =
(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const handleAlbumArtistChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
setAlbumArtistIds(e);
} else {
setAlbumArtistIds(null);
}
},
[setAlbumArtistIds],
);
const queryFavorite = query[FILTER_KEYS.SONG.FAVORITE] as boolean | undefined;
const queryHasRating = query[FILTER_KEYS.SONG.HAS_RATING] as boolean | undefined;
const queryMinYear = query[FILTER_KEYS.SONG.MIN_YEAR] as number | undefined;
const queryMaxYear = query[FILTER_KEYS.SONG.MAX_YEAR] as number | undefined;
const matchAndLabel = t('filter.matchAnd', { postProcess: 'titleCase' });
const matchOrLabel = t('filter.matchOr', { postProcess: 'titleCase' });
const filterSingleLabel = t('common.filter_single', { postProcess: 'titleCase' });
const filterMultipleLabel = t('common.filter_multiple', { postProcess: 'titleCase' });
return (
<Stack px="md" py="md">
<BooleanSegmentFilter
label={t('filter.isFavorited', { postProcess: 'sentenceCase' })}
onChange={setFavorite}
segmentData={segmentedControlData}
value={queryFavorite}
/>
<Stack gap="xs" mt="md">
<BooleanSegmentFilter
label={t('filter.isRated', { postProcess: 'sentenceCase' })}
onChange={setHasRating}
segmentData={segmentedControlData}
value={queryHasRating}
/>
</Stack>
<Divider my="md" />
<MultiSelectFilter
height={300}
label={
<MultiSelectFilterLabel
andOrValue={artistIdsMode}
entityLabel={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
filterMultipleLabel={filterMultipleLabel}
filterSingleLabel={filterSingleLabel}
matchAndLabel={matchAndLabel}
matchOrLabel={matchOrLabel}
onAndOrChange={setArtistIdsMode}
onSingleMultiChange={handleArtistSelectModeChange}
showAndOr={artistSelectMode === 'multi'}
singleMultiValue={artistSelectMode}
/>
}
onChange={handleArtistChange}
options={artistOptions}
RowComponent={ArtistMultiSelectRow}
singleSelect={artistSelectMode === 'single'}
value={selectedArtistIds}
/>
<Divider my="md" />
<MultiSelectFilter
height={300}
label={
<MultiSelectFilterLabel
andOrValue={albumArtistIdsMode}
entityLabel={t('entity.albumArtist', {
count: 2,
postProcess: 'sentenceCase',
})}
filterMultipleLabel={filterMultipleLabel}
filterSingleLabel={filterSingleLabel}
matchAndLabel={matchAndLabel}
matchOrLabel={matchOrLabel}
onAndOrChange={setAlbumArtistIdsMode}
onSingleMultiChange={handleAlbumArtistSelectModeChange}
showAndOr={albumArtistSelectMode === 'multi'}
singleMultiValue={albumArtistSelectMode}
/>
}
onChange={handleAlbumArtistChange}
options={albumArtistOptions}
RowComponent={ArtistMultiSelectRow}
singleSelect={albumArtistSelectMode === 'single'}
value={selectedAlbumArtistIds}
/>
<Divider my="md" />
<MultiSelectFilter
height={220}
label={
<MultiSelectFilterLabel
andOrValue={genreIdsMode}
entityLabel={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
filterMultipleLabel={filterMultipleLabel}
filterSingleLabel={filterSingleLabel}
matchAndLabel={matchAndLabel}
matchOrLabel={matchOrLabel}
onAndOrChange={setGenreIdsMode}
onSingleMultiChange={handleGenreSelectModeChange}
showAndOr={genreSelectMode === 'multi'}
singleMultiValue={genreSelectMode}
/>
}
onChange={handleGenreChange}
options={genreOptions}
RowComponent={GenreMultiSelectRow}
singleSelect={genreSelectMode === 'single'}
value={selectedGenreIds}
/>
<Divider my="md" />
<YearRangeFilter
fromYearLabel={t('filter.fromYear', { postProcess: 'titleCase' })}
maxYear={queryMaxYear}
minYear={queryMinYear}
onMaxYear={debouncedHandleMaxYear}
onMinYear={debouncedHandleMinYear}
toYearLabel={t('filter.toYear', { postProcess: 'titleCase' })}
/>
</Stack>
);
};
@@ -1,251 +0,0 @@
import { useEffect, useMemo } from 'react';
import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list';
import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { DefaultItemControlProps, ItemControls } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { applyClientSideSongFilters } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';
import { type PlaylistAlbumRow, playlistSongsToAlbums } from '/@/renderer/features/playlists/utils';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
import { useGeneralSettings, useListSettings } from '/@/renderer/store';
import { sortSongList } from '/@/shared/api/utils';
import {
LibraryItem,
PlaylistSongListResponse,
Song,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import {
ItemListKey,
ListDisplayType,
ListPaginationType,
Play,
TableColumn,
} from '/@/shared/types/types';
export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListResponse }) => {
const player = usePlayer();
const { setItemCount, setListData } = useListContext();
const { detail, display, grid, itemsPerPage, pagination, table } = useListSettings(
ItemListKey.PLAYLIST_ALBUM,
);
const { enableGridMultiSelect } = useGeneralSettings();
const { currentPage, onChange: onPageChange } = useItemListPagination();
const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters();
const filteredAndSortedSongs = useMemo(() => {
const raw = data?.items ?? [];
const filtered = applyClientSideSongFilters(raw, query as Record<string, unknown>);
const searched = searchTerm?.trim()
? searchLibraryItems(filtered, searchTerm, LibraryItem.SONG)
: filtered;
return sortSongList(
searched,
(query.sortBy as SongListSort) ?? SongListSort.ID,
(query.sortOrder as SortOrder) ?? SortOrder.ASC,
);
}, [data?.items, query, searchTerm]);
const sortedAlbums = useMemo(
() => playlistSongsToAlbums(filteredAndSortedSongs),
[filteredAndSortedSongs],
);
const isPaginated = pagination === ListPaginationType.PAGINATED;
const totalAlbumCount = sortedAlbums.length;
const albumPageCount = Math.max(1, Math.ceil(totalAlbumCount / itemsPerPage));
const paginatedAlbums = useMemo(() => {
if (!isPaginated) return sortedAlbums;
const start = currentPage * itemsPerPage;
return sortedAlbums.slice(start, start + itemsPerPage);
}, [isPaginated, currentPage, itemsPerPage, sortedAlbums]);
const albumsToRender = isPaginated ? paginatedAlbums : sortedAlbums;
const playlistSongs = useMemo(() => data?.items ?? [], [data?.items]);
const albumControlOverrides = useMemo<Partial<ItemControls>>(() => {
return {
onFavorite: undefined,
onMore: ({ event, internalState, item }: DefaultItemControlProps) => {
if (!event) return;
const selected = internalState?.getSelected();
if (selected?.length === 0 && !item) {
return;
}
let itemsToUse: (PlaylistAlbumRow | Song)[];
if ((selected?.length ?? 0) > 0) {
itemsToUse = selected as (PlaylistAlbumRow | Song)[];
} else {
itemsToUse = [item as PlaylistAlbumRow | Song];
}
const songs: Song[] = [];
for (const item of itemsToUse) {
if (item._itemType === LibraryItem.ALBUM) {
songs.push(...((item as PlaylistAlbumRow)._playlistSongs ?? []));
} else if (item._itemType === LibraryItem.SONG) {
songs.push(item as Song);
}
}
ContextMenuController.call({
cmd: { items: songs, type: LibraryItem.PLAYLIST_SONG },
event,
});
},
onPlay: ({
item,
itemType,
playType,
}: DefaultItemControlProps & { playType: Play }) => {
if (!item) return;
const rowSongs = (item as PlaylistAlbumRow)._playlistSongs;
if (itemType === LibraryItem.ALBUM && rowSongs?.length) {
player.addToQueueByData(rowSongs, playType);
return;
}
player.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
},
onRating: undefined,
};
}, [player]);
useEffect(() => {
setItemCount?.(totalAlbumCount);
}, [setItemCount, totalAlbumCount]);
useEffect(() => {
setListData?.(filteredAndSortedSongs);
}, [filteredAndSortedSongs, setListData]);
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ enabled: true });
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.PLAYLIST_ALBUM,
});
const { handleColumnResized } = useItemListColumnResize({
itemListKey: ItemListKey.PLAYLIST_ALBUM,
});
const { handleColumnReordered: handleDetailColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.PLAYLIST_ALBUM,
tableKey: 'detail',
});
const { handleColumnResized: handleDetailColumnResized } = useItemListColumnResize({
itemListKey: ItemListKey.PLAYLIST_ALBUM,
tableKey: 'detail',
});
const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.PLAYLIST_ALBUM, grid.size);
const tableColumns = useMemo(() => {
return table.columns.filter(
(column) =>
column.id !== TableColumn.USER_FAVORITE && column.id !== TableColumn.USER_RATING,
);
}, [table.columns]);
const renderAlbumList = () => {
switch (display) {
case ListDisplayType.DETAIL:
return (
<ItemDetailList
enableHeader={detail?.enableHeader}
items={albumsToRender}
listKey={ItemListKey.PLAYLIST_ALBUM}
onColumnReordered={handleDetailColumnReordered}
onColumnResized={handleDetailColumnResized}
onScrollEnd={handleOnScrollEnd}
onSongRowDoubleClick={({ internalState, item }) => {
if (playlistSongs.length === 0) return;
internalState?.setSelected([item]);
player.addToQueueByData(playlistSongs, Play.NOW, item.id);
}}
overrideControls={albumControlOverrides}
scrollOffset={scrollOffset ?? 0}
songsByAlbumId={{}}
tableId="album-detail"
/>
);
case ListDisplayType.GRID:
return (
<ItemGridList
data={albumsToRender}
enableExpansion
enableMultiSelect={enableGridMultiSelect}
gap={grid.itemGap}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
itemType={LibraryItem.ALBUM}
onScrollEnd={handleOnScrollEnd}
overrideControls={albumControlOverrides}
rows={rows}
size={grid.size}
/>
);
case ListDisplayType.TABLE:
return (
<ItemTableList
autoFitColumns={table.autoFitColumns}
CellComponent={ItemTableListColumn}
columns={tableColumns}
data={albumsToRender}
enableAlternateRowColors={table.enableAlternateRowColors}
enableHeader={table.enableHeader}
enableHorizontalBorders={table.enableHorizontalBorders}
enableRowHoverHighlight={table.enableRowHoverHighlight}
enableSelection
enableVerticalBorders={table.enableVerticalBorders}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemType={LibraryItem.ALBUM}
onColumnReordered={handleColumnReordered}
onColumnResized={handleColumnResized}
onScrollEnd={handleOnScrollEnd}
overrideControls={albumControlOverrides}
size={table.size}
/>
);
default:
return null;
}
};
if (isPaginated) {
return (
<ItemListWithPagination
currentPage={currentPage}
itemsPerPage={itemsPerPage}
onChange={onPageChange}
pageCount={albumPageCount}
totalItemCount={totalAlbumCount}
>
{renderAlbumList()}
</ItemListWithPagination>
);
}
return renderAlbumList();
};
@@ -2,27 +2,14 @@ import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
import { ItemListHandle } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { PlaylistDetailAlbumView } from '/@/renderer/features/playlists/components/playlist-detail-album-view';
import { usePlaylistTrackList } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';
import { useCurrentServer, useListSettings } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import {
LibraryItem,
PlaylistSongListQuery,
PlaylistSongListResponse,
Song,
} from '/@/shared/types/domain-types';
import {
ItemListKey,
ListDisplayType,
ListPaginationType,
TableColumn,
} from '/@/shared/types/types';
import { PlaylistSongListQuery, PlaylistSongListResponse } from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType, TableColumn } from '/@/shared/types/types';
const PlaylistDetailSongListTable = lazy(() =>
import('/@/renderer/features/playlists/components/playlist-detail-song-list-table').then(
@@ -51,6 +38,7 @@ const PlaylistDetailSongListGrid = lazy(() =>
export const PlaylistDetailSongListContent = () => {
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const { setItemCount } = useListContext();
const queryClient = useQueryClient();
const playlistSongsQuery = useSuspenseQuery(
@@ -62,12 +50,18 @@ export const PlaylistDetailSongListContent = () => {
}),
);
useEffect(() => {
if (
playlistSongsQuery.data?.totalRecordCount !== undefined &&
playlistSongsQuery.data.totalRecordCount !== null
) {
setItemCount?.(playlistSongsQuery.data.totalRecordCount);
}
}, [playlistSongsQuery.data?.totalRecordCount, setItemCount]);
useEffect(() => {
const handleRefresh = async (payload: { key: string }) => {
if (
payload.key !== ItemListKey.PLAYLIST_SONG &&
payload.key !== ItemListKey.PLAYLIST_ALBUM
) {
if (payload.key !== ItemListKey.PLAYLIST_SONG) {
return;
}
@@ -87,7 +81,7 @@ export const PlaylistDetailSongListContent = () => {
return () => {
eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh);
};
}, [playlistId, queryClient, server?.id]);
}, [playlistId, queryClient, server.id]);
return (
<Suspense fallback={<Spinner container />}>
@@ -98,35 +92,13 @@ export const PlaylistDetailSongListContent = () => {
export type OverridePlaylistSongListQuery = Omit<Partial<PlaylistSongListQuery>, 'id'>;
interface PlaylistDetailSongListViewProps {
data: PlaylistSongListResponse;
items?: Song[];
}
export const PlaylistDetailSongListView = ({ data, items }: PlaylistDetailSongListViewProps) => {
export const PlaylistDetailSongListView = ({ data }: { data: PlaylistSongListResponse }) => {
const server = useCurrentServer();
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;
const { display, table } = useListSettings(ItemListKey.PLAYLIST_SONG);
switch (display) {
case ListDisplayType.GRID: {
return (
<PlaylistDetailSongListGrid
data={data}
items={items}
serverId={server.id}
{...paginationProps}
/>
);
return <PlaylistDetailSongListGrid data={data} serverId={server.id} />;
}
case ListDisplayType.TABLE: {
return (
@@ -139,10 +111,8 @@ export const PlaylistDetailSongListView = ({ data, items }: PlaylistDetailSongLi
enableHorizontalBorders={table.enableHorizontalBorders}
enableRowHoverHighlight={table.enableRowHoverHighlight}
enableVerticalBorders={table.enableVerticalBorders}
items={items}
serverId={server.id}
size={table.size}
{...paginationProps}
/>
);
}
@@ -282,31 +252,19 @@ export const PlaylistDetailSongListEdit = ({ data }: { data: PlaylistSongListRes
}
};
const PlaylistDetailTrackView = ({ data }: { data: PlaylistSongListResponse }) => {
const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => {
const { isSmartPlaylist, mode } = useListContext();
if (isSmartPlaylist) {
return <PlaylistDetailTrackViewContent data={data} />;
return <PlaylistDetailSongListView data={data} />;
}
if (mode === 'edit') {
return <PlaylistDetailSongListEdit data={data} />;
switch (mode) {
case 'edit':
return <PlaylistDetailSongListEdit data={data} />;
case 'view':
return <PlaylistDetailSongListView data={data} />;
default:
return null;
}
return <PlaylistDetailTrackViewContent data={data} />;
};
const PlaylistDetailTrackViewContent = ({ data }: { data: PlaylistSongListResponse }) => {
const { sortedAndFilteredSongs } = usePlaylistTrackList(data);
return <PlaylistDetailSongListView data={data} items={sortedAndFilteredSongs} />;
};
const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => {
const { displayMode } = useListContext();
if (displayMode === LibraryItem.ALBUM) {
return <PlaylistDetailAlbumView data={data} />;
}
return <PlaylistDetailTrackView data={data} />;
};
@@ -4,7 +4,6 @@ import { useEffect } from 'react';
import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
@@ -16,52 +15,39 @@ import {
LibraryItem,
PlaylistSongListQuery,
PlaylistSongListResponse,
Song,
} from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface PlaylistDetailSongListGridProps
extends Omit<ItemListGridComponentProps<PlaylistSongListQuery>, 'query'> {
currentPage?: number;
data: PlaylistSongListResponse;
items?: Song[];
itemsPerPage?: number;
onPageChange?: (page: number) => void;
}
export const PlaylistDetailSongListGrid = forwardRef<any, PlaylistDetailSongListGridProps>(
({
currentPage,
data,
items: itemsProp,
itemsPerPage,
onPageChange,
saveScrollOffset = true,
}) => {
({ data, saveScrollOffset = true }) => {
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
enabled: saveScrollOffset,
});
const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters();
const { setListData } = useListContext();
const songData = useMemo(() => {
let items = data?.items || [];
const songDataFromData = useMemo(() => {
let list = data?.items || [];
if (searchTerm) {
list = searchLibraryItems(list, searchTerm, LibraryItem.SONG);
return list;
items = searchLibraryItems(items, searchTerm, LibraryItem.SONG);
}
return sortSongList(list, query.sortBy, query.sortOrder);
return sortSongList(items, query.sortBy, query.sortOrder);
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
const { setListData } = useListContext();
const songData = itemsProp ?? songDataFromData;
useEffect(() => {
if (itemsProp == null && setListData) {
setListData(songDataFromData);
if (setListData) {
setListData(songData);
}
}, [itemsProp, songDataFromData, setListData]);
}, [songData, setListData]);
const gridProps = useListSettings(ItemListKey.PLAYLIST_SONG).grid;
@@ -72,22 +58,9 @@ export const PlaylistDetailSongListGrid = forwardRef<any, PlaylistDetailSongList
);
const { enableGridMultiSelect } = useGeneralSettings();
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 = (
return (
<ItemGridList
data={dataToRender}
data={songData}
enableMultiSelect={enableGridMultiSelect}
gap={gridProps.itemGap}
initialTop={{
@@ -95,27 +68,11 @@ export const PlaylistDetailSongListGrid = forwardRef<any, PlaylistDetailSongList
type: 'offset',
}}
itemsPerRow={gridProps.itemsPerRowEnabled ? gridProps.itemsPerRow : undefined}
itemType={LibraryItem.PLAYLIST_SONG}
itemType={LibraryItem.SONG}
onScrollEnd={handleOnScrollEnd}
rows={rows}
size={gridProps.size}
/>
);
if (isPaginated && itemsPerPage != null) {
return (
<ItemListWithPagination
currentPage={currentPage!}
itemsPerPage={itemsPerPage}
onChange={onPageChange!}
pageCount={pageCount}
totalItemCount={totalCount}
>
{grid}
</ItemListWithPagination>
);
}
return grid;
},
);
@@ -1,48 +1,30 @@
import { openContextModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import i18n from '/@/i18n/i18n';
import {
ALBUM_TABLE_COLUMNS,
PLAYLIST_SONG_TABLE_COLUMNS,
SONG_TABLE_COLUMNS,
} from '/@/renderer/components/item-list/item-table-list/default-columns';
import { PLAYLIST_SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { useListContext } from '/@/renderer/context/list-context';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { ClientSideSongFilters } from '/@/renderer/features/playlists/components/client-side-song-filters';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { FilterButton } from '/@/renderer/features/shared/components/filter-button';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';
import { isFilterValueSet } from '/@/renderer/features/shared/components/list-filters';
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { useContainerQuery } from '/@/renderer/hooks';
import {
PlaylistTarget,
useCurrentServerId,
usePlaylistTarget,
useSettingsStoreActions,
} from '/@/renderer/store';
import { useCurrentServerId } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Divider } from '/@/shared/components/divider/divider';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Modal } from '/@/shared/components/modal/modal';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
@@ -51,77 +33,12 @@ interface PlaylistDetailSongListHeaderFiltersProps {
isSmartPlaylist?: boolean;
}
const PlaylistSongListFiltersModal = () => {
const { t } = useTranslation();
const { isSidebarOpen, setIsSidebarOpen } = useListContext();
const { clear, query } = usePlaylistSongListFilters();
const [isOpen, handlers] = useDisclosure(false);
const hasActiveFilters = useMemo(() => {
return Boolean(
isFilterValueSet(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]) ||
isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) ||
query[FILTER_KEYS.SONG.FAVORITE] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) ||
query[FILTER_KEYS.SONG.HAS_RATING] !== undefined ||
query[FILTER_KEYS.SONG.MAX_YEAR] !== undefined ||
query[FILTER_KEYS.SONG.MIN_YEAR] !== undefined,
);
}, [query]);
const handlePin = () => {
setIsSidebarOpen?.(!isSidebarOpen);
};
const canPin = Boolean(setIsSidebarOpen);
return (
<>
<FilterButton isActive={hasActiveFilters} onClick={handlers.toggle} />
<Modal
handlers={handlers}
opened={isOpen}
size="lg"
styles={{
content: {
height: '100%',
maxHeight: '640px',
maxWidth: 'var(--theme-content-max-width)',
width: '100%',
},
}}
title={
<Group justify="space-between" style={{ paddingRight: '3rem', width: '100%' }}>
<Group>
{canPin && (
<ActionIcon
icon={isSidebarOpen ? 'unpin' : 'pin'}
onClick={handlePin}
variant="subtle"
/>
)}
{t('common.filters', { postProcess: 'sentenceCase' })}
</Group>
<Button onClick={clear} size="compact-sm" variant="subtle">
{t('common.reset', { postProcess: 'sentenceCase' })}
</Button>
</Group>
}
>
<ClientSideSongFilters />
</Modal>
</>
);
};
export const PlaylistDetailSongListHeaderFilters = ({
isSmartPlaylist,
}: PlaylistDetailSongListHeaderFiltersProps) => {
const { t } = useTranslation();
const { listKey: listKeyFromContext, mode, setMode } = useListContext();
const { mode, setMode } = useListContext();
const { playlistId } = useParams() as { playlistId: string };
const playlistTarget = usePlaylistTarget();
const { setPlaylistBehavior } = useSettingsStoreActions();
const serverId = useCurrentServerId();
const detailQuery = useQuery(playlistsQueries.detail({ query: { id: playlistId }, serverId }));
@@ -138,25 +55,9 @@ export const PlaylistDetailSongListHeaderFilters = ({
});
};
const listKey =
listKeyFromContext ??
(playlistTarget === PlaylistTarget.ALBUM
? ItemListKey.PLAYLIST_ALBUM
: ItemListKey.PLAYLIST_SONG);
const isAlbumMode = listKey === ItemListKey.PLAYLIST_ALBUM;
const toggleChoice = isAlbumMode
? t('entity.album', { count: 2, postProcess: 'titleCase' })
: t('entity.track', { count: 2, postProcess: 'titleCase' });
const handleToggleDisplayMode = useCallback(() => {
setPlaylistBehavior(
playlistTarget === PlaylistTarget.ALBUM ? PlaylistTarget.TRACK : PlaylistTarget.ALBUM,
);
}, [playlistTarget, setPlaylistBehavior]);
const { ref: containerRef, ...breakpoints } = useContainerQuery();
const isViewEditMode = !isSmartPlaylist && (breakpoints.isSm || isAlbumMode);
const isViewEditMode = !isSmartPlaylist && breakpoints.isSm;
const isEditMode = mode === 'edit';
const [collapsed, setCollapsed] = useLocalStorage<boolean>({
@@ -167,14 +68,6 @@ export const PlaylistDetailSongListHeaderFilters = ({
return (
<Flex justify="space-between" ref={containerRef}>
<Group gap="sm" w="100%">
<Button
leftSection={<Icon icon="arrowLeftRight" />}
onClick={handleToggleDisplayMode}
variant="subtle"
>
{toggleChoice}
</Button>
<Divider orientation="vertical" />
<ListSortByDropdown
defaultSortByValue={SongListSort.ID}
disabled={isEditMode}
@@ -187,9 +80,8 @@ export const PlaylistDetailSongListHeaderFilters = ({
disabled={isEditMode}
listKey={ItemListKey.PLAYLIST_SONG}
/>
<Divider orientation="vertical" />
<PlaylistSongListFiltersModal />
<ListRefreshButton disabled={isEditMode} listKey={listKey} />
{!collapsed && <ListSearchInput />}
<ListRefreshButton disabled={isEditMode} listKey={ItemListKey.PLAYLIST_SONG} />
<MoreButton onClick={handleMore} />
</Group>
<Group gap="sm" wrap="nowrap">
@@ -217,26 +109,11 @@ export const PlaylistDetailSongListHeaderFilters = ({
variant="subtle"
/>
</Tooltip>
<ListDisplayTypeToggleButton enableDetail={isAlbumMode} listKey={listKey} />
{isAlbumMode ? (
<ListConfigMenu
detailConfig={{
optionsConfig: {
autoFitColumns: { hidden: true },
},
tableColumnsData: SONG_TABLE_COLUMNS,
tableKey: 'detail',
}}
listKey={listKey}
tableColumnsData={ALBUM_TABLE_COLUMNS}
/>
) : (
<ListConfigMenu
displayTypes={SONG_DISPLAY_TYPES}
listKey={listKey}
tableColumnsData={PLAYLIST_SONG_TABLE_COLUMNS}
/>
)}
<ListDisplayTypeToggleButton listKey={ItemListKey.PLAYLIST_SONG} />
<ListConfigMenu
listKey={ItemListKey.PLAYLIST_SONG}
tableColumnsData={PLAYLIST_SONG_TABLE_COLUMNS}
/>
</Group>
</Flex>
);
@@ -93,7 +93,6 @@ export const PlaylistDetailSongListHeader = ({
</PageHeader>
) : (
<LibraryHeader
compact
imageUrl={imageUrl}
item={{
imageId: detailQuery?.data?.imageId,
@@ -102,7 +101,6 @@ export const PlaylistDetailSongListHeader = ({
type: LibraryItem.PLAYLIST,
}}
title={detailQuery?.data?.name || ''}
topRight={<ListSearchInput />}
>
<LibraryHeaderMenu
onPlay={(type) => handlePlay(type)}
@@ -4,7 +4,6 @@ import { useEffect } from 'react';
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemControls, ItemListTableComponentProps } from '/@/renderer/components/item-list/types';
@@ -25,11 +24,7 @@ import { ItemListKey, Play } from '/@/shared/types/types';
interface PlaylistDetailSongListTableProps
extends Omit<ItemListTableComponentProps<PlaylistSongListQuery>, 'query'> {
currentPage?: number;
data: PlaylistSongListResponse;
items?: Song[];
itemsPerPage?: number;
onPageChange?: (page: number) => void;
}
export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongListTableProps>(
@@ -37,7 +32,6 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
{
autoFitColumns = false,
columns,
currentPage,
data,
enableAlternateRowColors = false,
enableHeader = true,
@@ -45,9 +39,6 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
enableRowHoverHighlight = true,
enableSelection = true,
enableVerticalBorders = false,
items: itemsProp,
itemsPerPage,
onPageChange,
saveScrollOffset = true,
size = 'default',
},
@@ -67,24 +58,23 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters();
const { setListData } = useListContext();
const songData = useMemo(() => {
let items = data?.items || [];
const songDataFromData = useMemo(() => {
let list = data?.items || [];
if (searchTerm) {
list = searchLibraryItems(list, searchTerm, LibraryItem.SONG);
return list;
items = searchLibraryItems(items, searchTerm, LibraryItem.SONG);
}
return sortSongList(list, query.sortBy, query.sortOrder);
return sortSongList(items, query.sortBy, query.sortOrder);
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
const { setListData } = useListContext();
const songData = itemsProp ?? songDataFromData;
useEffect(() => {
if (itemsProp == null && setListData) {
setListData(songDataFromData);
if (setListData) {
setListData(songData);
}
}, [itemsProp, songDataFromData, setListData]);
}, [songData, setListData]);
const player = usePlayer();
@@ -117,26 +107,13 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
};
}, []);
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 = (
return (
<ItemTableList
activeRowId={currentSong?.id}
autoFitColumns={autoFitColumns}
CellComponent={ItemTableListColumn}
columns={columns}
data={dataToRender}
data={songData}
enableAlternateRowColors={enableAlternateRowColors}
enableExpansion={false}
enableHeader={enableHeader}
@@ -158,22 +135,6 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
size={size}
/>
);
if (isPaginated && itemsPerPage != null) {
return (
<ItemListWithPagination
currentPage={currentPage!}
itemsPerPage={itemsPerPage}
onChange={onPageChange!}
pageCount={pageCount}
totalItemCount={totalCount}
>
{table}
</ItemListWithPagination>
);
}
return table;
},
);
@@ -1,393 +0,0 @@
import { closeAllModals, openModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
PlaylistQueryBuilder,
PlaylistQueryBuilderRef,
} from '/@/renderer/features/playlists/components/playlist-query-builder';
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
import { Box } from '/@/shared/components/box/box';
import { Button } from '/@/shared/components/button/button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { JsonInput } from '/@/shared/components/json-input/json-input';
import { ConfirmModal } from '/@/shared/components/modal/modal';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { SongListSort } from '/@/shared/types/domain-types';
export interface PlaylistQueryEditorProps {
createPlaylistMutation: ReturnType<typeof useCreatePlaylist>;
detailQuery: ReturnType<typeof useQuery<any>>;
handleSave: (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
) => void;
handleSaveAs: (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
) => void;
isQueryBuilderExpanded: boolean;
onToggleExpand: () => void;
playlistId: string;
queryBuilderRef: React.RefObject<null | PlaylistQueryBuilderRef>;
}
type AppliedJsonState = {
limit?: number;
query: Record<string, any>;
sort?: string;
};
type EditorMode = 'builder' | 'json';
const serializeFiltersToRulesJson = (filters: {
extraFilters: { limit?: number; sortBy?: string[] };
filters: any;
}): Record<string, any> => {
const queryValue = convertQueryGroupToNDQuery(filters.filters);
const sortString = filters.extraFilters.sortBy?.[0];
return {
...queryValue,
...(filters.extraFilters.limit != null && { limit: filters.extraFilters.limit }),
...(sortString && { sort: sortString }),
};
};
const parseRulesJsonToSaveArgs = (
parsed: Record<string, any>,
): { extraFilters: { limit?: number; sortBy?: string[] }; filter: Record<string, any> } => {
const rootKey = parsed.all ? 'all' : 'any';
const filter = rootKey in parsed ? { [rootKey]: parsed[rootKey] } : { all: [] };
return {
extraFilters: {
...(parsed.limit != null && { limit: parsed.limit }),
...(parsed.sort != null && { sortBy: [parsed.sort] }),
},
filter,
};
};
export const PlaylistQueryEditor = ({
createPlaylistMutation,
detailQuery,
handleSave,
handleSaveAs,
isQueryBuilderExpanded,
onToggleExpand,
playlistId,
queryBuilderRef,
}: PlaylistQueryEditorProps) => {
const { t } = useTranslation();
const [editorMode, setEditorMode] = useState<EditorMode>('builder');
const [jsonText, setJsonText] = useState('');
const [appliedJsonState, setAppliedJsonState] = useState<AppliedJsonState | null>(null);
const getFiltersForSave = useCallback((): null | {
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string };
filter: Record<string, any>;
} => {
if (editorMode === 'json') {
try {
const parsed = JSON.parse(jsonText) as Record<string, any>;
const { extraFilters, filter } = parseRulesJsonToSaveArgs(parsed);
return { extraFilters, filter };
} catch {
return null;
}
}
const filters = queryBuilderRef.current?.getFilters();
if (!filters) return null;
return {
extraFilters: filters.extraFilters,
filter: convertQueryGroupToNDQuery(filters.filters),
};
}, [editorMode, jsonText, queryBuilderRef]);
const openPreviewModal = useCallback(() => {
const payload = getFiltersForSave();
if (!payload) {
if (editorMode === 'json') {
toast.error({ message: t('error.invalidJson', { postProcess: 'sentenceCase' }) });
}
return;
}
const previewValue = {
...payload.filter,
...(payload.extraFilters.limit != null && { limit: payload.extraFilters.limit }),
...(payload.extraFilters.sortBy?.[0] && { sort: payload.extraFilters.sortBy[0] }),
};
openModal({
children: <JsonPreview value={previewValue} />,
size: 'xl',
title: t('common.preview', { postProcess: 'titleCase' }),
});
}, [editorMode, getFiltersForSave, t]);
const openSaveAndReplaceModal = useCallback(() => {
if (!isQueryBuilderExpanded) return;
const payload = getFiltersForSave();
if (!payload) {
if (editorMode === 'json') {
toast.error({ message: t('error.invalidJson', { postProcess: 'sentenceCase' }) });
}
return;
}
openModal({
children: (
<ConfirmModal
onConfirm={() => {
handleSave(payload.filter, payload.extraFilters);
closeAllModals();
}}
>
<Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
</ConfirmModal>
),
title: t('common.saveAndReplace', { postProcess: 'titleCase' }),
});
}, [editorMode, getFiltersForSave, handleSave, isQueryBuilderExpanded, t]);
const parseSortBy = useCallback((): string[] => {
const sort = detailQuery?.data?.rules?.sort;
// Handle new syntax: comma-separated with +/- prefix
// e.g., "+album,-year" -> return as single string in array
if (typeof sort === 'string') {
// Check if it's new syntax (has +/- prefix or commas)
if (sort.includes(',') || sort.startsWith('+') || sort.startsWith('-')) {
return [sort];
}
// Old syntax: single field, convert to new format with default order
const order = detailQuery?.data?.rules?.order || 'asc';
const prefix = order === 'desc' ? '-' : '+';
return [`${prefix}${sort}`];
}
if (Array.isArray(sort)) {
// If array, check if first item has +/- prefix
if (
sort.length > 0 &&
typeof sort[0] === 'string' &&
(sort[0].startsWith('+') || sort[0].startsWith('-'))
) {
return sort;
}
// Old array format, convert to new format
const order = detailQuery?.data?.rules?.order || 'asc';
const prefix = order === 'desc' ? '-' : '+';
return sort.map((s) => `${prefix}${s}`);
}
return ['+dateAdded'];
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
const parseSortOrder = useCallback((): 'asc' | 'desc' => {
const sort = detailQuery?.data?.rules?.sort;
if (typeof sort === 'string' && sort.startsWith('-')) {
return 'desc';
}
// Fall back to old order field or default
return detailQuery?.data?.rules?.order || 'asc';
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
const effectiveQuery = useMemo(
() =>
appliedJsonState?.query ??
(detailQuery?.data?.rules?.all
? { all: detailQuery.data.rules.all }
: detailQuery?.data?.rules?.any
? { any: detailQuery.data.rules.any }
: detailQuery?.data?.rules),
[appliedJsonState?.query, detailQuery?.data?.rules],
);
const effectiveLimit = appliedJsonState?.limit ?? detailQuery?.data?.rules?.limit;
const effectiveSortBy = useMemo(
() =>
(appliedJsonState?.sort ? [appliedJsonState.sort] : parseSortBy()) as
| SongListSort
| SongListSort[],
[appliedJsonState?.sort, parseSortBy],
);
const effectiveSortOrder = appliedJsonState?.sort
? appliedJsonState.sort.startsWith('-')
? 'desc'
: 'asc'
: parseSortOrder();
const handleEditorModeChange = useCallback(
(value: string) => {
const nextMode = value as EditorMode;
if (nextMode === 'json') {
const filters = queryBuilderRef.current?.getFilters();
if (filters) {
setJsonText(JSON.stringify(serializeFiltersToRulesJson(filters), null, 2));
} else {
const fallback: Record<string, any> = effectiveQuery
? { ...effectiveQuery }
: { all: [] };
if (effectiveLimit != null) fallback.limit = effectiveLimit;
if (effectiveSortBy?.[0]) fallback.sort = effectiveSortBy[0];
if (!fallback.sort) fallback.sort = '+dateAdded';
setJsonText(JSON.stringify(fallback, null, 2));
}
setEditorMode('json');
} else {
if (editorMode === 'json') {
try {
const parsed = JSON.parse(jsonText) as Record<string, any>;
const rootKey = parsed.all ? 'all' : 'any';
if (!parsed[rootKey] || !Array.isArray(parsed[rootKey])) {
throw new Error('Invalid rules structure');
}
setAppliedJsonState({
limit: parsed.limit,
query: { [rootKey]: parsed[rootKey] },
sort: parsed.sort,
});
} catch {
toast.error({
message: t('error.invalidJson', {
postProcess: 'sentenceCase',
}),
});
return;
}
}
setEditorMode('builder');
}
},
[editorMode, effectiveLimit, effectiveQuery, effectiveSortBy, jsonText, queryBuilderRef, t],
);
return (
<div
className="query-editor-container"
style={{ borderTop: '1px solid var(--theme-colors-border)' }}
>
<Stack gap={0} h="100%" mah="30dvh" p="sm" w="100%">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<Button
leftSection={
<Icon
icon={isQueryBuilderExpanded ? 'arrowDownS' : 'arrowUpS'}
size="lg"
/>
}
onClick={onToggleExpand}
size="sm"
variant="subtle"
>
{t('form.queryEditor.title', {
postProcess: 'titleCase',
})}
</Button>
{isQueryBuilderExpanded && (
<SegmentedControl
data={[
{
label: (
<Flex>
<Icon icon="queryBuilder" />
</Flex>
),
value: 'builder',
},
{
label: (
<Flex>
<Icon icon="json" />
</Flex>
),
value: 'json',
},
]}
onChange={handleEditorModeChange}
size="xs"
value={editorMode}
/>
)}
</Group>
<Group gap="xs">
<Button onClick={openPreviewModal} size="sm" variant="subtle">
{t('common.preview', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={!isQueryBuilderExpanded}
leftSection={<Icon icon="save" />}
loading={createPlaylistMutation?.isPending}
onClick={() => {
if (!isQueryBuilderExpanded) return;
const payload = getFiltersForSave();
if (payload) {
handleSaveAs(payload.filter, payload.extraFilters);
} else if (editorMode === 'json') {
toast.error({
message: t('error.invalidJson', {
postProcess: 'sentenceCase',
}),
});
}
}}
size="sm"
variant="subtle"
>
{t('common.saveAs', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={!isQueryBuilderExpanded}
leftSection={<Icon color="error" icon="save" />}
onClick={openSaveAndReplaceModal}
size="sm"
variant="subtle"
>
{t('common.saveAndReplace', {
postProcess: 'titleCase',
})}
</Button>
</Group>
</Group>
<Box
py="md"
style={{
display: isQueryBuilderExpanded ? 'flex' : 'none',
flex: 1,
minHeight: 0,
overflow: 'hidden',
}}
>
{editorMode === 'builder' ? (
<PlaylistQueryBuilder
key={JSON.stringify(appliedJsonState ?? detailQuery?.data?.rules)}
limit={effectiveLimit}
playlistId={playlistId}
query={effectiveQuery}
ref={queryBuilderRef}
sortBy={effectiveSortBy}
sortOrder={effectiveSortOrder}
/>
) : (
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<JsonInput
autosize
minRows={8}
onChange={(value) => setJsonText(value)}
placeholder='{ "all": [], "limit": 100, "sort": "+dateAdded" }'
spellCheck={false}
style={{ flex: 1, minHeight: 0 }}
value={jsonText}
/>
</ScrollArea>
)}
</Box>
</Stack>
</div>
);
};
@@ -5,25 +5,17 @@ import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-searc
import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { useAppStore } from '/@/renderer/store/app.store';
import {
parseArrayParam,
parseBooleanParam,
parseCustomFiltersParam,
parseIntParam,
setMultipleSearchParams,
setSearchParam,
} from '/@/renderer/utils/query-params';
import { SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export const usePlaylistSongListFilters = () => {
const albumArtistIdsMode = useAppStore((state) => state.albumArtistIdsMode);
const artistIdsMode = useAppStore((state) => state.artistIdsMode);
const genreIdsMode = useAppStore((state) => state.genreIdsMode);
const setAlbumArtistIdsModeStore = useAppStore((state) => state.actions.setAlbumArtistIdsMode);
const setArtistIdsModeStore = useAppStore((state) => state.actions.setArtistIdsMode);
const setGenreIdsModeStore = useAppStore((state) => state.actions.setGenreIdsMode);
const { sortBy } = useSortByFilter<SongListSort>(SongListSort.ID, ItemListKey.PLAYLIST_SONG);
const { sortOrder } = useSortOrderFilter(SortOrder.ASC, ItemListKey.PLAYLIST_SONG);
@@ -32,8 +24,8 @@ export const usePlaylistSongListFilters = () => {
const [searchParams, setSearchParams] = useSearchParams();
const albumArtistIds = useMemo(
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS),
const albumIds = useMemo(
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_IDS),
[searchParams],
);
@@ -62,22 +54,16 @@ export const usePlaylistSongListFilters = () => {
[searchParams],
);
const hasRating = useMemo(
() => parseBooleanParam(searchParams, FILTER_KEYS.SONG.HAS_RATING),
[searchParams],
);
const custom = useMemo(
() => parseCustomFiltersParam(searchParams, FILTER_KEYS.SONG._CUSTOM),
[searchParams],
);
const setAlbumArtistIds = useCallback(
const setAlbumIds = useCallback(
(value: null | string[]) => {
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS, value),
{ replace: true },
);
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_IDS, value), {
replace: true,
});
},
[setSearchParams],
);
@@ -127,30 +113,6 @@ export const usePlaylistSongListFilters = () => {
[setSearchParams],
);
const setHasRating = useCallback(
(value: boolean | null) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.HAS_RATING, value), {
replace: true,
});
},
[setSearchParams],
);
const setAlbumArtistIdsMode = useCallback(
(value: 'and' | 'or') => setAlbumArtistIdsModeStore(value),
[setAlbumArtistIdsModeStore],
);
const setArtistIdsMode = useCallback(
(value: 'and' | 'or') => setArtistIdsModeStore(value),
[setArtistIdsModeStore],
);
const setGenreIdsMode = useCallback(
(value: 'and' | 'or') => setGenreIdsModeStore(value),
[setGenreIdsModeStore],
);
const setCustom = useCallback(
(value: null | Record<string, any>) => {
setSearchParams(
@@ -179,74 +141,26 @@ export const usePlaylistSongListFilters = () => {
[setSearchParams],
);
const clear = useCallback(() => {
setSearchParams(
(prev) =>
setMultipleSearchParams(
prev,
{
[FILTER_KEYS.SONG._CUSTOM]: null,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: null,
[FILTER_KEYS.SONG.ARTIST_IDS]: null,
[FILTER_KEYS.SONG.FAVORITE]: null,
[FILTER_KEYS.SONG.GENRE_ID]: null,
[FILTER_KEYS.SONG.HAS_RATING]: null,
[FILTER_KEYS.SONG.MAX_YEAR]: null,
[FILTER_KEYS.SONG.MIN_YEAR]: null,
},
new Set([FILTER_KEYS.SONG._CUSTOM]),
),
{ replace: true },
);
}, [setSearchParams]);
const query = useMemo(
() => ({
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
[FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: albumArtistIds ?? undefined,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE]: albumArtistIdsMode,
[FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined,
[FILTER_KEYS.SONG.ARTIST_IDS_MODE]: artistIdsMode,
[FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined,
[FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined,
[FILTER_KEYS.SONG.GENRE_ID_MODE]: genreIdsMode,
[FILTER_KEYS.SONG.HAS_RATING]: hasRating ?? undefined,
[FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined,
[FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined,
}),
[
searchTerm,
sortBy,
sortOrder,
custom,
albumArtistIds,
albumArtistIdsMode,
artistIds,
artistIdsMode,
favorite,
genreId,
genreIdsMode,
hasRating,
maxYear,
minYear,
],
);
const query = {
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
[FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined,
[FILTER_KEYS.SONG.ALBUM_IDS]: albumIds ?? undefined,
[FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined,
[FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined,
[FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined,
[FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined,
[FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined,
};
return {
clear,
query,
setAlbumArtistIds,
setAlbumArtistIdsMode,
setAlbumIds,
setArtistIds,
setArtistIdsMode,
setCustom,
setFavorite,
setGenreId,
setGenreIdsMode,
setHasRating,
setMaxYear,
setMinYear,
setSearchTerm,
@@ -1,118 +0,0 @@
import { useEffect, useMemo } from 'react';
import { useListContext } from '/@/renderer/context/list-context';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
import { sortSongList } from '/@/shared/api/utils';
import {
LibraryItem,
PlaylistSongListResponse,
Song,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
export function applyClientSideSongFilters(songs: Song[], query: Record<string, unknown>): Song[] {
let result = songs;
const favorite = query[FILTER_KEYS.SONG.FAVORITE] as boolean | undefined;
if (favorite === true) {
result = result.filter((s) => s.userFavorite === true);
} else if (favorite === false) {
result = result.filter((s) => s.userFavorite === false);
}
const hasRating = query[FILTER_KEYS.SONG.HAS_RATING] as boolean | undefined;
if (hasRating === true) {
result = result.filter((s) => s.userRating != null && s.userRating > 0);
} else if (hasRating === false) {
result = result.filter((s) => s.userRating == null || s.userRating === 0);
}
const albumArtistIdsMode =
(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const albumArtistIds = query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS] as string[] | undefined;
if (albumArtistIds?.length) {
if (albumArtistIdsMode === 'and') {
result = result.filter((s) =>
albumArtistIds!.every((id) => s.albumArtists?.some((a) => a.id === id)),
);
} else {
const set = new Set(albumArtistIds);
result = result.filter((s) => s.albumArtists?.some((a) => a.id && set.has(a.id)));
}
}
const artistIdsMode =
(query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const artistIds = query[FILTER_KEYS.SONG.ARTIST_IDS] as string[] | undefined;
if (artistIds?.length) {
if (artistIdsMode === 'and') {
result = result.filter((s) =>
artistIds!.every((id) => s.artists?.some((a) => a.id === id)),
);
} else {
const set = new Set(artistIds);
result = result.filter((s) => s.artists?.some((a) => a.id && set.has(a.id)));
}
}
const genreIdsMode =
(query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';
const genreIds = query[FILTER_KEYS.SONG.GENRE_ID] as string[] | undefined;
if (genreIds?.length) {
if (genreIdsMode === 'and') {
result = result.filter((s) =>
genreIds!.every((id) => s.genres?.some((g) => g.id === id)),
);
} else {
const set = new Set(genreIds);
result = result.filter((s) => s.genres?.some((g) => g.id && set.has(g.id)));
}
}
const minYear = query[FILTER_KEYS.SONG.MIN_YEAR] as number | undefined;
if (minYear != null) {
result = result.filter((s) => s.releaseYear != null && s.releaseYear >= minYear);
}
const maxYear = query[FILTER_KEYS.SONG.MAX_YEAR] as number | undefined;
if (maxYear != null) {
result = result.filter((s) => s.releaseYear != null && s.releaseYear <= maxYear);
}
return result;
}
export function usePlaylistTrackList(data: PlaylistSongListResponse | undefined): {
sortedAndFilteredSongs: Song[];
totalCount: number;
} {
const { setItemCount, setListData } = useListContext();
const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters();
const sortedAndFilteredSongs = useMemo(() => {
const raw = data?.items ?? [];
const filtered = applyClientSideSongFilters(raw, query as Record<string, unknown>);
const searched = searchTerm
? searchLibraryItems(filtered, searchTerm, LibraryItem.SONG)
: filtered;
return sortSongList(
searched,
(query.sortBy as SongListSort) ?? SongListSort.ID,
(query.sortOrder as SortOrder) ?? SortOrder.ASC,
);
}, [data?.items, query, searchTerm]);
const totalCount = sortedAndFilteredSongs.length;
useEffect(() => {
setListData?.(sortedAndFilteredSongs);
setItemCount?.(totalCount);
}, [query, searchTerm, setListData, setItemCount, sortedAndFilteredSongs, totalCount]);
return { sortedAndFilteredSongs, totalCount };
}
@@ -1,71 +1,235 @@
import { closeAllModals, openModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { Suspense, useMemo, useRef, useState } from 'react';
import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath, useLocation, useNavigate, useParams } from 'react-router';
import { ListContext, useListContext } from '/@/renderer/context/list-context';
import { ListContext } from '/@/renderer/context/list-context';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { ClientSideSongFilters } from '/@/renderer/features/playlists/components/client-side-song-filters';
import { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content';
import { PlaylistDetailSongListHeader } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header';
import { PlaylistQueryBuilderRef } from '/@/renderer/features/playlists/components/playlist-query-builder';
import { PlaylistQueryEditor } from '/@/renderer/features/playlists/components/playlist-query-editor';
import {
PlaylistQueryBuilder,
PlaylistQueryBuilderRef,
} from '/@/renderer/features/playlists/components/playlist-query-builder';
import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { AppRoute } from '/@/renderer/router/routes';
import {
PlaylistTarget,
useCurrentServer,
usePageSidebar,
usePlaylistTarget,
} from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { useCurrentServer } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { ConfirmModal } from '/@/shared/components/modal/modal';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
import { ServerType, SongListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
const PlaylistSongListFiltersSidebar = () => {
interface PlaylistQueryEditorProps {
createPlaylistMutation: ReturnType<typeof useCreatePlaylist>;
detailQuery: ReturnType<typeof useQuery<any>>;
handleSave: (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
) => void;
handleSaveAs: (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
) => void;
isQueryBuilderExpanded: boolean;
onToggleExpand: () => void;
playlistId: string;
queryBuilderRef: React.RefObject<null | PlaylistQueryBuilderRef>;
}
const PlaylistQueryEditor = ({
createPlaylistMutation,
detailQuery,
handleSave,
handleSaveAs,
isQueryBuilderExpanded,
onToggleExpand,
playlistId,
queryBuilderRef,
}: PlaylistQueryEditorProps) => {
const { t } = useTranslation();
const { setIsSidebarOpen } = useListContext();
const { clear } = usePlaylistSongListFilters();
const openPreviewModal = useCallback(() => {
const filters = queryBuilderRef.current?.getFilters();
if (!filters) {
return;
}
const queryValue = convertQueryGroupToNDQuery(filters.filters);
const sortString = filters.extraFilters.sortBy?.[0];
const previewValue = {
...queryValue,
...(filters.extraFilters.limit && { limit: filters.extraFilters.limit }),
...(sortString && { sort: sortString }),
};
openModal({
children: <JsonPreview value={previewValue} />,
size: 'xl',
title: t('common.preview', { postProcess: 'titleCase' }),
});
}, [queryBuilderRef, t]);
const openSaveAndReplaceModal = useCallback(() => {
if (!isQueryBuilderExpanded) {
return;
}
const filters = queryBuilderRef.current?.getFilters();
if (!filters) {
return;
}
openModal({
children: (
<ConfirmModal
onConfirm={() => {
handleSave(
convertQueryGroupToNDQuery(filters.filters),
filters.extraFilters,
);
closeAllModals();
}}
>
<Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
</ConfirmModal>
),
title: t('common.saveAndReplace', { postProcess: 'sentenceCase' }),
});
}, [isQueryBuilderExpanded, queryBuilderRef, handleSave, t]);
const parseSortBy = useCallback((): string[] => {
const sort = detailQuery?.data?.rules?.sort;
// Handle new syntax: comma-separated with +/- prefix
// e.g., "+album,-year" -> return as single string in array
if (typeof sort === 'string') {
// Check if it's new syntax (has +/- prefix or commas)
if (sort.includes(',') || sort.startsWith('+') || sort.startsWith('-')) {
return [sort];
}
// Old syntax: single field, convert to new format with default order
const order = detailQuery?.data?.rules?.order || 'asc';
const prefix = order === 'desc' ? '-' : '+';
return [`${prefix}${sort}`];
}
if (Array.isArray(sort)) {
// If array, check if first item has +/- prefix
if (
sort.length > 0 &&
typeof sort[0] === 'string' &&
(sort[0].startsWith('+') || sort[0].startsWith('-'))
) {
return sort;
}
// Old array format, convert to new format
const order = detailQuery?.data?.rules?.order || 'asc';
const prefix = order === 'desc' ? '-' : '+';
return sort.map((s) => `${prefix}${s}`);
}
return ['+dateAdded'];
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
const parseSortOrder = useCallback((): 'asc' | 'desc' => {
const sort = detailQuery?.data?.rules?.sort;
if (typeof sort === 'string' && sort.startsWith('-')) {
return 'desc';
}
// Fall back to old order field or default
return detailQuery?.data?.rules?.order || 'asc';
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
return (
<Stack h="100%" style={{ minHeight: 0 }}>
<Group justify="space-between" pb={0} pl="md" pr="md" pt="md">
<Text fw={500} size="xl">
{t('common.filters', { postProcess: 'sentenceCase' })}
</Text>
<Group gap="xs">
<Button onClick={clear} size="compact-sm" variant="subtle">
{t('common.reset', { postProcess: 'sentenceCase' })}
</Button>
{setIsSidebarOpen && (
<ActionIcon
icon="unpin"
onClick={() => setIsSidebarOpen(false)}
size="compact-sm"
<div className="query-editor-container">
<Stack gap={0} h="100%" mah="30dvh" p="md" w="100%">
<Group justify="space-between" pb="md" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<Button
leftSection={
<Icon
icon={isQueryBuilderExpanded ? 'arrowUpS' : 'arrowDownS'}
size="lg"
/>
}
onClick={onToggleExpand}
size="sm"
variant="subtle"
/>
)}
>
{t('form.queryEditor.title', {
postProcess: 'titleCase',
})}
</Button>
</Group>
<Group gap="xs">
<Button onClick={openPreviewModal} size="sm" variant="subtle">
{t('common.preview', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={!isQueryBuilderExpanded}
leftSection={<Icon icon="save" />}
loading={createPlaylistMutation?.isPending}
onClick={() => {
if (!isQueryBuilderExpanded) return;
const filters = queryBuilderRef.current?.getFilters();
if (filters) {
handleSaveAs(
convertQueryGroupToNDQuery(filters.filters),
filters.extraFilters,
);
}
}}
size="sm"
variant="subtle"
>
{t('common.saveAs', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={!isQueryBuilderExpanded}
leftSection={<Icon color="error" icon="save" />}
onClick={openSaveAndReplaceModal}
size="sm"
variant="subtle"
>
{t('common.saveAndReplace', {
postProcess: 'titleCase',
})}
</Button>
</Group>
</Group>
</Group>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<ClientSideSongFilters />
</ScrollArea>
</Stack>
<div
style={{
display: isQueryBuilderExpanded ? 'flex' : 'none',
flex: 1,
minHeight: 0,
overflow: 'hidden',
}}
>
<PlaylistQueryBuilder
key={JSON.stringify(detailQuery?.data?.rules)}
limit={detailQuery?.data?.rules?.limit}
playlistId={playlistId}
query={detailQuery?.data?.rules}
ref={queryBuilderRef}
sortBy={parseSortBy() as SongListSort | SongListSort[]}
sortOrder={parseSortOrder()}
/>
</div>
</Stack>
</div>
);
};
@@ -232,45 +396,24 @@ const PlaylistDetailSongListRoute = () => {
setIsQueryBuilderExpanded(true);
};
const playlistTarget = usePlaylistTarget();
const displayMode: LibraryItem.ALBUM | LibraryItem.SONG =
playlistTarget === PlaylistTarget.ALBUM ? LibraryItem.ALBUM : LibraryItem.SONG;
const listKey =
displayMode === LibraryItem.ALBUM ? ItemListKey.PLAYLIST_ALBUM : ItemListKey.PLAYLIST_SONG;
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
const [listData, setListData] = useState<unknown[]>([]);
const [mode, setMode] = useState<'edit' | 'view'>('view');
const [isSidebarOpen, setIsSidebarOpen] = usePageSidebar(listKey);
const providerValue = useMemo(() => {
return {
customFilters: undefined,
displayMode,
id: playlistId,
isSidebarOpen,
isSmartPlaylist,
itemCount,
listData,
listKey,
mode,
pageKey: listKey,
setIsSidebarOpen,
pageKey: ItemListKey.PLAYLIST_SONG,
setItemCount,
setListData,
setMode,
};
}, [
playlistId,
isSmartPlaylist,
displayMode,
listKey,
isSidebarOpen,
itemCount,
listData,
mode,
setIsSidebarOpen,
]);
}, [playlistId, isSmartPlaylist, itemCount, listData, mode]);
return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
@@ -286,15 +429,6 @@ const PlaylistDetailSongListRoute = () => {
onDelete={() => openDeletePlaylistModal()}
onToggleQueryBuilder={handleToggleShowQueryBuilder}
/>
<ListWithSidebarContainer>
<ListWithSidebarContainer.SidebarPortal>
<PlaylistSongListFiltersSidebar />
</ListWithSidebarContainer.SidebarPortal>
<Suspense fallback={<Spinner container />}>
<PlaylistDetailSongListContent />
</Suspense>
</ListWithSidebarContainer>
{(isSmartPlaylist || showQueryBuilder) && (
<PlaylistQueryEditor
createPlaylistMutation={createPlaylistMutation}
@@ -307,6 +441,9 @@ const PlaylistDetailSongListRoute = () => {
queryBuilderRef={queryBuilderRef}
/>
)}
<Suspense fallback={<Spinner container />}>
<PlaylistDetailSongListContent />
</Suspense>
</ListContext.Provider>
</AnimatedPage>
);
-67
View File
@@ -1,75 +1,8 @@
import { nanoid } from 'nanoid/non-secure';
import { NDSongQueryFields } from '/@/shared/api/navidrome/navidrome-types';
import { Album, LibraryItem, Song } from '/@/shared/types/domain-types';
import { QueryBuilderGroup } from '/@/shared/types/types';
export type PlaylistAlbumRow = Album & { _playlistSongs?: Song[] };
export function playlistSongsToAlbums(songs: Song[]): PlaylistAlbumRow[] {
if (songs.length === 0) return [];
const rows: PlaylistAlbumRow[] = [];
let group: Song[] = [songs[0]];
let prevAlbumId = songs[0].albumId;
const pushRow = (song: Song, groupSongs: Song[]) => {
rows.push({
_itemType: LibraryItem.ALBUM,
_playlistSongs: groupSongs,
_serverId: song._serverId,
_serverType: song._serverType,
albumArtistName: song.albumArtistName,
albumArtists: song.albumArtists,
artists: song.artists,
comment: song.comment,
createdAt: song.createdAt,
duration: null,
explicitStatus: song.explicitStatus,
genres: song.genres,
id: song.albumId,
imageId: song.imageId,
imageUrl: song.imageUrl,
isCompilation: song.compilation,
lastPlayedAt: song.lastPlayedAt,
mbzId: null,
mbzReleaseGroupId: null,
name: song.album ?? '',
originalDate: null,
originalYear: null,
participants: song.participants,
playCount: null,
recordLabels: [],
releaseDate: song.releaseDate,
releaseType: null,
releaseTypes: [],
releaseYear: song.releaseYear,
size: null,
songCount: null,
sortName: song.album ?? '',
tags: song.tags,
updatedAt: song.updatedAt,
userFavorite: false,
userRating: null,
version: null,
});
};
for (let i = 1; i < songs.length; i++) {
const song = songs[i];
if (song.albumId === prevAlbumId) {
group.push(song);
} else {
pushRow(group[0], group);
group = [song];
prevAlbumId = song.albumId;
}
}
pushRow(group[0], group);
return rows;
}
export const parseQueryBuilderChildren = (groups: QueryBuilderGroup[], data: any[]) => {
if (groups.length === 0) {
return data;
@@ -11,10 +11,7 @@ import {
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { FilterBar } from '/@/renderer/features/shared/components/filter-bar';
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
import { AppRoute } from '/@/renderer/router/routes';
import { Button, ButtonGroup } from '/@/shared/components/button/button';
@@ -47,7 +44,6 @@ export const SearchHeader = ({ navigationId }: SearchHeaderProps) => {
tableColumnsData: ALBUM_ARTIST_TABLE_COLUMNS,
},
[LibraryItem.SONG]: {
displayTypes: SONG_DISPLAY_TYPES,
listKey: ItemListKey.SONG,
tableColumnsData: SONG_TABLE_COLUMNS,
},
@@ -13,7 +13,7 @@ import JellyfinIcon from '/@/renderer/features/servers/assets/jellyfin.png';
import NavidromeIcon from '/@/renderer/features/servers/assets/navidrome.png';
import SubsonicIcon from '/@/renderer/features/servers/assets/opensubsonic.png';
import { IgnoreCorsSslSwitches } from '/@/renderer/features/servers/components/ignore-cors-ssl-switches';
import { useAuthStoreActions, useServerList } from '/@/renderer/store';
import { useAuthStoreActions } from '/@/renderer/store';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
@@ -98,7 +98,6 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
const focusTrapRef = useFocusTrap(true);
const [isLoading, setIsLoading] = useState(false);
const { addServer, setCurrentServer } = useAuthStoreActions();
const serverList = useServerList();
const { servers: discovered } = useAutodiscovery();
const serverLock = isServerLock();
@@ -129,13 +128,6 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
};
const handleSubmit = form.onSubmit(async (values) => {
if (serverLock && Object.keys(serverList).length >= 1) {
toast.error({
message: t('error.serverLockSingleServer', { postProcess: 'sentenceCase' }),
});
return;
}
const authFunction = api.controller.authenticate;
if (!authFunction) {
@@ -2,7 +2,6 @@ import { openContextModal } from '@mantine/modals';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';
import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';
import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';
import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';
@@ -24,7 +23,6 @@ export const ServerList = () => {
const { t } = useTranslation();
const currentServer = useCurrentServer();
const serverListQuery = useServerList();
const serverLock = isServerLock();
const handleAddServerModal = () => {
openContextModal({
@@ -72,17 +70,15 @@ export const ServerList = () => {
</Accordion.Item>
);
})}
{!serverLock && (
<Group grow pt="md">
<Button
autoFocus
leftSection={<Icon icon="add" />}
onClick={handleAddServerModal}
>
{t('form.addServer.title', { postProcess: 'titleCase' })}
</Button>
</Group>
)}
<Group grow pt="md">
<Button
autoFocus
leftSection={<Icon icon="add" />}
onClick={handleAddServerModal}
>
{t('form.addServer.title', { postProcess: 'titleCase' })}
</Button>
</Group>
</Accordion>
{isElectron() && (
<>
@@ -10,11 +10,11 @@ import { Switch } from '/@/shared/components/switch/switch';
export const AnalyticsSettings = memo(() => {
const { t } = useTranslation();
const handleSetSendAnalytics = (send: boolean) => {
if (send) {
localStorage.removeItem('umami.disabled');
} else {
const handleToggleAnalytics = (disable: boolean) => {
if (disable) {
localStorage.setItem('umami.disabled', '1');
} else {
localStorage.removeItem('umami.disabled');
}
};
@@ -22,13 +22,12 @@ export const AnalyticsSettings = memo(() => {
{
control: (
<Switch
aria-label={t('setting.analyticsEnable', { postProcess: 'sentenceCase' })}
defaultChecked={localStorage.getItem('umami.disabled') !== '1'}
onChange={(e) => handleSetSendAnalytics(e.currentTarget.checked)}
defaultChecked={localStorage.getItem('umami.disabled') === '1'}
onChange={(e) => handleToggleAnalytics(e.currentTarget.checked)}
/>
),
description: t('setting.analyticsEnable_description', { postProcess: 'sentenceCase' }),
title: t('setting.analyticsEnable', { postProcess: 'sentenceCase' }),
description: t('setting.analyticsDisable_description', { postProcess: 'sentenceCase' }),
title: t('setting.analyticsDisable', { postProcess: 'sentenceCase' }),
},
];
@@ -109,7 +109,9 @@ export const ThemeSettings = memo(() => {
localSettings.themeSet(
e.currentTarget.checked
? 'system'
: (getAppTheme(settings.theme).mode ?? 'dark'),
: settings.theme === AppTheme.DEFAULT_DARK
? 'dark'
: 'light',
);
}
}}
@@ -136,7 +138,7 @@ export const ThemeSettings = memo(() => {
},
});
const colorScheme = getAppTheme(theme).mode ?? 'dark';
const colorScheme = theme === AppTheme.DEFAULT_DARK ? 'dark' : 'light';
setColorScheme(colorScheme);
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Tabs } from '/@/shared/components/tabs/tabs';
const GeneralTab = lazy(() =>
@@ -72,29 +71,29 @@ export const SettingsContent = () => {
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="general">
<Suspense fallback={<Spinner container />}>
<Suspense fallback={null}>
<GeneralTab />
</Suspense>
</Tabs.Panel>
<Tabs.Panel value="playback">
<Suspense fallback={<Spinner container />}>
<Suspense fallback={null}>
<PlaybackTab />
</Suspense>
</Tabs.Panel>
<Tabs.Panel value="hotkeys">
<Suspense fallback={<Spinner container />}>
<Suspense fallback={null}>
<HotkeysTab />
</Suspense>
</Tabs.Panel>
{isElectron() && (
<Tabs.Panel value="window">
<Suspense fallback={<Spinner container />}>
<Suspense fallback={null}>
<WindowTab />
</Suspense>
</Tabs.Panel>
)}
<Tabs.Panel value="advanced">
<Suspense fallback={<Spinner container />}>
<Suspense fallback={null}>
<AdvancedTab />
</Suspense>
</Tabs.Panel>
@@ -98,28 +98,6 @@ export const DiscordSettings = memo(() => {
postProcess: 'sentenceCase',
}),
},
{
control: (
<Switch
checked={settings.showStateIcon}
onChange={(e) => {
setSettings({
discord: {
showStateIcon: e.currentTarget.checked,
},
});
}}
/>
),
description: t('setting.discordStateIcon', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: t('setting.discordStateIcon', {
postProcess: 'sentenceCase',
}),
},
{
control: (
<Switch
@@ -71,27 +71,26 @@ export const UpdateSettings = memo(() => {
{
control: (
<Switch
aria-label={t('setting.automaticUpdates', { postProcess: 'sentenceCase' })}
defaultChecked={!settings.disableAutoUpdate}
aria-label="Disable automatic updates"
defaultChecked={settings.disableAutoUpdate}
disabled={disableAutoUpdates()}
onChange={(e) => {
if (!e) return;
const enabled = e.currentTarget.checked;
localSettings?.set('disable_auto_updates', !enabled);
localSettings?.set('disable_auto_updates', e.currentTarget.checked);
setSettings({
window: {
disableAutoUpdate: !enabled,
disableAutoUpdate: e.currentTarget.checked,
},
});
}}
/>
),
description: t('setting.automaticUpdates', {
description: t('setting.disableAutomaticUpdates', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: disableAutoUpdates(),
title: t('setting.automaticUpdates', { postProcess: 'sentenceCase' }),
title: t('setting.disableAutomaticUpdates', { postProcess: 'sentenceCase' }),
},
];
@@ -30,7 +30,7 @@ export const sharedQueries = {
},
tagList: (args: QueryHookArgs<TagListQuery>) => {
return queryOptions({
gcTime: 1000 * 60 * 24,
gcTime: 1000 * 60,
queryFn: ({ signal }) => {
return api.controller.getTagList({
apiClientProps: { serverId: args.serverId, signal },
@@ -38,8 +38,7 @@ export const sharedQueries = {
});
},
queryKey: queryKeys.tags.list(args.serverId || '', args.query.type),
staleTime: 1000 * 60 * 24,
structuralSharing: false,
staleTime: 1000 * 60,
...args.options,
});
},
@@ -1,10 +1,3 @@
.top-right {
position: absolute;
top: var(--theme-spacing-lg);
right: var(--theme-spacing-md);
z-index: 20;
}
.library-header {
position: relative;
display: grid;
@@ -63,52 +56,6 @@
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 {

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