FOSFENO/web/panel/panel.js
hacklab 61543e1987 MOTORES: pastillas configurables y destino (salida o capa)
Las pestanas de capa de la barra superior no valian: mezclaban navegar por el
panel con elegir sobre que se trabaja, y sacaban del panel de directo. Se
quitan y todo pasa a MOTORES, con dos decisiones separadas:

  DONDE  -> fila 'Donde se ve': SALIDA o una capa del mapping (+ crea una).
  QUE    -> las pastillas: combinaciones guardadas por el usuario.

Pinchar una pastilla la manda al destino elegido: con SALIDA cambia el motor
global, con una capa cambia SOLO esa capa y el resto sigue igual. Con una capa
elegida sus ajustes salen en MOTORES (la tarjeta de propiedades se mueve, no
se duplica: dos formularios para lo mismo se desincronizan a la primera).

Las pastillas son configurables: motor + preset + ajustes y su nombre, con
'Configurar pastillas'. Persisten en data/slots.json (gitignorado, es de cada
instalacion). De fabrica vienen los cinco motores de siempre, asi que quien
actualice se encuentra lo mismo que tenia. Hydra, Mezcla y projectM nativo
solo pueden ir a la salida, y al intentar mandarlos a una capa se dice.

Comprobado en Chromium por CDP, no con --dump-dom: ese fotografia el DOM al
cargar, antes de que llegue el estado por socket, y daba falsos "esta vacio".
Verificado que disparar sobre una capa deja el motor global intacto, que la
tarjeta de propiedades va y vuelve de su sitio, y que las pastillas nuevas se
guardan en disco.

De paso, dos fallos que aparecieron al probarlo:
- Restaurar la vista guardada corria al principio del fichero y llamaba a
  codigo que usa variables declaradas con let mas abajo: ReferenceError que
  se llevaba por delante el resto del script (socket incluido) y dejaba el
  panel en blanco. Ahora se restaura al final.
- #card-destino no estaba en la lista de 'order' del CSS, asi que caia a 0 y
  se colaba encima de las pastillas pese a ir despues en el HTML.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 16:47:01 +02:00

1959 lines
80 KiB
JavaScript

