Mapping in-browser (Fase 1) + trabajo en curso

Projection mapping editable desde el navegador, sin herramientas externas:
- web/stage/mapper.js (nuevo): compositor WebGL que texturiza el motor activo
  sobre superficies deformables (homografia por vertice: keystone correcto).
- Editor con raton en el escenario (crear, arrastrar esquinas, mover, borrar)
  y previsualizacion arrastrable en el panel (raton/tactil) para editar desde
  el movil sin tocar la ventana de visuales.
- Backend: estado "mapping" + evento set_mapping, persistido en data/mapping.json
  (ignorado en git). Seccion "Mapping" en el panel + texto de ayuda.
- Solo motores web (butterchurn/hydra/shaders/mixer); projectM no se mapea.
- .gitignore: excluye config de runtime (mapping.json, pm-favoritos.json).

Incluye ademas trabajo en curso previo sin commitear (server.py, stage.js,
panel.js/css, ayuda.json, hydra-sketches.json, problemas.md,
start-projectm.sh), subido junto a peticion del autor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hacklab 2026-07-27 00:19:30 +02:00
parent 0b1546cecc
commit 8dd58bb293
12 changed files with 1815 additions and 85 deletions

View file

@ -40,15 +40,21 @@
<span class="label">Audio</span>
<button class="info" data-help="audio">i</button>
</div>
<div class="seg" id="audio-source">
<button id="audio-src-mic" class="segbtn" data-src="mic">Microfono (sala)</button>
<button id="audio-src-monitor" class="segbtn" data-src="monitor">Audio del navegador</button>
</div>
<select id="audio-device"><option>Detectando entradas...</option></select>
<button id="audio-apply" class="cmd accent">Aplicar microfono</button>
<button id="audio-apply" class="cmd accent">Aplicar entrada</button>
<button id="dev-rescan" class="cmd">Buscar dispositivos de nuevo</button>
<div class="row">
<span class="label">BPM detectado</span>
<span id="bpm" class="bpm">--</span>
</div>
<p class="hint">Si conectas el microfono o la camara con el equipo ya
encendido, pulsa este boton para que aparezcan.</p>
<p class="hint">Con <b>Audio del navegador</b> las visuales reaccionan a
lo que suena en el equipo (Spotify, YouTube...) aunque lo oigas por los
cascos. Si conectas un microfono con el equipo ya encendido, pulsa
Buscar dispositivos de nuevo.</p>
</section>
<!-- Sensibilidad -->
@ -61,9 +67,9 @@
<input id="sens" type="range" min="0" max="4" step="0.1" value="1">
</section>
<!-- Estado -->
<!-- Estado: visual que se esta proyectando ahora mismo -->
<section class="card" id="card-now">
<span class="label">Ahora suena</span>
<span class="label">En pantalla</span>
<div id="now" class="now">-</div>
</section>
@ -153,15 +159,29 @@
<button class="segbtn" data-src="video">Video</button>
<button class="segbtn" data-src="mix">Mezcla</button>
</div>
<div class="row">
<span class="label">Fondo (capa de abajo)</span>
<select id="mix-fondo" class="inline">
<option value="cam">Camara</option>
<option value="butter">Visuales Butter (MilkDrop)</option>
</select>
</div>
<label class="check">
<input type="checkbox" id="mix-cam"> Camara activada
</label>
<select id="mix-camera"><option value="0">Camara por defecto</option></select>
<select id="mix-video"><option value="">-- sin video --</option></select>
<button id="mix-rescan" class="cmd">Actualizar lista de videos</button>
<div class="row">
<button id="mix-rescan" class="cmd">Actualizar lista</button>
<button id="mix-upload-btn" class="cmd">Subir video o imagen</button>
<span id="mix-upload-status" class="hint"></span>
</div>
<input type="file" id="mix-upload" hidden
accept="video/*,image/jpeg,image/png,image/gif,image/webp">
<div class="row">
<span class="label">Modo de mezcla</span>
<select id="mix-blend" class="inline">
<option value="recorte">Recorte (personaje delante)</option>
<option value="blend">Fundido</option>
<option value="diff">Diferencia</option>
<option value="mult">Multiplicar</option>
@ -184,10 +204,75 @@
<span class="label">projectM</span>
<button class="info" data-help="projectm">i</button>
</div>
<select id="pm-category">
<option value="">Todas las categorias</option>
</select>
<select id="pm-preset">
<option value="">-- rotando (elige uno para fijarlo) --</option>
</select>
<select id="pm-variante">
<option value="">-- variantes: rotando (elige una a dedo) --</option>
</select>
<div class="row">
<button class="cmd" data-action="prev">&laquo; Anterior</button>
<button class="cmd" data-action="next">Siguiente &raquo;</button>
</div>
<div class="row">
<button id="pm-lock" class="cmd">Congelar visual</button>
<button id="pm-fix" class="cmd">Fijar este estilo</button>
</div>
<p class="hint" id="pm-now">En pantalla: ...</p>
<div class="cardhead">
<span class="label">Favoritos (para pinchar en directo)</span>
</div>
<div class="row">
<button id="pm-fav-add" class="cmd accent">&#9733; Guardar el de pantalla</button>
</div>
<div id="pm-fav-list"></div>
<label class="check">
<input type="checkbox" id="pm-shuffle"> Orden aleatorio
</label>
<div class="row">
<span class="label">Pantalla</span>
<select id="pm-monitor" class="inline">
<option value="auto">Auto (la del escenario)</option>
</select>
</div>
<div class="row">
<span class="label">Segundos por visual: <span id="pm-dur-val">60</span> s</span>
</div>
<input id="pm-duration" type="range" min="5" max="600" step="5" value="60">
<p class="hint">Congelar deja clavado el visual que se ve (al momento,
sin cortes). Fijar este estilo lo apunta en las listas y projectM
rota solo entre variantes de ese look. Cada cambio de listas o
deslizador relanza projectM (~1 s en negro).</p>
</section>
<!-- Mapping (projection mapping) -->
<section class="card" id="card-mapping">
<div class="cardhead">
<span class="label">Mapping</span>
<button class="info" data-help="mapping">i</button>
</div>
<label class="check">
<input type="checkbox" id="map-enabled"> Activar mapping
</label>
<label class="check">
<input type="checkbox" id="map-edit"> Modo edicion (arrastrar en el escenario)
</label>
<div class="row">
<button id="map-add" class="cmd accent">+ Anadir superficie</button>
<button id="map-reset" class="cmd">Reset</button>
</div>
<span class="label">Previsualizacion (arrastra las esquinas)</span>
<canvas id="map-preview"
style="width:100%;aspect-ratio:16/9;background:#0a0a0a;border:1px solid #333;border-radius:6px;display:block;margin:6px 0;touch-action:none;cursor:crosshair;"></canvas>
<div id="map-list"></div>
<p class="hint">1) Elige un motor que no sea projectM. 2) Pulsa
<b>Anadir superficie</b>. 3) <b>Arrastra las esquinas</b> aqui en la
previsualizacion (raton o dedo) para encajar la forma; se ve en el
proyector al instante. Cada superficie mapea la misma imagen. Se
guarda solo.</p>
</section>
</div>

