Compare commits

..

46 Commits

Author SHA1 Message Date
jeffvli 6528859ea9 adjust ItemDetail row play control alignment to match ItemTable 2026-05-22 03:43:48 -07:00
jeffvli 704745eb7c fix item table crashing with row play controls
- hovercard causes issues with the image component as the component is mounted/unmounted within the portal
2026-05-22 03:28:37 -07:00
jeffvli 2759cfee97 Revert "fix NativeImage state causing crashes in table"
This reverts commit 4008c8dfdf.
2026-05-22 01:56:50 -07:00
jeffvli 755f0aab9d fix mediasession breaking on player repeat (#1472)
- switch to single web player instance for loop instead of dual-player
- this fixes the issue, but does have a breaking change if using the crossfade player
2026-05-22 01:32:22 -07:00
jeffvli 0e163543fc update lockfile for electron v41 2026-05-21 11:30:09 -07:00
jeffvli fad2534d44 update to electron v41 in deps 2026-05-21 11:28:13 -07:00
jeffvli edfc64d790 update to electron v41 (#2047) 2026-05-21 11:20:55 -07:00
Tiago Simionato 8f40894926 feat: persist player timestamp (#2043)
* feat: persist player timestamp
2026-05-21 00:16:46 -07:00
Kendall Garner 2befcb4e74 disable node integration (#2049) 2026-05-20 21:06:28 -07:00
jeffvli 2d78c32a68 prevent track radio from prepending song in queue
- this behavior relates to when using NEXT or LAST as this will add a duplicate song
2026-05-20 20:49:29 -07:00
jeffvli 3d0500980a implement row playback controls for the detail list 2026-05-20 20:40:30 -07:00
jeffvli 3551ee5077 prevent desktopCaturer from running unless visualizer explicitly requested (#1931) 2026-05-20 20:34:25 -07:00
jeffvli 4008c8dfdf fix NativeImage state causing crashes in table 2026-05-19 21:03:28 -07:00
jeffvli 64efbc5210 add table row playback controls
- supports song, album, artist, and album artist tables
- hovering over the first row index or track number column will display a hovercard for the playback controls
2026-05-19 21:03:27 -07:00
Kendall Garner 42e9394246 fix lint 2026-05-19 20:11:42 -07:00
Kendall Garner afbe8fac52 include add to playlist action in quick search 2026-05-19 20:06:15 -07:00
Hosted Weblate 2c1be99bd5 Translated using Weblate
Currently translated at 98.4% (1211 of 1230 strings) (French)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/

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

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

Fixes #2038

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

* fix: correct import ordering for lint

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

---------

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

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

Translated using Weblate

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

Translated using Weblate

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

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: York <goog10216922@gmail.com>
2026-05-15 07:11:36 +02:00
Hosted Weblate 61c8e996d3 Translated using Weblate
Currently translated at 98.8% (1212 of 1226 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Co-authored-by: linger <linger0517@gmail.com>
2026-05-14 05:38:01 +02:00
jeffvli ed80f06e72 lint files 2026-05-13 20:37:50 -07:00
jeffvli adc9e30266 Merge branch 'development' of https://github.com/jeffvli/feishin into development 2026-05-13 19:02:54 -07:00
jeffvli fcd4c27fb8 support playlist folder drag/drop 2026-05-13 18:39:06 -07:00
jeffvli c4ef6f3799 add sidebar playlist folder settings to env, add compact sidebar playlist view 2026-05-13 17:26:41 -07:00
Hosted Weblate 073b482fed Translated using Weblate
Currently translated at 100.0% (1209 of 1209 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Translated using Weblate

Currently translated at 100.0% (1209 of 1209 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

Currently translated at 100.0% (1209 of 1209 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

Currently translated at 99.6% (1204 of 1208 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: linger <linger0517@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-05-14 02:08:22 +02:00
Norman 74939c6417 Adding a hotkey to jump to the currently playing song (only works without pagination) (#2024) 2026-05-13 17:08:05 -07:00
Shawn 27a62a2a02 feat(sidebar): multi-level playlist folders with tree and navigation views (#2017)
Group playlists into folders using a configurable separator (default '/').
Three view modes:
- Single: first-level grouping only (original behavior)
- Tree: full recursive nesting with connecting lines (configurable indent and line color)
- Navigation: drill-down view with stacked breadcrumb chain

Folders are sorted before playlists at every level. New settings render as
indented sub-options under the master 'Enable folders' toggle.
2026-05-13 17:07:45 -07:00
jeffvli ffe59b2c78 refactor scrobbling to use duration instead of progress (#2010)
- add scrobble status debug and indicator
- add force / reset scrobble
2026-05-12 22:04:46 -07:00
jeffvli 4226da94ec add Mantine progress component 2026-05-12 21:43:35 -07:00
jeffvli 6e634972c9 add transparent icon color variant 2026-05-12 21:43:27 -07:00
Hosted Weblate 984b85e0e9 Translated using Weblate
Currently translated at 75.6% (914 of 1208 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

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

Translated using Weblate

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

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: linger <linger0517@gmail.com>
2026-05-13 02:48:17 +00:00
jeffvli 9bb38e1df3 apply additional name sort to RELEASE_DATE album list sort (#2022) 2026-05-12 19:48:03 -07:00
jeffvli 4ecd8271a2 add transcode and playback filters to env settings (#2018) 2026-05-12 19:39:28 -07:00
jeffvli ce7a319d2f fix entity locales to titleCase 2026-05-12 18:50:03 -07:00
jeffvli 1616021451 fix regression on ActionBar width
- Mantine v9 uses a new inner div on the Grid component
2026-05-12 02:15:56 -07:00
Jeff e2a1d813a9 Use proper casing for i18n locales (#1998) 2026-05-11 19:42:07 -07:00
99 changed files with 4321 additions and 665 deletions
+9
View File
@@ -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 (064). |
| `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. |
---
+2 -2
View File
@@ -1,7 +1,7 @@
appId: org.jeffvli.feishin
productName: Feishin
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
electronVersion: 39.4.0
electronVersion: 41.7.0
directories:
buildResources: assets
files:
@@ -47,7 +47,7 @@ mac:
gatekeeperAssess: false
notarize: false
extendInfo:
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
NSAudioCaptureUsageDescription: 'System audio access is required for mpv visualizer capture in Feishin'
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg:
+2 -2
View File
@@ -1,7 +1,7 @@
appId: org.jeffvli.feishin
productName: Feishin
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
electronVersion: 39.4.0
electronVersion: 41.7.0
directories:
buildResources: assets
files:
@@ -47,7 +47,7 @@ mac:
gatekeeperAssess: false
notarize: false
extendInfo:
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
NSAudioCaptureUsageDescription: 'System audio access is required for mpv visualizer capture in Feishin'
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg:
+1 -1
View File
@@ -1,7 +1,7 @@
appId: org.jeffvli.feishin
productName: Feishin
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
electronVersion: 39.4.0
electronVersion: 41.7.0
directories:
buildResources: assets
files:
+1 -1
View File
@@ -160,7 +160,7 @@
"babel-plugin-react-compiler": "^1.0.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^39.8.6",
"electron": "^41.7.0",
"electron-builder": "^26.8.2",
"electron-devtools-installer": "^4.0.0",
"electron-vite": "^4.0.1",
+13 -25
View File
@@ -23,10 +23,10 @@ importers:
version: 1.1.0
'@electron-toolkit/preload':
specifier: ^3.0.2
version: 3.0.2(electron@39.8.6)
version: 3.0.2(electron@41.7.0)
'@electron-toolkit/utils':
specifier: ^4.0.0
version: 4.0.0(electron@39.8.6)
version: 4.0.0(electron@41.7.0)
'@mantine/colors-generator':
specifier: ^9.1.1
version: 9.1.1(chroma-js@3.1.2)
@@ -269,8 +269,8 @@ importers:
specifier: ^10.1.0
version: 10.1.0
electron:
specifier: ^39.8.6
version: 39.8.6
specifier: ^41.7.0
version: 41.7.0
electron-builder:
specifier: ^26.8.2
version: 26.8.2(electron-builder-squirrel-windows@26.8.2)
@@ -2126,9 +2126,6 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
'@types/node@24.12.2':
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
@@ -3001,8 +2998,8 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'}
electron@39.8.6:
resolution: {integrity: sha512-uWX6Jh5LmwL13VwOSKBjebI+ck+03GOwc8V2Sgbmr9pJVJ/cHfli/PkjXuRDr+hq+SLHQuT9mGHSIfScebApRA==}
electron@41.7.0:
resolution: {integrity: sha512-U6KAKivjk6YQ0Z5+eloJBjwhbHRE206gvy1UBMw2bSluWtMh5waeXMvX6AT/Ujm5ymYXVJOp7g9N7vOFw16wBQ==}
engines: {node: '>= 12.20.55'}
hasBin: true
@@ -5507,9 +5504,6 @@ packages:
underscore.string@3.3.6:
resolution: {integrity: sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@@ -6684,17 +6678,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@electron-toolkit/preload@3.0.2(electron@39.8.6)':
'@electron-toolkit/preload@3.0.2(electron@41.7.0)':
dependencies:
electron: 39.8.6
electron: 41.7.0
'@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)':
dependencies:
'@types/node': 24.12.2
'@electron-toolkit/utils@4.0.0(electron@39.8.6)':
'@electron-toolkit/utils@4.0.0(electron@41.7.0)':
dependencies:
electron: 39.8.6
electron: 41.7.0
'@electron/asar@3.4.1':
dependencies:
@@ -7675,7 +7669,7 @@ snapshots:
'@types/electron-localshortcut@3.1.3':
dependencies:
electron: 39.8.6
electron: 41.7.0
transitivePeerDependencies:
- supports-color
@@ -7703,10 +7697,6 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/node@22.19.17':
dependencies:
undici-types: 6.21.0
'@types/node@24.12.2':
dependencies:
undici-types: 7.16.0
@@ -8800,10 +8790,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron@39.8.6:
electron@41.7.0:
dependencies:
'@electron/get': 2.0.3
'@types/node': 22.19.17
'@types/node': 24.12.2
extract-zip: 2.0.1
transitivePeerDependencies:
- supports-color
@@ -11588,8 +11578,6 @@ snapshots:
sprintf-js: 1.1.3
util-deprecate: 1.0.2
undici-types@6.21.0: {}
undici-types@7.16.0: {}
undici@6.24.1: {}
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+27 -5
View File
@@ -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
View File
@@ -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": "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
View File
@@ -48,7 +48,8 @@
"sleepTimer_timeRemaining": "{{time}} restante(s)",
"sleepTimer_setCustom": "Définir le minuteur",
"sleepTimer_cancel": "Annuler le minuteur",
"albumRadio": "Radio d'album"
"albumRadio": "Radio d'album",
"scrobbleForceSubmit": "Forcer le scrobble"
},
"action": {
"editPlaylist": "Éditer $t(entity.playlist, {\"count\": 1})",
@@ -92,7 +93,9 @@
"selectRangeOfItems": "Sélectionner une plage d'entrées",
"selectAll": "Tout sélectionner",
"openApplicationDirectory": "Ouvrir le répertoire de l'application",
"goToCurrent": "Aller à la piste en cours"
"goToCurrent": "Aller à la piste en cours",
"collapseAllFolders": "Réduire tous les dossiers",
"expandAllFolders": "Développer tous les dossiers"
},
"common": {
"backward": "En arrière",
@@ -222,7 +225,8 @@
"filter_multiple": "Multiple",
"rename": "Renommer",
"newVersionAvailable": "Une nouvelle version est disponible",
"numberOfResults": "{{numberOfResults}} résultats"
"numberOfResults": "{{numberOfResults}} résultats",
"back": "Retour"
},
"error": {
"remotePortWarning": "Redémarrer le serveur pour appliquer le nouveau port",
@@ -699,7 +703,7 @@
"transcodeFormat_description": "Sélectionne le format du transcodage. laisser vide pour laisser le serveur décider",
"volumeWidth": "Largeur de la barre de volume",
"volumeWidth_description": "La largeur de la barre de volume",
"customCssEnable": "Active le CSS personnalisé",
"customCssEnable": "Activer le CSS personnalisé",
"customCssEnable_description": "Permet l'écriture de CSS personnalisé",
"customCssNotice": "Attention : bien qu'il y ait un certain assainissement (blocage de URL() et de content :), l'utilisation de CSS personnalisé peut toujours présenter des risques en modifiant l'interface",
"customCss": "Css personnalisé",
@@ -895,7 +899,24 @@
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
"sidePlayQueueLayout_optionVertical": "Vertical",
"waveformLoadingDelay": "Délai de chargement de la forme d'onde",
"waveformLoadingDelay_description": "Délai en secondes avant le chargement de l'onde. augmentez cette valeur si vous rencontrez des saccades lors de l'utilisation du lecteur web."
"waveformLoadingDelay_description": "Délai en secondes avant le chargement de l'onde. augmentez cette valeur si vous rencontrez des saccades lors de l'utilisation du lecteur web.",
"sidebarPlaylistMode_optionExpanded": "Étendu",
"hotkey_listShowPlayingSong": "Affiche le titre en cours dans la liste",
"preventSuspendOnPlayback_description": "Empêcher la suspension de l'application lors de la lecture de musique",
"preventSuspendOnPlayback": "Empêcher la suspension lors de la lecture",
"sidebarPlaylistFolders_description": "Crée une vue de dossier pour les listes de lecture dont le nom inclut le séparateur configuré",
"sidebarPlaylistFolders": "Activer les dossiers",
"sidebarPlaylistFolderSeparator_description": "Caractère (ou chaîne de caractères) servant à séparer les niveaux de dossiers dans le nom d'une liste de lecture",
"sidebarPlaylistFolderSeparator": "Séparateur de dossiers",
"sidebarPlaylistFolderView_description": "Comment les dossiers sont affichés dans la barre latérale",
"sidebarPlaylistFolderView": "Affichage des dossiers",
"sidebarPlaylistFolderView_optionSingle": "Dossier unique",
"sidebarPlaylistFolderView_optionTree": "Vue arborescente",
"sidebarPlaylistFolderView_optionNavigation": "Vue de navigation",
"sidebarPlaylistFolderTreeIndent": "Indentation de l'arbre",
"sidebarPlaylistMode_description": "Comment chaque liste de lecture est affichée dans la barre latérale",
"sidebarPlaylistMode": "Mode de liste de lecture de la barre latérale",
"sidebarPlaylistMode_optionCompact": "Compacte"
},
"form": {
"deletePlaylist": {
@@ -922,12 +943,13 @@
"input_remoteUrlPlaceholder": "Optionnel : URL publique pour les fonctionnalités externes"
},
"addToPlaylist": {
"success": "$t(entity.trackWithCount, {\"count\" : {{message}} }) ajouté à $t(entity.playlistWithCount, {\"count\" : {{numOfPlaylists}} })",
"success": "$t(entity.trackWithCount, {\"count\": {{message}} }) ajouté à $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "Ajouter à $t(entity.playlist, {\"count\": 1})",
"input_skipDuplicates": "Sauter les doublons",
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"create": "Créer $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "Rechercher $t(entity.playlist, {\"count\": 2}) ou tapez pour en créer une nouvelle"
"searchOrCreate": "Rechercher $t(entity.playlist, {\"count\": 2}) ou tapez pour en créer une nouvelle",
"noneAdded": "Aucune piste n'a été ajoutée à $t(entity.playlist, {\"count\": 1}) {{playlist}}"
},
"createPlaylist": {
"title": "Créer une $t(entity.playlist, {\"count\": 1})",
@@ -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",
+2 -2
View File
@@ -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"
},
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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": {
+9 -9
View File
@@ -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
View File
@@ -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",
+8 -8
View File
@@ -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",
+75 -53
View File
@@ -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": {
+1
View File
@@ -4,3 +4,4 @@ import './player';
import './remote';
import './settings';
import './discord-rpc';
import './visualizer';
@@ -0,0 +1,30 @@
import { ipcMain } from 'electron';
import { getMpvInstance } from '../player';
import { store } from '../settings';
import { PlayerType } from '/@/shared/types/types';
let isLocalVisualizerSurfaceVisible = false;
export const setLocalVisualizerSurfaceVisible = (visible: boolean) => {
isLocalVisualizerSurfaceVisible = visible;
};
export const canHandleVisualizerDisplayMedia = (): boolean => {
const playbackType = store.get('playbackType', PlayerType.WEB) as PlayerType;
if (playbackType !== PlayerType.LOCAL) {
return false;
}
if (!isLocalVisualizerSurfaceVisible) {
return false;
}
return Boolean(getMpvInstance()?.isRunning());
};
ipcMain.on('visualizer-set-local-surface-visible', (_event, visible: boolean) => {
setLocalVisualizerSurfaceVisible(Boolean(visible));
});
+8 -2
View File
@@ -30,7 +30,9 @@ import packageJson from '../../package.json';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { shutdownServer } from './features/core/remote';
import { store } from './features/core/settings';
import { canHandleVisualizerDisplayMedia } from './features/core/visualizer';
import MenuBuilder, { MenuPlaybackState } from './menu';
import './features';
import {
autoUpdaterLogInterface,
createLog,
@@ -40,7 +42,6 @@ import {
isMacOS,
isWindows,
} from './utils';
import './features';
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
@@ -515,7 +516,7 @@ async function createWindow(first = true): Promise<void> {
backgroundThrottling: false,
contextIsolation: true,
devTools: true,
nodeIntegration: true,
nodeIntegration: false,
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: !store.get('ignore_cors'),
@@ -734,6 +735,11 @@ async function createWindow(first = true): Promise<void> {
});
mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
if (!canHandleVisualizerDisplayMedia()) {
callback({});
return;
}
if (!isMacOS()) {
callback({ audio: 'loopback' });
return;
-3
View File
@@ -1,11 +1,8 @@
import { ElectronAPI } from '@electron-toolkit/preload';
import { PreloadApi } from './index';
declare global {
interface Window {
api: PreloadApi;
electron: ElectronAPI;
LEGACY_AUTHENTICATION?: boolean;
queryLocalFonts?: () => Promise<Font[]>;
REMOTE_URL?: string;
+4 -5
View File
@@ -1,5 +1,4 @@
import { electronAPI } from '@electron-toolkit/preload';
import { contextBridge } from 'electron';
import { contextBridge, webUtils } from 'electron';
import { autodiscover } from './autodiscover';
import { browser } from './browser';
@@ -11,12 +10,14 @@ import { mpris } from './mpris';
import { mpvPlayer, mpvPlayerListener } from './mpv-player';
import { remote } from './remote';
import { utils } from './utils';
import { visualizer } from './visualizer';
// Custom APIs for renderer
const api = {
autodiscover,
browser,
discordRpc,
getPathForFile: webUtils.getPathForFile,
ipc,
localSettings,
lyrics,
@@ -25,6 +26,7 @@ const api = {
mpvPlayerListener,
remote,
utils,
visualizer,
};
export type PreloadApi = typeof api;
@@ -34,14 +36,11 @@ export type PreloadApi = typeof api;
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI);
contextBridge.exposeInMainWorld('api', api);
} catch (error) {
console.error(error);
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI;
// @ts-ignore (define in dts)
window.api = api;
}
+11
View File
@@ -0,0 +1,11 @@
import { ipcRenderer } from 'electron';
const setLocalSurfaceVisible = (visible: boolean) => {
ipcRenderer.send('visualizer-set-local-surface-visible', visible);
};
export const visualizer = {
setLocalSurfaceVisible,
};
export type VisualizerApi = typeof visualizer;
@@ -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} />;
};
@@ -0,0 +1,30 @@
.cell-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 100%;
}
.play-target {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.index-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
}
@@ -0,0 +1,39 @@
import { ReactNode } from 'react';
import styles from './row-play-control-cell.module.css';
import { ItemRowPlayControls } from '/@/renderer/features/shared/components/item-row-play-controls';
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
import { Play } from '/@/shared/types/types';
export const ItemDetailRowPlayControlCell = ({
indexContent,
onPlay,
showPlayControls,
}: {
indexContent: ReactNode;
onPlay: (playType: Play) => void;
showPlayControls: boolean;
}) => {
if (!showPlayControls) {
return <>{indexContent}</>;
}
return (
<div className={styles.cellWrapper}>
<HoverCard
offset={{ crossAxis: 32, mainAxis: 16 }}
openDelay={300}
position="top"
withArrow
>
<HoverCard.Target>
<div className={styles.playTarget}>{indexContent}</div>
</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<ItemRowPlayControls onPlay={onPlay} />
</HoverCard.Dropdown>
</HoverCard>
</div>
);
};
@@ -1,7 +1,46 @@
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
import styles from './row-play-control-cell.module.css';
import { ItemDetailListCellProps } from './types';
import { useDetailRowPlayControl } from './use-detail-row-play-control';
export const TrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
import { Icon } from '/@/shared/components/icon/icon';
import { TableColumn } from '/@/shared/types/types';
const formatTrackNumber = (song: ItemDetailListCellProps['song']) => {
const disc = song.discNumber ?? 1;
const track = song.trackNumber.toString().padStart(2, '0');
return `${disc}-${track}`;
};
const DefaultTrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
return <>{formatTrackNumber(song)}</>;
};
const PlayableTrackNumberColumn = (props: ItemDetailListCellProps) => {
const { handlePlay, isActive, isPlaying, showPlayControls } = useDetailRowPlayControl(props);
const indexContent = isActive ? (
<div className={styles.iconWrapper}>
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
</div>
) : (
formatTrackNumber(props.song)
);
return (
<ItemDetailRowPlayControlCell
indexContent={indexContent}
onPlay={handlePlay}
showPlayControls={showPlayControls}
/>
);
};
export const TrackNumberColumn = (props: ItemDetailListCellProps) => {
if (!props.columns || !isRowPlayControlColumn(TableColumn.TRACK_NUMBER, props.columns)) {
return <DefaultTrackNumberColumn {...props} />;
}
return <PlayableTrackNumberColumn {...props} />;
};
@@ -1,8 +1,9 @@
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
import { Song } from '/@/shared/types/domain-types';
export interface ItemDetailListCellProps {
columns?: ItemTableListColumnConfig[];
controls?: ItemControls;
internalState?: ItemListStateActions;
isMutatingFavorite?: boolean;
@@ -0,0 +1,47 @@
import { useCallback } from 'react';
import { ItemDetailListCellProps } from './types';
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
import { usePlayerStatus } from '/@/renderer/store';
import { Song } from '/@/shared/types/domain-types';
import { Play, PlayerStatus } from '/@/shared/types/types';
export const useDetailRowPlayControl = ({
internalState,
rowIndex = 0,
song,
}: Pick<ItemDetailListCellProps, 'internalState' | 'rowIndex' | 'song'>) => {
const status = usePlayerStatus();
const player = usePlayer();
const { isActive } = useIsCurrentSong(song);
const isPlaying = isActive && status === PlayerStatus.PLAYING;
const showPlayControls = !!song?.id;
const handlePlay = useCallback(
(playType: Play) => {
if (!song) {
return;
}
playSongFromItemListControl({
index: rowIndex,
internalState,
item: song as Song,
meta: { playType, singleSongOnly: true },
player,
});
},
[internalState, player, rowIndex, song],
);
return {
handlePlay,
isActive,
isPlaying,
showPlayControls,
};
};
@@ -447,6 +447,14 @@
padding-left: 0;
}
.row .track-cell-play-control {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
}
.track-row-dragging {
opacity: 0.5;
}
@@ -33,6 +33,7 @@ import styles from './item-detail-list.module.css';
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import {
ItemListStateActions,
@@ -365,6 +366,7 @@ const TrackRow = memo(
const isTitleColumn = col.id === TableColumn.TITLE;
const isImageColumn = col.id === TableColumn.IMAGE;
const isIconActionColumn = isNoHorizontalPaddingColumn(col.id);
const isPlayControlColumn = isRowPlayControlColumn(col.id, columns);
const showHoverContent = shouldShowHoverOnlyColumnContent(
col.id,
isRowHovered,
@@ -374,6 +376,7 @@ const TrackRow = memo(
const content = isSongsLoading ? null : showHoverContent ? (
<CellComponent
columnId={col.id}
columns={columns}
controls={controls}
internalState={internalState}
isMutatingFavorite={isMutatingFavorite}
@@ -393,6 +396,7 @@ const TrackRow = memo(
[styles.trackCellImage]: isImageColumn,
[styles.trackCellMuted]: !isTitleColumn,
[styles.trackCellNoHPadding]: isIconActionColumn,
[styles.trackCellPlayControl]: isPlayControlColumn,
[styles.trackCellVerticalBorderVisible]:
enableVerticalBorders && !isLastColumn,
[styles.trackCellWithVerticalBorder]: !isLastColumn,
@@ -1,3 +1,45 @@
.full-size-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: visible;
-webkit-line-clamp: unset;
line-clamp: unset;
}
.expansion-cell {
overflow: visible;
}
.expansion-inner {
position: relative;
width: 100%;
height: 100%;
overflow: visible;
}
.play-target {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.expand {
position: absolute;
top: 50%;
left: 50%;
z-index: 4;
transform: translate(-50%, -50%);
}
.index-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
@@ -2,30 +2,38 @@ import clsx from 'clsx';
import styles from './row-index-column.module.css';
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
import { RowPlayControlCell } from '/@/renderer/components/item-list/item-table-list/columns/row-play-control-cell';
import { useRowPlayControl } from '/@/renderer/components/item-list/item-table-list/columns/use-row-play-control';
import {
ItemTableListInnerColumn,
TableColumnContainer,
TableColumnTextContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { ItemListItem } from '/@/renderer/components/item-list/types';
import { usePlayerStatus } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Flex } from '/@/shared/components/flex/flex';
import { Icon } from '/@/shared/components/icon/icon';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
import { PlayerStatus } from '/@/shared/types/types';
import { LibraryItem } from '/@/shared/types/domain-types';
import { TableColumn } from '/@/shared/types/types';
const RowIndexColumnBase = (props: ItemTableListInnerColumn) => {
const { itemType } = props;
if (!isRowPlayControlColumn(TableColumn.ROW_INDEX, props.columns)) {
return <DefaultRowIndexColumn {...props} />;
}
switch (itemType) {
case LibraryItem.ALBUM:
case LibraryItem.ALBUM_ARTIST:
case LibraryItem.ARTIST:
case LibraryItem.FOLDER:
case LibraryItem.PLAYLIST_SONG:
case LibraryItem.QUEUE_SONG:
case LibraryItem.SONG:
return <QueueSongRowIndexColumn {...props} />;
return <PlayableRowIndexColumn {...props} />;
default:
return <DefaultRowIndexColumn {...props} />;
}
@@ -88,12 +96,8 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
return <TableColumnTextContainer {...props}>{adjustedRowIndex}</TableColumnTextContainer>;
};
const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => {
const status = usePlayerStatus();
const song = (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex]) as QueueSong;
const isActive = useIsActiveRow(song?.id, song?._uniqueId);
const isActiveAndPlaying = isActive && status === PlayerStatus.PLAYING;
const PlayableRowIndexColumn = (props: ItemTableListInnerColumn) => {
const { handlePlay, isActive, isPlaying, showPlayControls } = useRowPlayControl(props);
let adjustedRowIndex =
props.getAdjustedRowIndex?.(props.rowIndex) ??
@@ -104,38 +108,20 @@ const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => {
adjustedRowIndex = props.startRowIndex + adjustedRowIndex;
}
const indexContent = isActive ? (
<Flex className={styles.indexContent}>
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
</Flex>
) : (
adjustedRowIndex
);
return (
<InnerQueueSongRowIndexColumn
<RowPlayControlCell
{...props}
adjustedRowIndex={adjustedRowIndex}
isActive={isActive}
isPlaying={isActiveAndPlaying}
indexContent={indexContent}
onPlay={handlePlay}
showPlayControls={showPlayControls}
/>
);
};
const InnerQueueSongRowIndexColumn = (
props: ItemTableListInnerColumn & {
adjustedRowIndex: number;
isActive: boolean;
isPlaying: boolean;
},
) => {
return (
<TableColumnTextContainer {...props}>
{props.isActive ? (
props.isPlaying ? (
<Flex>
<Icon fill="primary" icon="mediaPlay" />
</Flex>
) : (
<Flex>
<Icon fill="primary" icon="mediaPause" />
</Flex>
)
) : (
props.adjustedRowIndex
)}
</TableColumnTextContainer>
);
};
@@ -0,0 +1,125 @@
import clsx from 'clsx';
import { ReactNode, useCallback } from 'react';
import styles from './row-index-column.module.css';
import {
ItemTableListInnerColumn,
TableColumnContainer,
TableColumnTextContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemListItem } from '/@/renderer/components/item-list/types';
import { ItemRowPlayControls } from '/@/renderer/features/shared/components/item-row-play-controls';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Flex } from '/@/shared/components/flex/flex';
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
import { Text } from '/@/shared/components/text/text';
import { Play } from '/@/shared/types/types';
export const RowPlayControlCell = (
props: ItemTableListInnerColumn & {
indexContent: ReactNode;
onPlay: (playType: Play) => void;
showPlayControls: boolean;
},
) => {
const {
controls,
data,
enableExpansion,
getRowItem,
indexContent,
internalState,
itemType,
onPlay,
rowIndex,
showPlayControls,
} = props;
const handleExpand = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const item = (getRowItem?.(rowIndex) ?? data[rowIndex]) as ItemListItem;
const rowId = internalState.extractRowId(item);
const index = rowId ? internalState.findItemIndex(rowId) : -1;
controls.onExpand?.({
event: e,
index,
internalState,
item,
itemType,
});
},
[controls, data, getRowItem, internalState, itemType, rowIndex],
);
const getIndexDisplay = (useMutedText: boolean) => {
const hideOnHoverClass = enableExpansion ? 'hide-on-hover' : undefined;
if (typeof indexContent === 'number') {
return useMutedText ? (
<Text className={hideOnHoverClass} isMuted isNoSelect>
{indexContent}
</Text>
) : (
indexContent
);
}
return <span className={hideOnHoverClass}>{indexContent}</span>;
};
const expansionTarget = (
<div className={styles.playTarget}>
{getIndexDisplay(true)}
<ActionIcon
className={clsx(styles.expand, 'hover-only')}
icon="arrowDownS"
iconProps={{ color: 'muted', size: 'md' }}
onClick={handleExpand}
size="xs"
variant="subtle"
/>
</div>
);
if (enableExpansion) {
return (
<TableColumnContainer {...props} className={styles.expansionCell}>
<div className={styles.expansionInner}>
{showPlayControls ? (
<HoverCard openDelay={300} position="top" withArrow withinPortal={false}>
<HoverCard.Target>{expansionTarget}</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<ItemRowPlayControls onPlay={onPlay} />
</HoverCard.Dropdown>
</HoverCard>
) : (
expansionTarget
)}
</div>
</TableColumnContainer>
);
}
if (!showPlayControls) {
return (
<TableColumnTextContainer {...props}>{getIndexDisplay(false)}</TableColumnTextContainer>
);
}
return (
<TableColumnTextContainer {...props} className={styles.fullSizeContent}>
<HoverCard openDelay={300} position="top" withArrow withinPortal={false}>
<HoverCard.Target>
<Flex className={styles.indexContent} justify="center" w="100%">
{getIndexDisplay(false)}
</Flex>
</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<ItemRowPlayControls onPlay={onPlay} />
</HoverCard.Dropdown>
</HoverCard>
</TableColumnTextContainer>
);
};
@@ -0,0 +1,51 @@
import styles from './row-index-column.module.css';
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
import { NumericColumn } from '/@/renderer/components/item-list/item-table-list/columns/numeric-column';
import { RowPlayControlCell } from '/@/renderer/components/item-list/item-table-list/columns/row-play-control-cell';
import {
supportsTrackNumberRowPlayControls,
useRowPlayControl,
} from '/@/renderer/components/item-list/item-table-list/columns/use-row-play-control';
import { ItemTableListInnerColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { Flex } from '/@/shared/components/flex/flex';
import { Icon } from '/@/shared/components/icon/icon';
import { TableColumn } from '/@/shared/types/types';
export const TrackNumberColumn = (props: ItemTableListInnerColumn) => {
if (
isRowPlayControlColumn(TableColumn.TRACK_NUMBER, props.columns) &&
supportsTrackNumberRowPlayControls(props.itemType)
) {
return <PlayableTrackNumberColumn {...props} />;
}
return <NumericColumn {...props} />;
};
const PlayableTrackNumberColumn = (props: ItemTableListInnerColumn) => {
const { handlePlay, isActive, isPlaying, showPlayControls } = useRowPlayControl(props);
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as unknown[])[props.rowIndex];
const trackNumber = (rowItem as undefined | { trackNumber?: number })?.trackNumber;
if (typeof trackNumber !== 'number') {
return <NumericColumn {...props} />;
}
const indexContent = isActive ? (
<Flex className={styles.indexContent}>
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
</Flex>
) : (
trackNumber
);
return (
<RowPlayControlCell
{...props}
indexContent={indexContent}
onPlay={handlePlay}
showPlayControls={showPlayControls}
/>
);
};
@@ -0,0 +1,140 @@
import { useCallback } from 'react';
import {
playAlbumFromItemListControl,
playArtistFromItemListControl,
playSongFromItemListControl,
} from '/@/renderer/components/item-list/helpers/play-row-from-list';
import { ItemTableListInnerColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlayerSong, usePlayerStatus } from '/@/renderer/store';
import {
Album,
AlbumArtist,
Artist,
LibraryItem,
QueueSong,
Song,
} from '/@/shared/types/domain-types';
import { Play, PlayerStatus } from '/@/shared/types/types';
export const supportsRowPlayControls = (itemType: LibraryItem) =>
itemType === LibraryItem.ALBUM ||
itemType === LibraryItem.ALBUM_ARTIST ||
itemType === LibraryItem.ARTIST ||
itemType === LibraryItem.PLAYLIST_SONG ||
itemType === LibraryItem.SONG;
export const supportsTrackNumberRowPlayControls = (itemType: LibraryItem) =>
itemType === LibraryItem.PLAYLIST_SONG ||
itemType === LibraryItem.QUEUE_SONG ||
itemType === LibraryItem.SONG;
export const hasPlayableRowItem = (
itemType: LibraryItem,
items: { album: Album; artist: AlbumArtist | Artist; song: QueueSong },
) => {
switch (itemType) {
case LibraryItem.ALBUM:
return !!items.album?.id;
case LibraryItem.ALBUM_ARTIST:
case LibraryItem.ARTIST:
return !!items.artist?.id;
default:
return !!items.song;
}
};
export const useRowPlayControl = (props: ItemTableListInnerColumn) => {
const status = usePlayerStatus();
const currentSong = usePlayerSong();
const player = usePlayer();
const rowItem = props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex];
const song = rowItem as QueueSong;
const album = rowItem as Album;
const artist = rowItem as AlbumArtist | Artist;
const isActiveFromRow = useIsActiveRow(song?.id, song?._uniqueId);
const isActive = (() => {
switch (props.itemType) {
case LibraryItem.ALBUM:
return !!album?.id && currentSong?.albumId === album.id;
case LibraryItem.ALBUM_ARTIST:
return (
!!artist?.id &&
!!currentSong?.albumArtists?.some(
(relatedArtist) => relatedArtist.id === artist.id,
)
);
case LibraryItem.ARTIST:
return (
!!artist?.id &&
!!currentSong?.artists?.some((relatedArtist) => relatedArtist.id === artist.id)
);
default:
return isActiveFromRow;
}
})();
const isPlaying = isActive && status === PlayerStatus.PLAYING;
const showPlayControls =
supportsRowPlayControls(props.itemType) &&
hasPlayableRowItem(props.itemType, { album, artist, song });
const handlePlay = useCallback(
(playType: Play) => {
if (props.itemType === LibraryItem.ALBUM) {
if (!album?.id) {
return;
}
playAlbumFromItemListControl({
album,
meta: { playType },
player,
});
return;
}
if (
props.itemType === LibraryItem.ALBUM_ARTIST ||
props.itemType === LibraryItem.ARTIST
) {
if (!artist?.id) {
return;
}
playArtistFromItemListControl({
artist,
itemType: props.itemType,
meta: { playType },
player,
});
return;
}
if (!song) {
return;
}
playSongFromItemListControl({
item: song as Song,
meta: { playType, singleSongOnly: true },
player,
});
},
[album, artist, player, props.itemType, song],
);
return {
album,
artist,
handlePlay,
isActive,
isPlaying,
showPlayControls,
song,
};
};
@@ -44,7 +44,6 @@ import { FavoriteColumn } from '/@/renderer/components/item-list/item-table-list
import { GenreBadgeColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-badge-column';
import { GenreColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-column';
import { ImageColumn } from '/@/renderer/components/item-list/item-table-list/columns/image-column';
import { NumericColumn } from '/@/renderer/components/item-list/item-table-list/columns/numeric-column';
import { PathColumn } from '/@/renderer/components/item-list/item-table-list/columns/path-column';
import { PlaylistReorderColumn } from '/@/renderer/components/item-list/item-table-list/columns/playlist-reorder-column';
import { RatingColumn } from '/@/renderer/components/item-list/item-table-list/columns/rating-column';
@@ -54,6 +53,7 @@ import { TextColumn } from '/@/renderer/components/item-list/item-table-list/col
import { TitleArtistColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-artist-column';
import { TitleColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-column';
import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-combined-column';
import { TrackNumberColumn } from '/@/renderer/components/item-list/item-table-list/columns/track-number-column';
import { YearColumn } from '/@/renderer/components/item-list/item-table-list/columns/year-column';
import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state';
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
@@ -240,7 +240,9 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
case TableColumn.DISC_NUMBER:
case TableColumn.SAMPLE_RATE:
case TableColumn.TRACK_NUMBER:
return <NumericColumn {...props} {...dragProps} controls={controls} type={type} />;
return (
<TrackNumberColumn {...props} {...dragProps} controls={controls} type={type} />
);
case TableColumn.COMPOSER:
return <ComposerColumn {...props} {...dragProps} controls={controls} type={type} />;
@@ -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]);
@@ -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;
+9
View File
@@ -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;
+6 -2
View File
@@ -37,7 +37,7 @@ interface UseDraggableProps {
drop?: {
canDrop: (args: { source: DragData }) => boolean;
getData: () => DragData;
onDrag: (args: { edge: Edge | null }) => void;
onDrag: (args: { edge: Edge | null; source: DragData }) => void;
onDragLeave: () => void;
onDrop: (args: { edge: Edge | null; self: DragData; source: DragData }) => void;
};
@@ -139,10 +139,14 @@ export const useDragDrop = <TElement extends HTMLElement>({
},
onDrag: (args) => {
const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data);
drop.onDrag?.({ edge: closestEdgeOfTarget });
drop.onDrag?.({
edge: closestEdgeOfTarget,
source: args.source.data as unknown as DragData,
});
setIsDraggedOver(closestEdgeOfTarget);
},
onDragLeave: () => {
drop.onDragLeave?.();
setIsDraggedOver(null);
},
onDrop: (args) => {
+33
View File
@@ -0,0 +1,33 @@
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { useEffect } from 'react';
import { DragData } from '/@/shared/types/drag-and-drop';
interface UseDragMonitorProps {
canMonitor?: (source: DragData) => boolean;
isEnabled?: boolean;
onDragStart?: (source: DragData) => void;
onDrop?: () => void;
}
export const useDragMonitor = ({
canMonitor,
isEnabled = true,
onDragStart,
onDrop,
}: UseDragMonitorProps) => {
useEffect(() => {
if (!isEnabled) return;
return monitorForElements({
onDragStart: ({ source }) => {
const data = source.data as unknown as DragData;
if (canMonitor && !canMonitor(data)) return;
onDragStart?.(data);
},
onDrop: () => {
onDrop?.();
},
});
}, [canMonitor, isEnabled, onDragStart, onDrop]);
};
+140 -17
View File
@@ -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',
+1
View File
@@ -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';
+65 -7
View File
@@ -7,6 +7,7 @@ import { useShallow } from 'zustand/react/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { useRadioStore as useRadioPlayerStore } from '/@/renderer/features/radio/hooks/use-radio-player';
import { createSelectors } from '/@/renderer/lib/zustand';
import { useSettingsStore } from '/@/renderer/store/settings.store';
import {
@@ -138,6 +139,25 @@ export function calculateNextSong(
}
}
export function getDualPlayerSongs(
playerNum: 1 | 2,
currentSong: QueueSong | undefined,
nextSong: QueueSong | undefined,
repeat: PlayerRepeat,
): { player1: QueueSong | undefined; player2: QueueSong | undefined } {
if (repeat === PlayerRepeat.ONE) {
return {
player1: playerNum === 1 ? currentSong : undefined,
player2: playerNum === 2 ? currentSong : undefined,
};
}
return {
player1: playerNum === 1 ? currentSong : nextSong,
player2: playerNum === 2 ? currentSong : nextSong,
};
}
// Helper function to check if shuffle is enabled
export function isShuffleEnabled(state: {
player: { shuffle: PlayerShuffle };
@@ -477,6 +497,10 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
break;
}
case Play.NOW: {
if (useRadioPlayerStore.getState().currentStreamUrl) {
useRadioPlayerStore.getState().actions.stop();
}
set((state) => {
newItems.forEach((item) => {
state.queue.songs[item._uniqueId] = item;
@@ -531,6 +555,10 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
break;
}
case Play.SHUFFLE: {
if (useRadioPlayerStore.getState().currentStreamUrl) {
useRadioPlayerStore.getState().actions.stop();
}
set((state) => {
newItems.forEach((item) => {
state.queue.songs[item._uniqueId] = item;
@@ -791,13 +819,20 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
nextSong = calculateNextSong(queueIndex, queue.items, repeat);
}
const { player1, player2 } = getDualPlayerSongs(
state.player.playerNum,
currentSong,
nextSong,
repeat,
);
return {
currentSong,
index: queueIndex, // Return the actual queue position for display
nextSong,
num: state.player.playerNum,
player1: state.player.playerNum === 1 ? currentSong : nextSong,
player2: state.player.playerNum === 2 ? currentSong : nextSong,
player1,
player2,
previousSong,
queueLength: state.queue.default.length,
status: state.player.status,
@@ -886,12 +921,21 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
? stateSnapshot.queue.shuffled.length
: queue.items.length;
const newPlayerNum = player.playerNum === 1 ? 2 : 1;
const { nextIndex: nextPlaybackIndex, shouldPause } = calculateNextIndex(
currentIndex,
playbackLength,
repeat,
);
const isRepeatOneSameTrack =
repeat === PlayerRepeat.ONE && nextPlaybackIndex === currentIndex;
// Dual web players alternate for gapless/crossfade between tracks. Repeat-one
// replays the same track — keep playerNum so Chromium stays bound to the same
// <audio> element and hardware media keys keep working.
const newPlayerNum = isRepeatOneSameTrack
? player.playerNum
: player.playerNum === 1
? 2
: 1;
const pauseOnNext = player.pauseOnNextSongEnd;
const newStatus =
shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING;
@@ -954,13 +998,20 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
currentQueueIndex > 0 ? queue.items[currentQueueIndex - 1] : undefined;
}
const { player1, player2 } = getDualPlayerSongs(
newPlayerNum,
currentSong,
nextSong,
repeat,
);
return {
currentSong,
index: currentQueueIndex,
nextSong,
num: newPlayerNum,
player1: newPlayerNum === 1 ? currentSong : nextSong,
player2: newPlayerNum === 2 ? currentSong : nextSong,
player1,
player2,
previousSong,
queueLength: queue.items.length,
status: newStatus,
@@ -1953,13 +2004,20 @@ export const usePlayerData = (): PlayerData => {
nextSong = calculateNextSong(queueIndex, queue.items, repeat);
}
const { player1, player2 } = getDualPlayerSongs(
state.player.playerNum,
currentSong,
nextSong,
repeat,
);
return {
currentSong,
index: queueIndex, // Return the actual queue position for display
nextSong,
num: state.player.playerNum,
player1: state.player.playerNum === 1 ? currentSong : nextSong,
player2: state.player.playerNum === 2 ? currentSong : nextSong,
player1,
player2,
previousSong,
queueLength: state.queue.default.length,
status: state.player.status,
@@ -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 },
}));
};
+37
View File
@@ -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);
+30 -6
View File
@@ -1,4 +1,5 @@
import { subscribeWithSelector } from 'zustand/middleware';
import { del, get, set } from 'idb-keyval';
import { persist, subscribeWithSelector } from 'zustand/middleware';
import { createWithEqualityFn } from 'zustand/traditional';
interface TimestampState {
@@ -6,13 +7,36 @@ interface TimestampState {
timestamp: number;
}
const timestampStorage = {
getItem: async (name: string) => {
const value = await get(name);
if (value === undefined) {
return null;
}
return { state: { timestamp: value }, version: 1 } as const;
},
removeItem: async (name: string) => {
await del(name);
},
setItem: async (name: string, value: { state: { timestamp: number }; version?: number }) => {
await set(name, value.state.timestamp);
},
};
export const useTimestampStoreBase = createWithEqualityFn<TimestampState>()(
subscribeWithSelector((set) => ({
setTimestamp: (timestamp: number) => {
set({ timestamp });
persist(
subscribeWithSelector((set) => ({
setTimestamp: (timestamp: number) => {
set({ timestamp });
},
timestamp: 0,
})),
{
name: 'player-timestamp',
storage: timestampStorage,
version: 1,
},
timestamp: 0,
})),
),
);
export const subscribePlayerProgress = (
+2 -1
View File
@@ -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;
}
+13 -2
View File
@@ -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 (
+1 -1
View File
@@ -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';
+1
View File
@@ -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',
}