FOSFENO: motor de visuales audio-reactivas para Raspberry Pi

Primera version. Cinco motores (projectM, Butterchurn, Hydra, Shaders GLSL y mezclador VJ con camara y video), panel de control web, deteccion de BPM propia, pantalla de conexion con codigo QR, instalador robusto para Raspberry Pi 4 y 5 y documentacion completa en docs/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hacklab 2026-05-22 14:18:19 +02:00
commit 30a09fdee6
31 changed files with 3478 additions and 0 deletions

201
web/panel/index.html Normal file
View file

@ -0,0 +1,201 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FOSFENO :: Panel</title>
<link rel="stylesheet" href="/panel/panel.css">
<link rel="stylesheet" href="/lib/codemirror/codemirror.css">
<link rel="stylesheet" href="/lib/codemirror/material-darker.css">
</head>
<body>
<header>
<h1>F O S F E N O</h1>
<span id="conn" class="dot off" title="Conexion"></span>
</header>
<!-- Banda de avisos: aqui aparece cualquier error o aviso -->
<div id="notif" class="notif" hidden>
<span id="notif-msg"></span>
<button id="notif-close" aria-label="Cerrar aviso">&times;</button>
</div>
<main>
<!-- Encendido -->
<section class="card center">
<div class="cardhead">
<span class="label">Visuales</span>
<button class="info" data-help="power">i</button>
</div>
<button id="power" class="power-btn">ON</button>
</section>
<!-- Selector de motor -->
<section class="card">
<div class="cardhead">
<span class="label">Motor de visuales</span>
<button class="info" data-help="engines">i</button>
</div>
<div class="engines">
<button class="engine" data-engine="projectm">
<strong>projectM</strong><small>nativo</small></button>
<button class="engine" data-engine="butterchurn">
<strong>Butter</strong><small>MilkDrop</small></button>
<button class="engine" data-engine="hydra">
<strong>Hydra</strong><small>codigo</small></button>
<button class="engine" data-engine="shaders">
<strong>Shaders</strong><small>GLSL</small></button>
<button class="engine" data-engine="mixer">
<strong>Mezcla</strong><small>camara</small></button>
</div>
</section>
<!-- Audio: tarjeta + BPM -->
<section class="card">
<div class="cardhead">
<span class="label">Audio</span>
<button class="info" data-help="audio">i</button>
</div>
<select id="audio-device"><option>Detectando entradas...</option></select>
<button id="dev-rescan" class="cmd">Buscar dispositivos de nuevo</button>
<div class="row">
<span class="label">BPM detectado</span>
<span id="bpm" class="bpm">--</span>
</div>
<p class="hint">Si conectas el microfono o la camara con la Raspberry ya
encendida, pulsa este boton para que aparezcan.</p>
</section>
<!-- Sensibilidad -->
<section class="card">
<div class="cardhead">
<span class="label">Sensibilidad al audio:
<span id="sens-val" class="value">1.0</span></span>
<button class="info" data-help="sensibilidad">i</button>
</div>
<input id="sens" type="range" min="0" max="4" step="0.1" value="1">
</section>
<!-- Butterchurn -->
<section class="card" id="ctl-butterchurn" hidden>
<div class="cardhead">
<span class="label">Butterchurn</span>
<button class="info" data-help="butterchurn">i</button>
</div>
<div class="row">
<button class="cmd" data-action="prev">&laquo; Anterior</button>
<button class="cmd" data-action="next">Siguiente &raquo;</button>
</div>
<select id="bc-preset"><option>Cargando presets...</option></select>
<label class="check">
<input type="checkbox" id="bc-shuffle"> Cambio automatico de preset
</label>
<div class="seg">
<button id="bc-mode-seconds" class="segbtn">Por segundos</button>
<button id="bc-mode-beats" class="segbtn">Al compas (BPM)</button>
</div>
<div class="row">
<span class="label">Intervalo: <span id="bc-int-val">20</span>
<span id="bc-int-unit">s</span></span>
</div>
<input id="bc-interval" type="range" min="1" max="120" step="1" value="20">
<div class="row">
<span class="label">Transicion: <span id="bc-blend-val">2.7</span> s</span>
</div>
<input id="bc-blend" type="range" min="0" max="10" step="0.1" value="2.7">
</section>
<!-- Editor de codigo (Hydra / Shaders) -->
<section class="card" id="ctl-editor" hidden>
<div class="cardhead">
<span class="label" id="editor-title">Editor de codigo</span>
<button class="info" id="editor-info" data-help="hydra">i</button>
</div>
<select id="lib-select"><option>Libreria...</option></select>
<textarea id="code"></textarea>
<div class="row">
<button id="run-code" class="cmd accent">Ejecutar</button>
<button id="clear-code" class="cmd">Limpiar</button>
</div>
<p class="hint" id="editor-hint"></p>
</section>
<!-- Mezclador VJ -->
<section class="card" id="ctl-mixer" hidden>
<div class="cardhead">
<span class="label">Mezclador VJ</span>
<button class="info" data-help="mixer">i</button>
</div>
<div class="seg" id="mixer-source">
<button class="segbtn" data-src="cam">Camara</button>
<button class="segbtn" data-src="video">Video</button>
<button class="segbtn" data-src="mix">Mezcla</button>
</div>
<label class="check">
<input type="checkbox" id="mix-cam"> Camara activada
</label>
<select id="mix-camera"><option value="0">Camara por defecto</option></select>
<select id="mix-video"><option value="">-- sin video --</option></select>
<button id="mix-rescan" class="cmd">Actualizar lista de videos</button>
<div class="row">
<span class="label">Modo de mezcla</span>
<select id="mix-blend" class="inline">
<option value="blend">Fundido</option>
<option value="diff">Diferencia</option>
<option value="mult">Multiplicar</option>
<option value="add">Sumar</option>
<option value="layer">Capa</option>
</select>
</div>
<div id="mixer-sliders"></div>
<label class="check">
<input type="checkbox" id="mix-invert"> Invertir colores
</label>
<label class="check">
<input type="checkbox" id="mix-beat"> Pulso al ritmo
</label>
</section>
<!-- projectM -->
<section class="card" id="ctl-projectm" hidden>
<div class="cardhead">
<span class="label">projectM</span>
<button class="info" data-help="projectm">i</button>
</div>
<div class="row">
<button class="cmd" data-action="prev">&laquo; Anterior</button>
<button class="cmd" data-action="next">Siguiente &raquo;</button>
</div>
</section>
<!-- Estado -->
<section class="card">
<span class="label">Ahora suena</span>
<div id="now" class="now">-</div>
</section>
<!-- Sistema -->
<section class="card sys">
<button id="reboot" class="sysbtn">Reiniciar Pi</button>
<button id="shutdown" class="sysbtn danger">Apagar Pi</button>
</section>
<p id="net-foot" class="netfoot">FOSFENO</p>
</main>
<!-- Ventana de informacion -->
<div id="modal" class="modal" hidden>
<div class="modal-box">
<h2 id="modal-title">Informacion</h2>
<div id="modal-body"></div>
<button id="modal-close" class="cmd accent">Entendido</button>
</div>
</div>
<script src="/lib/socket.io.min.js"></script>
<script src="/lib/codemirror/codemirror.js"></script>
<script src="/lib/codemirror/javascript.js"></script>
<script src="/lib/codemirror/clike.js"></script>
<script src="/panel/panel.js"></script>
</body>
</html>

