Merge branch 'development' into react-image-lazy-loaded

This commit is contained in:
Kendall Garner 2025-09-03 19:47:53 -07:00
commit 1aac1a6361
No known key found for this signature in database
GPG key ID: 9355F387FE765C94
193 changed files with 2003 additions and 2154 deletions

View file

@ -19,6 +19,7 @@ import nl from './locales/nl.json';
import pl from './locales/pl.json';
import ptBr from './locales/pt-BR.json';
import ru from './locales/ru.json';
import sl from './locales/sl.json';
import sr from './locales/sr.json';
import sv from './locales/sv.json';
import ta from './locales/ta.json';
@ -43,6 +44,7 @@ const resources = {
pl: { translation: pl },
'pt-BR': { translation: ptBr },
ru: { translation: ru },
sl: { translation: sl },
sr: { translation: sr },
sv: { translation: sv },
ta: { translation: ta },
@ -119,6 +121,10 @@ export const languages = [
label: 'Русский',
value: 'ru',
},
{
label: 'Slovenščina',
value: 'sl',
},
{
label: 'Srpski',
value: 'sr',

View file

@ -271,7 +271,9 @@
"discordPausedStatus": "zobrazit rich presence při pozastavení",
"discordPausedStatus_description": "pokud je povoleno, bude při pozastavení přehrávače zobrazen stav",
"preservePitch": "zachovat výšku",
"preservePitch_description": "zachová výšku při úpravě rychlosti přehrávání"
"preservePitch_description": "zachová výšku při úpravě rychlosti přehrávání",
"notify": "povolit oznámení o skladbách",
"notify_description": "zobrazit oznámení při změně aktuální skladby"
},
"action": {
"editPlaylist": "upravit $t(entity.playlist_one)",
@ -393,7 +395,9 @@
"additionalParticipants": "další přispívající",
"tags": "štítky",
"viewReleaseNotes": "zobrazit seznam změn",
"newVersion": "byla nainstalována nová verze ({{version}})"
"newVersion": "byla nainstalována nová verze ({{version}})",
"bitDepth": "bitová hloubka",
"sampleRate": "vzorkovací frekvence"
},
"table": {
"config": {
@ -495,7 +499,8 @@
"badAlbum": "tuto stránku vidíte, protože tato skladba není součástí alba. tento problém může nastat, pokud máte skladbu na nejvyšší úrovni vaší složky s hudbou. jellyfin seskupuje skladby pouze, pokud se nacházejí ve složce.",
"networkError": "vyskytla se chyba sítě",
"openError": "nepodařilo se otevřít soubor",
"badValue": "neplatná možnost „{{value}}“. tato možnost již neexistuje"
"badValue": "neplatná možnost „{{value}}“. tato možnost již neexistuje",
"notificationDenied": "oprávnění k posílání oznámení byla zamítnuta. toto nastavení nemá žádný vliv"
},
"filter": {
"mostPlayed": "nejvíce přehráváno",

View file

@ -36,6 +36,7 @@
"ascending": "ascending",
"backward": "backward",
"biography": "biography",
"bitDepth": "bit depth",
"bitrate": "bitrate",
"bpm": "bpm",
"cancel": "cancel",
@ -99,6 +100,7 @@
"resetToDefault": "reset to default",
"restartRequired": "restart required",
"right": "right",
"sampleRate": "sample rate",
"save": "save",
"saveAndReplace": "save and replace",
"saveAs": "save as",
@ -286,6 +288,11 @@
"updateServer": {
"success": "server updated successfully",
"title": "update server"
},
"privateMode": {
"enabled": "private mode enabled, playback status is now hidden from external integrations",
"disabled": "private mode disabled, playback status is now visible to enabled external integrations",
"title": "private mode"
}
},
"page": {
@ -319,6 +326,8 @@
"goBack": "go back",
"goForward": "go forward",
"manageServers": "manage servers",
"privateModeOff": "turn off private mode",
"privateModeOn": "turn on private mode",
"openBrowserDevtools": "open browser devtools",
"quit": "$t(common.quit)",
"selectServer": "select server",
@ -527,6 +536,10 @@
"discordServeImage_description": "share cover art for {{discord}} rich presence from server itself, only available for jellyfin and navidrome",
"discordUpdateInterval": "{{discord}} rich presence update interval",
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
"discordDisplayType": "{{discord}} presence display type",
"discordDisplayType_description": "changes what you are listening to in your status",
"discordDisplayType_songname": "song name",
"discordDisplayType_artistname": "artist name(s)",
"doubleClickBehavior": "queue all searched tracks when double clicking",
"doubleClickBehavior_description": "if true, all matching tracks in a track search will be queued. otherwise, only the clicked one will be queued",
"enableRemote": "enable remote control server",

View file

@ -271,7 +271,9 @@
"discordPausedStatus": "Mostrar estado de actividad cuando esté en pausa",
"discordPausedStatus_description": "Cuando está activado, el estado mostrará cuando el reproductor esté en pausa",
"preservePitch": "Mantener el tono",
"preservePitch_description": "Mantiene el tono cuando se modifica la velocidad de reproducción"
"preservePitch_description": "Mantiene el tono cuando se modifica la velocidad de reproducción",
"notify": "Activar notificaciones de canciones",
"notify_description": "Muestra notificaciones cuando se cambia la canción actual"
},
"action": {
"editPlaylist": "editar $t(entity.playlist_one)",
@ -393,7 +395,9 @@
"additionalParticipants": "Participantes adicionales",
"tags": "Etiquetas",
"newVersion": "Una nueva versión ha sido instalada ({{version}})",
"viewReleaseNotes": "Ver notas de lanzamiento"
"viewReleaseNotes": "Ver notas de lanzamiento",
"bitDepth": "Profundidad de bit",
"sampleRate": "Frecuencia de muestreo"
},
"error": {
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
@ -418,7 +422,8 @@
"badAlbum": "Estás viendo esta página porque esta canción no forma parte de un álbum. Este problema puede ocurrir si tienes una canción en el nivel superior de tu carpeta de música. Jellyfin solo agrupa pistas si están en una carpeta.",
"networkError": "Ocurrió un error de red",
"openError": "No se pudo abrir el archivo",
"badValue": "Opción inválida \"{{value}}\". Este valor ya no existe"
"badValue": "Opción inválida \"{{value}}\". Este valor ya no existe",
"notificationDenied": "Se denegaron los permisos para notificaciones. Esta configuración no tiene efecto"
},
"filter": {
"mostPlayed": "más reproducido",

View file

@ -101,6 +101,7 @@
"forceRestartRequired": "redémarrer pour appliquer les changements… fermer la notification pour redémarrer",
"setting": "paramètre",
"setting_one": "paramètre",
"setting_many": "",
"setting_other": "paramètres",
"version": "version",
"title": "titre",
@ -154,7 +155,9 @@
"additionalParticipants": "participants additionnels",
"tags": "tags",
"newVersion": "une nouvelle version vient d'être installé ({{version}})",
"viewReleaseNotes": "voir la note de version"
"viewReleaseNotes": "voir la note de version",
"sampleRate": "taux d'échantillonnage",
"bitDepth": "bit par échantillon"
},
"error": {
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
@ -179,7 +182,8 @@
"openError": "impossible d'ouvrir le fichier",
"networkError": "une erreur de réseau est survenue",
"badAlbum": "vous voyez cette page parce que cette chanson ne fait pas parti d'un album. vous rencontrez probablement cette erreur si vous avez une chanson qui n'est pas dans votre répertoire de musique. jellyfin gère les chansons uniquement si elles sont dans un sous-dossier, qui est lui-même dans un dossier \"Musique(s)\".",
"badValue": "option {{value}} invalide. Cette valeur n'existe plus"
"badValue": "option {{value}} invalide. Cette valeur n'existe plus",
"notificationDenied": "les autorisations pour les notifications ont été refusées. ce paramètre n'a aucun effet"
},
"filter": {
"mostPlayed": "plus joués",
@ -400,7 +404,7 @@
"discordIdleStatus_description": "quand activé, mettre à jour le status pendant que le lecteur est inactif",
"showSkipButtons": "affiche les boutons suivants et précédents",
"minimumScrobblePercentage": "durée minimal du scobble (pourcentage)",
"lyricFetch": "récupère les paroles depuis internet",
"lyricFetch": "récupérer les paroles depuis internet",
"scrobble": "scrobble",
"enableRemote_description": "activer le serveur de contrôle à distance, qui permet à d'autres appareils de contrôler l'application",
"fontType_optionSystem": "police système",
@ -578,7 +582,7 @@
"artistConfiguration": "page de configuration de l'artiste de l'album",
"artistConfiguration_description": "configurer les éléments et l'ordre à afficher, sur la page de l'artiste de l'album",
"doubleClickBehavior": "mettre en file d'attente toutes les pistes recherchées lors d'un double clic",
"contextMenu": "configuration du menu contexte (clic droit)",
"contextMenu": "configuration du menu contextuel (clic droit)",
"contextMenu_description": "permet de masquer les éléments qui s'affichent dans le menu lorsque vous cliquez avec le bouton droit de la souris sur un élément. les éléments qui ne sont pas cochés seront masqués",
"albumBackground": "image d'arrière-plan de l'album",
"albumBackground_description": "ajoute une image d'arrière-plan pour les pages de l'album contenant les illustrations de l'album",
@ -615,7 +619,9 @@
"discordPausedStatus_description": "quand activé, le status s'affichera lorsque le lecteur est en pause",
"discordPausedStatus": "afficher le status d'activité en pause",
"preservePitch": "préserver la hauteur",
"preservePitch_description": "préserver la hauteur lors du changement de la vitesse de lecture"
"preservePitch_description": "préserver la hauteur lors du changement de la vitesse de lecture",
"notify": "activer les notifications des chansons",
"notify_description": "affiche une notification lors du changement de chanson"
},
"form": {
"deletePlaylist": {

View file

@ -16,7 +16,12 @@
"toggleSmartPlaylistEditor": "attiva/disattiva editor $t(entity.smartPlaylist)",
"removeFromFavorites": "rimuovi da $t(entity.favorite_other)",
"moveToTop": "sposta in cima",
"moveToBottom": "sposta in fondo"
"moveToBottom": "sposta in fondo",
"moveToNext": "passa al successivo",
"openIn": {
"lastfm": "Apri in Last.fm",
"musicbrainz": "Apri in MusicBrainz"
}
},
"common": {
"backward": "indietro",
@ -99,7 +104,22 @@
"yes": "si",
"random": "casuale",
"size": "dimensione",
"note": "nota"
"note": "nota",
"additionalParticipants": "partecipanti aggiuntivi",
"newVersion": "è stata installata una nuova versione ({{version}})",
"viewReleaseNotes": "mostra le note di rilascio",
"albumGain": "guadagno (gain) dell'album",
"albumPeak": "picco di volume dell'album",
"close": "chiudi",
"codec": "codec",
"mbid": "MusicBrainz ID",
"preview": "anteprima",
"reload": "ricarica",
"share": "condividi",
"tags": "tags",
"trackGain": "normalizzazione (gain) del brano",
"trackPeak": "picco di volume del brano",
"translation": "traduzione"
},
"player": {
"repeat_all": "ripeti coda",
@ -113,7 +133,7 @@
"skip_back": "salta indietro",
"favorite": "preferito",
"next": "successivo",
"shuffle": "mescola",
"shuffle": "riproduzione casuale",
"playbackFetchNoResults": "nessuna canzone trovata",
"playbackFetchInProgress": "caricamento canzoni…",
"addNext": "aggiungi successivo",
@ -130,7 +150,9 @@
"shuffle_off": "non mescolare",
"addLast": "aggiungi in coda",
"mute": "silenzia",
"skip_forward": "salta avanti"
"skip_forward": "salta avanti",
"playSimilarSongs": "riproduci brani simili",
"viewQueue": "visualizza coda"
},
"setting": {
"crossfadeStyle_description": "seleziona lo stile dissolvenza da usare per il player audio",
@ -150,7 +172,7 @@
"skipDuration_description": "imposta la durata da saltare quando vengono usati i pulsanti di salto nella barra del player",
"enableRemote_description": "abilita il controllo remoto del server per permettere ad altri dispositivi di controllare l'applicazione",
"fontType_optionSystem": "font di sistema",
"mpvExecutablePath_description": "imposta il percorso dell'eseguibile di mpv",
"mpvExecutablePath_description": "imposta il percorso dell'eseguibile mpv. se lasciato vuoto, verrà utilizzato il percorso predefinito",
"hotkey_favoriteCurrentSong": "$t(common.currentSong) preferita",
"crossfadeStyle": "stile dissolvenza",
"sidebarConfiguration": "configurazione barra laterale",
@ -268,7 +290,7 @@
"replayGainMode_description": "aggiusta il volume secondo i valori {{ReplayGain}} salvati nei metadati del file",
"showSkipButtons": "mostra pulsanti per saltare",
"sampleRate": "frequenza di campionamento",
"sampleRate_description": "seleziona la frequenza di campionamento di output da usare se la frequenza di campionamento selezionata è diversa da quella della del media attuale",
"sampleRate_description": "seleziona la frequenza di campionamento di output da utilizzare se quella selezionata è diversa da quella del file sorgente in riproduzione. Un valore inferiore a 8000 utilizzerà la frequenza predefinita",
"hotkey_togglePreviousSongFavorite": "imposta/rimuovi $t(common.previousSong) favorito",
"hotkey_unfavoritePreviousSong": "rimuovi $t(common.previousSong) dai preferiti",
"showSkipButton_description": "mostra o nascondi i pulsanti per saltare nella barra del player",
@ -293,7 +315,85 @@
"clearQueryCache": "pulisci cache di feishin",
"buttonSize_description": "Dimensione bottoni nella barra di riproduzione",
"clearCache": "pulisci la cache del browser",
"clearQueryCache_description": "\"leggera\" pulizia di feishin. verranno aggiornate le playlist, metadata delle tracce e i testi salvati. impostazioni, credenziali del server e le immagini salvate saranno mantenute"
"clearQueryCache_description": "\"leggera\" pulizia di feishin. verranno aggiornate le playlist, metadata delle tracce e i testi salvati. impostazioni, credenziali del server e le immagini salvate saranno mantenute",
"albumBackground": "immagine di sfondo dell'album",
"albumBackground_description": "aggiunge un'immagine di sfondo per le pagine degli album contenenti l'album art",
"albumBackgroundBlur": "intensità sfocatura immagine di sfondo dell'album",
"albumBackgroundBlur_description": "regola la quantità di sfocatura applicata all'immagine di sfondo dell'album",
"artistConfiguration": "configurazione della pagina artista dellalbum",
"artistConfiguration_description": "configurare quali elementi vengono visualizzati, e in quale ordine, nella pagina dell'artista dell'album",
"buttonSize": "dimensione del bottone nella barra di riproduzione",
"clearCacheSuccess": "cache pulita correttamente",
"contextMenu": "configurazione menu contestuale (clic destro)",
"contextMenu_description": "consente di nascondere gli elementi che vengono visualizzati nel menu quando si fa clic destro su un elemento. gli oggetti non selezionati saranno nascosti",
"customCssEnable": "abilita css personalizzato",
"customCssEnable_description": "consente di scrivere css personalizzati.",
"customCssNotice": "Attenzione: sebbene ci sia una certa sanitizzazione (vengono bloccati url() e content:), luso di CSS personalizzati può comunque comportare dei rischi modificando linterfaccia.",
"customCss": "css personalizzato",
"customCss_description": "contenuto CSS personalizzato. Nota: le proprietà content e gli URL remoti non sono consentiti. Di seguito è mostrata unanteprima del tuo contenuto. Sono presenti anche altri campi non impostati da te a causa della sanitizzazione.",
"discordPausedStatus": "mostra rich presence di Discord quando la riproduzione è in pausa",
"discordPausedStatus_description": "quando abilitato, verrà mostrato lo stato del lettore in standby/pausa (nessun brano in riproduzione)",
"discordListening": "mostra stato come in ascolto",
"discordListening_description": "mostra lo stato come in ascolto invece che in riproduzione",
"discordServeImage": "recupera le immagini di {{discord}} dal server",
"discordServeImage_description": "condividi la copertina per la rich presence di {{discord}} direttamente dal server, disponibile solo per Jellyfin e Navidrome",
"doubleClickBehavior": "aggiungi alla coda tutte le tracce cercate, con un doppio clic",
"doubleClickBehavior_description": "se attivato, tutte le tracce corrispondenti alla ricerca verranno aggiunte alla coda. altrimenti, verrà aggiunta alla coda solo la traccia selezionata",
"externalLinks": "mostra link esterni",
"externalLinks_description": "consente di visualizzare link esterni (Last.fm, MusicBrainz) sulle pagine di artista/album",
"preferLocalLyrics": "utilizza i testi locali",
"preferLocalLyrics_description": "usa i testi locali anziché quelli online, quando disponibili",
"genreBehavior": "comportamento predefinito della pagina genere",
"genreBehavior_description": "determina se cliccando su un genere si apre di default la lista dei brani o degli album",
"homeConfiguration": "configurazione della home page",
"homeConfiguration_description": "configura quali elementi vengono mostrati e in quale ordine nella home page",
"homeFeature": "carosello in evidenza nella home page",
"homeFeature_description": "controlla se mostrare il grande carosello in evidenza nella pagina principale",
"imageAspectRatio": "usa dimensioni originali(aspect ratio) della copertina",
"imageAspectRatio_description": "se abilitato, la copertina verrà mostrata utilizzando le dimesioni originali. per le immagini con rapporto diverso da 1:1, lo spazio residuo resterà vuoto",
"lastfm": "mostra links last.fm",
"lastfm_description": "mostra i link per last.fm sulle pagine di artista/album",
"lastfmApiKey": "{{lastfm}} chiave API",
"lastfmApiKey_description": "chiave API per {{lastfm}}. necessaria per visualizzare le copertine",
"mpvExtraParameters_help": "uno per linea",
"musicbrainz": "mostra links musicbrainz",
"musicbrainz_description": "mostra link a musicbrainz sulle pagine degli artisti/album, se è disponibile un mbid",
"neteaseTranslation": "Abilita traduzioni di NetEase",
"neteaseTranslation_description": "Se abilitato, recupera e mostra i testi tradotti da NetEase, se disponibili.",
"passwordStore": "Archivio di password/segreti",
"passwordStore_description": "specifica quale archivio di password e segreti utilizzare. modificalo in caso di problemi nel salvataggio delle credenziali.",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playerAlbumArtResolution": "risoluzione della copertina nel lettore",
"playerAlbumArtResolution_description": "la risoluzione dellanteprima della copertina nel lettore in formato grande. valori più alti la rendono più nitida, ma possono rallentare il caricamento. Il valore predefinito è 0, che indica la modalità automatica",
"sidePlayQueueStyle_optionAttached": "fissata",
"sidePlayQueueStyle_optionDetached": "sganciata",
"startMinimized": "avvia minimizzato",
"startMinimized_description": "avvia l'app nella barra di sistema",
"transcodeNote": "ha effetto dopo 1 brano (web) - 2 brani (mpv)",
"transcode": "abilita la transcodifica",
"transcode_description": "abilita la transcodifica in formati diversi",
"playerbarOpenDrawer": "attiva/disattiva schermo intero",
"playerbarOpenDrawer_description": "consente di cliccare sulla barra del lettore per aprire il lettore a schermo intero",
"replayGainClipping": "clipping di {{ReplayGain}}",
"replayGainFallback": "metodo alternativo di {{ReplayGain}}",
"transcodeBitrate": "bitrate per la transcodifica",
"transcodeBitrate_description": "seleziona il bitrate per la transcodifica. 0 significa lasciare che sia il server a scegliere",
"transcodeFormat": "formato per la transcodifica",
"transcodeFormat_description": "seleziona il formato per la transcodifica. se vuoto viene decisco dal server",
"translationApiProvider": "translation api provider",
"translationApiProvider_description": "api provider for translation",
"translationApiKey": "chiave api translation",
"translationApiKey_description": "chiave api per la traduzione (supporta solo endpoint di servizio globali)",
"translationTargetLanguage": "lingua di destinazione della traduzione",
"translationTargetLanguage_description": "lingua di destinazione per la traduzione",
"trayEnabled": "Mostra icona app nella barra di sistema",
"trayEnabled_description": "mostra/nascondi icona app nella barra si sistema. se disabilitato, disattiva anche minimizza/chiudi nella barra di sistema",
"volumeWidth": "larghezza della barra del volume",
"webAudio": "use audio web",
"webAudio_description": "usa audio web. abilita funzionalità avanzate come ReplayGain. disabilita se riscontri problemi",
"preservePitch": "mantieni tono (pitch)",
"preservePitch_description": "mantiene il tono (pitch) durante la modifica della velocità di riproduzione",
"volumeWidth_description": "larghezza del cursore del volume"
},
"error": {
"remotePortWarning": "riavvia il server per applicare la nuova porta",
@ -314,7 +414,11 @@
"mpvRequired": "MPV richiesto",
"audioDeviceFetchError": "si è verificato un errore nel provare ad ottenre i device audio",
"invalidServer": "server non valido",
"loginRateError": "troppi tentativi di accesso, per favore riprova tra qualche secondo"
"loginRateError": "troppi tentativi di accesso, per favore riprova tra qualche secondo",
"badAlbum": "stai visualizzando questa pagina perché questa canzone non fa parte di un album. probabilmente vedi questo messaggio perché hai una canzone posizionata direttamente nella cartella principale della tua libreria musicale. jellyfin raggruppa le tracce solo se si trovano allinterno di una cartella.",
"badValue": "opzione non valida \"{{value}}\". valore inesistente",
"networkError": "si è verificato un errore di rete",
"openError": "impossibile aprire il file"
},
"filter": {
"mostPlayed": "più riprodotti",
@ -372,7 +476,9 @@
"settings": "$t(common.setting_other)",
"home": "$t(common.home)",
"artists": "$t(entity.artist_other)",
"albumArtists": "$t(entity.albumArtist_other)"
"albumArtists": "$t(entity.albumArtist_other)",
"myLibrary": "la mia libreria",
"shared": "condivisa $t(entity.playlist_other)"
},
"fullscreenPlayer": {
"config": {
@ -386,11 +492,16 @@
"unsynchronized": "non sinncronizzato",
"lyricAlignment": "allineamento testo",
"useImageAspectRatio": "usa le proporzioni dell'immagine",
"lyricGap": "gap testo"
"lyricGap": "gap testo",
"dynamicImageBlur": "intensità sfocatura immagine",
"dynamicIsImage": "abilita immagine di sfondo",
"lyricOffset": "ritardo testi (ms)"
},
"upNext": "successivamente",
"lyrics": "testi",
"related": "correlati"
"related": "correlati",
"visualizer": "visualizzatore audio",
"noLyrics": "nessun testo trovato"
},
"appMenu": {
"selectServer": "seleziona server",
@ -420,7 +531,13 @@
"addFavorite": "$t(action.addToFavorites)",
"play": "$t(player.play)",
"numberSelected": "{{count}} selezionati",
"removeFromQueue": "$t(action.removeFromQueue)"
"removeFromQueue": "$t(action.removeFromQueue)",
"download": "download",
"moveToNext": "$t(action.moveToNext)",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"playShuffled": "$t(player.shuffle)",
"shareItem": "condividi elemento",
"showDetails": "mostra info"
},
"home": {
"mostPlayed": "più riprodotti",
@ -431,22 +548,28 @@
},
"albumDetail": {
"moreFromArtist": "di più da questo $t(entity.artist_one)",
"moreFromGeneric": "di più da {{item}}"
"moreFromGeneric": "di più da {{item}}",
"released": "rilasciato"
},
"setting": {
"playbackTab": "riproduzione",
"generalTab": "generale",
"hotkeysTab": "tasti a scelta rapida",
"windowTab": "finestra"
"windowTab": "finestra",
"advanced": "avanzate"
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
},
"genreList": {
"title": "$t(entity.genre_other)"
"title": "$t(entity.genre_other)",
"showAlbums": "mostra $t(entity.genre_one) $t(entity.album_other)",
"showTracks": "mostra $t(entity.genre_one) $t(entity.track_other)"
},
"trackList": {
"title": "$t(entity.track_other)"
"title": "$t(entity.track_other)",
"artistTracks": "tracce di {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)"
},
"globalSearch": {
"commands": {
@ -460,7 +583,36 @@
"title": "$t(entity.playlist_other)"
},
"albumList": {
"title": "$t(entity.album_other)"
"title": "$t(entity.album_other)",
"artistAlbums": "albums di {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)"
},
"albumArtistDetail": {
"about": "Info {{artist}}",
"appearsOn": "compare su",
"recentReleases": "uscite recenti",
"viewDiscography": "mostra discografia",
"relatedArtists": "correlati $t(entity.artist_other)",
"topSongs": "brani migliori",
"topSongsFrom": "brani migliori da {{title}}",
"viewAll": "mostra tutto",
"viewAllTracks": "mostra tutto $t(entity.track_other)"
},
"manageServers": {
"title": "gestisci servers",
"serverDetails": "dettagli server",
"url": "URL",
"username": "nome utente",
"editServerDetailsTooltip": "modifica dettagli server",
"removeServer": "rimuovi server"
},
"itemDetail": {
"copyPath": "copia percorso negli appunti",
"copiedPath": "percorso copiato con successo",
"openFile": "mostra traccia nel gestore file"
},
"playlist": {
"reorder": "riordino abilitato solo quando si ordina per id"
}
},
"form": {
@ -491,7 +643,7 @@
"error_savePassword": "si è verificato un errore quando si è provato a salvare la password"
},
"addToPlaylist": {
"success": "aggiunto {{message}} $t(entity.track_other) a {{numOfPlaylists}} $t(entity.playlist_other)",
"success": "aggiunto $t(entity.trackWithCount, {\"count\": {{message}} }) a $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "aggiungi a $t(entity.playlist_one)",
"input_skipDuplicates": "salta duplicati",
"input_playlists": "$t(entity.playlist_other)"
@ -502,7 +654,8 @@
},
"queryEditor": {
"input_optionMatchAll": "soddisfa tutti",
"input_optionMatchAny": "soddisfa qualsiasi"
"input_optionMatchAny": "soddisfa qualsiasi",
"title": "editor di query"
},
"lyricSearch": {
"input_name": "$t(common.name)",
@ -510,7 +663,17 @@
"title": "cerca testi"
},
"editPlaylist": {
"title": "modifica $t(entity.playlist_one)"
"title": "modifica $t(entity.playlist_one)",
"publicJellyfinNote": "Jellyfin non mostra se una playlist è pubblica o meno. Se vuoi che rimanga pubblica, assicurati di selezionare lopzione seguente",
"success": "$t(entity.playlist_one) aggiornato con successo"
},
"shareItem": {
"allowDownloading": "consentire il download",
"description": "descrizione",
"setExpiration": "imposta scadenza",
"success": "link di condivisione copiato negli appunti (o clicca qui per aprirlo)",
"expireInvalid": "la scadenza deve essere nel futuro",
"createFailed": "condivisione fallita (è abilitata la condivisione?)"
}
},
"table": {
@ -520,11 +683,17 @@
"gap": "$t(common.gap)",
"tableColumns": "tabella colonne",
"autoFitColumns": "adatta colonne automaticamente",
"size": "$t(common.size)"
"size": "$t(common.size)",
"followCurrentSong": "segui il brano corrente",
"itemGap": "spaziatura tra gli elementi (px)",
"itemSize": "dimensione dellelemento (px)"
},
"view": {
"table": "tabella",
"card": "Scheda"
"card": "Scheda",
"grid": "griglia",
"list": "lista",
"poster": "poster"
},
"label": {
"releaseDate": "data rilascio",
@ -552,7 +721,9 @@
"discNumber": "numero disco",
"favorite": "$t(common.favorite)",
"year": "$t(common.year)",
"albumArtist": "$t(entity.albumArtist_one)"
"albumArtist": "$t(entity.albumArtist_one)",
"codec": "$t(common.codec)",
"songCount": "$t(entity.track_other)"
}
},
"column": {
@ -578,7 +749,8 @@
"path": "percorso",
"discNumber": "disco",
"channels": "$t(common.channel_other)",
"size": "$t(common.size)"
"size": "$t(common.size)",
"codec": "$t(common.codec)"
}
},
"entity": {
@ -627,6 +799,12 @@
"genreWithCount_other": "{{count}} generi",
"trackWithCount_one": "{{count}} traccia",
"trackWithCount_many": "{{count}} tracce",
"trackWithCount_other": "{{count}} tracce"
"trackWithCount_other": "{{count}} tracce",
"play_one": "{{count}} riproduzione",
"play_many": "{{count}} riproduzioni",
"play_other": "{{count}} riproduzioni",
"song_one": "traccia",
"song_many": "tracce",
"song_other": "tracce"
}
}

