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. ![Visuales estilo MilkDrop](assets/milkdrop.jpg) 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
-
- - - - - +
+
+
+ + + +
+ + +
+
+ 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();