mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 03:41:40 +00:00
Merge pull request #694 from got-feedback/perf/highway-frame-hotspots
perf: eliminate per-frame layout thrash, shader-compile spikes, and per-frame allocations in the player
This commit is contained in:
+12
-1
@@ -9777,6 +9777,12 @@ async function startSongCountIn() {
|
||||
|
||||
// Time display + highway sync
|
||||
let lastAudioTime = 0;
|
||||
// hud-time write cache: the 60 Hz tick below used to rewrite textContent
|
||||
// (and getElementById) every tick even though the mm:ss display only
|
||||
// changes once a second — each write invalidates layout. Write-on-change
|
||||
// with a cached element ref (re-resolved if detached).
|
||||
let _hudTimeEl = null;
|
||||
let _hudTimeLast = '';
|
||||
setInterval(() => {
|
||||
let ct = _audioTime();
|
||||
const dur = _audioDuration();
|
||||
@@ -9804,7 +9810,12 @@ setInterval(() => {
|
||||
ct = lastAudioTime;
|
||||
}
|
||||
lastAudioTime = ct;
|
||||
document.getElementById('hud-time').textContent = `${formatTime(ct)} / ${formatTime(dur)}`;
|
||||
const hudText = `${formatTime(ct)} / ${formatTime(dur)}`;
|
||||
if (hudText !== _hudTimeLast) {
|
||||
if (!_hudTimeEl || !_hudTimeEl.isConnected) _hudTimeEl = document.getElementById('hud-time');
|
||||
if (_hudTimeEl) _hudTimeEl.textContent = hudText;
|
||||
_hudTimeLast = hudText;
|
||||
}
|
||||
if (dur) {
|
||||
_maybeRefreshSectionPracticeDuration(dur);
|
||||
}
|
||||
|
||||
+169
-92
@@ -122,6 +122,24 @@ 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).
|
||||
// NOTE those manual resets are LATENCY optimizations, not correctness
|
||||
// requirements: the periodic re-sample runs every _DOM_VIS_CHECK_FRAMES
|
||||
// frames regardless, so a visibility-affecting path that forgets to
|
||||
// reset self-heals within ~10 frames — stale visibility can never be
|
||||
// served indefinitely.
|
||||
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
|
||||
@@ -733,103 +751,121 @@ function createHighway() {
|
||||
// on the freshly-mounted canvas.
|
||||
let _currentCanvasContextType = '2d';
|
||||
|
||||
// One persistent bundle object per createHighway() instance —
|
||||
// _makeBundle() mutates its fields in place each call instead of
|
||||
// allocating a fresh ~35-field object per rAF frame (steady GC churn
|
||||
// on weak hardware, ×N under splitscreen). Consequences for
|
||||
// consumers: the bundle OBJECT's identity is stable across frames
|
||||
// and carries no meaning; its field values are only valid for the
|
||||
// duration of the current draw call. Array fields (`notes`,
|
||||
// `chords`, `handShapes`, `chordTemplates`, ...) still swap
|
||||
// reference whenever chart data changes — field-identity caches
|
||||
// (e.g. highway_3d's merge caches) rely on that invariant.
|
||||
const _bundleReused = {};
|
||||
|
||||
function _makeBundle() {
|
||||
// Snapshot of current factory state passed to each renderer call.
|
||||
// Arrays and songInfo are LIVE references, not copies — the bundle
|
||||
// itself is rebuilt each frame but its `notes`, `chords`,
|
||||
// `anchors`, `beats`, etc. point at closure state. Renderers
|
||||
// MUST NOT mutate these; treat them as read-only. We don't
|
||||
// Object.freeze or deep-copy for per-frame allocation cost reasons.
|
||||
return {
|
||||
// Timing
|
||||
currentTime,
|
||||
songInfo,
|
||||
isReady: ready,
|
||||
// True while the chart clock is actively advancing; false when
|
||||
// audio is paused / stalled / mid-seek (setTime has kept getting
|
||||
// the same t for > _CHART_MAX_INTERP_MS). This is the same
|
||||
// predicate getTime() uses to decide raw-vs-interpolated, and the
|
||||
// no-anchor boot state reads as not-playing — matching getTime()
|
||||
// returning raw chartTime there. Renderers that run their own
|
||||
// sub-frame clock (highway_3d's smoothNow) gate on this to fall
|
||||
// back to raw instead of extrapolating forward against a frozen
|
||||
// audio sample. Undefined on downlevel hosts → those renderers
|
||||
// keep their own staleness-based fallback.
|
||||
isPlaying: !Number.isNaN(_chartAnchorPerfNow)
|
||||
&& (performance.now() - _chartLastAdvanceAt) <= _CHART_MAX_INTERP_MS,
|
||||
// Arrays and songInfo are LIVE references, not copies — the
|
||||
// bundle's `notes`, `chords`, `anchors`, `beats`, etc. point at
|
||||
// closure state. Renderers MUST NOT mutate these; treat them as
|
||||
// read-only. We don't Object.freeze or deep-copy for per-frame
|
||||
// cost reasons.
|
||||
const b = _bundleReused;
|
||||
// Timing
|
||||
b.currentTime = currentTime;
|
||||
b.songInfo = songInfo;
|
||||
b.isReady = ready;
|
||||
// True while the chart clock is actively advancing; false when
|
||||
// audio is paused / stalled / mid-seek (setTime has kept getting
|
||||
// the same t for > _CHART_MAX_INTERP_MS). This is the same
|
||||
// predicate getTime() uses to decide raw-vs-interpolated, and the
|
||||
// no-anchor boot state reads as not-playing — matching getTime()
|
||||
// returning raw chartTime there. Renderers that run their own
|
||||
// sub-frame clock (highway_3d's smoothNow) gate on this to fall
|
||||
// back to raw instead of extrapolating forward against a frozen
|
||||
// audio sample. Undefined on downlevel hosts → those renderers
|
||||
// keep their own staleness-based fallback.
|
||||
b.isPlaying = !Number.isNaN(_chartAnchorPerfNow)
|
||||
&& (performance.now() - _chartLastAdvanceAt) <= _CHART_MAX_INTERP_MS;
|
||||
|
||||
// Chart content (filter-aware — difficulty-filtered arrays
|
||||
// preferred; raw arrays are the fallback when no ladder data).
|
||||
notes: _filteredNotes !== null ? _filteredNotes : notes,
|
||||
chords: _filteredChords !== null ? _filteredChords : chords,
|
||||
anchors: _filteredAnchors !== null ? _filteredAnchors : anchors,
|
||||
beats,
|
||||
sections,
|
||||
chordTemplates,
|
||||
stringCount,
|
||||
// Mirrors song_info tuning capo offsets (±semitones from the
|
||||
// instrument’s standard open-string layout). Live reference.
|
||||
tuning: songInfo?.tuning,
|
||||
capo: songInfo?.capo,
|
||||
lyrics,
|
||||
lyricsSource,
|
||||
toneChanges,
|
||||
toneBase,
|
||||
// Drum tab payload (or null when the active arrangement has
|
||||
// no drum_tab). Live reference — renderers MUST treat as
|
||||
// read-only. Plugins should prefer this over decoding the
|
||||
// standard `notes` stream when present; absence is the
|
||||
// signal to fall back to legacy MIDI-encoded drums.
|
||||
drumTab,
|
||||
// Chart content (filter-aware — difficulty-filtered arrays
|
||||
// preferred; raw arrays are the fallback when no ladder data).
|
||||
b.notes = _filteredNotes !== null ? _filteredNotes : notes;
|
||||
b.chords = _filteredChords !== null ? _filteredChords : chords;
|
||||
b.anchors = _filteredAnchors !== null ? _filteredAnchors : anchors;
|
||||
b.beats = beats;
|
||||
b.sections = sections;
|
||||
b.chordTemplates = chordTemplates;
|
||||
b.stringCount = stringCount;
|
||||
// Mirrors song_info tuning capo offsets (±semitones from the
|
||||
// instrument’s standard open-string layout). Live reference.
|
||||
b.tuning = songInfo?.tuning;
|
||||
b.capo = songInfo?.capo;
|
||||
b.lyrics = lyrics;
|
||||
b.lyricsSource = lyricsSource;
|
||||
b.toneChanges = toneChanges;
|
||||
b.toneBase = toneBase;
|
||||
// Drum tab payload (or null when the active arrangement has
|
||||
// no drum_tab). Live reference — renderers MUST treat as
|
||||
// read-only. Plugins should prefer this over decoding the
|
||||
// standard `notes` stream when present; absence is the
|
||||
// signal to fall back to legacy MIDI-encoded drums.
|
||||
b.drumTab = drumTab;
|
||||
|
||||
// Master-difficulty (feedBack#48)
|
||||
mastery: _mastery,
|
||||
hasPhraseData: !!(_phrases && _phrases.length > 0),
|
||||
// When phrase data authored ANY handshape, respect the filtered
|
||||
// list strictly (even when this difficulty leaves it empty) —
|
||||
// otherwise low-mastery levels would surface arp hints that
|
||||
// don't belong. Only fall back to the flat list when the
|
||||
// phrase data carries no handshapes at all (common on DLC
|
||||
// where handshapes ship on the arrangement root).
|
||||
handShapes: (_filteredHandShapes !== null && _phrasesHaveHandShapes)
|
||||
? _filteredHandShapes
|
||||
: handShapes,
|
||||
// Master-difficulty (feedBack#48)
|
||||
b.mastery = _mastery;
|
||||
b.hasPhraseData = !!(_phrases && _phrases.length > 0);
|
||||
// When phrase data authored ANY handshape, respect the filtered
|
||||
// list strictly (even when this difficulty leaves it empty) —
|
||||
// otherwise low-mastery levels would surface arp hints that
|
||||
// don't belong. Only fall back to the flat list when the
|
||||
// phrase data carries no handshapes at all (common on DLC
|
||||
// where handshapes ship on the arrangement root).
|
||||
b.handShapes = (_filteredHandShapes !== null && _phrasesHaveHandShapes)
|
||||
? _filteredHandShapes
|
||||
: handShapes;
|
||||
|
||||
// Display flags
|
||||
inverted: _inverted,
|
||||
lefty: _lefty,
|
||||
renderScale: _effectiveRenderScale(),
|
||||
lyricsVisible: showLyrics,
|
||||
// Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers
|
||||
// (e.g. the 3D highway) can mirror the 2D opt-in toggle. The fg
|
||||
// finger-hint pref rides alongside (default on, independently hideable).
|
||||
teachingMarksVisible: _showTeachingMarks,
|
||||
fingerHintsVisible: _showFingerHints,
|
||||
// Display flags
|
||||
b.inverted = _inverted;
|
||||
b.lefty = _lefty;
|
||||
b.renderScale = _effectiveRenderScale();
|
||||
b.lyricsVisible = showLyrics;
|
||||
// Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers
|
||||
// (e.g. the 3D highway) can mirror the 2D opt-in toggle. The fg
|
||||
// finger-hint pref rides alongside (default on, independently hideable).
|
||||
b.teachingMarksVisible = _showTeachingMarks;
|
||||
b.fingerHintsVisible = _showFingerHints;
|
||||
|
||||
// 2D-style helpers (renderers that don't need these can ignore).
|
||||
// `fillTextUnmirrored` is deliberately NOT exposed here —
|
||||
// the factory-level version writes to the default renderer's
|
||||
// closure ctx, which is null for custom renderers. Renderers
|
||||
// that need lefty-aware text should check `bundle.lefty` and
|
||||
// apply the mirror transform themselves on their own context.
|
||||
project,
|
||||
fretX,
|
||||
// 2D-style helpers (renderers that don't need these can ignore).
|
||||
// `fillTextUnmirrored` is deliberately NOT exposed here —
|
||||
// the factory-level version writes to the default renderer's
|
||||
// closure ctx, which is null for custom renderers. Renderers
|
||||
// that need lefty-aware text should check `bundle.lefty` and
|
||||
// apply the mirror transform themselves on their own context.
|
||||
b.project = project;
|
||||
b.fretX = fretX;
|
||||
// Windowed-iteration helpers (stable references): lower-bound
|
||||
// binary searches so custom viz don't full-scan chart arrays per
|
||||
// frame. lowerBoundT keys on `.t` (notes / chords); lowerBoundTime
|
||||
// keys on `.time` (beats / anchors / sections).
|
||||
b.lowerBoundT = bsearch;
|
||||
b.lowerBoundTime = bsearchTime;
|
||||
|
||||
// Per-note judgment overlay (feedBack#254). Renderers call
|
||||
// this per visible note / chord-note to find out whether a
|
||||
// scorer (note_detect) has flagged it hit / actively-held /
|
||||
// missed, so the gem itself can light up instead of relying
|
||||
// on an overlay ring. Returns null when no provider is set
|
||||
// or it reports nothing for this note; otherwise
|
||||
// { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
|
||||
getNoteState: _noteState, // stable reference — no per-frame allocation
|
||||
// Lets custom renderers (e.g. highway_3d) tell "is a provider
|
||||
// attached" apart from "no provider, getNoteState always
|
||||
// returns null" — `getNoteState` always exists on the bundle
|
||||
// so its presence alone isn't a useful "detect mode" signal.
|
||||
// Renderers gate verdict-window cull / draw extensions on this.
|
||||
getNoteStateProvider: _getNoteStateProvider, // stable — see above
|
||||
};
|
||||
// Per-note judgment overlay (feedBack#254). Renderers call
|
||||
// this per visible note / chord-note to find out whether a
|
||||
// scorer (note_detect) has flagged it hit / actively-held /
|
||||
// missed, so the gem itself can light up instead of relying
|
||||
// on an overlay ring. Returns null when no provider is set
|
||||
// or it reports nothing for this note; otherwise
|
||||
// { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
|
||||
b.getNoteState = _noteState; // stable reference
|
||||
// Lets custom renderers (e.g. highway_3d) tell "is a provider
|
||||
// attached" apart from "no provider, getNoteState always
|
||||
// returns null" — `getNoteState` always exists on the bundle
|
||||
// so its presence alone isn't a useful "detect mode" signal.
|
||||
// Renderers gate verdict-window cull / draw extensions on this.
|
||||
b.getNoteStateProvider = _getNoteStateProvider; // stable — see above
|
||||
return b;
|
||||
}
|
||||
|
||||
const _defaultRenderer = {
|
||||
@@ -1047,6 +1083,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 +1283,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
|
||||
@@ -1513,7 +1557,13 @@ function createHighway() {
|
||||
}
|
||||
|
||||
function drawBeats(W, H) {
|
||||
for (const beat of beats) {
|
||||
// Window the beat scan — a long song carries thousands of beats
|
||||
// and iterating (and projecting) all of them per frame was pure
|
||||
// waste; project() culling stays as the safety net.
|
||||
const lo = bsearchTime(beats, currentTime - 0.25);
|
||||
const hi = bsearchTime(beats, currentTime + VISIBLE_SECONDS + 0.25);
|
||||
for (let i = lo; i < hi; i++) {
|
||||
const beat = beats[i];
|
||||
const tOff = beat.time - currentTime;
|
||||
const p = project(tOff);
|
||||
if (!p || p.scale < 0.06) continue;
|
||||
@@ -2624,6 +2674,19 @@ function createHighway() {
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
// Lower-bound binary search for `.time`-keyed arrays (beats, anchors,
|
||||
// sections) — bsearch/bsearchChords key on `.t` and would compare
|
||||
// against undefined here. Exposed to custom viz as
|
||||
// bundle.lowerBoundTime.
|
||||
function bsearchTime(arr, time) {
|
||||
let lo = 0, hi = arr.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (arr[mid].time < time) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
// ── Chord rendering — chains, frames, fretline preview (feedBack#88) ──
|
||||
//
|
||||
@@ -2956,6 +3019,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 +3061,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 +3838,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
|
||||
@@ -3779,6 +3850,12 @@ function createHighway() {
|
||||
// can call this once to sync their initial state — the event
|
||||
// is transition-only and won't re-fire for late subscribers.
|
||||
isVisible() {
|
||||
// Force a fresh DOM sample — this is a documented "live DOM
|
||||
// check" for late subscribers seeding initial state, so it
|
||||
// must not serve the rAF loop's throttled cache (up to
|
||||
// ~166 ms stale). Called rarely; the layout-read cost that
|
||||
// motivated the throttle only matters per-frame.
|
||||
_domVisSampledFrame = NaN;
|
||||
return _isHighwayVisible();
|
||||
},
|
||||
getNotes() { return notes; },
|
||||
|
||||
+78
-14
@@ -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) {
|
||||
@@ -249,6 +291,16 @@
|
||||
// Re-sync the lyrics icon so programmatic highway.setLyricsVisible()
|
||||
// (e.g. from lyrics_karaoke) isn't left stale; cheap + idempotent.
|
||||
syncLyricsIcon();
|
||||
// Reconcile the edge-driven hover flag against ground truth at
|
||||
// this throttled cadence (~6 Hz, not per frame). Covers both
|
||||
// failure modes of pure mouseenter/mouseleave tracking: a
|
||||
// missed mouseleave (controls hidden/detached under the
|
||||
// pointer → flag stuck true, transport never auto-hides) and
|
||||
// a re-created #player-controls node whose listeners were
|
||||
// lost (flag stuck false-ish / dead). matches(':hover') on a
|
||||
// detached node is simply false, so this also self-clears.
|
||||
const c = $('player-controls');
|
||||
overControls = !!(c && typeof c.matches === 'function' && c.matches(':hover'));
|
||||
}
|
||||
tickIdle();
|
||||
rafId = requestAnimationFrame(loop);
|
||||
@@ -263,6 +315,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 +339,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() {
|
||||
|
||||
+7
-2
@@ -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) — */
|
||||
|
||||
Reference in New Issue
Block a user