/*
* 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.
*/
// Clave compartida (config.json -> auth.token). Llega en la direccion del
// panel, que es la que trae el codigo QR del proyector. Vacia = sin clave.
const CLAVE = new URLSearchParams(location.search).get("k") || "";
const socket = io({ auth: { token: CLAVE } });
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);
// ==========================================================================
// Vistas: MOTORES (lo de siempre) y MAPPING (encaje sobre la superficie)
// Se separan para no mezclar los controles de directo con los de montaje.
// La eleccion se recuerda en el propio navegador.
// ==========================================================================
/* mapSurfaces se declara mas abajo con let: setView puede correr ANTES de esa
linea (al restaurar la vista guardada), y tocarla daria ReferenceError. */
function capasHoy() {
try { return mapSurfaces || []; } catch (e) { return []; }
}
function setView(view) {
if (view !== "mapping") view = "motores";
document.body.dataset.view = view;
vistaActual = view;
$$(".tab").forEach((t) => t.classList.toggle("active", t.dataset.view === view));
try { localStorage.setItem("fosfeno.view", view); } catch (e) { /* privado */ }
// Al salir de MOTORES, las propiedades de capa vuelven a su tarjeta de
// MAPPING (ver colocarPropiedades)
colocarPropiedades();
// El lienzo del mapping mide 0 mientras esta oculto: hay que repintarlo
// al entrar en su vista, ya con su tamano real.
if (view === "mapping" && state) drawMapPreview();
}
let vistaActual = "motores";
$$(".tab[data-view]").forEach((t) =>
t.addEventListener("click", () => setView(t.dataset.view)));
/* La vista guardada se restaura al FINAL del fichero (restaurarVista), no
aqui: setView toca variables declaradas mas abajo con let, y llamarlo a
estas alturas revienta con "Cannot access ... before initialization" —
matando de paso el resto del script, socket incluido. */
// 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; }
// ==========================================================================
// Pastillas y destino
// --------------------------------------------------------------------------
// El panel se maneja con dos decisiones separadas, y esa separacion es lo que
// lo hace usable en directo:
//
// DONDE -> la fila de destinos: la salida entera, o una capa del mapping.
// QUE -> las pastillas (circulos): combinaciones guardadas por el usuario.
//
// Pinchar una pastilla la manda al destino que este elegido. Asi el mismo
// gesto sirve para cambiar el visual de toda la pantalla o el de una zona
// mapeada, sin cambiar de pestana ni de mentalidad.
// ==========================================================================
let destinoSel = "salida"; // "salida" | id de superficie
let modoConfig = false; // configurando pastillas en vez de dispararlas
let slotEditando = null; // id de la pastilla abierta en el editor
const SLOT_ETIQUETA = {
butter: "MilkDrop", shader: "GLSL", video: "clip", camara: "camara",
hydra: "codigo", mixer: "mezcla", "projectm-nativo": "nativo",
};
// Lo que solo tiene sentido a pantalla completa: son motores globales, no
// fuentes que una capa pueda pintar por su cuenta.
const SOLO_SALIDA = new Set(["hydra", "mixer", "projectm-nativo"]);
function slots() { return (state && state.slots) || []; }
function slotSub(sl) {
if (sl.tipo === "butter" && sl.biblioteca === "projectm" && sl.milk) {
return sl.milk.split("/").pop().replace(/\.milk$/i, "").slice(0, 12);
}
if (sl.tipo === "butter" && sl.preset) return sl.preset.slice(0, 12);
if (sl.tipo === "video" && sl.archivo) return tituloClip(sl.archivo).slice(0, 12);
if (sl.tipo === "shader" && sl.shader) return sl.shader.slice(0, 12);
return SLOT_ETIQUETA[sl.tipo] || "";
}
/* Que pastilla se corresponde con lo que hay ahora mismo en la salida, para
poder marcarla. No siempre hay una: si tocas el preset a mano, ya no
coincide con ninguna y no se marca ninguna, que es lo honesto. */
function slotActivaEnSalida() {
if (destinoSel !== "salida" || !state) return null;
const e = state.engine;
const cand = slots().filter((sl) => {
if (sl.tipo === "projectm-nativo") return e === "projectm";
if (sl.tipo === "hydra") return e === "hydra";
if (sl.tipo === "mixer") return e === "mixer";
if (sl.tipo === "shader") return e === "shaders";
if (sl.tipo === "butter") return e === "butterchurn";
return false;
});
if (cand.length === 1) return cand[0].id;
// Varias del mismo motor: desempata el preset
const exacto = cand.find((sl) => sl.tipo === "butter"
&& sl.preset && sl.preset === (state.butterchurn || {}).preset);
return exacto ? exacto.id : (cand.length ? cand[0].id : null);
}
function renderSlots() {
const cont = $("#slots");
if (!cont) return;
const lista = slots();
const activa = slotActivaEnSalida();
const sig = lista.map((s) => s.id + ":" + s.nombre + ":" + slotSub(s)).join("|")
+ "|" + modoConfig + "|" + activa + "|" + slotEditando;
if (cont.dataset.sig === sig) return;
cont.dataset.sig = sig;
cont.innerHTML = "";
lista.forEach((sl) => {
const b = document.createElement("button");
b.className = "engine";
if (!modoConfig && sl.id === activa) b.classList.add("active");
if (modoConfig && sl.id === slotEditando) b.classList.add("editando");
b.innerHTML = "";
const n = document.createElement("strong");
n.textContent = sl.nombre || "?";
const s = document.createElement("small");
s.textContent = slotSub(sl);
b.appendChild(n); b.appendChild(s);
b.title = modoConfig ? "Configurar esta pastilla" : "Lanzar al destino elegido";
b.addEventListener("click", () => {
if (modoConfig) { abrirEditorSlot(sl.id); return; }
dispararSlot(sl);
});
cont.appendChild(b);
});
if (modoConfig) {
const mas = document.createElement("button");
mas.className = "engine nueva";
mas.innerHTML = "<strong>+</strong><small>nueva</small>";
mas.title = "Anadir una pastilla";
mas.addEventListener("click", nuevaSlot);
cont.appendChild(mas);
}
}
/* Lanza una pastilla al destino elegido. A la salida se traduce a motor; a
una capa, a fuente de capa. Lo que no cabe en una capa se dice, en vez de
no hacer nada y dejar al usuario pensando que ha fallado el toque. */
function dispararSlot(sl) {
if (destinoSel === "salida") {
const ajustes = (engine, patch) =>
socket.emit("update_settings", { engine: engine, patch: patch });
if (sl.tipo === "projectm-nativo") { socket.emit("set_engine", { engine: "projectm" }); return; }
if (sl.tipo === "hydra") { socket.emit("set_engine", { engine: "hydra" }); return; }
if (sl.tipo === "mixer") { socket.emit("set_engine", { engine: "mixer" }); return; }
if (sl.tipo === "shader") {
socket.emit("set_engine", { engine: "shaders" });
const sh = shaderLib.find((x) => x.id === sl.shader);
if (sh) socket.emit("run_code",
{ engine: "shaders", code: sh.code, label: sh.name });
return;
}
if (sl.tipo === "butter") {
if (sl.biblioteca === "projectm" && sl.milk) {
// La biblioteca .milk se traduce en el navegador, y eso solo pasa
// dentro de una capa. A pantalla completa lo lanza projectM nativo,
// que lee el .milk tal cual: mismo preset, otro motor.
const p = sl.milk.split("/");
socket.emit("set_engine", { engine: "projectm" });
ajustes("projectm", { category: p[0], preset: p[1],
variante: (p[2] || "").replace(/\.milk$/i, "") });
return;
}
socket.emit("set_engine", { engine: "butterchurn" });
if (sl.preset) ajustes("butterchurn", { preset: sl.preset, shuffle: false });
return;
}
if (sl.tipo === "video" || sl.tipo === "camara") {
socket.emit("set_engine", { engine: "mixer" });
ajustes("mixer", sl.tipo === "video"
? { source: "video", video: sl.archivo || "" }
: { source: "cam", camOn: true, cameraId: sl.camaraId || 0 });
return;
}
return;
}
// Destino = una capa del mapping
if (SOLO_SALIDA.has(sl.tipo)) {
showNotif({ level: "warn", message: "\"" + (sl.nombre || "Esa pastilla")
+ "\" solo puede ir a pantalla completa, no a una capa. "
+ "Elige el destino SALIDA." });
return;
}
const f = { tipo: sl.tipo };
if (sl.tipo === "butter") {
f.biblioteca = sl.biblioteca === "projectm" ? "projectm" : "butter";
f.milk = sl.milk || "";
f.preset = sl.preset || "";
f.auto = false; f.segundos = 20; f.transicion = 2.5;
} else if (sl.tipo === "shader") f.shader = sl.shader || "";
else if (sl.tipo === "video") f.archivo = sl.archivo || "";
else if (sl.tipo === "camara") f.camaraId = sl.camaraId || 0;
patchCapa(destinoSel, { fuente: f });
renderCapas(); renderPropiedades(); drawMapPreview();
}
/* --- Destinos: salida entera o una capa --- */
function renderDestinos() {
const cont = $("#destinos");
if (!cont) return;
const capas = capasHoy();
// El destino elegido pudo desaparecer (capa borrada desde otro movil)
if (destinoSel !== "salida" && !capas.some((s) => s.id === destinoSel)) {
destinoSel = "salida";
}
const sig = "salida|" + capas.map((s, i) => s.id + ":" + (s.nombre || i + 1)).join("|")
+ "|" + destinoSel;
if (cont.dataset.sig !== sig) {
cont.dataset.sig = sig;
cont.innerHTML = "";
const mk = (id, txt, titulo) => {
const b = document.createElement("button");
b.className = "subtab" + (destinoSel === id ? " active" : "");
b.textContent = txt;
b.title = titulo;
b.addEventListener("click", () => { destinoSel = id; renderTodo(); });
cont.appendChild(b);
return b;
};
mk("salida", "SALIDA", "Lo que se ve en el proyector, a pantalla completa");
capas.forEach((s, i) =>
mk(s.id, (s.nombre || "CAPA " + (i + 1)).toUpperCase(),
"Una zona del mapping"));
const mas = document.createElement("button");
mas.className = "subtab nueva";
mas.textContent = "+";
mas.title = "Crear una capa nueva";
mas.addEventListener("click", () => {
const id = nuevaCapa();
destinoSel = id;
renderCapas(); renderTodo();
});
cont.appendChild(mas);
}
const hint = $("#destino-hint");
if (hint) {
hint.textContent = destinoSel === "salida"
? "Las pastillas cambian lo que se ve en todo el proyector."
: "Las pastillas cambian solo esta capa. El resto sigue igual.";
}
if (destinoSel !== "salida") mapSel = { type: "surface", id: destinoSel };
colocarPropiedades();
}
/* La tarjeta de propiedades de capa es UNA sola en todo el panel: vive en
MAPPING y MOTORES se la lleva prestada cuando el destino es una capa. Se
mueve en vez de duplicarla para no acabar con dos formularios que hay que
mantener a la vez (y que se desincronizan a la primera). */
function colocarPropiedades() {
const card = $("#card-propiedades");
const hueco = $("#destino-props");
if (!card || !hueco) return;
const enMotores = document.body.dataset.view === "motores"
&& destinoSel !== "salida";
if (enMotores && card.parentElement !== hueco) {
hueco.appendChild(card);
} else if (!enMotores && card.parentElement === hueco) {
const col = $("#card-capas");
if (col && col.parentElement) col.parentElement.insertBefore(card, col.nextSibling);
}
hueco.hidden = !enMotores;
}
function renderTodo() {
renderSlots(); renderDestinos(); renderPropiedades(); drawMapPreview();
}
/* --- Configurar pastillas --- */
$("#slots-config").addEventListener("click", () => {
modoConfig = !modoConfig;
if (!modoConfig) slotEditando = null;
$("#slots-config").textContent = modoConfig
? "Listo (volver a lanzar)" : "Configurar pastillas";
$("#slots-config").classList.toggle("accent", modoConfig);
$("#slot-editor").hidden = !modoConfig || !slotEditando;
renderSlots();
});
function nuevaSlot() {
const sl = { id: "sl" + Date.now() + "_" + Math.floor(Math.random() * 1e4),
nombre: "NUEVA", tipo: "butter", biblioteca: "butter", preset: "" };
socket.emit("set_slots", { slots: slots().concat([sl]) });
slotEditando = sl.id;
$("#slot-editor").hidden = false;
}
function abrirEditorSlot(id) {
slotEditando = (slotEditando === id) ? null : id;
$("#slot-editor").hidden = !slotEditando;
renderSlots();
if (slotEditando) renderEditorSlot();
}
function slotActual() { return slots().find((s) => s.id === slotEditando) || null; }
function guardarSlot(cambios) {
const nuevas = slots().map((s) =>
s.id === slotEditando ? Object.assign({}, s, cambios) : s);
socket.emit("set_slots", { slots: nuevas });
}
function renderEditorSlot() {
const sl = slotActual();
if (!sl) { $("#slot-editor").hidden = true; return; }
if (document.activeElement !== $("#slot-nombre")) {
$("#slot-nombre").value = sl.nombre || "";
}
$("#slot-tipo").value = sl.tipo;
$$(".slot-op").forEach((el) => { el.hidden = true; });
const bloque = $("#sop-" + (sl.tipo === "camara" ? "" : sl.tipo));
if (bloque) bloque.hidden = false;
if (sl.tipo === "butter") {
const esMilk = sl.biblioteca === "projectm";
$("#slot-biblioteca").value = esMilk ? "projectm" : "butter";
$("#sop-milk").hidden = !esMilk;
$("#slot-preset").hidden = esMilk;
fillSelectOnce($("#slot-preset"), state.meta.butterchurnPresets, (n) => n, (n) => n);
$("#slot-preset").value = sl.preset || "";
if (esMilk) {
const lib = (state.meta && state.meta.projectmPresets) || {};
const p = partesMilk(sl.milk);
rellena($("#slot-milk-cat"), Object.keys(lib), "-- categoria --");
$("#slot-milk-cat").value = p.cat;
rellena($("#slot-milk-vis"), p.cat ? (lib[p.cat] || []) : [], "-- visual --");
$("#slot-milk-vis").value = p.vis;
const vs = $("#slot-milk-var");
const sig = p.cat + "|" + p.vis;
if (vs.dataset.pedido !== sig) {
vs.dataset.pedido = sig;
rellena(vs, [], "-- variante --");
if (p.cat && p.vis) {
fetch("/pm/variantes?categoria=" + encodeURIComponent(p.cat)
+ "&visual=" + encodeURIComponent(p.vis))
.then((r) => r.json())
.then((d) => {
if (vs.dataset.pedido !== sig) return;
vs.dataset.sig = "";
rellena(vs, d.variantes || [], "-- variante --");
vs.value = partesMilk((slotActual() || {}).milk).vari;
}).catch(() => {});
}
} else if (document.activeElement !== vs) vs.value = p.vari;
}
} else if (sl.tipo === "shader") {
fillSelectOnce($("#slot-shader"), shaderLib, (x) => x.id, (x) => x.name);
$("#slot-shader").value = sl.shader || "";
} else if (sl.tipo === "video") {
fillClipSelect($("#slot-video"));
$("#slot-video").value = sl.archivo || "";
}
}
$("#slot-nombre").addEventListener("input", (e) =>
guardarSlot({ nombre: e.target.value.toUpperCase().slice(0, 12) }));
$("#slot-tipo").addEventListener("change", (e) => {
const t = e.target.value;
const base = { tipo: t };
if (t === "butter") { base.biblioteca = "butter"; base.preset = ""; base.milk = ""; }
if (t === "shader") base.shader = shaderLib.length ? shaderLib[0].id : "";
if (t === "video") {
const vs = (state.meta && state.meta.videos) || [];
base.archivo = vs.length ? vs[0] : "";
}
guardarSlot(base);
});
$("#slot-biblioteca").addEventListener("change", (e) =>
guardarSlot({ biblioteca: e.target.value }));
$("#slot-preset").addEventListener("change", (e) =>
guardarSlot({ preset: e.target.value }));
$("#slot-shader").addEventListener("change", (e) =>
guardarSlot({ shader: e.target.value }));
$("#slot-video").addEventListener("change", (e) =>
guardarSlot({ archivo: e.target.value }));
$("#slot-milk-cat").addEventListener("change", (e) =>
guardarSlot({ milk: e.target.value ? e.target.value + "/" : "" }));
$("#slot-milk-vis").addEventListener("change", (e) =>
guardarSlot({ milk: $("#slot-milk-cat").value + "/" + e.target.value + "/" }));
$("#slot-milk-var").addEventListener("change", (e) =>
guardarSlot({ milk: $("#slot-milk-cat").value + "/" + $("#slot-milk-vis").value
+ "/" + e.target.value + ".milk" }));
$("#slot-borrar").addEventListener("click", () => {
socket.emit("set_slots", { slots: slots().filter((s) => s.id !== slotEditando) });
slotEditando = null;
$("#slot-editor").hidden = true;
});
$("#slot-listo").addEventListener("click", () => {
slotEditando = null;
$("#slot-editor").hidden = true;
renderSlots();
});
// ==========================================================================
// Avisos (banda superior)
// ==========================================================================
let notifTimer = null;
function showNotif(entry) {
if (!entry || !entry.message) return;
const n = $("#notif");
// El nivel va con prefijo 'lv-': sin el, un aviso 'info' cogia tambien el
// estilo del boton redondo de ayuda (.info) y salia como una pelotita.
n.className = "notif lv-" + (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 id="ml-${cfg.key}">${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);
renderSlots();
renderDestinos();
if (slotEditando) renderEditorSlot();
fillSelectOnce($("#audio-device"), state.meta.audioDevices,
(d) => d.id, (d) => d.name);
if (state.audio.device) $("#audio-device").value = state.audio.device;
const source = state.audio.source || "mic";
$("#audio-src-mic").classList.toggle("active", source === "mic");
$("#audio-src-monitor").classList.toggle("active", source === "monitor");
$("#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();
renderProjectM();
renderMapping();
// Aviso en la pestana: el mapping esta deformando la salida aunque
// estemos mirando la pestana de motores.
$("#tab-map-dot").hidden = !(state.mapping && state.mapping.enabled);
$("#now").textContent = nowText() || "-";
const net = state.network || {};
const port = (net.port && net.port !== 80) ? ":" + net.port : "";
const k = CLAVE ? "?k=" + encodeURIComponent(CLAVE) : "";
$("#net-foot").textContent = "Panel: http://" +
(net.hostname || "fosfeno") + ".local" + port + "/" + k +
(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);
}
}
/* Texto unico de "que se esta viendo". Lo usan la tarjeta En pantalla y la
tarjeta de projectM, para que ambas cuenten exactamente lo mismo. Con
projectM se ensena con la misma taxonomia de las listas (Categoria /
Visual) y despues la variante concreta que rota dentro de ese look. */
function nowText() {
if (state.engine === "projectm") {
const pm = state.projectm || {};
if (!pm.nowPlaying) return "";
const sitio = (pm.nowCategory && pm.nowVisual)
? pm.nowCategory + " / " + pm.nowVisual + " · " : "";
return sitio + pm.nowPlaying + (pm.locked ? " [congelado]" : "");
}
return state.status.label || "";
}
// Opciones fijas que sobreviven al rellenar cada lista: el "-- elige ... --"
// de arriba, y lo que se ensena cuando no hay nada que listar.
const SELECT_FIJA = {
"mix-video": '<option value="">-- sin video --</option>',
"prop-video": '<option value="">-- elige un clip --</option>',
"prop-shader": '<option value="">-- elige un shader --</option>',
};
const SELECT_VACIA = {
"mix-camera": '<option value="0">Camara por defecto</option>',
"prop-camara": '<option value="0">Camara por defecto</option>',
"prop-video": '<option value="">-- no hay clips: subelos en MOTORES --</option>',
"prop-preset": '<option value="">-- presets no disponibles --</option>',
};
function fillSelectOnce(sel, items, valFn, txtFn) {
items = items || [];
if (sel.dataset.count == items.length) return;
sel.dataset.count = items.length;
let keep = SELECT_FIJA[sel.id] || "";
if (!items.length && SELECT_VACIA[sel.id]) keep = SELECT_VACIA[sel.id];
sel.innerHTML = keep;
items.forEach((it) => {
const opt = document.createElement("option");
opt.value = valFn(it);
opt.textContent = txtFn(it);
sel.appendChild(opt);
});
}
/* Lista de clips con la ficha del catalogo (data/visuales.json, que genera
scripts/catalogar-visuales.py): agrupada por pelicula y con el nombre del
personaje en vez del nombre del archivo. Los clips que casi no tienen
figura salen marcados, que es lo que evita elegir a ciegas uno que sobre el
proyector se ve negro. Sin catalogo cae en la lista de siempre. */
const MARCA_CALIDAD = { floja: " · poca figura", vacia: " · casi vacio" };
function fichaClip(archivo) {
return ((state.meta && state.meta.visuales) || {})[archivo] || null;
}
/* Nombre corto para las frases ("Jack Sparrow y Will Turner") */
function tituloClip(archivo) {
const f = fichaClip(archivo);
return (f && f.titulo) || archivo;
}
/* Nombre largo para las listas: titulo + duracion + aviso de calidad */
function etiquetaClip(archivo) {
const f = fichaClip(archivo);
if (!f) return archivo;
const dur = f.duracion ? " " + Math.round(f.duracion) + "s" : "";
return f.titulo + dur + (MARCA_CALIDAD[f.calidad] || "");
}
function fillClipSelect(sel) {
const vs = (state.meta && state.meta.videos) || [];
const cat = (state.meta && state.meta.visuales) || {};
// La firma incluye el catalogo: si se recataloga sin cambiar el numero de
// archivos, la lista tambien tiene que rehacerse.
const sig = vs.length + "|" + Object.keys(cat).length;
if (sel.dataset.sig === sig) return;
sel.dataset.sig = sig;
delete sel.dataset.count; // no dejar que fillSelectOnce lo de por hecho
sel.innerHTML = (vs.length ? SELECT_FIJA[sel.id] : SELECT_VACIA[sel.id])
|| SELECT_FIJA[sel.id] || "";
// Agrupadas por obra, en el orden en que vienen los archivos (p1, p2...);
// lo que no tiene ficha va al final, en "Sin catalogar".
const grupos = new Map();
vs.forEach((v) => {
const f = cat[v];
const obra = (f && f.obra) || "Sin catalogar";
if (!grupos.has(obra)) grupos.set(obra, []);
grupos.get(obra).push(v);
});
grupos.forEach((lista, obra) => {
const destino = grupos.size > 1
? sel.appendChild(Object.assign(document.createElement("optgroup"),
{ label: obra }))
: sel;
lista.forEach((v) => {
const opt = document.createElement("option");
opt.value = v;
opt.textContent = etiquetaClip(v);
const f = cat[v];
if (f && f.aviso) opt.title = f.aviso;
destino.appendChild(opt);
});
});
}
function renderButterchurn() {
if (state.engine !== "butterchurn") return;
const b = state.butterchurn;
fillSelectOnce($("#bc-preset"), state.meta.butterchurnPresets,
(n) => n, (n) => n);
// El preset se sigue solo con el cambio automatico: no lo movemos mientras
// el usuario tiene la lista abierta eligiendo.
if (b.preset && document.activeElement !== $("#bc-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" : "Editor Shaders";
$("#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);
}
}
}
/* Frase de una linea que dice exactamente que va a salir por el proyector
con los ajustes actuales. Es la respuesta a "y esto ahora que hace": el
mezclador tiene dos capas (fondo + clip) y tres formas de combinarlas. */
function mixerResumen(m) {
const fondo = m.fondo === "butter"
? "las visuales Butter (MilkDrop)" : "la camara";
const clip = m.video ? tituloClip(m.video) : "el clip";
if (m.source === "video") {
return m.video
? "En pantalla: solo " + clip + ", a pantalla completa. El fondo no se usa."
: "En pantalla: nada todavia, elige un clip en la lista.";
}
if (m.source === "cam") {
return "En pantalla: solo " + fondo + ", a pantalla completa. "
+ "El clip no se usa.";
}
if (m.blendMode === "recorte") {
return "En pantalla: " + clip + " recortado (su negro se vuelve "
+ "transparente) DELANTE de " + fondo + ". El deslizador de arriba "
+ "ajusta el umbral del recorte.";
}
const modo = $("#mix-blend").selectedOptions[0];
return "En pantalla: " + clip + " y " + fondo + " combinados en modo "
+ ((modo && modo.textContent) || m.blendMode).toLowerCase() + ".";
}
function renderMixer() {
if (state.engine !== "mixer") return;
const m = state.mixer;
const esButter = m.fondo === "butter";
const usaFondo = m.source === "cam" || m.source === "mix";
const usaClip = m.source === "video" || m.source === "mix";
$$("#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;
fillClipSelect($("#mix-video"));
$("#mix-video").value = m.video || "";
$("#mix-fondo").value = m.fondo || "cam";
$("#mix-blend").value = m.blendMode;
$("#mix-invert").checked = m.invert;
$("#mix-beat").checked = m.beatPulse;
// Cada capa ensena solo sus propios controles: con fondo Butter la camara
// no pinta nada (se apaga de verdad en el escenario) y al reves.
$("#mix-cam-block").hidden = esButter;
$("#mix-bg-block").hidden = !esButter;
// El modo de mezcla solo tiene sentido si se ven las dos capas a la vez
$("#mix-blend-row").hidden = m.source !== "mix";
// Lo que no se esta usando queda atenuado, no escondido
$("#mix-fondo-row").classList.toggle("dim", !usaFondo);
$("#mix-cam-block").classList.toggle("dim", !usaFondo);
$("#mix-bg-block").classList.toggle("dim", !usaFondo);
$("#mix-clip-label").classList.toggle("dim", !usaClip);
$("#mix-video").classList.toggle("dim", !usaClip);
// Preset de MilkDrop que hace de fondo (el mismo del motor Butter)
if (esButter) {
const b = state.butterchurn;
fillSelectOnce($("#mix-bg-preset"), state.meta.butterchurnPresets,
(n) => n, (n) => n);
if (b.preset && document.activeElement !== $("#mix-bg-preset")) {
$("#mix-bg-preset").value = b.preset;
}
$("#mix-bg-shuffle").checked = b.shuffle;
}
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);
}
});
// En modo recorte el deslizador de mezcla no funde: fija el umbral
$("#ml-mix").textContent = (m.source === "mix" && m.blendMode === "recorte")
? "Umbral del recorte" : "Mezcla A/B";
$("#mv-mix").parentElement.parentElement.classList.toggle("dim",
m.source !== "mix");
$("#mix-resumen").textContent = mixerResumen(m);
}
function renderProjectM() {
if (state.engine !== "projectm") return;
const pm = state.projectm || {};
const cats = state.meta.projectmPresets || {};
const catSel = $("#pm-category");
const catSig = Object.keys(cats).join("|");
if (catSel.dataset.sig !== catSig) {
catSel.dataset.sig = catSig;
catSel.innerHTML = '<option value="">Todas las categorias</option>';
Object.keys(cats).forEach((c) => {
const opt = document.createElement("option");
opt.value = c;
opt.textContent = c + " (" + cats[c].length + ")";
catSel.appendChild(opt);
});
}
catSel.value = pm.category || "";
// La lista de presets depende de la categoria elegida
const preSel = $("#pm-preset");
const presets = cats[pm.category] || [];
const preSig = (pm.category || "") + "|" + presets.length;
if (preSel.dataset.sig !== preSig) {
preSel.dataset.sig = preSig;
preSel.innerHTML = presets.length
? '<option value="">-- rotando (elige uno para fijarlo) --</option>'
: '<option value="">-- elige antes una categoria --</option>';
presets.forEach((p) => {
const opt = document.createElement("option");
opt.value = p;
opt.textContent = p.replace(/\.milk$/i, "");
preSel.appendChild(opt);
});
}
preSel.value = pm.preset || "";
// Tercer nivel: variantes (.milk) del visual elegido, para clavar una
// a dedo. La lista se pide al servidor al cambiar de visual.
const varSel = $("#pm-variante");
const varSig = (pm.category || "") + "|" + (pm.preset || "");
if (varSel.dataset.sig !== varSig) {
varSel.dataset.sig = varSig;
varSel.innerHTML = pm.category && pm.preset
? '<option value="">-- variantes: rotando (elige una a dedo) --</option>'
: '<option value="">-- elige antes un visual --</option>';
if (pm.category && pm.preset) {
fetch("/pm/variantes?categoria=" + encodeURIComponent(pm.category)
+ "&visual=" + encodeURIComponent(pm.preset))
.then((r) => r.json())
.then((d) => {
if (varSel.dataset.sig !== varSig) return; // cambio a medias
(d.variantes || []).forEach((v) => {
const opt = document.createElement("option");
opt.value = v;
opt.textContent = v;
varSel.appendChild(opt);
});
varSel.value = (state.projectm && state.projectm.variante) || "";
})
.catch(() => {});
}
} else {
varSel.value = pm.variante || "";
}
// Favoritos: una fila por visual guardado, para lanzarlo de un toque
const favList = $("#pm-fav-list");
const favs = pm.favoritos || [];
const favSig = favs.map((f) => f.categoria + "/" + f.visual + "/"
+ f.variante).join("|");
if (favList.dataset.sig !== favSig) {
favList.dataset.sig = favSig;
favList.innerHTML = "";
favs.forEach((f) => {
const row = document.createElement("div");
row.className = "row";
const play = document.createElement("button");
play.className = "cmd favplay";
play.textContent = "▶ " + f.variante;
play.title = f.categoria + " / " + f.visual;
play.addEventListener("click", () => socket.emit("pm_favorito", {
action: "play", categoria: f.categoria,
visual: f.visual, variante: f.variante,
}));
const del = document.createElement("button");
del.className = "cmd favdel";
del.textContent = "✕";
del.title = "Quitar de favoritos";
del.addEventListener("click", () => socket.emit("pm_favorito", {
action: "del", categoria: f.categoria,
visual: f.visual, variante: f.variante,
}));
row.appendChild(play);
row.appendChild(del);
favList.appendChild(row);
});
}
$("#pm-shuffle").checked = pm.shuffle !== false;
// Selector de pantalla: Auto + monitores detectados por el servidor
const monSel = $("#pm-monitor");
const mons = state.meta.monitors || [];
const monSig = mons.map((m) => m.id + m.name).join("|");
if (monSel.dataset.sig !== monSig) {
monSel.dataset.sig = monSig;
monSel.innerHTML = '<option value="auto">Auto (la del escenario)</option>';
mons.forEach((m) => {
const opt = document.createElement("option");
opt.value = m.id;
opt.textContent = "Pantalla " + m.id + " - " + m.name;
monSel.appendChild(opt);
});
}
monSel.value = String(pm.monitor || "auto");
const dur = $("#pm-duration");
if (document.activeElement !== dur) dur.value = pm.duration || 60;
$("#pm-dur-val").textContent = pm.duration || 60;
const lockBtn = $("#pm-lock");
lockBtn.classList.toggle("accent", !!pm.locked);
lockBtn.textContent = pm.locked ? "Congelado (soltar)" : "Congelar visual";
$("#pm-now").textContent = "En pantalla: " + (nowText() || "...");
}
// ==========================================================================
// Projection mapping (Fase 2): previsualizacion con malla + mascaras
// ==========================================================================
let mapSurfaces = []; // superficies (malla) — copia local para editar
let mapMasks = []; // mascaras (cuadrilateros negros)
let mapSel = null; // { type:'surface'|'mask', id }
let mapDrag = null; // { type, id, pt|null, sx, sy, orig }
let mapEmitTimer = null;
const MAP_H = 13; // radio del tirador (px)
const MAP_MAXN = 6;
const sidx = (c, r, n) => r * (n + 1) + c;
const cl01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
/* Normaliza una capa: geometria a formato malla y ajustes con sus valores por
defecto. Tiene que coincidir con migrate() de stage/mapper.js. */
function mapMigrate(s) {
const capa = {
id: s.id,
nombre: s.nombre || "",
visible: s.visible !== false,
opacidad: typeof s.opacidad === "number" ? s.opacidad : 1,
mezcla: s.mezcla === "sumar" ? "sumar" : "normal",
fuente: Object.assign({ tipo: "motor" }, s.fuente || {}),
};
if (Array.isArray(s.points) && s.n) {
capa.n = s.n; capa.points = s.points.map((p) => [p[0], p[1]]);
} else if (Array.isArray(s.corners)) {
const c = s.corners;
capa.n = 1;
capa.points = [[c[0][0], c[0][1]], [c[1][0], c[1][1]],
[c[3][0], c[3][1]], [c[2][0], c[2][1]]]; // TL,TR,BL,BR
} else {
capa.n = 1;
capa.points = [[0.3, 0.3], [0.7, 0.3], [0.3, 0.7], [0.7, 0.7]];
}
return capa;
}
// Etiqueta corta de la fuente de una capa (la que sale en la lista)
const FUENTE_NOMBRE = {
motor: "El motor de MOTORES", butter: "MilkDrop propio",
shader: "Shader GLSL", video: "Clip de la galeria",
camara: "Camara", negro: "Negro (tapa)",
};
function fuenteTexto(f) {
f = f || {};
const base = FUENTE_NOMBRE[f.tipo] || FUENTE_NOMBRE.motor;
if (f.tipo === "butter") {
if (f.biblioteca === "projectm") {
return "projectM" + (f.milk
? " · " + f.milk.split("/").pop().replace(/\.milk$/i, "")
: " · sin preset elegido");
}
return base + (f.auto ? " · cambio automatico"
: (f.preset ? " · " + f.preset : ""));
}
if (f.tipo === "video" && f.archivo) return base + " · " + etiquetaClip(f.archivo);
if (f.tipo === "shader" && f.shader) return base + " · " + f.shader;
return base;
}
function mapSample(s, u, v) {
const n = s.n;
let ci = Math.min(n - 1, Math.floor(u * n)); if (ci < 0) ci = 0;
let cj = Math.min(n - 1, Math.floor(v * n)); if (cj < 0) cj = 0;
const lu = u * n - ci, lv = v * n - cj;
const P = (c, r) => s.points[sidx(c, r, n)];
const tl = P(ci, cj), tr = P(ci + 1, cj), bl = P(ci, cj + 1), br = P(ci + 1, cj + 1);
const ax = tl[0] + (tr[0] - tl[0]) * lu, ay = tl[1] + (tr[1] - tl[1]) * lu;
const bx = bl[0] + (br[0] - bl[0]) * lu, by = bl[1] + (br[1] - bl[1]) * lu;
return [ax + (bx - ax) * lv, ay + (by - ay) * lv];
}
function mapResample(s, newN) {
const pts = [];
for (let r = 0; r <= newN; r++)
for (let c = 0; c <= newN; c++) pts.push(mapSample(s, c / newN, r / newN));
// Solo cambia la malla: los ajustes de la capa se conservan
return Object.assign(mapMigrate(s), { n: newN, points: pts });
}
function mapPointInQuad(x, y, c) {
let inside = false;
for (let i = 0, j = 3; i < 4; j = i++) {
const xi = c[i][0], yi = c[i][1], xj = c[j][0], yj = c[j][1];
if (((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / ((yj - yi) || 1e-9) + xi)) inside = !inside;
}
return inside;
}
function mapOutline(s) {
const n = s.n;
return [s.points[sidx(0, 0, n)], s.points[sidx(n, 0, n)], s.points[sidx(n, n, n)], s.points[sidx(0, n, n)]];
}
const mapSelSurf = () => (mapSel && mapSel.type === "surface") ? mapSurfaces.find((s) => s.id === mapSel.id) : null;
/* Rotulo centrado que no se sale del lienzo: si la capa esta pegada a un
borde, el texto se recorta y se empuja hacia dentro. */
function rotulo(ctx, txt, x, y, w) {
let t = txt;
while (t.length > 6 && ctx.measureText(t).width > w - 12) {
t = t.slice(0, -2);
}
if (t !== txt) t = t.slice(0, -1) + "…";
const media = ctx.measureText(t).width / 2;
ctx.fillText(t, Math.min(Math.max(x, media + 5), w - media - 5), y);
}
function drawMapPreview() {
const cv = $("#map-preview");
if (!cv) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
const w = cv.clientWidth, h = cv.clientHeight;
if (!w || !h) return;
const W = Math.round(w * dpr), H = Math.round(h * dpr);
if (cv.width !== W || cv.height !== H) { cv.width = W; cv.height = H; }
const ctx = cv.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = "#333"; ctx.lineWidth = 1; ctx.strokeRect(0.5, 0.5, w - 1, h - 1);
ctx.font = "600 13px system-ui, sans-serif";
ctx.textAlign = "center"; ctx.textBaseline = "middle";
if (!mapSurfaces.length && !mapMasks.length) {
ctx.fillStyle = "rgba(255,255,255,0.4)";
ctx.fillText("Pulsa '+ Capa'", w / 2, h / 2);
return;
}
// superficies (malla n x n)
mapSurfaces.forEach((s, i) => {
const n = s.n;
const sel = mapSel && mapSel.type === "surface" && mapSel.id === s.id;
const col = sel ? "#00e5ff" : "#b4ff00";
ctx.globalAlpha = s.visible ? 1 : 0.35;
const P = (c, r) => { const p = s.points[sidx(c, r, n)]; return [p[0] * w, p[1] * h]; };
ctx.strokeStyle = sel ? "rgba(0,229,255,0.45)" : "rgba(180,255,0,0.3)";
ctx.lineWidth = 1;
for (let r = 0; r <= n; r++) { ctx.beginPath(); for (let c = 0; c <= n; c++) { const p = P(c, r); c ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); } ctx.stroke(); }
for (let c = 0; c <= n; c++) { ctx.beginPath(); for (let r = 0; r <= n; r++) { const p = P(c, r); r ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); } ctx.stroke(); }
// Nombre en el centro de verdad (el medio de las cuatro esquinas). La
// linea de la fuente solo en la capa seleccionada: con varias capas
// solapadas, los rotulos se pisaban unos a otros y no se leia nada.
const esq = [P(0, 0), P(n, 0), P(n, n), P(0, n)];
const cc = [(esq[0][0] + esq[1][0] + esq[2][0] + esq[3][0]) / 4,
(esq[0][1] + esq[1][1] + esq[2][1] + esq[3][1]) / 4];
ctx.shadowColor = "#000"; ctx.shadowBlur = 5; // legible sobre la malla
ctx.fillStyle = col;
rotulo(ctx, s.nombre || "Capa " + (i + 1), cc[0], sel ? cc[1] - 8 : cc[1], w);
if (sel) {
ctx.font = "11px system-ui, sans-serif";
ctx.fillStyle = "rgba(0,229,255,0.85)";
rotulo(ctx, fuenteTexto(s.fuente), cc[0], cc[1] + 9, w);
ctx.font = "600 13px system-ui, sans-serif";
}
ctx.shadowBlur = 0;
for (let r = 0; r <= n; r++) for (let c = 0; c <= n; c++) {
const p = P(c, r);
ctx.beginPath(); ctx.arc(p[0], p[1], MAP_H, 0, Math.PI * 2);
ctx.fillStyle = sel ? "#00e5ff" : "#fff"; ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = "#000"; ctx.stroke();
}
});
// mascaras (rojo)
mapMasks.forEach((m, i) => {
const sel = mapSel && mapSel.type === "mask" && mapSel.id === m.id;
const pts = m.corners.map(([x, y]) => [x * w, y * h]);
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
for (let k = 1; k < 4; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.closePath();
ctx.fillStyle = "rgba(255,60,60,0.3)"; ctx.fill();
ctx.lineWidth = sel ? 3 : 2; ctx.strokeStyle = sel ? "#ff2d2d" : "#ff6b6b";
ctx.setLineDash([6, 4]); ctx.stroke(); ctx.setLineDash([]);
const cx = (pts[0][0] + pts[1][0] + pts[2][0] + pts[3][0]) / 4;
const cy = (pts[0][1] + pts[1][1] + pts[2][1] + pts[3][1]) / 4;
ctx.fillStyle = "#ff6b6b"; ctx.fillText("M" + (i + 1), cx, cy);
pts.forEach(([px, py]) => {
ctx.beginPath(); ctx.arc(px, py, MAP_H, 0, Math.PI * 2);
ctx.fillStyle = "#ff6b6b"; ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = "#000"; ctx.stroke();
});
});
}
function mapLocalPt(ev) {
const r = $("#map-preview").getBoundingClientRect();
return { x: (ev.clientX - r.left) / r.width, y: (ev.clientY - r.top) / r.height, w: r.width, h: r.height };
}
function mapPick(p) {
const rx = (MAP_H + 8) / p.w, ry = (MAP_H + 8) / p.h;
for (let i = mapSurfaces.length - 1; i >= 0; i--) {
const s = mapSurfaces[i];
for (let k = 0; k < s.points.length; k++) {
const dx = (s.points[k][0] - p.x) / rx, dy = (s.points[k][1] - p.y) / ry;
if (dx * dx + dy * dy <= 1) return { type: "surface", id: s.id, pt: k };
}
}
for (let i = mapMasks.length - 1; i >= 0; i--) {
const c = mapMasks[i].corners;
for (let k = 0; k < 4; k++) {
const dx = (c[k][0] - p.x) / rx, dy = (c[k][1] - p.y) / ry;
if (dx * dx + dy * dy <= 1) return { type: "mask", id: mapMasks[i].id, pt: k };
}
}
for (let i = mapMasks.length - 1; i >= 0; i--)
if (mapPointInQuad(p.x, p.y, mapMasks[i].corners)) return { type: "mask", id: mapMasks[i].id, pt: null };
for (let i = mapSurfaces.length - 1; i >= 0; i--)
if (mapPointInQuad(p.x, p.y, mapOutline(mapSurfaces[i]))) return { type: "surface", id: mapSurfaces[i].id, pt: null };
return null;
}
function onMapDown(ev) {
ev.preventDefault();
const p = mapLocalPt(ev);
const hit = mapPick(p);
// Tocar fuera deselecciona; tocar dentro de una capa la selecciona y
// ensena sus propiedades (asi se elige la fuente sin buscarla en la lista).
if (!hit) { mapSel = null; mapDrag = null; drawMapPreview(); renderCapas(); renderPropiedades(); return; }
mapSel = { type: hit.type, id: hit.id };
const arr = hit.type === "surface"
? mapSurfaces.find((s) => s.id === hit.id).points
: mapMasks.find((m) => m.id === hit.id).corners;
mapDrag = { type: hit.type, id: hit.id, pt: hit.pt, sx: p.x, sy: p.y, orig: arr.map((q) => q.slice()) };
drawMapPreview(); renderCapas(); renderPropiedades();
}
function onMapMove(ev) {
if (!mapDrag) return;
ev.preventDefault();
const p = mapLocalPt(ev);
const dx = p.x - mapDrag.sx, dy = p.y - mapDrag.sy;
const arr = mapDrag.type === "surface"
? (mapSurfaces.find((s) => s.id === mapDrag.id) || {}).points
: (mapMasks.find((m) => m.id === mapDrag.id) || {}).corners;
if (!arr) return;
if (mapDrag.pt == null) {
for (let k = 0; k < arr.length; k++) { arr[k][0] = cl01(mapDrag.orig[k][0] + dx); arr[k][1] = cl01(mapDrag.orig[k][1] + dy); }
} else {
arr[mapDrag.pt][0] = cl01(mapDrag.orig[mapDrag.pt][0] + dx);
arr[mapDrag.pt][1] = cl01(mapDrag.orig[mapDrag.pt][1] + dy);
}
drawMapPreview(); scheduleMapEmit();
}
function onMapUp() { if (mapDrag) { emitMapNow(); mapDrag = null; } }
function scheduleMapEmit() { if (!mapEmitTimer) mapEmitTimer = setTimeout(() => { mapEmitTimer = null; emitMapNow(); }, 60); }
function emitMapNow() {
if (mapEmitTimer) { clearTimeout(mapEmitTimer); mapEmitTimer = null; }
socket.emit("set_mapping", { surfaces: mapSurfaces, masks: mapMasks });
}
/* Cambia ajustes de una capa y lo manda. Todo el panel de propiedades pasa
por aqui: modifica solo la capa seleccionada y deja el resto igual. */
function patchCapa(id, patch) {
mapSurfaces = mapSurfaces.map((s) =>
(s.id === id ? Object.assign({}, s, patch) : s));
emitMapNow();
}
function patchFuente(id, patch) {
const s = mapSurfaces.find((x) => x.id === id);
if (!s) return;
patchCapa(id, { fuente: Object.assign({}, s.fuente, patch) });
}
function moverCapa(id, delta) {
const i = mapSurfaces.findIndex((s) => s.id === id);
const j = i + delta;
if (i < 0 || j < 0 || j >= mapSurfaces.length) return;
const arr = mapSurfaces.slice();
arr.splice(j, 0, arr.splice(i, 1)[0]);
mapSurfaces = arr;
emitMapNow();
}
// ---------------------------------------------------------------- la lista
function renderCapas() {
const list = $("#map-list");
const sig = mapSurfaces.map((s) => [s.id, s.nombre, s.visible, s.n,
fuenteTexto(s.fuente)].join(",")).join("|")
+ "##" + mapMasks.map((k) => k.id).join("|")
+ "##" + (mapSel ? mapSel.type + mapSel.id : "");
$("#map-list-vacio").hidden = !!(mapSurfaces.length || mapMasks.length);
if (list.dataset.sig === sig) return;
list.dataset.sig = sig;
list.innerHTML = "";
const fila = (opciones) => {
const row = document.createElement("div");
row.className = "capa" + (opciones.sel ? " sel" : "")
+ (opciones.oculta ? " oculta" : "")
+ (opciones.mascara ? " mascara" : "");
row.addEventListener("click", opciones.onSel);
const txt = document.createElement("div");
txt.className = "txt";
txt.innerHTML = '<span class="nom"></span><span class="src"></span>';
txt.querySelector(".nom").textContent = opciones.nombre;
txt.querySelector(".src").textContent = opciones.sub;
row.appendChild(txt);
(opciones.botones || []).forEach((b) => {
const el = document.createElement("button");
el.textContent = b.txt;
el.title = b.titulo || "";
if (b.clase) el.className = b.clase;
if (b.off) el.disabled = true;
el.addEventListener("click", (e) => { e.stopPropagation(); b.fn(); });
row.appendChild(el);
});
list.appendChild(row);
};
// Las capas se pintan de arriba abajo: la ultima de la lista queda encima
// en el proyector, asi que la ensenamos al reves (como en cualquier VJ).
mapSurfaces.slice().reverse().forEach((s, ri) => {
const i = mapSurfaces.length - 1 - ri;
fila({
nombre: s.nombre || "Capa " + (i + 1),
sub: fuenteTexto(s.fuente) + " · malla " + s.n + "x" + s.n,
sel: mapSel && mapSel.type === "surface" && mapSel.id === s.id,
oculta: !s.visible,
onSel: () => { mapSel = { type: "surface", id: s.id }; renderMapping(); },
botones: [
{ txt: s.visible ? "◉" : "○", titulo: "Ver / ocultar",
fn: () => patchCapa(s.id, { visible: !s.visible }) },
{ txt: "▲", titulo: "Subir (mas al frente)", off: i === mapSurfaces.length - 1,
fn: () => moverCapa(s.id, 1) },
{ txt: "▼", titulo: "Bajar (mas al fondo)", off: i === 0,
fn: () => moverCapa(s.id, -1) },
{ txt: "✕", titulo: "Borrar capa", clase: "del", fn: () => {
mapSurfaces = mapSurfaces.filter((x) => x.id !== s.id);
if (mapSel && mapSel.id === s.id) mapSel = null;
emitMapNow();
} },
],
});
});
mapMasks.forEach((k, i) => fila({
nombre: "Mascara " + (i + 1),
sub: "Recuadro negro que tapa",
sel: mapSel && mapSel.type === "mask" && mapSel.id === k.id,
onSel: () => { mapSel = { type: "mask", id: k.id }; renderMapping(); },
mascara: true,
botones: [{ txt: "✕", titulo: "Borrar mascara", clase: "del", fn: () => {
mapMasks = mapMasks.filter((x) => x.id !== k.id);
if (mapSel && mapSel.id === k.id) mapSel = null;
emitMapNow();
} }],
}));
}
// ---------------------------------------------------------- las propiedades
function renderPropiedades() {
const s = mapSelSurf();
const esMascara = mapSel && mapSel.type === "mask";
$("#prop-vacio").hidden = !!(s || esMascara);
$("#prop-capa").hidden = !s;
$("#prop-mascara").hidden = !esMascara;
const i = s ? mapSurfaces.findIndex((x) => x.id === s.id) : -1;
$("#prop-titulo").textContent = s
? (s.nombre || "Capa " + (i + 1))
: (esMascara ? "Mascara" : "Propiedades");
if (!s) return;
const f = s.fuente;
if (document.activeElement !== $("#prop-nombre")) {
$("#prop-nombre").value = s.nombre;
$("#prop-nombre").placeholder = "Capa " + (i + 1);
}
$("#prop-fuente").value = f.tipo;
$$(".fuente-op").forEach((el) => { el.hidden = true; });
const bloque = $("#fop-" + f.tipo);
if (bloque) bloque.hidden = false;
if (f.tipo === "butter") {
const esMilk = f.biblioteca === "projectm";
$("#prop-biblioteca").value = esMilk ? "projectm" : "butter";
$("#fop-bib-butter").hidden = esMilk;
$("#fop-bib-milk").hidden = !esMilk;
if (esMilk) renderMilkCapa(f);
fillSelectOnce($("#prop-preset"), state.meta.butterchurnPresets,
(n) => n, (n) => n);
if (f.preset && document.activeElement !== $("#prop-preset")) {
$("#prop-preset").value = f.preset;
}
$("#prop-auto").checked = !!f.auto;
$("#prop-auto-row").hidden = !f.auto;
$("#prop-seg").hidden = !f.auto;
$("#prop-seg").value = f.segundos || 20;
$("#prop-seg-val").textContent = f.segundos || 20;
// Con el cambio automatico puesto manda el reloj: elegir preset a mano
// no haria nada, asi que se apaga en vez de mentir. Con la biblioteca de
// projectM el preset va clavado, asi que el automatico tampoco aplica.
$("#prop-auto").disabled = esMilk;
$("#prop-preset").disabled = !!f.auto;
$("#prop-preset-prev").disabled = !!f.auto;
$("#prop-preset-next").disabled = !!f.auto;
const tr = typeof f.transicion === "number" ? f.transicion : 2.5;
if (document.activeElement !== $("#prop-transicion")) {
$("#prop-transicion").value = tr;
}
$("#prop-tr-val").textContent = Number(tr).toFixed(1);
} else if (f.tipo === "shader") {
fillSelectOnce($("#prop-shader"), shaderLib, (x) => x.id, (x) => x.name);
$("#prop-shader").value = f.shader || "";
} else if (f.tipo === "video") {
fillClipSelect($("#prop-video"));
$("#prop-video").value = f.archivo || "";
} else if (f.tipo === "camara") {
fillSelectOnce($("#prop-camara"), state.meta.cameraDevices,
(d) => d.id, (d) => d.name);
$("#prop-camara").value = f.camaraId || 0;
}
if (document.activeElement !== $("#prop-opacidad")) {
$("#prop-opacidad").value = s.opacidad;
}
$("#prop-op-val").textContent = Math.round(s.opacidad * 100);
$("#prop-mezcla").value = s.mezcla;
$("#map-sub-val").textContent = s.n + "x" + s.n;
}
// refleja estado, dibuja previsualizacion, lista de capas y propiedades
function renderMapping() {
const m = state.mapping || { enabled: false, edit: false, surfaces: [], masks: [] };
const en = $("#map-enabled"), ed = $("#map-edit");
if (document.activeElement !== en) en.checked = !!m.enabled;
if (document.activeElement !== ed) ed.checked = !!m.edit;
ed.disabled = !m.enabled;
// Mientras se arrastra, se escribe un nombre o queda un envio de nombre
// pendiente, no pisamos la copia local: el estado que llega todavia es el
// de antes y borraria lo que se acaba de escribir.
const escribiendo = document.activeElement === $("#prop-nombre") || !!nombreTimer;
if (!mapDrag && !escribiendo) {
mapSurfaces = (m.surfaces || []).filter((s) => s && s.id).map(mapMigrate);
// Una mascara sin esquinas (mapping.json raro) se descarta en vez de
// reventar el render del panel entero.
mapMasks = (m.masks || [])
.filter((k) => k && Array.isArray(k.corners) && k.corners.length === 4)
.map((k) => ({ id: k.id, corners: k.corners.map((p) => [p[0], p[1]]) }));
if (mapSel) {
const ok = mapSel.type === "surface"
? mapSurfaces.some((s) => s.id === mapSel.id)
: mapMasks.some((k) => k.id === mapSel.id);
if (!ok) mapSel = null;
}
}
drawMapPreview();
renderCapas();
renderPropiedades();
}
// ==========================================================================
// Eventos de la interfaz
// ==========================================================================
$("#power").addEventListener("click", () =>
socket.emit("set_power", { on: !state.power }));
// --- Mapping ---
$("#map-enabled").addEventListener("change", (e) =>
socket.emit("set_mapping", { enabled: e.target.checked }));
$("#map-edit").addEventListener("change", (e) =>
socket.emit("set_mapping", { edit: e.target.checked }));
/* Crea una capa y devuelve su id. Cae escalonada para que dos seguidas no se
solapen del todo, y queda seleccionada para elegirle fuente al momento. */
function nuevaCapa() {
const n = mapSurfaces.length;
const s = 0.18;
const cx = cl01(0.3 + (n % 3) * 0.2), cy = cl01(0.35 + ((n / 3) | 0) * 0.2);
const nueva = {
id: "s" + Date.now() + "_" + Math.floor(Math.random() * 1e4), n: 1,
nombre: "", visible: true, opacidad: 1, mezcla: "normal",
fuente: fuenteDeCapaNueva(),
points: [[cx - s, cy - s], [cx + s, cy - s], [cx - s, cy + s], [cx + s, cy + s]], // TL,TR,BL,BR
};
mapSurfaces = mapSurfaces.concat([nueva]);
mapSel = { type: "surface", id: nueva.id };
socket.emit("set_mapping",
{ enabled: true, surfaces: mapSurfaces, masks: mapMasks });
return nueva.id;
}
$("#map-add").addEventListener("click", () => {
nuevaCapa();
renderCapas(); renderDestinos(); renderPropiedades(); drawMapPreview();
});
$("#map-add-mask").addEventListener("click", () => {
const s = 0.15, cx = 0.5, cy = 0.5;
const nueva = {
id: "m" + Date.now() + "_" + Math.floor(Math.random() * 1e4),
corners: [[cx - s, cy - s], [cx + s, cy - s], [cx + s, cy + s], [cx - s, cy + s]], // TL,TR,BR,BL
};
mapMasks = mapMasks.concat([nueva]);
mapSel = { type: "mask", id: nueva.id };
socket.emit("set_mapping",
{ enabled: true, surfaces: mapSurfaces, masks: mapMasks });
renderCapas(); renderPropiedades(); drawMapPreview();
});
$("#map-reset").addEventListener("click", () => {
if (confirm("Borrar todas las capas y mascaras del mapping?")) {
mapSurfaces = []; mapMasks = []; mapSel = null;
socket.emit("set_mapping", { surfaces: [], masks: [] });
}
});
$("#map-sub-inc").addEventListener("click", () => {
const s = mapSelSurf(); if (!s || s.n >= MAP_MAXN) return;
mapSurfaces = mapSurfaces.map((x) => (x.id === s.id ? mapResample(s, s.n + 1) : x));
emitMapNow();
});
$("#map-sub-dec").addEventListener("click", () => {
const s = mapSelSurf(); if (!s || s.n <= 1) return;
mapSurfaces = mapSurfaces.map((x) => (x.id === s.id ? mapResample(s, s.n - 1) : x));
emitMapNow();
});
// --- Propiedades de la capa seleccionada ---
const selId = () => (mapSel && mapSel.type === "surface" ? mapSel.id : null);
// El nombre se guarda con calma: cada pulsacion escribe mapping.json en el
// disco, asi que no mandamos una por tecla.
let nombreTimer = null;
$("#prop-nombre").addEventListener("input", (e) => {
const id = selId(); if (!id) return;
mapSurfaces = mapSurfaces.map((s) =>
(s.id === id ? Object.assign({}, s, { nombre: e.target.value }) : s));
clearTimeout(nombreTimer);
nombreTimer = setTimeout(() => { nombreTimer = null; emitMapNow(); }, 400);
});
$("#prop-nombre").addEventListener("change", () => { renderCapas(); drawMapPreview(); });
/* Al cambiar de fuente estrenamos ajustes con algo que se vea ya: un preset al
azar, el primer shader o el primer clip de la lista. Elegir "MilkDrop" y
quedarse en negro esperando a rellenar otro desplegable es de las cosas que
mas despistan. */
function fuenteEstrenada(tipo) {
const f = { tipo: tipo };
if (tipo === "butter") {
const ps = (state.meta && state.meta.butterchurnPresets) || [];
f.preset = ps.length ? ps[Math.floor(Math.random() * ps.length)] : "";
f.biblioteca = "butter"; f.milk = "";
f.auto = false; f.segundos = 20; f.transicion = 2.5;
} else if (tipo === "shader") {
f.shader = shaderLib.length ? shaderLib[0].id : "";
} else if (tipo === "video") {
const vs = (state.meta && state.meta.videos) || [];
f.archivo = vs.length ? vs[0] : "";
} else if (tipo === "camara") {
f.camaraId = 0;
}
return f;
}
/* Fuente con la que nace una capa nueva. NO puede ser "motor": esa fuente es
literalmente el lienzo del motor activo, asi que dos capas recien creadas
ensenaban la misma imagen y parecia que las capas no fueran independientes.
Nace en MilkDrop con preset al azar — contenido distinto desde el primer
clic — y solo cae en "motor" si Butterchurn aun no ha dado su lista. */
function fuenteDeCapaNueva() {
const ps = (state.meta && state.meta.butterchurnPresets) || [];
return ps.length ? fuenteEstrenada("butter") : { tipo: "motor" };
}
$("#prop-fuente").addEventListener("change", (e) => {
const id = selId(); if (!id) return;
patchCapa(id, { fuente: fuenteEstrenada(e.target.value) });
renderCapas(); renderPropiedades(); drawMapPreview();
});
/* --- Biblioteca de projectM dentro de una capa ---
La ruta de un preset es "Categoria/Visual/Variante.milk"; el escenario la
pide al backend y la traduce a Butterchurn al vuelo (ver layers.js). Aqui
solo se elige. Las categorias y visuales ya vienen en el estado; las
variantes se piden al servidor al cambiar de visual, igual que hace el
motor projectM. */
function partesMilk(ruta) {
const p = String(ruta || "").split("/");
return { cat: p[0] || "", vis: p[1] || "",
vari: (p[2] || "").replace(/\.milk$/i, "") };
}
function rellena(sel, items, vacio) {
const sig = vacio + "|" + items.join("|");
if (sel.dataset.sig === sig) return false;
sel.dataset.sig = sig;
sel.innerHTML = "";
const o0 = document.createElement("option");
o0.value = ""; o0.textContent = vacio;
sel.appendChild(o0);
items.forEach((v) => {
const o = document.createElement("option");
o.value = v; o.textContent = v;
sel.appendChild(o);
});
return true;
}
function renderMilkCapa(f) {
const lib = (state.meta && state.meta.projectmPresets) || {};
const { cat, vis, vari } = partesMilk(f.milk);
const cats = Object.keys(lib);
rellena($("#prop-milk-cat"), cats, "-- categoria --");
$("#prop-milk-cat").value = cat;
rellena($("#prop-milk-vis"), cat ? (lib[cat] || []) : [],
cat ? "-- visual --" : "-- elige antes una categoria --");
$("#prop-milk-vis").value = vis;
const varSel = $("#prop-milk-var");
const sig = cat + "|" + vis;
if (varSel.dataset.pedido !== sig) {
varSel.dataset.pedido = sig;
rellena(varSel, [], cat && vis ? "cargando variantes..."
: "-- elige antes un visual --");
if (cat && vis) {
fetch("/pm/variantes?categoria=" + encodeURIComponent(cat)
+ "&visual=" + encodeURIComponent(vis))
.then((r) => r.json())
.then((d) => {
if (varSel.dataset.pedido !== sig) return; // cambio a medias
varSel.dataset.sig = ""; // forzar repintado
rellena(varSel, d.variantes || [], "-- variante --");
const s = mapSelSurf();
varSel.value = partesMilk(s && s.fuente && s.fuente.milk).vari;
})
.catch(() => rellena(varSel, [], "-- no se pudieron leer --"));
}
} else if (document.activeElement !== varSel) {
varSel.value = vari;
}
const est = $("#prop-milk-est");
if (est) {
est.textContent = f.milk
? "En esta capa: " + f.milk.replace(/\.milk$/i, "")
: "Elige categoria, visual y variante — o pulsa 'Uno al azar'.";
}
}
/* Pone en la capa el .milk que toque, con lo que haya elegido de cada nivel.
Sin variante no hay preset que cargar: se coge la primera del visual. */
function ponMilk(cat, vis, vari) {
const id = selId(); if (!id) return;
if (!cat || !vis) { patchFuente(id, { biblioteca: "projectm", milk: "" }); return; }
if (vari) {
patchFuente(id, { biblioteca: "projectm", milk: `${cat}/${vis}/${vari}.milk` });
return;
}
fetch("/pm/variantes?categoria=" + encodeURIComponent(cat)
+ "&visual=" + encodeURIComponent(vis))
.then((r) => r.json())
.then((d) => {
const v = (d.variantes || [])[0];
patchFuente(id, { biblioteca: "projectm",
milk: v ? `${cat}/${vis}/${v}.milk` : "" });
})
.catch(() => {});
}
$("#prop-biblioteca").addEventListener("change", (e) => {
const id = selId(); if (!id) return;
if (e.target.value === "projectm") {
// Estrenar con algo que se vea: si no habia .milk, uno al azar
const s = mapSelSurf();
if (s && s.fuente && s.fuente.milk) patchFuente(id, { biblioteca: "projectm" });
else { patchFuente(id, { biblioteca: "projectm", auto: false }); milkAzar(); }
} else {
patchFuente(id, { biblioteca: "butter" });
}
renderPropiedades();
});
$("#prop-milk-cat").addEventListener("change", (e) =>
ponMilk(e.target.value, "", ""));
$("#prop-milk-vis").addEventListener("change", (e) =>
ponMilk($("#prop-milk-cat").value, e.target.value, ""));
$("#prop-milk-var").addEventListener("change", (e) =>
ponMilk($("#prop-milk-cat").value, $("#prop-milk-vis").value, e.target.value));
function milkAzar() {
const lib = (state.meta && state.meta.projectmPresets) || {};
const cats = Object.keys(lib).filter((c) => (lib[c] || []).length);
if (!cats.length) {
showNotif({ level: "warn", message: "No hay biblioteca de projectM "
+ "instalada: data/presets-projectm esta vacia." });
return;
}
const cat = cats[Math.floor(Math.random() * cats.length)];
const viss = lib[cat];
ponMilk(cat, viss[Math.floor(Math.random() * viss.length)], "");
}
$("#prop-milk-azar").addEventListener("click", milkAzar);
$("#prop-preset").addEventListener("change", (e) => {
const id = selId(); if (id) patchFuente(id, { preset: e.target.value });
});
function pasoPresetCapa(delta) {
const id = selId(); const s = mapSelSurf();
const ps = (state && state.meta.butterchurnPresets) || [];
if (!id || !s || !ps.length) return;
const i = ps.indexOf(s.fuente.preset);
patchFuente(id, { preset: ps[((i < 0 ? 0 : i) + delta + ps.length) % ps.length] });
}
$("#prop-preset-prev").addEventListener("click", () => pasoPresetCapa(-1));
$("#prop-preset-next").addEventListener("click", () => pasoPresetCapa(1));
$("#prop-auto").addEventListener("change", (e) => {
const id = selId(); if (id) patchFuente(id, { auto: e.target.checked });
});
$("#prop-seg").addEventListener("input", (e) =>
$("#prop-seg-val").textContent = e.target.value);
$("#prop-seg").addEventListener("change", (e) => {
const id = selId();
if (id) patchFuente(id, { segundos: parseInt(e.target.value, 10) });
});
$("#prop-transicion").addEventListener("input", (e) =>
$("#prop-tr-val").textContent = Number(e.target.value).toFixed(1));
$("#prop-transicion").addEventListener("change", (e) => {
const id = selId();
if (id) patchFuente(id, { transicion: parseFloat(e.target.value) });
});
$("#prop-shader").addEventListener("change", (e) => {
const id = selId(); if (id) patchFuente(id, { shader: e.target.value });
});
$("#prop-video").addEventListener("change", (e) => {
const id = selId(); if (id) patchFuente(id, { archivo: e.target.value });
});
$("#prop-camara").addEventListener("change", (e) => {
const id = selId(); if (id) patchFuente(id, { camaraId: parseInt(e.target.value, 10) });
});
$("#prop-opacidad").addEventListener("input", (e) =>
$("#prop-op-val").textContent = Math.round(parseFloat(e.target.value) * 100));
$("#prop-opacidad").addEventListener("change", (e) => {
const id = selId();
if (id) patchCapa(id, { opacidad: parseFloat(e.target.value) });
});
$("#prop-mezcla").addEventListener("change", (e) => {
const id = selId(); if (id) patchCapa(id, { mezcla: e.target.value });
});
/* Convierte la mascara seleccionada en una capa en el mismo sitio. Es el
error mas facil de cometer: pulsar "+ Mascara" buscando donde poner un
visual. En vez de dejar el callejon sin salida, se sale de el. */
$("#mask-a-capa").addEventListener("click", () => {
if (!mapSel || mapSel.type !== "mask") return;
const m = mapMasks.find((k) => k.id === mapSel.id);
if (!m) return;
const c = m.corners; // TL,TR,BR,BL -> la capa los quiere TL,TR,BL,BR
const nueva = {
id: "s" + Date.now() + "_" + Math.floor(Math.random() * 1e4), n: 1,
nombre: "", visible: true, opacidad: 1, mezcla: "normal",
fuente: fuenteDeCapaNueva(),
points: [c[0].slice(), c[1].slice(), c[3].slice(), c[2].slice()],
};
mapMasks = mapMasks.filter((k) => k.id !== m.id);
mapSurfaces = mapSurfaces.concat([nueva]);
mapSel = { type: "surface", id: nueva.id };
emitMapNow();
renderCapas(); renderPropiedades(); drawMapPreview();
});
// Previsualizacion arrastrable (raton + tactil via pointer events)
(function () {
const mp = $("#map-preview");
if (!mp) return;
mp.addEventListener("pointerdown", (e) => { mp.setPointerCapture(e.pointerId); onMapDown(e); });
mp.addEventListener("pointermove", onMapMove);
mp.addEventListener("pointerup", onMapUp);
mp.addEventListener("pointercancel", onMapUp);
window.addEventListener("resize", () => { if (state) drawMapPreview(); });
})();
$$(".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; });
// Eleccion de fuente: micro de la sala o audio del propio equipo (monitor).
// El cambio de 'source' ya hace que el escenario reconecte la captura; el
// boton "Aplicar entrada" queda como reintento manual.
$$("#audio-source .segbtn").forEach((b) =>
b.addEventListener("click", () =>
socket.emit("update_settings",
{ engine: "audio", patch: { source: b.dataset.src } })));
$("#audio-device").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "audio", patch: { device: e.target.value } }));
// Boton "Aplicar microfono": fija la entrada elegida y obliga al escenario
// a re-conectar la captura de audio (util tras enchufar o cambiar el micro).
$("#audio-apply").addEventListener("click", () => {
socket.emit("update_settings",
{ engine: "audio", patch: { device: $("#audio-device").value } });
socket.emit("reacquire_audio");
});
$("#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-fondo").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { fondo: 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"));
// Fondo de visuales Butter: es el mismo preset del motor Butter, asi que se
// cambia por el estado (no con un comando al escenario) y queda elegido en
// los dos sitios.
$("#mix-bg-preset").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "butterchurn", patch: { preset: e.target.value } }));
$("#mix-bg-shuffle").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "butterchurn", patch: { shuffle: e.target.checked } }));
function pasoFondo(delta) {
const names = (state && state.meta.butterchurnPresets) || [];
if (!names.length) return;
const i = names.indexOf(state.butterchurn.preset);
const next = ((i < 0 ? 0 : i) + delta + names.length) % names.length;
socket.emit("update_settings",
{ engine: "butterchurn", patch: { preset: names[next] } });
}
$("#mix-bg-prev").addEventListener("click", () => pasoFondo(-1));
$("#mix-bg-next").addEventListener("click", () => pasoFondo(1));
$("#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 } }));
// --- projectM ---
$("#pm-category").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "projectm",
patch: { category: e.target.value, preset: "", variante: "" } }));
$("#pm-preset").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "projectm", patch: { preset: e.target.value, variante: "" } }));
$("#pm-variante").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "projectm", patch: { variante: e.target.value } }));
$("#pm-fav-add").addEventListener("click", () =>
socket.emit("pm_favorito", { action: "add" }));
$("#pm-shuffle").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "projectm", patch: { shuffle: e.target.checked } }));
$("#pm-duration").addEventListener("input", (e) =>
$("#pm-dur-val").textContent = e.target.value);
$("#pm-duration").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "projectm", patch: { duration: parseInt(e.target.value, 10) } }));
$("#pm-monitor").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "projectm", patch: { monitor: e.target.value } }));
$("#pm-lock").addEventListener("click", () =>
socket.emit("engine_command", { action: "lock" }));
$("#pm-fix").addEventListener("click", () =>
socket.emit("engine_command", { action: "fix_current" }));
// --- Subida a la galeria del mezclador ---
$("#mix-upload-btn").addEventListener("click", () => $("#mix-upload").click());
$("#mix-upload").addEventListener("change", () => {
const f = $("#mix-upload").files[0];
if (!f) return;
const status = $("#mix-upload-status");
const fd = new FormData();
fd.append("file", f);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/upload" + (CLAVE ? "?k=" + encodeURIComponent(CLAVE) : ""));
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
status.textContent = "subiendo " + Math.round(e.loaded / e.total * 100) + "%";
}
};
xhr.onload = () => {
let r = {};
try { r = JSON.parse(xhr.responseText); } catch (e) { /* respuesta rara */ }
status.textContent = r.ok ? "subido ✓" : "";
if (!r.ok) {
showNotif({ level: "error",
message: r.error || "La subida fallo (" + xhr.status + ")." });
}
$("#mix-upload").value = "";
setTimeout(() => { status.textContent = ""; }, 5000);
};
xhr.onerror = () => {
status.textContent = "";
$("#mix-upload").value = "";
showNotif({ level: "error", message: "La subida fallo: no hay conexion "
+ "con el servidor de FOSFENO." });
};
status.textContent = "subiendo 0%";
xhr.send(fd);
});
// --- 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 = nowText() || "-";
$("#bpm").textContent = s.bpm > 0 ? s.bpm : "--";
});
socket.on("notify", (entry) => showNotif(entry));
socket.on("disconnect", () => { $("#conn").className = "dot off"; });
// ==========================================================================
// Arranque
// ==========================================================================
buildMixerSliders();
/* Restaurar la vista va AQUI, con el fichero ya entero: setView toca
variables declaradas con let a lo largo del modulo, y llamarlo antes las
pilla sin inicializar (ReferenceError que ademas se lleva por delante el
resto del script, socket incluido, y el panel se queda en blanco). */
(function restaurarVista() {
let saved = "motores";
try { saved = localStorage.getItem("fosfeno.view") || "motores"; }
catch (e) { /* almacenamiento no disponible */ }
setView(saved);
})();
loadData();