View file

@ -256,3 +256,14 @@ input[type=range] { width: 100%; accent-color: var(--green); height: 30px; }
text-align: center;
}
.modal-box p { color: var(--txt); font-size: 14px; line-height: 1.5; }
/* Favoritos de projectM: lista de visuales para pinchar en directo */
#pm-fav-list {
display: flex; flex-direction: column; gap: 8px;
max-height: 220px; overflow-y: auto;
}
#pm-fav-list .favplay {
text-align: left; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
#pm-fav-list .favdel { flex: 0 0 auto; }

View file

@ -168,6 +168,9 @@ function render() {
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;
@ -181,8 +184,10 @@ function render() {
renderButterchurn();
renderEditor();
renderMixer();
renderProjectM();
renderMapping();
$("#now").textContent = state.status.label || "-";
$("#now").textContent = nowText() || "-";
const net = state.network || {};
const port = (net.port && net.port !== 80) ? ":" + net.port : "";
@ -198,6 +203,21 @@ function render() {
}
}
/* 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;
@ -264,6 +284,7 @@ function renderMixer() {
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;
@ -276,12 +297,343 @@ function renderMixer() {
});
}
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: previsualizacion arrastrable + lista
// ==========================================================================
let mapSurfaces = []; // copia local (para editar sin parpadeos)
let mapSel = null; // indice de superficie seleccionada
let mapDrag = null; // { idx, corner|null, sx, sy, orig }
let mapEmitTimer = null;
const MAP_H = 15; // radio del tirador (px)
const cloneSurfaces = (arr) =>
(arr || []).map((s) => ({ id: s.id, corners: s.corners.map((p) => [p[0], p[1]]) }));
// interpolacion bilineal dentro del cuadrilatero (pts = TL,TR,BR,BL)
function bilerp(pts, u, v) {
const tx = pts[0][0] + (pts[1][0] - pts[0][0]) * u;
const ty = pts[0][1] + (pts[1][1] - pts[0][1]) * u;
const bx = pts[3][0] + (pts[2][0] - pts[3][0]) * u;
const by = pts[3][1] + (pts[2][1] - pts[3][1]) * u;
return [tx + (bx - tx) * v, ty + (by - ty) * v];
}
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) {
ctx.fillStyle = "rgba(255,255,255,0.4)";
ctx.fillText("Pulsa '+ Anadir superficie'", w / 2, h / 2);
return;
}
mapSurfaces.forEach((s, i) => {
const pts = s.corners.map(([x, y]) => [x * w, y * h]);
const sel = i === mapSel;
// rejilla interna: hace visible la deformacion
ctx.strokeStyle = sel ? "rgba(0,229,255,0.4)" : "rgba(180,255,0,0.22)";
ctx.lineWidth = 1;
const N = 4;
for (let g = 0; g <= N; g++) {
const t = g / N;
ctx.beginPath();
for (let k = 0; k <= N; k++) { const p = bilerp(pts, k / N, t); k ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); }
ctx.stroke();
ctx.beginPath();
for (let k = 0; k <= N; k++) { const p = bilerp(pts, t, k / N); k ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); }
ctx.stroke();
}
// contorno + relleno
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 = sel ? "rgba(0,229,255,0.08)" : "rgba(180,255,0,0.05)";
ctx.fill();
ctx.lineWidth = sel ? 2.5 : 1.5;
ctx.strokeStyle = sel ? "#00e5ff" : "#b4ff00";
ctx.stroke();
// numero
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 = sel ? "#00e5ff" : "rgba(180,255,0,0.9)";
ctx.fillText(String(i + 1), cx, cy);
// tiradores
pts.forEach(([px, py]) => {
ctx.beginPath(); ctx.arc(px, py, MAP_H * 0.7, 0, Math.PI * 2);
ctx.fillStyle = sel ? "#00e5ff" : "#fff"; 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 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 onMapDown(ev) {
ev.preventDefault();
const p = mapLocalPt(ev);
const rx = (MAP_H + 8) / p.w, ry = (MAP_H + 8) / p.h;
for (let i = mapSurfaces.length - 1; i >= 0; i--) {
const c = mapSurfaces[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) {
mapSel = i; mapDrag = { idx: i, corner: k, sx: p.x, sy: p.y, orig: c.map((q) => q.slice()) };
drawMapPreview(); return;
}
}
}
for (let i = mapSurfaces.length - 1; i >= 0; i--) {
if (mapPointInQuad(p.x, p.y, mapSurfaces[i].corners)) {
mapSel = i; mapDrag = { idx: i, corner: null, sx: p.x, sy: p.y, orig: mapSurfaces[i].corners.map((q) => q.slice()) };
drawMapPreview(); return;
}
}
mapSel = null; mapDrag = null; drawMapPreview();
}
function onMapMove(ev) {
if (!mapDrag) return;
ev.preventDefault();
const p = mapLocalPt(ev);
const dx = p.x - mapDrag.sx, dy = p.y - mapDrag.sy;
const s = mapSurfaces[mapDrag.idx]; if (!s) return;
const cl = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
if (mapDrag.corner == null) {
for (let k = 0; k < 4; k++) {
s.corners[k][0] = cl(mapDrag.orig[k][0] + dx);
s.corners[k][1] = cl(mapDrag.orig[k][1] + dy);
}
} else {
s.corners[mapDrag.corner][0] = cl(mapDrag.orig[mapDrag.corner][0] + dx);
s.corners[mapDrag.corner][1] = cl(mapDrag.orig[mapDrag.corner][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 });
}
// refleja estado, dibuja la previsualizacion y la lista de superficies
function renderMapping() {
const m = state.mapping || { enabled: false, edit: false, surfaces: [] };
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 || [];
// previsualizacion (no piso la geometria mientras arrastro aqui)
if (!mapDrag) mapSurfaces = cloneSurfaces(surfaces);
if (mapSel != null && mapSel >= mapSurfaces.length) mapSel = null;
drawMapPreview();
// lista con boton de borrar (se reconstruye solo si cambia)
const list = $("#map-list");
const sig = surfaces.map((s) => s.id).join("|") + "#" + surfaces.length;
if (list.dataset.sig === sig) return;
list.dataset.sig = sig;
list.innerHTML = "";
if (!surfaces.length) return;
surfaces.forEach((s, i) => {
const row = document.createElement("div");
row.className = "row";
const lbl = document.createElement("span");
lbl.className = "label";
lbl.textContent = "Superficie " + (i + 1);
const del = document.createElement("button");
del.className = "cmd favdel";
del.textContent = "✕";
del.title = "Borrar superficie";
del.addEventListener("click", () => {
const rest = ((state.mapping && state.mapping.surfaces) || [])
.filter((x) => x.id !== s.id);
socket.emit("set_mapping", { surfaces: rest });
});
row.appendChild(lbl);
row.appendChild(del);
list.appendChild(row);
});
}
// ==========================================================================
// 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),
corners: [[cx - s, cy - s], [cx + s, cy - s], [cx + s, cy + s], [cx - s, cy + s]],
};
socket.emit("set_mapping",
{ enabled: true, edit: true, surfaces: cur.concat([nueva]) });
});
$("#map-reset").addEventListener("click", () => {
if (confirm("Borrar todas las superficies de mapping?"))
socket.emit("set_mapping", { surfaces: [] });
});
// 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 })));
@ -295,6 +647,14 @@ $("#modal").addEventListener("click", (e) => {
$("#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 } }));
@ -367,6 +727,9 @@ $("#dev-rescan").addEventListener("click", () =>
$("#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 } }));
@ -378,6 +741,71 @@ $("#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", () =>
@ -402,7 +830,7 @@ socket.on("status", (s) => {
if (!state) return;
state.status.label = s.label;
state.audio.bpm = s.bpm;
$("#now").textContent = s.label || "-";
$("#now").textContent = nowText() || "-";
$("#bpm").textContent = s.bpm > 0 ? s.bpm : "--";
});
socket.on("notify", (entry) => showNotif(entry));