perf(fs-player): cut CPU under WebKitGTK software compositing

WebKitGTK on Linux runs with WEBKIT_DISABLE_COMPOSITING_MODE=1, so every
GPU-only effect is software-rasterised per frame. The fullscreen player
piled up large blur shadows, per-line scale transforms, oversized
radial-gradients with color-mix() and a 60 fps rAF spring on top — CPU
saturated and FPS dropped to ~10.

Linux-only via existing html.no-compositing class:
- Apple-style lyrics: drop transform: scale and 48 px / 32 px text-shadow
- Rail-style lyrics: drop 28 px text-shadow + scaleX
- Track title: 40 px glow → 6 px drop-shadow
- Album art wrap + play button: drop accent-coloured outer glows
- Mesh blobs: flatten 130 % radial-gradient + color-mix to solid #06060e
  and remove the 400 ms background transition
- Portrait wrap: drop translateZ(0) GPU-layer hint
- FS art: instant cover swap (no opacity crossfade)
- SpringScroller: replace rAF spring with native scrollTo({behavior:'smooth'})
- FsSeekbar: cache last applied values, skip identical DOM writes

Dynamic accent on title / seekbar / play button is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-18 01:06:04 +02:00
parent bac0417e00
commit 77edfaa867
3 changed files with 115 additions and 18 deletions
+33 -13
View File
@@ -434,20 +434,40 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
const s = usePlayerStore.getState(); // Cache the last applied values so we can skip DOM writes when nothing
const pct = s.progress * 100; // changed. The store subscription fires on every state change (queue,
if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime); // volume, currentTrack…), not just progress, so without this guard we
if (playedRef.current) playedRef.current.style.width = `${pct}%`; // were writing identical width/textContent strings dozens of times per
if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`; // second — each one triggers a style invalidation in WebKitGTK.
if (inputRef.current) inputRef.current.value = String(s.progress); let lastTime = -1;
let lastPct = -1;
let lastBufW = -1;
let lastProg = -1;
return usePlayerStore.subscribe(state => { const apply = (s: ReturnType<typeof usePlayerStore.getState>) => {
const p = state.progress * 100; const pct = s.progress * 100;
if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime); const bufW = Math.max(pct, s.buffered * 100);
if (playedRef.current) playedRef.current.style.width = `${p}%`; const sec = s.currentTime | 0;
if (bufRef.current) bufRef.current.style.width = `${Math.max(p, state.buffered * 100)}%`; if (sec !== lastTime) {
if (inputRef.current) inputRef.current.value = String(state.progress); lastTime = sec;
}); if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime);
}
if (pct !== lastPct) {
lastPct = pct;
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
}
if (bufW !== lastBufW) {
lastBufW = bufW;
if (bufRef.current) bufRef.current.style.width = `${bufW}%`;
}
if (s.progress !== lastProg) {
lastProg = s.progress;
if (inputRef.current) inputRef.current.value = String(s.progress);
}
};
apply(usePlayerStore.getState());
return usePlayerStore.subscribe(apply);
}, []); }, []);
const handleSeek = useCallback( const handleSeek = useCallback(
+73 -5
View File
@@ -3856,6 +3856,22 @@
html.no-compositing .fsr-lyrics-overlay { -webkit-mask-image: none; mask-image: none; } html.no-compositing .fsr-lyrics-overlay { -webkit-mask-image: none; mask-image: none; }
html.no-compositing .fsr-lyrics-rail { will-change: auto; } html.no-compositing .fsr-lyrics-rail { will-change: auto; }
/* Apple-style FS lyrics — strip GPU-only effects.
Without compositing, transform: scale() on every line forces a per-line
software repaint on each active-line change, and the 48 px / 32 px blur
text-shadows repaint on every word tick. Replace with opacity + colour,
which are cheap text-only repaints. */
html.no-compositing .fsa-lyric-line {
transform: none;
transition: color 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
html.no-compositing .fsa-lyric-line.fsal-past { transform: none; }
html.no-compositing .fsa-lyric-line.fsal-active { transform: none; text-shadow: none; }
html.no-compositing .fsa-lyric-line.fsal-active .fsa-lyric-word.active {
text-shadow: none;
}
/* ── no-compositing overrides ───────────────────────────────────────────────── /* ── no-compositing overrides ─────────────────────────────────────────────────
Applied only when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set (Arch Linux etc). Applied only when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set (Arch Linux etc).
Replaces GPU-only effects (backdrop-filter, CSS filter, mask-image) with Replaces GPU-only effects (backdrop-filter, CSS filter, mask-image) with
@@ -3880,10 +3896,19 @@ html.no-compositing .fs-portrait {
} }
/* Portrait wrap: replace mask-image fade with a pseudo-element overlay gradient. /* Portrait wrap: replace mask-image fade with a pseudo-element overlay gradient.
pointer-events: none on wrap means the overlay never blocks clicks. */ pointer-events: none on wrap means the overlay never blocks clicks.
Also drop translateZ(0) — useless GPU layer hint without a compositor. */
html.no-compositing .fs-portrait-wrap { html.no-compositing .fs-portrait-wrap {
-webkit-mask-image: none; -webkit-mask-image: none;
mask-image: none; mask-image: none;
transform: none;
}
/* FS art crossfade: opacity transition compositing is GPU-cheap, software-expensive.
Snap instantly between covers; visually a hard cut, but the previous layer is
replaced before the user can really notice. */
html.no-compositing .fs-art {
transition: none;
} }
html.no-compositing .fs-portrait-wrap::before { html.no-compositing .fs-portrait-wrap::before {
content: ''; content: '';
@@ -3901,12 +3926,16 @@ html.no-compositing .fs-portrait-wrap::before {
/* Lyrics fade overlays: gradient-only, no backdrop-filter — fine in no-compositing */ /* Lyrics fade overlays: gradient-only, no backdrop-filter — fine in no-compositing */
/* Mesh blobs: stop animations — in software mode each frame composites surfaces /* Mesh blobs: stop animations + flatten to a solid dark fill.
larger than the screen (blob-a is 130 × 130 % of the viewport); on high-DPI Even static, the 130 × 130 % radial-gradient with color-mix() is recomputed
displays this saturates the CPU and drops the player to ~10 FPS. on every paint, and the `transition: background 400ms` on accent change
Static gradients are preserved; only the slow pan animation is removed. */ re-rasterises the giant surface for nearly half a second per track switch.
Atmosphere is sacrificed; the dynamic accent is preserved on title /
seekbar / controls via their own var(--dynamic-fs-accent) bindings. */
html.no-compositing .fs-mesh-blob { html.no-compositing .fs-mesh-blob {
animation: none; animation: none;
background: #06060e;
transition: none;
} }
/* Seekbar played bar: remove box-shadow — its width changes on every playback /* Seekbar played bar: remove box-shadow — its width changes on every playback
@@ -3915,6 +3944,45 @@ html.no-compositing .fs-seekbar-played {
box-shadow: none; box-shadow: none;
} }
/* Track title: drop the 40 px text-shadow glow — paints a giant blur on a
clamp(2868 px) font and re-rasterises whenever anything around it
redraws (cluster updates, seekbar tick, dynamic accent change). */
html.no-compositing .fs-track-title {
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.65);
transition: color 200ms ease-in-out;
}
/* Album art wrap: replace the accent-coloured outer glow with a flat
shadow. The 28 px blur was repainting every accent change. */
html.no-compositing .fs-art-wrap {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.07);
transition: none;
}
/* Play button: drop accent glow + hover filter. Keep flat dark shadow only. */
html.no-compositing .fs-btn-play {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.55);
}
html.no-compositing .fs-btn-play:hover {
filter: none;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.65);
}
/* Rail-style FS lyrics: same problems as Apple style — 28 px text-shadow
blur on every active line + every active word, plus per-line transform
scaleX. Strip them; rail still scrolls fine via the parent translateY. */
html.no-compositing .fsr-lyric-line {
transition: color 280ms ease;
transform: none;
}
html.no-compositing .fsr-lyric-line.fsrl-active {
text-shadow: none;
transform: none;
}
html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
text-shadow: none;
}
/* Chat */ /* Chat */
.chat-popup { .chat-popup {
position: absolute; position: absolute;
+9
View File
@@ -40,6 +40,15 @@ export class SpringScroller {
scrollTo(targetY: number) { scrollTo(targetY: number) {
this.target = Math.max(0, targetY); this.target = Math.max(0, targetY);
// Software-rendered Linux: replace our 60 fps rAF spring with the
// browser's native smooth scroll. The browser handles easing internally
// (one animation, not 60 JS callbacks per second) and we still get a
// smooth visual instead of a hard snap.
if (document.documentElement.classList.contains('no-compositing')) {
this.stop();
this.container.scrollTo({ top: this.target, behavior: 'smooth' });
return;
}
if (this.rafId === null) this.tick(); if (this.rafId === null) this.tick();
} }