Compare commits

..
Author SHA1 Message Date
ChrisBeWithYouandClaude Opus 4.8 6e410ece40 feat(v3): modern "Add to playlist" picker (replaces run-on prompt)
The old add-to-playlist flow crammed every playlist into one uiPrompt label
("1. Foo  2. Bar  3. Baz ...") and asked the user to type a number -- unreadable
past a couple of playlists, and reported as a bug.

Replace it with openPlaylistPicker: a checkbox modal with membership pre-check (a
song already in a playlist shows checked; a multi-song selection shows an
indeterminate box when only some are in), an inline "+ New playlist" row, and a
search box once the list is long. Toggling adds/removes via the existing REST
(POST + DELETE .../songs/{filename}); only playlists the user actually touched
change. One shared function still feeds the per-card menu, the batch bar, and the
batch button, so the fix lands in all three. Escape / backdrop close; a
bottom-right fbNotify toast confirms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
2026-07-01 01:01:32 -05:00
f9607c5c94 Port the Min res (minimum auto-resolution) selector into the v3 player (#663)
The v2 control bar exposes a "Min res" selector next to Quality that sets
highway.setMinRenderScale — capping how far the load-adaptive resolution
scaler (feedBack#654) may downscale, or disabling it entirely (Full). The
v3 UI only ported the Quality selector, so v3 users had no way to stop the
highway auto-downscaling to as low as quarter-res on heavy scenes / weak-
GPU launches — pixelated even at Quality = HD, with no workaround (worse
than v2).

Add the Min res row to the v3 viz/quality rail popover, under Quality,
mirroring the v2 control (same options, handler, title, aria). The handler,
the setMinRenderScale/getMinRenderScale API, and the shared app.js init
that syncs the selector's value (guarded by element id) all already exist —
only the v3 markup was missing.

Fixes #662

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:19:08 +02:00
3e3a98a0d0 Aspect-aware framing for ultra-wide 3D highway panes (#652)
* Add aspect-aware framing for ultra-wide highway panes

On a top/bottom 2-player split each 3D highway pane is full-width /
half-height (~32:9). The camera's vertical FOV was locked at a single
value, so at that aspect the horizontal cone ballooned past 130deg and
squeezed the fixed-width neck into a thin central sliver with large dead
margins on either side.

Add a "horizontal-FOV-hold" path: past a configurable start aspect the
effective vertical FOV is lowered so the horizontal cone stays roughly
constant, letting the neck fill a wide pane. At/under the start aspect it
is an exact no-op, so normal ~16:9 single-player and most 2x2 panes are
unchanged. Optional pose nudges (height / dolly / pitch / look-depth)
further flatten the view toward a low, immersive angle.

Everything is driven by a runtime bridge (window.__h3dAspectTune) with a
live tuner panel (Shift+A in the player) exposing every knob plus a live
aspect/FOV readout, localStorage persistence, and a Copy button. Toggling
the feature off restores the exact prior framing, so it doubles as an A/B
control. Shipped on by default for wide panes for testing feedback.

Source-pinned by tests/js/highway_3d_wide_fov.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>

* fix(highway_3d): ship wide-pane framing default-OFF with a coherent config

Review fixes for the aspect-aware framing. The first cut shipped
_ASPECT_DEFAULTS = { enabled:true, baseVfov:30, blend:0, minVfovDeg:36 },
which contradicted the PR's own "default off → byte-for-byte prior behaviour"
claim:

- enabled:true made the tune active for everyone, and baseVfov:30 forced every
  pane's vertical fov from 70° to 30° (normal single-player/2x2 panes included —
  a drastic global zoom, not the advertised no-op).
- blend:0 collapsed the Hor+ math back to base, so the actual horizontal-FOV-
  hold did nothing even on wide panes — the only net effect was the zoom.
- minVfovDeg:36 > baseVfov:30 was an inverted floor (clamped wide panes UP to
  36° rather than flooring a real reduction).

New defaults: { enabled:false, baseVfov:BASE_VFOV(70), blend:1,
minVfovDeg:HORPLUS_MIN_VFOV(28) }. Now:

- OFF by default → camUpdate passes a null tune → effectiveVfov returns
  BASE_VFOV → exact no-op on every pane (verified: 70° at 16:9 and 32:9).
- When a tester enables it (Shift+A), baseVfov==BASE_VFOV keeps normal/≤start
  panes at 70° (still a no-op there) and blend:1 makes the hold actually engage
  on genuinely wide panes (47.7° at 32:9, flooring toward 28° as aspect grows).
- minVfovDeg < baseVfov is a real floor.

Also bumps the localStorage key (h3d_aspect_tune → h3d_aspect_tune2) so a
machine that persisted the old broken default gets the corrected one, and adds
source-pin tests guarding default-off + the coherent base/blend/floor so this
can't silently regress to default-on again. The pose-nudge values are left as
the author's in-progress wide-pane look (dormant until enabled). 110/110 tests
pass; node --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-30 17:36:32 +02:00
db81d7dafb Add progress bar to v3 "Up Next" pill (#649)
The persistent top-right "Up Next" pill showed the upcoming section name
and a countdown ("in 12.3s") but no at-a-glance sense of how far through
the current section the song is. Add a thin progress bar directly under
the existing text that fills as the current section elapses toward the
next, reaching full when the section flips.

The text row is wrapped unchanged in a flex row and the pill stacks the
bar beneath it; nothing else about the pill's content or styling changes.
Progress is computed in updateUpNext() as the fraction elapsed between the
previous section boundary (last section at/before now, else song start)
and the next section. The fill uses the same gradient as the section name
for visual cohesion.

Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:44:09 +02:00
7 changed files with 701 additions and 45 deletions
+352 -4
View File
@@ -1083,6 +1083,22 @@
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.
@@ -1591,6 +1607,257 @@
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)
*
@@ -3498,6 +3765,11 @@
// 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
@@ -5976,7 +6248,7 @@
scene = new T.Scene();
scene.fog = new T.Fog(0x101820, FOG_START * 0.8, FOG_END * 1.2);
cam = new T.PerspectiveCamera(70, 1, 0.01, FOG_END * 3);
cam = new T.PerspectiveCamera(BASE_VFOV, 1, 0.01, FOG_END * 3);
ambLight = new T.AmbientLight(0xffffff, 0.85);
scene.add(ambLight);
@@ -13915,11 +14187,77 @@
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.
@@ -13935,6 +14273,9 @@
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.
@@ -13943,7 +14284,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;
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
if (_freeCam && _freeCam.enabled) {
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
const _heightMul = Number.isFinite(_freeCam.heightMul) ? _freeCam.heightMul : 1;
@@ -13964,7 +14305,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, -FOCUS_D * 0.35); // tentative look — needed for project()
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ); // tentative look — needed for project()
cam.updateMatrixWorld();
_probe.project(cam); // _probe.y → NDC in [-1, 1]
@@ -13993,7 +14334,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, _lookAtZ);
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ);
}
// ── Fret-row fit guard ────────────────────────────────────────────
@@ -14090,6 +14431,10 @@
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;
}
@@ -14333,6 +14678,7 @@
}
_destroyed = _isReady = false;
_isFocused = true;
_registerAspectAbShortcut(); // session-global A/B toggle (self-guarded)
const myToken = ++_initToken;
highwayCanvas = canvas;
_invertedCached = !!(bundle && bundle.inverted);
@@ -14751,6 +15097,8 @@
_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;
+11 -12
View File
@@ -164,21 +164,20 @@
host.querySelector('[data-is-cal]').addEventListener('click', () => {
if (hasDetector) {
// 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).
// 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).
const ov = document.getElementById('input-setup-overlay');
const card = ov && ov.querySelector('[data-is-host]');
const prevDisplay = card ? card.style.display : '';
if (card) card.style.display = 'none';
const prevDisplay = ov ? ov.style.display : '';
if (ov) ov.style.display = 'none';
const restore = () => {
const o = document.getElementById('input-setup-overlay');
const c = o && o.querySelector('[data-is-host]');
if (c) c.style.display = prevDisplay;
if (o) o.style.display = prevDisplay;
};
window.noteDetect.launchCalibration({
instrument: inst,
+15 -3
View File
@@ -855,9 +855,12 @@
<div id="v3-live-performance-state" class="v3-live-performance-state" aria-hidden="true"></div>
</div>
<div id="v3-upnext" class="v3-upnext hidden">
<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 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>
</div>
</div>
</div>
@@ -923,6 +926,15 @@
<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,6 +187,19 @@
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');
}
+113 -24
View File
@@ -815,33 +815,122 @@
finishBatch();
}
// 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).
// 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.
async function addFilenamesToPlaylist(filenames) {
const fns = Array.from(filenames || []);
if (!fns.length) return null;
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;
return openPlaylistPicker(fns);
}
async function batchAddToPlaylist() {
+23 -2
View File
@@ -340,8 +340,9 @@ input, textarea, select,
/* — Up Next pill (top-right, persistent) — */
#player-hud .v3-upnext {
display: flex;
align-items: center;
gap: .5rem;
flex-direction: column;
align-items: stretch;
gap: .35rem;
padding: .45rem .9rem;
border-radius: .75rem;
background: rgba(15, 23, 42, .7);
@@ -351,6 +352,26 @@ 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
@@ -0,0 +1,174 @@
// 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',
);
});