// ═══════════════════════════════════════════════════════════════════════ // RPi Audio Mixer — Web UI Application (SPA) // Vanilla JS + WebSocket, no external dependencies // ═══════════════════════════════════════════════════════════════════════ const App = { // ── State ──────────────────────────────────────────────────────────── ws: null, sessionToken: null, connected: false, reconnectTimer: null, paramState: {}, // channel → { param_type → value } mixerState: null, // full MixerStateSchema from server currentView: 'mixer', eqChannel: -1, // currently open EQ modal channel logExpanded: false, // ═════════════════════════════════════════════════════════════════════ // Logging // ═════════════════════════════════════════════════════════════════════ _logEntries: 0, log(msg, cls) { const el = document.getElementById('log'); const div = document.createElement('div'); div.className = 'log-entry ' + (cls || ''); div.textContent = new Date().toLocaleTimeString() + ' ' + msg; el.prepend(div); this._logEntries++; if (this._logEntries > 200) { el.lastChild?.remove(); this._logEntries--; } }, toggleLog() { this.logExpanded = !this.logExpanded; const body = document.getElementById('log-body'); const toggle = document.getElementById('log-toggle'); if (this.logExpanded) { body.classList.remove('collapsed'); toggle.textContent = '📋 Log ▴'; } else { body.classList.add('collapsed'); toggle.textContent = '📋 Log ▾'; } }, // ═════════════════════════════════════════════════════════════════════ // Connection // ═════════════════════════════════════════════════════════════════════ async login() { const apiKey = document.getElementById('apikey-input').value; if (!apiKey) { this.log('No API key entered', 'log-error'); return; } try { const resp = await fetch('/api/v1/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, body: JSON.stringify({ api_key: apiKey }), }); const data = await resp.json(); if (data.token) { this.sessionToken = data.token; document.getElementById('connect-btn').disabled = false; this.log('Session obtained (expires ' + data.expires_in + 's)', 'log-state'); this.connectWS(); } else { this.log('Login failed: ' + (data.detail || JSON.stringify(data)), 'log-error'); } } catch (e) { this.log('Login error: ' + e.message, 'log-error'); } }, connectWS() { if (!this.sessionToken) { this.log('No session token — login first', 'log-error'); return; } if (this.ws) { this.ws.close(); this.ws = null; } if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const url = proto + '//' + location.host + '/ws?session=' + this.sessionToken; this.setStatus('connecting', '● Connecting...'); this.ws = new WebSocket(url); this.ws.onopen = () => { this.connected = true; this.setStatus('connected', '● Live'); document.getElementById('connect-btn').disabled = true; this.log('WebSocket connected', 'log-ws'); }; this.ws.onclose = (e) => { this.connected = false; this.setStatus('disconnected', '● Offline'); document.getElementById('connect-btn').disabled = false; this.log('WS closed: ' + (e.reason || 'code ' + e.code) + ' — reconnecting in 3s', 'log-error'); // Auto-reconnect after 3s this.reconnectTimer = setTimeout(() => { if (this.sessionToken && !this.connected) { this.log('Reconnecting...', 'log-ws'); this.connectWS(); } }, 3000); }; this.ws.onerror = () => { this.log('WebSocket error', 'log-error'); }; this.ws.onmessage = (e) => this.handleMessage(e); }, disconnectWS() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.ws) { this.ws.close(); this.ws = null; } }, setStatus(cls, text) { const el = document.getElementById('conn-status'); el.className = 'status ' + cls; el.textContent = text; }, // ═════════════════════════════════════════════════════════════════════ // Message handling // ═════════════════════════════════════════════════════════════════════ handleMessage(event) { try { const msg = JSON.parse(event.data); switch (msg.type) { case 'full_state': this.handleFullState(msg.payload); break; case 'parameter_update': this.handleParameterUpdate(msg.payload); break; case 'transport_command': this.handleTransportUpdate(msg.payload); break; case 'error': this.log('Server: ' + msg.payload.message, 'log-error'); break; default: this.log('Unknown msg: ' + msg.type, 'log-info'); } } catch (e) { this.log('Parse error: ' + e.message, 'log-error'); } }, handleFullState(state) { this.mixerState = state; const chCount = state.channels ? state.channels.length : 0; this.log('State received: ' + chCount + ' channels, ' + (state.plugins ? state.plugins.length : 0) + ' plugins, ' + (state.scenes ? state.scenes.length : 0) + ' scenes', 'log-state'); // Initialize paramState from channels if (state.channels) { state.channels.forEach(ch => { const i = ch.channel; if (!this.paramState[i]) this.paramState[i] = {}; this.paramState[i].volume = ch.volume_db; this.paramState[i].pan = ch.pan; this.paramState[i].mute = ch.mute ? 1 : 0; this.paramState[i].solo = ch.solo ? 1 : 0; this.paramState[i].gain = ch.gain_db; this.paramState[i].eq_enable = ch.eq?.enabled ? 1 : 0; }); } if (state.channels) this.buildChannelStrips(state.channels); if (state.master) this.updateMasterUI(state.master); if (state.transport) this.updateTransportUI(state.transport); if (state.plugins) this.buildPluginView(state.plugins); if (state.routing) this.buildRoutingView(state.routing); if (state.scenes) this.buildSessionsView(state.scenes); if (state.uptime_seconds) { document.getElementById('uptime-display').textContent = 'up ' + this.formatUptime(state.uptime_seconds); } }, handleParameterUpdate(payload) { const { param_type, value, channel } = payload; const ch = channel !== undefined ? channel : -1; // Update state if (ch >= 0) { if (!this.paramState[ch]) this.paramState[ch] = {}; this.paramState[ch][param_type] = value; } // Update UI this.updateChannelUI(ch, param_type, value); // Master parameters if (param_type === 'master_volume') { document.getElementById('master-vol-val').textContent = Number(value).toFixed(1) + ' dB'; const fader = document.querySelector('#master-fader input'); if (fader) fader.value = value; } if (param_type === 'master_mute') { document.getElementById('btn-mute-master').classList.toggle('on', value >= 0.5); } if (param_type === 'master_dim') { document.getElementById('btn-dim').classList.toggle('on', value >= 0.5); } }, handleTransportUpdate(payload) { this.updateTransportUI(payload); }, // ═════════════════════════════════════════════════════════════════════ // View routing // ═════════════════════════════════════════════════════════════════════ showView(viewName) { this.currentView = viewName; // Update tabs document.querySelectorAll('.tab').forEach(tab => { tab.classList.toggle('active', tab.dataset.view === viewName); }); // Update views document.querySelectorAll('.view').forEach(view => { view.classList.toggle('active', view.id === 'view-' + viewName); }); // Refresh view if we have data if (this.mixerState) { switch (viewName) { case 'routing': this.buildRoutingView(this.mixerState.routing); break; case 'plugins': this.buildPluginView(this.mixerState.plugins); this.populatePluginChannelSelect(); break; case 'sessions': this.buildSessionsView(this.mixerState.scenes); break; } } }, // ═════════════════════════════════════════════════════════════════════ // Channel strips // ═════════════════════════════════════════════════════════════════════ buildChannelStrips(channels) { const container = document.getElementById('channels'); container.innerHTML = ''; channels.forEach((ch, i) => { const strip = document.createElement('div'); strip.className = 'channel-strip'; strip.id = 'ch-' + i; if (ch.solo) strip.classList.add('soloed'); if (ch.mute) strip.classList.add('muted'); const volDb = ch.volume_db !== undefined ? ch.volume_db : 0; const pan = ch.pan !== undefined ? ch.pan : 0; strip.innerHTML = `
${ch.label || 'CH' + (i+1)}
` + `
` + `
` + `` + `Vol` + `${volDb.toFixed(1)} dB` + `
` + `
` + `` + `` + `` + `
` + `
` + `` + `` + `
`; container.appendChild(strip); }); }, updateChannelUI(channel, paramType, value) { if (channel < 0) return; const strip = document.getElementById('ch-' + channel); if (!strip) return; switch (paramType) { case 'volume': { const fader = strip.querySelector('.fader-container input'); const valEl = document.getElementById('vol-val-' + channel); if (fader) fader.value = value; if (valEl) valEl.textContent = Number(value).toFixed(1) + ' dB'; break; } case 'mute': { const btn = document.getElementById('mute-' + channel); const muted = value >= 0.5; if (btn) btn.classList.toggle('on', muted); strip.classList.toggle('muted', muted); break; } case 'solo': { const btn = document.getElementById('solo-' + channel); const soloed = value >= 0.5; if (btn) btn.classList.toggle('solo-on', soloed); strip.classList.toggle('soloed', soloed); break; } case 'pan': { const btn = document.getElementById('pan-btn-' + channel); if (btn) btn.title = 'Pan: ' + Number(value).toFixed(2); break; } case 'eq_enable': { const btn = document.getElementById('eq-btn-' + channel); if (btn) btn.classList.toggle('eq-on', value >= 0.5); break; } case 'gain': { // Could show in a tooltip or sub-label break; } } }, // ═════════════════════════════════════════════════════════════════════ // Level meters (simulated for demo — real data via OSC bridge later) // ═════════════════════════════════════════════════════════════════════ _meterInterval: null, startMeters() { if (this._meterInterval) return; this._meterInterval = setInterval(() => this._updateMeters(), 80); }, stopMeters() { if (this._meterInterval) { clearInterval(this._meterInterval); this._meterInterval = null; } }, _updateMeters() { // Simulate random meter levels if not receiving real data if (!this.connected) return; // Only update visible channel meters document.querySelectorAll('.meter-v').forEach(meter => { const fill = meter.querySelector('.meter-fill'); if (!fill) return; // Keep existing value with slight drift for demo const current = parseFloat(fill.style.height) || 0; const drift = current + (Math.random() - 0.55) * 3; const pct = Math.max(0, Math.min(100, drift)); fill.style.height = pct + '%'; fill.className = 'meter-fill ' + (pct > 75 ? 'high' : pct > 40 ? 'mid' : 'low'); }); }, // ═════════════════════════════════════════════════════════════════════ // Master UI // ═════════════════════════════════════════════════════════════════════ updateMasterUI(master) { if (master.volume_db !== undefined) { document.getElementById('master-fader').querySelector('input').value = master.volume_db; document.getElementById('master-vol-val').textContent = master.volume_db.toFixed(1) + ' dB'; } document.getElementById('btn-mute-master').classList.toggle('on', !!master.muted); document.getElementById('btn-dim').classList.toggle('on', !!master.dim_active); // Show bus info const info = document.getElementById('master-buses-info'); if (info && master.aux_buses) { info.textContent = master.aux_buses.length + ' aux · ' + (master.subgroups?.length || 0) + ' sub · ' + (master.vca_groups?.length || 0) + ' vca'; } }, // ═════════════════════════════════════════════════════════════════════ // Transport UI // ═════════════════════════════════════════════════════════════════════ updateTransportUI(transport) { document.getElementById('btn-play').classList.toggle('active', !!transport.playing); document.getElementById('btn-record').classList.toggle('recording', !!transport.recording); document.getElementById('btn-loop').classList.toggle('active', !!transport.loop); if (transport.tempo_bpm) { document.getElementById('tempo-display').textContent = Number(transport.tempo_bpm).toFixed(1) + ' BPM'; } if (transport.position_sec !== undefined) { document.getElementById('time-display').textContent = this.formatTime(transport.position_sec); } }, // ═════════════════════════════════════════════════════════════════════ // EQ Modal // ═════════════════════════════════════════════════════════════════════ openEQ(channel) { this.eqChannel = channel; const ch = this.mixerState?.channels?.[channel]; if (!ch) { this.log('No data for channel ' + channel, 'log-error'); return; } document.getElementById('eq-modal-title').textContent = (ch.label || 'CH' + (channel + 1)) + ' — EQ & Dynamics'; // EQ enable const eqEnable = document.getElementById('eq-enable'); eqEnable.checked = ch.eq?.enabled || false; eqEnable.onchange = () => App.sendParam('eq_enable', eqEnable.checked ? 1 : 0, channel); // EQ bands this._buildEQBand('eq-bands', channel, ch.eq || {}); // Compressor this._buildDynRows('comp-params', channel, ch.compressor || {}, [ ['comp_threshold', 'Thresh', -60, 0, 'dB'], ['comp_ratio', 'Ratio', 1, 20, ':1'], ['comp_attack', 'Attack', 0.1, 100, 'ms'], ['comp_release', 'Release', 10, 1000, 'ms'], ['comp_gain', 'Makeup', -20, 20, 'dB'], ]); // Gate this._buildDynRows('gate-params', channel, ch.gate || {}, [ ['gate_threshold', 'Thresh', -80, 0, 'dB'], ['gate_range', 'Range', -80, 0, 'dB'], ]); document.getElementById('eq-modal').classList.remove('hidden'); }, closeEQ() { document.getElementById('eq-modal').classList.add('hidden'); this.eqChannel = -1; }, _buildEQBand(containerId, channel, eq) { const container = document.getElementById(containerId); const bands = [ { id: 'low', label: 'Low', params: [ ['eq_low_freq', 'Freq', 20, 500, 'Hz'], ['eq_low_gain', 'Gain', -18, 18, 'dB'], ['eq_low_q', 'Q', 0.1, 5, ''], ]}, { id: 'mid', label: 'Mid', params: [ ['eq_mid_freq', 'Freq', 200, 8000, 'Hz'], ['eq_mid_gain', 'Gain', -18, 18, 'dB'], ['eq_mid_q', 'Q', 0.1, 5, ''], ]}, { id: 'high', label: 'High', params: [ ['eq_high_freq', 'Freq', 1000, 20000, 'Hz'], ['eq_high_gain', 'Gain', -18, 18, 'dB'], ['eq_high_q', 'Q', 0.1, 5, ''], ]}, ]; container.innerHTML = ''; bands.forEach(band => { const div = document.createElement('div'); div.className = 'eq-band'; const header = document.createElement('div'); header.className = 'eq-band-header'; header.textContent = band.label; div.appendChild(header); const rows = document.createElement('div'); rows.className = 'param-rows'; band.params.forEach(([pType, name, min, max, unit]) => { const val = eq[band.id] ? (eq[band.id][pType.replace('eq_' + band.id + '_', '') + '_' + unit.toLowerCase()] || 0) : 0; // Build default values const defaults = { eq_low_freq: 100, eq_low_gain: 0, eq_low_q: 0.71, eq_mid_freq: 1000, eq_mid_gain: 0, eq_mid_q: 0.71, eq_high_freq: 5000, eq_high_gain: 0, eq_high_q: 0.71, }; const defVal = defaults[pType] || 0; const row = this._makeParamRow(pType, name, min, max, defVal, unit, channel); rows.appendChild(row); }); div.appendChild(rows); container.appendChild(div); }); }, _buildDynRows(containerId, channel, obj, paramDefs) { const container = document.getElementById(containerId); container.innerHTML = ''; paramDefs.forEach(([pType, name, min, max, unit]) => { // Map schema field names to param types const fieldMap = { comp_threshold: 'threshold_db', comp_ratio: 'ratio', comp_attack: 'attack_ms', comp_release: 'release_ms', comp_gain: 'makeup_gain_db', gate_threshold: 'threshold_db', gate_range: 'range_db', }; const val = obj[fieldMap[pType]] || 0; const row = this._makeParamRow(pType, name, min, max, val, unit, channel); container.appendChild(row); }); }, _makeParamRow(paramType, name, min, max, value, unit, channel) { const row = document.createElement('div'); row.className = 'param-row'; row.innerHTML = `${name}` + `` + `${Number(value).toFixed(1)}${unit}`; return row; }, // ═════════════════════════════════════════════════════════════════════ // Routing View // ═════════════════════════════════════════════════════════════════════ buildRoutingView(routing) { if (!routing) return; // Sources panel const sourcesEl = document.getElementById('routing-sources'); if (routing.nodes) { sourcesEl.innerHTML = ''; const sourceTypes = ['system_input', 'channel_input', 'channel_direct', 'aux_send', 'fx_input']; routing.nodes.filter(n => sourceTypes.includes(n.type)).forEach(node => { const div = document.createElement('div'); div.className = 'routing-node'; div.draggable = true; div.dataset.nodeId = node.node_id; div.innerHTML = `${node.type.replace(/_/g,' ')} ${node.label || node.node_id}`; div.addEventListener('dragstart', (e) => this._onDragStart(e, node)); sourcesEl.appendChild(div); }); } // Destinations panel const destsEl = document.getElementById('routing-dests'); if (routing.nodes) { destsEl.innerHTML = ''; const destTypes = ['master_input', 'aux_bus', 'aux_return', 'subgroup', 'system_output', 'fx_output']; routing.nodes.filter(n => destTypes.includes(n.type)).forEach(node => { const div = document.createElement('div'); div.className = 'routing-node'; div.dataset.nodeId = node.node_id; div.innerHTML = `${node.type.replace(/_/g,' ')} ${node.label || node.node_id}`; div.addEventListener('dragover', (e) => { e.preventDefault(); div.classList.add('drop-target'); }); div.addEventListener('dragleave', () => div.classList.remove('drop-target')); div.addEventListener('drop', (e) => { e.preventDefault(); div.classList.remove('drop-target'); const srcId = e.dataTransfer.getData('text/plain'); if (srcId && node.node_id) { this.log('Route: ' + srcId + ' → ' + node.node_id, 'log-param'); // TODO: send routing WS message when backend supports it this.sendParam('route_connect', 1, -1, {source: srcId, dest: node.node_id}); } }); destsEl.appendChild(div); }); } // Matrix/connections panel const matrixEl = document.getElementById('routing-matrix'); if (routing.edges) { matrixEl.innerHTML = ''; const nodeMap = {}; if (routing.nodes) routing.nodes.forEach(n => { nodeMap[n.node_id] = n; }); routing.edges.forEach(edge => { const srcLabel = nodeMap[edge.source]?.label || edge.source; const dstLabel = nodeMap[edge.dest]?.label || edge.dest; const div = document.createElement('div'); div.className = 'routing-edge' + (edge.muted ? ' muted' : ''); div.innerHTML = `
` + `${srcLabel}` + `` + `${dstLabel}` + `
` + `${(edge.gain_db||0).toFixed(1)} dB` + `
` + `` + `
`; matrixEl.appendChild(div); }); if (routing.edges.length === 0) { matrixEl.innerHTML = '
No connections — drag sources to destinations
'; } } }, _onDragStart(e, node) { e.dataTransfer.setData('text/plain', node.node_id); e.target.classList.add('dragging'); e.target.addEventListener('dragend', () => e.target.classList.remove('dragging'), {once: true}); }, // ═════════════════════════════════════════════════════════════════════ // Plugins View // ═════════════════════════════════════════════════════════════════════ populatePluginChannelSelect() { const sel = document.getElementById('plugin-channel-select'); if (!this.mixerState?.channels) return; sel.innerHTML = ''; this.mixerState.channels.forEach((ch, i) => { sel.innerHTML += ``; }); }, showPluginsForChannel(chIdx) { const plugins = chIdx === 'all' ? this.mixerState?.plugins || [] : (this.mixerState?.plugins || []).filter(p => p.channel === parseInt(chIdx)); this.buildPluginView(plugins); }, buildPluginView(plugins) { const container = document.getElementById('plugins-list'); if (!plugins || plugins.length === 0) { container.innerHTML = '
No plugins loaded
'; return; } container.innerHTML = ''; plugins.forEach(plugin => { const card = document.createElement('div'); card.className = 'plugin-card'; const chLabel = plugin.channel >= 0 ? (this.mixerState?.channels?.[plugin.channel]?.label || 'CH' + (plugin.channel + 1)) : 'Master'; card.innerHTML = `
` + `
` + `
${plugin.name}
` + `
${plugin.role}
` + `
${chLabel}
` + `
` + `` + `
`; // Parameters if (plugin.params && plugin.params.length > 0) { const paramsDiv = document.createElement('div'); paramsDiv.className = 'plugin-params'; plugin.params.forEach(param => { const row = document.createElement('div'); row.className = 'plugin-param'; row.innerHTML = `${param.name}` + `` + `${(param.value*100).toFixed(0)}%`; paramsDiv.appendChild(row); }); card.appendChild(paramsDiv); } container.appendChild(card); }); }, togglePluginBypass(pluginId, btn) { // Send bypass toggle via WS // The backend will echo back the state update const currentBypassed = btn.classList.contains('bypassed'); const newState = currentBypassed ? 0 : 1; this.sendParam('plugin_bypass', newState, -1, {plugin_id: pluginId}); // Optimistic UI update btn.classList.toggle('bypassed'); btn.textContent = btn.classList.contains('bypassed') ? 'Bypassed' : 'Bypass'; }, sendPluginParam(pluginId, paramIndex, value) { this.sendParam('plugin_param', value, -1, {plugin_id: pluginId, param_index: paramIndex}); }, // ═════════════════════════════════════════════════════════════════════ // Sessions View // ═════════════════════════════════════════════════════════════════════ buildSessionsView(scenes) { const container = document.getElementById('sessions-list'); if (!scenes || scenes.length === 0) { container.innerHTML = '
No saved sessions. Save current state above.
'; return; } container.innerHTML = ''; scenes.forEach(name => { const card = document.createElement('div'); card.className = 'session-card'; card.innerHTML = `
💾 ${this._escapeHTML(name)}
` + `
` + `` + `` + `
`; card.addEventListener('click', (e) => { if (e.target.tagName === 'BUTTON') return; this.loadScene(name); }); container.appendChild(card); }); }, saveScene() { const nameInput = document.getElementById('scene-name-input'); const name = nameInput.value.trim(); if (!name) { this.log('Enter a scene name', 'log-error'); return; } this.sendParam('snapshot_save', 1, -1, {name: name}); this.log('Saving scene: ' + name, 'log-state'); nameInput.value = ''; }, loadScene(name) { this.log('Loading scene: ' + name, 'log-state'); this.sendParam('snapshot_load', 1, -1, {name: name}); }, deleteScene(name) { if (!confirm('Delete session "' + name + '"?')) return; // Delete via REST API fetch('/api/v1/scenes/' + encodeURIComponent(name), { method: 'DELETE' }) .then(() => { this.log('Deleted scene: ' + name, 'log-state'); // Remove from local state if (this.mixerState?.scenes) { this.mixerState.scenes = this.mixerState.scenes.filter(s => s !== name); this.buildSessionsView(this.mixerState.scenes); } }) .catch(e => this.log('Delete failed: ' + e.message, 'log-error')); }, // ═════════════════════════════════════════════════════════════════════ // Sending // ═════════════════════════════════════════════════════════════════════ sendParam(paramType, value, channel, extra) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { // Queue or ignore silently — don't spam errors return; } const payload = { param_type: String(paramType), value: parseFloat(value), channel: parseInt(channel), timestamp: Date.now() / 1000, }; if (extra) Object.assign(payload, extra); this.ws.send(JSON.stringify({ type: 'parameter_update', payload: payload, })); }, toggleParam(paramType, channel) { const current = this.paramState[channel]?.[paramType] || 0; const newVal = current >= 0.5 ? 0 : 1; this.sendParam(paramType, newVal, channel); }, sendTransport(command) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; this.ws.send(JSON.stringify({ type: 'transport_command', payload: { command: command }, })); }, cyclePan(channel) { const current = this.paramState[channel]?.pan || 0; // Cycle: center → right → left → center let newPan; if (current > -0.1 && current < 0.1) newPan = 1.0; else if (current > 0.5) newPan = -1.0; else newPan = 0.0; this.sendParam('pan', newPan, channel); }, showLocateDialog() { const timecode = prompt('Go to position (seconds or HH:MM:SS):', '0'); if (!timecode) return; let secs; if (timecode.includes(':')) { const parts = timecode.split(':').map(Number); secs = parts.length === 3 ? parts[0] * 3600 + parts[1] * 60 + parts[2] : parts[0] * 60 + parts[1]; } else { secs = parseFloat(timecode); } if (!isNaN(secs) && secs >= 0) { this.sendParam('locate', secs, -1); this.log('Locate to ' + this.formatTime(secs), 'log-param'); } }, showGainDialog(channel) { const current = this.paramState[channel]?.gain || 0; const val = prompt('Gain for ' + (this.mixerState?.channels?.[channel]?.label || 'CH'+(channel+1)) + ' (dB, -20 to +60):', current); if (val !== null) { const db = parseFloat(val); if (!isNaN(db)) this.sendParam('gain', db, channel); } }, // ═════════════════════════════════════════════════════════════════════ // Helpers // ═════════════════════════════════════════════════════════════════════ formatTime(secs) { const s = Math.max(0, Math.floor(secs)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const ss = s % 60; return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(ss).padStart(2, '0'); }, formatUptime(secs) { const s = Math.floor(secs); if (s < 60) return s + 's'; if (s < 3600) return Math.floor(s / 60) + 'm'; return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm'; }, _escapeHTML(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }, _escapeAttr(str) { return str.replace(/'/g, "\\'").replace(/"/g, '"'); }, // ═════════════════════════════════════════════════════════════════════ // Startup // ═════════════════════════════════════════════════════════════════════ init() { this.log('RPi Mixer UI loaded', 'log-info'); this.startMeters(); // Auto-login with default key this.login(); // Keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; switch (e.key) { case ' ': e.preventDefault(); this.sendTransport('play'); break; case 'Escape': this.closeEQ(); break; case '1': this.showView('mixer'); break; case '2': this.showView('routing'); break; case '3': this.showView('plugins'); break; case '4': this.showView('sessions'); break; case 'ArrowLeft': this.sendTransport('stop'); break; // Stop can be left arrow too } }); // Close EQ modal on backdrop click document.getElementById('eq-modal')?.addEventListener('click', (e) => { if (e.target.classList.contains('modal-backdrop')) this.closeEQ(); }); } }; // Boot window.addEventListener('DOMContentLoaded', () => App.init());