Files
raspberry-pi-mixer/web/index.html
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

220 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#1a1a2e">
<title>RPi Audio Mixer</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app">
<!-- ═══ Toolbar ═══ -->
<header id="toolbar">
<h1>🎚️ RPi Mixer</h1>
<div id="connection">
<span id="conn-status" class="status disconnected">● Offline</span>
<input type="text" id="apikey-input" placeholder="API Key" value="mixer-local" size="14">
<button id="login-btn" class="btn" onclick="App.login()">Login</button>
<button id="connect-btn" class="btn" onclick="App.connectWS()" disabled>Connect</button>
</div>
</header>
<!-- ═══ Tab bar ═══ -->
<nav id="tabs">
<button class="tab active" data-view="mixer" onclick="App.showView('mixer')">
<span class="tab-icon">🎛️</span> Mixer
</button>
<button class="tab" data-view="routing" onclick="App.showView('routing')">
<span class="tab-icon">🔀</span> Routing
</button>
<button class="tab" data-view="plugins" onclick="App.showView('plugins')">
<span class="tab-icon">🔌</span> Plugins
</button>
<button class="tab" data-view="sessions" onclick="App.showView('sessions')">
<span class="tab-icon">💾</span> Sessions
</button>
</nav>
<!-- ═══ Transport bar (global) ═══ -->
<div id="transport-bar">
<button class="tbtn" id="btn-play" onclick="App.sendTransport('play')" title="Play"></button>
<button class="tbtn" id="btn-stop" onclick="App.sendTransport('stop')" title="Stop"></button>
<button class="tbtn" id="btn-record" onclick="App.sendTransport('record')" title="Record"></button>
<button class="tbtn" id="btn-loop" onclick="App.sendTransport('loop')" title="Loop"></button>
<button class="tbtn" id="btn-locate" onclick="App.showLocateDialog()" title="Locate">⏱️</button>
<span class="transport-info" id="tempo-display">120.0 BPM</span>
<span class="transport-info" id="time-display">00:00:00</span>
<span class="transport-info dimmed" id="uptime-display"></span>
</div>
<!-- ═══ Main content ═══ -->
<main id="view-container">
<!-- ── Mixer View ── -->
<section id="view-mixer" class="view active">
<div id="mixer-scroll">
<div id="channels"></div>
<div id="master-section">
<div class="section-label">Master</div>
<div class="fader-container" id="master-fader">
<div class="meter-v" id="master-meter"><div class="meter-fill"></div></div>
<input type="range" min="-60" max="12" value="0" step="0.5" orient="vertical"
oninput="App.sendParam('master_volume', this.value, -1)">
<span class="fader-label">Master</span>
<span class="fader-db" id="master-vol-val">0.0 dB</span>
</div>
<div class="master-buttons">
<button class="toggle-btn" id="btn-mute-master" onclick="App.toggleParam('master_mute', -1)">M</button>
<button class="toggle-btn" id="btn-dim" onclick="App.toggleParam('master_dim', -1)">Dim</button>
<button class="toggle-btn" id="btn-mono" onclick="App.toggleParam('master_mono', -1)">Mono</button>
</div>
<div class="master-buses" id="master-buses-info"></div>
</div>
</div>
</section>
<!-- ── Routing View ── -->
<section id="view-routing" class="view">
<div class="routing-layout">
<div class="routing-panel sources-panel">
<h3>Sources</h3>
<div id="routing-sources" class="routing-list"></div>
</div>
<div class="routing-panel matrix-panel">
<h3>Connections</h3>
<div id="routing-matrix" class="routing-grid"></div>
</div>
<div class="routing-panel dests-panel">
<h3>Destinations</h3>
<div id="routing-dests" class="routing-list"></div>
</div>
</div>
</section>
<!-- ── Plugins View ── -->
<section id="view-plugins" class="view">
<div class="plugins-header">
<h3>Channel Plugins</h3>
<select id="plugin-channel-select" onchange="App.showPluginsForChannel(this.value)">
<option value="all">All Channels</option>
</select>
</div>
<div id="plugins-list" class="plugins-grid"></div>
</section>
<!-- ── Sessions View ── -->
<section id="view-sessions" class="view">
<div class="sessions-header">
<h3>Mixer Sessions</h3>
<div class="sessions-actions">
<input type="text" id="session-name-input" placeholder="Session name" class="text-input">
<input type="text" id="session-notes-input" placeholder="Notes (optional)" class="text-input" style="width:160px">
<button class="btn btn-primary" onclick="App.saveSession()">💾 Save Session</button>
<button class="btn" onclick="App.refreshSessions()">🔄 Refresh</button>
<select id="session-filter" onchange="App.refreshSessions()" style="width:100px">
<option value="all">All</option>
<option value="sessions">Sessions</option>
<option value="snapshots">Snapshots</option>
</select>
</div>
</div>
<!-- Auto-save status -->
<div id="autosave-status" class="autosave-status"><span id="autosave-indicator">● Auto-save active</span></div>
<!-- Sub-tabs for Sessions / Setlists / Snapshots -->
<div class="sessions-subtabs">
<button class="subtab active" id="subtab-sessions" onclick="App.showSessionsSubtab('sessions')">📁 Sessions</button>
<button class="subtab" id="subtab-setlists" onclick="App.showSessionsSubtab('setlists')">📋 Setlists</button>
<button class="subtab" id="subtab-snapshots" onclick="App.showSessionsSubtab('snapshots')">📸 Snapshots</button>
</div>
<!-- Sessions list -->
<div id="sessions-list" class="sessions-grid"></div>
<!-- Setlist view (hidden by default) -->
<div id="setlists-view" style="display:none">
<div class="sessions-actions" style="margin-bottom:0.5rem">
<input type="text" id="setlist-name-input" placeholder="Setlist name" class="text-input">
<button class="btn btn-primary" onclick="App.createSetlist()"> New Setlist</button>
</div>
<div id="setlists-list" class="sessions-grid"></div>
<div id="setlist-editor" style="display:none">
<h4>Setlist: <span id="setlist-editor-title"></span></h4>
<div id="setlist-entries"></div>
<div class="sessions-actions">
<input type="text" id="setlist-entry-session" placeholder="Session name" class="text-input">
<select id="setlist-entry-transition"><option value="cut">Cut</option><option value="crossfade">Crossfade</option><option value="wait">Wait</option></select>
<button class="btn" onclick="App.addSetlistEntry()"> Add Entry</button>
<button class="btn btn-primary" onclick="App.saveSetlist()">💾 Save Setlist</button>
<button class="btn" onclick="App.activateSetlist()">▶ Activate</button>
</div>
</div>
</div>
<!-- Snapshots view (hidden by default) -->
<div id="snapshots-view" style="display:none">
<div class="sessions-actions" style="margin-bottom:0.5rem">
<input type="text" id="snapshot-name-input" placeholder="Snapshot name" class="text-input">
<select id="snapshot-category-input" style="width:100px">
<option value="">No Category</option><option value="intro">Intro</option><option value="verse">Verse</option><option value="chorus">Chorus</option><option value="bridge">Bridge</option><option value="outro">Outro</option>
</select>
<button class="btn btn-primary" onclick="App.captureSnapshot()">📸 Capture</button>
<button class="btn" onclick="App.snapshotNext()">▶ Next</button>
<button class="btn" onclick="App.snapshotPrev()">◀ Prev</button>
</div>
<div id="snapshots-list" class="sessions-grid"></div>
</div>
<!-- Import/Export -->
<div class="sessions-actions" style="margin-top:1rem; border-top:1px solid var(--border-color); padding-top:0.5rem">
<button class="btn" onclick="App.importSession()">📥 Import</button>
<button class="btn" onclick="App.exportAllSessions()">📤 Export All</button>
<input type="file" id="session-import-file" accept=".json" style="display:none" onchange="App.handleImportFile(this)">
</div>
</section>
</main>
<!-- ═══ Channel EQ modal ═══ -->
<div id="eq-modal" class="modal hidden">
<div class="modal-backdrop" onclick="App.closeEQ()"></div>
<div class="modal-content">
<div class="modal-header">
<h3 id="eq-modal-title">Channel EQ</h3>
<button class="modal-close" onclick="App.closeEQ()"></button>
</div>
<div id="eq-controls">
<label class="eq-toggle">
<input type="checkbox" id="eq-enable" onchange="App.sendParam('eq_enable', this.checked ? 1 : 0, App.eqChannel)">
Enable EQ
</label>
<div class="eq-bands" id="eq-bands"></div>
</div>
<div id="dyn-controls">
<h4>Compressor</h4>
<div class="param-rows" id="comp-params"></div>
<h4>Noise Gate</h4>
<div class="param-rows" id="gate-params"></div>
</div>
</div>
</div>
<!-- ═══ Log panel ═══ -->
<div id="log-panel">
<div id="log-header">
<span id="log-toggle" onclick="App.toggleLog()">📋 Log ▾</span>
</div>
<div id="log-body" class="collapsed">
<div id="log"></div>
</div>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>