fix(windows): idle WebView2 when Tauri windows are hidden (#273)

* fix(windows): stop GPU rendering when windows are hidden

* perf(ui): tighten hidden-window rendering mitigation

Align implementation and documentation with what the injected pause/resume
scripts actually do, reduce main-thread and compositor wakeups while windows
are invisible, and make lifecycle cleanup reliable.

Rust (Tauri)

- Document PAUSE/RESUME as compositor-oriented hints: set __psyHidden, optional
  --psy-anim-speed for CSS that opts in, and pause @keyframes via
  animation-play-state only (not CSS transitions, not arbitrary timers or rAF).
- Walk descendants under #root instead of document-wide querySelectorAll('*')
  to cut cost on large pages; skip the walk if #root is missing (flag still set).
- Call eval(PAUSE_RENDERING_JS) before hide() on all hide paths (tray menu,
  tray icon toggle, mini open/close/show-main, native mini close) so the
  webview sees __psyHidden while still fully schedulable.
- Shorten pause_rendering command rustdoc to match the script.

Frontend

- WindowVisibilityProvider: replace self-rescheduling useCallback with a single
  effect that keeps one pending timeout id, uses a cancelled flag on unmount,
  and syncs hiddenRef from document.hidden at mount (no orphaned timers after
  tests/HMR).
- WaveformSeek (preview + animated seekbar): while hidden, poll with a 400 ms
  timeout instead of requestAnimationFrame every frame; cancel both rAF and
  timeout on teardown.

Why

- Accurate comments prevent future refactors from assuming “full JS idle”.
- Smaller DOM walks and fewer rAF wakeups reduce spikes when hiding the main
  window or mini player on Windows WebView2 and elsewhere.

* perf(ui): pause global CSS + timers when Tauri hides the window

Builds on PR #273 (`7803d8e` + `0e07a73`). Adds a second HTML flag driven only by
Rust pause/resume inject so infinite animations still pause when WebView2 keeps
`document.hidden === false` after `win.hide()`, and stops a few periodic JS
timers while the window is invisible.

- `lib.rs`: set/remove `data-psy-native-hidden` on `<html>` in PAUSE/RESUME JS;
  document why in rustdoc (rest of pause/resume unchanged from PR tip).
- `components.css`: same `animation-play-state: paused !important` rules as
  `data-app-hidden` for `[data-psy-native-hidden="true"]`; refresh the comment.
- `App.tsx`: comment distinguishes tab visibility vs Tauri-native hide.
- `Hero.tsx`: do not run the 10 s carousel interval while hidden
  (`useWindowVisibility` + `__psyHidden` in tick).
- `PlaybackScheduleBadge.tsx`, `playbackScheduleFormat.ts`: no 400/500 ms
  intervals while hidden; skip ticks when `document.hidden` or `__psyHidden`.
This commit is contained in:
Ivan Pelipenko
2026-04-23 23:47:23 +03:00
committed by GitHub
parent 694567843f
commit 0ead4fbeb1
9 changed files with 258 additions and 52 deletions
+85 -2
View File
@@ -2747,8 +2747,10 @@ fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
"show_hide" => { "show_hide" => {
if let Some(win) = app.get_webview_window("main") { if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) { if win.is_visible().unwrap_or(false) {
let _ = win.eval(PAUSE_RENDERING_JS);
let _ = win.hide(); let _ = win.hide();
} else { } else {
let _ = win.eval(RESUME_RENDERING_JS);
let _ = win.show(); let _ = win.show();
let _ = win.set_focus(); let _ = win.set_focus();
} }
@@ -2781,8 +2783,10 @@ fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
let app = tray.app_handle(); let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") { if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) { if win.is_visible().unwrap_or(false) {
let _ = win.eval(PAUSE_RENDERING_JS);
let _ = win.hide(); let _ = win.hide();
} else { } else {
let _ = win.eval(RESUME_RENDERING_JS);
let _ = win.show(); let _ = win.show();
let _ = win.set_focus(); let _ = win.set_focus();
} }
@@ -3027,6 +3031,45 @@ fn default_mini_position(app: &tauri::AppHandle) -> Option<tauri::PhysicalPositi
)) ))
} }
/// JS snippet to inject into a hidden webview to reduce compositor work while
/// the host window is invisible.
///
/// WebView2 on Windows can keep a GPU-backed compositor active even when the
/// native window is hidden. This script does **not** stop arbitrary JS timers
/// or every `requestAnimationFrame` loop — it sets a flag the app reads, zeros
/// `--psy-anim-speed` (for CSS that opts into it), and pauses **@keyframes**
/// animations via `animation-play-state` (not CSS transitions).
///
/// Also sets `data-psy-native-hidden` on `<html>` so global CSS can pause every
/// animation including `::before`/`::after` and portal content under `<body>`
/// when `document.hidden` stays false on some WebView2 builds after `win.hide()`.
const PAUSE_RENDERING_JS: &str = r#"
window.__psyHidden = true;
document.documentElement.setAttribute('data-psy-native-hidden', 'true');
document.documentElement.style.setProperty('--psy-anim-speed', '0');
(function () {
const root = document.getElementById('root');
if (!root) return;
root.querySelectorAll('*').forEach(function (el) {
el.style.animationPlayState = 'paused';
});
})();
"#;
/// JS snippet to resume rendering when the window becomes visible again.
const RESUME_RENDERING_JS: &str = r#"
window.__psyHidden = false;
document.documentElement.removeAttribute('data-psy-native-hidden');
document.documentElement.style.removeProperty('--psy-anim-speed');
(function () {
const root = document.getElementById('root');
if (!root) return;
root.querySelectorAll('*').forEach(function (el) {
el.style.animationPlayState = '';
});
})();
"#;
/// Build the mini player webview window. Caller decides `visible` so the /// Build the mini player webview window. Caller decides `visible` so the
/// same code path serves both pre-creation (Windows, hidden at app start) /// same code path serves both pre-creation (Windows, hidden at app start)
/// and lazy creation (other platforms, shown on demand). /// and lazy creation (other platforms, shown on demand).
@@ -3099,9 +3142,18 @@ fn build_mini_player_window(
// fire stray Moved events with default coords during the first paint. // fire stray Moved events with default coords during the first paint.
mark_mini_pos_programmatic(); mark_mini_pos_programmatic();
builder let win = builder
.build() .build()
.map_err(|e| format!("failed to build mini player window: {e}")) .map_err(|e| format!("failed to build mini player window: {e}"))?;
// Inject pause script immediately when the window is created hidden.
// On Windows WebView2 keeps the GPU context alive even with
// `SetIsVisible(false)` — this JS stops all rendering work.
if !visible {
let _ = win.eval(PAUSE_RENDERING_JS);
}
Ok(win)
} }
/// Pre-build the mini player window hidden, so the first `open_mini_player` /// Pre-build the mini player window hidden, so the first `open_mini_player`
@@ -3131,6 +3183,9 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
let visible = win.is_visible().unwrap_or(false); let visible = win.is_visible().unwrap_or(false);
if visible { if visible {
// Pause before hide so `__psyHidden` is set while the webview is still
// guaranteed schedulable (mirrors tray / main close ordering).
let _ = win.eval(PAUSE_RENDERING_JS);
win.hide().map_err(|e| e.to_string())?; win.hide().map_err(|e| e.to_string())?;
if let Some(main) = app.get_webview_window("main") { if let Some(main) = app.get_webview_window("main") {
let _ = main.unminimize(); let _ = main.unminimize();
@@ -3138,6 +3193,9 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
let _ = main.set_focus(); let _ = main.set_focus();
} }
} else { } else {
// Resume rendering before showing — the window needs to be ready
// to paint as soon as it becomes visible.
let _ = win.eval(RESUME_RENDERING_JS);
// Re-applying the saved position after show() — many Linux WMs // Re-applying the saved position after show() — many Linux WMs
// (Mutter, KWin) re-centre hidden windows when they're shown // (Mutter, KWin) re-centre hidden windows when they're shown
// again, ignoring any earlier set_position. Mark the move as // again, ignoring any earlier set_position. Mark the move as
@@ -3162,6 +3220,7 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
#[tauri::command] #[tauri::command]
fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> { fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> {
if let Some(win) = app.get_webview_window("mini") { if let Some(win) = app.get_webview_window("mini") {
let _ = win.eval(PAUSE_RENDERING_JS);
win.hide().map_err(|e| e.to_string())?; win.hide().map_err(|e| e.to_string())?;
} }
if let Some(main) = app.get_webview_window("main") { if let Some(main) = app.get_webview_window("main") {
@@ -3179,9 +3238,11 @@ fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> {
#[tauri::command] #[tauri::command]
fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
if let Some(mini) = app.get_webview_window("mini") { if let Some(mini) = app.get_webview_window("mini") {
let _ = mini.eval(PAUSE_RENDERING_JS);
let _ = mini.hide(); let _ = mini.hide();
} }
if let Some(main) = app.get_webview_window("main") { if let Some(main) = app.get_webview_window("main") {
let _ = main.eval(RESUME_RENDERING_JS);
main.unminimize().map_err(|e| e.to_string())?; main.unminimize().map_err(|e| e.to_string())?;
main.show().map_err(|e| e.to_string())?; main.show().map_err(|e| e.to_string())?;
main.set_focus().map_err(|e| e.to_string())?; main.set_focus().map_err(|e| e.to_string())?;
@@ -3189,6 +3250,19 @@ fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Inject the pause script into this webview (CSS @keyframes pause + `__psyHidden`).
#[tauri::command]
fn pause_rendering(window: tauri::WebviewWindow) -> Result<(), String> {
window.eval(PAUSE_RENDERING_JS).map_err(|e| e.to_string())
}
/// Resume rendering work in the current webview. Called when the window
/// becomes visible again.
#[tauri::command]
fn resume_rendering(window: tauri::WebviewWindow) -> Result<(), String> {
window.eval(RESUME_RENDERING_JS).map_err(|e| e.to_string())
}
/// Toggle always-on-top on the mini player window. /// Toggle always-on-top on the mini player window.
/// ///
/// Some window managers (KWin, certain Mutter releases, GNOME-on-Wayland) /// Some window managers (KWin, certain Mutter releases, GNOME-on-Wayland)
@@ -3530,6 +3604,10 @@ pub fn run() {
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
{ {
// Pause rendering before JS decides whether to hide to tray or exit.
if let Some(w) = window.app_handle().get_webview_window("main") {
let _ = w.eval(PAUSE_RENDERING_JS);
}
// Let JS decide: minimize to tray or exit, based on user setting. // Let JS decide: minimize to tray or exit, based on user setting.
let _ = window.emit("window:close-requested", ()); let _ = window.emit("window:close-requested", ());
} }
@@ -3537,6 +3615,9 @@ pub fn run() {
// Native close on the mini: hide instead of destroying so // Native close on the mini: hide instead of destroying so
// state is preserved, and restore the main window. // state is preserved, and restore the main window.
api.prevent_close(); api.prevent_close();
if let Some(w) = window.app_handle().get_webview_window("mini") {
let _ = w.eval(PAUSE_RENDERING_JS);
}
let _ = window.hide(); let _ = window.hide();
if let Some(main) = window.app_handle().get_webview_window("main") { if let Some(main) = window.app_handle().get_webview_window("main") {
let _ = main.unminimize(); let _ = main.unminimize();
@@ -3567,6 +3648,8 @@ pub fn run() {
set_mini_player_always_on_top, set_mini_player_always_on_top,
resize_mini_player, resize_mini_player,
show_main_window, show_main_window,
pause_rendering,
resume_rendering,
register_global_shortcut, register_global_shortcut,
unregister_global_shortcut, unregister_global_shortcut,
mpris_set_metadata, mpris_set_metadata,
+27 -25
View File
@@ -11,6 +11,7 @@ import PlayerBar from './components/PlayerBar';
import BottomNav from './components/BottomNav'; import BottomNav from './components/BottomNav';
import MobilePlayerView from './components/MobilePlayerView'; import MobilePlayerView from './components/MobilePlayerView';
import { useIsMobile } from './hooks/useIsMobile'; import { useIsMobile } from './hooks/useIsMobile';
import { WindowVisibilityProvider } from './hooks/useWindowVisibility';
import LiveSearch from './components/LiveSearch'; import LiveSearch from './components/LiveSearch';
import NowPlayingDropdown from './components/NowPlayingDropdown'; import NowPlayingDropdown from './components/NowPlayingDropdown';
import QueuePanel from './components/QueuePanel'; import QueuePanel from './components/QueuePanel';
@@ -377,11 +378,9 @@ function AppShell() {
}; };
}, []); }, []);
// Pause CSS animations when the window is minimized / hidden. // Pause CSS animations when the browser tab is hidden (`document.hidden`).
// WebView2 on Windows keeps compositing infinite-loop animations (mesh-aura, // Tauri `win.hide()` is mirrored separately via `data-psy-native-hidden` from
// portrait-drift, eq-bounce, …) even when the app is minimized, which shows // Rust (see components.css). WebView2 can keep compositing without the former.
// up as steady GPU usage. The CSS rule `html[data-app-hidden="true"]` in
// components.css pauses all running animations while this flag is set.
useEffect(() => { useEffect(() => {
const update = () => { const update = () => {
document.documentElement.dataset.appHidden = document.hidden ? 'true' : 'false'; document.documentElement.dataset.appHidden = document.hidden ? 'true' : 'false';
@@ -932,10 +931,11 @@ function TauriEventBridge() {
unlisten.push(u); unlisten.push(u);
} }
// window:close-requested is emitted by Rust (prevent_close + emit). // window:close-requested is emitted by Rust (prevent_close + emit).
// JS decides: minimize to tray or exit, based on user setting. // JS decides: minimize to tray or exit, based on user setting.
const u = await listen('window:close-requested', async () => { const u = await listen('window:close-requested', async () => {
if (useAuthStore.getState().minimizeToTray) { if (useAuthStore.getState().minimizeToTray) {
await invoke('pause_rendering').catch(() => {});
await getCurrentWindow().hide(); await getCurrentWindow().hide();
} else { } else {
await invoke('exit_app'); await invoke('exit_app');
@@ -1134,24 +1134,26 @@ export default function App() {
}, []); }, []);
return ( return (
<BrowserRouter> <WindowVisibilityProvider>
<PasteClipboardHandler /> <BrowserRouter>
<TauriEventBridge /> <PasteClipboardHandler />
<Routes> <TauriEventBridge />
<Route path="/login" element={<Login />} /> <Routes>
<Route <Route path="/login" element={<Login />} />
path="/*" <Route
element={ path="/*"
<RequireAuth> element={
<DragDropProvider> <RequireAuth>
<AppShell /> <DragDropProvider>
</DragDropProvider> <AppShell />
</RequireAuth> </DragDropProvider>
} </RequireAuth>
/> }
</Routes> />
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />} </Routes>
<ZipDownloadOverlay /> {exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
</BrowserRouter> <ZipDownloadOverlay />
</BrowserRouter>
</WindowVisibilityProvider>
); );
} }
+11 -4
View File
@@ -7,6 +7,7 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playAlbum'; import { playAlbum } from '../utils/playAlbum';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore'; import { useThemeStore } from '../store/themeStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter'; import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
@@ -65,6 +66,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [activeIdx, setActiveIdx] = useState(0); const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const windowHidden = useWindowVisibility();
useEffect(() => { useEffect(() => {
if (albumsProp?.length) { setAlbums(albumsProp); return; } if (albumsProp?.length) { setAlbums(albumsProp); return; }
@@ -87,18 +89,23 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
mixMinRatingArtist, mixMinRatingArtist,
]); ]);
// Start / restart auto-advance timer // Start / restart auto-advance timer (paused while the Tauri window is hidden).
const startTimer = useCallback((len: number) => { const startTimer = useCallback((len: number) => {
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
if (len <= 1) return; timerRef.current = null;
if (len <= 1 || windowHidden) return;
timerRef.current = setInterval(() => { timerRef.current = setInterval(() => {
if (document.hidden || (window as any).__psyHidden) return;
setActiveIdx(prev => (prev + 1) % len); setActiveIdx(prev => (prev + 1) % len);
}, INTERVAL_MS); }, INTERVAL_MS);
}, []); }, [windowHidden]);
useEffect(() => { useEffect(() => {
startTimer(albums.length); startTimer(albums.length);
return () => { if (timerRef.current) clearInterval(timerRef.current); }; return () => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
};
}, [albums.length, startTimer]); }, [albums.length, startTimer]);
const goTo = useCallback((idx: number) => { const goTo = useCallback((idx: number) => {
+5
View File
@@ -9,6 +9,7 @@ import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore'; import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { IS_LINUX } from '../utils/platform'; import { IS_LINUX } from '../utils/platform';
import MiniContextMenu from './MiniContextMenu'; import MiniContextMenu from './MiniContextMenu';
import OverlayScrollArea from './OverlayScrollArea'; import OverlayScrollArea from './OverlayScrollArea';
@@ -114,6 +115,9 @@ export default function MiniPlayer() {
const ticker = useRef<number | null>(null); const ticker = useRef<number | null>(null);
const queueScrollRef = useRef<HTMLDivElement>(null); const queueScrollRef = useRef<HTMLDivElement>(null);
const volumeWrapRef = useRef<HTMLDivElement>(null); const volumeWrapRef = useRef<HTMLDivElement>(null);
const hiddenRef = useRef(false);
const isHidden = useWindowVisibility();
useEffect(() => { hiddenRef.current = isHidden; }, [isHidden]);
// ── PsyDnD reorder ── // ── PsyDnD reorder ──
// Mirrors QueuePanel's pattern: mousedown threshold → startDrag, mousemove // Mirrors QueuePanel's pattern: mousedown threshold → startDrag, mousemove
@@ -235,6 +239,7 @@ export default function MiniPlayer() {
if (typeof e.payload.volume === 'number') setVolumeState(e.payload.volume); if (typeof e.payload.volume === 'number') setVolumeState(e.payload.volume);
}); });
const unProgress = listen<ProgressPayload>('audio:progress', (e) => { const unProgress = listen<ProgressPayload>('audio:progress', (e) => {
if (hiddenRef.current || (window as any).__psyHidden) return;
setCurrentTime(e.payload.current_time); setCurrentTime(e.payload.current_time);
if (e.payload.duration > 0) setDuration(e.payload.duration); if (e.payload.duration > 0) setDuration(e.payload.duration);
}); });
+10 -5
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat'; import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
export interface PlaybackScheduleBadgeProps { export interface PlaybackScheduleBadgeProps {
/** Anchor element (usually the play/pause button wrapper) — the ring centres on it. */ /** Anchor element (usually the play/pause button wrapper) — the ring centres on it. */
@@ -46,15 +47,19 @@ export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: Pl
const [nowMs, setNowMs] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now());
const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; size: number } | null>(null); const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; size: number } | null>(null);
const windowHidden = useWindowVisibility();
useEffect(() => { useEffect(() => {
if (deadlineMs == null) return; if (deadlineMs == null || windowHidden) return;
const id = window.setInterval(() => setNowMs(Date.now()), 500); const id = window.setInterval(() => {
if (document.hidden || (window as any).__psyHidden) return;
setNowMs(Date.now());
}, 500);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [deadlineMs]); }, [deadlineMs, windowHidden]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (deadlineMs == null) return; if (deadlineMs == null || windowHidden) return;
const el = layoutAnchorRef.current; const el = layoutAnchorRef.current;
if (!el) return; if (!el) return;
const sync = () => { const sync = () => {
@@ -74,7 +79,7 @@ export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: Pl
window.removeEventListener('scroll', sync, true); window.removeEventListener('scroll', sync, true);
window.clearInterval(iv); window.clearInterval(iv);
}; };
}, [deadlineMs, layoutAnchorRef]); }, [deadlineMs, layoutAnchorRef, windowHidden]);
if (deadlineMs == null || startMs == null || !anchorRect) return null; if (deadlineMs == null || startMs == null || !anchorRect) return null;
+43 -7
View File
@@ -727,7 +727,6 @@ export function SeekbarPreview({
onClick: () => void; onClick: () => void;
}) { }) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const rafRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@@ -738,16 +737,35 @@ export function SeekbarPreview({
} }
const animState = makeAnimState(); const animState = makeAnimState();
let t = 0; let t = 0;
let rafId: number | null = null;
let pollId: number | null = null;
const stop = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (pollId !== null) {
window.clearTimeout(pollId);
pollId = null;
}
};
const tick = () => { const tick = () => {
if (document.hidden || (window as any).__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
}, 400);
return;
}
t += 0.016; t += 0.016;
animState.time = t; animState.time = t;
const progress = 0.15 + 0.65 * (0.5 + 0.5 * Math.sin(t)); const progress = 0.15 + 0.65 * (0.5 + 0.5 * Math.sin(t));
const buffered = Math.min(1, progress + 0.18); const buffered = Math.min(1, progress + 0.18);
drawSeekbar(canvas, style, heights, progress, buffered, animState); drawSeekbar(canvas, style, heights, progress, buffered, animState);
rafRef.current = requestAnimationFrame(tick); rafId = requestAnimationFrame(tick);
}; };
rafRef.current = requestAnimationFrame(tick); tick();
return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); }; return () => stop();
}, [style]); }, [style]);
return ( return (
@@ -869,14 +887,32 @@ export default function WaveformSeek({ trackId }: Props) {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
animStateRef.current = makeAnimState(); animStateRef.current = makeAnimState();
let rafId: number; let rafId: number | null = null;
let pollId: number | null = null;
const stop = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (pollId !== null) {
window.clearTimeout(pollId);
pollId = null;
}
};
const tick = () => { const tick = () => {
if (document.hidden || (window as any).__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
}, 400);
return;
}
animStateRef.current.time += 0.016; animStateRef.current.time += 0.016;
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current); drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
rafId = requestAnimationFrame(tick); rafId = requestAnimationFrame(tick);
}; };
rafId = requestAnimationFrame(tick); tick();
return () => cancelAnimationFrame(rafId); return () => stop();
}, [seekbarStyle]); }, [seekbarStyle]);
// Resize observer. // Resize observer.
+60
View File
@@ -0,0 +1,60 @@
import {
createContext,
useContext,
useState,
useRef,
useEffect,
type ReactNode,
} from 'react';
const WindowVisibilityContext = createContext(false);
/**
* Tracks whether the Tauri window is hidden.
*
* On Windows WebView2, `visibilitychange` and `blur`/`focus` events do not
* fire when `win.hide()` is called. We fall back to polling `document.hidden`
* with an adaptive interval: fast checks while visible (to catch show quickly),
* slow checks while hidden (to minimize CPU wakeups).
*/
export function WindowVisibilityProvider({ children }: { children: ReactNode }) {
const [hidden, setHidden] = useState(document.hidden);
const hiddenRef = useRef(hidden);
useEffect(() => {
hiddenRef.current = document.hidden;
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const schedule = () => {
if (cancelled) return;
const interval = hiddenRef.current ? 1000 : 200;
timeoutId = setTimeout(() => {
timeoutId = null;
if (cancelled) return;
const current = document.hidden;
if (current !== hiddenRef.current) {
hiddenRef.current = current;
setHidden(current);
}
schedule();
}, interval);
};
schedule();
return () => {
cancelled = true;
if (timeoutId !== null) clearTimeout(timeoutId);
};
}, []);
return (
<WindowVisibilityContext.Provider value={hidden}>
{children}
</WindowVisibilityContext.Provider>
);
}
export function useWindowVisibility() {
return useContext(WindowVisibilityContext);
}
+9 -6
View File
@@ -9626,11 +9626,11 @@ html.no-compositing .fs-seekbar-played {
.device-sync-row-meta { font-size: 0.75rem; color: var(--text-secondary); flex-shrink: 0; } .device-sync-row-meta { font-size: 0.75rem; color: var(--text-secondary); flex-shrink: 0; }
/* Pause CSS animations when the window is hidden / minimized /* Pause CSS animations when the window is hidden / minimized
Set via App.tsx on `visibilitychange`. WebView2 on Windows keeps compositing - `data-app-hidden`: App.tsx `visibilitychange` `document.hidden` (all platforms).
infinite CSS animations (mesh-aura, portrait-drift, eq-bounce, track-pulse, - `data-psy-native-hidden`: Rust inject on Tauri `win.hide()` / show (WebView2
led-pulse, ) even when the app is minimized, causing constant GPU use. often keeps `document.hidden === false` when the native window is hidden).
Pausing them cuts that to near zero while hidden without touching the Either flag pauses infinite animations including portaled nodes and ::pseudo,
running Rust/audio threads. */ without touching Rust/audio threads. */
/* What's New banner + page /* What's New banner + page
Fixed neutral palette so it reads identically on every theme (light and Fixed neutral palette so it reads identically on every theme (light and
dark). The banner sits in the sidebar just above Now Playing; the page dark). The banner sits in the sidebar just above Now Playing; the page
@@ -10268,7 +10268,10 @@ html.no-compositing .fs-seekbar-played {
html[data-app-hidden="true"] *, html[data-app-hidden="true"] *,
html[data-app-hidden="true"] *::before, html[data-app-hidden="true"] *::before,
html[data-app-hidden="true"] *::after { 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 {
animation-play-state: paused !important; animation-play-state: paused !important;
} }
+8 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
/** Remaining time until wall-clock `deadlineMs` (m:ss or h:mm:ss). */ /** Remaining time until wall-clock `deadlineMs` (m:ss or h:mm:ss). */
export function formatPlaybackScheduleRemaining(deadlineMs: number | null, nowMs: number): string { export function formatPlaybackScheduleRemaining(deadlineMs: number | null, nowMs: number): string {
@@ -43,11 +44,15 @@ export function usePlaybackScheduleRemaining(): PlaybackScheduleInfo | null {
: null; : null;
const deadlineMs = mode === 'pause' ? scheduledPauseAtMs : mode === 'start' ? scheduledResumeAtMs : null; const deadlineMs = mode === 'pause' ? scheduledPauseAtMs : mode === 'start' ? scheduledResumeAtMs : null;
const [nowMs, setNowMs] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now());
const windowHidden = useWindowVisibility();
useEffect(() => { useEffect(() => {
if (deadlineMs == null) return; if (deadlineMs == null || windowHidden) return;
const id = window.setInterval(() => setNowMs(Date.now()), 500); const id = window.setInterval(() => {
if (document.hidden || (window as any).__psyHidden) return;
setNowMs(Date.now());
}, 500);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [deadlineMs]); }, [deadlineMs, windowHidden]);
if (mode == null || deadlineMs == null) return null; if (mode == null || deadlineMs == null) return null;
return { remaining: formatPlaybackScheduleRemaining(deadlineMs, nowMs), mode }; return { remaining: formatPlaybackScheduleRemaining(deadlineMs, nowMs), mode };
} }