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
+27 -25
View File
@@ -11,6 +11,7 @@ import PlayerBar from './components/PlayerBar';
import BottomNav from './components/BottomNav';
import MobilePlayerView from './components/MobilePlayerView';
import { useIsMobile } from './hooks/useIsMobile';
import { WindowVisibilityProvider } from './hooks/useWindowVisibility';
import LiveSearch from './components/LiveSearch';
import NowPlayingDropdown from './components/NowPlayingDropdown';
import QueuePanel from './components/QueuePanel';
@@ -377,11 +378,9 @@ function AppShell() {
};
}, []);
// Pause CSS animations when the window is minimized / hidden.
// WebView2 on Windows keeps compositing infinite-loop animations (mesh-aura,
// portrait-drift, eq-bounce, …) even when the app is minimized, which shows
// 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.
// Pause CSS animations when the browser tab is hidden (`document.hidden`).
// Tauri `win.hide()` is mirrored separately via `data-psy-native-hidden` from
// Rust (see components.css). WebView2 can keep compositing without the former.
useEffect(() => {
const update = () => {
document.documentElement.dataset.appHidden = document.hidden ? 'true' : 'false';
@@ -932,10 +931,11 @@ function TauriEventBridge() {
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.
const u = await listen('window:close-requested', async () => {
if (useAuthStore.getState().minimizeToTray) {
await invoke('pause_rendering').catch(() => {});
await getCurrentWindow().hide();
} else {
await invoke('exit_app');
@@ -1134,24 +1134,26 @@ export default function App() {
}, []);
return (
<BrowserRouter>
<PasteClipboardHandler />
<TauriEventBridge />
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<RequireAuth>
<DragDropProvider>
<AppShell />
</DragDropProvider>
</RequireAuth>
}
/>
</Routes>
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
<ZipDownloadOverlay />
</BrowserRouter>
<WindowVisibilityProvider>
<BrowserRouter>
<PasteClipboardHandler />
<TauriEventBridge />
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<RequireAuth>
<DragDropProvider>
<AppShell />
</DragDropProvider>
</RequireAuth>
}
/>
</Routes>
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
<ZipDownloadOverlay />
</BrowserRouter>
</WindowVisibilityProvider>
);
}