feedBack/static/v3/plugins-page.js
Bret Mogilefsky af2949677a
rename: slopsmith → feedBack, byron → got-feedBack (#537)
* Update GitHub repo references from feedback* to feedBack*

* rename: slopsmith -> feedBack, byron -> got-feedBack

Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias

Refs: #rename-slopsmith

* rename: complete regen against current main + fix backward-compat alias

Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).

Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
  window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
  onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
  progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
  vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
  FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
  path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
  resolution, and move the bus aliases to AFTER the _feedBackExisting merge
  block so they reference the fully-assembled object (also fixes the
  loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
  and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
  source labels.

Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rename: implement advertised backward-compat + prune dead community plugins

Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.

Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
  (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
  tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
  SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
  `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
  `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).

Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
  and clear the legacy key on write — so a user's update-channel preference
  survives the rename instead of resetting to "stable".

Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
  tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).

Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:01 +02:00

585 lines
31 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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 own SCREEN (its page); plugins with
* no screen fall back to their settings panel. 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.feedBack;
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', virtuoso: 'practice',
// games
flappy_bend: 'game', minigames: '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. A plugin's own screen (its full
// "page") is the primary surface, so clicking the pedal opens it when one
// exists — matching the stompbox metaphor (step on the pedal → see the
// pedal). Plugins with no screen fall back to their settings panel; the
// settings of a screen+settings plugin (e.g. audio_engine) stay reachable
// from the main Settings screen. Pure — unit-tested.
function settingsTarget(p) {
if (openable(p)) return { kind: 'screen', id: p.id };
if (p && p.has_settings) return { kind: 'settings', 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 : '');
// The action verb tracks what a click actually opens (screen-first,
// then settings) so the a11y label never promises the wrong surface.
var kind = settingsTarget(p).kind;
var action = kind === 'screen' ? ' — open' : (kind === 'settings' ? ' — open settings' : '');
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) + action) + '" title="' + esc(tip) + '"' + style + '>' +
'<span class="v3-pedal-glow" aria-hidden="true"></span>' +
(p.bundled ? '<span class="v3-pedal-bundled" title="Ships with FeedBack 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 if (p && p.has_settings) {
// Screen declared but not mounted yet — fall back to the
// settings panel rather than stranding the user on a toast.
if (window.showScreen) window.showScreen('settings');
openSettingsPanel(p.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();
})();