/* * 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. */ const socket = io(); 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); // 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; } // ========================================================================== // Avisos (banda superior) // ========================================================================== let notifTimer = null; function showNotif(entry) { if (!entry || !entry.message) return; const n = $("#notif"); n.className = "notif " + (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); $$(".engine").forEach((b) => b.classList.toggle("active", b.dataset.engine === state.engine)); fillSelectOnce($("#audio-device"), state.meta.audioDevices, (d) => d.id, (d) => d.name); if (state.audio.device) $("#audio-device").value = state.audio.device; $("#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(); $("#now").textContent = state.status.label || "-"; const net = state.network || {}; const port = (net.port && net.port !== 80) ? ":" + net.port : ""; $("#net-foot").textContent = "Panel: http://" + (net.hostname || "fosfeno") + ".local" + port + "/" + (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); } } function fillSelectOnce(sel, items, valFn, txtFn) { items = items || []; if (sel.dataset.count == items.length) return; sel.dataset.count = items.length; let keep = ""; if (sel.id === "mix-video") { keep = ''; } else if (sel.id === "mix-camera" && items.length === 0) { keep = ''; } sel.innerHTML = keep; items.forEach((it) => { const opt = document.createElement("option"); opt.value = valFn(it); opt.textContent = txtFn(it); sel.appendChild(opt); }); } function renderButterchurn() { if (state.engine !== "butterchurn") return; const b = state.butterchurn; fillSelectOnce($("#bc-preset"), state.meta.butterchurnPresets, (n) => n, (n) => n); if (b.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); } } } function renderMixer() { if (state.engine !== "mixer") return; const m = state.mixer; $$("#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; fillSelectOnce($("#mix-video"), (state.meta.videos || []).map((v) => ({ v })), (o) => o.v, (o) => o.v); $("#mix-video").value = m.video || ""; $("#mix-blend").value = m.blendMode; $("#mix-invert").checked = m.invert; $("#mix-beat").checked = m.beatPulse; 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); } }); } // ========================================================================== // Eventos de la interfaz // ========================================================================== $("#power").addEventListener("click", () => socket.emit("set_power", { on: !state.power })); $$(".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; }); $("#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-blend").addEventListener("change", (e) => socket.emit("update_settings", { engine: "mixer", patch: { blendMode: e.target.value } })); $("#mix-rescan").addEventListener("click", () => socket.emit("rescan_videos")); $("#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 } })); // --- 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 = s.label || "-"; $("#bpm").textContent = s.bpm > 0 ? s.bpm : "--"; }); socket.on("notify", (entry) => showNotif(entry)); socket.on("disconnect", () => { $("#conn").className = "dot off"; }); // ========================================================================== // Arranque // ========================================================================== buildMixerSliders(); loadData();