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 _aspectTgtRow = null; // the Target row (hidden when only one pane)
+ let _aspectHfovCb = null; // hfov-override checkbox (synced explicitly)
+ let _aspectHfovSl = null; // hfov-override slider
+ // Which pane the panel edits. '' = all panes (writes the shared base object);
+ // a pane key ('arr:' or the fallback 'pane:') writes that pane's
+ // sparse override, so one split pane can be framed independently.
+ let _aspectEditTarget = '';
+ // Bumped when the SET of live panes changes (add/prune) so the panel rebuilds
+ // the Target dropdown — never on a per-frame label re-report, which would
+ // flicker the .
+ 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 .
+ function _aspectRegisterPane(paneKey) {
+ const reg = window.__h3dAspectPanes || (window.__h3dAspectPanes = {});
+ const label = _aspectPaneLabel(paneKey);
+ let e = reg[paneKey];
+ if (!e) { e = reg[paneKey] = { label, seen: 0 }; _aspectPanesDirty = true; }
+ else if (e.label !== label) { e.label = label; _aspectPanesDirty = true; }
+ e.seen = _aspectNowMs();
+ }
+ // Drop panes not reported recently (song change, split teardown, pane close).
+ function _aspectPrunePanes() {
+ const reg = window.__h3dAspectPanes;
+ if (!reg) return;
+ const now = _aspectNowMs();
+ const ro = window.__h3dAspectReadout;
+ Object.keys(reg).forEach((k) => {
+ if (now - (reg[k].seen || 0) > 1500) {
+ delete reg[k];
+ // Prune the matching readout slot so it can't grow unbounded as
+ // songs/arrangements churn, and drop a dangling __last pointer.
+ if (ro) { delete ro[k]; if (ro.__last === k) delete ro.__last; }
+ _aspectPanesDirty = true;
+ }
+ });
+ }
+
+ // True while _syncAspectPanel is programmatically refreshing controls, so the
+ // synthetic 'input' events it dispatches to update labels don't write back
+ // into the tune (which would populate a full override for every field and
+ // spam localStorage). Real user input runs with this false.
+ let _aspectSyncing = false;
+ // Read/write against the current edit target ('' → base, else pane override).
+ function _aspectReadVal(k) {
+ const base = _aspectTune();
+ if (!_aspectEditTarget) return base[k];
+ const ov = base.__panels && base.__panels[_aspectEditTarget];
+ return (ov && (k in ov)) ? ov[k] : base[k];
+ }
+ function _aspectWriteVal(k, v) {
+ const base = _aspectTune();
+ if (!_aspectEditTarget) { base[k] = v; }
+ else {
+ const m = base.__panels || (base.__panels = {});
+ (m[_aspectEditTarget] || (m[_aspectEditTarget] = {}))[k] = v;
+ }
+ _aspectPersist();
+ }
+ // Clear a field: for the base target set the explicit auto value (null); for a
+ // pane target delete the override key so the pane re-inherits the base value
+ // (and drop the pane's override object once it's empty).
+ function _aspectClearVal(k) {
+ const base = _aspectTune();
+ if (!_aspectEditTarget) { base[k] = null; }
+ else {
+ const m = base.__panels, ov = m && m[_aspectEditTarget];
+ if (ov) { delete ov[k]; if (!Object.keys(ov).length) delete m[_aspectEditTarget]; }
+ }
+ _aspectPersist();
+ }
+
+ // (Re)build the Target dropdown from the live pane registry, preserving the
+ // current selection when it's still valid.
+ function _aspectBuildTargets() {
+ if (!_aspectTargetSel) return;
+ // Don't yank a dropdown the user is actively interacting with — leave it
+ // dirty and rebuild on a later tick once it's no longer focused.
+ if (document.activeElement === _aspectTargetSel) return;
+ const reg = window.__h3dAspectPanes || {};
+ const keys = Object.keys(reg).sort();
+ _aspectTargetSel.innerHTML = '';
+ const all = document.createElement('option');
+ all.value = ''; all.textContent = keys.length > 1 ? 'All panes' : 'All';
+ _aspectTargetSel.appendChild(all);
+ keys.forEach((pk) => {
+ const o = document.createElement('option');
+ o.value = pk; o.textContent = reg[pk].label;
+ _aspectTargetSel.appendChild(o);
+ });
+ // Force the edit target back to "All" when the Target row is hidden
+ // (single pane) or the selected pane is gone — otherwise a stale pane
+ // target would silently route edits into a hidden (and persistent
+ // arr:*) override in single-player.
+ if (keys.length <= 1 || (_aspectEditTarget && !reg[_aspectEditTarget])) {
+ _aspectEditTarget = '';
+ }
+ _aspectTargetSel.value = _aspectEditTarget;
+ // The Target row only matters with more than one pane (a split). With a
+ // single pane there's nothing to disambiguate, so hide it.
+ if (_aspectTgtRow) _aspectTgtRow.style.display = keys.length > 1 ? '' : 'none';
+ _aspectPanesDirty = false;
+ }
+
function _ensureAspectPanel() {
if (_aspectPanelEl || typeof document === 'undefined') return;
- const t = _aspectTune();
const wrap = document.createElement('div');
wrap.id = 'h3d-aspect-tuner';
wrap.style.cssText = [
'position:fixed', 'top:64px', 'right:12px', 'z-index:99999',
- 'width:230px', 'padding:10px 12px', 'border-radius:8px',
+ 'width:236px', 'padding:10px 12px', 'border-radius:8px',
'background:rgba(12,18,28,0.92)', 'border:1px solid rgba(120,150,200,0.35)',
'box-shadow:0 6px 24px rgba(0,0,0,0.5)', 'color:#cfe0f5',
'font:11px/1.35 system-ui,sans-serif', 'user-select:none',
'pointer-events:auto',
].join(';');
+ // Header: title + close (×). Close hides the panel; the feature keeps
+ // whatever enabled state it had — this is a dismiss, not an A/B toggle.
+ const hdr = document.createElement('div');
+ hdr.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;';
const title = document.createElement('div');
- title.textContent = 'Wide-pane framing (A/B)';
- title.style.cssText = 'font-weight:700;margin-bottom:6px;color:#e8c040;';
- wrap.appendChild(title);
+ title.textContent = 'Wide-pane framing';
+ title.style.cssText = 'font-weight:700;color:#e8c040;';
+ const close = document.createElement('button');
+ close.type = 'button'; // never submit if nested in a