feedBack/static/js/viz.js
Byron Gamatos d9fa6d3f55
refactor(highway): make the highway global explicit before the module flip (R3c) (#912)
73 bare `highway.x` references -> `window.highway.x`, across app.js and 10 other files.
Provably a NO-OP today. It is the precondition for flipping highway.js to a module.

━━━ WHY THIS HAS TO LAND FIRST ━━━

highway.js is a CLASSIC script. Its top-level `const highway = createHighway()` therefore
creates a GLOBAL LEXICAL BINDING — visible as a bare name to every other classic script AND
to every ES module. 73 call sites quietly rely on that.

The moment highway.js becomes a module, that binding is gone. `const` in a module is
module-scoped, not global. Every one of those 73 sites becomes a ReferenceError, and the
flip is impossible until they say what they mean.

`window.highway = highway` is already set, to the same object, on the same line. So this is
an identity rewrite — verified in the browser below.

━━━ THE REWRITE BIT ME THREE TIMES. REGEX IS NOT ENOUGH FOR THIS. ━━━

1. A SHADOWED LOCAL. capabilities/note-detection.js does `const highway = window.highway`.
   Its 9 bare uses are LOCAL and already correct; a blind rewrite would have emitted
   `const window.highway = window.highway`. Excluded.

2. HALF-CONVERTED GUARDS — the dangerous one. Six sites read
   `typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function'`.
   The regex converted the CONSEQUENT and left the TEST, which is WORSE than not touching
   them: after the flip `typeof highway` is 'undefined', so each guard is PERMANENTLY FALSE
   and the code behind it silently never runs. transport.js's was the seek->setTime sync:
   the chart clock would have quietly desynced after every seek, with nothing failing.
   All six now test window.highway.

3. TWO MORE BARE REFERENCES, found by Codex [P2] and confirmed by an AST scan: app.js:3114
   and :3176 use `highway && typeof window.highway.getSections === 'function'`. My grep
   searched for `typeof highway`, not `highway &&`. After the flip these throw, the catch
   swallows it, and the editor silently falls back to a ±4s edit window and arrangement 0.

Regex missed a shadow, a half-conversion, and two bare reads. The final check is an
AST pass that resolves scopes and reports every `highway` identifier not bound locally.
It now reports ZERO.

VERIFIED. A/B against origin/main in two browsers, 15 probes, IDENTICAL, zero page errors:
window.highway is the same object as the bare global, the whole API surface resolves, a real
song plays, the chart clock advances, getPerf().drawMs > 0 — and `seek syncs chart` passes,
which is the exact guard I nearly broke in (2).

node 1045, pytest 2416, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:56:07 +02:00

771 lines
38 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// The visualization layer — the viz picker, renderer selection, and Auto-match.
//
// Carved verbatim out of static/app.js (R3a). A LEAF module: it imports NOTHING,
// which is what lets static/js/plugin-loader.js take _populateVizPicker straight
// from here and drop the configurePluginLoader() host seam it needed while this
// code still lived in app.js.
//
// It owns the state behind those decisions (the one-shot WebGL2 probe, the
// 3D-promotion flag, the Auto label, the notation-hint memo) — all
// module-private, because nothing outside reads them.
// ── Visualization picker (feedBack#36) ─────────────────────────────────
//
// Discovers viz plugins via /api/plugins and adds them to the #viz-picker
// dropdown. A viz plugin declares itself by setting `"type": "visualization"`
// in its plugin.json AND exposing a factory function on
// window.feedBackViz_<id> that returns an object matching the setRenderer
// contract ({init, draw, resize, destroy}).
//
// The "default" option in the dropdown is the built-in 2D highway that
// lives inside createHighway(); selecting it calls setRenderer(null) which
// restores the default renderer. The bundled 3D Highway plugin
// (plugins/highway_3d/) registers as id `highway_3d` and is the new
// fresh-install default per feedBack#160 PR 3.
// ── WebGL2 detection (one-shot probe) ────────────────────────────────────
// 3D Highway requires WebGL2. On environments where it's unavailable
// (older browsers, some embedded webviews, software-only contexts), we
// silently fall back to the Classic 2D Highway and flash a single toast
// so the user knows why their highway looks different. Cached so we don't
// thrash the GPU with repeat throwaway-canvas creations.
let _webgl2Probe = null;
function _canRun3D() {
if (_webgl2Probe !== null) return _webgl2Probe;
try {
const c = document.createElement('canvas');
const gl = c.getContext('webgl2');
_webgl2Probe = !!gl;
// Lose the context immediately — the probe canvas is never reused.
if (gl && gl.getExtension) {
const ext = gl.getExtension('WEBGL_lose_context');
if (ext && ext.loseContext) ext.loseContext();
}
} catch (_) { _webgl2Probe = false; }
return _webgl2Probe;
}
// ── Migration / nag flags ────────────────────────────────────────────────
// `feedBack_3d_promoted_v1` is set the first time we auto-flip an existing
// `vizSelection='default'` user to `'highway_3d'`. Persistence ensures we
// don't re-nag on every reload — and ensures the WebGL2 fallback path
// doesn't ping-pong (one fallback toast, not one per page load).
const _3D_PROMOTED_FLAG_KEY = 'feedBack_3d_promoted_v1';
function _markPromoted() {
try { localStorage.setItem(_3D_PROMOTED_FLAG_KEY, '1'); } catch (_) {}
}
function _hasPromotedFlag() {
try { return localStorage.getItem(_3D_PROMOTED_FLAG_KEY) === '1'; }
catch (_) { return false; }
}
// Pending nag: queued during _populateVizPicker, fired on the first
// `song:ready` (so the toast lands when the user actually opens the
// player, not at page load when they're still in the library).
// `song:ready` is emitted by window.highway.js via window.feedBack.emit(), so
// subscribe through the same EventTarget. window.feedBack is created in
// this same file before _populateVizPicker is reachable, so the global
// is guaranteed to exist by the time this listener registers — but guard
// anyway in case this module is ever loaded standalone for tests.
let _pendingPromotionNag = false;
if (window.feedBack && typeof window.feedBack.on === 'function') {
window.feedBack.on('song:ready', () => {
if (!_pendingPromotionNag) return;
_pendingPromotionNag = false;
_showPromotionNag();
});
}
function _showPromotionNag() {
// Lightweight toast — no dependency on a generic toast helper, since
// app.js doesn't currently have one. Fixed bottom-center, dismissed
// by clicking either action button or the × close.
const existing = document.getElementById('feedBack-3d-nag');
if (existing) existing.remove();
const wrap = document.createElement('div');
wrap.id = 'feedBack-3d-nag';
wrap.setAttribute('role', 'dialog');
wrap.setAttribute('aria-modal', 'false');
wrap.setAttribute('aria-label', '3D Highway upgrade notification');
wrap.style.cssText = `
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
background: linear-gradient(145deg, #1a1a30 0%, #0d0d18 100%);
border: 1px solid rgba(64,128,224,0.4);
border-radius: 12px; padding: 12px 16px;
box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 1px rgba(64,128,224,0.15);
font-size: 13px; color: #e2e8f0; z-index: 10000;
max-width: 480px; display: flex; align-items: center; gap: 12px;
`;
wrap.innerHTML = `
<span aria-live="polite" style="flex:1;">Your highway was upgraded to <strong>3D</strong>.</span>
<button type="button" data-act="tour" style="background:rgba(64,128,224,0.25);color:#e2e8f0;border:1px solid rgba(64,128,224,0.5);padding:6px 12px;border-radius:8px;font-size:12px;cursor:pointer;">Try the tour</button>
<button type="button" data-act="back" style="background:transparent;color:#cbd5e1;border:1px solid rgba(255,255,255,0.1);padding:6px 12px;border-radius:8px;font-size:12px;cursor:pointer;">Switch back to 2D</button>
<button type="button" data-act="dismiss" aria-label="Dismiss" style="background:transparent;color:#6b7280;border:none;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">×</button>
`;
wrap.addEventListener('click', (ev) => {
const btn = ev.target.closest('button[data-act]');
if (!btn) return;
const act = btn.dataset.act;
if (act === 'tour') {
try {
if (window.feedBackTour && typeof window.feedBackTour.start === 'function') {
window.feedBackTour.start('highway_3d');
}
} catch (_) {}
} else if (act === 'back') {
setViz('default');
}
wrap.remove();
});
document.body.appendChild(wrap);
}
function _showWebGL2FallbackToast() {
// One-time fallback notice. Same lightweight DOM as the nag, simpler
// copy and only a dismiss button.
if (document.getElementById('feedBack-3d-fallback')) return;
const wrap = document.createElement('div');
wrap.id = 'feedBack-3d-fallback';
wrap.setAttribute('role', 'dialog');
wrap.setAttribute('aria-modal', 'false');
wrap.setAttribute('aria-label', 'WebGL2 not available');
wrap.style.cssText = `
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
background: #181830; border: 1px solid rgba(255,180,80,0.4);
border-radius: 12px; padding: 10px 14px;
font-size: 12px; color: #e2e8f0; z-index: 10000;
display: flex; align-items: center; gap: 10px;
`;
wrap.innerHTML = `
<span aria-live="polite">3D Highway needs WebGL2 — falling back to Classic 2D.</span>
<button type="button" data-act="dismiss" aria-label="Dismiss" style="background:transparent;color:#6b7280;border:none;font-size:16px;cursor:pointer;padding:0 4px;line-height:1;">×</button>
`;
wrap.addEventListener('click', (ev) => {
if (ev.target.closest('button[data-act]')) wrap.remove();
});
document.body.appendChild(wrap);
setTimeout(() => { try { wrap.remove(); } catch (_) {} }, 8000);
}
// The "default" option in the dropdown is the built-in 2D highway that
// lives inside createHighway(); selecting it calls setRenderer(null) which
// restores the default renderer.
function _ensureVenueVizOption(sel) {
if (!sel) return;
if (Array.from(sel.options).some(opt => opt.value === 'venue')) return;
if (!Array.from(sel.options).some(opt => opt.value === 'highway_3d')) return;
const h3dOpt = Array.from(sel.options).find(opt => opt.value === 'highway_3d');
const opt = document.createElement('option');
opt.value = 'venue';
opt.textContent = 'Venue';
if (h3dOpt && h3dOpt.nextSibling) sel.insertBefore(opt, h3dOpt.nextSibling);
else sel.appendChild(opt);
}
function _syncVenueVizPlayerClass(vizId) {
if (window.v3VenueViz && typeof window.v3VenueViz.setSelectedVizId === 'function') {
window.v3VenueViz.setSelectedVizId(vizId);
return;
}
if (window.v3VenueViz && typeof window.v3VenueViz.syncPlayerVizClass === 'function') {
window.v3VenueViz.syncPlayerVizClass(vizId);
return;
}
const player = document.getElementById('player');
if (player) player.classList.toggle('is-venue-visualization', vizId === 'venue');
}
export async function _populateVizPicker(plugins) {
const sel = document.getElementById('viz-picker');
if (!sel) return;
// Clear any previously-appended plugin options so calling this
// function more than once (e.g. from DevTools, or a hot-reloaded
// plugin) doesn't produce duplicates. The built-in "auto" and
// "default" options are static markup — preserve them.
const BUILTIN_OPT_VALUES = new Set(['auto', 'default', 'venue']);
Array.from(sel.options).forEach(opt => {
if (!BUILTIN_OPT_VALUES.has(opt.value)) sel.removeChild(opt);
});
// Accept a pre-fetched plugins array (normal startup path reuses
// loadPlugins' fetch). Fall back to our own fetch if called
// standalone — e.g. from the DevTools console for debugging.
if (!Array.isArray(plugins)) {
plugins = [];
try {
const resp = await fetch('/api/plugins');
if (resp.ok) plugins = await resp.json();
} catch (e) {
console.warn('viz picker: /api/plugins fetch failed', e);
}
}
const vizPlugins = plugins.filter(p => p && p.type === 'visualization');
// "default" is reserved for the built-in 2D renderer option and
// "auto" is reserved for the Auto-mode entry — both already in the
// <select>. A plugin with either id would collide: the
// restore-from-localStorage lookup would find the built-in entry,
// dragging the plugin into never-selected land silently. Fail
// loudly instead.
const RESERVED_IDS = new Set(['default', 'auto']);
for (const p of vizPlugins) {
if (RESERVED_IDS.has(p.id)) {
console.error(`viz picker: plugin id '${p.id}' collides with a reserved built-in picker entry ('auto' = Auto mode, 'default' = built-in 2D highway); rename the plugin's id in plugin.json to include it in the picker.`);
continue;
}
// Skip entries where the plugin script hasn't exposed a factory —
// likely means the script failed to load, or the plugin declared
// itself as a viz without shipping the factory yet.
const factoryName = 'feedBackViz_' + p.id;
if (typeof window[factoryName] !== 'function') {
console.warn(`viz picker: plugin '${p.id}' has type=visualization but ${factoryName} is not a function; skipping`);
continue;
}
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name || p.id;
sel.appendChild(opt);
}
_ensureVenueVizOption(sel);
// Refresh the visualization capability domain's provider registry from
// the picker entries just built (the domain host introspects each
// factory global for contextType / predicate metadata).
if (window.feedBack.vizDomain && typeof window.feedBack.vizDomain.refreshProviders === 'function') {
try {
// The host reads manifest-declared per-instance settings
// (capabilities.visualization.settings, feedBack#849) from the
// registered capability participant by id — no need to pass them
// through the picker here.
window.feedBack.vizDomain.refreshProviders(
Array.from(sel.options)
.filter(opt => !BUILTIN_OPT_VALUES.has(opt.value))
.map(opt => ({ id: opt.value, label: opt.text }))
);
} catch (e) { console.warn('viz picker: capability provider refresh failed', e); }
}
// Restore previous selection if still available. Direct option
// scan instead of a CSS-selector lookup so we don't depend on
// CSS.escape (missing in some test environments / older runtimes)
// and so a weird saved string (e.g. with a quote) can't throw.
// localStorage.getItem can itself throw when storage is blocked
// (private mode, sandboxed iframes, some strict test runners);
// fall back to null so the startup chain doesn't abort.
let saved = null;
try { saved = localStorage.getItem('vizSelection'); }
catch (e) { console.warn('viz picker: unable to read vizSelection', e); }
// ── 3D promotion migration (feedBack#160 PR 3) ──────────────────────
// Existing users with `vizSelection='default'` (the old built-in 2D
// highway) are auto-flipped to the bundled 3D Highway exactly once,
// and a non-modal nag toast offers them "Try the tour" / "Switch
// back to 2D" the first time they open the player. Users on `auto`
// are left alone (auto-pick semantics unchanged). Users on a custom
// viz plugin are left alone. WebGL2 absence falls back via setViz.
if (saved === 'default' && !_hasPromotedFlag()) {
const has3D = Array.from(sel.options).some(o => o.value === 'highway_3d');
if (has3D && _canRun3D()) {
saved = 'highway_3d';
try { localStorage.setItem('vizSelection', 'highway_3d'); } catch (_) {}
_markPromoted();
_pendingPromotionNag = true;
// Race guard: if song:ready already fired before _populateVizPicker
// ran (e.g. a deeplink or a fast-loading song), getSongInfo() will
// already be non-empty and we'll never receive another song:ready
// in this session. Show the nag immediately in that case.
const _si = window.highway && window.highway.getSongInfo();
if (_si && _si.title) {
_pendingPromotionNag = false;
_showPromotionNag();
}
} else if (has3D && !_canRun3D()) {
// 3D registered but WebGL2 absent — promote in name but
// immediately fall back so we don't ping-pong on every load.
// Set the flag so we don't try again next reload.
_markPromoted();
_showWebGL2FallbackToast();
}
// No `highway_3d` option (plugin unloaded?) → leave saved as
// 'default'. We'll retry the migration once the plugin is back.
}
const savedMatches = saved && Array.from(sel.options).some(opt => opt.value === saved);
if (savedMatches) {
sel.value = saved;
// 'default' needs no setViz — the highway already starts with
// the built-in renderer. 'auto' runs setViz so _autoMatchViz
// fires, though it's a no-op before the first song_info frame.
if (saved !== 'default') setViz(saved);
} else if (saved) {
// Saved selection references an option that no longer exists —
// plugin uninstalled since last session, renamed, or the plugin
// script failed to register its factory this time. Clear the
// stale value so we don't keep trying the same missing viz on
// every reload, and fall through to the fresh-install default
// below.
try { localStorage.removeItem('vizSelection'); }
catch (_) { /* storage blocked; ignore */ }
saved = null;
}
if (!saved) {
// Fresh install (or post-cleanup fallthrough): default to the
// bundled 3D Highway when available + WebGL2-capable, falling
// back to Auto otherwise so the arrangement-matching plugins
// (piano on Keys songs, drums on Drums songs, ...) still take
// over for non-3D arrangements.
const has3D = Array.from(sel.options).some(o => o.value === 'highway_3d');
if (has3D && _canRun3D()) {
sel.value = 'highway_3d';
try { localStorage.setItem('vizSelection', 'highway_3d'); } catch (_) {}
setViz('highway_3d');
} else {
sel.value = 'auto';
try { localStorage.setItem('vizSelection', 'auto'); } catch (_) {}
if (has3D && !_canRun3D()) { _markPromoted(); _showWebGL2FallbackToast(); }
}
}
// Close a startup race: if playback began before loadPlugins
// finished, song:ready already fired while the picker had no
// plugin options — _autoMatchViz saw no candidates and left the
// default active. Now that plugins are registered, re-evaluate
// against whatever song is currently loaded (a no-op when no song
// has been loaded yet, since window.highway.getSongInfo() returns {}).
if (sel.value === 'auto') _autoMatchViz();
}
function _tagVizRenderer(renderer, id) {
if (!renderer || !id) return renderer;
try {
if (!renderer.pluginId) renderer.pluginId = id;
if (!renderer.source) renderer.source = id;
} catch (_) {}
return renderer;
}
// Attribution hooks into the visualization capability domain (cap:6).
// Guarded no-ops when the domain host isn't loaded (minimal/test pages).
function _notifyVizDomain(id, source) {
const domain = window.feedBack && window.feedBack.vizDomain;
if (domain && typeof domain.notifyRendererChanged === 'function') {
try { domain.notifyRendererChanged(id, source); } catch (_) {}
}
}
function _noteVizAutoMatch(id, matched) {
const domain = window.feedBack && window.feedBack.vizDomain;
if (domain && typeof domain.noteAutoMatch === 'function') {
try { domain.noteAutoMatch(id, matched); } catch (_) {}
}
}
function _installVizRenderer(renderer, id, source = 'user-select') {
window.highway.setRenderer(_tagVizRenderer(renderer, id));
// Drop any stale notation-view hint now that we have a resolved renderer id.
// This is also the path used by _autoMatchViz() after it resolves 'auto' to
// a real plugin id, so the null passed at evaluation start is corrected here.
_dropStaleNotationHint(id);
_notifyVizDomain(id, source);
if (window.v3VenueViz && typeof window.v3VenueViz.notifyRendererInstalled === 'function') {
window.v3VenueViz.notifyRendererInstalled(id);
}
}
export function setViz(id) {
// Helper: reset the UI and persisted selection to the built-in
// "default" entry. Called whenever the requested viz can't be
// applied (missing factory, factory threw, factory returned a
// non-conforming renderer) so the picker, localStorage, and the
// highway's active renderer stay in sync.
const fallbackToDefault = () => {
try { localStorage.setItem('vizSelection', 'default'); } catch (_) {}
const sel = document.getElementById('viz-picker');
if (sel) sel.value = 'default';
window.highway.setRenderer(null);
_syncVenueVizPlayerClass('default');
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
window.v3VenueScene3d.syncViz('default');
}
_notifyVizDomain('default', 'fallback');
_maybeShowNotationViewHint('default');
};
// When switching away from Auto, reset the closed-state label so the
// Auto option shows base text the next time the user opens the dropdown.
// Also cancel any pending viz:renderer:ready listener from the previous
// Auto match cycle so it can't set a stale label after we've moved on.
if (id !== 'auto') {
if (_cancelPendingAutoLabel) { _cancelPendingAutoLabel(); _cancelPendingAutoLabel = null; }
_setAutoVizLabel(null);
}
if (id === 'default' || !id) {
try { localStorage.setItem('vizSelection', id || 'default'); } catch (_) {}
const _sel = document.getElementById('viz-picker');
if (_sel) _sel.value = 'default';
window.highway.setRenderer(null);
_syncVenueVizPlayerClass('default');
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
window.v3VenueScene3d.syncViz('default');
}
_notifyVizDomain('default', 'user-select');
_maybeShowNotationViewHint('default');
return;
}
if (id === 'auto') {
try { localStorage.setItem('vizSelection', 'auto'); } catch (_) {}
_syncVenueVizPlayerClass('auto');
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
window.v3VenueScene3d.syncViz('auto');
}
_autoMatchViz();
return;
}
if (id === 'venue') {
if (!_canRun3D()) {
console.warn('viz picker: WebGL2 unavailable, falling back to Classic 2D Highway');
_markPromoted();
_showWebGL2FallbackToast();
fallbackToDefault();
return;
}
const venueFactory = window['feedBackViz_highway_3d'];
if (typeof venueFactory !== 'function') {
console.error('viz picker: venue requires feedBackViz_highway_3d');
fallbackToDefault();
return;
}
let venueRenderer;
try { venueRenderer = venueFactory(); }
catch (e) {
console.error('viz picker: feedBackViz_highway_3d threw for venue mode', e);
fallbackToDefault();
return;
}
if (!venueRenderer || typeof venueRenderer.draw !== 'function') {
console.error('viz picker: feedBackViz_highway_3d returned an invalid renderer for venue mode');
fallbackToDefault();
return;
}
try { localStorage.setItem('vizSelection', 'venue'); } catch (_) {}
const _venueSel = document.getElementById('viz-picker');
if (_venueSel) _venueSel.value = 'venue';
_installVizRenderer(venueRenderer, 'highway_3d');
_syncVenueVizPlayerClass('venue');
console.info('[venue-viz] selected venue -> renderer highway_3d, venueClass=true');
if (window.v3VenueMoodFx && typeof window.v3VenueMoodFx.onVenueVisualizationSelected === 'function') {
window.v3VenueMoodFx.onVenueVisualizationSelected();
}
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
window.v3VenueScene3d.syncViz('venue');
}
_maybeShowNotationViewHint('highway_3d');
return;
}
// 3D Highway specifically gates on WebGL2. Any future WebGL viz
// plugin should declare its own probe — for now the bundled 3D
// Highway is the only viz with this requirement, so the gate is
// hardcoded. Falling back to 'default' (Classic 2D) keeps the
// picker in sync; toast informs the user.
if (id === 'highway_3d' && !_canRun3D()) {
console.warn('viz picker: WebGL2 unavailable, falling back to Classic 2D Highway');
_markPromoted();
_showWebGL2FallbackToast();
fallbackToDefault();
return;
}
const factory = window['feedBackViz_' + id];
if (typeof factory !== 'function') {
console.error(`viz picker: factory feedBackViz_${id} not available`);
fallbackToDefault();
return;
}
let renderer;
try { renderer = factory(); }
catch (e) {
console.error(`viz picker: factory feedBackViz_${id} threw`, e);
fallbackToDefault();
return;
}
// Validate shape — window.highway.setRenderer will itself fall back to
// default on a bad renderer, but without this check the UI and
// localStorage would still advertise the broken selection.
if (!renderer || typeof renderer.draw !== 'function') {
console.error(`viz picker: factory feedBackViz_${id} returned an invalid renderer (missing draw)`);
fallbackToDefault();
return;
}
// Persist only once we know the renderer is valid.
try { localStorage.setItem('vizSelection', id); } catch (_) {}
_installVizRenderer(renderer, id);
_syncVenueVizPlayerClass(id);
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
window.v3VenueScene3d.syncViz(id);
}
_maybeShowNotationViewHint(id);
}
// Auto mode: evaluate each registered viz factory's static
// `matchesArrangement(songInfo)` predicate and install the first
// matching renderer. No match → fall back to the built-in 2D window.highway.
//
// vizSelection stays 'auto' across invocations so the next song:ready
// re-evaluates. An explicit picker choice overrides Auto by persisting
// a different vizSelection.
//
// Enumerates viz plugins by walking the picker's own <option> list —
// that's the canonical set built by _populateVizPicker above and keeps
// us from needing a second module-level registry.
// Helper: update the closed-state label of the Auto option to show what was resolved.
// Resets to the base label when called with no argument (at evaluation start).
// _autoVizBaseLabel is captured from the DOM on first call so the reset text
// always matches the initial markup rather than a hardcoded duplicate.
let _autoVizBaseLabel = null;
function _setAutoVizLabel(resolvedText) {
const opt = document.querySelector('#viz-picker option[value="auto"]');
if (!opt) return;
if (_autoVizBaseLabel === null) _autoVizBaseLabel = opt.text;
opt.text = resolvedText != null ? `Auto \u2192 ${resolvedText}` : _autoVizBaseLabel;
}
// Holds a cleanup function for the pending viz:renderer:ready listener
// registered by _autoMatchViz(). Called at the start of each new evaluation
// to remove any listener left over from the previous match cycle.
let _cancelPendingAutoLabel = null;
// One-shot (per song) hint shown when a notation-only arrangement falls back
// to the built-in 2D window.highway. Such arrangements carry no wire notes
// (sloppak-spec §5.3: `file:` may be omitted when `notation:` is present), so
// the default renderer draws an empty board — without this the user is left
// staring at a silently blank window.highway. Core ships no notation view; point at
// the viz picker instead.
let _notationHintShownFor = null;
function _showNotationViewHint(arrangementIndex, activeVizId) {
const filename = (window.feedBack && window.feedBack.currentSong
&& window.feedBack.currentSong.filename) || '';
if (_notationHintShownFor === filename) return;
_notationHintShownFor = filename;
const player = document.getElementById('player');
if (!player) return;
const prev = document.getElementById('notation-view-hint');
if (prev) prev.remove();
const el = document.createElement('div');
el.id = 'notation-view-hint';
el.className = 'notation-view-hint';
el.dataset.filename = filename;
if (arrangementIndex != null) el.dataset.arrangementIndex = String(arrangementIndex);
if (activeVizId) el.dataset.vizId = String(activeVizId);
el.textContent = 'This arrangement is notation-only — the built-in highway has nothing to draw. '
+ 'Install a notation view plugin (e.g. Staff View or Keys Highway 3D) and select it in the visualization picker.';
const close = document.createElement('button');
close.className = 'notation-view-hint-close';
close.setAttribute('aria-label', 'Dismiss');
close.textContent = '×';
close.addEventListener('click', () => el.remove());
el.appendChild(close);
player.appendChild(el);
setTimeout(() => { el.remove(); }, 15000);
}
// Decide whether the active song needs the notation-view hint: the song is
// notation-only (has_notation + zero wire notes on the active arrangement)
// AND the given viz doesn't claim it via matchesArrangement. Covers both the
// Auto fallthrough (activeVizId='default') and explicit selections, where the
// renderer persists across songs — e.g. the fresh-install default highway_3d
// would otherwise show a silently empty 3D board on a notation-only song.
// Returns true when the hint was shown.
// A hint left over from a previous song refers to the wrong arrangement —
// drop it whenever the viz evaluation runs for a different filename, a
// different arrangement index, or a different active viz.
function _dropStaleNotationHint(activeVizId) {
const stale = document.getElementById('notation-view-hint');
if (!stale) return;
const curFilename = (window.feedBack && window.feedBack.currentSong
&& window.feedBack.currentSong.filename) || '';
if (stale.dataset.filename !== curFilename) { stale.remove(); return; }
const songInfo = (typeof window.highway?.getSongInfo === 'function')
? (window.highway.getSongInfo() || {}) : {};
const curArrIdx = songInfo.arrangement_index != null ? String(songInfo.arrangement_index) : null;
if (curArrIdx !== null && stale.dataset.arrangementIndex !== undefined
&& stale.dataset.arrangementIndex !== curArrIdx) {
stale.remove(); return;
}
if (activeVizId && stale.dataset.vizId !== undefined && stale.dataset.vizId !== String(activeVizId)) {
stale.remove();
}
}
export function _maybeShowNotationViewHint(activeVizId) {
_dropStaleNotationHint(activeVizId);
const songInfo = (typeof window.highway?.getSongInfo === 'function')
? (window.highway.getSongInfo() || {}) : {};
const activeArr = Array.isArray(songInfo.arrangements)
? songInfo.arrangements.find(a => a.index === songInfo.arrangement_index)
: null;
if (!(songInfo.has_notation && activeArr && activeArr.notes === 0)) {
// Condition no longer holds (arrangement switched to one with notes, or
// notation flag cleared) — remove any residual hint so it doesn't
// linger and contradict current state.
const existing = document.getElementById('notation-view-hint');
if (existing) existing.remove();
return false;
}
if (activeVizId && activeVizId !== 'default' && activeVizId !== 'auto') {
const factory = window['feedBackViz_' + activeVizId];
let claimed = false;
try {
claimed = typeof factory === 'function'
&& typeof factory.matchesArrangement === 'function'
&& !!factory.matchesArrangement(songInfo);
} catch (_) { /* predicate threw — treat as unclaimed */ }
if (claimed) {
// Renderer now claims notation — drop any existing hint.
const existing = document.getElementById('notation-view-hint');
if (existing) existing.remove();
return false;
}
}
_showNotationViewHint(songInfo.arrangement_index, activeVizId);
return true;
}
export function _autoMatchViz() {
const sel = document.getElementById('viz-picker');
if (!sel) return;
// Pass null here: sel.value is 'auto', which is never a valid viz-id hint
// key. Passing 'auto' would incorrectly drop hints whose data-viz-id is
// 'default' (the resolved renderer after a no-match pass), making the
// hint unshowable for the rest of the song. Drop using the resolved id
// happens later inside _installVizRenderer once the id is known.
_dropStaleNotationHint(null);
// Cancel any pending viz:renderer:ready listener from a previous match
// cycle. The song may change before the previous renderer's async init
// settles; we don't want that stale listener to clobber the new label.
if (_cancelPendingAutoLabel) { _cancelPendingAutoLabel(); _cancelPendingAutoLabel = null; }
// Reset label at evaluation start so a stale resolved label never persists
// if the song changes or the picker re-evaluates with a different outcome.
_setAutoVizLabel(null);
const songInfo = (typeof window.highway?.getSongInfo === 'function')
? (window.highway.getSongInfo() || {}) : {};
// Only update the label when a real song is loaded. Before the first
// song_info frame, getSongInfo() returns {} — leaving the reset state
// ("Auto (match arrangement)") is correct; we haven't evaluated yet.
const hasSong = Object.keys(songInfo).length > 0;
// Options are stable in DOM order, which matches what users see in
// the picker. The underlying order comes from /api/plugins →
// _populateVizPicker, and /api/plugins reflects the order the
// plugin loader discovered plugins in — plugins/__init__.py walks
// `sorted(plugins_base_dir.iterdir())`, i.e. sorted by the on-disk
// PLUGIN DIRECTORY name (e.g. "feedBack-plugin-drums" sorts
// before "feedBack-plugin-piano"), not by the plugin id declared
// in plugin.json. Two consequences worth noting:
// 1. First match wins among registered viz plugins — keep each
// plugin's matchesArrangement predicate narrow to avoid
// stealing songs from more specialized viz.
// 2. If you need a strict priority when multiple plugins match
// the same song, name the higher-priority plugin's directory
// earlier alphabetically. The picker dropdown reveals the
// actual tiebreaker at a glance.
const candidateIds = Array.from(sel.options)
.map(o => o.value)
.filter(v => v !== 'auto' && v !== 'default');
for (const id of candidateIds) {
const factory = window['feedBackViz_' + id];
if (typeof factory !== 'function') continue;
// If the factory statically declares contextType='webgl2', gate on
// WebGL2 availability so a match never installs a renderer that'll
// fail at init. This is the generic version of the old hard-coded
// highway_3d check — any future WebGL2 viz gets the same protection
// for free without needing a special-case here.
const factoryCtxType = typeof factory.contextType === 'string' ? factory.contextType : '2d';
if (factoryCtxType === 'webgl2' && !_canRun3D()) continue;
const predicate = factory.matchesArrangement;
if (typeof predicate !== 'function') continue;
let matched = false;
try { matched = !!predicate(songInfo); }
catch (err) {
console.error(`viz auto: matchesArrangement for ${id} threw`, err);
continue;
}
if (!matched) continue;
let renderer;
try { renderer = factory(); }
catch (err) {
console.error(`viz auto: factory feedBackViz_${id} threw`, err);
continue;
}
if (!renderer || typeof renderer.draw !== 'function') {
console.error(`viz auto: factory feedBackViz_${id} returned an invalid renderer (missing draw)`);
continue;
}
// Deliberately NOT persisting id — vizSelection stays 'auto' so
// the next song:ready re-evaluates against the new arrangement.
//
// Register the viz:renderer:ready listener BEFORE setRenderer() so we
// don't miss the event for sync renderers (no readyPromise), which emit
// it immediately inside setRenderer(). The _onReady guard still checks
// sel.value so a sync init failure (viz:reverted → sel.value='default')
// that fires during setRenderer() is handled correctly — the listener
// fires but finds sel.value !== 'auto' and skips the label update.
if (hasSong) {
const matchedOpt = Array.from(sel.options).find(o => o.value === id);
const labelText = matchedOpt ? matchedOpt.text : id;
function _onReady() { if (sel.value === 'auto') _setAutoVizLabel(labelText); }
window.feedBack.on('viz:renderer:ready', _onReady, { once: true });
_cancelPendingAutoLabel = () => window.feedBack.off('viz:renderer:ready', _onReady);
}
_installVizRenderer(renderer, id, 'auto-match');
_noteVizAutoMatch(id, true);
return;
}
// No match — restore the built-in 2D window.highway. setRenderer(null) is
// a no-op when the default is already active. If the previous Auto
// pick was a WebGL renderer, window.highway.setRenderer() handles the
// context-type change by replacing the canvas element (cloneNode +
// replaceWith) so the default 2D renderer's getContext('2d') always
// succeeds — no canvas-lock limitation here.
window.highway.setRenderer(null);
_notifyVizDomain('default', 'auto-match');
_noteVizAutoMatch('default', false);
// Update the label so the user can see Auto resolved to the built-in
// window.highway. Read from the DOM rather than hard-coding the name so a
// future rename of the default entry is automatically reflected.
if (hasSong) {
const defaultOpt = Array.from(sel.options).find(o => o.value === 'default');
// Notation-only arrangement falling through to the default renderer:
// there are no wire notes, so the board would be silently empty.
// Flag it in the Auto label and show the one-shot install hint.
if (_maybeShowNotationViewHint('default')) {
_setAutoVizLabel('no notation view installed');
} else {
_setAutoVizLabel(defaultOpt ? defaultOpt.text : null);
}
}
}
// ── viz:reverted ────────────────────────────────────────────────────────
// Lifted out of a top-level listener block in app.js that it shared with the
// non-viz song:loaded / arrangement:changed / song:ready handlers (those stay).
//
// It has to move WITH the state: it REASSIGNS `_cancelPendingAutoLabel`, and an
// imported binding is read-only — `_cancelPendingAutoLabel = null` would throw if
// this listener stayed behind in app.js. Same guard as the block it came from.
if (window.feedBack && typeof window.feedBack.on === 'function') {
// Highway signals when it's auto-reverted to the default renderer
// after a broken plugin (init failure or repeated draw failures).
// Sync the picker + persisted selection so the UI stops advertising
// the broken choice and the user doesn't hit the same failure on
// next reload.
window.feedBack.on('viz:reverted', (e) => {
const sel = document.getElementById('viz-picker');
if (sel) sel.value = 'default';
// Cancel any pending viz:renderer:ready label listener — the renderer
// that was queued never became (or stayed) active.
if (_cancelPendingAutoLabel) { _cancelPendingAutoLabel(); _cancelPendingAutoLabel = null; }
// Clear any Auto-resolved label — the renderer that was advertised
// never became (or stayed) active.
_setAutoVizLabel(null);
try { localStorage.setItem('vizSelection', 'default'); } catch (_) {}
console.warn(
`viz picker: reverted to default renderer (${e.detail?.reason || 'unknown'}).`
);
});
}