mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-26 12:26:34 +02:00
Compare commits
7 Commits
1616021451
...
ffe59b2c78
| Author | SHA1 | Date | |
|---|---|---|---|
| ffe59b2c78 | |||
| 4226da94ec | |||
| 6e634972c9 | |||
| 984b85e0e9 | |||
| 9bb38e1df3 | |||
| 4ecd8271a2 | |||
| ce7a319d2f |
@@ -66,6 +66,9 @@ These variables override app settings **on first run** when no persisted setting
|
||||
| `playback.scrobble.scrobbleAtDuration` | `240` | `FS_PLAYBACK_SCROBBLE_AT_DURATION` | Seconds of playback before scrobble. |
|
||||
| `playback.scrobble.scrobbleAtPercentage` | `75` | `FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE` | Percentage of track before scrobble. |
|
||||
| `playback.transcode.enabled` | `false` | `FS_PLAYBACK_TRANSCODE_ENABLED` | `true` / `false` — Enable transcoding. |
|
||||
| `playback.transcode.format` | *(unset)* | `FS_PLAYBACK_TRANSCODE_FORMAT` | Transcode format string (codec/container), e.g. server-specific value. Empty = use default. |
|
||||
| `playback.transcode.bitrate` | *(unset)* | `FS_PLAYBACK_TRANSCODE_BITRATE` | Transcode bitrate (number, kbps or as defined by server). |
|
||||
| `playback.filters` | `[]` | `FS_PLAYBACK_FILTERS` | JSON array of player filters: each object needs `id`, `field`, `operator`, `value`; optional `isEnabled`. Invalid JSON or shape is ignored. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ window.FS_PLAYBACK_SCROBBLE_NOTIFY = "${FS_PLAYBACK_SCROBBLE_NOTIFY}";
|
||||
window.FS_PLAYBACK_SCROBBLE_AT_DURATION = "${FS_PLAYBACK_SCROBBLE_AT_DURATION}";
|
||||
window.FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE = "${FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE}";
|
||||
window.FS_PLAYBACK_TRANSCODE_ENABLED = "${FS_PLAYBACK_TRANSCODE_ENABLED}";
|
||||
window.FS_PLAYBACK_TRANSCODE_FORMAT = "${FS_PLAYBACK_TRANSCODE_FORMAT}";
|
||||
window.FS_PLAYBACK_TRANSCODE_BITRATE = "${FS_PLAYBACK_TRANSCODE_BITRATE}";
|
||||
window.FS_PLAYBACK_FILTERS = "${FS_PLAYBACK_FILTERS}";
|
||||
|
||||
window.FS_DISCORD_ENABLED = "${FS_DISCORD_ENABLED}";
|
||||
window.FS_DISCORD_CLIENT_ID = "${FS_DISCORD_CLIENT_ID}";
|
||||
|
||||
+11
-11
@@ -344,30 +344,30 @@
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Àlbum",
|
||||
"album_many": "àlbums",
|
||||
"album_many": "Àlbums",
|
||||
"album_other": "Àlbums",
|
||||
"albumWithCount_one": "{{count}} àlbum",
|
||||
"albumWithCount_many": "{{count}} àlbums",
|
||||
"albumWithCount_other": "{{count}} àlbums",
|
||||
"albumArtist_one": "Artista de l'àlbum",
|
||||
"albumArtist_many": "artistes de l'àlbum",
|
||||
"albumArtist_many": "Artistes de l'àlbum",
|
||||
"albumArtist_other": "Artistes de l'àlbum",
|
||||
"albumArtistCount_one": "{{count}} artista de l'àlbum",
|
||||
"albumArtistCount_many": "{{count}} artistes de l'àlbum",
|
||||
"albumArtistCount_other": "{{count}} artistes de l'àlbum",
|
||||
"artist_one": "Artista",
|
||||
"artist_many": "artistes",
|
||||
"artist_many": "Artistes",
|
||||
"artist_other": "Artistes",
|
||||
"artistWithCount_one": "{{count}} artista",
|
||||
"artistWithCount_many": "{{count}} artistes",
|
||||
"artistWithCount_other": "{{count}} artistes",
|
||||
"playlist_one": "Llista de reproducció",
|
||||
"playlist_many": "llistes de reproducció",
|
||||
"playlist_many": "Llistes de reproducció",
|
||||
"playlist_other": "Llistes de reproducció",
|
||||
"playlistWithCount_one": "{{count}} llista de reproducció",
|
||||
"playlistWithCount_many": "{{count}} llistes de reproducció",
|
||||
"playlistWithCount_other": "{{count}} llistes de reproducció",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) intel·ligent",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Intel·ligent",
|
||||
"play_one": "{{count}} reproducció",
|
||||
"play_many": "{{count}} reproduccions",
|
||||
"play_other": "{{count}} reproduccions",
|
||||
@@ -378,25 +378,25 @@
|
||||
"genreWithCount_many": "{{count}} gèneres",
|
||||
"genreWithCount_other": "{{count}} gèneres",
|
||||
"track_one": "Pista",
|
||||
"track_many": "pistes",
|
||||
"track_many": "Pistes",
|
||||
"track_other": "Pistes",
|
||||
"trackWithCount_one": "{{count}} pista",
|
||||
"trackWithCount_many": "{{count}} pistes",
|
||||
"trackWithCount_other": "{{count}} pistes",
|
||||
"folder_one": "Carpeta",
|
||||
"folder_many": "carpetes",
|
||||
"folder_many": "Carpetes",
|
||||
"folder_other": "Carpetes",
|
||||
"genre_one": "Gènere",
|
||||
"genre_many": "gèneres",
|
||||
"genre_many": "Gèneres",
|
||||
"genre_other": "Gèneres",
|
||||
"song_one": "Cançó",
|
||||
"song_many": "cançons",
|
||||
"song_many": "Cançons",
|
||||
"song_other": "Cançons",
|
||||
"favorite_one": "Preferit",
|
||||
"favorite_many": "preferits",
|
||||
"favorite_many": "Preferits",
|
||||
"favorite_other": "Preferits",
|
||||
"radioStation_one": "Emissora de ràdio",
|
||||
"radioStation_many": "emissores de ràdio",
|
||||
"radioStation_many": "Emissores de ràdio",
|
||||
"radioStation_other": "Emissores de ràdio",
|
||||
"radioStationWithCount_one": "{{count}} emissora de ràdio",
|
||||
"radioStationWithCount_many": "{{count}} emissores de ràdio",
|
||||
|
||||
+16
-16
@@ -246,7 +246,7 @@
|
||||
"albumBackground_description": "Přidá obrázek alba na pozadí pro stránky alba obsahující obrázky alba",
|
||||
"albumBackgroundBlur": "Velikost rozostření obrázku alba na pozadí",
|
||||
"albumBackgroundBlur_description": "Upraví množství rozostření použité na obrázek alba na pozadí",
|
||||
"playerbarOpenDrawer": "Lišta přehrávače jako přEPínač celé obrazovky",
|
||||
"playerbarOpenDrawer": "Lišta přehrávače jako přepínač celé obrazovky",
|
||||
"playerbarOpenDrawer_description": "Umožňuje kliknutí na lištu přehrávače pro otevření celoobrazovkového přehrávače",
|
||||
"artistConfiguration": "Nastavení stránky umělce alba",
|
||||
"artistConfiguration_description": "Nastavit, které položky na stránce umělce alba budou zobrazeny a v jakém pořadí",
|
||||
@@ -689,7 +689,7 @@
|
||||
"rating": "Hodnocení",
|
||||
"favorite": "Oblíbené",
|
||||
"playCount": "Přehrání",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||
"albumCount": "Alba",
|
||||
"releaseYear": "Rok",
|
||||
"lastPlayed": "Naposledy přehráno",
|
||||
"biography": "Biografie",
|
||||
@@ -697,20 +697,20 @@
|
||||
"bitrate": "Datový tok",
|
||||
"title": "Název",
|
||||
"bpm": "BPM",
|
||||
"dateAdded": "Datum přIDání",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
||||
"dateAdded": "Datum přidání",
|
||||
"artist": "Umělec",
|
||||
"songCount": "Skladby",
|
||||
"trackNumber": "Skladba",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"genre": "Žánr",
|
||||
"albumArtist": "Umělec alba",
|
||||
"path": "Cesta",
|
||||
"discNumber": "Disk",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"size": "$t(common.size)",
|
||||
"codec": "$t(common.codec)",
|
||||
"channels": "Kanály",
|
||||
"size": "Velikost",
|
||||
"codec": "Kodek",
|
||||
"owner": "Majitel",
|
||||
"bitDepth": "$t(common.bitDepth)",
|
||||
"sampleRate": "$t(common.sampleRate)"
|
||||
"bitDepth": "Bitová hloubka",
|
||||
"sampleRate": "Vzorkovací frekvence"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -762,10 +762,10 @@
|
||||
"search": "Hledat",
|
||||
"bitrate": "Datový tok",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"recentlyAdded": "Nedávno přIDáno",
|
||||
"recentlyAdded": "Nedávno přidáno",
|
||||
"note": "Poznámka",
|
||||
"name": "Název",
|
||||
"dateAdded": "Datum přIDání",
|
||||
"dateAdded": "Datum přidání",
|
||||
"releaseDate": "Datum vydání",
|
||||
"albumCount": "Počet $t(entity.album, {\"count\": 2})",
|
||||
"communityRating": "Komunitní hodnocení",
|
||||
@@ -887,7 +887,7 @@
|
||||
},
|
||||
"home": {
|
||||
"mostPlayed": "Nejpřehrávanější",
|
||||
"newlyAdded": "Nově přIDáno",
|
||||
"newlyAdded": "Nově přidáno",
|
||||
"title": "$t(common.home)",
|
||||
"explore": "Procházet z vaší knihovny",
|
||||
"recentlyPlayed": "Nedávno přehráno",
|
||||
@@ -1030,7 +1030,7 @@
|
||||
"input_password": "Heslo",
|
||||
"input_legacyAuthentication": "Zapnout zastaralé ověřování",
|
||||
"input_name": "Název serveru",
|
||||
"success": "Server úspěšně přIDán",
|
||||
"success": "Server úspěšně přidán",
|
||||
"input_savePassword": "Uložit heslo",
|
||||
"ignoreSsl": "Ignorovat ssl $t(common.restartRequired)",
|
||||
"ignoreCors": "Ignorovat cors $t(common.restartRequired)",
|
||||
@@ -1042,7 +1042,7 @@
|
||||
"input_remoteUrlPlaceholder": "Volitelné: veřejná adresa url pro externí funkce"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "PřIDáno $t(entity.trackWithCount, {\"count\": {{message}} }) do $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||
"success": "$t(entity.trackWithCount, {\"count\": {{message}} }) přidáno do $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||
"title": "Přidat do $t(entity.playlist, {\"count\": 1})",
|
||||
"input_skipDuplicates": "Přeskočit duplicity",
|
||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||
|
||||
@@ -171,14 +171,14 @@
|
||||
"entity": {
|
||||
"album_one": "Album",
|
||||
"album_other": "Albums",
|
||||
"albumArtist_one": "Album artist",
|
||||
"albumArtist_other": "Album artists",
|
||||
"albumArtist_one": "Album Artist",
|
||||
"albumArtist_other": "Album Artists",
|
||||
"albumArtistCount_one": "{{count}} album artist",
|
||||
"albumArtistCount_other": "{{count}} album artists",
|
||||
"albumWithCount_one": "{{count}} album",
|
||||
"albumWithCount_other": "{{count}} albums",
|
||||
"radioStation_one": "Radio station",
|
||||
"radioStation_other": "Radio stations",
|
||||
"radioStation_one": "Radio Station",
|
||||
"radioStation_other": "Radio Stations",
|
||||
"radioStationWithCount_one": "{{count}} radio station",
|
||||
"radioStationWithCount_other": "{{count}} radio stations",
|
||||
"artist_one": "Artist",
|
||||
@@ -696,7 +696,8 @@
|
||||
"sleepTimer_off": "Off",
|
||||
"sleepTimer_timeRemaining": "{{time}} remaining",
|
||||
"sleepTimer_setCustom": "Set timer",
|
||||
"sleepTimer_cancel": "Cancel timer"
|
||||
"sleepTimer_cancel": "Cancel timer",
|
||||
"scrobbleForceSubmit": "Force scrobble"
|
||||
},
|
||||
"queryBuilder": {
|
||||
"standardTags": "Standard tags",
|
||||
|
||||
+34
-34
@@ -263,7 +263,7 @@
|
||||
"lastfmApiKey": "Clave API para {{lastfm}}",
|
||||
"discordServeImage": "Servir imágenes de {{discord}} desde el servidor",
|
||||
"discordServeImage_description": "Comparte el arte de la portada para el estado de actividad de {{discord}} desde el propio servidor, solo disponible para Jellyfin y Navidrome. {{discord}} usa un bot para obtener las imágenes, por lo que tu servidor debe ser alcanzable desde el internet público",
|
||||
"lastfm": "Mostrar enlaces de last.fm",
|
||||
"lastfm": "Mostrar enlaces de Last.fm",
|
||||
"lastfm_description": "Muestra enlaces a Last.fm en las páginas de artistas/álbumes",
|
||||
"musicbrainz": "Mostrar enlaces de MusicBrainz",
|
||||
"musicbrainz_description": "Muestra enlaces a MusicBrainz en las páginas de artistas/álbumes, donde exista MusicBrainz ID",
|
||||
@@ -342,7 +342,7 @@
|
||||
"playerFilters": "Filtrar las canciones de la cola",
|
||||
"playerFilters_description": "Omite la adición de canciones a la cola basado en los siguientes criterios",
|
||||
"playerbarSlider_description": "La forma de onda no es recomendable en una conexión a Internet lenta o medida",
|
||||
"autoDJ": "DJ automático",
|
||||
"autoDJ": "DJ Automático",
|
||||
"autoDJ_description": "Añade canciones similares a las de la cola automáticamente",
|
||||
"autoDJ_itemCount": "Recuento de elementos",
|
||||
"autoDJ_itemCount_description": "El número de elementos que se ha intentado añadir a la cola cuando DJ automático está activado",
|
||||
@@ -533,7 +533,7 @@
|
||||
"comingSoon": "Próximamente…",
|
||||
"reset": "Restablecer",
|
||||
"disable": "Desactivar",
|
||||
"sortOrder": "Ordenar",
|
||||
"sortOrder": "Orden",
|
||||
"none": "Ninguno",
|
||||
"menu": "Menú",
|
||||
"restartRequired": "Reinicio requerido",
|
||||
@@ -700,7 +700,7 @@
|
||||
"artists": "$t(entity.artist, {\"count\": 2})",
|
||||
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
|
||||
"shared": "Compartido $t(entity.playlist, {\"count\": 2})",
|
||||
"myLibrary": "Mi biblioteca",
|
||||
"myLibrary": "Mi Biblioteca",
|
||||
"favorites": "$t(entity.favorite, {\"count\": 2})",
|
||||
"radio": "$t(entity.radioStation, {\"count\": 2})",
|
||||
"collections": "Colecciones"
|
||||
@@ -761,7 +761,7 @@
|
||||
"genres": "$t(entity.genre, {\"count\": 2})"
|
||||
},
|
||||
"fullscreenPlayer": {
|
||||
"upNext": "Siguiente",
|
||||
"upNext": "A continuación",
|
||||
"config": {
|
||||
"dynamicBackground": "Fondo dinámico",
|
||||
"synchronized": "Sincronizado",
|
||||
@@ -800,7 +800,7 @@
|
||||
"updates": "Actualización",
|
||||
"cache": "Caché",
|
||||
"application": "Aplicación",
|
||||
"queryBuilder": "Generador de consultas",
|
||||
"queryBuilder": "Generador de Consultas",
|
||||
"theme": "Tema",
|
||||
"controls": "Controles",
|
||||
"remote": "Remoto",
|
||||
@@ -811,9 +811,9 @@
|
||||
"transcoding": "Transcodificación",
|
||||
"discord": "Discord",
|
||||
"sidebar": "Barra lateral",
|
||||
"playerFilters": "Filtros del reproductor",
|
||||
"playerFilters": "Filtros del Reproductor",
|
||||
"logger": "Registrador",
|
||||
"lyricsDisplay": "Mostrar letras"
|
||||
"lyricsDisplay": "Mostrar Letras"
|
||||
},
|
||||
"albumArtistList": {
|
||||
"title": "$t(entity.albumArtist, {\"count\": 2})"
|
||||
@@ -926,10 +926,10 @@
|
||||
"ignoreSsl": "Ignorar SSL ($t(common.restartRequired))",
|
||||
"ignoreCors": "Ignorar CORS ($t(common.restartRequired))",
|
||||
"error_savePassword": "Un error ocurrió cuando se intentó guardar la contraseña",
|
||||
"input_preferInstantMix": "Preferir mix instantáneo",
|
||||
"input_preferInstantMix": "Preferir Mix Instantáneo",
|
||||
"input_preferInstantMixDescription": "Usa solo el mix instantáneo para obtener canciones similares. Útil si tienes complementos que modifican este comportamiento",
|
||||
"input_remoteUrl": "URL pública",
|
||||
"input_preferRemoteUrl": "Preferir URL pública",
|
||||
"input_remoteUrl": "URL Pública",
|
||||
"input_preferRemoteUrl": "Preferir URL Pública",
|
||||
"input_remoteUrlPlaceholder": "Opcional: URL pública para características externas"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
@@ -1020,7 +1020,7 @@
|
||||
"album": "Álbum",
|
||||
"favorite": "Favorito",
|
||||
"playCount": "Reproducciones",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||
"albumCount": "Álbumes",
|
||||
"releaseYear": "Año",
|
||||
"lastPlayed": "Última reproducción",
|
||||
"biography": "Biografía",
|
||||
@@ -1029,19 +1029,19 @@
|
||||
"title": "Título",
|
||||
"bpm": "BPM",
|
||||
"dateAdded": "Fecha de adición",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
||||
"artist": "Artista",
|
||||
"songCount": "Pistas",
|
||||
"trackNumber": "Pista",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"genre": "Género",
|
||||
"albumArtist": "Artista del álbum",
|
||||
"path": "Ruta",
|
||||
"discNumber": "Disco",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"size": "$t(common.size)",
|
||||
"codec": "$t(common.codec)",
|
||||
"channels": "Canales",
|
||||
"size": "Tamaño",
|
||||
"codec": "Códecs",
|
||||
"owner": "Propietario",
|
||||
"bitDepth": "$t(common.bitDepth)",
|
||||
"sampleRate": "$t(common.sampleRate)"
|
||||
"bitDepth": "Profundidad de Bit",
|
||||
"sampleRate": "Frecuencia de Muestreo"
|
||||
},
|
||||
"config": {
|
||||
"label": {
|
||||
@@ -1080,7 +1080,7 @@
|
||||
"sampleRate": "$t(common.sampleRate)",
|
||||
"titleArtist": "$t(common.title) (artista)",
|
||||
"composer": "Compositor",
|
||||
"albumGroup": "Grupo del álbum"
|
||||
"albumGroup": "Grupo del Álbum"
|
||||
},
|
||||
"general": {
|
||||
"gap": "$t(common.gap)",
|
||||
@@ -1123,27 +1123,27 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) inteligente",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Inteligente",
|
||||
"genre_one": "Género",
|
||||
"genre_many": "géneros",
|
||||
"genre_many": "Géneros",
|
||||
"genre_other": "Géneros",
|
||||
"playlistWithCount_one": "{{count}} lista de reproducción",
|
||||
"playlistWithCount_many": "{{count}} listas de reproducción",
|
||||
"playlistWithCount_other": "{{count}} listas de reproducción",
|
||||
"playlist_one": "Lista de reproducción",
|
||||
"playlist_many": "listas de reproducción",
|
||||
"playlist_many": "Listas de reproducción",
|
||||
"playlist_other": "Listas de reproducción",
|
||||
"artist_one": "Artista",
|
||||
"artist_many": "artistas",
|
||||
"artist_many": "Artistas",
|
||||
"artist_other": "Artistas",
|
||||
"folderWithCount_one": "{{count}} carpeta",
|
||||
"folderWithCount_many": "{{count}} carpetas",
|
||||
"folderWithCount_other": "{{count}} carpetas",
|
||||
"albumArtist_one": "Artista del álbum",
|
||||
"albumArtist_many": "artistas del álbum",
|
||||
"albumArtist_many": "Artistas del álbum",
|
||||
"albumArtist_other": "Artistas del álbum",
|
||||
"track_one": "Pista",
|
||||
"track_many": "pistas",
|
||||
"track_many": "Pistas",
|
||||
"track_other": "Pistas",
|
||||
"albumArtistCount_one": "{{count}} artista del álbum",
|
||||
"albumArtistCount_many": "{{count}} artistas del álbum",
|
||||
@@ -1152,16 +1152,16 @@
|
||||
"albumWithCount_many": "{{count}} álbumes",
|
||||
"albumWithCount_other": "{{count}} álbumes",
|
||||
"favorite_one": "Favorito",
|
||||
"favorite_many": "favoritos",
|
||||
"favorite_many": "Favoritos",
|
||||
"favorite_other": "Favoritos",
|
||||
"artistWithCount_one": "{{count}} artista",
|
||||
"artistWithCount_many": "{{count}} artistas",
|
||||
"artistWithCount_other": "{{count}} artistas",
|
||||
"folder_one": "Carpeta",
|
||||
"folder_many": "carpetas",
|
||||
"folder_many": "Carpetas",
|
||||
"folder_other": "Carpetas",
|
||||
"album_one": "Álbum",
|
||||
"album_many": "álbumes",
|
||||
"album_many": "Álbumes",
|
||||
"album_other": "Álbumes",
|
||||
"genreWithCount_one": "{{count}} género",
|
||||
"genreWithCount_many": "{{count}} géneros",
|
||||
@@ -1173,7 +1173,7 @@
|
||||
"play_many": "{{count}} reproducciones",
|
||||
"play_other": "{{count}} reproducciones",
|
||||
"song_one": "Canción",
|
||||
"song_many": "canciones",
|
||||
"song_many": "Canciones",
|
||||
"song_other": "Canciones",
|
||||
"radioStation_one": "Estación de radio",
|
||||
"radioStation_many": "Estaciones de radio",
|
||||
@@ -1197,16 +1197,16 @@
|
||||
},
|
||||
"secondary": {
|
||||
"audiobook": "Audiolibro",
|
||||
"audioDrama": "Audio drama",
|
||||
"audioDrama": "Audio Drama",
|
||||
"compilation": "Compilación",
|
||||
"djMix": "Mezcla del DJ",
|
||||
"fieldRecording": "Grabación de campo",
|
||||
"fieldRecording": "Grabación de Campo",
|
||||
"interview": "Entrevista",
|
||||
"live": "En vivo",
|
||||
"mixtape": "Recopilatorio",
|
||||
"remix": "Remix",
|
||||
"soundtrack": "Banda sonora",
|
||||
"spokenWord": "Palabra hablada",
|
||||
"spokenWord": "Palabra Hablada",
|
||||
"demo": "Maqueta"
|
||||
}
|
||||
},
|
||||
|
||||
+11
-11
@@ -1011,25 +1011,25 @@
|
||||
},
|
||||
"entity": {
|
||||
"genre_one": "Genre",
|
||||
"genre_many": "genres",
|
||||
"genre_many": "Genres",
|
||||
"genre_other": "Genres",
|
||||
"playlistWithCount_one": "{{count}} liste de lecture",
|
||||
"playlistWithCount_many": "{{count}} listes de lecture",
|
||||
"playlistWithCount_other": "{{count}} listes de lecture",
|
||||
"playlist_one": "Liste de lecture",
|
||||
"playlist_many": "listes de lecture",
|
||||
"playlist_many": "Listes de lecture",
|
||||
"playlist_other": "Listes de lecture",
|
||||
"artist_one": "Artiste",
|
||||
"artist_many": "artistes",
|
||||
"artist_many": "Artistes",
|
||||
"artist_other": "Artistes",
|
||||
"folderWithCount_one": "{{count}} dossier",
|
||||
"folderWithCount_many": "{{count}} dossiers",
|
||||
"folderWithCount_other": "{{count}} dossiers",
|
||||
"albumArtist_one": "Artiste d'album",
|
||||
"albumArtist_many": "artistes d'albums",
|
||||
"albumArtist_many": "Artistes d'albums",
|
||||
"albumArtist_other": "Artistes d'albums",
|
||||
"track_one": "Piste",
|
||||
"track_many": "pistes",
|
||||
"track_many": "Pistes",
|
||||
"track_other": "Pistes",
|
||||
"albumArtistCount_one": "{{count}} artiste de l'album",
|
||||
"albumArtistCount_many": "{{count}} artistes d'albums",
|
||||
@@ -1038,17 +1038,17 @@
|
||||
"albumWithCount_many": "{{count}} albums",
|
||||
"albumWithCount_other": "{{count}} albums",
|
||||
"favorite_one": "Favori",
|
||||
"favorite_many": "favoris",
|
||||
"favorite_many": "Favoris",
|
||||
"favorite_other": "Favoris",
|
||||
"artistWithCount_one": "{{count}} artiste",
|
||||
"artistWithCount_many": "{{count}} artistes",
|
||||
"artistWithCount_other": "{{count}} artistes",
|
||||
"folder_one": "Dossier",
|
||||
"folder_many": "dossiers",
|
||||
"folder_many": "Dossiers",
|
||||
"folder_other": "Dossiers",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) intelligente",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Intelligente",
|
||||
"album_one": "Album",
|
||||
"album_many": "albums",
|
||||
"album_many": "Albums",
|
||||
"album_other": "Albums",
|
||||
"genreWithCount_one": "{{count}} genre",
|
||||
"genreWithCount_many": "{{count}} genres",
|
||||
@@ -1060,10 +1060,10 @@
|
||||
"play_many": "{{count}} écoutes",
|
||||
"play_other": "{{count}} écoutes",
|
||||
"song_one": "Titre",
|
||||
"song_many": "titres",
|
||||
"song_many": "Titres",
|
||||
"song_other": "Titres",
|
||||
"radioStation_one": "Station radio",
|
||||
"radioStation_many": "stations radio",
|
||||
"radioStation_many": "Stations radio",
|
||||
"radioStation_other": "Stations radio",
|
||||
"radioStationWithCount_one": "{{count}} station radio",
|
||||
"radioStationWithCount_many": "{{count}} stations radio",
|
||||
|
||||
@@ -192,8 +192,8 @@
|
||||
"play_other": "{{count}} lejátszások",
|
||||
"trackWithCount_one": "{{count}} sáv",
|
||||
"trackWithCount_other": "{{count}} sávok",
|
||||
"radioStation_one": "Rádió állomás",
|
||||
"radioStation_other": "Rádió állomások",
|
||||
"radioStation_one": "Rádióállomás",
|
||||
"radioStation_other": "Rádióállomások",
|
||||
"radioStationWithCount_one": "{{count}} rádióállomás",
|
||||
"radioStationWithCount_other": "{{count}} rádióállomások"
|
||||
},
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
},
|
||||
"entity": {
|
||||
"album_other": "Album",
|
||||
"albumArtist_other": "Artis album",
|
||||
"albumArtist_other": "Artis Album",
|
||||
"albumArtistCount_other": "{{count}} artis album",
|
||||
"albumWithCount_other": "{{count}} album",
|
||||
"artist_other": "Artis",
|
||||
@@ -170,14 +170,14 @@
|
||||
"folderWithCount_other": "{{count}} folder",
|
||||
"genre_other": "Genre",
|
||||
"genreWithCount_other": "{{count}} genre",
|
||||
"playlist_other": "Daftar putar",
|
||||
"playlist_other": "Daftar Putar",
|
||||
"play_other": "Putar {{count}}",
|
||||
"playlistWithCount_other": "{{count}} daftar putar",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) pintar",
|
||||
"track_other": "Pista",
|
||||
"song_other": "Lagu",
|
||||
"trackWithCount_other": "{{count}} pista",
|
||||
"radioStation_other": "Stasiun radio",
|
||||
"radioStation_other": "Stasiun Radio",
|
||||
"radioStationWithCount_other": "{{count}} stasiun radio"
|
||||
},
|
||||
"error": {
|
||||
|
||||
+13
-13
@@ -930,25 +930,25 @@
|
||||
},
|
||||
"entity": {
|
||||
"genre_one": "Genere",
|
||||
"genre_many": "generi",
|
||||
"genre_many": "Generi",
|
||||
"genre_other": "Generi",
|
||||
"playlistWithCount_one": "{{count}} playlist",
|
||||
"playlistWithCount_many": "{{count}} playlist",
|
||||
"playlistWithCount_other": "{{count}} playlist",
|
||||
"playlist_one": "Playlist",
|
||||
"playlist_many": "playlist",
|
||||
"playlist_many": "Playlist",
|
||||
"playlist_other": "Playlist",
|
||||
"artist_one": "Artista",
|
||||
"artist_many": "artisti",
|
||||
"artist_many": "Artisti",
|
||||
"artist_other": "Artisti",
|
||||
"folderWithCount_one": "{{count}} cartella",
|
||||
"folderWithCount_many": "{{count}} cartelle",
|
||||
"folderWithCount_other": "{{count}} cartelle",
|
||||
"albumArtist_one": "Artista album",
|
||||
"albumArtist_many": "artisti album",
|
||||
"albumArtist_other": "Artisti album",
|
||||
"albumArtist_one": "Artista Album",
|
||||
"albumArtist_many": "Artisti Album",
|
||||
"albumArtist_other": "Artisti Album",
|
||||
"track_one": "Traccia",
|
||||
"track_many": "tracce",
|
||||
"track_many": "Tracce",
|
||||
"track_other": "Tracce",
|
||||
"albumArtistCount_one": "{{count}} artista album",
|
||||
"albumArtistCount_many": "{{count}} artisti album",
|
||||
@@ -957,17 +957,17 @@
|
||||
"albumWithCount_many": "{{count}} album",
|
||||
"albumWithCount_other": "{{count}} album",
|
||||
"favorite_one": "Preferito",
|
||||
"favorite_many": "preferiti",
|
||||
"favorite_many": "Preferiti",
|
||||
"favorite_other": "Preferiti",
|
||||
"artistWithCount_one": "{{count}} artista",
|
||||
"artistWithCount_many": "{{count}} artisti",
|
||||
"artistWithCount_other": "{{count}} artisti",
|
||||
"folder_one": "Cartella",
|
||||
"folder_many": "cartelle",
|
||||
"folder_many": "Cartelle",
|
||||
"folder_other": "Cartelle",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) smart",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Smart",
|
||||
"album_one": "Album",
|
||||
"album_many": "album",
|
||||
"album_many": "Album",
|
||||
"album_other": "Album",
|
||||
"genreWithCount_one": "{{count}} genere",
|
||||
"genreWithCount_many": "{{count}} generi",
|
||||
@@ -979,10 +979,10 @@
|
||||
"play_many": "{{count}} riproduzioni",
|
||||
"play_other": "{{count}} riproduzioni",
|
||||
"song_one": "Traccia",
|
||||
"song_many": "tracce",
|
||||
"song_many": "Tracce",
|
||||
"song_other": "Tracce",
|
||||
"radioStation_one": "Stazione radio",
|
||||
"radioStation_many": "stazioni radio",
|
||||
"radioStation_many": "Stazioni radio",
|
||||
"radioStation_other": "Stazioni radio",
|
||||
"radioStationWithCount_one": "{{count}} stazione radio",
|
||||
"radioStationWithCount_many": "{{count}} stazioni radio",
|
||||
|
||||
@@ -764,31 +764,31 @@
|
||||
},
|
||||
"entity": {
|
||||
"albumArtist_one": "Artista do álbum",
|
||||
"albumArtist_many": "artistas do álbum",
|
||||
"albumArtist_many": "Artistas do álbum",
|
||||
"albumArtist_other": "Artistas do álbum",
|
||||
"albumArtistCount_one": "{{count}} artista do álbum",
|
||||
"albumArtistCount_many": "{{count}} artistas do álbum",
|
||||
"albumArtistCount_other": "{{count}} artistas do álbum",
|
||||
"album_one": "Álbum",
|
||||
"album_many": "álbuns",
|
||||
"album_many": "Álbuns",
|
||||
"album_other": "Álbuns",
|
||||
"artist_one": "Artista",
|
||||
"artist_many": "artistas",
|
||||
"artist_many": "Artistas",
|
||||
"artist_other": "Artistas",
|
||||
"albumWithCount_one": "{{count}} álbum",
|
||||
"albumWithCount_many": "{{count}} álbuns",
|
||||
"albumWithCount_other": "{{count}} álbuns",
|
||||
"favorite_one": "Favorito",
|
||||
"favorite_many": "favoritos",
|
||||
"favorite_many": "Favoritos",
|
||||
"favorite_other": "Favoritos",
|
||||
"artistWithCount_one": "{{count}} artista",
|
||||
"artistWithCount_many": "{{count}} artistas",
|
||||
"artistWithCount_other": "{{count}} artistas",
|
||||
"folder_one": "Pasta",
|
||||
"folder_many": "pastas",
|
||||
"folder_many": "Pastas",
|
||||
"folder_other": "Pastas",
|
||||
"genre_one": "Gênero",
|
||||
"genre_many": "gêneros",
|
||||
"genre_many": "Gêneros",
|
||||
"genre_other": "Gêneros",
|
||||
"playlistWithCount_one": "{{count}} playlist",
|
||||
"playlistWithCount_many": "{{count}} playlists",
|
||||
@@ -806,11 +806,11 @@
|
||||
"trackWithCount_many": "{{count}} faixas",
|
||||
"trackWithCount_other": "{{count}} faixas",
|
||||
"track_one": "Faixa",
|
||||
"track_many": "faixas",
|
||||
"track_many": "Faixas",
|
||||
"track_other": "Faixas",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) inteligente",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Inteligente",
|
||||
"song_one": "Música",
|
||||
"song_many": "músicas",
|
||||
"song_many": "Músicas",
|
||||
"song_other": "Músicas",
|
||||
"play_one": "{{count}} reprodução",
|
||||
"play_many": "{{count}} reproduções",
|
||||
|
||||
+10
-10
@@ -126,10 +126,10 @@
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Álbum",
|
||||
"album_many": "álbuns",
|
||||
"album_many": "Álbuns",
|
||||
"album_other": "Álbuns",
|
||||
"albumArtist_one": "Artista do álbum",
|
||||
"albumArtist_many": "artistas do álbum",
|
||||
"albumArtist_many": "Artistas do álbum",
|
||||
"albumArtist_other": "Artistas do álbum",
|
||||
"albumArtistCount_one": "{{count}} artista do álbum",
|
||||
"albumArtistCount_many": "{{count}} artistas do álbum",
|
||||
@@ -138,28 +138,28 @@
|
||||
"albumWithCount_many": "{{count}} álbuns",
|
||||
"albumWithCount_other": "{{count}} álbuns",
|
||||
"artist_one": "Artista",
|
||||
"artist_many": "artistas",
|
||||
"artist_many": "Artistas",
|
||||
"artist_other": "Artistas",
|
||||
"artistWithCount_one": "{{count}} artista",
|
||||
"artistWithCount_many": "{{count}} artistas",
|
||||
"artistWithCount_other": "{{count}} artistas",
|
||||
"favorite_one": "Favorito",
|
||||
"favorite_many": "favoritos",
|
||||
"favorite_many": "Favoritos",
|
||||
"favorite_other": "Favoritos",
|
||||
"folder_one": "Pasta",
|
||||
"folder_many": "pastas",
|
||||
"folder_many": "Pastas",
|
||||
"folder_other": "Pastas",
|
||||
"folderWithCount_one": "{{count}} pasta",
|
||||
"folderWithCount_many": "{{count}} pastas",
|
||||
"folderWithCount_other": "{{count}} pastas",
|
||||
"genre_one": "Gênero",
|
||||
"genre_many": "gêneros",
|
||||
"genre_many": "Gêneros",
|
||||
"genre_other": "Gêneros",
|
||||
"genreWithCount_one": "{{count}} gênero",
|
||||
"genreWithCount_many": "{{count}} gêneros",
|
||||
"genreWithCount_other": "{{count}} gêneros",
|
||||
"playlist_one": "Playlist",
|
||||
"playlist_many": "playlists",
|
||||
"playlist_many": "Playlists",
|
||||
"playlist_other": "Playlists",
|
||||
"play_one": "{{count}} reprodução",
|
||||
"play_many": "{{count}} reproduções",
|
||||
@@ -167,12 +167,12 @@
|
||||
"playlistWithCount_one": "{{count}} playlist",
|
||||
"playlistWithCount_many": "{{count}} playlists",
|
||||
"playlistWithCount_other": "{{count}} playlists",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) inteligente",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Inteligente",
|
||||
"track_one": "Faixa",
|
||||
"track_many": "faixas",
|
||||
"track_many": "Faixas",
|
||||
"track_other": "Faixas",
|
||||
"song_one": "Música",
|
||||
"song_many": "músicas",
|
||||
"song_many": "Músicas",
|
||||
"song_other": "Músicas",
|
||||
"trackWithCount_one": "{{count}} faixa",
|
||||
"trackWithCount_many": "{{count}} faixas",
|
||||
|
||||
@@ -580,25 +580,25 @@
|
||||
},
|
||||
"entity": {
|
||||
"genre_one": "Žanr",
|
||||
"genre_few": "žanrova",
|
||||
"genre_few": "Žanrova",
|
||||
"genre_other": "Žanrova",
|
||||
"playlistWithCount_one": "{{count}} plejlista",
|
||||
"playlistWithCount_few": "{{count}} plejlista",
|
||||
"playlistWithCount_other": "{{count}} plejlista",
|
||||
"playlist_one": "Plejlista",
|
||||
"playlist_few": "plejlista",
|
||||
"playlist_few": "Plejlista",
|
||||
"playlist_other": "Plejlista",
|
||||
"artist_one": "Umetnik",
|
||||
"artist_few": "umetnika",
|
||||
"artist_few": "Umetnika",
|
||||
"artist_other": "Umetnika",
|
||||
"folderWithCount_one": "{{count}} folder",
|
||||
"folderWithCount_few": "{{count}} foldera",
|
||||
"folderWithCount_other": "{{count}} foldera",
|
||||
"albumArtist_one": "Album umetnika",
|
||||
"albumArtist_few": "albuma umetnika",
|
||||
"albumArtist_few": "Albuma umetnika",
|
||||
"albumArtist_other": "Albuma umetnika",
|
||||
"track_one": "Pesma",
|
||||
"track_few": "pesama",
|
||||
"track_few": "Pesama",
|
||||
"track_other": "Pesama",
|
||||
"albumArtistCount_one": "{{count}} album umetnika",
|
||||
"albumArtistCount_few": "{{count}} albuma umetnika",
|
||||
@@ -607,17 +607,17 @@
|
||||
"albumWithCount_few": "{{count}} albuma",
|
||||
"albumWithCount_other": "{{count}} albuma",
|
||||
"favorite_one": "Favorit",
|
||||
"favorite_few": "favorita",
|
||||
"favorite_few": "Favorita",
|
||||
"favorite_other": "Favorita",
|
||||
"artistWithCount_one": "{{count}} umetnik",
|
||||
"artistWithCount_few": "{{count}} umetnika",
|
||||
"artistWithCount_other": "{{count}} umetnika",
|
||||
"folder_one": "Folder",
|
||||
"folder_few": "foldera",
|
||||
"folder_few": "Foldera",
|
||||
"folder_other": "Foldera",
|
||||
"smartPlaylist": "Pametna $t(entity.playlist, {\"count\": 1})",
|
||||
"album_one": "Album",
|
||||
"album_few": "albumi",
|
||||
"album_few": "Albumi",
|
||||
"album_other": "Albuma",
|
||||
"genreWithCount_one": "{{count}} žanr",
|
||||
"genreWithCount_few": "{{count}} žanrova",
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
"share": "分享",
|
||||
"tags": "標籤",
|
||||
"trackGain": "曲目增益",
|
||||
"trackPeak": "歌曲峰值",
|
||||
"trackPeak": "曲目峰值",
|
||||
"translation": "翻譯",
|
||||
"doNotShowAgain": "不再顯示",
|
||||
"externalLinks": "外部連結",
|
||||
@@ -103,8 +103,8 @@
|
||||
"sort": "排序",
|
||||
"tableColumns": "表格欄位",
|
||||
"clean": "清除",
|
||||
"explicitStatus": "Explicit狀態",
|
||||
"explicit": "Explicit",
|
||||
"explicitStatus": "露骨狀態",
|
||||
"explicit": "露骨",
|
||||
"gridRows": "網格行",
|
||||
"noFilters": "未設定任何過濾器",
|
||||
"countSelected": "{{count}}個已選取",
|
||||
@@ -128,7 +128,7 @@
|
||||
"credentialsRequired": "需要憑證",
|
||||
"genericError": "發生了錯誤",
|
||||
"invalidServer": "無效的伺服器",
|
||||
"localFontAccessDenied": "無法取得本地字體",
|
||||
"localFontAccessDenied": "無法取得本地字型",
|
||||
"loginRateError": "登入請求嘗試次數過多,請稍後再試",
|
||||
"remoteDisableError": "$t(common.disable)遠端伺服器時出現錯誤",
|
||||
"remoteEnableError": "$t(common.enable)遠端伺服器時出現錯誤",
|
||||
@@ -136,7 +136,7 @@
|
||||
"remotePortWarning": "重啟伺服器使新連接埠生效",
|
||||
"serverRequired": "需要伺服器",
|
||||
"sessionExpiredError": "工作階段已過期",
|
||||
"systemFontError": "嘗試取得系統字體時出現錯誤",
|
||||
"systemFontError": "嘗試取得系統字型時出現錯誤",
|
||||
"serverNotSelectedError": "未選擇伺服器",
|
||||
"mpvRequired": "需要 MPV",
|
||||
"playbackError": "無法播放媒體",
|
||||
@@ -221,7 +221,7 @@
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
"showLyricProvider": "顯示歌詞提供者",
|
||||
"useImageAspectRatio": "使用圖片縱橫比",
|
||||
"useImageAspectRatio": "使用圖片長寬比",
|
||||
"dynamicBackground": "動態背景",
|
||||
"followCurrentLyric": "跟隨目前歌詞",
|
||||
"lyricAlignment": "歌詞對齊",
|
||||
@@ -440,8 +440,8 @@
|
||||
"crossfadeDuration": "淡入淡出持續時間",
|
||||
"crossfadeDuration_description": "設定淡入淡出持續時間",
|
||||
"crossfadeStyle_description": "選擇用於音訊播放器的淡入淡出風格",
|
||||
"customFontPath": "自定字體路徑",
|
||||
"customFontPath_description": "設定應用程式使用的自定字體路徑",
|
||||
"customFontPath": "自訂字型路徑",
|
||||
"customFontPath_description": "設定應用程式要使用的自訂字型路徑",
|
||||
"disableLibraryUpdateOnStartup": "禁用啟動時檢查新版本",
|
||||
"discordApplicationId": "{{discord}} 應用程式 ID",
|
||||
"discordApplicationId_description": "{{discord}} Rich Presence 應用程式 ID(預設為 {{defaultId}})",
|
||||
@@ -454,12 +454,12 @@
|
||||
"enableRemote_description": "啟用遠端控制伺服器,以允許其他設備控制此應用程式",
|
||||
"exitToTray": "關閉時到將視窗最小化",
|
||||
"followLyric": "跟隨目前歌詞",
|
||||
"font_description": "設定應用程式使用的字體",
|
||||
"fontType": "字體類型",
|
||||
"fontType_description": "內建字體可以選擇 Feishin 提供的字體之一。系統字體允許您選擇作業系統提供的任何字體。自定選項允許您使用自己的字體",
|
||||
"fontType_optionBuiltIn": "內建字體",
|
||||
"fontType_optionCustom": "自定字體",
|
||||
"fontType_optionSystem": "系統字體",
|
||||
"font_description": "設定應用程式使用的字型",
|
||||
"fontType": "字型類型",
|
||||
"fontType_description": "內建字型可以選擇 Feishin 提供的字型之一。系統字型允許您選擇作業系統提供的任何字型。自訂選項允許您使用自己的字型",
|
||||
"fontType_optionBuiltIn": "內建字型",
|
||||
"fontType_optionCustom": "自訂字型",
|
||||
"fontType_optionSystem": "系統字型",
|
||||
"gaplessAudio": "無間隔音訊",
|
||||
"gaplessAudio_description": "調整 MPV 無間隔音訊設定",
|
||||
"gaplessAudio_optionWeak": "弱(建議)",
|
||||
@@ -557,7 +557,7 @@
|
||||
"playbackStyle": "播放風格",
|
||||
"exitToTray_description": "退出應用程式時最小化到系統匣而非關閉",
|
||||
"followLyric_description": "滾動歌詞到目前播放位置",
|
||||
"font": "字體",
|
||||
"font": "字型",
|
||||
"globalMediaHotkeys_description": "啟用或禁用系統媒體快捷鍵以控制播放",
|
||||
"hotkey_browserBack": "瀏覽器返回",
|
||||
"hotkey_favoriteCurrentSong": "收藏 $t(common.currentSong)",
|
||||
@@ -764,7 +764,7 @@
|
||||
"sidebarPlaylistListFilterRegex": "播放清單過濾器正規表達式",
|
||||
"blurExplicitImages": "模糊露骨圖片",
|
||||
"blurExplicitImages_description": "標記為露骨的專輯和歌曲封面將被模糊",
|
||||
"releaseChannel_optionAlpha": "alpha (每日建構版)",
|
||||
"releaseChannel_optionAlpha": "Alpha (每日建構版)",
|
||||
"analyticsEnable": "傳送基於使用情況的分析報告",
|
||||
"analyticsEnable_description": "匿名化的使用情況資料會傳送給開發者,以協助改進應用程式",
|
||||
"automaticUpdates": "自動更新",
|
||||
@@ -985,7 +985,7 @@
|
||||
"comment": "評論",
|
||||
"communityRating": "社群評分",
|
||||
"criticRating": "評論家評分",
|
||||
"dateAdded": "已新增日期",
|
||||
"dateAdded": "新增日期",
|
||||
"disc": "光碟",
|
||||
"duration": "時長",
|
||||
"id": "ID",
|
||||
@@ -1034,8 +1034,8 @@
|
||||
"success": "伺服器新增成功",
|
||||
"title": "新增伺服器",
|
||||
"error_savePassword": "儲存密碼時出現錯誤",
|
||||
"ignoreCors": "忽略 cors $t(common.restartRequired)",
|
||||
"ignoreSsl": "忽略 ssl $t(common.restartRequired)",
|
||||
"ignoreCors": "忽略 CORS $t(common.restartRequired)",
|
||||
"ignoreSsl": "忽略 SSL $t(common.restartRequired)",
|
||||
"input_preferInstantMix": "偏好即時混音",
|
||||
"input_preferInstantMixDescription": "僅使用即時混音功能來取得相似歌曲。若您擁有能修改此行為的外掛,此功能將相當實用",
|
||||
"input_preferRemoteUrl": "優先使用公開網址",
|
||||
@@ -1098,8 +1098,8 @@
|
||||
"successMustClick": "分享建立成功,點擊此處開啟"
|
||||
},
|
||||
"privateMode": {
|
||||
"enabled": "已啟用私人模式,播放狀態將對外部整合隱藏",
|
||||
"disabled": "已停用私人模式,播放狀態現對已啟用的外部整合可見",
|
||||
"enabled": "已啟用私人模式,播放狀態現在將對外部整合功能隱藏",
|
||||
"disabled": "已停用私人模式,啟用的外部整合功能現在可查看播放狀態",
|
||||
"title": "私人模式"
|
||||
},
|
||||
"largeFetchConfirmation": {
|
||||
|
||||
@@ -59,9 +59,24 @@
|
||||
.slider-value-wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 50px;
|
||||
min-height: 0;
|
||||
|
||||
@media (width < 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-value-wrapper-elapsed {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
max-width: 4.75rem;
|
||||
min-height: 0;
|
||||
|
||||
@media (width < 768px) {
|
||||
display: none;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { lazy, Suspense } from 'react';
|
||||
import { PlayerbarSeekSlider } from './playerbar-seek-slider';
|
||||
import styles from './playerbar-slider.module.css';
|
||||
|
||||
import { ScrobbleStatus } from '/@/renderer/features/player/components/scrobble-status';
|
||||
import {
|
||||
useAppStore,
|
||||
useAppStoreActions,
|
||||
@@ -41,17 +42,8 @@ export const PlayerbarSlider = () => {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.sliderContainer}>
|
||||
<div className={styles.sliderValueWrapper}>
|
||||
<Text
|
||||
className={PlaybackSelectors.elapsedTime}
|
||||
fw={600}
|
||||
isMuted
|
||||
isNoSelect
|
||||
size="xs"
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
<div className={styles.sliderValueWrapperElapsed}>
|
||||
<ScrobbleStatus formattedTime={formattedTime} />
|
||||
</div>
|
||||
<div className={styles.sliderWrapper}>
|
||||
{isWaveform ? (
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
invokeScrobbleForceSubmit,
|
||||
invokeScrobbleResetListenedState,
|
||||
} from '/@/renderer/features/player/hooks/use-scrobble';
|
||||
import { useAppStore, useScrobbleDebugStore, useSettingsStore } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Progress } from '/@/shared/components/progress/progress';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
||||
|
||||
const scrobbleProgressProps = {
|
||||
'aria-hidden': true,
|
||||
color: 'var(--theme-colors-primary)',
|
||||
size: 'xs' as const,
|
||||
};
|
||||
|
||||
const clampPct = (n: number) => Math.min(100, Math.max(0, n));
|
||||
|
||||
const ScrobbleConditionProgress = ({ value }: { value: number }) => (
|
||||
<Progress {...scrobbleProgressProps} value={value} w="100%" />
|
||||
);
|
||||
|
||||
export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const scrobbleEnabled = useSettingsStore((state) => state.playback.scrobble.enabled);
|
||||
const privateMode = useAppStore((state) => state.privateMode);
|
||||
const snapshot = useScrobbleDebugStore((state) => state.snapshot);
|
||||
|
||||
const hookInactive = !scrobbleEnabled || privateMode;
|
||||
|
||||
const listenedSec = (snapshot.listenedMs / 1000).toFixed(1);
|
||||
const listenPercentOfTrack =
|
||||
snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0;
|
||||
|
||||
const durationConditionPct =
|
||||
snapshot.targetDurationSec > 0
|
||||
? clampPct((snapshot.listenedMs / 1000 / snapshot.targetDurationSec) * 100)
|
||||
: 0;
|
||||
const percentConditionPct =
|
||||
snapshot.targetPercentage > 0 && snapshot.trackDurationMs > 0
|
||||
? clampPct((listenPercentOfTrack / snapshot.targetPercentage) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<HoverCard position="top" width={280}>
|
||||
<HoverCard.Target>
|
||||
<Group
|
||||
align="center"
|
||||
aria-label={`${t('player.scrobble')}, ${formattedTime}`}
|
||||
fz="xs"
|
||||
gap="sm"
|
||||
justify="center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ userSelect: 'none' }}
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Icon
|
||||
aria-hidden
|
||||
color={snapshot.submitted ? 'primary' : 'transparent'}
|
||||
fill={snapshot.submitted ? 'primary' : 'transparent'}
|
||||
icon="circle"
|
||||
size="0.375rem"
|
||||
/>
|
||||
<Text
|
||||
className={PlaybackSelectors.elapsedTime}
|
||||
fw={600}
|
||||
fz="inherit"
|
||||
isMuted
|
||||
isNoSelect
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
</Group>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
|
||||
<Stack gap="md" p="sm">
|
||||
{hookInactive ? (
|
||||
<Text size="sm">{t('form.privateMode.enabled')}</Text>
|
||||
) : (
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs">
|
||||
{`${listenedSec}s / ${snapshot.targetDurationSec}s`}
|
||||
</Text>
|
||||
<ScrobbleConditionProgress value={durationConditionPct} />
|
||||
</Stack>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs">
|
||||
{`${listenPercentOfTrack.toFixed(1)}% / ${snapshot.targetPercentage}%`}
|
||||
</Text>
|
||||
<ScrobbleConditionProgress value={percentConditionPct} />
|
||||
</Stack>
|
||||
<Group gap="xs" grow wrap="nowrap">
|
||||
<Button
|
||||
disabled={!snapshot.songId}
|
||||
onClick={() => invokeScrobbleResetListenedState()}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
>
|
||||
{t('common.reset')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!snapshot.songId || snapshot.submitted}
|
||||
onClick={() => invokeScrobbleForceSubmit()}
|
||||
size="xs"
|
||||
variant="filled"
|
||||
>
|
||||
{t('player.scrobbleForceSubmit')}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
|
||||
import {
|
||||
publishScrobbleDebug,
|
||||
useAppStore,
|
||||
usePlaybackSettings,
|
||||
usePlayerSong,
|
||||
@@ -16,34 +17,64 @@ import { logMsg } from '/@/renderer/utils/logger-message';
|
||||
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
type ScrobbleManualHandlers = {
|
||||
forceSubmitScrobble: () => void;
|
||||
resetListenedState: () => void;
|
||||
};
|
||||
|
||||
let scrobbleManualHandlers: null | ScrobbleManualHandlers = null;
|
||||
|
||||
export const registerScrobbleManualHandlers = (next: null | ScrobbleManualHandlers) => {
|
||||
scrobbleManualHandlers = next;
|
||||
};
|
||||
|
||||
export const invokeScrobbleForceSubmit = () => {
|
||||
scrobbleManualHandlers?.forceSubmitScrobble();
|
||||
};
|
||||
|
||||
export const invokeScrobbleResetListenedState = () => {
|
||||
scrobbleManualHandlers?.resetListenedState();
|
||||
};
|
||||
|
||||
/*
|
||||
Scrobble Conditions (match any):
|
||||
- If the song has been played for the required percentage
|
||||
- If the song has been played for the required duration
|
||||
Submission (Last.fm / etc.) eligibility uses accumulated listen time:
|
||||
- If listened time meets the required percentage of track duration
|
||||
- If listened time meets the required duration (seconds)
|
||||
|
||||
Scrobble Events:
|
||||
- On song timestamp update:
|
||||
- If the song has been played for the required percentage
|
||||
- If the song has been played for the required duration
|
||||
Listen time advances only while PLAYING, from consecutive timestamp deltas.
|
||||
Seeks and other timeline jumps re-baseline the next sample without counting
|
||||
the jump as listen time; accumulated listen time is kept across seeks.
|
||||
|
||||
- When the song changes (or is completed):
|
||||
- Current song: Sends the 'playing' scrobble event
|
||||
- Resets the 'isCurrentSongScrobbled' state to false
|
||||
Listen time and submission state reset when the playhead returns to the start
|
||||
of the track (position before SCROBBLE_TRACK_BEGIN_SEC), e.g. seek-to-start or
|
||||
restart-from-near-zero. Song change and repeat still reset for a new play-through.
|
||||
|
||||
- When the song is restarted:
|
||||
- Sends the 'submission' scrobble event if conditions are met AND the 'isCurrentSongScrobbled' state is false
|
||||
- Resets the 'isCurrentSongScrobbled' state to false
|
||||
Jellyfin progress APIs still use playback position (ticks), not listen time:
|
||||
- Periodic timeupdate while playing
|
||||
- timeupdate on seek
|
||||
- pause / unpause
|
||||
|
||||
- When the song is seeked:
|
||||
- Sends the 'timeupdate' scrobble event (Jellyfin only)
|
||||
Other events:
|
||||
- When the song changes: sends 'start' when the new track is playing;
|
||||
clears submission flag and listen accumulator for the new track.
|
||||
|
||||
- When the song is restarted (near 0 after 10s+): clears submission flag
|
||||
and listen accumulator.
|
||||
|
||||
Progress Events:
|
||||
- When the song is playing (Jellyfin only):
|
||||
- Sends the 'progress' scrobble event on an interval
|
||||
|
||||
- When the song is seeked: Jellyfin sends timeupdate (throttled). Seeking from
|
||||
at/after the intro into the start of the track clears listen accumulator and
|
||||
submission flag; other seeks keep accumulated listen time.
|
||||
*/
|
||||
|
||||
// Positions before this time (seconds) count as the start of the track for listen/scrobble resets.
|
||||
const SCROBBLE_TRACK_BEGIN_SEC = 5;
|
||||
|
||||
// Min previous position (seconds) to treat a jump to the start as a full restart.
|
||||
const SCROBBLE_RESTART_PREVIOUS_MIN_SEC = 10;
|
||||
|
||||
// Max seconds between timestamp samples to count as continuous play (above poll interval, below a teleport).
|
||||
const MAX_LISTEN_DELTA_SEC = 5;
|
||||
|
||||
const checkScrobbleConditions = (args: {
|
||||
scrobbleAtDurationMs: number;
|
||||
scrobbleAtPercentage: number;
|
||||
@@ -56,10 +87,10 @@ const checkScrobbleConditions = (args: {
|
||||
? (songCompletedDurationMs / songDurationMs) * 100
|
||||
: 0;
|
||||
|
||||
const shouldScrobbleBasedOnPercetange = percentageOfSongCompleted >= scrobbleAtPercentage;
|
||||
const shouldScrobbleBasedOnPercentage = percentageOfSongCompleted >= scrobbleAtPercentage;
|
||||
const shouldScrobbleBasedOnDuration = songCompletedDurationMs >= scrobbleAtDurationMs;
|
||||
|
||||
return shouldScrobbleBasedOnPercetange || shouldScrobbleBasedOnDuration;
|
||||
return shouldScrobbleBasedOnPercentage || shouldScrobbleBasedOnDuration;
|
||||
};
|
||||
|
||||
export const useScrobble = () => {
|
||||
@@ -77,7 +108,12 @@ export const useScrobble = () => {
|
||||
});
|
||||
|
||||
const imageUrlRef = useRef<null | string | undefined>(imageUrl);
|
||||
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
|
||||
const isCurrentSongScrobbledRef = useRef(false);
|
||||
const listenedMsRef = useRef(0);
|
||||
const lastListenSampleTimeRef = useRef<null | number>(null);
|
||||
const scrobbleAtDurationMsRef = useRef(0);
|
||||
const scrobbleAtPercentageRef = useRef(75);
|
||||
|
||||
const previousSongRef = useRef<QueueSong | undefined>(undefined);
|
||||
const previousTimestampRef = useRef<number>(0);
|
||||
const lastProgressEventRef = useRef<number>(0);
|
||||
@@ -89,30 +125,101 @@ export const useScrobble = () => {
|
||||
imageUrlRef.current = imageUrl;
|
||||
}, [imageUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
scrobbleAtDurationMsRef.current = (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000;
|
||||
scrobbleAtPercentageRef.current = scrobbleSettings?.scrobbleAtPercentage ?? 75;
|
||||
}, [scrobbleSettings?.scrobbleAtDuration, scrobbleSettings?.scrobbleAtPercentage]);
|
||||
|
||||
const flushScrobbleDebug = useCallback(() => {
|
||||
const song = usePlayerStore.getState().getCurrentSong();
|
||||
const status = usePlayerStore.getState().player.status;
|
||||
const positionSec = useTimestampStoreBase.getState().timestamp;
|
||||
const trackDurationMs = song?.duration ?? 0;
|
||||
|
||||
const eligibilityMet = Boolean(
|
||||
song?.id &&
|
||||
checkScrobbleConditions({
|
||||
scrobbleAtDurationMs: scrobbleAtDurationMsRef.current,
|
||||
scrobbleAtPercentage: scrobbleAtPercentageRef.current,
|
||||
songCompletedDurationMs: listenedMsRef.current,
|
||||
songDurationMs: trackDurationMs,
|
||||
}),
|
||||
);
|
||||
|
||||
publishScrobbleDebug({
|
||||
eligibilityMet,
|
||||
lastListenSampleTimeSec: lastListenSampleTimeRef.current,
|
||||
listenedMs: listenedMsRef.current,
|
||||
playerStatus: status,
|
||||
positionSec,
|
||||
songId: song?.id,
|
||||
songName: song?.name,
|
||||
submitted: isCurrentSongScrobbledRef.current,
|
||||
targetDurationSec: scrobbleAtDurationMsRef.current / 1000,
|
||||
targetPercentage: scrobbleAtPercentageRef.current,
|
||||
trackDurationMs,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleScrobbleFromProgress = useCallback(
|
||||
(properties: { timestamp: number }, prev: { timestamp: number }) => {
|
||||
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
|
||||
|
||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||
const currentStatus = usePlayerStore.getState().player.status;
|
||||
|
||||
if (!currentSong?.id || currentStatus !== PlayerStatus.PLAYING) return;
|
||||
|
||||
const currentTime = properties.timestamp;
|
||||
const previousTime = prev.timestamp;
|
||||
|
||||
// Detect song restart: when timestamp resets to near 0 and was playing for at least 10 seconds
|
||||
if (!currentSong?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStatus !== PlayerStatus.PLAYING) {
|
||||
lastListenSampleTimeRef.current = currentTime;
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect song restart: when timestamp resets to near 0 and was playing past the intro
|
||||
if (
|
||||
currentTime < previousTime &&
|
||||
currentTime < 5 && // Reset to near 0
|
||||
previousTime >= 10 // Was playing for at least 10 seconds
|
||||
currentTime < SCROBBLE_TRACK_BEGIN_SEC &&
|
||||
previousTime >= SCROBBLE_RESTART_PREVIOUS_MIN_SEC
|
||||
) {
|
||||
setIsCurrentSongScrobbled(false);
|
||||
isCurrentSongScrobbledRef.current = false;
|
||||
lastProgressEventRef.current = 0;
|
||||
previousTimestampRef.current = 0;
|
||||
listenedMsRef.current = 0;
|
||||
lastListenSampleTimeRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const lastSample = lastListenSampleTimeRef.current;
|
||||
if (lastSample === null) {
|
||||
const prevSec = prev.timestamp;
|
||||
if (currentTime > prevSec && currentTime - prevSec <= MAX_LISTEN_DELTA_SEC) {
|
||||
listenedMsRef.current += (currentTime - prevSec) * 1000;
|
||||
}
|
||||
lastListenSampleTimeRef.current = currentTime;
|
||||
} else {
|
||||
const deltaSec = currentTime - lastSample;
|
||||
const jumpedBackToTrackStart =
|
||||
currentTime < lastSample &&
|
||||
currentTime < SCROBBLE_TRACK_BEGIN_SEC &&
|
||||
lastSample >= SCROBBLE_TRACK_BEGIN_SEC;
|
||||
|
||||
if (jumpedBackToTrackStart) {
|
||||
listenedMsRef.current = 0;
|
||||
isCurrentSongScrobbledRef.current = false;
|
||||
lastProgressEventRef.current = 0;
|
||||
lastListenSampleTimeRef.current = currentTime;
|
||||
} else if (currentTime < lastSample || deltaSec > MAX_LISTEN_DELTA_SEC) {
|
||||
lastListenSampleTimeRef.current = currentTime;
|
||||
} else if (deltaSec > 0) {
|
||||
listenedMsRef.current += deltaSec * 1000;
|
||||
lastListenSampleTimeRef.current = currentTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Send Jellyfin progress events every 10 seconds
|
||||
if (currentSong._serverType === ServerType.JELLYFIN) {
|
||||
const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
|
||||
@@ -144,12 +251,12 @@ export const useScrobble = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should submit scrobble based on conditions
|
||||
if (!isCurrentSongScrobbled) {
|
||||
// Check if we should submit scrobble based on listened time
|
||||
if (!isCurrentSongScrobbledRef.current) {
|
||||
const shouldSubmitScrobble = checkScrobbleConditions({
|
||||
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
|
||||
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
||||
songCompletedDurationMs: currentTime * 1000,
|
||||
scrobbleAtDurationMs: scrobbleAtDurationMsRef.current,
|
||||
scrobbleAtPercentage: scrobbleAtPercentageRef.current,
|
||||
songCompletedDurationMs: listenedMsRef.current,
|
||||
songDurationMs: currentSong.duration,
|
||||
});
|
||||
|
||||
@@ -177,25 +284,18 @@ export const useScrobble = () => {
|
||||
category: LogCategory.SCROBBLE,
|
||||
meta: {
|
||||
id: currentSong.id,
|
||||
reason: 'from song progress',
|
||||
reason: 'from listened time',
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
setIsCurrentSongScrobbled(true);
|
||||
isCurrentSongScrobbledRef.current = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
isScrobbleEnabled,
|
||||
isPrivateModeEnabled,
|
||||
scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleSettings?.scrobbleAtPercentage,
|
||||
isCurrentSongScrobbled,
|
||||
sendScrobble,
|
||||
],
|
||||
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||
);
|
||||
|
||||
const handleScrobbleFromSongChange = useCallback(
|
||||
@@ -240,11 +340,16 @@ export const useScrobble = () => {
|
||||
if (!isScrobbleEnabled || isPrivateModeEnabled) {
|
||||
previousSongRef.current = currentSong;
|
||||
previousTimestampRef.current = 0;
|
||||
listenedMsRef.current = 0;
|
||||
lastListenSampleTimeRef.current = null;
|
||||
flushScrobbleDebug();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCurrentSongScrobbled(false);
|
||||
isCurrentSongScrobbledRef.current = false;
|
||||
lastProgressEventRef.current = 0;
|
||||
listenedMsRef.current = 0;
|
||||
lastListenSampleTimeRef.current = null;
|
||||
|
||||
// Use a timeout to prevent spamming the server when switching songs quickly
|
||||
clearTimeout(songChangeTimeoutRef.current);
|
||||
@@ -280,8 +385,15 @@ export const useScrobble = () => {
|
||||
|
||||
previousSongRef.current = currentSong;
|
||||
previousTimestampRef.current = 0;
|
||||
flushScrobbleDebug();
|
||||
},
|
||||
[scrobbleSettings?.notify, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||
[
|
||||
flushScrobbleDebug,
|
||||
scrobbleSettings?.notify,
|
||||
isScrobbleEnabled,
|
||||
isPrivateModeEnabled,
|
||||
sendScrobble,
|
||||
],
|
||||
);
|
||||
|
||||
const handleScrobbleFromSeek = useCallback(
|
||||
@@ -297,8 +409,21 @@ export const useScrobble = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const sampleBeforeSeek = lastListenSampleTimeRef.current;
|
||||
lastListenSampleTimeRef.current = properties.timestamp;
|
||||
|
||||
if (
|
||||
properties.timestamp < SCROBBLE_TRACK_BEGIN_SEC &&
|
||||
(sampleBeforeSeek === null || sampleBeforeSeek >= SCROBBLE_TRACK_BEGIN_SEC)
|
||||
) {
|
||||
listenedMsRef.current = 0;
|
||||
isCurrentSongScrobbledRef.current = false;
|
||||
lastProgressEventRef.current = 0;
|
||||
}
|
||||
|
||||
// Position scrobbles are only relevant for Jellyfin
|
||||
if (currentSong._serverType !== ServerType.JELLYFIN) {
|
||||
flushScrobbleDebug();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -307,6 +432,7 @@ export const useScrobble = () => {
|
||||
|
||||
// Only allow seek scrobble once per second
|
||||
if (timeSinceLastSeek < 1000) {
|
||||
flushScrobbleDebug();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -337,8 +463,9 @@ export const useScrobble = () => {
|
||||
},
|
||||
},
|
||||
);
|
||||
flushScrobbleDebug();
|
||||
},
|
||||
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||
);
|
||||
|
||||
const handleScrobbleFromStatus = useCallback(
|
||||
@@ -412,8 +539,10 @@ export const useScrobble = () => {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
flushScrobbleDebug();
|
||||
},
|
||||
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||
);
|
||||
|
||||
const handleScrobbleFromRepeat = useCallback(() => {
|
||||
@@ -428,9 +557,11 @@ export const useScrobble = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCurrentSongScrobbled(false);
|
||||
isCurrentSongScrobbledRef.current = false;
|
||||
lastProgressEventRef.current = 0;
|
||||
previousTimestampRef.current = 0;
|
||||
listenedMsRef.current = 0;
|
||||
lastListenSampleTimeRef.current = null;
|
||||
|
||||
sendScrobble.mutate(
|
||||
{
|
||||
@@ -455,17 +586,81 @@ export const useScrobble = () => {
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]);
|
||||
flushScrobbleDebug();
|
||||
}, [flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]);
|
||||
|
||||
// Update previous timestamp on progress for use in status change handler
|
||||
const handleProgressUpdate = useCallback(
|
||||
(properties: { timestamp: number }, prev: { timestamp: number }) => {
|
||||
previousTimestampRef.current = properties.timestamp;
|
||||
handleScrobbleFromProgress(properties, prev);
|
||||
flushScrobbleDebug();
|
||||
},
|
||||
[handleScrobbleFromProgress],
|
||||
[flushScrobbleDebug, handleScrobbleFromProgress],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
registerScrobbleManualHandlers({
|
||||
forceSubmitScrobble: () => {
|
||||
if (!isScrobbleEnabled || isPrivateModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const song = usePlayerStore.getState().getCurrentSong();
|
||||
if (!song?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position =
|
||||
song._serverType === ServerType.JELLYFIN ? song.duration * 1e7 : undefined;
|
||||
|
||||
sendScrobble.mutate(
|
||||
{
|
||||
apiClientProps: { serverId: song._serverId || '' },
|
||||
query: {
|
||||
albumId: song.albumId,
|
||||
id: song.id,
|
||||
position,
|
||||
submission: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledSubmission, {
|
||||
category: LogCategory.SCROBBLE,
|
||||
meta: {
|
||||
id: song.id,
|
||||
reason: 'forced from UI',
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
isCurrentSongScrobbledRef.current = true;
|
||||
flushScrobbleDebug();
|
||||
},
|
||||
resetListenedState: () => {
|
||||
if (!isScrobbleEnabled || isPrivateModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const song = usePlayerStore.getState().getCurrentSong();
|
||||
if (!song?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
listenedMsRef.current = 0;
|
||||
isCurrentSongScrobbledRef.current = false;
|
||||
lastProgressEventRef.current = 0;
|
||||
lastListenSampleTimeRef.current = useTimestampStoreBase.getState().timestamp;
|
||||
flushScrobbleDebug();
|
||||
},
|
||||
});
|
||||
|
||||
return () => registerScrobbleManualHandlers(null);
|
||||
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, sendScrobble]);
|
||||
|
||||
usePlayerEvents(
|
||||
{
|
||||
onCurrentSongChange: handleScrobbleFromSongChange,
|
||||
|
||||
Vendored
+3
@@ -65,13 +65,16 @@ declare global {
|
||||
FS_LYRICS_TRANSLATION_API_KEY?: string;
|
||||
FS_LYRICS_TRANSLATION_TARGET_LANGUAGE?: string;
|
||||
FS_PLAYBACK_AUDIO_FADE_ON_STATUS_CHANGE?: string;
|
||||
FS_PLAYBACK_FILTERS?: string;
|
||||
FS_PLAYBACK_MEDIA_SESSION?: string;
|
||||
FS_PLAYBACK_PRESERVE_PITCH?: string;
|
||||
FS_PLAYBACK_SCROBBLE_AT_DURATION?: string;
|
||||
FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE?: string;
|
||||
FS_PLAYBACK_SCROBBLE_ENABLED?: string;
|
||||
FS_PLAYBACK_SCROBBLE_NOTIFY?: string;
|
||||
FS_PLAYBACK_TRANSCODE_BITRATE?: string;
|
||||
FS_PLAYBACK_TRANSCODE_ENABLED?: string;
|
||||
FS_PLAYBACK_TRANSCODE_FORMAT?: string;
|
||||
FS_PLAYBACK_WEB_AUDIO?: string;
|
||||
LEGACY_AUTHENTICATION?: boolean | string;
|
||||
REMOTE_URL?: string;
|
||||
|
||||
@@ -1,7 +1,73 @@
|
||||
import type { SettingsState } from './settings.store';
|
||||
import type { PlayerFilter, SettingsState } from './settings.store';
|
||||
|
||||
import { sanitizeCss } from '/@/renderer/utils/sanitize';
|
||||
|
||||
const PLAYER_FILTER_FIELDS = new Set([
|
||||
'albumArtist',
|
||||
'artist',
|
||||
'duration',
|
||||
'favorite',
|
||||
'genre',
|
||||
'name',
|
||||
'note',
|
||||
'path',
|
||||
'playCount',
|
||||
'rating',
|
||||
'year',
|
||||
]);
|
||||
|
||||
const PLAYER_FILTER_OPERATORS = new Set([
|
||||
'after',
|
||||
'afterDate',
|
||||
'before',
|
||||
'beforeDate',
|
||||
'contains',
|
||||
'endsWith',
|
||||
'gt',
|
||||
'inTheLast',
|
||||
'inTheRange',
|
||||
'inTheRangeDate',
|
||||
'is',
|
||||
'isNot',
|
||||
'lt',
|
||||
'notContains',
|
||||
'notInTheLast',
|
||||
'regex',
|
||||
'startsWith',
|
||||
]);
|
||||
|
||||
function isValidPlayerFilter(item: unknown): item is PlayerFilter {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) return false;
|
||||
const o = item as Record<string, unknown>;
|
||||
if (typeof o.id !== 'string') return false;
|
||||
if (typeof o.field !== 'string' || !PLAYER_FILTER_FIELDS.has(o.field)) return false;
|
||||
if (typeof o.operator !== 'string' || !PLAYER_FILTER_OPERATORS.has(o.operator)) return false;
|
||||
if (!isValidPlayerFilterValue(o.value)) return false;
|
||||
if (o.isEnabled !== undefined && typeof o.isEnabled !== 'boolean') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isValidPlayerFilterValue(value: unknown): boolean {
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return true;
|
||||
}
|
||||
if (!Array.isArray(value)) return false;
|
||||
return value.every((v) => typeof v === 'string' || typeof v === 'number');
|
||||
}
|
||||
|
||||
function parsePlaybackFiltersJson(raw: string): unknown {
|
||||
const t = raw.trim();
|
||||
if (t === '') return undefined;
|
||||
try {
|
||||
const v = JSON.parse(t) as unknown;
|
||||
if (!Array.isArray(v)) return undefined;
|
||||
if (!v.every(isValidPlayerFilter)) return undefined;
|
||||
return v;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const APP_THEMES = new Set([
|
||||
'ayuDark',
|
||||
'ayuLight',
|
||||
@@ -55,28 +121,29 @@ type DeepPartial<T> = {
|
||||
interface EnvSettingSpec {
|
||||
enumSet?: Set<string>;
|
||||
key: string;
|
||||
path: [string, string, string] | [string, string];
|
||||
path: readonly string[];
|
||||
skipIfEmpty?: boolean;
|
||||
transform?: (raw: string) => unknown;
|
||||
type: 'bool' | 'enum' | 'num' | 'string';
|
||||
}
|
||||
|
||||
function setAtPath(
|
||||
obj: EnvSettingsOverrides,
|
||||
path: [string, string, string] | [string, string],
|
||||
value: unknown,
|
||||
): void {
|
||||
const [a, b, c] = path;
|
||||
const root = (obj as Record<string, unknown>)[a] ?? {};
|
||||
(obj as Record<string, unknown>)[a] = root;
|
||||
const branch = root as Record<string, unknown>;
|
||||
if (c === undefined) {
|
||||
branch[b] = value;
|
||||
} else {
|
||||
const nested = branch[b] ?? {};
|
||||
branch[b] = nested;
|
||||
(nested as Record<string, unknown>)[c] = value;
|
||||
function setAtPath(obj: EnvSettingsOverrides, path: readonly string[], value: unknown): void {
|
||||
if (path.length < 2) return;
|
||||
let cur: Record<string, unknown> = obj as Record<string, unknown>;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const key = path[i]!;
|
||||
const existing = cur[key];
|
||||
const next: Record<string, unknown> =
|
||||
existing !== null &&
|
||||
existing !== undefined &&
|
||||
typeof existing === 'object' &&
|
||||
!Array.isArray(existing)
|
||||
? { ...(existing as Record<string, unknown>) }
|
||||
: {};
|
||||
cur[key] = next;
|
||||
cur = next;
|
||||
}
|
||||
cur[path[path.length - 1]!] = value;
|
||||
}
|
||||
|
||||
const RGB_ACCENT_REGEX = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/;
|
||||
@@ -252,6 +319,23 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [
|
||||
path: ['playback', 'transcode', 'enabled'],
|
||||
type: 'bool',
|
||||
},
|
||||
{
|
||||
key: 'FS_PLAYBACK_TRANSCODE_FORMAT',
|
||||
path: ['playback', 'transcode', 'format'],
|
||||
skipIfEmpty: true,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: 'FS_PLAYBACK_TRANSCODE_BITRATE',
|
||||
path: ['playback', 'transcode', 'bitrate'],
|
||||
type: 'num',
|
||||
},
|
||||
{
|
||||
key: 'FS_PLAYBACK_FILTERS',
|
||||
path: ['playback', 'filters'],
|
||||
transform: parsePlaybackFiltersJson,
|
||||
type: 'string',
|
||||
},
|
||||
{ key: 'FS_DISCORD_ENABLED', path: ['discord', 'enabled'], type: 'bool' },
|
||||
{
|
||||
key: 'FS_DISCORD_CLIENT_ID',
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './app.store';
|
||||
export * from './auth.store';
|
||||
export * from './full-screen-player.store';
|
||||
export * from './player.store';
|
||||
export * from './scrobble-debug.store';
|
||||
export * from './scroll.store';
|
||||
export * from './settings.store';
|
||||
export * from './timestamp.store';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
export type ScrobbleDebugSnapshot = {
|
||||
eligibilityMet: boolean;
|
||||
lastListenSampleTimeSec: null | number;
|
||||
listenedMs: number;
|
||||
playerStatus: PlayerStatus;
|
||||
positionSec: number;
|
||||
songId?: string;
|
||||
songName?: string;
|
||||
submitted: boolean;
|
||||
targetDurationSec: number;
|
||||
targetPercentage: number;
|
||||
trackDurationMs: number;
|
||||
};
|
||||
|
||||
const initialSnapshot: ScrobbleDebugSnapshot = {
|
||||
eligibilityMet: false,
|
||||
lastListenSampleTimeSec: null,
|
||||
listenedMs: 0,
|
||||
playerStatus: PlayerStatus.PAUSED,
|
||||
positionSec: 0,
|
||||
songId: undefined,
|
||||
songName: undefined,
|
||||
submitted: false,
|
||||
targetDurationSec: 240,
|
||||
targetPercentage: 75,
|
||||
trackDurationMs: 0,
|
||||
};
|
||||
|
||||
type ScrobbleDebugStore = {
|
||||
snapshot: ScrobbleDebugSnapshot;
|
||||
};
|
||||
|
||||
export const useScrobbleDebugStore = createWithEqualityFn<ScrobbleDebugStore>()(() => ({
|
||||
snapshot: initialSnapshot,
|
||||
}));
|
||||
|
||||
export const publishScrobbleDebug = (partial: Partial<ScrobbleDebugSnapshot>) => {
|
||||
useScrobbleDebugStore.setState((state) => ({
|
||||
snapshot: { ...state.snapshot, ...partial },
|
||||
}));
|
||||
};
|
||||
@@ -518,8 +518,9 @@ export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder:
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
(v) => v.name.toLowerCase(),
|
||||
],
|
||||
[order, order],
|
||||
[order, order, 'asc'],
|
||||
);
|
||||
break;
|
||||
case AlbumListSort.SONG_COUNT:
|
||||
|
||||
@@ -79,6 +79,10 @@ img.size-5xl {
|
||||
color: rgb(255 49 49);
|
||||
}
|
||||
|
||||
.color-transparent {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.fill {
|
||||
fill: transparent;
|
||||
}
|
||||
@@ -123,6 +127,10 @@ img.size-5xl {
|
||||
fill: rgb(255 49 49);
|
||||
}
|
||||
|
||||
.fill-transparent {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
LuChevronLeft,
|
||||
LuChevronRight,
|
||||
LuChevronUp,
|
||||
LuCircle,
|
||||
LuCircleCheck,
|
||||
LuCircleX,
|
||||
LuClipboardCopy,
|
||||
@@ -247,6 +248,7 @@ export const AppIcon = {
|
||||
brandSpotify: SpotifyLogoIcon,
|
||||
cache: LuCloudDownload,
|
||||
check: LuCheck,
|
||||
circle: LuCircle,
|
||||
clipboardCopy: LuClipboardCopy,
|
||||
collection: LuPackage2,
|
||||
delete: LuTrash,
|
||||
@@ -384,6 +386,7 @@ type IconColor =
|
||||
| 'muted'
|
||||
| 'primary'
|
||||
| 'success'
|
||||
| 'transparent'
|
||||
| 'warn';
|
||||
|
||||
const _Icon = forwardRef<HTMLDivElement, IconProps>((props, ref) => {
|
||||
@@ -391,17 +394,19 @@ const _Icon = forwardRef<HTMLDivElement, IconProps>((props, ref) => {
|
||||
|
||||
const IconComponent: ComponentType<any> = AppIcon[icon];
|
||||
|
||||
const colorClassToken = color ?? (fill && fill !== 'transparent' ? fill : undefined);
|
||||
|
||||
const classNames = useMemo(
|
||||
() =>
|
||||
clsx(className, {
|
||||
[styles.fill]: true,
|
||||
[styles.pulse]: animate === 'pulse',
|
||||
[styles.spin]: animate === 'spin',
|
||||
[styles[`color-${color || fill}`]]: color || fill,
|
||||
[styles[`color-${colorClassToken}`]]: colorClassToken,
|
||||
[styles[`fill-${fill}`]]: fill,
|
||||
[styles[`size-${size}`]]: true,
|
||||
}),
|
||||
[animate, className, color, fill, size],
|
||||
[animate, className, colorClassToken, fill, size],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section {
|
||||
transition: width 0.15s ease-out;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ProgressProps as MantineProgressProps } from '@mantine/core';
|
||||
|
||||
import { Progress as MantineProgress } from '@mantine/core';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import styles from './progress.module.css';
|
||||
|
||||
export interface ProgressProps extends MantineProgressProps {}
|
||||
|
||||
export const Progress = forwardRef<HTMLDivElement, ProgressProps>(
|
||||
({ classNames, style, ...props }, ref) => {
|
||||
return (
|
||||
<MantineProgress
|
||||
classNames={{
|
||||
root: styles.root,
|
||||
section: styles.section,
|
||||
...classNames,
|
||||
}}
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Progress.displayName = 'Progress';
|
||||
Reference in New Issue
Block a user