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;