feat(perf): 3-state animation mode (Full / Reduced / Static) (#441)

* feat(perf): 3-state animation mode (Full / Reduced / Static)

Replaces the boolean `reducedAnimations` toggle with a three-way
`animationMode` setting, suggested by Viktor Petrovich after the
Windows audio fix (PR #426) shipped and confirmed a measurable
GPU drop:

- `full` (default): native frame rate, marquee scrolls normally
- `reduced`: 30 fps cap on the animated seekbar wave; player
  marquee runs at half speed
- `static`: rAF loop disabled; the seekbar repaints from the
  ~2 Hz audio:progress heartbeat. Player title/artist truncate
  with ellipsis instead of scrolling.

Migration in `onRehydrateStorage` maps legacy
`reducedAnimations: true` to `'reduced'`, anything else to
`'full'`. Static is opt-in only.

Settings UI follows the ReplayGain Auto/Track/Album pattern with
a contextual hint that explains what each mode does.

i18n: 5 new keys across 8 locales, 2 legacy keys removed.

* docs(changelog): add #441 3-state animation mode entry

* chore(credits): add #441 to Psychotoxical contributions
This commit is contained in:
Frank Stellmacher
2026-05-03 14:12:27 +02:00
committed by GitHub
parent 364b29ceee
commit 98ff73d17a
14 changed files with 174 additions and 41 deletions
+9 -2
View File
@@ -1,4 +1,5 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import { useAuthStore } from '../store/authStore';
interface Props {
text: string;
@@ -11,6 +12,7 @@ export default function MarqueeText({ text, className, style, onClick }: Props)
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [scrollAmount, setScrollAmount] = useState(0);
const animationMode = useAuthStore(s => s.animationMode);
const measure = useCallback(() => {
const container = containerRef.current;
@@ -30,17 +32,22 @@ export default function MarqueeText({ text, className, style, onClick }: Props)
return () => ro.disconnect();
}, [text, measure]);
// 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';
return (
<div
ref={containerRef}
className={`marquee-wrap${className ? ` ${className}` : ''}`}
style={style}
onClick={onClick}
data-anim-mode={animationMode}
>
<span
ref={textRef}
className={scrollAmount > 0 ? 'marquee-scroll' : ''}
style={scrollAmount > 0 ? { '--marquee-amount': `-${scrollAmount}px` } as React.CSSProperties : {}}
className={shouldScroll ? 'marquee-scroll' : ''}
style={shouldScroll ? { '--marquee-amount': `-${scrollAmount}px` } as React.CSSProperties : {}}
>
{text}
</span>
+29 -10
View File
@@ -916,14 +916,14 @@ export default function WaveformSeek({ trackId }: Props) {
const waveformBins = usePlayerStore(s => s.waveformBins);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
const reducedAnimations = useAuthStore(s => s.reducedAnimations);
const animationMode = useAuthStore(s => s.animationMode);
// Ref so the subscription callback (closed over at mount) can read the
// current style without stale-closure issues.
const styleRef = useRef(seekbarStyle);
styleRef.current = seekbarStyle;
const reducedRef = useRef(reducedAnimations);
reducedRef.current = reducedAnimations;
const animationModeRef = useRef(animationMode);
animationModeRef.current = animationMode;
useEffect(() => {
if (!trackId) {
@@ -1031,7 +1031,12 @@ export default function WaveformSeek({ trackId }: Props) {
}
progressRef.current = state.progress;
bufferedRef.current = state.buffered;
if (!ANIMATED_STYLES.has(styleRef.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.
const drawNow =
!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);
}
@@ -1050,9 +1055,22 @@ export default function WaveformSeek({ trackId }: Props) {
duration,
]);
// rAF loop — animated styles only.
// rAF loop — animated styles only, and only in `full`/`reduced` mode.
// In `static` mode the loop is skipped entirely; the imperative progress
// subscription (~2 Hz audio heartbeat) handles repaints, so the seekbar
// updates a couple of times per second with no wave animation.
useEffect(() => {
if (!ANIMATED_STYLES.has(seekbarStyle)) return;
if (animationMode === 'static') {
// Repaint once on entry so the canvas reflects current progress
// without any wave morph and stays put until the next heartbeat.
const canvas = canvasRef.current;
if (canvas) {
animStateRef.current = makeAnimState();
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
}
return;
}
const canvas = canvasRef.current;
if (!canvas) return;
animStateRef.current = makeAnimState();
@@ -1077,21 +1095,22 @@ export default function WaveformSeek({ trackId }: Props) {
}, 400);
return;
}
// 30 fps cap when reducedAnimations is on: skip every other rAF, advance
// 30 fps cap in `reduced` mode: skip every other rAF, advance
// animation time by a doubled delta so wave speed stays the same.
if (reducedRef.current && skip) {
const isReduced = animationModeRef.current === 'reduced';
if (isReduced && skip) {
skip = false;
rafId = requestAnimationFrame(tick);
return;
}
skip = reducedRef.current;
animStateRef.current.time += reducedRef.current ? 0.032 : 0.016;
skip = isReduced;
animStateRef.current.time += isReduced ? 0.032 : 0.016;
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
rafId = requestAnimationFrame(tick);
};
tick();
return () => stop();
}, [seekbarStyle]);
}, [seekbarStyle, animationMode]);
// Resize observer.
useEffect(() => {