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
+13
View File
@@ -142,6 +142,19 @@ Nine new input actions were added (requested by zunoz on Discord): start search,
Translations for the new action labels follow in a separate i18n nachhol-PR (de, fr, nl, zh, nb, ru, es). Translations for the new action labels follow in a separate i18n nachhol-PR (de, fr, nl, zh, nb, ru, es).
### Settings — 3-State Animation Mode (Full / Reduced / Static)
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#441](https://github.com/Psychotoxical/psysonic/pull/441), suggested by harumscarum on Telegram**
The boolean **Reduce animations** toggle in **Settings → Appearance** is now a three-state picker matching the ReplayGain Auto/Track/Album pattern:
- **Full** (default for new users): native frame rate, marquee scrolls normally.
- **Reduced** (default for users who had the legacy toggle on): 30 fps cap on the animated seekbar wave; the player title marquee runs at half speed.
- **Static**: the rAF loop driving the animated seekbar is disabled entirely — the seekbar repaints from the ~2 Hz `audio:progress` heartbeat only. The player title/artist no longer scroll; long names are truncated with an ellipsis. Lowest GPU/CPU cost of the three.
Existing users with `reducedAnimations: true` are migrated 1:1 to **Reduced** on first launch; everyone else lands on **Full**. The picker is in the same place as before. A contextual hint below the picker explains what the selected mode does.
## Fixed ## Fixed
- **Settings → Audio no longer blanks the app on macOS** *(Issue [#382](https://github.com/Psychotoxical/psysonic/issues/382), PR [#384](https://github.com/Psychotoxical/psysonic/pull/384), by [@Psychotoxical](https://github.com/Psychotoxical))*: Fixed a macOS-only crash where opening Settings → Audio could turn the whole app into a blank window. The Equalizer canvas now waits until it has valid layout dimensions before drawing, and redraws automatically once the section is visible. - **Settings → Audio no longer blanks the app on macOS** *(Issue [#382](https://github.com/Psychotoxical/psysonic/issues/382), PR [#384](https://github.com/Psychotoxical/psysonic/pull/384), by [@Psychotoxical](https://github.com/Psychotoxical))*: Fixed a macOS-only crash where opening Settings → Audio could turn the whole app into a blank window. The Equalizer canvas now waits until it has valid layout dimensions before drawing, and redraws automatically once the section is visible.
+9 -2
View File
@@ -1,4 +1,5 @@
import React, { useRef, useState, useEffect, useCallback } from 'react'; import React, { useRef, useState, useEffect, useCallback } from 'react';
import { useAuthStore } from '../store/authStore';
interface Props { interface Props {
text: string; text: string;
@@ -11,6 +12,7 @@ export default function MarqueeText({ text, className, style, onClick }: Props)
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLSpanElement>(null); const textRef = useRef<HTMLSpanElement>(null);
const [scrollAmount, setScrollAmount] = useState(0); const [scrollAmount, setScrollAmount] = useState(0);
const animationMode = useAuthStore(s => s.animationMode);
const measure = useCallback(() => { const measure = useCallback(() => {
const container = containerRef.current; const container = containerRef.current;
@@ -30,17 +32,22 @@ export default function MarqueeText({ text, className, style, onClick }: Props)
return () => ro.disconnect(); return () => ro.disconnect();
}, [text, measure]); }, [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 ( return (
<div <div
ref={containerRef} ref={containerRef}
className={`marquee-wrap${className ? ` ${className}` : ''}`} className={`marquee-wrap${className ? ` ${className}` : ''}`}
style={style} style={style}
onClick={onClick} onClick={onClick}
data-anim-mode={animationMode}
> >
<span <span
ref={textRef} ref={textRef}
className={scrollAmount > 0 ? 'marquee-scroll' : ''} className={shouldScroll ? 'marquee-scroll' : ''}
style={scrollAmount > 0 ? { '--marquee-amount': `-${scrollAmount}px` } as React.CSSProperties : {}} style={shouldScroll ? { '--marquee-amount': `-${scrollAmount}px` } as React.CSSProperties : {}}
> >
{text} {text}
</span> </span>
+29 -10
View File
@@ -916,14 +916,14 @@ export default function WaveformSeek({ trackId }: Props) {
const waveformBins = usePlayerStore(s => s.waveformBins); const waveformBins = usePlayerStore(s => s.waveformBins);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seekbarStyle = useAuthStore(s => s.seekbarStyle); 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 // Ref so the subscription callback (closed over at mount) can read the
// current style without stale-closure issues. // current style without stale-closure issues.
const styleRef = useRef(seekbarStyle); const styleRef = useRef(seekbarStyle);
styleRef.current = seekbarStyle; styleRef.current = seekbarStyle;
const reducedRef = useRef(reducedAnimations); const animationModeRef = useRef(animationMode);
reducedRef.current = reducedAnimations; animationModeRef.current = animationMode;
useEffect(() => { useEffect(() => {
if (!trackId) { if (!trackId) {
@@ -1031,7 +1031,12 @@ export default function WaveformSeek({ trackId }: Props) {
} }
progressRef.current = state.progress; progressRef.current = state.progress;
bufferedRef.current = state.buffered; 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; const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, styleRef.current, heightsRef.current, state.progress, state.buffered); if (canvas) drawSeekbar(canvas, styleRef.current, heightsRef.current, state.progress, state.buffered);
} }
@@ -1050,9 +1055,22 @@ export default function WaveformSeek({ trackId }: Props) {
duration, 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(() => { useEffect(() => {
if (!ANIMATED_STYLES.has(seekbarStyle)) return; 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; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
animStateRef.current = makeAnimState(); animStateRef.current = makeAnimState();
@@ -1077,21 +1095,22 @@ export default function WaveformSeek({ trackId }: Props) {
}, 400); }, 400);
return; 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. // 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; skip = false;
rafId = requestAnimationFrame(tick); rafId = requestAnimationFrame(tick);
return; return;
} }
skip = reducedRef.current; skip = isReduced;
animStateRef.current.time += reducedRef.current ? 0.032 : 0.016; animStateRef.current.time += isReduced ? 0.032 : 0.016;
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current); drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
rafId = requestAnimationFrame(tick); rafId = requestAnimationFrame(tick);
}; };
tick(); tick();
return () => stop(); return () => stop();
}, [seekbarStyle]); }, [seekbarStyle, animationMode]);
// Resize observer. // Resize observer.
useEffect(() => { useEffect(() => {
+7 -2
View File
@@ -925,8 +925,13 @@ export const deTranslation = {
sidebarLyricsStyleAppleDesc: 'Aktive Zeile oben mit sanfter Animation.', sidebarLyricsStyleAppleDesc: 'Aktive Zeile oben mit sanfter Animation.',
seekbarStyle: 'Seekbar-Stil', seekbarStyle: 'Seekbar-Stil',
seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen', seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen',
reducedAnimations: 'Animationen reduzieren', animationMode: 'Animationen',
reducedAnimationsDesc: 'Animierte Seekbar-Stile auf 30 fps drosseln, um die GPU-Last auf schwächerer Hardware zu senken. Optisch kaum wahrnehmbar.', animationModeDesc: 'Visuelle Glätte gegen geringere GPU-/CPU-Last auf langsamerer Hardware tauschen.',
animationModeFull: 'Voll',
animationModeReduced: 'Reduziert',
animationModeStatic: 'Statisch',
animationModeReducedHint: '30 fps Limit auf der Seekbar-Welle; Titel im Player läuft mit halber Geschwindigkeit.',
animationModeStaticHint: 'Keine Wellenanimation; Titel im Player wird abgeschnitten statt gescrollt.',
seekbarTruewave: 'Echte Wellenform', seekbarTruewave: 'Echte Wellenform',
seekbarPseudowave: 'Pseudo-Wellenform', seekbarPseudowave: 'Pseudo-Wellenform',
seekbarLinedot: 'Linie & Punkt', seekbarLinedot: 'Linie & Punkt',
+7 -2
View File
@@ -931,8 +931,13 @@ export const enTranslation = {
fsPortraitDim: 'Photo dimming', fsPortraitDim: 'Photo dimming',
seekbarStyle: 'Seekbar Style', seekbarStyle: 'Seekbar Style',
seekbarStyleDesc: 'Choose the look of the player seek bar', seekbarStyleDesc: 'Choose the look of the player seek bar',
reducedAnimations: 'Reduce animations', animationMode: 'Animations',
reducedAnimationsDesc: 'Cap animated seekbar styles to 30 fps to lower GPU usage on slower hardware. Visual difference is minimal.', animationModeDesc: 'Trade visual smoothness for lower GPU/CPU usage on slower hardware.',
animationModeFull: 'Full',
animationModeReduced: 'Reduced',
animationModeStatic: 'Static',
animationModeReducedHint: '30 fps cap on the seekbar wave; player title scrolls at half speed.',
animationModeStaticHint: 'No wave animation; player title is truncated instead of scrolling.',
seekbarTruewave: 'Truewave', seekbarTruewave: 'Truewave',
seekbarPseudowave: 'Pseudowave', seekbarPseudowave: 'Pseudowave',
seekbarLinedot: 'Line & Dot', seekbarLinedot: 'Line & Dot',
+7 -2
View File
@@ -918,8 +918,13 @@ export const esTranslation = {
sidebarLyricsStyleAppleDesc: 'La línea activa se ancla arriba con animación suave.', sidebarLyricsStyleAppleDesc: 'La línea activa se ancla arriba con animación suave.',
seekbarStyle: 'Estilo de Barra de Progreso', seekbarStyle: 'Estilo de Barra de Progreso',
seekbarStyleDesc: 'Elige la apariencia de la barra de progreso del reproductor', seekbarStyleDesc: 'Elige la apariencia de la barra de progreso del reproductor',
reducedAnimations: 'Reducir animaciones', animationMode: 'Animaciones',
reducedAnimationsDesc: 'Limita los estilos animados de la barra de progreso a 30 fps para reducir el uso de GPU en hardware más lento. La diferencia visual es mínima.', animationModeDesc: 'Cambia fluidez visual por menor uso de GPU/CPU en hardware más lento.',
animationModeFull: 'Completas',
animationModeReduced: 'Reducidas',
animationModeStatic: 'Estáticas',
animationModeReducedHint: 'Límite de 30 fps en la onda de la barra; el título del reproductor se desplaza a la mitad de velocidad.',
animationModeStaticHint: 'Sin animación de onda; el título del reproductor se trunca en lugar de desplazarse.',
seekbarTruewave: 'Forma de Onda Real', seekbarTruewave: 'Forma de Onda Real',
seekbarPseudowave: 'Forma de Onda Pseudo', seekbarPseudowave: 'Forma de Onda Pseudo',
seekbarLinedot: 'Línea y Punto', seekbarLinedot: 'Línea y Punto',
+7 -2
View File
@@ -913,8 +913,13 @@ export const frTranslation = {
sidebarLyricsStyleAppleDesc: "La ligne active reste en haut avec une animation fluide.", sidebarLyricsStyleAppleDesc: "La ligne active reste en haut avec une animation fluide.",
seekbarStyle: 'Style de la barre de lecture', seekbarStyle: 'Style de la barre de lecture',
seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression', seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression',
reducedAnimations: 'Réduire les animations', animationMode: 'Animations',
reducedAnimationsDesc: 'Limiter les styles de barre de lecture animés à 30 fps pour réduire l\'utilisation du GPU sur le matériel plus lent. La différence visuelle est minime.', animationModeDesc: 'Échanger la fluidité visuelle contre une utilisation GPU/CPU réduite sur du matériel plus lent.',
animationModeFull: 'Complètes',
animationModeReduced: 'Réduites',
animationModeStatic: 'Statiques',
animationModeReducedHint: 'Plafond de 30 fps sur l\'onde de la barre ; le titre du lecteur défile à moitié vitesse.',
animationModeStaticHint: 'Pas d\'animation d\'onde ; le titre du lecteur est tronqué au lieu de défiler.',
seekbarTruewave: 'Forme d\'onde réelle', seekbarTruewave: 'Forme d\'onde réelle',
seekbarPseudowave: 'Forme d\'onde pseudo', seekbarPseudowave: 'Forme d\'onde pseudo',
seekbarLinedot: 'Ligne & point', seekbarLinedot: 'Ligne & point',
+7 -2
View File
@@ -912,8 +912,13 @@ export const nbTranslation = {
sidebarLyricsStyleAppleDesc: 'Aktiv linje forankres øverst med jevn animasjon.', sidebarLyricsStyleAppleDesc: 'Aktiv linje forankres øverst med jevn animasjon.',
seekbarStyle: 'Søkefelt-stil', seekbarStyle: 'Søkefelt-stil',
seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet', seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet',
reducedAnimations: 'Reduser animasjoner', animationMode: 'Animasjoner',
reducedAnimationsDesc: 'Begrens animerte søkefelt-stiler til 30 fps for å redusere GPU-belastning på tregere maskinvare. Visuell forskjell er minimal.', animationModeDesc: 'Bytt visuell flyt mot lavere GPU-/CPU-bruk på tregere maskinvare.',
animationModeFull: 'Full',
animationModeReduced: 'Redusert',
animationModeStatic: 'Statisk',
animationModeReducedHint: '30 fps grense på søkefelt-bølgen; spillertittelen ruller med halv hastighet.',
animationModeStaticHint: 'Ingen bølgeanimasjon; spillertittelen forkortes i stedet for å rulle.',
seekbarTruewave: 'Ekte bølgeform', seekbarTruewave: 'Ekte bølgeform',
seekbarPseudowave: 'Pseudo-bølgeform', seekbarPseudowave: 'Pseudo-bølgeform',
seekbarLinedot: 'Linje & punkt', seekbarLinedot: 'Linje & punkt',
+7 -2
View File
@@ -912,8 +912,13 @@ export const nlTranslation = {
sidebarLyricsStyleAppleDesc: 'Actieve regel verankerd bovenaan met vloeiende animatie.', sidebarLyricsStyleAppleDesc: 'Actieve regel verankerd bovenaan met vloeiende animatie.',
seekbarStyle: 'Zoekbalkstijl', seekbarStyle: 'Zoekbalkstijl',
seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk', seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk',
reducedAnimations: 'Animaties beperken', animationMode: 'Animaties',
reducedAnimationsDesc: 'Beperk geanimeerde zoekbalkstijlen tot 30 fps om GPU-gebruik op tragere hardware te verminderen. Visueel verschil is minimaal.', animationModeDesc: 'Wissel visuele soepelheid in voor minder GPU-/CPU-gebruik op tragere hardware.',
animationModeFull: 'Volledig',
animationModeReduced: 'Gereduceerd',
animationModeStatic: 'Statisch',
animationModeReducedHint: '30 fps limiet op de zoekbalk-golf; spelertitel scrollt op halve snelheid.',
animationModeStaticHint: 'Geen golfanimatie; spelertitel wordt afgekapt in plaats van scrollen.',
seekbarTruewave: 'Echte golfvorm', seekbarTruewave: 'Echte golfvorm',
seekbarPseudowave: 'Pseudo-golfvorm', seekbarPseudowave: 'Pseudo-golfvorm',
seekbarLinedot: 'Lijn & punt', seekbarLinedot: 'Lijn & punt',
+7 -2
View File
@@ -964,8 +964,13 @@ export const ruTranslation = {
sidebarLyricsStyleAppleDesc: 'Активная строка фиксируется сверху с плавной анимацией.', sidebarLyricsStyleAppleDesc: 'Активная строка фиксируется сверху с плавной анимацией.',
seekbarStyle: 'Стиль прогресс-бара', seekbarStyle: 'Стиль прогресс-бара',
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения', seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
reducedAnimations: 'Снизить анимации', animationMode: 'Анимации',
reducedAnimationsDesc: 'Ограничить анимированные стили прогресс-бара 30 кадрами в секунду для снижения нагрузки на GPU на слабом железе. Визуальная разница минимальна.', animationModeDesc: 'Обменивайте визуальную плавность на меньшую нагрузку на GPU/CPU на слабом железе.',
animationModeFull: 'Полные',
animationModeReduced: 'Сниженные',
animationModeStatic: 'Статичные',
animationModeReducedHint: 'Ограничение 30 fps для волны прогресс-бара; заголовок плеера прокручивается с половинной скоростью.',
animationModeStaticHint: 'Без анимации волны; заголовок плеера обрезается вместо прокрутки.',
seekbarTruewave: 'Реальная форма волны', seekbarTruewave: 'Реальная форма волны',
seekbarPseudowave: 'Псевдо форма волны', seekbarPseudowave: 'Псевдо форма волны',
seekbarLinedot: 'Линия и точка', seekbarLinedot: 'Линия и точка',
+7 -2
View File
@@ -907,8 +907,13 @@ export const zhTranslation = {
sidebarLyricsStyleAppleDesc: '活动行固定在顶部,带有平滑弹簧动画。', sidebarLyricsStyleAppleDesc: '活动行固定在顶部,带有平滑弹簧动画。',
seekbarStyle: '进度条样式', seekbarStyle: '进度条样式',
seekbarStyleDesc: '选择播放进度条的外观', seekbarStyleDesc: '选择播放进度条的外观',
reducedAnimations: '减少动画', animationMode: '动画',
reducedAnimationsDesc: '将动画进度条样式限制为 30 fps,以降低较慢硬件上的 GPU 占用。视觉差异极小。', animationModeDesc: '较慢硬件上以视觉流畅度换取更低的 GPU/CPU 占用。',
animationModeFull: '完整',
animationModeReduced: '减少',
animationModeStatic: '静态',
animationModeReducedHint: '进度条波形限制为 30 fps;播放器标题以半速滚动。',
animationModeStaticHint: '无波形动画;播放器标题被截断而不滚动。',
seekbarTruewave: '真实波形', seekbarTruewave: '真实波形',
seekbarPseudowave: '伪波形', seekbarPseudowave: '伪波形',
seekbarLinedot: '线条与点', seekbarLinedot: '线条与点',
+36 -8
View File
@@ -349,6 +349,7 @@ const CONTRIBUTORS = [
'Statistics: shareable Top-Albums card export (PR #425)', 'Statistics: shareable Top-Albums card export (PR #425)',
'Windows: playback stutter under GPU load — MMCSS Pro Audio promotion + animation pause + reduce-animations toggle (PR #426)', 'Windows: playback stutter under GPU load — MMCSS Pro Audio promotion + animation pause + reduce-animations toggle (PR #426)',
'Audio: frame-align gapless-off track-separation silence (fixes mono-channel playback after natural track end) (PR #439)', 'Audio: frame-align gapless-off track-separation silence (fixes mono-channel playback after natural track end) (PR #439)',
'Settings: 3-state animation mode (Full / Reduced / Static) — replaces boolean reduce-animations toggle (PR #441)',
], ],
}, },
] as const; ] as const;
@@ -3863,15 +3864,42 @@ export default function Settings() {
/> />
))} ))}
</div> </div>
<div className="settings-toggle-row" style={{ marginTop: '1rem', paddingTop: '1rem', borderTop: '1px solid var(--border)' }}> <div className="settings-norm-block" style={{ marginTop: '1rem', paddingTop: '1rem', borderTop: '1px solid var(--border)' }}>
<div> <div className="settings-norm-field">
<div style={{ fontWeight: 500 }}>{t('settings.reducedAnimations')}</div> <div className="settings-norm-row">
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.reducedAnimationsDesc')}</div> <span className="settings-norm-label">{t('settings.animationMode')}</span>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
<button
className={`btn ${auth.animationMode === 'full' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '4px 14px' }}
onClick={() => auth.setAnimationMode('full')}
>
{t('settings.animationModeFull')}
</button>
<button
className={`btn ${auth.animationMode === 'reduced' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '4px 14px' }}
onClick={() => auth.setAnimationMode('reduced')}
>
{t('settings.animationModeReduced')}
</button>
<button
className={`btn ${auth.animationMode === 'static' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '4px 14px' }}
onClick={() => auth.setAnimationMode('static')}
>
{t('settings.animationModeStatic')}
</button>
</div>
</div>
<div className="settings-norm-help">
{auth.animationMode === 'reduced'
? t('settings.animationModeReducedHint')
: auth.animationMode === 'static'
? t('settings.animationModeStaticHint')
: t('settings.animationModeDesc')}
</div>
</div> </div>
<label className="toggle-switch" aria-label={t('settings.reducedAnimations')}>
<input type="checkbox" checked={auth.reducedAnimations} onChange={e => auth.setReducedAnimations(e.target.checked)} />
<span className="toggle-track" />
</label>
</div> </div>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
+20 -5
View File
@@ -25,6 +25,7 @@ export interface ServerProfile {
export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape'; export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
export type LoggingMode = 'off' | 'normal' | 'debug'; export type LoggingMode = 'off' | 'normal' | 'debug';
export type NormalizationEngine = 'off' | 'replaygain' | 'loudness'; export type NormalizationEngine = 'off' | 'replaygain' | 'loudness';
export type AnimationMode = 'full' | 'reduced' | 'static';
/** Integrated-loudness target presets (Settings + analysis). */ /** Integrated-loudness target presets (Settings + analysis). */
export type LoudnessLufsPreset = -16 | -14 | -12 | -10; export type LoudnessLufsPreset = -16 | -14 | -12 | -10;
@@ -176,8 +177,12 @@ interface AuthState {
lastSeenChangelogVersion: string; lastSeenChangelogVersion: string;
seekbarStyle: SeekbarStyle; seekbarStyle: SeekbarStyle;
/** Cap animated seekbar styles to 30 fps (and similar GPU-friendly tweaks) for low-end hardware. */ /** Animation budget across the UI:
reducedAnimations: boolean; * - `full` = native frame rate
* - `reduced`= 30 fps cap on animated seekbar; marquee at half frame rate
* - `static` = ~1 fps progress (no rAF interpolation); marquee disabled (truncate)
*/
animationMode: AnimationMode;
/** Persisted UI toggle: is the Now Playing section in queue panel collapsed */ /** Persisted UI toggle: is the Now Playing section in queue panel collapsed */
queueNowPlayingCollapsed: boolean; queueNowPlayingCollapsed: boolean;
@@ -327,7 +332,7 @@ interface AuthState {
setShowChangelogOnUpdate: (v: boolean) => void; setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void; setLastSeenChangelogVersion: (v: string) => void;
setSeekbarStyle: (v: SeekbarStyle) => void; setSeekbarStyle: (v: SeekbarStyle) => void;
setReducedAnimations: (v: boolean) => void; setAnimationMode: (v: AnimationMode) => void;
setQueueNowPlayingCollapsed: (v: boolean) => void; setQueueNowPlayingCollapsed: (v: boolean) => void;
setEnableHiRes: (v: boolean) => void; setEnableHiRes: (v: boolean) => void;
setAudioOutputDevice: (v: string | null) => void; setAudioOutputDevice: (v: string | null) => void;
@@ -448,7 +453,7 @@ export const useAuthStore = create<AuthState>()(
showChangelogOnUpdate: true, showChangelogOnUpdate: true,
lastSeenChangelogVersion: '', lastSeenChangelogVersion: '',
seekbarStyle: 'truewave', seekbarStyle: 'truewave',
reducedAnimations: false, animationMode: 'full',
queueNowPlayingCollapsed: false, queueNowPlayingCollapsed: false,
enableHiRes: false, enableHiRes: false,
audioOutputDevice: null, audioOutputDevice: null,
@@ -611,7 +616,7 @@ export const useAuthStore = create<AuthState>()(
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
setSeekbarStyle: (v) => set({ seekbarStyle: v }), setSeekbarStyle: (v) => set({ seekbarStyle: v }),
setReducedAnimations: (v) => set({ reducedAnimations: v }), setAnimationMode: (v) => set({ animationMode: v }),
setQueueNowPlayingCollapsed: (v: boolean) => set({ queueNowPlayingCollapsed: v }), setQueueNowPlayingCollapsed: (v: boolean) => set({ queueNowPlayingCollapsed: v }),
setEnableHiRes: (v) => set({ enableHiRes: v }), setEnableHiRes: (v) => set({ enableHiRes: v }),
setAudioOutputDevice: (v) => set({ audioOutputDevice: v }), setAudioOutputDevice: (v) => set({ audioOutputDevice: v }),
@@ -813,6 +818,15 @@ export const useAuthStore = create<AuthState>()(
? {} ? {}
: { seekbarStyle: 'truewave' as SeekbarStyle }; : { seekbarStyle: 'truewave' as SeekbarStyle };
// `reducedAnimations: boolean` superseded by `animationMode: AnimationMode`.
// Map legacy true → 'reduced', false/missing → 'full'. Static is opt-in only.
let animationModeMigrated: { animationMode?: AnimationMode } = {};
const legacyReduced = (state as { reducedAnimations?: unknown }).reducedAnimations;
const VALID_ANIM_MODES = new Set<string>(['full', 'reduced', 'static']);
if (!VALID_ANIM_MODES.has(state.animationMode as string)) {
animationModeMigrated = { animationMode: legacyReduced === true ? 'reduced' : 'full' };
}
const st = state as { const st = state as {
loudnessTargetLufs?: unknown; loudnessTargetLufs?: unknown;
loudnessPreAnalysisAttenuationDb?: unknown; loudnessPreAnalysisAttenuationDb?: unknown;
@@ -844,6 +858,7 @@ export const useAuthStore = create<AuthState>()(
...lyricsSourcesMigrated, ...lyricsSourcesMigrated,
...wheelSmoothOneTime, ...wheelSmoothOneTime,
...seekbarStyleMigrated, ...seekbarStyleMigrated,
...animationModeMigrated,
}); });
}, },
} }
+11
View File
@@ -1516,12 +1516,23 @@ html[data-platform="windows"] .player-bar.floating {
width: 100%; width: 100%;
} }
/* `static` animation mode: never scroll, truncate with ellipsis instead. */
.marquee-wrap[data-anim-mode="static"] {
text-overflow: ellipsis;
}
.marquee-scroll { .marquee-scroll {
display: inline-block; display: inline-block;
animation: player-marquee 12s linear infinite; animation: player-marquee 12s linear infinite;
animation-delay: 2s; animation-delay: 2s;
} }
/* `reduced` animation mode: double the marquee cycle so visual change
per second is roughly halved. */
.marquee-wrap[data-anim-mode="reduced"] .marquee-scroll {
animation-duration: 24s;
}
@keyframes player-marquee { @keyframes player-marquee {
0%, 12% { transform: translateX(0); } 0%, 12% { transform: translateX(0); }
78%, 88% { transform: translateX(var(--marquee-amount)); } 78%, 88% { transform: translateX(var(--marquee-amount)); }