Compare commits

..

1 Commits

Author SHA1 Message Date
jeffvli e3d7e8e856 add default values for optional docker env (#1500) 2026-01-04 15:36:32 -08:00
261 changed files with 5077 additions and 10183 deletions
-1
View File
@@ -5,7 +5,6 @@
*.jpeg binary
*.ico binary
*.icns binary
*.webp binary
*.eot binary
*.otf binary
*.ttf binary
+8 -3
View File
@@ -18,10 +18,15 @@ FROM nginx:alpine-slim
COPY --chown=nginx:nginx --from=builder /app/out/web /usr/share/nginx/html
COPY ./settings.js.template /etc/nginx/templates/settings.js.template
COPY ng.conf.template /etc/nginx/templates/default.conf.template
COPY ./ng.conf.template /etc/nginx/templates/default.conf.template
COPY ./docker-entrypoint.sh /docker-entrypoint.sh
ENV SERVER_LOCK=false SERVER_NAME="" SERVER_TYPE="" SERVER_URL=""
ENV LEGACY_AUTHENTICATION="" ANALYTICS_DISABLED="" PUBLIC_PATH="/"
RUN chmod +x /docker-entrypoint.sh
ENV PUBLIC_PATH="/"
ENV LEGACY_AUTHENTICATION="false"
ENV ANALYTICS_DISABLED="false"
EXPOSE 9180
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
+1 -1
View File
@@ -43,7 +43,7 @@ Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
## Screenshots
<a href="./media/preview_full_screen_player.png"><img src="./media/preview_full_screen_player.png" width="49.5%"/></a> <a href="./media/preview_album_artist_detail.png"><img src="./media/preview_album_artist_detail.png" width="49.5%"/></a> <a href="./media/preview_album_detail.png"><img src="./media/preview_album_detail.png" width="49.5%"/></a> <a href="./media/preview_smart_playlist.png"><img src="./media/preview_smart_playlist.png" width="49.5%"/></a>
<a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_full_screen_player.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_full_screen_player.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png" width="49.5%"/></a>
## Getting Started
+4 -4
View File
@@ -1,15 +1,15 @@
services:
feishin:
container_name: feishin
image: "ghcr.io/jeffvli/feishin:latest"
image: ghcr.io/jeffvli/feishin:latest
restart: unless-stopped
environment:
- SERVER_NAME=jellyfin # pre-defined server name
- SERVER_LOCK=false # When true AND name/type/url are set, only username/password can be toggled
- SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled
- SERVER_TYPE=jellyfin # the allowed types are: jellyfin, navidrome, subsonic. These values are case insensitive
- SERVER_URL=http://localhost:8096 # http://address:port or https://address:port
- SERVER_URL= # http://address:port or https://address:port
- LEGACY_AUTHENTICATION=false # When SERVER_LOCK is true, sets the legacyauth flag for server authentication (true or false)
- ANALYTICS_DISABLED=false # Set to true to disable Umami analytics tracking
- ANALYTICS_DISABLED=false # When true, disables analytics
ports:
- 9180:9180
# Alternatively, to restrict to only localhost, - 127.0.0.1:9180:8190
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# Set default values for environment variables if not already set
export LEGACY_AUTHENTICATION=${LEGACY_AUTHENTICATION:-false}
export ANALYTICS_DISABLED=${ANALYTICS_DISABLED:-false}
# Execute the original nginx command
exec "$@"
+1 -1
View File
@@ -15,7 +15,7 @@ win:
target:
- zip
- nsis
icon: assets/icons/icon.ico
icon: assets/icons/icon.png
nsis:
allowToChangeInstallationDirectory: true
-8
View File
@@ -6,7 +6,6 @@ import dynamicImportPlugin from 'vite-plugin-dynamic-import';
import { ViteEjsPlugin } from 'vite-plugin-ejs';
const currentOSEnv = process.platform;
const electronRendererTarget = 'chrome87';
const config: UserConfig = {
main: {
@@ -37,9 +36,6 @@ const config: UserConfig = {
},
},
preload: {
build: {
sourcemap: true,
},
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
@@ -52,11 +48,7 @@ const config: UserConfig = {
build: {
cssMinify: 'esbuild',
minify: 'esbuild',
modulePreload: {
polyfill: false,
},
sourcemap: true,
target: electronRendererTarget,
},
css: {
modules: {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 733 KiB

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 869 KiB

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 990 KiB

After

Width:  |  Height:  |  Size: 887 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

After

Width:  |  Height:  |  Size: 396 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.3.0",
"version": "1.2.0",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
-1
View File
@@ -23,7 +23,6 @@ export default defineConfig({
assetFileNames: '[name].[ext]',
chunkFileNames: '[name].js',
entryFileNames: '[name].js',
sourcemapExcludeSources: false,
},
},
sourcemap: true,
+1 -1
View File
@@ -1 +1 @@
"use strict";window.SERVER_URL="${SERVER_URL}";window.SERVER_NAME="${SERVER_NAME}";window.SERVER_TYPE="${SERVER_TYPE}";window.SERVER_LOCK="${SERVER_LOCK}";window.LEGACY_AUTHENTICATION="${LEGACY_AUTHENTICATION}";window.ANALYTICS_DISABLED="${ANALYTICS_DISABLED}";
"use strict";window.SERVER_URL="${SERVER_URL}";window.SERVER_NAME="${SERVER_NAME}";window.SERVER_TYPE="${SERVER_TYPE}";window.SERVER_LOCK=${SERVER_LOCK};window.LEGACY_AUTHENTICATION=${LEGACY_AUTHENTICATION};window.ANALYTICS_DISABLED="${ANALYTICS_DISABLED}";
+13 -47
View File
@@ -310,11 +310,7 @@
"tableColumns": "columnes de la taula",
"itemsMore": "{{count}} més",
"countSelected": "{{count}} seleccionats",
"retry": "reintenta",
"example": "exemple",
"mood": "estat d'ànim",
"filter_single": "senzill",
"filter_multiple": "multi"
"retry": "reintenta"
},
"entity": {
"album_one": "àlbum",
@@ -422,10 +418,7 @@
"success": "servidor afegit correctament",
"title": "afegeix un servidor",
"input_preferInstantMix": "prefereix el mix instantani",
"input_preferInstantMixDescription": "utilitza només el mix instantani per obtenir cançons similars. útil si teniu complements que modifiquin aquest comportament",
"input_preferRemoteUrl": "prefereix l'url públic",
"input_remoteUrl": "url públic",
"input_remoteUrlPlaceholder": "opcional: url públic per característiques externes"
"input_preferInstantMixDescription": "utilitza només el mix instantani per obtenir cançons similars. útil si teniu complements que modifiquin aquest comportament"
},
"shareItem": {
"description": "descripció",
@@ -770,7 +763,7 @@
"releaseChannel": "canal de versions",
"releaseChannel_description": "tria entre versions estables i versions beta per les actualitzacions automàtiques",
"mediaSession": "activa Media Session",
"mediaSession_description": "activa la integració amb Media Session per mostrar els controls multimèdia i les metadades a l'indicador de volum del sistema i la pantalla de bloqueig",
"mediaSession_description": "activa la integració amb Windows Media Session per mostrar els controls multimèdia i les metadades a l'indicador de volum del sistema i la pantalla de bloqueig (només per Windows)",
"crossfadeStyle": "estil de fosa encadenada",
"discordRichPresence": "estat d'activitat de {{discord}}",
"enableAutoTranslation_description": "activa la traducció automàtica en carregar la lletra",
@@ -843,20 +836,7 @@
"showRatings_description": "controla si es mostren les estrelles de valoració a la interfície",
"showRatings": "mostra la valoració d'estrelles",
"combinedLyricsAndVisualizer_description": "combina la lletra i el visualitzador en un sol tauler",
"combinedLyricsAndVisualizer": "combina la lletra i el visualitzador al tauler lateral del reproductor",
"artistReleaseTypeConfiguration": "configuració de tipus de llançament d'artista",
"artistReleaseTypeConfiguration_description": "configura quins llançaments es mostren, i en quin ordre, a la pàgina d'artista de l'àlbum",
"hotkey_listNavigateToPage": "navega per la llista fins a la pàgina de l'element",
"hotkey_listPlayDefault": "reprodueix llista",
"hotkey_listPlayLast": "reprodueix la llista al final",
"hotkey_listPlayNext": "reprodueix la llista a continuació",
"hotkey_listPlayNow": "reprodueix la llista ara",
"mpvExtraParameters": "paràmetres addicionals d'mpv",
"mpvExtraParameters_description": "arguments addicionals per l'mpv",
"pathReplace": "substitució de la ruta de l'arxiu",
"pathReplace_description": "substitueix la ruta d'arxiu predeterminada del servidor",
"pathReplace_optionRemovePrefix": "elimina el prefix",
"pathReplace_optionAddPrefix": "afegeix prefix"
"combinedLyricsAndVisualizer": "combina la lletra i el visualitzador al tauler lateral del reproductor"
},
"table": {
"column": {
@@ -953,9 +933,7 @@
"bitDepth": "$t(common.bitDepth)",
"genreBadge": "$t(entity.genre_one) (insígnies)",
"image": "imatge",
"sampleRate": "$t(common.sampleRate)",
"composer": "compositor",
"titleArtist": "$t(common.title) (artista)"
"sampleRate": "$t(common.sampleRate)"
},
"view": {
"table": "taula",
@@ -1044,6 +1022,9 @@
"addLastShuffled": "al final (mesclat)",
"addNextShuffled": "a continuació (mesclat)",
"holdToShuffle": "mantén premut per mesclar",
"queueType": "tipus de cua",
"queueType_default": "predeterminat",
"queueType_priority": "prioritat",
"lyrics": "lletra",
"restoreQueueFromServer": "restaura la cua del servidor",
"saveQueueToServer": "desa la cua al servidor",
@@ -1259,11 +1240,10 @@
"dualVertical": "Dual-Vertical"
},
"frequencyScale": {
"bark": "Escala Bark",
"linear": "Escala Lineal",
"log": "Escala logarítmica",
"mel": "Escala Mel",
"none": "Cap"
"bark": "Bark",
"linear": "Lineal",
"log": "Registre",
"mel": "Mel"
},
"weightingFilter": {
"none": "Cap",
@@ -1272,21 +1252,7 @@
"c": "C",
"d": "D",
"z": "Z"
},
"mode": {
"0": "[0] Freqüències discretes",
"1": "[1] 1/24a octava / 240 bandes",
"2": "[2] 1/12a octava / 120 bandes",
"3": "[3] 1/8a octava / 80 bandes",
"4": "[4] 1/6a octava / 60 bandes",
"5": "[5] 1/4a octava / 40 bandes",
"6": "[6] 1/3a octava / 30 bandes",
"7": "[7] Mitja octava / 20 bandes",
"8": "[8] Octava completa / 10 bandes",
"10": "[10] Línia / Gràfic d'àrea"
}
},
"pasteGradient": "enganxa degradat",
"pasteGradientPlaceholder": "enganxa el degradat JSON aquí..."
}
}
}
+6 -7
View File
@@ -33,6 +33,9 @@
"viewQueue": "zobrazit frontu",
"addLastShuffled": "poslední (náhodně)",
"addNextShuffled": "další (náhodně)",
"queueType": "typ fronty",
"queueType_default": "výchozí",
"queueType_priority": "priorita",
"holdToShuffle": "podržte pro zamíchání",
"lyrics": "texty",
"restoreQueueFromServer": "obnovit frontu ze serveru",
@@ -286,7 +289,7 @@
"releaseChannel": "kanál vydání",
"releaseChannel_description": "vyberte si mezi stabilními vydáními nebo beta vydáními pro automatické aktualizace",
"mediaSession": "povolit relaci médií",
"mediaSession_description": "povolí integraci do služby Media Session, což zobrazí ovládání a metadata médií v překrytí systémové hlasitosti a na zamykací obrazovce",
"mediaSession_description": "povolí integraci do služby Windows Media Session, což zobrazí ovládání a metadata médií v překrytí systémové hlasitosti a na zamykací obrazovce (pouze Windows)",
"exportImportSettings_control_description": "exportovat a importovat nastavení pomocí souboru JSON",
"exportImportSettings_control_exportText": "exportovat nastavení",
"exportImportSettings_control_importText": "importovat nastavení",
@@ -533,9 +536,7 @@
"countSelected": "vybráno {{count}}",
"retry": "zkusit znovu",
"mood": "nálada",
"example": "příklad",
"filter_single": "jeden",
"filter_multiple": "několik"
"example": "příklad"
},
"table": {
"config": {
@@ -608,9 +609,7 @@
"genreBadge": "$t(entity.genre_one) (značky)",
"image": "obrázek",
"bitDepth": "$t(common.bitDepth)",
"sampleRate": "$t(common.sampleRate)",
"composer": "skladatel",
"titleArtist": "$t(common.title) (umělec)"
"sampleRate": "$t(common.sampleRate)"
}
},
"column": {
+3
View File
@@ -691,7 +691,10 @@
"viewQueue": "Wiedergabeliste anzeigen",
"addLastShuffled": "als Letztes (zufällige Wiedergabe)",
"addNextShuffled": "als Nächstes (zufällige Wiedergabe)",
"queueType_default": "Standard",
"queueType_priority": "Priorität",
"holdToShuffle": "Halten für Zufallswiedergabe",
"queueType": "Wiedergabelistentyp",
"restoreQueueFromServer": "Wiedergabeliste von Server wiederherstellen",
"saveQueueToServer": "Wiedergabeliste auf Server speichern"
},
+5 -6
View File
@@ -90,8 +90,6 @@
"filter_one": "filter",
"filter_other": "filters",
"filters": "filters",
"filter_single": "single",
"filter_multiple": "multi",
"forceRestartRequired": "restart to apply changes… close the notification to restart",
"forward": "forward",
"gap": "gap",
@@ -631,6 +629,9 @@
"queue_moveToBottom": "move selected to top",
"queue_moveToTop": "move selected to bottom",
"queue_remove": "remove selected",
"queueType": "queue type",
"queueType_default": "default",
"queueType_priority": "priority",
"repeat": "repeat",
"repeat_all": "repeat all",
"repeat_off": "repeat disabled",
@@ -845,7 +846,7 @@
"lastfmApiKey": "{{lastfm}} API key",
"lyricFetch_description": "fetch lyrics from various internet sources",
"lyricFetch": "fetch lyrics from the internet",
"lyricFetchProvider_description": "select the providers to fetch lyrics from",
"lyricFetchProvider_description": "select the providers to fetch lyrics from. the order of the providers is the order in which they will be queried",
"lyricFetchProvider": "providers to fetch lyrics from",
"lyricOffset_description": "offset the lyric by the specified amount of milliseconds",
"lyricOffset": "lyric offset (ms)",
@@ -964,7 +965,7 @@
"sidePlayQueueStyle_description": "sets the style of the side play queue",
"sidePlayQueueStyle_optionAttached": "attached",
"sidePlayQueueStyle_optionDetached": "detached",
"mediaSession_description": "enables Media Session integration, displaying media controls and metadata in the system volume overlay and lock screen",
"mediaSession_description": "enables Windows Media Session integration, displaying media controls and metadata in the system volume overlay and lock screen (Windows only)",
"mediaSession": "enable media session",
"sidePlayQueueStyle": "side play queue style",
"skipDuration_description": "sets the duration to skip when using the skip buttons on the player bar",
@@ -1085,7 +1086,6 @@
"bpm": "$t(common.bpm)",
"channels": "$t(common.channel_other)",
"codec": "$t(common.codec)",
"composer": "composer",
"dateAdded": "date added",
"discNumber": "disc number",
"duration": "$t(common.duration)",
@@ -1105,7 +1105,6 @@
"size": "$t(common.size)",
"songCount": "$t(entity.track_other)",
"title": "$t(common.title)",
"titleArtist": "$t(common.title) (artist)",
"titleCombined": "$t(common.title) (combined)",
"trackNumber": "track number",
"year": "$t(common.year)"
+11 -12
View File
@@ -33,6 +33,9 @@
"viewQueue": "ver cola",
"addLastShuffled": "Al final (mezclado)",
"addNextShuffled": "Al siguiente (mezclado)",
"queueType": "Tipo de cola",
"queueType_default": "Predeterminado",
"queueType_priority": "Prioridad",
"holdToShuffle": "Mantener para mezclar",
"lyrics": "Letras",
"restoreQueueFromServer": "Restaurar cola del servidor",
@@ -49,7 +52,7 @@
"theme_description": "establece el tema a usar por la aplicación",
"hotkey_playbackPause": "pausa",
"replayGainFallback": "{{ReplayGain}} alternativa",
"sidebarCollapsedNavigation_description": "Muestra u oculta la navegación en la barra lateral contraída",
"sidebarCollapsedNavigation_description": "mostrar u ocultar la navegación en la barra lateral contraída",
"hotkey_volumeUp": "subir volumen",
"skipDuration": "duración de salto",
"discordIdleStatus_description": "cuando se activa, actualiza el estado mientras el reproductor está inactivo",
@@ -119,7 +122,7 @@
"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 u oculta los botones de saltar en la barra del reproductor",
"showSkipButtons_description": "muestra o esconde los botones de saltar en la barra del reproductor",
"crossfadeDuration_description": "establece la duración del efecto de crossfade",
"playbackStyle": "estilo de reproducción",
"hotkey_toggleShuffle": "alterna aleatorio",
@@ -134,12 +137,12 @@
"exitToTray": "salir a la bandeja",
"hotkey_rate4": "calificar con 4 estrellas",
"enableRemote": "activar control remoto del servidor",
"showSkipButton_description": "Muestra u oculta los botones de saltar en la barra del reproductor",
"showSkipButton_description": "muestra o esconde los botones de saltar en la barra del reproductor",
"savePlayQueue": "guardar cola de reproducción",
"minimumScrobbleSeconds_description": "la duración mínima en segundos de la canción que debe ser reproducida antes de hacer scrobble",
"fontType_description": "Fuente incorporada selecciona una de las fuentes proporcionadas por feishin. Fuente del sistema te permite seleccionar cualquier fuente proporcionada por tu sistema operativo. Personalizada te permite proporcionar tu propia fuente",
"playButtonBehavior": "comportamiento del botón de reproducción",
"sidebarPlaylistList_description": "Muestra u oculta las listas de reproducción en la barra lateral",
"sidebarPlaylistList_description": "muestra o esconde las listas de reproducción en la barra lateral",
"sidePlayQueueStyle_description": "establece el estilo de la cola de reproducción lateral",
"replayGainMode": "modo de {{ReplayGain}}",
"playbackStyle_optionNormal": "normal",
@@ -220,7 +223,7 @@
"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 ocultar elementos que son mostrados en el menú cuando haces clic derecho en un elemento. Los elementos que no estén seleccionados se ocultarán",
"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 escribir CSS personalizado",
"customCss": "CSS personalizado",
@@ -286,7 +289,7 @@
"releaseChannel_description": "Elige entre lanzamientos estables o beta para las actualizaciones automáticas",
"artistBackground_description": "Añade una imagen de fondo para las páginas de artista que contienen el arte del artista",
"mediaSession": "Activar sesión de medios",
"mediaSession_description": "Activa la integración de la sesión de medios, mostrando los controles de medios y los metadatos en la superposición del volumen del sistema y en la pantalla de bloqueo",
"mediaSession_description": "Activa la integración de la sesión de medios de Windows, mostrando los controles de medios y los metadatos en la superposición del volumen del sistema y en la pantalla de bloqueo (solo en Windows)",
"exportImportSettings_control_description": "Exporta e importa la configuración a través de JSON",
"exportImportSettings_control_exportText": "exportar configuración",
"exportImportSettings_control_importText": "importar configuración",
@@ -533,9 +536,7 @@
"countSelected": "{{count}} seleccionados",
"retry": "Reintentar",
"mood": "Estado de ánimo",
"example": "Ejemplo",
"filter_single": "simple",
"filter_multiple": "multi"
"example": "Ejemplo"
},
"error": {
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
@@ -979,9 +980,7 @@
"genreBadge": "$t(entity.genre_one) (insignias)",
"image": "Imagen",
"bitDepth": "$t(common.bitDepth)",
"sampleRate": "$t(common.sampleRate)",
"titleArtist": "$t(common.title) (artista)",
"composer": "Compositor"
"sampleRate": "$t(common.sampleRate)"
},
"general": {
"gap": "$t(common.gap)",
+22 -126
View File
@@ -7,7 +7,7 @@
"moveToBottom": "mugitu behera",
"moveToTop": "mugitu gora",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "kendu gogokoetatik",
"removeFromFavorites": "kendu $t(entity.favorite_other)-(e)tik",
"removeFromPlaylist": "kendu $t(entity.playlist_one)-(e)tik",
"removeFromQueue": "kendu ilaratik",
"setRating": "ezarri balorazioa",
@@ -20,18 +20,12 @@
"clearQueue": "garbitu ilara",
"createPlaylist": "sortu $t(entity.playlist_one)",
"deletePlaylist": "ezabatu $t(entity.playlist_one)",
"addToFavorites": "gehitu gogokoetara",
"addToPlaylist": "gehitu $t(entity.playlist_one)ra",
"addToFavorites": "gehitu $t(entity.favorite_other)-(e)ra",
"addToPlaylist": "gehitu $t(entity.playlist_one)-(e)ra",
"createRadioStation": "sortu $t(entity.radioStation_one)",
"deleteRadioStation": "ezabatu $t(entity.radioStation_one)",
"viewMore": "ikusi gehiago",
"shuffle": "nahastu",
"selectAll": "aukeratu guztiak",
"downloadStarted": "{{count}} elementuren deskarga hasi da",
"addOrRemoveFromSelection": "gehitu edo kendu hautapenetik",
"selectRangeOfItems": "aukeratu elementu sorta bat",
"shuffleAll": "nahastu dena",
"shuffleSelected": "nahastu aukeratutak"
"shuffle": "nahastu"
},
"common": {
"add": "gehitu",
@@ -65,7 +59,7 @@
"filter_other": "iragazkiak",
"filters": "iragazkiak",
"forceRestartRequired": "berreabiarazi aldaketak aplikatzeko... itxi notifikazioa berreabiarazteko",
"setting": "ezarpenak",
"setting": "ezarpena",
"share": "partekatu",
"action_one": "ekintza",
"action_other": "ekintzak",
@@ -143,10 +137,7 @@
"retry": "saiatu berriro",
"slower": "motelago",
"itemsMore": "{{count}} gehiago",
"sort": "ordenatu",
"recordLabel": "diskoetxea",
"example": "adibidea",
"tableColumns": "taulako zutabeak"
"sort": "ordenatu"
},
"player": {
"repeat": "errepikatu",
@@ -181,10 +172,11 @@
"viewQueue": "ikusi ilara",
"playbackFetchCancel": "honek denbora pixka bat behar du... itxi jakinarazpena bertan behera uzteko",
"lyrics": "letrak",
"queueType": "ilara mota",
"queueType_default": "lehenetsia",
"queueType_priority": "lehentasuna",
"restoreQueueFromServer": "berrezarri ilara zerbitzaritik",
"saveQueueToServer": "gorde ilara zerbitzarira",
"addLastShuffled": "azkena (ausaz)",
"addNextShuffled": "hurrengoa (ausaz)"
"saveQueueToServer": "gorde ilara zerbitzarira"
},
"table": {
"config": {
@@ -199,19 +191,7 @@
"tableColumns": "taula zutabeak",
"itemSize": "elementuaren tamaina (px)",
"followCurrentSong": "jarraitu uneko abestia",
"size_default": "lehenetsia",
"advancedSettings": "ezarpen aurreratuak",
"autoFitColumns": "zutabeak automatikoki doitu",
"pinToLeft": "ezkerrera finkatu",
"pinToRight": "eskuinera finkatu",
"alignLeft": "ezkerrean lerrokatu",
"alignCenter": "lerrokatu erdian",
"alignRight": "eskuinean lerrokatu",
"itemGap": "elementuen arteko tartea (px)",
"itemsPerRow": "elementuak errenkada bakoitzeko",
"size_large": "handia",
"pagination_itemsPerPage": "elementuak orrialde bakoitzeko",
"pagination_infinite": "infinitua"
"size_default": "lehenetsia"
},
"label": {
"actions": "$t(common.action_other)",
@@ -311,11 +291,7 @@
"song_one": "abestia",
"song_other": "abestiak",
"trackWithCount_one": "pista {{count}}",
"trackWithCount_other": "{{count}} pista",
"radioStation_one": "irrati-katea",
"radioStation_other": "irrati-kateak",
"radioStationWithCount_one": "irrati-kate {{count}}",
"radioStationWithCount_other": "{{count}} irrati-kate"
"trackWithCount_other": "{{count}} pista"
},
"error": {
"apiRouteError": "ezin izan da eskaera bideratu",
@@ -600,42 +576,7 @@
"imageResolution_optionSidebar": "alboko barra",
"replayGainClipping": "{{ReplayGain}} mozketa",
"replayGainFallback": "{{ReplayGain}} ordezko aukera",
"trayEnabled": "erakutsi erretilua",
"artistReleaseTypeConfiguration": "artistaren argitalpen motaren konfigurazioa",
"artistReleaseTypeConfiguration_description": "konfiguratu zein argitalpen mota erakusten diren, eta zein ordenatan, albumaren artistaren orrian",
"useThemeAccentColor": "erabili gaiaren azentu-kolorea",
"useThemeAccentColor_description": "erabili hautatutako gaian definitutako kolore nagusia azentu-kolore pertsonalizatuaren ordez",
"showRatings": "erakutsi izarren balorazioak",
"showRatings_description": "izarren balorazioen funtzioa interfazean agertzen den ala ez kontrolatzen du",
"imageResolution": "irudiaren erresoluzioa",
"imageResolution_description": "aplikazioan erabilitako irudien erresoluzioa. 0 balioa erabiliz gero, jatorrizko irudiaren erresoluzioa erabiliko da lehenespenez",
"followCurrentSong_description": "automatikoki korritu erreprodukzio-ilara uneko abestira",
"followCurrentSong": "jarraitu uneko abestia",
"lyricOffset_description": "letra zehaztutako milisegundo kopuruarekin desplazatu",
"lyricOffset": "letraren desplazamendua (ms)",
"mpvExtraParameters": "mpv parametro gehigarriak",
"mpvExtraParameters_description": "mpv-ri pasatzeko argumentu gehigarriak",
"notify": "abestien jakinarazpenak gaitu",
"notify_description": "erakutsi jakinarazpenak uneko abestia aldatzean",
"pathReplace": "fitxategiaren bidearen ordezkapena",
"pathReplace_description": "ordezkatu zure zerbitzariaren fitxategi-bide lehenetsia",
"pathReplace_optionRemovePrefix": "kendu aurrizkia",
"pathReplace_optionAddPrefix": "gehitu aurrizkia",
"passwordStore_description": "zein pasahitz/sekretu biltegi erabili. aldatu hau pasahitzak gordetzeko arazoak badituzu",
"playerFilters": "Iragazi ilarako abestiak",
"sidePlayQueueStyle_description": "alboko erreprodukzio-ilararen estiloa ezartzen du",
"mediaSession_description": "Windows Media Session integrazioa gaitzen du, multimedia kontrolak eta metadatuak sistemaren bolumenaren gainjartzean eta blokeo pantailan bistaratuz (Windows bakarrik)",
"sidePlayQueueStyle": "alboko erreprodukzio-ilarako estiloa",
"skipPlaylistPage": "saltatu erreprodukzio-zerrenda orria",
"startMinimized_description": "abiarazi aplikazioa sistemaren erretiluan",
"startMinimized": "hasi minimizatuta",
"transcode": "gaitu transkodetzea",
"transcode_description": "formatu ezberdinetara transkodetzea ahalbidetzen du",
"transcodeBitrate_description": "transkodetzeko bit-emaria hautatzen du. 0k zerbitzariari aukeratzen uzten diola esan nahi du",
"transcodeBitrate": "transkodetzeko bit-emaria",
"transcodeFormat_description": "transkodetzeko formatua hautatzen du. utzi hutsik zerbitzariak erabaki dezan",
"transcodeFormat": "transkodetzeko formatua",
"queryBuilderCustomFields_inputLabel": "etiketa"
"trayEnabled": "erakutsi erretilua"
},
"form": {
"addServer": {
@@ -651,8 +592,7 @@
"input_legacyAuthentication": "gaitu zaharkitutako autentifikazioa",
"success": "zerbitzaria behar bezala gehitu da",
"input_preferInstantMix": "nahiago izan berehalako nahasketa",
"input_preferInstantMixDescription": "erabili berehalako nahasketa soilik antzeko abestiak lortzeko. erabilgarria portaera hau aldatzen duten pluginak badituzu",
"input_remoteUrl": "URL publikoa"
"input_preferInstantMixDescription": "erabili berehalako nahasketa soilik antzeko abestiak lortzeko. erabilgarria portaera hau aldatzen duten pluginak badituzu"
},
"addToPlaylist": {
"input_playlists": "$t(entity.playlist_other)",
@@ -697,9 +637,7 @@
"queryEditor": {
"title": "kontsulta editorea",
"input_optionMatchAll": "guztiak bat etorri",
"input_optionMatchAny": "edozeinekin bat etorri",
"resetToDefault": "lehenetsitako egoerara berrezarri",
"clearFilters": "garbitu iragazkiak"
"input_optionMatchAny": "edozeinekin bat etorri"
},
"updateServer": {
"success": "zerbitzaria behar bezala eguneratu da",
@@ -715,9 +653,7 @@
},
"createRadioStation": {
"input_homepageUrl": "hasierako orriaren URLa",
"input_name": "izena",
"title": "irrati-katea sortu",
"success": "irrati-katea behar bezala sortu da"
"input_name": "izena"
},
"lyricsExport": {
"export": "esportatu letrak",
@@ -725,15 +661,7 @@
"input_offset": "$t(setting.lyricOffset)"
},
"shuffleAll": {
"input_genre": "$t(entity.genre_one)",
"title": "ausaz erreproduzitu",
"input_limit": "zenbat abesti?",
"input_played_optionAll": "pista guztiak",
"input_played_optionUnplayed": "erreproduzitu gabeko pistak bakarrik",
"input_played_optionPlayed": "erreproduzitutako pistak bakarrik"
},
"saveQueue": {
"success": "erreprodukzio-ilara zerbitzarian gordeta"
"input_genre": "$t(entity.genre_one)"
}
},
"page": {
@@ -762,11 +690,7 @@
"privateModeOn": "aktibatu modu pribatua",
"selectServer": "aukeratu zerbitzaria",
"version": "bertsioa {{version}}",
"openBrowserDevtools": "ireki nabigatzailearen garapen tresnak",
"commandPalette": "ireki komando-paleta",
"noMusicFolder": "ez da musika karpetarik hautatu",
"selectMusicFolder": "aukeratu musika karpeta",
"multipleMusicFolders": "{{count}} musika karpeta aukeratuta"
"openBrowserDevtools": "ireki nabigatzailearen garapen tresnak"
},
"manageServers": {
"url": "URLa",
@@ -867,11 +791,7 @@
"lyrics": "letrak",
"discord": "discord",
"playerFilters": "erreproduzitzailearen iragazkiak",
"updates": "eguneraketa",
"queryBuilder": "kontsulta-sortzailea",
"controls": "kontrolak",
"remote": "urrunekoa",
"lyricsDisplay": "erakutsi letrak"
"updates": "eguneraketa"
},
"sidebar": {
"albumArtists": "$t(entity.albumArtist_other)",
@@ -904,9 +824,7 @@
"viewAllTracks": "ikusi $t(entity.track_other) guztiak",
"appearsOn": "agertzen da hemen",
"recentReleases": "azken argitalpenak",
"viewDiscography": "ikusi diskografia",
"groupingTypeAll": "argitalpen mota guztiak",
"groupingTypePrimary": "argitalpen mota nagusiak"
"viewDiscography": "ikusi diskografia"
},
"itemDetail": {
"copyPath": "kopiatu bidea arbelean",
@@ -921,9 +839,6 @@
},
"favorites": {
"title": "$t(entity.favorite_other)"
},
"radioList": {
"title": "irrati-kateak"
}
},
"releaseType": {
@@ -949,12 +864,7 @@
"customTags": "etiketa pertsonalizatutak"
},
"filterOperator": {
"is": "da",
"contains": "dauka",
"notContains": "ez dauka",
"startsWith": "honekin hasten da",
"endsWith": "honekin amaitzen da",
"isNot": "ez da"
"is": "da"
},
"visualizer": {
"general": "Orokorra",
@@ -995,20 +905,6 @@
"z": "Z"
}
},
"opacity": "Opakotasuna",
"minimumFrequency": "Gutxieneko Maiztasuna",
"maximumFrequency": "Gehienezko Maiztasuna",
"frequencyScale": "Maiztasun Eskala",
"weightingFilter": "Ponderazio-iragazkia",
"minimumDecibels": "Gutxieneko Dezibelioak",
"maximumDecibels": "Gehienezko Dezibelioak",
"linearAmplitude": "Anplitude Lineala",
"linearBoost": "Bultzada Lineala",
"showPeaks": "Erakutsi Gailurrak",
"configCopied": "Konfigurazioa arbelean kopiatu da",
"configCopyFailed": "Konfigurazioa kopiatzeak huts egin du",
"configPasted": "Konfigurazioa behar bezala aplikatu da",
"configPasteFailed": "Konfigurazioa aplikatzeak huts egin du. Mesedez, egiaztatu formatua.",
"configPasteReadFailed": "Arbelatik irakurtzeak huts egin du"
"opacity": "Opakotasuna"
}
}
+15 -131
View File
@@ -33,12 +33,13 @@
"viewQueue": "voir la file d'attente",
"addLastShuffled": "dernier (mélangé)",
"addNextShuffled": "prochain (mélangé)",
"queueType": "type de file d'attente",
"queueType_default": "défaut",
"queueType_priority": "priorité",
"holdToShuffle": "maintenir pour mélanger",
"lyrics": "paroles",
"restoreQueueFromServer": "restaurer la file d'attente depuis le serveur",
"saveQueueToServer": "enregistrer la file d'attente sur le serveur",
"artistRadio": "radio de l'artiste",
"trackRadio": "radio du titre"
"saveQueueToServer": "enregistrer la file d'attente sur le serveur"
},
"action": {
"editPlaylist": "éditer $t(entity.playlist_one)",
@@ -74,11 +75,7 @@
"holdToMoveToTop": "Maintenir pour déplacer en haut",
"holdToMoveToBottom": "Maintenir pour déplacer en bas",
"createRadioStation": "créer $t(entity.radioStation_one)",
"deleteRadioStation": "supprimer $t(entity.radioStation_one)",
"addOrRemoveFromSelection": "ajouter ou supprimer de la sélection",
"selectRangeOfItems": "sélectionner une plage d'entrées",
"selectAll": "tout sélectionner",
"openApplicationDirectory": "ouvrir le répertoire de l'application"
"deleteRadioStation": "supprimer $t(entity.radioStation_one)"
},
"common": {
"backward": "en arrière",
@@ -198,11 +195,7 @@
"tableColumns": "colonnes du tableau",
"itemsMore": "plus {{count}}",
"view": "vue",
"noFilters": "aucun filtre configuré",
"countSelected": "{{count}} sélectionnée",
"example": "exemple",
"mood": "humeur",
"retry": "réessayer"
"noFilters": "aucun filtre configuré"
},
"error": {
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
@@ -231,9 +224,7 @@
"notificationDenied": "les autorisations pour les notifications ont été refusées. ce paramètre n'a aucun effet",
"multipleServerSaveQueueError": "la file d'attente de lecture contient un ou plusieurs morceaux qui ne proviennent pas du serveur actuel. Ceci n'est pas prise en charge",
"saveQueueFailed": "échec de l'enregistrement de la file d'attente",
"settingsSyncError": "des incohérences ont été détectées entre les paramètres du moteur de rendu et ceux du processus principal. redémarrez l'application pour appliquer les modifications",
"noNetwork": "serveur indisponible",
"noNetworkDescription": "impossible de se connecter à ce serveur"
"settingsSyncError": "des incohérences ont été détectées entre les paramètres du moteur de rendu et ceux du processus principal. redémarrez l'application pour appliquer les modifications"
},
"filter": {
"mostPlayed": "plus joués",
@@ -375,8 +366,7 @@
"transcoding": "transcodage",
"discord": "discord",
"logger": "logger",
"playerFilters": "filtres du lecteur",
"lyricsDisplay": "affichage des paroles"
"playerFilters": "filtres du lecteur"
},
"globalSearch": {
"commands": {
@@ -444,8 +434,7 @@
"recentReleases": "sorties récentes",
"viewDiscography": "voir la discographie",
"relatedArtists": "$t(entity.artist_other) similaires",
"topSongs": "meilleurs titres",
"groupingTypeAll": "toutes les types de sortie"
"topSongs": "meilleurs titres"
},
"itemDetail": {
"copyPath": "copier le chemin dans le presse-papiers",
@@ -719,7 +708,7 @@
"releaseChannel": "canal de diffusion",
"releaseChannel_description": "choisissez entre les versions stables ou les versions bêta pour les mises à jour automatiques",
"mediaSession": "activer media session",
"mediaSession_description": "active l'intégration Media Session, affichant les commandes multimédias et les métadonnées dans la superposition du volume du système et l'écran de verrouillage",
"mediaSession_description": "active l'intégration de la session Windows Media, affichant les commandes multimédias et les métadonnées dans la superposition du volume du système et l'écran de verrouillage (Windows uniquement)",
"enableAutoTranslation_description": "activer la traduction automatiquement lorsque les paroles sont chargées",
"enableAutoTranslation": "activer la traduction automatique",
"exportImportSettings_control_description": "exporter et importer les paramètres en JSON",
@@ -740,8 +729,8 @@
"notify": "activer les notifications de chansons",
"analyticsDisable": "Désactiver l'analytique basée sur l'utilisation",
"analyticsDisable_description": "les données d'utilisation anonymisées sont envoyées au développeur afin de contribuer à l'amélioration de l'application",
"playerbarSlider": "barre de lecture",
"playerbarSliderType_optionSlider": "pleine",
"playerbarSlider": "curseur de la barre de lecture",
"playerbarSliderType_optionSlider": "curseur",
"playerbarSliderType_optionWaveform": "forme d'onde",
"playerbarWaveformAlign": "forme d'onde alignée",
"playerbarWaveformAlign_optionTop": "haut",
@@ -775,32 +764,11 @@
"logLevel_optionError": "erreur",
"logLevel_optionInfo": "info",
"logLevel_optionWarn": "avertissement",
"playerFilters": "filtrer les titres de la file d'attente",
"playerFilters": "filtrer les tires de la file d'attente",
"playerFilters_description": "exclure les titres de la file d'attente selon les critères suivants",
"playerbarSlider_description": "la forme d'onde n'est pas recommandée sur une connexion lente ou limitée",
"useThemeAccentColor": "utiliser la couleur d'accent du thème",
"useThemeAccentColor_description": "utiliser la couleur principale définie dans le thème sélectionné au lieu de la couleur d'accent personnalisée",
"artistReleaseTypeConfiguration": "configuration du type de sortie de l'artiste",
"artistReleaseTypeConfiguration_description": "configure quel type de sortie est affiché, et dans quel ordre, sur la page artiste de l'album",
"mpvExtraParameters": "paramètres supplémentaires de mpv",
"mpvExtraParameters_description": "arguments supplémentaires à transmettre à mpv",
"pathReplace": "remplacement du chemin de fichier",
"pathReplace_description": "remplacez le chemin de fichier par défaut de votre serveur",
"pathReplace_optionRemovePrefix": "supprimer un prefix",
"pathReplace_optionAddPrefix": "ajouter un prefix",
"artistRadioCount_description": "définit le nombre de titres à récupérer pour la radio d'artiste et la radio de titre",
"artistRadioCount": "nombre de radio d'artiste/titre",
"imageResolution": "résolution d'image",
"imageResolution_description": "la résolution d'image utilisée dans l'application. définir une valeur à 0 utilisera la résolution native de l'image",
"imageResolution_optionTable": "tableau",
"imageResolution_optionItemCard": "entrée de carte",
"imageResolution_optionSidebar": "barre latérale",
"imageResolution_optionHeader": "en-tête",
"imageResolution_optionFullScreenPlayer": "lecteur en plein écran",
"showRatings_description": "contrôle si la notation à étoiles s'affiche dans l'interface",
"showRatings": "affiche la notation à étoiles",
"combinedLyricsAndVisualizer_description": "combine les paroles et le visualisateur dans le même panneau",
"combinedLyricsAndVisualizer": "combine les paroles et le visualisateur dans la barre latérale"
"useThemeAccentColor_description": "utiliser la couleur principale définie dans le thème sélectionné au lieu de la couleur d'accent personnalisée"
},
"form": {
"deletePlaylist": {
@@ -821,10 +789,7 @@
"ignoreCors": "ignorer cors $t(common.restartRequired)",
"error_savePassword": "une erreur sest produite lors de la tentative de sauvegarde du mot de passe",
"input_preferInstantMix": "Préférer le mix instantané",
"input_preferInstantMixDescription": "Utiliser uniquement le mix instantané pour jouer des pistes similaires. Activez cette option si vous avez des plugins qui modifient ce comportement",
"input_preferRemoteUrl": "préférer une URL publique",
"input_remoteUrl": "URL publique",
"input_remoteUrlPlaceholder": "optionnel : URL publique pour les fonctionnalités externes"
"input_preferInstantMixDescription": "Utiliser uniquement le mix instantané pour jouer des pistes similaires. Activez cette option si vous avez des plugins qui modifient ce comportement"
},
"addToPlaylist": {
"success": "$t(entity.trackWithCount, {\"count\" : {{message}} }) ajouté à $t(entity.playlistWithCount, {\"count\" : {{numOfPlaylists}} })",
@@ -903,11 +868,6 @@
},
"saveQueue": {
"success": "file d'attente de lecture enregistrée sur le serveur"
},
"lyricsExport": {
"export": "exporter les paroles",
"input_synced": "exporter les paroles synchronisées",
"input_offset": "$t(setting.lyricOffset)"
}
},
"entity": {
@@ -1126,81 +1086,5 @@
"notInPlaylist": "n'est pas dans",
"notInTheLast": "n'est pas dans le dernier",
"startsWith": "commence par"
},
"datetime": {
"minuteShort": "m",
"secondShort": "s",
"hourShort": "h",
"dayShort": "j"
},
"visualizer": {
"visualizerType": "type de visualisateur",
"cyclePresets": "cycle les préréglages",
"cycleTime": "temps de cycle (secondes)",
"includeAllPresets": "inclure tous les préréglages",
"ignoredPresets": "préréglages ignorés",
"selectedPresets": "préréglages sélectionné",
"randomizeNextPreset": "randomiser le préréglage suivant",
"blendTime": "temps de mélange",
"presets": "préréglages",
"selectPreset": "sélectionner un préréglage",
"applyPreset": "appliquer le préréglage",
"saveAsPreset": "enregistrer en tant que préréglage",
"updatePreset": "mettre à jour le préréglage",
"copyConfiguration": "copier la configuration",
"pasteConfiguration": "coller la configuration",
"pasteConfigurationPlaceholder": "coller ici la configuration JSON...",
"pasteFromClipboard": "coller depuis le presse-papier",
"applyConfiguration": "appliquer la configuration",
"configCopied": "configuration copiée dans le presse-papiers",
"configCopyFailed": "échec de la copie de la configuration",
"configPasted": "configuration appliquée avec succès",
"configPasteFailed": "échec de l'application de la configuration. Merci de vérifier le format.",
"configPasteReadFailed": "échec de la lecture du presse-papiers",
"presetName": "nom du préréglage",
"presetNamePlaceholder": "saisissez le nom du préréglage",
"general": "générale",
"mode": "mode",
"mode1To8": "Mode 1 - 8",
"mode10": "Mode 10",
"barSpace": "espacement des barres",
"lineWidth": "Largeur des traits",
"fillAlpha": "remplissage alpha",
"channelLayout": "disposition des canaux",
"maxFPS": "FPS Maximum",
"opacity": "opacité",
"customGradients": "dégradés personnalisés",
"addCustomGradient": "ajouter un dégradés personnalisés",
"gradientName": "nom du dégradé",
"gradientNamePlaceholder": "nom du dégradé",
"vertical": "verticale",
"horizontal": "horizontale",
"colorStops": "couleur d'arrêts",
"addColor": "ajouter un couleur",
"position": "position",
"level": "niveau",
"remove": "supprimer",
"pasteGradient": "coller le dégradé",
"pasteGradientPlaceholder": "coller ici le dégradé JSON...",
"custom": "personnalisé",
"builtIn": "intégré",
"colors": "couleurs",
"colorMode": "mode de couleur",
"gradient": "dégradé",
"gradientLeft": "dégradé gauche",
"gradientRight": "dégradé droite",
"smoothing": "lissage",
"frequencyRangeAndScaling": "plage de fréquence et mise à l'échelle",
"minimumFrequency": "fréquence minimum",
"maximumFrequency": "fréquence maximum",
"frequencyScale": "mise à l'échelle de fréquence",
"sensitivity": "sensibilité",
"weightingFilter": "filter de pondérage",
"minimumDecibels": "décibels minimum",
"maximumDecibels": "décibels maximum",
"linearAmplitude": "amplitude linéaire",
"linearBoost": "boost linéaire",
"peakBehavior": "comportement des piques",
"showPeaks": "afficher les piques"
}
}
+3
View File
@@ -602,6 +602,9 @@
"shuffle_off": "kevert lejátszás ki",
"addLastShuffled": "végére (keverve)",
"addNextShuffled": "következő (keverve)",
"queueType": "lekérdezés típus",
"queueType_default": "alapértelmezett",
"queueType_priority": "prioritás",
"holdToShuffle": "tartsd lenyomva a keveréshez",
"lyrics": "dalszöveg",
"saveQueueToServer": "műsorlista mentése a szerverre",
+8 -38
View File
@@ -32,6 +32,9 @@
"playSimilarSongs": "似たような曲を再生する",
"viewQueue": "キューを表示する",
"lyrics": "歌詞",
"queueType": "キュータイプ",
"queueType_default": "デフォルト",
"queueType_priority": "優先度",
"restoreQueueFromServer": "サーバーからキューを復元",
"saveQueueToServer": "サーバーにキューを保存"
},
@@ -325,25 +328,7 @@
"artistRadioCount": "アーティスト / トラックのラジオカウント",
"artistRadioCount_description": "アーティストラジオとトラックラジオで取得する曲数を設定します",
"imageResolution": "画像の解像度",
"imageResolution_description": "アプリ内で使用される画像の解像度。値を 0 に設定すると、デフォルトでネイティブ画像解像度が適用されます",
"showLyricsInSidebar_description": "添付の再生キューに歌詞を表示するパネルが追加されます",
"showLyricsInSidebar": "プレーヤーのサイドバーに歌詞を表示する",
"showRatings": "星評価を表示する",
"imageResolution_optionSidebar": "サイドバー",
"imageResolution_optionHeader": "ヘッダー",
"imageResolution_optionFullScreenPlayer": "全画面プレーヤー",
"playerbarSlider": "プレーヤーバースライダー",
"playerbarSlider_description": "低速または従量制のインターネット接続の場合は、波形は推奨されません",
"playerbarSliderType_optionSlider": "スライダー",
"playerbarSliderType_optionWaveform": "波形",
"playerbarWaveformAlign": "波形アライメント",
"showRatings_description": "インターフェースに星評価機能を表示するかどうかを制御します",
"showVisualizerInSidebar": "プレーヤーのサイドバーにビジュアライザーを表示する",
"combinedLyricsAndVisualizer": "プレイヤーのサイドバーに歌詞とビジュアライザーを統合する",
"audioFadeOnStatusChange_description": "再生 / 一時停止の状態が変わったときにフェードアウトとフェードインを有効にします",
"audioFadeOnStatusChange": "ステータス変更時の音声フェード",
"combinedLyricsAndVisualizer_description": "歌詞とビジュアライザーを同じパネルに統合します",
"showVisualizerInSidebar_description": "プレーヤーのサイドバーにビジュアライザーを表示するパネルが追加されます"
"imageResolution_description": "アプリ内で使用される画像の解像度。値を 0 に設定すると、デフォルトでネイティブ画像解像度が適用されます"
},
"action": {
"editPlaylist": "$t(entity.playlist_one) を編集",
@@ -381,9 +366,7 @@
"moveDown": "下に移動",
"holdToMoveToTop": "押し続けると一番上に移動します",
"holdToMoveToBottom": "押し続けると一番下に移動します",
"openApplicationDirectory": "アプリケーションディレクトリを開く",
"selectRangeOfItems": "項目の範囲を選択",
"addOrRemoveFromSelection": "選択に追加または削除"
"openApplicationDirectory": "アプリケーションディレクトリを開く"
},
"common": {
"backward": "戻る",
@@ -493,12 +476,7 @@
"retry": "再試行",
"itemsMore": "{{count}} 個以上",
"faster": "より速く",
"slower": "より遅く",
"example": "例",
"mood": "気分",
"recordLabel": "レコードレーベル",
"tableColumns": "テーブル列",
"clean": "クリーン"
"slower": "より遅く"
},
"table": {
"config": {
@@ -878,10 +856,7 @@
"ignoreCors": "CORSを無視 ($t(common.restartRequired))",
"error_savePassword": "パスワードを保存する際にエラーが発生しました",
"input_preferInstantMixDescription": "類似曲を取得するにはインスタントミックスのみを使用してください。この動作を変更するプラグインがある場合に役立ちます",
"input_preferInstantMix": "インスタントミックスを優先する",
"input_preferRemoteUrl": "公開 URL を優先する",
"input_remoteUrl": "公開 URL",
"input_remoteUrlPlaceholder": "オプション: 外部機能用の公開 URL"
"input_preferInstantMix": "インスタントミックスを優先する"
},
"addToPlaylist": {
"success": "$t(entity.trackWithCount, {\"count\": {{message}} }) を $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} }) に追加しました",
@@ -971,8 +946,7 @@
"trackWithCount_other": "{{count}} 個のトラック",
"play_other": "{{count}} 回再生",
"song_other": "曲",
"radioStation_other": "ラジオ局",
"radioStationWithCount_other": "{{count}} 局のラジオ局"
"radioStation_other": "ラジオ局"
},
"dragDropZone": {
"error_oneFileOnly": "1 つのファイルのみ選択してください",
@@ -1005,9 +979,5 @@
"queryBuilder": {
"standardTags": "標準タグ",
"customTags": "カスタムタグ"
},
"filterOperator": {
"matchesRegex": "正規表現に一致",
"notContains": "含まれていない"
}
}
+29 -384
View File
@@ -2,20 +2,20 @@
"action": {
"editPlaylist": "pas $t(entity.playlist_one) aan",
"goToPage": "ga naar pagina",
"moveToTop": "verplaats naar begin",
"moveToTop": "verplaats naar boven",
"addToFavorites": "toevoegen aan $t(entity.favorite_other)",
"addToPlaylist": "toevoegen aan $t(entity.playlist_one)",
"createPlaylist": "maak $t(entity.playlist_one)",
"removeFromPlaylist": "verwijder uit $t(entity.playlist_one)",
"removeFromPlaylist": "verwijder van $t(entity.playlist_one)",
"viewPlaylists": "bekijk $t(entity.playlist_other)",
"refresh": "$t(common.refresh)",
"deletePlaylist": "verwijder $t(entity.playlist_one)",
"removeFromQueue": "verwijder uit wachtrij",
"removeFromQueue": "verwijder van lijst",
"deselectAll": "deselecteer alles",
"moveToBottom": "verplaats naar einde",
"setRating": "kies beoordeling",
"moveToBottom": "verplaats naar bodem",
"setRating": "selecteer rating",
"toggleSmartPlaylistEditor": "editor $t(entity.smartPlaylist) schakelen",
"removeFromFavorites": "verwijder uit $t(entity.favorite_other)",
"removeFromFavorites": "verwijder van $t(entity.favorite_other)",
"clearQueue": "verwijder lijst",
"openIn": {
"lastfm": "Open in Last.fm",
@@ -28,16 +28,16 @@
"shuffleAll": "shuffle alles",
"shuffleSelected": "shuffle geselecteerde",
"viewMore": "bekijk meer",
"addOrRemoveFromSelection": "toevoegen aan of verwijderen uit selectie",
"addOrRemoveFromSelection": "toevoegen of verwijderen van selectie",
"selectRangeOfItems": "selecteer een reeks van nummers",
"createRadioStation": "maak $t(entity.radioStation_one)",
"deleteRadioStation": "verwijder $t(entity.radioStation_one)",
"selectAll": "selecteer alles",
"moveUp": "verplaats omhoog",
"moveDown": "verplaats omlaag",
"holdToMoveToTop": "ingedrukt houden om naar begin te verplaatsen",
"holdToMoveToBottom": "ingedrukt houden om naar einde te verplaatsen",
"openApplicationDirectory": "applicatiemap openen"
"moveUp": "beweeg naar boven",
"moveDown": "beweeg naar beneden",
"holdToMoveToTop": "ingedrukt houden om naar boven te verplaatsen",
"holdToMoveToBottom": "ingedrukt houden om naar beneden te verplaatsen",
"openApplicationDirectory": "applicatiefolder openen"
},
"common": {
"backward": "achteruit",
@@ -121,8 +121,8 @@
"setting": "instelling",
"close": "sluiten",
"additionalParticipants": "andere deelnemers",
"newVersion": "een nieuwe versie is geïnstalleerd ({{version}})",
"viewReleaseNotes": "lees uitgavenotities",
"newVersion": "een nieuwe versie is geinstalleerd ({{version}})",
"viewReleaseNotes": "zie release notes",
"albumGain": "album gain",
"translation": "vertaling",
"explicitStatus": "expliciete status",
@@ -152,10 +152,7 @@
"itemsMore": "{{count}} meer",
"countSelected": "{{count}} geselecteerd",
"view": "bekijken",
"noFilters": "geen filters ingesteld",
"example": "voorbeeld",
"mood": "stemming",
"retry": "opnieuw proberen"
"noFilters": "geen filters ingesteld"
},
"filter": {
"rating": "rating",
@@ -246,8 +243,7 @@
"privateModeOn": "schakel private modus in",
"selectMusicFolder": "selecteer muziekfolder",
"noMusicFolder": "geen muziekfolder geselecteerd",
"multipleMusicFolders": "{{count}} muziekfolders geselecteerd",
"commandPalette": "open opdrachtvenster"
"multipleMusicFolders": "{{count}} muziekfolders geselecteerd"
},
"albumDetail": {
"moreFromArtist": "meer van deze $t(entity.artist_one)",
@@ -294,9 +290,7 @@
"topSongsFrom": "top nummers van {{title}}",
"viewAll": "bekijk alle",
"viewAllTracks": "bekijk alle $t(entity.track_other)",
"recentReleases": "recente uitgaven",
"groupingTypeAll": "alle soorten publicaties",
"groupingTypePrimary": "primaire publicatiesoorten"
"recentReleases": "recente uitgaven"
},
"manageServers": {
"title": "beheer servers",
@@ -325,8 +319,7 @@
"newlyAdded": "nieuw toegevoegde uitgaven",
"recentlyPlayed": "recent afgespeeld",
"recentlyReleased": "recent uitgekomen",
"title": "$t(common.home)",
"genres": "$t(entity.genre_other)"
"title": "$t(common.home)"
},
"favorites": {
"title": "$t(entity.favorite_other)"
@@ -362,10 +355,7 @@
"audio": "geluid",
"lyrics": "songtekst",
"transcoding": "transcoderen",
"discord": "discord",
"lyricsDisplay": "songtekstweergave",
"logger": "logger",
"playerFilters": "spelerfilters"
"discord": "discord"
},
"sidebar": {
"albumArtists": "$t(entity.albumArtist_other)",
@@ -381,19 +371,12 @@
"search": "$t(common.search)",
"settings": "$t(common.setting_other)",
"shared": "$t(entity.playlist_other) gedeeld",
"tracks": "$t(entity.track_other)",
"radio": "$t(entity.radioStation_other)"
"tracks": "$t(entity.track_other)"
},
"trackList": {
"artistTracks": "nummers van {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
"title": "$t(entity.track_other)"
},
"radioList": {
"title": "radiostations"
},
"folderList": {
"title": "$t(entity.folder_other)"
}
},
"error": {
@@ -420,12 +403,7 @@
"networkError": "een netwerkfout heeft zich voorgedaan",
"notificationDenied": "toestemming voor meldingen werd afgewezen. Deze instelling heeft geen effect",
"openError": "kon het bestand niet openen",
"badAlbum": "je ziet deze pagina omdat dit nummer niet onderdeel is van een album. je komt waarchijnlijk dit probleem tegen als je een nummer op het bovenste niveau van je muziekmap hebt staan. Jellyfin kan alleen nummers groeperen als ze in een folder zitten",
"multipleServerSaveQueueError": "De afspeellijst bevat een of meer nummers die niet afkomstig zijn van de huidige server. Dit wordt niet ondersteund",
"noNetwork": "server niet beschikbaar",
"noNetworkDescription": "kan geen verbinding maken met deze server",
"saveQueueFailed": "kan wachtrij niet opslaan",
"settingsSyncError": "Er zijn verschillen gevonden tussen de instellingen in de renderer en het hoofdproces. Start de applicatie opnieuw op om de wijzigingen toe te passen"
"badAlbum": "je ziet deze pagina omdat dit nummer niet onderdeel is van een album. je komt waarchijnlijk dit probleem tegen als je een nummer op het bovenste niveau van je muziekmap hebt staan. Jellyfin kan alleen nummers groeperen als ze in een folder zitten"
},
"entity": {
"genre_one": "genre",
@@ -518,247 +496,7 @@
"gaplessAudio_optionWeak": "zwak (aanbevolen)",
"gaplessAudio": "gapless audio",
"globalMediaHotkeys_description": "het gebruik van systeem mediahotkeys voor controle van afspelen aan-/uitzetten",
"globalMediaHotkeys": "globale mediasneltoetsen",
"autoDJ": "auto-DJ",
"autoDJ_description": "soortgelijke nummers automatisch aan wachtrij toevoegen",
"autoDJ_itemCount": "aantal items",
"autoDJ_itemCount_description": "het aantal items dat aan de wachtrij wordt geprobeerd toe te voegen als auto-DJ is ingeschakeld",
"autoDJ_timing": "timing",
"autoDJ_timing_description": "het aantal overgebleven nummers in de wachtrij voordat auto-DJ wordt aangeroepen",
"accentColor_description": "stel de accentkleur voor de applicatie in",
"accentColor": "accentkleur",
"useThemeAccentColor": "gebruik accentkleur van thema",
"useThemeAccentColor_description": "gebruik de primaire kleur zoals gedefinieerd in het gekozen thema in plaats van de aangepaste accentkleur",
"albumBackground_description": "toon de albumhoes als achtergrond op albumpagina's",
"albumBackground": "achtergrondafbeelding album",
"albumBackgroundBlur_description": "de hoeveelheid vervaging die wordt toegepast op de achtergrondafbeelding van een album",
"albumBackgroundBlur": "hoeveelheid vervaging achtergrondafbeelding",
"analyticsDisable": "Opt-out van gebruiksgebaseerde gegevensverzameling",
"analyticsDisable_description": "Geanonimiseerde gebruiksgegevens worden naar de ontwikkelaars gestuurd om te ondersteunen bij het verbeteren van de applicatie",
"applicationHotkeys_description": "configureer sneltoetsen. vink aan om als globale sneltoets in te stellen (enkel voor desktop)",
"applicationHotkeys": "applicatiesneltoetsen",
"artistBackground": "achtergrondafbeelding artiest",
"artistBackground_description": "gebruik de artiestafbeelding als achtergrond op artiestpagina's",
"artistBackgroundBlur": "hoeveelheid vervaging van achtergrondafbeelding",
"artistBackgroundBlur_description": "de hoeveelheid vervaging die wordt toegepast op de achtergrondafbeelding van een artiest",
"artistConfiguration": "configuratie albumartiestpagina",
"artistConfiguration_description": "configureer welke items worden getoond op de albumartiestpagina en in welke volgorde",
"artistReleaseTypeConfiguration": "configuratie artiestuitgavesoorten",
"artistReleaseTypeConfiguration_description": "configureer welke uitgavesoorten worden getoond op de albumartiestpagina en in welke volgorde",
"audioDevice_description": "kies het audioapparaat dat wordt gebruikt om af te spelen (enkel voor de webspeler)",
"audioDevice": "audioapparaat",
"audioExclusiveMode_description": "schakel exclusieve uitvoermodus in. In deze modus wordt het systeem normaliter uitgesloten en zal enkel mpv audio kunnen uitvoeren",
"audioExclusiveMode": "audio-exclusieve modus",
"audioPlayer_description": "kies de audiospeler om te gebruiken bij het afspelen",
"audioPlayer": "audiospeler",
"buttonSize_description": "de grootte van de knoppen in de afspeelbalk",
"buttonSize": "knopgrootte afspeelbalk",
"clearCache_description": "een 'harde schoning' van feishin. naast het legen van feishin's cache wordt de browser-cache (opgeslagen afbeeldingen en andere gegevens) geleegd. inloggegevens en instellingen blijven bewaard",
"clearCache": "browser-cache legen",
"clearCacheSuccess": "cache succesvol geleegd",
"clearQueryCache_description": "een 'zachte schoning' van feishin. dit zal afspeellijsten verversen, metadata volgen en opgeslagen songteksten herstellen. inloggegevens en gecachete afbeeldingen blijven bewaard",
"clearQueryCache": "feishin's cache legen",
"contextMenu_description": "maakt het mogelijk om items te verbergen in het menu dat verschijnt bij het rechts klikken op een item. uitgevinkte items worden verborgen",
"contextMenu": "configuratie contextmenu (rechtermuisklik)",
"crossfadeDuration_description": "bepaal de duur van het crossfade-effect",
"crossfadeDuration": "duur crossfade",
"crossfadeStyle": "crossfade-stijl",
"crossfadeStyle_description": "kies de crossfade-stijl om te gebruiken met de audiospeler",
"customCss": "aangepaste css",
"customCss_description": "inhoud van de aangepastge css. Opmerking: content en niet-lokale urls zijn niet toegestaan. Een voorvertoning van de inhoud wordt hieronder getoond. Aanvullende velden die niet zijn ingesteld zijn aanwezig vanwege sanering",
"customCssEnable_description": "sta toe aangepaste css te schrijven",
"customCssEnable": "aangepaste css inschakelen",
"customCssNotice": "Waarschuwing: ondanks sanering (het niet toestaan van url() en content:) brengt aangepaste css nog steeds risico's met zich mee omdat de interface wordt gewijzigd",
"customFontPath_description": "bepaal het pad naar het aangepaste lettertype voor gebruik in de applicatie",
"customFontPath": "aangepaste lettertypelocatie",
"disableAutomaticUpdates": "automatische updates uitschakelen",
"releaseChannel_optionBeta": "beta",
"releaseChannel_optionLatest": "meest recente",
"releaseChannel": "releasekanaal",
"releaseChannel_description": "kies tussen stabiele releases of beta-releases voor automatische updates",
"disableLibraryUpdateOnStartup": "niet controleren op nieuwe versies bij het opstarten",
"discordApplicationId_description": "de applicatie-id voor {{discord}} rich presence (standaard is {{defaultId}})",
"hotkey_listPlayNow": "nu in lijst spelen",
"hotkey_navigateHome": "navigeer naar startpagina",
"hotkey_playbackNext": "volgend nummer",
"hotkey_playbackPause": "pauzeren",
"hotkey_playbackPlay": "afspelen",
"hotkey_playbackPlayPause": "afspelen / pauzeren",
"hotkey_playbackPrevious": "vorig nummer",
"hotkey_playbackStop": "stoppen",
"hotkey_rate0": "wis beoordeling",
"hotkey_rate1": "beoordeel 1 ster",
"hotkey_rate2": "beoordeel 2 sterren",
"hotkey_rate3": "beoordeel 3 sterren",
"hotkey_skipBackward": "spring terug",
"hotkey_skipForward": "spring vooruit",
"hotkey_toggleCurrentSongFavorite": "schakel favorietstatus $t(common.currentSong)",
"hotkey_toggleFullScreenPlayer": "schakel afspelen in volledig scherm",
"hotkey_togglePreviousSongFavorite": "schakel favorietstatus $t(common.previousSong)",
"hotkey_toggleQueue": "schakel wachtrij",
"hotkey_toggleRepeat": "schakel herhalen",
"hotkey_toggleShuffle": "schakel willekeurig afspelen",
"hotkey_unfavoriteCurrentSong": "verwijder $t(common.currentSong) uit favorieten",
"hotkey_unfavoritePreviousSong": "verwijder $t(common.previousSong) uit favorieten",
"hotkey_volumeDown": "volume omlaag",
"hotkey_volumeMute": "volume dempen",
"hotkey_volumeUp": "volume omhoog",
"hotkey_zoomIn": "inzoomen",
"hotkey_zoomOut": "uitzoomen",
"imageAspectRatio": "gebruik originele verhoudingen van albumhoes",
"imageAspectRatio_description": "toon albumhoes in de originele verhoudingen, indien ingeschakeld. bij albumhoezen die geen 1:1-verhouding hebben zal de overige ruimte leeg blijven",
"language": "taal",
"language_description": "stel de taal voor applicatie in ($t(common.restartRequired))",
"lastfm_description": "toon links naar Last.fm op artiest- en albumpagina's",
"lastfm": "toon Last.fm-links",
"lastfmApiKey_description": "de API-sleutel voor {{lastfm}}. vereist voor albumhoezen",
"lastfmApiKey": "{{lastfm}}-API-sleutel",
"lyricFetch_description": "bevraag verschillende bronnen op het internet voor songteksten",
"lyricFetch": "haal songteksten op van het internet",
"lyricFetchProvider_description": "kies de diensten die geraadpleegd worden voor songteksten. de volgorde van de diensten is tevens de volgorde waarop deze worden geraadpleegd",
"lyricFetchProvider": "diensten voor songteksten",
"lyricOffset_description": "compenseer de songtekst met het gegeven aantal milliseconden",
"lyricOffset": "compensatie songtekst (ms)",
"logLevel": "logniveau",
"logLevel_description": "het laagste logniveau dat wordt getoond. debug toont alle logs, error toont enkel foutmeldingen",
"logLevel_optionDebug": "debug",
"logLevel_optionError": "fouten",
"logLevel_optionInfo": "informatief",
"logLevel_optionWarn": "waarschuwingen",
"minimizeToTray_description": "minimaliseer de applicatie naar het systeemvak",
"minimizeToTray": "minimaliseer naar systeemvak",
"minimumScrobblePercentage_description": "het minimumpercentage dat van een nummer gespeeld om worden om deze te scrobblen",
"minimumScrobblePercentage": "minimale duur voor scrobblen (percentage)",
"minimumScrobbleSeconds_description": "de minimale duur in seconden dat van een nummer gespeeld moet zijn om deze te scrobblen",
"minimumScrobbleSeconds": "minimale duur voor scrobblen (seconden)",
"mpvExecutablePath_description": "bepaal het pad naar het uitvoerbare bestand van mpv. indien leeg wordt het standaard pad gebruikt",
"showRatings": "toon beoordelingssterren",
"showVisualizerInSidebar_description": "een paneel met de visualiseerder wordt aan de zijbalk toegevoegd",
"showVisualizerInSidebar": "toon visualiseerder in zijbalk",
"combinedLyricsAndVisualizer_description": "combineer songtekst en visualiseerder in hetzelfde paneel",
"combinedLyricsAndVisualizer": "combineer songtekst en visualseerder in zijbalk",
"preservePitch_description": "behoud toonhoogte bij het aanpassen van de afspeelsnelheid",
"preservePitch": "behoud toonhoogte",
"audioFadeOnStatusChange": "audio faseert uit bij statuswijziging",
"audioFadeOnStatusChange_description": "past in- en uitfasering toe als de afspeelstatus verandert",
"preventSleepOnPlayback_description": "voorkom slaapstand van het scherm als muziek afspeelt",
"preventSleepOnPlayback": "voorkom slaapstand bij afspelen",
"remotePassword_description": "bepaal het wachtwoord voor de externe-bedieningserver. Deze gegevens worden standaard onveilig verstuurd, dus gebruik bij voorkeur een uniek wachtwoord waar je niet om geeft",
"remotePassword": "wachtwoord van externe-bedieningserver",
"remotePort_description": "bepaal de poort voor de externe-bedieningserver",
"remotePort": "poort van externe-bedieningserver",
"remoteUsername": "gebruikersnaam van externe-bedieningserver",
"remoteUsername_description": "bepaal de gebruikersnaam voor de externe-bedieningserver. Als zowel gebruikersnaam als wachtwoord leeg is wordt geen authenticatie toegepast",
"replayGainClipping_description": "Voorkom clipping veroorzaakt door {{ReplayGain}} door automatisch het niveau te verlagen",
"replayGainClipping": "{{ReplayGain}}-clipping",
"replayGainFallback_description": "niveau in dB dat wordt toegepast als het bestand geen {{ReplayGain}}-tags bevat",
"replayGainFallback": "{{ReplayGain}}-terugval",
"replayGainMode_description": "pas het volumeniveau aan volgens {{ReplayGain}}-waarden opgeslagen in de metadata van het bestand",
"replayGainMode": "{{ReplayGain}}-modus",
"replayGainPreamp_description": "pas het voorverstekerniveau aan dat wordt toegepast op {{ReplayGain}}-waarden",
"replayGainPreamp": "{{ReplayGain}}-voorversterker (dB)",
"discordApplicationId": "{{discord}}-applicatie-id",
"discordDisplayType_artistname": "artiestnamen",
"discordDisplayType_description": "verandert waar je naar luistert in je status",
"discordDisplayType_songname": "liednaam",
"discordDisplayType": "weergavesoort {{discord}}-aanwezigheid",
"discordIdleStatus_description": "Werk de status bij als de speler inactief is",
"discordIdleStatus": "toon inactiviteit in rich presence",
"discordRichPresence_description": "toon afspeelstatus in {{discord}} rich presence. Afbeeldingssleutelwoorden zijn {{icon}}, {{playing}} en {{paused}}",
"discordServeImage": "deel afbeeldingen van de server met {{discord}}",
"discordServeImage_description": "deel albumhoezen voor {{discord}} rich presence vanaf de server zelf. enkel beschikbaar voor Jellyfin en Navidrome. {{discord}} gebruikt een bot om afbeeldingen op te vragen, dus moet je server publiek toegankelijk zijn",
"discordUpdateInterval": "verversinterval voor {{discord}} rich presence",
"discordUpdateInterval_description": "de interval in seconden tussen elke update (minimaal 15 seconden)",
"enableAutoTranslation_description": "schakel automatische vertaling in na het laden van songteksten",
"enableAutoTranslation": "automatisch vertalen inschakelen",
"enableRemote_description": "sta toe dat andere apparaten de applicatie kunnen bedienen via de externe-bedieningserver",
"enableRemote": "externe-bedieningserver inschakelen",
"followCurrentSong_description": "scroll de wachtrij automatisch naar het nummer dat momenteel wordt afgespeeld",
"followCurrentSong": "volg actieve nummer",
"homeConfiguration_description": "configureer welke items in welke volgorde getoond worden op de thuispagina",
"homeConfiguration": "configuratie thuispagina",
"homeFeature_description": "of de uitgelicht-carrousel op de thuispagina wordt getoond",
"homeFeature": "uitgelicht-carrousel thuispagina",
"hotkey_browserBack": "browser terug",
"hotkey_browserForward": "browser vooruit",
"hotkey_favoriteCurrentSong": "maak $t(common.currentSong) favoriet",
"hotkey_favoritePreviousSong": "maak $t(common.previousSong) favoriet",
"hotkey_globalSearch": "globaal zoeken",
"hotkey_localSearch": "zoeken op pagina",
"hotkey_listNavigateToPage": "navigeer naar lijst-item",
"hotkey_listPlayDefault": "speel in lijst",
"hotkey_listPlayLast": "speel laatste in lijst",
"hotkey_listPlayNext": "speel volgende in lijst",
"mpvExecutablePath": "pad uitvoerbaar bestand mpv",
"mpvExtraParameters": "aanvullende parameters mpv",
"mpvExtraParameters_description": "aanvullende parameters die aan mpv worden meegegeven",
"mpvExtraParameters_help": "één per regel",
"musicbrainz_description": "toon links naar MusicBrainz op artiest- en albumpagina's, als een MusicBrainz-ID aanwezig is",
"musicbrainz": "toon MusicBrainz-links",
"neteaseTranslation_description": "Haalt songteksten van NetEase op en toont deze, indien beschikbaar",
"neteaseTranslation": "Gebruikt vertalingen van NetEase",
"notify": "Nummerwisselnotificaties",
"notify_description": "Toont een notificatie als het actieve nummer wisselt",
"pathReplace": "bestandspadvervanging",
"pathReplace_description": "vervang het standaard bestandspad van je server",
"pathReplace_optionRemovePrefix": "verwijder voorvoegsel",
"pathReplace_optionAddPrefix": "voeg voorvoegsel toe",
"passwordStore_description": "welke wachtwoord- of secret-store gebruikt moet worden. wijzig dit als je problemen ervaart bij het opslaan van wachtwoorden",
"passwordStore": "wachtwoord- / secret-store",
"playerFilters": "Filter nummers uit de wachtrij",
"playerFilters_description": "Voorkom dat nummers aan de wachtrij worden toegevoegd op basis van de volgende criteria",
"playbackStyle_description": "kies de afspeelstijl om te gebruiken in de audiospeler",
"playbackStyle_optionCrossFade": "crossfade",
"playbackStyle_optionNormal": "normaal",
"playbackStyle": "afspeelstijl",
"playButtonBehavior_description": "het standaardgedrag van de afspelen-knop bij het toevoegen van nummers aan de wachtrij",
"playButtonBehavior": "gedrag afspelen-knop",
"artistRadioCount_description": "het aantal nummers dat moet worden opgehaald voor artiest- en nummer-radio",
"artistRadioCount": "aantal nummers artiest- / nummer-radio",
"imageResolution": "afbeeldingsgrootte",
"imageResolution_description": "de afmetingen van de afbeeldingen die gebruikt worden in de app. door 0 op te geven worden de originele afmetingen gebruikt",
"imageResolution_optionTable": "tabel",
"imageResolution_optionItemCard": "item-kaart",
"imageResolution_optionSidebar": "zijbalk",
"imageResolution_optionHeader": "kop",
"imageResolution_optionFullScreenPlayer": "schermvullende speler",
"playerbarOpenDrawer_description": "open de schermvullende speler door te klikken op de afspeelbalk",
"playerbarOpenDrawer": "volledig scherm via afspeelbalk",
"playerbarSlider": "voortgangsindicator in afspeelbalk",
"playerbarSlider_description": "golfvorm wordt afgeraden op een trage verbinding of bij een datalimiet",
"playerbarSliderType_optionSlider": "voortgangsindicator",
"playerbarSliderType_optionWaveform": "golfvorm",
"playerbarWaveformAlign": "uitlijning golfvorm",
"playerbarWaveformAlign_optionTop": "boven",
"playerbarWaveformAlign_optionCenter": "midden",
"playerbarWaveformAlign_optionBottom": "onder",
"playerbarWaveformBarWidth": "breedte golfvormbalk",
"playerbarWaveformGap": "tussenruimte golfvorm",
"playerbarWaveformRadius": "straal golfvorm",
"preferLocalLyrics_description": "geef de voorkeur aan lokale songteksten indien beschikbaar",
"preferLocalLyrics": "prefereer lokale songteksten",
"showLyricsInSidebar_description": "er zal een paneel worden toegevoegd aan de wachtrij waarin songteksten worden getoond",
"showLyricsInSidebar": "toon songteksten in zijbalk",
"showRatings_description": "toont beoordelingssterren in de interface",
"sampleRate": "bemonsteringsfrequentie",
"sampleRate_description": "de bemonsteringsfrequentie die wordt gebruikt als de gekozen bemonsteringsfrequentie afwijkt van die van de actieve media. bij een waarde lager dan 8000 wordt de standaard frequentie gebruikt",
"savePlayQueue_description": "sla de wachtij op bij het afsluiten van de applicatie en herstel deze als de applicatie wordt geopend",
"savePlayQueue": "sla wachtrij op",
"scrobble_description": "scrobblet afgespeelde nummers naar de mediaserver",
"scrobble": "scrobblen",
"showSkipButton_description": "toont of verstopt de spoelknoppen op de afspeelbalk",
"showSkipButton": "toon spoelknoppen",
"showSkipButtons_description": "toont of verstopt de spoelknoppen op de afspeelbalk",
"showSkipButtons": "toon spoelknoppen",
"sidebarCollapsedNavigation_description": "toon of verstop de navigatie in de ingeklapte zijbalk",
"sidebarCollapsedNavigation": "zijbalknavigatie (ingeklapt)",
"sidebarConfiguration_description": "kies de items en hun volgorde voor in de zijbalk",
"sidebarConfiguration": "configuratie zijbalk",
"sidebarPlaylistList_description": "toon of verstop afspeellijsten in de zijbalk",
"sidebarPlaylistList": "afspeellijsten zijbalk",
"sidePlayQueueStyle_description": "de stijl van de wachtrij aan de zijkant",
"sidePlayQueueStyle_optionAttached": "aangekoppeld",
"sidePlayQueueStyle_optionDetached": "afgekoppeld"
"globalMediaHotkeys": "globale mediasneltoetsen"
},
"form": {
"addServer": {
@@ -774,10 +512,7 @@
"ignoreCors": "negeer cors $t(common.restartRequired)",
"error_savePassword": "er is iets mis gegaan met het opslaan van het wachtwoord",
"input_preferInstantMix": "verkies directe mix",
"input_preferInstantMixDescription": "gebruik alleen instant mix om vergelijkbare nummer te krijgen. handig wanneer je plugins hebt die dit gedrag aanpassen",
"input_preferRemoteUrl": "geef voorkeur aan openbare url",
"input_remoteUrl": "publieke url",
"input_remoteUrlPlaceholder": "optioneel: publieke url voor externe mogelijkheden"
"input_preferInstantMixDescription": "gebruik alleen instant mix om vergelijkbare nummer te krijgen. handig wanneer je plugins hebt die dit gedrag aanpassen"
},
"deletePlaylist": {
"title": "verwijder $t(entity.playlist_one)",
@@ -817,8 +552,7 @@
"editPlaylist": {
"title": "$t(entity.playlist_one) aanpassen",
"publicJellyfinNote": "Jellyfin laat niet weten of een playlist publiek of privaat is. Als u wilt dat dit publiek blijft, selecteer de volgende invoer",
"success": "$t(entity.playlist_one) succesvol geüpdatet",
"editNote": "Handmatige bewerking wordt afgeraden voor grote afspeellijsten. Weet je zeker dat je het risico op dataverlies wilt accepteren door de bestaande afspeellijst te overschrijven?"
"success": "$t(entity.playlist_one) succesvol geüpdatet"
},
"updateServer": {
"title": "update server",
@@ -851,28 +585,13 @@
"input_played_optionAll": "alle nummers",
"input_played_optionUnplayed": "alleen ongespeelde nummers",
"input_played_optionPlayed": "alleen gespeelde nummers"
},
"createRadioStation": {
"success": "radiostation succesvol aangemaakt",
"title": "radiostation aanmaken",
"input_homepageUrl": "thuispagina-url",
"input_name": "naam",
"input_streamUrl": "stream-url"
},
"lyricsExport": {
"export": "exporteer songtekst",
"input_synced": "exporteer gesynchroniseerde songtekst",
"input_offset": "$t(setting.lyricOffset)"
},
"saveQueue": {
"success": "wachtrij opgeslagen op server"
}
},
"player": {
"addLast": "achteraan",
"addNext": "volgende",
"addLastShuffled": "als laatste toevoegen (willekeurig)",
"addNextShuffled": "als volgende toevoegen (willekeurig)",
"addLast": "achteraan toevoegen",
"addNext": "als volgende toevoegen",
"addLastShuffled": "als laatste toevoegen (geschud)",
"addNextShuffled": "als volgende toevoegen (geschud)",
"favorite": "favoriet",
"mute": "dempen",
"muted": "gedempt",
@@ -887,80 +606,6 @@
"previous": "vorige",
"queue_clear": "wachtrij wissen",
"queue_moveToBottom": "verplaats geselecteerde naar boven",
"queue_moveToTop": "verplaats geselecteerde naar beneden",
"artistRadio": "artiestenradio",
"holdToShuffle": "vasthouden om willekeurig af te spelen",
"lyrics": "songtekst",
"queue_remove": "verwijder geselecteerde",
"repeat": "herhalen",
"repeat_all": "alles herhalen",
"repeat_off": "herhalen uitgeschakeld",
"restoreQueueFromServer": "herstel wachtrij van server",
"saveQueueToServer": "sla wachtrij op server op",
"shuffle": "afspelen (willekeurig)",
"shuffle_off": "willekeurig afspelen uitgeschakeld",
"skip": "overslaan",
"skip_back": "spring terug",
"skip_forward": "spring vooruit",
"stop": "stoppen",
"toggleFullscreenPlayer": "schakel speler in volledig scherm",
"trackRadio": "nummerradio",
"unfavorite": "verwijder favoriet",
"pause": "pauzeren",
"viewQueue": "toon wachtrij"
},
"datetime": {
"minuteShort": "m",
"secondShort": "s",
"hourShort": "h",
"dayShort": "d"
},
"filterOperator": {
"afterDate": "is na (datum)",
"before": "is voor",
"beforeDate": "is vóór (datum)",
"contains": "bevat",
"endsWith": "eindigt met",
"inPlaylist": "is in",
"inTheLast": "is in de laatste",
"inTheRange": "ligt binnen het bereik",
"inTheRangeDate": "ligt binnen het bereik (datum)",
"is": "is",
"isNot": "is niet",
"isGreaterThan": "is groter dan",
"isLessThan": "is minder dan",
"matchesRegex": "komt overeen met regex",
"notContains": "bevat geen",
"notInPlaylist": "is niet in",
"notInTheLast": "is niet in de laatste",
"startsWith": "begint met",
"after": "is na"
},
"queryBuilder": {
"standardTags": "standaard tags",
"customTags": "aangepaste tags"
},
"releaseType": {
"primary": {
"album": "$t(entity.album_one)",
"broadcast": "uitzending",
"ep": "ep",
"other": "overig",
"single": "single"
},
"secondary": {
"audiobook": "luisterboek",
"audioDrama": "luisterdrama",
"compilation": "compilatie",
"djMix": "dj-mix",
"demo": "demo",
"fieldRecording": "veldopname",
"interview": "interview",
"live": "live",
"mixtape": "mixtape",
"remix": "remix",
"soundtrack": "soundtrack",
"spokenWord": "gesproken woord"
}
"queue_moveToTop": "verplaats geselecteerde naar beneden"
}
}
+29 -40
View File
@@ -73,7 +73,7 @@
"delete": "usuń",
"cancel": "anuluj",
"forceRestartRequired": "zrestartuj aby zastosować zmiany... zamknij powiadomienie aby zrestartować",
"setting": "ustawienia",
"setting": "ustawienie",
"version": "wersja",
"title": "tytuł",
"filter_one": "filtr",
@@ -157,52 +157,49 @@
"view": "wyświetl",
"countSelected": "wybrano {{count}}",
"retry": "spróbuj ponownie",
"mood": "nastrój",
"example": "przykład",
"filter_multiple": "multi",
"filter_single": "single"
"mood": "nastrój"
},
"entity": {
"genre_one": "gatunek",
"genre_few": "gatunki",
"genre_many": "gatunków",
"playlistWithCount_one": "{{count}} playlista",
"playlistWithCount_few": "{{count}} playlisty",
"playlistWithCount_many": "{{count}} playlist",
"playlist_one": "playlista",
"playlist_few": "playlisty",
"playlist_many": "playlist",
"artist_one": "wykonawca",
"artist_few": "wykonawców",
"artist_many": "wykonawców",
"folderWithCount_one": "{{count}} katalog",
"folderWithCount_few": "{{count}} katalogi",
"folderWithCount_many": "{{count}} katalogów",
"albumArtist_one": "wykonawca albumu",
"albumArtist_few": "wykonawcy albumu",
"albumArtist_many": "wykonawców albumu",
"track_one": "utwór",
"track_few": "utwory",
"track_many": "utworów",
"albumArtistCount_one": "{{count}} wykonawca albumu",
"albumArtistCount_few": "{{count}} wykonawców albumu",
"albumArtistCount_many": "{{count}} wykonawców albumu",
"albumArtist_few": "wykonawców albumów",
"albumArtist_many": "wykonawców albumów",
"albumWithCount_one": "{{count}} album",
"albumWithCount_few": "{{count}} albumy",
"albumWithCount_many": "{{count}} albumów",
"favorite_one": "ulubiony",
"favorite_few": "ulubione",
"favorite_many": "ulubionych",
"favorite_many": "ulubione",
"artistWithCount_one": "{{count}} wykonawca",
"artistWithCount_few": "{{count}} wykonawców",
"artistWithCount_many": "{{count}} wykonawców",
"folder_one": "katalog",
"folder_few": "katalogi",
"folder_many": "katalogów",
"smartPlaylist": "inteligentna $t(entity.playlist_one)",
"album_one": "album",
"album_few": "albumy",
"album_many": "albumów",
"playlistWithCount_one": "{{count}} playlista",
"playlistWithCount_few": "{{count}} playlisty",
"playlistWithCount_many": "{{count}} playlist",
"playlist_one": "playlista",
"playlist_few": "playlisty",
"playlist_many": "playlist",
"folderWithCount_one": "{{count}} katalog",
"folderWithCount_few": "{{count}} katalogi",
"folderWithCount_many": "{{count}} katalogów",
"track_one": "utwór",
"track_few": "utwory",
"track_many": "utworów",
"albumArtistCount_one": "{{count}} wykonawca albumu",
"albumArtistCount_few": "{{count}} wykonawców albumu",
"albumArtistCount_many": "{{count}} wykonawców albumu",
"smartPlaylist": "inteligentna $t(entity.playlist_one)",
"genreWithCount_one": "{{count}} gatunek",
"genreWithCount_few": "{{count}} gatunki",
"genreWithCount_many": "{{count}} gatunków",
@@ -214,12 +211,12 @@
"play_many": "{{count}} odtworzeń",
"song_one": "piosenka",
"song_few": "piosenki",
"song_many": "­piosenek",
"song_many": "piosenek",
"radioStation_one": "stacja radiowa",
"radioStation_few": "stacje radiowe",
"radioStation_many": "stacji radiowych",
"radioStationWithCount_one": "{{count}} stacja radiowa",
"radioStationWithCount_few": "{{count}} stacje radiowych",
"radioStationWithCount_few": "{{count}} stacje radiowe",
"radioStationWithCount_many": "{{count}} stacji radiowych"
},
"error": {
@@ -634,6 +631,9 @@
"playSimilarSongs": "odtwarzaj podobne",
"addLastShuffled": "ostatnie (wylosowane)",
"addNextShuffled": "następne (wylosowane)",
"queueType": "typ kolejki",
"queueType_default": "domyślna",
"queueType_priority": "priorytetowa",
"holdToShuffle": "przytrzymaj aby odtwarzać losowo",
"lyrics": "tekst",
"restoreQueueFromServer": "przywróć kolejkę z serwera",
@@ -919,7 +919,7 @@
"preservePitch": "utrzymuj ton",
"preventSleepOnPlayback_description": "powstrzymuje ekran przed uśpieniem, gdy muzyka jest odtwarzana",
"preventSleepOnPlayback": "powstrzymuj uśpienie podczas odtwarzania",
"mediaSession_description": "włącza integrację z Media Session, wyświetlając sterowanie mediami i metadane w systemowym oknie zmiany głośności i na ekranie blokady",
"mediaSession_description": "włącza integrację z Windows Media Session, wyświetlając sterowanie mediami i metadane w systemowym oknie zmiany głośności i na ekranie blokady (tylko Windows)",
"mediaSession": "włącz media session",
"transcode": "włącz transkodowanie",
"queryBuilder": "kreator zaptań",
@@ -964,16 +964,7 @@
"showRatings_description": "kontroluje czy funkcja oceniania gwiazdkami jest pokazywana w interfejsie",
"showRatings": "pokaż ocenianie gwiazdkami",
"mpvExtraParameters": "dodatkowe parametry mpv",
"mpvExtraParameters_description": "dodatkowe argumenty do przekazania mpv",
"hotkey_listNavigateToPage": "lista nawigacja do strony elementu",
"hotkey_listPlayDefault": "lista odtwarzaj",
"hotkey_listPlayLast": "lista odtwarzaj ostatnie",
"hotkey_listPlayNext": "lista odtwarzaj następne",
"hotkey_listPlayNow": "lista odtwarzaj teraz",
"pathReplace": "zamiana ścieżki pliku",
"pathReplace_description": "zamień domyślną ścieżkę pliku twojego serwera",
"pathReplace_optionRemovePrefix": "usuń prefix",
"pathReplace_optionAddPrefix": "dodaj prefix"
"mpvExtraParameters_description": "dodatkowe argumenty do przekazania mpv"
},
"table": {
"config": {
@@ -1046,9 +1037,7 @@
"genreBadge": "$t(entity.genre_one) (znaczki)",
"image": "obraz",
"bitDepth": "$t(common.bitDepth)",
"sampleRate": "$t(common.sampleRate)",
"composer": "kompozytor",
"titleArtist": "$t(common.title) (wykonawca)"
"sampleRate": "$t(common.sampleRate)"
}
},
"column": {
+13 -143
View File
@@ -21,22 +21,7 @@
"lastfm": "открыть на Last.fm",
"musicbrainz": "открыть на MusicBrainz"
},
"moveToNext": "следующий",
"addOrRemoveFromSelection": "добавить или удалить из выделения",
"createRadioStation": "создать $t(entity.radioStation_one)",
"deleteRadioStation": "удалить $t(entity.radioStation_one)",
"selectAll": "выделить все",
"downloadStarted": "Начата загрузка {{count}} предметов",
"moveUp": "перейти наверх",
"moveDown": "Перейти вниз",
"holdToMoveToTop": "Удержать для перехода на верх",
"holdToMoveToBottom": "удержать для перехода вниз",
"moveItems": "переместить предметы",
"shuffle": "Перемешать",
"shuffleAll": "перемешать все",
"shuffleSelected": "Смешать выбранное",
"viewMore": "Посмотреть больше",
"openApplicationDirectory": "открыть папку приложения"
"moveToNext": "следующий"
},
"common": {
"backward": "назад",
@@ -139,23 +124,7 @@
"viewReleaseNotes": "Список изменений",
"bitDepth": "Разрядность",
"sampleRate": "частота дискретизации",
"tags": "теги",
"countSelected": "{{count}} выбрано",
"faster": "быстрее",
"filter_single": "один",
"filter_multiple": "несколько",
"mood": "настроение",
"noFilters": "фильтры не настроены",
"private": "приватный",
"public": "открытый",
"retry": "повторить",
"recordLabel": "лейбл звукозаписи",
"releaseType": "тип выпуска",
"slower": "медленее",
"sort": "сортировать",
"clean": "очистить",
"gridRows": "Строки в сетке",
"tableColumns": "Столбцы таблицы"
"tags": "теги"
},
"entity": {
"album_one": "альбом",
@@ -210,13 +179,7 @@
"genreWithCount_many": "{{count}} жанров",
"trackWithCount_one": "{{count}} трек",
"trackWithCount_few": "{{count}} трека",
"trackWithCount_many": "{{count}} треков",
"radioStation_one": "радиостанция",
"radioStation_few": "радиостанции",
"radioStation_many": "радиостанции",
"radioStationWithCount_one": "Радиостанция",
"radioStationWithCount_few": "Радиостанций",
"radioStationWithCount_many": "Радиостанции"
"trackWithCount_many": "{{count}} треков"
},
"table": {
"config": {
@@ -315,12 +278,7 @@
"badAlbum": "вы видите эту страницу из-за того, что эта песня не входит в альбом. скорее всего, вы видите эту ошибку, так как песня находится в корневой директории папки с музыкой. Jellyfin группирует треки только по папкам",
"networkError": "возникла ошибка сети",
"badValue": "Недопустимый параметр «{{value}}». Это значение больше не существует",
"notificationDenied": "Доступ к уведомлениям запрещен. Настройка не работает",
"multipleServerSaveQueueError": "в очереди воспроизведения присутствует одна или несколько песен, которые не загружены с текущего сервера. это не поддерживается",
"noNetwork": "сервер недоступен",
"noNetworkDescription": "Не удалось подключиться к серверу",
"saveQueueFailed": "Не удалось сохранить очередь",
"settingsSyncError": "обнаружены несоответствия между настройками рендерера и основным процессом. перезапустите приложение, чтобы изменения вступили в силу"
"notificationDenied": "Доступ к уведомлениям запрещен. Настройка не работает"
},
"filter": {
"isCompilation": "сборник",
@@ -394,7 +352,7 @@
"queue_moveToTop": "переместить выделенное вниз",
"queue_moveToBottom": "переместить выделенное вверх",
"shuffle_off": "перемешивание выключено",
"addLast": "последний",
"addLast": "воспроизвести после всех",
"mute": "отключить звук",
"skip_forward": "вперёд",
"viewQueue": "показать очередь"
@@ -450,10 +408,7 @@
"goBack": "назад",
"goForward": "вперёд",
"privateModeOff": "Выключить приватный режим",
"privateModeOn": "Включить приватный режим",
"selectMusicFolder": "выбрать папку с музыкой",
"noMusicFolder": "папка с музыкой не выбрана",
"multipleMusicFolders": "{{count}} выбрано музыкальных папок"
"privateModeOn": "Включить приватный режим"
},
"manageServers": {
"title": "сервера",
@@ -484,8 +439,7 @@
"showDetails": "получить информацию",
"shareItem": "поделиться",
"goToAlbum": "Перейти к $t(entity.album_one)",
"goToAlbumArtist": "Перейти к $t(entity.albumArtist_one)",
"goTo": "перейти в"
"goToAlbumArtist": "Перейти к $t(entity.albumArtist_one)"
},
"home": {
"mostPlayed": "слушают чаще всего",
@@ -505,20 +459,7 @@
"generalTab": "общее",
"hotkeysTab": "горячие клавиши",
"windowTab": "окно",
"advanced": "расширенные",
"analytics": "аналитика",
"updates": "обновить",
"cache": "кэш",
"application": "приложение",
"theme": "тема",
"controls": "элементы управления",
"sidebar": "боковая панель",
"remote": "удаленный",
"exportImport": "импорт/экспорт",
"audio": "аудио",
"lyrics": "тексты песен",
"lyricsDisplay": "отображение текстов песен",
"transcoding": "транскодирование"
"advanced": "расширенные"
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
@@ -561,17 +502,12 @@
"viewAllTracks": "посмотреть все $t(entity.track_other)",
"recentReleases": "недавние релизы",
"about": "О {{artist}}",
"topSongsFrom": "популярные треки из {{title}}",
"groupingTypeAll": "все типы выпусков",
"groupingTypePrimary": "основные типы выпусков"
"topSongsFrom": "популярные треки из {{title}}"
},
"itemDetail": {
"copyPath": "скопировать путь в буфер обмена",
"openFile": "открыть трек в менеджере файлов",
"copiedPath": "путь успешно скопирован"
},
"radioList": {
"title": "радиостанции"
}
},
"form": {
@@ -601,18 +537,13 @@
"ignoreCors": "игнорировать CORS ($t(common.restartRequired))",
"error_savePassword": "произошла ошибка при сохранении пароля",
"input_preferInstantMix": "Предпочитать автоподборку",
"input_preferInstantMixDescription": "Использовать быстрый микс только для поиска похожих композиций. Полезно, если у вас есть плагины, которые изменяют это поведение",
"input_preferRemoteUrl": "предпочитать публичный url",
"input_remoteUrl": "публичный url",
"input_remoteUrlPlaceholder": "необязательно: публичный гкд-адрес для доступа к внешним функциям"
"input_preferInstantMixDescription": "Использовать быстрый микс только для поиска похожих композиций. Полезно, если у вас есть плагины, которые изменяют это поведение"
},
"addToPlaylist": {
"success": "добавлено: $t(entity.trackWithCount, {\"count\": {{message}} }) в $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "добавить в $t(entity.playlist_one)",
"input_skipDuplicates": "не добавлять дубликаты",
"input_playlists": "$t(entity.playlist_other)",
"create": "создать $t(entity.playlist_one) {{playlist}}",
"searchOrCreate": "для создания нового списка выполните поиск по $t(entity.playlist_other) или введите соответствующий текст"
"input_playlists": "$t(entity.playlist_other)"
},
"updateServer": {
"title": "обновление сервера",
@@ -621,11 +552,7 @@
"queryEditor": {
"input_optionMatchAll": "сопоставить все",
"input_optionMatchAny": "сопоставить любой",
"title": "Редактор запросов",
"addRuleGroup": "добавить группу правил",
"removeRuleGroup": "удалить группу правил",
"resetToDefault": "сбросить на настройки по умолчанию",
"clearFilters": "очистить фильтры"
"title": "Редактор запросов"
},
"lyricSearch": {
"input_name": "$t(common.name)",
@@ -635,8 +562,7 @@
"editPlaylist": {
"title": "редактировать $t(entity.playlist_one)",
"success": "$t(entity.playlist_one) обновлён успешно",
"publicJellyfinNote": "Jellyfin по какой-то причине не предоставляет информацию о том, публичный плейлист или нет. Если вы хотите, чтобы он остался публичным, выберите следующую опцию",
"editNote": "редактирование больших плейлистов вручную не рекомендуется. Вы уверены, что готовы принять риск потери данных, который может возникнуть в результате перезаписи существующего плейлиста?"
"publicJellyfinNote": "Jellyfin по какой-то причине не предоставляет информацию о том, публичный плейлист или нет. Если вы хотите, чтобы он остался публичным, выберите следующую опцию"
},
"shareItem": {
"success": "ссылка скопирована в буфер обмена (нажмите здесь, чтобы открыть)",
@@ -650,35 +576,6 @@
"enabled": "Приватный режим включен. Статус воспроизведения скрыт от внешних интеграций",
"disabled": "Приватный режим отключен. Статус воспроизведения теперь виден внешним интеграциям",
"title": "Приватный режим"
},
"largeFetchConfirmation": {
"title": "добавить элементы в очередь",
"description": "Это действие добавит все элементы в текущий отфильтрованный вид"
},
"createRadioStation": {
"success": "радиостанция успешно создана",
"title": "создать радиостанцию",
"input_homepageUrl": "домашняя страница",
"input_name": "имя",
"input_streamUrl": "ссылка потока"
},
"lyricsExport": {
"export": "экспортировать тексты песен",
"input_synced": "экспорт синхронизированных текстов песен",
"input_offset": "$t(setting.lyricOffset)"
},
"saveQueue": {
"success": "сохранена очередь воспроизведения на сервере"
},
"shuffleAll": {
"title": "Случайное воспроизведение",
"input_limit": "сколько песен?",
"input_minYear": "от года",
"input_maxYear": "до года",
"input_played": "воспроизвести фильтр",
"input_played_optionAll": "все треки",
"input_played_optionUnplayed": "только не игранные треки",
"input_played_optionPlayed": "только игранные треки"
}
},
"setting": {
@@ -892,32 +789,5 @@
"primary": {
"other": "другие"
}
},
"datetime": {
"minuteShort": "м",
"secondShort": "с",
"hourShort": "ч",
"dayShort": "д"
},
"filterOperator": {
"after": "после",
"afterDate": "после (дата)",
"before": "это раньше",
"beforeDate": "это раньше (дата)",
"contains": "содержит",
"endsWith": "заканчивается",
"inPlaylist": "находится в",
"inTheLast": "находится в последнем",
"inTheRange": "находится в диапазоне",
"inTheRangeDate": "находится в диапазоне (дата)",
"is": "является",
"isNot": "не",
"isGreaterThan": "больше чем",
"isLessThan": "меньше чем",
"matchesRegex": "соответствует выражению",
"notContains": "не содержит",
"notInPlaylist": "не в",
"notInTheLast": "не в последнем",
"startsWith": "начинается с"
}
}
+1 -2
View File
@@ -21,8 +21,7 @@
"openIn": {
"lastfm": "Otvoriť v Last.fm",
"musicbrainz": "Otvoriť v MusicBrainz"
},
"addOrRemoveFromSelection": "pridať či odstrániť z vybranie"
}
},
"common": {
"action_one": "akcia",
+1 -5
View File
@@ -1,5 +1 @@
{
"action": {
"addToFavorites": "додати до $t(entity.favorite_other)"
}
}
{}
+3 -1
View File
@@ -206,7 +206,9 @@
"viewQueue": "查看播放队列",
"saveQueueToServer": "将播放队列保存到服务器",
"restoreQueueFromServer": "从服务器恢复播放队列",
"lyrics": "歌词"
"queueType_default": "默认",
"lyrics": "歌词",
"queueType": "队列类型"
},
"setting": {
"crossfadeStyle_description": "选择用于音频播放器的淡入淡出风格",
+8 -21
View File
@@ -108,9 +108,7 @@
"gridRows": "網格行",
"noFilters": "未設定任何過濾器",
"countSelected": "{{count}}個已選取",
"retry": "重試",
"example": "範例",
"mood": "情緒"
"retry": "重試"
},
"error": {
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
@@ -371,6 +369,9 @@
"viewQueue": "檢視佇列",
"addLastShuffled": "新增至尾端 (隨機)",
"addNextShuffled": "新增至下一首 (隨機)",
"queueType": "佇列類型",
"queueType_default": "預設",
"queueType_priority": "優先",
"holdToShuffle": "按住以隨機",
"lyrics": "歌詞",
"restoreQueueFromServer": "從伺服器還原播放佇列",
@@ -623,7 +624,7 @@
"preventSleepOnPlayback": "防止播放時進入睡眠狀態",
"preventSleepOnPlayback_description": "在音樂播放時防止螢幕進入睡眠狀態",
"mediaSession": "啟用Media Session",
"mediaSession_description": "啟用 Media Session 整合功能,於系統音量Overlay和鎖定畫面中顯示媒體資料與控制面板",
"mediaSession_description": "啟用 Windows Media Session 整合功能,於系統音量Overlay和鎖定畫面中顯示媒體資料與控制面板(僅限 Windows",
"releaseChannel": "發佈通道",
"analyticsDisable": "選擇退出使用情況分析",
"analyticsDisable_description": "經過匿名處理的使用情況資料將傳送給開發者,以協助改進應用程式",
@@ -697,16 +698,7 @@
"combinedLyricsAndVisualizer": "在播放器側邊欄整合歌詞與視覺化效果",
"artistRadioCount": "藝人/歌曲電台數量",
"showRatings_description": "控制星級評分功能是否顯示於介面中",
"showRatings": "顯示星級評分",
"artistReleaseTypeConfiguration": "藝人發行類型設定",
"artistReleaseTypeConfiguration_description": "設定專輯藝人頁面中顯示的發行類型及排序",
"hotkey_listNavigateToPage": "從清單導覽至項目頁面",
"mpvExtraParameters": "MPV額外參數",
"mpvExtraParameters_description": "傳遞給MPV的額外參數",
"pathReplace": "檔案路徑替換",
"pathReplace_description": "替換您伺服器的預設檔案路徑",
"pathReplace_optionRemovePrefix": "移除前綴",
"pathReplace_optionAddPrefix": "增加前綴"
"showRatings": "顯示星級評分"
},
"table": {
"config": {
@@ -932,10 +924,7 @@
"ignoreCors": "忽略 cors $t(common.restartRequired)",
"ignoreSsl": "忽略 ssl $t(common.restartRequired)",
"input_preferInstantMix": "偏好即時混音",
"input_preferInstantMixDescription": "僅使用即時混音功能來獲取相似歌曲。若您擁有能修改此行為的外掛,此功能將相當實用",
"input_preferRemoteUrl": "優先使用公開網址",
"input_remoteUrl": "公開網址",
"input_remoteUrlPlaceholder": "選用:對外功能的公開網址"
"input_preferInstantMixDescription": "僅使用即時混音功能來獲取相似歌曲。若您擁有能修改此行為的外掛,此功能將相當實用"
},
"addToPlaylist": {
"input_playlists": "$t(entity.playlist_other)",
@@ -1074,9 +1063,7 @@
"matchesRegex": "符合正規表達式",
"notContains": "不包含",
"notInPlaylist": "不在…之中",
"startsWith": "以…開頭",
"inTheLast": "在最後",
"notInTheLast": "不在最後"
"startsWith": "以…開頭"
},
"datetime": {
"minuteShort": "分",
+5 -4
View File
@@ -100,7 +100,7 @@ export async function getLyricsBySongId(url: string): Promise<null | string> {
try {
result = await axios.get<string>(url, { responseType: 'text' });
} catch (e) {
console.error('Genius lyrics request got an error!', (e as Error)?.message);
console.error('Genius lyrics request got an error!', e);
return null;
}
@@ -138,7 +138,7 @@ export async function getSearchResults(
},
});
} catch (e) {
console.error('Genius search request got an error!', (e as Error)?.message);
console.error('Genius search request got an error!', e);
return null;
}
@@ -150,7 +150,6 @@ export async function getSearchResults(
return {
artist: song.artist_names,
id: song.url,
isSync: null,
name: song.full_title,
source: LyricSource.GENIUS,
};
@@ -164,11 +163,13 @@ export async function query(
): Promise<InternetProviderLyricResponse | null> {
const response = await getSongId(params);
if (!response) {
console.error('Could not find the song on Genius!');
return null;
}
const lyrics = await getLyricsBySongId(response.id);
if (!lyrics) {
console.error('Could not get lyrics on Genius!');
return null;
}
@@ -193,7 +194,7 @@ async function getSongId(
},
});
} catch (e) {
console.error('Genius search request got an error!', (e as Error)?.message);
console.error('Genius search request got an error!', e);
return null;
}
+48 -75
View File
@@ -1,10 +1,21 @@
import { ipcMain } from 'electron';
import { store } from '../settings';
import { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from './genius';
import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib';
import { getLyricsBySongId as getNetease, getSearchResults as searchNetease } from './netease';
import { orderSearchResults } from './shared';
import {
getLyricsBySongId as getGenius,
query as queryGenius,
getSearchResults as searchGenius,
} from './genius';
import {
getLyricsBySongId as getLrcLib,
query as queryLrclib,
getSearchResults as searchLrcLib,
} from './lrclib';
import {
getLyricsBySongId as getNetease,
query as queryNetease,
getSearchResults as searchNetease,
} from './netease';
import { Song } from '/@/shared/types/domain-types';
@@ -31,7 +42,6 @@ export type InternetProviderLyricResponse = {
export type InternetProviderLyricSearchResponse = {
artist: string;
id: string;
isSync: boolean | null;
name: string;
score?: number;
source: LyricSource;
@@ -62,6 +72,14 @@ type SearchFetcher = (
params: LyricSearchQuery,
) => Promise<InternetProviderLyricSearchResponse[] | null>;
type SongFetcher = (params: LyricSearchQuery) => Promise<InternetProviderLyricResponse | null>;
const FETCHERS: Record<LyricSource, SongFetcher> = {
[LyricSource.GENIUS]: queryGenius,
[LyricSource.LRCLIB]: queryLrclib,
[LyricSource.NETEASE]: queryNetease,
};
const SEARCH_FETCHERS: Record<LyricSource, SearchFetcher> = {
[LyricSource.GENIUS]: searchGenius,
[LyricSource.LRCLIB]: searchLrcLib,
@@ -90,82 +108,37 @@ const getRemoteLyrics = async (song: Song) => {
}
}
const params: LyricSearchQuery = {
album: song.album || song.name,
artist: song.artists[0].name,
duration: song.duration / 1000.0,
name: song.name,
};
const allSearchResults: InternetProviderLyricSearchResponse[] = [];
for (const source of sources) {
try {
const searchResults = await SEARCH_FETCHERS[source](params);
if (searchResults) {
allSearchResults.push(...searchResults);
}
} catch (error) {
console.error(`Error searching ${source} for lyrics:`, error);
}
}
if (allSearchResults.length === 0) {
return null;
}
const rankedResults = orderSearchResults({
params,
results: allSearchResults,
});
const bestMatch = rankedResults[0];
if (!bestMatch) {
return null;
}
// Score is 0-1 where 0 = perfect match, 1 = worst match
const matchThreshold = 0.55;
const matchScore = bestMatch.score ?? 1;
if (matchScore > matchThreshold) {
return null;
}
let lyricsFromSource: InternetProviderLyricResponse | null = null;
try {
const lyrics = await GET_FETCHERS[bestMatch.source](bestMatch.id);
if (lyrics) {
lyricsFromSource = {
artist: bestMatch.artist,
id: bestMatch.id,
lyrics,
name: bestMatch.name,
source: bestMatch.source,
};
}
} catch (error) {
console.error(`Error fetching lyrics from ${bestMatch.source}:`, error);
}
for (const source of sources) {
const params = {
album: song.album || song.name,
artist: song.artists[0].name,
duration: song.duration / 1000.0,
name: song.name,
};
const response = await FETCHERS[source](params as unknown as LyricSearchQuery);
if (lyricsFromSource) {
const newResult = cached
? {
...cached,
[lyricsFromSource.source]: lyricsFromSource,
}
: ({ [lyricsFromSource.source]: lyricsFromSource } as CachedLyrics);
if (response) {
const newResult = cached
? {
...cached,
[source]: response,
}
: ({ [source]: response } as CachedLyrics);
if (lyricCache.size === MAX_CACHED_ITEMS && cached === undefined) {
const toRemove = lyricCache.keys().next().value;
if (toRemove) {
lyricCache.delete(toRemove);
if (lyricCache.size === MAX_CACHED_ITEMS && cached === undefined) {
const toRemove = lyricCache.keys().next().value;
if (toRemove) {
lyricCache.delete(toRemove);
}
}
}
lyricCache.set(song.id.toString(), newResult);
lyricCache.set(song.id.toString(), newResult);
lyricsFromSource = response;
break;
}
}
return lyricsFromSource;
+4 -8
View File
@@ -17,12 +17,8 @@ const TIMEOUT_MS = 5000;
export interface LrcLibSearchResponse {
albumName: string;
artistName: string;
duration?: number;
id: number;
instrumental?: boolean;
name: string;
plainLyrics: null | string;
syncedLyrics: null | string;
}
export interface LrcLibTrackResponse {
@@ -46,7 +42,7 @@ export async function getLyricsBySongId(songId: string): Promise<null | string>
try {
result = await axios.get<LrcLibTrackResponse>(`${FETCH_URL}/${songId}`);
} catch (e) {
console.error('LrcLib lyrics request got an error!', (e as Error)?.message);
console.error('LrcLib lyrics request got an error!', e);
return null;
}
@@ -69,7 +65,7 @@ export async function getSearchResults(
},
});
} catch (e) {
console.error('LrcLib search request got an error!', (e as Error)?.message);
console.error('LrcLib search request got an error!', e);
return null;
}
@@ -79,7 +75,6 @@ export async function getSearchResults(
return {
artist: song.artistName,
id: String(song.id),
isSync: song.syncedLyrics ? true : false,
name: song.name,
source: LyricSource.LRCLIB,
};
@@ -107,13 +102,14 @@ export async function query(
timeout: TIMEOUT_MS,
});
} catch (e) {
console.error('LrcLib search request got an error!', (e as Error).message);
console.error('LrcLib search request got an error!', e);
return null;
}
const lyrics = result.data.syncedLyrics || result.data.plainLyrics || null;
if (!lyrics) {
console.error(`Could not get lyrics on LrcLib!`);
return null;
}
+2 -1
View File
@@ -128,7 +128,6 @@ export async function getSearchResults(
return {
artist,
id: String(song.id),
isSync: null,
name: song.name,
source: LyricSource.NETEASE,
};
@@ -142,11 +141,13 @@ export async function query(
): Promise<InternetProviderLyricResponse | null> {
const lyricsMatch = await getMatchedLyrics(params);
if (!lyricsMatch) {
console.error('Could not find the song on NetEase!');
return null;
}
const lyrics = await getLyricsBySongId(lyricsMatch.id);
if (!lyrics) {
console.error('Could not get lyrics on NetEase!');
return null;
}
+8 -69
View File
@@ -1,4 +1,4 @@
import Fuse, { FuseResult, IFuseOptions } from 'fuse.js';
import Fuse, { IFuseOptions } from 'fuse.js';
import {
InternetProviderLyricSearchResponse,
@@ -15,81 +15,20 @@ export const orderSearchResults = (args: {
fieldNormWeight: 1,
includeScore: true,
keys: [
{ getFn: (song) => song.name, name: 'name', weight: 2 },
{ getFn: (song) => song.artist, name: 'artist', weight: 2 },
{ getFn: (song) => song.name, name: 'name', weight: 3 },
{ getFn: (song) => song.artist, name: 'artist' },
],
threshold: 0.6,
threshold: 1.0,
};
const fuse = new Fuse(results, options);
let searchResults: Array<FuseResult<InternetProviderLyricSearchResponse>>;
if (params.artist && params.name) {
const artistFuse = new Fuse(results, {
includeScore: true,
keys: [{ getFn: (song) => song.artist, name: 'artist' }],
threshold: 0.6,
});
const nameFuse = new Fuse(results, {
includeScore: true,
keys: [{ getFn: (song) => song.name, name: 'name' }],
threshold: 0.6,
});
const artistResults = artistFuse.search(params.artist);
const nameResults = nameFuse.search(params.name);
const artistScores = new Map(artistResults.map((r) => [r.item.id, r.score ?? 1]));
const nameScores = new Map(nameResults.map((r) => [r.item.id, r.score ?? 1]));
const combinedResults = new Map<string, FuseResult<InternetProviderLyricSearchResponse>>();
artistResults.forEach((result) => {
const nameScore = nameScores.get(result.item.id);
if (nameScore !== undefined) {
const combinedScore = Math.max(result.score ?? 1, nameScore);
combinedResults.set(result.item.id, {
...result,
score: combinedScore,
});
}
});
nameResults.forEach((result) => {
if (!combinedResults.has(result.item.id)) {
const artistScore = artistScores.get(result.item.id);
if (artistScore !== undefined) {
const combinedScore = Math.max(result.score ?? 1, artistScore);
combinedResults.set(result.item.id, {
...result,
score: combinedScore,
});
}
}
});
searchResults = Array.from(combinedResults.values());
} else {
searchResults = fuse.search<InternetProviderLyricSearchResponse>({
...(params.artist && { artist: params.artist }),
...(params.name && { name: params.name }),
});
}
const sortedResults = searchResults.sort((a, b) => {
const aIsSync = a.item.isSync === true ? 1 : 0;
const bIsSync = b.item.isSync === true ? 1 : 0;
if (aIsSync !== bIsSync) {
return bIsSync - aIsSync;
}
return (a.score || 0) - (b.score || 0);
const searchResults = fuse.search<InternetProviderLyricSearchResponse>({
...(params.artist && { artist: params.artist }),
...(params.name && { name: params.name }),
});
return sortedResults.map((result) => ({
return searchResults.map((result) => ({
...result.item,
score: result.score,
}));
-57
View File
@@ -525,63 +525,6 @@ ipcMain.handle(
},
);
ipcMain.handle(
'player-get-audio-devices',
async (): Promise<{ label: string; value: string }[]> => {
try {
const instance = getMpvInstance();
let tempInstance: MpvAPI | null = null;
let mpvToUse: MpvAPI | null = null;
if (instance && instance.isRunning()) {
mpvToUse = instance;
} else {
try {
tempInstance = await createMpv({});
mpvToUse = tempInstance;
} catch (err: any | NodeMpvError) {
mpvLog(
{ action: 'Failed to create temporary MPV instance for audio device list' },
err,
);
return [];
}
}
try {
const deviceList = await mpvToUse.getProperty('audio-device-list');
if (!deviceList || !Array.isArray(deviceList)) {
return [];
}
const devices = deviceList.map((device: any) => {
const name = device.name || device.description || 'Unknown Device';
const description = device.description || '';
const label = description ? `${name} (${description})` : name;
return {
label,
value: name,
};
});
return devices;
} finally {
if (tempInstance && tempInstance !== instance) {
try {
await quit(tempInstance);
} catch {
// Ignore
}
}
}
} catch (err: any | NodeMpvError) {
mpvLog({ action: 'Failed to get audio devices' }, err);
return [];
}
},
);
enum MpvState {
STARTED,
IN_PROGRESS,
+1 -4
View File
@@ -620,11 +620,8 @@ ipcMain.on('update-playback', (_event, status: PlayerStatus) => {
broadcast({ data: status, event: 'playback' });
});
ipcMain.on('update-song', (_event, song: QueueSong | undefined, imageUrl?: null | string) => {
ipcMain.on('update-song', (_event, song: QueueSong | undefined) => {
const songChanged = song?.id !== currentState.song?.id;
if (song) {
song.imageUrl = imageUrl || null;
}
currentState.song = song;
if (songChanged) {
+2 -15
View File
@@ -1,8 +1,7 @@
import type { TitleTheme } from '/@/shared/types/types';
import { app, dialog, ipcMain, nativeTheme, OpenDialogOptions, safeStorage } from 'electron';
import { dialog, ipcMain, nativeTheme, OpenDialogOptions, safeStorage } from 'electron';
import Store from 'electron-store';
import path from 'path';
const getFrame = () => {
const isWindows = process.platform === 'win32';
@@ -19,18 +18,10 @@ const getFrame = () => {
return 'linux';
};
const isDevelopment = process.env.NODE_ENV === 'development';
const defaultUserDataPath = app.getPath('userData');
const storePath = isDevelopment
? path.normalize(`${defaultUserDataPath}-dev`)
: path.normalize(defaultUserDataPath);
export const store = new Store<any>({
beforeEachMigration: (_store, context) => {
console.log(`settings migrate from ${context.fromVersion}${context.toVersion}`);
},
cwd: storePath,
defaults: {
disable_auto_updates: false,
enableNeteaseTranslation: false,
@@ -61,11 +52,7 @@ ipcMain.handle('settings-get', (_event, data: { property: string }) => {
});
ipcMain.on('settings-set', (__event, data: { property: string; value: any }) => {
if (data.value === undefined) {
store.delete(data.property);
} else {
store.set(data.property, data.value);
}
store.set(`${data.property}`, data.value);
});
ipcMain.handle('password-get', (_event, server: string): null | string => {
+9 -9
View File
@@ -126,9 +126,7 @@ const installExtensions = async () => {
type: 'info',
});
})
.catch(() => {
// Ignore
});
.catch(console.error);
});
};
@@ -187,11 +185,13 @@ const createWinThumbarButtons = () => {
};
const createTray = () => {
tray =
isLinux() || isMacOS()
? new Tray(getAssetPath('icons/icon.png'))
: new Tray(getAssetPath('icons/icon.ico'));
if (isMacOS()) {
return;
}
tray = isLinux()
? new Tray(getAssetPath('icons/icon.png'))
: new Tray(getAssetPath('icons/icon.ico'));
const contextMenu = Menu.buildFromTemplate([
{
click: () => {
@@ -275,8 +275,8 @@ async function createWindow(first = true): Promise<void> {
autoHideMenuBar: true,
frame: false,
height: 900,
icon: isWindows() ? getAssetPath('icons/icon.ico') : getAssetPath('icons/icon.png'),
minHeight: 120,
icon: getAssetPath('icons/icon.png'),
minHeight: 640,
minWidth: 480,
show: false,
webPreferences: {
+12 -8
View File
@@ -1,16 +1,24 @@
import { ipcRenderer, IpcRendererEvent, OpenDialogOptions, webFrame } from 'electron';
import Store from 'electron-store';
import { TitleTheme } from '/@/shared/types/types';
const store = new Store();
const set = (
property: string,
value: boolean | Record<string, unknown> | string | string[] | undefined,
) => {
ipcRenderer.send('settings-set', { property, value });
if (value === undefined) {
store.delete(property);
return;
}
store.set(`${property}`, value);
};
const get = async (property: string) => {
return ipcRenderer.invoke('settings-get', { property });
const get = (property: string) => {
return store.get(`${property}`);
};
const restart = () => {
@@ -79,13 +87,9 @@ const env = {
SERVER_NAME: process.env.SERVER_NAME ?? '',
SERVER_TYPE,
SERVER_URL: process.env.SERVER_URL ?? 'http://',
START_MAXIMIZED: undefined as boolean | undefined,
START_MAXIMIZED: store.get('maximized'),
};
get('maximized').then((value) => {
env.START_MAXIMIZED = value as boolean | undefined;
});
export const localSettings = {
disableMediaKeys,
enableMediaKeys,
-5
View File
@@ -98,10 +98,6 @@ const getStreamMetadata = async () => {
return ipcRenderer.invoke('player-stream-metadata');
};
const getAudioDevices = async () => {
return ipcRenderer.invoke('player-get-audio-devices');
};
const rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-auto-next', cb);
};
@@ -178,7 +174,6 @@ export const mpvPlayer = {
autoNext,
cleanup,
currentTime,
getAudioDevices,
getCurrentTime,
getMetadata,
getStreamMetadata,
+2 -2
View File
@@ -73,8 +73,8 @@ const updateShuffle = (shuffle: boolean) => {
ipcRenderer.send('update-shuffle', shuffle);
};
const updateSong = (song: QueueSong | undefined, imageUrl?: null | string) => {
ipcRenderer.send('update-song', song, imageUrl);
const updateSong = (args: QueueSong | undefined) => {
ipcRenderer.send('update-song', args);
};
const updateUsername = (username: string) => {
@@ -248,15 +248,6 @@ export const contract = c.router({
404: jfType._response.error,
},
},
getStudioList: {
method: 'GET',
path: 'studios',
query: jfType._parameters.studioList,
responses: {
200: jfType._response.studioList,
400: jfType._response.error,
},
},
getTopSongsList: {
method: 'GET',
path: 'users/:userId/items',
@@ -25,7 +25,6 @@ import {
songListSortMap,
SortOrder,
sortOrderMap,
Tag,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
@@ -367,7 +366,7 @@ export const JellyfinController: InternalControllerEndpoint = {
GenreIds: query.genreIds ? query.genreIds.join(',') : undefined,
IncludeItemTypes: 'MusicAlbum',
IsFavorite: query.favorite,
Limit: query.limit === -1 ? undefined : query.limit,
Limit: query.limit,
ParentId: getLibraryId(query.musicFolderId),
Recursive: true,
SearchTerm: query.searchTerm,
@@ -1120,19 +1119,19 @@ export const JellyfinController: InternalControllerEndpoint = {
? formatCommaDelimitedString(query.albumIds)
: undefined;
const parentIdFilter = [albumIdsFilter, artistIdsFilter].filter(Boolean).join(',');
const res = await jfApiClient(apiClientProps).getSongList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
AlbumIds: albumIdsFilter,
ArtistIds: artistIdsFilter,
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags',
GenreIds: query.genreIds?.join(','),
IncludeItemTypes: 'Audio',
IsFavorite: query.favorite,
Limit: query.limit,
ParentId: getLibraryId(query.musicFolderId),
ParentId: parentIdFilter,
Recursive: true,
SearchTerm: query.searchTerm,
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
@@ -1234,40 +1233,12 @@ export const JellyfinController: InternalControllerEndpoint = {
throw new Error('failed to get tags');
}
const studioRes = await jfApiClient(apiClientProps).getStudioList({
query: {
EnableTotalRecordCount: true,
IncludeItemTypes: query.type === LibraryItem.SONG ? 'Audio' : 'MusicAlbum',
ParentId: query.folder,
},
});
if (studioRes.status !== 200) {
throw new Error('failed to get studios');
}
const tags: Tag[] = [];
if (res.body.Tags?.length) {
tags.push({
name: 'Tags',
options: res.body.Tags.sort((a, b) =>
a
.toLocaleLowerCase()
.localeCompare(b.toLocaleLowerCase(), undefined, { numeric: true }),
).map((tag) => ({ id: tag, name: tag })),
});
}
if (studioRes.body.Items.length) {
tags.push({
name: 'Studios',
options: studioRes.body.Items.sort((a, b) =>
a.Name.toLocaleLowerCase().localeCompare(b.Name.toLocaleLowerCase()),
).map((option) => ({ id: option.Name, name: option.Name })),
});
}
return { excluded: { album: [], song: [] }, tags };
return {
boolTags: res.body.Tags?.sort((a, b) =>
a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()),
),
excluded: { album: [], song: [] },
};
},
getTopSongs: async (args) => {
const { apiClientProps, query } = args;
@@ -59,17 +59,19 @@ const EXCLUDED_ALBUM_TAGS = new Set<string>([
'asin',
'barcode',
'copyright',
'disctotal',
'encodedby',
'isrc',
'key',
'language',
'musicbrainz_workid',
'script',
'tracktotal',
'website',
'work',
]);
const EXCLUDED_SONG_TAGS = new Set<string>(['disctotal', 'tracktotal']);
const EXCLUDED_SONG_TAGS = new Set<string>([]);
// Tags that use IDs as values as opposed to the tag value
const ID_TAGS = new Set<string>(['albumversion', 'mood']);
@@ -745,7 +747,7 @@ export const NavidromeController: InternalControllerEndpoint = {
const { apiClientProps } = args;
if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) {
return { excluded: { album: [], song: [] } };
return { boolTags: undefined, enumTags: undefined, excluded: { album: [], song: [] } };
}
const res = await ndApiClient(apiClientProps).getTagList({
@@ -776,16 +778,12 @@ export const NavidromeController: InternalControllerEndpoint = {
}
}
const tags = Array.from(tagsToValues)
const enumTags = Array.from(tagsToValues)
.map((data) => ({
name: data[0],
options: data[1]
.sort((a, b) =>
a.name
.toLocaleLowerCase()
.localeCompare(b.name.toLocaleLowerCase(), undefined, {
numeric: true,
}),
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
)
.map((option) => ({ id: option.id, name: option.name })),
}))
@@ -795,11 +793,12 @@ export const NavidromeController: InternalControllerEndpoint = {
const excludedSongTags = Array.from(EXCLUDED_SONG_TAGS.values());
return {
boolTags: undefined,
enumTags,
excluded: {
album: excludedAlbumTags,
song: excludedSongTags,
},
tags,
};
},
getTopSongs: SubsonicController.getTopSongs,
@@ -1056,13 +1056,12 @@ export const SubsonicController: InternalControllerEndpoint = {
}
const items =
res.body.playlist.entry?.map((song, index) =>
res.body.playlist.entry?.map((song) =>
ssNormalize.song(
song,
apiClientProps.server,
context?.pathReplace,
context?.pathReplaceWith,
index,
),
) || [];
@@ -1600,76 +1599,6 @@ export const SubsonicController: InternalControllerEndpoint = {
return (res.body.starred?.song || []).length || 0;
}
const artistIds = query.albumArtistIds || query.artistIds;
if (query.albumIds || artistIds) {
const fromAlbumPromises: Promise<ServerInferResponses<typeof contract.getAlbum>>[] = [];
const artistDetailPromises: Promise<ServerInferResponses<typeof contract.getArtist>>[] =
[];
if (query.albumIds) {
for (const albumId of query.albumIds) {
fromAlbumPromises.push(
ssApiClient(apiClientProps).getAlbum({
query: {
id: albumId,
},
}),
);
}
}
if (artistIds) {
for (const artistId of artistIds) {
artistDetailPromises.push(
ssApiClient(apiClientProps).getArtist({
query: {
id: artistId,
},
}),
);
}
const artistResult = await Promise.all(artistDetailPromises);
const albums = artistResult.flatMap((artist) => {
if (artist.status !== 200) {
return [];
}
return artist.body.artist.album ?? [];
});
const albumIds = albums.map((album) => album.id);
for (const albumId of albumIds) {
fromAlbumPromises.push(
ssApiClient(apiClientProps).getAlbum({
query: {
id: albumId.toString(),
},
}),
);
}
}
let results: z.infer<typeof ssType._response.song>[] = [];
if (fromAlbumPromises.length > 0) {
const albumsResult = await Promise.all(fromAlbumPromises);
results = albumsResult.flatMap((album) => {
if (album.status !== 200) {
return [];
}
return album.body.album.song;
});
}
return results.length;
}
let totalRecordCount = 0;
// Rather than just do `search3` by groups of 500, instead
-68
View File
@@ -1,68 +0,0 @@
import { QueryClient } from '@tanstack/react-query';
import { getServerById } from '/@/renderer/store';
import { ServerType } from '/@/shared/types/domain-types';
interface OptimizedListCountOptions<TQuery, TListQuery, TResponse> {
client: QueryClient;
listQueryFn: (args: {
apiClientProps: { serverId: string; signal?: AbortSignal };
query: TListQuery;
}) => Promise<TResponse>;
listQueryKeyFn: (serverId: string, query: TListQuery) => readonly unknown[];
query: TQuery;
serverId: string;
signal?: AbortSignal;
}
export const getOptimizedListCount = async <
TQuery,
TListQuery extends { limit?: number; startIndex?: number },
TResponse extends { totalRecordCount: null | number },
>({
client,
listQueryFn,
listQueryKeyFn,
query,
serverId,
signal,
}: OptimizedListCountOptions<TQuery, TListQuery, TResponse>): Promise<null | number> => {
const server = getServerById(serverId);
if (server?.type !== ServerType.NAVIDROME && server?.type !== ServerType.JELLYFIN) {
return null;
}
const limit =
typeof query === 'object' &&
query !== null &&
'limit' in query &&
typeof (query as any).limit === 'number' &&
(query as any).limit > 0
? (query as any).limit
: 100;
// In most cases, the list count is called when entering the first page, so we fetch from the first page
// This optimization will only help in this case, otherwise we still need 2 requests to get both the count and the data
const pageQuery = {
...query,
limit,
startIndex: 0,
} as unknown as TListQuery;
const pageQueryKey = listQueryKeyFn(serverId, pageQuery);
const cachedPage = client.getQueryData(pageQueryKey);
if (cachedPage && typeof cachedPage === 'object' && 'totalRecordCount' in cachedPage) {
return (cachedPage as TResponse).totalRecordCount ?? 0;
}
const pageResult = await listQueryFn({
apiClientProps: { serverId, signal },
query: pageQuery,
});
client.setQueryData(pageQueryKey, pageResult);
return pageResult.totalRecordCount ?? 0;
};
+3 -10
View File
@@ -7,11 +7,12 @@ import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
import isElectron from 'is-electron';
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import i18n from '/@/i18n/i18n';
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
import { ReleaseNotesModal } from './release-notes-modal';
import { AppRouter } from '/@/renderer/router/app-router';
import { useCssSettings, useHotkeySettings, useLanguage } from '/@/renderer/store';
import { useAppTheme } from '/@/renderer/themes/use-app-theme';
@@ -21,12 +22,6 @@ import '/@/shared/styles/global.css';
import { PlayerProvider } from '/@/renderer/features/player/context/player-context';
import { AudioPlayers } from '/@/renderer/features/player/components/audio-players';
const ReleaseNotesModal = lazy(() =>
import('./release-notes-modal').then((module) => ({
default: module.ReleaseNotesModal,
})),
);
const ipc = isElectron() ? window.api.ipc : null;
export const App = () => {
@@ -100,9 +95,7 @@ export const App = () => {
<AppRouter />
</PlayerProvider>
</WebAudioContext.Provider>
<Suspense fallback={null}>
<ReleaseNotesModal />
</Suspense>
<ReleaseNotesModal />
</MantineProvider>
);
};
@@ -27,20 +27,6 @@
isolation: isolate;
}
.blurred-background {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
background-repeat: no-repeat;
background-position: center;
background-size: cover;
opacity: 0.8;
transform: scale(1.1);
}
.carousel-item :global(.overlay) {
border-radius: var(--theme-radius-md);
}
@@ -67,13 +53,6 @@
padding: var(--theme-spacing-md);
}
.single-carousel-container .carousel-item .content {
flex-direction: row;
gap: var(--theme-spacing-lg);
align-items: flex-end;
padding: var(--theme-spacing-xl);
}
.title-section {
display: flex;
flex-shrink: 0;
@@ -98,15 +77,6 @@
max-height: 160px;
}
.single-carousel-container .carousel-item .content .image-section {
flex-shrink: 0;
justify-content: flex-start;
width: auto;
height: auto;
min-height: auto;
max-height: none;
}
.play-button-overlay {
position: absolute;
top: 50%;
@@ -136,23 +106,6 @@
text-align: center;
}
.single-carousel-container .carousel-item .content .metadata-section {
flex: 1;
align-items: flex-start;
justify-content: center;
height: auto;
min-height: auto;
max-height: none;
text-align: left;
}
/* Hide metadata on screens smaller than xs */
@media (width < 36em) {
.single-carousel-container .carousel-item .content .metadata-section {
display: none;
}
}
.image-link {
display: block;
transition: transform 0.3s ease;
@@ -176,11 +129,6 @@
transition: filter 0.3s ease;
}
.single-carousel-container .album-image-container {
width: 200px;
max-width: 200px;
}
.album-image-container::before {
position: absolute;
top: 0;
@@ -201,7 +149,7 @@
.album-image {
width: 100%;
height: 100%;
height: auto;
object-fit: cover;
border-radius: var(--theme-radius-md);
}
@@ -211,12 +159,6 @@
filter: drop-shadow(0 16px 40px rgb(0 0 0 / 60%)) drop-shadow(0 6px 16px rgb(0 0 0 / 50%));
}
/* Single carousel: remove hover shadow effect */
.single-carousel-container .carousel-item:hover .album-image-container,
.single-carousel-container .carousel-link:hover .album-image-container {
filter: drop-shadow(0 6px 20px rgb(0 0 0 / 50%)) drop-shadow(0 2px 8px rgb(0 0 0 / 40%));
}
.artist-link {
display: inline-block;
color: inherit;
@@ -276,21 +218,6 @@
transform: translateY(-50%) scale(0.95);
}
.single-carousel-container .nav-arrow-left,
.single-carousel-container .nav-arrow-right {
pointer-events: none;
opacity: 0;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.single-carousel-container:hover .nav-arrow-left,
.single-carousel-container:hover .nav-arrow-right {
pointer-events: auto;
opacity: 1;
}
@container (min-width: $mantine-breakpoint-xs) {
.carousel-item {
min-height: 300px;
@@ -1,349 +0,0 @@
import type { MouseEvent } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { generatePath, Link } from 'react-router';
import styles from './feature-carousel.module.css';
import { ItemImage, useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { BackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';
import { calculateTitleSize } from '/@/renderer/features/shared/components/library-header';
import { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';
import { useContainerQuery, useFastAverageColor } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group';
import { Separator } from '/@/shared/components/separator/separator';
import { Stack } from '/@/shared/components/stack/stack';
import { TextTitle } from '/@/shared/components/text-title/text-title';
import { Text } from '/@/shared/components/text/text';
import { Album, LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
const containerVariants = {
animate: {},
exit: {},
initial: {},
};
const itemVariants = {
animate: {
opacity: 1,
scale: 1,
transition: {
duration: 0.2,
ease: 'easeOut' as const,
},
y: 0,
},
exit: {
opacity: 0,
transition: {
duration: 0.3,
ease: 'easeIn' as const,
},
y: 0,
},
initial: {
opacity: 0,
y: 0,
},
};
interface CarouselItemProps {
album: Album;
}
interface SingleFeatureCarouselProps {
data: Album[] | undefined;
onNearEnd?: () => void;
}
// const CAROUSEL_AUTOPLAY_INTERVAL = 10000;
const CarouselItem = ({ album }: CarouselItemProps) => {
const imageUrl = useItemImageUrl({
id: album.imageId || undefined,
itemType: LibraryItem.ALBUM,
type: 'itemCard',
});
const { background: backgroundColor } = useFastAverageColor({
algorithm: 'dominant',
src: imageUrl || null,
srcLoaded: true,
});
const server = useCurrentServer();
const { addToQueueByFetch } = usePlayer();
const handlePlay = (type: Play) => {
if (!server?.id) return;
addToQueueByFetch(server.id, [album.id], LibraryItem.ALBUM, type);
};
const metadataItems = useMemo(() => {
return [
...(album.genres?.slice(0, 2).map((genre) => genre.name) || []),
album.releaseYear ? album.releaseYear.toString() : null,
].filter(Boolean);
}, [album]);
return (
<div className={styles.carouselItem}>
{imageUrl && (
<div
className={styles.blurredBackground}
style={{
backgroundImage: `url(${imageUrl})`,
filter: 'blur(3rem)',
}}
/>
)}
<BackgroundOverlay backgroundColor={backgroundColor} opacity={0.7} />
<Link
className={styles.carouselLink}
state={{ item: album }}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: album.id,
})}
>
<div className={styles.content}>
<div className={styles.imageSection}>
<ItemImage
className={styles.albumImage}
containerClassName={styles.albumImageContainer}
id={album.imageId}
itemType={LibraryItem.ALBUM}
type="itemCard"
/>
<div className={styles.playButtonOverlay}>
<PlayButtonGroup onPlay={handlePlay} />
</div>
</div>
<div className={styles.metadataSection}>
<Stack gap="sm">
<TextTitle
className={styles.title}
fw={900}
lh={1.1}
order={1}
style={{ fontSize: calculateTitleSize(album.name) }}
ta="left"
>
{album.name}
</TextTitle>
{album.albumArtistName && (
<TextTitle
className={styles.title}
fw={700}
lh={1.1}
order={5}
ta="left"
>
{album.albumArtistName}
</TextTitle>
)}
<Group gap="xs" justify="flex-start" wrap="wrap">
{metadataItems.map((item, index) => (
<Text
className={styles.title}
fw={600}
key={`metadata-${item}`}
size="sm"
>
{item}
{index < metadataItems.length - 1 && <Separator />}
</Text>
))}
</Group>
</Stack>
</div>
</div>
</Link>
</div>
);
};
export const SingleFeatureCarousel = ({ data, onNearEnd }: SingleFeatureCarouselProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
const directionRef = useRef<{ isNext: boolean }>({ isNext: true });
const { ref: containerRef } = useContainerQuery({
'2xl': 1920,
'3xl': 2560,
lg: 1024,
md: 768,
sm: 640,
xl: 1440,
});
// Check if we're near the end and trigger loading more
useEffect(() => {
if (!data || !onNearEnd) return;
const remainingItems = data.length - currentIndex;
// Trigger when we have less than 3 items remaining
if (remainingItems < 3) {
onNearEnd();
}
}, [data, currentIndex, onNearEnd]);
// useEffect(() => {
// if (!data || data.length <= 1 || isPaused) {
// if (intervalRef.current) {
// clearInterval(intervalRef.current);
// intervalRef.current = null;
// }
// return;
// }
// if (intervalRef.current) {
// clearInterval(intervalRef.current);
// }
// intervalRef.current = setInterval(() => {
// setCurrentIndex((prev) => (prev + 1) % data.length);
// directionRef.current = { isNext: true };
// }, CAROUSEL_AUTOPLAY_INTERVAL);
// return () => {
// if (intervalRef.current) {
// clearInterval(intervalRef.current);
// intervalRef.current = null;
// }
// };
// }, [data, isPaused, intervalKey]);
const handleNext = useCallback(
(e?: MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (!data) return;
directionRef.current = { isNext: true };
setCurrentIndex((prev) => (prev + 1) % data.length);
// setIntervalKey((prev) => prev + 1);
},
[data],
);
const handlePrevious = useCallback(
(e?: MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (!data) return;
directionRef.current = { isNext: false };
setCurrentIndex((prev) => (prev - 1 + data.length) % data.length);
// setIntervalKey((prev) => prev + 1);
},
[data],
);
const canNavigate = data && data.length > 1;
const wheelCooldownRef = useRef(0);
const wheelThreshold = 10;
const wheelCooldownMs = 250;
const handleWheel = useCallback(
(event: React.WheelEvent<HTMLDivElement>) => {
if (!canNavigate || !data) {
return;
}
if (!event.shiftKey) {
return;
}
const now = Date.now();
const elapsed = now - wheelCooldownRef.current;
const horizontalDelta = Math.abs(event.deltaY);
if (horizontalDelta < wheelThreshold || elapsed < wheelCooldownMs) {
return;
}
if (event.deltaY > 0) {
wheelCooldownRef.current = now;
handleNext();
} else if (event.deltaY < 0) {
wheelCooldownRef.current = now;
handlePrevious();
}
},
[canNavigate, data, handleNext, handlePrevious, wheelCooldownMs, wheelThreshold],
);
if (!data || data.length === 0) {
return null;
}
const currentAlbum = data[currentIndex];
return (
<div
className={`${styles.carouselContainer} ${styles.singleCarouselContainer}`}
// onMouseEnter={() => setIsPaused(true)}
// onMouseLeave={() => setIsPaused(false)}
onWheel={handleWheel}
ref={containerRef}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.div
animate="animate"
className={styles.carousel}
exit="exit"
initial="initial"
key={`carousel-${currentIndex}`}
style={{ '--items-per-row': 1 } as React.CSSProperties}
variants={containerVariants}
>
<motion.div
key={`item-${currentAlbum.id}-${currentIndex}`}
variants={itemVariants}
>
<CarouselItem album={currentAlbum} />
</motion.div>
</motion.div>
</AnimatePresence>
{data.length > 1 && (
<>
<ActionIcon
className={styles.navArrowLeft}
icon="arrowLeftS"
iconProps={{ size: 'xl' }}
onClick={handlePrevious}
radius="50%"
size="md"
styles={{
icon: {
color: 'white',
fill: 'white',
},
}}
variant="subtle"
/>
<ActionIcon
className={styles.navArrowRight}
icon="arrowRightS"
iconProps={{ size: 'xl' }}
onClick={handleNext}
radius="50%"
size="md"
styles={{
icon: {
color: 'white',
fill: 'white',
},
}}
variant="subtle"
/>
</>
)}
</div>
);
};
@@ -6,24 +6,10 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import styles from './grid-carousel.module.css';
import { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card';
import { useContainerQuery } from '/@/renderer/hooks';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group';
import { TextTitle } from '/@/shared/components/text-title/text-title';
import { LibraryItem } from '/@/shared/types/domain-types';
export const useGridCarouselContainerQuery = () => {
return useContainerQuery({
'2xl': 1280,
'3xl': 1440,
lg: 960,
md: 720,
sm: 520,
xl: 1152,
xs: 360,
});
};
interface Card {
content: ReactNode;
@@ -32,16 +18,12 @@ interface Card {
interface GridCarouselProps {
cards: Card[];
containerQuery?: ReturnType<typeof useGridCarouselContainerQuery>;
enableRefresh?: boolean;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
loadNextPage?: () => void;
onNextPage: (page: number) => void;
onPrevPage: (page: number) => void;
onRefresh?: () => void;
placeholderItemType?: LibraryItem;
placeholderRows?: DataRow[];
rowCount?: number;
title?: ReactNode | string;
}
@@ -65,22 +47,24 @@ const pageVariants: Variants = {
function BaseGridCarousel(props: GridCarouselProps) {
const {
cards,
containerQuery: providedContainerQuery,
enableRefresh = false,
hasNextPage,
isFetchingNextPage,
loadNextPage,
onNextPage,
onPrevPage,
onRefresh,
placeholderItemType,
placeholderRows,
rowCount = 1,
title,
} = props;
const defaultContainerQuery = useGridCarouselContainerQuery();
const containerQuery = providedContainerQuery || defaultContainerQuery;
const { ref, ...cq } = containerQuery;
const { ref, ...cq } = useContainerQuery({
'2xl': 1280,
'3xl': 1440,
lg: 960,
md: 720,
sm: 520,
xl: 1152,
xs: 360,
});
const [currentPage, setCurrentPage] = useState({
isNext: false,
@@ -113,48 +97,11 @@ function BaseGridCarousel(props: GridCarouselProps) {
});
const visibleCards = useMemo(() => {
const startIndex = currentPage.page * cardsToShow * rowCount;
const endIndex = (currentPage.page + 1) * cardsToShow * rowCount;
const slicedCards = cards.slice(startIndex, endIndex);
const expectedCardCount = cardsToShow * rowCount;
const missingCardCount = expectedCardCount - slicedCards.length;
// Add placeholder cards during loading state
if (
missingCardCount > 0 &&
hasNextPage &&
isFetchingNextPage &&
placeholderItemType &&
placeholderRows
) {
const placeholderCards: Card[] = Array.from(
{ length: missingCardCount },
(_, index) => ({
content: (
<MemoizedItemCard
data={undefined}
itemType={placeholderItemType}
rows={placeholderRows}
type="poster"
/>
),
id: `placeholder-${startIndex + slicedCards.length + index}`,
}),
);
return [...slicedCards, ...placeholderCards];
}
return slicedCards;
}, [
currentPage.page,
cardsToShow,
rowCount,
cards,
hasNextPage,
isFetchingNextPage,
placeholderItemType,
placeholderRows,
]);
return cards.slice(
currentPage.page * cardsToShow * rowCount,
(currentPage.page + 1) * cardsToShow * rowCount,
);
}, [cards, currentPage, cardsToShow, rowCount]);
const shouldLoadNextPage = visibleCards.length < cardsToShow * rowCount;
@@ -302,74 +249,6 @@ export const GridCarousel = memo(BaseGridCarousel);
GridCarousel.displayName = 'GridCarousel';
interface GridCarouselSkeletonProps {
containerQuery?: ReturnType<typeof useGridCarouselContainerQuery>;
enableRefresh?: boolean;
placeholderItemType: LibraryItem;
placeholderRows: DataRow[];
rowCount?: number;
title?: ReactNode | string;
}
const GridCarouselSkeleton = (props: GridCarouselSkeletonProps) => {
const {
containerQuery: providedContainerQuery,
enableRefresh = false,
placeholderItemType,
placeholderRows,
rowCount = 1,
title,
} = props;
const { ...cq } = providedContainerQuery;
const cardsToShow = cq.isCalculated
? getCardsToShow({
isLargerThan2xl: cq.is2xl,
isLargerThan3xl: cq.is3xl,
isLargerThanLg: cq.isLg,
isLargerThanMd: cq.isMd,
isLargerThanSm: cq.isSm,
isLargerThanXl: cq.isXl,
})
: 6;
const placeholderCards = useMemo(() => {
const cardCount = cardsToShow * rowCount;
return Array.from({ length: cardCount }, (_, index) => ({
content: (
<MemoizedItemCard
data={undefined}
itemType={placeholderItemType}
rows={placeholderRows}
type="poster"
/>
),
id: `skeleton-${index}`,
}));
}, [cardsToShow, rowCount, placeholderItemType, placeholderRows]);
return (
<GridCarousel
cards={placeholderCards}
containerQuery={providedContainerQuery}
enableRefresh={enableRefresh}
hasNextPage={false}
isFetchingNextPage={false}
onNextPage={() => {}}
onPrevPage={() => {}}
placeholderItemType={placeholderItemType}
placeholderRows={placeholderRows}
rowCount={rowCount}
title={title}
/>
);
};
export const GridCarouselSkeletonFallback = memo(GridCarouselSkeleton);
GridCarouselSkeletonFallback.displayName = 'GridCarouselSkeletonFallback';
function getCardsToShow(breakpoints: {
isLargerThan2xl: boolean;
isLargerThan3xl: boolean;
@@ -420,15 +420,14 @@ const CompactItemCard = ({
row !== null && row !== undefined,
)
.map((row, index) => (
<Text
<div
className={clsx(styles.row, {
[styles.muted]: index > 0,
})}
key={row.id}
size={index > 0 ? 'sm' : 'md'}
>
&nbsp;
</Text>
</div>
))}
</div>
</div>
@@ -637,15 +636,14 @@ const DefaultItemCard = ({
(row): row is NonNullable<typeof row> => row !== null && row !== undefined,
)
.map((row, index) => (
<Text
<div
className={clsx(styles.row, {
[styles.muted]: index > 0,
})}
key={row.id}
size={index > 0 ? 'sm' : 'md'}
>
&nbsp;
</Text>
</div>
))}
</div>
</div>
@@ -924,15 +922,14 @@ const PosterItemCard = ({
(row): row is NonNullable<typeof row> => row !== null && row !== undefined,
)
.map((row, index) => (
<Text
<div
className={clsx(styles.row, {
[styles.muted]: index > 0,
})}
key={row.id}
size={index > 0 ? 'sm' : 'md'}
>
&nbsp;
</Text>
</div>
))}
</div>
</div>
@@ -1065,7 +1062,7 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
: null;
if (originalYear !== null && originalYear !== releaseYear) {
return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;
return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;
}
return String(releaseYear);
@@ -1082,7 +1079,7 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
data.originalDate &&
data.originalDate !== data.releaseDate
) {
return `${formatDateAbsoluteUTC(data.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(data.releaseDate)}`;
return `${formatDateAbsoluteUTC(data.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(data.releaseDate)}`;
}
return `${formatDateAbsoluteUTC(data.releaseDate)}`;
@@ -36,18 +36,16 @@ const BaseItemImage = (
props: Omit<ImageProps, 'id' | 'src'> & {
id?: null | string;
itemType: LibraryItem;
serverId?: null | string;
src?: null | string;
type?: keyof z.infer<typeof GeneralSettingsSchema>['imageRes'];
},
) => {
const { serverId, src, ...rest } = props;
const { src, ...rest } = props;
const imageUrl = useItemImageUrl({
id: props.id,
imageUrl: src,
itemType: props.itemType,
serverId: serverId || undefined,
type: props.type,
});
@@ -32,13 +32,6 @@ export const getListQueryKeyName = (itemType: LibraryItem): string => {
}
};
type InfiniteLoaderCacheData = {
dataMap: Map<number, unknown>;
idToIndexMap: Map<string, number>;
pagesLoaded: Record<string, boolean>;
version: number;
};
interface UseItemListInfiniteLoaderProps {
eventKey: string;
fetchThreshold?: number;
@@ -50,12 +43,10 @@ interface UseItemListInfiniteLoaderProps {
serverId: string;
}
function getInitialData(): InfiniteLoaderCacheData {
function getInitialData(itemCount: number) {
return {
dataMap: new Map(),
idToIndexMap: new Map(),
data: Array.from({ length: itemCount }, () => undefined),
pagesLoaded: {},
version: 0,
};
}
@@ -94,7 +85,7 @@ export const useItemListInfiniteLoader = ({
const { setItemCount } = useListContext();
useEffect(() => {
if (totalItemCount == null || !setItemCount) {
if (!totalItemCount || !setItemCount) {
return;
}
@@ -127,27 +118,28 @@ export const useItemListInfiniteLoader = ({
queryKey: queryKeys[getListQueryKeyName(itemType)].list(serverId, queryParams),
});
const endIndex = startIndex + itemsPerPage;
// Update the query data with the fetched page
queryClient.setQueryData(dataQueryKey, (oldData: InfiniteLoaderCacheData) => {
const nextDataMap = new Map(oldData.dataMap);
const nextIdToIndexMap = new Map(oldData.idToIndexMap);
queryClient.setQueryData(
dataQueryKey,
(oldData: { data: unknown[]; pagesLoaded: Record<string, boolean> }) => {
const newData = [
...oldData.data.slice(0, startIndex),
...result.items,
...oldData.data.slice(endIndex),
];
const newPagesLoaded = {
...oldData.pagesLoaded,
[pageNumber]: true,
};
result.items.forEach((item, offset) => {
const index = startIndex + offset;
nextDataMap.set(index, item);
if (item && typeof item === 'object' && 'id' in (item as any)) {
const id = String((item as any).id);
nextIdToIndexMap.set(id, index);
}
});
return {
dataMap: nextDataMap,
idToIndexMap: nextIdToIndexMap,
pagesLoaded: { ...oldData.pagesLoaded, [pageNumber]: true },
version: oldData.version + 1,
};
});
return {
data: newData,
pagesLoaded: newPagesLoaded,
};
},
);
// Track the last fetched page
lastFetchedPageRef.current = Math.max(lastFetchedPageRef.current, pageNumber);
@@ -187,10 +179,7 @@ export const useItemListInfiniteLoader = ({
if (!oldData) return oldData;
return {
...oldData,
dataMap: new Map(),
idToIndexMap: new Map(),
pagesLoaded: {},
version: (oldData?.version ?? 0) + 1,
};
});
@@ -222,11 +211,11 @@ export const useItemListInfiniteLoader = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataQueryKey, queryClient, fetchPage, itemsPerPage]);
const { data } = useQuery<InfiniteLoaderCacheData>({
const { data } = useQuery<{ data: unknown[]; pagesLoaded: Record<string, boolean> }>({
enabled: false,
initialData: getInitialData(),
initialData: getInitialData(totalItemCount),
queryFn: () => {
return getInitialData();
return getInitialData(totalItemCount);
},
queryKey: dataQueryKey,
});
@@ -244,7 +233,7 @@ export const useItemListInfiniteLoader = ({
const pageNumber = Math.floor(range.startIndex / itemsPerPage);
const currentData = queryClient.getQueryData<{
dataMap: Map<number, unknown>;
data: unknown[];
pagesLoaded: Record<string, boolean>;
}>(dataQueryKey);
@@ -300,20 +289,18 @@ export const useItemListInfiniteLoader = ({
// Reset the infinite list data
const currentData = queryClient.getQueryData<{
dataMap: Map<number, unknown>;
data: unknown[];
pagesLoaded: Record<string, boolean>;
}>(dataQueryKey);
if (force || currentData) {
// Reset data to initial state and clear all loaded pages
await queryClient.setQueryData(dataQueryKey, (oldData: any) => {
if (!oldData) return getInitialData();
if (!oldData) return getInitialData(totalItemCount);
return {
...oldData,
dataMap: new Map(),
idToIndexMap: new Map(),
data: Array.from({ length: totalItemCount }, () => undefined),
pagesLoaded: {},
version: (oldData?.version ?? 0) + 1,
};
});
lastFetchedPageRef.current = -1;
@@ -349,23 +336,28 @@ export const useItemListInfiniteLoader = ({
const updateItems = useCallback(
(indexes: number[], value: object) => {
queryClient.setQueryData(dataQueryKey, (prev: InfiniteLoaderCacheData) => {
const nextDataMap = new Map(prev.dataMap);
queryClient.setQueryData(
dataQueryKey,
(prev: { data: unknown[]; pagesLoaded: Record<string, boolean> }) => {
return {
...prev,
data: prev.data.map((item: any, index) => {
if (!item) {
return item;
}
indexes.forEach((index) => {
const existing = nextDataMap.get(index);
if (!existing || typeof existing !== 'object') {
return;
}
nextDataMap.set(index, { ...(existing as any), ...(value as any) });
});
if (!indexes.includes(index)) {
return item;
}
return {
...prev,
dataMap: nextDataMap,
version: prev.version + 1,
};
});
return {
...item,
...value,
};
}),
};
},
);
},
[queryClient, dataQueryKey],
);
@@ -392,9 +384,16 @@ export const useItemListInfiniteLoader = ({
return;
}
const idToIndexMap = data.data
.filter(Boolean)
.reduce((acc: Record<string, number>, item: any, index: number) => {
acc[item.id] = index;
return acc;
}, {});
const dataIndexes = payload.id
.map((id: string) => (data as any).idToIndexMap?.get(id))
.filter((idx): idx is number => typeof idx === 'number');
.map((id: string) => idToIndexMap[id])
.filter((idx) => idx !== undefined);
if (dataIndexes.length === 0) {
return;
@@ -408,9 +407,16 @@ export const useItemListInfiniteLoader = ({
return;
}
const idToIndexMap = data.data
.filter(Boolean)
.reduce((acc: Record<string, number>, item: any, index: number) => {
acc[item.id] = index;
return acc;
}, {});
const dataIndexes = payload.id
.map((id: string) => (data as any).idToIndexMap?.get(id))
.filter((idx): idx is number => typeof idx === 'number');
.map((id: string) => idToIndexMap[id])
.filter((idx) => idx !== undefined);
if (dataIndexes.length === 0) {
return;
@@ -428,40 +434,7 @@ export const useItemListInfiniteLoader = ({
};
}, [data, eventKey, itemType, serverId, updateItems]);
const itemCount = totalItemCount ?? 0;
const getItem = useCallback(
(index: number) => {
return (data as any).dataMap?.get(index);
},
[data],
);
const getItemIndex = useCallback(
(id: string) => {
return (data as any).idToIndexMap?.get(id);
},
[data],
);
const loadedItems = useMemo(() => {
const map: Map<number, unknown> | undefined = (data as any).dataMap;
if (!map || map.size === 0) return [];
return Array.from(map.entries())
.sort(([a], [b]) => a - b)
.map(([, v]) => v);
}, [data]);
return {
dataVersion: (data as any).version ?? 0,
getItem,
getItemIndex,
itemCount,
loadedItems,
onRangeChanged,
refresh,
updateItems,
};
return { data: data.data, onRangeChanged, refresh, updateItems };
};
export const parseListCountQuery = (query: any) => {
@@ -62,7 +62,7 @@ export const useItemListPaginatedLoader = ({
const { setItemCount } = useListContext();
useEffect(() => {
if (totalItemCount == null || !setItemCount) {
if (!totalItemCount || !setItemCount) {
return;
}
@@ -39,7 +39,9 @@ const getDefaultRowsForItemType = (
}
};
// Map TableColumn enum values to row IDs used in getDataRows
const getRowIdFromTableColumn = (tableColumn: TableColumn): null | string => {
// Map TableColumn enum values to the row IDs used in getDataRows
const columnToRowIdMap: Record<TableColumn, null | string> = {
[TableColumn.ACTIONS]: null,
[TableColumn.ALBUM]: 'album',
@@ -72,7 +74,6 @@ const getRowIdFromTableColumn = (tableColumn: TableColumn): null | string => {
[TableColumn.SKIP]: null,
[TableColumn.SONG_COUNT]: 'songCount',
[TableColumn.TITLE]: 'name',
[TableColumn.TITLE_ARTIST]: null,
[TableColumn.TITLE_COMBINED]: null,
[TableColumn.TRACK_NUMBER]: null,
[TableColumn.USER_FAVORITE]: 'userFavorite',
@@ -53,16 +53,14 @@ interface VirtualizedGridListProps {
_tableMetaVersion: number; // Used to trigger rerenders via React.memo comparison
controls: ItemControls;
currentPage?: number;
dataVersion?: number;
data: unknown[];
enableDrag?: boolean;
enableExpansion: boolean;
enableSelection: boolean;
gap: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
getItem?: (index: number) => ItemCardProps['data'];
height: number;
initialTop?: ItemGridListProps['initialTop'];
internalState: ItemListStateActions;
itemCount: number;
itemType: LibraryItem;
onRangeChanged?: ItemGridListProps['onRangeChanged'];
onScroll?: ItemGridListProps['onScroll'];
@@ -83,16 +81,14 @@ const VirtualizedGridList = React.memo(
({
controls,
currentPage,
dataVersion,
data,
enableDrag,
enableExpansion,
enableSelection,
gap,
getItem,
height,
initialTop,
internalState,
itemCount,
itemType,
onRangeChanged,
onScroll,
@@ -111,14 +107,12 @@ const VirtualizedGridList = React.memo(
return {
columns: tableMeta?.columnCount || 0,
controls,
dataVersion,
data,
enableDrag,
enableExpansion,
enableSelection,
gap,
getItem,
internalState,
itemCount,
itemType,
rows,
size,
@@ -128,9 +122,7 @@ const VirtualizedGridList = React.memo(
tableMeta,
controls,
rows,
getItem,
itemCount,
dataVersion,
data,
enableDrag,
enableExpansion,
enableSelection,
@@ -293,14 +285,12 @@ const createThrottledSetTableMeta = (
export interface GridItemProps {
columns: number;
controls: ItemCardProps['controls'];
dataVersion?: number;
data: any[];
enableDrag?: boolean;
enableExpansion?: boolean;
enableSelection?: boolean;
gap: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
getItem?: (index: number) => ItemCardProps['data'];
internalState: ItemListStateActions;
itemCount: number;
itemType: LibraryItem;
rows?: ItemCardProps['rows'];
size?: 'compact' | 'default' | 'large';
@@ -314,21 +304,16 @@ export interface GridItemProps {
export interface ItemGridListProps {
currentPage?: number;
data: unknown[];
dataVersion?: number;
enableDrag?: boolean;
enableEntranceAnimation?: boolean;
enableExpansion?: boolean;
enableSelection?: boolean;
enableSelectionDialog?: boolean;
gap?: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
getItem?: (index: number) => ItemCardProps['data'];
getItemIndex?: (rowId: string) => number | undefined;
getRowId?: ((item: unknown) => string) | string;
initialTop?: {
to: number;
type: 'index' | 'offset';
};
itemCount?: number;
itemsPerRow?: number;
itemType: LibraryItem;
onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => void;
@@ -343,17 +328,12 @@ export interface ItemGridListProps {
const BaseItemGridList = ({
currentPage,
data,
dataVersion,
enableDrag = true,
enableEntranceAnimation = true,
enableExpansion = false,
enableSelection = true,
gap = 'sm',
getItem,
getItemIndex,
getRowId,
initialTop,
itemCount,
itemsPerRow,
itemType,
onRangeChanged,
@@ -372,14 +352,6 @@ const BaseItemGridList = ({
const handleRef = useRef<ItemListHandle | null>(null);
const mergedContainerRef = useMergedRef(containerRef, rootRef, containerFocusRef);
const resolvedItemCount = itemCount ?? data.length;
const resolvedGetItem = useCallback<(index: number) => ItemCardProps['data']>(
(index: number) => {
return (getItem ? getItem(index) : (data as any[])[index]) as ItemCardProps['data'];
},
[data, getItem],
);
const getDataFn = useCallback(() => {
return data;
}, [data]);
@@ -468,7 +440,7 @@ const BaseItemGridList = ({
const { current: container } = containerRef;
if (!container) return;
throttledSetTableMeta(containerWidth, resolvedItemCount, (meta) => {
throttledSetTableMeta(containerWidth, data.length, (meta) => {
if (!meta) return;
const current = tableMetaRef.current;
@@ -485,7 +457,7 @@ const BaseItemGridList = ({
setTableMetaVersion((v) => v + 1);
}
});
}, [containerWidth, resolvedItemCount, throttledSetTableMeta, containerRef]);
}, [containerWidth, data.length, throttledSetTableMeta, containerRef]);
const controls = useDefaultItemListControls({ overrides: overrideControls });
@@ -538,12 +510,10 @@ const BaseItemGridList = ({
const lastSelected = selected[selected.length - 1];
const lastRowId = internalState.extractRowId(lastSelected);
if (lastRowId) {
currentIndex =
getItemIndex?.(lastRowId) ??
data.findIndex((d: any) => {
const rowId = internalState.extractRowId(d);
return rowId === lastRowId;
});
currentIndex = data.findIndex((d: any) => {
const rowId = internalState.extractRowId(d);
return rowId === lastRowId;
});
}
}
@@ -554,7 +524,7 @@ const BaseItemGridList = ({
: 0;
const currentCol =
currentIndex !== -1 ? currentIndex % tableMetaRef.current.columnCount : 0;
const totalRows = Math.ceil(resolvedItemCount / tableMetaRef.current.columnCount);
const totalRows = Math.ceil(data.length / tableMetaRef.current.columnCount);
let newIndex = 0;
if (currentIndex !== -1) {
@@ -566,7 +536,7 @@ const BaseItemGridList = ({
const nextRowStart = nextRow * tableMetaRef.current.columnCount;
const nextRowEnd = Math.min(
nextRowStart + tableMetaRef.current.columnCount - 1,
resolvedItemCount - 1,
data.length - 1,
);
// Keep same column position, or use last item in row if column doesn't exist
newIndex = Math.min(nextRowStart + currentCol, nextRowEnd);
@@ -587,7 +557,7 @@ const BaseItemGridList = ({
1,
0,
);
newIndex = Math.min(newIndex, resolvedItemCount - 1);
newIndex = Math.min(newIndex, data.length - 1);
} else {
newIndex = currentIndex;
}
@@ -597,14 +567,14 @@ const BaseItemGridList = ({
// Move right, wrap to next row if at end of row
if (
currentCol < tableMetaRef.current.columnCount - 1 &&
currentIndex < resolvedItemCount - 1
currentIndex < data.length - 1
) {
newIndex = currentIndex + 1;
} else if (currentRow < totalRows - 1) {
// Wrap to start of next row
newIndex = Math.min(
(currentRow + 1) * tableMetaRef.current.columnCount,
resolvedItemCount - 1,
data.length - 1,
);
} else {
newIndex = currentIndex;
@@ -618,7 +588,7 @@ const BaseItemGridList = ({
const prevRowStart = prevRow * tableMetaRef.current.columnCount;
const prevRowEnd = Math.min(
prevRowStart + tableMetaRef.current.columnCount - 1,
resolvedItemCount - 1,
data.length - 1,
);
// Keep same column position, or use last item in row if column doesn't exist
newIndex = Math.min(prevRowStart + currentCol, prevRowEnd);
@@ -633,7 +603,7 @@ const BaseItemGridList = ({
newIndex = 0;
}
const newItem: any = resolvedGetItem(newIndex);
const newItem: any = data[newIndex];
if (!newItem) return;
// Handle Shift + Arrow for incremental range selection (matches shift+click behavior)
@@ -646,12 +616,10 @@ const BaseItemGridList = ({
const lastRowId = internalState.extractRowId(lastSelectedItem);
if (!lastRowId) return;
const lastIndex =
getItemIndex?.(lastRowId) ??
data.findIndex((d: any) => {
const rowId = internalState.extractRowId(d);
return rowId === lastRowId;
});
const lastIndex = data.findIndex((d: any) => {
const rowId = internalState.extractRowId(d);
return rowId === lastRowId;
});
if (lastIndex !== -1 && newIndex !== -1) {
// Create range selection from last selected to new position
@@ -660,7 +628,7 @@ const BaseItemGridList = ({
const rangeItems: ItemListStateItemWithRequiredProperties[] = [];
for (let i = startIndex; i <= stopIndex; i++) {
const rangeItem = resolvedGetItem(i);
const rangeItem = data[i];
if (
rangeItem &&
typeof rangeItem === 'object' &&
@@ -725,15 +693,7 @@ const BaseItemGridList = ({
scrollToIndex(newIndex);
},
[
data,
enableSelection,
getItemIndex,
internalState,
resolvedGetItem,
resolvedItemCount,
scrollToIndex,
],
[data, enableSelection, internalState, scrollToIndex],
);
const imperativeHandle: ItemListHandle = useMemo(() => {
@@ -770,7 +730,7 @@ const BaseItemGridList = ({
ref={mergedContainerRef}
tabIndex={0}
{...animationProps.fadeIn}
transition={{ duration: enableEntranceAnimation ? 1 : 0, ease: 'anticipate' }}
transition={{ duration: 1, ease: 'anticipate' }}
>
<AutoSizer>
{({ height, width }) => (
@@ -778,16 +738,14 @@ const BaseItemGridList = ({
_tableMetaVersion={tableMetaVersion}
controls={controls}
currentPage={currentPage}
dataVersion={dataVersion}
data={data}
enableDrag={enableDrag}
enableExpansion={enableExpansion}
enableSelection={enableSelection}
gap={gap}
getItem={resolvedGetItem}
height={height}
initialTop={initialTop}
internalState={internalState}
itemCount={resolvedItemCount}
itemType={itemType}
onRangeChanged={onRangeChanged}
onScroll={onScroll ?? (() => {})}
@@ -811,10 +769,10 @@ const BaseItemGridList = ({
const ListComponent = memo((props: ListChildComponentProps<GridItemProps>) => {
const { index, style } = props;
const { columns, controls, enableDrag, gap, getItem, itemCount, itemType, rows, size } =
props.data;
const { columns, controls, data, enableDrag, gap, itemType, rows, size } = props.data;
const items: ReactNode[] = [];
const itemCount = data.length;
const startIndex = index * columns;
const stopIndex = Math.min(itemCount - 1, startIndex + columns - 1);
@@ -827,8 +785,7 @@ const ListComponent = memo((props: ListChildComponentProps<GridItemProps>) => {
}
for (let i = startIndex; i <= stopIndex + columnCountToAdd; i += 1) {
if (i < itemCount) {
const item = getItem ? getItem(i) : undefined;
if (i < data.length) {
items.push(
<div
className={clsx(styles.itemRow, styles[`gap-${gap}`])}
@@ -837,7 +794,7 @@ const ListComponent = memo((props: ListChildComponentProps<GridItemProps>) => {
>
<ItemCard
controls={controls}
data={item}
data={data[i]}
enableDrag={enableDrag}
enableExpansion={props.data.enableExpansion}
internalState={props.data.internalState}
@@ -6,7 +6,7 @@ import { ItemListItem } from '/@/renderer/components/item-list/types';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
export const ActionsColumn = (props: ItemTableListInnerColumn) => {
const row: any = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: any = (props.data as (any | undefined)[])[props.rowIndex];
const handleActionClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
@@ -13,10 +13,11 @@ import { JoinedArtists } from '/@/renderer/features/albums/components/joined-art
import { Album, RelatedAlbumArtist, Song } from '/@/shared/types/domain-types';
const AlbumArtistsColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: RelatedAlbumArtist[] | undefined = rowItem?.[props.columns[props.columnIndex].id];
const row: RelatedAlbumArtist[] | undefined = (
props.data as (RelatedAlbumArtist[] | undefined)[]
)[props.rowIndex]?.[props.columns[props.columnIndex].id];
const item = rowItem as Album | Song | undefined;
const item = props.data[props.rowIndex] as Album | Song | undefined;
const albumArtistString = item && 'albumArtistName' in item ? item.albumArtistName : '';
if (Array.isArray(row)) {
@@ -15,10 +15,11 @@ import { Text } from '/@/shared/components/text/text';
import { Song } from '/@/shared/types/domain-types';
const AlbumColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: null | string | undefined = rowItem?.[props.columns[props.columnIndex].id];
const row: null | string | undefined = (props.data as (null | string | undefined)[])[
props.rowIndex
]?.[props.columns[props.columnIndex].id];
const song = rowItem as Song | undefined;
const song = props.data[props.rowIndex] as Song | undefined;
const albumId = song?.albumId;
const albumPath = useMemo(() => {
@@ -16,10 +16,9 @@ import { Text } from '/@/shared/components/text/text';
import { LibraryItem, RelatedAlbumArtist, Song } from '/@/shared/types/domain-types';
const AlbumArtistsColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: RelatedAlbumArtist[] | undefined = (rowItem as any)?.[
props.columns[props.columnIndex].id
];
const row: RelatedAlbumArtist[] | undefined = (
props.data as (RelatedAlbumArtist[] | undefined)[]
)[props.rowIndex]?.[props.columns[props.columnIndex].id];
const artists = useMemo(() => {
if (!row) return [];
@@ -68,8 +67,7 @@ const AlbumArtistsColumn = (props: ItemTableListInnerColumn) => {
};
const SongArtistsColumn = (props: ItemTableListInnerColumn) => {
const row: Song | undefined = (props.getRowItem?.(props.rowIndex) ??
(props.data as any[])[props.rowIndex]) as Song | undefined;
const row: Song | undefined = (props.data as (Song | undefined)[])[props.rowIndex];
if (row) {
return (
@@ -1,16 +0,0 @@
.composers-container {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
color: var(--theme-colors-foreground-muted);
user-select: none;
}
.composers-container.compact {
-webkit-line-clamp: 1;
}
.composers-container.large {
-webkit-line-clamp: 3;
}
@@ -1,50 +0,0 @@
import clsx from 'clsx';
import { memo } from 'react';
import styles from './composer-column.module.css';
import {
ColumnNullFallback,
ColumnSkeletonVariable,
ItemTableListInnerColumn,
TableColumnContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
import { Album, RelatedArtist, Song } from '/@/shared/types/domain-types';
const ComposerColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const item = rowItem as Album | Song | undefined;
const composers = item?.participants?.composer || [];
if (composers && Array.isArray(composers) && composers.length > 0) {
return (
<TableColumnContainer {...props}>
<div
className={clsx(styles.composersContainer, {
[styles.compact]: props.size === 'compact',
[styles.large]: props.size === 'large',
})}
>
<JoinedArtists
artistName=""
artists={composers as RelatedArtist[]}
linkProps={{ fw: 400, isMuted: true }}
rootTextProps={{ fw: 400, isMuted: true, size: 'sm' }}
/>
</div>
</TableColumnContainer>
);
}
if (composers?.length === 0 || item === null || item === undefined) {
return <ColumnNullFallback {...props} />;
}
return <ColumnSkeletonVariable {...props} />;
};
export const ComposerColumnMemo = memo(ComposerColumn);
export { ComposerColumnMemo as ComposerColumn };
@@ -6,8 +6,9 @@ import {
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
export const CountColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: number | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const row: number | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
if (typeof row === 'number') {
return (
@@ -30,8 +30,9 @@ const getDateTooltipLabel = (utcString: string) => {
};
export const DateColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
if (typeof row === 'string' && row) {
return (
@@ -51,11 +52,12 @@ export const DateColumn = (props: ItemTableListInnerColumn) => {
};
export const AbsoluteDateColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
if (props.type === TableColumn.RELEASE_DATE) {
const item = rowItem as any;
const item = (props.data as (any | undefined)[])[props.rowIndex];
if (item && 'releaseDate' in item && item.releaseDate) {
const releaseDate = item.releaseDate;
const originalDate =
@@ -66,7 +68,7 @@ export const AbsoluteDateColumn = (props: ItemTableListInnerColumn) => {
if (originalDate) {
const formattedOriginalDate = formatDateAbsoluteUTC(originalDate);
const formattedReleaseDate = formatDateAbsoluteUTC(releaseDate);
const displayText = `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`;
const displayText = `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`;
return (
<TableColumnTextContainer {...props}>
@@ -113,8 +115,9 @@ export const AbsoluteDateColumn = (props: ItemTableListInnerColumn) => {
};
export const RelativeDateColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
if (typeof row === 'string') {
return (
@@ -6,8 +6,9 @@ import {
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
export const DefaultColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: any | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const row: any | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
if (typeof row === 'string') {
return <TableColumnTextContainer {...props}>{row}</TableColumnTextContainer>;
@@ -8,8 +8,9 @@ import {
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
export const DurationColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: number | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const row: number | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
if (typeof row === 'number') {
return (
@@ -8,8 +8,9 @@ import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutatio
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
export const FavoriteColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: boolean | undefined = rowItem?.[props.columns[props.columnIndex].id];
const row: boolean | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
const isMutatingCreateFavorite = useIsMutatingCreateFavorite();
const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();
@@ -30,7 +31,7 @@ export const FavoriteColumn = (props: ItemTableListInnerColumn) => {
onClick={(event) => {
event.stopPropagation();
event.preventDefault();
const item = rowItem as ItemListItem;
const item = props.data[props.rowIndex] as ItemListItem;
const rowId = props.internalState.extractRowId(item);
const index = rowId ? props.internalState.findItemIndex(rowId) : -1;
props.controls.onFavorite?.({
@@ -18,8 +18,9 @@ import { stringToColor } from '/@/shared/utils/string-to-color';
const MAX_GENRES = 4;
const GenreBadgeColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: Genre[] | undefined = (rowItem as any)?.genres;
const row: Genre[] | undefined = (props.data as (Genre[] | undefined)[])[props.rowIndex]?.[
'genres'
];
const genres = useMemo(() => {
if (!row) return [];
@@ -15,8 +15,9 @@ import { Text } from '/@/shared/components/text/text';
import { Genre } from '/@/shared/types/domain-types';
const GenreColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: Genre[] | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const row: Genre[] | undefined = (props.data as (Genre[] | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
const genres = useMemo(() => {
if (!row) return [];
@@ -20,9 +20,8 @@ import { Folder, LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
export const ImageColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = rowItem?.id;
const item = rowItem as any;
const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.id;
const item = props.data[props.rowIndex] as any;
const playButtonBehavior = usePlayButtonBehavior();
const internalState = (props as any).internalState;
const [isHovered, setIsHovered] = useState(false);
@@ -114,7 +113,7 @@ export const ImageColumn = (props: ItemTableListInnerColumn) => {
);
}
if ((rowItem as unknown as Folder)?._itemType === LibraryItem.FOLDER) {
if ((props.data[props.rowIndex] as unknown as Folder)?._itemType === LibraryItem.FOLDER) {
return (
<TableColumnContainer {...props}>
<Icon className={styles.folderIcon} icon="folder" size="2xl" />
@@ -6,8 +6,9 @@ import {
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
export const NumericColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: number | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const row: number | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
if (typeof row === 'number') {
return <TableColumnTextContainer {...props}>{row}</TableColumnTextContainer>;
@@ -6,8 +6,9 @@ import {
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
export const PathColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
if (typeof row === 'string' && row) {
return (
@@ -24,9 +24,7 @@ export const PlaylistReorderColumn = (props: ItemTableListInnerColumn) => {
const { playlistId } = useParams() as { playlistId?: string };
const isHeaderEnabled = !!props.enableHeader;
const isDataRow = isHeaderEnabled ? props.rowIndex > 0 : true;
const item = isDataRow
? (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex])
: null;
const item = isDataRow ? props.data[props.rowIndex] : null;
const isPlaylistSong = props.itemType === LibraryItem.PLAYLIST_SONG;
@@ -155,8 +153,8 @@ export const PlaylistReorderColumn = (props: ItemTableListInnerColumn) => {
const isDragging = props.internalState ? isDraggingState : isDraggingLocal;
const getValidDataItems = useCallback(() => {
return props.internalState.getData().filter((d) => d !== null && (d as any).id);
}, [props.internalState]);
return props.data.filter((d) => d !== null && (d as any).id);
}, [props.data]);
const handleMoveUp = useCallback(() => {
if (!item || !isDataRow || !isPlaylistSong || !playlistId) {
@@ -7,8 +7,9 @@ import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-r
import { Rating } from '/@/shared/components/rating/rating';
export const RatingColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: null | number | undefined = rowItem?.[props.columns[props.columnIndex].id];
const row: null | number | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
const isMutatingRating = useIsMutatingRating();
@@ -18,7 +19,7 @@ export const RatingColumn = (props: ItemTableListInnerColumn) => {
<Rating
className={row ? undefined : 'hover-only-flex'}
onChange={(rating) => {
const item = rowItem as ItemListItem;
const item = props.data[props.rowIndex] as ItemListItem;
const rowId = props.internalState.extractRowId(item);
const index = rowId ? props.internalState.findItemIndex(rowId) : -1;
props.controls.onRating?.({
@@ -8,7 +8,6 @@ import {
TableColumnContainer,
TableColumnTextContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { ItemListItem } from '/@/renderer/components/item-list/types';
import { usePlayerStatus } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
@@ -34,6 +33,7 @@ export const RowIndexColumn = (props: ItemTableListInnerColumn) => {
const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
const {
adjustedRowIndexMap,
controls,
data,
enableExpansion,
@@ -45,9 +45,7 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
} = props;
let adjustedRowIndex =
props.getAdjustedRowIndex?.(rowIndex) ??
props.adjustedRowIndexMap?.get(rowIndex) ??
(enableHeader ? rowIndex : rowIndex + 1);
adjustedRowIndexMap?.get(rowIndex) ?? (enableHeader ? rowIndex : rowIndex + 1);
if (startRowIndex !== undefined && adjustedRowIndex > 0) {
adjustedRowIndex = startRowIndex + adjustedRowIndex;
@@ -61,8 +59,7 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
icon="arrowDownS"
iconProps={{ color: 'muted', size: 'md' }}
onClick={(e) => {
const item = (props.getRowItem?.(rowIndex) ??
data[rowIndex]) as ItemListItem;
const item = data[rowIndex] as ItemListItem;
const rowId = internalState.extractRowId(item);
const index = rowId ? internalState.findItemIndex(rowId) : -1;
controls.onExpand?.({
@@ -88,13 +85,14 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => {
const status = usePlayerStatus();
const song = (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex]) as QueueSong;
const isActive = useIsActiveRow(song?.id, song?._uniqueId);
const song = props.data[props.rowIndex] as QueueSong;
const isActive =
!!props.activeRowId &&
(props.activeRowId === song?.id || props.activeRowId === song?._uniqueId);
const isActiveAndPlaying = isActive && status === PlayerStatus.PLAYING;
let adjustedRowIndex =
props.getAdjustedRowIndex?.(props.rowIndex) ??
props.adjustedRowIndexMap?.get(props.rowIndex) ??
(props.enableHeader ? props.rowIndex : props.rowIndex + 1);
@@ -7,8 +7,9 @@ import {
import { formatSizeString } from '/@/renderer/utils/format';
export const SizeColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: number | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const row: number | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
if (typeof row === 'number') {
return (
@@ -10,8 +10,9 @@ import {
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
export const TextColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
if (typeof row === 'string' && row) {
return (
@@ -1,67 +0,0 @@
.title-artist {
display: flex;
gap: var(--theme-spacing-sm);
width: 100%;
height: 100%;
}
.text-container {
display: grid;
grid-template-rows: 1fr 1fr;
gap: var(--theme-spacing-xs);
min-width: 0;
}
.text-container.align-left {
justify-items: start;
}
.text-container.align-center {
justify-items: center;
}
.text-container.align-right {
justify-items: end;
}
.text-container.compact {
gap: 0;
}
.title {
display: inline-block;
width: 100%;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
a.title {
width: auto;
}
.artists {
display: block;
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--theme-font-size-xs) !important;
color: var(--theme-colors-foreground-muted);
white-space: nowrap;
user-select: none;
}
.folder-icon {
color: black;
fill: rgb(255 215 100);
}
.active {
color: var(--theme-colors-primary);
}
@@ -1,209 +0,0 @@
import clsx from 'clsx';
import { CSSProperties } from 'react';
import { Link } from 'react-router';
import styles from './title-artist-column.module.css';
import { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path';
import {
ColumnNullFallback,
ColumnSkeletonVariable,
ItemTableListInnerColumn,
TableColumnContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
import { Icon } from '/@/shared/components/icon/icon';
import { Text } from '/@/shared/components/text/text';
import { Folder, LibraryItem, QueueSong } from '/@/shared/types/domain-types';
export const DefaultTitleArtistColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: object | undefined = (rowItem as any)?.id;
const item = rowItem as any;
const align = props.columns[props.columnIndex]?.align || 'start';
if (item && 'name' in item && 'artists' in item) {
const rowHeight = props.getRowHeight(props.rowIndex, props);
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
const item = rowItem as any;
const titleLinkProps = path
? {
component: Link,
isLink: true,
state: { item },
to: path,
}
: {};
return (
<TableColumnContainer
className={clsx(styles.titleArtist)}
containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}
{...props}
>
<div
className={clsx(styles.textContainer, {
[styles.alignCenter]: align === 'center',
[styles.alignLeft]: align === 'start',
[styles.alignRight]: align === 'end',
[styles.compact]: props.size === 'compact',
})}
>
<Text className={styles.title} isNoSelect size="md" {...titleLinkProps}>
{item.name as string}
</Text>
<div className={styles.artists}>
<JoinedArtists
artistName={item.albumArtist}
artists={item.albumArtists}
linkProps={{ fw: 400, isMuted: true }}
rootTextProps={{ fw: 400, isMuted: true, size: 'sm' }}
/>
</div>
</div>
</TableColumnContainer>
);
}
if (row === null) {
return <ColumnNullFallback {...props} />;
}
return <ColumnSkeletonVariable {...props} />;
};
export const QueueSongTitleArtistColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: object | undefined = rowItem as any;
const song = rowItem as QueueSong;
const isActive = useIsActiveRow(song?.id, song?._uniqueId);
const align = props.columns[props.columnIndex]?.align || 'start';
const alignClass =
align === 'center' ? 'align-center' : align === 'end' ? 'align-right' : 'align-left';
if (row && 'name' in row && 'artists' in row) {
const rowHeight = props.getRowHeight(props.rowIndex, props);
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
const item = rowItem as any;
const titleLinkProps = path
? {
component: Link,
isLink: true,
state: { item },
to: path,
}
: {};
return (
<TableColumnContainer
className={clsx(styles.titleArtist, styles[alignClass])}
containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}
{...props}
>
<div
className={clsx(styles.textContainer, styles[alignClass], {
[styles.active]: isActive,
[styles.compact]: props.size === 'compact',
})}
>
<Text
className={clsx({
[styles.active]: isActive,
[styles.title]: true,
})}
isNoSelect
size="md"
{...titleLinkProps}
>
{row.name as string}
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
<Text
className={clsx({
[styles.active]: isActive,
})}
component="span"
isMuted
size="sm"
>
{' ('}
{song.trackSubtitle}
{')'}
</Text>
)}
</Text>
<div className={styles.artists}>
<JoinedArtists
artistName={item.artistName}
artists={item.artists}
linkProps={{ fw: 400, isMuted: true }}
rootTextProps={{ fw: 400, isMuted: true, size: 'sm' }}
/>
</div>
</div>
</TableColumnContainer>
);
}
if ((rowItem as unknown as Folder)?._itemType === LibraryItem.FOLDER) {
const rowHeight = props.getRowHeight(props.rowIndex, props);
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
const item = rowItem as any;
const textStyles = isActive ? { color: 'var(--theme-colors-primary)' } : {};
const titleLinkProps = path
? {
component: Link,
isLink: true,
state: { item },
to: path,
}
: {};
const title = (rowItem as unknown as Folder)?.name;
return (
<TableColumnContainer
className={clsx(styles.titleArtist, styles[alignClass])}
containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}
{...props}
>
<Icon className={styles.folderIcon} icon="folder" size="2xl" />
<Text
className={styles.title}
isNoSelect
size="md"
{...titleLinkProps}
style={textStyles}
>
{title}
</Text>
</TableColumnContainer>
);
}
if (row === null) {
return <ColumnNullFallback {...props} />;
}
return <ColumnSkeletonVariable {...props} />;
};
export const TitleArtistColumn = (props: ItemTableListInnerColumn) => {
const { itemType } = props;
switch (itemType) {
case LibraryItem.FOLDER:
case LibraryItem.PLAYLIST_SONG:
case LibraryItem.QUEUE_SONG:
case LibraryItem.SONG:
return <QueueSongTitleArtistColumn {...props} />;
default:
return <DefaultTitleArtistColumn {...props} />;
}
};
@@ -1,6 +1,7 @@
.name-container {
display: -webkit-inline-box;
align-self: flex-start;
width: fit-content;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
@@ -8,10 +9,6 @@
-webkit-box-orient: vertical;
}
a.name-container {
width: auto;
}
.name-container.compact {
-webkit-line-clamp: 1;
}
@@ -10,7 +10,6 @@ import {
ItemTableListInnerColumn,
TableColumnContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
@@ -29,12 +28,13 @@ export const TitleColumn = (props: ItemTableListInnerColumn) => {
};
function DefaultTitleColumn(props: ItemTableListInnerColumn) {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = rowItem?.[props.columns[props.columnIndex].id];
const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
if (typeof row === 'string') {
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
const item = rowItem as any;
const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string);
const item = props.data[props.rowIndex] as any;
const titleLinkProps = path
? {
@@ -70,15 +70,18 @@ function DefaultTitleColumn(props: ItemTableListInnerColumn) {
}
function QueueSongTitleColumn(props: ItemTableListInnerColumn) {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = rowItem?.[props.columns[props.columnIndex].id];
const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
const song = rowItem as QueueSong;
const isActive = useIsActiveRow(song?.id, song?._uniqueId);
const song = props.data[props.rowIndex] as QueueSong;
const isActive =
!!props.activeRowId &&
(props.activeRowId === song?.id || props.activeRowId === song?._uniqueId);
if (typeof row === 'string') {
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
const item = rowItem as any;
const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string);
const item = props.data[props.rowIndex] as any;
const titleLinkProps = path
? {
@@ -13,41 +13,21 @@
min-width: 0;
}
.text-container.align-left {
justify-items: start;
}
.text-container.align-center {
justify-items: center;
}
.text-container.align-right {
justify-items: end;
}
.text-container.compact {
gap: 0;
}
.title {
display: inline-block;
width: 100%;
width: fit-content;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
a.title {
width: auto;
}
.artists {
display: block;
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--theme-font-size-xs) !important;
@@ -12,7 +12,6 @@ import {
ItemTableListInnerColumn,
TableColumnContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
import {
@@ -26,9 +25,8 @@ import { Folder, LibraryItem, QueueSong } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: object | undefined = (rowItem as any)?.id;
const item = rowItem as any;
const row: object | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.id;
const item = props.data[props.rowIndex] as any;
const internalState = (props as any).internalState;
const playButtonBehavior = usePlayButtonBehavior();
const [isHovered, setIsHovered] = useState(false);
@@ -77,10 +75,9 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
if (item && 'name' in item && 'imageUrl' in item && 'artists' in item) {
const rowHeight = props.getRowHeight(props.rowIndex, props);
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
const align = props.columns[props.columnIndex]?.align || 'start';
const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string);
const item = rowItem as any;
const item = props.data[props.rowIndex] as any;
const titleLinkProps = path
? {
component: Link,
@@ -131,9 +128,6 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
</div>
<div
className={clsx(styles.textContainer, {
[styles.alignCenter]: align === 'center',
[styles.alignLeft]: align === 'start',
[styles.alignRight]: align === 'end',
[styles.compact]: props.size === 'compact',
})}
>
@@ -161,15 +155,16 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
};
export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: object | undefined = rowItem as any;
const row: object | undefined = (props.data as (any | undefined)[])[props.rowIndex];
const song = rowItem as QueueSong;
const item = rowItem as any;
const song = props.data[props.rowIndex] as QueueSong;
const item = props.data[props.rowIndex] as any;
const internalState = (props as any).internalState;
const playButtonBehavior = usePlayButtonBehavior();
const [isHovered, setIsHovered] = useState(false);
const isActive = useIsActiveRow(song?.id, song?._uniqueId);
const isActive =
!!props.activeRowId &&
(props.activeRowId === song?.id || props.activeRowId === song?._uniqueId);
const handlePlay = (playType: Play, event: React.MouseEvent<HTMLButtonElement>) => {
if (!item) {
@@ -215,10 +210,9 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
if (row && 'name' in row && 'imageUrl' in row && 'artists' in row) {
const rowHeight = props.getRowHeight(props.rowIndex, props);
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
const align = props.columns[props.columnIndex]?.align || 'start';
const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string);
const item = rowItem as any;
const item = props.data[props.rowIndex] as any;
const titleLinkProps = path
? {
@@ -244,7 +238,6 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
containerClassName={styles.image}
id={item?.imageId}
itemType={item?._itemType}
serverId={item?._serverId}
src={item?.imageUrl}
type="table"
/>
@@ -272,9 +265,6 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
<div
className={clsx(styles.textContainer, {
[styles.active]: isActive,
[styles.alignCenter]: align === 'center',
[styles.alignLeft]: align === 'start',
[styles.alignRight]: align === 'end',
[styles.compact]: props.size === 'compact',
})}
>
@@ -316,11 +306,11 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
);
}
if ((rowItem as unknown as Folder)?._itemType === LibraryItem.FOLDER) {
if ((props.data[props.rowIndex] as unknown as Folder)?._itemType === LibraryItem.FOLDER) {
const rowHeight = props.getRowHeight(props.rowIndex, props);
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string);
const item = rowItem as any;
const item = props.data[props.rowIndex] as any;
const textStyles = isActive ? { color: 'var(--theme-colors-primary)' } : {};
const titleLinkProps = path
@@ -332,7 +322,7 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
}
: {};
const title = (rowItem as unknown as Folder)?.name;
const title = (props.data[props.rowIndex] as unknown as Folder)?.name;
return (
<TableColumnContainer
@@ -7,8 +7,7 @@ import {
import { SEPARATOR_STRING } from '/@/shared/api/utils';
export const YearColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const item = rowItem as any;
const item = (props.data as (any | undefined)[])[props.rowIndex];
if (item && 'releaseYear' in item && item.releaseYear !== null) {
const releaseYear = item.releaseYear;
@@ -18,7 +17,7 @@ export const YearColumn = (props: ItemTableListInnerColumn) => {
if (originalYear !== null && originalYear !== releaseYear) {
return (
<TableColumnTextContainer {...props}>
{originalYear}
{originalYear}
{SEPARATOR_STRING}
{releaseYear}
</TableColumnTextContainer>
@@ -30,7 +29,9 @@ export const YearColumn = (props: ItemTableListInnerColumn) => {
}
}
const row: number | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const row: number | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
if (row === null) {
return <ColumnNullFallback {...props} />;
@@ -49,15 +49,6 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
value: TableColumn.TITLE_COMBINED,
width: 300,
},
{
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.titleArtist', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.TITLE_ARTIST,
width: 300,
},
{
align: 'center',
autoSize: false,
@@ -94,15 +85,6 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
value: TableColumn.ARTIST,
width: 300,
},
{
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.composer', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.COMPOSER,
width: 300,
},
{
align: 'start',
autoSize: false,
@@ -333,15 +315,6 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
value: TableColumn.TITLE_COMBINED,
width: 300,
},
{
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.titleArtist', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.TITLE_ARTIST,
width: 300,
},
{
align: 'center',
autoSize: false,
@@ -369,15 +342,6 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
value: TableColumn.ARTIST,
width: 300,
},
{
align: 'start',
autoSize: false,
isEnabled: false,
label: i18n.t('table.config.label.composer', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.COMPOSER,
width: 300,
},
{
align: 'center',
autoSize: false,
@@ -1,82 +0,0 @@
import { useEffect } from 'react';
interface UseContainerWidthTrackingProps {
autoFitColumns: boolean;
containerRef: React.RefObject<HTMLDivElement | null>;
rowRef: React.RefObject<HTMLDivElement | null>;
setCenterContainerWidth: (width: number) => void;
setTotalContainerWidth: (width: number) => void;
}
/**
* Hook to track container widths using ResizeObserver for column width calculations.
*/
export const useContainerWidthTracking = ({
autoFitColumns,
containerRef,
rowRef,
setCenterContainerWidth,
setTotalContainerWidth,
}: UseContainerWidthTrackingProps) => {
// Track center container width (for column distribution)
useEffect(() => {
const el = rowRef.current;
if (!el) return;
const updateWidth = () => {
setCenterContainerWidth(el.clientWidth || 0);
};
updateWidth();
let debounceTimeout: NodeJS.Timeout | null = null;
const resizeObserver = new ResizeObserver(() => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
debounceTimeout = setTimeout(() => {
updateWidth();
}, 100);
});
resizeObserver.observe(el);
return () => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
resizeObserver.disconnect();
};
}, [rowRef, setCenterContainerWidth]);
// Track total container width for autoFitColumns
useEffect(() => {
const el = containerRef.current;
if (!el || !autoFitColumns) return;
const updateWidth = () => {
setTotalContainerWidth(el.clientWidth || 0);
};
updateWidth();
let debounceTimeout: NodeJS.Timeout | null = null;
const resizeObserver = new ResizeObserver(() => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
debounceTimeout = setTimeout(() => {
updateWidth();
}, 100);
});
resizeObserver.observe(el);
return () => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
resizeObserver.disconnect();
};
}, [autoFitColumns, containerRef, setTotalContainerWidth]);
};
@@ -1,158 +0,0 @@
import { useEffect } from 'react';
interface UseRowInteractionDelegateProps {
containerRef: React.RefObject<HTMLDivElement | null>;
enableRowHoverHighlight: boolean;
}
/**
* Hook to handle row hover and drag-over styling via delegated event listeners.
* This is intentionally imperative to avoid React re-rendering the entire visible grid on hover.
*/
export const useRowInteractionDelegate = ({
containerRef,
enableRowHoverHighlight,
}: UseRowInteractionDelegateProps) => {
// Row hover highlight: do one delegated listener per table rather than per cell
useEffect(() => {
if (!enableRowHoverHighlight) return;
const root = containerRef.current;
if (!root) return;
let hoveredKey: null | string = null;
let rafId: null | number = null;
const getRowKey = (target: EventTarget | null): null | string => {
const el = target instanceof Element ? target : null;
const rowEl = el?.closest?.('[data-row-index]') as HTMLElement | null;
return rowEl?.getAttribute('data-row-index') ?? null;
};
const apply = (prev: null | string, next: null | string) => {
if (rafId !== null) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
if (prev) {
root.querySelectorAll(`[data-row-index="${prev}"]`).forEach((node) => {
(node as HTMLElement).removeAttribute('data-row-hovered');
});
}
if (next) {
root.querySelectorAll(`[data-row-index="${next}"]`).forEach((node) => {
(node as HTMLElement).setAttribute('data-row-hovered', 'true');
});
}
});
};
const setHovered = (next: null | string) => {
if (next === hoveredKey) return;
const prev = hoveredKey;
hoveredKey = next;
apply(prev, next);
};
const onPointerOver = (e: PointerEvent) => {
setHovered(getRowKey(e.target));
};
const onPointerOut = (e: PointerEvent) => {
// If moving within the same row, keep it hovered
const relatedKey = getRowKey((e as any).relatedTarget);
if (relatedKey === hoveredKey) return;
setHovered(relatedKey);
};
root.addEventListener('pointerover', onPointerOver);
root.addEventListener('pointerout', onPointerOut);
return () => {
root.removeEventListener('pointerover', onPointerOver);
root.removeEventListener('pointerout', onPointerOut);
if (rafId !== null) cancelAnimationFrame(rafId);
// Ensure we don't leave stale attributes behind
if (hoveredKey) apply(hoveredKey, null);
};
}, [containerRef, enableRowHoverHighlight]);
// Dragged-over row border styling delegation
useEffect(() => {
const root = containerRef.current;
if (!root) return;
let current: null | { edge: 'bottom' | 'top'; rowKey: string } = null;
let pending: null | { edge: 'bottom' | 'top' | null; rowKey: string } = null;
let rafId: null | number = null;
const clearRow = (rowKey: string) => {
root.querySelectorAll(`[data-row-index="${rowKey}"]`).forEach((node) => {
const el = node as HTMLElement;
el.removeAttribute('data-row-dragged-over');
el.removeAttribute('data-row-dragged-over-first');
});
};
const applyRow = (rowKey: string, edge: 'bottom' | 'top') => {
const nodes = root.querySelectorAll(`[data-row-index="${rowKey}"]`);
nodes.forEach((node, idx) => {
const el = node as HTMLElement;
el.setAttribute('data-row-dragged-over', edge);
if (idx === 0) {
el.setAttribute('data-row-dragged-over-first', 'true');
} else {
el.removeAttribute('data-row-dragged-over-first');
}
});
};
const flush = () => {
rafId = null;
const next = pending;
pending = null;
if (!next) return;
// Clear previous row if we're moving rows or clearing.
if (current && current.rowKey !== next.rowKey) {
clearRow(current.rowKey);
current = null;
}
if (!next.edge) {
if (current) {
clearRow(current.rowKey);
current = null;
}
return;
}
// If same row + edge, no-op.
if (current && current.rowKey === next.rowKey && current.edge === next.edge) return;
if (current) clearRow(current.rowKey);
applyRow(next.rowKey, next.edge);
current = { edge: next.edge, rowKey: next.rowKey };
};
const scheduleFlush = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(flush);
};
const onRowDragOver = (e: Event) => {
const ev = e as CustomEvent<{ edge?: 'bottom' | 'top' | null; rowKey?: string }>;
const rowKey = ev.detail?.rowKey;
const edge = ev.detail?.edge ?? null;
if (!rowKey) return;
pending = { edge, rowKey };
scheduleFlush();
};
root.addEventListener('itl:row-drag-over', onRowDragOver as any);
return () => {
root.removeEventListener('itl:row-drag-over', onRowDragOver as any);
if (rafId !== null) cancelAnimationFrame(rafId);
if (current) clearRow(current.rowKey);
};
}, [containerRef]);
};
@@ -1,51 +0,0 @@
import { useEffect } from 'react';
interface UseStickyGroupRowPositioningProps {
containerRef: React.RefObject<HTMLDivElement | null>;
shouldRenderStickyGroupRow: boolean;
stickyGroupRowRef: React.RefObject<HTMLDivElement | null>;
}
/**
* Hook to update the position and width of the sticky group row based on container position.
*/
export const useStickyGroupRowPositioning = ({
containerRef,
shouldRenderStickyGroupRow,
stickyGroupRowRef,
}: UseStickyGroupRowPositioningProps) => {
useEffect(() => {
if (!shouldRenderStickyGroupRow || !stickyGroupRowRef.current || !containerRef.current) {
return;
}
const stickyGroupRow = stickyGroupRowRef.current;
const container = containerRef.current;
let isMounted = true;
const updatePosition = () => {
// Guard against updates after unmount
if (!isMounted || !stickyGroupRow || !container) {
return;
}
try {
const containerRect = container.getBoundingClientRect();
stickyGroupRow.style.left = `${containerRect.left}px`;
stickyGroupRow.style.width = `${containerRect.width}px`;
} catch {
// Silently handle errors if elements are no longer in DOM
}
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
isMounted = false;
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [containerRef, shouldRenderStickyGroupRow, stickyGroupRowRef]);
};
@@ -1,52 +0,0 @@
import { useEffect } from 'react';
interface UseStickyHeaderPositioningProps {
containerRef: React.RefObject<HTMLDivElement | null>;
shouldShowStickyHeader: boolean;
stickyHeaderRef: React.RefObject<HTMLDivElement | null>;
}
/**
* Hook to update the position and width of the sticky header based on container position.
* Scroll synchronization is handled separately in useStickyTableHeader.
*/
export const useStickyHeaderPositioning = ({
containerRef,
shouldShowStickyHeader,
stickyHeaderRef,
}: UseStickyHeaderPositioningProps) => {
useEffect(() => {
if (!shouldShowStickyHeader || !stickyHeaderRef.current || !containerRef.current) {
return;
}
const stickyHeader = stickyHeaderRef.current;
const container = containerRef.current;
let isMounted = true;
const updatePosition = () => {
// Guard against updates after unmount
if (!isMounted || !stickyHeader || !container) {
return;
}
try {
const containerRect = container.getBoundingClientRect();
stickyHeader.style.left = `${containerRect.left}px`;
stickyHeader.style.width = `${containerRect.width}px`;
} catch {
// Silently handle errors if elements are no longer in DOM
}
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
isMounted = false;
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [containerRef, shouldShowStickyHeader, stickyHeaderRef]);
};
@@ -1,115 +0,0 @@
import { useMemo } from 'react';
import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns';
import { ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
export const useTableColumnModel = ({
autoFitColumns,
centerContainerWidth,
columns,
totalContainerWidth,
}: {
autoFitColumns: boolean;
centerContainerWidth: number;
columns: ItemTableListColumnConfig[];
totalContainerWidth: number;
}) => {
const parsedColumns = useMemo(() => parseTableColumns(columns), [columns]);
const calculatedColumnWidths = useMemo(() => {
const baseWidths = parsedColumns.map((c) => c.width);
// When autoSizeColumns is enabled, treat all widths as proportions and scale to fit container
if (autoFitColumns) {
const totalReferenceWidth = baseWidths.reduce((sum, width) => sum + width, 0);
if (totalReferenceWidth === 0 || totalContainerWidth === 0) {
return baseWidths.map((width) => Math.round(width));
}
const scaleFactor = totalContainerWidth / totalReferenceWidth;
const scaledWidths = baseWidths.map((width) => Math.round(width * scaleFactor));
// Adjust for rounding errors: ensure total equals totalContainerWidth
const totalScaled = scaledWidths.reduce((sum, width) => sum + width, 0);
const difference = totalContainerWidth - totalScaled;
if (difference !== 0 && scaledWidths.length > 0) {
const sortedIndices = scaledWidths
.map((width, idx) => ({ idx, width }))
.sort((a, b) => b.width - a.width);
const adjustmentPerColumn = Math.sign(difference);
const adjustmentCount = Math.abs(difference);
for (let i = 0; i < adjustmentCount && i < sortedIndices.length; i++) {
scaledWidths[sortedIndices[i].idx] += adjustmentPerColumn;
}
}
return scaledWidths;
}
// Original behavior: distribute extra space to auto-size columns
const distributed = baseWidths.slice();
const unpinnedIndices: number[] = [];
const autoUnpinnedIndices: number[] = [];
parsedColumns.forEach((col, idx) => {
if (col.pinned === null) {
unpinnedIndices.push(idx);
if (col.autoSize) {
autoUnpinnedIndices.push(idx);
}
}
});
if (unpinnedIndices.length === 0 || autoUnpinnedIndices.length === 0) {
return distributed.map((width) => Math.round(width));
}
const unpinnedBaseTotal = unpinnedIndices.reduce((sum, idx) => sum + baseWidths[idx], 0);
const extra = Math.max(0, centerContainerWidth - unpinnedBaseTotal);
if (extra <= 0) {
return distributed.map((width) => Math.round(width));
}
const extraPer = extra / autoUnpinnedIndices.length;
autoUnpinnedIndices.forEach((idx) => {
distributed[idx] = Math.round(baseWidths[idx] + extraPer);
});
return distributed.map((width) => Math.round(width));
}, [autoFitColumns, centerContainerWidth, parsedColumns, totalContainerWidth]);
const pinnedLeftColumnCount = useMemo(
() => parsedColumns.filter((col) => col.pinned === 'left').length,
[parsedColumns],
);
const pinnedRightColumnCount = useMemo(
() => parsedColumns.filter((col) => col.pinned === 'right').length,
[parsedColumns],
);
const columnCount = parsedColumns.length;
const totalColumnCount = columnCount - pinnedLeftColumnCount - pinnedRightColumnCount;
return useMemo(
() => ({
calculatedColumnWidths,
columnCount,
parsedColumns,
pinnedLeftColumnCount,
pinnedRightColumnCount,
totalColumnCount,
}),
[
calculatedColumnWidths,
columnCount,
parsedColumns,
pinnedLeftColumnCount,
pinnedRightColumnCount,
totalColumnCount,
],
);
};
@@ -1,43 +0,0 @@
import { useEffect, useImperativeHandle, useMemo } from 'react';
import { ItemListHandle, ItemListStateActions } from '/@/renderer/components/item-list/types';
interface UseTableImperativeHandleProps {
enableHeader: boolean;
handleRef: React.RefObject<ItemListHandle | null>;
internalState: ItemListStateActions;
ref?: React.Ref<ItemListHandle>;
scrollToTableIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => void;
scrollToTableOffset: (offset: number) => void;
}
/**
* Hook to set up the imperative handle for ItemTableList, providing scroll methods and internal state.
*/
export const useTableImperativeHandle = ({
enableHeader,
handleRef,
internalState,
ref,
scrollToTableIndex,
scrollToTableOffset,
}: UseTableImperativeHandleProps) => {
const imperativeHandle: ItemListHandle = useMemo(
() => ({
internalState,
scrollToIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => {
scrollToTableIndex(enableHeader ? index + 1 : index, options);
},
scrollToOffset: (offset: number) => {
scrollToTableOffset(offset);
},
}),
[enableHeader, internalState, scrollToTableIndex, scrollToTableOffset],
);
useImperativeHandle(ref, () => imperativeHandle);
useEffect(() => {
handleRef.current = imperativeHandle;
}, [handleRef, imperativeHandle]);
};
@@ -1,42 +0,0 @@
import { useEffect, useRef } from 'react';
interface UseTableInitialScrollProps {
initialTop?: {
behavior?: 'auto' | 'smooth';
to: number;
type: 'index' | 'offset';
};
scrollToTableIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => void;
scrollToTableOffset: (offset: number) => void;
startRowIndex?: number;
}
/**
* Hook to handle initial scroll position and scrolling to top when startRowIndex changes.
*/
export const useTableInitialScroll = ({
initialTop,
scrollToTableIndex,
scrollToTableOffset,
startRowIndex,
}: UseTableInitialScrollProps) => {
const isInitialScrollPositionSet = useRef<boolean>(false);
useEffect(() => {
if (!initialTop || isInitialScrollPositionSet.current) return;
isInitialScrollPositionSet.current = true;
if (initialTop.type === 'offset') {
scrollToTableOffset(initialTop.to);
} else {
scrollToTableIndex(initialTop.to);
}
}, [initialTop, scrollToTableIndex, scrollToTableOffset]);
// Scroll to top when startRowIndex changes
useEffect(() => {
if (startRowIndex !== undefined) {
scrollToTableOffset(0);
}
}, [startRowIndex, scrollToTableOffset]);
};
@@ -1,216 +0,0 @@
import { useCallback } from 'react';
import {
ItemListStateActions,
ItemListStateItemWithRequiredProperties,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { PlayerContext } from '/@/renderer/features/player/context/player-context';
import { LibraryItem } from '/@/shared/types/domain-types';
interface UseTableKeyboardNavigationProps {
calculateScrollTopForIndex: (index: number) => number;
cellPadding: TableItemProps['cellPadding'];
data: unknown[];
DEFAULT_ROW_HEIGHT: number;
enableHeader: boolean;
enableSelection: boolean;
extractRowId: (item: unknown) => string | undefined;
getItem?: (index: number) => undefined | unknown;
getItemIndex?: (rowId: string) => number | undefined;
getStateItem: (item: any) => ItemListStateItemWithRequiredProperties | null;
hasRequiredStateItemProperties: (
item: unknown,
) => item is ItemListStateItemWithRequiredProperties;
internalState: ItemListStateActions;
itemCount?: number;
itemType: LibraryItem;
parsedColumns: TableItemProps['columns'];
pinnedRightColumnCount: number;
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
playerContext: PlayerContext;
rowHeight: ((index: number, cellProps: TableItemProps) => number) | number | undefined;
rowRef: React.RefObject<HTMLDivElement | null>;
scrollToTableIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => void;
size: TableItemProps['size'];
tableId: string;
}
/**
* Hook to handle keyboard navigation (ArrowUp/ArrowDown) for table row selection and scrolling.
*/
export const useTableKeyboardNavigation = ({
calculateScrollTopForIndex,
cellPadding,
data,
DEFAULT_ROW_HEIGHT,
enableHeader,
enableSelection,
extractRowId,
getItem,
getItemIndex,
getStateItem,
hasRequiredStateItemProperties,
internalState,
itemCount,
itemType,
parsedColumns,
pinnedRightColumnCount,
pinnedRightColumnRef,
playerContext,
rowHeight,
rowRef,
scrollToTableIndex,
size,
tableId,
}: UseTableKeyboardNavigationProps) => {
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (!enableSelection) return;
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
e.stopPropagation();
const selected = internalState.getSelected();
const validSelected = selected.filter(hasRequiredStateItemProperties);
let currentIndex = -1;
const totalCount = itemCount ?? data.length;
if (validSelected.length > 0) {
const lastSelected = validSelected[validSelected.length - 1];
const rowId = extractRowId(lastSelected);
if (rowId) {
currentIndex =
getItemIndex?.(rowId) ?? data.findIndex((d) => extractRowId(d) === rowId);
}
}
let newIndex = 0;
if (currentIndex !== -1) {
newIndex =
e.key === 'ArrowDown'
? Math.min(currentIndex + 1, totalCount - 1)
: Math.max(currentIndex - 1, 0);
}
const newItem: any = getItem ? getItem(newIndex) : data[newIndex];
if (!newItem) return;
const newItemListItem = getStateItem(newItem);
if (newItemListItem && extractRowId(newItemListItem)) {
internalState.setSelected([newItemListItem]);
}
// Check if we need to scroll by determining if the item is at the edge of the viewport
const gridIndex = enableHeader ? newIndex + 1 : newIndex;
const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement | undefined;
const pinnedRightContainer = pinnedRightColumnRef.current?.childNodes[0] as
| HTMLDivElement
| undefined;
// Use right pinned column scroll position if right-pinned columns exist
const scrollContainer =
pinnedRightColumnCount > 0 && pinnedRightContainer
? pinnedRightContainer
: mainContainer;
if (scrollContainer) {
const viewportTop = scrollContainer.scrollTop;
const viewportHeight = scrollContainer.clientHeight;
const viewportBottom = viewportTop + viewportHeight;
const rowTop = calculateScrollTopForIndex(gridIndex);
const adjustedIndex = enableHeader ? Math.max(0, newIndex - 1) : newIndex;
const mockCellProps: TableItemProps = {
cellPadding,
columns: parsedColumns,
controls: {} as ItemControls,
data: enableHeader ? [null] : [],
enableAlternateRowColors: false,
enableExpansion: false,
enableHeader,
enableHorizontalBorders: false,
enableRowHoverHighlight: false,
enableSelection,
enableVerticalBorders: false,
getRowHeight: () => DEFAULT_ROW_HEIGHT,
getRowItem: (rowIndex: number) => {
if (!getItem) return undefined;
if (enableHeader && rowIndex === 0) return null;
const dataIndex = enableHeader ? rowIndex - 1 : rowIndex;
return getItem(dataIndex);
},
internalState: {} as ItemListStateActions,
itemType,
playerContext,
size,
tableId,
};
let calculatedRowHeight: number;
if (typeof rowHeight === 'number') {
calculatedRowHeight = rowHeight;
} else if (typeof rowHeight === 'function') {
calculatedRowHeight = rowHeight(adjustedIndex, mockCellProps);
} else {
calculatedRowHeight = DEFAULT_ROW_HEIGHT;
}
const rowBottom = rowTop + calculatedRowHeight;
// Check if row is fully visible within viewport
const isFullyVisible = rowTop >= viewportTop && rowBottom <= viewportBottom;
// Check if row is at the edge (top or bottom of viewport)
const isAtTopEdge = rowTop < viewportTop;
const isAtBottomEdge = rowBottom >= viewportBottom;
// Only scroll if the item is not fully visible or at the edge
if (!isFullyVisible || isAtTopEdge || isAtBottomEdge) {
// Determine alignment based on direction
const align: 'bottom' | 'top' =
e.key === 'ArrowDown' && isAtBottomEdge
? 'bottom'
: e.key === 'ArrowUp' && isAtTopEdge
? 'top'
: isAtBottomEdge
? 'bottom'
: isAtTopEdge
? 'top'
: 'top';
scrollToTableIndex(gridIndex, { align });
}
}
},
[
calculateScrollTopForIndex,
cellPadding,
data,
getItem,
getItemIndex,
DEFAULT_ROW_HEIGHT,
enableHeader,
enableSelection,
extractRowId,
getStateItem,
hasRequiredStateItemProperties,
internalState,
itemCount,
itemType,
parsedColumns,
pinnedRightColumnCount,
pinnedRightColumnRef,
playerContext,
rowHeight,
rowRef,
scrollToTableIndex,
size,
tableId,
],
);
return { handleKeyDown };
};
@@ -1,507 +0,0 @@
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import throttle from 'lodash/throttle';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { useEffect } from 'react';
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
export const useTablePaneSync = ({
enableDrag,
enableHeader,
handleRef,
onScrollEndRef,
pinnedLeftColumnCount,
pinnedLeftColumnRef,
pinnedRightColumnCount,
pinnedRightColumnRef,
pinnedRowRef,
rowRef,
scrollContainerRef,
setShowLeftShadow,
setShowRightShadow,
setShowTopShadow,
}: {
enableDrag: boolean | undefined;
enableHeader: boolean;
handleRef: React.RefObject<null | { internalState: ItemListStateActions }>;
onScrollEndRef: React.RefObject<
((offset: number, internalState: ItemListStateActions) => void) | undefined
>;
pinnedLeftColumnCount: number;
pinnedLeftColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRightColumnCount: number;
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRowRef: React.RefObject<HTMLDivElement | null>;
rowRef: React.RefObject<HTMLDivElement | null>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
setShowLeftShadow: (v: boolean) => void;
setShowRightShadow: (v: boolean) => void;
setShowTopShadow: (v: boolean) => void;
}) => {
// Main grid overlayscrollbars - only handle X-axis if right-pinned columns exist
const [initialize, osInstance] = useOverlayScrollbars({
defer: false,
events: {
initialized(osInstance) {
const { viewport } = osInstance.elements();
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
if (pinnedRightColumnCount > 0) {
viewport.style.overflowY = 'auto';
} else {
viewport.style.overflowY = `var(--os-viewport-overflow-y)`;
}
},
},
options: {
overflow: {
x: 'scroll',
y: pinnedRightColumnCount > 0 ? 'hidden' : 'scroll',
},
paddingAbsolute: true,
scrollbars: {
autoHide: 'leave',
autoHideDelay: 500,
pointers: ['mouse', 'pen', 'touch'],
theme: 'feishin-os-scrollbar',
},
},
});
// Right pinned columns overlayscrollbars - enable Y-axis scroll when right-pinned columns exist
const [initializeRightPinned, osInstanceRightPinned] = useOverlayScrollbars({
defer: false,
events: {
initialized(osInstance) {
const { viewport } = osInstance.elements();
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
viewport.style.overflowY = `var(--os-viewport-overflow-y)`;
},
},
options: {
overflow: { x: 'hidden', y: 'scroll' },
paddingAbsolute: true,
scrollbars: {
autoHide: 'leave',
autoHideDelay: 500,
pointers: ['mouse', 'pen', 'touch'],
theme: 'feishin-os-scrollbar',
},
},
});
useEffect(() => {
const { current: root } = scrollContainerRef;
if (!root || !root.firstElementChild) {
return;
}
const viewport = root.firstElementChild as HTMLElement;
initialize({
elements: { viewport },
target: root,
});
if (enableDrag) {
autoScrollForElements({
canScroll: () => true,
element: viewport,
getAllowedAxis: () => 'vertical',
getConfiguration: () => ({ maxScrollSpeed: 'fast' }),
});
}
return () => {
try {
const instance = osInstance();
const { current: root } = scrollContainerRef;
if (instance && root) {
const viewport = root.firstElementChild as HTMLElement;
const rootInDocument = document.contains(root);
const viewportInDocument = viewport && document.contains(viewport);
if (rootInDocument && viewportInDocument) {
instance.destroy();
}
}
} catch {
// Ignore error
}
};
}, [enableDrag, initialize, osInstance, pinnedRightColumnCount, scrollContainerRef]);
useEffect(() => {
if (pinnedLeftColumnCount === 0) {
return;
}
const { current: root } = pinnedLeftColumnRef;
if (!root || !root.firstElementChild) {
return;
}
const viewport = root.firstElementChild as HTMLElement;
if (enableDrag) {
autoScrollForElements({
canScroll: () => true,
element: viewport,
getAllowedAxis: () => 'vertical',
getConfiguration: () => ({ maxScrollSpeed: 'fast' }),
});
}
}, [enableDrag, pinnedLeftColumnCount, pinnedLeftColumnRef]);
// Initialize overlayscrollbars for right pinned columns
useEffect(() => {
if (pinnedRightColumnCount === 0) {
return;
}
const { current: root } = pinnedRightColumnRef;
if (!root || !root.firstElementChild) {
return;
}
const viewport = root.firstElementChild as HTMLElement;
initializeRightPinned({
elements: { viewport },
target: root,
});
if (enableDrag) {
autoScrollForElements({
canScroll: () => true,
element: viewport,
getAllowedAxis: () => 'vertical',
getConfiguration: () => ({ maxScrollSpeed: 'fast' }),
});
}
return () => {
try {
const instance = osInstanceRightPinned();
const { current: root } = pinnedRightColumnRef;
if (instance && root) {
const viewport = root.firstElementChild as HTMLElement;
const rootInDocument = document.contains(root);
const viewportInDocument = viewport && document.contains(viewport);
if (rootInDocument && viewportInDocument) {
instance.destroy();
}
}
} catch {
// Ignore error
}
};
}, [
enableDrag,
initializeRightPinned,
osInstanceRightPinned,
pinnedRightColumnCount,
pinnedRightColumnRef,
]);
useEffect(() => {
const header = pinnedRowRef.current?.childNodes[0] as HTMLDivElement;
const row = rowRef.current?.childNodes[0] as HTMLDivElement;
const pinnedLeft = pinnedLeftColumnRef.current?.childNodes[0] as HTMLDivElement;
const pinnedRight = pinnedRightColumnRef.current?.childNodes[0] as HTMLDivElement;
if (!row) return;
// Ensure all containers have the same height
const syncHeights = () => {
const rowHeight = row.scrollHeight;
let targetHeight = rowHeight;
if (pinnedLeft) {
const pinnedLeftHeight = pinnedLeft.scrollHeight;
targetHeight = Math.max(targetHeight, pinnedLeftHeight);
}
if (pinnedRight) {
const pinnedRightHeight = pinnedRight.scrollHeight;
targetHeight = Math.max(targetHeight, pinnedRightHeight);
}
if (pinnedLeft && pinnedLeft.style.height !== `${targetHeight}px`) {
pinnedLeft.style.height = `${targetHeight}px`;
}
if (pinnedRight && pinnedRight.style.height !== `${targetHeight}px`) {
pinnedRight.style.height = `${targetHeight}px`;
}
if (row.style.height !== `${targetHeight}px`) {
row.style.height = `${targetHeight}px`;
}
};
const timeoutId = setTimeout(syncHeights, 0);
const activeElement = { element: null } as { element: HTMLDivElement | null };
const scrollingElements = new Set<HTMLDivElement>();
const scrollTimeouts = new Map<HTMLDivElement, NodeJS.Timeout>();
const setActiveElement = (e: HTMLElementEventMap['pointermove']) => {
activeElement.element = e.currentTarget as HTMLDivElement;
};
const setActiveElementFromWheel = (e: HTMLElementEventMap['wheel']) => {
activeElement.element = e.currentTarget as HTMLDivElement;
};
const markElementAsScrolling = (element: HTMLDivElement) => {
scrollingElements.add(element);
const existingTimeout = scrollTimeouts.get(element);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
const timeout = setTimeout(() => {
scrollingElements.delete(element);
const hasRightPinnedColumns = pinnedRightColumnCount > 0;
const scrollElement = hasRightPinnedColumns && pinnedRight ? pinnedRight : row;
if (scrollElement && onScrollEndRef.current) {
onScrollEndRef.current(
scrollElement.scrollTop,
(handleRef.current?.internalState ??
(undefined as any)) as ItemListStateActions,
);
}
scrollTimeouts.delete(element);
}, 150);
scrollTimeouts.set(element, timeout);
};
const syncScroll = (e: HTMLElementEventMap['scroll']) => {
const currentElement = e.currentTarget as HTMLDivElement;
markElementAsScrolling(currentElement);
const shouldSync =
currentElement === activeElement.element || scrollingElements.has(currentElement);
if (!shouldSync) return;
const scrollTop = (e.currentTarget as HTMLDivElement).scrollTop;
const scrollLeft = (e.currentTarget as HTMLDivElement).scrollLeft;
const isScrolling = {
header: false,
pinnedLeft: false,
pinnedRight: false,
row: false,
};
const hasRightPinnedColumns = pinnedRightColumnCount > 0;
if (header && e.currentTarget === header && !isScrolling.row) {
isScrolling.row = true;
row.scrollTo({ behavior: 'instant', left: scrollLeft });
isScrolling.row = false;
}
if (
e.currentTarget === row &&
!isScrolling.header &&
!isScrolling.pinnedLeft &&
!isScrolling.pinnedRight
) {
if (header) {
isScrolling.header = true;
header.scrollTo({ behavior: 'instant', left: scrollLeft });
}
if (hasRightPinnedColumns && pinnedRight) {
isScrolling.pinnedRight = true;
pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop });
isScrolling.pinnedRight = false;
} else {
if (pinnedLeft) {
isScrolling.pinnedLeft = true;
pinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop });
}
if (pinnedRight) {
isScrolling.pinnedRight = true;
pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop });
}
}
isScrolling.header = false;
isScrolling.pinnedLeft = false;
}
if (pinnedLeft && e.currentTarget === pinnedLeft && !isScrolling.row) {
if (hasRightPinnedColumns && pinnedRight) {
isScrolling.pinnedRight = true;
pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop });
isScrolling.pinnedRight = false;
} else {
isScrolling.row = true;
row.scrollTo({ behavior: 'instant', top: scrollTop });
isScrolling.row = false;
}
}
if (pinnedRight && e.currentTarget === pinnedRight && !isScrolling.row) {
isScrolling.row = true;
row.scrollTo({ behavior: 'instant', top: scrollTop });
isScrolling.row = false;
if (pinnedLeft) {
isScrolling.pinnedLeft = true;
pinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop });
isScrolling.pinnedLeft = false;
}
}
};
if (header) {
header.addEventListener('pointermove', setActiveElement);
header.addEventListener('wheel', setActiveElementFromWheel);
header.addEventListener('scroll', syncScroll);
}
row.addEventListener('pointermove', setActiveElement);
row.addEventListener('wheel', setActiveElementFromWheel);
row.addEventListener('scroll', syncScroll);
if (pinnedLeft) {
pinnedLeft.addEventListener('pointermove', setActiveElement);
pinnedLeft.addEventListener('wheel', setActiveElementFromWheel);
pinnedLeft.addEventListener('scroll', syncScroll);
}
if (pinnedRight) {
pinnedRight.addEventListener('pointermove', setActiveElement);
pinnedRight.addEventListener('wheel', setActiveElementFromWheel);
pinnedRight.addEventListener('scroll', syncScroll);
}
let heightSyncDebounceTimeout: NodeJS.Timeout | null = null;
const resizeObserver = new ResizeObserver(() => {
if (heightSyncDebounceTimeout) {
clearTimeout(heightSyncDebounceTimeout);
}
heightSyncDebounceTimeout = setTimeout(() => {
syncHeights();
}, 100);
});
resizeObserver.observe(row);
if (pinnedLeft) resizeObserver.observe(pinnedLeft);
if (pinnedRight) resizeObserver.observe(pinnedRight);
return () => {
clearTimeout(timeoutId);
scrollTimeouts.forEach((timeout) => clearTimeout(timeout));
scrollTimeouts.clear();
scrollingElements.clear();
if (header) {
header.removeEventListener('pointermove', setActiveElement);
header.removeEventListener('wheel', setActiveElementFromWheel);
header.removeEventListener('scroll', syncScroll);
}
row.removeEventListener('pointermove', setActiveElement);
row.removeEventListener('wheel', setActiveElementFromWheel);
row.removeEventListener('scroll', syncScroll);
if (pinnedLeft) {
pinnedLeft.removeEventListener('pointermove', setActiveElement);
pinnedLeft.removeEventListener('wheel', setActiveElementFromWheel);
pinnedLeft.removeEventListener('scroll', syncScroll);
}
if (pinnedRight) {
pinnedRight.removeEventListener('pointermove', setActiveElement);
pinnedRight.removeEventListener('wheel', setActiveElementFromWheel);
pinnedRight.removeEventListener('scroll', syncScroll);
}
if (heightSyncDebounceTimeout) {
clearTimeout(heightSyncDebounceTimeout);
}
resizeObserver.disconnect();
};
}, [
handleRef,
onScrollEndRef,
pinnedLeftColumnCount,
pinnedLeftColumnRef,
pinnedRightColumnCount,
pinnedRightColumnRef,
pinnedRowRef,
rowRef,
]);
// Handle left and right shadow visibility based on horizontal scroll
useEffect(() => {
const row = rowRef.current?.childNodes[0] as HTMLDivElement;
if (!row) {
const timeout = setTimeout(() => {
setShowLeftShadow(false);
setShowRightShadow(false);
}, 0);
return () => clearTimeout(timeout);
}
const checkScrollPosition = throttle(() => {
const scrollLeft = row.scrollLeft;
const maxScrollLeft = row.scrollWidth - row.clientWidth;
setShowLeftShadow(pinnedLeftColumnCount > 0 && scrollLeft > 0);
setShowRightShadow(pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft);
}, 50);
checkScrollPosition();
row.addEventListener('scroll', checkScrollPosition, { passive: true });
return () => {
checkScrollPosition.cancel();
row.removeEventListener('scroll', checkScrollPosition);
};
}, [
pinnedLeftColumnCount,
pinnedRightColumnCount,
rowRef,
setShowLeftShadow,
setShowRightShadow,
]);
// Handle top shadow visibility based on vertical scroll
useEffect(() => {
const row = rowRef.current?.childNodes[0] as HTMLDivElement;
const pinnedRight = pinnedRightColumnRef.current?.childNodes[0] as HTMLDivElement;
if (!row || !enableHeader) {
const timeout = setTimeout(() => {
setShowTopShadow(false);
}, 0);
return () => clearTimeout(timeout);
}
const scrollElement = pinnedRightColumnCount > 0 && pinnedRight ? pinnedRight : row;
const checkScrollPosition = throttle(() => {
const currentScrollTop = scrollElement.scrollTop;
setShowTopShadow(currentScrollTop > 0);
}, 50);
checkScrollPosition();
scrollElement.addEventListener('scroll', checkScrollPosition, { passive: true });
return () => {
checkScrollPosition.cancel();
scrollElement.removeEventListener('scroll', checkScrollPosition);
};
}, [enableHeader, pinnedRightColumnCount, pinnedRightColumnRef, rowRef, setShowTopShadow]);
};
@@ -1,55 +0,0 @@
import { useMemo } from 'react';
import { TableGroupHeader } from '/@/renderer/components/item-list/item-table-list/item-table-list';
export const useTableRowModel = ({
data,
enableHeader,
groups,
}: {
data: unknown[];
enableHeader: boolean;
groups?: TableGroupHeader[];
}) => {
const dataWithGroups = useMemo(() => {
const result: (null | unknown)[] = enableHeader ? [null] : [];
if (!groups || groups.length === 0) {
result.push(...data);
return result;
}
// Build the expanded row model: [header?] + (groupHeader + groupItems)* + any remaining items.
let dataIndex = 0;
for (const group of groups) {
// Group header row
result.push(null);
// Group items
const end = Math.min(data.length, dataIndex + group.itemCount);
for (; dataIndex < end; dataIndex++) {
result.push(data[dataIndex]);
}
}
// If groups don't account for all items, append the remainder.
for (; dataIndex < data.length; dataIndex++) {
result.push(data[dataIndex]);
}
return result;
}, [data, enableHeader, groups]);
const groupHeaderRowCount = useMemo(() => {
if (!groups || groups.length === 0) return 0;
return groups.length;
}, [groups]);
return useMemo(
() => ({
dataWithGroups,
groupHeaderRowCount,
}),
[dataWithGroups, groupHeaderRowCount],
);
};
@@ -1,181 +0,0 @@
import { useCallback, useMemo } from 'react';
import { TableItemProps } from '../item-table-list';
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { PlayerContext } from '/@/renderer/features/player/context/player-context';
import { LibraryItem } from '/@/shared/types/domain-types';
export const useTableScrollToIndex = ({
cellPadding,
columns,
data,
enableAlternateRowColors,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
itemType,
pinnedLeftColumnRef,
pinnedRightColumnRef,
playerContext,
rowHeight,
rowRef,
size,
tableId,
}: {
cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
columns: TableItemProps['columns'];
data: unknown[];
enableAlternateRowColors: boolean;
enableExpansion: boolean;
enableHeader: boolean;
enableHorizontalBorders: boolean;
enableRowHoverHighlight: boolean;
enableSelection: boolean;
enableVerticalBorders: boolean;
itemType: LibraryItem;
pinnedLeftColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
playerContext: PlayerContext;
rowHeight: ((index: number, cellProps: TableItemProps) => number) | number | undefined;
rowRef: React.RefObject<HTMLDivElement | null>;
size: 'compact' | 'default' | 'large';
tableId: string;
}) => {
const DEFAULT_ROW_HEIGHT = useMemo(() => {
return size === 'compact' ? 40 : size === 'large' ? 88 : 64;
}, [size]);
const mockCellPropsBase = useMemo<TableItemProps>(
() => ({
cellPadding,
columns,
controls: {} as ItemControls,
data: enableHeader ? [null, ...data] : data,
enableAlternateRowColors,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
getRowHeight: () => DEFAULT_ROW_HEIGHT,
internalState: {} as ItemListStateActions,
itemType,
playerContext,
size,
tableId,
}),
[
DEFAULT_ROW_HEIGHT,
cellPadding,
columns,
data,
enableAlternateRowColors,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
itemType,
playerContext,
size,
tableId,
],
);
const getRowHeightAtIndex = useCallback(
(index: number) => {
if (typeof rowHeight === 'number') return rowHeight;
if (typeof rowHeight === 'function') return rowHeight(index, mockCellPropsBase);
return DEFAULT_ROW_HEIGHT;
},
[DEFAULT_ROW_HEIGHT, mockCellPropsBase, rowHeight],
);
const scrollToTableOffset = useCallback(
(offset: number) => {
const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement | undefined;
const pinnedLeftContainer = pinnedLeftColumnRef.current?.childNodes[0] as
| HTMLDivElement
| undefined;
const pinnedRightContainer = pinnedRightColumnRef.current?.childNodes[0] as
| HTMLDivElement
| undefined;
const behavior = 'instant';
if (mainContainer) {
mainContainer.scrollTo({ behavior, top: offset });
}
if (pinnedLeftContainer) {
pinnedLeftContainer.scrollTo({ behavior, top: offset });
}
if (pinnedRightContainer) {
pinnedRightContainer.scrollTo({ behavior, top: offset });
}
},
[pinnedLeftColumnRef, pinnedRightColumnRef, rowRef],
);
const calculateScrollTopForIndex = useCallback(
(index: number) => {
const adjustedIndex = enableHeader ? Math.max(0, index - 1) : index;
let scrollTop = 0;
for (let i = 0; i < adjustedIndex; i++) {
scrollTop += getRowHeightAtIndex(i);
}
return scrollTop;
},
[enableHeader, getRowHeightAtIndex],
);
const scrollToTableIndex = useCallback(
(index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => {
const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement | undefined;
if (!mainContainer) return;
const viewportHeight = mainContainer.clientHeight;
const align = options?.align || 'top';
// Calculate the base scroll offset (top of the row)
let offset = calculateScrollTopForIndex(index);
// Calculate row height for the target index
const adjustedIndex = enableHeader ? Math.max(0, index - 1) : index;
const targetRowHeight = getRowHeightAtIndex(adjustedIndex);
if (align === 'center') {
offset = offset - viewportHeight / 2 + targetRowHeight / 2;
} else if (align === 'bottom') {
offset = offset - viewportHeight + targetRowHeight;
}
offset = Math.max(0, offset);
scrollToTableOffset(offset);
},
[
calculateScrollTopForIndex,
enableHeader,
getRowHeightAtIndex,
rowRef,
scrollToTableOffset,
],
);
return useMemo(
() => ({
calculateScrollTopForIndex,
DEFAULT_ROW_HEIGHT,
scrollToTableIndex,
scrollToTableOffset,
}),
[calculateScrollTopForIndex, DEFAULT_ROW_HEIGHT, scrollToTableIndex, scrollToTableOffset],
);
};
@@ -100,7 +100,7 @@
}
}
.container.data-row.row-hover-highlight-enabled[data-row-hovered='true']::before {
.container.data-row.row-hover-highlight-enabled.row-hovered::before {
position: absolute;
top: 0;
right: 0;
@@ -137,7 +137,7 @@
opacity: 0.5;
}
.container.data-row[data-row-dragged-over='top']::after {
.container.data-row.dragged-over-top::after {
position: absolute;
top: -1px;
right: 0;
@@ -149,14 +149,14 @@
background-color: var(--theme-colors-primary);
}
.container.data-row[data-row-dragged-over='top'][data-row-dragged-over-first='true']::after {
.container.data-row.dragged-over-top.dragged-over-first-cell::after {
right: -9999px;
left: -9999px;
margin-right: 9999px;
margin-left: 9999px;
}
.container.data-row[data-row-dragged-over='bottom']::after {
.container.data-row.dragged-over-bottom::after {
position: absolute;
right: 0;
bottom: -1px;
@@ -168,7 +168,7 @@
background-color: var(--theme-colors-primary);
}
.container.data-row[data-row-dragged-over='bottom'][data-row-dragged-over-first='true']::after {
.container.data-row.dragged-over-bottom.dragged-over-first-cell::after {
right: -9999px;
left: -9999px;
margin-right: 9999px;
@@ -287,12 +287,12 @@
}
.container.data-row:hover :global(.hover-only),
.container.data-row[data-row-hovered='true'] :global(.hover-only) {
.container.data-row.row-hovered :global(.hover-only) {
display: block;
}
.container.data-row:hover :global(.hover-only-flex),
.container.data-row[data-row-hovered='true'] :global(.hover-only-flex) {
.container.data-row.row-hovered :global(.hover-only-flex) {
display: flex;
}
@@ -301,7 +301,7 @@
}
.container.data-row:hover :global(.hide-on-hover),
.container.data-row[data-row-hovered='true'] :global(.hide-on-hover) {
.container.data-row.row-hovered :global(.hide-on-hover) {
display: none;
}

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