diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index ae7f8a7..8da18ef 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1607,37 +1607,32 @@ ss.isCanvasFocused(highwayCanvas)); } - // A/B toggle for the wide-pane horizontal-FOV-hold. Flips - // window.__h3dAspectTune.enabled so the running app can switch between the - // current framing (off, the baseline) and the Hor+ framing (on) with one - // keypress, across all panes at once. Registered once per session via a - // module-level guard (it toggles a shared global, so per-instance - // registration would stack duplicate handlers and cancel itself out); it's - // a harmless debug control, so it is never unregistered. No-ops where the - // core shortcut API isn't present (older core / borrowed contexts). - let _abShortcutRegistered = false; - function _registerAspectAbShortcut() { - if (_abShortcutRegistered) return; + // Shortcut for the wide-pane framing tuner. Opens/closes the floating panel + // (the A/B on/off and the per-pane target live inside it now). Registered + // once per session via a module-level guard (it drives shared module state, + // so per-instance registration would stack duplicate handlers and cancel + // itself out); it's a harmless debug control, so it is never unregistered. + // No-ops where the core shortcut API isn't present (older core / borrowed + // contexts). + let _tunerShortcutRegistered = false; + function _registerTunerShortcut() { + if (_tunerShortcutRegistered) return; if (typeof window.registerShortcut !== 'function') return; - _abShortcutRegistered = true; + _tunerShortcutRegistered = true; try { window.registerShortcut({ key: 'A', // uppercase e.key → produced with Shift held (Shift+A) - description: '3D Highway: toggle wide-pane framing A/B (Shift+A)', + description: '3D Highway: open/close wide-pane framing tuner (Shift+A)', scope: 'player', handler: () => { - const t = _aspectTune(); - t.enabled = !t.enabled; - try { console.log('[h3d] wide-pane framing', t.enabled ? 'ON' : 'OFF'); } catch (e) {} - // Surface the live tuner panel whenever the feature is on, - // hide it when off. Built lazily on first use. - _ensureAspectPanel(); - _setAspectPanelVisible(t.enabled); - _syncAspectPanel(); + // Open/close the live tuner panel. The A/B on/off and the + // per-pane target now live in the panel itself, so the + // shortcut is just a dismiss/reveal. + _toggleAspectPanel(); }, }); } catch (e) { - _abShortcutRegistered = false; // allow a later retry if it threw + _tunerShortcutRegistered = false; // allow a later retry if it threw } } @@ -1685,8 +1680,42 @@ let _aspectPanelEl = null; // the floating panel root (built once) let _aspectPanelRO = null; // readout
let _aspectPanelRAF = 0; // readout poll handle + let _aspectTargetSel = null; // the "Target" . + let _aspectPanesDirty = true; + // Monotonic counter for the per-instance fallback key (when a pane has no + // arrangement name to key by). + let _aspectPaneCounter = 0; + function _aspectNowMs() { + try { if (performance && performance.now) return performance.now(); } catch (e) {} + try { return Date.now(); } catch (e) { return 0; } // keep pruning functional + } + // Pane key: prefer the arrangement name ('arr:Bass') so a pane's framing is + // stable across songs AND distinct between split panes, with no dependency on + // the external splitscreen panel index (which isn't always available). Fall + // back to a per-instance id ('pane:3') when there's no arrangement. + function _aspectPaneKey(arrangement, uid) { + const a = (typeof arrangement === 'string') ? arrangement.trim() : ''; + return a ? ('arr:' + a) : ('pane:' + uid); + } + // Human label derived from the key. + function _aspectPaneLabel(paneKey) { + if (paneKey.slice(0, 4) === 'arr:') return paneKey.slice(4); + if (paneKey.slice(0, 5) === 'pane:') return 'Pane ' + paneKey.slice(5); + return paneKey; + } - // Get-or-create the live bridge object, seeded from defaults + localStorage. + // Get-or-create the shared bridge object, seeded from defaults + localStorage. + // May carry a sparse `__panels` map of per-pane overrides. function _aspectTune() { let t = window.__h3dAspectTune; if (!t || typeof t !== 'object') { @@ -1699,48 +1728,196 @@ } return t; } + // Bumped on every tune mutation (all writes funnel through _aspectPersist) so + // the per-pane resolve cache below can invalidate cheaply. + let _aspectRev = 0; function _aspectPersist() { + _aspectRev++; try { const t = _aspectTune(), out = {}; Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t[k]; }); + // Persist per-pane overrides keyed by arrangement ('arr:*') only, so a + // pane's framing carries across songs. Instance-id fallback keys + // ('pane:*') are session-only — persisting them would leak a new key + // every reload. + if (t.__panels) { + const p = {}; let any = false; + Object.keys(t.__panels).forEach((k) => { + if (k.slice(0, 4) === 'arr:') { p[k] = t.__panels[k]; any = true; } + }); + if (any) out.__panels = p; + } localStorage.setItem(_ASPECT_LS, JSON.stringify(out)); } catch (e) {} } + // Resolve the effective tune for a pane: the shared base, with that pane's + // override keys (if any) laid on top. Called every frame per renderer, so the + // merged object is memoized per pane and only rebuilt when the tune mutates + // (_aspectRev changes). Panes with no override return the base directly (no + // allocation). + const _aspectResolveCache = new Map(); // paneKey -> { rev, obj } + function _resolveTuneFor(paneKey) { + const base = _aspectTune(); + const ov = base.__panels && base.__panels[paneKey]; + if (!ov) return base; + const c = _aspectResolveCache.get(paneKey); + if (c && c.rev === _aspectRev) return c.obj; + const out = {}; + Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = (k in ov) ? ov[k] : base[k]; }); + _aspectResolveCache.set(paneKey, { rev: _aspectRev, obj: out }); + return out; + } + // Record a live pane so the Target dropdown can list it. Called every frame + // by each renderer with its pane key. `seen` is refreshed each call for + // pruning; the dropdown is only marked dirty when a pane is newly added — not + // on every re-report, which would flicker the '); + assert.match(src, /function\s+_aspectRegisterPane\s*\(/, + '_aspectRegisterPane must record live panes for the picker'); + assert.match(src, /if\s*\(\s*window\.__h3dAspectPanelOpen\s*\)\s*_aspectRegisterPane\(\s*_paneKey\s*\)/, + 'camUpdate must register its pane only while the tuner panel is open'); +}); + +test('panes are keyed by arrangement (stable across songs, no split-API dep)', () => { + // 'arr:' keys are distinct between split panes AND stable across + // songs, without depending on the external splitscreen panel index (which + // isn't always available). A per-instance id is the no-arrangement fallback. + assert.match( + src, + /function\s+_aspectPaneKey\s*\(\s*arrangement\s*,\s*uid\s*\)[\s\S]*?'arr:'\s*\+\s*a[\s\S]*?'pane:'\s*\+\s*uid/, + '_aspectPaneKey must prefer arr: and fall back to pane:', + ); + assert.match( + src, + /const\s+_paneKey\s*=\s*_aspectPaneKey\(\s*[\s\S]*?songInfo[\s\S]*?arrangement\s*,\s*_paneUid\s*\)\s*;/, + 'camUpdate must key the pane by arrangement (with the uid fallback)', + ); +}); + +test('arrangement-keyed overrides persist; instance-id keys stay session-only', () => { + assert.match( + src, + /function\s+_aspectPersist\s*\(\)[\s\S]*?k\.slice\(0,\s*4\)\s*===\s*'arr:'[\s\S]*?out\.__panels\s*=\s*p/, + '_aspectPersist must persist only arr:* overrides so they carry across songs', + ); +}); + +test('the target dropdown prunes dead panes and does not rebuild while focused', () => { + assert.match(src, /function\s+_aspectPrunePanes\s*\(\)[\s\S]*?delete\s+reg\[k\]/, + '_aspectPrunePanes must drop panes not seen recently'); + assert.match(src, /_aspectPrunePanes\(\)\s*;[\s\S]*?if\s*\(\s*_aspectPanesDirty\s*\)\s*_aspectBuildTargets\(\)/, + 'the readout tick must prune then rebuild only when dirty'); + assert.match(src, /function\s+_aspectBuildTargets\s*\(\)[\s\S]*?document\.activeElement\s*===\s*_aspectTargetSel[\s\S]*?return/, + '_aspectBuildTargets must skip rebuilding while the select is focused'); +}); + +test('programmatic sync does not write back into the tune', () => { + // _syncAspectPanel dispatches synthetic input events to refresh labels; the + // slider handler must skip the write while syncing, else opening/switching a + // target would populate a full override for every field. + assert.match(src, /_aspectSyncing\s*=\s*true[\s\S]*?finally[\s\S]*?_aspectSyncing\s*=\s*false/, + '_syncAspectPanel must set/reset the _aspectSyncing guard'); + assert.match(src, /if\s*\(\s*!_aspectSyncing\s*\)\s*_aspectWriteVal\(\s*f\.k\s*,/, + 'the slider input handler must skip the write while syncing'); +}); + +test('unchecking hfov override clears a pane override key (re-inherits base)', () => { + assert.match( + src, + /function\s+_aspectClearVal\s*\(\s*k\s*\)[\s\S]*?delete\s+ov\[k\][\s\S]*?delete\s+m\[\s*_aspectEditTarget\s*\]/, + '_aspectClearVal must delete the pane override key (and empty object)', + ); + assert.match(src, /else\s+_aspectClearVal\(\s*'hfovDeg'\s*\)/, + 'unchecking the hfov override must call _aspectClearVal'); +}); + +test('pruning drops the matching readout slot and a dangling __last', () => { + assert.match( + src, + /delete\s+reg\[k\]\s*;[\s\S]*?delete\s+ro\[k\]\s*;\s*if\s*\(\s*ro\.__last\s*===\s*k\s*\)\s*delete\s+ro\.__last/, + '_aspectPrunePanes must prune the readout cache alongside the registry', + ); +}); + +test('single-pane forces the edit target back to All (no hidden pane edits)', () => { + assert.match( + src, + /if\s*\(\s*keys\.length\s*<=\s*1\s*\|\|\s*\(\s*_aspectEditTarget\s*&&\s*!reg\[_aspectEditTarget\]\s*\)\s*\)\s*\{\s*_aspectEditTarget\s*=\s*''/, + '_aspectBuildTargets must reset the edit target to "" when the Target row is hidden', + ); +}); + +test('resolved per-pane tune is memoized and invalidated by a revision', () => { + assert.match(src, /_aspectRev\s*\+\+/, 'a mutation revision must be bumped on persist'); + assert.match( + src, + /_aspectResolveCache\.get\(\s*paneKey\s*\)[\s\S]*?c\.rev\s*===\s*_aspectRev[\s\S]*?return\s+c\.obj/, + '_resolveTuneFor must return a cached object when the revision is unchanged', + ); +}); + +test('the pane clock falls back to Date.now so pruning keeps working', () => { + assert.match( + src, + /function\s+_aspectNowMs\s*\(\)[\s\S]*?performance\.now\(\)[\s\S]*?return\s+Date\.now\(\)/, + '_aspectNowMs must fall back to Date.now() when the Performance API is absent', + ); +}); + +test('opening the panel prunes before the first dropdown build', () => { + assert.match( + src, + /if\s*\(\s*on\s*\)\s*\{\s*_aspectPrunePanes\(\)\s*;\s*_aspectBuildTargets\(\)/, + '_setAspectPanelVisible must prune stale panes before building the dropdown', + ); +}); + +test('Reset on All restores defaults exactly (no forced enabled)', () => { + // Panel visibility is independent of the enabled flag now, so Reset must not + // force enabled true — it should restore _ASPECT_DEFAULTS verbatim. + assert.doesNotMatch(src, /Object\.keys\(_ASPECT_DEFAULTS\)[\s\S]*?base\.enabled\s*=\s*true/, + 'Reset must not override the default enabled state'); +}); + +test('the panel has a dismiss (close) control', () => { + assert.match( + src, + /close\.textContent\s*=\s*'×'[\s\S]*?_setAspectPanelVisible\(\s*false\s*\)/, + 'the panel header must have a × button that hides the panel', + ); }); test('camUpdate only writes cam.fov when it actually changes', () => { @@ -153,14 +293,16 @@ test('camUpdate only writes cam.fov when it actually changes', () => { ); }); -// ── A/B toggle + lifecycle reset ────────────────────────────────────────────── +// ── Shortcut (open/close) + lifecycle reset ─────────────────────────────────── -test('an A/B toggle shortcut flips the tune enabled flag', () => { +test('the shortcut opens/closes the tuner panel', () => { assert.match( src, - /registerShortcut\(\{[\s\S]*?const\s+t\s*=\s*_aspectTune\(\)\s*;[\s\S]*?t\.enabled\s*=\s*!\s*t\.enabled/, - 'a registerShortcut handler must toggle the bridge enabled flag', + /registerShortcut\(\{[\s\S]*?_toggleAspectPanel\(\)/, + 'a registerShortcut handler must toggle the tuner panel', ); + assert.match(src, /function\s+_toggleAspectPanel\s*\(\)/, + '_toggleAspectPanel() must exist to reveal/dismiss the panel'); }); test('destroy() resets the pane aspect and restores the base fov', () => {