diff --git a/static/app.js b/static/app.js index d5a376e..8e75e02 100644 --- a/static/app.js +++ b/static/app.js @@ -1,8 +1,13 @@ import { bootstrapPluginsAndUi, - configurePluginLoader, loadPlugins, } from './js/plugin-loader.js'; +import { + _autoMatchViz, + _maybeShowNotationViewHint, + _populateVizPicker, + setViz, +} from './js/viz.js'; // Demo analytics — real impl set by demo.js; no-op in normal builds window.feedBackDemoTrack = window.feedBackDemoTrack ?? null; @@ -7669,756 +7674,8 @@ if (window.feedBack) { _maybeShowNotationViewHint(sel.value); } }); - // 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'}).` - ); - }); } -// ── 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_ 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 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 = ` - Your highway was upgraded to 3D. - - - - `; - 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 = ` - 3D Highway needs WebGL2 — falling back to Classic 2D. - - `; - 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'); -} - -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 - // . 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 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') { + 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'; + 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'; + 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 — 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 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