diff --git a/plugin.json b/plugin.json index eb90611..c63e7ec 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "id": "section_map", "name": "Section Map", - "version": "1.2.3", + "version": "1.2.4", "private": false, "script": "screen.js", "category": "practice", diff --git a/screen.js b/screen.js index f36f995..de00f21 100644 --- a/screen.js +++ b/screen.js @@ -14,6 +14,16 @@ let _smDifficultyUnsubscribe = null; let _smSongReadySubscribed = false; let _smDifficultySubscribed = false; +// Cached DOM refs for the 5Hz poller, populated by _smRender() (which runs +// only when the section list itself changes) and invalidated by _smRemove(). +// Lets _smUpdate() avoid a fresh querySelectorAll('.sm-block')/getElementById +// pass on every single tick. +let _smMarkerEl = null; +let _smBlockEls = []; +// Index of the block last marked "active" (opacity 1), or -1 when unknown +// (forces the next tick to (re)apply opacity instead of assuming default). +let _smActiveIdx = -1; + // Most-specific-first: 'pre' must come before 'chorus'/'verse' so a // "pre-chorus"/"pre-verse" section name (which contains both substrings) // matches its own color instead of falling through to the base section's. @@ -89,7 +99,18 @@ function _smStartRealtimeHooks() { const { sectionDifficulties } = event.detail || {}; if (sectionDifficulties) { _smSectionDifficulty = sectionDifficulties; - _smRender(); + // Difficulty refreshes change only the per-section glass fill, + // not colors/labels/positions — update those in place rather + // than paying for a full _smRender() (which tears down and + // rebuilds every block's DOM) on every event, which can fire + // repeatedly over the course of a song. Falls back to a full + // render if the cached blocks don't match the current + // section list (bar not built for these sections yet). + if (_smBar && _smSections.length > 0 && _smBlockEls.length === _smSections.length) { + _smUpdateDifficultyFills(); + } else { + _smRender(); + } } }); _smDifficultySubscribed = typeof _smDifficultyUnsubscribe === 'function'; @@ -148,6 +169,9 @@ function _smRemove() { _smBar.remove(); _smBar = null; } + _smMarkerEl = null; + _smBlockEls = []; + _smActiveIdx = -1; } // The playback clock, across whichever backend is driving audio. getTime() is @@ -230,23 +254,33 @@ function _smUpdate() { _smRender(); } - // Update playback position indicator - const marker = document.getElementById('sm-marker'); - if (marker && _smDuration > 0) { + // Update playback position indicator. _smMarkerEl is cached by _smRender() + // so this is a style write, not a fresh getElementById lookup every 200ms. + if (_smMarkerEl && _smDuration > 0) { const pct = (t / _smDuration) * 100; - marker.style.left = pct + '%'; + _smMarkerEl.style.left = pct + '%'; } - // Highlight active section - const blocks = _smBar.querySelectorAll('.sm-block'); + // Highlight active section. _smBlockEls is cached by _smRender() (rebuilt + // only when the section list changes), and blocks start at opacity 0.5 + // (baked into the render template), so a tick only needs to touch the + // two blocks whose highlight state actually flipped instead of doing a + // querySelectorAll + full forEach opacity rewrite every 200ms regardless + // of whether the active section changed since the last tick. let activeIdx = 0; for (let i = 0; i < _smSections.length; i++) { if (_smSections[i].time <= t) activeIdx = i; else break; } - blocks.forEach((block, i) => { - block.style.opacity = i === activeIdx ? '1' : '0.5'; - }); + if (activeIdx !== _smActiveIdx) { + if (_smActiveIdx >= 0 && _smBlockEls[_smActiveIdx]) { + _smBlockEls[_smActiveIdx].style.opacity = '0.5'; + } + if (_smBlockEls[activeIdx]) { + _smBlockEls[activeIdx].style.opacity = '1'; + } + _smActiveIdx = activeIdx; + } } function _smRender() { @@ -278,7 +312,11 @@ function _smRender() { const safeLabel = _smEscapeHtml(label); const safeTitle = _smEscapeHtml(`${titleLabel} (${_smFmt(sec.time)})`); - html += `
${difficultyContent} ${safeLabel} @@ -290,6 +328,12 @@ function _smRender() { _smBar.innerHTML = html; _smBar.style.position = 'relative'; + + // Re-cache the DOM refs _smUpdate()'s per-tick highlight/marker pass + // reads, since the innerHTML rebuild just invalidated the old ones. + _smMarkerEl = _smBar.querySelector ? _smBar.querySelector('#sm-marker') : null; + _smBlockEls = _smBar.querySelectorAll ? Array.from(_smBar.querySelectorAll('.sm-block')) : []; + _smActiveIdx = -1; } // Render glass-filling visualization for section difficulty @@ -308,12 +352,52 @@ function _smRenderGlassFilling(difficulty) { const glassStyle = sizeStyles[glassSize] || sizeStyles.medium; - return `
-
+
`; } +// Refresh only the per-section difficulty "glass fill" indicators in place +// (fill height + title) instead of rebuilding the whole section-map bar. +// Called from the 'difficulty:sections-updated' event, which can fire +// repeatedly while a song plays as dynamic-difficulty tracks accuracy — none +// of that data changes section colors/labels/positions, so a full _smRender() +// on every event would tear down and rebuild every block's DOM just to +// repaint a handful of small fill bars. +function _smUpdateDifficultyFills() { + if (!_smBar || !_smBlockEls.length) return; + for (let i = 0; i < _smBlockEls.length; i++) { + const block = _smBlockEls[i]; + if (!block || typeof block.querySelector !== 'function') continue; + const difficulty = _smGetSectionDifficulty(i); + const glass = block.querySelector('.sm-glass'); + if (_smDynamicDifficultyAvailable && difficulty && typeof difficulty.fillPercentage === 'number') { + const fillPct = Math.max(0, Math.min(100, difficulty.fillPercentage)); + const glassSize = difficulty.glassSize || 'medium'; + const sizeChanged = glass && glass.getAttribute && glass.getAttribute('data-size') !== glassSize; + if (glass && !sizeChanged) { + // Same glass already present (the common case while a song + // plays) — just update its fill height and title. + glass.title = `Difficulty: ${fillPct.toFixed(0)}%`; + const fillEl = glass.querySelector('.sm-glass-fill'); + if (fillEl) fillEl.style.height = fillPct + '%'; + } else { + // No glass yet, or its size bucket changed — (re)build just + // this one glass element rather than the whole bar. + if (glass) glass.remove(); + block.insertAdjacentHTML('afterbegin', _smRenderGlassFilling(difficulty)); + } + } else if (glass) { + glass.remove(); + } + } +} + function _smFmt(s) { return Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0'); } @@ -334,8 +418,12 @@ if (typeof module !== 'undefined' && module.exports) { _smGetColor, _smFmt, _smCreate, _smRemove, _smUpdate, _smRender, _smOnClick, _smOnWheel, _smEscapeHtml, _smIsDynamicDifficultyAvailable, _smGetSectionDifficulty, _smRenderGlassFilling, + _smUpdateDifficultyFills, _smInitializeDifficultyListener, _smStartRealtimeHooks, _smStopRealtimeHooks, _smSetPlayerVisible, - _getState: () => ({ bar: _smBar, sections: _smSections, duration: _smDuration, sectionDifficulty: _smSectionDifficulty, ddAvailable: _smDynamicDifficultyAvailable }), + _getState: () => ({ + bar: _smBar, sections: _smSections, duration: _smDuration, sectionDifficulty: _smSectionDifficulty, + ddAvailable: _smDynamicDifficultyAvailable, markerEl: _smMarkerEl, blockEls: _smBlockEls, activeIdx: _smActiveIdx, + }), _setState(next) { if ('sections' in next) _smSections = next.sections; if ('duration' in next) _smDuration = next.duration; @@ -348,6 +436,9 @@ if (typeof module !== 'undefined' && module.exports) { if ('difficultyUnsubscribe' in next) _smDifficultyUnsubscribe = next.difficultyUnsubscribe; if ('songReadySubscribed' in next) _smSongReadySubscribed = next.songReadySubscribed; if ('difficultySubscribed' in next) _smDifficultySubscribed = next.difficultySubscribed; + if ('markerEl' in next) _smMarkerEl = next.markerEl; + if ('blockEls' in next) _smBlockEls = next.blockEls; + if ('activeIdx' in next) _smActiveIdx = next.activeIdx; }, }; } else { diff --git a/tests/screen.test.js b/tests/screen.test.js index 479dc11..0ed7640 100644 --- a/tests/screen.test.js +++ b/tests/screen.test.js @@ -306,6 +306,151 @@ test('_smSetPlayerVisible(false) stops polling so inactive screens do not tick _ }); +// _smRender bakes opacity:0.5 into every block up front so _smUpdate's +// per-tick highlight pass never needs a querySelectorAll/forEach sweep — +// it only ever touches the (at most two) blocks whose active state flipped. + +test('_smRender bakes opacity:0.5 into every block', () => { + const mod = freshPlugin(); + const bar = new FakeBar(); + mod._setState({ + bar, + sections: [{ name: 'Intro', time: 0 }, { name: 'Verse 1', time: 10 }, { name: 'Chorus', time: 20 }], + duration: 30, + }); + mod._smRender(); + assert.equal((bar.innerHTML.match(/opacity:0\.5/g) || []).length, 3); +}); + +test('_smUpdate only touches the blocks whose active state changed, leaving cached blockEls alone otherwise', () => { + const mod = freshPlugin(); + const originalHighway = global.highway; + const sections = [{ name: 'Intro', time: 0 }, { name: 'Verse 1', time: 10 }, { name: 'Chorus', time: 20 }]; + const blockEls = sections.map(() => ({ style: {} })); + const markerEl = { style: {} }; + let t = 0; + global.highway = { + getSections: () => sections, + getSongInfo: () => ({ duration: 30 }), + getTime: () => t, + }; + try { + // Same `sections` reference as highway.getSections() returns, so + // _smUpdate's "only rebuild if sections changed" check skips + // _smRender() and our injected blockEls/markerEl survive untouched. + mod._setState({ bar: new FakeBar(), sections, duration: 30, blockEls, markerEl, activeIdx: -1 }); + + mod._smUpdate(); + assert.equal(blockEls[0].style.opacity, '1'); + assert.equal(blockEls[1].style.opacity, undefined); + assert.equal(blockEls[2].style.opacity, undefined); + assert.equal(markerEl.style.left, '0%'); + + t = 15; + mod._smUpdate(); + assert.equal(blockEls[0].style.opacity, '0.5', 'previous active block reverts to inactive'); + assert.equal(blockEls[1].style.opacity, '1', 'newly active block is highlighted'); + assert.equal(blockEls[2].style.opacity, undefined, 'never-active block is left untouched'); + assert.equal(markerEl.style.left, '50%'); + } finally { + if (typeof originalHighway === 'undefined') delete global.highway; + else global.highway = originalHighway; + } +}); + +// _smUpdateDifficultyFills updates an existing glass in place (no DOM +// rebuild) on a difficulty refresh, only rebuilding the one glass element +// when its size bucket changes, per its own doc comment. + +class FakeGlassFill { + constructor() { this.style = {}; } +} + +class FakeGlass { + constructor(size, fillPct) { + this._size = size; + this.title = `Difficulty: ${fillPct}%`; + this.removed = false; + this._fill = new FakeGlassFill(); + this._fill.style.height = fillPct + '%'; + } + getAttribute(name) { return name === 'data-size' ? this._size : null; } + querySelector(sel) { return sel === '.sm-glass-fill' ? this._fill : null; } + remove() { this.removed = true; } +} + +class FakeDifficultyBlock { + constructor(glass) { + this._glass = glass || null; + this.insertedHTML = []; + } + querySelector(sel) { return sel === '.sm-glass' ? this._glass : null; } + insertAdjacentHTML(_pos, html) { + this.insertedHTML.push(html); + const sizeMatch = html.match(/data-size="(\w+)"/); + const fillMatch = html.match(/height:([\d.]+)%/); + const titleMatch = html.match(/title="([^"]*)"/); + const glass = new FakeGlass(sizeMatch ? sizeMatch[1] : 'medium', fillMatch ? fillMatch[1] : '0'); + if (titleMatch) glass.title = titleMatch[1]; + this._glass = glass; + } +} + +test('_smUpdateDifficultyFills updates an existing glass fill/title in place without rebuilding it', () => { + const mod = freshPlugin(); + const glass = new FakeGlass('medium', 10); + const block = new FakeDifficultyBlock(glass); + mod._setState({ + bar: new FakeBar(), + blockEls: [block], + sectionDifficulty: [{ fillPercentage: 40, glassSize: 'medium' }], + ddAvailable: true, + }); + + mod._smUpdateDifficultyFills(); + + assert.equal(block._glass, glass, 'same glass element reused, not rebuilt'); + assert.equal(glass.removed, false); + assert.equal(block.insertedHTML.length, 0); + assert.equal(glass._fill.style.height, '40%'); + assert.equal(glass.title, 'Difficulty: 40%'); +}); + +test('_smUpdateDifficultyFills rebuilds the glass when its size bucket changes', () => { + const mod = freshPlugin(); + const glass = new FakeGlass('small', 10); + const block = new FakeDifficultyBlock(glass); + mod._setState({ + bar: new FakeBar(), + blockEls: [block], + sectionDifficulty: [{ fillPercentage: 75, glassSize: 'large' }], + ddAvailable: true, + }); + + mod._smUpdateDifficultyFills(); + + assert.equal(glass.removed, true, 'old size-mismatched glass is discarded'); + assert.equal(block.insertedHTML.length, 1); + assert.notEqual(block._glass, glass, 'a fresh glass element replaces it'); + assert.equal(block._glass.title, 'Difficulty: 75%'); +}); + +test('_smUpdateDifficultyFills removes a stale glass once difficulty data is no longer available', () => { + const mod = freshPlugin(); + const glass = new FakeGlass('medium', 10); + const block = new FakeDifficultyBlock(glass); + mod._setState({ + bar: new FakeBar(), + blockEls: [block], + sectionDifficulty: [], + ddAvailable: true, + }); + + mod._smUpdateDifficultyFills(); + + assert.equal(glass.removed, true); +}); + test('_smSetPlayerVisible retries event subscription after feedBack becomes available', () => { const mod = freshPlugin(); const originalSetInterval = global.setInterval;