diff --git a/app-icon.png b/app-icon.png deleted file mode 100644 index aba32071..00000000 Binary files a/app-icon.png and /dev/null differ diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d11b2623..8a9517e5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -66,6 +66,7 @@ windows = { version = "0.58", features = [ "Win32_Graphics", "Win32_Graphics_Gdi", "Win32_System_Com", + "Win32_System_Threading", "Win32_UI_Controls", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", diff --git a/src-tauri/src/audio/commands.rs b/src-tauri/src/audio/commands.rs index 369aad58..94475fbb 100644 --- a/src-tauri/src/audio/commands.rs +++ b/src-tauri/src/audio/commands.rs @@ -1638,12 +1638,13 @@ pub async fn audio_play_radio( let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone()); let notifying = NotifyingSource::new(fade_out, done_flag.clone()); let counting = CountingSource::new(notifying, state.samples_played.clone()); + let boosted = PriorityBoostSource::new(counting); if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); } let sink = Arc::new(Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?); sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0)); - sink.append(counting); + sink.append(boosted); { let mut cur = state.current.lock().unwrap(); diff --git a/src-tauri/src/audio/decode.rs b/src-tauri/src/audio/decode.rs index 13d8a546..a6deed4e 100644 --- a/src-tauri/src/audio/decode.rs +++ b/src-tauri/src/audio/decode.rs @@ -519,7 +519,7 @@ pub(crate) fn parse_gapless_info(data: &[u8]) -> GaplessInfo { /// Result of build_source: the fully-wrapped source plus metadata and control Arcs. pub(crate) struct BuiltSource { - pub(crate) source: CountingSource>>>>, + pub(crate) source: PriorityBoostSource>>>>>, pub(crate) duration_secs: f64, pub(crate) output_rate: u32, pub(crate) output_channels: u16, @@ -611,9 +611,10 @@ pub(crate) fn build_source( let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone()); let notifying = NotifyingSource::new(fade_out, done_flag); let counting = CountingSource::new(notifying, sample_counter); + let boosted = PriorityBoostSource::new(counting); Ok(BuiltSource { - source: counting, + source: boosted, duration_secs: effective_dur, output_rate, output_channels: channels, @@ -670,9 +671,10 @@ pub(crate) fn build_streaming_source( let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone()); let notifying = NotifyingSource::new(fade_out, done_flag); let counting = CountingSource::new(notifying, sample_counter); + let boosted = PriorityBoostSource::new(counting); Ok(BuiltSource { - source: counting, + source: boosted, duration_secs: effective_dur, output_rate, output_channels: channels, diff --git a/src-tauri/src/audio/device_watcher.rs b/src-tauri/src/audio/device_watcher.rs index 99c2bb64..afcba933 100644 --- a/src-tauri/src/audio/device_watcher.rs +++ b/src-tauri/src/audio/device_watcher.rs @@ -5,6 +5,8 @@ use std::time::Duration; use tauri::Emitter; use super::engine::AudioEngine; +#[cfg(not(target_os = "linux"))] +use super::dev_io::output_enumeration_includes_pinned; pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) { let reopen_tx = engine.stream_reopen_tx.clone(); diff --git a/src-tauri/src/audio/preview.rs b/src-tauri/src/audio/preview.rs index 39d213e4..4fae198f 100644 --- a/src-tauri/src/audio/preview.rs +++ b/src-tauri/src/audio/preview.rs @@ -9,6 +9,7 @@ use tauri::{AppHandle, Emitter, State}; use super::decode::SizedDecoder; use super::engine::{audio_http_client, AudioEngine}; use super::helpers::MASTER_HEADROOM; +use super::sources::PriorityBoostSource; // ──────────────────────────────────────────────────────────────────────────── // Preview engine — secondary Sink on the same OutputStream, fed by Symphonia. @@ -189,6 +190,7 @@ pub async fn audio_preview_play( } let dur = Duration::from_secs_f64(duration_sec.max(1.0).min(120.0)); let source = source.take_duration(dur); + let source = PriorityBoostSource::new(source); // ── Build secondary sink on the existing OutputStream ──────────────────── let sink = Arc::new( diff --git a/src-tauri/src/audio/sources.rs b/src-tauri/src/audio/sources.rs index 4defa3a5..46e18f70 100644 --- a/src-tauri/src/audio/sources.rs +++ b/src-tauri/src/audio/sources.rs @@ -399,3 +399,83 @@ impl> Source for CountingSource { result } } + +// ─── PriorityBoostSource — promote the calling thread on first sample ──────── +// +// rodio's `Sink` runs `Source::next` inside the cpal output-stream callback. +// On Windows that callback is the WASAPI render thread, which by default has +// only normal priority — when WebView2 / DWM / GPU work spikes the system, +// the audio thread gets preempted and underruns produce audible click / +// stutter. This wrapper sets the MMCSS "Pro Audio" task class on the first +// `next()` call so the kernel keeps the render thread on a real-time class +// alongside other audio applications. On Linux/macOS the wrapper compiles to +// a no-op — those platforms already promote their audio threads externally +// (PipeWire/rtkit, CoreAudio). +// +// Idempotent across track changes: each new track instantiates a fresh +// PriorityBoostSource, but `AvSetMmThreadCharacteristicsW` can be called +// repeatedly on the same thread. + +#[cfg(target_os = "windows")] +fn promote_thread_to_pro_audio() { + use std::sync::atomic::{AtomicBool, Ordering}; + use windows::core::PCWSTR; + use windows::Win32::System::Threading::AvSetMmThreadCharacteristicsW; + + static LOGGED: AtomicBool = AtomicBool::new(false); + + // Null-terminated UTF-16 task name, lifetime-pinned for the call. + let task: [u16; 10] = [ + b'P' as u16, b'r' as u16, b'o' as u16, b' ' as u16, + b'A' as u16, b'u' as u16, b'd' as u16, b'i' as u16, + b'o' as u16, 0, + ]; + let mut idx: u32 = 0; + let result = unsafe { AvSetMmThreadCharacteristicsW(PCWSTR(task.as_ptr()), &mut idx) }; + + if result.is_ok() && !LOGGED.swap(true, Ordering::Relaxed) { + // First-time log: not in the hot path on subsequent track starts. + // Logging is file IO (blocking) but we only run it once per process + // lifetime, on the very first render-callback invocation. + crate::app_eprintln!("[psysonic] WASAPI render thread promoted to MMCSS \"Pro Audio\""); + } + // Handle leaks intentionally — promotion lasts until the thread exits, + // which matches the WASAPI render-thread lifetime. +} + +#[cfg(not(target_os = "windows"))] +#[inline(always)] +fn promote_thread_to_pro_audio() {} + +pub(crate) struct PriorityBoostSource> { + inner: S, + promoted: bool, +} + +impl> PriorityBoostSource { + pub(crate) fn new(inner: S) -> Self { + Self { inner, promoted: false } + } +} + +impl> Iterator for PriorityBoostSource { + type Item = f32; + #[inline] + fn next(&mut self) -> Option { + if !self.promoted { + self.promoted = true; + promote_thread_to_pro_audio(); + } + self.inner.next() + } +} + +impl> Source for PriorityBoostSource { + fn current_frame_len(&self) -> Option { self.inner.current_frame_len() } + fn channels(&self) -> u16 { self.inner.channels() } + fn sample_rate(&self) -> u32 { self.inner.sample_rate() } + fn total_duration(&self) -> Option { self.inner.total_duration() } + fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> { + self.inner.try_seek(pos) + } +} diff --git a/src-tauri/src/lib_commands/sync/tray.rs b/src-tauri/src/lib_commands/sync/tray.rs index ff1543da..35d5ea37 100644 --- a/src-tauri/src/lib_commands/sync/tray.rs +++ b/src-tauri/src/lib_commands/sync/tray.rs @@ -390,6 +390,11 @@ pub(crate) fn no_compositing_mode() -> bool { .unwrap_or(false) } +#[cfg(not(target_os = "linux"))] +pub(crate) fn is_tiling_wm() -> bool { + false +} + /// Tauri command: lets the frontend know whether we're running under a tiling /// WM so it can decide whether to render the custom TitleBar component. #[tauri::command] diff --git a/src/App.tsx b/src/App.tsx index 0f3d0702..a193bed1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -580,6 +580,25 @@ function AppShell() { return () => document.removeEventListener('visibilitychange', update); }, []); + // Pause cosmetic animations when the window loses OS focus but stays visible + // (alt-tab, click into another app). On low-VRAM laptops WebView2 keeps + // compositing mesh blobs / waveform / marquee at full rate even though the + // user isn't looking — measurable GPU drain reported in issue #334. + useEffect(() => { + const update = () => { + const blurred = !document.hasFocus(); + window.__psyBlurred = blurred; + document.documentElement.dataset.appBlurred = blurred ? 'true' : 'false'; + }; + window.addEventListener('focus', update); + window.addEventListener('blur', update); + update(); + return () => { + window.removeEventListener('focus', update); + window.removeEventListener('blur', update); + }; + }, []); + const isMobilePlayer = isMobile && location.pathname === '/now-playing'; return ( diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index 1ca94d73..f0c44668 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -825,7 +825,7 @@ export function SeekbarPreview({ } }; const tick = () => { - if (document.hidden || window.__psyHidden) { + if (document.hidden || window.__psyHidden || window.__psyBlurred) { pollId = window.setTimeout(() => { pollId = null; tick(); @@ -912,11 +912,14 @@ export default function WaveformSeek({ trackId }: Props) { const waveformBins = usePlayerStore(s => s.waveformBins); const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); const seekbarStyle = useAuthStore(s => s.seekbarStyle); + const reducedAnimations = useAuthStore(s => s.reducedAnimations); // Ref so the subscription callback (closed over at mount) can read the // current style without stale-closure issues. const styleRef = useRef(seekbarStyle); styleRef.current = seekbarStyle; + const reducedRef = useRef(reducedAnimations); + reducedRef.current = reducedAnimations; useEffect(() => { if (!trackId) { @@ -1051,6 +1054,7 @@ export default function WaveformSeek({ trackId }: Props) { animStateRef.current = makeAnimState(); let rafId: number | null = null; let pollId: number | null = null; + let skip = false; const stop = () => { if (rafId !== null) { cancelAnimationFrame(rafId); @@ -1062,14 +1066,22 @@ export default function WaveformSeek({ trackId }: Props) { } }; const tick = () => { - if (document.hidden || window.__psyHidden) { + if (document.hidden || window.__psyHidden || window.__psyBlurred) { pollId = window.setTimeout(() => { pollId = null; tick(); }, 400); return; } - animStateRef.current.time += 0.016; + // 30 fps cap when reducedAnimations is on: skip every other rAF, advance + // animation time by a doubled delta so wave speed stays the same. + if (reducedRef.current && skip) { + skip = false; + rafId = requestAnimationFrame(tick); + return; + } + skip = reducedRef.current; + animStateRef.current.time += reducedRef.current ? 0.032 : 0.016; drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current); rafId = requestAnimationFrame(tick); }; diff --git a/src/locales/de.ts b/src/locales/de.ts index 7e22230d..950fb3c4 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -915,6 +915,8 @@ export const deTranslation = { sidebarLyricsStyleAppleDesc: 'Aktive Zeile oben mit sanfter Animation.', seekbarStyle: 'Seekbar-Stil', seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen', + reducedAnimations: 'Animationen reduzieren', + reducedAnimationsDesc: 'Animierte Seekbar-Stile auf 30 fps drosseln, um die GPU-Last auf schwächerer Hardware zu senken. Optisch kaum wahrnehmbar.', seekbarTruewave: 'Echte Wellenform', seekbarPseudowave: 'Pseudo-Wellenform', seekbarLinedot: 'Linie & Punkt', diff --git a/src/locales/en.ts b/src/locales/en.ts index 339408ab..89891da8 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -921,6 +921,8 @@ export const enTranslation = { fsPortraitDim: 'Photo dimming', seekbarStyle: 'Seekbar Style', seekbarStyleDesc: 'Choose the look of the player seek bar', + reducedAnimations: 'Reduce animations', + reducedAnimationsDesc: 'Cap animated seekbar styles to 30 fps to lower GPU usage on slower hardware. Visual difference is minimal.', seekbarTruewave: 'Truewave', seekbarPseudowave: 'Pseudowave', seekbarLinedot: 'Line & Dot', diff --git a/src/locales/es.ts b/src/locales/es.ts index c1f8e128..f0b366d5 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -908,6 +908,8 @@ export const esTranslation = { sidebarLyricsStyleAppleDesc: 'La línea activa se ancla arriba con animación suave.', seekbarStyle: 'Estilo de Barra de Progreso', seekbarStyleDesc: 'Elige la apariencia de la barra de progreso del reproductor', + reducedAnimations: 'Reducir animaciones', + reducedAnimationsDesc: 'Limita los estilos animados de la barra de progreso a 30 fps para reducir el uso de GPU en hardware más lento. La diferencia visual es mínima.', seekbarTruewave: 'Forma de Onda Real', seekbarPseudowave: 'Forma de Onda Pseudo', seekbarLinedot: 'Línea y Punto', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 69054263..f7420d90 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -903,6 +903,8 @@ export const frTranslation = { sidebarLyricsStyleAppleDesc: "La ligne active reste en haut avec une animation fluide.", seekbarStyle: 'Style de la barre de lecture', seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression', + reducedAnimations: 'Réduire les animations', + reducedAnimationsDesc: 'Limiter les styles de barre de lecture animés à 30 fps pour réduire l\'utilisation du GPU sur le matériel plus lent. La différence visuelle est minime.', seekbarTruewave: 'Forme d\'onde réelle', seekbarPseudowave: 'Forme d\'onde pseudo', seekbarLinedot: 'Ligne & point', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index bfc8b7cd..c7a6f33e 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -902,6 +902,8 @@ export const nbTranslation = { sidebarLyricsStyleAppleDesc: 'Aktiv linje forankres øverst med jevn animasjon.', seekbarStyle: 'Søkefelt-stil', seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet', + reducedAnimations: 'Reduser animasjoner', + reducedAnimationsDesc: 'Begrens animerte søkefelt-stiler til 30 fps for å redusere GPU-belastning på tregere maskinvare. Visuell forskjell er minimal.', seekbarTruewave: 'Ekte bølgeform', seekbarPseudowave: 'Pseudo-bølgeform', seekbarLinedot: 'Linje & punkt', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index d9d65483..275fc676 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -902,6 +902,8 @@ export const nlTranslation = { sidebarLyricsStyleAppleDesc: 'Actieve regel verankerd bovenaan met vloeiende animatie.', seekbarStyle: 'Zoekbalkstijl', seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk', + reducedAnimations: 'Animaties beperken', + reducedAnimationsDesc: 'Beperk geanimeerde zoekbalkstijlen tot 30 fps om GPU-gebruik op tragere hardware te verminderen. Visueel verschil is minimaal.', seekbarTruewave: 'Echte golfvorm', seekbarPseudowave: 'Pseudo-golfvorm', seekbarLinedot: 'Lijn & punt', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index e77fda99..a6b814c3 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -954,6 +954,8 @@ export const ruTranslation = { sidebarLyricsStyleAppleDesc: 'Активная строка фиксируется сверху с плавной анимацией.', seekbarStyle: 'Стиль прогресс-бара', seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения', + reducedAnimations: 'Снизить анимации', + reducedAnimationsDesc: 'Ограничить анимированные стили прогресс-бара 30 кадрами в секунду для снижения нагрузки на GPU на слабом железе. Визуальная разница минимальна.', seekbarTruewave: 'Реальная форма волны', seekbarPseudowave: 'Псевдо форма волны', seekbarLinedot: 'Линия и точка', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 1d5e53ac..53aaa121 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -897,6 +897,8 @@ export const zhTranslation = { sidebarLyricsStyleAppleDesc: '活动行固定在顶部,带有平滑弹簧动画。', seekbarStyle: '进度条样式', seekbarStyleDesc: '选择播放进度条的外观', + reducedAnimations: '减少动画', + reducedAnimationsDesc: '将动画进度条样式限制为 30 fps,以降低较慢硬件上的 GPU 占用。视觉差异极小。', seekbarTruewave: '真实波形', seekbarPseudowave: '伪波形', seekbarLinedot: '线条与点', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 47477e40..a4a3629b 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -396,7 +396,7 @@ const SETTINGS_INDEX: SearchIndexEntry[] = [ { tab: 'appearance', titleKey: 'settings.uiScaleTitle', keywords: 'ui scale zoom dpi size' }, { tab: 'appearance', titleKey: 'settings.font', keywords: 'font typography typeface' }, { tab: 'appearance', titleKey: 'settings.fsPlayerSection', keywords: 'fullscreen player mesh blob' }, - { tab: 'appearance', titleKey: 'settings.seekbarStyle', keywords: 'seekbar progress bar waveform' }, + { tab: 'appearance', titleKey: 'settings.seekbarStyle', keywords: 'seekbar progress bar waveform reduced animations performance gpu fps low-end framerate cap' }, { tab: 'input', titleKey: 'settings.inputKeybindingsTitle', keywords: 'keybindings shortcuts hotkeys keyboard' }, { tab: 'input', titleKey: 'settings.globalShortcutsTitle', keywords: 'global shortcuts hotkeys system-wide media keys' }, { tab: 'system', titleKey: 'settings.language', keywords: 'language locale translation i18n' }, @@ -3844,6 +3844,16 @@ export default function Settings() { /> ))} +
+
+
{t('settings.reducedAnimations')}
+
{t('settings.reducedAnimationsDesc')}
+
+ +
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 2c88c8e8..83b60bb3 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -176,6 +176,8 @@ interface AuthState { lastSeenChangelogVersion: string; seekbarStyle: SeekbarStyle; + /** Cap animated seekbar styles to 30 fps (and similar GPU-friendly tweaks) for low-end hardware. */ + reducedAnimations: boolean; /** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */ enableHiRes: boolean; @@ -323,6 +325,7 @@ interface AuthState { setShowChangelogOnUpdate: (v: boolean) => void; setLastSeenChangelogVersion: (v: string) => void; setSeekbarStyle: (v: SeekbarStyle) => void; + setReducedAnimations: (v: boolean) => void; setEnableHiRes: (v: boolean) => void; setAudioOutputDevice: (v: string | null) => void; setHotCacheEnabled: (v: boolean) => void; @@ -442,6 +445,7 @@ export const useAuthStore = create()( showChangelogOnUpdate: true, lastSeenChangelogVersion: '', seekbarStyle: 'truewave', + reducedAnimations: false, enableHiRes: false, audioOutputDevice: null, hotCacheEnabled: false, @@ -603,6 +607,7 @@ export const useAuthStore = create()( setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), setSeekbarStyle: (v) => set({ seekbarStyle: v }), + setReducedAnimations: (v) => set({ reducedAnimations: v }), setEnableHiRes: (v) => set({ enableHiRes: v }), setAudioOutputDevice: (v) => set({ audioOutputDevice: v }), setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }), diff --git a/src/styles/components.css b/src/styles/components.css index 741dc7ac..ec4e5064 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -11097,7 +11097,10 @@ html[data-app-hidden="true"] *::before, html[data-app-hidden="true"] *::after, html[data-psy-native-hidden="true"] *, html[data-psy-native-hidden="true"] *::before, -html[data-psy-native-hidden="true"] *::after { +html[data-psy-native-hidden="true"] *::after, +html[data-app-blurred="true"] *, +html[data-app-blurred="true"] *::before, +html[data-app-blurred="true"] *::after { animation-play-state: paused !important; } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 0c02eab1..ef481c5d 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -3,6 +3,7 @@ declare global { interface Window { __psyHidden?: boolean; + __psyBlurred?: boolean; } }