install.sh --laptop ahora detecta el gestor de paquetes (apt, dnf, pacman o zypper) e instala las dependencias en Debian/Ubuntu/Mint, Fedora, Arch/Manjaro y openSUSE; en el resto avisa de los 5 paquetes a instalar a mano. En portatil no se compila projectM (opcional). Panel: titulo superior mas grande y cada titulo de tarjeta mas grande y en fuente Xirod; en pantalla ancha el panel ocupa el 94% (hasta 1600px) para aprovechar el portatil. Audio: nuevo boton 'Aplicar microfono' que reconecta la captura desde el panel (evento reacquire_audio); el microfono integrado del portatil se capta como entrada por defecto. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
415 lines
16 KiB
JavaScript
415 lines
16 KiB
JavaScript
/*
|
|
* 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 = '<option value="">-- Cargar de la libreria --</option>';
|
|
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 =
|
|
`<div class="head"><span>${cfg.label}</span>` +
|
|
`<b id="mv-${cfg.key}">-</b></div>`;
|
|
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 = '<option value="">-- sin video --</option>';
|
|
} else if (sel.id === "mix-camera" && items.length === 0) {
|
|
keep = '<option value="0">Camara por defecto</option>';
|
|
}
|
|
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();
|