mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-23 10:56:28 +02:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 61c8e996d3 | |||
| ed80f06e72 | |||
| adc9e30266 | |||
| fcd4c27fb8 | |||
| c4ef6f3799 | |||
| 073b482fed | |||
| 74939c6417 | |||
| 27a62a2a02 | |||
| ffe59b2c78 | |||
| 4226da94ec | |||
| 6e634972c9 | |||
| 984b85e0e9 | |||
| 9bb38e1df3 | |||
| 4ecd8271a2 | |||
| ce7a319d2f | |||
| 1616021451 | |||
| e2a1d813a9 |
@@ -43,7 +43,13 @@ These variables override app settings **on first run** when no persisted setting
|
||||
| `general.showVisualizerInSidebar` | `true` | `FS_GENERAL_SHOW_VISUALIZER_IN_SIDEBAR` | `true` / `false` — Show visualizer in sidebar. |
|
||||
| `general.sidebarCollapsedNavigation` | `true` | `FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION` | `true` / `false` — Start with collapsed sidebar nav. |
|
||||
| `general.sidebarCollapseShared` | `false` | `FS_GENERAL_SIDEBAR_COLLAPSE_SHARED` | `true` / `false` — Share sidebar collapse state. |
|
||||
| `general.sidebarPlaylistFolders` | `true` | `FS_GENERAL_SIDEBAR_PLAYLIST_FOLDERS` | `true` / `false` — Group playlists into folders by name separator. |
|
||||
| `general.sidebarPlaylistFolderSeparator` | `/` | `FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_SEPARATOR` | Character or string that separates folder levels in a playlist name. Empty = use default. |
|
||||
| `general.sidebarPlaylistFolderTreeIndent` | `16` | `FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_INDENT` | Pixels each tree level is indented (0–64). |
|
||||
| `general.sidebarPlaylistFolderTreeLineColor` | *(empty)* | `FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_LINE_COLOR` | CSS color for tree connecting lines. Empty = theme default. |
|
||||
| `general.sidebarPlaylistFolderView` | `tree` | `FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_VIEW` | `single` / `tree` / `navigation` — How folders are displayed in the sidebar. |
|
||||
| `general.sidebarPlaylistList` | `true` | `FS_GENERAL_SIDEBAR_PLAYLIST_LIST` | `true` / `false` — Show playlist list in sidebar. |
|
||||
| `general.sidebarPlaylistMode` | `expanded` | `FS_GENERAL_SIDEBAR_PLAYLIST_MODE` | `compact` / `expanded` — Sidebar playlist row layout. |
|
||||
| `general.sidebarPlaylistSorting` | `false` | `FS_GENERAL_SIDEBAR_PLAYLIST_SORTING` | `true` / `false` — Enable playlist sorting in sidebar. |
|
||||
| `general.sideQueueType` | `sideQueue` | `FS_GENERAL_SIDE_QUEUE_TYPE` | `sideDrawerQueue` / `sideQueue` — Side play queue style. |
|
||||
| `general.sideQueueLayout` | `horizontal` | `FS_GENERAL_SIDE_QUEUE_LAYOUT` | `horizontal` / `vertical` — Attached side queue layout orientation. |
|
||||
@@ -66,6 +72,9 @@ These variables override app settings **on first run** when no persisted setting
|
||||
| `playback.scrobble.scrobbleAtDuration` | `240` | `FS_PLAYBACK_SCROBBLE_AT_DURATION` | Seconds of playback before scrobble. |
|
||||
| `playback.scrobble.scrobbleAtPercentage` | `75` | `FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE` | Percentage of track before scrobble. |
|
||||
| `playback.transcode.enabled` | `false` | `FS_PLAYBACK_TRANSCODE_ENABLED` | `true` / `false` — Enable transcoding. |
|
||||
| `playback.transcode.format` | *(unset)* | `FS_PLAYBACK_TRANSCODE_FORMAT` | Transcode format string (codec/container), e.g. server-specific value. Empty = use default. |
|
||||
| `playback.transcode.bitrate` | *(unset)* | `FS_PLAYBACK_TRANSCODE_BITRATE` | Transcode bitrate (number, kbps or as defined by server). |
|
||||
| `playback.filters` | `[]` | `FS_PLAYBACK_FILTERS` | JSON array of player filters: each object needs `id`, `field`, `operator`, `value`; optional `isEnabled`. Invalid JSON or shape is ignored. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
appId: org.jeffvli.feishin
|
||||
productName: Feishin
|
||||
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||
electronVersion: 39.4.0
|
||||
electronVersion: 41.7.0
|
||||
directories:
|
||||
buildResources: assets
|
||||
files:
|
||||
@@ -47,7 +47,7 @@ mac:
|
||||
gatekeeperAssess: false
|
||||
notarize: false
|
||||
extendInfo:
|
||||
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
|
||||
NSAudioCaptureUsageDescription: 'System audio access is required for mpv visualizer capture in Feishin'
|
||||
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
|
||||
|
||||
dmg:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
appId: org.jeffvli.feishin
|
||||
productName: Feishin
|
||||
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||
electronVersion: 39.4.0
|
||||
electronVersion: 41.7.0
|
||||
directories:
|
||||
buildResources: assets
|
||||
files:
|
||||
@@ -47,7 +47,7 @@ mac:
|
||||
gatekeeperAssess: false
|
||||
notarize: false
|
||||
extendInfo:
|
||||
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
|
||||
NSAudioCaptureUsageDescription: 'System audio access is required for mpv visualizer capture in Feishin'
|
||||
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
|
||||
|
||||
dmg:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
appId: org.jeffvli.feishin
|
||||
productName: Feishin
|
||||
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||
electronVersion: 39.4.0
|
||||
electronVersion: 41.7.0
|
||||
directories:
|
||||
buildResources: assets
|
||||
files:
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^39.8.6",
|
||||
"electron": "^41.7.0",
|
||||
"electron-builder": "^26.8.2",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electron-vite": "^4.0.1",
|
||||
|
||||
Generated
+13
-25
@@ -23,10 +23,10 @@ importers:
|
||||
version: 1.1.0
|
||||
'@electron-toolkit/preload':
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2(electron@39.8.6)
|
||||
version: 3.0.2(electron@41.7.0)
|
||||
'@electron-toolkit/utils':
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0(electron@39.8.6)
|
||||
version: 4.0.0(electron@41.7.0)
|
||||
'@mantine/colors-generator':
|
||||
specifier: ^9.1.1
|
||||
version: 9.1.1(chroma-js@3.1.2)
|
||||
@@ -269,8 +269,8 @@ importers:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0
|
||||
electron:
|
||||
specifier: ^39.8.6
|
||||
version: 39.8.6
|
||||
specifier: ^41.7.0
|
||||
version: 41.7.0
|
||||
electron-builder:
|
||||
specifier: ^26.8.2
|
||||
version: 26.8.2(electron-builder-squirrel-windows@26.8.2)
|
||||
@@ -2126,9 +2126,6 @@ packages:
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/node@22.19.17':
|
||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
||||
|
||||
'@types/node@24.12.2':
|
||||
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
|
||||
|
||||
@@ -3001,8 +2998,8 @@ packages:
|
||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
electron@39.8.6:
|
||||
resolution: {integrity: sha512-uWX6Jh5LmwL13VwOSKBjebI+ck+03GOwc8V2Sgbmr9pJVJ/cHfli/PkjXuRDr+hq+SLHQuT9mGHSIfScebApRA==}
|
||||
electron@41.7.0:
|
||||
resolution: {integrity: sha512-U6KAKivjk6YQ0Z5+eloJBjwhbHRE206gvy1UBMw2bSluWtMh5waeXMvX6AT/Ujm5ymYXVJOp7g9N7vOFw16wBQ==}
|
||||
engines: {node: '>= 12.20.55'}
|
||||
hasBin: true
|
||||
|
||||
@@ -5507,9 +5504,6 @@ packages:
|
||||
underscore.string@3.3.6:
|
||||
resolution: {integrity: sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
@@ -6684,17 +6678,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@electron-toolkit/preload@3.0.2(electron@39.8.6)':
|
||||
'@electron-toolkit/preload@3.0.2(electron@41.7.0)':
|
||||
dependencies:
|
||||
electron: 39.8.6
|
||||
electron: 41.7.0
|
||||
|
||||
'@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)':
|
||||
dependencies:
|
||||
'@types/node': 24.12.2
|
||||
|
||||
'@electron-toolkit/utils@4.0.0(electron@39.8.6)':
|
||||
'@electron-toolkit/utils@4.0.0(electron@41.7.0)':
|
||||
dependencies:
|
||||
electron: 39.8.6
|
||||
electron: 41.7.0
|
||||
|
||||
'@electron/asar@3.4.1':
|
||||
dependencies:
|
||||
@@ -7675,7 +7669,7 @@ snapshots:
|
||||
|
||||
'@types/electron-localshortcut@3.1.3':
|
||||
dependencies:
|
||||
electron: 39.8.6
|
||||
electron: 41.7.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7703,10 +7697,6 @@ snapshots:
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/node@22.19.17':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@24.12.2':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
@@ -8800,10 +8790,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron@39.8.6:
|
||||
electron@41.7.0:
|
||||
dependencies:
|
||||
'@electron/get': 2.0.3
|
||||
'@types/node': 22.19.17
|
||||
'@types/node': 24.12.2
|
||||
extract-zip: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -11588,8 +11578,6 @@ snapshots:
|
||||
sprintf-js: 1.1.3
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
undici@6.24.1: {}
|
||||
|
||||
@@ -38,7 +38,13 @@ window.FS_GENERAL_SHOW_RATINGS = "${FS_GENERAL_SHOW_RATINGS}";
|
||||
window.FS_GENERAL_SHOW_VISUALIZER_IN_SIDEBAR = "${FS_GENERAL_SHOW_VISUALIZER_IN_SIDEBAR}";
|
||||
window.FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION = "${FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION}";
|
||||
window.FS_GENERAL_SIDEBAR_COLLAPSE_SHARED = "${FS_GENERAL_SIDEBAR_COLLAPSE_SHARED}";
|
||||
window.FS_GENERAL_SIDEBAR_PLAYLIST_FOLDERS = "${FS_GENERAL_SIDEBAR_PLAYLIST_FOLDERS}";
|
||||
window.FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_SEPARATOR = "${FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_SEPARATOR}";
|
||||
window.FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_INDENT = "${FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_INDENT}";
|
||||
window.FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_LINE_COLOR = "${FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_LINE_COLOR}";
|
||||
window.FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_VIEW = "${FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_VIEW}";
|
||||
window.FS_GENERAL_SIDEBAR_PLAYLIST_LIST = "${FS_GENERAL_SIDEBAR_PLAYLIST_LIST}";
|
||||
window.FS_GENERAL_SIDEBAR_PLAYLIST_MODE = "${FS_GENERAL_SIDEBAR_PLAYLIST_MODE}";
|
||||
window.FS_GENERAL_SIDEBAR_PLAYLIST_SORTING = "${FS_GENERAL_SIDEBAR_PLAYLIST_SORTING}";
|
||||
window.FS_GENERAL_SIDE_QUEUE_TYPE = "${FS_GENERAL_SIDE_QUEUE_TYPE}";
|
||||
window.FS_GENERAL_SIDE_QUEUE_LAYOUT = "${FS_GENERAL_SIDE_QUEUE_LAYOUT}";
|
||||
@@ -58,6 +64,9 @@ window.FS_PLAYBACK_SCROBBLE_NOTIFY = "${FS_PLAYBACK_SCROBBLE_NOTIFY}";
|
||||
window.FS_PLAYBACK_SCROBBLE_AT_DURATION = "${FS_PLAYBACK_SCROBBLE_AT_DURATION}";
|
||||
window.FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE = "${FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE}";
|
||||
window.FS_PLAYBACK_TRANSCODE_ENABLED = "${FS_PLAYBACK_TRANSCODE_ENABLED}";
|
||||
window.FS_PLAYBACK_TRANSCODE_FORMAT = "${FS_PLAYBACK_TRANSCODE_FORMAT}";
|
||||
window.FS_PLAYBACK_TRANSCODE_BITRATE = "${FS_PLAYBACK_TRANSCODE_BITRATE}";
|
||||
window.FS_PLAYBACK_FILTERS = "${FS_PLAYBACK_FILTERS}";
|
||||
|
||||
window.FS_DISCORD_ENABLED = "${FS_DISCORD_ENABLED}";
|
||||
window.FS_DISCORD_CLIENT_ID = "${FS_DISCORD_CLIENT_ID}";
|
||||
|
||||
+11
-11
@@ -344,30 +344,30 @@
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Àlbum",
|
||||
"album_many": "àlbums",
|
||||
"album_many": "Àlbums",
|
||||
"album_other": "Àlbums",
|
||||
"albumWithCount_one": "{{count}} àlbum",
|
||||
"albumWithCount_many": "{{count}} àlbums",
|
||||
"albumWithCount_other": "{{count}} àlbums",
|
||||
"albumArtist_one": "Artista de l'àlbum",
|
||||
"albumArtist_many": "artistes de l'àlbum",
|
||||
"albumArtist_many": "Artistes de l'àlbum",
|
||||
"albumArtist_other": "Artistes de l'àlbum",
|
||||
"albumArtistCount_one": "{{count}} artista de l'àlbum",
|
||||
"albumArtistCount_many": "{{count}} artistes de l'àlbum",
|
||||
"albumArtistCount_other": "{{count}} artistes de l'àlbum",
|
||||
"artist_one": "Artista",
|
||||
"artist_many": "artistes",
|
||||
"artist_many": "Artistes",
|
||||
"artist_other": "Artistes",
|
||||
"artistWithCount_one": "{{count}} artista",
|
||||
"artistWithCount_many": "{{count}} artistes",
|
||||
"artistWithCount_other": "{{count}} artistes",
|
||||
"playlist_one": "Llista de reproducció",
|
||||
"playlist_many": "llistes de reproducció",
|
||||
"playlist_many": "Llistes de reproducció",
|
||||
"playlist_other": "Llistes de reproducció",
|
||||
"playlistWithCount_one": "{{count}} llista de reproducció",
|
||||
"playlistWithCount_many": "{{count}} llistes de reproducció",
|
||||
"playlistWithCount_other": "{{count}} llistes de reproducció",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) intel·ligent",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Intel·ligent",
|
||||
"play_one": "{{count}} reproducció",
|
||||
"play_many": "{{count}} reproduccions",
|
||||
"play_other": "{{count}} reproduccions",
|
||||
@@ -378,25 +378,25 @@
|
||||
"genreWithCount_many": "{{count}} gèneres",
|
||||
"genreWithCount_other": "{{count}} gèneres",
|
||||
"track_one": "Pista",
|
||||
"track_many": "pistes",
|
||||
"track_many": "Pistes",
|
||||
"track_other": "Pistes",
|
||||
"trackWithCount_one": "{{count}} pista",
|
||||
"trackWithCount_many": "{{count}} pistes",
|
||||
"trackWithCount_other": "{{count}} pistes",
|
||||
"folder_one": "Carpeta",
|
||||
"folder_many": "carpetes",
|
||||
"folder_many": "Carpetes",
|
||||
"folder_other": "Carpetes",
|
||||
"genre_one": "Gènere",
|
||||
"genre_many": "gèneres",
|
||||
"genre_many": "Gèneres",
|
||||
"genre_other": "Gèneres",
|
||||
"song_one": "Cançó",
|
||||
"song_many": "cançons",
|
||||
"song_many": "Cançons",
|
||||
"song_other": "Cançons",
|
||||
"favorite_one": "Preferit",
|
||||
"favorite_many": "preferits",
|
||||
"favorite_many": "Preferits",
|
||||
"favorite_other": "Preferits",
|
||||
"radioStation_one": "Emissora de ràdio",
|
||||
"radioStation_many": "emissores de ràdio",
|
||||
"radioStation_many": "Emissores de ràdio",
|
||||
"radioStation_other": "Emissores de ràdio",
|
||||
"radioStationWithCount_one": "{{count}} emissora de ràdio",
|
||||
"radioStationWithCount_many": "{{count}} emissores de ràdio",
|
||||
|
||||
+44
-22
@@ -48,7 +48,8 @@
|
||||
"sleepTimer_timeRemaining": "Zbývá {{time}}",
|
||||
"sleepTimer_setCustom": "Nastavit časovač",
|
||||
"sleepTimer_cancel": "Zrušit časovač",
|
||||
"albumRadio": "Rádio alba"
|
||||
"albumRadio": "Rádio alba",
|
||||
"scrobbleForceSubmit": "Vynutit scrobble"
|
||||
},
|
||||
"setting": {
|
||||
"crossfadeStyle_description": "Vyberte způsob prolnutí u přehrávače zvuku",
|
||||
@@ -246,7 +247,7 @@
|
||||
"albumBackground_description": "Přidá obrázek alba na pozadí pro stránky alba obsahující obrázky alba",
|
||||
"albumBackgroundBlur": "Velikost rozostření obrázku alba na pozadí",
|
||||
"albumBackgroundBlur_description": "Upraví množství rozostření použité na obrázek alba na pozadí",
|
||||
"playerbarOpenDrawer": "Lišta přehrávače jako přEPínač celé obrazovky",
|
||||
"playerbarOpenDrawer": "Lišta přehrávače jako přepínač celé obrazovky",
|
||||
"playerbarOpenDrawer_description": "Umožňuje kliknutí na lištu přehrávače pro otevření celoobrazovkového přehrávače",
|
||||
"artistConfiguration": "Nastavení stránky umělce alba",
|
||||
"artistConfiguration_description": "Nastavit, které položky na stránce umělce alba budou zobrazeny a v jakém pořadí",
|
||||
@@ -429,7 +430,25 @@
|
||||
"playerbarWaveformStretch": "Natáhnutí vlnové křivky",
|
||||
"playerbarWaveformStretch_description": "Natáhně vlnovou křivku tak, aby vyplnila dostupný prostor",
|
||||
"preventSuspendOnPlayback_description": "Zabránit aplikaci v uspání během přehrávání hudby",
|
||||
"preventSuspendOnPlayback": "Zabránit uspání při přehrávání"
|
||||
"preventSuspendOnPlayback": "Zabránit uspání při přehrávání",
|
||||
"hotkey_listShowPlayingSong": "Zobrazit přehrávanou skladbu v seznamu",
|
||||
"sidebarPlaylistFolders_description": "Vytvořit zobrazení složky pro seznamy skadeb, které zahrnují nastavený oddělovač v názvu",
|
||||
"sidebarPlaylistFolders": "Povolit složky",
|
||||
"sidebarPlaylistFolderSeparator_description": "Znak (nebo řetězec) oddělující úrovně složek v názvu seznamu skladeb",
|
||||
"sidebarPlaylistFolderSeparator": "Oddělovač složek",
|
||||
"sidebarPlaylistFolderView_description": "Jak jsou složky zobrazeny v postranní liště",
|
||||
"sidebarPlaylistFolderView": "Zobrazení složky",
|
||||
"sidebarPlaylistFolderView_optionSingle": "Jedna složka",
|
||||
"sidebarPlaylistFolderView_optionTree": "Stromové zobrazení",
|
||||
"sidebarPlaylistFolderView_optionNavigation": "Zobrazení navigace",
|
||||
"sidebarPlaylistFolderTreeIndent_description": "Počet pixelů, o který je odsazená každá větev",
|
||||
"sidebarPlaylistFolderTreeIndent": "Odsazení větví",
|
||||
"sidebarPlaylistFolderTreeLineColor_description": "Barva spojujících linek větví (ponechte prázdné pro výchozí)",
|
||||
"sidebarPlaylistFolderTreeLineColor": "Barva linky stromu",
|
||||
"sidebarPlaylistMode_description": "Jak je každý seznam skladeb zobrazen v seznamu v postranní liště",
|
||||
"sidebarPlaylistMode": "Režim seznamů skladeb v postranní liště",
|
||||
"sidebarPlaylistMode_optionCompact": "Kompaktní",
|
||||
"sidebarPlaylistMode_optionExpanded": "Rozšířený"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "Upravit $t(entity.playlist, {\"count\": 1})",
|
||||
@@ -473,7 +492,9 @@
|
||||
"addOrRemoveFromSelection": "Přidat nebo odebrat z výběru",
|
||||
"selectRangeOfItems": "Vyberte rozsah položek",
|
||||
"selectAll": "Vybrat vše",
|
||||
"goToCurrent": "Přejít na aktuální položku"
|
||||
"goToCurrent": "Přejít na aktuální položku",
|
||||
"collapseAllFolders": "Sbalit všechny složky",
|
||||
"expandAllFolders": "Rozbalit všechny složky"
|
||||
},
|
||||
"common": {
|
||||
"backward": "Zpátky",
|
||||
@@ -602,7 +623,8 @@
|
||||
"rename": "Přejmenovat",
|
||||
"newVersionAvailable": "Je dostupná nová verze",
|
||||
"numberOfResults": "{{numberOfResults}} výsledků",
|
||||
"grouping": "Seskupování"
|
||||
"grouping": "Seskupování",
|
||||
"back": "Zpět"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -689,7 +711,7 @@
|
||||
"rating": "Hodnocení",
|
||||
"favorite": "Oblíbené",
|
||||
"playCount": "Přehrání",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||
"albumCount": "Alba",
|
||||
"releaseYear": "Rok",
|
||||
"lastPlayed": "Naposledy přehráno",
|
||||
"biography": "Biografie",
|
||||
@@ -697,20 +719,20 @@
|
||||
"bitrate": "Datový tok",
|
||||
"title": "Název",
|
||||
"bpm": "BPM",
|
||||
"dateAdded": "Datum přIDání",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
||||
"dateAdded": "Datum přidání",
|
||||
"artist": "Umělec",
|
||||
"songCount": "Skladby",
|
||||
"trackNumber": "Skladba",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"genre": "Žánr",
|
||||
"albumArtist": "Umělec alba",
|
||||
"path": "Cesta",
|
||||
"discNumber": "Disk",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"size": "$t(common.size)",
|
||||
"codec": "$t(common.codec)",
|
||||
"channels": "Kanály",
|
||||
"size": "Velikost",
|
||||
"codec": "Kodek",
|
||||
"owner": "Majitel",
|
||||
"bitDepth": "$t(common.bitDepth)",
|
||||
"sampleRate": "$t(common.sampleRate)"
|
||||
"bitDepth": "Bitová hloubka",
|
||||
"sampleRate": "Vzorkovací frekvence"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -762,10 +784,10 @@
|
||||
"search": "Hledat",
|
||||
"bitrate": "Datový tok",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"recentlyAdded": "Nedávno přIDáno",
|
||||
"recentlyAdded": "Nedávno přidáno",
|
||||
"note": "Poznámka",
|
||||
"name": "Název",
|
||||
"dateAdded": "Datum přIDání",
|
||||
"dateAdded": "Datum přidání",
|
||||
"releaseDate": "Datum vydání",
|
||||
"albumCount": "Počet $t(entity.album, {\"count\": 2})",
|
||||
"communityRating": "Komunitní hodnocení",
|
||||
@@ -887,7 +909,7 @@
|
||||
},
|
||||
"home": {
|
||||
"mostPlayed": "Nejpřehrávanější",
|
||||
"newlyAdded": "Nově přIDáno",
|
||||
"newlyAdded": "Nově přidáno",
|
||||
"title": "$t(common.home)",
|
||||
"explore": "Procházet z vaší knihovny",
|
||||
"recentlyPlayed": "Nedávno přehráno",
|
||||
@@ -1030,7 +1052,7 @@
|
||||
"input_password": "Heslo",
|
||||
"input_legacyAuthentication": "Zapnout zastaralé ověřování",
|
||||
"input_name": "Název serveru",
|
||||
"success": "Server úspěšně přIDán",
|
||||
"success": "Server úspěšně přidán",
|
||||
"input_savePassword": "Uložit heslo",
|
||||
"ignoreSsl": "Ignorovat ssl $t(common.restartRequired)",
|
||||
"ignoreCors": "Ignorovat cors $t(common.restartRequired)",
|
||||
@@ -1042,7 +1064,7 @@
|
||||
"input_remoteUrlPlaceholder": "Volitelné: veřejná adresa url pro externí funkce"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "PřIDáno $t(entity.trackWithCount, {\"count\": {{message}} }) do $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||
"success": "$t(entity.trackWithCount, {\"count\": {{message}} }) přidáno do $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||
"title": "Přidat do $t(entity.playlist, {\"count\": 1})",
|
||||
"input_skipDuplicates": "Přeskočit duplicity",
|
||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||
@@ -1139,7 +1161,7 @@
|
||||
"folderWithCount_few": "{{count}} složky",
|
||||
"folderWithCount_other": "{{count}} složek",
|
||||
"albumArtist_one": "Umělec alba",
|
||||
"albumArtist_few": "umělci alb",
|
||||
"albumArtist_few": "Umělci alb",
|
||||
"albumArtist_other": "Umělci alb",
|
||||
"track_one": "Skladba",
|
||||
"track_few": "skladby",
|
||||
@@ -1176,7 +1198,7 @@
|
||||
"song_few": "písničky",
|
||||
"song_other": "Písní",
|
||||
"radioStation_one": "Stanice rádia",
|
||||
"radioStation_few": "stanice rádia",
|
||||
"radioStation_few": "Stanice rádia",
|
||||
"radioStation_other": "Stanice rádia",
|
||||
"radioStationWithCount_one": "{{count}} stanice rádia",
|
||||
"radioStationWithCount_few": "{{count}} stanice rádia",
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"selectRangeOfItems": "Select a range of items",
|
||||
"clearQueue": "Clear queue",
|
||||
"goToCurrent": "Go to current item",
|
||||
"collapseAllFolders": "Collapse all folders",
|
||||
"expandAllFolders": "Expand all folders",
|
||||
"createPlaylist": "Create $t(entity.playlist, {\"count\": 1})",
|
||||
"createRadioStation": "Create $t(entity.radioStation, {\"count\": 1})",
|
||||
"deletePlaylist": "Delete $t(entity.playlist, {\"count\": 1})",
|
||||
@@ -56,6 +58,7 @@
|
||||
"albumPeak": "Album peak",
|
||||
"areYouSure": "Are you sure?",
|
||||
"ascending": "Ascending",
|
||||
"back": "Back",
|
||||
"backward": "Backward",
|
||||
"biography": "Biography",
|
||||
"bitDepth": "Bit depth",
|
||||
@@ -171,14 +174,14 @@
|
||||
"entity": {
|
||||
"album_one": "Album",
|
||||
"album_other": "Albums",
|
||||
"albumArtist_one": "Album artist",
|
||||
"albumArtist_other": "Album artists",
|
||||
"albumArtist_one": "Album Artist",
|
||||
"albumArtist_other": "Album Artists",
|
||||
"albumArtistCount_one": "{{count}} album artist",
|
||||
"albumArtistCount_other": "{{count}} album artists",
|
||||
"albumWithCount_one": "{{count}} album",
|
||||
"albumWithCount_other": "{{count}} albums",
|
||||
"radioStation_one": "Radio station",
|
||||
"radioStation_other": "Radio stations",
|
||||
"radioStation_one": "Radio Station",
|
||||
"radioStation_other": "Radio Stations",
|
||||
"radioStationWithCount_one": "{{count}} radio station",
|
||||
"radioStationWithCount_other": "{{count}} radio stations",
|
||||
"artist_one": "Artist",
|
||||
@@ -696,7 +699,8 @@
|
||||
"sleepTimer_off": "Off",
|
||||
"sleepTimer_timeRemaining": "{{time}} remaining",
|
||||
"sleepTimer_setCustom": "Set timer",
|
||||
"sleepTimer_cancel": "Cancel timer"
|
||||
"sleepTimer_cancel": "Cancel timer",
|
||||
"scrobbleForceSubmit": "Force scrobble"
|
||||
},
|
||||
"queryBuilder": {
|
||||
"standardTags": "Standard tags",
|
||||
@@ -873,6 +877,7 @@
|
||||
"hotkey_listPlayLast": "List play last",
|
||||
"hotkey_listPlayNext": "List play next",
|
||||
"hotkey_listPlayNow": "List play now",
|
||||
"hotkey_listShowPlayingSong": "Show playing song in list",
|
||||
"hotkey_navigateHome": "Navigate to home",
|
||||
"hotkey_playbackNext": "Next track",
|
||||
"hotkey_playbackPause": "Pause",
|
||||
@@ -1043,8 +1048,25 @@
|
||||
"sidebarConfiguration": "Sidebar configuration",
|
||||
"playerItemConfiguration_description": "Configure what items are shown, and in what order, on the fullscreen player",
|
||||
"playerItemConfiguration": "Player item configuration",
|
||||
"sidebarPlaylistFolders_description": "Create a folder view for playlists that include the configured separator in the name",
|
||||
"sidebarPlaylistFolders": "Enable folders",
|
||||
"sidebarPlaylistFolderSeparator_description": "Character (or string) that separates folder levels in a playlist name",
|
||||
"sidebarPlaylistFolderSeparator": "Folder separator",
|
||||
"sidebarPlaylistFolderView_description": "How folders are displayed in the sidebar",
|
||||
"sidebarPlaylistFolderView": "Folder view",
|
||||
"sidebarPlaylistFolderView_optionSingle": "Single folder",
|
||||
"sidebarPlaylistFolderView_optionTree": "Tree view",
|
||||
"sidebarPlaylistFolderView_optionNavigation": "Navigation view",
|
||||
"sidebarPlaylistFolderTreeIndent_description": "Pixels each tree level is indented",
|
||||
"sidebarPlaylistFolderTreeIndent": "Tree indent",
|
||||
"sidebarPlaylistFolderTreeLineColor_description": "Color of the connecting tree lines (leave empty for theme default)",
|
||||
"sidebarPlaylistFolderTreeLineColor": "Tree line color",
|
||||
"sidebarPlaylistList_description": "Show or hide the playlist list in the sidebar",
|
||||
"sidebarPlaylistList": "Sidebar playlist list",
|
||||
"sidebarPlaylistMode_description": "How each playlist is displayed in the sidebar list",
|
||||
"sidebarPlaylistMode": "Sidebar playlist mode",
|
||||
"sidebarPlaylistMode_optionCompact": "Compact",
|
||||
"sidebarPlaylistMode_optionExpanded": "Expanded",
|
||||
"sidebarPlaylistSorting_description": "Allows manual playlist sorting in the sidebar using drag and drop instead of the default server order",
|
||||
"sidebarPlaylistSorting": "Sidebar playlist sorting",
|
||||
"sidebarPlaylistListFilterRegex_description": "Hide playlists in the sidebar that match this regular expression",
|
||||
|
||||
+63
-41
@@ -48,7 +48,8 @@
|
||||
"sleepTimer_off": "Apagado",
|
||||
"sleepTimer_endOfSong": "Fin de la canción actual",
|
||||
"sleepTimer": "Temporizador de apagado",
|
||||
"albumRadio": "Radio del álbum"
|
||||
"albumRadio": "Radio del álbum",
|
||||
"scrobbleForceSubmit": "Forzar scrobble"
|
||||
},
|
||||
"setting": {
|
||||
"crossfadeStyle_description": "Selecciona el estilo de crossfade a usar por el reproductor de audio",
|
||||
@@ -263,7 +264,7 @@
|
||||
"lastfmApiKey": "Clave API para {{lastfm}}",
|
||||
"discordServeImage": "Servir imágenes de {{discord}} desde el servidor",
|
||||
"discordServeImage_description": "Comparte el arte de la portada para el estado de actividad de {{discord}} desde el propio servidor, solo disponible para Jellyfin y Navidrome. {{discord}} usa un bot para obtener las imágenes, por lo que tu servidor debe ser alcanzable desde el internet público",
|
||||
"lastfm": "Mostrar enlaces de last.fm",
|
||||
"lastfm": "Mostrar enlaces de Last.fm",
|
||||
"lastfm_description": "Muestra enlaces a Last.fm en las páginas de artistas/álbumes",
|
||||
"musicbrainz": "Mostrar enlaces de MusicBrainz",
|
||||
"musicbrainz_description": "Muestra enlaces a MusicBrainz en las páginas de artistas/álbumes, donde exista MusicBrainz ID",
|
||||
@@ -342,7 +343,7 @@
|
||||
"playerFilters": "Filtrar las canciones de la cola",
|
||||
"playerFilters_description": "Omite la adición de canciones a la cola basado en los siguientes criterios",
|
||||
"playerbarSlider_description": "La forma de onda no es recomendable en una conexión a Internet lenta o medida",
|
||||
"autoDJ": "DJ automático",
|
||||
"autoDJ": "DJ Automático",
|
||||
"autoDJ_description": "Añade canciones similares a las de la cola automáticamente",
|
||||
"autoDJ_itemCount": "Recuento de elementos",
|
||||
"autoDJ_itemCount_description": "El número de elementos que se ha intentado añadir a la cola cuando DJ automático está activado",
|
||||
@@ -429,7 +430,25 @@
|
||||
"playerbarWaveformStretch": "Estiramiento de la forma de onda",
|
||||
"playerbarWaveformStretch_description": "Estira la forma de onda para rellenar el espacio disponible",
|
||||
"preventSuspendOnPlayback": "Evitar la suspensión durante la reproducción",
|
||||
"preventSuspendOnPlayback_description": "Evita que la aplicación se suspenda mientras se reproduce música"
|
||||
"preventSuspendOnPlayback_description": "Evita que la aplicación se suspenda mientras se reproduce música",
|
||||
"hotkey_listShowPlayingSong": "Mostrar canción en reproducción en la lista",
|
||||
"sidebarPlaylistFolders": "Habilitar carpetas",
|
||||
"sidebarPlaylistFolders_description": "Crea una vista de carpeta para las listas de reproducción que incluye el separador configurado en el nombre",
|
||||
"sidebarPlaylistFolderSeparator_description": "Carácter (o cadena) que separa los niveles de la carpeta en un nombre de lista de reproducción",
|
||||
"sidebarPlaylistFolderSeparator": "Separador de carpeta",
|
||||
"sidebarPlaylistFolderView": "Vista de carpeta",
|
||||
"sidebarPlaylistFolderView_description": "Cómo se muestran las carpetas en la barra lateral",
|
||||
"sidebarPlaylistFolderView_optionSingle": "Carpeta simple",
|
||||
"sidebarPlaylistFolderView_optionTree": "Vista en árbol",
|
||||
"sidebarPlaylistFolderView_optionNavigation": "Vista de navegación",
|
||||
"sidebarPlaylistFolderTreeIndent": "Sangría del árbol",
|
||||
"sidebarPlaylistFolderTreeLineColor": "Color de línea del árbol",
|
||||
"sidebarPlaylistFolderTreeLineColor_description": "Color de las líneas interconectadas del árbol (dejar en blanco para usar el tema predeterminado)",
|
||||
"sidebarPlaylistMode": "Modo de lista de reproducción de la barra lateral",
|
||||
"sidebarPlaylistMode_optionCompact": "Compacto",
|
||||
"sidebarPlaylistMode_optionExpanded": "Expandido",
|
||||
"sidebarPlaylistMode_description": "Cómo se muestra cada lista de reproducción en la lista de la barra lateral",
|
||||
"sidebarPlaylistFolderTreeIndent_description": "Píxeles que está sangrado cada nivel del árbol"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "Editar $t(entity.playlist, {\"count\": 1})",
|
||||
@@ -473,7 +492,9 @@
|
||||
"addOrRemoveFromSelection": "Añadir o quitar de la selección",
|
||||
"selectRangeOfItems": "Seleccionar un intervalo de elementos",
|
||||
"selectAll": "Seleccionar todo",
|
||||
"goToCurrent": "Ir al elemento actual"
|
||||
"goToCurrent": "Ir al elemento actual",
|
||||
"collapseAllFolders": "Contraer todas las carpetas",
|
||||
"expandAllFolders": "Expandir todas las carpetas"
|
||||
},
|
||||
"common": {
|
||||
"backward": "Hacia atrás",
|
||||
@@ -533,7 +554,7 @@
|
||||
"comingSoon": "Próximamente…",
|
||||
"reset": "Restablecer",
|
||||
"disable": "Desactivar",
|
||||
"sortOrder": "Ordenar",
|
||||
"sortOrder": "Orden",
|
||||
"none": "Ninguno",
|
||||
"menu": "Menú",
|
||||
"restartRequired": "Reinicio requerido",
|
||||
@@ -602,7 +623,8 @@
|
||||
"rename": "Renombrar",
|
||||
"newVersionAvailable": "Una nueva versión está disponible",
|
||||
"numberOfResults": "{{numberOfResults}} resultados",
|
||||
"grouping": "Agrupar"
|
||||
"grouping": "Agrupar",
|
||||
"back": "Atrás"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "Reiniciar el servidor para aplicar el nuevo puerto",
|
||||
@@ -700,7 +722,7 @@
|
||||
"artists": "$t(entity.artist, {\"count\": 2})",
|
||||
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
|
||||
"shared": "Compartido $t(entity.playlist, {\"count\": 2})",
|
||||
"myLibrary": "Mi biblioteca",
|
||||
"myLibrary": "Mi Biblioteca",
|
||||
"favorites": "$t(entity.favorite, {\"count\": 2})",
|
||||
"radio": "$t(entity.radioStation, {\"count\": 2})",
|
||||
"collections": "Colecciones"
|
||||
@@ -761,7 +783,7 @@
|
||||
"genres": "$t(entity.genre, {\"count\": 2})"
|
||||
},
|
||||
"fullscreenPlayer": {
|
||||
"upNext": "Siguiente",
|
||||
"upNext": "A continuación",
|
||||
"config": {
|
||||
"dynamicBackground": "Fondo dinámico",
|
||||
"synchronized": "Sincronizado",
|
||||
@@ -800,7 +822,7 @@
|
||||
"updates": "Actualización",
|
||||
"cache": "Caché",
|
||||
"application": "Aplicación",
|
||||
"queryBuilder": "Generador de consultas",
|
||||
"queryBuilder": "Generador de Consultas",
|
||||
"theme": "Tema",
|
||||
"controls": "Controles",
|
||||
"remote": "Remoto",
|
||||
@@ -811,9 +833,9 @@
|
||||
"transcoding": "Transcodificación",
|
||||
"discord": "Discord",
|
||||
"sidebar": "Barra lateral",
|
||||
"playerFilters": "Filtros del reproductor",
|
||||
"playerFilters": "Filtros del Reproductor",
|
||||
"logger": "Registrador",
|
||||
"lyricsDisplay": "Mostrar letras"
|
||||
"lyricsDisplay": "Mostrar Letras"
|
||||
},
|
||||
"albumArtistList": {
|
||||
"title": "$t(entity.albumArtist, {\"count\": 2})"
|
||||
@@ -926,10 +948,10 @@
|
||||
"ignoreSsl": "Ignorar SSL ($t(common.restartRequired))",
|
||||
"ignoreCors": "Ignorar CORS ($t(common.restartRequired))",
|
||||
"error_savePassword": "Un error ocurrió cuando se intentó guardar la contraseña",
|
||||
"input_preferInstantMix": "Preferir mix instantáneo",
|
||||
"input_preferInstantMix": "Preferir Mix Instantáneo",
|
||||
"input_preferInstantMixDescription": "Usa solo el mix instantáneo para obtener canciones similares. Útil si tienes complementos que modifican este comportamiento",
|
||||
"input_remoteUrl": "URL pública",
|
||||
"input_preferRemoteUrl": "Preferir URL pública",
|
||||
"input_remoteUrl": "URL Pública",
|
||||
"input_preferRemoteUrl": "Preferir URL Pública",
|
||||
"input_remoteUrlPlaceholder": "Opcional: URL pública para características externas"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
@@ -1020,7 +1042,7 @@
|
||||
"album": "Álbum",
|
||||
"favorite": "Favorito",
|
||||
"playCount": "Reproducciones",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||
"albumCount": "Álbumes",
|
||||
"releaseYear": "Año",
|
||||
"lastPlayed": "Última reproducción",
|
||||
"biography": "Biografía",
|
||||
@@ -1029,19 +1051,19 @@
|
||||
"title": "Título",
|
||||
"bpm": "BPM",
|
||||
"dateAdded": "Fecha de adición",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
||||
"artist": "Artista",
|
||||
"songCount": "Pistas",
|
||||
"trackNumber": "Pista",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"genre": "Género",
|
||||
"albumArtist": "Artista del álbum",
|
||||
"path": "Ruta",
|
||||
"discNumber": "Disco",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"size": "$t(common.size)",
|
||||
"codec": "$t(common.codec)",
|
||||
"channels": "Canales",
|
||||
"size": "Tamaño",
|
||||
"codec": "Códecs",
|
||||
"owner": "Propietario",
|
||||
"bitDepth": "$t(common.bitDepth)",
|
||||
"sampleRate": "$t(common.sampleRate)"
|
||||
"bitDepth": "Profundidad de Bit",
|
||||
"sampleRate": "Frecuencia de Muestreo"
|
||||
},
|
||||
"config": {
|
||||
"label": {
|
||||
@@ -1080,7 +1102,7 @@
|
||||
"sampleRate": "$t(common.sampleRate)",
|
||||
"titleArtist": "$t(common.title) (artista)",
|
||||
"composer": "Compositor",
|
||||
"albumGroup": "Grupo del álbum"
|
||||
"albumGroup": "Grupo del Álbum"
|
||||
},
|
||||
"general": {
|
||||
"gap": "$t(common.gap)",
|
||||
@@ -1123,27 +1145,27 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) inteligente",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Inteligente",
|
||||
"genre_one": "Género",
|
||||
"genre_many": "géneros",
|
||||
"genre_many": "Géneros",
|
||||
"genre_other": "Géneros",
|
||||
"playlistWithCount_one": "{{count}} lista de reproducción",
|
||||
"playlistWithCount_many": "{{count}} listas de reproducción",
|
||||
"playlistWithCount_other": "{{count}} listas de reproducción",
|
||||
"playlist_one": "Lista de reproducción",
|
||||
"playlist_many": "listas de reproducción",
|
||||
"playlist_many": "Listas de reproducción",
|
||||
"playlist_other": "Listas de reproducción",
|
||||
"artist_one": "Artista",
|
||||
"artist_many": "artistas",
|
||||
"artist_many": "Artistas",
|
||||
"artist_other": "Artistas",
|
||||
"folderWithCount_one": "{{count}} carpeta",
|
||||
"folderWithCount_many": "{{count}} carpetas",
|
||||
"folderWithCount_other": "{{count}} carpetas",
|
||||
"albumArtist_one": "Artista del álbum",
|
||||
"albumArtist_many": "artistas del álbum",
|
||||
"albumArtist_many": "Artistas del álbum",
|
||||
"albumArtist_other": "Artistas del álbum",
|
||||
"track_one": "Pista",
|
||||
"track_many": "pistas",
|
||||
"track_many": "Pistas",
|
||||
"track_other": "Pistas",
|
||||
"albumArtistCount_one": "{{count}} artista del álbum",
|
||||
"albumArtistCount_many": "{{count}} artistas del álbum",
|
||||
@@ -1152,16 +1174,16 @@
|
||||
"albumWithCount_many": "{{count}} álbumes",
|
||||
"albumWithCount_other": "{{count}} álbumes",
|
||||
"favorite_one": "Favorito",
|
||||
"favorite_many": "favoritos",
|
||||
"favorite_many": "Favoritos",
|
||||
"favorite_other": "Favoritos",
|
||||
"artistWithCount_one": "{{count}} artista",
|
||||
"artistWithCount_many": "{{count}} artistas",
|
||||
"artistWithCount_other": "{{count}} artistas",
|
||||
"folder_one": "Carpeta",
|
||||
"folder_many": "carpetas",
|
||||
"folder_many": "Carpetas",
|
||||
"folder_other": "Carpetas",
|
||||
"album_one": "Álbum",
|
||||
"album_many": "álbumes",
|
||||
"album_many": "Álbumes",
|
||||
"album_other": "Álbumes",
|
||||
"genreWithCount_one": "{{count}} género",
|
||||
"genreWithCount_many": "{{count}} géneros",
|
||||
@@ -1173,11 +1195,11 @@
|
||||
"play_many": "{{count}} reproducciones",
|
||||
"play_other": "{{count}} reproducciones",
|
||||
"song_one": "Canción",
|
||||
"song_many": "canciones",
|
||||
"song_many": "Canciones",
|
||||
"song_other": "Canciones",
|
||||
"radioStation_one": "Estación de radio",
|
||||
"radioStation_many": "Estaciones de radio",
|
||||
"radioStation_other": "Estaciones de radio",
|
||||
"radioStation_one": "Estación de Radio",
|
||||
"radioStation_many": "Estaciones de Radio",
|
||||
"radioStation_other": "Estaciones de Radio",
|
||||
"radioStationWithCount_one": "{{count}} estación de radio",
|
||||
"radioStationWithCount_many": "{{count}} estaciones de radio",
|
||||
"radioStationWithCount_other": "{{count}} estaciones de radio"
|
||||
@@ -1197,16 +1219,16 @@
|
||||
},
|
||||
"secondary": {
|
||||
"audiobook": "Audiolibro",
|
||||
"audioDrama": "Audio drama",
|
||||
"audioDrama": "Audio Drama",
|
||||
"compilation": "Compilación",
|
||||
"djMix": "Mezcla del DJ",
|
||||
"fieldRecording": "Grabación de campo",
|
||||
"fieldRecording": "Grabación de Campo",
|
||||
"interview": "Entrevista",
|
||||
"live": "En vivo",
|
||||
"mixtape": "Recopilatorio",
|
||||
"remix": "Remix",
|
||||
"soundtrack": "Banda sonora",
|
||||
"spokenWord": "Palabra hablada",
|
||||
"spokenWord": "Palabra Hablada",
|
||||
"demo": "Maqueta"
|
||||
}
|
||||
},
|
||||
|
||||
+40
-18
@@ -48,7 +48,8 @@
|
||||
"sleepTimer_timeRemaining": "{{time}} restante(s)",
|
||||
"sleepTimer_setCustom": "Définir le minuteur",
|
||||
"sleepTimer_cancel": "Annuler le minuteur",
|
||||
"albumRadio": "Radio d'album"
|
||||
"albumRadio": "Radio d'album",
|
||||
"scrobbleForceSubmit": "Forcer le scrobble"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "Éditer $t(entity.playlist, {\"count\": 1})",
|
||||
@@ -92,7 +93,9 @@
|
||||
"selectRangeOfItems": "Sélectionner une plage d'entrées",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"openApplicationDirectory": "Ouvrir le répertoire de l'application",
|
||||
"goToCurrent": "Aller à la piste en cours"
|
||||
"goToCurrent": "Aller à la piste en cours",
|
||||
"collapseAllFolders": "Réduire tous les dossiers",
|
||||
"expandAllFolders": "Développer tous les dossiers"
|
||||
},
|
||||
"common": {
|
||||
"backward": "En arrière",
|
||||
@@ -222,7 +225,8 @@
|
||||
"filter_multiple": "Multiple",
|
||||
"rename": "Renommer",
|
||||
"newVersionAvailable": "Une nouvelle version est disponible",
|
||||
"numberOfResults": "{{numberOfResults}} résultats"
|
||||
"numberOfResults": "{{numberOfResults}} résultats",
|
||||
"back": "Retour"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "Redémarrer le serveur pour appliquer le nouveau port",
|
||||
@@ -699,7 +703,7 @@
|
||||
"transcodeFormat_description": "Sélectionne le format du transcodage. laisser vide pour laisser le serveur décider",
|
||||
"volumeWidth": "Largeur de la barre de volume",
|
||||
"volumeWidth_description": "La largeur de la barre de volume",
|
||||
"customCssEnable": "Active le CSS personnalisé",
|
||||
"customCssEnable": "Activer le CSS personnalisé",
|
||||
"customCssEnable_description": "Permet l'écriture de CSS personnalisé",
|
||||
"customCssNotice": "Attention : bien qu'il y ait un certain assainissement (blocage de URL() et de content :), l'utilisation de CSS personnalisé peut toujours présenter des risques en modifiant l'interface",
|
||||
"customCss": "Css personnalisé",
|
||||
@@ -895,7 +899,24 @@
|
||||
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
|
||||
"sidePlayQueueLayout_optionVertical": "Vertical",
|
||||
"waveformLoadingDelay": "Délai de chargement de la forme d'onde",
|
||||
"waveformLoadingDelay_description": "Délai en secondes avant le chargement de l'onde. augmentez cette valeur si vous rencontrez des saccades lors de l'utilisation du lecteur web."
|
||||
"waveformLoadingDelay_description": "Délai en secondes avant le chargement de l'onde. augmentez cette valeur si vous rencontrez des saccades lors de l'utilisation du lecteur web.",
|
||||
"sidebarPlaylistMode_optionExpanded": "Étendu",
|
||||
"hotkey_listShowPlayingSong": "Affiche le titre en cours dans la liste",
|
||||
"preventSuspendOnPlayback_description": "Empêcher la suspension de l'application lors de la lecture de musique",
|
||||
"preventSuspendOnPlayback": "Empêcher la suspension lors de la lecture",
|
||||
"sidebarPlaylistFolders_description": "Crée une vue de dossier pour les listes de lecture dont le nom inclut le séparateur configuré",
|
||||
"sidebarPlaylistFolders": "Activer les dossiers",
|
||||
"sidebarPlaylistFolderSeparator_description": "Caractère (ou chaîne de caractères) servant à séparer les niveaux de dossiers dans le nom d'une liste de lecture",
|
||||
"sidebarPlaylistFolderSeparator": "Séparateur de dossiers",
|
||||
"sidebarPlaylistFolderView_description": "Comment les dossiers sont affichés dans la barre latérale",
|
||||
"sidebarPlaylistFolderView": "Affichage des dossiers",
|
||||
"sidebarPlaylistFolderView_optionSingle": "Dossier unique",
|
||||
"sidebarPlaylistFolderView_optionTree": "Vue arborescente",
|
||||
"sidebarPlaylistFolderView_optionNavigation": "Vue de navigation",
|
||||
"sidebarPlaylistFolderTreeIndent": "Indentation de l'arbre",
|
||||
"sidebarPlaylistMode_description": "Comment chaque liste de lecture est affichée dans la barre latérale",
|
||||
"sidebarPlaylistMode": "Mode de liste de lecture de la barre latérale",
|
||||
"sidebarPlaylistMode_optionCompact": "Compacte"
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
@@ -922,12 +943,13 @@
|
||||
"input_remoteUrlPlaceholder": "Optionnel : URL publique pour les fonctionnalités externes"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "$t(entity.trackWithCount, {\"count\" : {{message}} }) ajouté à $t(entity.playlistWithCount, {\"count\" : {{numOfPlaylists}} })",
|
||||
"success": "$t(entity.trackWithCount, {\"count\": {{message}} }) ajouté à $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||
"title": "Ajouter à $t(entity.playlist, {\"count\": 1})",
|
||||
"input_skipDuplicates": "Sauter les doublons",
|
||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||
"create": "Créer $t(entity.playlist, {\"count\": 1}) {{playlist}}",
|
||||
"searchOrCreate": "Rechercher $t(entity.playlist, {\"count\": 2}) ou tapez pour en créer une nouvelle"
|
||||
"searchOrCreate": "Rechercher $t(entity.playlist, {\"count\": 2}) ou tapez pour en créer une nouvelle",
|
||||
"noneAdded": "Aucune piste n'a été ajoutée à $t(entity.playlist, {\"count\": 1}) {{playlist}}"
|
||||
},
|
||||
"createPlaylist": {
|
||||
"title": "Créer une $t(entity.playlist, {\"count\": 1})",
|
||||
@@ -1011,25 +1033,25 @@
|
||||
},
|
||||
"entity": {
|
||||
"genre_one": "Genre",
|
||||
"genre_many": "genres",
|
||||
"genre_many": "Genres",
|
||||
"genre_other": "Genres",
|
||||
"playlistWithCount_one": "{{count}} liste de lecture",
|
||||
"playlistWithCount_many": "{{count}} listes de lecture",
|
||||
"playlistWithCount_other": "{{count}} listes de lecture",
|
||||
"playlist_one": "Liste de lecture",
|
||||
"playlist_many": "listes de lecture",
|
||||
"playlist_many": "Listes de lecture",
|
||||
"playlist_other": "Listes de lecture",
|
||||
"artist_one": "Artiste",
|
||||
"artist_many": "artistes",
|
||||
"artist_many": "Artistes",
|
||||
"artist_other": "Artistes",
|
||||
"folderWithCount_one": "{{count}} dossier",
|
||||
"folderWithCount_many": "{{count}} dossiers",
|
||||
"folderWithCount_other": "{{count}} dossiers",
|
||||
"albumArtist_one": "Artiste d'album",
|
||||
"albumArtist_many": "artistes d'albums",
|
||||
"albumArtist_many": "Artistes d'albums",
|
||||
"albumArtist_other": "Artistes d'albums",
|
||||
"track_one": "Piste",
|
||||
"track_many": "pistes",
|
||||
"track_many": "Pistes",
|
||||
"track_other": "Pistes",
|
||||
"albumArtistCount_one": "{{count}} artiste de l'album",
|
||||
"albumArtistCount_many": "{{count}} artistes d'albums",
|
||||
@@ -1038,17 +1060,17 @@
|
||||
"albumWithCount_many": "{{count}} albums",
|
||||
"albumWithCount_other": "{{count}} albums",
|
||||
"favorite_one": "Favori",
|
||||
"favorite_many": "favoris",
|
||||
"favorite_many": "Favoris",
|
||||
"favorite_other": "Favoris",
|
||||
"artistWithCount_one": "{{count}} artiste",
|
||||
"artistWithCount_many": "{{count}} artistes",
|
||||
"artistWithCount_other": "{{count}} artistes",
|
||||
"folder_one": "Dossier",
|
||||
"folder_many": "dossiers",
|
||||
"folder_many": "Dossiers",
|
||||
"folder_other": "Dossiers",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) intelligente",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Intelligente",
|
||||
"album_one": "Album",
|
||||
"album_many": "albums",
|
||||
"album_many": "Albums",
|
||||
"album_other": "Albums",
|
||||
"genreWithCount_one": "{{count}} genre",
|
||||
"genreWithCount_many": "{{count}} genres",
|
||||
@@ -1060,10 +1082,10 @@
|
||||
"play_many": "{{count}} écoutes",
|
||||
"play_other": "{{count}} écoutes",
|
||||
"song_one": "Titre",
|
||||
"song_many": "titres",
|
||||
"song_many": "Titres",
|
||||
"song_other": "Titres",
|
||||
"radioStation_one": "Station radio",
|
||||
"radioStation_many": "stations radio",
|
||||
"radioStation_many": "Stations radio",
|
||||
"radioStation_other": "Stations radio",
|
||||
"radioStationWithCount_one": "{{count}} station radio",
|
||||
"radioStationWithCount_many": "{{count}} stations radio",
|
||||
|
||||
@@ -192,8 +192,8 @@
|
||||
"play_other": "{{count}} lejátszások",
|
||||
"trackWithCount_one": "{{count}} sáv",
|
||||
"trackWithCount_other": "{{count}} sávok",
|
||||
"radioStation_one": "Rádió állomás",
|
||||
"radioStation_other": "Rádió állomások",
|
||||
"radioStation_one": "Rádióállomás",
|
||||
"radioStation_other": "Rádióállomások",
|
||||
"radioStationWithCount_one": "{{count}} rádióállomás",
|
||||
"radioStationWithCount_other": "{{count}} rádióállomások"
|
||||
},
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
},
|
||||
"entity": {
|
||||
"album_other": "Album",
|
||||
"albumArtist_other": "Artis album",
|
||||
"albumArtist_other": "Artis Album",
|
||||
"albumArtistCount_other": "{{count}} artis album",
|
||||
"albumWithCount_other": "{{count}} album",
|
||||
"artist_other": "Artis",
|
||||
@@ -170,14 +170,14 @@
|
||||
"folderWithCount_other": "{{count}} folder",
|
||||
"genre_other": "Genre",
|
||||
"genreWithCount_other": "{{count}} genre",
|
||||
"playlist_other": "Daftar putar",
|
||||
"playlist_other": "Daftar Putar",
|
||||
"play_other": "Putar {{count}}",
|
||||
"playlistWithCount_other": "{{count}} daftar putar",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) pintar",
|
||||
"track_other": "Pista",
|
||||
"song_other": "Lagu",
|
||||
"trackWithCount_other": "{{count}} pista",
|
||||
"radioStation_other": "Stasiun radio",
|
||||
"radioStation_other": "Stasiun Radio",
|
||||
"radioStationWithCount_other": "{{count}} stasiun radio"
|
||||
},
|
||||
"error": {
|
||||
|
||||
+13
-13
@@ -930,25 +930,25 @@
|
||||
},
|
||||
"entity": {
|
||||
"genre_one": "Genere",
|
||||
"genre_many": "generi",
|
||||
"genre_many": "Generi",
|
||||
"genre_other": "Generi",
|
||||
"playlistWithCount_one": "{{count}} playlist",
|
||||
"playlistWithCount_many": "{{count}} playlist",
|
||||
"playlistWithCount_other": "{{count}} playlist",
|
||||
"playlist_one": "Playlist",
|
||||
"playlist_many": "playlist",
|
||||
"playlist_many": "Playlist",
|
||||
"playlist_other": "Playlist",
|
||||
"artist_one": "Artista",
|
||||
"artist_many": "artisti",
|
||||
"artist_many": "Artisti",
|
||||
"artist_other": "Artisti",
|
||||
"folderWithCount_one": "{{count}} cartella",
|
||||
"folderWithCount_many": "{{count}} cartelle",
|
||||
"folderWithCount_other": "{{count}} cartelle",
|
||||
"albumArtist_one": "Artista album",
|
||||
"albumArtist_many": "artisti album",
|
||||
"albumArtist_other": "Artisti album",
|
||||
"albumArtist_one": "Artista Album",
|
||||
"albumArtist_many": "Artisti Album",
|
||||
"albumArtist_other": "Artisti Album",
|
||||
"track_one": "Traccia",
|
||||
"track_many": "tracce",
|
||||
"track_many": "Tracce",
|
||||
"track_other": "Tracce",
|
||||
"albumArtistCount_one": "{{count}} artista album",
|
||||
"albumArtistCount_many": "{{count}} artisti album",
|
||||
@@ -957,17 +957,17 @@
|
||||
"albumWithCount_many": "{{count}} album",
|
||||
"albumWithCount_other": "{{count}} album",
|
||||
"favorite_one": "Preferito",
|
||||
"favorite_many": "preferiti",
|
||||
"favorite_many": "Preferiti",
|
||||
"favorite_other": "Preferiti",
|
||||
"artistWithCount_one": "{{count}} artista",
|
||||
"artistWithCount_many": "{{count}} artisti",
|
||||
"artistWithCount_other": "{{count}} artisti",
|
||||
"folder_one": "Cartella",
|
||||
"folder_many": "cartelle",
|
||||
"folder_many": "Cartelle",
|
||||
"folder_other": "Cartelle",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) smart",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Smart",
|
||||
"album_one": "Album",
|
||||
"album_many": "album",
|
||||
"album_many": "Album",
|
||||
"album_other": "Album",
|
||||
"genreWithCount_one": "{{count}} genere",
|
||||
"genreWithCount_many": "{{count}} generi",
|
||||
@@ -979,10 +979,10 @@
|
||||
"play_many": "{{count}} riproduzioni",
|
||||
"play_other": "{{count}} riproduzioni",
|
||||
"song_one": "Traccia",
|
||||
"song_many": "tracce",
|
||||
"song_many": "Tracce",
|
||||
"song_other": "Tracce",
|
||||
"radioStation_one": "Stazione radio",
|
||||
"radioStation_many": "stazioni radio",
|
||||
"radioStation_many": "Stazioni radio",
|
||||
"radioStation_other": "Stazioni radio",
|
||||
"radioStationWithCount_one": "{{count}} stazione radio",
|
||||
"radioStationWithCount_many": "{{count}} stazioni radio",
|
||||
|
||||
+45
-23
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"action": {
|
||||
"editPlaylist": "Edytuj $t(entity.playlist, {\"count\": 1})",
|
||||
"goToPage": "IDź do strony",
|
||||
"goToPage": "Idź do strony",
|
||||
"clearQueue": "Wyczyść kolejkę",
|
||||
"addToFavorites": "Dodaj do $t(entity.favorite, {\"count\": 2})",
|
||||
"removeFromPlaylist": "Usuń z $t(entity.playlist, {\"count\": 1})",
|
||||
@@ -41,7 +41,9 @@
|
||||
"selectRangeOfItems": "Wybierz zakres elementów",
|
||||
"selectAll": "Wybierz wszystkie",
|
||||
"openApplicationDirectory": "Otwórz katalog aplikacji",
|
||||
"goToCurrent": "Przejdź do aktualnego elementu"
|
||||
"goToCurrent": "Przejdź do aktualnego elementu",
|
||||
"collapseAllFolders": "Zwiń wszystkie foldery",
|
||||
"expandAllFolders": "Rozwiń wszystkie foldery"
|
||||
},
|
||||
"common": {
|
||||
"increase": "Zwiększ",
|
||||
@@ -170,7 +172,8 @@
|
||||
"rename": "Zmień nazwę",
|
||||
"newVersionAvailable": "Nowa wersja jest dostępna",
|
||||
"numberOfResults": "{{numberOfResults}} wyników",
|
||||
"grouping": "Grupowanie"
|
||||
"grouping": "Grupowanie",
|
||||
"back": "Wstecz"
|
||||
},
|
||||
"entity": {
|
||||
"genre_one": "Gatunek",
|
||||
@@ -189,8 +192,8 @@
|
||||
"folderWithCount_few": "{{count}} katalogi",
|
||||
"folderWithCount_many": "{{count}} katalogów",
|
||||
"albumArtist_one": "Wykonawca albumu",
|
||||
"albumArtist_few": "wykonawcy albumu",
|
||||
"albumArtist_many": "wykonawcy albumów",
|
||||
"albumArtist_few": "Wykonawców albumów",
|
||||
"albumArtist_many": "Wykonawców albumów",
|
||||
"track_one": "Utwór",
|
||||
"track_few": "utwory",
|
||||
"track_many": "utworów",
|
||||
@@ -226,8 +229,8 @@
|
||||
"song_few": "piosenki",
|
||||
"song_many": "piosenek",
|
||||
"radioStation_one": "Stacja radiowa",
|
||||
"radioStation_few": "stacje radiowe",
|
||||
"radioStation_many": "stacji radiowych",
|
||||
"radioStation_few": "Stacje radiowe",
|
||||
"radioStation_many": "Stacji radiowych",
|
||||
"radioStationWithCount_one": "{{count}} stacja radiowa",
|
||||
"radioStationWithCount_few": "{{count}} stacje radiowych",
|
||||
"radioStationWithCount_many": "{{count}} stacji radiowych"
|
||||
@@ -690,7 +693,8 @@
|
||||
"sleepTimer_timeRemaining": "Pozostało {{time}}",
|
||||
"sleepTimer_setCustom": "Ustaw wyłącznik",
|
||||
"sleepTimer_cancel": "Anuluj wyłączanie",
|
||||
"albumRadio": "Radio albumu"
|
||||
"albumRadio": "Radio albumu",
|
||||
"scrobbleForceSubmit": "Wymuś scrobble"
|
||||
},
|
||||
"setting": {
|
||||
"crossfadeStyle_description": "Wybierz styl przenikania, który ma być używany do odtwarzania dźwięku",
|
||||
@@ -887,7 +891,7 @@
|
||||
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
||||
"playerbarOpenDrawer_description": "Pozwala przełączyć na odtwarzacz pełnoekranowy po kliknięciu paska odtwarzania",
|
||||
"playerbarOpenDrawer": "Przełącznik pełnego ekranu na pasku odtwarzania",
|
||||
"imageAspectRatio": "Używaj natywnych proporcji OKładki",
|
||||
"imageAspectRatio": "Używaj natywnych proporcji okładki",
|
||||
"volumeWidth": "Szerokość paska głośności",
|
||||
"discordListening": "Pokazuj status jako słucha",
|
||||
"imageAspectRatio_description": "Jeżeli włączone, okładka będzie pokazywana z użyciem jej natywnych proporcji. dla okładek które nie mają proporcji 1:1, pozostałe miejsce będzie puste",
|
||||
@@ -912,7 +916,7 @@
|
||||
"musicbrainz_description": "Pokazuj linki do MusicBrainz na stronach wykonawców/albumów, gdzie istnieje MusicBrainz ID",
|
||||
"discordPausedStatus": "Pokaż status podczas pauzy",
|
||||
"discordServeImage": "Wysyłaj obrazy dla {{discord}} z serwera",
|
||||
"discordServeImage_description": "Pokazuj OKładki w statusie {{discord}} prosto z serwera, dostępne tylko dla Jellyfin i Navidrome. {{discord}} używa bota do pobierania obrazów, więc twój serwer musi być dostępny publicznie w internecie",
|
||||
"discordServeImage_description": "Pokazuj okładki w statusie {{discord}} prosto z serwera, dostępne tylko dla Jellyfin i Navidrome. {{discord}} używa bota do pobierania obrazów, więc twój serwer musi być dostępny publicznie w internecie",
|
||||
"analyticsDisable": "Zrezygnuj z analityki bazowanej na użytkowaniu",
|
||||
"analyticsDisable_description": "Zanonymizowane dane użytkowania są wysyłane do dewelopera w celu poprawienia aplikacji",
|
||||
"artistBackground": "Obraz tła wykonawcy",
|
||||
@@ -978,7 +982,7 @@
|
||||
"queryBuilderCustomFields": "Niestandardowe pola",
|
||||
"queryBuilderCustomFields_description": "Dodaj niestandardowe pola do użycia w kreatorach zapytań",
|
||||
"followCurrentSong_description": "Automatycznie przewija kolejkę odtwarzania do aktualnie odtwarzanej piosenki",
|
||||
"followCurrentSong": "ŚLEDź aktualną piosenkę",
|
||||
"followCurrentSong": "Śledź aktualną piosenkę",
|
||||
"playerFilters": "Filtruj piosenki z kolejki",
|
||||
"playerFilters_description": "Nie dodawaj piosenek do kolejki na podstawie poniższych kryteriów",
|
||||
"playerbarSlider_description": "Krzywe nie są zalecane w przypadku wolnego lub ograniczonego połączenia internetowego",
|
||||
@@ -1004,7 +1008,7 @@
|
||||
"imageResolution_description": "Rozdzielczość dla obrazów używanych w programie. użycie wartości 0 ustawi rozdzielczość na natywną",
|
||||
"imageResolution_optionTable": "Tabela",
|
||||
"imageResolution_optionItemCard": "Karta elementu",
|
||||
"imageResolution_optionSidebar": "pasek boczny",
|
||||
"imageResolution_optionSidebar": "Pasek boczny",
|
||||
"imageResolution_optionHeader": "Nagłówek",
|
||||
"imageResolution_optionFullScreenPlayer": "Odtwarzacz pełnoekranowy",
|
||||
"combinedLyricsAndVisualizer_description": "Połącz tekst i wizualizacje w tym samym panelu",
|
||||
@@ -1071,7 +1075,25 @@
|
||||
"playerbarWaveformStretch": "Rozciąganie przebiegu",
|
||||
"playerbarWaveformStretch_description": "Rozciąga przebieg aby wypełnić dostępną przestrzeń",
|
||||
"preventSuspendOnPlayback_description": "Powstrzymuj wstrzymanie podczas odtwarzania muzyki",
|
||||
"preventSuspendOnPlayback": "Powstrzymuje wstrzymanie przy odtwarzaniu"
|
||||
"preventSuspendOnPlayback": "Powstrzymuje wstrzymanie przy odtwarzaniu",
|
||||
"hotkey_listShowPlayingSong": "Pokaż odtwarzaną piosenkę w liście",
|
||||
"sidebarPlaylistFolders_description": "Utwórz widok folderu dla playlist który zawiera skonfigurowany separator w nazwie",
|
||||
"sidebarPlaylistFolders": "Włącz foldery",
|
||||
"sidebarPlaylistFolderSeparator_description": "Znak (lub ciąg) który oddziela poziomy folderów w nazwie playlisty",
|
||||
"sidebarPlaylistFolderSeparator": "Separator folderów",
|
||||
"sidebarPlaylistFolderView_description": "Jak foldery są wyświetlane w pasku bocznym",
|
||||
"sidebarPlaylistFolderView": "Widok folderów",
|
||||
"sidebarPlaylistFolderView_optionSingle": "Pojedynczy folder",
|
||||
"sidebarPlaylistFolderView_optionTree": "Widok drzewa",
|
||||
"sidebarPlaylistFolderView_optionNavigation": "Widok nawigacji",
|
||||
"sidebarPlaylistFolderTreeIndent_description": "Piksele na każdym poziomie drzewa mają wcięcie",
|
||||
"sidebarPlaylistFolderTreeIndent": "Wcięcie drzewa",
|
||||
"sidebarPlaylistFolderTreeLineColor_description": "Kolor linii łączących drzewo (pozostaw pusty dla domyślnego dla motywu)",
|
||||
"sidebarPlaylistFolderTreeLineColor": "Kolor linii drzewa",
|
||||
"sidebarPlaylistMode_description": "Jak każda z playlist jest wyświetlana w liście w pasku bocznym",
|
||||
"sidebarPlaylistMode": "Tryb playlist bocznego paska",
|
||||
"sidebarPlaylistMode_optionCompact": "Kompaktowy",
|
||||
"sidebarPlaylistMode_optionExpanded": "Rozszerzony"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -1089,7 +1111,7 @@
|
||||
"size": "$t(common.size)",
|
||||
"itemSize": "Rozmiar elementu (px)",
|
||||
"itemGap": "Odstęp między elementami (px)",
|
||||
"followCurrentSong": "ŚLEDź aktualną piosenkę",
|
||||
"followCurrentSong": "Śledź aktualną piosenkę",
|
||||
"advancedSettings": "Zaawansowane ustawienia",
|
||||
"autosize": "Rozmiar automatyczny",
|
||||
"moveUp": "Przesuń w górę",
|
||||
@@ -1158,7 +1180,7 @@
|
||||
"rating": "Ocena",
|
||||
"favorite": "Ulubione",
|
||||
"playCount": "Odtwarzane",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||
"albumCount": "Albumy",
|
||||
"releaseYear": "Rok",
|
||||
"lastPlayed": "Ostatnio odtwarzane",
|
||||
"biography": "Biografia",
|
||||
@@ -1167,19 +1189,19 @@
|
||||
"title": "Tytuł",
|
||||
"bpm": "BPM",
|
||||
"dateAdded": "Data dodania",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
||||
"artist": "Wykonawca",
|
||||
"songCount": "Utwory",
|
||||
"trackNumber": "Utwór",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"genre": "Gatunek",
|
||||
"albumArtist": "Wykonawca albumu",
|
||||
"path": "Ścieżka",
|
||||
"discNumber": "Płyta",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"size": "$t(common.size)",
|
||||
"codec": "$t(common.codec)",
|
||||
"channels": "Kanały",
|
||||
"size": "Rozmiar",
|
||||
"codec": "Kodek",
|
||||
"owner": "Właściciel",
|
||||
"bitDepth": "$t(common.bitDepth)",
|
||||
"sampleRate": "$t(common.sampleRate)"
|
||||
"bitDepth": "Głębia bitowa",
|
||||
"sampleRate": "Częstotliwość próbkowania"
|
||||
}
|
||||
},
|
||||
"queryBuilder": {
|
||||
|
||||
@@ -764,31 +764,31 @@
|
||||
},
|
||||
"entity": {
|
||||
"albumArtist_one": "Artista do álbum",
|
||||
"albumArtist_many": "artistas do álbum",
|
||||
"albumArtist_many": "Artistas do álbum",
|
||||
"albumArtist_other": "Artistas do álbum",
|
||||
"albumArtistCount_one": "{{count}} artista do álbum",
|
||||
"albumArtistCount_many": "{{count}} artistas do álbum",
|
||||
"albumArtistCount_other": "{{count}} artistas do álbum",
|
||||
"album_one": "Álbum",
|
||||
"album_many": "álbuns",
|
||||
"album_many": "Álbuns",
|
||||
"album_other": "Álbuns",
|
||||
"artist_one": "Artista",
|
||||
"artist_many": "artistas",
|
||||
"artist_many": "Artistas",
|
||||
"artist_other": "Artistas",
|
||||
"albumWithCount_one": "{{count}} álbum",
|
||||
"albumWithCount_many": "{{count}} álbuns",
|
||||
"albumWithCount_other": "{{count}} álbuns",
|
||||
"favorite_one": "Favorito",
|
||||
"favorite_many": "favoritos",
|
||||
"favorite_many": "Favoritos",
|
||||
"favorite_other": "Favoritos",
|
||||
"artistWithCount_one": "{{count}} artista",
|
||||
"artistWithCount_many": "{{count}} artistas",
|
||||
"artistWithCount_other": "{{count}} artistas",
|
||||
"folder_one": "Pasta",
|
||||
"folder_many": "pastas",
|
||||
"folder_many": "Pastas",
|
||||
"folder_other": "Pastas",
|
||||
"genre_one": "Gênero",
|
||||
"genre_many": "gêneros",
|
||||
"genre_many": "Gêneros",
|
||||
"genre_other": "Gêneros",
|
||||
"playlistWithCount_one": "{{count}} playlist",
|
||||
"playlistWithCount_many": "{{count}} playlists",
|
||||
@@ -806,11 +806,11 @@
|
||||
"trackWithCount_many": "{{count}} faixas",
|
||||
"trackWithCount_other": "{{count}} faixas",
|
||||
"track_one": "Faixa",
|
||||
"track_many": "faixas",
|
||||
"track_many": "Faixas",
|
||||
"track_other": "Faixas",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) inteligente",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Inteligente",
|
||||
"song_one": "Música",
|
||||
"song_many": "músicas",
|
||||
"song_many": "Músicas",
|
||||
"song_other": "Músicas",
|
||||
"play_one": "{{count}} reprodução",
|
||||
"play_many": "{{count}} reproduções",
|
||||
|
||||
+10
-10
@@ -126,10 +126,10 @@
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Álbum",
|
||||
"album_many": "álbuns",
|
||||
"album_many": "Álbuns",
|
||||
"album_other": "Álbuns",
|
||||
"albumArtist_one": "Artista do álbum",
|
||||
"albumArtist_many": "artistas do álbum",
|
||||
"albumArtist_many": "Artistas do álbum",
|
||||
"albumArtist_other": "Artistas do álbum",
|
||||
"albumArtistCount_one": "{{count}} artista do álbum",
|
||||
"albumArtistCount_many": "{{count}} artistas do álbum",
|
||||
@@ -138,28 +138,28 @@
|
||||
"albumWithCount_many": "{{count}} álbuns",
|
||||
"albumWithCount_other": "{{count}} álbuns",
|
||||
"artist_one": "Artista",
|
||||
"artist_many": "artistas",
|
||||
"artist_many": "Artistas",
|
||||
"artist_other": "Artistas",
|
||||
"artistWithCount_one": "{{count}} artista",
|
||||
"artistWithCount_many": "{{count}} artistas",
|
||||
"artistWithCount_other": "{{count}} artistas",
|
||||
"favorite_one": "Favorito",
|
||||
"favorite_many": "favoritos",
|
||||
"favorite_many": "Favoritos",
|
||||
"favorite_other": "Favoritos",
|
||||
"folder_one": "Pasta",
|
||||
"folder_many": "pastas",
|
||||
"folder_many": "Pastas",
|
||||
"folder_other": "Pastas",
|
||||
"folderWithCount_one": "{{count}} pasta",
|
||||
"folderWithCount_many": "{{count}} pastas",
|
||||
"folderWithCount_other": "{{count}} pastas",
|
||||
"genre_one": "Gênero",
|
||||
"genre_many": "gêneros",
|
||||
"genre_many": "Gêneros",
|
||||
"genre_other": "Gêneros",
|
||||
"genreWithCount_one": "{{count}} gênero",
|
||||
"genreWithCount_many": "{{count}} gêneros",
|
||||
"genreWithCount_other": "{{count}} gêneros",
|
||||
"playlist_one": "Playlist",
|
||||
"playlist_many": "playlists",
|
||||
"playlist_many": "Playlists",
|
||||
"playlist_other": "Playlists",
|
||||
"play_one": "{{count}} reprodução",
|
||||
"play_many": "{{count}} reproduções",
|
||||
@@ -167,12 +167,12 @@
|
||||
"playlistWithCount_one": "{{count}} playlist",
|
||||
"playlistWithCount_many": "{{count}} playlists",
|
||||
"playlistWithCount_other": "{{count}} playlists",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) inteligente",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Inteligente",
|
||||
"track_one": "Faixa",
|
||||
"track_many": "faixas",
|
||||
"track_many": "Faixas",
|
||||
"track_other": "Faixas",
|
||||
"song_one": "Música",
|
||||
"song_many": "músicas",
|
||||
"song_many": "Músicas",
|
||||
"song_other": "Músicas",
|
||||
"trackWithCount_one": "{{count}} faixa",
|
||||
"trackWithCount_many": "{{count}} faixas",
|
||||
|
||||
@@ -580,25 +580,25 @@
|
||||
},
|
||||
"entity": {
|
||||
"genre_one": "Žanr",
|
||||
"genre_few": "žanrova",
|
||||
"genre_few": "Žanrova",
|
||||
"genre_other": "Žanrova",
|
||||
"playlistWithCount_one": "{{count}} plejlista",
|
||||
"playlistWithCount_few": "{{count}} plejlista",
|
||||
"playlistWithCount_other": "{{count}} plejlista",
|
||||
"playlist_one": "Plejlista",
|
||||
"playlist_few": "plejlista",
|
||||
"playlist_few": "Plejlista",
|
||||
"playlist_other": "Plejlista",
|
||||
"artist_one": "Umetnik",
|
||||
"artist_few": "umetnika",
|
||||
"artist_few": "Umetnika",
|
||||
"artist_other": "Umetnika",
|
||||
"folderWithCount_one": "{{count}} folder",
|
||||
"folderWithCount_few": "{{count}} foldera",
|
||||
"folderWithCount_other": "{{count}} foldera",
|
||||
"albumArtist_one": "Album umetnika",
|
||||
"albumArtist_few": "albuma umetnika",
|
||||
"albumArtist_few": "Albuma umetnika",
|
||||
"albumArtist_other": "Albuma umetnika",
|
||||
"track_one": "Pesma",
|
||||
"track_few": "pesama",
|
||||
"track_few": "Pesama",
|
||||
"track_other": "Pesama",
|
||||
"albumArtistCount_one": "{{count}} album umetnika",
|
||||
"albumArtistCount_few": "{{count}} albuma umetnika",
|
||||
@@ -607,17 +607,17 @@
|
||||
"albumWithCount_few": "{{count}} albuma",
|
||||
"albumWithCount_other": "{{count}} albuma",
|
||||
"favorite_one": "Favorit",
|
||||
"favorite_few": "favorita",
|
||||
"favorite_few": "Favorita",
|
||||
"favorite_other": "Favorita",
|
||||
"artistWithCount_one": "{{count}} umetnik",
|
||||
"artistWithCount_few": "{{count}} umetnika",
|
||||
"artistWithCount_other": "{{count}} umetnika",
|
||||
"folder_one": "Folder",
|
||||
"folder_few": "foldera",
|
||||
"folder_few": "Foldera",
|
||||
"folder_other": "Foldera",
|
||||
"smartPlaylist": "Pametna $t(entity.playlist, {\"count\": 1})",
|
||||
"album_one": "Album",
|
||||
"album_few": "albumi",
|
||||
"album_few": "Albumi",
|
||||
"album_other": "Albuma",
|
||||
"genreWithCount_one": "{{count}} žanr",
|
||||
"genreWithCount_few": "{{count}} žanrova",
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
"share": "分享",
|
||||
"tags": "標籤",
|
||||
"trackGain": "曲目增益",
|
||||
"trackPeak": "歌曲峰值",
|
||||
"trackPeak": "曲目峰值",
|
||||
"translation": "翻譯",
|
||||
"doNotShowAgain": "不再顯示",
|
||||
"externalLinks": "外部連結",
|
||||
@@ -103,8 +103,8 @@
|
||||
"sort": "排序",
|
||||
"tableColumns": "表格欄位",
|
||||
"clean": "清除",
|
||||
"explicitStatus": "Explicit狀態",
|
||||
"explicit": "Explicit",
|
||||
"explicitStatus": "露骨狀態",
|
||||
"explicit": "露骨",
|
||||
"gridRows": "網格行",
|
||||
"noFilters": "未設定任何過濾器",
|
||||
"countSelected": "{{count}}個已選取",
|
||||
@@ -118,7 +118,8 @@
|
||||
"filter_multiple": "複選",
|
||||
"newVersionAvailable": "有新的版本可供使用",
|
||||
"numberOfResults": "{{numberOfResults}} 項結果",
|
||||
"grouping": "分組"
|
||||
"grouping": "分組",
|
||||
"back": "返回"
|
||||
},
|
||||
"error": {
|
||||
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
|
||||
@@ -128,7 +129,7 @@
|
||||
"credentialsRequired": "需要憑證",
|
||||
"genericError": "發生了錯誤",
|
||||
"invalidServer": "無效的伺服器",
|
||||
"localFontAccessDenied": "無法取得本地字體",
|
||||
"localFontAccessDenied": "無法取得本地字型",
|
||||
"loginRateError": "登入請求嘗試次數過多,請稍後再試",
|
||||
"remoteDisableError": "$t(common.disable)遠端伺服器時出現錯誤",
|
||||
"remoteEnableError": "$t(common.enable)遠端伺服器時出現錯誤",
|
||||
@@ -136,7 +137,7 @@
|
||||
"remotePortWarning": "重啟伺服器使新連接埠生效",
|
||||
"serverRequired": "需要伺服器",
|
||||
"sessionExpiredError": "工作階段已過期",
|
||||
"systemFontError": "嘗試取得系統字體時出現錯誤",
|
||||
"systemFontError": "嘗試取得系統字型時出現錯誤",
|
||||
"serverNotSelectedError": "未選擇伺服器",
|
||||
"mpvRequired": "需要 MPV",
|
||||
"playbackError": "無法播放媒體",
|
||||
@@ -197,7 +198,7 @@
|
||||
"title": "$t(common.home)",
|
||||
"mostPlayed": "最多播放",
|
||||
"newlyAdded": "最近新增的發行",
|
||||
"recentlyReleased": "最近發佈",
|
||||
"recentlyReleased": "最近發行",
|
||||
"genres": "$t(entity.genre, {\"count\": 2})"
|
||||
},
|
||||
"appMenu": {
|
||||
@@ -221,7 +222,7 @@
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
"showLyricProvider": "顯示歌詞提供者",
|
||||
"useImageAspectRatio": "使用圖片縱橫比",
|
||||
"useImageAspectRatio": "使用圖片長寬比",
|
||||
"dynamicBackground": "動態背景",
|
||||
"followCurrentLyric": "跟隨目前歌詞",
|
||||
"lyricAlignment": "歌詞對齊",
|
||||
@@ -322,8 +323,8 @@
|
||||
"topSongsFrom": "{{title}} 的熱門歌曲",
|
||||
"viewAll": "檢視所有",
|
||||
"viewAllTracks": "檢視所有$t(entity.track, {\"count\": 2})",
|
||||
"groupingTypeAll": "所有發佈類型",
|
||||
"groupingTypePrimary": "主要發佈類型",
|
||||
"groupingTypeAll": "所有發行類型",
|
||||
"groupingTypePrimary": "主要發行類型",
|
||||
"favoriteSongs": "最愛歌曲",
|
||||
"favoriteSongsFrom": "{{title}} 的最愛歌曲",
|
||||
"topSongsCommunity": "社群",
|
||||
@@ -417,7 +418,8 @@
|
||||
"sleepTimer_timeRemaining": "剩餘 {{time}}",
|
||||
"sleepTimer_setCustom": "設定定時器",
|
||||
"sleepTimer_cancel": "取消定時器",
|
||||
"albumRadio": "專輯電台"
|
||||
"albumRadio": "專輯電台",
|
||||
"scrobbleForceSubmit": "強制紀錄"
|
||||
},
|
||||
"setting": {
|
||||
"audioPlayer_description": "選擇用於播放的音訊播放器",
|
||||
@@ -440,8 +442,8 @@
|
||||
"crossfadeDuration": "淡入淡出持續時間",
|
||||
"crossfadeDuration_description": "設定淡入淡出持續時間",
|
||||
"crossfadeStyle_description": "選擇用於音訊播放器的淡入淡出風格",
|
||||
"customFontPath": "自定字體路徑",
|
||||
"customFontPath_description": "設定應用程式使用的自定字體路徑",
|
||||
"customFontPath": "自訂字型路徑",
|
||||
"customFontPath_description": "設定應用程式要使用的自訂字型路徑",
|
||||
"disableLibraryUpdateOnStartup": "禁用啟動時檢查新版本",
|
||||
"discordApplicationId": "{{discord}} 應用程式 ID",
|
||||
"discordApplicationId_description": "{{discord}} Rich Presence 應用程式 ID(預設為 {{defaultId}})",
|
||||
@@ -454,12 +456,12 @@
|
||||
"enableRemote_description": "啟用遠端控制伺服器,以允許其他設備控制此應用程式",
|
||||
"exitToTray": "關閉時到將視窗最小化",
|
||||
"followLyric": "跟隨目前歌詞",
|
||||
"font_description": "設定應用程式使用的字體",
|
||||
"fontType": "字體類型",
|
||||
"fontType_description": "內建字體可以選擇 Feishin 提供的字體之一。系統字體允許您選擇作業系統提供的任何字體。自定選項允許您使用自己的字體",
|
||||
"fontType_optionBuiltIn": "內建字體",
|
||||
"fontType_optionCustom": "自定字體",
|
||||
"fontType_optionSystem": "系統字體",
|
||||
"font_description": "設定應用程式使用的字型",
|
||||
"fontType": "字型類型",
|
||||
"fontType_description": "內建字型可以選擇 Feishin 提供的字型之一。系統字型允許您選擇作業系統提供的任何字型。自訂選項允許您使用自己的字型",
|
||||
"fontType_optionBuiltIn": "內建字型",
|
||||
"fontType_optionCustom": "自訂字型",
|
||||
"fontType_optionSystem": "系統字型",
|
||||
"gaplessAudio": "無間隔音訊",
|
||||
"gaplessAudio_description": "調整 MPV 無間隔音訊設定",
|
||||
"gaplessAudio_optionWeak": "弱(建議)",
|
||||
@@ -499,7 +501,7 @@
|
||||
"lyricFetchProvider_description": "選擇歌詞來源",
|
||||
"minimizeToTray": "最小化到系統匣",
|
||||
"minimizeToTray_description": "將應用程式最小化到系統匣",
|
||||
"minimumScrobbleSeconds": "最小紀錄時間(秒)",
|
||||
"minimumScrobbleSeconds": "最小紀錄時長(秒)",
|
||||
"minimumScrobbleSeconds_description": "歌曲被記錄為已播放(Scrobble)所需的最小播放時間",
|
||||
"mpvExecutablePath": "MPV 執行檔路徑",
|
||||
"playbackStyle_optionCrossFade": "淡入淡出",
|
||||
@@ -557,7 +559,7 @@
|
||||
"playbackStyle": "播放風格",
|
||||
"exitToTray_description": "退出應用程式時最小化到系統匣而非關閉",
|
||||
"followLyric_description": "滾動歌詞到目前播放位置",
|
||||
"font": "字體",
|
||||
"font": "字型",
|
||||
"globalMediaHotkeys_description": "啟用或禁用系統媒體快捷鍵以控制播放",
|
||||
"hotkey_browserBack": "瀏覽器返回",
|
||||
"hotkey_favoriteCurrentSong": "收藏 $t(common.currentSong)",
|
||||
@@ -615,7 +617,7 @@
|
||||
"lastfmApiKey_description": "{{lastfm}}的API金鑰。用於封面照",
|
||||
"mpvExtraParameters_help": "一行一個",
|
||||
"musicbrainz": "顯示 MusicBrainz 連結",
|
||||
"musicbrainz_description": "在存在 MusicBrainz ID 的藝人/專輯頁面上顯示 MusicBrainz 的連結",
|
||||
"musicbrainz_description": "在擁有 MusicBrainz ID 的藝人/專輯頁面上顯示 MusicBrainz 的連結",
|
||||
"neteaseTranslation": "啟用網易翻譯",
|
||||
"neteaseTranslation_description": "啟用後,將從網易取得並顯示翻譯的歌詞(如果有)",
|
||||
"passwordStore": "密碼/secret儲存",
|
||||
@@ -626,7 +628,7 @@
|
||||
"startMinimized": "啟動時最小化",
|
||||
"startMinimized_description": "在系統匣中啟動應用程式",
|
||||
"transcode_description": "啟用轉碼到不同格式",
|
||||
"transcodeBitrate": "要轉碼的位元率",
|
||||
"transcodeBitrate": "轉碼的位元率",
|
||||
"transcodeBitrate_description": "選擇要轉碼的位元率。 0 表示讓伺服器選擇",
|
||||
"transcodeFormat": "轉碼的格式",
|
||||
"transcodeFormat_description": "選擇要轉碼的格式。留空來讓伺服器決定",
|
||||
@@ -708,7 +710,7 @@
|
||||
"queryBuilderCustomFields_description": "在查詢建構器中新增自訂欄位",
|
||||
"followCurrentSong_description": "自動將播放佇列捲動至當前播放的歌曲",
|
||||
"followCurrentSong": "跟隨當前歌曲",
|
||||
"playerbarSlider_description": "不建議在網路速度緩慢或計費的網路下使用波形",
|
||||
"playerbarSlider_description": "不建議在速度緩慢或計費的網路下使用波形",
|
||||
"playerFilters": "從佇列中過濾歌曲",
|
||||
"playerFilters_description": "根據以下條件,排除要新增至佇列中的歌曲",
|
||||
"autoDJ": "Auto DJ",
|
||||
@@ -717,7 +719,7 @@
|
||||
"autoDJ_itemCount_description": "在啟用Auto DJ時嘗試加入佇列的歌曲數量",
|
||||
"autoDJ_timing_description": "佇列中剩餘多少歌曲時啟動 Auto DJ",
|
||||
"autoDJ_timing": "觸發時機",
|
||||
"logLevel": "log等級",
|
||||
"logLevel": "Log等級",
|
||||
"logLevel_description": "設定要顯示的最低日誌等級。Debug 會顯示所有日誌,Error 僅會顯示錯誤訊息",
|
||||
"logLevel_optionDebug": "Debug",
|
||||
"logLevel_optionError": "Error",
|
||||
@@ -725,7 +727,7 @@
|
||||
"logLevel_optionWarn": "Warn",
|
||||
"useThemeAccentColor": "使用主題強調色",
|
||||
"useThemeAccentColor_description": "使用所選主題中定義的主要顏色,而非自訂的強調色",
|
||||
"artistRadioCount_description": "設定為藝人電台與曲目電台擷取的歌曲數量",
|
||||
"artistRadioCount_description": "設定要為藝人電台與曲目電台擷取的歌曲數量",
|
||||
"imageResolution": "圖片解析度",
|
||||
"imageResolution_description": "應用程式中所使用圖片的解析度。設定為 0 時,將使用圖片的原始解析度",
|
||||
"imageResolution_optionTable": "表格",
|
||||
@@ -760,11 +762,11 @@
|
||||
"enableGridMultiSelect_description": "啟用時,允許在網格檢視中選擇多項。停用時,單擊網格項目圖片將導航到項目頁面",
|
||||
"sidebarPlaylistSorting_description": "允許在側邊欄中使用拖放手動對播放清單進行排序,而不是預設的伺服器排序",
|
||||
"sidebarPlaylistListFilterRegex_description": "在側邊欄中隱藏與此正規表達式匹配的播放清單",
|
||||
"sidebarPlaylistListFilterRegex_placeholder": "範例: ^Daily Mix.*",
|
||||
"sidebarPlaylistListFilterRegex_placeholder": "範例: ^daily mix.*",
|
||||
"sidebarPlaylistListFilterRegex": "播放清單過濾器正規表達式",
|
||||
"blurExplicitImages": "模糊露骨圖片",
|
||||
"blurExplicitImages_description": "標記為露骨的專輯和歌曲封面將被模糊",
|
||||
"releaseChannel_optionAlpha": "alpha (每日建構版)",
|
||||
"releaseChannel_optionAlpha": "Alpha (每日建構版)",
|
||||
"analyticsEnable": "傳送基於使用情況的分析報告",
|
||||
"analyticsEnable_description": "匿名化的使用情況資料會傳送給開發者,以協助改進應用程式",
|
||||
"automaticUpdates": "自動更新",
|
||||
@@ -783,22 +785,40 @@
|
||||
"autosaveCount_description": "在儲存佇列之前,有多少曲目更改。1(最小)表示每次歌曲更改",
|
||||
"spotify_description": "在藝人與專輯頁面顯示 Spotify 的連結",
|
||||
"spotify": "顯示 Spotify 的連結",
|
||||
"nativeSpotify_description": "在 Spotify 應用程式中開啟,而非在瀏覽器中開啟",
|
||||
"nativeSpotify_description": "在 Spotify 應用程式而非瀏覽器中開啟",
|
||||
"nativeSpotify": "使用 Spotify 應用程式",
|
||||
"sidePlayQueueLayout": "側邊播放佇列佈局",
|
||||
"sidePlayQueueLayout_description": "設定吸附側邊播放佇列的佈局",
|
||||
"sidePlayQueueLayout_optionHorizontal": "水平",
|
||||
"sidePlayQueueLayout_optionVertical": "垂直",
|
||||
"listenbrainz_description": "在藝術家/專輯頁面上顯示 ListenBrainz 的連結",
|
||||
"listenbrainz_description": "在藝人/專輯頁面上顯示 ListenBrainz 的連結",
|
||||
"listenbrainz": "顯示 ListenBrainz 連結",
|
||||
"qobuz_description": "在藝術家/專輯頁面上顯示 Qobuz 的連結",
|
||||
"qobuz_description": "在藝人/專輯頁面上顯示 Qobuz 的連結",
|
||||
"qobuz": "顯示 Qobuz 連結",
|
||||
"waveformLoadingDelay": "波形載入延遲",
|
||||
"waveformLoadingDelay_description": "載入波形前的延遲(以秒為單位)。如果您在使用網頁播放器時遇到卡頓,請增加此值。",
|
||||
"playerbarWaveformStretch": "波形拉伸",
|
||||
"playerbarWaveformStretch_description": "拉伸波形來填補可用空間",
|
||||
"preventSuspendOnPlayback_description": "音樂播放時防止應用程式進入休眠",
|
||||
"preventSuspendOnPlayback": "在播放時防止應用程式暫停"
|
||||
"preventSuspendOnPlayback": "在播放時防止應用程式暫停",
|
||||
"hotkey_listShowPlayingSong": "在清單中顯示正在播放的歌曲",
|
||||
"sidebarPlaylistFolders_description": "為名稱中包含配置分隔符的播放清單建立資料夾檢視",
|
||||
"sidebarPlaylistFolders": "啟用資料夾",
|
||||
"sidebarPlaylistFolderSeparator_description": "在播放清單名稱中分隔資料夾級別的字元(或字串)",
|
||||
"sidebarPlaylistFolderSeparator": "資料夾分隔符",
|
||||
"sidebarPlaylistFolderView_description": "資料夾在側邊欄中顯示的方式",
|
||||
"sidebarPlaylistFolderView": "資料夾檢視",
|
||||
"sidebarPlaylistFolderView_optionSingle": "單一資料夾",
|
||||
"sidebarPlaylistFolderView_optionTree": "樹狀顯示",
|
||||
"sidebarPlaylistFolderView_optionNavigation": "導覽視圖",
|
||||
"sidebarPlaylistFolderTreeIndent_description": "每個樹狀層級縮排的像素",
|
||||
"sidebarPlaylistFolderTreeIndent": "樹狀縮排",
|
||||
"sidebarPlaylistFolderTreeLineColor_description": "樹狀連接線的顏色(留空則使用佈景主題預設值)",
|
||||
"sidebarPlaylistFolderTreeLineColor": "樹狀線條顏色",
|
||||
"sidebarPlaylistMode_description": "各播放清單在側邊欄列表中的顯示方式",
|
||||
"sidebarPlaylistMode": "側邊欄播放清單模式",
|
||||
"sidebarPlaylistMode_optionCompact": "緊湊",
|
||||
"sidebarPlaylistMode_optionExpanded": "展開"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -839,7 +859,7 @@
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"bpm": "$t(common.BPM)",
|
||||
"bpm": "$t(common.bpm)",
|
||||
"biography": "$t(common.biography)",
|
||||
"bitrate": "$t(common.bitrate)",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
@@ -882,11 +902,11 @@
|
||||
"column": {
|
||||
"album": "專輯",
|
||||
"albumArtist": "專輯藝人",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"albumCount": "專輯",
|
||||
"artist": "藝人",
|
||||
"biography": "簡介",
|
||||
"bitrate": "位元率",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
"channels": "聲道",
|
||||
"comment": "評論",
|
||||
"dateAdded": "新增日期",
|
||||
"discNumber": "光碟",
|
||||
@@ -894,19 +914,19 @@
|
||||
"lastPlayed": "最後播放",
|
||||
"path": "路徑",
|
||||
"playCount": "播放次數",
|
||||
"rating": "評價",
|
||||
"releaseDate": "發布日期",
|
||||
"rating": "評分",
|
||||
"releaseDate": "發行日期",
|
||||
"releaseYear": "年份",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"genre": "曲風",
|
||||
"bpm": "BPM",
|
||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
||||
"songCount": "曲目",
|
||||
"title": "標題",
|
||||
"trackNumber": "曲目編號",
|
||||
"size": "$t(common.size)",
|
||||
"codec": "$t(common.codec)",
|
||||
"size": "大小",
|
||||
"codec": "編碼",
|
||||
"owner": "擁有者",
|
||||
"bitDepth": "$t(common.bitDepth)",
|
||||
"sampleRate": "$t(common.sampleRate)"
|
||||
"bitDepth": "位元深度",
|
||||
"sampleRate": "取樣率"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -951,7 +971,9 @@
|
||||
"addOrRemoveFromSelection": "新增或移除選取項目",
|
||||
"selectAll": "全選",
|
||||
"selectRangeOfItems": "批量選取",
|
||||
"goToCurrent": "前往當前項目"
|
||||
"goToCurrent": "前往當前項目",
|
||||
"expandAllFolders": "展開所有資料夾",
|
||||
"collapseAllFolders": "摺疊所有資料夾"
|
||||
},
|
||||
"entity": {
|
||||
"album_other": "專輯",
|
||||
@@ -985,7 +1007,7 @@
|
||||
"comment": "評論",
|
||||
"communityRating": "社群評分",
|
||||
"criticRating": "評論家評分",
|
||||
"dateAdded": "已新增日期",
|
||||
"dateAdded": "新增日期",
|
||||
"disc": "光碟",
|
||||
"duration": "時長",
|
||||
"id": "ID",
|
||||
@@ -1007,13 +1029,13 @@
|
||||
"rating": "評分",
|
||||
"recentlyPlayed": "最近播放",
|
||||
"recentlyUpdated": "最近更新",
|
||||
"releaseDate": "發布日期",
|
||||
"releaseDate": "發行日期",
|
||||
"songCount": "曲目數",
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
||||
"favorited": "已收藏",
|
||||
"recentlyAdded": "最近新增",
|
||||
"releaseYear": "發布年份",
|
||||
"releaseYear": "發行年份",
|
||||
"search": "搜尋",
|
||||
"title": "標題",
|
||||
"toYear": "從年份",
|
||||
@@ -1034,8 +1056,8 @@
|
||||
"success": "伺服器新增成功",
|
||||
"title": "新增伺服器",
|
||||
"error_savePassword": "儲存密碼時出現錯誤",
|
||||
"ignoreCors": "忽略 cors $t(common.restartRequired)",
|
||||
"ignoreSsl": "忽略 ssl $t(common.restartRequired)",
|
||||
"ignoreCors": "忽略 CORS $t(common.restartRequired)",
|
||||
"ignoreSsl": "忽略 SSL $t(common.restartRequired)",
|
||||
"input_preferInstantMix": "偏好即時混音",
|
||||
"input_preferInstantMixDescription": "僅使用即時混音功能來取得相似歌曲。若您擁有能修改此行為的外掛,此功能將相當實用",
|
||||
"input_preferRemoteUrl": "優先使用公開網址",
|
||||
@@ -1098,8 +1120,8 @@
|
||||
"successMustClick": "分享建立成功,點擊此處開啟"
|
||||
},
|
||||
"privateMode": {
|
||||
"enabled": "已啟用私人模式,播放狀態將對外部整合隱藏",
|
||||
"disabled": "已停用私人模式,播放狀態現對已啟用的外部整合可見",
|
||||
"enabled": "已啟用私人模式,播放狀態現在將對外部整合功能隱藏",
|
||||
"disabled": "已停用私人模式,啟用的外部整合功能現在可查看播放狀態",
|
||||
"title": "私人模式"
|
||||
},
|
||||
"largeFetchConfirmation": {
|
||||
@@ -1133,7 +1155,7 @@
|
||||
"input_offset": "$t(setting.lyricOffset)"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "電臺更新成功"
|
||||
"success": "電台更新成功"
|
||||
}
|
||||
},
|
||||
"releaseType": {
|
||||
|
||||
@@ -4,3 +4,4 @@ import './player';
|
||||
import './remote';
|
||||
import './settings';
|
||||
import './discord-rpc';
|
||||
import './visualizer';
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
import { getMpvInstance } from '../player';
|
||||
import { store } from '../settings';
|
||||
|
||||
import { PlayerType } from '/@/shared/types/types';
|
||||
|
||||
let isLocalVisualizerSurfaceVisible = false;
|
||||
|
||||
export const setLocalVisualizerSurfaceVisible = (visible: boolean) => {
|
||||
isLocalVisualizerSurfaceVisible = visible;
|
||||
};
|
||||
|
||||
export const canHandleVisualizerDisplayMedia = (): boolean => {
|
||||
const playbackType = store.get('playbackType', PlayerType.WEB) as PlayerType;
|
||||
|
||||
if (playbackType !== PlayerType.LOCAL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isLocalVisualizerSurfaceVisible) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(getMpvInstance()?.isRunning());
|
||||
};
|
||||
|
||||
ipcMain.on('visualizer-set-local-surface-visible', (_event, visible: boolean) => {
|
||||
setLocalVisualizerSurfaceVisible(Boolean(visible));
|
||||
});
|
||||
+8
-2
@@ -30,7 +30,9 @@ import packageJson from '../../package.json';
|
||||
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
|
||||
import { shutdownServer } from './features/core/remote';
|
||||
import { store } from './features/core/settings';
|
||||
import { canHandleVisualizerDisplayMedia } from './features/core/visualizer';
|
||||
import MenuBuilder, { MenuPlaybackState } from './menu';
|
||||
import './features';
|
||||
import {
|
||||
autoUpdaterLogInterface,
|
||||
createLog,
|
||||
@@ -40,7 +42,6 @@ import {
|
||||
isMacOS,
|
||||
isWindows,
|
||||
} from './utils';
|
||||
import './features';
|
||||
|
||||
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
|
||||
|
||||
@@ -515,7 +516,7 @@ async function createWindow(first = true): Promise<void> {
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: true,
|
||||
devTools: true,
|
||||
nodeIntegration: true,
|
||||
nodeIntegration: false,
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
webSecurity: !store.get('ignore_cors'),
|
||||
@@ -734,6 +735,11 @@ async function createWindow(first = true): Promise<void> {
|
||||
});
|
||||
|
||||
mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
|
||||
if (!canHandleVisualizerDisplayMedia()) {
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMacOS()) {
|
||||
callback({ audio: 'loopback' });
|
||||
return;
|
||||
|
||||
Vendored
-3
@@ -1,11 +1,8 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload';
|
||||
|
||||
import { PreloadApi } from './index';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: PreloadApi;
|
||||
electron: ElectronAPI;
|
||||
LEGACY_AUTHENTICATION?: boolean;
|
||||
queryLocalFonts?: () => Promise<Font[]>;
|
||||
REMOTE_URL?: string;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { electronAPI } from '@electron-toolkit/preload';
|
||||
import { contextBridge } from 'electron';
|
||||
import { contextBridge, webUtils } from 'electron';
|
||||
|
||||
import { autodiscover } from './autodiscover';
|
||||
import { browser } from './browser';
|
||||
@@ -11,12 +10,14 @@ import { mpris } from './mpris';
|
||||
import { mpvPlayer, mpvPlayerListener } from './mpv-player';
|
||||
import { remote } from './remote';
|
||||
import { utils } from './utils';
|
||||
import { visualizer } from './visualizer';
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
autodiscover,
|
||||
browser,
|
||||
discordRpc,
|
||||
getPathForFile: webUtils.getPathForFile,
|
||||
ipc,
|
||||
localSettings,
|
||||
lyrics,
|
||||
@@ -25,6 +26,7 @@ const api = {
|
||||
mpvPlayerListener,
|
||||
remote,
|
||||
utils,
|
||||
visualizer,
|
||||
};
|
||||
|
||||
export type PreloadApi = typeof api;
|
||||
@@ -34,14 +36,11 @@ export type PreloadApi = typeof api;
|
||||
// just add to the DOM global.
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI);
|
||||
contextBridge.exposeInMainWorld('api', api);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore (define in dts)
|
||||
window.electron = electronAPI;
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
const setLocalSurfaceVisible = (visible: boolean) => {
|
||||
ipcRenderer.send('visualizer-set-local-surface-visible', visible);
|
||||
};
|
||||
|
||||
export const visualizer = {
|
||||
setLocalSurfaceVisible,
|
||||
};
|
||||
|
||||
export type VisualizerApi = typeof visualizer;
|
||||
@@ -100,9 +100,6 @@ const EXCLUDED_SONG_TAGS = new Set<string>(['disctotal', 'tracktotal']);
|
||||
const numericSortCollator = new Intl.Collator(undefined, { numeric: true });
|
||||
const collator = new Intl.Collator();
|
||||
|
||||
// Tags that use IDs as values as opposed to the tag value
|
||||
const ID_TAGS = new Set<string>(['albumversion', 'mood']);
|
||||
|
||||
const excludeMissing = (server?: null | ServerListItemWithCredential) => {
|
||||
if (!server) {
|
||||
return undefined;
|
||||
@@ -965,13 +962,13 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
if (!EXCLUDED_TAGS.has(tag.tagName)) {
|
||||
if (tagsToValues.has(tag.tagName)) {
|
||||
tagsToValues.get(tag.tagName)!.push({
|
||||
id: ID_TAGS.has(tag.tagName) ? tag.id : tag.tagValue,
|
||||
id: tag.id,
|
||||
name: tag.tagValue,
|
||||
});
|
||||
} else {
|
||||
tagsToValues.set(tag.tagName, [
|
||||
{
|
||||
id: ID_TAGS.has(tag.tagName) ? tag.id : tag.tagValue,
|
||||
id: tag.id,
|
||||
name: tag.tagValue,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -6,7 +6,7 @@ import styles from './drag-preview.module.css';
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { DragData } from '/@/shared/types/drag-and-drop';
|
||||
import { DragData, DragTarget } from '/@/shared/types/drag-and-drop';
|
||||
|
||||
interface DragPreviewProps {
|
||||
data: DragData;
|
||||
@@ -29,7 +29,8 @@ export const DragPreview = memo(({ data }: DragPreviewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const itemCount = items.length;
|
||||
const firstItem = items[0];
|
||||
const itemName = firstItem ? getItemName(firstItem) : 'Item';
|
||||
const folderName = data.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER ? data.id[0] : undefined;
|
||||
const itemName = folderName || (firstItem ? getItemName(firstItem) : 'Item');
|
||||
|
||||
const itemImage = useItemImageUrl({
|
||||
id: (firstItem as { imageId: string })?.imageId,
|
||||
@@ -50,6 +51,9 @@ export const DragPreview = memo(({ data }: DragPreviewProps) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles['icon-container']}>
|
||||
{data.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER && (
|
||||
<Icon icon="folder" size="xl" />
|
||||
)}
|
||||
{data.itemType === LibraryItem.ALBUM && <Icon icon="album" size="xl" />}
|
||||
{data.itemType === LibraryItem.SONG && (
|
||||
<Icon icon="itemSong" size="xl" />
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -16,11 +16,13 @@ export const useListHotkeys = ({
|
||||
focused,
|
||||
internalState,
|
||||
itemType,
|
||||
onShowPlayingSong,
|
||||
}: {
|
||||
controls: ItemControls;
|
||||
focused: boolean;
|
||||
internalState: ItemListStateActions;
|
||||
itemType: LibraryItem;
|
||||
onShowPlayingSong?: () => void;
|
||||
}) => {
|
||||
const { bindings } = useHotkeySettings();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
@@ -119,5 +121,11 @@ export const useListHotkeys = ({
|
||||
}
|
||||
},
|
||||
],
|
||||
[
|
||||
bindings.listShowPlayingSong.hotkey,
|
||||
() => {
|
||||
onShowPlayingSong?.();
|
||||
},
|
||||
],
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import { PlayCountColumn } from './play-count-column';
|
||||
import { RatingColumn } from './rating-column';
|
||||
import { ReleaseDateColumn } from './release-date-column';
|
||||
import { RowIndexColumn } from './row-index-column';
|
||||
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
|
||||
import { SampleRateColumn } from './sample-rate-column';
|
||||
import { SizeColumn } from './size-column';
|
||||
import { TitleArtistColumn } from './title-artist-column';
|
||||
@@ -111,6 +112,7 @@ export {
|
||||
GenreBadgeColumn,
|
||||
GenreColumn,
|
||||
ImageColumn,
|
||||
ItemDetailRowPlayControlCell,
|
||||
LastPlayedColumn,
|
||||
PathColumn,
|
||||
PlayCountColumn,
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
import styles from './row-index-column.module.css';
|
||||
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
import { useDetailRowPlayControl } from './use-detail-row-play-control';
|
||||
|
||||
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
||||
import { usePlayerStatus } from '/@/renderer/store';
|
||||
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
export const RowIndexColumn = ({ rowIndex, song }: ItemDetailListCellProps) => {
|
||||
const status = usePlayerStatus();
|
||||
const { isActive } = useIsCurrentSong(song);
|
||||
const isPlaying = isActive && status === PlayerStatus.PLAYING;
|
||||
|
||||
if (isActive) {
|
||||
return (
|
||||
<div className={styles.iconWrapper}>
|
||||
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { TableColumn } from '/@/shared/types/types';
|
||||
|
||||
const DefaultRowIndexColumn = ({ rowIndex }: ItemDetailListCellProps) => {
|
||||
return <>{String((rowIndex ?? 0) + 1)}</>;
|
||||
};
|
||||
|
||||
const PlayableRowIndexColumn = (props: ItemDetailListCellProps) => {
|
||||
const { handlePlay, isActive, isPlaying, showPlayControls } = useDetailRowPlayControl(props);
|
||||
|
||||
const indexContent = isActive ? (
|
||||
<div className={styles.iconWrapper}>
|
||||
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
|
||||
</div>
|
||||
) : (
|
||||
String((props.rowIndex ?? 0) + 1)
|
||||
);
|
||||
|
||||
return (
|
||||
<ItemDetailRowPlayControlCell
|
||||
indexContent={indexContent}
|
||||
onPlay={handlePlay}
|
||||
showPlayControls={showPlayControls}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const RowIndexColumn = (props: ItemDetailListCellProps) => {
|
||||
if (!props.columns || !isRowPlayControlColumn(TableColumn.ROW_INDEX, props.columns)) {
|
||||
return <DefaultRowIndexColumn {...props} />;
|
||||
}
|
||||
|
||||
return <PlayableRowIndexColumn {...props} />;
|
||||
};
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
.cell-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.play-target {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.index-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import styles from './row-play-control-cell.module.css';
|
||||
|
||||
import { ItemRowPlayControls } from '/@/renderer/features/shared/components/item-row-play-controls';
|
||||
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
export const ItemDetailRowPlayControlCell = ({
|
||||
indexContent,
|
||||
onPlay,
|
||||
showPlayControls,
|
||||
}: {
|
||||
indexContent: ReactNode;
|
||||
onPlay: (playType: Play) => void;
|
||||
showPlayControls: boolean;
|
||||
}) => {
|
||||
if (!showPlayControls) {
|
||||
return <>{indexContent}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.cellWrapper}>
|
||||
<HoverCard
|
||||
offset={{ crossAxis: 32, mainAxis: 16 }}
|
||||
openDelay={300}
|
||||
position="top"
|
||||
withArrow
|
||||
>
|
||||
<HoverCard.Target>
|
||||
<div className={styles.playTarget}>{indexContent}</div>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
|
||||
<ItemRowPlayControls onPlay={onPlay} />
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,46 @@
|
||||
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
|
||||
import styles from './row-play-control-cell.module.css';
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
import { useDetailRowPlayControl } from './use-detail-row-play-control';
|
||||
|
||||
export const TrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
|
||||
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { TableColumn } from '/@/shared/types/types';
|
||||
|
||||
const formatTrackNumber = (song: ItemDetailListCellProps['song']) => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
const track = song.trackNumber.toString().padStart(2, '0');
|
||||
return `${disc}-${track}`;
|
||||
};
|
||||
|
||||
const DefaultTrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
|
||||
return <>{formatTrackNumber(song)}</>;
|
||||
};
|
||||
|
||||
const PlayableTrackNumberColumn = (props: ItemDetailListCellProps) => {
|
||||
const { handlePlay, isActive, isPlaying, showPlayControls } = useDetailRowPlayControl(props);
|
||||
|
||||
const indexContent = isActive ? (
|
||||
<div className={styles.iconWrapper}>
|
||||
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
|
||||
</div>
|
||||
) : (
|
||||
formatTrackNumber(props.song)
|
||||
);
|
||||
|
||||
return (
|
||||
<ItemDetailRowPlayControlCell
|
||||
indexContent={indexContent}
|
||||
onPlay={handlePlay}
|
||||
showPlayControls={showPlayControls}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TrackNumberColumn = (props: ItemDetailListCellProps) => {
|
||||
if (!props.columns || !isRowPlayControlColumn(TableColumn.TRACK_NUMBER, props.columns)) {
|
||||
return <DefaultTrackNumberColumn {...props} />;
|
||||
}
|
||||
|
||||
return <PlayableTrackNumberColumn {...props} />;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||
import { ItemControls } from '/@/renderer/components/item-list/types';
|
||||
import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
export interface ItemDetailListCellProps {
|
||||
columns?: ItemTableListColumnConfig[];
|
||||
controls?: ItemControls;
|
||||
internalState?: ItemListStateActions;
|
||||
isMutatingFavorite?: boolean;
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
||||
import { usePlayerStatus } from '/@/renderer/store';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
import { Play, PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
export const useDetailRowPlayControl = ({
|
||||
internalState,
|
||||
rowIndex = 0,
|
||||
song,
|
||||
}: Pick<ItemDetailListCellProps, 'internalState' | 'rowIndex' | 'song'>) => {
|
||||
const status = usePlayerStatus();
|
||||
const player = usePlayer();
|
||||
const { isActive } = useIsCurrentSong(song);
|
||||
const isPlaying = isActive && status === PlayerStatus.PLAYING;
|
||||
|
||||
const showPlayControls = !!song?.id;
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(playType: Play) => {
|
||||
if (!song) {
|
||||
return;
|
||||
}
|
||||
|
||||
playSongFromItemListControl({
|
||||
index: rowIndex,
|
||||
internalState,
|
||||
item: song as Song,
|
||||
meta: { playType, singleSongOnly: true },
|
||||
player,
|
||||
});
|
||||
},
|
||||
[internalState, player, rowIndex, song],
|
||||
);
|
||||
|
||||
return {
|
||||
handlePlay,
|
||||
isActive,
|
||||
isPlaying,
|
||||
showPlayControls,
|
||||
};
|
||||
};
|
||||
@@ -447,6 +447,14 @@
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.row .track-cell-play-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.track-row-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import styles from './item-detail-list.module.css';
|
||||
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
|
||||
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
||||
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
|
||||
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
|
||||
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
|
||||
import {
|
||||
ItemListStateActions,
|
||||
@@ -365,6 +366,7 @@ const TrackRow = memo(
|
||||
const isTitleColumn = col.id === TableColumn.TITLE;
|
||||
const isImageColumn = col.id === TableColumn.IMAGE;
|
||||
const isIconActionColumn = isNoHorizontalPaddingColumn(col.id);
|
||||
const isPlayControlColumn = isRowPlayControlColumn(col.id, columns);
|
||||
const showHoverContent = shouldShowHoverOnlyColumnContent(
|
||||
col.id,
|
||||
isRowHovered,
|
||||
@@ -374,6 +376,7 @@ const TrackRow = memo(
|
||||
const content = isSongsLoading ? null : showHoverContent ? (
|
||||
<CellComponent
|
||||
columnId={col.id}
|
||||
columns={columns}
|
||||
controls={controls}
|
||||
internalState={internalState}
|
||||
isMutatingFavorite={isMutatingFavorite}
|
||||
@@ -393,6 +396,7 @@ const TrackRow = memo(
|
||||
[styles.trackCellImage]: isImageColumn,
|
||||
[styles.trackCellMuted]: !isTitleColumn,
|
||||
[styles.trackCellNoHPadding]: isIconActionColumn,
|
||||
[styles.trackCellPlayControl]: isPlayControlColumn,
|
||||
[styles.trackCellVerticalBorderVisible]:
|
||||
enableVerticalBorders && !isLastColumn,
|
||||
[styles.trackCellWithVerticalBorder]: !isLastColumn,
|
||||
|
||||
@@ -1,3 +1,45 @@
|
||||
.full-size-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
-webkit-line-clamp: unset;
|
||||
line-clamp: unset;
|
||||
}
|
||||
|
||||
.expansion-cell {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.expansion-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.play-target {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.expand {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 4;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.index-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -2,30 +2,38 @@ import clsx from 'clsx';
|
||||
|
||||
import styles from './row-index-column.module.css';
|
||||
|
||||
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
|
||||
import { RowPlayControlCell } from '/@/renderer/components/item-list/item-table-list/columns/row-play-control-cell';
|
||||
import { useRowPlayControl } from '/@/renderer/components/item-list/item-table-list/columns/use-row-play-control';
|
||||
import {
|
||||
ItemTableListInnerColumn,
|
||||
TableColumnContainer,
|
||||
TableColumnTextContainer,
|
||||
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
|
||||
import { ItemListItem } from '/@/renderer/components/item-list/types';
|
||||
import { usePlayerStatus } from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { TableColumn } from '/@/shared/types/types';
|
||||
|
||||
const RowIndexColumnBase = (props: ItemTableListInnerColumn) => {
|
||||
const { itemType } = props;
|
||||
|
||||
if (!isRowPlayControlColumn(TableColumn.ROW_INDEX, props.columns)) {
|
||||
return <DefaultRowIndexColumn {...props} />;
|
||||
}
|
||||
|
||||
switch (itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
case LibraryItem.ARTIST:
|
||||
case LibraryItem.FOLDER:
|
||||
case LibraryItem.PLAYLIST_SONG:
|
||||
case LibraryItem.QUEUE_SONG:
|
||||
case LibraryItem.SONG:
|
||||
return <QueueSongRowIndexColumn {...props} />;
|
||||
return <PlayableRowIndexColumn {...props} />;
|
||||
default:
|
||||
return <DefaultRowIndexColumn {...props} />;
|
||||
}
|
||||
@@ -88,12 +96,8 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
|
||||
return <TableColumnTextContainer {...props}>{adjustedRowIndex}</TableColumnTextContainer>;
|
||||
};
|
||||
|
||||
const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => {
|
||||
const status = usePlayerStatus();
|
||||
const song = (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex]) as QueueSong;
|
||||
const isActive = useIsActiveRow(song?.id, song?._uniqueId);
|
||||
|
||||
const isActiveAndPlaying = isActive && status === PlayerStatus.PLAYING;
|
||||
const PlayableRowIndexColumn = (props: ItemTableListInnerColumn) => {
|
||||
const { handlePlay, isActive, isPlaying, showPlayControls } = useRowPlayControl(props);
|
||||
|
||||
let adjustedRowIndex =
|
||||
props.getAdjustedRowIndex?.(props.rowIndex) ??
|
||||
@@ -104,38 +108,20 @@ const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => {
|
||||
adjustedRowIndex = props.startRowIndex + adjustedRowIndex;
|
||||
}
|
||||
|
||||
const indexContent = isActive ? (
|
||||
<Flex className={styles.indexContent}>
|
||||
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
|
||||
</Flex>
|
||||
) : (
|
||||
adjustedRowIndex
|
||||
);
|
||||
|
||||
return (
|
||||
<InnerQueueSongRowIndexColumn
|
||||
<RowPlayControlCell
|
||||
{...props}
|
||||
adjustedRowIndex={adjustedRowIndex}
|
||||
isActive={isActive}
|
||||
isPlaying={isActiveAndPlaying}
|
||||
indexContent={indexContent}
|
||||
onPlay={handlePlay}
|
||||
showPlayControls={showPlayControls}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const InnerQueueSongRowIndexColumn = (
|
||||
props: ItemTableListInnerColumn & {
|
||||
adjustedRowIndex: number;
|
||||
isActive: boolean;
|
||||
isPlaying: boolean;
|
||||
},
|
||||
) => {
|
||||
return (
|
||||
<TableColumnTextContainer {...props}>
|
||||
{props.isActive ? (
|
||||
props.isPlaying ? (
|
||||
<Flex>
|
||||
<Icon fill="primary" icon="mediaPlay" />
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex>
|
||||
<Icon fill="primary" icon="mediaPause" />
|
||||
</Flex>
|
||||
)
|
||||
) : (
|
||||
props.adjustedRowIndex
|
||||
)}
|
||||
</TableColumnTextContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import clsx from 'clsx';
|
||||
import { ReactNode, useCallback } from 'react';
|
||||
|
||||
import styles from './row-index-column.module.css';
|
||||
|
||||
import {
|
||||
ItemTableListInnerColumn,
|
||||
TableColumnContainer,
|
||||
TableColumnTextContainer,
|
||||
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { ItemListItem } from '/@/renderer/components/item-list/types';
|
||||
import { ItemRowPlayControls } from '/@/renderer/features/shared/components/item-row-play-controls';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
export const RowPlayControlCell = (
|
||||
props: ItemTableListInnerColumn & {
|
||||
indexContent: ReactNode;
|
||||
onPlay: (playType: Play) => void;
|
||||
showPlayControls: boolean;
|
||||
},
|
||||
) => {
|
||||
const {
|
||||
controls,
|
||||
data,
|
||||
enableExpansion,
|
||||
getRowItem,
|
||||
indexContent,
|
||||
internalState,
|
||||
itemType,
|
||||
onPlay,
|
||||
rowIndex,
|
||||
showPlayControls,
|
||||
} = props;
|
||||
|
||||
const handleExpand = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
const item = (getRowItem?.(rowIndex) ?? data[rowIndex]) as ItemListItem;
|
||||
const rowId = internalState.extractRowId(item);
|
||||
const index = rowId ? internalState.findItemIndex(rowId) : -1;
|
||||
controls.onExpand?.({
|
||||
event: e,
|
||||
index,
|
||||
internalState,
|
||||
item,
|
||||
itemType,
|
||||
});
|
||||
},
|
||||
[controls, data, getRowItem, internalState, itemType, rowIndex],
|
||||
);
|
||||
|
||||
const getIndexDisplay = (useMutedText: boolean) => {
|
||||
const hideOnHoverClass = enableExpansion ? 'hide-on-hover' : undefined;
|
||||
|
||||
if (typeof indexContent === 'number') {
|
||||
return useMutedText ? (
|
||||
<Text className={hideOnHoverClass} isMuted isNoSelect>
|
||||
{indexContent}
|
||||
</Text>
|
||||
) : (
|
||||
indexContent
|
||||
);
|
||||
}
|
||||
|
||||
return <span className={hideOnHoverClass}>{indexContent}</span>;
|
||||
};
|
||||
|
||||
const expansionTarget = (
|
||||
<div className={styles.playTarget}>
|
||||
{getIndexDisplay(true)}
|
||||
<ActionIcon
|
||||
className={clsx(styles.expand, 'hover-only')}
|
||||
icon="arrowDownS"
|
||||
iconProps={{ color: 'muted', size: 'md' }}
|
||||
onClick={handleExpand}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (enableExpansion) {
|
||||
return (
|
||||
<TableColumnContainer {...props} className={styles.expansionCell}>
|
||||
<div className={styles.expansionInner}>
|
||||
{showPlayControls ? (
|
||||
<HoverCard openDelay={300} position="top" withArrow withinPortal={false}>
|
||||
<HoverCard.Target>{expansionTarget}</HoverCard.Target>
|
||||
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
|
||||
<ItemRowPlayControls onPlay={onPlay} />
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
) : (
|
||||
expansionTarget
|
||||
)}
|
||||
</div>
|
||||
</TableColumnContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!showPlayControls) {
|
||||
return (
|
||||
<TableColumnTextContainer {...props}>{getIndexDisplay(false)}</TableColumnTextContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableColumnTextContainer {...props} className={styles.fullSizeContent}>
|
||||
<HoverCard openDelay={300} position="top" withArrow withinPortal={false}>
|
||||
<HoverCard.Target>
|
||||
<Flex className={styles.indexContent} justify="center" w="100%">
|
||||
{getIndexDisplay(false)}
|
||||
</Flex>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
|
||||
<ItemRowPlayControls onPlay={onPlay} />
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
</TableColumnTextContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import styles from './row-index-column.module.css';
|
||||
|
||||
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
|
||||
import { NumericColumn } from '/@/renderer/components/item-list/item-table-list/columns/numeric-column';
|
||||
import { RowPlayControlCell } from '/@/renderer/components/item-list/item-table-list/columns/row-play-control-cell';
|
||||
import {
|
||||
supportsTrackNumberRowPlayControls,
|
||||
useRowPlayControl,
|
||||
} from '/@/renderer/components/item-list/item-table-list/columns/use-row-play-control';
|
||||
import { ItemTableListInnerColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { TableColumn } from '/@/shared/types/types';
|
||||
|
||||
export const TrackNumberColumn = (props: ItemTableListInnerColumn) => {
|
||||
if (
|
||||
isRowPlayControlColumn(TableColumn.TRACK_NUMBER, props.columns) &&
|
||||
supportsTrackNumberRowPlayControls(props.itemType)
|
||||
) {
|
||||
return <PlayableTrackNumberColumn {...props} />;
|
||||
}
|
||||
|
||||
return <NumericColumn {...props} />;
|
||||
};
|
||||
|
||||
const PlayableTrackNumberColumn = (props: ItemTableListInnerColumn) => {
|
||||
const { handlePlay, isActive, isPlaying, showPlayControls } = useRowPlayControl(props);
|
||||
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as unknown[])[props.rowIndex];
|
||||
const trackNumber = (rowItem as undefined | { trackNumber?: number })?.trackNumber;
|
||||
|
||||
if (typeof trackNumber !== 'number') {
|
||||
return <NumericColumn {...props} />;
|
||||
}
|
||||
|
||||
const indexContent = isActive ? (
|
||||
<Flex className={styles.indexContent}>
|
||||
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
|
||||
</Flex>
|
||||
) : (
|
||||
trackNumber
|
||||
);
|
||||
|
||||
return (
|
||||
<RowPlayControlCell
|
||||
{...props}
|
||||
indexContent={indexContent}
|
||||
onPlay={handlePlay}
|
||||
showPlayControls={showPlayControls}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
playAlbumFromItemListControl,
|
||||
playArtistFromItemListControl,
|
||||
playSongFromItemListControl,
|
||||
} from '/@/renderer/components/item-list/helpers/play-row-from-list';
|
||||
import { ItemTableListInnerColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { usePlayerSong, usePlayerStatus } from '/@/renderer/store';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
Artist,
|
||||
LibraryItem,
|
||||
QueueSong,
|
||||
Song,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { Play, PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
export const supportsRowPlayControls = (itemType: LibraryItem) =>
|
||||
itemType === LibraryItem.ALBUM ||
|
||||
itemType === LibraryItem.ALBUM_ARTIST ||
|
||||
itemType === LibraryItem.ARTIST ||
|
||||
itemType === LibraryItem.PLAYLIST_SONG ||
|
||||
itemType === LibraryItem.SONG;
|
||||
|
||||
export const supportsTrackNumberRowPlayControls = (itemType: LibraryItem) =>
|
||||
itemType === LibraryItem.PLAYLIST_SONG ||
|
||||
itemType === LibraryItem.QUEUE_SONG ||
|
||||
itemType === LibraryItem.SONG;
|
||||
|
||||
export const hasPlayableRowItem = (
|
||||
itemType: LibraryItem,
|
||||
items: { album: Album; artist: AlbumArtist | Artist; song: QueueSong },
|
||||
) => {
|
||||
switch (itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
return !!items.album?.id;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
case LibraryItem.ARTIST:
|
||||
return !!items.artist?.id;
|
||||
default:
|
||||
return !!items.song;
|
||||
}
|
||||
};
|
||||
|
||||
export const useRowPlayControl = (props: ItemTableListInnerColumn) => {
|
||||
const status = usePlayerStatus();
|
||||
const currentSong = usePlayerSong();
|
||||
const player = usePlayer();
|
||||
const rowItem = props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex];
|
||||
const song = rowItem as QueueSong;
|
||||
const album = rowItem as Album;
|
||||
const artist = rowItem as AlbumArtist | Artist;
|
||||
|
||||
const isActiveFromRow = useIsActiveRow(song?.id, song?._uniqueId);
|
||||
const isActive = (() => {
|
||||
switch (props.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
return !!album?.id && currentSong?.albumId === album.id;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
return (
|
||||
!!artist?.id &&
|
||||
!!currentSong?.albumArtists?.some(
|
||||
(relatedArtist) => relatedArtist.id === artist.id,
|
||||
)
|
||||
);
|
||||
case LibraryItem.ARTIST:
|
||||
return (
|
||||
!!artist?.id &&
|
||||
!!currentSong?.artists?.some((relatedArtist) => relatedArtist.id === artist.id)
|
||||
);
|
||||
default:
|
||||
return isActiveFromRow;
|
||||
}
|
||||
})();
|
||||
|
||||
const isPlaying = isActive && status === PlayerStatus.PLAYING;
|
||||
|
||||
const showPlayControls =
|
||||
supportsRowPlayControls(props.itemType) &&
|
||||
hasPlayableRowItem(props.itemType, { album, artist, song });
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(playType: Play) => {
|
||||
if (props.itemType === LibraryItem.ALBUM) {
|
||||
if (!album?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
playAlbumFromItemListControl({
|
||||
album,
|
||||
meta: { playType },
|
||||
player,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
props.itemType === LibraryItem.ALBUM_ARTIST ||
|
||||
props.itemType === LibraryItem.ARTIST
|
||||
) {
|
||||
if (!artist?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
playArtistFromItemListControl({
|
||||
artist,
|
||||
itemType: props.itemType,
|
||||
meta: { playType },
|
||||
player,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!song) {
|
||||
return;
|
||||
}
|
||||
|
||||
playSongFromItemListControl({
|
||||
item: song as Song,
|
||||
meta: { playType, singleSongOnly: true },
|
||||
player,
|
||||
});
|
||||
},
|
||||
[album, artist, player, props.itemType, song],
|
||||
);
|
||||
|
||||
return {
|
||||
album,
|
||||
artist,
|
||||
handlePlay,
|
||||
isActive,
|
||||
isPlaying,
|
||||
showPlayControls,
|
||||
song,
|
||||
};
|
||||
};
|
||||
@@ -44,7 +44,6 @@ import { FavoriteColumn } from '/@/renderer/components/item-list/item-table-list
|
||||
import { GenreBadgeColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-badge-column';
|
||||
import { GenreColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-column';
|
||||
import { ImageColumn } from '/@/renderer/components/item-list/item-table-list/columns/image-column';
|
||||
import { NumericColumn } from '/@/renderer/components/item-list/item-table-list/columns/numeric-column';
|
||||
import { PathColumn } from '/@/renderer/components/item-list/item-table-list/columns/path-column';
|
||||
import { PlaylistReorderColumn } from '/@/renderer/components/item-list/item-table-list/columns/playlist-reorder-column';
|
||||
import { RatingColumn } from '/@/renderer/components/item-list/item-table-list/columns/rating-column';
|
||||
@@ -54,6 +53,7 @@ import { TextColumn } from '/@/renderer/components/item-list/item-table-list/col
|
||||
import { TitleArtistColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-artist-column';
|
||||
import { TitleColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-column';
|
||||
import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-combined-column';
|
||||
import { TrackNumberColumn } from '/@/renderer/components/item-list/item-table-list/columns/track-number-column';
|
||||
import { YearColumn } from '/@/renderer/components/item-list/item-table-list/columns/year-column';
|
||||
import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state';
|
||||
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
|
||||
@@ -240,7 +240,9 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
|
||||
case TableColumn.DISC_NUMBER:
|
||||
case TableColumn.SAMPLE_RATE:
|
||||
case TableColumn.TRACK_NUMBER:
|
||||
return <NumericColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
return (
|
||||
<TrackNumberColumn {...props} {...dragProps} controls={controls} type={type} />
|
||||
);
|
||||
|
||||
case TableColumn.COMPOSER:
|
||||
return <ComposerColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
ItemTableListColumnConfig,
|
||||
} from '/@/renderer/components/item-list/types';
|
||||
import { PlayerContext, usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { usePlayerStore } from '/@/renderer/store';
|
||||
import { animationProps } from '/@/shared/components/animations/animation-props';
|
||||
import { useFocusWithin } from '/@/shared/hooks/use-focus-within';
|
||||
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
|
||||
@@ -1596,11 +1597,22 @@ const BaseItemTableList = ({
|
||||
],
|
||||
);
|
||||
|
||||
const onShowPlayingSong = useCallback(() => {
|
||||
const targetId = usePlayerStore.getState().getCurrentSong()?.id;
|
||||
if (!targetId) return;
|
||||
const index =
|
||||
getItemIndex?.(targetId) ??
|
||||
data.findIndex((item) => (item as null | { id?: string })?.id === targetId);
|
||||
if (index === undefined || index < 0) return;
|
||||
handleRef.current?.scrollToIndex(index, { align: 'center', behavior: 'auto' });
|
||||
}, [data, getItemIndex]);
|
||||
|
||||
useListHotkeys({
|
||||
controls,
|
||||
focused,
|
||||
internalState,
|
||||
itemType,
|
||||
onShowPlayingSong,
|
||||
});
|
||||
|
||||
const tableConfigValue = useMemo<ItemTableListConfig>(
|
||||
|
||||
@@ -12,6 +12,7 @@ import styles from './album-detail-content.module.css';
|
||||
|
||||
import { useGridCarouselContainerQuery } from '/@/renderer/components/grid-carousel/grid-carousel-v2';
|
||||
import { useItemListStateSubscription } from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
|
||||
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
||||
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||
@@ -60,7 +61,7 @@ import {
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';
|
||||
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
const MetadataPillGroup = ({
|
||||
items,
|
||||
@@ -830,13 +831,13 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const playType = (meta?.playType as Play) || Play.NOW;
|
||||
|
||||
const items = internalState?.getData() as Song[];
|
||||
|
||||
if (index !== undefined) {
|
||||
player.addToQueueByData(items, playType, item.id);
|
||||
}
|
||||
playSongFromItemListControl({
|
||||
index,
|
||||
internalState,
|
||||
item: item as Song,
|
||||
meta,
|
||||
player,
|
||||
});
|
||||
},
|
||||
};
|
||||
}, [player]);
|
||||
|
||||
@@ -14,6 +14,7 @@ import styles from './album-artist-detail-content.module.css';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card';
|
||||
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
|
||||
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
|
||||
import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';
|
||||
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
||||
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
||||
@@ -365,12 +366,13 @@ const AlbumArtistMetadataTopSongsContent = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const playType = (meta?.playType as Play) || Play.NOW;
|
||||
const items = internalState?.getData() as Song[];
|
||||
|
||||
if (index !== undefined) {
|
||||
player.addToQueueByData(items, playType, item.id);
|
||||
}
|
||||
playSongFromItemListControl({
|
||||
index,
|
||||
internalState,
|
||||
item: item as Song,
|
||||
meta,
|
||||
player,
|
||||
});
|
||||
},
|
||||
};
|
||||
}, [player]);
|
||||
@@ -657,12 +659,13 @@ const AlbumArtistMetadataFavoriteSongs = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const playType = (meta?.playType as Play) || Play.NOW;
|
||||
const items = internalState?.getData() as Song[];
|
||||
|
||||
if (index !== undefined) {
|
||||
player.addToQueueByData(items, playType, item.id);
|
||||
}
|
||||
playSongFromItemListControl({
|
||||
index,
|
||||
internalState,
|
||||
item: item as Song,
|
||||
meta,
|
||||
player,
|
||||
});
|
||||
},
|
||||
};
|
||||
}, [player]);
|
||||
|
||||
+9
-7
@@ -2,6 +2,7 @@ import { useSuspenseQueries } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
|
||||
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
||||
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
||||
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
|
||||
@@ -24,7 +25,7 @@ import { useCurrentServer } from '/@/renderer/store/auth.store';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { sortSongList } from '/@/shared/api/utils';
|
||||
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, Play } from '/@/shared/types/types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
const AlbumArtistDetailFavoriteSongsListRoute = () => {
|
||||
const { albumArtistId, artistId } = useParams() as {
|
||||
@@ -96,12 +97,13 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const playType = (meta?.playType as Play) || Play.NOW;
|
||||
const items = internalState?.getData() as Song[];
|
||||
|
||||
if (index !== undefined) {
|
||||
player.addToQueueByData(items, playType, item.id);
|
||||
}
|
||||
playSongFromItemListControl({
|
||||
index,
|
||||
internalState,
|
||||
item: item as Song,
|
||||
meta,
|
||||
player,
|
||||
});
|
||||
},
|
||||
};
|
||||
}, [player]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
|
||||
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
||||
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
||||
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
|
||||
@@ -18,7 +19,7 @@ import { useCurrentServer } from '/@/renderer/store/auth.store';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, Play } from '/@/shared/types/types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
const AlbumArtistDetailTopSongsListRoute = () => {
|
||||
const { albumArtistId, artistId } = useParams() as {
|
||||
@@ -78,12 +79,13 @@ const AlbumArtistDetailTopSongsListRoute = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const playType = (meta?.playType as Play) || Play.NOW;
|
||||
const items = internalState?.getData() as Song[];
|
||||
|
||||
if (index !== undefined) {
|
||||
player.addToQueueByData(items, playType, item.id);
|
||||
}
|
||||
playSongFromItemListControl({
|
||||
index,
|
||||
internalState,
|
||||
item: item as Song,
|
||||
meta,
|
||||
player,
|
||||
});
|
||||
},
|
||||
};
|
||||
}, [player]);
|
||||
|
||||
@@ -12,10 +12,15 @@ import { Play } from '/@/shared/types/types';
|
||||
|
||||
interface PlayTrackRadioActionProps {
|
||||
disabled?: boolean;
|
||||
skipFirstSong?: boolean;
|
||||
song: Song;
|
||||
}
|
||||
|
||||
export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionProps) => {
|
||||
export const PlayTrackRadioAction = ({
|
||||
disabled,
|
||||
skipFirstSong,
|
||||
song,
|
||||
}: PlayTrackRadioActionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const serverId = useCurrentServerId();
|
||||
@@ -38,13 +43,17 @@ export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionPro
|
||||
});
|
||||
|
||||
if (similarSongs && similarSongs.length > 0) {
|
||||
player.addToQueueByData([song, ...similarSongs], playType);
|
||||
// We need to skip the first song when adding to the queue as NEXT or LAST, otherwise you will have a duplicate song
|
||||
const shouldSkipFirstSong =
|
||||
skipFirstSong && (playType === Play.NEXT || playType === Play.LAST);
|
||||
const queueSongs = shouldSkipFirstSong ? similarSongs : [song, ...similarSongs];
|
||||
player.addToQueueByData(queueSongs, playType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load track radio:', error);
|
||||
}
|
||||
},
|
||||
[player, queryClient, serverId, song],
|
||||
[player, queryClient, serverId, skipFirstSong, song],
|
||||
);
|
||||
|
||||
const handlePlayTrackRadioNow = useCallback(() => {
|
||||
|
||||
@@ -35,7 +35,7 @@ export const QueueContextMenu = ({ items }: QueueContextMenuProps) => {
|
||||
<MoveQueueItemsAction items={items} />
|
||||
<ShuffleItemsAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<PlayTrackRadioAction disabled={items.length > 1} song={items[0]} />
|
||||
<PlayTrackRadioAction disabled={items.length > 1} skipFirstSong song={items[0]} />
|
||||
<ContextMenu.Divider />
|
||||
<AddToPlaylistAction items={ids} itemType={LibraryItem.SONG} />
|
||||
<ContextMenu.Divider />
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface WebPlayerEngineHandle extends AudioPlayer {
|
||||
interface WebPlayerEngineProps {
|
||||
isMuted: boolean;
|
||||
isTransitioning: boolean;
|
||||
loopPlayer1: boolean;
|
||||
loopPlayer2: boolean;
|
||||
onEndedPlayer1: () => void;
|
||||
onEndedPlayer2: () => void;
|
||||
onErrorPause: () => void;
|
||||
@@ -55,6 +57,8 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
||||
const {
|
||||
isMuted,
|
||||
isTransitioning,
|
||||
loopPlayer1,
|
||||
loopPlayer2,
|
||||
onEndedPlayer1,
|
||||
onEndedPlayer2,
|
||||
onErrorPause,
|
||||
@@ -292,8 +296,9 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
||||
controls={false}
|
||||
height={0}
|
||||
id="web-player-1"
|
||||
loop={loopPlayer1}
|
||||
muted={isMuted}
|
||||
onEnded={src1 ? () => onEndedPlayer1() : undefined}
|
||||
onEnded={src1 && !loopPlayer1 ? () => onEndedPlayer1() : undefined}
|
||||
onError={handleOnError(
|
||||
player1Ref,
|
||||
() => onEndedPlayer1(),
|
||||
@@ -317,8 +322,9 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
|
||||
controls={false}
|
||||
height={0}
|
||||
id="web-player-2"
|
||||
loop={loopPlayer2}
|
||||
muted={isMuted}
|
||||
onEnded={src2 ? () => onEndedPlayer2() : undefined}
|
||||
onEnded={src2 && !loopPlayer2 ? () => onEndedPlayer2() : undefined}
|
||||
onError={handleOnError(
|
||||
player2Ref,
|
||||
() => onEndedPlayer2(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import type ReactPlayer from 'react-player';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||
import {
|
||||
WebPlayerEngine,
|
||||
WebPlayerEngineHandle,
|
||||
@@ -20,12 +21,13 @@ import {
|
||||
usePlayerData,
|
||||
usePlayerMuted,
|
||||
usePlayerProperties,
|
||||
usePlayerRepeat,
|
||||
usePlayerStoreBase,
|
||||
usePlayerVolume,
|
||||
} from '/@/renderer/store';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { QueueSong } from '/@/shared/types/domain-types';
|
||||
import { CrossfadeStyle, PlayerStatus, PlayerStyle } from '/@/shared/types/types';
|
||||
import { CrossfadeStyle, PlayerRepeat, PlayerStatus, PlayerStyle } from '/@/shared/types/types';
|
||||
|
||||
const PLAY_PAUSE_FADE_DURATION = 300;
|
||||
const PLAY_PAUSE_FADE_INTERVAL = 10;
|
||||
@@ -34,6 +36,8 @@ export function WebPlayer() {
|
||||
const playerRef = useRef<null | WebPlayerEngineHandle>(null);
|
||||
const { t } = useTranslation();
|
||||
const { num, player1, player2, status } = usePlayerData();
|
||||
const repeat = usePlayerRepeat();
|
||||
const repeatOneProgressRef = useRef({ player1: 0, player2: 0 });
|
||||
const { mediaAutoNext, mediaPause, setTimestamp } = usePlayerActions();
|
||||
const playback = useMpvSettings();
|
||||
const { webAudio } = useWebAudio();
|
||||
@@ -97,12 +101,37 @@ export function WebPlayer() {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRepeatOne = useCallback(
|
||||
(playerId: 1 | 2, playedSeconds: number, duration: number) => {
|
||||
if (repeat !== PlayerRepeat.ONE || duration <= 0 || num !== playerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = playerId === 1 ? 'player1' : 'player2';
|
||||
const last = repeatOneProgressRef.current[key];
|
||||
repeatOneProgressRef.current[key] = playedSeconds;
|
||||
|
||||
if (last > duration * 0.85 && playedSeconds < duration * 0.15) {
|
||||
setTimestamp(0);
|
||||
eventEmitter.emit('PLAYER_REPEATED', {
|
||||
index: usePlayerStoreBase.getState().player.index,
|
||||
});
|
||||
}
|
||||
},
|
||||
[num, repeat, setTimestamp],
|
||||
);
|
||||
|
||||
const onProgressPlayer1 = useCallback(
|
||||
(e: PlayerOnProgressProps) => {
|
||||
if (!playerRef.current?.player1()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (repeat === PlayerRepeat.ONE) {
|
||||
handleRepeatOne(1, e.playedSeconds, getDuration(playerRef.current.player1().ref));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (transitionType) {
|
||||
case PlayerStyle.CROSSFADE:
|
||||
crossfadeHandler({
|
||||
@@ -132,7 +161,17 @@ export function WebPlayer() {
|
||||
break;
|
||||
}
|
||||
},
|
||||
[crossfadeDuration, crossfadeStyle, isTransitioning, num, player2, transitionType, volume],
|
||||
[
|
||||
crossfadeDuration,
|
||||
crossfadeStyle,
|
||||
handleRepeatOne,
|
||||
isTransitioning,
|
||||
num,
|
||||
player2,
|
||||
repeat,
|
||||
transitionType,
|
||||
volume,
|
||||
],
|
||||
);
|
||||
|
||||
const onProgressPlayer2 = useCallback(
|
||||
@@ -141,6 +180,11 @@ export function WebPlayer() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (repeat === PlayerRepeat.ONE) {
|
||||
handleRepeatOne(2, e.playedSeconds, getDuration(playerRef.current.player2().ref));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (transitionType) {
|
||||
case PlayerStyle.CROSSFADE:
|
||||
crossfadeHandler({
|
||||
@@ -170,7 +214,17 @@ export function WebPlayer() {
|
||||
break;
|
||||
}
|
||||
},
|
||||
[crossfadeDuration, crossfadeStyle, isTransitioning, num, player1, transitionType, volume],
|
||||
[
|
||||
crossfadeDuration,
|
||||
crossfadeStyle,
|
||||
handleRepeatOne,
|
||||
isTransitioning,
|
||||
num,
|
||||
player1,
|
||||
repeat,
|
||||
transitionType,
|
||||
volume,
|
||||
],
|
||||
);
|
||||
|
||||
const handleOnEndedPlayer1 = useCallback(() => {
|
||||
@@ -474,10 +528,15 @@ export function WebPlayer() {
|
||||
});
|
||||
}, [mediaPause, t]);
|
||||
|
||||
const loopPlayer1 = repeat === PlayerRepeat.ONE && num === 1;
|
||||
const loopPlayer2 = repeat === PlayerRepeat.ONE && num === 2;
|
||||
|
||||
return (
|
||||
<WebPlayerEngine
|
||||
isMuted={isMuted}
|
||||
isTransitioning={Boolean(isTransitioning)}
|
||||
loopPlayer1={loopPlayer1}
|
||||
loopPlayer2={loopPlayer2}
|
||||
onEndedPlayer1={handleOnEndedPlayer1}
|
||||
onEndedPlayer2={handleOnEndedPlayer2}
|
||||
onErrorPause={handleOnErrorPause}
|
||||
|
||||
@@ -14,7 +14,10 @@ import { MediaSessionHook } from '/@/renderer/features/player/hooks/use-media-se
|
||||
import { MPRISHook } from '/@/renderer/features/player/hooks/use-mpris';
|
||||
import { PlaybackHotkeysHook } from '/@/renderer/features/player/hooks/use-playback-hotkeys';
|
||||
import { PowerSaveBlockerHook } from '/@/renderer/features/player/hooks/use-power-save-blocker';
|
||||
import { QueueRestoreTimestampHook } from '/@/renderer/features/player/hooks/use-queue-restore';
|
||||
import {
|
||||
InitialTimestampRestoreHook,
|
||||
QueueRestoreTimestampHook,
|
||||
} from '/@/renderer/features/player/hooks/use-queue-restore';
|
||||
import { ScrobbleHook } from '/@/renderer/features/player/hooks/use-scrobble';
|
||||
import { UpdateCurrentSongHook } from '/@/renderer/features/player/hooks/use-update-current-song';
|
||||
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
|
||||
@@ -134,6 +137,7 @@ export const AudioPlayers = () => {
|
||||
<RemoteHook />
|
||||
<AutoDJHook />
|
||||
<QueueRestoreTimestampHook />
|
||||
<InitialTimestampRestoreHook />
|
||||
<UpdateCurrentSongHook />
|
||||
<RadioAudioInstanceHook />
|
||||
<RadioMetadataHook />
|
||||
|
||||
@@ -59,9 +59,24 @@
|
||||
.slider-value-wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 50px;
|
||||
min-height: 0;
|
||||
|
||||
@media (width < 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-value-wrapper-elapsed {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
max-width: 4.75rem;
|
||||
min-height: 0;
|
||||
|
||||
@media (width < 768px) {
|
||||
display: none;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { lazy, Suspense } from 'react';
|
||||
import { PlayerbarSeekSlider } from './playerbar-seek-slider';
|
||||
import styles from './playerbar-slider.module.css';
|
||||
|
||||
import { ScrobbleStatus } from '/@/renderer/features/player/components/scrobble-status';
|
||||
import {
|
||||
useAppStore,
|
||||
useAppStoreActions,
|
||||
@@ -41,17 +42,8 @@ export const PlayerbarSlider = () => {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.sliderContainer}>
|
||||
<div className={styles.sliderValueWrapper}>
|
||||
<Text
|
||||
className={PlaybackSelectors.elapsedTime}
|
||||
fw={600}
|
||||
isMuted
|
||||
isNoSelect
|
||||
size="xs"
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
<div className={styles.sliderValueWrapperElapsed}>
|
||||
<ScrobbleStatus formattedTime={formattedTime} />
|
||||
</div>
|
||||
<div className={styles.sliderWrapper}>
|
||||
{isWaveform ? (
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
invokeScrobbleForceSubmit,
|
||||
invokeScrobbleResetListenedState,
|
||||
} from '/@/renderer/features/player/hooks/use-scrobble';
|
||||
import { useAppStore, useScrobbleDebugStore, useSettingsStore } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Progress } from '/@/shared/components/progress/progress';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
||||
|
||||
const scrobbleProgressProps = {
|
||||
'aria-hidden': true,
|
||||
color: 'var(--theme-colors-primary)',
|
||||
size: 'xs' as const,
|
||||
};
|
||||
|
||||
const clampPct = (n: number) => Math.min(100, Math.max(0, n));
|
||||
|
||||
const ScrobbleConditionProgress = ({ value }: { value: number }) => (
|
||||
<Progress {...scrobbleProgressProps} value={value} w="100%" />
|
||||
);
|
||||
|
||||
export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const scrobbleEnabled = useSettingsStore((state) => state.playback.scrobble.enabled);
|
||||
const privateMode = useAppStore((state) => state.privateMode);
|
||||
const snapshot = useScrobbleDebugStore((state) => state.snapshot);
|
||||
|
||||
const hookInactive = !scrobbleEnabled || privateMode;
|
||||
|
||||
const listenedSec = (snapshot.listenedMs / 1000).toFixed(1);
|
||||
const listenPercentOfTrack =
|
||||
snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0;
|
||||
|
||||
const durationConditionPct =
|
||||
snapshot.targetDurationSec > 0
|
||||
? clampPct((snapshot.listenedMs / 1000 / snapshot.targetDurationSec) * 100)
|
||||
: 0;
|
||||
const percentConditionPct =
|
||||
snapshot.targetPercentage > 0 && snapshot.trackDurationMs > 0
|
||||
? clampPct((listenPercentOfTrack / snapshot.targetPercentage) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<HoverCard position="top" width={280}>
|
||||
<HoverCard.Target>
|
||||
<Group
|
||||
align="center"
|
||||
aria-label={`${t('player.scrobble')}, ${formattedTime}`}
|
||||
fz="xs"
|
||||
gap="sm"
|
||||
justify="center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ userSelect: 'none' }}
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Icon
|
||||
aria-hidden
|
||||
color={snapshot.submitted ? 'primary' : 'transparent'}
|
||||
fill={snapshot.submitted ? 'primary' : 'transparent'}
|
||||
icon="circle"
|
||||
size="0.375rem"
|
||||
/>
|
||||
<Text
|
||||
className={PlaybackSelectors.elapsedTime}
|
||||
fw={600}
|
||||
fz="inherit"
|
||||
isMuted
|
||||
isNoSelect
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
</Group>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
|
||||
<Stack gap="md" p="sm">
|
||||
{hookInactive ? (
|
||||
<Text size="sm">{t('form.privateMode.enabled')}</Text>
|
||||
) : (
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs">
|
||||
{`${listenedSec}s / ${snapshot.targetDurationSec}s`}
|
||||
</Text>
|
||||
<ScrobbleConditionProgress value={durationConditionPct} />
|
||||
</Stack>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs">
|
||||
{`${listenPercentOfTrack.toFixed(1)}% / ${snapshot.targetPercentage}%`}
|
||||
</Text>
|
||||
<ScrobbleConditionProgress value={percentConditionPct} />
|
||||
</Stack>
|
||||
<Group gap="xs" grow wrap="nowrap">
|
||||
<Button
|
||||
disabled={!snapshot.songId}
|
||||
onClick={() => invokeScrobbleResetListenedState()}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
>
|
||||
{t('common.reset')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!snapshot.songId || snapshot.submitted}
|
||||
onClick={() => invokeScrobbleForceSubmit()}
|
||||
size="xs"
|
||||
variant="filled"
|
||||
>
|
||||
{t('player.scrobbleForceSubmit')}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||
@@ -9,13 +9,18 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import {
|
||||
setTimestamp,
|
||||
useCurrentServerId,
|
||||
usePlayerActions,
|
||||
usePlayerHydrated,
|
||||
usePlayerSong,
|
||||
usePlayerStatus,
|
||||
usePlayerStore,
|
||||
useTimestampStoreBase,
|
||||
} from '/@/renderer/store';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
export const useQueueRestoreTimestamp = () => {
|
||||
const player = usePlayerStore();
|
||||
const { mediaSeekToTimestamp } = usePlayerActions();
|
||||
|
||||
usePlayerEvents(
|
||||
{
|
||||
@@ -24,7 +29,7 @@ export const useQueueRestoreTimestamp = () => {
|
||||
|
||||
setTimeout(() => {
|
||||
setTimestamp(position);
|
||||
player.mediaSeekToTimestamp(position);
|
||||
mediaSeekToTimestamp(position);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
@@ -37,6 +42,72 @@ export const QueueRestoreTimestampHook = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useInitialTimestampRestore = () => {
|
||||
const { mediaSeekToTimestamp } = usePlayerActions();
|
||||
const playerHydrated = usePlayerHydrated();
|
||||
const currentSong = usePlayerSong();
|
||||
const playerStatus = usePlayerStatus();
|
||||
const timestamp = useTimestampStoreBase((state) => state.timestamp);
|
||||
|
||||
const startupRestoreInitializedRef = useRef(false);
|
||||
const startupSeekArmedRef = useRef<null | number>(null);
|
||||
const startupSeekAppliedRef = useRef(false);
|
||||
|
||||
const applyStartupSeek = useCallback(() => {
|
||||
if (startupSeekAppliedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seekTimestamp = startupSeekArmedRef.current;
|
||||
if (!seekTimestamp || seekTimestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
startupSeekAppliedRef.current = true;
|
||||
startupSeekArmedRef.current = null;
|
||||
|
||||
setTimeout(() => {
|
||||
mediaSeekToTimestamp(seekTimestamp);
|
||||
}, 100);
|
||||
}, [mediaSeekToTimestamp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (startupRestoreInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!playerHydrated || !currentSong) {
|
||||
return;
|
||||
}
|
||||
|
||||
startupRestoreInitializedRef.current = true;
|
||||
|
||||
if (timestamp > 0) {
|
||||
startupSeekArmedRef.current = timestamp;
|
||||
}
|
||||
|
||||
if (playerStatus === PlayerStatus.PLAYING) {
|
||||
applyStartupSeek();
|
||||
}
|
||||
}, [applyStartupSeek, currentSong, playerHydrated, playerStatus, timestamp]);
|
||||
|
||||
usePlayerEvents(
|
||||
{
|
||||
onPlayerStatus: (properties) => {
|
||||
if (properties.status === PlayerStatus.PLAYING) {
|
||||
applyStartupSeek();
|
||||
}
|
||||
},
|
||||
},
|
||||
[applyStartupSeek],
|
||||
);
|
||||
};
|
||||
|
||||
export const InitialTimestampRestoreHook = () => {
|
||||
useInitialTimestampRestore();
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useSaveQueue = () => {
|
||||
const serverId = useCurrentServerId();
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
|
||||
import {
|
||||
publishScrobbleDebug,
|
||||
useAppStore,
|
||||
usePlaybackSettings,
|
||||
usePlayerSong,
|
||||
@@ -16,34 +17,64 @@ import { logMsg } from '/@/renderer/utils/logger-message';
|
||||
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
type ScrobbleManualHandlers = {
|
||||
forceSubmitScrobble: () => void;
|
||||
resetListenedState: () => void;
|
||||
};
|
||||
|
||||
let scrobbleManualHandlers: null | ScrobbleManualHandlers = null;
|
||||
|
||||
export const registerScrobbleManualHandlers = (next: null | ScrobbleManualHandlers) => {
|
||||
scrobbleManualHandlers = next;
|
||||
};
|
||||
|
||||
export const invokeScrobbleForceSubmit = () => {
|
||||
scrobbleManualHandlers?.forceSubmitScrobble();
|
||||
};
|
||||
|
||||
export const invokeScrobbleResetListenedState = () => {
|
||||
scrobbleManualHandlers?.resetListenedState();
|
||||
};
|
||||
|
||||
/*
|
||||
Scrobble Conditions (match any):
|
||||
- If the song has been played for the required percentage
|
||||
- If the song has been played for the required duration
|
||||
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 duration (seconds)
|
||||
|
||||
Scrobble Events:
|
||||
- On song timestamp update:
|
||||
- If the song has been played for the required percentage
|
||||
- If the song has been played for the required duration
|
||||
Listen time advances only while PLAYING, from consecutive timestamp deltas.
|
||||
Seeks and other timeline jumps re-baseline the next sample without counting
|
||||
the jump as listen time; accumulated listen time is kept across seeks.
|
||||
|
||||
- When the song changes (or is completed):
|
||||
- Current song: Sends the 'playing' scrobble event
|
||||
- Resets the 'isCurrentSongScrobbled' state to false
|
||||
Listen time and submission state reset when the playhead returns to the start
|
||||
of the track (position before SCROBBLE_TRACK_BEGIN_SEC), e.g. seek-to-start or
|
||||
restart-from-near-zero. Song change and repeat still reset for a new play-through.
|
||||
|
||||
- When the song is restarted:
|
||||
- Sends the 'submission' scrobble event if conditions are met AND the 'isCurrentSongScrobbled' state is false
|
||||
- Resets the 'isCurrentSongScrobbled' state to false
|
||||
Jellyfin progress APIs still use playback position (ticks), not listen time:
|
||||
- Periodic timeupdate while playing
|
||||
- timeupdate on seek
|
||||
- pause / unpause
|
||||
|
||||
- When the song is seeked:
|
||||
- Sends the 'timeupdate' scrobble event (Jellyfin only)
|
||||
Other events:
|
||||
- When the song changes: sends 'start' when the new track is playing;
|
||||
clears submission flag and listen accumulator for the new track.
|
||||
|
||||
- When the song is restarted (near 0 after 10s+): clears submission flag
|
||||
and listen accumulator.
|
||||
|
||||
Progress Events:
|
||||
- When the song is playing (Jellyfin only):
|
||||
- Sends the 'progress' scrobble event on an interval
|
||||
|
||||
- When the song is seeked: Jellyfin sends timeupdate (throttled). Seeking from
|
||||
at/after the intro into the start of the track clears listen accumulator and
|
||||
submission flag; other seeks keep accumulated listen time.
|
||||
*/
|
||||
|
||||
// Positions before this time (seconds) count as the start of the track for listen/scrobble resets.
|
||||
const SCROBBLE_TRACK_BEGIN_SEC = 5;
|
||||
|
||||
// Min previous position (seconds) to treat a jump to the start as a full restart.
|
||||
const SCROBBLE_RESTART_PREVIOUS_MIN_SEC = 10;
|
||||
|
||||
// Max seconds between timestamp samples to count as continuous play (above poll interval, below a teleport).
|
||||
const MAX_LISTEN_DELTA_SEC = 5;
|
||||
|
||||
const checkScrobbleConditions = (args: {
|
||||
scrobbleAtDurationMs: number;
|
||||
scrobbleAtPercentage: number;
|
||||
@@ -56,10 +87,10 @@ const checkScrobbleConditions = (args: {
|
||||
? (songCompletedDurationMs / songDurationMs) * 100
|
||||
: 0;
|
||||
|
||||
const shouldScrobbleBasedOnPercetange = percentageOfSongCompleted >= scrobbleAtPercentage;
|
||||
const shouldScrobbleBasedOnPercentage = percentageOfSongCompleted >= scrobbleAtPercentage;
|
||||
const shouldScrobbleBasedOnDuration = songCompletedDurationMs >= scrobbleAtDurationMs;
|
||||
|
||||
return shouldScrobbleBasedOnPercetange || shouldScrobbleBasedOnDuration;
|
||||
return shouldScrobbleBasedOnPercentage || shouldScrobbleBasedOnDuration;
|
||||
};
|
||||
|
||||
export const useScrobble = () => {
|
||||
@@ -77,7 +108,12 @@ export const useScrobble = () => {
|
||||
});
|
||||
|
||||
const imageUrlRef = useRef<null | string | undefined>(imageUrl);
|
||||
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
|
||||
const isCurrentSongScrobbledRef = useRef(false);
|
||||
const listenedMsRef = useRef(0);
|
||||
const lastListenSampleTimeRef = useRef<null | number>(null);
|
||||
const scrobbleAtDurationMsRef = useRef(0);
|
||||
const scrobbleAtPercentageRef = useRef(75);
|
||||
|
||||
const previousSongRef = useRef<QueueSong | undefined>(undefined);
|
||||
const previousTimestampRef = useRef<number>(0);
|
||||
const lastProgressEventRef = useRef<number>(0);
|
||||
@@ -89,30 +125,101 @@ export const useScrobble = () => {
|
||||
imageUrlRef.current = imageUrl;
|
||||
}, [imageUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
scrobbleAtDurationMsRef.current = (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000;
|
||||
scrobbleAtPercentageRef.current = scrobbleSettings?.scrobbleAtPercentage ?? 75;
|
||||
}, [scrobbleSettings?.scrobbleAtDuration, scrobbleSettings?.scrobbleAtPercentage]);
|
||||
|
||||
const flushScrobbleDebug = useCallback(() => {
|
||||
const song = usePlayerStore.getState().getCurrentSong();
|
||||
const status = usePlayerStore.getState().player.status;
|
||||
const positionSec = useTimestampStoreBase.getState().timestamp;
|
||||
const trackDurationMs = song?.duration ?? 0;
|
||||
|
||||
const eligibilityMet = Boolean(
|
||||
song?.id &&
|
||||
checkScrobbleConditions({
|
||||
scrobbleAtDurationMs: scrobbleAtDurationMsRef.current,
|
||||
scrobbleAtPercentage: scrobbleAtPercentageRef.current,
|
||||
songCompletedDurationMs: listenedMsRef.current,
|
||||
songDurationMs: trackDurationMs,
|
||||
}),
|
||||
);
|
||||
|
||||
publishScrobbleDebug({
|
||||
eligibilityMet,
|
||||
lastListenSampleTimeSec: lastListenSampleTimeRef.current,
|
||||
listenedMs: listenedMsRef.current,
|
||||
playerStatus: status,
|
||||
positionSec,
|
||||
songId: song?.id,
|
||||
songName: song?.name,
|
||||
submitted: isCurrentSongScrobbledRef.current,
|
||||
targetDurationSec: scrobbleAtDurationMsRef.current / 1000,
|
||||
targetPercentage: scrobbleAtPercentageRef.current,
|
||||
trackDurationMs,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleScrobbleFromProgress = useCallback(
|
||||
(properties: { timestamp: number }, prev: { timestamp: number }) => {
|
||||
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
|
||||
|
||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||
const currentStatus = usePlayerStore.getState().player.status;
|
||||
|
||||
if (!currentSong?.id || currentStatus !== PlayerStatus.PLAYING) return;
|
||||
|
||||
const currentTime = properties.timestamp;
|
||||
const previousTime = prev.timestamp;
|
||||
|
||||
// Detect song restart: when timestamp resets to near 0 and was playing for at least 10 seconds
|
||||
if (!currentSong?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStatus !== PlayerStatus.PLAYING) {
|
||||
lastListenSampleTimeRef.current = currentTime;
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect song restart: when timestamp resets to near 0 and was playing past the intro
|
||||
if (
|
||||
currentTime < previousTime &&
|
||||
currentTime < 5 && // Reset to near 0
|
||||
previousTime >= 10 // Was playing for at least 10 seconds
|
||||
currentTime < SCROBBLE_TRACK_BEGIN_SEC &&
|
||||
previousTime >= SCROBBLE_RESTART_PREVIOUS_MIN_SEC
|
||||
) {
|
||||
setIsCurrentSongScrobbled(false);
|
||||
isCurrentSongScrobbledRef.current = false;
|
||||
lastProgressEventRef.current = 0;
|
||||
previousTimestampRef.current = 0;
|
||||
listenedMsRef.current = 0;
|
||||
lastListenSampleTimeRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const lastSample = lastListenSampleTimeRef.current;
|
||||
if (lastSample === null) {
|
||||
const prevSec = prev.timestamp;
|
||||
if (currentTime > prevSec && currentTime - prevSec <= MAX_LISTEN_DELTA_SEC) {
|
||||
listenedMsRef.current += (currentTime - prevSec) * 1000;
|
||||
}
|
||||
lastListenSampleTimeRef.current = currentTime;
|
||||
} else {
|
||||
const deltaSec = currentTime - lastSample;
|
||||
const jumpedBackToTrackStart =
|
||||
currentTime < lastSample &&
|
||||
currentTime < SCROBBLE_TRACK_BEGIN_SEC &&
|
||||
lastSample >= SCROBBLE_TRACK_BEGIN_SEC;
|
||||
|
||||
if (jumpedBackToTrackStart) {
|
||||
listenedMsRef.current = 0;
|
||||
isCurrentSongScrobbledRef.current = false;
|
||||
lastProgressEventRef.current = 0;
|
||||
lastListenSampleTimeRef.current = currentTime;
|
||||
} else if (currentTime < lastSample || deltaSec > MAX_LISTEN_DELTA_SEC) {
|
||||
lastListenSampleTimeRef.current = currentTime;
|
||||
} else if (deltaSec > 0) {
|
||||
listenedMsRef.current += deltaSec * 1000;
|
||||
lastListenSampleTimeRef.current = currentTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Send Jellyfin progress events every 10 seconds
|
||||
if (currentSong._serverType === ServerType.JELLYFIN) {
|
||||
const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
|
||||
@@ -144,12 +251,12 @@ export const useScrobble = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should submit scrobble based on conditions
|
||||
if (!isCurrentSongScrobbled) {
|
||||
// Check if we should submit scrobble based on listened time
|
||||
if (!isCurrentSongScrobbledRef.current) {
|
||||
const shouldSubmitScrobble = checkScrobbleConditions({
|
||||
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
|
||||
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
||||
songCompletedDurationMs: currentTime * 1000,
|
||||
scrobbleAtDurationMs: scrobbleAtDurationMsRef.current,
|
||||
scrobbleAtPercentage: scrobbleAtPercentageRef.current,
|
||||
songCompletedDurationMs: listenedMsRef.current,
|
||||
songDurationMs: currentSong.duration,
|
||||
});
|
||||
|
||||
@@ -177,25 +284,18 @@ export const useScrobble = () => {
|
||||
category: LogCategory.SCROBBLE,
|
||||
meta: {
|
||||
id: currentSong.id,
|
||||
reason: 'from song progress',
|
||||
reason: 'from listened time',
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
setIsCurrentSongScrobbled(true);
|
||||
isCurrentSongScrobbledRef.current = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
isScrobbleEnabled,
|
||||
isPrivateModeEnabled,
|
||||
scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleSettings?.scrobbleAtPercentage,
|
||||
isCurrentSongScrobbled,
|
||||
sendScrobble,
|
||||
],
|
||||
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||
);
|
||||
|
||||
const handleScrobbleFromSongChange = useCallback(
|
||||
@@ -240,11 +340,16 @@ export const useScrobble = () => {
|
||||
if (!isScrobbleEnabled || isPrivateModeEnabled) {
|
||||
previousSongRef.current = currentSong;
|
||||
previousTimestampRef.current = 0;
|
||||
listenedMsRef.current = 0;
|
||||
lastListenSampleTimeRef.current = null;
|
||||
flushScrobbleDebug();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCurrentSongScrobbled(false);
|
||||
isCurrentSongScrobbledRef.current = false;
|
||||
lastProgressEventRef.current = 0;
|
||||
listenedMsRef.current = 0;
|
||||
lastListenSampleTimeRef.current = null;
|
||||
|
||||
// Use a timeout to prevent spamming the server when switching songs quickly
|
||||
clearTimeout(songChangeTimeoutRef.current);
|
||||
@@ -280,8 +385,15 @@ export const useScrobble = () => {
|
||||
|
||||
previousSongRef.current = currentSong;
|
||||
previousTimestampRef.current = 0;
|
||||
flushScrobbleDebug();
|
||||
},
|
||||
[scrobbleSettings?.notify, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||
[
|
||||
flushScrobbleDebug,
|
||||
scrobbleSettings?.notify,
|
||||
isScrobbleEnabled,
|
||||
isPrivateModeEnabled,
|
||||
sendScrobble,
|
||||
],
|
||||
);
|
||||
|
||||
const handleScrobbleFromSeek = useCallback(
|
||||
@@ -297,8 +409,21 @@ export const useScrobble = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const sampleBeforeSeek = lastListenSampleTimeRef.current;
|
||||
lastListenSampleTimeRef.current = properties.timestamp;
|
||||
|
||||
if (
|
||||
properties.timestamp < SCROBBLE_TRACK_BEGIN_SEC &&
|
||||
(sampleBeforeSeek === null || sampleBeforeSeek >= SCROBBLE_TRACK_BEGIN_SEC)
|
||||
) {
|
||||
listenedMsRef.current = 0;
|
||||
isCurrentSongScrobbledRef.current = false;
|
||||
lastProgressEventRef.current = 0;
|
||||
}
|
||||
|
||||
// Position scrobbles are only relevant for Jellyfin
|
||||
if (currentSong._serverType !== ServerType.JELLYFIN) {
|
||||
flushScrobbleDebug();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -307,6 +432,7 @@ export const useScrobble = () => {
|
||||
|
||||
// Only allow seek scrobble once per second
|
||||
if (timeSinceLastSeek < 1000) {
|
||||
flushScrobbleDebug();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -337,8 +463,9 @@ export const useScrobble = () => {
|
||||
},
|
||||
},
|
||||
);
|
||||
flushScrobbleDebug();
|
||||
},
|
||||
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||
);
|
||||
|
||||
const handleScrobbleFromStatus = useCallback(
|
||||
@@ -412,8 +539,10 @@ export const useScrobble = () => {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
flushScrobbleDebug();
|
||||
},
|
||||
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||
);
|
||||
|
||||
const handleScrobbleFromRepeat = useCallback(() => {
|
||||
@@ -428,9 +557,11 @@ export const useScrobble = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCurrentSongScrobbled(false);
|
||||
isCurrentSongScrobbledRef.current = false;
|
||||
lastProgressEventRef.current = 0;
|
||||
previousTimestampRef.current = 0;
|
||||
listenedMsRef.current = 0;
|
||||
lastListenSampleTimeRef.current = null;
|
||||
|
||||
sendScrobble.mutate(
|
||||
{
|
||||
@@ -455,17 +586,81 @@ export const useScrobble = () => {
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]);
|
||||
flushScrobbleDebug();
|
||||
}, [flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]);
|
||||
|
||||
// Update previous timestamp on progress for use in status change handler
|
||||
const handleProgressUpdate = useCallback(
|
||||
(properties: { timestamp: number }, prev: { timestamp: number }) => {
|
||||
previousTimestampRef.current = properties.timestamp;
|
||||
handleScrobbleFromProgress(properties, prev);
|
||||
flushScrobbleDebug();
|
||||
},
|
||||
[handleScrobbleFromProgress],
|
||||
[flushScrobbleDebug, handleScrobbleFromProgress],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
registerScrobbleManualHandlers({
|
||||
forceSubmitScrobble: () => {
|
||||
if (!isScrobbleEnabled || isPrivateModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const song = usePlayerStore.getState().getCurrentSong();
|
||||
if (!song?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position =
|
||||
song._serverType === ServerType.JELLYFIN ? song.duration * 1e7 : undefined;
|
||||
|
||||
sendScrobble.mutate(
|
||||
{
|
||||
apiClientProps: { serverId: song._serverId || '' },
|
||||
query: {
|
||||
albumId: song.albumId,
|
||||
id: song.id,
|
||||
position,
|
||||
submission: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledSubmission, {
|
||||
category: LogCategory.SCROBBLE,
|
||||
meta: {
|
||||
id: song.id,
|
||||
reason: 'forced from UI',
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
isCurrentSongScrobbledRef.current = true;
|
||||
flushScrobbleDebug();
|
||||
},
|
||||
resetListenedState: () => {
|
||||
if (!isScrobbleEnabled || isPrivateModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const song = usePlayerStore.getState().getCurrentSong();
|
||||
if (!song?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
listenedMsRef.current = 0;
|
||||
isCurrentSongScrobbledRef.current = false;
|
||||
lastProgressEventRef.current = 0;
|
||||
lastListenSampleTimeRef.current = useTimestampStoreBase.getState().timestamp;
|
||||
flushScrobbleDebug();
|
||||
},
|
||||
});
|
||||
|
||||
return () => registerScrobbleManualHandlers(null);
|
||||
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, sendScrobble]);
|
||||
|
||||
usePlayerEvents(
|
||||
{
|
||||
onCurrentSongChange: handleScrobbleFromSongChange,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { forwardRef, useMemo } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
|
||||
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
||||
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
||||
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
PlaylistSongListResponse,
|
||||
Song,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, Play, TableColumn } from '/@/shared/types/types';
|
||||
import { ItemListKey, TableColumn } from '/@/shared/types/types';
|
||||
|
||||
interface PlaylistDetailSongListTableProps extends Omit<
|
||||
ItemListTableComponentProps<PlaylistSongListQuery>,
|
||||
@@ -103,12 +104,13 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
|
||||
return;
|
||||
}
|
||||
|
||||
const playType = (meta?.playType as Play) || Play.NOW;
|
||||
const items = internalState?.getData() as Song[];
|
||||
|
||||
if (index !== undefined) {
|
||||
player.addToQueueByData(items, playType, item.id);
|
||||
}
|
||||
playSongFromItemListControl({
|
||||
index,
|
||||
internalState,
|
||||
item: item as Song,
|
||||
meta,
|
||||
player,
|
||||
});
|
||||
},
|
||||
};
|
||||
}, [player]);
|
||||
@@ -228,12 +230,13 @@ export const PlaylistDetailSongListEditTable = forwardRef<any, PlaylistDetailSon
|
||||
return;
|
||||
}
|
||||
|
||||
const playType = (meta?.playType as Play) || Play.NOW;
|
||||
const items = internalState?.getData() as Song[];
|
||||
|
||||
if (index !== undefined) {
|
||||
player.addToQueueByData(items, playType, item.id);
|
||||
}
|
||||
playSongFromItemListControl({
|
||||
index,
|
||||
internalState,
|
||||
item: item as Song,
|
||||
meta,
|
||||
player,
|
||||
});
|
||||
},
|
||||
};
|
||||
}, [player]);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useIsMutating, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Playlist } from '/@/shared/types/domain-types';
|
||||
|
||||
export const sidebarPlaylistFolderMoveMutationKey = ['sidebar-playlist-folder-move'];
|
||||
|
||||
export type SidebarPlaylistFolderMoveArgs = {
|
||||
serverId: string;
|
||||
updates: SidebarPlaylistFolderMoveUpdate[];
|
||||
};
|
||||
|
||||
export type SidebarPlaylistFolderMoveUpdate = {
|
||||
newName: string;
|
||||
playlist: Playlist;
|
||||
};
|
||||
|
||||
export const useSidebarPlaylistFolderMove = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, AxiosError, SidebarPlaylistFolderMoveArgs>({
|
||||
mutationFn: async ({ serverId, updates }) => {
|
||||
for (const { newName, playlist } of updates) {
|
||||
if (newName === playlist.name) continue;
|
||||
|
||||
await api.controller.updatePlaylist({
|
||||
apiClientProps: { serverId },
|
||||
body: {
|
||||
comment: playlist.description || '',
|
||||
name: newName,
|
||||
ownerId: playlist.ownerId || '',
|
||||
public: playlist.public || false,
|
||||
queryBuilderRules: playlist.rules ?? undefined,
|
||||
sync: playlist.sync ?? undefined,
|
||||
},
|
||||
query: { id: playlist.id },
|
||||
});
|
||||
}
|
||||
},
|
||||
mutationKey: sidebarPlaylistFolderMoveMutationKey,
|
||||
onSuccess: (_data, { serverId, updates }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.playlists.list(serverId),
|
||||
});
|
||||
|
||||
for (const { playlist } of updates) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.playlists.detail(serverId, playlist.id),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.playlists.songList(serverId, playlist.id),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useIsMutatingSidebarPlaylistFolderMove = () => {
|
||||
return useIsMutating({ mutationKey: sidebarPlaylistFolderMoveMutationKey }) > 0;
|
||||
};
|
||||
@@ -136,6 +136,8 @@ export function RadioWebPlayer() {
|
||||
<WebPlayerEngine
|
||||
isMuted={isMuted}
|
||||
isTransitioning={false}
|
||||
loopPlayer1={false}
|
||||
loopPlayer2={false}
|
||||
onEndedPlayer1={onEndedPlayer1}
|
||||
onEndedPlayer2={() => {}}
|
||||
onErrorPause={() => {}}
|
||||
|
||||
@@ -29,9 +29,8 @@ export const useRemote = () => {
|
||||
|
||||
// Initialize the remote
|
||||
useEffect(() => {
|
||||
if (!isRemoteEnabled) {
|
||||
return;
|
||||
}
|
||||
// we must send this EVEN IF the remote is disabled, as this is what
|
||||
// makes sure that the main process gets the port/username/password on startup
|
||||
|
||||
logFn.debug(logMsg[LogCategory.REMOTE].initializingRemoteSettings, {
|
||||
category: LogCategory.REMOTE,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { openContextModal } from '@mantine/modals';
|
||||
import { CSSProperties, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import styles from './library-command-item.module.css';
|
||||
|
||||
@@ -13,6 +15,7 @@ import { useCurrentServer } from '/@/renderer/store';
|
||||
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
import { ExplicitStatus, LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
@@ -61,6 +64,7 @@ export const LibraryCommandItem = ({
|
||||
}: LibraryCommandItemProps) => {
|
||||
const { addToQueueByData, addToQueueByFetch } = usePlayer();
|
||||
const server = useCurrentServer();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(playType: Play) => {
|
||||
@@ -103,6 +107,39 @@ export const LibraryCommandItem = ({
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenPlaylistModal = useCallback(() => {
|
||||
const modalProps: {
|
||||
albumId?: string[];
|
||||
artistId?: string[];
|
||||
songId?: string[];
|
||||
} = {};
|
||||
|
||||
switch (itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
modalProps.albumId = [id];
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
case LibraryItem.ARTIST:
|
||||
modalProps.artistId = [id];
|
||||
break;
|
||||
case LibraryItem.QUEUE_SONG:
|
||||
case LibraryItem.SONG:
|
||||
modalProps.songId = [id];
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
openContextModal({
|
||||
innerProps: {
|
||||
...modalProps,
|
||||
},
|
||||
modal: 'addToPlaylist',
|
||||
size: 'lg',
|
||||
title: t('page.contextMenu.addToPlaylist'),
|
||||
});
|
||||
}, [id, itemType, t]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const showControls = isHighlighted || isHovered;
|
||||
@@ -180,6 +217,29 @@ export const LibraryCommandItem = ({
|
||||
)}
|
||||
/>
|
||||
</PlayTooltip>
|
||||
<Tooltip disabled={disabled} label={t('action.addToPlaylist')}>
|
||||
<ActionIcon
|
||||
icon="playlistAdd"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handleOpenPlaylistModal();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) {
|
||||
handleOpenPlaylistModal();
|
||||
}
|
||||
} else if (e.key === 'Tab') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
variant="default"
|
||||
/>
|
||||
</Tooltip>
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -39,7 +39,7 @@ import { FontType } from '/@/shared/types/types';
|
||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||
const ipc = isElectron() ? window.api.ipc : null;
|
||||
// Electron 32+ removed file.path, use this which is exposed in preload to get real path
|
||||
const webUtils = isElectron() ? window.electron.webUtils : null;
|
||||
const getPathForFile = isElectron() ? window.api.getPathForFile : null;
|
||||
|
||||
const HOME_FEATURE_STYLE_OPTIONS = [
|
||||
{
|
||||
@@ -295,7 +295,7 @@ export const ApplicationSettings = memo(() => {
|
||||
setSettings({
|
||||
font: {
|
||||
...fontSettings,
|
||||
custom: e ? webUtils?.getPathForFile(e) || null : null,
|
||||
custom: e ? getPathForFile?.(e) || null : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,15 +7,29 @@ import {
|
||||
SettingsSection,
|
||||
} from '/@/renderer/features/settings/components/settings-section';
|
||||
import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store';
|
||||
import { ColorInput } from '/@/shared/components/color-input/color-input';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
||||
|
||||
type FolderView = 'navigation' | 'single' | 'tree';
|
||||
type PlaylistMode = 'compact' | 'expanded';
|
||||
|
||||
export const SidebarSettings = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const settings = useGeneralSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
|
||||
const handleSetSidebarPlaylistFolders = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings({
|
||||
general: {
|
||||
sidebarPlaylistFolders: e.target.checked,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetSidebarPlaylistList = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings({
|
||||
general: {
|
||||
@@ -56,6 +70,60 @@ export const SidebarSettings = memo(() => {
|
||||
});
|
||||
}, 500);
|
||||
|
||||
const [localSeparator, setLocalSeparator] = useState(settings.sidebarPlaylistFolderSeparator);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSeparator(settings.sidebarPlaylistFolderSeparator);
|
||||
}, [settings.sidebarPlaylistFolderSeparator]);
|
||||
|
||||
const debouncedSetSeparator = useDebouncedCallback((value: string) => {
|
||||
if (value.length === 0) return;
|
||||
setSettings({
|
||||
general: {
|
||||
sidebarPlaylistFolderSeparator: value,
|
||||
},
|
||||
});
|
||||
}, 500);
|
||||
|
||||
const folderViewOptions: Array<{ label: string; value: FolderView }> = [
|
||||
{
|
||||
label: t('setting.sidebarPlaylistFolderView_optionSingle', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
value: 'single',
|
||||
},
|
||||
{
|
||||
label: t('setting.sidebarPlaylistFolderView_optionTree', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
value: 'tree',
|
||||
},
|
||||
{
|
||||
label: t('setting.sidebarPlaylistFolderView_optionNavigation', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
value: 'navigation',
|
||||
},
|
||||
];
|
||||
|
||||
const playlistModeOptions: Array<{ label: string; value: PlaylistMode }> = [
|
||||
{
|
||||
label: t('setting.sidebarPlaylistMode_optionCompact', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
value: 'compact',
|
||||
},
|
||||
{
|
||||
label: t('setting.sidebarPlaylistMode_optionExpanded', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
value: 'expanded',
|
||||
},
|
||||
];
|
||||
|
||||
const foldersEnabled = settings.sidebarPlaylistFolders;
|
||||
const isTreeView = settings.sidebarPlaylistFolderView === 'tree';
|
||||
|
||||
const options: SettingOption[] = [
|
||||
{
|
||||
control: (
|
||||
@@ -98,6 +166,137 @@ export const SidebarSettings = memo(() => {
|
||||
}),
|
||||
title: t('setting.sidebarPlaylistSorting'),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
data={playlistModeOptions}
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
setSettings({
|
||||
general: {
|
||||
sidebarPlaylistMode: value as PlaylistMode,
|
||||
},
|
||||
});
|
||||
}}
|
||||
value={settings.sidebarPlaylistMode}
|
||||
width={200}
|
||||
/>
|
||||
),
|
||||
description: t('setting.sidebarPlaylistMode', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
title: t('setting.sidebarPlaylistMode', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
checked={settings.sidebarPlaylistFolders}
|
||||
onChange={handleSetSidebarPlaylistFolders}
|
||||
/>
|
||||
),
|
||||
description: t('setting.sidebarPlaylistFolders', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
title: t('setting.sidebarPlaylistFolders', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<TextInput
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setLocalSeparator(value);
|
||||
debouncedSetSeparator(value);
|
||||
}}
|
||||
value={localSeparator}
|
||||
width={120}
|
||||
/>
|
||||
),
|
||||
description: t('setting.sidebarPlaylistFolderSeparator', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
indent: true,
|
||||
isHidden: !foldersEnabled,
|
||||
title: t('setting.sidebarPlaylistFolderSeparator', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
data={folderViewOptions}
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
setSettings({
|
||||
general: {
|
||||
sidebarPlaylistFolderView: value as FolderView,
|
||||
},
|
||||
});
|
||||
}}
|
||||
value={settings.sidebarPlaylistFolderView}
|
||||
width={200}
|
||||
/>
|
||||
),
|
||||
description: t('setting.sidebarPlaylistFolderView', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
indent: true,
|
||||
isHidden: !foldersEnabled,
|
||||
title: t('setting.sidebarPlaylistFolderView', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<NumberInput
|
||||
max={64}
|
||||
min={0}
|
||||
onBlur={(e) => {
|
||||
const value = Number(e.currentTarget.value);
|
||||
if (Number.isFinite(value)) {
|
||||
setSettings({
|
||||
general: {
|
||||
sidebarPlaylistFolderTreeIndent: Math.max(
|
||||
0,
|
||||
Math.min(64, Math.round(value)),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
value={settings.sidebarPlaylistFolderTreeIndent}
|
||||
width={100}
|
||||
/>
|
||||
),
|
||||
description: t('setting.sidebarPlaylistFolderTreeIndent', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
indent: true,
|
||||
isHidden: !foldersEnabled || !isTreeView,
|
||||
title: t('setting.sidebarPlaylistFolderTreeIndent', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<ColorInput
|
||||
format="rgba"
|
||||
onChangeEnd={(value) => {
|
||||
setSettings({
|
||||
general: {
|
||||
sidebarPlaylistFolderTreeLineColor: value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
value={settings.sidebarPlaylistFolderTreeLineColor}
|
||||
/>
|
||||
),
|
||||
description: t('setting.sidebarPlaylistFolderTreeLineColor', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
indent: true,
|
||||
isHidden: !foldersEnabled || !isTreeView,
|
||||
title: t('setting.sidebarPlaylistFolderTreeLineColor', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
|
||||
@@ -60,6 +60,7 @@ const BINDINGS_MAP: Record<BindingActions, string> = {
|
||||
context: 'listPlayNext',
|
||||
}),
|
||||
listPlayNow: i18n.t('setting.hotkey', { context: 'listPlayNow' }),
|
||||
listShowPlayingSong: i18n.t('setting.hotkey', { context: 'listShowPlayingSong' }),
|
||||
localSearch: i18n.t('setting.hotkey', { context: 'localSearch' }),
|
||||
navigateHome: i18n.t('setting.hotkey', {
|
||||
context: 'navigateHome',
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row-indented {
|
||||
padding-left: var(--theme-spacing-lg);
|
||||
margin-left: var(--theme-spacing-md);
|
||||
border-left: 2px solid var(--theme-colors-surface);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import styles from './settings-option.module.css';
|
||||
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
@@ -9,15 +11,20 @@ import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
interface SettingsOptionProps {
|
||||
control: React.ReactNode;
|
||||
description?: React.ReactNode | string;
|
||||
indent?: boolean;
|
||||
note?: string;
|
||||
title: React.ReactNode | string;
|
||||
}
|
||||
|
||||
export const SettingsOptions = memo(
|
||||
({ control, description, note, title }: SettingsOptionProps) => {
|
||||
({ control, description, indent, note, title }: SettingsOptionProps) => {
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" style={{ alignItems: 'center' }} wrap="nowrap">
|
||||
<Group
|
||||
className={indent ? styles.rowIndented : styles.row}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Stack
|
||||
gap="xs"
|
||||
style={{
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TextTitle } from '/@/shared/components/text-title/text-title';
|
||||
export type SettingOption = {
|
||||
control: ReactNode;
|
||||
description: ReactNode | string;
|
||||
indent?: boolean;
|
||||
isHidden?: boolean;
|
||||
note?: string;
|
||||
title: string;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 65px;
|
||||
-webkit-app-region: drag;
|
||||
|
||||
|
||||
@@ -18,7 +18,19 @@ export const ActionBar = () => {
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Grid display="flex" gap="sm" style={{ padding: '0 var(--theme-spacing-md)' }} w="100%">
|
||||
<Grid
|
||||
display="flex"
|
||||
gap="sm"
|
||||
styles={{
|
||||
inner: {
|
||||
width: '100%',
|
||||
},
|
||||
root: {
|
||||
padding: '0 var(--theme-spacing-md',
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Grid.Col span={7}>
|
||||
<TextInput
|
||||
leftSection={<Icon icon="search" />}
|
||||
|
||||
@@ -7,8 +7,10 @@ import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
|
||||
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
|
||||
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
||||
import {
|
||||
SidebarPlaylistAddDragContext,
|
||||
SidebarPlaylistList,
|
||||
SidebarSharedPlaylistList,
|
||||
useSidebarPlaylistAddDragMonitor,
|
||||
} from '/@/renderer/features/sidebar/components/sidebar-playlist-list';
|
||||
import {
|
||||
SidebarItemType,
|
||||
@@ -20,6 +22,17 @@ import { Group } from '/@/shared/components/group/group';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
const MobileSidebarPlaylistSection = () => {
|
||||
const isAddDragActive = useSidebarPlaylistAddDragMonitor();
|
||||
|
||||
return (
|
||||
<SidebarPlaylistAddDragContext.Provider value={isAddDragActive}>
|
||||
<SidebarPlaylistList />
|
||||
<SidebarSharedPlaylistList />
|
||||
</SidebarPlaylistAddDragContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const MobileSidebar = () => {
|
||||
const { t } = useTranslation();
|
||||
const sidebarPlaylistList = useSidebarPlaylistList();
|
||||
@@ -93,12 +106,7 @@ export const MobileSidebar = () => {
|
||||
})}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
{sidebarPlaylistList && (
|
||||
<>
|
||||
<SidebarPlaylistList />
|
||||
<SidebarSharedPlaylistList />
|
||||
</>
|
||||
)}
|
||||
{sidebarPlaylistList && <MobileSidebarPlaylistSection />}
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
.folder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-dragged-over {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
border: 2px solid var(--theme-colors-primary);
|
||||
border-radius: var(--theme-radius-md);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--theme-spacing-md);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--theme-radius-md);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-colors-background);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.collapse {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.count {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.children {
|
||||
padding-left: var(--theme-spacing-md);
|
||||
}
|
||||
|
||||
.tree-children {
|
||||
padding-left: var(--playlist-folder-tree-indent, 16px);
|
||||
}
|
||||
|
||||
.tree-branch {
|
||||
position: relative;
|
||||
padding-left: calc(var(--playlist-folder-tree-indent, 16px) - 4px);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 50%;
|
||||
left: 0;
|
||||
width: calc(var(--playlist-folder-tree-indent, 16px) - 8px);
|
||||
content: "";
|
||||
border-bottom: 1px solid var(--playlist-folder-tree-line-color, var(--theme-colors-border));
|
||||
border-left: 1px solid var(--playlist-folder-tree-line-color, var(--theme-colors-border));
|
||||
}
|
||||
|
||||
&:not(:last-child)::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
content: "";
|
||||
border-left: 1px solid var(--playlist-folder-tree-line-color, var(--theme-colors-border));
|
||||
}
|
||||
}
|
||||
|
||||
.navigation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-folder {
|
||||
display: flex;
|
||||
gap: var(--theme-spacing-md);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: var(--theme-spacing-xs) var(--theme-spacing-md);
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--theme-radius-md);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-colors-surface);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-folder-icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
min-width: 3rem;
|
||||
height: 3rem;
|
||||
background-color: var(--theme-colors-surface);
|
||||
border-radius: var(--theme-radius-md);
|
||||
}
|
||||
|
||||
.nav-chevron {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.root-drop-target-dragged-over {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
border: 2px solid var(--theme-colors-primary);
|
||||
border-radius: var(--theme-radius-md);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,10 @@
|
||||
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -13,13 +17,28 @@
|
||||
}
|
||||
|
||||
.row-hover {
|
||||
.metadata {
|
||||
.metadata,
|
||||
.compact-name {
|
||||
margin-right: 100px;
|
||||
}
|
||||
|
||||
background-color: var(--theme-colors-surface);
|
||||
}
|
||||
|
||||
.row-compact {
|
||||
align-items: center;
|
||||
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
|
||||
}
|
||||
|
||||
.compact-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -29,9 +48,24 @@
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.row-dragged-over {
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
box-shadow: 0 0 0 2px var(--theme-colors-primary);
|
||||
.controls-compact {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: var(--theme-spacing-xs);
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--theme-spacing-sm);
|
||||
background: none;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.row-dragged-over::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
border: 2px solid var(--theme-colors-primary);
|
||||
border-radius: var(--theme-radius-md);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { openContextModal } from '@mantine/modals';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import clsx from 'clsx';
|
||||
import { memo, MouseEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { createContext, memo, MouseEvent, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath, Link } from 'react-router';
|
||||
|
||||
@@ -12,27 +13,38 @@ import { ContextMenuController } from '/@/renderer/features/context-menu/context
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||
import { openCreatePlaylistModal } from '/@/renderer/features/playlists/components/create-playlist-form';
|
||||
import { useIsMutatingSidebarPlaylistFolderMove } from '/@/renderer/features/playlists/mutations/sidebar-playlist-folder-move-mutation';
|
||||
import { ItemRowPlayControls } from '/@/renderer/features/shared/components/item-row-play-controls';
|
||||
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';
|
||||
collectFolderPaths,
|
||||
PlaylistFolderDragExpandProvider,
|
||||
PlaylistFolderViews,
|
||||
PlaylistRootAccordionControl,
|
||||
usePlaylistFolderState,
|
||||
usePlaylistFolderViewState,
|
||||
usePlaylistNavigationState,
|
||||
} from '/@/renderer/features/sidebar/components/playlist-folder-tree';
|
||||
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||
import { useDragMonitor } from '/@/renderer/hooks/use-drag-monitor';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useCurrentServerId,
|
||||
usePermissions,
|
||||
useSidebarPlaylistListFilterRegex,
|
||||
useSidebarPlaylistMode,
|
||||
useSidebarPlaylistSorting,
|
||||
} from '/@/renderer/store';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
import { Accordion } from '/@/shared/components/accordion/accordion';
|
||||
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { animationProps } from '/@/shared/components/animations/animation-props';
|
||||
import { animationVariants } from '/@/shared/components/animations/animation-variants';
|
||||
import { ButtonProps } from '/@/shared/components/button/button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Image } from '/@/shared/components/image/image';
|
||||
import { LoadingOverlay } from '/@/shared/components/loading-overlay/loading-overlay';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||
import {
|
||||
@@ -42,15 +54,51 @@ import {
|
||||
Song,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
|
||||
import { DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
const MotionLink = motion.create(Link);
|
||||
|
||||
const playlistRowDimVariants = animationVariants.combine(animationVariants.fadeIn, {
|
||||
hidden: { opacity: 0.5 },
|
||||
});
|
||||
|
||||
const getPlaylistOrderKey = (serverId: string | undefined, scope: 'owned' | 'shared') => {
|
||||
const sid = serverId || 'local';
|
||||
return `playlist_order:${sid}:${scope}`;
|
||||
};
|
||||
|
||||
interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'onPlay'> {
|
||||
export const SidebarPlaylistAddDragContext = createContext(false);
|
||||
|
||||
const isAddToPlaylistDragSource = (source: DragData) => {
|
||||
return (
|
||||
source.itemType !== undefined &&
|
||||
source.type !== DragTarget.PLAYLIST &&
|
||||
(source.operation?.includes(DragOperation.ADD) ?? false)
|
||||
);
|
||||
};
|
||||
|
||||
export const useSidebarPlaylistAddDragMonitor = () => {
|
||||
const [isAddDragActive, setIsAddDragActive] = useState(false);
|
||||
|
||||
const handleAddDragStart = useCallback(() => {
|
||||
setIsAddDragActive(true);
|
||||
}, []);
|
||||
|
||||
const handleAddDragDrop = useCallback(() => {
|
||||
setIsAddDragActive(false);
|
||||
}, []);
|
||||
|
||||
useDragMonitor({
|
||||
canMonitor: isAddToPlaylistDragSource,
|
||||
onDragStart: handleAddDragStart,
|
||||
onDrop: handleAddDragDrop,
|
||||
});
|
||||
|
||||
return isAddDragActive;
|
||||
};
|
||||
|
||||
export interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'onPlay'> {
|
||||
item: Playlist;
|
||||
name: string;
|
||||
onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;
|
||||
@@ -58,7 +106,7 @@ interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'on
|
||||
to: string;
|
||||
}
|
||||
|
||||
const PlaylistRowButton = memo(
|
||||
export const PlaylistRowButton = memo(
|
||||
({ item, name, onContextMenu, onReorder, to }: PlaylistRowButtonProps) => {
|
||||
const url = {
|
||||
pathname: generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: to }),
|
||||
@@ -66,8 +114,12 @@ const PlaylistRowButton = memo(
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
const sidebarPlaylistSorting = useSidebarPlaylistSorting();
|
||||
const sidebarPlaylistMode = useSidebarPlaylistMode();
|
||||
const isCompact = sidebarPlaylistMode === 'compact';
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isSmartPlaylist = Boolean(item.rules);
|
||||
const isAddDragActive = useContext(SidebarPlaylistAddDragContext);
|
||||
|
||||
const { isDraggedOver, isDragging, ref } = useDragDrop<HTMLAnchorElement>({
|
||||
drag: {
|
||||
@@ -85,6 +137,7 @@ const PlaylistRowButton = memo(
|
||||
canDrop: (args) => {
|
||||
// Allow dropping items into a playlist (ADD)
|
||||
const canAdd =
|
||||
!isSmartPlaylist &&
|
||||
args.source.itemType !== undefined &&
|
||||
args.source.type !== DragTarget.PLAYLIST &&
|
||||
(args.source.operation?.includes(DragOperation.ADD) ?? false);
|
||||
@@ -106,6 +159,7 @@ const PlaylistRowButton = memo(
|
||||
};
|
||||
},
|
||||
onDrag: () => {
|
||||
console.log('started drag');
|
||||
return;
|
||||
},
|
||||
onDragLeave: () => {
|
||||
@@ -138,6 +192,10 @@ const PlaylistRowButton = memo(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSmartPlaylist) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modalProps: {
|
||||
albumId?: string[];
|
||||
artistId?: string[];
|
||||
@@ -210,12 +268,18 @@ const PlaylistRowButton = memo(
|
||||
type: 'table',
|
||||
});
|
||||
|
||||
const isDimmed = isDragging || (isSmartPlaylist && isAddDragActive);
|
||||
|
||||
return (
|
||||
<Link
|
||||
<MotionLink
|
||||
{...animationProps.fadeIn}
|
||||
animate={isDimmed ? 'hidden' : 'show'}
|
||||
className={clsx(styles.row, {
|
||||
[styles.rowDraggedOver]: isDraggedOver,
|
||||
[styles.rowCompact]: isCompact,
|
||||
[styles.rowDraggedOver]: isDraggedOver && !isSmartPlaylist,
|
||||
[styles.rowHover]: isHovered,
|
||||
})}
|
||||
initial={false}
|
||||
onContextMenu={(e: MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
onContextMenu(e, item);
|
||||
@@ -223,136 +287,80 @@ const PlaylistRowButton = memo(
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
ref={ref}
|
||||
style={{
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}}
|
||||
to={url}
|
||||
variants={playlistRowDimVariants}
|
||||
>
|
||||
<div className={styles.rowGroup}>
|
||||
<Image containerClassName={styles.imageContainer} src={imageUrl} />
|
||||
<div className={styles.metadata}>
|
||||
<Text className={styles.name} fw={500} size="md">
|
||||
{isCompact ? (
|
||||
<>
|
||||
<Text className={styles.compactName} fw={500} size="md">
|
||||
{name}
|
||||
</Text>
|
||||
<div className={styles.metadataGroup}>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.metadataGroupItem,
|
||||
styles.metadataGroupItemNoShrink,
|
||||
)}
|
||||
>
|
||||
<Icon color="muted" icon="itemSong" size="sm" />
|
||||
<Text isMuted size="sm">
|
||||
{item.songCount || 0}
|
||||
{isHovered && (
|
||||
<ItemRowPlayControls
|
||||
className={clsx(styles.controls, styles.controlsCompact)}
|
||||
onPlay={(playType) => handlePlay(to, playType)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.rowGroup}>
|
||||
<Image containerClassName={styles.imageContainer} src={imageUrl} />
|
||||
<div className={styles.metadata}>
|
||||
<Text className={styles.name} fw={500} size="md">
|
||||
{name}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.metadataGroupItem}>
|
||||
<Icon color="muted" icon="duration" size="sm" />
|
||||
<Text isMuted size="sm">
|
||||
{formatDurationString(item.duration ?? 0)}
|
||||
</Text>
|
||||
</div>
|
||||
{item.ownerId === permissions.userId && Boolean(item.public) && (
|
||||
<div className={styles.metadataGroupItem}>
|
||||
<Text isMuted size="sm">
|
||||
{t('common.public')}
|
||||
</Text>
|
||||
<div className={styles.metadataGroup}>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.metadataGroupItem,
|
||||
styles.metadataGroupItemNoShrink,
|
||||
)}
|
||||
>
|
||||
<Icon color="muted" icon="itemSong" size="sm" />
|
||||
<Text isMuted size="sm">
|
||||
{item.songCount || 0}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.metadataGroupItem}>
|
||||
<Icon color="muted" icon="duration" size="sm" />
|
||||
<Text isMuted size="sm">
|
||||
{formatDurationString(item.duration ?? 0)}
|
||||
</Text>
|
||||
</div>
|
||||
{item.ownerId === permissions.userId &&
|
||||
Boolean(item.public) && (
|
||||
<div className={styles.metadataGroupItem}>
|
||||
<Text isMuted size="sm">
|
||||
{t('common.public')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{item.ownerId !== permissions.userId && (
|
||||
<div className={styles.metadataGroupItem}>
|
||||
<Icon color="muted" icon="user" size="sm" />
|
||||
<Text isMuted size="sm">
|
||||
{item.owner}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{item.ownerId !== permissions.userId && (
|
||||
<div className={styles.metadataGroupItem}>
|
||||
<Icon color="muted" icon="user" size="sm" />
|
||||
<Text isMuted size="sm">
|
||||
{item.owner}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isHovered && <RowControls id={to} onPlay={handlePlay} />}
|
||||
</Link>
|
||||
{isHovered && (
|
||||
<ItemRowPlayControls
|
||||
className={styles.controls}
|
||||
onPlay={(playType) => handlePlay(to, playType)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MotionLink>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const RowControls = ({
|
||||
id,
|
||||
onPlay,
|
||||
}: {
|
||||
id: string;
|
||||
onPlay: (id: string, playType: Play) => void;
|
||||
}) => {
|
||||
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={styles.controls}>
|
||||
<PlayTooltip type={Play.NOW}>
|
||||
<ActionIcon
|
||||
icon="mediaPlay"
|
||||
iconProps={{
|
||||
size: 'md',
|
||||
}}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
{...handlePlayNow.handlers}
|
||||
{...handlePlayNow.props}
|
||||
/>
|
||||
</PlayTooltip>
|
||||
<PlayTooltip type={Play.NEXT}>
|
||||
<ActionIcon
|
||||
icon="mediaPlayNext"
|
||||
iconProps={{
|
||||
size: 'md',
|
||||
}}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
{...handlePlayNext.handlers}
|
||||
{...handlePlayNext.props}
|
||||
/>
|
||||
</PlayTooltip>
|
||||
<PlayTooltip type={Play.LAST}>
|
||||
<ActionIcon
|
||||
icon="mediaPlayLast"
|
||||
iconProps={{
|
||||
size: 'md',
|
||||
}}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
{...handlePlayLast.handlers}
|
||||
{...handlePlayLast.props}
|
||||
/>
|
||||
</PlayTooltip>
|
||||
</ActionIconGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarPlaylistList = () => {
|
||||
const player = usePlayer();
|
||||
const { t } = useTranslation();
|
||||
@@ -485,12 +493,64 @@ export const SidebarPlaylistList = () => {
|
||||
openCreatePlaylistModal(server, e);
|
||||
};
|
||||
|
||||
const folderViewState = usePlaylistFolderViewState(playlistItems?.items ?? []);
|
||||
const { folderView, groups, tree } = folderViewState;
|
||||
const navigation = usePlaylistNavigationState();
|
||||
const inNavigation = folderView === 'navigation' && navigation.pathStack.length > 0;
|
||||
|
||||
const folderPaths = useMemo(() => {
|
||||
if (folderView === 'single') {
|
||||
return groups.reduce<string[]>((acc, g) => {
|
||||
if (g.type === 'folder') acc.push(g.name);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
return collectFolderPaths(tree);
|
||||
}, [folderView, groups, tree]);
|
||||
|
||||
const { expandedSet, setMany, toggle } = usePlaylistFolderState('owned');
|
||||
const allExpanded =
|
||||
folderPaths.length > 0 && folderPaths.every((path) => expandedSet.has(path));
|
||||
|
||||
const handleToggleAllFolders = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
setMany(folderPaths, !allExpanded);
|
||||
},
|
||||
[setMany, folderPaths, allExpanded],
|
||||
);
|
||||
|
||||
const handleNavigateUp = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
navigation.goUp();
|
||||
},
|
||||
[navigation],
|
||||
);
|
||||
|
||||
const showExpandAll = folderView !== 'navigation' && folderPaths.length > 0;
|
||||
const isFolderMovePending = useIsMutatingSidebarPlaylistFolderMove();
|
||||
|
||||
return (
|
||||
<Accordion.Item value="playlists">
|
||||
<Accordion.Control component="div" role="button" style={{ userSelect: 'none' }}>
|
||||
<Group justify="space-between" pr="var(--theme-spacing-md)">
|
||||
<Text fw={500}>{t('page.sidebar.playlists')}</Text>
|
||||
<Group gap="xs">
|
||||
<PlaylistRootAccordionControl allPlaylists={playlistItems?.items ?? []}>
|
||||
<Group gap="xs" justify="space-between" pr="var(--theme-spacing-md)" wrap="nowrap">
|
||||
<Group gap="xs" style={{ minWidth: 0 }} wrap="nowrap">
|
||||
{inNavigation && (
|
||||
<ActionIcon
|
||||
icon="arrowLeftS"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={handleNavigateUp}
|
||||
size="xs"
|
||||
tooltip={{ label: t('common.back') }}
|
||||
variant="subtle"
|
||||
/>
|
||||
)}
|
||||
<Text className={styles.name} fw={500}>
|
||||
{inNavigation ? navigation.currentName : t('page.sidebar.playlists')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ActionIcon
|
||||
icon="add"
|
||||
iconProps={{
|
||||
@@ -503,6 +563,27 @@ export const SidebarPlaylistList = () => {
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
{showExpandAll && (
|
||||
<ActionIcon
|
||||
icon={allExpanded ? 'collapseAll' : 'expandAll'}
|
||||
iconProps={{
|
||||
size: 'lg',
|
||||
}}
|
||||
onClick={handleToggleAllFolders}
|
||||
size="xs"
|
||||
tooltip={{
|
||||
label: t(
|
||||
allExpanded
|
||||
? 'action.collapseAllFolders'
|
||||
: 'action.expandAllFolders',
|
||||
{
|
||||
postProcess: 'sentenceCase',
|
||||
},
|
||||
),
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
)}
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
icon="list"
|
||||
@@ -519,18 +600,20 @@ export const SidebarPlaylistList = () => {
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{playlistItems?.items?.map((item, index) => (
|
||||
<PlaylistRowButton
|
||||
item={item}
|
||||
key={index}
|
||||
name={item.name}
|
||||
</PlaylistRootAccordionControl>
|
||||
<Accordion.Panel className={styles.panel}>
|
||||
<LoadingOverlay pos="absolute" visible={isFolderMovePending} />
|
||||
<PlaylistFolderDragExpandProvider expandedSet={expandedSet} setMany={setMany}>
|
||||
<PlaylistFolderViews
|
||||
{...folderViewState}
|
||||
allPlaylists={playlistItems?.items ?? []}
|
||||
expandedSet={expandedSet}
|
||||
navigation={navigation}
|
||||
onContextMenu={handleContextMenu}
|
||||
onReorder={handleReorder}
|
||||
to={item.id}
|
||||
onToggleFolder={toggle}
|
||||
/>
|
||||
))}
|
||||
</PlaylistFolderDragExpandProvider>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
@@ -668,28 +751,58 @@ export const SidebarSharedPlaylistList = () => {
|
||||
setPlaylistOrder(reorderedIds);
|
||||
};
|
||||
|
||||
const folderViewState = usePlaylistFolderViewState(playlistItems?.items ?? []);
|
||||
const navigation = usePlaylistNavigationState();
|
||||
const { expandedSet, setMany, toggle } = usePlaylistFolderState('shared');
|
||||
const inNavigation =
|
||||
folderViewState.folderView === 'navigation' && navigation.pathStack.length > 0;
|
||||
|
||||
const handleNavigateUp = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
navigation.goUp();
|
||||
},
|
||||
[navigation],
|
||||
);
|
||||
|
||||
const isFolderMovePending = useIsMutatingSidebarPlaylistFolderMove();
|
||||
|
||||
if (playlistItems?.items?.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion.Item value="shared-playlists">
|
||||
<Accordion.Control>
|
||||
<Text fw={500} variant="secondary">
|
||||
{t('page.sidebar.shared')}
|
||||
</Text>
|
||||
<Accordion.Control component="motion.div" role="button" style={{ userSelect: 'none' }}>
|
||||
<Group gap="xs" style={{ minWidth: 0 }} wrap="nowrap">
|
||||
{inNavigation && (
|
||||
<ActionIcon
|
||||
icon="arrowLeftS"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={handleNavigateUp}
|
||||
size="xs"
|
||||
tooltip={{ label: t('common.back') }}
|
||||
variant="subtle"
|
||||
/>
|
||||
)}
|
||||
<Text className={styles.name} fw={500} variant="secondary">
|
||||
{inNavigation ? navigation.currentName : t('page.sidebar.shared')}
|
||||
</Text>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{playlistItems?.items?.map((item, index) => (
|
||||
<PlaylistRowButton
|
||||
item={item}
|
||||
key={index}
|
||||
name={item.name}
|
||||
<Accordion.Panel className={styles.panel}>
|
||||
<LoadingOverlay pos="absolute" visible={isFolderMovePending} />
|
||||
<PlaylistFolderDragExpandProvider expandedSet={expandedSet} setMany={setMany}>
|
||||
<PlaylistFolderViews
|
||||
{...folderViewState}
|
||||
allPlaylists={playlistItems?.items ?? []}
|
||||
expandedSet={expandedSet}
|
||||
navigation={navigation}
|
||||
onContextMenu={handleContextMenu}
|
||||
onReorder={handleReorder}
|
||||
to={item.id}
|
||||
onToggleFolder={toggle}
|
||||
/>
|
||||
))}
|
||||
</PlaylistFolderDragExpandProvider>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
|
||||
@@ -16,8 +16,10 @@ import { SidebarCollectionList } from '/@/renderer/features/sidebar/components/s
|
||||
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
|
||||
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
||||
import {
|
||||
SidebarPlaylistAddDragContext,
|
||||
SidebarPlaylistList,
|
||||
SidebarSharedPlaylistList,
|
||||
useSidebarPlaylistAddDragMonitor,
|
||||
} from '/@/renderer/features/sidebar/components/sidebar-playlist-list';
|
||||
import {
|
||||
useAppStore,
|
||||
@@ -45,6 +47,17 @@ import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
import { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { Platform } from '/@/shared/types/types';
|
||||
|
||||
const SidebarPlaylistSection = () => {
|
||||
const isAddDragActive = useSidebarPlaylistAddDragMonitor();
|
||||
|
||||
return (
|
||||
<SidebarPlaylistAddDragContext.Provider value={isAddDragActive}>
|
||||
<SidebarPlaylistList />
|
||||
<SidebarSharedPlaylistList />
|
||||
</SidebarPlaylistAddDragContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const Sidebar = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -140,12 +153,7 @@ export const Sidebar = () => {
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<SidebarCollectionList />
|
||||
{sidebarPlaylistList && (
|
||||
<>
|
||||
<SidebarPlaylistList />
|
||||
<SidebarSharedPlaylistList />
|
||||
</>
|
||||
)}
|
||||
{sidebarPlaylistList && <SidebarPlaylistSection />}
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
|
||||
@@ -21,6 +21,21 @@ type PromptState = 'loading' | { consent: boolean };
|
||||
|
||||
export function VisualizerSystemAudioBridgeHook() {
|
||||
const playbackType = usePlaybackType();
|
||||
const isVisualizerSurfaceVisible = useIsLocalVisualizerSurfaceVisible();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldReportVisible = playbackType === PlayerType.LOCAL && isVisualizerSurfaceVisible;
|
||||
|
||||
window.api.visualizer.setLocalSurfaceVisible(shouldReportVisible);
|
||||
|
||||
return () => {
|
||||
window.api.visualizer.setLocalSurfaceVisible(false);
|
||||
};
|
||||
}, [playbackType, isVisualizerSurfaceVisible]);
|
||||
|
||||
if (!isElectron() || playbackType !== PlayerType.LOCAL) {
|
||||
return null;
|
||||
|
||||
Vendored
+9
@@ -46,7 +46,13 @@ declare global {
|
||||
FS_GENERAL_SIDE_QUEUE_TYPE?: string;
|
||||
FS_GENERAL_SIDEBAR_COLLAPSE_SHARED?: string;
|
||||
FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION?: string;
|
||||
FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_SEPARATOR?: string;
|
||||
FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_INDENT?: string;
|
||||
FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_LINE_COLOR?: string;
|
||||
FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_VIEW?: string;
|
||||
FS_GENERAL_SIDEBAR_PLAYLIST_FOLDERS?: string;
|
||||
FS_GENERAL_SIDEBAR_PLAYLIST_LIST?: string;
|
||||
FS_GENERAL_SIDEBAR_PLAYLIST_MODE?: string;
|
||||
FS_GENERAL_SIDEBAR_PLAYLIST_SORTING?: string;
|
||||
FS_GENERAL_THEME?: string;
|
||||
FS_GENERAL_THEME_DARK?: string;
|
||||
@@ -65,13 +71,16 @@ declare global {
|
||||
FS_LYRICS_TRANSLATION_API_KEY?: string;
|
||||
FS_LYRICS_TRANSLATION_TARGET_LANGUAGE?: string;
|
||||
FS_PLAYBACK_AUDIO_FADE_ON_STATUS_CHANGE?: string;
|
||||
FS_PLAYBACK_FILTERS?: string;
|
||||
FS_PLAYBACK_MEDIA_SESSION?: string;
|
||||
FS_PLAYBACK_PRESERVE_PITCH?: string;
|
||||
FS_PLAYBACK_SCROBBLE_AT_DURATION?: string;
|
||||
FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE?: string;
|
||||
FS_PLAYBACK_SCROBBLE_ENABLED?: string;
|
||||
FS_PLAYBACK_SCROBBLE_NOTIFY?: string;
|
||||
FS_PLAYBACK_TRANSCODE_BITRATE?: string;
|
||||
FS_PLAYBACK_TRANSCODE_ENABLED?: string;
|
||||
FS_PLAYBACK_TRANSCODE_FORMAT?: string;
|
||||
FS_PLAYBACK_WEB_AUDIO?: string;
|
||||
LEGACY_AUTHENTICATION?: boolean | string;
|
||||
REMOTE_URL?: string;
|
||||
|
||||
@@ -37,7 +37,7 @@ interface UseDraggableProps {
|
||||
drop?: {
|
||||
canDrop: (args: { source: DragData }) => boolean;
|
||||
getData: () => DragData;
|
||||
onDrag: (args: { edge: Edge | null }) => void;
|
||||
onDrag: (args: { edge: Edge | null; source: DragData }) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (args: { edge: Edge | null; self: DragData; source: DragData }) => void;
|
||||
};
|
||||
@@ -139,10 +139,14 @@ export const useDragDrop = <TElement extends HTMLElement>({
|
||||
},
|
||||
onDrag: (args) => {
|
||||
const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);
|
||||
drop.onDrag?.({ edge: closestEdgeOfTarget });
|
||||
drop.onDrag?.({
|
||||
edge: closestEdgeOfTarget,
|
||||
source: args.source.data as unknown as DragData,
|
||||
});
|
||||
setIsDraggedOver(closestEdgeOfTarget);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
drop.onDragLeave?.();
|
||||
setIsDraggedOver(null);
|
||||
},
|
||||
onDrop: (args) => {
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -1,7 +1,73 @@
|
||||
import type { SettingsState } from './settings.store';
|
||||
import type { PlayerFilter, SettingsState } from './settings.store';
|
||||
|
||||
import { sanitizeCss } from '/@/renderer/utils/sanitize';
|
||||
|
||||
const PLAYER_FILTER_FIELDS = new Set([
|
||||
'albumArtist',
|
||||
'artist',
|
||||
'duration',
|
||||
'favorite',
|
||||
'genre',
|
||||
'name',
|
||||
'note',
|
||||
'path',
|
||||
'playCount',
|
||||
'rating',
|
||||
'year',
|
||||
]);
|
||||
|
||||
const PLAYER_FILTER_OPERATORS = new Set([
|
||||
'after',
|
||||
'afterDate',
|
||||
'before',
|
||||
'beforeDate',
|
||||
'contains',
|
||||
'endsWith',
|
||||
'gt',
|
||||
'inTheLast',
|
||||
'inTheRange',
|
||||
'inTheRangeDate',
|
||||
'is',
|
||||
'isNot',
|
||||
'lt',
|
||||
'notContains',
|
||||
'notInTheLast',
|
||||
'regex',
|
||||
'startsWith',
|
||||
]);
|
||||
|
||||
function isValidPlayerFilter(item: unknown): item is PlayerFilter {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) return false;
|
||||
const o = item as Record<string, unknown>;
|
||||
if (typeof o.id !== 'string') return false;
|
||||
if (typeof o.field !== 'string' || !PLAYER_FILTER_FIELDS.has(o.field)) return false;
|
||||
if (typeof o.operator !== 'string' || !PLAYER_FILTER_OPERATORS.has(o.operator)) return false;
|
||||
if (!isValidPlayerFilterValue(o.value)) return false;
|
||||
if (o.isEnabled !== undefined && typeof o.isEnabled !== 'boolean') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isValidPlayerFilterValue(value: unknown): boolean {
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return true;
|
||||
}
|
||||
if (!Array.isArray(value)) return false;
|
||||
return value.every((v) => typeof v === 'string' || typeof v === 'number');
|
||||
}
|
||||
|
||||
function parsePlaybackFiltersJson(raw: string): unknown {
|
||||
const t = raw.trim();
|
||||
if (t === '') return undefined;
|
||||
try {
|
||||
const v = JSON.parse(t) as unknown;
|
||||
if (!Array.isArray(v)) return undefined;
|
||||
if (!v.every(isValidPlayerFilter)) return undefined;
|
||||
return v;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const APP_THEMES = new Set([
|
||||
'ayuDark',
|
||||
'ayuLight',
|
||||
@@ -43,6 +109,8 @@ const FONT_TYPES = new Set(['builtIn', 'custom', 'system']);
|
||||
const HOME_FEATURE_STYLES = new Set(['multiple', 'single']);
|
||||
const SIDE_QUEUE_TYPES = new Set(['sideDrawerQueue', 'sideQueue']);
|
||||
const SIDE_QUEUE_LAYOUTS = new Set(['horizontal', 'vertical']);
|
||||
const SIDEBAR_PLAYLIST_FOLDER_VIEWS = new Set(['navigation', 'single', 'tree']);
|
||||
const SIDEBAR_PLAYLIST_MODES = new Set(['compact', 'expanded']);
|
||||
|
||||
export type EnvSettingsOverrides = DeepPartial<
|
||||
Pick<SettingsState, 'autoDJ' | 'css' | 'discord' | 'font' | 'general' | 'lyrics' | 'playback'>
|
||||
@@ -55,28 +123,29 @@ type DeepPartial<T> = {
|
||||
interface EnvSettingSpec {
|
||||
enumSet?: Set<string>;
|
||||
key: string;
|
||||
path: [string, string, string] | [string, string];
|
||||
path: readonly string[];
|
||||
skipIfEmpty?: boolean;
|
||||
transform?: (raw: string) => unknown;
|
||||
type: 'bool' | 'enum' | 'num' | 'string';
|
||||
}
|
||||
|
||||
function setAtPath(
|
||||
obj: EnvSettingsOverrides,
|
||||
path: [string, string, string] | [string, string],
|
||||
value: unknown,
|
||||
): void {
|
||||
const [a, b, c] = path;
|
||||
const root = (obj as Record<string, unknown>)[a] ?? {};
|
||||
(obj as Record<string, unknown>)[a] = root;
|
||||
const branch = root as Record<string, unknown>;
|
||||
if (c === undefined) {
|
||||
branch[b] = value;
|
||||
} else {
|
||||
const nested = branch[b] ?? {};
|
||||
branch[b] = nested;
|
||||
(nested as Record<string, unknown>)[c] = value;
|
||||
function setAtPath(obj: EnvSettingsOverrides, path: readonly string[], value: unknown): void {
|
||||
if (path.length < 2) return;
|
||||
let cur: Record<string, unknown> = obj as Record<string, unknown>;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const key = path[i]!;
|
||||
const existing = cur[key];
|
||||
const next: Record<string, unknown> =
|
||||
existing !== null &&
|
||||
existing !== undefined &&
|
||||
typeof existing === 'object' &&
|
||||
!Array.isArray(existing)
|
||||
? { ...(existing as Record<string, unknown>) }
|
||||
: {};
|
||||
cur[key] = next;
|
||||
cur = next;
|
||||
}
|
||||
cur[path[path.length - 1]!] = value;
|
||||
}
|
||||
|
||||
const RGB_ACCENT_REGEX = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/;
|
||||
@@ -189,11 +258,48 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [
|
||||
path: ['general', 'sidebarCollapseShared'],
|
||||
type: 'bool',
|
||||
},
|
||||
{
|
||||
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_FOLDERS',
|
||||
path: ['general', 'sidebarPlaylistFolders'],
|
||||
type: 'bool',
|
||||
},
|
||||
{
|
||||
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_SEPARATOR',
|
||||
path: ['general', 'sidebarPlaylistFolderSeparator'],
|
||||
skipIfEmpty: true,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_INDENT',
|
||||
path: ['general', 'sidebarPlaylistFolderTreeIndent'],
|
||||
transform: (s) => {
|
||||
const n = parseNum(s);
|
||||
return n !== undefined ? Math.min(64, Math.max(0, Math.round(n))) : undefined;
|
||||
},
|
||||
type: 'num',
|
||||
},
|
||||
{
|
||||
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_LINE_COLOR',
|
||||
path: ['general', 'sidebarPlaylistFolderTreeLineColor'],
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
enumSet: SIDEBAR_PLAYLIST_FOLDER_VIEWS,
|
||||
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_VIEW',
|
||||
path: ['general', 'sidebarPlaylistFolderView'],
|
||||
type: 'enum',
|
||||
},
|
||||
{
|
||||
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_LIST',
|
||||
path: ['general', 'sidebarPlaylistList'],
|
||||
type: 'bool',
|
||||
},
|
||||
{
|
||||
enumSet: SIDEBAR_PLAYLIST_MODES,
|
||||
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_MODE',
|
||||
path: ['general', 'sidebarPlaylistMode'],
|
||||
type: 'enum',
|
||||
},
|
||||
{
|
||||
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_SORTING',
|
||||
path: ['general', 'sidebarPlaylistSorting'],
|
||||
@@ -252,6 +358,23 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [
|
||||
path: ['playback', 'transcode', 'enabled'],
|
||||
type: 'bool',
|
||||
},
|
||||
{
|
||||
key: 'FS_PLAYBACK_TRANSCODE_FORMAT',
|
||||
path: ['playback', 'transcode', 'format'],
|
||||
skipIfEmpty: true,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: 'FS_PLAYBACK_TRANSCODE_BITRATE',
|
||||
path: ['playback', 'transcode', 'bitrate'],
|
||||
type: 'num',
|
||||
},
|
||||
{
|
||||
key: 'FS_PLAYBACK_FILTERS',
|
||||
path: ['playback', 'filters'],
|
||||
transform: parsePlaybackFiltersJson,
|
||||
type: 'string',
|
||||
},
|
||||
{ key: 'FS_DISCORD_ENABLED', path: ['discord', 'enabled'], type: 'bool' },
|
||||
{
|
||||
key: 'FS_DISCORD_CLIENT_ID',
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './app.store';
|
||||
export * from './auth.store';
|
||||
export * from './full-screen-player.store';
|
||||
export * from './player.store';
|
||||
export * from './scrobble-debug.store';
|
||||
export * from './scroll.store';
|
||||
export * from './settings.store';
|
||||
export * from './timestamp.store';
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useShallow } from 'zustand/react/shallow';
|
||||
import { createWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||
import { useRadioStore as useRadioPlayerStore } from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import { createSelectors } from '/@/renderer/lib/zustand';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import {
|
||||
@@ -138,6 +139,25 @@ export function calculateNextSong(
|
||||
}
|
||||
}
|
||||
|
||||
export function getDualPlayerSongs(
|
||||
playerNum: 1 | 2,
|
||||
currentSong: QueueSong | undefined,
|
||||
nextSong: QueueSong | undefined,
|
||||
repeat: PlayerRepeat,
|
||||
): { player1: QueueSong | undefined; player2: QueueSong | undefined } {
|
||||
if (repeat === PlayerRepeat.ONE) {
|
||||
return {
|
||||
player1: playerNum === 1 ? currentSong : undefined,
|
||||
player2: playerNum === 2 ? currentSong : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
player1: playerNum === 1 ? currentSong : nextSong,
|
||||
player2: playerNum === 2 ? currentSong : nextSong,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to check if shuffle is enabled
|
||||
export function isShuffleEnabled(state: {
|
||||
player: { shuffle: PlayerShuffle };
|
||||
@@ -477,6 +497,10 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
break;
|
||||
}
|
||||
case Play.NOW: {
|
||||
if (useRadioPlayerStore.getState().currentStreamUrl) {
|
||||
useRadioPlayerStore.getState().actions.stop();
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
newItems.forEach((item) => {
|
||||
state.queue.songs[item._uniqueId] = item;
|
||||
@@ -531,6 +555,10 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
break;
|
||||
}
|
||||
case Play.SHUFFLE: {
|
||||
if (useRadioPlayerStore.getState().currentStreamUrl) {
|
||||
useRadioPlayerStore.getState().actions.stop();
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
newItems.forEach((item) => {
|
||||
state.queue.songs[item._uniqueId] = item;
|
||||
@@ -791,13 +819,20 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
nextSong = calculateNextSong(queueIndex, queue.items, repeat);
|
||||
}
|
||||
|
||||
const { player1, player2 } = getDualPlayerSongs(
|
||||
state.player.playerNum,
|
||||
currentSong,
|
||||
nextSong,
|
||||
repeat,
|
||||
);
|
||||
|
||||
return {
|
||||
currentSong,
|
||||
index: queueIndex, // Return the actual queue position for display
|
||||
nextSong,
|
||||
num: state.player.playerNum,
|
||||
player1: state.player.playerNum === 1 ? currentSong : nextSong,
|
||||
player2: state.player.playerNum === 2 ? currentSong : nextSong,
|
||||
player1,
|
||||
player2,
|
||||
previousSong,
|
||||
queueLength: state.queue.default.length,
|
||||
status: state.player.status,
|
||||
@@ -886,12 +921,21 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
? stateSnapshot.queue.shuffled.length
|
||||
: queue.items.length;
|
||||
|
||||
const newPlayerNum = player.playerNum === 1 ? 2 : 1;
|
||||
const { nextIndex: nextPlaybackIndex, shouldPause } = calculateNextIndex(
|
||||
currentIndex,
|
||||
playbackLength,
|
||||
repeat,
|
||||
);
|
||||
const isRepeatOneSameTrack =
|
||||
repeat === PlayerRepeat.ONE && nextPlaybackIndex === currentIndex;
|
||||
// Dual web players alternate for gapless/crossfade between tracks. Repeat-one
|
||||
// replays the same track — keep playerNum so Chromium stays bound to the same
|
||||
// <audio> element and hardware media keys keep working.
|
||||
const newPlayerNum = isRepeatOneSameTrack
|
||||
? player.playerNum
|
||||
: player.playerNum === 1
|
||||
? 2
|
||||
: 1;
|
||||
const pauseOnNext = player.pauseOnNextSongEnd;
|
||||
const newStatus =
|
||||
shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING;
|
||||
@@ -954,13 +998,20 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
currentQueueIndex > 0 ? queue.items[currentQueueIndex - 1] : undefined;
|
||||
}
|
||||
|
||||
const { player1, player2 } = getDualPlayerSongs(
|
||||
newPlayerNum,
|
||||
currentSong,
|
||||
nextSong,
|
||||
repeat,
|
||||
);
|
||||
|
||||
return {
|
||||
currentSong,
|
||||
index: currentQueueIndex,
|
||||
nextSong,
|
||||
num: newPlayerNum,
|
||||
player1: newPlayerNum === 1 ? currentSong : nextSong,
|
||||
player2: newPlayerNum === 2 ? currentSong : nextSong,
|
||||
player1,
|
||||
player2,
|
||||
previousSong,
|
||||
queueLength: queue.items.length,
|
||||
status: newStatus,
|
||||
@@ -1953,13 +2004,20 @@ export const usePlayerData = (): PlayerData => {
|
||||
nextSong = calculateNextSong(queueIndex, queue.items, repeat);
|
||||
}
|
||||
|
||||
const { player1, player2 } = getDualPlayerSongs(
|
||||
state.player.playerNum,
|
||||
currentSong,
|
||||
nextSong,
|
||||
repeat,
|
||||
);
|
||||
|
||||
return {
|
||||
currentSong,
|
||||
index: queueIndex, // Return the actual queue position for display
|
||||
nextSong,
|
||||
num: state.player.playerNum,
|
||||
player1: state.player.playerNum === 1 ? currentSong : nextSong,
|
||||
player2: state.player.playerNum === 2 ? currentSong : nextSong,
|
||||
player1,
|
||||
player2,
|
||||
previousSong,
|
||||
queueLength: state.queue.default.length,
|
||||
status: state.player.status,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
export type ScrobbleDebugSnapshot = {
|
||||
eligibilityMet: boolean;
|
||||
lastListenSampleTimeSec: null | number;
|
||||
listenedMs: number;
|
||||
playerStatus: PlayerStatus;
|
||||
positionSec: number;
|
||||
songId?: string;
|
||||
songName?: string;
|
||||
submitted: boolean;
|
||||
targetDurationSec: number;
|
||||
targetPercentage: number;
|
||||
trackDurationMs: number;
|
||||
};
|
||||
|
||||
const initialSnapshot: ScrobbleDebugSnapshot = {
|
||||
eligibilityMet: false,
|
||||
lastListenSampleTimeSec: null,
|
||||
listenedMs: 0,
|
||||
playerStatus: PlayerStatus.PAUSED,
|
||||
positionSec: 0,
|
||||
songId: undefined,
|
||||
songName: undefined,
|
||||
submitted: false,
|
||||
targetDurationSec: 240,
|
||||
targetPercentage: 75,
|
||||
trackDurationMs: 0,
|
||||
};
|
||||
|
||||
type ScrobbleDebugStore = {
|
||||
snapshot: ScrobbleDebugSnapshot;
|
||||
};
|
||||
|
||||
export const useScrobbleDebugStore = createWithEqualityFn<ScrobbleDebugStore>()(() => ({
|
||||
snapshot: initialSnapshot,
|
||||
}));
|
||||
|
||||
export const publishScrobbleDebug = (partial: Partial<ScrobbleDebugSnapshot>) => {
|
||||
useScrobbleDebugStore.setState((state) => ({
|
||||
snapshot: { ...state.snapshot, ...partial },
|
||||
}));
|
||||
};
|
||||
@@ -160,6 +160,7 @@ const BindingActionsSchema = z.enum([
|
||||
'listPlayNext',
|
||||
'listPlayLast',
|
||||
'listNavigateToPage',
|
||||
'listShowPlayingSong',
|
||||
]);
|
||||
|
||||
const DiscordDisplayTypeSchema = z.enum(['artist', 'feishin', 'song']);
|
||||
@@ -175,6 +176,10 @@ const SideQueueLayoutSchema = z.enum(['horizontal', 'vertical']);
|
||||
|
||||
const SidebarPanelTypeSchema = z.enum(['queue', 'lyrics', 'visualizer']);
|
||||
|
||||
const SidebarPlaylistFolderViewSchema = z.enum(['single', 'tree', 'navigation']);
|
||||
|
||||
const SidebarPlaylistModeSchema = z.enum(['compact', 'expanded']);
|
||||
|
||||
const CollectionSchema = z.object({
|
||||
filterQueryString: z.string(),
|
||||
id: z.string(),
|
||||
@@ -500,8 +505,14 @@ export const GeneralSettingsSchema = z.object({
|
||||
sidebarCollapseShared: z.boolean(),
|
||||
sidebarItems: z.array(SidebarItemTypeSchema),
|
||||
sidebarPanelOrder: z.array(SidebarPanelTypeSchema),
|
||||
sidebarPlaylistFolders: z.boolean(),
|
||||
sidebarPlaylistFolderSeparator: z.string().min(1),
|
||||
sidebarPlaylistFolderTreeIndent: z.number().int().min(0).max(64),
|
||||
sidebarPlaylistFolderTreeLineColor: z.string(),
|
||||
sidebarPlaylistFolderView: SidebarPlaylistFolderViewSchema,
|
||||
sidebarPlaylistList: z.boolean(),
|
||||
sidebarPlaylistListFilterRegex: z.string(),
|
||||
sidebarPlaylistMode: SidebarPlaylistModeSchema,
|
||||
sidebarPlaylistSorting: z.boolean(),
|
||||
sideQueueLayout: SideQueueLayoutSchema,
|
||||
sideQueueType: SideQueueTypeSchema,
|
||||
@@ -756,6 +767,7 @@ export enum BindingActions {
|
||||
LIST_PLAY_LAST = 'listPlayLast',
|
||||
LIST_PLAY_NEXT = 'listPlayNext',
|
||||
LIST_PLAY_NOW = 'listPlayNow',
|
||||
LIST_SHOW_PLAYING_SONG = 'listShowPlayingSong',
|
||||
LOCAL_SEARCH = 'localSearch',
|
||||
MUTE = 'volumeMute',
|
||||
NAVIGATE_HOME = 'navigateHome',
|
||||
@@ -1169,8 +1181,14 @@ const initialState: SettingsState = {
|
||||
sidebarCollapseShared: false,
|
||||
sidebarItems,
|
||||
sidebarPanelOrder: ['queue', 'lyrics', 'visualizer'],
|
||||
sidebarPlaylistFolders: true,
|
||||
sidebarPlaylistFolderSeparator: '/',
|
||||
sidebarPlaylistFolderTreeIndent: 16,
|
||||
sidebarPlaylistFolderTreeLineColor: '',
|
||||
sidebarPlaylistFolderView: 'tree',
|
||||
sidebarPlaylistList: true,
|
||||
sidebarPlaylistListFilterRegex: '',
|
||||
sidebarPlaylistMode: 'expanded',
|
||||
sidebarPlaylistSorting: false,
|
||||
sideQueueLayout: 'horizontal',
|
||||
sideQueueType: 'sideQueue',
|
||||
@@ -1205,6 +1223,7 @@ const initialState: SettingsState = {
|
||||
listPlayLast: { allowGlobal: false, hotkey: '', isGlobal: false },
|
||||
listPlayNext: { allowGlobal: false, hotkey: '', isGlobal: false },
|
||||
listPlayNow: { allowGlobal: false, hotkey: '', isGlobal: false },
|
||||
listShowPlayingSong: { allowGlobal: false, hotkey: 'mod+l', isGlobal: false },
|
||||
localSearch: { allowGlobal: false, hotkey: 'mod+f', isGlobal: false },
|
||||
navigateHome: { allowGlobal: false, hotkey: '', isGlobal: false },
|
||||
next: { allowGlobal: true, hotkey: '', isGlobal: false },
|
||||
@@ -2552,9 +2571,27 @@ export const useCollections = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const useSidebarPlaylistFolders = () =>
|
||||
useSettingsStore((state) => state.general.sidebarPlaylistFolders, shallow);
|
||||
|
||||
export const useSidebarPlaylistFolderSeparator = () =>
|
||||
useSettingsStore((state) => state.general.sidebarPlaylistFolderSeparator, shallow);
|
||||
|
||||
export const useSidebarPlaylistFolderView = () =>
|
||||
useSettingsStore((state) => state.general.sidebarPlaylistFolderView, shallow);
|
||||
|
||||
export const useSidebarPlaylistFolderTreeIndent = () =>
|
||||
useSettingsStore((state) => state.general.sidebarPlaylistFolderTreeIndent, shallow);
|
||||
|
||||
export const useSidebarPlaylistFolderTreeLineColor = () =>
|
||||
useSettingsStore((state) => state.general.sidebarPlaylistFolderTreeLineColor, shallow);
|
||||
|
||||
export const useSidebarPlaylistList = () =>
|
||||
useSettingsStore((state) => state.general.sidebarPlaylistList, shallow);
|
||||
|
||||
export const useSidebarPlaylistMode = () =>
|
||||
useSettingsStore((state) => state.general.sidebarPlaylistMode, shallow);
|
||||
|
||||
export const useSidebarPlaylistSorting = () =>
|
||||
useSettingsStore((state) => state.general.sidebarPlaylistSorting, shallow);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import { del, get, set } from 'idb-keyval';
|
||||
import { persist, subscribeWithSelector } from 'zustand/middleware';
|
||||
import { createWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
interface TimestampState {
|
||||
@@ -6,13 +7,36 @@ interface TimestampState {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const timestampStorage = {
|
||||
getItem: async (name: string) => {
|
||||
const value = await get(name);
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { state: { timestamp: value }, version: 1 } as const;
|
||||
},
|
||||
removeItem: async (name: string) => {
|
||||
await del(name);
|
||||
},
|
||||
setItem: async (name: string, value: { state: { timestamp: number }; version?: number }) => {
|
||||
await set(name, value.state.timestamp);
|
||||
},
|
||||
};
|
||||
|
||||
export const useTimestampStoreBase = createWithEqualityFn<TimestampState>()(
|
||||
subscribeWithSelector((set) => ({
|
||||
setTimestamp: (timestamp: number) => {
|
||||
set({ timestamp });
|
||||
persist(
|
||||
subscribeWithSelector((set) => ({
|
||||
setTimestamp: (timestamp: number) => {
|
||||
set({ timestamp });
|
||||
},
|
||||
timestamp: 0,
|
||||
})),
|
||||
{
|
||||
name: 'player-timestamp',
|
||||
storage: timestampStorage,
|
||||
version: 1,
|
||||
},
|
||||
timestamp: 0,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
export const subscribePlayerProgress = (
|
||||
|
||||
@@ -518,8 +518,9 @@ export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder:
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
(v) => v.name.toLowerCase(),
|
||||
],
|
||||
[order, order],
|
||||
[order, order, 'asc'],
|
||||
);
|
||||
break;
|
||||
case AlbumListSort.SONG_COUNT:
|
||||
|
||||
@@ -79,6 +79,10 @@ img.size-5xl {
|
||||
color: rgb(255 49 49);
|
||||
}
|
||||
|
||||
.color-transparent {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.fill {
|
||||
fill: transparent;
|
||||
}
|
||||
@@ -123,6 +127,10 @@ img.size-5xl {
|
||||
fill: rgb(255 49 49);
|
||||
}
|
||||
|
||||
.fill-transparent {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,10 @@ import {
|
||||
LuChevronLast,
|
||||
LuChevronLeft,
|
||||
LuChevronRight,
|
||||
LuChevronsDownUp,
|
||||
LuChevronsUpDown,
|
||||
LuChevronUp,
|
||||
LuCircle,
|
||||
LuCircleCheck,
|
||||
LuCircleX,
|
||||
LuClipboardCopy,
|
||||
@@ -51,6 +54,7 @@ import {
|
||||
LuExternalLink,
|
||||
LuFileJson,
|
||||
LuFlag,
|
||||
LuFolderClosed,
|
||||
LuFolderOpen,
|
||||
LuGauge,
|
||||
LuGithub,
|
||||
@@ -247,7 +251,9 @@ export const AppIcon = {
|
||||
brandSpotify: SpotifyLogoIcon,
|
||||
cache: LuCloudDownload,
|
||||
check: LuCheck,
|
||||
circle: LuCircle,
|
||||
clipboardCopy: LuClipboardCopy,
|
||||
collapseAll: LuChevronsDownUp,
|
||||
collection: LuPackage2,
|
||||
delete: LuTrash,
|
||||
disc: LuDisc,
|
||||
@@ -267,11 +273,13 @@ export const AppIcon = {
|
||||
emptySongImage: LuMusic,
|
||||
error: LuShieldAlert,
|
||||
expand: LuExpand,
|
||||
expandAll: LuChevronsUpDown,
|
||||
externalLink: LuExternalLink,
|
||||
favorite: LuHeart,
|
||||
fileJson: LuFileJson,
|
||||
filter: LuListFilter,
|
||||
folder: LuFolderOpen,
|
||||
folderClosed: LuFolderClosed,
|
||||
genre: LuFlag,
|
||||
goToItem: LuCornerDownRight,
|
||||
hash: LuHash,
|
||||
@@ -384,6 +392,7 @@ type IconColor =
|
||||
| 'muted'
|
||||
| 'primary'
|
||||
| 'success'
|
||||
| 'transparent'
|
||||
| 'warn';
|
||||
|
||||
const _Icon = forwardRef<HTMLDivElement, IconProps>((props, ref) => {
|
||||
@@ -391,17 +400,19 @@ const _Icon = forwardRef<HTMLDivElement, IconProps>((props, ref) => {
|
||||
|
||||
const IconComponent: ComponentType<any> = AppIcon[icon];
|
||||
|
||||
const colorClassToken = color ?? (fill && fill !== 'transparent' ? fill : undefined);
|
||||
|
||||
const classNames = useMemo(
|
||||
() =>
|
||||
clsx(className, {
|
||||
[styles.fill]: true,
|
||||
[styles.pulse]: animate === 'pulse',
|
||||
[styles.spin]: animate === 'spin',
|
||||
[styles[`color-${color || fill}`]]: color || fill,
|
||||
[styles[`color-${colorClassToken}`]]: colorClassToken,
|
||||
[styles[`fill-${fill}`]]: fill,
|
||||
[styles[`size-${size}`]]: true,
|
||||
}),
|
||||
[animate, className, color, fill, size],
|
||||
[animate, className, colorClassToken, fill, size],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -60,7 +60,7 @@ export function BaseImage({
|
||||
enableAnimation = false,
|
||||
enableDebounce = false,
|
||||
enableViewport = true,
|
||||
fetchPriority,
|
||||
fetchPriority = 'low',
|
||||
imageContainerProps,
|
||||
imageRequest,
|
||||
includeLoader = true,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section {
|
||||
transition: width 0.15s ease-out;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ProgressProps as MantineProgressProps } from '@mantine/core';
|
||||
|
||||
import { Progress as MantineProgress } from '@mantine/core';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import styles from './progress.module.css';
|
||||
|
||||
export interface ProgressProps extends MantineProgressProps {}
|
||||
|
||||
export const Progress = forwardRef<HTMLDivElement, ProgressProps>(
|
||||
({ classNames, style, ...props }, ref) => {
|
||||
return (
|
||||
<MantineProgress
|
||||
classNames={{
|
||||
root: styles.root,
|
||||
section: styles.section,
|
||||
...classNames,
|
||||
}}
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Progress.displayName = 'Progress';
|
||||
@@ -12,6 +12,7 @@ export enum DragTarget {
|
||||
GRID_ROW = 'gridRow',
|
||||
PLAYLIST = LibraryItem.PLAYLIST,
|
||||
QUEUE_SONG = LibraryItem.QUEUE_SONG,
|
||||
SIDEBAR_PLAYLIST_FOLDER = 'sidebarPlaylistFolder',
|
||||
SONG = LibraryItem.SONG,
|
||||
TABLE_COLUMN = 'tableColumn',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user