fix: stabilize preview seekbar, post-sleep audio recovery, and card hover behavior (#476)

* fix(player): freeze main seekbar during track preview

Preview pauses the main sink in Rust while isPlaying stays true in the
store, so WaveformSeek's interpolation rAF must not advance progress.

* fix(audio): recover output after sleep and stalled streams

Add platform-specific post-sleep recovery hooks for Windows and Linux, and add a watchdog that reopens the output stream when playback is active but sample progress stalls, so audio can recover without restarting the app.

* fix(ui): remove card hover lift and smooth artwork zoom

Remove vertical hover translation from album and artist cards, and move image fade transition out of inline styles so cover zoom uses CSS timing consistently.

* fix(player): prevent seekbar jump after preview ends

Reset interpolation anchor timing when preview freeze state changes so the main seekbar does not momentarily jump forward before resyncing.

* fix(audio): reduce false watchdog recoveries and add diagnostics

Arm stalled-output recovery only after long poll gaps that suggest sleep/resume, and add detailed watcher logs for arm/clear/trigger paths to diagnose unintended stream reopens.

* chore(ui): drop card GPU hints and clarify macOS sleep scope

Remove translateZ and will-change hints from album and artist cover images to avoid per-card compositing overhead on software-composited Linux paths, and document why post-sleep recovery hooks currently target only Windows and Linux.

* docs(audio): document intentional Win32 callback pointer lifetime

Add inline rationale for the two Box::into_raw pointers in Windows suspend/resume registration so future maintenance does not treat the process-lifetime pointers as accidental leaks.

* docs(changelog): summarize playback stability updates for PR #476

Add a high-level changelog entry for preview seekbar fixes, sleep/wake audio recovery hooks and watchdog diagnostics, and card-hover stability adjustments from PR #476.

* docs(contributors): add cucadmuh entry for PR #476

Logs the post-sleep audio recovery, preview-seekbar fixes and card
hover stability work in the Settings → System → Contributors list.
This commit is contained in:
cucadmuh
2026-05-06 13:28:38 +03:00
committed by GitHub
parent 5c8cfb8be3
commit d48ea819c1
13 changed files with 445 additions and 53 deletions
+161 -41
View File
@@ -1,21 +1,82 @@
//! Poll default output device and pinned-device presence; reopen stream when needed.
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use tauri::Emitter;
use tauri::Manager;
use super::engine::AudioEngine;
#[cfg(not(target_os = "linux"))]
use super::dev_io::output_enumeration_includes_pinned;
/// What to tell the frontend after a successful stream reopen.
pub(crate) enum ReopenNotify {
/// Normal path — same as `audio_set_device`.
DeviceChanged,
/// Pinned device unplugged (Windows/macOS only); Rust cleared the pin — clear Settings + restart playback.
#[cfg(not(target_os = "linux"))]
DeviceReset,
}
/// Opens a new CPAL/rodio output stream with the given rate and device name (same path as
/// manual device switch). Used by the device watcher and Windows suspend/resume notifications.
pub(crate) async fn reopen_output_stream(
app: &tauri::AppHandle,
device_name: Option<String>,
notify: ReopenNotify,
) -> bool {
let Some(engine) = app.try_state::<AudioEngine>() else {
return false;
};
let rate = engine.stream_sample_rate.load(Ordering::Relaxed);
let reopen_tx = engine.stream_reopen_tx.clone();
let stream_handle = engine.stream_handle.clone();
let current = engine.current.clone();
let fading_out = engine.fading_out_sink.clone();
let new_handle = tauri::async_runtime::spawn_blocking(move || {
let (reply_tx, reply_rx) =
std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
if reopen_tx
.send((rate, false, device_name, reply_tx))
.is_err()
{
return None;
}
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
})
.await
.unwrap_or(None);
let Some(handle) = new_handle else {
return false;
};
*stream_handle.lock().unwrap() = handle;
if let Some(s) = current.lock().unwrap().sink.take() {
s.stop();
}
if let Some(s) = fading_out.lock().unwrap().take() {
s.stop();
}
match notify {
ReopenNotify::DeviceChanged => {
app.emit("audio:device-changed", ()).ok();
}
#[cfg(not(target_os = "linux"))]
ReopenNotify::DeviceReset => {
app.emit("audio:device-reset", ()).ok();
}
}
true
}
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
let reopen_tx = engine.stream_reopen_tx.clone();
let stream_handle = engine.stream_handle.clone();
let stream_rate = engine.stream_sample_rate.clone();
let current = engine.current.clone();
let fading_out = engine.fading_out_sink.clone();
let selected_device = engine.selected_device.clone();
let samples_played = engine.samples_played.clone();
let current = engine.current.clone();
tauri::async_runtime::spawn(async move {
let mut last_default: Option<String> = tauri::async_runtime::spawn_blocking(|| {
@@ -28,9 +89,98 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
// macOS/Windows: consecutive polls where a pinned device is absent from cpal's list.
#[cfg(not(target_os = "linux"))]
let mut pinned_miss_count: u32 = 0;
// Fallback recovery when OS sleep/resume notifications are missed: if playback is
// "running" but sample counter is flat for too long, reopen output stream.
// To avoid false positives during normal playback, arm this watchdog only
// after a suspiciously long poll gap (e.g. process resumed after sleep).
let mut last_samples_seen: u64 = 0;
let mut stalled_since: Option<Instant> = None;
let mut last_stall_recover_at: Option<Instant> = None;
let mut last_poll_at = Instant::now();
let mut watchdog_armed_until: Option<Instant> = None;
loop {
tokio::time::sleep(Duration::from_secs(3)).await;
let now = Instant::now();
let poll_gap = now.saturating_duration_since(last_poll_at);
last_poll_at = now;
if poll_gap >= Duration::from_secs(15) {
let armed_until = now + Duration::from_secs(120);
watchdog_armed_until = Some(armed_until);
crate::app_eprintln!(
"[psysonic] device-watcher: watchdog armed for 120s (poll gap {:?}, likely sleep/resume)",
poll_gap
);
}
let watchdog_armed = watchdog_armed_until.is_some_and(|until| now < until);
// ── Fallback stall detector (works even if sleep/resume signal was missed) ──
let mut should_recover_stall = false;
let mut stall_for = Duration::ZERO;
{
let samples_now = samples_played.load(Ordering::Relaxed);
let cur = current.lock().unwrap();
let active = cur
.sink
.as_ref()
.is_some_and(|s| !s.is_paused() && !s.empty());
if !watchdog_armed {
if stalled_since.take().is_some() {
crate::app_eprintln!(
"[psysonic] device-watcher: watchdog disarmed, clearing stall candidate"
);
}
last_samples_seen = samples_now;
} else if !active || samples_now != last_samples_seen {
if stalled_since.take().is_some() {
crate::app_eprintln!(
"[psysonic] device-watcher: stall candidate cleared (active={active}, samples_delta={})",
samples_now as i128 - last_samples_seen as i128
);
}
stalled_since = None;
last_samples_seen = samples_now;
} else {
let since = stalled_since.get_or_insert_with(Instant::now);
if since.elapsed() < Duration::from_millis(100) {
crate::app_eprintln!(
"[psysonic] device-watcher: stall candidate started (samples={}, active={active})",
samples_now
);
}
stall_for = since.elapsed();
let cooldown_ok = last_stall_recover_at
.map(|t| t.elapsed() >= Duration::from_secs(20))
.unwrap_or(true);
if stall_for >= Duration::from_secs(8) && cooldown_ok {
should_recover_stall = true;
}
}
}
if should_recover_stall {
let pinned = selected_device.lock().unwrap().clone();
let samples_now = samples_played.load(Ordering::Relaxed);
crate::app_eprintln!(
"[psysonic] device-watcher: output stalled for {:?} (samples={}) — reopening stream, pinned={:?}",
stall_for,
samples_now,
pinned
);
if reopen_output_stream(&app, pinned, ReopenNotify::DeviceChanged).await {
last_stall_recover_at = Some(Instant::now());
stalled_since = None;
last_samples_seen = samples_played.load(Ordering::Relaxed);
crate::app_eprintln!(
"[psysonic] device-watcher: stalled-output recovery succeeded"
);
} else {
crate::app_eprintln!(
"[psysonic] device-watcher: stalled-output reopen timed out"
);
}
}
// Enumerate all available output devices and the current default.
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
@@ -96,22 +246,9 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
tokio::time::sleep(Duration::from_millis(500)).await;
let rate = stream_rate.load(Ordering::Relaxed);
let reopen_tx2 = reopen_tx.clone();
let new_handle = tauri::async_runtime::spawn_blocking(move || {
let (reply_tx, reply_rx) =
std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
if reopen_tx2.send((rate, false, None, reply_tx)).is_err() {
return None;
}
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
}).await.unwrap_or(None);
if let Some(handle) = new_handle {
*stream_handle.lock().unwrap() = handle;
if let Some(s) = current.lock().unwrap().sink.take() { s.stop(); }
if let Some(s) = fading_out.lock().unwrap().take() { s.stop(); }
app.emit("audio:device-reset", ()).ok();
let reopened = reopen_output_stream(&app, None, ReopenNotify::DeviceReset).await;
if !reopened {
crate::app_eprintln!("[psysonic] device-watcher: stream reopen timed out (pinned disconnect)");
}
last_default = current_default;
@@ -133,26 +270,9 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
// Debounce: give the OS time to finish configuring the new device.
tokio::time::sleep(Duration::from_millis(500)).await;
let rate = stream_rate.load(Ordering::Relaxed);
let reopen_tx2 = reopen_tx.clone();
let new_handle = tauri::async_runtime::spawn_blocking(move || {
let (reply_tx, reply_rx) =
std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
if reopen_tx2.send((rate, false, None, reply_tx)).is_err() {
return None;
}
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
}).await.unwrap_or(None);
let Some(handle) = new_handle else {
if !reopen_output_stream(&app, None, ReopenNotify::DeviceChanged).await {
crate::app_eprintln!("[psysonic] device-watcher: stream reopen timed out");
continue;
};
*stream_handle.lock().unwrap() = handle;
if let Some(s) = current.lock().unwrap().sink.take() { s.stop(); }
if let Some(s) = fading_out.lock().unwrap().take() { s.stop(); }
app.emit("audio:device-changed", ()).ok();
}
}
});
}