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
+36 -8
View File
@@ -349,6 +349,7 @@ const CONTRIBUTORS = [
'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)',
'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;
@@ -3863,15 +3864,42 @@ export default function Settings() {
/>
))}
</div>
<div className="settings-toggle-row" style={{ marginTop: '1rem', paddingTop: '1rem', borderTop: '1px solid var(--border)' }}>
<div>
<div style={{ fontWeight: 500 }}>{t('settings.reducedAnimations')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.reducedAnimationsDesc')}</div>
<div className="settings-norm-block" style={{ marginTop: '1rem', paddingTop: '1rem', borderTop: '1px solid var(--border)' }}>
<div className="settings-norm-field">
<div className="settings-norm-row">
<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>
<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>
</SettingsSubSection>