mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 20:38:31 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
212afca2fb | ||
|
|
cb236e6c04 | ||
|
|
64f04565e2 | ||
|
|
f53d566dbc | ||
|
|
b5dd585d25 | ||
|
|
d47883c5e5 | ||
|
|
ebbfc8da6f | ||
|
|
14b4058bc6 | ||
|
|
bfb31a8b89 | ||
|
|
a222b45c02 | ||
|
|
5b904706d0 | ||
|
|
38772f604a |
+12
-5
@@ -43,12 +43,19 @@ module.exports = [
|
|||||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
|
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
|
||||||
rules: { 'max-lines': sizeRule(1500) },
|
rules: { 'max-lines': sizeRule(1500) },
|
||||||
},
|
},
|
||||||
// ES-module graphs (a plugin's src/ tree, .mjs tests): module parsing + the
|
// ES-module graphs (a plugin's src/ tree, .mjs tests, core's own static/js/
|
||||||
// acyclic-imports hard gate + the size norm. A migrated bundled plugin's
|
// tree): module parsing + the acyclic-imports hard gate + the size norm. A
|
||||||
// entry `import './src/main.js'` screen.js must parse as a module — add its
|
// migrated bundled plugin's entry `import './src/main.js'` screen.js must
|
||||||
// glob here in that plugin's migration PR (classic screen.js stays a script).
|
// parse as a module — add its glob here in that plugin's migration PR
|
||||||
|
// (classic screen.js stays a script).
|
||||||
|
//
|
||||||
|
// `static/app.js` is listed explicitly: it is served as
|
||||||
|
// <script type="module"> (R3a) and now `import`s its carved-out modules, so
|
||||||
|
// parsing it as a script would be a syntax error. It is the ENTRY of core's
|
||||||
|
// module graph, which is what makes no-cycle meaningful here — a carved
|
||||||
|
// module that imports app.js back would close a cycle and fail this gate.
|
||||||
{
|
{
|
||||||
files: ['**/src/**/*.js', '**/*.mjs'],
|
files: ['**/src/**/*.js', '**/*.mjs', 'static/app.js', 'static/js/**/*.js'],
|
||||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||||
plugins: { 'import-x': importX },
|
plugins: { 'import-x': importX },
|
||||||
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
|
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
|
||||||
|
|||||||
@@ -2386,7 +2386,25 @@ app.include_router(ws_highway.router)
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
class _RevalidatedStaticFiles(StaticFiles):
|
||||||
|
"""StaticFiles that forces conditional revalidation on every request.
|
||||||
|
|
||||||
|
Without Cache-Control, Chromium applies heuristic freshness (10% of the
|
||||||
|
file's age since Last-Modified) and serves /static/app.js from its disk
|
||||||
|
cache for hours-to-days without asking the server. In the desktop app that
|
||||||
|
meant a new build's renderer ran the PREVIOUS build's app.js — the
|
||||||
|
2026-07-11 ASIO investigation lost a day to a stale loader that couldn't
|
||||||
|
even load module plugins. `no-cache` does NOT disable caching: the browser
|
||||||
|
keeps the cached copy and revalidates with If-None-Match; unchanged files
|
||||||
|
still cost only a 304."""
|
||||||
|
|
||||||
|
async def get_response(self, path, scope):
|
||||||
|
response = await super().get_response(path, scope)
|
||||||
|
response.headers.setdefault("Cache-Control", "no-cache")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/static", _RevalidatedStaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
+384
-4378
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
|||||||
|
// The one <audio> element the whole app plays through.
|
||||||
|
//
|
||||||
|
// This exists so that code carved out of app.js can reach the player without
|
||||||
|
// importing app.js back — which would close a cycle and fail the import-x/no-cycle
|
||||||
|
// gate. It is the same handle app.js has always held (`document.getElementById`
|
||||||
|
// on the element in the shell), just given a home of its own.
|
||||||
|
//
|
||||||
|
// It is deliberately a `const`, and it is never reassigned anywhere in core — so a
|
||||||
|
// read-only import binding is exactly right, and no state container is needed.
|
||||||
|
// (Contrast the reassigned scalars — isPlaying, _avOffsetMs, … — which cannot be
|
||||||
|
// shared this way, because an imported binding cannot be written to.)
|
||||||
|
//
|
||||||
|
// Module scripts evaluate after the HTML is parsed, so the element is already in
|
||||||
|
// the document by the time this runs. app.js is loaded as <script type="module">,
|
||||||
|
// and its imports evaluate before its body — the same point at which app.js used
|
||||||
|
// to run this exact lookup itself.
|
||||||
|
export const audio = document.getElementById('audio');
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// The host seam — how a carved-out module calls back into app.js.
|
||||||
|
//
|
||||||
|
// WHY THIS EXISTS. What is left in app.js is not a tree, it is a cycle: seeding a
|
||||||
|
// dependency closure from count-in, from loops, from section-practice, or from the
|
||||||
|
// JUCE seek shim all return the SAME 178-function set, and setLoop() and
|
||||||
|
// practiceSection() call each other directly. So a module carved out of that
|
||||||
|
// component will always need to call back into app.js — and it cannot `import`
|
||||||
|
// app.js to do it, because app.js imports the module, and that closes a cycle the
|
||||||
|
// import-x/no-cycle gate (rightly) rejects.
|
||||||
|
//
|
||||||
|
// So app.js hands its functions DOWN, once, at boot: `configureHost({ playSong, … })`.
|
||||||
|
//
|
||||||
|
// ─── THE FAILURE MODE THIS IS BUILT TO PREVENT ───────────────────────────────
|
||||||
|
//
|
||||||
|
// The obvious way to write this is a plain object with no-op defaults. That is a
|
||||||
|
// TRAP, and we walked into it once already: the plugin loader's host seam defaulted
|
||||||
|
// `populateVizPicker` to `() => {}`, which means that if the wiring call in app.js
|
||||||
|
// is ever dropped, renamed, or drifts, the loader keeps running, the viz picker
|
||||||
|
// silently stops refreshing, and NOTHING — no test, no boot check, no bot — says a
|
||||||
|
// word. A feature just quietly stops existing.
|
||||||
|
//
|
||||||
|
// Two layers stop that here, and the second is the one that actually closes it:
|
||||||
|
//
|
||||||
|
// 1. RUNTIME — reading an unwired hook THROWS. There are no defaults and no
|
||||||
|
// stubs. `host.playSong` either is the real function or it is a loud error.
|
||||||
|
// An unwired hook cannot degrade into a no-op, because there is nothing for
|
||||||
|
// it to degrade INTO.
|
||||||
|
//
|
||||||
|
// 2. STATIC — tests/js/host_contract.test.js asserts that the set of hooks the
|
||||||
|
// modules USE is exactly the set app.js WIRES. This is the important one:
|
||||||
|
// layer 1 only fires if the broken path actually executes, and the whole
|
||||||
|
// danger of this seam is paths that don't run in a smoke test. The static
|
||||||
|
// check catches a drifted or misspelled hook in CI, on a path nobody ran.
|
||||||
|
//
|
||||||
|
// Consequence for anyone adding a hook: add it to the configureHost({…}) call in
|
||||||
|
// app.js *and* use it as `host.<name>`. The contract test fails on either alone —
|
||||||
|
// deliberately. A hook wired but never used is dead weight; a hook used but never
|
||||||
|
// wired is a bug that would otherwise hide.
|
||||||
|
|
||||||
|
const _hooks = Object.create(null);
|
||||||
|
let _configured = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called ONCE by app.js at boot, before any carved module runs. Every value must
|
||||||
|
* be a function — a hook that is accidentally `undefined` (a typo, a renamed
|
||||||
|
* export, a dropped line) fails HERE, at startup, rather than silently much later.
|
||||||
|
*/
|
||||||
|
export function configureHost(hooks) {
|
||||||
|
if (_configured) {
|
||||||
|
throw new Error('[host] configureHost() called twice — it must be wired exactly once, at boot.');
|
||||||
|
}
|
||||||
|
const bad = Object.entries(hooks || {})
|
||||||
|
.filter(([, v]) => typeof v !== 'function')
|
||||||
|
.map(([k]) => k);
|
||||||
|
if (bad.length) {
|
||||||
|
throw new Error(
|
||||||
|
`[host] these hooks are not functions: ${bad.join(', ')}. `
|
||||||
|
+ 'A hook is usually undefined because it was renamed or its line was dropped.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Object.assign(_hooks, hooks);
|
||||||
|
_configured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The seam itself. Reading a hook that was never wired THROWS — it never returns
|
||||||
|
* undefined and never returns a silent no-op. See the note at the top: a no-op
|
||||||
|
* default is precisely the bug this module exists to make impossible.
|
||||||
|
*/
|
||||||
|
export const host = new Proxy(Object.create(null), {
|
||||||
|
get(_target, name) {
|
||||||
|
if (typeof name === 'symbol') return undefined; // let JS probe it freely
|
||||||
|
if (!_configured) {
|
||||||
|
throw new Error(
|
||||||
|
`[host] host.${name} was read before configureHost() ran. `
|
||||||
|
+ 'app.js must call configureHost() at boot, before any carved module executes.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fn = _hooks[name];
|
||||||
|
if (typeof fn !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
`[host] host.${name} is not wired. Add it to the configureHost({ … }) `
|
||||||
|
+ 'call in app.js. (tests/js/host_contract.test.js should have caught this in CI.)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fn;
|
||||||
|
},
|
||||||
|
// Keep the object honest for anything that introspects it.
|
||||||
|
has(_target, name) { return name in _hooks; },
|
||||||
|
ownKeys() { return Object.keys(_hooks); },
|
||||||
|
getOwnPropertyDescriptor(_target, name) {
|
||||||
|
return name in _hooks
|
||||||
|
? { value: _hooks[name], enumerable: true, configurable: true, writable: false }
|
||||||
|
: undefined;
|
||||||
|
},
|
||||||
|
set(_target, name) {
|
||||||
|
throw new Error(`[host] host.${String(name)} is read-only — hooks are wired only via configureHost().`);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
// The A–B loop — set / clear / persist, and the saved-loops list.
|
||||||
|
//
|
||||||
|
// The second slice out of app.js's strongly-connected core, and it owns the loop
|
||||||
|
// state: loopA, loopB, _loopMutationGen. Nothing outside this module writes them
|
||||||
|
// (restartCurrentSong() looked like it did, but it declares its own local shadows).
|
||||||
|
//
|
||||||
|
// DIRECTION MATTERS HERE. loops and section-practice are mutually dependent — the
|
||||||
|
// SCC in miniature. clearLoop() has to drop section-practice's selection, and
|
||||||
|
// practiceSection() has to call setLoop(). Both directions cannot be imports or the
|
||||||
|
// no-cycle gate (rightly) rejects it. So the edge is oriented:
|
||||||
|
//
|
||||||
|
// section-practice -> reaches loops through the HOST SEAM (host.setLoop, …)
|
||||||
|
// loops -> imports section-practice DIRECTLY
|
||||||
|
//
|
||||||
|
// section-practice is the higher-level feature — it is a consumer of loops, not the
|
||||||
|
// other way round — so it is the one that gets the indirection. app.js wires this
|
||||||
|
// module's exports into the seam for it.
|
||||||
|
//
|
||||||
|
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
|
||||||
|
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
|
||||||
|
import { esc, uiPrompt } from './dom.js';
|
||||||
|
import { host } from './host.js';
|
||||||
|
import {
|
||||||
|
_setSectionPracticeMode,
|
||||||
|
_syncSectionPracticeFromLoop,
|
||||||
|
_updateSectionPracticeHighlight,
|
||||||
|
practiceSection,
|
||||||
|
resetSelection,
|
||||||
|
} from './section-practice.js';
|
||||||
|
|
||||||
|
// ── A-B Loop ────────────────────────────────────────────────────────────
|
||||||
|
export let loopA = null;
|
||||||
|
export let loopB = null;
|
||||||
|
// Bumped on every NON-practiceSection loop mutation (direct setLoop from Saved
|
||||||
|
// Loops / the plugin API, and clearLoop). practiceSection() captures it and bails
|
||||||
|
// if it changes mid-retry, so a stale section retry can't overwrite a loop the
|
||||||
|
// user just set/cleared by another path. practiceSection's own setLoop calls pass
|
||||||
|
// skipSectionSync and do NOT bump it (they must not supersede themselves).
|
||||||
|
export let _loopMutationGen = 0;
|
||||||
|
|
||||||
|
export function setLoopStart() {
|
||||||
|
loopA = host._audioTime();
|
||||||
|
document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
|
||||||
|
updateLoopUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLoopEnd() {
|
||||||
|
if (loopA === null) return;
|
||||||
|
loopB = host._audioTime();
|
||||||
|
if (loopB <= loopA) { loopB = null; return; }
|
||||||
|
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
|
||||||
|
updateLoopUI();
|
||||||
|
// Manual A/B arming is a loop mutation like setLoop()'s — emit the same
|
||||||
|
// transport event so event-driven consumers (note_detect drill sync) see
|
||||||
|
// button-armed loops without having to poll getLoop().
|
||||||
|
window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLoop(options) {
|
||||||
|
const { emitTransportEvent = true } = options || {};
|
||||||
|
// playSong() clears the loop on every song load, so only signal a
|
||||||
|
// loop-cleared transport event when a loop was actually active —
|
||||||
|
// otherwise every song switch emits a spurious playback:loop-cleared.
|
||||||
|
const hadLoop = loopA !== null || loopB !== null;
|
||||||
|
_setSectionPracticeMode(false, { skipClearLoop: true });
|
||||||
|
loopA = null;
|
||||||
|
loopB = null;
|
||||||
|
document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition';
|
||||||
|
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition';
|
||||||
|
document.getElementById('btn-loop-clear').classList.add('hidden');
|
||||||
|
document.getElementById('btn-loop-save').classList.add('hidden');
|
||||||
|
document.getElementById('loop-label').textContent = '';
|
||||||
|
document.getElementById('saved-loops').value = '';
|
||||||
|
resetSelection();
|
||||||
|
_updateSectionPracticeHighlight(host._audioTime());
|
||||||
|
if (hadLoop && emitTransportEvent && typeof window !== 'undefined') {
|
||||||
|
window.feedBack?.playback?.transportEvent?.('loop-cleared', {
|
||||||
|
requesterId: 'core.loop',
|
||||||
|
reason: 'app loop cleared',
|
||||||
|
loop: { enabled: false, state: 'inactive' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resync #saved-loops + #btn-loop-delete with the currently-active
|
||||||
|
// loopA/loopB. Used by both setLoop's success path (so plugin-driven
|
||||||
|
// loops show up correctly in the dropdown) and loadSavedLoop's
|
||||||
|
// failure path (so a cancelled selection reverts to the still-active
|
||||||
|
// loop). Without this sync, deleteSelectedLoop could target a stale
|
||||||
|
// option that doesn't match the active loop.
|
||||||
|
function _syncSavedLoopSelection() {
|
||||||
|
const sel = document.getElementById('saved-loops');
|
||||||
|
const delBtn = document.getElementById('btn-loop-delete');
|
||||||
|
if (!sel || !delBtn) return;
|
||||||
|
let selected = '';
|
||||||
|
if (loopA !== null && loopB !== null) {
|
||||||
|
for (const opt of sel.options) {
|
||||||
|
if (Number(opt.dataset.start) === loopA && Number(opt.dataset.end) === loopB) {
|
||||||
|
selected = opt.value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sel.value = selected;
|
||||||
|
delBtn.classList.toggle('hidden', !selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Programmatically set both loop endpoints and seek to A. The dropdown
|
||||||
|
// path (loadSavedLoop) and the plugin-API path (window.feedBack.setLoop)
|
||||||
|
// both funnel through here so the UI state stays canonical regardless of
|
||||||
|
// who triggered the loop.
|
||||||
|
//
|
||||||
|
// Returns true if the seek landed at A and the loop is now active;
|
||||||
|
// returns false if the seek was cancelled by teardown or landed off-target
|
||||||
|
// (JUCE clamp / HTML5 snap > 50ms from A). On false, loopA/loopB are NOT
|
||||||
|
// committed and the UI is not painted — the prior loop (if any) stays
|
||||||
|
// active. Throws on invalid inputs.
|
||||||
|
export async function setLoop(a, b, options) {
|
||||||
|
const { emitTransportEvent = true, skipSectionSync = false, commitGuard = null } = options || {};
|
||||||
|
const aNum = Number(a);
|
||||||
|
const bNum = Number(b);
|
||||||
|
if (!Number.isFinite(aNum) || !Number.isFinite(bNum) || bNum <= aNum) {
|
||||||
|
throw new Error(`setLoop: requires finite a and b with b > a (got a=${a}, b=${b})`);
|
||||||
|
}
|
||||||
|
// Don't arm loopA/loopB before the seek lands — the 60Hz tick's wrap
|
||||||
|
// detector (`ct >= loopB`) would trigger startCountIn against
|
||||||
|
// half-applied state.
|
||||||
|
const r = await host._audioSeek(aNum, 'loop-set');
|
||||||
|
if (!r.completed || Math.abs(r.to - aNum) > 0.05) return false;
|
||||||
|
// Caller-owned staleness gate, re-checked after the awaited seek and before
|
||||||
|
// we commit loopA/loopB. practiceSection() passes this so a superseded retry
|
||||||
|
// (newer section click, mode turned off, or song/arrangement teardown that
|
||||||
|
// happened during the seek) does not arm a stale loop. Returning false here
|
||||||
|
// leaves the prior loop (if any) untouched, same as the off-target path.
|
||||||
|
if (typeof commitGuard === 'function' && !commitGuard()) return false;
|
||||||
|
loopA = aNum;
|
||||||
|
loopB = bNum;
|
||||||
|
// A direct (non-practice) loop set supersedes any in-flight practiceSection
|
||||||
|
// retry; practiceSection passes skipSectionSync and is exempt so it doesn't
|
||||||
|
// cancel itself.
|
||||||
|
if (!skipSectionSync) _loopMutationGen++;
|
||||||
|
document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
|
||||||
|
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
|
||||||
|
updateLoopUI();
|
||||||
|
// Sync the saved-loops dropdown so a plugin-driven setLoop call
|
||||||
|
// surfaces the matching saved option (and Delete button) — otherwise
|
||||||
|
// the dropdown can stay on a stale selection and deleteSelectedLoop
|
||||||
|
// would target the wrong record.
|
||||||
|
_syncSavedLoopSelection();
|
||||||
|
// practiceSection() passes skipSectionSync: it sets its own section state
|
||||||
|
// under a request-gen guard, so the shared setLoop path must NOT re-sync
|
||||||
|
// here — otherwise a stale (superseded / mode-off) practiceSection retry
|
||||||
|
// that lands inside setLoop would re-arm the loop and flip the mode back on
|
||||||
|
// before the caller's gen check can bail. Direct callers (Saved Loops,
|
||||||
|
// window.feedBack.setLoop) still sync so their chip selection tracks.
|
||||||
|
if (!skipSectionSync && typeof _syncSectionPracticeFromLoop === 'function') {
|
||||||
|
_syncSectionPracticeFromLoop();
|
||||||
|
}
|
||||||
|
if (emitTransportEvent && typeof window !== 'undefined') {
|
||||||
|
window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLoopUI() {
|
||||||
|
const label = document.getElementById('loop-label');
|
||||||
|
const hasLoop = loopA !== null && loopB !== null;
|
||||||
|
if (hasLoop) {
|
||||||
|
label.textContent = `${host.formatTime(loopA)} → ${host.formatTime(loopB)}`;
|
||||||
|
document.getElementById('btn-loop-clear').classList.remove('hidden');
|
||||||
|
document.getElementById('btn-loop-save').classList.remove('hidden');
|
||||||
|
} else if (loopA !== null) {
|
||||||
|
label.textContent = `${host.formatTime(loopA)} → ?`;
|
||||||
|
document.getElementById('btn-loop-clear').classList.add('hidden');
|
||||||
|
document.getElementById('btn-loop-save').classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
label.textContent = '';
|
||||||
|
}
|
||||||
|
host._updateEditRegionBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSavedLoops() {
|
||||||
|
const sel = document.getElementById('saved-loops');
|
||||||
|
const delBtn = document.getElementById('btn-loop-delete');
|
||||||
|
if (!host.currentFilename()) { sel.classList.add('hidden'); delBtn.classList.add('hidden'); return; }
|
||||||
|
|
||||||
|
const resp = await fetch(`/api/loops?filename=${encodeURIComponent(decodeURIComponent(host.currentFilename()))}`);
|
||||||
|
const loops = await resp.json();
|
||||||
|
|
||||||
|
sel.innerHTML = '<option value="">Saved Loops</option>';
|
||||||
|
for (const l of loops) {
|
||||||
|
sel.innerHTML += `<option value="${l.id}" data-start="${l.start}" data-end="${l.end}">${esc(l.name)} (${host.formatTime(l.start)}→${host.formatTime(l.end)})</option>`;
|
||||||
|
}
|
||||||
|
if (loops.length > 0) {
|
||||||
|
sel.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
sel.classList.add('hidden');
|
||||||
|
}
|
||||||
|
delBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSavedLoop(loopId) {
|
||||||
|
const sel = document.getElementById('saved-loops');
|
||||||
|
const opt = sel.selectedOptions[0];
|
||||||
|
const delBtn = document.getElementById('btn-loop-delete');
|
||||||
|
if (!loopId || !opt?.dataset.start) {
|
||||||
|
delBtn.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
// Pass raw strings — setLoop's Number() coercion is stricter than
|
||||||
|
// parseFloat (rejects "12abc") so malformed dataset values throw
|
||||||
|
// and fall into the catch instead of silently truncating.
|
||||||
|
ok = await setLoop(opt.dataset.start, opt.dataset.end);
|
||||||
|
} catch (err) {
|
||||||
|
// Malformed dataset (server returned bad data): treat the same as
|
||||||
|
// a failed seek so the dropdown resyncs and we don't propagate an
|
||||||
|
// uncaught rejection out of the onchange handler.
|
||||||
|
console.warn('[loadSavedLoop] setLoop threw:', err);
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
// Seek aborted, landed off-target, or input was malformed.
|
||||||
|
// Resync the dropdown with the still-active loop so the UI
|
||||||
|
// doesn't lie about which loop is loaded.
|
||||||
|
_syncSavedLoopSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Success path: setLoop already called _syncSavedLoopSelection,
|
||||||
|
// which surfaces the delete button when the new loop matches a
|
||||||
|
// saved option (which the dropdown selection guarantees here).
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveCurrentLoop() {
|
||||||
|
if (loopA === null || loopB === null || !host.currentFilename()) return;
|
||||||
|
const name = await uiPrompt({ title: 'Save Loop', label: 'Loop name', value: 'Loop', okLabel: 'Save' });
|
||||||
|
if (name === null) return; // cancelled
|
||||||
|
const finalName = name.trim() || 'Loop'; // never persist an empty name
|
||||||
|
await fetch('/api/loops', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
filename: decodeURIComponent(host.currentFilename()),
|
||||||
|
name: finalName,
|
||||||
|
start: loopA,
|
||||||
|
end: loopB,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await loadSavedLoops();
|
||||||
|
document.getElementById('btn-loop-save').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSelectedLoop() {
|
||||||
|
const sel = document.getElementById('saved-loops');
|
||||||
|
const loopId = sel.value;
|
||||||
|
if (!loopId) return;
|
||||||
|
await fetch(`/api/loops/${loopId}`, { method: 'DELETE' });
|
||||||
|
clearLoop();
|
||||||
|
await loadSavedLoops();
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Shared, MUTABLE player state.
|
||||||
|
//
|
||||||
|
// WHY A CONTAINER AND NOT PLAIN EXPORTS. An imported binding is read-only. Every
|
||||||
|
// slice carved out of app.js so far has only ever READ the state it shares
|
||||||
|
// (loopA/loopB, _audioSeekGen, currentFilename), so a getter hook was enough and no
|
||||||
|
// container was needed. That runs out here: count-in genuinely WRITES `isPlaying`
|
||||||
|
// (it starts and stops playback) and `lastAudioTime`. `import { isPlaying }` then
|
||||||
|
// `isPlaying = true` throws — the binding cannot be assigned to.
|
||||||
|
//
|
||||||
|
// So the state moves onto an object. `S.isPlaying = true` is a property write, which
|
||||||
|
// works from any module holding the same `S`. This is the same shape the stems,
|
||||||
|
// studio, and editor migrations converged on.
|
||||||
|
//
|
||||||
|
// It is deliberately SMALL. app.js has ~104 top-level `let` scalars; lifting all of
|
||||||
|
// them would be a ~977-site rewrite for no benefit, since most are private to one
|
||||||
|
// cluster and travel with it. Only the ones a carved module must WRITE belong here.
|
||||||
|
// Add to it when a carve actually needs it, not before.
|
||||||
|
//
|
||||||
|
// NB app.js's own 71 reference sites were rewritten mechanically — but from the AST,
|
||||||
|
// not by text substitution. Of 100 textual occurrences of these two names, only 71
|
||||||
|
// resolve to the module binding: 22 are member accesses (`someObj.isPlaying`), 4 are
|
||||||
|
// the local parameter of setPlayButtonState(isPlaying), one is an object key, and two
|
||||||
|
// are shorthand properties (`{ isPlaying }`) that must become `{ isPlaying: S.isPlaying }`.
|
||||||
|
// A blind find-and-replace corrupts all 29.
|
||||||
|
export const S = {
|
||||||
|
/** Is the transport running? Written by playback, count-in, and the JUCE shims. */
|
||||||
|
isPlaying: false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last audio position we saw, in seconds. Used to detect a seek that did not
|
||||||
|
* land where it was asked to (JUCE can clamp; HTML5 can round).
|
||||||
|
*/
|
||||||
|
lastAudioTime: 0,
|
||||||
|
};
|
||||||
@@ -0,0 +1,863 @@
|
|||||||
|
// The plugin loader — the R0 host rails.
|
||||||
|
//
|
||||||
|
// Carved verbatim out of static/app.js (R3a). This is the highest-risk module in
|
||||||
|
// core: it fetches /api/plugins, injects each plugin's screen.js (as
|
||||||
|
// <script type="module"> when its manifest says scriptType:"module"), mounts nav
|
||||||
|
// entries and screens, and wires plugin capability + UI contributions. If it
|
||||||
|
// breaks, every plugin breaks — so every change here ends with a real plugin
|
||||||
|
// booted against a local uvicorn, not just a green test run.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// `_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();
|
||||||
|
const CAPABILITY_INSPECTOR_NAV_SETTING = 'capability_inspector.showInPluginsMenu';
|
||||||
|
|
||||||
|
function _capabilityInspectorNavEnabled() {
|
||||||
|
try { return localStorage.getItem(CAPABILITY_INSPECTOR_NAV_SETTING) === '1'; }
|
||||||
|
catch (_) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive a display label from a (possibly string) nav value. `/api/plugins`
|
||||||
|
// can return `nav` as a plain string (manifest `"nav": "Declared"`) or an
|
||||||
|
// object with a `.label`, and _pluginNav() may synthesize an object (e.g. the
|
||||||
|
// Capability Inspector). Handle all three so string labels and the synthesized
|
||||||
|
// label aren't dropped in favour of the plugin name.
|
||||||
|
function _navLabel(nav, plugin) {
|
||||||
|
if (typeof nav === 'string' && nav.trim()) return nav;
|
||||||
|
if (nav && typeof nav === 'object' && nav.label) return nav.label;
|
||||||
|
return (plugin && (plugin.name || plugin.id)) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _pluginNav(plugin) {
|
||||||
|
if (!plugin || !plugin.id) return null;
|
||||||
|
if (plugin.id === 'capability_inspector') {
|
||||||
|
if (!_capabilityInspectorNavEnabled()) return null;
|
||||||
|
return plugin.nav || { label: 'Capabilities', screen: 'plugin-capability_inspector' };
|
||||||
|
}
|
||||||
|
return plugin.nav || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _commandUiDomain(domain, command, plugin, payload) {
|
||||||
|
try {
|
||||||
|
if (!window.feedBack?.capabilities?.command) return;
|
||||||
|
await window.feedBack.capabilities.command(domain, command, {
|
||||||
|
requester: plugin.id || 'plugin',
|
||||||
|
target: { id: payload.id, pluginId: plugin.id, region: payload.region },
|
||||||
|
payload: { ...payload, pluginId: plugin.id },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`ui contribution ${command} failed for ${plugin.id}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _registerLegacyPluginUiContributions(plugin) {
|
||||||
|
const previous = _pluginUiContributions.get(plugin.id) || [];
|
||||||
|
for (const contribution of previous) {
|
||||||
|
await _commandUiDomain(contribution.domain, 'unmount', plugin, contribution);
|
||||||
|
}
|
||||||
|
const contributions = [];
|
||||||
|
const nav = _pluginNav(plugin);
|
||||||
|
if (nav) {
|
||||||
|
contributions.push({ domain: 'ui.navigation', id: `${plugin.id}:nav`, region: 'plugins', label: _navLabel(nav, plugin), mounted: true });
|
||||||
|
}
|
||||||
|
if (plugin.has_screen) {
|
||||||
|
contributions.push({ domain: 'ui.plugin-screens', id: `${plugin.id}:screen`, region: 'plugin-screens', label: plugin.name || plugin.id, mounted: true });
|
||||||
|
}
|
||||||
|
if (plugin.has_settings) {
|
||||||
|
contributions.push({ domain: 'settings', id: `${plugin.id}:settings`, region: 'plugin-settings', label: plugin.name || plugin.id, mounted: true });
|
||||||
|
}
|
||||||
|
if (plugin.type === 'visualization') {
|
||||||
|
contributions.push({ domain: 'ui.player-overlays', id: `${plugin.id}:visualization`, region: 'visualization-picker', label: plugin.name || plugin.id, mounted: true });
|
||||||
|
}
|
||||||
|
contributions.sort((a, b) => `${a.domain}:${a.id}`.localeCompare(`${b.domain}:${b.id}`));
|
||||||
|
_pluginUiContributions.set(plugin.id, contributions);
|
||||||
|
for (const contribution of contributions) {
|
||||||
|
await _commandUiDomain(contribution.domain, 'register-contribution', plugin, contribution);
|
||||||
|
await _commandUiDomain(contribution.domain, 'mount', plugin, contribution);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings-tab containers that can host plugin <details> panels on the v3
|
||||||
|
// tabbed settings page. '#plugin-settings' is the fallback bucket (and the
|
||||||
|
// only container in the classic v2 settings page); the per-tab containers map
|
||||||
|
// to a plugin manifest's settings.category. A plugin with no category, or one
|
||||||
|
// whose tab container is absent (v2, or render not yet run), falls back to
|
||||||
|
// '#plugin-settings'. Body divs injected per plugin use id
|
||||||
|
// `plugin-settings-<pluginId>` and live INSIDE a <details>, so they are never
|
||||||
|
// direct children of these containers — no id collision in the scans below.
|
||||||
|
const _PLUGIN_SETTINGS_CONTAINER_IDS = [
|
||||||
|
'plugin-settings', 'plugin-settings-graphics',
|
||||||
|
'plugin-settings-mic', 'plugin-settings-progression',
|
||||||
|
];
|
||||||
|
function _pluginSettingsContainers() {
|
||||||
|
const out = [];
|
||||||
|
for (const id of _PLUGIN_SETTINGS_CONTAINER_IDS) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) out.push(el);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function _pluginSettingsTarget(plugin) {
|
||||||
|
const cat = plugin && plugin.settings_category;
|
||||||
|
if (cat) {
|
||||||
|
const el = document.getElementById('plugin-settings-' + cat);
|
||||||
|
if (el) return el;
|
||||||
|
}
|
||||||
|
return document.getElementById('plugin-settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPlugins() {
|
||||||
|
if (_loadPluginsInFlight) { console.log('[feedBack] loadPlugins: in-flight, skipping'); return null; }
|
||||||
|
_loadPluginsInFlight = true;
|
||||||
|
console.log('[feedBack] loadPlugins: start');
|
||||||
|
let plugins;
|
||||||
|
const navContainer = document.getElementById('nav-plugins');
|
||||||
|
const mobileNavContainer = document.getElementById('mobile-nav-plugins');
|
||||||
|
// Snapshot current nav so we can restore it if the fetch fails.
|
||||||
|
const _savedNav = navContainer ? navContainer.innerHTML : null;
|
||||||
|
const _savedMobileNav = mobileNavContainer ? mobileNavContainer.innerHTML : null;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/plugins');
|
||||||
|
const fetchedPlugins = await resp.json();
|
||||||
|
const capabilityPlugins = fetchedPlugins.slice().sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')));
|
||||||
|
plugins = fetchedPlugins.slice().sort((a, b) => {
|
||||||
|
const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
|
||||||
|
return nameDelta || String(a.id || '').localeCompare(String(b.id || ''));
|
||||||
|
});
|
||||||
|
// NOTE deliberately NO stale-contribution sweep for plugins absent
|
||||||
|
// from this response. Absent ≠ uninstalled: the backend clears its
|
||||||
|
// plugin registry at the start of load_plugins() and repopulates it
|
||||||
|
// incrementally while HTTP stays up, so every backend restart serves a
|
||||||
|
// window of partial (even empty) responses. The old sweep unmounted UI
|
||||||
|
// contributions and unregistered capability participants on mere
|
||||||
|
// absence, permanently breaking still-loaded plugins — their scripts
|
||||||
|
// don't re-run (loadedScripts guard below), so nothing ever
|
||||||
|
// re-registered. A genuine mid-session uninstall now leaves the
|
||||||
|
// (already-evaluated, un-unloadable) script's contributions in place
|
||||||
|
// until reload; its nav entry still disappears because nav is rebuilt
|
||||||
|
// from the response each round. Same invariant as the settings/screen
|
||||||
|
// DOM wipe and _reconcilePluginStyles below.
|
||||||
|
console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const capabilityApi = window.feedBack?.capabilities;
|
||||||
|
if (capabilityApi?.registerParticipants) {
|
||||||
|
capabilityApi.registerParticipants(capabilityPlugins);
|
||||||
|
if (capabilityApi.registerCompatibilityShim) {
|
||||||
|
for (const plugin of capabilityPlugins) {
|
||||||
|
for (const shim of Array.isArray(plugin.compatibility_shims) ? plugin.compatibility_shims : []) {
|
||||||
|
capabilityApi.registerCompatibilityShim(shim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
capabilityApi.validateRuntime?.({ phase: 'plugin-manifest-load' });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[feedBack] capability manifest registration failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin settings panels mount into one of several tab containers —
|
||||||
|
// see _pluginSettingsContainers()/_pluginSettingsTarget() above.
|
||||||
|
|
||||||
|
// Plugins whose screen.js has already been evaluated this session
|
||||||
|
// at the current version AND whose DOM is still in the document.
|
||||||
|
// Their listeners were bound to the existing settings / screen DOM,
|
||||||
|
// so we must preserve that DOM — the script load guard below skips
|
||||||
|
// re-evaluating screen.js, and a fresh empty DOM with no listeners
|
||||||
|
// would leave the plugin half-hydrated on subsequent loadPlugins()
|
||||||
|
// calls (e.g. the streamed refetches in _streamPluginStartup).
|
||||||
|
//
|
||||||
|
// The DOM-existence check is the safety net for plugins that
|
||||||
|
// disappeared and reappeared between calls (uninstall + reinstall,
|
||||||
|
// or a backend snapshot churn that drops a plugin then restores
|
||||||
|
// it). In that case the loadedScripts key would still be set, but
|
||||||
|
// any listeners are bound to elements that have since been removed
|
||||||
|
// — drop the stale key so screen.js re-runs against the fresh DOM
|
||||||
|
// we're about to inject.
|
||||||
|
// Map<pluginId, version> — one entry per plugin. Storing only the
|
||||||
|
// currently-loaded version (rather than a Set of all (id, version)
|
||||||
|
// pairs ever loaded) means upgrade → downgrade → upgrade cycles
|
||||||
|
// within one session don't leave stale keys that could mistakenly
|
||||||
|
// mark an old version as already-hydrated. Coerce a legacy Set, if
|
||||||
|
// present, to an empty Map — the previous shape never shipped.
|
||||||
|
let loadedScripts = window.feedBack._loadedPluginScripts;
|
||||||
|
if (!(loadedScripts instanceof Map)) {
|
||||||
|
loadedScripts = new Map();
|
||||||
|
window.feedBack._loadedPluginScripts = loadedScripts;
|
||||||
|
}
|
||||||
|
const _removePluginScriptTags = (pluginId) => {
|
||||||
|
// Filter via dataset rather than a CSS attribute selector —
|
||||||
|
// CSS.escape is not universally available, and plugin IDs
|
||||||
|
// aren't constrained server-side.
|
||||||
|
document.querySelectorAll('script[data-plugin-id]').forEach((s) => {
|
||||||
|
if (s.dataset.pluginId === pluginId) s.remove();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// Mirror of loadedScripts for the plugin `styles` capability: a single
|
||||||
|
// versioned <link rel=stylesheet> per plugin lives in <head>, deduped by
|
||||||
|
// id → version so an upgrade swaps it and re-activation doesn't pile up
|
||||||
|
// duplicate tags. The <link> covers both the plugin's screen and its
|
||||||
|
// settings panel. Plugins ship preflight-off (utilities only) CSS, so a
|
||||||
|
// stylesheet that lingers after deactivation can't bleed a base reset.
|
||||||
|
let loadedStyles = window.feedBack._loadedPluginStyles;
|
||||||
|
if (!(loadedStyles instanceof Map)) {
|
||||||
|
loadedStyles = new Map();
|
||||||
|
window.feedBack._loadedPluginStyles = loadedStyles;
|
||||||
|
}
|
||||||
|
const _removePluginStyleTags = (pluginId) => {
|
||||||
|
// Same dataset-filter rationale as _removePluginScriptTags.
|
||||||
|
document.querySelectorAll('link[data-plugin-id]').forEach((l) => {
|
||||||
|
if (l.dataset.pluginId === pluginId) l.remove();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const _injectPluginStyles = (plugin) => {
|
||||||
|
// Tear down a <link> we injected earlier this session when the plugin
|
||||||
|
// no longer ships a usable stylesheet — upgraded to drop `styles`, or
|
||||||
|
// to an invalid path — so stale CSS can't keep applying after the
|
||||||
|
// plugin disabled its styling.
|
||||||
|
const teardownStale = () => {
|
||||||
|
if (loadedStyles.has(plugin.id)) {
|
||||||
|
_removePluginStyleTags(plugin.id);
|
||||||
|
loadedStyles.delete(plugin.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!plugin.has_styles || !plugin.styles) { teardownStale(); return; }
|
||||||
|
// `styles` is a plugin-root-relative path (like screen/script/routes)
|
||||||
|
// and must live under assets/ so it serves through the sandboxed
|
||||||
|
// asset route — e.g. "assets/plugin.css". Reject anything that can't
|
||||||
|
// reach a served file or would build a malformed URL: not under
|
||||||
|
// assets/, a `..` traversal segment, a backslash, or a `?`/`#` that
|
||||||
|
// would collide with the cache-busting query we append. The server
|
||||||
|
// also enforces containment via safe_join — this just avoids the
|
||||||
|
// wasted 404 and matches the documented contract.
|
||||||
|
const path = String(plugin.styles).replace(/^\/+/, '');
|
||||||
|
const unsafe = !path.startsWith('assets/')
|
||||||
|
|| /(^|\/)\.\.(\/|$)/.test(path)
|
||||||
|
|| /[\\?#]/.test(path);
|
||||||
|
if (unsafe) {
|
||||||
|
console.warn(`Plugin ${plugin.id}: styles must be a path under assets/ with no "..", backslash, or query/fragment (got "${plugin.styles}") — skipping`);
|
||||||
|
teardownStale();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wantedVersion = plugin.version || '';
|
||||||
|
// Idempotent: same id+version already injected → nothing to do.
|
||||||
|
if (loadedStyles.get(plugin.id) === wantedVersion) return;
|
||||||
|
// A different version (or none) was loaded — drop the prior <link>
|
||||||
|
// so we never accumulate stale stylesheets across upgrades.
|
||||||
|
_removePluginStyleTags(plugin.id);
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.dataset.pluginId = plugin.id;
|
||||||
|
link.dataset.pluginVersion = wantedVersion;
|
||||||
|
// Version in the URL (the plugin `version`, mirroring the screen.js
|
||||||
|
// loader's ?v= convention) so a plugin upgrade within one session
|
||||||
|
// fetches fresh CSS instead of a copy cached by path alone.
|
||||||
|
const v = encodeURIComponent(wantedVersion);
|
||||||
|
link.href = `/api/plugins/${plugin.id}/${path}${v ? `?v=${v}` : ''}`;
|
||||||
|
// Cascade ordering: insert this <link> BEFORE core's prebuilt
|
||||||
|
// Tailwind (/static/tailwind.min.css) instead of appending at the
|
||||||
|
// end of <head>. A plugin that ships a full utility build — the
|
||||||
|
// default output of running the Tailwind CLI without a scoped
|
||||||
|
// content config — re-defines core utilities like .grid /
|
||||||
|
// .xl:grid-cols-4; appended last, those equal-specificity rules
|
||||||
|
// would win on source order and clobber core's responsive layout
|
||||||
|
// (e.g. the library grid collapses to 2 columns, the nav bar
|
||||||
|
// breaks). Loading the plugin sheet first means core wins any
|
||||||
|
// EQUAL-specificity collision, while the plugin's own namespaced
|
||||||
|
// classes still apply. A plugin can still deliberately override core
|
||||||
|
// via higher-specificity selectors or !important — this only removes
|
||||||
|
// the accidental source-order clobber.
|
||||||
|
const coreSheet =
|
||||||
|
document.head.querySelector('link[rel="stylesheet"][href*="tailwind.min.css"]')
|
||||||
|
|| document.head.querySelector('link[rel="stylesheet"]');
|
||||||
|
if (coreSheet) {
|
||||||
|
document.head.insertBefore(link, coreSheet);
|
||||||
|
} else {
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
loadedStyles.set(plugin.id, wantedVersion);
|
||||||
|
};
|
||||||
|
const _reconcilePluginStyles = (currentPlugins) => {
|
||||||
|
// Drop stylesheets for plugins the response KNOWS about but that
|
||||||
|
// are no longer ready+styled this round. _injectPluginStyles below
|
||||||
|
// only visits plugins still returned by the API, so a newly-not-
|
||||||
|
// ready or unstyled plugin would otherwise keep its <link>
|
||||||
|
// applying. Plugins merely ABSENT from the response keep their
|
||||||
|
// stylesheet — a transient partial response during a backend
|
||||||
|
// restart is not an uninstall (same invariant as the screen/
|
||||||
|
// settings wipe below), and stripping the <link> would leave a
|
||||||
|
// still-loaded plugin visible but unstyled.
|
||||||
|
const responded = new Set(currentPlugins.map((p) => p.id));
|
||||||
|
const styled = new Set(
|
||||||
|
currentPlugins
|
||||||
|
.filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles)
|
||||||
|
.map((p) => p.id),
|
||||||
|
);
|
||||||
|
for (const id of Array.from(loadedStyles.keys())) {
|
||||||
|
if (responded.has(id) && !styled.has(id)) {
|
||||||
|
_removePluginStyleTags(id);
|
||||||
|
loadedStyles.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const existingSettingsByPluginId = new Map();
|
||||||
|
for (const container of _pluginSettingsContainers()) {
|
||||||
|
for (const child of container.children) {
|
||||||
|
const pid = child.dataset ? child.dataset.pluginId : null;
|
||||||
|
if (pid) existingSettingsByPluginId.set(pid, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Plugins named in THIS response. A plugin can be transiently absent
|
||||||
|
// from /api/plugins — the backend clears its registry at the start of
|
||||||
|
// load_plugins() and repopulates it incrementally while HTTP stays up,
|
||||||
|
// so every backend restart serves a window of partial (even empty)
|
||||||
|
// responses. The wipe loops below must never treat that absence as an
|
||||||
|
// uninstall: stripping a still-loaded plugin's DOM while keeping its
|
||||||
|
// loadedScripts entry made the NEXT refetch fail the DOM check and
|
||||||
|
// re-evaluate its screen.js mid-session — which duplicated the desktop
|
||||||
|
// audio_engine's native signal chain (its init re-ran against the
|
||||||
|
// surviving engine chain). Absent plugins keep their DOM and script;
|
||||||
|
// they're re-reconciled when they reappear in a later response.
|
||||||
|
const respondedIds = new Set(plugins.map((p) => p.id));
|
||||||
|
const alreadyHydrated = new Set();
|
||||||
|
for (const p of plugins) {
|
||||||
|
if (!p.has_script) continue;
|
||||||
|
// Version must match exactly — an upgrade / downgrade has to
|
||||||
|
// re-run the new script against fresh DOM.
|
||||||
|
if (loadedScripts.get(p.id) !== (p.version || '')) continue;
|
||||||
|
const screenOk = !p.has_screen || !!document.getElementById(`plugin-${p.id}`);
|
||||||
|
const settingsOk = !p.has_settings || existingSettingsByPluginId.has(p.id);
|
||||||
|
if (screenOk && settingsOk) {
|
||||||
|
alreadyHydrated.add(p.id);
|
||||||
|
} else {
|
||||||
|
// DOM was wiped externally (uninstall + reinstall, snapshot
|
||||||
|
// churn) — drop the entry and remove the orphaned <script>
|
||||||
|
// so screen.js re-runs against fresh DOM below.
|
||||||
|
loadedScripts.delete(p.id);
|
||||||
|
_removePluginScriptTags(p.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear plugin-owned containers, but keep already-hydrated plugins'
|
||||||
|
// settings / screen DOM. Nav links carry no per-plugin script state,
|
||||||
|
// so always rebuild them.
|
||||||
|
navContainer.innerHTML = '';
|
||||||
|
mobileNavContainer.innerHTML = '<span class="text-xs text-gray-600 uppercase tracking-wider">Plugins</span>';
|
||||||
|
for (const container of _pluginSettingsContainers()) {
|
||||||
|
[...container.children].forEach((el) => {
|
||||||
|
const pid = el.dataset ? el.dataset.pluginId : null;
|
||||||
|
// Remove junk (no plugin id) and plugins the response KNOWS
|
||||||
|
// about but that failed hydration; leave plugins absent from
|
||||||
|
// the response untouched (see respondedIds above).
|
||||||
|
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.screen[id^="plugin-"]').forEach((el) => {
|
||||||
|
// dataset.pluginId is the source of truth (set on injection);
|
||||||
|
// the id-prefix fallback covers screens injected before this
|
||||||
|
// change shipped — both forms strip a single leading "plugin-".
|
||||||
|
const pid = (el.dataset && el.dataset.pluginId)
|
||||||
|
|| el.id.replace(/^plugin-/, '');
|
||||||
|
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plugin settings area hosts both "Plugin Updates" and per-plugin
|
||||||
|
// collapsibles. Reveal it whenever any plugins are installed —
|
||||||
|
// updates are relevant even for plugins that contribute no settings.
|
||||||
|
if (plugins.length > 0) {
|
||||||
|
const area = document.getElementById('plugin-settings-area');
|
||||||
|
if (area) area.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build plugin dropdown for desktop nav
|
||||||
|
const navPlugins = plugins.map(plugin => ({ plugin, nav: _pluginNav(plugin) })).filter(entry => entry.nav);
|
||||||
|
if (navPlugins.length > 0) {
|
||||||
|
const dropdown = document.createElement('div');
|
||||||
|
dropdown.className = 'relative';
|
||||||
|
dropdown.innerHTML = `
|
||||||
|
<button class="text-sm text-gray-400 hover:text-white transition flex items-center gap-1" onclick="this.nextElementSibling.classList.toggle('hidden')">
|
||||||
|
Plugins
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="hidden absolute top-full left-0 mt-2 bg-dark-800 border border-gray-700 rounded-xl shadow-xl py-2 min-w-[180px] max-h-[80vh] overflow-y-auto z-50" id="plugin-dropdown"></div>`;
|
||||||
|
navContainer.appendChild(dropdown);
|
||||||
|
const ddMenu = dropdown.querySelector('#plugin-dropdown');
|
||||||
|
|
||||||
|
// Close the plugin dropdown when clicking outside it. Bind ONCE:
|
||||||
|
// loadPlugins() re-runs on every plugin status change during
|
||||||
|
// startup (SSE-driven refetches), and each run rebuilds `dropdown`
|
||||||
|
// / `ddMenu`. A per-run addEventListener would leak a new global
|
||||||
|
// click listener on every refetch, each closing over a now-detached
|
||||||
|
// dropdown. The one-time handler instead resolves the LIVE dropdown
|
||||||
|
// from the DOM at click time, so it always targets the current one.
|
||||||
|
if (!window.feedBack._pluginDropdownOutsideClickBound) {
|
||||||
|
window.feedBack._pluginDropdownOutsideClickBound = true;
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const menu = document.getElementById('plugin-dropdown');
|
||||||
|
if (!menu) return;
|
||||||
|
const container = menu.parentElement;
|
||||||
|
if (container && !container.contains(e.target)) menu.classList.add('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { plugin, nav } of navPlugins) {
|
||||||
|
const screenId = `plugin-${plugin.id}`;
|
||||||
|
// A plugin is navigable only once it's ready. While its deps
|
||||||
|
// install (status "installing") or after a failed load
|
||||||
|
// (status "failed") we still render the nav slot — disabled,
|
||||||
|
// with an "installing…" suffix or the error as a tooltip — so
|
||||||
|
// the nav is stable and the user sees the plugin is coming
|
||||||
|
// (#421). Entries without a status (legacy / stub) are ready.
|
||||||
|
const status = plugin.status || 'ready';
|
||||||
|
const isReady = status === 'ready';
|
||||||
|
// nav is truthy here (navPlugins is filtered on entry.nav), and
|
||||||
|
// is the computed value from _pluginNav() — which may be a
|
||||||
|
// string, an object that omits `label`, or a synthesized object
|
||||||
|
// (e.g. the Capability Inspector). _navLabel() normalizes all
|
||||||
|
// three and falls back to name/id so a missing label never
|
||||||
|
// renders "undefined" or throws. Use the loop's `nav`, not the
|
||||||
|
// raw `plugin.nav`, so string and synthesized labels survive.
|
||||||
|
const label = _navLabel(nav, plugin);
|
||||||
|
|
||||||
|
const item = document.createElement('a');
|
||||||
|
item.href = '#';
|
||||||
|
ddMenu.appendChild(item);
|
||||||
|
// Mobile nav — flat list
|
||||||
|
const ma = document.createElement('a');
|
||||||
|
ma.href = '#';
|
||||||
|
mobileNavContainer.appendChild(ma);
|
||||||
|
|
||||||
|
if (isReady) {
|
||||||
|
item.className = 'block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-dark-700 transition';
|
||||||
|
item.textContent = label;
|
||||||
|
item.onclick = (e) => { e.preventDefault(); ddMenu.classList.add('hidden'); window.showScreen(screenId); window.feedBackDemoTrack?.('event/plugin-open/' + plugin.id); };
|
||||||
|
ma.className = 'text-gray-400 hover:text-white pl-4 text-sm';
|
||||||
|
ma.textContent = label;
|
||||||
|
ma.onclick = (e) => { e.preventDefault(); window.showScreen(screenId); ma.closest('#mobile-menu').classList.add('hidden'); window.feedBackDemoTrack?.('event/plugin-open/' + plugin.id); };
|
||||||
|
} else {
|
||||||
|
const installing = status === 'installing';
|
||||||
|
const suffix = installing ? ' (installing…)' : ' (failed)';
|
||||||
|
const tip = installing
|
||||||
|
? 'This plugin is installing its dependencies and will become available shortly.'
|
||||||
|
: (plugin.error || 'This plugin failed to load. Check the server startup log for details.');
|
||||||
|
// Disabled appearance: dimmed, default cursor, no nav handler.
|
||||||
|
const cls = 'block px-4 py-2 text-sm text-gray-600 cursor-default select-none'
|
||||||
|
+ (installing ? ' animate-pulse' : '');
|
||||||
|
item.className = cls;
|
||||||
|
item.setAttribute('aria-disabled', 'true');
|
||||||
|
item.title = tip;
|
||||||
|
item.textContent = label + suffix;
|
||||||
|
// Drop disabled entries out of the tab order and strip the
|
||||||
|
// href so keyboard/screen-reader users don't land on a
|
||||||
|
// non-actionable "link" (a11y). Swallow clicks too, in case
|
||||||
|
// it's still reached via mouse.
|
||||||
|
item.removeAttribute('href');
|
||||||
|
item.setAttribute('tabindex', '-1');
|
||||||
|
item.onclick = (e) => { e.preventDefault(); };
|
||||||
|
ma.className = 'pl-4 text-sm text-gray-600 cursor-default select-none' + (installing ? ' animate-pulse' : '');
|
||||||
|
ma.setAttribute('aria-disabled', 'true');
|
||||||
|
ma.title = tip;
|
||||||
|
ma.textContent = label + suffix;
|
||||||
|
ma.removeAttribute('href');
|
||||||
|
ma.setAttribute('tabindex', '-1');
|
||||||
|
ma.onclick = (e) => { e.preventDefault(); };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tear down stylesheets for plugins that are gone / no longer styled
|
||||||
|
// before (re)injecting for the current set.
|
||||||
|
_reconcilePluginStyles(plugins);
|
||||||
|
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
try {
|
||||||
|
// Only ready plugins have their assets available (the backend
|
||||||
|
// guards screen.html/screen.js/settings.html on status=="ready").
|
||||||
|
// Installing/failed plugins contribute only the disabled nav slot
|
||||||
|
// built above — skip screen/settings/script injection for them.
|
||||||
|
if (plugin.status && plugin.status !== 'ready') continue;
|
||||||
|
await _registerLegacyPluginUiContributions(plugin);
|
||||||
|
const screenId = `plugin-${plugin.id}`;
|
||||||
|
|
||||||
|
// Inject the plugin's stylesheet FIRST (before screen HTML/JS) so
|
||||||
|
// its utilities are present on first paint. Idempotent + version-
|
||||||
|
// deduped, so it's safe to call for already-hydrated plugins too.
|
||||||
|
_injectPluginStyles(plugin);
|
||||||
|
|
||||||
|
// Inject screen container. Skip for already-hydrated plugins —
|
||||||
|
// their existing screen DOM still has the listeners that
|
||||||
|
// screen.js bound on first load (rebuilding here would orphan
|
||||||
|
// them, since the script load guard further down won't re-run
|
||||||
|
// screen.js to re-bind).
|
||||||
|
if (plugin.has_screen && !alreadyHydrated.has(plugin.id)) {
|
||||||
|
const screenDiv = document.createElement('div');
|
||||||
|
screenDiv.id = screenId;
|
||||||
|
screenDiv.className = 'screen';
|
||||||
|
screenDiv.dataset.pluginId = plugin.id;
|
||||||
|
screenDiv.dataset.pluginVersion = plugin.version || '';
|
||||||
|
// Insert before the player screen
|
||||||
|
const player = document.getElementById('player');
|
||||||
|
player.parentNode.insertBefore(screenDiv, player);
|
||||||
|
|
||||||
|
const htmlResp = await fetch(`/api/plugins/${plugin.id}/screen.html`);
|
||||||
|
screenDiv.innerHTML = await htmlResp.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject settings section — wrapped in a collapsible <details>
|
||||||
|
// per plugin so the page stays scannable as plugins accumulate.
|
||||||
|
// Collapsed by default; <details>/<summary> handles state natively.
|
||||||
|
// Skip for already-hydrated plugins — preserved details element
|
||||||
|
// still carries listeners wired by its inline settings script
|
||||||
|
// and by screen.js on first load.
|
||||||
|
// Resolve which settings tab this plugin's panel mounts under
|
||||||
|
// (manifest settings.category), falling back to '#plugin-settings'.
|
||||||
|
const settingsTarget = plugin.has_settings ? _pluginSettingsTarget(plugin) : null;
|
||||||
|
if (plugin.has_settings && settingsTarget && !alreadyHydrated.has(plugin.id)) {
|
||||||
|
const details = document.createElement('details');
|
||||||
|
details.className = 'bg-dark-700/40 border border-gray-800 rounded-xl overflow-hidden group';
|
||||||
|
details.dataset.pluginId = plugin.id;
|
||||||
|
details.dataset.pluginVersion = plugin.version || '';
|
||||||
|
|
||||||
|
const summary = document.createElement('summary');
|
||||||
|
// .plugin-settings-summary class hides the browser's native
|
||||||
|
// disclosure triangle (see style.css) so only our chevron shows.
|
||||||
|
// flex-col allows the fallback explanation note to appear below
|
||||||
|
// the name/badges row when plugin.fallback is set.
|
||||||
|
summary.className = 'plugin-settings-summary cursor-pointer select-none px-4 py-3 text-sm font-medium text-gray-300 hover:bg-dark-700/70 transition flex flex-col';
|
||||||
|
// Inner row: plugin name/badges (left) + chevron (right).
|
||||||
|
const headerRow = document.createElement('span');
|
||||||
|
headerRow.className = 'flex items-center justify-between';
|
||||||
|
const labelWrap = document.createElement('span');
|
||||||
|
labelWrap.className = 'flex items-center gap-2';
|
||||||
|
const labelSpan = document.createElement('span');
|
||||||
|
labelSpan.textContent = plugin.name || plugin.id;
|
||||||
|
labelWrap.appendChild(labelSpan);
|
||||||
|
// "Bundled" marker (feedBack#160). Visually distinguishes
|
||||||
|
// plugins that ship with the default container image from
|
||||||
|
// user-installed ones so users don't try to remove a core
|
||||||
|
// plugin via the manage-plugin flow and brick a feature
|
||||||
|
// that's expected to "just work".
|
||||||
|
if (plugin.bundled) {
|
||||||
|
const bundledDesc = 'This plugin ships with FeedBack core and is expected to be present.';
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border border-purple-400/30 bg-purple-500/10 text-purple-300';
|
||||||
|
badge.title = bundledDesc;
|
||||||
|
badge.setAttribute('aria-label', 'Bundled — ' + bundledDesc);
|
||||||
|
badge.setAttribute('role', 'img');
|
||||||
|
badge.innerHTML = `
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 11c1.657 0 3-1.343 3-3V6a3 3 0 10-6 0v2c0 1.657 1.343 3 3 3zM6 11h12a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2v-6a2 2 0 012-2z"/>
|
||||||
|
</svg>
|
||||||
|
Bundled
|
||||||
|
`;
|
||||||
|
labelWrap.appendChild(badge);
|
||||||
|
}
|
||||||
|
// "Fallback" warning badge: the bundled copy failed to load its
|
||||||
|
// routes, so the server fell back to this older user-installed
|
||||||
|
// copy. Warn users so they know the bundled build is broken and
|
||||||
|
// can check the server startup log for the root cause.
|
||||||
|
if (plugin.fallback) {
|
||||||
|
const fbBadge = document.createElement('span');
|
||||||
|
fbBadge.className = 'inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border border-yellow-400/40 bg-yellow-500/10 text-yellow-300';
|
||||||
|
fbBadge.setAttribute('aria-hidden', 'true');
|
||||||
|
fbBadge.innerHTML = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg> Fallback';
|
||||||
|
labelWrap.appendChild(fbBadge);
|
||||||
|
}
|
||||||
|
// Assemble inner header row: [name/badges (left)] [chevron (right)].
|
||||||
|
// Both are placed in headerRow so the fallback note (if any)
|
||||||
|
// can sit below the entire row as a second flex-col child of
|
||||||
|
// summary, rather than being squeezed inline beside the chevron.
|
||||||
|
headerRow.appendChild(labelWrap);
|
||||||
|
// Chevron icon — built via setAttributeNS so the SVG sits in
|
||||||
|
// the SVG namespace and renders correctly. Plugin label is
|
||||||
|
// appended as text above so manifest values can't inject HTML.
|
||||||
|
const svgNS = 'http://www.w3.org/2000/svg';
|
||||||
|
const svg = document.createElementNS(svgNS, 'svg');
|
||||||
|
svg.setAttribute('class', 'w-4 h-4 text-gray-500 transition-transform group-open:rotate-180');
|
||||||
|
svg.setAttribute('fill', 'none');
|
||||||
|
svg.setAttribute('stroke', 'currentColor');
|
||||||
|
svg.setAttribute('viewBox', '0 0 24 24');
|
||||||
|
const svgPath = document.createElementNS(svgNS, 'path');
|
||||||
|
svgPath.setAttribute('stroke-linecap', 'round');
|
||||||
|
svgPath.setAttribute('stroke-linejoin', 'round');
|
||||||
|
svgPath.setAttribute('stroke-width', '2');
|
||||||
|
svgPath.setAttribute('d', 'M19 9l-7 7-7-7');
|
||||||
|
svg.appendChild(svgPath);
|
||||||
|
headerRow.appendChild(svg);
|
||||||
|
summary.appendChild(headerRow);
|
||||||
|
// Fallback explanation note: a visible <p> below the header row,
|
||||||
|
// accessible to touch/keyboard users (browser tooltip via title/
|
||||||
|
// aria-label alone is hover-only and insufficient). Appended to
|
||||||
|
// summary (not labelWrap) so it renders as the second child in
|
||||||
|
// summary's flex-col layout, appearing below the name+badges row.
|
||||||
|
if (plugin.fallback) {
|
||||||
|
const fbNote = document.createElement('span');
|
||||||
|
fbNote.className = 'block text-xs text-yellow-300/80 mt-1';
|
||||||
|
fbNote.textContent = 'The bundled version failed to start. This user-installed copy is serving as a fallback. Check the server startup log for details.';
|
||||||
|
summary.appendChild(fbNote);
|
||||||
|
}
|
||||||
|
details.appendChild(summary);
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.id = `plugin-settings-${plugin.id}`;
|
||||||
|
body.className = 'px-4 py-4 border-t border-gray-800 space-y-4';
|
||||||
|
details.appendChild(body);
|
||||||
|
|
||||||
|
settingsTarget.appendChild(details);
|
||||||
|
|
||||||
|
const settingsResp = await fetch(`/api/plugins/${plugin.id}/settings.html`);
|
||||||
|
body.innerHTML = await settingsResp.text();
|
||||||
|
// <script> tags inserted via innerHTML are intentionally
|
||||||
|
// inert per the HTML5 spec — the browser parses them as
|
||||||
|
// DOM nodes but never runs the body. That silently breaks
|
||||||
|
// any plugin settings.html that wires event handlers via
|
||||||
|
// addEventListener (e.g. file pickers, anything that
|
||||||
|
// can't be expressed as an inline onclick=… attribute),
|
||||||
|
// and any inline IIFE that hydrates form values from
|
||||||
|
// localStorage. Re-create each script node — script
|
||||||
|
// elements created via document.createElement DO execute
|
||||||
|
// when appended — so plugins get the script behavior
|
||||||
|
// they'd expect from a normal HTML document.
|
||||||
|
body.querySelectorAll('script').forEach(oldScript => {
|
||||||
|
const newScript = document.createElement('script');
|
||||||
|
for (const attr of oldScript.attributes) {
|
||||||
|
newScript.setAttribute(attr.name, attr.value);
|
||||||
|
}
|
||||||
|
newScript.textContent = oldScript.textContent;
|
||||||
|
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load plugin JS
|
||||||
|
if (plugin.has_script) {
|
||||||
|
const wantedVersion = plugin.version || '';
|
||||||
|
if (loadedScripts.get(plugin.id) !== wantedVersion) {
|
||||||
|
// A different version (or none) was loaded previously —
|
||||||
|
// remove the prior <script> tag for this plugin id so we
|
||||||
|
// don't accumulate stale versions on upgrade/downgrade.
|
||||||
|
_removePluginScriptTags(plugin.id);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
// Include version in URL so a plugin upgrade within the
|
||||||
|
// same browser session fetches the new screen.js instead
|
||||||
|
// of a cached copy keyed only by path (matches the art
|
||||||
|
// URL ?v=mtime convention elsewhere in this file).
|
||||||
|
const v = encodeURIComponent(wantedVersion);
|
||||||
|
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
|
||||||
|
// Module-migration (R0): a migrated plugin declares
|
||||||
|
// scriptType:"module" and its screen.js is `import
|
||||||
|
// './src/main.js'`. A <script type="module"> fires load
|
||||||
|
// only after its whole static-import graph evaluates, so
|
||||||
|
// the await-onload completion + _loadingPluginId contract
|
||||||
|
// below is preserved (a classic-IIFE dynamic import()
|
||||||
|
// would not). Classic plugins are unaffected.
|
||||||
|
if (plugin.script_type === 'module') script.type = 'module';
|
||||||
|
script.dataset.pluginId = plugin.id;
|
||||||
|
script.dataset.pluginVersion = wantedVersion;
|
||||||
|
window.feedBack._loadingPluginId = plugin.id;
|
||||||
|
script.onload = () => {
|
||||||
|
if (window.feedBack._loadingPluginId === plugin.id) delete window.feedBack._loadingPluginId;
|
||||||
|
loadedScripts.set(plugin.id, wantedVersion);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
script.onerror = (err) => {
|
||||||
|
if (window.feedBack._loadingPluginId === plugin.id) delete window.feedBack._loadingPluginId;
|
||||||
|
loadedScripts.delete(plugin.id);
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Plugin '${plugin.id}' failed to load, skipping:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load plugins:', e);
|
||||||
|
// Restore nav so a failed re-hydration call doesn't leave it blank.
|
||||||
|
if (_savedNav !== null && navContainer) navContainer.innerHTML = _savedNav;
|
||||||
|
if (_savedMobileNav !== null && mobileNavContainer) mobileNavContainer.innerHTML = _savedMobileNav;
|
||||||
|
_loadPluginsInFlight = false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_loadPluginsInFlight = false;
|
||||||
|
return plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-run loadPlugins (and the viz picker, since a newly-ready plugin may
|
||||||
|
// register a window.feedBackViz_<id> factory) when plugin status changes.
|
||||||
|
// Debounced so a burst of plugin-registered/plugin-error events during
|
||||||
|
// startup collapses into a single refetch.
|
||||||
|
let _pluginRefreshTimer = null;
|
||||||
|
function _refreshPluginsSoon() {
|
||||||
|
clearTimeout(_pluginRefreshTimer);
|
||||||
|
_pluginRefreshTimer = setTimeout(async () => {
|
||||||
|
const plugins = await loadPlugins();
|
||||||
|
if (plugins) {
|
||||||
|
_populateVizPicker(plugins);
|
||||||
|
} else {
|
||||||
|
// loadPlugins() returned null because a refetch was already in
|
||||||
|
// flight, so this status change would otherwise be dropped. Re-arm
|
||||||
|
// the debounce so the newer state is still applied once the
|
||||||
|
// in-flight load finishes. Reuses the 250ms delay (and the
|
||||||
|
// in-flight guard clears quickly), so this can't tight-loop.
|
||||||
|
_refreshPluginsSoon();
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _pluginStreamStarted = false;
|
||||||
|
function _streamPluginStartup() {
|
||||||
|
// Watch the SAME /api/startup-status/stream the splash used to gate on.
|
||||||
|
// Instead of blocking, we let the nav render immediately (loadPlugins ran
|
||||||
|
// already) and refetch whenever a plugin graduates to ready or fails — so
|
||||||
|
// its nav slot flips from "installing…" to active/failed without a reload
|
||||||
|
// (#421). loadPlugins is idempotent (in-flight guard + version map), so
|
||||||
|
// extra refetches are cheap and safe.
|
||||||
|
if (_pluginStreamStarted) return;
|
||||||
|
_pluginStreamStarted = true;
|
||||||
|
|
||||||
|
if (typeof EventSource === 'undefined') { _pollPluginStartup(); return; }
|
||||||
|
|
||||||
|
const es = new EventSource('/api/startup-status/stream');
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
let status;
|
||||||
|
try { status = JSON.parse(event.data); } catch { return; }
|
||||||
|
if (!status || status.type === 'keepalive') return;
|
||||||
|
const phase = (status.phase || '').trim();
|
||||||
|
if (phase === 'plugin-registered' || phase === 'plugin-error') {
|
||||||
|
_refreshPluginsSoon();
|
||||||
|
}
|
||||||
|
// Terminal: one last refetch to catch anything missed, then stop.
|
||||||
|
if (!status.running && (phase === 'complete' || phase === 'error')) {
|
||||||
|
_refreshPluginsSoon();
|
||||||
|
es.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
es.onerror = () => {
|
||||||
|
// Stream dropped (proxy buffering, backend hiccup). Stop retrying the
|
||||||
|
// stream and fall back to a bounded poll so late installs still surface.
|
||||||
|
es.close();
|
||||||
|
_pollPluginStartup();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let _pollStartupStarted = false;
|
||||||
|
async function _pollPluginStartup() {
|
||||||
|
// SSE-unavailable fallback: poll /api/startup-status until the backend
|
||||||
|
// finishes its plugin loader, refetching whenever the ready count changes
|
||||||
|
// or it goes terminal. Bounded so a backend that never finishes doesn't
|
||||||
|
// poll forever.
|
||||||
|
if (_pollStartupStarted) return;
|
||||||
|
_pollStartupStarted = true;
|
||||||
|
// Generous headroom over the documented worst case (whisperx → torch et al.
|
||||||
|
// can take 20-30 min): a 30-min ceiling would stop polling right as a
|
||||||
|
// slipping install — slow mirror, pip retry — actually finishes. 60 min
|
||||||
|
// leaves margin so the late graduation still surfaces. (#421)
|
||||||
|
const DEADLINE_MS = 60 * 60 * 1000;
|
||||||
|
const start = Date.now();
|
||||||
|
// Track a composite signature, not just the ready count: a plugin can fail
|
||||||
|
// (phase → "plugin-error", current_plugin/error change) without changing
|
||||||
|
// `loaded`, e.g. the next plugin breaks after all prior ones succeeded.
|
||||||
|
// Watching only `loaded` would miss that transition until some later
|
||||||
|
// ready-count change or terminal completion, so the failed/error nav state
|
||||||
|
// wouldn't surface. Refetch whenever any of these move.
|
||||||
|
let lastSig = null;
|
||||||
|
while (Date.now() - start < DEADLINE_MS) {
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/startup-status');
|
||||||
|
if (!resp.ok) continue;
|
||||||
|
const status = await resp.json();
|
||||||
|
const sig = JSON.stringify([
|
||||||
|
Number(status.loaded || 0),
|
||||||
|
status.phase || '',
|
||||||
|
status.current_plugin || '',
|
||||||
|
status.error || '',
|
||||||
|
]);
|
||||||
|
if (sig !== lastSig) { lastSig = sig; _refreshPluginsSoon(); }
|
||||||
|
if (!status.running) { _refreshPluginsSoon(); return; }
|
||||||
|
} catch (_e) { /* network error — keep trying */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapPluginsAndUi() {
|
||||||
|
// #421: never gate the nav on full plugin startup. Render it immediately
|
||||||
|
// from /api/plugins (ready plugins active; installing/failed disabled),
|
||||||
|
// then stream plugin status so each entry resolves in place as its
|
||||||
|
// dependencies finish installing or its load fails.
|
||||||
|
const plugins = await loadPlugins();
|
||||||
|
_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';
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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'}).`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ const fs = require('node:fs');
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
const ROOT = path.join(__dirname, '..', '..');
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
const APP_JS = path.join(ROOT, 'static', 'app.js');
|
const PLUGIN_LOADER_JS = path.join(ROOT, 'static', 'js', 'plugin-loader.js');
|
||||||
const MANIFEST = path.join(ROOT, 'plugins', 'capability_inspector', 'plugin.json');
|
const MANIFEST = path.join(ROOT, 'plugins', 'capability_inspector', 'plugin.json');
|
||||||
const SCREEN_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'screen.html');
|
const SCREEN_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'screen.html');
|
||||||
const SETTINGS_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'settings.html');
|
const SETTINGS_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'settings.html');
|
||||||
@@ -28,7 +28,7 @@ test('capability inspector manifest ships settings but no default nav entry', ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('capability inspector plugins menu entry is localStorage opt-in', () => {
|
test('capability inspector plugins menu entry is localStorage opt-in', () => {
|
||||||
const src = source(APP_JS);
|
const src = source(PLUGIN_LOADER_JS);
|
||||||
const helper = region(src, "const CAPABILITY_INSPECTOR_NAV_SETTING = 'capability_inspector.showInPluginsMenu'", 1400);
|
const helper = region(src, "const CAPABILITY_INSPECTOR_NAV_SETTING = 'capability_inspector.showInPluginsMenu'", 1400);
|
||||||
const menu = region(src, 'const navPlugins = plugins.map', 1000);
|
const menu = region(src, 'const navPlugins = plugins.map', 1000);
|
||||||
const contributions = region(src, 'async function _registerLegacyPluginUiContributions(plugin)', 1400);
|
const contributions = region(src, 'async function _registerLegacyPluginUiContributions(plugin)', 1400);
|
||||||
@@ -106,7 +106,7 @@ test('capability inspector screen ships scoped graph lane CSS', () => {
|
|||||||
assert.match(html, /left: -1\.75rem/);
|
assert.match(html, /left: -1\.75rem/);
|
||||||
});
|
});
|
||||||
test('_navLabel resolves string, object, synthesized, and empty nav values', () => {
|
test('_navLabel resolves string, object, synthesized, and empty nav values', () => {
|
||||||
const src = source(APP_JS);
|
const src = source(PLUGIN_LOADER_JS);
|
||||||
const m = src.match(/function _navLabel\(nav, plugin\) \{[\s\S]*?\n\}/);
|
const m = src.match(/function _navLabel\(nav, plugin\) \{[\s\S]*?\n\}/);
|
||||||
assert.ok(m, 'could not extract _navLabel from app.js');
|
assert.ok(m, 'could not extract _navLabel from app.js');
|
||||||
const _navLabel = new Function(`${m[0]}; return _navLabel;`)();
|
const _navLabel = new Function(`${m[0]}; return _navLabel;`)();
|
||||||
@@ -123,7 +123,7 @@ test('_navLabel resolves string, object, synthesized, and empty nav values', ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('plugin nav dropdown label uses the computed nav, not the raw plugin.nav', () => {
|
test('plugin nav dropdown label uses the computed nav, not the raw plugin.nav', () => {
|
||||||
const src = source(APP_JS);
|
const src = source(PLUGIN_LOADER_JS);
|
||||||
// Regression guard for the string/synthesized-nav label fix: the dropdown
|
// Regression guard for the string/synthesized-nav label fix: the dropdown
|
||||||
// label must derive from the loop's computed nav via _navLabel, not from
|
// label must derive from the loop's computed nav via _navLabel, not from
|
||||||
// plugin.nav?.label (which drops string and synthesized labels).
|
// plugin.nav?.label (which drops string and synthesized labels).
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ const assert = require('node:assert/strict');
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
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) {
|
function extractBlock(src, signature) {
|
||||||
const start = src.indexOf(signature);
|
const start = src.indexOf(signature);
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ const path = require('node:path');
|
|||||||
|
|
||||||
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||||
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.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).
|
// Brace-balanced extraction (same helper shape as highway_note_state.test.js).
|
||||||
function extractBlock(src, signature) {
|
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');
|
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', () => {
|
test('app.js color manager name-maps to both highways, with identity no-op + builtin guard', () => {
|
||||||
const src = fs.readFileSync(appJs, 'utf8');
|
const src = fs.readFileSync(appJs, 'utf8');
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// The host-seam contract: the hooks the modules USE must be exactly the hooks
|
||||||
|
// app.js WIRES.
|
||||||
|
//
|
||||||
|
// This is the test that makes the seam safe. static/js/host.js already throws at
|
||||||
|
// runtime when an unwired hook is read — but a runtime throw only fires if the
|
||||||
|
// broken path actually executes, and the entire danger of a host seam is the paths
|
||||||
|
// that DON'T run in a smoke test. That is not hypothetical: the plugin loader's
|
||||||
|
// seam defaulted a hook to `() => {}`, and a dropped wiring line would have left
|
||||||
|
// the viz picker silently not refreshing with no test, boot check, or bot noticing.
|
||||||
|
//
|
||||||
|
// So this closes it statically. Rename a hook in app.js, drop a line from the
|
||||||
|
// configureHost({…}) call, or typo a `host.foo` in a module, and CI fails — on a
|
||||||
|
// path nobody ever ran.
|
||||||
|
//
|
||||||
|
// It is deliberately symmetric:
|
||||||
|
// * used but not wired -> a latent crash (host.js would throw at runtime)
|
||||||
|
// * wired but not used -> dead weight, and usually the fossil of a rename
|
||||||
|
// Both fail.
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
const APP_JS = path.join(ROOT, 'static', 'app.js');
|
||||||
|
const JS_DIR = path.join(ROOT, 'static', 'js');
|
||||||
|
|
||||||
|
// Strip comments, so prose about `host.foo` in a header block is not read as a call
|
||||||
|
// site.
|
||||||
|
//
|
||||||
|
// NOTHING ELSE. An earlier version also tried to strip import statements (to stop
|
||||||
|
// `from './host.js'` reading as a hook called `js`) and its `[\s\S]*?` spanned lines
|
||||||
|
// and silently ate 14,000 characters of the file — including, in the bite test, the
|
||||||
|
// very drift it was supposed to catch. A guard with a hole in it is worse than no
|
||||||
|
// guard, because you trust it. The `host.js` path is excluded far more cheaply,
|
||||||
|
// below, by refusing a match followed by a quote.
|
||||||
|
function scrub(src) {
|
||||||
|
return src
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
|
.replace(/^\s*\/\/[^\n]*$/gm, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// `host.<name>` — but not `host.js'` from the `from './host.js'` import path, which is
|
||||||
|
// the one string in these files that looks like a hook and isn't.
|
||||||
|
//
|
||||||
|
// The trailing class must forbid a WORD character as well as a quote. With only
|
||||||
|
// `(?!['"])`, `host.js'` fails on `js` (a quote follows), then BACKTRACKS to `j` —
|
||||||
|
// where the next char is `s`, not a quote — and happily reports a hook called `j`.
|
||||||
|
// Forbidding `[\w$]` too leaves it nowhere to backtrack to.
|
||||||
|
const HOOK_RE = /(?<![\w$.])host\.([A-Za-z_$][\w$]*)(?![\w$'"])/g;
|
||||||
|
|
||||||
|
/** Every `host.<name>` referenced by a carved module. */
|
||||||
|
function hooksUsed() {
|
||||||
|
const used = new Map(); // name -> [files]
|
||||||
|
for (const file of fs.readdirSync(JS_DIR)) {
|
||||||
|
if (!file.endsWith('.js') || file === 'host.js') continue;
|
||||||
|
const raw = fs.readFileSync(path.join(JS_DIR, file), 'utf8');
|
||||||
|
if (!/from\s+'\.\/host\.js'/.test(raw)) continue;
|
||||||
|
for (const m of scrub(raw).matchAll(HOOK_RE)) {
|
||||||
|
if (!used.has(m[1])) used.set(m[1], []);
|
||||||
|
used.get(m[1]).push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return used;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every hook app.js passes to configureHost({ … }). */
|
||||||
|
function hooksWired() {
|
||||||
|
const src = scrub(fs.readFileSync(APP_JS, 'utf8'));
|
||||||
|
// NB the closing brace is INDENTED (the call sits inside the boot function), so
|
||||||
|
// anchoring on `\n});` at column 0 runs straight past it and swallows the next
|
||||||
|
// object literal in the file — which is how this first read 77 "hooks", most of
|
||||||
|
// them app.js's window contract.
|
||||||
|
const call = src.match(/configureHost\(\{([\s\S]*?)\n\s*\}\);/);
|
||||||
|
if (!call) return null; // no seam wired yet — fine until there is one
|
||||||
|
const wired = new Set();
|
||||||
|
for (const m of call[1].matchAll(/(?:^|,)\s*([A-Za-z_$][\w$]*)\s*(?=[,:}]|$)/gm)) {
|
||||||
|
wired.add(m[1]);
|
||||||
|
}
|
||||||
|
return wired;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('every host.<hook> a module uses is wired by app.js', () => {
|
||||||
|
const used = hooksUsed();
|
||||||
|
if (used.size === 0) return; // no consumers yet
|
||||||
|
const wired = hooksWired();
|
||||||
|
assert.ok(wired, 'modules import ./host.js but app.js never calls configureHost({ … })');
|
||||||
|
|
||||||
|
const missing = [...used.keys()]
|
||||||
|
.filter((h) => !wired.has(h))
|
||||||
|
.map((h) => `${h} (used in ${used.get(h).join(', ')})`);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
missing, [],
|
||||||
|
'these hooks are read by a module but never wired by app.js — they would throw at runtime, '
|
||||||
|
+ 'on whatever path happens to reach them',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every hook app.js wires is actually used by a module', () => {
|
||||||
|
const wired = hooksWired();
|
||||||
|
if (!wired || wired.size === 0) return;
|
||||||
|
const used = hooksUsed();
|
||||||
|
|
||||||
|
const unused = [...wired].filter((h) => !used.has(h));
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
unused, [],
|
||||||
|
'these hooks are wired by app.js but no module reads them — dead weight, and usually '
|
||||||
|
+ 'the fossil of a rename that left the other half behind',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -84,7 +84,11 @@ function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows A
|
|||||||
json: () => Promise.resolve({ path: '/local/song.ogg' }),
|
json: () => Promise.resolve({ path: '/local/song.ogg' }),
|
||||||
}),
|
}),
|
||||||
document: { hidden: false },
|
document: { hidden: false },
|
||||||
isPlaying: true,
|
// `isPlaying` moved onto the shared player-state container so a carved module
|
||||||
|
// can WRITE it (an imported binding is read-only). The sliced code now reads and
|
||||||
|
// writes S.isPlaying, so the sandbox provides the same container — the
|
||||||
|
// assertions below are unchanged.
|
||||||
|
S: { isPlaying: true, lastAudioTime: 0 },
|
||||||
audio,
|
audio,
|
||||||
jucePlayer,
|
jucePlayer,
|
||||||
__calls: calls,
|
__calls: calls,
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ test('native audio-mix participant suppresses matching legacy fader and records
|
|||||||
|
|
||||||
const ROOT = path.join(__dirname, '..', '..');
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
const APP_JS = path.join(ROOT, 'static', 'app.js');
|
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');
|
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
|
||||||
|
|
||||||
function source(file) {
|
function source(file) {
|
||||||
@@ -87,7 +92,7 @@ function region(src, needle, length = 1200) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('plugin script hydration exposes the current plugin id for legacy registrations', () => {
|
test('plugin script hydration exposes the current plugin id for legacy registrations', () => {
|
||||||
const src = source(APP_JS);
|
const src = source(PLUGIN_LOADER_JS);
|
||||||
const block = region(src, 'script.src = `/api/plugins/${plugin.id}/screen.js');
|
const block = region(src, 'script.src = `/api/plugins/${plugin.id}/screen.js');
|
||||||
assert.match(block, /window\.feedBack\._loadingPluginId\s*=\s*plugin\.id/);
|
assert.match(block, /window\.feedBack\._loadingPluginId\s*=\s*plugin\.id/);
|
||||||
assert.match(block, /delete\s+window\.feedBack\._loadingPluginId/);
|
assert.match(block, /delete\s+window\.feedBack\._loadingPluginId/);
|
||||||
@@ -113,7 +118,7 @@ test('library providers route through native library capability', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('visualization renderer installs preserve plugin attribution', () => {
|
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 tagger = region(src, 'function _tagVizRenderer(renderer, id)', 700);
|
||||||
const setViz = region(src, 'function setViz(id)', 3600);
|
const setViz = region(src, 'function setViz(id)', 3600);
|
||||||
const autoViz = region(src, 'function _autoMatchViz()', 5200);
|
const autoViz = region(src, 'function _autoMatchViz()', 5200);
|
||||||
|
|||||||
+44
-12
@@ -11,11 +11,16 @@ const fs = require('node:fs');
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const vm = require('node:vm');
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
// The A-B loop was carved out of app.js into its own module (R3a). The
|
||||||
|
// window.feedBack API surface it is published through stayed in app.js.
|
||||||
|
const LOOPS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'loops.js');
|
||||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||||
|
|
||||||
function extractFunction(src, signature) {
|
function extractFunction(rawSrc, signature) {
|
||||||
|
// loops.js is an ES module; the vm sandbox evaluates plain script text.
|
||||||
|
const src = rawSrc.replace(/^export /gm, '');
|
||||||
const start = src.indexOf(signature);
|
const start = src.indexOf(signature);
|
||||||
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
|
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in static/js/loops.js`);
|
||||||
let scan = start + signature.length;
|
let scan = start + signature.length;
|
||||||
if (src[scan] === '(') {
|
if (src[scan] === '(') {
|
||||||
let parenDepth = 1;
|
let parenDepth = 1;
|
||||||
@@ -44,10 +49,18 @@ function buildSandbox() {
|
|||||||
const seekCalls = [];
|
const seekCalls = [];
|
||||||
const sectionPracticeModeCalls = [];
|
const sectionPracticeModeCalls = [];
|
||||||
const transportEvents = [];
|
const transportEvents = [];
|
||||||
|
// clearLoop() used to zero section-practice's three selection scalars by hand.
|
||||||
|
// They now live in static/js/section-practice.js, which owns them, so clearLoop
|
||||||
|
// calls its exported resetSelection() instead. This is a SPY, not a stub — the
|
||||||
|
// test below still asserts the reset happens, it just asserts it through the
|
||||||
|
// seam rather than by reaching into someone else's state.
|
||||||
|
const resetSelectionCalls = [];
|
||||||
const sandbox = {
|
const sandbox = {
|
||||||
seekCalls,
|
seekCalls,
|
||||||
sectionPracticeModeCalls,
|
sectionPracticeModeCalls,
|
||||||
transportEvents,
|
transportEvents,
|
||||||
|
resetSelectionCalls,
|
||||||
|
resetSelection: () => resetSelectionCalls.push(true),
|
||||||
// Mutable state (declared as `var` in eval prelude so it lives on
|
// Mutable state (declared as `var` in eval prelude so it lives on
|
||||||
// the sandbox global and the extracted functions can read/write).
|
// the sandbox global and the extracted functions can read/write).
|
||||||
// The actual values are set below.
|
// The actual values are set below.
|
||||||
@@ -81,6 +94,7 @@ function buildSandbox() {
|
|||||||
// updateLoopUI references formatTime for the label; we don't
|
// updateLoopUI references formatTime for the label; we don't
|
||||||
// assert on the label text in these tests, so a stub is enough.
|
// assert on the label text in these tests, so a stub is enough.
|
||||||
formatTime: (s) => String(s),
|
formatTime: (s) => String(s),
|
||||||
|
_updateEditRegionBtn: () => {},
|
||||||
window: {
|
window: {
|
||||||
feedBack: {
|
feedBack: {
|
||||||
playback: {
|
playback: {
|
||||||
@@ -89,6 +103,19 @@ function buildSandbox() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// The loop module reaches back into app.js through the host seam
|
||||||
|
// (static/js/host.js), so the extracted bodies call host._audioSeek(),
|
||||||
|
// host._audioTime(), and so on. Point the seam at the SAME spies the sandbox
|
||||||
|
// already had: the assertions below are unchanged, they just travel through the
|
||||||
|
// indirection the real code now uses.
|
||||||
|
sandbox.host = {
|
||||||
|
_audioSeek: (...a) => sandbox._audioSeek(...a),
|
||||||
|
_audioTime: () => sandbox._audioTime(),
|
||||||
|
formatTime: (...a) => sandbox.formatTime(...a),
|
||||||
|
_updateEditRegionBtn: () => sandbox._updateEditRegionBtn(),
|
||||||
|
currentFilename: () => 'test-song.sloppak',
|
||||||
|
startCountIn: () => {},
|
||||||
|
};
|
||||||
vm.createContext(sandbox);
|
vm.createContext(sandbox);
|
||||||
return sandbox;
|
return sandbox;
|
||||||
}
|
}
|
||||||
@@ -121,7 +148,7 @@ function loadFunctions(sandbox, src) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('setLoop mutates loopA/loopB and seeks to A', async () => {
|
test('setLoop mutates loopA/loopB and seeks to A', async () => {
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||||
const sandbox = buildSandbox();
|
const sandbox = buildSandbox();
|
||||||
loadFunctions(sandbox, src);
|
loadFunctions(sandbox, src);
|
||||||
|
|
||||||
@@ -137,7 +164,7 @@ test('setLoop mutates loopA/loopB and seeks to A', async () => {
|
|||||||
test('setLoop returns false and leaves loopA/loopB untouched on cancelled seek', async () => {
|
test('setLoop returns false and leaves loopA/loopB untouched on cancelled seek', async () => {
|
||||||
// Plugin-facing contract: cancelled seek (teardown gen bump) returns
|
// Plugin-facing contract: cancelled seek (teardown gen bump) returns
|
||||||
// false; the loop is NOT armed.
|
// false; the loop is NOT armed.
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||||
const sandbox = buildSandbox();
|
const sandbox = buildSandbox();
|
||||||
sandbox._audioSeek = () => Promise.resolve({ completed: false, from: NaN, to: NaN });
|
sandbox._audioSeek = () => Promise.resolve({ completed: false, from: NaN, to: NaN });
|
||||||
loadFunctions(sandbox, src);
|
loadFunctions(sandbox, src);
|
||||||
@@ -154,7 +181,7 @@ test('setLoop returns false and leaves loopA/loopB untouched on cancelled seek',
|
|||||||
test('setLoop returns false and leaves loopA/loopB untouched on off-target landing', async () => {
|
test('setLoop returns false and leaves loopA/loopB untouched on off-target landing', async () => {
|
||||||
// JUCE rollback / HTML5 clamp: completed:true but to drifts > 50ms
|
// JUCE rollback / HTML5 clamp: completed:true but to drifts > 50ms
|
||||||
// from the requested a. The loop is NOT armed.
|
// from the requested a. The loop is NOT armed.
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||||
const sandbox = buildSandbox();
|
const sandbox = buildSandbox();
|
||||||
sandbox._audioSeek = (s) => Promise.resolve({ completed: true, from: 0, to: s + 0.5 });
|
sandbox._audioSeek = (s) => Promise.resolve({ completed: true, from: 0, to: s + 0.5 });
|
||||||
loadFunctions(sandbox, src);
|
loadFunctions(sandbox, src);
|
||||||
@@ -172,7 +199,7 @@ test('setLoop coerces string inputs (parseFloat-style)', async () => {
|
|||||||
// loadSavedLoop passes parseFloat(dataset.start) — but the dataset
|
// loadSavedLoop passes parseFloat(dataset.start) — but the dataset
|
||||||
// values may already be strings. Number() coercion in setLoop must
|
// values may already be strings. Number() coercion in setLoop must
|
||||||
// accept finite numeric strings.
|
// accept finite numeric strings.
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||||
const sandbox = buildSandbox();
|
const sandbox = buildSandbox();
|
||||||
loadFunctions(sandbox, src);
|
loadFunctions(sandbox, src);
|
||||||
|
|
||||||
@@ -183,7 +210,7 @@ test('setLoop coerces string inputs (parseFloat-style)', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('setLoop rejects non-finite inputs', async () => {
|
test('setLoop rejects non-finite inputs', async () => {
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||||
const sandbox = buildSandbox();
|
const sandbox = buildSandbox();
|
||||||
loadFunctions(sandbox, src);
|
loadFunctions(sandbox, src);
|
||||||
|
|
||||||
@@ -193,7 +220,7 @@ test('setLoop rejects non-finite inputs', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('setLoop rejects b <= a', async () => {
|
test('setLoop rejects b <= a', async () => {
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||||
const sandbox = buildSandbox();
|
const sandbox = buildSandbox();
|
||||||
loadFunctions(sandbox, src);
|
loadFunctions(sandbox, src);
|
||||||
|
|
||||||
@@ -201,8 +228,8 @@ test('setLoop rejects b <= a', async () => {
|
|||||||
await assert.rejects(() => sandbox.__setLoop(10, 5), /b > a/);
|
await assert.rejects(() => sandbox.__setLoop(10, 5), /b > a/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clearLoop resets loopA/loopB to null', async () => {
|
test('clearLoop resets loopA/loopB to null (and asks section-practice to drop its selection)', async () => {
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||||
const sandbox = buildSandbox();
|
const sandbox = buildSandbox();
|
||||||
loadFunctions(sandbox, src);
|
loadFunctions(sandbox, src);
|
||||||
|
|
||||||
@@ -211,6 +238,11 @@ test('clearLoop resets loopA/loopB to null', async () => {
|
|||||||
const { loopA, loopB } = sandbox.__getLoop();
|
const { loopA, loopB } = sandbox.__getLoop();
|
||||||
assert.equal(loopA, null);
|
assert.equal(loopA, null);
|
||||||
assert.equal(loopB, null);
|
assert.equal(loopB, null);
|
||||||
|
assert.equal(
|
||||||
|
sandbox.resetSelectionCalls.length, 1,
|
||||||
|
'clearLoop must ask section-practice to drop its selection (it used to zero the '
|
||||||
|
+ 'scalars by hand; the module owns them now)',
|
||||||
|
);
|
||||||
assert.equal(sandbox.sectionPracticeModeCalls.length, 1);
|
assert.equal(sandbox.sectionPracticeModeCalls.length, 1);
|
||||||
assert.equal(sandbox.sectionPracticeModeCalls[0].on, false);
|
assert.equal(sandbox.sectionPracticeModeCalls[0].on, false);
|
||||||
// Field-wise: vm-context objects break deepStrictEqual across realms.
|
// Field-wise: vm-context objects break deepStrictEqual across realms.
|
||||||
@@ -218,7 +250,7 @@ test('clearLoop resets loopA/loopB to null', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('loop helpers emit transport snapshots by default and can suppress adapter echoes', async () => {
|
test('loop helpers emit transport snapshots by default and can suppress adapter echoes', async () => {
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||||
const sandbox = buildSandbox();
|
const sandbox = buildSandbox();
|
||||||
loadFunctions(sandbox, src);
|
loadFunctions(sandbox, src);
|
||||||
|
|
||||||
@@ -256,7 +288,7 @@ test('loadSavedLoop funnels through setLoop (no duplicated UI mutation)', () =>
|
|||||||
// re-implementing the loopA/loopB assignment. Catches a future drift
|
// re-implementing the loopA/loopB assignment. Catches a future drift
|
||||||
// where someone "fixes" loadSavedLoop and forgets to keep setLoop in
|
// where someone "fixes" loadSavedLoop and forgets to keep setLoop in
|
||||||
// sync.
|
// sync.
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||||
const fn = extractFunction(src, 'async function loadSavedLoop(');
|
const fn = extractFunction(src, 'async function loadSavedLoop(');
|
||||||
assert.match(fn, /await\s+setLoop\(/, 'loadSavedLoop must call setLoop');
|
assert.match(fn, /await\s+setLoop\(/, 'loadSavedLoop must call setLoop');
|
||||||
// The pre-refactor body assigned loopA = parseFloat(...) directly;
|
// The pre-refactor body assigned loopA = parseFloat(...) directly;
|
||||||
|
|||||||
@@ -55,8 +55,10 @@ function buildSandbox() {
|
|||||||
loopA: 10,
|
loopA: 10,
|
||||||
loopB: 20,
|
loopB: 20,
|
||||||
_countingIn: false,
|
_countingIn: false,
|
||||||
isPlaying: false,
|
// isPlaying / lastAudioTime moved onto the shared player-state container
|
||||||
lastAudioTime: 0,
|
// (static/js/player-state.js) so a carved module can WRITE them — an imported
|
||||||
|
// binding is read-only. Same values, same assertions, one indirection.
|
||||||
|
S: { isPlaying: false, lastAudioTime: 0 },
|
||||||
|
|
||||||
// Browser-ish globals.
|
// Browser-ish globals.
|
||||||
performance: { now: () => Date.now() },
|
performance: { now: () => Date.now() },
|
||||||
@@ -135,8 +137,7 @@ test('loop:restart fires once when wrap path runs', async () => {
|
|||||||
var _countInGen = 0;
|
var _countInGen = 0;
|
||||||
var _countInTimer = null;
|
var _countInTimer = null;
|
||||||
var _countInRaf = 0;
|
var _countInRaf = 0;
|
||||||
var isPlaying = false;
|
var S = { isPlaying: false, lastAudioTime: 0 };
|
||||||
var lastAudioTime = 0;
|
|
||||||
${startCountInSrc}
|
${startCountInSrc}
|
||||||
globalThis.__startCountIn = startCountIn;
|
globalThis.__startCountIn = startCountIn;
|
||||||
`;
|
`;
|
||||||
@@ -180,8 +181,7 @@ test('loop:restart aborts when seek lands far from loopA (JUCE rollback)', async
|
|||||||
var _countInGen = 0;
|
var _countInGen = 0;
|
||||||
var _countInTimer = null;
|
var _countInTimer = null;
|
||||||
var _countInRaf = 0;
|
var _countInRaf = 0;
|
||||||
var isPlaying = false;
|
var S = { isPlaying: false, lastAudioTime: 0 };
|
||||||
var lastAudioTime = 0;
|
|
||||||
${startCountInSrc}
|
${startCountInSrc}
|
||||||
globalThis.__startCountIn = startCountIn;
|
globalThis.__startCountIn = startCountIn;
|
||||||
globalThis.__getCountingIn = () => _countingIn;
|
globalThis.__getCountingIn = () => _countingIn;
|
||||||
|
|||||||
@@ -29,8 +29,11 @@ async function runTogglePlayRejecting({ rerouteInProgress }) {
|
|||||||
const buttonStates = [];
|
const buttonStates = [];
|
||||||
const sandbox = {
|
const sandbox = {
|
||||||
console: { log() {}, warn() {}, error() {} },
|
console: { log() {}, warn() {}, error() {} },
|
||||||
// not-playing -> togglePlay takes the HTML5 play branch
|
// not-playing -> togglePlay takes the HTML5 play branch.
|
||||||
isPlaying: false,
|
// isPlaying / lastAudioTime moved onto the shared player-state container
|
||||||
|
// (static/js/player-state.js) so a carved module can WRITE them — an imported
|
||||||
|
// binding is read-only. Same values, same assertions, one indirection.
|
||||||
|
S: { isPlaying: false, lastAudioTime: 0 },
|
||||||
_audioSeekGen: 0,
|
_audioSeekGen: 0,
|
||||||
_playAttemptGen: 0,
|
_playAttemptGen: 0,
|
||||||
setPlayButtonState(v) { buttonStates.push(v); },
|
setPlayButtonState(v) { buttonStates.push(v); },
|
||||||
@@ -51,7 +54,7 @@ async function runTogglePlayRejecting({ rerouteInProgress }) {
|
|||||||
vm.createContext(sandbox);
|
vm.createContext(sandbox);
|
||||||
vm.runInContext(TOGGLE_PLAY_SRC, sandbox, { filename: 'app.js#togglePlay' });
|
vm.runInContext(TOGGLE_PLAY_SRC, sandbox, { filename: 'app.js#togglePlay' });
|
||||||
await vm.runInContext('togglePlay()', sandbox);
|
await vm.runInContext('togglePlay()', sandbox);
|
||||||
return { buttonStates, isPlaying: sandbox.isPlaying };
|
return { buttonStates, isPlaying: sandbox.S.isPlaying };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('reroute-aborted play() leaves the button on Pause (isPlaying stays true)', async () => {
|
test('reroute-aborted play() leaves the button on Pause (isPlaying stays true)', async () => {
|
||||||
|
|||||||
@@ -84,5 +84,8 @@ test('playback adapter suppresses duplicate HTML5 pause events before emitting c
|
|||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||||
const fn = extractFunction(src, 'function _installPlaybackTransportAdapter()');
|
const fn = extractFunction(src, 'function _installPlaybackTransportAdapter()');
|
||||||
|
|
||||||
assert.match(fn, /if \(!window\._juceMode && wasPlaying\) \{\s*isPlaying = false;\s*window\.feedBack\.isPlaying = false;\s*audio\.pause\(\);\s*_markPlaybackPaused\(\);\s*\}/);
|
// isPlaying moved onto the shared player-state container so a carved module can
|
||||||
|
// WRITE it (an imported binding is read-only). window.feedBack.isPlaying — the
|
||||||
|
// public mirror — is unchanged.
|
||||||
|
assert.match(fn, /if \(!window\._juceMode && wasPlaying\) \{\s*S\.isPlaying = false;\s*window\.feedBack\.isPlaying = false;\s*audio\.pause\(\);\s*_markPlaybackPaused\(\);\s*\}/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Verify loadPlugins' plugin-DOM wipe loops in static/app.js: a plugin that is
|
// Verify loadPlugins' plugin-DOM wipe loops in static/js/plugin-loader.js: a plugin that is
|
||||||
// merely ABSENT from the current /api/plugins response (transient partial
|
// merely ABSENT from the current /api/plugins response (transient partial
|
||||||
// response while the backend's plugin registry is repopulating after a
|
// response while the backend's plugin registry is repopulating after a
|
||||||
// restart) must keep its settings panel and screen DOM. Wiping it while its
|
// restart) must keep its settings panel and screen DOM. Wiping it while its
|
||||||
@@ -14,7 +14,7 @@ const fs = require('node:fs');
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const vm = require('node:vm');
|
const vm = require('node:vm');
|
||||||
|
|
||||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||||
|
|
||||||
// Slice the wipe block out of loadPlugins by its stable landmarks: from the
|
// Slice the wipe block out of loadPlugins by its stable landmarks: from the
|
||||||
// nav reset that opens it to the comment introducing the next section.
|
// nav reset that opens it to the comment introducing the next section.
|
||||||
@@ -40,7 +40,7 @@ function makeEl(pluginId, id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runWipe({ respondedIds, alreadyHydrated, settingsChildren, screens }) {
|
function runWipe({ respondedIds, alreadyHydrated, settingsChildren, screens }) {
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
|
||||||
const block = extractWipeBlock(src);
|
const block = extractWipeBlock(src);
|
||||||
settingsChildren.forEach((el) => { el._parent = settingsChildren; });
|
settingsChildren.forEach((el) => { el._parent = settingsChildren; });
|
||||||
const container = { children: settingsChildren };
|
const container = { children: settingsChildren };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Guards the R0 module-migration loader change in static/app.js: a migrated
|
// Guards the R0 module-migration loader change in static/js/plugin-loader.js: a migrated
|
||||||
// plugin (manifest scriptType:"module", surfaced as plugin.script_type) must be
|
// plugin (manifest scriptType:"module", surfaced as plugin.script_type) must be
|
||||||
// injected as <script type="module"> so its screen.js `import './src/main.js'`
|
// injected as <script type="module"> so its screen.js `import './src/main.js'`
|
||||||
// graph loads, while classic plugins stay untouched.
|
// graph loads, while classic plugins stay untouched.
|
||||||
@@ -16,8 +16,8 @@ const assert = require('node:assert/strict');
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
|
||||||
|
|
||||||
// Isolate the screen.js <script> injection block: from where its src is built
|
// Isolate the screen.js <script> injection block: from where its src is built
|
||||||
// to where the element is appended.
|
// to where the element is appended.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Verify the plugin `styles` capability in static/app.js: _injectPluginStyles
|
// Verify the plugin `styles` capability in static/js/plugin-loader.js: _injectPluginStyles
|
||||||
// adds exactly one versioned <link rel="stylesheet"> per plugin, swaps it on a
|
// adds exactly one versioned <link rel="stylesheet"> per plugin, swaps it on a
|
||||||
// version upgrade (no duplicates, no stale tags), injects nothing for a plugin
|
// version upgrade (no duplicates, no stale tags), injects nothing for a plugin
|
||||||
// without `styles`, and routes the URL through the sandboxed asset endpoint.
|
// without `styles`, and routes the URL through the sandboxed asset endpoint.
|
||||||
@@ -9,7 +9,7 @@ const fs = require('node:fs');
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const vm = require('node:vm');
|
const vm = require('node:vm');
|
||||||
|
|
||||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||||
|
|
||||||
// Brace-balanced extraction of a `const NAME = (...) => { ... }` arrow, so a
|
// Brace-balanced extraction of a `const NAME = (...) => { ... }` arrow, so a
|
||||||
// nested object/template literal can't make a naive regex stop early.
|
// nested object/template literal can't make a naive regex stop early.
|
||||||
@@ -84,7 +84,7 @@ function setupSandbox() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
vm.createContext(sandbox);
|
vm.createContext(sandbox);
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
|
||||||
const removeSrc = extractConstArrow(src, '_removePluginStyleTags');
|
const removeSrc = extractConstArrow(src, '_removePluginStyleTags');
|
||||||
const injectSrc = extractConstArrow(src, '_injectPluginStyles');
|
const injectSrc = extractConstArrow(src, '_injectPluginStyles');
|
||||||
const reconcileSrc = extractConstArrow(src, '_reconcilePluginStyles');
|
const reconcileSrc = extractConstArrow(src, '_reconcilePluginStyles');
|
||||||
|
|||||||
@@ -50,17 +50,42 @@ function makeFakeContext(sampleRate = 48000) {
|
|||||||
this.mediaSourceEl = el;
|
this.mediaSourceEl = el;
|
||||||
return { connect() {}, disconnect() {} };
|
return { connect() {}, disconnect() {} };
|
||||||
},
|
},
|
||||||
|
createMediaStreamSource(stream) {
|
||||||
|
this.mediaStreamSource = stream;
|
||||||
|
return { connect() {}, disconnect() {} };
|
||||||
|
},
|
||||||
|
close() { this.closed = true; return Promise.resolve(); },
|
||||||
};
|
};
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {}) {
|
// Fake getDisplayMedia stream for the loopback-capture path.
|
||||||
const calls = { setRendererBus: [], pushRendererAudio: [] };
|
function makeLoopbackStream({ suppressed = true } = {}) {
|
||||||
|
const stopped = [];
|
||||||
|
const audioTrack = {
|
||||||
|
kind: 'audio',
|
||||||
|
stop() { stopped.push('audio'); },
|
||||||
|
getSettings: () => (suppressed ? { suppressLocalAudioPlayback: true } : {}),
|
||||||
|
};
|
||||||
|
const videoTrack = { kind: 'video', stop() { stopped.push('video'); } };
|
||||||
|
return {
|
||||||
|
__stopped: stopped,
|
||||||
|
getAudioTracks: () => [audioTrack],
|
||||||
|
getVideoTracks: () => [videoTrack],
|
||||||
|
getTracks: () => [videoTrack, audioTrack],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// `displayMedia`: undefined → loopback capture unavailable (Docker sphere /
|
||||||
|
// old desktop main); a function → used as navigator.mediaDevices.getDisplayMedia.
|
||||||
|
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true, displayMedia } = {}) {
|
||||||
|
const calls = { setRendererBus: [], pushRendererAudio: [], setPageMuted: [] };
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
||||||
setRendererBus: (en, g) => { calls.setRendererBus.push([en, g]); return Promise.resolve(); },
|
setRendererBus: (en, g) => { calls.setRendererBus.push([en, g]); return Promise.resolve(); },
|
||||||
pushRendererAudio: (buf, rate) => { calls.pushRendererAudio.push([buf.length, rate]); },
|
pushRendererAudio: (buf, rate) => { calls.pushRendererAudio.push([buf.length, rate]); },
|
||||||
|
setPageMuted: (m) => { calls.setPageMuted.push(m); return Promise.resolve(m); },
|
||||||
};
|
};
|
||||||
|
|
||||||
class FakeWorkletNode {
|
class FakeWorkletNode {
|
||||||
@@ -85,6 +110,7 @@ function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {
|
|||||||
__createdContexts: [],
|
__createdContexts: [],
|
||||||
__audioEl: { id: 'audio' },
|
__audioEl: { id: 'audio' },
|
||||||
__calls: calls,
|
__calls: calls,
|
||||||
|
navigator: { mediaDevices: displayMedia ? { getDisplayMedia: displayMedia } : {} },
|
||||||
window: null,
|
window: null,
|
||||||
};
|
};
|
||||||
sandbox.window = {
|
sandbox.window = {
|
||||||
@@ -111,12 +137,21 @@ function makeStemsGraph() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked', async () => {
|
// Surface-mode (stems/element) tests run WITHOUT getDisplayMedia: the first
|
||||||
|
// tick probes loopback, fails, and latches _loopbackUnavailable; the second
|
||||||
|
// tick exercises the fallback surface mode. This mirrors an old desktop main
|
||||||
|
// without the display-media handler.
|
||||||
|
async function reevaluateWithFallback(sb) {
|
||||||
|
await sb.window._reevaluateRendererBus(); // loopback probe → unavailable
|
||||||
|
await sb.window._reevaluateRendererBus(); // surface fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked (loopback unavailable)', async () => {
|
||||||
const sb = makeSandbox({ exclusive: () => true });
|
const sb = makeSandbox({ exclusive: () => true });
|
||||||
const graph = makeStemsGraph();
|
const graph = makeStemsGraph();
|
||||||
sb.window.feedBack.stems.audioGraph = graph;
|
sb.window.feedBack.stems.audioGraph = graph;
|
||||||
|
|
||||||
await sb.window._reevaluateRendererBus();
|
await reevaluateWithFallback(sb);
|
||||||
|
|
||||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||||
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems ctx re-pointed at null sink');
|
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems ctx re-pointed at null sink');
|
||||||
@@ -128,7 +163,7 @@ test('output returns to shared → bus disabled, sink restored', async () => {
|
|||||||
const graph = makeStemsGraph();
|
const graph = makeStemsGraph();
|
||||||
sb.window.feedBack.stems.audioGraph = graph;
|
sb.window.feedBack.stems.audioGraph = graph;
|
||||||
|
|
||||||
await sb.window._reevaluateRendererBus();
|
await reevaluateWithFallback(sb);
|
||||||
excl = false;
|
excl = false;
|
||||||
await sb.window._reevaluateRendererBus();
|
await sb.window._reevaluateRendererBus();
|
||||||
|
|
||||||
@@ -145,26 +180,27 @@ test('stems graph + shared output → feeder stays off (no double audio)', async
|
|||||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'bus never touched in shared mode');
|
assert.equal(sb.__calls.setRendererBus.length, 0, 'bus never touched in shared mode');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('element song + exclusive → element captured into bus', async () => {
|
test('element song + exclusive → element captured into bus (loopback unavailable)', async () => {
|
||||||
const sb = makeSandbox({ exclusive: () => true });
|
const sb = makeSandbox({ exclusive: () => true });
|
||||||
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
||||||
sb.window._juceMode = false;
|
sb.window._juceMode = false;
|
||||||
|
|
||||||
await sb.window._reevaluateRendererBus();
|
await reevaluateWithFallback(sb);
|
||||||
|
|
||||||
assert.equal(sb.__createdContexts.length, 1, 'capture context created');
|
assert.equal(sb.__createdContexts.length, 1, 'capture context created');
|
||||||
assert.equal(sb.__createdContexts[0].mediaSourceEl, sb.__audioEl, 'element source captured');
|
assert.equal(sb.__createdContexts[0].mediaSourceEl, sb.__audioEl, 'element source captured');
|
||||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('song riding the native transport (_juceMode) → feeder stays off', async () => {
|
test('native-transport song, loopback unavailable → surface modes stay off', async () => {
|
||||||
const sb = makeSandbox({ exclusive: () => true });
|
const sb = makeSandbox({ exclusive: () => true });
|
||||||
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
|
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
|
||||||
sb.window._juceMode = true;
|
sb.window._juceMode = true;
|
||||||
|
|
||||||
await sb.window._reevaluateRendererBus();
|
await reevaluateWithFallback(sb);
|
||||||
|
|
||||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'native transport owns the song');
|
assert.ok(!sb.__calls.setRendererBus.some(([en]) => en === true),
|
||||||
|
'bus never ENABLED (failed-probe cleanup may disable it)');
|
||||||
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
|
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -172,7 +208,7 @@ test('stems graph replaced mid-engagement → re-engages on the new graph', asyn
|
|||||||
const sb = makeSandbox({ exclusive: () => true });
|
const sb = makeSandbox({ exclusive: () => true });
|
||||||
const g1 = makeStemsGraph();
|
const g1 = makeStemsGraph();
|
||||||
sb.window.feedBack.stems.audioGraph = g1;
|
sb.window.feedBack.stems.audioGraph = g1;
|
||||||
await sb.window._reevaluateRendererBus();
|
await reevaluateWithFallback(sb);
|
||||||
|
|
||||||
const g2 = makeStemsGraph();
|
const g2 = makeStemsGraph();
|
||||||
sb.window.feedBack.stems.audioGraph = g2;
|
sb.window.feedBack.stems.audioGraph = g2;
|
||||||
@@ -182,6 +218,105 @@ test('stems graph replaced mid-engagement → re-engages on the new graph', asyn
|
|||||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 're-enabled for new graph');
|
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 're-enabled for new graph');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Loopback mode (whole-app capture) ────────────────────────────────────────
|
||||||
|
|
||||||
|
test('exclusive output + loopback available → engages without any song loaded', async () => {
|
||||||
|
const stream = makeLoopbackStream();
|
||||||
|
const sb = makeSandbox({ exclusive: () => true, displayMedia: () => Promise.resolve(stream) });
|
||||||
|
|
||||||
|
await sb.window._reevaluateRendererBus();
|
||||||
|
|
||||||
|
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled for whole session');
|
||||||
|
assert.ok(stream.__stopped.includes('video'), 'unused video track stopped');
|
||||||
|
assert.equal(sb.__createdContexts.at(-1)?.mediaStreamSource, stream, 'loopback stream captured');
|
||||||
|
assert.equal(sb.__calls.setPageMuted.length, 0, 'suppress constraint honoured — no page mute');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loopback context is closed on disengage (no orphaned tap worklet)', async () => {
|
||||||
|
let excl = true;
|
||||||
|
const stream = makeLoopbackStream();
|
||||||
|
const sb = makeSandbox({ exclusive: () => excl, displayMedia: () => Promise.resolve(stream) });
|
||||||
|
|
||||||
|
await sb.window._reevaluateRendererBus(); // engage loopback
|
||||||
|
const lbCtx = sb.__createdContexts.at(-1);
|
||||||
|
assert.equal(lbCtx?.mediaStreamSource, stream, 'loopback engaged');
|
||||||
|
assert.notEqual(lbCtx.closed, true, 'context live while engaged');
|
||||||
|
|
||||||
|
excl = false;
|
||||||
|
await sb.window._reevaluateRendererBus(); // disengage
|
||||||
|
assert.equal(lbCtx.closed, true, 'loopback context closed on disengage');
|
||||||
|
assert.ok(stream.__stopped.includes('audio'), 'capture stream stopped');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loopback preferred over stems when both available', async () => {
|
||||||
|
const stream = makeLoopbackStream();
|
||||||
|
const sb = makeSandbox({ exclusive: () => true, displayMedia: () => Promise.resolve(stream) });
|
||||||
|
const graph = makeStemsGraph();
|
||||||
|
sb.window.feedBack.stems.audioGraph = graph;
|
||||||
|
|
||||||
|
await sb.window._reevaluateRendererBus();
|
||||||
|
|
||||||
|
assert.equal(graph.context.sinkIdCalls.length, 0, 'stems ctx untouched — loopback owns capture');
|
||||||
|
assert.equal(sb.__createdContexts.at(-1)?.mediaStreamSource, stream, 'loopback engaged');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('suppressLocalAudioPlayback unsupported → page-mute fallback, unmuted on disengage', async () => {
|
||||||
|
let excl = true;
|
||||||
|
const stream = makeLoopbackStream({ suppressed: false });
|
||||||
|
const sb = makeSandbox({ exclusive: () => excl, displayMedia: () => Promise.resolve(stream) });
|
||||||
|
|
||||||
|
await sb.window._reevaluateRendererBus();
|
||||||
|
assert.deepEqual(sb.__calls.setPageMuted, [true], 'page muted as fallback');
|
||||||
|
|
||||||
|
excl = false;
|
||||||
|
await sb.window._reevaluateRendererBus();
|
||||||
|
assert.deepEqual(sb.__calls.setPageMuted, [true, false], 'page unmuted on disengage');
|
||||||
|
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDisplayMedia rejected → sticky fallback to surface modes', async () => {
|
||||||
|
const sb = makeSandbox({
|
||||||
|
exclusive: () => true,
|
||||||
|
displayMedia: () => Promise.reject(new DOMException('denied', 'NotAllowedError')),
|
||||||
|
});
|
||||||
|
const graph = makeStemsGraph();
|
||||||
|
sb.window.feedBack.stems.audioGraph = graph;
|
||||||
|
|
||||||
|
await sb.window._reevaluateRendererBus(); // probe fails, latches unavailable
|
||||||
|
await sb.window._reevaluateRendererBus(); // falls back to stems
|
||||||
|
|
||||||
|
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems fallback engaged');
|
||||||
|
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled via fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('element capture collision (createMediaElementSource throws) → no poisoned state, clean retry', async () => {
|
||||||
|
const sb = makeSandbox({ exclusive: () => true }); // loopback unavailable
|
||||||
|
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
||||||
|
// First capture attempt collides (highway analyser owns the element).
|
||||||
|
let collide = true;
|
||||||
|
const origFactory = sb.AudioContext;
|
||||||
|
sb.__createdContexts.length = 0;
|
||||||
|
// Patch contexts so createMediaElementSource throws while colliding.
|
||||||
|
sb.AudioContext = function () {
|
||||||
|
const c = origFactory();
|
||||||
|
const orig = c.createMediaElementSource.bind(c);
|
||||||
|
c.createMediaElementSource = (el) => {
|
||||||
|
if (collide) throw new DOMException('already connected', 'InvalidStateError');
|
||||||
|
return orig(el);
|
||||||
|
};
|
||||||
|
c.close = () => Promise.resolve();
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
|
||||||
|
await reevaluateWithFallback(sb); // element engage fails (collision)
|
||||||
|
assert.ok(!sb.__calls.setRendererBus.some(([en]) => en === true), 'bus never left enabled');
|
||||||
|
|
||||||
|
collide = false;
|
||||||
|
await sb.window._reevaluateRendererBus(); // retry succeeds — no TypeError, fresh ctx
|
||||||
|
|
||||||
|
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'element engaged after collision cleared');
|
||||||
|
});
|
||||||
|
|
||||||
test('engine stops → bus disabled', async () => {
|
test('engine stops → bus disabled', async () => {
|
||||||
let running = true;
|
let running = true;
|
||||||
const sb = makeSandbox({ isAudioRunning: () => running, exclusive: () => true });
|
const sb = makeSandbox({ isAudioRunning: () => running, exclusive: () => true });
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ const assert = require('node:assert/strict');
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
|
// _installSectionPracticeDismiss was carved out of app.js into its own module (R3a).
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'js', 'section-practice.js'), 'utf8');
|
||||||
const m = src.match(/function _installSectionPracticeDismiss\s*\(\)\s*\{[\s\S]*?\n\}/);
|
const m = src.match(/function _installSectionPracticeDismiss\s*\(\)\s*\{[\s\S]*?\n\}/);
|
||||||
assert.ok(m, '_installSectionPracticeDismiss() not found in static/app.js');
|
assert.ok(m, '_installSectionPracticeDismiss() not found in static/js/section-practice.js');
|
||||||
const body = m[0];
|
const body = m[0];
|
||||||
|
|
||||||
test('the outside-click dismiss binds in the CAPTURE phase', () => {
|
test('the outside-click dismiss binds in the CAPTURE phase', () => {
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
|
|||||||
const sandbox = {
|
const sandbox = {
|
||||||
loopA,
|
loopA,
|
||||||
loopB,
|
loopB,
|
||||||
isPlaying,
|
// isPlaying moved onto the shared player-state container so a carved module can
|
||||||
|
// WRITE it (an imported binding is read-only). Same value, same assertions.
|
||||||
|
S: { isPlaying, lastAudioTime: 0 },
|
||||||
__cancelCountInCalls: 0,
|
__cancelCountInCalls: 0,
|
||||||
__seekCalls: [],
|
__seekCalls: [],
|
||||||
__startCountInCalls: [],
|
__startCountInCalls: [],
|
||||||
@@ -42,7 +44,7 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
|
|||||||
},
|
},
|
||||||
__togglePlay() {
|
__togglePlay() {
|
||||||
sandbox.__togglePlayCalls++;
|
sandbox.__togglePlayCalls++;
|
||||||
sandbox.isPlaying = true;
|
sandbox.S.isPlaying = true;
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -53,7 +55,7 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
|
|||||||
function loadRestart(sandbox, src, { audioSeekImpl } = {}) {
|
function loadRestart(sandbox, src, { audioSeekImpl } = {}) {
|
||||||
const restartSrc = extractFunction(src, 'async function restartCurrentSong(');
|
const restartSrc = extractFunction(src, 'async function restartCurrentSong(');
|
||||||
const code = `
|
const code = `
|
||||||
var isPlaying = ${sandbox.isPlaying};
|
var S = { isPlaying: ${sandbox.S.isPlaying}, lastAudioTime: 0 };
|
||||||
function _cancelCountIn() { __cancelCountInCalls++; }
|
function _cancelCountIn() { __cancelCountInCalls++; }
|
||||||
async function _audioSeek(s, reason) {
|
async function _audioSeek(s, reason) {
|
||||||
return (${audioSeekImpl || '__audioSeek'})(s, reason);
|
return (${audioSeekImpl || '__audioSeek'})(s, reason);
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ function loadFunctions(sandbox, src) {
|
|||||||
// _audioSeek now syncs the jump-fix tracker so far seeks don't
|
// _audioSeek now syncs the jump-fix tracker so far seeks don't
|
||||||
// trigger an immediate revert; declare it here so the sandbox
|
// trigger an immediate revert; declare it here so the sandbox
|
||||||
// assignment lands on a real binding rather than an implicit global.
|
// assignment lands on a real binding rather than an implicit global.
|
||||||
let lastAudioTime = 0;
|
// lastAudioTime moved onto the shared player-state container
|
||||||
|
// (static/js/player-state.js) so a carved module can WRITE it — an imported
|
||||||
|
// binding is read-only. The sliced code writes S.lastAudioTime now.
|
||||||
|
let S = { isPlaying: false, lastAudioTime: 0 };
|
||||||
// _audioSeek wraps jucePlayer.seek in a timeout race; pull in the
|
// _audioSeek wraps jucePlayer.seek in a timeout race; pull in the
|
||||||
// helper + constant. Tests can override jucePlayer.seek to vary
|
// helper + constant. Tests can override jucePlayer.seek to vary
|
||||||
// behavior; the timeout (2 s) is well above any test setTimeout.
|
// behavior; the timeout (2 s) is well above any test setTimeout.
|
||||||
|
|||||||
@@ -143,7 +143,10 @@ function loadPlaySong(sandbox) {
|
|||||||
: '';
|
: '';
|
||||||
const code = `
|
const code = `
|
||||||
var artAbortController = null;
|
var artAbortController = null;
|
||||||
var isPlaying = true;
|
// isPlaying moved onto the shared player-state container so a carved module can
|
||||||
|
// WRITE it (an imported binding is read-only). NB window.feedBack.isPlaying — the
|
||||||
|
// public mirror stubbed above — is a different thing and is unchanged.
|
||||||
|
var S = { isPlaying: true, lastAudioTime: 0 };
|
||||||
var currentFilename = null;
|
var currentFilename = null;
|
||||||
var _playerOriginScreen = null;
|
var _playerOriginScreen = null;
|
||||||
var _pendingAutostart = false;
|
var _pendingAutostart = false;
|
||||||
|
|||||||
@@ -7,20 +7,23 @@ const path = require('node:path');
|
|||||||
const vm = require('node:vm');
|
const vm = require('node:vm');
|
||||||
|
|
||||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.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);
|
||||||
|
// 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 TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
|
||||||
const TUNING_UTILS_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'tuning-utils.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');
|
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
|
||||||
|
|
||||||
function loadTuningHelpers() {
|
function loadTuningHelpers() {
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(TUNING_JS, 'utf8');
|
||||||
const start = src.indexOf('function isBassArrangement(');
|
// The module is nothing BUT the tuning helpers now, so there is no block to
|
||||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
// slice out — take it whole. `export` is stripped so the vm sandbox can still
|
||||||
const end = src.indexOf(endMarker);
|
// evaluate it as a plain script (the window.* contract lives in app.js).
|
||||||
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
|
const body = src.replace(/^export /gm, '');
|
||||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||||
vm.createContext(sandbox);
|
vm.createContext(sandbox);
|
||||||
vm.runInContext(
|
vm.runInContext(
|
||||||
src.slice(start, end + endMarker.length),
|
body,
|
||||||
sandbox
|
sandbox
|
||||||
);
|
);
|
||||||
return sandbox.window.feedBack;
|
return sandbox.window.feedBack;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ const fs = require('node:fs');
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const vm = require('node:vm');
|
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 HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||||
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
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 path = require('node:path');
|
||||||
const vm = require('node:vm');
|
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 HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||||
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
|
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
|
||||||
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.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() {
|
function loadTuningHelpers() {
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||||
const start = src.indexOf('function isBassArrangement(');
|
// The module is nothing BUT the tuning helpers now, so there is no block to
|
||||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
// slice out — take it whole. `export` is stripped so the vm sandbox can still
|
||||||
const end = src.indexOf(endMarker);
|
// evaluate it as a plain script (the window.* contract lives in app.js).
|
||||||
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
|
const body = src.replace(/^export /gm, '');
|
||||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||||
vm.createContext(sandbox);
|
vm.createContext(sandbox);
|
||||||
vm.runInContext(
|
vm.runInContext(
|
||||||
src.slice(start, end + endMarker.length) + '\n'
|
body + '\n'
|
||||||
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
|
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
|
||||||
+ 'exports.displayTuningTargetDetails = displayTuningTargetDetails;\n'
|
+ 'exports.displayTuningTargetDetails = displayTuningTargetDetails;\n'
|
||||||
+ 'exports.isBassArrangement = isBassArrangement;\n'
|
+ 'exports.isBassArrangement = isBassArrangement;\n'
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ const path = require('node:path');
|
|||||||
const vm = require('node:vm');
|
const vm = require('node:vm');
|
||||||
|
|
||||||
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
|
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) {
|
function extractBlock(src, startMarker) {
|
||||||
const start = src.indexOf(startMarker);
|
const start = src.indexOf(startMarker);
|
||||||
@@ -27,14 +28,14 @@ function extractBlock(src, startMarker) {
|
|||||||
|
|
||||||
function loadTuningHelpers() {
|
function loadTuningHelpers() {
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||||
const start = src.indexOf('function _looksLikeRawTuningOffsets(');
|
// The module is nothing BUT the tuning helpers now, so there is no block to
|
||||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
// slice out — take it whole. `export` is stripped so the vm sandbox can still
|
||||||
const end = src.indexOf(endMarker);
|
// evaluate it as a plain script (the window.* contract lives in app.js).
|
||||||
if (start === -1 || end === -1) throw new Error('tuning helpers not found');
|
const body = src.replace(/^export /gm, '');
|
||||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||||
vm.createContext(sandbox);
|
vm.createContext(sandbox);
|
||||||
vm.runInContext(
|
vm.runInContext(
|
||||||
src.slice(start, end + endMarker.length) + '\n'
|
body + '\n'
|
||||||
+ 'exports.displayTuningName = displayTuningName;\n'
|
+ 'exports.displayTuningName = displayTuningName;\n'
|
||||||
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
|
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
|
||||||
+ 'exports.parseRawTuningOffsets = parseRawTuningOffsets;',
|
+ '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 venueViz = require('../../static/v3/venue-viz.js');
|
||||||
const pov = require('../../static/v3/venue-instrument-pov.js');
|
const pov = require('../../static/v3/venue-instrument-pov.js');
|
||||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.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 H3D_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||||
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||||
const ASSET_DIR = path.join(__dirname, '..', '..', 'static', 'assets', 'venue', 'themes', 'small-club');
|
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/');
|
assert.equal(venueScene.ASSET_BASE, '/static/assets/venue/themes/small-club/');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('app.js syncs venue 3D scene on viz changes', () => {
|
test('viz.js syncs venue 3D scene on viz changes', () => {
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(VIZ_JS, 'utf8');
|
||||||
assert.match(src, /v3VenueScene3d\.syncViz\('venue'\)/);
|
assert.match(src, /v3VenueScene3d\.syncViz\('venue'\)/);
|
||||||
assert.match(src, /v3VenueScene3d\.syncViz\(id\)/);
|
assert.match(src, /v3VenueScene3d\.syncViz\(id\)/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const path = require('node:path');
|
|||||||
const venueViz = require('../../static/v3/venue-viz.js');
|
const venueViz = require('../../static/v3/venue-viz.js');
|
||||||
const venue = require('../../static/v3/venue-mood-fx.js');
|
const venue = require('../../static/v3/venue-mood-fx.js');
|
||||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.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 INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||||
const V3_CSS = path.join(__dirname, '..', '..', 'static', 'v3', 'v3.css');
|
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"/);
|
assert.match(html, /id="v3-venue-scene-wash"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('app.js adds Venue visualization option and adapter', () => {
|
test('viz.js adds Venue visualization option and adapter', () => {
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(VIZ_JS, 'utf8');
|
||||||
assert.match(src, /function _ensureVenueVizOption/);
|
assert.match(src, /function _ensureVenueVizOption/);
|
||||||
assert.match(src, /opt\.value = 'venue'/);
|
assert.match(src, /opt\.value = 'venue'/);
|
||||||
assert.match(src, /opt\.textContent = '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/);
|
assert.match(source, /v3-venue-mode-badge/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('app.js preserves plugin viz population for drum/tab/piano highways', () => {
|
test('viz.js preserves plugin viz population for drum/tab/piano highways', () => {
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(VIZ_JS, 'utf8');
|
||||||
assert.match(src, /p\.type === 'visualization'/);
|
assert.match(src, /p\.type === 'visualization'/);
|
||||||
assert.match(src, /feedBackViz_/);
|
assert.match(src, /feedBackViz_/);
|
||||||
assert.match(src, /BUILTIN_OPT_VALUES/);
|
assert.match(src, /BUILTIN_OPT_VALUES/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('venue option remains distinct from highway_3d in app adapter', () => {
|
test('venue option remains distinct from highway_3d in viz adapter', () => {
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(VIZ_JS, 'utf8');
|
||||||
assert.match(src, /if \(id === 'venue'\)/);
|
assert.match(src, /if \(id === 'venue'\)/);
|
||||||
assert.doesNotMatch(src, /if \(id === 'venue'\)[\s\S]{0,400}sel\.value = 'highway_3d'/);
|
assert.doesNotMatch(src, /if \(id === 'venue'\)[\s\S]{0,400}sel\.value = 'highway_3d'/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import pytest
|
|||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
WORKSPACE_ROOT = ROOT.parent
|
WORKSPACE_ROOT = ROOT.parent
|
||||||
|
|
||||||
|
# The plugin loader was carved out of static/app.js into its own module (R3a).
|
||||||
|
# These tests assert on its source text, so they read it from its new home.
|
||||||
|
PLUGIN_LOADER = ROOT / "static" / "js" / "plugin-loader.js"
|
||||||
|
|
||||||
|
|
||||||
def _sibling_file(plugin_dir: str, filename: str) -> Path:
|
def _sibling_file(plugin_dir: str, filename: str) -> Path:
|
||||||
path = WORKSPACE_ROOT / plugin_dir / filename
|
path = WORKSPACE_ROOT / plugin_dir / filename
|
||||||
@@ -23,7 +27,7 @@ def _sibling_text(plugin_dir: str, filename: str, required_token: str | None = N
|
|||||||
|
|
||||||
|
|
||||||
def test_plugin_loader_guards_duplicate_hydration_and_scripts():
|
def test_plugin_loader_guards_duplicate_hydration_and_scripts():
|
||||||
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
source = PLUGIN_LOADER.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "let _loadPluginsInFlight = false" in source
|
assert "let _loadPluginsInFlight = false" in source
|
||||||
assert "window.feedBack._loadedPluginScripts" in source
|
assert "window.feedBack._loadedPluginScripts" in source
|
||||||
@@ -31,7 +35,7 @@ def test_plugin_loader_guards_duplicate_hydration_and_scripts():
|
|||||||
|
|
||||||
|
|
||||||
def test_plugin_loader_unmounts_previous_ui_contributions_before_reregistering():
|
def test_plugin_loader_unmounts_previous_ui_contributions_before_reregistering():
|
||||||
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
source = PLUGIN_LOADER.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "const _pluginUiContributions = new Map()" in source
|
assert "const _pluginUiContributions = new Map()" in source
|
||||||
assert "await _commandUiDomain(contribution.domain, 'unmount', plugin, contribution)" in source
|
assert "await _commandUiDomain(contribution.domain, 'unmount', plugin, contribution)" in source
|
||||||
@@ -48,7 +52,7 @@ def test_plugin_loader_does_not_treat_response_absence_as_uninstall():
|
|||||||
# (plugin scripts don't re-run), and the DOM/style wipes forced a
|
# (plugin scripts don't re-run), and the DOM/style wipes forced a
|
||||||
# mid-session screen.js re-evaluation that duplicated the desktop
|
# mid-session screen.js re-evaluation that duplicated the desktop
|
||||||
# audio_engine's native signal chain.
|
# audio_engine's native signal chain.
|
||||||
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
source = PLUGIN_LOADER.read_text(encoding="utf-8")
|
||||||
|
|
||||||
# The absence-triggered sweep is gone (rationale comment in its place)...
|
# The absence-triggered sweep is gone (rationale comment in its place)...
|
||||||
assert "const livePluginIds" not in source
|
assert "const livePluginIds" not in source
|
||||||
@@ -154,7 +158,13 @@ def test_deferred_runtime_domains_remain_reserved_not_bridged():
|
|||||||
|
|
||||||
|
|
||||||
def test_capability_events_do_not_bridge_deferred_surfaces():
|
def test_capability_events_do_not_bridge_deferred_surfaces():
|
||||||
app_source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
# These are NEGATIVE assertions, so they must span every file the code could
|
||||||
|
# have moved to — otherwise carving a function out of app.js turns the guard
|
||||||
|
# vacuous instead of failing.
|
||||||
|
app_source = (
|
||||||
|
(ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
||||||
|
+ PLUGIN_LOADER.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
capability_source = (ROOT / "static" / "capabilities.js").read_text(encoding="utf-8")
|
capability_source = (ROOT / "static" / "capabilities.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
for token in ["return 'ui.navigation'", "return 'note-detection'", "eventName.startsWith('viz:') || eventName.startsWith('highway:')"]:
|
for token in ["return 'ui.navigation'", "return 'note-detection'", "eventName.startsWith('viz:') || eventName.startsWith('highway:')"]:
|
||||||
@@ -164,7 +174,7 @@ def test_capability_events_do_not_bridge_deferred_surfaces():
|
|||||||
|
|
||||||
|
|
||||||
def test_plugin_loader_registers_manifest_capability_declarations():
|
def test_plugin_loader_registers_manifest_capability_declarations():
|
||||||
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
source = PLUGIN_LOADER.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "const capabilityPlugins = fetchedPlugins.slice().sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))" in source
|
assert "const capabilityPlugins = fetchedPlugins.slice().sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))" in source
|
||||||
assert "capabilityApi.registerParticipants(capabilityPlugins)" in source
|
assert "capabilityApi.registerParticipants(capabilityPlugins)" in source
|
||||||
@@ -176,7 +186,9 @@ def test_app_event_bus_dispatches_locally_and_preserves_juce_stop_state():
|
|||||||
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "this.dispatchEvent(new CustomEvent(event, { detail }))" in source
|
assert "this.dispatchEvent(new CustomEvent(event, { detail }))" in source
|
||||||
assert "const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || isPlaying" in source
|
# `isPlaying` moved onto the shared player-state container (static/js/player-state.js)
|
||||||
|
# so a carved module can WRITE it — an imported binding is read-only.
|
||||||
|
assert "const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || S.isPlaying" in source
|
||||||
assert "sm.emit('song:resume', payload)" in source
|
assert "sm.emit('song:resume', payload)" in source
|
||||||
assert "window.feedBack.emit('song:resume', payload)" in source
|
assert "window.feedBack.emit('song:resume', payload)" in source
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user