From 77edfaa8672ae44fa38b4f2e922657c6fee3c080 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sat, 18 Apr 2026 01:06:04 +0200 Subject: [PATCH] perf(fs-player): cut CPU under WebKitGTK software compositing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/FullscreenPlayer.tsx | 46 ++++++++++++----- src/styles/components.css | 78 +++++++++++++++++++++++++++-- src/utils/springScroll.ts | 9 ++++ 3 files changed, 115 insertions(+), 18 deletions(-) diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 1262e26d..5c794a06 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -434,20 +434,40 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) { const inputRef = useRef(null); useEffect(() => { - const s = usePlayerStore.getState(); - const pct = s.progress * 100; - if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime); - if (playedRef.current) playedRef.current.style.width = `${pct}%`; - if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`; - if (inputRef.current) inputRef.current.value = String(s.progress); + // Cache the last applied values so we can skip DOM writes when nothing + // changed. The store subscription fires on every state change (queue, + // volume, currentTrack…), not just progress, so without this guard we + // were writing identical width/textContent strings dozens of times per + // second — each one triggers a style invalidation in WebKitGTK. + let lastTime = -1; + let lastPct = -1; + let lastBufW = -1; + let lastProg = -1; - return usePlayerStore.subscribe(state => { - const p = state.progress * 100; - if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime); - if (playedRef.current) playedRef.current.style.width = `${p}%`; - if (bufRef.current) bufRef.current.style.width = `${Math.max(p, state.buffered * 100)}%`; - if (inputRef.current) inputRef.current.value = String(state.progress); - }); + const apply = (s: ReturnType) => { + const pct = s.progress * 100; + const bufW = Math.max(pct, s.buffered * 100); + const sec = s.currentTime | 0; + if (sec !== lastTime) { + 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( diff --git a/src/styles/components.css b/src/styles/components.css index d32947ac..8d467130 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -3856,6 +3856,22 @@ html.no-compositing .fsr-lyrics-overlay { -webkit-mask-image: none; mask-image: none; } 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 ───────────────────────────────────────────────── Applied only when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set (Arch Linux etc). 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. - 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 { -webkit-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 { content: ''; @@ -3901,12 +3926,16 @@ html.no-compositing .fs-portrait-wrap::before { /* Lyrics fade overlays: gradient-only, no backdrop-filter — fine in no-compositing */ -/* Mesh blobs: stop animations — in software mode each frame composites surfaces - larger than the screen (blob-a is 130 × 130 % of the viewport); on high-DPI - displays this saturates the CPU and drops the player to ~10 FPS. - Static gradients are preserved; only the slow pan animation is removed. */ +/* Mesh blobs: stop animations + flatten to a solid dark fill. + Even static, the 130 × 130 % radial-gradient with color-mix() is recomputed + on every paint, and the `transition: background 400ms` on accent change + 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 { animation: none; + background: #06060e; + transition: none; } /* 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; } +/* Track title: drop the 40 px text-shadow glow — paints a giant blur on a + clamp(28–68 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-popup { position: absolute; diff --git a/src/utils/springScroll.ts b/src/utils/springScroll.ts index ce18f221..a2981067 100644 --- a/src/utils/springScroll.ts +++ b/src/utils/springScroll.ts @@ -40,6 +40,15 @@ export class SpringScroller { scrollTo(targetY: number) { 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(); }