mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-27 12:56:31 +02:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18b62ae22b | |||
| 56a552f893 | |||
| d11c3fa58c | |||
| 54f181c542 | |||
| 1bf51d1d72 | |||
| 37df94bd3b | |||
| dd60499185 | |||
| 009732e745 | |||
| 0de1e1aa3e | |||
| 6528859ea9 | |||
| 704745eb7c | |||
| 2759cfee97 | |||
| 755f0aab9d | |||
| 0e163543fc | |||
| fad2534d44 | |||
| edfc64d790 | |||
| 8f40894926 | |||
| 2befcb4e74 | |||
| 2d78c32a68 | |||
| 3d0500980a | |||
| 3551ee5077 | |||
| 4008c8dfdf | |||
| 64efbc5210 | |||
| 42e9394246 | |||
| afbe8fac52 | |||
| 2c1be99bd5 | |||
| ada94e5f5d | |||
| c8675ab600 | |||
| 37367a6741 | |||
| e7b65c8e86 | |||
| 3d1095dbd8 | |||
| da4284bac0 | |||
| f3c0b68a0f | |||
| 5a3f2fe96f | |||
| aec5e84758 | |||
| e85acee3e6 | |||
| 406cf470bc | |||
| 167ee03a1f |
@@ -1,7 +1,7 @@
|
|||||||
appId: org.jeffvli.feishin
|
appId: org.jeffvli.feishin
|
||||||
productName: Feishin
|
productName: Feishin
|
||||||
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||||
electronVersion: 39.4.0
|
electronVersion: 41.7.0
|
||||||
directories:
|
directories:
|
||||||
buildResources: assets
|
buildResources: assets
|
||||||
files:
|
files:
|
||||||
@@ -47,7 +47,7 @@ mac:
|
|||||||
gatekeeperAssess: false
|
gatekeeperAssess: false
|
||||||
notarize: false
|
notarize: false
|
||||||
extendInfo:
|
extendInfo:
|
||||||
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
|
NSAudioCaptureUsageDescription: 'System audio access is required for mpv visualizer capture in Feishin'
|
||||||
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
|
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
|
||||||
|
|
||||||
dmg:
|
dmg:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
appId: org.jeffvli.feishin
|
appId: org.jeffvli.feishin
|
||||||
productName: Feishin
|
productName: Feishin
|
||||||
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||||
electronVersion: 39.4.0
|
electronVersion: 41.7.0
|
||||||
directories:
|
directories:
|
||||||
buildResources: assets
|
buildResources: assets
|
||||||
files:
|
files:
|
||||||
@@ -47,7 +47,7 @@ mac:
|
|||||||
gatekeeperAssess: false
|
gatekeeperAssess: false
|
||||||
notarize: false
|
notarize: false
|
||||||
extendInfo:
|
extendInfo:
|
||||||
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
|
NSAudioCaptureUsageDescription: 'System audio access is required for mpv visualizer capture in Feishin'
|
||||||
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
|
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
|
||||||
|
|
||||||
dmg:
|
dmg:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
appId: org.jeffvli.feishin
|
appId: org.jeffvli.feishin
|
||||||
productName: Feishin
|
productName: Feishin
|
||||||
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||||
electronVersion: 39.4.0
|
electronVersion: 41.7.0
|
||||||
directories:
|
directories:
|
||||||
buildResources: assets
|
buildResources: assets
|
||||||
files:
|
files:
|
||||||
|
|||||||
+1
-1
@@ -160,7 +160,7 @@
|
|||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^39.8.6",
|
"electron": "^41.7.0",
|
||||||
"electron-builder": "^26.8.2",
|
"electron-builder": "^26.8.2",
|
||||||
"electron-devtools-installer": "^4.0.0",
|
"electron-devtools-installer": "^4.0.0",
|
||||||
"electron-vite": "^4.0.1",
|
"electron-vite": "^4.0.1",
|
||||||
|
|||||||
Generated
+13
-25
@@ -23,10 +23,10 @@ importers:
|
|||||||
version: 1.1.0
|
version: 1.1.0
|
||||||
'@electron-toolkit/preload':
|
'@electron-toolkit/preload':
|
||||||
specifier: ^3.0.2
|
specifier: ^3.0.2
|
||||||
version: 3.0.2(electron@39.8.6)
|
version: 3.0.2(electron@41.7.0)
|
||||||
'@electron-toolkit/utils':
|
'@electron-toolkit/utils':
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.0.0(electron@39.8.6)
|
version: 4.0.0(electron@41.7.0)
|
||||||
'@mantine/colors-generator':
|
'@mantine/colors-generator':
|
||||||
specifier: ^9.1.1
|
specifier: ^9.1.1
|
||||||
version: 9.1.1(chroma-js@3.1.2)
|
version: 9.1.1(chroma-js@3.1.2)
|
||||||
@@ -269,8 +269,8 @@ importers:
|
|||||||
specifier: ^10.1.0
|
specifier: ^10.1.0
|
||||||
version: 10.1.0
|
version: 10.1.0
|
||||||
electron:
|
electron:
|
||||||
specifier: ^39.8.6
|
specifier: ^41.7.0
|
||||||
version: 39.8.6
|
version: 41.7.0
|
||||||
electron-builder:
|
electron-builder:
|
||||||
specifier: ^26.8.2
|
specifier: ^26.8.2
|
||||||
version: 26.8.2(electron-builder-squirrel-windows@26.8.2)
|
version: 26.8.2(electron-builder-squirrel-windows@26.8.2)
|
||||||
@@ -2126,9 +2126,6 @@ packages:
|
|||||||
'@types/ms@2.1.0':
|
'@types/ms@2.1.0':
|
||||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||||
|
|
||||||
'@types/node@22.19.17':
|
|
||||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
|
||||||
|
|
||||||
'@types/node@24.12.2':
|
'@types/node@24.12.2':
|
||||||
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
|
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
|
||||||
|
|
||||||
@@ -3001,8 +2998,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
electron@39.8.6:
|
electron@41.7.0:
|
||||||
resolution: {integrity: sha512-uWX6Jh5LmwL13VwOSKBjebI+ck+03GOwc8V2Sgbmr9pJVJ/cHfli/PkjXuRDr+hq+SLHQuT9mGHSIfScebApRA==}
|
resolution: {integrity: sha512-U6KAKivjk6YQ0Z5+eloJBjwhbHRE206gvy1UBMw2bSluWtMh5waeXMvX6AT/Ujm5ymYXVJOp7g9N7vOFw16wBQ==}
|
||||||
engines: {node: '>= 12.20.55'}
|
engines: {node: '>= 12.20.55'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -5507,9 +5504,6 @@ packages:
|
|||||||
underscore.string@3.3.6:
|
underscore.string@3.3.6:
|
||||||
resolution: {integrity: sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==}
|
resolution: {integrity: sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==}
|
||||||
|
|
||||||
undici-types@6.21.0:
|
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
|
||||||
|
|
||||||
undici-types@7.16.0:
|
undici-types@7.16.0:
|
||||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||||
|
|
||||||
@@ -6684,17 +6678,17 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@electron-toolkit/preload@3.0.2(electron@39.8.6)':
|
'@electron-toolkit/preload@3.0.2(electron@41.7.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
electron: 39.8.6
|
electron: 41.7.0
|
||||||
|
|
||||||
'@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)':
|
'@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.12.2
|
'@types/node': 24.12.2
|
||||||
|
|
||||||
'@electron-toolkit/utils@4.0.0(electron@39.8.6)':
|
'@electron-toolkit/utils@4.0.0(electron@41.7.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
electron: 39.8.6
|
electron: 41.7.0
|
||||||
|
|
||||||
'@electron/asar@3.4.1':
|
'@electron/asar@3.4.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7675,7 +7669,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/electron-localshortcut@3.1.3':
|
'@types/electron-localshortcut@3.1.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
electron: 39.8.6
|
electron: 41.7.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -7703,10 +7697,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@types/ms@2.1.0': {}
|
||||||
|
|
||||||
'@types/node@22.19.17':
|
|
||||||
dependencies:
|
|
||||||
undici-types: 6.21.0
|
|
||||||
|
|
||||||
'@types/node@24.12.2':
|
'@types/node@24.12.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.16.0
|
undici-types: 7.16.0
|
||||||
@@ -8800,10 +8790,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
electron@39.8.6:
|
electron@41.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@electron/get': 2.0.3
|
'@electron/get': 2.0.3
|
||||||
'@types/node': 22.19.17
|
'@types/node': 24.12.2
|
||||||
extract-zip: 2.0.1
|
extract-zip: 2.0.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -11588,8 +11578,6 @@ snapshots:
|
|||||||
sprintf-js: 1.1.3
|
sprintf-js: 1.1.3
|
||||||
util-deprecate: 1.0.2
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
|
||||||
|
|
||||||
undici-types@7.16.0: {}
|
undici-types@7.16.0: {}
|
||||||
|
|
||||||
undici@6.24.1: {}
|
undici@6.24.1: {}
|
||||||
|
|||||||
+42
-20
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"page": {
|
"page": {
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"myLibrary": "La meva llibreria",
|
"myLibrary": "La meva mediateca",
|
||||||
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
|
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
|
||||||
"albums": "$t(entity.album, {\"count\": 2})",
|
"albums": "$t(entity.album, {\"count\": 2})",
|
||||||
"artists": "$t(entity.artist, {\"count\": 2})",
|
"artists": "$t(entity.artist, {\"count\": 2})",
|
||||||
@@ -340,7 +340,8 @@
|
|||||||
"filter_multiple": "Multi",
|
"filter_multiple": "Multi",
|
||||||
"rename": "Reanomena",
|
"rename": "Reanomena",
|
||||||
"newVersionAvailable": "Hi ha una nova versió disponible",
|
"newVersionAvailable": "Hi ha una nova versió disponible",
|
||||||
"numberOfResults": "{{numberOfResults}} resultats"
|
"numberOfResults": "{{numberOfResults}} resultats",
|
||||||
|
"back": "Enrere"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"album_one": "Àlbum",
|
"album_one": "Àlbum",
|
||||||
@@ -557,7 +558,9 @@
|
|||||||
"selectRangeOfItems": "Selecciona un interval d'elements",
|
"selectRangeOfItems": "Selecciona un interval d'elements",
|
||||||
"selectAll": "Selecciona-ho tot",
|
"selectAll": "Selecciona-ho tot",
|
||||||
"openApplicationDirectory": "Obre el directori de l'aplicació",
|
"openApplicationDirectory": "Obre el directori de l'aplicació",
|
||||||
"goToCurrent": "Anar a l'element actual"
|
"goToCurrent": "Anar a l'element actual",
|
||||||
|
"collapseAllFolders": "Replega totes les carpetes",
|
||||||
|
"expandAllFolders": "Expandeix totes les carpetes"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"language_description": "Estableix l'idioma de l'aplicació ($t(common.restartRequired))",
|
"language_description": "Estableix l'idioma de l'aplicació ($t(common.restartRequired))",
|
||||||
@@ -921,8 +924,8 @@
|
|||||||
"primaryShade_description": "Substitueix el to primari (0–9) utilitzat per a botons, enllaços i altres elements de color primari",
|
"primaryShade_description": "Substitueix el to primari (0–9) utilitzat per a botons, enllaços i altres elements de color primari",
|
||||||
"playerItemConfiguration_description": "Configurar quins elements es mostren i en quin ordre al reproductor de pantalla completa",
|
"playerItemConfiguration_description": "Configurar quins elements es mostren i en quin ordre al reproductor de pantalla completa",
|
||||||
"playerItemConfiguration": "Configuració d'elements del jugador",
|
"playerItemConfiguration": "Configuració d'elements del jugador",
|
||||||
"listenbrainz_description": "mostra enllaços a ListenBrainz a les pàgines d'artista/àlbum",
|
"listenbrainz_description": "Mostra enllaços a ListenBrainz a les pàgines d'artista/àlbum",
|
||||||
"listenbrainz": "mostra enllaços a ListenBrainz",
|
"listenbrainz": "Mostra enllaços a ListenBrainz",
|
||||||
"qobuz_description": "Mostra enllaços a Qobuz a les pàgines d'artista/àlbum",
|
"qobuz_description": "Mostra enllaços a Qobuz a les pàgines d'artista/àlbum",
|
||||||
"qobuz": "Mostra enllaços a Qobuz",
|
"qobuz": "Mostra enllaços a Qobuz",
|
||||||
"spotify_description": "Mostra enllaços a Spotify a les pàgines d'artista/àlbum",
|
"spotify_description": "Mostra enllaços a Spotify a les pàgines d'artista/àlbum",
|
||||||
@@ -930,25 +933,43 @@
|
|||||||
"nativeSpotify_description": "Obre amb Spotify en lloc del vostre navegador",
|
"nativeSpotify_description": "Obre amb Spotify en lloc del vostre navegador",
|
||||||
"nativeSpotify": "Fes servir Spotify",
|
"nativeSpotify": "Fes servir Spotify",
|
||||||
"playerbarWaveformStretch": "Extensió de la forma d'ona",
|
"playerbarWaveformStretch": "Extensió de la forma d'ona",
|
||||||
"playerbarWaveformStretch_description": "estén la forma d'ona per omplir l'espai disponible",
|
"playerbarWaveformStretch_description": "Estén la forma d'ona per omplir l'espai disponible",
|
||||||
"sidePlayQueueLayout": "Disposició de la cua de reproducció lateral",
|
"sidePlayQueueLayout": "Disposició de la cua de reproducció lateral",
|
||||||
"sidePlayQueueLayout_description": "estableix la disposició de la cua de reproducció lateral adjunta",
|
"sidePlayQueueLayout_description": "Estableix la disposició de la cua de reproducció lateral adjunta",
|
||||||
"sidePlayQueueLayout_optionHorizontal": "horitzontal",
|
"sidePlayQueueLayout_optionHorizontal": "Horitzontal",
|
||||||
"sidePlayQueueLayout_optionVertical": "vertical",
|
"sidePlayQueueLayout_optionVertical": "Vertical",
|
||||||
"waveformLoadingDelay": "Demora de càrrega de la forma d'ona",
|
"waveformLoadingDelay": "Demora de càrrega de la forma d'ona",
|
||||||
"waveformLoadingDelay_description": "Demora en segons abans de carregar la forma d'ona. Incrementeu aquest valor si patiu interrupcions en fer servir el reproductor web.",
|
"waveformLoadingDelay_description": "Demora en segons abans de carregar la forma d'ona. Incrementeu aquest valor si patiu interrupcions en fer servir el reproductor web.",
|
||||||
"preventSuspendOnPlayback_description": "Evita que l'aplicació quedi suspesa mentre es reprodueix música",
|
"preventSuspendOnPlayback_description": "Evita que l'aplicació quedi suspesa mentre es reprodueix música",
|
||||||
"preventSuspendOnPlayback": "Evita la suspensió durant la reproducció"
|
"preventSuspendOnPlayback": "Evita la suspensió durant la reproducció",
|
||||||
|
"hotkey_listShowPlayingSong": "Mostra la cançó en reproducció a la llista",
|
||||||
|
"sidebarPlaylistFolders_description": "Create una vista de carpeta per lliste de reproducció que inclogui el separador configurat al nom",
|
||||||
|
"sidebarPlaylistFolders": "Habilita les carpetes",
|
||||||
|
"sidebarPlaylistFolderSeparator_description": "Caràcter (o cadena) que separa els nivells de carpetes al nom d'una llista de reproducció",
|
||||||
|
"sidebarPlaylistFolderSeparator": "Separador de carpetes",
|
||||||
|
"sidebarPlaylistFolderView_description": "Com es mostren les carpetes a la barra lateral",
|
||||||
|
"sidebarPlaylistFolderView": "Vista de carpeta",
|
||||||
|
"sidebarPlaylistFolderView_optionSingle": "Carpeta única",
|
||||||
|
"sidebarPlaylistFolderView_optionTree": "Vista d'arbre",
|
||||||
|
"sidebarPlaylistFolderView_optionNavigation": "Vista de navegació",
|
||||||
|
"sidebarPlaylistFolderTreeIndent_description": "Píxels de sagnat per cada nivell de l'arbre",
|
||||||
|
"sidebarPlaylistFolderTreeIndent": "Sagnat de l'arbre",
|
||||||
|
"sidebarPlaylistFolderTreeLineColor_description": "Color de les línies connectores de l'arbre (deixeu-ho en blanc per fer servir el valor predeterminat)",
|
||||||
|
"sidebarPlaylistFolderTreeLineColor": "Color de la línia de l'arbre",
|
||||||
|
"sidebarPlaylistMode_description": "Com es mostra cada llista de reproducció a la llista de la barra lateral",
|
||||||
|
"sidebarPlaylistMode": "Mode de llista de reproducció a la barra lateral",
|
||||||
|
"sidebarPlaylistMode_optionCompact": "Compacte",
|
||||||
|
"sidebarPlaylistMode_optionExpanded": "Expandit"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"column": {
|
"column": {
|
||||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
"albumCount": "Àlbums",
|
||||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
"artist": "Artista",
|
||||||
"channels": "$t(common.channel, {\"count\": 2})",
|
"channels": "Canals",
|
||||||
"codec": "$t(common.codec)",
|
"codec": "Còdec",
|
||||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
"genre": "Gènere",
|
||||||
"size": "$t(common.size)",
|
"size": "Mida",
|
||||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
"songCount": "Pistes",
|
||||||
"releaseYear": "Any",
|
"releaseYear": "Any",
|
||||||
"playCount": "Reproduccions",
|
"playCount": "Reproduccions",
|
||||||
"releaseDate": "Data de llançament",
|
"releaseDate": "Data de llançament",
|
||||||
@@ -966,8 +987,8 @@
|
|||||||
"path": "Ruta",
|
"path": "Ruta",
|
||||||
"rating": "Qualificació",
|
"rating": "Qualificació",
|
||||||
"title": "Títol",
|
"title": "Títol",
|
||||||
"bitDepth": "$t(common.bitDepth)",
|
"bitDepth": "Profunditat de bits",
|
||||||
"sampleRate": "$t(common.sampleRate)",
|
"sampleRate": "Freqüència de mostreig",
|
||||||
"owner": "Propietari"
|
"owner": "Propietari"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@@ -1146,7 +1167,8 @@
|
|||||||
"sleepTimer_timeRemaining": "Queden {{time}}",
|
"sleepTimer_timeRemaining": "Queden {{time}}",
|
||||||
"sleepTimer_setCustom": "Configura el temporitzador",
|
"sleepTimer_setCustom": "Configura el temporitzador",
|
||||||
"sleepTimer_cancel": "Cancel·la el temporitzador",
|
"sleepTimer_cancel": "Cancel·la el temporitzador",
|
||||||
"albumRadio": "Ràdio d'àlbums"
|
"albumRadio": "Ràdio d'àlbums",
|
||||||
|
"scrobbleForceSubmit": "Força l'scrobble"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"credentialsRequired": "Credencials requerides",
|
"credentialsRequired": "Credencials requerides",
|
||||||
|
|||||||
@@ -430,7 +430,25 @@
|
|||||||
"playerbarWaveformStretch": "Natáhnutí vlnové křivky",
|
"playerbarWaveformStretch": "Natáhnutí vlnové křivky",
|
||||||
"playerbarWaveformStretch_description": "Natáhně vlnovou křivku tak, aby vyplnila dostupný prostor",
|
"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_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": {
|
"action": {
|
||||||
"editPlaylist": "Upravit $t(entity.playlist, {\"count\": 1})",
|
"editPlaylist": "Upravit $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -474,7 +492,9 @@
|
|||||||
"addOrRemoveFromSelection": "Přidat nebo odebrat z výběru",
|
"addOrRemoveFromSelection": "Přidat nebo odebrat z výběru",
|
||||||
"selectRangeOfItems": "Vyberte rozsah položek",
|
"selectRangeOfItems": "Vyberte rozsah položek",
|
||||||
"selectAll": "Vybrat vše",
|
"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": {
|
"common": {
|
||||||
"backward": "Zpátky",
|
"backward": "Zpátky",
|
||||||
@@ -603,7 +623,8 @@
|
|||||||
"rename": "Přejmenovat",
|
"rename": "Přejmenovat",
|
||||||
"newVersionAvailable": "Je dostupná nová verze",
|
"newVersionAvailable": "Je dostupná nová verze",
|
||||||
"numberOfResults": "{{numberOfResults}} výsledků",
|
"numberOfResults": "{{numberOfResults}} výsledků",
|
||||||
"grouping": "Seskupování"
|
"grouping": "Seskupování",
|
||||||
|
"back": "Zpět"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
|
|||||||
@@ -430,7 +430,25 @@
|
|||||||
"playerbarWaveformStretch": "Estiramiento de la forma de onda",
|
"playerbarWaveformStretch": "Estiramiento de la forma de onda",
|
||||||
"playerbarWaveformStretch_description": "Estira la forma de onda para rellenar el espacio disponible",
|
"playerbarWaveformStretch_description": "Estira la forma de onda para rellenar el espacio disponible",
|
||||||
"preventSuspendOnPlayback": "Evitar la suspensión durante la reproducción",
|
"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": {
|
"action": {
|
||||||
"editPlaylist": "Editar $t(entity.playlist, {\"count\": 1})",
|
"editPlaylist": "Editar $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -474,7 +492,9 @@
|
|||||||
"addOrRemoveFromSelection": "Añadir o quitar de la selección",
|
"addOrRemoveFromSelection": "Añadir o quitar de la selección",
|
||||||
"selectRangeOfItems": "Seleccionar un intervalo de elementos",
|
"selectRangeOfItems": "Seleccionar un intervalo de elementos",
|
||||||
"selectAll": "Seleccionar todo",
|
"selectAll": "Seleccionar todo",
|
||||||
"goToCurrent": "Ir al elemento actual"
|
"goToCurrent": "Ir al elemento actual",
|
||||||
|
"collapseAllFolders": "Contraer todas las carpetas",
|
||||||
|
"expandAllFolders": "Expandir todas las carpetas"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"backward": "Hacia atrás",
|
"backward": "Hacia atrás",
|
||||||
@@ -603,7 +623,8 @@
|
|||||||
"rename": "Renombrar",
|
"rename": "Renombrar",
|
||||||
"newVersionAvailable": "Una nueva versión está disponible",
|
"newVersionAvailable": "Una nueva versión está disponible",
|
||||||
"numberOfResults": "{{numberOfResults}} resultados",
|
"numberOfResults": "{{numberOfResults}} resultados",
|
||||||
"grouping": "Agrupar"
|
"grouping": "Agrupar",
|
||||||
|
"back": "Atrás"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "Reiniciar el servidor para aplicar el nuevo puerto",
|
"remotePortWarning": "Reiniciar el servidor para aplicar el nuevo puerto",
|
||||||
|
|||||||
@@ -48,7 +48,8 @@
|
|||||||
"sleepTimer_timeRemaining": "{{time}} restante(s)",
|
"sleepTimer_timeRemaining": "{{time}} restante(s)",
|
||||||
"sleepTimer_setCustom": "Définir le minuteur",
|
"sleepTimer_setCustom": "Définir le minuteur",
|
||||||
"sleepTimer_cancel": "Annuler le minuteur",
|
"sleepTimer_cancel": "Annuler le minuteur",
|
||||||
"albumRadio": "Radio d'album"
|
"albumRadio": "Radio d'album",
|
||||||
|
"scrobbleForceSubmit": "Forcer le scrobble"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "Éditer $t(entity.playlist, {\"count\": 1})",
|
"editPlaylist": "Éditer $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -92,7 +93,9 @@
|
|||||||
"selectRangeOfItems": "Sélectionner une plage d'entrées",
|
"selectRangeOfItems": "Sélectionner une plage d'entrées",
|
||||||
"selectAll": "Tout sélectionner",
|
"selectAll": "Tout sélectionner",
|
||||||
"openApplicationDirectory": "Ouvrir le répertoire de l'application",
|
"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": {
|
"common": {
|
||||||
"backward": "En arrière",
|
"backward": "En arrière",
|
||||||
@@ -222,7 +225,8 @@
|
|||||||
"filter_multiple": "Multiple",
|
"filter_multiple": "Multiple",
|
||||||
"rename": "Renommer",
|
"rename": "Renommer",
|
||||||
"newVersionAvailable": "Une nouvelle version est disponible",
|
"newVersionAvailable": "Une nouvelle version est disponible",
|
||||||
"numberOfResults": "{{numberOfResults}} résultats"
|
"numberOfResults": "{{numberOfResults}} résultats",
|
||||||
|
"back": "Retour"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"remotePortWarning": "Redémarrer le serveur pour appliquer le nouveau port",
|
"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",
|
"transcodeFormat_description": "Sélectionne le format du transcodage. laisser vide pour laisser le serveur décider",
|
||||||
"volumeWidth": "Largeur de la barre de volume",
|
"volumeWidth": "Largeur de la barre de volume",
|
||||||
"volumeWidth_description": "La 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é",
|
"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",
|
"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é",
|
"customCss": "Css personnalisé",
|
||||||
@@ -895,7 +899,24 @@
|
|||||||
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
|
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
|
||||||
"sidePlayQueueLayout_optionVertical": "Vertical",
|
"sidePlayQueueLayout_optionVertical": "Vertical",
|
||||||
"waveformLoadingDelay": "Délai de chargement de la forme d'onde",
|
"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": {
|
"form": {
|
||||||
"deletePlaylist": {
|
"deletePlaylist": {
|
||||||
@@ -922,12 +943,13 @@
|
|||||||
"input_remoteUrlPlaceholder": "Optionnel : URL publique pour les fonctionnalités externes"
|
"input_remoteUrlPlaceholder": "Optionnel : URL publique pour les fonctionnalités externes"
|
||||||
},
|
},
|
||||||
"addToPlaylist": {
|
"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})",
|
"title": "Ajouter à $t(entity.playlist, {\"count\": 1})",
|
||||||
"input_skipDuplicates": "Sauter les doublons",
|
"input_skipDuplicates": "Sauter les doublons",
|
||||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||||
"create": "Créer $t(entity.playlist, {\"count\": 1}) {{playlist}}",
|
"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": {
|
"createPlaylist": {
|
||||||
"title": "Créer une $t(entity.playlist, {\"count\": 1})",
|
"title": "Créer une $t(entity.playlist, {\"count\": 1})",
|
||||||
|
|||||||
+19
-14
@@ -48,7 +48,8 @@
|
|||||||
"holdToShuffle": "長押しでシャッフル",
|
"holdToShuffle": "長押しでシャッフル",
|
||||||
"albumRadio": "アルバム・ラジオ",
|
"albumRadio": "アルバム・ラジオ",
|
||||||
"artistRadio": "アーティストラジオ",
|
"artistRadio": "アーティストラジオ",
|
||||||
"trackRadio": "ラジオを追跡する"
|
"trackRadio": "ラジオを追跡する",
|
||||||
|
"scrobbleForceSubmit": "強制 Scrobble"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します",
|
"crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します",
|
||||||
@@ -429,7 +430,8 @@
|
|||||||
"playerbarWaveformStretch": "波形伸縮",
|
"playerbarWaveformStretch": "波形伸縮",
|
||||||
"playerbarWaveformStretch_description": "波形を伸縮させて、利用可能なスペースを埋めます",
|
"playerbarWaveformStretch_description": "波形を伸縮させて、利用可能なスペースを埋めます",
|
||||||
"preventSuspendOnPlayback_description": "音楽再生中にアプリケーションが停止しないようにします",
|
"preventSuspendOnPlayback_description": "音楽再生中にアプリケーションが停止しないようにします",
|
||||||
"preventSuspendOnPlayback": "再生の中断を防止する"
|
"preventSuspendOnPlayback": "再生の中断を防止する",
|
||||||
|
"hotkey_listShowPlayingSong": "再生中の曲をリストに表示"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
|
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
|
||||||
@@ -473,7 +475,9 @@
|
|||||||
"openApplicationDirectory": "アプリケーションディレクトリを開く",
|
"openApplicationDirectory": "アプリケーションディレクトリを開く",
|
||||||
"selectRangeOfItems": "項目の範囲を選択",
|
"selectRangeOfItems": "項目の範囲を選択",
|
||||||
"addOrRemoveFromSelection": "選択に追加または削除",
|
"addOrRemoveFromSelection": "選択に追加または削除",
|
||||||
"goToCurrent": "現在の項目へ移動"
|
"goToCurrent": "現在の項目へ移動",
|
||||||
|
"collapseAllFolders": "すべてのフォルダーを折りたたむ",
|
||||||
|
"expandAllFolders": "すべてのフォルダーを展開する"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"backward": "戻る",
|
"backward": "戻る",
|
||||||
@@ -594,7 +598,8 @@
|
|||||||
"rename": "名前を変更",
|
"rename": "名前を変更",
|
||||||
"newVersionAvailable": "新しいバージョンが利用可能です",
|
"newVersionAvailable": "新しいバージョンが利用可能です",
|
||||||
"numberOfResults": "{{numberOfResults}} 件の結果",
|
"numberOfResults": "{{numberOfResults}} 件の結果",
|
||||||
"grouping": "グループ化"
|
"grouping": "グループ化",
|
||||||
|
"back": "戻る"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
@@ -643,7 +648,7 @@
|
|||||||
"titleCombined": "$t(common.title) (結合)",
|
"titleCombined": "$t(common.title) (結合)",
|
||||||
"dateAdded": "追加日",
|
"dateAdded": "追加日",
|
||||||
"size": "$t(common.size)",
|
"size": "$t(common.size)",
|
||||||
"bpm": "$t(common.BPM)",
|
"bpm": "$t(common.bpm)",
|
||||||
"lastPlayed": "最後に再生",
|
"lastPlayed": "最後に再生",
|
||||||
"trackNumber": "トラック番号",
|
"trackNumber": "トラック番号",
|
||||||
"rowIndex": "行インデックス",
|
"rowIndex": "行インデックス",
|
||||||
@@ -681,7 +686,7 @@
|
|||||||
"rating": "評価",
|
"rating": "評価",
|
||||||
"favorite": "お気に入り",
|
"favorite": "お気に入り",
|
||||||
"playCount": "再生回数",
|
"playCount": "再生回数",
|
||||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
"albumCount": "アルバム",
|
||||||
"releaseYear": "年",
|
"releaseYear": "年",
|
||||||
"lastPlayed": "最後に再生",
|
"lastPlayed": "最後に再生",
|
||||||
"biography": "バイオグラフィー",
|
"biography": "バイオグラフィー",
|
||||||
@@ -690,18 +695,18 @@
|
|||||||
"title": "タイトル",
|
"title": "タイトル",
|
||||||
"bpm": "BPM",
|
"bpm": "BPM",
|
||||||
"dateAdded": "追加日",
|
"dateAdded": "追加日",
|
||||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
"artist": "アーティスト",
|
||||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
"songCount": "トラック",
|
||||||
"trackNumber": "トラック",
|
"trackNumber": "トラック",
|
||||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
"genre": "ジャンル",
|
||||||
"albumArtist": "アルバムアーティスト",
|
"albumArtist": "アルバムアーティスト",
|
||||||
"path": "パス",
|
"path": "パス",
|
||||||
"discNumber": "ディスク",
|
"discNumber": "ディスク",
|
||||||
"channels": "$t(common.channel, {\"count\": 2})",
|
"channels": "チャンネル",
|
||||||
"size": "$t(common.size)",
|
"size": "サイズ",
|
||||||
"codec": "$t(common.codec)",
|
"codec": "コーデック",
|
||||||
"bitDepth": "$t(common.bitDepth)",
|
"bitDepth": "ビット深度",
|
||||||
"sampleRate": "$t(common.sampleRate)",
|
"sampleRate": "サンプリング周波数",
|
||||||
"owner": "所有者"
|
"owner": "所有者"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,7 +41,9 @@
|
|||||||
"selectRangeOfItems": "Wybierz zakres elementów",
|
"selectRangeOfItems": "Wybierz zakres elementów",
|
||||||
"selectAll": "Wybierz wszystkie",
|
"selectAll": "Wybierz wszystkie",
|
||||||
"openApplicationDirectory": "Otwórz katalog aplikacji",
|
"openApplicationDirectory": "Otwórz katalog aplikacji",
|
||||||
"goToCurrent": "Przejdź do aktualnego elementu"
|
"goToCurrent": "Przejdź do aktualnego elementu",
|
||||||
|
"collapseAllFolders": "Zwiń wszystkie foldery",
|
||||||
|
"expandAllFolders": "Rozwiń wszystkie foldery"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"increase": "Zwiększ",
|
"increase": "Zwiększ",
|
||||||
@@ -170,7 +172,8 @@
|
|||||||
"rename": "Zmień nazwę",
|
"rename": "Zmień nazwę",
|
||||||
"newVersionAvailable": "Nowa wersja jest dostępna",
|
"newVersionAvailable": "Nowa wersja jest dostępna",
|
||||||
"numberOfResults": "{{numberOfResults}} wyników",
|
"numberOfResults": "{{numberOfResults}} wyników",
|
||||||
"grouping": "Grupowanie"
|
"grouping": "Grupowanie",
|
||||||
|
"back": "Wstecz"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"genre_one": "Gatunek",
|
"genre_one": "Gatunek",
|
||||||
@@ -1072,7 +1075,25 @@
|
|||||||
"playerbarWaveformStretch": "Rozciąganie przebiegu",
|
"playerbarWaveformStretch": "Rozciąganie przebiegu",
|
||||||
"playerbarWaveformStretch_description": "Rozciąga przebieg aby wypełnić dostępną przestrzeń",
|
"playerbarWaveformStretch_description": "Rozciąga przebieg aby wypełnić dostępną przestrzeń",
|
||||||
"preventSuspendOnPlayback_description": "Powstrzymuj wstrzymanie podczas odtwarzania muzyki",
|
"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": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
@@ -1090,7 +1111,7 @@
|
|||||||
"size": "$t(common.size)",
|
"size": "$t(common.size)",
|
||||||
"itemSize": "Rozmiar elementu (px)",
|
"itemSize": "Rozmiar elementu (px)",
|
||||||
"itemGap": "Odstęp między elementami (px)",
|
"itemGap": "Odstęp między elementami (px)",
|
||||||
"followCurrentSong": "ŚLEDź aktualną piosenkę",
|
"followCurrentSong": "Śledź aktualną piosenkę",
|
||||||
"advancedSettings": "Zaawansowane ustawienia",
|
"advancedSettings": "Zaawansowane ustawienia",
|
||||||
"autosize": "Rozmiar automatyczny",
|
"autosize": "Rozmiar automatyczny",
|
||||||
"moveUp": "Przesuń w górę",
|
"moveUp": "Przesuń w górę",
|
||||||
|
|||||||
@@ -41,7 +41,9 @@
|
|||||||
"viewMore": "Посмотреть больше",
|
"viewMore": "Посмотреть больше",
|
||||||
"openApplicationDirectory": "Открыть папку приложения",
|
"openApplicationDirectory": "Открыть папку приложения",
|
||||||
"selectRangeOfItems": "Выбрать диапазон элементов",
|
"selectRangeOfItems": "Выбрать диапазон элементов",
|
||||||
"goToCurrent": "Перейти к текущему элементу"
|
"goToCurrent": "Перейти к текущему элементу",
|
||||||
|
"collapseAllFolders": "Свернуть все папки",
|
||||||
|
"expandAllFolders": "Развернуть все папки"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"backward": "Назад",
|
"backward": "Назад",
|
||||||
@@ -171,7 +173,8 @@
|
|||||||
"externalLinks": "Внешние ссылки",
|
"externalLinks": "Внешние ссылки",
|
||||||
"explicitStatus": "Признак нецензурного контента",
|
"explicitStatus": "Признак нецензурного контента",
|
||||||
"newVersionAvailable": "Доступна новая версия",
|
"newVersionAvailable": "Доступна новая версия",
|
||||||
"numberOfResults": "{{numberOfResults}} результатов"
|
"numberOfResults": "{{numberOfResults}} результатов",
|
||||||
|
"back": "Назад"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"album_one": "Альбом",
|
"album_one": "Альбом",
|
||||||
@@ -430,7 +433,8 @@
|
|||||||
"sleepTimer_timeRemaining": "{{time}} осталось",
|
"sleepTimer_timeRemaining": "{{time}} осталось",
|
||||||
"sleepTimer_setCustom": "Установить таймер",
|
"sleepTimer_setCustom": "Установить таймер",
|
||||||
"sleepTimer_custom": "Пользовательский",
|
"sleepTimer_custom": "Пользовательский",
|
||||||
"sleepTimer_cancel": "Отменить таймер"
|
"sleepTimer_cancel": "Отменить таймер",
|
||||||
|
"scrobbleForceSubmit": "Принудительная скробблинг"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -491,7 +495,8 @@
|
|||||||
"selectMusicFolder": "Выбрать папку с музыкой",
|
"selectMusicFolder": "Выбрать папку с музыкой",
|
||||||
"noMusicFolder": "Папка с музыкой не выбрана",
|
"noMusicFolder": "Папка с музыкой не выбрана",
|
||||||
"multipleMusicFolders": "{{count}} выбрано музыкальных папок",
|
"multipleMusicFolders": "{{count}} выбрано музыкальных папок",
|
||||||
"commandPalette": "открыть командную строку"
|
"commandPalette": "открыть командную строку",
|
||||||
|
"settings": "$t(common.setting, {\"count\": 2})"
|
||||||
},
|
},
|
||||||
"manageServers": {
|
"manageServers": {
|
||||||
"title": "Сервера",
|
"title": "Сервера",
|
||||||
@@ -569,7 +574,8 @@
|
|||||||
},
|
},
|
||||||
"genreList": {
|
"genreList": {
|
||||||
"showAlbums": "показать $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})",
|
"showAlbums": "показать $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})",
|
||||||
"showTracks": "показать $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})"
|
"showTracks": "показать $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})",
|
||||||
|
"title": "$t(entity.genre, {\"count\": 2})"
|
||||||
},
|
},
|
||||||
"trackList": {
|
"trackList": {
|
||||||
"artistTracks": "Треки {{artist}}",
|
"artistTracks": "Треки {{artist}}",
|
||||||
@@ -681,7 +687,8 @@
|
|||||||
"input_skipDuplicates": "не добавлять дубликаты",
|
"input_skipDuplicates": "не добавлять дубликаты",
|
||||||
"create": "создать $t(entity.playlist, {\"count\": 1}) {{playlist}}",
|
"create": "создать $t(entity.playlist, {\"count\": 1}) {{playlist}}",
|
||||||
"searchOrCreate": "для создания нового списка выполните поиск по $t(entity.playlist, {\"count\": 2}) или введите соответствующий текст",
|
"searchOrCreate": "для создания нового списка выполните поиск по $t(entity.playlist, {\"count\": 2}) или введите соответствующий текст",
|
||||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})"
|
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||||
|
"noneAdded": "Ни один трек не добавлен в $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
|
||||||
},
|
},
|
||||||
"updateServer": {
|
"updateServer": {
|
||||||
"title": "Обновление сервера",
|
"title": "Обновление сервера",
|
||||||
|
|||||||
@@ -118,7 +118,8 @@
|
|||||||
"filter_multiple": "複選",
|
"filter_multiple": "複選",
|
||||||
"newVersionAvailable": "有新的版本可供使用",
|
"newVersionAvailable": "有新的版本可供使用",
|
||||||
"numberOfResults": "{{numberOfResults}} 項結果",
|
"numberOfResults": "{{numberOfResults}} 項結果",
|
||||||
"grouping": "分組"
|
"grouping": "分組",
|
||||||
|
"back": "返回"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
|
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
|
||||||
@@ -800,7 +801,24 @@
|
|||||||
"playerbarWaveformStretch_description": "拉伸波形來填補可用空間",
|
"playerbarWaveformStretch_description": "拉伸波形來填補可用空間",
|
||||||
"preventSuspendOnPlayback_description": "音樂播放時防止應用程式進入休眠",
|
"preventSuspendOnPlayback_description": "音樂播放時防止應用程式進入休眠",
|
||||||
"preventSuspendOnPlayback": "在播放時防止應用程式暫停",
|
"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": {
|
"table": {
|
||||||
"config": {
|
"config": {
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ import './player';
|
|||||||
import './remote';
|
import './remote';
|
||||||
import './settings';
|
import './settings';
|
||||||
import './discord-rpc';
|
import './discord-rpc';
|
||||||
|
import './visualizer';
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ipcMain } from 'electron';
|
||||||
|
|
||||||
|
import { getMpvInstance } from '../player';
|
||||||
|
import { store } from '../settings';
|
||||||
|
|
||||||
|
import { PlayerType } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
let isLocalVisualizerSurfaceVisible = false;
|
||||||
|
|
||||||
|
export const setLocalVisualizerSurfaceVisible = (visible: boolean) => {
|
||||||
|
isLocalVisualizerSurfaceVisible = visible;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canHandleVisualizerDisplayMedia = (): boolean => {
|
||||||
|
const playbackType = store.get('playbackType', PlayerType.WEB) as PlayerType;
|
||||||
|
|
||||||
|
if (playbackType !== PlayerType.LOCAL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLocalVisualizerSurfaceVisible) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(getMpvInstance()?.isRunning());
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcMain.on('visualizer-set-local-surface-visible', (_event, visible: boolean) => {
|
||||||
|
setLocalVisualizerSurfaceVisible(Boolean(visible));
|
||||||
|
});
|
||||||
+8
-2
@@ -30,7 +30,9 @@ import packageJson from '../../package.json';
|
|||||||
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
|
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
|
||||||
import { shutdownServer } from './features/core/remote';
|
import { shutdownServer } from './features/core/remote';
|
||||||
import { store } from './features/core/settings';
|
import { store } from './features/core/settings';
|
||||||
|
import { canHandleVisualizerDisplayMedia } from './features/core/visualizer';
|
||||||
import MenuBuilder, { MenuPlaybackState } from './menu';
|
import MenuBuilder, { MenuPlaybackState } from './menu';
|
||||||
|
import './features';
|
||||||
import {
|
import {
|
||||||
autoUpdaterLogInterface,
|
autoUpdaterLogInterface,
|
||||||
createLog,
|
createLog,
|
||||||
@@ -40,7 +42,6 @@ import {
|
|||||||
isMacOS,
|
isMacOS,
|
||||||
isWindows,
|
isWindows,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import './features';
|
|
||||||
|
|
||||||
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
|
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
|
||||||
|
|
||||||
@@ -515,7 +516,7 @@ async function createWindow(first = true): Promise<void> {
|
|||||||
backgroundThrottling: false,
|
backgroundThrottling: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
devTools: true,
|
devTools: true,
|
||||||
nodeIntegration: true,
|
nodeIntegration: false,
|
||||||
preload: join(__dirname, '../preload/index.js'),
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
webSecurity: !store.get('ignore_cors'),
|
webSecurity: !store.get('ignore_cors'),
|
||||||
@@ -734,6 +735,11 @@ async function createWindow(first = true): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
|
mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
|
||||||
|
if (!canHandleVisualizerDisplayMedia()) {
|
||||||
|
callback({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isMacOS()) {
|
if (!isMacOS()) {
|
||||||
callback({ audio: 'loopback' });
|
callback({ audio: 'loopback' });
|
||||||
return;
|
return;
|
||||||
|
|||||||
Vendored
-3
@@ -1,11 +1,8 @@
|
|||||||
import { ElectronAPI } from '@electron-toolkit/preload';
|
|
||||||
|
|
||||||
import { PreloadApi } from './index';
|
import { PreloadApi } from './index';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
api: PreloadApi;
|
api: PreloadApi;
|
||||||
electron: ElectronAPI;
|
|
||||||
LEGACY_AUTHENTICATION?: boolean;
|
LEGACY_AUTHENTICATION?: boolean;
|
||||||
queryLocalFonts?: () => Promise<Font[]>;
|
queryLocalFonts?: () => Promise<Font[]>;
|
||||||
REMOTE_URL?: string;
|
REMOTE_URL?: string;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { electronAPI } from '@electron-toolkit/preload';
|
import { contextBridge, webUtils } from 'electron';
|
||||||
import { contextBridge } from 'electron';
|
|
||||||
|
|
||||||
import { autodiscover } from './autodiscover';
|
import { autodiscover } from './autodiscover';
|
||||||
import { browser } from './browser';
|
import { browser } from './browser';
|
||||||
@@ -11,12 +10,14 @@ import { mpris } from './mpris';
|
|||||||
import { mpvPlayer, mpvPlayerListener } from './mpv-player';
|
import { mpvPlayer, mpvPlayerListener } from './mpv-player';
|
||||||
import { remote } from './remote';
|
import { remote } from './remote';
|
||||||
import { utils } from './utils';
|
import { utils } from './utils';
|
||||||
|
import { visualizer } from './visualizer';
|
||||||
|
|
||||||
// Custom APIs for renderer
|
// Custom APIs for renderer
|
||||||
const api = {
|
const api = {
|
||||||
autodiscover,
|
autodiscover,
|
||||||
browser,
|
browser,
|
||||||
discordRpc,
|
discordRpc,
|
||||||
|
getPathForFile: webUtils.getPathForFile,
|
||||||
ipc,
|
ipc,
|
||||||
localSettings,
|
localSettings,
|
||||||
lyrics,
|
lyrics,
|
||||||
@@ -25,6 +26,7 @@ const api = {
|
|||||||
mpvPlayerListener,
|
mpvPlayerListener,
|
||||||
remote,
|
remote,
|
||||||
utils,
|
utils,
|
||||||
|
visualizer,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PreloadApi = typeof api;
|
export type PreloadApi = typeof api;
|
||||||
@@ -34,14 +36,11 @@ export type PreloadApi = typeof api;
|
|||||||
// just add to the DOM global.
|
// just add to the DOM global.
|
||||||
if (process.contextIsolated) {
|
if (process.contextIsolated) {
|
||||||
try {
|
try {
|
||||||
contextBridge.exposeInMainWorld('electron', electronAPI);
|
|
||||||
contextBridge.exposeInMainWorld('api', api);
|
contextBridge.exposeInMainWorld('api', api);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore (define in dts)
|
|
||||||
window.electron = electronAPI;
|
|
||||||
// @ts-ignore (define in dts)
|
// @ts-ignore (define in dts)
|
||||||
window.api = api;
|
window.api = api;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
const setLocalSurfaceVisible = (visible: boolean) => {
|
||||||
|
ipcRenderer.send('visualizer-set-local-surface-visible', visible);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const visualizer = {
|
||||||
|
setLocalSurfaceVisible,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VisualizerApi = typeof visualizer;
|
||||||
@@ -1062,7 +1062,9 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
throw new Error('Failed to get server info');
|
throw new Error('Failed to get server info');
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultFeatures = {};
|
const defaultFeatures = {
|
||||||
|
[ServerFeature.REPORT_PLAYBACK]: [1],
|
||||||
|
};
|
||||||
|
|
||||||
const features = {
|
const features = {
|
||||||
...defaultFeatures,
|
...defaultFeatures,
|
||||||
|
|||||||
@@ -100,9 +100,6 @@ const EXCLUDED_SONG_TAGS = new Set<string>(['disctotal', 'tracktotal']);
|
|||||||
const numericSortCollator = new Intl.Collator(undefined, { numeric: true });
|
const numericSortCollator = new Intl.Collator(undefined, { numeric: true });
|
||||||
const collator = new Intl.Collator();
|
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) => {
|
const excludeMissing = (server?: null | ServerListItemWithCredential) => {
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -965,13 +962,13 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
if (!EXCLUDED_TAGS.has(tag.tagName)) {
|
if (!EXCLUDED_TAGS.has(tag.tagName)) {
|
||||||
if (tagsToValues.has(tag.tagName)) {
|
if (tagsToValues.has(tag.tagName)) {
|
||||||
tagsToValues.get(tag.tagName)!.push({
|
tagsToValues.get(tag.tagName)!.push({
|
||||||
id: ID_TAGS.has(tag.tagName) ? tag.id : tag.tagValue,
|
id: tag.id,
|
||||||
name: tag.tagValue,
|
name: tag.tagValue,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
tagsToValues.set(tag.tagName, [
|
tagsToValues.set(tag.tagName, [
|
||||||
{
|
{
|
||||||
id: ID_TAGS.has(tag.tagName) ? tag.id : tag.tagValue,
|
id: tag.id,
|
||||||
name: tag.tagValue,
|
name: tag.tagValue,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -289,6 +289,14 @@ export const contract = c.router({
|
|||||||
200: ssType._response.removeFavorite,
|
200: ssType._response.removeFavorite,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
reportPlayback: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'reportPlayback.view',
|
||||||
|
query: ssType._parameters.reportPlayback,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.reportPlayback,
|
||||||
|
},
|
||||||
|
},
|
||||||
savePlayQueue: {
|
savePlayQueue: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'savePlayQueue.view',
|
path: 'savePlayQueue.view',
|
||||||
|
|||||||
@@ -1460,6 +1460,10 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
features.serverPlayQueue = [1];
|
features.serverPlayQueue = [1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subsonicFeatures[SubsonicExtensions.PLAYBACK_REPORT]) {
|
||||||
|
features.reportPlayback = [1];
|
||||||
|
}
|
||||||
|
|
||||||
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
|
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
|
||||||
},
|
},
|
||||||
getSimilarSongs: async (args) => {
|
getSimilarSongs: async (args) => {
|
||||||
@@ -2298,6 +2302,57 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
scrobble: async (args) => {
|
scrobble: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
if (hasFeature(apiClientProps.server, ServerFeature.REPORT_PLAYBACK)) {
|
||||||
|
if (query.submission) {
|
||||||
|
const res = await ssApiClient(apiClientProps).scrobble({
|
||||||
|
query: {
|
||||||
|
id: query.id,
|
||||||
|
submission: query.submission,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to scrobble');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: 'paused' | 'playing' | 'starting' | 'stopped' = 'playing';
|
||||||
|
|
||||||
|
switch (query.event) {
|
||||||
|
case 'pause':
|
||||||
|
state = 'paused';
|
||||||
|
break;
|
||||||
|
case 'start':
|
||||||
|
state = 'starting';
|
||||||
|
break;
|
||||||
|
case 'timeupdate':
|
||||||
|
case 'unpause':
|
||||||
|
state = 'playing';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
state = 'playing';
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await ssApiClient(apiClientProps).reportPlayback({
|
||||||
|
query: {
|
||||||
|
ignoreScrobble: true,
|
||||||
|
mediaId: query.id,
|
||||||
|
mediaType: query.mediaType,
|
||||||
|
playbackRate: query.playbackRate,
|
||||||
|
positionMs: query.position ?? 0,
|
||||||
|
state,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to report playback');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await ssApiClient(apiClientProps).scrobble({
|
const res = await ssApiClient(apiClientProps).scrobble({
|
||||||
query: {
|
query: {
|
||||||
id: query.id,
|
id: query.id,
|
||||||
|
|||||||
@@ -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 { RatingColumn } from './rating-column';
|
||||||
import { ReleaseDateColumn } from './release-date-column';
|
import { ReleaseDateColumn } from './release-date-column';
|
||||||
import { RowIndexColumn } from './row-index-column';
|
import { RowIndexColumn } from './row-index-column';
|
||||||
|
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
|
||||||
import { SampleRateColumn } from './sample-rate-column';
|
import { SampleRateColumn } from './sample-rate-column';
|
||||||
import { SizeColumn } from './size-column';
|
import { SizeColumn } from './size-column';
|
||||||
import { TitleArtistColumn } from './title-artist-column';
|
import { TitleArtistColumn } from './title-artist-column';
|
||||||
@@ -111,6 +112,7 @@ export {
|
|||||||
GenreBadgeColumn,
|
GenreBadgeColumn,
|
||||||
GenreColumn,
|
GenreColumn,
|
||||||
ImageColumn,
|
ImageColumn,
|
||||||
|
ItemDetailRowPlayControlCell,
|
||||||
LastPlayedColumn,
|
LastPlayedColumn,
|
||||||
PathColumn,
|
PathColumn,
|
||||||
PlayCountColumn,
|
PlayCountColumn,
|
||||||
|
|||||||
@@ -1,23 +1,40 @@
|
|||||||
import styles from './row-index-column.module.css';
|
import styles from './row-index-column.module.css';
|
||||||
|
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
|
||||||
import { ItemDetailListCellProps } from './types';
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
import { useDetailRowPlayControl } from './use-detail-row-play-control';
|
||||||
|
|
||||||
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
|
||||||
import { usePlayerStatus } from '/@/renderer/store';
|
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { PlayerStatus } from '/@/shared/types/types';
|
import { TableColumn } from '/@/shared/types/types';
|
||||||
|
|
||||||
export const RowIndexColumn = ({ rowIndex, song }: ItemDetailListCellProps) => {
|
|
||||||
const status = usePlayerStatus();
|
|
||||||
const { isActive } = useIsCurrentSong(song);
|
|
||||||
const isPlaying = isActive && status === PlayerStatus.PLAYING;
|
|
||||||
|
|
||||||
if (isActive) {
|
|
||||||
return (
|
|
||||||
<div className={styles.iconWrapper}>
|
|
||||||
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const DefaultRowIndexColumn = ({ rowIndex }: ItemDetailListCellProps) => {
|
||||||
return <>{String((rowIndex ?? 0) + 1)}</>;
|
return <>{String((rowIndex ?? 0) + 1)}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PlayableRowIndexColumn = (props: ItemDetailListCellProps) => {
|
||||||
|
const { handlePlay, isActive, isPlaying, showPlayControls } = useDetailRowPlayControl(props);
|
||||||
|
|
||||||
|
const indexContent = isActive ? (
|
||||||
|
<div className={styles.iconWrapper}>
|
||||||
|
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
String((props.rowIndex ?? 0) + 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemDetailRowPlayControlCell
|
||||||
|
indexContent={indexContent}
|
||||||
|
onPlay={handlePlay}
|
||||||
|
showPlayControls={showPlayControls}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RowIndexColumn = (props: ItemDetailListCellProps) => {
|
||||||
|
if (!props.columns || !isRowPlayControlColumn(TableColumn.ROW_INDEX, props.columns)) {
|
||||||
|
return <DefaultRowIndexColumn {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PlayableRowIndexColumn {...props} />;
|
||||||
|
};
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
.cell-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-target {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import styles from './row-play-control-cell.module.css';
|
||||||
|
|
||||||
|
import { ItemRowPlayControls } from '/@/renderer/features/shared/components/item-row-play-controls';
|
||||||
|
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
|
||||||
|
import { Play } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
export const ItemDetailRowPlayControlCell = ({
|
||||||
|
indexContent,
|
||||||
|
onPlay,
|
||||||
|
showPlayControls,
|
||||||
|
}: {
|
||||||
|
indexContent: ReactNode;
|
||||||
|
onPlay: (playType: Play) => void;
|
||||||
|
showPlayControls: boolean;
|
||||||
|
}) => {
|
||||||
|
if (!showPlayControls) {
|
||||||
|
return <>{indexContent}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.cellWrapper}>
|
||||||
|
<HoverCard
|
||||||
|
offset={{ crossAxis: 32, mainAxis: 16 }}
|
||||||
|
openDelay={300}
|
||||||
|
position="top"
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<HoverCard.Target>
|
||||||
|
<div className={styles.playTarget}>{indexContent}</div>
|
||||||
|
</HoverCard.Target>
|
||||||
|
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
|
||||||
|
<ItemRowPlayControls onPlay={onPlay} />
|
||||||
|
</HoverCard.Dropdown>
|
||||||
|
</HoverCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,46 @@
|
|||||||
|
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
|
||||||
|
import styles from './row-play-control-cell.module.css';
|
||||||
import { ItemDetailListCellProps } from './types';
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
import { useDetailRowPlayControl } from './use-detail-row-play-control';
|
||||||
|
|
||||||
export const TrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
|
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
|
||||||
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
|
import { TableColumn } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
const formatTrackNumber = (song: ItemDetailListCellProps['song']) => {
|
||||||
const disc = song.discNumber ?? 1;
|
const disc = song.discNumber ?? 1;
|
||||||
const track = song.trackNumber.toString().padStart(2, '0');
|
const track = song.trackNumber.toString().padStart(2, '0');
|
||||||
return `${disc}-${track}`;
|
return `${disc}-${track}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DefaultTrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
|
||||||
|
return <>{formatTrackNumber(song)}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PlayableTrackNumberColumn = (props: ItemDetailListCellProps) => {
|
||||||
|
const { handlePlay, isActive, isPlaying, showPlayControls } = useDetailRowPlayControl(props);
|
||||||
|
|
||||||
|
const indexContent = isActive ? (
|
||||||
|
<div className={styles.iconWrapper}>
|
||||||
|
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
formatTrackNumber(props.song)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemDetailRowPlayControlCell
|
||||||
|
indexContent={indexContent}
|
||||||
|
onPlay={handlePlay}
|
||||||
|
showPlayControls={showPlayControls}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TrackNumberColumn = (props: ItemDetailListCellProps) => {
|
||||||
|
if (!props.columns || !isRowPlayControlColumn(TableColumn.TRACK_NUMBER, props.columns)) {
|
||||||
|
return <DefaultTrackNumberColumn {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PlayableTrackNumberColumn {...props} />;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
|
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||||
import { ItemControls } from '/@/renderer/components/item-list/types';
|
import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
|
||||||
import { Song } from '/@/shared/types/domain-types';
|
import { Song } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
export interface ItemDetailListCellProps {
|
export interface ItemDetailListCellProps {
|
||||||
|
columns?: ItemTableListColumnConfig[];
|
||||||
controls?: ItemControls;
|
controls?: ItemControls;
|
||||||
internalState?: ItemListStateActions;
|
internalState?: ItemListStateActions;
|
||||||
isMutatingFavorite?: boolean;
|
isMutatingFavorite?: boolean;
|
||||||
|
|||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
|
||||||
|
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
|
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
||||||
|
import { usePlayerStatus } from '/@/renderer/store';
|
||||||
|
import { Song } from '/@/shared/types/domain-types';
|
||||||
|
import { Play, PlayerStatus } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
export const useDetailRowPlayControl = ({
|
||||||
|
internalState,
|
||||||
|
rowIndex = 0,
|
||||||
|
song,
|
||||||
|
}: Pick<ItemDetailListCellProps, 'internalState' | 'rowIndex' | 'song'>) => {
|
||||||
|
const status = usePlayerStatus();
|
||||||
|
const player = usePlayer();
|
||||||
|
const { isActive } = useIsCurrentSong(song);
|
||||||
|
const isPlaying = isActive && status === PlayerStatus.PLAYING;
|
||||||
|
|
||||||
|
const showPlayControls = !!song?.id;
|
||||||
|
|
||||||
|
const handlePlay = useCallback(
|
||||||
|
(playType: Play) => {
|
||||||
|
if (!song) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playSongFromItemListControl({
|
||||||
|
index: rowIndex,
|
||||||
|
internalState,
|
||||||
|
item: song as Song,
|
||||||
|
meta: { playType, singleSongOnly: true },
|
||||||
|
player,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[internalState, player, rowIndex, song],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handlePlay,
|
||||||
|
isActive,
|
||||||
|
isPlaying,
|
||||||
|
showPlayControls,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -447,6 +447,14 @@
|
|||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row .track-cell-play-control {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.track-row-dragging {
|
.track-row-dragging {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import styles from './item-detail-list.module.css';
|
|||||||
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
|
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
|
||||||
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
||||||
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
|
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
|
||||||
|
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
|
||||||
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
|
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
|
||||||
import {
|
import {
|
||||||
ItemListStateActions,
|
ItemListStateActions,
|
||||||
@@ -365,6 +366,7 @@ const TrackRow = memo(
|
|||||||
const isTitleColumn = col.id === TableColumn.TITLE;
|
const isTitleColumn = col.id === TableColumn.TITLE;
|
||||||
const isImageColumn = col.id === TableColumn.IMAGE;
|
const isImageColumn = col.id === TableColumn.IMAGE;
|
||||||
const isIconActionColumn = isNoHorizontalPaddingColumn(col.id);
|
const isIconActionColumn = isNoHorizontalPaddingColumn(col.id);
|
||||||
|
const isPlayControlColumn = isRowPlayControlColumn(col.id, columns);
|
||||||
const showHoverContent = shouldShowHoverOnlyColumnContent(
|
const showHoverContent = shouldShowHoverOnlyColumnContent(
|
||||||
col.id,
|
col.id,
|
||||||
isRowHovered,
|
isRowHovered,
|
||||||
@@ -374,6 +376,7 @@ const TrackRow = memo(
|
|||||||
const content = isSongsLoading ? null : showHoverContent ? (
|
const content = isSongsLoading ? null : showHoverContent ? (
|
||||||
<CellComponent
|
<CellComponent
|
||||||
columnId={col.id}
|
columnId={col.id}
|
||||||
|
columns={columns}
|
||||||
controls={controls}
|
controls={controls}
|
||||||
internalState={internalState}
|
internalState={internalState}
|
||||||
isMutatingFavorite={isMutatingFavorite}
|
isMutatingFavorite={isMutatingFavorite}
|
||||||
@@ -393,6 +396,7 @@ const TrackRow = memo(
|
|||||||
[styles.trackCellImage]: isImageColumn,
|
[styles.trackCellImage]: isImageColumn,
|
||||||
[styles.trackCellMuted]: !isTitleColumn,
|
[styles.trackCellMuted]: !isTitleColumn,
|
||||||
[styles.trackCellNoHPadding]: isIconActionColumn,
|
[styles.trackCellNoHPadding]: isIconActionColumn,
|
||||||
|
[styles.trackCellPlayControl]: isPlayControlColumn,
|
||||||
[styles.trackCellVerticalBorderVisible]:
|
[styles.trackCellVerticalBorderVisible]:
|
||||||
enableVerticalBorders && !isLastColumn,
|
enableVerticalBorders && !isLastColumn,
|
||||||
[styles.trackCellWithVerticalBorder]: !isLastColumn,
|
[styles.trackCellWithVerticalBorder]: !isLastColumn,
|
||||||
|
|||||||
@@ -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 {
|
.expand {
|
||||||
position: absolute;
|
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 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 {
|
import {
|
||||||
ItemTableListInnerColumn,
|
ItemTableListInnerColumn,
|
||||||
TableColumnContainer,
|
TableColumnContainer,
|
||||||
TableColumnTextContainer,
|
TableColumnTextContainer,
|
||||||
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
} 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 { ItemListItem } from '/@/renderer/components/item-list/types';
|
||||||
import { usePlayerStatus } from '/@/renderer/store';
|
|
||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Flex } from '/@/shared/components/flex/flex';
|
import { Flex } from '/@/shared/components/flex/flex';
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
import { PlayerStatus } from '/@/shared/types/types';
|
import { TableColumn } from '/@/shared/types/types';
|
||||||
|
|
||||||
const RowIndexColumnBase = (props: ItemTableListInnerColumn) => {
|
const RowIndexColumnBase = (props: ItemTableListInnerColumn) => {
|
||||||
const { itemType } = props;
|
const { itemType } = props;
|
||||||
|
|
||||||
|
if (!isRowPlayControlColumn(TableColumn.ROW_INDEX, props.columns)) {
|
||||||
|
return <DefaultRowIndexColumn {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
switch (itemType) {
|
switch (itemType) {
|
||||||
|
case LibraryItem.ALBUM:
|
||||||
|
case LibraryItem.ALBUM_ARTIST:
|
||||||
|
case LibraryItem.ARTIST:
|
||||||
case LibraryItem.FOLDER:
|
case LibraryItem.FOLDER:
|
||||||
case LibraryItem.PLAYLIST_SONG:
|
case LibraryItem.PLAYLIST_SONG:
|
||||||
case LibraryItem.QUEUE_SONG:
|
case LibraryItem.QUEUE_SONG:
|
||||||
case LibraryItem.SONG:
|
case LibraryItem.SONG:
|
||||||
return <QueueSongRowIndexColumn {...props} />;
|
return <PlayableRowIndexColumn {...props} />;
|
||||||
default:
|
default:
|
||||||
return <DefaultRowIndexColumn {...props} />;
|
return <DefaultRowIndexColumn {...props} />;
|
||||||
}
|
}
|
||||||
@@ -88,12 +96,8 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
|
|||||||
return <TableColumnTextContainer {...props}>{adjustedRowIndex}</TableColumnTextContainer>;
|
return <TableColumnTextContainer {...props}>{adjustedRowIndex}</TableColumnTextContainer>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => {
|
const PlayableRowIndexColumn = (props: ItemTableListInnerColumn) => {
|
||||||
const status = usePlayerStatus();
|
const { handlePlay, isActive, isPlaying, showPlayControls } = useRowPlayControl(props);
|
||||||
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;
|
|
||||||
|
|
||||||
let adjustedRowIndex =
|
let adjustedRowIndex =
|
||||||
props.getAdjustedRowIndex?.(props.rowIndex) ??
|
props.getAdjustedRowIndex?.(props.rowIndex) ??
|
||||||
@@ -104,38 +108,20 @@ const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => {
|
|||||||
adjustedRowIndex = props.startRowIndex + adjustedRowIndex;
|
adjustedRowIndex = props.startRowIndex + adjustedRowIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const indexContent = isActive ? (
|
||||||
|
<Flex className={styles.indexContent}>
|
||||||
|
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
adjustedRowIndex
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InnerQueueSongRowIndexColumn
|
<RowPlayControlCell
|
||||||
{...props}
|
{...props}
|
||||||
adjustedRowIndex={adjustedRowIndex}
|
indexContent={indexContent}
|
||||||
isActive={isActive}
|
onPlay={handlePlay}
|
||||||
isPlaying={isActiveAndPlaying}
|
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 { 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 { 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 { 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 { 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 { 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';
|
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 { 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 { 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 { 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 { 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 { 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';
|
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.DISC_NUMBER:
|
||||||
case TableColumn.SAMPLE_RATE:
|
case TableColumn.SAMPLE_RATE:
|
||||||
case TableColumn.TRACK_NUMBER:
|
case TableColumn.TRACK_NUMBER:
|
||||||
return <NumericColumn {...props} {...dragProps} controls={controls} type={type} />;
|
return (
|
||||||
|
<TrackNumberColumn {...props} {...dragProps} controls={controls} type={type} />
|
||||||
|
);
|
||||||
|
|
||||||
case TableColumn.COMPOSER:
|
case TableColumn.COMPOSER:
|
||||||
return <ComposerColumn {...props} {...dragProps} controls={controls} type={type} />;
|
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 { useGridCarouselContainerQuery } from '/@/renderer/components/grid-carousel/grid-carousel-v2';
|
||||||
import { useItemListStateSubscription } from '/@/renderer/components/item-list/helpers/item-list-state';
|
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 { 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 { 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';
|
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||||
@@ -60,7 +61,7 @@ import {
|
|||||||
SongListSort,
|
SongListSort,
|
||||||
SortOrder,
|
SortOrder,
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';
|
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||||
|
|
||||||
const MetadataPillGroup = ({
|
const MetadataPillGroup = ({
|
||||||
items,
|
items,
|
||||||
@@ -830,13 +831,13 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playType = (meta?.playType as Play) || Play.NOW;
|
playSongFromItemListControl({
|
||||||
|
index,
|
||||||
const items = internalState?.getData() as Song[];
|
internalState,
|
||||||
|
item: item as Song,
|
||||||
if (index !== undefined) {
|
meta,
|
||||||
player.addToQueueByData(items, playType, item.id);
|
player,
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [player]);
|
}, [player]);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import styles from './album-artist-detail-content.module.css';
|
|||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
import { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card';
|
import { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card';
|
||||||
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
|
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 { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';
|
||||||
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
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 { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
||||||
@@ -365,12 +366,13 @@ const AlbumArtistMetadataTopSongsContent = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playType = (meta?.playType as Play) || Play.NOW;
|
playSongFromItemListControl({
|
||||||
const items = internalState?.getData() as Song[];
|
index,
|
||||||
|
internalState,
|
||||||
if (index !== undefined) {
|
item: item as Song,
|
||||||
player.addToQueueByData(items, playType, item.id);
|
meta,
|
||||||
}
|
player,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [player]);
|
}, [player]);
|
||||||
@@ -657,12 +659,13 @@ const AlbumArtistMetadataFavoriteSongs = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playType = (meta?.playType as Play) || Play.NOW;
|
playSongFromItemListControl({
|
||||||
const items = internalState?.getData() as Song[];
|
index,
|
||||||
|
internalState,
|
||||||
if (index !== undefined) {
|
item: item as Song,
|
||||||
player.addToQueueByData(items, playType, item.id);
|
meta,
|
||||||
}
|
player,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [player]);
|
}, [player]);
|
||||||
|
|||||||
+9
-7
@@ -2,6 +2,7 @@ import { useSuspenseQueries } from '@tanstack/react-query';
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useParams } from 'react-router';
|
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 { 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 { 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';
|
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 { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||||
import { sortSongList } from '/@/shared/api/utils';
|
import { sortSongList } from '/@/shared/api/utils';
|
||||||
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey, Play } from '/@/shared/types/types';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
const AlbumArtistDetailFavoriteSongsListRoute = () => {
|
const AlbumArtistDetailFavoriteSongsListRoute = () => {
|
||||||
const { albumArtistId, artistId } = useParams() as {
|
const { albumArtistId, artistId } = useParams() as {
|
||||||
@@ -96,12 +97,13 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playType = (meta?.playType as Play) || Play.NOW;
|
playSongFromItemListControl({
|
||||||
const items = internalState?.getData() as Song[];
|
index,
|
||||||
|
internalState,
|
||||||
if (index !== undefined) {
|
item: item as Song,
|
||||||
player.addToQueueByData(items, playType, item.id);
|
meta,
|
||||||
}
|
player,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [player]);
|
}, [player]);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useSuspenseQuery } from '@tanstack/react-query';
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useParams } from 'react-router';
|
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 { 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 { 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';
|
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 { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||||
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||||
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey, Play } from '/@/shared/types/types';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
const AlbumArtistDetailTopSongsListRoute = () => {
|
const AlbumArtistDetailTopSongsListRoute = () => {
|
||||||
const { albumArtistId, artistId } = useParams() as {
|
const { albumArtistId, artistId } = useParams() as {
|
||||||
@@ -78,12 +79,13 @@ const AlbumArtistDetailTopSongsListRoute = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playType = (meta?.playType as Play) || Play.NOW;
|
playSongFromItemListControl({
|
||||||
const items = internalState?.getData() as Song[];
|
index,
|
||||||
|
internalState,
|
||||||
if (index !== undefined) {
|
item: item as Song,
|
||||||
player.addToQueueByData(items, playType, item.id);
|
meta,
|
||||||
}
|
player,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [player]);
|
}, [player]);
|
||||||
|
|||||||
@@ -12,10 +12,15 @@ import { Play } from '/@/shared/types/types';
|
|||||||
|
|
||||||
interface PlayTrackRadioActionProps {
|
interface PlayTrackRadioActionProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
skipFirstSong?: boolean;
|
||||||
song: Song;
|
song: Song;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionProps) => {
|
export const PlayTrackRadioAction = ({
|
||||||
|
disabled,
|
||||||
|
skipFirstSong,
|
||||||
|
song,
|
||||||
|
}: PlayTrackRadioActionProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
@@ -38,13 +43,17 @@ export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionPro
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (similarSongs && similarSongs.length > 0) {
|
if (similarSongs && similarSongs.length > 0) {
|
||||||
player.addToQueueByData([song, ...similarSongs], playType);
|
// We need to skip the first song when adding to the queue as NEXT or LAST, otherwise you will have a duplicate song
|
||||||
|
const shouldSkipFirstSong =
|
||||||
|
skipFirstSong && (playType === Play.NEXT || playType === Play.LAST);
|
||||||
|
const queueSongs = shouldSkipFirstSong ? similarSongs : [song, ...similarSongs];
|
||||||
|
player.addToQueueByData(queueSongs, playType);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load track radio:', error);
|
console.error('Failed to load track radio:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[player, queryClient, serverId, song],
|
[player, queryClient, serverId, skipFirstSong, song],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePlayTrackRadioNow = useCallback(() => {
|
const handlePlayTrackRadioNow = useCallback(() => {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const QueueContextMenu = ({ items }: QueueContextMenuProps) => {
|
|||||||
<MoveQueueItemsAction items={items} />
|
<MoveQueueItemsAction items={items} />
|
||||||
<ShuffleItemsAction items={items} />
|
<ShuffleItemsAction items={items} />
|
||||||
<ContextMenu.Divider />
|
<ContextMenu.Divider />
|
||||||
<PlayTrackRadioAction disabled={items.length > 1} song={items[0]} />
|
<PlayTrackRadioAction disabled={items.length > 1} skipFirstSong song={items[0]} />
|
||||||
<ContextMenu.Divider />
|
<ContextMenu.Divider />
|
||||||
<AddToPlaylistAction items={ids} itemType={LibraryItem.SONG} />
|
<AddToPlaylistAction items={ids} itemType={LibraryItem.SONG} />
|
||||||
<ContextMenu.Divider />
|
<ContextMenu.Divider />
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface WebPlayerEngineHandle extends AudioPlayer {
|
|||||||
interface WebPlayerEngineProps {
|
interface WebPlayerEngineProps {
|
||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
isTransitioning: boolean;
|
isTransitioning: boolean;
|
||||||
|
loopPlayer1: boolean;
|
||||||
|
loopPlayer2: boolean;
|
||||||
onEndedPlayer1: () => void;
|
onEndedPlayer1: () => void;
|
||||||
onEndedPlayer2: () => void;
|
onEndedPlayer2: () => void;
|
||||||
onErrorPause: () => void;
|
onErrorPause: () => void;
|
||||||
@@ -55,6 +57,8 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
|||||||
const {
|
const {
|
||||||
isMuted,
|
isMuted,
|
||||||
isTransitioning,
|
isTransitioning,
|
||||||
|
loopPlayer1,
|
||||||
|
loopPlayer2,
|
||||||
onEndedPlayer1,
|
onEndedPlayer1,
|
||||||
onEndedPlayer2,
|
onEndedPlayer2,
|
||||||
onErrorPause,
|
onErrorPause,
|
||||||
@@ -292,8 +296,9 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
|||||||
controls={false}
|
controls={false}
|
||||||
height={0}
|
height={0}
|
||||||
id="web-player-1"
|
id="web-player-1"
|
||||||
|
loop={loopPlayer1}
|
||||||
muted={isMuted}
|
muted={isMuted}
|
||||||
onEnded={src1 ? () => onEndedPlayer1() : undefined}
|
onEnded={src1 && !loopPlayer1 ? () => onEndedPlayer1() : undefined}
|
||||||
onError={handleOnError(
|
onError={handleOnError(
|
||||||
player1Ref,
|
player1Ref,
|
||||||
() => onEndedPlayer1(),
|
() => onEndedPlayer1(),
|
||||||
@@ -317,8 +322,9 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
|||||||
controls={false}
|
controls={false}
|
||||||
height={0}
|
height={0}
|
||||||
id="web-player-2"
|
id="web-player-2"
|
||||||
|
loop={loopPlayer2}
|
||||||
muted={isMuted}
|
muted={isMuted}
|
||||||
onEnded={src2 ? () => onEndedPlayer2() : undefined}
|
onEnded={src2 && !loopPlayer2 ? () => onEndedPlayer2() : undefined}
|
||||||
onError={handleOnError(
|
onError={handleOnError(
|
||||||
player2Ref,
|
player2Ref,
|
||||||
() => onEndedPlayer2(),
|
() => onEndedPlayer2(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type ReactPlayer from 'react-player';
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||||
import {
|
import {
|
||||||
WebPlayerEngine,
|
WebPlayerEngine,
|
||||||
WebPlayerEngineHandle,
|
WebPlayerEngineHandle,
|
||||||
@@ -20,12 +21,13 @@ import {
|
|||||||
usePlayerData,
|
usePlayerData,
|
||||||
usePlayerMuted,
|
usePlayerMuted,
|
||||||
usePlayerProperties,
|
usePlayerProperties,
|
||||||
|
usePlayerRepeat,
|
||||||
usePlayerStoreBase,
|
usePlayerStoreBase,
|
||||||
usePlayerVolume,
|
usePlayerVolume,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
import { QueueSong } from '/@/shared/types/domain-types';
|
import { QueueSong } from '/@/shared/types/domain-types';
|
||||||
import { CrossfadeStyle, PlayerStatus, PlayerStyle } from '/@/shared/types/types';
|
import { CrossfadeStyle, PlayerRepeat, PlayerStatus, PlayerStyle } from '/@/shared/types/types';
|
||||||
|
|
||||||
const PLAY_PAUSE_FADE_DURATION = 300;
|
const PLAY_PAUSE_FADE_DURATION = 300;
|
||||||
const PLAY_PAUSE_FADE_INTERVAL = 10;
|
const PLAY_PAUSE_FADE_INTERVAL = 10;
|
||||||
@@ -34,6 +36,8 @@ export function WebPlayer() {
|
|||||||
const playerRef = useRef<null | WebPlayerEngineHandle>(null);
|
const playerRef = useRef<null | WebPlayerEngineHandle>(null);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { num, player1, player2, status } = usePlayerData();
|
const { num, player1, player2, status } = usePlayerData();
|
||||||
|
const repeat = usePlayerRepeat();
|
||||||
|
const repeatOneProgressRef = useRef({ player1: 0, player2: 0 });
|
||||||
const { mediaAutoNext, mediaPause, setTimestamp } = usePlayerActions();
|
const { mediaAutoNext, mediaPause, setTimestamp } = usePlayerActions();
|
||||||
const playback = useMpvSettings();
|
const playback = useMpvSettings();
|
||||||
const { webAudio } = useWebAudio();
|
const { webAudio } = useWebAudio();
|
||||||
@@ -97,12 +101,37 @@ export function WebPlayer() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleRepeatOne = useCallback(
|
||||||
|
(playerId: 1 | 2, playedSeconds: number, duration: number) => {
|
||||||
|
if (repeat !== PlayerRepeat.ONE || duration <= 0 || num !== playerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = playerId === 1 ? 'player1' : 'player2';
|
||||||
|
const last = repeatOneProgressRef.current[key];
|
||||||
|
repeatOneProgressRef.current[key] = playedSeconds;
|
||||||
|
|
||||||
|
if (last > duration * 0.85 && playedSeconds < duration * 0.15) {
|
||||||
|
setTimestamp(0);
|
||||||
|
eventEmitter.emit('PLAYER_REPEATED', {
|
||||||
|
index: usePlayerStoreBase.getState().player.index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[num, repeat, setTimestamp],
|
||||||
|
);
|
||||||
|
|
||||||
const onProgressPlayer1 = useCallback(
|
const onProgressPlayer1 = useCallback(
|
||||||
(e: PlayerOnProgressProps) => {
|
(e: PlayerOnProgressProps) => {
|
||||||
if (!playerRef.current?.player1()) {
|
if (!playerRef.current?.player1()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (repeat === PlayerRepeat.ONE) {
|
||||||
|
handleRepeatOne(1, e.playedSeconds, getDuration(playerRef.current.player1().ref));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (transitionType) {
|
switch (transitionType) {
|
||||||
case PlayerStyle.CROSSFADE:
|
case PlayerStyle.CROSSFADE:
|
||||||
crossfadeHandler({
|
crossfadeHandler({
|
||||||
@@ -132,7 +161,17 @@ export function WebPlayer() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[crossfadeDuration, crossfadeStyle, isTransitioning, num, player2, transitionType, volume],
|
[
|
||||||
|
crossfadeDuration,
|
||||||
|
crossfadeStyle,
|
||||||
|
handleRepeatOne,
|
||||||
|
isTransitioning,
|
||||||
|
num,
|
||||||
|
player2,
|
||||||
|
repeat,
|
||||||
|
transitionType,
|
||||||
|
volume,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onProgressPlayer2 = useCallback(
|
const onProgressPlayer2 = useCallback(
|
||||||
@@ -141,6 +180,11 @@ export function WebPlayer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (repeat === PlayerRepeat.ONE) {
|
||||||
|
handleRepeatOne(2, e.playedSeconds, getDuration(playerRef.current.player2().ref));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (transitionType) {
|
switch (transitionType) {
|
||||||
case PlayerStyle.CROSSFADE:
|
case PlayerStyle.CROSSFADE:
|
||||||
crossfadeHandler({
|
crossfadeHandler({
|
||||||
@@ -170,7 +214,17 @@ export function WebPlayer() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[crossfadeDuration, crossfadeStyle, isTransitioning, num, player1, transitionType, volume],
|
[
|
||||||
|
crossfadeDuration,
|
||||||
|
crossfadeStyle,
|
||||||
|
handleRepeatOne,
|
||||||
|
isTransitioning,
|
||||||
|
num,
|
||||||
|
player1,
|
||||||
|
repeat,
|
||||||
|
transitionType,
|
||||||
|
volume,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOnEndedPlayer1 = useCallback(() => {
|
const handleOnEndedPlayer1 = useCallback(() => {
|
||||||
@@ -474,10 +528,15 @@ export function WebPlayer() {
|
|||||||
});
|
});
|
||||||
}, [mediaPause, t]);
|
}, [mediaPause, t]);
|
||||||
|
|
||||||
|
const loopPlayer1 = repeat === PlayerRepeat.ONE && num === 1;
|
||||||
|
const loopPlayer2 = repeat === PlayerRepeat.ONE && num === 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WebPlayerEngine
|
<WebPlayerEngine
|
||||||
isMuted={isMuted}
|
isMuted={isMuted}
|
||||||
isTransitioning={Boolean(isTransitioning)}
|
isTransitioning={Boolean(isTransitioning)}
|
||||||
|
loopPlayer1={loopPlayer1}
|
||||||
|
loopPlayer2={loopPlayer2}
|
||||||
onEndedPlayer1={handleOnEndedPlayer1}
|
onEndedPlayer1={handleOnEndedPlayer1}
|
||||||
onEndedPlayer2={handleOnEndedPlayer2}
|
onEndedPlayer2={handleOnEndedPlayer2}
|
||||||
onErrorPause={handleOnErrorPause}
|
onErrorPause={handleOnErrorPause}
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import { MediaSessionHook } from '/@/renderer/features/player/hooks/use-media-se
|
|||||||
import { MPRISHook } from '/@/renderer/features/player/hooks/use-mpris';
|
import { MPRISHook } from '/@/renderer/features/player/hooks/use-mpris';
|
||||||
import { PlaybackHotkeysHook } from '/@/renderer/features/player/hooks/use-playback-hotkeys';
|
import { PlaybackHotkeysHook } from '/@/renderer/features/player/hooks/use-playback-hotkeys';
|
||||||
import { PowerSaveBlockerHook } from '/@/renderer/features/player/hooks/use-power-save-blocker';
|
import { PowerSaveBlockerHook } from '/@/renderer/features/player/hooks/use-power-save-blocker';
|
||||||
import { QueueRestoreTimestampHook } from '/@/renderer/features/player/hooks/use-queue-restore';
|
import {
|
||||||
|
InitialTimestampRestoreHook,
|
||||||
|
QueueRestoreTimestampHook,
|
||||||
|
} from '/@/renderer/features/player/hooks/use-queue-restore';
|
||||||
import { ScrobbleHook } from '/@/renderer/features/player/hooks/use-scrobble';
|
import { ScrobbleHook } from '/@/renderer/features/player/hooks/use-scrobble';
|
||||||
import { UpdateCurrentSongHook } from '/@/renderer/features/player/hooks/use-update-current-song';
|
import { UpdateCurrentSongHook } from '/@/renderer/features/player/hooks/use-update-current-song';
|
||||||
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
|
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
|
||||||
@@ -134,6 +137,7 @@ export const AudioPlayers = () => {
|
|||||||
<RemoteHook />
|
<RemoteHook />
|
||||||
<AutoDJHook />
|
<AutoDJHook />
|
||||||
<QueueRestoreTimestampHook />
|
<QueueRestoreTimestampHook />
|
||||||
|
<InitialTimestampRestoreHook />
|
||||||
<UpdateCurrentSongHook />
|
<UpdateCurrentSongHook />
|
||||||
<RadioAudioInstanceHook />
|
<RadioAudioInstanceHook />
|
||||||
<RadioMetadataHook />
|
<RadioMetadataHook />
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.bar {
|
.bar {
|
||||||
background-color: var(--theme-colors-primary-filled);
|
background-color: var(--theme-colors-primary-filled);
|
||||||
@@ -44,48 +46,45 @@
|
|||||||
transition: opacity 0.2s ease-in-out;
|
transition: opacity 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track {
|
|
||||||
&::before {
|
|
||||||
right: calc(0.1rem * -1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-container {
|
.slider-container {
|
||||||
display: flex;
|
--slider-value-width: 4.75rem;
|
||||||
|
--slider-value-gap: var(--theme-spacing-sm);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--slider-value-width) minmax(0, 1fr) var(--slider-value-width);
|
||||||
|
gap: var(--slider-value-gap);
|
||||||
|
align-items: center;
|
||||||
width: 95%;
|
width: 95%;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-value-wrapper {
|
.slider-value-wrapper {
|
||||||
display: flex;
|
min-width: 0;
|
||||||
flex: 1;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
max-width: 50px;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
@media (width < 768px) {
|
@media (width < 768px) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-value-wrapper-elapsed {
|
@media (width < 768px) {
|
||||||
display: flex;
|
.slider-container {
|
||||||
flex: 1;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 4.75rem;
|
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
@media (width < 768px) {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-wrapper {
|
.slider-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 6;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const PlayerbarSlider = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.sliderContainer}>
|
<div className={styles.sliderContainer}>
|
||||||
<div className={styles.sliderValueWrapperElapsed}>
|
<div className={styles.sliderValueWrapper}>
|
||||||
<ScrobbleStatus formattedTime={formattedTime} />
|
<ScrobbleStatus formattedTime={formattedTime} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.sliderWrapper}>
|
<div className={styles.sliderWrapper}>
|
||||||
@@ -81,7 +81,6 @@ export const CustomPlayerbarSlider = ({ ...props }: SliderProps) => {
|
|||||||
label: styles.label,
|
label: styles.label,
|
||||||
root: styles.root,
|
root: styles.root,
|
||||||
thumb: styles.thumb,
|
thumb: styles.thumb,
|
||||||
track: styles.track,
|
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
size={6}
|
size={6}
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ const scrobbleProgressProps = {
|
|||||||
|
|
||||||
const clampPct = (n: number) => Math.min(100, Math.max(0, n));
|
const clampPct = (n: number) => Math.min(100, Math.max(0, n));
|
||||||
|
|
||||||
|
const capForDisplay = (value: number, limit: number) =>
|
||||||
|
limit > 0 ? Math.min(value, limit) : value;
|
||||||
|
|
||||||
|
const progressTowardLimit = (current: number, limit: number) =>
|
||||||
|
limit > 0 ? clampPct((current / limit) * 100) : 0;
|
||||||
|
|
||||||
const ScrobbleConditionProgress = ({ value }: { value: number }) => (
|
const ScrobbleConditionProgress = ({ value }: { value: number }) => (
|
||||||
<Progress {...scrobbleProgressProps} value={value} w="100%" />
|
<Progress {...scrobbleProgressProps} value={value} w="100%" />
|
||||||
);
|
);
|
||||||
@@ -34,18 +40,25 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
|
|||||||
|
|
||||||
const hookInactive = !scrobbleEnabled || privateMode;
|
const hookInactive = !scrobbleEnabled || privateMode;
|
||||||
|
|
||||||
const listenedSec = (snapshot.listenedMs / 1000).toFixed(1);
|
const listenedSecRaw = snapshot.listenedMs / 1000;
|
||||||
const listenPercentOfTrack =
|
const listenedSecDisplay = snapshot.submitted
|
||||||
snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0;
|
? snapshot.targetDurationSec
|
||||||
|
: capForDisplay(listenedSecRaw, snapshot.targetDurationSec);
|
||||||
|
|
||||||
const durationConditionPct =
|
const listenPercentOfTrackRaw =
|
||||||
snapshot.targetDurationSec > 0
|
snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0;
|
||||||
? clampPct((snapshot.listenedMs / 1000 / snapshot.targetDurationSec) * 100)
|
const listenPercentDisplay = snapshot.submitted
|
||||||
: 0;
|
? snapshot.targetPercentage
|
||||||
const percentConditionPct =
|
: capForDisplay(listenPercentOfTrackRaw, snapshot.targetPercentage);
|
||||||
snapshot.targetPercentage > 0 && snapshot.trackDurationMs > 0
|
|
||||||
? clampPct((listenPercentOfTrack / snapshot.targetPercentage) * 100)
|
const durationConditionPct = progressTowardLimit(
|
||||||
: 0;
|
listenedSecDisplay,
|
||||||
|
snapshot.targetDurationSec,
|
||||||
|
);
|
||||||
|
const percentConditionPct = progressTowardLimit(
|
||||||
|
listenPercentDisplay,
|
||||||
|
snapshot.targetPercentage,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverCard position="top" width={280}>
|
<HoverCard position="top" width={280}>
|
||||||
@@ -87,13 +100,13 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
|
|||||||
<>
|
<>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="xs">
|
<Text size="xs">
|
||||||
{`${listenedSec}s / ${snapshot.targetDurationSec}s`}
|
{`${listenedSecDisplay.toFixed(1)}s / ${snapshot.targetDurationSec}s`}
|
||||||
</Text>
|
</Text>
|
||||||
<ScrobbleConditionProgress value={durationConditionPct} />
|
<ScrobbleConditionProgress value={durationConditionPct} />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="xs">
|
<Text size="xs">
|
||||||
{`${listenPercentOfTrack.toFixed(1)}% / ${snapshot.targetPercentage}%`}
|
{`${listenPercentDisplay.toFixed(1)}% / ${snapshot.targetPercentage}%`}
|
||||||
</Text>
|
</Text>
|
||||||
<ScrobbleConditionProgress value={percentConditionPct} />
|
<ScrobbleConditionProgress value={percentConditionPct} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||||
@@ -9,13 +9,18 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
|||||||
import {
|
import {
|
||||||
setTimestamp,
|
setTimestamp,
|
||||||
useCurrentServerId,
|
useCurrentServerId,
|
||||||
|
usePlayerActions,
|
||||||
|
usePlayerHydrated,
|
||||||
|
usePlayerSong,
|
||||||
|
usePlayerStatus,
|
||||||
usePlayerStore,
|
usePlayerStore,
|
||||||
useTimestampStoreBase,
|
useTimestampStoreBase,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
|
import { PlayerStatus } from '/@/shared/types/types';
|
||||||
|
|
||||||
export const useQueueRestoreTimestamp = () => {
|
export const useQueueRestoreTimestamp = () => {
|
||||||
const player = usePlayerStore();
|
const { mediaSeekToTimestamp } = usePlayerActions();
|
||||||
|
|
||||||
usePlayerEvents(
|
usePlayerEvents(
|
||||||
{
|
{
|
||||||
@@ -24,7 +29,7 @@ export const useQueueRestoreTimestamp = () => {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setTimestamp(position);
|
setTimestamp(position);
|
||||||
player.mediaSeekToTimestamp(position);
|
mediaSeekToTimestamp(position);
|
||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -37,6 +42,72 @@ export const QueueRestoreTimestampHook = () => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useInitialTimestampRestore = () => {
|
||||||
|
const { mediaSeekToTimestamp } = usePlayerActions();
|
||||||
|
const playerHydrated = usePlayerHydrated();
|
||||||
|
const currentSong = usePlayerSong();
|
||||||
|
const playerStatus = usePlayerStatus();
|
||||||
|
const timestamp = useTimestampStoreBase((state) => state.timestamp);
|
||||||
|
|
||||||
|
const startupRestoreInitializedRef = useRef(false);
|
||||||
|
const startupSeekArmedRef = useRef<null | number>(null);
|
||||||
|
const startupSeekAppliedRef = useRef(false);
|
||||||
|
|
||||||
|
const applyStartupSeek = useCallback(() => {
|
||||||
|
if (startupSeekAppliedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seekTimestamp = startupSeekArmedRef.current;
|
||||||
|
if (!seekTimestamp || seekTimestamp <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startupSeekAppliedRef.current = true;
|
||||||
|
startupSeekArmedRef.current = null;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
mediaSeekToTimestamp(seekTimestamp);
|
||||||
|
}, 100);
|
||||||
|
}, [mediaSeekToTimestamp]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (startupRestoreInitializedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playerHydrated || !currentSong) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startupRestoreInitializedRef.current = true;
|
||||||
|
|
||||||
|
if (timestamp > 0) {
|
||||||
|
startupSeekArmedRef.current = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerStatus === PlayerStatus.PLAYING) {
|
||||||
|
applyStartupSeek();
|
||||||
|
}
|
||||||
|
}, [applyStartupSeek, currentSong, playerHydrated, playerStatus, timestamp]);
|
||||||
|
|
||||||
|
usePlayerEvents(
|
||||||
|
{
|
||||||
|
onPlayerStatus: (properties) => {
|
||||||
|
if (properties.status === PlayerStatus.PLAYING) {
|
||||||
|
applyStartupSeek();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[applyStartupSeek],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InitialTimestampRestoreHook = () => {
|
||||||
|
useInitialTimestampRestore();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export const useSaveQueue = () => {
|
export const useSaveQueue = () => {
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,21 @@ import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
|||||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||||
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
|
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
|
||||||
import {
|
import {
|
||||||
|
getServerById,
|
||||||
publishScrobbleDebug,
|
publishScrobbleDebug,
|
||||||
useAppStore,
|
useAppStore,
|
||||||
usePlaybackSettings,
|
usePlaybackSettings,
|
||||||
usePlayerSong,
|
usePlayerSong,
|
||||||
|
usePlayerSpeed,
|
||||||
usePlayerStore,
|
usePlayerStore,
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useTimestampStoreBase,
|
useTimestampStoreBase,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
import { LogCategory, logFn } from '/@/renderer/utils/logger';
|
import { LogCategory, logFn } from '/@/renderer/utils/logger';
|
||||||
import { logMsg } from '/@/renderer/utils/logger-message';
|
import { logMsg } from '/@/renderer/utils/logger-message';
|
||||||
|
import { hasFeature } from '/@/shared/api/utils';
|
||||||
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||||
|
import { ServerFeature } from '/@/shared/types/features-types';
|
||||||
import { PlayerStatus } from '/@/shared/types/types';
|
import { PlayerStatus } from '/@/shared/types/types';
|
||||||
|
|
||||||
type ScrobbleManualHandlers = {
|
type ScrobbleManualHandlers = {
|
||||||
@@ -36,6 +40,14 @@ export const invokeScrobbleResetListenedState = () => {
|
|||||||
scrobbleManualHandlers?.resetListenedState();
|
scrobbleManualHandlers?.resetListenedState();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getPositionValue = (seconds: number, useTicks: boolean) => {
|
||||||
|
if (useTicks) {
|
||||||
|
return Math.round(seconds * 1e7);
|
||||||
|
}
|
||||||
|
|
||||||
|
return seconds;
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Submission (Last.fm / etc.) eligibility uses accumulated listen time:
|
Submission (Last.fm / etc.) eligibility uses accumulated listen time:
|
||||||
- If listened time meets the required percentage of track duration
|
- If listened time meets the required percentage of track duration
|
||||||
@@ -99,6 +111,7 @@ export const useScrobble = () => {
|
|||||||
const isPrivateModeEnabled = useAppStore((state) => state.privateMode);
|
const isPrivateModeEnabled = useAppStore((state) => state.privateMode);
|
||||||
const sendScrobble = useSendScrobble();
|
const sendScrobble = useSendScrobble();
|
||||||
const currentSong = usePlayerSong();
|
const currentSong = usePlayerSong();
|
||||||
|
const playbackRate = usePlayerSpeed();
|
||||||
|
|
||||||
const imageUrl = useItemImageUrl({
|
const imageUrl = useItemImageUrl({
|
||||||
id: currentSong?.imageId || undefined,
|
id: currentSong?.imageId || undefined,
|
||||||
@@ -166,6 +179,11 @@ export const useScrobble = () => {
|
|||||||
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
|
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
|
||||||
|
|
||||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||||
|
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||||
|
const serverId = currentSong?._serverId;
|
||||||
|
const server = getServerById(serverId);
|
||||||
|
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
|
||||||
|
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
|
||||||
const currentStatus = usePlayerStore.getState().player.status;
|
const currentStatus = usePlayerStore.getState().player.status;
|
||||||
const currentTime = properties.timestamp;
|
const currentTime = properties.timestamp;
|
||||||
const previousTime = prev.timestamp;
|
const previousTime = prev.timestamp;
|
||||||
@@ -220,19 +238,20 @@ export const useScrobble = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send Jellyfin progress events every 10 seconds
|
// Send progress events every 10 seconds
|
||||||
if (currentSong._serverType === ServerType.JELLYFIN) {
|
if (hasPlaybackReport) {
|
||||||
const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
|
const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
|
||||||
if (timeSinceLastProgress >= 10) {
|
if (timeSinceLastProgress >= 10) {
|
||||||
const position = currentTime * 1e7;
|
|
||||||
sendScrobble.mutate(
|
sendScrobble.mutate(
|
||||||
{
|
{
|
||||||
apiClientProps: { serverId: currentSong._serverId || '' },
|
apiClientProps: { serverId: serverId || '' },
|
||||||
query: {
|
query: {
|
||||||
albumId: currentSong.albumId,
|
albumId: currentSong.albumId,
|
||||||
event: 'timeupdate',
|
event: 'timeupdate',
|
||||||
id: currentSong.id,
|
id: currentSong.id,
|
||||||
position,
|
mediaType: mediaType,
|
||||||
|
playbackRate,
|
||||||
|
position: getPositionValue(currentTime, useTicks),
|
||||||
submission: false,
|
submission: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -261,20 +280,15 @@ export const useScrobble = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (shouldSubmitScrobble) {
|
if (shouldSubmitScrobble) {
|
||||||
// Since jellyfin-plugin-lastfm uses the submission Position to determine if the song should actually scrobble
|
|
||||||
// we just send the full duration of the song when it matches the local scrobble conditions
|
|
||||||
const position =
|
|
||||||
currentSong._serverType === ServerType.JELLYFIN
|
|
||||||
? currentSong.duration * 1e7
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
sendScrobble.mutate(
|
sendScrobble.mutate(
|
||||||
{
|
{
|
||||||
apiClientProps: { serverId: currentSong._serverId || '' },
|
apiClientProps: { serverId: currentSong._serverId || '' },
|
||||||
query: {
|
query: {
|
||||||
albumId: currentSong.albumId,
|
albumId: currentSong.albumId,
|
||||||
id: currentSong.id,
|
id: currentSong.id,
|
||||||
position,
|
mediaType: mediaType,
|
||||||
|
playbackRate: playbackRate,
|
||||||
|
position: getPositionValue(currentSong.duration ?? 0, useTicks),
|
||||||
submission: true,
|
submission: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -295,7 +309,7 @@ export const useScrobble = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleScrobbleFromSongChange = useCallback(
|
const handleScrobbleFromSongChange = useCallback(
|
||||||
@@ -305,6 +319,7 @@ export const useScrobble = () => {
|
|||||||
) => {
|
) => {
|
||||||
const currentSong = properties.song;
|
const currentSong = properties.song;
|
||||||
const previousSong = previousSongRef.current;
|
const previousSong = previousSongRef.current;
|
||||||
|
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||||
|
|
||||||
// Handle notifications
|
// Handle notifications
|
||||||
if (scrobbleSettings?.notify && currentSong?.id) {
|
if (scrobbleSettings?.notify && currentSong?.id) {
|
||||||
@@ -365,6 +380,8 @@ export const useScrobble = () => {
|
|||||||
albumId: currentSong.albumId,
|
albumId: currentSong.albumId,
|
||||||
event: 'start',
|
event: 'start',
|
||||||
id: currentSong.id,
|
id: currentSong.id,
|
||||||
|
mediaType: mediaType,
|
||||||
|
playbackRate: playbackRate,
|
||||||
position: 0,
|
position: 0,
|
||||||
submission: false,
|
submission: false,
|
||||||
},
|
},
|
||||||
@@ -388,11 +405,12 @@ export const useScrobble = () => {
|
|||||||
flushScrobbleDebug();
|
flushScrobbleDebug();
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
flushScrobbleDebug,
|
|
||||||
scrobbleSettings?.notify,
|
scrobbleSettings?.notify,
|
||||||
isScrobbleEnabled,
|
isScrobbleEnabled,
|
||||||
isPrivateModeEnabled,
|
isPrivateModeEnabled,
|
||||||
|
flushScrobbleDebug,
|
||||||
sendScrobble,
|
sendScrobble,
|
||||||
|
playbackRate,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -404,6 +422,11 @@ export const useScrobble = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||||
|
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||||
|
const serverId = currentSong?._serverId;
|
||||||
|
const server = getServerById(serverId);
|
||||||
|
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
|
||||||
|
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
|
||||||
|
|
||||||
if (!currentSong?.id) {
|
if (!currentSong?.id) {
|
||||||
return;
|
return;
|
||||||
@@ -422,7 +445,7 @@ export const useScrobble = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Position scrobbles are only relevant for Jellyfin
|
// Position scrobbles are only relevant for Jellyfin
|
||||||
if (currentSong._serverType !== ServerType.JELLYFIN) {
|
if (!hasPlaybackReport) {
|
||||||
flushScrobbleDebug();
|
flushScrobbleDebug();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -436,8 +459,6 @@ export const useScrobble = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const position = properties.timestamp * 1e7;
|
|
||||||
|
|
||||||
lastProgressEventRef.current = properties.timestamp;
|
lastProgressEventRef.current = properties.timestamp;
|
||||||
lastSeekEventRef.current = now;
|
lastSeekEventRef.current = now;
|
||||||
|
|
||||||
@@ -448,7 +469,9 @@ export const useScrobble = () => {
|
|||||||
albumId: currentSong.albumId,
|
albumId: currentSong.albumId,
|
||||||
event: 'timeupdate',
|
event: 'timeupdate',
|
||||||
id: currentSong.id,
|
id: currentSong.id,
|
||||||
position,
|
mediaType: mediaType,
|
||||||
|
playbackRate: playbackRate,
|
||||||
|
position: getPositionValue(properties.timestamp, useTicks),
|
||||||
submission: false,
|
submission: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -465,7 +488,7 @@ export const useScrobble = () => {
|
|||||||
);
|
);
|
||||||
flushScrobbleDebug();
|
flushScrobbleDebug();
|
||||||
},
|
},
|
||||||
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate, flushScrobbleDebug],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleScrobbleFromStatus = useCallback(
|
const handleScrobbleFromStatus = useCallback(
|
||||||
@@ -475,18 +498,22 @@ export const useScrobble = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||||
|
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||||
|
const serverId = currentSong?._serverId;
|
||||||
|
const server = getServerById(serverId);
|
||||||
|
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
|
||||||
|
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
|
||||||
|
|
||||||
if (!currentSong?.id) {
|
if (!currentSong?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only apply to Jellyfin controller scrobble
|
// Only apply to Jellyfin controller scrobble
|
||||||
if (currentSong._serverType !== ServerType.JELLYFIN) {
|
if (!hasPlaybackReport) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
|
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
|
||||||
const position = currentTimestamp * 1e7;
|
|
||||||
|
|
||||||
// Send pause event when status changes to paused
|
// Send pause event when status changes to paused
|
||||||
if (properties.status === PlayerStatus.PAUSED && prev.status === PlayerStatus.PLAYING) {
|
if (properties.status === PlayerStatus.PAUSED && prev.status === PlayerStatus.PLAYING) {
|
||||||
@@ -497,7 +524,9 @@ export const useScrobble = () => {
|
|||||||
albumId: currentSong.albumId,
|
albumId: currentSong.albumId,
|
||||||
event: 'pause',
|
event: 'pause',
|
||||||
id: currentSong.id,
|
id: currentSong.id,
|
||||||
position,
|
mediaType: mediaType,
|
||||||
|
playbackRate: playbackRate,
|
||||||
|
position: getPositionValue(currentTimestamp, useTicks),
|
||||||
submission: false,
|
submission: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -523,7 +552,9 @@ export const useScrobble = () => {
|
|||||||
albumId: currentSong.albumId,
|
albumId: currentSong.albumId,
|
||||||
event: 'unpause',
|
event: 'unpause',
|
||||||
id: currentSong.id,
|
id: currentSong.id,
|
||||||
position,
|
mediaType: mediaType,
|
||||||
|
playbackRate: playbackRate,
|
||||||
|
position: getPositionValue(currentTimestamp, useTicks),
|
||||||
submission: false,
|
submission: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -542,7 +573,7 @@ export const useScrobble = () => {
|
|||||||
|
|
||||||
flushScrobbleDebug();
|
flushScrobbleDebug();
|
||||||
},
|
},
|
||||||
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
[isScrobbleEnabled, isPrivateModeEnabled, flushScrobbleDebug, sendScrobble, playbackRate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleScrobbleFromRepeat = useCallback(() => {
|
const handleScrobbleFromRepeat = useCallback(() => {
|
||||||
@@ -552,6 +583,7 @@ export const useScrobble = () => {
|
|||||||
|
|
||||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||||
const currentStatus = usePlayerStore.getState().player.status;
|
const currentStatus = usePlayerStore.getState().player.status;
|
||||||
|
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
|
||||||
|
|
||||||
if (currentStatus !== PlayerStatus.PLAYING || !currentSong?.id) {
|
if (currentStatus !== PlayerStatus.PLAYING || !currentSong?.id) {
|
||||||
return;
|
return;
|
||||||
@@ -570,6 +602,8 @@ export const useScrobble = () => {
|
|||||||
albumId: currentSong.albumId,
|
albumId: currentSong.albumId,
|
||||||
event: 'start',
|
event: 'start',
|
||||||
id: currentSong.id,
|
id: currentSong.id,
|
||||||
|
mediaType: mediaType,
|
||||||
|
playbackRate: playbackRate,
|
||||||
position: 0,
|
position: 0,
|
||||||
submission: false,
|
submission: false,
|
||||||
},
|
},
|
||||||
@@ -587,7 +621,7 @@ export const useScrobble = () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
flushScrobbleDebug();
|
flushScrobbleDebug();
|
||||||
}, [flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]);
|
}, [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate, flushScrobbleDebug]);
|
||||||
|
|
||||||
// Update previous timestamp on progress for use in status change handler
|
// Update previous timestamp on progress for use in status change handler
|
||||||
const handleProgressUpdate = useCallback(
|
const handleProgressUpdate = useCallback(
|
||||||
@@ -607,20 +641,22 @@ export const useScrobble = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const song = usePlayerStore.getState().getCurrentSong();
|
const song = usePlayerStore.getState().getCurrentSong();
|
||||||
|
const mediaType = song?._itemType.includes('song') ? 'song' : 'podcast';
|
||||||
|
const useTicks = song?._serverType === ServerType.JELLYFIN;
|
||||||
|
|
||||||
if (!song?.id) {
|
if (!song?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const position =
|
|
||||||
song._serverType === ServerType.JELLYFIN ? song.duration * 1e7 : undefined;
|
|
||||||
|
|
||||||
sendScrobble.mutate(
|
sendScrobble.mutate(
|
||||||
{
|
{
|
||||||
apiClientProps: { serverId: song._serverId || '' },
|
apiClientProps: { serverId: song._serverId || '' },
|
||||||
query: {
|
query: {
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
id: song.id,
|
id: song.id,
|
||||||
position,
|
mediaType: mediaType,
|
||||||
|
playbackRate: playbackRate,
|
||||||
|
position: getPositionValue(song.duration ?? 0, useTicks),
|
||||||
submission: true,
|
submission: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -659,7 +695,7 @@ export const useScrobble = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => registerScrobbleManualHandlers(null);
|
return () => registerScrobbleManualHandlers(null);
|
||||||
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, sendScrobble]);
|
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, playbackRate, sendScrobble]);
|
||||||
|
|
||||||
usePlayerEvents(
|
usePlayerEvents(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { forwardRef, useMemo } from 'react';
|
import { forwardRef, useMemo } from 'react';
|
||||||
import { useEffect } 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 { 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 { 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';
|
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
|
||||||
@@ -21,7 +22,7 @@ import {
|
|||||||
PlaylistSongListResponse,
|
PlaylistSongListResponse,
|
||||||
Song,
|
Song,
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey, Play, TableColumn } from '/@/shared/types/types';
|
import { ItemListKey, TableColumn } from '/@/shared/types/types';
|
||||||
|
|
||||||
interface PlaylistDetailSongListTableProps extends Omit<
|
interface PlaylistDetailSongListTableProps extends Omit<
|
||||||
ItemListTableComponentProps<PlaylistSongListQuery>,
|
ItemListTableComponentProps<PlaylistSongListQuery>,
|
||||||
@@ -103,12 +104,13 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playType = (meta?.playType as Play) || Play.NOW;
|
playSongFromItemListControl({
|
||||||
const items = internalState?.getData() as Song[];
|
index,
|
||||||
|
internalState,
|
||||||
if (index !== undefined) {
|
item: item as Song,
|
||||||
player.addToQueueByData(items, playType, item.id);
|
meta,
|
||||||
}
|
player,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [player]);
|
}, [player]);
|
||||||
@@ -228,12 +230,13 @@ export const PlaylistDetailSongListEditTable = forwardRef<any, PlaylistDetailSon
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playType = (meta?.playType as Play) || Play.NOW;
|
playSongFromItemListControl({
|
||||||
const items = internalState?.getData() as Song[];
|
index,
|
||||||
|
internalState,
|
||||||
if (index !== undefined) {
|
item: item as Song,
|
||||||
player.addToQueueByData(items, playType, item.id);
|
meta,
|
||||||
}
|
player,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [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
|
<WebPlayerEngine
|
||||||
isMuted={isMuted}
|
isMuted={isMuted}
|
||||||
isTransitioning={false}
|
isTransitioning={false}
|
||||||
|
loopPlayer1={false}
|
||||||
|
loopPlayer2={false}
|
||||||
onEndedPlayer1={onEndedPlayer1}
|
onEndedPlayer1={onEndedPlayer1}
|
||||||
onEndedPlayer2={() => {}}
|
onEndedPlayer2={() => {}}
|
||||||
onErrorPause={() => {}}
|
onErrorPause={() => {}}
|
||||||
|
|||||||
@@ -29,9 +29,8 @@ export const useRemote = () => {
|
|||||||
|
|
||||||
// Initialize the remote
|
// Initialize the remote
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRemoteEnabled) {
|
// we must send this EVEN IF the remote is disabled, as this is what
|
||||||
return;
|
// makes sure that the main process gets the port/username/password on startup
|
||||||
}
|
|
||||||
|
|
||||||
logFn.debug(logMsg[LogCategory.REMOTE].initializingRemoteSettings, {
|
logFn.debug(logMsg[LogCategory.REMOTE].initializingRemoteSettings, {
|
||||||
category: LogCategory.REMOTE,
|
category: LogCategory.REMOTE,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { openContextModal } from '@mantine/modals';
|
||||||
import { CSSProperties, useCallback, useState } from 'react';
|
import { CSSProperties, useCallback, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import styles from './library-command-item.module.css';
|
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 { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Flex } from '/@/shared/components/flex/flex';
|
import { Flex } from '/@/shared/components/flex/flex';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||||
import { ExplicitStatus, LibraryItem, Song } from '/@/shared/types/domain-types';
|
import { ExplicitStatus, LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||||
import { Play } from '/@/shared/types/types';
|
import { Play } from '/@/shared/types/types';
|
||||||
|
|
||||||
@@ -61,6 +64,7 @@ export const LibraryCommandItem = ({
|
|||||||
}: LibraryCommandItemProps) => {
|
}: LibraryCommandItemProps) => {
|
||||||
const { addToQueueByData, addToQueueByFetch } = usePlayer();
|
const { addToQueueByData, addToQueueByFetch } = usePlayer();
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handlePlay = useCallback(
|
const handlePlay = useCallback(
|
||||||
(playType: Play) => {
|
(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 [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
const showControls = isHighlighted || isHovered;
|
const showControls = isHighlighted || isHovered;
|
||||||
@@ -180,6 +217,29 @@ export const LibraryCommandItem = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</PlayTooltip>
|
</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>
|
</ActionIconGroup>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import { FontType } from '/@/shared/types/types';
|
|||||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||||
const ipc = isElectron() ? window.api.ipc : null;
|
const ipc = isElectron() ? window.api.ipc : null;
|
||||||
// Electron 32+ removed file.path, use this which is exposed in preload to get real path
|
// Electron 32+ removed file.path, use this which is exposed in preload to get real path
|
||||||
const webUtils = isElectron() ? window.electron.webUtils : null;
|
const getPathForFile = isElectron() ? window.api.getPathForFile : null;
|
||||||
|
|
||||||
const HOME_FEATURE_STYLE_OPTIONS = [
|
const HOME_FEATURE_STYLE_OPTIONS = [
|
||||||
{
|
{
|
||||||
@@ -295,7 +295,7 @@ export const ApplicationSettings = memo(() => {
|
|||||||
setSettings({
|
setSettings({
|
||||||
font: {
|
font: {
|
||||||
...fontSettings,
|
...fontSettings,
|
||||||
custom: e ? webUtils?.getPathForFile(e) || null : null,
|
custom: e ? getPathForFile?.(e) || null : null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
|
||||||
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
||||||
import {
|
import {
|
||||||
|
SidebarPlaylistAddDragContext,
|
||||||
SidebarPlaylistList,
|
SidebarPlaylistList,
|
||||||
SidebarSharedPlaylistList,
|
SidebarSharedPlaylistList,
|
||||||
|
useSidebarPlaylistAddDragMonitor,
|
||||||
} from '/@/renderer/features/sidebar/components/sidebar-playlist-list';
|
} from '/@/renderer/features/sidebar/components/sidebar-playlist-list';
|
||||||
import {
|
import {
|
||||||
SidebarItemType,
|
SidebarItemType,
|
||||||
@@ -20,6 +22,17 @@ import { Group } from '/@/shared/components/group/group';
|
|||||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
|
||||||
|
const MobileSidebarPlaylistSection = () => {
|
||||||
|
const isAddDragActive = useSidebarPlaylistAddDragMonitor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarPlaylistAddDragContext.Provider value={isAddDragActive}>
|
||||||
|
<SidebarPlaylistList />
|
||||||
|
<SidebarSharedPlaylistList />
|
||||||
|
</SidebarPlaylistAddDragContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const MobileSidebar = () => {
|
export const MobileSidebar = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const sidebarPlaylistList = useSidebarPlaylistList();
|
const sidebarPlaylistList = useSidebarPlaylistList();
|
||||||
@@ -93,12 +106,7 @@ export const MobileSidebar = () => {
|
|||||||
})}
|
})}
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
{sidebarPlaylistList && (
|
{sidebarPlaylistList && <MobileSidebarPlaylistSection />}
|
||||||
<>
|
|
||||||
<SidebarPlaylistList />
|
|
||||||
<SidebarSharedPlaylistList />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
gap: var(--theme-spacing-md);
|
gap: var(--theme-spacing-md);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--theme-spacing-xs) var(--theme-spacing-md);
|
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -32,12 +32,18 @@
|
|||||||
border-radius: var(--theme-radius-md);
|
border-radius: var(--theme-radius-md);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--theme-colors-surface);
|
background-color: var(--theme-colors-background);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron {
|
.chevron {
|
||||||
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse {
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
import {
|
import {
|
||||||
ComponentPropsWithoutRef,
|
ComponentPropsWithoutRef,
|
||||||
|
createContext,
|
||||||
CSSProperties,
|
CSSProperties,
|
||||||
MouseEvent,
|
MouseEvent,
|
||||||
ReactElement,
|
ReactElement,
|
||||||
|
ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useContext,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import styles from './playlist-folder-tree.module.css';
|
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 { PlaylistRowButton } from '/@/renderer/features/sidebar/components/sidebar-playlist-list';
|
||||||
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +41,25 @@ import { DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-dr
|
|||||||
|
|
||||||
const STORAGE_KEY_PREFIX = 'feishin:playlist-folder-state';
|
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 => {
|
export const getPlaylistLeafName = (name: string, separator: string): string => {
|
||||||
if (!separator) return name;
|
if (!separator) return name;
|
||||||
const segments = name.split(separator).filter((segment) => segment.length > 0);
|
const segments = name.split(separator).filter((segment) => segment.length > 0);
|
||||||
@@ -69,6 +96,42 @@ export const isDirectChildFolder = (
|
|||||||
return relativePath.length > 0 && !relativePath.includes(separator);
|
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 = (
|
export const isValidFolderNest = (
|
||||||
sourceFolderPath: string,
|
sourceFolderPath: string,
|
||||||
targetFolderPath: string,
|
targetFolderPath: string,
|
||||||
@@ -123,74 +186,82 @@ export const remapPlaylistToRoot = (playlistName: string, separator: string): st
|
|||||||
return getPlaylistLeafName(playlistName, separator).trim();
|
return getPlaylistLeafName(playlistName, separator).trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePlaylistName = async (
|
export const isRootLevelFolder = (folderPath: string, separator: string): boolean => {
|
||||||
updateMutation: ReturnType<typeof useUpdatePlaylist>,
|
const segments = folderPath.split(separator).filter((segment) => segment.length > 0);
|
||||||
serverId: string,
|
return segments.length === 1;
|
||||||
playlist: Playlist,
|
};
|
||||||
newName: string,
|
|
||||||
) => {
|
|
||||||
if (newName === playlist.name) return;
|
|
||||||
|
|
||||||
await updateMutation.mutateAsync({
|
export const remapPlaylistFolderToRoot = (
|
||||||
apiClientProps: { serverId },
|
playlistName: string,
|
||||||
body: {
|
sourceFolderPath: string,
|
||||||
comment: playlist.description || '',
|
separator: string,
|
||||||
name: newName,
|
): null | string => {
|
||||||
ownerId: playlist.ownerId || '',
|
const sourcePrefix = `${sourceFolderPath}${separator}`;
|
||||||
public: playlist.public || false,
|
if (!playlistName.startsWith(sourcePrefix)) return null;
|
||||||
queryBuilderRules: playlist.rules ?? undefined,
|
|
||||||
sync: playlist.sync ?? undefined,
|
const remainder = playlistName.slice(sourcePrefix.length);
|
||||||
},
|
if (!remainder) return null;
|
||||||
query: { id: playlist.id },
|
|
||||||
});
|
// 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[]) => {
|
export const usePlaylistRootDrop = (allPlaylists: Playlist[]) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
const separator = useSidebarPlaylistFolderSeparator();
|
const separator = useSidebarPlaylistFolderSeparator();
|
||||||
const updateMutation = useUpdatePlaylist({});
|
const folderMoveMutation = useSidebarPlaylistFolderMove();
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(
|
||||||
async (source: DragData) => {
|
async (source: DragData) => {
|
||||||
if (!serverId) return;
|
if (!serverId) return;
|
||||||
|
|
||||||
try {
|
const updates: SidebarPlaylistFolderMoveUpdate[] = [];
|
||||||
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(
|
if (source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) {
|
||||||
allPlaylists,
|
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,
|
sourceFolderPath,
|
||||||
separator,
|
separator,
|
||||||
);
|
);
|
||||||
|
if (!newName) continue;
|
||||||
|
|
||||||
for (const playlist of affected) {
|
updates.push({ newName, playlist });
|
||||||
await updatePlaylistName(
|
|
||||||
updateMutation,
|
|
||||||
serverId,
|
|
||||||
playlist,
|
|
||||||
remapPlaylistToRoot(playlist.name, separator),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
const playlists = source.item as Playlist[] | undefined;
|
const playlists = source.item as Playlist[] | undefined;
|
||||||
if (!Array.isArray(playlists) || playlists.length === 0) return;
|
if (!Array.isArray(playlists) || playlists.length === 0) return;
|
||||||
|
|
||||||
for (const playlist of playlists) {
|
for (const playlist of playlists) {
|
||||||
await updatePlaylistName(
|
updates.push({
|
||||||
updateMutation,
|
newName: remapPlaylistToRoot(playlist.name, separator),
|
||||||
serverId,
|
|
||||||
playlist,
|
playlist,
|
||||||
remapPlaylistToRoot(playlist.name, separator),
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await folderMoveMutation.mutateAsync({ serverId, updates });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error({
|
toast.error({
|
||||||
message: err instanceof Error ? err.message : undefined,
|
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>({
|
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.
|
// Drag-and-drop on folder headers: folders can be dragged, and accept folder or playlist drops.
|
||||||
const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) => {
|
const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
const separator = useSidebarPlaylistFolderSeparator();
|
const separator = useSidebarPlaylistFolderSeparator();
|
||||||
const updateMutation = useUpdatePlaylist({});
|
const folderMoveMutation = useSidebarPlaylistFolderMove();
|
||||||
|
const dragExpand = useContext(PlaylistFolderDragExpandContext);
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(
|
||||||
async (source: DragData) => {
|
async (source: DragData) => {
|
||||||
if (!serverId) return;
|
if (!serverId) return;
|
||||||
|
|
||||||
try {
|
const updates: SidebarPlaylistFolderMoveUpdate[] = [];
|
||||||
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 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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;
|
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;
|
const playlists = source.item as Playlist[] | undefined;
|
||||||
if (!Array.isArray(playlists) || playlists.length === 0) return;
|
if (!Array.isArray(playlists) || playlists.length === 0) return;
|
||||||
|
|
||||||
@@ -328,19 +547,14 @@ const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) =>
|
|||||||
const newName = buildPlaylistNameInFolder(folderPath, leafName, separator);
|
const newName = buildPlaylistNameInFolder(folderPath, leafName, separator);
|
||||||
if (newName === playlist.name) continue;
|
if (newName === playlist.name) continue;
|
||||||
|
|
||||||
await updateMutation.mutateAsync({
|
updates.push({ newName, playlist });
|
||||||
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 },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await folderMoveMutation.mutateAsync({ serverId, updates });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error({
|
toast.error({
|
||||||
message: err instanceof Error ? err.message : undefined,
|
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>({
|
const { isDraggedOver, isDragging, ref } = useDragDrop<HTMLButtonElement>({
|
||||||
drag: {
|
drag: {
|
||||||
// Folders are virtual; drag data carries the folder path, not playlist items.
|
|
||||||
getId: () => [folderPath],
|
getId: () => [folderPath],
|
||||||
getItem: () => [],
|
getItem: () => [],
|
||||||
metadata: { folderName: folderPath },
|
metadata: { folderName: folderPath },
|
||||||
@@ -361,20 +574,7 @@ const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) =>
|
|||||||
target: DragTarget.SIDEBAR_PLAYLIST_FOLDER,
|
target: DragTarget.SIDEBAR_PLAYLIST_FOLDER,
|
||||||
},
|
},
|
||||||
drop: {
|
drop: {
|
||||||
canDrop: (args) => {
|
canDrop: (args) => canDropOnPlaylistFolder(args.source, folderPath, separator),
|
||||||
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;
|
|
||||||
},
|
|
||||||
getData: () => ({
|
getData: () => ({
|
||||||
id: [folderPath],
|
id: [folderPath],
|
||||||
type: DragTarget.SIDEBAR_PLAYLIST_FOLDER,
|
type: DragTarget.SIDEBAR_PLAYLIST_FOLDER,
|
||||||
@@ -386,6 +586,7 @@ const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) =>
|
|||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
onDrop: ({ source }) => {
|
onDrop: ({ source }) => {
|
||||||
|
dragExpand?.onFolderDrop(folderPath);
|
||||||
void handleDrop(source);
|
void handleDrop(source);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -429,7 +630,7 @@ const PlaylistFolderHeader = ({
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className={styles.navFolderIcon}>
|
<div className={styles.navFolderIcon}>
|
||||||
<Icon color="muted" icon="folder" size="xl" />
|
<Icon color="muted" icon={isOpen ? 'folder' : 'folderClosed'} size="md" />
|
||||||
</div>
|
</div>
|
||||||
<Text className={styles.name} fw={500} size="md">
|
<Text className={styles.name} fw={500} size="md">
|
||||||
{name}
|
{name}
|
||||||
@@ -454,18 +655,21 @@ const PlaylistFolderHeader = ({
|
|||||||
style={{ opacity: isDragging ? 0.5 : 1 }}
|
style={{ opacity: isDragging ? 0.5 : 1 }}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon color="muted" icon={isOpen ? 'folder' : 'folderClosed'} size="md" />
|
||||||
className={styles.chevron}
|
|
||||||
icon={isOpen ? 'arrowDownS' : 'arrowRightS'}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<Icon color="muted" icon="folder" size="sm" />
|
|
||||||
<Text className={styles.name} fw={500} size="md">
|
<Text className={styles.name} fw={500} size="md">
|
||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className={styles.count} isMuted size="sm">
|
<Text className={styles.count} isMuted size="sm">
|
||||||
{leafCount}
|
{leafCount}
|
||||||
</Text>
|
</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>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -686,7 +890,7 @@ export const PlaylistFolderTree = ({
|
|||||||
|
|
||||||
const isOpen = expandedSet.has(group.name);
|
const isOpen = expandedSet.has(group.name);
|
||||||
return (
|
return (
|
||||||
<div className={styles.folder} key={`folder:${group.name}`}>
|
<PlaylistFolder folderPath={group.name} key={`folder:${group.name}`}>
|
||||||
<PlaylistFolderHeader
|
<PlaylistFolderHeader
|
||||||
allPlaylists={allPlaylists}
|
allPlaylists={allPlaylists}
|
||||||
folderPath={group.name}
|
folderPath={group.name}
|
||||||
@@ -696,21 +900,19 @@ export const PlaylistFolderTree = ({
|
|||||||
onClick={() => onToggleFolder(group.name)}
|
onClick={() => onToggleFolder(group.name)}
|
||||||
variant="header"
|
variant="header"
|
||||||
/>
|
/>
|
||||||
{isOpen && (
|
<PlaylistFolderCollapse className={styles.children} open={isOpen}>
|
||||||
<div className={styles.children}>
|
{group.items.map((item) => (
|
||||||
{group.items.map((item) => (
|
<PlaylistRowButton
|
||||||
<PlaylistRowButton
|
item={item}
|
||||||
item={item}
|
key={item.id}
|
||||||
key={item.id}
|
name={item.name.slice(group.name.length + 1)}
|
||||||
name={item.name.slice(group.name.length + 1)}
|
onContextMenu={onContextMenu}
|
||||||
onContextMenu={onContextMenu}
|
onReorder={onReorder}
|
||||||
onReorder={onReorder}
|
to={item.id}
|
||||||
to={item.id}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
</PlaylistFolderCollapse>
|
||||||
</div>
|
</PlaylistFolder>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
@@ -750,7 +952,7 @@ export const PlaylistFolderTreeView = ({
|
|||||||
|
|
||||||
const isOpen = expandedSet.has(node.path);
|
const isOpen = expandedSet.has(node.path);
|
||||||
return (
|
return (
|
||||||
<div className={styles.folder} key={`folder:${node.path}`}>
|
<PlaylistFolder folderPath={node.path} key={`folder:${node.path}`}>
|
||||||
<PlaylistFolderHeader
|
<PlaylistFolderHeader
|
||||||
allPlaylists={allPlaylists}
|
allPlaylists={allPlaylists}
|
||||||
folderPath={node.path}
|
folderPath={node.path}
|
||||||
@@ -760,16 +962,14 @@ export const PlaylistFolderTreeView = ({
|
|||||||
onClick={() => onToggleFolder(node.path)}
|
onClick={() => onToggleFolder(node.path)}
|
||||||
variant="header"
|
variant="header"
|
||||||
/>
|
/>
|
||||||
{isOpen && (
|
<PlaylistFolderCollapse className={styles.treeChildren} open={isOpen}>
|
||||||
<div className={styles.treeChildren}>
|
{node.children.map((child) => (
|
||||||
{node.children.map((child) => (
|
<div className={styles.treeBranch} key={getNodeKey(child)}>
|
||||||
<div className={styles.treeBranch} key={getNodeKey(child)}>
|
{renderNode(child)}
|
||||||
{renderNode(child)}
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</PlaylistFolderCollapse>
|
||||||
</div>
|
</PlaylistFolder>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
|
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -29,13 +33,12 @@
|
|||||||
.compact-name {
|
.compact-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -45,7 +48,6 @@
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.controls-compact {
|
.controls-compact {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -56,10 +58,14 @@
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-dragged-over::after {
|
||||||
.row-dragged-over {
|
position: absolute;
|
||||||
border-radius: var(--mantine-radius-sm);
|
inset: 0;
|
||||||
box-shadow: 0 0 0 2px var(--theme-colors-primary);
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
content: '';
|
||||||
|
border: 2px solid var(--theme-colors-primary);
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { openContextModal } from '@mantine/modals';
|
import { openContextModal } from '@mantine/modals';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import clsx from 'clsx';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { generatePath, Link } from 'react-router';
|
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 { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||||
import { openCreatePlaylistModal } from '/@/renderer/features/playlists/components/create-playlist-form';
|
import { openCreatePlaylistModal } from '/@/renderer/features/playlists/components/create-playlist-form';
|
||||||
import {
|
import { useIsMutatingSidebarPlaylistFolderMove } from '/@/renderer/features/playlists/mutations/sidebar-playlist-folder-move-mutation';
|
||||||
LONG_PRESS_PLAY_BEHAVIOR,
|
import { ItemRowPlayControls } from '/@/renderer/features/shared/components/item-row-play-controls';
|
||||||
PlayTooltip,
|
|
||||||
} from '/@/renderer/features/shared/components/play-button-group';
|
|
||||||
import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click';
|
|
||||||
import {
|
import {
|
||||||
collectFolderPaths,
|
collectFolderPaths,
|
||||||
|
PlaylistFolderDragExpandProvider,
|
||||||
PlaylistFolderViews,
|
PlaylistFolderViews,
|
||||||
PlaylistRootAccordionControl,
|
PlaylistRootAccordionControl,
|
||||||
usePlaylistFolderState,
|
usePlaylistFolderState,
|
||||||
@@ -26,6 +25,7 @@ import {
|
|||||||
usePlaylistNavigationState,
|
usePlaylistNavigationState,
|
||||||
} from '/@/renderer/features/sidebar/components/playlist-folder-tree';
|
} from '/@/renderer/features/sidebar/components/playlist-folder-tree';
|
||||||
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||||
|
import { useDragMonitor } from '/@/renderer/hooks/use-drag-monitor';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import {
|
import {
|
||||||
useCurrentServer,
|
useCurrentServer,
|
||||||
@@ -37,11 +37,14 @@ import {
|
|||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
import { formatDurationString } from '/@/renderer/utils';
|
import { formatDurationString } from '/@/renderer/utils';
|
||||||
import { Accordion } from '/@/shared/components/accordion/accordion';
|
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 { ButtonProps } from '/@/shared/components/button/button';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Image } from '/@/shared/components/image/image';
|
import { Image } from '/@/shared/components/image/image';
|
||||||
|
import { LoadingOverlay } from '/@/shared/components/loading-overlay/loading-overlay';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||||
import {
|
import {
|
||||||
@@ -51,14 +54,50 @@ import {
|
|||||||
Song,
|
Song,
|
||||||
SortOrder,
|
SortOrder,
|
||||||
} from '/@/shared/types/domain-types';
|
} 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';
|
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 getPlaylistOrderKey = (serverId: string | undefined, scope: 'owned' | 'shared') => {
|
||||||
const sid = serverId || 'local';
|
const sid = serverId || 'local';
|
||||||
return `playlist_order:${sid}:${scope}`;
|
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'> {
|
export interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'onPlay'> {
|
||||||
item: Playlist;
|
item: Playlist;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -79,6 +118,8 @@ export const PlaylistRowButton = memo(
|
|||||||
const isCompact = sidebarPlaylistMode === 'compact';
|
const isCompact = sidebarPlaylistMode === 'compact';
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const isSmartPlaylist = Boolean(item.rules);
|
||||||
|
const isAddDragActive = useContext(SidebarPlaylistAddDragContext);
|
||||||
|
|
||||||
const { isDraggedOver, isDragging, ref } = useDragDrop<HTMLAnchorElement>({
|
const { isDraggedOver, isDragging, ref } = useDragDrop<HTMLAnchorElement>({
|
||||||
drag: {
|
drag: {
|
||||||
@@ -96,6 +137,7 @@ export const PlaylistRowButton = memo(
|
|||||||
canDrop: (args) => {
|
canDrop: (args) => {
|
||||||
// Allow dropping items into a playlist (ADD)
|
// Allow dropping items into a playlist (ADD)
|
||||||
const canAdd =
|
const canAdd =
|
||||||
|
!isSmartPlaylist &&
|
||||||
args.source.itemType !== undefined &&
|
args.source.itemType !== undefined &&
|
||||||
args.source.type !== DragTarget.PLAYLIST &&
|
args.source.type !== DragTarget.PLAYLIST &&
|
||||||
(args.source.operation?.includes(DragOperation.ADD) ?? false);
|
(args.source.operation?.includes(DragOperation.ADD) ?? false);
|
||||||
@@ -117,6 +159,7 @@ export const PlaylistRowButton = memo(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
onDrag: () => {
|
onDrag: () => {
|
||||||
|
console.log('started drag');
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
onDragLeave: () => {
|
onDragLeave: () => {
|
||||||
@@ -149,6 +192,10 @@ export const PlaylistRowButton = memo(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSmartPlaylist) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const modalProps: {
|
const modalProps: {
|
||||||
albumId?: string[];
|
albumId?: string[];
|
||||||
artistId?: string[];
|
artistId?: string[];
|
||||||
@@ -221,13 +268,18 @@ export const PlaylistRowButton = memo(
|
|||||||
type: 'table',
|
type: 'table',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isDimmed = isDragging || (isSmartPlaylist && isAddDragActive);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<MotionLink
|
||||||
|
{...animationProps.fadeIn}
|
||||||
|
animate={isDimmed ? 'hidden' : 'show'}
|
||||||
className={clsx(styles.row, {
|
className={clsx(styles.row, {
|
||||||
[styles.rowCompact]: isCompact,
|
[styles.rowCompact]: isCompact,
|
||||||
[styles.rowDraggedOver]: isDraggedOver,
|
[styles.rowDraggedOver]: isDraggedOver && !isSmartPlaylist,
|
||||||
[styles.rowHover]: isHovered,
|
[styles.rowHover]: isHovered,
|
||||||
})}
|
})}
|
||||||
|
initial={false}
|
||||||
onContextMenu={(e: MouseEvent<HTMLAnchorElement>) => {
|
onContextMenu={(e: MouseEvent<HTMLAnchorElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onContextMenu(e, item);
|
onContextMenu(e, item);
|
||||||
@@ -235,17 +287,20 @@ export const PlaylistRowButton = memo(
|
|||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={{
|
|
||||||
opacity: isDragging ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
to={url}
|
to={url}
|
||||||
|
variants={playlistRowDimVariants}
|
||||||
>
|
>
|
||||||
{isCompact ? (
|
{isCompact ? (
|
||||||
<>
|
<>
|
||||||
<Text className={styles.compactName} fw={500} size="md">
|
<Text className={styles.compactName} fw={500} size="md">
|
||||||
{name}
|
{name}
|
||||||
</Text>
|
</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>
|
||||||
</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 = () => {
|
export const SidebarPlaylistList = () => {
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -551,6 +529,7 @@ export const SidebarPlaylistList = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const showExpandAll = folderView !== 'navigation' && folderPaths.length > 0;
|
const showExpandAll = folderView !== 'navigation' && folderPaths.length > 0;
|
||||||
|
const isFolderMovePending = useIsMutatingSidebarPlaylistFolderMove();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion.Item value="playlists">
|
<Accordion.Item value="playlists">
|
||||||
@@ -571,7 +550,7 @@ export const SidebarPlaylistList = () => {
|
|||||||
{inNavigation ? navigation.currentName : t('page.sidebar.playlists')}
|
{inNavigation ? navigation.currentName : t('page.sidebar.playlists')}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="add"
|
icon="add"
|
||||||
iconProps={{
|
iconProps={{
|
||||||
@@ -622,16 +601,19 @@ export const SidebarPlaylistList = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</PlaylistRootAccordionControl>
|
</PlaylistRootAccordionControl>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel className={styles.panel}>
|
||||||
<PlaylistFolderViews
|
<LoadingOverlay pos="absolute" visible={isFolderMovePending} />
|
||||||
{...folderViewState}
|
<PlaylistFolderDragExpandProvider expandedSet={expandedSet} setMany={setMany}>
|
||||||
allPlaylists={playlistItems?.items ?? []}
|
<PlaylistFolderViews
|
||||||
expandedSet={expandedSet}
|
{...folderViewState}
|
||||||
navigation={navigation}
|
allPlaylists={playlistItems?.items ?? []}
|
||||||
onContextMenu={handleContextMenu}
|
expandedSet={expandedSet}
|
||||||
onReorder={handleReorder}
|
navigation={navigation}
|
||||||
onToggleFolder={toggle}
|
onContextMenu={handleContextMenu}
|
||||||
/>
|
onReorder={handleReorder}
|
||||||
|
onToggleFolder={toggle}
|
||||||
|
/>
|
||||||
|
</PlaylistFolderDragExpandProvider>
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
);
|
);
|
||||||
@@ -771,7 +753,7 @@ export const SidebarSharedPlaylistList = () => {
|
|||||||
|
|
||||||
const folderViewState = usePlaylistFolderViewState(playlistItems?.items ?? []);
|
const folderViewState = usePlaylistFolderViewState(playlistItems?.items ?? []);
|
||||||
const navigation = usePlaylistNavigationState();
|
const navigation = usePlaylistNavigationState();
|
||||||
const { expandedSet, toggle } = usePlaylistFolderState('shared');
|
const { expandedSet, setMany, toggle } = usePlaylistFolderState('shared');
|
||||||
const inNavigation =
|
const inNavigation =
|
||||||
folderViewState.folderView === 'navigation' && navigation.pathStack.length > 0;
|
folderViewState.folderView === 'navigation' && navigation.pathStack.length > 0;
|
||||||
|
|
||||||
@@ -783,6 +765,8 @@ export const SidebarSharedPlaylistList = () => {
|
|||||||
[navigation],
|
[navigation],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isFolderMovePending = useIsMutatingSidebarPlaylistFolderMove();
|
||||||
|
|
||||||
if (playlistItems?.items?.length === 0) {
|
if (playlistItems?.items?.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -806,16 +790,19 @@ export const SidebarSharedPlaylistList = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel className={styles.panel}>
|
||||||
<PlaylistFolderViews
|
<LoadingOverlay pos="absolute" visible={isFolderMovePending} />
|
||||||
{...folderViewState}
|
<PlaylistFolderDragExpandProvider expandedSet={expandedSet} setMany={setMany}>
|
||||||
allPlaylists={playlistItems?.items ?? []}
|
<PlaylistFolderViews
|
||||||
expandedSet={expandedSet}
|
{...folderViewState}
|
||||||
navigation={navigation}
|
allPlaylists={playlistItems?.items ?? []}
|
||||||
onContextMenu={handleContextMenu}
|
expandedSet={expandedSet}
|
||||||
onReorder={handleReorder}
|
navigation={navigation}
|
||||||
onToggleFolder={toggle}
|
onContextMenu={handleContextMenu}
|
||||||
/>
|
onReorder={handleReorder}
|
||||||
|
onToggleFolder={toggle}
|
||||||
|
/>
|
||||||
|
</PlaylistFolderDragExpandProvider>
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import { SidebarCollectionList } from '/@/renderer/features/sidebar/components/s
|
|||||||
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
|
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
|
||||||
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
||||||
import {
|
import {
|
||||||
|
SidebarPlaylistAddDragContext,
|
||||||
SidebarPlaylistList,
|
SidebarPlaylistList,
|
||||||
SidebarSharedPlaylistList,
|
SidebarSharedPlaylistList,
|
||||||
|
useSidebarPlaylistAddDragMonitor,
|
||||||
} from '/@/renderer/features/sidebar/components/sidebar-playlist-list';
|
} from '/@/renderer/features/sidebar/components/sidebar-playlist-list';
|
||||||
import {
|
import {
|
||||||
useAppStore,
|
useAppStore,
|
||||||
@@ -45,6 +47,17 @@ import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
|||||||
import { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';
|
import { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';
|
||||||
import { Platform } from '/@/shared/types/types';
|
import { Platform } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
const SidebarPlaylistSection = () => {
|
||||||
|
const isAddDragActive = useSidebarPlaylistAddDragMonitor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarPlaylistAddDragContext.Provider value={isAddDragActive}>
|
||||||
|
<SidebarPlaylistList />
|
||||||
|
<SidebarSharedPlaylistList />
|
||||||
|
</SidebarPlaylistAddDragContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const Sidebar = () => {
|
export const Sidebar = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -140,12 +153,7 @@ export const Sidebar = () => {
|
|||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
<SidebarCollectionList />
|
<SidebarCollectionList />
|
||||||
{sidebarPlaylistList && (
|
{sidebarPlaylistList && <SidebarPlaylistSection />}
|
||||||
<>
|
|
||||||
<SidebarPlaylistList />
|
|
||||||
<SidebarSharedPlaylistList />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<AnimatePresence initial={false} mode="popLayout">
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
|
|||||||
@@ -21,6 +21,21 @@ type PromptState = 'loading' | { consent: boolean };
|
|||||||
|
|
||||||
export function VisualizerSystemAudioBridgeHook() {
|
export function VisualizerSystemAudioBridgeHook() {
|
||||||
const playbackType = usePlaybackType();
|
const playbackType = usePlaybackType();
|
||||||
|
const isVisualizerSurfaceVisible = useIsLocalVisualizerSurfaceVisible();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isElectron()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldReportVisible = playbackType === PlayerType.LOCAL && isVisualizerSurfaceVisible;
|
||||||
|
|
||||||
|
window.api.visualizer.setLocalSurfaceVisible(shouldReportVisible);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.api.visualizer.setLocalSurfaceVisible(false);
|
||||||
|
};
|
||||||
|
}, [playbackType, isVisualizerSurfaceVisible]);
|
||||||
|
|
||||||
if (!isElectron() || playbackType !== PlayerType.LOCAL) {
|
if (!isElectron() || playbackType !== PlayerType.LOCAL) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ interface UseDraggableProps {
|
|||||||
drop?: {
|
drop?: {
|
||||||
canDrop: (args: { source: DragData }) => boolean;
|
canDrop: (args: { source: DragData }) => boolean;
|
||||||
getData: () => DragData;
|
getData: () => DragData;
|
||||||
onDrag: (args: { edge: Edge | null }) => void;
|
onDrag: (args: { edge: Edge | null; source: DragData }) => void;
|
||||||
onDragLeave: () => void;
|
onDragLeave: () => void;
|
||||||
onDrop: (args: { edge: Edge | null; self: DragData; source: DragData }) => void;
|
onDrop: (args: { edge: Edge | null; self: DragData; source: DragData }) => void;
|
||||||
};
|
};
|
||||||
@@ -139,10 +139,14 @@ export const useDragDrop = <TElement extends HTMLElement>({
|
|||||||
},
|
},
|
||||||
onDrag: (args) => {
|
onDrag: (args) => {
|
||||||
const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);
|
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);
|
setIsDraggedOver(closestEdgeOfTarget);
|
||||||
},
|
},
|
||||||
onDragLeave: () => {
|
onDragLeave: () => {
|
||||||
|
drop.onDragLeave?.();
|
||||||
setIsDraggedOver(null);
|
setIsDraggedOver(null);
|
||||||
},
|
},
|
||||||
onDrop: (args) => {
|
onDrop: (args) => {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import { useShallow } from 'zustand/react/shallow';
|
|||||||
import { createWithEqualityFn } from 'zustand/traditional';
|
import { createWithEqualityFn } from 'zustand/traditional';
|
||||||
|
|
||||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
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 { createSelectors } from '/@/renderer/lib/zustand';
|
||||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||||
import {
|
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
|
// Helper function to check if shuffle is enabled
|
||||||
export function isShuffleEnabled(state: {
|
export function isShuffleEnabled(state: {
|
||||||
player: { shuffle: PlayerShuffle };
|
player: { shuffle: PlayerShuffle };
|
||||||
@@ -477,6 +497,10 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case Play.NOW: {
|
case Play.NOW: {
|
||||||
|
if (useRadioPlayerStore.getState().currentStreamUrl) {
|
||||||
|
useRadioPlayerStore.getState().actions.stop();
|
||||||
|
}
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
newItems.forEach((item) => {
|
newItems.forEach((item) => {
|
||||||
state.queue.songs[item._uniqueId] = item;
|
state.queue.songs[item._uniqueId] = item;
|
||||||
@@ -531,6 +555,10 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case Play.SHUFFLE: {
|
case Play.SHUFFLE: {
|
||||||
|
if (useRadioPlayerStore.getState().currentStreamUrl) {
|
||||||
|
useRadioPlayerStore.getState().actions.stop();
|
||||||
|
}
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
newItems.forEach((item) => {
|
newItems.forEach((item) => {
|
||||||
state.queue.songs[item._uniqueId] = item;
|
state.queue.songs[item._uniqueId] = item;
|
||||||
@@ -791,13 +819,20 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
nextSong = calculateNextSong(queueIndex, queue.items, repeat);
|
nextSong = calculateNextSong(queueIndex, queue.items, repeat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { player1, player2 } = getDualPlayerSongs(
|
||||||
|
state.player.playerNum,
|
||||||
|
currentSong,
|
||||||
|
nextSong,
|
||||||
|
repeat,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentSong,
|
currentSong,
|
||||||
index: queueIndex, // Return the actual queue position for display
|
index: queueIndex, // Return the actual queue position for display
|
||||||
nextSong,
|
nextSong,
|
||||||
num: state.player.playerNum,
|
num: state.player.playerNum,
|
||||||
player1: state.player.playerNum === 1 ? currentSong : nextSong,
|
player1,
|
||||||
player2: state.player.playerNum === 2 ? currentSong : nextSong,
|
player2,
|
||||||
previousSong,
|
previousSong,
|
||||||
queueLength: state.queue.default.length,
|
queueLength: state.queue.default.length,
|
||||||
status: state.player.status,
|
status: state.player.status,
|
||||||
@@ -886,12 +921,21 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
? stateSnapshot.queue.shuffled.length
|
? stateSnapshot.queue.shuffled.length
|
||||||
: queue.items.length;
|
: queue.items.length;
|
||||||
|
|
||||||
const newPlayerNum = player.playerNum === 1 ? 2 : 1;
|
|
||||||
const { nextIndex: nextPlaybackIndex, shouldPause } = calculateNextIndex(
|
const { nextIndex: nextPlaybackIndex, shouldPause } = calculateNextIndex(
|
||||||
currentIndex,
|
currentIndex,
|
||||||
playbackLength,
|
playbackLength,
|
||||||
repeat,
|
repeat,
|
||||||
);
|
);
|
||||||
|
const isRepeatOneSameTrack =
|
||||||
|
repeat === PlayerRepeat.ONE && nextPlaybackIndex === currentIndex;
|
||||||
|
// Dual web players alternate for gapless/crossfade between tracks. Repeat-one
|
||||||
|
// replays the same track — keep playerNum so Chromium stays bound to the same
|
||||||
|
// <audio> element and hardware media keys keep working.
|
||||||
|
const newPlayerNum = isRepeatOneSameTrack
|
||||||
|
? player.playerNum
|
||||||
|
: player.playerNum === 1
|
||||||
|
? 2
|
||||||
|
: 1;
|
||||||
const pauseOnNext = player.pauseOnNextSongEnd;
|
const pauseOnNext = player.pauseOnNextSongEnd;
|
||||||
const newStatus =
|
const newStatus =
|
||||||
shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING;
|
shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING;
|
||||||
@@ -954,13 +998,20 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
currentQueueIndex > 0 ? queue.items[currentQueueIndex - 1] : undefined;
|
currentQueueIndex > 0 ? queue.items[currentQueueIndex - 1] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { player1, player2 } = getDualPlayerSongs(
|
||||||
|
newPlayerNum,
|
||||||
|
currentSong,
|
||||||
|
nextSong,
|
||||||
|
repeat,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentSong,
|
currentSong,
|
||||||
index: currentQueueIndex,
|
index: currentQueueIndex,
|
||||||
nextSong,
|
nextSong,
|
||||||
num: newPlayerNum,
|
num: newPlayerNum,
|
||||||
player1: newPlayerNum === 1 ? currentSong : nextSong,
|
player1,
|
||||||
player2: newPlayerNum === 2 ? currentSong : nextSong,
|
player2,
|
||||||
previousSong,
|
previousSong,
|
||||||
queueLength: queue.items.length,
|
queueLength: queue.items.length,
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
@@ -1953,13 +2004,20 @@ export const usePlayerData = (): PlayerData => {
|
|||||||
nextSong = calculateNextSong(queueIndex, queue.items, repeat);
|
nextSong = calculateNextSong(queueIndex, queue.items, repeat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { player1, player2 } = getDualPlayerSongs(
|
||||||
|
state.player.playerNum,
|
||||||
|
currentSong,
|
||||||
|
nextSong,
|
||||||
|
repeat,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentSong,
|
currentSong,
|
||||||
index: queueIndex, // Return the actual queue position for display
|
index: queueIndex, // Return the actual queue position for display
|
||||||
nextSong,
|
nextSong,
|
||||||
num: state.player.playerNum,
|
num: state.player.playerNum,
|
||||||
player1: state.player.playerNum === 1 ? currentSong : nextSong,
|
player1,
|
||||||
player2: state.player.playerNum === 2 ? currentSong : nextSong,
|
player2,
|
||||||
previousSong,
|
previousSong,
|
||||||
queueLength: state.queue.default.length,
|
queueLength: state.queue.default.length,
|
||||||
status: state.player.status,
|
status: state.player.status,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { subscribeWithSelector } from 'zustand/middleware';
|
import { del, get, set } from 'idb-keyval';
|
||||||
|
import { persist, subscribeWithSelector } from 'zustand/middleware';
|
||||||
import { createWithEqualityFn } from 'zustand/traditional';
|
import { createWithEqualityFn } from 'zustand/traditional';
|
||||||
|
|
||||||
interface TimestampState {
|
interface TimestampState {
|
||||||
@@ -6,13 +7,36 @@ interface TimestampState {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timestampStorage = {
|
||||||
|
getItem: async (name: string) => {
|
||||||
|
const value = await get(name);
|
||||||
|
if (value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { state: { timestamp: value }, version: 1 } as const;
|
||||||
|
},
|
||||||
|
removeItem: async (name: string) => {
|
||||||
|
await del(name);
|
||||||
|
},
|
||||||
|
setItem: async (name: string, value: { state: { timestamp: number }; version?: number }) => {
|
||||||
|
await set(name, value.state.timestamp);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const useTimestampStoreBase = createWithEqualityFn<TimestampState>()(
|
export const useTimestampStoreBase = createWithEqualityFn<TimestampState>()(
|
||||||
subscribeWithSelector((set) => ({
|
persist(
|
||||||
setTimestamp: (timestamp: number) => {
|
subscribeWithSelector((set) => ({
|
||||||
set({ timestamp });
|
setTimestamp: (timestamp: number) => {
|
||||||
|
set({ timestamp });
|
||||||
|
},
|
||||||
|
timestamp: 0,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
name: 'player-timestamp',
|
||||||
|
storage: timestampStorage,
|
||||||
|
version: 1,
|
||||||
},
|
},
|
||||||
timestamp: 0,
|
),
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const subscribePlayerProgress = (
|
export const subscribePlayerProgress = (
|
||||||
|
|||||||
@@ -454,6 +454,7 @@ const similarSongs2 = z.object({
|
|||||||
export enum SubsonicExtensions {
|
export enum SubsonicExtensions {
|
||||||
FORM_POST = 'formPost',
|
FORM_POST = 'formPost',
|
||||||
INDEX_BASED_QUEUE = 'indexBasedQueue',
|
INDEX_BASED_QUEUE = 'indexBasedQueue',
|
||||||
|
PLAYBACK_REPORT = 'playbackReport',
|
||||||
SONG_LYRICS = 'songLyrics',
|
SONG_LYRICS = 'songLyrics',
|
||||||
TRANSCODE_OFFSET = 'transcodeOffset',
|
TRANSCODE_OFFSET = 'transcodeOffset',
|
||||||
TRANSCODING = 'transcoding',
|
TRANSCODING = 'transcoding',
|
||||||
@@ -793,6 +794,17 @@ const getInternetRadioStations = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const reportPlaybackParameters = z.object({
|
||||||
|
ignoreScrobble: z.boolean().optional(),
|
||||||
|
mediaId: z.string(),
|
||||||
|
mediaType: z.enum(['song', 'podcast']),
|
||||||
|
playbackRate: z.number().optional(),
|
||||||
|
positionMs: z.number(),
|
||||||
|
state: z.enum(['starting', 'playing', 'paused', 'stopped']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportPlayback = z.null();
|
||||||
|
|
||||||
export const ssType = {
|
export const ssType = {
|
||||||
_body: {
|
_body: {
|
||||||
getTranscodeDecision: transcodeDecisionRequestBody,
|
getTranscodeDecision: transcodeDecisionRequestBody,
|
||||||
@@ -824,6 +836,7 @@ export const ssType = {
|
|||||||
getTranscodeStream: getTranscodeStreamParameters,
|
getTranscodeStream: getTranscodeStreamParameters,
|
||||||
randomSongList: randomSongListParameters,
|
randomSongList: randomSongListParameters,
|
||||||
removeFavorite: removeFavoriteParameters,
|
removeFavorite: removeFavoriteParameters,
|
||||||
|
reportPlayback: reportPlaybackParameters,
|
||||||
savePlayQueueByIndex: savePlayQueueByIndexParameters,
|
savePlayQueueByIndex: savePlayQueueByIndexParameters,
|
||||||
saveQueue: saveQueueParameters,
|
saveQueue: saveQueueParameters,
|
||||||
scrobble: scrobbleParameters,
|
scrobble: scrobbleParameters,
|
||||||
@@ -877,6 +890,7 @@ export const ssType = {
|
|||||||
playQueueByIndex,
|
playQueueByIndex,
|
||||||
randomSongList,
|
randomSongList,
|
||||||
removeFavorite,
|
removeFavorite,
|
||||||
|
reportPlayback,
|
||||||
saveQueue,
|
saveQueue,
|
||||||
scrobble,
|
scrobble,
|
||||||
search3,
|
search3,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
LuExternalLink,
|
LuExternalLink,
|
||||||
LuFileJson,
|
LuFileJson,
|
||||||
LuFlag,
|
LuFlag,
|
||||||
|
LuFolderClosed,
|
||||||
LuFolderOpen,
|
LuFolderOpen,
|
||||||
LuGauge,
|
LuGauge,
|
||||||
LuGithub,
|
LuGithub,
|
||||||
@@ -278,6 +279,7 @@ export const AppIcon = {
|
|||||||
fileJson: LuFileJson,
|
fileJson: LuFileJson,
|
||||||
filter: LuListFilter,
|
filter: LuListFilter,
|
||||||
folder: LuFolderOpen,
|
folder: LuFolderOpen,
|
||||||
|
folderClosed: LuFolderClosed,
|
||||||
genre: LuFlag,
|
genre: LuFlag,
|
||||||
goToItem: LuCornerDownRight,
|
goToItem: LuCornerDownRight,
|
||||||
hash: LuHash,
|
hash: LuHash,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function BaseImage({
|
|||||||
enableAnimation = false,
|
enableAnimation = false,
|
||||||
enableDebounce = false,
|
enableDebounce = false,
|
||||||
enableViewport = true,
|
enableViewport = true,
|
||||||
fetchPriority,
|
fetchPriority = 'low',
|
||||||
imageContainerProps,
|
imageContainerProps,
|
||||||
imageRequest,
|
imageRequest,
|
||||||
includeLoader = true,
|
includeLoader = true,
|
||||||
|
|||||||
@@ -1365,6 +1365,8 @@ export type ScrobbleQuery = {
|
|||||||
albumId?: string;
|
albumId?: string;
|
||||||
event?: 'pause' | 'start' | 'timeupdate' | 'unpause';
|
event?: 'pause' | 'start' | 'timeupdate' | 'unpause';
|
||||||
id: string;
|
id: string;
|
||||||
|
mediaType: 'podcast' | 'song';
|
||||||
|
playbackRate: number;
|
||||||
position?: number;
|
position?: number;
|
||||||
submission: boolean;
|
submission: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export enum ServerFeature {
|
|||||||
PLAYLIST_IMAGE_UPLOAD = 'playlistImageUpload',
|
PLAYLIST_IMAGE_UPLOAD = 'playlistImageUpload',
|
||||||
PLAYLISTS_SMART = 'playlistsSmart',
|
PLAYLISTS_SMART = 'playlistsSmart',
|
||||||
PUBLIC_PLAYLIST = 'publicPlaylist',
|
PUBLIC_PLAYLIST = 'publicPlaylist',
|
||||||
|
REPORT_PLAYBACK = 'reportPlayback',
|
||||||
SERVER_PLAY_QUEUE = 'serverPlayQueue',
|
SERVER_PLAY_QUEUE = 'serverPlayQueue',
|
||||||
SHARING_ALBUM_SONG = 'sharingAlbumSong',
|
SHARING_ALBUM_SONG = 'sharingAlbumSong',
|
||||||
SIMILAR_SONGS_MUSIC_FOLDER = 'similarSongsMusicFolder',
|
SIMILAR_SONGS_MUSIC_FOLDER = 'similarSongsMusicFolder',
|
||||||
|
|||||||
Reference in New Issue
Block a user