Clean release snapshot

This commit is contained in:
byrongamatos
2026-06-16 18:47:13 +02:00
commit 6c110398b4
574 changed files with 162566 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
/*
* fee[dB]ack v0.3.0 — Audio-routing status widget (dashboard stat card).
*
* Flagship capability-pipeline consumer (design/05): reads the audio session
* purely through the capability runtime —
* audio-mix inspect → { state, faders, requiredKinds, route, analyser }
* audio-input list-sources → { sources: [...] }
* audio-monitoring inspect → { sessions, totalSessions }
* — and never touches static/audio-mixer.js internals or plugins/nam_tone
* routes directly. "Not Connected" is the honest default in the browser
* (no native engine ⇒ no available route). Degrades on no-owner/no-handler/
* failed and when capabilities are absent.
*/
(function () {
'use strict';
const sm = window.slopsmith;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const OK = new Set(['handled', 'passed', 'transformed', 'degraded']);
async function cmd(domain, name, args) {
try {
const caps = sm && sm.capabilities;
if (!caps || typeof caps.command !== 'function') return null;
const r = await caps.command(domain, name, args || {});
// no-owner / no-handler / unsupported-command / incompatible-version
// / failed → treat as "feature absent".
return r && OK.has(r.outcome) ? (r.payload || {}) : null;
} catch (e) { return null; }
}
async function readStatus() {
const [mix, srcs, mon] = await Promise.all([
cmd('audio-mix', 'inspect'),
cmd('audio-input', 'list-sources'),
cmd('audio-monitoring', 'inspect'),
]);
const sources = (srcs && Array.isArray(srcs.sources)) ? srcs.sources : [];
const selected = sources.find((s) => s && s.selected) || null;
const inputAvailable = !!(selected && selected.availability === 'available');
const route = mix && mix.route;
const routeActive = !!(route && route.availability === 'available');
const plugins = (mix && Array.isArray(mix.faders)) ? mix.faders.filter((f) => f && f.kind === 'plugin') : [];
const effectActive = plugins.length > 0 || !!(mix && mix.requiredKinds && mix.requiredKinds.plugin);
const monitoring = !!(mon && mon.totalSessions > 0);
// Connected = a native engine is actually routing audio. In the browser
// there is no route, so this is false (the honest default).
const connected = routeActive;
const inputLabel = selected ? (selected.label || 'Input') : null;
const effectLabel = plugins.length ? (plugins[0].label || plugins[0].ownerPluginId || 'VST/NAM/IR') : null;
const outputLabel = route ? (route.label || route.routeKind || null) : null;
return { connected, inputAvailable, effectActive, routeActive, monitoring, inputLabel, effectLabel, outputLabel };
}
function dot(on, color) {
return '<span class="w-2.5 h-2.5 rounded-full ' + (on ? (color || 'bg-cyan-400') : 'bg-gray-600') + '"></span>';
}
async function render(container) {
container = container || document.getElementById('v3-audio-routing');
if (!container) return;
const st = await readStatus();
const statusLine = st.connected
? '<span class="text-cyan-300">Connected</span>' +
(st.effectLabel ? ' <span class="text-fb-textDim">· ' + esc(st.effectLabel) + '</span>' : '')
: '<span class="text-fb-textDim">Not Connected</span>';
container.className = 'bg-fb-card/80 backdrop-blur rounded-lg p-4 border border-fb-border/50 ' +
(st.connected ? 'ring-1 ring-cyan-500/30' : '');
container.innerHTML =
'<div class="flex items-center gap-2 text-xs text-fb-textDim mb-3">' +
'<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V5l12-2v13M9 13l12-2"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>' +
'<span>Audio Routing</span></div>' +
'<div class="flex items-center gap-2 text-[11px] text-fb-textDim">' +
dot(st.inputAvailable) + '<span>Input</span>' +
'<span class="flex-1 border-t border-dashed border-fb-border/70"></span>' +
dot(st.effectActive) + '<span>VST/NAM/IR</span>' +
'<span class="flex-1 border-t border-dashed border-fb-border/70"></span>' +
dot(st.routeActive) + '<span>Output</span></div>' +
'<div class="mt-2 text-sm font-medium">' + statusLine + '</div>';
}
window.v3AudioRouting = { render: render, readStatus: readStatus };
// Refresh on relevant changes (light; no continuous polling). The dashboard
// also calls render() each time Home is shown.
if (sm && typeof sm.on === 'function') {
sm.on('instrument:changed', () => render());
sm.on('song:play', () => render());
sm.on('song:stop', () => render());
}
// NOTE: do NOT subscribe to the capability '*' wildcard to auto-refresh.
// render() -> readStatus() issues three capability commands (audio-mix
// inspect, audio-input list-sources, audio-monitoring inspect), and the
// runtime fans every resulting event out to '*' subscribers
// (capabilities.js _notifySubscribers). A '*' handler that calls render()
// therefore re-triggers itself through its own commands' events — an
// exponential render/command storm that exhausts the V8 heap (OOM) within
// ~0.5s of load and freezes the renderer. The explicit instrument/song
// handlers above, plus the dashboard calling render() each time Home is
// shown, are sufficient to keep this status card current.
})();
+1
View File
@@ -0,0 +1 @@
# Bundled default avatars land here in prompt 15 (backend profile + onboarding).
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="amp avatar"><rect width="64" height="64" rx="16" fill="#ef4444"/><g transform="translate(8 8) scale(2)" fill="none" stroke="#f8fafc" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5a1 1 0 011-1zm7 4a4 4 0 100 8 4 4 0 000-8zm5-1h1m-1 2h1"/></g></svg>

After

Width:  |  Height:  |  Size: 412 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="bolt avatar"><rect width="64" height="64" rx="16" fill="#a855f7"/><g transform="translate(8 8) scale(2)" fill="none" stroke="#f8fafc" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L4 14h6l-1 8 9-12h-6l1-8z"/></g></svg>

After

Width:  |  Height:  |  Size: 336 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="flame avatar"><rect width="64" height="64" rx="16" fill="#e8c040"/><g transform="translate(8 8) scale(2)" fill="none" stroke="#f8fafc" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3c1 3-2 4-2 7a2 2 0 004 0c0-1 0-1 1-2 2 2 3 4 3 6a6 6 0 11-12 0c0-4 4-6 6-11z"/></g></svg>

After

Width:  |  Height:  |  Size: 388 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="headstock avatar"><rect width="64" height="64" rx="16" fill="#22c55e"/><g transform="translate(8 8) scale(2)" fill="none" stroke="#f8fafc" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M10 3h4v5h3a2 2 0 012 2v2a2 2 0 01-2 2h-1v6h-4v-6H9a2 2 0 01-2-2v-2a2 2 0 012-2h1V3z"/></g></svg>

After

Width:  |  Height:  |  Size: 395 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="note avatar"><rect width="64" height="64" rx="16" fill="#eab308"/><g transform="translate(8 8) scale(2)" fill="none" stroke="#f8fafc" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V6l10-2v12M9 18a3 3 0 11-6 0 3 3 0 016 0zm10-2a3 3 0 11-6 0 3 3 0 016 0z"/></g></svg>

After

Width:  |  Height:  |  Size: 383 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="pick avatar"><rect width="64" height="64" rx="16" fill="#0ea5e9"/><g transform="translate(8 8) scale(2)" fill="none" stroke="#f8fafc" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3c4 0 7 2.5 7 6 0 4.5-4 9-7 12-3-3-7-7.5-7-12 0-3.5 3-6 7-6z"/></g></svg>

After

Width:  |  Height:  |  Size: 370 B

+381
View File
@@ -0,0 +1,381 @@
/*
* fee[dB]ack v0.3.0 — topbar Tuner + Instrument Selector cards.
*
* Implements the Google Stitch "Tuner and Instrument Selector Components"
* (project 2627687099825089475): charcoal rounded cards — a tuner display
* (vertical heat-gradient meter + active-green segment + big italic note +
* Hz) and an instrument selector (guitar icon + chevron dropdown), scaled to
* the header row.
*
* Behaviour:
* - Clicking the tuner card opens the SAME tuner as the plugin's floating
* "Tuner" button (window.tuner.toggle() / #tuner-toggle-btn).
* - The instrument selector persists instrument/strings/tuning/reference in
* /api/settings, emits `instrument:changed`, AND pushes the selection into
* the tuner plugin (POST /api/plugins/tuner/config + window._tunerReloadConfig)
* so the tuner auto-switches its tuning.
*/
(function () {
'use strict';
const sm = window.slopsmith;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5] };
// Tuning names per instrument key (e.g. 'guitar-6', 'bass-4'), loaded from
// GET /api/tunings. Falls back to empty arrays until the fetch resolves.
let _tuningsByKey = {};
function _tuningsForKey(key) { return Object.keys(_tuningsByKey[key] || {}); }
function _tuningsForInstrument(instrument, string_count) {
return _tuningsForKey(instrument + '-' + string_count);
}
// Lowest-string note per tuning name, for the tuner card's note readout.
// Populated from /api/tunings frequencies: low string = index 0.
let TUNING_NOTE = {};
// Chromatic scale (C-based). Index 0 = lowest string relative to E2 (82.41 Hz).
// The lowest open string of guitar-6 Standard is E2; offset 0 maps to 'E'.
const NOTE_NAMES = ['C', 'C#', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B'];
function _freqToNote(hz) {
if (!hz || !Number.isFinite(hz) || hz <= 0) return 'E';
const midi = Math.round(69 + 12 * Math.log2(hz / 440));
return NOTE_NAMES[((midi % 12) + 12) % 12];
}
// Resolve a custom offset-array tuning to a readable low-string note name.
// Offsets are semitones from E Standard (index 0 = lowest string).
function lowStringNote(off) {
const semis = Array.isArray(off) && Number.isFinite(off[0]) ? off[0] : 0;
return NOTE_NAMES[(((4 + semis) % 12) + 12) % 12];
}
// Display label for the active tuning: a named tuning as-is, or 'Custom'
// for an offset-array tuning (which has no canonical name).
function tuningLabel() {
return typeof settings.tuning === 'string' ? settings.tuning : 'Custom';
}
let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440 };
async function loadTunings() {
try {
const r = await fetch('/api/tunings');
if (!r.ok) return;
const data = await r.json();
_tuningsByKey = data.tunings || {};
// Build TUNING_NOTE from the first (lowest) string frequency of each tuning.
TUNING_NOTE = {};
for (const key of Object.keys(_tuningsByKey)) {
for (const [name, freqs] of Object.entries(_tuningsByKey[key])) {
if (!(name in TUNING_NOTE) && Array.isArray(freqs) && freqs.length > 0) {
TUNING_NOTE[name] = _freqToNote(freqs[0]);
}
}
}
} catch (_) { /* non-fatal — TUNINGS falls back to empty, dropdown shows nothing */ }
}
async function loadSettings() {
try {
const r = await fetch('/api/settings');
if (r.ok) {
const s = await r.json();
// Clamp persisted values to valid ranges — config.json could
// hold out-of-range data (hand-edited or from an import), and the
// badge/tuner must render consistent state, not a bad number.
const instrument = s.instrument === 'bass' ? 'bass' : 'guitar';
const counts = STRING_COUNTS[instrument];
const sc = Number(s.string_count);
const scValid = counts.includes(sc) ? sc : counts[0];
const tunings = _tuningsForInstrument(instrument, scValid);
let ref = Number(s.reference_pitch);
if (!Number.isFinite(ref)) ref = 440;
// tuning: a known named tuning is used as-is; a custom
// offset-array tuning (see /api/settings) is PRESERVED rather
// than discarded — the named-tuning badge can't label it yet
// (tracked for P23), and pushToTuner()/renderTuner() guard the
// non-string case — anything else falls back to the default.
let tuning;
if (typeof s.tuning === 'string') tuning = tunings.includes(s.tuning) ? s.tuning : (tunings[0] || 'Standard');
else if (Array.isArray(s.tuning)) tuning = s.tuning;
else tuning = tunings[0] || 'Standard';
settings = {
instrument: instrument,
string_count: scValid,
tuning: tuning,
reference_pitch: Math.min(450, Math.max(430, ref)),
};
}
} catch (e) { /* settings endpoint always present */ }
}
async function saveSettings(patch) {
// Only adopt the patch once the server accepts it. /api/settings returns
// {error: ...} with HTTP 200 on a validation failure, so a rejected
// patch must NOT mutate local state, emit instrument:changed, or push to
// the tuner — otherwise the UI/tuner desync from the persisted config.
let accepted = false;
try {
const r = await fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch),
});
if (r.ok) {
const body = await r.json().catch(() => ({}));
accepted = !(body && body.error);
}
} catch (e) { /* non-fatal — leave settings unchanged */ }
if (!accepted) return;
Object.assign(settings, patch);
if (sm && sm.emit) sm.emit('instrument:changed', {
instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning,
});
pushToTuner();
renderTuner(); // reflect new tuning on the tuner card
}
// Drive the tuner plugin's instrument + tuning from the selection.
async function pushToTuner() {
try {
const lastInstrument = settings.instrument + '-' + settings.string_count; // e.g. guitar-6, bass-4
// The tuner plugin keys its config by tuning NAME. A custom
// offset-array tuning has no name, so sync only the instrument and
// skip lastTuning rather than POST an array the plugin can't parse.
const body = { lastInstrument };
if (typeof settings.tuning === 'string') {
body.lastTuning = settings.tuning;
}
await fetch('/api/plugins/tuner/config', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (typeof window._tunerReloadConfig === 'function') await window._tunerReloadConfig();
} catch (e) { /* tuner plugin may be absent */ }
}
function openTuner() {
// Same action as the plugin's floating "Tuner" button.
if (window.tuner && typeof window.tuner.toggle === 'function') { window.tuner.toggle(); return; }
const btn = document.getElementById('tuner-toggle-btn');
if (btn) btn.click();
// else: tuner plugin not installed — no-op.
}
// ── Live tuner badge helpers ──────────────────────────────────────────--
let _lastFrame = null;
// 11-bar heat gradient: index 0 = very flat (dark red), 5 = center (green), 10 = very sharp (dark red).
// Each step = 5 cents; range ±25 cents.
const _SEG_BASE = [
'bg-red-900', // 0 25 cents
'bg-red-700', // 1 20 cents
'bg-orange-600', // 2 15 cents
'bg-yellow-600', // 3 10 cents
'bg-emerald-700', // 4 5 cents
'bg-emerald-700', // 5 0 cents (center)
'bg-emerald-700', // 6 +5 cents
'bg-yellow-600', // 7 +10 cents
'bg-orange-600', // 8 +15 cents
'bg-red-700', // 9 +20 cents
'bg-red-900', // 10 +25 cents
];
function _segActiveClass(i) {
if (i === 5) return 'bg-emerald-400 shadow-[0_0_10px_3px_rgba(52,211,153,0.95)]';
if (i === 4 || i === 6) return 'bg-emerald-500 shadow-[0_0_8px_2px_rgba(52,211,153,0.85)]';
if (i === 3 || i === 7) return 'bg-yellow-400 shadow-[0_0_8px_2px_rgba(234,179,8,0.9)]';
if (i === 2 || i === 8) return 'bg-orange-500 shadow-[0_0_6px_2px_rgba(249,115,22,0.85)]';
if (i === 1 || i === 9) return 'bg-red-500 shadow-[0_0_6px_2px_rgba(239,68,68,0.8)]';
return 'bg-red-700 shadow-[0_0_4px_1px_rgba(185,28,28,0.7)]'; // 0 or 10
}
// Returns true when the tuning name implies flat notation (mirrors tuning-utils.js).
function _preferFlats(tuningName) {
return typeof tuningName === 'string' && /\b[A-G]b\b/.test(tuningName);
}
// Compute nearest chromatic note + cents deviation from raw freq (always free-tune).
function _freeTuneCents(freq, useFlats, referencePitch) {
if (!freq || freq <= 0) return { note: '—', cents: 0 };
const ref = (referencePitch > 0 && isFinite(referencePitch)) ? referencePitch : 440;
const midi = 69 + 12 * Math.log2(freq / ref);
const rounded = Math.round(midi);
const cents = Math.round((midi - rounded) * 100);
const sharps = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const flats = ['C','Db','D','Eb', 'E','F','Gb','G','Ab','A','Bb', 'B'];
return { note: (useFlats ? flats : sharps)[((rounded % 12) + 12) % 12], cents };
}
function _applyFrame(frame) {
const host = document.getElementById('v3-badge-tuner');
if (!host) return;
const noteEl = host.querySelector('[data-tuner-note]');
const hzEl = host.querySelector('[data-tuner-hz]');
const segs = host.querySelectorAll('[data-tuner-seg]');
if (!segs.length) return; // skeleton not yet rendered
if (!frame || !frame.hasSignal) {
const fallbackNote = typeof settings.tuning === 'string'
? (TUNING_NOTE[settings.tuning] || 'E')
: lowStringNote(settings.tuning);
if (noteEl) noteEl.textContent = fallbackNote;
if (hzEl) hzEl.textContent = Math.round(settings.reference_pitch || 440) + 'hz';
segs.forEach((s, i) => { s.className = 'w-5 h-[3px] rounded-full ' + _SEG_BASE[i]; });
return;
}
// Always free-tune: derive note + cents from raw freq, ignoring tuning target.
const { freq } = frame;
const { note, cents } = _freeTuneCents(freq, _preferFlats(settings.tuning), settings.reference_pitch);
if (noteEl) noteEl.textContent = note;
if (hzEl) hzEl.textContent = Math.round(freq) + 'hz';
// cents ±25 maps to indices 010; centre (0¢) = index 5, sharp (+) = top (0), flat () = bottom (10).
const activeIdx = Math.max(0, Math.min(10, 5 - Math.round(cents / 5)));
segs.forEach((s, i) => {
s.className = 'w-5 h-[3px] rounded-full ' + (i === activeIdx ? _segActiveClass(i) : _SEG_BASE[i]);
});
}
// ── Tuner card (Stitch LeftTunerComponent) ────────────────────────────--
function renderTuner() {
const host = document.getElementById('v3-badge-tuner');
if (!host) return;
const hz = Math.round(settings.reference_pitch || 440);
const initNote = typeof settings.tuning === 'string'
? (TUNING_NOTE[settings.tuning] || 'E') : lowStringNote(settings.tuning);
const seg = (i) => '<div data-tuner-seg="' + i + '" class="w-5 h-[3px] rounded-full ' + _SEG_BASE[i] + '"></div>';
const meter = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(seg).join('');
host.innerHTML =
'<div id="v3-tuner-wrap" class="relative">' +
'<button type="button" data-open-tuner title="Open tuner" ' +
'class="bg-fb-card border border-fb-border/50 rounded-2xl h-[92px] w-[96px] shrink-0 px-3 flex items-center gap-3 overflow-hidden hover:ring-1 hover:ring-fb-primary/40 transition">' +
'<div class="shrink-0 flex flex-col gap-[3px] items-center justify-center">' + meter + '</div>' +
'<div class="min-w-0 text-white text-center leading-none">' +
'<div data-tuner-note class="text-2xl font-black italic tracking-tighter leading-none">' + esc(initNote) + '</div>' +
'<div data-tuner-hz class="text-[9px] text-gray-400 mt-0.5 tracking-wider truncate">' + hz + 'hz</div>' +
'</div></button>' +
'</div>';
host.querySelector('[data-open-tuner]').addEventListener('click', (e) => {
e.stopPropagation();
const tunerPanel = document.getElementById('tuner-plugin-ui');
const tunerIsOpen = tunerPanel && !tunerPanel.classList.contains('hidden');
if (!tunerIsOpen) {
// About to open — close the instruments panel first.
closeInstMenu();
document.removeEventListener('click', closeInstMenu);
}
openTuner();
});
_applyFrame(_lastFrame);
}
// ── Instrument selector card (Stitch RightInstrumentSelector) ──────────--
const guitarIcon =
'<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">' +
'<path d="M21.66 3.34a1.2 1.2 0 0 0-1.7 0l-2.1 2.1-.7-.7a1 1 0 0 0-1.42 1.42l.3.3-6.06 6.05a4.5 4.5 0 1 0 1.42 1.42l6.05-6.06.3.3a1 1 0 0 0 1.42-1.42l-.7-.7 2.1-2.1a1.2 1.2 0 0 0 0-1.7zM7 19a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></svg>';
// Instrument-menu open/close helpers keyed by id (NOT a captured element),
// so they survive renderInstrument() replacing the menu node. closeInstMenu
// is a stable named handler so it can be registered/removed across opens.
function liveInstMenu() {
const host = document.getElementById('v3-badge-instrument');
return host ? host.querySelector('[data-inst-menu]') : null;
}
function closeInstMenu() { const m = liveInstMenu(); if (m) m.classList.add('hidden'); }
function openInstMenu() {
const m = liveInstMenu();
if (!m) return;
// Close the tuner panel if it is open so the two panels are mutually exclusive.
const tunerPanel = document.getElementById('tuner-plugin-ui');
if (tunerPanel && !tunerPanel.classList.contains('hidden') && window.tuner) {
window.tuner.disable();
}
m.classList.remove('hidden');
// (Re)register the outside-click closer for whatever the CURRENT menu
// node is — dedupe first so repeated opens don't stack listeners.
document.removeEventListener('click', closeInstMenu);
document.addEventListener('click', closeInstMenu, { once: true });
}
function renderInstrument() {
const host = document.getElementById('v3-badge-instrument');
if (!host) return;
host.innerHTML =
'<div class="relative">' +
'<button type="button" data-inst-toggle title="Instrument: ' + esc(settings.string_count + '-str ' + tuningLabel()) + '" ' +
'class="bg-fb-card border border-fb-border/50 rounded-2xl h-[92px] w-16 flex flex-col items-center justify-center gap-2 hover:ring-1 hover:ring-fb-primary/40 transition">' +
guitarIcon +
'<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>' +
'</button>' +
'<div data-inst-menu class="hidden absolute right-0 mt-2 w-60 bg-fb-card border border-fb-border/50 rounded-xl shadow-xl p-3 z-50 space-y-3">' +
instRow('Instrument', ['guitar', 'bass'].map((v) =>
pill('inst', v, v[0].toUpperCase() + v.slice(1), settings.instrument === v)).join('')) +
instRow('Strings', STRING_COUNTS[settings.instrument].map((v) =>
pill('strings', v, v + '', settings.string_count === v)).join('')) +
'<div><div class="text-[10px] uppercase tracking-wider text-fb-textDim mb-1">Tuning</div>' +
'<select data-inst-tuning class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-fb-text outline-none focus:border-fb-primary">' +
// An offset-array tuning has no named option — surface it as a
// disabled, selected 'Custom' entry so the dropdown reflects reality
// (picking a named tuning still works and replaces the custom one).
(typeof settings.tuning === 'string' ? '' : '<option selected disabled>Custom</option>') +
_tuningsForInstrument(settings.instrument, settings.string_count).map((t) => '<option' + (t === settings.tuning ? ' selected' : '') + '>' + esc(t) + '</option>').join('') + '</select></div>' +
'<div><div class="flex justify-between text-[10px] uppercase tracking-wider text-fb-textDim mb-1"><span>Reference pitch</span><span data-ref-val>' + settings.reference_pitch + ' Hz</span></div>' +
'<input data-inst-ref type="range" min="430" max="450" step="1" value="' + settings.reference_pitch + '" class="w-full slider-input"></div>' +
'</div></div>';
const toggle = host.querySelector('[data-inst-toggle]');
const menu = host.querySelector('[data-inst-menu]');
// Open/close via the id-based helpers so the outside-click closer always
// targets the live menu (renderInstrument may replace this node) and is
// only armed while the menu is actually open.
toggle.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.classList.contains('hidden')) openInstMenu();
else { closeInstMenu(); document.removeEventListener('click', closeInstMenu); }
});
menu.addEventListener('click', (e) => e.stopPropagation());
// After a settings change re-renders the menu, re-open the NEW node and
// re-arm its outside-click closer (openInstMenu re-queries by id).
const keepOpen = openInstMenu;
menu.querySelectorAll('[data-pill="inst"]').forEach((b) => b.addEventListener('click', async () => {
const v = b.getAttribute('data-val');
const counts = STRING_COUNTS[v];
// Clamp string_count AND tuning to ones valid for the new
// instrument, so switching can't persist (and push to the tuner) an
// unsupported instrument+tuning combo.
const newSc = counts.includes(settings.string_count) ? settings.string_count : counts[0];
const tunings = _tuningsForInstrument(v, newSc);
await saveSettings({
instrument: v,
string_count: newSc,
tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning),
});
renderInstrument(); keepOpen();
}));
menu.querySelectorAll('[data-pill="strings"]').forEach((b) => b.addEventListener('click', async () => {
await saveSettings({ string_count: Number(b.getAttribute('data-val')) }); renderInstrument(); keepOpen();
}));
menu.querySelector('[data-inst-tuning]').addEventListener('change', (e) => saveSettings({ tuning: e.target.value }));
const ref = menu.querySelector('[data-inst-ref]');
ref.addEventListener('input', (e) => { menu.querySelector('[data-ref-val]').textContent = e.target.value + ' Hz'; });
ref.addEventListener('change', (e) => saveSettings({ reference_pitch: Number(e.target.value) }));
}
function instRow(label, inner) {
return '<div><div class="text-[10px] uppercase tracking-wider text-fb-textDim mb-1">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
}
function pill(group, val, label, active) {
return '<button type="button" data-pill="' + group + '" data-val="' + val + '" class="px-2 py-1 rounded-md text-xs ' +
(active ? 'bg-fb-primary text-white' : 'bg-gray-800/50 text-fb-textDim hover:text-fb-text') + '">' + esc(label) + '</button>';
}
window.v3Badges = { reload: async () => { await Promise.all([loadTunings(), loadSettings()]); renderInstrument(); renderTuner(); } };
async function boot() {
await Promise.all([loadTunings(), loadSettings()]);
renderInstrument();
renderTuner();
pushToTuner(); // sync the tuner to the persisted selection on load
if (sm && sm.on) {
sm.on('tuner:frame', (e) => {
_lastFrame = e.detail;
_applyFrame(e.detail);
});
sm.on('tunings:updated', async () => {
await loadTunings();
renderInstrument();
});
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();
+47
View File
@@ -0,0 +1,47 @@
/*
* fee[dB]ack v0.3.0 — brand helpers.
*
* Single source for the wordmark so the sidebar, topbar (prompt 12), modals
* and onboarding (prompt 15) render it identically. The `[dB]` is a literal
* bracketed decibel pun, accent-colored (design/01-design-system.md §4).
* Vanilla JS, no framework (constitution P-II).
*/
(function () {
'use strict';
// Inner markup of the wordmark (no wrapper element) so callers can choose
// the tag/size. `fee` + `ack` use the primary text color; `[dB]` is sky.
const WORDMARK_INNER =
'fee<span class="text-fb-primary">[dB]</span>ack';
/**
* Returns the styled fee[dB]ack wordmark as an HTML string.
* @param {Object} [opts]
* @param {string} [opts.size='text-xl'] Tailwind text-size utility.
* @param {boolean} [opts.mono=false] Monochrome (single color, keeps
* the bracket characters) for small
* or single-tone contexts.
* @param {string} [opts.extra=''] Extra classes on the wrapper.
*/
function wordmarkHTML(opts) {
opts = opts || {};
const size = opts.size || 'text-xl';
const extra = opts.extra || '';
const inner = opts.mono ? 'fee[dB]ack' : WORDMARK_INNER;
return '<span class="font-extrabold tracking-tight text-fb-text ' +
size + ' ' + extra + '">' + inner + '</span>';
}
/** Replaces an element's contents with the wordmark. */
function renderWordmark(el, opts) {
if (!el) return;
el.innerHTML = wordmarkHTML(opts);
}
window.fbBrand = {
wordmarkHTML: wordmarkHTML,
renderWordmark: renderWordmark,
WORDMARK_SVG: '/static/v3/brand/feedback-wordmark.svg',
FAVICON_SVG: '/static/v3/brand/favicon.svg',
};
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="fee[dB]ack">
<!-- fee[dB]ack app mark: the [dB] motif (decibel pun) on the navy tile.
Sky dB with dimmer brackets; legible down to favicon sizes. -->
<rect width="64" height="64" rx="14" fill="#111827"/>
<text x="32" y="33" text-anchor="middle" dominant-baseline="central"
font-family="Inter, system-ui, -apple-system, Segoe UI, sans-serif"
font-weight="800" font-size="30" letter-spacing="-1">
<tspan fill="#94a3b8">[</tspan><tspan fill="#0ea5e9">dB</tspan><tspan fill="#94a3b8">]</tspan>
</text>
</svg>

After

Width:  |  Height:  |  Size: 623 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 64" role="img" aria-label="fee[dB]ack">
<!-- Full fee[dB]ack wordmark: fee + ack in primary text, [dB] in sky.
The brackets are literal characters, not borders (design-system §4). -->
<text x="6" y="32" dominant-baseline="central"
font-family="Inter, system-ui, -apple-system, Segoe UI, sans-serif"
font-weight="800" font-size="40" letter-spacing="-1.5">
<tspan fill="#f8fafc">fee</tspan><tspan fill="#0ea5e9">[dB]</tspan><tspan fill="#f8fafc">ack</tspan>
</text>
</svg>

After

Width:  |  Height:  |  Size: 562 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+52
View File
@@ -0,0 +1,52 @@
/*
* fee[dB]ack v0.3.0 — core library-card actions.
*
* Registers the built-in Edit-metadata and Retune-to-E-Standard actions
* through the ui.library-card-injection capability, so core and plugin card
* actions flow through one pipeline (the Songs grid renders whatever is
* registered). These call the legacy globals (openEditModal / retuneSong)
* exposed by app.js in the v3 shell — detection/playback stay on documented
* globals (design/05). Re-running is a no-op: libraryCardActions.register()
* rejects duplicate ids, so the first registration stands.
*/
(function () {
'use strict';
const sm = window.slopsmith;
if (!sm || !sm.libraryCardActions) return;
const STD_RETUNABLE = ['Eb Standard', 'D Standard', 'C# Standard', 'C Standard'];
const reg = sm.libraryCardActions;
const hooks = window.__slopsmithV3CoreCardActions || (window.__slopsmithV3CoreCardActions = {});
if (hooks.installed) return;
hooks.installed = true;
reg.register({
id: 'core.edit-metadata',
pluginId: 'core',
label: 'Edit metadata',
placement: 'menu',
order: 10,
applies: (song) => !!(song && song.filename),
run: (song) => {
if (typeof window.openEditModal !== 'function') return;
window.openEditModal({
f: song.filename, t: song.title || '', a: song.artist || '',
al: song.album || '', y: song.year || '',
}, null);
},
});
reg.register({
id: 'core.retune-estd',
pluginId: 'core',
label: 'Convert to E Standard',
placement: 'menu',
order: 20,
applies: (song) => !!(song && song.filename && song.format !== 'sloppak'
&& song.tuning && !song.has_estd && STD_RETUNABLE.includes(song.tuning)),
run: (song) => {
if (typeof window.retuneSong !== 'function') return;
window.retuneSong(song.filename, song.title || song.filename, song.tuning, 'E Standard');
},
});
})();
+272
View File
@@ -0,0 +1,272 @@
/*
* fee[dB]ack v0.3.0 — Dashboard / Home (#v3-home).
*
* Composes data from the backends built in other prompts: profile (15),
* song-stats/recent (14), continue (16), library stats + plugins. Each widget
* fetches + renders independently and DEGRADES GRACEFULLY — a missing/empty
* endpoint shows a placeholder, never blocks first paint (design/05 §1).
* Vanilla JS, fb-* tokens (constitution P-II).
*/
(function () {
'use strict';
const sm = window.slopsmith;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
const jget = async (u) => { try { const r = await fetch(u); return r.ok ? r.json() : null; } catch (e) { return null; } };
function libArtUrl(song) {
const v = song.mtime ? ('?v=' + Math.floor(song.mtime)) : '';
return '/api/song/' + enc(song.filename) + '/art' + v;
}
// A random library song (for the "Pick a song" card when nothing's been played).
async function randomSong() {
const stats = await jget('/api/library/stats');
const total = (stats && (stats.total_songs ?? stats.total)) || 0;
if (!total) return null;
const idx = Math.floor(Math.random() * total);
const data = await jget('/api/library?size=1&page=' + idx);
return data && data.songs && data.songs[0] ? data.songs[0] : null;
}
// Accuracy badge ramp (design/04-badges.md §C): ≥90% good, 5089% mid, <50% low.
function accuracyBadge(acc) {
if (acc == null) return '';
const pct = Math.round(acc * 100);
const color = acc >= 0.9 ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low');
const text = acc >= 0.5 && acc < 0.9 ? 'text-black' : 'text-white';
return '<span class="absolute bottom-0 right-0 ' + color + '/90 ' + text +
' px-2 py-1 rounded-tl-md text-xs font-bold flex items-center gap-1">' +
'<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/></svg>' +
pct + '%</span>';
}
function songArt(url, extra) {
return '<img src="' + esc(url) + '" alt="" class="' + (extra || '') +
'" onerror="this.style.visibility=\'hidden\'">';
}
function tuningChip(name, cls) {
if (!name) return '';
return '<span class="' + (cls || '') + ' bg-fb-mid text-black text-xs font-bold px-2 py-1 rounded-sm">' + esc(name) + '</span>';
}
// Source format of a song — prefer the server's `format`, fall back to the
// filename extension. '' = unknown.
function fmtName(song) {
let f = ((song && song.format) || '').toLowerCase();
if (!f) {
const fn = ((song && song.filename) || '').toLowerCase();
f = fn.endsWith('.sloppak') ? 'sloppak' : '';
}
return f === 'sloppak' ? 'SLOPPAK' : f === 'loose' ? 'FOLDER' : '';
}
// Corner badge for art-thumbnail cards (sloppak accented, others muted).
function fmtBadge(song) {
const l = fmtName(song);
if (!l) return '';
const c = l === 'SLOPPAK' ? 'bg-fb-primary text-white' : 'bg-black/70 text-fb-textDim';
return '<span class="absolute bottom-0 left-0 ' + c + ' text-[9px] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
}
// Inline pill for the hero (Pick/Continue) card, where the art is text-overlaid
// and a corner badge would collide — sits next to the card's label instead.
function fmtTag(song) {
const l = fmtName(song);
if (!l) return '';
const c = l === 'SLOPPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card/80 text-fb-textDim';
return '<span class="' + c + ' text-[9px] font-bold px-1.5 py-0.5 rounded tracking-wide shrink-0">' + l + '</span>';
}
// ── Continue-Playing resume ──────────────────────────────────────────---
function resume(filename, lastPosition, arrangement) {
if (typeof window.playSong !== 'function') return;
// playSong expects an encoded filename (it decodeURIComponent()s it for
// the highway WS) and takes the arrangement index as its 2nd arg — pass
// both so resume reopens the exact arrangement the user left, not the
// default. It has no start-time arg, so play then best-effort seek once
// the session is up (constitution: highway WS).
Promise.resolve(window.playSong(enc(filename), arrangement)).then(() => {
if (Number.isFinite(lastPosition) && lastPosition > 0 && sm && typeof sm.seek === 'function') {
setTimeout(() => { try { sm.seek(lastPosition, 'continue-playing'); } catch (e) { /* */ } }, 800);
}
});
}
// ── Render ───────────────────────────────────────────────────────────---
async function render() {
const root = document.getElementById('v3-home');
if (!root) return;
// Kick off independent fetches.
const [profile, version, cont, libStats, plugins, recent] = await Promise.all([
jget('/api/profile'), jget('/api/version'), jget('/api/session/continue'),
jget('/api/library/stats'), jget('/api/plugins'), jget('/api/stats/recent?limit=6'),
]);
const name = (profile && profile.display_name) || 'there';
const ver = (version && version.version) || '';
const changelogUrl = ((version && version.source_url) || 'https://github.com/byrongamatos/slopsmith') + '/blob/main/CHANGELOG.md';
const songCount = (libStats && (libStats.total_songs ?? libStats.total)) || 0;
const pluginCount = Array.isArray(plugins)
? plugins.filter((p) => (p && (p.status || 'ready') === 'ready')).length : 0;
// "Jump back in": scored recents, else fall back to recently-added songs
// so the section is never empty on a fresh profile.
let recentList = Array.isArray(recent) ? recent : [];
if (!recentList.length) {
const lib = await jget('/api/library?sort=recent&size=6&page=0');
recentList = ((lib && lib.songs) || []).map((s) => ({
filename: s.filename, title: s.title, artist: s.artist,
art_url: libArtUrl(s), best_accuracy: null,
}));
}
// When nothing's been played, the Continue card becomes a random
// pick-a-song that plays on click.
const pick = (cont && cont.filename) ? null : await randomSong();
// Continue card.
let continueCard;
if (cont && cont.filename) {
const dur = cont.duration || 0;
const segs = 4;
const filled = dur > 0 ? Math.round((cont.last_position / dur) * segs) : 0;
const bars = Array.from({ length: segs }, (_, i) =>
'<span class="flex-1 h-1.5 rounded-full ' + (i < filled ? 'bg-fb-primary' : 'bg-gray-500/40') + '"></span>').join('');
continueCard =
'<button id="v3-continue" class="group relative text-left rounded-xl overflow-hidden border border-fb-border/50 bg-fb-card aspect-square self-start flex flex-col justify-end">' +
songArt(cont.art_url, 'absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-70 transition') +
'<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent"></div>' +
tuningChip(cont.tuning_name, 'absolute top-3 right-3') +
'<div class="relative p-4">' +
'<div class="flex items-center justify-between gap-2 mb-1"><span class="text-xs uppercase tracking-wider text-fb-textDim">Continue Playing</span>' + fmtTag(cont) + '</div>' +
'<div class="text-fb-text font-bold truncate">' + esc(cont.title) + '</div>' +
'<div class="text-sm text-fb-textDim truncate mb-3">' + esc(cont.artist) + '</div>' +
'<div class="flex gap-1">' + bars + '</div></div>' +
'<span class="absolute top-3 left-3 text-fb-text/80 group-hover:text-fb-text">▶</span></button>';
} else if (pick) {
continueCard =
'<button id="v3-pick" data-fn="' + esc(pick.filename) + '" class="group relative text-left rounded-xl overflow-hidden border border-fb-border/50 bg-fb-card aspect-square self-start flex flex-col justify-end">' +
songArt(libArtUrl(pick), 'absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-70 transition') +
'<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent"></div>' +
tuningChip(pick.tuning_name, 'absolute top-3 right-3') +
'<div class="relative p-4">' +
'<div class="flex items-center justify-between gap-2 mb-1"><span class="text-xs uppercase tracking-wider text-fb-textDim">Pick a song</span>' + fmtTag(pick) + '</div>' +
'<div class="text-fb-text font-bold truncate">' + esc(pick.title) + '</div>' +
'<div class="text-sm text-fb-textDim truncate">' + esc(pick.artist) + '</div></div>' +
'<span class="absolute top-3 left-3 text-fb-text/80 group-hover:text-fb-text">▶</span></button>';
} else {
continueCard =
'<div class="rounded-xl border border-fb-border/50 bg-fb-card/60 aspect-square self-start flex flex-col items-center justify-center text-center p-4">' +
'<div class="text-fb-textDim text-sm mb-3">Pick a song to get started</div>' +
'<button id="v3-continue-pick" class="bg-fb-card hover:bg-fb-card/70 border border-fb-border/50 text-fb-text text-sm px-4 py-2 rounded-md">Browse library</button></div>';
}
// Recently played.
let recentSection;
if (recentList.length) {
const cards = recentList.map((r) =>
'<button data-recent="' + esc(r.filename) + '" data-arr="' + esc(r.arrangement != null ? r.arrangement : '') + '" class="group text-left">' +
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card">' +
songArt(r.art_url, 'w-full h-full object-cover transition-transform duration-300 group-hover:scale-105') +
accuracyBadge(r.best_accuracy) + fmtBadge(r) + '</div>' +
'<div class="mt-1 text-sm text-fb-text truncate">' + esc(r.title) + '</div>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(r.artist) + '</div></button>').join('');
recentSection =
'<section class="mt-10"><h3 class="text-2xl font-bold text-fb-text mb-4">Jump back in!</h3>' +
'<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">' + cards + '</div></section>';
} else {
recentSection =
'<section class="mt-10"><h3 class="text-2xl font-bold text-fb-text mb-4">Jump back in!</h3>' +
'<p class="text-fb-textDim text-sm">Play a song to see it here.</p></section>';
}
root.innerHTML =
'<div class="max-w-7xl mx-auto px-6 md:px-8 pb-8">' +
// (Title "Welcome back, {name}!" lives in the topbar header row.)
(ver ? '<p class="text-sm text-fb-textDim">Have you read the latest changes in the ' +
'<a href="' + esc(changelogUrl) + '" target="_blank" rel="noopener" class="text-fb-primary hover:text-fb-primaryHi">Patch Notes for ' + esc(ver) + '</a>?</p>' : '') +
// Featured grid: hero + continue
'<div class="grid lg:grid-cols-3 gap-6 mt-6">' +
'<div class="lg:col-span-2 relative rounded-xl overflow-hidden min-h-[480px] flex items-center bg-fb-bg">' +
// Hero artwork (neon note-highway), right-anchored. Placeholder
// cropped from the design mock — swap static/v3/brand/hero.png for
// the designer's high-res original (same path) when available.
'<img src="/static/v3/brand/hero.png" alt="" aria-hidden="true" ' +
'class="absolute inset-y-0 right-0 h-full w-2/3 object-cover object-right" ' +
'onerror="this.style.display=\'none\'">' +
// 135° gradient overlay: solid navy on the left for text legibility,
// fading to transparent so the artwork shows through on the right.
'<div class="absolute inset-0" style="background-image:linear-gradient(135deg,#0f172a 0%,#0f172a 40%,rgba(15,23,42,0.55) 65%,rgba(15,23,42,0.15) 100%);"></div>' +
'<div class="relative p-8 max-w-md">' +
'<h3 class="text-4xl font-bold leading-tight text-fb-text">Turn any song into practice.</h3>' +
'<p class="text-fb-textDim mt-2">Play along to your library, track your accuracy, and rank up.</p>' +
'<div class="flex gap-3 mt-5">' +
'<button id="v3-start" class="bg-fb-primary hover:bg-fb-primaryHi text-white px-6 py-2 rounded-md font-medium shadow-lg shadow-fb-primary/20">Start Playing</button>' +
'<button id="v3-lessons-btn" class="bg-transparent border border-fb-textDim hover:border-white text-white px-6 py-2 rounded-md font-medium">Lessons</button>' +
'</div></div></div>' +
continueCard +
'</div>' +
// Stats row
'<div class="grid md:grid-cols-3 gap-6 mt-6">' +
audioRoutingCard() +
statCard(String(songCount), 'songs', 'text-fb-gold') +
statCard(String(pluginCount), 'active', 'text-fb-good') +
'</div>' +
recentSection +
'</div>';
// Wire interactions.
const startBtn = root.querySelector('#v3-start');
if (startBtn) startBtn.addEventListener('click', () => {
if (cont && cont.filename) resume(cont.filename, cont.last_position, cont.arrangement);
else if (pick) window.playSong && window.playSong(enc(pick.filename));
else window.showScreen && window.showScreen('v3-songs');
});
root.querySelector('#v3-continue')?.addEventListener('click', () => resume(cont.filename, cont.last_position, cont.arrangement));
root.querySelector('#v3-pick')?.addEventListener('click', () => window.playSong && window.playSong(enc(pick.filename)));
root.querySelector('#v3-continue-pick')?.addEventListener('click', () => window.showScreen && window.showScreen('v3-songs'));
root.querySelector('#v3-lessons-btn')?.addEventListener('click', () => window.showScreen && window.showScreen('v3-lessons'));
root.querySelectorAll('[data-recent]').forEach((b) =>
b.addEventListener('click', () => {
if (!window.playSong) return;
// /api/stats/recent rows are arrangement-specific — reopen the
// arrangement that was actually played, not the default.
const arr = b.getAttribute('data-arr');
window.playSong(enc(b.getAttribute('data-recent')), arr === '' || arr == null ? undefined : Number(arr));
}));
// Let prompt 18 enhance the audio-routing card once it exists.
if (window.v3AudioRouting && typeof window.v3AudioRouting.render === 'function') {
try { window.v3AudioRouting.render(document.getElementById('v3-audio-routing')); } catch (e) { /* */ }
}
}
function statCard(value, unit, unitColor) {
return '<div class="bg-fb-card/80 backdrop-blur rounded-lg p-4 border border-fb-border/50 flex flex-col justify-center">' +
'<div class="text-2xl font-bold text-fb-text">' + esc(value) +
' <span class="text-sm font-medium ' + unitColor + '">' + esc(unit) + '</span></div></div>';
}
// Audio-routing widget placeholder (prompt 18 replaces #v3-audio-routing's
// body via window.v3AudioRouting). Until then: "Not Connected".
function audioRoutingCard() {
return '<div id="v3-audio-routing" class="bg-fb-card/80 backdrop-blur rounded-lg p-4 border border-fb-border/50">' +
'<div class="flex items-center justify-between text-xs text-fb-textDim mb-2"><span>Audio Routing</span></div>' +
'<div class="flex items-center gap-2 text-xs text-fb-textDim">' +
'<span>Input</span><span class="flex-1 border-t border-dashed border-fb-border"></span>' +
'<span class="w-2 h-2 rounded-full bg-gray-500"></span>' +
'<span>VST/NAM/IR</span><span class="flex-1 border-t border-dashed border-fb-border"></span>' +
'<span class="w-2 h-2 rounded-full bg-gray-500"></span><span>Output</span></div>' +
'<div class="mt-2 text-sm font-medium text-fb-textDim">Not Connected</div></div>';
}
window.v3Dashboard = { render: render };
if (sm && typeof sm.on === 'function') {
sm.on('screen:changed', (e) => { if (e && e.detail && e.detail.id === 'v3-home') render(); });
sm.on('v3:profile-updated', () => render());
}
function boot() { render(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();
+281
View File
@@ -0,0 +1,281 @@
/*
* fee[dB]ack v0.3.0 — FeedBarcade (#v3-feedbarcade), v3-native minigames hub.
*
* A vanilla-JS reskin of the bundled `minigames` plugin's screen into the fb-*
* design (constitution P-II). It REUSES the minigames backend + the
* window.slopsmithMinigames SDK — it does not fork scoring/run logic and adds
* no second XP curve. XP stays unified through the core store (P15): the header
* reads /api/profile/progress (the same data the topbar profile badge shows),
* while per-game bests + cross-game unlocks come from the minigames /profile.
*
* UI placement is a DEFERRED capability domain (design/05): we use the legacy
* plugin loader + /api/plugins/minigames/* routes, NOT capability dispatch.
* Launch delegates to slopsmithMinigames.start(gameId), which mounts the
* plugin's own #mg-stage overlay (eagerly injected at boot) — so this screen
* never needs to visit the legacy #plugin-minigames hub.
*/
(function () {
'use strict';
const SCREEN_ID = 'v3-feedbarcade';
const sm = window.slopsmith;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
let _progress = null; // /api/profile/progress → header level/xp/streak
let _mgProfile = null; // /api/plugins/minigames/profile → per_game + unlocks
let _registry = null; // /api/plugins/minigames/registry → installed minigames
// Stage-visibility tracking for the refresh-after-run observer.
let _observer = null;
let _stageWasVisible = false;
let _refreshTimer = null;
function sdk() { return window.slopsmithMinigames || null; }
function registeredList() {
const s = sdk();
try { return (s && typeof s.listRegistered === 'function') ? s.listRegistered() : []; }
catch (e) { return []; }
}
// ── API ────────────────────────────────────────────────────────────────--
async function jget(url) { try { const r = await fetch(url); return r.ok ? r.json() : null; } catch (e) { return null; } }
async function fetchProgress() { _progress = await jget('/api/profile/progress'); }
async function fetchMgProfile() {
const s = sdk();
if (s && typeof s.getProfile === 'function') {
try { _mgProfile = await s.getProfile(); return; } catch (e) { /* fall through */ }
}
_mgProfile = await jget('/api/plugins/minigames/profile');
}
async function fetchRegistry() { _registry = await jget('/api/plugins/minigames/registry'); }
async function load() {
await Promise.all([fetchProgress(), fetchMgProfile(), fetchRegistry()]);
}
// ── Toast (non-blocking, self-contained) ──────────────────────────────────
function toast(msg) {
let host = document.getElementById('v3-fb-toast');
if (!host) {
host = document.createElement('div');
host.id = 'v3-fb-toast';
host.className = 'fixed bottom-6 left-1/2 -translate-x-1/2 z-[120] bg-fb-card text-fb-text ' +
'border border-fb-border/60 rounded-lg px-4 py-2 text-sm shadow-xl';
document.body.appendChild(host);
}
host.textContent = msg;
host.classList.remove('hidden');
clearTimeout(host._t);
host._t = setTimeout(() => host.classList.add('hidden'), 3000);
}
// ── Render: header ────────────────────────────────────────────────────────
function headerHTML() {
const p = _progress || { current_streak: 0, best_streak: 0 };
// Progression (spec 010): rounds earn Decibels (dB) and advance
// minigame challenges/quests — the old XP level meter is gone.
const prog = (window.v3Progression && window.v3Progression.get()) || null;
const rank = prog ? prog.mastery_rank : 0;
const wallet = (prog && prog.wallet) || { balance: 0 };
return '<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50">' +
'<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">' +
'<div>' +
'<h2 class="text-3xl font-bold text-fb-text">FeedBarcade</h2>' +
'<p class="text-sm text-fb-textDim mt-1">Standalone games that share your guitar input — rounds earn Decibels (dB) and count toward challenges and quests.</p>' +
'</div>' +
'<div class="flex items-center gap-4 text-sm text-fb-textDim">' +
'<span class="text-fb-text font-semibold">Rank ' + rank + '</span>' +
'<span class="text-fb-gold font-semibold">' + Number(wallet.balance || 0).toLocaleString() + ' dB</span>' +
'<span class="text-fb-accent">🔥 ' + (p.current_streak || 0) + '-day streak</span>' +
'</div></div>' +
'</div>';
}
// ── Render: a single game tile (song-card pattern) ────────────────────────
function gameTile(m) {
const id = m.plugin_id;
const spec = registeredList().find((g) => g.id === id) || null;
const launchable = !!spec;
const title = m.title || (spec && spec.title) || id;
const tagline = m.tagline || (spec && spec.tagline) || '';
const stats = (_mgProfile && _mgProfile.totals && _mgProfile.totals.per_game && _mgProfile.totals.per_game[id]) || {};
const best = Number(stats.best_score) || 0;
const runs = Number(stats.runs) || 0;
const art = m.thumbnail
? '<img src="/api/plugins/' + enc(id) + '/assets/' + enc(m.thumbnail) + '" alt="" ' +
'class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" ' +
'onerror="this.style.display=\'none\';this.nextElementSibling.classList.remove(\'hidden\')">' +
'<div class="hidden absolute inset-0 flex items-center justify-center text-5xl">🎮</div>'
: '<div class="absolute inset-0 flex items-center justify-center text-5xl">🎮</div>';
const playOverlay = launchable
? '<div class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition bg-black/40">' +
'<span class="px-4 py-2 rounded-full bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-semibold shadow-lg">▶ Play</span></div>'
: '<div class="absolute inset-0 flex items-center justify-center bg-black/50 text-xs text-fb-textDim text-center px-3">Loading… reload if this persists</div>';
// Launchable tiles use a real <button> so the play surface is keyboard-
// focusable and announced as a control; non-launchable (loading) tiles
// stay a plain div.
const surfaceOpen = launchable
? '<button type="button" data-mg-play aria-label="Play ' + esc(title) + '" class="relative aspect-square w-full block p-0 border-0 rounded-lg overflow-hidden bg-fb-card cursor-pointer">'
: '<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card">';
const surfaceClose = launchable ? '</button>' : '</div>';
return '<div class="group relative" data-mg-game="' + esc(id) + '">' +
surfaceOpen +
art + playOverlay +
surfaceClose +
'<div class="mt-1 text-sm text-fb-text truncate" title="' + esc(title) + '">' + esc(title) + '</div>' +
(tagline ? '<div class="text-xs text-fb-textDim truncate" title="' + esc(tagline) + '">' + esc(tagline) + '</div>' : '') +
'<div class="mt-1 flex items-center justify-between text-xs text-fb-textDim">' +
'<span>Runs <b class="text-fb-text">' + runs + '</b></span>' +
'<span>Best <b class="text-fb-text">' + best + '</b></span>' +
'</div></div>';
}
// ── Render: grid / empty state ────────────────────────────────────────────
function gridHTML() {
const games = (_registry && Array.isArray(_registry.minigames)) ? _registry.minigames : [];
if (!games.length) {
return '<div class="bg-fb-card/80 backdrop-blur rounded-xl p-10 border border-fb-border/50 text-center">' +
'<div class="text-5xl mb-3">🎮</div>' +
'<p class="text-fb-text font-semibold">No minigames installed</p>' +
'<p class="text-sm text-fb-textDim mt-1">Install a minigame plugin to start playing.</p>' +
'<button type="button" data-fb-plugins class="mt-4 text-sm text-fb-primary hover:text-fb-primaryHi">Browse plugins →</button>' +
'</div>';
}
return '<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">' +
games.map(gameTile).join('') + '</div>';
}
// ── Render: cross-game unlocks ────────────────────────────────────────────
function unlocksHTML() {
const unlocks = (_mgProfile && Array.isArray(_mgProfile.unlocks)) ? _mgProfile.unlocks : [];
if (!unlocks.length) return '';
// Map a game id → display title from the registry, for nicer labels.
const titleById = {};
((_registry && _registry.minigames) || []).forEach((m) => { titleById[m.plugin_id] = m.title || m.plugin_id; });
const chips = unlocks.map((u) => {
const sepIndex = String(u).indexOf(':');
const gid = sepIndex >= 0 ? u.slice(0, sepIndex) : '';
const name = sepIndex >= 0 ? u.slice(sepIndex + 1) : u;
const game = titleById[gid] || gid;
return '<span class="inline-flex items-center gap-1.5 rounded-full bg-fb-bg/50 border border-fb-border/50 px-3 py-1 text-xs text-fb-text">' +
'<span class="text-fb-gold">★</span>' + esc(name) +
(game ? '<span class="text-fb-textDim">· ' + esc(game) + '</span>' : '') + '</span>';
}).join('');
return '<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50">' +
'<h3 class="text-lg font-bold text-fb-text mb-3">Unlocks</h3>' +
'<div class="flex flex-wrap gap-2">' + chips + '</div></div>';
}
// ── Render: createNoteDetector hint (non-blocking) ─────────────────────────
function noteDetectHintHTML() {
if (typeof window.createNoteDetector === 'function') return '';
return '<div class="bg-fb-card/60 border border-fb-border/40 rounded-lg px-4 py-2 text-xs text-fb-textDim">' +
'Some minigames score your playing and need the Note Detector plugin. They still run without it.' +
'</div>';
}
function render() {
const root = document.getElementById(SCREEN_ID);
if (!root) return;
root.innerHTML =
'<div class="max-w-6xl mx-auto p-6 md:p-8 space-y-6">' +
headerHTML() +
noteDetectHintHTML() +
gridHTML() +
unlocksHTML() +
'</div>';
wire(root);
}
// ── Wiring ────────────────────────────────────────────────────────────────
function wire(scope) {
scope.querySelector('[data-fb-plugins]')?.addEventListener('click', () => {
if (typeof window.showScreen === 'function') window.showScreen('v3-plugins');
});
scope.querySelectorAll('[data-mg-game]').forEach((card) => {
const id = card.getAttribute('data-mg-game');
card.querySelector('[data-mg-play]')?.addEventListener('click', () => launch(id));
});
}
// The minigames plugin defines its run overlays (#mg-stage / #mg-picker /
// #mg-summary) INSIDE its hub screen container (#plugin-minigames). When a
// game is launched from this v3 screen, that hub screen is the inactive
// `.screen` (display:none), so the fixed overlays — though their own
// `hidden` class is cleared by the SDK — sit under a display:none ancestor
// and never paint. Relocate them to <body>: they're `position:fixed` and
// the SDK only ever reaches them via getElementById, so this is safe and
// idempotent, and a run is now visible no matter which screen is active.
// (The legacy hub still works — fixed overlays render the same at <body>.)
function portalOverlays() {
['mg-stage', 'mg-picker', 'mg-summary'].forEach((id) => {
const el = document.getElementById(id);
if (el && el.parentElement !== document.body) document.body.appendChild(el);
});
}
async function launch(id) {
const s = sdk();
if (!s || typeof s.start !== 'function') { toast('Minigames are still loading — try again in a moment.'); return; }
if (!registeredList().some((g) => g.id === id)) { toast('This minigame is not ready yet. Reload the page if it persists.'); return; }
if (!document.getElementById('mg-stage')) { toast('Minigame stage not ready yet.'); return; }
portalOverlays();
try { await s.start(id); } catch (e) { console.warn('[feedbarcade] launch failed:', e); }
}
// ── Refresh-after-run ──────────────────────────────────────────────────────
// The SDK emits no public run-complete event, but both end() and
// teardownActiveSession() re-add `hidden` to #mg-stage. Observe that and
// refresh on the visible→hidden edge so the header XP and per-game bests
// update in place after a run. refresh() is idempotent + cheap.
const refresh = async function () { await load(); render(); };
function stageVisible(stage) { return !!stage && !stage.classList.contains('hidden'); }
function ensureStageObserver() {
if (_observer) return;
const stage = document.getElementById('mg-stage');
if (!stage) return; // plugin not mounted yet; retry on ready/screen-change
portalOverlays(); // hoist overlays out of the (hidden) hub screen
_stageWasVisible = stageVisible(stage);
_observer = new MutationObserver(() => {
const vis = stageVisible(stage);
if (_stageWasVisible && !vis) {
clearTimeout(_refreshTimer);
_refreshTimer = setTimeout(refresh, 150); // coalesce end() + summary close
}
_stageWasVisible = vis;
});
_observer.observe(stage, { attributes: true, attributeFilter: ['class'] });
}
// ── Public API + boot ──────────────────────────────────────────────────────
window.v3Feedbarcade = { refresh };
async function boot() {
// Refresh progression state before the first render so headerHTML() shows
// the correct rank/dB instead of the Rank 0 / 0 dB cold-load fallback.
if (window.v3Progression && typeof window.v3Progression.refresh === 'function') {
try { await window.v3Progression.refresh(); } catch (e) { /* proceed with cached state */ }
}
await load();
render();
ensureStageObserver();
if (sm && typeof sm.on === 'function') {
sm.on('screen:changed', (e) => {
if (e && e.detail && e.detail.id === SCREEN_ID) { ensureStageObserver(); refresh(); }
});
}
// The minigames plugin publishes its SDK + injects #mg-stage at boot and
// fires this once ready; re-render so late-registered specs appear and
// the stage observer attaches.
window.addEventListener('slopsmith-minigames-ready', () => { ensureStageObserver(); refresh(); });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})();
+891
View File
@@ -0,0 +1,891 @@
<!DOCTYPE html>
<!--
fee[dB]ack v0.3.0 shell (SLOPSMITH_UI=v3 / GET /v3).
This is a re-chromed copy of the legacy static/index.html: the v0.3.0
sidebar + topbar replace the (hidden) legacy navbar, new #v3-* screens are
added, and all the legacy screens (#home library, #favorites, #settings,
#player, #audio, plugin nav containers) are kept verbatim so static/app.js
boots UNMODIFIED and the whole engine — player/highway, plugin loader,
capabilities, audio, library, settings — is reused as-is. Navigation is the
shared window.showScreen across both #v3-* and legacy/#plugin-* screens.
See ~/Repositories/slopsmith-feedback-v030/prompts/12-app-shell.md.
-->
<html lang="en" class="dark scroll-smooth">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0f172a">
<meta name="description" content="fee[dB]ack — practice loop for your guitar library: play along, track accuracy, level up.">
<title>fee[dB]ack</title>
<!-- Splitscreen pop-out windows (`?ssFollower=1`) get driven into follower
mode by the splitscreen plugin AFTER it loads, which is well after
the parser has rendered v3-sidebar / v3-topbar / #player-controls /
the v3 player rail. Without an early flag the popup flashes the
normal v3 chrome for hundreds of ms before the plugin gets a chance
to hide it. Tag the document root before ANY external script parses
and pair it with a style block that suppresses every chrome element
immediately. The splitscreen plugin's own ss-follower body class
takes over once it boots, mirroring this rule set so the two stay
in sync (see the plugin's screen.js chrome-hide CSS).
IMPORTANT: do NOT hide #v3-main here. #player lives *inside* #v3-main
(it's the scroll container that holds every .screen), so a
`display:none` on #v3-main also blanks #player — the one surface we
need visible. app.js's follower bootstrap already activated #player
(app.js:9716), and the plugin's ss-follower rules likewise keep
#player shown and never touch #v3-main; matching that keeps the
empty player chrome painted through the boot window instead of a
blank popup. Hide the chrome *inside* #v3-main (topbar, non-player
screens, rail) individually, leaving #v3-main as a transparent
flex container. -->
<style>
html.ss-follower-pre #v3-sidebar,
html.ss-follower-pre #v3-topbar,
html.ss-follower-pre #v3-railzone,
html.ss-follower-pre [id^="v3-rail-pop-"],
html.ss-follower-pre #player-controls,
html.ss-follower-pre #player-hud,
html.ss-follower-pre #section-map,
html.ss-follower-pre #navbar,
html.ss-follower-pre .screen:not(#player) { display: none !important; }
html.ss-follower-pre body { margin: 0; overflow: hidden; }
</style>
<script>
(function () {
try {
if (new URLSearchParams(location.search).get('ssFollower') === '1') {
document.documentElement.classList.add('ss-follower-pre');
}
} catch (_) { /* file:// or sandboxed iframe */ }
})();
</script>
<!-- fee[dB]ack mark (the [dB] motif). SVG primary, PNG fallback. -->
<link rel="icon" type="image/svg+xml" href="/static/v3/brand/favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="/static/v3/brand/favicon-32.png">
<link rel="apple-touch-icon" href="/static/v3/brand/icon-192.png">
<link rel="manifest" href="/static/v3/manifest.json">
<!-- Tailwind utility classes are served from a prebuilt static
stylesheet (regenerated by scripts/build-tailwind.sh). The old
Play CDN (cdn.tailwindcss.com) JIT scanned the DOM ~1.8x/sec
on the main thread, dropping ~26% of frames with the 3D
highway running — see slopsmith-desktop#110. Theme extensions
(dark/accent/gold colors, Inter font) live in tailwind.config.js. -->
<link rel="stylesheet" href="/static/tailwind.min.css">
<!-- UI font: Rubik (Google Fonts, OFL). Applied via fontFamily.display in tailwind.config.js. -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Rubik:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/vendor/shepherd.css">
<link rel="stylesheet" href="/static/tour-engine.css">
<!-- v0.3.0 shell styles (radial-gradient bg, custom scrollbars). -->
<link rel="stylesheet" href="/static/v3/v3.css">
<!-- Diagnostics console capture must wrap console.* before any other
script logs anything; load it as early as possible. See
docs/diagnostics-bundle-spec.md (slopsmith#166). -->
<script src="/static/diagnostics.js"></script>
<script src="/static/capabilities.js"></script>
<script src="/static/capabilities/library.js"></script>
<script src="/static/capabilities/tuning.js"></script>
<script src="/static/capabilities/audio-session.js"></script>
<script src="/static/capabilities/audio-effects.js"></script>
<script src="/static/capabilities/playback.js"></script>
<!-- fee[dB]ack v0.3.0: ui.library-card-injection capability (plugin card actions). -->
<script src="/static/capabilities/library-card-actions.js"></script>
<script src="/static/capabilities/visualization.js"></script>
<script src="/static/capabilities/note-detection.js"></script>
</head>
<body class="h-screen flex overflow-hidden bg-fb-sidebar text-fb-text font-display">
<!-- fee[dB]ack v0.3.0 sidebar. Built by shell.js (HOME / LIBRARY groups);
the inline brand is the no-JS fallback. -->
<aside id="v3-sidebar" class="w-64 border-r border-fb-border/50 flex-col shrink-0 hidden md:flex">
<div id="v3-brand" class="p-6">
<span class="font-extrabold tracking-tight text-fb-text text-xl">fee<span class="text-fb-primary">[dB]</span>ack</span>
</div>
<nav id="v3-nav" class="flex-1 overflow-y-auto px-3 pb-6 space-y-6" aria-label="Primary"></nav>
</aside>
<!-- Main scroll region: topbar + v0.3.0 screens + (reused) legacy screens. -->
<main id="v3-main" class="flex-1 overflow-y-auto relative">
<!-- Topbar (built by shell.js): secondary nav, search, Support, badge cluster. -->
<header id="v3-topbar"></header>
<!-- New v0.3.0 screens. Empty here; filled by later prompts (dashboard 13,
plugins 19, profile/playlists/saved 15/16). #v3-home is the default. -->
<div id="v3-home" class="screen active"></div>
<div id="v3-songs" class="screen"></div>
<div id="v3-plugins" class="screen"></div>
<div id="v3-profile" class="screen"></div>
<div id="v3-progress" class="screen"></div>
<div id="v3-shop" class="screen"></div>
<div id="v3-playlists" class="screen"></div>
<div id="v3-saved" class="screen"></div>
<div id="v3-feedbarcade" class="screen"></div>
<!-- Lessons: native v3 tutorials screen lands in a later prompt; until
the tutorials plugin is installed this shows a placeholder rather
than dumping to the Plugins page. -->
<div id="v3-lessons" class="screen">
<div class="max-w-3xl mx-auto px-6 md:px-8 pb-8">
<div class="bg-fb-card/80 backdrop-blur rounded-xl p-8 border border-fb-border/50 text-center">
<div class="text-4xl mb-3">🎓</div>
<h3 class="text-xl font-bold text-fb-text mb-1">Lessons</h3>
<p class="text-fb-textDim text-sm">Guided lessons are coming soon. Theyll appear here once the tutorials plugin is installed.</p>
</div>
</div>
</div>
<!-- Legacy top navbar — hidden in v3 (the sidebar/topbar replace it) but kept
in the DOM so app.js's #nav-plugins / #mobile-nav-plugins injection and the
navbar-scroll handler still resolve their elements. -->
<nav id="navbar" class="hidden fixed top-0 w-full z-50 transition-all duration-300">
<div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
<div class="flex items-end gap-1.5">
<a href="#" onclick="showScreen('home');return false" class="text-xl font-bold bg-gradient-to-r from-accent-light to-purple-400 bg-clip-text text-transparent">
Slopsmith
</a>
<span id="app-version" class="text-xs text-gray-600 mb-0.5"></span>
</div>
<div class="hidden md:flex items-center gap-8">
<a href="#" onclick="showScreen('home');return false" class="text-sm text-gray-400 hover:text-white transition">Library</a>
<a href="#" onclick="showScreen('favorites');return false" class="text-sm text-gray-400 hover:text-white transition">Favorites</a>
<a href="#" onclick="document.getElementById('upload-songs-file').click();return false" class="text-sm text-gray-400 hover:text-white transition">Upload</a>
<span id="nav-plugins" class="contents"></span>
<a href="#" onclick="showScreen('settings');return false" class="text-sm text-gray-400 hover:text-white transition">Settings</a>
</div>
<!-- Mobile menu -->
<button onclick="document.getElementById('mobile-menu').classList.toggle('hidden')" class="md:hidden text-gray-400">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
</div>
<div id="mobile-menu" class="hidden md:hidden bg-dark-800/95 backdrop-blur border-t border-gray-800">
<div class="px-6 py-4 flex flex-col gap-3">
<a href="#" onclick="showScreen('home');this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Library</a>
<a href="#" onclick="showScreen('favorites');this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Favorites</a>
<a href="#" onclick="document.getElementById('upload-songs-file').click();this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Upload</a>
<span id="mobile-nav-plugins" class="flex flex-col gap-2 border-t border-b border-gray-800 py-2 my-1">
<span class="text-xs text-gray-600 uppercase tracking-wider">Plugins</span>
</span>
<a href="#" onclick="showScreen('settings');this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Settings</a>
</div>
</div>
</nav>
<!-- Hidden file input shared by the navbar "Upload" link. Kept at body
level so it stays reachable regardless of which screen is active. -->
<input type="file" id="upload-songs-file" accept=".sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
<!-- ══ HOME (Hero + Library) — reused as the v3 "Songs" screen ════════ -->
<div id="home" class="screen">
<!-- Library -->
<section id="library-section" class="max-w-7xl mx-auto px-6 pt-24 pb-16">
<div id="alpha-warning-banner" class="hidden mb-6 px-4 py-3 bg-amber-900/30 border border-amber-500/30 rounded-xl flex items-start gap-3" role="status">
<span class="text-amber-400 text-lg leading-none mt-0.5" aria-hidden="true"></span>
<div class="text-sm text-amber-100">
<strong class="text-amber-300">Heads up — this is an alpha build.</strong>
Some things may be broken or change without warning. If you hit a bug, please file an issue. Thanks for trying it out!
</div>
</div>
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-10">
<div>
<h2 id="lib-title" class="text-3xl font-bold text-white">Your Library</h2>
<p class="text-gray-500 mt-1" id="lib-count"></p>
</div>
<div class="flex gap-3 w-full md:w-auto flex-wrap">
<select id="lib-provider" onchange="setLibraryProvider(this.value)"
aria-label="Library source"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none" title="Library source">
<option value="local">My Library</option>
</select>
<!-- View toggle -->
<div class="flex bg-dark-700 border border-gray-800 rounded-xl overflow-hidden">
<button id="view-grid-btn" onclick="setLibView('grid')" class="px-3 py-2.5 text-sm transition" title="Grid view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
</button>
<button id="view-tree-btn" onclick="setLibView('tree')" class="px-3 py-2.5 text-sm transition" title="Artist/Album view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="3" rx="1"/><rect x="3" y="6" width="12" height="3" rx="1"/><rect x="3" y="11" width="12" height="3" rx="1"/></svg>
</button>
</div>
<!-- Grid controls -->
<select id="lib-sort" onchange="sortLibrary()"
class="lib-grid-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="artist">Artist A-Z</option>
<option value="artist-desc">Artist Z-A</option>
<option value="title">Title A-Z</option>
<option value="title-desc">Title Z-A</option>
<option value="recent">Recently Added</option>
<option value="year-desc">Year (newest)</option>
<option value="year">Year (oldest)</option>
<option value="tuning">Tuning</option>
</select>
<!-- Format filter (shared) -->
<select id="lib-format" onchange="sortLibrary()"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none" title="Filter by format">
<option value="">All formats</option>
<option value="sloppak">Sloppak</option>
<option value="loose">Folder</option>
</select>
<!-- Tree controls -->
<button onclick="toggleAllArtists(true)" class="lib-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Expand All</button>
<button onclick="toggleAllArtists(false)" class="lib-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Collapse All</button>
<!-- Filters drawer toggle (slopsmith#129) -->
<button onclick="toggleLibFilters()" id="btn-lib-filters"
class="bg-dark-700 border border-gray-800 hover:border-accent/40 rounded-xl px-4 py-2.5 text-sm text-gray-300 transition flex items-center gap-2"
title="Filter by parts, tuning, lyrics">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h18M6 12h12M10 20h4"/></svg>
<span>Filters</span>
<span id="lib-filters-count" class="hidden bg-accent/30 text-accent-light text-xs font-semibold rounded-full px-1.5 py-0.5 min-w-[1.25rem] text-center">0</span>
</button>
<!-- Shared -->
<input type="text" id="lib-filter" placeholder="Search songs..." oninput="filterLibrary()"
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 focus:ring-1 focus:ring-accent/30 outline-none flex-1 md:w-60 transition">
</div>
</div>
<!-- Active-filter chip row (only visible when filters are set, slopsmith#129) -->
<div id="lib-filter-chips" class="hidden flex flex-wrap gap-2 mb-5"></div>
<div id="lib-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
<!-- Cards populated by JS -->
</div>
<div id="lib-tree" class="space-y-2 hidden">
<!-- Tree populated by JS -->
</div>
</section>
<!-- ══ Filters drawer (slopsmith#129/#69/#22) ═════════════════════ -->
<div id="lib-filter-overlay" class="fixed inset-0 bg-black/40 z-40 hidden"
onclick="toggleLibFilters(false)"></div>
<aside id="lib-filter-drawer"
class="fixed top-0 right-0 h-full w-full sm:w-96 bg-dark-800 border-l border-gray-800 z-50 transform translate-x-full transition-transform duration-200 overflow-y-auto">
<div class="p-6 space-y-6">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-white">Filters</h3>
<button onclick="toggleLibFilters(false)" class="text-gray-500 hover:text-white" title="Close">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<section>
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">Arrangements</div>
<p class="text-xs text-gray-600 mb-3">Click cycles: any → require → exclude</p>
<div id="filter-arrangements" class="flex flex-wrap gap-2"></div>
</section>
<section id="filter-stems-section">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">Stems <span class="text-gray-600 normal-case font-normal">(sloppak)</span></div>
<p class="text-xs text-gray-600 mb-3">Click cycles: any → require → exclude</p>
<div id="filter-stems" class="flex flex-wrap gap-2"></div>
</section>
<section>
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">Lyrics</div>
<div id="filter-lyrics" class="flex flex-wrap gap-2"></div>
</section>
<section>
<details>
<summary class="cursor-pointer flex items-center justify-between text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">
<span>Tuning</span>
<span id="filter-tunings-summary" class="text-gray-600 normal-case font-normal text-xs">All tunings</span>
</summary>
<div id="filter-tunings" class="mt-3 space-y-1 max-h-64 overflow-y-auto pr-1"></div>
</details>
</section>
<div class="flex items-center justify-between pt-4 border-t border-gray-800">
<button onclick="clearLibFilters()" class="text-sm text-gray-400 hover:text-white transition">Clear all</button>
<button onclick="toggleLibFilters(false)" class="bg-accent hover:bg-accent-light px-4 py-2 rounded-lg text-sm font-medium text-white transition">Done</button>
</div>
</div>
</aside>
</div>
<!-- ══ FAVORITES ════════════════════════════════════════════════════ -->
<div id="favorites" class="screen">
<section class="max-w-7xl mx-auto px-6 pt-24 pb-16">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-10">
<div>
<h2 class="text-3xl font-bold text-white">Favorites</h2>
<p class="text-gray-500 mt-1" id="fav-count"></p>
</div>
<div class="flex gap-3 w-full md:w-auto flex-wrap">
<div class="flex bg-dark-700 border border-gray-800 rounded-xl overflow-hidden">
<button id="fav-view-grid-btn" onclick="setFavView('grid')" class="px-3 py-2.5 text-sm transition" title="Grid view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
</button>
<button id="fav-view-tree-btn" onclick="setFavView('tree')" class="px-3 py-2.5 text-sm transition" title="Artist/Album view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="3" rx="1"/><rect x="3" y="6" width="12" height="3" rx="1"/><rect x="3" y="11" width="12" height="3" rx="1"/></svg>
</button>
</div>
<select id="fav-sort" onchange="sortFavorites()"
class="fav-grid-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="artist">Artist A-Z</option>
<option value="artist-desc">Artist Z-A</option>
<option value="title">Title A-Z</option>
<option value="title-desc">Title Z-A</option>
<option value="recent">Recently Added</option>
<option value="tuning">Tuning</option>
</select>
<button onclick="toggleAllFavoriteArtists(true)" class="fav-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Expand All</button>
<button onclick="toggleAllFavoriteArtists(false)" class="fav-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Collapse All</button>
<input type="text" id="fav-filter" placeholder="Search favorites..." oninput="filterFavorites()"
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 focus:ring-1 focus:ring-accent/30 outline-none flex-1 md:w-60 transition">
</div>
</div>
<div id="fav-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
</div>
<div id="fav-tree" class="space-y-2 hidden">
</div>
</section>
</div>
<!-- ══ Plugin screens injected dynamically by loadPlugins() ══════════ -->
<!-- ══ SETTINGS ═══════════════════════════════════════════════════════ -->
<div id="settings" class="screen">
<div class="max-w-2xl mx-auto px-6 pt-24 pb-16">
<button onclick="showScreen('home')" class="text-gray-500 hover:text-white text-sm mb-6 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> Back
</button>
<h2 class="text-3xl font-bold text-white mb-8">Settings</h2>
<div class="space-y-10">
<!-- App Updates — Velopack auto-update, desktop only. Stays
hidden in the plain web app; setupAppUpdates() unhides
this block when window.slopsmithDesktop.update exists,
and shows a disabled "not available on Linux" fallback
when running on Linux. -->
<div id="app-updates-block" class="hidden border border-gray-800 rounded-xl bg-dark-800/40 p-5">
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">App Updates</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block" for="app-update-channel">Update channel</label>
<select id="app-update-channel"
class="w-full bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="stable">Stable</option>
<option value="rc">Release candidate</option>
<option value="beta">Beta</option>
<option value="alpha">Alpha</option>
</select>
</div>
<div class="flex items-end">
<button id="app-update-check-now"
class="bg-accent hover:bg-accent-light px-4 py-2.5 rounded-xl text-sm font-medium text-white transition disabled:opacity-50">
Check for updates
</button>
</div>
</div>
<p id="app-update-status" class="text-xs text-gray-500 mt-3">Loading updater status…</p>
<p id="app-update-linux-note" class="hidden text-xs text-yellow-300 mt-2">
Auto-update is not available on Linux —
<a href="https://github.com/byrongamatos/slopsmith-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download new versions from GitHub Releases</a>.
</p>
</div>
<!-- ── Core Slopsmith settings ─────────────────────────────── -->
<section>
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">Slopsmith</h3>
<div class="space-y-6">
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Library Folder Path</label>
<div class="flex gap-3">
<input type="text" id="dlc-path" placeholder="/path/to/your/library"
class="flex-1 bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
<button onclick="pickDlcFolder()" id="btn-pick-dlc" class="hidden bg-dark-600 hover:bg-dark-500 px-4 py-2.5 rounded-xl text-sm text-gray-300 transition whitespace-nowrap">📂 Browse</button>
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
</div>
</div>
<div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" id="setting-lefty" onchange="highway.setLefty(this.checked)"
class="rounded border-gray-600 bg-dark-700 text-accent focus:ring-accent/40">
<span class="text-sm text-gray-300">Left-handed <span class="text-gray-500">(invert frets on the note highway)</span></span>
</label>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Default Arrangement</label>
<select id="default-arrangement"
onchange="persistSetting('default_arrangement', this.value)"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="">Most notes (auto)</option>
<option value="Lead">Lead</option>
<option value="Rhythm">Rhythm</option>
<option value="Bass">Bass</option>
</select>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Arrangement Names</label>
<select id="arrangement-naming-mode"
onchange="_onNamingModeChange(this.value)"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="smart">Smart (Lead, Alt. Lead, Rhythm, Bass…)</option>
<option value="legacy">Legacy (Combo, Bass)</option>
</select>
</div>
<div>
<label for="setting-av-offset" class="text-sm font-medium text-gray-400 mb-2 block">
A/V Sync Offset: <span id="setting-av-offset-val">0</span> ms
</label>
<input type="range" id="setting-av-offset" min="-1000" max="1000" step="1" value="0"
oninput="setAvOffsetMs(this.value)"
class="w-full slider-input">
<p class="text-xs text-gray-600 mt-1">Positive = audio plays ahead of visual notes; raise this value to catch the highway up. Adjust live with the [ and ] keys (Shift for ±50 ms). Auto-saves on every change.</p>
</div>
<div>
<label for="setting-live-guitar-tone-source" class="text-sm font-medium text-gray-400 mb-2 block">Live guitar tone source</label>
<select id="setting-live-guitar-tone-source"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="internal">feed[dB]ack internal tone</option>
<option value="external_hardware">External amp / hardware pedalboard</option>
<option value="spark_control_x">Spark LIVE + Spark Control X</option>
</select>
<p class="text-xs text-gray-600 mt-1">Choose External/Spark if your guitar tone comes from hardware like Spark LIVE. feed[dB]ack will still score your playing but won&rsquo;t warn that no internal amp tone is loaded.</p>
</div>
<div>
<label for="demucs-server-url" class="text-sm font-medium text-gray-400 mb-2 block">Demucs Server (for stem separation)</label>
<div class="flex gap-3">
<input type="text" id="demucs-server-url" placeholder="http://192.168.1.100:7865"
class="flex-1 bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
</div>
<p class="text-xs text-gray-600 mt-1">Optional. Run <a href="https://github.com/byrongamatos/slopsmith-demucs-server" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">slopsmith-demucs-server</a> on a machine with a GPU to offload stem splitting and avoid resource exhaustion on the host running Slopsmith.</p>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Library</label>
<div class="flex items-center gap-3">
<button onclick="rescanLibrary()" id="btn-rescan" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Rescan Library</button>
<button onclick="fullRescanLibrary()" id="btn-full-rescan" class="bg-dark-600 hover:bg-red-900/30 px-5 py-2.5 rounded-xl text-sm text-gray-400 transition">Full Rescan</button>
<span id="rescan-status" class="text-xs text-gray-500"></span>
</div>
<p class="text-xs text-gray-600 mt-1">Rescan checks for new songs. Full Rescan clears the cache and re-imports everything.</p>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Backup</label>
<div class="flex items-center gap-3">
<button onclick="exportSettings()" id="btn-export-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Settings</button>
<button onclick="document.getElementById('import-settings-file').click()" id="btn-import-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Import Settings</button>
<input type="file" id="import-settings-file" accept="application/json,.json" class="hidden" onchange="importSettings(this.files[0]); this.value=''">
<span id="backup-status" class="text-xs text-gray-500"></span>
</div>
<p class="text-xs text-gray-600 mt-1">Export bundles server config, browser preferences, and opted-in plugin data into one JSON file. Import overwrites current settings and reloads.</p>
</div>
<!-- ── Diagnostics (slopsmith#166) ────────────────────── -->
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Diagnostics</label>
<div class="grid grid-cols-2 gap-2 mb-3 text-xs text-gray-400">
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-system" checked class="rounded border-gray-600 bg-dark-700 text-accent"> System info</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-hardware" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Hardware (CPU/GPU/RAM)</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-logs" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Server logs (last 5 MB)</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-console" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Browser console + errors</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-plugins" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Plugin diagnostics</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-redact" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Redact paths &amp; song names</label>
</div>
<div class="flex items-center gap-3">
<button onclick="previewDiagnostics()" id="btn-diag-preview" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Preview Bundle</button>
<button onclick="exportDiagnostics()" id="btn-diag-export" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Diagnostics</button>
<span id="diag-status" class="text-xs text-gray-500"></span>
</div>
<p class="text-xs text-gray-600 mt-1">Bundles server logs, hardware info, plugin inventory, and the browser console transcript into one zip for bug reports. Redaction strips DLC paths, song filenames, and IP addresses by default. Attach to GitHub issues; AI agents can parse the included <code>manifest.json</code>.</p>
<div id="diag-preview" class="hidden mt-3 bg-dark-700 border border-gray-800 rounded-xl p-3 text-xs text-gray-400 max-h-96 overflow-auto"></div>
</div>
<div id="settings-status" class="text-sm text-gray-500"></div>
</div>
</section>
<!-- ── Plugin settings ─────────────────────────────────────── -->
<section id="plugin-settings-area" class="hidden">
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">Plugins</h3>
<div class="space-y-6">
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Plugin Updates</label>
<div class="flex items-center gap-3 mb-2">
<button onclick="checkPluginUpdates()" id="btn-check-updates" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Check for Updates</button>
<span id="updates-status" class="text-xs text-gray-500"></span>
</div>
<div id="plugin-updates-list" class="space-y-2"></div>
</div>
<!-- Per-plugin collapsible sections injected here -->
<div id="plugin-settings" class="space-y-3"></div>
</div>
</section>
<!-- ── About / Source / License (AGPL §13 disclosure) ──────── -->
<section>
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">About</h3>
<div class="space-y-2 text-sm text-gray-400">
<div>Slopsmith <span id="app-version-about" class="text-gray-500"></span></div>
<div>Licensed under <a id="about-license-link" href="https://github.com/byrongamatos/slopsmith/blob/main/LICENSE" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">GNU AGPL v3.0</a>.</div>
<div><a id="about-source-link" href="https://github.com/byrongamatos/slopsmith" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">Source code repository</a></div>
<p class="text-xs text-gray-600 mt-2">Slopsmith is free software. You can redistribute it and modify it under the terms of the AGPL. If you run a modified version that interacts with users over a network, you must make the modified source available to those users.</p>
</div>
</section>
</div>
</div>
</div>
<!-- Global audio element (outside player so it's always accessible) -->
<audio id="audio" preload="auto"></audio>
<script>
// Web Audio API fallback for iOS WKWebView which can't play WAV via <audio>
(function() {
var _waCtx = null, _waSource = null, _waStartTime = 0, _waBuffer = null, _waPlaying = false;
// Monotonic load token + the in-flight request, so a newer load()
// supersedes (and aborts) an older one rather than being dropped while
// the previous fetch/decode is still running.
var _waLoadSeq = 0, _waXhr = null;
var audioEl = document.getElementById('audio');
window._webAudioFallback = {
load: function(url, cb) {
if (!url) return;
if (!_waCtx) _waCtx = new (window.AudioContext || window.webkitAudioContext)();
var mySeq = ++_waLoadSeq;
if (_waXhr) { try { _waXhr.abort(); } catch (e) {} }
console.log('[WebAudio] Loading: ' + url);
var xhr = new XMLHttpRequest();
_waXhr = xhr;
xhr.open('GET', url, true);
xhr.responseType = 'arraybuffer';
xhr.onload = function() {
if (mySeq !== _waLoadSeq) return; // a newer load() superseded us
_waCtx.decodeAudioData(xhr.response, function(decoded) {
if (mySeq !== _waLoadSeq) return; // superseded during decode
_waBuffer = decoded;
_waXhr = null;
console.log('[WebAudio] Decoded: ' + decoded.duration.toFixed(1) + 's');
if (cb) cb();
}, function(e) {
if (mySeq === _waLoadSeq) _waXhr = null;
console.error('[WebAudio] Decode error:', e);
});
};
xhr.onerror = function() { if (mySeq === _waLoadSeq) _waXhr = null; };
xhr.send();
},
play: function() {
if (!_waBuffer || !_waCtx) return false;
this.stop();
if (_waCtx.state === 'suspended') _waCtx.resume();
var src = _waCtx.createBufferSource();
_waSource = src;
src.buffer = _waBuffer;
// AudioBufferSourceNode has no preservesPitch equivalent so changing playbackRate here also changes pitch
src.playbackRate.value = audioEl.playbackRate || 1;
src.connect(_waCtx.destination);
// Clear playing state when the buffer ends naturally (stop() also
// clears it). Guard on identity so a stale source ending after a
// new one started can't flip the flag off.
src.onended = function() { if (_waSource === src) _waPlaying = false; };
_waStartTime = _waCtx.currentTime;
src.start(0);
_waPlaying = true;
console.log('[WebAudio] Playing');
return true;
},
stop: function() {
if (_waSource) { try { _waSource.stop(); } catch(e){} _waSource = null; }
_waPlaying = false;
},
getTime: function() {
if (!_waPlaying || !_waCtx) return 0;
return _waCtx.currentTime - _waStartTime;
},
isActive: function() { return _waPlaying; },
isReady: function() { return !!_waBuffer; },
getDuration: function() { return _waBuffer ? _waBuffer.duration : 0; }
};
})();
</script>
<!-- ══ PLAYER ═════════════════════════════════════════════════════════ -->
<!-- v3 player chrome (P22): persistent top HUD + Up-Next pill, hover-reveal
left rail with feature popovers, and an auto-hiding bottom transport.
Every control keeps its legacy element id + global handler — only the
layout/skin changes, so app.js/highway.js drive it unmodified. Behavior
lives in static/v3/player-chrome.js. -->
<div id="player" class="screen">
<canvas id="highway"></canvas>
<div id="v3-venue-mood-fx" class="venue-mood-fx hidden" aria-hidden="true">
<div class="venue-mood-lights" aria-hidden="true"></div>
<div class="venue-mood-crowd" aria-hidden="true"></div>
<div class="venue-mood-haze" aria-hidden="true"></div>
</div>
<div id="v3-venue-scene-wash" class="v3-venue-scene-wash hidden" aria-hidden="true"></div>
<div id="v3-venue-mode-badge" class="v3-venue-mode-badge hidden" aria-live="polite">
<span class="v3-venue-mode-badge-label">Venue mode — 3D scene assets coming next</span>
</div>
<!-- Top HUD — persistent (song info, time, Up Next) -->
<div id="player-hud" class="absolute top-0 left-0 right-0 flex justify-between items-start px-5 py-4 pointer-events-none z-20">
<div class="text-sm leading-tight">
<div><span id="hud-artist" class="text-gray-300"></span><span id="hud-title" class="text-white font-semibold"></span></div>
<div id="hud-arrangement" class="text-gray-500 text-xs mt-0.5"></div>
<div id="hud-tuning" class="text-gray-500 text-xs mt-0.5"></div>
<div id="hud-tuning-targets" class="text-gray-500 text-xs mt-0.5"></div>
</div>
<div class="flex flex-col items-end gap-2">
<div class="text-right">
<div id="hud-time" class="text-sm text-gray-400 tabular-nums"></div>
<div id="hud-avoffset" class="text-xs text-gray-500 tabular-nums hidden" title="A/V offset — [ and ] to adjust, Shift for ±50 ms">A/V 0 ms</div>
</div>
<div id="v3-live-performance-hud" class="v3-live-performance-hud hidden is-idle" aria-live="polite" aria-atomic="true" aria-label="Live performance">
<div class="v3-live-performance-heading">
<span class="v3-live-performance-tag">LIVE</span>
<span id="v3-live-performance-percent" class="v3-live-performance-percent">&mdash;</span>
</div>
<div id="v3-live-performance-hits" class="v3-live-performance-hits">Waiting for notes</div>
<div id="v3-live-performance-streak" class="v3-live-performance-streak">Streak 0</div>
<div id="v3-live-performance-state" class="v3-live-performance-state" aria-hidden="true"></div>
</div>
<div id="v3-upnext" class="v3-upnext hidden">
<span class="text-gray-400">Up Next:</span>
<span id="v3-upnext-name" class="v3-upnext-name"></span>
<span id="v3-upnext-eta" class="text-gray-400 text-xs"></span>
</div>
</div>
</div>
<!-- Left rail (hover-revealed) + feature popovers -->
<div id="v3-railzone" class="v3-railzone">
<nav id="v3-player-rail" class="v3-rail" aria-label="Player tools">
<button class="v3-rail-icon" type="button" data-rail="viz" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-viz" title="Visualization & quality" aria-label="Visualization & quality">
<span class="v3-rail-border"></span>
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M19,3H5C3.89,3 3,3.89 3,5V19C3,20.11 3.89,21 5,21H19C20.11,21 21,20.11 21,19V5C21,3.89 20.11,3 19,3M12,5A2,2 0 0,1 14,7A2,2 0 0,1 12,9A2,2 0 0,1 10,7A2,2 0 0,1 12,5M7,5A2,2 0 0,1 9,7A2,2 0 0,1 7,9A2,2 0 0,1 5,7A2,2 0 0,1 7,5M17,5A2,2 0 0,1 19,7A2,2 0 0,1 17,9A2,2 0 0,1 15,7A2,2 0 0,1 17,5M12,18A3,3 0 0,1 9,15A3,3 0 0,1 12,12A3,3 0 0,1 15,15A3,3 0 0,1 12,18Z"/></svg>
</button>
<button class="v3-rail-icon" type="button" data-rail="audio" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-audio" title="Audio routing" aria-label="Audio routing">
<span class="v3-rail-border"></span>
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M16,7V3H14V7H10V3H8V7H8C7,7 6,8 6,9V14.5L9.5,18V21H14.5V18L18,14.5V9C18,8 17,7 16,7Z"/></svg>
</button>
<button class="v3-rail-icon" type="button" data-rail="mixer" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-mixer" title="Mixer" aria-label="Mixer">
<span class="v3-rail-border"></span>
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M5,3H19V5H5V3M5,7H19V9H5V7M5,11H19V13H5V11M5,15H19V17H5V15M5,19H19V21H5V19Z"/></svg>
</button>
<button class="v3-rail-icon" type="button" data-rail-action="lyrics" aria-pressed="false" title="Toggle lyrics" aria-label="Toggle lyrics">
<span class="v3-rail-border"></span>
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z"/></svg>
</button>
<button class="v3-rail-icon" type="button" data-rail="plugins" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-plugins" title="Plugin controls" aria-label="Plugin controls">
<span class="v3-rail-border"></span>
<span class="v3-rail-badge" id="v3-plugin-count" hidden></span>
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M20.5,11H19V7C19,5.89 18.1,5 17,5H13V3.5A2.5,2.5 0 0,0 10.5,1A2.5,2.5 0 0,0 8,3.5V5H4A2,2 0 0,0 2,7V10.8H3.5C5,10.8 6.2,12 6.2,13.5C6.2,15 5,16.2 3.5,16.2H2V20A2,2 0 0,0 4,22H7.8V20.5C7.8,19 9,17.8 10.5,17.8C12,17.8 13.2,19 13.2,20.5V22H17A2,2 0 0,0 19,20V16H20.5A2.5,2.5 0 0,0 23,13.5A2.5,2.5 0 0,0 20.5,11Z"/></svg>
</button>
<span class="v3-rail-dot" aria-hidden="true"></span>
<button class="v3-rail-icon" type="button" data-rail="advanced" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-advanced" title="Advanced settings" aria-label="Advanced settings">
<span class="v3-rail-border v3-rail-border-gear"></span>
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11.03L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.47,5.34 14.87,5.09L14.49,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.51,2.42L9.13,5.09C8.53,5.34 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11.03C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.53,18.66 9.13,18.91L9.51,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.49,21.58L14.87,18.91C15.47,18.66 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/></svg>
</button>
</nav>
<!-- Plugin-controls slot: the host re-homes any controls a plugin
injects into #player-controls (the auto-hiding transport) into
this stable, always-reachable popover. See player-chrome.js
(rehoming MutationObserver). -->
<div id="v3-rail-pop-plugins" class="v3-rail-pop hidden" role="group" aria-label="Plugin controls">
<div class="v3-pop-label">Plugin controls</div>
<div id="v3-plugin-controls-slot" class="v3-plugin-slot"></div>
<div id="v3-plugin-slot-empty" class="v3-pop-empty">No plugin controls for this song.</div>
</div>
<div id="v3-rail-pop-viz" class="v3-rail-pop hidden" role="group" aria-label="Visualization &amp; quality">
<div class="v3-pop-row">
<span class="v3-pop-label" id="viz-picker-label">Visualization</span>
<select id="viz-picker" onchange="setViz(this.value)" class="v3-pop-select" aria-labelledby="viz-picker-label" title="Visualization">
<option value="auto">Auto (match arrangement)</option>
<option value="default">Classic 2D Highway</option>
<!-- Additional entries populated on load from /api/plugins (slopsmith#36).
The bundled 3D Highway plugin (plugins/highway_3d/) registers as
`highway_3d` and is the default on fresh installs — see
_populateVizPicker() in app.js. -->
</select>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label">Quality</span>
<select id="quality-select" onchange="highway.setRenderScale(parseFloat(this.value))" class="v3-pop-select">
<option value="1">HD</option>
<option value="0.75">Medium</option>
<option value="0.5">Low</option>
</select>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label" id="venue-motion-label">Venue Motion</span>
<select id="venue-motion-select" class="v3-pop-select" aria-labelledby="venue-motion-label" title="Venue Motion">
<option value="off">Off</option>
<option value="subtle" selected>Subtle</option>
<option value="full">Full</option>
</select>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label" id="venue-mood-fx-label">Venue Mood FX</span>
<select id="venue-mood-fx-select" class="v3-pop-select" aria-labelledby="venue-mood-fx-label" title="Venue Mood FX">
<option value="off">Off</option>
<option value="subtle" selected>Subtle</option>
<option value="full">Full</option>
</select>
</div>
<p id="venue-viz-mode-hint" class="hidden text-xs text-cyan-300/90 px-1 pb-1 leading-snug">Venue uses 3D Highway with a small-club stage scene behind the fretboard.</p>
<p id="venue-viz-load-failed-hint" class="hidden text-xs text-amber-400/90 px-1 pb-1 leading-snug">Venue scene assets could not load — playing as plain 3D Highway. Check static/assets/venue/themes/small-club/.</p>
<p id="venue-mood-fx-3d-hint" class="hidden text-xs text-amber-400/90 px-1 pb-1 leading-snug">Use Venue for the reactive crowd/stage version.</p>
<p class="text-xs text-gray-500 px-1 pb-1 leading-snug">Venue Motion adds subtle background parallax only — the note highway stays fixed. Mood FX controls performance tint overlays.</p>
</div>
<div id="v3-rail-pop-audio" class="v3-rail-pop hidden" role="group" aria-label="Audio routing">
<div id="v3-rail-audio-routing"></div>
<div class="v3-pop-row mt-2">
<span class="v3-pop-label">Guitar tone</span>
<select id="player-live-guitar-tone-source"
class="v3-pop-select max-w-[210px]">
<option value="internal">feed[dB]ack internal tone</option>
<option value="external_hardware">External amp / hardware pedalboard</option>
<option value="spark_control_x">Spark LIVE + Spark Control X</option>
</select>
</div>
<p class="text-xs text-gray-500 px-1 pb-1 leading-snug">External/Spark: hardware supplies tone; no internal amp warning.</p>
</div>
<div id="v3-rail-pop-mixer" class="v3-rail-pop hidden" role="group" aria-label="Mixer">
<div id="mixer-control">
<div id="mixer-anchor" class="relative">
<button id="btn-mixer" type="button" onclick="window.slopsmith.audio.toggleMixer()" class="v3-pop-btn" aria-haspopup="true" aria-expanded="false" aria-controls="mixer-popover" title="Audio mixer">Mixer ▾</button>
<div id="mixer-popover" class="hidden absolute left-0 top-full mt-2 z-50 bg-dark-700 border border-gray-800 rounded-xl shadow-xl" role="group" aria-label="Audio mixer"></div>
</div>
</div>
</div>
<div id="v3-rail-pop-advanced" class="v3-rail-pop hidden" role="group" aria-label="Advanced settings">
<div class="v3-pop-row">
<span class="v3-pop-label">Arrangement</span>
<span class="flex items-center gap-1">
<select id="arr-select" onchange="changeArrangement(this.value)" class="v3-pop-select max-w-[130px]"></select>
<button id="arr-default-pin" type="button" onclick="pinCurrentArrangementDefault()" aria-pressed="false" aria-label="Select an arrangement to make it the default" class="v3-pop-pin" title="Select an arrangement to make it the default"></button>
</span>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label" id="mastery-slider-label">Difficulty</span>
<span class="flex items-center gap-2">
<input type="range" id="mastery-slider" min="0" max="100" value="100" step="5" oninput="setMastery(this.value)" class="accent-accent slider-input" title="Master difficulty — low = simpler chart, high = full" aria-labelledby="mastery-slider-label">
<span id="mastery-label" class="v3-pop-val">100%</span>
</span>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label" id="player-av-offset-slider-label">A/V sync (ms)</span>
<span class="flex items-center gap-2">
<input type="range" id="player-av-offset-slider" min="-1000" max="1000" value="0" step="1" oninput="setAvOffsetMs(this.value)" class="accent-accent slider-input" title="A/V sync offset (ms) — positive = audio plays ahead of visuals. [ and ] adjust ±10 ms (Shift = ±50). Double-click to reset." ondblclick="setAvOffsetMs(0)" aria-labelledby="player-av-offset-slider-label">
<span id="player-av-offset-label" class="v3-pop-val tabular-nums">+0ms</span>
</span>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label">Loop</span>
<span class="flex items-center gap-1 flex-wrap">
<button onclick="setLoopStart()" id="btn-loop-a" class="v3-pop-btn" title="Set loop start at current time">A</button>
<button onclick="setLoopEnd()" id="btn-loop-b" class="v3-pop-btn" title="Set loop end at current time">B</button>
<button onclick="saveCurrentLoop()" id="btn-loop-save" class="v3-pop-btn hidden" title="Save this loop">Save</button>
<button onclick="clearLoop()" id="btn-loop-clear" class="v3-pop-btn hidden" title="Clear loop"></button>
<span id="loop-label" class="text-xs text-gray-500"></span>
</span>
</div>
<div class="v3-pop-row">
<select id="saved-loops" onchange="loadSavedLoop(this.value)" class="v3-pop-select max-w-[160px] hidden">
<option value="">Saved Loops</option>
</select>
<button onclick="deleteSelectedLoop()" id="btn-loop-delete" class="v3-pop-btn hidden" title="Delete selected loop"></button>
</div>
<button onclick="showScreen('home')" class="v3-pop-close" title="Close player">✕ Close player</button>
</div>
</div>
<!-- Bottom transport — auto-hides on idle, reveals on mouse-move.
Children tagged data-v3-native are the host's own controls; the
plugin-rehoming shim (player-chrome.js) relocates any OTHER child
(a plugin's injected control) into the Plugins rail popover. -->
<div id="player-controls" class="v3-transport">
<!-- Canonical lyrics toggle: hidden here (not in the rail zone) so a
legacy plugin that inserts next to #btn-lyrics (e.g. lyrics_karaoke)
lands in #player-controls, where the shim re-homes it. Hidden via
inline style because highway.setOnLyricsChange() rewrites its
className on every toggle (a `hidden` class would be stripped). -->
<button onclick="highway.toggleLyrics()" id="btn-lyrics" data-v3-native style="display:none" aria-hidden="true" tabindex="-1">Lyrics ✓</button>
<div class="v3-speed-cluster" data-v3-native>
<div class="v3-speed">
<span class="v3-speed-bars" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span>
<input type="range" id="speed-slider" min="15" max="150" value="100" step="5" oninput="setSpeed(this.value/100)" class="v3-speed-slider accent-accent" aria-label="Playback speed">
<span id="speed-label" class="v3-speed-label">1.0x</span>
</div>
<div id="speed-presets" class="v3-speed-presets" role="group" aria-label="Practice speed presets" title="Speed preset buttons">
<button type="button" class="v3-speed-preset-btn v3-speed-preset-active" data-speed-preset="100">100</button>
<button type="button" class="v3-speed-preset-btn" data-speed-preset="90">90</button>
<button type="button" class="v3-speed-preset-btn" data-speed-preset="80" data-sweet-spot="1">80</button>
<button type="button" class="v3-speed-preset-btn" data-speed-preset="75" data-sweet-spot="1">75</button>
<button type="button" class="v3-speed-preset-btn" data-speed-preset="70" data-sweet-spot="1">70</button>
<button type="button" class="v3-speed-preset-btn" data-speed-preset="60">60</button>
<button type="button" class="v3-speed-preset-btn" data-speed-preset="50">50</button>
</div>
</div>
<div class="v3-transport-mid" data-v3-native>
<button onclick="seekBy(-5)" class="v3-seek" title="Seek Back 5s" aria-label="Seek Back 5s"><img src="/static/svg/rw.svg" class="button-icon-svg" alt="" aria-hidden="true" /></button>
<button type="button" onclick="restartCurrentSong()" class="v3-seek" title="Restart song" aria-label="Restart song"></button>
<button onclick="togglePlay()" id="btn-play" class="v3-play" aria-label="Play" title="Play" aria-pressed="false"><img src="/static/svg/play.svg" class="button-icon-svg" alt="" aria-hidden="true" /></button>
<button onclick="seekBy(5)" class="v3-seek" title="Seek Forward 5s" aria-label="Seek Forward 5s"><img src="/static/svg/ff.svg" class="button-icon-svg" alt="" aria-hidden="true" /></button>
</div>
<span class="v3-chevrons" data-v3-native aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></span>
</div>
</div>
</main>
<!-- /#v3-main -->
<script src="/static/highway.js"></script>
<script src="/static/vendor/lottie.min.js"></script>
<script src="/static/lottie-api.js"></script>
<script src="/static/app.js"></script>
<script src="/static/audio-mixer.js"></script>
<script src="/static/vendor/shepherd.min.js"></script>
<script src="/static/tour-engine.js"></script>
<!-- fee[dB]ack v0.3.0 shell: brand helper, then the shell (sidebar/topbar/
routing). Loaded after app.js/audio-mixer so window.showScreen and
window.slopsmith(.audio) exist; dashboard.js is filled in prompt 13. -->
<script src="/static/v3/brand.js"></script>
<script src="/static/v3/shell.js"></script>
<!-- Progression (spec 010): theme-core before profile.js so the equipped
theme/avatar frame apply with the first badge render; progression-core
registers the `progression` capability owner + window.v3Progression. -->
<script src="/static/v3/theme-core.js"></script>
<script src="/static/v3/progression-core.js"></script>
<script src="/static/v3/profile.js"></script>
<script src="/static/v3/progress.js"></script>
<script src="/static/v3/shop.js"></script>
<script src="/static/v3/tuner-core.js"></script>
<script src="/static/v3/badges.js"></script>
<script src="/static/v3/stats-recorder.js"></script>
<script src="/static/v3/live-performance-hud.js"></script>
<script src="/static/v3/venue-viz.js"></script>
<script src="/static/v3/venue-instrument-pov.js"></script>
<!-- venue-mood-fx must load before venue-scene-3d: the scene bridge reads
window.v3VenueMoodFx.getMotion() synchronously at boot when the saved
viz is 'venue'; loading it after falls back to 'subtle' and ignores a
saved 'off'/'full' motion preference on first paint. -->
<script src="/static/v3/venue-mood-fx.js"></script>
<script src="/static/v3/venue-scene-3d.js"></script>
<script src="/static/v3/playlists.js"></script>
<script src="/static/v3/audio-routing.js"></script>
<script src="/static/v3/live-guitar-tone-source.js"></script>
<script src="/static/v3/pedal-cables.js"></script>
<script src="/static/v3/plugins-page.js"></script>
<script src="/static/v3/card-actions-core.js"></script>
<script src="/static/v3/songs.js"></script>
<script src="/static/v3/lessons.js"></script>
<script src="/static/v3/dashboard.js"></script>
<script src="/static/v3/feedbarcade.js"></script>
<script src="/static/v3/player-chrome.js"></script>
<script>
// Navbar scroll effect
window.addEventListener('scroll', () => {
const nav = document.getElementById('navbar');
if (window.scrollY > 50) {
nav.classList.add('bg-dark-900/80', 'backdrop-blur-lg', 'border-b', 'border-gray-800/50');
} else {
nav.classList.remove('bg-dark-900/80', 'backdrop-blur-lg', 'border-b', 'border-gray-800/50');
}
});
</script>
</body>
</html>
+269
View File
@@ -0,0 +1,269 @@
/*
* fee[dB]ack v0.3.0 — Lessons (#v3-lessons).
*
* An fb-styled catalog over the external `tutorials` plugin (P25). It lists
* lesson packs + lessons with progress and the unified level/XP/streak, and
* the Start/Continue action deep-links into the plugin's OWN lesson view
* (`plugin-tutorials`) which keeps the video + playSong + run/XP submission.
* We do NOT fork lesson logic or add a second XP curve — completing a lesson
* in the plugin posts to /api/plugins/minigames/runs → the unified core XP
* store (P15), the same level the profile badge reads.
*
* Capability note (design/05 §0/§5): UI placement + plugin nav/screen are a
* DEFERRED capability domain — consume /api/plugins + the plugin REST + the
* legacy globals (navigate/showScreen), NOT capability dispatch. Every fetch
* degrades gracefully (plugin absent / 404 / empty) and never blocks first
* paint. Vanilla JS, fb-* tokens (constitution P-II).
*/
(function () {
'use strict';
const sm = window.slopsmith;
const PLUGIN_ID = 'tutorials';
const API = '/api/plugins/' + PLUGIN_ID;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
const jget = async (u) => { try { const r = await fetch(u); return r.ok ? r.json() : null; } catch (e) { return null; } };
// View state survives re-renders so returning from a plugin lesson lands
// back on the pack the user was viewing.
const state = { view: { kind: 'catalog' }, progress: null };
// ── progress helpers ─────────────────────────────────────────────────---
function lessonState(packId, lessonId) {
const packs = (state.progress && state.progress.packs) || {};
const pack = packs[packId] || {};
const lessons = pack.lessons || {};
return lessons[lessonId] || null;
}
function packPassedCount(pack) {
const lessons = (pack && pack.lessons) || [];
return lessons.reduce((n, l) => {
const st = lessonState(pack.id, l.id);
return n + (st && st.passed ? 1 : 0);
}, 0);
}
// The "next" lesson to Start/Continue: first not-passed, else the first.
function nextLesson(pack) {
const lessons = (pack && pack.lessons) || [];
if (!lessons.length) return null;
return lessons.find((l) => { const st = lessonState(pack.id, l.id); return !(st && st.passed); }) || lessons[0];
}
// ── small view pieces ────────────────────────────────────────────────---
function techChips(techs, max) {
const arr = Array.isArray(techs) ? techs : [];
const shown = arr.slice(0, max || 6);
const extra = arr.length - shown.length;
let html = shown.map((t) =>
'<span class="text-[10px] uppercase tracking-wider text-fb-textDim bg-black/30 border border-fb-border/50 rounded px-1.5 py-0.5">' + esc(t) + '</span>').join('');
if (extra > 0) html += '<span class="text-[10px] text-fb-textDim">+' + extra + '</span>';
return '<div class="flex flex-wrap gap-1">' + html + '</div>';
}
function progressBar(passed, total) {
const pct = total > 0 ? Math.round((passed / total) * 100) : 0;
return '<div class="flex items-center gap-2">' +
'<div class="flex-1 h-1.5 rounded-full bg-black/40 overflow-hidden">' +
'<span class="block h-full bg-fb-primary" style="width:' + pct + '%"></span></div>' +
'<span class="text-xs text-fb-textDim whitespace-nowrap">' + passed + '/' + total + '</span></div>';
}
// Per-lesson status: mastered ⭐, passed ✓, else best-accuracy ramp / "—".
function lessonStatus(packId, lessonId) {
const st = lessonState(packId, lessonId);
if (st && st.mastered) return '<span class="text-fb-gold text-xs font-bold flex items-center gap-1">★ Mastered</span>';
if (st && st.passed) return '<span class="text-fb-good text-xs font-bold flex items-center gap-1">✓ Passed</span>';
if (st && st.best_accuracy != null && st.best_accuracy > 0) {
const acc = st.best_accuracy;
const pct = Math.round(acc * 100);
const color = acc >= 0.9 ? 'text-fb-good' : (acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low');
return '<span class="' + color + ' text-xs font-bold">' + pct + '%</span>';
}
return '<span class="text-fb-textDim text-xs">Not started</span>';
}
// Unified rank/dB/streak strip (progression spec 010 — replaces the old
// level/XP meter; lesson completions still earn dB via the unified store).
function headerXp(prog) {
const p = prog || { current_streak: 0, best_streak: 0 };
const progression = (window.v3Progression && window.v3Progression.get()) || null;
const rank = progression ? progression.mastery_rank : 0;
const wallet = (progression && progression.wallet) || { balance: 0 };
return '<div class="bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 mb-6">' +
'<div class="flex items-center justify-between gap-4 text-sm text-fb-textDim">' +
'<span class="text-fb-text font-semibold">Rank ' + rank + '</span>' +
'<span class="text-fb-gold font-semibold">' + Number(wallet.balance || 0).toLocaleString() + ' dB</span>' +
'<span class="text-fb-accent">🔥 ' + (p.current_streak || 0) + '-day streak</span>' +
'<span class="hidden sm:inline">Best: ' + (p.best_streak || 0) + '</span></div></div>';
}
function pageWrap(inner) {
return '<div class="max-w-5xl mx-auto px-6 md:px-8 pb-8">' + inner + '</div>';
}
function emptyState(title, body, ctaLabel, ctaScreen) {
return pageWrap(
'<h2 class="text-3xl font-bold text-fb-text mb-6">Lessons</h2>' +
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-8 border border-fb-border/50 text-center">' +
'<div class="text-4xl mb-3">🎓</div>' +
'<h3 class="text-xl font-bold text-fb-text mb-1">' + esc(title) + '</h3>' +
'<p class="text-fb-textDim text-sm">' + esc(body) + '</p>' +
(ctaLabel ? '<button data-go="' + esc(ctaScreen) + '" class="mt-4 bg-fb-primary hover:bg-fb-primaryHi text-white px-5 py-2 rounded-md font-medium shadow-lg shadow-fb-primary/20">' + esc(ctaLabel) + '</button>' : '') +
'</div>');
}
// ── catalog view ─────────────────────────────────────────────────────---
async function renderCatalog(root, prog) {
// /packs returns { packs: [...] }; tolerate a bare array too.
const res = await jget(API + '/packs');
const packs = res && Array.isArray(res.packs) ? res.packs : (Array.isArray(res) ? res : null);
if (!packs) {
root.innerHTML = emptyState('Lessons arent installed yet',
'The Tutorials plugin isnt active. Enable it from the Plugins page to unlock guided lessons.',
'Open Plugins', 'v3-plugins');
wireGo(root);
return;
}
if (!packs.length) {
root.innerHTML = pageWrap(
'<h2 class="text-3xl font-bold text-fb-text mb-6">Lessons</h2>' + headerXp(prog) +
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-8 border border-fb-border/50 text-center text-fb-textDim text-sm">No lesson packs installed yet.</div>');
return;
}
const cards = packs.map((pk) => {
const total = pk.lesson_count || 0;
// Pack summaries don't carry per-lesson ids, so progress count uses
// the progress store keyed by this pack (passed lessons we know of).
const ps = (state.progress && state.progress.packs && state.progress.packs[pk.id]) || {};
const passed = ps.lessons ? Object.values(ps.lessons).filter((l) => l && l.passed).length : 0;
const cover = pk.cover_url
? '<img src="' + esc(pk.cover_url) + '" alt="" class="w-full h-full object-cover" onerror="this.style.visibility=\'hidden\'">'
: '<div class="w-full h-full flex items-center justify-center text-4xl text-fb-textDim/40">🎸</div>';
const cont = passed > 0 && passed < total;
return '<div class="group bg-fb-card/80 backdrop-blur rounded-xl border border-fb-border/50 overflow-hidden flex flex-col">' +
'<button data-pack="' + esc(pk.id) + '" class="relative block aspect-video bg-fb-cardMuted overflow-hidden text-left">' +
cover + '<div class="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent"></div></button>' +
'<div class="p-4 flex flex-col flex-1 gap-3">' +
'<button data-pack="' + esc(pk.id) + '" class="text-left">' +
'<div class="text-fb-text font-bold truncate">' + esc(pk.title) + '</div>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(pk.author || '') + ' · ' + total + ' lesson' + (total === 1 ? '' : 's') + '</div></button>' +
techChips(pk.techniques, 5) +
progressBar(passed, total) +
'<div class="flex gap-2 mt-auto pt-1">' +
'<button data-start-pack="' + esc(pk.id) + '" class="flex-1 bg-fb-primary hover:bg-fb-primaryHi text-white text-sm px-4 py-2 rounded-md font-medium shadow-lg shadow-fb-primary/20">' + (cont ? 'Continue' : 'Start') + '</button>' +
'<button data-pack="' + esc(pk.id) + '" class="bg-transparent border border-fb-textDim hover:border-white text-white text-sm px-4 py-2 rounded-md">View</button>' +
'</div></div></div>';
}).join('');
root.innerHTML = pageWrap(
'<h2 class="text-3xl font-bold text-fb-text mb-6">Lessons</h2>' + headerXp(prog) +
'<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">' + cards + '</div>');
root.querySelectorAll('[data-pack]').forEach((b) =>
b.addEventListener('click', () => openPack(b.getAttribute('data-pack'))));
root.querySelectorAll('[data-start-pack]').forEach((b) =>
b.addEventListener('click', () => startPackById(b.getAttribute('data-start-pack'))));
}
// ── pack detail view ─────────────────────────────────────────────────---
async function renderPack(root, prog, packId) {
const pack = await jget(API + '/packs/' + enc(packId));
if (!pack || !Array.isArray(pack.lessons)) {
// Pack vanished (uninstalled / bad id) — fall back to the catalog.
state.view = { kind: 'catalog' };
return renderCatalog(root, prog);
}
const lessons = pack.lessons;
const passed = packPassedCount(pack);
const cover = pack.cover_url
? '<img src="' + esc(pack.cover_url) + '" alt="" class="w-full h-full object-cover" onerror="this.style.visibility=\'hidden\'">'
: '<div class="w-full h-full flex items-center justify-center text-4xl text-fb-textDim/40">🎸</div>';
const next = nextLesson(pack);
const contLabel = passed > 0 && passed < lessons.length ? 'Continue' : 'Start';
const rows = lessons.map((l, i) => {
const thumb = l.thumb_url
? '<img src="' + esc(l.thumb_url) + '" alt="" class="w-full h-full object-cover" onerror="this.style.visibility=\'hidden\'">'
: '<div class="w-full h-full flex items-center justify-center text-fb-textDim/40">' + (i + 1) + '</div>';
const st = lessonState(packId, l.id);
const label = st && st.passed ? 'Replay' : 'Start';
return '<div class="flex items-center gap-4 bg-fb-card/60 border border-fb-border/50 rounded-lg p-3">' +
'<div class="w-20 h-12 rounded bg-fb-cardMuted overflow-hidden flex-shrink-0 text-xs">' + thumb + '</div>' +
'<div class="flex-1 min-w-0">' +
'<div class="text-fb-text font-medium truncate">' + esc(l.title || ('Lesson ' + (i + 1))) + '</div>' +
'<div class="mt-1">' + techChips(l.techniques, 5) + '</div></div>' +
'<div class="flex-shrink-0 mr-2">' + lessonStatus(packId, l.id) + '</div>' +
'<button data-lesson="' + esc(l.id) + '" class="flex-shrink-0 bg-fb-primary hover:bg-fb-primaryHi text-white text-sm px-4 py-2 rounded-md font-medium">' + label + '</button>' +
'</div>';
}).join('');
root.innerHTML = pageWrap(
'<button data-back class="text-sm text-fb-textDim hover:text-fb-text mb-4 flex items-center gap-1">&larr; All lessons</button>' +
headerXp(prog) +
'<div class="bg-fb-card/80 backdrop-blur rounded-xl border border-fb-border/50 overflow-hidden mb-6">' +
'<div class="relative aspect-[3/1] bg-fb-cardMuted overflow-hidden">' + cover +
'<div class="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent"></div>' +
'<div class="absolute bottom-0 left-0 p-5">' +
'<h2 class="text-3xl font-bold text-fb-text">' + esc(pack.title) + '</h2>' +
'<div class="text-sm text-fb-textDim">' + esc(pack.author || '') + '</div></div></div>' +
'<div class="p-5 space-y-3">' + techChips(pack.techniques, 12) +
progressBar(passed, lessons.length) +
(next ? '<button data-lesson="' + esc(next.id) + '" class="bg-fb-primary hover:bg-fb-primaryHi text-white px-6 py-2 rounded-md font-medium shadow-lg shadow-fb-primary/20">' + contLabel + ' pack</button>' : '') +
'</div></div>' +
'<div class="space-y-3">' + rows + '</div>');
root.querySelector('[data-back]')?.addEventListener('click', () => { state.view = { kind: 'catalog' }; render(); });
root.querySelectorAll('[data-lesson]').forEach((b) =>
b.addEventListener('click', () => launchLesson(packId, b.getAttribute('data-lesson'))));
}
// ── navigation into the plugin's own lesson view ─────────────────────---
function launchLesson(packId, lessonId) {
if (!packId || !lessonId) return;
// navigate() stores nav params AND calls showScreen(); the tutorials
// plugin's init() reads getNavParams() to deep-link to {packId, lessonId}.
if (sm && typeof sm.navigate === 'function') {
sm.navigate('plugin-tutorials', { packId: packId, lessonId: lessonId });
} else if (typeof window.showScreen === 'function') {
// Fallback when navigate() is unavailable: stash the same nav params
// navigate() would set so the tutorials plugin can still deep-link
// instead of landing on the generic browse screen.
if (sm) sm._navParams = { packId: packId, lessonId: lessonId };
window.showScreen('plugin-tutorials');
}
}
async function startPackById(packId) {
// Fetch the manifest so we can resolve the pack's next lesson, then launch.
const pack = await jget(API + '/packs/' + enc(packId));
const next = pack && Array.isArray(pack.lessons) ? nextLesson(pack) : null;
if (next) launchLesson(packId, next.id);
else openPack(packId);
}
function openPack(packId) { state.view = { kind: 'pack', packId: packId }; render(); }
function wireGo(root) {
root.querySelectorAll('[data-go]').forEach((b) =>
b.addEventListener('click', () => window.showScreen && window.showScreen(b.getAttribute('data-go'))));
}
// ── entry ────────────────────────────────────────────────────────────---
async function render() {
const root = document.getElementById('v3-lessons');
if (!root) return;
// Refresh unified progress (level/XP/streak) + per-lesson progress every
// render so XP earned in the plugin shows when the user returns.
const [prog, tut] = await Promise.all([
jget('/api/profile/progress'),
jget(API + '/progress'),
]);
state.progress = tut || { packs: {} };
if (state.view.kind === 'pack') await renderPack(root, prog, state.view.packId);
else await renderCatalog(root, prog);
}
window.v3Lessons = { render: render };
if (sm && typeof sm.on === 'function') {
sm.on('screen:changed', (e) => { if (e && e.detail && e.detail.id === 'v3-lessons') render(); });
sm.on('v3:profile-updated', () => { if (document.getElementById('v3-lessons')?.classList.contains('active')) render(); });
// Refresh the header rank/dB strip when progression state changes while
// the screen is already visible (e.g. after a minigame run on this screen).
sm.on('progression:updated', () => { if (document.getElementById('v3-lessons')?.classList.contains('active')) render(); });
}
})();
+103
View File
@@ -0,0 +1,103 @@
/*
* fee[dB]ack v0.3.0 — live guitar tone source preference.
*
* Lets users who get guitar tone from external hardware (Spark LIVE, pedalboards,
* etc.) opt out of the desktop "no internal amp loaded" monitor-mute hint.
* Persisted in localStorage; desktop renderer reads the same key directly.
*/
(function (root) {
'use strict';
const KEY = 'slopsmith-live-guitar-tone-source';
const SOURCES = Object.freeze({
INTERNAL: 'internal',
EXTERNAL_HARDWARE: 'external_hardware',
SPARK_CONTROL_X: 'spark_control_x',
});
const DEFAULT = SOURCES.INTERNAL;
const LABELS = Object.freeze({
[SOURCES.INTERNAL]: 'feed[dB]ack internal tone',
[SOURCES.EXTERNAL_HARDWARE]: 'External amp / hardware pedalboard',
[SOURCES.SPARK_CONTROL_X]: 'Spark LIVE + Spark Control X',
});
const HELP_TEXT =
'Choose External/Spark if your guitar tone comes from hardware like Spark LIVE. '
+ 'feed[dB]ack will still score your playing but won\u2019t warn that no internal amp tone is loaded.';
function normalize(value) {
if (value === SOURCES.EXTERNAL_HARDWARE || value === SOURCES.SPARK_CONTROL_X) return value;
return DEFAULT;
}
function get() {
try { return normalize(localStorage.getItem(KEY)); } catch (_) { return DEFAULT; }
}
function set(value) {
const next = normalize(value);
try { localStorage.setItem(KEY, next); } catch (_) { /* private mode / quota */ }
syncSelects(next);
return next;
}
function shouldSuppressMonitorMuteHint(source) {
const s = normalize(source == null ? get() : source);
return s === SOURCES.EXTERNAL_HARDWARE || s === SOURCES.SPARK_CONTROL_X;
}
function syncSelects(value) {
// Exported + required in tests; guard DOM access so set()/init() can be
// called in a non-browser environment without throwing.
if (typeof document === 'undefined') return;
const v = normalize(value == null ? get() : value);
document.querySelectorAll('[data-live-guitar-tone-source]').forEach((el) => {
if (el && el.value !== v) el.value = v;
});
}
function bindSelect(el) {
if (!el || el.dataset.liveGuitarToneBound === '1') return;
el.dataset.liveGuitarToneBound = '1';
el.setAttribute('data-live-guitar-tone-source', '1');
el.value = get();
el.addEventListener('change', () => { set(el.value); });
}
function init() {
if (typeof document === 'undefined') return;
bindSelect(document.getElementById('setting-live-guitar-tone-source'));
bindSelect(document.getElementById('player-live-guitar-tone-source'));
syncSelects();
}
const api = {
KEY,
SOURCES,
DEFAULT,
LABELS,
HELP_TEXT,
get,
set,
normalize,
shouldSuppressMonitorMuteHint,
init,
};
if (root) root.v3LiveGuitarToneSource = api;
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}
}(typeof window !== 'undefined' ? window : null));
+252
View File
@@ -0,0 +1,252 @@
/*
* fee[dB]ack v0.3.0 — live performance HUD (read-only overlay).
*
* Mirrors stats-recorder tallies from note:hit / note:miss events without
* writing back to scoring. Visual state thresholds are UI-only.
*/
(function (root) {
'use strict';
const STATE_CLASSES = ['is-idle', 'is-fire', 'is-strong', 'is-steady', 'is-recovery', 'is-smoke'];
const STATE_META = Object.freeze({
idle: { label: 'Waiting for notes', icon: '' },
fire: { label: 'Hot streak', icon: '\uD83D\uDD25' },
strong: { label: 'Locked in', icon: '\u2728' },
steady: { label: 'Solid run', icon: '\uD83D\uDC4D' },
recovery: { label: 'Recovering', icon: '\uD83D\uDCAA' },
smoke: { label: 'Shake it off', icon: '\uD83D\uDCA8' },
});
function accuracyPct(hits, misses) {
const judged = hits + misses;
if (judged <= 0) return null;
return Math.round((hits / Math.max(1, judged)) * 100);
}
function calculateLivePerformanceState({ hits = 0, misses = 0, streak = 0, bestStreak = 0 } = {}) {
const h = Math.max(0, Number(hits) || 0);
const m = Math.max(0, Number(misses) || 0);
const s = Math.max(0, Number(streak) || 0);
const best = Math.max(0, Number(bestStreak) || 0);
const judged = h + m;
const pct = accuracyPct(h, m);
let state = 'idle';
if (judged === 0) {
state = 'idle';
} else if (pct >= 90 && s >= 10) {
state = 'fire';
} else if (pct >= 85) {
state = 'strong';
} else if (pct >= 70) {
state = 'steady';
} else if (pct >= 50) {
state = 'recovery';
} else {
state = 'smoke';
}
const meta = STATE_META[state] || STATE_META.idle;
return {
hits: h,
misses: m,
streak: s,
bestStreak: best,
judged,
accuracyPct: pct,
state,
stateLabel: meta.label,
stateIcon: meta.icon,
};
}
function formatPercentText(stats) {
if (!stats || stats.judged === 0 || stats.accuracyPct == null) return '\u2014';
return String(stats.accuracyPct) + '%';
}
function formatHitsText(stats) {
if (!stats || stats.judged === 0) return 'Waiting for notes';
return 'Hits ' + stats.hits + ' / ' + stats.judged;
}
function formatStreakText(stats) {
if (!stats) return 'Streak 0';
return 'Streak ' + stats.streak;
}
function formatStateText(stats) {
if (!stats || stats.judged === 0) return '';
const icon = stats.stateIcon ? stats.stateIcon + ' ' : '';
return icon + stats.stateLabel;
}
function applyHudClasses(el, state) {
if (!el || !el.classList) return;
STATE_CLASSES.forEach((cls) => el.classList.remove(cls));
if (state) el.classList.add('is-' + state);
}
function renderHudDom(els, stats) {
if (!els) return stats;
const s = stats || calculateLivePerformanceState();
if (els.root) applyHudClasses(els.root, s.state);
if (els.percent) els.percent.textContent = formatPercentText(s);
if (els.hits) els.hits.textContent = formatHitsText(s);
if (els.streak) els.streak.textContent = formatStreakText(s);
if (els.state) {
els.state.textContent = formatStateText(s);
if (els.state.setAttribute) {
els.state.setAttribute('aria-hidden', s.judged === 0 ? 'true' : 'false');
}
}
return s;
}
function createCounters() {
return { hits: 0, misses: 0, streak: 0, bestStreak: 0 };
}
function bindRuntime(sm, domEls) {
if (!sm || typeof sm.on !== 'function') return null;
let active = false;
// Stay hidden until the first judged note actually arrives. note:hit /
// note:miss are emitted only by the notedetect plugin, so users without
// detection (or with it off) would otherwise see a permanent
// "Waiting for notes" overlay for the whole song.
let revealed = false;
let counters = createCounters();
const els = domEls || {
root: typeof document !== 'undefined' ? document.getElementById('v3-live-performance-hud') : null,
percent: typeof document !== 'undefined' ? document.getElementById('v3-live-performance-percent') : null,
hits: typeof document !== 'undefined' ? document.getElementById('v3-live-performance-hits') : null,
streak: typeof document !== 'undefined' ? document.getElementById('v3-live-performance-streak') : null,
state: typeof document !== 'undefined' ? document.getElementById('v3-live-performance-state') : null,
};
function setVisible(show) {
if (!els.root) return;
if (show) els.root.classList.remove('hidden');
else els.root.classList.add('hidden');
}
function resetCounters() {
counters = createCounters();
}
function currentStats() {
return calculateLivePerformanceState(counters);
}
function paint() {
const stats = renderHudDom(els, currentStats());
try {
if (sm && typeof sm.emit === 'function') {
sm.emit('v3:live-performance-state', {
hits: stats.hits,
misses: stats.misses,
judged: stats.judged,
streak: stats.streak,
bestStreak: stats.bestStreak,
accuracyPct: stats.accuracyPct,
state: stats.state,
});
}
} catch (_) { /* venue mood and other observers must never break HUD */ }
}
function reveal() {
if (!active || revealed) return;
revealed = true;
setVisible(true);
}
function showSession() {
active = true;
revealed = false;
resetCounters();
// Primed but hidden — reveal() on the first note:hit/note:miss.
setVisible(false);
paint();
}
function hideSession() {
active = false;
revealed = false;
resetCounters();
setVisible(false);
paint();
}
function onHit() {
if (!active) return;
reveal();
counters.hits++;
counters.streak++;
if (counters.streak > counters.bestStreak) counters.bestStreak = counters.streak;
paint();
}
function onMiss() {
if (!active) return;
reveal();
counters.misses++;
counters.streak = 0;
paint();
}
sm.on('song:loading', () => { showSession(); });
sm.on('song:arrangement-changed', () => {
if (!active) return;
resetCounters();
paint();
});
sm.on('song:stop', () => { hideSession(); });
sm.on('song:ended', () => { hideSession(); });
sm.on('note:hit', onHit);
sm.on('note:miss', onMiss);
return {
getCounters: () => ({ ...counters }),
getStats: currentStats,
isActive: () => active,
showSession,
hideSession,
onHit,
onMiss,
paint,
els,
};
}
const api = {
STATE_CLASSES,
STATE_META,
accuracyPct,
calculateLivePerformanceState,
formatPercentText,
formatHitsText,
formatStreakText,
formatStateText,
applyHudClasses,
renderHudDom,
bindRuntime,
};
if (root) root.v3LivePerformanceHud = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof document !== 'undefined') {
const boot = () => {
const sm = root && root.slopsmith;
if (sm) bindRuntime(sm);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}
}(typeof window !== 'undefined' ? window : null));
+14
View File
@@ -0,0 +1,14 @@
{
"name": "fee[dB]ack",
"short_name": "feedback",
"description": "Practice loop for your guitar library — play along, track accuracy, level up.",
"start_url": "/v3",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"icons": [
{ "src": "/static/v3/brand/favicon.svg", "type": "image/svg+xml", "sizes": "any", "purpose": "any" },
{ "src": "/static/v3/brand/icon-192.png", "type": "image/png", "sizes": "192x192", "purpose": "any maskable" },
{ "src": "/static/v3/brand/icon-512.png", "type": "image/png", "sizes": "512x512", "purpose": "any maskable" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+334
View File
@@ -0,0 +1,334 @@
/*
* fee[dB]ack v0.3.0 — Pedalboard patch cables (decorative).
*
* Draws purely cosmetic 1/4" patch cables between the pedals on each board of
* the v3 Plugins page (#v3-plugins). Cables carry NO routing/audio meaning —
* they're eye candy. A light Verlet "rope" makes each cable sag under gravity
* and swing slightly, settling on load and reacting to drags / resize / scroll.
*
* Constraints honoured:
* - The SVG overlay is `pointer-events:none` so it never blocks pedal clicks.
* - One shared requestAnimationFrame loop; it runs ONLY while #v3-plugins is the
* active screen and stops when the screen is hidden (hooked off the same
* `screen:changed` event plugins-page uses).
* - `prefers-reduced-motion: reduce` → static (sagged but not swinging) cables,
* no rAF. A live drag still gets a one-shot redraw via refresh().
* - Cheap: a handful of segments per cable, anchors recomputed from
* getBoundingClientRect() each active frame so cables track moved pedals.
*/
(function () {
'use strict';
var SEGMENTS = 12; // points per seeded cable (used by the pure helpers/tests)
var MAX_CABLES = 200; // hard cap so a pathological board can't explode
var CABLE_SAG = 0.06; // dip of the cable curve as a fraction of its span (small = rigid)
var SCREEN_ID = 'v3-plugins';
// Distance from the socket (plug tip) to the boot end where the cable
// actually attaches — matches makePlug()'s boot outer edge. The rope is
// pinned here (NOT at the socket), so the cable connects to the END of the
// jack. Pedal spacing (GAP_X in plugins-page.js) must exceed 2×this so two
// facing plugs leave room for visible cable between them.
var PLUG_BOOT = 44;
// Cable anchors sit at this fraction of the pedal's height (the side jacks on
// the pedal photos measure ~57.5% down), so they stay aligned at any size.
var JACK_FRAC = 0.575;
var sm = window.slopsmith;
// ---- pure geometry helpers (exported for tests) -----------------------
// Jacks sit on the pedal's SIDE faces like a real stompbox: output on the
// RIGHT edge, input on the LEFT edge, both at the pedal's vertical centre —
// so a cable runs side-to-side into the next pedal. Board-relative so the
// overlay viewBox lines up regardless of page scroll. The small default
// inset tucks the plug tip just inside the edge socket (.v3-pedal-jack in
// v3.css straddles the side edge). A single `inset` keeps the symmetric
// behaviour the unit test relies on.
function computeJacks(pedalRect, boardRect, inset) {
var i = inset == null ? 1 : inset;
var midY = (pedalRect.top - boardRect.top) + JACK_FRAC * (pedalRect.bottom - pedalRect.top);
return {
out: { x: pedalRect.right - boardRect.left - i, y: midY }, // right side
in: { x: pedalRect.left - boardRect.left + i, y: midY }, // left side
};
}
function dist(a, b) { var dx = b.x - a.x, dy = b.y - a.y; return Math.sqrt(dx * dx + dy * dy); }
// Initial straight-line seeding of a cable's points between a and b.
function seedPoints(a, b, segments) {
var n = Math.max(2, segments | 0);
var pts = [];
for (var i = 0; i < n; i++) {
var t = i / (n - 1);
var x = a.x + (b.x - a.x) * t;
var y = a.y + (b.y - a.y) * t;
pts.push({ x: x, y: y, px: x, py: y });
}
return pts;
}
// Build an SVG path string through a list of {x,y} points (smooth-ish).
function pointsToPath(pts) {
if (!pts || pts.length < 2) return '';
var d = 'M ' + pts[0].x.toFixed(1) + ' ' + pts[0].y.toFixed(1);
for (var i = 1; i < pts.length; i++) {
d += ' L ' + pts[i].x.toFixed(1) + ' ' + pts[i].y.toFixed(1);
}
return d;
}
// Static sagged cable (reduced-motion / no-physics): a quadratic with its
// control point pushed below the midpoint by `sag` (scaled to span).
function staticCablePath(a, b, sag) {
var mx = (a.x + b.x) / 2;
var my = (a.y + b.y) / 2;
var span = dist(a, b);
var drop = (sag == null ? CABLE_SAG : sag) * span + 4;
return 'M ' + a.x.toFixed(1) + ' ' + a.y.toFixed(1) +
' Q ' + mx.toFixed(1) + ' ' + (my + drop).toFixed(1) +
' ' + b.x.toFixed(1) + ' ' + b.y.toFixed(1);
}
var SVG_NS = 'http://www.w3.org/2000/svg';
function reducedMotion() {
try { return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); }
catch (e) { return false; }
}
// ---- 1/4" plug graphics (vector, drawn at each cable end) --------------
function _rect(x, y, w, h, r, fill) {
var e = document.createElementNS(SVG_NS, 'rect');
e.setAttribute('x', x); e.setAttribute('y', y);
e.setAttribute('width', w); e.setAttribute('height', h);
if (r) { e.setAttribute('rx', r); e.setAttribute('ry', r); }
e.setAttribute('fill', fill);
return e;
}
// Chrome (cross-axis sheen) + boot gradients, one set per board svg so the
// url(#id) references stay unique across overlays.
function makeDefs(idx) {
var defs = document.createElementNS(SVG_NS, 'defs');
function grad(id, stops) {
var g = document.createElementNS(SVG_NS, 'linearGradient');
g.setAttribute('id', id);
g.setAttribute('x1', '0'); g.setAttribute('y1', '0');
g.setAttribute('x2', '0'); g.setAttribute('y2', '1'); // across the barrel
stops.forEach(function (s) {
var st = document.createElementNS(SVG_NS, 'stop');
st.setAttribute('offset', s[0]); st.setAttribute('stop-color', s[1]);
g.appendChild(st);
});
defs.appendChild(g);
}
grad('v3plug-chrome-' + idx, [
['0', '#f8fafc'], ['0.22', '#cbd5e1'], ['0.5', '#5b6b7b'],
['0.78', '#cbd5e1'], ['1', '#3a4756'],
]);
grad('v3plug-boot-' + idx, [['0', '#3b4252'], ['0.5', '#1c2230'], ['1', '#0a0d13']]);
return defs;
}
// A plug pointing along +x with its metal tip at the origin (the socket).
// boot (cable strain-relief) → barrel → insulator ring → tip sleeve → cap.
function makePlug(idx) {
var g = document.createElementNS(SVG_NS, 'g');
g.setAttribute('class', 'v3-cable-plug');
var chrome = 'url(#v3plug-chrome-' + idx + ')';
// Chunkier plug sized to match the pedal photo's 1/4" jack barrels.
g.appendChild(_rect(26, -11, 18, 22, 6, 'url(#v3plug-boot-' + idx + ')')); // boot
g.appendChild(_rect(6, -9, 22, 18, 3, chrome)); // barrel
g.appendChild(_rect(16, -9, 2, 18, 0, 'rgba(0,0,0,.35)')); // barrel rib
g.appendChild(_rect(3, -10, 4, 20, 1.5, '#0a0d13')); // insulator ring
g.appendChild(_rect(-7, -6, 11, 12, 5, chrome)); // tip sleeve
var cap = document.createElementNS(SVG_NS, 'circle');
cap.setAttribute('cx', -6); cap.setAttribute('cy', 0); cap.setAttribute('r', 5.5);
cap.setAttribute('fill', chrome);
g.appendChild(cap);
return g;
}
// Orient a plug group so its tip sits at `tip` and points toward `toward`.
function placePlug(g, tip, toward) {
if (!g) return;
var ang = Math.atan2(toward.y - tip.y, toward.x - tip.x) * 180 / Math.PI;
g.setAttribute('transform',
'translate(' + tip.x.toFixed(1) + ',' + tip.y.toFixed(1) + ') rotate(' + ang.toFixed(1) + ')');
}
// ---- state ------------------------------------------------------------
var boards = []; // [{ el, svg, cables:[{a,b,pts,el,restLen}] }]
var rafId = 0;
var active = false; // is #v3-plugins the current screen?
var dragForce = false; // a drag is in progress → run even under RM
function cancelLoop() { if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } }
// Recompute the two pinned endpoints of every cable from current layout.
function repinAnchors() {
for (var bi = 0; bi < boards.length; bi++) {
var b = boards[bi];
var brect = b.el.getBoundingClientRect();
// Size overlay to the board's full scrollable area.
var w = b.el.scrollWidth, h = b.el.scrollHeight;
b.svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
b.svg.setAttribute('width', w);
b.svg.setAttribute('height', h);
for (var ci = 0; ci < b.cables.length; ci++) {
var c = b.cables[ci];
var ra = c.fromEl.getBoundingClientRect();
var rb = c.toEl.getBoundingClientRect();
c.a = computeJacks(ra, brect).out; // output socket (right side)
c.b = computeJacks(rb, brect).in; // input socket (left side)
// Rope attaches at each plug's BOOT end, not the socket: output
// plug extends +x, input plug extends -x.
c.ra = { x: c.a.x + PLUG_BOOT, y: c.a.y };
c.rb = { x: c.b.x - PLUG_BOOT, y: c.b.y };
}
}
}
// The plugs are rigid: the output jack (right side) always points straight
// out to the right, the input jack (left side) straight out to the left —
// they do NOT swing with the cable. Only the rope between them dips.
function placeRigidPlugs(c) {
placePlug(c.plugA, c.a, { x: c.a.x + 1, y: c.a.y }); // output → +x
placePlug(c.plugB, c.b, { x: c.b.x - 1, y: c.b.y }); // input → -x
}
// Draw every cable as a curve computed DIRECTLY from its current endpoints —
// no Verlet, no inertia, no momentum. The shape is a pure function of where
// the pedals are right now, so it tracks a dragged pedal exactly (1:1) with
// zero float / slow-motion drift. A tiny gravity-biased dip gives it a
// natural hang without any springiness.
function drawAll() {
for (var bi = 0; bi < boards.length; bi++) {
var cs = boards[bi].cables;
for (var ci = 0; ci < cs.length; ci++) {
var c = cs[ci];
c.el.setAttribute('d', staticCablePath(c.ra, c.rb));
placeRigidPlugs(c);
}
}
}
// Single render pass: refresh anchors from the live layout, then draw.
function render() {
if (!boards.length) return;
repinAnchors();
drawAll();
}
// During a drag we run a short rAF loop purely so the cable re-reads the
// pedal's getBoundingClientRect every frame and stays glued to it; outside a
// drag there's nothing to animate, so we render on demand only (no idle
// loop, no physics to settle → nothing to look like the moon).
function tick() {
rafId = 0;
if (!dragForce) { render(); return; } // final frame, then stop
render();
rafId = requestAnimationFrame(tick);
}
function start() { if (boards.length) render(); }
function stop() { cancelLoop(); }
function refresh() { if (boards.length) render(); }
// Called by plugins-page around a pedal drag.
function setDragging(on) {
dragForce = !!on;
if (dragForce) { if (!rafId) rafId = requestAnimationFrame(tick); }
else render();
}
// (Re)build overlays + cable lists for the current board DOM. Called by
// plugins-page after every render() (which rebuilds the board markup).
function attach(rootEl) {
cancelLoop();
boards = [];
if (!rootEl) return;
var boardEls = rootEl.querySelectorAll('.v3-pedalboard');
var total = 0;
for (var bi = 0; bi < boardEls.length; bi++) {
var el = boardEls[bi];
var svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('class', 'v3-cable-layer');
svg.setAttribute('aria-hidden', 'true');
svg.appendChild(makeDefs(bi));
el.insertBefore(svg, el.firstChild);
var pedals = el.querySelectorAll('.v3-pedal');
var cables = [];
for (var pi = 0; pi + 1 < pedals.length && total < MAX_CABLES; pi++) {
var path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('class', 'v3-cable');
svg.appendChild(path);
// Plug graphics go on TOP of the path so the cable reads as
// entering the boot.
var plugA = makePlug(bi), plugB = makePlug(bi);
svg.appendChild(plugA); svg.appendChild(plugB);
cables.push({
fromEl: pedals[pi], toEl: pedals[pi + 1],
a: { x: 0, y: 0 }, b: { x: 0, y: 0 },
ra: { x: 0, y: 0 }, rb: { x: 0, y: 0 },
el: path, plugA: plugA, plugB: plugB,
});
total++;
}
boards.push({ el: el, svg: svg, cables: cables });
}
render();
}
function destroy() { cancelLoop(); boards = []; }
// ---- wiring -----------------------------------------------------------
// Pause/resume with the screen. screen:changed fires with the NEW screen id
// on every navigation, so this covers both enter and leave.
if (sm && typeof sm.on === 'function') {
sm.on('screen:changed', function (e) {
var id = e && e.detail && e.detail.id;
if (id === SCREEN_ID) { active = true; start(); }
else { active = false; stop(); }
});
}
// Recompute on viewport changes (throttled via rAF-coalescing in refresh()).
var pending = false;
function onLayout() {
if (!active && !dragForce) return;
if (pending) return;
pending = true;
requestAnimationFrame(function () { pending = false; refresh(); });
}
window.addEventListener('resize', onLayout, { passive: true });
// Scroll can change board rects relative to viewport; getBoundingClientRect
// already accounts for it, but a moved pedal mid-scroll wants a redraw.
window.addEventListener('scroll', onLayout, { passive: true, capture: true });
window.v3PedalCables = {
attach: attach,
refresh: refresh,
setDragging: setDragging,
start: start,
stop: stop,
destroy: destroy,
// Mark active without waiting for a screen:changed (plugins-page calls
// this when it renders while already on the plugins screen at boot).
markActive: function (on) { active = !!on; if (active) start(); else stop(); },
// Pure helpers exposed for unit tests.
_test: {
computeJacks: computeJacks,
seedPoints: seedPoints,
pointsToPath: pointsToPath,
staticCablePath: staticCablePath,
dist: dist,
SEGMENTS: SEGMENTS,
MAX_CABLES: MAX_CABLES, JACK_FRAC: JACK_FRAC,
},
};
})();
+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Plugin pedal">
<defs>
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#334155"/>
<stop offset="0.55" stop-color="#1e293b"/>
<stop offset="1" stop-color="#0f172a"/>
</linearGradient>
<radialGradient id="knob" cx="0.35" cy="0.3" r="0.8">
<stop offset="0" stop-color="#cbd5e1"/>
<stop offset="0.5" stop-color="#64748b"/>
<stop offset="1" stop-color="#1e293b"/>
</radialGradient>
</defs>
<rect x="28" y="20" width="200" height="216" rx="18" fill="url(#body)" stroke="#0b1220" stroke-width="3"/>
<rect x="44" y="36" width="168" height="74" rx="10" fill="#0b1220" stroke="#334155" stroke-width="2"/>
<!-- three knobs -->
<g>
<circle cx="78" cy="146" r="20" fill="url(#knob)" stroke="#0b1220" stroke-width="2"/>
<line x1="78" y1="146" x2="78" y2="130" stroke="#e2e8f0" stroke-width="3" stroke-linecap="round"/>
<circle cx="128" cy="146" r="20" fill="url(#knob)" stroke="#0b1220" stroke-width="2"/>
<line x1="128" y1="146" x2="140" y2="135" stroke="#e2e8f0" stroke-width="3" stroke-linecap="round"/>
<circle cx="178" cy="146" r="20" fill="url(#knob)" stroke="#0b1220" stroke-width="2"/>
<line x1="178" y1="146" x2="166" y2="135" stroke="#e2e8f0" stroke-width="3" stroke-linecap="round"/>
</g>
<!-- LED -->
<circle cx="128" cy="74" r="7" fill="#22d3ee"/>
<!-- footswitch -->
<rect x="98" y="186" width="60" height="22" rx="11" fill="#94a3b8" stroke="#0b1220" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+84
View File
@@ -0,0 +1,84 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 460" role="img" aria-label="Pedal enclosure">
<defs>
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#3b4859"/>
<stop offset="0.18" stop-color="#2c3949"/>
<stop offset="0.55" stop-color="#222d3b"/>
<stop offset="1" stop-color="#161f2b"/>
</linearGradient>
<linearGradient id="bodyEdge" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#566476"/>
<stop offset="1" stop-color="#0c1118"/>
</linearGradient>
<radialGradient id="chrome" cx="0.4" cy="0.3" r="0.85">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="0.35" stop-color="#cbd5e1"/>
<stop offset="0.7" stop-color="#6b7787"/>
<stop offset="1" stop-color="#2b3543"/>
</radialGradient>
<radialGradient id="knob" cx="0.5" cy="0.32" r="0.85">
<stop offset="0" stop-color="#5b6779"/>
<stop offset="0.45" stop-color="#39434f"/>
<stop offset="0.8" stop-color="#1a212b"/>
<stop offset="1" stop-color="#0b0f15"/>
</radialGradient>
<radialGradient id="foot" cx="0.5" cy="0.34" r="0.8">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="0.2" stop-color="#e2e8f0"/>
<stop offset="0.5" stop-color="#aab6c4"/>
<stop offset="0.78" stop-color="#6b7787"/>
<stop offset="1" stop-color="#39434f"/>
</radialGradient>
<radialGradient id="screw" cx="0.4" cy="0.35" r="0.8">
<stop offset="0" stop-color="#e6ebf1"/>
<stop offset="0.55" stop-color="#8a97a6"/>
<stop offset="1" stop-color="#2b3543"/>
</radialGradient>
</defs>
<!-- enclosure body -->
<rect x="14" y="8" width="272" height="444" rx="30" fill="url(#bodyEdge)"/>
<rect x="17" y="11" width="266" height="438" rx="28" fill="url(#body)" stroke="#0a0e14" stroke-width="1.5"/>
<!-- top edge sheen -->
<rect x="26" y="18" width="248" height="420" rx="22" fill="none" stroke="rgba(255,255,255,.05)" stroke-width="2"/>
<!-- corner screws -->
<g stroke="#0a0e14" stroke-width="1">
<g><circle cx="42" cy="40" r="7" fill="url(#screw)"/><line x1="38" y1="40" x2="46" y2="40" stroke="#1a212b" stroke-width="1.4"/></g>
<g><circle cx="258" cy="40" r="7" fill="url(#screw)"/><line x1="254" y1="40" x2="262" y2="40" stroke="#1a212b" stroke-width="1.4"/></g>
<g><circle cx="42" cy="420" r="7" fill="url(#screw)"/><line x1="38" y1="420" x2="46" y2="420" stroke="#1a212b" stroke-width="1.4"/></g>
<g><circle cx="258" cy="420" r="7" fill="url(#screw)"/><line x1="254" y1="420" x2="262" y2="420" stroke="#1a212b" stroke-width="1.4"/></g>
</g>
<!-- knob row (top) -->
<g>
<g transform="translate(78,62)">
<circle r="22" fill="url(#knob)" stroke="#0a0e14" stroke-width="1.5"/>
<circle r="22" fill="none" stroke="rgba(90,100,115,.5)" stroke-width="1"/>
<rect x="-1.6" y="-19" width="3.2" height="10" rx="1.6" fill="#e2e8f0" transform="rotate(-50)"/>
</g>
<g transform="translate(150,62)">
<circle r="22" fill="url(#knob)" stroke="#0a0e14" stroke-width="1.5"/>
<circle r="22" fill="none" stroke="rgba(90,100,115,.5)" stroke-width="1"/>
<rect x="-1.6" y="-19" width="3.2" height="10" rx="1.6" fill="#e2e8f0" transform="rotate(12)"/>
</g>
<g transform="translate(222,62)">
<circle r="22" fill="url(#knob)" stroke="#0a0e14" stroke-width="1.5"/>
<circle r="22" fill="none" stroke="rgba(90,100,115,.5)" stroke-width="1"/>
<rect x="-1.6" y="-19" width="3.2" height="10" rx="1.6" fill="#e2e8f0" transform="rotate(58)"/>
</g>
</g>
<!-- side jack nuts (cable plugs from pedal-cables.js insert here) -->
<g>
<rect x="6" y="218" width="16" height="24" rx="3" fill="url(#chrome)" stroke="#0a0e14" stroke-width="1"/>
<rect x="278" y="218" width="16" height="24" rx="3" fill="url(#chrome)" stroke="#0a0e14" stroke-width="1"/>
</g>
<!-- footswitch (bottom) -->
<g transform="translate(150,392)">
<circle r="34" fill="url(#chrome)" stroke="#0a0e14" stroke-width="1.5"/>
<circle r="27" fill="#11161e"/>
<circle r="24" fill="url(#foot)" stroke="#0a0e14" stroke-width="1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

+288
View File
@@ -0,0 +1,288 @@
/*
* fee[dB]ack v0.3.0 — P22 player chrome.
*
* Drives the v3 player overlay (static/v3/index.html #player): the Up-Next
* section pill, the hover-reveal left icon rail + its feature popovers, the
* auto-hiding bottom transport, and the speed-level visual (bars + chevrons).
*
* Design contract: the actual controls are the SAME legacy elements/handlers
* (ids unchanged), just relocated into rail popovers — so app.js/highway.js
* keep populating and reacting to them unmodified. This module only adds
* presentation behavior (open/close, reveal/hide, mirror state). It runs only
* while #player is the active screen.
*/
(function () {
'use strict';
const $ = (id) => document.getElementById(id);
const player = () => $('player');
const now = () => (window.performance && performance.now ? performance.now() : (window.__pcNow = (window.__pcNow || 0) + 16));
const IDLE_MS = 2500; // transport hides after this much pointer stillness
const UPNEXT_MS = 160; // throttle the Up-Next recompute (~6 Hz)
let rafId = null, running = false;
let lastMove = 0, lastUpNext = 0;
let openPop = null; // { btn, pop }
// ── v3 UI signal + plugin-control slot API ───────────────────────────────
// Lets plugins detect v3 (window.slopsmith.uiVersion === 'v3') and mount
// controls into a stable slot instead of the auto-hiding transport.
window.slopsmith = window.slopsmith || {};
window.slopsmith.uiVersion = 'v3';
window.slopsmith.ui = window.slopsmith.ui || {};
window.slopsmith.ui.version = 'v3';
window.slopsmith.ui.playerControlSlot = function () { return $('v3-plugin-controls-slot'); };
// ── Plugin-control re-homing shim ────────────────────────────────────────
// Legacy plugins inject controls into #player-controls (now an auto-hiding
// minimal transport, often via a now-deleted separator anchor). Move any
// non-native child into the always-reachable Plugins rail popover. Moving a
// node preserves its identity + listeners, so the plugin's own toggle logic
// keeps working.
function updatePluginSlotState() {
const slot = $('v3-plugin-controls-slot');
if (!slot) return;
// Count only controls that are actually shown — plugins inject hidden
// gear/settings panels (display:none / .hidden) too, which shouldn't
// inflate the badge.
const n = Array.prototype.filter.call(slot.children, (el) =>
el.nodeType === 1 &&
!el.hasAttribute('hidden') &&
!(el.classList && el.classList.contains('hidden')) &&
(el.style ? el.style.display !== 'none' : true)).length;
const empty = $('v3-plugin-slot-empty');
if (empty) empty.style.display = n ? 'none' : '';
const badge = $('v3-plugin-count');
if (badge) {
if (n) { badge.textContent = String(n); badge.hidden = false; }
else { badge.hidden = true; }
}
}
function rehomePluginControls() {
const bar = $('player-controls');
const slot = $('v3-plugin-controls-slot');
if (!bar || !slot) return;
// Snapshot children first — appendChild mutates the live list.
Array.prototype.slice.call(bar.children).forEach((el) => {
if (el.nodeType !== 1 || el.hasAttribute('data-v3-native')) return;
slot.appendChild(el); // move (identity + listeners preserved)
});
updatePluginSlotState();
}
function installRehomeObserver() {
const bar = $('player-controls');
if (!bar || bar.dataset.pcRehome) return;
bar.dataset.pcRehome = '1';
rehomePluginControls(); // initial sweep
// Only addedNodes matter; our own moves are removals from the bar and
// never re-trigger a move, so there's no loop.
try {
new MutationObserver((muts) => {
if (muts.some((m) => m.addedNodes && m.addedNodes.length)) rehomePluginControls();
}).observe(bar, { childList: true });
} catch (e) { /* MutationObserver always present in target browsers */ }
// Also watch the slot itself: v3-aware plugins mount controls straight
// into it (bypassing #player-controls), and plugins show/hide their own
// controls — both must refresh the badge/empty state.
const slot = $('v3-plugin-controls-slot');
if (slot) {
try {
new MutationObserver(updatePluginSlotState).observe(slot, {
childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden'],
});
} catch (e) { /* non-fatal */ }
}
}
// ── Rail popovers ───────────────────────────────────────────────────────
function setPopOpenFlag(on) {
const p = player();
if (p) p.classList.toggle('pop-open', !!on);
}
function closePop() {
if (!openPop) return;
if (openPop.pop) openPop.pop.classList.add('hidden');
if (openPop.btn) {
openPop.btn.setAttribute('aria-expanded', 'false');
openPop.btn.classList.remove('is-active');
}
openPop = null;
setPopOpenFlag(false);
}
function openPopFor(btn) {
const key = btn.getAttribute('data-rail');
const pop = $('v3-rail-pop-' + key);
if (!pop) return;
if (openPop && openPop.pop === pop) { closePop(); return; } // toggle
closePop();
pop.classList.remove('hidden');
btn.setAttribute('aria-expanded', 'true');
btn.classList.add('is-active');
openPop = { btn: btn, pop: pop };
setPopOpenFlag(true);
// Lazily mount the P18 audio-routing widget the first time it's shown.
if (key === 'audio' && window.v3AudioRouting && typeof window.v3AudioRouting.render === 'function') {
try { window.v3AudioRouting.render($('v3-rail-audio-routing')); } catch (e) { /* non-fatal */ }
}
}
// Reflect the real highway lyrics state onto the Mic rail icon (lyrics
// default ON and persist in localStorage, so click-parity would desync).
function syncLyricsIcon() {
const rail = $('v3-player-rail');
const lyr = rail && rail.querySelector('[data-rail-action="lyrics"]');
if (!lyr) return;
const on = (window.highway && typeof highway.getLyricsVisible === 'function')
? highway.getLyricsVisible()
: lyr.classList.contains('is-active');
lyr.classList.toggle('is-active', !!on);
lyr.setAttribute('aria-pressed', on ? 'true' : 'false');
}
function wireRail() {
const rail = $('v3-player-rail');
if (!rail || rail.dataset.pcWired) return;
rail.dataset.pcWired = '1';
rail.querySelectorAll('[data-rail]').forEach((b) =>
b.addEventListener('click', (e) => { e.stopPropagation(); openPopFor(b); }));
// Mic icon: a direct lyrics toggle (clicks the hidden canonical button so
// highway.toggleLyrics() + any label logic runs), mirroring on/off state.
const lyr = rail.querySelector('[data-rail-action="lyrics"]');
if (lyr) lyr.addEventListener('click', (e) => {
e.stopPropagation();
const real = $('btn-lyrics');
if (real) real.click(); // runs highway.toggleLyrics() via its onclick
else if (window.highway && typeof highway.toggleLyrics === 'function') highway.toggleLyrics();
syncLyricsIcon(); // reflect the ACTUAL toggled state, not click parity
});
// Click-outside + Esc close (bound once; harmless when no popover open).
if (!window.__pcGlobalClose) {
window.__pcGlobalClose = true;
document.addEventListener('click', (e) => {
if (!openPop) return;
if (openPop.pop.contains(e.target) || openPop.btn.contains(e.target)) return;
closePop();
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePop(); });
}
}
// ── Up Next pill ─────────────────────────────────────────────────────────
function updateUpNext() {
const pill = $('v3-upnext');
if (!pill) return;
const hw = window.highway;
const secs = (hw && typeof hw.getSections === 'function') ? hw.getSections() : null;
const t = (hw && typeof hw.getTime === 'function') ? hw.getTime() : null;
if (!Array.isArray(secs) || !secs.length || t == null || isNaN(t)) { pill.classList.add('hidden'); return; }
let next = null;
for (let i = 0; i < secs.length; i++) {
if (typeof secs[i].time === 'number' && secs[i].time > t + 0.05) { next = secs[i]; break; }
}
if (!next) { pill.classList.add('hidden'); return; }
const dt = Math.max(0, next.time - t);
const nm = $('v3-upnext-name'), eta = $('v3-upnext-eta');
if (nm) nm.textContent = next.name || '—';
if (eta) eta.textContent = 'in ' + dt.toFixed(1) + 's';
pill.classList.remove('hidden');
}
// ── Speed visual (bars + chevrons reflect #speed-slider) ──────────────────
function updateSpeedViz() {
const s = $('speed-slider');
if (!s) return;
const min = parseFloat(s.min) || 25, max = parseFloat(s.max) || 150;
const frac = Math.min(1, Math.max(0, (parseFloat(s.value) - min) / (max - min)));
const bars = document.querySelectorAll('#player-controls .v3-speed-bars i');
const onBars = Math.round(frac * bars.length);
bars.forEach((b, i) => b.classList.toggle('on', i < onBars));
const chev = document.querySelectorAll('#player-controls .v3-chevrons i');
const onChev = Math.round(frac * chev.length);
chev.forEach((c, i) => c.classList.toggle('on', i < onChev));
}
// ── Auto-hide transport ───────────────────────────────────────────────────
function revealChrome() {
const p = player();
if (!p) return;
p.classList.add('chrome-active');
p.classList.remove('chrome-idle');
lastMove = now();
}
function tickIdle() {
const p = player();
if (!p) return;
const playBtn = $('btn-play');
const playing = playBtn && playBtn.getAttribute('aria-pressed') === 'true';
const controls = $('player-controls');
const overControls = controls && typeof controls.matches === 'function' && controls.matches(':hover');
// Keep the transport up while paused, hovering it, or a popover is open.
if (openPop || overControls || !playing) { lastMove = now(); return; }
if (now() - lastMove > IDLE_MS) {
p.classList.remove('chrome-active');
p.classList.add('chrome-idle');
}
}
// ── rAF loop ──────────────────────────────────────────────────────────────
function loop() {
const t = now();
if (t - lastUpNext >= UPNEXT_MS) {
lastUpNext = t;
updateUpNext();
// Re-sync the lyrics icon so programmatic highway.setLyricsVisible()
// (e.g. from lyrics_karaoke) isn't left stale; cheap + idempotent.
syncLyricsIcon();
}
tickIdle();
rafId = requestAnimationFrame(loop);
}
// ── Lifecycle ───────────────────────────────────────────────────────────--
function start() {
if (running) return;
const p = player();
if (!p) return;
running = true;
wireRail();
p.addEventListener('mousemove', revealChrome);
p.addEventListener('touchstart', revealChrome, { passive: true });
const s = $('speed-slider');
if (s && !s.dataset.pcVizWired) { s.dataset.pcVizWired = '1'; s.addEventListener('input', updateSpeedViz); }
updateSpeedViz();
syncLyricsIcon();
rehomePluginControls(); // sweep controls a plugin injected before/at player open
revealChrome();
if (!rafId) loop();
}
function stop() {
if (!running) return;
running = false;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
const p = player();
if (p) {
p.removeEventListener('mousemove', revealChrome);
p.removeEventListener('touchstart', revealChrome);
p.classList.remove('chrome-active', 'chrome-idle');
}
closePop();
}
function syncActivation() {
const p = player();
if (!p) return;
if (p.classList.contains('active')) start(); else stop();
}
function init() {
const p = player();
if (!p) return;
installRehomeObserver(); // always on, so plugin injections are caught whenever they happen
try {
new MutationObserver(syncActivation).observe(p, { attributes: true, attributeFilter: ['class'] });
} catch (e) { /* MutationObserver always present in target browsers */ }
syncActivation();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
+189
View File
@@ -0,0 +1,189 @@
/*
* fee[dB]ack v0.3.0 — Playlists + Saved for Later screens.
*
* Vanilla JS (constitution P-II). Core REST (/api/playlists, /api/saved/*),
* no capability domain. Renders #v3-playlists (list + detail with drag-
* reorder) and #v3-saved (the reserved system playlist). Exposes
* window.v3Saved.toggle(filename) for the "Save for later" affordance on song
* cards/rows (used by the library/dashboard).
*/
(function () {
'use strict';
const sm = window.slopsmith;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// Return null (don't throw) on a network/connection failure so a fetch
// rejection can't abort rendering — matches the "degrades gracefully"
// contract and the other v3 modules' jget/jsend.
async function jget(u) { try { const r = await fetch(u); return r.ok ? r.json() : null; } catch (e) { return null; } }
async function jsend(method, u, body) {
try {
const r = await fetch(u, {
method, headers: { 'Content-Type': 'application/json' },
body: body == null ? undefined : JSON.stringify(body),
});
return r.ok ? r.json() : null;
} catch (e) { return null; }
}
function songRow(s, opts) {
opts = opts || {};
const handle = opts.draggable
? '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>' : '';
const tuning = s.tuning_name
? '<span class="ml-2 text-[10px] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : '';
return '<li data-fn="' + esc(s.filename) + '"' + (opts.draggable ? ' draggable="true"' : '') +
' class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-fb-card/50 group">' +
handle +
'<img src="' + esc(s.art_url) + '" alt="" class="w-10 h-10 rounded object-cover bg-fb-card" onerror="this.style.visibility=\'hidden\'">' +
'<span class="flex-1 min-w-0"><span class="block text-sm text-fb-text truncate">' + esc(s.title) + tuning + '</span>' +
'<span class="block text-xs text-fb-textDim truncate">' + esc(s.artist) + '</span></span>' +
'<button data-v3-play aria-label="Play" class="opacity-0 group-hover:opacity-100 text-fb-primary hover:text-fb-primaryHi text-sm px-2" title="Play">▶</button>' +
'<button data-remove aria-label="Remove from playlist" class="opacity-0 group-hover:opacity-100 text-fb-textDim hover:text-fb-accent text-sm px-2" title="Remove">✕</button>' +
'</li>';
}
function wireSongRows(listEl, pid, onChange) {
listEl.querySelectorAll('li[data-fn]').forEach((li) => {
const fn = li.getAttribute('data-fn');
li.querySelector('[data-v3-play]')?.addEventListener('click', () => {
// playSong decodeURIComponent()s its arg for the highway WS, so
// pass an encoded filename (like the rest of v3) — a raw name
// with %/#/?/ in it would otherwise misroute or throw.
if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(fn));
});
li.querySelector('[data-remove]')?.addEventListener('click', async () => {
await fetch('/api/playlists/' + pid + '/songs/' + encodeURIComponent(fn), { method: 'DELETE' });
onChange();
});
});
// Drag-reorder.
let dragEl = null;
listEl.querySelectorAll('li[draggable="true"]').forEach((li) => {
li.addEventListener('dragstart', () => { dragEl = li; li.classList.add('opacity-50'); });
li.addEventListener('dragend', () => { li.classList.remove('opacity-50'); });
li.addEventListener('dragover', (e) => {
e.preventDefault();
if (!dragEl || dragEl === li) return;
const rect = li.getBoundingClientRect();
const after = (e.clientY - rect.top) > rect.height / 2;
li.parentNode.insertBefore(dragEl, after ? li.nextSibling : li);
});
li.addEventListener('drop', async (e) => {
e.preventDefault();
const order = Array.from(listEl.querySelectorAll('li[data-fn]')).map((x) => x.getAttribute('data-fn'));
await jsend('POST', '/api/playlists/' + pid + '/reorder', { order });
// Re-sync from the server: if /reorder was rejected (concurrent
// change) or the request failed, the optimistic DOM order would
// otherwise diverge from what was actually persisted.
onChange();
});
});
}
// ── #v3-playlists ─────────────────────────────────────────────────────--
async function renderPlaylists() {
const root = document.getElementById('v3-playlists');
if (!root) return;
const lists = (await jget('/api/playlists')) || [];
root.innerHTML =
'<div class="max-w-5xl mx-auto px-6 md:px-8 pb-8">' +
'<div class="flex items-center justify-end mb-6">' +
'<button id="v3-pl-new" class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm font-medium shadow-lg shadow-fb-primary/20">New playlist</button>' +
'</div>' +
(lists.length
? '<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">' + lists.map((p) =>
'<button data-pl="' + p.id + '" class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
'<div class="w-full aspect-square rounded-lg bg-fb-bg/50 mb-3 flex items-center justify-center text-fb-textDim">' +
(p.system_key ? '🔖' : '🎵') + '</div>' +
'<div class="text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</div>' +
'<div class="text-xs text-fb-textDim">' + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
'</button>').join('') + '</div>'
: '<p class="text-fb-textDim">No playlists yet. Create one to group songs.</p>') +
'</div>';
root.querySelector('#v3-pl-new')?.addEventListener('click', async () => {
const name = (window.prompt('Playlist name?') || '').trim();
if (!name) return;
await jsend('POST', '/api/playlists', { name });
renderPlaylists();
});
root.querySelectorAll('[data-pl]').forEach((b) =>
b.addEventListener('click', () => renderPlaylistDetail(parseInt(b.getAttribute('data-pl'), 10))));
}
async function renderPlaylistDetail(pid) {
const root = document.getElementById('v3-playlists');
if (!root) return;
const pl = await jget('/api/playlists/' + pid);
if (!pl) { renderPlaylists(); return; }
const isSystem = !!pl.system_key;
root.innerHTML =
'<div class="max-w-3xl mx-auto p-6 md:p-8">' +
'<button id="v3-pl-back" class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Playlists</button>' +
'<div class="flex items-center justify-between mb-6 gap-3">' +
'<h2 class="text-3xl font-bold text-fb-text truncate">' + esc(pl.name) + '</h2>' +
(isSystem ? '' :
'<div class="flex gap-2 shrink-0">' +
'<button id="v3-pl-rename" class="text-sm text-fb-textDim hover:text-fb-text px-2">Rename</button>' +
'<button id="v3-pl-delete" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Delete</button></div>') +
'</div>' +
(pl.songs.length
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem })).join('') + '</ul>'
: '<p class="text-fb-textDim">Empty — add songs from the library.</p>') +
'</div>';
root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
const listEl = root.querySelector('#v3-pl-songs');
if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid));
root.querySelector('#v3-pl-rename')?.addEventListener('click', async () => {
const name = (window.prompt('Rename playlist', pl.name) || '').trim();
if (!name) return;
await jsend('PATCH', '/api/playlists/' + pid, { name });
renderPlaylistDetail(pid);
});
root.querySelector('#v3-pl-delete')?.addEventListener('click', async () => {
if (!window.confirm('Delete "' + pl.name + '"?')) return;
await fetch('/api/playlists/' + pid, { method: 'DELETE' });
renderPlaylists();
});
}
// ── #v3-saved ─────────────────────────────────────────────────────────--
async function renderSaved() {
const root = document.getElementById('v3-saved');
if (!root) return;
const lists = (await jget('/api/playlists')) || [];
const saved = lists.find((p) => p.system_key === 'saved_for_later');
const pl = saved ? await jget('/api/playlists/' + saved.id) : null;
root.innerHTML =
'<div class="max-w-3xl mx-auto px-6 md:px-8 pb-8">' +
(pl && pl.songs.length
? '<ul id="v3-saved-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, {})).join('') + '</ul>'
: '<p class="text-fb-textDim">Nothing saved yet. Use “Save for later” on a song to add it here.</p>') +
'</div>';
const listEl = root.querySelector('#v3-saved-songs');
if (listEl && pl) wireSongRows(listEl, pl.id, renderSaved);
}
// ── Public: Save-for-later toggle for song cards/rows ─────────────────--
window.v3Saved = {
toggle: async function (filename) {
const res = await jsend('POST', '/api/saved/toggle', { filename });
return res ? res.saved : null;
},
};
window.v3Playlists = { refresh: renderPlaylists, refreshSaved: renderSaved };
// Lazy-render when these screens are shown (data can change between visits).
if (sm && typeof sm.on === 'function') {
sm.on('screen:changed', (e) => {
const id = e && e.detail && e.detail.id;
if (id === 'v3-playlists') renderPlaylists();
else if (id === 'v3-saved') renderSaved();
});
}
function boot() { renderPlaylists(); renderSaved(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();
+571
View File
@@ -0,0 +1,571 @@
/*
* fee[dB]ack v0.3.0 — Plugins page (#v3-plugins), "Pedalboard" layout.
*
* Plugins are grouped by category onto guitar-pedalboard surfaces; each plugin
* renders as a stompbox pedal (thumbnail + name + short description). Pedals are
* free-form draggable within their board (positions persist in localStorage),
* and decorative patch cables (pedal-cables.js) sag/swing between them. Clicking
* (not dragging) a pedal opens that plugin's SETTINGS page; plugins with no
* settings fall back to their screen. Data comes from the enriched /api/plugins
* (now carrying description/category/icon — see plugins/__init__.py::_nav_entry).
* The bundled Capability Inspector still owns the live capability graph; we
* surface a deep-link to it rather than rebuilding it.
*/
(function () {
'use strict';
var sm = window.slopsmith;
var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); };
var LS_KEY = 'v3.pedalboard.layout';
var DEFAULT_THUMB = '/static/v3/pedal-default.svg';
// Pool of pedal "skin" photos (static/v3/pedals/). Each plugin is assigned
// one at random on first sight and keeps it across sessions (persisted in
// localStorage). Add more by dropping files here and listing them.
var FRAMES_KEY = 'v3.pedalboard.frames';
var PEDAL_FRAMES = [
'pedal-001.png', 'pedal-002.png', 'pedal-003.png', 'pedal-004.png', 'pedal-005.png',
'pedal-006.png', 'pedal-007.png', 'pedal-008.png', 'pedal-009.png', 'pedal-010.png',
'pedal-011.png', 'pedal-012.png', 'pedal-013.png', 'pedal-014.png', 'pedal-015.png',
'pedal-016.png', 'pedal-017.png', 'pedal-018.png', 'pedal-019.png',
];
// Bump when the skin images change on disk — busts the browser cache so the
// new artwork shows without a manual hard-reload (filenames are reused).
var FRAMES_VERSION = 8;
// Recursively re-home parsed JSON onto NULL-prototype objects, so manifest-
// controlled keys (category / plugin id) like '__proto__' / 'toString' /
// 'constructor' — at ANY nesting level — resolve to undefined instead of an
// inherited member (the layout map nests category → id → {x,y}).
function _nullProto(o) {
if (!o || typeof o !== 'object') return o;
if (Array.isArray(o)) return o.map(_nullProto);
var out = Object.create(null);
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) out[k] = _nullProto(o[k]);
return out;
}
function _loadMap(key) {
try {
var r = window.localStorage.getItem(key);
var o = r ? JSON.parse(r) : null;
return (o && typeof o === 'object' && !Array.isArray(o)) ? _nullProto(o) : Object.create(null);
} catch (e) { return Object.create(null); }
}
function loadFrames() { return _loadMap(FRAMES_KEY); }
function saveFrames(o) { try { window.localStorage.setItem(FRAMES_KEY, JSON.stringify(o)); } catch (e) { /* */ } }
function pickFrame() { return PEDAL_FRAMES.length ? PEDAL_FRAMES[Math.floor(Math.random() * PEDAL_FRAMES.length)] : null; }
// Return the persisted skin for a plugin, assigning (and recording into
// `frames`) a fresh random one if absent or no longer in the pool.
function frameFor(id, frames) {
var f = frames[id];
if (!f || PEDAL_FRAMES.indexOf(f) === -1) { f = pickFrame(); if (f) frames[id] = f; }
return f;
}
function frameUrl(f) { return f ? '/static/v3/pedals/' + f + '?v=' + FRAMES_VERSION : '/static/v3/pedal-frame.svg'; }
// Persisted collapsed state per board (category → true when collapsed).
var COLLAPSE_KEY = 'v3.pedalboard.collapsed';
function loadCollapsed() { return _loadMap(COLLAPSE_KEY); }
function saveCollapsed(o) { try { window.localStorage.setItem(COLLAPSE_KEY, JSON.stringify(o)); } catch (e) { /* */ } }
// Board order + human labels. Only non-empty boards render.
var BOARD_ORDER = ['audio', 'creation', 'practice', 'game', 'tools', 'other'];
// Null-prototype lookup maps: keys are manifest-controlled (category / plugin
// id), so `BOARD_LABEL[cat]` / `CURATED[id]` must not resolve inherited members
// for keys like '__proto__' / 'toString' / 'constructor'.
var BOARD_LABEL = Object.assign(Object.create(null), {
audio: 'Audio', creation: 'Creation', practice: 'Practice',
game: 'Games', tools: 'Tools', other: 'Other',
});
// Curated plugin-id → category map. Keyed on loader ids (which differ from
// repo names, e.g. midi_amp / rig_builder / note_detect). A manifest
// `category` overrides this; unknown ids fall through to deriveFromType then
// 'other'. Tune freely — it only affects which board a pedal sits on.
var CURATED = Object.assign(Object.create(null), {
// audio — tone / amp / stems
nam_tone: 'audio', rig_builder: 'audio', nam_rig_builder: 'audio',
stems: 'audio', stem_mixer: 'audio', midi_amp: 'audio', midi: 'audio',
tones: 'audio', note_detect: 'audio', notedetect: 'audio', backingtrack: 'audio',
tuner: 'audio',
// creation — highways / visualizers / authoring
highway_3d: 'creation', drum_highway_3d: 'creation', drums: 'creation',
piano: 'creation', jumpingtab: 'creation', invert_highway: 'creation',
splitscreen: 'creation', studio: 'creation', multiplayer: 'creation',
// practice — drills / theory / reference
metronome: 'practice', practice_journal: 'practice', practice: 'practice',
section_map: 'practice', sectionmap: 'practice', stepmode: 'practice',
guitar_theory: 'practice', fretboard: 'practice', tabview: 'practice',
lyrics_karaoke: 'practice', chordgem: 'practice', player_guide: 'practice',
the_daily: 'practice', tutorials: 'practice',
// games
flappy_bend: 'game', minigames: 'game', slopscale: 'game', chord_sprint: 'game',
// tools — import / manage / utility
editor: 'tools', tabimport: 'tools', sloppak_converter: 'tools',
profileimport: 'tools', find_more: 'tools', themes: 'tools',
update_manager: 'tools', song_preview: 'tools', setlist: 'tools',
transpose_chords: 'tools', discextract: 'tools', rs1extract: 'tools',
loosefolder: 'tools', cf: 'tools',
});
function deriveFromType(p) {
if (p && p.type === 'visualization') return 'creation';
return null;
}
function categoryOf(p) {
// A manifest `category` is authoritative (an unknown value just becomes
// its own board, rendered after the known ones). Otherwise fall back to
// the curated id map, then a type-derived guess, then 'other'.
if (p && typeof p.category === 'string' && p.category.trim()) return p.category.trim().toLowerCase();
return (p && CURATED[p.id]) || deriveFromType(p) || 'other';
}
function thumbUrl(p) {
if (p && typeof p.icon === 'string' && p.icon) {
var rel = p.icon.replace(/^assets\//, '');
return '/api/plugins/' + encodeURIComponent(p.id) + '/assets/' + encodeURI(rel);
}
return DEFAULT_THUMB;
}
function openable(p) {
// A plugin's screen is openable only if a #plugin-<id> screen exists
// (declared via nav/has_screen, or already injected by a script plugin).
return !!(p.nav || p.has_screen || (p.has_script && document.getElementById('plugin-' + p.id)));
}
// Decide what a pedal click should open: its settings panel, else its
// screen, else nothing (toast). Pure — unit-tested.
function settingsTarget(p) {
if (p && p.has_settings) return { kind: 'settings', id: p.id };
if (openable(p)) return { kind: 'screen', id: p.id };
return { kind: 'none', id: p && p.id };
}
// ---- layout persistence (pure-ish, tested) ----------------------------
function loadLayout() { return _loadMap(LS_KEY); }
function saveLayout(obj) {
try { window.localStorage.setItem(LS_KEY, JSON.stringify(obj)); } catch (e) { /* quota / private mode */ }
}
function clampToBoard(pos, boardW, pedalW) {
var maxX = Math.max(0, boardW - pedalW);
var x = Math.min(Math.max(0, pos.x || 0), maxX);
var y = Math.max(0, pos.y || 0);
return { x: x, y: y };
}
// The column count adapts to the board width so pedals stay near TARGET_W
// (more columns on a wide window, fewer when narrow); their pixel width is
// then computed to fill the row evenly. GAP_X must exceed 2×PLUG_BOOT
// (pedal-cables.js) so facing plugs leave room for cable. Height = width /
// PEDAL_ASPECT.
var TARGET_W = 220, PEDAL_ASPECT = 0.6, PAD = 24, GAP_X = 96, GAP_Y = 40;
function pedalDims(boardW) {
var cols = Math.max(1, Math.round((boardW - PAD * 2 + GAP_X) / (TARGET_W + GAP_X)));
var w = Math.max(120, Math.floor((boardW - PAD * 2 - (cols - 1) * GAP_X) / cols));
return { w: w, h: Math.round(w / PEDAL_ASPECT), cols: cols };
}
// Default flow slot for the Nth pedal on a board of width boardW.
// GAP_X must exceed 2×PLUG_BOOT (pedal-cables.js) so two facing side-jack
// plugs leave room for a visible cable between adjacent pedals. PEDAL_W/H
// must match .v3-pedal's rendered size in v3.css.
function defaultSlot(index, boardW) {
var d = pedalDims(boardW);
var col = index % d.cols, row = Math.floor(index / d.cols);
return { x: PAD + col * (d.w + GAP_X), y: PAD + row * (d.h + GAP_Y) };
}
// ---- rendering --------------------------------------------------------
function statusPill(p) {
var s = p.status || 'ready';
if (s === 'failed') return '<span class="v3-pedal-pill v3-pill-bad">Failed</span>';
if (s === 'installing') return '<span class="v3-pedal-pill v3-pill-wait">Installing</span>';
return '';
}
function pedalHtml(p) {
var failed = p.status === 'failed';
var desc = p.description || '';
// The skin photo (knobs, footswitch, jacks all baked in) is the card
// background; the plugin's identity overlays in a label panel.
// Skin goes on a CSS var so it lives on the ::before layer (which dims
// when disabled) while the glow ring stays full-colour.
var style = p._frameUrl ? ' style="--skin:url(\'' + esc(p._frameUrl) + '\')"' : '';
var off = p.enabled === false;
// Description shows only as a hover tooltip on the pedal, not on the face.
var tip = (p.name || p.id) + (desc ? ' — ' + desc : '');
return '<div class="v3-pedal' + (off ? ' v3-pedal-off' : '') + '" data-id="' + esc(p.id) + '" tabindex="0" role="button" ' +
'aria-label="' + esc(p.name || p.id) + ' — open settings" title="' + esc(tip) + '"' + style + '>' +
'<span class="v3-pedal-glow" aria-hidden="true"></span>' +
(p.bundled ? '<span class="v3-pedal-bundled" title="Ships with Slopsmith core">core</span>' : '') +
'<span class="v3-pedal-offbadge" aria-hidden="true">off</span>' +
'<div class="v3-pedal-label">' +
'<img class="v3-pedal-thumb" alt="" loading="lazy" src="' + esc(thumbUrl(p)) + '">' +
'<div class="v3-pedal-name">' + esc(p.name || p.id) + '</div>' +
statusPill(p) +
(failed && p.error ? '<div class="v3-pedal-err">' + esc(p.error) + '</div>' : '') +
'</div>' +
// Footswitch hotspot — clicking toggles the plugin on/off (does NOT
// open settings). Positioned over the photo's stomp button.
'<button class="v3-pedal-foot" data-foot="' + esc(p.id) + '" ' +
'title="Turn this plugin on/off" aria-label="Toggle ' + esc(p.name || p.id) + '"></button>' +
'</div>';
}
function boardHtml(cat, list, collapsed) {
var ec = esc(cat); // category can come from a plugin manifest → escape it
return '<section class="v3-board-section' + (collapsed ? ' collapsed' : '') + '" data-category="' + ec + '">' +
'<button class="v3-board-title" data-toggle="' + ec + '" aria-expanded="' + (!collapsed) + '">' +
'<svg class="v3-board-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">' +
'<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>' +
'<span>' + esc(BOARD_LABEL[cat] || cat) + '</span>' +
'<span class="v3-board-count">' + list.length + '</span></button>' +
'<div class="v3-pedalboard" data-category="' + ec + '">' +
list.map(pedalHtml).join('') +
'</div></section>';
}
function toast(msg) {
var host = document.getElementById('v3-plugins-toast');
if (!host) {
host = document.createElement('div');
host.id = 'v3-plugins-toast';
host.className = 'fixed bottom-6 left-1/2 -translate-x-1/2 z-[120] bg-fb-card text-fb-text ' +
'border border-fb-border/60 rounded-lg px-4 py-2 text-sm shadow-xl';
document.body.appendChild(host);
}
host.textContent = msg;
host.classList.remove('hidden');
clearTimeout(host._t);
host._t = setTimeout(function () { host.classList.add('hidden'); }, 3000);
}
// Open a plugin's settings panel: switch to the legacy Settings screen (it
// mounts each plugin's settings.html as a <details data-plugin-id> under
// #plugin-settings — app.js), then expand + scroll to it. Retries a few
// frames in case the details haven't hydrated yet.
function openSettingsPanel(id, tries) {
var t = tries == null ? 12 : tries;
// Match by iterating (not an interpolated selector) so an unusual plugin
// id with quotes/brackets can't break querySelector or inject a selector.
var d = null, all = document.querySelectorAll('#plugin-settings details[data-plugin-id]');
for (var i = 0; i < all.length; i++) { if (all[i].getAttribute('data-plugin-id') === id) { d = all[i]; break; } }
if (d) {
d.open = true;
try { d.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) { d.scrollIntoView(); }
return;
}
if (t > 0) requestAnimationFrame(function () { openSettingsPanel(id, t - 1); });
}
function openPluginSettings(p) {
var tgt = settingsTarget(p);
if (tgt.kind === 'settings') {
if (window.showScreen) window.showScreen('settings');
openSettingsPanel(tgt.id);
} else if (tgt.kind === 'screen') {
// Only navigate if the screen is actually mounted — an installing/
// failed plugin has manifest has_screen but no #plugin-<id> div yet.
if (window.showScreen && document.getElementById('plugin-' + tgt.id)) {
window.showScreen('plugin-' + tgt.id);
} else {
toast('This plugin is still loading — try again in a moment.');
}
} else {
toast('This plugin has no settings or screen to open.');
}
}
// Footswitch enable/disable with "last intent wins": clicks set pedal._want
// (the desired enabled state) + an optimistic class; one request is in flight
// at a time, and if the user's intent changed while it ran we resend, so the
// final state always matches the latest click (no stale-response overwrite).
function flushFoot(pedal, id) {
pedal._pending = true;
var sending = pedal._want;
fetch('/api/plugins/' + encodeURIComponent(id) + '/enabled', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: sending }),
}).then(function (r) { if (!r.ok) throw new Error('http ' + r.status); return r.json(); })
.then(function (res) {
if (pedal._plugin) pedal._plugin.enabled = res.enabled; // last known server state
if (pedal._want !== res.enabled) { flushFoot(pedal, id); return; } // intent changed → resend
pedal.classList.toggle('v3-pedal-off', res.enabled === false);
pedal._pending = false;
})
.catch(function () {
pedal._pending = false;
// revert optimistic UI to the last known-good server state
var known = pedal._plugin ? pedal._plugin.enabled !== false : true;
pedal._want = known;
pedal.classList.toggle('v3-pedal-off', !known);
toast('Could not toggle this plugin.');
});
}
// Position every pedal on a board from saved layout (clamped) or default
// flow, and grow the board to fit. Mutates the DOM.
function layoutBoard(boardEl, cat, layout) {
var boardW = boardEl.clientWidth || boardEl.offsetWidth || 800;
var d = pedalDims(boardW);
var pedals = boardEl.querySelectorAll('.v3-pedal');
var saved = layout && layout[cat];
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) saved = Object.create(null);
var maxBottom = 0;
for (var i = 0; i < pedals.length; i++) {
var el = pedals[i];
var id = el.getAttribute('data-id');
el.style.width = d.w + 'px';
el.style.height = d.h + 'px';
var pos = saved[id] ? clampToBoard(saved[id], boardW, d.w) : defaultSlot(i, boardW);
el.style.left = pos.x + 'px';
el.style.top = pos.y + 'px';
maxBottom = Math.max(maxBottom, pos.y + d.h);
}
boardEl.style.minHeight = (maxBottom + PAD) + 'px';
}
var DRAG_THRESHOLD = 5;
function wirePedalDrag(boardEl, cat) {
var boardW = function () { return boardEl.clientWidth || 800; };
boardEl.querySelectorAll('.v3-pedal').forEach(function (el) {
var startX = 0, startY = 0, origX = 0, origY = 0, dragging = false;
el.addEventListener('pointerdown', function (ev) {
if (ev.button != null && ev.button !== 0) return;
startX = ev.clientX; startY = ev.clientY;
origX = parseFloat(el.style.left) || 0;
origY = parseFloat(el.style.top) || 0;
dragging = false;
// Clear any stale suppression flag from a prior drag that wasn't
// followed by a click (some browsers skip click after capture).
el._dragged = false;
try { el.setPointerCapture(ev.pointerId); } catch (e) { /* */ }
});
el.addEventListener('pointermove', function (ev) {
if (el.hasPointerCapture && !el.hasPointerCapture(ev.pointerId)) return;
var dx = ev.clientX - startX, dy = ev.clientY - startY;
if (!dragging && Math.abs(dx) + Math.abs(dy) < DRAG_THRESHOLD) return;
if (!dragging) {
dragging = true;
el.classList.add('v3-pedal-dragging');
if (window.v3PedalCables) window.v3PedalCables.setDragging(true);
}
var bw = boardW(); var d = pedalDims(bw);
var pos = clampToBoard({ x: origX + dx, y: origY + dy }, bw, d.w);
el.style.left = pos.x + 'px';
el.style.top = pos.y + 'px';
// Grow board to fit if dragged below current extent.
var need = pos.y + d.h + PAD;
if (need > (parseFloat(boardEl.style.minHeight) || 0)) boardEl.style.minHeight = need + 'px';
if (window.v3PedalCables) window.v3PedalCables.refresh();
});
function endDrag(ev) {
if (el.hasPointerCapture && ev && ev.pointerId != null) {
try { el.releasePointerCapture(ev.pointerId); } catch (e) { /* */ }
}
if (dragging) {
el.classList.remove('v3-pedal-dragging');
el._dragged = true; // suppress the click that follows
var layout = loadLayout();
var bucket = layout[cat];
if (!bucket || typeof bucket !== 'object' || Array.isArray(bucket)) {
bucket = layout[cat] = Object.create(null);
}
bucket[el.getAttribute('data-id')] = {
x: parseFloat(el.style.left) || 0,
y: parseFloat(el.style.top) || 0,
};
saveLayout(layout);
if (window.v3PedalCables) window.v3PedalCables.setDragging(false);
}
dragging = false;
}
el.addEventListener('pointerup', endDrag);
el.addEventListener('pointercancel', endDrag);
// Click / keyboard activate → open settings, unless a drag just ran.
el.addEventListener('click', function () {
if (el._dragged) { el._dragged = false; return; }
if (el._plugin) openPluginSettings(el._plugin);
});
el.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
if (el._plugin) openPluginSettings(el._plugin);
}
});
});
}
async function render() {
var root = document.getElementById('v3-plugins');
if (!root) return;
var plugins = [];
try { var r = await fetch('/api/plugins'); if (r.ok) plugins = await r.json(); } catch (e) { /* */ }
if (!Array.isArray(plugins)) plugins = [];
var active = plugins.filter(function (p) { return (p.status || 'ready') === 'ready'; }).length;
var inspectorPresent = plugins.some(function (p) { return p.id === 'capability_inspector'; });
// Assign each plugin its persisted (or freshly-random) pedal skin.
var frames = loadFrames();
plugins.forEach(function (p) { p._frameUrl = frameUrl(frameFor(p.id, frames)); });
saveFrames(frames);
// Group by category. Null-prototype map so a manifest category like
// '__proto__' / 'toString' / 'constructor' resolves to undefined (not an
// inherited member) and can't crash the grouping.
var byCat = Object.create(null);
plugins.forEach(function (p) {
var c = categoryOf(p);
(byCat[c] = byCat[c] || []).push(p);
});
var cats = BOARD_ORDER.filter(function (c) { return byCat[c] && byCat[c].length; });
// Any unexpected category not in BOARD_ORDER → append after.
Object.keys(byCat).forEach(function (c) { if (cats.indexOf(c) === -1) cats.push(c); });
var collapsed = loadCollapsed();
var boardsHtml = cats.map(function (c) { return boardHtml(c, byCat[c], !!collapsed[c]); }).join('');
root.innerHTML =
'<div class="v3-pedalboards-wrap px-6 md:px-8 pb-10">' +
'<div class="flex items-center justify-between gap-3 mb-6 flex-wrap">' +
'<span class="text-lg font-medium text-fb-good">' + active + ' active</span>' +
'<div class="flex items-center gap-2">' +
'<button id="v3-pedal-reset" class="text-sm bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-textDim hover:text-fb-text px-3 py-1.5 rounded-md" title="Reset pedal positions and re-roll skins">Reset</button>' +
(inspectorPresent
? '<button id="v3-open-inspector" class="text-sm bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text px-3 py-1.5 rounded-md">Capability Inspector →</button>'
: '') +
'</div></div>' +
(boardsHtml || '<p class="text-fb-textDim text-sm">No plugins installed.</p>') +
'</div>';
// Bind plugin objects to pedal elements + lay them out + wire drag.
var byId = Object.create(null);
plugins.forEach(function (p) { byId[p.id] = p; });
var layout = loadLayout();
root.querySelectorAll('.v3-pedalboard').forEach(function (boardEl) {
var cat = boardEl.getAttribute('data-category');
boardEl.querySelectorAll('.v3-pedal').forEach(function (el) {
el._plugin = byId[el.getAttribute('data-id')];
});
layoutBoard(boardEl, cat, layout);
wirePedalDrag(boardEl, cat);
});
// Thumbnail fallback on load error.
root.querySelectorAll('.v3-pedal-thumb').forEach(function (img) {
img.addEventListener('error', function () {
if (img.src.indexOf(DEFAULT_THUMB) === -1) img.src = DEFAULT_THUMB;
}, { once: true });
});
// Cables overlay (decorative). Re-attach since render() rebuilt the DOM.
// Only run the loop if the plugins screen is actually visible — render()
// also fires at boot while #v3-plugins is hidden, and screen:changed in
// pedal-cables.js handles activation on later navigation.
if (window.v3PedalCables) {
window.v3PedalCables.attach(root);
window.v3PedalCables.markActive(root.classList.contains('active'));
}
// Collapsible boards: toggle on title click, persist per category.
root.querySelectorAll('[data-toggle]').forEach(function (btn) {
btn.addEventListener('click', function () {
var cat = btn.getAttribute('data-toggle');
var sec = btn.closest('.v3-board-section');
if (!sec) return;
var nowCollapsed = !sec.classList.contains('collapsed');
sec.classList.toggle('collapsed', nowCollapsed);
btn.setAttribute('aria-expanded', String(!nowCollapsed));
var c = loadCollapsed();
if (nowCollapsed) c[cat] = true; else delete c[cat];
saveCollapsed(c);
// While collapsed the board is display:none, so its pedals were
// laid out against a 0-width board. Re-flow on expand now that it
// has a real width.
if (!nowCollapsed) {
var board = sec.querySelector('.v3-pedalboard');
if (board) layoutBoard(board, cat, loadLayout());
}
if (window.v3PedalCables) window.v3PedalCables.refresh();
});
});
// Footswitch → enable/disable the plugin (POST /api/plugins/<id>/enabled).
// stopPropagation so it neither starts a drag nor opens settings.
root.querySelectorAll('[data-foot]').forEach(function (btn) {
btn.addEventListener('pointerdown', function (ev) { ev.stopPropagation(); });
btn.addEventListener('click', function (ev) {
ev.stopPropagation();
var id = btn.getAttribute('data-foot');
var pedal = btn.closest('.v3-pedal');
if (!pedal) return;
var desired = pedal.classList.contains('v3-pedal-off'); // off → want enabled
pedal.classList.toggle('v3-pedal-off', !desired); // optimistic
pedal._want = desired;
// A request already running will reconcile to the new _want when
// it returns; otherwise start one.
if (!pedal._pending) flushFoot(pedal, id);
});
});
var reset = root.querySelector('#v3-pedal-reset');
if (reset) reset.addEventListener('click', function () {
// Clear saved positions AND skin assignments so both re-roll.
try { window.localStorage.removeItem(LS_KEY); window.localStorage.removeItem(FRAMES_KEY); } catch (e) { /* */ }
render();
});
var insp = root.querySelector('#v3-open-inspector');
if (insp) insp.addEventListener('click', function () {
if (window.showScreen && document.getElementById('plugin-capability_inspector')) {
window.showScreen('plugin-capability_inspector');
}
});
}
window.v3PluginsPage = {
render: render,
// Pure helpers exposed for unit tests.
_test: {
categoryOf: categoryOf, thumbUrl: thumbUrl, settingsTarget: settingsTarget,
clampToBoard: clampToBoard, defaultSlot: defaultSlot,
loadLayout: loadLayout, saveLayout: saveLayout,
frameFor: frameFor, pickFrame: pickFrame, frameUrl: frameUrl,
loadCollapsed: loadCollapsed, saveCollapsed: saveCollapsed, COLLAPSE_KEY: COLLAPSE_KEY,
DRAG_THRESHOLD: DRAG_THRESHOLD, LS_KEY: LS_KEY, CURATED: CURATED,
BOARD_ORDER: BOARD_ORDER, PEDAL_FRAMES: PEDAL_FRAMES, FRAMES_KEY: FRAMES_KEY,
},
};
if (sm && typeof sm.on === 'function') {
sm.on('screen:changed', function (e) { if (e && e.detail && e.detail.id === 'v3-plugins') render(); });
}
// Re-flow the boards when the window resizes (responsive pedal width) — only
// while the Plugins screen is visible, throttled to one frame.
function relayoutAll() {
var root = document.getElementById('v3-plugins');
if (!root || !root.classList.contains('active')) return;
var layout = loadLayout();
root.querySelectorAll('.v3-pedalboard').forEach(function (boardEl) {
layoutBoard(boardEl, boardEl.getAttribute('data-category'), layout);
});
if (window.v3PedalCables) window.v3PedalCables.refresh();
}
var _resizePending = false;
window.addEventListener('resize', function () {
if (_resizePending) return;
_resizePending = true;
requestAnimationFrame(function () { _resizePending = false; relayoutAll(); });
}, { passive: true });
function boot() { render(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();
+458
View File
@@ -0,0 +1,458 @@
/*
* fee[dB]ack v0.3.0 — player profile: first-run onboarding overlay, the topbar
* profile badge, and the #v3-profile screen.
*
* Vanilla JS (constitution P-II). Reads /api/profile + /api/profile/progress
* (one call for the whole badge). Degrades gracefully when stats endpoints
* (prompt 14) aren't present yet. window.v3Onboarding is defined synchronously
* so the shell boot (shell.js) can call it regardless of script order.
*/
(function () {
'use strict';
let _profile = null; // {display_name, avatar_url, player_hash, onboarded}
let _progress = null; // {level, xp, xp_in_level, xp_to_next, current_streak, best_streak}
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
function avatarImg(url, sizeCls, shape) {
shape = shape || 'rounded-full';
if (url) {
return '<img src="' + esc(url) + '" alt="" class="' + sizeCls + ' ' + shape +
' object-cover bg-fb-card border border-fb-border/50">';
}
// Neutral fallback glyph.
return '<span class="' + sizeCls + ' ' + shape + ' bg-fb-card border border-fb-border/50 ' +
'inline-flex items-center justify-center text-fb-textDim">' +
'<svg class="w-1/2 h-1/2" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">' +
'<path stroke-linecap="round" stroke-linejoin="round" d="M12 12a4 4 0 100-8 4 4 0 000 8zm-7 8a7 7 0 0114 0"/></svg></span>';
}
// ── API ──────────────────────────────────────────────────────────────────
async function fetchProfile() {
try { const r = await fetch('/api/profile'); if (r.ok) _profile = await r.json(); } catch (e) { /* P15 */ }
return _profile;
}
async function fetchProgress() {
try { const r = await fetch('/api/profile/progress'); if (r.ok) _progress = await r.json(); } catch (e) { /* P15 */ }
return _progress;
}
// ── Topbar profile badge ───────────────────────────────────────────────--
function renderBadge() {
const host = document.getElementById('v3-badge-profile');
if (!host) return;
if (!_profile || !_profile.onboarded) { host.innerHTML = ''; return; }
const p = _progress || { current_streak: 0 };
// Progression (spec 010): the badge shows Mastery Rank + Decibels.
// Layout still matches the Google Stitch "Profile Card Component"
// (dark rounded card, white avatar tile, flame + "N DAYS"), but the
// 6-bar equalizer now meters CURRENT CHALLENGE-SET progress (completed
// challenges across all paths' active sets / required total) and the
// big number is the Mastery Rank, with the spendable dB balance beside.
const prog = (window.v3Progression && window.v3Progression.get()) || null;
const rank = prog ? prog.mastery_rank : 0;
const balance = prog && prog.wallet ? prog.wallet.balance : 0;
let challengesDone = 0, challengesRequired = 0;
((prog && prog.paths) || []).forEach((path) => {
if (path.next) {
challengesDone += Math.min(path.next.completed, path.next.required);
challengesRequired += path.next.required;
}
});
const flame = '<svg class="w-5 h-5 text-[#FF4B4B]" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">' +
'<path d="M17.557 9.414c.18-.73.242-1.501.183-2.29-.325-4.374-4.507-7.124-7.592-7.124-3.085 0-4.27 2.112-3.288 3.876.983 1.764 1.446 3.828.623 5.462-.76.712-1.503.73-2.146 1.054-1.288.65-2.112 1.954-2.112 3.414 0 4.418 3.582 8 8 8s8-3.582 8-8c0-1.85-.63-3.551-1.668-4.892zm-7.411 11.586c-2.209 0-4-1.791-4-4 0-1.398 1.065-2.201 1.065-2.201.272.775 1.015 1.327 1.885 1.327 1.105 0 2-.895 2-2s-.895-2-2-2c-.372 0-.719.102-1.018.277.29-.533.72-1.002 1.258-1.332 1.303-.801 2.94-.8 4.243.001 1.316.809 1.972 2.33 1.972 3.927 0 3.314-2.686 6-6 6z"/></svg>';
const HEIGHTS = ['h-2', 'h-4', 'h-8', 'h-6', 'h-10', 'h-12'];
const FILL = ['#3B82F6', '#22C55E', '#FACC15', '#F97316', '#D1D5DB', '#22C55E'];
const filled = challengesRequired > 0
? Math.max(0, Math.min(6, Math.round((challengesDone / challengesRequired) * 6))) : 0;
const bars = HEIGHTS.map((h, i) =>
'<div class="w-2 ' + h + ' rounded-sm" style="background-color:' + (i < filled ? FILL[i] : '#3f3f46') + '"></div>').join('');
host.innerHTML =
'<button type="button" data-v3-open-profile class="bg-fb-card border border-fb-border/50 text-white rounded-2xl flex items-center gap-3 p-2 pr-3 shadow-lg ' +
'hover:ring-1 hover:ring-fb-primary/40 transition" title="Profile">' +
'<div data-v3-avatar-tile class="bg-white w-12 h-12 rounded-xl overflow-hidden flex items-center justify-center shrink-0">' +
(_profile.avatar_url
? '<img alt="User Avatar" src="' + esc(_profile.avatar_url) + '" class="w-full h-full object-cover object-center" onerror="this.style.visibility=\'hidden\'">'
: '<svg class="w-2/3 h-2/3 text-gray-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 12a4 4 0 100-8 4 4 0 000 8zm-7 8a7 7 0 0114 0"/></svg>') +
'</div>' +
'<div class="flex flex-col justify-between py-0.5">' +
'<div class="flex items-center gap-1.5 mb-1">' + flame +
'<span class="text-base font-extrabold tracking-tight leading-none">' + (p.current_streak || 0) + ' DAYS</span></div>' +
'<div class="flex items-end gap-2">' +
'<div class="flex flex-col leading-none">' +
'<span class="text-gray-400 text-[10px] font-medium">Rank:</span>' +
'<span class="text-xl font-bold leading-none">' + rank + '</span></div>' +
'<div class="flex items-end gap-1">' + bars + '</div>' +
'<span class="text-[10px] font-semibold text-fb-gold leading-none">' + Number(balance).toLocaleString() + ' dB</span>' +
'</div></div></button>';
// Equipped avatar frame (spec 010 cosmetics).
if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') {
window.v3Theme.applyFrame(host.querySelector('[data-v3-avatar-tile]'));
}
const btn = host.querySelector('[data-v3-open-profile]');
if (btn) btn.addEventListener('click', () => window.showScreen && window.showScreen('v3-profile'));
}
// ── Profile screen (#v3-profile) ────────────────────────────────────────-
function renderProfileScreen() {
const root = document.getElementById('v3-profile');
if (!root) return;
const p = _progress || { current_streak: 0, best_streak: 0 };
const name = (_profile && _profile.display_name) || 'Player';
// Progression (spec 010): the profile header shows Mastery Rank,
// per-path levels, and Decibels (balance + lifetime) — the old
// XP-level meter is replaced by the rank/challenge system.
const prog = (window.v3Progression && window.v3Progression.get()) || null;
const rank = prog ? prog.mastery_rank : 0;
const wallet = (prog && prog.wallet) || { balance: 0, lifetime_db: 0 };
const pathChips = ((prog && prog.paths) || []).map((path) =>
'<span class="inline-flex items-center gap-1 bg-fb-bg/40 border border-fb-border/50 rounded-full px-3 py-1 text-xs text-fb-text">' +
esc(path.name) + ' <span class="text-fb-primary font-semibold">Lv ' + path.level + '</span></span>').join(' ');
root.innerHTML =
'<div class="max-w-4xl mx-auto p-6 md:p-8 space-y-6">' +
// Header card
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50 flex flex-col sm:flex-row items-center gap-6">' +
'<span data-v3-avatar-frame class="inline-block rounded-full">' +
avatarImg(_profile && _profile.avatar_url, 'w-24 h-24') + '</span>' +
'<div class="flex-1 w-full text-center sm:text-left">' +
'<h2 class="text-3xl font-bold text-fb-text">' + esc(name) + '</h2>' +
'<div class="mt-2 flex items-center justify-center sm:justify-start gap-4 text-sm text-fb-textDim flex-wrap">' +
'<span class="text-fb-text font-semibold">Mastery Rank ' + rank + '</span>' +
'<span class="text-fb-gold font-semibold">' + Number(wallet.balance).toLocaleString() + ' dB</span>' +
'<span>' + Number(wallet.lifetime_db).toLocaleString() + ' dB lifetime</span>' +
'<span class="text-fb-accent">🔥 ' + (p.current_streak || 0) + '-day streak</span>' +
'<span>Best: ' + (p.best_streak || 0) + '</span></div>' +
(pathChips ? '<div class="mt-3 flex items-center justify-center sm:justify-start gap-2 flex-wrap">' + pathChips + '</div>' : '') +
'<div class="mt-4 flex items-center justify-center sm:justify-start gap-4">' +
'<button type="button" data-v3-edit-profile class="text-sm text-fb-primary hover:text-fb-primaryHi">Edit name &amp; avatar</button>' +
'<button type="button" data-v3-open-progress class="text-sm text-fb-primary hover:text-fb-primaryHi">View challenges &amp; quests →</button>' +
'</div></div></div>' +
// Per-song bests (filled by prompt 14's song_stats; placeholder here)
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50">' +
'<h3 class="text-lg font-bold text-fb-text mb-2">Your best scores</h3>' +
'<p id="v3-profile-bests" class="text-sm text-fb-textDim">Play a song to start tracking your accuracy and best scores.</p>' +
'</div>' +
(_profile && _profile.player_hash
? '<p class="text-center text-[10px] uppercase tracking-wider text-fb-textDim/60">player id ' + esc(_profile.player_hash.slice(0, 12)) + '</p>'
: '') +
'</div>';
const edit = root.querySelector('[data-v3-edit-profile]');
if (edit) edit.addEventListener('click', () => show(_profile, { editing: true }));
const openProgress = root.querySelector('[data-v3-open-progress]');
if (openProgress) openProgress.addEventListener('click', () => window.showScreen && window.showScreen('v3-progress'));
if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') {
window.v3Theme.applyFrame(root.querySelector('[data-v3-avatar-frame]'));
}
}
// ── First-run onboarding (and edit) overlay ───────────────────────────────
// First-run is a 3-step flow (spec 010): 1) name + avatar, 2) pick one or
// more instrument paths, 3) the calibration challenge offer (play the
// diagnostic sloppak at 100% — or skip and reach Mastery Rank 1 anyway).
// The profile POST always lands before the step-3 choice so onboarded=1 is
// never blocked by the calibration decision. Editing keeps the single form.
function show(profile, opts) {
opts = opts || {};
const editing = !!opts.editing;
document.getElementById('v3-onboarding')?.remove();
const stepDots = editing ? '' :
'<div class="flex justify-center gap-1.5 mt-3" id="v3-ob-dots">' +
[1, 2, 3].map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
'</div>';
const overlay = document.createElement('div');
overlay.id = 'v3-onboarding';
overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
overlay.innerHTML =
'<div class="bg-fb-card rounded-xl border border-fb-border/50 w-full max-w-lg p-6 space-y-5">' +
'<div class="text-center">' +
'<div class="text-2xl">' + (window.fbBrand ? window.fbBrand.wordmarkHTML({ size: 'text-2xl' }) : 'fee[dB]ack') + '</div>' +
'<p id="v3-ob-subtitle" class="text-sm text-fb-textDim mt-1">' + (editing ? 'Edit your player profile' : 'Set up your player profile') + '</p>' +
stepDots + '</div>' +
// Step 1 — name + avatar (the original form, unchanged DOM).
'<div id="v3-ob-step1">' +
'<div><label class="block text-xs uppercase tracking-wider text-fb-textDim mb-1">Display name</label>' +
'<input id="v3-ob-name" type="text" maxlength="32" placeholder="Your name" ' +
'class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text ' +
'focus:border-fb-primary focus:ring-1 focus:ring-fb-primary outline-none"></div>' +
'<div class="mt-4"><label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Avatar</label>' +
'<div id="v3-ob-avatars" class="grid grid-cols-4 sm:grid-cols-6 gap-2"></div>' +
'<div class="mt-2 flex items-center gap-3">' +
'<button type="button" id="v3-ob-upload-btn" class="text-sm text-fb-primary hover:text-fb-primaryHi">Upload your own</button>' +
'<input type="file" id="v3-ob-upload" accept="image/*" class="hidden">' +
'<span id="v3-ob-preview"></span></div></div></div>' +
// Step 2 — instrument paths (first-run only; tiles filled on entry).
'<div id="v3-ob-step2" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Pick your instrument path(s)</label>' +
'<p class="text-sm text-fb-textDim mb-3">Each path levels up by completing challenges — together they make up your Mastery Rank. You can add more later.</p>' +
'<div id="v3-ob-paths" class="grid grid-cols-3 gap-2"></div></div>' +
// Step 3 — calibration offer (first-run only).
'<div id="v3-ob-step3" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Calibration challenge</label>' +
'<p class="text-sm text-fb-textDim">Prove your setup: play the <span class="text-fb-text">Slopsmith Diagnostic</span> with note detection and finish at <span class="text-fb-text font-semibold">100% accuracy</span> to reach <span class="text-fb-text font-semibold">Mastery Rank 1</span>.</p>' +
'<p class="text-sm text-fb-textDim mt-2">Not ready? Skip it and youll start at Rank 1 anyway — you can still play it later from the Progress screen.</p></div>' +
'<p id="v3-ob-error" class="text-sm text-fb-accent hidden"></p>' +
'<div class="flex justify-end gap-3">' +
(editing ? '<button type="button" id="v3-ob-cancel" class="px-4 py-2 rounded-md text-sm text-fb-textDim hover:text-fb-text">Cancel</button>' : '') +
'<button type="button" id="v3-ob-skip" class="hidden px-4 py-2 rounded-md text-sm text-fb-textDim hover:text-fb-text">Skip for now</button>' +
'<button type="button" id="v3-ob-submit" disabled class="bg-fb-primary hover:bg-fb-primaryHi disabled:opacity-40 disabled:cursor-not-allowed text-white px-6 py-2 rounded-md font-medium shadow-lg shadow-fb-primary/20 transition-colors">' +
(editing ? 'Save' : 'Next') + '</button></div></div>';
document.body.appendChild(overlay);
const nameEl = overlay.querySelector('#v3-ob-name');
const grid = overlay.querySelector('#v3-ob-avatars');
const submit = overlay.querySelector('#v3-ob-submit');
const errEl = overlay.querySelector('#v3-ob-error');
const fileEl = overlay.querySelector('#v3-ob-upload');
const preview = overlay.querySelector('#v3-ob-preview');
const skipBtn = overlay.querySelector('#v3-ob-skip');
let selected = null; // { type:'default', value } | { type:'upload', value:url }
let step = 1; // first-run wizard step (editing stays on 1)
let selectedPaths = []; // step-2 picks
let pathsAvailable = false; // any tiles rendered? (false → don't strand the user)
let diagnosticFilename = null; // from /api/progression (step-3 "Play it now")
if (editing && profile) {
nameEl.value = profile.display_name || '';
}
function refreshSubmit() {
if (editing || step === 1) {
// First-run onboarding requires picking an avatar. When editing an
// existing profile, a name-only change is allowed: leaving `selected`
// null omits `avatar` from the POST, and the server keeps the current
// one — including a custom upload that isn't in the bundled grid (so
// Save no longer stays disabled for those).
const haveAvatar = !!selected || (editing && profile && !!profile.avatar_url);
submit.disabled = !(nameEl.value.trim().length >= 1 && haveAvatar);
} else if (step === 2) {
// ≥1 path required — unless none could be offered (offline /
// empty content), where blocking would strand onboarding.
submit.disabled = pathsAvailable && selectedPaths.length < 1;
} else {
submit.disabled = false;
}
}
function setStep(n) {
step = n;
errEl.classList.add('hidden');
for (let i = 1; i <= 3; i++) {
overlay.querySelector('#v3-ob-step' + i).classList.toggle('hidden', i !== n);
}
overlay.querySelectorAll('#v3-ob-dots [data-dot]').forEach((d) => {
d.classList.toggle('bg-fb-primary', Number(d.getAttribute('data-dot')) <= n);
d.classList.toggle('bg-fb-border', Number(d.getAttribute('data-dot')) > n);
});
const subtitle = overlay.querySelector('#v3-ob-subtitle');
if (subtitle) {
subtitle.textContent = n === 1 ? 'Set up your player profile'
: n === 2 ? 'Choose your instrument paths'
: 'One last thing — calibrate your setup';
}
submit.textContent = n === 3 ? 'Play it now' : 'Next';
skipBtn.classList.toggle('hidden', n !== 3);
refreshSubmit();
}
async function loadPathTiles() {
const host = overlay.querySelector('#v3-ob-paths');
let available = [];
try {
const r = await fetch('/api/progression');
if (r.ok) {
const data = await r.json();
diagnosticFilename = (data.onboarding || {}).diagnostic_filename || null;
available = (data.available_paths || []).concat(
(data.paths || []).map((p) => ({ id: p.id, name: p.name })));
}
} catch (e) { /* offline — leave empty, Next stays disabled */ }
pathsAvailable = available.length > 0;
host.innerHTML = available.map((p) =>
'<button type="button" data-ob-path="' + esc(p.id) + '" ' +
'class="rounded-lg border border-fb-border/50 bg-fb-bg/40 px-3 py-4 text-sm font-medium text-fb-text ' +
'hover:border-fb-primary/50 transition">' + esc(p.name) + '</button>').join('') ||
'<p class="text-sm text-fb-textDim col-span-3">Couldnt load instrument paths — you can pick them later on the Progress screen.</p>';
refreshSubmit();
host.querySelectorAll('[data-ob-path]').forEach((b) => {
b.addEventListener('click', () => {
const id = b.getAttribute('data-ob-path');
const idx = selectedPaths.indexOf(id);
if (idx >= 0) selectedPaths.splice(idx, 1); else selectedPaths.push(id);
b.classList.toggle('ring-2', idx < 0);
b.classList.toggle('ring-fb-primary', idx < 0);
refreshSubmit();
});
});
}
nameEl.addEventListener('input', refreshSubmit);
// Reflect the pre-filled name + existing avatar immediately so an edit
// with no changes can still Save (button doesn't stay disabled until
// the user touches the form).
refreshSubmit();
function selectTile(el, choice) {
selected = choice;
grid.querySelectorAll('[data-av]').forEach((t) => t.classList.remove('ring-2', 'ring-fb-primary'));
if (el) el.classList.add('ring-2', 'ring-fb-primary');
refreshSubmit();
}
// Load bundled defaults. The default avatar's stored value is its
// bundled FILENAME (e.g. "pick.svg"), which the server validates
// against the bundled set.
fetch('/api/profile/avatars').then((r) => r.ok ? r.json() : []).then((list) => {
grid.innerHTML = (list || []).map((a) =>
'<button type="button" data-av data-name="' + esc(a.name) + '" data-url="' + esc(a.url) + '" ' +
'class="aspect-square rounded-lg overflow-hidden bg-fb-bg/40 hover:ring-2 hover:ring-fb-primary/50 transition">' +
'<img src="' + esc(a.url) + '" alt="' + esc(a.name) + '" class="w-full h-full object-cover"></button>').join('');
grid.querySelectorAll('[data-av]').forEach((b) => {
b.addEventListener('click', () => selectTile(b, { type: 'default', value: b.dataset.name }));
});
// Pre-select the current avatar when editing.
if (editing && profile && profile.avatar_url) {
const match = Array.from(grid.querySelectorAll('[data-av]')).find((b) => b.dataset.url === profile.avatar_url);
if (match) selectTile(match, { type: 'default', value: match.dataset.name });
}
}).catch(() => { /* offline / server restart — leave the avatar grid empty rather than throw */ });
// Upload handler — base64 to /api/profile/avatar (mirrors art upload).
overlay.querySelector('#v3-ob-upload-btn').addEventListener('click', () => fileEl.click());
fileEl.addEventListener('change', () => {
const f = fileEl.files && fileEl.files[0];
if (!f) return;
if (f.size > 6 * 1024 * 1024) { showErr('Image too large (max 6 MB).'); return; }
const reader = new FileReader();
reader.onload = async () => {
try {
const res = await fetch('/api/profile/avatar', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: reader.result }),
});
const body = await res.json();
if (!res.ok) { showErr(body.error || 'Upload failed.'); return; }
preview.innerHTML = '<img src="' + esc(body.url) + '" class="w-10 h-10 rounded-full object-cover inline-block align-middle">';
grid.querySelectorAll('[data-av]').forEach((t) => t.classList.remove('ring-2', 'ring-fb-primary'));
selected = { type: 'upload', value: body.url };
refreshSubmit();
} catch (e) { showErr('Upload failed.'); }
};
reader.readAsDataURL(f);
});
function showErr(msg) { errEl.textContent = msg; errEl.classList.remove('hidden'); }
if (editing) overlay.querySelector('#v3-ob-cancel')?.addEventListener('click', () => overlay.remove());
async function postProfile() {
const res = await fetch('/api/profile', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: nameEl.value.trim(), avatar: selected || undefined }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error || 'Could not save profile.');
return body;
}
async function finish() {
overlay.remove();
await fetchProgress();
if (window.v3Progression && typeof window.v3Progression.refresh === 'function') {
await window.v3Progression.refresh();
}
renderBadge();
renderProfileScreen();
if (window.slopsmith && window.slopsmith.emit) window.slopsmith.emit('v3:profile-updated', _profile);
}
submit.addEventListener('click', async () => {
errEl.classList.add('hidden');
if (editing) {
submit.disabled = true;
try {
_profile = await postProfile();
await finish();
} catch (e) { showErr(e.message || 'Could not save profile.'); submit.disabled = false; }
return;
}
if (step === 1) {
setStep(2);
loadPathTiles();
return;
}
if (step === 2) {
// Create the profile (onboarded=1) BEFORE the calibration choice
// so closing the overlay at step 3 can never lose the profile.
submit.disabled = true;
try {
_profile = await postProfile();
if (selectedPaths.length) {
// A failed path save must NOT advance — step 3's skip
// requires ≥1 selected path (spec invariant) and would
// otherwise leave a pathless rank-1 profile.
const res = await fetch('/api/progression/paths', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ add: selectedPaths }),
});
if (!res.ok) {
let msg = 'Could not save your instrument paths — try again.';
try { msg = (await res.json()).error || msg; } catch (e) { /* keep default */ }
throw new Error(msg);
}
}
setStep(3);
} catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); }
return;
}
// Step 3 — "Play it now": leave calibration pending (it completes
// through the normal scored-stats path) and launch the diagnostic.
const target = diagnosticFilename;
await finish();
if (target && typeof window.playSong === 'function') window.playSong(target);
});
skipBtn.addEventListener('click', async () => {
// Step 3 — skip: Mastery Rank 1 immediately, calibration stays
// replayable from the Progress screen.
skipBtn.disabled = true;
try {
const res = await fetch('/api/progression/onboarding', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'skip' }),
});
if (!res.ok) { skipBtn.disabled = false; return; }
} catch (e) { /* offline — skippable later from Progress */ }
await finish();
});
if (!editing) setStep(1);
setTimeout(() => nameEl.focus(), 50);
}
window.v3Onboarding = { show: show };
window.v3Profile = {
refresh: async function () { await fetchProfile(); await fetchProgress(); renderBadge(); renderProfileScreen(); },
get: () => _profile,
};
async function boot() {
await fetchProfile();
await fetchProgress();
renderBadge();
renderProfileScreen();
// Rank / dB / frames re-render whenever progression state or equipped
// cosmetics move (progression-core.js / theme-core.js own the fetches).
if (window.slopsmith && typeof window.slopsmith.on === 'function') {
window.slopsmith.on('progression:updated', () => { renderBadge(); renderProfileScreen(); });
window.slopsmith.on('v3:cosmetics-applied', () => { renderBadge(); renderProfileScreen(); });
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})();
+328
View File
@@ -0,0 +1,328 @@
/*
* fee[dB]ack v0.3.0 — Progress screen (spec 010).
*
* Mastery Rank hero, per-path challenge checklists ("x of y to level up"),
* the calibration challenge card, add-a-path tiles, daily/weekly quests with
* reset countdowns, and the Decibels balance with a shop link. State comes
* from window.v3Progression (progression-core.js); re-renders on
* `progression:updated` and on screen activation.
*
* Vanilla JS, no framework (constitution P-II).
*/
(function () {
'use strict';
const sm = window.slopsmith;
const SCREEN_ID = 'v3-progress';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const fmtDb = (n) => Number(n || 0).toLocaleString() + ' dB';
function resetsIn(iso) {
const t = new Date(iso).getTime();
if (!Number.isFinite(t)) return '';
let mins = Math.max(0, Math.round((t - Date.now()) / 60000));
const days = Math.floor(mins / 1440); mins -= days * 1440;
const hours = Math.floor(mins / 60); mins -= hours * 60;
if (days > 0) return 'resets in ' + days + 'd ' + hours + 'h';
if (hours > 0) return 'resets in ' + hours + 'h ' + mins + 'm';
return 'resets in ' + mins + 'm';
}
function progressBar(count, target, done) {
const pct = target > 0 ? Math.min(100, Math.round((count / target) * 100)) : 0;
return '<div class="w-full h-1.5 rounded-full bg-black/40 overflow-hidden">' +
'<span class="block h-full ' + (done ? 'bg-fb-good' : 'bg-fb-primary') + '" style="width:' + pct + '%"></span></div>';
}
const checkIcon = '<svg class="w-5 h-5 text-fb-good shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>';
function challengeRow(c) {
return '<div class="flex items-start gap-3 py-2">' +
(c.completed
? checkIcon
: '<span class="w-5 h-5 rounded-full border-2 border-fb-border shrink-0 mt-0.5"></span>') +
'<div class="flex-1 min-w-0">' +
'<div class="flex items-baseline justify-between gap-2">' +
'<span class="text-sm font-medium ' + (c.completed ? 'text-fb-textDim line-through' : 'text-fb-text') + '">' + esc(c.title) + '</span>' +
'<span class="text-xs text-fb-textDim shrink-0">' + c.count + '/' + c.target + '</span></div>' +
'<p class="text-xs text-fb-textDim mt-0.5">' + esc(c.description) + '</p>' +
(c.completed ? '' : '<div class="mt-1.5">' + progressBar(c.count, c.target, false) + '</div>') +
'</div></div>';
}
function pathCard(p) {
let body;
if (p.next) {
const remaining = Math.max(0, p.next.required - p.next.completed);
body = '<p class="text-xs text-fb-textDim mb-1">Level ' + p.next.level + ' — complete ' +
'<span class="text-fb-text font-semibold">' + remaining + '</span> more challenge' + (remaining === 1 ? '' : 's') +
' (' + p.next.completed + ' of ' + p.next.required + ' done)</p>' +
'<div class="divide-y divide-fb-border/30">' + p.next.challenges.map(challengeRow).join('') + '</div>';
} else {
body = '<p class="text-sm text-fb-gold font-semibold mt-1">Path mastered — max level reached!</p>';
}
return '<div class="bg-fb-card/80 backdrop-blur rounded-xl p-5 border border-fb-border/50">' +
'<div class="flex items-center justify-between mb-3">' +
'<h3 class="text-lg font-bold text-fb-text">' + esc(p.name) + '</h3>' +
'<span class="text-sm font-semibold text-fb-primary">Level ' + p.level + '<span class="text-fb-textDim font-normal"> / ' + p.max_level + '</span></span>' +
'</div>' + body + '</div>';
}
function questRow(q) {
return '<div class="flex items-start gap-3 py-2">' +
(q.completed
? checkIcon
: '<span class="w-5 h-5 rounded-full border-2 border-fb-border shrink-0 mt-0.5"></span>') +
'<div class="flex-1 min-w-0">' +
'<div class="flex items-baseline justify-between gap-2">' +
'<span class="text-sm font-medium ' + (q.completed ? 'text-fb-textDim line-through' : 'text-fb-text') + '">' + esc(q.title) + '</span>' +
'<span class="text-xs font-semibold text-fb-gold shrink-0">+' + Number(q.reward_db || 0).toLocaleString() + ' dB</span></div>' +
'<p class="text-xs text-fb-textDim mt-0.5">' + esc(q.description) + ' <span class="text-fb-textDim/70">(' + q.count + '/' + q.target + ')</span></p>' +
(q.completed ? '' : '<div class="mt-1.5">' + progressBar(q.count, q.target, false) + '</div>') +
'</div></div>';
}
function questCard(title, block) {
if (!block) return '';
const rows = (block.quests || []).map(questRow).join('') ||
'<p class="text-sm text-fb-textDim py-2">No quests available.</p>';
return '<div class="bg-fb-card/80 backdrop-blur rounded-xl p-5 border border-fb-border/50">' +
'<div class="flex items-center justify-between mb-1">' +
'<h3 class="text-lg font-bold text-fb-text">' + title + '</h3>' +
'<span class="text-xs text-fb-textDim">' + esc(resetsIn(block.resets_at)) + '</span></div>' +
'<div class="divide-y divide-fb-border/30">' + rows + '</div></div>';
}
function calibrationCard(onboarding) {
if (!onboarding || onboarding.calibration_status === 'completed') return '';
const pending = onboarding.calibration_status === 'pending';
return '<div class="bg-fb-card/80 backdrop-blur rounded-xl p-5 border ' +
(pending ? 'border-fb-primary/50' : 'border-fb-border/50') + '">' +
'<div class="flex items-center justify-between gap-3 flex-wrap">' +
'<div class="min-w-0">' +
'<h3 class="text-lg font-bold text-fb-text">Calibration challenge</h3>' +
'<p class="text-sm text-fb-textDim mt-1">Play the <span class="text-fb-text">Slopsmith Diagnostic</span> with note detection and finish at ' +
'<span class="text-fb-text font-semibold">100% accuracy</span>' +
(pending ? ' to reach Mastery Rank 1.' : ' to prove your setup (you skipped this — rank already granted).') + '</p></div>' +
'<div class="flex items-center gap-2 shrink-0">' +
'<button type="button" data-prog-calibrate class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md transition-colors">Play it now</button>' +
(pending ? '<button type="button" data-prog-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip for now</button>' : '') +
'</div></div></div>';
}
function addPathCard(available) {
if (!available || !available.length) return '';
return '<div class="bg-fb-card/80 backdrop-blur rounded-xl p-5 border border-fb-border/50 border-dashed">' +
'<h3 class="text-sm font-semibold text-fb-textDim uppercase tracking-wider mb-3">Start a new path</h3>' +
'<div class="flex flex-wrap gap-2">' +
available.map((p) =>
'<button type="button" data-prog-add-path="' + esc(p.id) + '" ' +
'class="bg-fb-bg/40 hover:bg-fb-card border border-fb-border/50 hover:border-fb-primary/50 text-fb-text text-sm font-medium px-4 py-2 rounded-lg transition-colors">+ ' + esc(p.name) + '</button>'
).join('') + '</div></div>';
}
function render() {
const root = document.getElementById(SCREEN_ID);
if (!root) return;
const st = window.v3Progression && window.v3Progression.get();
if (!st) {
root.innerHTML = '<div class="max-w-5xl mx-auto p-6 md:p-8"><p class="text-sm text-fb-textDim">Loading progress…</p></div>';
return;
}
const wallet = st.wallet || { balance: 0, lifetime_db: 0 };
root.innerHTML =
'<div class="max-w-5xl mx-auto p-6 md:p-8 space-y-6">' +
// Hero: rank + wallet
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50 flex flex-col sm:flex-row items-center gap-6">' +
'<div class="text-center sm:text-left flex-1">' +
'<div class="text-xs uppercase tracking-wider text-fb-textDim">Mastery Rank</div>' +
'<div class="text-6xl font-extrabold text-fb-text leading-none mt-1">' + st.mastery_rank + '</div>' +
'<p class="text-xs text-fb-textDim mt-2">Onboarding' +
((st.onboarding || {}).calibration_status === 'pending' ? ' (0)' : ' (1)') +
(st.paths || []).map((p) => ' + ' + esc(p.name) + ' (' + p.level + ')').join('') + '</p></div>' +
'<div class="text-center sm:text-right">' +
'<div class="text-xs uppercase tracking-wider text-fb-textDim">Decibels</div>' +
'<div class="text-3xl font-bold text-fb-gold mt-1">' + fmtDb(wallet.balance) + '</div>' +
'<div class="text-xs text-fb-textDim mt-1">' + fmtDb(wallet.lifetime_db) + ' earned lifetime</div>' +
'<button type="button" data-prog-shop class="mt-2 text-sm text-fb-primary hover:text-fb-primaryHi font-medium">Open Shop →</button>' +
'</div></div>' +
calibrationCard(st.onboarding) +
// Paths
((st.paths || []).length
? '<div class="grid md:grid-cols-2 gap-4">' + st.paths.map(pathCard).join('') + '</div>'
: '<div class="bg-fb-card/80 rounded-xl p-5 border border-fb-border/50"><p class="text-sm text-fb-textDim">Pick an instrument path below to start earning Mastery Rank.</p></div>') +
addPathCard(st.available_paths) +
// Quests
'<div class="grid md:grid-cols-2 gap-4">' +
questCard('Daily quests', (st.quests || {}).daily) +
questCard('Weekly quests', (st.quests || {}).weekly) +
'</div></div>';
const shopBtn = root.querySelector('[data-prog-shop]');
if (shopBtn) shopBtn.addEventListener('click', () => window.showScreen && window.showScreen('v3-shop'));
const cal = root.querySelector('[data-prog-calibrate]');
if (cal) cal.addEventListener('click', () => {
const fn = (st.onboarding || {}).diagnostic_filename;
if (fn && typeof window.playSong === 'function') window.playSong(fn);
});
const skip = root.querySelector('[data-prog-skip]');
if (skip) skip.addEventListener('click', async () => {
try {
const res = await fetch('/api/progression/onboarding', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'skip' }),
});
if (res.ok) window.v3Progression && window.v3Progression.refresh();
} catch (e) { /* offline */ }
});
root.querySelectorAll('[data-prog-add-path]').forEach((b) => {
b.addEventListener('click', async () => {
b.disabled = true;
try {
await fetch('/api/progression/paths', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ add: [b.getAttribute('data-prog-add-path')] }),
});
} catch (e) { /* offline */ }
window.v3Progression && window.v3Progression.refresh();
});
});
}
function launchDiagnostic(onboarding) {
const fn = (onboarding || {}).diagnostic_filename;
if (fn && typeof window.playSong === 'function') window.playSong(fn);
}
function priorCalibrationStatus(detail) {
// Honor an explicit prior_calibration_status even when it is null (the
// emitter always includes it). Truthiness would treat null as "missing"
// and fall back to the cached state, which may already have flipped to
// 'completed' in racey delivery paths — suppressing the success overlay.
if (detail && Object.prototype.hasOwnProperty.call(detail, 'prior_calibration_status')) {
return detail.prior_calibration_status;
}
const st = (window.v3Progression && window.v3Progression.get()) || {};
return (st.onboarding || {}).calibration_status;
}
// ── Calibration success prompt ───────────────────────────────────────────
// Fired when progression reports calibration_completed (100% diagnostic).
// Reads prior onboarding status before async refresh lands so pending vs
// skipped copy stays accurate.
function showCalibrationSuccess(detail) {
if (document.getElementById('v3-calibration-success')) return;
document.getElementById('v3-calibration-retry')?.remove();
const prior = priorCalibrationStatus(detail || {});
if (prior === 'completed') return;
const pending = prior === 'pending';
const skipped = prior === 'skipped';
let body;
if (pending) {
body = 'You finished Basic Guitar Diagnostic at <span class="text-fb-text font-semibold">100% accuracy</span>. Mastery Rank 1 is ready.';
} else if (skipped) {
body = 'You finished Basic Guitar Diagnostic at <span class="text-fb-text font-semibold">100% accuracy</span>. Your input and note detection setup is verified.';
} else {
body = 'You finished Basic Guitar Diagnostic at <span class="text-fb-text font-semibold">100% accuracy</span>. Your setup is verified.';
}
const st = (window.v3Progression && window.v3Progression.get()) || {};
const onboarding = st.onboarding || {};
const overlay = document.createElement('div');
overlay.id = 'v3-calibration-success';
overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
overlay.innerHTML =
'<div class="bg-fb-card rounded-xl border border-fb-border/50 w-full max-w-md p-6 space-y-4 text-center">' +
'<div class="text-4xl text-fb-good">✓</div>' +
'<h3 class="text-xl font-bold text-fb-text">Setup verified!</h3>' +
'<p class="text-sm text-fb-textDim">' + body + '</p>' +
'<div class="flex items-center justify-center gap-3 flex-wrap">' +
'<button type="button" data-cal-success-continue class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-5 py-2 rounded-md transition-colors">Continue</button>' +
(skipped ? '<button type="button" data-cal-success-replay class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Play again</button>' : '') +
'</div></div>';
document.body.appendChild(overlay);
overlay.querySelector('[data-cal-success-continue]').addEventListener('click', () => overlay.remove());
const replay = overlay.querySelector('[data-cal-success-replay]');
if (replay) replay.addEventListener('click', () => {
overlay.remove();
launchDiagnostic(onboarding);
});
}
// ── Calibration retry prompt ─────────────────────────────────────────────
// Fired by stats-recorder when the diagnostic sloppak finished scored but
// below 100% (and calibration isn't completed yet): offer another go, and
// — while calibration is still pending — the skip-to-Rank-1 escape hatch.
function showCalibrationRetry(detail) {
document.getElementById('v3-calibration-retry')?.remove();
document.getElementById('v3-calibration-success')?.remove();
const st = (window.v3Progression && window.v3Progression.get()) || {};
const onboarding = st.onboarding || {};
if (onboarding.calibration_status === 'completed') return; // raced a 100% run
const pending = onboarding.calibration_status === 'pending';
const pct = Math.max(0, Math.min(100, Math.round((detail.accuracy || 0) * 100)));
const overlay = document.createElement('div');
overlay.id = 'v3-calibration-retry';
overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
overlay.innerHTML =
'<div class="bg-fb-card rounded-xl border border-fb-border/50 w-full max-w-md p-6 space-y-4 text-center">' +
'<div class="text-4xl">🎯</div>' +
'<h3 class="text-xl font-bold text-fb-text">' + (pct >= 90 ? 'So close!' : 'Calibration not passed') + '</h3>' +
'<p class="text-sm text-fb-textDim">You finished the calibration run at ' +
'<span class="text-fb-text font-semibold">' + pct + '%</span> — it takes ' +
'<span class="text-fb-text font-semibold">100%</span> to complete' +
(pending ? ' and reach Mastery Rank 1' : '') + '. Want another go?</p>' +
'<div class="flex items-center justify-center gap-3 flex-wrap">' +
'<button type="button" data-cal-retry class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-5 py-2 rounded-md transition-colors">Try again</button>' +
(pending ? '<button type="button" data-cal-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip — take Rank 1</button>' : '') +
'<button type="button" data-cal-close class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Not now</button>' +
'</div></div>';
document.body.appendChild(overlay);
overlay.querySelector('[data-cal-retry]').addEventListener('click', () => {
overlay.remove();
launchDiagnostic(onboarding);
});
const skip = overlay.querySelector('[data-cal-skip]');
if (skip) skip.addEventListener('click', async () => {
skip.disabled = true;
try {
await fetch('/api/progression/onboarding', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'skip' }),
});
} catch (e) { /* offline — still skippable from the Progress screen */ }
overlay.remove();
window.v3Progression && window.v3Progression.refresh();
});
overlay.querySelector('[data-cal-close]').addEventListener('click', () => overlay.remove());
}
function boot() {
render();
if (sm && typeof sm.on === 'function') {
sm.on('progression:updated', render);
sm.on('progression:calibration-attempt', (e) => showCalibrationRetry((e && e.detail) || {}));
sm.on('progression:calibration-completed', (e) => {
showCalibrationSuccess((e && e.detail) || {});
});
sm.on('screen:changed', (e) => {
if (e && e.detail && e.detail.id === SCREEN_ID) {
window.v3Progression && window.v3Progression.refresh();
render();
}
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})();
+201
View File
@@ -0,0 +1,201 @@
/*
* fee[dB]ack v0.3.0 — progression core (spec 010).
*
* Shared client state for /api/progression (mastery rank, paths/challenges,
* quests, Decibels wallet) AND the owner of the `progression` capability
* domain (kind: command, safety: safe), so plugins coordinate through the
* capability pipeline instead of private globals:
*
* inspect → the full progression state
* record-event {type, payload} → whitelisted event intake (minigame_run);
* song_completed is server-derived and denied
* list-shop / buy-item / equip-item
* (buy/equip require authorization:'user-action')
*
* Lifecycle events are emitted on the capability surface and mirrored on
* window.slopsmith as `progression:*` for non-capability consumers:
* challenge-completed, quest-completed, path-level-up, rank-changed,
* db-changed, calibration-completed, cosmetic-equipped (+ progression:updated
* whenever fresh state lands).
*
* Vanilla JS, no framework (constitution P-II).
*/
(function () {
'use strict';
const sm = window.slopsmith = window.slopsmith || {};
let _state = null; // last /api/progression payload
let _fetching = null; // in-flight refresh (coalesced)
function _emit(name, detail) {
const capabilities = sm.capabilities;
if (capabilities && capabilities.version === 1 && typeof capabilities.emitEvent === 'function') {
try { capabilities.emitEvent('progression', name, detail || {}); } catch (e) { /* non-fatal */ }
}
if (typeof sm.emit === 'function') {
try { sm.emit('progression:' + name, detail || {}); } catch (e) { /* non-fatal */ }
}
}
function _diff(prev, next) {
if (!prev || !next) return;
if (prev.mastery_rank !== next.mastery_rank) {
_emit('rank-changed', { from: prev.mastery_rank, to: next.mastery_rank });
}
const before = (prev.wallet || {}).balance;
const after = (next.wallet || {}).balance;
if (before !== after) _emit('db-changed', { from: before, to: after, wallet: next.wallet });
}
async function refresh() {
if (_fetching) return _fetching;
_fetching = (async () => {
try {
const r = await fetch('/api/progression');
if (r.ok) {
const prev = _state;
_state = await r.json();
_diff(prev, _state);
_contributeDiagnostics();
if (typeof sm.emit === 'function') sm.emit('progression:updated', _state);
}
} catch (e) { /* offline — keep last-known state */ }
_fetching = null;
return _state;
})();
return _fetching;
}
// Fan a record-event / stats outcome summary out as lifecycle events, then
// refresh the cached state. Anything that receives a summary payload
// (stats-recorder, the minigames hub, the events command) feeds this.
function notify(summary) {
if (summary && typeof summary === 'object') {
(summary.challenges_completed || []).forEach((c) => _emit('challenge-completed', c));
(summary.quests_completed || []).forEach((q) => _emit('quest-completed', q));
(summary.level_ups || []).forEach((l) => _emit('path-level-up', l));
if (summary.calibration_completed) {
// Capture the pre-completion status NOW, before refresh() below
// flips the cached calibration_status to 'completed'. The handler
// prefers detail.prior_calibration_status over the cache, so this
// keeps the success modal correct even if the event is delivered
// after a refresh has already landed.
const prior = (_state && _state.onboarding && _state.onboarding.calibration_status) || null;
_emit('calibration-completed', { prior_calibration_status: prior });
}
}
return refresh(); // rank-changed / db-changed fall out of the diff
}
async function _post(url, body) {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
});
let data = {};
try { data = await r.json(); } catch (e) { /* empty body */ }
return { ok: r.ok, status: r.status, data };
}
// ── Diagnostics (redaction-safe: counts + totals only, no song names) ────
function _contributeDiagnostics() {
const diagnostics = sm.diagnostics;
if (!diagnostics || typeof diagnostics.contribute !== 'function' || !_state) return;
try {
diagnostics.contribute('progression', {
schema: 'slopsmith.progression.diag.v1',
mastery_rank: _state.mastery_rank,
calibration_status: (_state.onboarding || {}).calibration_status,
paths: (_state.paths || []).map((p) => ({ id: p.id, level: p.level, max_level: p.max_level })),
quests: {
daily: ((_state.quests || {}).daily || {}).quests
? _state.quests.daily.quests.filter((q) => q.completed).length + '/' + _state.quests.daily.quests.length
: null,
weekly: ((_state.quests || {}).weekly || {}).quests
? _state.quests.weekly.quests.filter((q) => q.completed).length + '/' + _state.quests.weekly.quests.length
: null,
},
wallet: _state.wallet || null,
});
} catch (e) { /* diagnostics must never break progression */ }
}
// ── Capability domain owner ──────────────────────────────────────────────
const _handled = (payload) => ({ outcome: 'handled', payload: payload || {} });
const _denied = (reason, payload) => ({ outcome: 'denied', reason, payload: payload || {} });
const _failed = (reason) => ({ outcome: 'failed', reason });
function _registerOwner() {
const capabilities = sm.capabilities;
if (!capabilities || capabilities.version !== 1 || typeof capabilities.registerOwner !== 'function') return;
capabilities.registerOwner('progression', {
pluginId: 'core.progression',
kind: 'command',
ownership: 'exclusive-owner',
safety: 'safe',
commands: ['inspect', 'record-event', 'list-shop', 'buy-item', 'equip-item'],
events: ['challenge-completed', 'quest-completed', 'path-level-up', 'rank-changed',
'db-changed', 'calibration-completed', 'cosmetic-equipped'],
description: 'Owns player progression: mastery rank, instrument-path challenges, daily/weekly quests, the Decibels wallet, and the cosmetics shop.',
handlers: {
inspect: async () => _handled((await refresh()) || {}),
'record-event': async (ctx) => {
const payload = (ctx && ctx.payload) || {};
try {
const r = await _post('/api/progression/events',
{ type: payload.type, payload: payload.payload || {} });
if (!r.ok) return _denied(r.data.error || ('HTTP ' + r.status));
notify(r.data.progression);
return _handled(r.data.progression);
} catch (e) { return _failed('progression event intake unreachable'); }
},
'list-shop': async () => {
try {
const r = await fetch('/api/shop');
if (!r.ok) return _failed('HTTP ' + r.status);
return _handled(await r.json());
} catch (e) { return _failed('shop unreachable'); }
},
'buy-item': async (ctx) => {
if (!ctx || ctx.authorization !== 'user-action') {
return _denied('buy-item requires authorization: user-action');
}
try {
const r = await _post('/api/shop/buy', { item_id: (ctx.payload || {}).item_id });
if (!r.ok) return _denied(r.data.error || ('HTTP ' + r.status), r.data);
refresh();
return _handled(r.data);
} catch (e) { return _failed('shop unreachable'); }
},
'equip-item': async (ctx) => {
if (!ctx || ctx.authorization !== 'user-action') {
return _denied('equip-item requires authorization: user-action');
}
const payload = ctx.payload || {};
try {
const r = await _post('/api/shop/equip',
{ slot: payload.slot, item_id: payload.item_id == null ? null : payload.item_id });
if (!r.ok) return _denied(r.data.error || ('HTTP ' + r.status), r.data);
_emit('cosmetic-equipped', { slot: payload.slot, item_id: payload.item_id == null ? null : payload.item_id });
return _handled(r.data);
} catch (e) { return _failed('shop unreachable'); }
},
},
});
}
// ── Public API + boot ────────────────────────────────────────────────────
window.v3Progression = {
refresh,
notify,
get: () => _state,
};
_registerOwner();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => { refresh(); }, { once: true });
} else {
refresh();
}
})();
+344
View File
@@ -0,0 +1,344 @@
/*
* fee[dB]ack v0.3.0 — app shell: sidebar, topbar, client routing.
*
* Vanilla JS, no framework/bundler (constitution P-II). Reuses the engine
* from app.js: navigation is the shared window.showScreen across both the new
* #v3-* screens and the reused legacy/#plugin-* screens. UI placement is a
* DEFERRED capability domain, so plugin nav/screens are consumed via the
* legacy plugin loader + /api/plugins, NOT via capability dispatch
* (design/05-capability-pipelines.md). All v3 UI state uses the `v3:` prefix.
*/
(function () {
'use strict';
// HTML-escape untrusted strings (plugin manifest id/label) before they go
// into innerHTML, so a hostile/buggy manifest can't inject markup or event
// attributes into the sidebar plugin nav.
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// ── Navigation registry ────────────────────────────────────────────────
// Each entry maps a stable hash key → a screen id (showScreen target) and a
// label. Legacy screens are reused: "Songs" = #home (library), "Favorites"
// = #favorites, "Settings" = #settings. New screens use #v3-* ids.
const NAV = [
{ key: 'home', screen: 'v3-home', label: 'Home', group: 'HOME', icon: 'home' },
{ key: 'progress', screen: 'v3-progress', label: 'Progress', group: 'HOME', icon: 'trophy' },
{ key: 'shop', screen: 'v3-shop', label: 'Shop', group: 'HOME', icon: 'tag' },
{ key: 'feedbarcade', screen: 'v3-feedbarcade', label: 'FeedBarcade', group: 'HOME', icon: 'arcade' },
{ key: 'plugins', screen: 'v3-plugins', label: 'Plugins', group: 'HOME', icon: 'plug' },
{ key: 'settings', screen: 'settings', label: 'Settings', group: 'HOME', icon: 'gear' },
{ key: 'playlists', screen: 'v3-playlists', label: 'Playlists', group: 'LIBRARY', icon: 'list' },
{ key: 'songs', screen: 'v3-songs', label: 'Songs', group: 'LIBRARY', icon: 'disc' },
{ key: 'lessons', screen: 'v3-lessons', label: 'Lessons', group: 'LIBRARY', icon: 'lessons' },
{ key: 'favorites', screen: 'favorites', label: 'Favorites', group: 'LIBRARY', icon: 'star' },
{ key: 'saved', screen: 'v3-saved', label: 'Saved for Later', group: 'LIBRARY', icon: 'bookmark' },
// Not in the sidebar groups, but routable (profile badge → here).
{ key: 'profile', screen: 'v3-profile', label: 'Profile', group: null, icon: 'user' },
];
const TOPBAR_KEYS = ['home', 'songs', 'plugins', 'settings'];
const SIDEBAR_GROUPS = ['HOME', 'LIBRARY'];
// Minimal inline icon set (currentColor 24x24 stroke paths).
const ICONS = {
home: 'M3 11l9-8 9 8M5 10v10h5v-6h4v6h5V10',
plug: 'M9 7V3m6 4V3M7 7h10v4a5 5 0 01-10 0V7zm5 9v5',
gear: 'M12 9a3 3 0 100 6 3 3 0 000-6zm8.4 3a8.4 8.4 0 00-.1-1.3l2-1.6-2-3.4-2.4 1a8 8 0 00-2.2-1.3l-.4-2.6H9.7l-.4 2.6A8 8 0 007.1 6l-2.4-1-2 3.4 2 1.6a8.4 8.4 0 000 2.6l-2 1.6 2 3.4 2.4-1a8 8 0 002.2 1.3l.4 2.6h4.6l.4-2.6a8 8 0 002.2-1.3l2.4 1 2-3.4-2-1.6c.1-.4.1-.9.1-1.3z',
list: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
disc: 'M12 3a9 9 0 100 18 9 9 0 000-18zm0 6a3 3 0 100 6 3 3 0 000-6z',
star: 'M12 3l2.9 6 6.6.9-4.8 4.6 1.1 6.5L12 18.8 6.2 21l1.1-6.5L2.5 9.9 9.1 9 12 3z',
bookmark: 'M6 3h12a1 1 0 011 1v17l-7-4-7 4V4a1 1 0 011-1z',
user: 'M12 12a4 4 0 100-8 4 4 0 000 8zm-7 8a7 7 0 0114 0',
arcade: 'M7 8h10a4 4 0 014 4 4 4 0 01-4 4H7a4 4 0 01-4-4 4 4 0 014-4zm0 4h3m-1.5-1.5v3M15 11h.01M17.5 13h.01',
lessons: 'M12 4L2 9l10 5 10-5-10-5zM6 11.5V16c0 1 2.7 2.5 6 2.5s6-1.5 6-2.5v-4.5',
trophy: 'M8 21h8m-4-4v4m-6-17h12v5a6 6 0 01-12 0V4zm12 2h2a2 2 0 01-2 4M6 6H4a2 2 0 002 4',
tag: 'M20.6 13.4l-7.2 7.2a2 2 0 01-2.8 0l-7-7V4h9.6l7.4 7.4a2 2 0 010 2zM7.5 7.5h.01',
};
function iconSvg(name) {
const d = ICONS[name] || ICONS.disc;
return '<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" ' +
'viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="' + d + '"/></svg>';
}
const byKey = (k) => NAV.find((n) => n.key === k);
const byScreen = (s) => NAV.find((n) => n.screen === s);
const currentScreenId = () => (document.querySelector('.screen.active') || {}).id || 'v3-home';
// The topbar IS each page's header row, so its title mirrors the screen.
function titleFor(screenId) {
if (screenId === 'v3-home') {
const p = (window.v3Profile && window.v3Profile.get && window.v3Profile.get()) || null;
return 'Welcome back, ' + ((p && p.display_name) || 'there') + '!';
}
const entry = byScreen(screenId);
if (entry) return entry.label;
if (screenId && screenId.indexOf('plugin-') === 0) return 'Plugins';
return '';
}
function setTopbarTitle(text) {
const el = document.getElementById('v3-topbar-title');
if (el) el.textContent = text || ''; // textContent → no escaping needed
}
// ── Active-state sync ───────────────────────────────────────────────────
function syncActive(screenId) {
const entry = byScreen(screenId);
document.querySelectorAll('[data-v3-nav]').forEach((el) => {
const on = entry && el.getAttribute('data-v3-nav') === entry.key;
el.classList.toggle('bg-fb-card', on);
el.classList.toggle('text-fb-text', on);
el.classList.toggle('text-fb-textDim', !on);
});
setTopbarTitle(titleFor(screenId));
// NOTE: we deliberately do NOT reflect the screen into location.hash on
// every navigation. app.js's audio 'error' handler suppresses empty-src
// errors only when `audio.src === window.location.href`; a `#/...`
// fragment makes href differ from the fragment-less resolved empty src,
// so screen-switch audio cleanup would log a spurious media error (and
// pollute the diagnostics console capture). Deep-linking IN is still
// supported on load (see boot()); live reflection OUT is intentionally
// omitted — it's optional per the prompt, console cleanliness is not.
}
// ── Navigation ──────────────────────────────────────────────────────────
function go(screenId) {
if (typeof window.showScreen !== 'function') return;
// Guard plugin screens that may not be injected yet (loadPlugins runs
// async at app.js boot). showScreen() throws on a missing element.
if (screenId.indexOf('plugin-') === 0 && !document.getElementById(screenId)) {
window.showScreen('v3-plugins');
return;
}
window.showScreen(screenId); // wrapper below re-syncs active state
closeMobileSidebar();
}
// ── Sidebar ───────────────────────────────────────────────────────────--
function navItemHTML(entry) {
return '<a href="#/' + entry.key + '" data-v3-nav="' + entry.key + '" ' +
'class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-fb-textDim ' +
'hover:text-fb-text hover:bg-fb-card/50 transition-colors">' +
iconSvg(entry.icon) + '<span>' + entry.label + '</span></a>';
}
function renderSidebar() {
const nav = document.getElementById('v3-nav');
if (!nav) return;
let html = '';
for (const group of SIDEBAR_GROUPS) {
const items = NAV.filter((n) => n.group === group);
if (!items.length) continue;
html += '<div><div class="px-3 mb-1 text-[10px] uppercase tracking-wider font-semibold text-fb-textDim/70">' +
group + '</div><div class="space-y-0.5">' + items.map(navItemHTML).join('') + '</div></div>';
}
// Plugins group is appended later by renderPluginNav().
html += '<div id="v3-nav-plugins"></div>';
nav.innerHTML = html;
nav.querySelectorAll('a[data-v3-nav]').forEach((a) => {
a.addEventListener('click', (e) => {
e.preventDefault();
go(byKey(a.getAttribute('data-v3-nav')).screen);
});
});
}
// ── Topbar ───────────────────────────────────────────────────────────---
const PATREON_URL = 'https://patreon.com';
function renderTopbar() {
const bar = document.getElementById('v3-topbar');
if (!bar) return;
bar.className = 'sticky top-0 z-20 bg-fb-sidebar/80 backdrop-blur';
bar.innerHTML =
// Row 1 — top utility bar: search + Support Us! (stay here, NOT on
// the title row).
'<div class="flex items-center gap-4 px-4 md:px-8 pt-4">' +
'<button id="v3-hamburger" class="md:hidden text-fb-textDim hover:text-fb-text shrink-0" aria-label="Menu">' +
'<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/></svg></button>' +
'<div class="flex-1 max-w-md relative">' +
'<svg class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-fb-textDim" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path stroke-linecap="round" d="M21 21l-4-4"/></svg>' +
'<input id="v3-search" type="search" placeholder="Search songs…" aria-label="Search songs" ' +
'class="w-full bg-gray-800/50 border border-gray-700 rounded-md pl-10 pr-4 py-2 text-sm ' +
'text-fb-text placeholder-fb-textDim focus:border-fb-primary focus:ring-1 focus:ring-fb-primary outline-none"></div>' +
'<a href="' + PATREON_URL + '" target="_blank" rel="noopener" class="ml-auto ' +
'hidden sm:inline-flex items-center gap-2 bg-fb-accent hover:bg-red-600 text-white text-sm font-medium px-4 py-2 rounded-md shadow-lg shadow-fb-accent/20 transition-colors">' +
'<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M14.8 3c-3 0-5.4 2.4-5.4 5.4S11.8 13.9 14.8 13.9 20.2 11.5 20.2 8.4 17.8 3 14.8 3zM3.8 3h3.4v18H3.8z"/></svg>' +
'Support Us!</a></div>' +
// Row 2 — page header: title + ONLY the tuner/instrument/profile
// badge cluster on the same line as the header.
'<div class="flex items-center gap-3 px-4 md:px-8 pt-2 pb-4">' +
'<h1 id="v3-topbar-title" class="text-2xl md:text-3xl font-bold text-fb-text truncate min-w-0 flex-1"></h1>' +
'<div class="flex items-center gap-2 shrink-0">' +
'<span id="v3-badge-tuner" class="contents"></span>' +
'<span id="v3-badge-instrument" class="contents"></span>' +
'<span id="v3-badge-profile" class="contents"></span>' +
'</div></div>';
const burger = document.getElementById('v3-hamburger');
if (burger) burger.addEventListener('click', toggleMobileSidebar);
setTopbarTitle(titleFor(currentScreenId()));
// Search drives the native Songs screen (prompt 21). Debounced so we
// don't refetch on every keystroke. Degrades silently if v3Songs isn't
// loaded yet.
const search = document.getElementById('v3-search');
if (search) {
let t = 0;
search.addEventListener('input', () => {
if (t) clearTimeout(t);
t = setTimeout(() => {
if (window.v3Songs && typeof window.v3Songs.search === 'function') window.v3Songs.search(search.value);
}, 250);
});
}
}
// ── Responsive sidebar ───────────────────────────────────────────────---
function ensureBackdrop() {
let bd = document.getElementById('v3-sidebar-backdrop');
if (!bd) {
bd = document.createElement('div');
bd.id = 'v3-sidebar-backdrop';
bd.className = 'fixed inset-0 bg-black/60 z-40 md:hidden hidden';
bd.addEventListener('click', closeMobileSidebar);
document.body.appendChild(bd);
}
return bd;
}
function toggleMobileSidebar() {
const sb = document.getElementById('v3-sidebar');
const bd = ensureBackdrop();
if (!sb) return;
const opening = sb.classList.contains('hidden');
sb.classList.toggle('hidden', !opening);
// On mobile, float the sidebar over content.
sb.classList.toggle('flex', opening);
sb.classList.toggle('fixed', opening);
sb.classList.toggle('inset-y-0', opening);
sb.classList.toggle('left-0', opening);
sb.classList.toggle('z-50', opening);
bd.classList.toggle('hidden', !opening);
}
function closeMobileSidebar() {
if (window.matchMedia && window.matchMedia('(min-width: 768px)').matches) return;
const sb = document.getElementById('v3-sidebar');
const bd = document.getElementById('v3-sidebar-backdrop');
if (sb) {
sb.classList.add('hidden');
sb.classList.remove('flex', 'fixed', 'inset-y-0', 'left-0', 'z-50');
}
if (bd) bd.classList.add('hidden');
}
// ── Plugin nav (legacy loader is the source; UI domain is deferred) ──────
async function renderPluginNav() {
const host = document.getElementById('v3-nav-plugins');
if (!host) return;
let plugins = [];
try {
const res = await fetch('/api/plugins');
if (res.ok) plugins = await res.json();
} catch (e) { return; } // degrade: no plugin group
const withNav = (Array.isArray(plugins) ? plugins : []).filter((p) => p && p.nav && (p.nav.label || p.name));
if (!withNav.length) return;
let html = '<div class="px-3 mt-2 mb-1 text-[10px] uppercase tracking-wider font-semibold text-fb-textDim/70">PLUGINS</div><div class="space-y-0.5">';
for (const p of withNav) {
const label = (p.nav && p.nav.label) || p.name || p.id;
html += '<a href="#" data-v3-plugin="' + esc(p.id) + '" ' +
'class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-fb-textDim hover:text-fb-text hover:bg-fb-card/50 transition-colors">' +
iconSvg('plug') + '<span class="truncate">' + esc(label) + '</span></a>';
}
html += '</div>';
host.innerHTML = html;
host.querySelectorAll('a[data-v3-plugin]').forEach((a) => {
a.addEventListener('click', (e) => {
e.preventDefault();
go('plugin-' + a.getAttribute('data-v3-plugin'));
});
});
}
// ── showScreen wrapper (idempotent rehydration — design/05 §Rehydration) ─
function installShowScreenHook() {
const hooks = window.__slopsmithV3ShellHooks || (window.__slopsmithV3ShellHooks = {});
hooks.syncActive = syncActive; // always point at the latest impl
if (hooks.installed) return;
hooks.installed = true;
hooks.baseShowScreen = window.showScreen;
window.showScreen = function (id) {
// Route every "go to the library" navigation to the v3 native Songs
// screen instead of the legacy #home library, so player-close,
// settings-back, the hidden legacy navbar, etc. all stay in v3.
const target = (id === 'home') ? 'v3-songs' : id;
const r = hooks.baseShowScreen ? hooks.baseShowScreen.call(this, target) : undefined;
try { hooks.syncActive && hooks.syncActive(target); } catch (e) { /* non-fatal */ }
return r;
};
}
// ── Boot ────────────────────────────────────────────────────────────────
async function boot() {
if (window.fbBrand) window.fbBrand.renderWordmark(document.getElementById('v3-brand'), { size: 'text-xl' });
renderSidebar();
renderTopbar();
ensureBackdrop();
installShowScreenHook();
renderPluginNav(); // async, non-blocking
// First-run gate: onboarding overlay is owned by prompt 15. Until it
// exists, degrade gracefully and go straight to the dashboard.
let profile = null;
try {
const res = await fetch('/api/profile');
if (res.ok) profile = await res.json();
} catch (e) { /* profile endpoint lands in prompt 15 */ }
if (profile && profile.onboarded === false && window.v3Onboarding && typeof window.v3Onboarding.show === 'function') {
try { window.v3Onboarding.show(profile); } catch (e) { /* fall through */ }
}
// Splitscreen pop-out windows (`?ssFollower=1`) get sent to
// showScreen('player') by app.js's bootstrap (app.js:9716) before
// this runs, exactly so the library doesn't flash on the popup.
// Don't undo that here — the splitscreen IIFE loads next and takes
// the popup the rest of the way into follower mode. Without this
// bail, the default 'v3-home' target below would re-activate the
// library screen and bring the flash back.
let isFollowerWindow = false;
try { isFollowerWindow = new URLSearchParams(location.search).get('ssFollower') === '1'; }
catch (_) { /* file:// or sandboxed iframe — fall through */ }
if (isFollowerWindow) {
syncActive('player');
} else {
// Deep-link IN on load: honor a #/key fragment, then strip it so
// subsequent screen-switch audio cleanup doesn't trip app.js's
// href-based empty-src guard (see syncActive). #v3-home is already
// `.active` in the HTML, so when that's the target we only sync the
// chrome — calling showScreen() redundantly would run its non-player
// teardown branch and log a spurious "Empty src" media error.
const m = (location.hash || '').match(/^#\/([\w-]+)/);
const entry = m && byKey(m[1]);
if (location.hash) {
try { history.replaceState(null, '', location.pathname + location.search); } catch (e) { /* file:// */ }
}
const target = entry ? entry.screen : 'v3-home';
const el = document.getElementById(target);
if (el && el.classList.contains('active')) {
syncActive(target);
} else {
go(target);
}
}
// The "Welcome back, {name}!" title needs the profile, which loads
// async — refresh once it's in (and whenever it changes).
function refreshHomeTitle() { if (currentScreenId() === 'v3-home') setTopbarTitle(titleFor('v3-home')); }
if (window.slopsmith && typeof window.slopsmith.on === 'function') {
window.slopsmith.on('v3:profile-updated', refreshHomeTitle);
}
setTimeout(refreshHomeTitle, 700);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})();
+211
View File
@@ -0,0 +1,211 @@
/*
* fee[dB]ack v0.3.0 — Shop screen (spec 010).
*
* Cosmetics catalog (themes + avatar frames) bought with Decibels earned by
* playing — there is deliberately NO real-money path anywhere in this screen.
* Buy/Equip dogfood the `progression` capability domain (buy-item/equip-item
* with authorization:'user-action'), falling back to direct fetch when the
* capability runtime is unavailable. Themes support a live preview via
* window.v3Theme.apply; leaving the screen restores the equipped look.
*
* Vanilla JS, no framework (constitution P-II).
*/
(function () {
'use strict';
const sm = window.slopsmith;
const SCREEN_ID = 'v3-shop';
let _data = null; // last GET /api/shop payload
let _previewing = null; // item id currently previewed (theme slot only)
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const fmtDb = (n) => Number(n || 0).toLocaleString() + ' dB';
async function load() {
try {
const r = await fetch('/api/shop');
if (r.ok) _data = await r.json();
} catch (e) { /* offline — keep stale */ }
return _data;
}
// ── Capability-first actions (fetch fallback) ────────────────────────────
async function _viaCapability(command, payload) {
const capabilities = sm && sm.capabilities;
if (capabilities && capabilities.version === 1) {
const result = await capabilities.command('progression', command, {
requester: 'core.shop-screen',
origin: 'user',
authorization: 'user-action',
reason: 'Shop screen user action',
payload,
});
return { ok: result.outcome === 'handled', reason: result.reason, payload: result.payload };
}
const url = command === 'buy-item' ? '/api/shop/buy' : '/api/shop/equip';
const body = command === 'buy-item'
? { item_id: payload.item_id }
: { slot: payload.slot, item_id: payload.item_id == null ? null : payload.item_id };
try {
const r = await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await r.json().catch(() => ({}));
return { ok: r.ok, reason: data.error, payload: data };
} catch (e) { return { ok: false, reason: 'offline' }; }
}
async function buy(itemId) {
const r = await _viaCapability('buy-item', { item_id: itemId });
if (!r.ok && r.reason) toast(r.reason);
await refresh();
}
async function equip(slot, itemId) {
_previewing = null;
const r = await _viaCapability('equip-item', { slot, item_id: itemId });
if (!r.ok && r.reason) toast(r.reason);
if (window.v3Theme) window.v3Theme.refresh();
if (window.v3Profile) window.v3Profile.refresh();
await refresh();
}
function toast(msg) {
const root = document.getElementById(SCREEN_ID);
const el = root && root.querySelector('[data-shop-toast]');
if (!el) return;
el.textContent = msg;
el.classList.remove('hidden');
setTimeout(() => el.classList.add('hidden'), 4000);
}
function previewTheme(item) {
if (!window.v3Theme) return;
if (_previewing === item.id) {
_previewing = null;
window.v3Theme.refresh(); // restore equipped look
} else {
_previewing = item.id;
window.v3Theme.apply(item.payload);
}
render();
}
function stopPreview() {
if (_previewing && window.v3Theme) {
_previewing = null;
window.v3Theme.refresh();
}
}
// ── Rendering ────────────────────────────────────────────────────────────
function swatches(item) {
const c = (item.payload && item.payload.colors) || {};
const picks = [c.bg, c.card, c.primary, c.accent, c.gold].filter(Boolean);
if (!picks.length) return '';
return '<div class="flex gap-1 mt-2">' + picks.map((hex) =>
'<span class="w-5 h-5 rounded-full border border-fb-border/50" style="background-color:' + esc(hex) + '"></span>').join('') + '</div>';
}
function frameDemo(item) {
const style = String((item.payload && item.payload.frame_style) || '').replace(/[{}<>"]/g, '');
return '<div class="mt-2"><span class="inline-block w-10 h-10 rounded-xl bg-fb-bg/40" style="' + esc(style) + '"></span></div>';
}
function itemCard(item, balance) {
const affordable = balance >= item.cost;
let actions = '';
if (item.equipped) {
actions = '<button type="button" data-shop-unequip="' + esc(item.slot) + '" ' +
'class="text-sm text-fb-textDim hover:text-fb-text px-3 py-1.5">Unequip</button>' +
'<span class="text-sm font-semibold text-fb-good px-3 py-1.5">Equipped</span>';
} else if (item.owned) {
actions = '<button type="button" data-shop-equip="' + esc(item.id) + '" data-slot="' + esc(item.slot) + '" ' +
'class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-1.5 rounded-md transition-colors">Equip</button>';
} else {
actions = '<button type="button" data-shop-buy="' + esc(item.id) + '" ' + (affordable ? '' : 'disabled ') +
'class="bg-fb-primary hover:bg-fb-primaryHi disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium px-4 py-1.5 rounded-md transition-colors">' +
'Buy · ' + fmtDb(item.cost) + '</button>';
}
if (item.slot === 'theme') {
actions = '<button type="button" data-shop-preview="' + esc(item.id) + '" ' +
'class="text-sm text-fb-primary hover:text-fb-primaryHi px-2 py-1.5">' +
(_previewing === item.id ? 'Stop preview' : 'Preview') + '</button>' + actions;
}
return '<div class="bg-fb-card/80 backdrop-blur rounded-xl p-5 border ' +
(item.equipped ? 'border-fb-good/50' : 'border-fb-border/50') + '">' +
'<div class="flex items-start justify-between gap-2">' +
'<div class="min-w-0"><h4 class="font-bold text-fb-text">' + esc(item.name) + '</h4>' +
'<p class="text-xs text-fb-textDim mt-0.5">' + esc(item.description) + '</p>' +
(item.slot === 'theme' ? swatches(item) : frameDemo(item)) + '</div>' +
(!item.owned ? '<span class="text-xs font-semibold text-fb-gold shrink-0">' + fmtDb(item.cost) + '</span>' : '') +
'</div>' +
'<div class="flex items-center justify-end gap-1 mt-3">' + actions + '</div></div>';
}
function section(title, items, balance) {
if (!items.length) return '';
return '<div><h3 class="text-lg font-bold text-fb-text mb-3">' + title + '</h3>' +
'<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">' +
items.map((i) => itemCard(i, balance)).join('') + '</div></div>';
}
function render() {
const root = document.getElementById(SCREEN_ID);
if (!root) return;
if (!_data) {
root.innerHTML = '<div class="max-w-5xl mx-auto p-6 md:p-8"><p class="text-sm text-fb-textDim">Loading shop…</p></div>';
return;
}
const wallet = _data.wallet || { balance: 0, lifetime_db: 0 };
const items = _data.items || [];
root.innerHTML =
'<div class="max-w-5xl mx-auto p-6 md:p-8 space-y-6">' +
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-5 border border-fb-border/50 flex items-center justify-between gap-4 flex-wrap">' +
'<div><div class="text-xs uppercase tracking-wider text-fb-textDim">Your Decibels</div>' +
'<div class="text-3xl font-bold text-fb-gold mt-1">' + fmtDb(wallet.balance) + '</div></div>' +
'<p class="text-xs text-fb-textDim max-w-xs text-right">Earn dB by playing songs, FeedBarcade rounds, and quests. Cosmetics only — never purchasable with money.</p>' +
'</div>' +
'<p data-shop-toast class="hidden text-sm text-fb-accent"></p>' +
section('Themes', items.filter((i) => i.slot === 'theme'), wallet.balance) +
section('Avatar frames', items.filter((i) => i.slot === 'avatar_frame'), wallet.balance) +
'</div>';
root.querySelectorAll('[data-shop-buy]').forEach((b) =>
b.addEventListener('click', () => buy(b.getAttribute('data-shop-buy'))));
root.querySelectorAll('[data-shop-equip]').forEach((b) =>
b.addEventListener('click', () => equip(b.getAttribute('data-slot'), b.getAttribute('data-shop-equip'))));
root.querySelectorAll('[data-shop-unequip]').forEach((b) =>
b.addEventListener('click', () => equip(b.getAttribute('data-shop-unequip'), null)));
root.querySelectorAll('[data-shop-preview]').forEach((b) =>
b.addEventListener('click', () => {
const item = (_data.items || []).find((i) => i.id === b.getAttribute('data-shop-preview'));
if (item) previewTheme(item);
}));
}
async function refresh() { await load(); render(); }
window.v3Shop = { refresh };
function boot() {
render();
if (sm && typeof sm.on === 'function') {
sm.on('screen:changed', (e) => {
const id = e && e.detail && e.detail.id;
if (id === SCREEN_ID) refresh();
else stopPreview(); // leaving the shop restores the equipped look
});
sm.on('progression:db-changed', () => {
if (document.getElementById(SCREEN_ID)?.classList.contains('active')) refresh();
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})();
+991
View File
@@ -0,0 +1,991 @@
/*
* fee[dB]ack v0.3.0 — Songs / Library (#v3-songs), native rebuild.
*
* A vanilla-JS library browser over the existing /api/library* endpoints:
* provider selector (via the `library` capability, not DOM scraping), grid +
* tree views, sort, format filter, a tri-state filter drawer (arrangements /
* stems / lyrics / tunings), search (driven by the topbar), infinite scroll,
* fb song cards with accuracy badges (song_stats), favorite + save-for-later,
* and upload. Reuses window.playSong for playback (design/05: library is an
* active capability domain; everything else stays on the documented globals).
*/
(function () {
'use strict';
const sm = window.slopsmith;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
const SORTS = [
['artist', 'Artist AZ'], ['artist-desc', 'Artist ZA'],
['title', 'Title AZ'], ['title-desc', 'Title ZA'],
['recent', 'Recently Added'], ['year-desc', 'Year (newest)'],
['year', 'Year (oldest)'], ['tuning', 'Tuning'],
];
const FORMATS = [['', 'All formats'], ['sloppak', 'Sloppak'], ['loose', 'Folder']];
const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];
const STEMS = ['guitar', 'bass', 'drums', 'vocals', 'other'];
const PAGE_SIZE = 24;
const SCROLL_STATE_KEY = 'v3:songs-scroll-state';
const btnCtrl = 'bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary';
const state = {
provider: 'local', view: 'grid', sort: 'artist', format: '', q: '',
artist: '', album: '',
filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [] },
page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [],
artistCatalog: [], renderedHash: '',
scrollBound: false,
songsById: {}, selectMode: false, selected: new Set(),
};
function activeFilterCount() {
const f = state.filters;
return f.arr_has.length + f.arr_lacks.length + f.stem_has.length + f.stem_lacks.length +
(f.lyrics ? 1 : 0) + f.tunings.length + (state.artist ? 1 : 0) + (state.album ? 1 : 0);
}
function _getV3MainScroller() { return document.getElementById('v3-main'); }
function buildLibraryStateHash(st) {
const f = (st && st.filters) || {};
return JSON.stringify({
view: st.view || 'grid',
q: st.q || '',
sort: st.sort || 'artist',
provider: st.provider || 'local',
format: st.format || '',
artist: st.artist || '',
album: st.album || '',
filters: {
arr_has: [...(f.arr_has || [])].sort(),
arr_lacks: [...(f.arr_lacks || [])].sort(),
stem_has: [...(f.stem_has || [])].sort(),
stem_lacks: [...(f.stem_lacks || [])].sort(),
lyrics: f.lyrics || '',
tunings: [...(f.tunings || [])].sort(),
},
});
}
function _libraryStateHash() { return buildLibraryStateHash(state); }
function _saveLibraryScrollSnapshot() {
const main = _getV3MainScroller();
const snap = {
hash: _libraryStateHash(),
scrollTop: main ? main.scrollTop : 0,
view: state.view,
page: state.page,
loadedCount: loadedCount(),
};
try { sessionStorage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap)); } catch (e) { /* quota / private mode */ }
}
function _readLibraryScrollSnapshot() {
try {
const raw = sessionStorage.getItem(SCROLL_STATE_KEY);
if (!raw) return null;
const snap = JSON.parse(raw);
return (snap && typeof snap === 'object') ? snap : null;
} catch (e) { return null; }
}
function _clearLibraryScrollSnapshot() {
try { sessionStorage.removeItem(SCROLL_STATE_KEY); } catch (e) { /* */ }
}
function _applyMainScrollTop(scrollTop) {
const main = _getV3MainScroller();
if (!main) return;
const top = Math.max(0, Number(scrollTop) || 0);
const apply = () => { main.scrollTop = top; };
apply();
requestAnimationFrame(apply);
setTimeout(apply, 0);
}
function _gridDomIntact() {
const grid = document.getElementById('v3-songs-grid');
return !!grid && loadedCount() > 0;
}
function _treeDomIntact() {
const tree = document.getElementById('v3-songs-tree');
if (!tree) return false;
return !!(tree.querySelector('[data-fn]') || tree.querySelector('details'));
}
// Resolve once no grid fetch is in flight. loadGrid early-returns while
// state.loading is set, so paging without waiting would silently skip a
// page (it bumps state.page but the fetch no-ops). Bounded so a wedged
// load can't hang the restore forever.
async function _waitForGridIdle(maxMs) {
const cap = (maxMs == null ? 8000 : maxMs);
let waited = 0;
while (state.loading && waited < cap) {
await new Promise((r) => setTimeout(r, 16));
waited += 16;
}
}
async function _ensureGridPagesThrough(targetPage) {
const goal = Math.max(0, Number(targetPage) || 0);
// The initial page-0 load (or an auto-fill) may still be settling; wait
// for the real state.total before deciding how far to page, otherwise a
// total of 0 exits the loop immediately and the depth never restores.
await _waitForGridIdle();
while (state.page < goal && loadedCount() < state.total) {
if (state.loading) { await _waitForGridIdle(); continue; }
state.page++;
await loadGrid(false);
}
}
function queryParams(extra, opts) {
const f = state.filters;
const skipArtistAlbum = opts && opts.catalog;
const p = new URLSearchParams();
p.set('provider', state.provider);
p.set('sort', state.sort);
if (state.format) p.set('format', state.format);
if (state.q) p.set('q', state.q);
if (!skipArtistAlbum && state.artist) p.set('artist', state.artist);
if (!skipArtistAlbum && state.album) p.set('album', state.album);
if (f.arr_has.length) p.set('arrangements_has', f.arr_has.join(','));
if (f.arr_lacks.length) p.set('arrangements_lacks', f.arr_lacks.join(','));
if (f.stem_has.length) p.set('stems_has', f.stem_has.join(','));
if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(','));
if (f.lyrics) p.set('has_lyrics', f.lyrics);
if (f.tunings.length) p.set('tunings', f.tunings.join(','));
Object.entries(extra || {}).forEach(([k, v]) => p.set(k, v));
return p;
}
function albumsForArtist(name) {
const a = (state.artistCatalog || []).find((x) => x.name === name);
return a ? (a.albums || []) : [];
}
function _chromeIntact() {
return !!(document.getElementById('v3-songs-filters') &&
document.getElementById('v3-songs-artist') &&
document.getElementById('v3-songs-grid'));
}
function artistSelectHtml() {
const opts = ['<option value="">All artists</option>']
.concat((state.artistCatalog || []).map((a) =>
'<option value="' + esc(a.name) + '"' + (a.name === state.artist ? ' selected' : '') + '>' + esc(a.name) + '</option>'));
return opts.join('');
}
function albumSelectHtml() {
if (!state.artist) {
return '<option value="">Choose artist first</option>';
}
const albums = albumsForArtist(state.artist);
const opts = ['<option value="">All albums</option>']
.concat(albums.map((n) =>
'<option value="' + esc(n) + '"' + (n === state.album ? ' selected' : '') + '>' + esc(n) + '</option>'));
return opts.join('');
}
function refreshArtistAlbumSelects() {
const artistEl = document.getElementById('v3-songs-artist');
const albumEl = document.getElementById('v3-songs-album');
if (artistEl) artistEl.innerHTML = artistSelectHtml();
if (albumEl) {
albumEl.innerHTML = albumSelectHtml();
albumEl.disabled = !state.artist;
}
}
function syncChromeFromState() {
const map = {
'v3-songs-provider': state.provider,
'v3-songs-sort': state.sort,
'v3-songs-format': state.format,
};
Object.entries(map).forEach(([id, val]) => {
const el = document.getElementById(id);
if (el && el.value !== val) el.value = val;
});
refreshArtistAlbumSelects();
const gridBtn = document.getElementById('v3-songs-grid-btn');
const treeBtn = document.getElementById('v3-songs-tree-btn');
if (gridBtn) gridBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
if (treeBtn) treeBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
updateFilterBadge();
}
async function loadArtistCatalog() {
const artists = [];
let page = 0, total = Infinity;
while (artists.length < total) {
const data = await jget('/api/library/artists?' + queryParams({ size: 100, page }, { catalog: true }).toString());
if (!data || !Array.isArray(data.artists)) break;
artists.push(...data.artists);
total = (data.total_artists != null) ? data.total_artists : artists.length;
if (!data.artists.length || page > 1000) break;
page++;
}
state.artistCatalog = artists.map((a) => ({
name: a.name,
albums: (a.albums || []).map((al) => al.name),
}));
if (state.artist && !state.artistCatalog.some((a) => a.name === state.artist)) {
state.artist = '';
state.album = '';
} else if (state.album && !albumsForArtist(state.artist).includes(state.album)) {
state.album = '';
}
return state.artistCatalog;
}
function resetScrollToTop() {
_clearLibraryScrollSnapshot();
_applyMainScrollTop(0);
}
function setArtist(value) {
state.artist = value || '';
if (state.album && !albumsForArtist(state.artist).includes(state.album)) state.album = '';
resetScrollToTop();
refreshArtistAlbumSelects();
reload();
}
function setAlbum(value) {
if (!state.artist) { state.album = ''; return; }
state.album = value || '';
resetScrollToTop();
reload();
}
async function jget(url) { try { const r = await fetch(url); return r.ok ? r.json() : null; } catch (e) { return null; } }
// ── Provider-aware song helpers ────────────────────────────────────────
// Remote library providers (slopsmith-plugin-remote-library-*) expose songs
// by provider-owned id with their own art/sync/play flow. Reuse the legacy
// app.js globals (the shared engine) so v3 behaves identically for remote
// providers instead of assuming every row is a local file. All degrade to
// the local path when the helpers/providers aren't present.
function songId(s) {
return (window._librarySongId ? window._librarySongId(s) : (s.filename || '')) || '';
}
function localFilename(s) {
return window._libraryLocalFilename ? window._libraryLocalFilename(s, state.provider) : (s.filename || '');
}
// Stable per-card key: the local filename when present (local song, or a
// synced remote one), else the provider song id.
function cardKey(s) { return localFilename(s) || songId(s); }
function artUrl(song) {
if (window._librarySongArtUrl) return window._librarySongArtUrl(song, state.provider);
const v = song.mtime ? ('?v=' + Math.floor(song.mtime)) : '';
return song.filename ? '/api/song/' + enc(song.filename) + '/art' + v : '';
}
// Play a card: local (or already-synced remote) → playSong the local file;
// an unsynced remote song → sync it first, then play when ready.
function playCard(song, arrIdx) {
if (!song) return;
_saveLibraryScrollSnapshot();
const lf = localFilename(song);
if (lf) { if (window.playSong) window.playSong(enc(lf), arrIdx); return; }
const sid = songId(song);
if (window.syncLibrarySong && sid) window.syncLibrarySong(state.provider, sid, { playWhenReady: true });
}
function accuracyBadge(filename) {
const acc = state.accuracy[filename];
if (acc == null) return '';
const pct = Math.round(acc * 100);
const color = acc >= 0.9 ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low');
const text = acc >= 0.5 && acc < 0.9 ? 'text-black' : 'text-white';
return '<span class="absolute bottom-0 right-0 ' + color + '/90 ' + text + ' px-2 py-0.5 rounded-tl-md text-xs font-bold flex items-center gap-1">' +
'<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/></svg>' + pct + '%</span>';
}
// Source format of a song — prefer the server's `format` field, fall back
// to the filename extension. Returns '' for unknown.
function fmtLabel(song) {
let f = (song.format || '').toLowerCase();
if (!f) {
const fn = (song.filename || '').toLowerCase();
f = fn.endsWith('.sloppak') ? 'sloppak' : '';
}
return f === 'sloppak' ? 'SLOPPAK' : f === 'loose' ? 'FOLDER' : '';
}
// Corner badge for art-based cards (sloppak accented, others muted).
function fmtBadge(song) {
const l = fmtLabel(song);
if (!l) return '';
const c = l === 'SLOPPAK' ? 'bg-fb-primary text-white' : 'bg-black/70 text-fb-textDim';
return '<span class="absolute bottom-0 left-0 ' + c + ' text-[9px] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
}
function songCard(song) {
const fav = song.favorite;
const key = cardKey(song);
// In select mode the checkbox occupies top-2 left-2, so shift the
// tuning chip right (left-9) to avoid overlapping it.
const tuningLabel = (typeof window.displayTuningName === 'function')
? window.displayTuningName(song.tuning_name || song.tuning)
: (song.tuning_name || '');
let tuning = '';
if (tuningLabel) {
const rawOffsets = (typeof window.parseRawTuningOffsets === 'function')
? (window.parseRawTuningOffsets(song.tuning_offsets)
|| window.parseRawTuningOffsets(song.tuning_name || song.tuning))
: null;
const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets
&& typeof window.displayTuningTargets === 'function')
? window.displayTuningTargets(rawOffsets, { tuningName: tuningLabel })
: '';
const badgeTitle = targetNotes
? ('Custom Tuning: ' + targetNotes)
: tuningLabel;
const pos = 'absolute top-2 ' + (state.selectMode ? 'left-9' : 'left-2');
if (targetNotes) {
tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[9px] font-bold px-1.5 py-0.5 rounded-sm leading-tight max-w-[5.5rem] text-center" title="' + esc(badgeTitle) + '">'
+ esc('Custom Tuning') + '<br><span class="font-semibold tracking-wide">' + esc(targetNotes) + '</span></span>';
} else {
tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[10px] font-bold px-1.5 py-0.5 rounded-sm" title="' + esc(badgeTitle) + '">' + esc(tuningLabel) + '</span>';
}
}
// Display-only (pointer-events-none) so a click falls through to the
// card's data-v3-play handler, which owns the toggle — avoids double-toggle.
const checkbox = state.selectMode
? '<input type="checkbox" data-select class="absolute top-2 left-2 z-20 w-5 h-5 accent-fb-primary pointer-events-none"' + (state.selected.has(key) ? ' checked' : '') + '>'
: '';
const arrChips = (song.arrangements || []).slice(0, 4).map((a) =>
'<button data-arr="' + esc(a.index != null ? a.index : '') + '" title="Play ' + esc(a.name) + '" class="text-[10px] px-1.5 py-0.5 rounded bg-gray-800/60 text-fb-textDim hover:bg-fb-primary hover:text-white transition">' + esc(a.name) + '</button>').join('');
// Plugin-contributed card actions placed 'inline' (in the hover action
// row) or 'overlay' (centered over the art). Menu-placed actions live in
// the ⋮ menu (openCardMenu); rendering these here means plugins using
// those placements are no longer silently dropped. No bundled action
// uses them, so for the stock library both strings are empty — the card
// renders exactly as before.
const reg = sm && sm.libraryCardActions;
const acts = (reg && typeof reg.list === 'function') ? reg.list(song) : [];
const actBtn = (a) =>
'<button data-act-card="' + esc(a.id) + '" title="' + esc(a.label || a.id) + '" aria-label="' + esc(a.label || a.id) + '"' +
(a.enabled === false ? ' disabled' : '') +
' class="px-2 h-7 min-w-[1.75rem] rounded-full bg-black/55 hover:bg-black/75 flex items-center justify-center text-xs leading-none ' +
(a.enabled === false ? 'opacity-40 cursor-not-allowed ' : '') +
(a.destructive ? 'text-fb-accent' : 'text-white') + '">' + esc(a.icon || a.label || '•') + '</button>';
const inlineBtns = acts.filter((a) => a.placement === 'inline').map(actBtn).join('');
const overlayActs = acts.filter((a) => a.placement === 'overlay');
const overlay = overlayActs.length
? '<div class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition pointer-events-none"><div class="flex flex-wrap gap-1 justify-center max-w-[90%] pointer-events-auto">' + overlayActs.map(actBtn).join('') + '</div></div>'
: '';
return '<div class="group relative" data-fn="' + esc(key) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer" data-v3-play>' +
'<img src="' + esc(artUrl(song)) + '" alt="" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
tuning + checkbox + accuracyBadge(key) + fmtBadge(song) + overlay +
'<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
inlineBtns +
'<button data-fav title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' +
'<button data-save title="Save for later" aria-label="Save for later" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white text-sm">🔖</button>' +
'<button data-menu title="More" aria-label="More actions" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white text-sm leading-none">⋮</button>' +
'</div></div>' +
'<div class="mt-1 text-sm text-fb-text truncate" title="' + esc(song.title) + '">' + esc(song.title) + '</div>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(song.artist) + '</div>' +
(arrChips ? '<div class="flex flex-wrap gap-1 mt-1">' + arrChips + '</div>' : '') +
'</div>';
}
// Per-card action menu, built from the ui.library-card-injection registry
// (core Edit/Retune + any plugin-registered actions).
let _closeCardMenu = null; // tears down the currently-open card menu + its document closer
function openCardMenu(cardEl, song, anchorBtn) {
// Fully close any already-open menu first — removing just the DOM node
// (as before) would orphan its document-level click closer.
if (_closeCardMenu) _closeCardMenu();
const reg = sm && sm.libraryCardActions;
// Only show actions intended for the overflow menu — actions placed
// 'inline'/'overlay' get their own affordances on the card (see songCard).
// Undefined placement defaults to the menu.
const items = (reg ? reg.list(song) : []).filter((a) => !a.placement || a.placement === 'menu');
const menu = document.createElement('div');
menu.className = 'v3-card-menu absolute top-10 right-2 z-30 min-w-[10rem] bg-fb-card border border-fb-border/60 rounded-lg shadow-xl py-1 text-sm';
const rows = [
{ id: '__play', label: 'Play', run: () => { _saveLibraryScrollSnapshot(); window.playSong && window.playSong(enc(song.filename)); } },
...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })),
];
menu.innerHTML = rows.map((r) =>
'<button data-act="' + esc(r.id) + '" class="w-full text-left px-3 py-1.5 hover:bg-fb-card/60 ' +
(r.enabled === false ? 'opacity-40 cursor-not-allowed ' : '') +
(r.destructive ? 'text-fb-accent' : 'text-fb-text') + '">' + esc(r.label) +
(r.plugin && r.plugin !== 'core' ? '<span class="text-[10px] text-fb-textDim ml-1">' + esc(r.plugin) + '</span>' : '') + '</button>').join('');
cardEl.appendChild(menu);
// Tear down BOTH the menu and its document-level closer together, so a
// menu-item click doesn't leave the closer attached (it would otherwise
// leak, retaining this menu's closures until the next document click).
const closer = (e) => { if (!menu.contains(e.target) && e.target !== anchorBtn) closeMenu(); };
function closeMenu() { menu.remove(); document.removeEventListener('click', closer); if (_closeCardMenu === closeMenu) _closeCardMenu = null; }
_closeCardMenu = closeMenu;
menu.querySelectorAll('[data-act]').forEach((b) => b.addEventListener('click', async (e) => {
e.stopPropagation();
const id = b.getAttribute('data-act');
closeMenu();
if (id === '__play') { playCard(song); return; }
if (reg) await reg.run(id, song, { source: 'v3-songs' });
}));
setTimeout(() => document.addEventListener('click', closer), 0);
}
function wireCards(scope) {
scope.querySelectorAll('[data-fn]').forEach((el) => {
if (el.dataset.wired) return; // don't double-bind on append/auto-fill
el.dataset.wired = '1';
const fn = el.getAttribute('data-fn');
const song = state.songsById[fn] || { filename: fn };
el.querySelectorAll('[data-v3-play]').forEach((pe) => pe.addEventListener('click', (e) => {
if (state.selectMode) { e.preventDefault(); toggleSelect(fn, el); return; }
playCard(song); // local → play; unsynced remote → sync then play
}));
el.querySelector('[data-menu]')?.addEventListener('click', (e) => { e.stopPropagation(); openCardMenu(el, song, e.currentTarget); });
el.querySelectorAll('[data-arr]').forEach((ab) => ab.addEventListener('click', (e) => {
e.stopPropagation();
const idx = ab.getAttribute('data-arr');
playCard(song, idx === '' ? undefined : Number(idx));
}));
el.querySelector('[data-fav]')?.addEventListener('click', async (e) => {
e.stopPropagation();
const btn = e.currentTarget;
try {
const r = await fetch('/api/favorites/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) });
const d = await r.json();
btn.textContent = d.favorite ? '♥' : '♡';
btn.setAttribute('aria-pressed', d.favorite ? 'true' : 'false');
btn.classList.toggle('text-fb-accent', d.favorite);
btn.classList.toggle('text-white', !d.favorite);
} catch (err) { /* */ }
});
el.querySelector('[data-save]')?.addEventListener('click', async (e) => {
e.stopPropagation();
if (window.v3Saved) { const saved = await window.v3Saved.toggle(fn); e.currentTarget.classList.toggle('text-fb-primary', !!saved); }
});
// Inline/overlay plugin card actions → run via the shared registry.
el.querySelectorAll('[data-act-card]').forEach((ab) => ab.addEventListener('click', async (e) => {
e.stopPropagation();
const reg = sm && sm.libraryCardActions;
if (reg) await reg.run(ab.getAttribute('data-act-card'), song, { source: 'v3-songs' });
}));
});
}
// ── Multi-select + batch actions ──────────────────────────────────────--
function toggleSelect(fn, el) {
if (state.selected.has(fn)) state.selected.delete(fn); else state.selected.add(fn);
const on = state.selected.has(fn);
const cb = el.querySelector('[data-select]'); if (cb) cb.checked = on;
el.querySelector('[data-v3-play]')?.classList.toggle('ring-2', on);
el.querySelector('[data-v3-play]')?.classList.toggle('ring-fb-primary', on);
renderBatchBar();
}
function setSelectMode(on) {
state.selectMode = on;
if (!on) state.selected.clear();
const btn = document.getElementById('v3-songs-select');
if (btn) btn.className = btnCtrl + (on ? ' bg-fb-primary text-white' : '');
reload(); // re-render cards with/without checkboxes
renderBatchBar();
}
function renderBatchBar() {
let bar = document.getElementById('v3-songs-batch');
if (!state.selectMode || state.selected.size === 0) { if (bar) bar.remove(); return; }
if (!bar) {
bar = document.createElement('div');
bar.id = 'v3-songs-batch';
bar.className = 'fixed bottom-4 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 bg-fb-card border border-fb-border/60 rounded-full shadow-xl px-4 py-2';
document.body.appendChild(bar);
}
bar.innerHTML =
'<span class="text-sm text-fb-text">' + state.selected.size + ' selected</span>' +
'<button data-batch="playlist" class="text-sm bg-fb-primary hover:bg-fb-primaryHi text-white px-3 py-1 rounded-full">Add to playlist</button>' +
'<button data-batch="saved" class="text-sm bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text px-3 py-1 rounded-full">Save for Later</button>' +
'<button data-batch="clear" class="text-sm text-fb-textDim hover:text-fb-text px-2">Clear</button>';
bar.querySelector('[data-batch="clear"]').addEventListener('click', () => { state.selected.clear(); reload(); renderBatchBar(); });
bar.querySelector('[data-batch="saved"]').addEventListener('click', batchSave);
bar.querySelector('[data-batch="playlist"]').addEventListener('click', batchAddToPlaylist);
}
async function batchSave() {
const lists = (await jget('/api/playlists')) || [];
const saved = lists.find((p) => p.system_key === 'saved_for_later');
let present = new Set();
if (saved) { const pl = await jget('/api/playlists/' + saved.id); present = new Set(((pl && pl.songs) || []).map((s) => s.filename)); }
// Additive: only toggle (add) songs not already saved.
for (const fn of state.selected) {
if (!present.has(fn)) {
try { await fetch('/api/saved/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); } catch (e) { /* */ }
}
}
finishBatch();
}
async function batchAddToPlaylist() {
const lists = (await jget('/api/playlists')) || [];
const choices = lists.filter((p) => !p.system_key);
const labels = choices.map((p, i) => (i + 1) + '. ' + p.name).join('\n');
const ans = (window.prompt('Add ' + state.selected.size + ' song(s) to which playlist?\n' + labels + '\n\nEnter a number, or a new playlist name:', '') || '').trim();
if (!ans) return;
let pid = null;
const num = parseInt(ans, 10);
if (!isNaN(num) && choices[num - 1]) pid = choices[num - 1].id;
else { const created = await jsend('POST', '/api/playlists', { name: ans }); pid = created && created.id; }
if (!pid) return;
for (const fn of state.selected) {
try { await fetch('/api/playlists/' + pid + '/songs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); } catch (e) { /* */ }
}
finishBatch();
}
function finishBatch() {
state.selected.clear();
if (window.v3Playlists) { try { window.v3Playlists.refresh(); window.v3Playlists.refreshSaved(); } catch (e) { /* */ } }
reload(); renderBatchBar();
}
async function jsend(method, url, body) {
try { const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); return r.ok ? r.json() : null; } catch (e) { return null; }
}
// ── Grid (paged + infinite scroll) ─────────────────────────────────────--
async function loadGrid(reset) {
// A reset requested mid-fetch (provider/sort/filter/search change) must
// not be dropped — remember it and re-run once the in-flight load
// returns, otherwise the stale response repopulates the grid.
if (state.loading) { if (reset) state.pendingReset = true; return; }
const grid = document.getElementById('v3-songs-grid');
if (!grid) return;
// A reset wipes the grid (and any open card menu's DOM); close the menu
// first so its document-level click closer doesn't leak.
if (reset) { if (_closeCardMenu) _closeCardMenu(); state.page = 0; state.total = 0; grid.innerHTML = ''; }
state.loading = true;
const data = await jget('/api/library?' + queryParams({ page: state.page, size: PAGE_SIZE }).toString());
state.loading = false;
if (state.pendingReset) { state.pendingReset = false; return loadGrid(true); }
if (!data) return;
state.total = data.total || 0;
(data.songs || []).forEach((s) => { state.songsById[cardKey(s)] = s; grid.insertAdjacentHTML('beforeend', songCard(s)); });
wireCards(grid);
const countEl = document.getElementById('v3-songs-count');
if (countEl) countEl.textContent = state.total + ' song' + (state.total === 1 ? '' : 's');
const loaded = grid.querySelectorAll('[data-fn]').length;
const sentinel = document.getElementById('v3-songs-sentinel');
if (sentinel) sentinel.style.display = loaded < state.total ? 'block' : 'none';
// Auto-fill: if the grid doesn't yet overflow the scroller, keep loading
// (so a short first page still becomes scrollable without user action).
maybeFill();
}
function loadedCount() { return document.querySelectorAll('#v3-songs-grid [data-fn]').length; }
// The scroll listener lives on the SHARED #v3-main container, so guard every
// paging entry point on the Songs screen actually being active — otherwise
// scrolling another screen would keep fetching /api/library into the hidden
// grid after Songs has been visited once.
function songsActive() { const el = document.getElementById('v3-songs'); return !!el && el.classList.contains('active'); }
function loadNext() {
if (state.loading || state.view !== 'grid' || !songsActive()) return;
if (loadedCount() < state.total) { state.page++; loadGrid(false); }
}
function maybeFill() {
const main = document.getElementById('v3-main');
if (!main || state.view !== 'grid' || state.loading || !songsActive()) return;
// Not tall enough to scroll yet, and more remain → pull the next page.
if (main.scrollHeight <= main.clientHeight + 80 && loadedCount() < state.total) loadNext();
}
// Robust infinite scroll: a scroll listener on the real scroll container
// (#v3-main), bound once. Avoids the IntersectionObserver "already in view
// at observe-time" race that stuck the grid on page 0.
function bindScroll() {
const main = document.getElementById('v3-main');
if (!main || state.scrollBound) return;
state.scrollBound = true;
main.addEventListener('scroll', () => {
if (state.view !== 'grid' || state.loading) return;
if (main.scrollTop + main.clientHeight >= main.scrollHeight - 600) loadNext();
}, { passive: true });
}
// ── Tree ────────────────────────────────────────────────────────────────
async function loadTree() {
const host = document.getElementById('v3-songs-tree');
if (!host) return;
host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
// Page through ALL artists — the endpoint clamps size to 100, so a
// single request would silently truncate libraries with >100 artists.
const artists = [];
let page = 0, total = Infinity;
while (artists.length < total) {
const data = await jget('/api/library/artists?' + queryParams({ size: 100, page }).toString());
if (!data || !Array.isArray(data.artists)) break;
artists.push(...data.artists);
total = (data.total_artists != null) ? data.total_artists : artists.length;
if (!data.artists.length || page > 1000) break; // safety: no progress / runaway guard
page++;
}
if (!artists.length) { host.innerHTML = '<p class="text-fb-textDim text-sm">Nothing here.</p>'; return; }
artists.forEach((a) => (a.albums || []).forEach((al) => (al.songs || []).forEach((s) => { state.songsById[cardKey(s)] = s; })));
host.innerHTML = artists.map((a) =>
'<details class="border-b border-fb-border/40"><summary class="cursor-pointer py-2 text-fb-text flex items-center justify-between">' +
'<span>' + esc(a.name) + '</span><span class="text-xs text-fb-textDim">' + esc(a.song_count) + '</span></summary>' +
'<div class="pl-3 pb-2 space-y-2">' + (a.albums || []).map((al) =>
'<div><div class="text-xs uppercase tracking-wider text-fb-textDim/70 mt-2 mb-1">' + esc(al.name || 'Unknown') + '</div>' +
(al.songs || []).map((s) => { const k = cardKey(s); const fl = fmtLabel(s); return (
'<div class="flex items-center gap-2 py-1 group" data-fn="' + esc(k) + '" data-library-song="' + esc(songId(s)) + '" data-library-provider="' + esc(state.provider) + '">' +
'<img src="' + esc(artUrl(s)) + '" alt="" class="w-8 h-8 rounded object-cover bg-fb-card cursor-pointer" data-v3-play onerror="this.style.visibility=\'hidden\'">' +
'<span class="flex-1 min-w-0 cursor-pointer" data-v3-play><span class="block text-sm text-fb-text truncate">' + esc(s.title) + '</span></span>' +
(fl ? '<span class="text-[9px] font-bold px-1 py-0.5 rounded shrink-0 ' + (fl === 'SLOPPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card text-fb-textDim') + '">' + fl + '</span>' : '') +
(state.accuracy[k] != null ? '<span class="text-xs font-bold ' + (state.accuracy[k] >= 0.9 ? 'text-fb-good' : state.accuracy[k] >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(state.accuracy[k] * 100) + '%</span>' : '') +
'<button data-fav class="opacity-0 group-hover:opacity-100 px-1 ' + (s.favorite ? 'text-fb-accent' : 'text-fb-textDim') + '">' + (s.favorite ? '♥' : '♡') + '</button>' +
'</div>'); }).join('') + '</div>').join('') + '</div></details>').join('');
wireCards(host);
}
// ── Filter drawer ─────────────────────────────────────────────────────--
function triState(list_has, list_lacks, value) {
if (list_has.includes(value)) return 'has';
if (list_lacks.includes(value)) return 'lacks';
return 'any';
}
function cycleTri(hasArr, lacksArr, value) {
const s = triState(hasArr, lacksArr, value);
const rm = (a) => { const i = a.indexOf(value); if (i >= 0) a.splice(i, 1); };
rm(hasArr); rm(lacksArr);
if (s === 'any') hasArr.push(value);
else if (s === 'has') lacksArr.push(value);
// 'lacks' → cycles back to any (already removed)
}
function triPill(group, value, label, st) {
const cls = st === 'has' ? 'bg-fb-good/30 text-fb-good border-fb-good/40'
: st === 'lacks' ? 'bg-fb-low/30 text-fb-low border-fb-low/40'
: 'bg-gray-800/50 text-fb-textDim border-gray-700';
const mark = st === 'has' ? '✓ ' : st === 'lacks' ? '✕ ' : '';
return '<button data-tri="' + group + '" data-val="' + esc(value) + '" class="px-2 py-1 rounded-md text-xs border ' + cls + '">' + mark + esc(label) + '</button>';
}
function renderDrawer() {
const d = document.getElementById('v3-songs-drawer');
if (!d) return;
const f = state.filters;
d.innerHTML =
'<div class="p-5 space-y-5">' +
'<div class="flex items-center justify-between"><h3 class="text-lg font-semibold text-fb-text">Filters</h3>' +
'<button data-drawer-close class="text-fb-textDim hover:text-fb-text">✕</button></div>' +
section('Arrangements', ARRANGEMENTS.map((a) => triPill('arr', a, a, triState(f.arr_has, f.arr_lacks, a))).join('')) +
section('Stems (sloppak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) +
section('Lyrics', ['', '1', '0'].map((v) => '<button data-lyrics="' + v + '" class="px-2 py-1 rounded-md text-xs border ' + (f.lyrics === v ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + (v === '' ? 'Any' : v === '1' ? 'Has lyrics' : 'No lyrics') + '</button>').join('')) +
section('Tuning', (state.tuningNames || []).map((t) => {
// Filter on the server's grouping key (raw offsets for customs)
// so two "Custom Tuning" entries are distinct; show their target
// notes in the label so they're distinguishable.
const val = t.key || t.name;
let label = t.name;
if (t.name === 'Custom Tuning' && t.offsets
&& typeof window.parseRawTuningOffsets === 'function'
&& typeof window.displayTuningTargets === 'function') {
const offs = window.parseRawTuningOffsets(t.offsets);
const notes = offs ? window.displayTuningTargets(offs, { tuningName: t.name }) : '';
if (notes) label = 'Custom · ' + notes;
}
return triPill('tuning', val, label + ' (' + t.count + ')', f.tunings.includes(val) ? 'has' : 'any');
}).join('') || '<span class="text-xs text-fb-textDim">No tunings</span>') +
'<div class="flex justify-between pt-3 border-t border-fb-border/50"><button data-drawer-clear class="text-sm text-fb-textDim hover:text-fb-text">Clear all</button>' +
'<button data-drawer-apply class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Done</button></div></div>';
d.querySelectorAll('[data-tri]').forEach((b) => b.addEventListener('click', () => {
const g = b.getAttribute('data-tri'), v = b.getAttribute('data-val');
if (g === 'arr') cycleTri(f.arr_has, f.arr_lacks, v);
else if (g === 'stem') cycleTri(f.stem_has, f.stem_lacks, v);
else if (g === 'tuning') { const i = f.tunings.indexOf(v); if (i >= 0) f.tunings.splice(i, 1); else f.tunings.push(v); }
renderDrawer();
}));
d.querySelectorAll('[data-lyrics]').forEach((b) => b.addEventListener('click', () => { f.lyrics = b.getAttribute('data-lyrics'); renderDrawer(); }));
d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer);
d.querySelector('[data-drawer-clear]')?.addEventListener('click', async () => {
state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [] };
state.artist = '';
state.album = '';
renderDrawer();
await loadArtistCatalog();
refreshArtistAlbumSelects();
reload();
});
d.querySelector('[data-drawer-apply]')?.addEventListener('click', async () => {
closeDrawer();
await loadArtistCatalog();
refreshArtistAlbumSelects();
reload();
});
}
function section(label, inner) {
return '<div><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-2">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
}
function openDrawer() { renderDrawer(); document.getElementById('v3-songs-drawer')?.classList.remove('translate-x-full'); document.getElementById('v3-songs-overlay')?.classList.remove('hidden'); }
function closeDrawer() { document.getElementById('v3-songs-drawer')?.classList.add('translate-x-full'); document.getElementById('v3-songs-overlay')?.classList.add('hidden'); updateFilterBadge(); }
function updateFilterBadge() { const b = document.getElementById('v3-songs-filter-count'); if (b) { const n = activeFilterCount(); b.textContent = n; b.classList.toggle('hidden', n === 0); } }
function reload() {
_clearLibraryScrollSnapshot();
// Record the state this fetch reflects so a later sidebar return can
// tell whether the grid is stale (e.g. an off-screen search changed
// state.q) and needs a refresh rather than a scroll-preserving no-op.
state.renderedHash = _libraryStateHash();
updateFilterBadge();
// Keep a handle on the load so callers (notably the scroll restore on
// screen re-entry) can await page-0 actually landing before paging
// deeper. The visibility/scroll resets below stay synchronous.
const loaded = state.view === 'grid' ? loadGrid(true) : loadTree();
document.getElementById('v3-songs-grid')?.classList.toggle('hidden', state.view !== 'grid');
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
_applyMainScrollTop(0);
return loaded;
}
// ── Chrome ────────────────────────────────────────────────────────────--
async function loadProviders() {
try {
const lp = sm && sm.libraryProviders;
// refresh() re-fetches /api/library/providers so REMOTE providers
// (registered by slopsmith-plugin-remote-library-*) appear — list()
// returns only the capability's initial local-only snapshot.
const fn = lp && (typeof lp.refresh === 'function' ? lp.refresh : (typeof lp.list === 'function' ? lp.list : null));
if (fn) {
const snap = await fn.call(lp);
if (snap && Array.isArray(snap.providers)) {
state.provider = snap.current || (snap.providers[0] && snap.providers[0].id) || 'local';
return snap.providers;
}
}
} catch (e) { /* */ }
const data = await jget('/api/library/providers');
return (data && data.providers) || [{ id: 'local', label: 'My Library' }];
}
async function render() {
const root = document.getElementById('v3-songs');
if (!root) return;
const providers = await loadProviders();
const [, tn] = await Promise.all([
(async () => { state.accuracy = (await jget('/api/stats/best')) || {}; })(),
jget('/api/library/tuning-names?provider=' + enc(state.provider)),
loadArtistCatalog(),
]);
state.tuningNames = (tn && tn.tunings) || [];
const opt = (arr, sel) => arr.map(([v, l]) => '<option value="' + esc(v) + '"' + (v === sel ? ' selected' : '') + '>' + esc(l) + '</option>').join('');
const provOpts = providers.map((p) => '<option value="' + esc(p.id) + '"' + (p.id === state.provider ? ' selected' : '') + '>' + esc(p.label || p.id) + '</option>').join('');
const ctrl = btnCtrl;
root.innerHTML =
'<div class="max-w-7xl mx-auto px-6 md:px-8 pb-8">' +
'<div class="sticky top-0 z-20 -mx-6 md:-mx-8 px-6 md:px-8 py-3 mb-4 bg-fb-sidebar/95 backdrop-blur border-b border-fb-border/40">' +
'<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">' +
'<div><p class="text-fb-textDim text-sm" id="v3-songs-count"></p></div>' +
'<div class="flex flex-wrap gap-2">' +
(providers.length > 1 ? '<select id="v3-songs-provider" class="' + ctrl + '">' + provOpts + '</select>' : '') +
'<select id="v3-songs-artist" class="' + ctrl + ' max-w-[11rem]" aria-label="Artist">' + artistSelectHtml() + '</select>' +
'<select id="v3-songs-album" class="' + ctrl + ' max-w-[11rem]" aria-label="Album"' + (state.artist ? '' : ' disabled') + '>' + albumSelectHtml() + '</select>' +
'<div class="flex rounded-md overflow-hidden border border-gray-700"><button id="v3-songs-grid-btn" class="px-3 py-2 text-sm">▦</button><button id="v3-songs-tree-btn" class="px-3 py-2 text-sm">≣</button></div>' +
'<select id="v3-songs-sort" class="' + ctrl + '">' + opt(SORTS, state.sort) + '</select>' +
'<select id="v3-songs-format" class="' + ctrl + '">' + opt(FORMATS, state.format) + '</select>' +
'<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
'</div></div></div>' +
'<div id="v3-songs-grid" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"></div>' +
'<div id="v3-songs-tree" class="hidden"></div>' +
'<div id="v3-songs-sentinel" class="h-8"></div>' +
// Filter drawer + overlay
'<div id="v3-songs-overlay" class="fixed inset-0 bg-black/50 z-40 hidden"></div>' +
'<aside id="v3-songs-drawer" class="fixed top-0 right-0 h-full w-full sm:w-96 bg-fb-sidebar border-l border-fb-border/50 z-50 transform translate-x-full transition-transform duration-200 overflow-y-auto v3-scroll"></aside>' +
'</div>';
// Wire toolbar.
const byId = (id) => document.getElementById(id);
byId('v3-songs-provider')?.addEventListener('change', async (e) => {
state.provider = e.target.value;
state.artist = '';
state.album = '';
try { sm.libraryProviders && await sm.libraryProviders.select(state.provider); } catch (err) { /* */ }
await loadArtistCatalog();
refreshArtistAlbumSelects();
reload();
});
byId('v3-songs-artist')?.addEventListener('change', (e) => setArtist(e.target.value));
byId('v3-songs-album')?.addEventListener('change', (e) => setAlbum(e.target.value));
byId('v3-songs-sort').addEventListener('change', (e) => { state.sort = e.target.value; reload(); });
byId('v3-songs-format').addEventListener('change', async (e) => {
state.format = e.target.value;
await loadArtistCatalog();
refreshArtistAlbumSelects();
reload();
});
byId('v3-songs-filters').addEventListener('click', openDrawer);
byId('v3-songs-overlay').addEventListener('click', closeDrawer);
byId('v3-songs-upload').addEventListener('click', () => {
const legacy = document.getElementById('upload-songs-file');
// Upload targets the LOCAL library + scan; watchUploadScan refreshes
// the grid for the local provider. Uploading while browsing a remote
// provider won't surface the new local songs — switching the grid to
// local on upload is a P23 remote-provider follow-up.
if (legacy) { legacy.click(); watchUploadScan(); }
});
byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode));
// Bulletproof multi-select: in select mode, a capture-phase click on the
// grid toggles the card and STOPS the event, so nothing (a per-card
// handler, a stray/legacy listener, an arrangement chip) can start
// playback. Fixes "checkbox click opens the song / access-denied".
const gridEl = byId('v3-songs-grid');
if (gridEl) gridEl.addEventListener('click', (e) => {
if (!state.selectMode) return;
const card = e.target.closest('[data-fn]');
if (!card || !gridEl.contains(card)) return;
e.preventDefault();
e.stopImmediatePropagation();
toggleSelect(card.getAttribute('data-fn'), card);
}, true);
const setView = (v) => {
state.view = v;
byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
byId('v3-songs-tree-btn').className = 'px-3 py-2 text-sm ' + (v === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
return reload();
};
byId('v3-songs-grid-btn').addEventListener('click', () => setView('grid'));
byId('v3-songs-tree-btn').addEventListener('click', () => setView('tree'));
// Await the initial load so a caller awaiting render() (the scroll
// restore on screen re-entry) sees a populated grid + real state.total
// before it tries to page deeper.
await setView(state.view);
bindScroll();
updateFilterBadge();
state.built = true;
}
async function onV3SongsScreenEnter() {
const snap = _readLibraryScrollSnapshot();
const hashMatch = !!(snap && snap.hash === _libraryStateHash());
const domReady = state.built && !!document.getElementById('v3-songs-grid');
const chromeOk = _chromeIntact();
const viewOk = state.view === (snap && snap.view ? snap.view : state.view);
if (snap && hashMatch && domReady && chromeOk && viewOk) {
if (state.view === 'grid' && _gridDomIntact()) {
if ((snap.page || 0) > state.page || (snap.loadedCount || 0) > loadedCount()) {
await _ensureGridPagesThrough(snap.page || 0);
}
document.getElementById('v3-songs-grid')?.classList.toggle('hidden', false);
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', true);
syncChromeFromState();
_applyMainScrollTop(snap.scrollTop || 0);
_clearLibraryScrollSnapshot();
return;
}
if (state.view === 'tree' && _treeDomIntact()) {
document.getElementById('v3-songs-grid')?.classList.toggle('hidden', true);
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', false);
syncChromeFromState();
_applyMainScrollTop(snap.scrollTop || 0);
_clearLibraryScrollSnapshot();
return;
}
}
// Sidebar return without a player snapshot — keep grid, refresh chrome.
if (!snap && domReady && chromeOk && state.built) {
syncChromeFromState();
// If state drifted while we were away (notably an off-screen topbar
// search updating state.q), the persisted grid is stale — refetch
// instead of silently showing the old results. Unchanged state keeps
// the scroll-preserving no-op.
if (state.renderedHash !== _libraryStateHash()) { reload(); return; }
document.getElementById('v3-songs-grid')?.classList.toggle('hidden', state.view !== 'grid');
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
return;
}
const snapToRestore = hashMatch ? snap : null;
if (snap && !hashMatch) _clearLibraryScrollSnapshot();
await render();
if (snapToRestore && snapToRestore.hash === _libraryStateHash()) {
if (state.view === 'grid') await _ensureGridPagesThrough(snapToRestore.page || 0);
_applyMainScrollTop(snapToRestore.scrollTop || 0);
}
_clearLibraryScrollSnapshot();
}
// After an upload click-through (which reuses the legacy uploader +
// background scan), poll /api/scan-status and reload the v3 grid once the
// scan we triggered finishes — the legacy uploader only refreshes the
// legacy screens, so without this newly-uploaded songs wouldn't appear in
// v3 until a manual refresh. Bounded so a no-op upload can't poll forever.
let _uploadScanTimer = null;
function watchUploadScan() {
if (_uploadScanTimer) clearInterval(_uploadScanTimer);
let sawRunning = false, ticks = 0;
_uploadScanTimer = setInterval(async () => {
ticks++;
let sd = null;
try { const r = await fetch('/api/scan-status'); if (r.ok) sd = await r.json(); } catch (e) { /* */ }
if (sd && sd.running) sawRunning = true;
if ((sawRunning && sd && !sd.running) || ticks >= 90) {
clearInterval(_uploadScanTimer); _uploadScanTimer = null;
if (sawRunning) reload();
}
}, 1000);
}
// Topbar search drives this screen.
async function search(q) {
state.q = q || '';
if (songsActive()) {
await loadArtistCatalog();
refreshArtistAlbumSelects();
reload();
} else if (window.showScreen) {
window.showScreen('v3-songs');
}
}
window.v3Songs = {
render: render,
reload: reload,
search: search,
setQuery: (q) => { state.q = q || ''; },
_scrollHelpers: {
SCROLL_STATE_KEY,
buildLibraryStateHash,
readSnapshot: _readLibraryScrollSnapshot,
clearSnapshot: _clearLibraryScrollSnapshot,
},
};
if (sm && typeof sm.on === 'function') {
sm.on('screen:changed', (e) => {
const id = e && e.detail && e.detail.id;
if (id === 'v3-songs') { onV3SongsScreenEnter(); return; }
// Leaving Songs: tear down select mode + the body-mounted batch bar,
// so an active multi-selection doesn't leave a floating bar (and
// stale selection) visible on unrelated screens.
if (state.selectMode || state.selected.size) {
state.selectMode = false;
state.selected.clear();
const bar = document.getElementById('v3-songs-batch');
if (bar) bar.remove();
}
});
sm.on('song:stop', () => { /* refresh accuracy lazily next render */ });
}
})();
+174
View File
@@ -0,0 +1,174 @@
/*
* fee[dB]ack v0.3.0 — song-stats recorder (core glue).
*
* Bridges the highway note-detection scorer to the core song_stats store
* (POST /api/stats). The scorer lives in the OPTIONAL external plugin
* slopsmith-plugin-notedetect, which emits `note:hit` / `note:miss` per note
* on window.slopsmith — note-detection is a DEFERRED capability domain, so we
* use those legacy events directly (design/05-capability-pipelines.md). If the
* plugin isn't installed, no note events fire and nothing is recorded
* (graceful degrade — the dashboard simply shows no accuracy).
*
* Two paths:
* • Scored session: tally hits/misses (or accept an explicit
* `note_detect:session-ended` summary), then POST on song end.
* • Resume position: a lightweight POST of the play position (as the
* `lastPlayPosition` field, which /api/stats accepts alongside
* `last_position`) on pause/stop so Continue-Playing works for non-scored
* plays.
*
* Score/accuracy formula mirrors lib/song_score.py so badge == server.
*/
(function () {
'use strict';
const sm = window.slopsmith;
if (!sm || typeof sm.on !== 'function') return;
let cur = null; // active session
let recordedThisSession = false;
function reset(filename, arrangement) {
cur = {
filename: filename || null,
arrangement: Number.isFinite(arrangement) ? arrangement : 0,
hits: 0, misses: 0, streak: 0, bestStreak: 0,
scored: false, lastTime: 0,
};
recordedThisSession = false;
}
// accuracy = hits / max(1, hits+misses); score = round(hits*100*accuracy)
function accuracyOf(hits, misses) { return hits / Math.max(1, hits + misses); }
function scoreOf(hits, misses) { return Math.round(hits * 100 * accuracyOf(hits, misses)); }
async function post(body) {
try {
const r = await fetch('/api/stats', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
try { return await r.json(); } catch (e) { return null; }
} catch (e) { return null; /* offline / endpoint absent — non-fatal */ }
}
// Fan the scored-POST outcome out to the progression core (challenge/quest
// completion events + state refresh). Summary may be null (older server).
// `posted` is the stats body we sent; `natural` is true when the song ran
// to its end (vs. the user stopping early).
async function notifyProgression(response, posted, natural) {
const summary = response && response.progression;
if (window.v3Progression && typeof window.v3Progression.notify === 'function') {
// Await the notify promise (which wraps a refresh()) so the profile
// badge renders post-award rank/dB rather than stale cached values.
try { await window.v3Progression.notify(summary); } catch (e) { /* non-fatal */ }
}
// Calibration attempt feedback (spec 010): the diagnostic sloppak was
// played to the end with scoring but below 100% — surface it so the UI
// can offer a retry. Early quits don't prompt (the player bailed on
// purpose), and neither do replays once calibration is completed.
try {
const state = window.v3Progression && window.v3Progression.get && window.v3Progression.get();
const onboarding = (state && state.onboarding) || {};
if (natural && posted && posted.filename &&
posted.filename === onboarding.diagnostic_filename &&
onboarding.calibration_status !== 'completed' &&
!(summary && summary.calibration_completed) &&
typeof posted.accuracy === 'number' && posted.accuracy < 1) {
sm.emit('progression:calibration-attempt', { accuracy: posted.accuracy });
}
} catch (e) { /* feedback must never break stats recording */ }
}
function finalizeScored(position, natural) {
if (!cur || !cur.filename || recordedThisSession) return;
if (!cur.scored || (cur.hits + cur.misses) <= 0) return; // no real scoring this session
recordedThisSession = true;
const body = {
filename: cur.filename,
arrangement: cur.arrangement,
score: scoreOf(cur.hits, cur.misses),
accuracy: accuracyOf(cur.hits, cur.misses),
hits: cur.hits,
misses: cur.misses,
bestStreak: cur.bestStreak,
lastPlayPosition: Number.isFinite(position) ? position : cur.lastTime,
};
post(body).then(async (response) => {
await notifyProgression(response, body, !!natural);
// Refresh the profile badge AFTER the progression state moved so
// the rank/dB it renders are post-award values.
if (window.v3Profile && typeof window.v3Profile.refresh === 'function') {
window.v3Profile.refresh();
}
});
}
function touchPosition(position) {
if (!cur || !cur.filename) return;
// Allow 0: restarting a song and stopping at the very beginning must be
// able to clear a stale Continue offset. Only negatives are invalid.
if (!Number.isFinite(position) || position < 0) return;
post({ filename: cur.filename, arrangement: cur.arrangement, lastPlayPosition: position });
}
// ── Session lifecycle ─────────────────────────────────────────────────--
sm.on('song:loading', (e) => {
const d = (e && e.detail) || {};
reset(d.filename, d.arrangement == null ? 0 : Number(d.arrangement));
});
sm.on('song:arrangement-changed', (e) => {
const d = (e && e.detail) || {};
// Arrangement switch restarts scoring — treat as a fresh session.
reset(d.filename || (cur && cur.filename), d.arrangement == null ? 0 : Number(d.arrangement));
});
// ── Per-note tally (from the note_detect plugin) ──────────────────────--
sm.on('note:hit', () => {
if (!cur) reset(null, 0);
cur.scored = true; cur.hits++; cur.streak++;
if (cur.streak > cur.bestStreak) cur.bestStreak = cur.streak;
});
sm.on('note:miss', () => {
if (!cur) reset(null, 0);
cur.scored = true; cur.misses++; cur.streak = 0;
});
// Track latest position for finalize fallbacks.
sm.on('song:position-changed', (e) => {
const t = e && e.detail && e.detail.time;
if (cur && Number.isFinite(t)) cur.lastTime = t;
});
// ── Authoritative explicit summary (if the plugin emits one) ──────────--
sm.on('note_detect:session-ended', (e) => {
const d = (e && e.detail) || {};
if (!d.filename || recordedThisSession) return;
recordedThisSession = true;
const body = {
filename: d.filename,
arrangement: d.arrangement == null ? 0 : Number(d.arrangement),
score: d.score, accuracy: d.accuracy,
hits: d.hits, misses: d.misses, bestStreak: d.bestStreak,
lastPlayPosition: d.lastPlayPosition,
};
post(body).then(async (response) => {
// The plugin's explicit summary is an authoritative session end —
// treat it as a natural finish for calibration-retry feedback.
await notifyProgression(response, body, true);
if (window.v3Profile && typeof window.v3Profile.refresh === 'function') window.v3Profile.refresh();
});
});
// ── Finalize / resume-position ────────────────────────────────────────--
sm.on('song:ended', (e) => finalizeScored(e && e.detail && e.detail.time, true));
sm.on('song:pause', (e) => touchPosition(e && e.detail && e.detail.time));
sm.on('song:stop', (e) => {
// Record the scored session if it wasn't already (e.g. user closed the
// player before the track ended), then persist the resume position.
// Not a natural end — no calibration-retry prompt for deliberate quits.
const t = e && e.detail && e.detail.time;
finalizeScored(t, false);
touchPosition(t);
});
})();
+128
View File
@@ -0,0 +1,128 @@
/*
* fee[dB]ack v0.3.0 — cosmetics applier (spec 010): shop themes + avatar frames.
*
* Replicates the proven plugins/themes CSS-variable pattern for the v3 `fb-*`
* palette: one injected <style> re-points the fb utility classes at
* `--fbv-*` variables under an `html[data-fb-theme]` gate, so the default
* look is untouched until a theme is equipped and "unequip" is just removing
* the attribute. No Tailwind build interaction (constitution P-II) — rules
* are generated at runtime from the equipped item's color payload.
*
* Loads BEFORE profile.js so the equipped theme + avatar frame apply with the
* first badge render (equipped cosmetics ride along on GET /api/profile).
* Decorative accents (rings, shadows, placeholder tints) deliberately keep
* their defaults — themes recolor surfaces, text, and borders.
*/
(function () {
'use strict';
const STYLE_ID = 'fb-theme-style';
// Mirrors the `fb` palette in tailwind.config.js.
const KEYS = ['bg', 'sidebar', 'card', 'cardMuted', 'primary', 'primaryHi', 'accent',
'text', 'textDim', 'border', 'good', 'mid', 'low', 'gold'];
// Opacity suffixes used by v3 markup (bg-fb-card/80, border-fb-border/50, …).
const OPACITY = { 95: '0.95', 90: '0.9', 80: '0.8', 70: '0.7', 60: '0.6',
50: '0.5', 40: '0.4', 30: '0.3', 20: '0.2', 10: '0.1' };
let _frameStyle = ''; // equipped avatar-frame CSS fragment ('' = none)
function hexToRgb(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
if (!m) return null;
const n = parseInt(m[1], 16);
return ((n >> 16) & 255) + ' ' + ((n >> 8) & 255) + ' ' + (n & 255);
}
function cssFor(colors) {
let vars = '';
let rules = '';
for (const key of KEYS) {
const rgb = hexToRgb(colors[key]);
if (!rgb) continue;
vars += ' --fbv-' + key + ': ' + rgb + ';\n';
const v = 'var(--fbv-' + key + ')';
rules +=
'html[data-fb-theme] .bg-fb-' + key + ' { background-color: rgb(' + v + '); }\n' +
'html[data-fb-theme] .hover\\:bg-fb-' + key + ':hover { background-color: rgb(' + v + '); }\n' +
'html[data-fb-theme] .text-fb-' + key + ' { color: rgb(' + v + '); }\n' +
'html[data-fb-theme] .hover\\:text-fb-' + key + ':hover { color: rgb(' + v + '); }\n' +
'html[data-fb-theme] .border-fb-' + key + ' { border-color: rgb(' + v + '); }\n';
for (const suffix in OPACITY) {
const op = OPACITY[suffix];
rules +=
'html[data-fb-theme] .bg-fb-' + key + '\\/' + suffix + ' { background-color: rgb(' + v + ' / ' + op + '); }\n' +
'html[data-fb-theme] .text-fb-' + key + '\\/' + suffix + ' { color: rgb(' + v + ' / ' + op + '); }\n' +
'html[data-fb-theme] .border-fb-' + key + '\\/' + suffix + ' { border-color: rgb(' + v + ' / ' + op + '); }\n' +
'html[data-fb-theme] .divide-fb-' + key + '\\/' + suffix + ' > :not([hidden]) ~ :not([hidden]) { border-color: rgb(' + v + ' / ' + op + '); }\n';
}
}
// The app shell paints body via bg-fb-sidebar (covered above); cover a
// bare body too so the radial-gradient fallback areas follow the theme.
rules += 'html[data-fb-theme] body { background-color: rgb(var(--fbv-sidebar)); color: rgb(var(--fbv-text)); }\n';
return 'html[data-fb-theme] {\n' + vars + '}\n' + rules;
}
function apply(payload) {
const colors = payload && payload.colors;
let styleEl = document.getElementById(STYLE_ID);
if (!colors) {
if (styleEl) styleEl.remove();
document.documentElement.removeAttribute('data-fb-theme');
return;
}
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = cssFor(colors);
document.documentElement.setAttribute('data-fb-theme', '1');
}
function setFrame(payload) {
// Bundled-content trust model; still keep it attribute-safe (it is set
// via element.style, never innerHTML).
_frameStyle = String((payload && payload.frame_style) || '').replace(/[{}<>]/g, '');
}
// Apply the equipped frame to an avatar wrapper element (badge, profile,
// Progress). Always resets first so unequip clears previous frames.
function applyFrame(el) {
if (!el) return;
el.style.boxShadow = '';
if (_frameStyle) el.style.cssText += ';' + _frameStyle + ';';
}
function applyCosmetics(cosmetics) {
cosmetics = cosmetics || {};
apply((cosmetics.theme || {}).payload || null);
setFrame((cosmetics.avatar_frame || {}).payload || null);
}
async function refresh() {
try {
const r = await fetch('/api/profile');
if (r.ok) {
const profile = await r.json();
applyCosmetics(profile.cosmetics);
if (window.slopsmith && typeof window.slopsmith.emit === 'function') {
window.slopsmith.emit('v3:cosmetics-applied', profile.cosmetics || {});
}
}
} catch (e) { /* offline — keep current look */ }
}
window.v3Theme = {
apply, // preview/apply a theme payload directly (null = default)
applyCosmetics, // apply a {theme, avatar_frame} equipped map
applyFrame, // decorate an avatar wrapper with the equipped frame
frameStyle: () => _frameStyle,
refresh, // re-read equipped cosmetics from /api/profile
};
refresh();
// Re-apply when an equip/unequip happens anywhere (shop screen, capability
// command from a plugin).
if (window.slopsmith && typeof window.slopsmith.on === 'function') {
window.slopsmith.on('progression:cosmetic-equipped', refresh);
}
})();
+96
View File
@@ -0,0 +1,96 @@
/*
* fee[dB]ack v0.3.0 — shared tuner DSP/math.
*
* A small, dependency-free YIN pitch detector + frequency→note/cents helper,
* used by the topbar tuner badge (and reusable by anything else that needs a
* lightweight readout). This intentionally does NOT reach into the external
* note_detect plugin's internals — it's a standalone copy of the standard
* algorithm (constitution P-II, plugin isolation). Browser-global as
* `window.tunerCore`; also CommonJS-exported for node tests (tests/js).
*/
(function (root) {
'use strict';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
/**
* YIN pitch detection on a Float32 time-domain buffer.
* @returns {{frequency:number, confidence:number}} frequency in Hz (0 if
* none found), confidence in 0..1 (1 - aperiodicity).
*/
function yinDetect(buffer, sampleRate, threshold) {
threshold = threshold == null ? 0.12 : threshold;
const n = buffer.length;
const halfN = Math.floor(n / 2);
const yin = new Float32Array(halfN);
// Difference function.
for (let tau = 0; tau < halfN; tau++) {
let sum = 0;
for (let i = 0; i < halfN; i++) {
const delta = buffer[i] - buffer[i + tau];
sum += delta * delta;
}
yin[tau] = sum;
}
// Cumulative mean normalized difference.
yin[0] = 1;
let running = 0;
for (let tau = 1; tau < halfN; tau++) {
running += yin[tau];
yin[tau] = running > 0 ? yin[tau] * tau / running : 1;
}
// Absolute threshold: first dip below `threshold`, then the local min.
let tauEstimate = -1;
for (let tau = 2; tau < halfN; tau++) {
if (yin[tau] < threshold) {
while (tau + 1 < halfN && yin[tau + 1] < yin[tau]) tau++;
tauEstimate = tau;
break;
}
}
if (tauEstimate === -1) return { frequency: 0, confidence: 0 };
// Parabolic interpolation around the dip for sub-sample accuracy.
const x0 = tauEstimate > 0 ? tauEstimate - 1 : tauEstimate;
const x2 = tauEstimate + 1 < halfN ? tauEstimate + 1 : tauEstimate;
let betterTau = tauEstimate;
if (x0 !== tauEstimate && x2 !== tauEstimate) {
const s0 = yin[x0], s1 = yin[tauEstimate], s2 = yin[x2];
const denom = 2 * (2 * s1 - s2 - s0);
if (denom !== 0) betterTau = tauEstimate + (s2 - s0) / denom;
}
// A near-zero denom can fling betterTau out of [x0, x2] or non-finite,
// making frequency Infinity/NaN; clamp it back to the dip neighbourhood
// and treat a non-positive tau as "no detection" (frequency 0).
if (!Number.isFinite(betterTau) || betterTau < x0 || betterTau > x2) betterTau = tauEstimate;
return {
frequency: betterTau > 0 ? sampleRate / betterTau : 0,
confidence: Math.max(0, Math.min(1, 1 - yin[tauEstimate])),
};
}
/**
* Map a frequency to the nearest note + cents deviation.
* @param {number} freq Hz
* @param {number} [referencePitch=440] A4 reference (430450)
* @returns {{name,octave,note,midi,cents,targetFreq}|null}
*/
function freqToNote(freq, referencePitch) {
if (!freq || freq <= 0) return null;
const a4 = referencePitch || 440;
const midiFloat = 69 + 12 * Math.log2(freq / a4);
const midi = Math.round(midiFloat);
const targetFreq = a4 * Math.pow(2, (midi - 69) / 12);
const cents = Math.round(1200 * Math.log2(freq / targetFreq));
const name = NOTE_NAMES[((midi % 12) + 12) % 12];
const octave = Math.floor(midi / 12) - 1;
return { name, octave, note: name + octave, midi, cents, targetFreq };
}
const api = { yinDetect, freqToNote, NOTE_NAMES };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (root) root.tunerCore = api;
})(typeof window !== 'undefined' ? window : null);
+957
View File
@@ -0,0 +1,957 @@
/*
* fee[dB]ack v0.3.0 — non-Tailwind bits for the v3 shell.
* Body radial-gradient background + custom scrollbars. Tokens mirror the
* `fb` palette in tailwind.config.js.
*/
/* The v3 tuner card replaces the tuner plugin's floating launcher — hide it. */
#tuner-toggle-btn { display: none !important; }
/* The navy radial wash lives on the SIDEBAR (the content area is the solid
darker tone). Brighter toward the top. */
#v3-sidebar {
background-image: radial-gradient(circle at top, #1e293b 0%, #0f172a 100%);
background-attachment: fixed;
}
/* Custom 6px scrollbars on the scroll regions (WebKit/Blink). */
#v3-main::-webkit-scrollbar,
#v3-nav::-webkit-scrollbar,
.v3-scroll::-webkit-scrollbar {
width: 6px;
height: 6px;
}
#v3-main::-webkit-scrollbar-track,
#v3-nav::-webkit-scrollbar-track,
.v3-scroll::-webkit-scrollbar-track {
background: transparent;
}
#v3-main::-webkit-scrollbar-thumb,
#v3-nav::-webkit-scrollbar-thumb,
.v3-scroll::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 3px;
}
#v3-main::-webkit-scrollbar-thumb:hover,
#v3-nav::-webkit-scrollbar-thumb:hover,
.v3-scroll::-webkit-scrollbar-thumb:hover {
background: #475569;
}
/* Firefox scrollbars. */
#v3-main,
#v3-nav,
.v3-scroll {
scrollbar-width: thin;
scrollbar-color: #334155 transparent;
}
/* ══ v3 player chrome (P22) ════════════════════════════════════════════════
Persistent top HUD + Up-Next pill, hover-reveal left rail with feature
popovers, and an auto-hiding bottom transport. Scoped under #player; this
markup only exists in static/v3/index.html, so the legacy player is
unaffected. Behavior: static/v3/player-chrome.js. */
#player { background: #050508; position: relative; }
#player #highway { position: relative; z-index: 1; }
/* — Venue Mood FX — Phase 1: bottom strip disabled; real scene ships in 3D bg — */
.venue-mood-fx {
display: none !important;
pointer-events: none;
}
/* Edge-only wash above 3D canvas, below HUD/transport (z-index 3) */
#player .v3-venue-scene-wash {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
background:
linear-gradient(180deg, rgba(34, 211, 238, .16) 0%, transparent 14%),
linear-gradient(0deg, rgba(30, 64, 110, .2) 0%, transparent 16%),
linear-gradient(90deg, rgba(34, 211, 238, .1) 0%, transparent 10%),
linear-gradient(270deg, rgba(34, 211, 238, .1) 0%, transparent 10%);
}
/* Non-blocking Venue mode label — below HUD, above canvas */
#player .v3-venue-mode-badge {
position: absolute;
top: 4.75rem;
left: 1.25rem;
z-index: 18;
pointer-events: none;
max-width: min(22rem, calc(100% - 2.5rem));
padding: .35rem .65rem;
font-size: .7rem;
line-height: 1.35;
letter-spacing: .02em;
color: rgba(165, 243, 252, .95);
background: rgba(8, 47, 73, .78);
border: 1px solid rgba(34, 211, 238, .38);
border-radius: .375rem;
box-shadow: 0 2px 10px rgba(2, 8, 23, .35);
backdrop-filter: blur(4px);
}
#player .v3-venue-scene-wash.hidden,
#player .v3-venue-mode-badge.hidden {
display: none;
}
/* Legacy ::before wash retained for tests; superseded by .v3-venue-scene-wash */
#player.is-venue-visualization.venue-scene-pending::before {
content: none;
}
/* Legacy strip rules retained for when STRIP_OVERLAY_ENABLED returns */
.venue-mood-fx:not(.hidden) { opacity: 1; }
.venue-mood-fx.hidden { display: none; }
.venue-mood-lights,
.venue-mood-crowd,
.venue-mood-haze {
position: absolute;
inset: 0;
pointer-events: none;
}
/* Stage wash — original gradients only */
.venue-mood-lights {
background:
radial-gradient(ellipse 55% 85% at 10% 100%, rgba(251, 191, 36, .22) 0%, transparent 58%),
radial-gradient(ellipse 55% 85% at 90% 100%, rgba(56, 189, 248, .2) 0%, transparent 58%),
radial-gradient(ellipse 90% 120% at 50% 110%, rgba(30, 58, 95, .55) 0%, transparent 68%),
linear-gradient(180deg, transparent 0%, rgba(8, 12, 22, .35) 55%, rgba(4, 6, 12, .82) 100%);
opacity: .75;
}
/* Crowd silhouettes — CSS shapes, no external assets */
.venue-mood-crowd {
bottom: 0;
top: auto;
height: 72%;
background:
radial-gradient(ellipse 8% 42% at 6% 100%, rgba(48, 72, 118, .96) 0%, rgba(36, 56, 92, .96) 58%, transparent 59%),
radial-gradient(ellipse 7% 38% at 14% 100%, rgba(52, 78, 124, .94) 0%, rgba(40, 62, 98, .94) 56%, transparent 57%),
radial-gradient(ellipse 9% 44% at 24% 100%, rgba(44, 68, 110, .95) 0%, rgba(34, 54, 88, .95) 58%, transparent 59%),
radial-gradient(ellipse 8% 40% at 34% 100%, rgba(50, 74, 120, .94) 0%, rgba(38, 58, 94, .94) 57%, transparent 58%),
radial-gradient(ellipse 10% 46% at 46% 100%, rgba(42, 66, 108, .96) 0%, rgba(32, 52, 86, .96) 60%, transparent 61%),
radial-gradient(ellipse 9% 42% at 58% 100%, rgba(48, 72, 116, .95) 0%, rgba(36, 56, 90, .95) 58%, transparent 59%),
radial-gradient(ellipse 8% 40% at 68% 100%, rgba(52, 78, 122, .93) 0%, rgba(40, 62, 96, .93) 56%, transparent 57%),
radial-gradient(ellipse 9% 44% at 78% 100%, rgba(44, 68, 112, .95) 0%, rgba(34, 54, 88, .95) 58%, transparent 59%),
radial-gradient(ellipse 8% 38% at 88% 100%, rgba(50, 74, 118, .94) 0%, rgba(38, 58, 92, .94) 56%, transparent 57%),
radial-gradient(ellipse 7% 36% at 96% 100%, rgba(46, 70, 114, .93) 0%, rgba(36, 56, 90, .93) 55%, transparent 56%);
opacity: .55;
transform: translateY(0);
}
.venue-mood-haze {
background: linear-gradient(180deg, transparent 0%, rgba(15, 23, 42, .12) 40%, rgba(30, 41, 59, .28) 100%);
opacity: 0;
transition: opacity .6s ease;
}
/* Player shell atmosphere when venue enabled */
#player.venue-mood-subtle::before,
#player.venue-mood-full::before {
content: '';
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background: radial-gradient(ellipse 120% 80% at 50% 100%, rgba(30, 64, 110, .12) 0%, transparent 55%);
transition: background .6s ease, opacity .6s ease;
}
#player.venue-mood-full::before {
background:
radial-gradient(ellipse 90% 55% at 50% 0%, rgba(34, 211, 238, .06) 0%, transparent 60%),
radial-gradient(ellipse 120% 80% at 50% 100%, rgba(30, 64, 110, .18) 0%, transparent 55%);
}
/* Subtle vs full intensity */
#player.venue-mood-subtle .venue-mood-lights { opacity: .65; }
#player.venue-mood-subtle .venue-mood-crowd { opacity: .48; }
#player.venue-mood-full .venue-mood-lights {
background:
radial-gradient(ellipse 52% 95% at 8% 100%, rgba(251, 191, 36, .42) 0%, transparent 58%),
radial-gradient(ellipse 52% 95% at 92% 100%, rgba(56, 189, 248, .38) 0%, transparent 58%),
radial-gradient(ellipse 95% 130% at 50% 115%, rgba(59, 130, 246, .48) 0%, transparent 72%),
linear-gradient(180deg, transparent 0%, rgba(15, 23, 42, .45) 42%, rgba(8, 12, 24, .94) 100%);
opacity: .92;
}
#player.venue-mood-full .venue-mood-crowd { opacity: .78; }
/* Full idle baseline — visible before any notes are judged */
#player.venue-mood-full .venue-mood-fx .venue-mood-lights { opacity: .88; }
#player.venue-mood-full .venue-mood-fx .venue-mood-crowd { opacity: .72; }
#player.venue-mood-full .venue-mood-fx .venue-mood-haze {
opacity: .28;
background: linear-gradient(180deg, transparent 0%, rgba(56, 189, 248, .08) 35%, rgba(30, 64, 110, .22) 100%);
}
/* Performance state visuals */
.venue-mood-fx.venue-mood-state-idle .venue-mood-lights { opacity: .45; }
.venue-mood-fx.venue-mood-state-idle .venue-mood-crowd { opacity: .32; }
#player.venue-mood-full .venue-mood-fx.venue-mood-state-idle .venue-mood-lights { opacity: .88; }
#player.venue-mood-full .venue-mood-fx.venue-mood-state-idle .venue-mood-crowd { opacity: .72; }
.venue-mood-fx.venue-mood-state-steady .venue-mood-lights {
background:
radial-gradient(ellipse 85% 110% at 50% 108%, rgba(51, 85, 120, .5) 0%, transparent 66%),
linear-gradient(180deg, transparent 0%, rgba(10, 16, 28, .4) 60%, rgba(4, 6, 12, .85) 100%);
}
.venue-mood-fx.venue-mood-state-strong .venue-mood-lights {
background:
radial-gradient(ellipse 70% 95% at 50% 105%, rgba(34, 211, 238, .28) 0%, transparent 62%),
radial-gradient(ellipse 90% 115% at 50% 110%, rgba(51, 85, 120, .58) 0%, transparent 68%),
linear-gradient(180deg, transparent 0%, rgba(8, 14, 26, .35) 55%, rgba(4, 6, 12, .88) 100%);
}
.venue-mood-fx.venue-mood-state-strong .venue-mood-crowd { opacity: .62; }
.venue-mood-fx.venue-mood-state-fire .venue-mood-lights {
background:
radial-gradient(ellipse 55% 80% at 50% 95%, rgba(251, 146, 60, .42) 0%, transparent 58%),
radial-gradient(ellipse 85% 110% at 50% 108%, rgba(180, 70, 20, .35) 0%, transparent 65%),
linear-gradient(180deg, transparent 0%, rgba(20, 10, 6, .3) 50%, rgba(4, 6, 12, .9) 100%);
}
.venue-mood-fx.venue-mood-state-fire .venue-mood-crowd { opacity: .78; }
.venue-mood-fx.venue-mood-state-recovery .venue-mood-lights {
background:
radial-gradient(ellipse 90% 105% at 50% 110%, rgba(120, 90, 30, .32) 0%, transparent 66%),
linear-gradient(180deg, transparent 0%, rgba(18, 14, 8, .42) 58%, rgba(4, 6, 12, .86) 100%);
}
.venue-mood-fx.venue-mood-state-recovery .venue-mood-crowd { opacity: .38; }
.venue-mood-fx.venue-mood-state-smoke .venue-mood-lights { opacity: .35; }
.venue-mood-fx.venue-mood-state-smoke .venue-mood-crowd { opacity: .22; transform: translateY(6%); }
.venue-mood-fx.venue-mood-state-smoke .venue-mood-haze {
opacity: .72;
background:
linear-gradient(180deg, transparent 0%, rgba(71, 85, 105, .22) 35%, rgba(51, 65, 85, .45) 100%),
radial-gradient(ellipse 100% 80% at 50% 100%, rgba(100, 116, 139, .25) 0%, transparent 70%);
}
@media (prefers-reduced-motion: no-preference) {
.venue-mood-fx.venue-mood-state-steady .venue-mood-crowd,
#player.venue-mood-full .venue-mood-fx.venue-mood-state-steady .venue-mood-crowd {
animation: v3-venue-crowd-sway 4.8s ease-in-out infinite;
}
.venue-mood-fx.venue-mood-state-strong .venue-mood-crowd,
#player.venue-mood-full .venue-mood-fx.venue-mood-state-strong .venue-mood-crowd {
animation: v3-venue-crowd-sway 3.6s ease-in-out infinite;
}
.venue-mood-fx.venue-mood-state-fire .venue-mood-crowd,
#player.venue-mood-full .venue-mood-fx.venue-mood-state-fire .venue-mood-crowd {
animation: v3-venue-crowd-bounce 2.2s ease-in-out infinite;
}
.venue-mood-fx.venue-mood-state-fire .venue-mood-lights {
animation: v3-venue-fire-glow 2.8s ease-in-out infinite;
}
.venue-mood-fx.venue-mood-state-smoke .venue-mood-haze {
animation: v3-venue-smoke-drift 5.5s ease-in-out infinite;
}
}
@keyframes v3-venue-crowd-sway {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
@keyframes v3-venue-crowd-bounce {
0%, 100% { transform: translateY(0) scaleY(1); }
35% { transform: translateY(-4px) scaleY(1.02); }
65% { transform: translateY(-1px) scaleY(1); }
}
@keyframes v3-venue-fire-glow {
0%, 100% { filter: brightness(1); }
50% { filter: brightness(1.12); }
}
@keyframes v3-venue-smoke-drift {
0%, 100% { opacity: .65; transform: translateY(0); }
50% { opacity: .78; transform: translateY(-3px); }
}
@media (prefers-reduced-motion: reduce) {
.venue-mood-fx .venue-mood-crowd,
.venue-mood-fx .venue-mood-lights,
.venue-mood-fx .venue-mood-haze {
animation: none !important;
}
}
/* — Up Next pill (top-right, persistent) — */
#player-hud .v3-upnext {
display: flex;
align-items: center;
gap: .5rem;
padding: .45rem .9rem;
border-radius: .75rem;
background: rgba(15, 23, 42, .7);
border: 1px solid rgba(51, 65, 85, .5);
backdrop-filter: blur(6px);
font-size: .85rem;
pointer-events: auto;
}
#player-hud .v3-upnext.hidden { display: none; }
/* — Live performance HUD (top-right, read-only) — */
.v3-live-performance-hud {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: .2rem;
min-width: 9.5rem;
padding: .55rem .85rem;
border-radius: .85rem;
background: rgba(15, 23, 42, .78);
border: 1px solid rgba(71, 85, 105, .55);
backdrop-filter: blur(8px);
box-shadow: 0 8px 24px rgba(0, 0, 0, .35);
pointer-events: none;
transition: border-color .35s ease, box-shadow .35s ease, background .35s ease;
}
.v3-live-performance-hud.hidden { display: none; }
.v3-live-performance-heading {
display: flex;
align-items: baseline;
gap: .45rem;
line-height: 1;
}
.v3-live-performance-tag {
font-size: .68rem;
font-weight: 800;
letter-spacing: .14em;
color: #94a3b8;
}
.v3-live-performance-percent {
font-size: 2rem;
font-weight: 900;
letter-spacing: -.03em;
color: #f8fafc;
font-variant-numeric: tabular-nums;
}
.v3-live-performance-hits,
.v3-live-performance-streak {
font-size: .78rem;
font-weight: 600;
color: #cbd5e1;
font-variant-numeric: tabular-nums;
}
.v3-live-performance-state {
font-size: .72rem;
font-weight: 700;
color: #e2e8f0;
min-height: 1rem;
}
.v3-live-performance-hud.is-idle .v3-live-performance-percent { color: #94a3b8; }
.v3-live-performance-hud.is-fire {
border-color: rgba(251, 146, 60, .75);
background: linear-gradient(145deg, rgba(67, 20, 7, .82), rgba(15, 23, 42, .88));
box-shadow: 0 0 22px rgba(251, 146, 60, .28);
animation: v3-live-fire-pulse 2.4s ease-in-out infinite;
}
.v3-live-performance-hud.is-fire .v3-live-performance-percent {
color: #fdba74;
text-shadow: 0 0 12px rgba(251, 146, 60, .45);
}
.v3-live-performance-hud.is-fire .v3-live-performance-state { color: #fb923c; }
.v3-live-performance-hud.is-strong {
border-color: rgba(34, 211, 238, .55);
box-shadow: 0 0 16px rgba(34, 211, 238, .18);
}
.v3-live-performance-hud.is-strong .v3-live-performance-percent { color: #67e8f9; }
.v3-live-performance-hud.is-steady {
border-color: rgba(100, 116, 139, .55);
}
.v3-live-performance-hud.is-recovery {
border-color: rgba(250, 204, 21, .45);
background: rgba(30, 27, 12, .72);
}
.v3-live-performance-hud.is-recovery .v3-live-performance-percent { color: #fde68a; }
.v3-live-performance-hud.is-smoke {
border-color: rgba(100, 116, 139, .35);
background: rgba(15, 23, 42, .62);
opacity: .92;
animation: v3-live-smoke-haze 3.2s ease-in-out infinite;
}
.v3-live-performance-hud.is-smoke .v3-live-performance-percent { color: #94a3b8; }
.v3-live-performance-hud.is-smoke .v3-live-performance-state { color: #cbd5e1; }
@keyframes v3-live-fire-pulse {
0%, 100% { box-shadow: 0 0 18px rgba(251, 146, 60, .22); }
50% { box-shadow: 0 0 26px rgba(251, 146, 60, .38); }
}
@keyframes v3-live-smoke-haze {
0%, 100% { transform: translateY(0); opacity: .92; }
50% { transform: translateY(-1px); opacity: .84; }
}
.v3-upnext-name {
font-weight: 800;
background: linear-gradient(90deg, #22d3ee, #a855f7, #f472b6);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* — Left rail hover-zone — */
.v3-railzone {
position: absolute;
left: 0; top: 0; bottom: 0;
z-index: 30;
display: flex;
align-items: center;
pointer-events: none; /* only the catcher + rail take pointer events */
}
.v3-railzone::before { /* invisible left-edge strip that reveals the rail */
content: "";
position: absolute;
left: 0; top: 0; bottom: 0;
width: 96px;
pointer-events: auto;
}
.v3-rail {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: .25rem;
padding: 1.5rem .75rem;
pointer-events: auto;
opacity: 0;
transform: translateX(-12px);
transition: opacity .25s ease, transform .25s ease;
}
.v3-railzone:hover .v3-rail,
.v3-railzone:focus-within .v3-rail,
#player.pop-open .v3-rail { opacity: 1; transform: none; }
.v3-rail-icon {
position: relative;
width: 70px; height: 70px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 0;
cursor: pointer;
color: #8ba3ac;
transition: transform .18s ease, color .18s ease;
}
.v3-rail-border {
position: absolute;
inset: 0;
border: 2px solid rgba(139, 163, 172, .45);
border-radius: 50%;
transition: border-color .18s ease;
}
.v3-rail-border-gear {
border-color: rgba(139, 163, 172, .45);
}
.v3-rail-svg { width: 32px; height: 32px; fill: currentcolor; z-index: 1; }
.v3-rail-dot { width: 6px; height: 6px; background: #4a5d6e; border-radius: 2px; margin: 1rem 0; }
/* pop-out + active state */
.v3-rail-icon:hover,
.v3-rail-icon:focus-visible { color: #e8eef1; }
.v3-rail-icon:hover .v3-rail-border,
.v3-rail-icon:focus-visible .v3-rail-border { border-color: rgba(203, 213, 225, .85); }
.v3-rail-icon.is-active { color: #e2e8f0; }
.v3-rail-icon.is-active .v3-rail-border { border-color: #22d3ee; }
@media (prefers-reduced-motion: no-preference) {
.v3-rail-icon:hover,
.v3-rail-icon:focus-visible { transform: translateX(6px) scale(1.12); }
}
/* — Section Practice circular rail icon (v3 chrome) — */
#v3-player-rail .section-practice-control--v3 {
padding: 0;
margin: 0;
width: auto;
display: flex;
justify-content: center;
flex-shrink: 0;
}
#v3-player-rail .section-practice-control--v3 .section-practice-pill {
position: relative;
width: 70px;
height: 70px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
gap: 0;
min-width: 0;
color: #8ba3ac;
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
cursor: pointer;
transition: transform .18s ease, color .18s ease;
}
#v3-player-rail .section-practice-control--v3 .section-practice-pill-text,
#v3-player-rail .section-practice-control--v3 .section-practice-pill-caret {
display: none !important;
}
#v3-player-rail .section-practice-control--v3 .section-practice-pill-icon {
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
font-size: 32px;
line-height: 1;
}
#v3-player-rail .section-practice-control--v3 .section-practice-pill-icon .v3-rail-svg,
#v3-player-rail .section-practice-control--v3 .section-practice-pill-svg {
width: 32px;
height: 32px;
fill: currentcolor;
}
#v3-player-rail .section-practice-control--v3 .section-practice-pill:hover,
#v3-player-rail .section-practice-control--v3 .section-practice-pill:focus-visible {
color: #e8eef1;
background: transparent;
box-shadow: none;
}
#v3-player-rail .section-practice-control--v3 .section-practice-pill:hover .v3-rail-border,
#v3-player-rail .section-practice-control--v3 .section-practice-pill:focus-visible .v3-rail-border {
border-color: rgba(203, 213, 225, .85);
}
#v3-player-rail .section-practice-control--v3 .section-practice-pill[aria-expanded="true"],
#v3-player-rail .section-practice-control--v3 .section-practice-pill.section-practice-pill--active {
color: #e2e8f0;
background: transparent;
box-shadow: none;
}
#v3-player-rail .section-practice-control--v3 .section-practice-pill[aria-expanded="true"] .v3-rail-border,
#v3-player-rail .section-practice-control--v3 .section-practice-pill.section-practice-pill--active .v3-rail-border {
border-color: #22d3ee;
}
@media (prefers-reduced-motion: no-preference) {
#v3-player-rail .section-practice-control--v3 .section-practice-pill:hover,
#v3-player-rail .section-practice-control--v3 .section-practice-pill:focus-visible {
transform: translateX(6px) scale(1.12);
}
}
#v3-player-rail .section-practice-control--v3 #section-practice-bar {
left: calc(100% + 10px);
right: auto;
bottom: auto;
top: 0;
min-width: 230px;
max-width: min(340px, calc(100vw - 120px));
padding: .9rem;
border-radius: .9rem;
border: 1px solid rgba(51, 65, 85, .6);
background: rgba(15, 23, 42, .96);
box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
}
/* — Rail feature popovers — */
.v3-rail-pop {
position: absolute;
left: 92px;
top: 50%;
transform: translateY(-50%);
min-width: 230px;
max-width: 340px;
pointer-events: auto;
background: rgba(15, 23, 42, .96);
border: 1px solid rgba(51, 65, 85, .6);
border-radius: .9rem;
box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
padding: .9rem;
display: flex;
flex-direction: column;
gap: .6rem;
z-index: 40;
}
.v3-rail-pop.hidden { display: none; }
.v3-pop-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
font-size: .8rem;
color: #cbd5e1;
}
.v3-pop-label {
color: #94a3b8;
font-size: .72rem;
text-transform: uppercase;
letter-spacing: .04em;
white-space: nowrap;
}
.v3-pop-select,
.v3-pop-btn {
background: #1e293b;
border: 1px solid #334155;
color: #e2e8f0;
border-radius: .5rem;
padding: .35rem .6rem;
font-size: .8rem;
outline: none;
cursor: pointer;
}
.v3-pop-btn:hover { background: #334155; }
.v3-pop-pin {
background: #1e293b;
border: 1px solid #334155;
color: #94a3b8;
border-radius: .5rem;
width: 2rem; height: 2rem;
cursor: pointer;
}
.v3-pop-val { color: #94a3b8; font-size: .75rem; min-width: 3rem; text-align: right; }
.v3-pop-close {
margin-top: .25rem;
background: #1e293b;
border: 1px solid #334155;
color: #94a3b8;
border-radius: .5rem;
padding: .4rem;
font-size: .8rem;
cursor: pointer;
}
.v3-pop-close:hover { background: rgba(127, 29, 29, .5); color: #fca5a5; }
/* — Bottom transport (auto-hide) — */
#player .v3-transport {
position: absolute;
left: 0; right: 0; bottom: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
gap: 1.25rem;
padding: 1rem;
background: transparent;
opacity: 0;
transform: translateY(12px);
transition: opacity .25s ease, transform .25s ease;
}
#player.chrome-active .v3-transport,
#player.pop-open .v3-transport,
#player .v3-transport:hover { opacity: 1; transform: none; }
#player.chrome-idle { cursor: none; }
.v3-transport-mid { display: flex; align-items: center; gap: .75rem; }
.v3-seek {
width: 2.5rem; height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
background: rgba(30, 41, 59, .7);
border: 1px solid rgba(51, 65, 85, .6);
border-radius: 999px;
color: #cbd5e1;
cursor: pointer;
transition: background .15s;
}
.v3-seek:hover { background: rgba(51, 65, 85, .9); }
.v3-play {
width: 4rem; height: 2.6rem;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #475569, #334155);
border: 1px solid rgba(100, 116, 139, .6);
border-radius: 999px;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0, 0, 0, .4);
}
.v3-play:hover { filter: brightness(1.15); }
/* speed widget (bars + slider + label + presets) */
.v3-speed-cluster {
display: flex;
align-items: center;
gap: .5rem;
flex-shrink: 1;
min-width: 0;
max-width: min(52vw, 28rem);
}
.v3-speed { display: flex; align-items: center; gap: .5rem; flex-shrink: 0; }
.v3-speed-presets {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: .2rem;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.v3-speed-presets::-webkit-scrollbar { display: none; }
.v3-speed-preset-btn {
flex: 0 0 auto;
padding: .15rem .45rem;
min-width: 1.75rem;
font-size: .7rem;
font-weight: 700;
line-height: 1.25;
color: #cbd5e1;
background: rgba(30, 41, 59, .9);
border: 1px solid rgba(71, 85, 105, .85);
border-radius: 999px;
cursor: pointer;
transition: background .15s, border-color .15s, color .15s, box-shadow .15s;
}
.v3-speed-preset-btn:hover {
color: #e2e8f0;
background: rgba(51, 65, 85, .9);
border-color: rgba(100, 116, 139, .8);
}
.v3-speed-preset-btn[data-sweet-spot="1"]:not(.v3-speed-preset-active) {
border-color: rgba(34, 211, 238, .35);
}
.v3-speed-preset-active {
color: #0f172a;
background: #22d3ee;
border-color: #22d3ee;
}
.v3-speed-preset-btn[data-sweet-spot="1"].v3-speed-preset-active {
box-shadow: 0 0 8px rgba(34, 211, 238, .45);
}
.v3-speed-bars { display: inline-flex; align-items: flex-end; gap: 2px; height: 22px; }
.v3-speed-bars i {
width: 3px;
background: #475569;
border-radius: 1px;
transition: background .15s, height .15s;
}
.v3-speed-bars i:nth-child(1) { height: 5px; }
.v3-speed-bars i:nth-child(2) { height: 7px; }
.v3-speed-bars i:nth-child(3) { height: 9px; }
.v3-speed-bars i:nth-child(4) { height: 11px; }
.v3-speed-bars i:nth-child(5) { height: 13px; }
.v3-speed-bars i:nth-child(6) { height: 16px; }
.v3-speed-bars i:nth-child(7) { height: 19px; }
.v3-speed-bars i:nth-child(8) { height: 22px; }
.v3-speed-bars i.on { background: #22d3ee; }
.v3-speed-slider { width: 90px; }
.v3-speed-label { color: #94a3b8; font-size: .8rem; min-width: 2.4rem; }
@media (max-width: 720px) {
.v3-speed-cluster {
flex-wrap: wrap;
max-width: min(62vw, 18rem);
}
.v3-speed-presets { flex-wrap: wrap; overflow-x: visible; }
}
/* rainbow chevrons (speed-level indicator) */
.v3-chevrons { display: inline-flex; align-items: center; gap: 1px; flex-shrink: 0; }
.v3-chevrons i {
width: 10px; height: 14px;
background: #475569;
opacity: .35;
transition: opacity .15s;
clip-path: polygon(0 0, 55% 50%, 0 100%, 45% 100%, 100% 50%, 45% 0);
}
.v3-chevrons i.on { opacity: 1; }
.v3-chevrons i:nth-child(1).on { background: #f87171; }
.v3-chevrons i:nth-child(2).on { background: #fb923c; }
.v3-chevrons i:nth-child(3).on { background: #facc15; }
.v3-chevrons i:nth-child(4).on { background: #4ade80; }
.v3-chevrons i:nth-child(5).on { background: #22d3ee; }
/* — Plugin-control slot (host re-homes plugin controls here from the transport) — */
.v3-plugin-slot {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .4rem;
}
.v3-plugin-slot > * { margin: 0 !important; } /* neutralize legacy ml-1/ml-auto spacing */
.v3-pop-empty { color: #64748b; font-size: .75rem; font-style: italic; }
/* small count badge on the Plugins rail icon */
.v3-rail-badge {
position: absolute;
top: 6px; right: 6px;
min-width: 16px; height: 16px;
padding: 0 4px;
display: flex; align-items: center; justify-content: center;
background: #22d3ee; color: #0f172a;
font-size: 10px; font-weight: 800; line-height: 1;
border-radius: 999px; z-index: 2;
}
.v3-rail-badge[hidden] { display: none; }
/* ── v3 UI font: Rubik ──────────────────────────────────────────────────────
`font-display` is a SHARED Tailwind token (Inter, used by the v2 body too), so
it can't be repointed in tailwind.config without changing v2. v3.css is loaded
only by the v3 page, so scope Rubik here. `body.font-display` (element+class)
outranks Tailwind's `.font-display` (class) by specificity. The Rubik webfont
is loaded via a <link> in static/v3/index.html. */
body.font-display { font-family: Rubik, system-ui, sans-serif; }
/* ── v3 Plugins page — Pedalboard ────────────────────────────────────────────
Plugins grouped onto category "pedalboards"; each plugin is a draggable
stompbox pedal with decorative patch cables (static/v3/pedal-cables.js)
sagging between them. Custom CSS (not Tailwind utilities) so the prebuilt
stylesheet needn't be regenerated. Palette mirrors the fb-* tokens. */
/* The title sits on the board's top-right as a browser-style tab; it also
doubles as the collapse toggle. The section reserves a row of height for it. */
.v3-board-section { position: relative; padding-top: 30px; margin-bottom: 2rem; }
.v3-board-title {
position: absolute; top: 0; right: 22px; z-index: 3;
display: flex; align-items: center; gap: .4rem;
height: 31px; box-sizing: border-box; padding: 0 .75rem;
cursor: pointer; text-align: left; font-family: inherit;
font-size: .72rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
color: #cbd5e1;
/* tab body: matches the board's top gradient stop, rounded only on top, and
its bottom overlaps the board's top edge so they read as one piece. */
background: #1c2740;
border: 1px solid rgba(51, 65, 85, .55);
border-bottom: none;
border-radius: .55rem .55rem 0 0;
}
.v3-board-title:hover { color: #fff; background: #243150; }
.v3-board-title:focus-visible { outline: 2px solid #0ea5e9; outline-offset: 1px; }
/* When collapsed there's no board below, so close the tab into a full pill. */
.v3-board-section.collapsed .v3-board-title {
border-bottom: 1px solid rgba(51, 65, 85, .55);
border-radius: .55rem;
}
.v3-board-chevron {
width: 14px; height: 14px; flex: none; color: #64748b;
transition: transform .15s ease;
}
.v3-board-title:hover .v3-board-chevron { color: #94a3b8; }
.v3-board-section.collapsed .v3-board-chevron { transform: rotate(-90deg); }
.v3-board-section.collapsed .v3-pedalboard { display: none; }
.v3-board-count {
font-size: .65rem; font-weight: 700; color: #cbd5e1;
background: rgba(51, 65, 85, .5); border-radius: 999px; padding: .05rem .45rem;
}
/* The board surface: a dark, subtly textured panel the pedals sit on. */
.v3-pedalboard {
position: relative;
border-radius: 1rem;
border: 1px solid rgba(51, 65, 85, .5); /* fb-border */
background:
repeating-linear-gradient(45deg, rgba(255, 255, 255, .012) 0 2px, transparent 2px 9px),
radial-gradient(120% 140% at 50% 0%, #1c2740 0%, #131b2e 60%, #0d1422 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .03), inset 0 -18px 40px rgba(0, 0, 0, .35);
min-height: 220px;
overflow: hidden;
}
/* Decorative patch-cable overlay — never blocks pedal clicks. */
.v3-cable-layer {
position: absolute; inset: 0;
width: 100%; height: 100%;
pointer-events: none;
z-index: 1;
}
.v3-cable {
fill: none;
stroke: #e2683a; /* warm 1/4" cable */
stroke-width: 4;
stroke-linecap: round;
opacity: .85;
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, .45));
}
/* Stompbox pedal — a transparent pedal photo "skin" (static/v3/pedals/*) set as
the card background; the plugin's name + description overlay in a label panel.
The skin is random-per-plugin and persisted (see plugins-page.js). The card
dimensions must match PEDAL_W/PEDAL_H in plugins-page.js. */
.v3-pedal {
position: absolute;
/* width/height are set responsively per board in plugins-page.js so a fixed
number of pedals fit per row. */
box-sizing: border-box;
color: #f8fafc;
cursor: grab;
user-select: none;
touch-action: none; /* let pointer-drag own the gesture */
z-index: 2;
transition: filter .12s ease;
}
/* The skin photo lives on ::before so the disabled-dim filter only touches it,
not the glow ring / label / badges (which must stay full-colour). */
.v3-pedal::before {
content: ''; position: absolute; inset: 0; z-index: 0;
background-image: var(--skin);
background-size: 100% 100%; background-position: center; background-repeat: no-repeat;
}
.v3-pedal:hover { filter: brightness(1.06); }
.v3-pedal:focus-visible { outline: 2px solid #0ea5e9; outline-offset: 3px; border-radius: .8rem; }
.v3-pedal-dragging { cursor: grabbing; z-index: 5; }
/* Footswitch status glow: green when the plugin is active, red when disabled.
Full-pedal overlay (same crop+pad as the skins) so the ring sits on the
footswitch and scales with the pedal. */
.v3-pedal-glow {
position: absolute; inset: 0; z-index: 1; pointer-events: none;
background-image: url('/static/v3/overlay-active.png');
background-size: 100% 100%; background-position: center; background-repeat: no-repeat;
}
.v3-pedal-off .v3-pedal-glow { background-image: url('/static/v3/overlay-inactive.png'); }
/* Footswitch click hotspot over the photo's stomp button (toggles on/off). */
.v3-pedal-foot {
position: absolute; left: 50%; bottom: 16%; transform: translate(-50%, -2px);
width: 16%; aspect-ratio: 1 / 1; border-radius: 50%; /* scales with pedal */
background: transparent; border: 0; padding: 0; cursor: pointer; z-index: 5;
}
.v3-pedal-foot:focus-visible { outline: 2px solid #0ea5e9; outline-offset: 2px; border-radius: 50%; }
/* Disabled plugin: dim + desaturate only the SKIN (so the red glow stays vivid),
and show an "off" badge. */
.v3-pedal-off::before { filter: grayscale(.85) brightness(.5); }
.v3-pedal-offbadge {
position: absolute; top: 5px; left: 5px; z-index: 6; display: none;
font-size: .55rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase;
color: #fecaca; background: rgba(60, 10, 10, .8);
border: 1px solid rgba(239, 68, 68, .6); border-radius: .3rem; padding: .05rem .35rem;
}
.v3-pedal-off .v3-pedal-offbadge { display: block; }
/* The drawn cable plug picks up a soft drop shadow so it lifts off the cable. */
.v3-cable-plug { filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, .55)); }
/* Overlay label on the pedal face — over the artwork band between the photo's
knobs (top) and footswitch (bottom). Translucent so the skin still reads. */
.v3-pedal-label {
position: absolute; left: 9%; right: 9%; top: 31%; bottom: calc(28% + 4px); z-index: 2;
transform: translateY(-10px); /* nudge up */
display: flex; flex-direction: column; align-items: center; gap: .3rem;
padding: .55rem .7rem;
background: rgba(7, 10, 16, .74);
border: 1px solid rgba(255, 255, 255, .1);
border-radius: .5rem;
backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
box-shadow: 0 4px 14px rgba(0, 0, 0, .5);
overflow: hidden; text-align: center;
}
/* Thumbnail fills the panel (description is now a hover tooltip, not on the face). */
.v3-pedal-thumb {
flex: 1 1 auto; min-height: 0;
width: 100%; height: auto; object-fit: contain; border-radius: .35rem;
display: block; -webkit-user-drag: none;
}
.v3-pedal-name {
flex: none;
font-size: .86rem; font-weight: 700; line-height: 1.15;
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.v3-pedal-err { font-size: .6rem; color: #fca5a5; overflow-wrap: break-word; }
.v3-pedal-pill {
display: inline-block;
font-size: .58rem; font-weight: 700; padding: .08rem .4rem; border-radius: 999px;
}
.v3-pill-bad { background: rgba(239, 68, 68, .25); color: #fca5a5; }
.v3-pill-wait { background: rgba(234, 179, 8, .25); color: #fde68a; }
.v3-pedal-bundled {
position: absolute; top: 4px; right: 4px; z-index: 4;
font-size: .52rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
color: #ddd6fe; background: rgba(76, 29, 149, .6);
border: 1px solid rgba(139, 92, 246, .4); border-radius: .3rem; padding: .04rem .28rem;
}
+36
View File
@@ -0,0 +1,36 @@
/*
* fee[dB]ack v0.3.0 Venue instrument POV resolver.
*
* Maps arrangement / instrument labels to venue background POVs.
* Vocals/karaoke routing uses the active arrangement name only not
* lyrics overlay visibility during guitar practice.
*/
(function (root) {
'use strict';
const POV_IDS = Object.freeze(['guitar', 'bass', 'drums', 'piano', 'vocals']);
function resolveVenueInstrumentPov(input) {
const s = String(input == null ? '' : input).trim().toLowerCase();
if (!s) return 'guitar';
if (/\b(drums?)\b/.test(s)) return 'drums';
if (/\b(bass)\b/.test(s)) return 'bass';
if (/\b(piano|keys|keyboard)\b/.test(s)) return 'piano';
if (/\b(karaoke|vocal|vocals|lyric|lyrics|sing|singing)\b/.test(s)) return 'vocals';
if (/\b(lead|rhythm|guitar|combo)\b/.test(s)) return 'guitar';
return 'guitar';
}
function isVocalsKaraokeArrangement(input) {
return resolveVenueInstrumentPov(input) === 'vocals';
}
const api = {
POV_IDS,
resolveVenueInstrumentPov,
isVocalsKaraokeArrangement,
};
if (root) root.v3VenueInstrumentPov = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
}(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : null)));
+520
View File
@@ -0,0 +1,520 @@
/*
* fee[dB]ack v0.3.0 Venue Mood FX (visual-only reactive stage/crowd).
*
* Phase 1: bottom CSS crowd strip is disabled until real venue scene assets
* ship inside highway_3d. Venue visualization still maps to 3D Highway; a
* subtle full-screen wash + hint text only.
*
* Subscribes to v3:live-performance-state from the Live Performance HUD.
*/
(function (root) {
'use strict';
const KEY = 'slopsmith-venue-mood-fx';
const MOTION_KEY = 'slopsmith-venue-motion';
const SETTINGS = Object.freeze({ OFF: 'off', SUBTLE: 'subtle', FULL: 'full' });
const DEFAULT = SETTINGS.SUBTLE;
const MOTION_DEFAULT = SETTINGS.SUBTLE;
const VENUE_VIZ_ID = 'venue';
// Bottom blob/crowd DOM strip — off until asset-based 3D venue scene lands.
const STRIP_OVERLAY_ENABLED = false;
function isVenueVisualizationActive(vizMode) {
if (root && root.v3VenueViz && typeof root.v3VenueViz.isVenueVisualization === 'function') {
return root.v3VenueViz.isVenueVisualization(vizMode);
}
return String(vizMode || '') === VENUE_VIZ_ID;
}
const MOOD_SETTING_CLASSES = ['venue-mood-off', 'venue-mood-subtle', 'venue-mood-full'];
const MOOD_STATE_CLASSES = [
'venue-mood-state-idle',
'venue-mood-state-steady',
'venue-mood-state-strong',
'venue-mood-state-fire',
'venue-mood-state-recovery',
'venue-mood-state-smoke',
];
const MOOD_STATE_IDS = ['idle', 'steady', 'strong', 'fire', 'recovery', 'smoke'];
function normalizeVenueMoodSetting(value) {
if (value === SETTINGS.OFF || value === SETTINGS.FULL) return value;
if (value === SETTINGS.SUBTLE) return SETTINGS.SUBTLE;
return DEFAULT;
}
function normalizeVenueMotionSetting(value) {
return normalizeVenueMoodSetting(value);
}
function venueMotionProfile(mode) {
const m = normalizeVenueMotionSetting(mode);
if (m === SETTINGS.OFF) {
return Object.freeze({
breathe: 0, parallax: 0, hazeDrift: 0, warmthPulse: 0, shimmer: 0,
});
}
if (m === SETTINGS.FULL) {
return Object.freeze({
breathe: 0.014, parallax: 0.010, hazeDrift: 0.020, warmthPulse: 0.028, shimmer: 0.10,
});
}
return Object.freeze({
breathe: 0.005, parallax: 0.004, hazeDrift: 0.007, warmthPulse: 0.010, shimmer: 0.04,
});
}
function venueMotionIntensity(mode) {
const profile = venueMotionProfile(mode);
return profile.breathe + profile.parallax + profile.hazeDrift;
}
function prefersReducedMotion() {
if (typeof window === 'undefined' || !window.matchMedia) return false;
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (_) { return false; }
}
function venueMoodClassForState(state) {
const s = String(state || 'idle').toLowerCase();
return MOOD_STATE_IDS.includes(s) ? 'venue-mood-state-' + s : 'venue-mood-state-idle';
}
function readVizMode() {
try {
if (root && root.v3VenueViz && typeof root.v3VenueViz.getSelectedVizId === 'function') {
return String(root.v3VenueViz.getSelectedVizId());
}
const sel = typeof document !== 'undefined' ? document.getElementById('viz-picker') : null;
if (sel && sel.value) return String(sel.value);
return localStorage.getItem('vizSelection') || 'default';
} catch (_) {
return 'default';
}
}
function isElementDisplayed(el) {
if (!el) return false;
try {
if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') {
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
if (parseFloat(style.opacity) === 0) return false;
} else if (el.style && el.style.display === 'none') {
return false;
}
return true;
} catch (_) {
return false;
}
}
function hasVisible3dWrap() {
try {
if (typeof document === 'undefined') return false;
const wraps = document.querySelectorAll('.h3d-wrap[data-h3d-primary], .h3d-wrap');
for (let i = 0; i < wraps.length; i++) {
if (isElementDisplayed(wraps[i])) return true;
}
return false;
} catch (_) {
return false;
}
}
// Back-compat alias — only counts a wrap that is actually displayed.
function hasActive3dWrap() {
return hasVisible3dWrap();
}
function isSuppressedBy3d(setting, vizMode, has3dWrap) {
if (normalizeVenueMoodSetting(setting) === SETTINGS.OFF) return false;
if (isVenueVisualizationActive(vizMode)) return false;
const viz = String(vizMode || 'default');
if (viz === 'highway_3d') return true;
if (viz === 'auto' && has3dWrap) return true;
return false;
}
function shouldShowStripOverlay(setting, vizMode, has3dWrap, sessionActive) {
if (!STRIP_OVERLAY_ENABLED) return false;
if (!sessionActive) return false;
return shouldEnableVenueMood(setting, vizMode, has3dWrap);
}
function shouldEnableVenueMood(setting, vizMode, has3dWrap) {
if (normalizeVenueMoodSetting(setting) === SETTINGS.OFF) return false;
return !isSuppressedBy3d(setting, vizMode, has3dWrap);
}
function get() {
try { return normalizeVenueMoodSetting(localStorage.getItem(KEY)); } catch (_) { return DEFAULT; }
}
// True once the user (or a prior session) has persisted a mood preference.
// DEFAULT is 'subtle', so get() can't distinguish "never set" from an
// explicit 'subtle' — read the raw key to tell them apart.
function hasStoredMoodSetting() {
try { return localStorage.getItem(KEY) != null; } catch (_) { return false; }
}
function getMotion() {
try { return normalizeVenueMotionSetting(localStorage.getItem(MOTION_KEY)); } catch (_) { return MOTION_DEFAULT; }
}
function set(value) {
const next = normalizeVenueMoodSetting(value);
try { localStorage.setItem(KEY, next); } catch (_) { /* private mode / quota */ }
const sel = typeof document !== 'undefined' ? document.getElementById('venue-mood-fx-select') : null;
if (sel && sel.value !== next) sel.value = next;
return next;
}
function setMotion(value) {
const next = normalizeVenueMotionSetting(value);
try { localStorage.setItem(MOTION_KEY, next); } catch (_) { /* private mode / quota */ }
const sel = typeof document !== 'undefined' ? document.getElementById('venue-motion-select') : null;
if (sel && sel.value !== next) sel.value = next;
syncMotionToRenderer(next);
return next;
}
function syncMotionToRenderer(mode) {
const next = normalizeVenueMotionSetting(mode != null ? mode : getMotion());
const host = (typeof window !== 'undefined' && window)
|| (typeof globalThis !== 'undefined' && globalThis)
|| root;
if (host && typeof host.h3dVenueSceneSetMotionMode === 'function') {
host.h3dVenueSceneSetMotionMode(next);
}
}
function applyClasses(player, layer, setting, state, visible) {
if (!player || !player.classList) return;
MOOD_SETTING_CLASSES.forEach((c) => player.classList.remove(c));
MOOD_STATE_CLASSES.forEach((c) => player.classList.remove(c));
player.classList.add('venue-mood-' + normalizeVenueMoodSetting(setting));
player.classList.add(venueMoodClassForState(state));
if (layer && layer.classList) {
MOOD_STATE_CLASSES.forEach((c) => layer.classList.remove(c));
layer.classList.add(venueMoodClassForState(state));
if (visible) {
layer.classList.remove('hidden');
layer.setAttribute('aria-hidden', 'false');
} else {
layer.classList.add('hidden');
layer.setAttribute('aria-hidden', 'true');
}
}
}
let _runtime = null;
let _lastLoggedSetting = null;
function bindRuntime(sm, dom) {
if (!sm || typeof sm.on !== 'function') return null;
const player = dom && dom.player
? dom.player
: (typeof document !== 'undefined' ? document.getElementById('player') : null);
const layer = dom && dom.layer
? dom.layer
: (typeof document !== 'undefined' ? document.getElementById('v3-venue-mood-fx') : null);
const hint3d = dom && dom.hint3d
? dom.hint3d
: (typeof document !== 'undefined' ? document.getElementById('venue-mood-fx-3d-hint') : null);
const hintVenue = dom && dom.hintVenue
? dom.hintVenue
: (typeof document !== 'undefined' ? document.getElementById('venue-viz-mode-hint') : null);
const hintFailed = dom && dom.hintFailed
? dom.hintFailed
: (typeof document !== 'undefined' ? document.getElementById('venue-viz-load-failed-hint') : null);
const badge = dom && dom.badge
? dom.badge
: (typeof document !== 'undefined' ? document.getElementById('v3-venue-mode-badge') : null);
const sceneWash = dom && dom.sceneWash
? dom.sceneWash
: (typeof document !== 'undefined' ? document.getElementById('v3-venue-scene-wash') : null);
let sessionActive = false;
let currentState = 'idle';
function updateVizHints(vizMode) {
const isVenue = isVenueVisualizationActive(vizMode);
const loadFailed = !!(root && root.v3VenueScene3d && typeof root.v3VenueScene3d.getState === 'function' &&
root.v3VenueScene3d.getState().loadFailed);
const sceneLoaded = !!(root && root.v3VenueScene3d && typeof root.v3VenueScene3d.isSceneLoaded === 'function' &&
root.v3VenueScene3d.isSceneLoaded());
if (hintVenue && hintVenue.classList) {
if (isVenue && !loadFailed) hintVenue.classList.remove('hidden');
else hintVenue.classList.add('hidden');
}
if (hintFailed && hintFailed.classList) {
if (isVenue && loadFailed) hintFailed.classList.remove('hidden');
else hintFailed.classList.add('hidden');
}
if (hint3d && hint3d.classList) {
if (String(vizMode || '') === 'highway_3d') hint3d.classList.remove('hidden');
else hint3d.classList.add('hidden');
}
if (sceneLoaded || loadFailed) syncVenuePlaceholder(vizMode);
}
function syncVenueVizPlayerClass(vizMode) {
if (root && root.v3VenueViz && typeof root.v3VenueViz.syncPlayerVizClass === 'function') {
root.v3VenueViz.syncPlayerVizClass(vizMode);
} else if (player && player.classList) {
const on = isVenueVisualizationActive(vizMode);
if (typeof player.classList.toggle === 'function') {
player.classList.toggle('is-venue-visualization', on);
} else if (on) {
player.classList.add('is-venue-visualization');
} else {
player.classList.remove('is-venue-visualization');
}
}
}
function syncVenuePlaceholder(vizMode) {
const isVenue = isVenueVisualizationActive(vizMode);
const showDom = isVenue && !!(root && root.v3VenueScene3d &&
typeof root.v3VenueScene3d.shouldShowDomPlaceholder === 'function' &&
root.v3VenueScene3d.shouldShowDomPlaceholder());
if (badge && badge.classList) {
if (showDom) badge.classList.remove('hidden');
else badge.classList.add('hidden');
}
if (sceneWash && sceneWash.classList) {
if (showDom) sceneWash.classList.remove('hidden');
else sceneWash.classList.add('hidden');
}
}
function syncVenueSceneClass(vizMode) {
if (!player || !player.classList) return;
const pending = isVenueVisualizationActive(vizMode) && sessionActive && !STRIP_OVERLAY_ENABLED;
if (typeof player.classList.toggle === 'function') {
player.classList.toggle('venue-scene-pending', pending);
} else if (pending) {
player.classList.add('venue-scene-pending');
} else {
player.classList.remove('venue-scene-pending');
}
}
function refreshVisibility() {
const setting = get();
const vizMode = readVizMode();
const has3dWrap = hasVisible3dWrap();
const enabled = shouldEnableVenueMood(setting, vizMode, has3dWrap);
const showStrip = shouldShowStripOverlay(setting, vizMode, has3dWrap, sessionActive);
applyClasses(player, layer, setting, currentState, showStrip);
syncVenueVizPlayerClass(vizMode);
syncVenueSceneClass(vizMode);
syncVenuePlaceholder(vizMode);
updateVizHints(vizMode);
return showStrip;
}
function getState() {
const setting = get();
const vizMode = readVizMode();
const has3dWrap = hasVisible3dWrap();
const enabled = shouldEnableVenueMood(setting, vizMode, has3dWrap);
const showStrip = shouldShowStripOverlay(setting, vizMode, has3dWrap, sessionActive);
return {
setting,
vizMode,
has3dWrap,
enabled,
sessionActive,
visible: showStrip,
stripOverlayEnabled: STRIP_OVERLAY_ENABLED,
state: currentState,
suppressedBy3d: isSuppressedBy3d(setting, vizMode, has3dWrap),
isVenueVisualization: isVenueVisualizationActive(vizMode),
venueScenePending: isVenueVisualizationActive(vizMode) && sessionActive && !STRIP_OVERLAY_ENABLED,
};
}
function onPerformanceState(e) {
const d = (e && e.detail) || {};
if (!sessionActive) return;
const next = d.state || 'idle';
// This fires once per note hit/miss. When the mood state is unchanged
// (e.g. a run of hits all in 'fire'), refreshVisibility would recompute
// an identical result while forcing a style/layout recalc via
// hasVisible3dWrap()'s getComputedStyle loop — so bail on no-op events.
if (next === currentState) return;
currentState = next;
refreshVisibility();
}
function beginSession() {
sessionActive = true;
currentState = 'idle';
refreshVisibility();
}
function endSession() {
sessionActive = false;
currentState = 'idle';
refreshVisibility();
}
function onSettingChange(value) {
const next = set(value);
const st = getState();
if (next !== _lastLoggedSetting) {
_lastLoggedSetting = next;
console.info('[venue-mood] setting=' + st.setting
+ ' state=' + st.state
+ ' suppressed=' + st.suppressedBy3d
+ ' visible=' + st.visible);
}
refreshVisibility();
}
function bindMotionSelect() {
const sel = typeof document !== 'undefined' ? document.getElementById('venue-motion-select') : null;
if (!sel || sel.dataset.venueMotionBound === '1') return;
sel.dataset.venueMotionBound = '1';
sel.value = getMotion();
sel.addEventListener('change', () => { setMotion(sel.value); });
syncMotionToRenderer(sel.value);
}
function bindSelect() {
const sel = typeof document !== 'undefined' ? document.getElementById('venue-mood-fx-select') : null;
if (!sel || sel.dataset.venueMoodBound === '1') return;
sel.dataset.venueMoodBound = '1';
sel.value = get();
sel.addEventListener('change', () => { onSettingChange(sel.value); });
}
function bindVizPicker() {
const sel = typeof document !== 'undefined' ? document.getElementById('viz-picker') : null;
if (!sel || sel.dataset.venueMoodVizBound === '1') return;
sel.dataset.venueMoodVizBound = '1';
sel.addEventListener('change', () => {
// Default to FULL only the first time Venue is chosen; never
// clobber a preference the user already set (incl. 'subtle'/'off').
if (isVenueVisualizationActive(sel.value) && !hasStoredMoodSetting()) {
set(SETTINGS.FULL);
}
refreshVisibility();
});
}
sm.on('v3:live-performance-state', onPerformanceState);
sm.on('song:loading', beginSession);
sm.on('song:arrangement-changed', () => {
if (!sessionActive) return;
currentState = 'idle';
refreshVisibility();
});
sm.on('song:stop', endSession);
sm.on('song:ended', endSession);
sm.on('viz:renderer:ready', refreshVisibility);
sm.on('viz:reverted', refreshVisibility);
bindSelect();
bindMotionSelect();
applyClasses(player, layer, get(), 'idle', false);
refreshVisibility();
const runtime = {
getSetting: get,
setSetting: set,
refreshVisibility,
beginSession,
endSession,
onPerformanceState,
onSettingChange,
getSessionActive: () => sessionActive,
getCurrentState: () => currentState,
getState,
};
_runtime = runtime;
return runtime;
}
function onVenueVisualizationSelected() {
// Default the mood to FULL only on the first-ever Venue selection; once
// the user has a stored preference (incl. an explicit 'subtle' or 'off')
// re-entering Venue must not overwrite it.
if (!hasStoredMoodSetting()) set(SETTINGS.FULL);
if (_runtime && typeof _runtime.refreshVisibility === 'function') _runtime.refreshVisibility();
}
function getState() {
if (_runtime && typeof _runtime.getState === 'function') return _runtime.getState();
const setting = get();
const vizMode = readVizMode();
const has3dWrap = hasVisible3dWrap();
const enabled = shouldEnableVenueMood(setting, vizMode, has3dWrap);
return {
setting,
vizMode,
has3dWrap,
enabled,
sessionActive: false,
visible: false,
stripOverlayEnabled: STRIP_OVERLAY_ENABLED,
state: 'idle',
suppressedBy3d: isSuppressedBy3d(setting, vizMode, has3dWrap),
isVenueVisualization: isVenueVisualizationActive(vizMode),
venueScenePending: false,
};
}
const api = {
KEY,
MOTION_KEY,
SETTINGS,
DEFAULT,
MOTION_DEFAULT,
VENUE_VIZ_ID,
STRIP_OVERLAY_ENABLED,
MOOD_SETTING_CLASSES,
MOOD_STATE_CLASSES,
normalizeVenueMoodSetting,
normalizeVenueMotionSetting,
venueMotionProfile,
venueMotionIntensity,
prefersReducedMotion,
venueMoodClassForState,
readVizMode,
isVenueVisualizationActive,
isElementDisplayed,
hasVisible3dWrap,
hasActive3dWrap,
isSuppressedBy3d,
shouldEnableVenueMood,
shouldShowStripOverlay,
get,
getMotion,
set,
setMotion,
syncMotionToRenderer,
applyClasses,
bindRuntime,
onVenueVisualizationSelected,
getState,
};
if (root) root.v3VenueMoodFx = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof document !== 'undefined') {
const boot = () => {
const sm = root && root.slopsmith;
if (sm) bindRuntime(sm);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}
}(typeof window !== 'undefined' ? window : null));
+251
View File
@@ -0,0 +1,251 @@
/*
* fee[dB]ack v0.3.0 Venue 3D scene bridge.
*
* Activates the highway_3d `venue` background style when Visualization = Venue.
* Reacts to v3:live-performance-state for lighting mood (read-only).
*/
(function (root) {
'use strict';
const THEME_ID = 'small-club';
const ASSET_BASE = '/static/assets/venue/themes/small-club/';
const BG_PLATE = 'bg-plate.png';
const BG_PLATE_WEBP = 'bg-plate.webp';
let _active = false;
let _assetsLoaded = false;
let _loadFailed = false;
let _lastMood = 'idle';
let _bound = false;
function isVenueViz() {
if (root && root.v3VenueViz && typeof root.v3VenueViz.isVenueVisualization === 'function') {
const sel = root.v3VenueViz.getSelectedVizId
? root.v3VenueViz.getSelectedVizId()
: root.v3VenueViz.readVizSelection();
return root.v3VenueViz.isVenueVisualization(sel);
}
try {
const sel = document.getElementById('viz-picker');
if (sel && sel.value) return String(sel.value) === 'venue';
return localStorage.getItem('vizSelection') === 'venue';
} catch (_) {
return false;
}
}
function h3dApi(name) {
return root && typeof root[name] === 'function' ? root[name] : null;
}
function setH3dActive(on) {
const fn = h3dApi('h3dVenueSceneSetActive');
if (fn) fn(!!on);
}
function setH3dMood(state) {
const fn = h3dApi('h3dVenueSceneSetMood');
if (fn) fn(state);
}
function readH3dState() {
const fn = h3dApi('h3dVenueSceneGetState');
return fn ? fn() : null;
}
function syncPlaceholderVisibility() {
try {
if (root && root.v3VenueViz && typeof root.v3VenueViz.syncPlayerVizClass === 'function') {
const id = root.v3VenueViz.getSelectedVizId
? root.v3VenueViz.getSelectedVizId()
: root.v3VenueViz.readVizSelection();
root.v3VenueViz.syncPlayerVizClass(id);
}
} catch (_) { /* visual-only */ }
try {
if (root && root.v3VenueMoodFx && typeof root.v3VenueMoodFx.onVenueVisualizationSelected === 'function' &&
isVenueViz()) {
root.v3VenueMoodFx.onVenueVisualizationSelected();
}
} catch (_) { /* visual-only */ }
}
function readArrangementSignal() {
// Intentional karaoke/vocals signal: active arrangement name from the
// highway WS (user selected Vocals in #arr-select). Do NOT use
// highway.getLyricsVisible() — lyrics overlay stays on during normal
// guitar practice and must not force vocals POV.
try {
const si = root.highway && typeof root.highway.getSongInfo === 'function'
? root.highway.getSongInfo()
: null;
if (si && si.arrangement) return si.arrangement;
const cs = root.slopsmith && root.slopsmith.currentSong;
if (cs && cs.arrangement) return cs.arrangement;
if (cs && cs.arrangementSmartName) return cs.arrangementSmartName;
} catch (_) { /* visual-only */ }
return '';
}
function syncInstrumentPov() {
const fn = h3dApi('h3dVenueSceneSetInstrumentPov');
if (fn) fn(readArrangementSignal());
}
function activate() {
if (_active) {
syncInstrumentPov();
syncVenueMotion();
return;
}
_active = true;
_assetsLoaded = false;
_loadFailed = false;
setH3dActive(true);
setH3dMood(_lastMood);
syncInstrumentPov();
syncVenueMotion();
}
function syncVenueMotion() {
const motionApi = root && root.v3VenueMoodFx;
const mode = motionApi && typeof motionApi.getMotion === 'function'
? motionApi.getMotion()
: 'subtle';
const fn = h3dApi('h3dVenueSceneSetMotionMode');
if (fn) fn(mode);
else if (motionApi && typeof motionApi.syncMotionToRenderer === 'function') {
motionApi.syncMotionToRenderer(mode);
}
}
function deactivate() {
if (!_active) {
setH3dActive(false);
return;
}
_active = false;
_assetsLoaded = false;
_loadFailed = false;
setH3dActive(false);
syncPlaceholderVisibility();
}
function syncViz(vizId) {
const id = String(vizId || '');
if (id === 'venue') {
activate();
} else {
deactivate();
}
}
function onPerformanceState(e) {
if (!_active) return;
const d = (e && e.detail) || {};
const state = String(d.state || 'idle').toLowerCase();
// v3:live-performance-state fires per note hit/miss; skip the renderer
// push when the mood is unchanged (e.g. a run of hits all in 'fire').
if (state === _lastMood) return;
_lastMood = state;
setH3dMood(state);
}
function onAssetsLoaded() {
_assetsLoaded = true;
_loadFailed = false;
syncPlaceholderVisibility();
}
function onAssetsFailed() {
_loadFailed = true;
_assetsLoaded = false;
syncPlaceholderVisibility();
}
function bindRuntime() {
if (_bound) return;
_bound = true;
const sm = root && root.slopsmith;
if (sm && typeof sm.on === 'function') {
sm.on('v3:live-performance-state', onPerformanceState);
sm.on('song:loaded', () => {
if (_active) syncInstrumentPov();
});
sm.on('arrangement:changed', () => {
if (_active) syncInstrumentPov();
});
sm.on('song:arrangement-changed', () => {
if (_active) syncInstrumentPov();
});
sm.on('viz:renderer:ready', () => {
if (isVenueViz()) activate();
else deactivate();
});
sm.on('viz:reverted', () => deactivate());
}
if (isVenueViz()) activate();
}
function getState() {
const h3d = readH3dState();
const povApi = root && root.v3VenueInstrumentPov;
const arrangement = readArrangementSignal();
const instrumentPov = povApi && typeof povApi.resolveVenueInstrumentPov === 'function'
? povApi.resolveVenueInstrumentPov(arrangement)
: 'guitar';
return {
active: _active,
themeId: THEME_ID,
assetBase: ASSET_BASE,
arrangement,
instrumentPov,
assetsLoaded: _assetsLoaded || !!(h3d && h3d.assetsLoaded),
loadFailed: _loadFailed || !!(h3d && h3d.loadFailed),
mood: _lastMood,
h3dVenueState: h3d,
isVenueViz: isVenueViz(),
};
}
function shouldShowDomPlaceholder() {
// V2: no on-screen construction badge during Venue playback.
return false;
}
const api = {
THEME_ID,
ASSET_BASE,
BG_PLATE,
BG_PLATE_WEBP,
activate,
deactivate,
syncViz,
onAssetsLoaded,
onAssetsFailed,
onPerformanceState,
bindRuntime,
getState,
syncInstrumentPov,
syncVenueMotion,
readArrangementSignal,
shouldShowDomPlaceholder,
isSceneLoaded: () => {
if (_assetsLoaded) return true;
const h3d = readH3dState();
return !!(h3d && h3d.assetsLoaded);
},
};
if (root) root.v3VenueScene3d = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof document !== 'undefined') {
const boot = () => bindRuntime();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}
}(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : null)));
+121
View File
@@ -0,0 +1,121 @@
/*
* fee[dB]ack v0.3.0 Venue visualization adapter.
*
* "Venue" is a built-in viz picker entry that reuses the highway_3d renderer
* while keeping vizSelection='venue' so venue mood FX stay enabled.
*/
(function (root) {
'use strict';
const VENUE_VIZ_ID = 'venue';
const RENDERER_VIZ_ID = 'highway_3d';
const PLAYER_CLASS = 'is-venue-visualization';
let _selectedVizId = null;
let _activeRendererId = null;
function isVenueVisualization(vizMode) {
return String(vizMode || '') === VENUE_VIZ_ID;
}
function resolveRendererVizId(vizMode) {
return isVenueVisualization(vizMode) ? RENDERER_VIZ_ID : vizMode;
}
function readStoredVizSelection() {
try {
return localStorage.getItem('vizSelection') || 'default';
} catch (_) {
return 'default';
}
}
function readVizSelection() {
try {
const sel = typeof document !== 'undefined' ? document.getElementById('viz-picker') : null;
if (sel && sel.value) return String(sel.value);
return readStoredVizSelection();
} catch (_) {
return 'default';
}
}
function getSelectedVizId() {
if (_selectedVizId) return _selectedVizId;
return readVizSelection();
}
function syncVenuePlaceholder(vizMode) {
try {
const isVenue = isVenueVisualization(vizMode);
const showDom = isVenue && !!(root && root.v3VenueScene3d &&
typeof root.v3VenueScene3d.shouldShowDomPlaceholder === 'function' &&
root.v3VenueScene3d.shouldShowDomPlaceholder());
const badge = typeof document !== 'undefined' ? document.getElementById('v3-venue-mode-badge') : null;
const wash = typeof document !== 'undefined' ? document.getElementById('v3-venue-scene-wash') : null;
if (badge && badge.classList) {
if (showDom) badge.classList.remove('hidden');
else badge.classList.add('hidden');
}
if (wash && wash.classList) {
if (showDom) wash.classList.remove('hidden');
else wash.classList.add('hidden');
}
} catch (_) { /* visual-only */ }
}
function syncPlayerVizClass(vizMode) {
try {
const player = typeof document !== 'undefined' ? document.getElementById('player') : null;
if (!player || !player.classList) return;
player.classList.toggle(PLAYER_CLASS, isVenueVisualization(vizMode));
syncVenuePlaceholder(vizMode);
} catch (_) { /* visual-only */ }
}
function setSelectedVizId(id) {
_selectedVizId = id == null ? null : String(id);
syncPlayerVizClass(_selectedVizId);
}
function notifyRendererInstalled(rendererId) {
_activeRendererId = rendererId == null ? null : String(rendererId);
}
function getState() {
const player = typeof document !== 'undefined' ? document.getElementById('player') : null;
const pickerViz = readVizSelection();
const selectedViz = getSelectedVizId();
const storedVizSelection = readStoredVizSelection();
const moodApi = root && root.v3VenueMoodFx;
return {
selectedViz,
storedVizSelection,
pickerViz,
activeRendererId: _activeRendererId,
isVenueVisualization: isVenueVisualization(selectedViz),
playerHasVenueClass: !!(player && player.classList && player.classList.contains(PLAYER_CLASS)),
playerClasses: player ? String(player.className || '') : '',
hasVenueMoodApi: !!(moodApi && typeof moodApi.getState === 'function'),
venueMoodState: moodApi && typeof moodApi.getState === 'function' ? moodApi.getState() : null,
};
}
const api = {
VENUE_VIZ_ID,
RENDERER_VIZ_ID,
PLAYER_CLASS,
isVenueVisualization,
resolveRendererVizId,
readVizSelection,
readStoredVizSelection,
getSelectedVizId,
syncPlayerVizClass,
setSelectedVizId,
notifyRendererInstalled,
getState,
};
if (root) root.v3VenueViz = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
}(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : null)));