diff --git a/static/highway.js b/static/highway.js index 58e5dbc..dad37f0 100644 --- a/static/highway.js +++ b/static/highway.js @@ -122,6 +122,19 @@ function createHighway() { // where offsetParent === null isn't enough. let _visibleOverride = null; let _lastVisible = null; + // Throttled DOM visibility sampling. Reading canvas.offsetParent + // every rAF frame forces a style/layout recalc — profiled at ~0.5 s + // main-thread self-time over a 63 s session. The displayed state + // changes rarely (navigate / splitscreen panel toggle), so the DOM + // is only re-sampled every _DOM_VIS_CHECK_FRAMES frames; the cached + // value serves the frames in between (worst-case transition latency + // ~10 frames ≈ 166 ms at 60 Hz — fine for a hide/show pause signal). + // Set _domVisSampledFrame to NaN to force a fresh sample on the next + // check (done on init, canvas replace, resize, and override-clear so + // deliberate transitions don't wait out the throttle window). + const _DOM_VIS_CHECK_FRAMES = 10; + let _domVisCached = false; + let _domVisSampledFrame = NaN; let animFrame = null; // Paused-render throttle (feedBack#654). The rAF loop runs // unconditionally and only gates on visibility + ready, never on @@ -1047,6 +1060,7 @@ function createHighway() { // suppress the first transition. Reset to null so the next // rAF tick re-emits unconditionally. _lastVisible = null; + _domVisSampledFrame = NaN; // fresh canvas → fresh DOM sample // Defensive notify for plugins / overlays that cache the // canvas element across events. Lazy lookups via // getElementById('highway') do not need this — they'll pick @@ -1246,7 +1260,14 @@ function createHighway() { // hosts that need those use setVisible() instead. function _isHighwayVisible() { if (_visibleOverride !== null) return _visibleOverride; - return !!(canvas && canvas.offsetParent !== null); + // Throttled offsetParent read — see _DOM_VIS_CHECK_FRAMES above. + if (Number.isNaN(_domVisSampledFrame) + || ((_frameIdx - _domVisSampledFrame) | 0) >= _DOM_VIS_CHECK_FRAMES + || ((_frameIdx - _domVisSampledFrame) | 0) < 0) { + _domVisCached = !!(canvas && canvas.offsetParent !== null); + _domVisSampledFrame = _frameIdx; + } + return _domVisCached; } // Emit only on transition so renderer-side listeners aren't woken @@ -2956,6 +2977,7 @@ function createHighway() { init(canvasEl, container) { canvas = canvasEl; _resizeContainer = container || null; + _domVisSampledFrame = NaN; // new mount → fresh DOM sample // Size the canvas BEFORE installing the renderer so // _setRenderer's init/resize calls see the real dimensions // instead of the default 300x150 backing store. Otherwise @@ -2997,6 +3019,9 @@ function createHighway() { resize() { if (!canvas) return; + // Layout just changed (window resize / container swap) — + // re-sample DOM visibility on the next check. + _domVisSampledFrame = NaN; let w, h; if (_resizeContainer) { const rect = _resizeContainer.getBoundingClientRect(); @@ -3771,6 +3796,10 @@ function createHighway() { // rAF tick. setVisible(v) { _visibleOverride = (v === null || v === undefined) ? null : !!v; + // Clearing the override resumes DOM-based detection — force a + // fresh offsetParent sample so the resulting transition (if + // any) emits now, not after the throttle window. + if (_visibleOverride === null) _domVisSampledFrame = NaN; _emitVisibilityIfChanged(); }, // Snapshot of the current visibility state (the override if diff --git a/static/v3/player-chrome.js b/static/v3/player-chrome.js index ce13c57..f2837ad 100644 --- a/static/v3/player-chrome.js +++ b/static/v3/player-chrome.js @@ -24,6 +24,18 @@ let rafId = null, running = false; let lastMove = 0, lastUpNext = 0; let openPop = null; // { btn, pop } + // Hover state over #player-controls, maintained by mouseenter/mouseleave + // (wired in start()). tickIdle previously called matches(':hover') every + // rAF frame, which forces a style recalc — profiled hot (feedBack perf). + let overControls = false; + const _onControlsEnter = () => { overControls = true; }; + const _onControlsLeave = () => { overControls = false; }; + // Up-Next pill: cached element refs (re-resolved when detached) and + // last-written values, so the 6 Hz recompute only touches the DOM when + // something actually changed — unconditional textContent/width writes + // re-triggered layout every tick. + let upnextEls = null; // { pill, nm, eta, fill } + let upnextLast = { name: null, eta: null, prog: -1, hidden: null }; // ── v3 UI signal + plugin-control slot API ─────────────────────────────── // Lets plugins detect v3 (window.feedBack.uiVersion === 'v3') and mount @@ -169,28 +181,52 @@ } // ── Up Next pill ───────────────────────────────────────────────────────── + function _upnextRefs() { + // Cache refs; re-resolve only when a node detached (screen re-mount). + if (!upnextEls || !upnextEls.pill || !upnextEls.pill.isConnected) { + const pill = $('v3-upnext'); + if (!pill) return null; + upnextEls = { + pill, + nm: $('v3-upnext-name'), + eta: $('v3-upnext-eta'), + fill: $('v3-upnext-bar-fill'), + }; + // Fresh nodes → forget last-written state so everything re-syncs. + upnextLast = { name: null, eta: null, prog: -1, hidden: null }; + } + return upnextEls; + } + function _upnextSetHidden(els, hidden) { + if (upnextLast.hidden === hidden) return; + upnextLast.hidden = hidden; + els.pill.classList.toggle('hidden', hidden); + } function updateUpNext() { - const pill = $('v3-upnext'); - if (!pill) return; + const els = _upnextRefs(); + if (!els) return; // Gated by the core "Show 'Up Next'" pref (Gameplay tab, default ON). - if (window.feedBack && window.feedBack.showUpNext === false) { pill.classList.add('hidden'); return; } + if (window.feedBack && window.feedBack.showUpNext === false) { _upnextSetHidden(els, true); return; } const hw = window.highway; const secs = (hw && typeof hw.getSections === 'function') ? hw.getSections() : null; const t = (hw && typeof hw.getTime === 'function') ? hw.getTime() : null; - if (!Array.isArray(secs) || !secs.length || t == null || isNaN(t)) { pill.classList.add('hidden'); return; } + if (!Array.isArray(secs) || !secs.length || t == null || isNaN(t)) { _upnextSetHidden(els, true); return; } let next = null; for (let i = 0; i < secs.length; i++) { if (typeof secs[i].time === 'number' && secs[i].time > t + 0.05) { next = secs[i]; break; } } - if (!next) { pill.classList.add('hidden'); return; } + if (!next) { _upnextSetHidden(els, true); return; } const dt = Math.max(0, next.time - t); - const nm = $('v3-upnext-name'), eta = $('v3-upnext-eta'); - if (nm) nm.textContent = next.name || '—'; - if (eta) eta.textContent = 'in ' + dt.toFixed(1) + 's'; + // Coarsened eta (whole seconds from 10 s out, 1 decimal inside) + + // write-on-change: drops textContent writes (each a layout pass) + // from ~6/s to ~1/s. + const name = next.name || '—'; + const etaText = 'in ' + (dt >= 10 ? Math.round(dt) + '' : dt.toFixed(1)) + 's'; + if (els.nm && name !== upnextLast.name) { upnextLast.name = name; els.nm.textContent = name; } + if (els.eta && etaText !== upnextLast.eta) { upnextLast.eta = etaText; els.eta.textContent = etaText; } // Progress bar: fraction of the current section elapsed toward `next`. // Previous boundary is the last section at/before now (else song start). - const fill = $('v3-upnext-bar-fill'); - if (fill) { + if (els.fill) { let prevT = 0; for (let i = 0; i < secs.length; i++) { if (typeof secs[i].time === 'number' && secs[i].time <= t) prevT = secs[i].time; @@ -198,9 +234,15 @@ } const span = next.time - prevT; const prog = span > 0 ? Math.max(0, Math.min(1, (t - prevT) / span)) : 0; - fill.style.width = (prog * 100).toFixed(1) + '%'; + const q = Math.round(prog * 1000) / 1000; + if (q !== upnextLast.prog) { + upnextLast.prog = q; + // scaleX is compositor-only — width writes re-ran layout. + // Pairs with transform-origin:left on #v3-upnext-bar-fill. + els.fill.style.transform = 'scaleX(' + q + ')'; + } } - pill.classList.remove('hidden'); + _upnextSetHidden(els, false); } // ── Speed visual (bars + chevrons reflect #speed-slider) ────────────────── @@ -230,8 +272,8 @@ if (!p) return; const playBtn = $('btn-play'); const playing = playBtn && playBtn.getAttribute('aria-pressed') === 'true'; - const controls = $('player-controls'); - const overControls = controls && typeof controls.matches === 'function' && controls.matches(':hover'); + // overControls maintained by mouseenter/mouseleave (see start()) — + // matches(':hover') here forced a per-frame style recalc. // Keep the transport up while paused, hovering it, or a popover is open. if (openPop || overControls || !playing) { lastMove = now(); return; } if (now() - lastMove > IDLE_MS) { @@ -263,6 +305,12 @@ wireRail(); p.addEventListener('mousemove', revealChrome); p.addEventListener('touchstart', revealChrome, { passive: true }); + const c = $('player-controls'); + if (c) { + overControls = typeof c.matches === 'function' && c.matches(':hover'); + c.addEventListener('mouseenter', _onControlsEnter); + c.addEventListener('mouseleave', _onControlsLeave); + } const s = $('speed-slider'); if (s && !s.dataset.pcVizWired) { s.dataset.pcVizWired = '1'; s.addEventListener('input', updateSpeedViz); } updateSpeedViz(); @@ -281,6 +329,12 @@ p.removeEventListener('touchstart', revealChrome); p.classList.remove('chrome-active', 'chrome-idle'); } + const c = $('player-controls'); + if (c) { + c.removeEventListener('mouseenter', _onControlsEnter); + c.removeEventListener('mouseleave', _onControlsLeave); + } + overControls = false; closePop(); } function syncActivation() { diff --git a/static/v3/v3.css b/static/v3/v3.css index 8cc2336..1e76a5c 100644 --- a/static/v3/v3.css +++ b/static/v3/v3.css @@ -367,10 +367,15 @@ input, textarea, select, } #player-hud .v3-upnext .v3-upnext-bar-fill { height: 100%; - width: 0%; + /* Fill is driven via transform: scaleX(0..1) from player-chrome.js — + compositor-only, unlike the previous width writes which re-ran + layout on every update tick. */ + width: 100%; + transform: scaleX(0); + transform-origin: left; border-radius: inherit; background: linear-gradient(90deg, #22d3ee, #a855f7, #f472b6); - transition: width .12s linear; + transition: transform .12s linear; } /* — Live performance HUD (top-right, read-only) — */