mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(audio): rust track preview engine + inline play/preview buttons (#392)
* feat(audio): rust preview engine with secondary sink Adds a parallel rodio Sink on the existing OutputStream for 30s mid-track previews. Two new Tauri commands (audio_preview_play, audio_preview_stop) plus three events (audio:preview-start / -progress / -end). The main sink is paused with Sink::pause() and auto-resumed on preview end iff it was playing beforehand. * feat(playlists): migrate suggestion preview to rust audio engine Replaces the HTML5 <audio> path with the new rust preview engine. previewStore mirrors the engine's start/progress/end events so any tracklist row can render preview UI from a single source of truth. Spacebar redirects to stopPreview while a preview plays, hardware mediakeys are silently dropped (Q5), and tray clicks cancel the preview before forwarding the original action. * feat(albums): inline play + preview buttons in tracklist rows Track number stays static on hover instead of swapping to a play icon — the dedicated Play and Preview buttons in the title cell take over click-to-play and click-to-preview. Active+playing rows keep the eq-bars (also on hover), active+paused rows fall back to the static accent-coloured number. Pilot for the wider rollout to other tracklists. * feat(tracklists): roll out inline play + preview buttons Mirrors the AlbumTrackList pilot across the remaining track-row based lists: PlaylistDetail main tracks, Favorites, ArtistDetail top tracks, RandomMix (both genre-mix and filtered-songs lists). Track number stays static, the dedicated Play + Preview buttons in the title cell take over click-to-play and click-to-preview. * feat(settings): track preview toggle + configurable position and duration Adds an opt-out switch and two sliders to Settings → Audio: start position (0-90 % of track length, default 33 %) and preview duration (5-60 s, default 30 s). The progress-ring animation follows the duration via a CSS variable so the visual matches the engine's auto-stop. Disabling the feature hides every inline preview button via a single root-level data attribute, no per-row conditional rendering required. i18n keys added in all 8 locales. * fix(audio): cancel preview when main playback (re)starts audio_play, audio_play_radio, audio_resume and audio_stop did not know about the parallel preview sink, so clicking Play on a track that was currently being previewed left the preview running on top of the freshly started main playback. New helper clears the resume flag, bumps the preview generation, drops the sink and emits an 'interrupted' end event before any of those commands touches the main sink. * feat(settings): per-location track preview toggles Splits the single trackPreviewsEnabled toggle into a master + 6 per-location sub-toggles (suggestions, albums, playlists, favorites, artist, randomMix). Master remains the kill switch; sub-toggles are only honoured when master is on. Each tracklist container is marked with `data-preview-loc="<id>"` and hidden via scoped CSS when the matching root attribute is "off". startPreview now takes a location argument so the store can guard logic too. i18n added in all 8 locales. * fix(contextmenu): use ChevronsRight for Play Next to distinguish from preview
This commit is contained in:
committed by
GitHub
parent
ff47170736
commit
a14dba8167
+302
-1
@@ -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<Mutex<Option<(String, u64)>>>,
|
||||
/// 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<Mutex<Option<Arc<Sink>>>>,
|
||||
/// 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<AtomicU64>,
|
||||
/// 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<AtomicBool>,
|
||||
/// 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<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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::<f32>()
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user