Compare commits

..

38 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
jeffvli 4008c8dfdf fix NativeImage state causing crashes in table 2026-05-19 21:03:28 -07:00
jeffvli 64efbc5210 add table row playback controls
- supports song, album, artist, and album artist tables
- hovering over the first row index or track number column will display a hovercard for the playback controls
2026-05-19 21:03:27 -07:00
Kendall Garner 42e9394246 fix lint 2026-05-19 20:11:42 -07:00
Kendall Garner afbe8fac52 include add to playlist action in quick search 2026-05-19 20:06:15 -07:00
Hosted Weblate 2c1be99bd5 Translated using Weblate
Currently translated at 98.4% (1211 of 1230 strings) (French)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/

Co-authored-by: KosmoMoustache <kosmomoustache@users.noreply.hosted.weblate.org>
2026-05-19 14:48:01 +02:00
jeffvli ada94e5f5d add loading state to playlist folder move 2026-05-19 01:50:36 -07:00
jeffvli c8675ab600 prevent item drop on smart playlists 2026-05-19 01:30:27 -07:00
jeffvli 37367a6741 fix nested playlist folder drag behavior 2026-05-18 21:45:07 -07:00
jeffvli e7b65c8e86 add drag over expand/collapse behavior for playlist folders 2026-05-18 19:15:28 -07:00
jeffvli 3d1095dbd8 adjust playlist folder design, add animations 2026-05-18 18:39:55 -07:00
jeffvli da4284bac0 fix playlist folder drop on root behavior
- previously, nested folders dropped onto root would destroy the folder. instead, move the nested folder into the root
2026-05-18 18:39:55 -07:00
Braelyn Boynton f3c0b68a0f fix(player): stop radio before starting track playback (#2039)
* fix(player): stop radio before starting track playback

When internet radio is streaming, clicking a track to play does nothing
because the MPV engine guards check currentStreamUrl and bail early.
Stop the radio stream before setting up the new queue on Play.NOW and
Play.SHUFFLE so the audio engine proceeds normally.

Fixes #2038

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: correct import ordering for lint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 17:58:08 -07:00
jeffvli 5a3f2fe96f set low image fetchpriority by default 2026-05-18 17:52:28 -07:00
Kendall Garner aec5e84758 fix(navidrome): always filter by id instead of value 2026-05-18 17:17:20 -07:00
Kendall Garner e85acee3e6 fix(remote): always send remote settings on init 2026-05-17 07:22:05 -07:00
Hosted Weblate 406cf470bc Translated using Weblate
Currently translated at 100.0% (1230 of 1230 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Co-authored-by: skajmer <skajmer@protonmail.com>
2026-05-16 10:11:35 +00:00
Hosted Weblate 167ee03a1f Translated using Weblate
Currently translated at 100.0% (1230 of 1230 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

Currently translated at 100.0% (1230 of 1230 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Translated using Weblate

Currently translated at 100.0% (1230 of 1230 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: York <goog10216922@gmail.com>
2026-05-15 07:11:36 +02:00
78 changed files with 2267 additions and 610 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",
+24 -3
View File
@@ -430,7 +430,25 @@
"playerbarWaveformStretch": "Natáhnutí vlnové křivky",
"playerbarWaveformStretch_description": "Natáhně vlnovou křivku tak, aby vyplnila dostupný prostor",
"preventSuspendOnPlayback_description": "Zabránit aplikaci v uspání během přehrávání hudby",
"preventSuspendOnPlayback": "Zabránit uspání při přehrávání"
"preventSuspendOnPlayback": "Zabránit uspání při přehrávání",
"hotkey_listShowPlayingSong": "Zobrazit přehrávanou skladbu v seznamu",
"sidebarPlaylistFolders_description": "Vytvořit zobrazení složky pro seznamy skadeb, které zahrnují nastavený oddělovač v názvu",
"sidebarPlaylistFolders": "Povolit složky",
"sidebarPlaylistFolderSeparator_description": "Znak (nebo řetězec) oddělující úrovně složek v názvu seznamu skladeb",
"sidebarPlaylistFolderSeparator": "Oddělovač složek",
"sidebarPlaylistFolderView_description": "Jak jsou složky zobrazeny v postranní liště",
"sidebarPlaylistFolderView": "Zobrazení složky",
"sidebarPlaylistFolderView_optionSingle": "Jedna složka",
"sidebarPlaylistFolderView_optionTree": "Stromové zobrazení",
"sidebarPlaylistFolderView_optionNavigation": "Zobrazení navigace",
"sidebarPlaylistFolderTreeIndent_description": "Počet pixelů, o který je odsazená každá větev",
"sidebarPlaylistFolderTreeIndent": "Odsazení větví",
"sidebarPlaylistFolderTreeLineColor_description": "Barva spojujících linek větví (ponechte prázdné pro výchozí)",
"sidebarPlaylistFolderTreeLineColor": "Barva linky stromu",
"sidebarPlaylistMode_description": "Jak je každý seznam skladeb zobrazen v seznamu v postranní liště",
"sidebarPlaylistMode": "Režim seznamů skladeb v postranní liště",
"sidebarPlaylistMode_optionCompact": "Kompaktní",
"sidebarPlaylistMode_optionExpanded": "Rozšířený"
},
"action": {
"editPlaylist": "Upravit $t(entity.playlist, {\"count\": 1})",
@@ -474,7 +492,9 @@
"addOrRemoveFromSelection": "Přidat nebo odebrat z výběru",
"selectRangeOfItems": "Vyberte rozsah položek",
"selectAll": "Vybrat vše",
"goToCurrent": "Přejít na aktuální položku"
"goToCurrent": "Přejít na aktuální položku",
"collapseAllFolders": "Sbalit všechny složky",
"expandAllFolders": "Rozbalit všechny složky"
},
"common": {
"backward": "Zpátky",
@@ -603,7 +623,8 @@
"rename": "Přejmenovat",
"newVersionAvailable": "Je dostupná nová verze",
"numberOfResults": "{{numberOfResults}} výsledků",
"grouping": "Seskupování"
"grouping": "Seskupování",
"back": "Zpět"
},
"table": {
"config": {
+24 -3
View File
@@ -430,7 +430,25 @@
"playerbarWaveformStretch": "Estiramiento de la forma de onda",
"playerbarWaveformStretch_description": "Estira la forma de onda para rellenar el espacio disponible",
"preventSuspendOnPlayback": "Evitar la suspensión durante la reproducción",
"preventSuspendOnPlayback_description": "Evita que la aplicación se suspenda mientras se reproduce música"
"preventSuspendOnPlayback_description": "Evita que la aplicación se suspenda mientras se reproduce música",
"hotkey_listShowPlayingSong": "Mostrar canción en reproducción en la lista",
"sidebarPlaylistFolders": "Habilitar carpetas",
"sidebarPlaylistFolders_description": "Crea una vista de carpeta para las listas de reproducción que incluye el separador configurado en el nombre",
"sidebarPlaylistFolderSeparator_description": "Carácter (o cadena) que separa los niveles de la carpeta en un nombre de lista de reproducción",
"sidebarPlaylistFolderSeparator": "Separador de carpeta",
"sidebarPlaylistFolderView": "Vista de carpeta",
"sidebarPlaylistFolderView_description": "Cómo se muestran las carpetas en la barra lateral",
"sidebarPlaylistFolderView_optionSingle": "Carpeta simple",
"sidebarPlaylistFolderView_optionTree": "Vista en árbol",
"sidebarPlaylistFolderView_optionNavigation": "Vista de navegación",
"sidebarPlaylistFolderTreeIndent": "Sangría del árbol",
"sidebarPlaylistFolderTreeLineColor": "Color de línea del árbol",
"sidebarPlaylistFolderTreeLineColor_description": "Color de las líneas interconectadas del árbol (dejar en blanco para usar el tema predeterminado)",
"sidebarPlaylistMode": "Modo de lista de reproducción de la barra lateral",
"sidebarPlaylistMode_optionCompact": "Compacto",
"sidebarPlaylistMode_optionExpanded": "Expandido",
"sidebarPlaylistMode_description": "Cómo se muestra cada lista de reproducción en la lista de la barra lateral",
"sidebarPlaylistFolderTreeIndent_description": "Píxeles que está sangrado cada nivel del árbol"
},
"action": {
"editPlaylist": "Editar $t(entity.playlist, {\"count\": 1})",
@@ -474,7 +492,9 @@
"addOrRemoveFromSelection": "Añadir o quitar de la selección",
"selectRangeOfItems": "Seleccionar un intervalo de elementos",
"selectAll": "Seleccionar todo",
"goToCurrent": "Ir al elemento actual"
"goToCurrent": "Ir al elemento actual",
"collapseAllFolders": "Contraer todas las carpetas",
"expandAllFolders": "Expandir todas las carpetas"
},
"common": {
"backward": "Hacia atrás",
@@ -603,7 +623,8 @@
"rename": "Renombrar",
"newVersionAvailable": "Una nueva versión está disponible",
"numberOfResults": "{{numberOfResults}} resultados",
"grouping": "Agrupar"
"grouping": "Agrupar",
"back": "Atrás"
},
"error": {
"remotePortWarning": "Reiniciar el servidor para aplicar el nuevo puerto",
+29 -7
View File
@@ -48,7 +48,8 @@
"sleepTimer_timeRemaining": "{{time}} restante(s)",
"sleepTimer_setCustom": "Définir le minuteur",
"sleepTimer_cancel": "Annuler le minuteur",
"albumRadio": "Radio d'album"
"albumRadio": "Radio d'album",
"scrobbleForceSubmit": "Forcer le scrobble"
},
"action": {
"editPlaylist": "Éditer $t(entity.playlist, {\"count\": 1})",
@@ -92,7 +93,9 @@
"selectRangeOfItems": "Sélectionner une plage d'entrées",
"selectAll": "Tout sélectionner",
"openApplicationDirectory": "Ouvrir le répertoire de l'application",
"goToCurrent": "Aller à la piste en cours"
"goToCurrent": "Aller à la piste en cours",
"collapseAllFolders": "Réduire tous les dossiers",
"expandAllFolders": "Développer tous les dossiers"
},
"common": {
"backward": "En arrière",
@@ -222,7 +225,8 @@
"filter_multiple": "Multiple",
"rename": "Renommer",
"newVersionAvailable": "Une nouvelle version est disponible",
"numberOfResults": "{{numberOfResults}} résultats"
"numberOfResults": "{{numberOfResults}} résultats",
"back": "Retour"
},
"error": {
"remotePortWarning": "Redémarrer le serveur pour appliquer le nouveau port",
@@ -699,7 +703,7 @@
"transcodeFormat_description": "Sélectionne le format du transcodage. laisser vide pour laisser le serveur décider",
"volumeWidth": "Largeur de la barre de volume",
"volumeWidth_description": "La largeur de la barre de volume",
"customCssEnable": "Active le CSS personnalisé",
"customCssEnable": "Activer le CSS personnalisé",
"customCssEnable_description": "Permet l'écriture de CSS personnalisé",
"customCssNotice": "Attention : bien qu'il y ait un certain assainissement (blocage de URL() et de content :), l'utilisation de CSS personnalisé peut toujours présenter des risques en modifiant l'interface",
"customCss": "Css personnalisé",
@@ -895,7 +899,24 @@
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
"sidePlayQueueLayout_optionVertical": "Vertical",
"waveformLoadingDelay": "Délai de chargement de la forme d'onde",
"waveformLoadingDelay_description": "Délai en secondes avant le chargement de l'onde. augmentez cette valeur si vous rencontrez des saccades lors de l'utilisation du lecteur web."
"waveformLoadingDelay_description": "Délai en secondes avant le chargement de l'onde. augmentez cette valeur si vous rencontrez des saccades lors de l'utilisation du lecteur web.",
"sidebarPlaylistMode_optionExpanded": "Étendu",
"hotkey_listShowPlayingSong": "Affiche le titre en cours dans la liste",
"preventSuspendOnPlayback_description": "Empêcher la suspension de l'application lors de la lecture de musique",
"preventSuspendOnPlayback": "Empêcher la suspension lors de la lecture",
"sidebarPlaylistFolders_description": "Crée une vue de dossier pour les listes de lecture dont le nom inclut le séparateur configuré",
"sidebarPlaylistFolders": "Activer les dossiers",
"sidebarPlaylistFolderSeparator_description": "Caractère (ou chaîne de caractères) servant à séparer les niveaux de dossiers dans le nom d'une liste de lecture",
"sidebarPlaylistFolderSeparator": "Séparateur de dossiers",
"sidebarPlaylistFolderView_description": "Comment les dossiers sont affichés dans la barre latérale",
"sidebarPlaylistFolderView": "Affichage des dossiers",
"sidebarPlaylistFolderView_optionSingle": "Dossier unique",
"sidebarPlaylistFolderView_optionTree": "Vue arborescente",
"sidebarPlaylistFolderView_optionNavigation": "Vue de navigation",
"sidebarPlaylistFolderTreeIndent": "Indentation de l'arbre",
"sidebarPlaylistMode_description": "Comment chaque liste de lecture est affichée dans la barre latérale",
"sidebarPlaylistMode": "Mode de liste de lecture de la barre latérale",
"sidebarPlaylistMode_optionCompact": "Compacte"
},
"form": {
"deletePlaylist": {
@@ -922,12 +943,13 @@
"input_remoteUrlPlaceholder": "Optionnel : URL publique pour les fonctionnalités externes"
},
"addToPlaylist": {
"success": "$t(entity.trackWithCount, {\"count\" : {{message}} }) ajouté à $t(entity.playlistWithCount, {\"count\" : {{numOfPlaylists}} })",
"success": "$t(entity.trackWithCount, {\"count\": {{message}} }) ajouté à $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "Ajouter à $t(entity.playlist, {\"count\": 1})",
"input_skipDuplicates": "Sauter les doublons",
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"create": "Créer $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "Rechercher $t(entity.playlist, {\"count\": 2}) ou tapez pour en créer une nouvelle"
"searchOrCreate": "Rechercher $t(entity.playlist, {\"count\": 2}) ou tapez pour en créer une nouvelle",
"noneAdded": "Aucune piste n'a été ajoutée à $t(entity.playlist, {\"count\": 1}) {{playlist}}"
},
"createPlaylist": {
"title": "Créer une $t(entity.playlist, {\"count\": 1})",
+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": "所有者"
}
},
+25 -4
View File
@@ -41,7 +41,9 @@
"selectRangeOfItems": "Wybierz zakres elementów",
"selectAll": "Wybierz wszystkie",
"openApplicationDirectory": "Otwórz katalog aplikacji",
"goToCurrent": "Przejdź do aktualnego elementu"
"goToCurrent": "Przejdź do aktualnego elementu",
"collapseAllFolders": "Zwiń wszystkie foldery",
"expandAllFolders": "Rozwiń wszystkie foldery"
},
"common": {
"increase": "Zwiększ",
@@ -170,7 +172,8 @@
"rename": "Zmień nazwę",
"newVersionAvailable": "Nowa wersja jest dostępna",
"numberOfResults": "{{numberOfResults}} wyników",
"grouping": "Grupowanie"
"grouping": "Grupowanie",
"back": "Wstecz"
},
"entity": {
"genre_one": "Gatunek",
@@ -1072,7 +1075,25 @@
"playerbarWaveformStretch": "Rozciąganie przebiegu",
"playerbarWaveformStretch_description": "Rozciąga przebieg aby wypełnić dostępną przestrzeń",
"preventSuspendOnPlayback_description": "Powstrzymuj wstrzymanie podczas odtwarzania muzyki",
"preventSuspendOnPlayback": "Powstrzymuje wstrzymanie przy odtwarzaniu"
"preventSuspendOnPlayback": "Powstrzymuje wstrzymanie przy odtwarzaniu",
"hotkey_listShowPlayingSong": "Pokaż odtwarzaną piosenkę w liście",
"sidebarPlaylistFolders_description": "Utwórz widok folderu dla playlist który zawiera skonfigurowany separator w nazwie",
"sidebarPlaylistFolders": "Włącz foldery",
"sidebarPlaylistFolderSeparator_description": "Znak (lub ciąg) który oddziela poziomy folderów w nazwie playlisty",
"sidebarPlaylistFolderSeparator": "Separator folderów",
"sidebarPlaylistFolderView_description": "Jak foldery są wyświetlane w pasku bocznym",
"sidebarPlaylistFolderView": "Widok folderów",
"sidebarPlaylistFolderView_optionSingle": "Pojedynczy folder",
"sidebarPlaylistFolderView_optionTree": "Widok drzewa",
"sidebarPlaylistFolderView_optionNavigation": "Widok nawigacji",
"sidebarPlaylistFolderTreeIndent_description": "Piksele na każdym poziomie drzewa mają wcięcie",
"sidebarPlaylistFolderTreeIndent": "Wcięcie drzewa",
"sidebarPlaylistFolderTreeLineColor_description": "Kolor linii łączących drzewo (pozostaw pusty dla domyślnego dla motywu)",
"sidebarPlaylistFolderTreeLineColor": "Kolor linii drzewa",
"sidebarPlaylistMode_description": "Jak każda z playlist jest wyświetlana w liście w pasku bocznym",
"sidebarPlaylistMode": "Tryb playlist bocznego paska",
"sidebarPlaylistMode_optionCompact": "Kompaktowy",
"sidebarPlaylistMode_optionExpanded": "Rozszerzony"
},
"table": {
"config": {
@@ -1090,7 +1111,7 @@
"size": "$t(common.size)",
"itemSize": "Rozmiar elementu (px)",
"itemGap": "Odstęp między elementami (px)",
"followCurrentSong": LEDź aktualną piosenkę",
"followCurrentSong": ledź aktualną piosenkę",
"advancedSettings": "Zaawansowane ustawienia",
"autosize": "Rozmiar automatyczny",
"moveUp": "Przesuń w górę",
+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": "Обновление сервера",
+20 -2
View File
@@ -118,7 +118,8 @@
"filter_multiple": "複選",
"newVersionAvailable": "有新的版本可供使用",
"numberOfResults": "{{numberOfResults}} 項結果",
"grouping": "分組"
"grouping": "分組",
"back": "返回"
},
"error": {
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
@@ -800,7 +801,24 @@
"playerbarWaveformStretch_description": "拉伸波形來填補可用空間",
"preventSuspendOnPlayback_description": "音樂播放時防止應用程式進入休眠",
"preventSuspendOnPlayback": "在播放時防止應用程式暫停",
"hotkey_listShowPlayingSong": "在清單中顯示正在播放的歌曲"
"hotkey_listShowPlayingSong": "在清單中顯示正在播放的歌曲",
"sidebarPlaylistFolders_description": "為名稱中包含配置分隔符的播放清單建立資料夾檢視",
"sidebarPlaylistFolders": "啟用資料夾",
"sidebarPlaylistFolderSeparator_description": "在播放清單名稱中分隔資料夾級別的字元(或字串)",
"sidebarPlaylistFolderSeparator": "資料夾分隔符",
"sidebarPlaylistFolderView_description": "資料夾在側邊欄中顯示的方式",
"sidebarPlaylistFolderView": "資料夾檢視",
"sidebarPlaylistFolderView_optionSingle": "單一資料夾",
"sidebarPlaylistFolderView_optionTree": "樹狀顯示",
"sidebarPlaylistFolderView_optionNavigation": "導覽視圖",
"sidebarPlaylistFolderTreeIndent_description": "每個樹狀層級縮排的像素",
"sidebarPlaylistFolderTreeIndent": "樹狀縮排",
"sidebarPlaylistFolderTreeLineColor_description": "樹狀連接線的顏色(留空則使用佈景主題預設值)",
"sidebarPlaylistFolderTreeLineColor": "樹狀線條顏色",
"sidebarPlaylistMode_description": "各播放清單在側邊欄列表中的顯示方式",
"sidebarPlaylistMode": "側邊欄播放清單模式",
"sidebarPlaylistMode_optionCompact": "緊湊",
"sidebarPlaylistMode_optionExpanded": "展開"
},
"table": {
"config": {
+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,
@@ -100,9 +100,6 @@ const EXCLUDED_SONG_TAGS = new Set<string>(['disctotal', 'tracktotal']);
const numericSortCollator = new Intl.Collator(undefined, { numeric: true });
const collator = new Intl.Collator();
// Tags that use IDs as values as opposed to the tag value
const ID_TAGS = new Set<string>(['albumversion', 'mood']);
const excludeMissing = (server?: null | ServerListItemWithCredential) => {
if (!server) {
return undefined;
@@ -965,13 +962,13 @@ export const NavidromeController: InternalControllerEndpoint = {
if (!EXCLUDED_TAGS.has(tag.tagName)) {
if (tagsToValues.has(tag.tagName)) {
tagsToValues.get(tag.tagName)!.push({
id: ID_TAGS.has(tag.tagName) ? tag.id : tag.tagValue,
id: tag.id,
name: tag.tagValue,
});
} else {
tagsToValues.set(tag.tagName, [
{
id: ID_TAGS.has(tag.tagName) ? tag.id : tag.tagValue,
id: tag.id,
name: tag.tagValue,
},
]);
@@ -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,
@@ -0,0 +1,26 @@
import { ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
import { TableColumn } from '/@/shared/types/types';
const ROW_PLAY_CONTROL_COLUMN_IDS = [TableColumn.TRACK_NUMBER, TableColumn.ROW_INDEX] as const;
type RowPlayControlColumnId = (typeof ROW_PLAY_CONTROL_COLUMN_IDS)[number];
const isRowPlayControlColumnId = (columnId: TableColumn): columnId is RowPlayControlColumnId =>
ROW_PLAY_CONTROL_COLUMN_IDS.includes(columnId as RowPlayControlColumnId);
export const getRowPlayControlColumnId = (
columns: Array<Pick<ItemTableListColumnConfig, 'id'>>,
): null | TableColumn => {
for (const column of columns) {
if (isRowPlayControlColumnId(column.id)) {
return column.id;
}
}
return null;
};
export const isRowPlayControlColumn = (
columnId: TableColumn,
columns: Array<Pick<ItemTableListColumnConfig, 'id'>>,
): boolean => getRowPlayControlColumnId(columns) === columnId;
@@ -0,0 +1,74 @@
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { Album, AlbumArtist, Artist, LibraryItem, Song } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
type PlayableArtistItem = AlbumArtist | Artist;
interface PlayerQueueByDataActions {
addToQueueByData: (data: Song[], type: Play, playSongId?: string) => void;
}
interface PlayerQueueByFetchActions {
addToQueueByFetch: (
serverId: string,
ids: string[],
itemType: LibraryItem,
playType: Play,
) => void;
}
export const playSongFromItemListControl = ({
index,
internalState,
item,
meta,
player,
}: {
index?: number;
internalState?: ItemListStateActions;
item: Song;
meta?: Record<string, unknown>;
player: PlayerQueueByDataActions;
}) => {
const playType = (meta?.playType as Play) || Play.NOW;
const singleSongOnly = meta?.singleSongOnly === true;
if (singleSongOnly) {
player.addToQueueByData([item], playType, item.id);
return;
}
const items = internalState?.getData() as Song[];
if (index !== undefined && items) {
player.addToQueueByData(items, playType, item.id);
}
};
export const playAlbumFromItemListControl = ({
album,
meta,
player,
}: {
album: Album;
meta?: Record<string, unknown>;
player: PlayerQueueByFetchActions;
}) => {
const playType = (meta?.playType as Play) || Play.NOW;
player.addToQueueByFetch(album._serverId, [album.id], LibraryItem.ALBUM, playType);
};
export const playArtistFromItemListControl = ({
artist,
itemType,
meta,
player,
}: {
artist: PlayableArtistItem;
itemType: LibraryItem.ALBUM_ARTIST | LibraryItem.ARTIST;
meta?: Record<string, unknown>;
player: PlayerQueueByFetchActions;
}) => {
const playType = (meta?.playType as Play) || Play.NOW;
player.addToQueueByFetch(artist._serverId, [artist.id], itemType, playType);
};
@@ -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,
@@ -1,3 +1,45 @@
.full-size-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: visible;
-webkit-line-clamp: unset;
line-clamp: unset;
}
.expansion-cell {
overflow: visible;
}
.expansion-inner {
position: relative;
width: 100%;
height: 100%;
overflow: visible;
}
.play-target {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.expand {
position: absolute;
top: 50%;
left: 50%;
z-index: 4;
transform: translate(-50%, -50%);
}
.index-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
@@ -2,30 +2,38 @@ import clsx from 'clsx';
import styles from './row-index-column.module.css';
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
import { RowPlayControlCell } from '/@/renderer/components/item-list/item-table-list/columns/row-play-control-cell';
import { useRowPlayControl } from '/@/renderer/components/item-list/item-table-list/columns/use-row-play-control';
import {
ItemTableListInnerColumn,
TableColumnContainer,
TableColumnTextContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { ItemListItem } from '/@/renderer/components/item-list/types';
import { usePlayerStatus } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Flex } from '/@/shared/components/flex/flex';
import { Icon } from '/@/shared/components/icon/icon';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
import { PlayerStatus } from '/@/shared/types/types';
import { LibraryItem } from '/@/shared/types/domain-types';
import { TableColumn } from '/@/shared/types/types';
const RowIndexColumnBase = (props: ItemTableListInnerColumn) => {
const { itemType } = props;
if (!isRowPlayControlColumn(TableColumn.ROW_INDEX, props.columns)) {
return <DefaultRowIndexColumn {...props} />;
}
switch (itemType) {
case LibraryItem.ALBUM:
case LibraryItem.ALBUM_ARTIST:
case LibraryItem.ARTIST:
case LibraryItem.FOLDER:
case LibraryItem.PLAYLIST_SONG:
case LibraryItem.QUEUE_SONG:
case LibraryItem.SONG:
return <QueueSongRowIndexColumn {...props} />;
return <PlayableRowIndexColumn {...props} />;
default:
return <DefaultRowIndexColumn {...props} />;
}
@@ -88,12 +96,8 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
return <TableColumnTextContainer {...props}>{adjustedRowIndex}</TableColumnTextContainer>;
};
const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => {
const status = usePlayerStatus();
const song = (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex]) as QueueSong;
const isActive = useIsActiveRow(song?.id, song?._uniqueId);
const isActiveAndPlaying = isActive && status === PlayerStatus.PLAYING;
const PlayableRowIndexColumn = (props: ItemTableListInnerColumn) => {
const { handlePlay, isActive, isPlaying, showPlayControls } = useRowPlayControl(props);
let adjustedRowIndex =
props.getAdjustedRowIndex?.(props.rowIndex) ??
@@ -104,38 +108,20 @@ const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => {
adjustedRowIndex = props.startRowIndex + adjustedRowIndex;
}
const indexContent = isActive ? (
<Flex className={styles.indexContent}>
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
</Flex>
) : (
adjustedRowIndex
);
return (
<InnerQueueSongRowIndexColumn
<RowPlayControlCell
{...props}
adjustedRowIndex={adjustedRowIndex}
isActive={isActive}
isPlaying={isActiveAndPlaying}
indexContent={indexContent}
onPlay={handlePlay}
showPlayControls={showPlayControls}
/>
);
};
const InnerQueueSongRowIndexColumn = (
props: ItemTableListInnerColumn & {
adjustedRowIndex: number;
isActive: boolean;
isPlaying: boolean;
},
) => {
return (
<TableColumnTextContainer {...props}>
{props.isActive ? (
props.isPlaying ? (
<Flex>
<Icon fill="primary" icon="mediaPlay" />
</Flex>
) : (
<Flex>
<Icon fill="primary" icon="mediaPause" />
</Flex>
)
) : (
props.adjustedRowIndex
)}
</TableColumnTextContainer>
);
};
@@ -0,0 +1,125 @@
import clsx from 'clsx';
import { ReactNode, useCallback } from 'react';
import styles from './row-index-column.module.css';
import {
ItemTableListInnerColumn,
TableColumnContainer,
TableColumnTextContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemListItem } from '/@/renderer/components/item-list/types';
import { ItemRowPlayControls } from '/@/renderer/features/shared/components/item-row-play-controls';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Flex } from '/@/shared/components/flex/flex';
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
import { Text } from '/@/shared/components/text/text';
import { Play } from '/@/shared/types/types';
export const RowPlayControlCell = (
props: ItemTableListInnerColumn & {
indexContent: ReactNode;
onPlay: (playType: Play) => void;
showPlayControls: boolean;
},
) => {
const {
controls,
data,
enableExpansion,
getRowItem,
indexContent,
internalState,
itemType,
onPlay,
rowIndex,
showPlayControls,
} = props;
const handleExpand = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const item = (getRowItem?.(rowIndex) ?? data[rowIndex]) as ItemListItem;
const rowId = internalState.extractRowId(item);
const index = rowId ? internalState.findItemIndex(rowId) : -1;
controls.onExpand?.({
event: e,
index,
internalState,
item,
itemType,
});
},
[controls, data, getRowItem, internalState, itemType, rowIndex],
);
const getIndexDisplay = (useMutedText: boolean) => {
const hideOnHoverClass = enableExpansion ? 'hide-on-hover' : undefined;
if (typeof indexContent === 'number') {
return useMutedText ? (
<Text className={hideOnHoverClass} isMuted isNoSelect>
{indexContent}
</Text>
) : (
indexContent
);
}
return <span className={hideOnHoverClass}>{indexContent}</span>;
};
const expansionTarget = (
<div className={styles.playTarget}>
{getIndexDisplay(true)}
<ActionIcon
className={clsx(styles.expand, 'hover-only')}
icon="arrowDownS"
iconProps={{ color: 'muted', size: 'md' }}
onClick={handleExpand}
size="xs"
variant="subtle"
/>
</div>
);
if (enableExpansion) {
return (
<TableColumnContainer {...props} className={styles.expansionCell}>
<div className={styles.expansionInner}>
{showPlayControls ? (
<HoverCard openDelay={300} position="top" withArrow withinPortal={false}>
<HoverCard.Target>{expansionTarget}</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<ItemRowPlayControls onPlay={onPlay} />
</HoverCard.Dropdown>
</HoverCard>
) : (
expansionTarget
)}
</div>
</TableColumnContainer>
);
}
if (!showPlayControls) {
return (
<TableColumnTextContainer {...props}>{getIndexDisplay(false)}</TableColumnTextContainer>
);
}
return (
<TableColumnTextContainer {...props} className={styles.fullSizeContent}>
<HoverCard openDelay={300} position="top" withArrow withinPortal={false}>
<HoverCard.Target>
<Flex className={styles.indexContent} justify="center" w="100%">
{getIndexDisplay(false)}
</Flex>
</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<ItemRowPlayControls onPlay={onPlay} />
</HoverCard.Dropdown>
</HoverCard>
</TableColumnTextContainer>
);
};
@@ -0,0 +1,51 @@
import styles from './row-index-column.module.css';
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
import { NumericColumn } from '/@/renderer/components/item-list/item-table-list/columns/numeric-column';
import { RowPlayControlCell } from '/@/renderer/components/item-list/item-table-list/columns/row-play-control-cell';
import {
supportsTrackNumberRowPlayControls,
useRowPlayControl,
} from '/@/renderer/components/item-list/item-table-list/columns/use-row-play-control';
import { ItemTableListInnerColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { Flex } from '/@/shared/components/flex/flex';
import { Icon } from '/@/shared/components/icon/icon';
import { TableColumn } from '/@/shared/types/types';
export const TrackNumberColumn = (props: ItemTableListInnerColumn) => {
if (
isRowPlayControlColumn(TableColumn.TRACK_NUMBER, props.columns) &&
supportsTrackNumberRowPlayControls(props.itemType)
) {
return <PlayableTrackNumberColumn {...props} />;
}
return <NumericColumn {...props} />;
};
const PlayableTrackNumberColumn = (props: ItemTableListInnerColumn) => {
const { handlePlay, isActive, isPlaying, showPlayControls } = useRowPlayControl(props);
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as unknown[])[props.rowIndex];
const trackNumber = (rowItem as undefined | { trackNumber?: number })?.trackNumber;
if (typeof trackNumber !== 'number') {
return <NumericColumn {...props} />;
}
const indexContent = isActive ? (
<Flex className={styles.indexContent}>
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
</Flex>
) : (
trackNumber
);
return (
<RowPlayControlCell
{...props}
indexContent={indexContent}
onPlay={handlePlay}
showPlayControls={showPlayControls}
/>
);
};
@@ -0,0 +1,140 @@
import { useCallback } from 'react';
import {
playAlbumFromItemListControl,
playArtistFromItemListControl,
playSongFromItemListControl,
} from '/@/renderer/components/item-list/helpers/play-row-from-list';
import { ItemTableListInnerColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlayerSong, usePlayerStatus } from '/@/renderer/store';
import {
Album,
AlbumArtist,
Artist,
LibraryItem,
QueueSong,
Song,
} from '/@/shared/types/domain-types';
import { Play, PlayerStatus } from '/@/shared/types/types';
export const supportsRowPlayControls = (itemType: LibraryItem) =>
itemType === LibraryItem.ALBUM ||
itemType === LibraryItem.ALBUM_ARTIST ||
itemType === LibraryItem.ARTIST ||
itemType === LibraryItem.PLAYLIST_SONG ||
itemType === LibraryItem.SONG;
export const supportsTrackNumberRowPlayControls = (itemType: LibraryItem) =>
itemType === LibraryItem.PLAYLIST_SONG ||
itemType === LibraryItem.QUEUE_SONG ||
itemType === LibraryItem.SONG;
export const hasPlayableRowItem = (
itemType: LibraryItem,
items: { album: Album; artist: AlbumArtist | Artist; song: QueueSong },
) => {
switch (itemType) {
case LibraryItem.ALBUM:
return !!items.album?.id;
case LibraryItem.ALBUM_ARTIST:
case LibraryItem.ARTIST:
return !!items.artist?.id;
default:
return !!items.song;
}
};
export const useRowPlayControl = (props: ItemTableListInnerColumn) => {
const status = usePlayerStatus();
const currentSong = usePlayerSong();
const player = usePlayer();
const rowItem = props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex];
const song = rowItem as QueueSong;
const album = rowItem as Album;
const artist = rowItem as AlbumArtist | Artist;
const isActiveFromRow = useIsActiveRow(song?.id, song?._uniqueId);
const isActive = (() => {
switch (props.itemType) {
case LibraryItem.ALBUM:
return !!album?.id && currentSong?.albumId === album.id;
case LibraryItem.ALBUM_ARTIST:
return (
!!artist?.id &&
!!currentSong?.albumArtists?.some(
(relatedArtist) => relatedArtist.id === artist.id,
)
);
case LibraryItem.ARTIST:
return (
!!artist?.id &&
!!currentSong?.artists?.some((relatedArtist) => relatedArtist.id === artist.id)
);
default:
return isActiveFromRow;
}
})();
const isPlaying = isActive && status === PlayerStatus.PLAYING;
const showPlayControls =
supportsRowPlayControls(props.itemType) &&
hasPlayableRowItem(props.itemType, { album, artist, song });
const handlePlay = useCallback(
(playType: Play) => {
if (props.itemType === LibraryItem.ALBUM) {
if (!album?.id) {
return;
}
playAlbumFromItemListControl({
album,
meta: { playType },
player,
});
return;
}
if (
props.itemType === LibraryItem.ALBUM_ARTIST ||
props.itemType === LibraryItem.ARTIST
) {
if (!artist?.id) {
return;
}
playArtistFromItemListControl({
artist,
itemType: props.itemType,
meta: { playType },
player,
});
return;
}
if (!song) {
return;
}
playSongFromItemListControl({
item: song as Song,
meta: { playType, singleSongOnly: true },
player,
});
},
[album, artist, player, props.itemType, song],
);
return {
album,
artist,
handlePlay,
isActive,
isPlaying,
showPlayControls,
song,
};
};
@@ -44,7 +44,6 @@ import { FavoriteColumn } from '/@/renderer/components/item-list/item-table-list
import { GenreBadgeColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-badge-column';
import { GenreColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-column';
import { ImageColumn } from '/@/renderer/components/item-list/item-table-list/columns/image-column';
import { NumericColumn } from '/@/renderer/components/item-list/item-table-list/columns/numeric-column';
import { PathColumn } from '/@/renderer/components/item-list/item-table-list/columns/path-column';
import { PlaylistReorderColumn } from '/@/renderer/components/item-list/item-table-list/columns/playlist-reorder-column';
import { RatingColumn } from '/@/renderer/components/item-list/item-table-list/columns/rating-column';
@@ -54,6 +53,7 @@ import { TextColumn } from '/@/renderer/components/item-list/item-table-list/col
import { TitleArtistColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-artist-column';
import { TitleColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-column';
import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-combined-column';
import { TrackNumberColumn } from '/@/renderer/components/item-list/item-table-list/columns/track-number-column';
import { YearColumn } from '/@/renderer/components/item-list/item-table-list/columns/year-column';
import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state';
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
@@ -240,7 +240,9 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
case TableColumn.DISC_NUMBER:
case TableColumn.SAMPLE_RATE:
case TableColumn.TRACK_NUMBER:
return <NumericColumn {...props} {...dragProps} controls={controls} type={type} />;
return (
<TrackNumberColumn {...props} {...dragProps} controls={controls} type={type} />
);
case TableColumn.COMPOSER:
return <ComposerColumn {...props} {...dragProps} controls={controls} type={type} />;
@@ -12,6 +12,7 @@ import styles from './album-detail-content.module.css';
import { useGridCarouselContainerQuery } from '/@/renderer/components/grid-carousel/grid-carousel-v2';
import { useItemListStateSubscription } from '/@/renderer/components/item-list/helpers/item-list-state';
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
@@ -60,7 +61,7 @@ import {
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
const MetadataPillGroup = ({
items,
@@ -830,13 +831,13 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
return;
}
const playType = (meta?.playType as Play) || Play.NOW;
const items = internalState?.getData() as Song[];
if (index !== undefined) {
player.addToQueueByData(items, playType, item.id);
}
playSongFromItemListControl({
index,
internalState,
item: item as Song,
meta,
player,
});
},
};
}, [player]);
@@ -14,6 +14,7 @@ import styles from './album-artist-detail-content.module.css';
import { queryKeys } from '/@/renderer/api/query-keys';
import { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
@@ -365,12 +366,13 @@ const AlbumArtistMetadataTopSongsContent = ({
return;
}
const playType = (meta?.playType as Play) || Play.NOW;
const items = internalState?.getData() as Song[];
if (index !== undefined) {
player.addToQueueByData(items, playType, item.id);
}
playSongFromItemListControl({
index,
internalState,
item: item as Song,
meta,
player,
});
},
};
}, [player]);
@@ -657,12 +659,13 @@ const AlbumArtistMetadataFavoriteSongs = ({
return;
}
const playType = (meta?.playType as Play) || Play.NOW;
const items = internalState?.getData() as Song[];
if (index !== undefined) {
player.addToQueueByData(items, playType, item.id);
}
playSongFromItemListControl({
index,
internalState,
item: item as Song,
meta,
player,
});
},
};
}, [player]);
@@ -2,6 +2,7 @@ import { useSuspenseQueries } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useParams } from 'react-router';
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
@@ -24,7 +25,7 @@ import { useCurrentServer } from '/@/renderer/store/auth.store';
import { useSettingsStore } from '/@/renderer/store/settings.store';
import { sortSongList } from '/@/shared/api/utils';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
import { ItemListKey, Play } from '/@/shared/types/types';
import { ItemListKey } from '/@/shared/types/types';
const AlbumArtistDetailFavoriteSongsListRoute = () => {
const { albumArtistId, artistId } = useParams() as {
@@ -96,12 +97,13 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
return;
}
const playType = (meta?.playType as Play) || Play.NOW;
const items = internalState?.getData() as Song[];
if (index !== undefined) {
player.addToQueueByData(items, playType, item.id);
}
playSongFromItemListControl({
index,
internalState,
item: item as Song,
meta,
player,
});
},
};
}, [player]);
@@ -2,6 +2,7 @@ import { useSuspenseQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useParams } from 'react-router';
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
@@ -18,7 +19,7 @@ import { useCurrentServer } from '/@/renderer/store/auth.store';
import { useSettingsStore } from '/@/renderer/store/settings.store';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
import { ItemListKey, Play } from '/@/shared/types/types';
import { ItemListKey } from '/@/shared/types/types';
const AlbumArtistDetailTopSongsListRoute = () => {
const { albumArtistId, artistId } = useParams() as {
@@ -78,12 +79,13 @@ const AlbumArtistDetailTopSongsListRoute = () => {
return;
}
const playType = (meta?.playType as Play) || Play.NOW;
const items = internalState?.getData() as Song[];
if (index !== undefined) {
player.addToQueueByData(items, playType, item.id);
}
playSongFromItemListControl({
index,
internalState,
item: item as Song,
meta,
player,
});
},
};
}, [player]);
@@ -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(
{
@@ -1,6 +1,7 @@
import { forwardRef, useMemo } from 'react';
import { useEffect } from 'react';
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
@@ -21,7 +22,7 @@ import {
PlaylistSongListResponse,
Song,
} from '/@/shared/types/domain-types';
import { ItemListKey, Play, TableColumn } from '/@/shared/types/types';
import { ItemListKey, TableColumn } from '/@/shared/types/types';
interface PlaylistDetailSongListTableProps extends Omit<
ItemListTableComponentProps<PlaylistSongListQuery>,
@@ -103,12 +104,13 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
return;
}
const playType = (meta?.playType as Play) || Play.NOW;
const items = internalState?.getData() as Song[];
if (index !== undefined) {
player.addToQueueByData(items, playType, item.id);
}
playSongFromItemListControl({
index,
internalState,
item: item as Song,
meta,
player,
});
},
};
}, [player]);
@@ -228,12 +230,13 @@ export const PlaylistDetailSongListEditTable = forwardRef<any, PlaylistDetailSon
return;
}
const playType = (meta?.playType as Play) || Play.NOW;
const items = internalState?.getData() as Song[];
if (index !== undefined) {
player.addToQueueByData(items, playType, item.id);
}
playSongFromItemListControl({
index,
internalState,
item: item as Song,
meta,
player,
});
},
};
}, [player]);
@@ -0,0 +1,62 @@
import { useIsMutating, useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { Playlist } from '/@/shared/types/domain-types';
export const sidebarPlaylistFolderMoveMutationKey = ['sidebar-playlist-folder-move'];
export type SidebarPlaylistFolderMoveArgs = {
serverId: string;
updates: SidebarPlaylistFolderMoveUpdate[];
};
export type SidebarPlaylistFolderMoveUpdate = {
newName: string;
playlist: Playlist;
};
export const useSidebarPlaylistFolderMove = () => {
const queryClient = useQueryClient();
return useMutation<void, AxiosError, SidebarPlaylistFolderMoveArgs>({
mutationFn: async ({ serverId, updates }) => {
for (const { newName, playlist } of updates) {
if (newName === playlist.name) continue;
await api.controller.updatePlaylist({
apiClientProps: { serverId },
body: {
comment: playlist.description || '',
name: newName,
ownerId: playlist.ownerId || '',
public: playlist.public || false,
queryBuilderRules: playlist.rules ?? undefined,
sync: playlist.sync ?? undefined,
},
query: { id: playlist.id },
});
}
},
mutationKey: sidebarPlaylistFolderMoveMutationKey,
onSuccess: (_data, { serverId, updates }) => {
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.list(serverId),
});
for (const { playlist } of updates) {
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.detail(serverId, playlist.id),
});
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.songList(serverId, playlist.id),
});
}
},
});
};
export const useIsMutatingSidebarPlaylistFolderMove = () => {
return useIsMutating({ mutationKey: sidebarPlaylistFolderMoveMutationKey }) > 0;
};
@@ -136,6 +136,8 @@ export function RadioWebPlayer() {
<WebPlayerEngine
isMuted={isMuted}
isTransitioning={false}
loopPlayer1={false}
loopPlayer2={false}
onEndedPlayer1={onEndedPlayer1}
onEndedPlayer2={() => {}}
onErrorPause={() => {}}
@@ -29,9 +29,8 @@ export const useRemote = () => {
// Initialize the remote
useEffect(() => {
if (!isRemoteEnabled) {
return;
}
// we must send this EVEN IF the remote is disabled, as this is what
// makes sure that the main process gets the port/username/password on startup
logFn.debug(logMsg[LogCategory.REMOTE].initializingRemoteSettings, {
category: LogCategory.REMOTE,
@@ -1,4 +1,6 @@
import { openContextModal } from '@mantine/modals';
import { CSSProperties, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './library-command-item.module.css';
@@ -13,6 +15,7 @@ import { useCurrentServer } from '/@/renderer/store';
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
import { Flex } from '/@/shared/components/flex/flex';
import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { ExplicitStatus, LibraryItem, Song } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
@@ -61,6 +64,7 @@ export const LibraryCommandItem = ({
}: LibraryCommandItemProps) => {
const { addToQueueByData, addToQueueByFetch } = usePlayer();
const server = useCurrentServer();
const { t } = useTranslation();
const handlePlay = useCallback(
(playType: Play) => {
@@ -103,6 +107,39 @@ export const LibraryCommandItem = ({
},
});
const handleOpenPlaylistModal = useCallback(() => {
const modalProps: {
albumId?: string[];
artistId?: string[];
songId?: string[];
} = {};
switch (itemType) {
case LibraryItem.ALBUM:
modalProps.albumId = [id];
break;
case LibraryItem.ALBUM_ARTIST:
case LibraryItem.ARTIST:
modalProps.artistId = [id];
break;
case LibraryItem.QUEUE_SONG:
case LibraryItem.SONG:
modalProps.songId = [id];
break;
default:
return;
}
openContextModal({
innerProps: {
...modalProps,
},
modal: 'addToPlaylist',
size: 'lg',
title: t('page.contextMenu.addToPlaylist'),
});
}, [id, itemType, t]);
const [isHovered, setIsHovered] = useState(false);
const showControls = isHighlighted || isHovered;
@@ -180,6 +217,29 @@ export const LibraryCommandItem = ({
)}
/>
</PlayTooltip>
<Tooltip disabled={disabled} label={t('action.addToPlaylist')}>
<ActionIcon
icon="playlistAdd"
onClick={(event) => {
event.stopPropagation();
event.preventDefault();
handleOpenPlaylistModal();
}}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
handleOpenPlaylistModal();
}
} else if (e.key === 'Tab') {
e.stopPropagation();
}
}}
size="xs"
variant="default"
/>
</Tooltip>
</ActionIconGroup>
)}
</Flex>
@@ -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,
},
})
}
@@ -0,0 +1,5 @@
.controls {
display: flex;
flex-shrink: 0;
gap: 0;
}
@@ -0,0 +1,90 @@
import clsx from 'clsx';
import styles from './item-row-play-controls.module.css';
import {
LONG_PRESS_PLAY_BEHAVIOR,
PlayTooltip,
} from '/@/renderer/features/shared/components/play-button-group';
import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click';
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
import { Play } from '/@/shared/types/types';
interface ItemRowPlayControlsProps {
className?: string;
disabled?: boolean;
onPlay: (playType: Play) => void;
}
export const ItemRowPlayControls = ({ className, disabled, onPlay }: ItemRowPlayControlsProps) => {
const handlePlayNext = usePlayButtonClick({
onClick: () => {
onPlay(Play.NEXT);
},
onLongPress: () => {
onPlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NEXT]);
},
});
const handlePlayNow = usePlayButtonClick({
onClick: () => {
onPlay(Play.NOW);
},
onLongPress: () => {
onPlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NOW]);
},
});
const handlePlayLast = usePlayButtonClick({
onClick: () => {
onPlay(Play.LAST);
},
onLongPress: () => {
onPlay(LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]);
},
});
return (
<ActionIconGroup className={clsx(styles.controls, className)}>
<PlayTooltip disabled={disabled} type={Play.NOW}>
<ActionIcon
icon="mediaPlay"
iconProps={{
size: 'md',
}}
size="xs"
variant="subtle"
{...handlePlayNow.handlers}
{...handlePlayNow.props}
disabled={disabled}
/>
</PlayTooltip>
<PlayTooltip disabled={disabled} type={Play.NEXT}>
<ActionIcon
icon="mediaPlayNext"
iconProps={{
size: 'md',
}}
size="xs"
variant="subtle"
{...handlePlayNext.handlers}
{...handlePlayNext.props}
disabled={disabled}
/>
</PlayTooltip>
<PlayTooltip disabled={disabled} type={Play.LAST}>
<ActionIcon
icon="mediaPlayLast"
iconProps={{
size: 'md',
}}
size="xs"
variant="subtle"
{...handlePlayLast.handlers}
{...handlePlayLast.props}
disabled={disabled}
/>
</PlayTooltip>
</ActionIconGroup>
);
};
@@ -7,8 +7,10 @@ import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
import {
SidebarPlaylistAddDragContext,
SidebarPlaylistList,
SidebarSharedPlaylistList,
useSidebarPlaylistAddDragMonitor,
} from '/@/renderer/features/sidebar/components/sidebar-playlist-list';
import {
SidebarItemType,
@@ -20,6 +22,17 @@ import { Group } from '/@/shared/components/group/group';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { Text } from '/@/shared/components/text/text';
const MobileSidebarPlaylistSection = () => {
const isAddDragActive = useSidebarPlaylistAddDragMonitor();
return (
<SidebarPlaylistAddDragContext.Provider value={isAddDragActive}>
<SidebarPlaylistList />
<SidebarSharedPlaylistList />
</SidebarPlaylistAddDragContext.Provider>
);
};
export const MobileSidebar = () => {
const { t } = useTranslation();
const sidebarPlaylistList = useSidebarPlaylistList();
@@ -93,12 +106,7 @@ export const MobileSidebar = () => {
})}
</Accordion.Panel>
</Accordion.Item>
{sidebarPlaylistList && (
<>
<SidebarPlaylistList />
<SidebarSharedPlaylistList />
</>
)}
{sidebarPlaylistList && <MobileSidebarPlaylistSection />}
</Accordion>
</ScrollArea>
</div>
@@ -22,7 +22,7 @@
gap: var(--theme-spacing-md);
align-items: center;
width: 100%;
padding: var(--theme-spacing-xs) var(--theme-spacing-md);
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
font: inherit;
color: inherit;
text-align: left;
@@ -32,12 +32,18 @@
border-radius: var(--theme-radius-md);
&:hover {
background-color: var(--theme-colors-surface);
background-color: var(--theme-colors-background);
}
}
.chevron {
display: flex;
flex-shrink: 0;
align-items: center;
}
.collapse {
overflow: hidden;
}
.name {
@@ -1,18 +1,26 @@
import clsx from 'clsx';
import { motion } from 'motion/react';
import {
ComponentPropsWithoutRef,
createContext,
CSSProperties,
MouseEvent,
ReactElement,
ReactNode,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import styles from './playlist-folder-tree.module.css';
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
import {
SidebarPlaylistFolderMoveUpdate,
useSidebarPlaylistFolderMove,
} from '/@/renderer/features/playlists/mutations/sidebar-playlist-folder-move-mutation';
import { PlaylistRowButton } from '/@/renderer/features/sidebar/components/sidebar-playlist-list';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import {
@@ -33,6 +41,25 @@ import { DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-dr
const STORAGE_KEY_PREFIX = 'feishin:playlist-folder-state';
const FOLDER_COLLAPSE_TRANSITION = { duration: 0.2, ease: 'easeInOut' } as const;
interface PlaylistFolderCollapseProps {
children: ReactNode;
className?: string;
open: boolean;
}
const PlaylistFolderCollapse = ({ children, className, open }: PlaylistFolderCollapseProps) => (
<motion.div
animate={{ height: open ? 'auto' : 0, opacity: open ? 1 : 0 }}
className={clsx(styles.collapse, className)}
initial={false}
transition={FOLDER_COLLAPSE_TRANSITION}
>
{children}
</motion.div>
);
export const getPlaylistLeafName = (name: string, separator: string): string => {
if (!separator) return name;
const segments = name.split(separator).filter((segment) => segment.length > 0);
@@ -69,6 +96,42 @@ export const isDirectChildFolder = (
return relativePath.length > 0 && !relativePath.includes(separator);
};
export const canDropOnPlaylistFolder = (
source: DragData,
folderPath: string,
separator: string,
): boolean => {
if (source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) {
const sourceFolderPath =
source.id[0] ?? (source.metadata as undefined | { folderName?: string })?.folderName;
if (!sourceFolderPath) return false;
return isValidFolderNest(sourceFolderPath, folderPath, separator);
}
if (source.itemType === LibraryItem.PLAYLIST) {
const items = source.item as Playlist[] | undefined;
return Array.isArray(items) && items.length > 0;
}
return false;
};
export const canDragOverPlaylistFolder = (
source: DragData,
folderPath: string,
separator: string,
): boolean => {
if (canDropOnPlaylistFolder(source, folderPath, separator)) {
return true;
}
return (
source.itemType !== undefined &&
source.type !== DragTarget.PLAYLIST &&
(source.operation?.includes(DragOperation.ADD) ?? false)
);
};
export const isValidFolderNest = (
sourceFolderPath: string,
targetFolderPath: string,
@@ -123,74 +186,82 @@ export const remapPlaylistToRoot = (playlistName: string, separator: string): st
return getPlaylistLeafName(playlistName, separator).trim();
};
const updatePlaylistName = async (
updateMutation: ReturnType<typeof useUpdatePlaylist>,
serverId: string,
playlist: Playlist,
newName: string,
) => {
if (newName === playlist.name) return;
export const isRootLevelFolder = (folderPath: string, separator: string): boolean => {
const segments = folderPath.split(separator).filter((segment) => segment.length > 0);
return segments.length === 1;
};
await updateMutation.mutateAsync({
apiClientProps: { serverId },
body: {
comment: playlist.description || '',
name: newName,
ownerId: playlist.ownerId || '',
public: playlist.public || false,
queryBuilderRules: playlist.rules ?? undefined,
sync: playlist.sync ?? undefined,
},
query: { id: playlist.id },
});
export const remapPlaylistFolderToRoot = (
playlistName: string,
sourceFolderPath: string,
separator: string,
): null | string => {
const sourcePrefix = `${sourceFolderPath}${separator}`;
if (!playlistName.startsWith(sourcePrefix)) return null;
const remainder = playlistName.slice(sourcePrefix.length);
if (!remainder) return null;
// Root-level folder on root: flatten playlists to root (Rock/x -> x).
if (isRootLevelFolder(sourceFolderPath, separator)) {
return remapPlaylistToRoot(playlistName, separator);
}
// Nested folder on root: promote one level (Auto/Test/x -> Test/x).
const folderName = getFolderName(sourceFolderPath, separator);
return `${folderName}${separator}${remainder}`;
};
export const usePlaylistRootDrop = (allPlaylists: Playlist[]) => {
const { t } = useTranslation();
const serverId = useCurrentServerId();
const separator = useSidebarPlaylistFolderSeparator();
const updateMutation = useUpdatePlaylist({});
const folderMoveMutation = useSidebarPlaylistFolderMove();
const handleDrop = useCallback(
async (source: DragData) => {
if (!serverId) return;
try {
if (source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) {
const sourceFolderPath =
source.id[0] ??
(source.metadata as undefined | { folderName?: string })?.folderName;
if (!sourceFolderPath) return;
const updates: SidebarPlaylistFolderMoveUpdate[] = [];
const affected = getPlaylistsInFolderTree(
allPlaylists,
if (source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) {
const sourceFolderPath =
source.id[0] ??
(source.metadata as undefined | { folderName?: string })?.folderName;
if (!sourceFolderPath) return;
const affected = getPlaylistsInFolderTree(
allPlaylists,
sourceFolderPath,
separator,
);
for (const playlist of affected) {
const newName = remapPlaylistFolderToRoot(
playlist.name,
sourceFolderPath,
separator,
);
if (!newName) continue;
for (const playlist of affected) {
await updatePlaylistName(
updateMutation,
serverId,
playlist,
remapPlaylistToRoot(playlist.name, separator),
);
}
return;
updates.push({ newName, playlist });
}
} else {
const playlists = source.item as Playlist[] | undefined;
if (!Array.isArray(playlists) || playlists.length === 0) return;
for (const playlist of playlists) {
await updatePlaylistName(
updateMutation,
serverId,
updates.push({
newName: remapPlaylistToRoot(playlist.name, separator),
playlist,
remapPlaylistToRoot(playlist.name, separator),
);
});
}
}
if (updates.length === 0) return;
try {
await folderMoveMutation.mutateAsync({ serverId, updates });
} catch (err: unknown) {
toast.error({
message: err instanceof Error ? err.message : undefined,
@@ -198,7 +269,7 @@ export const usePlaylistRootDrop = (allPlaylists: Playlist[]) => {
});
}
},
[allPlaylists, separator, serverId, t, updateMutation],
[allPlaylists, folderMoveMutation, separator, serverId, t],
);
const { isDraggedOver, ref } = useDragDrop<HTMLButtonElement>({
@@ -263,63 +334,211 @@ export const PlaylistRootAccordionControl = ({
);
};
interface PlaylistFolderDragExpandContextValue {
onFolderDragHover: (folderPath: string) => void;
onFolderDragLeave: (folderPath: string) => void;
onFolderDrop: (folderPath: string) => void;
}
const PlaylistFolderDragExpandContext = createContext<null | PlaylistFolderDragExpandContextValue>(
null,
);
interface PlaylistFolderDragExpandProviderProps {
children: ReactNode;
expandedSet: Set<string>;
setMany: (paths: string[], shouldExpand: boolean) => void;
}
export const PlaylistFolderDragExpandProvider = ({
children,
expandedSet,
setMany,
}: PlaylistFolderDragExpandProviderProps) => {
const separator = useSidebarPlaylistFolderSeparator();
const autoExpandedRef = useRef<Set<string>>(new Set());
const activeHoveredRef = useRef<null | string>(null);
const getOpenChain = useCallback(
(folderPath: string) => {
const segments = folderPath.split(separator).filter((segment) => segment.length > 0);
const chain: string[] = [];
for (let index = 1; index < segments.length; index++) {
chain.push(segments.slice(0, index).join(separator));
}
chain.push(folderPath);
return chain;
},
[separator],
);
const collapseAutoExpanded = useCallback(
(paths: string[]) => {
const toCollapse = paths.filter((path) => autoExpandedRef.current.has(path));
if (toCollapse.length === 0) return;
for (const path of toCollapse) autoExpandedRef.current.delete(path);
setMany(toCollapse, false);
},
[setMany],
);
const onFolderDragHover = useCallback(
(folderPath: string) => {
const current = activeHoveredRef.current;
if (
current &&
current !== folderPath &&
current.startsWith(`${folderPath}${separator}`)
) {
return;
}
const openChain = getOpenChain(folderPath);
activeHoveredRef.current = folderPath;
const toCollapse = [...autoExpandedRef.current].filter(
(path) => !openChain.includes(path),
);
if (toCollapse.length > 0) {
collapseAutoExpanded(toCollapse);
}
const toExpand = openChain.filter(
(path) => !expandedSet.has(path) && !autoExpandedRef.current.has(path),
);
if (toExpand.length === 0) return;
for (const path of toExpand) autoExpandedRef.current.add(path);
setMany(toExpand, true);
},
[collapseAutoExpanded, expandedSet, getOpenChain, separator, setMany],
);
const onFolderDragLeave = useCallback(
(folderPath: string) => {
const active = activeHoveredRef.current;
if (active && active !== folderPath && active.startsWith(`${folderPath}${separator}`)) {
return;
}
if (active === folderPath) {
activeHoveredRef.current = null;
}
collapseAutoExpanded([folderPath]);
},
[collapseAutoExpanded, separator],
);
const onFolderDrop = useCallback(
(folderPath: string) => {
for (const path of getOpenChain(folderPath)) autoExpandedRef.current.delete(path);
if (activeHoveredRef.current === folderPath) {
activeHoveredRef.current = null;
}
},
[getOpenChain],
);
const value = useMemo(
() => ({ onFolderDragHover, onFolderDragLeave, onFolderDrop }),
[onFolderDragHover, onFolderDragLeave, onFolderDrop],
);
return (
<PlaylistFolderDragExpandContext.Provider value={value}>
{children}
</PlaylistFolderDragExpandContext.Provider>
);
};
const usePlaylistFolderExpandDrop = (folderPath: string) => {
const separator = useSidebarPlaylistFolderSeparator();
const dragExpand = useContext(PlaylistFolderDragExpandContext);
return useDragDrop<HTMLDivElement>({
drop: {
canDrop: (args) => canDragOverPlaylistFolder(args.source, folderPath, separator),
getData: () => ({
id: [folderPath],
type: DragTarget.SIDEBAR_PLAYLIST_FOLDER,
}),
onDrag: ({ source }) => {
if (!dragExpand) return;
if (!canDragOverPlaylistFolder(source, folderPath, separator)) return;
dragExpand.onFolderDragHover(folderPath);
},
onDragLeave: () => {
dragExpand?.onFolderDragLeave(folderPath);
},
onDrop: () => {
dragExpand?.onFolderDrop(folderPath);
},
},
isEnabled: Boolean(dragExpand),
});
};
interface PlaylistFolderProps {
children: ReactNode;
folderPath: string;
}
const PlaylistFolder = ({ children, folderPath }: PlaylistFolderProps) => {
const { ref } = usePlaylistFolderExpandDrop(folderPath);
return (
<div className={styles.folder} ref={ref}>
{children}
</div>
);
};
// Drag-and-drop on folder headers: folders can be dragged, and accept folder or playlist drops.
const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) => {
const { t } = useTranslation();
const serverId = useCurrentServerId();
const separator = useSidebarPlaylistFolderSeparator();
const updateMutation = useUpdatePlaylist({});
const folderMoveMutation = useSidebarPlaylistFolderMove();
const dragExpand = useContext(PlaylistFolderDragExpandContext);
const handleDrop = useCallback(
async (source: DragData) => {
if (!serverId) return;
try {
if (source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) {
// Folder drop: rename every playlist under the dragged folder tree.
const sourceFolderPath =
source.id[0] ??
(source.metadata as undefined | { folderName?: string })?.folderName;
if (
!sourceFolderPath ||
!isValidFolderNest(sourceFolderPath, folderPath, separator)
) {
return;
}
const affected = getPlaylistsInFolderTree(
allPlaylists,
sourceFolderPath,
separator,
);
for (const playlist of affected) {
const newName = remapPlaylistFolderPath(
playlist.name,
sourceFolderPath,
folderPath,
separator,
);
if (!newName || newName === playlist.name) continue;
await updateMutation.mutateAsync({
apiClientProps: { serverId },
body: {
comment: playlist.description || '',
name: newName,
ownerId: playlist.ownerId || '',
public: playlist.public || false,
queryBuilderRules: playlist.rules ?? undefined,
sync: playlist.sync ?? undefined,
},
query: { id: playlist.id },
});
}
const updates: SidebarPlaylistFolderMoveUpdate[] = [];
if (source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) {
const sourceFolderPath =
source.id[0] ??
(source.metadata as undefined | { folderName?: string })?.folderName;
if (
!sourceFolderPath ||
!isValidFolderNest(sourceFolderPath, folderPath, separator)
) {
return;
}
// Playlist drop: move a single playlist into this folder using its leaf name only.
const affected = getPlaylistsInFolderTree(
allPlaylists,
sourceFolderPath,
separator,
);
for (const playlist of affected) {
const newName = remapPlaylistFolderPath(
playlist.name,
sourceFolderPath,
folderPath,
separator,
);
if (!newName || newName === playlist.name) continue;
updates.push({ newName, playlist });
}
} else {
const playlists = source.item as Playlist[] | undefined;
if (!Array.isArray(playlists) || playlists.length === 0) return;
@@ -328,19 +547,14 @@ const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) =>
const newName = buildPlaylistNameInFolder(folderPath, leafName, separator);
if (newName === playlist.name) continue;
await updateMutation.mutateAsync({
apiClientProps: { serverId },
body: {
comment: playlist.description || '',
name: newName,
ownerId: playlist.ownerId || '',
public: playlist.public || false,
queryBuilderRules: playlist.rules ?? undefined,
sync: playlist.sync ?? undefined,
},
query: { id: playlist.id },
});
updates.push({ newName, playlist });
}
}
if (updates.length === 0) return;
try {
await folderMoveMutation.mutateAsync({ serverId, updates });
} catch (err: unknown) {
toast.error({
message: err instanceof Error ? err.message : undefined,
@@ -348,12 +562,11 @@ const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) =>
});
}
},
[allPlaylists, folderPath, separator, serverId, t, updateMutation],
[allPlaylists, folderMoveMutation, folderPath, separator, serverId, t],
);
const { isDraggedOver, isDragging, ref } = useDragDrop<HTMLButtonElement>({
drag: {
// Folders are virtual; drag data carries the folder path, not playlist items.
getId: () => [folderPath],
getItem: () => [],
metadata: { folderName: folderPath },
@@ -361,20 +574,7 @@ const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) =>
target: DragTarget.SIDEBAR_PLAYLIST_FOLDER,
},
drop: {
canDrop: (args) => {
if (args.source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) {
const sourceFolderPath =
args.source.id[0] ??
(args.source.metadata as undefined | { folderName?: string })?.folderName;
if (!sourceFolderPath) return false;
return isValidFolderNest(sourceFolderPath, folderPath, separator);
}
// Single playlist rows can also be dropped onto a folder header.
if (args.source.itemType !== LibraryItem.PLAYLIST) return false;
const items = args.source.item as Playlist[] | undefined;
return Array.isArray(items) && items.length > 0;
},
canDrop: (args) => canDropOnPlaylistFolder(args.source, folderPath, separator),
getData: () => ({
id: [folderPath],
type: DragTarget.SIDEBAR_PLAYLIST_FOLDER,
@@ -386,6 +586,7 @@ const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) =>
return;
},
onDrop: ({ source }) => {
dragExpand?.onFolderDrop(folderPath);
void handleDrop(source);
},
},
@@ -429,7 +630,7 @@ const PlaylistFolderHeader = ({
type="button"
>
<div className={styles.navFolderIcon}>
<Icon color="muted" icon="folder" size="xl" />
<Icon color="muted" icon={isOpen ? 'folder' : 'folderClosed'} size="md" />
</div>
<Text className={styles.name} fw={500} size="md">
{name}
@@ -454,18 +655,21 @@ const PlaylistFolderHeader = ({
style={{ opacity: isDragging ? 0.5 : 1 }}
type="button"
>
<Icon
className={styles.chevron}
icon={isOpen ? 'arrowDownS' : 'arrowRightS'}
size="sm"
/>
<Icon color="muted" icon="folder" size="sm" />
<Icon color="muted" icon={isOpen ? 'folder' : 'folderClosed'} size="md" />
<Text className={styles.name} fw={500} size="md">
{name}
</Text>
<Text className={styles.count} isMuted size="sm">
{leafCount}
</Text>
<motion.span
animate={{ rotate: isOpen ? 180 : 0 }}
className={styles.chevron}
initial={false}
transition={FOLDER_COLLAPSE_TRANSITION}
>
<Icon icon="arrowUpS" size="md" />
</motion.span>
</button>
);
};
@@ -686,7 +890,7 @@ export const PlaylistFolderTree = ({
const isOpen = expandedSet.has(group.name);
return (
<div className={styles.folder} key={`folder:${group.name}`}>
<PlaylistFolder folderPath={group.name} key={`folder:${group.name}`}>
<PlaylistFolderHeader
allPlaylists={allPlaylists}
folderPath={group.name}
@@ -696,21 +900,19 @@ export const PlaylistFolderTree = ({
onClick={() => onToggleFolder(group.name)}
variant="header"
/>
{isOpen && (
<div className={styles.children}>
{group.items.map((item) => (
<PlaylistRowButton
item={item}
key={item.id}
name={item.name.slice(group.name.length + 1)}
onContextMenu={onContextMenu}
onReorder={onReorder}
to={item.id}
/>
))}
</div>
)}
</div>
<PlaylistFolderCollapse className={styles.children} open={isOpen}>
{group.items.map((item) => (
<PlaylistRowButton
item={item}
key={item.id}
name={item.name.slice(group.name.length + 1)}
onContextMenu={onContextMenu}
onReorder={onReorder}
to={item.id}
/>
))}
</PlaylistFolderCollapse>
</PlaylistFolder>
);
})}
</>
@@ -750,7 +952,7 @@ export const PlaylistFolderTreeView = ({
const isOpen = expandedSet.has(node.path);
return (
<div className={styles.folder} key={`folder:${node.path}`}>
<PlaylistFolder folderPath={node.path} key={`folder:${node.path}`}>
<PlaylistFolderHeader
allPlaylists={allPlaylists}
folderPath={node.path}
@@ -760,16 +962,14 @@ export const PlaylistFolderTreeView = ({
onClick={() => onToggleFolder(node.path)}
variant="header"
/>
{isOpen && (
<div className={styles.treeChildren}>
{node.children.map((child) => (
<div className={styles.treeBranch} key={getNodeKey(child)}>
{renderNode(child)}
</div>
))}
</div>
)}
</div>
<PlaylistFolderCollapse className={styles.treeChildren} open={isOpen}>
{node.children.map((child) => (
<div className={styles.treeBranch} key={getNodeKey(child)}>
{renderNode(child)}
</div>
))}
</PlaylistFolderCollapse>
</PlaylistFolder>
);
};
@@ -4,6 +4,10 @@
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
}
.panel {
position: relative;
}
.row {
position: relative;
display: flex;
@@ -29,13 +33,12 @@
.compact-name {
flex: 1;
min-width: 0;
padding: 0;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.controls {
position: absolute;
top: 50%;
@@ -45,7 +48,6 @@
transform: translateY(-50%);
}
.controls-compact {
position: absolute;
top: 50%;
@@ -56,10 +58,14 @@
transform: translateY(-50%);
}
.row-dragged-over {
border-radius: var(--mantine-radius-sm);
box-shadow: 0 0 0 2px var(--theme-colors-primary);
.row-dragged-over::after {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
content: '';
border: 2px solid var(--theme-colors-primary);
border-radius: var(--theme-radius-md);
opacity: 0.8;
}
@@ -1,7 +1,8 @@
import { openContextModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import clsx from 'clsx';
import { memo, MouseEvent, useCallback, useMemo, useState } from 'react';
import { motion } from 'motion/react';
import { createContext, memo, MouseEvent, useCallback, useContext, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath, Link } from 'react-router';
@@ -12,13 +13,11 @@ import { ContextMenuController } from '/@/renderer/features/context-menu/context
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { openCreatePlaylistModal } from '/@/renderer/features/playlists/components/create-playlist-form';
import {
LONG_PRESS_PLAY_BEHAVIOR,
PlayTooltip,
} from '/@/renderer/features/shared/components/play-button-group';
import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click';
import { useIsMutatingSidebarPlaylistFolderMove } from '/@/renderer/features/playlists/mutations/sidebar-playlist-folder-move-mutation';
import { ItemRowPlayControls } from '/@/renderer/features/shared/components/item-row-play-controls';
import {
collectFolderPaths,
PlaylistFolderDragExpandProvider,
PlaylistFolderViews,
PlaylistRootAccordionControl,
usePlaylistFolderState,
@@ -26,6 +25,7 @@ import {
usePlaylistNavigationState,
} from '/@/renderer/features/sidebar/components/playlist-folder-tree';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { useDragMonitor } from '/@/renderer/hooks/use-drag-monitor';
import { AppRoute } from '/@/renderer/router/routes';
import {
useCurrentServer,
@@ -37,11 +37,14 @@ import {
} from '/@/renderer/store';
import { formatDurationString } from '/@/renderer/utils';
import { Accordion } from '/@/shared/components/accordion/accordion';
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { animationProps } from '/@/shared/components/animations/animation-props';
import { animationVariants } from '/@/shared/components/animations/animation-variants';
import { ButtonProps } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { LoadingOverlay } from '/@/shared/components/loading-overlay/loading-overlay';
import { Text } from '/@/shared/components/text/text';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import {
@@ -51,14 +54,50 @@ import {
Song,
SortOrder,
} from '/@/shared/types/domain-types';
import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
import { DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
import { Play } from '/@/shared/types/types';
const MotionLink = motion.create(Link);
const playlistRowDimVariants = animationVariants.combine(animationVariants.fadeIn, {
hidden: { opacity: 0.5 },
});
const getPlaylistOrderKey = (serverId: string | undefined, scope: 'owned' | 'shared') => {
const sid = serverId || 'local';
return `playlist_order:${sid}:${scope}`;
};
export const SidebarPlaylistAddDragContext = createContext(false);
const isAddToPlaylistDragSource = (source: DragData) => {
return (
source.itemType !== undefined &&
source.type !== DragTarget.PLAYLIST &&
(source.operation?.includes(DragOperation.ADD) ?? false)
);
};
export const useSidebarPlaylistAddDragMonitor = () => {
const [isAddDragActive, setIsAddDragActive] = useState(false);
const handleAddDragStart = useCallback(() => {
setIsAddDragActive(true);
}, []);
const handleAddDragDrop = useCallback(() => {
setIsAddDragActive(false);
}, []);
useDragMonitor({
canMonitor: isAddToPlaylistDragSource,
onDragStart: handleAddDragStart,
onDrop: handleAddDragDrop,
});
return isAddDragActive;
};
export interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'onPlay'> {
item: Playlist;
name: string;
@@ -79,6 +118,8 @@ export const PlaylistRowButton = memo(
const isCompact = sidebarPlaylistMode === 'compact';
const [isHovered, setIsHovered] = useState(false);
const isSmartPlaylist = Boolean(item.rules);
const isAddDragActive = useContext(SidebarPlaylistAddDragContext);
const { isDraggedOver, isDragging, ref } = useDragDrop<HTMLAnchorElement>({
drag: {
@@ -96,6 +137,7 @@ export const PlaylistRowButton = memo(
canDrop: (args) => {
// Allow dropping items into a playlist (ADD)
const canAdd =
!isSmartPlaylist &&
args.source.itemType !== undefined &&
args.source.type !== DragTarget.PLAYLIST &&
(args.source.operation?.includes(DragOperation.ADD) ?? false);
@@ -117,6 +159,7 @@ export const PlaylistRowButton = memo(
};
},
onDrag: () => {
console.log('started drag');
return;
},
onDragLeave: () => {
@@ -149,6 +192,10 @@ export const PlaylistRowButton = memo(
return;
}
if (isSmartPlaylist) {
return;
}
const modalProps: {
albumId?: string[];
artistId?: string[];
@@ -221,13 +268,18 @@ export const PlaylistRowButton = memo(
type: 'table',
});
const isDimmed = isDragging || (isSmartPlaylist && isAddDragActive);
return (
<Link
<MotionLink
{...animationProps.fadeIn}
animate={isDimmed ? 'hidden' : 'show'}
className={clsx(styles.row, {
[styles.rowCompact]: isCompact,
[styles.rowDraggedOver]: isDraggedOver,
[styles.rowDraggedOver]: isDraggedOver && !isSmartPlaylist,
[styles.rowHover]: isHovered,
})}
initial={false}
onContextMenu={(e: MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
onContextMenu(e, item);
@@ -235,17 +287,20 @@ export const PlaylistRowButton = memo(
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
ref={ref}
style={{
opacity: isDragging ? 0.5 : 1,
}}
to={url}
variants={playlistRowDimVariants}
>
{isCompact ? (
<>
<Text className={styles.compactName} fw={500} size="md">
{name}
</Text>
{isHovered && <RowControls id={to} onPlay={handlePlay} variant="compact" />}
{isHovered && (
<ItemRowPlayControls
className={clsx(styles.controls, styles.controlsCompact)}
onPlay={(playType) => handlePlay(to, playType)}
/>
)}
</>
) : (
<>
@@ -293,96 +348,19 @@ export const PlaylistRowButton = memo(
</div>
</div>
{isHovered && <RowControls id={to} onPlay={handlePlay} />}
{isHovered && (
<ItemRowPlayControls
className={styles.controls}
onPlay={(playType) => handlePlay(to, playType)}
/>
)}
</>
)}
</Link>
</MotionLink>
);
},
);
const RowControls = ({
id,
onPlay,
variant = 'expanded',
}: {
id: string;
onPlay: (id: string, playType: Play) => void;
variant?: 'compact' | 'expanded';
}) => {
const handlePlayNext = usePlayButtonClick({
onClick: () => {
onPlay(id, Play.NEXT);
},
onLongPress: () => {
onPlay(id, LONG_PRESS_PLAY_BEHAVIOR[Play.NEXT]);
},
});
const handlePlayNow = usePlayButtonClick({
onClick: () => {
onPlay(id, Play.NOW);
},
onLongPress: () => {
onPlay(id, LONG_PRESS_PLAY_BEHAVIOR[Play.NOW]);
},
});
const handlePlayLast = usePlayButtonClick({
onClick: () => {
onPlay(id, Play.LAST);
},
onLongPress: () => {
onPlay(id, LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]);
},
});
return (
<ActionIconGroup
className={clsx(styles.controls, {
[styles.controlsCompact]: variant === 'compact',
})}
>
<PlayTooltip type={Play.NOW}>
<ActionIcon
icon="mediaPlay"
iconProps={{
size: 'md',
}}
size="xs"
variant="subtle"
{...handlePlayNow.handlers}
{...handlePlayNow.props}
/>
</PlayTooltip>
<PlayTooltip type={Play.NEXT}>
<ActionIcon
icon="mediaPlayNext"
iconProps={{
size: 'md',
}}
size="xs"
variant="subtle"
{...handlePlayNext.handlers}
{...handlePlayNext.props}
/>
</PlayTooltip>
<PlayTooltip type={Play.LAST}>
<ActionIcon
icon="mediaPlayLast"
iconProps={{
size: 'md',
}}
size="xs"
variant="subtle"
{...handlePlayLast.handlers}
{...handlePlayLast.props}
/>
</PlayTooltip>
</ActionIconGroup>
);
};
export const SidebarPlaylistList = () => {
const player = usePlayer();
const { t } = useTranslation();
@@ -551,6 +529,7 @@ export const SidebarPlaylistList = () => {
);
const showExpandAll = folderView !== 'navigation' && folderPaths.length > 0;
const isFolderMovePending = useIsMutatingSidebarPlaylistFolderMove();
return (
<Accordion.Item value="playlists">
@@ -571,7 +550,7 @@ export const SidebarPlaylistList = () => {
{inNavigation ? navigation.currentName : t('page.sidebar.playlists')}
</Text>
</Group>
<Group gap="xs">
<Group gap="xs" wrap="nowrap">
<ActionIcon
icon="add"
iconProps={{
@@ -622,16 +601,19 @@ export const SidebarPlaylistList = () => {
</Group>
</Group>
</PlaylistRootAccordionControl>
<Accordion.Panel>
<PlaylistFolderViews
{...folderViewState}
allPlaylists={playlistItems?.items ?? []}
expandedSet={expandedSet}
navigation={navigation}
onContextMenu={handleContextMenu}
onReorder={handleReorder}
onToggleFolder={toggle}
/>
<Accordion.Panel className={styles.panel}>
<LoadingOverlay pos="absolute" visible={isFolderMovePending} />
<PlaylistFolderDragExpandProvider expandedSet={expandedSet} setMany={setMany}>
<PlaylistFolderViews
{...folderViewState}
allPlaylists={playlistItems?.items ?? []}
expandedSet={expandedSet}
navigation={navigation}
onContextMenu={handleContextMenu}
onReorder={handleReorder}
onToggleFolder={toggle}
/>
</PlaylistFolderDragExpandProvider>
</Accordion.Panel>
</Accordion.Item>
);
@@ -771,7 +753,7 @@ export const SidebarSharedPlaylistList = () => {
const folderViewState = usePlaylistFolderViewState(playlistItems?.items ?? []);
const navigation = usePlaylistNavigationState();
const { expandedSet, toggle } = usePlaylistFolderState('shared');
const { expandedSet, setMany, toggle } = usePlaylistFolderState('shared');
const inNavigation =
folderViewState.folderView === 'navigation' && navigation.pathStack.length > 0;
@@ -783,6 +765,8 @@ export const SidebarSharedPlaylistList = () => {
[navigation],
);
const isFolderMovePending = useIsMutatingSidebarPlaylistFolderMove();
if (playlistItems?.items?.length === 0) {
return null;
}
@@ -806,16 +790,19 @@ export const SidebarSharedPlaylistList = () => {
</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
<PlaylistFolderViews
{...folderViewState}
allPlaylists={playlistItems?.items ?? []}
expandedSet={expandedSet}
navigation={navigation}
onContextMenu={handleContextMenu}
onReorder={handleReorder}
onToggleFolder={toggle}
/>
<Accordion.Panel className={styles.panel}>
<LoadingOverlay pos="absolute" visible={isFolderMovePending} />
<PlaylistFolderDragExpandProvider expandedSet={expandedSet} setMany={setMany}>
<PlaylistFolderViews
{...folderViewState}
allPlaylists={playlistItems?.items ?? []}
expandedSet={expandedSet}
navigation={navigation}
onContextMenu={handleContextMenu}
onReorder={handleReorder}
onToggleFolder={toggle}
/>
</PlaylistFolderDragExpandProvider>
</Accordion.Panel>
</Accordion.Item>
);
@@ -16,8 +16,10 @@ import { SidebarCollectionList } from '/@/renderer/features/sidebar/components/s
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
import {
SidebarPlaylistAddDragContext,
SidebarPlaylistList,
SidebarSharedPlaylistList,
useSidebarPlaylistAddDragMonitor,
} from '/@/renderer/features/sidebar/components/sidebar-playlist-list';
import {
useAppStore,
@@ -45,6 +47,17 @@ import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';
import { Platform } from '/@/shared/types/types';
const SidebarPlaylistSection = () => {
const isAddDragActive = useSidebarPlaylistAddDragMonitor();
return (
<SidebarPlaylistAddDragContext.Provider value={isAddDragActive}>
<SidebarPlaylistList />
<SidebarSharedPlaylistList />
</SidebarPlaylistAddDragContext.Provider>
);
};
export const Sidebar = () => {
const { t } = useTranslation();
@@ -140,12 +153,7 @@ export const Sidebar = () => {
</Accordion.Panel>
</Accordion.Item>
<SidebarCollectionList />
{sidebarPlaylistList && (
<>
<SidebarPlaylistList />
<SidebarSharedPlaylistList />
</>
)}
{sidebarPlaylistList && <SidebarPlaylistSection />}
</Accordion>
</ScrollArea>
<AnimatePresence initial={false} mode="popLayout">
@@ -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;
+6 -2
View File
@@ -37,7 +37,7 @@ interface UseDraggableProps {
drop?: {
canDrop: (args: { source: DragData }) => boolean;
getData: () => DragData;
onDrag: (args: { edge: Edge | null }) => void;
onDrag: (args: { edge: Edge | null; source: DragData }) => void;
onDragLeave: () => void;
onDrop: (args: { edge: Edge | null; self: DragData; source: DragData }) => void;
};
@@ -139,10 +139,14 @@ export const useDragDrop = <TElement extends HTMLElement>({
},
onDrag: (args) => {
const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);
drop.onDrag?.({ edge: closestEdgeOfTarget });
drop.onDrag?.({
edge: closestEdgeOfTarget,
source: args.source.data as unknown as DragData,
});
setIsDraggedOver(closestEdgeOfTarget);
},
onDragLeave: () => {
drop.onDragLeave?.();
setIsDraggedOver(null);
},
onDrop: (args) => {
+33
View File
@@ -0,0 +1,33 @@
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { useEffect } from 'react';
import { DragData } from '/@/shared/types/drag-and-drop';
interface UseDragMonitorProps {
canMonitor?: (source: DragData) => boolean;
isEnabled?: boolean;
onDragStart?: (source: DragData) => void;
onDrop?: () => void;
}
export const useDragMonitor = ({
canMonitor,
isEnabled = true,
onDragStart,
onDrop,
}: UseDragMonitorProps) => {
useEffect(() => {
if (!isEnabled) return;
return monitorForElements({
onDragStart: ({ source }) => {
const data = source.data as unknown as DragData;
if (canMonitor && !canMonitor(data)) return;
onDragStart?.(data);
},
onDrop: () => {
onDrop?.();
},
});
}, [canMonitor, isEnabled, onDragStart, onDrop]);
};
+65 -7
View File
@@ -7,6 +7,7 @@ import { useShallow } from 'zustand/react/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { useRadioStore as useRadioPlayerStore } from '/@/renderer/features/radio/hooks/use-radio-player';
import { createSelectors } from '/@/renderer/lib/zustand';
import { useSettingsStore } from '/@/renderer/store/settings.store';
import {
@@ -138,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 };
@@ -477,6 +497,10 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
break;
}
case Play.NOW: {
if (useRadioPlayerStore.getState().currentStreamUrl) {
useRadioPlayerStore.getState().actions.stop();
}
set((state) => {
newItems.forEach((item) => {
state.queue.songs[item._uniqueId] = item;
@@ -531,6 +555,10 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
break;
}
case Play.SHUFFLE: {
if (useRadioPlayerStore.getState().currentStreamUrl) {
useRadioPlayerStore.getState().actions.stop();
}
set((state) => {
newItems.forEach((item) => {
state.queue.songs[item._uniqueId] = item;
@@ -791,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,
@@ -886,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;
@@ -954,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,
@@ -1953,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,
+2
View File
@@ -54,6 +54,7 @@ import {
LuExternalLink,
LuFileJson,
LuFlag,
LuFolderClosed,
LuFolderOpen,
LuGauge,
LuGithub,
@@ -278,6 +279,7 @@ export const AppIcon = {
fileJson: LuFileJson,
filter: LuListFilter,
folder: LuFolderOpen,
folderClosed: LuFolderClosed,
genre: LuFlag,
goToItem: LuCornerDownRight,
hash: LuHash,
+1 -1
View File
@@ -60,7 +60,7 @@ export function BaseImage({
enableAnimation = false,
enableDebounce = false,
enableViewport = true,
fetchPriority,
fetchPriority = 'low',
imageContainerProps,
imageRequest,
includeLoader = true,
+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',