Compare commits

..

21 Commits

Author SHA1 Message Date
jeffvli 18b62ae22b cap scrobble status at max values 2026-05-25 13:07:44 -07:00
jeffvli 56a552f893 fix playerbar slider styles
- left / right values should be consistent width
2026-05-25 12:59:07 -07:00
jeffvli d11c3fa58c handle positional scrobbles for OS 2026-05-25 12:23:08 -07:00
jeffvli 54f181c542 set ignoreScrobble, playbackRate as optional params 2026-05-25 11:59:21 -07:00
jeffvli 1bf51d1d72 add reportPlayback as default Jellyfin feature 2026-05-25 11:58:01 -07:00
jeffvli 37df94bd3b add playbackReport handler to Subsonic controller 2026-05-25 11:56:00 -07:00
jeffvli dd60499185 add reportPlayback server feature 2026-05-25 11:55:41 -07:00
jeffvli 009732e745 add OS reportPlayback endpoint and types 2026-05-25 11:43:50 -07:00
Hosted Weblate 0de1e1aa3e Translated using Weblate
Currently translated at 26.0% (320 of 1230 strings) (Japanese)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ja/

Translated using Weblate

Currently translated at 98.6% (1213 of 1230 strings) (Catalan)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/

Translated using Weblate

Currently translated at 73.8% (908 of 1230 strings) (Russian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ru/

Co-authored-by: Napster2k <Napster2k@gmail.com>
Co-authored-by: Ondo <SparkyOndo@proton.me>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
2026-05-22 11:11:40 +00:00
jeffvli 6528859ea9 adjust ItemDetail row play control alignment to match ItemTable 2026-05-22 03:43:48 -07:00
jeffvli 704745eb7c fix item table crashing with row play controls
- hovercard causes issues with the image component as the component is mounted/unmounted within the portal
2026-05-22 03:28:37 -07:00
jeffvli 2759cfee97 Revert "fix NativeImage state causing crashes in table"
This reverts commit 4008c8dfdf.
2026-05-22 01:56:50 -07:00
jeffvli 755f0aab9d fix mediasession breaking on player repeat (#1472)
- switch to single web player instance for loop instead of dual-player
- this fixes the issue, but does have a breaking change if using the crossfade player
2026-05-22 01:32:22 -07:00
jeffvli 0e163543fc update lockfile for electron v41 2026-05-21 11:30:09 -07:00
jeffvli fad2534d44 update to electron v41 in deps 2026-05-21 11:28:13 -07:00
jeffvli edfc64d790 update to electron v41 (#2047) 2026-05-21 11:20:55 -07:00
Tiago Simionato 8f40894926 feat: persist player timestamp (#2043)
* feat: persist player timestamp
2026-05-21 00:16:46 -07:00
Kendall Garner 2befcb4e74 disable node integration (#2049) 2026-05-20 21:06:28 -07:00
jeffvli 2d78c32a68 prevent track radio from prepending song in queue
- this behavior relates to when using NEXT or LAST as this will add a duplicate song
2026-05-20 20:49:29 -07:00
jeffvli 3d0500980a implement row playback controls for the detail list 2026-05-20 20:40:30 -07:00
jeffvli 3551ee5077 prevent desktopCaturer from running unless visualizer explicitly requested (#1931) 2026-05-20 20:34:25 -07:00
47 changed files with 836 additions and 260 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
appId: org.jeffvli.feishin appId: org.jeffvli.feishin
productName: Feishin productName: Feishin
artifactName: ${productName}-${version}-${os}-${arch}.${ext} artifactName: ${productName}-${version}-${os}-${arch}.${ext}
electronVersion: 39.4.0 electronVersion: 41.7.0
directories: directories:
buildResources: assets buildResources: assets
files: files:
@@ -47,7 +47,7 @@ mac:
gatekeeperAssess: false gatekeeperAssess: false
notarize: false notarize: false
extendInfo: 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' NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg: dmg:
+2 -2
View File
@@ -1,7 +1,7 @@
appId: org.jeffvli.feishin appId: org.jeffvli.feishin
productName: Feishin productName: Feishin
artifactName: ${productName}-${version}-${os}-${arch}.${ext} artifactName: ${productName}-${version}-${os}-${arch}.${ext}
electronVersion: 39.4.0 electronVersion: 41.7.0
directories: directories:
buildResources: assets buildResources: assets
files: files:
@@ -47,7 +47,7 @@ mac:
gatekeeperAssess: false gatekeeperAssess: false
notarize: false notarize: false
extendInfo: 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' NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg: dmg:
+1 -1
View File
@@ -1,7 +1,7 @@
appId: org.jeffvli.feishin appId: org.jeffvli.feishin
productName: Feishin productName: Feishin
artifactName: ${productName}-${version}-${os}-${arch}.${ext} artifactName: ${productName}-${version}-${os}-${arch}.${ext}
electronVersion: 39.4.0 electronVersion: 41.7.0
directories: directories:
buildResources: assets buildResources: assets
files: files:
+1 -1
View File
@@ -160,7 +160,7 @@
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"electron": "^39.8.6", "electron": "^41.7.0",
"electron-builder": "^26.8.2", "electron-builder": "^26.8.2",
"electron-devtools-installer": "^4.0.0", "electron-devtools-installer": "^4.0.0",
"electron-vite": "^4.0.1", "electron-vite": "^4.0.1",
+13 -25
View File
@@ -23,10 +23,10 @@ importers:
version: 1.1.0 version: 1.1.0
'@electron-toolkit/preload': '@electron-toolkit/preload':
specifier: ^3.0.2 specifier: ^3.0.2
version: 3.0.2(electron@39.8.6) version: 3.0.2(electron@41.7.0)
'@electron-toolkit/utils': '@electron-toolkit/utils':
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.0(electron@39.8.6) version: 4.0.0(electron@41.7.0)
'@mantine/colors-generator': '@mantine/colors-generator':
specifier: ^9.1.1 specifier: ^9.1.1
version: 9.1.1(chroma-js@3.1.2) version: 9.1.1(chroma-js@3.1.2)
@@ -269,8 +269,8 @@ importers:
specifier: ^10.1.0 specifier: ^10.1.0
version: 10.1.0 version: 10.1.0
electron: electron:
specifier: ^39.8.6 specifier: ^41.7.0
version: 39.8.6 version: 41.7.0
electron-builder: electron-builder:
specifier: ^26.8.2 specifier: ^26.8.2
version: 26.8.2(electron-builder-squirrel-windows@26.8.2) version: 26.8.2(electron-builder-squirrel-windows@26.8.2)
@@ -2126,9 +2126,6 @@ packages:
'@types/ms@2.1.0': '@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} 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': '@types/node@24.12.2':
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
@@ -3001,8 +2998,8 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
electron@39.8.6: electron@41.7.0:
resolution: {integrity: sha512-uWX6Jh5LmwL13VwOSKBjebI+ck+03GOwc8V2Sgbmr9pJVJ/cHfli/PkjXuRDr+hq+SLHQuT9mGHSIfScebApRA==} resolution: {integrity: sha512-U6KAKivjk6YQ0Z5+eloJBjwhbHRE206gvy1UBMw2bSluWtMh5waeXMvX6AT/Ujm5ymYXVJOp7g9N7vOFw16wBQ==}
engines: {node: '>= 12.20.55'} engines: {node: '>= 12.20.55'}
hasBin: true hasBin: true
@@ -5507,9 +5504,6 @@ packages:
underscore.string@3.3.6: underscore.string@3.3.6:
resolution: {integrity: sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==} resolution: {integrity: sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.16.0: undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@@ -6684,17 +6678,17 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@electron-toolkit/preload@3.0.2(electron@39.8.6)': '@electron-toolkit/preload@3.0.2(electron@41.7.0)':
dependencies: dependencies:
electron: 39.8.6 electron: 41.7.0
'@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)': '@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)':
dependencies: dependencies:
'@types/node': 24.12.2 '@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: dependencies:
electron: 39.8.6 electron: 41.7.0
'@electron/asar@3.4.1': '@electron/asar@3.4.1':
dependencies: dependencies:
@@ -7675,7 +7669,7 @@ snapshots:
'@types/electron-localshortcut@3.1.3': '@types/electron-localshortcut@3.1.3':
dependencies: dependencies:
electron: 39.8.6 electron: 41.7.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -7703,10 +7697,6 @@ snapshots:
'@types/ms@2.1.0': {} '@types/ms@2.1.0': {}
'@types/node@22.19.17':
dependencies:
undici-types: 6.21.0
'@types/node@24.12.2': '@types/node@24.12.2':
dependencies: dependencies:
undici-types: 7.16.0 undici-types: 7.16.0
@@ -8800,10 +8790,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
electron@39.8.6: electron@41.7.0:
dependencies: dependencies:
'@electron/get': 2.0.3 '@electron/get': 2.0.3
'@types/node': 22.19.17 '@types/node': 24.12.2
extract-zip: 2.0.1 extract-zip: 2.0.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -11588,8 +11578,6 @@ snapshots:
sprintf-js: 1.1.3 sprintf-js: 1.1.3
util-deprecate: 1.0.2 util-deprecate: 1.0.2
undici-types@6.21.0: {}
undici-types@7.16.0: {} undici-types@7.16.0: {}
undici@6.24.1: {} undici@6.24.1: {}
+42 -20
View File
@@ -1,7 +1,7 @@
{ {
"page": { "page": {
"sidebar": { "sidebar": {
"myLibrary": "La meva llibreria", "myLibrary": "La meva mediateca",
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})", "albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
"albums": "$t(entity.album, {\"count\": 2})", "albums": "$t(entity.album, {\"count\": 2})",
"artists": "$t(entity.artist, {\"count\": 2})", "artists": "$t(entity.artist, {\"count\": 2})",
@@ -340,7 +340,8 @@
"filter_multiple": "Multi", "filter_multiple": "Multi",
"rename": "Reanomena", "rename": "Reanomena",
"newVersionAvailable": "Hi ha una nova versió disponible", "newVersionAvailable": "Hi ha una nova versió disponible",
"numberOfResults": "{{numberOfResults}} resultats" "numberOfResults": "{{numberOfResults}} resultats",
"back": "Enrere"
}, },
"entity": { "entity": {
"album_one": "Àlbum", "album_one": "Àlbum",
@@ -557,7 +558,9 @@
"selectRangeOfItems": "Selecciona un interval d'elements", "selectRangeOfItems": "Selecciona un interval d'elements",
"selectAll": "Selecciona-ho tot", "selectAll": "Selecciona-ho tot",
"openApplicationDirectory": "Obre el directori de l'aplicació", "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": { "setting": {
"language_description": "Estableix l'idioma de l'aplicació ($t(common.restartRequired))", "language_description": "Estableix l'idioma de l'aplicació ($t(common.restartRequired))",
@@ -921,8 +924,8 @@
"primaryShade_description": "Substitueix el to primari (09) utilitzat per a botons, enllaços i altres elements de color primari", "primaryShade_description": "Substitueix el to primari (09) 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_description": "Configurar quins elements es mostren i en quin ordre al reproductor de pantalla completa",
"playerItemConfiguration": "Configuració d'elements del jugador", "playerItemConfiguration": "Configuració d'elements del jugador",
"listenbrainz_description": "mostra enllaços a ListenBrainz a les pàgines d'artista/àlbum", "listenbrainz_description": "Mostra enllaços a ListenBrainz a les pàgines d'artista/àlbum",
"listenbrainz": "mostra enllaços a ListenBrainz", "listenbrainz": "Mostra enllaços a ListenBrainz",
"qobuz_description": "Mostra enllaços a Qobuz a les pàgines d'artista/àlbum", "qobuz_description": "Mostra enllaços a Qobuz a les pàgines d'artista/àlbum",
"qobuz": "Mostra enllaços a Qobuz", "qobuz": "Mostra enllaços a Qobuz",
"spotify_description": "Mostra enllaços a Spotify a les pàgines d'artista/àlbum", "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_description": "Obre amb Spotify en lloc del vostre navegador",
"nativeSpotify": "Fes servir Spotify", "nativeSpotify": "Fes servir Spotify",
"playerbarWaveformStretch": "Extensió de la forma d'ona", "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": "Disposició de la cua de reproducció lateral",
"sidePlayQueueLayout_description": "estableix la disposició de la cua de reproducció lateral adjunta", "sidePlayQueueLayout_description": "Estableix la disposició de la cua de reproducció lateral adjunta",
"sidePlayQueueLayout_optionHorizontal": "horitzontal", "sidePlayQueueLayout_optionHorizontal": "Horitzontal",
"sidePlayQueueLayout_optionVertical": "vertical", "sidePlayQueueLayout_optionVertical": "Vertical",
"waveformLoadingDelay": "Demora de càrrega de la forma d'ona", "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.", "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_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": { "table": {
"column": { "column": {
"albumCount": "$t(entity.album, {\"count\": 2})", "albumCount": "Àlbums",
"artist": "$t(entity.artist, {\"count\": 1})", "artist": "Artista",
"channels": "$t(common.channel, {\"count\": 2})", "channels": "Canals",
"codec": "$t(common.codec)", "codec": "dec",
"genre": "$t(entity.genre, {\"count\": 1})", "genre": "Gènere",
"size": "$t(common.size)", "size": "Mida",
"songCount": "$t(entity.track, {\"count\": 2})", "songCount": "Pistes",
"releaseYear": "Any", "releaseYear": "Any",
"playCount": "Reproduccions", "playCount": "Reproduccions",
"releaseDate": "Data de llançament", "releaseDate": "Data de llançament",
@@ -966,8 +987,8 @@
"path": "Ruta", "path": "Ruta",
"rating": "Qualificació", "rating": "Qualificació",
"title": "Títol", "title": "Títol",
"bitDepth": "$t(common.bitDepth)", "bitDepth": "Profunditat de bits",
"sampleRate": "$t(common.sampleRate)", "sampleRate": "Freqüència de mostreig",
"owner": "Propietari" "owner": "Propietari"
}, },
"config": { "config": {
@@ -1146,7 +1167,8 @@
"sleepTimer_timeRemaining": "Queden {{time}}", "sleepTimer_timeRemaining": "Queden {{time}}",
"sleepTimer_setCustom": "Configura el temporitzador", "sleepTimer_setCustom": "Configura el temporitzador",
"sleepTimer_cancel": "Cancel·la el temporitzador", "sleepTimer_cancel": "Cancel·la el temporitzador",
"albumRadio": "Ràdio d'àlbums" "albumRadio": "Ràdio d'àlbums",
"scrobbleForceSubmit": "Força l'scrobble"
}, },
"error": { "error": {
"credentialsRequired": "Credencials requerides", "credentialsRequired": "Credencials requerides",
+19 -14
View File
@@ -48,7 +48,8 @@
"holdToShuffle": "長押しでシャッフル", "holdToShuffle": "長押しでシャッフル",
"albumRadio": "アルバム・ラジオ", "albumRadio": "アルバム・ラジオ",
"artistRadio": "アーティストラジオ", "artistRadio": "アーティストラジオ",
"trackRadio": "ラジオを追跡する" "trackRadio": "ラジオを追跡する",
"scrobbleForceSubmit": "強制 Scrobble"
}, },
"setting": { "setting": {
"crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します", "crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します",
@@ -429,7 +430,8 @@
"playerbarWaveformStretch": "波形伸縮", "playerbarWaveformStretch": "波形伸縮",
"playerbarWaveformStretch_description": "波形を伸縮させて、利用可能なスペースを埋めます", "playerbarWaveformStretch_description": "波形を伸縮させて、利用可能なスペースを埋めます",
"preventSuspendOnPlayback_description": "音楽再生中にアプリケーションが停止しないようにします", "preventSuspendOnPlayback_description": "音楽再生中にアプリケーションが停止しないようにします",
"preventSuspendOnPlayback": "再生の中断を防止する" "preventSuspendOnPlayback": "再生の中断を防止する",
"hotkey_listShowPlayingSong": "再生中の曲をリストに表示"
}, },
"action": { "action": {
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集", "editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
@@ -473,7 +475,9 @@
"openApplicationDirectory": "アプリケーションディレクトリを開く", "openApplicationDirectory": "アプリケーションディレクトリを開く",
"selectRangeOfItems": "項目の範囲を選択", "selectRangeOfItems": "項目の範囲を選択",
"addOrRemoveFromSelection": "選択に追加または削除", "addOrRemoveFromSelection": "選択に追加または削除",
"goToCurrent": "現在の項目へ移動" "goToCurrent": "現在の項目へ移動",
"collapseAllFolders": "すべてのフォルダーを折りたたむ",
"expandAllFolders": "すべてのフォルダーを展開する"
}, },
"common": { "common": {
"backward": "戻る", "backward": "戻る",
@@ -594,7 +598,8 @@
"rename": "名前を変更", "rename": "名前を変更",
"newVersionAvailable": "新しいバージョンが利用可能です", "newVersionAvailable": "新しいバージョンが利用可能です",
"numberOfResults": "{{numberOfResults}} 件の結果", "numberOfResults": "{{numberOfResults}} 件の結果",
"grouping": "グループ化" "grouping": "グループ化",
"back": "戻る"
}, },
"table": { "table": {
"config": { "config": {
@@ -643,7 +648,7 @@
"titleCombined": "$t(common.title) (結合)", "titleCombined": "$t(common.title) (結合)",
"dateAdded": "追加日", "dateAdded": "追加日",
"size": "$t(common.size)", "size": "$t(common.size)",
"bpm": "$t(common.BPM)", "bpm": "$t(common.bpm)",
"lastPlayed": "最後に再生", "lastPlayed": "最後に再生",
"trackNumber": "トラック番号", "trackNumber": "トラック番号",
"rowIndex": "行インデックス", "rowIndex": "行インデックス",
@@ -681,7 +686,7 @@
"rating": "評価", "rating": "評価",
"favorite": "お気に入り", "favorite": "お気に入り",
"playCount": "再生回数", "playCount": "再生回数",
"albumCount": "$t(entity.album, {\"count\": 2})", "albumCount": "アルバム",
"releaseYear": "年", "releaseYear": "年",
"lastPlayed": "最後に再生", "lastPlayed": "最後に再生",
"biography": "バイオグラフィー", "biography": "バイオグラフィー",
@@ -690,18 +695,18 @@
"title": "タイトル", "title": "タイトル",
"bpm": "BPM", "bpm": "BPM",
"dateAdded": "追加日", "dateAdded": "追加日",
"artist": "$t(entity.artist, {\"count\": 1})", "artist": "アーティスト",
"songCount": "$t(entity.track, {\"count\": 2})", "songCount": "トラック",
"trackNumber": "トラック", "trackNumber": "トラック",
"genre": "$t(entity.genre, {\"count\": 1})", "genre": "ジャンル",
"albumArtist": "アルバムアーティスト", "albumArtist": "アルバムアーティスト",
"path": "パス", "path": "パス",
"discNumber": "ディスク", "discNumber": "ディスク",
"channels": "$t(common.channel, {\"count\": 2})", "channels": "チャンネル",
"size": "$t(common.size)", "size": "サイズ",
"codec": "$t(common.codec)", "codec": "コーデック",
"bitDepth": "$t(common.bitDepth)", "bitDepth": "ビット深度",
"sampleRate": "$t(common.sampleRate)", "sampleRate": "サンプリング周波数",
"owner": "所有者" "owner": "所有者"
} }
}, },
+13 -6
View File
@@ -41,7 +41,9 @@
"viewMore": "Посмотреть больше", "viewMore": "Посмотреть больше",
"openApplicationDirectory": "Открыть папку приложения", "openApplicationDirectory": "Открыть папку приложения",
"selectRangeOfItems": "Выбрать диапазон элементов", "selectRangeOfItems": "Выбрать диапазон элементов",
"goToCurrent": "Перейти к текущему элементу" "goToCurrent": "Перейти к текущему элементу",
"collapseAllFolders": "Свернуть все папки",
"expandAllFolders": "Развернуть все папки"
}, },
"common": { "common": {
"backward": "Назад", "backward": "Назад",
@@ -171,7 +173,8 @@
"externalLinks": "Внешние ссылки", "externalLinks": "Внешние ссылки",
"explicitStatus": "Признак нецензурного контента", "explicitStatus": "Признак нецензурного контента",
"newVersionAvailable": "Доступна новая версия", "newVersionAvailable": "Доступна новая версия",
"numberOfResults": "{{numberOfResults}} результатов" "numberOfResults": "{{numberOfResults}} результатов",
"back": "Назад"
}, },
"entity": { "entity": {
"album_one": "Альбом", "album_one": "Альбом",
@@ -430,7 +433,8 @@
"sleepTimer_timeRemaining": "{{time}} осталось", "sleepTimer_timeRemaining": "{{time}} осталось",
"sleepTimer_setCustom": "Установить таймер", "sleepTimer_setCustom": "Установить таймер",
"sleepTimer_custom": "Пользовательский", "sleepTimer_custom": "Пользовательский",
"sleepTimer_cancel": "Отменить таймер" "sleepTimer_cancel": "Отменить таймер",
"scrobbleForceSubmit": "Принудительная скробблинг"
}, },
"page": { "page": {
"sidebar": { "sidebar": {
@@ -491,7 +495,8 @@
"selectMusicFolder": "Выбрать папку с музыкой", "selectMusicFolder": "Выбрать папку с музыкой",
"noMusicFolder": "Папка с музыкой не выбрана", "noMusicFolder": "Папка с музыкой не выбрана",
"multipleMusicFolders": "{{count}} выбрано музыкальных папок", "multipleMusicFolders": "{{count}} выбрано музыкальных папок",
"commandPalette": "открыть командную строку" "commandPalette": "открыть командную строку",
"settings": "$t(common.setting, {\"count\": 2})"
}, },
"manageServers": { "manageServers": {
"title": "Сервера", "title": "Сервера",
@@ -569,7 +574,8 @@
}, },
"genreList": { "genreList": {
"showAlbums": "показать $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})", "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": { "trackList": {
"artistTracks": "Треки {{artist}}", "artistTracks": "Треки {{artist}}",
@@ -681,7 +687,8 @@
"input_skipDuplicates": "не добавлять дубликаты", "input_skipDuplicates": "не добавлять дубликаты",
"create": "создать $t(entity.playlist, {\"count\": 1}) {{playlist}}", "create": "создать $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "для создания нового списка выполните поиск по $t(entity.playlist, {\"count\": 2}) или введите соответствующий текст", "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": { "updateServer": {
"title": "Обновление сервера", "title": "Обновление сервера",
+1
View File
@@ -4,3 +4,4 @@ import './player';
import './remote'; import './remote';
import './settings'; import './settings';
import './discord-rpc'; import './discord-rpc';
import './visualizer';
@@ -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));
});
+8 -2
View File
@@ -30,7 +30,9 @@ import packageJson from '../../package.json';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys'; import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { shutdownServer } from './features/core/remote'; import { shutdownServer } from './features/core/remote';
import { store } from './features/core/settings'; import { store } from './features/core/settings';
import { canHandleVisualizerDisplayMedia } from './features/core/visualizer';
import MenuBuilder, { MenuPlaybackState } from './menu'; import MenuBuilder, { MenuPlaybackState } from './menu';
import './features';
import { import {
autoUpdaterLogInterface, autoUpdaterLogInterface,
createLog, createLog,
@@ -40,7 +42,6 @@ import {
isMacOS, isMacOS,
isWindows, isWindows,
} from './utils'; } from './utils';
import './features';
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types'; import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
@@ -515,7 +516,7 @@ async function createWindow(first = true): Promise<void> {
backgroundThrottling: false, backgroundThrottling: false,
contextIsolation: true, contextIsolation: true,
devTools: true, devTools: true,
nodeIntegration: true, nodeIntegration: false,
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, '../preload/index.js'),
sandbox: false, sandbox: false,
webSecurity: !store.get('ignore_cors'), webSecurity: !store.get('ignore_cors'),
@@ -734,6 +735,11 @@ async function createWindow(first = true): Promise<void> {
}); });
mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => { mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
if (!canHandleVisualizerDisplayMedia()) {
callback({});
return;
}
if (!isMacOS()) { if (!isMacOS()) {
callback({ audio: 'loopback' }); callback({ audio: 'loopback' });
return; return;
-3
View File
@@ -1,11 +1,8 @@
import { ElectronAPI } from '@electron-toolkit/preload';
import { PreloadApi } from './index'; import { PreloadApi } from './index';
declare global { declare global {
interface Window { interface Window {
api: PreloadApi; api: PreloadApi;
electron: ElectronAPI;
LEGACY_AUTHENTICATION?: boolean; LEGACY_AUTHENTICATION?: boolean;
queryLocalFonts?: () => Promise<Font[]>; queryLocalFonts?: () => Promise<Font[]>;
REMOTE_URL?: string; REMOTE_URL?: string;
+4 -5
View File
@@ -1,5 +1,4 @@
import { electronAPI } from '@electron-toolkit/preload'; import { contextBridge, webUtils } from 'electron';
import { contextBridge } from 'electron';
import { autodiscover } from './autodiscover'; import { autodiscover } from './autodiscover';
import { browser } from './browser'; import { browser } from './browser';
@@ -11,12 +10,14 @@ import { mpris } from './mpris';
import { mpvPlayer, mpvPlayerListener } from './mpv-player'; import { mpvPlayer, mpvPlayerListener } from './mpv-player';
import { remote } from './remote'; import { remote } from './remote';
import { utils } from './utils'; import { utils } from './utils';
import { visualizer } from './visualizer';
// Custom APIs for renderer // Custom APIs for renderer
const api = { const api = {
autodiscover, autodiscover,
browser, browser,
discordRpc, discordRpc,
getPathForFile: webUtils.getPathForFile,
ipc, ipc,
localSettings, localSettings,
lyrics, lyrics,
@@ -25,6 +26,7 @@ const api = {
mpvPlayerListener, mpvPlayerListener,
remote, remote,
utils, utils,
visualizer,
}; };
export type PreloadApi = typeof api; export type PreloadApi = typeof api;
@@ -34,14 +36,11 @@ export type PreloadApi = typeof api;
// just add to the DOM global. // just add to the DOM global.
if (process.contextIsolated) { if (process.contextIsolated) {
try { try {
contextBridge.exposeInMainWorld('electron', electronAPI);
contextBridge.exposeInMainWorld('api', api); contextBridge.exposeInMainWorld('api', api);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
} else { } else {
// @ts-ignore (define in dts)
window.electron = electronAPI;
// @ts-ignore (define in dts) // @ts-ignore (define in dts)
window.api = api; window.api = api;
} }
+11
View File
@@ -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'); throw new Error('Failed to get server info');
} }
const defaultFeatures = {}; const defaultFeatures = {
[ServerFeature.REPORT_PLAYBACK]: [1],
};
const features = { const features = {
...defaultFeatures, ...defaultFeatures,
@@ -289,6 +289,14 @@ export const contract = c.router({
200: ssType._response.removeFavorite, 200: ssType._response.removeFavorite,
}, },
}, },
reportPlayback: {
method: 'GET',
path: 'reportPlayback.view',
query: ssType._parameters.reportPlayback,
responses: {
200: ssType._response.reportPlayback,
},
},
savePlayQueue: { savePlayQueue: {
method: 'GET', method: 'GET',
path: 'savePlayQueue.view', path: 'savePlayQueue.view',
@@ -1460,6 +1460,10 @@ export const SubsonicController: InternalControllerEndpoint = {
features.serverPlayQueue = [1]; features.serverPlayQueue = [1];
} }
if (subsonicFeatures[SubsonicExtensions.PLAYBACK_REPORT]) {
features.reportPlayback = [1];
}
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion }; return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
}, },
getSimilarSongs: async (args) => { getSimilarSongs: async (args) => {
@@ -2298,6 +2302,57 @@ export const SubsonicController: InternalControllerEndpoint = {
scrobble: async (args) => { scrobble: async (args) => {
const { apiClientProps, query } = 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({ const res = await ssApiClient(apiClientProps).scrobble({
query: { query: {
id: query.id, id: query.id,
@@ -27,6 +27,7 @@ import { PlayCountColumn } from './play-count-column';
import { RatingColumn } from './rating-column'; import { RatingColumn } from './rating-column';
import { ReleaseDateColumn } from './release-date-column'; import { ReleaseDateColumn } from './release-date-column';
import { RowIndexColumn } from './row-index-column'; import { RowIndexColumn } from './row-index-column';
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
import { SampleRateColumn } from './sample-rate-column'; import { SampleRateColumn } from './sample-rate-column';
import { SizeColumn } from './size-column'; import { SizeColumn } from './size-column';
import { TitleArtistColumn } from './title-artist-column'; import { TitleArtistColumn } from './title-artist-column';
@@ -111,6 +112,7 @@ export {
GenreBadgeColumn, GenreBadgeColumn,
GenreColumn, GenreColumn,
ImageColumn, ImageColumn,
ItemDetailRowPlayControlCell,
LastPlayedColumn, LastPlayedColumn,
PathColumn, PathColumn,
PlayCountColumn, PlayCountColumn,
@@ -1,23 +1,40 @@
import styles from './row-index-column.module.css'; import styles from './row-index-column.module.css';
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
import { ItemDetailListCellProps } from './types'; import { ItemDetailListCellProps } from './types';
import { useDetailRowPlayControl } from './use-detail-row-play-control';
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song'; import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
import { usePlayerStatus } from '/@/renderer/store';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { PlayerStatus } from '/@/shared/types/types'; import { TableColumn } 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>
);
}
const DefaultRowIndexColumn = ({ rowIndex }: ItemDetailListCellProps) => {
return <>{String((rowIndex ?? 0) + 1)}</>; 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} />;
};
@@ -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 { 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 disc = song.discNumber ?? 1;
const track = song.trackNumber.toString().padStart(2, '0'); const track = song.trackNumber.toString().padStart(2, '0');
return `${disc}-${track}`; 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 { 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'; import { Song } from '/@/shared/types/domain-types';
export interface ItemDetailListCellProps { export interface ItemDetailListCellProps {
columns?: ItemTableListColumnConfig[];
controls?: ItemControls; controls?: ItemControls;
internalState?: ItemListStateActions; internalState?: ItemListStateActions;
isMutatingFavorite?: boolean; isMutatingFavorite?: boolean;
@@ -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; padding-left: 0;
} }
.row .track-cell-play-control {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
}
.track-row-dragging { .track-row-dragging {
opacity: 0.5; 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 { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
import { ItemImage } from '/@/renderer/components/item-image/item-image'; import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items'; 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 { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import { import {
ItemListStateActions, ItemListStateActions,
@@ -365,6 +366,7 @@ const TrackRow = memo(
const isTitleColumn = col.id === TableColumn.TITLE; const isTitleColumn = col.id === TableColumn.TITLE;
const isImageColumn = col.id === TableColumn.IMAGE; const isImageColumn = col.id === TableColumn.IMAGE;
const isIconActionColumn = isNoHorizontalPaddingColumn(col.id); const isIconActionColumn = isNoHorizontalPaddingColumn(col.id);
const isPlayControlColumn = isRowPlayControlColumn(col.id, columns);
const showHoverContent = shouldShowHoverOnlyColumnContent( const showHoverContent = shouldShowHoverOnlyColumnContent(
col.id, col.id,
isRowHovered, isRowHovered,
@@ -374,6 +376,7 @@ const TrackRow = memo(
const content = isSongsLoading ? null : showHoverContent ? ( const content = isSongsLoading ? null : showHoverContent ? (
<CellComponent <CellComponent
columnId={col.id} columnId={col.id}
columns={columns}
controls={controls} controls={controls}
internalState={internalState} internalState={internalState}
isMutatingFavorite={isMutatingFavorite} isMutatingFavorite={isMutatingFavorite}
@@ -393,6 +396,7 @@ const TrackRow = memo(
[styles.trackCellImage]: isImageColumn, [styles.trackCellImage]: isImageColumn,
[styles.trackCellMuted]: !isTitleColumn, [styles.trackCellMuted]: !isTitleColumn,
[styles.trackCellNoHPadding]: isIconActionColumn, [styles.trackCellNoHPadding]: isIconActionColumn,
[styles.trackCellPlayControl]: isPlayControlColumn,
[styles.trackCellVerticalBorderVisible]: [styles.trackCellVerticalBorderVisible]:
enableVerticalBorders && !isLastColumn, enableVerticalBorders && !isLastColumn,
[styles.trackCellWithVerticalBorder]: !isLastColumn, [styles.trackCellWithVerticalBorder]: !isLastColumn,
@@ -88,7 +88,7 @@ export const RowPlayControlCell = (
<TableColumnContainer {...props} className={styles.expansionCell}> <TableColumnContainer {...props} className={styles.expansionCell}>
<div className={styles.expansionInner}> <div className={styles.expansionInner}>
{showPlayControls ? ( {showPlayControls ? (
<HoverCard openDelay={300} position="top" withArrow> <HoverCard openDelay={300} position="top" withArrow withinPortal={false}>
<HoverCard.Target>{expansionTarget}</HoverCard.Target> <HoverCard.Target>{expansionTarget}</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}> <HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<ItemRowPlayControls onPlay={onPlay} /> <ItemRowPlayControls onPlay={onPlay} />
@@ -110,7 +110,7 @@ export const RowPlayControlCell = (
return ( return (
<TableColumnTextContainer {...props} className={styles.fullSizeContent}> <TableColumnTextContainer {...props} className={styles.fullSizeContent}>
<HoverCard openDelay={300} position="top" withArrow> <HoverCard openDelay={300} position="top" withArrow withinPortal={false}>
<HoverCard.Target> <HoverCard.Target>
<Flex className={styles.indexContent} justify="center" w="100%"> <Flex className={styles.indexContent} justify="center" w="100%">
{getIndexDisplay(false)} {getIndexDisplay(false)}
@@ -12,10 +12,15 @@ import { Play } from '/@/shared/types/types';
interface PlayTrackRadioActionProps { interface PlayTrackRadioActionProps {
disabled?: boolean; disabled?: boolean;
skipFirstSong?: boolean;
song: Song; song: Song;
} }
export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionProps) => { export const PlayTrackRadioAction = ({
disabled,
skipFirstSong,
song,
}: PlayTrackRadioActionProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const player = usePlayer(); const player = usePlayer();
const serverId = useCurrentServerId(); const serverId = useCurrentServerId();
@@ -38,13 +43,17 @@ export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionPro
}); });
if (similarSongs && similarSongs.length > 0) { 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) { } catch (error) {
console.error('Failed to load track radio:', error); console.error('Failed to load track radio:', error);
} }
}, },
[player, queryClient, serverId, song], [player, queryClient, serverId, skipFirstSong, song],
); );
const handlePlayTrackRadioNow = useCallback(() => { const handlePlayTrackRadioNow = useCallback(() => {
@@ -35,7 +35,7 @@ export const QueueContextMenu = ({ items }: QueueContextMenuProps) => {
<MoveQueueItemsAction items={items} /> <MoveQueueItemsAction items={items} />
<ShuffleItemsAction items={items} /> <ShuffleItemsAction items={items} />
<ContextMenu.Divider /> <ContextMenu.Divider />
<PlayTrackRadioAction disabled={items.length > 1} song={items[0]} /> <PlayTrackRadioAction disabled={items.length > 1} skipFirstSong song={items[0]} />
<ContextMenu.Divider /> <ContextMenu.Divider />
<AddToPlaylistAction items={ids} itemType={LibraryItem.SONG} /> <AddToPlaylistAction items={ids} itemType={LibraryItem.SONG} />
<ContextMenu.Divider /> <ContextMenu.Divider />
@@ -23,6 +23,8 @@ export interface WebPlayerEngineHandle extends AudioPlayer {
interface WebPlayerEngineProps { interface WebPlayerEngineProps {
isMuted: boolean; isMuted: boolean;
isTransitioning: boolean; isTransitioning: boolean;
loopPlayer1: boolean;
loopPlayer2: boolean;
onEndedPlayer1: () => void; onEndedPlayer1: () => void;
onEndedPlayer2: () => void; onEndedPlayer2: () => void;
onErrorPause: () => void; onErrorPause: () => void;
@@ -55,6 +57,8 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
const { const {
isMuted, isMuted,
isTransitioning, isTransitioning,
loopPlayer1,
loopPlayer2,
onEndedPlayer1, onEndedPlayer1,
onEndedPlayer2, onEndedPlayer2,
onErrorPause, onErrorPause,
@@ -292,8 +296,9 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
controls={false} controls={false}
height={0} height={0}
id="web-player-1" id="web-player-1"
loop={loopPlayer1}
muted={isMuted} muted={isMuted}
onEnded={src1 ? () => onEndedPlayer1() : undefined} onEnded={src1 && !loopPlayer1 ? () => onEndedPlayer1() : undefined}
onError={handleOnError( onError={handleOnError(
player1Ref, player1Ref,
() => onEndedPlayer1(), () => onEndedPlayer1(),
@@ -317,8 +322,9 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
controls={false} controls={false}
height={0} height={0}
id="web-player-2" id="web-player-2"
loop={loopPlayer2}
muted={isMuted} muted={isMuted}
onEnded={src2 ? () => onEndedPlayer2() : undefined} onEnded={src2 && !loopPlayer2 ? () => onEndedPlayer2() : undefined}
onError={handleOnError( onError={handleOnError(
player2Ref, player2Ref,
() => onEndedPlayer2(), () => onEndedPlayer2(),
@@ -4,6 +4,7 @@ import type ReactPlayer from 'react-player';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { import {
WebPlayerEngine, WebPlayerEngine,
WebPlayerEngineHandle, WebPlayerEngineHandle,
@@ -20,12 +21,13 @@ import {
usePlayerData, usePlayerData,
usePlayerMuted, usePlayerMuted,
usePlayerProperties, usePlayerProperties,
usePlayerRepeat,
usePlayerStoreBase, usePlayerStoreBase,
usePlayerVolume, usePlayerVolume,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { QueueSong } from '/@/shared/types/domain-types'; 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_DURATION = 300;
const PLAY_PAUSE_FADE_INTERVAL = 10; const PLAY_PAUSE_FADE_INTERVAL = 10;
@@ -34,6 +36,8 @@ export function WebPlayer() {
const playerRef = useRef<null | WebPlayerEngineHandle>(null); const playerRef = useRef<null | WebPlayerEngineHandle>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const { num, player1, player2, status } = usePlayerData(); const { num, player1, player2, status } = usePlayerData();
const repeat = usePlayerRepeat();
const repeatOneProgressRef = useRef({ player1: 0, player2: 0 });
const { mediaAutoNext, mediaPause, setTimestamp } = usePlayerActions(); const { mediaAutoNext, mediaPause, setTimestamp } = usePlayerActions();
const playback = useMpvSettings(); const playback = useMpvSettings();
const { webAudio } = useWebAudio(); 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( const onProgressPlayer1 = useCallback(
(e: PlayerOnProgressProps) => { (e: PlayerOnProgressProps) => {
if (!playerRef.current?.player1()) { if (!playerRef.current?.player1()) {
return; return;
} }
if (repeat === PlayerRepeat.ONE) {
handleRepeatOne(1, e.playedSeconds, getDuration(playerRef.current.player1().ref));
return;
}
switch (transitionType) { switch (transitionType) {
case PlayerStyle.CROSSFADE: case PlayerStyle.CROSSFADE:
crossfadeHandler({ crossfadeHandler({
@@ -132,7 +161,17 @@ export function WebPlayer() {
break; break;
} }
}, },
[crossfadeDuration, crossfadeStyle, isTransitioning, num, player2, transitionType, volume], [
crossfadeDuration,
crossfadeStyle,
handleRepeatOne,
isTransitioning,
num,
player2,
repeat,
transitionType,
volume,
],
); );
const onProgressPlayer2 = useCallback( const onProgressPlayer2 = useCallback(
@@ -141,6 +180,11 @@ export function WebPlayer() {
return; return;
} }
if (repeat === PlayerRepeat.ONE) {
handleRepeatOne(2, e.playedSeconds, getDuration(playerRef.current.player2().ref));
return;
}
switch (transitionType) { switch (transitionType) {
case PlayerStyle.CROSSFADE: case PlayerStyle.CROSSFADE:
crossfadeHandler({ crossfadeHandler({
@@ -170,7 +214,17 @@ export function WebPlayer() {
break; break;
} }
}, },
[crossfadeDuration, crossfadeStyle, isTransitioning, num, player1, transitionType, volume], [
crossfadeDuration,
crossfadeStyle,
handleRepeatOne,
isTransitioning,
num,
player1,
repeat,
transitionType,
volume,
],
); );
const handleOnEndedPlayer1 = useCallback(() => { const handleOnEndedPlayer1 = useCallback(() => {
@@ -474,10 +528,15 @@ export function WebPlayer() {
}); });
}, [mediaPause, t]); }, [mediaPause, t]);
const loopPlayer1 = repeat === PlayerRepeat.ONE && num === 1;
const loopPlayer2 = repeat === PlayerRepeat.ONE && num === 2;
return ( return (
<WebPlayerEngine <WebPlayerEngine
isMuted={isMuted} isMuted={isMuted}
isTransitioning={Boolean(isTransitioning)} isTransitioning={Boolean(isTransitioning)}
loopPlayer1={loopPlayer1}
loopPlayer2={loopPlayer2}
onEndedPlayer1={handleOnEndedPlayer1} onEndedPlayer1={handleOnEndedPlayer1}
onEndedPlayer2={handleOnEndedPlayer2} onEndedPlayer2={handleOnEndedPlayer2}
onErrorPause={handleOnErrorPause} onErrorPause={handleOnErrorPause}
@@ -14,7 +14,10 @@ import { MediaSessionHook } from '/@/renderer/features/player/hooks/use-media-se
import { MPRISHook } from '/@/renderer/features/player/hooks/use-mpris'; import { MPRISHook } from '/@/renderer/features/player/hooks/use-mpris';
import { PlaybackHotkeysHook } from '/@/renderer/features/player/hooks/use-playback-hotkeys'; import { PlaybackHotkeysHook } from '/@/renderer/features/player/hooks/use-playback-hotkeys';
import { PowerSaveBlockerHook } from '/@/renderer/features/player/hooks/use-power-save-blocker'; 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 { ScrobbleHook } from '/@/renderer/features/player/hooks/use-scrobble';
import { UpdateCurrentSongHook } from '/@/renderer/features/player/hooks/use-update-current-song'; import { UpdateCurrentSongHook } from '/@/renderer/features/player/hooks/use-update-current-song';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio'; import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
@@ -134,6 +137,7 @@ export const AudioPlayers = () => {
<RemoteHook /> <RemoteHook />
<AutoDJHook /> <AutoDJHook />
<QueueRestoreTimestampHook /> <QueueRestoreTimestampHook />
<InitialTimestampRestoreHook />
<UpdateCurrentSongHook /> <UpdateCurrentSongHook />
<RadioAudioInstanceHook /> <RadioAudioInstanceHook />
<RadioMetadataHook /> <RadioMetadataHook />
@@ -14,6 +14,8 @@
} }
.root { .root {
width: 100%;
&:hover { &:hover {
.bar { .bar {
background-color: var(--theme-colors-primary-filled); background-color: var(--theme-colors-primary-filled);
@@ -44,48 +46,45 @@
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
} }
.track {
&::before {
right: calc(0.1rem * -1);
}
}
.slider-container { .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%; width: 95%;
height: 20px; height: 20px;
} }
.slider-value-wrapper { .slider-value-wrapper {
display: flex; min-width: 0;
flex: 1;
align-items: center;
justify-content: center;
max-width: 50px;
min-height: 0; min-height: 0;
&:first-child {
justify-self: end;
}
&:last-child {
justify-self: start;
}
@media (width < 768px) { @media (width < 768px) {
display: none; display: none;
} }
} }
.slider-value-wrapper-elapsed { @media (width < 768px) {
display: flex; .slider-container {
flex: 1; grid-template-columns: minmax(0, 1fr);
align-items: center;
justify-content: center;
min-width: 0;
max-width: 4.75rem;
min-height: 0;
@media (width < 768px) {
display: none;
} }
} }
.slider-wrapper { .slider-wrapper {
display: flex; display: flex;
flex: 6;
align-items: center; align-items: center;
width: 100%;
min-width: 0;
height: 100%; height: 100%;
} }
@@ -42,7 +42,7 @@ export const PlayerbarSlider = () => {
return ( return (
<> <>
<div className={styles.sliderContainer}> <div className={styles.sliderContainer}>
<div className={styles.sliderValueWrapperElapsed}> <div className={styles.sliderValueWrapper}>
<ScrobbleStatus formattedTime={formattedTime} /> <ScrobbleStatus formattedTime={formattedTime} />
</div> </div>
<div className={styles.sliderWrapper}> <div className={styles.sliderWrapper}>
@@ -81,7 +81,6 @@ export const CustomPlayerbarSlider = ({ ...props }: SliderProps) => {
label: styles.label, label: styles.label,
root: styles.root, root: styles.root,
thumb: styles.thumb, thumb: styles.thumb,
track: styles.track,
}} }}
{...props} {...props}
size={6} size={6}
@@ -22,6 +22,12 @@ const scrobbleProgressProps = {
const clampPct = (n: number) => Math.min(100, Math.max(0, n)); 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 }) => ( const ScrobbleConditionProgress = ({ value }: { value: number }) => (
<Progress {...scrobbleProgressProps} value={value} w="100%" /> <Progress {...scrobbleProgressProps} value={value} w="100%" />
); );
@@ -34,18 +40,25 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
const hookInactive = !scrobbleEnabled || privateMode; const hookInactive = !scrobbleEnabled || privateMode;
const listenedSec = (snapshot.listenedMs / 1000).toFixed(1); const listenedSecRaw = snapshot.listenedMs / 1000;
const listenPercentOfTrack = const listenedSecDisplay = snapshot.submitted
snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0; ? snapshot.targetDurationSec
: capForDisplay(listenedSecRaw, snapshot.targetDurationSec);
const durationConditionPct = const listenPercentOfTrackRaw =
snapshot.targetDurationSec > 0 snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0;
? clampPct((snapshot.listenedMs / 1000 / snapshot.targetDurationSec) * 100) const listenPercentDisplay = snapshot.submitted
: 0; ? snapshot.targetPercentage
const percentConditionPct = : capForDisplay(listenPercentOfTrackRaw, snapshot.targetPercentage);
snapshot.targetPercentage > 0 && snapshot.trackDurationMs > 0
? clampPct((listenPercentOfTrack / snapshot.targetPercentage) * 100) const durationConditionPct = progressTowardLimit(
: 0; listenedSecDisplay,
snapshot.targetDurationSec,
);
const percentConditionPct = progressTowardLimit(
listenPercentDisplay,
snapshot.targetPercentage,
);
return ( return (
<HoverCard position="top" width={280}> <HoverCard position="top" width={280}>
@@ -87,13 +100,13 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
<> <>
<Stack gap="xs"> <Stack gap="xs">
<Text size="xs"> <Text size="xs">
{`${listenedSec}s / ${snapshot.targetDurationSec}s`} {`${listenedSecDisplay.toFixed(1)}s / ${snapshot.targetDurationSec}s`}
</Text> </Text>
<ScrobbleConditionProgress value={durationConditionPct} /> <ScrobbleConditionProgress value={durationConditionPct} />
</Stack> </Stack>
<Stack gap="xs"> <Stack gap="xs">
<Text size="xs"> <Text size="xs">
{`${listenPercentOfTrack.toFixed(1)}% / ${snapshot.targetPercentage}%`} {`${listenPercentDisplay.toFixed(1)}% / ${snapshot.targetPercentage}%`}
</Text> </Text>
<ScrobbleConditionProgress value={percentConditionPct} /> <ScrobbleConditionProgress value={percentConditionPct} />
</Stack> </Stack>
@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { t } from 'i18next'; import { t } from 'i18next';
import { useCallback } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; 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 { import {
setTimestamp, setTimestamp,
useCurrentServerId, useCurrentServerId,
usePlayerActions,
usePlayerHydrated,
usePlayerSong,
usePlayerStatus,
usePlayerStore, usePlayerStore,
useTimestampStoreBase, useTimestampStoreBase,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { PlayerStatus } from '/@/shared/types/types';
export const useQueueRestoreTimestamp = () => { export const useQueueRestoreTimestamp = () => {
const player = usePlayerStore(); const { mediaSeekToTimestamp } = usePlayerActions();
usePlayerEvents( usePlayerEvents(
{ {
@@ -24,7 +29,7 @@ export const useQueueRestoreTimestamp = () => {
setTimeout(() => { setTimeout(() => {
setTimestamp(position); setTimestamp(position);
player.mediaSeekToTimestamp(position); mediaSeekToTimestamp(position);
}, 100); }, 100);
}, },
}, },
@@ -37,6 +42,72 @@ export const QueueRestoreTimestampHook = () => {
return null; 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 = () => { export const useSaveQueue = () => {
const serverId = useCurrentServerId(); 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 { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation'; import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
import { import {
getServerById,
publishScrobbleDebug, publishScrobbleDebug,
useAppStore, useAppStore,
usePlaybackSettings, usePlaybackSettings,
usePlayerSong, usePlayerSong,
usePlayerSpeed,
usePlayerStore, usePlayerStore,
useSettingsStore, useSettingsStore,
useTimestampStoreBase, useTimestampStoreBase,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message'; import { logMsg } from '/@/renderer/utils/logger-message';
import { hasFeature } from '/@/shared/api/utils';
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types'; import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
import { PlayerStatus } from '/@/shared/types/types'; import { PlayerStatus } from '/@/shared/types/types';
type ScrobbleManualHandlers = { type ScrobbleManualHandlers = {
@@ -36,6 +40,14 @@ export const invokeScrobbleResetListenedState = () => {
scrobbleManualHandlers?.resetListenedState(); 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: Submission (Last.fm / etc.) eligibility uses accumulated listen time:
- If listened time meets the required percentage of track duration - If listened time meets the required percentage of track duration
@@ -99,6 +111,7 @@ export const useScrobble = () => {
const isPrivateModeEnabled = useAppStore((state) => state.privateMode); const isPrivateModeEnabled = useAppStore((state) => state.privateMode);
const sendScrobble = useSendScrobble(); const sendScrobble = useSendScrobble();
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const playbackRate = usePlayerSpeed();
const imageUrl = useItemImageUrl({ const imageUrl = useItemImageUrl({
id: currentSong?.imageId || undefined, id: currentSong?.imageId || undefined,
@@ -166,6 +179,11 @@ export const useScrobble = () => {
if (!isScrobbleEnabled || isPrivateModeEnabled) return; if (!isScrobbleEnabled || isPrivateModeEnabled) return;
const currentSong = usePlayerStore.getState().getCurrentSong(); 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 currentStatus = usePlayerStore.getState().player.status;
const currentTime = properties.timestamp; const currentTime = properties.timestamp;
const previousTime = prev.timestamp; const previousTime = prev.timestamp;
@@ -220,19 +238,20 @@ export const useScrobble = () => {
} }
} }
// Send Jellyfin progress events every 10 seconds // Send progress events every 10 seconds
if (currentSong._serverType === ServerType.JELLYFIN) { if (hasPlaybackReport) {
const timeSinceLastProgress = currentTime - lastProgressEventRef.current; const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
if (timeSinceLastProgress >= 10) { if (timeSinceLastProgress >= 10) {
const position = currentTime * 1e7;
sendScrobble.mutate( sendScrobble.mutate(
{ {
apiClientProps: { serverId: currentSong._serverId || '' }, apiClientProps: { serverId: serverId || '' },
query: { query: {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'timeupdate', event: 'timeupdate',
id: currentSong.id, id: currentSong.id,
position, mediaType: mediaType,
playbackRate,
position: getPositionValue(currentTime, useTicks),
submission: false, submission: false,
}, },
}, },
@@ -261,20 +280,15 @@ export const useScrobble = () => {
}); });
if (shouldSubmitScrobble) { 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( sendScrobble.mutate(
{ {
apiClientProps: { serverId: currentSong._serverId || '' }, apiClientProps: { serverId: currentSong._serverId || '' },
query: { query: {
albumId: currentSong.albumId, albumId: currentSong.albumId,
id: currentSong.id, id: currentSong.id,
position, mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentSong.duration ?? 0, useTicks),
submission: true, submission: true,
}, },
}, },
@@ -295,7 +309,7 @@ export const useScrobble = () => {
} }
} }
}, },
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate],
); );
const handleScrobbleFromSongChange = useCallback( const handleScrobbleFromSongChange = useCallback(
@@ -305,6 +319,7 @@ export const useScrobble = () => {
) => { ) => {
const currentSong = properties.song; const currentSong = properties.song;
const previousSong = previousSongRef.current; const previousSong = previousSongRef.current;
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
// Handle notifications // Handle notifications
if (scrobbleSettings?.notify && currentSong?.id) { if (scrobbleSettings?.notify && currentSong?.id) {
@@ -365,6 +380,8 @@ export const useScrobble = () => {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'start', event: 'start',
id: currentSong.id, id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
position: 0, position: 0,
submission: false, submission: false,
}, },
@@ -388,11 +405,12 @@ export const useScrobble = () => {
flushScrobbleDebug(); flushScrobbleDebug();
}, },
[ [
flushScrobbleDebug,
scrobbleSettings?.notify, scrobbleSettings?.notify,
isScrobbleEnabled, isScrobbleEnabled,
isPrivateModeEnabled, isPrivateModeEnabled,
flushScrobbleDebug,
sendScrobble, sendScrobble,
playbackRate,
], ],
); );
@@ -404,6 +422,11 @@ export const useScrobble = () => {
} }
const currentSong = usePlayerStore.getState().getCurrentSong(); 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) { if (!currentSong?.id) {
return; return;
@@ -422,7 +445,7 @@ export const useScrobble = () => {
} }
// Position scrobbles are only relevant for Jellyfin // Position scrobbles are only relevant for Jellyfin
if (currentSong._serverType !== ServerType.JELLYFIN) { if (!hasPlaybackReport) {
flushScrobbleDebug(); flushScrobbleDebug();
return; return;
} }
@@ -436,8 +459,6 @@ export const useScrobble = () => {
return; return;
} }
const position = properties.timestamp * 1e7;
lastProgressEventRef.current = properties.timestamp; lastProgressEventRef.current = properties.timestamp;
lastSeekEventRef.current = now; lastSeekEventRef.current = now;
@@ -448,7 +469,9 @@ export const useScrobble = () => {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'timeupdate', event: 'timeupdate',
id: currentSong.id, id: currentSong.id,
position, mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(properties.timestamp, useTicks),
submission: false, submission: false,
}, },
}, },
@@ -465,7 +488,7 @@ export const useScrobble = () => {
); );
flushScrobbleDebug(); flushScrobbleDebug();
}, },
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate, flushScrobbleDebug],
); );
const handleScrobbleFromStatus = useCallback( const handleScrobbleFromStatus = useCallback(
@@ -475,18 +498,22 @@ export const useScrobble = () => {
} }
const currentSong = usePlayerStore.getState().getCurrentSong(); 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) { if (!currentSong?.id) {
return; return;
} }
// Only apply to Jellyfin controller scrobble // Only apply to Jellyfin controller scrobble
if (currentSong._serverType !== ServerType.JELLYFIN) { if (!hasPlaybackReport) {
return; return;
} }
const currentTimestamp = useTimestampStoreBase.getState().timestamp; const currentTimestamp = useTimestampStoreBase.getState().timestamp;
const position = currentTimestamp * 1e7;
// Send pause event when status changes to paused // Send pause event when status changes to paused
if (properties.status === PlayerStatus.PAUSED && prev.status === PlayerStatus.PLAYING) { if (properties.status === PlayerStatus.PAUSED && prev.status === PlayerStatus.PLAYING) {
@@ -497,7 +524,9 @@ export const useScrobble = () => {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'pause', event: 'pause',
id: currentSong.id, id: currentSong.id,
position, mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentTimestamp, useTicks),
submission: false, submission: false,
}, },
}, },
@@ -523,7 +552,9 @@ export const useScrobble = () => {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'unpause', event: 'unpause',
id: currentSong.id, id: currentSong.id,
position, mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentTimestamp, useTicks),
submission: false, submission: false,
}, },
}, },
@@ -542,7 +573,7 @@ export const useScrobble = () => {
flushScrobbleDebug(); flushScrobbleDebug();
}, },
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], [isScrobbleEnabled, isPrivateModeEnabled, flushScrobbleDebug, sendScrobble, playbackRate],
); );
const handleScrobbleFromRepeat = useCallback(() => { const handleScrobbleFromRepeat = useCallback(() => {
@@ -552,6 +583,7 @@ export const useScrobble = () => {
const currentSong = usePlayerStore.getState().getCurrentSong(); const currentSong = usePlayerStore.getState().getCurrentSong();
const currentStatus = usePlayerStore.getState().player.status; const currentStatus = usePlayerStore.getState().player.status;
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
if (currentStatus !== PlayerStatus.PLAYING || !currentSong?.id) { if (currentStatus !== PlayerStatus.PLAYING || !currentSong?.id) {
return; return;
@@ -570,6 +602,8 @@ export const useScrobble = () => {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'start', event: 'start',
id: currentSong.id, id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
position: 0, position: 0,
submission: false, submission: false,
}, },
@@ -587,7 +621,7 @@ export const useScrobble = () => {
}, },
); );
flushScrobbleDebug(); flushScrobbleDebug();
}, [flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]); }, [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate, flushScrobbleDebug]);
// Update previous timestamp on progress for use in status change handler // Update previous timestamp on progress for use in status change handler
const handleProgressUpdate = useCallback( const handleProgressUpdate = useCallback(
@@ -607,20 +641,22 @@ export const useScrobble = () => {
} }
const song = usePlayerStore.getState().getCurrentSong(); const song = usePlayerStore.getState().getCurrentSong();
const mediaType = song?._itemType.includes('song') ? 'song' : 'podcast';
const useTicks = song?._serverType === ServerType.JELLYFIN;
if (!song?.id) { if (!song?.id) {
return; return;
} }
const position =
song._serverType === ServerType.JELLYFIN ? song.duration * 1e7 : undefined;
sendScrobble.mutate( sendScrobble.mutate(
{ {
apiClientProps: { serverId: song._serverId || '' }, apiClientProps: { serverId: song._serverId || '' },
query: { query: {
albumId: song.albumId, albumId: song.albumId,
id: song.id, id: song.id,
position, mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(song.duration ?? 0, useTicks),
submission: true, submission: true,
}, },
}, },
@@ -659,7 +695,7 @@ export const useScrobble = () => {
}); });
return () => registerScrobbleManualHandlers(null); return () => registerScrobbleManualHandlers(null);
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, sendScrobble]); }, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, playbackRate, sendScrobble]);
usePlayerEvents( usePlayerEvents(
{ {
@@ -136,6 +136,8 @@ export function RadioWebPlayer() {
<WebPlayerEngine <WebPlayerEngine
isMuted={isMuted} isMuted={isMuted}
isTransitioning={false} isTransitioning={false}
loopPlayer1={false}
loopPlayer2={false}
onEndedPlayer1={onEndedPlayer1} onEndedPlayer1={onEndedPlayer1}
onEndedPlayer2={() => {}} onEndedPlayer2={() => {}}
onErrorPause={() => {}} onErrorPause={() => {}}
@@ -39,7 +39,7 @@ import { FontType } from '/@/shared/types/types';
const localSettings = isElectron() ? window.api.localSettings : null; const localSettings = isElectron() ? window.api.localSettings : null;
const ipc = isElectron() ? window.api.ipc : null; const ipc = isElectron() ? window.api.ipc : null;
// Electron 32+ removed file.path, use this which is exposed in preload to get real path // 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 = [ const HOME_FEATURE_STYLE_OPTIONS = [
{ {
@@ -295,7 +295,7 @@ export const ApplicationSettings = memo(() => {
setSettings({ setSettings({
font: { font: {
...fontSettings, ...fontSettings,
custom: e ? webUtils?.getPathForFile(e) || null : null, custom: e ? getPathForFile?.(e) || null : null,
}, },
}) })
} }
@@ -21,6 +21,21 @@ type PromptState = 'loading' | { consent: boolean };
export function VisualizerSystemAudioBridgeHook() { export function VisualizerSystemAudioBridgeHook() {
const playbackType = usePlaybackType(); 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) { if (!isElectron() || playbackType !== PlayerType.LOCAL) {
return null; return null;
+56 -7
View File
@@ -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 // Helper function to check if shuffle is enabled
export function isShuffleEnabled(state: { export function isShuffleEnabled(state: {
player: { shuffle: PlayerShuffle }; player: { shuffle: PlayerShuffle };
@@ -800,13 +819,20 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
nextSong = calculateNextSong(queueIndex, queue.items, repeat); nextSong = calculateNextSong(queueIndex, queue.items, repeat);
} }
const { player1, player2 } = getDualPlayerSongs(
state.player.playerNum,
currentSong,
nextSong,
repeat,
);
return { return {
currentSong, currentSong,
index: queueIndex, // Return the actual queue position for display index: queueIndex, // Return the actual queue position for display
nextSong, nextSong,
num: state.player.playerNum, num: state.player.playerNum,
player1: state.player.playerNum === 1 ? currentSong : nextSong, player1,
player2: state.player.playerNum === 2 ? currentSong : nextSong, player2,
previousSong, previousSong,
queueLength: state.queue.default.length, queueLength: state.queue.default.length,
status: state.player.status, status: state.player.status,
@@ -895,12 +921,21 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
? stateSnapshot.queue.shuffled.length ? stateSnapshot.queue.shuffled.length
: queue.items.length; : queue.items.length;
const newPlayerNum = player.playerNum === 1 ? 2 : 1;
const { nextIndex: nextPlaybackIndex, shouldPause } = calculateNextIndex( const { nextIndex: nextPlaybackIndex, shouldPause } = calculateNextIndex(
currentIndex, currentIndex,
playbackLength, playbackLength,
repeat, 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 pauseOnNext = player.pauseOnNextSongEnd;
const newStatus = const newStatus =
shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING; shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING;
@@ -963,13 +998,20 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
currentQueueIndex > 0 ? queue.items[currentQueueIndex - 1] : undefined; currentQueueIndex > 0 ? queue.items[currentQueueIndex - 1] : undefined;
} }
const { player1, player2 } = getDualPlayerSongs(
newPlayerNum,
currentSong,
nextSong,
repeat,
);
return { return {
currentSong, currentSong,
index: currentQueueIndex, index: currentQueueIndex,
nextSong, nextSong,
num: newPlayerNum, num: newPlayerNum,
player1: newPlayerNum === 1 ? currentSong : nextSong, player1,
player2: newPlayerNum === 2 ? currentSong : nextSong, player2,
previousSong, previousSong,
queueLength: queue.items.length, queueLength: queue.items.length,
status: newStatus, status: newStatus,
@@ -1962,13 +2004,20 @@ export const usePlayerData = (): PlayerData => {
nextSong = calculateNextSong(queueIndex, queue.items, repeat); nextSong = calculateNextSong(queueIndex, queue.items, repeat);
} }
const { player1, player2 } = getDualPlayerSongs(
state.player.playerNum,
currentSong,
nextSong,
repeat,
);
return { return {
currentSong, currentSong,
index: queueIndex, // Return the actual queue position for display index: queueIndex, // Return the actual queue position for display
nextSong, nextSong,
num: state.player.playerNum, num: state.player.playerNum,
player1: state.player.playerNum === 1 ? currentSong : nextSong, player1,
player2: state.player.playerNum === 2 ? currentSong : nextSong, player2,
previousSong, previousSong,
queueLength: state.queue.default.length, queueLength: state.queue.default.length,
status: state.player.status, status: state.player.status,
+30 -6
View File
@@ -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'; import { createWithEqualityFn } from 'zustand/traditional';
interface TimestampState { interface TimestampState {
@@ -6,13 +7,36 @@ interface TimestampState {
timestamp: number; 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>()( export const useTimestampStoreBase = createWithEqualityFn<TimestampState>()(
subscribeWithSelector((set) => ({ persist(
setTimestamp: (timestamp: number) => { subscribeWithSelector((set) => ({
set({ timestamp }); setTimestamp: (timestamp: number) => {
set({ timestamp });
},
timestamp: 0,
})),
{
name: 'player-timestamp',
storage: timestampStorage,
version: 1,
}, },
timestamp: 0, ),
})),
); );
export const subscribePlayerProgress = ( export const subscribePlayerProgress = (
+14
View File
@@ -454,6 +454,7 @@ const similarSongs2 = z.object({
export enum SubsonicExtensions { export enum SubsonicExtensions {
FORM_POST = 'formPost', FORM_POST = 'formPost',
INDEX_BASED_QUEUE = 'indexBasedQueue', INDEX_BASED_QUEUE = 'indexBasedQueue',
PLAYBACK_REPORT = 'playbackReport',
SONG_LYRICS = 'songLyrics', SONG_LYRICS = 'songLyrics',
TRANSCODE_OFFSET = 'transcodeOffset', TRANSCODE_OFFSET = 'transcodeOffset',
TRANSCODING = 'transcoding', TRANSCODING = 'transcoding',
@@ -793,6 +794,17 @@ const getInternetRadioStations = z.object({
.optional(), .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 = { export const ssType = {
_body: { _body: {
getTranscodeDecision: transcodeDecisionRequestBody, getTranscodeDecision: transcodeDecisionRequestBody,
@@ -824,6 +836,7 @@ export const ssType = {
getTranscodeStream: getTranscodeStreamParameters, getTranscodeStream: getTranscodeStreamParameters,
randomSongList: randomSongListParameters, randomSongList: randomSongListParameters,
removeFavorite: removeFavoriteParameters, removeFavorite: removeFavoriteParameters,
reportPlayback: reportPlaybackParameters,
savePlayQueueByIndex: savePlayQueueByIndexParameters, savePlayQueueByIndex: savePlayQueueByIndexParameters,
saveQueue: saveQueueParameters, saveQueue: saveQueueParameters,
scrobble: scrobbleParameters, scrobble: scrobbleParameters,
@@ -877,6 +890,7 @@ export const ssType = {
playQueueByIndex, playQueueByIndex,
randomSongList, randomSongList,
removeFavorite, removeFavorite,
reportPlayback,
saveQueue, saveQueue,
scrobble, scrobble,
search3, search3,
+6 -13
View File
@@ -89,18 +89,11 @@ export function BaseImage({
const effectiveImageRequest = const effectiveImageRequest =
isInSessionCache || !enableDebounce ? rawImageRequest : debouncedImageRequest; isInSessionCache || !enableDebounce ? rawImageRequest : debouncedImageRequest;
const imageCacheKey = src ?? rawImageRequest?.cacheKey;
const trackLoadedInInstance = enableViewport;
const [hasLoadedInInstance, setHasLoadedInInstance] = useState(false); const [hasLoadedInInstance, setHasLoadedInInstance] = useState(false);
useEffect(() => { useEffect(() => {
if (!trackLoadedInInstance) { setHasLoadedInInstance(false);
return; }, [effectiveImageRequest?.cacheKey]);
}
setHasLoadedInInstance((current) => (current ? false : current));
}, [imageCacheKey, trackLoadedInInstance]);
const shouldLoadImage = Boolean( const shouldLoadImage = Boolean(
effectiveImageRequest && effectiveImageRequest &&
@@ -119,13 +112,13 @@ export function BaseImage({
}); });
useEffect(() => { useEffect(() => {
if (!trackLoadedInInstance || !nativeImage.isLoaded || !imageCacheKey) { if (!nativeImage.isLoaded || !effectiveImageRequest?.cacheKey) {
return; return;
} }
loadedImageCacheKeys.add(imageCacheKey); loadedImageCacheKeys.add(effectiveImageRequest.cacheKey);
setHasLoadedInInstance((current) => (current ? current : true)); setHasLoadedInInstance(true);
}, [imageCacheKey, nativeImage.isLoaded, trackLoadedInInstance]); }, [effectiveImageRequest?.cacheKey, nativeImage.isLoaded]);
return ( return (
<ImageContainer <ImageContainer
@@ -26,11 +26,8 @@ export function useNativeImage({
const loadedRequestSignatureRef = useRef<null | string>(null); const loadedRequestSignatureRef = useRef<null | string>(null);
const objectUrlRef = useRef<null | string>(null); const objectUrlRef = useRef<null | string>(null);
const onFetchErrorRef = useRef(onFetchError); const onFetchErrorRef = useRef(onFetchError);
const requestRef = useRef(request);
const [state, setState] = useState<NativeImageState>({ status: 'idle' }); const [state, setState] = useState<NativeImageState>({ status: 'idle' });
requestRef.current = request;
const requestSignature = useMemo(() => { const requestSignature = useMemo(() => {
if (!request) { if (!request) {
return null; return null;
@@ -47,8 +44,6 @@ export function useNativeImage({
onFetchErrorRef.current = onFetchError; onFetchErrorRef.current = onFetchError;
useEffect(() => { useEffect(() => {
const request = requestRef.current;
const abortCurrentRequest = () => { const abortCurrentRequest = () => {
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
abortControllerRef.current = null; abortControllerRef.current = null;
@@ -67,61 +62,28 @@ export function useNativeImage({
if (!request || !requestSignature) { if (!request || !requestSignature) {
abortCurrentRequest(); abortCurrentRequest();
revokeObjectUrl(); revokeObjectUrl();
setState((currentState) => { setState({ status: 'idle' });
if (currentState.status === 'idle' && !currentState.displaySrc) {
return currentState;
}
return { status: 'idle' };
});
return; return;
} }
if (!enabled) { if (!enabled) {
abortCurrentRequest(); abortCurrentRequest();
setState((currentState) => { setState((currentState) =>
if (currentState.displaySrc) { currentState.displaySrc
if (currentState.status === 'loaded') { ? { ...currentState, status: 'loaded' }
return currentState; : { status: 'idle' },
} );
return { ...currentState, status: 'loaded' };
}
if (currentState.status === 'idle' && !currentState.displaySrc) {
return currentState;
}
return { status: 'idle' };
});
return; return;
} }
if (loadedRequestSignatureRef.current === requestSignature && objectUrlRef.current) { if (loadedRequestSignatureRef.current === requestSignature && objectUrlRef.current) {
const cachedObjectUrl = objectUrlRef.current; setState({ displaySrc: objectUrlRef.current, status: 'loaded' });
setState((currentState) => {
if (
currentState.status === 'loaded' &&
currentState.displaySrc === cachedObjectUrl
) {
return currentState;
}
return { displaySrc: cachedObjectUrl, status: 'loaded' };
});
return; return;
} }
abortCurrentRequest(); abortCurrentRequest();
revokeObjectUrl(); revokeObjectUrl();
setState((currentState) => { setState({ status: 'loading' });
if (currentState.status === 'loading' && !currentState.displaySrc) {
return currentState;
}
return { status: 'loading' };
});
const abortController = new AbortController(); const abortController = new AbortController();
abortControllerRef.current = abortController; abortControllerRef.current = abortController;
@@ -176,7 +138,7 @@ export function useNativeImage({
abortControllerRef.current = null; abortControllerRef.current = null;
} }
}; };
}, [enabled, fetchPriority, requestSignature]); }, [enabled, fetchPriority, request, requestSignature]);
useEffect(() => { useEffect(() => {
return () => { return () => {
+2
View File
@@ -1365,6 +1365,8 @@ export type ScrobbleQuery = {
albumId?: string; albumId?: string;
event?: 'pause' | 'start' | 'timeupdate' | 'unpause'; event?: 'pause' | 'start' | 'timeupdate' | 'unpause';
id: string; id: string;
mediaType: 'podcast' | 'song';
playbackRate: number;
position?: number; position?: number;
submission: boolean; submission: boolean;
}; };
+1
View File
@@ -13,6 +13,7 @@ export enum ServerFeature {
PLAYLIST_IMAGE_UPLOAD = 'playlistImageUpload', PLAYLIST_IMAGE_UPLOAD = 'playlistImageUpload',
PLAYLISTS_SMART = 'playlistsSmart', PLAYLISTS_SMART = 'playlistsSmart',
PUBLIC_PLAYLIST = 'publicPlaylist', PUBLIC_PLAYLIST = 'publicPlaylist',
REPORT_PLAYBACK = 'reportPlayback',
SERVER_PLAY_QUEUE = 'serverPlayQueue', SERVER_PLAY_QUEUE = 'serverPlayQueue',
SHARING_ALBUM_SONG = 'sharingAlbumSong', SHARING_ALBUM_SONG = 'sharingAlbumSong',
SIMILAR_SONGS_MUSIC_FOLDER = 'similarSongsMusicFolder', SIMILAR_SONGS_MUSIC_FOLDER = 'similarSongsMusicFolder',