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>
This commit is contained in:
hacklab 2026-08-02 16:47:01 +02:00
parent f017246f0e
commit 61543e1987
8 changed files with 602 additions and 146 deletions

View file

@ -22,11 +22,6 @@
<button class="tab active" data-view="motores">MOTORES</button>
<button class="tab" data-view="mapping">MAPPING<span
id="tab-map-dot" class="tabdot" hidden></span></button>
<!-- Una pestana por capa del mapping: se generan solas (renderTabsCapas)
para poder saltar a editar cada capa sin pasar por MAPPING -->
<span id="tabs-capas"></span>
<button class="tab tabnew" id="tab-nueva-capa"
title="Crear una capa nueva y abrirla">+ CAPA</button>
</nav>
<span id="conn" class="dot off" title="Conexion"></span>
</header>
@ -102,24 +97,75 @@
<div class="column">
<!-- Selector de motor -->
<!-- Pastillas: cada circulo es una combinacion guardada (motor +
preset + ajustes) con su nombre. Se disparan sobre el DESTINO que
este elegido abajo: la salida entera o una capa del mapping. -->
<section class="card" id="card-engines">
<div class="cardhead">
<span class="label">Motor de visuales</span>
<span class="label">Pastillas</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 class="engines" id="slots"></div>
<div class="row">
<button id="slots-config" class="cmd">Configurar pastillas</button>
</div>
<!-- Editor de una pastilla (solo en modo configurar) -->
<div class="stack" id="slot-editor" hidden>
<div class="row">
<span class="label">Nombre</span>
<input type="text" id="slot-nombre" class="txt" maxlength="12"
placeholder="TUNEL">
</div>
<span class="label">Que lanza</span>
<select id="slot-tipo">
<option value="butter">Visuales MilkDrop</option>
<option value="shader">Shader GLSL</option>
<option value="video">Clip o imagen de la galeria</option>
<option value="camara">Camara</option>
<option value="hydra">Hydra (solo a la salida)</option>
<option value="mixer">Mezclador VJ (solo a la salida)</option>
<option value="projectm-nativo">projectM nativo (solo a la salida)</option>
</select>
<div class="stack slot-op" id="sop-butter" hidden>
<select id="slot-biblioteca">
<option value="butter">Butterchurn &mdash; ~100 presets</option>
<option value="projectm">projectM &mdash; biblioteca .milk</option>
</select>
<select id="slot-preset"><option value="">-- al azar --</option></select>
<div class="stack" id="sop-milk" hidden>
<select id="slot-milk-cat"><option value="">-- categoria --</option></select>
<select id="slot-milk-vis"><option value="">-- visual --</option></select>
<select id="slot-milk-var"><option value="">-- variante --</option></select>
</div>
</div>
<div class="stack slot-op" id="sop-shader" hidden>
<select id="slot-shader"><option value="">-- elige un shader --</option></select>
</div>
<div class="stack slot-op" id="sop-video" hidden>
<select id="slot-video"><option value="">-- elige un clip --</option></select>
</div>
<div class="row">
<button id="slot-borrar" class="cmd del">Borrar pastilla</button>
<button id="slot-listo" class="cmd accent">Listo</button>
</div>
<p class="hint">Las pastillas se guardan en el equipo: al volver
siguen ahi. En directo, pincha una y salta al destino elegido.</p>
</div>
</section>
<!-- Destino: a donde va lo que dispares. La salida entera, o una capa
concreta del mapping. Aqui tambien se crean capas nuevas. -->
<section class="card" id="card-destino">
<div class="cardhead">
<span class="label">Donde se ve</span>
<button class="info" data-help="destino">i</button>
</div>
<div class="subtabs" id="destinos"></div>
<p class="hint" id="destino-hint"></p>
<!-- Cuando el destino es una capa, aqui se mete su tarjeta de
propiedades (la misma de MAPPING, prestada) -->
<div id="destino-props"></div>
</section>
<!-- Butterchurn -->

View file

