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:
Frank Stellmacher
2026-05-01 09:11:28 +02:00
committed by GitHub
parent ff47170736
commit a14dba8167
21 changed files with 1074 additions and 159 deletions
+302 -1
View File
@@ -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();
}
}
+2
View File
@@ -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,