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
+26 -24
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';
@@ -378,11 +379,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';
@@ -938,6 +937,7 @@ function TauriEventBridge() {
// 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');
@@ -1136,24 +1136,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>
);
}
+11 -4
View File
@@ -7,6 +7,7 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playAlbum';
import { useIsMobile } from '../hooks/useIsMobile';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
@@ -65,6 +66,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const windowHidden = useWindowVisibility();
useEffect(() => {
if (albumsProp?.length) { setAlbums(albumsProp); return; }
@@ -87,18 +89,23 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
mixMinRatingArtist,
]);
// Start / restart auto-advance timer
// Start / restart auto-advance timer (paused while the Tauri window is hidden).
const startTimer = useCallback((len: number) => {
if (timerRef.current) clearInterval(timerRef.current);
if (len <= 1) return;
timerRef.current = null;
if (len <= 1 || windowHidden) return;
timerRef.current = setInterval(() => {
if (document.hidden || window.__psyHidden) return;
setActiveIdx(prev => (prev + 1) % len);
}, INTERVAL_MS);
}, []);
}, [windowHidden]);
useEffect(() => {
startTimer(albums.length);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
return () => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
};
}, [albums.length, startTimer]);
const goTo = useCallback((idx: number) => {
+5
View File
@@ -9,6 +9,7 @@ import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore';
import { useDragDrop } from '../contexts/DragDropContext';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { IS_LINUX } from '../utils/platform';
import MiniContextMenu from './MiniContextMenu';
import OverlayScrollArea from './OverlayScrollArea';
@@ -114,6 +115,9 @@ export default function MiniPlayer() {
const ticker = useRef<number | null>(null);
const queueScrollRef = useRef<HTMLDivElement>(null);
const volumeWrapRef = useRef<HTMLDivElement>(null);
const hiddenRef = useRef(false);
const isHidden = useWindowVisibility();
useEffect(() => { hiddenRef.current = isHidden; }, [isHidden]);
// ── PsyDnD reorder ──
// 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);
});
const unProgress = listen<ProgressPayload>('audio:progress', (e) => {
if (hiddenRef.current || window.__psyHidden) return;
setCurrentTime(e.payload.current_time);
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 { useShallow } from 'zustand/react/shallow';
import { formatPlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
export interface PlaybackScheduleBadgeProps {
/** 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 [anchorRect, setAnchorRect] = useState<{ left: number; top: number; size: number } | null>(null);
const windowHidden = useWindowVisibility();
useEffect(() => {
if (deadlineMs == null) return;
const id = window.setInterval(() => setNowMs(Date.now()), 500);
if (deadlineMs == null || windowHidden) return;
const id = window.setInterval(() => {
if (document.hidden || window.__psyHidden) return;
setNowMs(Date.now());
}, 500);
return () => window.clearInterval(id);
}, [deadlineMs]);
}, [deadlineMs, windowHidden]);
useLayoutEffect(() => {
if (deadlineMs == null) return;
if (deadlineMs == null || windowHidden) return;
const el = layoutAnchorRef.current;
if (!el) return;
const sync = () => {
@@ -74,7 +79,7 @@ export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: Pl
window.removeEventListener('scroll', sync, true);
window.clearInterval(iv);
};
}, [deadlineMs, layoutAnchorRef]);
}, [deadlineMs, layoutAnchorRef, windowHidden]);
if (deadlineMs == null || startMs == null || !anchorRect) return null;
+43 -7
View File
@@ -727,7 +727,6 @@ export function SeekbarPreview({
onClick: () => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rafRef = useRef<number | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
@@ -738,16 +737,35 @@ export function SeekbarPreview({
}
const animState = makeAnimState();
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 = () => {
if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
}, 400);
return;
}
t += 0.016;
animState.time = t;
const progress = 0.15 + 0.65 * (0.5 + 0.5 * Math.sin(t));
const buffered = Math.min(1, progress + 0.18);
drawSeekbar(canvas, style, heights, progress, buffered, animState);
rafRef.current = requestAnimationFrame(tick);
rafId = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); };
tick();
return () => stop();
}, [style]);
return (
@@ -869,14 +887,32 @@ export default function WaveformSeek({ trackId }: Props) {
const canvas = canvasRef.current;
if (!canvas) return;
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 = () => {
if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
}, 400);
return;
}
animStateRef.current.time += 0.016;
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
tick();
return () => stop();
}, [seekbarStyle]);
// 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; }
/* ── Pause CSS animations when the window is hidden / minimized ────────────
Set via App.tsx on `visibilitychange`. WebView2 on Windows keeps compositing
infinite CSS animations (mesh-aura, portrait-drift, eq-bounce, track-pulse,
led-pulse, …) even when the app is minimized, causing constant GPU use.
Pausing them cuts that to near zero while hidden without touching the
running Rust/audio threads. */
- `data-app-hidden`: App.tsx `visibilitychange` → `document.hidden` (all platforms).
- `data-psy-native-hidden`: Rust inject on Tauri `win.hide()` / show (WebView2
often keeps `document.hidden === false` when the native window is hidden).
Either flag pauses infinite animations including portaled nodes and ::pseudo,
without touching Rust/audio threads. */
/* ─ What's New — banner + page ────────────────────────────────────────────
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
@@ -10378,7 +10378,10 @@ html.no-compositing .fs-seekbar-played {
html[data-app-hidden="true"] *,
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;
}
+8 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { usePlayerStore } from '../store/playerStore';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
/** Remaining time until wall-clock `deadlineMs` (m:ss or h:mm:ss). */
export function formatPlaybackScheduleRemaining(deadlineMs: number | null, nowMs: number): string {
@@ -43,11 +44,15 @@ export function usePlaybackScheduleRemaining(): PlaybackScheduleInfo | null {
: null;
const deadlineMs = mode === 'pause' ? scheduledPauseAtMs : mode === 'start' ? scheduledResumeAtMs : null;
const [nowMs, setNowMs] = useState(() => Date.now());
const windowHidden = useWindowVisibility();
useEffect(() => {
if (deadlineMs == null) return;
const id = window.setInterval(() => setNowMs(Date.now()), 500);
if (deadlineMs == null || windowHidden) return;
const id = window.setInterval(() => {
if (document.hidden || window.__psyHidden) return;
setNowMs(Date.now());
}, 500);
return () => window.clearInterval(id);
}, [deadlineMs]);
}, [deadlineMs, windowHidden]);
if (mode == null || deadlineMs == null) return null;
return { remaining: formatPlaybackScheduleRemaining(deadlineMs, nowMs), mode };
}
+8
View File
@@ -1 +1,9 @@
/// <reference types="vite/client" />
declare global {
interface Window {
__psyHidden?: boolean;
}
}
export {};