mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
+85
-2
@@ -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
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user