Compare commits

..
Author SHA1 Message Date
OmikronApexandClaude Sonnet 4.6 d528b298ca fix(input_setup): keep backdrop-blur during calibration wizard
Hiding the entire #input-setup-overlay removed it from the render tree,
which also removed its backdrop-blur-sm — causing the background blur to
vanish while the calibration wizard was open.

Fix: hide only the [data-is-host] card inside the overlay, keeping the
overlay element itself (and its backdrop-blur) alive in the DOM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 23:48:10 +02:00
7 changed files with 45 additions and 701 deletions
+4 -352
View File
@@ -1083,22 +1083,6 @@
const FOCUS_D = 600 * K;
const CAM_LERP_BASE = 0.02;
// Base vertical field of view (deg). THREE's PerspectiveCamera fov is the
// VERTICAL angle; horizontal follows from the aspect ratio. At a normal
// ~16:9 pane this gives a ~102° horizontal cone. On an ultra-wide pane
// (top/bottom 2-player split → full-width/half-height → ~32:9) that
// horizontal cone balloons past 130° and squeezes the fixed-width neck into
// a central sliver. The optional horizontal-FOV-hold path below counters
// that by lowering the effective vertical fov as the pane widens.
const BASE_VFOV = 70;
// Horizontal-FOV-hold ("Hor+") defaults. At/under HORPLUS_START_ASPECT the
// effective vertical fov equals BASE_VFOV (exact no-op); past it the
// vertical fov drops to keep the horizontal cone ~constant so the neck
// fills a wide pane. HORPLUS_MIN_VFOV floors the result on pathological
// aspects. Engaged only via the window.__h3dAspectTune bridge (default off).
const HORPLUS_START_ASPECT = 16 / 9;
const HORPLUS_MIN_VFOV = 28;
// Zoom-dependent framing — height (h*) and depth (dist*) multipliers
// applied to cam.position. Interpolated by `dist`:
// NEAR = tight view (nut position, span<=4 -> dist~=93*K): lower/closer.
@@ -1607,257 +1591,6 @@
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;
if (typeof window.registerShortcut !== 'function') return;
_abShortcutRegistered = 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)',
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();
},
});
} catch (e) {
_abShortcutRegistered = false; // allow a later retry if it threw
}
}
// ── Wide-pane framing: live tuner bridge + panel ──────────────────────────
// window.__h3dAspectTune is the single source of truth the renderer reads
// each frame (see effectiveVfov + camUpdate). The defaults reproduce the
// current framing exactly (enabled:false). Values persist to localStorage so
// a tuning session survives reloads; the floating panel (Shift+A) writes the
// same object live. All of this is a debug aid — none of it runs unless the
// user opts in.
// Versioned key: the first iteration shipped a broken default (enabled:true,
// baseVfov:30) and may have persisted it. Bumping the key ignores that stale
// state so the corrected default-off config actually takes effect.
const _ASPECT_LS = 'h3d_aspect_tune2';
// Working defaults. Default OFF, so out of the box this is an exact no-op —
// every pane renders byte-for-byte as before (effectiveVfov returns
// BASE_VFOV and the pose nudges gate off). The config is also coherent when
// a tester turns it ON via Shift+A: baseVfov == BASE_VFOV so normal ~16:9
// panes (single-player, most 2x2) stay at 70° even enabled, and only panes
// wider than startAspect (2.25) engage the Hor+ hold; blend:1 makes that
// hold actually take effect; minVfovDeg (28) sits below baseVfov so the floor
// is a real floor. The pose nudges are the in-progress wide-pane look a
// tester sees once enabled. localStorage overrides all of this per machine.
const _ASPECT_DEFAULTS = {
enabled: false, baseVfov: BASE_VFOV, startAspect: 2.25, hfovDeg: null,
blend: 1, minVfovDeg: HORPLUS_MIN_VFOV, splitOnly: false,
heightMul: 0.30, distMul: 0.95, pitchAdd: -1.5, lookDepthMul: 1,
};
// Slider specs (numeric fields). Checkboxes (enabled/splitOnly) + the hfov
// override are handled separately in the panel builder. Ranges are wide on
// purpose — this is a tuning aid, the no-op default sits mid-range.
const _ASPECT_FIELDS = [
{ k: 'baseVfov', label: 'Base vFOV°', min: 18, max: 90, step: 1 },
{ k: 'startAspect', label: 'Start aspect', min: 1.0, max: 4.0, step: 0.05 },
{ k: 'blend', label: 'Blend', min: 0, max: 1, step: 0.05 },
{ k: 'minVfovDeg', label: 'Min vFOV°', min: 10, max: 60, step: 1 },
{ k: 'heightMul', label: 'Height ×', min: 0.1, max: 2.5, step: 0.05 },
{ k: 'distMul', label: 'Dolly ×', min: 0.2, max: 3.0, step: 0.05 },
{ k: 'pitchAdd', label: 'Pitch +', min: -40, max: 40, step: 0.5 },
// Aims the camera further down the neck (>1) or pulls the aim back (<1).
// This is the lever that flattens the mid-distance "hump" toward a
// straight gradual recede.
{ k: 'lookDepthMul', label: 'Look depth', min: 0.2, max: 3.0, step: 0.05 },
];
let _aspectPanelEl = null; // the floating panel root (built once)
let _aspectPanelRO = null; // readout <div>
let _aspectPanelRAF = 0; // readout poll handle
// Get-or-create the live bridge object, seeded from defaults + localStorage.
function _aspectTune() {
let t = window.__h3dAspectTune;
if (!t || typeof t !== 'object') {
t = Object.assign({}, _ASPECT_DEFAULTS);
try {
const raw = localStorage.getItem(_ASPECT_LS);
if (raw) Object.assign(t, JSON.parse(raw));
} catch (e) {}
window.__h3dAspectTune = t;
}
return t;
}
function _aspectPersist() {
try {
const t = _aspectTune(), out = {};
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t[k]; });
localStorage.setItem(_ASPECT_LS, JSON.stringify(out));
} catch (e) {}
}
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',
'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(';');
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);
// enabled + splitOnly checkboxes
[['enabled', 'Enabled (Shift+A)'], ['splitOnly', 'Split panes only']].forEach(([k, lbl]) => {
const row = document.createElement('label');
row.style.cssText = 'display:flex;align-items:center;gap:6px;margin:2px 0;cursor:pointer;';
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = !!t[k]; cb.dataset.k = k;
cb.addEventListener('change', () => {
_aspectTune()[k] = cb.checked; _aspectPersist();
if (k === 'enabled') _setAspectPanelVisible(cb.checked);
});
const span = document.createElement('span'); span.textContent = lbl;
row.appendChild(cb); row.appendChild(span); wrap.appendChild(row);
});
// numeric sliders
_ASPECT_FIELDS.forEach((f) => {
const row = document.createElement('div');
row.style.cssText = 'margin:5px 0;';
const head = document.createElement('div');
head.style.cssText = 'display:flex;justify-content:space-between;';
const lab = document.createElement('span'); lab.textContent = f.label;
const val = document.createElement('span');
val.style.cssText = 'color:#8fb6ff;font-variant-numeric:tabular-nums;';
head.appendChild(lab); head.appendChild(val); row.appendChild(head);
const sl = document.createElement('input');
sl.type = 'range'; sl.min = f.min; sl.max = f.max; sl.step = f.step;
sl.value = Number.isFinite(t[f.k]) ? t[f.k] : _ASPECT_DEFAULTS[f.k];
sl.dataset.k = f.k;
sl.style.cssText = 'width:100%;';
const show = () => { val.textContent = (+sl.value).toFixed(f.step < 1 ? 2 : 0); };
show();
sl.addEventListener('input', () => {
_aspectTune()[f.k] = parseFloat(sl.value); show(); _aspectPersist();
});
row.appendChild(sl); wrap.appendChild(row);
});
// hfov override (checkbox enables a slider; off → hfovDeg=null = auto)
{
const row = document.createElement('div'); row.style.cssText = 'margin:5px 0;';
const head = document.createElement('label');
head.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;';
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = Number.isFinite(t.hfovDeg);
const lbl = document.createElement('span'); lbl.textContent = 'Override held hFOV°';
head.appendChild(cb); head.appendChild(lbl); row.appendChild(head);
const sl = document.createElement('input');
sl.type = 'range'; sl.min = 40; sl.max = 160; sl.step = 1;
sl.value = Number.isFinite(t.hfovDeg) ? t.hfovDeg : 102;
sl.disabled = !cb.checked;
sl.style.cssText = 'width:100%;';
cb.addEventListener('change', () => {
sl.disabled = !cb.checked;
_aspectTune().hfovDeg = cb.checked ? parseFloat(sl.value) : null;
_aspectPersist();
});
sl.addEventListener('input', () => {
if (cb.checked) { _aspectTune().hfovDeg = parseFloat(sl.value); _aspectPersist(); }
});
row.appendChild(sl); wrap.appendChild(row);
}
// live readout
_aspectPanelRO = document.createElement('div');
_aspectPanelRO.style.cssText = 'margin-top:6px;padding-top:6px;border-top:1px solid rgba(120,150,200,0.25);color:#9fb;font-variant-numeric:tabular-nums;';
_aspectPanelRO.textContent = 'aspect — · vFOV —';
wrap.appendChild(_aspectPanelRO);
// buttons
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex;gap:6px;margin-top:8px;';
const mkBtn = (txt, fn) => {
const b = document.createElement('button');
b.textContent = txt;
b.style.cssText = 'flex:1;padding:4px 0;border-radius:5px;border:1px solid rgba(120,150,200,0.4);background:rgba(40,60,90,0.6);color:#cfe0f5;cursor:pointer;font:11px system-ui;';
b.addEventListener('click', fn);
return b;
};
btnRow.appendChild(mkBtn('Reset', () => {
const t2 = _aspectTune();
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { t2[k] = _ASPECT_DEFAULTS[k]; });
t2.enabled = true; // keep panel up after reset
_aspectPersist(); _syncAspectPanel();
}));
btnRow.appendChild(mkBtn('Copy', () => {
const t2 = _aspectTune(), out = {};
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t2[k]; });
const json = JSON.stringify(out, null, 2);
try { console.log('[h3d] wide-pane framing values:\n' + json); } catch (e) {}
try { if (navigator.clipboard) navigator.clipboard.writeText(json); } catch (e) {}
}));
wrap.appendChild(btnRow);
document.body.appendChild(wrap);
_aspectPanelEl = wrap;
_aspectPanelEl.style.display = 'none';
}
// Push current bridge values back into the panel controls (after Reset or an
// external edit). Cheap; only runs on demand.
function _syncAspectPanel() {
if (!_aspectPanelEl) return;
const t = _aspectTune();
_aspectPanelEl.querySelectorAll('input[type=checkbox][data-k]').forEach((cb) => {
cb.checked = !!t[cb.dataset.k];
});
_aspectPanelEl.querySelectorAll('input[type=range][data-k]').forEach((sl) => {
const k = sl.dataset.k;
if (Number.isFinite(t[k])) sl.value = t[k];
sl.dispatchEvent(new Event('input')); // refresh the value label
});
}
function _setAspectPanelVisible(on) {
_ensureAspectPanel();
if (!_aspectPanelEl) return;
_aspectPanelEl.style.display = on ? 'block' : 'none';
window.__h3dAspectPanelOpen = !!on; // gates the per-frame readout publish
if (on && !_aspectPanelRAF) {
const tick = () => {
if (!window.__h3dAspectPanelOpen) { _aspectPanelRAF = 0; return; }
const ro = window.__h3dAspectReadout;
if (_aspectPanelRO && ro && Number.isFinite(ro.aspect)) {
_aspectPanelRO.textContent =
'aspect ' + ro.aspect.toFixed(2) + ' · vFOV ' + ro.vfov.toFixed(1) + '°';
}
_aspectPanelRAF = requestAnimationFrame(tick);
};
_aspectPanelRAF = requestAnimationFrame(tick);
}
}
/* ======================================================================
* Background animations (issue #13)
*
@@ -3765,11 +3498,6 @@
// that CSS-box drift and re-frame, instead of the user having to
// un/re-maximize the window.
let _appliedW = 0, _appliedH = 0;
// Last pane aspect (w/h) handed to the camera, cached so camUpdate can
// recompute the horizontal-FOV-hold each frame (and react to live
// __h3dAspectTune edits) without waiting for a resize. 0 until first
// applySize().
let _paneAspect = 0;
// True once applySize() has pinned the .h3d-wrap overlay to the
// highway canvas's offset box. Stays false while the canvas has no
// layout yet (init() can run before #highway has a real box, where
@@ -6248,7 +5976,7 @@
scene = new T.Scene();
scene.fog = new T.Fog(0x101820, FOG_START * 0.8, FOG_END * 1.2);
cam = new T.PerspectiveCamera(BASE_VFOV, 1, 0.01, FOG_END * 3);
cam = new T.PerspectiveCamera(70, 1, 0.01, FOG_END * 3);
ambLight = new T.AmbientLight(0xffffff, 0.85);
scene.add(ambLight);
@@ -14187,77 +13915,11 @@
ctx.restore();
}
// Horizontal-FOV-hold ("Hor+"). Returns the vertical fov (deg) the
// camera should use for the given pane aspect. With the bridge off (or
// absent), or at/under the start aspect, it returns the base vertical
// fov unchanged — an exact no-op, so normal panes render identically to
// before. Past the start aspect it lowers the vertical fov to keep the
// horizontal cone ~constant, so the neck fills an ultra-wide pane
// instead of collapsing into a central sliver. Pure + finite-guarded.
function effectiveVfov(aspect, tune) {
const base = (tune && Number.isFinite(tune.baseVfov)) ? tune.baseVfov : BASE_VFOV;
if (!tune || !tune.enabled || !Number.isFinite(aspect) || aspect <= 0) return base;
const start = (Number.isFinite(tune.startAspect) && tune.startAspect > 0)
? tune.startAspect : HORPLUS_START_ASPECT;
if (aspect <= start) return base;
const floor = Number.isFinite(tune.minVfovDeg) ? tune.minVfovDeg : HORPLUS_MIN_VFOV;
const DEG = Math.PI / 180;
// Held horizontal fov: explicit hfovDeg if given, else the horizontal
// cone the base vertical fov produces at the start aspect.
const hfov = (Number.isFinite(tune.hfovDeg) && tune.hfovDeg > 0)
? tune.hfovDeg * DEG
: 2 * Math.atan(Math.tan(base * DEG / 2) * start);
// Vertical fov that reproduces that horizontal cone at this aspect.
let vfov = 2 * Math.atan(Math.tan(hfov / 2) / aspect) / DEG;
const blend = Number.isFinite(tune.blend) ? Math.max(0, Math.min(1, tune.blend)) : 1;
vfov = base + (vfov - base) * blend; // 0 = base, 1 = full Hor+
if (!Number.isFinite(vfov)) return base;
return Math.max(floor, Math.min(base, vfov));
}
/* ── Camera smooth lerp ──────────────────────────────────────────── */
function camUpdate(bundle) {
const bpm = computeBPM(bundle.beats, bundle.currentTime);
const lerp = CAM_LERP_BASE * Math.max(bpm, 60) / 120;
// ── Horizontal-FOV-hold + optional wide-pane pose nudges ──
// Driven by window.__h3dAspectTune (default off → exact no-op).
// _aspectTune() returns the live bridge object, seeded from defaults
// + localStorage on first read so a persisted tuning session applies
// on load without opening the panel. Every field is finite-coerced.
// When disabled (or splitOnly and not in a split) the tune is treated
// as null, so 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 costs nothing.
const _aspTune = _aspectTune();
const _aspActive = !!(_aspTune && _aspTune.enabled
&& !(_aspTune.splitOnly && !_ssActive()));
const _tune = _aspActive ? _aspTune : null;
const _vfov = effectiveVfov(_paneAspect, _tune);
if (Number.isFinite(_vfov) && Math.abs(_vfov - cam.fov) > 1e-4) {
cam.fov = _vfov;
cam.updateProjectionMatrix();
}
// Publish a live readout for the tuner panel (only while it's open,
// so the steady path stays allocation-free). Last pane to render wins
// the slot — fine, all panes share the same aspect in a split layout.
if (window.__h3dAspectPanelOpen) {
const _ro = window.__h3dAspectReadout || (window.__h3dAspectReadout = {});
_ro.aspect = _paneAspect; _ro.vfov = _vfov;
}
// Optional pose nudges (height / dolly / pitch) to chase a low-flat
// wide-pane look if fov alone isn't enough. Gated to wide panes and
// suppressed while the Camera Director owns the view (it wins).
const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0)
? _tune.startAspect : HORPLUS_START_ASPECT;
const _dirActive = !!(window.__h3dCamCtl && window.__h3dCamCtl.enabled);
const _wide = !!(_tune && _paneAspect > _startAspect) && !_dirActive;
const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1;
const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1;
const _poseLookYAdd = (_wide && Number.isFinite(_tune.pitchAdd)) ? _tune.pitchAdd * K : 0;
const _poseLookZMul = (_wide && Number.isFinite(_tune.lookDepthMul) && _tune.lookDepthMul > 0)
? _tune.lookDepthMul : 1;
curX += (tgtX - curX) * lerp;
// The fret-row fit guard (end of camUpdate) may dolly the camera back
// via _fretRowFitBoost; the span-driven tgtDist still owns zooming IN.
@@ -14273,9 +13935,6 @@
const _dMul = CAM_FRAME_D_NEAR + (CAM_FRAME_D_FAR - CAM_FRAME_D_NEAR) * _zt;
const shoulderOffset = (_leftyCached ? -1 : 1) * 10 * K;
let _camX = curX + shoulderOffset, _camY = h * _hMul, _camZ = dist * _dMul;
// Optional wide-pane pose nudges (default identity → no-op).
if (_poseHMul !== 1) _camY *= _poseHMul;
if (_poseDMul !== 1) _camZ *= _poseDMul;
// ── Free-camera user tweaks (orbit / height / zoom / pan) ──
// Driven by the Camera Director plugin via window.__h3dCamCtl.
// Layered ON TOP of the auto-framing so note tracking still works.
@@ -14284,7 +13943,7 @@
// finite number before use so a malformed object can never feed NaN
// into cam.position / cam.lookAt.
const _freeCam = window.__h3dCamCtl;
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
const _lookAtZ = -FOCUS_D * 0.35;
if (_freeCam && _freeCam.enabled) {
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
const _heightMul = Number.isFinite(_freeCam.heightMul) ? _freeCam.heightMul : 1;
@@ -14305,7 +13964,7 @@
// This lets the camera adapt to any panel aspect ratio automatically.
const fretMidY = (sY(0) + sY(nStr - 1)) / 2;
_probe.set(curX, fretMidY, 0); // play-line fretboard centre
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ); // tentative look — needed for project()
cam.lookAt(curX, curLookY, -FOCUS_D * 0.35); // tentative look — needed for project()
cam.updateMatrixWorld();
_probe.project(cam); // _probe.y → NDC in [-1, 1]
@@ -14334,7 +13993,7 @@
const _pitch = Number.isFinite(_freeCam.pitch) ? _freeCam.pitch : 0;
cam.lookAt(curX + _panX * K, curLookY + (_pitch + _panY) * K, _lookAtZ);
} else {
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ);
cam.lookAt(curX, curLookY, _lookAtZ);
}
// ── Fret-row fit guard ────────────────────────────────────────────
@@ -14431,10 +14090,6 @@
cam.aspect = w / h;
cam.updateProjectionMatrix();
aspectScale = Math.max(1, REF_ASPECT / Math.max(cam.aspect, 0.5));
// Cache the pane aspect for the horizontal-FOV-hold in camUpdate.
// cam.fov itself is owned by camUpdate (not set here) so live
// __h3dAspectTune edits apply every frame without a resize.
_paneAspect = cam.aspect;
_appliedW = w; _appliedH = h;
}
@@ -14678,7 +14333,6 @@
}
_destroyed = _isReady = false;
_isFocused = true;
_registerAspectAbShortcut(); // session-global A/B toggle (self-guarded)
const myToken = ++_initToken;
highwayCanvas = canvas;
_invertedCached = !!(bundle && bundle.inverted);
@@ -15097,8 +14751,6 @@
_destroyed = true; _isReady = false; _diagChord = null; _diagPrev = null; _diagLastKey = null; _diagRenderCache.clear();
_lastHwW = 0; _lastHwH = 0;
_appliedW = 0; _appliedH = 0;
_paneAspect = 0;
if (cam && cam.fov !== BASE_VFOV) { cam.fov = BASE_VFOV; cam.updateProjectionMatrix(); }
_wrapPinned = false;
_unsubscribeFocus(); teardown();
highwayCanvas = null;
+12 -11
View File
@@ -164,20 +164,21 @@
host.querySelector('[data-is-cal]').addEventListener('click', () => {
if (hasDetector) {
// Hide our own full-screen overlay while note_detect's
// Calibration Wizard runs on top. That wizard goes
// transparent (pointer-events:none) when it minimizes to
// expose the Tuner; if our overlay stayed up it would show
// through — covering the tuner with the still-mounted
// "select your input" card and looking like a second
// wizard at the input step. Restore it on done/cancel
// (one of which always fires when that wizard closes).
// Hide only the inner card while note_detect's Calibration
// Wizard runs on top — keep the overlay element in the DOM
// so its backdrop-blur stays active. The wizard goes
// pointer-events:none when it minimizes to expose the Tuner;
// hiding just the card prevents the "select your input" panel
// from showing through without removing the background blur.
// Restore on done/cancel (one always fires when wizard closes).
const ov = document.getElementById('input-setup-overlay');
const prevDisplay = ov ? ov.style.display : '';
if (ov) ov.style.display = 'none';
const card = ov && ov.querySelector('[data-is-host]');
const prevDisplay = card ? card.style.display : '';
if (card) card.style.display = 'none';
const restore = () => {
const o = document.getElementById('input-setup-overlay');
if (o) o.style.display = prevDisplay;
const c = o && o.querySelector('[data-is-host]');
if (c) c.style.display = prevDisplay;
};
window.noteDetect.launchCalibration({
instrument: inst,
+3 -15
View File
@@ -855,12 +855,9 @@
<div id="v3-live-performance-state" class="v3-live-performance-state" aria-hidden="true"></div>
</div>
<div id="v3-upnext" class="v3-upnext hidden">
<div class="v3-upnext-row">
<span class="text-gray-400">Up Next:</span>
<span id="v3-upnext-name" class="v3-upnext-name"></span>
<span id="v3-upnext-eta" class="text-gray-400 text-xs"></span>
</div>
<div class="v3-upnext-bar"><div id="v3-upnext-bar-fill" class="v3-upnext-bar-fill"></div></div>
<span class="text-gray-400">Up Next:</span>
<span id="v3-upnext-name" class="v3-upnext-name"></span>
<span id="v3-upnext-eta" class="text-gray-400 text-xs"></span>
</div>
</div>
</div>
@@ -926,15 +923,6 @@
<option value="0.5">Low</option>
</select>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label" id="min-scale-label">Min res</span>
<select id="min-scale-select" onchange="highway.setMinRenderScale && highway.setMinRenderScale(parseFloat(this.value))" class="v3-pop-select" aria-labelledby="min-scale-label" title="Minimum auto resolution — how far the highway may lower its resolution to hold the frame rate on heavy scenes. 'Full' disables auto-downscaling, but the Quality selector still caps the maximum (so it's only full resolution at Quality = HD).">
<option value="0.25">25%</option>
<option value="0.5">50%</option>
<option value="0.75">75%</option>
<option value="1">Full</option>
</select>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label" id="scoreboard-label">Scoreboard</span>
<select id="scoreboard-select" onchange="setScoreboard(this.value)" class="v3-pop-select" aria-labelledby="scoreboard-label" title="Highway scoreboard">
-13
View File
@@ -187,19 +187,6 @@
const nm = $('v3-upnext-name'), eta = $('v3-upnext-eta');
if (nm) nm.textContent = next.name || '—';
if (eta) eta.textContent = 'in ' + dt.toFixed(1) + 's';
// Progress bar: fraction of the current section elapsed toward `next`.
// Previous boundary is the last section at/before now (else song start).
const fill = $('v3-upnext-bar-fill');
if (fill) {
let prevT = 0;
for (let i = 0; i < secs.length; i++) {
if (typeof secs[i].time === 'number' && secs[i].time <= t) prevT = secs[i].time;
else break;
}
const span = next.time - prevT;
const prog = span > 0 ? Math.max(0, Math.min(1, (t - prevT) / span)) : 0;
fill.style.width = (prog * 100).toFixed(1) + '%';
}
pill.classList.remove('hidden');
}
+24 -113
View File
@@ -815,122 +815,33 @@
finishBatch();
}
// Modern "Add to playlist" picker - replaces the old run-on numbered prompt
// ("1. Foo 2. Bar ...", which didn't scale past a couple of playlists). Shows a
// checkbox list of playlists with membership PRE-CHECK (a song already in a
// playlist shows checked; a multi-song selection shows the indeterminate box
// when only some are in), an inline "New playlist" row, and a search box once
// the list is long. Toggling a row adds/removes the given songs via the
// existing REST; only rows the user actually TOUCHED are changed. Resolves
// true if any change was applied, else null (cancelled / no-op). Shared by the
// per-card more-menu and the select-mode batch bar.
function openPlaylistPicker(fns) {
return new Promise((resolve) => { (async () => {
const lists = ((await jget('/api/playlists')) || []).filter((p) => !p.system_key);
// Pre-check membership: fetch each playlist's songs once (playlists are
// few, and this is a one-off on open, not a hot path). ALL selected in
// -> checked; SOME -> indeterminate.
const counts = await Promise.all(lists.map(async (p) => {
const pl = await jget('/api/playlists/' + p.id);
const has = new Set(((pl && pl.songs) || []).map((s) => s.filename));
return fns.reduce((n, fn) => n + (has.has(fn) ? 1 : 0), 0);
}));
const rows = lists.map((p, i) => ({
id: p.id, name: p.name, count: p.count || 0,
initial: counts[i] === fns.length ? 'all' : (counts[i] > 0 ? 'some' : 'none'),
checked: counts[i] === fns.length, touched: false,
}));
const overlay = document.createElement('div');
overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
let query = '';
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(null); } };
function done(applied) { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(applied || null); }
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(null); });
const boxFor = (r) => r.touched ? (r.checked ? '☑' : '☐')
: (r.initial === 'all' ? '☑' : r.initial === 'some' ? '▣' : '☐');
function rowHtml(r) {
return '<button type="button" data-pl="' + esc(String(r.id)) + '" class="w-full flex items-center gap-3 px-3 py-2 rounded-md hover:bg-white/5 text-left">' +
'<span class="text-lg leading-none w-5 text-fb-primary">' + boxFor(r) + '</span>' +
'<span class="flex-1 truncate text-sm text-fb-text">' + esc(r.name) + '</span>' +
'<span class="text-xs text-fb-textDim">' + (r.count || 0) + '</span></button>';
}
function listHtml() {
const q = query.trim().toLowerCase();
const shown = q ? rows.filter((r) => r.name.toLowerCase().includes(q)) : rows;
if (!shown.length) return '<p class="text-sm text-fb-textDim text-center py-6">' + (rows.length ? 'No matches.' : 'No playlists yet - create one above.') + '</p>';
return shown.map(rowHtml).join('');
}
function bindRows() {
overlay.querySelectorAll('[data-pl]').forEach((b) => b.addEventListener('click', () => {
const r = rows.find((x) => String(x.id) === b.getAttribute('data-pl'));
if (!r) return;
r.touched = true; r.checked = !r.checked;
repaintList();
}));
}
function repaintList() { const el = overlay.querySelector('[data-list]'); if (el) { el.innerHTML = listHtml(); bindRows(); } }
async function createNew() {
const inp = overlay.querySelector('[data-new]');
const name = ((inp && inp.value) || '').trim();
if (!name) return;
const created = await jsend('POST', '/api/playlists', { name });
if (created && created.id) {
rows.unshift({ id: created.id, name: created.name || name, count: 0, initial: 'none', checked: true, touched: true });
query = ''; paint();
overlay.querySelector('[data-new]')?.focus();
}
}
async function apply() {
// Act only where the final state differs from what's already stored.
const acts = rows.filter((r) => r.touched && ((r.checked && r.initial !== 'all') || (!r.checked && r.initial !== 'none')));
for (const r of acts) {
for (const fn of fns) {
try {
if (r.checked) await fetch('/api/playlists/' + r.id + '/songs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) });
else await fetch('/api/playlists/' + r.id + '/songs/' + encodeURIComponent(fn), { method: 'DELETE' });
} catch (e) { /* */ }
}
}
if (window.v3Playlists) { try { window.v3Playlists.refresh(); } catch (e) { /* */ } }
if (acts.length && window.fbNotify) {
try { window.fbNotify.show({ title: 'Playlists updated', message: 'Updated ' + acts.length + ' playlist' + (acts.length === 1 ? '' : 's'), icon: '\U0001f3b5' }); } catch (e) { /* */ }
}
done(acts.length ? true : null);
}
function paint() {
overlay.innerHTML =
'<div class="bg-fb-card rounded-xl border border-fb-border/50 w-full max-w-md p-5 space-y-4" role="dialog" aria-label="Add to playlist">' +
'<div class="flex items-center justify-between"><h3 class="text-lg font-semibold text-fb-text">Add ' + fns.length + ' song' + (fns.length === 1 ? '' : 's') + ' to playlist</h3>' +
'<button type="button" data-x class="text-fb-textDim hover:text-fb-text text-xl leading-none">✕</button></div>' +
'<div class="flex gap-2"><input data-new type="text" placeholder="+ New playlist name" class="' + btnCtrl + ' flex-1"><button type="button" data-create class="bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text px-3 rounded-md text-sm">Create</button></div>' +
(rows.length > 8 ? '<input data-search type="text" placeholder="Search playlists..." class="' + btnCtrl + ' w-full" value="' + esc(query) + '">' : '') +
'<div data-list class="max-h-72 overflow-y-auto v3-scroll -mx-1 px-1">' + listHtml() + '</div>' +
'<div class="flex justify-end"><button type="button" data-done class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-5 py-2 rounded-md">Done</button></div>' +
'</div>';
overlay.querySelector('[data-x]').addEventListener('click', () => done(null));
overlay.querySelector('[data-done]').addEventListener('click', apply);
overlay.querySelector('[data-create]').addEventListener('click', createNew);
overlay.querySelector('[data-new]').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); createNew(); } });
const s = overlay.querySelector('[data-search]');
if (s) s.addEventListener('input', (e) => { query = e.target.value; repaintList(); });
bindRows();
}
document.body.appendChild(overlay);
paint();
(overlay.querySelector('[data-search]') || overlay.querySelector('[data-new]'))?.focus();
})(); });
}
// Open the picker for the given song filenames. Shared by the select-mode
// batch bar and the per-card more-menu. Resolves truthy if a change was applied.
// Prompt for a target playlist (pick a listed number, or type a new name to
// create it) and add the given song filenames to it. Shared by the
// select-mode batch bar and the per-card ⋮ menu's single-song add. Returns
// the playlist id (or null if cancelled).
async function addFilenamesToPlaylist(filenames) {
const fns = Array.from(filenames || []);
if (!fns.length) return null;
return openPlaylistPicker(fns);
const lists = (await jget('/api/playlists')) || [];
const choices = lists.filter((p) => !p.system_key);
const labels = choices.map((p, i) => (i + 1) + '. ' + p.name).join(' ');
const ans = ((await window.uiPrompt({
title: 'Add ' + fns.length + ' song' + (fns.length === 1 ? '' : 's') + ' to a playlist',
label: (labels ? labels + '' : '') + 'Type a number above, or a new playlist name:',
okLabel: 'Add',
placeholder: 'Number or new playlist name',
})) || '').trim();
if (!ans) return null;
let pid = null;
const num = parseInt(ans, 10);
if (!isNaN(num) && choices[num - 1]) pid = choices[num - 1].id;
else { const created = await jsend('POST', '/api/playlists', { name: ans }); pid = created && created.id; }
if (!pid) return null;
for (const fn of fns) {
try { await fetch('/api/playlists/' + pid + '/songs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); } catch (e) { /* */ }
}
if (window.v3Playlists) { try { window.v3Playlists.refresh(); } catch (e) { /* */ } }
return pid;
}
async function batchAddToPlaylist() {
+2 -23
View File
@@ -340,9 +340,8 @@ input, textarea, select,
/* — Up Next pill (top-right, persistent) — */
#player-hud .v3-upnext {
display: flex;
flex-direction: column;
align-items: stretch;
gap: .35rem;
align-items: center;
gap: .5rem;
padding: .45rem .9rem;
border-radius: .75rem;
background: rgba(15, 23, 42, .7);
@@ -352,26 +351,6 @@ input, textarea, select,
pointer-events: auto;
}
#player-hud .v3-upnext.hidden { display: none; }
/* Text row keeps the original inline layout untouched. */
#player-hud .v3-upnext .v3-upnext-row {
display: flex;
align-items: center;
gap: .5rem;
}
/* Progress bar under the text — fills as the current section elapses. */
#player-hud .v3-upnext .v3-upnext-bar {
height: 4px;
border-radius: 999px;
background: rgba(148, 163, 184, .25);
overflow: hidden;
}
#player-hud .v3-upnext .v3-upnext-bar-fill {
height: 100%;
width: 0%;
border-radius: inherit;
background: linear-gradient(90deg, #22d3ee, #a855f7, #f472b6);
transition: width .12s linear;
}
/* — Live performance HUD (top-right, read-only) — */
.v3-live-performance-hud {
-174
View File
@@ -1,174 +0,0 @@
// Pins the wide-pane horizontal-FOV-hold ("Hor+") framing in
// plugins/highway_3d/screen.js.
//
// What it guards: ultra-wide panes (top/bottom 2-player split → full-width /
// half-height → ~32:9) used to render the neck as a thin central sliver because
// THREE's PerspectiveCamera fov is VERTICAL and was locked at 70°, ballooning
// the horizontal cone past 130°. The fix lets camUpdate lower the effective
// vertical fov as the pane widens (holding the horizontal cone ~constant) so the
// neck fills the pane. It is gated behind window.__h3dAspectTune (default off →
// byte-for-byte the prior behaviour) for live A/B comparison.
//
// A refactor that re-hardcodes the camera fov, drops the change-guarded cam.fov
// write, stops caching the pane aspect, or removes the no-op-at-startAspect
// guarantee would silently regress the feature (or worse, change normal-pane
// framing). These are source-level pins — same strategy as the other
// tests/js/ files (no DOM / WebGL in CI).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// ── Constants ────────────────────────────────────────────────────────────────
test('BASE_VFOV is a named constant (not a literal in the camera ctor)', () => {
assert.match(
src,
/const\s+BASE_VFOV\s*=\s*70\s*;/,
'BASE_VFOV must be declared as a constant',
);
});
test('the camera is constructed with BASE_VFOV, not a bare 70', () => {
assert.match(
src,
/new\s+T\.PerspectiveCamera\(\s*BASE_VFOV\s*,/,
'PerspectiveCamera must take BASE_VFOV as its vertical fov',
);
});
test('the Hor+ start-aspect and min-vfov defaults exist', () => {
assert.match(src, /const\s+HORPLUS_START_ASPECT\s*=\s*16\s*\/\s*9\s*;/,
'HORPLUS_START_ASPECT must default to 16/9 (no-op at/under the reference aspect)');
assert.match(src, /const\s+HORPLUS_MIN_VFOV\s*=\s*\d+\s*;/,
'HORPLUS_MIN_VFOV floor must be declared');
});
// ── effectiveVfov: no-op guarantees ──────────────────────────────────────────
test('effectiveVfov returns the base fov when the bridge is off/absent', () => {
// The disabled / malformed-input guard returns `base` before any Hor+ math,
// so normal panes are unaffected when __h3dAspectTune is missing or off.
assert.match(
src,
/function\s+effectiveVfov\s*\(\s*aspect\s*,\s*tune\s*\)\s*\{[\s\S]*?if\s*\(\s*!tune\s*\|\|\s*!tune\.enabled[\s\S]*?return\s+base\s*;/,
'effectiveVfov must short-circuit to the base fov when disabled',
);
});
test('effectiveVfov is a no-op at/under the start aspect', () => {
assert.match(
src,
/if\s*\(\s*aspect\s*<=\s*start\s*\)\s*return\s+base\s*;/,
'effectiveVfov must return base when aspect <= start (no-op for normal/2x2 panes)',
);
});
// ── shipped defaults: off + coherent ─────────────────────────────────────────
// The "default off → byte-for-byte prior behaviour" contract only holds if the
// shipped _ASPECT_DEFAULTS actually ship disabled with a base that matches the
// camera's constructed fov. A previous revision shipped enabled:true with
// baseVfov:30 (and blend:0), which forced every pane's fov to 30/36 and
// silently re-framed normal single-player panes. These pin against that.
test('_ASPECT_DEFAULTS ships disabled (no-op out of the box)', () => {
assert.match(
src,
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\benabled\s*:\s*false\b/,
'_ASPECT_DEFAULTS.enabled must default to false so the feature is opt-in',
);
});
test('the default base fov matches BASE_VFOV (enabling is still a no-op on normal panes)', () => {
// baseVfov === BASE_VFOV means even with the feature ON, a <=startAspect pane
// returns the unchanged 70° — the effect is confined to genuinely wide panes.
assert.match(
src,
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bbaseVfov\s*:\s*BASE_VFOV\b/,
'_ASPECT_DEFAULTS.baseVfov must default to BASE_VFOV, not a divergent literal',
);
});
test('the default blend engages the hold and the floor sits below the base', () => {
// blend:1 means turning the feature on actually holds the horizontal cone
// (blend:0 would collapse effectiveVfov back to base = feature inert), and
// minVfovDeg:HORPLUS_MIN_VFOV keeps the floor below baseVfov (a real floor,
// not one that clamps the base upward).
assert.match(
src,
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bblend\s*:\s*1\b/,
'_ASPECT_DEFAULTS.blend must default to 1 so the Hor+ hold actually applies when enabled',
);
assert.match(
src,
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bminVfovDeg\s*:\s*HORPLUS_MIN_VFOV\b/,
'_ASPECT_DEFAULTS.minVfovDeg must default to HORPLUS_MIN_VFOV (a floor below baseVfov)',
);
});
// ── camUpdate: change-guarded fov write + cached aspect ───────────────────────
test('applySize caches the pane aspect for camUpdate', () => {
assert.match(
src,
/_paneAspect\s*=\s*cam\.aspect\s*;/,
'applySize must cache cam.aspect into _paneAspect',
);
});
test('camUpdate reads the live tune bridge and respects splitOnly', () => {
assert.match(
src,
/const\s+_aspTune\s*=\s*_aspectTune\(\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/,
'camUpdate must read the bridge via _aspectTune() and gate splitOnly on _ssActive()',
);
});
test('the tune bridge seeds from localStorage (persisted sessions apply on load)', () => {
assert.match(
src,
/function\s+_aspectTune\s*\(\)[\s\S]*?localStorage\.getItem\(\s*_ASPECT_LS\s*\)/,
'_aspectTune() must seed the bridge from localStorage',
);
});
test('a floating tuner panel is built and toggled with the A/B state', () => {
assert.match(src, /function\s+_ensureAspectPanel\s*\(\)/,
'_ensureAspectPanel() must exist to build the live panel');
assert.match(src, /function\s+_setAspectPanelVisible\s*\(/,
'_setAspectPanelVisible() must show/hide the panel with the feature');
});
test('camUpdate only writes cam.fov when it actually changes', () => {
// Guarding the write avoids a per-frame updateProjectionMatrix on a steady
// pane and keeps the disabled path free.
assert.match(
src,
/Math\.abs\(\s*_vfov\s*-\s*cam\.fov\s*\)\s*>\s*1e-4[\s\S]*?cam\.fov\s*=\s*_vfov\s*;[\s\S]*?cam\.updateProjectionMatrix\(\)/,
'camUpdate must guard the cam.fov write behind a change check',
);
});
// ── A/B toggle + lifecycle reset ──────────────────────────────────────────────
test('an A/B toggle shortcut flips the tune enabled flag', () => {
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',
);
});
test('destroy() resets the pane aspect and restores the base fov', () => {
assert.match(src, /_paneAspect\s*=\s*0\s*;/,
'destroy() must reset _paneAspect to 0');
assert.match(
src,
/cam\.fov\s*!==\s*BASE_VFOV[\s\S]*?cam\.fov\s*=\s*BASE_VFOV\s*;\s*cam\.updateProjectionMatrix\(\)/,
'destroy() must restore cam.fov to BASE_VFOV for instance reuse',
);
});