Files
raspberry-pi-mixer/web/app.js
T
shawn cdf04bd8eb
Lint & Validate / lint (push) Has been cancelled
P6-R1: Audio/video streaming pipeline
- src/streaming/ module: camera detection (USB webcam + Pi Camera Module via
  v4l2/libcamera), GStreamer pipeline builder with V4L2/OMX/x264 h264 encoders,
  platform presets (YouTube, Twitch, Facebook, custom RTMP), streamer lifecycle
  manager with auto-reconnect, scene management, stream statistics
- Keyboard shortcut controller (evdev + pynput fallback) for stream control:
  Ctrl+Shift+S (start/stop), Ctrl+Shift+1-9 (scene switch), Ctrl+Shift+R (reconnect)
- REST API: /api/v1/stream/* endpoints for start/stop/status, scenes, cameras,
  platforms, and hotkey listing — integrated into NetworkServer
- StreamStateProvider bridging layer connects REST routes to Streamer instance
- GStreamer pipeline supports ALSA/JACK/PulseAudio audio capture and
  V4L2/libcamera video capture with H.264 hardware encoding on RPi4B
- Low-latency streaming settings for live performance (zerolatency, no B-frames)
- 89 streaming tests (709 total project tests pass)
2026-05-19 22:13:51 -04:00

1368 lines
61 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 (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 = '<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 = '';
items.forEach(item => {
const card = document.createElement('div');
card.className = 'session-card';
if (item.type === 'snapshot') {
const catBadge = item.category ? ` <span class="badge">${this._escapeHTML(item.category)}</span>` : '';
const curBadge = item.isCurrent ? ' <span class="badge active">active</span>' : '';
card.innerHTML =
`<div class="session-card-name">📸 ${this._escapeHTML(item.name)}${catBadge}${curBadge}</div>` +
`<div class="session-card-actions">` +
`<button onclick="App.recallSnapshot('${this._escapeAttr(item.name)}')">▶ Recall</button>` +
`<button class="danger" onclick="App.deleteSnapshot('${this._escapeAttr(item.name)}')">🗑️</button>` +
`</div>`;
} 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 =
`<div class="session-card-name">💾 ${this._escapeHTML(item.name)}</div>` +
`<div class="session-card-info">${date} · ${size}</div>` +
`<div class="session-card-actions">` +
`<button onclick="App.loadSession('${this._escapeAttr(item.name)}')">📂 Load</button>` +
`<button onclick="App.exportSession('${this._escapeAttr(item.name)}')">📤</button>` +
`<button onclick="App.duplicateSession('${this._escapeAttr(item.name)}')">📋</button>` +
`<button class="danger" onclick="App.deleteSession('${this._escapeAttr(item.name)}')">🗑️</button>` +
`</div>`;
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 = '<div style="color:var(--text-faint);text-align:center;padding:1rem">No setlists. Create one below.</div>';
return;
}
container.innerHTML = '';
setlists.forEach(sl => {
const card = document.createElement('div');
card.className = 'session-card';
card.innerHTML =
`<div class="session-card-name">📋 ${this._escapeHTML(sl.name)}</div>` +
`<div class="session-card-info">${sl.entry_count || 0} entries</div>` +
`<div class="session-card-actions">` +
`<button onclick="App.editSetlist('${this._escapeAttr(sl.name)}')">✏️ Edit</button>` +
`<button onclick="App.loadSetlist('${this._escapeAttr(sl.name)}')">▶ Activate</button>` +
`<button class="danger" onclick="App.deleteSetlist('${this._escapeAttr(sl.name)}')">🗑️</button>` +
`</div>`;
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 = '<div style="color:var(--text-faint);padding:0.5rem">No entries. Add one below.</div>';
return;
}
container.innerHTML = '';
this._currentSetlist.entries.forEach((entry, i) => {
const row = document.createElement('div');
row.className = 'setlist-entry-row';
row.innerHTML =
`<span>${i + 1}. ${this._escapeHTML(entry.session_name)}</span>` +
`<span class="badge">${entry.transition || 'cut'}</span>` +
`<button onclick="App.removeSetlistEntry(${i})">✕</button>`;
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 = '<div style="color:var(--text-faint);text-align:center;padding:2rem;grid-column:1/-1">No snapshots. Capture one above.</div>';
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 ? ` <span class="badge">${this._escapeHTML(snap.category)}</span>` : '';
card.innerHTML =
`<div class="session-card-name">📸 ${this._escapeHTML(snap.name)}${catBadge}</div>` +
`<div class="session-card-actions">` +
`<button onclick="App.recallSnapshot('${this._escapeAttr(snap.name)}')">▶ Recall</button>` +
`<button class="danger" onclick="App.deleteSnapshot('${this._escapeAttr(snap.name)}')">🗑️</button>` +
`</div>`;
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, '&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());