mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 02:58:31 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fbe650ea6 | ||
|
|
d47883c5e5 | ||
|
|
ebbfc8da6f | ||
|
|
14b4058bc6 | ||
|
|
bfb31a8b89 | ||
|
|
a222b45c02 |
+37
-2183
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,280 @@
|
||||
// The diagnostics-bundle export — the Settings "Export diagnostics" flow.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
// It snapshots the browser-only state (console ring buffer, hardware probe,
|
||||
// localStorage, ua) via window.feedBack.diagnostics, POSTs it to
|
||||
// /api/diagnostics/export with the user's include/redact toggles, and streams the
|
||||
// returned zip to disk. Bundle layout + schemas: docs/diagnostics-bundle-spec.md.
|
||||
//
|
||||
// Everything except the two entry points is module-private — the preview
|
||||
// renderer, the file-label table, and the byte/HTML formatters are used nowhere
|
||||
// else in core.
|
||||
|
||||
//
|
||||
// Companion to Settings export but for troubleshooting bug reports.
|
||||
// Bundle layout + schemas: docs/diagnostics-bundle-spec.md.
|
||||
//
|
||||
// Frontend's job is to:
|
||||
// 1. Snapshot the browser-only state (console ring buffer, hardware
|
||||
// probe, localStorage, ua) via window.feedBack.diagnostics.
|
||||
// 2. POST it to /api/diagnostics/export with the user's include /
|
||||
// redact toggles.
|
||||
// 3. Stream the returned zip to disk.
|
||||
|
||||
function _diagIncludeFromUI() {
|
||||
const v = (id) => document.getElementById(id)?.checked !== false;
|
||||
return {
|
||||
system: v('diag-incl-system'),
|
||||
hardware: v('diag-incl-hardware'),
|
||||
logs: v('diag-incl-logs'),
|
||||
console: v('diag-incl-console'),
|
||||
plugins: v('diag-incl-plugins'),
|
||||
};
|
||||
}
|
||||
|
||||
function _diagRedactFromUI() {
|
||||
const el = document.getElementById('diag-redact');
|
||||
return el ? !!el.checked : true;
|
||||
}
|
||||
|
||||
// Map raw file paths inside the bundle to plain-English labels +
|
||||
// descriptions for the preview UI. Only paths that show up in
|
||||
// previews need entries — unknown paths fall back to the path itself.
|
||||
const _DIAG_FILE_LABELS = {
|
||||
'system/version.json': { label: 'App version', desc: 'FeedBack version, Python, OS' },
|
||||
'system/env.json': { label: 'Environment', desc: 'Allowlisted env vars (LOG_LEVEL, etc.). No secrets.' },
|
||||
'system/hardware.json': { label: 'Hardware (server-side)', desc: 'CPU, RAM, GPU. In Docker this reflects the container, not the host.' },
|
||||
'system/plugins.json': { label: 'Plugins', desc: 'Loaded plugins + git commit + orphan detection.' },
|
||||
'logs/server.log': { label: 'Server log', desc: 'Tail of LOG_FILE (last ~5 MB).' },
|
||||
'logs/server.log.meta.json': { label: 'Log metadata', desc: 'Log file path, size, rotation info.' },
|
||||
'client/console.json': { label: 'Browser console', desc: 'console.log/warn/error transcript + window errors.' },
|
||||
'client/hardware.json': { label: 'Hardware (browser)', desc: 'WebGL/WebGPU adapter, host OS via userAgent.' },
|
||||
'client/local_storage.json': { label: 'Browser storage', desc: 'localStorage contents (preferences).' },
|
||||
'client/ua.json': { label: 'User agent', desc: 'Browser, screen, page URL.' },
|
||||
};
|
||||
|
||||
function _formatBytes(n) {
|
||||
if (!n || n < 1024) return (n || 0) + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function _escapeHtml(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function _renderDiagPreview(data) {
|
||||
const m = data.manifest || {};
|
||||
const files = m.files || [];
|
||||
const groups = { system: [], logs: [], client: [], plugins: [], other: [] };
|
||||
for (const f of files) {
|
||||
const top = (f.path || '').split('/')[0];
|
||||
(groups[top] || groups.other).push(f);
|
||||
}
|
||||
const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0);
|
||||
const include = _diagIncludeFromUI();
|
||||
const redact = _diagRedactFromUI();
|
||||
|
||||
const sections = [];
|
||||
// Per-file `summary` (server-derived) → human one-liner.
|
||||
function _summaryLine(path, summary) {
|
||||
if (!summary || typeof summary !== 'object') return '';
|
||||
if (path === 'system/plugins.json') {
|
||||
const loaded = summary.loaded_count || 0;
|
||||
const orphans = summary.orphan_count || 0;
|
||||
const orphPart = orphans ? ` · <span class="text-amber-400">${orphans} orphan${orphans === 1 ? '' : 's'}</span>` : '';
|
||||
return `${loaded} plugin${loaded === 1 ? '' : 's'} loaded${orphPart}`;
|
||||
}
|
||||
if (path === 'client/console.json') {
|
||||
const total = summary.entry_count || 0;
|
||||
const lvl = summary.by_level || {};
|
||||
const parts = [];
|
||||
for (const k of ['error','warn','info','log','debug']) {
|
||||
if (lvl[k]) parts.push(`${lvl[k]} ${k}`);
|
||||
}
|
||||
return `${total} entries${parts.length ? ' (' + parts.join(', ') + ')' : ''}`;
|
||||
}
|
||||
if (path === 'system/hardware.json') {
|
||||
const bits = [];
|
||||
if (summary.cpu_brand) bits.push(summary.cpu_brand);
|
||||
if (summary.cores_logical) bits.push(`${summary.cores_logical} cores`);
|
||||
if (summary.gpu_count) bits.push(`${summary.gpu_count} GPU`);
|
||||
if (summary.runtime) bits.push(`runtime: ${summary.runtime}`);
|
||||
return bits.join(' · ');
|
||||
}
|
||||
if (path === 'client/hardware.json') {
|
||||
const bits = [];
|
||||
if (summary.runtime) bits.push(summary.runtime);
|
||||
if (summary.webgl_renderer) bits.push(summary.webgl_renderer);
|
||||
return bits.join(' · ');
|
||||
}
|
||||
if (path === 'client/local_storage.json') {
|
||||
return `${summary.key_count || 0} keys`;
|
||||
}
|
||||
if (path === 'system/version.json') {
|
||||
const bits = [];
|
||||
if (summary.feedBack) bits.push(`feedBack ${summary.feedBack}`);
|
||||
if (summary.python) bits.push(`python ${summary.python}`);
|
||||
if (summary.os) bits.push(summary.os);
|
||||
return bits.join(' · ');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function pushSection(title, list, emptyHint) {
|
||||
if (!list.length) {
|
||||
if (emptyHint) {
|
||||
sections.push(`<div class="mb-3"><div class="text-gray-300 font-semibold mb-1">${_escapeHtml(title)}</div><div class="text-gray-500">${_escapeHtml(emptyHint)}</div></div>`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const rows = list.map(f => {
|
||||
const meta = _DIAG_FILE_LABELS[f.path] || { label: f.path, desc: '' };
|
||||
const summary = _summaryLine(f.path, f.summary);
|
||||
const summaryHtml = summary
|
||||
? `<div class="text-accent-light text-[10px] mt-0.5">${summary}</div>`
|
||||
: '';
|
||||
return `<div class="flex justify-between gap-4 py-1 border-b border-dark-600 last:border-0">
|
||||
<div class="min-w-0">
|
||||
<div class="text-gray-200">${_escapeHtml(meta.label)}</div>
|
||||
<div class="text-gray-500 text-[10px]">${_escapeHtml(meta.desc)}</div>
|
||||
${summaryHtml}
|
||||
</div>
|
||||
<div class="text-gray-400 text-right whitespace-nowrap">${_escapeHtml(_formatBytes(f.size))}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
sections.push(`<div class="mb-3"><div class="text-gray-300 font-semibold mb-1">${_escapeHtml(title)}</div>${rows}</div>`);
|
||||
}
|
||||
|
||||
pushSection('System', groups.system, include.system ? '' : 'Skipped (toggle off)');
|
||||
pushSection('Server logs', groups.logs, include.logs
|
||||
? 'No log file configured — set LOG_FILE env var to include server logs.'
|
||||
: 'Skipped (toggle off)');
|
||||
pushSection('Plugin diagnostics', groups.plugins, include.plugins
|
||||
? 'No plugins have opted in to diagnostics.'
|
||||
: 'Skipped (toggle off)');
|
||||
|
||||
// Client section preview is a server-side estimate only — actual
|
||||
// client/* payloads are added at Export time after the browser
|
||||
// snapshots. Show what WILL be added, not file sizes.
|
||||
const clientLines = [];
|
||||
if (include.console) clientLines.push({ label: 'Browser console', desc: 'console.log/warn/error transcript + window errors.' });
|
||||
if (include.hardware) clientLines.push({ label: 'Hardware (browser)', desc: 'WebGL/WebGPU adapter, host OS via userAgent.' });
|
||||
clientLines.push({ label: 'Browser storage', desc: 'localStorage contents (preferences).' });
|
||||
clientLines.push({ label: 'User agent', desc: 'Browser, screen, page URL.' });
|
||||
const clientHtml = clientLines.map(c => `<div class="flex justify-between gap-4 py-1 border-b border-dark-600 last:border-0">
|
||||
<div><div class="text-gray-200">${_escapeHtml(c.label)}</div><div class="text-gray-500 text-[10px]">${_escapeHtml(c.desc)}</div></div>
|
||||
<div class="text-gray-500 text-right whitespace-nowrap">added on export</div>
|
||||
</div>`).join('');
|
||||
sections.push(`<div class="mb-3"><div class="text-gray-300 font-semibold mb-1">Browser data</div>${clientHtml}</div>`);
|
||||
|
||||
const notesHtml = (m.notes || []).length
|
||||
? `<div class="mb-3 bg-dark-600 border border-amber-500/30 rounded-lg p-2">
|
||||
<div class="text-amber-400 text-[10px] font-semibold uppercase mb-1">Notes</div>
|
||||
${(m.notes).map(n => `<div class="text-gray-300 text-[11px]">• ${_escapeHtml(n)}</div>`).join('')}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const privacyHtml = redact
|
||||
? `<div class="text-emerald-400 text-[11px]">🔒 Redaction enabled — paths, song names, IPs, and secrets will be replaced with stable hash tokens.</div>`
|
||||
: `<div class="text-amber-400 text-[11px]">⚠ Redaction OFF — bundle will contain raw paths, song names, and IPs. Only share with people you trust.</div>`;
|
||||
|
||||
return `
|
||||
<div class="text-[11px]">
|
||||
<div class="flex justify-between items-baseline mb-2">
|
||||
<div class="text-gray-200 font-semibold">${_escapeHtml(data.filename)}</div>
|
||||
<div class="text-gray-400">${_escapeHtml(_formatBytes(totalBytes))}<span class="text-gray-600"> server-side</span></div>
|
||||
</div>
|
||||
<div class="text-gray-500 text-[10px] mb-3">runtime: ${_escapeHtml(m.runtime || 'unknown')} · exported_at: ${_escapeHtml(m.exported_at || '')}</div>
|
||||
${notesHtml}
|
||||
${sections.join('')}
|
||||
${privacyHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export async function previewDiagnostics() {
|
||||
const status = document.getElementById('diag-status');
|
||||
const preview = document.getElementById('diag-preview');
|
||||
if (!status || !preview) return;
|
||||
status.textContent = 'Building preview…';
|
||||
preview.classList.add('hidden');
|
||||
const include = _diagIncludeFromUI();
|
||||
const params = new URLSearchParams({
|
||||
redact: String(_diagRedactFromUI()),
|
||||
system: String(include.system),
|
||||
hardware: String(include.hardware),
|
||||
logs: String(include.logs),
|
||||
console: String(include.console),
|
||||
plugins: String(include.plugins),
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`/api/diagnostics/preview?${params.toString()}`);
|
||||
if (!resp.ok) {
|
||||
status.textContent = `Preview failed (HTTP ${resp.status})`;
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
preview.innerHTML = _renderDiagPreview(data);
|
||||
preview.classList.remove('hidden');
|
||||
status.textContent = 'Preview ready.';
|
||||
} catch (e) {
|
||||
status.textContent = `Preview failed: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportDiagnostics() {
|
||||
const status = document.getElementById('diag-status');
|
||||
if (!status) return;
|
||||
status.textContent = 'Building bundle…';
|
||||
const include = _diagIncludeFromUI();
|
||||
const redact = _diagRedactFromUI();
|
||||
|
||||
const diag = window.feedBack && window.feedBack.diagnostics;
|
||||
const body = {
|
||||
redact,
|
||||
include,
|
||||
client_console: include.console && diag ? diag.snapshotConsole() : null,
|
||||
client_hardware: include.hardware && diag ? await diag.snapshotHardware() : null,
|
||||
client_ua: diag ? diag.snapshotUa() : null,
|
||||
local_storage: diag ? diag.snapshotLocalStorage() : null,
|
||||
client_contributions: diag ? diag.snapshotContributions() : null,
|
||||
};
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch('/api/diagnostics/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed: ${e.message}`;
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
status.textContent = `Export failed (HTTP ${resp.status})`;
|
||||
return;
|
||||
}
|
||||
let filename = 'feedBack-diag.zip';
|
||||
const disp = resp.headers.get('Content-Disposition');
|
||||
if (disp) {
|
||||
const m = /filename="([^"]+)"/.exec(disp);
|
||||
if (m) filename = m[1];
|
||||
}
|
||||
try {
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
status.textContent = `Exported ${filename}`;
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed during download: ${e.message}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// DOM + HTML-escaping primitives, and the modal dialogs built on them.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
//
|
||||
// This one is a GATHER, not a slice — the six lived in six different places in
|
||||
// app.js. They belong together because they are the bottom of the UI stack:
|
||||
// `esc` / `_escAttr` alone have ~48 call sites, and every later carve that
|
||||
// renders HTML will need them. Giving them a home NOW means those carves can
|
||||
// import them instead of inventing a host seam to reach back into app.js —
|
||||
// which is exactly the trap the plugin-loader carve had to work around before
|
||||
// the viz layer became a module.
|
||||
|
||||
export function _isElementVisible(el) {
|
||||
// Walk ancestors looking for display:none. Handles collapsed
|
||||
// `.album-body` / `.artist-body` subtrees (hidden via CSS class
|
||||
// rules). Using a DOM walk rather than `offsetParent` avoids the
|
||||
// false-negative for `position:fixed` elements whose offsetParent
|
||||
// is null even when they are perfectly visible.
|
||||
if (!el) return false;
|
||||
let node = el;
|
||||
while (node && node !== document.body) {
|
||||
if (getComputedStyle(node).display === 'none') return false;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Focus trap: keep Tab / Shift+Tab cycling inside `modal` so focus
|
||||
// can't escape to the content underneath while the overlay is open.
|
||||
// Call this once after the modal is in the DOM and initial focus is set.
|
||||
export function _trapFocusInModal(modal) {
|
||||
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
modal.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
const els = Array.from(modal.querySelectorAll(FOCUSABLE)).filter(el => {
|
||||
if (!_isElementVisible(el)) return false;
|
||||
if (getComputedStyle(el).visibility === 'hidden') return false;
|
||||
if (el.disabled) return false;
|
||||
return true;
|
||||
});
|
||||
if (!els.length) return;
|
||||
const first = els[0];
|
||||
const last = els[els.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
} else {
|
||||
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Styled async confirm dialog. Returns a Promise<boolean>. For destructive
|
||||
// prompts pass `danger: true` — confirm button turns red and Cancel gets
|
||||
// initial focus so an accidental Enter won't fire the action. `body` is
|
||||
// inserted as HTML so callers can use formatting; callers are responsible
|
||||
// for escaping any user-supplied content in it (use _escAttr).
|
||||
export function _confirmDialog({ title, body = '', confirmText = 'Confirm', cancelText = 'Cancel', danger = false } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const previouslyFocused = document.activeElement;
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'feedBack-modal fixed inset-0 z-[250] flex items-center justify-center bg-black/70 backdrop-blur-sm';
|
||||
modal.setAttribute('role', 'alertdialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.setAttribute('aria-label', title || 'Confirm');
|
||||
const confirmClass = danger
|
||||
? 'flex-1 bg-red-600 hover:bg-red-500 px-4 py-2 rounded-xl text-sm font-semibold text-white transition focus:outline-none focus:ring-2 focus:ring-red-400/60'
|
||||
: 'flex-1 bg-accent hover:bg-accent-light px-4 py-2 rounded-xl text-sm font-semibold text-white transition focus:outline-none focus:ring-2 focus:ring-accent/60';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-dark-700 border border-gray-700 rounded-2xl p-6 w-full max-w-sm mx-4 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-white mb-3">${_escAttr(title || '')}</h3>
|
||||
<div class="mb-5">${body}</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="button" data-confirm class="${confirmClass}">${_escAttr(confirmText)}</button>
|
||||
<button type="button" data-cancel class="px-4 py-2 bg-dark-600 hover:bg-dark-500 rounded-xl text-sm text-gray-300 transition focus:outline-none focus:ring-2 focus:ring-gray-500/40">${_escAttr(cancelText)}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
function finish(result) {
|
||||
modal.remove();
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
if (previouslyFocused && document.body.contains(previouslyFocused)) {
|
||||
try { previouslyFocused.focus({ preventScroll: true }); } catch {}
|
||||
}
|
||||
resolve(result);
|
||||
}
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); finish(false); }
|
||||
else if (e.key === 'Enter' && document.activeElement === modal.querySelector('[data-confirm]')) {
|
||||
e.preventDefault(); finish(true);
|
||||
}
|
||||
}
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) finish(false);
|
||||
else if (e.target.closest('[data-confirm]')) finish(true);
|
||||
else if (e.target.closest('[data-cancel]')) finish(false);
|
||||
});
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
_trapFocusInModal(modal);
|
||||
// Focus Cancel by default for destructive prompts so an accidental
|
||||
// Enter / Space won't fire the dangerous action; otherwise focus
|
||||
// the confirm button so Enter accepts.
|
||||
const focusTarget = modal.querySelector(danger ? '[data-cancel]' : '[data-confirm]');
|
||||
if (focusTarget) focusTarget.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// `esc()` escapes the HTML-content metacharacters (<, >, &) but not
|
||||
// quotes — fine for text-node interpolation but unsafe when the
|
||||
// result is used as an attribute value, where a literal `"` ends the
|
||||
// attribute early. Use `_escAttr` for any `attr="${...}"` site.
|
||||
export function _escAttr(s) {
|
||||
return esc(s == null ? '' : String(s))
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// In-app text prompt — replaces window.prompt(), which Electron does NOT
|
||||
// implement (it logs "prompt() is and will not be supported" and returns null),
|
||||
// so any prompt()-based flow is a silent no-op on desktop. Returns the entered
|
||||
// string, or null if cancelled (Esc / Cancel / backdrop). Styled to match the
|
||||
// edit modal; role=dialog so the global keyboard shortcuts ignore typing here.
|
||||
// Injection-safe: all caller text is set via textContent / value, never innerHTML.
|
||||
export function uiPrompt({ title = '', label = '', value = '', okLabel = 'Save', placeholder = '' } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'feedBack-modal fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
if (title) modal.setAttribute('aria-label', title);
|
||||
modal.innerHTML = `
|
||||
<form class="bg-dark-700 border border-gray-700 rounded-2xl p-6 w-full max-w-sm mx-4 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-white mb-4" data-ui-prompt-title hidden></h3>
|
||||
<label class="text-xs text-gray-400 mb-1 block" data-ui-prompt-label hidden></label>
|
||||
<input type="text" data-ui-prompt-input autocomplete="off"
|
||||
class="w-full bg-dark-600 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-accent/50">
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button type="submit"
|
||||
class="flex-1 bg-accent hover:bg-accent-light px-4 py-2 rounded-xl text-sm font-semibold text-white transition" data-ui-prompt-ok></button>
|
||||
<button type="button" data-ui-prompt-cancel
|
||||
class="px-4 py-2 bg-dark-600 hover:bg-dark-500 rounded-xl text-sm text-gray-300 transition">Cancel</button>
|
||||
</div>
|
||||
</form>`;
|
||||
const titleEl = modal.querySelector('[data-ui-prompt-title]');
|
||||
const labelEl = modal.querySelector('[data-ui-prompt-label]');
|
||||
const input = modal.querySelector('[data-ui-prompt-input]');
|
||||
const okEl = modal.querySelector('[data-ui-prompt-ok]');
|
||||
if (title) { titleEl.textContent = title; titleEl.hidden = false; }
|
||||
if (label) { labelEl.textContent = label; labelEl.hidden = false; }
|
||||
okEl.textContent = okLabel;
|
||||
input.value = value;
|
||||
if (placeholder) input.placeholder = placeholder;
|
||||
|
||||
// Restore focus to wherever it was when we're done (matches the edit
|
||||
// modal's behavior so keyboard users aren't dumped at the page top).
|
||||
const previousActiveElement = document.activeElement;
|
||||
const focusables = () => Array.from(
|
||||
modal.querySelectorAll('input, button, [tabindex]:not([tabindex="-1"])'),
|
||||
).filter((el) => !el.disabled && el.offsetParent !== null);
|
||||
|
||||
let settled = false;
|
||||
const close = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
modal.remove();
|
||||
if (previousActiveElement && typeof previousActiveElement.focus === 'function') {
|
||||
previousActiveElement.focus();
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(null); return; }
|
||||
// Trap Tab inside the modal so focus can't wander to the page behind it.
|
||||
if (e.key === 'Tab') {
|
||||
const items = focusables();
|
||||
if (!items.length) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (e.shiftKey && (active === first || !modal.contains(active))) {
|
||||
e.preventDefault(); last.focus();
|
||||
} else if (!e.shiftKey && (active === last || !modal.contains(active))) {
|
||||
e.preventDefault(); first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
modal.querySelector('form').addEventListener('submit', (e) => { e.preventDefault(); close(input.value); });
|
||||
modal.querySelector('[data-ui-prompt-cancel]').addEventListener('click', () => close(null));
|
||||
// Backdrop (overlay itself, not the panel) cancels.
|
||||
modal.addEventListener('mousedown', (e) => { if (e.target === modal) close(null); });
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
document.body.appendChild(modal);
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
// Highway string colours — user theming for the 2D + bundled 3D highways.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
//
|
||||
// Slot→hex colours (per named string slot, so a 6-string map survives a 4-string
|
||||
// bass and a 7-string's Low B), named themes in localStorage, a copy/paste share
|
||||
// code, and the Settings-screen picker UI. The highways colour by raw string
|
||||
// INDEX, so a translation table maps named slots → per-index colours for the
|
||||
// current arrangement, recomputed whenever a song loads.
|
||||
//
|
||||
// Exports exactly two entry points; the other 43 symbols (the HWC_* tables, the
|
||||
// theme store, the picker handlers, the window.feedBack facade) are used nowhere
|
||||
// else in core and stay private. The Settings buttons are wired by
|
||||
// addEventListener inside hwcInitSettingsUI — there are no inline on*= handlers
|
||||
// here, so nothing needs re-exposing on window.
|
||||
//
|
||||
// It does import uiPrompt from ./dom.js (the "name this theme" prompt) — which is
|
||||
// precisely why dom.js was carved out first: without it this module would have
|
||||
// needed a host seam back into app.js.
|
||||
import { uiPrompt } from './dom.js';
|
||||
|
||||
// Colors are assigned per NAMED string (Low E, A, D, G, B, High E, plus the
|
||||
// extended low strings of 7/8-string guitars), so a string keeps its color
|
||||
// when the string count changes (e.g. Low E stays the same from a 6-string
|
||||
// guitar to a 4-string bass, and on a 7-string the extra Low B takes the
|
||||
// 7-string slot rather than bumping every color over). The highways color by
|
||||
// raw string INDEX, so a small translation table maps named slots → per-index
|
||||
// colors for the current arrangement; this is recomputed whenever a song loads
|
||||
// (its string count / bass-vs-guitar may differ). Applies to BOTH the 2D and
|
||||
// bundled 3D highway; stored client-side; shared via a copy/paste code.
|
||||
const HWC_KEY_ACTIVE = 'highwayStringColors'; // JSON slot→hex map (active)
|
||||
const HWC_KEY_THEMES = 'highwayColorThemes'; // { "<name>": {slot:hex} }
|
||||
const HWC_KEY_NAME = 'highwayColorActiveName'; // selected saved theme name, or ''
|
||||
const HWC_HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
// Named color slots, in display order (high → low, then extended low strings).
|
||||
const HWC_SLOTS = [
|
||||
{ key: 'highE', label: 'High E', sub: '1st' },
|
||||
{ key: 'B', label: 'B', sub: '2nd' },
|
||||
{ key: 'G', label: 'G', sub: '3rd' },
|
||||
{ key: 'D', label: 'D', sub: '4th' },
|
||||
{ key: 'A', label: 'A', sub: '5th' },
|
||||
{ key: 'lowE', label: 'Low E', sub: '6th / lowest' },
|
||||
{ key: 'low7', label: 'Low B', sub: '7-string' },
|
||||
{ key: 'low8', label: 'Low F#', sub: '8-string' },
|
||||
];
|
||||
const HWC_SLOT_KEYS = HWC_SLOTS.map((s) => s.key);
|
||||
// Hardcoded fallback (matches the highway defaults) for before the 2D highway
|
||||
// is queryable.
|
||||
const HWC_DEFAULT_FALLBACK = { lowE: '#cc0000', A: '#cca800', D: '#0066cc', G: '#cc6600', B: '#00cc66', highE: '#9900cc', low7: '#cc00aa', low8: '#00cccc' };
|
||||
|
||||
// One-click string-color presets. Each is a full named-slot → hex map (every
|
||||
// slot, so 7/8-string charts get a sensible color too) keyed by the same slot
|
||||
// names as HWC_SLOTS, so "Low E" always lands on the lowE slot regardless of
|
||||
// string count. Hues are chosen for the dark scene (~#080810): each color is
|
||||
// bright enough to read on black and distinct from its neighbours.
|
||||
// - warmcool: an ordered low→high spectrum (warm reds at the bass end →
|
||||
// cool blues/violet at the treble end) so pitch reads as color temperature.
|
||||
// - vivid: punchier, higher-saturation take on the classic mapping for a
|
||||
// stage-bright look.
|
||||
// - colorblind: the Okabe–Ito accessible qualitative palette (vermillion,
|
||||
// orange, yellow, bluish-green, sky-blue, blue, reddish-purple), the most
|
||||
// distinguishable option for deuteranopia/protanopia.
|
||||
// - colorblind_deuteranope: a deuteranope-tuned variant of the Okabe–Ito set
|
||||
// above, contributed by a deuteranopic player who still found that set hard
|
||||
// to separate. Retunes the six main strings (red / yellow-green / blue /
|
||||
// orange / teal / deep-purple) and keeps its 7/8-string colors unchanged.
|
||||
// - neon: electric, max-saturation hues whose LIGHTNESS deliberately zig-zags
|
||||
// between neighbours (bright→bright→brightest→dark blue→bright green→dark
|
||||
// violet) so adjacent strings separate harder than vivid — a stage/stream
|
||||
// "pop" set, not a vivid duplicate.
|
||||
// - accessible: a CVD-safe set ORDERED by ascending lightness low→high (deep
|
||||
// blue → vermilion → azure → orange → yellow → cream). Unlike the unordered
|
||||
// Okabe–Ito 'colorblind' set, the value ramp teaches pitch low→high AND
|
||||
// survives grayscale/colorblindness; no red/green pair carries meaning.
|
||||
// - ember: a warm, lower-intensity family for long sessions, luminance-stepped
|
||||
// from rust/ember at the bass through warm gold to cream at the treble. The
|
||||
// bass embers stay light enough to clear the near-black scene.
|
||||
// - tapedeck: a vintage-print, slightly desaturated ochre-tinted family
|
||||
// (rust-red → mustard → avocado → teal → faded denim → dusty plum). Muted
|
||||
// hues collapse, so neighbour LIGHTNESS deliberately zig-zags to keep the
|
||||
// dusty mid-strings (avocado/teal/denim) distinct on the dark board.
|
||||
// - crtgreen / crtamber: monochrome CRT-phosphor families (green / amber)
|
||||
// stepped by STRICT ASCENDING LIGHTNESS low→high. Mono sets collapse on hue,
|
||||
// so lightness alone carries the ordering. Verified to stay legible even on
|
||||
// the matching phosphor scene board (green-on-green / amber-on-amber).
|
||||
// - pitchramp: a smooth low→high hue sweep (violet → blue → teal → green →
|
||||
// yellow → warm-white) with rising lightness — memorable + teaches order.
|
||||
// - sunrise: a soft dawn gradient (plum → rose → coral → amber → gold → cream),
|
||||
// warm and lower-intensity, lightness-stepped low→high.
|
||||
const HWC_PRESETS = [
|
||||
{
|
||||
id: 'warmcool', label: 'Warm → Cool',
|
||||
colors: { lowE: '#ff3b30', A: '#ff7a18', D: '#ffc400', G: '#36c46a', B: '#2196f3', highE: '#9b5cff', low7: '#ff2d78', low8: '#00c2c7' },
|
||||
},
|
||||
{
|
||||
id: 'vivid', label: 'Vivid',
|
||||
colors: { lowE: '#ff2222', A: '#ffd000', D: '#1e8bff', G: '#ff7a00', B: '#16d65a', highE: '#b24bff', low7: '#ff3cc0', low8: '#15d8d8' },
|
||||
},
|
||||
{
|
||||
id: 'colorblind', label: 'Colorblind-friendly',
|
||||
colors: { lowE: '#d55e00', A: '#e69f00', D: '#f0e442', G: '#009e73', B: '#56b4e9', highE: '#cc79a7', low7: '#0072b2', low8: '#999999' },
|
||||
},
|
||||
{
|
||||
id: 'colorblind_deuteranope', label: 'Colorblind (deuteranope)',
|
||||
colors: { lowE: '#aa1414', A: '#88de00', D: '#1889e3', G: '#c6601c', B: '#00f5b2', highE: '#4d2173', low7: '#0072b2', low8: '#999999' },
|
||||
},
|
||||
{
|
||||
id: 'neon', label: 'Neon',
|
||||
colors: { lowE: '#ff1f4e', A: '#ff9d00', D: '#e9ff00', G: '#1844ff', B: '#00ff84', highE: '#d000ff', low7: '#ff00aa', low8: '#00f0ff' },
|
||||
},
|
||||
{
|
||||
id: 'accessible', label: 'Accessible (ordered)',
|
||||
colors: { lowE: '#2453c0', A: '#c44a00', D: '#3f93cf', G: '#ec9a1e', B: '#f2d43c', highE: '#f5eecb', low7: '#173f96', low8: '#0f2c6b' },
|
||||
},
|
||||
{
|
||||
id: 'ember', label: 'Warm Ember',
|
||||
colors: { lowE: '#c0392b', A: '#e0552a', D: '#ef7d2e', G: '#f6a13a', B: '#f4c95d', highE: '#f7e3a8', low7: '#9e2f23', low8: '#7d2418' },
|
||||
},
|
||||
{
|
||||
id: 'tapedeck', label: 'Tape Deck',
|
||||
colors: { lowE: '#b04632', A: '#d8ad42', D: '#5f7a34', G: '#54b3a6', B: '#5e83ad', highE: '#b98abb', low7: '#8f3526', low8: '#6f2a1e' },
|
||||
},
|
||||
{
|
||||
id: 'crtgreen', label: 'CRT Green',
|
||||
colors: { lowE: '#0a5a23', A: '#108a30', D: '#1fb53f', G: '#3ad94f', B: '#74f06a', highE: '#c7ffb0', low7: '#08491c', low8: '#063514' },
|
||||
},
|
||||
{
|
||||
id: 'crtamber', label: 'CRT Amber',
|
||||
colors: { lowE: '#7a3a02', A: '#a85f06', D: '#cf8410', G: '#e8a82a', B: '#f4cf5e', highE: '#ffeeb8', low7: '#5f2d01', low8: '#471f00' },
|
||||
},
|
||||
{
|
||||
id: 'pitchramp', label: 'Pitch Ramp',
|
||||
colors: { lowE: '#7a2390', A: '#2f5ad8', D: '#1f9bc4', G: '#2fb84a', B: '#cfd22a', highE: '#f3e0c0', low7: '#5e1a78', low8: '#440f5e' },
|
||||
},
|
||||
{
|
||||
id: 'sunrise', label: 'Sunrise',
|
||||
colors: { lowE: '#8a3a6e', A: '#bf4a5e', D: '#e0664f', G: '#f29a55', B: '#f7c873', highE: '#fce8b8', low7: '#6e2c5c', low8: '#54214a' },
|
||||
},
|
||||
];
|
||||
|
||||
// Translation table: chart string index → named slot, for a given string count
|
||||
// and bass/guitar family. Mirrors the 3D highway's _baseOpenStringMidis: bass
|
||||
// shares the low strings (E A D G), 7/8-string guitars prepend lower strings,
|
||||
// and sub-6 guitars truncate from the high end. Index 0 is always the lowest.
|
||||
function _hwcSlotKeysForChart(sc, isBass) {
|
||||
sc = Math.max(1, Math.min(8, (sc | 0) || 6));
|
||||
if (isBass) {
|
||||
if (sc <= 4) return ['lowE', 'A', 'D', 'G'].slice(0, sc);
|
||||
if (sc === 5) return ['low7', 'lowE', 'A', 'D', 'G'];
|
||||
return ['low8', 'low7', 'lowE', 'A', 'D', 'G'].slice(0, sc);
|
||||
}
|
||||
if (sc <= 6) return ['lowE', 'A', 'D', 'G', 'B', 'highE'].slice(0, sc);
|
||||
if (sc === 7) return ['low7', 'lowE', 'A', 'D', 'G', 'B', 'highE'];
|
||||
return ['low8', 'low7', 'lowE', 'A', 'D', 'G', 'B', 'highE'];
|
||||
}
|
||||
|
||||
// Current arrangement shape (string count + bass-vs-guitar) from the 2D highway.
|
||||
function _hwcChartShape() {
|
||||
let sc = 6, arr = '';
|
||||
try { sc = window.highway?.getStringCount?.() || 6; } catch (_) {}
|
||||
try { arr = window.highway?.getSongInfo?.()?.arrangement || window.feedBack?.currentSong?.arrangement || ''; } catch (_) {}
|
||||
return { sc: Math.max(1, Math.min(8, sc)), isBass: /bass/i.test(String(arr)) };
|
||||
}
|
||||
|
||||
// Normalize an arbitrary value to a slot→hex map of validated lowercase colors
|
||||
// (absent / invalid slots are omitted).
|
||||
function _hwcNormalize(slotMap) {
|
||||
const out = {};
|
||||
if (slotMap && typeof slotMap === 'object' && !Array.isArray(slotMap)) {
|
||||
for (const k of HWC_SLOT_KEYS) {
|
||||
const v = (typeof slotMap[k] === 'string') ? slotMap[k].trim().toLowerCase() : '';
|
||||
if (HWC_HEX_RE.test(v)) out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Canonical default color per named slot (the classic highway mapping).
|
||||
// Fixed, not read back from the highway (which may already be name-remapped for
|
||||
// a 7/8-string chart), so the pickers always preview the true per-name default.
|
||||
function getHighwayDefaultSlotColors() {
|
||||
return { ...HWC_DEFAULT_FALLBACK };
|
||||
}
|
||||
|
||||
// Active (user-customized) slot→hex map from storage ({} when none set).
|
||||
function getHighwayStringColors() {
|
||||
try {
|
||||
const raw = localStorage.getItem(HWC_KEY_ACTIVE);
|
||||
if (raw) return _hwcNormalize(JSON.parse(raw));
|
||||
} catch (_) { /* corrupt / blocked */ }
|
||||
return {};
|
||||
}
|
||||
|
||||
// Defaults overlaid with the user's custom slots (custom wins). Always a full
|
||||
// 8-slot map, so name-mapping has a color for every string of any arrangement.
|
||||
function _hwcMergedSlotColors() {
|
||||
return { ...getHighwayDefaultSlotColors(), ...getHighwayStringColors() };
|
||||
}
|
||||
|
||||
// True when the slot→index mapping is the identity (index 0 = lowest = Low E):
|
||||
// guitar ≤6 strings and 4-string bass. For these the name mapping equals the
|
||||
// stock index order, so we leave the highways on their hand-tuned defaults
|
||||
// (byte-identical) unless the user set custom colors. Extended-range charts —
|
||||
// 7/8-string guitar and 5/6-string bass — prepend lower strings (Low B/F#),
|
||||
// shifting Low E up an index, so their defaults must be name-remapped too.
|
||||
function _hwcMappingIsIdentity(sc, isBass) {
|
||||
return isBass ? sc <= 4 : sc <= 6;
|
||||
}
|
||||
|
||||
// Translate a full slot map into the index-keyed array the highways consume.
|
||||
function _hwcEffectiveIndexColors(slotMap, sc, isBass) {
|
||||
const keys = _hwcSlotKeysForChart(sc, isBass);
|
||||
return keys.map((k) => slotMap[k] || null);
|
||||
}
|
||||
|
||||
// Persist the user's custom slot map (or clear it), then apply. Only slots that
|
||||
// actually DIFFER from the default are stored — so reverting every picker to its
|
||||
// stock color persists as empty and the identity/stock path is restored (rather
|
||||
// than pinning the highways on an all-default "custom" theme).
|
||||
function applyHighwayStringColors(slotMap, opts) {
|
||||
const persist = !opts || opts.persist !== false;
|
||||
const colors = _hwcNormalize(slotMap);
|
||||
const defaults = getHighwayDefaultSlotColors();
|
||||
const overrides = {};
|
||||
for (const k of Object.keys(colors)) {
|
||||
if (colors[k] !== defaults[k]) overrides[k] = colors[k];
|
||||
}
|
||||
if (persist) {
|
||||
try {
|
||||
if (Object.keys(overrides).length) localStorage.setItem(HWC_KEY_ACTIVE, JSON.stringify(overrides));
|
||||
else localStorage.removeItem(HWC_KEY_ACTIVE);
|
||||
} catch (_) {}
|
||||
}
|
||||
reapplyHighwayStringColors();
|
||||
}
|
||||
|
||||
// Apply a named one-click string-color preset (see HWC_PRESETS) to all strings.
|
||||
// Persists + applies to both highways (via applyHighwayStringColors), then —
|
||||
// when the Settings UI is mounted — refreshes the per-string pickers so their
|
||||
// swatches show the preset's colors. Unknown id is a no-op.
|
||||
function applyHighwayStringPreset(id) {
|
||||
const preset = HWC_PRESETS.find((p) => p.id === id);
|
||||
if (!preset) return false;
|
||||
applyHighwayStringColors(preset.colors);
|
||||
try { if (typeof hwcRenderPickers === 'function') hwcRenderPickers(); } catch (_) {}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Apply colors by NAMED string to both highways for the current arrangement.
|
||||
// Colors follow the string name regardless of count: Low E stays Low E's color
|
||||
// on a 6-, 7-, or 8-string. Defaults map identically to the stock order for
|
||||
// 6-string/bass (so those stay byte-identical); 7/8-string remaps the defaults
|
||||
// too so Low E keeps its color. The String Colors UI replaces the 3D highway's
|
||||
// old palette picker, so core always drives the 3D string colors here.
|
||||
function reapplyHighwayStringColors() {
|
||||
const { sc, isBass } = _hwcChartShape();
|
||||
const custom = getHighwayStringColors();
|
||||
const hasCustom = Object.keys(custom).length > 0;
|
||||
|
||||
if (!hasCustom && _hwcMappingIsIdentity(sc, isBass)) {
|
||||
// Pure stock defaults in natural order — leave the hand-tuned highway
|
||||
// defaults intact, and make sure the 3D is on its plain default palette
|
||||
// (clears any stale 'custom' / leftover palette selection).
|
||||
try { window.highway?.setStringColors?.(null); } catch (_) {}
|
||||
try {
|
||||
if (localStorage.getItem('h3d_bg_palette') !== 'default') window.h3dBgSetPalette?.('default');
|
||||
} catch (_) {}
|
||||
try { window.feedBack?.emit?.('highway:stringColors', {}); } catch (_) {}
|
||||
return;
|
||||
}
|
||||
|
||||
const eff = _hwcEffectiveIndexColors(_hwcMergedSlotColors(), sc, isBass);
|
||||
try { window.highway?.setStringColors?.(eff); } catch (_) {}
|
||||
try { window.h3dBgSetStringColors?.(eff); } catch (_) {}
|
||||
try { window.feedBack?.emit?.('highway:stringColors', custom); } catch (_) {}
|
||||
}
|
||||
|
||||
function _hwcReadThemes() {
|
||||
// Null-prototype store: theme names come from user input / share codes, so
|
||||
// names like `constructor`/`toString`/`__proto__` must not collide with
|
||||
// inherited Object properties or mutate the prototype.
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(HWC_KEY_THEMES) || '{}');
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return Object.create(null);
|
||||
const out = Object.create(null);
|
||||
for (const [name, colors] of Object.entries(parsed)) out[name] = _hwcNormalize(colors);
|
||||
return out;
|
||||
} catch (_) { return Object.create(null); }
|
||||
}
|
||||
function _hwcWriteThemes(o) { try { localStorage.setItem(HWC_KEY_THEMES, JSON.stringify(o)); } catch (_) {} }
|
||||
function listHighwayColorThemes() { return Object.keys(_hwcReadThemes()); }
|
||||
function getActiveHighwayColorThemeName() { try { return localStorage.getItem(HWC_KEY_NAME) || ''; } catch (_) { return ''; } }
|
||||
|
||||
function saveHighwayColorTheme(name, slotMap) {
|
||||
name = String(name || '').trim();
|
||||
if (!name) return false;
|
||||
const o = _hwcReadThemes();
|
||||
o[name] = _hwcNormalize(slotMap);
|
||||
_hwcWriteThemes(o);
|
||||
try { localStorage.setItem(HWC_KEY_NAME, name); } catch (_) {}
|
||||
return true;
|
||||
}
|
||||
function deleteHighwayColorTheme(name) {
|
||||
const o = _hwcReadThemes();
|
||||
if (Object.prototype.hasOwnProperty.call(o, name)) { delete o[name]; _hwcWriteThemes(o); }
|
||||
if (getActiveHighwayColorThemeName() === name) { try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {} }
|
||||
}
|
||||
// Select a saved theme by name, or pass '' to revert to defaults.
|
||||
function selectHighwayColorTheme(name) {
|
||||
if (!name) {
|
||||
try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {}
|
||||
applyHighwayStringColors(null);
|
||||
return;
|
||||
}
|
||||
const o = _hwcReadThemes();
|
||||
if (!Object.prototype.hasOwnProperty.call(o, name)) return;
|
||||
try { localStorage.setItem(HWC_KEY_NAME, name); } catch (_) {}
|
||||
applyHighwayStringColors(o[name]);
|
||||
}
|
||||
|
||||
// Compact, paste-friendly share code: "SLOPHWY2." + base64url(JSON{n,c}) where
|
||||
// c is the named slot→hex map.
|
||||
function encodeHighwayColorShare(name, slotMap) {
|
||||
const payload = { n: String(name || '').slice(0, 60), c: _hwcNormalize(slotMap) };
|
||||
const json = JSON.stringify(payload);
|
||||
let b64;
|
||||
try { b64 = btoa(unescape(encodeURIComponent(json))); } catch (_) { b64 = btoa(json); }
|
||||
return 'SLOPHWY2.' + b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
function decodeHighwayColorShare(code) {
|
||||
if (typeof code !== 'string') return null;
|
||||
let s = code.trim();
|
||||
// Require the exact versioned prefix. Anything else (a future/legacy
|
||||
// SLOPHWY*, or unprefixed text) is rejected so the version boundary is real.
|
||||
const PREFIX = 'SLOPHWY2.';
|
||||
if (s.slice(0, PREFIX.length).toUpperCase() !== PREFIX) return null;
|
||||
s = s.slice(PREFIX.length);
|
||||
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (s.length % 4) s += '=';
|
||||
let json;
|
||||
try { json = decodeURIComponent(escape(atob(s))); } catch (_) { try { json = atob(s); } catch (_) { return null; } }
|
||||
let obj;
|
||||
try { obj = JSON.parse(json); } catch (_) { return null; }
|
||||
if (!obj || typeof obj.c !== 'object' || Array.isArray(obj.c)) return null;
|
||||
return { name: String(obj.n || '').slice(0, 60), colors: _hwcNormalize(obj.c) };
|
||||
}
|
||||
// Import a share code: store it as a (uniquely named) saved theme and apply.
|
||||
function importHighwayColorShare(code) {
|
||||
const parsed = decodeHighwayColorShare(code);
|
||||
if (!parsed) return null;
|
||||
let name = parsed.name || 'Imported';
|
||||
const existing = _hwcReadThemes();
|
||||
if (Object.prototype.hasOwnProperty.call(existing, name)) {
|
||||
let i = 2;
|
||||
while (Object.prototype.hasOwnProperty.call(existing, name + ' ' + i)) i++;
|
||||
name = name + ' ' + i;
|
||||
}
|
||||
saveHighwayColorTheme(name, parsed.colors);
|
||||
applyHighwayStringColors(parsed.colors);
|
||||
return { name, colors: parsed.colors };
|
||||
}
|
||||
|
||||
// Startup: apply persisted colors to the 2D highway immediately and re-apply on
|
||||
// every song load (string count / bass-vs-guitar can change the slot→index
|
||||
// mapping) and whenever a viz renderer (re)initializes (the 3D loads async +
|
||||
// rebuilds per song, so a one-shot apply could land before it exists).
|
||||
let _hwcWired = false;
|
||||
export function initHighwayColors() {
|
||||
reapplyHighwayStringColors();
|
||||
if (!_hwcWired && window.feedBack && typeof window.feedBack.on === 'function') {
|
||||
_hwcWired = true;
|
||||
window.feedBack.on('viz:renderer:ready', reapplyHighwayStringColors);
|
||||
window.feedBack.on('song:loaded', reapplyHighwayStringColors);
|
||||
window.feedBack.on('song:ready', reapplyHighwayStringColors);
|
||||
}
|
||||
_hwcInstallFacade();
|
||||
}
|
||||
|
||||
// ── Public plugin API: window.feedBack.highwayColors ─────────────────────
|
||||
// A stable, documented facade over the (otherwise private) string-color
|
||||
// manager so plugins can read / react to / set the user's per-string colors
|
||||
// without reaching into internals. This is a synchronous data-plane API, not a
|
||||
// capability domain — consistent with the constitution keeping highway/viz
|
||||
// surfaces off the capability graph until a dedicated render-facade slice
|
||||
// lands. Colors are keyed by NAMED string slot (see `slots`); use
|
||||
// `keysForChart`/`toEffective` to map names → per-string-index for a given
|
||||
// arrangement. See docs/plugin-capability-inventory.md.
|
||||
const _hwcChangeWrappers = new WeakMap();
|
||||
function _hwcInstallFacade() {
|
||||
if (!window.feedBack || window.feedBack.highwayColors) return;
|
||||
const api = {
|
||||
version: 1,
|
||||
// Ordered named slots: [{ key, label, sub }]. `key` is the stable id.
|
||||
slots: HWC_SLOTS.map((s) => ({ key: s.key, label: s.label, sub: s.sub })),
|
||||
// User-set overrides only (named slot → hex); empty object = defaults.
|
||||
get() { return getHighwayStringColors(); },
|
||||
// Canonical default color per named slot.
|
||||
getDefaults() { return getHighwayDefaultSlotColors(); },
|
||||
// Defaults overlaid with overrides — the colors in effect, by name.
|
||||
getResolved() { return _hwcMergedSlotColors(); },
|
||||
// Which named slot each chart string index maps to, for an arrangement
|
||||
// (index 0 = lowest string). e.g. (7,false) → ['low7','lowE','A',...].
|
||||
keysForChart(stringCount, isBass) { return _hwcSlotKeysForChart(stringCount, !!isBass); },
|
||||
// Per-string-INDEX hex array (resolved colors) for an arrangement.
|
||||
// Omit args to use the currently-loaded chart's shape.
|
||||
toEffective(stringCount, isBass) {
|
||||
const shape = (typeof stringCount === 'number')
|
||||
? { sc: stringCount, isBass: !!isBass }
|
||||
: _hwcChartShape();
|
||||
return _hwcEffectiveIndexColors(_hwcMergedSlotColors(), shape.sc, shape.isBass);
|
||||
},
|
||||
// The per-index colors actually applied to the live 2D highway now.
|
||||
getCurrent() {
|
||||
try { return (window.highway && window.highway.getStringColors) ? window.highway.getStringColors() : []; }
|
||||
catch (_) { return []; }
|
||||
},
|
||||
// Set colors programmatically (persists + applies to both highways).
|
||||
// Pass a named slot map, or null/{} to revert to defaults.
|
||||
apply(slotMap) { return applyHighwayStringColors(slotMap); },
|
||||
// One-click presets: [{ id, label, colors }] (full named-slot maps).
|
||||
presets: HWC_PRESETS.map((p) => ({ id: p.id, label: p.label, colors: { ...p.colors } })),
|
||||
// Apply a preset by id (persists + applies to both highways).
|
||||
applyPreset(id) { return applyHighwayStringPreset(id); },
|
||||
// Share-code interop (the "SLOPHWY2." copy/paste format).
|
||||
encodeShare(name, slotMap) { return encodeHighwayColorShare(name, slotMap); },
|
||||
decodeShare(code) { return decodeHighwayColorShare(code); },
|
||||
// Subscribe to color changes; handler receives the resolved slot map.
|
||||
// Returns an unsubscribe fn that removes exactly THIS subscription;
|
||||
// offChange(fn) removes every subscription registered with that fn.
|
||||
// (Each fn maps to a Set of wrappers so repeated mount/init paths that
|
||||
// subscribe the same handler don't clobber each other or leak.)
|
||||
onChange(fn) {
|
||||
if (typeof fn !== 'function' || !window.feedBack) return () => {};
|
||||
const wrapper = () => {
|
||||
try { fn(api.getResolved()); } catch (e) { console.error('[highwayColors] onChange handler threw', e); }
|
||||
};
|
||||
let set = _hwcChangeWrappers.get(fn);
|
||||
if (!set) { set = new Set(); _hwcChangeWrappers.set(fn, set); }
|
||||
set.add(wrapper);
|
||||
window.feedBack.on('highway:stringColors', wrapper);
|
||||
return () => {
|
||||
if (window.feedBack) window.feedBack.off('highway:stringColors', wrapper);
|
||||
const s = _hwcChangeWrappers.get(fn);
|
||||
if (s) { s.delete(wrapper); if (!s.size) _hwcChangeWrappers.delete(fn); }
|
||||
};
|
||||
},
|
||||
offChange(fn) {
|
||||
const set = _hwcChangeWrappers.get(fn);
|
||||
if (set && window.feedBack) {
|
||||
for (const wrapper of set) window.feedBack.off('highway:stringColors', wrapper);
|
||||
_hwcChangeWrappers.delete(fn);
|
||||
}
|
||||
},
|
||||
};
|
||||
window.feedBack.highwayColors = api;
|
||||
}
|
||||
|
||||
// ── Highway String Colors — Settings UI wiring ───────────────────────────
|
||||
// Pickers are per NAMED string (see HWC_SLOTS). Assigning "Low E" a color
|
||||
// keeps Low E that color regardless of string count — the translation table
|
||||
// (_hwcSlotKeysForChart) handles the index remapping per arrangement.
|
||||
|
||||
function _hwcStatus(msg) {
|
||||
const el = document.getElementById('hwc-status');
|
||||
if (!el) return;
|
||||
el.textContent = msg || '';
|
||||
if (msg) {
|
||||
clearTimeout(_hwcStatus._t);
|
||||
_hwcStatus._t = setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
// Render one color input per named slot, seeded from active colors (falling
|
||||
// back to the highway defaults for that slot).
|
||||
function hwcRenderPickers() {
|
||||
const host = document.getElementById('hwc-pickers');
|
||||
if (!host) return;
|
||||
const defaults = getHighwayDefaultSlotColors();
|
||||
const active = getHighwayStringColors();
|
||||
host.innerHTML = '';
|
||||
for (const slot of HWC_SLOTS) {
|
||||
const val = active[slot.key] || defaults[slot.key] || '#888888';
|
||||
const wrap = document.createElement('label');
|
||||
wrap.className = 'flex items-center gap-2 text-xs text-gray-400';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'color';
|
||||
input.id = 'hwc-color-' + slot.key;
|
||||
input.dataset.slot = slot.key;
|
||||
input.value = val;
|
||||
input.style.width = '2.5rem';
|
||||
input.style.height = '1.75rem';
|
||||
input.style.padding = '2px';
|
||||
input.style.cursor = 'pointer';
|
||||
input.className = 'rounded border border-gray-800 bg-dark-700';
|
||||
input.addEventListener('input', () => hwcOnColorInput());
|
||||
wrap.appendChild(input);
|
||||
const span = document.createElement('span');
|
||||
span.textContent = slot.label;
|
||||
wrap.appendChild(span);
|
||||
const sub = document.createElement('span');
|
||||
sub.className = 'text-gray-600';
|
||||
sub.textContent = slot.sub;
|
||||
wrap.appendChild(sub);
|
||||
host.appendChild(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
function hwcReadPickers() {
|
||||
const out = {};
|
||||
for (const slot of HWC_SLOTS) {
|
||||
const el = document.getElementById('hwc-color-' + slot.key);
|
||||
if (el) out[slot.key] = el.value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Live apply on any picker change. Leaves the saved-theme select alone so a
|
||||
// tweaked-but-unsaved state is allowed; "Save as…" captures it.
|
||||
function hwcOnColorInput() {
|
||||
applyHighwayStringColors(hwcReadPickers());
|
||||
}
|
||||
|
||||
function hwcPopulateThemeSelect() {
|
||||
const sel = document.getElementById('hwc-theme-select');
|
||||
if (!sel) return;
|
||||
const names = listHighwayColorThemes().sort((a, b) => a.localeCompare(b));
|
||||
const current = getActiveHighwayColorThemeName();
|
||||
sel.innerHTML = '';
|
||||
const def = document.createElement('option');
|
||||
def.value = '';
|
||||
def.textContent = 'Default colors';
|
||||
sel.appendChild(def);
|
||||
for (const n of names) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = n;
|
||||
opt.textContent = n;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.value = (current && names.includes(current)) ? current : '';
|
||||
}
|
||||
|
||||
function hwcOnSelectTheme(name) {
|
||||
selectHighwayColorTheme(name);
|
||||
hwcRenderPickers();
|
||||
}
|
||||
|
||||
async function hwcSaveTheme() {
|
||||
const name = await uiPrompt({ title: 'Save Highway Colors', label: 'Theme name', value: getActiveHighwayColorThemeName() || 'My Colors', okLabel: 'Save' });
|
||||
if (!name) return;
|
||||
saveHighwayColorTheme(name, hwcReadPickers());
|
||||
hwcPopulateThemeSelect();
|
||||
_hwcStatus('Saved “' + name + '”');
|
||||
}
|
||||
|
||||
function hwcDeleteTheme() {
|
||||
const name = getActiveHighwayColorThemeName();
|
||||
if (!name) { _hwcStatus('No saved theme selected'); return; }
|
||||
deleteHighwayColorTheme(name);
|
||||
applyHighwayStringColors(null);
|
||||
hwcPopulateThemeSelect();
|
||||
hwcRenderPickers();
|
||||
_hwcStatus('Deleted “' + name + '”');
|
||||
}
|
||||
|
||||
function hwcReset() {
|
||||
try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {}
|
||||
applyHighwayStringColors(null);
|
||||
hwcPopulateThemeSelect();
|
||||
hwcRenderPickers();
|
||||
_hwcStatus('Reset to defaults');
|
||||
}
|
||||
|
||||
async function hwcCopyShare() {
|
||||
const name = getActiveHighwayColorThemeName() || 'Highway Colors';
|
||||
const code = encodeHighwayColorShare(name, hwcReadPickers());
|
||||
let copied = false;
|
||||
try { await navigator.clipboard.writeText(code); copied = true; } catch (_) {}
|
||||
if (!copied) {
|
||||
// Fallback: drop the code into the import field so it can be copied manually.
|
||||
const inp = document.getElementById('hwc-import-code');
|
||||
if (inp) { inp.value = code; inp.select(); }
|
||||
}
|
||||
_hwcStatus(copied ? 'Share code copied' : 'Copy failed — code shown below');
|
||||
}
|
||||
|
||||
function hwcImport() {
|
||||
const inp = document.getElementById('hwc-import-code');
|
||||
const code = inp ? inp.value : '';
|
||||
const res = importHighwayColorShare(code);
|
||||
if (!res) { _hwcStatus('Invalid share code'); return; }
|
||||
if (inp) inp.value = '';
|
||||
hwcPopulateThemeSelect();
|
||||
hwcRenderPickers();
|
||||
_hwcStatus('Imported “' + res.name + '”');
|
||||
}
|
||||
|
||||
export function hwcInitSettingsUI() {
|
||||
hwcPopulateThemeSelect();
|
||||
hwcRenderPickers();
|
||||
}
|
||||
+68
-34
@@ -7,40 +7,15 @@
|
||||
// breaks, every plugin breaks — so every change here ends with a real plugin
|
||||
// booted against a local uvicorn, not just a green test run.
|
||||
//
|
||||
// Two things it needs from app.js, and why neither is an import:
|
||||
// * window.showScreen — already the public host contract (constitution II),
|
||||
// so it is called through `window` rather than re-coupled as an import.
|
||||
// * _populateVizPicker — app.js's viz-picker UI, which must run after plugin
|
||||
// scripts have loaded (it looks for window.feedBackViz_<id> factories).
|
||||
// It is injected once at boot via configurePluginLoader() instead of
|
||||
// imported, so this module never imports app.js and the graph stays acyclic
|
||||
// (the import-x/no-cycle gate).
|
||||
|
||||
// The injected host seam. Populated once, by app.js, at boot.
|
||||
// The one thing it still needs from app.js is `window.showScreen` — already the
|
||||
// public host contract (constitution II), so it is called through `window` rather
|
||||
// than re-coupled as an import.
|
||||
//
|
||||
// The default is NOISY on purpose. A silent no-op stub is the classic failure
|
||||
// mode for this pattern: if the wiring call in app.js is ever dropped or drifts,
|
||||
// the loader keeps working, the viz picker just quietly stops refreshing, and
|
||||
// nothing — no test, no boot check — says a word. Failing loudly means the
|
||||
// smoke harness (which treats console errors as failures) catches it.
|
||||
const _host = {
|
||||
populateVizPicker: () => {
|
||||
console.error(
|
||||
'[plugin-loader] host seam not configured — app.js must call '
|
||||
+ 'configurePluginLoader({ populateVizPicker }) before plugins load.',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Wire the host functions this module needs. Called once from app.js at boot,
|
||||
* before any plugin is loaded.
|
||||
*/
|
||||
export function configurePluginLoader(hooks) {
|
||||
if (hooks && typeof hooks.populateVizPicker === 'function') {
|
||||
_host.populateVizPicker = hooks.populateVizPicker;
|
||||
}
|
||||
}
|
||||
// `_populateVizPicker` used to arrive through a configurePluginLoader() host seam:
|
||||
// it lived in app.js, and importing app.js from here would have closed a cycle.
|
||||
// The viz layer is now its own leaf module, so the seam is GONE — this imports it
|
||||
// directly, and the graph stays acyclic without any injection.
|
||||
import { _populateVizPicker } from './viz.js';
|
||||
|
||||
let _loadPluginsInFlight = false;
|
||||
const _pluginUiContributions = new Map();
|
||||
@@ -731,7 +706,7 @@ function _refreshPluginsSoon() {
|
||||
_pluginRefreshTimer = setTimeout(async () => {
|
||||
const plugins = await loadPlugins();
|
||||
if (plugins) {
|
||||
_host.populateVizPicker(plugins);
|
||||
_populateVizPicker(plugins);
|
||||
} else {
|
||||
// loadPlugins() returned null because a refetch was already in
|
||||
// flight, so this status change would otherwise be dropped. Re-arm
|
||||
@@ -827,3 +802,62 @@ export async function bootstrapPluginsAndUi() {
|
||||
_streamPluginStartup();
|
||||
return plugins;
|
||||
}
|
||||
|
||||
|
||||
// ── Plugin updates ──────────────────────────────────────────────────────
|
||||
// The Settings-screen "Check for updates" / "Update" buttons. Carved out of
|
||||
// app.js (R3a) into the loader rather than a module of their own: this is plugin
|
||||
// MANAGEMENT, it belongs with the code that loads them. Both are inline handlers,
|
||||
// so app.js re-exposes them on window.
|
||||
|
||||
export async function checkPluginUpdates() {
|
||||
const btn = document.getElementById('btn-check-updates');
|
||||
const status = document.getElementById('updates-status');
|
||||
const list = document.getElementById('plugin-updates-list');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Checking...';
|
||||
status.textContent = '';
|
||||
list.innerHTML = '';
|
||||
try {
|
||||
const resp = await fetch('/api/plugins/updates');
|
||||
const data = await resp.json();
|
||||
const updates = data.updates || {};
|
||||
const keys = Object.keys(updates);
|
||||
if (keys.length === 0) {
|
||||
status.textContent = 'All plugins are up to date.';
|
||||
} else {
|
||||
status.textContent = `${keys.length} update${keys.length > 1 ? 's' : ''} available`;
|
||||
for (const id of keys) {
|
||||
const u = updates[id];
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center gap-3 bg-dark-700 rounded-lg px-4 py-2';
|
||||
row.innerHTML = `
|
||||
<span class="text-sm text-gray-300 flex-1">${u.name} <span class="text-xs text-gray-500">(${u.behind} commit${u.behind > 1 ? 's' : ''} behind — ${u.local} → ${u.remote})</span></span>
|
||||
<button onclick="updatePlugin('${id}', this)" class="bg-accent/20 hover:bg-accent/30 text-accent-light px-3 py-1 rounded-lg text-xs transition">Update</button>`;
|
||||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
status.textContent = 'Failed to check for updates.';
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Check for Updates';
|
||||
}
|
||||
|
||||
export async function updatePlugin(pluginId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Updating...';
|
||||
try {
|
||||
const resp = await fetch(`/api/plugins/${pluginId}/update`, { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
btn.textContent = 'Updated — restart to apply';
|
||||
btn.className = 'bg-green-900/30 text-green-400 px-3 py-1 rounded-lg text-xs';
|
||||
} else {
|
||||
btn.textContent = 'Failed';
|
||||
btn.title = data.error || '';
|
||||
}
|
||||
} catch (e) {
|
||||
btn.textContent = 'Error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// Settings backup — the export / import bundle.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
//
|
||||
// Two entry points, both inline handlers on the Settings screen, so app.js keeps
|
||||
// re-exposing them on window. The import is two-phase (server first, atomic; then
|
||||
// a best-effort localStorage merge) — the rationale comment below is the contract
|
||||
// and moved with the code.
|
||||
|
||||
//
|
||||
// Bundles server config + every localStorage key + opted-in plugin server
|
||||
// files into a single JSON file.
|
||||
//
|
||||
// Apply semantics — phased, NOT all-or-nothing across the two stores:
|
||||
// 1. Server first (/api/settings/import). Phase-1 validation guards
|
||||
// the whole bundle; phase-2 disk commit is per-file but ordered
|
||||
// so a mid-apply failure surfaces a `partial` field. A server
|
||||
// failure short-circuits before any localStorage write, so the
|
||||
// browser side stays untouched on validation refusals.
|
||||
// 2. localStorage second, only after the server returns ok. Applied
|
||||
// as a MERGE (no clear): bundled keys overwrite, locally-present
|
||||
// keys absent from the bundle are preserved (so a plugin
|
||||
// installed after the export keeps its first-run defaults).
|
||||
// A localStorage exception here (quota / private mode) is
|
||||
// surfaced verbatim — server state is already committed and we
|
||||
// don't pretend the import was clean.
|
||||
//
|
||||
// In short: the server side is atomic in phase 1 and surface-partial in
|
||||
// phase 2; the localStorage side is best-effort merge after server
|
||||
// success. Failures are reported, never silenced.
|
||||
|
||||
export async function exportSettings() {
|
||||
const status = document.getElementById('backup-status');
|
||||
status.textContent = 'Exporting...';
|
||||
try {
|
||||
const resp = await fetch('/api/settings/export');
|
||||
if (!resp.ok) {
|
||||
status.textContent = `Export failed (HTTP ${resp.status})`;
|
||||
return;
|
||||
}
|
||||
const bundle = await resp.json();
|
||||
// Layer in the browser's localStorage. Use the standard Storage
|
||||
// iteration API (length + key(i)) rather than Object.keys —
|
||||
// Object.keys on a Storage instance is not deterministic across
|
||||
// browsers and can both miss entries and include non-entry
|
||||
// properties depending on the implementation. Keys are preserved
|
||||
// verbatim as strings; that's how localStorage stores them, and
|
||||
// round-trip fidelity matters more than re-typing values that
|
||||
// were never typed in the first place.
|
||||
const localStorageData = {};
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key === null) continue;
|
||||
const value = localStorage.getItem(key);
|
||||
if (value !== null) localStorageData[key] = value;
|
||||
}
|
||||
bundle.local_storage = localStorageData;
|
||||
|
||||
// Trigger download via blob + temporary <a download>. We honor the
|
||||
// server's Content-Disposition filename when present, otherwise
|
||||
// fall back to a date-stamped default.
|
||||
let filename = 'feedBack-settings.json';
|
||||
const disposition = resp.headers.get('Content-Disposition');
|
||||
if (disposition) {
|
||||
const match = /filename="([^"]+)"/.exec(disposition);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
status.textContent = `Exported ${filename}`;
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function importSettings(file) {
|
||||
if (!file) return;
|
||||
const status = document.getElementById('backup-status');
|
||||
if (!confirm('Import will overwrite settings present in the bundle (server config, browser preferences, and opted-in plugin data) and reload the page. Settings not in the bundle (e.g. from plugins installed after the export) are preserved. Continue?')) {
|
||||
status.textContent = 'Import cancelled';
|
||||
return;
|
||||
}
|
||||
let bundle;
|
||||
try {
|
||||
bundle = JSON.parse(await file.text());
|
||||
} catch (e) {
|
||||
status.textContent = `Import failed: not valid JSON (${e.message})`;
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = 'Importing...';
|
||||
let resp, data;
|
||||
try {
|
||||
resp = await fetch('/api/settings/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(bundle),
|
||||
});
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
status.textContent = `Import failed: ${e.message}`;
|
||||
return;
|
||||
}
|
||||
// Two failure shapes to surface: our own validation handler
|
||||
// returns `{ok: false, error: "..."}`, but if the body fails
|
||||
// FastAPI's request-level validation (e.g. top-level value is
|
||||
// an array, not an object), the response is the framework's
|
||||
// `{detail: ...}` shape with no `ok` key. `resp.ok` distinguishes
|
||||
// both from success without depending on which path produced
|
||||
// the failure.
|
||||
if (!resp.ok || data.ok === false) {
|
||||
let msg = data.error;
|
||||
if (!msg && data.detail) {
|
||||
msg = typeof data.detail === 'string'
|
||||
? data.detail
|
||||
: JSON.stringify(data.detail);
|
||||
}
|
||||
status.textContent = `Import failed: ${msg || `HTTP ${resp.status}`}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Server applied successfully. Now apply the localStorage portion as
|
||||
// a MERGE (not clear+restore): keys in the bundle overwrite, keys
|
||||
// present locally but absent from the bundle are preserved. This
|
||||
// matters when a plugin was installed *after* the export — wiping
|
||||
// its localStorage would erase first-run defaults the plugin set on
|
||||
// load, leaving it in a worse state than before the import. The
|
||||
// tradeoff is that orphan keys from removed plugins or renamed key
|
||||
// schemes also linger; cleaning those up is the user's job.
|
||||
const ls = bundle.local_storage;
|
||||
if (ls && typeof ls === 'object') {
|
||||
try {
|
||||
for (const [key, value] of Object.entries(ls)) {
|
||||
if (typeof value === 'string') localStorage.setItem(key, value);
|
||||
}
|
||||
} catch (e) {
|
||||
// Quota exceeded / private mode etc. Server side already
|
||||
// committed, so we surface the partial state rather than
|
||||
// pretending it succeeded.
|
||||
status.textContent = `Server applied, but localStorage write failed: ${e.message}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const warnings = (data.warnings || []).join('; ');
|
||||
status.textContent = warnings ? `Imported with warnings: ${warnings}. Reloading...` : 'Imported. Reloading...';
|
||||
setTimeout(() => location.reload(), 800);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// Tuning display — naming, string counts, and target frequencies.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
//
|
||||
// Turns raw per-string semitone offsets into things a human reads: a tuning NAME
|
||||
// ("Drop D", "Eb Standard", or a raw-offsets fallback), whether an arrangement is
|
||||
// bass, its effective string count, and the target FREQUENCIES + note names the
|
||||
// tuner checks against. Pure functions over a small MIDI/note-name table.
|
||||
//
|
||||
// The window / window.feedBack assignments for these stay in app.js — they are the
|
||||
// public contract (constitution II names window.feedBack), and app.js re-exposes
|
||||
// the imported bindings from exactly where it always did, so nothing about the
|
||||
// surface or its ordering changes.
|
||||
|
||||
// Display-only tuning label helpers — never mutate offsets or affect playback.
|
||||
function _looksLikeRawTuningOffsets(str) {
|
||||
if (!str || typeof str !== 'string') return false;
|
||||
const s = str.trim();
|
||||
if (!s) return false;
|
||||
if (/^-?\d+$/.test(s)) return true;
|
||||
if (/^-?\d+(?: -?\d+)+$/.test(s)) return true;
|
||||
if (/^-?\d+(?:,-?\d+)+$/.test(s)) return true;
|
||||
if (/^-?\d+(-?\d+){2,}$/.test(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function _tuningNameFromOffsets(offsets) {
|
||||
if (!offsets || !offsets.length) return '';
|
||||
const standard = {
|
||||
0: 'E Standard', '-1': 'Eb Standard', '-2': 'D Standard',
|
||||
'-3': 'C# Standard', '-4': 'C Standard', '-5': 'B Standard',
|
||||
'-6': 'Bb Standard', '-7': 'A Standard',
|
||||
1: 'F Standard', 2: 'F# Standard',
|
||||
};
|
||||
// Uniform offsets across 4 (bass) / 5 / 6 strings name the same Standard;
|
||||
// a 4-string bass [0,0,0,0] must read "E Standard", not "Custom Tuning".
|
||||
if (offsets.length >= 4 && offsets.every((o) => o === offsets[0])) {
|
||||
const name = standard[offsets[0]];
|
||||
if (name) return name;
|
||||
}
|
||||
if (offsets.length >= 4 && offsets[0] === offsets[1] - 2
|
||||
&& offsets.slice(1).every((o) => o === offsets[1])) {
|
||||
const noteNames = ['E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B', 'C', 'C#', 'D', 'Eb'];
|
||||
return 'Drop ' + noteNames[((offsets[0] % 12) + 12) % 12];
|
||||
}
|
||||
const named = {
|
||||
'-2,0,0,0,0,0': 'Drop D',
|
||||
'-4,-2,-2,-2,-2,-2': 'Drop C',
|
||||
'-2,-2,0,0,0,0': 'Double Drop D',
|
||||
'0,0,0,-1,0,0': 'Open G',
|
||||
'-2,-2,0,0,-2,-2': 'Open D',
|
||||
'-2,0,0,0,-2,0': 'DADGAD',
|
||||
'0,2,2,1,0,0': 'Open E',
|
||||
'-2,0,0,2,3,2': 'Open D (alt)',
|
||||
};
|
||||
if (offsets.length === 6) {
|
||||
const key = offsets.join(',');
|
||||
if (named[key]) return named[key];
|
||||
}
|
||||
return 'Custom Tuning';
|
||||
}
|
||||
|
||||
export function displayTuningName(value, offsets) {
|
||||
// Explicit offsets win — always name them.
|
||||
if (Array.isArray(offsets) && offsets.length > 0) {
|
||||
return _tuningNameFromOffsets(offsets);
|
||||
}
|
||||
if (value && typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === 'Unknown') return '';
|
||||
if (!_looksLikeRawTuningOffsets(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
// A raw offset string (now served by the API) — parse and name it so a
|
||||
// known tuning like "-1 -1 -1 -1 -1 -1" reads "Eb Standard" rather than
|
||||
// collapsing to "Custom Tuning".
|
||||
const parsed = (typeof parseRawTuningOffsets === 'function')
|
||||
? parseRawTuningOffsets(trimmed) : null;
|
||||
if (parsed && parsed.length) return _tuningNameFromOffsets(parsed);
|
||||
return 'Custom Tuning';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function isBassArrangement(context) {
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
if (typeof ctx.isBass === 'boolean') return ctx.isBass;
|
||||
const label = ((ctx.arrangement || '') + ' ' + (ctx.arrangement_smart_name || '')).toLowerCase();
|
||||
if (/\bbass\b/.test(label)) return true;
|
||||
if (/\b(lead|rhythm|combo|guitar)\b/.test(label)) return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function effectiveStringCount(offsets, context) {
|
||||
if (!Array.isArray(offsets) || !offsets.length) return 0;
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const isBass = isBassArrangement(ctx);
|
||||
let sc = ctx.stringCount > 0 ? Number(ctx.stringCount) : 0;
|
||||
if (!isBass) {
|
||||
if (sc > 0 && sc <= 5 && offsets.length >= 6) sc = 6;
|
||||
if (!sc) sc = offsets.length >= 6 ? offsets.length : 6;
|
||||
} else if (!sc) {
|
||||
sc = offsets.length >= 5 ? offsets.length : 4;
|
||||
}
|
||||
return Math.min(sc, offsets.length);
|
||||
}
|
||||
|
||||
export function songTuningContext(songInfo) {
|
||||
if (!songInfo || typeof songInfo !== 'object') return {};
|
||||
return {
|
||||
stringCount: songInfo.stringCount,
|
||||
arrangement: songInfo.arrangement,
|
||||
arrangement_smart_name: songInfo.arrangement_smart_name,
|
||||
};
|
||||
}
|
||||
|
||||
// Open-string target notes (display only) — mirrors plugins/tuner/utils/tuning-utils.js.
|
||||
const _TUNING_BASE_MIDI = {
|
||||
4: [28, 33, 38, 43],
|
||||
5: [23, 28, 33, 38, 43],
|
||||
6: [40, 45, 50, 55, 59, 64],
|
||||
7: [35, 40, 45, 50, 55, 59, 64],
|
||||
8: [30, 35, 40, 45, 50, 55, 59, 64],
|
||||
};
|
||||
|
||||
const _TUNING_NOTE_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
|
||||
const _TUNING_NOTE_FLAT = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
|
||||
function _tuningMidiToFreq(m) {
|
||||
return Math.pow(2, (m - 69) / 12) * 440;
|
||||
}
|
||||
|
||||
function _tuningOffsetsToFreqs(offsets, isBass) {
|
||||
const len = offsets.length;
|
||||
let base;
|
||||
if (len === 4 || len === 5) {
|
||||
base = isBass ? _TUNING_BASE_MIDI[len] : _TUNING_BASE_MIDI[6];
|
||||
} else {
|
||||
base = _TUNING_BASE_MIDI[len] || _TUNING_BASE_MIDI[6];
|
||||
}
|
||||
return offsets.map((offset, i) => {
|
||||
const root = i < base.length ? base[i] : base[base.length - 1];
|
||||
return _tuningMidiToFreq(root + offset);
|
||||
});
|
||||
}
|
||||
|
||||
function _noteNameFromFreq(freq, useFlats) {
|
||||
const midi = 69 + 12 * Math.log2(freq / 440);
|
||||
const rounded = Math.round(midi);
|
||||
const names = useFlats ? _TUNING_NOTE_FLAT : _TUNING_NOTE_SHARP;
|
||||
return names[((rounded % 12) + 12) % 12];
|
||||
}
|
||||
|
||||
function _octaveNoteFromFreq(freq, useFlats) {
|
||||
const midi = 69 + 12 * Math.log2(freq / 440);
|
||||
const rounded = Math.round(midi);
|
||||
const octave = Math.floor(rounded / 12) - 1;
|
||||
return _noteNameFromFreq(freq, useFlats) + octave;
|
||||
}
|
||||
|
||||
function _stringOrdinalLabel(n) {
|
||||
const v = n % 100;
|
||||
if (v >= 11 && v <= 13) return n + 'th';
|
||||
const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th';
|
||||
return n + suffix;
|
||||
}
|
||||
|
||||
function _tuningTargetFreqs(offsets, context) {
|
||||
if (!Array.isArray(offsets) || !offsets.length) return [];
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const stringCount = effectiveStringCount(offsets, ctx);
|
||||
const trimmed = offsets.slice(0, stringCount);
|
||||
if (!trimmed.length) return [];
|
||||
const isBass = isBassArrangement(ctx);
|
||||
try {
|
||||
return _tuningOffsetsToFreqs(trimmed, isBass);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Flat vs sharp spelling. A caller that knows the preference can pass
|
||||
// ctx.useFlats; otherwise we infer from a flat-keyed tuning name. The v3
|
||||
// card/HUD pass "Custom Tuning" (raw offsets carry no key), so those default
|
||||
// to sharps unless an explicit useFlats is supplied.
|
||||
function _resolveTargetUseFlats(ctx) {
|
||||
if (typeof ctx.useFlats === 'boolean') return ctx.useFlats;
|
||||
return typeof ctx.tuningName === 'string' && /\b[A-G]b\b/.test(ctx.tuningName);
|
||||
}
|
||||
|
||||
export function displayTuningTargetDetails(offsets, context) {
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const useFlats = _resolveTargetUseFlats(ctx);
|
||||
const freqs = _tuningTargetFreqs(offsets, ctx);
|
||||
return freqs.map((f, i) => {
|
||||
const stringNumber = freqs.length - i;
|
||||
const note = _noteNameFromFreq(f, useFlats);
|
||||
const octaveNote = _octaveNoteFromFreq(f, useFlats);
|
||||
return {
|
||||
stringNumber,
|
||||
note,
|
||||
octaveNote,
|
||||
title: _stringOrdinalLabel(stringNumber) + ' string: ' + octaveNote,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function displayTuningTargets(offsets, context) {
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const useFlats = _resolveTargetUseFlats(ctx);
|
||||
const freqs = _tuningTargetFreqs(offsets, ctx);
|
||||
if (!freqs.length) return '';
|
||||
return freqs.map((f) => _noteNameFromFreq(f, useFlats)).join(' ');
|
||||
}
|
||||
|
||||
export function parseRawTuningOffsets(value) {
|
||||
if (Array.isArray(value) && value.length) return value;
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
const s = value.trim();
|
||||
if (/^-?\d+(?: -?\d+)+$/.test(s)) {
|
||||
return s.split(/\s+/).map((n) => Number(n));
|
||||
}
|
||||
if (/^-?\d+(?:,-?\d+)+$/.test(s)) {
|
||||
return s.split(',').map((n) => Number(n));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
// 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 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 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 <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 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 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 highway !== 'undefined' && typeof highway.getSongInfo === 'function')
|
||||
? (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 highway !== 'undefined' && typeof highway.getSongInfo === 'function')
|
||||
? (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 highway !== 'undefined' && typeof highway.getSongInfo === 'function')
|
||||
? (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 highway. setRenderer(null) is
|
||||
// a no-op when the default is already active. If the previous Auto
|
||||
// pick was a WebGL renderer, 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.
|
||||
highway.setRenderer(null);
|
||||
_notifyVizDomain('default', 'auto-match');
|
||||
_noteVizAutoMatch('default', false);
|
||||
// Update the label so the user can see Auto resolved to the built-in
|
||||
// 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'}).`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const appJs = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The highway string-colour manager was carved out of app.js into its own
|
||||
// module (R3a).
|
||||
const appJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-colors.js');
|
||||
|
||||
function extractBlock(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
|
||||
@@ -13,7 +13,9 @@ const path = require('node:path');
|
||||
|
||||
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const appJs = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The highway string-colour manager was carved out of app.js into its own
|
||||
// module (R3a).
|
||||
const appJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-colors.js');
|
||||
|
||||
// Brace-balanced extraction (same helper shape as highway_note_state.test.js).
|
||||
function extractBlock(src, signature) {
|
||||
@@ -86,7 +88,7 @@ test('3D gem-body gradients follow the active palette (not hardcoded)', () => {
|
||||
assert.match(apply, /_recolorGemGradients\(\)/, '_applyPaletteToMaterials must recolor gems on palette change');
|
||||
});
|
||||
|
||||
// ── Core color manager (static/app.js) ────────────────────────────────────
|
||||
// ── Core color manager (static/js/highway-colors.js) ──────────────────────
|
||||
|
||||
test('app.js color manager name-maps to both highways, with identity no-op + builtin guard', () => {
|
||||
const src = fs.readFileSync(appJs, 'utf8');
|
||||
|
||||
@@ -74,6 +74,8 @@ const APP_JS = path.join(ROOT, 'static', 'app.js');
|
||||
// The plugin loader was carved out of app.js into its own module (R3a); the
|
||||
// library-provider code below still lives in app.js.
|
||||
const PLUGIN_LOADER_JS = path.join(ROOT, 'static', 'js', 'plugin-loader.js');
|
||||
// The viz layer was carved out of app.js too (R3a).
|
||||
const VIZ_JS = path.join(ROOT, 'static', 'js', 'viz.js');
|
||||
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
|
||||
|
||||
function source(file) {
|
||||
@@ -116,7 +118,7 @@ test('library providers route through native library capability', () => {
|
||||
});
|
||||
|
||||
test('visualization renderer installs preserve plugin attribution', () => {
|
||||
const src = source(APP_JS);
|
||||
const src = source(VIZ_JS);
|
||||
const tagger = region(src, 'function _tagVizRenderer(renderer, id)', 700);
|
||||
const setViz = region(src, 'function setViz(id)', 3600);
|
||||
const autoViz = region(src, 'function _autoMatchViz()', 5200);
|
||||
|
||||
@@ -7,20 +7,23 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The tuning-display helpers were carved out of app.js into their own module (R3a);
|
||||
// the autoplay-gate test below still reads app.js.
|
||||
const TUNING_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
|
||||
const TUNING_UTILS_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'tuning-utils.js');
|
||||
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
|
||||
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const start = src.indexOf('function isBassArrangement(');
|
||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const end = src.indexOf(endMarker);
|
||||
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
|
||||
const src = fs.readFileSync(TUNING_JS, 'utf8');
|
||||
// The module is nothing BUT the tuning helpers now, so there is no block to
|
||||
// slice out — take it whole. `export` is stripped so the vm sandbox can still
|
||||
// evaluate it as a plain script (the window.* contract lives in app.js).
|
||||
const body = src.replace(/^export /gm, '');
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length),
|
||||
body,
|
||||
sandbox
|
||||
);
|
||||
return sandbox.window.feedBack;
|
||||
|
||||
@@ -6,7 +6,8 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The tuning-display helpers were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The tuning-display helpers were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
|
||||
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
|
||||
@@ -14,14 +15,14 @@ const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const start = src.indexOf('function isBassArrangement(');
|
||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const end = src.indexOf(endMarker);
|
||||
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
|
||||
// The module is nothing BUT the tuning helpers now, so there is no block to
|
||||
// slice out — take it whole. `export` is stripped so the vm sandbox can still
|
||||
// evaluate it as a plain script (the window.* contract lives in app.js).
|
||||
const body = src.replace(/^export /gm, '');
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length) + '\n'
|
||||
body + '\n'
|
||||
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
|
||||
+ 'exports.displayTuningTargetDetails = displayTuningTargetDetails;\n'
|
||||
+ 'exports.isBassArrangement = isBassArrangement;\n'
|
||||
|
||||
@@ -7,7 +7,8 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The tuning-display helpers were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
|
||||
function extractBlock(src, startMarker) {
|
||||
const start = src.indexOf(startMarker);
|
||||
@@ -27,14 +28,14 @@ function extractBlock(src, startMarker) {
|
||||
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const start = src.indexOf('function _looksLikeRawTuningOffsets(');
|
||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const end = src.indexOf(endMarker);
|
||||
if (start === -1 || end === -1) throw new Error('tuning helpers not found');
|
||||
// The module is nothing BUT the tuning helpers now, so there is no block to
|
||||
// slice out — take it whole. `export` is stripped so the vm sandbox can still
|
||||
// evaluate it as a plain script (the window.* contract lives in app.js).
|
||||
const body = src.replace(/^export /gm, '');
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length) + '\n'
|
||||
body + '\n'
|
||||
+ 'exports.displayTuningName = displayTuningName;\n'
|
||||
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
|
||||
+ 'exports.parseRawTuningOffsets = parseRawTuningOffsets;',
|
||||
|
||||
@@ -9,6 +9,9 @@ const venueScene = require('../../static/v3/venue-scene-3d.js');
|
||||
const venueViz = require('../../static/v3/venue-viz.js');
|
||||
const pov = require('../../static/v3/venue-instrument-pov.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The viz layer (setViz / the venue option / the picker) was carved out of
|
||||
// app.js into its own module (R3a).
|
||||
const VIZ_JS = path.join(__dirname, '..', '..', 'static', 'js', 'viz.js');
|
||||
const H3D_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const ASSET_DIR = path.join(__dirname, '..', '..', 'static', 'assets', 'venue', 'themes', 'small-club');
|
||||
@@ -185,8 +188,8 @@ test('venue-scene-3d exports bg plate asset ids', () => {
|
||||
assert.equal(venueScene.ASSET_BASE, '/static/assets/venue/themes/small-club/');
|
||||
});
|
||||
|
||||
test('app.js syncs venue 3D scene on viz changes', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
test('viz.js syncs venue 3D scene on viz changes', () => {
|
||||
const src = fs.readFileSync(VIZ_JS, 'utf8');
|
||||
assert.match(src, /v3VenueScene3d\.syncViz\('venue'\)/);
|
||||
assert.match(src, /v3VenueScene3d\.syncViz\(id\)/);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ const path = require('node:path');
|
||||
const venueViz = require('../../static/v3/venue-viz.js');
|
||||
const venue = require('../../static/v3/venue-mood-fx.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The viz layer was carved out of app.js into its own module (R3a).
|
||||
const VIZ_JS = path.join(__dirname, '..', '..', 'static', 'js', 'viz.js');
|
||||
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const V3_CSS = path.join(__dirname, '..', '..', 'static', 'v3', 'v3.css');
|
||||
|
||||
@@ -139,8 +141,8 @@ test('index.html contains in-player venue placeholder markup', () => {
|
||||
assert.match(html, /id="v3-venue-scene-wash"/);
|
||||
});
|
||||
|
||||
test('app.js adds Venue visualization option and adapter', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
test('viz.js adds Venue visualization option and adapter', () => {
|
||||
const src = fs.readFileSync(VIZ_JS, 'utf8');
|
||||
assert.match(src, /function _ensureVenueVizOption/);
|
||||
assert.match(src, /opt\.value = 'venue'/);
|
||||
assert.match(src, /opt\.textContent = 'Venue'/);
|
||||
@@ -210,15 +212,15 @@ test('venue mood source documents strip overlay disabled', () => {
|
||||
assert.match(source, /v3-venue-mode-badge/);
|
||||
});
|
||||
|
||||
test('app.js preserves plugin viz population for drum/tab/piano highways', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
test('viz.js preserves plugin viz population for drum/tab/piano highways', () => {
|
||||
const src = fs.readFileSync(VIZ_JS, 'utf8');
|
||||
assert.match(src, /p\.type === 'visualization'/);
|
||||
assert.match(src, /feedBackViz_/);
|
||||
assert.match(src, /BUILTIN_OPT_VALUES/);
|
||||
});
|
||||
|
||||
test('venue option remains distinct from highway_3d in app adapter', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
test('venue option remains distinct from highway_3d in viz adapter', () => {
|
||||
const src = fs.readFileSync(VIZ_JS, 'utf8');
|
||||
assert.match(src, /if \(id === 'venue'\)/);
|
||||
assert.doesNotMatch(src, /if \(id === 'venue'\)[\s\S]{0,400}sel\.value = 'highway_3d'/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user