Compare commits

...

8 Commits

Author SHA1 Message Date
Psychotoxical 1adfda1daa fix: remove invalid single-instance capability + dead static tray icon
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 13:51:50 +02:00
Psychotoxical e65c476a76 fix: v1.25.1 — Opus playback, single-instance enforcement
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 13:45:37 +02:00
Psychotoxical 560349819f feat: v1.25.0 — Tray Icon, Minimize to Tray, Sidebar Customization
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:46:41 +02:00
Psychotoxical ada5327493 chore: bump AUR pkgver to 1.24.0 2026-03-31 23:14:45 +02:00
Psychotoxical c67d606f89 feat: v1.24.0 — Playlist Management, native sample rate playback
- Full playlist feature: overview grid, detail page with hero collage,
  tracklist DnD, song search, suggestions, context menu submenu
- Audio: disable all app-level resampling — every track plays at its
  native sample rate (target_rate always 0 in audio_play + chain_next)
- Fix: playlist hero bg flicker (memoize buildCoverArtUrl calls)
- Fix: input focus double-border (search-input → .input class)
- Polish: redesigned playlist search panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:14:15 +02:00
Psychotoxical 662cc94ca8 fix: disable forced resampling to 44100 Hz on first track
current_sample_rate was initialized to 44100, causing every track to be
resampled down to 44.1 kHz. Setting it to 0 disables resampling until
the actual track rate is known, so songs play at their native sample rate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 09:01:58 +02:00
Psychotoxical 1eacaf678c Update README.md 2026-03-30 23:16:47 +02:00
Psychotoxical 4a8fb64c66 Update README.md 2026-03-30 23:16:04 +02:00
26 changed files with 2218 additions and 119 deletions
+52
View File
@@ -5,6 +5,58 @@ 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.25.1] - 2026-04-01
### Fixed
- **Opus playback** *(reported by [@powerfool](https://github.com/powerfool))*: `.opus` files were silently skipped because Symphonia has no Opus decoder. Opus tracks are now transcoded server-side to FLAC via the Subsonic `format=flac` parameter, avoiding any additional lossy encoding step.
- **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
+2 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.23.0",
"version": "1.25.1",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.23.0
pkgver=1.25.1
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+165 -12
View File
@@ -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.25.0"
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",
]
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.21.0"
version = "1.25.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"
+6 -3
View File
@@ -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
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.23.0",
"version": "1.25.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": {
+19 -8
View File
@@ -26,6 +26,8 @@ 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';
@@ -251,6 +253,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>
@@ -275,7 +279,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 +338,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 +375,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);
+10 -3
View File
@@ -96,6 +96,7 @@ export interface SubsonicPlaylist {
changed: string;
owner?: string;
public?: boolean;
coverArt?: string;
}
export interface SubsonicNowPlaying extends SubsonicSong {
@@ -324,7 +325,7 @@ export async function reportNowPlaying(id: string): Promise<void> {
}
// ─── Stream URL ───────────────────────────────────────────────
export function buildStreamUrl(id: string): string {
export function buildStreamUrl(id: string, suffix?: string): string {
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
@@ -335,6 +336,11 @@ export function buildStreamUrl(id: string): string {
u: server?.username ?? '',
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
});
if (suffix === 'opus') {
p.set('format', 'flac'); // Transcode OPUS to FLAC since Rust/Symphonia has no Opus decoder
}
return `${baseUrl}/rest/stream.view?${p.toString()}`;
}
@@ -384,12 +390,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> {
+7 -3
View File
@@ -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) => {
+179 -3
View File
@@ -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 } 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,6 +20,144 @@ function sanitizeFilename(name: string): string {
.substring(0, 200) || 'download';
}
// ── Add-to-Playlist submenu ───────────────────────────────────────
function AddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone: () => void }) {
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 = 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();
@@ -29,10 +168,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 +268,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);
@@ -192,6 +346,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 +393,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}`))}>
+5 -3
View File
@@ -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
@@ -154,8 +154,10 @@ 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) : '';
// 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]);
// useCachedUrl must be called unconditionally (hook rules)
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
+6 -7
View File
@@ -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,9 +92,9 @@ 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) : '';
// 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);
// Keep the last known good URL so HeroBg never receives '' during a cache-miss
@@ -102,9 +102,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
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" />;
+6 -2
View File
@@ -1,4 +1,4 @@
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';
@@ -159,6 +159,10 @@ export default function QueuePanel() {
const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrack = usePlayerStore(s => s.currentTrack);
const currentTime = usePlayerStore(s => s.currentTime);
const currentCoverSrc = useMemo(
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
[currentTrack?.coverArt]
);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const playTrack = usePlayerStore(s => s.playTrack);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
@@ -371,7 +375,7 @@ export default function QueuePanel() {
<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
View File
@@ -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' : ''}`}
+3
View File
@@ -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;
+170
View File
@@ -25,6 +25,7 @@ const enTranslation = {
downloadingTracks: 'Caching {{n}} tracks…',
offlineLibrary: 'Offline Library',
genres: 'Genres',
playlists: 'Playlists',
},
home: {
starred: 'Personal Favorites',
@@ -100,6 +101,7 @@ const enTranslation = {
openAlbum: 'Open Album',
goToArtist: 'Go to Artist',
download: 'Download (ZIP)',
addToPlaylist: 'Add to Playlist',
},
albumDetail: {
back: 'Back',
@@ -359,6 +361,8 @@ 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.',
downloadsTitle: 'Download Folder',
downloadsDefault: 'Default Downloads Folder',
pickFolder: 'Select',
@@ -388,6 +392,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 +599,32 @@ const enTranslation = {
lyrics: 'Lyrics',
lyricsLoading: 'Loading lyrics…',
lyricsNotFound: 'No lyrics found for this track',
},
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',
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,6 +652,7 @@ const deTranslation = {
downloadingTracks: '{{n}} Tracks werden gecacht…',
offlineLibrary: 'Offline-Bibliothek',
genres: 'Genres',
playlists: 'Playlists',
},
home: {
starred: 'Persönliche Favoriten',
@@ -693,6 +728,7 @@ const deTranslation = {
openAlbum: 'Album öffnen',
goToArtist: 'Zum Künstler',
download: 'Herunterladen (ZIP)',
addToPlaylist: 'Zur Playlist hinzufügen',
},
albumDetail: {
back: 'Zurück',
@@ -952,6 +988,8 @@ 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.',
downloadsTitle: 'Download-Ordner',
downloadsDefault: 'Standard-Downloads-Ordner',
pickFolder: 'Auswählen',
@@ -981,6 +1019,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 +1226,32 @@ const deTranslation = {
lyrics: 'Lyrics',
lyricsLoading: 'Lyrics werden geladen…',
lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden',
},
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',
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,6 +1279,7 @@ const frTranslation = {
downloadingTracks: '{{n}} pistes en cache…',
offlineLibrary: 'Bibliothèque hors ligne',
genres: 'Genres',
playlists: 'Playlists',
},
home: {
starred: 'Favoris personnels',
@@ -1286,6 +1355,7 @@ const frTranslation = {
openAlbum: 'Ouvrir l\'album',
goToArtist: 'Aller à l\'artiste',
download: 'Télécharger (ZIP)',
addToPlaylist: 'Ajouter à la playlist',
},
albumDetail: {
back: 'Retour',
@@ -1545,6 +1615,8 @@ 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.',
downloadsTitle: 'Dossier de téléchargement',
downloadsDefault: 'Dossier de téléchargement par défaut',
pickFolder: 'Sélectionner',
@@ -1574,6 +1646,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 +1853,32 @@ const frTranslation = {
lyrics: 'Paroles',
lyricsLoading: 'Chargement des paroles…',
lyricsNotFound: 'Aucune parole trouvée pour ce titre',
},
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',
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,6 +1906,7 @@ const nlTranslation = {
downloadingTracks: '{{n}} nummers worden gecached…',
offlineLibrary: 'Offline bibliotheek',
genres: 'Genres',
playlists: 'Playlists',
},
home: {
starred: 'Persoonlijke favorieten',
@@ -1879,6 +1982,7 @@ const nlTranslation = {
openAlbum: 'Album openen',
goToArtist: 'Naar artiest',
download: 'Downloaden (ZIP)',
addToPlaylist: 'Toevoegen aan playlist',
},
albumDetail: {
back: 'Terug',
@@ -2138,6 +2242,8 @@ 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.',
downloadsTitle: 'Downloadmap',
downloadsDefault: 'Standaard downloadmap',
pickFolder: 'Selecteren',
@@ -2167,6 +2273,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 +2480,32 @@ const nlTranslation = {
lyrics: 'Songtekst',
lyricsLoading: 'Songtekst laden…',
lyricsNotFound: 'Geen songtekst gevonden voor dit nummer',
},
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',
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,6 +2533,7 @@ const zhTranslation = {
downloadingTracks: '正在缓存 {{n}} 首歌曲…',
offlineLibrary: '离线音乐库',
genres: '流派',
playlists: '播放列表',
},
home: {
starred: '个人收藏',
@@ -2472,6 +2609,7 @@ const zhTranslation = {
openAlbum: '打开专辑',
goToArtist: '前往艺术家',
download: '下载 (ZIP)',
addToPlaylist: '添加到播放列表',
},
albumDetail: {
back: '返回',
@@ -2731,6 +2869,8 @@ const zhTranslation = {
cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。',
cacheClearConfirm: '全部清除',
cacheClearCancel: '取消',
minimizeToTray: '最小化到托盘',
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
downloadsTitle: '下载文件夹',
downloadsDefault: '默认下载文件夹',
pickFolder: '选择',
@@ -2760,6 +2900,10 @@ const zhTranslation = {
tabPlayback: '播放',
tabLibrary: '音乐库',
tabAppearance: '外观',
sidebarTitle: '侧边栏',
sidebarReset: '重置为默认',
sidebarDrag: '拖动以重新排序',
sidebarFixed: '始终显示',
tabShortcuts: '快捷键',
tabServer: '服务器',
tabAbout: '关于',
@@ -2963,6 +3107,32 @@ const zhTranslation = {
lyrics: '歌词',
lyricsLoading: '正在加载歌词…',
lyricsNotFound: '未找到此曲目的歌词',
},
playlists: {
title: '播放列表',
newPlaylist: '新建播放列表',
unnamed: '未命名播放列表',
createName: '播放列表名称…',
create: '创建',
cancel: '取消',
empty: '暂无播放列表。',
emptyPlaylist: '此播放列表为空。',
notFound: '未找到播放列表。',
songs: '{{n}} 首歌曲',
playAll: '全部播放',
addToQueue: '添加到队列',
back: '返回播放列表',
deletePlaylist: '删除',
confirmDelete: '再次点击确认删除',
removeSong: '从播放列表中移除',
addSongs: '添加歌曲',
searchPlaceholder: '搜索音乐库…',
noResults: '无结果。',
suggestions: '推荐歌曲',
noSuggestions: '暂无推荐。',
titleBadge: '播放列表',
refreshSuggestions: '新建议',
addSong: '添加到播放列表',
}
};
+613
View File
@@ -0,0 +1,613 @@
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 } from 'lucide-react';
import {
getPlaylist, updatePlaylist, search, setRating, star, unstar,
getSimilarSongs2, 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);
// ── 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);
// 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[]) => {
const withArtist = currentSongs.filter(s => s.artistId);
if (!withArtist.length) return;
const pick = withArtist[Math.floor(Math.random() * withArtist.length)];
const existingIds = new Set(currentSongs.map(s => s.id));
setLoadingSuggestions(true);
setSuggestions([]);
try {
const similar = await getSimilarSongs2(pick.artistId!, 25);
setSuggestions(similar.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!);
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}>
{/* Header */}
<div className="tracklist-header tracklist-va tracklist-playlist">
<div className="col-center">#</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' : ''}`}
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);
}}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
{/* # — play on click, grip icon on hover */}
<div
className="track-num"
style={{ cursor: 'pointer', color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined }}
onClick={() => { const tracks = songs.map(songToTrack); playTrack(tracks[idx], tracks); }}
>
{hoveredSongId === song.id && currentTrack?.id !== song.id
? <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}
</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>
);
}
+192
View File
@@ -0,0 +1,192 @@
import React, { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { ListMusic, Play, Plus, 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"
>
<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>
);
}
+171 -30
View File
@@ -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,17 @@ 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.downloadsTitle')}</div>
@@ -1132,6 +1138,141 @@ function renderInline(text: string): React.ReactNode[] {
});
}
function SidebarGripHandle({ idx, label }: { idx: number; label: string }) {
const { t } = useTranslation();
const { onMouseDown } = useDragSource(() => ({
data: JSON.stringify({ type: 'sidebar_reorder', index: idx }),
label,
}));
return (
<span
className="sidebar-customizer-grip"
data-tooltip={t('settings.sidebarDrag')}
data-tooltip-pos="right"
onMouseDown={onMouseDown}
>
<GripVertical size={16} />
</span>
);
}
function SidebarCustomizer() {
const { t } = useTranslation();
const { items, setItems, toggleItem, reset } = useSidebarStore();
const { isDragging: isPsyDragging } = useDragDrop();
const listRef = useRef<HTMLDivElement>(null);
const [dropTarget, setDropTarget] = useState<{ idx: number; before: boolean } | null>(null);
const dropTargetRef = useRef<{ idx: number; before: boolean } | null>(null);
const itemsRef = useRef(items);
itemsRef.current = items;
useEffect(() => {
if (!isPsyDragging) {
dropTargetRef.current = null;
setDropTarget(null);
}
}, [isPsyDragging]);
useEffect(() => {
const el = listRef.current;
if (!el) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: { type?: string; index?: number };
try { parsed = JSON.parse(detail.data); } catch { return; }
if (parsed.type !== 'sidebar_reorder' || parsed.index == null) return;
const fromIdx = parsed.index;
const target = dropTargetRef.current;
dropTargetRef.current = null;
setDropTarget(null);
if (target === null) return;
const insertBefore = target.before ? target.idx : target.idx + 1;
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
const next = [...itemsRef.current];
const [moved] = next.splice(fromIdx, 1);
const adjustedInsert = insertBefore > fromIdx ? insertBefore - 1 : insertBefore;
next.splice(adjustedInsert, 0, moved);
setItems(next);
};
el.addEventListener('psy-drop', onPsyDrop);
return () => el.removeEventListener('psy-drop', onPsyDrop);
}, [setItems]);
const handleMouseMove = (e: React.MouseEvent) => {
if (!isPsyDragging || !listRef.current) return;
const rows = listRef.current.querySelectorAll<HTMLElement>('[data-sidebar-idx]');
let target: { idx: number; before: boolean } | null = null;
for (const row of rows) {
const rect = row.getBoundingClientRect();
const idx = Number(row.dataset.sidebarIdx);
if (e.clientY < rect.top + rect.height / 2) {
target = { idx, before: true };
break;
}
target = { idx, before: false };
}
dropTargetRef.current = target;
setDropTarget(target);
};
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
className="settings-card"
style={{ padding: '4px 0' }}
ref={listRef}
onMouseMove={handleMouseMove}
>
{items.map((cfg, idx) => {
const meta = ALL_NAV_ITEMS[cfg.id];
if (!meta) return null;
const Icon = meta.icon;
const isBefore = isPsyDragging && dropTarget?.idx === idx && dropTarget.before;
const isAfter = isPsyDragging && dropTarget?.idx === idx && !dropTarget.before;
return (
<div
key={cfg.id}
data-sidebar-idx={idx}
className="sidebar-customizer-row"
style={{
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
}}
>
<SidebarGripHandle idx={idx} 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>
);
})}
<div className="sidebar-customizer-fixed-hint">
<span>{t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')}</span>
</div>
</div>
</section>
);
}
function ChangelogSection() {
const { t } = useTranslation();
const showChangelogOnUpdate = useAuthStore(s => s.showChangelogOnUpdate);
+4
View File
@@ -33,6 +33,7 @@ interface AuthState {
crossfadeEnabled: boolean;
crossfadeSecs: number;
gaplessEnabled: boolean;
minimizeToTray: boolean;
showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string;
@@ -64,6 +65,7 @@ interface AuthState {
setCrossfadeEnabled: (v: boolean) => void;
setCrossfadeSecs: (v: number) => void;
setGaplessEnabled: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void;
setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void;
logout: () => void;
@@ -96,6 +98,7 @@ export const useAuthStore = create<AuthState>()(
crossfadeEnabled: false,
crossfadeSecs: 3,
gaplessEnabled: false,
minimizeToTray: false,
showChangelogOnUpdate: true,
lastSeenChangelogVersion: '',
isLoggedIn: false,
@@ -160,6 +163,7 @@ 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 }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
+23
View File
@@ -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' }
)
);
+47
View File
@@ -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' }
)
);
+410
View File
@@ -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;
@@ -1122,6 +1160,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: 36px 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);
@@ -1855,6 +1947,57 @@
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-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 +4370,270 @@
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: rgba(220, 60, 60, 0.9) !important;
color: #fff !important;
opacity: 1 !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;
}