View file

@ -104,13 +104,14 @@
"year": "år",
"yes": "ja",
"descending": "synkende",
"dismiss": "avkreft",
"dismiss": "lukk",
"delete": "slett",
"description": "beskrivelse",
"manage": "håndtere",
"maximize": "maksimer",
"right": "høyre",
"sortOrder": "rekkefølge"
"sortOrder": "rekkefølge",
"tags": "tagger"
},
"entity": {
"smartPlaylist": "smart $t(entity.playlist_one)",
@ -233,7 +234,7 @@
"addServer": {
"ignoreCors": "ignorer cors ($t(common.restartRequired))",
"ignoreSsl": "ignorer ssl ($t(common.restartRequired))",
"error_savePassword": "en problem oppstod ved lagring av passord",
"error_savePassword": "et problem oppstod ved lagring av passord",
"input_savePassword": "lagre passord",
"input_url": "lenke",
"input_username": "brukernavn",
@ -269,6 +270,10 @@
"updateServer": {
"success": "vellykket oppdatering av serveren",
"title": "oppdater server"
},
"queryEditor": {
"input_optionMatchAll": "match alle",
"input_optionMatchAny": "matche hvilken som helst"
}
},
"page": {
@ -338,7 +343,7 @@
"lyricGap": "sangtekstavstand",
"dynamicImageBlur": "bilduskarphetstørrelse",
"lyricAlignment": "sangtekstjustering",
"lyricOffset": "sangtekstjustering (ms)",
"lyricOffset": "sangtekstforskyvning (ms)",
"lyricSize": "sangtekststørrelse",
"opacity": "absorpsjon",
"showLyricMatch": "vis sangteksttreff",
@ -405,7 +410,8 @@
"search": "$t(common.search)",
"settings": "$t(common.setting_other)",
"shared": "delt $t(entity.playlist_other)",
"artists": "$t(entity.artist_other)"
"artists": "$t(entity.artist_other)",
"myLibrary": "mitt bibliotek"
},
"setting": {
"generalTab": "generelt",
@ -416,6 +422,9 @@
},
"playlistList": {
"title": "$t(entity.playlist_other)"
},
"playlist": {
"reorder": "omorganisering kun mulig ved sortering på id"
}
},
"player": {
@ -439,6 +448,68 @@
"queue_moveToTop": "flytt valgte til bunnen",
"playbackFetchNoResults": "ingen sanger funnet",
"playbackSpeed": "avspillingshastighet",
"playSimilarSongs": "spill lignende sanger"
"playSimilarSongs": "spill lignende sanger",
"skip": "hopp over",
"shuffle": "spill i tilfeldig rekkefølge",
"shuffle_off": "tilfeldig rekkefølge skrudd av",
"skip_back": "hopp bakover",
"skip_forward": "hopp fremover",
"stop": "stopp",
"toggleFullscreenPlayer": "bytt til fullskjermspiller",
"pause": "sett på pause",
"viewQueue": "se kø",
"unfavorite": "fjern fra favoritter"
},
"setting": {
"accentColor": "aksentfarge",
"accentColor_description": "setter aksentfarge i applikasjonen",
"albumBackground": "album bakgrunnsbilde",
"albumBackgroundBlur": "album bakgrunnsbilde uskarphetsstørrelse",
"albumBackgroundBlur_description": "justerer grad av uskarphet lagt til på album bakgrunnsbilde",
"audioDevice": "lydenhet",
"zoom": "zoomprosent",
"zoom_description": "angir zoomprosent for applikasjonen"
},
"table": {
"config": {
"label": {
"playCount": "antall avspillinger",
"releaseDate": "utgivelsesdato",
"trackNumber": "spornummer",
"rowIndex": "radindeks",
"dateAdded": "dato lagt til",
"discNumber": "skivenummer",
"lastPlayed": "sist avspilt"
},
"view": {
"table": "tabell",
"card": "kort",
"grid": "rutenett",
"list": "liste",
"poster": "plakat"
},
"general": {
"autoFitColumns": "automatisk kolonnetilpasning",
"displayType": "visningstype",
"followCurrentSong": "følg gjeldende sang"
}
},
"column": {
"releaseYear": "år",
"comment": "kommentar",
"biography": "biografi",
"album": "album",
"albumArtist": "albumartist",
"dateAdded": "dato lagt til",
"discNumber": "skive",
"favorite": "favoritt",
"lastPlayed": "sist avspilt",
"path": "sti",
"playCount": "avspillinger",
"rating": "vurdering",
"releaseDate": "utgivelsesdato",
"title": "tittel",
"trackNumber": "spor"
}
}
}

View file

