mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-22 18:36:34 +02:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db79d1a71e | |||
| 22d37135ae | |||
| 61c6036d41 | |||
| 95ae474cc6 | |||
| 504bbeed91 | |||
| 0e5b3450dd | |||
| 650ae0b320 | |||
| 66cfab3b57 | |||
| 0455d5bfb8 | |||
| 112449576e | |||
| 3122f4121e | |||
| 28b8894b49 | |||
| 41d5694f1f | |||
| 7befd70e21 | |||
| 0de1e1aa3e | |||
| 6528859ea9 | |||
| 704745eb7c | |||
| 2759cfee97 | |||
| 755f0aab9d | |||
| 0e163543fc | |||
| fad2534d44 | |||
| edfc64d790 | |||
| 8f40894926 | |||
| 2befcb4e74 | |||
| 2d78c32a68 | |||
| 3d0500980a | |||
| 3551ee5077 |
@@ -114,8 +114,11 @@ These variables override app settings **on first run** when no persisted setting
|
||||
|
||||
| Setting path | Default | Env variable | Available values / Description |
|
||||
|-------------|---------|--------------|--------------------------------|
|
||||
| `autoDJ.albumStrategy` | `similar` | `FS_AUTO_DJ_ALBUM_STRATEGY` | `similar` / `library_random`. |
|
||||
| `autoDJ.enabled` | `false` | `FS_AUTO_DJ_ENABLED` | `true` / `false`. |
|
||||
| `autoDJ.itemCount` | `5` | `FS_AUTO_DJ_ITEM_COUNT` | Number of items to add. |
|
||||
| `autoDJ.mode` | `songs` | `FS_AUTO_DJ_MODE` | `songs` / `albums`. |
|
||||
| `autoDJ.songStrategy` | `similar` | `FS_AUTO_DJ_SONG_STRATEGY` | `similar` / `library_random`. |
|
||||
| `autoDJ.timing` | `1` | `FS_AUTO_DJ_TIMING` | Timing value (number). |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
appId: org.jeffvli.feishin
|
||||
productName: Feishin
|
||||
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||
electronVersion: 39.4.0
|
||||
electronVersion: 41.7.0
|
||||
directories:
|
||||
buildResources: assets
|
||||
files:
|
||||
@@ -47,7 +47,7 @@ mac:
|
||||
gatekeeperAssess: false
|
||||
notarize: false
|
||||
extendInfo:
|
||||
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
|
||||
NSAudioCaptureUsageDescription: 'System audio access is required for mpv visualizer capture in Feishin'
|
||||
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
|
||||
|
||||
dmg:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
appId: org.jeffvli.feishin
|
||||
productName: Feishin
|
||||
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||
electronVersion: 39.4.0
|
||||
electronVersion: 41.7.0
|
||||
directories:
|
||||
buildResources: assets
|
||||
files:
|
||||
@@ -47,7 +47,7 @@ mac:
|
||||
gatekeeperAssess: false
|
||||
notarize: false
|
||||
extendInfo:
|
||||
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
|
||||
NSAudioCaptureUsageDescription: 'System audio access is required for mpv visualizer capture in Feishin'
|
||||
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
|
||||
|
||||
dmg:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
appId: org.jeffvli.feishin
|
||||
productName: Feishin
|
||||
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||
electronVersion: 39.4.0
|
||||
electronVersion: 41.7.0
|
||||
directories:
|
||||
buildResources: assets
|
||||
files:
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^39.8.6",
|
||||
"electron": "^41.7.0",
|
||||
"electron-builder": "^26.8.2",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electron-vite": "^4.0.1",
|
||||
|
||||
Generated
+13
-25
@@ -23,10 +23,10 @@ importers:
|
||||
version: 1.1.0
|
||||
'@electron-toolkit/preload':
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2(electron@39.8.6)
|
||||
version: 3.0.2(electron@41.7.0)
|
||||
'@electron-toolkit/utils':
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0(electron@39.8.6)
|
||||
version: 4.0.0(electron@41.7.0)
|
||||
'@mantine/colors-generator':
|
||||
specifier: ^9.1.1
|
||||
version: 9.1.1(chroma-js@3.1.2)
|
||||
@@ -269,8 +269,8 @@ importers:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0
|
||||
electron:
|
||||
specifier: ^39.8.6
|
||||
version: 39.8.6
|
||||
specifier: ^41.7.0
|
||||
version: 41.7.0
|
||||
electron-builder:
|
||||
specifier: ^26.8.2
|
||||
version: 26.8.2(electron-builder-squirrel-windows@26.8.2)
|
||||
@@ -2126,9 +2126,6 @@ packages:
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/node@22.19.17':
|
||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
||||
|
||||
'@types/node@24.12.2':
|
||||
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
|
||||
|
||||
@@ -3001,8 +2998,8 @@ packages:
|
||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
electron@39.8.6:
|
||||
resolution: {integrity: sha512-uWX6Jh5LmwL13VwOSKBjebI+ck+03GOwc8V2Sgbmr9pJVJ/cHfli/PkjXuRDr+hq+SLHQuT9mGHSIfScebApRA==}
|
||||
electron@41.7.0:
|
||||
resolution: {integrity: sha512-U6KAKivjk6YQ0Z5+eloJBjwhbHRE206gvy1UBMw2bSluWtMh5waeXMvX6AT/Ujm5ymYXVJOp7g9N7vOFw16wBQ==}
|
||||
engines: {node: '>= 12.20.55'}
|
||||
hasBin: true
|
||||
|
||||
@@ -5507,9 +5504,6 @@ packages:
|
||||
underscore.string@3.3.6:
|
||||
resolution: {integrity: sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
@@ -6684,17 +6678,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@electron-toolkit/preload@3.0.2(electron@39.8.6)':
|
||||
'@electron-toolkit/preload@3.0.2(electron@41.7.0)':
|
||||
dependencies:
|
||||
electron: 39.8.6
|
||||
electron: 41.7.0
|
||||
|
||||
'@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)':
|
||||
dependencies:
|
||||
'@types/node': 24.12.2
|
||||
|
||||
'@electron-toolkit/utils@4.0.0(electron@39.8.6)':
|
||||
'@electron-toolkit/utils@4.0.0(electron@41.7.0)':
|
||||
dependencies:
|
||||
electron: 39.8.6
|
||||
electron: 41.7.0
|
||||
|
||||
'@electron/asar@3.4.1':
|
||||
dependencies:
|
||||
@@ -7675,7 +7669,7 @@ snapshots:
|
||||
|
||||
'@types/electron-localshortcut@3.1.3':
|
||||
dependencies:
|
||||
electron: 39.8.6
|
||||
electron: 41.7.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7703,10 +7697,6 @@ snapshots:
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/node@22.19.17':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@24.12.2':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
@@ -8800,10 +8790,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron@39.8.6:
|
||||
electron@41.7.0:
|
||||
dependencies:
|
||||
'@electron/get': 2.0.3
|
||||
'@types/node': 22.19.17
|
||||
'@types/node': 24.12.2
|
||||
extract-zip: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -11588,8 +11578,6 @@ snapshots:
|
||||
sprintf-js: 1.1.3
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
undici@6.24.1: {}
|
||||
|
||||
@@ -88,8 +88,11 @@ window.FS_LYRICS_TRANSLATION_API_KEY = "${FS_LYRICS_TRANSLATION_API_KEY}";
|
||||
window.FS_LYRICS_TRANSLATION_TARGET_LANGUAGE = "${FS_LYRICS_TRANSLATION_TARGET_LANGUAGE}";
|
||||
window.FS_LYRICS_ALIGNMENT = "${FS_LYRICS_ALIGNMENT}";
|
||||
|
||||
window.FS_AUTO_DJ_ALBUM_STRATEGY = "${FS_AUTO_DJ_ALBUM_STRATEGY}";
|
||||
window.FS_AUTO_DJ_ENABLED = "${FS_AUTO_DJ_ENABLED}";
|
||||
window.FS_AUTO_DJ_ITEM_COUNT = "${FS_AUTO_DJ_ITEM_COUNT}";
|
||||
window.FS_AUTO_DJ_MODE = "${FS_AUTO_DJ_MODE}";
|
||||
window.FS_AUTO_DJ_SONG_STRATEGY = "${FS_AUTO_DJ_SONG_STRATEGY}";
|
||||
window.FS_AUTO_DJ_TIMING = "${FS_AUTO_DJ_TIMING}";
|
||||
|
||||
window.FS_CSS_CONTENT = "${FS_CSS_CONTENT}";
|
||||
|
||||
+42
-20
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"sidebar": {
|
||||
"myLibrary": "La meva llibreria",
|
||||
"myLibrary": "La meva mediateca",
|
||||
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
|
||||
"albums": "$t(entity.album, {\"count\": 2})",
|
||||
"artists": "$t(entity.artist, {\"count\": 2})",
|
||||
@@ -340,7 +340,8 @@
|
||||
"filter_multiple": "Multi",
|
||||
"rename": "Reanomena",
|
||||
"newVersionAvailable": "Hi ha una nova versió disponible",
|
||||
"numberOfResults": "{{numberOfResults}} resultats"
|
||||
"numberOfResults": "{{numberOfResults}} resultats",
|
||||
"back": "Enrere"
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Àlbum",
|
||||
@@ -557,7 +558,9 @@
|
||||
"selectRangeOfItems": "Selecciona un interval d'elements",
|
||||
"selectAll": "Selecciona-ho tot",
|
||||
"openApplicationDirectory": "Obre el directori de l'aplicació",
|
||||
"goToCurrent": "Anar a l'element actual"
|
||||
"goToCurrent": "Anar a l'element actual",
|
||||
"collapseAllFolders": "Replega totes les carpetes",
|
||||
"expandAllFolders": "Expandeix totes les carpetes"
|
||||
},
|
||||
"setting": {
|
||||
"language_description": "Estableix l'idioma de l'aplicació ($t(common.restartRequired))",
|
||||
@@ -921,8 +924,8 @@
|
||||
"primaryShade_description": "Substitueix el to primari (0–9) utilitzat per a botons, enllaços i altres elements de color primari",
|
||||
"playerItemConfiguration_description": "Configurar quins elements es mostren i en quin ordre al reproductor de pantalla completa",
|
||||
"playerItemConfiguration": "Configuració d'elements del jugador",
|
||||
"listenbrainz_description": "mostra enllaços a ListenBrainz a les pàgines d'artista/àlbum",
|
||||
"listenbrainz": "mostra enllaços a ListenBrainz",
|
||||
"listenbrainz_description": "Mostra enllaços a ListenBrainz a les pàgines d'artista/àlbum",
|
||||
"listenbrainz": "Mostra enllaços a ListenBrainz",
|
||||
"qobuz_description": "Mostra enllaços a Qobuz a les pàgines d'artista/àlbum",
|
||||
"qobuz": "Mostra enllaços a Qobuz",
|
||||
"spotify_description": "Mostra enllaços a Spotify a les pàgines d'artista/àlbum",
|
||||
@@ -930,25 +933,43 @@
|
||||
"nativeSpotify_description": "Obre amb Spotify en lloc del vostre navegador",
|
||||
"nativeSpotify": "Fes servir Spotify",
|
||||
"playerbarWaveformStretch": "Extensió de la forma d'ona",
|
||||
"playerbarWaveformStretch_description": "estén la forma d'ona per omplir l'espai disponible",
|
||||
"playerbarWaveformStretch_description": "Estén la forma d'ona per omplir l'espai disponible",
|
||||
"sidePlayQueueLayout": "Disposició de la cua de reproducció lateral",
|
||||
"sidePlayQueueLayout_description": "estableix la disposició de la cua de reproducció lateral adjunta",
|
||||
"sidePlayQueueLayout_optionHorizontal": "horitzontal",
|
||||
"sidePlayQueueLayout_optionVertical": "vertical",
|
||||
"sidePlayQueueLayout_description": "Estableix la disposició de la cua de reproducció lateral adjunta",
|
||||
"sidePlayQueueLayout_optionHorizontal": "Horitzontal",
|
||||
"sidePlayQueueLayout_optionVertical": "Vertical",
|
||||
"waveformLoadingDelay": "Demora de càrrega de la forma d'ona",
|
||||
"waveformLoadingDelay_description": "Demora en segons abans de carregar la forma d'ona. Incrementeu aquest valor si patiu interrupcions en fer servir el reproductor web.",
|
||||
"preventSuspendOnPlayback_description": "Evita que l'aplicació quedi suspesa mentre es reprodueix música",
|
||||
"preventSuspendOnPlayback": "Evita la suspensió durant la reproducció"
|
||||
"preventSuspendOnPlayback": "Evita la suspensió durant la reproducció",
|
||||
"hotkey_listShowPlayingSong": "Mostra la cançó en reproducció a la llista",
|
||||
"sidebarPlaylistFolders_description": "Create una vista de carpeta per lliste de reproducció que inclogui el separador configurat al nom",
|
||||
"sidebarPlaylistFolders": "Habilita les carpetes",
|
||||
"sidebarPlaylistFolderSeparator_description": "Caràcter (o cadena) que separa els nivells de carpetes al nom d'una llista de reproducció",
|
||||
"sidebarPlaylistFolderSeparator": "Separador de carpetes",
|
||||
"sidebarPlaylistFolderView_description": "Com es mostren les carpetes a la barra lateral",
|
||||
"sidebarPlaylistFolderView": "Vista de carpeta",
|
||||
"sidebarPlaylistFolderView_optionSingle": "Carpeta única",
|
||||
"sidebarPlaylistFolderView_optionTree": "Vista d'arbre",
|
||||
"sidebarPlaylistFolderView_optionNavigation": "Vista de navegació",
|
||||
"sidebarPlaylistFolderTreeIndent_description": "Píxels de sagnat per cada nivell de l'arbre",
|
||||
"sidebarPlaylistFolderTreeIndent": "Sagnat de l'arbre",
|
||||
"sidebarPlaylistFolderTreeLineColor_description": "Color de les línies connectores de l'arbre (deixeu-ho en blanc per fer servir el valor predeterminat)",
|
||||
"sidebarPlaylistFolderTreeLineColor": "Color de la línia de l'arbre",
|
||||
"sidebarPlaylistMode_description": "Com es mostra cada llista de reproducció a la llista de la barra lateral",
|
||||
"sidebarPlaylistMode": "Mode de llista de reproducció a la barra lateral",
|
||||
"sidebarPlaylistMode_optionCompact": "Compacte",
|
||||
"sidebarPlaylistMode_optionExpanded": "Expandit"
|
||||
},
|
||||
"table": {
|
||||
"column": {
|
||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"codec": "$t(common.codec)",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"size": "$t(common.size)",
|
||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
||||
"albumCount": "Àlbums",
|
||||
"artist": "Artista",
|
||||
"channels": "Canals",
|
||||
"codec": "Còdec",
|
||||
"genre": "Gènere",
|
||||
"size": "Mida",
|
||||
"songCount": "Pistes",
|
||||
"releaseYear": "Any",
|
||||
"playCount": "Reproduccions",
|
||||
"releaseDate": "Data de llançament",
|
||||
@@ -966,8 +987,8 @@
|
||||
"path": "Ruta",
|
||||
"rating": "Qualificació",
|
||||
"title": "Títol",
|
||||
"bitDepth": "$t(common.bitDepth)",
|
||||
"sampleRate": "$t(common.sampleRate)",
|
||||
"bitDepth": "Profunditat de bits",
|
||||
"sampleRate": "Freqüència de mostreig",
|
||||
"owner": "Propietari"
|
||||
},
|
||||
"config": {
|
||||
@@ -1146,7 +1167,8 @@
|
||||
"sleepTimer_timeRemaining": "Queden {{time}}",
|
||||
"sleepTimer_setCustom": "Configura el temporitzador",
|
||||
"sleepTimer_cancel": "Cancel·la el temporitzador",
|
||||
"albumRadio": "Ràdio d'àlbums"
|
||||
"albumRadio": "Ràdio d'àlbums",
|
||||
"scrobbleForceSubmit": "Força l'scrobble"
|
||||
},
|
||||
"error": {
|
||||
"credentialsRequired": "Credencials requerides",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"viewPlaylists": "$t(entity.playlist, {\"count\": 2}) anzeigen",
|
||||
"refresh": "$t(common.refresh)",
|
||||
"removeFromQueue": "Aus wiedergabeliste entfernen",
|
||||
"setRating": "Bewerten",
|
||||
"setRating": "Bewertung setzen",
|
||||
"toggleSmartPlaylistEditor": "Editor für $t(entity.smartPlaylist) ein-/ausblenden",
|
||||
"removeFromFavorites": "Aus $t(entity.favorite, {\"count\": 2}) entfernen",
|
||||
"openIn": {
|
||||
@@ -41,7 +41,9 @@
|
||||
"selectRangeOfItems": "Wählen sie eine reihe von elementen",
|
||||
"holdToMoveToTop": "Halten um nach oben zu bewegen",
|
||||
"holdToMoveToBottom": "Halten um nach unten zu bewegen",
|
||||
"goToCurrent": "Zu aktuellem eintrag wechseln"
|
||||
"goToCurrent": "Zu aktuellem eintrag wechseln",
|
||||
"collapseAllFolders": "Alle Ordner einklappen",
|
||||
"expandAllFolders": "Alle Ordner ausklappen"
|
||||
},
|
||||
"common": {
|
||||
"backward": "Zurück",
|
||||
|
||||
@@ -415,6 +415,11 @@
|
||||
},
|
||||
"shuffleAll": {
|
||||
"title": "Play random",
|
||||
"input_kind_albums": "Albums",
|
||||
"input_kind_songs": "Songs",
|
||||
"input_kind": "Random picks",
|
||||
"input_limit_albums": "How many albums?",
|
||||
"input_limit_songs": "How many songs?",
|
||||
"input_genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"input_limit": "How many songs?",
|
||||
"input_minYear": "From year",
|
||||
@@ -731,11 +736,19 @@
|
||||
},
|
||||
"setting": {
|
||||
"autoDJ": "Auto DJ",
|
||||
"autoDJ_description": "Automatically add similar songs to the queue",
|
||||
"autoDJ_itemCount": "Item count",
|
||||
"autoDJ_itemCount_description": "The number of items attempted to be added to the queue when auto DJ is enabled",
|
||||
"autoDJ_itemCount_description": "The number of items attempted to be added to the queue",
|
||||
"autoDJ_timing": "Timing",
|
||||
"autoDJ_timing_description": "The number of songs remaining in the queue before auto DJ is triggered",
|
||||
"autoDJ_mode": "Mode",
|
||||
"autoDJ_mode_albums": "Albums",
|
||||
"autoDJ_mode_description": "Choose to add either songs or entire albums to the queue",
|
||||
"autoDJ_mode_songs": "Songs",
|
||||
"autoDJ_enabled": "Enable Auto DJ",
|
||||
"autoDJ_albumStrategy": "Album selection mode",
|
||||
"autoDJ_songStrategy": "Song selection mode",
|
||||
"autoDJ_strategy_option_library_random": "Random",
|
||||
"autoDJ_strategy_option_similar": "Similar",
|
||||
"autosave": "Automatically save play queue",
|
||||
"autosave_description": "Enable automatically saving the play queue to your server. This is only possible when using Navidrome/Subsonic, and you cannot have a mixed play queue.",
|
||||
"autosaveCount": "Automatic play queue save frequency",
|
||||
|
||||
+126
-62
@@ -16,7 +16,10 @@
|
||||
"viewPlaylists": "Lihat $t(entity.playlist, {\"count\": 2})",
|
||||
"openIn": {
|
||||
"lastfm": "Buka di Last.fm",
|
||||
"musicbrainz": "Buka di MusicBrainz"
|
||||
"musicbrainz": "Buka di MusicBrainz",
|
||||
"listenbrainz": "Buka di ListenBrainz",
|
||||
"qobuz": "Buka di Qobuz",
|
||||
"spotify": "Buka di Spotify"
|
||||
},
|
||||
"addToFavorites": "Tambahkan ke $t(entity.favorite, {\"count\": 2})",
|
||||
"clearQueue": "Kosongkan antrian",
|
||||
@@ -38,12 +41,14 @@
|
||||
"shuffleSelected": "Acak yang dipilih",
|
||||
"viewMore": "Lihat lebih banyak",
|
||||
"openApplicationDirectory": "Buka direktori aplikasi",
|
||||
"goToCurrent": "Pergi ke item saat ini"
|
||||
"goToCurrent": "Pergi ke item saat ini",
|
||||
"collapseAllFolders": "Ciutkan semua folder",
|
||||
"expandAllFolders": "Bentangkan semua folder"
|
||||
},
|
||||
"common": {
|
||||
"clear": "Bersihkan",
|
||||
"action_other": "Aksi",
|
||||
"codec": "Koded",
|
||||
"codec": "Kodek",
|
||||
"channel_other": "Saluran",
|
||||
"duration": "Durasi",
|
||||
"create": "Buat",
|
||||
@@ -96,7 +101,7 @@
|
||||
"random": "Acak",
|
||||
"rating": "Penilaian",
|
||||
"refresh": "Segarkan",
|
||||
"reload": "Muat Ulang",
|
||||
"reload": "Muat ulang",
|
||||
"reset": "Reset",
|
||||
"resetToDefault": "Reset ke default",
|
||||
"restartRequired": "Restart diperlukan",
|
||||
@@ -111,7 +116,7 @@
|
||||
"sortOrder": "Urutkan",
|
||||
"title": "Judul",
|
||||
"trackNumber": "Pista",
|
||||
"trackGain": "Gain pista",
|
||||
"trackGain": "Gain trek",
|
||||
"trackPeak": "Puncak lagu",
|
||||
"unknown": "Tidak dikenal",
|
||||
"version": "Versi",
|
||||
@@ -156,7 +161,11 @@
|
||||
"clean": "Bersih",
|
||||
"gridRows": "Baris kisi",
|
||||
"tableColumns": "Kolom tabel",
|
||||
"itemsMore": "{{count}} lagi"
|
||||
"itemsMore": "{{count}} lagi",
|
||||
"back": "Kembali",
|
||||
"grouping": "Pengelompokan",
|
||||
"numberOfResults": "{{numberOfResults}} hasil",
|
||||
"newVersionAvailable": "Versi baru tersedia"
|
||||
},
|
||||
"entity": {
|
||||
"album_other": "Album",
|
||||
@@ -173,7 +182,7 @@
|
||||
"playlist_other": "Daftar Putar",
|
||||
"play_other": "Putar {{count}}",
|
||||
"playlistWithCount_other": "{{count}} daftar putar",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) pintar",
|
||||
"smartPlaylist": "Cerdas $t(entity.playlist, {\"count\": 1})",
|
||||
"track_other": "Pista",
|
||||
"song_other": "Lagu",
|
||||
"trackWithCount_other": "{{count}} pista",
|
||||
@@ -184,7 +193,7 @@
|
||||
"apiRouteError": "Tidak dapat mengarahkan permintaan",
|
||||
"audioDeviceFetchError": "Terjadi kesalahan saat mencoba mengambil perangkat audio",
|
||||
"authenticationFailed": "Autentikasi gagal",
|
||||
"badAlbum": "Anda melihat halaman ini karena lagu ini tidak termasuk dalam album. Masalah ini bisa terjadi jika Anda memiliki lagu di tingkat atas folder musik Anda. Jellyfin hanya mengelompokkan lagu jika mereka berada di dalam folder",
|
||||
"badAlbum": "Anda melihat halaman ini karena lagu ini bukan bagian dari album. Masalah ini kemungkinan besar muncul jika Anda memiliki lagu di tingkat teratas folder musik Anda. Jellyfin hanya mengelompokkan trek jika berada di dalam folder",
|
||||
"credentialsRequired": "Kredensial diperlukan",
|
||||
"endpointNotImplementedError": "Endpoint {{endpoint}} tidak diimplementasikan untuk {{serverType}}",
|
||||
"genericError": "Terjadi kesalahan",
|
||||
@@ -211,7 +220,8 @@
|
||||
"saveQueueFailed": "Gagal menyimpan antrean",
|
||||
"settingsSyncError": "Ditemukan ketidaksesuaian antara pengaturan di perender dan proses utama. mulai ulang aplikasi untuk menerapkan perubahan",
|
||||
"invalidJson": "JSON tidak valid",
|
||||
"serverLockSingleServer": "Hanya satu server yang diizinkan ketika server dikunci"
|
||||
"serverLockSingleServer": "Hanya satu server yang diizinkan ketika server dikunci",
|
||||
"playbackPausedDueToError": "Pemutaran dijeda karena terjadi kesalahan"
|
||||
},
|
||||
"filter": {
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
@@ -286,7 +296,8 @@
|
||||
"success": "Ditambahkan $t(entity.trackWithCount, {\"count\": {{message}} }) ke $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||
"title": "Tambahkan ke $t(entity.playlist, {\"count\": 1})",
|
||||
"create": "Buat $t(entity.playlist, {\"count\": 1}) {{playlist}}",
|
||||
"searchOrCreate": "Cari $t(entity.playlist, {\"count\": 2}) atau ketik untuk membuat yang baru"
|
||||
"searchOrCreate": "Cari $t(entity.playlist, {\"count\": 2}) atau ketik untuk membuat yang baru",
|
||||
"noneAdded": "Tidak ada trek yang ditambahkan ke $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
|
||||
},
|
||||
"createPlaylist": {
|
||||
"input_description": "$t(common.description)",
|
||||
@@ -321,12 +332,12 @@
|
||||
"clearFilters": "Hapus filter"
|
||||
},
|
||||
"shareItem": {
|
||||
"allowDownloading": "Izinkan unduhan",
|
||||
"allowDownloading": "Izinkan pengunduhan",
|
||||
"description": "Deskripsi",
|
||||
"setExpiration": "Atur masa berlaku",
|
||||
"success": "Tautan berbagi berhasil disalin ke papan klip (atau klik di sini untuk membuka)",
|
||||
"expireInvalid": "Masa berlaku harus di masa depan",
|
||||
"createFailed": "Tidak dapat membuat sumber daya berbagi (Apakah berbagi diaktifkan?)",
|
||||
"success": "Tautan bagikan disalin ke papan klip (atau klik di sini untuk membukanya)",
|
||||
"expireInvalid": "Masa berlaku harus di waktu mendatang",
|
||||
"createFailed": "Gagal membuat bagikanan (apakah fitur berbagi diaktifkan?)",
|
||||
"copyToClipboard": "Salin ke clipboard: Ctrl+C, enter",
|
||||
"successMustClick": "Berbagi berhasil dibuat. klik di sini untuk membuka"
|
||||
},
|
||||
@@ -368,19 +379,22 @@
|
||||
"enabled": "Mode pribadi diaktifkan, status pemutaran kini disembunyikan dari integrasi eksternal",
|
||||
"disabled": "Mode pribadi dinonaktifkan, status pemutaran kini terlihat oleh integrasi eksternal yang diaktifkan",
|
||||
"title": "Mode pribadi"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "Stasiun radio berhasil diperbarui"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"albumArtistDetail": {
|
||||
"about": "Tentang {{artist}}",
|
||||
"recentReleases": "Rilis terbaru",
|
||||
"recentReleases": "Rilisan terbaru",
|
||||
"viewDiscography": "Lihat diskografi",
|
||||
"relatedArtists": "$t(entity.artist, {\"count\": 2}) serupa",
|
||||
"topSongs": "Lagu terbaik",
|
||||
"topSongsFrom": "Lagu terbaik dari {{title}}",
|
||||
"relatedArtists": "$t(entity.artist, {\"count\": 2}) terkait",
|
||||
"topSongs": "Lagu teratas",
|
||||
"topSongsFrom": "Lagu teratas dari {{title}}",
|
||||
"viewAll": "Lihat semua",
|
||||
"viewAllTracks": "Lihat semua $t(entity.track, {\"count\": 2})",
|
||||
"appearsOn": "Tampil di",
|
||||
"appearsOn": "Muncul di",
|
||||
"groupingTypeAll": "Semua jenis rilis",
|
||||
"groupingTypePrimary": "Jenis rilis utama",
|
||||
"favoriteSongs": "Lagu favorit",
|
||||
@@ -447,7 +461,7 @@
|
||||
"setRating": "$t(action.setRating)",
|
||||
"playShuffled": "$t(player.shuffle)",
|
||||
"shareItem": "Bagikan item",
|
||||
"showDetails": "Lihat detail",
|
||||
"showDetails": "Dapatkan info",
|
||||
"moveToTop": "$t(action.moveToTop)",
|
||||
"play": "$t(player.play)",
|
||||
"moveItems": "$t(action.moveItems)",
|
||||
@@ -470,7 +484,9 @@
|
||||
"unsynchronized": "Tidak sinkronisasi",
|
||||
"useImageAspectRatio": "Gunakan rasio aspek gambar",
|
||||
"lyricOffset": "Offset lirik (ms)",
|
||||
"lyricGap": "Jarak lirik"
|
||||
"lyricGap": "Jarak lirik",
|
||||
"lyricOpacityNonActive": "Opasitas lirik nonaktif",
|
||||
"lyricScaleNonActive": "Skala lirik nonaktif"
|
||||
},
|
||||
"lyrics": "Lirik",
|
||||
"related": "Terkait",
|
||||
@@ -503,7 +519,7 @@
|
||||
"itemDetail": {
|
||||
"copyPath": "Salin jalur ke papan klip",
|
||||
"copiedPath": "Jalur berhasil disalin",
|
||||
"openFile": "Tampilkan lagu di pengelola file"
|
||||
"openFile": "Tampilkan trek di pengelola file"
|
||||
},
|
||||
"playlist": {
|
||||
"reorder": "Pengurutan ulang hanya diaktifkan saat mengurutkan berdasarkan ID"
|
||||
@@ -631,19 +647,20 @@
|
||||
"sleepTimer_off": "Mati",
|
||||
"sleepTimer_timeRemaining": "{{time}} tersisa",
|
||||
"sleepTimer_setCustom": "Atur pengatur waktu",
|
||||
"sleepTimer_cancel": "Batalkan pengatur waktu"
|
||||
"sleepTimer_cancel": "Batalkan pengatur waktu",
|
||||
"scrobbleForceSubmit": "Paksa scrobble"
|
||||
},
|
||||
"setting": {
|
||||
"accentColor": "Warna sorotan",
|
||||
"accentColor_description": "Menetapkan warna sorotan aplikasi",
|
||||
"albumBackground": "Gambar latar belakang album",
|
||||
"albumBackground_description": "Tambahkan gambar latar belakang ke halaman album yang berisi sampul album",
|
||||
"albumBackgroundBlur": "Ukuran blur gambar latar belakang album",
|
||||
"albumBackgroundBlur_description": "Atur tingkat blur gambar latar belakang album",
|
||||
"albumBackground_description": "Menambahkan gambar latar belakang untuk halaman album yang berisi sampul album",
|
||||
"albumBackgroundBlur": "Ukuran keburaman gambar latar belakang album",
|
||||
"albumBackgroundBlur_description": "Menyesuaikan tingkat keburaman yang diterapkan pada gambar latar belakang album",
|
||||
"applicationHotkeys": "Tombol pintasan aplikasi",
|
||||
"applicationHotkeys_description": "Menetapkan tombol pintasan aplikasi. centang untuk menjadikannya tombol pintasan global (desktop saja)",
|
||||
"artistConfiguration": "Pengaturan halaman artis album",
|
||||
"artistConfiguration_description": "Atur elemen apa yang ditampilkan dan urutannya di halaman artis album",
|
||||
"artistConfiguration": "Konfigurasi halaman artis album",
|
||||
"artistConfiguration_description": "Atur item apa saja yang ditampilkan, dan dalam urutan apa, pada halaman artis album",
|
||||
"audioDevice": "Perangkat audio",
|
||||
"audioDevice_description": "Pilih perangkat audio yang digunakan untuk pemutaran",
|
||||
"audioExclusiveMode": "Mode audio eksklusif",
|
||||
@@ -657,12 +674,12 @@
|
||||
"windowBarStyle_description": "Pilih gaya bilah jendela",
|
||||
"zoom": "Persentase zoom",
|
||||
"zoom_description": "Tentukan persentase zoom aplikasi",
|
||||
"clearCache_description": "'Pembersihan keras' Feishin. Untuk membersihkan cache Feishin, kosongkan cache browser (gambar yang disimpan dan elemen lainnya). Kredensial dan pengaturan server tetap terjaga",
|
||||
"clearCache_description": "'Pembersihan keras' Feishin. Selain membersihkan cache Feishin, cache browser juga dikosongkan (gambar tersimpan dan aset lainnya). Kredensial server dan pengaturan tetap dipertahankan",
|
||||
"clearQueryCache": "Bersihkan cache Feishin",
|
||||
"clearQueryCache_description": "'Pembersihan lunak' Feishin. Ini akan menyegarkan daftar putar, metadata lagu, dan mengatur ulang lirik yang disimpan. Pengaturan, kredensial server, dan gambar cache tetap terjaga",
|
||||
"clearCacheSuccess": "Cache berhasil dibersihkan",
|
||||
"contextMenu": "Pengaturan menu konteks (klik kanan)",
|
||||
"contextMenu_description": "Memungkinkan Anda menyembunyikan elemen yang ditampilkan dalam menu saat Anda klik kanan pada elemen. Elemen yang tidak dipilih akan disembunyikan",
|
||||
"clearQueryCache_description": "'Pembersihan lunak' Feishin. Ini akan menyegarkan daftar putar, metadata trek, dan mengatur ulang lirik yang disimpan. Pengaturan, kredensial server, dan gambar yang dicache tetap dipertahankan",
|
||||
"clearCacheSuccess": "Cache berhasil dikosongkan",
|
||||
"contextMenu": "Konfigurasi menu konteks (klik kanan)",
|
||||
"contextMenu_description": "Memungkinkan Anda menyembunyikan item yang ditampilkan di menu saat Anda mengeklik kanan suatu item. Item yang tidak dicentang akan disembunyikan",
|
||||
"crossfadeDuration": "Durasi crossfade",
|
||||
"crossfadeDuration_description": "Atur durasi efek crossfade",
|
||||
"crossfadeStyle_description": "Pilih gaya crossfade yang digunakan oleh pemutar audio",
|
||||
@@ -677,7 +694,7 @@
|
||||
"discordApplicationId_description": "ID aplikasi untuk rich presence {{discord}} (default: {{defaultId}})",
|
||||
"discordIdleStatus": "Tampilkan status tidak aktif dalam status aktivitas",
|
||||
"discordIdleStatus_description": "Ketika diaktifkan, memperbarui status saat pemutar tidak aktif",
|
||||
"discordListening": "Tampilkan status sebagai mendengarkan",
|
||||
"discordListening": "Tampilkan status sebagai sedang mendengarkan",
|
||||
"discordListening_description": "Tampilkan status sebagai mendengarkan alih-alih bermain",
|
||||
"discordRichPresence_description": "Aktifkan status pemutaran di status aktivitas {{discord}}. Gambar tombol adalah: {{icon}}, {{playing}}, dan {{paused}}",
|
||||
"discordUpdateInterval": "Interval pembaruan status aktivitas {{discord}}",
|
||||
@@ -685,7 +702,7 @@
|
||||
"enableRemote": "Aktifkan kontrol jarak jauh server",
|
||||
"enableRemote_description": "Aktifkan kontrol jarak jauh server untuk memungkinkan perangkat lain mengontrol aplikasi",
|
||||
"externalLinks": "Tampilkan tautan eksternal",
|
||||
"externalLinks_description": "Izinkan untuk menampilkan tautan eksternal (Last.fm, MusicBrainz) di halaman artis/album",
|
||||
"externalLinks_description": "Mengaktifkan penampilan tautan eksternal (Last.fm, MusicBrainz) pada halaman artis/album",
|
||||
"exitToTray": "Keluar ke baki",
|
||||
"exitToTray_description": "Keluar dari aplikasi ke baki sistem",
|
||||
"followLyric": "Ikuti lirik saat ini",
|
||||
@@ -702,14 +719,14 @@
|
||||
"gaplessAudio_optionWeak": "Lemah (disarankan)",
|
||||
"globalMediaHotkeys": "Tombol pintasan media global",
|
||||
"globalMediaHotkeys_description": "Aktifkan atau nonaktifkan penggunaan tombol pintasan sistem media untuk mengontrol pemutaran",
|
||||
"homeConfiguration": "Pengaturan halaman beranda",
|
||||
"homeConfiguration_description": "Mengatur elemen mana yang ditampilkan dan urutannya di halaman beranda",
|
||||
"homeFeature": "Karusel fitur beranda",
|
||||
"homeFeature_description": "Mengontrol apakah karusel besar fitur ditampilkan di halaman beranda",
|
||||
"homeConfiguration": "Konfigurasi halaman beranda",
|
||||
"homeConfiguration_description": "Atur item apa saja yang ditampilkan, dan dalam urutan apa, pada halaman beranda",
|
||||
"homeFeature": "Karusel unggulan beranda",
|
||||
"homeFeature_description": "Mengatur apakah karusel unggulan besar ditampilkan di halaman beranda",
|
||||
"hotkey_browserBack": "Mundur",
|
||||
"hotkey_browserForward": "Maju",
|
||||
"hotkey_favoriteCurrentSong": "$t(common.currentSong) favorit",
|
||||
"hotkey_favoritePreviousSong": "$t(common.previousSong) favorit",
|
||||
"hotkey_favoriteCurrentSong": "Favoritkan $t(common.currentSong)",
|
||||
"hotkey_favoritePreviousSong": "Favoritkan $t(common.previousSong)",
|
||||
"hotkey_globalSearch": "Pencarian global",
|
||||
"hotkey_localSearch": "Pencarian di halaman",
|
||||
"hotkey_playbackNext": "Lagu berikutnya",
|
||||
@@ -718,7 +735,7 @@
|
||||
"hotkey_playbackPlayPause": "Putar / jeda",
|
||||
"hotkey_playbackPrevious": "Lagu sebelumnya",
|
||||
"hotkey_playbackStop": "Berhenti",
|
||||
"hotkey_rate0": "Bersihkan penilaian",
|
||||
"hotkey_rate0": "Hapus penilaian",
|
||||
"hotkey_rate1": "Beri penilaian 1 bintang",
|
||||
"hotkey_rate2": "Beri penilaian 2 bintang",
|
||||
"hotkey_rate3": "Beri penilaian 3 bintang",
|
||||
@@ -732,15 +749,15 @@
|
||||
"hotkey_toggleQueue": "Ubah antrean",
|
||||
"hotkey_toggleRepeat": "Toggle ulangi",
|
||||
"hotkey_toggleShuffle": "Toggle acak",
|
||||
"hotkey_unfavoriteCurrentSong": "$t(common.currentSong) tidak favorit",
|
||||
"hotkey_unfavoritePreviousSong": "$t(common.previousSong) tidak favorit",
|
||||
"hotkey_unfavoriteCurrentSong": "Batalkan favorit $t(common.currentSong)",
|
||||
"hotkey_unfavoritePreviousSong": "Batalkan favorit $t(common.previousSong)",
|
||||
"hotkey_volumeDown": "Turunkan volume",
|
||||
"hotkey_volumeMute": "Senyapkan volume",
|
||||
"hotkey_volumeUp": "Naikkan volume",
|
||||
"hotkey_zoomIn": "Perbesar",
|
||||
"hotkey_zoomOut": "Perkecil",
|
||||
"imageAspectRatio": "Gunakan rasio aspek sampul asli",
|
||||
"imageAspectRatio_description": "Jika diaktifkan, sampul akan ditampilkan dengan rasio aspek aslinya. Untuk seni yang tidak 1:1, ruang yang tersisa akan kosong",
|
||||
"imageAspectRatio": "Gunakan rasio aspek asli sampul",
|
||||
"imageAspectRatio_description": "Jika diaktifkan, sampul akan ditampilkan menggunakan rasio aspek aslinya. Untuk sampul yang tidak 1:1, ruang yang tersisa akan kosong",
|
||||
"language_description": "Menetapkan bahasa untuk aplikasi ($t(common.restartRequired))",
|
||||
"lastfmApiKey": "Kunci API untuk {{lastfm}}",
|
||||
"lastfmApiKey_description": "Kunci API untuk {{lastfm}}. Diperlukan untuk sampul",
|
||||
@@ -769,8 +786,8 @@
|
||||
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
||||
"playerbarOpenDrawer": "Buka pemutar ke layar penuh",
|
||||
"playerbarOpenDrawer_description": "Izinkan mengklik bilah pemutar untuk membuka pemutar di layar penuh",
|
||||
"playerbarOpenDrawer": "Tombol alih layar penuh bilah pemutar",
|
||||
"playerbarOpenDrawer_description": "Memungkinkan bilah pemutar diklik untuk membuka pemutar layar penuh",
|
||||
"remotePassword": "Kata sandi kontrol jarak jauh server",
|
||||
"remotePassword_description": "Tentukan kata sandi untuk kontrol jarak jauh server. Kredensial ini dikirimkan dengan tidak aman secara default, jadi Anda harus menggunakan kata sandi unik untuk menghindari masalah",
|
||||
"remotePort": "Port kontrol jarak jauh server",
|
||||
@@ -829,7 +846,7 @@
|
||||
"translationApiKey_description": "Kunci API untuk terjemahan (hanya endpoint layanan global)",
|
||||
"translationTargetLanguage": "Bahasa tujuan penerjemahan",
|
||||
"translationTargetLanguage_description": "Bahasa tujuan untuk penerjemahan",
|
||||
"trayEnabled": "Tampilkan di area pemberitahuan",
|
||||
"trayEnabled": "Tampilkan baki",
|
||||
"trayEnabled_description": "Tampilkan/sembunyikan ikon/menu di area pemberitahuan. Jika dinonaktifkan, juga menonaktifkan meminimalkan/keluar ke baki",
|
||||
"useSystemTheme": "Gunakan tema sistem",
|
||||
"useSystemTheme_description": "Ikuti preferensi terang atau gelap yang ditetapkan oleh sistem",
|
||||
@@ -838,13 +855,13 @@
|
||||
"volumeWidth": "Lebar penggeser volume",
|
||||
"volumeWidth_description": "Lebar penggeser volume",
|
||||
"webAudio": "Gunakan audio web",
|
||||
"clearCache": "Bersihkan cache browser",
|
||||
"clearCache": "Kosongkan cache browser",
|
||||
"disableLibraryUpdateOnStartup": "Nonaktifkan pemeriksaan versi baru saat startup",
|
||||
"mpvExecutablePath": "Jalur executable mpv",
|
||||
"playButtonBehavior_optionPlay": "$t(player.play)",
|
||||
"sampleRate": "Rasio sampel",
|
||||
"savePlayQueue": "Simpan antrean pemutaran",
|
||||
"autoDJ": "DJ otomatis",
|
||||
"autoDJ": "DJ Otomatis",
|
||||
"autoDJ_description": "Tambahkan lagu serupa secara otomatis ke antrean",
|
||||
"autoDJ_itemCount": "Jumlah item",
|
||||
"autoDJ_itemCount_description": "Jumlah item yang dicoba ditambahkan ke antrean saat DJ otomatis diaktifkan",
|
||||
@@ -990,36 +1007,76 @@
|
||||
"playerItemConfiguration": "Konfigurasi item pemutar",
|
||||
"sidebarPlaylistListFilterRegex_description": "Sembunyikan playlist di bilah sisi yang cocok dengan ekspresi reguler ini",
|
||||
"sidebarPlaylistListFilterRegex_placeholder": "Mis. ^daily mix.*",
|
||||
"sidebarPlaylistListFilterRegex": "Regex filter playlist"
|
||||
"sidebarPlaylistListFilterRegex": "Regex filter playlist",
|
||||
"autosave": "Simpan antrean putar secara otomatis",
|
||||
"autosave_description": "Aktifkan penyimpanan otomatis antrean putar ke server Anda. Ini hanya dimungkinkan saat menggunakan Navidrome/Subsonic, dan Anda tidak dapat memiliki antrean putar campuran.",
|
||||
"autosaveCount": "Frekuensi penyimpanan otomatis antrean putar",
|
||||
"autosaveCount_description": "Berapa banyak perubahan trek sebelum antrean disimpan. 1 (minimum) berarti setiap pergantian lagu",
|
||||
"hotkey_listShowPlayingSong": "Tampilkan lagu yang sedang diputar dalam daftar",
|
||||
"listenbrainz_description": "Tampilkan tautan ke ListenBrainz pada halaman artis/album",
|
||||
"listenbrainz": "Tampilkan tautan ListenBrainz",
|
||||
"qobuz_description": "Tampilkan tautan ke Qobuz pada halaman artis/album",
|
||||
"qobuz": "Tampilkan tautan Qobuz",
|
||||
"spotify_description": "Tampilkan tautan ke Spotify pada halaman artis/album",
|
||||
"spotify": "Tampilkan tautan Spotify",
|
||||
"nativeSpotify_description": "Buka di aplikasi Spotify alih-alih di browser Anda",
|
||||
"nativeSpotify": "Gunakan aplikasi Spotify",
|
||||
"playerbarWaveformStretch": "Peregangan bentuk gelombang",
|
||||
"playerbarWaveformStretch_description": "Meregangkan bentuk gelombang untuk memenuhi ruang yang tersedia",
|
||||
"preventSuspendOnPlayback_description": "Cegah aplikasi ditangguhkan saat musik diputar",
|
||||
"preventSuspendOnPlayback": "Cegah penangguhan saat pemutaran",
|
||||
"sidebarPlaylistFolders_description": "Buat tampilan folder untuk daftar putar yang menyertakan pemisah yang dikonfigurasi dalam namanya",
|
||||
"sidebarPlaylistFolders": "Aktifkan folder",
|
||||
"sidebarPlaylistFolderSeparator_description": "Karakter (atau string) yang memisahkan tingkat folder dalam nama daftar putar",
|
||||
"sidebarPlaylistFolderSeparator": "Pemisah folder",
|
||||
"sidebarPlaylistFolderView_description": "Cara folder ditampilkan di bilah sisi",
|
||||
"sidebarPlaylistFolderView": "Tampilan folder",
|
||||
"sidebarPlaylistFolderView_optionSingle": "Folder tunggal",
|
||||
"sidebarPlaylistFolderView_optionTree": "Tampilan pohon",
|
||||
"sidebarPlaylistFolderView_optionNavigation": "Tampilan navigasi",
|
||||
"sidebarPlaylistFolderTreeIndent_description": "Jumlah piksel indentasi tiap tingkat pohon",
|
||||
"sidebarPlaylistFolderTreeIndent": "Indentasi pohon",
|
||||
"sidebarPlaylistFolderTreeLineColor_description": "Warna garis penghubung pohon (biarkan kosong untuk default tema)",
|
||||
"sidebarPlaylistFolderTreeLineColor": "Warna garis pohon",
|
||||
"sidebarPlaylistMode_description": "Cara setiap daftar putar ditampilkan dalam daftar bilah sisi",
|
||||
"sidebarPlaylistMode": "Mode daftar putar bilah sisi",
|
||||
"sidebarPlaylistMode_optionCompact": "Ringkas",
|
||||
"sidebarPlaylistMode_optionExpanded": "Diperluas",
|
||||
"sidePlayQueueLayout": "Tata letak antrean putar samping",
|
||||
"sidePlayQueueLayout_description": "Mengatur tata letak antrean putar samping yang terlampir",
|
||||
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
|
||||
"sidePlayQueueLayout_optionVertical": "Vertikal",
|
||||
"waveformLoadingDelay": "Penundaan pemuatan bentuk gelombang",
|
||||
"waveformLoadingDelay_description": "Penundaan dalam detik sebelum memuat bentuk gelombang. Tingkatkan nilai ini jika Anda mengalami tersendat saat menggunakan pemutar web."
|
||||
},
|
||||
"table": {
|
||||
"column": {
|
||||
"album": "Album",
|
||||
"albumArtist": "Artis album",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"albumCount": "Album",
|
||||
"artist": "Artis",
|
||||
"biography": "Biografi",
|
||||
"bitrate": "Bitrate",
|
||||
"bpm": "Lpm",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"codec": "$t(common.codec)",
|
||||
"channels": "Saluran",
|
||||
"codec": "Kodek",
|
||||
"comment": "Komentar",
|
||||
"dateAdded": "Tanggal ditambahkan",
|
||||
"discNumber": "Nomor disk",
|
||||
"favorite": "Favorit",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"genre": "Genre",
|
||||
"lastPlayed": "Terakhir diputar",
|
||||
"path": "Jalur",
|
||||
"playCount": "Putaran",
|
||||
"rating": "Penilaian",
|
||||
"releaseDate": "Tanggal rilis",
|
||||
"releaseYear": "Tahun",
|
||||
"size": "$t(common.size)",
|
||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
||||
"size": "Ukuran",
|
||||
"songCount": "Trek",
|
||||
"title": "Judul",
|
||||
"trackNumber": "Pista",
|
||||
"bitDepth": "$t(common.bitDepth)",
|
||||
"sampleRate": "$t(common.sampleRate)",
|
||||
"bitDepth": "Kedalaman Bit",
|
||||
"sampleRate": "Laju Sampel",
|
||||
"owner": "Pemilik"
|
||||
},
|
||||
"config": {
|
||||
@@ -1307,6 +1364,13 @@
|
||||
"d": "D",
|
||||
"z": "Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
"systemAudioConsentAllow": "Izinkan",
|
||||
"systemAudioConsentBody": "Visualizer memerlukan akses ke audio sistem agar dapat berfungsi",
|
||||
"systemAudioConsentDecline": "Tolak",
|
||||
"systemAudioConsentTitle": "Izinkan akses ke audio sistem?",
|
||||
"systemAudioCaptureFailed": "Tidak dapat memulai pengambilan: {{message}}",
|
||||
"systemAudioNoAudioTrack": "Tidak ada trek audio yang dikembalikan. Pastikan pengambilan audio diaktifkan saat diminta.",
|
||||
"systemAudioExclusiveModeNotSupported": "Visualizer tidak tersedia saat mode audio eksklusif diaktifkan. Nonaktifkan Mode Audio Eksklusif di pengaturan MPV lalu coba lagi."
|
||||
}
|
||||
}
|
||||
|
||||
+19
-14
@@ -48,7 +48,8 @@
|
||||
"holdToShuffle": "長押しでシャッフル",
|
||||
"albumRadio": "アルバム・ラジオ",
|
||||
"artistRadio": "アーティストラジオ",
|
||||
"trackRadio": "ラジオを追跡する"
|
||||
"trackRadio": "ラジオを追跡する",
|
||||
"scrobbleForceSubmit": "強制 Scrobble"
|
||||
},
|
||||
"setting": {
|
||||
"crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します",
|
||||
@@ -429,7 +430,8 @@
|
||||
"playerbarWaveformStretch": "波形伸縮",
|
||||
"playerbarWaveformStretch_description": "波形を伸縮させて、利用可能なスペースを埋めます",
|
||||
"preventSuspendOnPlayback_description": "音楽再生中にアプリケーションが停止しないようにします",
|
||||
"preventSuspendOnPlayback": "再生の中断を防止する"
|
||||
"preventSuspendOnPlayback": "再生の中断を防止する",
|
||||
"hotkey_listShowPlayingSong": "再生中の曲をリストに表示"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
|
||||
@@ -473,7 +475,9 @@
|
||||
"openApplicationDirectory": "アプリケーションディレクトリを開く",
|
||||
"selectRangeOfItems": "項目の範囲を選択",
|
||||
"addOrRemoveFromSelection": "選択に追加または削除",
|
||||
"goToCurrent": "現在の項目へ移動"
|
||||
"goToCurrent": "現在の項目へ移動",
|
||||
"collapseAllFolders": "すべてのフォルダーを折りたたむ",
|
||||
"expandAllFolders": "すべてのフォルダーを展開する"
|
||||
},
|
||||
"common": {
|
||||
"backward": "戻る",
|
||||
@@ -594,7 +598,8 @@
|
||||
"rename": "名前を変更",
|
||||
"newVersionAvailable": "新しいバージョンが利用可能です",
|
||||
"numberOfResults": "{{numberOfResults}} 件の結果",
|
||||
"grouping": "グループ化"
|
||||
"grouping": "グループ化",
|
||||
"back": "戻る"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -643,7 +648,7 @@
|
||||
"titleCombined": "$t(common.title) (結合)",
|
||||
"dateAdded": "追加日",
|
||||
"size": "$t(common.size)",
|
||||
"bpm": "$t(common.BPM)",
|
||||
"bpm": "$t(common.bpm)",
|
||||
"lastPlayed": "最後に再生",
|
||||
"trackNumber": "トラック番号",
|
||||
"rowIndex": "行インデックス",
|
||||
@@ -681,7 +686,7 @@
|
||||
"rating": "評価",
|
||||
"favorite": "お気に入り",
|
||||
"playCount": "再生回数",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||
"albumCount": "アルバム",
|
||||
"releaseYear": "年",
|
||||
"lastPlayed": "最後に再生",
|
||||
"biography": "バイオグラフィー",
|
||||
@@ -690,18 +695,18 @@
|
||||
"title": "タイトル",
|
||||
"bpm": "BPM",
|
||||
"dateAdded": "追加日",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
||||
"artist": "アーティスト",
|
||||
"songCount": "トラック",
|
||||
"trackNumber": "トラック",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"genre": "ジャンル",
|
||||
"albumArtist": "アルバムアーティスト",
|
||||
"path": "パス",
|
||||
"discNumber": "ディスク",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"size": "$t(common.size)",
|
||||
"codec": "$t(common.codec)",
|
||||
"bitDepth": "$t(common.bitDepth)",
|
||||
"sampleRate": "$t(common.sampleRate)",
|
||||
"channels": "チャンネル",
|
||||
"size": "サイズ",
|
||||
"codec": "コーデック",
|
||||
"bitDepth": "ビット深度",
|
||||
"sampleRate": "サンプリング周波数",
|
||||
"owner": "所有者"
|
||||
}
|
||||
},
|
||||
|
||||
+348
-17
@@ -20,8 +20,25 @@
|
||||
"viewPlaylists": "Ver $t(entity.playlist, {\"count\": 2})",
|
||||
"openIn": {
|
||||
"lastfm": "Abrir em Last.fm",
|
||||
"musicbrainz": "Abrir em MusicBrainz"
|
||||
}
|
||||
"musicbrainz": "Abrir em MusicBrainz",
|
||||
"listenbrainz": "Abrir no ListenBrainz",
|
||||
"qobuz": "Abrir no Qobuz",
|
||||
"spotify": "Abrir no Spotify"
|
||||
},
|
||||
"addOrRemoveFromSelection": "Adicionar ou remover da seleção",
|
||||
"goToCurrent": "Ir para o elemento atual",
|
||||
"collapseAllFolders": "Colapsar todas as pastas",
|
||||
"expandAllFolders": "Expandir todas as pastas",
|
||||
"createRadioStation": "Criar $t(entity.radioStation, {\"count\": 1})",
|
||||
"deleteRadioStation": "Apagar $t(entity.radioStation, {\"count\": 1})",
|
||||
"selectAll": "Selecionar tudo",
|
||||
"moveUp": "Mover para cima",
|
||||
"moveDown": "Mover para baixo",
|
||||
"holdToMoveToTop": "Segure para ir ao topo",
|
||||
"holdToMoveToBottom": "Mover para ir ao fundo",
|
||||
"moveItems": "Mover elementos",
|
||||
"viewMore": "Ver mais",
|
||||
"openApplicationDirectory": "Abrir a pasta da aplicação"
|
||||
},
|
||||
"common": {
|
||||
"action_one": "Ação",
|
||||
@@ -122,7 +139,21 @@
|
||||
"unknown": "Desconhecido",
|
||||
"version": "Versão",
|
||||
"year": "Ano",
|
||||
"yes": "Sim"
|
||||
"yes": "Sim",
|
||||
"countSelected": "{{count}} selecionado",
|
||||
"bitDepth": "Profundidade de bits",
|
||||
"example": "Exemplo",
|
||||
"externalLinks": "Ligações externas",
|
||||
"mood": "Humor",
|
||||
"private": "Privado",
|
||||
"public": "Público",
|
||||
"retry": "Tentar novamente",
|
||||
"rename": "Renomear",
|
||||
"sampleRate": "Taxa de amostragem",
|
||||
"sort": "Ordenar",
|
||||
"clean": "Limpar",
|
||||
"itemsMore": "{{count}} mais",
|
||||
"newVersionAvailable": "Uma nova versão está disponível"
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Álbum",
|
||||
@@ -201,7 +232,9 @@
|
||||
"serverNotSelectedError": "Nenhum servidor selecionado",
|
||||
"serverRequired": "Servidor necessário",
|
||||
"sessionExpiredError": "A sua sessão expirou",
|
||||
"systemFontError": "Ocorreu um erro ao tentar obter fontes do sistema"
|
||||
"systemFontError": "Ocorreu um erro ao tentar obter fontes do sistema",
|
||||
"invalidJson": "JSON inválido",
|
||||
"noNetwork": "Servidor não disponível"
|
||||
},
|
||||
"filter": {
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
@@ -245,7 +278,10 @@
|
||||
"songCount": "Contador de músicas",
|
||||
"title": "Titulo",
|
||||
"toYear": "Até o ano",
|
||||
"trackNumber": "Faixa"
|
||||
"trackNumber": "Faixa",
|
||||
"matchAnd": "E",
|
||||
"matchOr": "Ou",
|
||||
"sortName": "Ordenar por nome"
|
||||
},
|
||||
"form": {
|
||||
"addServer": {
|
||||
@@ -259,7 +295,8 @@
|
||||
"input_url": "Url",
|
||||
"input_username": "Nome de utilizador",
|
||||
"success": "Servidor adicionado com sucesso",
|
||||
"title": "Adicionar servidor"
|
||||
"title": "Adicionar servidor",
|
||||
"input_remoteUrl": "URL público"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||
@@ -292,7 +329,9 @@
|
||||
},
|
||||
"queryEditor": {
|
||||
"input_optionMatchAll": "Corresponder todos",
|
||||
"input_optionMatchAny": "Corresponder qualquer um"
|
||||
"input_optionMatchAny": "Corresponder qualquer um",
|
||||
"resetToDefault": "Restaurar à predefinição",
|
||||
"clearFilters": "Limpar filtros"
|
||||
},
|
||||
"shareItem": {
|
||||
"allowDownloading": "Permitir descargas",
|
||||
@@ -305,6 +344,21 @@
|
||||
"updateServer": {
|
||||
"success": "Servidor atualizado com sucesso",
|
||||
"title": "Atualizar servidor"
|
||||
},
|
||||
"createRadioStation": {
|
||||
"title": "Criar estação de rádio",
|
||||
"input_name": "Nome"
|
||||
},
|
||||
"lyricsExport": {
|
||||
"input_synced": "Exportar letras sincronizadas"
|
||||
},
|
||||
"shuffleAll": {
|
||||
"title": "Tocar aleatório",
|
||||
"input_minYear": "A partir do ano",
|
||||
"input_maxYear": "Até o ano"
|
||||
},
|
||||
"privateMode": {
|
||||
"title": "Modo Privado"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
@@ -317,7 +371,9 @@
|
||||
"topSongs": "Músicas mais tocadas",
|
||||
"topSongsFrom": "Músicas mais tocadas de {{title}}",
|
||||
"viewAll": "Ver tudo",
|
||||
"viewAllTracks": "Ver todas as $t(entity.track, {\"count\": 2})"
|
||||
"viewAllTracks": "Ver todas as $t(entity.track, {\"count\": 2})",
|
||||
"topSongsCommunity": "Comunidade",
|
||||
"topSongsPersonal": "Pessoal"
|
||||
},
|
||||
"albumArtistList": {
|
||||
"title": "$t(entity.albumArtist, {\"count\": 2})"
|
||||
@@ -374,7 +430,8 @@
|
||||
"setRating": "$t(action.setRating)",
|
||||
"playShuffled": "$t(player.shuffle)",
|
||||
"shareItem": "Partilhar elemento",
|
||||
"showDetails": "Obter informações"
|
||||
"showDetails": "Obter informações",
|
||||
"goTo": "Ir para"
|
||||
},
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
@@ -417,7 +474,8 @@
|
||||
"mostPlayed": "Mais tocado",
|
||||
"newlyAdded": "Lançamentos recém-adicionados",
|
||||
"recentlyPlayed": "Tocado recentemente",
|
||||
"title": "$t(common.home)"
|
||||
"title": "$t(common.home)",
|
||||
"genres": "$t(entity.genre, {\"count\": 2})"
|
||||
},
|
||||
"itemDetail": {
|
||||
"copyPath": "Copiar caminho para a área de transferência",
|
||||
@@ -435,7 +493,18 @@
|
||||
"generalTab": "Geral",
|
||||
"hotkeysTab": "Teclas de atalho",
|
||||
"playbackTab": "Reprodução",
|
||||
"windowTab": "Janela"
|
||||
"windowTab": "Janela",
|
||||
"application": "Aplicação",
|
||||
"queryBuilder": "Construtor de Consultas",
|
||||
"theme": "Tema",
|
||||
"controls": "Controles",
|
||||
"sidebar": "Barra lateral",
|
||||
"remote": "Remoto",
|
||||
"exportImport": "Importar/exportar",
|
||||
"audio": "Áudio",
|
||||
"lyrics": "Letras",
|
||||
"transcoding": "Transcodificar",
|
||||
"discord": "Discord"
|
||||
},
|
||||
"sidebar": {
|
||||
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
|
||||
@@ -450,12 +519,19 @@
|
||||
"search": "$t(common.search)",
|
||||
"settings": "$t(common.setting, {\"count\": 2})",
|
||||
"shared": "$t(entity.playlist, {\"count\": 2}) partilhada",
|
||||
"tracks": "$t(entity.track, {\"count\": 2})"
|
||||
"tracks": "$t(entity.track, {\"count\": 2})",
|
||||
"collections": "Coleções"
|
||||
},
|
||||
"trackList": {
|
||||
"artistTracks": "Faixas de {{artist}}",
|
||||
"genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})",
|
||||
"title": "$t(entity.track, {\"count\": 2})"
|
||||
},
|
||||
"radioList": {
|
||||
"title": "Estações de rádio"
|
||||
},
|
||||
"folderList": {
|
||||
"title": "$t(entity.folder, {\"count\": 2})"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -489,7 +565,11 @@
|
||||
"toggleFullscreenPlayer": "Alternar player de ecrã cheio",
|
||||
"unfavorite": "Remover favorito",
|
||||
"pause": "Pausar",
|
||||
"viewQueue": "Ver fila"
|
||||
"viewQueue": "Ver fila",
|
||||
"lyrics": "Letra",
|
||||
"sleepTimer_minutes": "{{count}} min",
|
||||
"sleepTimer_hours": "{{count}} hr",
|
||||
"sleepTimer_off": "Desligado"
|
||||
},
|
||||
"setting": {
|
||||
"accentColor": "Cor de realce",
|
||||
@@ -528,18 +608,269 @@
|
||||
"discordApplicationId": "{{discord}} ID da aplicação",
|
||||
"discordIdleStatus_description": "Quando ativado, atualiza o estado enquanto o player está ocioso",
|
||||
"discordUpdateInterval_description": "O tempo em segundos entre cada atualização (mínimo 15 segundos)",
|
||||
"playButtonBehavior_description": "Define o comportamento padrão do botão play ao adicionar músicas à fila"
|
||||
"playButtonBehavior_description": "Define o comportamento padrão do botão play ao adicionar músicas à fila",
|
||||
"autoDJ_itemCount": "Número de elementos",
|
||||
"autoDJ_timing": "Tempo",
|
||||
"customCss_description": "Conteúdo CSS personalizado. Observação: conteúdo e urls remotas são propriedades não permitidas. Uma pré-visualização do seu conteúdo é exibida abaixo. Campos adicionais que não definiu estão presentes devido à sanitização",
|
||||
"automaticUpdates": "Atualizações automáticas",
|
||||
"releaseChannel_optionBeta": "Beta",
|
||||
"releaseChannel_optionLatest": "Mais recente",
|
||||
"discordApplicationId_description": "O ID da aplicação para o rich presence do {{discord}} (defaults: {{defaultId}})",
|
||||
"discordDisplayType_artistname": "Nome(s) do(s) artista(s)",
|
||||
"discordDisplayType": "Tipo de exibição da presença do {{discord}}",
|
||||
"discordIdleStatus": "Mostrar estado ocioso do rich presence",
|
||||
"discordLinkType_mbz_lastfm": "{{musicbrainz}} com alternativa para {{lastfm}}",
|
||||
"discordLinkType_none": "$t(common.none)",
|
||||
"discordLinkType": "Ligações de presença do {{discord}}",
|
||||
"discordServeImage_description": "Partilhar a capa para o rich presence do {{discord}} a partir do próprio servidor, disponível apenas para Jellyfin e Navidrome. O {{discord}} usa um bot para buscar imagens, portanto o seu servidor deve estar acessível pela internet pública",
|
||||
"discordUpdateInterval": "Intervalo de atualização do rich presence do {{discord}}",
|
||||
"exportImportSettings_control_importText": "Importar configurações",
|
||||
"exportImportSettings_control_title": "Importar / exportar configurações",
|
||||
"exportImportSettings_importBtn": "Importar configurações",
|
||||
"exportImportSettings_importModalTitle": "Importar configurações do Feishin",
|
||||
"externalLinks": "Mostrar ligações externas",
|
||||
"font": "Fonte",
|
||||
"fontType_optionBuiltIn": "Fonte embutida",
|
||||
"fontType_optionCustom": "Fonte personalizada",
|
||||
"fontType_optionSystem": "Fonte do sistema",
|
||||
"fontType": "Tipo da fonte",
|
||||
"homeFeatureStyle_optionMultiple": "Múltiplos",
|
||||
"hotkey_globalSearch": "Pequisa global",
|
||||
"hotkey_playbackPause": "Pausar",
|
||||
"hotkey_playbackPlay": "Tocar",
|
||||
"hotkey_playbackPlayPause": "Play / pausar",
|
||||
"hotkey_playbackStop": "Parar",
|
||||
"hotkey_volumeMute": "Volume mudo",
|
||||
"hotkey_volumeUp": "Aumentar volume",
|
||||
"hotkey_zoomIn": "Aproximar",
|
||||
"hotkey_zoomOut": "Afastar",
|
||||
"imageAspectRatio_description": "Se ativado, a capa será exibida usando a sua proporção nativa. Para capas que não forem 1:1, o espaço restante ficará vazio",
|
||||
"imageAspectRatio": "Usar proporção nativa da capa",
|
||||
"language_description": "Define o idioma da aplicação ($t(common.restartRequired))",
|
||||
"lastfm_description": "Exibir ligações para o Last.fm nas páginas de artista/álbum",
|
||||
"lastfm": "Mostrar ligações do Last.fm",
|
||||
"lastfmApiKey_description": "A chave de API para {{lastfm}}. Necessária para capas de álbuns",
|
||||
"lastfmApiKey": "{{lastfm}} chave API",
|
||||
"lyricFetch_description": "Buscar letras em várias fontes da internet",
|
||||
"lyricFetch": "Buscar letras na internet",
|
||||
"lyricFetchProvider": "Provedores para buscar letras",
|
||||
"lyricOffset_description": "Compensar a letra pelo valor especificado em milissegundos",
|
||||
"lyricOffset": "Compensação da letra (ms)",
|
||||
"minimizeToTray_description": "Minimizar a aplicação para a bandeja do sistema",
|
||||
"minimizeToTray": "Minimizar para a bandeja",
|
||||
"minimumScrobblePercentage_description": "O percentual mínimo da música que deve ser reproduzido antes de ser scrobblada",
|
||||
"minimumScrobblePercentage": "Duração mínima para scrobble (percentual)",
|
||||
"minimumScrobbleSeconds_description": "A duração mínima em segundos da música que deve ser reproduzida antes de ser scrobblada",
|
||||
"minimumScrobbleSeconds": "Scrobble mínimo (segundos)",
|
||||
"mpvExecutablePath": "Caminho do executável do mpv",
|
||||
"mpvExtraParameters_help": "Um por linha",
|
||||
"musicbrainz_description": "Exibir ligações para o MusicBrainz nas páginas de artista/álbum, quando o ID do MusicBrainz existir",
|
||||
"musicbrainz": "Mostrar ligações do MusicBrainz",
|
||||
"neteaseTranslation_description": "Quando ativado, busca e exibe letras traduzidas do NetEase, se disponíveis",
|
||||
"neteaseTranslation": "Ativar traduções do NetEase",
|
||||
"passwordStore_description": "Qual armazenamento de palavras-passe/segredos usar. Altere isto se tem problemas para armazenar palavras-passe",
|
||||
"playbackStyle_description": "Selecione o estilo de reprodução a ser usado pelo reprodutor de áudio",
|
||||
"playbackStyle_optionCrossFade": "Transição suave",
|
||||
"playbackStyle_optionNormal": "Normal",
|
||||
"playbackStyle": "Estilo de reprodução",
|
||||
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
|
||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||
"playButtonBehavior_optionPlay": "$t(player.play)",
|
||||
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
||||
"playButtonBehavior": "Comportamento do botão de reprodução",
|
||||
"imageResolution_optionSidebar": "Barra lateral",
|
||||
"imageResolution_optionHeader": "Cabeçalho",
|
||||
"imageResolution_optionFullScreenPlayer": "Reprodutor de ecrã cheio",
|
||||
"playerbarOpenDrawer_description": "Permite clicar na barra do reprodutor para abrir o reprodutor em ecrã cheio",
|
||||
"playerbarOpenDrawer": "Alternar ecrã cheio na barra do reprodutor",
|
||||
"playerbarSliderType_optionWaveform": "Forma de onda",
|
||||
"playerbarWaveformAlign_optionTop": "Topo",
|
||||
"playerbarWaveformAlign_optionCenter": "Centro",
|
||||
"playerbarWaveformAlign_optionBottom": "Fundo",
|
||||
"showRatings_description": "Exibir ou ocultar as avaliações por estrelas",
|
||||
"showRatings": "Exibir avaliações por estrelas",
|
||||
"remotePassword_description": "Define a palavra-passe do servidor de controlo remoto. Estas credenciais, por padrão, são transferidas de forma insegura — use uma palavra-passe única da qualnão dependa",
|
||||
"remotePort": "Porta do servidor de controlo remoto",
|
||||
"replayGainClipping_description": "Evitar clipping causado pelo {{ReplayGain}} reduzindo automaticamente o ganho",
|
||||
"replayGainClipping": "Clipping do {{ReplayGain}}",
|
||||
"replayGainFallback": "Fallback do {{ReplayGain}}",
|
||||
"replayGainMode_optionAlbum": "$t(entity.album, {\"count\": 1})",
|
||||
"replayGainMode_optionNone": "$t(common.none)",
|
||||
"replayGainMode_optionTrack": "$t(entity.track, {\"count\": 1})",
|
||||
"replayGainMode": "Modo {{ReplayGain}}",
|
||||
"replayGainPreamp": "Pré-amplificador {{ReplayGain}} (db)",
|
||||
"sampleRate": "Taxa de amostragem",
|
||||
"sidebarPlaylistFolderView_optionNavigation": "Vista de navegação",
|
||||
"sidebarPlaylistMode_optionCompact": "Compacto",
|
||||
"sidebarPlaylistMode_optionExpanded": "Expandido",
|
||||
"sidePlayQueueStyle_optionAttached": "Anexado",
|
||||
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
|
||||
"sidePlayQueueLayout_optionVertical": "Vertical",
|
||||
"startMinimized": "Abrir minimizado",
|
||||
"theme": "Tema",
|
||||
"themeDark": "Tema (escuro)",
|
||||
"transcode": "Ativar transcodificação",
|
||||
"translationTargetLanguage": "Idioma de destino para tradução",
|
||||
"useSystemTheme": "Usar tema do sistema",
|
||||
"queryBuilderCustomFields_inputLabel": "Etiqueta"
|
||||
},
|
||||
"table": {
|
||||
"column": {
|
||||
"discNumber": "Disco",
|
||||
"size": "$t(common.size)",
|
||||
"title": "Titulo"
|
||||
"size": "Tamanho",
|
||||
"title": "Titulo",
|
||||
"album": "Álbum",
|
||||
"albumArtist": "Artista do álbum",
|
||||
"albumCount": "Álbuns",
|
||||
"artist": "Artista",
|
||||
"biography": "Bibliografia",
|
||||
"bitDepth": "Profundidade de Bits",
|
||||
"bitrate": "Bitrate",
|
||||
"bpm": "Bpm",
|
||||
"channels": "Canais",
|
||||
"codec": "Codec",
|
||||
"comment": "Comentário",
|
||||
"dateAdded": "Data Adicionada",
|
||||
"favorite": "Favorito",
|
||||
"genre": "Gênero",
|
||||
"lastPlayed": "Última tocada",
|
||||
"path": "Caminho",
|
||||
"playCount": "Reproduções",
|
||||
"rating": "Avaliação",
|
||||
"releaseDate": "Data de Lançamento",
|
||||
"releaseYear": "Ano",
|
||||
"sampleRate": "Taxa de amostragem",
|
||||
"trackNumber": "Faixa",
|
||||
"owner": "Dono"
|
||||
},
|
||||
"config": {
|
||||
"label": {
|
||||
"discNumber": "Numero do disco",
|
||||
"titleCombined": "$t(common.title) (combinado)"
|
||||
"titleCombined": "$t(common.title) (combinado)",
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"biography": "$t(common.biography)",
|
||||
"bitrate": "$t(common.bitrate)",
|
||||
"bpm": "$t(common.bpm)",
|
||||
"codec": "$t(common.codec)",
|
||||
"composer": "Compositor",
|
||||
"duration": "$t(common.duration)",
|
||||
"favorite": "$t(common.favorite)",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"image": "Imagem",
|
||||
"lastPlayed": "Última reprodução",
|
||||
"note": "$t(common.note)",
|
||||
"owner": "$t(common.owner)",
|
||||
"path": "$t(common.path)",
|
||||
"playCount": "Contador de Reprodução",
|
||||
"rating": "$t(common.rating)",
|
||||
"releaseDate": "Data de Lançamento",
|
||||
"size": "$t(common.size)",
|
||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
||||
"title": "$t(common.title)",
|
||||
"year": "$t(common.year)"
|
||||
},
|
||||
"general": {
|
||||
"advancedSettings": "Configurações avançadas",
|
||||
"moveUp": "Mover para cima",
|
||||
"moveDown": "Mover para baixo",
|
||||
"alignLeft": "Alinhar à esquerda",
|
||||
"alignCenter": "Alinhar ao centro",
|
||||
"alignRight": "Alinhar à direita",
|
||||
"displayType": "Tipo do ecrã",
|
||||
"gap": "$t(common.gap)",
|
||||
"size": "$t(common.size)",
|
||||
"size_default": "Predefinição",
|
||||
"size_compact": "Compacto",
|
||||
"size_large": "Grande",
|
||||
"tableColumns": "Colunas das tabelas",
|
||||
"pagination": "Paginação",
|
||||
"pagination_itemsPerPage": "Elementos por página",
|
||||
"pagination_infinite": "Infinito",
|
||||
"pagination_paginate": "Paginado",
|
||||
"showHeader": "Exibir cabeçalho"
|
||||
},
|
||||
"view": {
|
||||
"grid": "Grade"
|
||||
}
|
||||
}
|
||||
},
|
||||
"filterOperator": {
|
||||
"contains": "Contém",
|
||||
"endsWith": "Termina com",
|
||||
"is": "É",
|
||||
"isNot": "Não é",
|
||||
"isGreaterThan": "É maior que",
|
||||
"isLessThan": "É menor que",
|
||||
"notContains": "Não contém",
|
||||
"startsWith": "Começa com"
|
||||
},
|
||||
"releaseType": {
|
||||
"primary": {
|
||||
"broadcast": "Broadcast",
|
||||
"ep": "EP",
|
||||
"other": "Outros",
|
||||
"single": "Simples"
|
||||
},
|
||||
"secondary": {
|
||||
"compilation": "Compilação",
|
||||
"djMix": "Mixagem de DJ",
|
||||
"demo": "Demo",
|
||||
"interview": "Entrevista",
|
||||
"live": "Ao Vivo"
|
||||
}
|
||||
},
|
||||
"visualizer": {
|
||||
"systemAudioConsentAllow": "Permitir",
|
||||
"systemAudioConsentDecline": "Recusar",
|
||||
"ignoredPresets": "Predefinições Ignoradas",
|
||||
"selectedPresets": "84",
|
||||
"presets": "Predefinições",
|
||||
"applyPreset": "Aplicar Predefinição",
|
||||
"saveAsPreset": "Gravar como Predefinição",
|
||||
"updatePreset": "Atualizar Predefinição",
|
||||
"copyConfiguration": "Copiar Configuração",
|
||||
"pasteConfiguration": "Colar Configuração",
|
||||
"presetNamePlaceholder": "Digite o nome da predefinição",
|
||||
"general": "Geral",
|
||||
"mode": "Modo",
|
||||
"mode1To8": "Modo 1 - 8",
|
||||
"mode10": "Modo 10",
|
||||
"lineWidth": "Largura da Linha",
|
||||
"opacity": "Opacidade",
|
||||
"vertical": "Vertical",
|
||||
"horizontal": "Horizontal",
|
||||
"level": "Nível",
|
||||
"remove": "Remover",
|
||||
"custom": "Personalizado",
|
||||
"builtIn": "Embutido",
|
||||
"colors": "Cores",
|
||||
"gradient": "Gradiente",
|
||||
"smoothing": "Suavizamento",
|
||||
"sensitivity": "Sensibilidade",
|
||||
"gravity": "Gravidade",
|
||||
"radial": "Radial",
|
||||
"radius": "Raio",
|
||||
"options": {
|
||||
"colorMode": {
|
||||
"gradient": "Gradiente"
|
||||
},
|
||||
"gradient": {
|
||||
"classic": "Clássico",
|
||||
"rainbow": "Arco-íris"
|
||||
},
|
||||
"frequencyScale": {
|
||||
"none": "Nenhum"
|
||||
},
|
||||
"weightingFilter": {
|
||||
"none": "Nenhum",
|
||||
"a": "A",
|
||||
"b": "B",
|
||||
"c": "C",
|
||||
"d": "D",
|
||||
"z": "Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@
|
||||
"viewMore": "Посмотреть больше",
|
||||
"openApplicationDirectory": "Открыть папку приложения",
|
||||
"selectRangeOfItems": "Выбрать диапазон элементов",
|
||||
"goToCurrent": "Перейти к текущему элементу"
|
||||
"goToCurrent": "Перейти к текущему элементу",
|
||||
"collapseAllFolders": "Свернуть все папки",
|
||||
"expandAllFolders": "Развернуть все папки"
|
||||
},
|
||||
"common": {
|
||||
"backward": "Назад",
|
||||
@@ -171,7 +173,8 @@
|
||||
"externalLinks": "Внешние ссылки",
|
||||
"explicitStatus": "Признак нецензурного контента",
|
||||
"newVersionAvailable": "Доступна новая версия",
|
||||
"numberOfResults": "{{numberOfResults}} результатов"
|
||||
"numberOfResults": "{{numberOfResults}} результатов",
|
||||
"back": "Назад"
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Альбом",
|
||||
@@ -430,7 +433,8 @@
|
||||
"sleepTimer_timeRemaining": "{{time}} осталось",
|
||||
"sleepTimer_setCustom": "Установить таймер",
|
||||
"sleepTimer_custom": "Пользовательский",
|
||||
"sleepTimer_cancel": "Отменить таймер"
|
||||
"sleepTimer_cancel": "Отменить таймер",
|
||||
"scrobbleForceSubmit": "Принудительная скробблинг"
|
||||
},
|
||||
"page": {
|
||||
"sidebar": {
|
||||
@@ -491,7 +495,8 @@
|
||||
"selectMusicFolder": "Выбрать папку с музыкой",
|
||||
"noMusicFolder": "Папка с музыкой не выбрана",
|
||||
"multipleMusicFolders": "{{count}} выбрано музыкальных папок",
|
||||
"commandPalette": "открыть командную строку"
|
||||
"commandPalette": "открыть командную строку",
|
||||
"settings": "$t(common.setting, {\"count\": 2})"
|
||||
},
|
||||
"manageServers": {
|
||||
"title": "Сервера",
|
||||
@@ -569,7 +574,8 @@
|
||||
},
|
||||
"genreList": {
|
||||
"showAlbums": "показать $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})",
|
||||
"showTracks": "показать $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})"
|
||||
"showTracks": "показать $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})",
|
||||
"title": "$t(entity.genre, {\"count\": 2})"
|
||||
},
|
||||
"trackList": {
|
||||
"artistTracks": "Треки {{artist}}",
|
||||
@@ -681,7 +687,8 @@
|
||||
"input_skipDuplicates": "не добавлять дубликаты",
|
||||
"create": "создать $t(entity.playlist, {\"count\": 1}) {{playlist}}",
|
||||
"searchOrCreate": "для создания нового списка выполните поиск по $t(entity.playlist, {\"count\": 2}) или введите соответствующий текст",
|
||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})"
|
||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||
"noneAdded": "Ни один трек не добавлен в $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
|
||||
},
|
||||
"updateServer": {
|
||||
"title": "Обновление сервера",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const disableAutoUpdates = () => {
|
||||
return process.env['DISABLE_AUTO_UPDATES'];
|
||||
};
|
||||
|
||||
export const isMacOS = () => {
|
||||
return process.platform === 'darwin';
|
||||
};
|
||||
|
||||
export const isWindows = () => {
|
||||
return process.platform === 'win32';
|
||||
};
|
||||
|
||||
export const isLinux = () => {
|
||||
return process.platform === 'linux';
|
||||
};
|
||||
@@ -4,3 +4,4 @@ import './player';
|
||||
import './remote';
|
||||
import './settings';
|
||||
import './discord-rpc';
|
||||
import './visualizer';
|
||||
|
||||
@@ -7,9 +7,10 @@ import { pid } from 'node:process';
|
||||
import process from 'process';
|
||||
|
||||
import { getMainWindow, sendToastToRenderer } from '../../../index';
|
||||
import { createLog, isMacOS, isWindows } from '../../../utils';
|
||||
import { createLog } from '../../../utils';
|
||||
import { store } from '../settings';
|
||||
|
||||
import { isMacOS, isWindows } from '/@/main/env';
|
||||
import { PlayerData } from '/@/shared/types/domain-types';
|
||||
|
||||
declare module 'node-mpv';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { BrowserWindow, globalShortcut, systemPreferences } from 'electron';
|
||||
|
||||
import { isLinux, isMacOS } from '../../../utils';
|
||||
import { store } from '../settings';
|
||||
|
||||
import { isLinux, isMacOS } from '/@/main/env';
|
||||
import { PlayerType } from '/@/shared/types/types';
|
||||
|
||||
export const enableMediaKeys = (window: BrowserWindow | null) => {
|
||||
|
||||
@@ -9,8 +9,8 @@ import { deflate, gzip } from 'zlib';
|
||||
|
||||
import manifest from './manifest.json';
|
||||
|
||||
import { isLinux } from '/@/main/env';
|
||||
import { getMainWindow } from '/@/main/index';
|
||||
import { isLinux } from '/@/main/utils';
|
||||
import { QueueSong } from '/@/shared/types/domain-types';
|
||||
import { ClientEvent, ServerEvent } from '/@/shared/types/remote-types';
|
||||
import { PlayerRepeat, PlayerStatus, SongState } from '/@/shared/types/types';
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
import { getMpvInstance } from '../player';
|
||||
import { store } from '../settings';
|
||||
|
||||
import { PlayerType } from '/@/shared/types/types';
|
||||
|
||||
let isLocalVisualizerSurfaceVisible = false;
|
||||
|
||||
export const setLocalVisualizerSurfaceVisible = (visible: boolean) => {
|
||||
isLocalVisualizerSurfaceVisible = visible;
|
||||
};
|
||||
|
||||
export const canHandleVisualizerDisplayMedia = (): boolean => {
|
||||
const playbackType = store.get('playbackType', PlayerType.WEB) as PlayerType;
|
||||
|
||||
if (playbackType !== PlayerType.LOCAL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isLocalVisualizerSurfaceVisible) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(getMpvInstance()?.isRunning());
|
||||
};
|
||||
|
||||
ipcMain.on('visualizer-set-local-surface-visible', (_event, visible: boolean) => {
|
||||
setLocalVisualizerSurfaceVisible(Boolean(visible));
|
||||
});
|
||||
+48
-14
@@ -16,6 +16,7 @@ import {
|
||||
protocol,
|
||||
Rectangle,
|
||||
screen,
|
||||
session,
|
||||
shell,
|
||||
Tray,
|
||||
} from 'electron';
|
||||
@@ -30,18 +31,12 @@ import packageJson from '../../package.json';
|
||||
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
|
||||
import { shutdownServer } from './features/core/remote';
|
||||
import { store } from './features/core/settings';
|
||||
import { canHandleVisualizerDisplayMedia } from './features/core/visualizer';
|
||||
import MenuBuilder, { MenuPlaybackState } from './menu';
|
||||
import {
|
||||
autoUpdaterLogInterface,
|
||||
createLog,
|
||||
disableAutoUpdates,
|
||||
hotkeyToElectronAccelerator,
|
||||
isLinux,
|
||||
isMacOS,
|
||||
isWindows,
|
||||
} from './utils';
|
||||
import './features';
|
||||
import { autoUpdaterLogInterface, createLog, hotkeyToElectronAccelerator } from './utils';
|
||||
|
||||
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '/@/main/env';
|
||||
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
|
||||
|
||||
const ALPHA_UPDATER_CONFIG: {
|
||||
@@ -285,6 +280,16 @@ let currentRepeatMode: PlayerRepeat = PlayerRepeat.NONE;
|
||||
let currentSidebarCollapsed = false;
|
||||
let currentShuffleEnabled = false;
|
||||
let playbackMenuAccelerators: MenuPlaybackState['accelerators'] = {};
|
||||
let inputFocused = false;
|
||||
|
||||
ipcMain.on('input-focus-state', (_event, focused: boolean) => {
|
||||
const next = !!focused;
|
||||
if (inputFocused === next) return;
|
||||
inputFocused = next;
|
||||
if (isMacOS()) {
|
||||
rebuildMainMenu();
|
||||
}
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
import('source-map-support').then((sourceMapSupport) => {
|
||||
@@ -345,7 +350,7 @@ const rebuildMainMenu = () => {
|
||||
if (!menuBuilder || !mainWindow) return;
|
||||
|
||||
menuBuilder.buildMenu({
|
||||
accelerators: playbackMenuAccelerators,
|
||||
accelerators: inputFocused ? {} : playbackMenuAccelerators,
|
||||
playbackStatus: currentPlaybackStatus,
|
||||
privateMode: currentPrivateMode,
|
||||
repeatMode: currentRepeatMode,
|
||||
@@ -476,6 +481,15 @@ const createTray = () => {
|
||||
tray.setContextMenu(contextMenu);
|
||||
};
|
||||
|
||||
const validateUrl = (url: string): boolean => {
|
||||
// Minor security, really. Enforce only loading websites (http/https). file://
|
||||
// URLs and the like should've already been blocked, but this is another check.
|
||||
// Note that arbitrary web URLs are still allowed under this scheme, although
|
||||
// that should really only be hit by Subsonic share url (or if artist homepage
|
||||
// is allowed for ND extensions)
|
||||
return url.startsWith('http://') || url.startsWith('https://');
|
||||
};
|
||||
|
||||
async function createWindow(first = true): Promise<void> {
|
||||
if (isDevelopment) {
|
||||
await installExtensions().catch(console.log);
|
||||
@@ -515,9 +529,9 @@ async function createWindow(first = true): Promise<void> {
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: true,
|
||||
devTools: true,
|
||||
nodeIntegration: true,
|
||||
nodeIntegration: false,
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
sandbox: true,
|
||||
webSecurity: !store.get('ignore_cors'),
|
||||
},
|
||||
width: 1440,
|
||||
@@ -729,11 +743,18 @@ async function createWindow(first = true): Promise<void> {
|
||||
|
||||
// Open URLs in the user's browser
|
||||
mainWindow.webContents.setWindowOpenHandler((edata) => {
|
||||
shell.openExternal(edata.url);
|
||||
if (validateUrl(edata.url)) {
|
||||
shell.openExternal(edata.url);
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
|
||||
if (!canHandleVisualizerDisplayMedia()) {
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMacOS()) {
|
||||
callback({ audio: 'loopback' });
|
||||
return;
|
||||
@@ -764,7 +785,9 @@ async function createWindow(first = true): Promise<void> {
|
||||
nativeTheme.themeSource = theme || 'dark';
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url);
|
||||
if (validateUrl(details.url)) {
|
||||
shell.openExternal(details.url);
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
@@ -1011,6 +1034,17 @@ if (!singleInstance) {
|
||||
return response;
|
||||
});
|
||||
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
'Content-Security-Policy': [
|
||||
"script-src 'self' 'unsafe-inline' https://umami.jeffvli.org; style-src 'self' 'unsafe-inline'; media-src 'self' http: https: data: blob:; img-src 'self' http: https: data: blob:; connect-src 'self' http: https: ws: wss:; default-src 'self';",
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
createWindow();
|
||||
if (store.get('window_enable_tray', true)) {
|
||||
createTray();
|
||||
|
||||
@@ -18,22 +18,6 @@ if (process.env.NODE_ENV === 'development') {
|
||||
};
|
||||
}
|
||||
|
||||
export const disableAutoUpdates = () => {
|
||||
return process.env['DISABLE_AUTO_UPDATES'];
|
||||
};
|
||||
|
||||
export const isMacOS = () => {
|
||||
return process.platform === 'darwin';
|
||||
};
|
||||
|
||||
export const isWindows = () => {
|
||||
return process.platform === 'win32';
|
||||
};
|
||||
|
||||
export const isLinux = () => {
|
||||
return process.platform === 'linux';
|
||||
};
|
||||
|
||||
export const hotkeyToElectronAccelerator = (hotkey: string) => {
|
||||
let accelerator = hotkey;
|
||||
|
||||
|
||||
Vendored
-3
@@ -1,11 +1,8 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload';
|
||||
|
||||
import { PreloadApi } from './index';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: PreloadApi;
|
||||
electron: ElectronAPI;
|
||||
LEGACY_AUTHENTICATION?: boolean;
|
||||
queryLocalFonts?: () => Promise<Font[]>;
|
||||
REMOTE_URL?: string;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { electronAPI } from '@electron-toolkit/preload';
|
||||
import { contextBridge } from 'electron';
|
||||
import { contextBridge, webUtils } from 'electron';
|
||||
|
||||
import { autodiscover } from './autodiscover';
|
||||
import { browser } from './browser';
|
||||
@@ -11,12 +10,14 @@ import { mpris } from './mpris';
|
||||
import { mpvPlayer, mpvPlayerListener } from './mpv-player';
|
||||
import { remote } from './remote';
|
||||
import { utils } from './utils';
|
||||
import { visualizer } from './visualizer';
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
autodiscover,
|
||||
browser,
|
||||
discordRpc,
|
||||
getPathForFile: webUtils.getPathForFile,
|
||||
ipc,
|
||||
localSettings,
|
||||
lyrics,
|
||||
@@ -25,6 +26,7 @@ const api = {
|
||||
mpvPlayerListener,
|
||||
remote,
|
||||
utils,
|
||||
visualizer,
|
||||
};
|
||||
|
||||
export type PreloadApi = typeof api;
|
||||
@@ -34,14 +36,11 @@ export type PreloadApi = typeof api;
|
||||
// just add to the DOM global.
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI);
|
||||
contextBridge.exposeInMainWorld('api', api);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore (define in dts)
|
||||
window.electron = electronAPI;
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api;
|
||||
}
|
||||
|
||||
@@ -8,21 +8,11 @@ const send = (channel: string, ...args: any[]) => {
|
||||
ipcRenderer.send(channel, ...args);
|
||||
};
|
||||
|
||||
const invoke = (channel: string, ...args: any[]) => {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
};
|
||||
|
||||
const on = (channel: string, listener: (event: any, ...args: any[]) => void) => {
|
||||
ipcRenderer.on(channel, listener);
|
||||
};
|
||||
|
||||
const removeListener = (channel: string, listener: (event: any, ...args: any[]) => void) => {
|
||||
ipcRenderer.removeListener(channel, listener);
|
||||
};
|
||||
|
||||
export const ipc = {
|
||||
invoke,
|
||||
on,
|
||||
removeAllListeners,
|
||||
removeListener,
|
||||
send,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ipcRenderer, IpcRendererEvent, OpenDialogOptions, webFrame } from 'electron';
|
||||
import { ipcRenderer, OpenDialogOptions, webFrame } from 'electron';
|
||||
|
||||
import { TitleTheme } from '/@/shared/types/types';
|
||||
|
||||
@@ -41,8 +41,8 @@ const setZoomFactor = (zoomFactor: number) => {
|
||||
webFrame.setZoomFactor(zoomFactor / 100);
|
||||
};
|
||||
|
||||
const fontError = (cb: (event: IpcRendererEvent, file: string) => void) => {
|
||||
ipcRenderer.on('custom-font-error', cb);
|
||||
const fontError = (cb: (file: string) => void) => {
|
||||
ipcRenderer.on('custom-font-error', (_, file) => cb(file));
|
||||
};
|
||||
|
||||
const themeSet = (theme: TitleTheme): void => {
|
||||
|
||||
+11
-15
@@ -1,4 +1,4 @@
|
||||
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
import { QueueSong } from '/@/shared/types/domain-types';
|
||||
import { PlayerRepeat, PlayerStatus } from '/@/shared/types/types';
|
||||
@@ -31,28 +31,24 @@ const updateSong = (song: QueueSong | undefined, imageUrl?: null | string) => {
|
||||
ipcRenderer.send('update-song', song, imageUrl);
|
||||
};
|
||||
|
||||
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
|
||||
ipcRenderer.on('request-seek', cb);
|
||||
const requestSeek = (cb: (data: { offset: number }) => void) => {
|
||||
ipcRenderer.on('request-seek', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
|
||||
ipcRenderer.on('request-position', cb);
|
||||
const requestPosition = (cb: (data: { position: number }) => void) => {
|
||||
ipcRenderer.on('request-position', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const requestToggleRepeat = (
|
||||
cb: (event: IpcRendererEvent, data: { repeat: PlayerRepeat }) => void,
|
||||
) => {
|
||||
ipcRenderer.on('mpris-request-toggle-repeat', cb);
|
||||
const requestToggleRepeat = (cb: (data: { repeat: PlayerRepeat }) => void) => {
|
||||
ipcRenderer.on('mpris-request-toggle-repeat', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const requestToggleShuffle = (
|
||||
cb: (event: IpcRendererEvent, data: { shuffle: boolean }) => void,
|
||||
) => {
|
||||
ipcRenderer.on('mpris-request-toggle-shuffle', cb);
|
||||
const requestToggleShuffle = (cb: (data: { shuffle: boolean }) => void) => {
|
||||
ipcRenderer.on('mpris-request-toggle-shuffle', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
|
||||
ipcRenderer.on('request-volume', cb);
|
||||
const requestVolume = (cb: (data: { volume: number }) => void) => {
|
||||
ipcRenderer.on('request-volume', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
export const mpris = {
|
||||
|
||||
+37
-37
@@ -1,4 +1,4 @@
|
||||
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
import { PlayerData } from '/@/shared/types/domain-types';
|
||||
|
||||
@@ -102,76 +102,76 @@ const getAudioDevices = async () => {
|
||||
return ipcRenderer.invoke('player-get-audio-devices');
|
||||
};
|
||||
|
||||
const rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-auto-next', cb);
|
||||
const rendererAutoNext = (cb: (data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-auto-next', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const rendererCurrentTime = (cb: (event: IpcRendererEvent, data: number) => void) => {
|
||||
ipcRenderer.on('renderer-player-current-time', cb);
|
||||
const rendererCurrentTime = (cb: (data: number) => void) => {
|
||||
ipcRenderer.on('renderer-player-current-time', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const rendererNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-next', cb);
|
||||
const rendererNext = (cb: (data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-next', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const rendererPause = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-pause', cb);
|
||||
const rendererPause = (cb: (data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-pause', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const rendererPlay = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-play', cb);
|
||||
const rendererPlay = (cb: (data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-play', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const rendererPlayPause = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-play-pause', cb);
|
||||
const rendererPlayPause = (cb: (data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-play-pause', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const rendererPrevious = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-previous', cb);
|
||||
const rendererPrevious = (cb: (data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-previous', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const rendererStop = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-stop', cb);
|
||||
const rendererStop = (cb: (data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-stop', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const rendererSkipForward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-skip-forward', cb);
|
||||
const rendererSkipForward = (cb: (data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-skip-forward', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const rendererSkipBackward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-skip-backward', cb);
|
||||
const rendererSkipBackward = (cb: (data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-skip-backward', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const rendererVolumeUp = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-volume-up', cb);
|
||||
const rendererVolumeUp = (cb: (data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-volume-up', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const rendererVolumeDown = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-volume-down', cb);
|
||||
const rendererVolumeDown = (cb: (data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-volume-down', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const rendererVolumeMute = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-volume-mute', cb);
|
||||
const rendererVolumeMute = (cb: (data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-volume-mute', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const rendererToggleRepeat = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-toggle-repeat', cb);
|
||||
const rendererToggleRepeat = (cb: (data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-toggle-repeat', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const rendererToggleShuffle = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-toggle-shuffle', cb);
|
||||
const rendererToggleShuffle = (cb: (data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-toggle-shuffle', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const rendererQuit = (cb: (event: IpcRendererEvent) => void) => {
|
||||
ipcRenderer.on('renderer-player-quit', cb);
|
||||
const rendererQuit = (cb: () => void) => {
|
||||
ipcRenderer.on('renderer-player-quit', () => cb());
|
||||
};
|
||||
|
||||
const rendererError = (cb: (event: IpcRendererEvent, data: string) => void) => {
|
||||
ipcRenderer.on('renderer-player-error', cb);
|
||||
const rendererError = (cb: (data: string) => void) => {
|
||||
ipcRenderer.on('renderer-player-error', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const rendererPlayerFallback = (cb: (event: IpcRendererEvent, data: boolean) => void) => {
|
||||
ipcRenderer.on('renderer-player-fallback', cb);
|
||||
const rendererPlayerFallback = (cb: (data: boolean) => void) => {
|
||||
ipcRenderer.on('renderer-player-fallback', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
export const mpvPlayer = {
|
||||
|
||||
+11
-16
@@ -1,33 +1,28 @@
|
||||
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
import { QueueSong } from '/@/shared/types/domain-types';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
const requestFavorite = (
|
||||
cb: (
|
||||
event: IpcRendererEvent,
|
||||
data: { favorite: boolean; id: string; serverId: string },
|
||||
) => void,
|
||||
cb: (data: { favorite: boolean; id: string; serverId: string }) => void,
|
||||
) => {
|
||||
ipcRenderer.on('request-favorite', cb);
|
||||
ipcRenderer.on('request-favorite', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
|
||||
ipcRenderer.on('request-position', cb);
|
||||
const requestPosition = (cb: (data: { position: number }) => void) => {
|
||||
ipcRenderer.on('request-position', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const requestRating = (
|
||||
cb: (event: IpcRendererEvent, data: { id: string; rating: number; serverId: string }) => void,
|
||||
) => {
|
||||
ipcRenderer.on('request-rating', cb);
|
||||
const requestRating = (cb: (data: { id: string; rating: number; serverId: string }) => void) => {
|
||||
ipcRenderer.on('request-rating', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
|
||||
ipcRenderer.on('request-seek', cb);
|
||||
const requestSeek = (cb: (data: { offset: number }) => void) => {
|
||||
ipcRenderer.on('request-seek', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
|
||||
ipcRenderer.on('request-volume', cb);
|
||||
const requestVolume = (cb: (data: { volume: number }) => void) => {
|
||||
ipcRenderer.on('request-volume', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const setRemoteEnabled = (enabled: boolean): Promise<null | string> => {
|
||||
|
||||
+37
-33
@@ -1,6 +1,6 @@
|
||||
import { ipcRenderer, IpcRendererEvent, webFrame } from 'electron';
|
||||
import { ipcRenderer, webFrame } from 'electron';
|
||||
|
||||
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '../main/utils';
|
||||
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '../main/env';
|
||||
|
||||
const openItem = async (path: string) => {
|
||||
return ipcRenderer.invoke('open-item', path);
|
||||
@@ -10,29 +10,14 @@ const openApplicationDirectory = async () => {
|
||||
return ipcRenderer.invoke('open-application-directory');
|
||||
};
|
||||
|
||||
const playerErrorListener = (cb: (event: IpcRendererEvent, data: { code: number }) => void) => {
|
||||
ipcRenderer.on('player-error-listener', cb);
|
||||
const playerErrorListener = (cb: (data: { code: number }) => void) => {
|
||||
ipcRenderer.on('player-error-listener', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const mainMessageListener = (
|
||||
cb: (
|
||||
event: IpcRendererEvent,
|
||||
data: { message: string; type: 'error' | 'info' | 'success' | 'warning' },
|
||||
) => void,
|
||||
cb: (data: { message: string; type: 'error' | 'info' | 'success' | 'warning' }) => void,
|
||||
) => {
|
||||
ipcRenderer.on('toast-from-main', cb);
|
||||
};
|
||||
|
||||
const logger = (
|
||||
cb: (
|
||||
event: IpcRendererEvent,
|
||||
data: {
|
||||
message: string;
|
||||
type: 'debug' | 'error' | 'info' | 'verbose' | 'warning';
|
||||
},
|
||||
) => void,
|
||||
) => {
|
||||
ipcRenderer.send('logger', cb);
|
||||
ipcRenderer.on('toast-from-main', (_, data) => cb(data));
|
||||
};
|
||||
|
||||
const download = (url: string) => {
|
||||
@@ -43,6 +28,14 @@ const checkForUpdates = (): Promise<{ updateAvailable: boolean; version?: string
|
||||
return ipcRenderer.invoke('app-check-for-updates');
|
||||
};
|
||||
|
||||
const startPowerSaveBlocker = (full: boolean) => {
|
||||
return ipcRenderer.invoke('power-save-blocker-start', { full });
|
||||
};
|
||||
|
||||
const stopPowerSaveBlocker = () => {
|
||||
return ipcRenderer.invoke('power-save-blocker-stop');
|
||||
};
|
||||
|
||||
const forceGarbageCollection = (): boolean => {
|
||||
try {
|
||||
if (typeof global.gc === 'function') {
|
||||
@@ -61,28 +54,36 @@ const forceGarbageCollection = (): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
const rendererOpenSettings = (cb: (event: IpcRendererEvent) => void) => {
|
||||
ipcRenderer.on('renderer-open-settings', cb);
|
||||
const setInputFocused = (focused: boolean) => {
|
||||
ipcRenderer.send('input-focus-state', focused);
|
||||
};
|
||||
|
||||
const rendererOpenCommandPalette = (cb: (event: IpcRendererEvent) => void) => {
|
||||
ipcRenderer.on('renderer-open-command-palette', cb);
|
||||
const rendererOpenSettings = (cb: () => void) => {
|
||||
ipcRenderer.on('renderer-open-settings', () => cb());
|
||||
};
|
||||
|
||||
const rendererOpenManageServers = (cb: (event: IpcRendererEvent) => void) => {
|
||||
ipcRenderer.on('renderer-open-manage-servers', cb);
|
||||
const rendererOpenCommandPalette = (cb: () => void) => {
|
||||
ipcRenderer.on('renderer-open-command-palette', () => cb());
|
||||
};
|
||||
|
||||
const rendererTogglePrivateMode = (cb: (event: IpcRendererEvent) => void) => {
|
||||
const rendererOpenManageServers = (cb: () => void) => {
|
||||
ipcRenderer.on('renderer-open-manage-servers', () => cb());
|
||||
};
|
||||
|
||||
const rendererTogglePrivateMode = (cb: () => void) => {
|
||||
ipcRenderer.on('renderer-toggle-private-mode', cb);
|
||||
};
|
||||
|
||||
const rendererToggleSidebar = (cb: (event: IpcRendererEvent) => void) => {
|
||||
ipcRenderer.on('renderer-toggle-sidebar', cb);
|
||||
const rendererToggleSidebar = (cb: () => void) => {
|
||||
ipcRenderer.on('renderer-toggle-sidebar', () => cb());
|
||||
};
|
||||
|
||||
const rendererOpenReleaseNotes = (cb: (event: IpcRendererEvent) => void) => {
|
||||
ipcRenderer.on('renderer-open-release-notes', cb);
|
||||
const rendererOpenReleaseNotes = (cb: () => void) => {
|
||||
ipcRenderer.on('renderer-open-release-notes', () => cb());
|
||||
};
|
||||
|
||||
const rendererUpdateAvailable = (cb: (version: string) => void) => {
|
||||
ipcRenderer.on('update-available', (_, version) => cb(version));
|
||||
};
|
||||
|
||||
export const utils = {
|
||||
@@ -93,7 +94,6 @@ export const utils = {
|
||||
isLinux,
|
||||
isMacOS,
|
||||
isWindows,
|
||||
logger,
|
||||
mainMessageListener,
|
||||
openApplicationDirectory,
|
||||
openItem,
|
||||
@@ -104,6 +104,10 @@ export const utils = {
|
||||
rendererOpenSettings,
|
||||
rendererTogglePrivateMode,
|
||||
rendererToggleSidebar,
|
||||
rendererUpdateAvailable,
|
||||
setInputFocused,
|
||||
startPowerSaveBlocker,
|
||||
stopPowerSaveBlocker,
|
||||
};
|
||||
|
||||
export type Utils = typeof utils;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
const setLocalSurfaceVisible = (visible: boolean) => {
|
||||
ipcRenderer.send('visualizer-set-local-surface-visible', visible);
|
||||
};
|
||||
|
||||
export const visualizer = {
|
||||
setLocalSurfaceVisible,
|
||||
};
|
||||
|
||||
export type VisualizerApi = typeof visualizer;
|
||||
@@ -1062,7 +1062,9 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to get server info');
|
||||
}
|
||||
|
||||
const defaultFeatures = {};
|
||||
const defaultFeatures = {
|
||||
[ServerFeature.REPORT_PLAYBACK]: [1],
|
||||
};
|
||||
|
||||
const features = {
|
||||
...defaultFeatures,
|
||||
|
||||
@@ -289,6 +289,14 @@ export const contract = c.router({
|
||||
200: ssType._response.removeFavorite,
|
||||
},
|
||||
},
|
||||
reportPlayback: {
|
||||
method: 'GET',
|
||||
path: 'reportPlayback.view',
|
||||
query: ssType._parameters.reportPlayback,
|
||||
responses: {
|
||||
200: ssType._response.reportPlayback,
|
||||
},
|
||||
},
|
||||
savePlayQueue: {
|
||||
method: 'GET',
|
||||
path: 'savePlayQueue.view',
|
||||
|
||||
@@ -1460,6 +1460,10 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
features.serverPlayQueue = [1];
|
||||
}
|
||||
|
||||
if (subsonicFeatures[SubsonicExtensions.PLAYBACK_REPORT]) {
|
||||
features.reportPlayback = [1];
|
||||
}
|
||||
|
||||
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
|
||||
},
|
||||
getSimilarSongs: async (args) => {
|
||||
@@ -2298,6 +2302,57 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
scrobble: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (hasFeature(apiClientProps.server, ServerFeature.REPORT_PLAYBACK)) {
|
||||
if (query.submission) {
|
||||
const res = await ssApiClient(apiClientProps).scrobble({
|
||||
query: {
|
||||
id: query.id,
|
||||
submission: query.submission,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to scrobble');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
let state: 'paused' | 'playing' | 'starting' | 'stopped' = 'playing';
|
||||
|
||||
switch (query.event) {
|
||||
case 'pause':
|
||||
state = 'paused';
|
||||
break;
|
||||
case 'start':
|
||||
state = 'starting';
|
||||
break;
|
||||
case 'timeupdate':
|
||||
case 'unpause':
|
||||
state = 'playing';
|
||||
break;
|
||||
default:
|
||||
state = 'playing';
|
||||
}
|
||||
|
||||
const res = await ssApiClient(apiClientProps).reportPlayback({
|
||||
query: {
|
||||
ignoreScrobble: true,
|
||||
mediaId: query.id,
|
||||
mediaType: query.mediaType,
|
||||
playbackRate: query.playbackRate,
|
||||
positionMs: query.position ?? 0,
|
||||
state,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to report playback');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await ssApiClient(apiClientProps).scrobble({
|
||||
query: {
|
||||
id: query.id,
|
||||
|
||||
@@ -93,6 +93,7 @@ const AppEffects = () => (
|
||||
<GlobalShortcutsEffect />
|
||||
<LanguageEffect />
|
||||
<NativeMenuSyncEffect />
|
||||
<InputFocusEffect />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -170,3 +171,42 @@ const NativeMenuSyncEffect = () => {
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const InputFocusEffect = () => {
|
||||
useEffect(() => {
|
||||
if (!isElectron()) return;
|
||||
|
||||
const handleFocusIn = (e: FocusEvent) => {
|
||||
const target = e.target as Element | null;
|
||||
if (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
(target instanceof HTMLElement && target.isContentEditable)
|
||||
) {
|
||||
window.api?.utils?.setInputFocused?.(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocusOut = (e: FocusEvent) => {
|
||||
const related = e.relatedTarget as Element | null;
|
||||
if (
|
||||
related instanceof HTMLInputElement ||
|
||||
related instanceof HTMLTextAreaElement ||
|
||||
(related instanceof HTMLElement && related.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
window.api?.utils?.setInputFocused?.(false);
|
||||
};
|
||||
|
||||
document.addEventListener('focusin', handleFocusIn);
|
||||
document.addEventListener('focusout', handleFocusOut);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('focusin', handleFocusIn);
|
||||
document.removeEventListener('focusout', handleFocusOut);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import { PlayCountColumn } from './play-count-column';
|
||||
import { RatingColumn } from './rating-column';
|
||||
import { ReleaseDateColumn } from './release-date-column';
|
||||
import { RowIndexColumn } from './row-index-column';
|
||||
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
|
||||
import { SampleRateColumn } from './sample-rate-column';
|
||||
import { SizeColumn } from './size-column';
|
||||
import { TitleArtistColumn } from './title-artist-column';
|
||||
@@ -111,6 +112,7 @@ export {
|
||||
GenreBadgeColumn,
|
||||
GenreColumn,
|
||||
ImageColumn,
|
||||
ItemDetailRowPlayControlCell,
|
||||
LastPlayedColumn,
|
||||
PathColumn,
|
||||
PlayCountColumn,
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
import styles from './row-index-column.module.css';
|
||||
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
import { useDetailRowPlayControl } from './use-detail-row-play-control';
|
||||
|
||||
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
||||
import { usePlayerStatus } from '/@/renderer/store';
|
||||
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
export const RowIndexColumn = ({ rowIndex, song }: ItemDetailListCellProps) => {
|
||||
const status = usePlayerStatus();
|
||||
const { isActive } = useIsCurrentSong(song);
|
||||
const isPlaying = isActive && status === PlayerStatus.PLAYING;
|
||||
|
||||
if (isActive) {
|
||||
return (
|
||||
<div className={styles.iconWrapper}>
|
||||
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { TableColumn } from '/@/shared/types/types';
|
||||
|
||||
const DefaultRowIndexColumn = ({ rowIndex }: ItemDetailListCellProps) => {
|
||||
return <>{String((rowIndex ?? 0) + 1)}</>;
|
||||
};
|
||||
|
||||
const PlayableRowIndexColumn = (props: ItemDetailListCellProps) => {
|
||||
const { handlePlay, isActive, isPlaying, showPlayControls } = useDetailRowPlayControl(props);
|
||||
|
||||
const indexContent = isActive ? (
|
||||
<div className={styles.iconWrapper}>
|
||||
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
|
||||
</div>
|
||||
) : (
|
||||
String((props.rowIndex ?? 0) + 1)
|
||||
);
|
||||
|
||||
return (
|
||||
<ItemDetailRowPlayControlCell
|
||||
indexContent={indexContent}
|
||||
onPlay={handlePlay}
|
||||
showPlayControls={showPlayControls}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const RowIndexColumn = (props: ItemDetailListCellProps) => {
|
||||
if (!props.columns || !isRowPlayControlColumn(TableColumn.ROW_INDEX, props.columns)) {
|
||||
return <DefaultRowIndexColumn {...props} />;
|
||||
}
|
||||
|
||||
return <PlayableRowIndexColumn {...props} />;
|
||||
};
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
.cell-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.play-target {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.index-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import styles from './row-play-control-cell.module.css';
|
||||
|
||||
import { ItemRowPlayControls } from '/@/renderer/features/shared/components/item-row-play-controls';
|
||||
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
export const ItemDetailRowPlayControlCell = ({
|
||||
indexContent,
|
||||
onPlay,
|
||||
showPlayControls,
|
||||
}: {
|
||||
indexContent: ReactNode;
|
||||
onPlay: (playType: Play) => void;
|
||||
showPlayControls: boolean;
|
||||
}) => {
|
||||
if (!showPlayControls) {
|
||||
return <>{indexContent}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.cellWrapper}>
|
||||
<HoverCard
|
||||
offset={{ crossAxis: 32, mainAxis: 16 }}
|
||||
openDelay={300}
|
||||
position="top"
|
||||
withArrow
|
||||
>
|
||||
<HoverCard.Target>
|
||||
<div className={styles.playTarget}>{indexContent}</div>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
|
||||
<ItemRowPlayControls onPlay={onPlay} />
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,46 @@
|
||||
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
|
||||
import styles from './row-play-control-cell.module.css';
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
import { useDetailRowPlayControl } from './use-detail-row-play-control';
|
||||
|
||||
export const TrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
|
||||
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { TableColumn } from '/@/shared/types/types';
|
||||
|
||||
const formatTrackNumber = (song: ItemDetailListCellProps['song']) => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
const track = song.trackNumber.toString().padStart(2, '0');
|
||||
return `${disc}-${track}`;
|
||||
};
|
||||
|
||||
const DefaultTrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
|
||||
return <>{formatTrackNumber(song)}</>;
|
||||
};
|
||||
|
||||
const PlayableTrackNumberColumn = (props: ItemDetailListCellProps) => {
|
||||
const { handlePlay, isActive, isPlaying, showPlayControls } = useDetailRowPlayControl(props);
|
||||
|
||||
const indexContent = isActive ? (
|
||||
<div className={styles.iconWrapper}>
|
||||
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
|
||||
</div>
|
||||
) : (
|
||||
formatTrackNumber(props.song)
|
||||
);
|
||||
|
||||
return (
|
||||
<ItemDetailRowPlayControlCell
|
||||
indexContent={indexContent}
|
||||
onPlay={handlePlay}
|
||||
showPlayControls={showPlayControls}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TrackNumberColumn = (props: ItemDetailListCellProps) => {
|
||||
if (!props.columns || !isRowPlayControlColumn(TableColumn.TRACK_NUMBER, props.columns)) {
|
||||
return <DefaultTrackNumberColumn {...props} />;
|
||||
}
|
||||
|
||||
return <PlayableTrackNumberColumn {...props} />;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||
import { ItemControls } from '/@/renderer/components/item-list/types';
|
||||
import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
export interface ItemDetailListCellProps {
|
||||
columns?: ItemTableListColumnConfig[];
|
||||
controls?: ItemControls;
|
||||
internalState?: ItemListStateActions;
|
||||
isMutatingFavorite?: boolean;
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
||||
import { usePlayerStatus } from '/@/renderer/store';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
import { Play, PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
export const useDetailRowPlayControl = ({
|
||||
internalState,
|
||||
rowIndex = 0,
|
||||
song,
|
||||
}: Pick<ItemDetailListCellProps, 'internalState' | 'rowIndex' | 'song'>) => {
|
||||
const status = usePlayerStatus();
|
||||
const player = usePlayer();
|
||||
const { isActive } = useIsCurrentSong(song);
|
||||
const isPlaying = isActive && status === PlayerStatus.PLAYING;
|
||||
|
||||
const showPlayControls = !!song?.id;
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(playType: Play) => {
|
||||
if (!song) {
|
||||
return;
|
||||
}
|
||||
|
||||
playSongFromItemListControl({
|
||||
index: rowIndex,
|
||||
internalState,
|
||||
item: song as Song,
|
||||
meta: { playType, singleSongOnly: true },
|
||||
player,
|
||||
});
|
||||
},
|
||||
[internalState, player, rowIndex, song],
|
||||
);
|
||||
|
||||
return {
|
||||
handlePlay,
|
||||
isActive,
|
||||
isPlaying,
|
||||
showPlayControls,
|
||||
};
|
||||
};
|
||||
@@ -447,6 +447,14 @@
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.row .track-cell-play-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.track-row-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import styles from './item-detail-list.module.css';
|
||||
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
|
||||
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
||||
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
|
||||
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
|
||||
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
|
||||
import {
|
||||
ItemListStateActions,
|
||||
@@ -365,6 +366,7 @@ const TrackRow = memo(
|
||||
const isTitleColumn = col.id === TableColumn.TITLE;
|
||||
const isImageColumn = col.id === TableColumn.IMAGE;
|
||||
const isIconActionColumn = isNoHorizontalPaddingColumn(col.id);
|
||||
const isPlayControlColumn = isRowPlayControlColumn(col.id, columns);
|
||||
const showHoverContent = shouldShowHoverOnlyColumnContent(
|
||||
col.id,
|
||||
isRowHovered,
|
||||
@@ -374,6 +376,7 @@ const TrackRow = memo(
|
||||
const content = isSongsLoading ? null : showHoverContent ? (
|
||||
<CellComponent
|
||||
columnId={col.id}
|
||||
columns={columns}
|
||||
controls={controls}
|
||||
internalState={internalState}
|
||||
isMutatingFavorite={isMutatingFavorite}
|
||||
@@ -393,6 +396,7 @@ const TrackRow = memo(
|
||||
[styles.trackCellImage]: isImageColumn,
|
||||
[styles.trackCellMuted]: !isTitleColumn,
|
||||
[styles.trackCellNoHPadding]: isIconActionColumn,
|
||||
[styles.trackCellPlayControl]: isPlayControlColumn,
|
||||
[styles.trackCellVerticalBorderVisible]:
|
||||
enableVerticalBorders && !isLastColumn,
|
||||
[styles.trackCellWithVerticalBorder]: !isLastColumn,
|
||||
|
||||
@@ -88,7 +88,7 @@ export const RowPlayControlCell = (
|
||||
<TableColumnContainer {...props} className={styles.expansionCell}>
|
||||
<div className={styles.expansionInner}>
|
||||
{showPlayControls ? (
|
||||
<HoverCard openDelay={300} position="top" withArrow>
|
||||
<HoverCard openDelay={300} position="top" withArrow withinPortal={false}>
|
||||
<HoverCard.Target>{expansionTarget}</HoverCard.Target>
|
||||
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
|
||||
<ItemRowPlayControls onPlay={onPlay} />
|
||||
@@ -110,7 +110,7 @@ export const RowPlayControlCell = (
|
||||
|
||||
return (
|
||||
<TableColumnTextContainer {...props} className={styles.fullSizeContent}>
|
||||
<HoverCard openDelay={300} position="top" withArrow>
|
||||
<HoverCard openDelay={300} position="top" withArrow withinPortal={false}>
|
||||
<HoverCard.Target>
|
||||
<Flex className={styles.indexContent} justify="center" w="100%">
|
||||
{getIndexDisplay(false)}
|
||||
|
||||
@@ -12,10 +12,15 @@ import { Play } from '/@/shared/types/types';
|
||||
|
||||
interface PlayTrackRadioActionProps {
|
||||
disabled?: boolean;
|
||||
skipFirstSong?: boolean;
|
||||
song: Song;
|
||||
}
|
||||
|
||||
export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionProps) => {
|
||||
export const PlayTrackRadioAction = ({
|
||||
disabled,
|
||||
skipFirstSong,
|
||||
song,
|
||||
}: PlayTrackRadioActionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const serverId = useCurrentServerId();
|
||||
@@ -38,13 +43,17 @@ export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionPro
|
||||
});
|
||||
|
||||
if (similarSongs && similarSongs.length > 0) {
|
||||
player.addToQueueByData([song, ...similarSongs], playType);
|
||||
// We need to skip the first song when adding to the queue as NEXT or LAST, otherwise you will have a duplicate song
|
||||
const shouldSkipFirstSong =
|
||||
skipFirstSong && (playType === Play.NEXT || playType === Play.LAST);
|
||||
const queueSongs = shouldSkipFirstSong ? similarSongs : [song, ...similarSongs];
|
||||
player.addToQueueByData(queueSongs, playType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load track radio:', error);
|
||||
}
|
||||
},
|
||||
[player, queryClient, serverId, song],
|
||||
[player, queryClient, serverId, skipFirstSong, song],
|
||||
);
|
||||
|
||||
const handlePlayTrackRadioNow = useCallback(() => {
|
||||
|
||||
@@ -35,7 +35,7 @@ export const QueueContextMenu = ({ items }: QueueContextMenuProps) => {
|
||||
<MoveQueueItemsAction items={items} />
|
||||
<ShuffleItemsAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<PlayTrackRadioAction disabled={items.length > 1} song={items[0]} />
|
||||
<PlayTrackRadioAction disabled={items.length > 1} skipFirstSong song={items[0]} />
|
||||
<ContextMenu.Divider />
|
||||
<AddToPlaylistAction items={ids} itemType={LibraryItem.SONG} />
|
||||
<ContextMenu.Divider />
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface WebPlayerEngineHandle extends AudioPlayer {
|
||||
interface WebPlayerEngineProps {
|
||||
isMuted: boolean;
|
||||
isTransitioning: boolean;
|
||||
loopPlayer1: boolean;
|
||||
loopPlayer2: boolean;
|
||||
onEndedPlayer1: () => void;
|
||||
onEndedPlayer2: () => void;
|
||||
onErrorPause: () => void;
|
||||
@@ -55,6 +57,8 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
||||
const {
|
||||
isMuted,
|
||||
isTransitioning,
|
||||
loopPlayer1,
|
||||
loopPlayer2,
|
||||
onEndedPlayer1,
|
||||
onEndedPlayer2,
|
||||
onErrorPause,
|
||||
@@ -292,8 +296,9 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
||||
controls={false}
|
||||
height={0}
|
||||
id="web-player-1"
|
||||
loop={loopPlayer1}
|
||||
muted={isMuted}
|
||||
onEnded={src1 ? () => onEndedPlayer1() : undefined}
|
||||
onEnded={src1 && !loopPlayer1 ? () => onEndedPlayer1() : undefined}
|
||||
onError={handleOnError(
|
||||
player1Ref,
|
||||
() => onEndedPlayer1(),
|
||||
@@ -317,8 +322,9 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
||||
controls={false}
|
||||
height={0}
|
||||
id="web-player-2"
|
||||
loop={loopPlayer2}
|
||||
muted={isMuted}
|
||||
onEnded={src2 ? () => onEndedPlayer2() : undefined}
|
||||
onEnded={src2 && !loopPlayer2 ? () => onEndedPlayer2() : undefined}
|
||||
onError={handleOnError(
|
||||
player2Ref,
|
||||
() => onEndedPlayer2(),
|
||||
|
||||
@@ -112,7 +112,7 @@ export const useMainPlayerListener = () => {
|
||||
decreaseVolume(volumeWheelStep);
|
||||
});
|
||||
|
||||
mpvPlayerListener.rendererError((_event: any, message: string) => {
|
||||
mpvPlayerListener.rendererError((message: string) => {
|
||||
handleMpvError(message);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type ReactPlayer from 'react-player';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||
import {
|
||||
WebPlayerEngine,
|
||||
WebPlayerEngineHandle,
|
||||
@@ -20,12 +21,13 @@ import {
|
||||
usePlayerData,
|
||||
usePlayerMuted,
|
||||
usePlayerProperties,
|
||||
usePlayerRepeat,
|
||||
usePlayerStoreBase,
|
||||
usePlayerVolume,
|
||||
} from '/@/renderer/store';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { QueueSong } from '/@/shared/types/domain-types';
|
||||
import { CrossfadeStyle, PlayerStatus, PlayerStyle } from '/@/shared/types/types';
|
||||
import { CrossfadeStyle, PlayerRepeat, PlayerStatus, PlayerStyle } from '/@/shared/types/types';
|
||||
|
||||
const PLAY_PAUSE_FADE_DURATION = 300;
|
||||
const PLAY_PAUSE_FADE_INTERVAL = 10;
|
||||
@@ -34,6 +36,8 @@ export function WebPlayer() {
|
||||
const playerRef = useRef<null | WebPlayerEngineHandle>(null);
|
||||
const { t } = useTranslation();
|
||||
const { num, player1, player2, status } = usePlayerData();
|
||||
const repeat = usePlayerRepeat();
|
||||
const repeatOneProgressRef = useRef({ player1: 0, player2: 0 });
|
||||
const { mediaAutoNext, mediaPause, setTimestamp } = usePlayerActions();
|
||||
const playback = useMpvSettings();
|
||||
const { webAudio } = useWebAudio();
|
||||
@@ -97,12 +101,37 @@ export function WebPlayer() {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRepeatOne = useCallback(
|
||||
(playerId: 1 | 2, playedSeconds: number, duration: number) => {
|
||||
if (repeat !== PlayerRepeat.ONE || duration <= 0 || num !== playerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = playerId === 1 ? 'player1' : 'player2';
|
||||
const last = repeatOneProgressRef.current[key];
|
||||
repeatOneProgressRef.current[key] = playedSeconds;
|
||||
|
||||
if (last > duration * 0.85 && playedSeconds < duration * 0.15) {
|
||||
setTimestamp(0);
|
||||
eventEmitter.emit('PLAYER_REPEATED', {
|
||||
index: usePlayerStoreBase.getState().player.index,
|
||||
});
|
||||
}
|
||||
},
|
||||
[num, repeat, setTimestamp],
|
||||
);
|
||||
|
||||
const onProgressPlayer1 = useCallback(
|
||||
(e: PlayerOnProgressProps) => {
|
||||
if (!playerRef.current?.player1()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (repeat === PlayerRepeat.ONE) {
|
||||
handleRepeatOne(1, e.playedSeconds, getDuration(playerRef.current.player1().ref));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (transitionType) {
|
||||
case PlayerStyle.CROSSFADE:
|
||||
crossfadeHandler({
|
||||
@@ -132,7 +161,17 @@ export function WebPlayer() {
|
||||
break;
|
||||
}
|
||||
},
|
||||
[crossfadeDuration, crossfadeStyle, isTransitioning, num, player2, transitionType, volume],
|
||||
[
|
||||
crossfadeDuration,
|
||||
crossfadeStyle,
|
||||
handleRepeatOne,
|
||||
isTransitioning,
|
||||
num,
|
||||
player2,
|
||||
repeat,
|
||||
transitionType,
|
||||
volume,
|
||||
],
|
||||
);
|
||||
|
||||
const onProgressPlayer2 = useCallback(
|
||||
@@ -141,6 +180,11 @@ export function WebPlayer() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (repeat === PlayerRepeat.ONE) {
|
||||
handleRepeatOne(2, e.playedSeconds, getDuration(playerRef.current.player2().ref));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (transitionType) {
|
||||
case PlayerStyle.CROSSFADE:
|
||||
crossfadeHandler({
|
||||
@@ -170,7 +214,17 @@ export function WebPlayer() {
|
||||
break;
|
||||
}
|
||||
},
|
||||
[crossfadeDuration, crossfadeStyle, isTransitioning, num, player1, transitionType, volume],
|
||||
[
|
||||
crossfadeDuration,
|
||||
crossfadeStyle,
|
||||
handleRepeatOne,
|
||||
isTransitioning,
|
||||
num,
|
||||
player1,
|
||||
repeat,
|
||||
transitionType,
|
||||
volume,
|
||||
],
|
||||
);
|
||||
|
||||
const handleOnEndedPlayer1 = useCallback(() => {
|
||||
@@ -474,10 +528,15 @@ export function WebPlayer() {
|
||||
});
|
||||
}, [mediaPause, t]);
|
||||
|
||||
const loopPlayer1 = repeat === PlayerRepeat.ONE && num === 1;
|
||||
const loopPlayer2 = repeat === PlayerRepeat.ONE && num === 2;
|
||||
|
||||
return (
|
||||
<WebPlayerEngine
|
||||
isMuted={isMuted}
|
||||
isTransitioning={Boolean(isTransitioning)}
|
||||
loopPlayer1={loopPlayer1}
|
||||
loopPlayer2={loopPlayer2}
|
||||
onEndedPlayer1={handleOnEndedPlayer1}
|
||||
onEndedPlayer2={handleOnEndedPlayer2}
|
||||
onErrorPause={handleOnErrorPause}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { autoDjGenreIdsForSongGenre, autoDjPushUniqueAlbumIds } from './auto-dj-utils';
|
||||
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import { AUTO_DJ_STRATEGY, type AutoDJStrategy } from '/@/renderer/store/settings.store';
|
||||
import { shuffle } from '/@/renderer/utils/shuffle';
|
||||
import {
|
||||
AlbumListSort,
|
||||
type QueueSong,
|
||||
type ServerListItem,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
|
||||
export type AutoDjAlbumCollectArgs = {
|
||||
albumStrategy: AutoDJStrategy;
|
||||
currentSong: QueueSong;
|
||||
itemCount: number;
|
||||
musicFolderId: string | string[] | undefined;
|
||||
queryClient: QueryClient;
|
||||
queueAlbumIdSet: Set<string>;
|
||||
server: null | ServerListItem | undefined;
|
||||
serverId: string;
|
||||
trySimilarSongs: boolean;
|
||||
};
|
||||
|
||||
export const runAutoDjAlbumIds = async (args: AutoDjAlbumCollectArgs): Promise<string[]> => {
|
||||
switch (args.albumStrategy) {
|
||||
case AUTO_DJ_STRATEGY.LIBRARY_RANDOM: {
|
||||
return collectAlbumsLibraryRandom(args);
|
||||
}
|
||||
default: {
|
||||
return collectAlbumsSimilar(args);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const collectAlbumsLibraryRandom = async (args: AutoDjAlbumCollectArgs): Promise<string[]> => {
|
||||
const page = await args.queryClient.fetchQuery({
|
||||
...albumQueries.list({
|
||||
query: {
|
||||
limit: Math.max(args.itemCount, 1),
|
||||
musicFolderId: args.musicFolderId,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: args.serverId,
|
||||
}),
|
||||
queryKey: queryKeys.player.fetch({ autoDjAlbumLibraryRandom: args.currentSong?.id }),
|
||||
});
|
||||
|
||||
const ids = page.items.map((a) => a.id).filter((id) => id && !args.queueAlbumIdSet.has(id));
|
||||
return shuffle(ids).slice(0, args.itemCount);
|
||||
};
|
||||
|
||||
const collectAlbumsSimilar = async (args: AutoDjAlbumCollectArgs): Promise<string[]> => {
|
||||
const targetAlbumCount = args.itemCount;
|
||||
const candidateAlbumIds: string[] = [];
|
||||
const seenAlbumCandidates = new Set<string>();
|
||||
|
||||
if (args.trySimilarSongs && args.currentSong?.id) {
|
||||
const similarSongsFromSimilarApi = await args.queryClient.fetchQuery({
|
||||
...songsQueries.similar({
|
||||
query: {
|
||||
count: args.itemCount * 4,
|
||||
songId: args.currentSong.id,
|
||||
},
|
||||
serverId: args.serverId,
|
||||
}),
|
||||
queryKey: queryKeys.player.fetch({
|
||||
similarSongAlbumDj: args.currentSong.id,
|
||||
}),
|
||||
});
|
||||
|
||||
autoDjPushUniqueAlbumIds(
|
||||
candidateAlbumIds,
|
||||
seenAlbumCandidates,
|
||||
args.queueAlbumIdSet,
|
||||
...similarSongsFromSimilarApi.map((s) => s.albumId),
|
||||
);
|
||||
}
|
||||
|
||||
if (candidateAlbumIds.length < targetAlbumCount && args.currentSong && args.server) {
|
||||
const genre = args.currentSong.genres?.[0];
|
||||
if (genre) {
|
||||
const genreIds = autoDjGenreIdsForSongGenre(genre, args.server.type);
|
||||
|
||||
const genreAlbums = await args.queryClient.fetchQuery({
|
||||
...albumQueries.list({
|
||||
query: {
|
||||
genreIds,
|
||||
limit: 50,
|
||||
musicFolderId: args.musicFolderId,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: args.serverId,
|
||||
}),
|
||||
queryKey: queryKeys.player.fetch({
|
||||
genreAlbumDj: genreIds,
|
||||
song: args.currentSong.id,
|
||||
}),
|
||||
});
|
||||
|
||||
autoDjPushUniqueAlbumIds(
|
||||
candidateAlbumIds,
|
||||
seenAlbumCandidates,
|
||||
args.queueAlbumIdSet,
|
||||
...genreAlbums.items.map((album) => album.id),
|
||||
);
|
||||
|
||||
if (!args.trySimilarSongs) {
|
||||
const randomAlbumMixCount = Math.max(1, Math.ceil(50 * 0.2));
|
||||
const randomAlbumsMix = await args.queryClient.fetchQuery({
|
||||
...albumQueries.list({
|
||||
query: {
|
||||
limit: randomAlbumMixCount,
|
||||
musicFolderId: args.musicFolderId,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: args.serverId,
|
||||
}),
|
||||
queryKey: queryKeys.player.fetch({
|
||||
genreAlbumDjMixRandom: args.currentSong.id,
|
||||
}),
|
||||
});
|
||||
|
||||
autoDjPushUniqueAlbumIds(
|
||||
candidateAlbumIds,
|
||||
seenAlbumCandidates,
|
||||
args.queueAlbumIdSet,
|
||||
...randomAlbumsMix.items.map((album) => album.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateAlbumIds.length < targetAlbumCount && args.currentSong) {
|
||||
const albumArtist = args.currentSong.albumArtists?.[0];
|
||||
|
||||
if (albumArtist) {
|
||||
const albumsByArtist = await args.queryClient.fetchQuery({
|
||||
...albumQueries.list({
|
||||
query: {
|
||||
artistIds: [albumArtist.id],
|
||||
limit: 50,
|
||||
musicFolderId: args.musicFolderId,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: args.serverId,
|
||||
}),
|
||||
queryKey: queryKeys.player.fetch({
|
||||
artistAlbumDj: albumArtist.id,
|
||||
song: args.currentSong.id,
|
||||
}),
|
||||
});
|
||||
|
||||
autoDjPushUniqueAlbumIds(
|
||||
candidateAlbumIds,
|
||||
seenAlbumCandidates,
|
||||
args.queueAlbumIdSet,
|
||||
...albumsByArtist.items.map((album) => album.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateAlbumIds.length < targetAlbumCount && args.currentSong) {
|
||||
const randomAlbumsFallback = await args.queryClient.fetchQuery({
|
||||
...albumQueries.list({
|
||||
query: {
|
||||
limit: 80,
|
||||
musicFolderId: args.musicFolderId,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: args.serverId,
|
||||
}),
|
||||
queryKey: queryKeys.player.fetch({
|
||||
fallbackAlbumDj: args.currentSong.id,
|
||||
}),
|
||||
});
|
||||
|
||||
autoDjPushUniqueAlbumIds(
|
||||
candidateAlbumIds,
|
||||
seenAlbumCandidates,
|
||||
args.queueAlbumIdSet,
|
||||
...randomAlbumsFallback.items.map((album) => album.id),
|
||||
);
|
||||
}
|
||||
|
||||
const shuffledAlbums = shuffle(candidateAlbumIds);
|
||||
return shuffledAlbums.slice(0, targetAlbumCount);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import { AUTO_DJ_STRATEGY, type AutoDJStrategy } from '/@/renderer/store/settings.store';
|
||||
import { shuffleInPlace } from '/@/renderer/utils/shuffle';
|
||||
import {
|
||||
Played,
|
||||
type QueueSong,
|
||||
type ServerListItem,
|
||||
Song,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
|
||||
export type AutoDjSongCollectArgs = {
|
||||
currentSong: QueueSong;
|
||||
itemCount: number;
|
||||
musicFolderId: string | string[] | undefined;
|
||||
queryClient: QueryClient;
|
||||
queueSongIdSet: Set<string>;
|
||||
server: null | ServerListItem | undefined;
|
||||
serverId: string;
|
||||
songStrategy: AutoDJStrategy;
|
||||
trySimilarSongs: boolean;
|
||||
};
|
||||
|
||||
export const runAutoDjSongs = async (args: AutoDjSongCollectArgs): Promise<Song[]> => {
|
||||
switch (args.songStrategy) {
|
||||
case AUTO_DJ_STRATEGY.LIBRARY_RANDOM: {
|
||||
return collectSongsLibraryRandom(args);
|
||||
}
|
||||
default: {
|
||||
return collectSongsSimilar(args);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const collectSongsLibraryRandom = async (args: AutoDjSongCollectArgs): Promise<Song[]> => {
|
||||
const randomSongs = await args.queryClient.fetchQuery({
|
||||
...songsQueries.random({
|
||||
query: {
|
||||
limit: Math.max(args.itemCount * 3, 50),
|
||||
played: Played.All,
|
||||
},
|
||||
serverId: args.serverId,
|
||||
}),
|
||||
queryKey: queryKeys.player.fetch({ autoDjLibraryRandomSongs: args.currentSong.id }),
|
||||
});
|
||||
|
||||
const pool = randomSongs.items.filter((song) => !args.queueSongIdSet.has(song.id));
|
||||
const shuffled = shuffleInPlace(pool);
|
||||
return shuffled.slice(0, args.itemCount);
|
||||
};
|
||||
|
||||
const collectSongsSimilar = async (args: AutoDjSongCollectArgs): Promise<Song[]> => {
|
||||
let uniqueSimilarSongs: Song[] = [];
|
||||
|
||||
if (args.trySimilarSongs) {
|
||||
const similarSongs = await args.queryClient.fetchQuery({
|
||||
...songsQueries.similar({
|
||||
query: {
|
||||
count: args.itemCount,
|
||||
songId: args.currentSong?.id,
|
||||
},
|
||||
serverId: args.serverId,
|
||||
}),
|
||||
queryKey: queryKeys.player.fetch({ similarSongs: args.currentSong?.id }),
|
||||
});
|
||||
|
||||
uniqueSimilarSongs = similarSongs.filter((song) => !args.queueSongIdSet.has(song.id));
|
||||
}
|
||||
|
||||
if (uniqueSimilarSongs.length < args.itemCount) {
|
||||
const genre = args.currentSong?.genres?.[0];
|
||||
|
||||
if (genre) {
|
||||
const genreLimit = 50;
|
||||
const genreSimilarSongs = await args.queryClient.fetchQuery({
|
||||
...songsQueries.random({
|
||||
query: {
|
||||
genre: genre.id,
|
||||
limit: genreLimit,
|
||||
played: Played.All,
|
||||
},
|
||||
serverId: args.serverId,
|
||||
}),
|
||||
queryKey: queryKeys.player.fetch({
|
||||
genre,
|
||||
similarSongs: args.currentSong?.id,
|
||||
}),
|
||||
});
|
||||
|
||||
const genreSongs = genreSimilarSongs.items.filter(
|
||||
(song) => !args.queueSongIdSet.has(song.id),
|
||||
);
|
||||
|
||||
if (!args.trySimilarSongs) {
|
||||
const randomSongCount = Math.max(1, Math.ceil(genreLimit * 0.2));
|
||||
|
||||
const randomSongs = await args.queryClient.fetchQuery({
|
||||
...songsQueries.random({
|
||||
query: { limit: randomSongCount, played: Played.All },
|
||||
serverId: args.serverId,
|
||||
}),
|
||||
});
|
||||
|
||||
const uniqueRandomSongs = randomSongs.items.filter(
|
||||
(song) => !args.queueSongIdSet.has(song.id),
|
||||
);
|
||||
|
||||
const randomSongsToAdd = uniqueRandomSongs.slice(0, randomSongCount);
|
||||
uniqueSimilarSongs.push(...randomSongsToAdd, ...genreSongs);
|
||||
} else {
|
||||
uniqueSimilarSongs.push(...genreSongs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uniqueSimilarSongs.length < args.itemCount) {
|
||||
const albumArtist = args.currentSong?.albumArtists?.[0];
|
||||
|
||||
if (albumArtist) {
|
||||
const albumArtistSimilarSongs = await args.queryClient.fetchQuery({
|
||||
...songsQueries.list({
|
||||
query: {
|
||||
albumArtistIds: [albumArtist.id],
|
||||
limit: 50,
|
||||
sortBy: SongListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: args.serverId,
|
||||
}),
|
||||
queryKey: queryKeys.player.fetch({
|
||||
albumArtist,
|
||||
similarSongs: args.currentSong?.id,
|
||||
}),
|
||||
});
|
||||
|
||||
uniqueSimilarSongs.push(
|
||||
...albumArtistSimilarSongs.items.filter(
|
||||
(song) => !args.queueSongIdSet.has(song.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (uniqueSimilarSongs.length < args.itemCount) {
|
||||
const randomSongs = await args.queryClient.fetchQuery({
|
||||
...songsQueries.random({
|
||||
query: { limit: 50, played: Played.All },
|
||||
serverId: args.serverId,
|
||||
}),
|
||||
});
|
||||
|
||||
uniqueSimilarSongs.push(
|
||||
...randomSongs.items.filter((song) => !args.queueSongIdSet.has(song.id)),
|
||||
);
|
||||
}
|
||||
|
||||
const shuffledSongs = shuffleInPlace(uniqueSimilarSongs);
|
||||
return shuffledSongs.slice(0, args.itemCount);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Genre } from '/@/shared/types/domain-types';
|
||||
|
||||
import { ServerType } from '/@/shared/types/domain-types';
|
||||
|
||||
export const autoDjPushUniqueAlbumIds = (
|
||||
accumulator: string[],
|
||||
seenAlbums: Set<string>,
|
||||
queueAlbumIdSet: Set<string>,
|
||||
...ids: (string | undefined)[]
|
||||
) => {
|
||||
for (const id of ids) {
|
||||
if (!id || queueAlbumIdSet.has(id) || seenAlbums.has(id)) continue;
|
||||
seenAlbums.add(id);
|
||||
accumulator.push(id);
|
||||
}
|
||||
};
|
||||
|
||||
export const autoDjGenreIdsForSongGenre = (genre: Genre, serverType: ServerType): string[] => {
|
||||
if (serverType === ServerType.JELLYFIN) {
|
||||
return [genre.id];
|
||||
}
|
||||
|
||||
if (serverType === ServerType.NAVIDROME || serverType === ServerType.SUBSONIC) {
|
||||
return [genre.name];
|
||||
}
|
||||
|
||||
return [genre.id];
|
||||
};
|
||||
@@ -14,7 +14,10 @@ import { MediaSessionHook } from '/@/renderer/features/player/hooks/use-media-se
|
||||
import { MPRISHook } from '/@/renderer/features/player/hooks/use-mpris';
|
||||
import { PlaybackHotkeysHook } from '/@/renderer/features/player/hooks/use-playback-hotkeys';
|
||||
import { PowerSaveBlockerHook } from '/@/renderer/features/player/hooks/use-power-save-blocker';
|
||||
import { QueueRestoreTimestampHook } from '/@/renderer/features/player/hooks/use-queue-restore';
|
||||
import {
|
||||
InitialTimestampRestoreHook,
|
||||
QueueRestoreTimestampHook,
|
||||
} from '/@/renderer/features/player/hooks/use-queue-restore';
|
||||
import { ScrobbleHook } from '/@/renderer/features/player/hooks/use-scrobble';
|
||||
import { UpdateCurrentSongHook } from '/@/renderer/features/player/hooks/use-update-current-song';
|
||||
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
|
||||
@@ -134,6 +137,7 @@ export const AudioPlayers = () => {
|
||||
<RemoteHook />
|
||||
<AutoDJHook />
|
||||
<QueueRestoreTimestampHook />
|
||||
<InitialTimestampRestoreHook />
|
||||
<UpdateCurrentSongHook />
|
||||
<RadioAudioInstanceHook />
|
||||
<RadioMetadataHook />
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
}
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
.bar {
|
||||
background-color: var(--theme-colors-primary-filled);
|
||||
@@ -44,48 +46,45 @@
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.track {
|
||||
&::before {
|
||||
right: calc(0.1rem * -1);
|
||||
}
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
display: flex;
|
||||
--slider-value-width: 4.75rem;
|
||||
--slider-value-gap: var(--theme-spacing-sm);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: var(--slider-value-width) minmax(0, 1fr) var(--slider-value-width);
|
||||
gap: var(--slider-value-gap);
|
||||
align-items: center;
|
||||
width: 95%;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.slider-value-wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 50px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
&:first-child {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
@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;
|
||||
@media (width < 768px) {
|
||||
.slider-container {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.slider-wrapper {
|
||||
display: flex;
|
||||
flex: 6;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export const PlayerbarSlider = () => {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.sliderContainer}>
|
||||
<div className={styles.sliderValueWrapperElapsed}>
|
||||
<div className={styles.sliderValueWrapper}>
|
||||
<ScrobbleStatus formattedTime={formattedTime} />
|
||||
</div>
|
||||
<div className={styles.sliderWrapper}>
|
||||
@@ -81,7 +81,6 @@ export const CustomPlayerbarSlider = ({ ...props }: SliderProps) => {
|
||||
label: styles.label,
|
||||
root: styles.root,
|
||||
thumb: styles.thumb,
|
||||
track: styles.track,
|
||||
}}
|
||||
{...props}
|
||||
size={6}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from 'i18next';
|
||||
import { useCallback, useEffect, useState, WheelEvent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState, WheelEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PopoverPlayQueue } from '/@/renderer/features/now-playing/components/popover-play-queue';
|
||||
@@ -12,6 +12,9 @@ import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-
|
||||
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
||||
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
|
||||
import {
|
||||
AUTO_DJ_MODE,
|
||||
AUTO_DJ_STRATEGY,
|
||||
type AutoDJStrategy,
|
||||
useAppStoreActions,
|
||||
useAutoDJSettings,
|
||||
useCurrentServer,
|
||||
@@ -34,7 +37,15 @@ import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Paper } from '/@/shared/components/paper/paper';
|
||||
import { Popover } from '/@/shared/components/popover/popover';
|
||||
import { Rating } from '/@/shared/components/rating/rating';
|
||||
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useMediaQuery } from '/@/shared/hooks/use-media-query';
|
||||
import { useThrottledCallback } from '/@/shared/hooks/use-throttled-callback';
|
||||
import { useThrottledValue } from '/@/shared/hooks/use-throttled-value';
|
||||
@@ -90,28 +101,148 @@ const AutoDJButton = () => {
|
||||
const settings = useAutoDJSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
|
||||
const toggleAutoDJ = () => {
|
||||
setSettings({
|
||||
autoDJ: {
|
||||
...settings,
|
||||
enabled: !settings.enabled,
|
||||
const itemLabels = useMemo(() => {
|
||||
return {
|
||||
description: t('setting.autoDJ_itemCount_description'),
|
||||
title: t('setting.autoDJ_itemCount'),
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const strategySelectData = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t('setting.autoDJ_strategy_option_similar'),
|
||||
value: AUTO_DJ_STRATEGY.SIMILAR,
|
||||
},
|
||||
});
|
||||
};
|
||||
{
|
||||
label: t('setting.autoDJ_strategy_option_library_random'),
|
||||
value: AUTO_DJ_STRATEGY.LIBRARY_RANDOM,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const strategyLabels =
|
||||
settings.mode === AUTO_DJ_MODE.ALBUMS
|
||||
? {
|
||||
description: '',
|
||||
title: t('setting.autoDJ_albumStrategy'),
|
||||
}
|
||||
: {
|
||||
description: '',
|
||||
title: t('setting.autoDJ_songStrategy'),
|
||||
};
|
||||
|
||||
const strategyValue =
|
||||
settings.mode === AUTO_DJ_MODE.ALBUMS
|
||||
? (settings.albumStrategy ?? AUTO_DJ_STRATEGY.SIMILAR)
|
||||
: (settings.songStrategy ?? AUTO_DJ_STRATEGY.SIMILAR);
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleAutoDJ();
|
||||
}}
|
||||
size="compact-xs"
|
||||
style={{ color: settings.enabled ? 'var(--theme-colors-primary)' : undefined }}
|
||||
uppercase
|
||||
variant="transparent"
|
||||
>
|
||||
{t('setting.autoDJ')}
|
||||
</Button>
|
||||
<Popover position="top-end" withArrow>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
size="compact-xs"
|
||||
style={{ color: settings.enabled ? 'var(--theme-colors-primary)' : undefined }}
|
||||
uppercase
|
||||
variant="transparent"
|
||||
>
|
||||
{t('setting.autoDJ')}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown maw={320} miw={260} onClick={(e) => e.stopPropagation()} p="sm">
|
||||
<Stack gap="sm">
|
||||
<Paper p="md" radius="md">
|
||||
<Group align="center" gap="xs" justify="space-between" wrap="nowrap">
|
||||
<Text fw={600} isNoSelect size="sm">
|
||||
{t('setting.autoDJ_enabled')}
|
||||
</Text>
|
||||
<Switch
|
||||
checked={settings.enabled}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
autoDJ: { enabled: e.currentTarget.checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{ label: t('setting.autoDJ_mode_songs'), value: AUTO_DJ_MODE.SONGS },
|
||||
{
|
||||
label: t('setting.autoDJ_mode_albums'),
|
||||
value: AUTO_DJ_MODE.ALBUMS,
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setSettings({
|
||||
autoDJ: {
|
||||
mode: value as 'albums' | 'songs',
|
||||
},
|
||||
})
|
||||
}
|
||||
value={settings.mode}
|
||||
w="100%"
|
||||
/>
|
||||
<Select
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
data={strategySelectData}
|
||||
description={strategyLabels.description}
|
||||
label={strategyLabels.title}
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
setSettings({
|
||||
autoDJ:
|
||||
settings.mode === AUTO_DJ_MODE.ALBUMS
|
||||
? { albumStrategy: value as AutoDJStrategy }
|
||||
: { songStrategy: value as AutoDJStrategy },
|
||||
});
|
||||
}}
|
||||
size="md"
|
||||
value={strategyValue}
|
||||
w="100%"
|
||||
/>
|
||||
<NumberInput
|
||||
aria-label={itemLabels.title}
|
||||
description={itemLabels.description}
|
||||
hideControls={false}
|
||||
label={itemLabels.title}
|
||||
max={50}
|
||||
min={1}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
autoDJ: {
|
||||
itemCount: Number(e),
|
||||
},
|
||||
})
|
||||
}
|
||||
size="md"
|
||||
value={Number(settings.itemCount)}
|
||||
/>
|
||||
<NumberInput
|
||||
aria-label={t('setting.autoDJ_timing')}
|
||||
description={t('setting.autoDJ_timing_description')}
|
||||
hideControls={false}
|
||||
label={t('setting.autoDJ_timing')}
|
||||
max={5}
|
||||
min={1}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
autoDJ: {
|
||||
timing: Number(e),
|
||||
},
|
||||
})
|
||||
}
|
||||
size="md"
|
||||
value={Number(settings.timing)}
|
||||
/>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ const scrobbleProgressProps = {
|
||||
|
||||
const clampPct = (n: number) => Math.min(100, Math.max(0, n));
|
||||
|
||||
const capForDisplay = (value: number, limit: number) =>
|
||||
limit > 0 ? Math.min(value, limit) : value;
|
||||
|
||||
const progressTowardLimit = (current: number, limit: number) =>
|
||||
limit > 0 ? clampPct((current / limit) * 100) : 0;
|
||||
|
||||
const ScrobbleConditionProgress = ({ value }: { value: number }) => (
|
||||
<Progress {...scrobbleProgressProps} value={value} w="100%" />
|
||||
);
|
||||
@@ -34,18 +40,25 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
|
||||
|
||||
const hookInactive = !scrobbleEnabled || privateMode;
|
||||
|
||||
const listenedSec = (snapshot.listenedMs / 1000).toFixed(1);
|
||||
const listenPercentOfTrack =
|
||||
snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0;
|
||||
const listenedSecRaw = snapshot.listenedMs / 1000;
|
||||
const listenedSecDisplay = snapshot.submitted
|
||||
? snapshot.targetDurationSec
|
||||
: capForDisplay(listenedSecRaw, snapshot.targetDurationSec);
|
||||
|
||||
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;
|
||||
const listenPercentOfTrackRaw =
|
||||
snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0;
|
||||
const listenPercentDisplay = snapshot.submitted
|
||||
? snapshot.targetPercentage
|
||||
: capForDisplay(listenPercentOfTrackRaw, snapshot.targetPercentage);
|
||||
|
||||
const durationConditionPct = progressTowardLimit(
|
||||
listenedSecDisplay,
|
||||
snapshot.targetDurationSec,
|
||||
);
|
||||
const percentConditionPct = progressTowardLimit(
|
||||
listenPercentDisplay,
|
||||
snapshot.targetPercentage,
|
||||
);
|
||||
|
||||
return (
|
||||
<HoverCard position="top" width={280}>
|
||||
@@ -87,13 +100,13 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs">
|
||||
{`${listenedSec}s / ${snapshot.targetDurationSec}s`}
|
||||
{`${listenedSecDisplay.toFixed(1)}s / ${snapshot.targetDurationSec}s`}
|
||||
</Text>
|
||||
<ScrobbleConditionProgress value={durationConditionPct} />
|
||||
</Stack>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs">
|
||||
{`${listenPercentOfTrack.toFixed(1)}% / ${snapshot.targetPercentage}%`}
|
||||
{`${listenPercentDisplay.toFixed(1)}% / ${snapshot.targetPercentage}%`}
|
||||
</Text>
|
||||
<ScrobbleConditionProgress value={percentConditionPct} />
|
||||
</Stack>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createWithEqualityFn } from 'zustand/traditional';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';
|
||||
@@ -18,9 +19,18 @@ import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Played, RandomSongListQuery, ServerType } from '/@/shared/types/domain-types';
|
||||
import {
|
||||
AlbumListQuery,
|
||||
AlbumListSort,
|
||||
LibraryItem,
|
||||
Played,
|
||||
RandomSongListQuery,
|
||||
ServerType,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
interface ShuffleAllSlice extends RandomSongListQuery {
|
||||
@@ -29,6 +39,7 @@ interface ShuffleAllSlice extends RandomSongListQuery {
|
||||
};
|
||||
enableMaxYear: boolean;
|
||||
enableMinYear: boolean;
|
||||
playbackKind: 'albums' | 'songs';
|
||||
}
|
||||
|
||||
const useShuffleAllStore = createWithEqualityFn<ShuffleAllSlice>()(
|
||||
@@ -42,16 +53,28 @@ const useShuffleAllStore = createWithEqualityFn<ShuffleAllSlice>()(
|
||||
enableMaxYear: false,
|
||||
enableMinYear: false,
|
||||
genre: '',
|
||||
limit: 100,
|
||||
maxYear: 2020,
|
||||
minYear: 2000,
|
||||
musicFolder: '',
|
||||
playbackKind: 'songs',
|
||||
played: Played.All,
|
||||
songCount: 100,
|
||||
})),
|
||||
{
|
||||
merge: (persistedState, currentState) => merge(currentState, persistedState),
|
||||
migrate: (persisted, version: number) => {
|
||||
if (!persisted) {
|
||||
return persisted;
|
||||
}
|
||||
|
||||
if (version >= 2) {
|
||||
return persisted;
|
||||
}
|
||||
|
||||
return persisted;
|
||||
},
|
||||
name: 'store_shuffle_all',
|
||||
version: 1,
|
||||
version: 2,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -66,13 +89,24 @@ export const useShuffleAllStoreActions = () => useShuffleAllStore((state) => sta
|
||||
|
||||
export const ShuffleAllContextModal = () => {
|
||||
const server = useCurrentServer();
|
||||
const { addToQueueByData } = usePlayer();
|
||||
const { addToQueueByData, addToQueueByFetch } = usePlayer();
|
||||
const { t } = useTranslation();
|
||||
const { enableMaxYear, enableMinYear, genre, limit, maxYear, minYear, musicFolderId, played } =
|
||||
useShuffleAllStore();
|
||||
const {
|
||||
enableMaxYear,
|
||||
enableMinYear,
|
||||
genre,
|
||||
limit,
|
||||
maxYear,
|
||||
minYear,
|
||||
musicFolderId,
|
||||
playbackKind,
|
||||
played,
|
||||
} = useShuffleAllStore();
|
||||
const { setStore } = useShuffleAllStoreActions();
|
||||
|
||||
const { isFetching, refetch } = useQuery({
|
||||
const clampedLimit = Math.min(500, Math.max(1, limit || 100));
|
||||
|
||||
const { isFetching: isFetchingSongs, refetch: refetchSongs } = useQuery({
|
||||
...randomFetchQuery({
|
||||
query: {
|
||||
genre: genre || undefined,
|
||||
@@ -89,22 +123,75 @@ export const ShuffleAllContextModal = () => {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { isFetching: isFetchingAlbums, refetch: refetchAlbums } = useQuery({
|
||||
...shuffleAlbumListQuery({
|
||||
query: {
|
||||
genreIds: genre ? [genre] : undefined,
|
||||
limit: clampedLimit,
|
||||
minYear: enableMinYear ? minYear || undefined : undefined,
|
||||
musicFolderId: musicFolderId || undefined,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server.id,
|
||||
}),
|
||||
enabled: false,
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const fetchTypeRef = useRef<Play>(null);
|
||||
|
||||
const handlePlay = async (playType: Play) => {
|
||||
fetchTypeRef.current = playType;
|
||||
|
||||
const { data } = await refetch();
|
||||
if (playbackKind === 'albums') {
|
||||
const { data } = await refetchAlbums();
|
||||
|
||||
addToQueueByData(data?.items || [], playType);
|
||||
addToQueueByFetch(
|
||||
server.id,
|
||||
data?.items.map((a) => a.id) ?? [],
|
||||
LibraryItem.ALBUM,
|
||||
playType,
|
||||
);
|
||||
} else {
|
||||
const { data } = await refetchSongs();
|
||||
|
||||
addToQueueByData(data?.items || [], playType);
|
||||
}
|
||||
|
||||
closeAllModals();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{
|
||||
label: t('form.shuffleAll.input_kind_songs'),
|
||||
value: 'songs',
|
||||
},
|
||||
{
|
||||
label: t('form.shuffleAll.input_kind_albums'),
|
||||
value: 'albums',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setStore({
|
||||
playbackKind: value as 'albums' | 'songs',
|
||||
})
|
||||
}
|
||||
size="sm"
|
||||
value={playbackKind}
|
||||
w="100%"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('form.shuffleAll.input_limit')}
|
||||
label={
|
||||
playbackKind === 'albums'
|
||||
? t('form.shuffleAll.input_limit_albums')
|
||||
: t('form.shuffleAll.input_limit_songs')
|
||||
}
|
||||
max={500}
|
||||
min={1}
|
||||
onChange={(e) => setStore({ limit: e ? Number(e) : 500 })}
|
||||
@@ -127,6 +214,7 @@ export const ShuffleAllContextModal = () => {
|
||||
value={minYear}
|
||||
/>
|
||||
<NumberInput
|
||||
disabled={playbackKind === 'albums'}
|
||||
label={t('form.shuffleAll.input_maxYear')}
|
||||
max={2050}
|
||||
min={1850}
|
||||
@@ -134,6 +222,7 @@ export const ShuffleAllContextModal = () => {
|
||||
rightSection={
|
||||
<Checkbox
|
||||
checked={enableMaxYear}
|
||||
disabled={playbackKind === 'albums'}
|
||||
onChange={(e) => setStore({ enableMaxYear: e.currentTarget.checked })}
|
||||
style={{ marginRight: '0.5rem' }}
|
||||
/>
|
||||
@@ -144,7 +233,7 @@ export const ShuffleAllContextModal = () => {
|
||||
<Suspense fallback={<Select data={[]} />}>
|
||||
<GenreSelect />
|
||||
</Suspense>
|
||||
{server?.type === ServerType.JELLYFIN && (
|
||||
{server?.type === ServerType.JELLYFIN && playbackKind === 'songs' && (
|
||||
<Select
|
||||
clearable
|
||||
data={PLAYED_DATA}
|
||||
@@ -156,10 +245,7 @@ export const ShuffleAllContextModal = () => {
|
||||
/>
|
||||
)}
|
||||
<Divider />
|
||||
<PlayButtonGroup
|
||||
loading={(isFetching && fetchTypeRef.current) || false}
|
||||
onPlay={handlePlay}
|
||||
/>
|
||||
<PlayButtonGroup loading={isFetchingSongs || isFetchingAlbums} onPlay={handlePlay} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -186,6 +272,13 @@ const randomFetchQuery = (args: {
|
||||
});
|
||||
};
|
||||
|
||||
const shuffleAlbumListQuery = (args: { query: AlbumListQuery; serverId: string }) => {
|
||||
return albumQueries.list({
|
||||
query: args.query,
|
||||
serverId: args.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
export const openShuffleAllModal = async () => {
|
||||
openContextModal({
|
||||
innerProps: {},
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||
import { runAutoDjAlbumIds } from '/@/renderer/features/player/auto-dj/auto-dj-albums';
|
||||
import { runAutoDjSongs } from '/@/renderer/features/player/auto-dj/auto-dj-songs';
|
||||
import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import {
|
||||
AUTO_DJ_STRATEGY,
|
||||
isShuffleEnabled,
|
||||
mapShuffledToQueueIndex,
|
||||
useAutoDJSettings,
|
||||
@@ -17,9 +18,8 @@ import {
|
||||
} from '/@/renderer/store';
|
||||
import { LogCategory, logFn } from '/@/renderer/utils/logger';
|
||||
import { logMsg } from '/@/renderer/utils/logger-message';
|
||||
import { shuffleInPlace } from '/@/renderer/utils/shuffle';
|
||||
import { hasFeature } from '/@/shared/api/utils';
|
||||
import { Played, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
@@ -34,6 +34,9 @@ export const useAutoDJ = () => {
|
||||
const hasSimilarSongsMusicFolder = hasFeature(server, ServerFeature.SIMILAR_SONGS_MUSIC_FOLDER);
|
||||
|
||||
useEffect(() => {
|
||||
const albumStrategy = settings.albumStrategy ?? AUTO_DJ_STRATEGY.SIMILAR;
|
||||
const songStrategy = settings.songStrategy ?? AUTO_DJ_STRATEGY.SIMILAR;
|
||||
|
||||
const unsubscribe = usePlayerStoreBase.subscribe(
|
||||
(state) => {
|
||||
const queue = state.getQueue();
|
||||
@@ -54,7 +57,6 @@ export const useAutoDJ = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// If no current song, don't autoplay
|
||||
if (!properties.song?.id) {
|
||||
return;
|
||||
}
|
||||
@@ -70,142 +72,76 @@ export const useAutoDJ = () => {
|
||||
|
||||
try {
|
||||
const queue = usePlayerStore.getState().getQueue();
|
||||
const queueSongIdSet = new Set(queue.items.map((item) => item.id));
|
||||
let uniqueSimilarSongs: Song[] = [];
|
||||
|
||||
const hasMusicFolder = server?.musicFolderId && server.musicFolderId.length > 0;
|
||||
const musicFolderId =
|
||||
hasMusicFolder && server?.musicFolderId ? server.musicFolderId : undefined;
|
||||
const trySimilarSongs =
|
||||
!hasMusicFolder || (hasMusicFolder && hasSimilarSongsMusicFolder);
|
||||
|
||||
// Skip similar songs fetch if a music folder is selected and does not support musicFolderId on similar songs
|
||||
if (trySimilarSongs) {
|
||||
// First, try to fetch similar songs based on the current song
|
||||
const similarSongs = await queryClient.fetchQuery({
|
||||
...songsQueries.similar({
|
||||
query: {
|
||||
count: settings.itemCount,
|
||||
songId: properties.song?.id,
|
||||
},
|
||||
serverId,
|
||||
}),
|
||||
queryKey: queryKeys.player.fetch({ similarSongs: properties.song?.id }),
|
||||
const runnerDepsBase = {
|
||||
itemCount: settings.itemCount,
|
||||
musicFolderId,
|
||||
queryClient,
|
||||
server,
|
||||
serverId,
|
||||
trySimilarSongs,
|
||||
};
|
||||
|
||||
if (settings.mode === 'albums') {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queueAlbumIdSet = new Set(
|
||||
queue.items
|
||||
.map((item) => item.albumId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
|
||||
const albumsToAdd = await runAutoDjAlbumIds({
|
||||
...runnerDepsBase,
|
||||
albumStrategy,
|
||||
currentSong: properties.song,
|
||||
queueAlbumIdSet,
|
||||
});
|
||||
|
||||
uniqueSimilarSongs = similarSongs.filter(
|
||||
(song) => !queueSongIdSet.has(song.id),
|
||||
);
|
||||
}
|
||||
|
||||
// If not enough songs, try to fetch more similar songs based on the genre of the current song
|
||||
if (uniqueSimilarSongs.length < settings.itemCount) {
|
||||
const genre = properties.song?.genres?.[0];
|
||||
|
||||
if (genre) {
|
||||
const genreLimit = 50;
|
||||
const genreSimilarSongs = await queryClient.fetchQuery({
|
||||
...songsQueries.random({
|
||||
query: {
|
||||
genre: genre.id,
|
||||
limit: genreLimit,
|
||||
played: Played.All,
|
||||
},
|
||||
serverId,
|
||||
}),
|
||||
queryKey: queryKeys.player.fetch({
|
||||
genre,
|
||||
similarSongs: properties.song?.id,
|
||||
}),
|
||||
});
|
||||
|
||||
const genreSongs = genreSimilarSongs.items.filter(
|
||||
(song) => !queueSongIdSet.has(song.id),
|
||||
);
|
||||
|
||||
// If trySimilarSongs is false, add variation by mixing in random songs
|
||||
if (!trySimilarSongs) {
|
||||
// Calculate how many random songs we need: 20% or at least 1
|
||||
const randomSongCount = Math.max(1, Math.ceil(genreLimit * 0.2));
|
||||
|
||||
const randomSongs = await queryClient.fetchQuery({
|
||||
...songsQueries.random({
|
||||
query: { limit: randomSongCount, played: Played.All },
|
||||
serverId,
|
||||
}),
|
||||
});
|
||||
|
||||
const uniqueRandomSongs = randomSongs.items.filter(
|
||||
(song) => !queueSongIdSet.has(song.id),
|
||||
);
|
||||
|
||||
// Add minimum required random songs for variation
|
||||
const randomSongsToAdd = uniqueRandomSongs.slice(
|
||||
0,
|
||||
randomSongCount,
|
||||
);
|
||||
uniqueSimilarSongs.push(...randomSongsToAdd, ...genreSongs);
|
||||
} else {
|
||||
uniqueSimilarSongs.push(...genreSongs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not enough songs, try to fetch more similar songs based on the album artist of the current song
|
||||
if (uniqueSimilarSongs.length < settings.itemCount) {
|
||||
const albumArtist = properties.song?.albumArtists?.[0];
|
||||
|
||||
if (albumArtist) {
|
||||
const albumArtistSimilarSongs = await queryClient.fetchQuery({
|
||||
...songsQueries.list({
|
||||
query: {
|
||||
albumArtistIds: [albumArtist.id],
|
||||
limit: 50,
|
||||
sortBy: SongListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId,
|
||||
}),
|
||||
queryKey: queryKeys.player.fetch({
|
||||
albumArtist,
|
||||
similarSongs: properties.song?.id,
|
||||
}),
|
||||
});
|
||||
|
||||
uniqueSimilarSongs.push(
|
||||
...albumArtistSimilarSongs.items.filter(
|
||||
(song) => !queueSongIdSet.has(song.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If not enough songs, just fetch fully random songs
|
||||
if (uniqueSimilarSongs.length < settings.itemCount) {
|
||||
const randomSongs = await queryClient.fetchQuery({
|
||||
...songsQueries.random({
|
||||
query: { limit: 50, played: Played.All },
|
||||
if (albumsToAdd.length > 0) {
|
||||
await player.addToQueueByFetch(
|
||||
serverId,
|
||||
}),
|
||||
});
|
||||
albumsToAdd,
|
||||
LibraryItem.ALBUM,
|
||||
Play.LAST,
|
||||
);
|
||||
|
||||
uniqueSimilarSongs.push(
|
||||
...randomSongs.items.filter((song) => !queueSongIdSet.has(song.id)),
|
||||
);
|
||||
eventEmitter.emit('AUTODJ_QUEUE_ADDED', {
|
||||
songCount: albumsToAdd.length,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Shuffle the songs and then add to the queue
|
||||
const shuffledSongs = shuffleInPlace(uniqueSimilarSongs);
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Splice the first itemCount songs and add to the queue
|
||||
const songsToAdd = shuffledSongs.slice(0, settings.itemCount);
|
||||
const queueSongIdSet = new Set(queue.items.map((item) => item.id));
|
||||
|
||||
// Add to the end of the queue
|
||||
player.addToQueueByData(songsToAdd, Play.LAST);
|
||||
|
||||
// Emit event to trigger queue follow
|
||||
eventEmitter.emit('AUTODJ_QUEUE_ADDED', {
|
||||
songCount: songsToAdd.length,
|
||||
const songsToAdd = await runAutoDjSongs({
|
||||
...runnerDepsBase,
|
||||
currentSong: properties.song,
|
||||
queueSongIdSet,
|
||||
songStrategy,
|
||||
});
|
||||
|
||||
if (songsToAdd.length > 0) {
|
||||
player.addToQueueByData(songsToAdd, Play.LAST);
|
||||
|
||||
eventEmitter.emit('AUTODJ_QUEUE_ADDED', {
|
||||
songCount: songsToAdd.length,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logFn.error(logMsg[LogCategory.PLAYER].autoPlayFailed, {
|
||||
category: LogCategory.PLAYER,
|
||||
@@ -229,7 +165,10 @@ export const useAutoDJ = () => {
|
||||
server,
|
||||
serverId,
|
||||
settings.enabled,
|
||||
settings.albumStrategy,
|
||||
settings.itemCount,
|
||||
settings.mode,
|
||||
settings.songStrategy,
|
||||
settings.timing,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -117,11 +117,11 @@ export const useMPRIS = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
mpris?.requestPosition((_e: unknown, data: { position: number }) => {
|
||||
mpris?.requestPosition((data: { position: number }) => {
|
||||
player.mediaSeekToTimestamp(data.position);
|
||||
});
|
||||
|
||||
mpris?.requestSeek((_e: unknown, data: { offset: number }) => {
|
||||
mpris?.requestSeek((data: { offset: number }) => {
|
||||
player.mediaSkipForward(data.offset);
|
||||
});
|
||||
|
||||
@@ -133,7 +133,7 @@ export const useMPRIS = () => {
|
||||
player.toggleShuffle();
|
||||
});
|
||||
|
||||
mpris?.requestVolume((_e: unknown, data: { volume: number }) => {
|
||||
mpris?.requestVolume((data: { volume: number }) => {
|
||||
player.setVolume(data.volume);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,27 +4,27 @@ import React, { useCallback, useEffect } from 'react';
|
||||
import { usePlayerStatus, useSettingsStore, useWindowSettings } from '/@/renderer/store';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
const ipc = isElectron() ? window.api.ipc : null;
|
||||
const utils = isElectron() ? window.api.utils : null;
|
||||
|
||||
export const usePowerSaveBlocker = () => {
|
||||
const status = usePlayerStatus();
|
||||
const { preventSleepOnPlayback, preventSuspendOnPlayback } = useWindowSettings();
|
||||
|
||||
const startPowerSaveBlocker = useCallback(async () => {
|
||||
if (!ipc) return;
|
||||
if (!utils) return;
|
||||
|
||||
try {
|
||||
await ipc.invoke('power-save-blocker-start', { full: preventSleepOnPlayback });
|
||||
await utils.startPowerSaveBlocker(preventSleepOnPlayback);
|
||||
} catch (error) {
|
||||
console.error('Failed to start power save blocker:', error);
|
||||
}
|
||||
}, [preventSleepOnPlayback]);
|
||||
|
||||
const stopPowerSaveBlocker = useCallback(async () => {
|
||||
if (!ipc) return;
|
||||
if (!utils) return;
|
||||
|
||||
try {
|
||||
await ipc.invoke('power-save-blocker-stop');
|
||||
await utils.stopPowerSaveBlocker();
|
||||
} catch (error) {
|
||||
console.error('Failed to stop power save blocker:', error);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||
@@ -9,13 +9,18 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import {
|
||||
setTimestamp,
|
||||
useCurrentServerId,
|
||||
usePlayerActions,
|
||||
usePlayerHydrated,
|
||||
usePlayerSong,
|
||||
usePlayerStatus,
|
||||
usePlayerStore,
|
||||
useTimestampStoreBase,
|
||||
} from '/@/renderer/store';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
export const useQueueRestoreTimestamp = () => {
|
||||
const player = usePlayerStore();
|
||||
const { mediaSeekToTimestamp } = usePlayerActions();
|
||||
|
||||
usePlayerEvents(
|
||||
{
|
||||
@@ -24,7 +29,7 @@ export const useQueueRestoreTimestamp = () => {
|
||||
|
||||
setTimeout(() => {
|
||||
setTimestamp(position);
|
||||
player.mediaSeekToTimestamp(position);
|
||||
mediaSeekToTimestamp(position);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
@@ -37,6 +42,72 @@ export const QueueRestoreTimestampHook = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useInitialTimestampRestore = () => {
|
||||
const { mediaSeekToTimestamp } = usePlayerActions();
|
||||
const playerHydrated = usePlayerHydrated();
|
||||
const currentSong = usePlayerSong();
|
||||
const playerStatus = usePlayerStatus();
|
||||
const timestamp = useTimestampStoreBase((state) => state.timestamp);
|
||||
|
||||
const startupRestoreInitializedRef = useRef(false);
|
||||
const startupSeekArmedRef = useRef<null | number>(null);
|
||||
const startupSeekAppliedRef = useRef(false);
|
||||
|
||||
const applyStartupSeek = useCallback(() => {
|
||||
if (startupSeekAppliedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seekTimestamp = startupSeekArmedRef.current;
|
||||
if (!seekTimestamp || seekTimestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
startupSeekAppliedRef.current = true;
|
||||
startupSeekArmedRef.current = null;
|
||||
|
||||
setTimeout(() => {
|
||||
mediaSeekToTimestamp(seekTimestamp);
|
||||
}, 100);
|
||||
}, [mediaSeekToTimestamp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (startupRestoreInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!playerHydrated || !currentSong) {
|
||||
return;
|
||||
}
|
||||
|
||||
startupRestoreInitializedRef.current = true;
|
||||
|
||||
if (timestamp > 0) {
|
||||
startupSeekArmedRef.current = timestamp;
|
||||
}
|
||||
|
||||
if (playerStatus === PlayerStatus.PLAYING) {
|
||||
applyStartupSeek();
|
||||
}
|
||||
}, [applyStartupSeek, currentSong, playerHydrated, playerStatus, timestamp]);
|
||||
|
||||
usePlayerEvents(
|
||||
{
|
||||
onPlayerStatus: (properties) => {
|
||||
if (properties.status === PlayerStatus.PLAYING) {
|
||||
applyStartupSeek();
|
||||
}
|
||||
},
|
||||
},
|
||||
[applyStartupSeek],
|
||||
);
|
||||
};
|
||||
|
||||
export const InitialTimestampRestoreHook = () => {
|
||||
useInitialTimestampRestore();
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useSaveQueue = () => {
|
||||
const serverId = useCurrentServerId();
|
||||
|
||||
|
||||
@@ -4,17 +4,21 @@ 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 {
|
||||
getServerById,
|
||||
publishScrobbleDebug,
|
||||
useAppStore,
|
||||
usePlaybackSettings,
|
||||
usePlayerSong,
|
||||
usePlayerSpeed,
|
||||
usePlayerStore,
|
||||
useSettingsStore,
|
||||
useTimestampStoreBase,
|
||||
} from '/@/renderer/store';
|
||||
import { LogCategory, logFn } from '/@/renderer/utils/logger';
|
||||
import { logMsg } from '/@/renderer/utils/logger-message';
|
||||
import { hasFeature } from '/@/shared/api/utils';
|
||||
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
type ScrobbleManualHandlers = {
|
||||
@@ -36,6 +40,14 @@ export const invokeScrobbleResetListenedState = () => {
|
||||
scrobbleManualHandlers?.resetListenedState();
|
||||
};
|
||||
|
||||
const getPositionValue = (seconds: number, useTicks: boolean) => {
|
||||
if (useTicks) {
|
||||
return Math.round(seconds * 1e7);
|
||||
}
|
||||
|
||||
return seconds;
|
||||
};
|
||||
|
||||
/*
|
||||
Submission (Last.fm / etc.) eligibility uses accumulated listen time:
|
||||
- If listened time meets the required percentage of track duration
|
||||
@@ -99,6 +111,7 @@ export const useScrobble = () => {
|
||||
const isPrivateModeEnabled = useAppStore((state) => state.privateMode);
|
||||
const sendScrobble = useSendScrobble();
|
||||
const currentSong = usePlayerSong();
|
||||
const playbackRate = usePlayerSpeed();
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: currentSong?.imageId || undefined,
|
||||
@@ -166,6 +179,11 @@ export const useScrobble = () => {
|
||||
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
|
||||
|
||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||
const serverId = currentSong?._serverId;
|
||||
const server = getServerById(serverId);
|
||||
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
|
||||
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
|
||||
const currentStatus = usePlayerStore.getState().player.status;
|
||||
const currentTime = properties.timestamp;
|
||||
const previousTime = prev.timestamp;
|
||||
@@ -220,19 +238,20 @@ export const useScrobble = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Send Jellyfin progress events every 10 seconds
|
||||
if (currentSong._serverType === ServerType.JELLYFIN) {
|
||||
// Send progress events every 10 seconds
|
||||
if (hasPlaybackReport) {
|
||||
const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
|
||||
if (timeSinceLastProgress >= 10) {
|
||||
const position = currentTime * 1e7;
|
||||
sendScrobble.mutate(
|
||||
{
|
||||
apiClientProps: { serverId: currentSong._serverId || '' },
|
||||
apiClientProps: { serverId: serverId || '' },
|
||||
query: {
|
||||
albumId: currentSong.albumId,
|
||||
event: 'timeupdate',
|
||||
id: currentSong.id,
|
||||
position,
|
||||
mediaType: mediaType,
|
||||
playbackRate,
|
||||
position: getPositionValue(currentTime, useTicks),
|
||||
submission: false,
|
||||
},
|
||||
},
|
||||
@@ -261,20 +280,15 @@ export const useScrobble = () => {
|
||||
});
|
||||
|
||||
if (shouldSubmitScrobble) {
|
||||
// Since jellyfin-plugin-lastfm uses the submission Position to determine if the song should actually scrobble
|
||||
// we just send the full duration of the song when it matches the local scrobble conditions
|
||||
const position =
|
||||
currentSong._serverType === ServerType.JELLYFIN
|
||||
? currentSong.duration * 1e7
|
||||
: undefined;
|
||||
|
||||
sendScrobble.mutate(
|
||||
{
|
||||
apiClientProps: { serverId: currentSong._serverId || '' },
|
||||
query: {
|
||||
albumId: currentSong.albumId,
|
||||
id: currentSong.id,
|
||||
position,
|
||||
mediaType: mediaType,
|
||||
playbackRate: playbackRate,
|
||||
position: getPositionValue(currentSong.duration ?? 0, useTicks),
|
||||
submission: true,
|
||||
},
|
||||
},
|
||||
@@ -295,7 +309,7 @@ export const useScrobble = () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate],
|
||||
);
|
||||
|
||||
const handleScrobbleFromSongChange = useCallback(
|
||||
@@ -305,6 +319,7 @@ export const useScrobble = () => {
|
||||
) => {
|
||||
const currentSong = properties.song;
|
||||
const previousSong = previousSongRef.current;
|
||||
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||
|
||||
// Handle notifications
|
||||
if (scrobbleSettings?.notify && currentSong?.id) {
|
||||
@@ -365,6 +380,8 @@ export const useScrobble = () => {
|
||||
albumId: currentSong.albumId,
|
||||
event: 'start',
|
||||
id: currentSong.id,
|
||||
mediaType: mediaType,
|
||||
playbackRate: playbackRate,
|
||||
position: 0,
|
||||
submission: false,
|
||||
},
|
||||
@@ -388,11 +405,12 @@ export const useScrobble = () => {
|
||||
flushScrobbleDebug();
|
||||
},
|
||||
[
|
||||
flushScrobbleDebug,
|
||||
scrobbleSettings?.notify,
|
||||
isScrobbleEnabled,
|
||||
isPrivateModeEnabled,
|
||||
flushScrobbleDebug,
|
||||
sendScrobble,
|
||||
playbackRate,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -404,6 +422,11 @@ export const useScrobble = () => {
|
||||
}
|
||||
|
||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||
const serverId = currentSong?._serverId;
|
||||
const server = getServerById(serverId);
|
||||
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
|
||||
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
|
||||
|
||||
if (!currentSong?.id) {
|
||||
return;
|
||||
@@ -422,7 +445,7 @@ export const useScrobble = () => {
|
||||
}
|
||||
|
||||
// Position scrobbles are only relevant for Jellyfin
|
||||
if (currentSong._serverType !== ServerType.JELLYFIN) {
|
||||
if (!hasPlaybackReport) {
|
||||
flushScrobbleDebug();
|
||||
return;
|
||||
}
|
||||
@@ -436,8 +459,6 @@ export const useScrobble = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = properties.timestamp * 1e7;
|
||||
|
||||
lastProgressEventRef.current = properties.timestamp;
|
||||
lastSeekEventRef.current = now;
|
||||
|
||||
@@ -448,7 +469,9 @@ export const useScrobble = () => {
|
||||
albumId: currentSong.albumId,
|
||||
event: 'timeupdate',
|
||||
id: currentSong.id,
|
||||
position,
|
||||
mediaType: mediaType,
|
||||
playbackRate: playbackRate,
|
||||
position: getPositionValue(properties.timestamp, useTicks),
|
||||
submission: false,
|
||||
},
|
||||
},
|
||||
@@ -465,7 +488,7 @@ export const useScrobble = () => {
|
||||
);
|
||||
flushScrobbleDebug();
|
||||
},
|
||||
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate, flushScrobbleDebug],
|
||||
);
|
||||
|
||||
const handleScrobbleFromStatus = useCallback(
|
||||
@@ -475,18 +498,22 @@ export const useScrobble = () => {
|
||||
}
|
||||
|
||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||
const serverId = currentSong?._serverId;
|
||||
const server = getServerById(serverId);
|
||||
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
|
||||
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
|
||||
|
||||
if (!currentSong?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only apply to Jellyfin controller scrobble
|
||||
if (currentSong._serverType !== ServerType.JELLYFIN) {
|
||||
if (!hasPlaybackReport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
|
||||
const position = currentTimestamp * 1e7;
|
||||
|
||||
// Send pause event when status changes to paused
|
||||
if (properties.status === PlayerStatus.PAUSED && prev.status === PlayerStatus.PLAYING) {
|
||||
@@ -497,7 +524,9 @@ export const useScrobble = () => {
|
||||
albumId: currentSong.albumId,
|
||||
event: 'pause',
|
||||
id: currentSong.id,
|
||||
position,
|
||||
mediaType: mediaType,
|
||||
playbackRate: playbackRate,
|
||||
position: getPositionValue(currentTimestamp, useTicks),
|
||||
submission: false,
|
||||
},
|
||||
},
|
||||
@@ -523,7 +552,9 @@ export const useScrobble = () => {
|
||||
albumId: currentSong.albumId,
|
||||
event: 'unpause',
|
||||
id: currentSong.id,
|
||||
position,
|
||||
mediaType: mediaType,
|
||||
playbackRate: playbackRate,
|
||||
position: getPositionValue(currentTimestamp, useTicks),
|
||||
submission: false,
|
||||
},
|
||||
},
|
||||
@@ -542,7 +573,7 @@ export const useScrobble = () => {
|
||||
|
||||
flushScrobbleDebug();
|
||||
},
|
||||
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||
[isScrobbleEnabled, isPrivateModeEnabled, flushScrobbleDebug, sendScrobble, playbackRate],
|
||||
);
|
||||
|
||||
const handleScrobbleFromRepeat = useCallback(() => {
|
||||
@@ -552,6 +583,7 @@ export const useScrobble = () => {
|
||||
|
||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||
const currentStatus = usePlayerStore.getState().player.status;
|
||||
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||
|
||||
if (currentStatus !== PlayerStatus.PLAYING || !currentSong?.id) {
|
||||
return;
|
||||
@@ -570,6 +602,8 @@ export const useScrobble = () => {
|
||||
albumId: currentSong.albumId,
|
||||
event: 'start',
|
||||
id: currentSong.id,
|
||||
mediaType: mediaType,
|
||||
playbackRate: playbackRate,
|
||||
position: 0,
|
||||
submission: false,
|
||||
},
|
||||
@@ -587,7 +621,7 @@ export const useScrobble = () => {
|
||||
},
|
||||
);
|
||||
flushScrobbleDebug();
|
||||
}, [flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]);
|
||||
}, [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate, flushScrobbleDebug]);
|
||||
|
||||
// Update previous timestamp on progress for use in status change handler
|
||||
const handleProgressUpdate = useCallback(
|
||||
@@ -607,20 +641,22 @@ export const useScrobble = () => {
|
||||
}
|
||||
|
||||
const song = usePlayerStore.getState().getCurrentSong();
|
||||
const mediaType = song?._itemType.includes('song') ? 'song' : 'podcast';
|
||||
const useTicks = song?._serverType === ServerType.JELLYFIN;
|
||||
|
||||
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,
|
||||
mediaType: mediaType,
|
||||
playbackRate: playbackRate,
|
||||
position: getPositionValue(song.duration ?? 0, useTicks),
|
||||
submission: true,
|
||||
},
|
||||
},
|
||||
@@ -659,7 +695,7 @@ export const useScrobble = () => {
|
||||
});
|
||||
|
||||
return () => registerScrobbleManualHandlers(null);
|
||||
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, sendScrobble]);
|
||||
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, playbackRate, sendScrobble]);
|
||||
|
||||
usePlayerEvents(
|
||||
{
|
||||
|
||||
@@ -136,6 +136,8 @@ export function RadioWebPlayer() {
|
||||
<WebPlayerEngine
|
||||
isMuted={isMuted}
|
||||
isTransitioning={false}
|
||||
loopPlayer1={false}
|
||||
loopPlayer2={false}
|
||||
onEndedPlayer1={onEndedPlayer1}
|
||||
onEndedPlayer2={() => {}}
|
||||
onErrorPause={() => {}}
|
||||
|
||||
@@ -64,7 +64,7 @@ export const useRemote = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
remote.requestPosition((_e: unknown, data: { position: number }) => {
|
||||
remote.requestPosition((data: { position: number }) => {
|
||||
logFn.debug(logMsg[LogCategory.REMOTE].requestPositionReceived, {
|
||||
category: LogCategory.REMOTE,
|
||||
meta: { position: data.position },
|
||||
@@ -73,7 +73,7 @@ export const useRemote = () => {
|
||||
player.mediaSeekToTimestamp(newTime);
|
||||
});
|
||||
|
||||
remote.requestSeek((_e: unknown, data: { offset: number }) => {
|
||||
remote.requestSeek((data: { offset: number }) => {
|
||||
logFn.debug(logMsg[LogCategory.REMOTE].requestSeekReceived, {
|
||||
category: LogCategory.REMOTE,
|
||||
meta: { offset: data.offset },
|
||||
@@ -81,17 +81,15 @@ export const useRemote = () => {
|
||||
mediaSkipForward(data.offset);
|
||||
});
|
||||
|
||||
remote.requestRating(
|
||||
(_e: unknown, data: { id: string; rating: number; serverId: string }) => {
|
||||
logFn.debug(logMsg[LogCategory.REMOTE].requestRatingReceived, {
|
||||
category: LogCategory.REMOTE,
|
||||
meta: { id: data.id, rating: data.rating, serverId: data.serverId },
|
||||
});
|
||||
setRating(data.serverId, [data.id], LibraryItem.SONG, data.rating);
|
||||
},
|
||||
);
|
||||
remote.requestRating((data: { id: string; rating: number; serverId: string }) => {
|
||||
logFn.debug(logMsg[LogCategory.REMOTE].requestRatingReceived, {
|
||||
category: LogCategory.REMOTE,
|
||||
meta: { id: data.id, rating: data.rating, serverId: data.serverId },
|
||||
});
|
||||
setRating(data.serverId, [data.id], LibraryItem.SONG, data.rating);
|
||||
});
|
||||
|
||||
remote.requestVolume((_e: unknown, data: { volume: number }) => {
|
||||
remote.requestVolume((data: { volume: number }) => {
|
||||
logFn.debug(logMsg[LogCategory.REMOTE].requestVolumeReceived, {
|
||||
category: LogCategory.REMOTE,
|
||||
meta: { volume: data.volume },
|
||||
@@ -99,24 +97,20 @@ export const useRemote = () => {
|
||||
setVolume(data.volume);
|
||||
});
|
||||
|
||||
remote.requestFavorite(
|
||||
(_e: unknown, data: { favorite: boolean; id: string; serverId: string }) => {
|
||||
logFn.debug(logMsg[LogCategory.REMOTE].requestFavoriteReceived, {
|
||||
category: LogCategory.REMOTE,
|
||||
meta: { favorite: data.favorite, id: data.id, serverId: data.serverId },
|
||||
});
|
||||
const mutator = data.favorite
|
||||
? addToFavoritesMutation
|
||||
: removeFromFavoritesMutation;
|
||||
mutator.mutate({
|
||||
apiClientProps: { serverId: data.serverId },
|
||||
query: {
|
||||
id: [data.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
remote.requestFavorite((data: { favorite: boolean; id: string; serverId: string }) => {
|
||||
logFn.debug(logMsg[LogCategory.REMOTE].requestFavoriteReceived, {
|
||||
category: LogCategory.REMOTE,
|
||||
meta: { favorite: data.favorite, id: data.id, serverId: data.serverId },
|
||||
});
|
||||
const mutator = data.favorite ? addToFavoritesMutation : removeFromFavoritesMutation;
|
||||
mutator.mutate({
|
||||
apiClientProps: { serverId: data.serverId },
|
||||
query: {
|
||||
id: [data.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
ipc?.removeAllListeners('request-position');
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { IpcRendererEvent } from 'electron';
|
||||
|
||||
import { t } from 'i18next';
|
||||
import isElectron from 'is-electron';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
@@ -39,7 +37,7 @@ import { FontType } from '/@/shared/types/types';
|
||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||
const ipc = isElectron() ? window.api.ipc : null;
|
||||
// Electron 32+ removed file.path, use this which is exposed in preload to get real path
|
||||
const webUtils = isElectron() ? window.electron.webUtils : null;
|
||||
const getPathForFile = isElectron() ? window.api.getPathForFile : null;
|
||||
|
||||
const HOME_FEATURE_STYLE_OPTIONS = [
|
||||
{
|
||||
@@ -124,7 +122,7 @@ export const ApplicationSettings = memo(() => {
|
||||
// }, [fontSettings.custom]);
|
||||
|
||||
const onFontError = useCallback(
|
||||
(_: IpcRendererEvent, file: string) => {
|
||||
(file: string) => {
|
||||
toast.error({
|
||||
message: `${file} is not a valid font file`,
|
||||
});
|
||||
@@ -295,7 +293,7 @@ export const ApplicationSettings = memo(() => {
|
||||
setSettings({
|
||||
font: {
|
||||
...fontSettings,
|
||||
custom: e ? webUtils?.getPathForFile(e) || null : null,
|
||||
custom: e ? getPathForFile?.(e) || null : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,23 +1,112 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
SettingOption,
|
||||
SettingsSection,
|
||||
} from '/@/renderer/features/settings/components/settings-section';
|
||||
import { useAutoDJSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||
import {
|
||||
AUTO_DJ_MODE,
|
||||
AUTO_DJ_STRATEGY,
|
||||
type AutoDJStrategy,
|
||||
useAutoDJSettings,
|
||||
useSettingsStoreActions,
|
||||
} from '/@/renderer/store/settings.store';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
|
||||
export const AutoDJSettings = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const settings = useAutoDJSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
|
||||
const itemLabels = useMemo(() => {
|
||||
return {
|
||||
description: t('setting.autoDJ_itemCount_description'),
|
||||
title: t('setting.autoDJ_itemCount'),
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const strategySelectData = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t('setting.autoDJ_strategy_option_similar'),
|
||||
value: AUTO_DJ_STRATEGY.SIMILAR,
|
||||
},
|
||||
{
|
||||
label: t('setting.autoDJ_strategy_option_library_random'),
|
||||
value: AUTO_DJ_STRATEGY.LIBRARY_RANDOM,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const autoDJOptions: SettingOption[] = [
|
||||
{
|
||||
control: (
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{ label: t('setting.autoDJ_mode_songs'), value: AUTO_DJ_MODE.SONGS },
|
||||
{ label: t('setting.autoDJ_mode_albums'), value: AUTO_DJ_MODE.ALBUMS },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setSettings({
|
||||
autoDJ: {
|
||||
mode: value as 'albums' | 'songs',
|
||||
},
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
value={settings.mode}
|
||||
w="100%"
|
||||
/>
|
||||
),
|
||||
description: t('setting.autoDJ_mode_description'),
|
||||
title: t('setting.autoDJ_mode'),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
data={strategySelectData}
|
||||
onChange={(value) =>
|
||||
value &&
|
||||
setSettings({
|
||||
autoDJ: {
|
||||
songStrategy: value as AutoDJStrategy,
|
||||
},
|
||||
})
|
||||
}
|
||||
value={settings.songStrategy ?? AUTO_DJ_STRATEGY.SIMILAR}
|
||||
w="100%"
|
||||
/>
|
||||
),
|
||||
description: '',
|
||||
title: t('setting.autoDJ_songStrategy'),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
data={strategySelectData}
|
||||
onChange={(value) =>
|
||||
value &&
|
||||
setSettings({
|
||||
autoDJ: {
|
||||
albumStrategy: value as AutoDJStrategy,
|
||||
},
|
||||
})
|
||||
}
|
||||
value={settings.albumStrategy ?? AUTO_DJ_STRATEGY.SIMILAR}
|
||||
w="100%"
|
||||
/>
|
||||
),
|
||||
description: '',
|
||||
title: t('setting.autoDJ_albumStrategy'),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<NumberInput
|
||||
aria-label="Auto DJ item count"
|
||||
aria-label={itemLabels.title}
|
||||
hideControls={false}
|
||||
max={50}
|
||||
min={1}
|
||||
@@ -31,10 +120,8 @@ export const AutoDJSettings = memo(() => {
|
||||
value={Number(settings.itemCount)}
|
||||
/>
|
||||
),
|
||||
description: t('setting.autoDJ_itemCount', {
|
||||
context: 'description',
|
||||
}),
|
||||
title: t('setting.autoDJ_itemCount'),
|
||||
description: itemLabels.description,
|
||||
title: itemLabels.title,
|
||||
},
|
||||
{
|
||||
control: (
|
||||
|
||||
@@ -21,6 +21,21 @@ type PromptState = 'loading' | { consent: boolean };
|
||||
|
||||
export function VisualizerSystemAudioBridgeHook() {
|
||||
const playbackType = usePlaybackType();
|
||||
const isVisualizerSurfaceVisible = useIsLocalVisualizerSurfaceVisible();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldReportVisible = playbackType === PlayerType.LOCAL && isVisualizerSurfaceVisible;
|
||||
|
||||
window.api.visualizer.setLocalSurfaceVisible(shouldReportVisible);
|
||||
|
||||
return () => {
|
||||
window.api.visualizer.setLocalSurfaceVisible(false);
|
||||
};
|
||||
}, [playbackType, isVisualizerSurfaceVisible]);
|
||||
|
||||
if (!isElectron() || playbackType !== PlayerType.LOCAL) {
|
||||
return null;
|
||||
|
||||
Vendored
+3
@@ -1,8 +1,11 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
ANALYTICS_DISABLED?: boolean | string;
|
||||
FS_AUTO_DJ_ALBUM_STRATEGY?: string;
|
||||
FS_AUTO_DJ_ENABLED?: string;
|
||||
FS_AUTO_DJ_ITEM_COUNT?: string;
|
||||
FS_AUTO_DJ_MODE?: string;
|
||||
FS_AUTO_DJ_SONG_STRATEGY?: string;
|
||||
FS_AUTO_DJ_TIMING?: string;
|
||||
FS_CSS_CONTENT?: string;
|
||||
FS_CSS_ENABLED?: string;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Feishin</title>
|
||||
<% if (web) { %>
|
||||
|
||||
@@ -111,6 +111,8 @@ const SIDE_QUEUE_TYPES = new Set(['sideDrawerQueue', 'sideQueue']);
|
||||
const SIDE_QUEUE_LAYOUTS = new Set(['horizontal', 'vertical']);
|
||||
const SIDEBAR_PLAYLIST_FOLDER_VIEWS = new Set(['navigation', 'single', 'tree']);
|
||||
const SIDEBAR_PLAYLIST_MODES = new Set(['compact', 'expanded']);
|
||||
const AUTO_DJ_MODES = new Set(['albums', 'songs']);
|
||||
const AUTO_DJ_STRATEGIES = new Set(['library_random', 'similar']);
|
||||
|
||||
export type EnvSettingsOverrides = DeepPartial<
|
||||
Pick<SettingsState, 'autoDJ' | 'css' | 'discord' | 'font' | 'general' | 'lyrics' | 'playback'>
|
||||
@@ -422,8 +424,21 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [
|
||||
path: ['lyrics', 'alignment'],
|
||||
type: 'enum',
|
||||
},
|
||||
{
|
||||
enumSet: AUTO_DJ_STRATEGIES,
|
||||
key: 'FS_AUTO_DJ_ALBUM_STRATEGY',
|
||||
path: ['autoDJ', 'albumStrategy'],
|
||||
type: 'enum',
|
||||
},
|
||||
{ key: 'FS_AUTO_DJ_ENABLED', path: ['autoDJ', 'enabled'], type: 'bool' },
|
||||
{ key: 'FS_AUTO_DJ_ITEM_COUNT', path: ['autoDJ', 'itemCount'], type: 'num' },
|
||||
{ enumSet: AUTO_DJ_MODES, key: 'FS_AUTO_DJ_MODE', path: ['autoDJ', 'mode'], type: 'enum' },
|
||||
{
|
||||
enumSet: AUTO_DJ_STRATEGIES,
|
||||
key: 'FS_AUTO_DJ_SONG_STRATEGY',
|
||||
path: ['autoDJ', 'songStrategy'],
|
||||
type: 'enum',
|
||||
},
|
||||
{ key: 'FS_AUTO_DJ_TIMING', path: ['autoDJ', 'timing'], type: 'num' },
|
||||
{
|
||||
key: 'FS_CSS_CONTENT',
|
||||
|
||||
@@ -139,6 +139,25 @@ export function calculateNextSong(
|
||||
}
|
||||
}
|
||||
|
||||
export function getDualPlayerSongs(
|
||||
playerNum: 1 | 2,
|
||||
currentSong: QueueSong | undefined,
|
||||
nextSong: QueueSong | undefined,
|
||||
repeat: PlayerRepeat,
|
||||
): { player1: QueueSong | undefined; player2: QueueSong | undefined } {
|
||||
if (repeat === PlayerRepeat.ONE) {
|
||||
return {
|
||||
player1: playerNum === 1 ? currentSong : undefined,
|
||||
player2: playerNum === 2 ? currentSong : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
player1: playerNum === 1 ? currentSong : nextSong,
|
||||
player2: playerNum === 2 ? currentSong : nextSong,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to check if shuffle is enabled
|
||||
export function isShuffleEnabled(state: {
|
||||
player: { shuffle: PlayerShuffle };
|
||||
@@ -800,13 +819,20 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
nextSong = calculateNextSong(queueIndex, queue.items, repeat);
|
||||
}
|
||||
|
||||
const { player1, player2 } = getDualPlayerSongs(
|
||||
state.player.playerNum,
|
||||
currentSong,
|
||||
nextSong,
|
||||
repeat,
|
||||
);
|
||||
|
||||
return {
|
||||
currentSong,
|
||||
index: queueIndex, // Return the actual queue position for display
|
||||
nextSong,
|
||||
num: state.player.playerNum,
|
||||
player1: state.player.playerNum === 1 ? currentSong : nextSong,
|
||||
player2: state.player.playerNum === 2 ? currentSong : nextSong,
|
||||
player1,
|
||||
player2,
|
||||
previousSong,
|
||||
queueLength: state.queue.default.length,
|
||||
status: state.player.status,
|
||||
@@ -895,12 +921,21 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
? stateSnapshot.queue.shuffled.length
|
||||
: queue.items.length;
|
||||
|
||||
const newPlayerNum = player.playerNum === 1 ? 2 : 1;
|
||||
const { nextIndex: nextPlaybackIndex, shouldPause } = calculateNextIndex(
|
||||
currentIndex,
|
||||
playbackLength,
|
||||
repeat,
|
||||
);
|
||||
const isRepeatOneSameTrack =
|
||||
repeat === PlayerRepeat.ONE && nextPlaybackIndex === currentIndex;
|
||||
// Dual web players alternate for gapless/crossfade between tracks. Repeat-one
|
||||
// replays the same track — keep playerNum so Chromium stays bound to the same
|
||||
// <audio> element and hardware media keys keep working.
|
||||
const newPlayerNum = isRepeatOneSameTrack
|
||||
? player.playerNum
|
||||
: player.playerNum === 1
|
||||
? 2
|
||||
: 1;
|
||||
const pauseOnNext = player.pauseOnNextSongEnd;
|
||||
const newStatus =
|
||||
shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING;
|
||||
@@ -963,13 +998,20 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
currentQueueIndex > 0 ? queue.items[currentQueueIndex - 1] : undefined;
|
||||
}
|
||||
|
||||
const { player1, player2 } = getDualPlayerSongs(
|
||||
newPlayerNum,
|
||||
currentSong,
|
||||
nextSong,
|
||||
repeat,
|
||||
);
|
||||
|
||||
return {
|
||||
currentSong,
|
||||
index: currentQueueIndex,
|
||||
nextSong,
|
||||
num: newPlayerNum,
|
||||
player1: newPlayerNum === 1 ? currentSong : nextSong,
|
||||
player2: newPlayerNum === 2 ? currentSong : nextSong,
|
||||
player1,
|
||||
player2,
|
||||
previousSong,
|
||||
queueLength: queue.items.length,
|
||||
status: newStatus,
|
||||
@@ -1962,13 +2004,20 @@ export const usePlayerData = (): PlayerData => {
|
||||
nextSong = calculateNextSong(queueIndex, queue.items, repeat);
|
||||
}
|
||||
|
||||
const { player1, player2 } = getDualPlayerSongs(
|
||||
state.player.playerNum,
|
||||
currentSong,
|
||||
nextSong,
|
||||
repeat,
|
||||
);
|
||||
|
||||
return {
|
||||
currentSong,
|
||||
index: queueIndex, // Return the actual queue position for display
|
||||
nextSong,
|
||||
num: state.player.playerNum,
|
||||
player1: state.player.playerNum === 1 ? currentSong : nextSong,
|
||||
player2: state.player.playerNum === 2 ? currentSong : nextSong,
|
||||
player1,
|
||||
player2,
|
||||
previousSong,
|
||||
queueLength: state.queue.default.length,
|
||||
status: state.player.status,
|
||||
|
||||
@@ -675,9 +675,28 @@ const QueryBuilderSettingsSchema = z.object({
|
||||
tag: z.array(QueryBuilderCustomFieldSchema),
|
||||
});
|
||||
|
||||
export const AUTO_DJ_MODE = {
|
||||
ALBUMS: 'albums',
|
||||
SONGS: 'songs',
|
||||
} as const;
|
||||
|
||||
export type AutoDJMode = (typeof AUTO_DJ_MODE)[keyof typeof AUTO_DJ_MODE];
|
||||
|
||||
export const AUTO_DJ_STRATEGY = {
|
||||
LIBRARY_RANDOM: 'library_random',
|
||||
SIMILAR: 'similar',
|
||||
} as const;
|
||||
|
||||
export type AutoDJStrategy = (typeof AUTO_DJ_STRATEGY)[keyof typeof AUTO_DJ_STRATEGY];
|
||||
|
||||
const autoDjStrategyEnum = z.enum(['similar', 'library_random']);
|
||||
|
||||
const AutoDJSettingsSchema = z.object({
|
||||
albumStrategy: autoDjStrategyEnum,
|
||||
enabled: z.boolean(),
|
||||
itemCount: z.number(),
|
||||
mode: z.enum(['songs', 'albums']),
|
||||
songStrategy: autoDjStrategyEnum,
|
||||
timing: z.number(),
|
||||
});
|
||||
|
||||
@@ -1091,8 +1110,11 @@ const platformDefaultWindowBarStyle: Platform = getPlatformDefaultWindowBarStyle
|
||||
|
||||
const initialState: SettingsState = {
|
||||
autoDJ: {
|
||||
albumStrategy: AUTO_DJ_STRATEGY.SIMILAR,
|
||||
enabled: false,
|
||||
itemCount: 5,
|
||||
mode: 'songs',
|
||||
songStrategy: AUTO_DJ_STRATEGY.SIMILAR,
|
||||
timing: 1,
|
||||
},
|
||||
css: {
|
||||
@@ -2427,10 +2449,41 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 28) {
|
||||
if (!state.autoDJ) {
|
||||
state.autoDJ = { ...initialState.autoDJ };
|
||||
}
|
||||
|
||||
if (state.autoDJ.mode !== 'albums' && state.autoDJ.mode !== 'songs') {
|
||||
state.autoDJ.mode = initialState.autoDJ.mode;
|
||||
}
|
||||
|
||||
const normalizeAutoDjStrategy = (stored: unknown) => {
|
||||
if (stored === 'library_random') {
|
||||
return AUTO_DJ_STRATEGY.LIBRARY_RANDOM;
|
||||
}
|
||||
|
||||
if (
|
||||
stored === 'similar' ||
|
||||
stored === 'default' ||
|
||||
stored === 'similar_forward'
|
||||
) {
|
||||
return AUTO_DJ_STRATEGY.SIMILAR;
|
||||
}
|
||||
|
||||
return initialState.autoDJ.songStrategy;
|
||||
};
|
||||
|
||||
state.autoDJ.songStrategy = normalizeAutoDjStrategy(state.autoDJ.songStrategy);
|
||||
state.autoDJ.albumStrategy = normalizeAutoDjStrategy(
|
||||
state.autoDJ.albumStrategy,
|
||||
);
|
||||
}
|
||||
|
||||
return persistedState;
|
||||
},
|
||||
name: 'store_settings',
|
||||
version: 27,
|
||||
version: 28,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import { del, get, set } from 'idb-keyval';
|
||||
import { persist, subscribeWithSelector } from 'zustand/middleware';
|
||||
import { createWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
interface TimestampState {
|
||||
@@ -6,13 +7,36 @@ interface TimestampState {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const timestampStorage = {
|
||||
getItem: async (name: string) => {
|
||||
const value = await get(name);
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { state: { timestamp: value }, version: 1 } as const;
|
||||
},
|
||||
removeItem: async (name: string) => {
|
||||
await del(name);
|
||||
},
|
||||
setItem: async (name: string, value: { state: { timestamp: number }; version?: number }) => {
|
||||
await set(name, value.state.timestamp);
|
||||
},
|
||||
};
|
||||
|
||||
export const useTimestampStoreBase = createWithEqualityFn<TimestampState>()(
|
||||
subscribeWithSelector((set) => ({
|
||||
setTimestamp: (timestamp: number) => {
|
||||
set({ timestamp });
|
||||
persist(
|
||||
subscribeWithSelector((set) => ({
|
||||
setTimestamp: (timestamp: number) => {
|
||||
set({ timestamp });
|
||||
},
|
||||
timestamp: 0,
|
||||
})),
|
||||
{
|
||||
name: 'player-timestamp',
|
||||
storage: timestampStorage,
|
||||
version: 1,
|
||||
},
|
||||
timestamp: 0,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
export const subscribePlayerProgress = (
|
||||
|
||||
@@ -21,14 +21,14 @@ export const UpdateAvailableDialog = () => {
|
||||
useEffect(() => {
|
||||
if (!isElectron()) return;
|
||||
|
||||
const handleUpdateAvailable = (_event: any, newVersion: string) => {
|
||||
const handleUpdateAvailable = (newVersion: string) => {
|
||||
if (versionDismissed !== newVersion) {
|
||||
setVersion(newVersion);
|
||||
setOpened(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.api.ipc.on('update-available', handleUpdateAvailable);
|
||||
window.api.utils.rendererUpdateAvailable(handleUpdateAvailable);
|
||||
|
||||
return () => {
|
||||
window.api.ipc.removeListener?.('update-available', handleUpdateAvailable);
|
||||
|
||||
@@ -454,6 +454,7 @@ const similarSongs2 = z.object({
|
||||
export enum SubsonicExtensions {
|
||||
FORM_POST = 'formPost',
|
||||
INDEX_BASED_QUEUE = 'indexBasedQueue',
|
||||
PLAYBACK_REPORT = 'playbackReport',
|
||||
SONG_LYRICS = 'songLyrics',
|
||||
TRANSCODE_OFFSET = 'transcodeOffset',
|
||||
TRANSCODING = 'transcoding',
|
||||
@@ -793,6 +794,17 @@ const getInternetRadioStations = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const reportPlaybackParameters = z.object({
|
||||
ignoreScrobble: z.boolean().optional(),
|
||||
mediaId: z.string(),
|
||||
mediaType: z.enum(['song', 'podcast']),
|
||||
playbackRate: z.number().optional(),
|
||||
positionMs: z.number(),
|
||||
state: z.enum(['starting', 'playing', 'paused', 'stopped']),
|
||||
});
|
||||
|
||||
const reportPlayback = z.null();
|
||||
|
||||
export const ssType = {
|
||||
_body: {
|
||||
getTranscodeDecision: transcodeDecisionRequestBody,
|
||||
@@ -824,6 +836,7 @@ export const ssType = {
|
||||
getTranscodeStream: getTranscodeStreamParameters,
|
||||
randomSongList: randomSongListParameters,
|
||||
removeFavorite: removeFavoriteParameters,
|
||||
reportPlayback: reportPlaybackParameters,
|
||||
savePlayQueueByIndex: savePlayQueueByIndexParameters,
|
||||
saveQueue: saveQueueParameters,
|
||||
scrobble: scrobbleParameters,
|
||||
@@ -877,6 +890,7 @@ export const ssType = {
|
||||
playQueueByIndex,
|
||||
randomSongList,
|
||||
removeFavorite,
|
||||
reportPlayback,
|
||||
saveQueue,
|
||||
scrobble,
|
||||
search3,
|
||||
|
||||
@@ -89,18 +89,11 @@ export function BaseImage({
|
||||
const effectiveImageRequest =
|
||||
isInSessionCache || !enableDebounce ? rawImageRequest : debouncedImageRequest;
|
||||
|
||||
const imageCacheKey = src ?? rawImageRequest?.cacheKey;
|
||||
const trackLoadedInInstance = enableViewport;
|
||||
|
||||
const [hasLoadedInInstance, setHasLoadedInInstance] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!trackLoadedInInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasLoadedInInstance((current) => (current ? false : current));
|
||||
}, [imageCacheKey, trackLoadedInInstance]);
|
||||
setHasLoadedInInstance(false);
|
||||
}, [effectiveImageRequest?.cacheKey]);
|
||||
|
||||
const shouldLoadImage = Boolean(
|
||||
effectiveImageRequest &&
|
||||
@@ -119,13 +112,13 @@ export function BaseImage({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!trackLoadedInInstance || !nativeImage.isLoaded || !imageCacheKey) {
|
||||
if (!nativeImage.isLoaded || !effectiveImageRequest?.cacheKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadedImageCacheKeys.add(imageCacheKey);
|
||||
setHasLoadedInInstance((current) => (current ? current : true));
|
||||
}, [imageCacheKey, nativeImage.isLoaded, trackLoadedInInstance]);
|
||||
loadedImageCacheKeys.add(effectiveImageRequest.cacheKey);
|
||||
setHasLoadedInInstance(true);
|
||||
}, [effectiveImageRequest?.cacheKey, nativeImage.isLoaded]);
|
||||
|
||||
return (
|
||||
<ImageContainer
|
||||
|
||||
@@ -26,11 +26,8 @@ export function useNativeImage({
|
||||
const loadedRequestSignatureRef = useRef<null | string>(null);
|
||||
const objectUrlRef = useRef<null | string>(null);
|
||||
const onFetchErrorRef = useRef(onFetchError);
|
||||
const requestRef = useRef(request);
|
||||
const [state, setState] = useState<NativeImageState>({ status: 'idle' });
|
||||
|
||||
requestRef.current = request;
|
||||
|
||||
const requestSignature = useMemo(() => {
|
||||
if (!request) {
|
||||
return null;
|
||||
@@ -47,8 +44,6 @@ export function useNativeImage({
|
||||
onFetchErrorRef.current = onFetchError;
|
||||
|
||||
useEffect(() => {
|
||||
const request = requestRef.current;
|
||||
|
||||
const abortCurrentRequest = () => {
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = null;
|
||||
@@ -67,61 +62,28 @@ export function useNativeImage({
|
||||
if (!request || !requestSignature) {
|
||||
abortCurrentRequest();
|
||||
revokeObjectUrl();
|
||||
setState((currentState) => {
|
||||
if (currentState.status === 'idle' && !currentState.displaySrc) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return { status: 'idle' };
|
||||
});
|
||||
setState({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
abortCurrentRequest();
|
||||
setState((currentState) => {
|
||||
if (currentState.displaySrc) {
|
||||
if (currentState.status === 'loaded') {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return { ...currentState, status: 'loaded' };
|
||||
}
|
||||
|
||||
if (currentState.status === 'idle' && !currentState.displaySrc) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return { status: 'idle' };
|
||||
});
|
||||
setState((currentState) =>
|
||||
currentState.displaySrc
|
||||
? { ...currentState, status: 'loaded' }
|
||||
: { status: 'idle' },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadedRequestSignatureRef.current === requestSignature && objectUrlRef.current) {
|
||||
const cachedObjectUrl = objectUrlRef.current;
|
||||
|
||||
setState((currentState) => {
|
||||
if (
|
||||
currentState.status === 'loaded' &&
|
||||
currentState.displaySrc === cachedObjectUrl
|
||||
) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return { displaySrc: cachedObjectUrl, status: 'loaded' };
|
||||
});
|
||||
setState({ displaySrc: objectUrlRef.current, status: 'loaded' });
|
||||
return;
|
||||
}
|
||||
|
||||
abortCurrentRequest();
|
||||
revokeObjectUrl();
|
||||
setState((currentState) => {
|
||||
if (currentState.status === 'loading' && !currentState.displaySrc) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return { status: 'loading' };
|
||||
});
|
||||
setState({ status: 'loading' });
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
@@ -176,7 +138,7 @@ export function useNativeImage({
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, fetchPriority, requestSignature]);
|
||||
}, [enabled, fetchPriority, request, requestSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -1365,6 +1365,8 @@ export type ScrobbleQuery = {
|
||||
albumId?: string;
|
||||
event?: 'pause' | 'start' | 'timeupdate' | 'unpause';
|
||||
id: string;
|
||||
mediaType: 'podcast' | 'song';
|
||||
playbackRate: number;
|
||||
position?: number;
|
||||
submission: boolean;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum ServerFeature {
|
||||
PLAYLIST_IMAGE_UPLOAD = 'playlistImageUpload',
|
||||
PLAYLISTS_SMART = 'playlistsSmart',
|
||||
PUBLIC_PLAYLIST = 'publicPlaylist',
|
||||
REPORT_PLAYBACK = 'reportPlayback',
|
||||
SERVER_PLAY_QUEUE = 'serverPlayQueue',
|
||||
SHARING_ALBUM_SONG = 'sharingAlbumSong',
|
||||
SIMILAR_SONGS_MUSIC_FOLDER = 'similarSongsMusicFolder',
|
||||
|
||||
Reference in New Issue
Block a user