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