mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat: Rust audio engine, genre mix, queue shuffle, and UX improvements (v1.2.0)
### Audio Engine - Replace Howler.js with native Rust/rodio backend (src-tauri/src/audio.rs) - audio_play/pause/resume/stop/seek/set_volume Tauri commands - Generation counter cancels stale in-flight downloads on skip - Wall-clock position tracking; audio:ended after 2 ticks near end ### Playback Persistence - Persist currentTrack, queue, queueIndex, currentTime to localStorage - Cold-start resume: play + seek to saved position on app restart - Position priority: server queue > localStorage ### Random Mix - Genre blacklist: excludeAudiobooks toggle + custom chip-based blacklist - Clickable genre chips in tracklist to blacklist on the fly - Super Genre Mix: 9 genres, progressive rendering, Load 10 more - Promise.allSettled + 45s timeout for large Metal/Rock libraries - Two-column filter panel at top of page ### Queue & UI - Shuffle button in queue header (Fisher-Yates, keeps current track at 0) - LiveSearch arrow key / Enter / Escape keyboard navigation - Multi-line tooltip support (data-tooltip-wrap attribute) - Update link uses Tauri shell open() to launch system browser - Sidebar min-width 180px → 200px (fixes Psysonic title clipping) - Tooltip z-index fix: main-content z-index:1 over queue panel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,61 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-03-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Rust Audio Engine (replaces Howler.js)
|
||||||
|
- **New native audio backend** built in Rust using [rodio](https://github.com/RustAudio/rodio). Audio is now decoded and played entirely in the Tauri backend — no more reliance on the WebView's `<audio>` element or GStreamer pipeline quirks.
|
||||||
|
- Tauri commands: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume`.
|
||||||
|
- Frontend events: `audio:playing` (with duration), `audio:progress` (every 500 ms), `audio:ended`, `audio:error`.
|
||||||
|
- Generation counter (`AtomicU64`) ensures stale downloads from skipped tracks are cancelled immediately and do not emit events.
|
||||||
|
- Wall-clock position tracking (`seek_offset + elapsed`) instead of `sink.empty()` (unreliable in rodio 0.19 for VBR MP3). `audio:ended` fires after two consecutive ticks within 1 second of the track end — avoids false positives near the end without adding latency.
|
||||||
|
- Seek via `sink.try_seek()` — no pause/play cycle, no spurious `ended` events.
|
||||||
|
- Volume clamped to `[0.0, 1.0]` on every call.
|
||||||
|
|
||||||
|
#### Playback Persistence & Cold-Start Resume
|
||||||
|
- `currentTrack`, `queue`, `queueIndex`, and `currentTime` are now persisted to `localStorage` via Zustand `partialize`.
|
||||||
|
- On app restart with a previously loaded track, clicking Play resumes from the saved position without losing the queue.
|
||||||
|
- Position priority: server play queue position (if > 0) takes precedence over the locally saved value, so cross-device resume works correctly.
|
||||||
|
|
||||||
|
#### Random Mix — Genre Filter & Blacklist
|
||||||
|
- **Exclude audiobooks & radio plays** toggle: filters out songs whose genre, title, or album match a hardcoded list (`Hörbuch`, `Hörspiel`, `Audiobook`, `Spoken Word`, `Podcast`, `Krimi`, `Thriller`, `Speech`, `Fantasy`, `Comedy`, `Literature`, and more).
|
||||||
|
- **Custom genre blacklist**: add any genre keyword via the collapsible chip panel on the Random Mix page or in Settings → Random Mix. Persisted across sessions.
|
||||||
|
- **Clickable genre chips** in the tracklist: clicking an unblocked genre tag adds it to the blacklist instantly with 1.5 s visual feedback. Blocked genres are shown in red.
|
||||||
|
- Blacklist filter checks `song.genre`, `song.title`, and `song.album` to catch mislabelled tracks.
|
||||||
|
|
||||||
|
#### Random Mix — Super Genre Mix
|
||||||
|
- Nine pre-defined **Super Genres** (Metal, Rock, Pop, Electronic, Jazz, Classical, Hip-Hop, Country, World) appear as buttons, auto-generated from the server's genre list — only genres with at least one matching keyword are shown.
|
||||||
|
- Selecting a Super Genre fetches up to 50 songs distributed across all matched sub-genres in parallel, then shuffles the result.
|
||||||
|
- **Progressive rendering**: the tracklist appears as soon as the first genre request returns — users with large Metal/Rock libraries no longer stare at a spinner for the entire fetch. A small inline spinner next to the title indicates that more genres are still loading.
|
||||||
|
- **"Load 10 more"** button: fetches 10 additional songs from the same matched genres and appends them to the play queue.
|
||||||
|
- Random playlist is automatically hidden while a Genre Mix is active.
|
||||||
|
- Fetch timeout raised to **45 seconds** per genre request (was 15 s) and `Promise.allSettled` used so a single slow/failing genre does not abort the entire mix.
|
||||||
|
|
||||||
|
#### Queue Panel
|
||||||
|
- **Shuffle button** in the queue header: Fisher-Yates shuffles all queued tracks while keeping the currently playing track at position 0. Button is disabled when the queue has fewer than 2 entries.
|
||||||
|
|
||||||
|
#### UI / UX
|
||||||
|
- **LiveSearch keyboard navigation**: arrow keys navigate the dropdown, Enter selects the highlighted item or navigates to the full search results page, Escape closes the dropdown.
|
||||||
|
- **Multi-line tooltip support**: add `data-tooltip-wrap` attribute to any element with `data-tooltip` to enable line-wrapping (uses `white-space: pre-line` + `\n` in the string). Respects a 220 px max-width.
|
||||||
|
- **Genre column info icon** in Random Mix tracklist header: hover tooltip explains the clickable-genre-to-blacklist feature.
|
||||||
|
- **Update link** in the sidebar now uses Tauri Shell plugin `open()` to launch the system browser correctly — `<a target="_blank">` has no effect inside a Tauri WebView.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Songs skipping immediately** (root cause: Tauri v2 IPC maps Rust `snake_case` parameters to **camelCase** on the JS side — `duration_hint` must be `durationHint`). All `invoke()` calls updated.
|
||||||
|
- **Play button doing nothing after restart**: `currentTrack` was `null` after restart (not persisted). Fixed by adding it to `partialize`.
|
||||||
|
- **Position not restored after restart**: `initializeFromServerQueue` overwrote the local saved position with the server value even when the server reported 0. Now falls back to the localStorage value when the server position is 0.
|
||||||
|
- **Genre Mix blank on Metal/Rock**: a single timed-out genre request caused `Promise.all` to reject the entire mix. Replaced with `Promise.allSettled` + 45 s timeout; partial results are shown immediately.
|
||||||
|
- **Tooltip z-index**: tooltips in the main content area were rendered behind the queue panel. Fixed by giving `.main-content` `z-index: 1`, establishing a stacking context above the queue (which sits later in DOM order).
|
||||||
|
- **Sidebar title clipping**: "Psysonic" brand text was truncated at narrow viewport widths. Minimum sidebar width raised from 180 px to 200 px.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Audio architecture**: Howler.js removed. All audio state (`isPlaying`, `isAudioPaused`, `currentTime`, `duration`) is now driven by Tauri events from the Rust engine rather than Howler callbacks.
|
||||||
|
- **Random Mix layout**: Filter/blacklist panel and Genre Mix buttons are now combined in a two-column card at the top of the page instead of being scattered across the page.
|
||||||
|
- **Hardcoded genre blacklist** extended with: `Fantasy`, `Comedy`, `Literature`.
|
||||||
|
- **`getRandomSongs`** now accepts an optional `timeout` parameter (default 15 s) so callers can pass a longer value for large-library scenarios.
|
||||||
|
|
||||||
## [1.0.12] - 2026-03-14
|
## [1.0.12] - 2026-03-14
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Generated
+2
-17
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "psysonic",
|
"name": "psysonic",
|
||||||
"version": "0.1.0",
|
"version": "1.0.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "psysonic",
|
"name": "psysonic",
|
||||||
"version": "0.1.0",
|
"version": "1.0.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
"@tauri-apps/plugin-shell": "^2",
|
"@tauri-apps/plugin-shell": "^2",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
"@tauri-apps/plugin-store": "^2",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"howler": "^2.2.4",
|
|
||||||
"i18next": "^25.8.16",
|
"i18next": "^25.8.16",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"md5": "^2.3.0",
|
"md5": "^2.3.0",
|
||||||
@@ -28,7 +27,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/howler": "^2.2.12",
|
|
||||||
"@types/md5": "^2.3.5",
|
"@types/md5": "^2.3.5",
|
||||||
"@types/node": "^25.3.5",
|
"@types/node": "^25.3.5",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
@@ -1574,13 +1572,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/howler": {
|
|
||||||
"version": "2.2.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.12.tgz",
|
|
||||||
"integrity": "sha512-hy769UICzOSdK0Kn1FBk4gN+lswcj1EKRkmiDtMkUGvFfYJzgaDXmVXkSShS2m89ERAatGIPnTUlp2HhfkVo5g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/md5": {
|
"node_modules/@types/md5": {
|
||||||
"version": "2.3.6",
|
"version": "2.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.6.tgz",
|
||||||
@@ -2110,12 +2101,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/howler": {
|
|
||||||
"version": "2.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz",
|
|
||||||
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/html-parse-stringify": {
|
"node_modules/html-parse-stringify": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||||
|
|||||||
+1
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "psysonic",
|
"name": "psysonic",
|
||||||
"version": "1.0.12",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
"@tauri-apps/plugin-shell": "^2",
|
"@tauri-apps/plugin-shell": "^2",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
"@tauri-apps/plugin-store": "^2",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"howler": "^2.2.4",
|
|
||||||
"i18next": "^25.8.16",
|
"i18next": "^25.8.16",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"md5": "^2.3.0",
|
"md5": "^2.3.0",
|
||||||
@@ -31,7 +30,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/howler": "^2.2.12",
|
|
||||||
"@types/md5": "^2.3.5",
|
"@types/md5": "^2.3.5",
|
||||||
"@types/node": "^25.3.5",
|
"@types/node": "^25.3.5",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
|
|||||||
Generated
+840
-22
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "psysonic"
|
name = "psysonic"
|
||||||
version = "1.0.12"
|
version = "1.2.0"
|
||||||
description = "Psysonic Desktop Music Player"
|
description = "Psysonic Desktop Music Player"
|
||||||
authors = []
|
authors = []
|
||||||
license = ""
|
license = ""
|
||||||
@@ -30,3 +30,6 @@ tauri-plugin-dialog = "2"
|
|||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
||||||
|
reqwest = { version = "0.12", features = ["stream"] }
|
||||||
|
tokio = { version = "1", features = ["rt", "time"] }
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
use std::io::Cursor;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use rodio::{Decoder, Sink, Source};
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
|
// ─── Debug logger ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ─── Engine state (registered as Tauri managed state) ────────────────────────
|
||||||
|
|
||||||
|
pub struct AudioEngine {
|
||||||
|
pub stream_handle: Arc<rodio::OutputStreamHandle>,
|
||||||
|
pub current: Arc<Mutex<AudioCurrent>>,
|
||||||
|
/// Monotonically incremented on each audio_play / audio_stop call.
|
||||||
|
/// The background progress task captures its own `gen` at creation and
|
||||||
|
/// bails out if this counter has moved on, preventing stale events.
|
||||||
|
pub generation: Arc<AtomicU64>,
|
||||||
|
pub http_client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AudioCurrent {
|
||||||
|
/// The active rodio Sink. `None` when stopped.
|
||||||
|
pub sink: Option<Sink>,
|
||||||
|
pub duration_secs: f64,
|
||||||
|
/// Position (seconds) that we seeked/resumed from.
|
||||||
|
pub seek_offset: f64,
|
||||||
|
/// Instant when we started counting from seek_offset (None when paused/stopped).
|
||||||
|
pub play_started: Option<Instant>,
|
||||||
|
/// Set when paused; holds the position at pause time.
|
||||||
|
pub paused_at: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioCurrent {
|
||||||
|
pub fn position(&self) -> f64 {
|
||||||
|
if let Some(p) = self.paused_at {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
if let Some(t) = self.play_started {
|
||||||
|
let elapsed = t.elapsed().as_secs_f64();
|
||||||
|
(self.seek_offset + elapsed).min(self.duration_secs.max(0.001))
|
||||||
|
} else {
|
||||||
|
self.seek_offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialise the audio engine. Spawns a dedicated thread that holds the
|
||||||
|
/// `OutputStream` alive for the lifetime of the process (parking prevents
|
||||||
|
/// the thread — and thus the stream — from being dropped).
|
||||||
|
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||||
|
let (tx, rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
||||||
|
|
||||||
|
let thread = std::thread::Builder::new()
|
||||||
|
.name("psysonic-audio-stream".into())
|
||||||
|
.spawn(move || match rodio::OutputStream::try_default() {
|
||||||
|
Ok((_stream, handle)) => {
|
||||||
|
tx.send(handle).ok();
|
||||||
|
// Park forever — `_stream` must stay alive for audio to work.
|
||||||
|
loop {
|
||||||
|
std::thread::park();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[psysonic] audio output error: {e}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("spawn audio stream thread");
|
||||||
|
|
||||||
|
let stream_handle = rx.recv().expect("audio stream handle");
|
||||||
|
|
||||||
|
let engine = AudioEngine {
|
||||||
|
stream_handle: Arc::new(stream_handle),
|
||||||
|
current: Arc::new(Mutex::new(AudioCurrent {
|
||||||
|
sink: None,
|
||||||
|
duration_secs: 0.0,
|
||||||
|
seek_offset: 0.0,
|
||||||
|
play_started: None,
|
||||||
|
paused_at: None,
|
||||||
|
})),
|
||||||
|
generation: Arc::new(AtomicU64::new(0)),
|
||||||
|
http_client: reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(engine, thread)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Event payloads ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct ProgressPayload {
|
||||||
|
pub current_time: f64,
|
||||||
|
pub duration: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Download and play the given URL. Replaces any currently playing track.
|
||||||
|
/// Emits `audio:playing` (with duration as f64) once playback starts,
|
||||||
|
/// then `audio:progress` every 500 ms, and `audio:ended` when done.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn audio_play(
|
||||||
|
url: String,
|
||||||
|
volume: f32,
|
||||||
|
duration_hint: f64,
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, AudioEngine>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Claim this generation — any in-flight progress task with the old gen will exit.
|
||||||
|
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
|
||||||
|
// Stop existing playback immediately.
|
||||||
|
{
|
||||||
|
let mut cur = state.current.lock().unwrap();
|
||||||
|
if let Some(sink) = cur.sink.take() {
|
||||||
|
sink.stop();
|
||||||
|
}
|
||||||
|
cur.seek_offset = 0.0;
|
||||||
|
cur.play_started = None;
|
||||||
|
cur.paused_at = None;
|
||||||
|
cur.duration_secs = duration_hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Download ──────────────────────────────────────────────────────────────
|
||||||
|
let response = state
|
||||||
|
.http_client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
if state.generation.load(Ordering::SeqCst) != gen {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let msg = format!("HTTP {status}");
|
||||||
|
app.emit("audio:error", &msg).ok();
|
||||||
|
return Err(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Bail if superseded while downloading.
|
||||||
|
if state.generation.load(Ordering::SeqCst) != gen {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decode ────────────────────────────────────────────────────────────────
|
||||||
|
let data: Vec<u8> = bytes.into();
|
||||||
|
|
||||||
|
// Trust the Subsonic API duration_hint as the primary source.
|
||||||
|
// Decoder::total_duration() is unreliable for VBR MP3 (symphonia may
|
||||||
|
// return a single-frame or header duration that is far too short).
|
||||||
|
let decoder_duration = {
|
||||||
|
let cursor = Cursor::new(data.clone());
|
||||||
|
Decoder::new(cursor)
|
||||||
|
.ok()
|
||||||
|
.and_then(|d| d.total_duration())
|
||||||
|
.map(|d| d.as_secs_f64())
|
||||||
|
};
|
||||||
|
let duration_secs = if duration_hint > 1.0 {
|
||||||
|
duration_hint
|
||||||
|
} else {
|
||||||
|
decoder_duration.unwrap_or(duration_hint)
|
||||||
|
};
|
||||||
|
|
||||||
|
let cursor = Cursor::new(data);
|
||||||
|
let decoder = Decoder::new(cursor).map_err(|e| {
|
||||||
|
app.emit("audio:error", e.to_string()).ok();
|
||||||
|
e.to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Final generation check before committing.
|
||||||
|
if state.generation.load(Ordering::SeqCst) != gen {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create sink and start playback ────────────────────────────────────────
|
||||||
|
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
|
||||||
|
sink.set_volume(volume.clamp(0.0, 1.0));
|
||||||
|
sink.append(decoder);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut cur = state.current.lock().unwrap();
|
||||||
|
cur.sink = Some(sink);
|
||||||
|
cur.duration_secs = duration_secs;
|
||||||
|
cur.seek_offset = 0.0;
|
||||||
|
cur.play_started = Some(Instant::now());
|
||||||
|
cur.paused_at = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.emit("audio:playing", duration_secs).ok();
|
||||||
|
|
||||||
|
// ── Progress + ended detection ────────────────────────────────────────────
|
||||||
|
// We do NOT use `sink.empty()` because in rodio 0.19 the source moves from
|
||||||
|
// the pending queue to the active state almost immediately after `append()`,
|
||||||
|
// making `empty()` return `true` within milliseconds even for long tracks.
|
||||||
|
//
|
||||||
|
// Instead we use the wall-clock position (seek_offset + elapsed).
|
||||||
|
// `AudioCurrent::position()` is clamped to `duration_secs`, so once it
|
||||||
|
// reaches the end it stays there. We fire `audio:ended` after two
|
||||||
|
// consecutive ticks where position >= duration - 1.0 s, which:
|
||||||
|
// • avoids false positives from seeking very close to the end
|
||||||
|
// • fires roughly 0.5–1 s before the last sample, giving the frontend
|
||||||
|
// enough time to queue the next download.
|
||||||
|
let gen_counter = state.generation.clone();
|
||||||
|
let current_arc = state.current.clone();
|
||||||
|
let app_clone = app.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut near_end_ticks: u32 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
|
||||||
|
if gen_counter.load(Ordering::SeqCst) != gen {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (pos, dur, is_paused) = {
|
||||||
|
let cur = current_arc.lock().unwrap();
|
||||||
|
(cur.position(), cur.duration_secs, cur.paused_at.is_some())
|
||||||
|
};
|
||||||
|
|
||||||
|
app_clone
|
||||||
|
.emit(
|
||||||
|
"audio:progress",
|
||||||
|
ProgressPayload { current_time: pos, duration: dur },
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
if is_paused {
|
||||||
|
// Don't advance near-end counter while paused (stay put).
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if dur > 1.0 && pos >= dur - 1.0 {
|
||||||
|
near_end_ticks += 1;
|
||||||
|
if near_end_ticks >= 2 {
|
||||||
|
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||||||
|
app_clone.emit("audio:ended", ()).ok();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
near_end_ticks = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn audio_pause(state: State<'_, AudioEngine>) {
|
||||||
|
let mut cur = state.current.lock().unwrap();
|
||||||
|
if let Some(sink) = &cur.sink {
|
||||||
|
if !sink.is_paused() {
|
||||||
|
let pos = cur.position();
|
||||||
|
sink.pause();
|
||||||
|
cur.paused_at = Some(pos);
|
||||||
|
cur.play_started = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn audio_resume(state: State<'_, AudioEngine>) {
|
||||||
|
let mut cur = state.current.lock().unwrap();
|
||||||
|
if let Some(sink) = &cur.sink {
|
||||||
|
if sink.is_paused() {
|
||||||
|
let pos = cur.paused_at.unwrap_or(cur.seek_offset);
|
||||||
|
sink.play();
|
||||||
|
cur.seek_offset = pos;
|
||||||
|
cur.play_started = Some(Instant::now());
|
||||||
|
cur.paused_at = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn audio_stop(state: State<'_, AudioEngine>) {
|
||||||
|
state.generation.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let mut cur = state.current.lock().unwrap();
|
||||||
|
if let Some(sink) = cur.sink.take() {
|
||||||
|
sink.stop();
|
||||||
|
}
|
||||||
|
cur.duration_secs = 0.0;
|
||||||
|
cur.seek_offset = 0.0;
|
||||||
|
cur.play_started = None;
|
||||||
|
cur.paused_at = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
|
||||||
|
let mut cur = state.current.lock().unwrap();
|
||||||
|
if let Some(sink) = &cur.sink {
|
||||||
|
sink.try_seek(Duration::from_secs_f64(seconds.max(0.0)))
|
||||||
|
.map_err(|e: rodio::source::SeekError| e.to_string())?;
|
||||||
|
if cur.paused_at.is_some() {
|
||||||
|
cur.paused_at = Some(seconds);
|
||||||
|
} else {
|
||||||
|
cur.seek_offset = seconds;
|
||||||
|
cur.play_started = Some(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||||
|
let cur = state.current.lock().unwrap();
|
||||||
|
if let Some(sink) = &cur.sink {
|
||||||
|
sink.set_volume(volume.clamp(0.0, 1.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-1
@@ -1,6 +1,8 @@
|
|||||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod audio;
|
||||||
|
|
||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{MenuBuilder, MenuItemBuilder},
|
menu::{MenuBuilder, MenuItemBuilder},
|
||||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
@@ -18,7 +20,10 @@ fn exit_app(app_handle: tauri::AppHandle) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
let (audio_engine, _audio_thread) = audio::create_engine();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.manage(audio_engine)
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||||
@@ -107,7 +112,16 @@ pub fn run() {
|
|||||||
let _ = window.emit("window:close-requested", ());
|
let _ = window.emit("window:close-requested", ());
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![greet, exit_app])
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
greet,
|
||||||
|
exit_app,
|
||||||
|
audio::audio_play,
|
||||||
|
audio::audio_pause,
|
||||||
|
audio::audio_resume,
|
||||||
|
audio::audio_stop,
|
||||||
|
audio::audio_seek,
|
||||||
|
audio::audio_set_volume,
|
||||||
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running Psysonic");
|
.expect("error while running Psysonic");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Psysonic",
|
"productName": "Psysonic",
|
||||||
"version": "1.0.12",
|
"version": "1.2.0",
|
||||||
"identifier": "dev.psysonic.app",
|
"identifier": "dev.psysonic.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+6
-2
@@ -27,7 +27,7 @@ import SearchResults from './pages/SearchResults';
|
|||||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||||
import ContextMenu from './components/ContextMenu';
|
import ContextMenu from './components/ContextMenu';
|
||||||
import { useAuthStore } from './store/authStore';
|
import { useAuthStore } from './store/authStore';
|
||||||
import { usePlayerStore } from './store/playerStore';
|
import { usePlayerStore, initAudioListeners } from './store/playerStore';
|
||||||
import { useThemeStore } from './store/themeStore';
|
import { useThemeStore } from './store/themeStore';
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
@@ -110,7 +110,7 @@ function AppShell() {
|
|||||||
<div
|
<div
|
||||||
className="app-shell"
|
className="app-shell"
|
||||||
style={{
|
style={{
|
||||||
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(180px, 15vw, 220px)',
|
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)',
|
||||||
'--queue-width': isQueueVisible ? `${queueWidth}px` : '0px'
|
'--queue-width': isQueueVisible ? `${queueWidth}px` : '0px'
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties}
|
||||||
onContextMenu={e => e.preventDefault()}
|
onContextMenu={e => e.preventDefault()}
|
||||||
@@ -223,6 +223,10 @@ export default function App() {
|
|||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return initAudioListeners();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<TauriEventBridge />
|
<TauriEventBridge />
|
||||||
|
|||||||
+6
-2
@@ -74,6 +74,8 @@ export interface SubsonicSong {
|
|||||||
samplingRate?: number;
|
samplingRate?: number;
|
||||||
channelCount?: number;
|
channelCount?: number;
|
||||||
starred?: string;
|
starred?: string;
|
||||||
|
genre?: string;
|
||||||
|
path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubsonicPlaylist {
|
export interface SubsonicPlaylist {
|
||||||
@@ -160,8 +162,10 @@ export async function getAlbumList(
|
|||||||
return data.albumList2?.album ?? [];
|
return data.albumList2?.album ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRandomSongs(size = 50): Promise<SubsonicSong[]> {
|
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
|
||||||
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', { size });
|
const params: Record<string, string | number> = { size };
|
||||||
|
if (genre) params.genre = genre;
|
||||||
|
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
|
||||||
return data.randomSongs?.song ?? [];
|
return data.randomSongs?.song ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+105
-73
@@ -19,9 +19,11 @@ export default function LiveSearch() {
|
|||||||
const [results, setResults] = useState<SearchResults | null>(null);
|
const [results, setResults] = useState<SearchResults | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const playTrack = usePlayerStore(state => state.playTrack);
|
const playTrack = usePlayerStore(state => state.playTrack);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const doSearch = useCallback(
|
const doSearch = useCallback(
|
||||||
debounce(async (q: string) => {
|
debounce(async (q: string) => {
|
||||||
@@ -38,7 +40,7 @@ export default function LiveSearch() {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => { doSearch(query); }, [query, doSearch]);
|
useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]);
|
||||||
|
|
||||||
// Close on click outside
|
// Close on click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,6 +53,40 @@ export default function LiveSearch() {
|
|||||||
|
|
||||||
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
|
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
|
||||||
|
|
||||||
|
// Flat list of all navigable items for keyboard nav
|
||||||
|
const flatItems = results ? [
|
||||||
|
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
|
||||||
|
...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))),
|
||||||
|
...(results.songs.map(s => ({ id: s.id, action: () => {
|
||||||
|
playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating });
|
||||||
|
setOpen(false); setQuery('');
|
||||||
|
}}))),
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (!open || !flatItems.length) {
|
||||||
|
if (e.key === 'Enter' && query.trim()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = Math.min(activeIndex + 1, flatItems.length - 1);
|
||||||
|
setActiveIndex(next);
|
||||||
|
dropdownRef.current?.querySelectorAll<HTMLElement>('.search-result-item')[next]?.scrollIntoView({ block: 'nearest' });
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = Math.max(activeIndex - 1, -1);
|
||||||
|
setActiveIndex(next);
|
||||||
|
if (next >= 0) dropdownRef.current?.querySelectorAll<HTMLElement>('.search-result-item')[next]?.scrollIntoView({ block: 'nearest' });
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeIndex >= 0) { flatItems[activeIndex].action(); setActiveIndex(-1); }
|
||||||
|
else if (query.trim()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); }
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setOpen(false); setActiveIndex(-1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="live-search" ref={ref} role="search">
|
<div className="live-search" ref={ref} role="search">
|
||||||
<div className="live-search-input-wrap">
|
<div className="live-search-input-wrap">
|
||||||
@@ -69,12 +105,7 @@ export default function LiveSearch() {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={e => setQuery(e.target.value)}
|
onChange={e => setQuery(e.target.value)}
|
||||||
onFocus={() => results && setOpen(true)}
|
onFocus={() => results && setOpen(true)}
|
||||||
onKeyDown={e => {
|
onKeyDown={handleKeyDown}
|
||||||
if (e.key === 'Enter' && query.trim()) {
|
|
||||||
setOpen(false);
|
|
||||||
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-controls="search-results"
|
aria-controls="search-results"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
@@ -88,78 +119,79 @@ export default function LiveSearch() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="live-search-dropdown" id="search-results" role="listbox">
|
<div className="live-search-dropdown" id="search-results" role="listbox" ref={dropdownRef}>
|
||||||
{!hasResults && !loading && (
|
{!hasResults && !loading && (
|
||||||
<div className="search-empty">{t('search.noResults', { query })}</div>
|
<div className="search-empty">{t('search.noResults', { query })}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{results?.artists.length ? (
|
{(() => {
|
||||||
<div className="search-section">
|
let idx = 0;
|
||||||
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
|
return <>
|
||||||
{results.artists.map(a => (
|
{results?.artists.length ? (
|
||||||
<button
|
<div className="search-section">
|
||||||
key={a.id}
|
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
|
||||||
className="search-result-item"
|
{results.artists.map(a => {
|
||||||
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
|
const i = idx++;
|
||||||
role="option"
|
return (
|
||||||
>
|
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
|
||||||
<div className="search-result-icon"><Users size={14} /></div>
|
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
|
||||||
<span>{a.name}</span>
|
role="option" aria-selected={activeIndex === i}>
|
||||||
</button>
|
<div className="search-result-icon"><Users size={14} /></div>
|
||||||
))}
|
<span>{a.name}</span>
|
||||||
</div>
|
</button>
|
||||||
) : null}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{results?.albums.length ? (
|
{results?.albums.length ? (
|
||||||
<div className="search-section">
|
<div className="search-section">
|
||||||
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
|
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
|
||||||
{results.albums.map(a => (
|
{results.albums.map(a => {
|
||||||
<button
|
const i = idx++;
|
||||||
key={a.id}
|
return (
|
||||||
className="search-result-item"
|
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
|
||||||
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
|
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
|
||||||
role="option"
|
role="option" aria-selected={activeIndex === i}>
|
||||||
>
|
{a.coverArt ? (
|
||||||
{a.coverArt ? (
|
<img className="search-result-thumb" src={buildCoverArtUrl(a.coverArt, 40)} alt="" loading="lazy" />
|
||||||
<img className="search-result-thumb" src={buildCoverArtUrl(a.coverArt, 40)} alt="" loading="lazy" />
|
) : (
|
||||||
) : (
|
<div className="search-result-icon"><Disc3 size={14} /></div>
|
||||||
<div className="search-result-icon"><Disc3 size={14} /></div>
|
)}
|
||||||
)}
|
<div>
|
||||||
<div>
|
<div className="search-result-name">{a.name}</div>
|
||||||
<div className="search-result-name">{a.name}</div>
|
<div className="search-result-sub">{a.artist}</div>
|
||||||
<div className="search-result-sub">{a.artist}</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</button>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{results?.songs.length ? (
|
{results?.songs.length ? (
|
||||||
<div className="search-section">
|
<div className="search-section">
|
||||||
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
|
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
|
||||||
{results.songs.map(s => (
|
{results.songs.map(s => {
|
||||||
<button
|
const i = idx++;
|
||||||
key={s.id}
|
return (
|
||||||
className="search-result-item"
|
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
playTrack({
|
playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating });
|
||||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
setOpen(false); setQuery('');
|
||||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
|
}}
|
||||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating
|
role="option" aria-selected={activeIndex === i}>
|
||||||
});
|
<div className="search-result-icon"><Music size={14} /></div>
|
||||||
setOpen(false); setQuery('');
|
<div>
|
||||||
}}
|
<div className="search-result-name">{s.title}</div>
|
||||||
role="option"
|
<div className="search-result-sub">{s.artist} · {s.album}</div>
|
||||||
>
|
</div>
|
||||||
<div className="search-result-icon"><Music size={14} /></div>
|
</button>
|
||||||
<div>
|
);
|
||||||
<div className="search-result-name">{s.title}</div>
|
})}
|
||||||
<div className="search-result-sub">{s.artist} · {s.album}</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
</button>
|
</>;
|
||||||
))}
|
})()}
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { Track, usePlayerStore } from '../store/playerStore';
|
import { Track, usePlayerStore } from '../store/playerStore';
|
||||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen } from 'lucide-react';
|
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle } from 'lucide-react';
|
||||||
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -122,6 +122,7 @@ export default function QueuePanel() {
|
|||||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||||
|
|
||||||
const reorderQueue = usePlayerStore(s => s.reorderQueue);
|
const reorderQueue = usePlayerStore(s => s.reorderQueue);
|
||||||
|
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
|
||||||
const enqueue = usePlayerStore(s => s.enqueue);
|
const enqueue = usePlayerStore(s => s.enqueue);
|
||||||
|
|
||||||
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
|
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
|
||||||
@@ -247,6 +248,9 @@ export default function QueuePanel() {
|
|||||||
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
|
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<button onClick={() => shuffleQueue()} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.shuffle')} data-tooltip={t('queue.shuffle')} disabled={queue.length < 2}>
|
||||||
|
<Shuffle size={14} />
|
||||||
|
</button>
|
||||||
<button onClick={handleSave} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.savePlaylist')} data-tooltip={t('queue.save')}>
|
<button onClick={handleSave} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.savePlaylist')} data-tooltip={t('queue.save')}>
|
||||||
<Save size={14} />
|
<Save size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { open } from '@tauri-apps/plugin-shell';
|
||||||
import { version as appVersion } from '../../package.json';
|
import { version as appVersion } from '../../package.json';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -49,14 +50,12 @@ function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; lat
|
|||||||
<span className="update-toast-label">{t('sidebar.updateAvailable')}</span>
|
<span className="update-toast-label">{t('sidebar.updateAvailable')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="update-toast-version">{t('sidebar.updateReady', { version: latestVersion })}</div>
|
<div className="update-toast-version">{t('sidebar.updateReady', { version: latestVersion })}</div>
|
||||||
<a
|
<button
|
||||||
className="update-toast-link"
|
className="update-toast-link"
|
||||||
href="https://github.com/Psychotoxical/psysonic/releases"
|
onClick={() => open('https://github.com/Psychotoxical/psysonic/releases')}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
>
|
||||||
{t('sidebar.updateLink')}
|
{t('sidebar.updateLink')}
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-4
@@ -145,6 +145,19 @@ const enTranslation = {
|
|||||||
favoriteAdd: 'Add to Favorites',
|
favoriteAdd: 'Add to Favorites',
|
||||||
favoriteRemove: 'Remove from Favorites',
|
favoriteRemove: 'Remove from Favorites',
|
||||||
play: 'Play',
|
play: 'Play',
|
||||||
|
trackGenre: 'Genre',
|
||||||
|
excludeAudiobooks: 'Exclude audiobooks & radio plays',
|
||||||
|
excludeAudiobooksDesc: 'Filters genres: Hörbuch, Hörspiel, Audiobook, Spoken Word, …',
|
||||||
|
genreBlocked: 'Genre blocked',
|
||||||
|
genreAddedToBlacklist: 'Added to blacklist',
|
||||||
|
genreAlreadyBlocked: 'Already blocked',
|
||||||
|
blacklistToggle: 'Genre Blacklist',
|
||||||
|
genreMixTitle: 'Genre Mix',
|
||||||
|
genreMixDesc: 'Select a genre to get a curated random mix',
|
||||||
|
genreMixLoadMore: 'Load 10 more',
|
||||||
|
genreMixNoGenres: 'No genres found on server.',
|
||||||
|
filterPanelTitle: 'Filters',
|
||||||
|
genreClickHint: 'Click a genre tag\nto add it to your blacklist.\nExcluded from future mixes.',
|
||||||
},
|
},
|
||||||
playlists: {
|
playlists: {
|
||||||
title: 'Playlists',
|
title: 'Playlists',
|
||||||
@@ -261,8 +274,15 @@ const enTranslation = {
|
|||||||
aboutLicenseText: 'MIT — free to use, modify, and distribute.',
|
aboutLicenseText: 'MIT — free to use, modify, and distribute.',
|
||||||
aboutRepo: 'Source Code on GitHub',
|
aboutRepo: 'Source Code on GitHub',
|
||||||
aboutVersion: 'Version',
|
aboutVersion: 'Version',
|
||||||
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Howler.js',
|
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
|
||||||
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic'
|
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
|
||||||
|
randomMixTitle: 'Random Mix',
|
||||||
|
randomMixBlacklistTitle: 'Custom Genre Blacklist',
|
||||||
|
randomMixBlacklistDesc: 'Songs whose genre matches an entry in this list are excluded from the Random Mix (when "Exclude audiobooks" is active).',
|
||||||
|
randomMixBlacklistPlaceholder: 'Add genre…',
|
||||||
|
randomMixBlacklistAdd: 'Add',
|
||||||
|
randomMixBlacklistEmpty: 'No custom genres added yet.',
|
||||||
|
randomMixHardcodedTitle: 'Built-in exclusions (active when checkbox is on)',
|
||||||
},
|
},
|
||||||
help: {
|
help: {
|
||||||
title: 'Help',
|
title: 'Help',
|
||||||
@@ -328,6 +348,7 @@ const enTranslation = {
|
|||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
deleteConfirm: 'Delete playlist "{{name}}"?',
|
deleteConfirm: 'Delete playlist "{{name}}"?',
|
||||||
clear: 'Clear',
|
clear: 'Clear',
|
||||||
|
shuffle: 'Shuffle queue',
|
||||||
hide: 'Hide',
|
hide: 'Hide',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
nextTracks: 'Next Tracks',
|
nextTracks: 'Next Tracks',
|
||||||
@@ -506,6 +527,19 @@ const deTranslation = {
|
|||||||
favoriteAdd: 'Zu Favoriten hinzufügen',
|
favoriteAdd: 'Zu Favoriten hinzufügen',
|
||||||
favoriteRemove: 'Aus Favoriten entfernen',
|
favoriteRemove: 'Aus Favoriten entfernen',
|
||||||
play: 'Abspielen',
|
play: 'Abspielen',
|
||||||
|
trackGenre: 'Genre',
|
||||||
|
excludeAudiobooks: 'Hörbücher & Hörspiele ausschließen',
|
||||||
|
excludeAudiobooksDesc: 'Filtert Genres: Hörbuch, Hörspiel, Audiobook, Spoken Word, …',
|
||||||
|
genreBlocked: 'Genre gesperrt',
|
||||||
|
genreAddedToBlacklist: 'Zur Blacklist hinzugefügt',
|
||||||
|
genreAlreadyBlocked: 'Bereits gesperrt',
|
||||||
|
blacklistToggle: 'Genre-Blacklist',
|
||||||
|
genreMixTitle: 'Genre-Mix',
|
||||||
|
genreMixDesc: 'Genre auswählen für einen passenden Zufallsmix',
|
||||||
|
genreMixLoadMore: '10 weitere laden',
|
||||||
|
genreMixNoGenres: 'Keine Genres auf dem Server gefunden.',
|
||||||
|
filterPanelTitle: 'Filter',
|
||||||
|
genreClickHint: 'Genre-Tag anklicken,\num es zur Blacklist hinzuzufügen.\nBeim nächsten Mix ausgeschlossen.',
|
||||||
},
|
},
|
||||||
playlists: {
|
playlists: {
|
||||||
title: 'Playlists',
|
title: 'Playlists',
|
||||||
@@ -622,8 +656,15 @@ const deTranslation = {
|
|||||||
aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weiterzugeben.',
|
aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weiterzugeben.',
|
||||||
aboutRepo: 'Quellcode auf GitHub',
|
aboutRepo: 'Quellcode auf GitHub',
|
||||||
aboutVersion: 'Version',
|
aboutVersion: 'Version',
|
||||||
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Howler.js',
|
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
|
||||||
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic'
|
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
|
||||||
|
randomMixTitle: 'Zufallsmix',
|
||||||
|
randomMixBlacklistTitle: 'Eigene Genre-Blacklist',
|
||||||
|
randomMixBlacklistDesc: 'Songs, deren Genre einem Eintrag in dieser Liste entspricht, werden aus dem Zufallsmix ausgeschlossen (wenn „Hörbücher ausschließen" aktiv ist).',
|
||||||
|
randomMixBlacklistPlaceholder: 'Genre hinzufügen…',
|
||||||
|
randomMixBlacklistAdd: 'Hinzufügen',
|
||||||
|
randomMixBlacklistEmpty: 'Noch keine eigenen Genres hinzugefügt.',
|
||||||
|
randomMixHardcodedTitle: 'Eingebaute Ausschlüsse (aktiv wenn Checkbox an)',
|
||||||
},
|
},
|
||||||
help: {
|
help: {
|
||||||
title: 'Hilfe',
|
title: 'Hilfe',
|
||||||
@@ -689,6 +730,7 @@ const deTranslation = {
|
|||||||
delete: 'Löschen',
|
delete: 'Löschen',
|
||||||
deleteConfirm: 'Playlist "{{name}}" löschen?',
|
deleteConfirm: 'Playlist "{{name}}" löschen?',
|
||||||
clear: 'Leeren',
|
clear: 'Leeren',
|
||||||
|
shuffle: 'Queue mischen',
|
||||||
hide: 'Verbergen',
|
hide: 'Verbergen',
|
||||||
close: 'Schließen',
|
close: 'Schließen',
|
||||||
nextTracks: 'Nächste Titel',
|
nextTracks: 'Nächste Titel',
|
||||||
|
|||||||
+346
-11
@@ -1,9 +1,35 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { getRandomSongs, SubsonicSong, star, unstar } from '../api/subsonic';
|
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { Play, Star, RefreshCw } from 'lucide-react';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { Play, Star, RefreshCw, ChevronDown, ChevronUp, Plus } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const AUDIOBOOK_GENRES = [
|
||||||
|
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
|
||||||
|
'audiobook', 'audio book', 'spoken word', 'spokenword',
|
||||||
|
'podcast', 'kapitel', 'thriller', 'krimi', 'speech',
|
||||||
|
'fantasy', 'comedy', 'literature',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SuperGenre {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
keywords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPER_GENRES: SuperGenre[] = [
|
||||||
|
{ id: 'metal', label: 'Metal', keywords: ['metal', 'thrash', 'doom', 'sludge', 'hardcore', 'grindcore', 'deathcore', 'metalcore', 'stoner', 'crust', 'black', 'death'] },
|
||||||
|
{ id: 'rock', label: 'Rock', keywords: ['rock', 'punk', 'grunge', 'alternative', 'indie', 'post-rock', 'prog', 'garage', 'psychedelic', 'shoegaze'] },
|
||||||
|
{ id: 'pop', label: 'Pop', keywords: ['pop', 'synth-pop', 'dream pop', 'electropop', 'indie pop', 'dance pop'] },
|
||||||
|
{ id: 'electronic', label: 'Electronic', keywords: ['electronic', 'techno', 'trance', 'ambient', 'edm', 'house', 'dubstep', 'drum and bass', 'dnb', 'electro', 'idm', 'synthwave', 'darkwave', 'industrial'] },
|
||||||
|
{ id: 'jazz', label: 'Jazz', keywords: ['jazz', 'blues', 'soul', 'funk', 'swing', 'bebop', 'fusion'] },
|
||||||
|
{ id: 'classical', label: 'Classical', keywords: ['classical', 'orchestra', 'symphony', 'baroque', 'opera', 'chamber', 'romantic'] },
|
||||||
|
{ id: 'hiphop', label: 'Hip-Hop', keywords: ['hip-hop', 'hip hop', 'rap', 'r&b', 'rnb', 'trap', 'grime'] },
|
||||||
|
{ id: 'country', label: 'Country', keywords: ['country', 'folk', 'bluegrass', 'americana', 'western'] },
|
||||||
|
{ id: 'world', label: 'World', keywords: ['world', 'latin', 'reggae', 'ska', 'afro', 'celtic', 'flamenco', 'bossa nova'] },
|
||||||
|
];
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
if (!seconds || isNaN(seconds)) return '0:00';
|
if (!seconds || isNaN(seconds)) return '0:00';
|
||||||
const m = Math.floor(seconds / 60);
|
const m = Math.floor(seconds / 60);
|
||||||
@@ -18,6 +44,19 @@ export default function RandomMix() {
|
|||||||
const playTrack = usePlayerStore(s => s.playTrack);
|
const playTrack = usePlayerStore(s => s.playTrack);
|
||||||
const enqueue = usePlayerStore(s => s.enqueue);
|
const enqueue = usePlayerStore(s => s.enqueue);
|
||||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||||
|
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
|
||||||
|
const [addedGenre, setAddedGenre] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Blacklist panel state
|
||||||
|
const [blacklistOpen, setBlacklistOpen] = useState(false);
|
||||||
|
const [newGenre, setNewGenre] = useState('');
|
||||||
|
|
||||||
|
// Genre Mix state
|
||||||
|
const [serverGenres, setServerGenres] = useState<SubsonicGenre[]>([]);
|
||||||
|
const [selectedSuperGenre, setSelectedSuperGenre] = useState<string | null>(null);
|
||||||
|
const [genreMixSongs, setGenreMixSongs] = useState<SubsonicSong[]>([]);
|
||||||
|
const [genreMixLoading, setGenreMixLoading] = useState(false);
|
||||||
|
const [genreMixMatchedGenres, setGenreMixMatchedGenres] = useState<string[]>([]);
|
||||||
|
|
||||||
const fetchSongs = () => {
|
const fetchSongs = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -34,10 +73,25 @@ export default function RandomMix() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSongs();
|
fetchSongs();
|
||||||
|
getGenres().then(setServerGenres).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const filteredSongs = songs.filter(song => {
|
||||||
|
if (!excludeAudiobooks) return true;
|
||||||
|
const checkText = (text: string) => {
|
||||||
|
const t = text.toLowerCase();
|
||||||
|
if (AUDIOBOOK_GENRES.some(ag => t.includes(ag))) return true;
|
||||||
|
if (customGenreBlacklist.some(bg => t.includes(bg.toLowerCase()))) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if (song.genre && checkText(song.genre)) return false;
|
||||||
|
if (song.title && checkText(song.title)) return false;
|
||||||
|
if (song.album && checkText(song.album)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
const handlePlayAll = () => {
|
const handlePlayAll = () => {
|
||||||
if (songs.length > 0) playTrack(songs[0], songs);
|
if (filteredSongs.length > 0) playTrack(filteredSongs[0], filteredSongs);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
||||||
@@ -57,6 +111,71 @@ export default function RandomMix() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Compute which super-genres have matching server genres
|
||||||
|
const availableSuperGenres = SUPER_GENRES.filter(sg =>
|
||||||
|
serverGenres.some(sg2 =>
|
||||||
|
sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadGenreMix = async (superGenreId: string) => {
|
||||||
|
const sg = SUPER_GENRES.find(s => s.id === superGenreId);
|
||||||
|
if (!sg) return;
|
||||||
|
const matched = serverGenres
|
||||||
|
.filter(sg2 => sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw)))
|
||||||
|
.map(sg2 => sg2.value);
|
||||||
|
setGenreMixMatchedGenres(matched);
|
||||||
|
setGenreMixLoading(true);
|
||||||
|
setGenreMixSongs([]);
|
||||||
|
|
||||||
|
const perGenre = Math.max(1, Math.ceil(50 / matched.length));
|
||||||
|
const accumulated: SubsonicSong[] = [];
|
||||||
|
let resolved = 0;
|
||||||
|
|
||||||
|
await Promise.allSettled(matched.map(g =>
|
||||||
|
getRandomSongs(perGenre, g, 45000).then(songs => {
|
||||||
|
accumulated.push(...songs);
|
||||||
|
resolved++;
|
||||||
|
// Show first batch immediately; update on every subsequent resolve
|
||||||
|
setGenreMixSongs([...accumulated]);
|
||||||
|
if (resolved === 1) setGenreMixLoading(false);
|
||||||
|
}).catch(() => { resolved++; })
|
||||||
|
));
|
||||||
|
|
||||||
|
// Final shuffle once all requests are done
|
||||||
|
setGenreMixSongs(prev => {
|
||||||
|
const s = [...prev];
|
||||||
|
for (let i = s.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[s[i], s[j]] = [s[j], s[i]];
|
||||||
|
}
|
||||||
|
return s.slice(0, 50);
|
||||||
|
});
|
||||||
|
setGenreMixLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreGenreMix = async () => {
|
||||||
|
if (!genreMixMatchedGenres.length) return;
|
||||||
|
setGenreMixLoading(true);
|
||||||
|
try {
|
||||||
|
const perGenre = Math.max(1, Math.ceil(10 / genreMixMatchedGenres.length));
|
||||||
|
const settled = await Promise.allSettled(genreMixMatchedGenres.map(g => getRandomSongs(perGenre, g, 45000)));
|
||||||
|
const combined = settled.flatMap(r => r.status === 'fulfilled' ? r.value : []);
|
||||||
|
for (let i = combined.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[combined[i], combined[j]] = [combined[j], combined[i]];
|
||||||
|
}
|
||||||
|
enqueue(combined.slice(0, 10).map(s => ({
|
||||||
|
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||||
|
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
|
||||||
|
coverArt: s.coverArt, track: s.track, year: s.year,
|
||||||
|
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||||
|
})));
|
||||||
|
} finally {
|
||||||
|
setGenreMixLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-body animate-fade-in">
|
<div className="content-body animate-fade-in">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||||
@@ -66,33 +185,208 @@ export default function RandomMix() {
|
|||||||
<button className="btn btn-surface" onClick={fetchSongs} disabled={loading} data-tooltip={t('randomMix.remixTooltip')}>
|
<button className="btn btn-surface" onClick={fetchSongs} disabled={loading} data-tooltip={t('randomMix.remixTooltip')}>
|
||||||
<RefreshCw size={18} className={loading ? 'spin' : ''} /> {t('randomMix.remix')}
|
<RefreshCw size={18} className={loading ? 'spin' : ''} /> {t('randomMix.remix')}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary" onClick={handlePlayAll} disabled={loading || songs.length === 0}>
|
<button className="btn btn-primary" onClick={handlePlayAll} disabled={loading || filteredSongs.length === 0}>
|
||||||
<Play size={18} fill="currentColor" /> {t('randomMix.playAll')}
|
<Play size={18} fill="currentColor" /> {t('randomMix.playAll')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && songs.length === 0 ? (
|
{/* ── Filter + Genre Mix panel ─────────────────────────────── */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: '1px',
|
||||||
|
background: 'var(--border)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
marginBottom: '2rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Left: Blacklist */}
|
||||||
|
<div style={{ background: 'var(--bg-elevated)', padding: '1rem 1.25rem' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||||
|
{t('randomMix.filterPanelTitle')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', cursor: 'pointer', fontSize: 13, marginBottom: '0.75rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={excludeAudiobooks}
|
||||||
|
onChange={e => setExcludeAudiobooks(e.target.checked)}
|
||||||
|
style={{ marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, color: 'var(--text-primary)' }}>{t('randomMix.excludeAudiobooks')}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{t('randomMix.excludeAudiobooksDesc')}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 12, padding: '3px 8px', marginBottom: blacklistOpen ? '0.5rem' : 0 }}
|
||||||
|
onClick={() => setBlacklistOpen(v => !v)}
|
||||||
|
>
|
||||||
|
{blacklistOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||||
|
{t('randomMix.blacklistToggle')} ({customGenreBlacklist.length})
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{blacklistOpen && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginBottom: '0.5rem', minHeight: 24 }}>
|
||||||
|
{customGenreBlacklist.length === 0 ? (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{t('settings.randomMixBlacklistEmpty')}</span>
|
||||||
|
) : (
|
||||||
|
customGenreBlacklist.map(genre => (
|
||||||
|
<span key={genre} style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||||
|
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
|
||||||
|
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '1px 7px', fontSize: 11, fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
{genre}
|
||||||
|
<button
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 13 }}
|
||||||
|
onClick={() => setCustomGenreBlacklist(customGenreBlacklist.filter(g => g !== genre))}
|
||||||
|
>×</button>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
value={newGenre}
|
||||||
|
onChange={e => setNewGenre(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' && newGenre.trim()) {
|
||||||
|
const trimmed = newGenre.trim();
|
||||||
|
if (!customGenreBlacklist.includes(trimmed)) setCustomGenreBlacklist([...customGenreBlacklist, trimmed]);
|
||||||
|
setNewGenre('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('settings.randomMixBlacklistPlaceholder')}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
|
||||||
|
onClick={() => {
|
||||||
|
const trimmed = newGenre.trim();
|
||||||
|
if (trimmed && !customGenreBlacklist.includes(trimmed)) setCustomGenreBlacklist([...customGenreBlacklist, trimmed]);
|
||||||
|
setNewGenre('');
|
||||||
|
}}
|
||||||
|
disabled={!newGenre.trim()}
|
||||||
|
>{t('settings.randomMixBlacklistAdd')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Genre Mix */}
|
||||||
|
<div style={{ background: 'var(--bg-elevated)', padding: '1rem 1.25rem' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||||
|
{t('randomMix.genreMixTitle')}
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>{t('randomMix.genreMixDesc')}</p>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||||
|
{serverGenres.length === 0 ? (
|
||||||
|
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||||
|
) : availableSuperGenres.length === 0 ? (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('randomMix.genreMixNoGenres')}</span>
|
||||||
|
) : (
|
||||||
|
availableSuperGenres.map(sg => (
|
||||||
|
<button
|
||||||
|
key={sg.id}
|
||||||
|
className={`btn ${selectedSuperGenre === sg.id ? 'btn-primary' : 'btn-surface'}`}
|
||||||
|
style={{ fontSize: 12, padding: '4px 12px' }}
|
||||||
|
onClick={() => { setSelectedSuperGenre(sg.id); loadGenreMix(sg.id); }}
|
||||||
|
disabled={genreMixLoading}
|
||||||
|
>
|
||||||
|
{sg.label}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Genre Mix tracklist (shown when a super-genre is selected) */}
|
||||||
|
{(genreMixLoading || genreMixSongs.length > 0) && (
|
||||||
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
{SUPER_GENRES.find(s => s.id === selectedSuperGenre)?.label} Mix
|
||||||
|
{genreMixLoading && <div className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} />}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '4px 10px' }} onClick={loadMoreGenreMix} disabled={genreMixLoading}>
|
||||||
|
<Plus size={14} /> {t('randomMix.genreMixLoadMore')}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" style={{ fontSize: 12, padding: '4px 12px' }} onClick={() => genreMixSongs.length > 0 && playTrack(genreMixSongs[0], genreMixSongs)} disabled={genreMixLoading || genreMixSongs.length === 0}>
|
||||||
|
<Play size={14} fill="currentColor" /> {t('randomMix.playAll')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{genreMixLoading && genreMixSongs.length === 0 ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><div className="spinner" /></div>
|
||||||
|
) : (
|
||||||
|
<div className="tracklist">
|
||||||
|
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}>
|
||||||
|
<span></span>
|
||||||
|
<span>{t('randomMix.trackTitle')}</span>
|
||||||
|
<span>{t('randomMix.trackArtist')}</span>
|
||||||
|
<span>{t('randomMix.trackAlbum')}</span>
|
||||||
|
<span>{t('randomMix.trackGenre')}</span>
|
||||||
|
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
|
||||||
|
</div>
|
||||||
|
{genreMixSongs.map(song => (
|
||||||
|
<div key={song.id} className="track-row" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
|
||||||
|
onDoubleClick={() => playTrack(song, genreMixSongs)} role="row" draggable
|
||||||
|
onDragStart={e => {
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating } }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(song, genreMixSongs); }}>
|
||||||
|
<Play size={14} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
<div className="track-info"><span className="track-title" data-tooltip={song.title}>{song.title}</span></div>
|
||||||
|
<div className="track-artist-cell"><span className="track-artist">{song.artist}</span></div>
|
||||||
|
<div className="track-info"><span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span></div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.genre ?? '—'}</div>
|
||||||
|
<span className="track-duration" style={{ textAlign: 'right' }}>{formatDuration(song.duration)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedSuperGenre && (loading && songs.length === 0 ? (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||||
<div className="spinner" />
|
<div className="spinner" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="tracklist">
|
<div className="tracklist">
|
||||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}>
|
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}>
|
||||||
<span></span>
|
<span></span>
|
||||||
<span>{t('randomMix.trackTitle')}</span>
|
<span>{t('randomMix.trackTitle')}</span>
|
||||||
<span>{t('randomMix.trackArtist')}</span>
|
<span>{t('randomMix.trackArtist')}</span>
|
||||||
<span>{t('randomMix.trackAlbum')}</span>
|
<span>{t('randomMix.trackAlbum')}</span>
|
||||||
|
<span data-tooltip={t('randomMix.genreClickHint')} data-tooltip-wrap style={{ cursor: 'help' }}>
|
||||||
|
{t('randomMix.trackGenre')} <span style={{ color: 'var(--accent)', fontWeight: 700, fontSize: 13 }}>ⓘ</span>
|
||||||
|
</span>
|
||||||
<span style={{ textAlign: 'center' }}>{t('randomMix.trackFavorite')}</span>
|
<span style={{ textAlign: 'center' }}>{t('randomMix.trackFavorite')}</span>
|
||||||
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
|
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{songs.map((song) => (
|
{filteredSongs.map((song) => (
|
||||||
<div
|
<div
|
||||||
key={song.id}
|
key={song.id}
|
||||||
className="track-row"
|
className="track-row"
|
||||||
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}
|
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
|
||||||
onDoubleClick={() => playTrack(song, songs)}
|
onDoubleClick={() => playTrack(song, filteredSongs)}
|
||||||
role="row"
|
role="row"
|
||||||
draggable
|
draggable
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
@@ -108,7 +402,7 @@ export default function RandomMix() {
|
|||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{ padding: 4 }}
|
style={{ padding: 4 }}
|
||||||
onClick={(e) => { e.stopPropagation(); playTrack(song, songs); }}
|
onClick={(e) => { e.stopPropagation(); playTrack(song, filteredSongs); }}
|
||||||
data-tooltip={t('randomMix.play')}
|
data-tooltip={t('randomMix.play')}
|
||||||
>
|
>
|
||||||
<Play size={14} fill="currentColor" />
|
<Play size={14} fill="currentColor" />
|
||||||
@@ -126,6 +420,46 @@ export default function RandomMix() {
|
|||||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }} data-tooltip={song.album}>{song.album}</span>
|
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }} data-tooltip={song.album}>{song.album}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const genre = song.genre;
|
||||||
|
if (!genre) return <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>—</div>;
|
||||||
|
const isBlocked = AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) ||
|
||||||
|
customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()));
|
||||||
|
const justAdded = addedGenre === genre;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: isBlocked ? 'color-mix(in srgb, var(--danger) 15%, transparent)' : justAdded ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'var(--bg-hover)',
|
||||||
|
color: isBlocked ? 'var(--danger)' : justAdded ? 'var(--accent)' : 'var(--text-muted)',
|
||||||
|
border: 'none',
|
||||||
|
cursor: isBlocked ? 'default' : 'pointer',
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
height: 'auto',
|
||||||
|
minHeight: 'unset',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (isBlocked) return;
|
||||||
|
const already = customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()));
|
||||||
|
if (!already) {
|
||||||
|
setCustomGenreBlacklist([...customGenreBlacklist, genre]);
|
||||||
|
setAddedGenre(genre);
|
||||||
|
setTimeout(() => setAddedGenre(null), 1500);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-tooltip={isBlocked ? t('randomMix.genreBlocked') : justAdded ? t('randomMix.genreAddedToBlacklist') : genre}
|
||||||
|
>
|
||||||
|
{genre}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
@@ -143,7 +477,8 @@ export default function RandomMix() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-1
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink
|
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||||
import { useAuthStore, ServerProfile } from '../store/authStore';
|
import { useAuthStore, ServerProfile } from '../store/authStore';
|
||||||
@@ -10,6 +10,8 @@ import { pingWithCredentials } from '../api/subsonic';
|
|||||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
|
||||||
|
|
||||||
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
|
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [form, setForm] = useState({ name: '', url: '', username: '', password: '' });
|
const [form, setForm] = useState({ name: '', url: '', username: '', password: '' });
|
||||||
@@ -76,6 +78,7 @@ export default function Settings() {
|
|||||||
|
|
||||||
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
|
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [newGenre, setNewGenre] = useState('');
|
||||||
|
|
||||||
const testConnection = async (server: ServerProfile) => {
|
const testConnection = async (server: ServerProfile) => {
|
||||||
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
||||||
@@ -353,6 +356,94 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Random Mix */}
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<Shuffle size={18} />
|
||||||
|
<h2>{t('settings.randomMixTitle')}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="settings-card">
|
||||||
|
{/* Description */}
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||||
|
{t('settings.randomMixBlacklistDesc')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Custom blacklist chips */}
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem' }}>{t('settings.randomMixBlacklistTitle')}</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.75rem', minHeight: 32 }}>
|
||||||
|
{auth.customGenreBlacklist.length === 0 ? (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)', alignSelf: 'center' }}>{t('settings.randomMixBlacklistEmpty')}</span>
|
||||||
|
) : (
|
||||||
|
auth.customGenreBlacklist.map(genre => (
|
||||||
|
<span key={genre} style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
|
||||||
|
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '2px 8px', fontSize: 12, fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
{genre}
|
||||||
|
<button
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 14 }}
|
||||||
|
onClick={() => auth.setCustomGenreBlacklist(auth.customGenreBlacklist.filter(g => g !== genre))}
|
||||||
|
aria-label={`Remove ${genre}`}
|
||||||
|
>×</button>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add input */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', maxWidth: 400 }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
value={newGenre}
|
||||||
|
onChange={e => setNewGenre(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' && newGenre.trim()) {
|
||||||
|
const trimmed = newGenre.trim();
|
||||||
|
if (!auth.customGenreBlacklist.includes(trimmed)) {
|
||||||
|
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||||||
|
}
|
||||||
|
setNewGenre('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('settings.randomMixBlacklistPlaceholder')}
|
||||||
|
style={{ fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const trimmed = newGenre.trim();
|
||||||
|
if (trimmed && !auth.customGenreBlacklist.includes(trimmed)) {
|
||||||
|
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||||||
|
}
|
||||||
|
setNewGenre('');
|
||||||
|
}}
|
||||||
|
disabled={!newGenre.trim()}
|
||||||
|
>
|
||||||
|
{t('settings.randomMixBlacklistAdd')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider" style={{ margin: '1rem 0' }} />
|
||||||
|
|
||||||
|
{/* Hardcoded list */}
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||||
|
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
|
||||||
|
<span key={genre} style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center',
|
||||||
|
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||||
|
borderRadius: 'var(--radius-sm)', padding: '2px 8px', fontSize: 12,
|
||||||
|
}}>
|
||||||
|
{genre}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={handleLogout} id="settings-logout-btn">
|
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={handleLogout} id="settings-logout-btn">
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface AuthState {
|
|||||||
scrobblingEnabled: boolean;
|
scrobblingEnabled: boolean;
|
||||||
maxCacheMb: number;
|
maxCacheMb: number;
|
||||||
downloadFolder: string;
|
downloadFolder: string;
|
||||||
|
excludeAudiobooks: boolean;
|
||||||
|
customGenreBlacklist: string[];
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
@@ -44,6 +46,8 @@ interface AuthState {
|
|||||||
setScrobblingEnabled: (v: boolean) => void;
|
setScrobblingEnabled: (v: boolean) => void;
|
||||||
setMaxCacheMb: (v: number) => void;
|
setMaxCacheMb: (v: number) => void;
|
||||||
setDownloadFolder: (v: string) => void;
|
setDownloadFolder: (v: string) => void;
|
||||||
|
setExcludeAudiobooks: (v: boolean) => void;
|
||||||
|
setCustomGenreBlacklist: (v: string[]) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
|
||||||
// Derived
|
// Derived
|
||||||
@@ -68,6 +72,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
scrobblingEnabled: true,
|
scrobblingEnabled: true,
|
||||||
maxCacheMb: 500,
|
maxCacheMb: 500,
|
||||||
downloadFolder: '',
|
downloadFolder: '',
|
||||||
|
excludeAudiobooks: false,
|
||||||
|
customGenreBlacklist: [],
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
isConnecting: false,
|
isConnecting: false,
|
||||||
connectionError: null,
|
connectionError: null,
|
||||||
@@ -109,6 +115,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
|
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
|
||||||
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
|
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
|
||||||
setDownloadFolder: (v) => set({ downloadFolder: v }),
|
setDownloadFolder: (v) => set({ downloadFolder: v }),
|
||||||
|
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
|
||||||
|
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
|
||||||
|
|
||||||
logout: () => set({ isLoggedIn: false }),
|
logout: () => set({ isLoggedIn: false }),
|
||||||
|
|
||||||
|
|||||||
+170
-167
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import { Howl } from 'howler';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { buildStreamUrl, getPlayQueue, savePlayQueue, SubsonicSong, reportNowPlaying, scrobbleSong } from '../api/subsonic';
|
import { buildStreamUrl, getPlayQueue, savePlayQueue, SubsonicSong, reportNowPlaying, scrobbleSong } from '../api/subsonic';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
|
|
||||||
@@ -26,10 +27,9 @@ interface PlayerState {
|
|||||||
queueIndex: number;
|
queueIndex: number;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
progress: number; // 0–1
|
progress: number; // 0–1
|
||||||
buffered: number; // 0–1
|
buffered: number; // 0–1 (unused in Rust backend, kept for UI compat)
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
howl: Howl | null;
|
|
||||||
scrobbled: boolean;
|
scrobbled: boolean;
|
||||||
|
|
||||||
playTrack: (track: Track, queue?: Track[]) => void;
|
playTrack: (track: Track, queue?: Track[]) => void;
|
||||||
@@ -56,6 +56,7 @@ interface PlayerState {
|
|||||||
|
|
||||||
reorderQueue: (startIndex: number, endIndex: number) => void;
|
reorderQueue: (startIndex: number, endIndex: number) => void;
|
||||||
removeTrack: (index: number) => void;
|
removeTrack: (index: number) => void;
|
||||||
|
shuffleQueue: () => void;
|
||||||
|
|
||||||
initializeFromServerQueue: () => Promise<void>;
|
initializeFromServerQueue: () => Promise<void>;
|
||||||
|
|
||||||
@@ -72,39 +73,22 @@ interface PlayerState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Module-level playback primitives ─────────────────────────────────────────
|
// ─── Module-level playback primitives ─────────────────────────────────────────
|
||||||
//
|
|
||||||
// Kept outside Zustand to avoid stale-closure / React re-render races.
|
|
||||||
//
|
|
||||||
// activeHowl – the one and only live Howl; all event handlers reference this.
|
|
||||||
// playGeneration – monotonically incremented on every playTrack() call.
|
|
||||||
// Every Howl event callback captures its own `gen` value at creation time
|
|
||||||
// and bails out immediately if playGeneration has moved on. This prevents
|
|
||||||
// stale onend / onplay callbacks from a superseded Howl from affecting state.
|
|
||||||
|
|
||||||
let activeHowl: Howl | null = null;
|
// isAudioPaused — true when the Rust audio engine has a loaded-but-paused track.
|
||||||
|
// Used by resume() to decide between audio_resume (warm) vs audio_play (cold start).
|
||||||
|
let isAudioPaused = false;
|
||||||
|
|
||||||
|
// JS-side generation counter. Incremented on every playTrack() call.
|
||||||
|
// The invoke().catch() error handler captures its own gen and bails if
|
||||||
|
// playGeneration has moved on, preventing stale errors from skipping wrong tracks.
|
||||||
let playGeneration = 0;
|
let playGeneration = 0;
|
||||||
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
// Debounce timer for seek slider drags.
|
||||||
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
let resumeFromTime: number | null = null; // cold-start resume position (app relaunch)
|
|
||||||
let lastSeekAt = 0; // timestamp (ms) of the most recent seek — used to ignore spurious 'ended' events
|
|
||||||
let togglePlayLock = false; // prevents rapid double-click from sending pause→play before GStreamer settles
|
|
||||||
|
|
||||||
function clearProgress() {
|
// Guard against rapid double-click play/pause sending two state transitions
|
||||||
if (progressInterval) {
|
// to the Rust backend before it has finished the previous one.
|
||||||
clearInterval(progressInterval);
|
let togglePlayLock = false;
|
||||||
progressInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove all Howler-level listeners BEFORE stopping/unloading.
|
|
||||||
// This is the critical step that prevents stale `onend` callbacks from firing
|
|
||||||
// on a superseded Howl and triggering an unwanted next() / skip.
|
|
||||||
function destroyHowl(howl: Howl | null) {
|
|
||||||
if (!howl) return;
|
|
||||||
howl.off(); // remove all Howler event listeners
|
|
||||||
howl.stop(); // stop any playing sound
|
|
||||||
howl.unload(); // release the <audio> element and all resources
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Server queue sync ─────────────────────────────────────────────────────────
|
// ─── Server queue sync ─────────────────────────────────────────────────────────
|
||||||
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -119,6 +103,76 @@ function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTi
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Audio event handlers (called from initAudioListeners) ───────────────────
|
||||||
|
|
||||||
|
function handleAudioPlaying(_duration: number) {
|
||||||
|
usePlayerStore.setState({ isPlaying: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAudioProgress(current_time: number, duration: number) {
|
||||||
|
const store = usePlayerStore.getState();
|
||||||
|
const track = store.currentTrack;
|
||||||
|
if (!track) return;
|
||||||
|
const dur = duration > 0 ? duration : track.duration;
|
||||||
|
if (dur <= 0) return;
|
||||||
|
const progress = current_time / dur;
|
||||||
|
usePlayerStore.setState({ currentTime: current_time, progress, buffered: 0 });
|
||||||
|
|
||||||
|
// Scrobble at 50%
|
||||||
|
if (progress >= 0.5 && !store.scrobbled) {
|
||||||
|
usePlayerStore.setState({ scrobbled: true });
|
||||||
|
const { scrobblingEnabled } = useAuthStore.getState();
|
||||||
|
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAudioEnded() {
|
||||||
|
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
|
||||||
|
isAudioPaused = false;
|
||||||
|
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (repeatMode === 'one' && currentTrack) {
|
||||||
|
usePlayerStore.getState().playTrack(currentTrack, queue);
|
||||||
|
} else {
|
||||||
|
usePlayerStore.getState().next();
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAudioError(message: string) {
|
||||||
|
console.error('[psysonic] Audio error from backend:', message);
|
||||||
|
isAudioPaused = false;
|
||||||
|
const gen = playGeneration;
|
||||||
|
usePlayerStore.setState({ isPlaying: false });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (playGeneration !== gen) return;
|
||||||
|
usePlayerStore.getState().next();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up Tauri event listeners for the Rust audio engine.
|
||||||
|
* Returns a cleanup function — pass it to useEffect's return value so that
|
||||||
|
* React StrictMode (which double-invokes effects in dev) tears down the first
|
||||||
|
* set of listeners before creating the second, avoiding duplicate handlers.
|
||||||
|
*/
|
||||||
|
export function initAudioListeners(): () => void {
|
||||||
|
const pending = [
|
||||||
|
listen<number>('audio:playing', ({ payload }) => handleAudioPlaying(payload)),
|
||||||
|
listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) =>
|
||||||
|
handleAudioProgress(payload.current_time, payload.duration)
|
||||||
|
),
|
||||||
|
listen<void>('audio:ended', () => handleAudioEnded()),
|
||||||
|
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||||
|
];
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
pending.forEach(p => p.then(unlisten => unlisten()));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Store ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const usePlayerStore = create<PlayerState>()(
|
export const usePlayerStore = create<PlayerState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
@@ -130,7 +184,6 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
buffered: 0,
|
buffered: 0,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
volume: 0.8,
|
volume: 0.8,
|
||||||
howl: null,
|
|
||||||
scrobbled: false,
|
scrobbled: false,
|
||||||
isQueueVisible: true,
|
isQueueVisible: true,
|
||||||
isFullscreenOpen: false,
|
isFullscreenOpen: false,
|
||||||
@@ -154,155 +207,92 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
|
|
||||||
// ── stop ────────────────────────────────────────────────────────────────
|
// ── stop ────────────────────────────────────────────────────────────────
|
||||||
stop: () => {
|
stop: () => {
|
||||||
destroyHowl(activeHowl);
|
invoke('audio_stop').catch(console.error);
|
||||||
activeHowl = null;
|
isAudioPaused = false;
|
||||||
clearProgress();
|
|
||||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
||||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
|
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── playTrack ────────────────────────────────────────────────────────────
|
// ── playTrack ────────────────────────────────────────────────────────────
|
||||||
playTrack: (track, queue) => {
|
playTrack: (track, queue) => {
|
||||||
// Claim a new generation. Every callback created below captures `gen`.
|
|
||||||
// If playTrack() is called again before these callbacks fire, gen will
|
|
||||||
// no longer match playGeneration and the callbacks silently return.
|
|
||||||
const gen = ++playGeneration;
|
const gen = ++playGeneration;
|
||||||
|
isAudioPaused = false;
|
||||||
// Fully destroy the previous Howl — listeners first, then audio resources.
|
|
||||||
destroyHowl(activeHowl);
|
|
||||||
activeHowl = null;
|
|
||||||
clearProgress();
|
|
||||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
||||||
|
|
||||||
const state = get();
|
const state = get();
|
||||||
const newQueue = queue ?? state.queue;
|
const newQueue = queue ?? state.queue;
|
||||||
const idx = newQueue.findIndex(t => t.id === track.id);
|
const idx = newQueue.findIndex(t => t.id === track.id);
|
||||||
|
|
||||||
const howl = new Howl({
|
// Set state immediately so the UI updates before the download completes.
|
||||||
src: [buildStreamUrl(track.id)],
|
|
||||||
html5: true,
|
|
||||||
volume: state.volume,
|
|
||||||
});
|
|
||||||
activeHowl = howl;
|
|
||||||
|
|
||||||
// Commit state BEFORE howl.play() so queueIndex / currentTrack are
|
|
||||||
// already correct when the onplay / onend callbacks fire.
|
|
||||||
set({
|
set({
|
||||||
currentTrack: track,
|
currentTrack: track,
|
||||||
queue: newQueue,
|
queue: newQueue,
|
||||||
queueIndex: idx >= 0 ? idx : 0,
|
queueIndex: idx >= 0 ? idx : 0,
|
||||||
howl,
|
|
||||||
progress: 0,
|
progress: 0,
|
||||||
buffered: 0,
|
buffered: 0,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
scrobbled: false,
|
scrobbled: false,
|
||||||
|
isPlaying: true, // optimistic — reverted on error
|
||||||
});
|
});
|
||||||
|
|
||||||
howl.on('play', () => {
|
const url = buildStreamUrl(track.id);
|
||||||
|
invoke('audio_play', {
|
||||||
|
url,
|
||||||
|
volume: state.volume,
|
||||||
|
durationHint: track.duration,
|
||||||
|
}).catch((err: unknown) => {
|
||||||
if (playGeneration !== gen) return;
|
if (playGeneration !== gen) return;
|
||||||
set({ isPlaying: true });
|
console.error('[psysonic] audio_play failed:', err);
|
||||||
reportNowPlaying(track.id);
|
set({ isPlaying: false });
|
||||||
|
setTimeout(() => {
|
||||||
// Cold-start resume: seek to the position that was saved before the
|
if (playGeneration !== gen) return;
|
||||||
// app was closed. A short delay lets the audio pipeline stabilise.
|
get().next();
|
||||||
if (resumeFromTime !== null) {
|
|
||||||
const t = resumeFromTime;
|
|
||||||
resumeFromTime = null;
|
|
||||||
setTimeout(() => {
|
|
||||||
if (playGeneration === gen) activeHowl?.seek(t);
|
|
||||||
}, 80);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearProgress(); // guard against duplicate onplay
|
|
||||||
progressInterval = setInterval(() => {
|
|
||||||
// Bail out if this interval belongs to a superseded generation
|
|
||||||
if (playGeneration !== gen) { clearProgress(); return; }
|
|
||||||
const h = activeHowl;
|
|
||||||
if (!h) return;
|
|
||||||
|
|
||||||
const raw = h.seek();
|
|
||||||
const cur = typeof raw === 'number' ? raw : 0;
|
|
||||||
const dur = h.duration() || 1;
|
|
||||||
|
|
||||||
// Buffered indicator via underlying <audio> element
|
|
||||||
const audioNode = (h as any)._sounds?.[0]?._node as HTMLAudioElement | undefined;
|
|
||||||
if (audioNode?.buffered && audioNode.duration > 0) {
|
|
||||||
let totalBuf = 0;
|
|
||||||
for (let i = 0; i < audioNode.buffered.length; i++) {
|
|
||||||
totalBuf += audioNode.buffered.end(i) - audioNode.buffered.start(i);
|
|
||||||
}
|
|
||||||
set({ currentTime: cur, progress: cur / dur, buffered: Math.min(1, totalBuf / audioNode.duration) });
|
|
||||||
} else {
|
|
||||||
set({ currentTime: cur, progress: cur / dur });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scrobble at 50%
|
|
||||||
if (cur / dur >= 0.5 && !get().scrobbled) {
|
|
||||||
set({ scrobbled: true });
|
|
||||||
const { scrobblingEnabled } = useAuthStore.getState();
|
|
||||||
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
|
|
||||||
}
|
|
||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
howl.on('end', () => {
|
reportNowPlaying(track.id);
|
||||||
if (playGeneration !== gen) return;
|
|
||||||
// WebKit (and GStreamer on Linux) can fire spurious 'ended' events
|
|
||||||
// immediately after a direct audioNode.currentTime seek. Guard: if we
|
|
||||||
// are within 1 s of the last seek AND the playhead is not actually near
|
|
||||||
// the track end, treat this as a false alarm and ignore it.
|
|
||||||
if (Date.now() - lastSeekAt < 1000) {
|
|
||||||
const audioNode = (activeHowl as any)?._sounds?.[0]?._node as HTMLAudioElement | undefined;
|
|
||||||
const pos = audioNode ? audioNode.currentTime : (typeof activeHowl?.seek() === 'number' ? activeHowl.seek() as number : 0);
|
|
||||||
const dur = activeHowl?.duration() ?? 0;
|
|
||||||
if (dur > 0 && pos < dur - 1) return;
|
|
||||||
}
|
|
||||||
clearProgress();
|
|
||||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
|
||||||
const { repeatMode, currentTrack, queue: q } = get();
|
|
||||||
if (repeatMode === 'one' && currentTrack) {
|
|
||||||
get().playTrack(currentTrack, q);
|
|
||||||
} else {
|
|
||||||
get().next();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
howl.on('playerror', (_, err) => {
|
|
||||||
if (playGeneration !== gen) return;
|
|
||||||
console.error('Howl play error:', err);
|
|
||||||
clearProgress();
|
|
||||||
set({ isPlaying: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
howl.play();
|
|
||||||
syncQueueToServer(newQueue, track, 0);
|
syncQueueToServer(newQueue, track, 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── pause / resume / togglePlay ──────────────────────────────────────────
|
// ── pause / resume / togglePlay ──────────────────────────────────────────
|
||||||
pause: () => {
|
pause: () => {
|
||||||
activeHowl?.pause();
|
invoke('audio_pause').catch(console.error);
|
||||||
clearProgress();
|
isAudioPaused = true;
|
||||||
set({ isPlaying: false });
|
set({ isPlaying: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
resume: () => {
|
resume: () => {
|
||||||
const { currentTrack, queue, currentTime } = get();
|
const { currentTrack, queue, currentTime } = get();
|
||||||
if (!currentTrack) return;
|
if (!currentTrack) return;
|
||||||
if (activeHowl) {
|
|
||||||
activeHowl.play();
|
if (isAudioPaused) {
|
||||||
|
// Rust engine has audio loaded but paused — just resume it.
|
||||||
|
invoke('audio_resume').catch(console.error);
|
||||||
|
isAudioPaused = false;
|
||||||
set({ isPlaying: true });
|
set({ isPlaying: true });
|
||||||
return;
|
} else {
|
||||||
|
// Cold start (app relaunch) — audio is not loaded in Rust; re-download.
|
||||||
|
const gen = ++playGeneration;
|
||||||
|
const vol = get().volume;
|
||||||
|
set({ isPlaying: true });
|
||||||
|
invoke('audio_play', {
|
||||||
|
url: buildStreamUrl(currentTrack.id),
|
||||||
|
volume: vol,
|
||||||
|
durationHint: currentTrack.duration,
|
||||||
|
}).then(() => {
|
||||||
|
if (playGeneration === gen && currentTime > 1) {
|
||||||
|
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
|
||||||
|
}
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
if (playGeneration !== gen) return;
|
||||||
|
console.error('[psysonic] audio_play (cold resume) failed:', err);
|
||||||
|
set({ isPlaying: false });
|
||||||
|
});
|
||||||
|
syncQueueToServer(queue, currentTrack, currentTime);
|
||||||
}
|
}
|
||||||
// Cold start after app relaunch — Howl was not persisted.
|
|
||||||
resumeFromTime = currentTime > 0 ? currentTime : null;
|
|
||||||
get().playTrack(currentTrack, queue);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
togglePlay: () => {
|
togglePlay: () => {
|
||||||
// Guard: rapid double-clicks send pause→play (or play→pause) before
|
|
||||||
// GStreamer/WebKit has finished the previous state transition, causing
|
|
||||||
// the audio pipeline to hang for several seconds. Ignore the second
|
|
||||||
// click if it arrives within 300 ms of the first.
|
|
||||||
if (togglePlayLock) return;
|
if (togglePlayLock) return;
|
||||||
togglePlayLock = true;
|
togglePlayLock = true;
|
||||||
setTimeout(() => { togglePlayLock = false; }, 300);
|
setTimeout(() => { togglePlayLock = false; }, 300);
|
||||||
@@ -319,18 +309,17 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
} else if (repeatMode === 'all' && queue.length > 0) {
|
} else if (repeatMode === 'all' && queue.length > 0) {
|
||||||
get().playTrack(queue[0], queue);
|
get().playTrack(queue[0], queue);
|
||||||
} else {
|
} else {
|
||||||
// End of queue — clean stop without destroying currentTrack metadata
|
invoke('audio_stop').catch(console.error);
|
||||||
destroyHowl(activeHowl);
|
isAudioPaused = false;
|
||||||
activeHowl = null;
|
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||||
clearProgress();
|
|
||||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
previous: () => {
|
previous: () => {
|
||||||
const { queue, queueIndex, currentTime } = get();
|
const { queue, queueIndex, currentTime } = get();
|
||||||
if (currentTime > 3) {
|
if (currentTime > 3) {
|
||||||
activeHowl?.seek(0);
|
// Restart current track from the beginning.
|
||||||
|
invoke('audio_seek', { seconds: 0 }).catch(console.error);
|
||||||
set({ progress: 0, currentTime: 0 });
|
set({ progress: 0, currentTime: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -339,36 +328,25 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── seek ─────────────────────────────────────────────────────────────────
|
// ── seek ─────────────────────────────────────────────────────────────────
|
||||||
// Debounced 100 ms to collapse rapid slider drags into one actual seek.
|
// 100 ms debounce collapses rapid slider drags into one actual seek.
|
||||||
// We bypass Howler's seek() entirely and set currentTime directly on the
|
|
||||||
// underlying <audio> element. Howler's seek() internally calls pause() +
|
|
||||||
// play() which can fire spurious ended/stop events on some WebKit versions,
|
|
||||||
// especially on the second consecutive seek.
|
|
||||||
seek: (progress) => {
|
seek: (progress) => {
|
||||||
const { currentTrack } = get();
|
const { currentTrack } = get();
|
||||||
if (!activeHowl || !currentTrack) return;
|
if (!currentTrack) return;
|
||||||
const dur = activeHowl.duration() || currentTrack.duration;
|
const dur = currentTrack.duration;
|
||||||
if (!dur || !isFinite(dur)) return;
|
if (!dur || !isFinite(dur)) return;
|
||||||
// Clamp slightly before end to prevent accidentally triggering 'ended'
|
|
||||||
const time = Math.max(0, Math.min(progress * dur, dur - 0.25));
|
const time = Math.max(0, Math.min(progress * dur, dur - 0.25));
|
||||||
set({ progress: time / dur, currentTime: time });
|
set({ progress: time / dur, currentTime: time });
|
||||||
lastSeekAt = Date.now();
|
|
||||||
if (seekDebounce) clearTimeout(seekDebounce);
|
if (seekDebounce) clearTimeout(seekDebounce);
|
||||||
seekDebounce = setTimeout(() => {
|
seekDebounce = setTimeout(() => {
|
||||||
seekDebounce = null;
|
seekDebounce = null;
|
||||||
const audioNode = (activeHowl as any)?._sounds?.[0]?._node as HTMLAudioElement | undefined;
|
invoke('audio_seek', { seconds: time }).catch(console.error);
|
||||||
if (audioNode && isFinite(time)) {
|
|
||||||
audioNode.currentTime = time;
|
|
||||||
} else {
|
|
||||||
activeHowl?.seek(time);
|
|
||||||
}
|
|
||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── volume ───────────────────────────────────────────────────────────────
|
// ── volume ───────────────────────────────────────────────────────────────
|
||||||
setVolume: (v) => {
|
setVolume: (v) => {
|
||||||
const clamped = Math.max(0, Math.min(1, v));
|
const clamped = Math.max(0, Math.min(1, v));
|
||||||
activeHowl?.volume(clamped);
|
invoke('audio_set_volume', { volume: clamped }).catch(console.error);
|
||||||
set({ volume: clamped });
|
set({ volume: clamped });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -386,11 +364,10 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearQueue: () => {
|
clearQueue: () => {
|
||||||
destroyHowl(activeHowl);
|
invoke('audio_stop').catch(console.error);
|
||||||
activeHowl = null;
|
isAudioPaused = false;
|
||||||
clearProgress();
|
|
||||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
||||||
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
|
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||||
syncQueueToServer([], null, 0);
|
syncQueueToServer([], null, 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -405,6 +382,23 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
syncQueueToServer(result, currentTrack, get().currentTime);
|
syncQueueToServer(result, currentTrack, get().currentTime);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
shuffleQueue: () => {
|
||||||
|
const { queue, currentTrack } = get();
|
||||||
|
if (queue.length < 2) return;
|
||||||
|
const currentIdx = currentTrack ? queue.findIndex(t => t.id === currentTrack.id) : -1;
|
||||||
|
const others = queue.filter((_, i) => i !== currentIdx);
|
||||||
|
for (let i = others.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[others[i], others[j]] = [others[j], others[i]];
|
||||||
|
}
|
||||||
|
const result = currentIdx >= 0
|
||||||
|
? [queue[currentIdx], ...others]
|
||||||
|
: others;
|
||||||
|
const newIndex = currentIdx >= 0 ? 0 : -1;
|
||||||
|
set({ queue: result, queueIndex: Math.max(0, newIndex) });
|
||||||
|
syncQueueToServer(result, currentTrack, get().currentTime);
|
||||||
|
},
|
||||||
|
|
||||||
removeTrack: (index) => {
|
removeTrack: (index) => {
|
||||||
const { queue, queueIndex } = get();
|
const { queue, queueIndex } = get();
|
||||||
const newQueue = [...queue];
|
const newQueue = [...queue];
|
||||||
@@ -433,11 +427,16 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; }
|
if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer the server position if available; otherwise keep the
|
||||||
|
// localStorage-persisted currentTime (more reliable than server
|
||||||
|
// queue position, which may not flush before app close).
|
||||||
|
const serverTime = q.position ? q.position / 1000 : 0;
|
||||||
|
const localTime = get().currentTime;
|
||||||
set({
|
set({
|
||||||
queue: mappedTracks,
|
queue: mappedTracks,
|
||||||
queueIndex,
|
queueIndex,
|
||||||
currentTrack,
|
currentTrack,
|
||||||
currentTime: q.position ? q.position / 1000 : 0,
|
currentTime: serverTime > 0 ? serverTime : localTime,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -451,6 +450,10 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
volume: state.volume,
|
volume: state.volume,
|
||||||
repeatMode: state.repeatMode,
|
repeatMode: state.repeatMode,
|
||||||
|
currentTrack: state.currentTrack,
|
||||||
|
queue: state.queue,
|
||||||
|
queueIndex: state.queueIndex,
|
||||||
|
currentTime: state.currentTime,
|
||||||
} as Partial<PlayerState>),
|
} as Partial<PlayerState>),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -438,7 +438,8 @@
|
|||||||
transition: background var(--transition-fast);
|
transition: background var(--transition-fast);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
.search-result-item:hover { background: var(--bg-hover); }
|
.search-result-item:hover,
|
||||||
|
.search-result-item.active { background: var(--bg-hover); }
|
||||||
.search-result-icon { width: 32px; height: 32px; border-radius: var(--radius-sm); background: var(--bg-hover); display: flex; align-items: center; justify-content: center; color: var(--text-muted); flex-shrink: 0; }
|
.search-result-icon { width: 32px; height: 32px; border-radius: var(--radius-sm); background: var(--bg-hover); display: flex; align-items: center; justify-content: center; color: var(--text-muted); flex-shrink: 0; }
|
||||||
.search-result-thumb { width: 32px; height: 32px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; }
|
.search-result-thumb { width: 32px; height: 32px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; }
|
||||||
.search-result-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
.search-result-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||||
@@ -1415,6 +1416,12 @@
|
|||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-tooltip-wrap]::after {
|
||||||
|
white-space: pre-line;
|
||||||
|
max-width: 220px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
/* Modifiers for position */
|
/* Modifiers for position */
|
||||||
[data-tooltip][data-tooltip-pos="bottom"]::before {
|
[data-tooltip][data-tooltip-pos="bottom"]::before {
|
||||||
top: 100%;
|
top: 100%;
|
||||||
|
|||||||
@@ -235,6 +235,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: var(--bg-app);
|
background: var(--bg-app);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-header {
|
.content-header {
|
||||||
|
|||||||
Reference in New Issue
Block a user