Compare commits

..

35 Commits

Author SHA1 Message Date
jeffvli fb022891fe update to v0.12.6 2025-05-08 00:44:13 -07:00
Kendall Garner 5d9906b8f2 include album artist song/album count for jellyfin, and disable playing/adding playinsts for artists with no albums 2025-05-07 21:16:47 -07:00
jeffvli 6f7cb468b2 fix regression on subsonic album artist play 2025-05-07 20:59:16 -07:00
Kendall Garner 076693e969 Merge branch 'development' of github.com:jeffvli/feishin into development 2025-05-07 20:01:04 -07:00
Kendall Garner 781d8055b5 minor artist count fixes 2025-05-07 19:53:23 -07:00
jeffvli 960bb5c660 fix navigation to detail page on artist list 2025-05-07 19:40:54 -07:00
jeffvli 42bb2bf66f fix regression on album artist play button 2025-05-07 19:25:25 -07:00
jeffvli f03d88cd8c batch jellyfin song list requests when fetching by albumId (#922) 2025-05-07 01:42:32 -07:00
jeffvli 58f6535ba6 revert electron to gtk 3 (#923) 2025-05-07 01:28:54 -07:00
jeffvli 9a59ce3613 fix casing on artist albums title 2025-05-07 01:15:00 -07:00
jeffvli 6f37e13611 additional fix to sticky table header to account for native 2025-05-06 18:54:10 -07:00
jeffvli 3c494f1c72 update to v0.12.5 2025-05-06 18:27:02 -07:00
jeffvli ec0e7256cb fix set rating click on context menu (#900)
- star rating icon overriddes the pointer click event
2025-05-06 18:23:14 -07:00
jeffvli 262203b62d handle arrow keys in global hotkey (#913) 2025-05-06 15:19:34 -07:00
jeffvli 41bdc1a7b7 disable toast overlay blocking playerbar (#888) 2025-05-06 15:00:43 -07:00
jeffvli d35e73792f fix item playback buttons on search list (#885) 2025-05-06 14:50:59 -07:00
jeffvli 4a3604b1a8 handle playback on new artist list 2025-05-06 14:43:42 -07:00
jeffvli b9611589ba fix casing on some translation strings 2025-05-06 13:23:29 -07:00
jeffvli 12c517f0ff update to v0.12.4 2025-05-06 03:40:42 -07:00
Hosted Weblate 01884ab656 Translated using Weblate (Finnish)
Currently translated at 100.0% (657 of 657 strings)

Translated using Weblate (Finnish)

Currently translated at 77.3% (508 of 657 strings)

Translated using Weblate (Finnish)

Currently translated at 69.2% (455 of 657 strings)

Translated using Weblate (Finnish)

Currently translated at 64.9% (427 of 657 strings)

Translated using Weblate (Finnish)

Currently translated at 64.9% (427 of 657 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Lunya <Lunya2@protonmail.com>
Co-authored-by: Ricky Tigg <ricky.tigg@gmail.com>
Co-authored-by: jonoafi <joona@jonottaa.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fi/
Translation: feishin/Translation
2025-05-06 12:28:46 +02:00
Hosted Weblate 0ba830d5d7 Translated using Weblate (Persian)
Currently translated at 74.8% (492 of 657 strings)

Co-authored-by: Hadi <xhopeter@proton.me>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fa/
Translation: feishin/Translation
2025-05-06 12:28:46 +02:00
Hosted Weblate b08a0d178c Translated using Weblate (Spanish)
Currently translated at 100.0% (657 of 657 strings)

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translation: feishin/Translation
2025-05-06 12:28:46 +02:00
Hosted Weblate 9afa64b537 Translated using Weblate (Polish)
Currently translated at 100.0% (657 of 657 strings)

Translated using Weblate (Polish)

Currently translated at 93.4% (614 of 657 strings)

Translated using Weblate (Polish)

Currently translated at 92.2% (606 of 657 strings)

Co-authored-by: Adam Perkowski <adas1per@protonmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: skajmer <skajmer@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translation: feishin/Translation
2025-05-06 12:28:46 +02:00
Hosted Weblate be8bc74ab5 Translated using Weblate (Russian)
Currently translated at 95.7% (629 of 657 strings)

Translated using Weblate (Russian)

Currently translated at 95.2% (626 of 657 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Rachel Podya <homicide@disroot.org>
Co-authored-by: zv0r <mount.dev.brain@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ru/
Translation: feishin/Translation
2025-05-06 12:28:46 +02:00
Hosted Weblate 2f4e228fa1 Translated using Weblate (Portuguese (Brazil))
Currently translated at 61.1% (402 of 657 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Joao <joaohirasawaa@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pt_BR/
Translation: feishin/Translation
2025-05-06 12:28:46 +02:00
jeffvli 35ee7e4606 fix position of sticky table header when titlebar present 2025-05-06 03:27:30 -07:00
jeffvli b265f2817b bump to electron 36 (#921) 2025-05-06 02:58:22 -07:00
Kendall Garner 48af447838 fix entities version 2025-04-25 10:35:52 -07:00
Kendall Garner 397df0c9c6 npm update 2025-04-25 09:53:15 -07:00
Kendall Garner 68759a2613 artists fixes 2025-04-25 09:30:17 -07:00
Kendall Garner c376293f2f always add all artists to subsonic 2025-04-23 23:33:58 -07:00
Kendall Garner e84a4b20bc add artist list 2025-04-23 23:27:06 -07:00
Kendall Garner 14e9f6ac41 bump electronVersion 2025-04-22 07:41:56 -07:00
Kendall Garner 0115ecb59b fix repeat one/repeat all scrobble 2025-04-20 10:54:44 -07:00
Kendall Garner 1555b827ee fix: normalize artists with no album count 2025-03-10 14:20:19 -07:00
57 changed files with 14900 additions and 13181 deletions
+12375 -12685
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -2,7 +2,7 @@
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.12.3",
"version": "0.12.6",
"scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\" \"npm run build:remote\"",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
@@ -59,7 +59,7 @@
"package.json"
],
"afterSign": ".erb/scripts/notarize.js",
"electronVersion": "31.2.0",
"electronVersion": "36.1.0",
"mac": {
"target": {
"target": "default",
@@ -234,7 +234,7 @@
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0",
"electron": "^33.3.1",
"electron": "^36.1.0",
"electron-builder": "^24.13.3",
"electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1",
@@ -360,7 +360,11 @@
"zustand": "^4.3.9"
},
"resolutions": {
"styled-components": "^6"
"styled-components": "^6",
"entities": "2.2.0"
},
"overrides": {
"entities": "2.2.0"
},
"devEngines": {
"runtime": {
+29 -26
View File
@@ -1,12 +1,12 @@
{
"name": "feishin",
"version": "0.12.2",
"version": "0.12.6",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.12.2",
"version": "0.12.6",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
@@ -15,7 +15,7 @@
"ws": "^8.18.0"
},
"devDependencies": {
"electron": "31.1.0"
"electron": "36.1.0"
}
},
"node_modules/@electron/get": {
@@ -99,12 +99,13 @@
}
},
"node_modules/@types/node": {
"version": "20.14.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz",
"integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==",
"version": "22.15.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.12.tgz",
"integrity": "sha512-K0fpC/ZVeb8G9rm7bH7vI0KAec4XHEhBam616nVJCV51bKzJ6oA3luG4WdKoaztxe70QaNjS/xBmcDLmr4PiGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/responselike": {
@@ -456,14 +457,15 @@
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
},
"node_modules/electron": {
"version": "31.1.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-31.1.0.tgz",
"integrity": "sha512-TBOwqLxSxnx6+pH6GMri7R3JPH2AkuGJHfWZS0p1HsmN+Qr1T9b0IRJnnehSd/3NZAmAre4ft9Ljec7zjyKFJA==",
"version": "36.1.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-36.1.0.tgz",
"integrity": "sha512-gnp3BnbKdGsVc7cm1qlEaZc8pJsR08mIs8H/yTo8gHEtFkGGJbDTVZOYNAfbQlL0aXh+ozv+CnyiNeDNkT1Upg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^20.9.0",
"@types/node": "^22.7.7",
"extract-zip": "^2.0.1"
},
"bin": {
@@ -1274,10 +1276,11 @@
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/universalify": {
"version": "0.1.2",
@@ -1417,12 +1420,12 @@
}
},
"@types/node": {
"version": "20.14.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz",
"integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==",
"version": "22.15.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.12.tgz",
"integrity": "sha512-K0fpC/ZVeb8G9rm7bH7vI0KAec4XHEhBam616nVJCV51bKzJ6oA3luG4WdKoaztxe70QaNjS/xBmcDLmr4PiGw==",
"dev": true,
"requires": {
"undici-types": "~5.26.4"
"undici-types": "~6.21.0"
}
},
"@types/responselike": {
@@ -1684,13 +1687,13 @@
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
},
"electron": {
"version": "31.1.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-31.1.0.tgz",
"integrity": "sha512-TBOwqLxSxnx6+pH6GMri7R3JPH2AkuGJHfWZS0p1HsmN+Qr1T9b0IRJnnehSd/3NZAmAre4ft9Ljec7zjyKFJA==",
"version": "36.1.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-36.1.0.tgz",
"integrity": "sha512-gnp3BnbKdGsVc7cm1qlEaZc8pJsR08mIs8H/yTo8gHEtFkGGJbDTVZOYNAfbQlL0aXh+ozv+CnyiNeDNkT1Upg==",
"dev": true,
"requires": {
"@electron/get": "^2.0.0",
"@types/node": "^20.9.0",
"@types/node": "^22.7.7",
"extract-zip": "^2.0.1"
}
},
@@ -2291,9 +2294,9 @@
"optional": true
},
"undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true
},
"universalify": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.12.3",
"version": "0.12.6",
"description": "",
"main": "./dist/main/main.js",
"author": {
@@ -18,7 +18,7 @@
"ws": "^8.18.0"
},
"devDependencies": {
"electron": "31.1.0"
"electron": "36.1.0"
},
"license": "GPL-3.0"
}
+54 -54
View File
@@ -21,8 +21,8 @@
"repeat_off": "repetir desactivado",
"queue_clear": "limpiar cola",
"muted": "silenciado",
"unfavorite": "no favorito",
"queue_moveToTop": "mover seleccionado al fondo",
"unfavorite": "no favorita",
"queue_moveToTop": "mover seleccionado al final",
"queue_moveToBottom": "mover seleccionado al principio",
"shuffle_off": "mezclar desactivado",
"addLast": "añadir último",
@@ -37,7 +37,7 @@
"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 para usar en la reproducción (solo reproductor web)",
"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",
@@ -68,7 +68,7 @@
"scrobble_description": "hace scrobble de las reproducciones en tu servidor de medios",
"audioExclusiveMode_description": "activa el modo de audio exclusivo. En este modo, el sistema es normalmente bloqueado, y solo se permitirá mpv en la salida de audio",
"discordUpdateInterval": "intervalo de actualización del estado de actividad de {{discord}}",
"themeLight": "tema (luminoso)",
"themeLight": "tema (claro)",
"fontType_optionBuiltIn": "fuente incorporada",
"hotkey_playbackPlayPause": "play / pausa",
"hotkey_rate1": "calificar con 1 estrella",
@@ -82,8 +82,8 @@
"hotkey_playbackPlay": "reproducir",
"hotkey_togglePreviousSongFavorite": "cambia $t(common.previousSong) a favorito",
"hotkey_volumeDown": "bajar volumen",
"hotkey_unfavoritePreviousSong": "$t(common.previousSong) no favorito",
"audioPlayer_description": "selecciona el reproductor de audio a usar en la reproducción",
"hotkey_unfavoritePreviousSong": "$t(common.previousSong) no favorita",
"audioPlayer_description": "selecciona el reproductor de audio a usar durante la reproducción",
"globalMediaHotkeys": "teclas de acceso rápido globales a medios",
"hotkey_globalSearch": "búsqueda global",
"gaplessAudio_description": "establece la configuración de audio sin pausas para mpv",
@@ -106,11 +106,11 @@
"font": "fuente",
"mpvExtraParameters": "parámetros de mpv",
"replayGainMode_optionTrack": "$t(entity.track_one)",
"themeLight_description": "establece el tema luminoso a usar por la aplicación",
"themeLight_description": "establece el tema claro a usar por la aplicación",
"hotkey_toggleFullScreenPlayer": "cambia el reproductor a pantalla completa",
"hotkey_localSearch": "búsqueda en la página",
"hotkey_toggleQueue": "cambia la cola",
"remotePassword_description": "establece la contraseña para el control remoto del servidor. Esas credenciales son transferidas de forma insegura por defecto, por lo que deberías usar una contraseña única para que no tengas nada de qué preocuparte",
"remotePassword_description": "establece la contraseña para el control remoto del servidor. Esas credenciales son transferidas de forma insegura por defecto, por lo que deberías usar una contraseña única para que no tengas nada de lo que preocuparte",
"hotkey_rate5": "calificar con 5 estrellas",
"hotkey_playbackPrevious": "pista anterior",
"showSkipButtons_description": "muestra o esconde los botones de saltar en la barra del reproductor",
@@ -126,7 +126,7 @@
"hotkey_rate2": "calificar con 2 estrellas",
"playButtonBehavior_description": "establece el comportamiento por defecto del botón de reproducción cuando se añaden canciones a la cola",
"minimumScrobblePercentage_description": "el porcentaje mínimo de la canción que debe ser reproducido antes de hacer scrobble",
"exitToTray": "salida a bandeja",
"exitToTray": "salir a la bandeja",
"hotkey_rate4": "calificar con 4 estrellas",
"enableRemote": "activar control remoto del servidor",
"showSkipButton_description": "muestra o esconde los botones de saltar en la barra del reproductor",
@@ -142,13 +142,13 @@
"replayGainFallback_description": "ganancia en db a aplicar si el archivo no tiene etiquetas de {{ReplayGain}}",
"replayGainPreamp_description": "ajusta la ganancia del preamplificador aplicada a los valores de {{ReplayGain}}",
"hotkey_toggleRepeat": "alterna repetir",
"lyricOffset_description": "desfasa la letra por la cantidad de milisegundos especificada",
"lyricOffset_description": "desfasa la letra en la cantidad de milisegundos especificada",
"sidebarConfiguration_description": "selecciona los elementos y el orden en que aparecerán en la barra lateral",
"fontType": "tipo de fuente",
"remotePort": "puerto del control remoto del servidor",
"applicationHotkeys": "teclas de acceso rápido de la aplicación",
"hotkey_playbackNext": "pista siguiente",
"useSystemTheme_description": "sigue la preferencia luminosa u oscura definida por el sistema",
"useSystemTheme_description": "sigue la preferencia clara u oscura definida por el sistema",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"lyricFetch_description": "busca letras en varias fuentes de Internet",
"lyricFetchProvider_description": "selecciona los proveedores para buscar letras. el orden de los proveedores es el orden en el que se consultarán",
@@ -160,13 +160,13 @@
"sidePlayQueueStyle_optionDetached": "separada",
"audioPlayer": "reproductor de audio",
"hotkey_zoomOut": "reducir",
"hotkey_unfavoriteCurrentSong": "$t(common.currentSong) no favorito",
"hotkey_unfavoriteCurrentSong": "$t(common.currentSong) no favorita",
"hotkey_rate0": "Limpiar calificación",
"discordApplicationId": "id de aplicación {{discord}}",
"applicationHotkeys_description": "configura las teclas de acceso rápido de la aplicación. marca la casilla para establecerlas como teclas de acceso rápido globales (solo escritorio)",
"floatingQueueArea_description": "muestra un icono flotante en el lado derecho de la pantalla para ver la cola de reproducción",
"hotkey_volumeMute": "silenciar volumen",
"hotkey_toggleCurrentSongFavorite": "cambia $t(common.currentSong) a favorito",
"hotkey_toggleCurrentSongFavorite": "$t(common.currentSong) cambia a favorita",
"remoteUsername": "nombre de usuario del control remoto del servidor",
"showSkipButton": "mostrar botones de saltar",
"sidebarPlaylistList": "listas de reproducción de la barra lateral",
@@ -195,12 +195,12 @@
"hotkey_browserBack": "retroceso",
"clearCache": "Limpiar la caché del navegador",
"clearQueryCache": "Limpiar la caché de Feishin",
"clearQueryCache_description": "Una 'limpieza suave' de Feishin. Esto refrescará las listas de reproducción, metadatos de pistas y restablecerá las letras guardadas. Se mantienen los ajustes, credenciales del servidor y las imágenes en caché",
"clearQueryCache_description": "Una 'limpieza suave' de Feishin. Esto refrescará las listas de reproducción, los metadatos de las pistas y restablecerá las letras guardadas. Se mantienen los ajustes, credenciales del servidor y las imágenes en caché",
"buttonSize": "tamaño del botón de la barra de reproducción",
"clearCache_description": "Una 'limpieza fuerte' de Feishin. Para limpiar la caché de Feishin, vacía la caché del navegador (imágenes guardadas y otros elementos). Se mantienen las credenciales y ajustes del servidor",
"buttonSize_description": "el tamaño de los botones de la barra de reproducción",
"passwordStore_description": "qué método de almacenamiento de contraseñas/claves secretas utilizar. cambie esta opción si tiene problemas para guardar contraseñas.",
"startMinimized_description": "iniciar la aplicación en la bandeja del sistema",
"passwordStore_description": "qué método de almacenamiento de contraseñas/claves secretas utilizar. cambia esta opción si tienes problemas para guardar contraseñas.",
"startMinimized_description": "inicia la aplicación en la bandeja del sistema",
"startMinimized": "iniciar minimizado",
"passwordStore": "contraseñas/almacenamiento secreto",
"playerAlbumArtResolution_description": "la resolución para la vista previa de la carátula del álbum del reproductor grande. más grande hace que parezca más nítido, pero puede ralentizar la carga. El valor predeterminado es 0, lo que significa automático",
@@ -208,8 +208,8 @@
"homeConfiguration": "Configuración de la página de inicio",
"mpvExtraParameters_help": "Uno por línea",
"genreBehavior": "Comportamiento predeterminado de la página de géneros",
"externalLinks_description": "Permite mostrar enlaces externos (Last.fm, MusicBrainz) en páginas de artista/álbum",
"genreBehavior_description": "Determina si al pulsar en un género se abre por defecto la lista de pistas o de álbumes",
"externalLinks_description": "Permite mostrar enlaces externos (Last.fm, MusicBrainz) en las páginas del artista/álbum",
"genreBehavior_description": "Determina si al hacer clic en un género se abre por defecto la lista de pistas o de álbumes",
"homeConfiguration_description": "Configura qué elementos son mostrados y en qué orden en la página de inicio",
"clearCacheSuccess": "Caché limpiada correctamente",
"externalLinks": "Mostrar enlaces externos",
@@ -218,15 +218,15 @@
"imageAspectRatio_description": "Si está habilitado, la portada será mostrada usando su relación de aspecto nativa. Para arte que no es 1:1, el espacio restante estará vacío",
"imageAspectRatio": "Usar relación de aspecto nativa de portada",
"doubleClickBehavior": "poner en cola todas las pistas buscadas al hacer doble clic",
"doubleClickBehavior_description": "si es true, se pondrán en cola todas las pistas que coincidan en una búsqueda de pistas. De lo contrario, solo se pondrá en cola la pista seleccionada",
"doubleClickBehavior_description": "si está activado, se pondrán en cola todas las pistas que coincidan en una búsqueda de pistas. De lo contrario, solo se pondrán en cola las pistas seleccionadas",
"volumeWidth": "Ancho del deslizador de volumen",
"volumeWidth_description": "La anchura del deslizador de volumen",
"discordListening_description": "mostrar el estado como escuchando en lugar de jugando",
"discordListening_description": "muestra el estado como Escuchando en lugar de Jugando a",
"discordListening": "Mostrar estado como escuchando",
"contextMenu": "Configuración del menú de contexto (clic derecho)",
"contextMenu_description": "Te permite esconder elementos que son mostrados en el menú cuando haces clic derecho en un elemento. Los elementos que no estén seleccionados serán escondidos",
"customCssEnable": "Habilitar CSS personalizado",
"customCssEnable_description": "Permite la escritura de CSS personalizado.",
"customCssEnable_description": "Permite escribir CSS personalizado.",
"customCss": "CSS personalizado",
"customCssNotice": "Aviso: mientras hay alguna sanitización (rechazar url() y content:), usar CSS personalizado puede aún entrañar riesgos cambiando la interfaz.",
"customCss_description": "Content CSS personalizado. Nota: content y urls remotas son propiedades rechazadas. Una vista previa de tu content se muestra debajo. Las entradas adicionales que no estableciste están presentes debido a la sanitización.",
@@ -236,20 +236,20 @@
"transcode_description": "permite la transcodificación a distintos formatos",
"transcodeBitrate": "tasa de bits a transcodificar",
"transcodeBitrate_description": "selecciona el bitrate a transcodificar. 0 significa dejar que el servidor elija",
"transcodeNote": "Se mostrará después de 1 (web) - 2 (mpv) pistas",
"transcodeNote": "tendrá efecto después de 1 (web) - 2 (mpv) canciones",
"transcodeFormat": "formato a transcodificar",
"transcodeFormat_description": "selecciona el formato a transcodificar. dejar vacío para que el servidor decida",
"albumBackground": "imagen de fondo del álbum",
"albumBackground_description": "Agregar una imagen de fondo a las páginas del álbum que contienen la carátula del álbum",
"albumBackground_description": "Añade una imagen de fondo a las páginas del álbum que contienen la carátula del álbum",
"albumBackgroundBlur": "Tamaño de desenfoque de la imagen de fondo del álbum",
"albumBackgroundBlur_description": "Ajustar el nivel de desenfoque de la imagen de fondo del álbum",
"albumBackgroundBlur_description": "Ajusta la cantidad de desenfoque aplicado a la imagen de fondo del álbum",
"playerbarOpenDrawer": "Cambiar la barra del reproductor a pantalla completa",
"playerbarOpenDrawer_description": "Permitir hacer clic en la barra del reproductor para abrir el reproductor en pantalla completa",
"playerbarOpenDrawer_description": "Permite hacer clic en la barra del reproductor para abrir el reproductor a pantalla completa",
"artistConfiguration": "Configuración de la página del artista del álbum",
"artistConfiguration_description": "Configurar qué elementos se muestran y en qué orden en la página del artista del álbum",
"artistConfiguration_description": "Configura qué elementos se muestran y en qué orden en la página del artista del álbum",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"trayEnabled": "Mostrar en el área de notificación",
"trayEnabled_description": "mostrar/ocultar el icono/menú del área de notificación. si está deshabilitado, también deshabilita minimizar/salir a la bandeja",
"trayEnabled_description": "muestra/oculta el icono/menú del área de notificación. si está deshabilitado, también deshabilita minimizar/salir a la bandeja",
"translationApiProvider": "Proveedor de API de traducción",
"translationApiProvider_description": "Proveedor de API para traducción",
"translationApiKey": "clave api de traducción",
@@ -273,7 +273,7 @@
"deletePlaylist": "eliminar $t(entity.playlist_one)",
"removeFromQueue": "eliminar de la cola",
"deselectAll": "desmarcar todo",
"moveToBottom": "mover al fondo",
"moveToBottom": "mover al final",
"setRating": "establecer calificación",
"toggleSmartPlaylistEditor": "cambiar editor $t(entity.smartPlaylist)",
"removeFromFavorites": "eliminar de $t(entity.favorite_other)",
@@ -296,7 +296,7 @@
"left": "izquierda",
"save": "guardar",
"right": "derecha",
"currentSong": "actual $t(entity.track_one)",
"currentSong": "$t(entity.track_one) actual",
"collapse": "contraer",
"trackNumber": "pista",
"descending": "descendiente",
@@ -334,7 +334,7 @@
"saveAndReplace": "guardar y reemplazar",
"playerMustBePaused": "el reproductor debe pausarse",
"confirm": "confirmar",
"resetToDefault": "restablecer a valor por defecto",
"resetToDefault": "restablecer al valor predeterminado",
"home": "inicio",
"comingSoon": "próximamente…",
"reset": "restablecer",
@@ -365,8 +365,8 @@
"channel_one": "Canal",
"channel_many": "Canales",
"channel_other": "Canales",
"trackPeak": "la más alta de la canción",
"albumPeak": "lo más destacado del álbum",
"trackPeak": "pico de pista",
"albumPeak": "pico del álbum",
"albumGain": "Ganancia de álbum",
"mbid": "ID de MusicBrainz",
"codec": "Códec",
@@ -397,13 +397,13 @@
"audioDeviceFetchError": "un error ocurrió cuando se intentó obtener los dispositivos de audio",
"invalidServer": "servidor inválido",
"loginRateError": "demasiados intentos de inicio de sesión, por favor inténtalo en unos segundos",
"badAlbum": "Estás viendo esta página porque esta canción no forma parte de un álbum. Este problema puede ocurrir si tiene una canción en el nivel superior de su carpeta de música. Jellyfin solo agrupa pistas si están en una carpeta.",
"badAlbum": "Estás viendo esta página porque esta canción no forma parte de un álbum. Este problema puede ocurrir si tienes una canción en el nivel superior de tu carpeta de música. Jellyfin solo agrupa pistas si están en una carpeta.",
"networkError": "Ocurrió un error de red",
"openError": "No se pudo abrir el archivo"
},
"filter": {
"mostPlayed": "más reproducido",
"isCompilation": "es compilación",
"isCompilation": "es una compilación",
"recentlyPlayed": "recientemente reproducido",
"isRated": "es clasificado",
"title": "título",
@@ -434,7 +434,7 @@
"criticRating": "calificación de la crítica",
"trackNumber": "pista",
"comment": "comentarios",
"playCount": "número de reproducción",
"playCount": "número de reproducciones",
"recentlyUpdated": "actualizado recientemente",
"channels": "$t(common.channel_other)",
"owner": "$t(common.owner)",
@@ -500,8 +500,8 @@
"mostPlayed": "más reproducidos",
"newlyAdded": "nuevos lanzamientos añadidos",
"title": "$t(common.home)",
"explore": "explorar desde tu biblioteca",
"recentlyPlayed": "recientemente reproducidos"
"explore": "explora desde tu biblioteca",
"recentlyPlayed": "reproducidos recientemente"
},
"fullscreenPlayer": {
"upNext": "siguiente",
@@ -519,7 +519,7 @@
"lyricGap": "desfase de letra",
"dynamicImageBlur": "tamaño de desenfoque de imagen",
"dynamicIsImage": "habilitar imagen de fondo",
"lyricOffset": "compensación de letras (ms)"
"lyricOffset": "desplazamiento de letras (ms)"
},
"lyrics": "letras",
"related": "relacionado",
@@ -529,7 +529,7 @@
"albumDetail": {
"moreFromArtist": "más de este $t(entity.artist_one)",
"moreFromGeneric": "más de {{item}}",
"released": "publicado"
"released": "publicado el"
},
"setting": {
"playbackTab": "reproducción",
@@ -549,7 +549,7 @@
"trackList": {
"title": "$t(entity.track_other)",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
"artistTracks": "pistas por {{artist}}"
"artistTracks": "Pistas de {{artist}}"
},
"globalSearch": {
"commands": {
@@ -565,11 +565,11 @@
"albumList": {
"title": "$t(entity.album_other)",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)",
"artistAlbums": "álbumes de {{artist}}"
"artistAlbums": "Álbumes de {{artist}}"
},
"albumArtistDetail": {
"viewAllTracks": "ver todo de $t(entity.track_other)",
"relatedArtists": "$t(entity.artist_other) similar",
"viewAllTracks": "ver todas las $t(entity.track_other)",
"relatedArtists": "$t(entity.artist_other) similares",
"topSongs": "mejores canciones",
"topSongsFrom": "las mejores canciones de {{title}}",
"viewAll": "Ver todo",
@@ -675,7 +675,7 @@
"songCount": "$t(entity.track_other)",
"trackNumber": "pista",
"genre": "$t(entity.genre_one)",
"albumArtist": "artista de álbum",
"albumArtist": "artista del álbum",
"path": "ruta",
"discNumber": "disco",
"channels": "$t(common.channel_other)",
@@ -706,7 +706,7 @@
"note": "$t(common.note)",
"owner": "$t(common.owner)",
"path": "$t(common.path)",
"playCount": "número de reproducción",
"playCount": "número de reproducciones",
"genre": "$t(entity.genre_one)",
"favorite": "$t(common.favorite)",
"year": "$t(common.year)",
@@ -747,15 +747,15 @@
"folderWithCount_one": "{{count}} carpeta",
"folderWithCount_many": "{{count}} carpetas",
"folderWithCount_other": "{{count}} carpetas",
"albumArtist_one": "artista de álbum",
"albumArtist_many": "artistas de álbum",
"albumArtist_other": "artistas de álbum",
"albumArtist_one": "artista del álbum",
"albumArtist_many": "artistas del álbum",
"albumArtist_other": "artistas del álbum",
"track_one": "pista",
"track_many": "pistas",
"track_other": "pistas",
"albumArtistCount_one": "{{count}} artista de álbum",
"albumArtistCount_many": "{{count}} artistas de álbum",
"albumArtistCount_other": "{{count}} artistas de álbum",
"albumArtistCount_one": "{{count}} artista del álbum",
"albumArtistCount_many": "{{count}} artistas del álbum",
"albumArtistCount_other": "{{count}} artistas del álbum",
"albumWithCount_one": "{{count}} álbum",
"albumWithCount_many": "{{count}} álbumes",
"albumWithCount_other": "{{count}} álbumes",
@@ -777,9 +777,9 @@
"trackWithCount_one": "{{count}} pista",
"trackWithCount_many": "{{count}} pistas",
"trackWithCount_other": "{{count}} pistas",
"play_one": "Reproducir {{count}}",
"play_many": "Reproducir {{count}}",
"play_other": "Reproducir {{count}}",
"play_one": "{{count}} reproducción",
"play_many": "{{count}} reproducciones",
"play_other": "{{count}} reproducciones",
"song_one": "canción",
"song_many": "canciones",
"song_other": "canciones"
+86 -8
View File
@@ -6,11 +6,11 @@
"skip": "رد کن",
"toggleFullscreenPlayer": "تغییر به پخش‌کنندهٔ تمام‌صفحه",
"skip_back": "برو عقب",
"shuffle": "شافل",
"shuffle": "پخش تصادفی",
"repeat_off": "تکرار غیرفعال",
"pause": "ایست",
"unfavorite": "حذف از موردعلاقه‌ها",
"shuffle_off": "شافل غیرفعال",
"shuffle_off": "پخش تصادفی غیر فعال",
"skip_forward": "برو جلو",
"queue_moveToTop": "جابجا کردن انتخاب شده به پایین",
"queue_clear": "خالی کردن صف",
@@ -48,7 +48,7 @@
"deselectAll": "لغو انتخاب همه",
"moveToBottom": "انتقال به پایین",
"setRating": "تعیین امتیاز",
"toggleSmartPlaylistEditor": "تغییر $t(entity.smartPlaylist) ویرایشگر",
"toggleSmartPlaylistEditor": "تغییر ویرایشگر $t(entity.smartPlaylist)",
"removeFromFavorites": "حذف از $t(entity.favorite_other)",
"openIn": {
"lastfm": "باز کردن در Last.fm",
@@ -137,7 +137,44 @@
"audioExclusiveMode_description": "حالت اختصاصی خروجی را فعال می‌کند. در این حالت، سامانه معمولاً قفل است و فقط mpv می‌تواند خروجی صدا دهد",
"clearQueryCache_description": "یک 'پاک‌سازی نرم' از فیشین. این فهرست‌های پخش و فراداده‌ی قطعه‌ها را تازه می‌کند و متن شعرهای ذخیره شده را بازنشانی می‌کند. پیکربندی‌ها، اعتبارنامه‌های سرویس‌دهنده و نگاره‌های کَش شده حفظ می‌شوند",
"clearCache_description": "یک 'پاک‌سازی سخت' فیشین. افزون بر پاک‌سازی کَش فیشین، کَش مرورگر هم تهی می‌شود (نگاره‌های ذخیره شده و باقی دارایی‌ها). اعتبارنامه‌ها و پیکربندی‌ها حفظ می‌شوند",
"contextMenu_description": "به شما اجازه می‌دهد که آیتم‌های نمایش داده شده در فهرستی که وقتی روی یک آیتم کلیک راست می‌کنید پدیدار می‌شود، را پنهان کنید. آیتم‌هایی که منتخب نیستند پنهان می‌شوند"
"contextMenu_description": "به شما اجازه می‌دهد که آیتم‌های نمایش داده شده در فهرستی که وقتی روی یک آیتم کلیک راست می‌کنید پدیدار می‌شود، را پنهان کنید. آیتم‌هایی که منتخب نیستند پنهان می‌شوند",
"crossfadeStyle": "شیوه‌ی crossfade",
"customCssEnable_description": "اجازه دادن برای نوشتن css سفارشی.",
"translationApiKey": "کلید API ترجمه",
"webAudio_description": "از صدای وب بهره‌مند می‌شود. این قابلیت‌های پیشرفته‌ای مانند گین بازپخش (replygain) را فعال می‌کند. غیرفعال کنید اگر غیر از این را تجربه می‌کنید",
"windowBarStyle_description": "گزینش سبک نوار پنجره",
"translationApiKey_description": "کلید API برای ترجمه (پشتیبانی فقط برای نقطه‌ی پایانی سرویس‌دهنده‌ی جهانی)",
"theme": "تم",
"hotkey_togglePreviousSongFavorite": "تغییر وضعیت برای مورد علاقه‌ی $t(common.previousSong)",
"transcode": "فعال‌سازی رمزگردانی",
"transcode_description": "رمزگردانی به فرمت‌های گوناگون را فعال می‌کند",
"transcodeBitrate": "نرخ انتقال رمزگردانی",
"startMinimized": "پنهان‌شده آغاز کن",
"theme_description": "تم مورد استفاده در نرم‌افزار را می‌گزیند",
"themeLight": "تم (روشن)",
"transcodeBitrate_description": "نرخ انتقال برای رمزگردانی را انتخاب می‌کند. 0 بدان معناست سرور آن را انتخاب کند",
"transcodeFormat": "فرمت رمزگردانی",
"transcodeFormat_description": "فرمت رمزگردانی را انتخاب می‌کند. برای اینکه سرور آن را انتخاب کند، خالی بگذارید",
"customCssEnable": "فعال کردن css سفارشی",
"translationTargetLanguage": "زبان هدف ترجمه",
"hotkey_toggleCurrentSongFavorite": "تغییر وضعیت مورد علاقه برای $t(common.currentSong)",
"themeDark_description": "تم تاریک را برای استفاده‌ی نرم‌افزار می‌گزیند",
"volumeWheelStep_description": "اندازه‌ای از حجم صدا را در زمان اسکرول کردن روی نوار لغزنده تغییر داده شود",
"trayEnabled": "نمایش سینی",
"trayEnabled_description": "نمایش/پنهان کردن آیکون/فهرست در سینی. اگر غیرفعال باشد، کوچک کردن/خروج به سینی را نیز غیرفعال می‌کند",
"useSystemTheme_description": "از روشنی یا تاریکی که سیستم تعریف کرده است، پیروی می‌کند",
"crossfadeDuration": "زمان محو کردن گذار قطعه به قطعه‌ی بعدی",
"themeLight_description": "تم روشن را برای استفاده‌ی نرم‌افزار می‌گزیند",
"volumeWidth": "عرض نوار لغزنده‌ی حجم صدا",
"crossfadeStyle_description": "شیوه‌ی crossfade که می‌خواهید پخش‌کننده از آن استفاده کند را انتخاب کنید",
"startMinimized_description": "نرم‌افزار را در سینی اجرا کن",
"volumeWidth_description": "عرضی که نوار لغزنده‌ی حجم صدا داشته باشد",
"themeDark": "تم (تاریک)",
"useSystemTheme": "استفاده از تم سیستم",
"volumeWheelStep": "گام چرخ حجم صدا",
"webAudio": "استفاده از صدای وب",
"windowBarStyle": "سبک نوار پنجره",
"crossfadeDuration_description": "زمان افکت crossfade را مشخص می‌کند"
},
"common": {
"backward": "به عقب",
@@ -184,9 +221,9 @@
"forceRestartRequired": "برای اعمال تغییرها دوباره راه‌اندازی کنید… اعلان را برای راه‌اندازی دوباره ببندید",
"version": "نسخه",
"title": "عنوان",
"filter_one": "فیلتر",
"filter_other": "فیلتر",
"filters": "فیلتر",
"filter_one": "پالایش",
"filter_other": "پالایش",
"filters": "پالایش",
"create": "ساختن",
"bitrate": "بیت‌ریت",
"saveAndReplace": "ذخیره و جایگزین",
@@ -468,7 +505,7 @@
"synchronized": "همگام شده"
},
"noLyrics": "هیچ متن شعری پیدا نشد",
"lyrics": "متن شعرها",
"lyrics": "متن شعر",
"upNext": "در ادامه"
},
"home": {
@@ -544,5 +581,46 @@
"copiedPath": "مسیر با موفقیت کپی شد",
"openFile": "نمایش قطعه در مدیر پرونده"
}
},
"table": {
"column": {
"size": "$t(common.size)",
"lastPlayed": "آخرین بار پخش شده",
"discNumber": "دیسک",
"songCount": "$t(entity.track_other)",
"title": "عنوان",
"trackNumber": "قطعه",
"favorite": "مورد علاقه",
"genre": "$t(entity.genre_one)",
"comment": "دیدگاه",
"playCount": "تعداد پخش",
"rating": "امتیاز",
"path": "مسیر",
"releaseYear": "سال",
"dateAdded": "تاریخ افزوده شدن",
"releaseDate": "تاریخ عرضه"
},
"config": {
"general": {
"followCurrentSong": "آهنگ کنونی را دنبال کن",
"displayType": "نوع نمایش",
"itemSize": "اندازه‌ی آیتم (px)",
"size": "$t(common.size)",
"tableColumns": "ستون‌های جدول",
"autoFitColumns": "تطبیق دادن ستون‌ها به شیوه‌ی خودکار",
"gap": "$t(common.gap)",
"itemGap": "فاصله‌ی آیتم (px)"
},
"view": {
"card": "کارت"
},
"label": {
"playCount": "تعداد پخش",
"dateAdded": "تاریخ افزوده شدن",
"discNumber": "شماره‌ی دیسک",
"lastPlayed": "آخرین بار پخش شده",
"actions": "$t(common.action_other)"
}
}
}
}
+292 -23
View File
@@ -24,7 +24,7 @@
"dismiss": "hylkää",
"favorite": "suosikki",
"filter_one": "suodatin",
"filter_other": "suodatinta",
"filter_other": "suodattimet",
"filters": "suodattimet",
"forceRestartRequired": "käynnistä uudelleen ottaaksesi muutokset käyttöön… sulje ilmoitus käynnistääksesi uudelleen",
"gap": "väli",
@@ -42,11 +42,11 @@
"note": "huomautus",
"ok": "ok",
"owner": "omistaja",
"path": "reitti",
"path": "polku",
"preview": "esikatsele",
"previousSong": "edellinen $t(entity.track_one)",
"resetToDefault": "palauta oletusarvoihin",
"restartRequired": "uudelleen käynnistys vaaditaan",
"restartRequired": "vaatii uudelleenkäynnistyksen",
"right": "oikea",
"save": "tallenna",
"saveAndReplace": "tallenna ja korvaa",
@@ -57,7 +57,7 @@
"yes": "kyllä",
"close": "sulje",
"descending": "laskeva",
"biography": "elämänkerta",
"biography": "biografia",
"cancel": "peruuta",
"bpm": "bpm",
"decrease": "pienennä",
@@ -93,37 +93,37 @@
"entity": {
"album_one": "albumi",
"album_other": "albumit",
"albumArtist_one": "albumi artisti",
"albumArtist_other": "albumi artistit",
"albumArtist_one": "albumin artisti",
"albumArtist_other": "albumin artistit",
"artistWithCount_one": "{{count}} artisti",
"artistWithCount_other": "{{count}} artistia",
"playlist_one": "soittolista",
"playlist_other": "soittolistaa",
"playlist_other": "soittolistat",
"playlistWithCount_one": "{{count}} soittolista",
"playlistWithCount_other": "{{count}} soittolistaa",
"albumArtistCount_one": "{{count}} albumi artisti",
"albumArtistCount_other": "{{count}} albumi artistia",
"albumArtistCount_one": "{{count}} albumin artisti",
"albumArtistCount_other": "{{count}} albumin artistia",
"albumWithCount_one": "{{count}} albumi",
"albumWithCount_other": "{{count}} albumia",
"artist_one": "artisti",
"artist_other": "artistia",
"artist_other": "artistit",
"favorite_one": "suosikki",
"favorite_other": "suosikkia",
"favorite_other": "suosikit",
"folder_one": "kansio",
"folder_other": "kansiota",
"folder_other": "kansiot",
"folderWithCount_one": "{{count}} kansio",
"folderWithCount_other": "{{count}} kansiota",
"genre_one": "genre",
"genre_other": "genreä",
"genre_other": "genret",
"genreWithCount_one": "{{count}} genre",
"genreWithCount_other": "{{count}} genreä",
"smartPlaylist": "älykäs $t(entity.playlist_one)",
"track_one": "raita",
"track_other": "raitaa",
"track_other": "raidat",
"trackWithCount_one": "{{count}} raita",
"trackWithCount_other": "{{count}} raitaa",
"play_one": "{{count}} toista",
"play_other": "{{count}} toistaa",
"play_one": "{{count}} toisto",
"play_other": "{{count}} toistoa",
"song_one": "kappale",
"song_other": "kappaleet"
},
@@ -139,7 +139,7 @@
"musicbrainz": "Avaa MusicBrainz:ssä"
},
"goToPage": "mene sivulle",
"moveToBottom": "siirry alas",
"moveToBottom": "siirry pohjalle",
"moveToTop": "siirry ylös",
"addToFavorites": "lisää kohteeseen $t(entity.favorite_other)",
"addToPlaylist": "lisää kohteeseen $t(entity.playlist_one)",
@@ -168,12 +168,12 @@
"credentialsRequired": "käyttäjätunnuksia vaaditaan",
"loginRateError": "liian monta kirjautumisyritystä, kokeile muutaman sekuntin päästä uudestaan",
"mpvRequired": "MPV vaadittu",
"networkError": "yhteysvirhe",
"networkError": "verkkoyhteysvirhe",
"openError": "tiedostoa ei voitu avata",
"localFontAccessDenied": "paikallisiin fontteihin pääsy on estetty",
"localFontAccessDenied": "paikallisiin fontteihin pääsy on kielletty",
"playbackError": "mediaa toistaessa tapahtui virhe",
"remotePortWarning": "käynnistä palvelin uudestaan ottaaksesi uuden portin käyttöön",
"endpointNotImplementedError": "endpoint {{endpoint}} ei ole toteutettu {{serverType}} varten"
"endpointNotImplementedError": "päätepiste {{endpoint}} ei ole toteutettu {{serverType}} varten"
},
"filter": {
"album": "$t(entity.album_one)",
@@ -221,7 +221,7 @@
},
"form": {
"addServer": {
"input_legacyAuthentication": "käytä vanhaa kirjautumista",
"input_legacyAuthentication": "käytä vanhaa kirjautumistapaa",
"ignoreCors": "ohita CORS ($t(common.restartRequired))",
"input_name": "palvelimen nimi",
"ignoreSsl": "ohita SSL ($t(common.restartRequired))",
@@ -243,7 +243,7 @@
},
"addToPlaylist": {
"input_skipDuplicates": "ohita kaksoiskappaleet",
"success": "lisätty $t(entity.trackWithCount, {\"count\": {{message}} }) soittolistalle $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"success": "$t(entity.trackWithCount, {\"count\": {{message}} }) lisätty $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "lisää soittolistalle $t(entity.playlist_one)",
"input_playlists": "$t(entity.playlist_other)"
},
@@ -310,7 +310,201 @@
"artistConfiguration": "albumin artistin sivun hallinta",
"audioDevice_description": "valitse toistossa käytettävä äänilaite (vain verkkosoittimessa)",
"applicationHotkeys": "sovelluksen pikanäppäimet",
"albumBackground": "albumin taustakuva"
"albumBackground": "albumin taustakuva",
"customCss": "oma css",
"customFontPath_description": "asettaa polun mukautetulle fontille jota sovellus käyttää",
"homeConfiguration": "koti sivun muokkaus",
"homeConfiguration_description": "määritä mitä osioita näkyy, ja missä järjestyksessä, koti sivulla",
"gaplessAudio_optionWeak": "heikko (suositus)",
"genreBehavior_description": "määrittää avautuuko generä painettaessa oletuksena ääniraita vaiko albumi listassa",
"hotkey_browserBack": "selain takaisin",
"hotkey_playbackPlay": "toista",
"hotkey_playbackPlayPause": "toista / tauko",
"hotkey_playbackPrevious": "edellinen ääniraita",
"hotkey_rate3": "arvostelu 3 tähteä",
"hotkey_playbackStop": "lopeta",
"hotkey_rate4": "arvostelu 4 tähteä",
"hotkey_rate1": "arvostelu 1 tähti",
"hotkey_rate2": "arvostelu 2 tähteä",
"hotkey_unfavoriteCurrentSong": "poista suosikeista $t(common.currentSong)",
"fontType_description": "sisäänrakennettu fontti valitsee yhden Feishinin tuomista fonteista. järjestelmän fontti antaa sinun valita minkä tahansa käyttöjärjestelmään asennetun fontin. mukautettu antaa sinun tuoda oman fontin",
"fontType_optionBuiltIn": "sisäänrakennettu fontti",
"fontType_optionSystem": "järjestelmän fontti",
"fontType_optionCustom": "mukautettu fontti",
"hotkey_favoriteCurrentSong": "lisää suosikiksi $t(common.currentSong)",
"hotkey_favoritePreviousSong": "lisää suosikiksi $t(common.previousSong)",
"hotkey_rate5": "arvostelu 5 tähteä",
"hotkey_skipBackward": "ohita taaksepäin",
"hotkey_skipForward": "ohita eteenpäin",
"font": "kirjaisin",
"font_description": "asettaa fontin jota sovellus käyttää",
"discordApplicationId": "{{discord}} sovelluksen tunnus",
"hotkey_globalSearch": "globaali haku",
"hotkey_playbackNext": "seuraava ääniraita",
"hotkey_browserForward": "selain eteenpäin",
"hotkey_playbackPause": "tauko",
"hotkey_localSearch": "hae sivulta",
"customFontPath": "mukautetun fontin polku",
"fontType": "fonttityyppi",
"hotkey_unfavoritePreviousSong": "poista suosikeista $t(common.previousSong)",
"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ä",
"doubleClickBehavior": "lisää kaikki haetut kappaleet soittojonoon tuplaklikkauksella",
"discordUpdateInterval_description": "päivitysväli sekunnteina (vähintään 15 sekunttia)",
"discordRichPresence": "{{discord}} rich presence",
"discordRichPresence_description": "ota toiston tila käyttöön {{discord}}n rich presence-toiminnossa. Kuvakkeiden avaimet ovat {{icon}}, {{playing}} ja {{paused}}. ",
"discordUpdateInterval": "{{discord}} rich presencen päivitysväli",
"enableRemote": "aktivoi etäohjauspalvelin",
"externalLinks_description": "ottaa ulkoiset linkit (Last.fm, MusicBrainz) artistien/albumien sivuilla",
"exitToTray": "sulje tehtäväpalkkiin",
"doubleClickBehavior_description": "jos päällä, kaikki hakutuloksissa olevat kappaleet lisätään soittojonoon. muuten vain napsautettu kappale lisätään jonoon",
"discordApplicationId_description": "{{discord}}n ohjelma-ID rich presenceä varten (oletuksena {{defaultId}})",
"enableRemote_description": "aktivoi etäohjauspalvelimen, jolla muut laitteet voivat ohjata sovellusta",
"externalLinks": "näytä ulkoiset linkit",
"exitToTray_description": "sovellus suljetaan tehtäväpalkkiin",
"discordListening_description": "näytä status kuuntelee pelaa sijaan",
"discordListening": "näytä status kuuntelee",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"lastfmApiKey_description": "API-avain {{lastfm}}:lle. tarvitaan kansikuvia varten",
"passwordStore_description": "mitä salasanojen/avaimien tallennusta käytetään. muuta tätä, jos sinulla on ongelmia salasanojen tallennuksessa.",
"floatingQueueArea_description": "näyttää ikonin ikkunan oikealla reunalla jonon katselua varten",
"homeFeature_description": "ohjaa näytetäänkö suuri esittelykaruselli kotisivulla",
"hotkey_rate0": "arvostelun tyhjennys",
"hotkey_togglePreviousSongFavorite": "vaihda $t(common.previousSong) suosikkiasetus",
"imageAspectRatio_description": "jos käytössä, kansikuvat näytetään niiden alkuperäisellä kuvasuhteella. jos kuvasuhde ei ole 1:1, jäljelle jäävä tila jää tyhjäksi",
"language_description": "asettaa sovelluksen kielen $t(common.restartRequired)",
"lyricFetch": "hae sanoitukset internetistä",
"lyricFetchProvider_description": "valitse lähteet sanoituksien hakua varten. lähteiden järjestys on se järjestys, jossa ne tiedustellaan",
"minimumScrobblePercentage": "pienin skrobblauksen kesto (prosenttia)",
"mpvExecutablePath": "mpv:n suoritettavan tiedoston polku",
"mpvExecutablePath_description": "asettaa mpv:n suoritettavan tiedoston polun. ollessa tyhjä, käytetään oletuspolkua",
"mpvExtraParameters_help": "yksi per rivi",
"playButtonBehavior_optionPlay": "$t(player.play)",
"genreBehavior": "genre-sivun oletustoiminta",
"globalMediaHotkeys": "globaalit median pikanäppäimet",
"globalMediaHotkeys_description": "ota käyttöön tai poista käytöstä järjestelmän median pikanäppäinten käyttö toiston hallintaa",
"hotkey_toggleCurrentSongFavorite": "vaihda $t(common.currentSong) suosikkiasetus",
"imageAspectRatio": "käytä alkuperäistä kansikuvan kuvasuhdetta",
"language": "kieli",
"lyricOffset_description": "siirrä sanoituksia valitun ajan millisekuntteina",
"minimizeToTray": "pienennä ilmaisinalueelle",
"gaplessAudio_description": "asettaa tauottoman toiston asetukset mpv:hen",
"hotkey_volumeDown": "äänenvoimakkuuden vähentäminen",
"hotkey_zoomIn": "lähennä",
"lyricFetch_description": "hae sanoitukset eri lähteistä internetissä",
"lyricFetchProvider": "lähteet sanoituksia varten",
"lyricOffset": "sanotuksien siirto (ms)",
"mpvExtraParameters": "mpv:n parametrit",
"followLyric": "seuraa lyriikoita",
"followLyric_description": "vieritä lyriikat tämänhetkiseen paikkaan",
"hotkey_toggleQueue": "vaihda jono",
"minimumScrobblePercentage_description": "vähimmäisprosentti kappaleesta, joka on soitettava ennen kuin se skrobblataan",
"minimumScrobbleSeconds": "pienin skrobblaus (sekunttia)",
"minimumScrobbleSeconds_description": "vähimmäisaika kappaleesta, joka on soitettava ennen kuin se skrobblataan",
"passwordStore": "salasanojen/avaimien tallennus",
"hotkey_volumeUp": "äänenvoimakkuuden lisääminen",
"hotkey_toggleShuffle": "vaihda sekoitus",
"hotkey_volumeMute": "mykistäminen",
"lastfmApiKey": "{{lastfm}} API-avain",
"minimizeToTray_description": "pienennä sovellus ilmaisinalueelle",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"hotkey_zoomOut": "loitonna",
"floatingQueueArea": "näytä kelluvan jonon avausalue",
"homeFeature": "kodin esittelykaruselli",
"hotkey_toggleFullScreenPlayer": "vaihda kokonäytön toistin",
"hotkey_toggleRepeat": "vaihda kertaus",
"gaplessAudio": "tauoton toisto",
"transcodeFormat_description": "valitsee transkoodattavan formaatin. jätä tyhjäksi palvelimen valintaa varten",
"replayGainMode_optionNone": "$t(common.none)",
"replayGainMode_optionTrack": "$t(entity.track_one)",
"themeDark": "teema (tumma)",
"transcodeNote": "tulee voimaan 1 (web) - 2 (mpv) kappaleen jälkeen",
"translationApiKey_description": "API-avain käännöstä varten (tukee vain globaalia palvelun palvelupistettä)",
"playbackStyle_description": "valitse toiston tyyli, jota käytetään soittimessa",
"transcode_description": "ottaa transkoodaksen käyttöön eri formaateille",
"transcodeBitrate": "transkoodattava bittinopeus",
"translationApiProvider": "käännös-API:n palveluntarjoaja",
"trayEnabled_description": "näytä/piilota järjestelmäpalkin kuvake/valikko. jos poistettu käytöstä, myös pienennä/sulje järjestelmäpalkkiin -toiminto poistetaan käytöstä",
"windowBarStyle_description": "valitse ikkunapalkin tyyli",
"webAudio": "käytä web-ääntä",
"windowBarStyle": "ikkunapalkin tyyli",
"zoom": "zoomausprosentti",
"playbackStyle": "toiston tyyli",
"remotePassword": "kauko-ohjauspalvelimen salasana",
"remoteUsername_description": "asettaa käyttäjänimen kauko-ohjauspalvelimelle. jos sekä käyttäjätunnus, että salasana ovat tyhjänä, todennus poistetaan käytöstä",
"skipPlaylistPage": "ohita soittolistojen sivu",
"themeDark_description": "asettaa tumman teeman käytettäväksi sovelluksessa",
"playbackStyle_optionCrossFade": "ristivaihto",
"playbackStyle_optionNormal": "normaali",
"playButtonBehavior": "toistopainikkeen toiminta",
"playButtonBehavior_description": "asettaa toistopainikkeen oletustoiminnan lisättäessä kappaleita jonoon",
"remotePort": "kauko-ohjauspalvelimen portti",
"replayGainMode": "{{ReplayGain}} tila",
"sampleRate_description": "valitse käytettävä näytteenottotaajuus, jos valittu näytetaajuus poikkeaa nykyisen median taajuudesta. arvo, joka on alle 8 000, käyttää oletustaajuutta",
"skipDuration": "ohituksen kesto",
"sidePlayQueueStyle_description": "asettaa tyylin sivupalkin toistojonolle",
"sidePlayQueueStyle_optionAttached": "liitetty",
"sidePlayQueueStyle_optionDetached": "irrotettu",
"startMinimized_description": "käynnistä sovellus järjestelmäpalkissa",
"theme": "teema",
"useSystemTheme_description": "seuraa järjestelmän määrittämää asetusta vaalealle tai tummalle asetukselle",
"remoteUsername": "kauko-ohjauspalvelimen käyttäjänimi",
"remotePort_description": "asettaa kauko-ohjauspalvelimen portin",
"remotePassword_description": "asettaa kauko-ohjauspalvelimen salasanan. Nämä tunnukset siirretään oletuksena turvattomasti, joten sinun kuuluisi käyttää uniikkia salasanaa, josta et välitä",
"replayGainClipping": "{{ReplayGain}} leikkaus",
"replayGainClipping_description": "Estää {{ReplayGain}}n aiheuttaman leikkauksen laskemalla vahvistusta automaatisesti",
"replayGainFallback": "{{ReplayGain}} palautus",
"playerAlbumArtResolution_description": "suurien kansikuvien resoluutio soittimen esikatselussa. suurempi tekee niistä terävempiä, mutta voi hidastaa latausta. oletuksena on 0, joka tarkoittaa automaattista",
"replayGainMode_optionAlbum": "$t(entity.album_one)",
"replayGainPreamp": "{{ReplayGain}} esivahvistus (dB)",
"scrobble_description": "skrobblaa toistot mediapalvelimellesi",
"replayGainPreamp_description": "säätää esivahvistuksen määrää {{ReplayGain}} arvoon",
"showSkipButtons": "näytä ohituspainikkeet",
"showSkipButtons_description": "näytä tai piilota soitinpalkin ohituspainikkeet",
"showSkipButton": "näytä ohituspainikkeet",
"showSkipButton_description": "näytä tai piilota soitinpalkin ohituspainikkeet",
"sidebarPlaylistList": "sivupakin soittolistojen lista",
"skipDuration_description": "asettaa ohitettavan ajan käytettäessä soitinpalkin ohituspainikkeita",
"volumeWidth": "äänenvoimakkuuden säätimen leveys",
"sidebarCollapsedNavigation_description": "näytä tai piilota navigointi romautetussa sivupalkissa",
"sidebarConfiguration": "sivupalkin asetukset",
"sidebarConfiguration_description": "valitse kohteet ja niiden järjestys sivupalkissa",
"volumeWidth_description": "äänenvoimakkuuden säätimen leveys",
"playerAlbumArtResolution": "soittimen kansikuvien resoluutio",
"playerbarOpenDrawer": "toistipalkin kokoruudun kytkin",
"playerbarOpenDrawer_description": "sallii toistopalkin klikkaamisen avaamaan kokonäytön soittimen",
"replayGainFallback_description": "asetettava vahvistus desibelinä (dB), jos tiedostolla ei ole {{ReplayGain}} tageja",
"replayGainMode_description": "säätää äänenvoimmakkuutta {{ReplayGain}} arvojen mukaisesti tiedoston metadatasta",
"sampleRate": "näytteenottotaajuus",
"savePlayQueue": "tallenna toistojono",
"savePlayQueue_description": "tallenna toistojono, kun sovellus suljetaan ja avaa se uudestaan, kun sovellus avataan",
"scrobble": "skrobblaus",
"sidebarCollapsedNavigation": "sivupalkin (romautettu) navigointi",
"sidebarPlaylistList_description": "näytä tai piilota soittolistojen lista sivupalkissa",
"sidePlayQueueStyle": "sivupalkin jonon tyyli",
"skipPlaylistPage_description": "navigoidessa soittolistaan, mene soittolistan kappaleiden listaan oletussivun sijaan",
"theme_description": "asettaa ohjelmassa käytettävän teeman",
"themeLight": "teema (vaalea)",
"themeLight_description": "asettaa vaalean teeman käytettäväksi sovelluksessa",
"transcode": "ota transkoodaus käyttöön",
"transcodeBitrate_description": "valitsee transkoodattavan bittinopeuden. 0 tarkoittaa palvelimen valintaa",
"transcodeFormat": "transkoodattava formaatti",
"translationApiProvider_description": "palveluntarjoajan API käännöstä varten",
"translationApiKey": "käännöksen API-avain",
"translationTargetLanguage": "käännöksen kohdekieli",
"translationTargetLanguage_description": "kohdekieli käännöstä varten",
"trayEnabled": "näytä järjestelmäpalkki",
"volumeWheelStep_description": "äänenvoimakkuuden muutoksen suuruus rullattaessa hiiren rullalla äänenvoimakkuuden säätimen päällä",
"zoom_description": "asettaa sovelluksen zoomausprosentin",
"webAudio_description": "käytä web-ääntä. tämä mahdollistaa edistyneet ominaisuudet, kuten replaygainin. poista käytöstä, jos koet ongelmia",
"startMinimized": "käynnistä pienennettynä",
"useSystemTheme": "käytä järjestelmän teemaa",
"volumeWheelStep": "äänenvoimakkuusrullan askel"
},
"page": {
"itemDetail": {
@@ -494,5 +688,80 @@
"repeat_off": "kertaus pois päältä",
"shuffle_off": "sekoitus pois päältä",
"toggleFullscreenPlayer": "vaihda kokoruudun soittimeen"
},
"table": {
"config": {
"general": {
"gap": "$t(common.gap)",
"size": "$t(common.size)",
"autoFitColumns": "sovita sarakkeet",
"followCurrentSong": "seuraa nykyistä kappaletta",
"displayType": "näytön tyyppi",
"itemGap": "kohteiden väli (px)",
"itemSize": "kohteiden koko (px)",
"tableColumns": "taulukon sarakkeet"
},
"label": {
"channels": "$t(common.channel_other)",
"trackNumber": "raidan numero",
"album": "$t(entity.album_one)",
"actions": "$t(common.action_other)",
"codec": "$t(common.codec)",
"dateAdded": "lisäyspäivämäärä",
"owner": "$t(common.owner)",
"path": "$t(common.path)",
"albumArtist": "$t(entity.albumArtist_one)",
"artist": "$t(entity.artist_one)",
"discNumber": "levyn numero",
"duration": "$t(common.duration)",
"favorite": "$t(common.favorite)",
"lastPlayed": "viimeksi soitettu",
"note": "$t(common.note)",
"titleCombined": "$t(common.title) (yhdistetty)",
"rowIndex": "rivin indeksi",
"biography": "$t(common.biography)",
"bitrate": "$t(common.bitrate)",
"bpm": "$t(common.bpm)",
"genre": "$t(entity.genre_one)",
"playCount": "toistojen lukumäärä",
"rating": "$t(common.rating)",
"releaseDate": "julkaisupäivämäärä",
"size": "$t(common.size)",
"songCount": "$t(entity.track_other)",
"title": "$t(common.title)",
"year": "$t(common.year)"
},
"view": {
"table": "taulukko",
"card": "kortti",
"poster": "juliste"
}
},
"column": {
"releaseYear": "vuosi",
"bpm": "bpm",
"artist": "$t(entity.artist_one)",
"biography": "biografia",
"dateAdded": "lisäyspäivämäärä",
"album": "albumi",
"albumArtist": "albumin artisti",
"lastPlayed": "viimeksi toistettu",
"path": "polku",
"size": "$t(common.size)",
"songCount": "$t(entity.track_other)",
"title": "nimi",
"trackNumber": "raita",
"codec": "$t(common.codec)",
"comment": "kommentti",
"albumCount": "$t(entity.album_other)",
"bitrate": "bittinopeus",
"channels": "$t(common.channel_other)",
"discNumber": "levy",
"favorite": "suosikki",
"genre": "$t(entity.genre_one)",
"playCount": "toistoja",
"rating": "arvostelu",
"releaseDate": "julkaisupäivämäärä"
}
}
}
+98 -19
View File
@@ -20,7 +20,8 @@
"openIn": {
"lastfm": "Otwórz w Last.fm",
"musicbrainz": "Otwórz w MusicBrainz"
}
},
"moveToNext": "przesuń na następne"
},
"common": {
"increase": "zwiększ",
@@ -113,7 +114,8 @@
"trackPeak": "peak utworu",
"codec": "kodek",
"preview": "podgląd",
"close": "zamknij"
"close": "zamknij",
"translation": "tłumaczenie"
},
"entity": {
"genre_one": "gatunek",
@@ -161,7 +163,13 @@
"genreWithCount_many": "{{count}} gatunków",
"trackWithCount_one": "{{count}} utwór",
"trackWithCount_few": "{{count}} utwory",
"trackWithCount_many": "{{count}} utworów"
"trackWithCount_many": "{{count}} utworów",
"play_one": "{{count}} odtworzenie",
"play_few": "{{count}} odtworzenia",
"play_many": "{{count}} odtworzeń",
"song_one": "piosenka",
"song_few": "piosenki",
"song_many": "piosenek"
},
"error": {
"remotePortWarning": "uruchom ponownie serwer aby używać nowego portu",
@@ -259,7 +267,7 @@
"error_savePassword": "wystąpił błąd podczas próby zapisania hasła"
},
"addToPlaylist": {
"success": "dodano {{message}} $t(entity.track_other) do {{numOfPlaylists}} $t(entity.playlist_other)",
"success": "dodano $t(entity.trackWithCount, {\"count\": {{message}} }) do $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "dodano do $t(entity.playlist_one)",
"input_skipDuplicates": "pomiń duplikaty",
"input_playlists": "$t(entity.playlist_other)"
@@ -278,7 +286,9 @@
"title": "wyszukiwanie tekstów"
},
"editPlaylist": {
"title": "edytuj $t(entity.playlist_one)"
"title": "edytuj $t(entity.playlist_one)",
"success": "$t(entity.playlist_one) zaktualizowana pomyślnie",
"publicJellyfinNote": "Z jakiegoś powodu Jellyfin nie udostępnia informacji na temat publiczności playlisty. Jeżeli chcesz, aby ta pozostała publiczna, mniej wybraną poniższą opcję"
},
"shareItem": {
"allowDownloading": "zezwól na pobieranie",
@@ -304,11 +314,14 @@
"useImageAspectRatio": "użyj współczynnika proporcji obrazu",
"lyricGap": "odstępy tekstu",
"dynamicImageBlur": "rozmiar rozmycia obrazu",
"dynamicIsImage": "włącz obraz w tle"
"dynamicIsImage": "włącz obraz w tle",
"lyricOffset": "opóźnienie tekstów (ms)"
},
"upNext": "następny",
"lyrics": "tekst",
"related": "powiązane"
"related": "powiązane",
"visualizer": "wizualizer",
"noLyrics": "nie znaleziono tekstu"
},
"appMenu": {
"selectServer": "wybierz serwer",
@@ -340,11 +353,16 @@
"numberSelected": "zaznaczono {{count}}",
"removeFromQueue": "$t(action.removeFromQueue)",
"shareItem": "udostępnij pozycję",
"showDetails": "zobacz informacje"
"showDetails": "zobacz informacje",
"download": "pobierz",
"playShuffled": "$t(player.shuffle)",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"moveToNext": "$t(action.moveToNext)"
},
"albumDetail": {
"moreFromArtist": "więcej od $t(entity.artist_one)",
"moreFromGeneric": "więcej od {{item}}"
"moreFromGeneric": "więcej od {{item}}",
"released": "wydany"
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
@@ -384,7 +402,8 @@
"playbackTab": "odtworzenia",
"generalTab": "ogólne",
"hotkeysTab": "skróty klawiszowe",
"windowTab": "okno"
"windowTab": "okno",
"advanced": "zaawansowane"
},
"trackList": {
"title": "$t(entity.track_other)",
@@ -417,6 +436,17 @@
"copyPath": "kopiuj ścieżkę do schowka",
"copiedPath": "ścieżka została skopiowana pomyślnie",
"openFile": "pokaż utwór w menedżerze plików"
},
"manageServers": {
"title": "zarządzaj serwerami",
"url": "URL",
"username": "nazwa użytkownika",
"removeServer": "usuń serwer",
"serverDetails": "szczegóły serwera",
"editServerDetailsTooltip": "edytuj szczegóły serwera"
},
"playlist": {
"reorder": "zmiana kolejności jest możliwa tylko podczas sortowania według id"
}
},
"player": {
@@ -431,7 +461,7 @@
"skip_back": "przeskocz do tyłu",
"favorite": "ulubione",
"next": "następny",
"shuffle": "losowa kolejność",
"shuffle": "odtwarzaj losowo",
"playbackFetchNoResults": "nie znaleziono utworów",
"playbackFetchInProgress": "wczytywanie utworów…",
"addNext": "dodaj następny",
@@ -448,7 +478,9 @@
"shuffle_off": "losowa kolejność wyłączona",
"addLast": "dodaj na końcu",
"mute": "wycisz",
"skip_forward": "przeskocz do przodu"
"skip_forward": "przeskocz do przodu",
"viewQueue": "zobacz kolejkę",
"playSimilarSongs": "odtwarzaj podobne"
},
"setting": {
"crossfadeStyle_description": "wybierz styl przenikania, który ma być używany do odtwarzania dźwięku",
@@ -471,7 +503,7 @@
"hotkey_rate1": "oceń na 1 gwiazdkę",
"hotkey_skipForward": "przeskocz do przodu",
"disableLibraryUpdateOnStartup": "wyłącz wyszukiwanie aktualizacji podczas uruchamiania aplikacji",
"discordApplicationId_description": "id dla aplikacji {{discord}} obszernie obecne (domyślnie {{defaultId}})",
"discordApplicationId_description": "id dla aplikacji {{discord}} rich presence (domyślnie {{defaultId}})",
"gaplessAudio": "dźwięk bez przerw",
"hotkey_playbackPlay": "odtwarzaj",
"hotkey_togglePreviousSongFavorite": "dodaj $t(common.previousSong) do ulubionych",
@@ -501,7 +533,7 @@
"crossfadeDuration_description": "ustaw czas trwania efektu przenikania",
"language": "język",
"hotkey_toggleShuffle": "przełącz kolejność losową",
"discordRichPresence_description": "włącz status odtwarzania w {{discord}} rich presence. Dzięki temu będą wyświetlane informacje takie jak: {{icon}}, {{playing}} i {{paused}}. ",
"discordRichPresence_description": "włącz status odtwarzania w {{discord}} (rich presence). Dzięki temu będą wyświetlane informacje takie jak: {{icon}}, {{playing}} i {{paused}}. ",
"audioDevice": "urządzenia dźwiękowe",
"hotkey_rate2": "oceń na 2 gwiazdki",
"exitToTray": "zamknij do zasobnika",
@@ -522,7 +554,7 @@
"customFontPath": "niestandardowa ścieżka czcionki",
"followLyric": "podążaj za tekstem",
"crossfadeDuration": "czas trwania przenikania",
"discordIdleStatus": "pokaż obszerne informacje w stanie bezczynności",
"discordIdleStatus": "pokaż status w stanie bezczynności",
"audioPlayer": "odtwarzacz dźwięku",
"hotkey_zoomOut": "oddal",
"hotkey_unfavoriteCurrentSong": "usuń $t(common.currentSong) z ulubionych",
@@ -537,7 +569,7 @@
"customFontPath_description": "ustaw ścieżkę dla niestandardowych czcionek dla aplikacji",
"gaplessAudio_optionWeak": "słabe (rekomendowane)",
"hotkey_playbackStop": "zatrzymaj",
"discordRichPresence": "{{discord}} obszernie obecny",
"discordRichPresence": "Status {{discord}} (rich presence)",
"font_description": "ustaw czcionkę dla aplikacji",
"playButtonBehavior_optionPlay": "$t(player.play)",
"minimumScrobblePercentage": "minimalny czas trwania scrobble (procentowy)",
@@ -630,7 +662,52 @@
"genreBehavior": "domyślne zachowanie strony gatunek",
"externalLinks_description": "umożliwia wyświetlanie linków zewnętrznych (Last.fm, MusicBrainz) na stronach artystów/albumów",
"homeConfiguration": "konfiguracja strony głównej",
"homeConfiguration_description": "konfiguracja elementów wyświetlanych na stronie głównej i ich kolejności"
"homeConfiguration_description": "konfiguracja elementów wyświetlanych na stronie głównej i ich kolejności",
"albumBackground_description": "dodaje obraz tła dla stron albumu zawierających grafikę albumu",
"albumBackgroundBlur": "rozmiar rozmycia obrazu tła albumu",
"albumBackgroundBlur_description": "dostosowywuje ilość rozmycia nakladanego na obraz tła albumu",
"albumBackground": "obraz tła albumu",
"artistConfiguration_description": "skonfiguruj jakie elementy są pokazywane, i w jakiej kolejności, na stronie albumu wykonawcy",
"discordListening_description": "pokazuje status jako słucha zamiast w grze",
"transcodeNote": "przynosi efekt po 1 (web) - 2 (mpv) piosenkach",
"transcode_description": "włącza transkodowanie na inne formaty",
"transcodeBitrate": "bitrate do transkodowania",
"transcode": "włącz transkodowanie",
"translationApiProvider": "usługodawca do api tłumaczeń",
"translationApiProvider_description": "wybór usługodawcy do api tłumaczeń",
"translationApiKey": "klucz api do tłumaczeń",
"transcodeFormat_description": "wybiera format do transkodowania. zostaw pusty aby serwer wybrał format",
"translationApiKey_description": "klucz api do tłumaczenia (Obsługuje tylko globalny endpoint)",
"homeFeature": "karuzela polecanych na stronie głównej",
"customCssEnable": "włącz niestandardowy css",
"customCssEnable_description": "pozwalaj na pisanie niestandardowego css.",
"customCssNotice": "Ostrzeżenie: chociaż istnieje pewne filtrowanie (uniemożliwia używanie url() i content:), używanie niestandardowego CSS-a może stwarzać ryzyko przez zmiany w interfejsie.",
"customCss_description": "zawartość niestandardowego css. Uwaga: content i zdalne url są niedozwolonymi właściwościami. Podgląd twojej zawartości jest pokazana poniżej. Dodatkowe pola których nie ustawiłeś, są obecne z powodu sanityzacji.",
"customCss": "niestandardowy css",
"doubleClickBehavior": "zakolejkuj wszystkie wyszukane utwory gdy podwójnie kliknięto",
"trayEnabled_description": "pokaż/ukryj ikonę/menu w zasobniku. jeżeli wyłączone, wyłącza też minimalizowanie.wyjście do zasobnika",
"webAudio_description": "używaj web audio. włącza to zaawansowane funkcje takie jak replaygain. wyłącz jeżeli nie działa poprawnie",
"artistConfiguration": "konfiguracja strony albumu wykonawcy",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playerbarOpenDrawer_description": "pozwala przełączyć na odtwarzacz pełnoekranowy po kliknięciu paska odtwarzania",
"playerbarOpenDrawer": "przełącznik pełnego ekranu na pasku odtwarzania",
"imageAspectRatio": "używaj natywnych proporcji okładki",
"volumeWidth": "szerokość paska głośności",
"discordListening": "pokazuj status jako słucha",
"imageAspectRatio_description": "jeżeli włączone, okładka będzie pokazywana z użyciem jej natywnych proporcji. dla okładek które nie mają proporcji 1:1, pozostałe miejsce będzie puste",
"volumeWidth_description": "szerokość paska głośności",
"contextMenu_description": "pozwala ci na ukrycie elementów które są pokazywane w menu po kliknięciu prawym przyciskiem myszy na element. elementy które zostały odznaczone będą ukryte",
"contextMenu": "konfiguracja menu kontekstowego (pod prawym przyciskiem myszy)",
"transcodeBitrate_description": "wybiera bitrate do transkodowania. 0 pozwala wybrać to serwerowi",
"transcodeFormat": "format do transkodowania",
"translationTargetLanguage_description": "język do którego będzie tłumaczona treść",
"trayEnabled": "pokazuj w zasobniku",
"webAudio": "używaj web audio",
"homeFeature_description": "ustawienie powoduje to czy wyświetlana jest karuzela z polecanymi utworami na stronie głównej",
"doubleClickBehavior_description": "jeżeli włączone, wszystkie pasujące utwory w wyszukiwaniu zostaną zakolejkowane. w przeciwnym wypadku, tylko kliknięty będzie zakolejkowany",
"lastfmApiKey": "klucz API {{lastfm}}",
"lastfmApiKey_description": "klucz API dla {{lastfm}}. wymagany dla okładek",
"translationTargetLanguage": "docelowy język tłumaczenia"
},
"table": {
"config": {
@@ -646,7 +723,8 @@
"autoFitColumns": "automatyczne dopasowanie kolumn",
"size": "$t(common.size)",
"itemSize": "rozmiar elementu (px)",
"itemGap": "odstęp między elementami (px)"
"itemGap": "odstęp między elementami (px)",
"followCurrentSong": "śledź aktualną piosenkę"
},
"label": {
"releaseDate": "data premiery",
@@ -675,7 +753,8 @@
"favorite": "$t(common.favorite)",
"year": "$t(common.year)",
"albumArtist": "$t(entity.albumArtist_one)",
"codec": "$t(common.codec)"
"codec": "$t(common.codec)",
"songCount": "$t(entity.track_other)"
}
},
"column": {
+243 -25
View File
@@ -1,6 +1,6 @@
{
"common": {
"backward": "voltar",
"backward": "para trás",
"areYouSure": "tem certeza?",
"add": "adicionar",
"ascending": "ascendente",
@@ -27,7 +27,7 @@
"title": "titulo",
"create": "criar",
"confirm": "confirmar",
"home": "inicio",
"home": "início",
"comingSoon": "em breve…",
"channel_one": "canal",
"channel_many": "canais",
@@ -56,9 +56,9 @@
"path": "caminho",
"no": "não",
"owner": "dono",
"forward": "avançar",
"forward": "para frente",
"forceRestartRequired": "reinicie para aplicar as alterações… feche a notificação para reiniciar",
"setting": "contexto",
"setting": "configuração",
"version": "versão",
"filter_one": "filtro",
"filter_many": "filtros",
@@ -74,7 +74,7 @@
"restartRequired": "é necessário reiniciar",
"previousSong": "anterior $t(entity.track_one)",
"noResultsFromQuery": "a consulta não retornou resultados",
"quit": "abandonar",
"quit": "sair",
"search": "procurar",
"saveAs": "salvar como",
"yes": "sim",
@@ -87,7 +87,11 @@
"preview": "pré-visualizar",
"share": "compartilhar",
"close": "fechar",
"translation": "tradução"
"translation": "tradução",
"albumGain": "ganho do álbum",
"trackPeak": "pico da faixa",
"albumPeak": "pico do álbum",
"trackGain": "ganho da faixa"
},
"action": {
"goToPage": "vá para página",
@@ -116,7 +120,8 @@
"form": {
"deletePlaylist": {
"title": "deletar $t(entity.playlist_one)",
"input_confirm": "escreva o nome da $t(entity.playlist_one) para confirmar"
"input_confirm": "escreva o nome da $t(entity.playlist_one) para confirmar",
"success": "$t(entity.playlist_one) deletada com sucesso"
},
"addServer": {
"title": "adicionar servidor",
@@ -128,19 +133,25 @@
"input_url": "url",
"success": "servidor adicionado com sucesso",
"input_name": "nome do servidor",
"input_username": "nome de usuário"
"input_username": "nome de usuário",
"ignoreCors": "ignorar CORS ($t(common.restartRequired))"
},
"createPlaylist": {
"title": "criar $t(entity.playlist_one)",
"input_public": "público",
"input_description": "$t(common.description)",
"success": "$t(entity.playlist_one) criada com sucesso"
"success": "$t(entity.playlist_one) criada com sucesso",
"input_owner": "$t(common.owner)",
"input_name": "$t(common.name)"
},
"updateServer": {
"title": "atualizar servidor"
"title": "atualizar servidor",
"success": "servidor atualizado com sucesso"
},
"editPlaylist": {
"title": "editar $t(entity.playlist_one)"
"title": "editar $t(entity.playlist_one)",
"publicJellyfinNote": "O Jellyfin por algum motivo não expõe se uma playlist é pública ou não. Se você deseja que ela permaneça pública, por favor selecione a seguinte entrada",
"success": "$t(entity.playlist_one) atualizada com sucesso"
},
"addToPlaylist": {
"title": "adicionar à $t(entity.playlist_one)",
@@ -149,14 +160,52 @@
"success": "adicionado $t(entity.trackWithCount, {\"count\": {{message}} }) para $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })"
},
"lyricSearch": {
"title": "pesquisa de letras"
"title": "pesquisa de letras",
"input_artist": "$t(entity.artist_one)",
"input_name": "$t(common.name)"
},
"shareItem": {
"createFailed": "falha ao criar compartilhamento (o compartilhamento está ativado?)",
"setExpiration": "definir expiração",
"success": "link de compartilhamento copiado para a área de transferência (ou clique aqui para abrir)",
"allowDownloading": "permitir downloads",
"description": "descrição",
"expireInvalid": "a expiração deve ser uma data futura"
},
"queryEditor": {
"input_optionMatchAny": "corresponder qualquer um",
"input_optionMatchAll": "corresponder todos"
}
},
"setting": {
"discordIdleStatus_description": "quando ativado, atualiza o status enquanto o player está ocioso",
"discordUpdateInterval_description": "o tempo em segundos entre cada atualização (mínimo 15 segundos)",
"playButtonBehavior_description": "define o comportamento padrão do botão play ao adicionar músicas à fila",
"discordApplicationId": "{{discord}} ID do aplicativo"
"discordApplicationId": "{{discord}} ID do aplicativo",
"audioPlayer": "player de áudio",
"applicationHotkeys": "teclas de atalho da aplicação",
"applicationHotkeys_description": "configure as teclas de atalho da aplicação. clique na caixa de seleção para definir como tecla de atalho global (somente desktop)",
"contextMenu": "configuração do menu de contexto (clique do botão direito do mouse)",
"clearQueryCache": "limpar cache do feishin",
"clearCache": "limpar cache do navegador",
"clearQueryCache_description": "uma 'limpeza leve' do feishin. isso irá renovar playlists, metadados de faixas, e resetar letras salvas. as configurações, as credenciais de servidor e o cache de imagens serão mantidos",
"audioPlayer_description": "selecione o player de áudio usado para reprodução",
"audioExclusiveMode": "modo de áudio exclusivo",
"buttonSize": "tamanho do botão da barra de reprodução",
"albumBackground_description": "adiciona uma imagem de fundo contendo a arte do álbum para a página de álbum",
"clearCache_description": "uma 'limpeza geral' do feishin. em adição a limpar o cache do feishin, limpa o cache do navegador (imagens salvas e outros recursos). as credenciais de servidor e as configurações serão mantidas",
"clearCacheSuccess": "cache limpo com sucesso",
"audioDevice": "dispositivo de áudio",
"audioDevice_description": "selecione o dispositivo de áudio usado para reprodução (somente player web)",
"audioExclusiveMode_description": "ativar modo de saída exclusiva. Neste modo, o sistema é geralmente bloqueado, e apenas mpv terá saída de áudio",
"accentColor": "cor de realce",
"accentColor_description": "define a cor de realce para a aplicação",
"artistConfiguration": "configuração da página de artista de álbum",
"artistConfiguration_description": "configure quais itens serão mostrados, e em qual ordem, na página de artista de álbum",
"buttonSize_description": "o tamanho dos botões da barra de reprodução",
"albumBackgroundBlur": "tamanho de desfoque da imagem de fundo do álbum",
"albumBackgroundBlur_description": "ajusta a quantidade de desfoque aplicada para a imagem de fundo do álbum",
"albumBackground": "imagem de fundo do álbum"
},
"table": {
"config": {
@@ -184,22 +233,142 @@
"title": "$t(entity.albumArtist_other)"
},
"genreList": {
"title": "$t(entity.genre_other)"
"title": "$t(entity.genre_other)",
"showTracks": "mostrar $t(entity.genre_one) $t(entity.track_other)",
"showAlbums": "mostrar $t(entity.genre_one) $t(entity.album_other)"
},
"trackList": {
"title": "$t(entity.track_other)"
"title": "$t(entity.track_other)",
"artistTracks": "faixas de {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)"
},
"globalSearch": {
"title": "comandos"
"title": "comandos",
"commands": {
"serverCommands": "comandos do servidor",
"goToPage": "ir para a página",
"searchFor": "buscar por {{query}}"
}
},
"sidebar": {
"home": "$t(common.home)"
"home": "$t(common.home)",
"tracks": "$t(entity.track_other)",
"shared": "$t(entity.playlist_other) compartilhada",
"albums": "$t(entity.album_other)",
"genres": "$t(entity.genre_other)",
"folders": "$t(entity.folder_other)",
"albumArtists": "$t(entity.albumArtist_other)",
"artists": "$t(entity.artist_other)",
"nowPlaying": "tocando agora",
"playlists": "$t(entity.playlist_other)",
"search": "$t(common.search)",
"settings": "$t(common.setting_other)"
},
"playlistList": {
"title": "$t(entity.playlist_other)"
},
"albumList": {
"title": "$t(entity.album_other)"
"title": "$t(entity.album_other)",
"artistAlbums": "álbuns de {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)"
},
"appMenu": {
"openBrowserDevtools": "abrir ferramentas do desenvolvedor",
"quit": "$t(common.quit)",
"selectServer": "selecionar servidor",
"collapseSidebar": "recolher barra lateral",
"expandSidebar": "expandir barra lateral",
"goBack": "voltar",
"goForward": "avançar",
"version": "versão {{version}}",
"manageServers": "gerenciar servidores",
"settings": "$t(common.setting_other)"
},
"contextMenu": {
"moveToTop": "$t(action.moveToTop)",
"moveToBottom": "$t(action.moveToBottom)",
"removeFromFavorites": "$t(action.removeFromFavorites)",
"numberSelected": "{{count}} selecionado",
"addFavorite": "$t(action.addToFavorites)",
"addLast": "$t(player.addLast)",
"addNext": "$t(player.addNext)",
"addToFavorites": "$t(action.addToFavorites)",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"removeFromQueue": "$t(action.removeFromQueue)",
"play": "$t(player.play)",
"playShuffled": "$t(player.shuffle)",
"createPlaylist": "$t(action.createPlaylist)",
"download": "baixar",
"shareItem": "compartilhar item",
"showDetails": "obter informações",
"addToPlaylist": "$t(action.addToPlaylist)",
"deletePlaylist": "$t(action.deletePlaylist)",
"deselectAll": "$t(action.deselectAll)",
"moveToNext": "$t(action.moveToNext)",
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"setRating": "$t(action.setRating)"
},
"albumArtistDetail": {
"viewAllTracks": "ver todas as $t(entity.track_other)",
"appearsOn": "aparece em",
"recentReleases": "lançamentos recentes",
"viewDiscography": "ver discografia",
"relatedArtists": "$t(entity.artist_other) relacionados",
"viewAll": "ver tudo",
"topSongsFrom": "músicas mais tocadas de {{title}}",
"topSongs": "músicas mais tocadas",
"about": "Sobre {{artist}}"
},
"fullscreenPlayer": {
"config": {
"unsynchronized": "não sincronizado",
"dynamicIsImage": "habilitar imagem de fundo",
"dynamicImageBlur": "tamanho do desfoque da imagem",
"lyricAlignment": "alinhamento da letra",
"showLyricMatch": "exibir correspondência da letra",
"showLyricProvider": "exibir origem da letra",
"synchronized": "sincronizado",
"lyricOffset": "deslocamento da letra (ms)",
"followCurrentLyric": "acompanhar letra",
"useImageAspectRatio": "usar proporção da imagem",
"lyricGap": "espaçamento da letra",
"lyricSize": "tamanho da letra",
"dynamicBackground": "fundo dinâmico",
"opacity": "opacidade"
},
"related": "relacionado",
"visualizer": "visualizador",
"upNext": "a seguir",
"lyrics": "letra",
"noLyrics": "nenhuma letra encontrada"
},
"albumDetail": {
"moreFromArtist": "mais deste $t(entity.artist_one)",
"moreFromGeneric": "mais de {{item}}",
"released": "lançado"
},
"itemDetail": {
"copyPath": "copiar caminho para a área de transferência",
"copiedPath": "caminho copiado com sucesso",
"openFile": "mostrar faixa no gerenciador de arquivos"
},
"manageServers": {
"serverDetails": "detalhes do servidor",
"url": "URL",
"username": "nome de usuário",
"editServerDetailsTooltip": "editar detalhes do servidor",
"removeServer": "remover servidor",
"title": "gerenciar servidores"
},
"setting": {
"generalTab": "geral",
"hotkeysTab": "teclas de atalho",
"windowTab": "janela",
"advanced": "avançado",
"playbackTab": "reprodução"
},
"playlist": {
"reorder": "reordenar apenas disponível quando ordenado pelo id"
}
},
"filter": {
@@ -229,13 +398,55 @@
"recentlyAdded": "adicionado recentemente",
"releaseDate": "data de lançamento",
"recentlyPlayed": "tocado recentemente",
"criticRating": "Nota da crítica",
"criticRating": "avaliação da crítica",
"isFavorited": "é favoritado",
"releaseYear": "ano de lançamento"
"releaseYear": "ano de lançamento",
"rating": "avaliação",
"artist": "$t(entity.artist_one)",
"bpm": "bpm",
"channels": "$t(common.channel_other)",
"comment": "comentário",
"owner": "$t(common.owner)",
"path": "caminho",
"id": "id",
"bitrate": "bitrate",
"isRated": "possui avaliação",
"note": "nota",
"albumCount": "número de $t(entity.album_other)",
"genre": "$t(entity.genre_one)"
},
"player": {
"playbackFetchNoResults": "nenhuma música encontrada",
"playbackFetchInProgress": "carregando músicas…"
"playbackFetchInProgress": "carregando músicas…",
"skip_forward": "avançar",
"mute": "mudo",
"playSimilarSongs": "tocar músicas similares",
"skip": "pular",
"stop": "parar",
"addNext": "adicionar a seguir",
"muted": "mudo",
"queue_clear": "limpar fila",
"toggleFullscreenPlayer": "alternar player de tela cheia",
"addLast": "adicionar no final",
"next": "próximo",
"play": "tocar",
"playRandom": "tocar aleatório",
"shuffle_off": "aleatório desativado",
"queue_moveToBottom": "mover selecionados para o topo",
"queue_moveToTop": "mover selecionados para o fim",
"skip_back": "retroceder",
"unfavorite": "remover favorito",
"playbackSpeed": "velocidade de reprodução",
"previous": "anterior",
"favorite": "favorito",
"playbackFetchCancel": "isso está demorando um pouco... feche a notificação para cancelar",
"queue_remove": "remover selecionados",
"repeat": "repetir",
"repeat_all": "repetir tudo",
"repeat_off": "repetição desativada",
"shuffle": "tocar aleatório",
"pause": "pausar",
"viewQueue": "ver fila"
},
"entity": {
"albumArtist_one": "artista do álbum",
@@ -277,12 +488,19 @@
"genreWithCount_one": "{{count}} gênero",
"genreWithCount_many": "{{count}} gêneros",
"genreWithCount_other": "{{count}} gêneros",
"trackWithCount_one": "faixa",
"trackWithCount_many": "faixas",
"trackWithCount_other": "faixas",
"trackWithCount_one": "{{count}} faixa",
"trackWithCount_many": "{{count}} faixas",
"trackWithCount_other": "{{count}} faixas",
"track_one": "faixa",
"track_many": "faixas",
"track_other": "faixas"
"track_other": "faixas",
"smartPlaylist": "$t(entity.playlist_one) inteligente",
"song_one": "música",
"song_many": "músicas",
"song_other": "músicas",
"play_one": "{{count}} reprodução",
"play_many": "{{count}} reproduções",
"play_other": "{{count}} reproduções"
},
"error": {
"remotePortWarning": "reinicie o servidor para aplicar a nova porta",
+8 -4
View File
@@ -20,7 +20,8 @@
"openIn": {
"lastfm": "открыть на Last.fm",
"musicbrainz": "открыть на MusicBrainz"
}
},
"moveToNext": "следующий"
},
"common": {
"backward": "назад",
@@ -437,13 +438,15 @@
},
"albumDetail": {
"moreFromArtist": "больше от $t(entity.artist_one)",
"moreFromGeneric": "больше из {{item}}"
"moreFromGeneric": "больше из {{item}}",
"released": "выпущен"
},
"setting": {
"playbackTab": "воспроизведение",
"generalTab": "общее",
"hotkeysTab": "горячие клавиши",
"windowTab": "окно"
"windowTab": "окно",
"advanced": "расширенные"
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
@@ -540,7 +543,8 @@
"title": "поиск слов песни"
},
"editPlaylist": {
"title": "редактировать $t(entity.playlist_one)"
"title": "редактировать $t(entity.playlist_one)",
"success": "$t(entity.playlist_one) обновлён успешно"
},
"shareItem": {
"success": "ссылка скопирована в буфер обмена (нажмите здесь, чтобы открыть)",
+3
View File
@@ -494,6 +494,9 @@ const createWindow = async (first = true) => {
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
// https://github.com/electron/electron/issues/46538#issuecomment-2808806722
app.commandLine.appendSwitch('gtk-version', '3');
// Must duplicate with the one in renderer process settings.store.ts
enum BindingActions {
GLOBAL_SEARCH = 'globalSearch',
+4
View File
@@ -35,6 +35,10 @@ export const hotkeyToElectronAccelerator = (hotkey: string) => {
let accelerator = hotkey;
const replacements = {
arrowdown: 'Down',
arrowleft: 'Left',
arrowright: 'Right',
arrowup: 'Up',
mod: 'CmdOrCtrl',
numpad: 'num',
numpadadd: 'numadd',
+9
View File
@@ -99,6 +99,12 @@ export const controller: GeneralController = {
getAlbumListCount(args) {
return apiController('getAlbumListCount', args.apiClientProps.server?.type)?.(args);
},
getArtistList(args) {
return apiController('getArtistList', args.apiClientProps.server?.type)?.(args);
},
getArtistListCount(args) {
return apiController('getArtistListCount', args.apiClientProps.server?.type)?.(args);
},
getDownloadUrl(args) {
return apiController('getDownloadUrl', args.apiClientProps.server?.type)?.(args);
},
@@ -126,6 +132,9 @@ export const controller: GeneralController = {
getRandomSongList(args) {
return apiController('getRandomSongList', args.apiClientProps.server?.type)?.(args);
},
getRoles(args) {
return apiController('getRoles', args.apiClientProps.server?.type)?.(args);
},
getServerInfo(args) {
return apiController('getServerInfo', args.apiClientProps.server?.type)?.(args);
},
+120 -45
View File
@@ -152,7 +152,6 @@ export const JellyfinController: ControllerEndpoint = {
const { query, apiClientProps } = args;
const res = await jfApiClient(apiClientProps).deletePlaylist({
body: null,
params: {
id: query.id,
},
@@ -331,6 +330,41 @@ export const JellyfinController: ControllerEndpoint = {
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getArtistList: async (args) => {
const { query, apiClientProps } = args;
const res = await jfApiClient(apiClientProps).getArtistList({
query: {
Fields: 'Genres, DateCreated, ExternalUrls, Overview',
ImageTypeLimit: 1,
Limit: query.limit,
ParentId: query.musicFolderId,
Recursive: true,
SearchTerm: query.searchTerm,
SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex,
UserId: apiClientProps.server?.userId || undefined,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album artist list');
}
return {
items: res.body.Items.map((item) =>
jfNormalize.albumArtist(item, apiClientProps.server),
),
startIndex: query.startIndex,
totalRecordCount: res.body.TotalRecordCount,
};
},
getArtistListCount: async ({ apiClientProps, query }) =>
JellyfinController.getArtistList({
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getDownloadUrl: (args) => {
const { apiClientProps, query } = args;
@@ -559,6 +593,7 @@ export const JellyfinController: ControllerEndpoint = {
totalRecordCount: res.body.Items.length || 0,
};
},
getRoles: async () => [],
getServerInfo: async (args) => {
const { apiClientProps } = args;
@@ -660,56 +695,98 @@ export const JellyfinController: ControllerEndpoint = {
}
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
const albumIdsFilter = query.albumIds
? formatCommaDelimitedString(query.albumIds)
: undefined;
const artistIdsFilter = query.artistIds
? formatCommaDelimitedString(query.artistIds)
: undefined;
: query.albumArtistIds
? formatCommaDelimitedString(query.albumArtistIds)
: undefined;
const res = await jfApiClient(apiClientProps).getSongList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
AlbumIds: albumIdsFilter,
ArtistIds: artistIdsFilter,
Fields: 'Genres, DateCreated, MediaSources, ParentId',
GenreIds: query.genreIds?.join(','),
IncludeItemTypes: 'Audio',
IsFavorite: query.favorite,
Limit: query.limit,
ParentId: query.musicFolderId,
Recursive: true,
SearchTerm: query.searchTerm,
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex,
...query._custom?.jellyfin,
Years: yearsFilter,
},
});
let items: z.infer<typeof jfType._response.song>[] = [];
let totalRecordCount = 0;
const batchSize = 50;
if (res.status !== 200) {
throw new Error('Failed to get song list');
}
// Handle albumIds fetches in batches to prevent HTTP 414 errors
if (query.albumIds && query.albumIds.length > batchSize) {
const albumIdBatches = chunk(query.albumIds, batchSize);
let items: z.infer<typeof jfType._response.song>[];
for (const batch of albumIdBatches) {
const albumIdsFilter = formatCommaDelimitedString(batch);
// Jellyfin Bodge because of code from https://github.com/jellyfin/jellyfin/blob/c566ccb63bf61f9c36743ddb2108a57c65a2519b/Emby.Server.Implementations/Data/SqliteItemRepository.cs#L3622
// If the Album ID filter is passed, Jellyfin will search for
// 1. the matching album id
// 2. An album with the name of the album.
// It is this second condition causing issues,
if (query.albumIds) {
const albumIdSet = new Set(query.albumIds);
items = res.body.Items.filter((item) => albumIdSet.has(item.AlbumId!));
const res = await jfApiClient(apiClientProps).getSongList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
AlbumIds: albumIdsFilter,
ArtistIds: artistIdsFilter,
Fields: 'Genres, DateCreated, MediaSources, ParentId',
GenreIds: query.genreIds?.join(','),
IncludeItemTypes: 'Audio',
IsFavorite: query.favorite,
Limit: query.limit,
ParentId: query.musicFolderId,
Recursive: true,
SearchTerm: query.searchTerm,
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex,
...query._custom?.jellyfin,
Years: yearsFilter,
},
});
if (items.length < res.body.Items.length) {
res.body.TotalRecordCount -= res.body.Items.length - items.length;
if (res.status !== 200) {
throw new Error('Failed to get song list');
}
items = [...items, ...res.body.Items];
totalRecordCount += res.body.Items.length;
}
} else {
items = res.body.Items;
const albumIdsFilter = query.albumIds
? formatCommaDelimitedString(query.albumIds)
: undefined;
const res = await jfApiClient(apiClientProps).getSongList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
AlbumIds: albumIdsFilter,
ArtistIds: artistIdsFilter,
Fields: 'Genres, DateCreated, MediaSources, ParentId',
GenreIds: query.genreIds?.join(','),
IncludeItemTypes: 'Audio',
IsFavorite: query.favorite,
Limit: query.limit,
ParentId: query.musicFolderId,
Recursive: true,
SearchTerm: query.searchTerm,
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex,
...query._custom?.jellyfin,
Years: yearsFilter,
},
});
if (res.status !== 200) {
throw new Error('Failed to get song list');
}
// Jellyfin Bodge because of code from https://github.com/jellyfin/jellyfin/blob/c566ccb63bf61f9c36743ddb2108a57c65a2519b/Emby.Server.Implementations/Data/SqliteItemRepository.cs#L3622
// If the Album ID filter is passed, Jellyfin will search for
// 1. the matching album id
// 2. An album with the name of the album.
// It is this second condition causing issues,
if (query.albumIds) {
const albumIdSet = new Set(query.albumIds);
items = res.body.Items.filter((item) => albumIdSet.has(item.AlbumId!));
totalRecordCount = items.length;
} else {
items = res.body.Items;
totalRecordCount = res.body.TotalRecordCount;
}
}
return {
@@ -717,7 +794,7 @@ export const JellyfinController: ControllerEndpoint = {
jfNormalize.song(item, apiClientProps.server, '', query.imageSize),
),
startIndex: query.startIndex,
totalRecordCount: res.body.TotalRecordCount,
totalRecordCount,
};
},
getSongListCount: async ({ apiClientProps, query }) =>
@@ -775,7 +852,6 @@ export const JellyfinController: ControllerEndpoint = {
const { apiClientProps, query } = args;
const res = await jfApiClient(apiClientProps).movePlaylistItem({
body: null,
params: {
itemId: query.trackId,
newIdx: query.endingIndex.toString(),
@@ -794,7 +870,6 @@ export const JellyfinController: ControllerEndpoint = {
for (const chunk of chunks) {
const res = await jfApiClient(apiClientProps).removeFromPlaylist({
body: null,
params: {
id: query.id,
},
@@ -236,7 +236,6 @@ const normalizeAlbum = (
})),
id: item.Id,
imagePlaceholderUrl: null,
participants: null,
imageUrl: getAlbumCoverArtUrl({
baseUrl: server?.url || '',
item,
@@ -248,6 +247,7 @@ const normalizeAlbum = (
mbzId: item.ProviderIds?.MusicBrainzAlbum || null,
name: item.Name,
originalDate: null,
participants: null,
playCount: item.UserData?.PlayCount || 0,
releaseDate: item.PremiereDate?.split('T')[0] || null,
releaseYear: item.ProductionYear || null,
@@ -284,7 +284,7 @@ const normalizeAlbumArtist = (
) || [];
return {
albumCount: null,
albumCount: item.AlbumCount ?? null,
backgroundImageUrl: null,
biography: item.Overview || null,
duration: item.RunTimeTicks / 10000,
@@ -308,7 +308,7 @@ const normalizeAlbumArtist = (
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
similarArtists,
songCount: null,
songCount: item.SongCount ?? null,
userFavorite: item.UserData?.IsFavorite || false,
userRating: null,
};
@@ -431,6 +431,7 @@ const providerIds = z.object({
});
const albumArtist = z.object({
AlbumCount: z.number().optional(),
BackdropImageTags: z.array(z.string()),
ChannelId: z.null(),
DateCreated: z.string(),
@@ -446,6 +447,7 @@ const albumArtist = z.object({
ProviderIds: providerIds.optional(),
RunTimeTicks: z.number(),
ServerId: z.string(),
SongCount: z.number().optional(),
Type: z.string(),
UserData: userData.optional(),
});
@@ -30,6 +30,22 @@ const VERSION_INFO: VersionInfo = [
['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],
];
const NAVIDROME_ROLES: Array<string | { label: string; value: string }> = [
{ label: 'all artists', value: '' },
'arranger',
'artist',
'composer',
'conductor',
'director',
'djmixer',
'engineer',
'lyricist',
'mixer',
'performer',
'producer',
'remixer',
];
const excludeMissing = (server: ServerListItem | null) => {
if (hasFeature(server, ServerFeature.BFR)) {
return { missing: false };
@@ -105,7 +121,6 @@ export const NavidromeController: ControllerEndpoint = {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).deletePlaylist({
body: null,
params: {
id: query.id,
},
@@ -261,6 +276,47 @@ export const NavidromeController: ControllerEndpoint = {
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getArtistList: async (args) => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getAlbumArtistList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumArtistListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
name: query.searchTerm,
...query._custom?.navidrome,
role: query.role || undefined,
},
});
if (res.status !== 200) {
throw new Error('Failed to get artist list');
}
return {
items: res.body.data.map((albumArtist) =>
// Navidrome native API will return only external URL small/medium/large
// image URL. Set large image to undefined to force `albumArtist` to use
// /rest/getCoverArt.view?id=ar-...
ndNormalize.albumArtist(
{
...albumArtist,
largeImageUrl: undefined,
},
apiClientProps.server,
),
),
startIndex: query.startIndex,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
},
getArtistListCount: async ({ apiClientProps, query }) =>
NavidromeController.getArtistList({
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getDownloadUrl: SubsonicController.getDownloadUrl,
getGenreList: async (args) => {
const { query, apiClientProps } = args;
@@ -369,6 +425,8 @@ export const NavidromeController: ControllerEndpoint = {
};
},
getRandomSongList: SubsonicController.getRandomSongList,
getRoles: async ({ apiClientProps }) =>
hasFeature(apiClientProps.server, ServerFeature.BFR) ? NAVIDROME_ROLES : [],
getServerInfo: async (args) => {
const { apiClientProps } = args;
@@ -490,8 +548,9 @@ export const NavidromeController: ControllerEndpoint = {
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: songListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
album_artist_id: query.artistIds,
album_artist_id: query.albumArtistIds,
album_id: query.albumIds,
artist_id: query.artistIds,
genre_id: query.genreIds,
starred: query.favorite,
title: query.searchTerm,
@@ -564,7 +623,6 @@ export const NavidromeController: ControllerEndpoint = {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).removeFromPlaylist({
body: null,
params: {
id: query.id,
},
@@ -278,8 +278,25 @@ const normalizeAlbumArtist = (
});
}
let albumCount: number;
let songCount: number;
if (item.stats) {
albumCount = Math.max(
item.stats.albumartist?.albumCount ?? 0,
item.stats.artist?.albumCount ?? 0,
);
songCount = Math.max(
item.stats.albumartist?.songCount ?? 0,
item.stats.artist?.songCount ?? 0,
);
} else {
albumCount = item.albumCount;
songCount = item.songCount;
}
return {
albumCount: item.stats?.albumartist.albumCount || item.albumCount,
albumCount,
backgroundImageUrl: null,
biography: item.biography || null,
duration: null,
@@ -304,7 +321,7 @@ const normalizeAlbumArtist = (
imageUrl: artist?.artistImageUrl || null,
name: artist.name,
})) || null,
songCount: item.stats?.albumartist.songCount || item.songCount,
songCount,
userFavorite: item.starred,
userRating: item.rating,
};
+3
View File
@@ -231,6 +231,9 @@ export const queryKeys: Record<
return [serverId, 'playlists', 'songList'] as const;
},
},
roles: {
list: (serverId: string) => [serverId, 'roles'] as const,
},
search: {
list: (serverId: string, query?: SearchQuery) => {
if (query) return [serverId, 'search', 'list', query] as const;
+80 -112
View File
@@ -190,7 +190,7 @@ export const SubsonicController: ControllerEndpoint = {
return {
...ssNormalize.albumArtist(artist, apiClientProps.server, 300),
albums: artist.album.map((album) => ssNormalize.album(album, apiClientProps.server)),
albums: artist.album?.map((album) => ssNormalize.album(album, apiClientProps.server)),
similarArtists:
artistInfo?.similarArtist?.map((artist) =>
ssNormalize.albumArtist(artist, apiClientProps.server, 300),
@@ -305,7 +305,7 @@ export const SubsonicController: ControllerEndpoint = {
return [];
}
return artist.body.artist.album;
return artist.body.artist.album ?? [];
});
return {
@@ -515,6 +515,51 @@ export const SubsonicController: ControllerEndpoint = {
return totalRecordCount;
},
getArtistList: async (args) => {
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).getArtists({
query: {
musicFolderId: query.musicFolderId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get artist list');
}
let artists = (res.body.artists?.index || []).flatMap((index) => index.artist);
console.log(artists.length);
if (query.role) {
artists = artists.filter(
(artist) => !artist.roles || artist.roles.includes(query.role!),
);
}
let results = artists.map((artist) =>
ssNormalize.albumArtist(artist, apiClientProps.server, 300),
);
if (query.searchTerm) {
const searchResults = filter(results, (artist) => {
return artist.name.toLowerCase().includes(query.searchTerm!.toLowerCase());
});
results = searchResults;
}
if (query.sortBy) {
results = sortAlbumArtistList(results, query.sortBy, query.sortOrder);
}
return {
items: results,
startIndex: query.startIndex,
totalRecordCount: results?.length || 0,
};
},
getArtistListCount: async (args) =>
SubsonicController.getArtistList(args).then((res) => res!.totalRecordCount!),
getDownloadUrl: (args) => {
const { apiClientProps, query } = args;
@@ -711,6 +756,32 @@ export const SubsonicController: ControllerEndpoint = {
totalRecordCount: res.body.randomSongs?.song?.length || 0,
};
},
getRoles: async (args) => {
const { apiClientProps } = args;
const res = await ssApiClient(apiClientProps).getArtists({});
if (res.status !== 200) {
throw new Error('Failed to get artist list');
}
const roles = new Set<string>();
for (const index of res.body.artists?.index || []) {
for (const artist of index.artist) {
for (const role of artist.roles || []) {
roles.add(role);
}
}
}
const final: Array<string | { label: string; value: string }> = Array.from(roles).sort();
// Always add 'all artist' filter, even if there are no other roles
// This is relevant when switching from a server which has roles to one with
// no roles.
final.splice(0, 0, { label: 'all artists', value: '' });
return final;
},
getServerInfo: async (args) => {
const { apiClientProps } = args;
@@ -864,7 +935,9 @@ export const SubsonicController: ControllerEndpoint = {
};
}
if (query.albumIds || query.artistIds) {
const artistIds = query.albumArtistIds || query.artistIds;
if (query.albumIds || artistIds) {
if (query.albumIds) {
for (const albumId of query.albumIds) {
fromAlbumPromises.push(
@@ -877,8 +950,8 @@ export const SubsonicController: ControllerEndpoint = {
}
}
if (query.artistIds) {
for (const artistId of query.artistIds) {
if (artistIds) {
for (const artistId of artistIds) {
artistDetailPromises.push(
ssApiClient(apiClientProps).getArtist({
query: {
@@ -895,7 +968,7 @@ export const SubsonicController: ControllerEndpoint = {
return [];
}
return artist.body.artist.album;
return artist.body.artist.album ?? [];
});
const albumIds = albums.map((album) => album.id);
@@ -1230,6 +1303,7 @@ export const SubsonicController: ControllerEndpoint = {
return null;
},
search: async (args) => {
const { query, apiClientProps } = args;
@@ -1296,109 +1370,3 @@ export const SubsonicController: ControllerEndpoint = {
return null;
},
};
// export const getAlbumArtistDetail = async (
// args: AlbumArtistDetailArgs,
// ): Promise<SSAlbumArtistDetail> => {
// const { server, signal, query } = args;
// const defaultParams = getDefaultParams(server);
// const searchParams: SSAlbumArtistDetailParams = {
// id: query.id,
// ...defaultParams,
// };
// const data = await api
// .get('/getArtist.view', {
// prefixUrl: server?.url,
// searchParams,
// signal,
// })
// .json<SSAlbumArtistDetailResponse>();
// return data.artist;
// };
// const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<SSAlbumArtistList> => {
// const { signal, server, query } = args;
// const defaultParams = getDefaultParams(server);
// const searchParams: SSAlbumArtistListParams = {
// musicFolderId: query.musicFolderId,
// ...defaultParams,
// };
// const data = await api
// .get('rest/getArtists.view', {
// prefixUrl: server?.url,
// searchParams,
// signal,
// })
// .json<SSAlbumArtistListResponse>();
// const artists = (data.artists?.index || []).flatMap((index: SSArtistIndex) => index.artist);
// return {
// items: artists,
// startIndex: query.startIndex,
// totalRecordCount: null,
// };
// };
// const getGenreList = async (args: GenreListArgs): Promise<SSGenreList> => {
// const { server, signal } = args;
// const defaultParams = getDefaultParams(server);
// const data = await api
// .get('rest/getGenres.view', {
// prefixUrl: server?.url,
// searchParams: defaultParams,
// signal,
// })
// .json<SSGenreListResponse>();
// return data.genres.genre;
// };
// const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> => {
// const { server, query, signal } = args;
// const defaultParams = getDefaultParams(server);
// const searchParams = {
// id: query.id,
// ...defaultParams,
// };
// const data = await api
// .get('rest/getAlbum.view', {
// prefixUrl: server?.url,
// searchParams: parseSearchParams(searchParams),
// signal,
// })
// .json<SSAlbumDetailResponse>();
// const { song: songs, ...dataWithoutSong } = data.album;
// return { ...dataWithoutSong, songs };
// };
// const getAlbumList = async (args: AlbumListArgs): Promise<SSAlbumList> => {
// const { server, query, signal } = args;
// const defaultParams = getDefaultParams(server);
// const searchParams = {
// ...defaultParams,
// };
// const data = await api
// .get('rest/getAlbumList2.view', {
// prefixUrl: server?.url,
// searchParams: parseSearchParams(searchParams),
// signal,
// })
// .json<SSAlbumListResponse>();
// return {
// items: data.albumList2.album,
// startIndex: query.startIndex,
// totalRecordCount: null,
// };
// };
+3 -1
View File
@@ -156,12 +156,13 @@ const albumListParameters = z.object({
const albumList = z.array(album.omit({ song: true }));
const albumArtist = z.object({
album: z.array(album),
album: z.array(album).optional(),
albumCount: z.string(),
artistImageUrl: z.string().optional(),
coverArt: z.string().optional(),
id,
name: z.string(),
roles: z.array(z.string()).optional(),
starred: z.string().optional(),
});
@@ -175,6 +176,7 @@ const artistListEntry = albumArtist.pick({
coverArt: true,
id: true,
name: true,
roles: true,
starred: true,
});
+9 -3
View File
@@ -494,6 +494,7 @@ export interface SongListQuery extends BaseQuery<SongListSort> {
jellyfin?: Partial<z.infer<typeof jfType._parameters.songList>>;
navidrome?: Partial<z.infer<typeof ndType._parameters.songList>>;
};
albumArtistIds?: string[];
albumIds?: string[];
artistIds?: string[];
favorite?: boolean;
@@ -503,6 +504,7 @@ export interface SongListQuery extends BaseQuery<SongListSort> {
maxYear?: number;
minYear?: number;
musicFolderId?: string;
role?: string;
searchTerm?: string;
startIndex: number;
}
@@ -672,7 +674,7 @@ export type AlbumArtistDetailQuery = { id: string };
export type AlbumArtistDetailArgs = { query: AlbumArtistDetailQuery } & BaseEndpointArgs;
// Artist List
export type ArtistListResponse = BasePaginatedResponse<Artist[]> | null | undefined;
export type ArtistListResponse = BasePaginatedResponse<AlbumArtist[]> | null | undefined;
export enum ArtistListSort {
ALBUM = 'album',
@@ -695,6 +697,8 @@ export interface ArtistListQuery extends BaseQuery<ArtistListSort> {
};
limit?: number;
musicFolderId?: string;
role?: string;
searchTerm?: string;
startIndex: number;
}
@@ -1245,7 +1249,8 @@ export type ControllerEndpoint = {
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
getAlbumListCount: (args: AlbumListArgs) => Promise<number>;
// getArtistInfo?: (args: any) => void;
// getArtistList?: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getArtistListCount: (args: ArtistListArgs) => Promise<number>;
getDownloadUrl: (args: DownloadArgs) => string;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>;
@@ -1255,6 +1260,7 @@ export type ControllerEndpoint = {
getPlaylistListCount: (args: PlaylistListArgs) => Promise<number>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getRoles: (args: BaseEndpointArgs) => Promise<Array<string | { label: string; value: string }>>;
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
getSimilarSongs: (args: SimilarSongsArgs) => Promise<Song[]>;
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
@@ -1417,7 +1423,7 @@ export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder
export const sortAlbumArtistList = (
artists: AlbumArtist[],
sortBy: AlbumArtistListSort,
sortBy: AlbumArtistListSort | ArtistListSort,
sortOrder: SortOrder,
) => {
const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';
@@ -32,12 +32,24 @@ export const useFixedTableHeader = ({ enabled }: { enabled: boolean }) => {
if (!isTableHeaderInView && isTableInView) {
header?.classList.add('ag-header-fixed');
root?.classList.add('ag-header-fixed-margin');
if (windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS) {
header?.classList.add('ag-header-window-bar');
}
} else if (!isTableInView) {
header?.classList.remove('ag-header-fixed');
root?.classList.remove('ag-header-fixed-margin');
if (windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS) {
header?.classList.remove('ag-header-window-bar');
}
} else if (isTableHeaderInView) {
header?.classList.remove('ag-header-fixed');
root?.classList.remove('ag-header-fixed-margin');
if (windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS) {
header?.classList.remove('ag-header-window-bar');
}
}
}, [enabled, isTableHeaderInView, isTableInView, windowBarStyle]);
@@ -105,44 +105,42 @@ export const useVirtualTable = <TFilter extends BaseQuery<any>>({
const queryKeyFn:
| ((serverId: string, query: Record<any, any>, pagination: QueryPagination) => QueryKey)
| null = useMemo(() => {
if (itemType === LibraryItem.ALBUM) {
return queryKeys.albums.list;
switch (itemType) {
case LibraryItem.ALBUM:
return queryKeys.albums.list;
case LibraryItem.ALBUM_ARTIST:
return queryKeys.albumArtists.list;
case LibraryItem.ARTIST:
return queryKeys.artists.list;
case LibraryItem.GENRE:
return queryKeys.genres.list;
case LibraryItem.PLAYLIST:
return queryKeys.playlists.list;
case LibraryItem.SONG:
return queryKeys.songs.list;
default:
return null;
}
if (itemType === LibraryItem.ALBUM_ARTIST) {
return queryKeys.albumArtists.list;
}
if (itemType === LibraryItem.PLAYLIST) {
return queryKeys.playlists.list;
}
if (itemType === LibraryItem.SONG) {
return queryKeys.songs.list;
}
if (itemType === LibraryItem.GENRE) {
return queryKeys.genres.list;
}
return null;
}, [itemType]);
const queryFn: ((args: any) => Promise<BasePaginatedResponse<any> | null | undefined>) | null =
useMemo(() => {
if (itemType === LibraryItem.ALBUM) {
return api.controller.getAlbumList;
switch (itemType) {
case LibraryItem.ALBUM:
return api.controller.getAlbumList;
case LibraryItem.ALBUM_ARTIST:
return api.controller.getAlbumArtistList;
case LibraryItem.ARTIST:
return api.controller.getArtistList;
case LibraryItem.GENRE:
return api.controller.getGenreList;
case LibraryItem.PLAYLIST:
return api.controller.getPlaylistList;
case LibraryItem.SONG:
return api.controller.getSongList;
default:
return null;
}
if (itemType === LibraryItem.ALBUM_ARTIST) {
return api.controller.getAlbumArtistList;
}
if (itemType === LibraryItem.PLAYLIST) {
return api.controller.getPlaylistList;
}
if (itemType === LibraryItem.SONG) {
return api.controller.getSongList;
}
if (itemType === LibraryItem.GENRE) {
return api.controller.getGenreList;
}
return null;
}, [itemType]);
const onGridReady = useCallback(
@@ -390,7 +388,9 @@ export const useVirtualTable = <TFilter extends BaseQuery<any>>({
break;
case LibraryItem.ARTIST:
navigate(
generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, { artistId: e.data.id }),
generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {
artistId: e.data.id,
}),
);
break;
case LibraryItem.PLAYLIST:
@@ -129,7 +129,7 @@ const AlbumListRoute = () => {
const artist = searchParams.get('artistName');
const title = artist
? t('page.albumList.artistAlbums', { artist })
? t('page.albumList.artistAlbums', { artist, postProcess: 'sentenceCase' })
: genreId
? t('page.albumList.genreAlbums', { genre: titleCase(genreTitle) })
: undefined;
@@ -66,7 +66,11 @@ interface AlbumArtistDetailContentProps {
export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailContentProps) => {
const { t } = useTranslation();
const { artistItems, externalLinks } = useGeneralSettings();
const { albumArtistId } = useParams() as { albumArtistId: string };
const { albumArtistId, artistId } = useParams() as {
albumArtistId?: string;
artistId?: string;
};
const routeId = (artistId || albumArtistId) as string;
const cq = useContainerQuery();
const handlePlayQueueAdd = usePlayQueueAdd();
const server = useCurrentServer();
@@ -85,24 +89,24 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
}, [artistItems]);
const detailQuery = useAlbumArtistDetail({
query: { id: albumArtistId },
query: { id: routeId },
serverId: server?.id,
});
const artistDiscographyLink = `${generatePath(
AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY,
{
albumArtistId,
albumArtistId: routeId,
},
)}?${createSearchParams({
artistId: albumArtistId,
artistId: routeId,
artistName: detailQuery?.data?.name || '',
})}`;
const artistSongsLink = `${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_SONGS, {
albumArtistId,
albumArtistId: routeId,
})}?${createSearchParams({
artistId: albumArtistId,
artistId: routeId,
artistName: detailQuery?.data?.name || '',
})}`;
@@ -111,7 +115,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
enabled: enabledItem.recentAlbums,
},
query: {
artistIds: [albumArtistId],
artistIds: [routeId],
limit: 15,
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
@@ -125,7 +129,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
enabled: enabledItem.compilations && server?.type !== ServerType.SUBSONIC,
},
query: {
artistIds: [albumArtistId],
artistIds: [routeId],
compilation: true,
limit: 15,
sortBy: AlbumListSort.RELEASE_DATE,
@@ -141,7 +145,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
},
query: {
artist: detailQuery?.data?.name || '',
artistId: albumArtistId,
artistId: routeId,
},
serverId: server?.id,
});
@@ -292,8 +296,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
const handlePlay = async (playType?: Play) => {
handlePlayQueueAdd?.({
byItemType: {
id: [albumArtistId],
type: LibraryItem.ALBUM_ARTIST,
id: [routeId],
type: albumArtistId ? LibraryItem.ALBUM_ARTIST : LibraryItem.ARTIST,
},
playType: playType || playButtonBehavior,
});
@@ -336,9 +340,15 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
}
};
const albumCount = detailQuery?.data?.albumCount;
const artistContextItems =
(albumCount ?? 1) > 0
? ARTIST_CONTEXT_MENU_ITEMS
: ARTIST_CONTEXT_MENU_ITEMS.filter((item) => !item.id.toLowerCase().includes('play'));
const handleGeneralContextMenu = useHandleGeneralContextMenu(
LibraryItem.ALBUM_ARTIST,
ARTIST_CONTEXT_MENU_ITEMS,
artistContextItems,
);
const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
@@ -365,7 +375,10 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
<LibraryBackgroundOverlay $backgroundColor={background} />
<DetailContainer>
<Group spacing="md">
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<PlayButton
disabled={albumCount === 0}
onClick={() => handlePlay(playButtonBehavior)}
/>
<Group spacing="xs">
<Button
compact
@@ -528,7 +541,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
to={generatePath(
AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS,
{
albumArtistId,
albumArtistId: routeId,
},
)}
variant="subtle"
@@ -16,33 +16,41 @@ interface AlbumArtistDetailHeaderProps {
export const AlbumArtistDetailHeader = forwardRef(
({ background }: AlbumArtistDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
const { albumArtistId } = useParams() as { albumArtistId: string };
const { albumArtistId, artistId } = useParams() as {
albumArtistId?: string;
artistId?: string;
};
const routeId = (artistId || albumArtistId) as string;
const server = useCurrentServer();
const { t } = useTranslation();
const detailQuery = useAlbumArtistDetail({
query: { id: albumArtistId },
query: { id: routeId },
serverId: server?.id,
});
const albumCount = detailQuery?.data?.albumCount;
const songCount = detailQuery?.data?.songCount;
const duration = detailQuery?.data?.duration;
const durationEnabled = duration !== null && duration !== undefined;
const metadataItems = [
{
enabled: detailQuery?.data?.albumCount,
enabled: albumCount !== null && albumCount !== undefined,
id: 'albumCount',
secondary: false,
value: t('entity.albumWithCount', { count: detailQuery?.data?.albumCount || 0 }),
value: t('entity.albumWithCount', { count: albumCount || 0 }),
},
{
enabled: detailQuery?.data?.songCount,
enabled: songCount !== null && songCount !== undefined,
id: 'songCount',
secondary: false,
value: t('entity.trackWithCount', { count: detailQuery?.data?.songCount || 0 }),
value: t('entity.trackWithCount', { count: songCount || 0 }),
},
{
enabled: detailQuery.data?.duration,
enabled: durationEnabled,
id: 'duration',
secondary: true,
value:
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
value: durationEnabled && formatDurationString(duration),
},
];
@@ -0,0 +1,47 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { lazy, MutableRefObject, Suspense } from 'react';
import { Spinner } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ListDisplayType } from '/@/renderer/types';
import { useListStoreByKey } from '../../../store/list.store';
import { useListContext } from '/@/renderer/context/list-context';
const ArtistListGridView = lazy(() =>
import('/@/renderer/features/artists/components/artist-list-grid-view').then((module) => ({
default: module.ArtistListGridView,
})),
);
const ArtistListTableView = lazy(() =>
import('/@/renderer/features/artists/components/artist-list-table-view').then((module) => ({
default: module.ArtistListTableView,
})),
);
interface ArtistListContentProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const ArtistListContent = ({ itemCount, gridRef, tableRef }: ArtistListContentProps) => {
const { pageKey } = useListContext();
const { display } = useListStoreByKey({ key: pageKey });
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
return (
<Suspense fallback={<Spinner container />}>
{isGrid ? (
<ArtistListGridView
gridRef={gridRef}
itemCount={itemCount}
/>
) : (
<ArtistListTableView
itemCount={itemCount}
tableRef={tableRef}
/>
)}
</Suspense>
);
};
@@ -0,0 +1,173 @@
import { QueryKey, useQueryClient } from '@tanstack/react-query';
import { MutableRefObject, useCallback, useMemo } from 'react';
import AutoSizer, { Size } from 'react-virtualized-auto-sizer';
import { ListOnScrollProps } from 'react-window';
import { VirtualGridAutoSizerContainer } from '../../../components/virtual-grid/virtual-grid-wrapper';
import { useListStoreByKey } from '../../../store/list.store';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
AlbumArtist,
ArtistListQuery,
ArtistListResponse,
ArtistListSort,
LibraryItem,
} from '/@/renderer/api/types';
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components';
import { VirtualInfiniteGrid, VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { useListContext } from '/@/renderer/context/list-context';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useListStoreActions } from '/@/renderer/store';
import { CardRow, ListDisplayType } from '/@/renderer/types';
import { useHandleFavorite } from '/@/renderer/features/shared/hooks/use-handle-favorite';
interface ArtistListGridViewProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
}
export const ArtistListGridView = ({ itemCount, gridRef }: ArtistListGridViewProps) => {
const queryClient = useQueryClient();
const server = useCurrentServer();
const handlePlayQueueAdd = usePlayQueueAdd();
const { pageKey } = useListContext();
const { grid, display, filter } = useListStoreByKey<ArtistListQuery>({ key: pageKey });
const { setGrid } = useListStoreActions();
const handleFavorite = useHandleFavorite({ gridRef, server });
const fetchInitialData = useCallback(() => {
const query: Omit<ArtistListQuery, 'startIndex' | 'limit'> = {
...filter,
};
const queriesFromCache: [QueryKey, ArtistListResponse][] = queryClient.getQueriesData({
exact: false,
fetchStatus: 'idle',
queryKey: queryKeys.artists.list(server?.id || '', query),
stale: false,
});
const itemData = [];
for (const [, data] of queriesFromCache) {
const { items, startIndex } = data || {};
if (items && items.length !== 1 && startIndex !== undefined) {
let itemIndex = 0;
for (
let rowIndex = startIndex;
rowIndex < startIndex + items.length;
rowIndex += 1
) {
itemData[rowIndex] = items[itemIndex];
itemIndex += 1;
}
}
}
return itemData;
}, [filter, queryClient, server?.id]);
const fetch = useCallback(
async ({ skip: startIndex, take: limit }: { skip: number; take: number }) => {
const query: ArtistListQuery = {
...filter,
limit,
startIndex,
};
const queryKey = queryKeys.artists.list(server?.id || '', query);
const artistsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getArtistList({
apiClientProps: {
server,
signal,
},
query,
}),
{ cacheTime: 1000 * 60 * 1 },
);
return artistsRes;
},
[filter, queryClient, server],
);
const handleGridScroll = useCallback(
(e: ListOnScrollProps) => {
setGrid({ data: { scrollOffset: e.scrollOffset }, key: pageKey });
},
[pageKey, setGrid],
);
const cardRows = useMemo(() => {
const rows: CardRow<AlbumArtist>[] = [ALBUMARTIST_CARD_ROWS.name];
switch (filter.sortBy) {
case ArtistListSort.DURATION:
rows.push(ALBUMARTIST_CARD_ROWS.duration);
break;
case ArtistListSort.FAVORITED:
break;
case ArtistListSort.NAME:
break;
case ArtistListSort.ALBUM_COUNT:
rows.push(ALBUMARTIST_CARD_ROWS.albumCount);
break;
case ArtistListSort.PLAY_COUNT:
rows.push(ALBUMARTIST_CARD_ROWS.playCount);
break;
case ArtistListSort.RANDOM:
break;
case ArtistListSort.RATING:
rows.push(ALBUMARTIST_CARD_ROWS.rating);
break;
case ArtistListSort.RECENTLY_ADDED:
break;
case ArtistListSort.SONG_COUNT:
rows.push(ALBUMARTIST_CARD_ROWS.songCount);
break;
case ArtistListSort.RELEASE_DATE:
break;
}
return rows;
}, [filter.sortBy]);
return (
<VirtualGridAutoSizerContainer>
<AutoSizer>
{({ height, width }: Size) => (
<VirtualInfiniteGrid
ref={gridRef}
cardRows={cardRows}
display={display || ListDisplayType.CARD}
fetchFn={fetch}
fetchInitialData={fetchInitialData}
handleFavorite={handleFavorite}
handlePlayQueueAdd={handlePlayQueueAdd}
height={height}
initialScrollOffset={grid?.scrollOffset || 0}
itemCount={itemCount || 0}
itemGap={grid?.itemGap ?? 10}
itemSize={grid?.itemSize || 200}
itemType={LibraryItem.ARTIST}
loading={itemCount === undefined || itemCount === null}
minimumBatchSize={40}
route={{
route: AppRoute.LIBRARY_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'artistId' }],
}}
width={width}
onScroll={handleGridScroll}
/>
)}
</AutoSizer>
</VirtualGridAutoSizerContainer>
);
};
@@ -0,0 +1,619 @@
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import { IDatasource } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Divider, Flex, Group, Stack } from '@mantine/core';
import { useQueryClient } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
import { useListContext } from '../../../context/list-context';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
ArtistListQuery,
ArtistListSort,
LibraryItem,
ServerType,
SortOrder,
} from '/@/renderer/api/types';
import {
Button,
DropdownMenu,
MultiSelect,
Select,
Slider,
Switch,
Text,
} from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import {
ArtistListFilter,
useCurrentServer,
useListStoreActions,
useListStoreByKey,
} from '/@/renderer/store';
import { ListDisplayType, TableColumn } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
import { useRoles } from '/@/renderer/features/artists/queries/roles-query';
const FILTERS = {
jellyfin: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.album', { postProcess: 'titleCase' }),
value: ArtistListSort.ALBUM,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
value: ArtistListSort.DURATION,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: ArtistListSort.NAME,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
value: ArtistListSort.RANDOM,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
value: ArtistListSort.RECENTLY_ADDED,
},
],
navidrome: [
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }),
value: ArtistListSort.ALBUM_COUNT,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),
value: ArtistListSort.FAVORITED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),
value: ArtistListSort.PLAY_COUNT,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: ArtistListSort.NAME,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
value: ArtistListSort.RATING,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),
value: ArtistListSort.SONG_COUNT,
},
],
subsonic: [
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }),
value: ArtistListSort.ALBUM_COUNT,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),
value: ArtistListSort.FAVORITED,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: ArtistListSort.NAME,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
value: ArtistListSort.RATING,
},
],
};
interface ArtistListHeaderFiltersProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderFiltersProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const server = useCurrentServer();
const { pageKey } = useListContext();
const { display, table, grid, filter } = useListStoreByKey<ArtistListQuery>({
key: pageKey,
});
const { setFilter, setTable, setTablePagination, setDisplayType, setGrid } =
useListStoreActions();
const cq = useContainerQuery();
const roles = useRoles({
options: {
cacheTime: 1000 * 60 * 60 * 2,
staleTime: 1000 * 60 * 60 * 2,
},
query: {},
serverId: server?.id,
});
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
const musicFoldersQuery = useMusicFolders({ query: null, serverId: server?.id });
const sortByLabel =
(server?.type &&
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filter.sortBy)
?.name) ||
t('common.unknown', { postProcess: 'titleCase' });
const handleItemSize = (e: number) => {
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
setTable({ data: { rowHeight: e }, key: pageKey });
} else {
setGrid({ data: { itemSize: e }, key: pageKey });
}
};
const handleItemGap = (e: number) => {
setGrid({ data: { itemGap: e }, key: pageKey });
};
const debouncedHandleItemSize = debounce(handleItemSize, 20);
const fetch = useCallback(
async (startIndex: number, limit: number, filters: ArtistListFilter) => {
const queryKey = queryKeys.artists.list(server?.id || '', {
limit,
startIndex,
...filters,
});
const albums = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
return albums;
},
[queryClient, server],
);
const handleFilterChange = useCallback(
async (filters: ArtistListFilter) => {
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const queryKey = queryKeys.artists.list(server?.id || '', {
limit,
startIndex,
...filters,
});
const artistsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(
artistsRes?.items || [],
artistsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
if (display === ListDisplayType.TABLE_PAGINATED) {
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
}
} else {
gridRef.current?.scrollTo(0);
gridRef.current?.resetLoadMoreItemsCache();
// Refetching within the virtualized grid may be inconsistent due to it refetching
// using an outdated set of filters. To avoid this, we fetch using the updated filters
// and then set the grid's data here.
const data = await fetch(0, 200, filters);
if (!data?.items) return;
gridRef.current?.setItemData(data.items);
}
},
[display, tableRef, server, queryClient, setTablePagination, pageKey, gridRef, fetch],
);
const handleSetSortBy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value || !server?.type) return;
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
(f) => f.value === e.currentTarget.value,
)?.defaultOrder;
const updatedFilters = setFilter({
data: {
sortBy: e.currentTarget.value as ArtistListSort,
sortOrder: sortOrder || SortOrder.ASC,
},
itemType: LibraryItem.ARTIST,
key: pageKey,
}) as ArtistListFilter;
handleFilterChange(updatedFilters);
},
[handleFilterChange, pageKey, server?.type, setFilter],
);
const handleSetMusicFolder = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
let updatedFilters = null;
if (e.currentTarget.value === String(filter.musicFolderId)) {
updatedFilters = setFilter({
data: { musicFolderId: undefined },
itemType: LibraryItem.ARTIST,
key: pageKey,
}) as ArtistListFilter;
} else {
updatedFilters = setFilter({
data: { musicFolderId: e.currentTarget.value },
itemType: LibraryItem.ARTIST,
key: pageKey,
}) as ArtistListFilter;
}
handleFilterChange(updatedFilters);
},
[filter.musicFolderId, handleFilterChange, setFilter, pageKey],
);
const handleToggleSortOrder = useCallback(() => {
const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter({
data: { sortOrder: newSortOrder },
itemType: LibraryItem.ARTIST,
key: pageKey,
}) as ArtistListFilter;
handleFilterChange(updatedFilters);
}, [filter.sortOrder, handleFilterChange, pageKey, setFilter]);
const handleSetViewType = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey });
},
[pageKey, setDisplayType],
);
const handleTableColumns = (values: TableColumn[]) => {
const existingColumns = table.columns;
if (values.length === 0) {
return setTable({
data: {
columns: [],
},
key: pageKey,
});
}
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1], width: 100 };
setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey });
} else {
// If removing a column
const removed = existingColumns.filter((column) => !values.includes(column.column));
const newColumns = existingColumns.filter((column) => !removed.includes(column));
setTable({ data: { columns: newColumns }, key: pageKey });
}
return tableRef.current?.api.sizeColumnsToFit();
};
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey });
if (e.currentTarget.checked) {
tableRef.current?.api.sizeColumnsToFit();
}
};
const handleRefresh = useCallback(() => {
queryClient.invalidateQueries(queryKeys.artists.list(server?.id || ''));
handleFilterChange(filter);
}, [filter, handleFilterChange, queryClient, server?.id]);
const handleSetRole = useCallback(
(e: string | null) => {
const updatedFilters = setFilter({
data: {
role: e || '',
},
itemType: LibraryItem.ARTIST,
key: pageKey,
}) as ArtistListFilter;
handleFilterChange(updatedFilters);
},
[handleFilterChange, pageKey, setFilter],
);
return (
<Flex justify="space-between">
<Group
ref={cq.ref}
spacing="sm"
w="100%"
>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
{sortByLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
<DropdownMenu.Item
key={`filter-${f.name}`}
$isActive={f.value === filter.sortBy}
value={f.value}
onClick={handleSetSortBy}
>
{f.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Divider orientation="vertical" />
<OrderToggleButton
sortOrder={filter.sortOrder}
onToggle={handleToggleSortOrder}
/>
{server?.type === ServerType.JELLYFIN && (
<>
<Divider orientation="vertical" />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
{cq.isMd ? 'Folder' : <RiFolder2Line size={15} />}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{musicFoldersQuery.data?.items.map((folder) => (
<DropdownMenu.Item
key={`musicFolder-${folder.id}`}
$isActive={filter.musicFolderId === folder.id}
value={folder.id}
onClick={handleSetMusicFolder}
>
{folder.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
</>
)}
{roles.data?.length && (
<>
<Divider orientation="vertical" />
<Select
data={roles.data}
value={filter.role}
onChange={handleSetRole}
/>
</>
)}
<Divider orientation="vertical" />
<Button
compact
size="md"
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
variant="subtle"
onClick={handleRefresh}
>
<RiRefreshLine size="1.3rem" />
</Button>
<Divider orientation="vertical" />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
size="md"
variant="subtle"
>
<RiMoreFill size={15} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
{t('common.refresh', {
postProcess: 'titleCase',
})}
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group>
<DropdownMenu
position="bottom-end"
width={425}
>
<DropdownMenu.Target>
<Button
compact
size="md"
variant="subtle"
>
<RiSettings3Fill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={display === ListDisplayType.CARD}
value={ListDisplayType.CARD}
onClick={handleSetViewType}
>
{t('table.config.view.card', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
value={ListDisplayType.POSTER}
onClick={handleSetViewType}
>
{t('table.config.view.poster', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
{t('table.config.view.table', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Label>
{t('table.config.general.itemSize', { postProcess: 'sentenceCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
{display === ListDisplayType.CARD ||
display === ListDisplayType.POSTER ? (
<Slider
defaultValue={grid?.itemSize}
max={300}
min={150}
onChange={debouncedHandleItemSize}
/>
) : (
<Slider
defaultValue={table.rowHeight}
max={100}
min={30}
onChange={debouncedHandleItemSize}
/>
)}
</DropdownMenu.Item>
{isGrid && (
<>
<DropdownMenu.Label>
{t('table.config.general.itemGap', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={grid?.itemGap || 0}
max={30}
min={0}
onChangeEnd={handleItemGap}
/>
</DropdownMenu.Item>
</>
)}
{!isGrid && (
<>
<DropdownMenu.Label>
{t('table.config.general.tableColumns', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
sx={{ cursor: 'default' }}
>
<Stack>
<MultiSelect
clearable
data={ALBUMARTIST_TABLE_COLUMNS}
defaultValue={table?.columns.map(
(column) => column.column,
)}
width={300}
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>
{t('table.config.general.autoFitColumns', {
postProcess: 'sentenceCase',
})}
</Text>
<Switch
defaultChecked={table.autoFit}
onChange={handleAutoFitColumns}
/>
</Group>
</Stack>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</Flex>
);
};
@@ -0,0 +1,77 @@
import type { ChangeEvent, MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import { FilterBar } from '../../shared/components/filter-bar';
import { ArtistListQuery, LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { LibraryHeaderBar } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { ArtistListFilter, useCurrentServer } from '/@/renderer/store';
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
import { ArtistListHeaderFilters } from '/@/renderer/features/artists/components/artist-list-header-filters';
interface ArtistListHeaderProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const ArtistListHeader = ({ itemCount, gridRef, tableRef }: ArtistListHeaderProps) => {
const { t } = useTranslation();
const server = useCurrentServer();
const cq = useContainerQuery();
const { filter, refresh, search } = useDisplayRefresh<ArtistListQuery>({
gridRef,
itemCount,
itemType: LibraryItem.ARTIST,
server,
tableRef,
});
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = search(e) as ArtistListFilter;
refresh(updatedFilters);
}, 500);
return (
<Stack
ref={cq.ref}
spacing={0}
>
<PageHeader backgroundColor="var(--titlebar-bg)">
<Flex
justify="space-between"
w="100%"
>
<LibraryHeaderBar>
<LibraryHeaderBar.Title>
{t('entity.artist_other', { postProcess: 'titleCase' })}
</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge
isLoading={itemCount === null || itemCount === undefined}
>
{itemCount}
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
onChange={handleSearch}
/>
</Group>
</Flex>
</PageHeader>
<FilterBar>
<ArtistListHeaderFilters
gridRef={gridRef}
tableRef={tableRef}
/>
</FilterBar>
</Stack>
);
};
@@ -0,0 +1,40 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { MutableRefObject } from 'react';
import { useListContext } from '../../../context/list-context';
import { ARTIST_CONTEXT_MENU_ITEMS } from '../../context-menu/context-menu-items';
import { LibraryItem } from '/@/renderer/api/types';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
import { VirtualTable } from '/@/renderer/components/virtual-table';
import { useVirtualTable } from '/@/renderer/components/virtual-table/hooks/use-virtual-table';
import { useCurrentServer } from '/@/renderer/store';
interface ArtistListTableViewProps {
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const ArtistListTableView = ({ itemCount, tableRef }: ArtistListTableViewProps) => {
const server = useCurrentServer();
const { pageKey } = useListContext();
const tableProps = useVirtualTable({
contextMenu: ARTIST_CONTEXT_MENU_ITEMS,
itemCount,
itemType: LibraryItem.ARTIST,
pageKey,
server,
tableRef,
});
return (
<VirtualGridAutoSizerContainer>
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${tableProps.rowHeight}-${server?.id}`}
ref={tableRef}
{...tableProps}
/>
</VirtualGridAutoSizerContainer>
);
};
@@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { ArtistListQuery } from '/@/renderer/api/types';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useArtistListCount = (args: QueryHookArgs<ArtistListQuery>) => {
const { options, query, serverId } = args;
const server = getServerById(serverId);
return useQuery({
enabled: !!serverId,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getArtistListCount({
apiClientProps: {
server,
signal,
},
query,
});
},
queryKey: queryKeys.albumArtists.count(
serverId || '',
Object.keys(query).length === 0 ? undefined : query,
),
...options,
});
};
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useRoles = (args: QueryHookArgs<{}>) => {
const { options, serverId } = args;
const server = getServerById(serverId);
return useQuery({
enabled: !!serverId,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getRoles({
apiClientProps: {
server,
signal,
},
});
},
queryKey: queryKeys.roles.list(serverId || ''),
...options,
});
};
@@ -16,15 +16,21 @@ const AlbumArtistDetailRoute = () => {
const headerRef = useRef<HTMLDivElement>(null);
const server = useCurrentServer();
const { albumArtistId } = useParams() as { albumArtistId: string };
const { albumArtistId, artistId } = useParams() as {
albumArtistId?: string;
artistId?: string;
};
const routeId = (artistId || albumArtistId) as string;
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const detailQuery = useAlbumArtistDetail({
query: { id: albumArtistId },
query: { id: routeId },
serverId: server?.id,
});
const { color: background, colorId } = useFastAverageColor({
id: albumArtistId,
id: routeId,
src: detailQuery.data?.imageUrl,
srcLoaded: !detailQuery.isLoading,
});
@@ -32,19 +38,19 @@ const AlbumArtistDetailRoute = () => {
const handlePlay = () => {
handlePlayQueueAdd?.({
byItemType: {
id: [albumArtistId],
id: [routeId],
type: LibraryItem.ALBUM_ARTIST,
},
playType: playButtonBehavior,
});
};
if (!background || colorId !== albumArtistId) {
if (!background || colorId !== routeId) {
return <Spinner container />;
}
return (
<AnimatedPage key={`album-artist-detail-${albumArtistId}`}>
<AnimatedPage key={`album-artist-detail-${routeId}`}>
<NativeScrollArea
ref={scrollAreaRef}
pageHeaderProps={{
@@ -12,18 +12,22 @@ import { ListContext } from '/@/renderer/context/list-context';
const AlbumArtistDetailTopSongsListRoute = () => {
const tableRef = useRef<AgGridReactType | null>(null);
const { albumArtistId } = useParams() as { albumArtistId: string };
const { albumArtistId, artistId } = useParams() as {
albumArtistId?: string;
artistId?: string;
};
const routeId = (artistId || albumArtistId) as string;
const server = useCurrentServer();
const pageKey = LibraryItem.SONG;
const detailQuery = useAlbumArtistDetail({
query: { id: albumArtistId },
query: { id: routeId },
serverId: server?.id,
});
const topSongsQuery = useTopSongsList({
options: { enabled: !!detailQuery?.data?.name },
query: { artist: detailQuery?.data?.name || '', artistId: albumArtistId },
query: { artist: detailQuery?.data?.name || '', artistId: routeId },
serverId: server?.id,
});
@@ -31,10 +35,10 @@ const AlbumArtistDetailTopSongsListRoute = () => {
const providerValue = useMemo(() => {
return {
id: albumArtistId,
id: routeId,
pageKey,
};
}, [albumArtistId, pageKey]);
}, [routeId, pageKey]);
return (
<AnimatedPage>
@@ -0,0 +1,57 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useMemo, useRef } from 'react';
import { useCurrentServer } from '../../../store/auth.store';
import { useListFilterByKey } from '../../../store/list.store';
import { ArtistListQuery, LibraryItem } from '/@/renderer/api/types';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ListContext } from '/@/renderer/context/list-context';
import { AnimatedPage } from '/@/renderer/features/shared';
import { useArtistListCount } from '/@/renderer/features/artists/queries/artist-list-count-query';
import { ArtistListHeader } from '/@/renderer/features/artists/components/artist-list-header';
import { ArtistListContent } from '/@/renderer/features/artists/components/artist-list-content';
const ArtistListRoute = () => {
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
const tableRef = useRef<AgGridReactType | null>(null);
const pageKey = LibraryItem.ARTIST;
const server = useCurrentServer();
const artistListFilter = useListFilterByKey<ArtistListQuery>({ key: pageKey });
const itemCountCheck = useArtistListCount({
options: {
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: artistListFilter,
serverId: server?.id,
});
const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
const providerValue = useMemo(() => {
return {
id: undefined,
pageKey,
};
}, [pageKey]);
return (
<AnimatedPage>
<ListContext.Provider value={providerValue}>
<ArtistListHeader
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
<ArtistListContent
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
</ListContext.Provider>
</AnimatedPage>
);
};
export default ArtistListRoute;
@@ -97,6 +97,18 @@ const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating', 'shareI
const utils = isElectron() ? window.electron.utils : null;
function RatingIcon({ rating }: { rating: number }) {
return (
<Rating
readOnly
style={{
pointerEvents: 'none',
}}
value={rating}
/>
);
}
export interface ContextMenuProviderProps {
children: ReactNode;
}
@@ -835,62 +847,32 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
children: [
{
id: 'zeroStar',
label: (
<Rating
readOnly
value={0}
/>
),
label: <RatingIcon rating={0} />,
onClick: () => handleUpdateRating(0),
},
{
id: 'oneStar',
label: (
<Rating
readOnly
value={1}
/>
),
label: <RatingIcon rating={1} />,
onClick: () => handleUpdateRating(1),
},
{
id: 'twoStar',
label: (
<Rating
readOnly
value={2}
/>
),
label: <RatingIcon rating={2} />,
onClick: () => handleUpdateRating(2),
},
{
id: 'threeStar',
label: (
<Rating
readOnly
value={3}
/>
),
label: <RatingIcon rating={3} />,
onClick: () => handleUpdateRating(3),
},
{
id: 'fourStar',
label: (
<Rating
readOnly
value={4}
/>
),
label: <RatingIcon rating={4} />,
onClick: () => handleUpdateRating(4),
},
{
id: 'fiveStar',
label: (
<Rating
readOnly
value={5}
/>
),
label: <RatingIcon rating={5} />,
onClick: () => handleUpdateRating(5),
},
],
@@ -20,6 +20,7 @@ import {
getAlbumArtistSongsById,
getSongsByQuery,
getGenreSongsById,
getArtistSongsById,
} from '/@/renderer/features/player/utils';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useTranslation } from 'react-i18next';
@@ -119,6 +120,13 @@ export const useHandlePlayQueueAdd = () => {
queryClient,
server,
});
} else if (itemType === LibraryItem.ARTIST) {
songList = await getArtistSongsById({
id,
query,
queryClient,
server,
});
} else if (itemType === LibraryItem.GENRE) {
songList = await getGenreSongsById({ id, query, queryClient, server });
} else if (itemType === LibraryItem.SONG) {
@@ -162,7 +170,7 @@ export const useHandlePlayQueueAdd = () => {
if (!songs || songs?.length === 0)
return toast.warn({
message: t('common.noResultsFromQuery', { postProcess: 'sentenceCase' }),
title: t('player.playbackFetchNoResults'),
title: t('player.playbackFetchNoResults', { postProcess: 'sentenceCase' }),
});
if (initialIndex) {
@@ -342,8 +342,12 @@ export const useScrobble = () => {
// a single track on repeat one, or one track added to the queue
// multiple times in a row and playback goes normally (no next/previous)
equalityFn: (a, b) =>
// compute whether the song changed
(a[0] as QueueSong)?.uniqueId === (b[0] as QueueSong)?.uniqueId &&
a[2] === b[2],
// compute whether the position changed. This should imply 1
a[2] === b[2] &&
// compute whether the same player: relevant for repeat one and repeat all (one track)
a[3] === b[3],
},
);
+38 -1
View File
@@ -146,7 +146,7 @@ export const getAlbumArtistSongsById = async (args: {
const { id, queryClient, server, query } = args;
const queryFilter: SongListQuery = {
artistIds: id || [],
albumArtistIds: id || [],
sortBy: SongListSort.ALBUM_ARTIST,
sortOrder: SortOrder.ASC,
startIndex: 0,
@@ -174,6 +174,43 @@ export const getAlbumArtistSongsById = async (args: {
return res;
};
export const getArtistSongsById = async (args: {
id: string[];
query?: Partial<SongListQuery>;
queryClient: QueryClient;
server: ServerListItem;
}) => {
const { id, queryClient, server, query } = args;
const queryFilter: SongListQuery = {
artistIds: id,
sortBy: SongListSort.ALBUM,
sortOrder: SortOrder.ASC,
startIndex: 0,
...query,
};
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
const res = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getSongList({
apiClientProps: {
server,
signal,
},
query: queryFilter,
}),
{
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
);
return res;
};
export const getSongsByQuery = async (args: {
query?: Partial<SongListQuery>;
queryClient: QueryClient;
@@ -2,6 +2,7 @@
import { useCallback, useState, Fragment, useRef } from 'react';
import { ActionIcon, Group, Kbd, ScrollArea } from '@mantine/core';
import { useDisclosure, useDebouncedValue } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { RiSearchLine, RiCloseFill } from 'react-icons/ri';
import { generatePath, useNavigate } from 'react-router';
import styled from 'styled-components';
@@ -37,6 +38,7 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
const activePage = pages[pages.length - 1];
const isHome = activePage === CommandPalettePages.HOME;
const searchInputRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
const popPage = useCallback(() => {
setPages((pages) => {
@@ -187,13 +189,17 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
}}
>
<LibraryCommandItem
disabled={artist?.albumCount === 0}
handlePlayQueueAdd={handlePlayQueueAdd}
id={artist.id}
imageUrl={artist.imageUrl}
itemType={LibraryItem.ALBUM_ARTIST}
subtitle={
(artist?.albumCount || 0) > 0
? `${artist.albumCount} albums`
artist?.albumCount !== undefined &&
artist?.albumCount !== null
? t('entity.albumWithCount', {
count: artist.albumCount,
})
: undefined
}
title={artist.name}
@@ -53,6 +53,7 @@ const StyledImage = styled.img`
const ActionsContainer = styled(Flex)``;
interface LibraryCommandItemProps {
disabled?: boolean;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
id: string;
imageUrl: string | null;
@@ -62,6 +63,7 @@ interface LibraryCommandItemProps {
}
export const LibraryCommandItem = ({
disabled,
id,
imageUrl,
subtitle,
@@ -154,6 +156,7 @@ export const LibraryCommandItem = ({
>
<Button
compact
disabled={disabled}
size="md"
tooltip={{
label: t('player.play', { postProcess: 'sentenceCase' }),
@@ -166,6 +169,7 @@ export const LibraryCommandItem = ({
</Button>
<Button
compact
disabled={disabled}
size="md"
tooltip={{
label: t('player.addLast', { postProcess: 'sentenceCase' }),
@@ -179,6 +183,7 @@ export const LibraryCommandItem = ({
</Button>
<Button
compact
disabled={disabled}
size="md"
tooltip={{
label: t('player.addNext', { postProcess: 'sentenceCase' }),
@@ -4,7 +4,7 @@ import { MutableRefObject } from 'react';
import { generatePath, useNavigate } from 'react-router';
import { useParams, useSearchParams } from 'react-router-dom';
import { AppRoute } from '../../../router/routes';
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
import { LibraryItem, QueueSong, SongListQuery } from '/@/renderer/api/types';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
import { VirtualTable } from '/@/renderer/components/virtual-table';
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
@@ -80,7 +80,7 @@ export const SearchContent = ({ tableRef }: SearchContentProps) => {
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
const tableProps = useVirtualTable({
const tableProps = useVirtualTable<SongListQuery>({
contextMenu: contextMenuItems(),
customFilters: filter,
itemType,
@@ -96,6 +96,7 @@ export const SearchContent = ({ tableRef }: SearchContentProps) => {
key={`table-${itemType}-${tableProps.rowHeight}-${server?.id}`}
ref={tableRef}
context={{
itemType,
query: searchParams.get('query'),
}}
getRowId={(data) => data.data.id}
@@ -3,8 +3,8 @@ import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store';
const SIDEBAR_ITEMS: Array<[string, string]> = [
['Albums', 'page.sidebar.albums'],
['Artists', 'page.sidebar.artists'],
['Folders', 'page.sidebar.folders'],
['Artists', 'page.sidebar.albumArtists'],
['Artists-all', 'page.sidebar.artists'],
['Genres', 'page.sidebar.genres'],
['Home', 'page.sidebar.home'],
['Now Playing', 'page.sidebar.nowPlaying'],
@@ -15,7 +15,7 @@ const MotionButton = styled(UnstyledButton)`
fill: var(--btn-filled-fg);
}
&:hover {
&:hover:not([disabled]) {
background: var(--btn-filled-bg);
transform: scale(1.1);
@@ -28,6 +28,10 @@ const MotionButton = styled(UnstyledButton)`
transform: scale(0.95);
}
&:disabled {
opacity: 0.6;
}
transition: background-color 0.2s ease-in-out;
transition: transform 0.2s ease-in-out;
`;
@@ -46,7 +46,7 @@ const TextWrapper = styled.div`
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
white-space: pre-line;
`;
const ActiveTabIndicator = styled(motion.div)`
@@ -90,7 +90,6 @@ const _CollapsedSidebarItem = forwardRef<HTMLDivElement, CollapsedSidebarItemPro
<Text
$secondary={!isMatch}
fw="600"
overflow="hidden"
size="xs"
>
{label}
@@ -33,7 +33,11 @@ export const CollapsedSidebar = () => {
const translatedSidebarItemMap = useMemo(
() => ({
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
Artists: t('page.sidebar.artists', { postProcess: 'titleCase' }),
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }).replace(
' ',
'\n',
),
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
@@ -81,8 +81,8 @@ export const Sidebar = () => {
const translatedSidebarItemMap = useMemo(
() => ({
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
Artists: t('page.sidebar.artists', { postProcess: 'titleCase' }),
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),
@@ -122,9 +122,12 @@ const TrackListRoute = () => {
const artist = searchParams.get('artistName');
const title = artist
? t('page.trackList.artistTracks', { artist })
? t('page.trackList.artistTracks', { artist, postProcess: 'sentenceCase' })
: genreId
? t('page.trackList.genreTracks', { genre: titleCase(genreTitle) })
? t('page.trackList.genreTracks', {
genre: titleCase(genreTitle),
postProcess: 'sentenceCase',
})
: undefined;
return (
+30 -32
View File
@@ -27,44 +27,42 @@ export const useListFilterRefresh = ({
const queryKeyFn: ((serverId: string, query: Record<any, any>) => QueryKey) | null =
useMemo(() => {
if (itemType === LibraryItem.ALBUM) {
return queryKeys.albums.list;
switch (itemType) {
case LibraryItem.ALBUM:
return queryKeys.albums.list;
case LibraryItem.ALBUM_ARTIST:
return queryKeys.albumArtists.list;
case LibraryItem.ARTIST:
return queryKeys.artists.list;
case LibraryItem.GENRE:
return queryKeys.genres.list;
case LibraryItem.PLAYLIST:
return queryKeys.playlists.list;
case LibraryItem.SONG:
return queryKeys.songs.list;
default:
return null;
}
if (itemType === LibraryItem.ALBUM_ARTIST) {
return queryKeys.albumArtists.list;
}
if (itemType === LibraryItem.PLAYLIST) {
return queryKeys.playlists.list;
}
if (itemType === LibraryItem.SONG) {
return queryKeys.songs.list;
}
if (itemType === LibraryItem.GENRE) {
return queryKeys.genres.list;
}
return null;
}, [itemType]);
const queryFn: ((args: any) => Promise<BasePaginatedResponse<any> | null | undefined>) | null =
useMemo(() => {
if (itemType === LibraryItem.ALBUM) {
return api.controller.getAlbumList;
switch (itemType) {
case LibraryItem.ALBUM:
return api.controller.getAlbumList;
case LibraryItem.ALBUM_ARTIST:
return api.controller.getAlbumArtistList;
case LibraryItem.ARTIST:
return api.controller.getArtistList;
case LibraryItem.GENRE:
return api.controller.getGenreList;
case LibraryItem.PLAYLIST:
return api.controller.getPlaylistList;
case LibraryItem.SONG:
return api.controller.getSongList;
default:
return null;
}
if (itemType === LibraryItem.ALBUM_ARTIST) {
return api.controller.getAlbumArtistList;
}
if (itemType === LibraryItem.PLAYLIST) {
return api.controller.getPlaylistList;
}
if (itemType === LibraryItem.SONG) {
return api.controller.getSongList;
}
if (itemType === LibraryItem.GENRE) {
return api.controller.getGenreList;
}
return null;
}, [itemType]);
const handleRefreshTable = useCallback(
+1
View File
@@ -62,6 +62,7 @@ root.render(
<Notifications
containerWidth="300px"
position="bottom-center"
zIndex={5}
/>
<App />
</PersistQueryClientProvider>,
+24
View File
@@ -8,6 +8,7 @@ import { ModalsProvider } from '@mantine/modals';
import { BaseContextModal } from '/@/renderer/components';
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
import { ShareItemContextModal } from '/@/renderer/features/sharing';
import ArtistListRoute from '/@/renderer/features/artists/routes/artist-list-route';
const NowPlayingRoute = lazy(
() => import('/@/renderer/features/now-playing/routes/now-playing-route'),
@@ -144,6 +145,29 @@ export const AppRouter = () => {
errorElement={<RouteErrorBoundary />}
path={AppRoute.LIBRARY_ALBUMS_DETAIL}
/>
<Route
element={<ArtistListRoute />}
errorElement={<RouteErrorBoundary />}
path={AppRoute.LIBRARY_ARTISTS}
/>
<Route path={AppRoute.LIBRARY_ARTISTS_DETAIL}>
<Route
index
element={<AlbumArtistDetailRoute />}
/>
<Route
element={<AlbumListRoute />}
path={AppRoute.LIBRARY_ARTISTS_DETAIL_DISCOGRAPHY}
/>
<Route
element={<SongListRoute />}
path={AppRoute.LIBRARY_ARTISTS_DETAIL_SONGS}
/>
<Route
element={<AlbumArtistDetailTopSongsListRoute />}
path={AppRoute.LIBRARY_ARTISTS_DETAIL_TOP_SONGS}
/>
</Route>
<Route
element={<DummyAlbumDetailRoute />}
errorElement={<RouteErrorBoundary />}
+3
View File
@@ -12,6 +12,9 @@ export enum AppRoute {
LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS = '/library/album-artists/:albumArtistId/top-songs',
LIBRARY_ARTISTS = '/library/artists',
LIBRARY_ARTISTS_DETAIL = '/library/artists/:artistId',
LIBRARY_ARTISTS_DETAIL_DISCOGRAPHY = '/library/artists/:artistId/discography',
LIBRARY_ARTISTS_DETAIL_SONGS = '/library/artists/:artistId/songs',
LIBRARY_ARTISTS_DETAIL_TOP_SONGS = '/library/artists/:artistId/top-songs',
LIBRARY_FOLDERS = '/library/folders',
LIBRARY_GENRES = '/library/genres',
LIBRARY_GENRES_ALBUMS = '/library/genres/:genreId/albums',
+33
View File
@@ -7,6 +7,7 @@ import {
AlbumArtistListSort,
AlbumListArgs,
AlbumListSort,
ArtistListArgs,
GenreListArgs,
GenreListSort,
LibraryItem,
@@ -27,6 +28,7 @@ export const generatePageKey = (page: string, id?: string) => {
export type AlbumListFilter = Omit<AlbumListArgs['query'], 'startIndex' | 'limit'>;
export type SongListFilter = Omit<SongListArgs['query'], 'startIndex' | 'limit'>;
export type AlbumArtistListFilter = Omit<AlbumArtistListArgs['query'], 'startIndex' | 'limit'>;
export type ArtistListFilter = Omit<ArtistListArgs['query'], 'startIndex' | 'limit'>;
export type PlaylistListFilter = Omit<PlaylistListArgs['query'], 'startIndex' | 'limit'>;
export type GenreListFilter = Omit<GenreListArgs['query'], 'startIndex' | 'limit'>;
@@ -34,6 +36,7 @@ type FilterType =
| AlbumListFilter
| SongListFilter
| AlbumArtistListFilter
| ArtistListFilter
| PlaylistListFilter
| GenreListFilter;
@@ -509,6 +512,36 @@ export const useListStore = create<ListSlice>()(
scrollOffset: 0,
},
},
artist: {
display: ListDisplayType.POSTER,
filter: {
role: '',
sortBy: AlbumArtistListSort.NAME,
sortOrder: SortOrder.DESC,
},
grid: { itemGap: 10, itemSize: 200, scrollOffset: 0 },
table: {
autoFit: true,
columns: [
{
column: TableColumn.ROW_INDEX,
width: 50,
},
{
column: TableColumn.TITLE_COMBINED,
width: 500,
},
],
pagination: {
currentPage: 1,
itemsPerPage: 100,
totalItems: 1,
totalPages: 1,
},
rowHeight: 60,
scrollOffset: 0,
},
},
genre: {
display: ListDisplayType.TABLE,
filter: {
+9 -9
View File
@@ -35,7 +35,7 @@ export type SidebarItemType = {
route: AppRoute | string;
};
export const sidebarItems = [
export const sidebarItems: SidebarItemType[] = [
{
disabled: true,
id: 'Now Playing',
@@ -64,21 +64,21 @@ export const sidebarItems = [
{
disabled: false,
id: 'Artists',
label: i18n.t('page.sidebar.artists'),
label: i18n.t('page.sidebar.albumArtists'),
route: AppRoute.LIBRARY_ALBUM_ARTISTS,
},
{
disabled: false,
id: 'Artists-all',
label: i18n.t('page.sidebar.artists'),
route: AppRoute.LIBRARY_ARTISTS,
},
{
disabled: false,
id: 'Genres',
label: i18n.t('page.sidebar.genres'),
route: AppRoute.LIBRARY_GENRES,
},
{
disabled: true,
id: 'Folders',
label: i18n.t('page.sidebar.folders'),
route: AppRoute.LIBRARY_FOLDERS,
},
{
disabled: true,
id: 'Playlists',
@@ -735,7 +735,7 @@ export const useSettingsStore = create<SettingsSlice>()(
{
merge: mergeOverridingColumns,
name: 'store_settings',
version: 8,
version: 9,
},
),
);
+4
View File
@@ -10,6 +10,10 @@
transition: position 0.2s ease-in-out;
}
.ag-header-window-bar {
top: 95px;
}
.ag-header {
z-index: 5;
}