diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index a38d4f95..ffd9f792 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -2290,6 +2290,19 @@ pub struct AudioEngine { /// decode (ALSA underruns). The ranged task clears this on exit; `gen` avoids a /// late drop clearing a newer play of the same track. pub(crate) ranged_loudness_seed_hold: Arc>>, + /// Secondary sink dedicated to track previews. Runs on the same `OutputStream` + /// as the main sink (rodio mixes both internally) so we don't open a second + /// device handle — important on ALSA-exclusive hardware. + pub(crate) preview_sink: Arc>>>, + /// Cancel token for the active preview. Bumped on every `audio_preview_play` + /// and `audio_preview_stop` so that orphan timer/progress tasks bail out. + pub(crate) preview_gen: Arc, + /// True when `audio_preview_play` paused the main sink and should resume it + /// on preview end. False if the main sink was already paused (or empty). + pub(crate) preview_main_resume: Arc, + /// Subsonic song id of the currently playing preview. Echoed back in + /// `audio:preview-end` so the frontend can clear UI state for that row. + pub(crate) preview_song_id: Arc>>, } pub struct AudioCurrent { @@ -2565,6 +2578,10 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { current_playback_url: Arc::new(Mutex::new(None)), current_analysis_track_id: Arc::new(Mutex::new(None)), ranged_loudness_seed_hold: Arc::new(Mutex::new(None)), + preview_sink: Arc::new(Mutex::new(None)), + preview_gen: Arc::new(AtomicU64::new(0)), + preview_main_resume: Arc::new(AtomicBool::new(false)), + preview_song_id: Arc::new(Mutex::new(None)), }; (engine, thread) @@ -3146,6 +3163,10 @@ pub async fn audio_play( } } + // Cancel any active preview before starting fresh main playback so the + // two sinks don't end up mixed. + preview_clear_for_new_main_playback(&state, &app); + // ── Gapless pre-chain hit ───────────────────────────────────────────────── // audio_chain_preload already appended this URL to the Sink 30 s in // advance. The source is live in the queue — just return and let the @@ -4198,6 +4219,10 @@ pub fn audio_pause(state: State<'_, AudioEngine>) { /// swaps it in on the next `read()`), and a new download task is spawned. #[tauri::command] pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Result<(), String> { + // If a preview is running, cancel it first — otherwise sink.play() on the + // main sink would mix on top of the preview sink. + preview_clear_for_new_main_playback(&state, &app); + // Detect radio hard-disconnect. let reconnect_info = { let guard = state.radio_state.lock().unwrap(); @@ -4256,7 +4281,8 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu } #[tauri::command] -pub fn audio_stop(state: State<'_, AudioEngine>) { +pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) { + preview_clear_for_new_main_playback(&state, &app); state.generation.fetch_add(1, Ordering::SeqCst); *state.current_playback_url.lock().unwrap() = None; *state.current_analysis_track_id.lock().unwrap() = None; @@ -4604,6 +4630,9 @@ pub async fn audio_play_radio( ) -> Result<(), String> { let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1; + // Cancel any active preview so it doesn't keep playing alongside radio. + preview_clear_for_new_main_playback(&state, &app); + // Abort any previous radio task before stopping the sink. drop(state.radio_state.lock().unwrap().take()); @@ -5125,3 +5154,275 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) { } }); } + +// ──────────────────────────────────────────────────────────────────────────── +// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia. +// ──────────────────────────────────────────────────────────────────────────── + +#[derive(Clone, serde::Serialize)] +struct PreviewProgressPayload { + id: String, + elapsed: f64, + duration: f64, +} + +#[derive(Clone, serde::Serialize)] +struct PreviewEndPayload { + id: String, + /// "natural" = 30 s timer / source ended; "user" = explicit stop; + /// "interrupted" = a new preview superseded this one. + reason: &'static str, +} + +/// Pause main sink and remember whether to resume it after preview ends. +/// Mirrors `audio_pause` semantics so progress timestamps stay consistent. +fn preview_pause_main(state: &AudioEngine) { + let mut cur = state.current.lock().unwrap(); + if let Some(sink) = &cur.sink { + if !sink.is_paused() && !sink.empty() { + let pos = cur.position(); + sink.pause(); + cur.paused_at = Some(pos); + cur.play_started = None; + state.preview_main_resume.store(true, Ordering::Release); + } else { + state.preview_main_resume.store(false, Ordering::Release); + } + } else { + state.preview_main_resume.store(false, Ordering::Release); + } +} + +/// Cancel any active preview and clear the resume marker. Called from every +/// command that brings the main sink back to life under its own steam +/// (`audio_play`, `audio_play_radio`, `audio_resume`) — without this the +/// preview would keep playing in parallel and the watchdog would later try +/// to resume a main sink that's already running, double-mixing the audio. +fn preview_clear_for_new_main_playback(state: &AudioEngine, app: &AppHandle) { + // Order matters: clear the resume marker BEFORE bumping the generation + // so the watchdog — if it wakes between our writes — sees no work to do + // and bails without resuming main behind our back. + state.preview_main_resume.store(false, Ordering::Release); + state.preview_gen.fetch_add(1, Ordering::SeqCst); + let sink = state.preview_sink.lock().unwrap().take(); + let id = state.preview_song_id.lock().unwrap().take(); + if let Some(s) = sink { s.stop(); } + if let Some(id) = id { + app.emit("audio:preview-end", PreviewEndPayload { + id, + reason: "interrupted", + }).ok(); + } +} + +/// Resume main sink iff `preview_pause_main` paused it. No-op if main was +/// already paused/empty before preview started. +fn preview_resume_main(state: &AudioEngine) { + if !state.preview_main_resume.swap(false, Ordering::AcqRel) { + return; + } + 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; + } + } +} + +/// Format hint inferred from a Subsonic stream URL. The frontend always passes +/// a `format=flac` query param for `.opus` files (server transcodes); for +/// everything else we guess from the URL's `format=` value or fall back to None. +fn preview_format_hint_from_url(url: &str) -> Option { + url.split('?') + .nth(1)? + .split('&') + .find_map(|kv| { + let (k, v) = kv.split_once('=')?; + if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None } + }) +} + +#[tauri::command] +pub async fn audio_preview_play( + id: String, + url: String, + start_sec: f64, + duration_sec: f64, + volume: f32, + app: AppHandle, + state: State<'_, AudioEngine>, +) -> Result<(), String> { + let gen = state.preview_gen.fetch_add(1, Ordering::SeqCst) + 1; + + // Tear down any existing preview before pausing main (so a rapid preview + // swap doesn't double-pause and double-resume the main sink). + let prev_sink = state.preview_sink.lock().unwrap().take(); + let prev_id = state.preview_song_id.lock().unwrap().take(); + if let Some(s) = prev_sink { s.stop(); } + if let Some(prev) = prev_id { + app.emit("audio:preview-end", PreviewEndPayload { + id: prev, + reason: "interrupted", + }).ok(); + } + + // Pause main if and only if we don't already hold a "main was playing" + // marker from a superseded preview. swap_or-style: only pause if the flag + // is currently false. + if !state.preview_main_resume.load(Ordering::Acquire) { + preview_pause_main(&state); + } + + // ── Download ───────────────────────────────────────────────────────────── + let bytes = audio_http_client(&state) + .get(&url) + .send() + .await + .map_err(|e| format!("preview: connection failed: {e}"))? + .error_for_status() + .map_err(|e| format!("preview: HTTP {e}"))? + .bytes() + .await + .map_err(|e| format!("preview: read body: {e}"))? + .to_vec(); + + if state.preview_gen.load(Ordering::SeqCst) != gen { + // A newer preview started while we were downloading — bail. + return Ok(()); + } + + // ── Decode ─────────────────────────────────────────────────────────────── + let hint = preview_format_hint_from_url(&url); + let bytes_for_blocking = bytes; + let hint_for_blocking = hint.clone(); + let decoder = tokio::task::spawn_blocking(move || { + SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false) + }) + .await + .map_err(|e| format!("preview: decoder thread: {e}"))??; + + if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); } + + // ── Build source pipeline ──────────────────────────────────────────────── + // Minimal pipeline: f32 conversion + take_duration cap. No EQ, no crossfade, + // no ReplayGain — preview is intentionally simple. Volume is set on the Sink. + let dur = Duration::from_secs_f64(duration_sec.max(1.0).min(120.0)); + let source = decoder + .convert_samples::() + .take_duration(dur); + + // ── Build secondary sink on the existing OutputStream ──────────────────── + let sink = Arc::new( + Sink::try_new(&*state.stream_handle.lock().unwrap()) + .map_err(|e| format!("preview: sink new: {e}"))?, + ); + sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0)); + sink.append(source); + + // ── Best-effort seek to mid-track start position ───────────────────────── + // Symphonia FLAC without SEEKTABLE may fail here; preview then plays from 0 + // which is acceptable. Hard timeout 800 ms via a worker thread. + if start_sec > 0.5 { + let start_dur = Duration::from_secs_f64(start_sec); + let seek_sink = sink.clone(); + let (tx, rx) = std::sync::mpsc::channel::<()>(); + std::thread::spawn(move || { + seek_sink.try_seek(start_dur).ok(); + let _ = tx.send(()); + }); + let _ = rx.recv_timeout(Duration::from_millis(800)); + } + + *state.preview_sink.lock().unwrap() = Some(sink.clone()); + *state.preview_song_id.lock().unwrap() = Some(id.clone()); + + app.emit("audio:preview-start", id.clone()).ok(); + + // ── Spawn watchdog: progress emits + auto-end ──────────────────────────── + let preview_gen_arc = state.preview_gen.clone(); + let preview_sink_arc = state.preview_sink.clone(); + let preview_song_arc = state.preview_song_id.clone(); + let preview_resume_arc = state.preview_main_resume.clone(); + let main_current = state.current.clone(); + let app_for_task = app.clone(); + let id_for_task = id.clone(); + tokio::spawn(async move { + let started = Instant::now(); + let mut last_emit = Instant::now() - Duration::from_millis(300); + loop { + tokio::time::sleep(Duration::from_millis(100)).await; + // Cancel: another preview started or audio_preview_stop bumped the gen. + if preview_gen_arc.load(Ordering::SeqCst) != gen { return; } + + let elapsed = started.elapsed().as_secs_f64(); + let dur_secs = dur.as_secs_f64(); + + if last_emit.elapsed() >= Duration::from_millis(250) { + last_emit = Instant::now(); + app_for_task.emit("audio:preview-progress", PreviewProgressPayload { + id: id_for_task.clone(), + elapsed: elapsed.min(dur_secs), + duration: dur_secs, + }).ok(); + } + + // Natural end: timer expired OR sink drained early (decode error, + // short track, etc.). + let drained = match preview_sink_arc.lock().unwrap().as_ref() { + Some(s) => s.empty(), + None => true, + }; + if elapsed >= dur_secs || drained { + // Re-check generation under the cleanup lock to avoid racing + // a fresh preview that bumped the counter. + if preview_gen_arc.load(Ordering::SeqCst) != gen { return; } + if let Some(s) = preview_sink_arc.lock().unwrap().take() { s.stop(); } + let cleared_id = preview_song_arc.lock().unwrap().take() + .unwrap_or_else(|| id_for_task.clone()); + + // Resume main if we paused it. + if preview_resume_arc.swap(false, Ordering::AcqRel) { + let mut cur = main_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; + } + } + } + + app_for_task.emit("audio:preview-end", PreviewEndPayload { + id: cleared_id, + reason: "natural", + }).ok(); + return; + } + } + }); + + Ok(()) +} + +#[tauri::command] +pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) { + state.preview_gen.fetch_add(1, Ordering::SeqCst); + let sink = state.preview_sink.lock().unwrap().take(); + let id = state.preview_song_id.lock().unwrap().take(); + if let Some(s) = sink { s.stop(); } + + preview_resume_main(&state); + + if let Some(id) = id { + app.emit("audio:preview-end", PreviewEndPayload { + id, + reason: "user", + }).ok(); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ef8c4283..43bd58b7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4597,6 +4597,8 @@ pub fn run() { audio::autoeq_fetch_profile, audio::audio_preload, audio::audio_play_radio, + audio::audio_preview_play, + audio::audio_preview_stop, audio::audio_set_crossfade, audio::audio_set_gapless, audio::audio_set_normalization, diff --git a/src/App.tsx b/src/App.tsx index 47ad25f0..472aa77b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -107,6 +107,7 @@ import { useEqStore } from './store/eqStore'; import { useKeybindingsStore, matchInAppBinding, buildInAppBinding } from './store/keybindingsStore'; import { useGlobalShortcutsStore } from './store/globalShortcutsStore'; import { useZipDownloadStore } from './store/zipDownloadStore'; +import { usePreviewStore } from './store/previewStore'; import ZipDownloadOverlay from './components/ZipDownloadOverlay'; import PasteClipboardHandler from './components/PasteClipboardHandler'; @@ -563,6 +564,22 @@ function TauriEventBridge() { return () => { unlisten?.(); }; }, []); + // Track-preview lifecycle: Rust audio engine emits start/progress/end. The + // store mirrors them so any tracklist row can render its preview UI. + useEffect(() => { + const unlistenFns: Array<() => void> = []; + listen('audio:preview-start', e => { + usePreviewStore.getState()._onStart(e.payload); + }).then(u => unlistenFns.push(u)); + listen<{ id: string; elapsed: number; duration: number }>('audio:preview-progress', e => { + usePreviewStore.getState()._onProgress(e.payload.id, e.payload.elapsed, e.payload.duration); + }).then(u => unlistenFns.push(u)); + listen<{ id: string; reason: string }>('audio:preview-end', e => { + usePreviewStore.getState()._onEnd(e.payload.id); + }).then(u => unlistenFns.push(u)); + return () => { unlistenFns.forEach(fn => fn()); }; + }, []); + // Audio output device changed (Bluetooth headphones, USB DAC, etc.) // The Rust device-watcher has already reopened the stream on the new device // and dropped the old Sink, so we just need to restart playback. @@ -884,10 +901,24 @@ function TauriEventBridge() { if (!action) return; e.preventDefault(); + // While a track preview is running, Spacebar pauses the preview rather + // than the main player (which is already paused under it). Skip / prev + // also cancel the preview so the main player resumes cleanly. + const previewing = usePreviewStore.getState().previewingId !== null; + switch (action) { - case 'play-pause': togglePlay(); break; - case 'next': next(); break; - case 'prev': previous(); break; + case 'play-pause': + if (previewing) usePreviewStore.getState().stopPreview(); + else togglePlay(); + break; + case 'next': + if (previewing) usePreviewStore.getState().stopPreview(); + next(); + break; + case 'prev': + if (previewing) usePreviewStore.getState().stopPreview(); + previous(); + break; case 'volume-up': setVolume(Math.min(1, usePlayerStore.getState().volume + 0.05)); break; case 'volume-down': setVolume(Math.max(0, usePlayerStore.getState().volume - 0.05)); break; case 'seek-forward': { @@ -928,16 +959,36 @@ function TauriEventBridge() { const unlisten: Array<() => void> = []; const setup = async () => { + // Hardware mediakeys are silently dropped while a preview is playing — + // matches the cucadmuh-flow expectation that headphone buttons don't + // accidentally interrupt or switch the previewed track. + const ifNoPreview = (fn: () => void) => () => { + if (usePreviewStore.getState().previewingId === null) fn(); + }; const handlers: Array<[string, () => void]> = [ - ['media:play-pause', () => togglePlay()], - ['media:play', () => { const s = usePlayerStore.getState(); if (!s.isPlaying) s.resume(); }], - ['media:pause', () => { const s = usePlayerStore.getState(); if (s.isPlaying) s.pause(); }], - ['media:next', () => next()], - ['media:prev', () => previous()], - ['tray:play-pause', () => togglePlay()], - ['tray:next', () => next()], - ['tray:previous', () => previous()], - ['media:stop', () => usePlayerStore.getState().stop()], + ['media:play-pause', ifNoPreview(() => togglePlay())], + ['media:play', ifNoPreview(() => { const s = usePlayerStore.getState(); if (!s.isPlaying) s.resume(); })], + ['media:pause', ifNoPreview(() => { const s = usePlayerStore.getState(); if (s.isPlaying) s.pause(); })], + ['media:next', ifNoPreview(() => next())], + ['media:prev', ifNoPreview(() => previous())], + // Tray clicks are user-driven UI, so they fall through to the keyboard + // semantics: cancel the preview, then act. + ['tray:play-pause', () => { + if (usePreviewStore.getState().previewingId !== null) { + usePreviewStore.getState().stopPreview(); + } else { + togglePlay(); + } + }], + ['tray:next', () => { + if (usePreviewStore.getState().previewingId !== null) usePreviewStore.getState().stopPreview(); + next(); + }], + ['tray:previous', () => { + if (usePreviewStore.getState().previewingId !== null) usePreviewStore.getState().stopPreview(); + previous(); + }], + ['media:stop', ifNoPreview(() => usePlayerStore.getState().stop())], ['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }], ['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }], ]; @@ -1101,6 +1152,34 @@ export default function App() { document.documentElement.setAttribute('data-font', font); }, [font]); + // Hide all inline track-preview buttons when the user opts out — single + // CSS hook (`html[data-track-previews="off"]`) instead of conditional + // rendering in every tracklist. Per-location toggles use additional + // attributes `data-track-previews-{location}` consumed by scoped selectors. + const trackPreviewsEnabled = useAuthStore(s => s.trackPreviewsEnabled); + const trackPreviewLocations = useAuthStore(s => s.trackPreviewLocations); + const trackPreviewDurationSec = useAuthStore(s => s.trackPreviewDurationSec); + useEffect(() => { + document.documentElement.setAttribute( + 'data-track-previews', + trackPreviewsEnabled ? 'on' : 'off', + ); + }, [trackPreviewsEnabled]); + useEffect(() => { + const root = document.documentElement; + (Object.keys(trackPreviewLocations) as Array).forEach(loc => { + root.setAttribute(`data-track-previews-${loc.toLowerCase()}`, trackPreviewLocations[loc] ? 'on' : 'off'); + }); + }, [trackPreviewLocations]); + // Drive the SVG progress-ring keyframe duration from the same setting that + // governs the engine's auto-stop timer so both finish in lockstep. + useEffect(() => { + document.documentElement.style.setProperty( + '--preview-duration', + `${trackPreviewDurationSec}s`, + ); + }, [trackPreviewDurationSec]); + // Main window only: push playback state to mini window + handle control events. useEffect(() => { if (isMiniWindow) return; diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index c79900d2..eb32d088 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { Play, Heart, ListPlus, X, ChevronDown, Check, RotateCcw } from 'lucide-react'; +import { Play, ChevronRight, Heart, ListPlus, X, ChevronDown, Check, RotateCcw, Square } from 'lucide-react'; import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; import { SubsonicSong } from '../api/subsonic'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; @@ -11,6 +11,7 @@ import { useIsMobile } from '../hooks/useIsMobile'; import StarRating from './StarRating'; import { useSelectionStore } from '../store/selectionStore'; import { useThemeStore } from '../store/themeStore'; +import { usePreviewStore } from '../store/previewStore'; function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600); @@ -117,6 +118,8 @@ const TrackRow = React.memo(function TrackRow({ // Fine-grained: only re-renders when THIS row's selection boolean flips. const isSelected = useSelectionStore(s => s.selectedIds.has(song.id)); const isActive = currentTrackId === song.id; + // Primitive selector: row only re-renders when *this song's* preview state flips. + const isPreviewing = usePreviewStore(s => s.previewingId === song.id); const renderCell = (colDef: ColDef) => { const key = colDef.key as ColKey; @@ -125,26 +128,51 @@ const TrackRow = React.memo(function TrackRow({ return (
{ e.stopPropagation(); onPlaySong(song); }} + className={`track-num${isActive ? ' track-num-active' : ''}`} > { e.stopPropagation(); onToggleSelect(song.id, globalIdx, e.shiftKey); }} /> - {isActive && isPlaying && ( + {isActive && isPlaying ? (
+ ) : ( + {song.track ?? '—'} )} - - {song.track ?? '—'}
); case 'title': return ( -
+
+ + {song.title}
); @@ -216,7 +244,7 @@ const TrackRow = React.memo(function TrackRow({ return (
{ if ((e.target as HTMLElement).closest('button, a, input')) return; @@ -585,6 +613,7 @@ export default function AlbumTrackList({
{ if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll(); }} diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 94061497..e819a9ae 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react'; +import { Play, ListPlus, Radio, Heart, Download, ChevronRight, ChevronsRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react'; import { useOrbitStore } from '../store/orbitStore'; import { suggestOrbitTrack, @@ -1515,7 +1515,7 @@ export default function ContextMenu() { newQueue.splice(currentIdx + 1, 0, song); usePlayerStore.setState({ queue: newQueue }); })}> - {t('contextMenu.playNext')} + {t('contextMenu.playNext')}
handleAction(() => enqueue([song]))}> {t('contextMenu.addToQueue')} @@ -1679,7 +1679,7 @@ export default function ContextMenu() { newQueue.splice(currentIdx + 1, 0, song); usePlayerStore.setState({ queue: newQueue }); })}> - {t('contextMenu.playNext')} + {t('contextMenu.playNext')}
handleAction(() => enqueue([song]))}> {t('contextMenu.addToQueue')} @@ -1815,7 +1815,7 @@ export default function ContextMenu() { const currentIdx = usePlayerStore.getState().queueIndex; usePlayerStore.getState().enqueueAt(tracks, currentIdx + 1); })}> - {t('contextMenu.playNext')} + {t('contextMenu.playNext')}
handleAction(async () => { const albumData = await getAlbum(album.id); diff --git a/src/locales/de.ts b/src/locales/de.ts index 8817fe69..0c8bce73 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -870,6 +870,22 @@ export const deTranslation = { notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist', gapless: 'Nahtlose Wiedergabe', gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden', + trackPreviewsTitle: 'Track-Vorschau', + trackPreviewsToggle: 'Track-Vorschau aktivieren', + trackPreviewsDesc: 'Inline Play- und Vorschau-Buttons in den Tracklisten anzeigen für eine kurze Hörprobe mitten im Song.', + trackPreviewStart: 'Startposition', + trackPreviewStartDesc: 'Wie weit der Track schon gespielt sein soll wenn die Vorschau startet (% der Länge).', + trackPreviewDuration: 'Dauer', + trackPreviewDurationDesc: 'Wie lange jede Vorschau spielt bevor sie automatisch stoppt.', + trackPreviewDurationSecs: '{{n}} s', + trackPreviewLocationsTitle: 'Wo Vorschauen erscheinen', + trackPreviewLocationsDesc: 'Wähle aus, in welchen Listen die Vorschau-Buttons angezeigt werden.', + trackPreviewLocation_suggestions: 'In Playlist-Vorschlägen', + trackPreviewLocation_albums: 'In Album-Tracklisten', + trackPreviewLocation_playlists: 'In Playlists', + trackPreviewLocation_favorites: 'In Favoriten', + trackPreviewLocation_artist: 'In Künstler-Top-Tracks', + trackPreviewLocation_randomMix: 'In Random Mix', preloadMode: 'Nächsten Track vorpuffern', preloadModeDesc: 'Wann mit dem Puffern des nächsten Tracks begonnen werden soll', nextTrackBufferingTitle: 'Pufferung', diff --git a/src/locales/en.ts b/src/locales/en.ts index 466f4ffd..822b7a0f 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -876,6 +876,22 @@ export const enTranslation = { notWithCrossfade: 'Not available while Crossfade is active', gapless: 'Gapless Playback', gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs', + trackPreviewsTitle: 'Track Previews', + trackPreviewsToggle: 'Enable track previews', + trackPreviewsDesc: 'Show inline Play and Preview buttons in tracklists for a quick mid-song sample.', + trackPreviewStart: 'Start position', + trackPreviewStartDesc: 'How far into the track the preview starts (% of track length).', + trackPreviewDuration: 'Duration', + trackPreviewDurationDesc: 'How long each preview plays before it stops.', + trackPreviewDurationSecs: '{{n}} s', + trackPreviewLocationsTitle: 'Where previews appear', + trackPreviewLocationsDesc: 'Pick which lists show inline preview buttons.', + trackPreviewLocation_suggestions: 'In playlist suggestions', + trackPreviewLocation_albums: 'In album tracklists', + trackPreviewLocation_playlists: 'In playlists', + trackPreviewLocation_favorites: 'In favorites', + trackPreviewLocation_artist: 'In artist top tracks', + trackPreviewLocation_randomMix: 'In Random Mix', preloadMode: 'Preload Next Track', preloadModeDesc: 'When to start buffering the next track in the queue', nextTrackBufferingTitle: 'Buffering', diff --git a/src/locales/es.ts b/src/locales/es.ts index 1b08423f..897ca12e 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -863,6 +863,22 @@ export const esTranslation = { notWithCrossfade: 'No disponible mientras Crossfade está activo', gapless: 'Reproducción Gapless', gaplessDesc: 'Precarga siguiente pista para eliminar silencios entre canciones', + trackPreviewsTitle: 'Previsualizaciones de pistas', + trackPreviewsToggle: 'Activar previsualizaciones', + trackPreviewsDesc: 'Mostrar botones de Reproducir y Previsualización en las listas de pistas para una muestra rápida a mitad de canción.', + trackPreviewStart: 'Posición inicial', + trackPreviewStartDesc: 'Cuánto avanza la pista antes de iniciar la previsualización (% de la duración).', + trackPreviewDuration: 'Duración', + trackPreviewDurationDesc: 'Cuánto dura cada previsualización antes de detenerse.', + trackPreviewDurationSecs: '{{n}} s', + trackPreviewLocationsTitle: 'Dónde aparecen las previsualizaciones', + trackPreviewLocationsDesc: 'Elige en qué listas se muestran los botones de previsualización.', + trackPreviewLocation_suggestions: 'En sugerencias de listas', + trackPreviewLocation_albums: 'En listas de pistas de álbum', + trackPreviewLocation_playlists: 'En listas de reproducción', + trackPreviewLocation_favorites: 'En favoritos', + trackPreviewLocation_artist: 'En las mejores pistas del artista', + trackPreviewLocation_randomMix: 'En Random Mix', preloadMode: 'Precargar Siguiente Pista', preloadModeDesc: 'Cuándo comenzar a cargar la siguiente pista en cola', nextTrackBufferingTitle: 'Buffer', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 778201a5..c1f30505 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -858,6 +858,22 @@ export const frTranslation = { notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif', gapless: 'Lecture sans blanc', gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux', + trackPreviewsTitle: 'Aperçus de pistes', + trackPreviewsToggle: 'Activer les aperçus de pistes', + trackPreviewsDesc: 'Affiche les boutons Lecture et Aperçu dans les listes pour un court extrait au milieu du morceau.', + trackPreviewStart: 'Position de départ', + trackPreviewStartDesc: 'À quel point du morceau l\'aperçu commence (% de la durée).', + trackPreviewDuration: 'Durée', + trackPreviewDurationDesc: 'Combien de temps chaque aperçu joue avant de s\'arrêter.', + trackPreviewDurationSecs: '{{n}} s', + trackPreviewLocationsTitle: 'Où afficher les aperçus', + trackPreviewLocationsDesc: 'Choisissez dans quelles listes afficher les boutons d\'aperçu.', + trackPreviewLocation_suggestions: 'Dans les suggestions de playlist', + trackPreviewLocation_albums: 'Dans les listes d\'albums', + trackPreviewLocation_playlists: 'Dans les playlists', + trackPreviewLocation_favorites: 'Dans les favoris', + trackPreviewLocation_artist: 'Dans les meilleurs morceaux de l\'artiste', + trackPreviewLocation_randomMix: 'Dans Random Mix', preloadMode: 'Précharger la piste suivante', preloadModeDesc: 'Quand commencer à mettre en mémoire tampon la piste suivante', nextTrackBufferingTitle: 'Mise en mémoire tampon', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 40673c71..0421e07b 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -857,6 +857,22 @@ export const nbTranslation = { notWithCrossfade: 'Ikke tilgjengelig mens Crossfade er aktiv', gapless: 'Gapless avspilling', gaplessDesc: 'Forhåndsbuffer neste spor for å eliminere mellomrom mellom sanger', + trackPreviewsTitle: 'Sporforhåndsvisning', + trackPreviewsToggle: 'Aktiver sporforhåndsvisning', + trackPreviewsDesc: 'Vis innebygde Spill- og Forhåndsvisning-knapper i sporlister for en kort smakebit fra midten av sangen.', + trackPreviewStart: 'Startposisjon', + trackPreviewStartDesc: 'Hvor langt inn i sporet forhåndsvisningen starter (% av lengden).', + trackPreviewDuration: 'Varighet', + trackPreviewDurationDesc: 'Hvor lenge hver forhåndsvisning spiller før den stopper.', + trackPreviewDurationSecs: '{{n}} s', + trackPreviewLocationsTitle: 'Hvor forhåndsvisninger vises', + trackPreviewLocationsDesc: 'Velg hvilke lister som viser forhåndsvisningsknapper.', + trackPreviewLocation_suggestions: 'I spillelisteforslag', + trackPreviewLocation_albums: 'I albumsporlister', + trackPreviewLocation_playlists: 'I spillelister', + trackPreviewLocation_favorites: 'I favoritter', + trackPreviewLocation_artist: 'I artistens toppspor', + trackPreviewLocation_randomMix: 'I Random Mix', infiniteQueue: 'Uendelig kø', infiniteQueueDesc: 'Legg automatisk til tilfeldige spor når køen går tom', experimental: 'Eksperimentell', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 1191a22a..9fc3cc02 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -857,6 +857,22 @@ export const nlTranslation = { notWithCrossfade: 'Niet beschikbaar als overgang actief is', gapless: 'Naadloos afspelen', gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren', + trackPreviewsTitle: 'Track-voorvertoning', + trackPreviewsToggle: 'Track-voorvertoning inschakelen', + trackPreviewsDesc: 'Inline Play- en Voorvertoning-knoppen in trackslijsten tonen voor een korte sample midden in het nummer.', + trackPreviewStart: 'Startpositie', + trackPreviewStartDesc: 'Hoever in de track de voorvertoning start (% van de duur).', + trackPreviewDuration: 'Duur', + trackPreviewDurationDesc: 'Hoe lang elke voorvertoning speelt voordat hij stopt.', + trackPreviewDurationSecs: '{{n}} s', + trackPreviewLocationsTitle: 'Waar voorvertoningen verschijnen', + trackPreviewLocationsDesc: 'Kies in welke lijsten de voorvertoning-knoppen worden weergegeven.', + trackPreviewLocation_suggestions: 'In playlist-suggesties', + trackPreviewLocation_albums: 'In albumlijsten', + trackPreviewLocation_playlists: 'In playlists', + trackPreviewLocation_favorites: 'In favorieten', + trackPreviewLocation_artist: 'In top-tracks van artiest', + trackPreviewLocation_randomMix: 'In Random Mix', preloadMode: 'Volgend nummer vooraf laden', preloadModeDesc: 'Wanneer met het bufferen van het volgende nummer wordt begonnen', nextTrackBufferingTitle: 'Buffering', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 4bdb576e..96898eab 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -909,6 +909,22 @@ export const ruTranslation = { notWithCrossfade: 'Недоступно при включённом кроссфейде', gapless: 'Без пауз между треками', gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины', + trackPreviewsTitle: 'Превью треков', + trackPreviewsToggle: 'Включить превью треков', + trackPreviewsDesc: 'Показывать встроенные кнопки воспроизведения и превью в списках треков для быстрого прослушивания фрагмента.', + trackPreviewStart: 'Начальная позиция', + trackPreviewStartDesc: 'С какого момента трека начинается превью (% от длительности).', + trackPreviewDuration: 'Длительность', + trackPreviewDurationDesc: 'Сколько играет каждое превью до автоматической остановки.', + trackPreviewDurationSecs: '{{n}} с', + trackPreviewLocationsTitle: 'Где показывать превью', + trackPreviewLocationsDesc: 'Выберите, в каких списках показывать кнопки превью.', + trackPreviewLocation_suggestions: 'В предложениях плейлиста', + trackPreviewLocation_albums: 'В списках треков альбома', + trackPreviewLocation_playlists: 'В плейлистах', + trackPreviewLocation_favorites: 'В избранном', + trackPreviewLocation_artist: 'В топ-треках исполнителя', + trackPreviewLocation_randomMix: 'В Random Mix', preloadMode: 'Предзагрузка следующего трека', preloadModeDesc: 'Когда начинать буферизацию следующего в очереди', nextTrackBufferingTitle: 'Буферизация', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index b8303662..587e1972 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -852,6 +852,22 @@ export const zhTranslation = { notWithCrossfade: '交叉淡入淡出开启时不可用', gapless: '无缝播放', gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙', + trackPreviewsTitle: '曲目预览', + trackPreviewsToggle: '启用曲目预览', + trackPreviewsDesc: '在曲目列表中显示内联播放与预览按钮,可快速试听歌曲中段。', + trackPreviewStart: '起始位置', + trackPreviewStartDesc: '预览从曲目何处开始(占总时长的百分比)。', + trackPreviewDuration: '时长', + trackPreviewDurationDesc: '每段预览自动停止前的播放时长。', + trackPreviewDurationSecs: '{{n}} 秒', + trackPreviewLocationsTitle: '预览显示位置', + trackPreviewLocationsDesc: '选择在哪些列表中显示预览按钮。', + trackPreviewLocation_suggestions: '播放列表建议中', + trackPreviewLocation_albums: '专辑曲目列表中', + trackPreviewLocation_playlists: '播放列表中', + trackPreviewLocation_favorites: '收藏中', + trackPreviewLocation_artist: '艺人热门曲目中', + trackPreviewLocation_randomMix: 'Random Mix 中', preloadMode: '预加载下一曲目', preloadModeDesc: '何时开始缓冲队列中的下一曲目', nextTrackBufferingTitle: '缓冲', diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 1d144c32..c23421b1 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -4,11 +4,12 @@ import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, sear import AlbumCard from '../components/AlbumCard'; import CachedImage from '../components/CachedImage'; import CoverLightbox from '../components/CoverLightbox'; -import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2, ChevronDown, ChevronUp, Share2 } from 'lucide-react'; +import { ArrowLeft, Users, ExternalLink, Heart, Play, Square, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2, ChevronDown, ChevronRight, ChevronUp, Share2 } from 'lucide-react'; import { useIsMobile } from '../hooks/useIsMobile'; import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { open } from '@tauri-apps/plugin-shell'; import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { usePreviewStore } from '../store/previewStore'; import { useOfflineStore } from '../store/offlineStore'; import { useOfflineJobStore } from '../store/offlineJobStore'; import { useAuthStore } from '../store/authStore'; @@ -80,6 +81,7 @@ export default function ArtistDetail() { const openContextMenu = usePlayerStore(state => state.openContextMenu); const currentTrack = usePlayerStore(state => state.currentTrack); const isPlaying = usePlayerStore(state => state.isPlaying); + const previewingId = usePreviewStore(s => s.previewingId); const downloadArtist = useOfflineStore(s => s.downloadArtist); const bulkProgress = useOfflineJobStore(s => s.bulkProgress); const activeServerId = useAuthStore(s => s.activeServerId) ?? ''; @@ -688,7 +690,7 @@ export default function ArtistDetail() {

{t('artistDetail.topTracks')}

-
+
#
{t('artistDetail.trackTitle')}
@@ -700,7 +702,7 @@ export default function ArtistDetail() { return (
{ if ((e.target as HTMLElement).closest('button, a, input')) return; @@ -716,12 +718,38 @@ export default function ArtistDetail() { openContextMenu(e.clientX, e.clientY, track, 'song'); }} > -
{ e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTopSongWithContinuation(idx); }}> - {currentTrack?.id === song.id && isPlaying &&
} - - {idx + 1} +
+ {currentTrack?.id === song.id && isPlaying ? ( +
+ ) : ( + {idx + 1} + )}
-
+
+ + {song.coverArt && ( s.currentTrack); const currentRadio = usePlayerStore(s => s.currentRadio); const isPlaying = usePlayerStore(s => s.isPlaying); + const previewingId = usePreviewStore(s => s.previewingId); const starredOverrides = usePlayerStore(s => s.starredOverrides); const setStarredOverride = usePlayerStore(s => s.setStarredOverride); const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); @@ -472,7 +474,7 @@ export default function Favorites() { )}
-
{ +
{ if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll(); }}> @@ -623,7 +625,7 @@ export default function Favorites() { return (
{ if ((e.target as HTMLElement).closest('button, a, input')) return; @@ -669,14 +671,44 @@ export default function Favorites() { {visibleCols.map(colDef => { switch (colDef.key) { case 'num': return ( -
{ e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, visibleSongs.map(songToTrack)); }}> +
{ e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} /> - {currentTrack?.id === song.id && isPlaying &&
} - - {i + 1} + {currentTrack?.id === song.id && isPlaying ? ( +
+ ) : ( + {i + 1} + )} +
+ ); + case 'title': return ( +
+ + + {song.title}
); - case 'title': return
{song.title}
; case 'artist': return (
song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist} diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index dc7da965..5e4d91bd 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -7,11 +7,12 @@ import { AddToPlaylistSubmenu } from '../components/ContextMenu'; import { getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt, search, setRating, star, unstar, - getRandomSongs, buildDownloadUrl, buildStreamUrl, filterSongsToActiveLibrary, SubsonicPlaylist, SubsonicSong, + getRandomSongs, buildDownloadUrl, filterSongsToActiveLibrary, SubsonicPlaylist, SubsonicSong, } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useShallow } from 'zustand/react/shallow'; import { usePlaylistStore } from '../store/playlistStore'; +import { usePreviewStore } from '../store/previewStore'; import { useOfflineStore } from '../store/offlineStore'; import { useOfflineJobStore } from '../store/offlineJobStore'; import { useAuthStore } from '../store/authStore'; @@ -292,10 +293,7 @@ export default function PlaylistDetail() { const [sortClickCount, setSortClickCount] = useState(0); const [starredSongs, setStarredSongs] = useState>(new Set()); const [hoveredSuggestionId, setHoveredSuggestionId] = useState(null); - const [previewingId, setPreviewingId] = useState(null); - const previewAudioRef = useRef(null); - const previewTimerRef = useRef(null); - const previewResumeRef = useRef(false); + const previewingId = usePreviewStore(s => s.previewingId); const [contextMenuSongId, setContextMenuSongId] = useState(null); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const zipDownloads = useZipDownloadStore(s => s.downloads); @@ -933,97 +931,23 @@ export default function PlaylistDetail() { showToast(t('playlists.addSuccess', { count: 1, playlist: playlist?.name })); }; - // ── Preview (30s mid-song sample via parallel HTML5 audio) ──── - // Session counter invalidates `loadedmetadata`/`error` callbacks - // bound to a previous preview that the user has already switched - // away from — without it, a slow-to-load preview can `play()` on a - // discarded element while the new one is still loading. - const previewSessionRef = useRef(0); - - const stopPreview = useCallback(() => { - previewSessionRef.current++; - if (previewAudioRef.current) { - previewAudioRef.current.pause(); - previewAudioRef.current.src = ''; - previewAudioRef.current = null; - } - if (previewTimerRef.current !== null) { - clearTimeout(previewTimerRef.current); - previewTimerRef.current = null; - } - if (previewResumeRef.current) { - previewResumeRef.current = false; - usePlayerStore.getState().resume(); - } - setPreviewingId(null); + // ── Preview (30s mid-song sample via Rust audio engine) ──────── + // Pause/resume of the main player + timer + cancel-on-supersede are all + // handled in `audio_preview_play` / `audio_preview_stop`. The store mirrors + // engine events so we just dispatch here and read `previewingId` for UI. + const startPreview = useCallback((song: SubsonicSong) => { + usePreviewStore.getState().startPreview({ + id: song.id, + duration: song.duration, + }, 'suggestions').catch(() => { /* engine errored — store already rolled back */ }); }, []); - const startPreview = useCallback((song: SubsonicSong) => { - if (previewingId === song.id) { - stopPreview(); - return; + // Cancel any in-flight preview when the user navigates away. + useEffect(() => () => { + if (usePreviewStore.getState().previewingId) { + usePreviewStore.getState().stopPreview(); } - // Tear down audio + timer but keep the resume flag so the main - // player only resumes after the *last* preview ends. - previewSessionRef.current++; - if (previewAudioRef.current) { - previewAudioRef.current.pause(); - previewAudioRef.current.src = ''; - previewAudioRef.current = null; - } - if (previewTimerRef.current !== null) { - clearTimeout(previewTimerRef.current); - previewTimerRef.current = null; - } - if (!previewResumeRef.current && usePlayerStore.getState().isPlaying) { - usePlayerStore.getState().pause(); - previewResumeRef.current = true; - } - const session = ++previewSessionRef.current; - const audio = new Audio(); - // Match the rough loudness compensation the main Rust player applies, - // otherwise unanalysed previews blast out at full natural level - // while the main player serves cache-corrected tracks. - const baseVolume = usePlayerStore.getState().volume; - const auth = useAuthStore.getState(); - const attenuationDb = auth.normalizationEngine === 'loudness' - ? Math.min(0, auth.loudnessPreAnalysisAttenuationDb) - : 0; - audio.volume = Math.max(0, Math.min(1, baseVolume * Math.pow(10, attenuationDb / 20))); - audio.preload = 'auto'; - audio.addEventListener('loadedmetadata', () => { - if (previewSessionRef.current !== session) return; - const dur = audio.duration && Number.isFinite(audio.duration) - ? audio.duration - : (song.duration ?? 0); - audio.currentTime = Math.max(0, dur * 0.33); - audio.play().catch(() => { - if (previewSessionRef.current === session) stopPreview(); - }); - }, { once: true }); - audio.addEventListener('error', () => { - if (previewSessionRef.current === session) stopPreview(); - }, { once: true }); - audio.src = buildStreamUrl(song.id); - previewAudioRef.current = audio; - setPreviewingId(song.id); - previewTimerRef.current = window.setTimeout(stopPreview, 30000); - }, [previewingId, stopPreview]); - - useEffect(() => { - const unsub = usePlayerStore.subscribe((state, prev) => { - if (state.isPlaying && !prev.isPlaying && previewAudioRef.current) { - // Main playback resumed externally (spacebar, mediakey, suggestion-row click). - // Cancel the preview without resuming again. - previewResumeRef.current = false; - stopPreview(); - } - }); - return () => { - unsub(); - stopPreview(); - }; - }, [stopPreview]); + }, []); // ── Rating / Star ───────────────────────────────────────────── const handleRate = (songId: string, rating: number) => { @@ -1518,7 +1442,7 @@ export default function PlaylistDetail() { )} {/* ── Tracklist ── */} -
+
{/* Bulk action bar */} {selectedIds.size > 0 && ( @@ -1729,7 +1653,7 @@ export default function PlaylistDetail() { )}
!isFiltered && handleRowMouseEnter(i, e)} onMouseDown={e => handleRowMouseDown(e, realIdx)} @@ -1760,15 +1684,46 @@ export default function PlaylistDetail() { const inSelectMode = selectedIds.size > 0; switch (colDef.key) { case 'num': return ( -
{ e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(displayedTracks[i], displayedTracks); }}> +
{ e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} /> - {currentTrack?.id === song.id && isPlaying &&
} - - {i + 1} + {currentTrack?.id === song.id && isPlaying ? ( +
+ ) : ( + {i + 1} + )}
); case 'title': return ( -
{song.title}
+
+ + + {song.title} +
); case 'artist': return (
@@ -1816,7 +1771,7 @@ export default function PlaylistDetail() {
{/* ── Suggestions ── */} -
+

{t('playlists.suggestions')}

diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index 35a0d06e..0acf81de 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useMemo, useState } from 'react'; import { getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { usePreviewStore } from '../store/previewStore'; import { useAuthStore } from '../store/authStore'; -import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react'; +import { Play, RefreshCw, ChevronDown, ChevronRight, ChevronUp, Heart, Square } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useDragDrop } from '../contexts/DragDropContext'; import { useIsMobile } from '../hooks/useIsMobile'; @@ -38,6 +39,7 @@ export default function RandomMix() { const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const currentTrack = usePlayerStore(s => s.currentTrack); const isPlaying = usePlayerStore(s => s.isPlaying); + const previewingId = usePreviewStore(s => s.previewingId); const starredOverrides = usePlayerStore(s => s.starredOverrides); const setStarredOverride = usePlayerStore(s => s.setStarredOverride); const [contextMenuSongId, setContextMenuSongId] = useState(null); @@ -408,7 +410,7 @@ export default function RandomMix() { {genreMixLoading && genreMixSongs.length === 0 ? (
) : ( -
+
{t('randomMix.trackTitle')}
@@ -427,7 +429,7 @@ export default function RandomMix() { return (
{ if ((e.target as HTMLElement).closest('button, a, input')) return; if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }} onDoubleClick={orbitActive ? e => { if ((e.target as HTMLElement).closest('button, a, input')) return; addTrackToOrbit(song.id); } : undefined} @@ -449,12 +451,40 @@ export default function RandomMix() { document.addEventListener('mouseup', onUp); }} > -
{ e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}> - {isCurrentTrack && isPlaying &&
} - - {idx + 1} +
+ {isCurrentTrack && isPlaying ? ( +
+ ) : ( + {idx + 1} + )} +
+
+ + + {song.title}
-
{song.title}
{artist ? (
) : ( -
+
{t('randomMix.trackTitle')}
@@ -527,7 +557,7 @@ export default function RandomMix() { return (
{ if ((e.target as HTMLElement).closest('button, a, input')) return; if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }} onDoubleClick={orbitActive ? e => { if ((e.target as HTMLElement).closest('button, a, input')) return; addTrackToOrbit(song.id); } : undefined} @@ -553,13 +583,39 @@ export default function RandomMix() { document.addEventListener('mouseup', onUp); }} > -
{ e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}> - {isCurrentTrack && isPlaying &&
} - - {idx + 1} +
+ {isCurrentTrack && isPlaying ? ( +
+ ) : ( + {idx + 1} + )}
-
+
+ + {song.title}
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 08102873..47477e40 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -31,11 +31,13 @@ import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, + TRACK_PREVIEW_LOCATIONS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig, type LoggingMode, type LoudnessLufsPreset, + type TrackPreviewLocation, } from '../store/authStore'; import { SeekbarPreview } from '../components/WaveformSeek'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; @@ -2523,6 +2525,111 @@ export default function Settings() {
+ } + > +
+
+
+
+ {t('settings.trackPreviewsToggle')} +
+
+ {t('settings.trackPreviewsDesc')} +
+
+ +
+ + {auth.trackPreviewsEnabled && ( + <> +
+
+
+ {t('settings.trackPreviewLocationsTitle')} +
+
+ {t('settings.trackPreviewLocationsDesc')} +
+
+ {TRACK_PREVIEW_LOCATIONS.map((loc: TrackPreviewLocation) => ( +
+
+ {t(`settings.trackPreviewLocation_${loc}`)} +
+ +
+ ))} +
+
+ +
+
+
+ {t('settings.trackPreviewStart')} +
+
+ {t('settings.trackPreviewStartDesc')} +
+
+ auth.setTrackPreviewStartRatio(parseFloat(e.target.value))} + style={{ flex: 1, minWidth: 80, maxWidth: 240 }} + aria-label={t('settings.trackPreviewStart')} + /> + + {Math.round(auth.trackPreviewStartRatio * 100)}% + +
+
+ +
+
+
+ {t('settings.trackPreviewDuration')} +
+
+ {t('settings.trackPreviewDurationDesc')} +
+
+ auth.setTrackPreviewDurationSec(parseInt(e.target.value, 10))} + style={{ flex: 1, minWidth: 80, maxWidth: 240 }} + aria-label={t('settings.trackPreviewDuration')} + /> + + {t('settings.trackPreviewDurationSecs', { n: auth.trackPreviewDurationSec })} + +
+
+ + )} +
+ + )} diff --git a/src/store/authStore.ts b/src/store/authStore.ts index ada73e7d..2c88c8e8 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -49,6 +49,34 @@ function sanitizeLoudnessPreAnalysisFromStorage(v: unknown): number { export type LyricsSourceId = 'server' | 'lrclib' | 'netease'; export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; } +export type TrackPreviewLocation = + | 'suggestions' + | 'albums' + | 'playlists' + | 'favorites' + | 'artist' + | 'randomMix'; + +export type TrackPreviewLocations = Record; + +export const TRACK_PREVIEW_LOCATIONS: readonly TrackPreviewLocation[] = [ + 'suggestions', + 'albums', + 'playlists', + 'favorites', + 'artist', + 'randomMix', +]; + +const DEFAULT_TRACK_PREVIEW_LOCATIONS: TrackPreviewLocations = { + suggestions: true, + albums: true, + playlists: true, + favorites: true, + artist: true, + randomMix: true, +}; + const DEFAULT_LYRICS_SOURCES: LyricsSourceConfig[] = [ { id: 'server', enabled: true }, { id: 'lrclib', enabled: true }, @@ -89,6 +117,14 @@ interface AuthState { crossfadeEnabled: boolean; crossfadeSecs: number; gaplessEnabled: boolean; + /** Show inline Play+Preview buttons in tracklists. Default on per Q3. Master kill switch — when off, all locations are off. */ + trackPreviewsEnabled: boolean; + /** Per-location toggles. Only honoured when `trackPreviewsEnabled` is true. */ + trackPreviewLocations: TrackPreviewLocations; + /** Mid-track start position as a 0…1 ratio. Default 0.33 = 33%. */ + trackPreviewStartRatio: number; + /** Preview window length in seconds. Default 30 s. */ + trackPreviewDurationSec: number; preloadMode: 'off' | 'balanced' | 'early' | 'custom'; preloadCustomSeconds: number; infiniteQueueEnabled: boolean; @@ -252,6 +288,10 @@ interface AuthState { setCrossfadeEnabled: (v: boolean) => void; setCrossfadeSecs: (v: number) => void; setGaplessEnabled: (v: boolean) => void; + setTrackPreviewsEnabled: (v: boolean) => void; + setTrackPreviewLocation: (location: TrackPreviewLocation, enabled: boolean) => void; + setTrackPreviewStartRatio: (v: number) => void; + setTrackPreviewDurationSec: (v: number) => void; setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => void; setPreloadCustomSeconds: (v: number) => void; setInfiniteQueueEnabled: (v: boolean) => void; @@ -367,6 +407,10 @@ export const useAuthStore = create()( crossfadeEnabled: false, crossfadeSecs: 3, gaplessEnabled: false, + trackPreviewsEnabled: true, + trackPreviewLocations: { ...DEFAULT_TRACK_PREVIEW_LOCATIONS }, + trackPreviewStartRatio: 0.33, + trackPreviewDurationSec: 30, preloadMode: 'balanced', preloadCustomSeconds: 30, infiniteQueueEnabled: false, @@ -521,6 +565,12 @@ export const useAuthStore = create()( setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }), setCrossfadeSecs: (v) => set({ crossfadeSecs: v }), setGaplessEnabled: (v) => set({ gaplessEnabled: v }), + setTrackPreviewsEnabled: (v) => set({ trackPreviewsEnabled: !!v }), + setTrackPreviewLocation: (location, enabled) => set(state => ({ + trackPreviewLocations: { ...state.trackPreviewLocations, [location]: !!enabled }, + })), + setTrackPreviewStartRatio: (v) => set({ trackPreviewStartRatio: Math.max(0, Math.min(0.9, v)) }), + setTrackPreviewDurationSec: (v) => set({ trackPreviewDurationSec: Math.max(5, Math.min(120, Math.round(v))) }), setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => set({ preloadMode: v }), setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }), setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }), diff --git a/src/store/previewStore.ts b/src/store/previewStore.ts new file mode 100644 index 00000000..08093eb9 --- /dev/null +++ b/src/store/previewStore.ts @@ -0,0 +1,108 @@ +import { create } from 'zustand'; +import { invoke } from '@tauri-apps/api/core'; +import { buildStreamUrl } from '../api/subsonic'; +import { usePlayerStore } from './playerStore'; +import { useAuthStore, type TrackPreviewLocation } from './authStore'; + +interface PreviewState { + /** Subsonic song id of the active preview, or null when nothing previews. */ + previewingId: string | null; + /** Seconds elapsed in the current preview window. */ + elapsed: number; + /** Total preview window in seconds (echoes the duration_sec arg). */ + duration: number; + + startPreview: (song: { id: string; duration?: number }, location: TrackPreviewLocation) => Promise; + stopPreview: () => Promise; + + /** Internal — called from the TauriEventBridge on `audio:preview-start`. */ + _onStart: (id: string) => void; + /** Internal — called from the TauriEventBridge on `audio:preview-progress`. */ + _onProgress: (id: string, elapsed: number, duration: number) => void; + /** Internal — called from the TauriEventBridge on `audio:preview-end`. */ + _onEnd: (id: string) => void; +} + +const PREVIEW_VOLUME_MATCH = true; + +export const usePreviewStore = create((set, get) => ({ + previewingId: null, + elapsed: 0, + duration: 30, + + startPreview: async (song, location) => { + const auth = useAuthStore.getState(); + if (!auth.trackPreviewsEnabled) return; + if (!auth.trackPreviewLocations[location]) return; + + const current = get().previewingId; + if (current === song.id) { + await get().stopPreview(); + return; + } + + const previewDuration = auth.trackPreviewDurationSec; + const startRatio = auth.trackPreviewStartRatio; + const url = buildStreamUrl(song.id); + const trackDuration = Math.max(song.duration ?? 0, 0); + const startSec = trackDuration > previewDuration * 1.5 + ? trackDuration * startRatio + : 0; + + // Match the main player's effective volume so preview doesn't blast at + // unattenuated level. LUFS pre-analysis attenuation is folded into base + // volume by the audio engine for the main sink; we mirror by reading the + // player volume + applying the same headroom multiplier conceptually. + let volume = usePlayerStore.getState().volume; + if (PREVIEW_VOLUME_MATCH) { + if (auth.normalizationEngine === 'loudness') { + const preDbAtt = Math.min(0, auth.loudnessPreAnalysisAttenuationDb ?? -4.5); + volume = volume * Math.pow(10, preDbAtt / 20); + } + } + + set({ previewingId: song.id, elapsed: 0, duration: previewDuration }); + + try { + await invoke('audio_preview_play', { + id: song.id, + url, + startSec, + durationSec: previewDuration, + volume: Math.max(0, Math.min(1, volume)), + }); + } catch (e) { + // Roll back optimistic state on failure. + if (get().previewingId === song.id) { + set({ previewingId: null, elapsed: 0 }); + } + throw e; + } + }, + + stopPreview: async () => { + if (!get().previewingId) return; + try { + await invoke('audio_preview_stop'); + } catch { + /* engine will emit preview-end anyway; clear locally as fallback */ + set({ previewingId: null, elapsed: 0 }); + } + }, + + _onStart: (id) => { + if (get().previewingId !== id) { + set({ previewingId: id, elapsed: 0 }); + } + }, + + _onProgress: (id, elapsed, duration) => { + if (get().previewingId !== id) return; + set({ elapsed, duration }); + }, + + _onEnd: (id) => { + if (get().previewingId !== id) return; + set({ previewingId: null, elapsed: 0 }); + }, +})); diff --git a/src/styles/components.css b/src/styles/components.css index ea393381..a5f5fa91 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1765,7 +1765,28 @@ } .playlist-suggestion-preview-btn.is-previewing .playlist-suggestion-preview-ring-progress { - animation: playlist-preview-progress 30s linear forwards; + animation: playlist-preview-progress var(--preview-duration, 30s) linear forwards; +} + +/* Settings opt-out: a single root attribute hides every inline preview button + without forcing every tracklist to plumb the flag through props. Master + wins — when off, every preview button is hidden regardless of per-location + state. */ +html[data-track-previews="off"] .playlist-suggestion-preview-btn { + display: none; +} + +/* Per-location hide: each tracklist container marks its scope via + `data-preview-loc=""`. When the matching root attribute is "off", only + buttons inside that scope disappear. Attribute names are stored lowercase + by the DOM so `randomMix` becomes `randommix`. */ +html[data-track-previews-suggestions="off"] [data-preview-loc="suggestions"] .playlist-suggestion-preview-btn, +html[data-track-previews-albums="off"] [data-preview-loc="albums"] .playlist-suggestion-preview-btn, +html[data-track-previews-playlists="off"] [data-preview-loc="playlists"] .playlist-suggestion-preview-btn, +html[data-track-previews-favorites="off"] [data-preview-loc="favorites"] .playlist-suggestion-preview-btn, +html[data-track-previews-artist="off"] [data-preview-loc="artist"] .playlist-suggestion-preview-btn, +html[data-track-previews-randommix="off"] [data-preview-loc="randomMix"] .playlist-suggestion-preview-btn { + display: none; } @keyframes playlist-preview-progress { @@ -1865,6 +1886,25 @@ .track-num.track-num-paused .track-num-play { display: flex; opacity: 0.5; } .track-row:hover .track-num.track-num-paused .track-num-play { opacity: 1; } +/* ── Tracklists with inline action buttons (AlbumTrackList & co.) ────────── + Track number stays static instead of swapping to a play icon on hover. + The dedicated Play + Preview buttons in the title cell take over both + click-to-play and click-to-preview. Active+playing rows still show the + eq-bars, active+paused rows fall back to the static number. + These rules MUST come after the legacy `.track-row:hover` swap rules so + the cascade overrides them. */ +.track-row-with-actions:hover .track-num-number { display: inline; } +.track-row-with-actions .track-num { cursor: default; } +.track-row-with-actions .track-num.track-num-active .track-num-number { display: inline; } +.track-row-with-actions:hover .track-num.track-num-active .track-num-eq { display: flex; } + +/* Mirror the suggestion preview-button hover-highlight here so the icon + reaches full opacity / accent colour when the row is hovered. */ +.track-row-with-actions:hover .playlist-suggestion-preview-btn { + opacity: 1; + color: var(--accent); +} + /* Equalizer bars — shown for the currently playing track */ .eq-bars { display: flex;