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:
@@ -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) {}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user