mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-22 18:36:34 +02:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18b62ae22b | |||
| 56a552f893 | |||
| d11c3fa58c | |||
| 54f181c542 | |||
| 1bf51d1d72 | |||
| 37df94bd3b | |||
| dd60499185 | |||
| 009732e745 | |||
| 0de1e1aa3e | |||
| 6528859ea9 | |||
| 704745eb7c | |||
| 2759cfee97 | |||
| 755f0aab9d | |||
| 0e163543fc | |||
| fad2534d44 | |||
| edfc64d790 | |||
| 8f40894926 | |||
| 2befcb4e74 | |||
| 2d78c32a68 | |||
| 3d0500980a | |||
| 3551ee5077 |
@@ -1,7 +1,7 @@
|
||||
appId: org.jeffvli.feishin
|
||||
productName: Feishin
|
||||
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||
electronVersion: 39.4.0
|
||||
electronVersion: 41.7.0
|
||||
directories:
|
||||
buildResources: assets
|
||||
files:
|
||||
@@ -47,7 +47,7 @@ mac:
|
||||
gatekeeperAssess: false
|
||||
notarize: false
|
||||
extendInfo:
|
||||
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
|
||||
NSAudioCaptureUsageDescription: 'System audio access is required for mpv visualizer capture in Feishin'
|
||||
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
|
||||
|
||||
dmg:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
appId: org.jeffvli.feishin
|
||||
productName: Feishin
|
||||
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||
electronVersion: 39.4.0
|
||||
electronVersion: 41.7.0
|
||||
directories:
|
||||
buildResources: assets
|
||||
files:
|
||||
@@ -47,7 +47,7 @@ mac:
|
||||
gatekeeperAssess: false
|
||||
notarize: false
|
||||
extendInfo:
|
||||
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
|
||||
NSAudioCaptureUsageDescription: 'System audio access is required for mpv visualizer capture in Feishin'
|
||||
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
|
||||
|
||||
dmg:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
appId: org.jeffvli.feishin
|
||||
productName: Feishin
|
||||
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||
electronVersion: 39.4.0
|
||||
electronVersion: 41.7.0
|
||||
directories:
|
||||
buildResources: assets
|
||||
files:
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^39.8.6",
|
||||
"electron": "^41.7.0",
|
||||
"electron-builder": "^26.8.2",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electron-vite": "^4.0.1",
|
||||
|
||||
Generated
+13
-25
@@ -23,10 +23,10 @@ importers:
|
||||
version: 1.1.0
|
||||
'@electron-toolkit/preload':
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2(electron@39.8.6)
|
||||
version: 3.0.2(electron@41.7.0)
|
||||
'@electron-toolkit/utils':
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0(electron@39.8.6)
|
||||
version: 4.0.0(electron@41.7.0)
|
||||
'@mantine/colors-generator':
|
||||
specifier: ^9.1.1
|
||||
version: 9.1.1(chroma-js@3.1.2)
|
||||
@@ -269,8 +269,8 @@ importers:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0
|
||||
electron:
|
||||
specifier: ^39.8.6
|
||||
version: 39.8.6
|
||||
specifier: ^41.7.0
|
||||
version: 41.7.0
|
||||
electron-builder:
|
||||
specifier: ^26.8.2
|
||||
version: 26.8.2(electron-builder-squirrel-windows@26.8.2)
|
||||
@@ -2126,9 +2126,6 @@ packages:
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/node@22.19.17':
|
||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
||||
|
||||
'@types/node@24.12.2':
|
||||
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
|
||||
|
||||
@@ -3001,8 +2998,8 @@ packages:
|
||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
electron@39.8.6:
|
||||
resolution: {integrity: sha512-uWX6Jh5LmwL13VwOSKBjebI+ck+03GOwc8V2Sgbmr9pJVJ/cHfli/PkjXuRDr+hq+SLHQuT9mGHSIfScebApRA==}
|
||||
electron@41.7.0:
|
||||
resolution: {integrity: sha512-U6KAKivjk6YQ0Z5+eloJBjwhbHRE206gvy1UBMw2bSluWtMh5waeXMvX6AT/Ujm5ymYXVJOp7g9N7vOFw16wBQ==}
|
||||
engines: {node: '>= 12.20.55'}
|
||||
hasBin: true
|
||||
|
||||
@@ -5507,9 +5504,6 @@ packages:
|
||||
underscore.string@3.3.6:
|
||||
resolution: {integrity: sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
@@ -6684,17 +6678,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@electron-toolkit/preload@3.0.2(electron@39.8.6)':
|
||||
'@electron-toolkit/preload@3.0.2(electron@41.7.0)':
|
||||
dependencies:
|
||||
electron: 39.8.6
|
||||
electron: 41.7.0
|
||||
|
||||
'@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)':
|
||||
dependencies:
|
||||
'@types/node': 24.12.2
|
||||
|
||||
'@electron-toolkit/utils@4.0.0(electron@39.8.6)':
|
||||
'@electron-toolkit/utils@4.0.0(electron@41.7.0)':
|
||||
dependencies:
|
||||
electron: 39.8.6
|
||||
electron: 41.7.0
|
||||
|
||||
'@electron/asar@3.4.1':
|
||||
dependencies:
|
||||
@@ -7675,7 +7669,7 @@ snapshots:
|
||||
|
||||
'@types/electron-localshortcut@3.1.3':
|
||||
dependencies:
|
||||
electron: 39.8.6
|
||||
electron: 41.7.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7703,10 +7697,6 @@ snapshots:
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/node@22.19.17':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@24.12.2':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
@@ -8800,10 +8790,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron@39.8.6:
|
||||
electron@41.7.0:
|
||||
dependencies:
|
||||
'@electron/get': 2.0.3
|
||||
'@types/node': 22.19.17
|
||||
'@types/node': 24.12.2
|
||||
extract-zip: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -11588,8 +11578,6 @@ snapshots:
|
||||
sprintf-js: 1.1.3
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
undici@6.24.1: {}
|
||||
|
||||
+42
-20
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"sidebar": {
|
||||
"myLibrary": "La meva llibreria",
|
||||
"myLibrary": "La meva mediateca",
|
||||
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
|
||||
"albums": "$t(entity.album, {\"count\": 2})",
|
||||
"artists": "$t(entity.artist, {\"count\": 2})",
|
||||
@@ -340,7 +340,8 @@
|
||||
"filter_multiple": "Multi",
|
||||
"rename": "Reanomena",
|
||||
"newVersionAvailable": "Hi ha una nova versió disponible",
|
||||
"numberOfResults": "{{numberOfResults}} resultats"
|
||||
"numberOfResults": "{{numberOfResults}} resultats",
|
||||
"back": "Enrere"
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Àlbum",
|
||||
@@ -557,7 +558,9 @@
|
||||
"selectRangeOfItems": "Selecciona un interval d'elements",
|
||||
"selectAll": "Selecciona-ho tot",
|
||||
"openApplicationDirectory": "Obre el directori de l'aplicació",
|
||||
"goToCurrent": "Anar a l'element actual"
|
||||
"goToCurrent": "Anar a l'element actual",
|
||||
"collapseAllFolders": "Replega totes les carpetes",
|
||||
"expandAllFolders": "Expandeix totes les carpetes"
|
||||
},
|
||||
"setting": {
|
||||
"language_description": "Estableix l'idioma de l'aplicació ($t(common.restartRequired))",
|
||||
@@ -921,8 +924,8 @@
|
||||
"primaryShade_description": "Substitueix el to primari (0–9) utilitzat per a botons, enllaços i altres elements de color primari",
|
||||
"playerItemConfiguration_description": "Configurar quins elements es mostren i en quin ordre al reproductor de pantalla completa",
|
||||
"playerItemConfiguration": "Configuració d'elements del jugador",
|
||||
"listenbrainz_description": "mostra enllaços a ListenBrainz a les pàgines d'artista/àlbum",
|
||||
"listenbrainz": "mostra enllaços a ListenBrainz",
|
||||
"listenbrainz_description": "Mostra enllaços a ListenBrainz a les pàgines d'artista/àlbum",
|
||||
"listenbrainz": "Mostra enllaços a ListenBrainz",
|
||||
"qobuz_description": "Mostra enllaços a Qobuz a les pàgines d'artista/àlbum",
|
||||
"qobuz": "Mostra enllaços a Qobuz",
|
||||
"spotify_description": "Mostra enllaços a Spotify a les pàgines d'artista/àlbum",
|
||||
@@ -930,25 +933,43 @@
|
||||
"nativeSpotify_description": "Obre amb Spotify en lloc del vostre navegador",
|
||||
"nativeSpotify": "Fes servir Spotify",
|
||||
"playerbarWaveformStretch": "Extensió de la forma d'ona",
|
||||
"playerbarWaveformStretch_description": "estén la forma d'ona per omplir l'espai disponible",
|
||||
"playerbarWaveformStretch_description": "Estén la forma d'ona per omplir l'espai disponible",
|
||||
"sidePlayQueueLayout": "Disposició de la cua de reproducció lateral",
|
||||
"sidePlayQueueLayout_description": "estableix la disposició de la cua de reproducció lateral adjunta",
|
||||
"sidePlayQueueLayout_optionHorizontal": "horitzontal",
|
||||
"sidePlayQueueLayout_optionVertical": "vertical",
|
||||
"sidePlayQueueLayout_description": "Estableix la disposició de la cua de reproducció lateral adjunta",
|
||||
"sidePlayQueueLayout_optionHorizontal": "Horitzontal",
|
||||
"sidePlayQueueLayout_optionVertical": "Vertical",
|
||||
"waveformLoadingDelay": "Demora de càrrega de la forma d'ona",
|
||||
"waveformLoadingDelay_description": "Demora en segons abans de carregar la forma d'ona. Incrementeu aquest valor si patiu interrupcions en fer servir el reproductor web.",
|
||||
"preventSuspendOnPlayback_description": "Evita que l'aplicació quedi suspesa mentre es reprodueix música",
|
||||
"preventSuspendOnPlayback": "Evita la suspensió durant la reproducció"
|
||||
"preventSuspendOnPlayback": "Evita la suspensió durant la reproducció",
|
||||
"hotkey_listShowPlayingSong": "Mostra la cançó en reproducció a la llista",
|
||||
"sidebarPlaylistFolders_description": "Create una vista de carpeta per lliste de reproducció que inclogui el separador configurat al nom",
|
||||
"sidebarPlaylistFolders": "Habilita les carpetes",
|
||||
"sidebarPlaylistFolderSeparator_description": "Caràcter (o cadena) que separa els nivells de carpetes al nom d'una llista de reproducció",
|
||||
"sidebarPlaylistFolderSeparator": "Separador de carpetes",
|
||||
"sidebarPlaylistFolderView_description": "Com es mostren les carpetes a la barra lateral",
|
||||
"sidebarPlaylistFolderView": "Vista de carpeta",
|
||||
"sidebarPlaylistFolderView_optionSingle": "Carpeta única",
|
||||
"sidebarPlaylistFolderView_optionTree": "Vista d'arbre",
|
||||
"sidebarPlaylistFolderView_optionNavigation": "Vista de navegació",
|
||||
"sidebarPlaylistFolderTreeIndent_description": "Píxels de sagnat per cada nivell de l'arbre",
|
||||
"sidebarPlaylistFolderTreeIndent": "Sagnat de l'arbre",
|
||||
"sidebarPlaylistFolderTreeLineColor_description": "Color de les línies connectores de l'arbre (deixeu-ho en blanc per fer servir el valor predeterminat)",
|
||||
"sidebarPlaylistFolderTreeLineColor": "Color de la línia de l'arbre",
|
||||
"sidebarPlaylistMode_description": "Com es mostra cada llista de reproducció a la llista de la barra lateral",
|
||||
"sidebarPlaylistMode": "Mode de llista de reproducció a la barra lateral",
|
||||
"sidebarPlaylistMode_optionCompact": "Compacte",
|
||||
"sidebarPlaylistMode_optionExpanded": "Expandit"
|
||||
},
|
||||
"table": {
|
||||
"column": {
|
||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"codec": "$t(common.codec)",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"size": "$t(common.size)",
|
||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
||||
"albumCount": "Àlbums",
|
||||
"artist": "Artista",
|
||||
"channels": "Canals",
|
||||
"codec": "Còdec",
|
||||
"genre": "Gènere",
|
||||
"size": "Mida",
|
||||
"songCount": "Pistes",
|
||||
"releaseYear": "Any",
|
||||
"playCount": "Reproduccions",
|
||||
"releaseDate": "Data de llançament",
|
||||
@@ -966,8 +987,8 @@
|
||||
"path": "Ruta",
|
||||
"rating": "Qualificació",
|
||||
"title": "Títol",
|
||||
"bitDepth": "$t(common.bitDepth)",
|
||||
"sampleRate": "$t(common.sampleRate)",
|
||||
"bitDepth": "Profunditat de bits",
|
||||
"sampleRate": "Freqüència de mostreig",
|
||||
"owner": "Propietari"
|
||||
},
|
||||
"config": {
|
||||
@@ -1146,7 +1167,8 @@
|
||||
"sleepTimer_timeRemaining": "Queden {{time}}",
|
||||
"sleepTimer_setCustom": "Configura el temporitzador",
|
||||
"sleepTimer_cancel": "Cancel·la el temporitzador",
|
||||
"albumRadio": "Ràdio d'àlbums"
|
||||
"albumRadio": "Ràdio d'àlbums",
|
||||
"scrobbleForceSubmit": "Força l'scrobble"
|
||||
},
|
||||
"error": {
|
||||
"credentialsRequired": "Credencials requerides",
|
||||
|
||||
+19
-14
@@ -48,7 +48,8 @@
|
||||
"holdToShuffle": "長押しでシャッフル",
|
||||
"albumRadio": "アルバム・ラジオ",
|
||||
"artistRadio": "アーティストラジオ",
|
||||
"trackRadio": "ラジオを追跡する"
|
||||
"trackRadio": "ラジオを追跡する",
|
||||
"scrobbleForceSubmit": "強制 Scrobble"
|
||||
},
|
||||
"setting": {
|
||||
"crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します",
|
||||
@@ -429,7 +430,8 @@
|
||||
"playerbarWaveformStretch": "波形伸縮",
|
||||
"playerbarWaveformStretch_description": "波形を伸縮させて、利用可能なスペースを埋めます",
|
||||
"preventSuspendOnPlayback_description": "音楽再生中にアプリケーションが停止しないようにします",
|
||||
"preventSuspendOnPlayback": "再生の中断を防止する"
|
||||
"preventSuspendOnPlayback": "再生の中断を防止する",
|
||||
"hotkey_listShowPlayingSong": "再生中の曲をリストに表示"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
|
||||
@@ -473,7 +475,9 @@
|
||||
"openApplicationDirectory": "アプリケーションディレクトリを開く",
|
||||
"selectRangeOfItems": "項目の範囲を選択",
|
||||
"addOrRemoveFromSelection": "選択に追加または削除",
|
||||
"goToCurrent": "現在の項目へ移動"
|
||||
"goToCurrent": "現在の項目へ移動",
|
||||
"collapseAllFolders": "すべてのフォルダーを折りたたむ",
|
||||
"expandAllFolders": "すべてのフォルダーを展開する"
|
||||
},
|
||||
"common": {
|
||||
"backward": "戻る",
|
||||
@@ -594,7 +598,8 @@
|
||||
"rename": "名前を変更",
|
||||
"newVersionAvailable": "新しいバージョンが利用可能です",
|
||||
"numberOfResults": "{{numberOfResults}} 件の結果",
|
||||
"grouping": "グループ化"
|
||||
"grouping": "グループ化",
|
||||
"back": "戻る"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -643,7 +648,7 @@
|
||||
"titleCombined": "$t(common.title) (結合)",
|
||||
"dateAdded": "追加日",
|
||||
"size": "$t(common.size)",
|
||||
"bpm": "$t(common.BPM)",
|
||||
"bpm": "$t(common.bpm)",
|
||||
"lastPlayed": "最後に再生",
|
||||
"trackNumber": "トラック番号",
|
||||
"rowIndex": "行インデックス",
|
||||
@@ -681,7 +686,7 @@
|
||||
"rating": "評価",
|
||||
"favorite": "お気に入り",
|
||||
"playCount": "再生回数",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||
"albumCount": "アルバム",
|
||||
"releaseYear": "年",
|
||||
"lastPlayed": "最後に再生",
|
||||
"biography": "バイオグラフィー",
|
||||
@@ -690,18 +695,18 @@
|
||||
"title": "タイトル",
|
||||
"bpm": "BPM",
|
||||
"dateAdded": "追加日",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
||||
"artist": "アーティスト",
|
||||
"songCount": "トラック",
|
||||
"trackNumber": "トラック",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"genre": "ジャンル",
|
||||
"albumArtist": "アルバムアーティスト",
|
||||
"path": "パス",
|
||||
"discNumber": "ディスク",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"size": "$t(common.size)",
|
||||
"codec": "$t(common.codec)",
|
||||
"bitDepth": "$t(common.bitDepth)",
|
||||
"sampleRate": "$t(common.sampleRate)",
|
||||
"channels": "チャンネル",
|
||||
"size": "サイズ",
|
||||
"codec": "コーデック",
|
||||
"bitDepth": "ビット深度",
|
||||
"sampleRate": "サンプリング周波数",
|
||||
"owner": "所有者"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "Обновление сервера",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Vendored
-3
@@ -1,11 +1,8 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload';
|
||||
|
||||
import { PreloadApi } from './index';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: PreloadApi;
|
||||
electron: ElectronAPI;
|
||||
LEGACY_AUTHENTICATION?: boolean;
|
||||
queryLocalFonts?: () => Promise<Font[]>;
|
||||
REMOTE_URL?: string;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { electronAPI } from '@electron-toolkit/preload';
|
||||
import { contextBridge } from 'electron';
|
||||
import { contextBridge, webUtils } from 'electron';
|
||||
|
||||
import { autodiscover } from './autodiscover';
|
||||
import { browser } from './browser';
|
||||
@@ -11,12 +10,14 @@ import { mpris } from './mpris';
|
||||
import { mpvPlayer, mpvPlayerListener } from './mpv-player';
|
||||
import { remote } from './remote';
|
||||
import { utils } from './utils';
|
||||
import { visualizer } from './visualizer';
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
autodiscover,
|
||||
browser,
|
||||
discordRpc,
|
||||
getPathForFile: webUtils.getPathForFile,
|
||||
ipc,
|
||||
localSettings,
|
||||
lyrics,
|
||||
@@ -25,6 +26,7 @@ const api = {
|
||||
mpvPlayerListener,
|
||||
remote,
|
||||
utils,
|
||||
visualizer,
|
||||
};
|
||||
|
||||
export type PreloadApi = typeof api;
|
||||
@@ -34,14 +36,11 @@ export type PreloadApi = typeof api;
|
||||
// just add to the DOM global.
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI);
|
||||
contextBridge.exposeInMainWorld('api', api);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore (define in dts)
|
||||
window.electron = electronAPI;
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
.cell-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.play-target {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.index-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import styles from './row-play-control-cell.module.css';
|
||||
|
||||
import { ItemRowPlayControls } from '/@/renderer/features/shared/components/item-row-play-controls';
|
||||
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
export const ItemDetailRowPlayControlCell = ({
|
||||
indexContent,
|
||||
onPlay,
|
||||
showPlayControls,
|
||||
}: {
|
||||
indexContent: ReactNode;
|
||||
onPlay: (playType: Play) => void;
|
||||
showPlayControls: boolean;
|
||||
}) => {
|
||||
if (!showPlayControls) {
|
||||
return <>{indexContent}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.cellWrapper}>
|
||||
<HoverCard
|
||||
offset={{ crossAxis: 32, mainAxis: 16 }}
|
||||
openDelay={300}
|
||||
position="top"
|
||||
withArrow
|
||||
>
|
||||
<HoverCard.Target>
|
||||
<div className={styles.playTarget}>{indexContent}</div>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
|
||||
<ItemRowPlayControls onPlay={onPlay} />
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,46 @@
|
||||
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
|
||||
import styles from './row-play-control-cell.module.css';
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
import { useDetailRowPlayControl } from './use-detail-row-play-control';
|
||||
|
||||
export const TrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
|
||||
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { TableColumn } from '/@/shared/types/types';
|
||||
|
||||
const formatTrackNumber = (song: ItemDetailListCellProps['song']) => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
const track = song.trackNumber.toString().padStart(2, '0');
|
||||
return `${disc}-${track}`;
|
||||
};
|
||||
|
||||
const DefaultTrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
|
||||
return <>{formatTrackNumber(song)}</>;
|
||||
};
|
||||
|
||||
const PlayableTrackNumberColumn = (props: ItemDetailListCellProps) => {
|
||||
const { handlePlay, isActive, isPlaying, showPlayControls } = useDetailRowPlayControl(props);
|
||||
|
||||
const indexContent = isActive ? (
|
||||
<div className={styles.iconWrapper}>
|
||||
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
|
||||
</div>
|
||||
) : (
|
||||
formatTrackNumber(props.song)
|
||||
);
|
||||
|
||||
return (
|
||||
<ItemDetailRowPlayControlCell
|
||||
indexContent={indexContent}
|
||||
onPlay={handlePlay}
|
||||
showPlayControls={showPlayControls}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TrackNumberColumn = (props: ItemDetailListCellProps) => {
|
||||
if (!props.columns || !isRowPlayControlColumn(TableColumn.TRACK_NUMBER, props.columns)) {
|
||||
return <DefaultTrackNumberColumn {...props} />;
|
||||
}
|
||||
|
||||
return <PlayableTrackNumberColumn {...props} />;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||
import { ItemControls } from '/@/renderer/components/item-list/types';
|
||||
import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
export interface ItemDetailListCellProps {
|
||||
columns?: ItemTableListColumnConfig[];
|
||||
controls?: ItemControls;
|
||||
internalState?: ItemListStateActions;
|
||||
isMutatingFavorite?: boolean;
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
||||
import { usePlayerStatus } from '/@/renderer/store';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
import { Play, PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
export const useDetailRowPlayControl = ({
|
||||
internalState,
|
||||
rowIndex = 0,
|
||||
song,
|
||||
}: Pick<ItemDetailListCellProps, 'internalState' | 'rowIndex' | 'song'>) => {
|
||||
const status = usePlayerStatus();
|
||||
const player = usePlayer();
|
||||
const { isActive } = useIsCurrentSong(song);
|
||||
const isPlaying = isActive && status === PlayerStatus.PLAYING;
|
||||
|
||||
const showPlayControls = !!song?.id;
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(playType: Play) => {
|
||||
if (!song) {
|
||||
return;
|
||||
}
|
||||
|
||||
playSongFromItemListControl({
|
||||
index: rowIndex,
|
||||
internalState,
|
||||
item: song as Song,
|
||||
meta: { playType, singleSongOnly: true },
|
||||
player,
|
||||
});
|
||||
},
|
||||
[internalState, player, rowIndex, song],
|
||||
);
|
||||
|
||||
return {
|
||||
handlePlay,
|
||||
isActive,
|
||||
isPlaying,
|
||||
showPlayControls,
|
||||
};
|
||||
};
|
||||
@@ -447,6 +447,14 @@
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.row .track-cell-play-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.track-row-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import styles from './item-detail-list.module.css';
|
||||
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
|
||||
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
||||
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
|
||||
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
|
||||
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
|
||||
import {
|
||||
ItemListStateActions,
|
||||
@@ -365,6 +366,7 @@ const TrackRow = memo(
|
||||
const isTitleColumn = col.id === TableColumn.TITLE;
|
||||
const isImageColumn = col.id === TableColumn.IMAGE;
|
||||
const isIconActionColumn = isNoHorizontalPaddingColumn(col.id);
|
||||
const isPlayControlColumn = isRowPlayControlColumn(col.id, columns);
|
||||
const showHoverContent = shouldShowHoverOnlyColumnContent(
|
||||
col.id,
|
||||
isRowHovered,
|
||||
@@ -374,6 +376,7 @@ const TrackRow = memo(
|
||||
const content = isSongsLoading ? null : showHoverContent ? (
|
||||
<CellComponent
|
||||
columnId={col.id}
|
||||
columns={columns}
|
||||
controls={controls}
|
||||
internalState={internalState}
|
||||
isMutatingFavorite={isMutatingFavorite}
|
||||
@@ -393,6 +396,7 @@ const TrackRow = memo(
|
||||
[styles.trackCellImage]: isImageColumn,
|
||||
[styles.trackCellMuted]: !isTitleColumn,
|
||||
[styles.trackCellNoHPadding]: isIconActionColumn,
|
||||
[styles.trackCellPlayControl]: isPlayControlColumn,
|
||||
[styles.trackCellVerticalBorderVisible]:
|
||||
enableVerticalBorders && !isLastColumn,
|
||||
[styles.trackCellWithVerticalBorder]: !isLastColumn,
|
||||
|
||||
@@ -88,7 +88,7 @@ export const RowPlayControlCell = (
|
||||
<TableColumnContainer {...props} className={styles.expansionCell}>
|
||||
<div className={styles.expansionInner}>
|
||||
{showPlayControls ? (
|
||||
<HoverCard openDelay={300} position="top" withArrow>
|
||||
<HoverCard openDelay={300} position="top" withArrow withinPortal={false}>
|
||||
<HoverCard.Target>{expansionTarget}</HoverCard.Target>
|
||||
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
|
||||
<ItemRowPlayControls onPlay={onPlay} />
|
||||
@@ -110,7 +110,7 @@ export const RowPlayControlCell = (
|
||||
|
||||
return (
|
||||
<TableColumnTextContainer {...props} className={styles.fullSizeContent}>
|
||||
<HoverCard openDelay={300} position="top" withArrow>
|
||||
<HoverCard openDelay={300} position="top" withArrow withinPortal={false}>
|
||||
<HoverCard.Target>
|
||||
<Flex className={styles.indexContent} justify="center" w="100%">
|
||||
{getIndexDisplay(false)}
|
||||
|
||||
@@ -12,10 +12,15 @@ import { Play } from '/@/shared/types/types';
|
||||
|
||||
interface PlayTrackRadioActionProps {
|
||||
disabled?: boolean;
|
||||
skipFirstSong?: boolean;
|
||||
song: Song;
|
||||
}
|
||||
|
||||
export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionProps) => {
|
||||
export const PlayTrackRadioAction = ({
|
||||
disabled,
|
||||
skipFirstSong,
|
||||
song,
|
||||
}: PlayTrackRadioActionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const serverId = useCurrentServerId();
|
||||
@@ -38,13 +43,17 @@ export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionPro
|
||||
});
|
||||
|
||||
if (similarSongs && similarSongs.length > 0) {
|
||||
player.addToQueueByData([song, ...similarSongs], playType);
|
||||
// We need to skip the first song when adding to the queue as NEXT or LAST, otherwise you will have a duplicate song
|
||||
const shouldSkipFirstSong =
|
||||
skipFirstSong && (playType === Play.NEXT || playType === Play.LAST);
|
||||
const queueSongs = shouldSkipFirstSong ? similarSongs : [song, ...similarSongs];
|
||||
player.addToQueueByData(queueSongs, playType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load track radio:', error);
|
||||
}
|
||||
},
|
||||
[player, queryClient, serverId, song],
|
||||
[player, queryClient, serverId, skipFirstSong, song],
|
||||
);
|
||||
|
||||
const handlePlayTrackRadioNow = useCallback(() => {
|
||||
|
||||
@@ -35,7 +35,7 @@ export const QueueContextMenu = ({ items }: QueueContextMenuProps) => {
|
||||
<MoveQueueItemsAction items={items} />
|
||||
<ShuffleItemsAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<PlayTrackRadioAction disabled={items.length > 1} song={items[0]} />
|
||||
<PlayTrackRadioAction disabled={items.length > 1} skipFirstSong song={items[0]} />
|
||||
<ContextMenu.Divider />
|
||||
<AddToPlaylistAction items={ids} itemType={LibraryItem.SONG} />
|
||||
<ContextMenu.Divider />
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface WebPlayerEngineHandle extends AudioPlayer {
|
||||
interface WebPlayerEngineProps {
|
||||
isMuted: boolean;
|
||||
isTransitioning: boolean;
|
||||
loopPlayer1: boolean;
|
||||
loopPlayer2: boolean;
|
||||
onEndedPlayer1: () => void;
|
||||
onEndedPlayer2: () => void;
|
||||
onErrorPause: () => void;
|
||||
@@ -55,6 +57,8 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
||||
const {
|
||||
isMuted,
|
||||
isTransitioning,
|
||||
loopPlayer1,
|
||||
loopPlayer2,
|
||||
onEndedPlayer1,
|
||||
onEndedPlayer2,
|
||||
onErrorPause,
|
||||
@@ -292,8 +296,9 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
||||
controls={false}
|
||||
height={0}
|
||||
id="web-player-1"
|
||||
loop={loopPlayer1}
|
||||
muted={isMuted}
|
||||
onEnded={src1 ? () => onEndedPlayer1() : undefined}
|
||||
onEnded={src1 && !loopPlayer1 ? () => onEndedPlayer1() : undefined}
|
||||
onError={handleOnError(
|
||||
player1Ref,
|
||||
() => onEndedPlayer1(),
|
||||
@@ -317,8 +322,9 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
||||
controls={false}
|
||||
height={0}
|
||||
id="web-player-2"
|
||||
loop={loopPlayer2}
|
||||
muted={isMuted}
|
||||
onEnded={src2 ? () => onEndedPlayer2() : undefined}
|
||||
onEnded={src2 && !loopPlayer2 ? () => onEndedPlayer2() : undefined}
|
||||
onError={handleOnError(
|
||||
player2Ref,
|
||||
() => onEndedPlayer2(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -454,6 +454,7 @@ const similarSongs2 = z.object({
|
||||
export enum SubsonicExtensions {
|
||||
FORM_POST = 'formPost',
|
||||
INDEX_BASED_QUEUE = 'indexBasedQueue',
|
||||
PLAYBACK_REPORT = 'playbackReport',
|
||||
SONG_LYRICS = 'songLyrics',
|
||||
TRANSCODE_OFFSET = 'transcodeOffset',
|
||||
TRANSCODING = 'transcoding',
|
||||
@@ -793,6 +794,17 @@ const getInternetRadioStations = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const reportPlaybackParameters = z.object({
|
||||
ignoreScrobble: z.boolean().optional(),
|
||||
mediaId: z.string(),
|
||||
mediaType: z.enum(['song', 'podcast']),
|
||||
playbackRate: z.number().optional(),
|
||||
positionMs: z.number(),
|
||||
state: z.enum(['starting', 'playing', 'paused', 'stopped']),
|
||||
});
|
||||
|
||||
const reportPlayback = z.null();
|
||||
|
||||
export const ssType = {
|
||||
_body: {
|
||||
getTranscodeDecision: transcodeDecisionRequestBody,
|
||||
@@ -824,6 +836,7 @@ export const ssType = {
|
||||
getTranscodeStream: getTranscodeStreamParameters,
|
||||
randomSongList: randomSongListParameters,
|
||||
removeFavorite: removeFavoriteParameters,
|
||||
reportPlayback: reportPlaybackParameters,
|
||||
savePlayQueueByIndex: savePlayQueueByIndexParameters,
|
||||
saveQueue: saveQueueParameters,
|
||||
scrobble: scrobbleParameters,
|
||||
@@ -877,6 +890,7 @@ export const ssType = {
|
||||
playQueueByIndex,
|
||||
randomSongList,
|
||||
removeFavorite,
|
||||
reportPlayback,
|
||||
saveQueue,
|
||||
scrobble,
|
||||
search3,
|
||||
|
||||
@@ -89,18 +89,11 @@ export function BaseImage({
|
||||
const effectiveImageRequest =
|
||||
isInSessionCache || !enableDebounce ? rawImageRequest : debouncedImageRequest;
|
||||
|
||||
const imageCacheKey = src ?? rawImageRequest?.cacheKey;
|
||||
const trackLoadedInInstance = enableViewport;
|
||||
|
||||
const [hasLoadedInInstance, setHasLoadedInInstance] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!trackLoadedInInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasLoadedInInstance((current) => (current ? false : current));
|
||||
}, [imageCacheKey, trackLoadedInInstance]);
|
||||
setHasLoadedInInstance(false);
|
||||
}, [effectiveImageRequest?.cacheKey]);
|
||||
|
||||
const shouldLoadImage = Boolean(
|
||||
effectiveImageRequest &&
|
||||
@@ -119,13 +112,13 @@ export function BaseImage({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!trackLoadedInInstance || !nativeImage.isLoaded || !imageCacheKey) {
|
||||
if (!nativeImage.isLoaded || !effectiveImageRequest?.cacheKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadedImageCacheKeys.add(imageCacheKey);
|
||||
setHasLoadedInInstance((current) => (current ? current : true));
|
||||
}, [imageCacheKey, nativeImage.isLoaded, trackLoadedInInstance]);
|
||||
loadedImageCacheKeys.add(effectiveImageRequest.cacheKey);
|
||||
setHasLoadedInInstance(true);
|
||||
}, [effectiveImageRequest?.cacheKey, nativeImage.isLoaded]);
|
||||
|
||||
return (
|
||||
<ImageContainer
|
||||
|
||||
@@ -26,11 +26,8 @@ export function useNativeImage({
|
||||
const loadedRequestSignatureRef = useRef<null | string>(null);
|
||||
const objectUrlRef = useRef<null | string>(null);
|
||||
const onFetchErrorRef = useRef(onFetchError);
|
||||
const requestRef = useRef(request);
|
||||
const [state, setState] = useState<NativeImageState>({ status: 'idle' });
|
||||
|
||||
requestRef.current = request;
|
||||
|
||||
const requestSignature = useMemo(() => {
|
||||
if (!request) {
|
||||
return null;
|
||||
@@ -47,8 +44,6 @@ export function useNativeImage({
|
||||
onFetchErrorRef.current = onFetchError;
|
||||
|
||||
useEffect(() => {
|
||||
const request = requestRef.current;
|
||||
|
||||
const abortCurrentRequest = () => {
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = null;
|
||||
@@ -67,61 +62,28 @@ export function useNativeImage({
|
||||
if (!request || !requestSignature) {
|
||||
abortCurrentRequest();
|
||||
revokeObjectUrl();
|
||||
setState((currentState) => {
|
||||
if (currentState.status === 'idle' && !currentState.displaySrc) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return { status: 'idle' };
|
||||
});
|
||||
setState({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
abortCurrentRequest();
|
||||
setState((currentState) => {
|
||||
if (currentState.displaySrc) {
|
||||
if (currentState.status === 'loaded') {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return { ...currentState, status: 'loaded' };
|
||||
}
|
||||
|
||||
if (currentState.status === 'idle' && !currentState.displaySrc) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return { status: 'idle' };
|
||||
});
|
||||
setState((currentState) =>
|
||||
currentState.displaySrc
|
||||
? { ...currentState, status: 'loaded' }
|
||||
: { status: 'idle' },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadedRequestSignatureRef.current === requestSignature && objectUrlRef.current) {
|
||||
const cachedObjectUrl = objectUrlRef.current;
|
||||
|
||||
setState((currentState) => {
|
||||
if (
|
||||
currentState.status === 'loaded' &&
|
||||
currentState.displaySrc === cachedObjectUrl
|
||||
) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return { displaySrc: cachedObjectUrl, status: 'loaded' };
|
||||
});
|
||||
setState({ displaySrc: objectUrlRef.current, status: 'loaded' });
|
||||
return;
|
||||
}
|
||||
|
||||
abortCurrentRequest();
|
||||
revokeObjectUrl();
|
||||
setState((currentState) => {
|
||||
if (currentState.status === 'loading' && !currentState.displaySrc) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return { status: 'loading' };
|
||||
});
|
||||
setState({ status: 'loading' });
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
@@ -176,7 +138,7 @@ export function useNativeImage({
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, fetchPriority, requestSignature]);
|
||||
}, [enabled, fetchPriority, request, requestSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -1365,6 +1365,8 @@ export type ScrobbleQuery = {
|
||||
albumId?: string;
|
||||
event?: 'pause' | 'start' | 'timeupdate' | 'unpause';
|
||||
id: string;
|
||||
mediaType: 'podcast' | 'song';
|
||||
playbackRate: number;
|
||||
position?: number;
|
||||
submission: boolean;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum ServerFeature {
|
||||
PLAYLIST_IMAGE_UPLOAD = 'playlistImageUpload',
|
||||
PLAYLISTS_SMART = 'playlistsSmart',
|
||||
PUBLIC_PLAYLIST = 'publicPlaylist',
|
||||
REPORT_PLAYBACK = 'reportPlayback',
|
||||
SERVER_PLAY_QUEUE = 'serverPlayQueue',
|
||||
SHARING_ALBUM_SONG = 'sharingAlbumSong',
|
||||
SIMILAR_SONGS_MUSIC_FOLDER = 'similarSongsMusicFolder',
|
||||
|
||||
Reference in New Issue
Block a user