perf(linux): WebKit probe, throttled progress IPC, snapshot playback UI (#452)

* feat(linux): optional native GDK for Nix gdk-session

Introduce PSYSONIC_ALLOW_NATIVE_GDK so main skips the default GDK_BACKEND=x11
pin when the Nix gdk-session wrapper sets the flag. Remove GDK_BACKEND from
the npm tauri:dev script so it does not override nix develop defaults.

* fix(ui): portal server switch menu above sidebar

Main column stacks below the sidebar (layout z-index), so an in-tree dropdown
could never win over the left nav. Render the menu via createPortal to
document.body with fixed coordinates, matching the library scope picker.

* feat(perf): add mainstage probe controls and cut WebKit repaint load

Add a dedicated performance probe surface for mainstage/home toggles and wire Linux CPU diagnostics to isolate expensive UI paths. Tune waveform drawing and Home artwork clipping/windowing so visible content loads immediately while reducing WebKit compositor pressure during playback.

* fix(perf): stop hero rotation when section is off-screen

Gate hero auto-rotation and backdrop crossfade by real viewport visibility using the actual scrolling ancestor. This prevents periodic 10-second CPU spikes from hidden hero updates while preserving normal behavior when the hero is visible.

* fix(perf): isolate player progress updates from mainstage diagnostics

Add probe toggles for PlayerBar waveform and live progress UI updates to confirm playback progress churn as the main CPU driver.
Restore Home artwork quality defaults and keep visual-degradation modes opt-in via debug flags only.

* fix(hero): resume background and autoplay after viewport return

Re-check hero visibility on focus/visibility changes and add a short recovery poll while off-screen so missed scroll/RAF events cannot leave hero animation paused.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(perf): decouple playback progress from mainstage compositing pressure

Throttle audio progress delivery and route live seekbar timing through a lightweight progress channel to cut focus-time WebKit CPU spikes.
Add focused diagnostics in Performance Probe and restore hero/waveform behavior so visuals remain stable while profiling.

* fix(debug): open performance probe with Ctrl+Shift+D

Replace logo-triggered opening with a keyboard shortcut and keep logo purely decorative to avoid accidental probe activation.

* docs(changelog): document experiment/performance probe and playback work

Add an [Unreleased] section for the performance probe, throttled audio
progress IPC, snapshot-based live UI updates, WaveformSeek scheduling
over the same canvas bar renderer, Hero/Home rail fixes, and Linux/Nix
GDK dev ergonomics.

* perf(linux): add WebKit probe, throttle progress IPC, snapshot playback UI

Ship Performance Probe (Ctrl+Shift+D), Rust-throttled audio:progress, a
playback progress snapshot channel with coarse Zustand timeline commits,
Linux /proc CPU readout for the probe, Hero and Home rail artwork fixes,
Tracks SongRail windowing parity, MPRIS cleanup, gated perf counters, and
WaveformSeek paused-seek correctness. Documented in CHANGELOG for PR #452.

* docs(changelog): fold perf work into 1.45.0 and refresh date

Drop the separate 1.45.1 heading; keep PR #452 notes under 1.45.0 Added and
set the section date to 2026-05-04. Restore the safety preface before the
versioned sections.

* docs(changelog): order 1.45.0 Added entries by PR number

Sort the 1.45.0 release notes so subsections follow ascending PR id (390
through 452), with PR #452 last.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cucadmuh
2026-05-04 20:06:49 +03:00
committed by GitHub
parent 00045f755c
commit a6cc2e2ad4
32 changed files with 2184 additions and 408 deletions
+30 -5
View File
@@ -1,4 +1,4 @@
import React, { memo } from 'react';
import React, { memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -18,9 +18,20 @@ interface AlbumCardProps {
onToggleSelect?: (id: string) => void;
showRating?: boolean;
selectedAlbums?: SubsonicAlbum[];
disableArtwork?: boolean;
artworkSize?: number;
}
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false, selectedAlbums = [] }: AlbumCardProps) {
function AlbumCard({
album,
selected,
selectionMode,
onToggleSelect,
showRating = false,
selectedAlbums = [],
disableArtwork = false,
artworkSize = 300,
}: AlbumCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
@@ -31,7 +42,15 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
if (!meta || meta.trackIds.length === 0) return false;
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
});
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
// buildCoverArtUrl emits a salted URL; memoize to avoid churn on rerenders.
const coverUrl = useMemo(
() => (album.coverArt ? buildCoverArtUrl(album.coverArt, artworkSize) : ''),
[album.coverArt, artworkSize],
);
const coverCacheKey = useMemo(
() => (album.coverArt ? coverArtCacheKey(album.coverArt, artworkSize) : ''),
[album.coverArt, artworkSize],
);
const psyDrag = useDragDrop();
const isNewAlbum = isAlbumRecentlyAdded(album.created);
@@ -73,8 +92,14 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
}}
>
<div className="album-card-cover">
{coverUrl ? (
<CachedImage src={coverUrl} cacheKey={coverArtCacheKey(album.coverArt!, 300)} alt={`${album.name} Cover`} loading="lazy" />
{!disableArtwork && coverUrl ? (
<CachedImage
src={coverUrl}
cacheKey={coverCacheKey}
alt={`${album.name} Cover`}
loading="lazy"
decoding="async"
/>
) : (
<div className="album-card-cover-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
+92 -19
View File
@@ -4,6 +4,7 @@ import AlbumCard from './AlbumCard';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { NavLink, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePerfProbeFlags } from '../utils/perfFlags';
interface Props {
title: string;
@@ -15,23 +16,63 @@ interface Props {
showRating?: boolean;
/** Optional content rendered in the row header, left of the scroll-nav. */
headerExtra?: React.ReactNode;
disableArtwork?: boolean;
disableInteractivity?: boolean;
artworkSize?: number;
windowArtworkByViewport?: boolean;
initialArtworkBudget?: number;
}
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore, showRating, headerExtra }: Props) {
export default function AlbumRow({
title,
titleLink,
albums,
moreLink,
moreText,
onLoadMore,
showRating,
headerExtra,
disableArtwork = false,
disableInteractivity = false,
artworkSize,
windowArtworkByViewport = false,
initialArtworkBudget = 8,
}: Props) {
const perfFlags = usePerfProbeFlags();
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
const interactivityDisabled = perfFlags.disableMainstageRailInteractivity || disableInteractivity;
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
const loadingRef = useRef(false);
const recomputeArtworkBudget = () => {
if (!windowArtworkByViewport) return;
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, clientWidth } = el;
const firstCard = el.querySelector<HTMLElement>('.album-card, .artist-card');
const cardW = firstCard?.clientWidth || firstCard?.getBoundingClientRect().width || 170;
const gridStyles = window.getComputedStyle(el);
const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '16') || 16;
const step = Math.max(1, cardW + gap);
const visibleCount = Math.ceil((scrollLeft + clientWidth) / step);
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 4);
setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev));
};
const handleScroll = () => {
if (interactivityDisabled) return;
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
recomputeArtworkBudget();
// Auto-load trigger
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
@@ -49,10 +90,27 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
};
useEffect(() => {
if (interactivityDisabled) return;
handleScroll();
const raf = window.requestAnimationFrame(() => {
// One post-layout pass ensures we account for final grid/card geometry.
recomputeArtworkBudget();
});
window.addEventListener('resize', handleScroll);
return () => window.removeEventListener('resize', handleScroll);
}, [albums]);
const ro = new ResizeObserver(() => {
recomputeArtworkBudget();
});
if (scrollRef.current) ro.observe(scrollRef.current);
return () => {
window.cancelAnimationFrame(raf);
window.removeEventListener('resize', handleScroll);
ro.disconnect();
};
}, [albums, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
useEffect(() => {
setArtworkBudget(initialArtworkBudget);
}, [initialArtworkBudget, albums.length]);
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
@@ -74,26 +132,41 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
)}
<div className="album-row-nav">
{headerExtra}
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
<ChevronRight size={20} />
</button>
{!interactivityDisabled && (
<>
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
<ChevronRight size={20} />
</button>
</>
)}
</div>
</div>
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{albums.map(a => <AlbumCard key={a.id} album={a} showRating={showRating} />)}
<div className="album-grid" ref={scrollRef} onScroll={interactivityDisabled ? undefined : handleScroll}>
{albums.map((a, idx) => (
<AlbumCard
key={a.id}
album={a}
showRating={showRating}
disableArtwork={
artworkDisabled ||
(windowArtworkByViewport && idx >= artworkBudget)
}
artworkSize={artworkSize}
/>
))}
{loadingMore && (
<div className="album-card-more" style={{ cursor: 'default' }}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
+90 -47
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Check, ChevronDown } from 'lucide-react';
@@ -21,14 +22,37 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
const activeServerId = useAuthStore(s => s.activeServerId);
const [menuOpen, setMenuOpen] = useState(false);
const [switchingId, setSwitchingId] = useState<string | null>(null);
const [menuFixed, setMenuFixed] = useState({ top: 0, right: 0 });
const hostRef = useRef<HTMLDivElement>(null);
const menuPanelRef = useRef<HTMLDivElement>(null);
const multi = servers.length > 1;
const updateMenuPosition = useCallback(() => {
const el = hostRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setMenuFixed({ top: r.bottom + 6, right: window.innerWidth - r.right });
}, []);
useLayoutEffect(() => {
if (!menuOpen) return;
updateMenuPosition();
const onWin = () => updateMenuPosition();
window.addEventListener('resize', onWin);
window.addEventListener('scroll', onWin, true);
return () => {
window.removeEventListener('resize', onWin);
window.removeEventListener('scroll', onWin, true);
};
}, [menuOpen, updateMenuPosition]);
useEffect(() => {
if (!menuOpen) return;
const onDown = (e: MouseEvent) => {
if (hostRef.current?.contains(e.target as Node)) return;
const t = e.target as Node;
if (hostRef.current?.contains(t)) return;
if (menuPanelRef.current?.contains(t)) return;
setMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => {
@@ -103,55 +127,74 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
</span>
</div>
</div>
{multi && menuOpen && (
<div
className="nav-library-dropdown-panel connection-indicator-dropdown-panel"
role="menu"
aria-label={t('connection.switchServerTitle')}
>
{multi &&
menuOpen &&
typeof document !== 'undefined' &&
createPortal(
<div
ref={menuPanelRef}
className="nav-library-dropdown-panel connection-indicator-dropdown-panel"
role="menu"
aria-label={t('connection.switchServerTitle')}
style={{
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--text-muted)',
padding: '6px 10px 4px',
position: 'fixed',
top: menuFixed.top,
right: menuFixed.right,
minWidth: 220,
maxWidth: 'min(320px, 85vw)',
zIndex: 10050,
}}
>
{t('connection.switchServerTitle')}
</div>
{servers.map(srv => {
const active = srv.id === activeServerId;
const busy = switchingId !== null;
const labelText = serverListDisplayLabel(srv, servers);
return (
<button
key={srv.id}
type="button"
role="menuitem"
className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`}
disabled={busy}
onClick={() => onPickServer(srv)}
>
<span className="nav-library-dropdown-item-label">{labelText}</span>
{switchingId === srv.id ? (
<div className="spinner" style={{ width: 14, height: 14, flexShrink: 0 }} aria-hidden />
) : active ? (
<Check size={16} className="nav-library-dropdown-check" aria-hidden />
) : (
<span className="nav-library-dropdown-check-spacer" aria-hidden />
)}
</button>
);
})}
<div style={{ borderTop: '1px solid color-mix(in srgb, var(--text-muted) 15%, transparent)', marginTop: 2, paddingTop: 2 }} />
<button type="button" className="nav-library-dropdown-item" onClick={goServerSettings}>
<span className="nav-library-dropdown-item-label">{t('connection.manageServers')}</span>
<span className="nav-library-dropdown-check-spacer" aria-hidden />
</button>
</div>
)}
<div
style={{
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--text-muted)',
padding: '6px 10px 4px',
}}
>
{t('connection.switchServerTitle')}
</div>
{servers.map(srv => {
const active = srv.id === activeServerId;
const busy = switchingId !== null;
const labelText = serverListDisplayLabel(srv, servers);
return (
<button
key={srv.id}
type="button"
role="menuitem"
className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`}
disabled={busy}
onClick={() => onPickServer(srv)}
>
<span className="nav-library-dropdown-item-label">{labelText}</span>
{switchingId === srv.id ? (
<div className="spinner" style={{ width: 14, height: 14, flexShrink: 0 }} aria-hidden />
) : active ? (
<Check size={16} className="nav-library-dropdown-check" aria-hidden />
) : (
<span className="nav-library-dropdown-check-spacer" aria-hidden />
)}
</button>
);
})}
<div
style={{
borderTop: '1px solid color-mix(in srgb, var(--text-muted) 15%, transparent)',
marginTop: 2,
paddingTop: 2,
}}
/>
<button type="button" className="nav-library-dropdown-item" onClick={goServerSettings}>
<span className="nav-library-dropdown-item-label">{t('connection.manageServers')}</span>
<span className="nav-library-dropdown-check-spacer" aria-hidden />
</button>
</div>,
document.body
)}
</div>
);
}
+9 -9
View File
@@ -4,7 +4,7 @@ import {
ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal,
Moon, Sunrise,
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
import { useCachedUrl } from './CachedImage';
import { getCachedBlob } from '../utils/imageCache';
@@ -88,8 +88,8 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
setActiveIdx(idx);
}
};
apply(usePlayerStore.getState().currentTime);
return usePlayerStore.subscribe(s => apply(s.currentTime));
apply(getPlaybackProgressSnapshot().currentTime);
return subscribePlaybackProgress(s => apply(s.currentTime));
}, [hasSynced, currentTrack?.id]);
// Ease-scroll active line to ~35% from the top of the container.
@@ -129,8 +129,8 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
}
prevWord.current = { line: li, word: wi };
};
apply(usePlayerStore.getState().currentTime);
return usePlayerStore.subscribe(s => apply(s.currentTime));
apply(getPlaybackProgressSnapshot().currentTime);
return subscribePlaybackProgress(s => apply(s.currentTime));
}, [useWords, wordLines]);
const handleUserScroll = useCallback(() => {
@@ -275,8 +275,8 @@ const FsLyricsRail = memo(function FsLyricsRail({ currentTrack }: { currentTrack
}
prevWord.current = { line: li, word: wi };
};
apply(usePlayerStore.getState().currentTime);
return usePlayerStore.subscribe(s => apply(s.currentTime));
apply(getPlaybackProgressSnapshot().currentTime);
return subscribePlaybackProgress(s => apply(s.currentTime));
}, [useWords, wordLines]);
if (!currentTrack || loading || !hasSynced) return null;
@@ -457,14 +457,14 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
}, [seek]);
useEffect(() => {
const s = usePlayerStore.getState();
const s = getPlaybackProgressSnapshot();
const pct = s.progress * 100;
if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime);
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`;
if (inputRef.current) inputRef.current.value = String(s.progress);
return usePlayerStore.subscribe(state => {
return subscribePlaybackProgress(state => {
if (isDraggingRef.current) return;
const p = state.progress * 100;
if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime);
+120 -6
View File
@@ -11,6 +11,7 @@ import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
import { usePerfProbeFlags } from '../utils/perfFlags';
const INTERVAL_MS = 10000;
const HERO_ALBUM_COUNT = 8;
@@ -55,6 +56,7 @@ interface HeroProps {
}
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useIsMobile();
@@ -67,6 +69,102 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const windowHidden = useWindowVisibility();
const [windowBlurred, setWindowBlurred] = useState<boolean>(() => Boolean(window.__psyBlurred));
const heroRef = useRef<HTMLDivElement | null>(null);
const heroScrollRootRef = useRef<HTMLElement | null>(null);
const visibilityRafRef = useRef<number | null>(null);
const [heroInView, setHeroInView] = useState(true);
const heroInViewRef = useRef(true);
heroInViewRef.current = heroInView;
const computeHeroVisibleNow = useCallback((): boolean => {
const node = heroRef.current;
if (!node) return false;
const rect = node.getBoundingClientRect();
if (rect.height <= 0 || rect.width <= 0) {
return false;
}
const root = heroScrollRootRef.current;
const viewportTop = root ? root.getBoundingClientRect().top : 0;
const viewportBottom = root ? root.getBoundingClientRect().bottom : window.innerHeight;
const overlap = Math.max(0, Math.min(rect.bottom, viewportBottom) - Math.max(rect.top, viewportTop));
// Consider hero visible only when at least a meaningful slice is on screen.
const minVisiblePx = Math.min(56, rect.height * 0.2);
return overlap >= minVisiblePx;
}, []);
const updateHeroVisibility = useCallback(() => {
const visible = computeHeroVisibleNow();
setHeroInView(prev => (prev === visible ? prev : visible));
}, [computeHeroVisibleNow]);
useEffect(() => {
const node = heroRef.current;
if (!node) return;
// Prefer the nearest actual scrolling ancestor; class fallback for safety.
let scrollRoot: HTMLElement | null = null;
let parent = node.parentElement;
while (parent) {
const styles = window.getComputedStyle(parent);
const overflowY = styles.overflowY;
if ((overflowY === 'auto' || overflowY === 'scroll') && parent.scrollHeight > parent.clientHeight + 2) {
scrollRoot = parent;
break;
}
parent = parent.parentElement;
}
heroScrollRootRef.current =
scrollRoot ?? (node.closest('.app-shell-route-scroll__viewport') as HTMLElement | null);
updateHeroVisibility();
const root = heroScrollRootRef.current;
const onScroll = () => {
if (visibilityRafRef.current != null) return;
visibilityRafRef.current = window.requestAnimationFrame(() => {
visibilityRafRef.current = null;
updateHeroVisibility();
});
};
const onResize = () => updateHeroVisibility();
const onFocusLike = () => updateHeroVisibility();
root?.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onResize);
window.addEventListener('focus', onFocusLike);
document.addEventListener('visibilitychange', onFocusLike);
return () => {
root?.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
window.removeEventListener('focus', onFocusLike);
document.removeEventListener('visibilitychange', onFocusLike);
if (visibilityRafRef.current != null) {
window.cancelAnimationFrame(visibilityRafRef.current);
visibilityRafRef.current = null;
}
};
}, [updateHeroVisibility]);
useEffect(() => {
const updateBlurState = () => {
setWindowBlurred(Boolean(window.__psyBlurred));
};
window.addEventListener('focus', updateBlurState);
window.addEventListener('blur', updateBlurState);
updateBlurState();
return () => {
window.removeEventListener('focus', updateBlurState);
window.removeEventListener('blur', updateBlurState);
};
}, []);
useEffect(() => {
if (heroInView || windowHidden) return;
// Recovery guard: if a scroll/RAF event was missed while hero was outside
// viewport, keep checking briefly so autoplay/background resume immediately
// after returning into view.
const id = window.setInterval(() => {
updateHeroVisibility();
}, 220);
return () => window.clearInterval(id);
}, [heroInView, windowHidden, updateHeroVisibility]);
useEffect(() => {
if (albumsProp?.length) { setAlbums(albumsProp); return; }
@@ -93,12 +191,27 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const startTimer = useCallback((len: number) => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
if (len <= 1 || windowHidden) return;
if (len <= 1 || windowHidden || windowBlurred || !heroInViewRef.current || !computeHeroVisibleNow()) return;
timerRef.current = setInterval(() => {
if (document.hidden || window.__psyHidden) return;
const visibleNow = computeHeroVisibleNow();
if (!visibleNow && heroInViewRef.current) setHeroInView(false);
if (document.hidden || window.__psyHidden || window.__psyBlurred || !heroInViewRef.current || !visibleNow) {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
return;
}
setActiveIdx(prev => (prev + 1) % len);
}, INTERVAL_MS);
}, [windowHidden]);
}, [windowHidden, windowBlurred, computeHeroVisibleNow]);
useEffect(() => {
// Hard-stop timer immediately when hero leaves viewport.
if (heroInView && !windowBlurred) return;
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, [heroInView, windowBlurred]);
useEffect(() => {
startTimer(albums.length);
@@ -106,7 +219,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
};
}, [albums.length, startTimer]);
}, [albums.length, heroInView, startTimer]);
const goTo = useCallback((idx: number) => {
setActiveIdx(idx);
@@ -144,14 +257,15 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
return (
<div
ref={heroRef}
className="hero"
role="banner"
aria-label={t('hero.eyebrow')}
onClick={() => navigate(`/album/${album.id}`)}
style={{ cursor: 'pointer' }}
>
{enableCoverArtBackground && <HeroBg url={stableBgUrl.current} />}
{enableCoverArtBackground && <div className="hero-overlay" aria-hidden="true" />}
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
{/* key causes re-mount → animate-fade-in triggers on each album change */}
<div className="hero-content animate-fade-in" key={album.id}>
+3 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { usePlayerStore } from '../store/playerStore';
import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playerStore';
import type { LrcLine } from '../api/lrclib';
import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
import { useAuthStore } from '../store/authStore';
@@ -126,8 +126,8 @@ export default function LyricsPane({ currentTrack }: Props) {
prevActive.current = { line: lineIdx, word: wordIdx };
};
apply(usePlayerStore.getState().currentTime);
return usePlayerStore.subscribe(s => apply(s.currentTime));
apply(getPlaybackProgressSnapshot().currentTime);
return subscribePlaybackProgress(s => apply(s.currentTime));
}, [useWords, hasSynced, wordLines, syncedLines, scrollToLine]);
if (!currentTrack) {
+3 -1
View File
@@ -1,5 +1,6 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import { useAuthStore } from '../store/authStore';
import { usePerfProbeFlags } from '../utils/perfFlags';
interface Props {
text: string;
@@ -13,6 +14,7 @@ export default function MarqueeText({ text, className, style, onClick }: Props)
const textRef = useRef<HTMLSpanElement>(null);
const [scrollAmount, setScrollAmount] = useState(0);
const animationMode = useAuthStore(s => s.animationMode);
const perfFlags = usePerfProbeFlags();
const measure = useCallback(() => {
const container = containerRef.current;
@@ -34,7 +36,7 @@ export default function MarqueeText({ text, className, style, onClick }: Props)
// In `static` animation mode the marquee never scrolls — overflowing text
// is truncated with an ellipsis (handled by CSS via data-anim-mode).
const shouldScroll = scrollAmount > 0 && animationMode !== 'static';
const shouldScroll = scrollAmount > 0 && animationMode !== 'static' && !perfFlags.disableMarqueeScroll;
return (
<div
+9 -4
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useMemo, useRef, useEffect, CSSProperties } from 'react';
import React, { useState, useCallback, useMemo, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
@@ -6,7 +6,7 @@ import {
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
Moon, Sunrise,
} from 'lucide-react';
import { usePlayerStore, Track } from '../store/playerStore';
import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress, Track } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import { useCachedUrl } from './CachedImage';
import LyricsPane from './LyricsPane';
@@ -164,8 +164,13 @@ export default function MobilePlayerView() {
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const progress = usePlayerStore(s => s.progress);
const currentTime = usePlayerStore(s => s.currentTime);
const playbackProgress = useSyncExternalStore(
onStoreChange => subscribePlaybackProgress(() => onStoreChange()),
getPlaybackProgressSnapshot,
getPlaybackProgressSnapshot,
);
const progress = playbackProgress.progress;
const currentTime = playbackProgress.currentTime;
const togglePlay = usePlayerStore(s => s.togglePlay);
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
const transportAnchorRef = useRef<HTMLDivElement>(null);
+8 -4
View File
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { computeOverlayScrollbarThumbMeta } from '../utils/overlayScrollbarMetrics';
import { bindOverlayScrollbarThumbDrag } from '../utils/overlayScrollbarThumb';
import { usePerfProbeFlags } from '../utils/perfFlags';
export type OverlayScrollRailInset = 'none' | 'mini' | 'panel';
@@ -56,6 +57,7 @@ export default function OverlayScrollArea({
viewportOnWheel,
viewportOnTouchMove,
}: OverlayScrollAreaProps) {
const perfFlags = usePerfProbeFlags();
const wrapRef = useRef<HTMLDivElement | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const [meta, setMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
@@ -72,6 +74,7 @@ export default function OverlayScrollArea({
const measureKey = JSON.stringify(measureDeps ?? []);
useLayoutEffect(() => {
if (perfFlags.disableOverlayScrollbars) return;
if (!meta.visible) return;
const vp = viewportRef.current;
const wrap = wrapRef.current;
@@ -89,9 +92,10 @@ export default function OverlayScrollArea({
}
return next;
});
}, [meta.visible]);
}, [meta.visible, perfFlags.disableOverlayScrollbars]);
useEffect(() => {
if (perfFlags.disableOverlayScrollbars) return;
recompute();
const wrap = wrapRef.current;
const onWinResize = () => recompute();
@@ -105,7 +109,7 @@ export default function OverlayScrollArea({
window.removeEventListener('resize', onWinResize);
ro?.disconnect();
};
}, [recompute, measureKey]);
}, [recompute, measureKey, perfFlags.disableOverlayScrollbars]);
const setViewportNode = (el: HTMLDivElement | null) => {
viewportRef.current = el;
@@ -134,13 +138,13 @@ export default function OverlayScrollArea({
id={viewportId}
ref={setViewportNode}
className={viewportClass}
onScroll={recompute}
onScroll={perfFlags.disableOverlayScrollbars ? undefined : recompute}
onWheel={viewportOnWheel}
onTouchMove={viewportOnTouchMove}
>
{children}
</div>
{meta.visible && (
{!perfFlags.disableOverlayScrollbars && meta.visible && (
<div className="overlay-scroll__rail" aria-hidden>
<div
className="overlay-scroll__thumb"
+10 -6
View File
@@ -6,7 +6,7 @@ import {
PictureInPicture2, ArrowLeftRight, Moon, Sunrise, Ellipsis,
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../store/playerStore';
import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
@@ -26,6 +26,7 @@ import PlaybackDelayModal from './PlaybackDelayModal';
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
import { usePreviewStore } from '../store/previewStore';
import { usePerfProbeFlags } from '../utils/perfFlags';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -40,9 +41,9 @@ const PlaybackTime = memo(function PlaybackTime({ className }: { className?: str
const spanRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (spanRef.current) {
spanRef.current.textContent = formatTime(usePlayerStore.getState().currentTime);
spanRef.current.textContent = formatTime(getPlaybackProgressSnapshot().currentTime);
}
return usePlayerStore.subscribe(state => {
return subscribePlaybackProgress(state => {
if (spanRef.current) spanRef.current.textContent = formatTime(state.currentTime);
});
}, []);
@@ -55,12 +56,12 @@ const RemainingTime = memo(function RemainingTime({ duration, className }: { dur
useEffect(() => {
const updateRemaining = () => {
if (spanRef.current) {
const remaining = Math.max(0, duration - usePlayerStore.getState().currentTime);
const remaining = Math.max(0, duration - getPlaybackProgressSnapshot().currentTime);
spanRef.current.textContent = `-${formatTime(remaining)}`;
}
};
updateRemaining();
return usePlayerStore.subscribe(updateRemaining);
return subscribePlaybackProgress(updateRemaining);
}, [duration]);
return <span className={className} ref={spanRef} />;
});
@@ -117,6 +118,7 @@ export default function PlayerBar() {
const [utilityMenuStyle, setUtilityMenuStyle] = useState<React.CSSProperties>({});
const volumeWheelMenuTimerRef = useRef<number | null>(null);
const [suppressOverflowTooltip, setSuppressOverflowTooltip] = useState(false);
const perfFlags = usePerfProbeFlags();
useEffect(() => {
if (!floatingPlayerBar) return;
@@ -530,7 +532,9 @@ export default function PlayerBar() {
<>
<PlaybackTime className="player-time" />
<div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} />
{perfFlags.disableWaveformCanvas
? <div className="radio-progress-bar" aria-hidden />
: <WaveformSeek trackId={currentTrack?.id} />}
</div>
<span
className="player-time player-time-toggle"
+504 -1
View File
@@ -1,5 +1,6 @@
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
@@ -28,6 +29,8 @@ import {
type SidebarNavDropTarget,
} from '../utils/sidebarNavReorder';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import { resetPerfProbeFlags, setPerfProbeFlag, usePerfProbeFlags } from '../utils/perfFlags';
import { setPerfProbeTelemetryActive } from '../utils/perfTelemetry';
const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
@@ -81,6 +84,12 @@ export default function Sidebar({
const musicFolders = useAuthStore(s => s.musicFolders);
const musicLibraryFilterByServer = useAuthStore(s => s.musicLibraryFilterByServer);
const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter);
const hotCacheEnabled = useAuthStore(s => s.hotCacheEnabled);
const setHotCacheEnabled = useAuthStore(s => s.setHotCacheEnabled);
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
const loggingMode = useAuthStore(s => s.loggingMode);
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const sidebarItems = useSidebarStore(s => s.items);
const setSidebarItems = useSidebarStore(s => s.setItems);
@@ -151,6 +160,15 @@ export default function Sidebar({
const newReleasesRefreshSeqRef = useRef(0);
const newReleasesPageEnteredAtRef = useRef<number | null>(null);
const newReleasesResetTimerRef = useRef<number | null>(null);
const [perfProbeOpen, setPerfProbeOpen] = useState(false);
const perfFlags = usePerfProbeFlags();
const [perfCpu, setPerfCpu] = useState<{ app: number; webkit: number; supported: boolean } | null>(null);
const [perfDiagRates, setPerfDiagRates] = useState<{ progress: number; waveform: number; home: number } | null>(null);
useEffect(() => {
setPerfProbeTelemetryActive(perfProbeOpen);
return () => setPerfProbeTelemetryActive(false);
}, [perfProbeOpen]);
const newReleasesSeenStorageKey = useMemo(
() => `${NEW_RELEASES_UNREAD_STORAGE_PREFIX}:${serverId || 'no-server'}:${filterId || 'all'}`,
@@ -572,10 +590,113 @@ export default function Sidebar({
};
}, [location.pathname, refreshNewReleasesUnread]);
useEffect(() => {
if (!perfProbeOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setPerfProbeOpen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [perfProbeOpen]);
useEffect(() => {
if (!perfProbeOpen) return;
type Snapshot = {
supported: boolean;
total_jiffies: number;
app_jiffies: number;
webkit_jiffies: number;
logical_cpus: number;
};
let cancelled = false;
let prev: Snapshot | null = null;
let prevCounters: { progress: number; waveform: number; home: number } | null = null;
let prevCountersAt = 0;
let timer: number | null = null;
const poll = async () => {
try {
const snap = await invoke<Snapshot>('performance_cpu_snapshot');
if (cancelled) return;
if (!snap.supported) {
setPerfCpu({ app: 0, webkit: 0, supported: false });
return;
}
if (prev) {
const totalDelta = snap.total_jiffies - prev.total_jiffies;
const appDelta = snap.app_jiffies - prev.app_jiffies;
const webkitDelta = snap.webkit_jiffies - prev.webkit_jiffies;
if (totalDelta > 0) {
const cpuScale = Math.max(1, snap.logical_cpus || 1) * 100;
const appPct = Math.max(0, Math.min(1000, (appDelta / totalDelta) * cpuScale));
const webkitPct = Math.max(0, Math.min(1000, (webkitDelta / totalDelta) * cpuScale));
setPerfCpu({
app: Number.isFinite(appPct) ? appPct : 0,
webkit: Number.isFinite(webkitPct) ? webkitPct : 0,
supported: true,
});
}
}
const now = Date.now();
const root = globalThis as unknown as { __psyPerfCounters?: Record<string, number> };
const counters = root.__psyPerfCounters ?? {};
const nextCounters = {
progress: counters.audioProgressEvents ?? 0,
waveform: counters.waveformDraws ?? 0,
home: counters.homeCommits ?? 0,
};
if (prevCounters && prevCountersAt > 0) {
const dt = Math.max(0.25, (now - prevCountersAt) / 1000);
setPerfDiagRates({
progress: (nextCounters.progress - prevCounters.progress) / dt,
waveform: (nextCounters.waveform - prevCounters.waveform) / dt,
home: (nextCounters.home - prevCounters.home) / dt,
});
}
prevCounters = nextCounters;
prevCountersAt = now;
prev = snap;
} catch {
if (!cancelled) setPerfCpu({ app: 0, webkit: 0, supported: false });
} finally {
if (!cancelled) timer = window.setTimeout(poll, 2000);
}
};
void poll();
return () => {
cancelled = true;
if (timer != null) window.clearTimeout(timer);
};
}, [perfProbeOpen]);
useEffect(() => {
if (!perfProbeOpen) {
setPerfCpu(null);
setPerfDiagRates(null);
}
}, [perfProbeOpen]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey)) return;
if (e.key.toLowerCase() !== 'd') return;
const target = e.target as HTMLElement | null;
if (target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
)) return;
e.preventDefault();
setPerfProbeOpen(true);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return (
<>
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-brand">
<div className="sidebar-brand" aria-hidden>
{isCollapsed
? <PSmallLogo style={{ height: '32px', width: 'auto' }} />
: <PsysonicLogo style={{ height: '28px', width: 'auto' }} />
@@ -953,6 +1074,388 @@ export default function Sidebar({
</div>,
document.body,
)}
{perfProbeOpen &&
createPortal(
<div className="modal-overlay modal-overlay--perf-probe" onClick={() => setPerfProbeOpen(false)} role="dialog" aria-modal="true">
<div
className="modal-content sidebar-perf-modal"
onClick={e => e.stopPropagation()}
style={{ maxWidth: 560 }}
>
<button className="modal-close" onClick={() => setPerfProbeOpen(false)}><X size={18} /></button>
<h3 className="modal-title">Performance Probe</h3>
<p className="sidebar-perf-modal__hint">
Temporary runtime switches to estimate UI effect cost.
</p>
<div className="sidebar-perf-modal__cpu">
<div className="sidebar-perf-modal__cpu-title">Live CPU (approx)</div>
{perfCpu == null ? (
<div className="sidebar-perf-modal__cpu-row">Collecting samples</div>
) : perfCpu.supported ? (
<>
<div className="sidebar-perf-modal__cpu-row">psysonic: {perfCpu.app.toFixed(1)}%</div>
<div className="sidebar-perf-modal__cpu-row">WebKitWebProcess: {perfCpu.webkit.toFixed(1)}%</div>
{perfDiagRates && (
<>
<div className="sidebar-perf-modal__cpu-row">audio:progress rate: {perfDiagRates.progress.toFixed(1)}/s</div>
<div className="sidebar-perf-modal__cpu-row">waveform draws rate: {perfDiagRates.waveform.toFixed(1)}/s</div>
<div className="sidebar-perf-modal__cpu-row">Home commits rate: {perfDiagRates.home.toFixed(1)}/s</div>
</>
)}
</>
) : (
<div className="sidebar-perf-modal__cpu-row">Unavailable on this platform/build.</div>
)}
</div>
<details className="sidebar-perf-modal__phase">
<summary className="sidebar-perf-modal__phase-title">Phase 1 Global / Shell / Network</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableWaveformCanvas}
onChange={e => setPerfProbeFlag('disableWaveformCanvas', e.target.checked)}
/>
<span>Disable only PlayerBar waveform (`WaveformSeek`)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disablePlayerProgressUi}
onChange={e => setPerfProbeFlag('disablePlayerProgressUi', e.target.checked)}
/>
<span>Disable player live progress UI updates (time + seek/progress bindings)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMarqueeScroll}
onChange={e => setPerfProbeFlag('disableMarqueeScroll', e.target.checked)}
/>
<span>Disable marquee text scrolling</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableBackdropBlur}
onChange={e => setPerfProbeFlag('disableBackdropBlur', e.target.checked)}
/>
<span>Disable backdrop blur effects</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableCssAnimations}
onChange={e => setPerfProbeFlag('disableCssAnimations', e.target.checked)}
/>
<span>Disable CSS animations and transitions</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableOverlayScrollbars}
onChange={e => setPerfProbeFlag('disableOverlayScrollbars', e.target.checked)}
/>
<span>Disable overlay scrollbar engine (JS + rail)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableTooltipPortal}
onChange={e => setPerfProbeFlag('disableTooltipPortal', e.target.checked)}
/>
<span>Disable global tooltip portal/listeners</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableQueuePanelMount}
onChange={e => setPerfProbeFlag('disableQueuePanelMount', e.target.checked)}
/>
<span>Disable QueuePanel mount (desktop right column)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableBackgroundPolling}
onChange={e => setPerfProbeFlag('disableBackgroundPolling', e.target.checked)}
/>
<span>Disable background polling (connection + radio metadata)</span>
</label>
<div className="sidebar-perf-modal__subhead">Engine/network toggles</div>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={!hotCacheEnabled}
onChange={e => setHotCacheEnabled(!e.target.checked)}
/>
<span>Disable hot-cache prefetch downloads</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={normalizationEngine === 'off'}
onChange={e => setNormalizationEngine(e.target.checked ? 'off' : 'loudness')}
/>
<span>Disable normalization engine (set to Off)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={loggingMode === 'off'}
onChange={e => setLoggingMode(e.target.checked ? 'off' : 'normal')}
/>
<span>Set runtime logging mode to Off</span>
</label>
</details>
<details className="sidebar-perf-modal__phase">
<summary className="sidebar-perf-modal__phase-title">Phase 2 Mainstage (Center Content)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainRouteContentMount}
onChange={e => setPerfProbeFlag('disableMainRouteContentMount', e.target.checked)}
/>
<span>Disable central route content mount</span>
</label>
<details className="sidebar-perf-modal__phase sidebar-perf-modal__phase--nested" open>
<summary className="sidebar-perf-modal__phase-title">Shared mainstage layers (multiple pages)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageStickyHeader}
onChange={e => setPerfProbeFlag('disableMainstageStickyHeader', e.target.checked)}
/>
<span>Disable sticky headers (Tracks + Albums)</span>
</label>
</details>
<details className="sidebar-perf-modal__phase sidebar-perf-modal__phase--nested" open>
<summary className="sidebar-perf-modal__phase-title">Home (`/`)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageHero}
onChange={e => setPerfProbeFlag('disableMainstageHero', e.target.checked)}
/>
<span>Disable Home hero block</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageHeroBackdrop}
onChange={e => setPerfProbeFlag('disableMainstageHeroBackdrop', e.target.checked)}
/>
<span>Disable Hero backdrop/crossfade only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRails}
onChange={e => setPerfProbeFlag('disableMainstageRails', e.target.checked)}
/>
<span>Disable Home rows/rails (`AlbumRow` + `SongRail`)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeAlbumRows}
onChange={e => setPerfProbeFlag('disableHomeAlbumRows', e.target.checked)}
/>
<span>Disable Home `AlbumRow` sections only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeSongRails}
onChange={e => setPerfProbeFlag('disableHomeSongRails', e.target.checked)}
/>
<span>Disable Home `SongRail` sections only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRailArtwork}
onChange={e => setPerfProbeFlag('disableMainstageRailArtwork', e.target.checked)}
/>
<span>Disable artwork inside Home rows/rails</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeRailArtwork}
onChange={e => setPerfProbeFlag('disableHomeRailArtwork', e.target.checked)}
/>
<span>Disable artwork inside Home rows/rails only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeArtworkFx}
onChange={e => setPerfProbeFlag('disableHomeArtworkFx', e.target.checked)}
/>
<span>Keep artwork, disable Home card visual effects (hover/overlay/shadows)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeArtworkClip}
onChange={e => setPerfProbeFlag('disableHomeArtworkClip', e.target.checked)}
/>
<span>Diagnostic: flatten Home artwork clipping (no rounded corners/masks)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRailInteractivity}
onChange={e => setPerfProbeFlag('disableMainstageRailInteractivity', e.target.checked)}
/>
<span>Disable Home rail scroll/nav handlers</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageGridCards}
onChange={e => setPerfProbeFlag('disableMainstageGridCards', e.target.checked)}
/>
<span>Disable Home discover artists chip-grid</span>
</label>
</details>
<details className="sidebar-perf-modal__phase sidebar-perf-modal__phase--nested" open>
<summary className="sidebar-perf-modal__phase-title">Tracks (`/tracks`)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageHero}
onChange={e => setPerfProbeFlag('disableMainstageHero', e.target.checked)}
/>
<span>Disable Tracks hero block</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRails}
onChange={e => setPerfProbeFlag('disableMainstageRails', e.target.checked)}
/>
<span>Disable Tracks rails (Highly Rated + Random)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRailArtwork}
onChange={e => setPerfProbeFlag('disableMainstageRailArtwork', e.target.checked)}
/>
<span>Disable artwork inside Tracks rails</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRailInteractivity}
onChange={e => setPerfProbeFlag('disableMainstageRailInteractivity', e.target.checked)}
/>
<span>Disable Tracks rail scroll/nav handlers</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageVirtualLists}
onChange={e => setPerfProbeFlag('disableMainstageVirtualLists', e.target.checked)}
/>
<span>Disable Tracks virtual browse list (`VirtualSongList`)</span>
</label>
</details>
<details className="sidebar-perf-modal__phase sidebar-perf-modal__phase--nested" open>
<summary className="sidebar-perf-modal__phase-title">Albums (`/albums`)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageGridCards}
onChange={e => setPerfProbeFlag('disableMainstageGridCards', e.target.checked)}
/>
<span>Disable Albums card grid (`AlbumCard` list)</span>
</label>
</details>
</details>
<details className="sidebar-perf-modal__phase" open>
<summary className="sidebar-perf-modal__phase-title">Phase 3 Active diagnostics (quick access)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disablePlayerProgressUi}
onChange={e => setPerfProbeFlag('disablePlayerProgressUi', e.target.checked)}
/>
<span>Disable player live progress UI updates (time + seek/progress bindings)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableWaveformCanvas}
onChange={e => setPerfProbeFlag('disableWaveformCanvas', e.target.checked)}
/>
<span>Disable only PlayerBar waveform (`WaveformSeek`)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeRailArtwork}
onChange={e => setPerfProbeFlag('disableHomeRailArtwork', e.target.checked)}
/>
<span>Disable artwork inside Home rows/rails only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRailArtwork}
onChange={e => setPerfProbeFlag('disableMainstageRailArtwork', e.target.checked)}
/>
<span>Disable artwork inside Home rows/rails</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRails}
onChange={e => setPerfProbeFlag('disableMainstageRails', e.target.checked)}
/>
<span>Disable Home rows/rails (`AlbumRow` + `SongRail`)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageHeroBackdrop}
onChange={e => setPerfProbeFlag('disableMainstageHeroBackdrop', e.target.checked)}
/>
<span>Disable Hero backdrop/crossfade only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeArtworkFx}
onChange={e => setPerfProbeFlag('disableHomeArtworkFx', e.target.checked)}
/>
<span>Keep artwork, disable Home card visual effects (hover/overlay/shadows)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeArtworkClip}
onChange={e => setPerfProbeFlag('disableHomeArtworkClip', e.target.checked)}
/>
<span>Diagnostic: flatten Home artwork clipping (no rounded corners/masks)</span>
</label>
</details>
<div className="sidebar-perf-modal__actions">
<button type="button" className="btn btn-ghost" onClick={() => resetPerfProbeFlags()}>
Reset
</button>
<button type="button" className="btn btn-primary" onClick={() => setPerfProbeOpen(false)}>
Close
</button>
</div>
</div>
</div>,
document.body,
)}
</>
);
}
+16 -5
View File
@@ -1,4 +1,4 @@
import React, { memo } from 'react';
import React, { memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, ListPlus, Star } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -11,14 +11,24 @@ import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
interface SongCardProps {
song: SubsonicSong;
disableArtwork?: boolean;
artworkSize?: number;
}
function SongCard({ song }: SongCardProps) {
function SongCard({ song, disableArtwork = false, artworkSize = 200 }: SongCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const coverUrl = song.coverArt ? buildCoverArtUrl(song.coverArt, 200) : '';
// buildCoverArtUrl emits a salted URL; memoize to avoid churn on rerenders.
const coverUrl = useMemo(
() => (song.coverArt ? buildCoverArtUrl(song.coverArt, artworkSize) : ''),
[song.coverArt, artworkSize],
);
const coverCacheKey = useMemo(
() => (song.coverArt ? coverArtCacheKey(song.coverArt, artworkSize) : ''),
[song.coverArt, artworkSize],
);
const psyDrag = useDragDrop();
const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
@@ -74,12 +84,13 @@ function SongCard({ song }: SongCardProps) {
}}
>
<div className="song-card-cover">
{coverUrl ? (
{!disableArtwork && coverUrl ? (
<CachedImage
src={coverUrl}
cacheKey={coverArtCacheKey(song.coverArt!, 200)}
cacheKey={coverCacheKey}
alt={`${song.album} Cover`}
loading="lazy"
decoding="async"
/>
) : (
<div className="song-card-cover-placeholder">
+87 -20
View File
@@ -2,6 +2,7 @@ import React, { useRef, useState, useEffect } from 'react';
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic';
import SongCard from './SongCard';
import { usePerfProbeFlags } from '../utils/perfFlags';
interface Props {
title: string;
@@ -12,25 +13,79 @@ interface Props {
loading?: boolean;
/** Empty-state copy when songs is empty AND not loading. */
emptyText?: string;
disableArtwork?: boolean;
disableInteractivity?: boolean;
artworkSize?: number;
windowArtworkByViewport?: boolean;
initialArtworkBudget?: number;
}
export default function SongRail({ title, songs, onReroll, loading, emptyText }: Props) {
export default function SongRail({
title,
songs,
onReroll,
loading,
emptyText,
disableArtwork = false,
disableInteractivity = false,
artworkSize,
windowArtworkByViewport = false,
initialArtworkBudget = 10,
}: Props) {
const perfFlags = usePerfProbeFlags();
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
const interactivityDisabled = perfFlags.disableMainstageRailInteractivity || disableInteractivity;
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
const recomputeArtworkBudget = () => {
if (!windowArtworkByViewport) return;
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, clientWidth } = el;
const firstCard = el.querySelector<HTMLElement>('.song-card');
const cardW = firstCard?.clientWidth || firstCard?.getBoundingClientRect().width || 140;
const gridStyles = window.getComputedStyle(el);
const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '12') || 12;
const step = Math.max(1, cardW + gap);
const visibleCount = Math.ceil((scrollLeft + clientWidth) / step);
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 4);
setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev));
};
const handleScroll = () => {
if (interactivityDisabled) return;
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
recomputeArtworkBudget();
};
useEffect(() => {
if (interactivityDisabled) return;
handleScroll();
const raf = window.requestAnimationFrame(() => {
// One post-layout pass ensures we account for final grid/card geometry.
recomputeArtworkBudget();
});
window.addEventListener('resize', handleScroll);
return () => window.removeEventListener('resize', handleScroll);
}, [songs]);
const ro = new ResizeObserver(() => {
recomputeArtworkBudget();
});
if (scrollRef.current) ro.observe(scrollRef.current);
return () => {
window.cancelAnimationFrame(raf);
window.removeEventListener('resize', handleScroll);
ro.disconnect();
};
}, [songs, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
useEffect(() => {
setArtworkBudget(initialArtworkBudget);
}, [initialArtworkBudget, songs.length]);
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
@@ -58,20 +113,24 @@ export default function SongRail({ title, songs, onReroll, loading, emptyText }:
<RefreshCw size={16} className={loading ? 'is-spinning' : ''} />
</button>
)}
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
<ChevronRight size={20} />
</button>
{!interactivityDisabled && (
<>
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
<ChevronRight size={20} />
</button>
</>
)}
</div>
</div>
@@ -79,9 +138,17 @@ export default function SongRail({ title, songs, onReroll, loading, emptyText }:
{songs.length === 0 && emptyText ? (
<p className="song-row-empty">{emptyText}</p>
) : (
<div className="song-grid" ref={scrollRef} onScroll={handleScroll}>
{songs.map(s => (
<SongCard key={s.id} song={s} />
<div className="song-grid" ref={scrollRef} onScroll={interactivityDisabled ? undefined : handleScroll}>
{songs.map((s, idx) => (
<SongCard
key={s.id}
song={s}
disableArtwork={
artworkDisabled ||
(windowArtworkByViewport && idx >= artworkBudget)
}
artworkSize={artworkSize}
/>
))}
</div>
)}
+215 -59
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import { usePlayerStore } from '../store/playerStore';
import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playerStore';
import { useAuthStore, type SeekbarStyle } from '../store/authStore';
import { bumpPerfCounter } from '../utils/perfTelemetry';
function fmt(s: number): string {
if (!s || isNaN(s)) return '0:00';
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
@@ -15,6 +16,9 @@ const WAVE_MIX_MAX = 0.3;
const SEG_COUNT = 60;
const FLAT_WAVE_NORM = 0.06;
const WAVE_MORPH_MS = 1000;
const STATIC_REDRAW_MIN_MS = 90;
const STATIC_REDRAW_FORCE_MS = 220;
const INTERPOLATION_PAINT_MIN_MS = 80;
// ── animation state ───────────────────────────────────────────────────────────
@@ -40,13 +44,38 @@ const ANIMATED_STYLES = new Set<SeekbarStyle>(['particletrail', 'pulsewave', 'li
// ── color helper ──────────────────────────────────────────────────────────────
function getColors() {
const s = getComputedStyle(document.documentElement);
return {
played: s.getPropertyValue('--waveform-played').trim() || s.getPropertyValue('--accent').trim() || '#cba6f7',
type SeekbarColors = {
played: string;
buffered: string;
unplayed: string;
};
let cachedColors: SeekbarColors | null = null;
let cachedColorsKey = '';
function invalidateColorCache() {
cachedColors = null;
}
function getColors(): SeekbarColors {
const root = document.documentElement;
const style = root.style;
const key = [
root.getAttribute('data-theme') ?? '',
style.getPropertyValue('--accent'),
style.getPropertyValue('--waveform-played'),
style.getPropertyValue('--waveform-buffered'),
style.getPropertyValue('--waveform-unplayed'),
].join('|');
if (cachedColors && cachedColorsKey === key) return cachedColors;
const s = getComputedStyle(root);
cachedColorsKey = key;
cachedColors = {
played: s.getPropertyValue('--waveform-played').trim() || s.getPropertyValue('--accent').trim() || '#cba6f7',
buffered: s.getPropertyValue('--waveform-buffered').trim() || s.getPropertyValue('--ctp-overlay0').trim() || '#6c7086',
unplayed: s.getPropertyValue('--waveform-unplayed').trim() || s.getPropertyValue('--ctp-surface1').trim() || '#313244',
};
return cachedColors;
}
// ── canvas setup ──────────────────────────────────────────────────────────────
@@ -56,9 +85,8 @@ function setupCanvas(
): { ctx: CanvasRenderingContext2D; w: number; h: number } | null {
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const rect = canvas.getBoundingClientRect();
const w = rect.width || canvas.clientWidth;
const h = rect.height || canvas.clientHeight;
const w = canvas.clientWidth || canvas.getBoundingClientRect().width;
const h = canvas.clientHeight || canvas.getBoundingClientRect().height;
if (w === 0 || h === 0) return null;
const dpr = window.devicePixelRatio || 1;
const pw = Math.round(w * dpr);
@@ -72,6 +100,10 @@ function setupCanvas(
return { ctx, w, h };
}
function setShadowBlur(ctx: CanvasRenderingContext2D, blur: number) {
ctx.shadowBlur = Math.max(0, blur);
}
// ── waveform heights ──────────────────────────────────────────────────────────
function hashStr(str: string): number {
@@ -156,6 +188,15 @@ function waveformBarThickness(logicalH: number, norm: number): number {
return Math.max(1, safeNorm * logicalH);
}
function quantizeProgressByBars(progress: number): number {
const clamped = Math.max(0, Math.min(1, progress));
return Math.max(0, Math.min(1, Math.floor(clamped * BAR_COUNT) / BAR_COUNT));
}
function isBarQuantizedSeekStyle(style: SeekbarStyle): boolean {
return style === 'truewave' || style === 'pseudowave';
}
function drawWaveform(
canvas: HTMLCanvasElement,
heights: Float32Array | null,
@@ -166,6 +207,8 @@ function drawWaveform(
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const pNorm = Math.max(0, Math.min(1, progress));
const bNorm = Math.max(pNorm, Math.min(1, buffered));
if (!heights) {
// No waveform data yet: flat rail like `drawLineDot`, but do not return early
@@ -185,32 +228,30 @@ function drawWaveform(
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 5;
ctx.fillRect(0, cy - lh / 2, Math.min(1, progress) * w, lh);
ctx.shadowBlur = 0;
setShadowBlur(ctx, 5);
ctx.fillRect(0, cy - lh / 2, pNorm * w, lh);
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
if (w > 0) {
const dx = Math.max(dotR, Math.min(w - dotR, Math.min(1, progress) * w));
const dx = Math.max(dotR, Math.min(w - dotR, pNorm * w));
ctx.shadowColor = played;
ctx.shadowBlur = 7;
setShadowBlur(ctx, 7);
ctx.beginPath();
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
ctx.fillStyle = played;
ctx.fill();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
return;
}
const x1Of = (i: number) => (i / BAR_COUNT) * w;
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
ctx.globalAlpha = 0.28;
ctx.fillStyle = unplayed;
for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT < buffered) continue;
if (i / BAR_COUNT < bNorm) continue;
const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
@@ -220,17 +261,17 @@ function drawWaveform(
ctx.fillStyle = buffCol;
for (let i = 0; i < BAR_COUNT; i++) {
const frac = i / BAR_COUNT;
if (frac < progress || frac >= buffered) continue;
if (frac < pNorm || frac >= bNorm) continue;
const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
}
if (progress > 0) {
if (pNorm > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT >= progress) break;
if (i / BAR_COUNT >= pNorm) break;
const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
@@ -264,12 +305,12 @@ function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: numb
const dx = Math.max(dotR, Math.min(w - dotR, progress * w));
ctx.shadowColor = played;
ctx.shadowBlur = 7;
setShadowBlur(ctx, 7);
ctx.beginPath();
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
ctx.fillStyle = played;
ctx.fill();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
ctx.globalAlpha = 1;
}
@@ -300,11 +341,11 @@ function drawBar(canvas: HTMLCanvasElement, progress: number, buffered: number)
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 5;
setShadowBlur(ctx, 5);
ctx.beginPath();
ctx.roundRect(0, y, progress * w, bh, rad);
ctx.fill();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
}
@@ -336,11 +377,11 @@ function drawThick(canvas: HTMLCanvasElement, progress: number, buffered: number
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 10;
setShadowBlur(ctx, 10);
ctx.beginPath();
ctx.roundRect(0, y, progress * w, bh, rad);
ctx.fill();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
}
@@ -359,13 +400,13 @@ function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: nu
for (let i = 0; i < SEG_COUNT; i++) {
const frac = i / SEG_COUNT;
const x = i * (segW + gap);
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
if (frac < progress) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
if (i === playedIdx - 1) {
ctx.shadowColor = played;
ctx.shadowBlur = 5;
setShadowBlur(ctx, 5);
}
} else if (frac < buffered) {
ctx.globalAlpha = 0.55;
@@ -378,7 +419,7 @@ function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: nu
ctx.roundRect(x, y, Math.max(1, segW), segH, 1);
ctx.fill();
}
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
ctx.globalAlpha = 1;
}
@@ -410,34 +451,34 @@ function drawNeon(canvas: HTMLCanvasElement, progress: number, buffered: number)
ctx.globalAlpha = 0.18;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 22;
setShadowBlur(ctx, 22);
ctx.fillRect(0, cy - 5, px, 10);
// Mid glow
ctx.globalAlpha = 0.45;
ctx.shadowBlur = 12;
setShadowBlur(ctx, 12);
ctx.fillRect(0, cy - 2.5, px, 5);
// Inner glow
ctx.globalAlpha = 0.85;
ctx.shadowBlur = 5;
setShadowBlur(ctx, 5);
ctx.fillRect(0, cy - 1.5, px, 3);
// Bright white core
ctx.globalAlpha = 1;
ctx.fillStyle = '#ffffff';
ctx.shadowColor = played;
ctx.shadowBlur = 4;
setShadowBlur(ctx, 4);
ctx.fillRect(0, cy - 0.75, px, 1.5);
// End-cap flare
ctx.shadowBlur = 16;
setShadowBlur(ctx, 16);
ctx.beginPath();
ctx.arc(px, cy, 2.5, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
ctx.globalAlpha = 1;
}
@@ -478,16 +519,16 @@ function drawPulseWave(
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 3;
setShadowBlur(ctx, 3);
ctx.fillRect(0, cy - 1, startX, 2);
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
ctx.strokeStyle = played;
ctx.lineWidth = 1.5;
ctx.shadowColor = played;
ctx.shadowBlur = 7;
setShadowBlur(ctx, 7);
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
@@ -499,7 +540,7 @@ function drawPulseWave(
ctx.lineTo(x, cy - wave);
}
ctx.stroke();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
ctx.globalAlpha = 1;
}
@@ -561,22 +602,22 @@ function drawParticleTrail(
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 4;
setShadowBlur(ctx, 4);
ctx.fillRect(0, cy - 1, px, 2);
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
}
// Particles
ctx.shadowColor = played;
for (const p of animState.particles) {
ctx.globalAlpha = p.life * 0.85;
ctx.shadowBlur = 5;
setShadowBlur(ctx, 5);
ctx.fillStyle = played;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
// Playhead dot
if (progress > 0) {
@@ -584,11 +625,11 @@ function drawParticleTrail(
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 10;
setShadowBlur(ctx, 10);
ctx.beginPath();
ctx.arc(dx, cy, 4, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
@@ -660,9 +701,9 @@ function drawLiquidFill(
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 9;
setShadowBlur(ctx, 9);
ctx.fill();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
// Glass highlight on top
const hl = ctx.createLinearGradient(0, y0, 0, y0 + tubeH * 0.45);
@@ -720,9 +761,9 @@ function drawRetroTape(
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 4;
setShadowBlur(ctx, 4);
ctx.fillRect(0, cy - 1, px - reelR, 2);
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
}
// Spinning reel at playhead
@@ -730,13 +771,13 @@ function drawRetroTape(
ctx.strokeStyle = played;
ctx.lineWidth = 1;
ctx.shadowColor = played;
ctx.shadowBlur = 7;
setShadowBlur(ctx, 7);
// Outer ring
ctx.beginPath();
ctx.arc(px, cy, reelR, 0, Math.PI * 2);
ctx.stroke();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
// Hub
const hubR = Math.max(1.5, reelR * 0.28);
@@ -758,7 +799,7 @@ function drawRetroTape(
}
}
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
ctx.globalAlpha = 1;
}
@@ -772,6 +813,7 @@ export function drawSeekbar(
buffered: number,
animState?: AnimState,
) {
bumpPerfCounter('waveformDraws');
const anim = animState ?? makeAnimState();
switch (style) {
case 'truewave': drawWaveform(canvas, heights, progress, buffered); break;
@@ -829,7 +871,7 @@ export function SeekbarPreview({
}
};
const tick = () => {
if (document.hidden || window.__psyHidden || window.__psyBlurred) {
if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
@@ -902,17 +944,23 @@ export default function WaveformSeek({ trackId }: Props) {
const SEEK_COMMIT_MIN_HOLD_MS = 320;
const SEEK_COMMIT_PROGRESS_EPS = 0.02;
const WHEEL_SEEK_STEP_SECONDS = 10;
const WHEEL_SEEK_DEBOUNCE_MS = 1000;
const WHEEL_SEEK_DEBOUNCE_MS = 350;
const canvasRef = useRef<HTMLCanvasElement>(null);
const heightsRef = useRef<Float32Array | null>(null);
const progressRef = useRef(usePlayerStore.getState().progress);
const bufferedRef = useRef(usePlayerStore.getState().buffered);
const progressRef = useRef(getPlaybackProgressSnapshot().progress);
const bufferedRef = useRef(getPlaybackProgressSnapshot().buffered);
const visualProgressRef = useRef(progressRef.current);
const visualTargetProgressRef = useRef(progressRef.current);
const isDragging = useRef(false);
const animStateRef = useRef<AnimState>(makeAnimState());
const lastStaticDrawAtRef = useRef(0);
const lastStaticDrawProgressRef = useRef(-1);
const lastStaticDrawBufferedRef = useRef(-1);
const [hoverPct, setHoverPct] = useState<number | null>(null);
const seek = usePlayerStore(s => s.seek);
const isPlaying = usePlayerStore(s => s.isPlaying);
const waveformBins = usePlayerStore(s => s.waveformBins);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
@@ -1009,7 +1057,7 @@ export default function WaveformSeek({ trackId }: Props) {
// Imperative subscription — no React re-renders from progress changes.
// Static styles draw here; animated styles only update refs.
useEffect(() => {
return usePlayerStore.subscribe((state, prev) => {
return subscribePlaybackProgress((state, prev) => {
if (state.progress === prev.progress && state.buffered === prev.buffered) return;
// While user drags, keep the local preview stable. External progress ticks
// during streaming/recovery would otherwise fight the cursor and flicker.
@@ -1031,6 +1079,19 @@ export default function WaveformSeek({ trackId }: Props) {
}
progressRef.current = state.progress;
bufferedRef.current = state.buffered;
progressAnchorRef.current = {
progress: state.progress,
atMs: performance.now(),
};
visualTargetProgressRef.current = isBarQuantizedSeekStyle(styleRef.current)
? quantizeProgressByBars(state.progress)
: state.progress;
// While paused the interpolation rAF is disabled, so keep the drawn playhead
// in sync with external seeks (keyboard, MPRIS, queue). Drag/wheel still
// update these refs via previewFraction.
if (!usePlayerStore.getState().isPlaying) {
visualProgressRef.current = visualTargetProgressRef.current;
}
// Static styles always redraw on progress; animated styles let the rAF
// loop drive paints. In `static` animation mode we skip the rAF loop
// entirely, so animated styles also need to repaint here on every tick.
@@ -1038,7 +1099,25 @@ export default function WaveformSeek({ trackId }: Props) {
!ANIMATED_STYLES.has(styleRef.current) || animationModeRef.current === 'static';
if (drawNow) {
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, styleRef.current, heightsRef.current, state.progress, state.buffered);
if (!canvas) return;
if (!ANIMATED_STYLES.has(styleRef.current) && !isDragging.current) {
const now = Date.now();
const widthPx = Math.max(1, canvas.clientWidth || canvas.width || 1);
const minVisualDelta = 0.35 / widthPx; // allow smoother progress while still skipping no-op paints
const progressDelta = Math.abs(state.progress - lastStaticDrawProgressRef.current);
const bufferedDelta = Math.abs(state.buffered - lastStaticDrawBufferedRef.current);
const ageMs = now - lastStaticDrawAtRef.current;
const visuallySame = progressDelta < minVisualDelta && bufferedDelta < minVisualDelta;
if (
ageMs < STATIC_REDRAW_MIN_MS &&
visuallySame
) return;
if (visuallySame && ageMs < STATIC_REDRAW_FORCE_MS) return;
lastStaticDrawAtRef.current = now;
lastStaticDrawProgressRef.current = state.progress;
lastStaticDrawBufferedRef.current = state.buffered;
}
drawSeekbar(canvas, styleRef.current, heightsRef.current, visualProgressRef.current, state.buffered);
}
});
}, []);
@@ -1088,7 +1167,7 @@ export default function WaveformSeek({ trackId }: Props) {
}
};
const tick = () => {
if (document.hidden || window.__psyHidden || window.__psyBlurred) {
if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
@@ -1112,6 +1191,66 @@ export default function WaveformSeek({ trackId }: Props) {
return () => stop();
}, [seekbarStyle, animationMode]);
// Smoothly advance progress between sparse transport ticks.
useEffect(() => {
if (!isPlaying || duration <= 0 || !isFinite(duration)) return;
let rafId: number | null = null;
let lastPaintAt = 0;
const tick = (now: number) => {
if (document.hidden || window.__psyHidden) {
rafId = requestAnimationFrame(tick);
return;
}
if (isDragging.current) {
rafId = requestAnimationFrame(tick);
return;
}
const wheelPreviewFraction = wheelPreviewFractionRef.current;
if (wheelPreviewFraction != null && Date.now() < wheelPreviewUntilRef.current) {
rafId = requestAnimationFrame(tick);
return;
}
if (pendingCommittedSeekRef.current) {
rafId = requestAnimationFrame(tick);
return;
}
const anchor = progressAnchorRef.current;
const elapsedSec = Math.max(0, (now - anchor.atMs) / 1000);
const predicted = Math.max(0, Math.min(1, anchor.progress + elapsedSec / duration));
const nextTargetProgress = isBarQuantizedSeekStyle(styleRef.current)
? quantizeProgressByBars(predicted)
: predicted;
if (Math.abs(nextTargetProgress - visualTargetProgressRef.current) > 0.000001) {
visualTargetProgressRef.current = nextTargetProgress;
}
const currentVisual = visualProgressRef.current;
const targetVisual = visualTargetProgressRef.current;
const delta = targetVisual - currentVisual;
if (Math.abs(delta) > 0.000001) {
const smoothing = isBarQuantizedSeekStyle(styleRef.current) ? 0.22 : 0.28;
const nextVisualProgress = Math.abs(delta) < 0.002
? targetVisual
: currentVisual + delta * smoothing;
visualProgressRef.current = nextVisualProgress;
progressRef.current = nextVisualProgress;
const needsDirectDraw =
!ANIMATED_STYLES.has(styleRef.current) || animationModeRef.current === 'static';
if (needsDirectDraw && now - lastPaintAt >= INTERPOLATION_PAINT_MIN_MS) {
const canvas = canvasRef.current;
if (canvas) {
drawSeekbar(canvas, styleRef.current, heightsRef.current, nextVisualProgress, bufferedRef.current, animStateRef.current);
lastPaintAt = now;
}
}
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => {
if (rafId != null) cancelAnimationFrame(rafId);
};
}, [duration, isPlaying]);
// Resize observer.
useEffect(() => {
const canvas = canvasRef.current;
@@ -1128,6 +1267,7 @@ export default function WaveformSeek({ trackId }: Props) {
const canvas = canvasRef.current;
if (!canvas) return;
const observer = new MutationObserver(() => {
invalidateColorCache();
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
@@ -1140,6 +1280,10 @@ export default function WaveformSeek({ trackId }: Props) {
seekRef.current = seek;
const pendingSeekRef = useRef<number | null>(null);
const pendingCommittedSeekRef = useRef<{ fraction: number; setAtMs: number } | null>(null);
const progressAnchorRef = useRef<{ progress: number; atMs: number }>({
progress: progressRef.current,
atMs: performance.now(),
});
const wheelSeekTimerRef = useRef<number | null>(null);
const queuedWheelSeekFractionRef = useRef<number | null>(null);
const wheelPreviewFractionRef = useRef<number | null>(null);
@@ -1158,6 +1302,12 @@ export default function WaveformSeek({ trackId }: Props) {
// responsiveness; the actual seek is committed on mouseup.
const previewFraction = (fraction: number) => {
progressRef.current = fraction;
visualProgressRef.current = fraction;
visualTargetProgressRef.current = fraction;
progressAnchorRef.current = {
progress: fraction,
atMs: performance.now(),
};
pendingSeekRef.current = fraction;
const canvas = canvasRef.current;
if (canvas && !ANIMATED_STYLES.has(styleRef.current)) {
@@ -1222,6 +1372,12 @@ export default function WaveformSeek({ trackId }: Props) {
// Preventive UI update: move visual playhead immediately on every wheel event.
progressRef.current = nextFraction;
visualProgressRef.current = nextFraction;
visualTargetProgressRef.current = nextFraction;
progressAnchorRef.current = {
progress: nextFraction,
atMs: performance.now(),
};
wheelPreviewFractionRef.current = nextFraction;
wheelPreviewUntilRef.current = now + WHEEL_SEEK_DEBOUNCE_MS;
const canvas = canvasRef.current;