Build: Web UI + mDNS (pedal.local)

FastAPI web server with REST + WebSocket, 5 Jinja2 HTML pages,
mobile-friendly dark theme CSS, page-specific JS controllers,
and Avahi mDNS service advertisement for pedal.local.

Files:
  src/web/server.py          — FastAPI app with 17 REST endpoints + /ws WebSocket
  src/web/__init__.py         — Package init with WebServer, WebServerDeps exports
  src/web/templates/*.html    — Dashboard, Presets, Models, IRs, Settings (base)
  src/web/static/style.css    — Dark mobile-first CSS (80-82% max-width)
  src/web/static/websocket.js — Auto-reconnecting WebSocket manager
  src/web/static/app.js       — Dashboard live updates + REST helpers
  src/web/static/presets.js   — Bank/preset CRUD modal UI
  src/web/static/models.js    — NAM model list/load/upload
  src/web/static/irs.js       — IR file list/load/upload
  etc/avahi/pi-multifx-pedal.service — Avahi service advertisement
  scripts/setup-mdns.sh       — One-shot mDNS setup (avahi, hostname, restart)
  main.py                     — Wire WebServer into boot/shutdown
  requirements.txt            — Add fastapi, uvicorn, jinja2, python-multipart
  tests/test_web.py           — 36 tests (pages, REST, WebSocket, errors)

All 36 tests pass.
This commit is contained in:
2026-06-08 00:57:26 -04:00
parent b7f5488efd
commit 1c5b728e8b
19 changed files with 2884 additions and 2 deletions
+182
View File
@@ -0,0 +1,182 @@
/**
* Main app.js — Dashboard live updates, global handlers.
*
* Relies on pedalWS (from websocket.js) for real-time state pushes
* and provides REST helper functions used by all pages.
*/
/* ── REST helpers ───────────────────────────────────────────────── */
async function apiGet(path) {
const res = await fetch(`/api${path}`);
if (!res.ok) throw new Error(`API GET ${path}: ${res.status}`);
return res.json();
}
async function apiPost(path, body = {}) {
const res = await fetch(`/api${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`API POST ${path}: ${res.status}`);
return res.json();
}
async function apiPut(path, body = {}) {
const res = await fetch(`/api${path}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`API PUT ${path}: ${res.status}`);
return res.json();
}
async function apiDelete(path) {
const res = await fetch(`/api${path}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`API DELETE ${path}: ${res.status}`);
return res.json();
}
/* ── Dashboard updates (via WebSocket) ──────────────────────────── */
pedalWS.on('connected', (msg) => {
if (msg.state) updateDashboardState(msg.state);
});
pedalWS.on('state', (msg) => {
if (msg.state) updateDashboardState(msg.state);
});
pedalWS.on('preset_changed', (msg) => {
if (msg.preset) {
const nameEl = document.getElementById('preset-name');
const metaEl = document.getElementById('preset-meta');
if (nameEl) nameEl.textContent = msg.preset.name;
if (metaEl) metaEl.textContent = `Bank ${msg.preset.bank} — Program ${msg.preset.program}`;
}
if (msg.state) updateDashboardState(msg.state);
});
pedalWS.on('bypass_changed', (msg) => {
const btn = document.getElementById('bypass-btn');
if (btn) {
btn.textContent = msg.bypass ? 'BYPASSED' : 'ACTIVE';
btn.classList.toggle('active', msg.bypass);
}
});
pedalWS.on('tuner_changed', (msg) => {
const btn = document.getElementById('tuner-btn');
if (btn) {
btn.textContent = msg.tuner_enabled ? 'ON' : 'OFF';
btn.classList.toggle('active', msg.tuner_enabled);
}
const sBtn = document.getElementById('settings-tuner-btn');
if (sBtn) {
sBtn.textContent = msg.tuner_enabled ? 'ON' : 'OFF';
sBtn.classList.toggle('active', msg.tuner_enabled);
}
});
function updateDashboardState(state) {
if (!state || !state.connected) return;
// Preset info
if (state.current_preset) {
const nameEl = document.getElementById('preset-name');
const metaEl = document.getElementById('preset-meta');
if (nameEl) nameEl.textContent = state.current_preset.name;
if (metaEl) metaEl.textContent = `Bank ${state.current_preset.bank} — Program ${state.current_preset.program}`;
}
// Bypass
const bypassBtn = document.getElementById('bypass-btn');
if (bypassBtn) {
bypassBtn.textContent = state.bypass ? 'BYPASSED' : 'ACTIVE';
bypassBtn.classList.toggle('active', state.bypass);
}
// Tuner
const tunerBtn = document.getElementById('tuner-btn');
if (tunerBtn) {
tunerBtn.textContent = state.tuner_enabled ? 'ON' : 'OFF';
tunerBtn.classList.toggle('active', state.tuner_enabled);
}
const sTunerBtn = document.getElementById('settings-tuner-btn');
if (sTunerBtn) {
sTunerBtn.textContent = state.tuner_enabled ? 'ON' : 'OFF';
sTunerBtn.classList.toggle('active', state.tuner_enabled);
}
// Volume
const volSlider = document.getElementById('volume-slider');
const volText = document.getElementById('volume-text');
const sVolSlider = document.getElementById('settings-volume');
const sVolText = document.getElementById('settings-volume-text');
const vol = Math.round((state.master_volume || 0.8) * 100);
if (volSlider) volSlider.value = vol;
if (volText) volText.textContent = vol + '%';
if (sVolSlider) sVolSlider.value = vol;
if (sVolText) sVolText.textContent = vol + '%';
// NAM model
const modelNameEl = document.getElementById('model-name');
if (modelNameEl) modelNameEl.textContent = state.nam_model || 'None loaded';
// IR
const irNameEl = document.getElementById('ir-name');
if (irNameEl) irNameEl.textContent = state.ir_name || 'None loaded';
}
/* ── Dashboard action handlers ─────────────────────────────────── */
async function toggleBypass() {
const data = await apiPost('/bypass');
// WS will update UI
}
async function toggleTuner() {
const enabled = !document.getElementById('tuner-btn')?.classList.contains('active');
const data = await apiPost('/tuner', { enabled });
}
async function setVolume(val) {
const vol = parseInt(val) / 100;
await apiPut('/volume', { volume: vol });
}
async function activatePreset(direction, delta) {
const state = await apiGet('/state');
if (!state.connected) return;
const bank = state.current_preset?.bank || 0;
const program = (state.current_preset?.program || 0) + delta;
// Clamp 0-3 within the bank
const clamped = Math.max(0, Math.min(3, program));
if (clamped !== program) return; // at edge
await apiPost(`/presets/${bank}/${clamped}/activate`);
}
/* ── Initial page load: fetch state ─────────────────────────────── */
document.addEventListener('DOMContentLoaded', async () => {
// If we're on the dashboard, fetch initial state
if (document.getElementById('preset-name')) {
try {
const state = await apiGet('/state');
updateDashboardState(state);
} catch (e) {
console.warn('Could not fetch initial state:', e);
}
}
// Update WebSocket client count on settings page
const wsCount = document.getElementById('ws-count');
if (wsCount) {
try {
const state = await apiGet('/state');
wsCount.textContent = pedalWS._handlers ? '1' : '0';
} catch (e) {}
}
});
+95
View File
@@ -0,0 +1,95 @@
/**
* irs.js — IR Manager page.
*/
async function loadIRs() {
const listEl = document.getElementById('ir-list');
const currentEl = document.getElementById('current-ir');
listEl.innerHTML = '<p class="loading">Loading...</p>';
try {
const data = await apiGet('/irs');
renderIRList(listEl, currentEl, data);
} catch (e) {
listEl.innerHTML = `<p class="text-muted">Error: ${e.message}</p>`;
}
}
function renderIRList(listEl, currentEl, data) {
const currentName = data.current;
// Current IR display
if (currentEl) {
if (currentName) {
currentEl.innerHTML = `<span class="ir-name">${escapeHtml(currentName)}</span>`;
document.getElementById('unload-ir-btn').style.display = 'inline-flex';
} else {
currentEl.innerHTML = `<span class="ir-name">None loaded</span>`;
document.getElementById('unload-ir-btn').style.display = 'none';
}
}
// Available IRs
if (!data.irs || data.irs.length === 0) {
listEl.innerHTML = '<p class="text-muted">No .wav IR files found in the pedal directory.</p>';
return;
}
let html = '';
for (const ir of data.irs) {
const isLoaded = ir.name === currentName;
html += `<div class="ir-item ${isLoaded ? 'loaded' : ''}">`;
html += `<div>
<div class="ir-name">${escapeHtml(ir.name)}</div>
<div class="ir-meta">
${ir.num_taps} taps · ${ir.length_ms}ms @ ${ir.sample_rate}Hz
${isLoaded ? '· <strong style="color:var(--success)">CURRENT</strong>' : ''}
</div>
</div>`;
if (!isLoaded) {
html += `<button class="btn btn-primary" onclick='loadIR(${JSON.stringify(ir.path)})'
style="padding:4px 10px; font-size:0.78rem;">Load</button>`;
}
html += '</div>';
}
listEl.innerHTML = html;
}
async function loadIR(path) {
try {
await apiPost('/irs/load', { path });
loadIRs();
} catch (e) {
alert('Failed to load IR: ' + e.message);
}
}
async function unloadIR() {
try {
await apiPost('/irs/unload');
loadIRs();
} catch (e) {
alert('Failed to unload: ' + e.message);
}
}
async function uploadIR() {
const input = document.getElementById('ir-upload-input');
const file = input.files[0];
if (!file) return alert('Select a .wav file first.');
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/irs/upload', { method: 'POST', body: formData });
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
input.value = '';
loadIRs();
} catch (e) {
alert('Upload error: ' + e.message);
}
}
document.addEventListener('DOMContentLoaded', loadIRs);
+95
View File
@@ -0,0 +1,95 @@
/**
* models.js — NAM Model Manager page.
*/
async function loadModels() {
const listEl = document.getElementById('model-list');
const currentEl = document.getElementById('current-model');
listEl.innerHTML = '<p class="loading">Loading...</p>';
try {
const data = await apiGet('/models');
renderModelList(listEl, currentEl, data);
} catch (e) {
listEl.innerHTML = `<p class="text-muted">Error: ${e.message}</p>`;
}
}
function renderModelList(listEl, currentEl, data) {
const currentName = data.current;
// Current model display
if (currentEl) {
if (currentName) {
currentEl.innerHTML = `<span class="model-name">${escapeHtml(currentName)}</span>`;
document.getElementById('unload-model-btn').style.display = 'inline-flex';
} else {
currentEl.innerHTML = `<span class="model-name">None loaded</span>`;
document.getElementById('unload-model-btn').style.display = 'none';
}
}
// Available models
if (!data.models || data.models.length === 0) {
listEl.innerHTML = '<p class="text-muted">No .nam models found in the pedal directory.</p>';
return;
}
let html = '';
for (const m of data.models) {
const isLoaded = m.name === currentName;
html += `<div class="model-item ${isLoaded ? 'loaded' : ''}">`;
html += `<div>
<div class="model-name">${escapeHtml(m.name)}</div>
<div class="model-meta">
${m.architecture} · ${m.size_mb} MB
${isLoaded ? '· <strong style="color:var(--success)">CURRENT</strong>' : ''}
</div>
</div>`;
if (!isLoaded) {
html += `<button class="btn btn-primary" onclick='loadModel(${JSON.stringify(m.path)})'
style="padding:4px 10px; font-size:0.78rem;">Load</button>`;
}
html += '</div>';
}
listEl.innerHTML = html;
}
async function loadModel(path) {
try {
await apiPost('/models/load', { path });
loadModels();
} catch (e) {
alert('Failed to load model: ' + e.message);
}
}
async function unloadModel() {
try {
await apiPost('/models/unload');
loadModels();
} catch (e) {
alert('Failed to unload: ' + e.message);
}
}
async function uploadModel() {
const input = document.getElementById('model-upload-input');
const file = input.files[0];
if (!file) return alert('Select a .nam file first.');
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/models/upload', { method: 'POST', body: formData });
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
input.value = '';
loadModels();
} catch (e) {
alert('Upload error: ' + e.message);
}
}
document.addEventListener('DOMContentLoaded', loadModels);
+167
View File
@@ -0,0 +1,167 @@
/**
* presets.js — Preset Manager page: browse banks, load/save/edit.
*/
let currentBank = 0;
let currentProgram = 0;
async function loadPresets() {
const container = document.getElementById('bank-list');
container.innerHTML = '<p class="loading">Loading...</p>';
try {
const data = await apiGet('/presets');
renderBanks(container, data.banks);
} catch (e) {
container.innerHTML = `<p class="text-muted">Error loading presets: ${e.message}</p>`;
}
}
function renderBanks(container, banks) {
if (!banks || banks.length === 0) {
container.innerHTML = '<p class="text-muted">No presets found. Create one from the Dashboard.</p>';
return;
}
let html = '';
for (const bank of banks) {
html += `<div class="bank-section" data-bank="${bank.number}">`;
html += `<div class="bank-header">${bank.name}</div>`;
html += '<div class="preset-grid">';
for (let i = 0; i < 4; i++) {
const p = bank.presets && bank.presets[i];
if (p) {
const active = (p.bank === currentBank && p.program === currentProgram);
html += `<div class="preset-card ${active ? 'active' : ''}"
onclick="openPreset(${bank.number}, ${i})">`;
html += `<div class="preset-card-name">${escapeHtml(p.name)}</div>`;
html += `<div class="preset-card-slot">Bank ${bank.number} · ${i}</div>`;
html += '</div>';
} else {
html += `<div class="preset-card" style="opacity:0.4; cursor:default">
<div class="preset-card-name">— Empty —</div>
<div class="preset-card-slot">Slot ${i}</div>
</div>`;
}
}
html += '</div></div>';
}
container.innerHTML = html;
}
async function openPreset(bank, program) {
currentBank = bank;
currentProgram = program;
try {
const preset = await apiGet(`/presets/${bank}/${program}`);
const modal = document.getElementById('preset-modal');
const title = document.getElementById('modal-title');
const body = document.getElementById('modal-body');
title.textContent = `Edit: ${preset.name}`;
body.innerHTML = renderPresetForm(preset);
modal.style.display = 'flex';
} catch (e) {
console.error('Error loading preset:', e);
}
}
function renderPresetForm(preset) {
let html = `
<div class="param-row">
<span class="param-label">Name</span>
<input type="text" id="edit-preset-name" value="${escapeHtml(preset.name)}"
style="flex:1; background:var(--bg-secondary); border:1px solid var(--border);
color:var(--text-primary); padding:4px 8px; border-radius:4px;">
</div>
<div class="param-row">
<span class="param-label">Volume</span>
<input type="range" class="slider" id="edit-volume" min="0" max="100"
value="${Math.round((preset.master_volume || 0.8) * 100)}"
oninput="document.getElementById('edit-volume-text').textContent=this.value+'%'">
<span class="param-value" id="edit-volume-text">${Math.round((preset.master_volume || 0.8) * 100)}%</span>
</div>
<hr style="border-color:var(--border); margin:10px 0;">
<div style="font-size:0.85rem; color:var(--text-secondary); margin-bottom:8px;">FX Chain:</div>
`;
if (preset.chain && preset.chain.length > 0) {
for (const block of preset.chain) {
const label = block.fx_type.replace(/_/g, ' ');
const badged = block.enabled && !block.bypass
? `<span class="fx-badge" style="background:var(--success)">ON</span>`
: `<span class="fx-badge" style="background:var(--text-muted)">OFF</span>`;
html += `<div class="param-row" style="justify-content:space-between">
<span style="text-transform:capitalize; font-size:0.8rem;">${label} ${badged}</span>
</div>`;
}
} else {
html += `<p class="text-muted" style="font-size:0.8rem;">No FX blocks in chain</p>`;
}
return html;
}
async function savePreset() {
const modal = document.getElementById('preset-modal');
if (!modal || modal.style.display === 'none') return;
const nameInput = document.getElementById('edit-preset-name');
const volSlider = document.getElementById('edit-volume');
const name = nameInput ? nameInput.value : 'Preset';
const volume = volSlider ? parseInt(volSlider.value) / 100 : 0.8;
try {
// Fetch current preset data, update name/volume, re-save
const preset = await apiGet(`/presets/${currentBank}/${currentProgram}`);
preset.name = name;
preset.master_volume = volume;
await apiPut(`/presets/${currentBank}/${currentProgram}`, preset);
closeModal();
loadPresets();
} catch (e) {
console.error('Error saving preset:', e);
}
}
async function deletePreset() {
if (!confirm('Delete this preset?')) return;
try {
await apiDelete(`/presets/${currentBank}/${currentProgram}`);
closeModal();
loadPresets();
} catch (e) {
console.error('Error deleting preset:', e);
}
}
async function activateFromPresetPage(bank, program) {
try {
await apiPost(`/presets/${bank}/${program}/activate`);
currentBank = bank;
currentProgram = program;
loadPresets();
} catch (e) {
console.error('Error activating:', e);
}
}
function closeModal(event) {
const modal = document.getElementById('preset-modal');
if (event && event.target !== modal) return; // only close on overlay click
modal.style.display = 'none';
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load on page ready
document.addEventListener('DOMContentLoaded', loadPresets);
+654
View File
@@ -0,0 +1,654 @@
/* ── Pi Multi-FX Pedal — Web UI Styles ──────────────────────────────
Mobile-first, dark theme, 80-82% max-width for chat-bubble comfort.
─────────────────────────────────────────────────────────────────── */
/* ── Reset & Base ─────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg-primary: #0d0d0d;
--bg-secondary: #1a1a1a;
--bg-card: #222;
--bg-card-hover: #2a2a2a;
--text-primary: #e0e0e0;
--text-secondary: #999;
--text-muted: #666;
--accent: #4fc3f7;
--accent-dark: #0288d1;
--success: #66bb6a;
--danger: #ef5350;
--warning: #ffa726;
--border: #333;
--border-light: #444;
--radius: 8px;
--radius-sm: 4px;
--max-width: 82%;
--header-height: 48px;
}
html, body {
height: 100%;
width: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
/* ── App Container ────────────────────────────────────────────────── */
.app-container {
max-width: var(--max-width);
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Header ───────────────────────────────────────────────────────── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--header-height);
padding: 0 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
}
.header-left .logo {
color: var(--accent);
font-weight: 700;
font-size: 1rem;
text-decoration: none;
white-space: nowrap;
}
.header-nav {
display: flex;
gap: 4px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.header-nav::-webkit-scrollbar {
display: none;
}
.nav-link {
color: var(--text-secondary);
text-decoration: none;
padding: 6px 10px;
border-radius: var(--radius-sm);
font-size: 0.82rem;
white-space: nowrap;
transition: background 0.2s, color 0.2s;
}
.nav-link:hover {
background: var(--bg-card);
color: var(--text-primary);
}
.nav-link.active {
background: var(--accent-dark);
color: white;
}
.header-right {
display: flex;
align-items: center;
}
.connection-indicator {
font-size: 0.72rem;
padding: 3px 8px;
border-radius: 10px;
background: var(--danger);
color: white;
white-space: nowrap;
}
.connection-indicator.connected {
background: var(--success);
}
/* ── Main Content ─────────────────────────────────────────────────── */
.app-main {
flex: 1;
padding: 12px 0;
padding-bottom: 24px;
}
/* ── Cards ────────────────────────────────────────────────────────── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 12px;
overflow: hidden;
}
.card-header {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header h2 {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
}
.card-body {
padding: 14px;
}
/* ── Dashboard ────────────────────────────────────────────────────── */
.dashboard {
display: flex;
flex-direction: column;
gap: 0;
}
.preset-name-display {
font-size: 1.4rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 4px;
}
.preset-meta {
font-size: 0.82rem;
color: var(--text-secondary);
margin-bottom: 10px;
}
.preset-controls {
display: flex;
gap: 8px;
}
/* ── Status Row ───────────────────────────────────────────────────── */
.status-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.status-card {
text-align: center;
}
.status-label {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-value {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
/* ── Buttons ──────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--text-primary);
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.btn:hover {
background: var(--bg-card-hover);
}
.btn:active {
transform: scale(0.97);
}
.btn-primary {
background: var(--accent-dark);
border-color: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent);
}
.btn-secondary {
background: var(--bg-secondary);
border-color: var(--border);
}
.btn-danger {
background: var(--danger);
border-color: var(--danger);
color: white;
}
.btn-danger:hover {
background: #c62828;
}
.toggle-btn {
min-width: 90px;
font-weight: 600;
padding: 6px 12px;
font-size: 0.8rem;
}
.toggle-btn.active {
background: var(--success);
border-color: var(--success);
color: white;
}
.btn-close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.2rem;
cursor: pointer;
padding: 4px;
}
/* ── Sliders ──────────────────────────────────────────────────────── */
.slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--border);
outline: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--bg-card);
}
.slider::-moz-range-thumb {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--bg-card);
}
.volume-text {
font-size: 0.85rem;
color: var(--text-secondary);
min-width: 40px;
text-align: center;
}
/* ── FX Chain ────────────────────────────────────────────────────── */
.fx-chain {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.fx-block {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.8rem;
}
.fx-block.bypassed {
opacity: 0.5;
text-decoration: line-through;
}
.fx-block .fx-name {
color: var(--text-primary);
}
.fx-block .fx-badge {
font-size: 0.65rem;
padding: 1px 6px;
border-radius: 8px;
background: var(--accent-dark);
color: white;
}
.fx-block-chain-placeholder {
color: var(--text-muted);
font-size: 0.85rem;
}
/* ── Page layout ──────────────────────────────────────────────────── */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.page-header h1 {
font-size: 1.1rem;
font-weight: 700;
}
.loading {
color: var(--text-muted);
font-size: 0.85rem;
padding: 8px 0;
}
.hint {
color: var(--text-muted);
font-size: 0.8rem;
margin-bottom: 8px;
}
/* ── Bank / Preset List ───────────────────────────────────────────── */
.bank-section {
margin-bottom: 12px;
}
.bank-header {
font-size: 0.95rem;
font-weight: 600;
padding: 8px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
margin-bottom: 6px;
}
.preset-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.preset-card {
padding: 10px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: border-color 0.2s;
}
.preset-card:hover {
border-color: var(--accent-dark);
}
.preset-card.active {
border-color: var(--accent);
background: rgba(79, 195, 247, 0.1);
}
.preset-card .preset-card-name {
font-weight: 600;
font-size: 0.85rem;
margin-bottom: 4px;
}
.preset-card .preset-card-slot {
font-size: 0.72rem;
color: var(--text-muted);
}
/* ── Model / IR List ──────────────────────────────────────────────── */
.model-list, .ir-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.model-item, .ir-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.85rem;
}
.model-item.loaded, .ir-item.loaded {
border-color: var(--success);
}
.model-item .model-meta, .ir-item .ir-meta {
font-size: 0.72rem;
color: var(--text-muted);
}
.file-input {
display: block;
margin-bottom: 8px;
color: var(--text-secondary);
font-size: 0.85rem;
}
/* ── Settings ─────────────────────────────────────────────────────── */
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.setting-row:last-child {
border-bottom: none;
}
.setting-label {
font-size: 0.85rem;
color: var(--text-secondary);
}
.setting-value {
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 8px;
}
.setting-value code {
background: var(--bg-secondary);
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: 0.78rem;
}
.about-info p {
margin-bottom: 4px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.about-info a {
color: var(--accent);
}
/* ── Modal ─────────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 12px;
}
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
width: 100%;
max-width: 450px;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: 0.95rem;
}
.modal-body {
padding: 14px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
display: flex;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--border);
}
/* ── Param sliders in modal ────────────────────────────────────────── */
.param-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
}
.param-row .param-label {
font-size: 0.8rem;
color: var(--text-secondary);
min-width: 80px;
}
.param-row .param-value {
font-size: 0.78rem;
color: var(--text-muted);
min-width: 36px;
text-align: right;
}
.param-row .slider {
flex: 1;
}
/* ── Responsive ────────────────────────────────────────────────────── */
@media (max-width: 480px) {
:root {
--max-width: 82%;
}
.status-row {
grid-template-columns: 1fr;
}
.preset-grid {
grid-template-columns: 1fr 1fr;
}
.header-nav {
gap: 2px;
}
.nav-link {
padding: 6px 6px;
font-size: 0.75rem;
}
}
@media (min-width: 768px) {
:root {
--max-width: 600px;
}
}
@media (min-width: 1024px) {
:root {
--max-width: 720px;
}
}
/* ── Utilities ─────────────────────────────────────────────────────── */
.text-center { text-align: center; }
.text-right { text-align: right; }
.text-muted { color: var(--text-muted); }
.mt-8 { margin-top: 8px; }
.mb-8 { margin-bottom: 8px; }
.flex { display: flex; }
.flex-1 { flex: 1; }
.gap-8 { gap: 8px; }
+117
View File
@@ -0,0 +1,117 @@
/**
* WebSocket connection manager for Pi Multi-FX Pedal.
*
* Establishes a persistent WebSocket connection to the pedal server,
* auto-reconnects on disconnect, and dispatches typed events to
* registered handlers.
*
* Usage:
* const ws = new PedalWebSocket();
* ws.on('state', (data) => { ... });
* ws.on('preset_changed', (data) => { ... });
*/
class PedalWebSocket {
constructor(url = null) {
this.url = url || (() => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${window.location.host}/ws`;
})();
this.ws = null;
this._handlers = {};
this._reconnectTimer = null;
this._reconnectDelay = 1000; // start at 1s, back off
this._closed = false;
this.connect();
}
connect() {
if (this._closed) return;
try {
this.ws = new WebSocket(this.url);
} catch (e) {
console.error('[WS] Connection error:', e);
this._scheduleReconnect();
return;
}
this.ws.onopen = () => {
console.log('[WS] Connected');
this._reconnectDelay = 1000;
this._updateConnectionStatus(true);
};
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
this._dispatch(msg.type, msg);
} catch (e) {
console.warn('[WS] Parse error:', e);
}
};
this.ws.onclose = () => {
console.log('[WS] Disconnected');
this._updateConnectionStatus(false);
this._scheduleReconnect();
};
this.ws.onerror = (e) => {
console.error('[WS] Error:', e);
};
}
_scheduleReconnect() {
if (this._closed) return;
if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
this._reconnectTimer = setTimeout(() => {
console.log(`[WS] Reconnecting (delay=${this._reconnectDelay}ms)...`);
this._reconnectDelay = Math.min(this._reconnectDelay * 1.5, 15000);
this.connect();
}, this._reconnectDelay);
}
_updateConnectionStatus(connected) {
const el = document.getElementById('connection-status');
if (el) {
el.textContent = connected ? 'Connected' : 'Disconnected';
el.classList.toggle('connected', connected);
}
}
on(type, handler) {
if (!this._handlers[type]) this._handlers[type] = [];
this._handlers[type].push(handler);
}
off(type, handler) {
if (!this._handlers[type]) return;
this._handlers[type] = this._handlers[type].filter(h => h !== handler);
}
_dispatch(type, msg) {
const handlers = this._handlers[type];
if (handlers) {
for (const h of handlers) h(msg);
}
// Also fire wildcard handlers
const wildcard = this._handlers['*'];
if (wildcard) {
for (const h of wildcard) h(msg);
}
}
send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
close() {
this._closed = true;
if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
if (this.ws) this.ws.close();
}
}
// Singleton instance — shared across all pages
const pedalWS = new PedalWebSocket();