mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -115,6 +115,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* **Double-click** on the tray icon opens (or focuses) the main window without a spurious **context-menu** interaction; tray module import cleanup.
|
||||
|
||||
### Playback stability — preview seekbar, sleep/wake recovery, and card-hover jitter
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#476](https://github.com/Psychotoxical/psysonic/pull/476)**
|
||||
|
||||
* **Preview seekbar:** while Rust preview playback pauses the main sink, the main seekbar no longer extrapolates forward, and preview end no longer causes a brief forward jump before snapping back.
|
||||
* **Sleep/wake audio recovery:** added post-sleep output reopen hooks for **Windows** (power notifications) and **Linux** (logind `PrepareForSleep` wake signal), plus a guarded fallback watchdog path and richer runtime diagnostics.
|
||||
* **False-positive mitigation:** the watchdog now arms only after a long poll gap (sleep/resume-like condition) and logs arm/clear/trigger decisions, reducing unexpected stream reopens during normal playback.
|
||||
* **Card hover stability:** removed vertical lift on album/artist/base cards to avoid pointer-edge pulsation, kept artwork zoom smooth, and dropped per-card GPU layer hints that could regress software-composited Linux paths.
|
||||
|
||||
## [1.45.0] - 2026-05-04
|
||||
|
||||
## Added
|
||||
|
||||
@@ -67,6 +67,7 @@ windows = { version = "0.62", features = [
|
||||
"Win32_Graphics",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Power",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Controls",
|
||||
"Win32_UI_Shell",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ mod decode;
|
||||
mod dev_io;
|
||||
mod device_watcher;
|
||||
mod engine;
|
||||
mod power_resume;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod power_notify_win;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod power_notify_linux;
|
||||
mod helpers;
|
||||
mod ipc;
|
||||
pub mod preview;
|
||||
@@ -21,4 +26,21 @@ pub use device_watcher::start_device_watcher;
|
||||
pub use engine::{create_engine, refresh_http_user_agent, AudioEngine};
|
||||
pub use helpers::take_stream_completed_for_url;
|
||||
|
||||
/// Register platform-specific listeners so the output stream is reopened after sleep/resume
|
||||
/// when the device name may be unchanged (Windows WASAPI, Linux PipeWire, …).
|
||||
pub fn register_post_sleep_audio_recovery(app: tauri::AppHandle) {
|
||||
#[cfg(target_os = "windows")]
|
||||
power_notify_win::register(app);
|
||||
#[cfg(target_os = "linux")]
|
||||
power_notify_linux::register(app);
|
||||
// macOS intentionally falls through for now: we only ship native resume hooks
|
||||
// where we have verified regressions (Windows WASAPI, Linux logind/PipeWire).
|
||||
// macOS currently relies on the generic device watcher path.
|
||||
#[cfg(all(
|
||||
not(target_os = "windows"),
|
||||
not(target_os = "linux")
|
||||
))]
|
||||
let _ = app;
|
||||
}
|
||||
|
||||
pub(crate) use engine::{analysis_track_id_is_current_playback, ranged_loudness_backfill_should_defer};
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Linux: subscribe to logind `PrepareForSleep` on the system bus — `start == false` means resume
|
||||
//! completed (systemd says the boolean is true when going to sleep, false when waking).
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
use super::power_resume::{debounce_allow_resume_reopen, reopen_audio_after_system_resume};
|
||||
|
||||
pub fn register(app: AppHandle) {
|
||||
let res = std::thread::Builder::new()
|
||||
.name("psysonic-logind-sleep".into())
|
||||
.spawn(move || run_listener(app));
|
||||
|
||||
if let Err(e) = res {
|
||||
crate::app_eprintln!("[psysonic] could not spawn logind listener: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn run_listener(app: AppHandle) {
|
||||
use zbus::blocking::{Connection, MessageIterator};
|
||||
use zbus::message::Type;
|
||||
use zbus::MatchRule;
|
||||
|
||||
let conn = match Connection::system() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] D-Bus system bus unavailable — post-sleep audio recovery disabled: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let rule: zbus::MatchRule = match (|| -> zbus::Result<_> {
|
||||
Ok(MatchRule::builder()
|
||||
.msg_type(Type::Signal)
|
||||
.path("/org/freedesktop/login1")?
|
||||
.interface("org.freedesktop.login1.Manager")?
|
||||
.member("PrepareForSleep")?
|
||||
.build())
|
||||
})() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] MatchRule for logind PrepareForSleep failed: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut iter = match MessageIterator::for_match_rule(rule, &conn, Some(32)) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[psysonic] logind signal subscription failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
crate::app_eprintln!("[psysonic] logind PrepareForSleep listener registered (post-sleep audio recovery)");
|
||||
|
||||
loop {
|
||||
let Some(result) = iter.next() else {
|
||||
break;
|
||||
};
|
||||
let msg = match result {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[psysonic] logind message stream error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let start: bool = match msg.body().deserialize() {
|
||||
Ok(b) => b,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if start {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !debounce_allow_resume_reopen() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
reopen_audio_after_system_resume(&app).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Windows: `PowerRegisterSuspendResumeNotification` — resume from sleep without a default-device rename.
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
use tauri::AppHandle;
|
||||
use windows::Win32::{
|
||||
Foundation::{ERROR_SUCCESS, HANDLE},
|
||||
System::Power::{PowerRegisterSuspendResumeNotification, DEVICE_NOTIFY_SUBSCRIBE_PARAMETERS},
|
||||
UI::WindowsAndMessaging::{
|
||||
DEVICE_NOTIFY_CALLBACK, PBT_APMRESUMEAUTOMATIC, PBT_APMRESUMESUSPEND, PBT_APMRESUMESTANDBY,
|
||||
},
|
||||
};
|
||||
|
||||
use super::power_resume::{debounce_allow_resume_reopen, reopen_audio_after_system_resume};
|
||||
|
||||
unsafe extern "system" fn power_suspend_resume_callback(
|
||||
context: *const c_void,
|
||||
event_type: u32,
|
||||
_setting: *const c_void,
|
||||
) -> u32 {
|
||||
if context.is_null() {
|
||||
return 0;
|
||||
}
|
||||
if !matches!(
|
||||
event_type,
|
||||
PBT_APMRESUMEAUTOMATIC | PBT_APMRESUMESUSPEND | PBT_APMRESUMESTANDBY
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if !debounce_allow_resume_reopen() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let app = unsafe { &*(context as *const AppHandle) }.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
reopen_audio_after_system_resume(&app).await;
|
||||
});
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
pub fn register(app: AppHandle) {
|
||||
// Intentionally leaked for process lifetime: Win32 callback receives this pointer
|
||||
// on each suspend/resume notification and may outlive this function scope.
|
||||
let app_leak = Box::into_raw(Box::new(app));
|
||||
|
||||
let params = Box::new(DEVICE_NOTIFY_SUBSCRIBE_PARAMETERS {
|
||||
Callback: Some(power_suspend_resume_callback),
|
||||
Context: app_leak as *mut c_void,
|
||||
});
|
||||
// Intentionally leaked for process lifetime: the power subsystem keeps the
|
||||
// subscribe-parameters pointer after successful registration.
|
||||
let params_ptr = Box::into_raw(params);
|
||||
|
||||
let mut registration: *mut c_void = std::ptr::null_mut();
|
||||
let err = unsafe {
|
||||
PowerRegisterSuspendResumeNotification(
|
||||
DEVICE_NOTIFY_CALLBACK,
|
||||
HANDLE(params_ptr as *mut _),
|
||||
&mut registration as *mut *mut c_void,
|
||||
)
|
||||
};
|
||||
|
||||
if err != ERROR_SUCCESS {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] PowerRegisterSuspendResumeNotification failed: {:?} — post-sleep audio recovery disabled",
|
||||
err
|
||||
);
|
||||
unsafe {
|
||||
drop(Box::from_raw(params_ptr));
|
||||
drop(Box::from_raw(app_leak));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
crate::app_eprintln!("[psysonic] Windows power suspend/resume notifications registered for audio");
|
||||
// `registration` is an opaque handle returned by Win32 API. It does not own
|
||||
// Rust resources, so dropping the local copy is fine; callback context is
|
||||
// intentionally leaked above for process-lifetime notifications.
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Reopen CPAL/rodio output after system sleep/resume when the old stream can be silent
|
||||
//! while the reported default device name is unchanged (Windows WASAPI, Linux PipeWire/ALSA, etc.).
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::device_watcher::{reopen_output_stream, ReopenNotify};
|
||||
use super::engine::AudioEngine;
|
||||
|
||||
static RESUME_REOPEN_DEBOUNCE: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
const DEBOUNCE: Duration = Duration::from_millis(900);
|
||||
|
||||
/// Returns false if this resume should be ignored (coalesce bursts from the OS).
|
||||
pub(crate) fn debounce_allow_resume_reopen() -> bool {
|
||||
let mut g = RESUME_REOPEN_DEBOUNCE.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
if let Some(t) = *g {
|
||||
if now.duration_since(t) < DEBOUNCE {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
*g = Some(now);
|
||||
true
|
||||
}
|
||||
|
||||
/// Delay so the audio stack re-enumerates before we open a new stream.
|
||||
pub(crate) async fn reopen_audio_after_system_resume(app: &AppHandle) {
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
|
||||
let device_name = match app.try_state::<AudioEngine>() {
|
||||
Some(e) => e.selected_device.lock().unwrap().clone(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
if reopen_output_stream(app, device_name, ReopenNotify::DeviceChanged).await {
|
||||
crate::app_eprintln!("[psysonic] audio output reopened after system resume");
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] audio: stream reopen failed or timed out after system resume"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -768,6 +768,9 @@ pub fn run() {
|
||||
audio::start_device_watcher(&engine, app.handle().clone());
|
||||
}
|
||||
|
||||
// ── Reopen output after system sleep/resume (WASAPI / PipeWire etc.)
|
||||
audio::register_post_sleep_audio_recovery(app.handle().clone());
|
||||
|
||||
// ── Pre-create mini player window (Windows) ──────────────────
|
||||
// Creating the second WebView2 webview lazily from an invoke
|
||||
// handler on Windows reliably stalls the Tauri event loop —
|
||||
|
||||
@@ -234,7 +234,7 @@ export default function CachedImage({
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={finalSrc}
|
||||
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease', ...fallbackStyle }}
|
||||
style={{ ...style, opacity: loaded ? 1 : 0, ...fallbackStyle }}
|
||||
onLoad={e => { setLoaded(true); onLoad?.(e); }}
|
||||
onError={handleError}
|
||||
{...props}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playerStore';
|
||||
import { usePreviewStore } from '../store/previewStore';
|
||||
import { useAuthStore, type SeekbarStyle } from '../store/authStore';
|
||||
import { bumpPerfCounter } from '../utils/perfTelemetry';
|
||||
function fmt(s: number): string {
|
||||
@@ -961,6 +962,8 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
/** Track preview pauses the main sink in Rust; `isPlaying` stays true so the bar must not extrapolate. */
|
||||
const previewFreezesMainSeekbar = usePreviewStore(s => s.previewingId != null);
|
||||
const waveformBins = usePlayerStore(s => s.waveformBins);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
|
||||
@@ -1192,8 +1195,23 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
}, [seekbarStyle, animationMode]);
|
||||
|
||||
// Smoothly advance progress between sparse transport ticks.
|
||||
// Preview pauses main sink in Rust while UI `isPlaying` may still be true.
|
||||
// When preview ends, interpolation must restart from "now", otherwise the
|
||||
// old anchor timestamp adds preview duration and causes a one-frame jump.
|
||||
useEffect(() => {
|
||||
if (!isPlaying || duration <= 0 || !isFinite(duration)) return;
|
||||
progressAnchorRef.current = {
|
||||
progress: progressRef.current,
|
||||
atMs: performance.now(),
|
||||
};
|
||||
const quantizedOrRaw = isBarQuantizedSeekStyle(styleRef.current)
|
||||
? quantizeProgressByBars(progressRef.current)
|
||||
: progressRef.current;
|
||||
visualTargetProgressRef.current = quantizedOrRaw;
|
||||
// Keep current visual position as-is; only reset timing anchor.
|
||||
}, [previewFreezesMainSeekbar]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || previewFreezesMainSeekbar || duration <= 0 || !isFinite(duration)) return;
|
||||
let rafId: number | null = null;
|
||||
let lastPaintAt = 0;
|
||||
const tick = (now: number) => {
|
||||
@@ -1249,7 +1267,7 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
return () => {
|
||||
if (rafId != null) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [duration, isPlaying]);
|
||||
}, [duration, isPlaying, previewFreezesMainSeekbar]);
|
||||
|
||||
// Resize observer.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -207,6 +207,7 @@ const CONTRIBUTORS = [
|
||||
'Tauri: modularize audio and lib command layers (PR #422)',
|
||||
'Shortcuts: action registry, dynamic CLI help, 9 new input targets + F1 help binding (PR #435)',
|
||||
'Environment upgrade & hot-cache playback — replay via RAM/disk on same-track and queue-end resume, playback-source icon stays correct after resume/undo/gapless, sidebar new-releases 500-id cap merge, Windows tray double-click fix, lazy-loaded routes, rodio 0.22 migration (PR #463)',
|
||||
'Audio: post-sleep stream recovery (Windows + Linux) with poll-gap-armed stall watchdog; preview seekbar freeze + anti-jump on preview end; remove card hover lift and per-card GPU compositing hints (PR #476)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -208,7 +208,6 @@
|
||||
}
|
||||
|
||||
.album-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: inset 0 0 0 1px var(--border), var(--shadow-md);
|
||||
}
|
||||
|
||||
@@ -223,11 +222,13 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform var(--transition-slow);
|
||||
transform-origin: center center;
|
||||
transform: scale(1);
|
||||
transition: opacity 0.15s ease, transform 350ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.album-card:hover .album-card-cover img {
|
||||
transform: scale(1.04);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.album-card-cover-placeholder {
|
||||
@@ -252,7 +253,6 @@
|
||||
}
|
||||
|
||||
.artist-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--border);
|
||||
}
|
||||
@@ -272,11 +272,13 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform var(--transition-slow);
|
||||
transform-origin: center center;
|
||||
transform: scale(1);
|
||||
transition: opacity 0.15s ease, transform 350ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.artist-card:hover .artist-card-avatar img {
|
||||
transform: scale(1.04);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.artist-card-avatar-initial {
|
||||
@@ -7870,7 +7872,7 @@ html.no-compositing .fs-seekbar-played {
|
||||
}
|
||||
|
||||
.mix-pick-card:hover {
|
||||
transform: translateY(-4px) scale(1.015);
|
||||
transform: scale(1.015);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.35), 0 0 0 1px var(--accent), 0 0 24px -4px var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
@@ -12137,7 +12139,7 @@ html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .song-card-c
|
||||
min-width: 0;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.np-dash-disc-tile:hover { transform: translateY(-2px); }
|
||||
.np-dash-disc-tile:hover { transform: none; }
|
||||
.np-dash-disc-cover {
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
@@ -3899,7 +3899,6 @@ select.input.input:focus {
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: inset 0 0 0 1px var(--border), var(--shadow-md);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user