merge: integrate origin/main into feat/lucky-mix-flow

This commit is contained in:
Maxim Isaev
2026-04-24 00:30:40 +03:00
11 changed files with 288 additions and 54 deletions
+17 -3
View File
@@ -3064,7 +3064,12 @@ pub async fn audio_chain_preload(
let raw_bytes = Arc::new(data); let raw_bytes = Arc::new(data);
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume); // Only `gain_linear` is needed — `effective_volume` is intentionally NOT
// applied to the Sink here. `audio_chain_preload` runs ~30 s before the
// current track ends, and `Sink::set_volume` affects the WHOLE Sink (incl.
// the still-playing current source). Volume for the chained track is
// applied at the gapless transition in `spawn_progress_task`, not here.
let (gain_linear, _effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume);
let done_next = Arc::new(AtomicBool::new(false)); let done_next = Arc::new(AtomicBool::new(false));
// Use a dedicated counter for the chained source — it will be swapped into // Use a dedicated counter for the chained source — it will be swapped into
@@ -3116,11 +3121,11 @@ pub async fn audio_chain_preload(
} }
// Append to the existing Sink. The audio hardware stream never stalls. // Append to the existing Sink. The audio hardware stream never stalls.
// Note: `set_volume` is deliberately NOT called here (see comment above).
{ {
let cur = state.current.lock().unwrap(); let cur = state.current.lock().unwrap();
match &cur.sink { match &cur.sink {
Some(sink) => { Some(sink) => {
sink.set_volume(effective_volume);
sink.append(source); sink.append(source);
} }
None => return Ok(()), // playback stopped — bail None => return Ok(()), // playback stopped — bail
@@ -3208,7 +3213,12 @@ fn spawn_progress_task(
// a one-time value copy would go stale immediately. // a one-time value copy would go stale immediately.
samples_played = info.sample_counter; samples_played = info.sample_counter;
// Update tracking state. // Update tracking state and apply the chained track's
// effective volume. Deferred from `audio_chain_preload`
// (which runs ~30 s before the current track ends) to
// avoid changing loudness of the still-playing current
// track. `Sink::set_volume` affects the whole Sink, so it
// must only be called at the boundary, not at preload.
{ {
let mut cur = current_arc.lock().unwrap(); let mut cur = current_arc.lock().unwrap();
cur.replay_gain_linear = info.replay_gain_linear; cur.replay_gain_linear = info.replay_gain_linear;
@@ -3216,6 +3226,10 @@ fn spawn_progress_task(
cur.duration_secs = info.duration_secs; cur.duration_secs = info.duration_secs;
cur.seek_offset = 0.0; cur.seek_offset = 0.0;
cur.play_started = Some(Instant::now()); cur.play_started = Some(Instant::now());
if let Some(sink) = &cur.sink {
let effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
sink.set_volume(effective);
}
} }
// Record the gapless switch timestamp for ghost-command guard. // Record the gapless switch timestamp for ghost-command guard.
+85 -2
View File
@@ -2753,8 +2753,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();
} }
@@ -2787,8 +2789,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();
} }
@@ -3033,6 +3037,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).
@@ -3105,9 +3148,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`
@@ -3137,6 +3189,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();
@@ -3144,6 +3199,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
@@ -3168,6 +3226,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") {
@@ -3185,9 +3244,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())?;
@@ -3195,6 +3256,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)
@@ -3536,6 +3610,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", ());
} }
@@ -3543,6 +3621,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();
@@ -3574,6 +3655,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,
+26 -24
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';
@@ -378,11 +379,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';
@@ -938,6 +937,7 @@ function TauriEventBridge() {
// 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');
@@ -1136,24 +1136,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.__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.__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.__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.__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.__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.
+66
View File
@@ -0,0 +1,66 @@
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`
* OR-ed with `window.__psyHidden` (set from Rust before/after `win.hide()` /
* `show()`) the latter is the reliable signal on WebView2 where
* `document.hidden` may stay false. Adaptive interval: slow while hidden
* (minimize wakeups), 500 ms while visible (catch show without burning CPU).
*/
function isWindowHidden() {
return document.hidden || !!window.__psyHidden;
}
export function WindowVisibilityProvider({ children }: { children: ReactNode }) {
const [hidden, setHidden] = useState(isWindowHidden);
const hiddenRef = useRef(hidden);
useEffect(() => {
hiddenRef.current = isWindowHidden();
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const schedule = () => {
if (cancelled) return;
const interval = hiddenRef.current ? 1000 : 500;
timeoutId = setTimeout(() => {
timeoutId = null;
if (cancelled) return;
const current = isWindowHidden();
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
@@ -9736,11 +9736,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
@@ -10378,7 +10378,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.__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 };
} }
+8
View File
@@ -1 +1,9 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare global {
interface Window {
__psyHidden?: boolean;
}
}
export {};