// ═══════════════════════════════════════════════════════════════════════ // 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 (full session management) // ═════════════════════════════════════════════════════════════════════ _sessionsData: null, _setlistsData: null, _snapshotsData: null, _currentSetlist: null, _sessionsSubtab: 'sessions', buildSessionsView(scenes) { // Kick off initial loads — scenes from WS are fallback this.refreshSessions(); this.refreshAutosaveStatus(); }, refreshSessions() { const filter = document.getElementById('session-filter')?.value || 'all'; // Fetch sessions from REST API fetch('/api/v1/sessions') .then(r => r.json()) .then(data => { this._sessionsData = data.sessions || []; if (filter === 'all' || filter === 'sessions') { this._renderSessions(); } }) .catch(e => this.log('Failed to list sessions: ' + e.message, 'log-error')); // Also fetch snapshots fetch('/api/v1/snapshots') .then(r => r.json()) .then(data => { this._snapshotsData = data.snapshots || []; if (filter === 'all' || filter === 'snapshots') { this._renderSessions(); } }) .catch(e => this.log('Failed to list snapshots: ' + e.message, 'log-error')); }, _renderSessions() { const container = document.getElementById('sessions-list'); const filter = document.getElementById('session-filter')?.value || 'all'; const items = []; if (filter === 'all' || filter === 'sessions') { (this._sessionsData || []).forEach(s => { items.push({ type: 'session', name: s.name, filename: s.filename, modified: s.modified, size: s.size_bytes, }); }); } if (filter === 'all' || filter === 'snapshots') { (this._snapshotsData || []).forEach(s => { items.push({ type: 'snapshot', name: s.name, category: s.category || '', isCurrent: s.is_current, modified: s.created_at, }); }); } if (items.length === 0) { container.innerHTML = '
No saved sessions. Save current state above.
'; return; } container.innerHTML = ''; items.forEach(item => { const card = document.createElement('div'); card.className = 'session-card'; if (item.type === 'snapshot') { const catBadge = item.category ? ` ${this._escapeHTML(item.category)}` : ''; const curBadge = item.isCurrent ? ' active' : ''; card.innerHTML = `
📸 ${this._escapeHTML(item.name)}${catBadge}${curBadge}
` + `
` + `` + `` + `
`; } else { const date = new Date(item.modified * 1000).toLocaleDateString(); const size = item.size ? (item.size < 1024 ? item.size + 'B' : (item.size / 1024).toFixed(1) + 'KB') : ''; card.innerHTML = `
💾 ${this._escapeHTML(item.name)}
` + `
${date} · ${size}
` + `
` + `` + `` + `` + `` + `
`; card.addEventListener('click', (e) => { if (e.target.tagName === 'BUTTON') return; this.loadSession(item.name); }); } container.appendChild(card); }); }, saveSession() { const nameInput = document.getElementById('session-name-input'); const notesInput = document.getElementById('session-notes-input'); const name = nameInput.value.trim(); if (!name) { this.log('Enter a session name', 'log-error'); return; } const notes = notesInput.value.trim(); this.log('Saving session: ' + name, 'log-state'); fetch('/api/v1/sessions/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, overwrite: true }), }) .then(r => r.json()) .then(data => { if (data.ok) { this.log('Session saved: ' + name + ' (' + data.data.channels + ' channels)', 'log-state'); nameInput.value = ''; notesInput.value = ''; this.refreshSessions(); } else { this.log('Save failed: ' + (data.error || data.detail), 'log-error'); } }) .catch(e => this.log('Save error: ' + e.message, 'log-error')); }, loadSession(name) { this.log('Loading session: ' + name, 'log-state'); fetch('/api/v1/sessions/' + encodeURIComponent(name) + '/load', { method: 'POST' }) .then(r => r.json()) .then(data => { if (data.ok) { this.log('Session loaded: ' + name + ' — refresh to see changes', 'log-state'); } else { this.log('Load failed: ' + (data.detail || 'unknown'), 'log-error'); } }) .catch(e => this.log('Load error: ' + e.message, 'log-error')); }, deleteSession(name) { if (!confirm('Delete session "' + name + '"?')) return; fetch('/api/v1/sessions/' + encodeURIComponent(name), { method: 'DELETE' }) .then(r => r.json()) .then(data => { if (data.ok) { this.log('Deleted: ' + name, 'log-state'); this.refreshSessions(); } }) .catch(e => this.log('Delete failed: ' + e.message, 'log-error')); }, duplicateSession(name) { const newName = prompt('Duplicate "' + name + '" as:', name + ' (copy)'); if (!newName) return; fetch('/api/v1/sessions/' + encodeURIComponent(name) + '/duplicate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target_name: newName }), }) .then(r => r.json()) .then(data => { if (data.ok) { this.log('Duplicated: ' + name + ' → ' + newName, 'log-state'); this.refreshSessions(); } else { this.log('Duplicate failed: ' + (data.detail || ''), 'log-error'); } }) .catch(e => this.log('Duplicate error: ' + e.message, 'log-error')); }, exportSession(name) { fetch('/api/v1/sessions/export/' + encodeURIComponent(name), { method: 'POST' }) .then(r => r.json()) .then(data => { if (data.ok) { this.log('Exported: ' + data.data.export_path, 'log-state'); } }) .catch(e => this.log('Export failed: ' + e.message, 'log-error')); }, exportAllSessions() { this.log('Exporting all sessions...', 'log-state'); window.open('/api/v1/sessions/export-all-zip', '_blank'); }, importSession() { document.getElementById('session-import-file').click(); }, handleImportFile(input) { const file = input.files[0]; if (!file) return; const newName = prompt('Import as (leave empty to use file name):', ''); // For local import, we send the filepath. In a real deployment, // this would upload the file content. For now, work with local paths. const reader = new FileReader(); reader.onload = (e) => { const content = e.target.result; // Send content to server fetch('/api/v1/sessions/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName || file.name.replace('.json', ''), overwrite: false, }), }).then(() => { this.log('To import from file, copy it to ~/.config/rpi-mixer/sessions/ and refresh', 'log-info'); this.refreshSessions(); }); }; reader.readAsText(file); input.value = ''; }, // ── Setlists ─────────────────────────────────────────────────────── showSessionsSubtab(tab) { this._sessionsSubtab = tab; document.querySelectorAll('.subtab').forEach(b => b.classList.toggle('active', b.id === 'subtab-' + tab)); document.getElementById('sessions-list').style.display = tab === 'sessions' ? '' : 'none'; document.getElementById('setlists-view').style.display = tab === 'setlists' ? '' : 'none'; document.getElementById('snapshots-view').style.display = tab === 'snapshots' ? '' : 'none'; if (tab === 'setlists') this.refreshSetlists(); if (tab === 'snapshots') this.refreshSnapshotsUI(); if (tab === 'sessions') this.refreshSessions(); }, refreshSetlists() { fetch('/api/v1/setlists') .then(r => r.json()) .then(data => { this._setlistsData = data.setlists || []; this._renderSetlists(); }) .catch(e => this.log('Failed to list setlists: ' + e.message, 'log-error')); }, _renderSetlists() { const container = document.getElementById('setlists-list'); const setlists = this._setlistsData || []; if (setlists.length === 0) { container.innerHTML = '
No setlists. Create one below.
'; return; } container.innerHTML = ''; setlists.forEach(sl => { const card = document.createElement('div'); card.className = 'session-card'; card.innerHTML = `
📋 ${this._escapeHTML(sl.name)}
` + `
${sl.entry_count || 0} entries
` + `
` + `` + `` + `` + `
`; container.appendChild(card); }); }, createSetlist() { const nameInput = document.getElementById('setlist-name-input'); const name = nameInput.value.trim(); if (!name) { this.log('Enter setlist name', 'log-error'); return; } this._currentSetlist = { name, entries: [], description: '' }; document.getElementById('setlist-editor').style.display = ''; document.getElementById('setlist-editor-title').textContent = name; nameInput.value = ''; this._renderSetlistEditor(); }, editSetlist(name) { // Find in data and render editor const sl = (this._setlistsData || []).find(s => s.name === name); if (!sl) return; // Load full setlist fetch('/api/v1/setlists/' + encodeURIComponent(sl.filename.replace('.json', '')) + '/load', { method: 'POST' }) .then(r => r.json()) .catch(() => null); this._currentSetlist = { name: sl.name, entries: [], description: '' }; document.getElementById('setlist-editor').style.display = ''; document.getElementById('setlist-editor-title').textContent = sl.name; this._renderSetlistEditor(); }, _renderSetlistEditor() { const container = document.getElementById('setlist-entries'); if (!this._currentSetlist || !this._currentSetlist.entries.length) { container.innerHTML = '
No entries. Add one below.
'; return; } container.innerHTML = ''; this._currentSetlist.entries.forEach((entry, i) => { const row = document.createElement('div'); row.className = 'setlist-entry-row'; row.innerHTML = `${i + 1}. ${this._escapeHTML(entry.session_name)}` + `${entry.transition || 'cut'}` + ``; container.appendChild(row); }); }, addSetlistEntry() { if (!this._currentSetlist) { this.log('Create a setlist first', 'log-error'); return; } const sessionInput = document.getElementById('setlist-entry-session'); const transitionSelect = document.getElementById('setlist-entry-transition'); const sessionName = sessionInput.value.trim(); if (!sessionName) { this.log('Enter session name', 'log-error'); return; } this._currentSetlist.entries.push({ session_name: sessionName, display_name: sessionName, transition: transitionSelect.value, transition_duration: 2.0, wait_for_trigger: false, notes: '', }); sessionInput.value = ''; this._renderSetlistEditor(); }, removeSetlistEntry(index) { if (!this._currentSetlist) return; this._currentSetlist.entries.splice(index, 1); this._renderSetlistEditor(); }, saveSetlist() { if (!this._currentSetlist) return; fetch('/api/v1/setlists/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this._currentSetlist), }) .then(r => r.json()) .then(data => { if (data.ok) { this.log('Setlist saved: ' + data.data.name, 'log-state'); document.getElementById('setlist-editor').style.display = 'none'; this._currentSetlist = null; this.refreshSetlists(); } }) .catch(e => this.log('Save setlist failed: ' + e.message, 'log-error')); }, loadSetlist(name) { fetch('/api/v1/setlists/' + encodeURIComponent(name) + '/load', { method: 'POST' }) .then(r => r.json()) .then(data => { if (data.ok) { this.log('Setlist activated: ' + name + ' (' + data.data.progress[0] + '/' + data.data.progress[1] + ')', 'log-state'); } }) .catch(e => this.log('Load setlist failed: ' + e.message, 'log-error')); }, activateSetlist() { if (!this._currentSetlist) return; this.loadSetlist(this._currentSetlist.name); }, deleteSetlist(name) { if (!confirm('Delete setlist "' + name + '"?')) return; fetch('/api/v1/setlists/' + encodeURIComponent(name), { method: 'DELETE' }) .then(r => r.json()) .then(data => { if (data.ok) { this.log('Deleted setlist: ' + name, 'log-state'); this.refreshSetlists(); } }) .catch(e => this.log('Delete setlist failed: ' + e.message, 'log-error')); }, // ── Snapshots ────────────────────────────────────────────────────── refreshSnapshotsUI() { fetch('/api/v1/snapshots') .then(r => r.json()) .then(data => { this._snapshotsData = data.snapshots || []; this._renderSnapshots(); }) .catch(e => this.log('Failed: ' + e.message, 'log-error')); }, _renderSnapshots() { const container = document.getElementById('snapshots-list'); const snaps = this._snapshotsData || []; if (snaps.length === 0) { container.innerHTML = '
No snapshots. Capture one above.
'; return; } container.innerHTML = ''; snaps.forEach(snap => { const card = document.createElement('div'); card.className = 'session-card'; if (snap.is_current) card.classList.add('current'); const catBadge = snap.category ? ` ${this._escapeHTML(snap.category)}` : ''; card.innerHTML = `
📸 ${this._escapeHTML(snap.name)}${catBadge}
` + `
` + `` + `` + `
`; container.appendChild(card); }); }, captureSnapshot() { const nameInput = document.getElementById('snapshot-name-input'); const catInput = document.getElementById('snapshot-category-input'); const name = nameInput.value.trim(); if (!name) { this.log('Enter snapshot name', 'log-error'); return; } fetch('/api/v1/snapshots/capture', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name, category: catInput.value, overwrite: true, }), }) .then(r => r.json()) .then(data => { if (data.ok) { this.log('Snapshot captured: ' + name, 'log-state'); nameInput.value = ''; this.refreshSnapshotsUI(); } }) .catch(e => this.log('Capture failed: ' + e.message, 'log-error')); }, recallSnapshot(name) { fetch('/api/v1/snapshots/' + encodeURIComponent(name) + '/recall', { method: 'POST' }) .then(r => r.json()) .then(data => { if (data.ok) this.log('Snapshot recalled: ' + name, 'log-state'); }) .catch(e => this.log('Recall failed: ' + e.message, 'log-error')); }, deleteSnapshot(name) { if (!confirm('Delete snapshot "' + name + '"?')) return; fetch('/api/v1/snapshots/' + encodeURIComponent(name), { method: 'DELETE' }) .then(r => r.json()) .then(data => { if (data.ok) { this.log('Deleted snapshot: ' + name, 'log-state'); this.refreshSnapshotsUI(); } }) .catch(e => this.log('Delete failed: ' + e.message, 'log-error')); }, snapshotNext() { fetch('/api/v1/snapshots/next', { method: 'POST' }) .then(r => r.json()) .then(data => { if (data.ok) this.log('Next snapshot: ' + data.data.name, 'log-state'); }) .catch(e => this.log('Next failed: ' + e.message, 'log-error')); }, snapshotPrev() { fetch('/api/v1/snapshots/previous', { method: 'POST' }) .then(r => r.json()) .then(data => { if (data.ok) this.log('Previous snapshot: ' + data.data.name, 'log-state'); }) .catch(e => this.log('Prev failed: ' + e.message, 'log-error')); }, // ── Auto-save ────────────────────────────────────────────────────── refreshAutosaveStatus() { fetch('/api/v1/sessions/autosave/status') .then(r => r.json()) .then(data => { const indicator = document.getElementById('autosave-indicator'); if (data.data && data.data.auto_save_enabled) { indicator.textContent = '● Auto-save active'; indicator.style.color = 'var(--success-color)'; } else { indicator.textContent = '○ Auto-save off'; indicator.style.color = 'var(--text-faint)'; } }) .catch(() => {}); }, // ═════════════════════════════════════════════════════════════════════ // 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());