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