// 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_ 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 = ` 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'); } 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 //