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:
Psychotoxical
2026-03-15 12:06:03 +01:00
parent 9b1f3b019f
commit 744775d3a1
20 changed files with 2037 additions and 312 deletions
+55
View File
@@ -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/),
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
### Fixed
+2 -17
View File
@@ -1,12 +1,12 @@
{
"name": "psysonic",
"version": "0.1.0",
"version": "1.0.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "0.1.0",
"version": "1.0.12",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
@@ -16,7 +16,6 @@
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2",
"axios": "^1.7.7",
"howler": "^2.2.4",
"i18next": "^25.8.16",
"lucide-react": "^0.462.0",
"md5": "^2.3.0",
@@ -28,7 +27,6 @@
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/howler": "^2.2.12",
"@types/md5": "^2.3.5",
"@types/node": "^25.3.5",
"@types/react": "^18.3.11",
@@ -1574,13 +1572,6 @@
"dev": true,
"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": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.6.tgz",
@@ -2110,12 +2101,6 @@
"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": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+1 -3
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.0.12",
"version": "1.2.0",
"private": true,
"scripts": {
"dev": "vite",
@@ -19,7 +19,6 @@
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2",
"axios": "^1.7.7",
"howler": "^2.2.4",
"i18next": "^25.8.16",
"lucide-react": "^0.462.0",
"md5": "^2.3.0",
@@ -31,7 +30,6 @@
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/howler": "^2.2.12",
"@types/md5": "^2.3.5",
"@types/node": "^25.3.5",
"@types/react": "^18.3.11",
+840 -22
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.0.12"
version = "1.2.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -30,3 +30,6 @@ tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
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"] }
+322
View File
@@ -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.51 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
View File
@@ -1,6 +1,8 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod audio;
use tauri::{
menu::{MenuBuilder, MenuItemBuilder},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
@@ -18,7 +20,10 @@ fn exit_app(app_handle: tauri::AppHandle) {
}
pub fn run() {
let (audio_engine, _audio_thread) = audio::create_engine();
tauri::Builder::default()
.manage(audio_engine)
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
@@ -107,7 +112,16 @@ pub fn run() {
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!())
.expect("error while running Psysonic");
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.0.12",
"version": "1.2.0",
"identifier": "dev.psysonic.app",
"build": {
"beforeDevCommand": "npm run dev",
+6 -2
View File
@@ -27,7 +27,7 @@ import SearchResults from './pages/SearchResults';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
import { useAuthStore } from './store/authStore';
import { usePlayerStore } from './store/playerStore';
import { usePlayerStore, initAudioListeners } from './store/playerStore';
import { useThemeStore } from './store/themeStore';
function RequireAuth({ children }: { children: React.ReactNode }) {
@@ -110,7 +110,7 @@ function AppShell() {
<div
className="app-shell"
style={{
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(180px, 15vw, 220px)',
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)',
'--queue-width': isQueueVisible ? `${queueWidth}px` : '0px'
} as React.CSSProperties}
onContextMenu={e => e.preventDefault()}
@@ -223,6 +223,10 @@ export default function App() {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
useEffect(() => {
return initAudioListeners();
}, []);
return (
<BrowserRouter>
<TauriEventBridge />
+6 -2
View File
@@ -74,6 +74,8 @@ export interface SubsonicSong {
samplingRate?: number;
channelCount?: number;
starred?: string;
genre?: string;
path?: string;
}
export interface SubsonicPlaylist {
@@ -160,8 +162,10 @@ export async function getAlbumList(
return data.albumList2?.album ?? [];
}
export async function getRandomSongs(size = 50): Promise<SubsonicSong[]> {
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', { size });
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
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 ?? [];
}
+66 -34
View File
@@ -19,9 +19,11 @@ export default function LiveSearch() {
const [results, setResults] = useState<SearchResults | null>(null);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const navigate = useNavigate();
const playTrack = usePlayerStore(state => state.playTrack);
const ref = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const doSearch = useCallback(
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
useEffect(() => {
@@ -51,6 +53,40 @@ export default function LiveSearch() {
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 (
<div className="live-search" ref={ref} role="search">
<div className="live-search-input-wrap">
@@ -69,12 +105,7 @@ export default function LiveSearch() {
value={query}
onChange={e => setQuery(e.target.value)}
onFocus={() => results && setOpen(true)}
onKeyDown={e => {
if (e.key === 'Enter' && query.trim()) {
setOpen(false);
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
}
}}
onKeyDown={handleKeyDown}
aria-autocomplete="list"
aria-controls="search-results"
aria-expanded={open}
@@ -88,38 +119,40 @@ export default function LiveSearch() {
</div>
{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 && (
<div className="search-empty">{t('search.noResults', { query })}</div>
)}
{(() => {
let idx = 0;
return <>
{results?.artists.length ? (
<div className="search-section">
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
{results.artists.map(a => (
<button
key={a.id}
className="search-result-item"
{results.artists.map(a => {
const i = idx++;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
role="option"
>
role="option" aria-selected={activeIndex === i}>
<div className="search-result-icon"><Users size={14} /></div>
<span>{a.name}</span>
</button>
))}
);
})}
</div>
) : null}
{results?.albums.length ? (
<div className="search-section">
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
{results.albums.map(a => (
<button
key={a.id}
className="search-result-item"
{results.albums.map(a => {
const i = idx++;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
role="option"
>
role="option" aria-selected={activeIndex === i}>
{a.coverArt ? (
<img className="search-result-thumb" src={buildCoverArtUrl(a.coverArt, 40)} alt="" loading="lazy" />
) : (
@@ -130,36 +163,35 @@ export default function LiveSearch() {
<div className="search-result-sub">{a.artist}</div>
</div>
</button>
))}
);
})}
</div>
) : null}
{results?.songs.length ? (
<div className="search-section">
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
{results.songs.map(s => (
<button
key={s.id}
className="search-result-item"
{results.songs.map(s => {
const i = idx++;
return (
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
onClick={() => {
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
});
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('');
}}
role="option"
>
role="option" aria-selected={activeIndex === i}>
<div className="search-result-icon"><Music size={14} /></div>
<div>
<div className="search-result-name">{s.title}</div>
<div className="search-result-sub">{s.artist} · {s.album}</div>
</div>
</button>
))}
);
})}
</div>
) : null}
</>;
})()}
</div>
)}
</div>
+5 -1
View File
@@ -1,6 +1,6 @@
import React, { useState, useRef } from 'react';
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 { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
@@ -122,6 +122,7 @@ export default function QueuePanel() {
const clearQueue = usePlayerStore(s => s.clearQueue);
const reorderQueue = usePlayerStore(s => s.reorderQueue);
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
const enqueue = usePlayerStore(s => s.enqueue);
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>
<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')}>
<Save size={14} />
</button>
+4 -5
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { open } from '@tauri-apps/plugin-shell';
import { version as appVersion } from '../../package.json';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@@ -49,14 +50,12 @@ function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; lat
<span className="update-toast-label">{t('sidebar.updateAvailable')}</span>
</div>
<div className="update-toast-version">{t('sidebar.updateReady', { version: latestVersion })}</div>
<a
<button
className="update-toast-link"
href="https://github.com/Psychotoxical/psysonic/releases"
target="_blank"
rel="noopener noreferrer"
onClick={() => open('https://github.com/Psychotoxical/psysonic/releases')}
>
{t('sidebar.updateLink')}
</a>
</button>
</div>
);
}
+46 -4
View File
@@ -145,6 +145,19 @@ const enTranslation = {
favoriteAdd: 'Add to Favorites',
favoriteRemove: 'Remove from Favorites',
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: {
title: 'Playlists',
@@ -261,8 +274,15 @@ const enTranslation = {
aboutLicenseText: 'MIT — free to use, modify, and distribute.',
aboutRepo: 'Source Code on GitHub',
aboutVersion: 'Version',
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Howler.js',
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic'
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
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: {
title: 'Help',
@@ -328,6 +348,7 @@ const enTranslation = {
delete: 'Delete',
deleteConfirm: 'Delete playlist "{{name}}"?',
clear: 'Clear',
shuffle: 'Shuffle queue',
hide: 'Hide',
close: 'Close',
nextTracks: 'Next Tracks',
@@ -506,6 +527,19 @@ const deTranslation = {
favoriteAdd: 'Zu Favoriten hinzufügen',
favoriteRemove: 'Aus Favoriten entfernen',
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: {
title: 'Playlists',
@@ -622,8 +656,15 @@ const deTranslation = {
aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weiterzugeben.',
aboutRepo: 'Quellcode auf GitHub',
aboutVersion: 'Version',
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Howler.js',
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic'
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
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: {
title: 'Hilfe',
@@ -689,6 +730,7 @@ const deTranslation = {
delete: 'Löschen',
deleteConfirm: 'Playlist "{{name}}" löschen?',
clear: 'Leeren',
shuffle: 'Queue mischen',
hide: 'Verbergen',
close: 'Schließen',
nextTracks: 'Nächste Titel',
+346 -11
View File
@@ -1,9 +1,35 @@
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 { 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';
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 {
if (!seconds || isNaN(seconds)) return '0:00';
const m = Math.floor(seconds / 60);
@@ -18,6 +44,19 @@ export default function RandomMix() {
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
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 = () => {
setLoading(true);
@@ -34,10 +73,25 @@ export default function RandomMix() {
useEffect(() => {
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 = () => {
if (songs.length > 0) playTrack(songs[0], songs);
if (filteredSongs.length > 0) playTrack(filteredSongs[0], filteredSongs);
};
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 (
<div className="content-body animate-fade-in">
<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')}>
<RefreshCw size={18} className={loading ? 'spin' : ''} /> {t('randomMix.remix')}
</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')}
</button>
</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 className="spinner" />
</div>
) : (
<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>{t('randomMix.trackTitle')}</span>
<span>{t('randomMix.trackArtist')}</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: 'right' }}>{t('randomMix.trackDuration')}</span>
</div>
{songs.map((song) => (
{filteredSongs.map((song) => (
<div
key={song.id}
className="track-row"
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}
onDoubleClick={() => playTrack(song, songs)}
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
onDoubleClick={() => playTrack(song, filteredSongs)}
role="row"
draggable
onDragStart={e => {
@@ -108,7 +402,7 @@ export default function RandomMix() {
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={(e) => { e.stopPropagation(); playTrack(song, songs); }}
onClick={(e) => { e.stopPropagation(); playTrack(song, filteredSongs); }}
data-tooltip={t('randomMix.play')}
>
<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>
</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' }}>
<button
className="btn btn-ghost"
@@ -143,7 +477,8 @@ export default function RandomMix() {
</div>
))}
</div>
)}
))}
</div>
);
}
+92 -1
View File
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
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';
import { open as openUrl } from '@tauri-apps/plugin-shell';
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 { 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 }) {
const { t } = useTranslation();
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 [showAddForm, setShowAddForm] = useState(false);
const [newGenre, setNewGenre] = useState('');
const testConnection = async (server: ServerProfile) => {
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
@@ -353,6 +356,94 @@ export default function Settings() {
</div>
</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 */}
<section className="settings-section">
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={handleLogout} id="settings-logout-btn">
+8
View File
@@ -25,6 +25,8 @@ interface AuthState {
scrobblingEnabled: boolean;
maxCacheMb: number;
downloadFolder: string;
excludeAudiobooks: boolean;
customGenreBlacklist: string[];
// Status
isLoggedIn: boolean;
@@ -44,6 +46,8 @@ interface AuthState {
setScrobblingEnabled: (v: boolean) => void;
setMaxCacheMb: (v: number) => void;
setDownloadFolder: (v: string) => void;
setExcludeAudiobooks: (v: boolean) => void;
setCustomGenreBlacklist: (v: string[]) => void;
logout: () => void;
// Derived
@@ -68,6 +72,8 @@ export const useAuthStore = create<AuthState>()(
scrobblingEnabled: true,
maxCacheMb: 500,
downloadFolder: '',
excludeAudiobooks: false,
customGenreBlacklist: [],
isLoggedIn: false,
isConnecting: false,
connectionError: null,
@@ -109,6 +115,8 @@ export const useAuthStore = create<AuthState>()(
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
setDownloadFolder: (v) => set({ downloadFolder: v }),
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
logout: () => set({ isLoggedIn: false }),
+169 -166
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand';
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 { useAuthStore } from './authStore';
@@ -26,10 +27,9 @@ interface PlayerState {
queueIndex: number;
isPlaying: boolean;
progress: number; // 01
buffered: number; // 01
buffered: number; // 01 (unused in Rust backend, kept for UI compat)
currentTime: number;
volume: number;
howl: Howl | null;
scrobbled: boolean;
playTrack: (track: Track, queue?: Track[]) => void;
@@ -56,6 +56,7 @@ interface PlayerState {
reorderQueue: (startIndex: number, endIndex: number) => void;
removeTrack: (index: number) => void;
shuffleQueue: () => void;
initializeFromServerQueue: () => Promise<void>;
@@ -72,39 +73,22 @@ interface PlayerState {
}
// ─── 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 progressInterval: ReturnType<typeof setInterval> | null = null;
// Debounce timer for seek slider drags.
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() {
if (progressInterval) {
clearInterval(progressInterval);
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
}
// Guard against rapid double-click play/pause sending two state transitions
// to the Rust backend before it has finished the previous one.
let togglePlayLock = false;
// ─── Server queue sync ─────────────────────────────────────────────────────────
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -119,6 +103,76 @@ function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTi
}, 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>()(
persist(
(set, get) => ({
@@ -130,7 +184,6 @@ export const usePlayerStore = create<PlayerState>()(
buffered: 0,
currentTime: 0,
volume: 0.8,
howl: null,
scrobbled: false,
isQueueVisible: true,
isFullscreenOpen: false,
@@ -154,155 +207,92 @@ export const usePlayerStore = create<PlayerState>()(
// ── stop ────────────────────────────────────────────────────────────────
stop: () => {
destroyHowl(activeHowl);
activeHowl = null;
clearProgress();
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
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: (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;
// Fully destroy the previous Howl — listeners first, then audio resources.
destroyHowl(activeHowl);
activeHowl = null;
clearProgress();
isAudioPaused = false;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
const state = get();
const newQueue = queue ?? state.queue;
const idx = newQueue.findIndex(t => t.id === track.id);
const howl = new Howl({
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 state immediately so the UI updates before the download completes.
set({
currentTrack: track,
queue: newQueue,
queueIndex: idx >= 0 ? idx : 0,
howl,
progress: 0,
buffered: 0,
currentTime: 0,
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;
set({ isPlaying: true });
reportNowPlaying(track.id);
// Cold-start resume: seek to the position that was saved before the
// app was closed. A short delay lets the audio pipeline stabilise.
if (resumeFromTime !== null) {
const t = resumeFromTime;
resumeFromTime = null;
console.error('[psysonic] audio_play failed:', err);
set({ isPlaying: false });
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());
}
if (playGeneration !== gen) return;
get().next();
}, 500);
});
howl.on('end', () => {
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();
reportNowPlaying(track.id);
syncQueueToServer(newQueue, track, 0);
},
// ── pause / resume / togglePlay ──────────────────────────────────────────
pause: () => {
activeHowl?.pause();
clearProgress();
invoke('audio_pause').catch(console.error);
isAudioPaused = true;
set({ isPlaying: false });
},
resume: () => {
const { currentTrack, queue, currentTime } = get();
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 });
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: () => {
// 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;
togglePlayLock = true;
setTimeout(() => { togglePlayLock = false; }, 300);
@@ -319,18 +309,17 @@ export const usePlayerStore = create<PlayerState>()(
} else if (repeatMode === 'all' && queue.length > 0) {
get().playTrack(queue[0], queue);
} else {
// End of queue — clean stop without destroying currentTrack metadata
destroyHowl(activeHowl);
activeHowl = null;
clearProgress();
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
}
},
previous: () => {
const { queue, queueIndex, currentTime } = get();
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 });
return;
}
@@ -339,36 +328,25 @@ export const usePlayerStore = create<PlayerState>()(
},
// ── seek ─────────────────────────────────────────────────────────────────
// Debounced 100 ms to collapse 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.
// 100 ms debounce collapses rapid slider drags into one actual seek.
seek: (progress) => {
const { currentTrack } = get();
if (!activeHowl || !currentTrack) return;
const dur = activeHowl.duration() || currentTrack.duration;
if (!currentTrack) return;
const dur = currentTrack.duration;
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));
set({ progress: time / dur, currentTime: time });
lastSeekAt = Date.now();
if (seekDebounce) clearTimeout(seekDebounce);
seekDebounce = setTimeout(() => {
seekDebounce = null;
const audioNode = (activeHowl as any)?._sounds?.[0]?._node as HTMLAudioElement | undefined;
if (audioNode && isFinite(time)) {
audioNode.currentTime = time;
} else {
activeHowl?.seek(time);
}
invoke('audio_seek', { seconds: time }).catch(console.error);
}, 100);
},
// ── volume ───────────────────────────────────────────────────────────────
setVolume: (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 });
},
@@ -386,11 +364,10 @@ export const usePlayerStore = create<PlayerState>()(
},
clearQueue: () => {
destroyHowl(activeHowl);
activeHowl = null;
clearProgress();
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
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);
},
@@ -405,6 +382,23 @@ export const usePlayerStore = create<PlayerState>()(
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) => {
const { queue, queueIndex } = get();
const newQueue = [...queue];
@@ -433,11 +427,16 @@ export const usePlayerStore = create<PlayerState>()(
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({
queue: mappedTracks,
queueIndex,
currentTrack,
currentTime: q.position ? q.position / 1000 : 0,
currentTime: serverTime > 0 ? serverTime : localTime,
});
}
} catch (e) {
@@ -451,6 +450,10 @@ export const usePlayerStore = create<PlayerState>()(
partialize: (state) => ({
volume: state.volume,
repeatMode: state.repeatMode,
currentTrack: state.currentTrack,
queue: state.queue,
queueIndex: state.queueIndex,
currentTime: state.currentTime,
} as Partial<PlayerState>),
}
)
+8 -1
View File
@@ -438,7 +438,8 @@
transition: background var(--transition-fast);
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-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); }
@@ -1415,6 +1416,12 @@
visibility: visible;
}
[data-tooltip-wrap]::after {
white-space: pre-line;
max-width: 220px;
text-align: left;
}
/* Modifiers for position */
[data-tooltip][data-tooltip-pos="bottom"]::before {
top: 100%;
+1
View File
@@ -235,6 +235,7 @@
overflow: hidden;
min-width: 0;
background: var(--bg-app);
z-index: 1;
}
.content-header {