Files
raspberry-pi-mixer/web/app.js
T
shawn b79d7288c5
Lint & Validate / lint (push) Has been cancelled
P5-R3: Web app frontend — touch-optimized mixer UI
- Complete SPA rewrite: 4 tabs (Mixer, Routing, Plugins, Sessions)
- Touch-optimized faders with animated level meters (dark theme)
- Channel strips: fader, mute, solo, pan, EQ modal with 3-band EQ + compressor + gate
- Routing matrix: drag-and-drop source→destination, connection list with mute toggles
- Plugin control: per-channel plugin cards with bypass and parameter sliders
- Session browser: list/load/save/delete scenes via REST API
- Transport controls: play, stop, record, loop, locate (keyboard shortcuts: space=play)
- Responsive: phone portrait (touch-first), landscape, tablet, desktop
- Zero dependencies: vanilla JS + WebSocket only
- Backend: added DELETE /api/v1/scenes/{name} endpoint, wired delete_scene callback

web/app.js (917 lines), web/style.css (1100 lines), web/index.html (160 lines)
2026-05-19 21:05:43 -04:00

918 lines
42 KiB
JavaScript

// ═══════════════════════════════════════════════════════════════════════
// 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 =
`<div class="chan-label" title="${ch.label || 'Channel ' + (i+1)}"
onclick="App.openEQ(${i})">${ch.label || 'CH' + (i+1)}</div>` +
`<div class="fader-container">` +
`<div class="meter-v" id="meter-${i}"><div class="meter-fill low"></div></div>` +
`<input type="range" min="-60" max="12" value="${volDb}" step="0.5" orient="vertical"
oninput="App.sendParam('volume', this.value, ${i})">` +
`<span class="fader-label">Vol</span>` +
`<span class="fader-db" id="vol-val-${i}">${volDb.toFixed(1)} dB</span>` +
`</div>` +
`<div class="chan-buttons">` +
`<button class="chan-btn${ch.mute ? ' on' : ''}" id="mute-${i}"
onclick="App.toggleParam('mute', ${i})">M</button>` +
`<button class="chan-btn${ch.solo ? ' solo-on' : ''}" id="solo-${i}"
onclick="App.toggleParam('solo', ${i})">S</button>` +
`<button class="chan-btn" id="pan-btn-${i}"
onclick="App.cyclePan(${i})" title="Pan: ${pan.toFixed(2)}">P</button>` +
`</div>` +
`<div class="chan-extra">` +
`<button class="chan-extra-btn${ch.eq?.enabled ? ' eq-on' : ''}" id="eq-btn-${i}"
onclick="App.openEQ(${i})">EQ</button>` +
`<button class="chan-extra-btn" id="gain-btn-${i}"
onclick="App.showGainDialog(${i})">G</button>` +
`</div>`;
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 =
`<span class="param-row-name">${name}</span>` +
`<input type="range" min="${min}" max="${max}" value="${value}" step="${(max-min)/200}"
oninput="App.sendParam('${paramType}', this.value, ${channel});
this.parentElement.querySelector('.param-row-value').textContent = Number(this.value).toFixed(1) + '${unit}'">` +
`<span class="param-row-value">${Number(value).toFixed(1)}${unit}</span>`;
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 = `<span class="routing-node-type">${node.type.replace(/_/g,' ')}</span> ${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 = `<span class="routing-node-type">${node.type.replace(/_/g,' ')}</span> ${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 =
`<div class="routing-edge-path">` +
`<span class="src">${srcLabel}</span>` +
`<span class="arrow">→</span>` +
`<span class="dst">${dstLabel}</span>` +
`</div>` +
`<span style="font-size:0.6rem;color:var(--text-faint)">${(edge.gain_db||0).toFixed(1)} dB</span>` +
`<div class="routing-edge-actions">` +
`<button onclick="App.sendParam('route_mute', ${edge.muted ? 0 : 1}, -1,
{source:'${edge.source}',dest:'${edge.dest}'})" title="Toggle mute">` +
(edge.muted ? '🔇' : '🔊') + `</button>` +
`</div>`;
matrixEl.appendChild(div);
});
if (routing.edges.length === 0) {
matrixEl.innerHTML = '<div style="color:var(--text-faint);text-align:center;padding:2rem;">No connections — drag sources to destinations</div>';
}
}
},
_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 = '<option value="all">All Channels</option>';
this.mixerState.channels.forEach((ch, i) => {
sel.innerHTML += `<option value="${i}">${ch.label || 'CH' + (i+1)}</option>`;
});
},
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 = '<div style="color:var(--text-faint);text-align:center;padding:2rem;grid-column:1/-1">No plugins loaded</div>';
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 =
`<div class="plugin-card-header">` +
`<div>` +
`<div class="plugin-card-name">${plugin.name}</div>` +
`<div class="plugin-card-role">${plugin.role}</div>` +
`<div class="plugin-card-channel">${chLabel}</div>` +
`</div>` +
`<button class="plugin-bypass-btn${plugin.active ? '' : ' bypassed'}"
onclick="App.togglePluginBypass(${plugin.plugin_id}, this)">` +
(plugin.active ? 'Bypass' : 'Bypassed') +
`</button>` +
`</div>`;
// 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 =
`<span class="plugin-param-name">${param.name}</span>` +
`<input type="range" min="0" max="1" value="${param.value}" step="0.01"
oninput="App.sendPluginParam(${plugin.plugin_id}, ${param.index}, this.value);
this.parentElement.querySelector('.plugin-param-value').textContent = (Number(this.value)*100).toFixed(0)+'%'">` +
`<span class="plugin-param-value">${(param.value*100).toFixed(0)}%</span>`;
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 = '<div style="color:var(--text-faint);text-align:center;padding:2rem;grid-column:1/-1">No saved sessions. Save current state above.</div>';
return;
}
container.innerHTML = '';
scenes.forEach(name => {
const card = document.createElement('div');
card.className = 'session-card';
card.innerHTML =
`<div class="session-card-name">💾 ${this._escapeHTML(name)}</div>` +
`<div class="session-card-actions">` +
`<button onclick="App.loadScene('${this._escapeAttr(name)}')">📂 Load</button>` +
`<button class="danger" onclick="App.deleteScene('${this._escapeAttr(name)}')">🗑️</button>` +
`</div>`;
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, '&quot;');
},
// ═════════════════════════════════════════════════════════════════════
// 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());