- Superficies con rejilla n x n (hasta 6x6): arrastra cualquier punto para curvar, no solo las esquinas. Render perspectiva-correcto por celda (homografia cuadrado->celda). - Mascaras: cuadrilateros negros que tapan zonas / recortan derrames de luz. - Editable desde la previsualizacion del panel (raton/tactil) y el escenario: arrastrar puntos, +/- malla para subdividir, borrar por lista. - set_mapping acepta "masks"; persiste en data/mapping.json (ignorado en git). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
901 lines
37 KiB
JavaScript
901 lines
37 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;
|
|
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();
|
|
|
|
$("#now").textContent = nowText() || "-";
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/* 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 || "";
|
|
}
|
|
|
|
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-fondo").value = m.fondo || "cam";
|
|
$("#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);
|
|
}
|
|
});
|
|
}
|
|
|
|
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 = '<option value="">Todas las categorias</option>';
|
|
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
|
|
? '<option value="">-- rotando (elige uno para fijarlo) --</option>'
|
|
: '<option value="">-- elige antes una categoria --</option>';
|
|
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
|
|
? '<option value="">-- variantes: rotando (elige una a dedo) --</option>'
|
|
: '<option value="">-- elige antes un visual --</option>';
|
|
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 = '<option value="auto">Auto (la del escenario)</option>';
|
|
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);
|
|
|
|
function mapMigrate(s) {
|
|
if (Array.isArray(s.points) && s.n)
|
|
return { id: s.id, n: s.n, points: s.points.map((p) => [p[0], p[1]]) };
|
|
if (Array.isArray(s.corners)) {
|
|
const c = s.corners;
|
|
return { id: s.id, n: 1, 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
|
|
}
|
|
return { id: s.id, n: 1, points: [[0.3, 0.3], [0.7, 0.3], [0.3, 0.7], [0.7, 0.7]] };
|
|
}
|
|
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));
|
|
return { id: s.id, 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;
|
|
|
|
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 '+ Superficie'", 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";
|
|
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(); }
|
|
const cc = P((n / 2) | 0, (n / 2) | 0);
|
|
ctx.fillStyle = col; ctx.fillText(String(i + 1), cc[0], cc[1]);
|
|
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);
|
|
if (!hit) { mapSel = null; mapDrag = null; drawMapPreview(); updateMapSelBar(); 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(); updateMapSelBar();
|
|
}
|
|
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 });
|
|
}
|
|
|
|
function updateMapSelBar() {
|
|
const bar = $("#map-selbar");
|
|
const s = mapSelSurf();
|
|
if (!s) { bar.hidden = true; return; }
|
|
bar.hidden = false;
|
|
$("#map-sub-val").textContent = s.n + "x" + s.n;
|
|
}
|
|
|
|
// refleja estado, dibuja previsualizacion y listas (superficies + mascaras)
|
|
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;
|
|
const surfaces = m.surfaces || [], masks = m.masks || [];
|
|
|
|
if (!mapDrag) {
|
|
mapSurfaces = surfaces.map(mapMigrate);
|
|
mapMasks = masks.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();
|
|
updateMapSelBar();
|
|
|
|
// lista (superficies + mascaras) con borrar
|
|
const list = $("#map-list");
|
|
const sig = surfaces.map((s) => s.id + ":" + (s.n || 1)).join("|") + "##" + masks.map((k) => k.id).join("|");
|
|
if (list.dataset.sig === sig) return;
|
|
list.dataset.sig = sig;
|
|
list.innerHTML = "";
|
|
const addRow = (label, onDel) => {
|
|
const row = document.createElement("div"); row.className = "row";
|
|
const lbl = document.createElement("span"); lbl.className = "label"; lbl.textContent = label;
|
|
const del = document.createElement("button"); del.className = "cmd favdel"; del.textContent = "✕";
|
|
del.addEventListener("click", onDel);
|
|
row.appendChild(lbl); row.appendChild(del); list.appendChild(row);
|
|
};
|
|
surfaces.forEach((s, i) => addRow("Superficie " + (i + 1) + " (" + (s.n || 1) + "x" + (s.n || 1) + ")",
|
|
() => socket.emit("set_mapping", { surfaces: surfaces.filter((x) => x.id !== s.id) })));
|
|
masks.forEach((k, i) => addRow("Mascara " + (i + 1),
|
|
() => socket.emit("set_mapping", { masks: masks.filter((x) => x.id !== k.id) })));
|
|
}
|
|
|
|
// ==========================================================================
|
|
// 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 }));
|
|
$("#map-add").addEventListener("click", () => {
|
|
const cur = (state.mapping && state.mapping.surfaces) || [];
|
|
const s = 0.2, cx = 0.5, cy = 0.5;
|
|
const nueva = {
|
|
id: "s" + Date.now() + "_" + Math.floor(Math.random() * 1e4), n: 1,
|
|
points: [[cx - s, cy - s], [cx + s, cy - s], [cx - s, cy + s], [cx + s, cy + s]], // TL,TR,BL,BR
|
|
};
|
|
socket.emit("set_mapping", { enabled: true, edit: true, surfaces: cur.concat([nueva]) });
|
|
});
|
|
$("#map-add-mask").addEventListener("click", () => {
|
|
const cur = (state.mapping && state.mapping.masks) || [];
|
|
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
|
|
};
|
|
socket.emit("set_mapping", { enabled: true, edit: true, masks: cur.concat([nueva]) });
|
|
});
|
|
$("#map-reset").addEventListener("click", () => {
|
|
if (confirm("Borrar todas las superficies y mascaras de mapping?"))
|
|
socket.emit("set_mapping", { surfaces: [], masks: [] });
|
|
});
|
|
$("#map-sub-inc").addEventListener("click", () => {
|
|
const s = mapSelSurf(); if (!s || s.n >= MAP_MAXN) return;
|
|
const ns = mapResample(s, s.n + 1);
|
|
socket.emit("set_mapping", { surfaces: mapSurfaces.map((x) => x.id === s.id ? ns : x) });
|
|
});
|
|
$("#map-sub-dec").addEventListener("click", () => {
|
|
const s = mapSelSurf(); if (!s || s.n <= 1) return;
|
|
const ns = mapResample(s, s.n - 1);
|
|
socket.emit("set_mapping", { surfaces: mapSurfaces.map((x) => x.id === s.id ? ns : x) });
|
|
});
|
|
// 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"));
|
|
$("#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");
|
|
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();
|
|
loadData();
|