diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f21a2f9..deb8c7c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). +### 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 - **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. diff --git a/src/components/MarqueeText.tsx b/src/components/MarqueeText.tsx index ba3323b2..8dbb7f9e 100644 --- a/src/components/MarqueeText.tsx +++ b/src/components/MarqueeText.tsx @@ -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(null); const textRef = useRef(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 (
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} diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index ab6eae9f..15ca7986 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -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(() => { diff --git a/src/locales/de.ts b/src/locales/de.ts index f1e3ea86..94b1f9da 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -925,8 +925,13 @@ export const deTranslation = { sidebarLyricsStyleAppleDesc: 'Aktive Zeile oben mit sanfter Animation.', seekbarStyle: 'Seekbar-Stil', seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen', - reducedAnimations: 'Animationen reduzieren', - reducedAnimationsDesc: 'Animierte Seekbar-Stile auf 30 fps drosseln, um die GPU-Last auf schwächerer Hardware zu senken. Optisch kaum wahrnehmbar.', + animationMode: 'Animationen', + 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', seekbarPseudowave: 'Pseudo-Wellenform', seekbarLinedot: 'Linie & Punkt', diff --git a/src/locales/en.ts b/src/locales/en.ts index a770f8ed..ddaf7bfd 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -931,8 +931,13 @@ export const enTranslation = { fsPortraitDim: 'Photo dimming', seekbarStyle: 'Seekbar Style', seekbarStyleDesc: 'Choose the look of the player seek bar', - reducedAnimations: 'Reduce animations', - reducedAnimationsDesc: 'Cap animated seekbar styles to 30 fps to lower GPU usage on slower hardware. Visual difference is minimal.', + animationMode: 'Animations', + 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', seekbarPseudowave: 'Pseudowave', seekbarLinedot: 'Line & Dot', diff --git a/src/locales/es.ts b/src/locales/es.ts index 7f16c9fe..126ff993 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -918,8 +918,13 @@ export const esTranslation = { sidebarLyricsStyleAppleDesc: 'La línea activa se ancla arriba con animación suave.', seekbarStyle: 'Estilo de Barra de Progreso', seekbarStyleDesc: 'Elige la apariencia de la barra de progreso del reproductor', - reducedAnimations: 'Reducir 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.', + animationMode: 'Animaciones', + 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', seekbarPseudowave: 'Forma de Onda Pseudo', seekbarLinedot: 'Línea y Punto', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 2d5e3501..b4cbe0ac 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -913,8 +913,13 @@ export const frTranslation = { sidebarLyricsStyleAppleDesc: "La ligne active reste en haut avec une animation fluide.", seekbarStyle: 'Style de la barre de lecture', seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression', - reducedAnimations: 'Réduire les 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.', + animationMode: 'Animations', + 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', seekbarPseudowave: 'Forme d\'onde pseudo', seekbarLinedot: 'Ligne & point', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index dc922265..09752b6d 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -912,8 +912,13 @@ export const nbTranslation = { sidebarLyricsStyleAppleDesc: 'Aktiv linje forankres øverst med jevn animasjon.', seekbarStyle: 'Søkefelt-stil', seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet', - reducedAnimations: 'Reduser animasjoner', - reducedAnimationsDesc: 'Begrens animerte søkefelt-stiler til 30 fps for å redusere GPU-belastning på tregere maskinvare. Visuell forskjell er minimal.', + animationMode: 'Animasjoner', + 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', seekbarPseudowave: 'Pseudo-bølgeform', seekbarLinedot: 'Linje & punkt', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 54f82be7..dc84996c 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -912,8 +912,13 @@ export const nlTranslation = { sidebarLyricsStyleAppleDesc: 'Actieve regel verankerd bovenaan met vloeiende animatie.', seekbarStyle: 'Zoekbalkstijl', seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk', - reducedAnimations: 'Animaties beperken', - reducedAnimationsDesc: 'Beperk geanimeerde zoekbalkstijlen tot 30 fps om GPU-gebruik op tragere hardware te verminderen. Visueel verschil is minimaal.', + animationMode: 'Animaties', + 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', seekbarPseudowave: 'Pseudo-golfvorm', seekbarLinedot: 'Lijn & punt', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 13f03449..d66afd11 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -964,8 +964,13 @@ export const ruTranslation = { sidebarLyricsStyleAppleDesc: 'Активная строка фиксируется сверху с плавной анимацией.', seekbarStyle: 'Стиль прогресс-бара', seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения', - reducedAnimations: 'Снизить анимации', - reducedAnimationsDesc: 'Ограничить анимированные стили прогресс-бара 30 кадрами в секунду для снижения нагрузки на GPU на слабом железе. Визуальная разница минимальна.', + animationMode: 'Анимации', + animationModeDesc: 'Обменивайте визуальную плавность на меньшую нагрузку на GPU/CPU на слабом железе.', + animationModeFull: 'Полные', + animationModeReduced: 'Сниженные', + animationModeStatic: 'Статичные', + animationModeReducedHint: 'Ограничение 30 fps для волны прогресс-бара; заголовок плеера прокручивается с половинной скоростью.', + animationModeStaticHint: 'Без анимации волны; заголовок плеера обрезается вместо прокрутки.', seekbarTruewave: 'Реальная форма волны', seekbarPseudowave: 'Псевдо форма волны', seekbarLinedot: 'Линия и точка', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 577602dd..1f0323f0 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -907,8 +907,13 @@ export const zhTranslation = { sidebarLyricsStyleAppleDesc: '活动行固定在顶部,带有平滑弹簧动画。', seekbarStyle: '进度条样式', seekbarStyleDesc: '选择播放进度条的外观', - reducedAnimations: '减少动画', - reducedAnimationsDesc: '将动画进度条样式限制为 30 fps,以降低较慢硬件上的 GPU 占用。视觉差异极小。', + animationMode: '动画', + animationModeDesc: '在较慢的硬件上以视觉流畅度换取更低的 GPU/CPU 占用。', + animationModeFull: '完整', + animationModeReduced: '减少', + animationModeStatic: '静态', + animationModeReducedHint: '进度条波形限制为 30 fps;播放器标题以半速滚动。', + animationModeStaticHint: '无波形动画;播放器标题被截断而不滚动。', seekbarTruewave: '真实波形', seekbarPseudowave: '伪波形', seekbarLinedot: '线条与点', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index fab3d17a..e254e587 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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() { /> ))}
-
-
-
{t('settings.reducedAnimations')}
-
{t('settings.reducedAnimationsDesc')}
+
+
+
+ {t('settings.animationMode')} +
+ + + +
+
+
+ {auth.animationMode === 'reduced' + ? t('settings.animationModeReducedHint') + : auth.animationMode === 'static' + ? t('settings.animationModeStaticHint') + : t('settings.animationModeDesc')} +
-
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index f178c564..6ec67b9e 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -25,6 +25,7 @@ export interface ServerProfile { export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape'; export type LoggingMode = 'off' | 'normal' | 'debug'; export type NormalizationEngine = 'off' | 'replaygain' | 'loudness'; +export type AnimationMode = 'full' | 'reduced' | 'static'; /** Integrated-loudness target presets (Settings + analysis). */ export type LoudnessLufsPreset = -16 | -14 | -12 | -10; @@ -176,8 +177,12 @@ interface AuthState { lastSeenChangelogVersion: string; seekbarStyle: SeekbarStyle; - /** Cap animated seekbar styles to 30 fps (and similar GPU-friendly tweaks) for low-end hardware. */ - reducedAnimations: boolean; + /** Animation budget across the UI: + * - `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 */ queueNowPlayingCollapsed: boolean; @@ -327,7 +332,7 @@ interface AuthState { setShowChangelogOnUpdate: (v: boolean) => void; setLastSeenChangelogVersion: (v: string) => void; setSeekbarStyle: (v: SeekbarStyle) => void; - setReducedAnimations: (v: boolean) => void; + setAnimationMode: (v: AnimationMode) => void; setQueueNowPlayingCollapsed: (v: boolean) => void; setEnableHiRes: (v: boolean) => void; setAudioOutputDevice: (v: string | null) => void; @@ -448,7 +453,7 @@ export const useAuthStore = create()( showChangelogOnUpdate: true, lastSeenChangelogVersion: '', seekbarStyle: 'truewave', - reducedAnimations: false, + animationMode: 'full', queueNowPlayingCollapsed: false, enableHiRes: false, audioOutputDevice: null, @@ -611,7 +616,7 @@ export const useAuthStore = create()( setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), setSeekbarStyle: (v) => set({ seekbarStyle: v }), - setReducedAnimations: (v) => set({ reducedAnimations: v }), + setAnimationMode: (v) => set({ animationMode: v }), setQueueNowPlayingCollapsed: (v: boolean) => set({ queueNowPlayingCollapsed: v }), setEnableHiRes: (v) => set({ enableHiRes: v }), setAudioOutputDevice: (v) => set({ audioOutputDevice: v }), @@ -813,6 +818,15 @@ export const useAuthStore = create()( ? {} : { 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(['full', 'reduced', 'static']); + if (!VALID_ANIM_MODES.has(state.animationMode as string)) { + animationModeMigrated = { animationMode: legacyReduced === true ? 'reduced' : 'full' }; + } + const st = state as { loudnessTargetLufs?: unknown; loudnessPreAnalysisAttenuationDb?: unknown; @@ -844,6 +858,7 @@ export const useAuthStore = create()( ...lyricsSourcesMigrated, ...wheelSmoothOneTime, ...seekbarStyleMigrated, + ...animationModeMigrated, }); }, } diff --git a/src/styles/layout.css b/src/styles/layout.css index 9754a1dc..68fbb3a6 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -1516,12 +1516,23 @@ html[data-platform="windows"] .player-bar.floating { width: 100%; } +/* `static` animation mode: never scroll, truncate with ellipsis instead. */ +.marquee-wrap[data-anim-mode="static"] { + text-overflow: ellipsis; +} + .marquee-scroll { display: inline-block; animation: player-marquee 12s linear infinite; 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 { 0%, 12% { transform: translateX(0); } 78%, 88% { transform: translateX(var(--marquee-amount)); }