mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8da511a8f | |||
| 434ee0ecf1 | |||
| 7d1c66071e | |||
| 1adfda1daa | |||
| e65c476a76 | |||
| 560349819f | |||
| ada5327493 | |||
| c67d606f89 | |||
| 662cc94ca8 | |||
| 1eacaf678c | |||
| 4a8fb64c66 |
@@ -5,6 +5,88 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.26.1] - 2026-04-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Background flickering in Hero, Album Detail and Playlist Detail**: Blurred hero backgrounds were flickering for up to 20 seconds on first visit. Root cause: `useCachedUrl` with the default `fallbackToFetch = true` immediately returned the raw server URL, causing the background to render twice — once with the HTTP URL (triggering a server fetch) and again when the IndexedDB blob was ready. Fixed by passing `fallbackToFetch = false` in all three locations so the background only renders once the blob is cached.
|
||||
|
||||
---
|
||||
|
||||
## [1.26.0] - 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
- **Favorite button in Player Bar** *(requested by [@halfkey](https://github.com/halfkey))*: A star icon button now sits next to the Last.fm heart in the player bar. Clicking it toggles the favorite/unfavorite state for the currently playing track with an optimistic UI update — no page reload needed. Uses the same `starredOverrides` mechanism as the album tracklist for instant feedback.
|
||||
- **Bulk Select for song lists**: Multi-select support in Album tracklist and Playlist detail. A checkbox fades in to the left of the track number on hover. Selecting one or more tracks activates the bulk action bar at the top with two actions: **Add to Playlist** (opens the playlist picker submenu) and **Remove from Playlist** (Playlist detail only). Shift-click selects a range; the header checkbox selects / deselects all. CSS uses `color-mix` for the selection highlight, compatible with all 60 themes.
|
||||
- **Song Info modal**: Right-clicking any song and choosing "Song Info" opens a metadata panel fetched live via `getSong`. Displays: title, artist, album, album artist, year, genre, duration, track number; format, bitrate, sample rate, bit depth, channels (Mono / Stereo), file size; file path; and Replay Gain values (track / album gain + peak) when present. Closes with Escape or a click on the backdrop.
|
||||
- **Recently Played section on Home page**: A new "Recently Played" album row appears on the Home page between the hero carousel and the Discover section, powered by the `getAlbumList('recent')` endpoint.
|
||||
- **"Now Playing" visibility toggle in Settings**: New opt-in toggle in Settings → Behavior ("Show activity in Now Playing"). When disabled (default), `reportNowPlaying` is not called, so no activity is reported to the Navidrome "Now Playing" feed. Useful for users who share a server.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Queue cover art not updating**: After a track change the queue panel cover art often stayed on the previous album or took a long time to update. Root cause: `useCachedUrl` and `CachedImage` were not resetting their resolved URL when the `cacheKey` changed. Fixed by resetting `resolved` to `''` before each async cache fetch and basing `CachedImage`'s `loaded` state on `useEffect([cacheKey])` instead of a render-time comparison.
|
||||
- **Fullscreen Player background flickering**: The blurred background briefly showed a blank frame when switching tracks because the new image div was added to the DOM before the blob URL was ready. Fixed in `FsBg` by preloading the image via `new Image()` before inserting the layer, and using `useCachedUrl(..., false)` for the crossfade background so the raw URL is never used as a fallback during transitions.
|
||||
- **Playlist card delete confirmation not visible**: The confirm state only changed the icon colour, which was barely noticeable over the red button. Replaced with a size expansion (24 px → 30 px), an inset white ring, and a pulsing `delete-confirm-pulse` animation that alternates between two shades of red.
|
||||
- **Gruvbox Light Soft — back button and badge**: The album detail back-arrow and album badge were invisible against the warm light background. Added explicit colour overrides for `.album-detail-back` and `.album-detail-badge` in the gruvbox-light-soft theme.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`buildStreamUrl` signature**: Removed the unused `suffix` parameter. Opus transcoding (`format=flac`) is now handled in `playerStore.playTrack` via `track.suffix` check, keeping the URL builder stateless.
|
||||
|
||||
---
|
||||
|
||||
## [1.25.1] - 2026-04-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Single-instance enforcement** *(reported by [@netherguy4](https://github.com/netherguy4))*: Re-launching the app while it was already running (including minimized to tray) would spawn a new independent process, leading to playback conflicts and state divergence. Integrated `tauri-plugin-single-instance` — subsequent launches are intercepted, the existing window is shown, unminimized, and focused instead.
|
||||
|
||||
---
|
||||
|
||||
## [1.25.0] - 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
- **System Tray** *(requested by [@jackbot](https://github.com/jackbot) and [@thecyanide](https://github.com/thecyanide))*: Functional tray icon with context menu — Play / Pause, Previous Track, Next Track, Show / Hide, and Exit Psysonic. Left-clicking the tray icon toggles window visibility. The tray icon is built via `TrayIconBuilder` in Rust so menu events are properly wired.
|
||||
- **Minimize to Tray** *(requested by [@jackbot](https://github.com/jackbot) and [@thecyanide](https://github.com/thecyanide))*: New toggle in Settings → Behavior. When enabled, closing the window hides it to the tray instead of exiting. The close button behaviour is intercepted in Rust (`prevent_close` + `window:close-requested` event) and the JS side decides hide vs. exit based on the user setting.
|
||||
- **Sidebar Customization** *(requested by [@lighthous3d](https://github.com/lighthous3d))*: New section in Settings → Appearance. All library and system nav items can be shown/hidden via a toggle switch and reordered by dragging the grip handle. Order and visibility are persisted across sessions (`psysonic_sidebar` in localStorage). Fixed items (Now Playing, Settings) are listed as non-configurable below the list.
|
||||
- **Playlist cover art**: Playlist cards on the Playlists overview page now display the server-generated cover image (Navidrome's `coverArt` field on the playlist object) via the IndexedDB image cache. Falls back to the ListMusic icon when no cover is available.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cover image flickering**: `buildCoverArtUrl()` generates a new random auth salt on every call, causing `useCachedUrl` to re-trigger on every render and produce a rapid re-fetch loop. Fixed by wrapping all `buildCoverArtUrl` / `coverArtCacheKey` calls in `useMemo` with the cover ID as dependency in `ArtistCardLocal`, `QueuePanel`, `FullscreenPlayer`, `Hero`, and `PlaylistDetail`.
|
||||
- **DnD text selection**: Dragging a grip handle in the Sidebar Customizer (and any future `useDragSource` consumer) would select all text on the page during the threshold detection phase. Fixed by calling `e.preventDefault()` in `useDragSource`'s `onMouseDown` handler before the drag threshold is reached.
|
||||
- **Sidebar Customization DnD on Linux**: The initial implementation used the HTML5 Drag & Drop API, which always shows a forbidden cursor on WebKitGTK and does not fire drop events reliably. Rewritten to use the existing psy-drag mouse-event system (`useDragSource` / `psy-drop` custom event), consistent with the Queue panel.
|
||||
|
||||
---
|
||||
|
||||
## [1.24.0] - 2026-03-31
|
||||
|
||||
### Added
|
||||
|
||||
- **Playlist Management** *(requested by [@adirav02](https://github.com/adirav02))*: Full playlist management feature:
|
||||
- **Playlists overview page** (`/playlists`): card grid showing all server playlists with cover collage, song count and duration. Inline "New Playlist" creation (Enter to confirm, Escape to cancel). Two-click delete confirmation directly on the card.
|
||||
- **Playlist detail page** (`/playlists/:id`): hero area with 2×2 album cover collage and blurred background (matching Album Detail style), full tracklist with drag-and-drop reordering, star ratings, codec labels, per-row delete button, and context menu.
|
||||
- **Song search**: "Add Songs" button opens an inline search panel with debounced server search, thumbnail, artist · album info, and a round add button (accent on hover). Duplicate songs already in the playlist are filtered from results.
|
||||
- **Suggestions**: "Suggested Songs" section below the tracklist loads similar songs via `getSimilarSongs2` based on the first artist in the playlist. Refresh button to load a new batch. Same tracklist layout as search results.
|
||||
- **Context menu — Add to Playlist**: "Add to Playlist" submenu available on all song/album/queue-item context menus. Playlists sorted by most recently used. "New Playlist" inline create at the top of the submenu. Submenu flips left when near the right viewport edge.
|
||||
- **Sidebar**: Playlists navigation entry added between Favorites and Statistics.
|
||||
- **Recently used playlist tracking**: `playlistStore` (persisted) tracks the last 50 used playlist IDs for the context menu sort order.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Resampling — first track played at native sample rate** *(reported by [@sorensiimSalling](https://github.com/sorensiimSalling))*: `current_sample_rate` was initialized to `44100`, causing every track to be resampled down to 44.1 kHz on playback start. Initializing to `0` disables resampling until the actual track rate is known.
|
||||
- **Resampling — no application-level resampling for any track**: `target_rate` in `audio_play` and `audio_chain_next` is now always `0`. Previously, tracks after the first were resampled to match the first track's sample rate. Rodio handles conversion to the output device rate internally; every track now plays at its native sample rate.
|
||||
- **Playlist hero background flickering**: The blurred hero background in Playlist Detail flickered on every render because `buildCoverArtUrl()` generates a new random salt on every call, causing `useCachedUrl` to re-trigger in a loop. The fetch URL and cache key are now `useMemo`-stabilised.
|
||||
- **Input focus double border**: The playlist name and song search inputs used a `search-input` class that had no CSS definition, falling back to browser defaults. The global `:focus-visible` rule then added a second outline on top of the browser's own focus ring. Switched to the `.input` class which sets `outline: none` and uses `border-color` + glow on focus.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Playlist search panel**: Redesigned with `surface-2` background, `radius-lg`, slide-down open animation, 36 px thumbnails, artist · album subtitle line, and a round icon add-button (accent colour on hover) replacing the generic `btn-surface` button.
|
||||
|
||||
---
|
||||
|
||||
## [1.23.0] - 2026-03-30
|
||||
|
||||
### Added
|
||||
|
||||
@@ -72,8 +72,8 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
|
||||
## ● Known Limitations
|
||||
|
||||
- **Linux (Wayland — drag & drop cursor)**: On some Wayland compositors the drag cursor may show no visual indicator during DnD. This is a WebKitGTK limitation unrelated to the drop operation itself, which always works correctly.
|
||||
- **FLAC without SEEKTABLE**: FLAC files that were encoded without a SEEKTABLE index cannot be seeked. The seek silently fails; playback continues from the current position.
|
||||
Some known bugs actively working on fixes
|
||||
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.23.0",
|
||||
"version": "1.26.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.23.0
|
||||
pkgver=1.26.1
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
Generated
+165
-12
@@ -85,6 +85,18 @@ dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener 5.4.1",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
@@ -198,6 +210,24 @@ dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io 2.6.0",
|
||||
"async-lock 3.4.2",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener 5.4.1",
|
||||
"futures-lite 2.6.1",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
@@ -1059,6 +1089,12 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -3361,7 +3397,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.21.0"
|
||||
version = "1.26.1"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"md5",
|
||||
@@ -3377,6 +3413,7 @@ dependencies = [
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
@@ -4279,8 +4316,8 @@ dependencies = [
|
||||
"pollster",
|
||||
"thiserror 1.0.69",
|
||||
"windows 0.44.0",
|
||||
"zbus",
|
||||
"zvariant",
|
||||
"zbus 3.15.2",
|
||||
"zvariant 3.15.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4827,6 +4864,21 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus 5.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-store"
|
||||
version = "2.4.2"
|
||||
@@ -6569,12 +6621,12 @@ version = "3.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-broadcast 0.5.1",
|
||||
"async-executor",
|
||||
"async-fs",
|
||||
"async-io 1.13.0",
|
||||
"async-lock 2.8.0",
|
||||
"async-process",
|
||||
"async-process 1.8.1",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
@@ -6599,9 +6651,44 @@ dependencies = [
|
||||
"uds_windows",
|
||||
"winapi",
|
||||
"xdg-home",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zbus_macros 3.15.2",
|
||||
"zbus_names 2.6.1",
|
||||
"zvariant 3.15.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc"
|
||||
dependencies = [
|
||||
"async-broadcast 0.7.2",
|
||||
"async-executor",
|
||||
"async-io 2.6.0",
|
||||
"async-lock 3.4.2",
|
||||
"async-process 2.5.0",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener 5.4.1",
|
||||
"futures-core",
|
||||
"futures-lite 2.6.1",
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix 1.1.4",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 0.7.15",
|
||||
"zbus_macros 5.14.0",
|
||||
"zbus_names 4.3.1",
|
||||
"zvariant 5.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6615,7 +6702,22 @@ dependencies = [
|
||||
"quote",
|
||||
"regex",
|
||||
"syn 1.0.109",
|
||||
"zvariant_utils",
|
||||
"zvariant_utils 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zbus_names 4.3.1",
|
||||
"zvariant 5.10.0",
|
||||
"zvariant_utils 3.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6626,7 +6728,18 @@ checksum = "437d738d3750bed6ca9b8d423ccc7a8eb284f6b1d6d4e225a0e4e6258d864c8d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant",
|
||||
"zvariant 3.15.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 0.7.15",
|
||||
"zvariant 5.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6726,7 +6839,21 @@ dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant_derive",
|
||||
"zvariant_derive 3.15.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 0.7.15",
|
||||
"zvariant_derive 5.10.0",
|
||||
"zvariant_utils 3.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6739,7 +6866,20 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"zvariant_utils",
|
||||
"zvariant_utils 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zvariant_utils 3.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6752,3 +6892,16 @@ dependencies = [
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.117",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.21.0"
|
||||
version = "1.26.1"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -22,6 +22,7 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-store = "2"
|
||||
|
||||
@@ -967,7 +967,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
||||
chained_info: Arc::new(Mutex::new(None)),
|
||||
samples_played: Arc::new(AtomicU64::new(0)),
|
||||
current_sample_rate: Arc::new(AtomicU32::new(44100)),
|
||||
current_sample_rate: Arc::new(AtomicU32::new(0)),
|
||||
current_channels: Arc::new(AtomicU32::new(2)),
|
||||
gapless_switch_at: Arc::new(AtomicU64::new(0)),
|
||||
};
|
||||
@@ -1148,7 +1148,9 @@ pub async fn audio_play(
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
// Reset sample counter for the new track.
|
||||
state.samples_played.store(0, Ordering::Relaxed);
|
||||
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
// Always 0 — no application-level resampling. Rodio handles conversion to
|
||||
// the output device rate internally; we let every track play at its native rate.
|
||||
let target_rate: u32 = 0;
|
||||
// Extract format hint from URL for better symphonia probing.
|
||||
let format_hint = url.rsplit('.').next()
|
||||
.and_then(|ext| ext.split('?').next())
|
||||
@@ -1343,7 +1345,8 @@ pub async fn audio_chain_preload(
|
||||
// Use a dedicated counter for the chained source — it will be swapped into
|
||||
// samples_played when the chained track becomes active.
|
||||
let chain_counter = Arc::new(AtomicU64::new(0));
|
||||
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
// Always 0 — no application-level resampling (same as audio_play).
|
||||
let target_rate: u32 = 0;
|
||||
let format_hint = url.rsplit('.').next()
|
||||
.and_then(|ext| ext.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
|
||||
+82
-1
@@ -6,7 +6,11 @@ mod audio;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Emitter, Manager,
|
||||
};
|
||||
|
||||
/// Tracks which user-configured shortcuts are currently registered (shortcut_str → action).
|
||||
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
|
||||
@@ -298,8 +302,76 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
let window = app.get_webview_window("main").expect("no main window");
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}))
|
||||
|
||||
.setup(|app| {
|
||||
// ── System tray ───────────────────────────────────────────────
|
||||
{
|
||||
let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?;
|
||||
let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?;
|
||||
let previous = MenuItemBuilder::with_id("previous", "Previous Track").build(app)?;
|
||||
let sep1 = PredefinedMenuItem::separator(app)?;
|
||||
let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?;
|
||||
let sep2 = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "Exit Psysonic").build(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&play_pause)
|
||||
.item(&previous)
|
||||
.item(&next)
|
||||
.item(&sep1)
|
||||
.item(&show_hide)
|
||||
.item(&sep2)
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
|
||||
TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.tooltip("Psysonic")
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"play_pause" => { let _ = app.emit("tray:play-pause", ()); }
|
||||
"next" => { let _ = app.emit("tray:next", ()); }
|
||||
"previous" => { let _ = app.emit("tray:previous", ()); }
|
||||
"show_hide" => {
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
if win.is_visible().unwrap_or(false) {
|
||||
let _ = win.hide();
|
||||
} else {
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
"quit" => { std::process::exit(0); }
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
// Left-click: toggle window visibility
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event {
|
||||
let app = tray.app_handle();
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
if win.is_visible().unwrap_or(false) {
|
||||
let _ = win.hide();
|
||||
} else {
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
}
|
||||
|
||||
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
|
||||
{
|
||||
use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig};
|
||||
@@ -392,6 +464,15 @@ pub fn run() {
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
if window.label() == "main" {
|
||||
// Let JS decide: minimize to tray or exit, based on user setting.
|
||||
api.prevent_close();
|
||||
let _ = window.emit("window:close-requested", ());
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
exit_app,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.23.0",
|
||||
"version": "1.26.1",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -29,12 +29,6 @@
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/icon.png",
|
||||
"iconAsTemplate": false,
|
||||
"title": "Psysonic",
|
||||
"tooltip": "Psysonic"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
+21
-8
@@ -26,9 +26,12 @@ import Help from './pages/Help';
|
||||
import RandomAlbums from './pages/RandomAlbums';
|
||||
import SearchResults from './pages/SearchResults';
|
||||
import AdvancedSearch from './pages/AdvancedSearch';
|
||||
import Playlists from './pages/Playlists';
|
||||
import PlaylistDetail from './pages/PlaylistDetail';
|
||||
import NowPlayingPage from './pages/NowPlaying';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
import SongInfoModal from './components/SongInfoModal';
|
||||
import DownloadFolderModal from './components/DownloadFolderModal';
|
||||
import { DragDropProvider } from './contexts/DragDropContext';
|
||||
import TooltipPortal from './components/TooltipPortal';
|
||||
@@ -251,6 +254,8 @@ function AppShell() {
|
||||
<Route path="/offline" element={<OfflineLibrary />} />
|
||||
<Route path="/genres" element={<Genres />} />
|
||||
<Route path="/genres/:name" element={<GenreDetail />} />
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
@@ -268,6 +273,7 @@ function AppShell() {
|
||||
<FullscreenPlayer onClose={toggleFullscreen} />
|
||||
)}
|
||||
<ContextMenu />
|
||||
<SongInfoModal />
|
||||
<DownloadFolderModal />
|
||||
<TooltipPortal />
|
||||
{changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />}
|
||||
@@ -275,7 +281,7 @@ function AppShell() {
|
||||
);
|
||||
}
|
||||
|
||||
// Media key event handler
|
||||
// Media key + tray event handler
|
||||
function TauriEventBridge() {
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
@@ -334,9 +340,12 @@ function TauriEventBridge() {
|
||||
|
||||
const setup = async () => {
|
||||
const handlers: Array<[string, () => void]> = [
|
||||
['media:play-pause', () => togglePlay()],
|
||||
['media:next', () => next()],
|
||||
['media:prev', () => previous()],
|
||||
['media:play-pause', () => togglePlay()],
|
||||
['media:next', () => next()],
|
||||
['media:prev', () => previous()],
|
||||
['tray:play-pause', () => togglePlay()],
|
||||
['tray:next', () => next()],
|
||||
['tray:previous', () => previous()],
|
||||
['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }],
|
||||
['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }],
|
||||
];
|
||||
@@ -368,10 +377,14 @@ function TauriEventBridge() {
|
||||
unlisten.push(u);
|
||||
}
|
||||
|
||||
// Close → exit app
|
||||
const win = getCurrentWindow();
|
||||
const u = await win.onCloseRequested(async () => {
|
||||
await invoke('exit_app');
|
||||
// window:close-requested is emitted by Rust (prevent_close + emit).
|
||||
// JS decides: minimize to tray or exit, based on user setting.
|
||||
const u = await listen('window:close-requested', async () => {
|
||||
if (useAuthStore.getState().minimizeToTray) {
|
||||
await getCurrentWindow().hide();
|
||||
} else {
|
||||
await invoke('exit_app');
|
||||
}
|
||||
});
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
|
||||
+6
-2
@@ -74,6 +74,7 @@ export interface SubsonicSong {
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
samplingRate?: number;
|
||||
bitDepth?: number;
|
||||
channelCount?: number;
|
||||
starred?: string;
|
||||
genre?: string;
|
||||
@@ -96,6 +97,7 @@ export interface SubsonicPlaylist {
|
||||
changed: string;
|
||||
owner?: string;
|
||||
public?: boolean;
|
||||
coverArt?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicNowPlaying extends SubsonicSong {
|
||||
@@ -335,6 +337,7 @@ export function buildStreamUrl(id: string): string {
|
||||
u: server?.username ?? '',
|
||||
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
|
||||
});
|
||||
|
||||
return `${baseUrl}/rest/stream.view?${p.toString()}`;
|
||||
}
|
||||
|
||||
@@ -384,12 +387,13 @@ export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlayl
|
||||
return { playlist, songs: entry ?? [] };
|
||||
}
|
||||
|
||||
export async function createPlaylist(name: string, songIds?: string[]): Promise<void> {
|
||||
export async function createPlaylist(name: string, songIds?: string[]): Promise<SubsonicPlaylist> {
|
||||
const params: Record<string, unknown> = { name };
|
||||
if (songIds && songIds.length > 0) {
|
||||
params.songId = songIds;
|
||||
}
|
||||
await api('createPlaylist.view', params);
|
||||
const data = await api<{ playlist: SubsonicPlaylist }>('createPlaylist.view', params);
|
||||
return data.playlist;
|
||||
}
|
||||
|
||||
export async function updatePlaylist(id: string, songIds: string[]): Promise<void> {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Play, Star } from 'lucide-react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Play, Star, ListPlus, X } from 'lucide-react';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { AddToPlaylistSubmenu } from './ContextMenu';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -71,9 +72,45 @@ export default function AlbumTrackList({
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
// ── Bulk select ───────────────────────────────────────────────────
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
|
||||
const [showPlPicker, setShowPlPicker] = useState(false);
|
||||
|
||||
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (shift && lastSelectedIdx !== null) {
|
||||
const from = Math.min(lastSelectedIdx, globalIdx);
|
||||
const to = Math.max(lastSelectedIdx, globalIdx);
|
||||
songs.slice(from, to + 1).forEach(s => next.add(s.id));
|
||||
} else {
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLastSelectedIdx(globalIdx);
|
||||
};
|
||||
|
||||
const allSelected = selectedIds.size === songs.length && songs.length > 0;
|
||||
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
// Close playlist picker on outside click
|
||||
useEffect(() => {
|
||||
if (!showPlPicker) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showPlPicker]);
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
@@ -83,12 +120,51 @@ export default function AlbumTrackList({
|
||||
discs.get(disc)!.push(song);
|
||||
});
|
||||
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
|
||||
const isMultiDisc = discNums.length > 1;
|
||||
const isMultiDisc = discNums.length > 1;
|
||||
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
|
||||
return (
|
||||
<div className="tracklist">
|
||||
|
||||
{/* ── Bulk action bar ── */}
|
||||
{inSelectMode && (
|
||||
<div className="bulk-action-bar">
|
||||
<span className="bulk-action-count">
|
||||
{t('common.bulkSelected', { count: selectedIds.size })}
|
||||
</span>
|
||||
<div className="bulk-pl-picker-wrap">
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => setShowPlPicker(v => !v)}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
{t('common.bulkAddToPlaylist')}
|
||||
</button>
|
||||
{showPlPicker && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...selectedIds]}
|
||||
onDone={() => { setShowPlPicker(false); setSelectedIds(new Set()); }}
|
||||
dropDown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
<X size={13} />
|
||||
{t('common.bulkClear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`tracklist-header${' tracklist-va'}`}>
|
||||
<div className="col-center">#</div>
|
||||
<div className="col-center">
|
||||
{inSelectMode
|
||||
? <span className={`bulk-check${allSelected ? ' checked' : ''}`} onClick={toggleAll} style={{ cursor: 'pointer' }} />
|
||||
: '#'}
|
||||
</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
|
||||
@@ -105,81 +181,99 @@ export default function AlbumTrackList({
|
||||
CD {discNum}
|
||||
</div>
|
||||
)}
|
||||
{discs.get(discNum)!.map((song, i) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
onMouseEnter={() => setHoveredSongId(song.id)}
|
||||
onMouseLeave={() => setHoveredSongId(null)}
|
||||
onDoubleClick={() => onPlaySong(song)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY);
|
||||
}
|
||||
};
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
{discs.get(discNum)!.map((song) => {
|
||||
const globalIdx = songs.indexOf(song);
|
||||
return (
|
||||
<div
|
||||
className="track-num"
|
||||
style={{
|
||||
cursor: hoveredSongId === song.id ? 'pointer' : 'default',
|
||||
color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined,
|
||||
key={song.id}
|
||||
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
onMouseEnter={() => setHoveredSongId(song.id)}
|
||||
onMouseLeave={() => setHoveredSongId(null)}
|
||||
onDoubleClick={() => onPlaySong(song)}
|
||||
onClick={e => {
|
||||
if (inSelectMode && !(e.target as HTMLElement).closest('button, input')) {
|
||||
toggleSelect(song.id, globalIdx, e.shiftKey);
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY);
|
||||
}
|
||||
};
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
onClick={() => onPlaySong(song)}
|
||||
>
|
||||
{hoveredSongId === song.id && currentTrack?.id !== song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: currentTrack?.id === song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: (song.track ?? i + 1)}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
<div className="track-star-cell">
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => onToggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
<div
|
||||
className="track-num"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (inSelectMode || hoveredSongId === song.id) {
|
||||
toggleSelect(song.id, globalIdx, e.shiftKey);
|
||||
} else {
|
||||
onPlaySong(song);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
<span
|
||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${(inSelectMode || hoveredSongId === song.id) ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
|
||||
/>
|
||||
<span style={{ color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{hoveredSongId === song.id && currentTrack?.id !== song.id && !inSelectMode
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: currentTrack?.id === song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: (song.track ?? globalIdx + 1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
<div className="track-star-cell">
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => onToggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
<StarRating
|
||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
/>
|
||||
<div className="track-duration">
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
<div className="track-meta">
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec">{codecLabel(song)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StarRating
|
||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
/>
|
||||
<div className="track-duration">
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
<div className="track-meta">
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec">{codecLabel(song)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
@@ -11,14 +11,18 @@ interface Props {
|
||||
export default function ArtistCardLocal({ artist }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
// buildCoverArtUrl generates a new crypto salt on every call — must be
|
||||
// memoized to prevent a new URL on every parent re-render causing refetch loops.
|
||||
const coverSrc = useMemo(() => buildCoverArtUrl(coverId, 300), [coverId]);
|
||||
const coverCacheKey = useMemo(() => coverArtCacheKey(coverId, 300), [coverId]);
|
||||
|
||||
return (
|
||||
<div className="artist-card" onClick={() => navigate(`/artist/${artist.id}`)}>
|
||||
<div className="artist-card-avatar">
|
||||
{coverId ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(coverId, 300)}
|
||||
cacheKey={coverArtCacheKey(coverId, 300)}
|
||||
src={coverSrc}
|
||||
cacheKey={coverCacheKey}
|
||||
alt={artist.name}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getCachedUrl } from '../utils/imageCache';
|
||||
|
||||
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
@@ -6,31 +6,38 @@ interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
cacheKey: string;
|
||||
}
|
||||
|
||||
export function useCachedUrl(fetchUrl: string, cacheKey: string): string {
|
||||
/**
|
||||
* @param fallbackToFetch If true (default), returns the raw fetchUrl while the
|
||||
* blob is still resolving — useful for <img> tags so the browser starts
|
||||
* loading immediately. Pass false for CSS background-image consumers that
|
||||
* should only see a stable blob URL (prevents a double crossfade).
|
||||
*/
|
||||
export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch = true): string {
|
||||
const [resolved, setResolved] = useState('');
|
||||
useEffect(() => {
|
||||
if (!fetchUrl) { setResolved(''); return; }
|
||||
let cancelled = false;
|
||||
setResolved('');
|
||||
getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); });
|
||||
return () => { cancelled = true; };
|
||||
}, [fetchUrl, cacheKey]);
|
||||
return resolved || fetchUrl;
|
||||
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
|
||||
}
|
||||
|
||||
export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) {
|
||||
const resolvedSrc = useCachedUrl(src, cacheKey);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const prevSrc = useRef('');
|
||||
|
||||
if (resolvedSrc !== prevSrc.current) {
|
||||
prevSrc.current = resolvedSrc;
|
||||
// Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
|
||||
// URL upgrades within the same image — avoids the end-of-load flash.
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
}
|
||||
}, [cacheKey]);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
style={{ ...style, opacity: loaded ? 1 : 0, transition: loaded ? 'opacity 0.15s ease' : 'none' }}
|
||||
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease' }}
|
||||
onLoad={e => { setLoaded(true); onLoad?.(e); }}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart } from 'lucide-react';
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart, ListMusic, Plus, Info } from 'lucide-react';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
@@ -19,9 +20,149 @@ function sanitizeFilename(name: string): string {
|
||||
.substring(0, 200) || 'download';
|
||||
}
|
||||
|
||||
// ── Add-to-Playlist submenu ───────────────────────────────────────
|
||||
export function AddToPlaylistSubmenu({ songIds, onDone, dropDown }: { songIds: string[]; onDone: () => void; dropDown?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const subRef = useRef<HTMLDivElement>(null);
|
||||
const newNameRef = useRef<HTMLInputElement>(null);
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [adding, setAdding] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [flipLeft, setFlipLeft] = useState(false);
|
||||
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
||||
const recentIds = usePlaylistStore((s) => s.recentIds);
|
||||
|
||||
useEffect(() => {
|
||||
getPlaylists().then((all) => {
|
||||
const sorted = [...all].sort((a, b) => {
|
||||
const ai = recentIds.indexOf(a.id);
|
||||
const bi = recentIds.indexOf(b.id);
|
||||
if (ai === -1 && bi === -1) return a.name.localeCompare(b.name);
|
||||
if (ai === -1) return 1;
|
||||
if (bi === -1) return -1;
|
||||
return ai - bi;
|
||||
});
|
||||
setPlaylists(sorted);
|
||||
}).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Flip submenu left if it would overflow the right edge of the viewport
|
||||
useLayoutEffect(() => {
|
||||
if (subRef.current) {
|
||||
const rect = subRef.current.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth - 8) setFlipLeft(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (creating) newNameRef.current?.focus();
|
||||
}, [creating]);
|
||||
|
||||
const handleAdd = async (pl: SubsonicPlaylist) => {
|
||||
setAdding(pl.id);
|
||||
try {
|
||||
const { songs } = await getPlaylist(pl.id);
|
||||
const existingIds = new Set(songs.map((s) => s.id));
|
||||
const newIds = songIds.filter((id) => !existingIds.has(id));
|
||||
if (newIds.length > 0) {
|
||||
await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...newIds]);
|
||||
}
|
||||
touchPlaylist(pl.id);
|
||||
} catch {}
|
||||
setAdding(null);
|
||||
onDone();
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = newName.trim() || t('playlists.unnamed');
|
||||
try {
|
||||
const pl = await createPlaylist(name, songIds);
|
||||
if (pl?.id) touchPlaylist(pl.id);
|
||||
} catch {}
|
||||
onDone();
|
||||
};
|
||||
|
||||
const subStyle: React.CSSProperties = dropDown
|
||||
? { top: 'calc(100% + 4px)', left: 0, right: 'auto' }
|
||||
: flipLeft
|
||||
? { right: 'calc(100% + 4px)', left: 'auto' }
|
||||
: { left: 'calc(100% + 4px)', right: 'auto' };
|
||||
|
||||
return (
|
||||
<div className="context-submenu" ref={subRef} style={subStyle}>
|
||||
{/* New Playlist row */}
|
||||
{!creating ? (
|
||||
<div
|
||||
className="context-menu-item context-submenu-new"
|
||||
onClick={e => { e.stopPropagation(); setCreating(true); }}
|
||||
>
|
||||
<Plus size={13} /> {t('playlists.newPlaylist')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
ref={newNameRef}
|
||||
className="context-submenu-input"
|
||||
placeholder={t('playlists.createName')}
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||
}}
|
||||
/>
|
||||
<button className="context-submenu-create-btn" onClick={handleCreate}>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="context-menu-divider" />
|
||||
|
||||
{playlists.length === 0 && (
|
||||
<div className="context-submenu-empty">{t('playlists.empty')}</div>
|
||||
)}
|
||||
{playlists.map((pl) => (
|
||||
<div
|
||||
key={pl.id}
|
||||
className="context-menu-item"
|
||||
onClick={() => handleAdd(pl)}
|
||||
style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }}
|
||||
>
|
||||
<ListMusic size={13} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Same as AddToPlaylistSubmenu but resolves album songs first
|
||||
function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone: () => void }) {
|
||||
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getAlbum(albumId).then((data) => {
|
||||
setResolvedIds(data.songs.map((s) => s.id));
|
||||
}).catch(() => setResolvedIds([]));
|
||||
}, [albumId]);
|
||||
|
||||
if (resolvedIds === null) {
|
||||
return (
|
||||
<div className="context-submenu" style={{ display: 'flex', justifyContent: 'center', padding: '0.75rem' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (resolvedIds.length === 0) return null;
|
||||
return <AddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
|
||||
}
|
||||
|
||||
export default function ContextMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride } = usePlayerStore();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo } = usePlayerStore();
|
||||
const auth = useAuthStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const navigate = useNavigate();
|
||||
@@ -29,10 +170,14 @@ export default function ContextMenu() {
|
||||
|
||||
// Adjusted coordinates to keep menu on screen
|
||||
const [coords, setCoords] = useState({ x: 0, y: 0 });
|
||||
const [playlistSubmenuOpen, setPlaylistSubmenuOpen] = useState(false);
|
||||
const [playlistSongIds, setPlaylistSongIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contextMenu.isOpen) {
|
||||
setCoords({ x: contextMenu.x, y: contextMenu.y });
|
||||
setPlaylistSubmenuOpen(false);
|
||||
setPlaylistSongIds([]);
|
||||
}
|
||||
}, [contextMenu.isOpen, contextMenu.x, contextMenu.y]);
|
||||
|
||||
@@ -125,6 +270,17 @@ export default function ContextMenu() {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
|
||||
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
|
||||
</div>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
onMouseEnter={() => { setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{type === 'album-song' && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(song.albumId);
|
||||
@@ -166,6 +322,10 @@ export default function ContextMenu() {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
@@ -192,6 +352,17 @@ export default function ContextMenu() {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||
<Download size={14} /> {t('contextMenu.download')}
|
||||
</div>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` ? 'active' : ''}`}
|
||||
onMouseEnter={() => { setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
|
||||
<AlbumToPlaylistSubmenu albumId={album.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
@@ -228,6 +399,17 @@ export default function ContextMenu() {
|
||||
})}>
|
||||
{t('contextMenu.removeFromQueue')}
|
||||
</div>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
onMouseEnter={() => { setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
{song.albumId && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
|
||||
@@ -260,6 +442,10 @@ export default function ContextMenu() {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
|
||||
import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward,
|
||||
ChevronDown, Repeat, Repeat1, Square, Music, MicVocal
|
||||
@@ -62,16 +62,25 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
let cancelled = false;
|
||||
const id = counterRef.current++;
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
const t1 = setTimeout(() => {
|
||||
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
|
||||
}, 20);
|
||||
const t2 = setTimeout(() => {
|
||||
setLayers(prev => prev.filter(l => l.id === id));
|
||||
}, 800);
|
||||
return () => { clearTimeout(t1); clearTimeout(t2); };
|
||||
}, [url]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// Preload the image before starting the crossfade — prevents a blank flash
|
||||
// between the old and new layer while the browser decodes the image.
|
||||
const img = new Image();
|
||||
img.onload = img.onerror = () => {
|
||||
if (cancelled) return;
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
|
||||
setTimeout(() => {
|
||||
if (!cancelled) setLayers(prev => prev.filter(l => l.id === id));
|
||||
}, 800);
|
||||
});
|
||||
};
|
||||
img.src = url;
|
||||
return () => { cancelled = true; };
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -154,10 +163,13 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
// useCachedUrl must be called unconditionally (hook rules)
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
|
||||
// buildCoverArtUrl generates a new salt on every call — must be memoized
|
||||
// to prevent useCachedUrl from re-fetching on every progress re-render (100 ms).
|
||||
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
|
||||
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
|
||||
// No fetchUrl fallback for the background — we only want stable blob URLs
|
||||
// to avoid a double crossfade (fetchUrl → blobUrl for the same image).
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
||||
|
||||
// Fetch artist image for background — fall back to cover art if unavailable
|
||||
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus } from 'lucide-react';
|
||||
import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic';
|
||||
@@ -92,19 +92,18 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
});
|
||||
}, [album?.id]);
|
||||
|
||||
// Resolve background URL via cache
|
||||
const bgRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '';
|
||||
const bgCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '';
|
||||
const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey);
|
||||
// buildCoverArtUrl generates a new salt on every call — must be memoized.
|
||||
const bgRawUrl = useMemo(() => album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '', [album?.coverArt]);
|
||||
const bgCacheKey = useMemo(() => album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '', [album?.coverArt]);
|
||||
const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey, false);
|
||||
|
||||
// Keep the last known good URL so HeroBg never receives '' during a cache-miss
|
||||
// transition (which would cause the background to flash empty before fading in).
|
||||
const stableBgUrl = useRef('');
|
||||
if (resolvedBgUrl) stableBgUrl.current = resolvedBgUrl;
|
||||
|
||||
// Resolve cover thumbnail via cache
|
||||
const coverRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
const coverCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
|
||||
const coverRawUrl = useMemo(() => album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '', [album?.coverArt]);
|
||||
const coverCacheKey = useMemo(() => album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : '', [album?.coverArt]);
|
||||
|
||||
if (!album) return <div className="hero-placeholder" />;
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, Star, MicVocal
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import Equalizer from './Equalizer';
|
||||
@@ -35,9 +35,26 @@ export default function PlayerBar() {
|
||||
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
||||
lastfmLoved, toggleLastfmLove,
|
||||
isQueueVisible, toggleQueue,
|
||||
starredOverrides, setStarredOverride,
|
||||
} = usePlayerStore();
|
||||
const { lastfmSessionKey } = useAuthStore();
|
||||
|
||||
const isStarred = currentTrack
|
||||
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
|
||||
: false;
|
||||
|
||||
const toggleStar = useCallback(async () => {
|
||||
if (!currentTrack) return;
|
||||
const next = !isStarred;
|
||||
setStarredOverride(currentTrack.id, next);
|
||||
try {
|
||||
if (next) await star(currentTrack.id, 'song');
|
||||
else await unstar(currentTrack.id, 'song');
|
||||
} catch {
|
||||
setStarredOverride(currentTrack.id, !next);
|
||||
}
|
||||
}, [currentTrack, isStarred, setStarredOverride]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
|
||||
@@ -92,6 +109,17 @@ export default function PlayerBar() {
|
||||
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
/>
|
||||
</div>
|
||||
{currentTrack && (
|
||||
<button
|
||||
className="player-btn player-btn-sm player-star-btn"
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
style={{ color: isStarred ? 'var(--ctp-yellow)' : 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<Star size={15} fill={isStarred ? 'var(--ctp-yellow)' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
{currentTrack && lastfmSessionKey && (
|
||||
<button
|
||||
className="player-btn player-btn-sm player-love-btn"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus } from 'lucide-react';
|
||||
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -159,6 +160,15 @@ export default function QueuePanel() {
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const currentTime = usePlayerStore(s => s.currentTime);
|
||||
const currentCoverFetchUrl = useMemo(
|
||||
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
|
||||
[currentTrack?.coverArt]
|
||||
);
|
||||
const currentCoverCacheKey = useMemo(
|
||||
() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '',
|
||||
[currentTrack?.coverArt]
|
||||
);
|
||||
const currentCoverSrc = useCachedUrl(currentCoverFetchUrl, currentCoverCacheKey);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
@@ -359,19 +369,27 @@ export default function QueuePanel() {
|
||||
|
||||
{currentTrack && (
|
||||
<div className="queue-current-track">
|
||||
{(currentTrack.genre || currentTrack.suffix || currentTrack.bitRate) && (
|
||||
{(currentTrack.genre || currentTrack.suffix || currentTrack.bitRate || currentTrack.samplingRate || currentTrack.bitDepth) && (
|
||||
<div className="queue-current-tech">
|
||||
{[
|
||||
currentTrack.genre,
|
||||
currentTrack.suffix?.toUpperCase(),
|
||||
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : undefined,
|
||||
(() => {
|
||||
const bd = currentTrack.bitDepth;
|
||||
const sr = currentTrack.samplingRate ? `${currentTrack.samplingRate / 1000} kHz` : '';
|
||||
if (bd && sr) return `${bd}/${sr}`;
|
||||
if (bd) return `${bd}-bit`;
|
||||
if (sr) return sr;
|
||||
return undefined;
|
||||
})(),
|
||||
].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="queue-current-track-body">
|
||||
<div className="queue-current-cover">
|
||||
{currentTrack.coverArt ? (
|
||||
<img src={buildCoverArtUrl(currentTrack.coverArt, 128)} alt="" loading="eager" />
|
||||
<img src={currentCoverSrc} alt="" loading="eager" />
|
||||
) : (
|
||||
<div className="fallback"><Music size={32} /></div>
|
||||
)}
|
||||
|
||||
+41
-32
@@ -2,27 +2,33 @@ import React, { useEffect, useState } from 'react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSidebarStore } from '../store/sidebarStore';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines, HardDriveDownload, Tags
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines, HardDriveDownload, Tags, ListMusic
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
|
||||
const navItems = [
|
||||
{ icon: Disc3, labelKey: 'sidebar.mainstage', to: '/' },
|
||||
{ icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases' },
|
||||
{ icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums' },
|
||||
{ icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-albums' },
|
||||
{ icon: Users, labelKey: 'sidebar.artists', to: '/artists' },
|
||||
{ icon: Tags, labelKey: 'sidebar.genres', to: '/genres' },
|
||||
{ icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix' },
|
||||
{ icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' },
|
||||
];
|
||||
// All configurable nav items — order and visibility controlled by sidebarStore.
|
||||
// Exported so Settings can render the same item metadata.
|
||||
export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey: string; to: string; section: 'library' | 'system' }> = {
|
||||
mainstage: { icon: Disc3, labelKey: 'sidebar.mainstage', to: '/', section: 'library' },
|
||||
newReleases: { icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases', section: 'library' },
|
||||
allAlbums: { icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums', section: 'library' },
|
||||
randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-albums', section: 'library' },
|
||||
artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
|
||||
genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
|
||||
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', section: 'library' },
|
||||
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
|
||||
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
||||
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
||||
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
||||
};
|
||||
|
||||
function isNewer(latest: string, current: string): boolean {
|
||||
const parse = (v: string) => v.replace(/^[^0-9]*/, '').split('.').map(Number);
|
||||
@@ -76,8 +82,17 @@ export default function Sidebar({
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
||||
|
||||
// Resolve ordered, visible items per section from store config
|
||||
const visibleLibrary = sidebarItems
|
||||
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library')
|
||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
||||
const visibleSystem = sidebarItems
|
||||
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'system')
|
||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -121,7 +136,7 @@ export default function Sidebar({
|
||||
|
||||
<nav className="sidebar-nav" aria-label="Hauptnavigation">
|
||||
{!isCollapsed && <span className="nav-section-label">{t('sidebar.library')}</span>}
|
||||
{navItems.map(item => (
|
||||
{visibleLibrary.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
@@ -135,7 +150,7 @@ export default function Sidebar({
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* Now Playing — special styled */}
|
||||
{/* Now Playing — fixed, always visible */}
|
||||
<NavLink
|
||||
to="/now-playing"
|
||||
className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`}
|
||||
@@ -162,26 +177,20 @@ export default function Sidebar({
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{!isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
|
||||
{visibleSystem.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
|
||||
{latestVersion && <UpdateToast isCollapsed={isCollapsed} latestVersion={latestVersion} />}
|
||||
<NavLink
|
||||
to="/statistics"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t('sidebar.statistics') : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<BarChart3 size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t('sidebar.statistics')}</span>}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/help"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t('sidebar.help') : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<HelpCircle size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t('sidebar.help')}</span>}
|
||||
</NavLink>
|
||||
{visibleSystem.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { getSong, SubsonicSong } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(s: number): string {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string | null {
|
||||
if (!bytes) return null;
|
||||
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(2)} MB`;
|
||||
return `${(bytes / 1_000).toFixed(0)} KB`;
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
if (value === null || value === undefined || value === '' || value === '—') return null;
|
||||
return (
|
||||
<tr>
|
||||
<td className="song-info-label">{label}</td>
|
||||
<td className="song-info-value">{value}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <tr><td colSpan={2} className="song-info-divider" /></tr>;
|
||||
}
|
||||
|
||||
export default function SongInfoModal() {
|
||||
const { t } = useTranslation();
|
||||
const { songInfoModal, closeSongInfo } = usePlayerStore();
|
||||
const [song, setSong] = useState<SubsonicSong | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!songInfoModal.isOpen || !songInfoModal.songId) {
|
||||
setSong(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getSong(songInfoModal.songId).then(s => {
|
||||
if (!cancelled) { setSong(s); setLoading(false); }
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [songInfoModal.isOpen, songInfoModal.songId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!songInfoModal.isOpen) return;
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') closeSongInfo(); };
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [songInfoModal.isOpen, closeSongInfo]);
|
||||
|
||||
if (!songInfoModal.isOpen) return null;
|
||||
|
||||
const channels = song?.channelCount === 1
|
||||
? t('songInfo.mono')
|
||||
: song?.channelCount === 2
|
||||
? t('songInfo.stereo')
|
||||
: song?.channelCount
|
||||
? `${song.channelCount} ch`
|
||||
: null;
|
||||
|
||||
const trackLabel = song?.discNumber && song.discNumber > 1
|
||||
? `${song.discNumber} – ${song.track}`
|
||||
: song?.track != null
|
||||
? String(song.track)
|
||||
: null;
|
||||
|
||||
const hasReplayGain = song?.replayGain &&
|
||||
(song.replayGain.trackGain !== undefined || song.replayGain.albumGain !== undefined);
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="song-info-backdrop" onClick={closeSongInfo} />
|
||||
<div className="song-info-modal" role="dialog" aria-modal="true" aria-label={t('songInfo.title')}>
|
||||
<div className="song-info-header">
|
||||
<span className="song-info-title">{t('songInfo.title')}</span>
|
||||
<button className="btn btn-ghost song-info-close" onClick={closeSongInfo} aria-label="Close">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="song-info-body">
|
||||
{loading && <div className="song-info-loading">{t('common.loading')}</div>}
|
||||
|
||||
{!loading && song && (
|
||||
<table className="song-info-table">
|
||||
<tbody>
|
||||
<Row label={t('songInfo.songTitle')} value={song.title} />
|
||||
<Row label={t('songInfo.artist')} value={song.artist} />
|
||||
<Row label={t('songInfo.album')} value={song.album} />
|
||||
{song.albumArtist && song.albumArtist !== song.artist && (
|
||||
<Row label={t('songInfo.albumArtist')} value={song.albumArtist} />
|
||||
)}
|
||||
<Row label={t('songInfo.year')} value={song.year} />
|
||||
<Row label={t('songInfo.genre')} value={song.genre} />
|
||||
<Row label={t('songInfo.duration')} value={formatDuration(song.duration)} />
|
||||
<Row label={t('songInfo.track')} value={trackLabel} />
|
||||
|
||||
<Divider />
|
||||
|
||||
<Row label={t('songInfo.format')} value={[song.suffix?.toUpperCase(), song.contentType].filter(Boolean).join(' · ') || null} />
|
||||
<Row label={t('songInfo.bitrate')} value={song.bitRate ? `${song.bitRate} kbps` : null} />
|
||||
<Row label={t('songInfo.sampleRate')} value={song.samplingRate ? `${(song.samplingRate / 1000).toFixed(1)} kHz` : null} />
|
||||
<Row label={t('songInfo.bitDepth')} value={song.bitDepth ? `${song.bitDepth} bit` : null} />
|
||||
<Row label={t('songInfo.channels')} value={channels} />
|
||||
<Row label={t('songInfo.fileSize')} value={formatSize(song.size)} />
|
||||
|
||||
{song.path && (
|
||||
<>
|
||||
<Divider />
|
||||
<Row label={t('songInfo.path')} value={<span className="song-info-path">{song.path}</span>} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasReplayGain && (
|
||||
<>
|
||||
<Divider />
|
||||
{song.replayGain!.trackGain !== undefined && (
|
||||
<Row label={t('songInfo.replayGainTrack')} value={`${song.replayGain!.trackGain >= 0 ? '+' : ''}${song.replayGain!.trackGain.toFixed(2)} dB`} />
|
||||
)}
|
||||
{song.replayGain!.albumGain !== undefined && (
|
||||
<Row label={t('songInfo.replayGainAlbum')} value={`${song.replayGain!.albumGain >= 0 ? '+' : ''}${song.replayGain!.albumGain.toFixed(2)} dB`} />
|
||||
)}
|
||||
{song.replayGain!.trackPeak !== undefined && (
|
||||
<Row label={t('songInfo.replayGainPeak')} value={song.replayGain!.trackPeak.toFixed(6)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -200,6 +200,9 @@ export function useDragSource(getPayload: () => DragPayload) {
|
||||
(e: React.MouseEvent) => {
|
||||
// Only left-click
|
||||
if (e.button !== 0) return;
|
||||
// Prevent the browser from starting a text-selection drag during the
|
||||
// threshold detection phase (mousedown → mousemove before startDrag).
|
||||
e.preventDefault();
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
|
||||
+330
@@ -25,11 +25,13 @@ const enTranslation = {
|
||||
downloadingTracks: 'Caching {{n}} tracks…',
|
||||
offlineLibrary: 'Offline Library',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
},
|
||||
home: {
|
||||
starred: 'Personal Favorites',
|
||||
recent: 'Recently Added',
|
||||
mostPlayed: 'Most Played',
|
||||
recentlyPlayed: 'Recently Played',
|
||||
discover: 'Discover',
|
||||
loadMore: 'Load More',
|
||||
discoverMore: 'Discover More',
|
||||
@@ -100,6 +102,8 @@ const enTranslation = {
|
||||
openAlbum: 'Open Album',
|
||||
goToArtist: 'Go to Artist',
|
||||
download: 'Download (ZIP)',
|
||||
addToPlaylist: 'Add to Playlist',
|
||||
songInfo: 'Song Info',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Back',
|
||||
@@ -298,6 +302,10 @@ const enTranslation = {
|
||||
filterSearchGenres: 'Search genres…',
|
||||
filterNoGenres: 'No genres match',
|
||||
filterClear: 'Clear',
|
||||
bulkSelected: '{{count}} selected',
|
||||
bulkAddToPlaylist: 'Add to Playlist',
|
||||
bulkRemoveFromPlaylist: 'Remove from Playlist',
|
||||
bulkClear: 'Clear selection',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
@@ -359,6 +367,10 @@ const enTranslation = {
|
||||
cacheClearWarning: 'This will also remove all offline albums from the library.',
|
||||
cacheClearConfirm: 'Clear Everything',
|
||||
cacheClearCancel: 'Cancel',
|
||||
minimizeToTray: 'Minimize to Tray',
|
||||
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
|
||||
nowPlayingEnabled: 'Show in Now Playing',
|
||||
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
|
||||
downloadsTitle: 'Download Folder',
|
||||
downloadsDefault: 'Default Downloads Folder',
|
||||
pickFolder: 'Select',
|
||||
@@ -388,6 +400,10 @@ const enTranslation = {
|
||||
tabPlayback: 'Playback',
|
||||
tabLibrary: 'Library',
|
||||
tabAppearance: 'Appearance',
|
||||
sidebarTitle: 'Sidebar',
|
||||
sidebarReset: 'Reset to default',
|
||||
sidebarDrag: 'Drag to reorder',
|
||||
sidebarFixed: 'Always visible',
|
||||
tabShortcuts: 'Shortcuts',
|
||||
tabServer: 'Server',
|
||||
tabAbout: 'About',
|
||||
@@ -591,6 +607,56 @@ const enTranslation = {
|
||||
lyrics: 'Lyrics',
|
||||
lyricsLoading: 'Loading lyrics…',
|
||||
lyricsNotFound: 'No lyrics found for this track',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Song Info',
|
||||
songTitle: 'Title',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
albumArtist: 'Album Artist',
|
||||
year: 'Year',
|
||||
genre: 'Genre',
|
||||
duration: 'Duration',
|
||||
track: 'Track',
|
||||
format: 'Format',
|
||||
bitrate: 'Bitrate',
|
||||
sampleRate: 'Sample Rate',
|
||||
bitDepth: 'Bit Depth',
|
||||
channels: 'Channels',
|
||||
fileSize: 'File Size',
|
||||
path: 'Path',
|
||||
replayGainTrack: 'RG Track Gain',
|
||||
replayGainAlbum: 'RG Album Gain',
|
||||
replayGainPeak: 'RG Track Peak',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stereo',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Playlists',
|
||||
newPlaylist: 'New Playlist',
|
||||
unnamed: 'Unnamed Playlist',
|
||||
createName: 'Playlist name…',
|
||||
create: 'Create',
|
||||
cancel: 'Cancel',
|
||||
empty: 'No playlists yet.',
|
||||
emptyPlaylist: 'This playlist is empty.',
|
||||
notFound: 'Playlist not found.',
|
||||
songs: '{{n}} songs',
|
||||
playAll: 'Play All',
|
||||
shuffle: 'Shuffle',
|
||||
addToQueue: 'Add to Queue',
|
||||
back: 'Back to Playlists',
|
||||
deletePlaylist: 'Delete',
|
||||
confirmDelete: 'Click again to confirm',
|
||||
removeSong: 'Remove from playlist',
|
||||
addSongs: 'Add Songs',
|
||||
searchPlaceholder: 'Search your library…',
|
||||
noResults: 'No results.',
|
||||
suggestions: 'Suggested Songs',
|
||||
noSuggestions: 'No suggestions available.',
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'New suggestions',
|
||||
addSong: 'Add to playlist',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -618,11 +684,13 @@ const deTranslation = {
|
||||
downloadingTracks: '{{n}} Tracks werden gecacht…',
|
||||
offlineLibrary: 'Offline-Bibliothek',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
},
|
||||
home: {
|
||||
starred: 'Persönliche Favoriten',
|
||||
recent: 'Zuletzt hinzugefügt',
|
||||
mostPlayed: 'Meistgehört',
|
||||
recentlyPlayed: 'Kürzlich gespielt',
|
||||
discover: 'Entdecken',
|
||||
loadMore: 'Mehr laden',
|
||||
discoverMore: 'Mehr entdecken',
|
||||
@@ -693,6 +761,8 @@ const deTranslation = {
|
||||
openAlbum: 'Album öffnen',
|
||||
goToArtist: 'Zum Künstler',
|
||||
download: 'Herunterladen (ZIP)',
|
||||
addToPlaylist: 'Zur Playlist hinzufügen',
|
||||
songInfo: 'Song-Infos',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Zurück',
|
||||
@@ -891,6 +961,10 @@ const deTranslation = {
|
||||
filterSearchGenres: 'Genres suchen…',
|
||||
filterNoGenres: 'Keine Genres gefunden',
|
||||
filterClear: 'Zurücksetzen',
|
||||
bulkSelected: '{{count}} ausgewählt',
|
||||
bulkAddToPlaylist: 'Zur Playlist hinzufügen',
|
||||
bulkRemoveFromPlaylist: 'Aus Playlist entfernen',
|
||||
bulkClear: 'Auswahl aufheben',
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
@@ -952,6 +1026,10 @@ const deTranslation = {
|
||||
cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.',
|
||||
cacheClearConfirm: 'Alles löschen',
|
||||
cacheClearCancel: 'Abbrechen',
|
||||
minimizeToTray: 'Im Tray minimieren',
|
||||
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
|
||||
nowPlayingEnabled: 'Im Livefenster anzeigen',
|
||||
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
|
||||
downloadsTitle: 'Download-Ordner',
|
||||
downloadsDefault: 'Standard-Downloads-Ordner',
|
||||
pickFolder: 'Auswählen',
|
||||
@@ -981,6 +1059,10 @@ const deTranslation = {
|
||||
tabPlayback: 'Wiedergabe',
|
||||
tabLibrary: 'Bibliothek',
|
||||
tabAppearance: 'Darstellung',
|
||||
sidebarTitle: 'Seitenleiste',
|
||||
sidebarReset: 'Zurücksetzen',
|
||||
sidebarDrag: 'Ziehen zum Umsortieren',
|
||||
sidebarFixed: 'Immer sichtbar',
|
||||
tabShortcuts: 'Tastenkürzel',
|
||||
tabServer: 'Server',
|
||||
shortcutsReset: 'Auf Standard zurücksetzen',
|
||||
@@ -1184,6 +1266,56 @@ const deTranslation = {
|
||||
lyrics: 'Lyrics',
|
||||
lyricsLoading: 'Lyrics werden geladen…',
|
||||
lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Song-Infos',
|
||||
songTitle: 'Titel',
|
||||
artist: 'Künstler',
|
||||
album: 'Album',
|
||||
albumArtist: 'Album-Künstler',
|
||||
year: 'Jahr',
|
||||
genre: 'Genre',
|
||||
duration: 'Länge',
|
||||
track: 'Track',
|
||||
format: 'Format',
|
||||
bitrate: 'Bitrate',
|
||||
sampleRate: 'Abtastrate',
|
||||
bitDepth: 'Bittiefe',
|
||||
channels: 'Kanäle',
|
||||
fileSize: 'Dateigröße',
|
||||
path: 'Speicherort',
|
||||
replayGainTrack: 'RG Track Gain',
|
||||
replayGainAlbum: 'RG Album Gain',
|
||||
replayGainPeak: 'RG Track Peak',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stereo',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Playlists',
|
||||
newPlaylist: 'Neue Playlist',
|
||||
unnamed: 'Unbenannte Playlist',
|
||||
createName: 'Playlist-Name…',
|
||||
create: 'Erstellen',
|
||||
cancel: 'Abbrechen',
|
||||
empty: 'Noch keine Playlists.',
|
||||
emptyPlaylist: 'Diese Playlist ist leer.',
|
||||
notFound: 'Playlist nicht gefunden.',
|
||||
songs: '{{n}} Songs',
|
||||
playAll: 'Alle abspielen',
|
||||
shuffle: 'Zufallswiedergabe',
|
||||
addToQueue: 'Zur Warteschlange',
|
||||
back: 'Zurück zu Playlists',
|
||||
deletePlaylist: 'Löschen',
|
||||
confirmDelete: 'Nochmals klicken zum Bestätigen',
|
||||
removeSong: 'Aus Playlist entfernen',
|
||||
addSongs: 'Songs hinzufügen',
|
||||
searchPlaceholder: 'Bibliothek durchsuchen…',
|
||||
noResults: 'Keine Ergebnisse.',
|
||||
suggestions: 'Vorgeschlagene Songs',
|
||||
noSuggestions: 'Keine Vorschläge verfügbar.',
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Neue Vorschläge',
|
||||
addSong: 'Zur Playlist hinzufügen',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1211,11 +1343,13 @@ const frTranslation = {
|
||||
downloadingTracks: '{{n}} pistes en cache…',
|
||||
offlineLibrary: 'Bibliothèque hors ligne',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
},
|
||||
home: {
|
||||
starred: 'Favoris personnels',
|
||||
recent: 'Ajoutés récemment',
|
||||
mostPlayed: 'Les plus écoutés',
|
||||
recentlyPlayed: 'Récemment écoutés',
|
||||
discover: 'Découvrir',
|
||||
loadMore: 'Charger plus',
|
||||
discoverMore: 'Découvrir plus',
|
||||
@@ -1286,6 +1420,8 @@ const frTranslation = {
|
||||
openAlbum: 'Ouvrir l\'album',
|
||||
goToArtist: 'Aller à l\'artiste',
|
||||
download: 'Télécharger (ZIP)',
|
||||
addToPlaylist: 'Ajouter à la playlist',
|
||||
songInfo: 'Infos du morceau',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Retour',
|
||||
@@ -1484,6 +1620,10 @@ const frTranslation = {
|
||||
filterSearchGenres: 'Rechercher des genres…',
|
||||
filterNoGenres: 'Aucun genre trouvé',
|
||||
filterClear: 'Effacer',
|
||||
bulkSelected: '{{count}} sélectionné(s)',
|
||||
bulkAddToPlaylist: 'Ajouter à la playlist',
|
||||
bulkRemoveFromPlaylist: 'Retirer de la playlist',
|
||||
bulkClear: 'Désélectionner',
|
||||
},
|
||||
settings: {
|
||||
title: 'Paramètres',
|
||||
@@ -1545,6 +1685,10 @@ const frTranslation = {
|
||||
cacheClearWarning: 'Cela supprimera aussi tous les albums hors ligne de la bibliothèque.',
|
||||
cacheClearConfirm: 'Tout supprimer',
|
||||
cacheClearCancel: 'Annuler',
|
||||
minimizeToTray: 'Réduire dans la barre système',
|
||||
minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.',
|
||||
nowPlayingEnabled: 'Afficher dans la fenêtre live',
|
||||
nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.',
|
||||
downloadsTitle: 'Dossier de téléchargement',
|
||||
downloadsDefault: 'Dossier de téléchargement par défaut',
|
||||
pickFolder: 'Sélectionner',
|
||||
@@ -1574,6 +1718,10 @@ const frTranslation = {
|
||||
tabPlayback: 'Lecture',
|
||||
tabLibrary: 'Bibliothèque',
|
||||
tabAppearance: 'Apparence',
|
||||
sidebarTitle: 'Barre latérale',
|
||||
sidebarReset: 'Réinitialiser',
|
||||
sidebarDrag: 'Glisser pour réorganiser',
|
||||
sidebarFixed: 'Toujours visible',
|
||||
tabShortcuts: 'Raccourcis',
|
||||
tabServer: 'Serveur',
|
||||
shortcutsReset: 'Réinitialiser',
|
||||
@@ -1777,6 +1925,56 @@ const frTranslation = {
|
||||
lyrics: 'Paroles',
|
||||
lyricsLoading: 'Chargement des paroles…',
|
||||
lyricsNotFound: 'Aucune parole trouvée pour ce titre',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Infos du morceau',
|
||||
songTitle: 'Titre',
|
||||
artist: 'Artiste',
|
||||
album: 'Album',
|
||||
albumArtist: 'Artiste de l\'album',
|
||||
year: 'Année',
|
||||
genre: 'Genre',
|
||||
duration: 'Durée',
|
||||
track: 'Piste',
|
||||
format: 'Format',
|
||||
bitrate: 'Débit',
|
||||
sampleRate: 'Fréquence d\'échantillonnage',
|
||||
bitDepth: 'Profondeur de bits',
|
||||
channels: 'Canaux',
|
||||
fileSize: 'Taille du fichier',
|
||||
path: 'Emplacement',
|
||||
replayGainTrack: 'RG Gain de piste',
|
||||
replayGainAlbum: 'RG Gain d\'album',
|
||||
replayGainPeak: 'RG Crête de piste',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stéréo',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Playlists',
|
||||
newPlaylist: 'Nouvelle playlist',
|
||||
unnamed: 'Playlist sans nom',
|
||||
createName: 'Nom de la playlist…',
|
||||
create: 'Créer',
|
||||
cancel: 'Annuler',
|
||||
empty: 'Aucune playlist pour l\'instant.',
|
||||
emptyPlaylist: 'Cette playlist est vide.',
|
||||
notFound: 'Playlist introuvable.',
|
||||
songs: '{{n}} titres',
|
||||
playAll: 'Tout lire',
|
||||
shuffle: 'Aléatoire',
|
||||
addToQueue: 'Ajouter à la file',
|
||||
back: 'Retour aux playlists',
|
||||
deletePlaylist: 'Supprimer',
|
||||
confirmDelete: 'Cliquer à nouveau pour confirmer',
|
||||
removeSong: 'Retirer de la playlist',
|
||||
addSongs: 'Ajouter des titres',
|
||||
searchPlaceholder: 'Rechercher dans la bibliothèque…',
|
||||
noResults: 'Aucun résultat.',
|
||||
suggestions: 'Titres suggérés',
|
||||
noSuggestions: 'Aucune suggestion disponible.',
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Nouvelles suggestions',
|
||||
addSong: 'Ajouter à la playlist',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1804,11 +2002,13 @@ const nlTranslation = {
|
||||
downloadingTracks: '{{n}} nummers worden gecached…',
|
||||
offlineLibrary: 'Offline bibliotheek',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
},
|
||||
home: {
|
||||
starred: 'Persoonlijke favorieten',
|
||||
recent: 'Recent toegevoegd',
|
||||
mostPlayed: 'Meest gespeeld',
|
||||
recentlyPlayed: 'Recent afgespeeld',
|
||||
discover: 'Ontdekken',
|
||||
loadMore: 'Meer laden',
|
||||
discoverMore: 'Meer ontdekken',
|
||||
@@ -1879,6 +2079,8 @@ const nlTranslation = {
|
||||
openAlbum: 'Album openen',
|
||||
goToArtist: 'Naar artiest',
|
||||
download: 'Downloaden (ZIP)',
|
||||
addToPlaylist: 'Toevoegen aan playlist',
|
||||
songInfo: 'Nummerinfo',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Terug',
|
||||
@@ -2077,6 +2279,10 @@ const nlTranslation = {
|
||||
filterSearchGenres: 'Genres zoeken…',
|
||||
filterNoGenres: 'Geen genres gevonden',
|
||||
filterClear: 'Wissen',
|
||||
bulkSelected: '{{count}} geselecteerd',
|
||||
bulkAddToPlaylist: 'Toevoegen aan afspeellijst',
|
||||
bulkRemoveFromPlaylist: 'Verwijderen uit afspeellijst',
|
||||
bulkClear: 'Selectie wissen',
|
||||
},
|
||||
settings: {
|
||||
title: 'Instellingen',
|
||||
@@ -2138,6 +2344,10 @@ const nlTranslation = {
|
||||
cacheClearWarning: 'Dit verwijdert ook alle offline albums uit de bibliotheek.',
|
||||
cacheClearConfirm: 'Alles wissen',
|
||||
cacheClearCancel: 'Annuleren',
|
||||
minimizeToTray: 'Minimaliseren naar systeemvak',
|
||||
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
|
||||
nowPlayingEnabled: 'Weergeven in live-venster',
|
||||
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
|
||||
downloadsTitle: 'Downloadmap',
|
||||
downloadsDefault: 'Standaard downloadmap',
|
||||
pickFolder: 'Selecteren',
|
||||
@@ -2167,6 +2377,10 @@ const nlTranslation = {
|
||||
tabPlayback: 'Afspelen',
|
||||
tabLibrary: 'Bibliotheek',
|
||||
tabAppearance: 'Weergave',
|
||||
sidebarTitle: 'Zijbalk',
|
||||
sidebarReset: 'Standaard herstellen',
|
||||
sidebarDrag: 'Slepen om te herordenen',
|
||||
sidebarFixed: 'Altijd zichtbaar',
|
||||
tabShortcuts: 'Sneltoetsen',
|
||||
tabServer: 'Server',
|
||||
shortcutsReset: 'Standaard herstellen',
|
||||
@@ -2370,6 +2584,56 @@ const nlTranslation = {
|
||||
lyrics: 'Songtekst',
|
||||
lyricsLoading: 'Songtekst laden…',
|
||||
lyricsNotFound: 'Geen songtekst gevonden voor dit nummer',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Nummerinfo',
|
||||
songTitle: 'Titel',
|
||||
artist: 'Artiest',
|
||||
album: 'Album',
|
||||
albumArtist: 'Albumartiest',
|
||||
year: 'Jaar',
|
||||
genre: 'Genre',
|
||||
duration: 'Duur',
|
||||
track: 'Track',
|
||||
format: 'Formaat',
|
||||
bitrate: 'Bitrate',
|
||||
sampleRate: 'Samplefrequentie',
|
||||
bitDepth: 'Bitdiepte',
|
||||
channels: 'Kanalen',
|
||||
fileSize: 'Bestandsgrootte',
|
||||
path: 'Locatie',
|
||||
replayGainTrack: 'RG Track Gain',
|
||||
replayGainAlbum: 'RG Album Gain',
|
||||
replayGainPeak: 'RG Track Peak',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stereo',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Playlists',
|
||||
newPlaylist: 'Nieuwe playlist',
|
||||
unnamed: 'Naamloze playlist',
|
||||
createName: 'Playlistnaam…',
|
||||
create: 'Aanmaken',
|
||||
cancel: 'Annuleren',
|
||||
empty: 'Nog geen playlists.',
|
||||
emptyPlaylist: 'Deze playlist is leeg.',
|
||||
notFound: 'Playlist niet gevonden.',
|
||||
songs: '{{n}} nummers',
|
||||
playAll: 'Alles afspelen',
|
||||
shuffle: 'Willekeurig',
|
||||
addToQueue: 'Aan wachtrij toevoegen',
|
||||
back: 'Terug naar playlists',
|
||||
deletePlaylist: 'Verwijderen',
|
||||
confirmDelete: 'Nogmaals klikken om te bevestigen',
|
||||
removeSong: 'Uit playlist verwijderen',
|
||||
addSongs: 'Nummers toevoegen',
|
||||
searchPlaceholder: 'Doorzoek bibliotheek…',
|
||||
noResults: 'Geen resultaten.',
|
||||
suggestions: 'Aanbevolen nummers',
|
||||
noSuggestions: 'Geen suggesties beschikbaar.',
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Nieuwe suggesties',
|
||||
addSong: 'Toevoegen aan playlist',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2397,11 +2661,13 @@ const zhTranslation = {
|
||||
downloadingTracks: '正在缓存 {{n}} 首歌曲…',
|
||||
offlineLibrary: '离线音乐库',
|
||||
genres: '流派',
|
||||
playlists: '播放列表',
|
||||
},
|
||||
home: {
|
||||
starred: '个人收藏',
|
||||
recent: '最近添加',
|
||||
mostPlayed: '最常播放',
|
||||
recentlyPlayed: '最近播放',
|
||||
discover: '发现',
|
||||
loadMore: '加载更多',
|
||||
discoverMore: '发现更多',
|
||||
@@ -2472,6 +2738,8 @@ const zhTranslation = {
|
||||
openAlbum: '打开专辑',
|
||||
goToArtist: '前往艺术家',
|
||||
download: '下载 (ZIP)',
|
||||
addToPlaylist: '添加到播放列表',
|
||||
songInfo: '歌曲信息',
|
||||
},
|
||||
albumDetail: {
|
||||
back: '返回',
|
||||
@@ -2670,6 +2938,10 @@ const zhTranslation = {
|
||||
filterSearchGenres: '搜索流派…',
|
||||
filterNoGenres: '未找到匹配流派',
|
||||
filterClear: '清除',
|
||||
bulkSelected: '已选 {{count}} 首',
|
||||
bulkAddToPlaylist: '添加到播放列表',
|
||||
bulkRemoveFromPlaylist: '从播放列表移除',
|
||||
bulkClear: '取消选择',
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
@@ -2731,6 +3003,10 @@ const zhTranslation = {
|
||||
cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。',
|
||||
cacheClearConfirm: '全部清除',
|
||||
cacheClearCancel: '取消',
|
||||
minimizeToTray: '最小化到托盘',
|
||||
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
|
||||
nowPlayingEnabled: '在实时窗口中显示',
|
||||
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
|
||||
downloadsTitle: '下载文件夹',
|
||||
downloadsDefault: '默认下载文件夹',
|
||||
pickFolder: '选择',
|
||||
@@ -2760,6 +3036,10 @@ const zhTranslation = {
|
||||
tabPlayback: '播放',
|
||||
tabLibrary: '音乐库',
|
||||
tabAppearance: '外观',
|
||||
sidebarTitle: '侧边栏',
|
||||
sidebarReset: '重置为默认',
|
||||
sidebarDrag: '拖动以重新排序',
|
||||
sidebarFixed: '始终显示',
|
||||
tabShortcuts: '快捷键',
|
||||
tabServer: '服务器',
|
||||
tabAbout: '关于',
|
||||
@@ -2963,6 +3243,56 @@ const zhTranslation = {
|
||||
lyrics: '歌词',
|
||||
lyricsLoading: '正在加载歌词…',
|
||||
lyricsNotFound: '未找到此曲目的歌词',
|
||||
},
|
||||
songInfo: {
|
||||
title: '歌曲信息',
|
||||
songTitle: '标题',
|
||||
artist: '艺术家',
|
||||
album: '专辑',
|
||||
albumArtist: '专辑艺术家',
|
||||
year: '年份',
|
||||
genre: '流派',
|
||||
duration: '时长',
|
||||
track: '曲目',
|
||||
format: '格式',
|
||||
bitrate: '比特率',
|
||||
sampleRate: '采样率',
|
||||
bitDepth: '位深度',
|
||||
channels: '声道',
|
||||
fileSize: '文件大小',
|
||||
path: '文件路径',
|
||||
replayGainTrack: 'RG 曲目增益',
|
||||
replayGainAlbum: 'RG 专辑增益',
|
||||
replayGainPeak: 'RG 曲目峰值',
|
||||
mono: '单声道',
|
||||
stereo: '立体声',
|
||||
},
|
||||
playlists: {
|
||||
title: '播放列表',
|
||||
newPlaylist: '新建播放列表',
|
||||
unnamed: '未命名播放列表',
|
||||
createName: '播放列表名称…',
|
||||
create: '创建',
|
||||
cancel: '取消',
|
||||
empty: '暂无播放列表。',
|
||||
emptyPlaylist: '此播放列表为空。',
|
||||
notFound: '未找到播放列表。',
|
||||
songs: '{{n}} 首歌曲',
|
||||
playAll: '全部播放',
|
||||
shuffle: '随机播放',
|
||||
addToQueue: '添加到队列',
|
||||
back: '返回播放列表',
|
||||
deletePlaylist: '删除',
|
||||
confirmDelete: '再次点击确认删除',
|
||||
removeSong: '从播放列表中移除',
|
||||
addSongs: '添加歌曲',
|
||||
searchPlaceholder: '搜索音乐库…',
|
||||
noResults: '无结果。',
|
||||
suggestions: '推荐歌曲',
|
||||
noSuggestions: '暂无推荐。',
|
||||
titleBadge: '播放列表',
|
||||
refreshSuggestions: '新建议',
|
||||
addSong: '添加到播放列表',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -112,9 +112,15 @@ const handleEnqueueAll = () => {
|
||||
};
|
||||
|
||||
const handlePlaySong = (song: SubsonicSong) => {
|
||||
const track = songToTrack(song);
|
||||
if (!track.genre && album?.album.genre) track.genre = album.album.genre;
|
||||
playTrack(track, [track]);
|
||||
if (!album) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.songs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
});
|
||||
const track = tracks.find(t => t.id === song.id) || songToTrack(song);
|
||||
playTrack(track, tracks);
|
||||
};
|
||||
|
||||
const handleRate = async (songId: string, rating: number) => {
|
||||
@@ -228,7 +234,7 @@ const handleEnqueueAll = () => {
|
||||
// Hooks must be called unconditionally — derive from nullable album state
|
||||
const coverUrl = album?.album.coverArt ? buildCoverArtUrl(album.album.coverArt, 400) : '';
|
||||
const coverKey = album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '';
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
||||
|
||||
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
|
||||
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
|
||||
|
||||
+13
-2
@@ -11,6 +11,7 @@ export default function Home() {
|
||||
const [random, setRandom] = useState<SubsonicAlbum[]>([]);
|
||||
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
|
||||
const [recentlyPlayed, setRecentlyPlayed] = useState<SubsonicAlbum[]>([]);
|
||||
const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -20,13 +21,15 @@ export default function Home() {
|
||||
getAlbumList('newest', 12).catch(() => []),
|
||||
getAlbumList('random', 20).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getAlbumList('recent', 12).catch(() => []),
|
||||
getArtists().catch(() => []),
|
||||
]).then(([s, n, r, f, artists]) => {
|
||||
]).then(([s, n, r, f, rp, artists]) => {
|
||||
setStarred(s);
|
||||
setRecent(n);
|
||||
setHeroAlbums(r.slice(0, 8));
|
||||
setRandom(r.slice(8));
|
||||
setMostPlayed(f);
|
||||
setRecentlyPlayed(rp);
|
||||
// Pick 16 random artists via Fisher-Yates shuffle
|
||||
const shuffled = [...artists];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
@@ -39,7 +42,7 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
const loadMore = async (
|
||||
type: 'starred' | 'newest' | 'random' | 'frequent',
|
||||
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
|
||||
currentList: SubsonicAlbum[],
|
||||
setter: React.Dispatch<React.SetStateAction<SubsonicAlbum[]>>
|
||||
) => {
|
||||
@@ -96,6 +99,14 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{recentlyPlayed.length > 0 && (
|
||||
<AlbumRow
|
||||
title={t('home.recentlyPlayed')}
|
||||
albums={recentlyPlayed}
|
||||
onLoadMore={() => loadMore('recent', recentlyPlayed, setRecentlyPlayed)}
|
||||
moreText={t('home.loadMore')}
|
||||
/>
|
||||
)}
|
||||
{starred.length > 0 && (
|
||||
<AlbumRow
|
||||
title={t('home.starred')}
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle } from 'lucide-react';
|
||||
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||||
import {
|
||||
getPlaylist, updatePlaylist, search, setRating, star, unstar,
|
||||
getRandomSongs, SubsonicPlaylist, SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
||||
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function totalDurationLabel(songs: SubsonicSong[]): string {
|
||||
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
function codecLabel(song: SubsonicSong): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
|
||||
const [hover, setHover] = React.useState(0);
|
||||
return (
|
||||
<div className="star-rating">
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => onChange(n)}
|
||||
>★</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlaylistDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying } = usePlayerStore();
|
||||
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
||||
const { startDrag, isDragging } = useDragDrop();
|
||||
|
||||
const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
|
||||
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
|
||||
// ── Bulk select ───────────────────────────────────────────────────
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
|
||||
const [showBulkPlPicker, setShowBulkPlPicker] = useState(false);
|
||||
|
||||
const toggleSelect = (id: string, idx: number, shift: boolean) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (shift && lastSelectedIdx !== null) {
|
||||
const from = Math.min(lastSelectedIdx, idx);
|
||||
const to = Math.max(lastSelectedIdx, idx);
|
||||
songs.slice(from, to + 1).forEach(s => next.add(s.id));
|
||||
} else {
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLastSelectedIdx(idx);
|
||||
};
|
||||
|
||||
const allSelected = selectedIds.size === songs.length && songs.length > 0;
|
||||
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
|
||||
|
||||
const bulkRemove = () => {
|
||||
const next = songs.filter(s => !selectedIds.has(s.id));
|
||||
setSongs(next);
|
||||
savePlaylist(next);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!showBulkPlPicker) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowBulkPlPicker(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showBulkPlPicker]);
|
||||
|
||||
// ── 2×2 cover quad (first 4 unique album covers) ─────────────
|
||||
const coverQuad = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const s of songs) {
|
||||
if (s.coverArt && !seen.has(s.coverArt)) {
|
||||
seen.add(s.coverArt);
|
||||
result.push(s.coverArt);
|
||||
if (result.length === 4) break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [songs]);
|
||||
|
||||
// Stable fetch URLs + cache keys for the 2×2 grid and blurred background.
|
||||
// buildCoverArtUrl generates a new crypto salt on every call, so these MUST
|
||||
// be memoized — otherwise every render produces new URLs, useCachedUrl
|
||||
// re-triggers, state updates, another render → infinite flicker loop.
|
||||
const coverQuadUrls = useMemo(() =>
|
||||
Array.from({ length: 4 }, (_, i) => {
|
||||
const coverId = coverQuad[i % Math.max(1, coverQuad.length)];
|
||||
if (!coverId) return null;
|
||||
return { src: buildCoverArtUrl(coverId, 200), cacheKey: coverArtCacheKey(coverId, 200) };
|
||||
}),
|
||||
[coverQuad]);
|
||||
|
||||
const bgFetchUrl = useMemo(() => buildCoverArtUrl(coverQuad[0] ?? '', 300), [coverQuad]);
|
||||
const bgCacheKey = useMemo(() => coverArtCacheKey(coverQuad[0] ?? '', 300), [coverQuad]);
|
||||
const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey, false);
|
||||
|
||||
// Song search
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SubsonicSong[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const searchDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Suggestions
|
||||
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
|
||||
|
||||
// DnD
|
||||
const tracklistRef = useRef<HTMLDivElement>(null);
|
||||
const [dropTargetIdx, setDropTargetIdx] = useState<{ idx: number; before: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
// ── Load ─────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
getPlaylist(id)
|
||||
.then(({ playlist, songs }) => {
|
||||
setPlaylist(playlist);
|
||||
setSongs(songs);
|
||||
const init: Record<string, number> = {};
|
||||
const starred = new Set<string>();
|
||||
songs.forEach(s => {
|
||||
if (s.userRating) init[s.id] = s.userRating;
|
||||
if (s.starred) starred.add(s.id);
|
||||
});
|
||||
setRatings(init);
|
||||
setStarredSongs(starred);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
// ── Suggestions ───────────────────────────────────────────────
|
||||
const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => {
|
||||
if (!currentSongs.length) return;
|
||||
// Count genres across playlist songs, pick the most common one
|
||||
const genreCounts: Record<string, number> = {};
|
||||
for (const s of currentSongs) {
|
||||
if (s.genre) genreCounts[s.genre] = (genreCounts[s.genre] ?? 0) + 1;
|
||||
}
|
||||
const genres = Object.entries(genreCounts).sort((a, b) => b[1] - a[1]);
|
||||
// Fall back to no genre filter if none of the songs have genre tags
|
||||
const genre = genres.length > 0 ? genres[Math.floor(Math.random() * Math.min(3, genres.length))][0] : undefined;
|
||||
const existingIds = new Set(currentSongs.map(s => s.id));
|
||||
setLoadingSuggestions(true);
|
||||
setSuggestions([]);
|
||||
try {
|
||||
const random = await getRandomSongs(25, genre);
|
||||
setSuggestions(random.filter(s => !existingIds.has(s.id)).slice(0, 10));
|
||||
} catch {}
|
||||
setLoadingSuggestions(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (songs.length > 0) loadSuggestions(songs);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playlist?.id]);
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[]) => {
|
||||
if (!id) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updatePlaylist(id, updatedSongs.map(s => s.id));
|
||||
if (id) touchPlaylist(id);
|
||||
} catch {}
|
||||
setSaving(false);
|
||||
}, [id, touchPlaylist]);
|
||||
|
||||
// ── Remove ────────────────────────────────────────────────────
|
||||
const removeSong = (idx: number) => {
|
||||
const next = songs.filter((_, i) => i !== idx);
|
||||
setSongs(next);
|
||||
savePlaylist(next);
|
||||
};
|
||||
|
||||
// ── Add ───────────────────────────────────────────────────────
|
||||
const addSong = (song: SubsonicSong) => {
|
||||
if (songs.some(s => s.id === song.id)) return;
|
||||
const next = [...songs, song];
|
||||
setSongs(next);
|
||||
savePlaylist(next);
|
||||
setSuggestions(prev => prev.filter(s => s.id !== song.id));
|
||||
setSearchResults(prev => prev.filter(s => s.id !== song.id));
|
||||
};
|
||||
|
||||
// ── Rating / Star ─────────────────────────────────────────────
|
||||
const handleRate = (songId: string, rating: number) => {
|
||||
setRatings(prev => ({ ...prev, [songId]: rating }));
|
||||
setRating(songId, rating).catch(() => {});
|
||||
};
|
||||
|
||||
const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const isStarred = starredSongs.has(song.id);
|
||||
setStarredSongs(prev => {
|
||||
const next = new Set(prev);
|
||||
isStarred ? next.delete(song.id) : next.add(song.id);
|
||||
return next;
|
||||
});
|
||||
(isStarred ? unstar(song.id, 'song') : star(song.id, 'song')).catch(() => {});
|
||||
};
|
||||
|
||||
// ── Search ────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!searchOpen || !searchQuery.trim()) { setSearchResults([]); return; }
|
||||
if (searchDebounce.current) clearTimeout(searchDebounce.current);
|
||||
searchDebounce.current = setTimeout(async () => {
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await search(searchQuery, { songCount: 20, artistCount: 0, albumCount: 0 });
|
||||
const existingIds = new Set(songs.map(s => s.id));
|
||||
setSearchResults(res.songs.filter(s => !existingIds.has(s.id)));
|
||||
} catch {}
|
||||
setSearching(false);
|
||||
}, 350);
|
||||
return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); };
|
||||
}, [searchQuery, searchOpen, songs]);
|
||||
|
||||
// ── psy-drop DnD reordering ───────────────────────────────────
|
||||
useEffect(() => {
|
||||
const container = tracklistRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const onPsyDrop = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.data) return;
|
||||
let parsed: any;
|
||||
try { parsed = JSON.parse(detail.data); } catch { return; }
|
||||
if (parsed.type !== 'playlist_reorder') return;
|
||||
|
||||
setDropTargetIdx(null);
|
||||
|
||||
const fromIdx: number = parsed.index;
|
||||
|
||||
// Determine drop index from the event target row
|
||||
const target = (e.target as HTMLElement).closest('[data-track-idx]');
|
||||
let toIdx = songs.length;
|
||||
if (target) {
|
||||
const targetIdx = parseInt(target.getAttribute('data-track-idx') ?? '', 10);
|
||||
const rect = target.getBoundingClientRect();
|
||||
const cursorY = (e as CustomEvent & { clientY?: number }).clientY ?? (rect.top + rect.height / 2);
|
||||
const before = cursorY < rect.top + rect.height / 2;
|
||||
toIdx = before ? targetIdx : targetIdx + 1;
|
||||
}
|
||||
|
||||
if (fromIdx === toIdx || fromIdx === toIdx - 1) return;
|
||||
|
||||
setSongs(prev => {
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(fromIdx, 1);
|
||||
const insertAt = toIdx > fromIdx ? toIdx - 1 : toIdx;
|
||||
next.splice(insertAt, 0, moved);
|
||||
savePlaylist(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
container.addEventListener('psy-drop', onPsyDrop);
|
||||
return () => container.removeEventListener('psy-drop', onPsyDrop);
|
||||
}, [songs, savePlaylist]);
|
||||
|
||||
// ── Row mousedown: threshold drag for reorder (from anywhere on the row) ──
|
||||
const handleRowMouseDown = (e: React.MouseEvent, idx: number) => {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest('button, input')) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
startDrag(
|
||||
{ data: JSON.stringify({ type: 'playlist_reorder', index: idx }), label: songs[idx]?.title ?? '' },
|
||||
me.clientX, me.clientY
|
||||
);
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
// ── Drag-over visual feedback ─────────────────────────────────
|
||||
const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const before = e.clientY < rect.top + rect.height / 2;
|
||||
setDropTargetIdx({ idx, before });
|
||||
};
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!playlist) {
|
||||
return <div className="content-body"><div className="empty-state">{t('playlists.notFound')}</div></div>;
|
||||
}
|
||||
|
||||
const existingIds = new Set(songs.map(s => s.id));
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
|
||||
{/* ── Hero ── */}
|
||||
<div className="album-detail-header">
|
||||
{resolvedBgUrl && (
|
||||
<div className="album-detail-bg" style={{ backgroundImage: `url(${resolvedBgUrl})` }} aria-hidden="true" />
|
||||
)}
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
|
||||
<div className="album-detail-content">
|
||||
<button className="btn btn-ghost album-detail-back" onClick={() => navigate('/playlists')}>
|
||||
<ChevronLeft size={16} /> {t('playlists.title')}
|
||||
</button>
|
||||
|
||||
<div className="album-detail-hero">
|
||||
{/* 2×2 cover grid */}
|
||||
<div className="playlist-cover-grid">
|
||||
{coverQuadUrls.map((entry, i) =>
|
||||
entry
|
||||
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
|
||||
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge album-detail-badge">{t('playlists.titleBadge')}</span>
|
||||
<h1 className="album-detail-title">{playlist.name}</h1>
|
||||
<div className="album-detail-info">
|
||||
<span>{t('playlists.songs', { n: songs.length })}</span>
|
||||
{songs.length > 0 && <span>· {totalDurationLabel(songs)}</span>}
|
||||
{saving && <Loader2 size={12} className="spin-slow" style={{ display: 'inline', marginLeft: 4 }} />}
|
||||
</div>
|
||||
<div className="album-detail-actions">
|
||||
<div className="album-detail-actions-primary">
|
||||
<button className="btn btn-primary" disabled={songs.length === 0} onClick={() => {
|
||||
if (!songs.length) return;
|
||||
touchPlaylist(id!);
|
||||
const tracks = songs.map(songToTrack);
|
||||
playTrack(tracks[0], tracks);
|
||||
}}>
|
||||
<Play size={16} fill="currentColor" /> {t('playlists.playAll')}
|
||||
</button>
|
||||
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
|
||||
if (!songs.length) return;
|
||||
touchPlaylist(id!);
|
||||
const tracks = songs.map(songToTrack);
|
||||
const shuffled = [...tracks];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
playTrack(shuffled[0], shuffled);
|
||||
}}>
|
||||
<Shuffle size={16} /> {t('playlists.shuffle', 'Shuffle')}
|
||||
</button>
|
||||
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
|
||||
if (!songs.length) return;
|
||||
touchPlaylist(id!);
|
||||
enqueue(songs.map(songToTrack));
|
||||
}}>
|
||||
<ListPlus size={16} /> {t('playlists.addToQueue')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
|
||||
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); }}
|
||||
>
|
||||
<Search size={16} /> {t('playlists.addSongs')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Song search panel ── */}
|
||||
{searchOpen && (
|
||||
<div className="playlist-search-panel">
|
||||
<div className="playlist-search-input-wrap">
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('playlists.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button className="live-search-clear" onClick={() => { setSearchQuery(''); setSearchResults([]); }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{searching && <div style={{ textAlign: 'center', padding: '0.75rem' }}><div className="spinner" /></div>}
|
||||
{!searching && searchQuery && searchResults.length === 0 && (
|
||||
<div className="empty-state" style={{ padding: '0.5rem 0' }}>{t('playlists.noResults')}</div>
|
||||
)}
|
||||
{searchResults.map(song => (
|
||||
<div key={song.id} className="playlist-search-row">
|
||||
<CachedImage src={buildCoverArtUrl(song.coverArt ?? '', 40)} cacheKey={coverArtCacheKey(song.coverArt ?? '', 40)} alt="" className="playlist-search-thumb" />
|
||||
<div className="playlist-search-info">
|
||||
<span className="playlist-search-title">{song.title}</span>
|
||||
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
|
||||
</div>
|
||||
<span className="playlist-search-duration">{formatDuration(song.duration ?? 0)}</span>
|
||||
<button
|
||||
className="playlist-search-add-btn"
|
||||
data-tooltip={t('playlists.addSong')}
|
||||
onClick={() => addSong(song)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tracklist ── */}
|
||||
<div className="tracklist" ref={tracklistRef}>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="bulk-action-bar">
|
||||
<span className="bulk-action-count">
|
||||
{t('common.bulkSelected', { count: selectedIds.size })}
|
||||
</span>
|
||||
<div className="bulk-pl-picker-wrap">
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => setShowBulkPlPicker(v => !v)}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
{t('common.bulkAddToPlaylist')}
|
||||
</button>
|
||||
{showBulkPlPicker && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...selectedIds]}
|
||||
onDone={() => { setShowBulkPlPicker(false); setSelectedIds(new Set()); }}
|
||||
dropDown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
onClick={bulkRemove}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('common.bulkRemoveFromPlaylist')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
<X size={13} />
|
||||
{t('common.bulkClear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="tracklist-header tracklist-va tracklist-playlist">
|
||||
<div className="col-center" style={{ cursor: songs.length > 0 ? 'pointer' : undefined }} onClick={songs.length > 0 ? toggleAll : undefined}>
|
||||
{selectedIds.size > 0
|
||||
? <span className={`bulk-check${allSelected ? ' checked' : ''}`} />
|
||||
: '#'}
|
||||
</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackRating')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||
<div>{t('albumDetail.trackFormat')}</div>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
{songs.length === 0 && (
|
||||
<div className="empty-state" style={{ padding: '2rem 0' }}>{t('playlists.emptyPlaylist')}</div>
|
||||
)}
|
||||
|
||||
{songs.map((song, idx) => (
|
||||
<React.Fragment key={song.id + idx}>
|
||||
{/* Drop indicator above row */}
|
||||
{isDragging && dropTargetIdx?.idx === idx && dropTargetIdx.before && (
|
||||
<div className="playlist-drop-indicator" />
|
||||
)}
|
||||
|
||||
<div
|
||||
data-track-idx={idx}
|
||||
className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
onMouseEnter={e => { setHoveredSongId(song.id); handleRowMouseEnter(idx, e); }}
|
||||
onMouseLeave={() => setHoveredSongId(null)}
|
||||
onMouseDown={e => handleRowMouseDown(e, idx)}
|
||||
onDoubleClick={() => {
|
||||
const tracks = songs.map(songToTrack);
|
||||
playTrack(tracks[idx], tracks);
|
||||
}}
|
||||
onClick={e => {
|
||||
if (selectedIds.size > 0 && !(e.target as HTMLElement).closest('button, input')) {
|
||||
toggleSelect(song.id, idx, e.shiftKey);
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
>
|
||||
{/* # — checkbox in select mode, grip/play on hover otherwise */}
|
||||
{(() => {
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
return (
|
||||
<div
|
||||
className="track-num"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (inSelectMode || hoveredSongId === song.id) {
|
||||
toggleSelect(song.id, idx, e.shiftKey);
|
||||
} else {
|
||||
const tracks = songs.map(songToTrack);
|
||||
playTrack(tracks[idx], tracks);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${(inSelectMode || hoveredSongId === song.id) ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }}
|
||||
/>
|
||||
<span style={{ color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : 'var(--text-muted)' }}>
|
||||
{hoveredSongId === song.id && currentTrack?.id !== song.id && !inSelectMode
|
||||
? <GripVertical size={13} />
|
||||
: currentTrack?.id === song.id && isPlaying
|
||||
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
: currentTrack?.id === song.id
|
||||
? <Play size={13} fill="currentColor" />
|
||||
: idx + 1}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Title */}
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
|
||||
{/* Artist */}
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
|
||||
{/* Favorite */}
|
||||
<div className="track-star-cell">
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => handleToggleStar(song, e)}
|
||||
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
<StarRating value={ratings[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />
|
||||
|
||||
{/* Duration */}
|
||||
<div className="track-duration">{formatDuration(song.duration ?? 0)}</div>
|
||||
|
||||
{/* Format */}
|
||||
<div className="track-meta">
|
||||
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="playlist-row-delete-cell">
|
||||
<button
|
||||
className="playlist-row-delete-btn"
|
||||
onClick={e => { e.stopPropagation(); removeSong(idx); }}
|
||||
data-tooltip={t('playlists.removeSong')}
|
||||
data-tooltip-pos="left"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drop indicator below last row or between rows */}
|
||||
{isDragging && dropTargetIdx?.idx === idx && !dropTargetIdx.before && (
|
||||
<div className="playlist-drop-indicator" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* Total row */}
|
||||
{songs.length > 0 && (
|
||||
<div className="tracklist-total tracklist-va tracklist-playlist">
|
||||
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
|
||||
<span className="tracklist-total-value">{formatDuration(songs.reduce((a, s) => a + (s.duration ?? 0), 0))}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Suggestions ── */}
|
||||
<div className="playlist-suggestions tracklist">
|
||||
<div className="playlist-suggestions-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('playlists.suggestions')}</h2>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={() => loadSuggestions(songs)}
|
||||
disabled={loadingSuggestions || songs.length === 0}
|
||||
data-tooltip={t('playlists.refreshSuggestions')}
|
||||
>
|
||||
<RefreshCw size={14} className={loadingSuggestions ? 'spin-slow' : ''} />
|
||||
{t('playlists.refreshSuggestions')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!loadingSuggestions && suggestions.filter(s => !existingIds.has(s.id)).length === 0 && (
|
||||
<div className="empty-state" style={{ padding: '1.5rem 0', fontSize: '0.85rem' }}>{t('playlists.noSuggestions')}</div>
|
||||
)}
|
||||
|
||||
{suggestions.filter(s => !existingIds.has(s.id)).length > 0 && (
|
||||
<>
|
||||
<div className="tracklist-header tracklist-va tracklist-playlist" style={{ marginTop: 'var(--space-3)' }}>
|
||||
<div className="col-center">#</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
<div />
|
||||
<div />
|
||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||
<div>{t('albumDetail.trackFormat')}</div>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
{suggestions.filter(s => !existingIds.has(s.id)).map((song, idx) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row track-row-va tracklist-playlist${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
onMouseEnter={() => setHoveredSuggestionId(song.id)}
|
||||
onMouseLeave={() => setHoveredSuggestionId(null)}
|
||||
onDoubleClick={() => addSong(song)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
>
|
||||
<div className="track-num" style={{ color: 'var(--text-muted)' }}>
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
{/* no star/rating for suggestions */}
|
||||
<div />
|
||||
<div />
|
||||
<div className="track-duration">{formatDuration(song.duration ?? 0)}</div>
|
||||
<div className="track-meta">
|
||||
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
|
||||
</div>
|
||||
<div className="playlist-row-delete-cell">
|
||||
<button
|
||||
className="playlist-row-delete-btn"
|
||||
style={{ color: hoveredSuggestionId === song.id ? 'var(--accent)' : undefined }}
|
||||
onClick={e => { e.stopPropagation(); addSong(song); }}
|
||||
data-tooltip={t('playlists.addSong')}
|
||||
data-tooltip-pos="left"
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ListMusic, Play, Plus, Trash2, X } from 'lucide-react';
|
||||
import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
export default function Playlists() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { playTrack } = usePlayerStore();
|
||||
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
||||
const removeId = usePlaylistStore((s) => s.removeId);
|
||||
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getPlaylists()
|
||||
.then(setPlaylists)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (creating) nameInputRef.current?.focus();
|
||||
}, [creating]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = newName.trim() || t('playlists.unnamed');
|
||||
try {
|
||||
await createPlaylist(name);
|
||||
const updated = await getPlaylists();
|
||||
setPlaylists(updated);
|
||||
} catch {}
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
};
|
||||
|
||||
const handlePlay = async (e: React.MouseEvent, pl: SubsonicPlaylist) => {
|
||||
e.stopPropagation();
|
||||
if (playingId === pl.id) return;
|
||||
setPlayingId(pl.id);
|
||||
try {
|
||||
const data = await getPlaylist(pl.id);
|
||||
const tracks = data.songs.map(songToTrack);
|
||||
if (tracks.length > 0) {
|
||||
touchPlaylist(pl.id);
|
||||
playTrack(tracks[0], tracks);
|
||||
}
|
||||
} catch {}
|
||||
setPlayingId(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, pl: SubsonicPlaylist) => {
|
||||
e.stopPropagation();
|
||||
if (deleteConfirmId !== pl.id) {
|
||||
setDeleteConfirmId(pl.id);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deletePlaylist(pl.id);
|
||||
removeId(pl.id);
|
||||
setPlaylists((prev) => prev.filter((p) => p.id !== pl.id));
|
||||
} catch {}
|
||||
setDeleteConfirmId(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
|
||||
{/* ── Header row ── */}
|
||||
<div className="playlists-header">
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('playlists.title')}</h1>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
{creating ? (
|
||||
<>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
className="input"
|
||||
style={{ width: 220 }}
|
||||
placeholder={t('playlists.createName')}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleCreate}>
|
||||
{t('playlists.create')}
|
||||
</button>
|
||||
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
|
||||
{t('playlists.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||
<Plus size={15} /> {t('playlists.newPlaylist')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Grid ── */}
|
||||
{playlists.length === 0 ? (
|
||||
<div className="empty-state">{t('playlists.empty')}</div>
|
||||
) : (
|
||||
<div className="album-grid-wrap">
|
||||
{playlists.map((pl) => (
|
||||
<div
|
||||
key={pl.id}
|
||||
className="album-card"
|
||||
onClick={() => navigate(`/playlists/${pl.id}`)}
|
||||
onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }}
|
||||
>
|
||||
{/* Cover area — server collage or fallback icon */}
|
||||
<div className="album-card-cover">
|
||||
{pl.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(pl.coverArt, 256)}
|
||||
cacheKey={coverArtCacheKey(pl.coverArt, 256)}
|
||||
alt={pl.name}
|
||||
className="album-card-cover-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder playlist-card-icon">
|
||||
<ListMusic size={48} strokeWidth={1.2} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play overlay — same pattern as AlbumCard */}
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={(e) => handlePlay(e, pl)}
|
||||
disabled={playingId === pl.id}
|
||||
>
|
||||
{playingId === pl.id
|
||||
? <span className="spinner" style={{ width: 14, height: 14 }} />
|
||||
: <Play size={15} fill="currentColor" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Delete button — top-right corner */}
|
||||
<button
|
||||
className={`playlist-card-delete ${deleteConfirmId === pl.id ? 'playlist-card-delete--confirm' : ''}`}
|
||||
onClick={(e) => handleDelete(e, pl)}
|
||||
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('playlists.deletePlaylist')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
{deleteConfirmId === pl.id ? <Trash2 size={12} /> : <X size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="album-card-info">
|
||||
<div className="album-card-title">{pl.name}</div>
|
||||
<div className="album-card-artist">
|
||||
{t('playlists.songs', { n: pl.songCount })}
|
||||
{pl.duration > 0 && <> · {formatDuration(pl.duration)}</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+195
-30
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import changelogRaw from '../../CHANGELOG.md?raw';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
||||
GripVertical, PanelLeft, RotateCcw
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
@@ -19,6 +20,9 @@ import { useThemeStore } from '../store/themeStore';
|
||||
import { useFontStore, FontId } from '../store/fontStore';
|
||||
import { useKeybindingsStore, KeyAction, formatKeyCode, DEFAULT_BINDINGS } from '../store/keybindingsStore';
|
||||
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
|
||||
import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore';
|
||||
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
|
||||
import { ALL_NAV_ITEMS } from '../components/Sidebar';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -603,43 +607,34 @@ export default function Settings() {
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{([
|
||||
{ id: 'inter', label: 'Inter', sample: 'The quick brown fox' },
|
||||
{ id: 'outfit', label: 'Outfit', sample: 'The quick brown fox' },
|
||||
{ id: 'dm-sans', label: 'DM Sans', sample: 'The quick brown fox' },
|
||||
{ id: 'nunito', label: 'Nunito', sample: 'The quick brown fox' },
|
||||
{ id: 'rubik', label: 'Rubik', sample: 'The quick brown fox' },
|
||||
{ id: 'space-grotesk', label: 'Space Grotesk', sample: 'The quick brown fox' },
|
||||
{ id: 'figtree', label: 'Figtree', sample: 'The quick brown fox' },
|
||||
{ id: 'manrope', label: 'Manrope', sample: 'The quick brown fox' },
|
||||
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', sample: 'The quick brown fox' },
|
||||
{ id: 'lexend', label: 'Lexend', sample: 'The quick brown fox' },
|
||||
] as { id: FontId; label: string; sample: string }[]).map(f => (
|
||||
{(
|
||||
[
|
||||
{ id: 'inter', label: 'Inter', stack: "'Inter', sans-serif" },
|
||||
{ id: 'outfit', label: 'Outfit', stack: "'Outfit', sans-serif" },
|
||||
{ id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans', sans-serif" },
|
||||
{ id: 'nunito', label: 'Nunito', stack: "'Nunito', sans-serif" },
|
||||
{ id: 'rubik', label: 'Rubik', stack: "'Rubik', sans-serif" },
|
||||
{ id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk', sans-serif" },
|
||||
{ id: 'figtree', label: 'Figtree', stack: "'Figtree', sans-serif" },
|
||||
{ id: 'manrope', label: 'Manrope', stack: "'Manrope', sans-serif" },
|
||||
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans', sans-serif" },
|
||||
{ id: 'lexend', label: 'Lexend', stack: "'Lexend', sans-serif" },
|
||||
] as { id: FontId; label: string; stack: string }[]
|
||||
).map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={`btn ${fontStore.font === f.id ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ justifyContent: 'flex-start', fontFamily: f.stack }}
|
||||
onClick={() => fontStore.setFont(f.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '12px',
|
||||
background: fontStore.font === f.id ? 'var(--accent-dim)' : 'transparent',
|
||||
border: `1px solid ${fontStore.font === f.id ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
borderRadius: 'var(--radius-md)', padding: '10px 14px',
|
||||
cursor: 'pointer', textAlign: 'left', width: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 14, height: 14, borderRadius: '50%', flexShrink: 0,
|
||||
border: `2px solid ${fontStore.font === f.id ? 'var(--accent)' : 'var(--border)'}`,
|
||||
background: fontStore.font === f.id ? 'var(--accent)' : 'transparent',
|
||||
}} />
|
||||
<div>
|
||||
<div style={{ fontFamily: `'${f.label}', sans-serif`, fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{f.label}</div>
|
||||
<div style={{ fontFamily: `'${f.label}', sans-serif`, fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{f.sample}</div>
|
||||
</div>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SidebarCustomizer />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -972,6 +967,28 @@ export default function Settings() {
|
||||
<h2>{t('settings.behavior')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.minimizeToTrayDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.minimizeToTray')}>
|
||||
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingEnabledDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.nowPlayingEnabled')}>
|
||||
<input type="checkbox" checked={auth.nowPlayingEnabled} onChange={e => auth.setNowPlayingEnabled(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
|
||||
@@ -1132,6 +1149,154 @@ function renderInline(text: string): React.ReactNode[] {
|
||||
});
|
||||
}
|
||||
|
||||
function SidebarGripHandle({ idx, section, label }: { idx: number; section: 'library' | 'system'; label: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { onMouseDown } = useDragSource(() => ({
|
||||
data: JSON.stringify({ type: 'sidebar_reorder', index: idx, section }),
|
||||
label,
|
||||
}));
|
||||
return (
|
||||
<span
|
||||
className="sidebar-customizer-grip"
|
||||
data-tooltip={t('settings.sidebarDrag')}
|
||||
data-tooltip-pos="right"
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null;
|
||||
|
||||
function SidebarCustomizer() {
|
||||
const { t } = useTranslation();
|
||||
const { items, setItems, toggleItem, reset } = useSidebarStore();
|
||||
const { isDragging: isPsyDragging } = useDragDrop();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget>(null);
|
||||
const dropTargetRef = useRef<DropTarget>(null);
|
||||
const itemsRef = useRef(items);
|
||||
itemsRef.current = items;
|
||||
|
||||
const libraryItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'library');
|
||||
const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
|
||||
}, [isPsyDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const onPsyDrop = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.data) return;
|
||||
let parsed: { type?: string; index?: number; section?: string };
|
||||
try { parsed = JSON.parse(detail.data); } catch { return; }
|
||||
if (parsed.type !== 'sidebar_reorder' || parsed.index == null || !parsed.section) return;
|
||||
|
||||
const fromIdx = parsed.index;
|
||||
const fromSection = parsed.section as 'library' | 'system';
|
||||
const target = dropTargetRef.current;
|
||||
dropTargetRef.current = null; setDropTarget(null);
|
||||
if (!target || target.section !== fromSection) return;
|
||||
|
||||
const sectionItems = fromSection === 'library' ? [...libraryItems] : [...systemItems];
|
||||
const insertBefore = target.before ? target.idx : target.idx + 1;
|
||||
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
|
||||
|
||||
const [moved] = sectionItems.splice(fromIdx, 1);
|
||||
sectionItems.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
|
||||
|
||||
// Merge reordered section back into flat items array
|
||||
const all = [...itemsRef.current];
|
||||
const positions = all.map((cfg, i) => ({ cfg, i }))
|
||||
.filter(({ cfg }) => ALL_NAV_ITEMS[cfg.id]?.section === fromSection)
|
||||
.map(({ i }) => i);
|
||||
positions.forEach((pos, i) => { all[pos] = sectionItems[i]; });
|
||||
setItems(all);
|
||||
};
|
||||
el.addEventListener('psy-drop', onPsyDrop);
|
||||
return () => el.removeEventListener('psy-drop', onPsyDrop);
|
||||
}, [libraryItems, systemItems, setItems]);
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isPsyDragging || !containerRef.current) return;
|
||||
const rows = containerRef.current.querySelectorAll<HTMLElement>('[data-sidebar-idx]');
|
||||
let target: DropTarget = null;
|
||||
for (const row of rows) {
|
||||
const rect = row.getBoundingClientRect();
|
||||
const idx = Number(row.dataset.sidebarIdx);
|
||||
const section = row.dataset.sidebarSection as 'library' | 'system';
|
||||
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true, section }; break; }
|
||||
target = { idx, before: false, section };
|
||||
}
|
||||
dropTargetRef.current = target;
|
||||
setDropTarget(target);
|
||||
};
|
||||
|
||||
const renderRow = (cfg: SidebarItemConfig, localIdx: number, section: 'library' | 'system') => {
|
||||
const meta = ALL_NAV_ITEMS[cfg.id];
|
||||
if (!meta) return null;
|
||||
const Icon = meta.icon;
|
||||
const isBefore = isPsyDragging && dropTarget?.section === section && dropTarget.idx === localIdx && dropTarget.before;
|
||||
const isAfter = isPsyDragging && dropTarget?.section === section && dropTarget.idx === localIdx && !dropTarget.before;
|
||||
return (
|
||||
<div
|
||||
key={cfg.id}
|
||||
data-sidebar-idx={localIdx}
|
||||
data-sidebar-section={section}
|
||||
className="sidebar-customizer-row"
|
||||
style={{
|
||||
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
|
||||
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
|
||||
}}
|
||||
>
|
||||
<SidebarGripHandle idx={localIdx} section={section} label={t(meta.labelKey)} />
|
||||
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 14 }}>{t(meta.labelKey)}</span>
|
||||
<label className="toggle-switch" aria-label={t(meta.labelKey)}>
|
||||
<input type="checkbox" checked={cfg.visible} onChange={() => toggleItem(cfg.id)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<PanelLeft size={18} />
|
||||
<h2>{t('settings.sidebarTitle')}</h2>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted)' }}
|
||||
onClick={reset}
|
||||
data-tooltip={t('settings.sidebarReset')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{/* Library block */}
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
<div className="sidebar-customizer-block-label">{t('sidebar.library')}</div>
|
||||
{libraryItems.map((cfg, i) => renderRow(cfg, i, 'library'))}
|
||||
</div>
|
||||
{/* System block */}
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
<div className="sidebar-customizer-block-label">{t('sidebar.system')}</div>
|
||||
{systemItems.map((cfg, i) => renderRow(cfg, i, 'system'))}
|
||||
<div className="sidebar-customizer-fixed-hint">
|
||||
<span>{t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangelogSection() {
|
||||
const { t } = useTranslation();
|
||||
const showChangelogOnUpdate = useAuthStore(s => s.showChangelogOnUpdate);
|
||||
|
||||
@@ -33,6 +33,8 @@ interface AuthState {
|
||||
crossfadeEnabled: boolean;
|
||||
crossfadeSecs: number;
|
||||
gaplessEnabled: boolean;
|
||||
minimizeToTray: boolean;
|
||||
nowPlayingEnabled: boolean;
|
||||
showChangelogOnUpdate: boolean;
|
||||
lastSeenChangelogVersion: string;
|
||||
|
||||
@@ -64,6 +66,8 @@ interface AuthState {
|
||||
setCrossfadeEnabled: (v: boolean) => void;
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
setGaplessEnabled: (v: boolean) => void;
|
||||
setMinimizeToTray: (v: boolean) => void;
|
||||
setNowPlayingEnabled: (v: boolean) => void;
|
||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||
setLastSeenChangelogVersion: (v: string) => void;
|
||||
logout: () => void;
|
||||
@@ -96,6 +100,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
crossfadeEnabled: false,
|
||||
crossfadeSecs: 3,
|
||||
gaplessEnabled: false,
|
||||
minimizeToTray: false,
|
||||
nowPlayingEnabled: false,
|
||||
showChangelogOnUpdate: true,
|
||||
lastSeenChangelogVersion: '',
|
||||
isLoggedIn: false,
|
||||
@@ -160,6 +166,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
||||
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
|
||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface Track {
|
||||
replayGainPeak?: number;
|
||||
starred?: string;
|
||||
genre?: string;
|
||||
samplingRate?: number;
|
||||
bitDepth?: number;
|
||||
}
|
||||
|
||||
export function songToTrack(song: SubsonicSong): Track {
|
||||
@@ -48,6 +50,8 @@ export function songToTrack(song: SubsonicSong): Track {
|
||||
replayGainPeak: song.replayGain?.trackPeak,
|
||||
starred: song.starred,
|
||||
genre: song.genre,
|
||||
samplingRate: song.samplingRate,
|
||||
bitDepth: song.bitDepth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,6 +116,10 @@ interface PlayerState {
|
||||
};
|
||||
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', queueIndex?: number) => void;
|
||||
closeContextMenu: () => void;
|
||||
|
||||
songInfoModal: { isOpen: boolean; songId: string | null };
|
||||
openSongInfo: (songId: string) => void;
|
||||
closeSongInfo: () => void;
|
||||
}
|
||||
|
||||
// ─── Module-level playback primitives ─────────────────────────────────────────
|
||||
@@ -294,8 +302,8 @@ function handleAudioTrackSwitched(duration: number) {
|
||||
});
|
||||
|
||||
// Report Now Playing to Navidrome + Last.fm
|
||||
reportNowPlaying(nextTrack.id);
|
||||
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||
const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||
if (nowPlayingEnabled) reportNowPlaying(nextTrack.id);
|
||||
if (lastfmSessionKey) {
|
||||
if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey);
|
||||
lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => {
|
||||
@@ -439,6 +447,10 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
contextMenu: { ...state.contextMenu, isOpen: false },
|
||||
})),
|
||||
|
||||
songInfoModal: { isOpen: false, songId: null },
|
||||
openSongInfo: (songId) => set({ songInfoModal: { isOpen: true, songId } }),
|
||||
closeSongInfo: () => set({ songInfoModal: { isOpen: false, songId: null } }),
|
||||
|
||||
toggleQueue: () => set(state => ({ isQueueVisible: !state.isQueueVisible })),
|
||||
setQueueVisible: (v: boolean) => set({ isQueueVisible: v }),
|
||||
toggleFullscreen: () => set(state => ({ isFullscreenOpen: !state.isFullscreenOpen })),
|
||||
@@ -558,8 +570,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
});
|
||||
|
||||
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
||||
reportNowPlaying(track.id);
|
||||
const { scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
||||
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
||||
if (npEnabled) reportNowPlaying(track.id);
|
||||
if (lfmKey) {
|
||||
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
|
||||
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface PlaylistStore {
|
||||
recentIds: string[];
|
||||
touchPlaylist: (id: string) => void;
|
||||
removeId: (id: string) => void;
|
||||
}
|
||||
|
||||
export const usePlaylistStore = create<PlaylistStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
recentIds: [],
|
||||
touchPlaylist: (id) =>
|
||||
set((s) => ({
|
||||
recentIds: [id, ...s.recentIds.filter((x) => x !== id)].slice(0, 50),
|
||||
})),
|
||||
removeId: (id) =>
|
||||
set((s) => ({ recentIds: s.recentIds.filter((x) => x !== id) })),
|
||||
}),
|
||||
{ name: 'psysonic_playlists_recent' }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface SidebarItemConfig {
|
||||
id: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
// All configurable nav items in their default order.
|
||||
// Fixed items (nowPlaying, settings, offline) are not listed here.
|
||||
export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
|
||||
{ id: 'mainstage', visible: true },
|
||||
{ id: 'newReleases', visible: true },
|
||||
{ id: 'allAlbums', visible: true },
|
||||
{ id: 'randomAlbums', visible: true },
|
||||
{ id: 'artists', visible: true },
|
||||
{ id: 'genres', visible: true },
|
||||
{ id: 'randomMix', visible: true },
|
||||
{ id: 'favorites', visible: true },
|
||||
{ id: 'playlists', visible: true },
|
||||
{ id: 'statistics', visible: true },
|
||||
{ id: 'help', visible: true },
|
||||
];
|
||||
|
||||
interface SidebarStore {
|
||||
items: SidebarItemConfig[];
|
||||
setItems: (items: SidebarItemConfig[]) => void;
|
||||
toggleItem: (id: string) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useSidebarStore = create<SidebarStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
items: DEFAULT_SIDEBAR_ITEMS,
|
||||
|
||||
setItems: (items) => set({ items }),
|
||||
|
||||
toggleItem: (id) => set((s) => ({
|
||||
items: s.items.map(item => item.id === id ? { ...item, visible: !item.visible } : item),
|
||||
})),
|
||||
|
||||
reset: () => set({ items: DEFAULT_SIDEBAR_ITEMS }),
|
||||
}),
|
||||
{ name: 'psysonic_sidebar' }
|
||||
)
|
||||
);
|
||||
+576
-6
@@ -154,6 +154,20 @@
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
/* ─ Page header row (title + actions) ─ */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.page-header .page-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ─ Section titles ─ */
|
||||
.section-title,
|
||||
.page-title {
|
||||
@@ -830,6 +844,30 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Playlist 2×2 cover grid ── */
|
||||
.playlist-cover-grid {
|
||||
flex-shrink: 0;
|
||||
width: clamp(120px, 15vw, 200px);
|
||||
height: clamp(120px, 15vw, 200px);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.playlist-cover-cell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.playlist-cover-cell--empty {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.album-detail-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -1038,6 +1076,87 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ─ Song Info Modal ─ */
|
||||
.song-info-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9990;
|
||||
}
|
||||
.song-info-modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 9991;
|
||||
width: 460px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: 80vh;
|
||||
background: var(--bg-card, var(--bg-secondary));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.song-info-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.song-info-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.song-info-close {
|
||||
padding: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.song-info-body {
|
||||
overflow-y: auto;
|
||||
padding: 12px 4px 16px;
|
||||
}
|
||||
.song-info-loading {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
padding: 24px;
|
||||
}
|
||||
.song-info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.song-info-label {
|
||||
color: var(--text-muted);
|
||||
padding: 4px 16px 4px 16px;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
width: 140px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.song-info-value {
|
||||
color: var(--text-primary);
|
||||
padding: 4px 16px 4px 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.song-info-divider {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.song-info-path {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ─ Tracklist ─ */
|
||||
.tracklist {
|
||||
padding: 0 var(--space-6) var(--space-6);
|
||||
@@ -1049,7 +1168,7 @@
|
||||
|
||||
.tracklist-header {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
@@ -1063,12 +1182,12 @@
|
||||
}
|
||||
|
||||
.tracklist-header.tracklist-va {
|
||||
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
|
||||
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
|
||||
}
|
||||
|
||||
.track-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
@@ -1079,12 +1198,12 @@
|
||||
}
|
||||
|
||||
.track-row.track-row-va {
|
||||
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
|
||||
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
|
||||
}
|
||||
|
||||
.tracklist-total {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px;
|
||||
gap: var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
@@ -1092,7 +1211,7 @@
|
||||
}
|
||||
|
||||
.tracklist-total.tracklist-va {
|
||||
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
|
||||
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
|
||||
}
|
||||
|
||||
.tracklist-total-label {
|
||||
@@ -1122,6 +1241,60 @@
|
||||
grid-column: 6 / 7;
|
||||
}
|
||||
|
||||
/* ── Playlist tracklist variant — adds delete column ── */
|
||||
.tracklist-header.tracklist-playlist,
|
||||
.track-row.tracklist-playlist,
|
||||
.tracklist-total.tracklist-playlist {
|
||||
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px 36px;
|
||||
}
|
||||
|
||||
.tracklist-total.tracklist-playlist .tracklist-total-label {
|
||||
grid-column: 1 / 6;
|
||||
}
|
||||
|
||||
.tracklist-total.tracklist-playlist .tracklist-total-value {
|
||||
grid-column: 6 / 7;
|
||||
}
|
||||
|
||||
/* Delete button in playlist row */
|
||||
.playlist-row-delete-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.playlist-row-delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.track-row:hover .playlist-row-delete-btn,
|
||||
.track-row.context-active .playlist-row-delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.playlist-row-delete-btn:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* In suggestion rows the + btn is always visible at muted opacity */
|
||||
.playlist-suggestions .playlist-row-delete-btn {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.playlist-suggestions .track-row:hover .playlist-row-delete-btn {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.track-row:hover,
|
||||
.track-row.context-active {
|
||||
background: var(--bg-hover);
|
||||
@@ -1155,10 +1328,22 @@
|
||||
}
|
||||
|
||||
.track-num {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.track-num .bulk-check {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.track-num .bulk-check.bulk-check-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Equalizer bars — shown for the currently playing track */
|
||||
.eq-bars {
|
||||
@@ -1855,6 +2040,66 @@
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.settings-section-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
|
||||
/* ─ Sidebar Customizer ─ */
|
||||
.sidebar-customizer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 8px var(--space-4);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition-fast), opacity var(--transition-fast);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sidebar-customizer-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.sidebar-customizer-row.drag-over {
|
||||
background: var(--accent-dim);
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.sidebar-customizer-row.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sidebar-customizer-grip {
|
||||
cursor: grab;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-customizer-grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sidebar-customizer-block-label {
|
||||
padding: 6px var(--space-4) 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-customizer-fixed-hint {
|
||||
padding: 8px var(--space-4) 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.settings-about {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -4227,3 +4472,328 @@
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
/* ─ Playlists overview header ─ */
|
||||
.playlists-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
/* ─ Playlist Card (Grid Overview) ─ */
|
||||
.playlist-card-icon {
|
||||
color: var(--accent);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Delete button — top-right corner of card cover, hidden until hover */
|
||||
.playlist-card-delete {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.album-card:hover .playlist-card-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.playlist-card-delete:hover {
|
||||
background: rgba(220, 60, 60, 0.85);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.playlist-card-delete--confirm {
|
||||
background: #dc3c3c !important;
|
||||
color: #fff !important;
|
||||
opacity: 1 !important;
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
top: 3px !important;
|
||||
right: 3px !important;
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.45);
|
||||
animation: delete-confirm-pulse 0.7s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes delete-confirm-pulse {
|
||||
from { background: #dc3c3c; }
|
||||
to { background: #ff2222; }
|
||||
}
|
||||
|
||||
/* ─ Bulk Select ─ */
|
||||
.bulk-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--accent-dim, color-mix(in srgb, var(--accent) 15%, transparent));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.bulk-action-count {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
margin-right: auto;
|
||||
}
|
||||
.bulk-pl-picker-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bulk-check {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1.5px solid var(--text-muted);
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.1s, background 0.1s;
|
||||
}
|
||||
.bulk-check.checked {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2 6l3 3 5-5' stroke='%23fff' stroke-width='1.8' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-size: 10px 10px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
.track-row.bulk-selected {
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent) !important;
|
||||
}
|
||||
.track-row.bulk-selected:hover {
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Drop indicator line */
|
||||
.playlist-drop-indicator {
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 1px;
|
||||
margin: 1px 0;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* ─ Playlist Search Panel ─ */
|
||||
.playlist-search-panel {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3) var(--space-3) var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
animation: fade-in-down 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes fade-in-down {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.playlist-search-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.playlist-search-input-wrap .input {
|
||||
width: 100%;
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.playlist-search-input-wrap .live-search-clear {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.playlist-search-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr auto 52px 28px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 3px var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.playlist-search-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.playlist-search-thumb {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--bg-hover);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.playlist-search-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playlist-search-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.playlist-search-artist {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.playlist-search-album {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.playlist-search-duration {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.playlist-search-add-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.playlist-search-add-btn:hover {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-base);
|
||||
}
|
||||
|
||||
.playlist-suggestions {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.playlist-suggestions-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-3);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
/* ─ Context Menu Submenu ─ */
|
||||
.context-menu-item--submenu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.context-menu-item--submenu.active,
|
||||
.context-menu-item--submenu:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.context-submenu {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-1) 0;
|
||||
min-width: 190px;
|
||||
max-width: 250px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6);
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.context-submenu-empty {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.context-submenu-new {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.context-submenu-create {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.context-submenu-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
padding: 3px 7px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.context-submenu-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.context-submenu-create-btn {
|
||||
flex-shrink: 0;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ctp-crust);
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.context-submenu-create-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
|
||||
@@ -592,6 +592,11 @@
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* Star + Last.fm heart — visually separated from track title */
|
||||
.player-star-btn {
|
||||
margin-left: var(--space-3);
|
||||
}
|
||||
|
||||
.player-btn-primary {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
@@ -617,8 +622,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-left: 32px;
|
||||
margin-right: 32px;
|
||||
margin-left: 24px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.player-waveform-wrap {
|
||||
|
||||
@@ -1053,6 +1053,20 @@
|
||||
--danger: #9d0006;
|
||||
}
|
||||
|
||||
/* ── Gruvbox Light Soft overrides ── */
|
||||
[data-theme='gruvbox-light-soft'] .album-detail-back {
|
||||
color: #fbf1c7;
|
||||
}
|
||||
[data-theme='gruvbox-light-soft'] .album-detail-back:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme='gruvbox-light-soft'] .album-detail-badge {
|
||||
background: var(--accent);
|
||||
color: #fbf1c7;
|
||||
}
|
||||
|
||||
/* ─── Lambda 17 — Half-Life / City 17 ─── */
|
||||
/* HEV orange + Combine Overwatch teal + City 17 concrete */
|
||||
[data-theme='lambda-17'] {
|
||||
|
||||
Reference in New Issue
Block a user