cdf04bd8eb
Lint & Validate / lint (push) Has been cancelled
- 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)
1368 lines
61 KiB
JavaScript
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, '"');
|
|
},
|
|
|
|
// ═════════════════════════════════════════════════════════════════════
|
|
// 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());
|