212
web/panel/panel.css Normal file
View file

@ -0,0 +1,212 @@
/* FOSFENO :: Panel de control
Tema verde acido y negro, con acentos en naranja neon. */
:root {
--bg: #060a06;
--card: #0d120c;
--line: #243016;
--green: #b4ff00; /* verde acido */
--green-d: #86bd00;
--orange: #ff7a00; /* naranja acido */
--orange-n: #ff9d2a; /* naranja neon */
--ink: #050705; /* negro: texto sobre superficies brillantes */
--txt: #cfe8ad; /* texto claro sobre fondo oscuro */
--dim: #6f8a52;
--red: #ff3b2f;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
/* El atributo 'hidden' debe ocultar siempre, incluso sobre display:flex */
[hidden] { display: none !important; }
body {
background: var(--bg);
color: var(--txt);
font-family: "Segoe UI", Roboto, system-ui, sans-serif;
-webkit-tap-highlight-color: transparent;
padding-bottom: 48px;
}
header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 20px; border-bottom: 2px solid var(--green-d);
position: sticky; top: 0; background: var(--bg); z-index: 10;
}
header h1 {
font-size: 20px; font-weight: 800; letter-spacing: 0.32em;
color: var(--green); text-shadow: 0 0 14px rgba(180, 255, 0, 0.55);
}
.dot { width: 13px; height: 13px; border-radius: 50%; display: inline-block; }
.dot.on { background: var(--green); box-shadow: 0 0 9px var(--green); }
.dot.off { background: var(--red); box-shadow: 0 0 9px var(--red); }
main {
max-width: 560px; margin: 0 auto;
padding: 16px; display: flex; flex-direction: column; gap: 14px;
}
/* --- Tarjetas --- */
.card {
background: var(--card); border: 1px solid var(--line);
border-radius: 16px; padding: 16px;
display: flex; flex-direction: column; gap: 12px;
}
.card.center { align-items: center; }
.cardhead {
display: flex; align-items: center; justify-content: space-between;
gap: 10px; width: 100%;
}
.label { color: var(--green); font-size: 13px; text-transform: uppercase;
letter-spacing: 0.09em; font-weight: 700; }
.value { color: var(--orange-n); font-weight: 800; }
.hint { color: var(--dim); font-size: 12px; line-height: 1.45; }
.row { display: flex; align-items: center; justify-content: space-between;
gap: 10px; }
.row .cmd { flex: 1; }
/* --- Boton de informacion (circular naranja) --- */
.info {
width: 28px; height: 28px; flex: none; border-radius: 50%;
background: var(--orange); color: var(--ink); border: none;
font-weight: 900; font-style: italic; font-size: 15px; cursor: pointer;
box-shadow: 0 0 10px rgba(255, 122, 0, 0.5);
}
.info:active { background: var(--orange-n); }
/* --- Boton de encendido (circular grande) --- */
.power-btn {
width: 128px; height: 128px; border-radius: 50%;
border: none; cursor: pointer; font-weight: 900; font-size: 26px;
letter-spacing: 0.05em;
background: var(--green); color: var(--ink);
box-shadow: 0 0 26px rgba(180, 255, 0, 0.55);
}
.power-btn.off {
background: var(--card); color: var(--dim);
border: 3px solid var(--line); box-shadow: none;
}
/* --- Selector de motor (botones circulares) --- */
.engines {
display: flex; flex-wrap: wrap; gap: 12px; justify-content: center;
}
.engine {
width: 96px; height: 96px; border-radius: 50%;
display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 2px; cursor: pointer;
background: var(--card); border: 3px solid var(--line); color: var(--txt);
}
.engine strong { font-size: 13px; }
.engine small { font-size: 9px; color: var(--dim); }
.engine.active {
background: var(--green); color: var(--ink); border-color: var(--orange);
box-shadow: 0 0 20px rgba(180, 255, 0, 0.6);
}
.engine.active small { color: var(--ink); }
/* --- Botones de comando --- */
.cmd, .sysbtn {
padding: 13px; border-radius: 11px; cursor: pointer;
background: #161d10; border: 1px solid var(--line); color: var(--txt);
font-weight: 700; font-size: 14px;
}
.cmd:active, .sysbtn:active { background: #202a16; }
.cmd.accent {
background: var(--green); color: var(--ink); border: none;
}
/* --- Selectores y sliders --- */
select {
width: 100%; padding: 12px; border-radius: 11px;
background: #161d10; color: var(--txt); border: 1px solid var(--line);
font-size: 14px;
}
select.inline { width: auto; padding: 8px 10px; }
input[type=range] { width: 100%; accent-color: var(--green); height: 30px; }
.check { display: flex; align-items: center; gap: 10px; font-size: 14px;
color: var(--txt); }
.check input { width: 22px; height: 22px; accent-color: var(--orange); }
/* --- Botones segmentados --- */
.seg { display: flex; gap: 6px; }
.segbtn {
flex: 1; padding: 11px 6px; border-radius: 10px; cursor: pointer;
background: #161d10; border: 2px solid var(--line); color: var(--dim);
font-weight: 700; font-size: 13px;
}
.segbtn.active {
border-color: var(--orange); color: var(--orange-n);
box-shadow: 0 0 12px rgba(255, 122, 0, 0.35);
}
/* --- BPM --- */
.bpm {
font-size: 30px; font-weight: 900; color: var(--orange-n);
text-shadow: 0 0 14px rgba(255, 157, 42, 0.6);
font-variant-numeric: tabular-nums;
}
/* --- Sliders del mezclador --- */
.slider { display: flex; flex-direction: column; gap: 4px; margin-bottom: 6px; }
.slider .head {
display: flex; justify-content: space-between; font-size: 13px;
color: var(--dim);
}
.slider .head b { color: var(--green); font-weight: 800; }
/* --- Editor CodeMirror --- */
.CodeMirror {
height: 220px; border-radius: 11px; border: 1px solid var(--green-d);
font-size: 13px; font-family: "Fira Mono", Consolas, monospace;
}
.now { font-size: 15px; color: var(--orange-n); word-break: break-word; }
/* --- Sistema --- */
.sys { flex-direction: row; }
.sys .sysbtn { flex: 1; }
.sysbtn.danger { color: var(--red); border-color: var(--red); }
/* --- Banda de avisos --- */
.notif {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; font-size: 14px; font-weight: 600;
position: sticky; top: 60px; z-index: 9;
}
.notif.info { background: var(--green); color: var(--ink); }
.notif.warn { background: var(--orange); color: var(--ink); }
.notif.error { background: var(--red); color: #fff; }
.notif span { flex: 1; }
.notif button {
background: transparent; border: none; color: inherit;
font-size: 22px; font-weight: 900; cursor: pointer; line-height: 1;
}
/* --- Ventana de informacion --- */
.modal {
position: fixed; inset: 0; z-index: 50;
background: rgba(3, 6, 3, 0.88);
display: flex; align-items: center; justify-content: center; padding: 20px;
}
.modal-box {
background: var(--card); border: 2px solid var(--green);
border-radius: 16px; padding: 22px; max-width: 460px; width: 100%;
max-height: 80vh; overflow-y: auto;
display: flex; flex-direction: column; gap: 14px;
box-shadow: 0 0 30px rgba(180, 255, 0, 0.3);
}
.modal-box h2 {
color: var(--green); font-size: 18px; letter-spacing: 0.04em;
}
.modal-box p { color: var(--txt); font-size: 14px; line-height: 1.5; }
/* Pie con la direccion del panel */
.netfoot {
text-align: center; color: var(--dim);
font-size: 12px; font-family: monospace; padding: 4px 0 8px;
}

409
web/panel/panel.js Normal file
View file

@ -0,0 +1,409 @@
/*
* FOSFENO :: Panel de control
* Conecta con el servidor, refleja el estado y envia ordenes.
* Incluye editor de codigo, mezclador VJ, ventana de ayuda y avisos.
*/
const socket = io();
let state = null;
let editorEngine = ""; // que motor tiene cargado el editor
let helpData = {}; // textos de ayuda
let lastNotifT = 0; // marca de tiempo del ultimo aviso mostrado
const $ = (s) => document.querySelector(s);
const $$ = (s) => document.querySelectorAll(s);
// Sliders del mezclador VJ (se generan dinamicamente)
const MIXER_SLIDERS = [
{ key: "mix", label: "Mezcla A/B", min: 0, max: 1, step: 0.01 },
{ key: "hue", label: "Tono (hue)", min: 0, max: 1, step: 0.01 },
{ key: "saturate", label: "Saturacion", min: 0, max: 3, step: 0.05 },
{ key: "contrast", label: "Contraste", min: 0, max: 3, step: 0.05 },
{ key: "brightness", label: "Brillo", min: -0.5, max: 0.5, step: 0.02 },
{ key: "colorama", label: "Colorama", min: 0, max: 1, step: 0.01 },
{ key: "posterize", label: "Posterizar", min: 0, max: 8, step: 1 },
{ key: "pixelate", label: "Pixelado", min: 0, max: 1, step: 0.02 },
{ key: "kaleid", label: "Caleidoscopio", min: 0, max: 12, step: 1 },
{ key: "rotate", label: "Rotacion", min: -3, max: 3, step: 0.05 },
{ key: "feedback", label: "Feedback", min: 0, max: 0.95, step: 0.01 },
];
// ==========================================================================
// Editor de codigo (CodeMirror)
// ==========================================================================
let editor = null;
if (typeof CodeMirror !== "undefined") {
editor = CodeMirror.fromTextArea($("#code"), {
lineNumbers: true, theme: "material-darker",
mode: "javascript", lineWrapping: true,
});
}
// ==========================================================================
// Librerias de codigo y de ayuda
// ==========================================================================
let hydraLib = [];
let shaderLib = [];
async function loadData() {
try {
const sketches = await fetch("/data/hydra-sketches.json").then((r) => r.json());
const snippets = await fetch("/data/hydra-snippets.json").then((r) => r.json());
const shaders = await fetch("/data/shaders.json").then((r) => r.json());
hydraLib = [];
for (const [id, v] of Object.entries(sketches)) {
hydraLib.push({ id: "s:" + id, name: "* " + (v.name || id), code: v.code });
}
for (const [id, v] of Object.entries(snippets.snippets || {})) {
hydraLib.push({ id: "x:" + id, name: v.name || id, code: v.code });
}
shaderLib = Object.entries(shaders).map(
([id, v]) => ({ id: id, name: v.name || id, code: v.code }));
} catch (err) {
console.error("[FOSFENO] No se pudieron cargar las librerias:", err);
}
try {
const ayuda = await fetch("/data/ayuda.json").then((r) => r.json());
helpData = ayuda.ayuda || {};
} catch (err) {
console.error("[FOSFENO] No se pudo cargar la ayuda:", err);
}
// Si el estado llego antes que las librerias, repinta para que aparezcan
if (state) { editorEngine = ""; render(); }
}
function fillLibrarySelect(lib) {
const sel = $("#lib-select");
sel.innerHTML = '<option value="">-- Cargar de la libreria --</option>';
lib.forEach((it, i) => {
const opt = document.createElement("option");
opt.value = String(i);
opt.textContent = it.name;
sel.appendChild(opt);
});
}
// ==========================================================================
// Ventana de informacion
// ==========================================================================
function openHelp(key) {
const h = helpData[key];
if (!h) return;
$("#modal-title").textContent = h.title || "Informacion";
const body = $("#modal-body");
body.innerHTML = "";
(h.body || []).forEach((par) => {
const p = document.createElement("p");
p.textContent = par;
body.appendChild(p);
});
$("#modal").hidden = false;
}
function closeHelp() { $("#modal").hidden = true; }
// ==========================================================================
// Avisos (banda superior)
// ==========================================================================
let notifTimer = null;
function showNotif(entry) {
if (!entry || !entry.message) return;
const n = $("#notif");
n.className = "notif " + (entry.level || "info");
$("#notif-msg").textContent = entry.message;
n.hidden = false;
clearTimeout(notifTimer);
if (entry.level === "info") {
notifTimer = setTimeout(() => { n.hidden = true; }, 6000);
}
if (entry.t) lastNotifT = entry.t;
}
// ==========================================================================
// Sliders del mezclador
// ==========================================================================
function fmt(v, step) {
return step >= 1 ? String(Math.round(v)) : Number(v).toFixed(2);
}
function buildMixerSliders() {
const cont = $("#mixer-sliders");
MIXER_SLIDERS.forEach((cfg) => {
const wrap = document.createElement("div");
wrap.className = "slider";
wrap.innerHTML =
`<div class="head"><span>${cfg.label}</span>` +
`<b id="mv-${cfg.key}">-</b></div>`;
const input = document.createElement("input");
input.type = "range";
input.min = cfg.min; input.max = cfg.max; input.step = cfg.step;
input.id = "ms-" + cfg.key;
input.addEventListener("input", () => {
$("#mv-" + cfg.key).textContent = fmt(input.value, cfg.step);
});
input.addEventListener("change", () => {
const patch = {};
patch[cfg.key] = parseFloat(input.value);
socket.emit("update_settings", { engine: "mixer", patch: patch });
});
wrap.appendChild(input);
cont.appendChild(wrap);
});
}
// ==========================================================================
// Render
// ==========================================================================
function render() {
if (!state) return;
$("#conn").className = "dot on";
const power = $("#power");
power.textContent = state.power ? "ON" : "OFF";
power.classList.toggle("off", !state.power);
$$(".engine").forEach((b) =>
b.classList.toggle("active", b.dataset.engine === state.engine));
fillSelectOnce($("#audio-device"), state.meta.audioDevices,
(d) => d.id, (d) => d.name);
if (state.audio.device) $("#audio-device").value = state.audio.device;
$("#bpm").textContent = state.audio.bpm > 0 ? state.audio.bpm : "--";
$("#sens").value = state.sensitivity;
$("#sens-val").textContent = Number(state.sensitivity).toFixed(1);
$("#ctl-butterchurn").hidden = state.engine !== "butterchurn";
$("#ctl-editor").hidden = !(state.engine === "hydra" || state.engine === "shaders");
$("#ctl-mixer").hidden = state.engine !== "mixer";
$("#ctl-projectm").hidden = state.engine !== "projectm";
renderButterchurn();
renderEditor();
renderMixer();
$("#now").textContent = state.status.label || "-";
const net = state.network || {};
const port = (net.port && net.port !== 80) ? ":" + net.port : "";
$("#net-foot").textContent = "Panel: http://" +
(net.hostname || "fosfeno") + ".local" + port + "/" +
(net.ip ? " (" + net.ip + ")" : "");
// Aviso pendiente recibido antes de abrir el panel
const notes = state.notifications || [];
if (notes.length) {
const last = notes[notes.length - 1];
if (last.t !== lastNotifT) showNotif(last);
}
}
function fillSelectOnce(sel, items, valFn, txtFn) {
items = items || [];
if (sel.dataset.count == items.length) return;
sel.dataset.count = items.length;
let keep = "";
if (sel.id === "mix-video") {
keep = '<option value="">-- sin video --</option>';
} else if (sel.id === "mix-camera" && items.length === 0) {
keep = '<option value="0">Camara por defecto</option>';
}
sel.innerHTML = keep;
items.forEach((it) => {
const opt = document.createElement("option");
opt.value = valFn(it);
opt.textContent = txtFn(it);
sel.appendChild(opt);
});
}
function renderButterchurn() {
if (state.engine !== "butterchurn") return;
const b = state.butterchurn;
fillSelectOnce($("#bc-preset"), state.meta.butterchurnPresets,
(n) => n, (n) => n);
if (b.preset) $("#bc-preset").value = b.preset;
$("#bc-shuffle").checked = b.shuffle;
$("#bc-mode-seconds").classList.toggle("active", b.intervalMode === "seconds");
$("#bc-mode-beats").classList.toggle("active", b.intervalMode === "beats");
$("#bc-interval").value = b.interval;
$("#bc-int-val").textContent = b.interval;
$("#bc-int-unit").textContent = b.intervalMode === "beats" ? "comp." : "s";
$("#bc-blend").value = b.blendTime;
$("#bc-blend-val").textContent = Number(b.blendTime).toFixed(1);
}
function renderEditor() {
if (state.engine !== "hydra" && state.engine !== "shaders") return;
$("#editor-info").dataset.help = state.engine;
if (state.engine !== editorEngine) {
editorEngine = state.engine;
const isHydra = state.engine === "hydra";
$("#editor-title").textContent = isHydra
? "Editor Hydra (JavaScript)" : "Editor de shaders (GLSL)";
$("#editor-hint").textContent = isHydra
? "Variables: time, a.fft[0..4], bpm. Escribe o pega codigo Hydra."
: "Uniforms: u_time, u_bass, u_mid, u_treble, u_bpm, u_beat, u_fft.";
fillLibrarySelect(isHydra ? hydraLib : shaderLib);
if (editor) {
editor.setOption("mode", isHydra ? "javascript" : "text/x-csrc");
editor.setValue(state[state.engine].code || "");
setTimeout(() => editor.refresh(), 30);
}
}
}
function renderMixer() {
if (state.engine !== "mixer") return;
const m = state.mixer;
$$("#mixer-source .segbtn").forEach((b) =>
b.classList.toggle("active", b.dataset.src === m.source));
$("#mix-cam").checked = m.camOn;
fillSelectOnce($("#mix-camera"), state.meta.cameraDevices,
(d) => d.id, (d) => d.name);
$("#mix-camera").value = m.cameraId;
fillSelectOnce($("#mix-video"), (state.meta.videos || []).map((v) => ({ v })),
(o) => o.v, (o) => o.v);
$("#mix-video").value = m.video || "";
$("#mix-blend").value = m.blendMode;
$("#mix-invert").checked = m.invert;
$("#mix-beat").checked = m.beatPulse;
MIXER_SLIDERS.forEach((cfg) => {
const input = $("#ms-" + cfg.key);
if (input && document.activeElement !== input) {
input.value = m[cfg.key];
$("#mv-" + cfg.key).textContent = fmt(m[cfg.key], cfg.step);
}
});
}
// ==========================================================================
// Eventos de la interfaz
// ==========================================================================
$("#power").addEventListener("click", () =>
socket.emit("set_power", { on: !state.power }));
$$(".engine").forEach((b) =>
b.addEventListener("click", () =>
socket.emit("set_engine", { engine: b.dataset.engine })));
$$(".info").forEach((b) =>
b.addEventListener("click", () => openHelp(b.dataset.help)));
$("#modal-close").addEventListener("click", closeHelp);
$("#modal").addEventListener("click", (e) => {
if (e.target.id === "modal") closeHelp();
});
$("#notif-close").addEventListener("click", () => { $("#notif").hidden = true; });
$("#audio-device").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "audio", patch: { device: e.target.value } }));
$("#sens").addEventListener("input", (e) =>
$("#sens-val").textContent = Number(e.target.value).toFixed(1));
$("#sens").addEventListener("change", (e) =>
socket.emit("set_sensitivity", { value: parseFloat(e.target.value) }));
// --- Butterchurn ---
$("#bc-preset").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "butterchurn", patch: { preset: e.target.value } }));
$("#bc-shuffle").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "butterchurn", patch: { shuffle: e.target.checked } }));
$("#bc-mode-seconds").addEventListener("click", () =>
socket.emit("update_settings",
{ engine: "butterchurn", patch: { intervalMode: "seconds" } }));
$("#bc-mode-beats").addEventListener("click", () =>
socket.emit("update_settings",
{ engine: "butterchurn", patch: { intervalMode: "beats" } }));
$("#bc-interval").addEventListener("input", (e) =>
$("#bc-int-val").textContent = e.target.value);
$("#bc-interval").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "butterchurn", patch: { interval: parseInt(e.target.value, 10) } }));
$("#bc-blend").addEventListener("input", (e) =>
$("#bc-blend-val").textContent = Number(e.target.value).toFixed(1));
$("#bc-blend").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "butterchurn", patch: { blendTime: parseFloat(e.target.value) } }));
// --- Editor de codigo ---
$("#run-code").addEventListener("click", () => {
if (!editor || !editorEngine) return;
socket.emit("run_code",
{ engine: editorEngine, code: editor.getValue(), label: "codigo personalizado" });
});
$("#clear-code").addEventListener("click", () => editor && editor.setValue(""));
$("#lib-select").addEventListener("change", (e) => {
const lib = editorEngine === "hydra" ? hydraLib : shaderLib;
const item = lib[parseInt(e.target.value, 10)];
if (!item || !editor) return;
editor.setValue(item.code);
socket.emit("run_code",
{ engine: editorEngine, code: item.code, label: item.name });
});
// --- Mezclador ---
$$("#mixer-source .segbtn").forEach((b) =>
b.addEventListener("click", () =>
socket.emit("update_settings",
{ engine: "mixer", patch: { source: b.dataset.src } })));
$("#mix-cam").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { camOn: e.target.checked } }));
$("#mix-camera").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { cameraId: parseInt(e.target.value, 10) } }));
$("#dev-rescan").addEventListener("click", () =>
socket.emit("rescan_devices"));
$("#mix-video").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { video: e.target.value } }));
$("#mix-blend").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { blendMode: e.target.value } }));
$("#mix-rescan").addEventListener("click", () => socket.emit("rescan_videos"));
$("#mix-invert").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { invert: e.target.checked } }));
$("#mix-beat").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { beatPulse: e.target.checked } }));
// --- Comandos siguiente/anterior ---
$$(".cmd[data-action]").forEach((b) =>
b.addEventListener("click", () =>
socket.emit("engine_command", { action: b.dataset.action })));
// --- Sistema ---
$("#reboot").addEventListener("click", () => {
if (confirm("Reiniciar la Raspberry Pi?")) socket.emit("system", { action: "reboot" });
});
$("#shutdown").addEventListener("click", () => {
if (confirm("Apagar la Raspberry Pi?")) socket.emit("system", { action: "shutdown" });
});
// ==========================================================================
// WebSocket
// ==========================================================================
// Al conectar, el panel se presenta para que el proyector deje de mostrar
// la pantalla del codigo QR.
socket.on("connect", () => socket.emit("hello", { role: "panel" }));
socket.on("state", (next) => { state = next; render(); });
socket.on("status", (s) => {
if (!state) return;
state.status.label = s.label;
state.audio.bpm = s.bpm;
$("#now").textContent = s.label || "-";
$("#bpm").textContent = s.bpm > 0 ? s.bpm : "--";
});
socket.on("notify", (entry) => showNotif(entry));
socket.on("disconnect", () => { $("#conn").className = "dot off"; });
// ==========================================================================
// Arranque
// ==========================================================================
buildMixerSliders();
loadData();