@ -96,15 +96,20 @@ main {
}
.column { display: contents; } /* en movil, las columnas no existen */
/* Orden de las tarjetas cuando estan todas en una sola columna (movil) */
/* Orden de las tarjetas cuando estan todas en una sola columna (movil).
Toda tarjeta de <main> tiene que estar en esta lista: la que falte se
queda en order 0 y se cuela LA PRIMERA, por mucho que en el HTML este
abajo. Primero las pastillas (lo que se toca en directo) y justo despues
el destino, que es su pareja: que lanzas / a donde va. */
#card-power { order: 1; }
#card-engines { order: 2; }
#card-audio { order: 3; }
#card-sens { order: 4; }
#ctl-butterchurn, #ctl-editor, #ctl-mixer, #ctl-projectm { order: 5; }
#card-now { order: 6; }
#card-sys { order: 7; }
#net-foot { order: 8; }
#card-destino { order: 3; }
#card-audio { order: 4; }
#card-sens { order: 5; }
#ctl-butterchurn, #ctl-editor, #ctl-mixer, #ctl-projectm { order: 6; }
#card-now { order: 7; }
#card-sys { order: 8; }
#net-foot { order: 9; }
/* --- Disposicion: pantalla ancha (portatil) en dos columnas --- */
@media (min-width: 880px) {
@ -118,31 +123,20 @@ main {
}
/* --- Vistas (pestanas) ---
MOTORES ensena todo menos las tarjetas de mapping, y al reves. La vista
"capa" es MAPPING recortado: solo la previa y las propiedades de UNA capa,
sin la lista, para poder centrarse en su efecto. */
MOTORES ensena todo menos las tarjetas de mapping, y al reves. Salvo la
tarjeta de propiedades de capa, que MOTORES se lleva prestada cuando estas
editando una capa (ver colocarPropiedades en panel.js). */
body[data-view="motores"] [data-vista="mapping"] { display: none !important; }
body[data-view="mapping"] main > .column > :not([data-vista="mapping"]),
body[data-view="capa"] main > .column > :not([data-vista="mapping"]) {
body[data-view="motores"] #destino-props #card-propiedades {
display: block !important;
}
body[data-view="mapping"] main > .column > :not([data-vista="mapping"]) {
display: none !important;
}
body[data-view="capa"] #card-capas { display: none !important; }
body[data-view="mapping"] main,
body[data-view="capa"] main {
body[data-view="mapping"] main {
flex-direction: column; width: 100%; max-width: 900px;
}
body[data-view="mapping"] .column,
body[data-view="capa"] .column { display: contents; }
/* Pestanas de capa: mas pequenas que MOTORES/MAPPING, para que se lean como
lo que son (un atajo a cada capa) y no compitan con las dos principales. */
.tab.tabcapa, .tab.tabnew {
font-size: 0.78em; padding: 6px 10px; letter-spacing: 0.06em;
max-width: 11ch; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap;
}
.tab.tabnew { border-style: dashed; opacity: 0.85; }
.tab.tabnew:hover { opacity: 1; }
body[data-view="mapping"] .column { display: contents; }
/* En pantalla ancha el mapping se reparte: la previsualizacion grande a la
izquierda (que es donde se trabaja) y las capas con sus propiedades a la
@ -159,17 +153,6 @@ body[data-view="capa"] .column { display: contents; }
body[data-view="mapping"] #card-propiedades { grid-area: props; }
/* La previsualizacion manda: que ocupe todo el alto util disponible */
body[data-view="mapping"] #map-preview { aspect-ratio: 16 / 9; }
/* La vista de una capa: previa a la izquierda, sus ajustes a la derecha */
body[data-view="capa"] main {
display: grid; width: 96%; max-width: 1700px;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
grid-template-areas: "previa props";
align-items: start; align-content: start; gap: 16px;
}
body[data-view="capa"] #card-mapping { grid-area: previa; }
body[data-view="capa"] #card-propiedades { grid-area: props; }
body[data-view="capa"] #map-preview { aspect-ratio: 16 / 9; }
}
/* --- Tarjetas --- */
@ -253,6 +236,40 @@ body[data-view="capa"] .column { display: contents; }
box-shadow: 0 0 20px rgba(180, 255, 0, 0.6);
}
.engine.active small { color: var(--ink); }
/* En modo configurar, la pastilla abierta se marca distinto de la que esta
sonando: naranja = "la estas editando", verde = "es la que se ve". */
.engine.editando {
border-color: var(--orange); border-style: dashed;
box-shadow: 0 0 16px rgba(255, 157, 42, 0.5);
}
.engine.nueva { border-style: dashed; color: var(--dim); }
.engine.nueva strong { font-size: 22px; }
/* --- Fila de destinos (salida / capas) ---
Deliberadamente distintas de las pestanas de arriba: aquellas cambian de
pantalla, estas eligen a donde va lo que dispares. */
.subtabs {
display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;
margin-bottom: 4px;
}
.subtab {
padding: 8px 14px; border-radius: 999px; cursor: pointer;
background: #161d10; border: 2px solid var(--line); color: var(--dim);
font-weight: 700; font-size: 12px; letter-spacing: 0.08em;
max-width: 14ch; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap;
}
.subtab.active {
background: var(--orange); color: var(--ink); border-color: var(--orange);
}
.subtab.nueva { border-style: dashed; }
#destino-props { margin-top: 12px; }
/* Prestada de MAPPING: dentro de MOTORES no lleva su propio marco, que
quedaria como una tarjeta dentro de otra */
#destino-props #card-propiedades {
background: none; border: none; padding: 0; border-radius: 0;
}
.cmd.del { color: #ff8a7a; }
/* --- Botones de comando --- */
.cmd, .sysbtn {

View file

@ -21,85 +21,34 @@ const $$ = (s) => document.querySelectorAll(s);
// Se separan para no mezclar los controles de directo con los de montaje.
// La eleccion se recuerda en el propio navegador.
// ==========================================================================
/* Ademas de MOTORES y MAPPING hay una vista por CAPA ("capa:<id>"): ensena
solo la previsualizacion y las propiedades de esa capa, para poder trastear
con su efecto sin el resto del panel delante. Es lo que hace usable tener
varias capas: sin esto habia que ir a MAPPING, buscarla en la lista y
volver. */
/* mapSurfaces se declara mas abajo con let: al restaurar la vista guardada
esta funcion corre ANTES que esa linea, y tocarla daria ReferenceError. */
/* 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) {
const esCapa = String(view).startsWith("capa:");
if (!esCapa && view !== "mapping") view = "motores";
if (esCapa) {
const id = view.slice(5);
// Si la capa ya no existe (borrada desde otro movil), no dejamos el panel
// en una vista fantasma
if (!capasHoy().some((s) => s.id === id)) return setView("mapping");
mapSel = { type: "surface", id: id };
document.body.dataset.capa = id;
} else {
delete document.body.dataset.capa;
}
document.body.dataset.view = esCapa ? "capa" : view;
if (view !== "mapping") view = "motores";
document.body.dataset.view = view;
vistaActual = view;
$$(".tab").forEach((t) =>
t.classList.toggle("active", t.dataset.view === 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 (state && (view === "mapping" || esCapa)) {
renderPropiedades();
drawMapPreview();
}
if (view === "mapping" && state) drawMapPreview();
}
let vistaActual = "motores";
/* Rehace las pestanas de capa. Solo toca el DOM si algo cambio: si no, cada
estado que llega (y llegan muchos) tiraria la pestana que el usuario esta
pulsando. */
function renderTabsCapas() {
const cont = $("#tabs-capas");
if (!cont) return;
const capas = capasHoy();
const sig = capas.map((s, i) => s.id + ":" + (s.nombre || i + 1)).join("|");
if (cont.dataset.sig !== sig) {
cont.dataset.sig = sig;
cont.innerHTML = "";
capas.forEach((s, i) => {
const b = document.createElement("button");
b.className = "tab tabcapa";
b.dataset.view = "capa:" + s.id;
b.textContent = (s.nombre || "CAPA " + (i + 1)).toUpperCase();
b.title = "Editar esta capa";
b.addEventListener("click", () => setView("capa:" + s.id));
cont.appendChild(b);
});
}
// La capa activa pudo desaparecer: volver a MAPPING antes de quedarnos en
// una vista sin contenido
if (vistaActual.startsWith("capa:")
&& !capas.some((s) => s.id === vistaActual.slice(5))) {
setView("mapping");
return;
}
$$(".tab").forEach((t) =>
t.classList.toggle("active", t.dataset.view === vistaActual));
}
$$(".tab[data-view]").forEach((t) =>
t.addEventListener("click", () => setView(t.dataset.view)));
(function () {
let saved = "motores";
try { saved = localStorage.getItem("fosfeno.view") || "motores"; }
catch (e) { /* almacenamiento no disponible */ }
setView(saved);
})();
/* 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 = [
@ -190,6 +139,364 @@ function openHelp(key) {
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)
// ==========================================================================
@ -252,8 +559,9 @@ function render() {
power.textContent = state.power ? "ON" : "OFF";
power.classList.toggle("off", !state.power);
$$(".engine").forEach((b) =>
b.classList.toggle("active", b.dataset.engine === state.engine));
renderSlots();
renderDestinos();
if (slotEditando) renderEditorSlot();
fillSelectOnce($("#audio-device"), state.meta.audioDevices,
(d) => d.id, (d) => d.name);
@ -1090,7 +1398,6 @@ function renderMapping() {
}
drawMapPreview();
renderCapas();
renderTabsCapas();
renderPropiedades();
}
@ -1105,10 +1412,8 @@ $("#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 }));
/* Capa nueva: cae escalonada para que dos seguidas no se solapen del todo,
y queda seleccionada para poder elegir su fuente al momento. */
/* Crea una capa y devuelve su id. Cae escalonada para que dos seguidas no se
solapen del todo. */
solapen del todo, y queda seleccionada para elegirle fuente al momento. */
function nuevaCapa() {
const n = mapSurfaces.length;
const s = 0.18;
@ -1128,16 +1433,7 @@ function nuevaCapa() {
$("#map-add").addEventListener("click", () => {
nuevaCapa();
renderCapas(); renderTabsCapas(); renderPropiedades(); drawMapPreview();
});
/* "+ CAPA" de la barra de arriba: crea la capa y te deja YA dentro de su
pestana, que es lo que hace que trabajar con varias capas no sea un
suplicio de ir y volver a MAPPING. */
$("#tab-nueva-capa").addEventListener("click", () => {
const id = nuevaCapa();
renderCapas(); renderTabsCapas();
setView("capa:" + id);
renderCapas(); renderDestinos(); renderPropiedades(); drawMapPreview();
});
$("#map-add-mask").addEventListener("click", () => {
const s = 0.15, cx = 0.5, cy = 0.5;
@ -1648,4 +1944,16 @@ 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();