@ -93,7 +93,9 @@
"albumPeak": "pico do álbum",
"trackGain": "ganho da faixa",
"additionalParticipants": "participantes adicionais",
"tags": "tags"
"tags": "tags",
"newVersion": "uma nova versão foi instalada ({{version}})",
"viewReleaseNotes": "ver notas de lançamento"
},
"action": {
"goToPage": "vá para página",
@ -216,7 +218,9 @@
"crossfadeDuration_description": "define a duração do efeito crossfade",
"customCssNotice": "Aviso: apesar de existir alguma higienização (url() e content: não são permitidas), o uso de CSS personalizado ainda pode representar riscos ao alterar a interface.",
"crossfadeStyle": "estilo do crossfade",
"crossfadeStyle_description": "seleciona qual estilo de crossfade usado no player de áudio"
"crossfadeStyle_description": "seleciona qual estilo de crossfade usado no player de áudio",
"disableAutomaticUpdates": "desabilitar atualizações automáticas",
"disableLibraryUpdateOnStartup": "desabilitar a verificação de novas versões na inicialização"
},
"table": {
"config": {
@ -273,7 +277,8 @@
"nowPlaying": "tocando agora",
"playlists": "$t(entity.playlist_other)",
"search": "$t(common.search)",
"settings": "$t(common.setting_other)"
"settings": "$t(common.setting_other)",
"myLibrary": "minha biblioteca"
},
"playlistList": {
"title": "$t(entity.playlist_other)"

647
src/i18n/locales/sl.json Normal file
View file

@ -0,0 +1,647 @@
{
"action": {
"addToFavorites": "dodaj na $t(entity.favorite_other)",
"addToPlaylist": "dodaj na $t(entity.playlist_one)",
"clearQueue": "počisti čakalno vrsto",
"createPlaylist": "ustvari $t(entity.playlist_one)",
"deletePlaylist": "izbriši $t(entity.playlist_one)",
"deselectAll": "odizberi vse",
"editPlaylist": "uredi $t(entity.playlist_one)",
"goToPage": "pojdi na stran",
"moveToNext": "pojdi na naslednjo",
"moveToBottom": "pojdi na dno",
"moveToTop": "pojdi na vrh",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "odstrani iz $t(entity.favorite_other)",
"removeFromPlaylist": "odstrani iz seznama predvajanja",
"removeFromQueue": "odstrani iz čakalne vrste",
"setRating": "nastavi oceno",
"toggleSmartPlaylistEditor": "preklopi urejevalnik $t(entity.smartPlaylist)",
"viewPlaylists": "poglej $t(entity.playlist_other)",
"openIn": {
"lastfm": "Odpri v Last.fm",
"musicbrainz": "Odpri v MusicBrainz"
}
},
"common": {
"action_one": "dejanje",
"action_two": "dejanji",
"action_few": "dejanja",
"action_other": "dejanj",
"add": "dodaj",
"additionalParticipants": "dodatni udeleženci",
"newVersion": "nova verzija je bila nameščena ({{version}})",
"viewReleaseNotes": "poglej zapiske o različici",
"albumGain": "ojačitev albuma",
"albumPeak": "vrh albuma",
"areYouSure": "ali si prepričan?",
"ascending": "naraščajoče",
"backward": "nazaj",
"biography": "biografija",
"bitrate": "bitna hitrost",
"bpm": "unm",
"cancel": "prekliči",
"center": "center",
"channel_one": "kanal",
"channel_two": "kanala",
"channel_few": "kanali",
"channel_other": "kanalov",
"clear": "počisti",
"close": "zapri",
"codec": "kodek",
"collapse": "strni",
"comingSoon": "prihaja kmalu …",
"configure": "prilagodi",
"confirm": "potrdi",
"create": "ustvari",
"currentSong": "trenutna $t(entity.track_one)",
"decrease": "zmanjšaj",
"delete": "izbriši",
"descending": "padajoče",
"description": "opis",
"disable": "onemogoči",
"disc": "disk",
"dismiss": "spreglej",
"duration": "trajanje",
"edit": "uredi",
"enable": "omogoči",
"expand": "razširi",
"favorite": "najljubša",
"filter_one": "filter",
"filter_two": "filtra",
"filter_few": "filtri",
"filter_other": "filtrov",
"filters": "filtri",
"forceRestartRequired": "znova zaženi, da potrdiš spremembe ... zapri obvestilo, da znova zaženeš",
"forward": "naprej",
"gap": "reža",
"home": "domov",
"increase": "povišaj",
"limit": "omeji",
"manage": "upravljaj",
"maximize": "maksimiziraj",
"menu": "meni",
"minimize": "pomanjšaj",
"modified": "spremenjeno",
"mbid": "MusicBrainz identifikator (ID)",
"left": "levo",
"no": "ne",
"none": "noben",
"noResultsFromQuery": "poizvedba ni vrnila rezultatov",
"note": "opomba",
"ok": "ok",
"owner": "lastnik",
"path": "pot",
"playerMustBePaused": "predvajalnik mora biti ustavljen",
"preview": "predogled",
"previousSong": "prejšnja $t(entity.track_one)",
"quit": "izhod",
"random": "naključno",
"rating": "ocena",
"refresh": "osveži",
"reload": "ponovno naloži",
"reset": "ponastavi",
"resetToDefault": "ponastavi na privzeto",
"restartRequired": "zahtevan je ponovni zagon",
"right": "desno",
"save": "shrani",
"saveAndReplace": "shrani in zamenjaj",
"saveAs": "shrani kot",
"search": "išči",
"setting": "nastavitev",
"share": "deli",
"size": "velikost",
"sortOrder": "vrstni red",
"tags": "oznake",
"title": "naslov",
"trackNumber": "skladba",
"trackGain": "glasnost skladbe",
"trackPeak": "vrhunec skladbe",
"translation": "prevod",
"unknown": "neznan",
"version": "verzija",
"year": "leto",
"yes": "da",
"name": "ime"
},
"entity": {
"album_one": "album",
"album_two": "albuma",
"album_few": "albumi",
"album_other": "albumov",
"albumArtist_one": "izvajalec albuma",
"albumArtist_two": "izvajalec albumov",
"albumArtist_few": "izvajalec albumov",
"albumArtist_other": "izvajalec albumov",
"albumArtistCount_one": "{{count}} izvajalec albuma",
"albumArtistCount_two": "{{count}} izvajalca albuma",
"albumArtistCount_few": "{{count}} izvajalci albuma",
"albumArtistCount_other": "{{count}} izvajalcev albuma",
"albumWithCount_one": "{{count}} album",
"albumWithCount_two": "{{count}} albuma",
"albumWithCount_few": "{{count}} albumi",
"albumWithCount_other": "{{count}} albumov",
"artist_one": "izvajalec",
"artist_two": "izvajalca",
"artist_few": "izvajalci",
"artist_other": "izvajalcev",
"artistWithCount_one": "{{count}} izvajalec",
"artistWithCount_two": "{{count}} izvajalca",
"artistWithCount_few": "{{count}} izvajalci",
"artistWithCount_other": "{{count}} izvajalcev",
"favorite_one": "priljubljen",
"favorite_two": "priljubljena",
"favorite_few": "priljubljeni",
"favorite_other": "priljubljenih",
"folder_one": "mapa",
"folder_two": "mapi",
"folder_few": "mape",
"folder_other": "map",
"folderWithCount_one": "{{count}} mapa",
"folderWithCount_two": "{{count}} mapi",
"folderWithCount_few": "{{count}} mape",
"folderWithCount_other": "{{count}} map",
"genre_one": "zvrst",
"genre_two": "zvrsti",
"genre_few": "zvrsti",
"genre_other": "zvrsti",
"genreWithCount_one": "{{count}} zvrst",
"genreWithCount_two": "{{count}} zvrsti",
"genreWithCount_few": "{{count}} zvrsti",
"genreWithCount_other": "{{count}} zvrsti",
"playlist_one": "seznam predvajanja",
"playlist_two": "seznama predvajanja",
"playlist_few": "seznami predvajanja",
"playlist_other": "seznamov predvajanja",
"play_one": "{{count}} predvajanje",
"play_two": "{{count}} predvajanji",
"play_few": "{{count}} predvajanja",
"play_other": "{{count}} predvajanj",
"playlistWithCount_one": "{{count}} seznam predvajanja",
"playlistWithCount_two": "{{count}} seznama predvajanja",
"playlistWithCount_few": "{{count}} seznami predvajanja",
"playlistWithCount_other": "{{count}} seznamov predvajanja",
"smartPlaylist": "pametni $t(entity.playlist_one)",
"track_one": "skladba",
"track_two": "skladbi",
"track_few": "skladbe",
"track_other": "skladb",
"song_one": "pesem",
"song_two": "pesmi",
"song_few": "pesmi",
"song_other": "pesmi",
"trackWithCount_one": "{{count}} skladba",
"trackWithCount_two": "{{count}} skladbi",
"trackWithCount_few": "{{count}} skladbe",
"trackWithCount_other": "{{count}} skladb"
},
"error": {
"apiRouteError": "preusmeritev zahteve ni bila mogoča",
"audioDeviceFetchError": "napaka pri poskusu pridobivanja avdio naprav",
"authenticationFailed": "napaka pri avtentikaciji",
"badAlbum": "ta stran je prikazana ker skladba ne pripada nobenemu albumu. skladba se verjetno nahaja na vrhu datotečne strukture direktorija z glasbo. jellyfin razporedi skladbe v skupine samo v primeru, ko se nahajajo v direktoriju.",
"badValue": "neveljavna možnost \"{{value}}\". ta vrednost ne obstaja več",
"credentialsRequired": "zahtevana prijava",
"endpointNotImplementedError": "{{serverType}} ne implementira končne točke {{endpoint}}",
"genericError": "prišlo je do napake",
"invalidServer": "neveljaven strežnik",
"localFontAccessDenied": "dostop do lokalnih pisav je bil zavrnjen",
"loginRateError": "preveč poskusov prijave, prosimo, poskusite čez nekaj sekund",
"mpvRequired": "obvezen MPV",
"networkError": "prišlo je do mrežne napake",
"openError": "datoteke ni mogoče odpreti",
"playbackError": "prišlo je do napake pri poskusu predvajanja skladbe",
"remoteDisableError": "oddaljenega strežnika ni bilo mogoče $t(common.disable)ti",
"remoteEnableError": "oddaljenega strežnika ni bilo mogoče $t(common.enable)ti",
"remotePortError": "pri nastavljanju vrat oddaljenega strežnika je prišlo do napake",
"remotePortWarning": "ponovno zaženite strežnik da aplicirate spremembo strežniških vrat",
"serverNotSelectedError": "izbran ni bil noben strežnik",
"serverRequired": "strežnik zahtevan",
"sessionExpiredError": "vaša seja se je iztekla",
"systemFontError": "napaka pri pridobivanju sistemskih pisav"
},
"filter": {
"album": "$t(entity.album_one)",
"albumArtist": "$t(entity.albumArtist_one)",
"albumCount": "število $t(entity.album_other)",
"artist": "$t(entity.artist_one)",
"biography": "biografija",
"bitrate": "bitna hitrost",
"bpm": "bpm",
"channels": "$t(common.channel_other)",
"comment": "komentar",
"communityRating": "ocena skupnosti",
"criticRating": "ocena kritikov",
"dateAdded": "dodano",
"disc": "disk",
"duration": "trajanje",
"favorited": "priljubljeno",
"fromYear": "od leta",
"genre": "$t(entity.genre_one)",
"id": "identifikator",
"isCompilation": "je kompilacija",
"isFavorited": "je dodan med priljubljene",
"isPublic": "je javno",
"isRated": "je ocenjen",
"isRecentlyPlayed": "je bil nedavno predvajan",
"lastPlayed": "zadnje predvajano",
"mostPlayed": "najpogosteje predvajano",
"name": "ime",
"note": "opomba",
"owner": "$t(common.owner)",
"path": "pot",
"playCount": "število predvajanj",
"random": "naključno",
"rating": "ocena",
"recentlyAdded": "nedavno dodano",
"recentlyPlayed": "nedavno predvajano",
"recentlyUpdated": "nedavno posodobljeno",
"releaseDate": "datum izida",
"releaseYear": "leto izida",
"search": "išči",
"songCount": "število pesmi",
"title": "naslov",
"toYear": "do leta",
"trackNumber": "skladba"
},
"form": {
"addServer": {
"error_savePassword": "pri shranjevanju gesla je prišlo do napake",
"ignoreCors": "ignoriraj cors $t(common.restartRequired)",
"ignoreSsl": "ignoriraj ssl $t(common.restartRequired)",
"input_legacyAuthentication": "omogoči legacy avtentikacijo",
"input_name": "ime strežnika",
"input_password": "geslo",
"input_savePassword": "shrani geslo",
"input_url": "url",
"input_username": "uporabniško ime",
"success": "dodajanje strežnika uspešno",
"title": "dodaj strežnik"
},
"addToPlaylist": {
"input_playlists": "$t(entity.playlist_other)",
"input_skipDuplicates": "preskoči duplikate",
"success": "$t(entity.trackWithCount, {\"count\": {{message}} }) dodan v $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "dodaj v $t(entity.playlist_one)"
},
"createPlaylist": {
"input_description": "$t(common.description)",
"input_name": "$t(common.name)",
"input_owner": "$t(common.owner)",
"input_public": "javno",
"success": "$t(entity.playlist_one) je bil uspešno ustvarjen",
"title": "ustvari $t(entity.playlist_one)"
},
"deletePlaylist": {
"input_confirm": "vpišite ime $t(entity.playlist_one) za potrditev",
"success": "$t(entity.playlist_one) uspešno izbrisan",
"title": "izbriši $t(entity.playlist_one)"
},
"editPlaylist": {
"publicJellyfinNote": "Jellyfin ne poda informacij o tem, ali gre za javni ali zasebni seznam predvajanja. Če želite, da seznam predvajanja ostane javen, izberite naslednji vnos",
"success": "$t(entity.playlist_one) uspešno posodobljen",
"title": "uredi $t(entity.playlist_one)"
},
"lyricSearch": {
"input_artist": "$t(entity.artist_one)",
"input_name": "$t(common.name)",
"title": "iskanje po besedilu"
},
"queryEditor": {
"title": "urejevalnik poizvedb",
"input_optionMatchAll": "ujemanje vseh",
"input_optionMatchAny": "ujemanje z najmanj enim"
},
"shareItem": {
"allowDownloading": "dovoli prenašanje",
"description": "opis",
"setExpiration": "nastavi datum poteka veljavnosti",
"success": "deli povezavo v odložišču (ali klikni tukaj za odpiranje)",
"expireInvalid": "datum poteka veljavnosti mora biti v prihodnosti",
"createFailed": "deljenje ni uspelo (je deljenje omogočeno?)"
},
"updateServer": {
"success": "strežnik uspešno posodobljen",
"title": "posodobi strežnik"
}
},
"page": {
"albumArtistDetail": {
"about": "O izvajalcu",
"appearsOn": "se pojavi na",
"recentReleases": "zadnje izdaje",
"viewDiscography": "poglej diskografijo",
"relatedArtists": "sorodni $t(entity.artist_other)",
"topSongs": "najboljše skladbe",
"topSongsFrom": "najboljše skladbe iz {{title}}",
"viewAll": "poglej vse",
"viewAllTracks": "poglej vse $t(entity.track_other)"
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
},
"albumDetail": {
"moreFromArtist": "več od $t(entity.artist_one)",
"moreFromGeneric": "več iz {{item}}",
"released": "izdano"
},
"albumList": {
"artistAlbums": "albumi izvajalca {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)",
"title": "$t(entity.album_other)"
},
"appMenu": {
"collapseSidebar": "skrij stransko vrstico",
"expandSidebar": "razširi stransko vrstico",
"goBack": "nazaj",
"goForward": "naprej",
"manageServers": "urejanje strežnikov",
"openBrowserDevtools": "odpri orodja za razvijalce brskalnika",
"quit": "$t(common.quit)",
"selectServer": "izberi strežnik",
"settings": "$t(common.setting_other)",
"version": "verzija {{version}}"
},
"manageServers": {
"title": "urejanje strežnikov",
"serverDetails": "podrobosti o strežniku",
"url": "URL",
"username": "uporabniško ime",
"editServerDetailsTooltip": "urejanje podrobnosti strežnika",
"removeServer": "odstrani strežnik"
},
"contextMenu": {
"addFavorite": "$t(action.addToFavorites)",
"addLast": "$t(player.addLast)",
"addNext": "$t(player.addNext)",
"addToFavorites": "$t(action.addToFavorites)",
"addToPlaylist": "$t(action.addToPlaylist)",
"createPlaylist": "$t(action.createPlaylist)",
"deletePlaylist": "$t(action.deletePlaylist)",
"deselectAll": "$t(action.deselectAll)",
"download": "prenesi",
"moveToNext": "$t(action.moveToNext)",
"moveToBottom": "$t(action.moveToBottom)",
"moveToTop": "$t(action.moveToTop)",
"numberSelected": "{{count}} izbranih",
"play": "$t(player.play)",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"removeFromFavorites": "$t(action.removeFromFavorites)",
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromQueue": "$t(action.removeFromQueue)",
"setRating": "$t(action.setRating)",
"playShuffled": "$t(player.shuffle)",
"shareItem": "deli",
"showDetails": "pridobi informacije"
},
"fullscreenPlayer": {
"config": {
"dynamicBackground": "dinamično ozadje",
"dynamicImageBlur": "velikost zameglitve slike",
"dynamicIsImage": "omogoči sliko v ozadju",
"followCurrentLyric": "sledi besedilu",
"lyricAlignment": "poravnava besedila",
"lyricOffset": "zamik besedila (ms)",
"lyricGap": "razmik besedila",
"lyricSize": "velikost besedila",
"opacity": "prosojnost",
"showLyricMatch": "prikaži ujemanje besedila",
"showLyricProvider": "pokaži ponudnika besedila",
"synchronized": "sinhronizirano",
"unsynchronized": "nesinhronizirano",
"useImageAspectRatio": "uporabi razmerje stranic slike"
},
"lyrics": "besedilo",
"related": "sorodno",
"upNext": "sledi",
"visualizer": "vizualizator",
"noLyrics": "ni bilo najdenih besedil"
},
"genreList": {
"showAlbums": "prikaži $t(entity.genre_one) $t(entity.album_other)",
"showTracks": "prikaži $t(entity.genre_one) $t(entity.track_other)",
"title": "$t(entity.genre_other)"
},
"globalSearch": {
"commands": {
"goToPage": "pojdi na stran",
"searchFor": "išči {{query}}",
"serverCommands": "strežniški ukazi"
},
"title": "ukazi"
},
"home": {
"explore": "razišči knjižnico",
"mostPlayed": "najpogosteje predvajano",
"newlyAdded": "zadnje dodane izdaje",
"recentlyPlayed": "nedavno predvajano",
"title": "$t(common.home)"
},
"itemDetail": {
"copyPath": "kopiraj v odložišče",
"copiedPath": "kopiranje poti uspešno",
"openFile": "prikaži skladbo v upravitelju datotek"
},
"playlist": {
"reorder": "preurejanje je omogočeno samo pri razvrščanju po identifikatorju"
},
"playlistList": {
"title": "$t(entity.playlist_other)"
},
"setting": {
"advanced": "napredno",
"generalTab": "splošno",
"hotkeysTab": "blžnjice",
"playbackTab": "predvajanje",
"windowTab": "okno"
},
"sidebar": {
"albumArtists": "$t(entity.albumArtist_other)",
"albums": "$t(entity.album_other)",
"artists": "$t(entity.artist_other)",
"folders": "$t(entity.folder_other)",
"genres": "$t(entity.genre_other)",
"home": "$t(common.home)",
"myLibrary": "moja knjižnica",
"nowPlaying": "trenutno se predvaja",
"playlists": "$t(entity.playlist_other)",
"search": "$t(common.search)",
"settings": "$t(common.setting_other)",
"shared": "deljen $t(entity.playlist_other)",
"tracks": "$t(entity.track_other)"
},
"trackList": {
"artistTracks": "skladbe po {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
"title": "$t(entity.track_other)"
}
},
"player": {
"addLast": "dodaj zadnje",
"addNext": "dodaj naslednje",
"favorite": "dodaj med priljubljene",
"mute": "utišaj",
"muted": "utišano",
"next": "naslednje",
"play": "predvajaj",
"playbackFetchCancel": "akcija traja dlje časa... zaprite obvestilo za preklic",
"playbackFetchInProgress": "nalaganje pesmi…",
"playbackFetchNoResults": "nobena pesem ni bila najdena",
"playbackSpeed": "hitrost predvajanja",
"playRandom": "predvajaj naključno",
"playSimilarSongs": "predvajaj sorodne pesmi",
"previous": "prejšnje",
"queue_clear": "počisti čakalno vrsto",
"queue_moveToBottom": "premakni izbrano na vrh",
"queue_moveToTop": "premakni izbrano na dno",
"queue_remove": "odstrani izbrano",
"repeat": "ponovi",
"repeat_all": "ponovi vse",
"repeat_off": "ne ponavljaj",
"shuffle": "predvajaj v naključnem vrstnem redu",
"shuffle_off": "prevajanje v naključnem vrstnem redu izključeno",
"skip": "preskoči",
"skip_back": "preskoči nazaj",
"skip_forward": "preskoči naprej",
"stop": "ustavi",
"toggleFullscreenPlayer": "preklopi predvajalnik v celozaslonski način",
"unfavorite": "odstrani iz priljubljenih",
"pause": "premor",
"viewQueue": "poglej čakalno vrsto"
},
"setting": {
"accentColor": "barva poudarka",
"accentColor_description": "nastavi barva poudarka aplikacije",
"albumBackground": "slika ozadja albuma",
"albumBackground_description": "doda sliko ozadja za strani albuma",
"albumBackgroundBlur": "velikost zameglitve slike ozadja albuma",
"albumBackgroundBlur_description": "spremeni moč zameglitve slike ozadja albuma",
"applicationHotkeys": "bližnjične tipke aplikacije",
"applicationHotkeys_description": "konfigurira bližnjične tipke aplikacije. obkljukajte da nastavite globalne bližnjico na tipkovnici (samo na namizju)",
"artistConfiguration": "konfiguracija strani izvajalca albuma",
"artistConfiguration_description": "konfiguriranje vsebine in vrstnega reda prikaza na strani izvajalca albuma",
"audioDevice": "avdio naprava",
"audioDevice_description": "izberite avdio napravo za predvajanje (samo v spletnem predvajalniku)",
"audioExclusiveMode": "avdio način",
"audioExclusiveMode_description": "omogoči način ekskluzivnega predvajanja. V tem načinu je sistem običajno zaklenjen in samo mpv lahko oddaja zvok",
"audioPlayer": "avdio predvajalnik",
"audioPlayer_description": "izberite avdio predvajalnik za predvajanje",
"buttonSize": "velikost gumbov vrstice predvajalnika",
"buttonSize_description": "velikost gumbov v vrstici predvajalnika",
"clearCache": "izbriši začasni pomnilnik",
"clearCache_description": "poleg brisanja feishinovega začasnega pomnilnika bo izbrisan tudi začasni pomnilnik brskalnika. nastavitve in prijavni podatki strežnikov se ohranijo",
"clearQueryCache": "počisti feishinov začasni pomnilnik",
"clearQueryCache_description": "osveži sezname predvajanja, metapodatke in ponastavi shranjena besedila. nastavitve, prijavni podatki za strežnike in slike se ohranijo",
"clearCacheSuccess": "začasni pomnilnik uspešno izbrisan",
"contextMenu": "konfiguracija kontekstnega menija (desni klik)",
"contextMenu_description": "omogoči skrivanje vrstic v meniju, prikazanem ob desnem kliku. odznačeni predmeti bodo skriti",
"crossfadeDuration": "trajanje prehoda",
"crossfadeDuration_description": "nastavi čas trajanja prehoda med pesmimi",
"crossfadeStyle": "tip prehoda",
"crossfadeStyle_description": "izbira tipa efekta prehoda",
"customCssEnable": "omogoči css po meri",
"customCssEnable_description": "omogoča urejanje css-ja po meri.",
"customCssNotice": "Opozorilo: kljub določenim varnostnim ukrepom (prepoved url() in content:) lahko uporaba CSS po meri s spreminjanjem vmesnika še vedno predstavlja tveganje.",
"customCss": "css po meri",
"customCss_description": "vsebina css po meri. Opomba: vsebina in oddaljeni url-ji so prepovedane lastnosti. Spodaj je prikazan predogled vaše vsebine. Dodatna polja, ki jih niste nastavili, so prisotna zaradi prečiščevanja.",
"customFontPath": "pot za pisavo po meri",
"customFontPath_description": "nastavi pot do pisave po meri",
"disableAutomaticUpdates": "onemogoči samodejne posodobitve",
"disableLibraryUpdateOnStartup": "onemogoči prevejranje novih verzij ob zagonu",
"discordApplicationId": "{{discord}} identifikator aplikacije",
"discordApplicationId_description": "identifikator aplikacije za {{discord}} bogato prezenco (privzeto {{defaultId}})",
"discordPausedStatus": "prikaži bogato prezenco med ustavljenim predvajanjem",
"discordPausedStatus_description": "ko je nastavitev omogočena, se bo status prikazal tudi ko je predvajanje začasno zaustavljeno",
"discordIdleStatus": "prikaže stanje mirovanja v bogati prezenci",
"discordIdleStatus_description": "ko je nastavitev omogočena, se bo status posodabljal ko predvajalnik miruje",
"discordListening": "prikaži status poslušanja",
"discordListening_description": "prikaži status poslušanja namesto predvajanja",
"discordRichPresence": "{{discord}} bogata prezenca",
"discordRichPresence_description": "omogoči prikaz statusa predvajanja v {{discord}} bogati prezenci. Oznake slike so: {{icon}}, {{playing}} in {{paused}}",
"discordServeImage": "pošiljaj {{discord}} u slike iz strežnika",
"discordServeImage_description": "deli naslovne slike za {{discord}} bogato prisotnost iz samega strežnika, na voljo samo za jellyfin in navidrome",
"discordUpdateInterval": "interval posodabljanja {{discord}} bogate prezence",
"discordUpdateInterval_description": "čas v sekundah med posameznimi posodobitvami (najmanj 15 sekund)",
"doubleClickBehavior": "dvojni klik doda vse iskane skladbe v čakalno vrsto",
"doubleClickBehavior_description": "če je nastavitev vklopljena se bodo v čakalno vrsto dodale vse skladbe, ki ustrezajo iskanju. v nasprotnem primeru se v čakalno vrsto doda samo izbrana skladba",
"enableRemote": "omogoči oddaljeno upravljanje strežnika",
"enableRemote_description": "omogoči oddaljeno nadzorovanje strežnika in s tem dovoli drugim napravam da upravljajo aplikacijo",
"externalLinks": "prikaži zunanje povezave",
"externalLinks_description": "omogoči prikaz zunanjih povezav (Last.fm, MusicBrainz) na straneh albumov,izvajalcev",
"exitToTray": "minimiziraj",
"exitToTray_description": "ob izhodu se aplikacija minimizira v opravilno vrstico",
"floatingQueueArea": "prikaži območje plavajoče čakalne vrste",
"floatingQueueArea_description": "na desni strani zaslona prikažite ikono za ogled čakalne vrste predvajanja",
"followLyric": "sledenje besedilu",
"followLyric_description": "pomaknite besedilo pesmi do trenutnega položaja predvajanja",
"preferLocalLyrics": "prioritiziraj lokalna besedila",
"preferLocalLyrics_description": "prioritiziraj lokalna besedila pred oddaljenimi, kadar so na voljo",
"font": "pisava",
"font_description": "nastavi pisavo, ki jo bo aplikacija uporabljala",
"fontType": "tip pisave",
"fontType_description": "vgrajena pisava izbere eno od pisav, ki jih ponuja Feishin. sistemska pisava vam omogoča, da izberete katero koli pisavo, ki jo ponuja vaš operacijski sistem. po meri lahko izberete svojo pisavo",
"fontType_optionBuiltIn": "vgrajena pisava",
"fontType_optionCustom": "pisava po meri",
"fontType_optionSystem": "sistemska pisava",
"gaplessAudio": "neprekinjen avdio",
"gaplessAudio_description": "nastavi neprekinjen avdio za mpv",
"gaplessAudio_optionWeak": "šibko (priporočeno)",
"genreBehavior": "privzeto vedenje strani z zvrstmi",
"genreBehavior_description": "določa, ali se ob kliku na zvrst privzeto odpre seznam skladb ali albumov",
"globalMediaHotkeys": "globalne bližnjične tipke za vsebino",
"globalMediaHotkeys_description": "omogočite ali onemogočite uporabo bližnjic za sistemske medije za nadzor predvajanja",
"homeConfiguration": "konfiguracija domače strani",
"homeConfiguration_description": "konfigurirajte, kateri elementi so prikazani na domači strani in v kakšnem vrstnem redu",
"homeFeature": "tekoči trak na domači strani",
"homeFeature_description": "nadzoruje, ali naj se na domači strani prikaže velik tekoči trak",
"hotkey_browserBack": "nazaj (brskalnik)",
"hotkey_browserForward": "naprej (brskalnik)",
"hotkey_favoriteCurrentSong": "dodaj $t(common.currentSong) med priljubljene",
"hotkey_favoritePreviousSong": "dodaj $t(common.previousSong) med priljubljene",
"hotkey_globalSearch": "globalno iskanje",
"hotkey_localSearch": "iskanje na strani",
"hotkey_playbackNext": "naslednja skladba",
"hotkey_playbackPause": "pavza",
"hotkey_playbackPlay": "predvajaj",
"hotkey_playbackPlayPause": "predvajaj / pavza",
"hotkey_playbackPrevious": "prejšnja skladba",
"hotkey_playbackStop": "ustavi",
"hotkey_rate0": "počisti oceno",
"hotkey_rate1": "oceni z 1 zvezdico",
"hotkey_rate2": "oceni z 2 zvezdicama",
"hotkey_rate3": "oceni s 3 zvezdicami",
"hotkey_rate4": "oceni s 4 zvezdicami",
"hotkey_rate5": "oceni s 5 zvezdicami",
"hotkey_skipBackward": "preskoči nazaj",
"hotkey_skipForward": "preskoči naprej",
"hotkey_toggleCurrentSongFavorite": "dodaj/odstrani $t(common.currentSong) iz seznama priljubljenih",
"hotkey_toggleFullScreenPlayer": "preklopi predvajalnik na celozaslonski način",
"hotkey_togglePreviousSongFavorite": "dodaj/odstrani $t(common.previousSong) iz seznama priljubljenih",
"hotkey_toggleQueue": "preklopi čakalno vrsto",
"hotkey_toggleRepeat": "preklopi ponovitve",
"hotkey_toggleShuffle": "preklopi naključni vrstni red predvajanja",
"hotkey_unfavoriteCurrentSong": "odstrani $t(common.currentSong) iz seznama priljubljenih",
"hotkey_unfavoritePreviousSong": "odstrani $t(common.previousSong) iz seznama priljubljenih",
"hotkey_volumeDown": "znižaj glasnost",
"hotkey_volumeMute": "utišaj",
"hotkey_volumeUp": "povišaj glasnost",
"hotkey_zoomIn": "povečaj",
"hotkey_zoomOut": "pomanjšaj",
"imageAspectRatio": "uporabi razmerje stranic izvorne naslovnice",
"imageAspectRatio_description": "če je omogočeno, bo naslovnica prikazana z izvornim razmerjem stranic. za slike, ki niso 1:1, bo preostali prostor prazen",
"language": "jezik",
"language_description": "nastavi jezik aplikacije ($t(common.restartRequired))",
"lastfm": "prikaži last.fm povezave",
"lastfm_description": "prikaži povezave do last.fm na straneh izvajalcev/albumov",
"lastfmApiKey": "API ključ {{lastfm}}",
"lastfmApiKey_description": "API ključ za {{lastfm}}. potreben za naslovnico albuma",
"lyricFetch": "pridobi besedila iz interneta",
"lyricFetch_description": "pridobivanje besedil iz različnih internetnih virov",
"lyricFetchProvider": "ponudniki za pridobivanje besedil",
"lyricFetchProvider_description": "izberite ponudnike, od katerih želite pridobiti besedila. vrstni red ponudnikov je vrstni red, v katerem bodo poizvedovani",
"lyricOffset": "zamik besedila (ms)",
"lyricOffset_description": "zamakni besedilo za določeno število milisekund",
"minimizeToTray": "minimiziraj v sistemsko vrstico",
"minimizeToTray_description": "minimizirajte aplikacijo v sistemsko vrstico"
}
}

View file

@ -113,7 +113,9 @@
"additionalParticipants": "其他参与者",
"tags": "标签",
"viewReleaseNotes": "查看发行说明",
"newVersion": "已安装新版本 ({{version}})"
"newVersion": "已安装新版本 ({{version}})",
"bitDepth": "位深度",
"sampleRate": "采样率"
},
"entity": {
"albumArtist_other": "专辑艺术家",
@ -407,7 +409,9 @@
"discordPausedStatus": "暂停时显示rich presence",
"discordPausedStatus_description": "启用后将在播放器暂停时显示状态",
"preservePitch": "保持音高",
"preservePitch_description": "在调整播放速度时保持音高"
"preservePitch_description": "在调整播放速度时保持音高",
"notify": "启用歌曲通知",
"notify_description": "更改当前歌曲时显示通知"
},
"error": {
"remotePortWarning": "重启服务器使新端口生效",
@ -432,7 +436,8 @@
"badAlbum": "您看到此页面是因为这首歌不是专辑的一部分。如果您的音乐文件夹顶层有一首歌曲您很可能会遇到此问题。jellyfin 仅对位于文件夹中的曲目进行分组。",
"networkError": "发生网络错误",
"openError": "无法打开文件",
"badValue": "无效的选项 \"{{value}}\". 此值不再存在"
"badValue": "无效的选项 \"{{value}}\". 此值不再存在",
"notificationDenied": "通知权限被拒绝。此设置无效"
},
"filter": {
"mostPlayed": "最多播放过",

View file

@ -421,9 +421,6 @@ async function createWindow(first = true): Promise<void> {
store.set('fullscreen', mainWindow?.isFullScreen());
if (!exitFromTray && store.get('window_exit_to_tray')) {
if (isMacOS() && !forceQuit) {
exitFromTray = true;
}
event.preventDefault();
mainWindow?.hide();
}
@ -432,8 +429,6 @@ async function createWindow(first = true): Promise<void> {
event.preventDefault();
saved = true;
getMainWindow()?.webContents.send('renderer-save-queue');
ipcMain.once('player-save-queue', async (_event, data: Record<string, any>) => {
const queueLocation = join(app.getPath('userData'), 'queue');
const serialized = JSON.stringify(data);
@ -457,12 +452,19 @@ async function createWindow(first = true): Promise<void> {
} catch (error) {
console.error('error saving queue state: ', error);
} finally {
mainWindow?.close();
if (!isMacOS()) {
mainWindow?.close();
}
if (forceQuit) {
app.exit();
}
}
});
getMainWindow()?.webContents.send('renderer-save-queue');
} else {
if (forceQuit) {
app.exit();
}
}
});

View file

@ -22,10 +22,7 @@ export const App = () => {
const { mode, theme } = useAppTheme(isDark ? AppTheme.DEFAULT_DARK : AppTheme.DEFAULT_LIGHT);
return (
<MantineProvider
defaultColorScheme={mode}
theme={theme}
>
<MantineProvider defaultColorScheme={mode} theme={theme}>
<Shell />
</MantineProvider>
);

View file

@ -18,17 +18,7 @@ export const ThemeButton = () => {
}}
variant="default"
>
{isDark ? (
<Icon
icon="themeLight"
size={30}
/>
) : (
<Icon
icon="themeDark"
size={30}
/>
)}
{isDark ? <Icon icon="themeLight" size={30} /> : <Icon icon="themeDark" size={30} />}
</ActionIcon>
);
};

