Loading...
'; + + try { + const data = await apiGet('/irs'); + renderIRList(listEl, currentEl, data); + } catch (e) { + listEl.innerHTML = `Error: ${e.message}
`; + } +} + +function renderIRList(listEl, currentEl, data) { + const currentName = data.current; + + // Current IR display + if (currentEl) { + if (currentName) { + currentEl.innerHTML = `${escapeHtml(currentName)}`; + document.getElementById('unload-ir-btn').style.display = 'inline-flex'; + } else { + currentEl.innerHTML = `None loaded`; + document.getElementById('unload-ir-btn').style.display = 'none'; + } + } + + // Available IRs + if (!data.irs || data.irs.length === 0) { + listEl.innerHTML = 'No .wav IR files found in the pedal directory.
'; + return; + } + + let html = ''; + for (const ir of data.irs) { + const isLoaded = ir.name === currentName; + html += `Loading...
'; + + try { + const data = await apiGet('/models'); + renderModelList(listEl, currentEl, data); + } catch (e) { + listEl.innerHTML = `Error: ${e.message}
`; + } +} + +function renderModelList(listEl, currentEl, data) { + const currentName = data.current; + + // Current model display + if (currentEl) { + if (currentName) { + currentEl.innerHTML = `${escapeHtml(currentName)}`; + document.getElementById('unload-model-btn').style.display = 'inline-flex'; + } else { + currentEl.innerHTML = `None loaded`; + document.getElementById('unload-model-btn').style.display = 'none'; + } + } + + // Available models + if (!data.models || data.models.length === 0) { + listEl.innerHTML = 'No .nam models found in the pedal directory.
'; + return; + } + + let html = ''; + for (const m of data.models) { + const isLoaded = m.name === currentName; + html += `Loading...
'; + + try { + const data = await apiGet('/presets'); + renderBanks(container, data.banks); + } catch (e) { + container.innerHTML = `Error loading presets: ${e.message}
`; + } +} + +function renderBanks(container, banks) { + if (!banks || banks.length === 0) { + container.innerHTML = 'No presets found. Create one from the Dashboard.
'; + return; + } + + let html = ''; + for (const bank of banks) { + html += `No FX blocks in chain
`; + } + + 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); \ No newline at end of file diff --git a/src/web/static/style.css b/src/web/static/style.css new file mode 100644 index 0000000..6985a1a --- /dev/null +++ b/src/web/static/style.css @@ -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; } \ No newline at end of file diff --git a/src/web/static/websocket.js b/src/web/static/websocket.js new file mode 100644 index 0000000..5d8d4dc --- /dev/null +++ b/src/web/static/websocket.js @@ -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(); \ No newline at end of file diff --git a/src/web/templates/base.html b/src/web/templates/base.html new file mode 100644 index 0000000..0a27419 --- /dev/null +++ b/src/web/templates/base.html @@ -0,0 +1,37 @@ + + + + + + + +Connect to pedal to see active FX chain.
+Loading IR files...
+Upload .wav IR files to the pedal's IR directory.
+ + +Loading models...
+Upload .nam model files to the pedal's model directory.
+ + +Loading presets...
+pedal.localhttp://pedal.local:8080