diff --git a/CHANGELOG.md b/CHANGELOG.md index e5144fa6..efc100b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1bbf7517..951b351f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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", diff --git a/src-tauri/src/audio/device_watcher.rs b/src-tauri/src/audio/device_watcher.rs index a34070e3..ff43975f 100644 --- a/src-tauri/src/audio/device_watcher.rs +++ b/src-tauri/src/audio/device_watcher.rs @@ -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, + notify: ReopenNotify, +) -> bool { + let Some(engine) = app.try_state::() 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::>(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 = 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 = None; + let mut last_stall_recover_at: Option = None; + let mut last_poll_at = Instant::now(); + let mut watchdog_armed_until: Option = 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::>(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::>(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(); + } } }); } diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs index fe730a7b..e43f7b57 100644 --- a/src-tauri/src/audio/mod.rs +++ b/src-tauri/src/audio/mod.rs @@ -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}; diff --git a/src-tauri/src/audio/power_notify_linux.rs b/src-tauri/src/audio/power_notify_linux.rs new file mode 100644 index 00000000..8b34acbf --- /dev/null +++ b/src-tauri/src/audio/power_notify_linux.rs @@ -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; + }); + } +} diff --git a/src-tauri/src/audio/power_notify_win.rs b/src-tauri/src/audio/power_notify_win.rs new file mode 100644 index 00000000..dc6ef8d0 --- /dev/null +++ b/src-tauri/src/audio/power_notify_win.rs @@ -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. +} diff --git a/src-tauri/src/audio/power_resume.rs b/src-tauri/src/audio/power_resume.rs new file mode 100644 index 00000000..196a25ea --- /dev/null +++ b/src-tauri/src/audio/power_resume.rs @@ -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> = 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::() { + 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" + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 17a1e387..a68bf001 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 — diff --git a/src/components/CachedImage.tsx b/src/components/CachedImage.tsx index cfcc557b..24b6920f 100644 --- a/src/components/CachedImage.tsx +++ b/src/components/CachedImage.tsx @@ -234,7 +234,7 @@ export default function CachedImage({ { setLoaded(true); onLoad?.(e); }} onError={handleError} {...props} diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index 3be2fed6..7798ce9c 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -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(() => { diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 7e27a6b5..a942d660 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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)', ], }, { diff --git a/src/styles/components.css b/src/styles/components.css index ac2981f7..d342f398 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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); diff --git a/src/styles/theme.css b/src/styles/theme.css index 9d5c1a77..be45b26a 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -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); }