Persist per-pane framing across songs via durable slot keys

Per-pane overrides were keyed by an ephemeral per-instance id, so leaving a
song and opening another rebuilt the renderer with a new id and the pane's
framing was lost.

Key overrides by the durable split slot again ('main' | 'panel<idx>', via
_bgPanelKey) so the same slot means the same pane across songs, and persist
__panels to localStorage. Keep the anti-flicker fixes that were the actual
cause of the earlier dropdown churn (prune stale panes, rebuild only on a
pane-set change, never rebuild while the select is focused). The slot key is
latched to the last real slot so a transient null from panelIndexFor during
a song/layout transition can't flip it to 'main' and drop the override for a
frame; it resets in destroy() for instance reuse in another slot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
topkoa
2026-07-01 02:08:27 -04:00
co-authored by Claude Opus 4.8
parent 6c42d83c31
commit 64b95d6f34
2 changed files with 41 additions and 25 deletions
+25 -18
View File
@@ -1692,14 +1692,16 @@
// the Target dropdown — never on a per-frame label re-report, which would // the Target dropdown — never on a per-frame label re-report, which would
// flicker the <select>. // flicker the <select>.
let _aspectPanesDirty = true; let _aspectPanesDirty = true;
// Monotonic counter handing each renderer instance a stable pane id.
let _aspectPaneCounter = 0;
function _aspectNowMs() { function _aspectNowMs() {
try { return (performance && performance.now) ? performance.now() : 0; } catch (e) { return 0; } try { return (performance && performance.now) ? performance.now() : 0; } catch (e) { return 0; }
} }
function _aspectPaneLabel(arrangement, uid) { // Human label for a slot key: "Main", or "Panel N" (+ " — Arrangement").
function _aspectPaneLabel(paneKey, arrangement) {
let base;
if (paneKey === 'main') { base = 'Main'; }
else { const n = parseInt(paneKey.slice(5), 10); base = 'Panel ' + ((isFinite(n) ? n : 0) + 1); }
const a = (typeof arrangement === 'string') ? arrangement.trim() : ''; const a = (typeof arrangement === 'string') ? arrangement.trim() : '';
return a || ('Pane ' + uid); return a ? (base + ' — ' + a) : base;
} }
// Get-or-create the shared bridge object, seeded from defaults + localStorage. // Get-or-create the shared bridge object, seeded from defaults + localStorage.
@@ -1719,10 +1721,11 @@
function _aspectPersist() { function _aspectPersist() {
try { try {
const t = _aspectTune(), out = {}; const t = _aspectTune(), out = {};
// Persist only the shared base. Per-pane overrides (__panels) are
// keyed by ephemeral instance ids, so they're intentionally
// session-only — persisting them would leak stale keys forever.
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t[k]; }); Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t[k]; });
// Persist per-pane overrides too. Keys are durable split slots
// ('main' | 'panel<idx>'), a bounded set, so a pane's framing carries
// over when the user leaves a song and opens another in the same slot.
if (t.__panels) out.__panels = t.__panels;
localStorage.setItem(_ASPECT_LS, JSON.stringify(out)); localStorage.setItem(_ASPECT_LS, JSON.stringify(out));
} catch (e) {} } catch (e) {}
} }
@@ -1742,9 +1745,9 @@
// refreshed each call for pruning; the dropdown is only marked dirty when a // refreshed each call for pruning; the dropdown is only marked dirty when a
// pane is newly added or its label first resolves — not on every re-report, // pane is newly added or its label first resolves — not on every re-report,
// which would flicker the <select>. // which would flicker the <select>.
function _aspectRegisterPane(paneKey, uid, arrangement) { function _aspectRegisterPane(paneKey, arrangement) {
const reg = window.__h3dAspectPanes || (window.__h3dAspectPanes = {}); const reg = window.__h3dAspectPanes || (window.__h3dAspectPanes = {});
const label = _aspectPaneLabel(arrangement, uid); const label = _aspectPaneLabel(paneKey, arrangement);
let e = reg[paneKey]; let e = reg[paneKey];
if (!e) { e = reg[paneKey] = { label, seen: 0 }; _aspectPanesDirty = true; } if (!e) { e = reg[paneKey] = { label, seen: 0 }; _aspectPanesDirty = true; }
else if (e.label !== label) { e.label = label; _aspectPanesDirty = true; } else if (e.label !== label) { e.label = label; _aspectPanesDirty = true; }
@@ -3915,12 +3918,14 @@
// __h3dAspectTune edits) without waiting for a resize. 0 until first // __h3dAspectTune edits) without waiting for a resize. 0 until first
// applySize(). // applySize().
let _paneAspect = 0; let _paneAspect = 0;
// Stable per-instance id for the wide-pane tuner's Target picker. Each // Latched split-slot key for the wide-pane tuner ('main' | 'panel<idx>').
// renderer instance is exactly one pane, so keying overrides + the // Keyed by the durable split slot (via _bgPanelKey) so a pane's overrides
// readout by this (rather than the split plugin's panel index, which can // persist across songs — the same slot means the same pane to the user.
// ping-pong with focus) keeps the picker steady and unambiguous. Assigned // Latched to the last real slot so a transient null from panelIndexFor
// once in init(); survives destroy()/init() reuse of the same instance. // during a song/layout transition doesn't momentarily flip it to 'main'
let _paneUid = 0; // and drop the override for a frame. Reset in destroy() for instance
// reuse in a different slot.
let _paneKeyCached = '';
// True once applySize() has pinned the .h3d-wrap overlay to the // True once applySize() has pinned the .h3d-wrap overlay to the
// highway canvas's offset box. Stays false while the canvas has no // highway canvas's offset box. Stays false while the canvas has no
// layout yet (init() can run before #highway has a real box, where // layout yet (init() can run before #highway has a real box, where
@@ -14382,8 +14387,10 @@
// effectiveVfov returns the base vertical fov and cam.fov is restored // effectiveVfov returns the base vertical fov and cam.fov is restored
// to it. The fov write is guarded on an actual change so a steady pane // to it. The fov write is guarded on an actual change so a steady pane
// costs nothing. // costs nothing.
const _paneKey = 'pane' + _paneUid; const _pk0 = _bgPanelKey(highwayCanvas);
_aspectRegisterPane(_paneKey, _paneUid, bundle && bundle.songInfo && bundle.songInfo.arrangement); if (_pk0 !== 'main') _paneKeyCached = _pk0; // latch the real slot; ignore transient nulls
const _paneKey = _paneKeyCached || _pk0;
_aspectRegisterPane(_paneKey, bundle && bundle.songInfo && bundle.songInfo.arrangement);
const _aspTune = _resolveTuneFor(_paneKey); const _aspTune = _resolveTuneFor(_paneKey);
const _aspActive = !!(_aspTune && _aspTune.enabled const _aspActive = !!(_aspTune && _aspTune.enabled
&& !(_aspTune.splitOnly && !_ssActive())); && !(_aspTune.splitOnly && !_ssActive()));
@@ -14835,7 +14842,6 @@
} }
_destroyed = _isReady = false; _destroyed = _isReady = false;
_isFocused = true; _isFocused = true;
if (!_paneUid) _paneUid = ++_aspectPaneCounter; // stable pane id for the tuner picker
_registerAspectAbShortcut(); // session-global tuner shortcut (self-guarded) _registerAspectAbShortcut(); // session-global tuner shortcut (self-guarded)
const myToken = ++_initToken; const myToken = ++_initToken;
highwayCanvas = canvas; highwayCanvas = canvas;
@@ -15256,6 +15262,7 @@
_lastHwW = 0; _lastHwH = 0; _lastHwW = 0; _lastHwH = 0;
_appliedW = 0; _appliedH = 0; _appliedW = 0; _appliedH = 0;
_paneAspect = 0; _paneAspect = 0;
_paneKeyCached = '';
if (cam && cam.fov !== BASE_VFOV) { cam.fov = BASE_VFOV; cam.updateProjectionMatrix(); } if (cam && cam.fov !== BASE_VFOV) { cam.fov = BASE_VFOV; cam.updateProjectionMatrix(); }
_wrapPinned = false; _wrapPinned = false;
_unsubscribeFocus(); teardown(); _unsubscribeFocus(); teardown();
+16 -7
View File
@@ -174,13 +174,22 @@ test('a Target select and pane registry drive the per-pane picker', () => {
'camUpdate must register its pane each frame'); 'camUpdate must register its pane each frame');
}); });
test('panes are keyed by a stable per-instance id, not the split panel index', () => { test('panes are keyed by the durable split slot and the key is latched', () => {
// Keying by a stable instance uid keeps the Target picker steady; the split // Slot keys ('main' | 'panel<idx>') persist across songs, so a pane's
// plugin's panel index can ping-pong with focus and cause flicker/dupes. // overrides carry over. The key is latched to the last real slot so a
assert.match(src, /_paneUid\s*=\s*\+\+\s*_aspectPaneCounter/, // transient null from panelIndexFor doesn't flip it to 'main' for a frame.
'each renderer instance must take a stable pane uid in init()'); assert.match(src, /const\s+_pk0\s*=\s*_bgPanelKey\(\s*highwayCanvas\s*\)\s*;/,
assert.match(src, /const\s+_paneKey\s*=\s*'pane'\s*\+\s*_paneUid\s*;/, 'camUpdate must derive the slot key from _bgPanelKey(highwayCanvas)');
'camUpdate must key the pane by its stable instance uid'); assert.match(src, /if\s*\(\s*_pk0\s*!==\s*'main'\s*\)\s*_paneKeyCached\s*=\s*_pk0\s*;[\s\S]*?const\s+_paneKey\s*=\s*_paneKeyCached\s*\|\|\s*_pk0\s*;/,
'camUpdate must latch the last real slot key');
});
test('per-pane overrides persist to localStorage (carry across songs)', () => {
assert.match(
src,
/function\s+_aspectPersist\s*\(\)[\s\S]*?if\s*\(\s*t\.__panels\s*\)\s*out\.__panels\s*=\s*t\.__panels/,
'_aspectPersist must include __panels so per-slot overrides survive a reload / song change',
);
}); });
test('the target dropdown prunes dead panes and does not rebuild while focused', () => { test('the target dropdown prunes dead panes and does not rebuild while focused', () => {