diff --git a/.gitignore b/.gitignore
index af31128..d800b73 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,8 @@ data/miniaturas/
# Config de runtime (por instalacion): no va al repo
data/mapping.json
+# Pastillas del panel: son las combinaciones de cada uno, no del proyecto
+data/slots.json
data/pm-favoritos.json
data/pm-fijado/
diff --git a/backend/server.py b/backend/server.py
index 1d4de4b..7621350 100644
--- a/backend/server.py
+++ b/backend/server.py
@@ -79,6 +79,19 @@ socketio = SocketIO(app, async_mode="threading", cors_allowed_origins=None)
HYDRA_SKETCHES = load_json(DATA / "hydra-sketches.json", {})
SHADERS = load_json(DATA / "shaders.json", {})
+# Pastillas: combinaciones guardadas (motor + preset + ajustes) que el panel
+# dispara de un toque sobre el destino elegido (la salida o una capa del
+# mapping). Las de fabrica son los cinco motores de siempre, para que quien
+# actualice se encuentre lo mismo que tenia.
+SLOTS_FABRICA = [
+ {"id": "sl-projectm", "nombre": "projectM", "tipo": "projectm-nativo"},
+ {"id": "sl-butter", "nombre": "Butter", "tipo": "butter",
+ "biblioteca": "butter", "preset": ""},
+ {"id": "sl-hydra", "nombre": "Hydra", "tipo": "hydra"},
+ {"id": "sl-shaders", "nombre": "Shaders", "tipo": "shader", "shader": ""},
+ {"id": "sl-mezcla", "nombre": "Mezcla", "tipo": "mixer"},
+]
+
DEFAULTS = CFG.get("defaults", {})
ENGINES = ("projectm", "butterchurn", "hydra", "shaders", "mixer")
@@ -187,6 +200,9 @@ state = {
# apagado para que el proyector salga limpio, sin marcos
# de colores ni circulos en las esquinas.
edit=False),
+ # Pastillas del panel: persisten en data/slots.json
+ "slots": (load_json(DATA / "slots.json", None)
+ if (DATA / "slots.json").is_file() else None) or list(SLOTS_FABRICA),
"meta": {
"butterchurnPresets": [],
"audioDevices": [],
@@ -743,6 +759,34 @@ def capas_saneadas(lista, clave_geo):
return limpio
+def slots_saneadas(lista):
+ """Filtra lo que no tenga forma de pastilla y limita cuantas se aceptan:
+ igual que con el mapping, cualquiera en la red podria llenar el disco."""
+ limpio = []
+ for it in lista[:24]:
+ if isinstance(it, dict) and isinstance(it.get("id"), str) \
+ and isinstance(it.get("tipo"), str):
+ it["nombre"] = str(it.get("nombre", ""))[:12]
+ limpio.append(it)
+ return limpio
+
+
+@socketio.on("set_slots")
+def on_set_slots(data):
+ """Guarda las pastillas del panel (data/slots.json) y las difunde."""
+ lista = (data or {}).get("slots")
+ if not isinstance(lista, list):
+ return
+ with lock:
+ state["slots"] = slots_saneadas(lista)
+ try:
+ with open(DATA / "slots.json", "w", encoding="utf-8") as fh:
+ json.dump(state["slots"], fh, ensure_ascii=False, indent=2)
+ except OSError as exc:
+ notify("error", f"No se pudieron guardar las pastillas: {exc}")
+ broadcast()
+
+
@socketio.on("set_mapping")
def on_set_mapping(data):
"""Projection mapping: activar/editar y guardar las superficies.
diff --git a/data/ayuda.json b/data/ayuda.json
index 04a8c2a..498d591 100644
--- a/data/ayuda.json
+++ b/data/ayuda.json
@@ -23,11 +23,13 @@
]
},
"engines": {
- "title": "Motor de visuales",
+ "title": "Pastillas",
"body": [
- "FOSFENO tiene cinco motores de visuales distintos. Solo uno funciona a la vez.",
- "Cada boton redondo cambia al motor correspondiente. Al cambiar de motor aparecen debajo sus propios controles.",
- "Pulsa el boton de informacion de cada motor para saber que hace, que necesita y como se configura."
+ "Cada circulo es una PASTILLA: una combinacion tuya guardada (motor + preset + ajustes) con el nombre que le pongas.",
+ "Al pincharla, salta al DESTINO que tengas elegido abajo: la salida entera o una capa concreta del mapping. No cambia nada mas.",
+ "Con 'Configurar pastillas' el toque deja de lanzar y pasa a editar: eliges que hace cada una, le pones nombre, anades con el '+' o la borras. Se guardan en el equipo.",
+ "De fabrica vienen los cinco motores de siempre (projectM, Butter, Hydra, Shaders, Mezcla), asi que puedes seguir usandolo como antes y montarte las tuyas cuando quieras.",
+ "Hydra, Mezcla y projectM nativo solo pueden ir a pantalla completa: son motores globales, no fuentes que una capa sepa pintar."
]
},
"projectm": {
@@ -134,6 +136,15 @@
"projectM no puede ir en una capa: es un programa nativo que corre en su propia ventana, fuera del navegador. Para el mismo tipo de visual, usa 'Visuales MilkDrop propias', que son los mismos presets.",
"Ojo con la Raspberry: cada capa de MilkDrop o de shader es un motor de verdad funcionando. Dos o tres van bien; con muchas mas la cosa se arrastra. Los clips y la camara son mucho mas baratos."
]
+ },
+ "destino": {
+ "title": "Donde se ve",
+ "body": [
+ "Aqui eliges a donde van las pastillas que dispares.",
+ "SALIDA es el proyector entero. Cada CAPA es una zona del mapping.",
+ "Con una capa elegida, debajo salen sus ajustes (fuente, preset, opacidad, mezcla) sin tener que ir a la pestana MAPPING.",
+ "El '+' crea una capa nueva y te deja ya sobre ella. Para colocarla y deformarla sobre la superficie fisica, ve a MAPPING."
+ ]
}
}
-}
+}
\ No newline at end of file
diff --git a/docs/mapping.md b/docs/mapping.md
index 5376940..9327aaf 100644
--- a/docs/mapping.md
+++ b/docs/mapping.md
@@ -1,9 +1,10 @@
# Projection mapping
-> **Pestañas de capa.** Cada capa tiene su propia pestaña arriba, junto a
-> MOTORES y MAPPING, y el botón **+ CAPA** crea una y te deja ya dentro de
-> ella. Esa vista enseña solo la previsualización y los ajustes de *esa* capa:
-> es la forma de trabajar cuando hay varias, sin ir y volver a la lista.
+> **Las capas se manejan desde MOTORES.** La fila *Dónde se ve* de la pestaña
+> MOTORES lista `SALIDA` y una entrada por capa; eligiendo una, sus ajustes
+> salen ahí mismo y las pastillas la apuntan a ella. MAPPING queda para lo
+> suyo: **colocar y deformar** las capas sobre la superficie física. Ver
+> `uso.md`.
>
> **Visuales de projectM en una capa.** La fuente "Visuales MilkDrop" tiene
> ahora un selector de **biblioteca**: los ~100 presets de Butterchurn, o los
diff --git a/docs/uso.md b/docs/uso.md
index aaf3de7..3f016a9 100644
--- a/docs/uso.md
+++ b/docs/uso.md
@@ -50,9 +50,36 @@ para que no se te escapen.
El botón grande de encendido pone las visuales en marcha o las apaga. Apagar
deja el proyector en negro sin tener que apagar la Raspberry.
+## Pastillas y destino
+
+La pestaña MOTORES se maneja con **dos decisiones separadas**, y esa
+separación es lo que la hace usable en directo:
+
+- **Dónde se ve** — la fila de arriba: `SALIDA` (el proyector entero) o una
+ `CAPA` del mapping. El `+` crea una capa nueva y te deja ya sobre ella.
+- **Pastillas** — los círculos: combinaciones tuyas guardadas (motor + preset
+ + ajustes) con el nombre que les pongas.
+
+Pinchar una pastilla la manda **al destino que tengas elegido**. Con `SALIDA`
+cambia todo el proyector; con una capa elegida cambia solo esa zona y el resto
+sigue igual. Con una capa elegida, además, sus ajustes (fuente, preset,
+opacidad, mezcla) salen ahí mismo: no hace falta ir a MAPPING.
+
+Con **Configurar pastillas** el toque deja de lanzar y pasa a editar: decides
+qué hace cada una, la renombras, añades con el `+` o la borras. Se guardan en
+el equipo (`data/slots.json`), así que al volver siguen ahí.
+
+De fábrica vienen los cinco motores de siempre, así que puedes seguir usándolo
+como antes y montarte las tuyas cuando quieras.
+
+> **Hydra, Mezcla y projectM nativo solo van a pantalla completa.** Son
+> motores globales, no fuentes que una capa sepa pintar. Si intentas mandarlos
+> a una capa, el panel te lo dice en vez de no hacer nada.
+
## Los cinco motores
-Se eligen con los botones redondos. Solo uno está activo a la vez.
+Son los que vienen de fábrica en las pastillas. Solo uno está activo a la vez
+en la salida.

diff --git a/web/panel/index.html b/web/panel/index.html
index 114bb1d..e2f7816 100644
--- a/web/panel/index.html
+++ b/web/panel/index.html
@@ -22,11 +22,6 @@
-
-
-
@@ -102,24 +97,75 @@
-
+
- Motor de visuales
+ Pastillas
-
-
-
-
-
-
+
+
+
+
+
+
+
+ Nombre
+
+
+ Que lanza
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Las pastillas se guardan en el equipo: al volver
+ siguen ahi. En directo, pincha una y salta al destino elegido.
+
+
+
+
+
+
+ Donde se ve
+
+
+
+
+
+
diff --git a/web/panel/panel.css b/web/panel/panel.css
index 9bce58c..f12382e 100644
--- a/web/panel/panel.css
+++ b/web/panel/panel.css
@@ -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 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 {
diff --git a/web/panel/panel.js b/web/panel/panel.js
index 3a31dce..50ed1a4 100644
--- a/web/panel/panel.js
+++ b/web/panel/panel.js
@@ -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:"): 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 = "+nueva";
+ 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();