mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 12:52:29 +00:00
* 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>
585 lines
31 KiB
JavaScript
585 lines
31 KiB
JavaScript
/*
|
||
* 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
||
})();
|