View file

@ -32,17 +32,9 @@ export const RemoteContainer = () => {
const debouncedSetRating = debounce(setRating, 400);
return (
<Stack
gap="md"
h="100dvh"
w="100%"
>
<Stack gap="md" h="100dvh" w="100%">
{showImage && (
<Flex
align="center"
justify="center"
w="100%"
>
<Flex align="center" justify="center" w="100%">
<PlayerImage src={song?.imageUrl} />
</Flex>
)}
@ -87,10 +79,7 @@ export const RemoteContainer = () => {
</Group>
</Stack>
)}
<Group
gap={0}
grow
>
<Group gap={0} grow>
<ActionIcon
disabled={!id}
icon="favorite"
@ -109,10 +98,7 @@ export const RemoteContainer = () => {
/>
{(song?.serverType === 'navidrome' || song?.serverType === 'subsonic') && (
<div style={{ margin: 'auto' }}>
<Tooltip
label="Double click to clear"
openDelay={1000}
>
<Tooltip label="Double click to clear" openDelay={1000}>
<Rating
onChange={debouncedSetRating}
onDoubleClick={() => debouncedSetRating(0)}
@ -123,10 +109,7 @@ export const RemoteContainer = () => {
</div>
)}
</Group>
<Group
gap="xs"
grow
>
<Group gap="xs" grow>
<ActionIcon
disabled={!id}
icon="mediaPrevious"
@ -174,10 +157,7 @@ export const RemoteContainer = () => {
variant="default"
/>
</Group>
<Group
gap="xs"
grow
>
<Group gap="xs" grow>
<ActionIcon
icon="mediaShuffle"
iconProps={{
@ -232,10 +212,7 @@ export const RemoteContainer = () => {
max={100}
onChangeEnd={(e) => send({ event: 'volume', volume: e })}
rightLabel={
<Text
fw={600}
size="xs"
>
<Text fw={600} size="xs">
{volume ?? 0}
</Text>
}

View file

@ -13,16 +13,9 @@ export const Shell = () => {
const connected = useConnected();
return (
<AppShell
h="100vh"
padding="md"
w="100vw"
>
<AppShell h="100vh" padding="md" w="100vw">
<AppShell.Header style={{ background: 'var(--theme-colors-surface)' }}>
<Grid
px="md"
py="sm"
>
<Grid px="md" py="sm">
<Grid.Col span={4}>
<Flex
align="center"
@ -33,20 +26,11 @@ export const Shell = () => {
justifySelf: 'flex-start',
}}
>
<Image
fit="contain"
height={32}
src="/favicon.ico"
width={32}
/>
<Image fit="contain" height={32} src="/favicon.ico" width={32} />
</Flex>
</Grid.Col>
<Grid.Col span={8}>
<Group
gap="sm"
justify="flex-end"
wrap="nowrap"
>
<Group gap="sm" justify="flex-end" wrap="nowrap">
<ReconnectButton />
<ImageButton />
<ThemeButton />
@ -58,10 +42,7 @@ export const Shell = () => {
{connected ? (
<RemoteContainer />
) : (
<Center
h="100vh"
w="100vw"
>
<Center h="100vh" w="100vw">
<Spinner />
</Center>
)}

View file

@ -61,10 +61,7 @@ export const WrappedSlider = ({ leftLabel, rightLabel, value, ...props }: Wrappe
const [seek, setSeek] = useState(0);
return (
<Group
align="center"
wrap="nowrap"
>
<Group align="center" wrap="nowrap">
{leftLabel && <Text size="sm">{leftLabel}</Text>}
<PlayerbarSlider
{...props}

View file

@ -118,7 +118,7 @@ export const contract = c.router({
},
getGenreList: {
method: 'GET',
path: 'genres',
path: 'musicgenres',
query: jfType._parameters.genreList,
responses: {
200: jfType._response.genreList,

View file

@ -349,7 +349,14 @@ axiosClient.interceptors.response.use(
.catch((newError: any) => {
if (newError !== TIMEOUT_ERROR) {
console.error('Error when trying to reauthenticate: ', newError);
limitedFail(currentServer);
if (isAxiosError(newError) && newError.code === 'ERR_NETWORK') {
console.log(
'Network error during reauthentication - preserving credentials',
);
} else {
limitedFail(currentServer);
}
}
// make sure to pass the error so axios will error later on
@ -360,7 +367,11 @@ axiosClient.interceptors.response.use(
});
}
limitedFail(currentServer);
if (isAxiosError(error) && error.code === 'ERR_NETWORK') {
console.log('Network error during authentication - preserving credentials');
} else {
limitedFail(currentServer);
}
}
return Promise.reject(error);

View file

@ -251,6 +251,9 @@ axiosClient.interceptors.response.use(
message: data['subsonic-response'].error.message,
title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string,
});
// Since we do status === 200, override this value with the error code
response.status = data['subsonic-response'].error.code;
}
}

View file

@ -46,6 +46,10 @@ const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefin
[AlbumListSort.YEAR]: AlbumListSortType.BY_YEAR,
};
const MAX_SUBSONIC_ITEMS = 500;
// A trick to skip ahead 10x
const SUBSONIC_FAST_BATCH_SIZE = MAX_SUBSONIC_ITEMS * 10;
export const SubsonicController: ControllerEndpoint = {
addToPlaylist: async ({ apiClientProps, body, query }) => {
const res = await ssApiClient(apiClientProps).updatePlaylist({
@ -90,7 +94,7 @@ export const SubsonicController: ControllerEndpoint = {
};
}
await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({
const resp = await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({
query: {
c: 'Feishin',
f: 'json',
@ -99,6 +103,10 @@ export const SubsonicController: ControllerEndpoint = {
},
});
if (resp.status !== 200) {
throw new Error('Failed to log in');
}
return {
credential,
userId: null,
@ -269,7 +277,7 @@ export const SubsonicController: ControllerEndpoint = {
albumOffset: query.startIndex,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
query: query.searchTerm || '',
songCount: 0,
songOffset: 0,
},
@ -418,11 +426,11 @@ export const SubsonicController: ControllerEndpoint = {
while (fetchNextPage) {
const res = await ssApiClient(apiClientProps).search3({
query: {
albumCount: 500,
albumCount: MAX_SUBSONIC_ITEMS,
albumOffset: startIndex,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
query: query.searchTerm || '',
songCount: 0,
songOffset: 0,
},
@ -437,8 +445,7 @@ export const SubsonicController: ControllerEndpoint = {
totalRecordCount += albumCount;
startIndex += albumCount;
// The max limit size for Subsonic is 500
fetchNextPage = albumCount === 500;
fetchNextPage = albumCount === MAX_SUBSONIC_ITEMS;
}
return totalRecordCount;
@ -522,7 +529,7 @@ export const SubsonicController: ControllerEndpoint = {
genre: query.genres?.length ? query.genres[0] : undefined,
musicFolderId: query.musicFolderId,
offset: startIndex,
size: 500,
size: MAX_SUBSONIC_ITEMS,
toYear,
type,
},
@ -546,8 +553,7 @@ export const SubsonicController: ControllerEndpoint = {
totalRecordCount += albumCount;
startIndex += albumCount;
// The max limit size for Subsonic is 500
fetchNextPage = albumCount === 500;
fetchNextPage = albumCount === MAX_SUBSONIC_ITEMS;
}
return totalRecordCount;
@ -904,7 +910,7 @@ export const SubsonicController: ControllerEndpoint = {
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
query: query.searchTerm || '',
songCount: query.limit,
songOffset: query.startIndex,
},
@ -1046,7 +1052,7 @@ export const SubsonicController: ControllerEndpoint = {
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
query: query.searchTerm || '',
songCount: query.limit,
songOffset: query.startIndex,
},
@ -1086,8 +1092,8 @@ export const SubsonicController: ControllerEndpoint = {
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
songCount: 500,
query: query.searchTerm || '',
songCount: MAX_SUBSONIC_ITEMS,
songOffset: startIndex,
},
});
@ -1101,8 +1107,7 @@ export const SubsonicController: ControllerEndpoint = {
totalRecordCount += songCount;
startIndex += songCount;
// The max limit size for Subsonic is 500
fetchNextPage = songCount === 500;
fetchNextPage = songCount === MAX_SUBSONIC_ITEMS;
}
return totalRecordCount;
@ -1110,6 +1115,10 @@ export const SubsonicController: ControllerEndpoint = {
if (query.genreIds) {
let totalRecordCount = 0;
// Rather than just do `getSongsByGenre` by groups of 500, instead
// jump the offset 10x, and then backtrack on the last chunk. This improves
// performance for extremely large libraries
while (fetchNextSection) {
const res = await ssApiClient(apiClientProps).getSongsByGenre({
query: {
@ -1128,17 +1137,17 @@ export const SubsonicController: ControllerEndpoint = {
if (numberOfResults !== 1) {
fetchNextSection = false;
startIndex = sectionIndex === 0 ? 0 : sectionIndex - 5000;
startIndex = sectionIndex === 0 ? 0 : sectionIndex - SUBSONIC_FAST_BATCH_SIZE;
break;
} else {
sectionIndex += 5000;
sectionIndex += SUBSONIC_FAST_BATCH_SIZE;
}
}
while (fetchNextPage) {
const res = await ssApiClient(apiClientProps).getSongsByGenre({
query: {
count: 500,
count: MAX_SUBSONIC_ITEMS,
genre: query.genreIds[0],
musicFolderId: query.musicFolderId,
offset: startIndex,
@ -1154,7 +1163,7 @@ export const SubsonicController: ControllerEndpoint = {
totalRecordCount = startIndex + numberOfResults;
startIndex += numberOfResults;
fetchNextPage = numberOfResults === 500;
fetchNextPage = numberOfResults === MAX_SUBSONIC_ITEMS;
}
return totalRecordCount;
@ -1176,6 +1185,9 @@ export const SubsonicController: ControllerEndpoint = {
let totalRecordCount = 0;
// Rather than just do `search3` by groups of 500, instead
// jump the offset 10x, and then backtrack on the last chunk. This improves
// performance for extremely large libraries
while (fetchNextSection) {
const res = await ssApiClient(apiClientProps).search3({
query: {
@ -1183,7 +1195,7 @@ export const SubsonicController: ControllerEndpoint = {
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
query: query.searchTerm || '',
songCount: 1,
songOffset: sectionIndex,
},
@ -1195,13 +1207,12 @@ export const SubsonicController: ControllerEndpoint = {
const numberOfResults = (res.body.searchResult3?.song || []).length || 0;
// Check each batch of 5000 songs to check for data
sectionIndex += 5000;
fetchNextSection = numberOfResults === 1;
if (!fetchNextSection) {
// fetchNextBlock will be false on the next loop so we need to subtract 5000 * 2
startIndex = sectionIndex - 10000;
if (numberOfResults !== 1) {
fetchNextSection = false;
startIndex = sectionIndex === 0 ? 0 : sectionIndex - SUBSONIC_FAST_BATCH_SIZE;
break;
} else {
sectionIndex += SUBSONIC_FAST_BATCH_SIZE;
}
}
@ -1212,8 +1223,8 @@ export const SubsonicController: ControllerEndpoint = {
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
songCount: 500,
query: query.searchTerm || '',
songCount: MAX_SUBSONIC_ITEMS,
songOffset: startIndex,
},
});
@ -1227,8 +1238,7 @@ export const SubsonicController: ControllerEndpoint = {
totalRecordCount = startIndex + numberOfResults;
startIndex += numberOfResults;
// The max limit size for Subsonic is 500
fetchNextPage = numberOfResults === 500;
fetchNextPage = numberOfResults === MAX_SUBSONIC_ITEMS;
}
return totalRecordCount;

View file

@ -190,15 +190,8 @@ export const App = () => {
}, [language]);
return (
<MantineProvider
defaultColorScheme={mode as 'dark' | 'light'}
theme={theme}
>
<Notifications
containerWidth="300px"
position="bottom-center"
zIndex={5}
/>
<MantineProvider defaultColorScheme={mode as 'dark' | 'light'} theme={theme}>
<Notifications containerWidth="300px" position="bottom-center" zIndex={50000} />
<PlayQueueHandlerContext.Provider value={providerValue}>
<ContextMenuProvider>
<WebAudioContext.Provider value={webAudioProvider}>

View file

@ -225,6 +225,28 @@ export const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>((props,
setIsTransitioning(false);
};
const handleOnError = (playerRef: React.RefObject<ReactPlayer>) => {
return ({ target }: ErrorEvent) => {
const { current: player } = playerRef;
if (!player || !(target instanceof Audio)) {
return;
}
const { error } = target;
if (error?.code !== MediaError.MEDIA_ERR_DECODE) {
return;
}
const duration = player.getDuration();
const currentTime = player.getCurrentTime();
// Decode error within last second, handle as track ended
if (duration && duration - currentTime < 1) {
handleOnEnded();
}
};
};
useEffect(() => {
if (status === PlayerStatus.PLAYING) {
if (currentPlayer === 1) {
@ -424,6 +446,7 @@ export const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>((props,
muted={muted}
// If there is no stream url, we do not need to handle when the audio finishes
onEnded={stream1 ? handleOnEnded : undefined}
onError={handleOnError(player1Ref)}
onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1
}
@ -443,6 +466,7 @@ export const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>((props,
height={0}
muted={muted}
onEnded={stream2 ? handleOnEnded : undefined}
onError={handleOnError(player2Ref)}
onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2
}

View file

@ -47,10 +47,7 @@ export const CardControls = ({
return (
<div className={styles.gridCardControlsContainer}>
<div className={styles.bottomControls}>
<button
className={styles.playButton}
onClick={handlePlay}
>
<button className={styles.playButton} onClick={handlePlay}>
<Icon icon="mediaPlay" />
</button>
<Group gap="xs">

View file

@ -55,14 +55,8 @@ export const PosterCard = ({
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Link
className={styles.imageContainer}
to={path}
>
<Image
className={styles.image}
src={data?.imageUrl}
/>
<Link className={styles.imageContainer} to={path}>
<Image className={styles.image} src={data?.imageUrl} />
<GridCardControls
handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
@ -72,30 +66,21 @@ export const PosterCard = ({
/>
</Link>
<div className={styles.detailContainer}>
<CardRows
data={data}
rows={controls.cardRows}
/>
<CardRows data={data} rows={controls.cardRows} />
</div>
</div>
);
}
return (
<div
className={styles.container}
key={`placeholder-${uniqueId}-${data.id}`}
>
<div className={styles.container} key={`placeholder-${uniqueId}-${data.id}`}>
<div className={styles.imageContainer}>
<Skeleton className={styles.image} />
</div>
<div className={styles.detailContainer}>
<Stack gap="xs">
{(controls?.cardRows || []).map((row, index) => (
<Skeleton
height={14}
key={`${index}-${row.arrayProperty}`}
/>
<Skeleton height={14} key={`${index}-${row.arrayProperty}`} />
))}
</Stack>
</div>

View file

@ -35,14 +35,8 @@ export const ContextMenuButton = forwardRef(
onClick={props.onClick}
ref={ref}
>
<Group
justify="space-between"
w="100%"
>
<Group
className={styles.left}
gap="md"
>
<Group justify="space-between" w="100%">
<Group className={styles.left} gap="md">
{leftIcon}
{children}
</Group>

View file

@ -77,11 +77,7 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
className={styles.wrapper}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: currentItem?.id || '' })}
>
<AnimatePresence
custom={direction}
initial={false}
mode="popLayout"
>
<AnimatePresence custom={direction} initial={false} mode="popLayout">
{data && (
<motion.div
animate="animate"
@ -101,10 +97,7 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
/>
</div>
<div className={styles.infoColumn}>
<Stack
gap="md"
style={{ width: '100%' }}
>
<Stack gap="md" style={{ width: '100%' }}>
<div className={styles.titleWrapper}>
<TextTitle
fw={900}
@ -117,10 +110,7 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
</div>
<div className={styles.titleWrapper}>
{currentItem?.albumArtists.slice(0, 1).map((artist) => (
<Text
fw={600}
key={`carousel-artist-${artist.id}`}
>
<Text fw={600} key={`carousel-artist-${artist.id}`}>
{artist.name}
</Text>
))}

View file

@ -60,10 +60,7 @@ const Title = ({ handleNext, handlePrev, label, pagination }: TitleProps) => {
{isValidElement(label) ? (
label
) : (
<TextTitle
order={3}
weight={700}
>
<TextTitle order={3} weight={700}>
{label}
</TextTitle>
)}
@ -280,11 +277,7 @@ export const SwiperGridCarousel = ({
}, []);
return (
<Stack
className="grid-carousel"
gap="md"
ref={containerRef as any}
>
<Stack className="grid-carousel" gap="md" ref={containerRef as any}>
{title ? (
<Title
{...title}

View file

@ -91,11 +91,7 @@ export const NativeScrollArea = forwardRef(
{...pageHeaderProps}
/>
)}
<div
className={styles.scrollArea}
ref={mergedRef}
{...props}
>
<div className={styles.scrollArea} ref={mergedRef} {...props}>
{children}
</div>
</>

View file

@ -99,10 +99,7 @@ export const QueryBuilder = ({
};
return (
<Stack
gap="sm"
ml={`${level * 10}px`}
>
<Stack gap="sm" ml={`${level * 10}px`}>
<Group gap="sm">
<Select
data={FILTER_GROUP_OPTIONS_DATA}
@ -112,12 +109,7 @@ export const QueryBuilder = ({
value={data.type}
width="20%"
/>
<ActionIcon
icon="add"
onClick={handleAddRule}
size="sm"
variant="subtle"
/>
<ActionIcon icon="add" onClick={handleAddRule} size="sm" variant="subtle" />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<ActionIcon
@ -150,24 +142,14 @@ export const QueryBuilder = ({
<DropdownMenu.Divider />
<DropdownMenu.Item
isDanger
leftSection={
<Icon
color="error"
icon="refresh"
/>
}
leftSection={<Icon color="error" icon="refresh" />}
onClick={onResetFilters}
>
Reset to default
</DropdownMenu.Item>
<DropdownMenu.Item
isDanger
leftSection={
<Icon
color="error"
icon="delete"
/>
}
leftSection={<Icon color="error" icon="delete" />}
onClick={onClearFilters}
>
Clear filters

View file

@ -48,13 +48,7 @@ const QueryValueInput = ({ data, onChange, type, ...props }: any) => {
/>
);
case 'date':
return (
<TextInput
onChange={onChange}
size="sm"
{...props}
/>
);
return <TextInput onChange={onChange} size="sm" {...props} />;
case 'dateRange':
return (
<>
@ -92,21 +86,9 @@ const QueryValueInput = ({ data, onChange, type, ...props }: any) => {
/>
);
case 'playlist':
return (
<Select
data={data}
onChange={onChange}
{...props}
/>
);
return <Select data={data} onChange={onChange} {...props} />;
case 'string':
return (
<TextInput
onChange={onChange}
size="sm"
{...props}
/>
);
return <TextInput onChange={onChange} size="sm" {...props} />;
default:
return <></>;
@ -188,10 +170,7 @@ export const QueryBuilderOption = ({
const ml = (level + 1) * 10;
return (
<Group
gap="sm"
ml={ml}
>
<Group gap="sm" ml={ml}>
<Select
data={filters}
maxWidth={170}

View file

@ -81,10 +81,7 @@ export const DefaultCard = ({
data?.userFavorite && styles.isFavorite,
)}
>
<Image
className={styles.image}
src={data?.imageUrl}
/>
<Image className={styles.image} src={data?.imageUrl} />
<GridCardControls
handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
@ -95,10 +92,7 @@ export const DefaultCard = ({
/>
</div>
<div className={styles.detailContainer}>
<CardRows
data={data}
rows={controls.cardRows}
/>
<CardRows data={data} rows={controls.cardRows} />
</div>
</div>
</div>

View file

@ -86,10 +86,7 @@ export const GridCardControls = ({
onClick={handlePlay}
variant="filled"
>
<Icon
icon="mediaPlay"
size="xl"
/>
<Icon icon="mediaPlay" size="xl" />
</Button>
<div className={styles.bottomControls}>
{itemType !== LibraryItem.PLAYLIST && (

View file

@ -73,17 +73,11 @@ export const PosterCard = ({
margin: controls.itemGap,
}}
>
<div
className={styles.linkContainer}
onClick={() => navigate(path)}
>
<div className={styles.linkContainer} onClick={() => navigate(path)}>
<div
className={`${styles.imageContainer} ${data?.userFavorite ? styles.isFavorite : ''}`}
>
<Image
className={styles.image}
src={data?.imageUrl}
/>
<Image className={styles.image} src={data?.imageUrl} />
<GridCardControls
handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
@ -95,10 +89,7 @@ export const PosterCard = ({
</div>
</div>
<div className={styles.detailContainer}>
<CardRows
data={data}
rows={controls.cardRows}
/>
<CardRows data={data} rows={controls.cardRows} />
</div>
</div>
);

View file

@ -15,21 +15,14 @@ export const AlbumArtistCell = ({ data, value }: ICellRendererParams) => {
if (value === undefined) {
return (
<CellContainer position="left">
<Skeleton
height="1rem"
width="80%"
/>
<Skeleton height="1rem" width="80%" />
</CellContainer>
);
}
return (
<CellContainer position="left">
<Text
isMuted
overflow="hidden"
size="md"
>
<Text isMuted overflow="hidden" size="md">
{value?.map((item: AlbumArtist | Artist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && <Separator />}
@ -47,11 +40,7 @@ export const AlbumArtistCell = ({ data, value }: ICellRendererParams) => {
{item.name || '—'}
</Text>
) : (
<Text
isMuted
overflow="hidden"
size="md"
>
<Text isMuted overflow="hidden" size="md">
{item.name || '—'}
</Text>
)}

View file

@ -15,21 +15,14 @@ export const ArtistCell = ({ data, value }: ICellRendererParams) => {
if (value === undefined) {
return (
<CellContainer position="left">
<Skeleton
height="1rem"
width="80%"
/>
<Skeleton height="1rem" width="80%" />
</CellContainer>
);
}
return (
<CellContainer position="left">
<Text
isMuted
overflow="hidden"
size="md"
>
<Text isMuted overflow="hidden" size="md">
{value?.map((item: AlbumArtist | Artist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && <Separator />}
@ -47,11 +40,7 @@ export const ArtistCell = ({ data, value }: ICellRendererParams) => {
{item.name || '—'}
</Text>
) : (
<Text
isMuted
overflow="hidden"
size="md"
>
<Text isMuted overflow="hidden" size="md">
{item.name || '—'}
</Text>
)}

View file

@ -41,11 +41,7 @@ export const CombinedTitleCell = ({
>
<Skeleton className={styles.image} />
</div>
<Skeleton
className={styles.skeletonMetadata}
height="1rem"
width="80%"
/>
<Skeleton className={styles.skeletonMetadata} height="1rem" width="80%" />
</div>
);
}
@ -62,11 +58,7 @@ export const CombinedTitleCell = ({
width: `${(node.rowHeight || 40) - 10}px`,
}}
>
<Image
alt="cover"
className={styles.image}
src={value.imageUrl}
/>
<Image alt="cover" className={styles.image} src={value.imageUrl} />
<ListCoverControls
className={styles.playButton}
@ -77,18 +69,10 @@ export const CombinedTitleCell = ({
/>
</div>
<div className={styles.metadataWrapper}>
<Text
className="current-song-child"
overflow="hidden"
size="md"
>
<Text className="current-song-child" overflow="hidden" size="md">
{value.name}
</Text>
<Text
isMuted
overflow="hidden"
size="md"
>
<Text isMuted overflow="hidden" size="md">
{artists?.length ? (
artists.map((artist: AlbumArtist | Artist, index: number) => (
<React.Fragment key={`queue-${rowIndex}-artist-${artist.id}`}>

View file

@ -25,10 +25,7 @@ export const FullWidthDiscCell = ({ api, data, node }: ICellRendererParams) => {
return (
<div className={styles.container}>
<Group
justify="space-between"
w="100%"
>
<Group justify="space-between" w="100%">
<Button
leftSection={isSelected ? <Icon icon="squareCheck" /> : <Icon icon="square" />}
onClick={handleToggleDiscNodes}

View file

@ -23,10 +23,7 @@ export const GenericCell = ({ value, valueFormatted }: ICellRendererParams, opti
if (value === undefined) {
return (
<CellContainer position={position || 'left'}>
<Skeleton
height="1rem"
width="80%"
/>
<Skeleton height="1rem" width="80%" />
</CellContainer>
);
}
@ -45,12 +42,7 @@ export const GenericCell = ({ value, valueFormatted }: ICellRendererParams, opti
{isLink ? displayedValue.value : displayedValue}
</Text>
) : (
<Text
isMuted={!primary}
isNoSelect={false}
overflow="hidden"
size="md"
>
<Text isMuted={!primary} isNoSelect={false} overflow="hidden" size="md">
{displayedValue}
</Text>
)}

View file

@ -13,11 +13,7 @@ export const GenreCell = ({ data, value }: ICellRendererParams) => {
const genrePath = useGenreRoute();
return (
<CellContainer position="left">
<Text
isMuted
overflow="hidden"
size="md"
>
<Text isMuted overflow="hidden" size="md">
{value?.map((item: AlbumArtist | Artist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && <Separator />}

View file

@ -19,20 +19,14 @@ export const NoteCell = ({ value }: ICellRendererParams) => {
if (value === undefined) {
return (
<CellContainer position="left">
<Skeleton
height="1rem"
width="80%"
/>
<Skeleton height="1rem" width="80%" />
</CellContainer>
);
}
return (
<CellContainer position="left">
<Text
isMuted
overflow="hidden"
>
<Text isMuted overflow="hidden">
{formattedValue}
</Text>
</CellContainer>

View file

@ -26,11 +26,7 @@ export const RatingCell = ({ node, value }: ICellRendererParams) => {
return (
<CellContainer position="center">
<Rating
onChange={handleUpdateRating}
size="xs"
value={value?.userRating}
/>
<Rating onChange={handleUpdateRating} size="xs" value={value?.userRating} />
</CellContainer>
);
};

View file

@ -144,15 +144,9 @@ export const RowIndexCell = ({ eGridCell, value }: ICellRendererParams) => {
return (
<CellContainer position="right">
{isPlaying && isCurrentSong ? (
<Icon
fill="primary"
icon="mediaPlay"
/>
<Icon fill="primary" icon="mediaPlay" />
) : isCurrentSong ? (
<Icon
fill="primary"
icon="mediaPause"
/>
<Icon fill="primary" icon="mediaPause" />
) : (
<Text
className="current-song-child current-song-index"

View file

@ -8,21 +8,14 @@ export const TitleCell = ({ value }: ICellRendererParams) => {
if (value === undefined) {
return (
<CellContainer position="left">
<Skeleton
height="1rem"
width="80%"
/>
<Skeleton height="1rem" width="80%" />
</CellContainer>
);
}
return (
<CellContainer position="left">
<Text
className="current-song-child"
overflow="hidden"
size="md"
>
<Text className="current-song-child" overflow="hidden" size="md">
{value}
</Text>
</CellContainer>

View file

@ -7,10 +7,5 @@ export interface ICustomHeaderParams extends IHeaderParams {
}
export const DurationHeader = () => {
return (
<Icon
icon="duration"
size="sm"
/>
);
return <Icon icon="duration" size="sm" />;
};

View file

@ -16,36 +16,11 @@ type Options = {
type Presets = 'actions' | 'duration' | 'rowIndex' | 'userFavorite' | 'userRating';
const headerPresets = {
actions: (
<Icon
icon="ellipsisHorizontal"
size="sm"
/>
),
duration: (
<Icon
icon="duration"
size="sm"
/>
),
rowIndex: (
<Icon
icon="hash"
size="sm"
/>
),
userFavorite: (
<Icon
icon="favorite"
size="sm"
/>
),
userRating: (
<Icon
icon="star"
size="sm"
/>
),
actions: <Icon icon="ellipsisHorizontal" size="sm" />,
duration: <Icon icon="duration" size="sm" />,
rowIndex: <Icon icon="hash" size="sm" />,
userFavorite: <Icon icon="favorite" size="sm" />,
userRating: <Icon icon="star" size="sm" />,
};
export const GenericTableHeader = (

View file

@ -635,15 +635,8 @@ export const VirtualTable = forwardRef(
onNewColumnsLoaded={handleNewColumnsLoaded}
/>
{paginationProps && (
<AnimatePresence
initial={false}
mode="wait"
presenceAffectsLayout
>
<TablePagination
{...paginationProps}
tableRef={tableRef}
/>
<AnimatePresence initial={false} mode="wait" presenceAffectsLayout>
<TablePagination {...paginationProps} tableRef={tableRef} />
</AnimatePresence>
)}
</div>

View file

@ -76,10 +76,7 @@ export const TablePagination = ({
ref={containerQuery.ref}
style={{ borderTop: '1px solid var(--theme-generic-border-color)' }}
>
<Text
isMuted
size="md"
>
<Text isMuted size="md">
{containerQuery.isMd ? (
<>
Showing <b>{currentPageStartIndex}</b> - <b>{currentPageStopIndex}</b> of{' '}
@ -97,11 +94,7 @@ export const TablePagination = ({
</>
)}
</Text>
<Group
gap="sm"
ref={containerQuery.ref}
wrap="nowrap"
>
<Group gap="sm" ref={containerQuery.ref} wrap="nowrap">
<Popover
onClose={() => handlers.close()}
opened={isGoToPageOpen}
@ -127,10 +120,7 @@ export const TablePagination = ({
min={1}
width={70}
/>
<Button
type="submit"
variant="filled"
>
<Button type="submit" variant="filled">
Go
</Button>
</Group>

View file

@ -13,15 +13,8 @@ interface ActionRequiredContainerProps {
export const ActionRequiredContainer = ({ children, title }: ActionRequiredContainerProps) => (
<Stack style={{ cursor: 'default', maxWidth: '700px' }}>
<Group>
<Icon
fill="warn"
icon="warn"
size="lg"
/>
<Text
size="xl"
style={{ textTransform: 'uppercase' }}
>
<Icon fill="warn" icon="warn" size="lg" />
<Text size="xl" style={{ textTransform: 'uppercase' }}>
{title}
</Text>
</Group>

View file

@ -21,18 +21,11 @@ export const ErrorFallback = ({ resetErrorBoundary }: FallbackProps) => {
<Center style={{ height: '100vh' }}>
<Stack style={{ maxWidth: '50%' }}>
<Group gap="xs">
<Icon
fill="error"
icon="error"
size="lg"
/>
<Icon fill="error" icon="error" size="lg" />
<Text size="lg">{t('error.genericError')}</Text>
</Group>
<Text>{error?.message}</Text>
<Button
onClick={resetErrorBoundary}
variant="filled"
>
<Button onClick={resetErrorBoundary} variant="filled">
{t('common.reload')}
</Button>
</Stack>

View file

@ -43,18 +43,11 @@ export const MpvRequired = () => {
<Text>Set your MPV executable location below and restart the application.</Text>
<Text>
MPV is available at the following:{' '}
<a
href="https://mpv.io/installation/"
rel="noreferrer"
target="_blank"
>
<a href="https://mpv.io/installation/" rel="noreferrer" target="_blank">
https://mpv.io/
</a>
</Text>
<FileInput
disabled={disabled}
onChange={handleSetMpvPath}
/>
<FileInput disabled={disabled} onChange={handleSetMpvPath} />
<Text>{t('setting.disable_mpv', { context: 'description' })}</Text>
<Checkbox
label={t('setting.disableMpv')}

View file

@ -42,19 +42,12 @@ const RouteErrorBoundary = () => {
px={10}
variant="subtle"
/>
<Icon
fill="error"
icon="error"
size="lg"
/>
<Icon fill="error" icon="error" size="lg" />
<Text size="lg">{t('error.genericError')}</Text>
</Group>
<Divider my={5} />
<Text size="sm">{error?.message}</Text>
<Group
gap="sm"
grow
>
<Group gap="sm" grow>
<Button
leftSection={<Icon icon="home" />}
onClick={handleHome}
@ -81,11 +74,7 @@ const RouteErrorBoundary = () => {
</DropdownMenu>
</Group>
<Group grow>
<Button
onClick={handleReload}
size="md"
variant="filled"
>
<Button onClick={handleReload} size="md" variant="filled">
{t('common.reload')}
</Button>
</Group>

View file

@ -132,10 +132,7 @@ function ServerSelector() {
}}
variant={server.id === currentServer?.id ? 'filled' : 'default'}
>
<Group
justify="space-between"
w="100%"
>
<Group justify="space-between" w="100%">
<Group>
<img
src={logo}
@ -144,10 +141,7 @@ function ServerSelector() {
width: 'var(--theme-font-size-2xl)',
}}
/>
<Text
fw={600}
size="lg"
>
<Text fw={600} size="lg">
{server.name}
</Text>
</Group>

View file

@ -49,10 +49,7 @@ const ActionRequiredRoute = () => {
<AnimatedPage>
<PageHeader />
<Center style={{ height: '100%', width: '100vw' }}>
<Stack
gap="xl"
style={{ maxWidth: '50%' }}
>
<Stack gap="xl" style={{ maxWidth: '50%' }}>
<Group wrap="nowrap">
{displayedCheck && (
<ActionRequiredContainer title={displayedCheck.title}>
@ -64,10 +61,7 @@ const ActionRequiredRoute = () => {
{canReturnHome && <Navigate to={AppRoute.HOME} />}
{/* This should be displayed if a credential is required */}
{isCredentialRequired && (
<Group
justify="center"
wrap="nowrap"
>
<Group justify="center" wrap="nowrap">
<Button
fullWidth
leftSection={<Icon icon="edit" />}

View file

@ -18,24 +18,14 @@ const InvalidRoute = () => {
<AnimatedPage>
<Center style={{ height: '100%', width: '100%' }}>
<Stack>
<Group
justify="center"
wrap="nowrap"
>
<Icon
color="warn"
icon="error"
/>
<Group justify="center" wrap="nowrap">
<Icon color="warn" icon="error" />
<Text size="xl">
{t('error.apiRouteError', { postProcess: 'sentenceCase' })}
</Text>
</Group>
<Text>{location.pathname}</Text>
<ActionIcon
icon="arrowLeftS"
onClick={() => navigate(-1)}
variant="filled"
/>
<ActionIcon icon="arrowLeftS" onClick={() => navigate(-1)} variant="filled" />
</Stack>
</Center>
</AnimatedPage>

View file

@ -319,17 +319,11 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP
const mbzId = detailQuery?.data?.mbzId;
return (
<div
className={styles.contentContainer}
ref={cq.ref}
>
<div className={styles.contentContainer} ref={cq.ref}>
<LibraryBackgroundOverlay backgroundColor={background} />
<div className={styles.detailContainer}>
<section>
<Group
gap="sm"
justify="space-between"
>
<Group gap="sm" justify="space-between">
<Group>
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<Group gap="xs">
@ -485,11 +479,7 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP
suppressRowDrag
/>
</div>
<Stack
gap="lg"
mt="3rem"
ref={cq.ref}
>
<Stack gap="lg" mt="3rem" ref={cq.ref}>
{cq.height || cq.width ? (
<>
{carousels

View file

@ -33,15 +33,9 @@ export const AlbumListContent = ({ gridRef, itemCount, tableRef }: AlbumListCont
return (
<Suspense fallback={<Spinner container />}>
{display === ListDisplayType.CARD || display === ListDisplayType.GRID ? (
<AlbumListGridView
gridRef={gridRef}
itemCount={itemCount}
/>
<AlbumListGridView gridRef={gridRef} itemCount={itemCount} />
) : (
<AlbumListTableView
itemCount={itemCount}
tableRef={tableRef}
/>
<AlbumListTableView itemCount={itemCount} tableRef={tableRef} />
)}
</Suspense>
);

View file

@ -448,11 +448,7 @@ export const AlbumListHeaderFilters = ({
return (
<Flex justify="space-between">
<Group
gap="sm"
ref={cq.ref}
w="100%"
>
<Group gap="sm" ref={cq.ref} w="100%">
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button variant="subtle">{sortByLabel}</Button>
@ -471,10 +467,7 @@ export const AlbumListHeaderFilters = ({
</DropdownMenu.Dropdown>
</DropdownMenu>
<Divider orientation="vertical" />
<OrderToggleButton
onToggle={handleToggleSortOrder}
sortOrder={filter.sortOrder}
/>
<OrderToggleButton onToggle={handleToggleSortOrder} sortOrder={filter.sortOrder} />
{server?.type === ServerType.JELLYFIN && (
<>
<Divider orientation="vertical" />
@ -497,10 +490,7 @@ export const AlbumListHeaderFilters = ({
</DropdownMenu>
</>
)}
<FilterButton
isActive={!!isFilterApplied}
onClick={handleOpenFiltersModal}
/>
<FilterButton isActive={!!isFilterApplied} onClick={handleOpenFiltersModal} />
<RefreshButton onClick={handleRefresh} />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
@ -535,10 +525,7 @@ export const AlbumListHeaderFilters = ({
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group
gap="sm"
wrap="nowrap"
>
<Group gap="sm" wrap="nowrap">
<ListConfigMenu
autoFitColumns={table.autoFit}
disabledViewTypes={[ListDisplayType.LIST]}

View file

@ -61,15 +61,9 @@ export const AlbumListHeader = ({
}, [filter, genreId, refresh, tableRef]);
return (
<Stack
gap={0}
ref={cq.ref}
>
<Stack gap={0} ref={cq.ref}>
<PageHeader backgroundColor="var(--theme-colors-background)">
<Flex
justify="space-between"
w="100%"
>
<Flex justify="space-between" w="100%">
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton
onClick={() => handlePlay?.({ playType: playButtonBehavior })}
@ -85,10 +79,7 @@ export const AlbumListHeader = ({
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
onChange={handleSearch}
/>
<SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} />
</Group>
</Flex>
</PageHeader>

View file

@ -227,16 +227,9 @@ export const JellyfinAlbumFilters = ({
return (
<Stack p="0.8rem">
{yesNoFilter.map((filter) => (
<Group
justify="space-between"
key={`nd-filter-${filter.label}`}
>
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
<Text>{filter.label}</Text>
<YesNoSelect
onChange={filter.onChange}
size="xs"
value={filter.value}
/>
<YesNoSelect onChange={filter.onChange} size="xs" value={filter.value} />
</Group>
))}
<Divider my="0.5rem" />

View file

@ -248,28 +248,15 @@ export const NavidromeAlbumFilters = ({
return (
<Stack p="0.8rem">
{yesNoUndefinedFilters.map((filter) => (
<Group
justify="space-between"
key={`nd-filter-${filter.label}`}
>
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
<Text>{filter.label}</Text>
<YesNoSelect
onChange={filter.onChange}
size="xs"
value={filter.value}
/>
<YesNoSelect onChange={filter.onChange} size="xs" value={filter.value} />
</Group>
))}
{toggleFilters.map((filter) => (
<Group
justify="space-between"
key={`nd-filter-${filter.label}`}
>
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
<Text>{filter.label}</Text>
<Switch
checked={filter?.value || false}
onChange={filter.onChange}
/>
<Switch checked={filter?.value || false} onChange={filter.onChange} />
</Group>
))}
<Divider my="0.5rem" />
@ -307,10 +294,7 @@ export const NavidromeAlbumFilters = ({
{tagsQuery.data?.enumTags?.length &&
tagsQuery.data.enumTags.length > 0 &&
tagsQuery.data.enumTags.map((tag) => (
<Group
grow
key={tag.name}
>
<Group grow key={tag.name}>
<SelectWithInvalidData
clearable
data={tag.options}

View file

@ -148,15 +148,9 @@ export const SubsonicAlbumFilters = ({
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
<Group
justify="space-between"
key={`nd-filter-${filter.label}`}
>
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
<Text>{filter.label}</Text>
<Switch
checked={filter?.value || false}
onChange={filter.onChange}
/>
<Switch checked={filter?.value || false} onChange={filter.onChange} />
</Group>
))}
<Divider my="0.5rem" />

View file

@ -70,10 +70,7 @@ const AlbumDetailRoute = () => {
}}
ref={headerRef}
/>
<AlbumDetailContent
background={background}
tableRef={tableRef}
/>
<AlbumDetailContent background={background} tableRef={tableRef} />
</NativeScrollArea>
</AnimatedPage>
);

View file

@ -144,11 +144,7 @@ const AlbumListRoute = () => {
tableRef={tableRef}
title={title}
/>
<AlbumListContent
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
<AlbumListContent gridRef={gridRef} itemCount={itemCount} tableRef={tableRef} />
</ListContext.Provider>
</AnimatedPage>
);

View file

@ -174,10 +174,7 @@ const DummyAlbumDetailRoute = () => {
</Stack>
<div className={styles.detailContainer}>
<section>
<Group
gap="sm"
justify="space-between"
>
<Group gap="sm" justify="space-between">
<Group>
<PlayButton onClick={() => handlePlay()} />
<ActionIcon
@ -231,11 +228,7 @@ const DummyAlbumDetailRoute = () => {
<section>
<Center>
<Group mr={5}>
<Icon
fill="error"
icon="error"
size={30}
/>
<Icon fill="error" icon="error" size={30} />
</Group>
<h2>{t('error.badAlbum', { postProcess: 'sentenceCase' })}</h2>
</Center>

View file

@ -202,10 +202,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
order: itemOrder.recentAlbums,
title: (
<Group align="flex-end">
<TextTitle
fw={700}
order={2}
>
<TextTitle fw={700} order={2}>
{t('page.albumArtistDetail.recentReleases', {
postProcess: 'sentenceCase',
})}
@ -232,10 +229,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
loading: compilationAlbumsQuery?.isLoading || compilationAlbumsQuery.isFetching,
order: itemOrder.compilations,
title: (
<TextTitle
fw={700}
order={2}
>
<TextTitle fw={700} order={2}>
{t('page.albumArtistDetail.appearsOn', { postProcess: 'sentenceCase' })}
</TextTitle>
),
@ -247,10 +241,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
itemType: LibraryItem.ALBUM_ARTIST,
order: itemOrder.similarArtists,
title: (
<TextTitle
fw={700}
order={2}
>
<TextTitle fw={700} order={2}>
{t('page.albumArtistDetail.relatedArtists', {
postProcess: 'sentenceCase',
})}
@ -355,19 +346,10 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
detailQuery?.isLoading ||
(server?.type === ServerType.NAVIDROME && enabledItem.topSongs && topSongsQuery?.isLoading);
if (isLoading)
return (
<div
className={styles.contentContainer}
ref={cq.ref}
/>
);
if (isLoading) return <div className={styles.contentContainer} ref={cq.ref} />;
return (
<div
className={styles.contentContainer}
ref={cq.ref}
>
<div className={styles.contentContainer} ref={cq.ref}>
<LibraryBackgroundOverlay backgroundColor={background} />
<div className={styles.detailContainer}>
<Group gap="md">
@ -481,15 +463,9 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
) : null}
<Grid gutter="xl">
{biography ? (
<Grid.Col
order={itemOrder.biography}
span={12}
>
<Grid.Col order={itemOrder.biography} span={12}>
<section style={{ maxWidth: '1280px' }}>
<TextTitle
fw={700}
order={2}
>
<TextTitle fw={700} order={2}>
{t('page.albumArtistDetail.about', {
artist: detailQuery?.data?.name,
})}
@ -499,23 +475,11 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
</Grid.Col>
) : null}
{showTopSongs ? (
<Grid.Col
order={itemOrder.topSongs}
span={12}
>
<Grid.Col order={itemOrder.topSongs} span={12}>
<section>
<Group
justify="space-between"
wrap="nowrap"
>
<Group
align="flex-end"
wrap="nowrap"
>
<TextTitle
fw={700}
order={2}
>
<Group justify="space-between" wrap="nowrap">
<Group align="flex-end" wrap="nowrap">
<TextTitle fw={700} order={2}>
{t('page.albumArtistDetail.topSongs', {
postProcess: 'sentenceCase',
})}

View file

@ -42,15 +42,9 @@ export const AlbumArtistListContent = ({
return (
<Suspense fallback={<Spinner container />}>
{isGrid ? (
<AlbumArtistListGridView
gridRef={gridRef}
itemCount={itemCount}
/>
<AlbumArtistListGridView gridRef={gridRef} itemCount={itemCount} />
) : (
<AlbumArtistListTableView
itemCount={itemCount}
tableRef={tableRef}
/>
<AlbumArtistListTableView itemCount={itemCount} tableRef={tableRef} />
)}
</Suspense>
);

View file

@ -372,11 +372,7 @@ export const AlbumArtistListHeaderFilters = ({
return (
<Flex justify="space-between">
<Group
gap="sm"
ref={cq.ref}
w="100%"
>
<Group gap="sm" ref={cq.ref} w="100%">
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button variant="subtle">{sortByLabel}</Button>
@ -395,10 +391,7 @@ export const AlbumArtistListHeaderFilters = ({
</DropdownMenu.Dropdown>
</DropdownMenu>
<Divider orientation="vertical" />
<OrderToggleButton
onToggle={handleToggleSortOrder}
sortOrder={filter.sortOrder}
/>
<OrderToggleButton onToggle={handleToggleSortOrder} sortOrder={filter.sortOrder} />
{server?.type === ServerType.JELLYFIN && (
<>
<DropdownMenu position="bottom-start">
@ -437,10 +430,7 @@ export const AlbumArtistListHeaderFilters = ({
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group
gap="sm"
wrap="nowrap"
>
<Group gap="sm" wrap="nowrap">
<ListConfigMenu
autoFitColumns={table.autoFit}
disabledViewTypes={[ListDisplayType.LIST]}

View file

@ -46,15 +46,9 @@ export const AlbumArtistListHeader = ({
}, 500);
return (
<Stack
gap={0}
ref={cq.ref}
>
<Stack gap={0} ref={cq.ref}>
<PageHeader>
<Flex
justify="space-between"
w="100%"
>
<Flex justify="space-between" w="100%">
<LibraryHeaderBar>
<LibraryHeaderBar.Title>
{t('page.albumArtistList.title', { postProcess: 'titleCase' })}
@ -66,18 +60,12 @@ export const AlbumArtistListHeader = ({
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
onChange={handleSearch}
/>
<SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} />
</Group>
</Flex>
</PageHeader>
<FilterBar>
<AlbumArtistListHeaderFilters
gridRef={gridRef}
tableRef={tableRef}
/>
<AlbumArtistListHeaderFilters gridRef={gridRef} tableRef={tableRef} />
</FilterBar>
</Stack>
);

View file

@ -34,15 +34,9 @@ export const ArtistListContent = ({ gridRef, itemCount, tableRef }: ArtistListCo
return (
<Suspense fallback={<Spinner container />}>
{isGrid ? (
<ArtistListGridView
gridRef={gridRef}
itemCount={itemCount}
/>
<ArtistListGridView gridRef={gridRef} itemCount={itemCount} />
) : (
<ArtistListTableView
itemCount={itemCount}
tableRef={tableRef}
/>
<ArtistListTableView itemCount={itemCount} tableRef={tableRef} />
)}
</Suspense>
);

View file

@ -388,11 +388,7 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
return (
<Flex justify="space-between">
<Group
gap="sm"
ref={cq.ref}
w="100%"
>
<Group gap="sm" ref={cq.ref} w="100%">
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button variant="subtle">{sortByLabel}</Button>
@ -411,19 +407,13 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
</DropdownMenu.Dropdown>
</DropdownMenu>
<Divider orientation="vertical" />
<OrderToggleButton
onToggle={handleToggleSortOrder}
sortOrder={filter.sortOrder}
/>
<OrderToggleButton onToggle={handleToggleSortOrder} sortOrder={filter.sortOrder} />
{server?.type === ServerType.JELLYFIN && (
<>
<Divider orientation="vertical" />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<ActionIcon
icon="folder"
variant="subtle"
/>
<ActionIcon icon="folder" variant="subtle" />
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{musicFoldersQuery.data?.items.map((folder) => (
@ -442,11 +432,7 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
)}
{roles.data?.length && (
<>
<Select
data={roles.data}
onChange={handleSetRole}
value={filter.role}
/>
<Select data={roles.data} onChange={handleSetRole} value={filter.role} />
</>
)}
<RefreshButton onClick={handleRefresh} />
@ -466,10 +452,7 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group
gap="xs"
wrap="nowrap"
>
<Group gap="xs" wrap="nowrap">
<ListConfigMenu
autoFitColumns={table.autoFit}
displayType={display}

View file

@ -42,15 +42,9 @@ export const ArtistListHeader = ({ gridRef, itemCount, tableRef }: ArtistListHea
}, 500);
return (
<Stack
gap={0}
ref={cq.ref}
>
<Stack gap={0} ref={cq.ref}>
<PageHeader>
<Flex
justify="space-between"
w="100%"
>
<Flex justify="space-between" w="100%">
<LibraryHeaderBar>
<LibraryHeaderBar.Title>
{t('entity.artist_other', { postProcess: 'titleCase' })}
@ -62,18 +56,12 @@ export const ArtistListHeader = ({ gridRef, itemCount, tableRef }: ArtistListHea
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
onChange={handleSearch}
/>
<SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} />
</Group>
</Flex>
</PageHeader>
<FilterBar>
<ArtistListHeaderFilters
gridRef={gridRef}
tableRef={tableRef}
/>
<ArtistListHeaderFilters gridRef={gridRef} tableRef={tableRef} />
</FilterBar>
</Stack>
);

View file

@ -41,16 +41,8 @@ const ArtistListRoute = () => {
return (
<AnimatedPage>
<ListContext.Provider value={providerValue}>
<ArtistListHeader
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
<ArtistListContent
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
<ArtistListHeader gridRef={gridRef} itemCount={itemCount} tableRef={tableRef} />
<ArtistListContent gridRef={gridRef} itemCount={itemCount} tableRef={tableRef} />
</ListContext.Provider>
</AnimatedPage>
);

View file

@ -533,10 +533,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
openModal({
children: (
<ConfirmModal
loading={removeFromPlaylistMutation.isLoading}
onConfirm={confirm}
>
<ConfirmModal loading={removeFromPlaylistMutation.isLoading} onConfirm={confirm}>
{t('common.areYouSure', { postProcess: 'sentenceCase' })}
</ConfirmModal>
),
@ -922,26 +919,15 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
<Portal>
<AnimatePresence>
{opened && (
<ContextMenu
minWidth={125}
ref={mergedRef}
xPos={ctx.xPos}
yPos={ctx.yPos}
>
<ContextMenu minWidth={125} ref={mergedRef} xPos={ctx.xPos} yPos={ctx.yPos}>
<Stack gap={0}>
<Stack
gap={0}
onClick={closeContextMenu}
>
<Stack gap={0} onClick={closeContextMenu}>
{ctx.menuItems?.map((item) => {
return (
!contextMenuItems[item.id].disabled && (
<Fragment key={`context-menu-${item.id}`}>
{item.children ? (
<HoverCard
offset={0}
position="right"
>
<HoverCard offset={0} position="right">
<HoverCard.Target>
<ContextMenuButton
leftIcon={

View file

@ -1,10 +1,12 @@
import { SetActivity } from '@xhayper/discord-rpc';
import { SetActivity, StatusDisplayType } from '@xhayper/discord-rpc';
import isElectron from 'is-electron';
import { useCallback, useEffect, useState } from 'react';
import { controller } from '/@/renderer/api/controller';
import {
DiscordDisplayType,
getServerById,
useAppStore,
useDiscordSetttings,
useGeneralSettings,
usePlayerStore,
@ -17,6 +19,7 @@ const discordRpc = isElectron() ? window.api.discordRpc : null;
export const useDiscordRpc = () => {
const discordSettings = useDiscordSetttings();
const generalSettings = useGeneralSettings();
const { privateMode } = useAppStore();
const [lastUniqueId, setlastUniqueId] = useState('');
const setActivity = useCallback(
@ -26,10 +29,8 @@ export const useDiscordRpc = () => {
) => {
if (
!current[0] || // No track
(current[0] &&
current[2] === 'paused' && // Track paused
(discordSettings.showPaused ? current[1] === 0 : true)) || // Beginning of track (only if show paused setting enabled)
(discordSettings.showPaused ? false : current[1] === 0) // Beginning of track (only if show paused setting disabled)
current[1] === 0 || // Start of track
(current[2] === 'paused' && !discordSettings.showPaused) // Track paused with show paused setting disabled
)
return discordRpc?.clearActivity();
@ -38,11 +39,13 @@ export const useDiscordRpc = () => {
const trackChanged = lastUniqueId !== song.uniqueId;
/*
1. If we jump more then 1.2 seconds from last state, update status to match
2. If the current song id is completely different, update status
3. If the player state changed, update status
1. If the song has just started, update status
2. If we jump more then 1.2 seconds from last state, update status to match
3. If the current song id is completely different, update status
4. If the player state changed, update status
*/
if (
previous[1] === 0 ||
Math.abs((current[1] as number) - (previous[1] as number)) > 1.2 ||
trackChanged ||
current[2] !== previous[2]
@ -54,6 +57,12 @@ export const useDiscordRpc = () => {
const artists = song?.artists.map((artist) => artist.name).join(', ');
const statusDisplayMap = {
[DiscordDisplayType.ARTIST_NAME]: StatusDisplayType.STATE,
[DiscordDisplayType.FEISHIN]: StatusDisplayType.NAME,
[DiscordDisplayType.SONG_NAME]: StatusDisplayType.DETAILS,
};
const activity: SetActivity = {
details: song?.name.padEnd(2, ' ') || 'Idle',
instance: false,
@ -61,7 +70,8 @@ export const useDiscordRpc = () => {
largeImageText: song?.album || 'Unknown album',
smallImageKey: undefined,
smallImageText: current[2] as string,
state: (artists && `By ${artists}`) || 'Unknown artist',
state: artists || 'Unknown artist',
statusDisplayType: statusDisplayMap[discordSettings.displayType],
// I would love to use the actual type as opposed to hardcoding to 2,
// but manually installing the discord-types package appears to break things
type: discordSettings.showAsListening ? 2 : 0,
@ -134,20 +144,21 @@ export const useDiscordRpc = () => {
discordSettings.showPaused,
generalSettings.lastfmApiKey,
discordSettings.clientId,
discordSettings.displayType,
lastUniqueId,
],
);
useEffect(() => {
if (!discordSettings.enabled) return discordRpc?.quit();
if (!discordSettings.enabled || privateMode) return discordRpc?.quit();
return () => {
discordRpc?.quit();
};
}, [discordSettings.clientId, discordSettings.enabled]);
}, [discordSettings.clientId, privateMode, discordSettings.enabled]);
useEffect(() => {
if (!discordSettings.enabled) return;
if (!discordSettings.enabled || privateMode) return;
const unsubSongChange = usePlayerStore.subscribe(
(state) => [state.current.song, state.current.time, state.current.status],
setActivity,
@ -155,5 +166,5 @@ export const useDiscordRpc = () => {
return () => {
unsubSongChange();
};
}, [discordSettings.enabled, setActivity]);
}, [discordSettings.enabled, privateMode, setActivity]);
};

View file

@ -33,15 +33,9 @@ export const GenreListContent = ({ gridRef, itemCount, tableRef }: AlbumListCont
return (
<Suspense fallback={<Spinner container />}>
{display === ListDisplayType.CARD || display === ListDisplayType.GRID ? (
<GenreListGridView
gridRef={gridRef}
itemCount={itemCount}
/>
<GenreListGridView gridRef={gridRef} itemCount={itemCount} />
) : (
<GenreListTableView
itemCount={itemCount}
tableRef={tableRef}
/>
<GenreListTableView itemCount={itemCount} tableRef={tableRef} />
)}
</Suspense>
);

View file

@ -254,11 +254,7 @@ export const GenreListHeaderFilters = ({
return (
<Flex justify="space-between">
<Group
gap="sm"
ref={cq.ref}
w="100%"
>
<Group gap="sm" ref={cq.ref} w="100%">
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button variant="subtle">{sortByLabel}</Button>
@ -277,10 +273,7 @@ export const GenreListHeaderFilters = ({
</DropdownMenu.Dropdown>
</DropdownMenu>
<Divider orientation="vertical" />
<OrderToggleButton
onToggle={handleToggleSortOrder}
sortOrder={filter.sortOrder}
/>
<OrderToggleButton onToggle={handleToggleSortOrder} sortOrder={filter.sortOrder} />
{server?.type === ServerType.JELLYFIN && (
<>
<Divider orientation="vertical" />
@ -340,10 +333,7 @@ export const GenreListHeaderFilters = ({
</Button>
</DropdownMenu>
</Group>
<Group
gap="sm"
wrap="nowrap"
>
<Group gap="sm" wrap="nowrap">
<ListConfigMenu
autoFitColumns={table.autoFit}
disabledViewTypes={[ListDisplayType.LIST]}

View file

@ -40,15 +40,9 @@ export const GenreListHeader = ({ gridRef, itemCount, tableRef }: GenreListHeade
}, 500);
return (
<Stack
gap={0}
ref={cq.ref}
>
<Stack gap={0} ref={cq.ref}>
<PageHeader>
<Flex
justify="space-between"
w="100%"
>
<Flex justify="space-between" w="100%">
<LibraryHeaderBar>
<LibraryHeaderBar.Title>
{t('page.genreList.title', { postProcess: 'titleCase' })}
@ -60,10 +54,7 @@ export const GenreListHeader = ({ gridRef, itemCount, tableRef }: GenreListHeade
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
onChange={handleSearch}
/>
<SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} />
</Group>
</Flex>
</PageHeader>

View file

@ -42,16 +42,8 @@ const GenreListRoute = () => {
return (
<AnimatedPage>
<ListContext.Provider value={providerValue}>
<GenreListHeader
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
<GenreListContent
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
<GenreListHeader gridRef={gridRef} itemCount={itemCount} tableRef={tableRef} />
<GenreListContent gridRef={gridRef} itemCount={itemCount} tableRef={tableRef} />
</ListContext.Provider>
</AnimatedPage>
);

View file

@ -81,10 +81,7 @@ const formatArtists = (artists: null | RelatedArtist[] | undefined) =>
{artist.name || '—'}
</Text>
) : (
<Text
overflow="visible"
size="md"
>
<Text overflow="visible" size="md">
{artist.name || '-'}
</Text>
)}
@ -119,17 +116,7 @@ const FormatGenre = (item: Album | AlbumArtist | Playlist | Song) => {
};
const BoolField = (key: boolean) =>
key ? (
<Icon
color="success"
icon="check"
/>
) : (
<Icon
color="error"
icon="x"
/>
);
key ? <Icon color="success" icon="check" /> : <Icon color="error" icon="x" />;
const AlbumPropertyMapping: ItemDetailRow<Album>[] = [
{ key: 'name', label: 'common.title' },
@ -287,6 +274,8 @@ const SongPropertyMapping: ItemDetailRow<Song>[] = [
{ label: 'filter.isCompilation', render: (song) => BoolField(song.compilation || false) },
{ key: 'container', label: 'common.codec' },
{ key: 'bitRate', label: 'common.bitrate', render: (song) => `${song.bitRate} kbps` },
{ key: 'sampleRate', label: 'common.sampleRate' },
{ key: 'bitDepth', label: 'common.bitDepth' },
{ key: 'channels', label: 'common.channel_other' },
{ key: 'size', label: 'common.size', render: (song) => formatSizeString(song.size) },
{
@ -409,12 +398,7 @@ export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => {
}
return (
<Table
highlightOnHover
variant="vertical"
withRowBorders={false}
withTableBorder
>
<Table highlightOnHover variant="vertical" withRowBorders={false} withTableBorder>
<Table.Tbody>{body}</Table.Tbody>
</Table>
);

View file

@ -22,10 +22,7 @@ export const SongPath = ({ path }: SongPathProps) => {
return (
<Group>
<CopyButton
timeout={2000}
value={path}
>
<CopyButton timeout={2000} value={path}>
{({ copied, copy }) => (
<Tooltip
label={t(
@ -36,10 +33,7 @@ export const SongPath = ({ path }: SongPathProps) => {
)}
withinPortal
>
<ActionIcon
onClick={copy}
variant="transparent"
>
<ActionIcon onClick={copy} variant="transparent">
{copied ? <Icon icon="check" /> : <Icon icon="clipboardCopy" />}
</ActionIcon>
</Tooltip>

View file

@ -38,33 +38,15 @@ const SearchResult = ({ data, onClick }: SearchResultProps) => {
source === LyricSource.GENIUS ? id.replace(/^((http[s]?|ftp):\/)?\/?([^:/\s]+)/g, '') : id;
return (
<button
className={styles.searchItem}
onClick={onClick}
>
<Group
justify="space-between"
wrap="nowrap"
>
<Stack
gap={0}
maw="65%"
>
<Text
fw={600}
size="md"
>
<button className={styles.searchItem} onClick={onClick}>
<Group justify="space-between" wrap="nowrap">
<Stack gap={0} maw="65%">
<Text fw={600} size="md">
{name}
</Text>
<Text isMuted>{artist}</Text>
<Group
gap="sm"
wrap="nowrap"
>
<Text
isMuted
size="sm"
>
<Group gap="sm" wrap="nowrap">
<Text isMuted size="sm">
{[source, cleanId].join(' — ')}
</Text>
</Group>
@ -167,11 +149,7 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
export const openLyricSearchModal = ({ artist, name, onSearchOverride }: LyricSearchFormProps) => {
openModal({
children: (
<LyricsSearchForm
artist={artist}
name={name}
onSearchOverride={onSearchOverride}
/>
<LyricsSearchForm artist={artist} name={name} onSearchOverride={onSearchOverride} />
),
size: 'lg',
title: i18n.t('form.lyricSearch.title', { postProcess: 'titleCase' }) as string,

View file

@ -151,10 +151,7 @@ export const Lyrics = () => {
<ErrorBoundary FallbackComponent={ErrorFallback}>
<div className={styles.lyricsContainer}>
{isLoadingLyrics ? (
<Spinner
container
size={25}
/>
<Spinner container size={25} />
) : (
<AnimatePresence mode="sync">
{hasNoLyrics ? (

View file

@ -29,10 +29,7 @@ export const UnsynchronizedLyrics = ({
}, [translatedLyrics]);
return (
<div
className={styles.container}
style={{ gap: `${settings.gapUnsync}px` }}
>
<div className={styles.container} style={{ gap: `${settings.gapUnsync}px` }}>
{settings.showProvider && source && (
<LyricLine
alignment={settings.alignment}

View file

@ -11,30 +11,17 @@ export const DrawerPlayQueue = () => {
const queueRef = useRef<null | { grid: AgGridReactType<Song> }>(null);
return (
<Flex
direction="column"
h="100%"
>
<Flex direction="column" h="100%">
<div
style={{
backgroundColor: 'var(--theme-colors-background)',
borderRadius: '10px',
}}
>
<PlayQueueListControls
tableRef={queueRef}
type="sideQueue"
/>
<PlayQueueListControls tableRef={queueRef} type="sideQueue" />
</div>
<Flex
bg="var(--theme-colors-background)"
h="100%"
mb="0.6rem"
>
<PlayQueue
ref={queueRef}
type="sideQueue"
/>
<Flex bg="var(--theme-colors-background)" h="100%" mb="0.6rem">
<PlayQueue ref={queueRef} type="sideQueue" />
</Flex>
</Flex>
);

View file

@ -174,10 +174,7 @@ export const PlayQueueListControls = ({ tableRef, type }: PlayQueueListOptionsPr
/>
</Group>
<Group>
<Popover
position="top-end"
transitionProps={{ transition: 'fade' }}
>
<Popover position="top-end" transitionProps={{ transition: 'fade' }}>
<Popover.Target>
<ActionIcon
icon="settings"

View file

@ -18,19 +18,10 @@ export const SidebarPlayQueue = () => {
const isWeb = windowBarStyle === Platform.WEB;
return (
<VirtualGridContainer>
<Box
display={!isWeb ? 'flex' : undefined}
h="65px"
>
<PlayQueueListControls
tableRef={queueRef}
type="sideQueue"
/>
<Box display={!isWeb ? 'flex' : undefined} h="65px">
<PlayQueueListControls tableRef={queueRef} type="sideQueue" />
</Box>
<PlayQueue
ref={queueRef}
type="sideQueue"
/>
<PlayQueue ref={queueRef} type="sideQueue" />
</VirtualGridContainer>
);
};

View file

@ -16,14 +16,8 @@ const NowPlayingRoute = () => {
<AnimatedPage>
<VirtualGridContainer>
<NowPlayingHeader />
<PlayQueueListControls
tableRef={queueRef}
type="nowPlaying"
/>
<PlayQueue
ref={queueRef}
type="nowPlaying"
/>
<PlayQueueListControls tableRef={queueRef} type="nowPlaying" />
<PlayQueue ref={queueRef} type="nowPlaying" />
</VirtualGridContainer>
</AnimatedPage>
);

View file

@ -115,13 +115,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
<div className={styles.controlsContainer}>
<div className={styles.buttonsContainer}>
<PlayerButton
icon={
<Icon
fill="default"
icon="mediaStop"
size={buttonSize - 2}
/>
}
icon={<Icon fill="default" icon="mediaStop" size={buttonSize - 2} />}
onClick={handleStop}
tooltip={{
label: t('player.stop', { postProcess: 'sentenceCase' }),
@ -152,13 +146,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
variant="tertiary"
/>
<PlayerButton
icon={
<Icon
fill="default"
icon="mediaPrevious"
size={buttonSize}
/>
}
icon={<Icon fill="default" icon="mediaPrevious" size={buttonSize} />}
onClick={handlePrevTrack}
tooltip={{
label: t('player.previous', { postProcess: 'sentenceCase' }),
@ -169,11 +157,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
{skip?.enabled && (
<PlayerButton
icon={
<Icon
fill="default"
icon="mediaStepBackward"
size={buttonSize}
/>
<Icon fill="default" icon="mediaStepBackward" size={buttonSize} />
}
onClick={() => handleSkipBackward(skip?.skipBackwardSeconds)}
tooltip={{
@ -194,13 +178,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
/>
{skip?.enabled && (
<PlayerButton
icon={
<Icon
fill="default"
icon="mediaStepForward"
size={buttonSize}
/>
}
icon={<Icon fill="default" icon="mediaStepForward" size={buttonSize} />}
onClick={() => handleSkipForward(skip?.skipForwardSeconds)}
tooltip={{
label: t('player.skip', {
@ -214,13 +192,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
/>
)}
<PlayerButton
icon={
<Icon
fill="default"
icon="mediaNext"
size={buttonSize}
/>
}
icon={<Icon fill="default" icon="mediaNext" size={buttonSize} />}
onClick={handleNextTrack}
tooltip={{
label: t('player.next', { postProcess: 'sentenceCase' }),
@ -231,11 +203,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
<PlayerButton
icon={
repeat === PlayerRepeat.ONE ? (
<Icon
fill="primary"
icon="mediaRepeatOne"
size={buttonSize}
/>
<Icon fill="primary" icon="mediaRepeatOne" size={buttonSize} />
) : (
<Icon
fill={repeat === PlayerRepeat.NONE ? 'default' : 'primary'}
@ -268,13 +236,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
variant="tertiary"
/>
<PlayerButton
icon={
<Icon
fill="default"
icon="mediaRandom"
size={buttonSize}
/>
}
icon={<Icon fill="default" icon="mediaRandom" size={buttonSize} />}
onClick={() =>
openShuffleAllModal({
handlePlayQueueAdd,
@ -291,12 +253,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
</div>
<div className={styles.sliderContainer}>
<div className={styles.sliderValueWrapper}>
<Text
fw={600}
isMuted
isNoSelect
size="xs"
>
<Text fw={600} isMuted isNoSelect size="xs">
{formattedTime}
</Text>
</div>
@ -324,12 +281,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
/>
</div>
<div className={styles.sliderValueWrapper}>
<Text
fw={600}
isMuted
isNoSelect
size="xs"
>
<Text fw={600} isMuted isNoSelect size="xs">
{duration}
</Text>
</div>

View file

@ -68,11 +68,7 @@ const ImageWithPlaceholder = ({
width: '100%',
}}
>
<Icon
color="muted"
icon="itemAlbum"
size="25%"
/>
<Icon color="muted" icon="itemAlbum" size="25%" />
</Center>
);
}
@ -167,14 +163,8 @@ export const FullScreenPlayerImage = () => {
justify="flex-start"
p="1rem"
>
<div
className={styles.imageContainer}
ref={mainImageRef}
>
<AnimatePresence
initial={false}
mode="sync"
>
<div className={styles.imageContainer} ref={mainImageRef}>
<AnimatePresence initial={false} mode="sync">
{imageState.current === 0 && (
<ImageWithPlaceholder
animate="open"
@ -206,18 +196,8 @@ export const FullScreenPlayerImage = () => {
)}
</AnimatePresence>
</div>
<Stack
className={styles.metadataContainer}
gap="md"
maw="100%"
>
<Text
fw={900}
lh="1.2"
overflow="hidden"
size="4xl"
w="100%"
>
<Stack className={styles.metadataContainer} gap="md" maw="100%">
<Text fw={900} lh="1.2" overflow="hidden" size="4xl" w="100%">
{currentSong?.name}
</Text>
<Text
@ -257,10 +237,7 @@ export const FullScreenPlayerImage = () => {
</Fragment>
))}
</Text>
<Group
justify="center"
mt="sm"
>
<Group justify="center" mt="sm">
{currentSong?.container && (
<Badge variant="transparent">{currentSong?.container}</Badge>
)}

View file

@ -76,10 +76,7 @@ export const FullScreenPlayerQueue = () => {
justify="center"
>
{headerItems.map((item) => (
<div
className={styles.headerItemWrapper}
key={`tab-${item.label}`}
>
<div className={styles.headerItemWrapper} key={`tab-${item.label}`}>
<Button
flex={1}
fw="600"

View file

@ -238,10 +238,7 @@ const Controls = ({ isPageHovered }: ControlsProps) => {
})}
</Option.Label>
<Option.Control>
<Group
w="100%"
wrap="nowrap"
>
<Group w="100%" wrap="nowrap">
<Slider
defaultValue={lyricConfig.fontSize}
label={(e) =>
@ -278,10 +275,7 @@ const Controls = ({ isPageHovered }: ControlsProps) => {
})}
</Option.Label>
<Option.Control>
<Group
w="100%"
wrap="nowrap"
>
<Group w="100%" wrap="nowrap">
<Slider
defaultValue={lyricConfig.gap}
label={(e) => `Synchronized: ${e}px`}

View file

@ -4,10 +4,5 @@ import { useCurrentSong } from '/@/renderer/store';
export const FullScreenSimilarSongs = () => {
const currentSong = useCurrentSong();
return currentSong?.id ? (
<SimilarSongsList
fullScreen
song={currentSong}
/>
) : null;
return currentSong?.id ? <SimilarSongsList fullScreen song={currentSong} /> : null;
};

View file

@ -69,10 +69,7 @@ export const LeftControls = () => {
return (
<div className={styles.leftControlsContainer}>
<LayoutGroup>
<AnimatePresence
initial={false}
mode="popLayout"
>
<AnimatePresence initial={false} mode="popLayout">
{!hideImage && (
<div className={styles.imageWrapper}>
<motion.div
@ -123,19 +120,9 @@ export const LeftControls = () => {
</div>
)}
</AnimatePresence>
<motion.div
className={styles.metadataStack}
layout="position"
>
<div
className={styles.lineItem}
onClick={stopPropagation}
>
<Group
align="center"
gap="xs"
wrap="nowrap"
>
<motion.div className={styles.metadataStack} layout="position">
<div className={styles.lineItem} onClick={stopPropagation}>
<Group align="center" gap="xs" wrap="nowrap">
<Text
component={Link}
fw={500}

View file

@ -61,7 +61,7 @@ interface PlayButtonProps extends Omit<ActionIconProps, 'icon' | 'variant'> {
}
export const PlayButton = forwardRef<HTMLButtonElement, PlayButtonProps>(
({ isPaused, ...props }: PlayButtonProps, ref) => {
({ isPaused, onClick, ...props }: PlayButtonProps, ref) => {
return (
<ActionIcon
className={styles.main}
@ -69,6 +69,10 @@ export const PlayButton = forwardRef<HTMLButtonElement, PlayButtonProps>(
iconProps={{
size: 'lg',
}}
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}}
ref={ref}
tooltip={{
label: isPaused

View file

@ -193,13 +193,7 @@ export const RightControls = () => {
}, [addToFavoritesMutation, removeFromFavoritesMutation, updateRatingMutation]);
return (
<Flex
align="flex-end"
direction="column"
h="100%"
px="1rem"
py="0.5rem"
>
<Flex align="flex-end" direction="column" h="100%" px="1rem" py="0.5rem">
<Group h="calc(100% / 3)">
{showRating && (
<Rating
@ -209,24 +203,17 @@ export const RightControls = () => {
/>
)}
</Group>
<Group
align="center"
gap="xs"
wrap="nowrap"
>
<DropdownMenu
arrowOffset={12}
offset={0}
position="top-end"
width={425}
withArrow
>
<Group align="center" gap="xs" wrap="nowrap">
<DropdownMenu arrowOffset={12} offset={0} position="top-end" width={425} withArrow>
<DropdownMenu.Target>
<ActionIcon
icon="mediaSpeed"
iconProps={{
size: 'lg',
}}
onClick={(e) => {
e.stopPropagation();
}}
size="sm"
tooltip={{
label: t('player.playbackSpeed', { postProcess: 'sentenceCase' }),
@ -268,7 +255,10 @@ export const RightControls = () => {
fill: currentSong?.userFavorite ? 'primary' : undefined,
size: 'lg',
}}
onClick={() => handleToggleFavorite(currentSong)}
onClick={(e) => {
e.stopPropagation();
handleToggleFavorite(currentSong);
}}
size="sm"
tooltip={{
label: currentSong?.userFavorite
@ -283,7 +273,10 @@ export const RightControls = () => {
iconProps={{
size: 'lg',
}}
onClick={handleToggleQueue}
onClick={(e) => {
e.stopPropagation();
handleToggleQueue();
}}
size="sm"
tooltip={{
label: t('player.viewQueue', { postProcess: 'titleCase' }),
@ -297,7 +290,10 @@ export const RightControls = () => {
color: muted ? 'muted' : undefined,
size: 'xl',
}}
onClick={handleMute}
onClick={(e) => {
e.stopPropagation();
handleMute();
}}
onWheel={handleVolumeWheel}
size="sm"
tooltip={{

View file

@ -33,10 +33,5 @@ export const Visualizer = () => {
return () => {};
}, [accent, canvasRef, motion, webAudio]);
return (
<div
className={styles.container}
ref={canvasRef}
/>
);
return <div className={styles.container} ref={canvasRef} />;
};

View file

@ -78,6 +78,7 @@ export const useHandlePlayQueueAdd = () => {
// Allow this to be undefined for "play shuffled". If undefined, default to 0,
// otherwise, choose the selected item in the queue
let initialSongIndex: number | undefined;
let toastId: string | null = null;
if (byItemType) {
let songList: SongListResponse | undefined;
@ -87,9 +88,8 @@ export const useHandlePlayQueueAdd = () => {
timeoutIds.current = {
...timeoutIds.current,
[fetchId]: setTimeout(() => {
toast.info({
toastId = toast.info({
autoClose: false,
id: fetchId,
message: t('player.playbackFetchCancel', {
postProcess: 'sentenceCase',
}),
@ -148,7 +148,9 @@ export const useHandlePlayQueueAdd = () => {
clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);
delete timeoutIds.current[fetchId];
toast.hide(fetchId);
if(toastId){
toast.hide(toastId);
}
} catch (err: any) {
if (instanceOfCancellationError(err)) {
return null;

View file

@ -1,8 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
import { usePlayerStore } from '/@/renderer/store';
import { usePlaybackSettings } from '/@/renderer/store/settings.store';
import { useAppStore, usePlaybackSettings, usePlayerStore } from '/@/renderer/store';
import { QueueSong, ServerType } from '/@/shared/types/domain-types';
import { PlayerStatus } from '/@/shared/types/types';
@ -34,6 +33,8 @@ Progress Events (Jellyfin only):
- Sends the 'progress' scrobble event on an interval
*/
type PlayerEvent = [PlayerStatus, number];
type SongEvent = [QueueSong | undefined, number, 1 | 2];
const checkScrobbleConditions = (args: {
@ -57,13 +58,14 @@ const checkScrobbleConditions = (args: {
export const useScrobble = () => {
const scrobbleSettings = usePlaybackSettings().scrobble;
const isScrobbleEnabled = scrobbleSettings?.enabled;
const isPrivateModeEnabled = useAppStore().privateMode;
const sendScrobble = useSendScrobble();
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
const handleScrobbleFromSeek = useCallback(
(currentTime: number) => {
if (!isScrobbleEnabled) return;
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
const currentSong = usePlayerStore.getState().current.song;
@ -82,35 +84,48 @@ export const useScrobble = () => {
serverId: currentSong?.serverId,
});
},
[isScrobbleEnabled, sendScrobble],
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
);
const progressIntervalId = useRef<null | ReturnType<typeof setInterval>>(null);
const songChangeTimeoutId = useRef<null | ReturnType<typeof setTimeout>>(null);
const songChangeTimeoutId = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const notifyTimeoutId = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const handleScrobbleFromSongChange = useCallback(
(current: SongEvent, previous: SongEvent) => {
if (scrobbleSettings?.notify && current[0]) {
if (scrobbleSettings?.notify && current[0]?.id) {
clearTimeout(notifyTimeoutId.current);
const currentSong = current[0];
const artists =
currentSong.artists?.length > 0
? currentSong.artists.map((artist) => artist.name).join(', ')
: currentSong.artistName;
// Set a delay so that quickly (within a second) switching songs doesn't trigger multiple
// notifications
notifyTimeoutId.current = setTimeout(() => {
// Only trigger if the song changed, or the player changed. This should be the case
// anyways, but who knows
if (
currentSong.uniqueId !== previous[0]?.uniqueId ||
current[2] !== previous[2]
) {
const artists =
currentSong.artists?.length > 0
? currentSong.artists.map((artist) => artist.name).join(', ')
: currentSong.artistName;
new Notification(`Now playing ${currentSong.name}`, {
body: `by ${artists} on ${currentSong.album}`,
icon: currentSong.imageUrl || undefined,
});
new Notification(`Now playing ${currentSong.name}`, {
body: `by ${artists} on ${currentSong.album}`,
icon: currentSong.imageUrl || undefined,
});
}
}, 1000);
}
if (!isScrobbleEnabled) return;
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current);
progressIntervalId.current = null;
}
// const currentSong = current[0] as QueueSong | undefined;
const previousSong = previous[0];
const previousSongTimeSec = previous[1];
@ -146,7 +161,7 @@ export const useScrobble = () => {
setIsCurrentSongScrobbled(false);
// Use a timeout to prevent spamming the server with scrobble events when switching through songs quickly
clearTimeout(songChangeTimeoutId.current as ReturnType<typeof setTimeout>);
clearTimeout(songChangeTimeoutId.current);
songChangeTimeoutId.current = setTimeout(() => {
const currentSong = current[0];
// Get the current status from the state, not variable. This is because
@ -186,6 +201,7 @@ export const useScrobble = () => {
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isScrobbleEnabled,
isPrivateModeEnabled,
isCurrentSongScrobbled,
sendScrobble,
handleScrobbleFromSeek,
@ -193,11 +209,8 @@ export const useScrobble = () => {
);
const handleScrobbleFromStatusChange = useCallback(
(
current: (number | PlayerStatus | undefined)[],
previous: (number | PlayerStatus | undefined)[],
) => {
if (!isScrobbleEnabled) return;
(current: PlayerEvent, previous: PlayerEvent) => {
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
const currentSong = usePlayerStore.getState().current.song;
@ -208,8 +221,8 @@ export const useScrobble = () => {
? usePlayerStore.getState().current.time * 1e7
: undefined;
const currentStatus = current[0] as PlayerStatus;
const currentTimeSec = current[1] as number;
const currentStatus = current[0];
const currentTimeSec = current[1];
// Whenever the player is restarted, send a 'start' scrobble
if (currentStatus === PlayerStatus.PLAYING) {
@ -249,12 +262,12 @@ export const useScrobble = () => {
});
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current as ReturnType<typeof setInterval>);
clearInterval(progressIntervalId.current);
progressIntervalId.current = null;
}
} else {
const isLastTrackInQueue = usePlayerStore.getState().actions.checkIsLastTrack();
const previousTimeSec = previous[1] as number;
const previousTimeSec = previous[1];
// If not already scrobbled, send a 'submission' scrobble if conditions are met
const shouldSubmitScrobble = checkScrobbleConditions({
@ -281,6 +294,7 @@ export const useScrobble = () => {
},
[
isScrobbleEnabled,
isPrivateModeEnabled,
sendScrobble,
handleScrobbleFromSeek,
scrobbleSettings?.scrobbleAtDuration,
@ -294,7 +308,7 @@ export const useScrobble = () => {
// need to perform another check to see if the scrobble conditions are met
const handleScrobbleFromSongRestart = useCallback(
(currentTime: number) => {
if (!isScrobbleEnabled) return;
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
const currentSong = usePlayerStore.getState().current.song;
@ -337,6 +351,7 @@ export const useScrobble = () => {
},
[
isScrobbleEnabled,
isPrivateModeEnabled,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
@ -358,17 +373,17 @@ export const useScrobble = () => {
// multiple times in a row and playback goes normally (no next/previous)
equalityFn: (a, b) =>
// compute whether the song changed
(a[0] as QueueSong)?.uniqueId === (b[0] as QueueSong)?.uniqueId &&
a[0]?.uniqueId === b[0]?.uniqueId &&
// compute whether the same player: relevant for repeat one and repeat all (one track)
a[2] === b[2],
},
);
const unsubStatusChange = usePlayerStore.subscribe(
(state) => [state.current.status, state.current.time],
(state): PlayerEvent => [state.current.status, state.current.time],
handleScrobbleFromStatusChange,
{
equalityFn: (a, b) => (a[0] as PlayerStatus) === (b[0] as PlayerStatus),
equalityFn: (a, b) => a[0] === b[0],
},
);

View file

@ -36,6 +36,7 @@ export const AddToPlaylistContextModal = ({
const { albumId, artistId, genreId, songId } = innerProps;
const server = useCurrentServer();
const [isLoading, setIsLoading] = useState(false);
const [isDropdownOpened, setIsDropdownOpened] = useState(true);
const addToPlaylistMutation = useAddToPlaylist({});
@ -235,7 +236,13 @@ export const AddToPlaylistContextModal = ({
})}
searchable
size="md"
dropdownOpened={isDropdownOpened}
{...form.getInputProps('playlistId')}
onClick={() => setIsDropdownOpened(true)}
onChange={(e) => {
setIsDropdownOpened(false);
form.getInputProps('playlistId').onChange(e);
}}
/>
<Switch
label={t('form.addToPlaylist.input', {

View file

@ -155,10 +155,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
)}
<Group justify="flex-end">
<Button
onClick={onCancel}
variant="subtle"
>
<Button onClick={onCancel} variant="subtle">
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
<Button

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