Cesar Mendivil 00e98a19b3 feat: add InternalWHIP component and associated tests
- Implemented the InternalWHIP component for managing WHIP server configurations.
- Added functionality to load live WHIP state from Core and handle OBS URL generation.
- Included polling for active streams and notifying parent components of state changes.
- Created comprehensive tests for the InternalWHIP component covering various scenarios including fallback mechanisms and state changes.

test: add integration tests for WHIP source component

- Developed end-to-end tests for the InternalWHIP component to verify its behavior under different configurations.
- Ensured that the component correctly handles the loading of WHIP state, displays appropriate messages, and emits the correct onChange events.

test: add Settings WHIP configuration tests

- Implemented tests for the WHIP settings tab to validate loading and saving of WHIP configurations.
- Verified that the correct values are sent back to the Core when the user saves changes.
- Ensured that the UI reflects the current state of the WHIP configuration after Core restarts or changes.
2026-03-14 12:27:53 -07:00

1309 lines
67 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>LiveKit Compose → RTMP</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Syne:wght@400;600;800&display=swap" rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2/dist/livekit-client.umd.min.js"></script>
<style>
:root{
--bg:#080c10;--surface:#0e1419;--panel:#141b24;--border:#1e2d3d;
--accent:#00d4aa;--accent2:#ff4d6d;--accent3:#ffd60a;
--text:#cdd6e4;--muted:#4a5568;
}
*{box-sizing:border-box;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:'Syne',sans-serif;height:100vh;overflow:hidden;}
body::before{content:'';position:fixed;inset:0;
background-image:linear-gradient(rgba(0,212,170,.03) 1px,transparent 1px),
linear-gradient(90deg,rgba(0,212,170,.03) 1px,transparent 1px);
background-size:40px 40px;pointer-events:none;z-index:0;}
/* HEADER */
header{display:flex;align-items:center;justify-content:space-between;
padding:10px 20px;border-bottom:1px solid var(--border);
background:rgba(8,12,16,.96);backdrop-filter:blur(12px);
position:relative;z-index:200;height:50px;flex-shrink:0;}
.logo{display:flex;align-items:center;gap:8px;font-size:.95rem;font-weight:800;color:var(--accent);}
.hbadge{display:flex;align-items:center;gap:5px;padding:3px 10px;border-radius:20px;
background:var(--panel);border:1px solid var(--border);
font-family:'JetBrains Mono',monospace;font-size:.7rem;}
.dot{width:7px;height:7px;border-radius:50%;background:var(--muted);}
.dot.live{background:var(--accent2);box-shadow:0 0 8px var(--accent2);animation:pulse 1.5s infinite;}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
/* APP GRID */
.app{display:grid;grid-template-columns:240px 1fr 300px;height:calc(100vh - 50px);position:relative;z-index:1;}
/* LEFT PANEL */
.lpanel{background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;}
.phead{padding:10px 12px;border-bottom:1px solid var(--border);flex-shrink:0;
font-size:.58rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1.5px;}
.pbody{flex:1;overflow-y:auto;padding:10px;}
.add-row{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:12px;}
.add-btn{display:flex;flex-direction:column;align-items:center;gap:4px;padding:10px 6px;
border:1px dashed var(--border);border-radius:7px;background:none;color:var(--muted);
font-family:'Syne',sans-serif;font-size:.68rem;font-weight:600;cursor:pointer;transition:all .2s;}
.add-btn:hover{border-color:var(--accent);color:var(--accent);background:rgba(0,212,170,.04);}
.add-btn svg{width:17px;height:17px;}
.qlabel{font-size:.56rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:6px;}
.plist{display:flex;flex-direction:column;gap:5px;}
.pitem{display:flex;align-items:center;gap:7px;padding:7px 8px;
background:var(--panel);border:1px solid var(--border);border-radius:6px;position:relative;transition:border-color .2s;}
.picon{width:24px;height:24px;border-radius:5px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
.picon.cam{background:rgba(0,212,170,.15);color:var(--accent);}
.picon.screen{background:rgba(255,214,10,.15);color:var(--accent3);}
.pinfo{flex:1;min-width:0;}
.pname{font-size:.73rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.psub{font-size:.58rem;color:var(--muted);font-family:'JetBrains Mono',monospace;}
.pbtns{display:flex;gap:3px;flex-shrink:0;}
.ibtn{background:none;border:1px solid var(--border);color:var(--muted);
width:22px;height:22px;border-radius:4px;cursor:pointer;
display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0;}
.ibtn:hover{border-color:var(--accent);color:var(--accent);}
.ibtn.del:hover{border-color:var(--accent2);color:var(--accent2);}
.ibtn.pub{border-color:var(--accent);color:var(--accent);}
.ibtn svg{width:10px;height:10px;pointer-events:none;}
.ptag{position:absolute;top:2px;right:2px;font-size:.5rem;font-weight:700;padding:1px 4px;border-radius:2px;
font-family:'JetBrains Mono',monospace;pointer-events:none;}
.ptag.canvas{background:rgba(0,212,170,.1);border:1px solid rgba(0,212,170,.3);color:var(--accent);}
.ptag.live{background:rgba(255,77,109,.1);border:1px solid rgba(255,77,109,.3);color:var(--accent2);bottom:2px;top:auto;}
/* CENTER */
.center{display:flex;flex-direction:column;background:#000;overflow:hidden;}
.ctbar{display:flex;align-items:center;gap:6px;padding:6px 12px;
background:var(--surface);border-bottom:1px solid var(--border);flex-shrink:0;}
.tlabel{font-size:.56rem;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:1px;margin-right:auto;}
.tchip{padding:2px 8px;border-radius:3px;background:var(--panel);border:1px solid var(--border);
font-family:'JetBrains Mono',monospace;font-size:.6rem;color:var(--text);}
.tchip.live{color:var(--accent2);border-color:rgba(255,77,109,.3);}
.stagewrap{flex:1;position:relative;overflow:hidden;background:#060a0d;}
#stage{position:absolute;background:#0c0c0c;overflow:hidden;
box-shadow:0 0 0 1px var(--border),0 0 60px rgba(0,0,0,.8);}
#stage::before{content:'';position:absolute;inset:0;
background-image:linear-gradient(rgba(255,255,255,.02) 1px,transparent 1px),
linear-gradient(90deg,rgba(255,255,255,.02) 1px,transparent 1px);
background-size:20px 20px;pointer-events:none;z-index:0;}
/* OVERLAY */
.overlay{position:absolute;border:2px solid transparent;border-radius:3px;
overflow:visible;cursor:move;user-select:none;transition:border-color .12s;z-index:10;}
.ov-inner{position:absolute;inset:0;overflow:hidden;border-radius:2px;}
.ov-inner video{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;}
.overlay:hover{border-color:rgba(0,212,170,.4);}
.overlay.sel{border-color:var(--accent)!important;z-index:100;}
/* resize handles */
.rh{position:absolute;width:8px;height:8px;background:var(--accent);border:2px solid #0c0c0c;
border-radius:2px;z-index:30;opacity:0;transition:opacity .12s;}
.overlay.sel .rh{opacity:1;}
.rh.nw{top:-4px;left:-4px;cursor:nw-resize;} .rh.ne{top:-4px;right:-4px;cursor:ne-resize;}
.rh.sw{bottom:-4px;left:-4px;cursor:sw-resize;} .rh.se{bottom:-4px;right:-4px;cursor:se-resize;}
.rh.n{top:-4px;left:50%;transform:translateX(-50%);cursor:n-resize;}
.rh.s{bottom:-4px;left:50%;transform:translateX(-50%);cursor:s-resize;}
.rh.e{right:-4px;top:50%;transform:translateY(-50%);cursor:e-resize;}
.rh.w{left:-4px;top:50%;transform:translateY(-50%);cursor:w-resize;}
/* overlay toolbar */
.ov-tb{position:absolute;top:-28px;left:-2px;display:none;align-items:center;gap:2px;
background:var(--panel);border:1px solid var(--border);border-radius:4px;
padding:2px 5px;z-index:50;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,.5);}
.overlay.sel .ov-tb{display:flex;}
.ovbtn{background:none;border:none;color:var(--muted);cursor:pointer;
font-size:.58rem;font-family:'JetBrains Mono',monospace;padding:2px 4px;border-radius:2px;transition:all .12s;}
.ovbtn:hover{color:var(--accent);background:rgba(0,212,170,.1);}
.ovbtn.r:hover{color:var(--accent2);background:rgba(255,77,109,.1);}
.ovname{font-size:.58rem;color:var(--text);font-family:'JetBrains Mono',monospace;
padding:0 5px 0 2px;border-right:1px solid var(--border);margin-right:2px;
max-width:90px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
/* CANVAS HINT */
.chint{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;z-index:1;}
.chint-inner{text-align:center;color:var(--muted);}
.chint-inner svg{opacity:.12;margin-bottom:10px;}
.chint-inner p{font-size:.75rem;line-height:1.7;}
/* CONTROLS BAR */
.cbar{display:flex;align-items:center;gap:5px;padding:7px 10px;
background:var(--surface);border-top:1px solid var(--border);flex-shrink:0;flex-wrap:wrap;}
.cbtn{display:flex;align-items:center;gap:4px;padding:5px 9px;border-radius:5px;
border:1px solid var(--border);background:var(--panel);color:var(--text);
font-family:'Syne',sans-serif;font-size:.7rem;font-weight:600;cursor:pointer;transition:all .2s;}
.cbtn:hover{border-color:var(--accent);color:var(--accent);}
.cbtn svg{width:12px;height:12px;}
.csep{width:1px;height:18px;background:var(--border);margin:0 2px;}
.cbtn.pub-btn{border-color:var(--accent);color:var(--accent);font-weight:800;}
.cbtn.pub-btn.active{border-color:var(--accent2);color:var(--accent2);}
/* RIGHT PANEL */
.rpanel{background:var(--surface);border-left:1px solid var(--border);display:flex;flex-direction:column;overflow-y:auto;}
.sec{padding:12px 14px;border-bottom:1px solid var(--border);}
.stitle{font-size:.56rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1.5px;
margin-bottom:10px;display:flex;align-items:center;gap:6px;}
.stitle::after{content:'';flex:1;height:1px;background:var(--border);}
.lgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;}
.lopt{border:1px solid var(--border);border-radius:5px;padding:5px;cursor:pointer;transition:all .2s;
display:flex;flex-direction:column;align-items:center;gap:3px;background:var(--panel);}
.lopt:hover,.lopt.active{border-color:var(--accent);}
.lopt.active{background:rgba(0,212,170,.07);}
.lthumb{width:100%;height:28px;border-radius:2px;background:var(--border);position:relative;overflow:hidden;}
.ln{font-size:.54rem;color:var(--muted);font-weight:600;}
.prow{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-bottom:5px;}
.pg{display:flex;flex-direction:column;gap:2px;margin-bottom:5px;}
.pl{font-size:.58rem;color:var(--muted);font-weight:600;}
.pi{padding:5px 7px;background:var(--panel);border:1px solid var(--border);border-radius:4px;
color:var(--text);font-family:'JetBrains Mono',monospace;font-size:.68rem;outline:none;width:100%;transition:border-color .2s;}
.pi:focus{border-color:var(--accent);}
.erow{display:flex;justify-content:space-between;align-items:center;margin-bottom:3px;
font-size:.63rem;font-family:'JetBrains Mono',monospace;}
.ek{color:var(--muted);}.ev{color:var(--accent);}.ev.w{color:var(--accent3);}.ev.e{color:var(--accent2);}
.startbtn{width:100%;padding:10px;border-radius:6px;border:none;
background:linear-gradient(135deg,var(--accent),#00a884);
color:#000;font-family:'Syne',sans-serif;font-size:.8rem;font-weight:800;
cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;gap:6px;margin-top:5px;}
.startbtn:hover{transform:translateY(-1px);box-shadow:0 6px 20px rgba(0,212,170,.25);}
.startbtn:disabled{opacity:.5;cursor:not-allowed;transform:none;}
.startbtn.streaming{background:linear-gradient(135deg,var(--accent2),#c0392b);}
.code-block{background:#0a0f14;border:1px solid var(--border);border-radius:5px;
padding:9px;font-family:'JetBrains Mono',monospace;font-size:.6rem;line-height:1.7;
overflow-x:auto;color:#a0c4b0;position:relative;max-height:160px;overflow-y:auto;}
.kw{color:#ff79c6;}.str{color:#f1fa8c;}.fn{color:#50fa7b;}.cm{color:var(--muted);font-style:italic;}.prop{color:#8be9fd;}
.copybtn{position:absolute;top:5px;right:5px;background:var(--panel);border:1px solid var(--border);
color:var(--muted);font-size:.57rem;padding:2px 5px;border-radius:3px;cursor:pointer;
font-family:'JetBrains Mono',monospace;transition:all .2s;}
.copybtn:hover{color:var(--accent);border-color:var(--accent);}
.tabs{display:flex;border:1px solid var(--border);border-radius:4px;overflow:hidden;margin-bottom:7px;}
.tab{flex:1;padding:5px;text-align:center;font-size:.6rem;font-weight:600;cursor:pointer;
background:var(--panel);color:var(--muted);border:none;transition:all .2s;}
.tab.active{background:rgba(0,212,170,.1);color:var(--accent);}
::-webkit-scrollbar{width:3px;}
::-webkit-scrollbar-track{background:transparent;}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px;}
.toast{position:fixed;bottom:16px;right:16px;z-index:9999;
background:var(--panel);border:1px solid var(--accent);border-radius:5px;
padding:7px 12px;font-size:.7rem;color:var(--accent);
font-family:'JetBrains Mono',monospace;
opacity:0;transform:translateY(6px);transition:all .22s;pointer-events:none;}
.toast.show{opacity:1;transform:translateY(0);}
.info-box{background:rgba(0,212,170,.04);border:1px solid rgba(0,212,170,.15);border-radius:4px;
padding:7px 9px;font-size:.6rem;color:var(--muted);line-height:1.6;margin-top:6px;}
.info-box strong{color:var(--accent);}
</style>
</head>
<body>
<header>
<div class="logo">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none"><rect width="22" height="22" rx="5" fill="rgba(0,212,170,.12)"/><path d="M3 11c0-4.4 3.6-8 8-8s8 3.6 8 8" stroke="#00d4aa" stroke-width="1.8" stroke-linecap="round"/><circle cx="11" cy="11" r="2.5" fill="#00d4aa"/></svg>
LiveKit Compose → RTMP
</div>
<div style="display:flex;gap:6px;align-items:center">
<div class="hbadge"><div class="dot" id="statusDot"></div><span id="statusText">IDLE</span></div>
<div class="hbadge" id="timerBadge" style="display:none"><span id="timer">00:00:00</span></div>
</div>
</header>
<div class="app">
<!-- ═══ LEFT: Sources Queue ═══ -->
<div class="lpanel">
<div class="phead">Fuentes — Cola pendiente</div>
<div class="pbody">
<div class="add-row">
<button class="add-btn" onclick="addCamera()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>
+ Cámara
</button>
<button class="add-btn" onclick="addScreen()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
+ Pantalla
</button>
</div>
<div class="qlabel">En espera</div>
<div class="plist" id="pendingList">
<div id="emptyQueue" style="text-align:center;color:var(--muted);font-size:.68rem;padding:14px 0;line-height:1.6">
Sin fuentes.<br/>Añade cámara o pantalla.
</div>
</div>
</div>
</div>
<!-- ═══ CENTER: Canvas ═══ -->
<div class="center">
<div class="ctbar">
<span class="tlabel">Compose Canvas</span>
<span class="tchip" id="resChip">1920×1080</span>
<span class="tchip" id="fpsChip">30 fps</span>
<span class="tchip live" id="liveChip" style="display:none">● LIVE</span>
</div>
<div class="stagewrap" id="stagewrap">
<canvas id="compositeCanvas" style="display:none"></canvas>
<div id="stage">
<div class="chint" id="chint">
<div class="chint-inner">
<svg width="50" height="50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width=".7"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
<p>Añade fuentes desde la cola<br/>y pulsa <strong style="color:var(--accent)">▶ Publicar</strong></p>
</div>
</div>
</div>
</div>
<div class="cbar">
<button id="pubBtn" class="cbtn pub-btn" onclick="toggleCompositePublish()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1.42 9a16 16 0 0121.16 0"/><path d="M5 12.55a11 11 0 0114.08 0"/><path d="M8.53 16.11a6 6 0 016.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg>
▶ Publicar Canvas → Room
</button>
<div class="csep"></div>
<button class="cbtn" onclick="deselect()">Deselect</button>
<button class="cbtn" onclick="bringFront()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 11 12 6 7 11"/><polyline points="17 18 12 13 7 18"/></svg>
Frente
</button>
<button class="cbtn" onclick="sendBack()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 6 12 11 7 6"/><polyline points="17 13 12 18 7 13"/></svg>
Atrás
</button>
<div class="csep"></div>
<button class="cbtn" onclick="removeSelected()" style="color:var(--accent2);border-color:rgba(255,77,109,.3)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6"/></svg>
Quitar
</button>
<button class="cbtn" onclick="clearCanvas()" style="margin-left:auto;color:var(--muted)">
Limpiar
</button>
</div>
</div>
<!-- ═══ RIGHT: Config ═══ -->
<div class="rpanel">
<!-- Selected layer props -->
<div class="sec">
<div class="stitle">Capa seleccionada</div>
<div id="noSel" style="color:var(--muted);font-size:.7rem;text-align:center;padding:4px 0">Selecciona un elemento</div>
<div id="selProps" style="display:none">
<div style="background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:7px;margin-bottom:7px">
<div class="erow"><span class="ek">nombre</span><span class="ev" id="liName"></span></div>
<div class="erow"><span class="ek">pos</span><span class="ev" id="liPos"></span></div>
<div class="erow"><span class="ek">tamaño</span><span class="ev" id="liSize"></span></div>
<div class="erow"><span class="ek">z</span><span class="ev" id="liZ"></span></div>
</div>
<div class="prow">
<div class="pg"><div class="pl">X</div><input class="pi" id="px" type="number" oninput="applyProp()"/></div>
<div class="pg"><div class="pl">Y</div><input class="pi" id="py" type="number" oninput="applyProp()"/></div>
</div>
<div class="prow">
<div class="pg"><div class="pl">W</div><input class="pi" id="pw" type="number" oninput="applyProp()"/></div>
<div class="pg"><div class="pl">H</div><input class="pi" id="ph" type="number" oninput="applyProp()"/></div>
</div>
<div class="pg">
<div class="pl">Opacidad: <span id="opv">100</span>%</div>
<input type="range" min="10" max="100" value="100" id="opRange"
oninput="applyOpacity(this.value)" style="width:100%;accent-color:var(--accent)"/>
</div>
</div>
</div>
<!-- Layouts -->
<div class="sec">
<div class="stitle">Layouts</div>
<div class="lgrid">
<div class="lopt" onclick="applyLayout('full',this)">
<div class="lthumb" style="padding:3px"><div style="width:100%;height:100%;background:rgba(0,212,170,.35);border-radius:1px"></div></div>
<div class="ln">Full</div>
</div>
<div class="lopt" onclick="applyLayout('side',this)">
<div class="lthumb" style="display:flex;gap:2px;padding:3px">
<div style="flex:1;background:rgba(0,212,170,.3);border-radius:1px"></div>
<div style="flex:1;background:rgba(0,212,170,.15);border-radius:1px"></div>
</div>
<div class="ln">Side</div>
</div>
<div class="lopt" onclick="applyLayout('pip-br',this)">
<div class="lthumb" style="position:relative;background:rgba(0,212,170,.15);margin:3px">
<div style="position:absolute;bottom:2px;right:2px;width:36%;height:52%;background:rgba(0,212,170,.6);border-radius:1px;border:1px solid #00d4aa"></div>
</div>
<div class="ln">PiP ↘</div>
</div>
<div class="lopt" onclick="applyLayout('pip-bl',this)">
<div class="lthumb" style="position:relative;background:rgba(0,212,170,.15);margin:3px">
<div style="position:absolute;bottom:2px;left:2px;width:36%;height:52%;background:rgba(0,212,170,.6);border-radius:1px;border:1px solid #00d4aa"></div>
</div>
<div class="ln">PiP ↙</div>
</div>
<div class="lopt" onclick="applyLayout('tri',this)">
<div class="lthumb" style="display:flex;gap:2px;padding:3px">
<div style="flex:2;background:rgba(0,212,170,.25);border-radius:1px"></div>
<div style="flex:1;display:flex;flex-direction:column;gap:2px">
<div style="flex:1;background:rgba(0,212,170,.15);border-radius:1px"></div>
<div style="flex:1;background:rgba(255,214,10,.15);border-radius:1px"></div>
</div>
</div>
<div class="ln">1+2</div>
</div>
<div class="lopt" onclick="applyLayout('quad',this)">
<div class="lthumb" style="display:grid;grid-template-columns:1fr 1fr;gap:2px;padding:3px">
<div style="background:rgba(0,212,170,.25);border-radius:1px"></div>
<div style="background:rgba(0,212,170,.15);border-radius:1px"></div>
<div style="background:rgba(255,214,10,.12);border-radius:1px"></div>
<div style="background:rgba(255,77,109,.12);border-radius:1px"></div>
</div>
<div class="ln">2×2</div>
</div>
</div>
</div>
<!-- Room status -->
<div class="sec">
<div class="stitle">LiveKit Room</div>
<div style="background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:7px">
<div class="erow"><span class="ek">estado</span>
<span style="display:flex;align-items:center;gap:4px">
<span id="rdot" style="width:6px;height:6px;border-radius:50%;background:var(--muted)"></span>
<span class="ev" id="rstatus" style="color:var(--muted)">DESCONECTADO</span>
</span>
</div>
<div class="erow"><span class="ek">tracks</span><span class="ev" id="rcount">0</span></div>
</div>
<div class="info-box">
<strong>Flujo correcto:</strong><br>
1. Añade fuentes → canvas<br>
2. <strong>▶ Publicar Canvas → Room</strong><br>
3. Iniciar Streaming
</div>
</div>
<!-- LiveKit config -->
<div class="sec">
<div class="stitle">LiveKit Server</div>
<div class="pg"><div class="pl">URL (https://)</div>
<input class="pi" id="lvkUrl" value="https://your-livekit.livekit.cloud" oninput="fixUrl(this);updateCode()"/>
<div id="urlHint" style="display:none;font-size:.55rem;color:var(--accent3);margin-top:2px">wss → https auto-corregido</div>
</div>
<div class="prow">
<div class="pg"><div class="pl">API Key</div><input class="pi" id="lvkKey" value="" oninput="updateCode()"/></div>
<div class="pg"><div class="pl">Room</div><input class="pi" id="lvkRoom" value="broadcast-1" oninput="updateCode()"/></div>
</div>
<div class="pg" style="position:relative">
<div class="pl" style="display:flex;justify-content:space-between">
API Secret
<span id="secWarn" style="color:var(--accent2);font-size:.53rem;display:none">⚠ requerido</span>
</div>
<input class="pi" id="lvkSecret" type="password" placeholder="tu_api_secret" oninput="checkSecret();updateCode()" style="padding-right:30px"/>
<button onclick="toggleSecret()" style="position:absolute;right:5px;bottom:2px;background:none;border:none;color:var(--muted);cursor:pointer;padding:3px">
<svg id="eyeIco" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>
<!-- RTMP -->
<div class="sec">
<div class="stitle">Destino RTMP</div>
<div class="pg"><div class="pl">URL</div><input class="pi" id="rtmpUrl" value="rtmp://restreamer.nextream.sytes.net/" oninput="updateCode()"/></div>
<div class="pg"><div class="pl">Stream Key</div><input class="pi" id="rtmpKey" value="" oninput="updateCode()"/></div>
</div>
<!-- Encode -->
<div class="sec">
<div class="stitle">Encode</div>
<div class="prow">
<div class="pg"><div class="pl">W</div><input class="pi" id="cfgW" type="number" value="1920" oninput="updateCode();updateResChip()"/></div>
<div class="pg"><div class="pl">H</div><input class="pi" id="cfgH" type="number" value="1080" oninput="updateCode();updateResChip()"/></div>
</div>
<div class="prow">
<div class="pg"><div class="pl">FPS</div>
<select class="pi" id="cfgFps" onchange="updateCode();document.getElementById('fpsChip').textContent=this.value+' fps'">
<option>24</option><option selected>30</option><option>60</option>
</select>
</div>
<div class="pg"><div class="pl">Bitrate kbps</div><input class="pi" id="cfgBitrate" type="number" value="4500" oninput="updateCode()"/></div>
</div>
</div>
<!-- Code -->
<div class="sec">
<div class="stitle">API Payload</div>
<div class="tabs">
<button class="tab active" onclick="switchTab('curl',this)">cURL</button>
<button class="tab" onclick="switchTab('js',this)">JS</button>
<button class="tab" onclick="switchTab('go',this)">Go</button>
</div>
<div class="code-block"><button class="copybtn" onclick="copyCode()">copy</button><div id="codeContent"></div></div>
</div>
<!-- Egress status -->
<div class="sec">
<div class="stitle">Egress</div>
<div style="background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px">
<div class="erow"><span class="ek">egress_id</span><span class="ev" id="eId"></span></div>
<div class="erow"><span class="ek">status</span><span class="ev w" id="eState">IDLE</span></div>
<div class="erow"><span class="ek">started_at</span><span class="ev" id="eStart"></span></div>
<div class="erow"><span class="ek">layers</span><span class="ev" id="eLayers">0</span></div>
<div class="erow"><span class="ek">error</span><span class="ev e" id="eErr"></span></div>
</div>
<button class="startbtn" id="startBtn" onclick="toggleStream()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3.5" fill="#000"/></svg>
Iniciar Streaming
</button>
</div>
<!-- API Log -->
<div class="sec">
<div class="stitle">API Log</div>
<div id="apiLog" style="background:#0a0f14;border:1px solid var(--border);border-radius:4px;
padding:9px;font-family:'JetBrains Mono',monospace;font-size:.58rem;line-height:1.7;
max-height:150px;overflow-y:auto;color:var(--muted);white-space:pre-wrap;word-break:break-all">
<span style="font-style:italic">Eventos API aparecerán aquí…</span>
</div>
</div>
</div><!-- /rpanel -->
</div>
<div class="toast" id="toast"></div>
<script>
'use strict';
// ══════════════════════════════════════════════════
// STATE
// ══════════════════════════════════════════════════
const pending = []; // { id, type, stream, label }
const layers = []; // { id, type, stream, label, x, y, w, h, z, el }
let selId = null;
let streaming = false;
let timerInt = null;
let startTime = null;
let codeTab = 'curl';
let zCtr = 10;
const CW = 1920, CH = 1080;
// LiveKit
let room = null; // LivekitClient.Room instance
let compositeRAF = null;
let compositeStream = null;
let compositeAudioCtx = null;
let compositePub = null; // { vidPub, audPub }
let activeEgressId = null;
// ══════════════════════════════════════════════════
// STAGE SCALING
// ══════════════════════════════════════════════════
function getScale() {
const w = document.getElementById('stagewrap');
return Math.min((w.clientWidth - 32) / CW, (w.clientHeight - 32) / CH);
}
function resizeStage() {
const stage = document.getElementById('stage');
const wrap = document.getElementById('stagewrap');
const sc = getScale();
const sw = CW * sc, sh = CH * sc;
stage.style.width = sw + 'px';
stage.style.height = sh + 'px';
stage.style.left = ((wrap.clientWidth - sw) / 2) + 'px';
stage.style.top = ((wrap.clientHeight - sh) / 2) + 'px';
layers.forEach(positionEl);
}
window.addEventListener('resize', resizeStage);
function positionEl(l) {
const sc = getScale();
l.el.style.left = (l.x * sc) + 'px';
l.el.style.top = (l.y * sc) + 'px';
l.el.style.width = (l.w * sc) + 'px';
l.el.style.height = (l.h * sc) + 'px';
}
// ══════════════════════════════════════════════════
// CAPTURE
// ══════════════════════════════════════════════════
async function addCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
const devs = await navigator.mediaDevices.enumerateDevices();
const cam = devs.find(d => d.kind === 'videoinput');
const id = 'cam_' + Date.now();
const label = (cam?.label || '').split('(')[0].trim() || 'Cámara ' + (pending.filter(p => p.type === 'cam').length + 1);
pending.push({ id, type: 'cam', stream, label });
renderPending();
toast('✓ Cámara lista en cola');
} catch(e) { toast('⚠ ' + e.message, true); }
}
async function addScreen() {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
const id = 'screen_' + Date.now();
const label = 'Pantalla ' + (pending.filter(p => p.type === 'screen').length + 1);
pending.push({ id, type: 'screen', stream, label });
stream.getVideoTracks()[0].onended = () => {
const i = pending.findIndex(p => p.id === id);
if (i >= 0) { pending.splice(i, 1); renderPending(); }
if (layers.some(l => l.id === id)) removeLayer(id);
};
renderPending();
toast('✓ Pantalla lista en cola');
} catch(e) { if (e.name !== 'NotAllowedError') toast('⚠ ' + e.message, true); }
}
// ══════════════════════════════════════════════════
// PENDING LIST
// ══════════════════════════════════════════════════
function renderPending() {
const list = document.getElementById('pendingList');
document.getElementById('emptyQueue').style.display = pending.length ? 'none' : 'block';
list.querySelectorAll('.pitem').forEach(e => e.remove());
const camSvg = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>';
const scrSvg = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>';
const addSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
const selSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/></svg>';
const delSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
pending.forEach(src => {
const onCanvas = layers.some(l => l.id === src.id);
const div = document.createElement('div');
div.className = 'pitem';
div.innerHTML = `
<div class="picon ${src.type}">${src.type === 'cam' ? camSvg : scrSvg}</div>
<div class="pinfo">
<div class="pname">${src.label}</div>
<div class="psub">${onCanvas ? '● canvas' : '○ pendiente'}</div>
</div>
<div class="pbtns">
${!onCanvas
? `<button class="ibtn" title="Añadir al canvas" onclick="addToCanvas('${src.id}')">${addSvg}</button>`
: `<button class="ibtn" title="Seleccionar en canvas" onclick="selectById('${src.id}')">${selSvg}</button>`}
<button class="ibtn del" title="Eliminar" onclick="deletePending('${src.id}')">${delSvg}</button>
</div>
${onCanvas ? '<div class="ptag canvas">canvas</div>' : ''}
`;
list.appendChild(div);
});
}
function deletePending(id) {
const i = pending.findIndex(p => p.id === id);
if (i < 0) return;
pending[i].stream.getTracks().forEach(t => t.stop());
pending.splice(i, 1);
if (layers.some(l => l.id === id)) removeLayer(id);
renderPending();
}
// ══════════════════════════════════════════════════
// ADD TO CANVAS
// ══════════════════════════════════════════════════
function addToCanvas(id) {
const src = pending.find(p => p.id === id);
if (!src || layers.some(l => l.id === id)) return;
const dw = Math.round(CW * 0.5);
const dh = Math.round(dw * 9 / 16);
const off = layers.length * 48;
const x = Math.min(off, CW - dw - 20);
const y = Math.min(off, CH - dh - 20);
const layer = { id: src.id, type: src.type, stream: src.stream, label: src.label, x, y, w: dw, h: dh, z: ++zCtr, el: null };
layer.el = buildOverlay(layer);
document.getElementById('stage').appendChild(layer.el);
layers.push(layer);
positionEl(layer);
selectLayer(id);
renderPending();
updateHint();
updateCode();
toast('✓ "' + src.label + '" en canvas');
}
// ══════════════════════════════════════════════════
// BUILD OVERLAY ELEMENT
// ══════════════════════════════════════════════════
function buildOverlay(layer) {
const wrap = document.createElement('div');
wrap.className = 'overlay';
wrap.dataset.id = layer.id;
wrap.style.zIndex = layer.z;
const inner = document.createElement('div');
inner.className = 'ov-inner';
const vid = document.createElement('video');
vid.autoplay = true; vid.muted = true; vid.playsInline = true;
vid.srcObject = layer.stream;
inner.appendChild(vid);
wrap.appendChild(inner);
const tb = document.createElement('div');
tb.className = 'ov-tb';
tb.innerHTML = `<span class="ovname">${layer.label}</span>
<button class="ovbtn" onclick="bringFront()">↑</button>
<button class="ovbtn" onclick="sendBack()">↓</button>
<button class="ovbtn r" onclick="removeSelected()">✕</button>`;
wrap.appendChild(tb);
['nw','n','ne','e','se','s','sw','w'].forEach(d => {
const h = document.createElement('div');
h.className = 'rh ' + d; h.dataset.dir = d;
wrap.appendChild(h);
});
makeDraggable(wrap, layer);
makeResizable(wrap, layer);
wrap.addEventListener('mousedown', e => {
if (e.target.classList.contains('rh') || e.target.closest('.ov-tb')) return;
selectLayer(layer.id);
});
return wrap;
}
// ══════════════════════════════════════════════════
// DRAG & RESIZE
// ══════════════════════════════════════════════════
function makeDraggable(el, layer) {
let ox, oy, lx, ly, down = false;
el.addEventListener('mousedown', e => {
if (e.target.classList.contains('rh') || e.target.closest('.ov-tb')) return;
e.preventDefault(); down = true;
const sc = getScale();
ox = e.clientX; oy = e.clientY; lx = layer.x; ly = layer.y;
const mv = e2 => {
if (!down) return;
layer.x = Math.max(0, Math.min(CW - layer.w, lx + (e2.clientX - ox) / sc));
layer.y = Math.max(0, Math.min(CH - layer.h, ly + (e2.clientY - oy) / sc));
positionEl(layer); updateSelProps();
};
const up = () => { down = false; document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); };
document.addEventListener('mousemove', mv);
document.addEventListener('mouseup', up);
});
}
function makeResizable(el, layer) {
el.querySelectorAll('.rh').forEach(h => {
h.addEventListener('mousedown', e => {
e.preventDefault(); e.stopPropagation();
const dir = h.dataset.dir, sc = getScale();
const sx = e.clientX, sy = e.clientY;
const ox = layer.x, oy = layer.y, ow = layer.w, oh = layer.h;
const MIN = 60;
const mv = e2 => {
const dx = (e2.clientX - sx) / sc, dy = (e2.clientY - sy) / sc;
let nx = ox, ny = oy, nw = ow, nh = oh;
if (dir.includes('e')) nw = Math.max(MIN, ow + dx);
if (dir.includes('s')) nh = Math.max(MIN, oh + dy);
if (dir.includes('w')) { nw = Math.max(MIN, ow - dx); nx = ox + (ow - nw); }
if (dir.includes('n')) { nh = Math.max(MIN, oh - dy); ny = oy + (oh - nh); }
layer.x = nx; layer.y = ny; layer.w = nw; layer.h = nh;
positionEl(layer); updateSelProps();
};
const up = () => { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); };
document.addEventListener('mousemove', mv);
document.addEventListener('mouseup', up);
});
});
}
// ══════════════════════════════════════════════════
// SELECTION
// ══════════════════════════════════════════════════
function selectLayer(id) {
selId = id;
layers.forEach(l => l.el.classList.toggle('sel', l.id === id));
updateSelProps();
}
function selectById(id) { selectLayer(id); }
function deselect() {
selId = null;
layers.forEach(l => l.el.classList.remove('sel'));
updateSelProps();
}
function updateSelProps() {
const l = layers.find(x => x.id === selId);
document.getElementById('noSel').style.display = l ? 'none' : 'block';
document.getElementById('selProps').style.display = l ? 'block' : 'none';
if (!l) return;
document.getElementById('liName').textContent = l.label;
document.getElementById('liPos').textContent = Math.round(l.x) + ', ' + Math.round(l.y);
document.getElementById('liSize').textContent = Math.round(l.w) + '×' + Math.round(l.h);
document.getElementById('liZ').textContent = l.z;
document.getElementById('px').value = Math.round(l.x);
document.getElementById('py').value = Math.round(l.y);
document.getElementById('pw').value = Math.round(l.w);
document.getElementById('ph').value = Math.round(l.h);
document.getElementById('eLayers').textContent = layers.length;
}
function applyProp() {
const l = layers.find(x => x.id === selId); if (!l) return;
l.x = +document.getElementById('px').value || 0;
l.y = +document.getElementById('py').value || 0;
l.w = +document.getElementById('pw').value || 320;
l.h = +document.getElementById('ph').value || 180;
positionEl(l); updateSelProps();
}
function applyOpacity(v) {
document.getElementById('opv').textContent = v;
const l = layers.find(x => x.id === selId);
if (l) l.el.style.opacity = v / 100;
}
// ══════════════════════════════════════════════════
// Z-ORDER
// ══════════════════════════════════════════════════
function bringFront() {
const l = layers.find(x => x.id === selId); if (!l) return;
l.z = ++zCtr; l.el.style.zIndex = l.z; updateSelProps();
}
function sendBack() {
const l = layers.find(x => x.id === selId); if (!l) return;
l.z = Math.max(1, l.z - 2); l.el.style.zIndex = l.z; updateSelProps();
}
// ══════════════════════════════════════════════════
// REMOVE / CLEAR
// ══════════════════════════════════════════════════
function removeLayer(id) {
const i = layers.findIndex(l => l.id === id); if (i < 0) return;
layers[i].el.remove(); layers.splice(i, 1);
if (selId === id) deselect();
renderPending(); updateHint(); updateCode();
document.getElementById('eLayers').textContent = layers.length;
}
function removeSelected() { if (selId) removeLayer(selId); }
function clearCanvas() { [...layers].forEach(l => removeLayer(l.id)); }
// ══════════════════════════════════════════════════
// LAYOUT PRESETS
// ══════════════════════════════════════════════════
function applyLayout(name, el) {
document.querySelectorAll('.lopt').forEach(o => o.classList.remove('active'));
el.classList.add('active');
if (!layers.length) { toast('Sin capas en el canvas', true); return; }
const n = layers.length, W = CW, H = CH, G = 4;
let slots = [];
if (name === 'full') {
layers.forEach(() => slots.push({ x:0, y:0, w:W, h:H }));
} else if (name === 'side') {
const tw = Math.floor((W - G * (n-1)) / n);
layers.forEach((_, i) => slots.push({ x: i*(tw+G), y:0, w:tw, h:H }));
} else if (name === 'pip-br') {
slots.push({ x:0, y:0, w:W, h:H });
const pw = Math.round(W*.27), ph = Math.round(pw*9/16);
for (let i=1;i<n;i++) slots.push({ x:W-pw-28, y:H-ph-28, w:pw, h:ph });
} else if (name === 'pip-bl') {
slots.push({ x:0, y:0, w:W, h:H });
const pw = Math.round(W*.27), ph = Math.round(pw*9/16);
for (let i=1;i<n;i++) slots.push({ x:28, y:H-ph-28, w:pw, h:ph });
} else if (name === 'tri') {
const mw = Math.floor(W*2/3);
slots.push({ x:0, y:0, w:mw, h:H });
const rest = n-1, rh = rest > 0 ? Math.floor((H-G*(rest-1))/rest) : H;
for (let i=0;i<rest;i++) slots.push({ x:mw+G, y:i*(rh+G), w:W-mw-G, h:rh });
} else if (name === 'quad') {
const cols = Math.ceil(Math.sqrt(n)), rows = Math.ceil(n/cols);
const tw = Math.floor((W-G*(cols-1))/cols), th = Math.floor((H-G*(rows-1))/rows);
layers.forEach((_, i) => { const c=i%cols, r=Math.floor(i/cols); slots.push({ x:c*(tw+G), y:r*(th+G), w:tw, h:th }); });
}
layers.forEach((l, i) => {
const s = slots[Math.min(i, slots.length-1)];
l.x=s.x; l.y=s.y; l.w=s.w; l.h=s.h; l.z=10+i; l.el.style.zIndex=l.z;
positionEl(l);
});
deselect(); updateCode(); toast('Layout "' + name + '" aplicado');
}
// ══════════════════════════════════════════════════
// COMPOSITE CANVAS CAPTURE
// Draws all video layers onto a hidden <canvas> at
// 1920×1080, publishes as ScreenShare track → LiveKit
// egress renders exactly what you see.
// ══════════════════════════════════════════════════
function startCompositeCapture() {
const canvas = document.getElementById('compositeCanvas');
canvas.width = CW; canvas.height = CH;
const ctx = canvas.getContext('2d');
function drawFrame() {
ctx.fillStyle = '#0c0c0c';
ctx.fillRect(0, 0, CW, CH);
[...layers].sort((a,b) => a.z - b.z).forEach(l => {
try {
const vid = l.el.querySelector('video');
if (vid && vid.readyState >= 2) {
ctx.globalAlpha = parseFloat(l.el.style.opacity || 1);
ctx.drawImage(vid, l.x, l.y, l.w, l.h);
ctx.globalAlpha = 1;
}
} catch(_) {}
});
compositeRAF = requestAnimationFrame(drawFrame);
}
drawFrame();
const fps = parseInt(document.getElementById('cfgFps').value) || 30;
const videoStream = canvas.captureStream(fps);
// Mix audio from all sources
compositeAudioCtx = new AudioContext();
const dest = compositeAudioCtx.createMediaStreamDestination();
layers.forEach(l => {
const src = pending.find(p => p.id === l.id);
if (src) {
const aTracks = src.stream.getAudioTracks();
if (aTracks.length) {
const node = compositeAudioCtx.createMediaStreamSource(new MediaStream(aTracks));
node.connect(dest);
}
}
});
const tracks = [videoStream.getVideoTracks()[0]];
const audioOut = dest.stream.getAudioTracks()[0];
if (audioOut) tracks.push(audioOut);
compositeStream = new MediaStream(tracks);
logEvt('COMPOSITE_START', { fps, layers: layers.length });
return compositeStream;
}
function stopCompositeCapture() {
if (compositeRAF) { cancelAnimationFrame(compositeRAF); compositeRAF = null; }
if (compositeAudioCtx) { compositeAudioCtx.close(); compositeAudioCtx = null; }
compositeStream = null;
logEvt('COMPOSITE_STOP', {});
}
// ══════════════════════════════════════════════════
// LIVEKIT JWT
// ══════════════════════════════════════════════════
async function makeToken(apiKey, apiSecret, roomName, identity, isServer) {
const header = { alg:'HS256', typ:'JWT' };
const now = Math.floor(Date.now() / 1000);
const claims = isServer
? { iss:apiKey, sub:apiKey, iat:now, nbf:now, exp:now+3600,
video:{ roomCreate:true, roomList:true, roomRecord:true, roomAdmin:true, ingressAdmin:true } }
: { iss:apiKey, sub:identity, iat:now, nbf:now, exp:now+86400,
video:{ room:roomName, roomJoin:true, canPublish:true, canSubscribe:true, canPublishData:true } };
// Encode JSON -> UTF-8 bytes -> base64url (btoa breaks on non-ASCII)
const enc = new TextEncoder();
const b64url = obj => {
const bytes = enc.encode(JSON.stringify(obj));
let bin = '';
bytes.forEach(b => bin += String.fromCharCode(b));
return btoa(bin).replace(/=/g,'').replace(/\+/g,'-').replace(/\//g,'_');
};
const msg = b64url(header) + '.' + b64url(claims);
const cryptoKey = await crypto.subtle.importKey(
'raw', enc.encode(apiSecret),
{ name:'HMAC', hash:'SHA-256' }, false, ['sign']
);
const sigBuf = await crypto.subtle.sign('HMAC', cryptoKey, enc.encode(msg));
let sigBin = '';
new Uint8Array(sigBuf).forEach(b => sigBin += String.fromCharCode(b));
const b64sig = btoa(sigBin).replace(/=/g,'').replace(/\+/g,'-').replace(/\//g,'_');
const token = msg + '.' + b64sig;
// Log decoded claims for debugging
try {
const pad = s => s + '==='.slice((s.length+3)%4);
const decoded = JSON.parse(atob(pad(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/'))));
logEvt('JWT_CLAIMS', { iss:decoded.iss, sub:decoded.sub, room:decoded.video?.room||'(server)', exp:decoded.exp });
} catch(_) {}
return token;
}
// ══════════════════════════════════════════════════
// LIVEKIT ROOM CONNECTION
// ══════════════════════════════════════════════════
async function ensureRoom() {
if (room && room.state === 'connected') return room;
const key = document.getElementById('lvkKey').value.trim();
const secret = document.getElementById('lvkSecret').value.trim();
const host = normUrl(document.getElementById('lvkUrl').value);
const rname = document.getElementById('lvkRoom').value.trim();
if (!key || !secret || !rname) throw new Error('Configura Key, Secret y Room');
const wsUrl = host.replace(/^https:/i,'wss:').replace(/^http:/i,'ws:');
const ident = 'composer-' + Math.random().toString(36).slice(2,8);
const token = await makeToken(key, secret, rname, ident, false);
room = new LivekitClient.Room({ adaptiveStream:false, dynacast:false });
room.on(LivekitClient.RoomEvent.Disconnected, () => { setRoomStatus('disconnected'); compositePub = null; });
room.on(LivekitClient.RoomEvent.Reconnecting, () => setRoomStatus('reconnecting'));
room.on(LivekitClient.RoomEvent.Reconnected, () => setRoomStatus('connected'));
await room.connect(wsUrl, token);
setRoomStatus('connected');
logEvt('ROOM_CONNECTED', { identity: ident, room: rname });
return room;
}
// ══════════════════════════════════════════════════
// COMPOSITE PUBLISH / UNPUBLISH
// ══════════════════════════════════════════════════
async function publishComposite() {
if (layers.length === 0) { toast('Añade fuentes al canvas primero', true); return; }
try {
const r = await ensureRoom();
const stream = startCompositeCapture();
const vTrack = stream.getVideoTracks()[0];
const aTrack = stream.getAudioTracks()[0];
const bitrate = (parseInt(document.getElementById('cfgBitrate').value) || 4500) * 1000;
const fps = parseInt(document.getElementById('cfgFps').value) || 30;
const vidPub = await r.localParticipant.publishTrack(
new LivekitClient.LocalVideoTrack(vTrack),
{ source: LivekitClient.Track.Source.ScreenShare, simulcast: false,
videoEncoding: { maxBitrate: bitrate, maxFramerate: fps } }
);
const audPub = aTrack
? await r.localParticipant.publishTrack(
new LivekitClient.LocalAudioTrack(aTrack),
{ source: LivekitClient.Track.Source.ScreenShareAudio }
)
: null;
compositePub = { vidPub, audPub };
setRoomStatus('connected');
const btn = document.getElementById('pubBtn');
btn.classList.add('active');
btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="1" y1="1" x2="23" y2="23"/><path d="M16.72 11.06A10.94 10.94 0 0119 12.55"/><path d="M5 12.55a10.94 10.94 0 015.17-2.39"/></svg> ⏹ Detener Publish';
logEvt('COMPOSITE_PUBLISHED', { video: true, audio: !!audPub });
toast('✓ Canvas publicado en room → LiveKit');
} catch(e) {
stopCompositeCapture();
logEvt('COMPOSITE_FAIL', { error: e.message });
toast('✗ ' + e.message, true);
}
}
async function unpublishComposite() {
if (!compositePub || !room) return;
try {
if (compositePub.vidPub) await room.localParticipant.unpublishTrack(compositePub.vidPub.track);
if (compositePub.audPub) await room.localParticipant.unpublishTrack(compositePub.audPub.track);
} catch(e) { console.warn(e); }
compositePub = null;
stopCompositeCapture();
const btn = document.getElementById('pubBtn');
btn.classList.remove('active');
btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1.42 9a16 16 0 0121.16 0"/><path d="M5 12.55a11 11 0 0114.08 0"/><path d="M8.53 16.11a6 6 0 016.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg> ▶ Publicar Canvas → Room';
logEvt('COMPOSITE_UNPUBLISHED', {});
toast('⏹ Publish detenido');
}
function toggleCompositePublish() {
if (compositePub) unpublishComposite();
else publishComposite();
}
function setRoomStatus(state) {
const map = { connected:['CONECTADO','var(--accent)'], disconnected:['DESCONECTADO','var(--muted)'], reconnecting:['RECONECTANDO','var(--accent3)'] };
const [txt, color] = map[state] || ['—','var(--muted)'];
const el = document.getElementById('rstatus');
const dot = document.getElementById('rdot');
const cnt = document.getElementById('rcount');
if (el) { el.textContent = txt; el.style.color = color; }
if (dot) { dot.style.background = color; dot.style.boxShadow = state==='connected'?`0 0 5px ${color}`:'none'; }
if (cnt) cnt.textContent = compositePub ? '1 composite' : '0';
}
// ══════════════════════════════════════════════════
// SAFE FETCH
// ══════════════════════════════════════════════════
async function safeFetch(url, opts) {
const res = await fetch(url, opts);
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch(_) { data = { msg: text, _raw: text }; }
return { ok: res.ok, status: res.status, data, text };
}
// ══════════════════════════════════════════════════
// STREAMING — real LiveKit Egress API calls
// ══════════════════════════════════════════════════
async function toggleStream() {
if (streaming) stopStream(); else startStream();
}
async function startStream() {
const key = document.getElementById('lvkKey').value.trim();
const secret = document.getElementById('lvkSecret').value.trim();
const host = normUrl(document.getElementById('lvkUrl').value);
const rname = document.getElementById('lvkRoom').value.trim();
const rtmpUrl = document.getElementById('rtmpUrl').value.trim();
const rtmpKey = document.getElementById('rtmpKey').value.trim();
const errs = [];
if (!key) errs.push('API Key vacío');
if (!secret) errs.push('API Secret vacío');
if (!host || host.includes('your-livekit')) errs.push('URL LiveKit no configurada');
if (!rname) errs.push('Room vacía');
if (!rtmpUrl)errs.push('RTMP URL vacía');
if (!rtmpKey)errs.push('Stream Key vacío');
if (errs.length) { setEgressErr(errs[0]); toast('⚠ ' + errs[0], true); return; }
const btn = document.getElementById('startBtn');
btn.disabled = true; btn.textContent = 'Conectando…';
setEgressState('STARTING', 'w');
try {
const token = await makeToken(key, secret, rname, key, true);
logEvt('JWT_OK', { key, exp:'+1h' });
// 1. Create room
const rr = await safeFetch(`${host}/twirp/livekit.RoomService/CreateRoom`, {
method:'POST',
headers:{ 'Content-Type':'application/json', 'Authorization':'Bearer '+token },
body: JSON.stringify({ name:rname, empty_timeout:300, max_participants:50 })
});
if (!rr.ok) {
const m = rr.data?.msg || rr.text;
if (!m.toLowerCase().includes('already')) { logEvt('CREATE_ROOM_FAIL',{body:rr.text}); throw new Error('CreateRoom: '+m); }
}
logEvt('CREATE_ROOM_OK', { sid: rr.data?.sid || 'existing' });
// 2. Start Egress
const body = {
room_name: rname,
layout: 'speaker-dark',
stream_outputs: [{ urls: [rtmpUrl + rtmpKey] }],
options: {
width: +document.getElementById('cfgW').value,
height: +document.getElementById('cfgH').value,
framerate: +document.getElementById('cfgFps').value,
video_bitrate: +document.getElementById('cfgBitrate').value,
audio_bitrate: 128
}
};
const er = await safeFetch(`${host}/twirp/livekit.Egress/StartRoomCompositeEgress`, {
method:'POST',
headers:{ 'Content-Type':'application/json', 'Authorization':'Bearer '+token },
body: JSON.stringify(body)
});
logEvt('EGRESS_RAW', { status: er.status, body: er.text.slice(0,300) });
if (!er.ok) throw new Error(er.data?.msg || er.text || 'HTTP '+er.status);
activeEgressId = er.data.egress_id || er.data.egressId || '?';
streaming = true;
btn.disabled = false;
btn.className = 'startbtn streaming';
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg> Detener Streaming';
document.getElementById('statusDot').classList.add('live');
document.getElementById('statusText').textContent = 'LIVE';
document.getElementById('liveChip').style.display = 'inline-block';
document.getElementById('timerBadge').style.display = 'flex';
startTime = Date.now(); timerInt = setInterval(tickTimer, 1000);
document.getElementById('eId').textContent = activeEgressId;
document.getElementById('eStart').textContent = new Date().toISOString().slice(0,19).replace('T',' ');
document.getElementById('eErr').textContent = '—';
setEgressState('ACTIVE', 'ev');
logEvt('START_OK', er.data);
toast('🔴 Egress iniciado · ' + activeEgressId);
} catch(e) {
streaming = false;
btn.disabled = false; btn.className = 'startbtn';
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3.5" fill="#000"/></svg> Iniciar Streaming';
setEgressState('ERROR', 'ev e');
setEgressErr(e.message);
logEvt('START_FAIL', { error: e.message });
toast('✗ ' + e.message, true);
}
updateCode();
}
async function stopStream() {
const key = document.getElementById('lvkKey').value.trim();
const secret = document.getElementById('lvkSecret').value.trim();
const host = normUrl(document.getElementById('lvkUrl').value);
const btn = document.getElementById('startBtn');
btn.disabled = true; btn.textContent = 'Deteniendo…';
try {
const token = await makeToken(key, secret, '', key, true);
const r = await safeFetch(`${host}/twirp/livekit.Egress/StopEgress`, {
method:'POST',
headers:{ 'Content-Type':'application/json', 'Authorization':'Bearer '+token },
body: JSON.stringify({ egress_id: activeEgressId })
});
if (!r.ok) throw new Error(r.data?.msg || r.text);
logEvt('STOP_OK', { status: r.status });
toast('⏹ Streaming detenido');
} catch(e) { toast('⚠ Stop: ' + e.message, true); logEvt('STOP_FAIL',{error:e.message}); }
finally { resetStreamUI(); activeEgressId = null; }
}
function resetStreamUI() {
streaming = false; clearInterval(timerInt);
const btn = document.getElementById('startBtn');
btn.disabled = false; btn.className = 'startbtn';
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3.5" fill="#000"/></svg> Iniciar Streaming';
document.getElementById('statusDot').classList.remove('live');
document.getElementById('statusText').textContent = 'IDLE';
document.getElementById('liveChip').style.display = 'none';
document.getElementById('timerBadge').style.display = 'none';
setEgressState('ENDED', 'ev w');
updateCode();
}
function setEgressState(txt, cls) { const e=document.getElementById('eState'); e.textContent=txt; e.className='ev '+cls; }
function setEgressErr(msg) { document.getElementById('eErr').textContent = msg; }
function tickTimer() {
const e=Date.now()-startTime;
document.getElementById('timer').textContent =
String(Math.floor(e/3600000)).padStart(2,'0')+':'+
String(Math.floor((e%3600000)/60000)).padStart(2,'0')+':'+
String(Math.floor((e%60000)/1000)).padStart(2,'0');
}
// ══════════════════════════════════════════════════
// CODE GENERATION
// ══════════════════════════════════════════════════
function buildPayload() {
return {
room_name: document.getElementById('lvkRoom').value,
layout: 'speaker-dark',
options: {
width: +document.getElementById('cfgW').value,
height: +document.getElementById('cfgH').value,
framerate: +document.getElementById('cfgFps').value,
video_bitrate: +document.getElementById('cfgBitrate').value,
audio_bitrate: 128
},
stream_outputs: [{ urls: [document.getElementById('rtmpUrl').value + document.getElementById('rtmpKey').value] }]
};
}
function switchTab(t, el) {
codeTab = t;
document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'));
el.classList.add('active'); updateCode();
}
function updateCode() {
const p = buildPayload();
const host = normUrl(document.getElementById('lvkUrl').value);
const key = document.getElementById('lvkKey').value.trim();
const secret = document.getElementById('lvkSecret').value.trim();
const smask = secret ? secret.slice(0,4)+'…'+secret.slice(-4) : 'YOUR_SECRET';
let html = '';
if (codeTab === 'curl') {
const body = JSON.stringify(p, null, 2).replace(/</g,'&lt;').replace(/>/g,'&gt;');
html = `<span class="cm"># 1. Crear room</span>
<span class="kw">curl</span> -X POST ${host}/twirp/livekit.RoomService/CreateRoom \\
-H <span class="str">"Authorization: Bearer &lt;JWT&gt;"</span> \\
-H <span class="str">"Content-Type: application/json"</span> \\
-d <span class="str">'{"name":"${p.room_name}","empty_timeout":300}'</span>
<span class="cm"># 2. Iniciar Egress</span>
<span class="kw">curl</span> -X POST ${host}/twirp/livekit.Egress/StartRoomCompositeEgress \\
-H <span class="str">"Authorization: Bearer &lt;JWT_KEY_${smask}&gt;"</span> \\
-H <span class="str">"Content-Type: application/json"</span> \\
-d '<span class="str">${body}</span>'`;
} else if (codeTab === 'js') {
html = `<span class="kw">import</span> { EgressClient, RoomServiceClient } <span class="kw">from</span> <span class="str">'livekit-server-sdk'</span>;
<span class="kw">const</span> svc = <span class="kw">new</span> <span class="fn">RoomServiceClient</span>(<span class="str">'${host}'</span>, <span class="str">'${key}'</span>, <span class="str">'${smask}'</span>);
<span class="kw">const</span> egress = <span class="kw">new</span> <span class="fn">EgressClient</span>(<span class="str">'${host}'</span>, <span class="str">'${key}'</span>, <span class="str">'${smask}'</span>);
<span class="cm">// 1. Create room</span>
<span class="kw">await</span> svc.<span class="fn">createRoom</span>({ name: <span class="str">'${p.room_name}'</span>, emptyTimeout: 300 });
<span class="cm">// 2. Start egress (composite canvas → RTMP)</span>
<span class="kw">const</span> info = <span class="kw">await</span> egress.<span class="fn">startRoomCompositeEgress</span>(
<span class="str">'${p.room_name}'</span>,
{ <span class="prop">stream</span>: { <span class="prop">urls</span>: [<span class="str">'${p.stream_outputs[0].urls[0]}'</span>] } },
{ <span class="prop">layout</span>: <span class="str">'speaker-dark'</span>, <span class="prop">width</span>: ${p.options.width}, <span class="prop">height</span>: ${p.options.height},
<span class="prop">framerate</span>: ${p.options.framerate}, <span class="prop">videoBitrate</span>: ${p.options.video_bitrate} }
);
console.<span class="fn">log</span>(info.egressId); <span class="cm">// ${activeEgressId||'(sin iniciar)'}</span>`;
} else {
html = `<span class="cm">// API Secret: ${smask}</span>
svc := lksdk.<span class="fn">NewRoomServiceClient</span>(<span class="str">"${host}"</span>, <span class="str">"${key}"</span>, <span class="str">"${smask}"</span>)
egress := lksdk.<span class="fn">NewEgressClient</span>(<span class="str">"${host}"</span>, <span class="str">"${key}"</span>, <span class="str">"${smask}"</span>)
svc.<span class="fn">CreateRoom</span>(ctx, &amp;lkproto.CreateRoomRequest{Name:<span class="str">"${p.room_name}"</span>})
info, _ := egress.<span class="fn">StartRoomCompositeEgress</span>(ctx, &amp;lkproto.RoomCompositeEgressRequest{
RoomName: <span class="str">"${p.room_name}"</span>, Layout: <span class="str">"speaker-dark"</span>,
Output: &amp;lkproto.RoomCompositeEgressRequest_Stream{
Stream: &amp;lkproto.StreamOutput{Urls:[]<span class="kw">string</span>{<span class="str">"${p.stream_outputs[0].urls[0]}"</span>}},
},
Options: &amp;lkproto.RoomCompositeOptions{
Width:${p.options.width}, Height:${p.options.height},
Framerate:${p.options.framerate}, VideoBitrate:${p.options.video_bitrate},
},
})`;
}
document.getElementById('codeContent').innerHTML = html;
document.getElementById('eLayers').textContent = layers.length;
}
function copyCode() {
navigator.clipboard.writeText(document.getElementById('codeContent').innerText).then(() => toast('✓ Copiado'));
}
// ══════════════════════════════════════════════════
// HELPERS
// ══════════════════════════════════════════════════
function normUrl(raw) {
return raw.trim().replace(/\/+$/,'').replace(/^wss:\/\//i,'https://').replace(/^ws:\/\//i,'http://');
}
function fixUrl(inp) {
const fixed = normUrl(inp.value);
if (fixed !== inp.value.trim()) {
inp.value = fixed;
const h = document.getElementById('urlHint');
if (h) { h.style.display='block'; setTimeout(()=>h.style.display='none',3000); }
updateCode();
}
}
function toggleSecret() {
const inp = document.getElementById('lvkSecret');
const ico = document.getElementById('eyeIco');
inp.type = inp.type === 'password' ? 'text' : 'password';
ico.innerHTML = inp.type === 'text'
? '<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>'
: '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>';
}
function checkSecret() {
const w = document.getElementById('secWarn');
if (w) w.style.display = document.getElementById('lvkSecret').value.trim() ? 'none' : 'inline';
}
function updateResChip() {
document.getElementById('resChip').textContent =
document.getElementById('cfgW').value + '×' + document.getElementById('cfgH').value;
}
function updateHint() {
document.getElementById('chint').style.display = layers.length ? 'none' : 'flex';
}
function logEvt(event, data) {
const log = document.getElementById('apiLog');
if (!log) return;
const ts = new Date().toISOString().slice(11,19);
const color= event.endsWith('OK')||event.endsWith('CONNECTED')||event.endsWith('PUBLISHED')
? 'var(--accent)' : event.endsWith('FAIL')||event.endsWith('ERROR') ? 'var(--accent2)' : 'var(--accent3)';
const line = document.createElement('div');
line.style.cssText = 'margin-bottom:4px;padding-bottom:4px;border-bottom:1px solid var(--border)';
line.innerHTML = `<span style="color:var(--muted)">${ts}</span> <span style="color:${color};font-weight:700">${event}</span>\n`
+ `<span style="color:#7ec8b0">${JSON.stringify(data,null,2).replace(/</g,'&lt;')}</span>`;
// Remove placeholder
const placeholder = log.querySelector('span[style*="italic"]');
if (placeholder) placeholder.remove();
log.insertBefore(line, log.firstChild);
}
function toast(msg, err=false) {
const el = document.getElementById('toast');
el.textContent = msg;
el.style.borderColor = err ? 'var(--accent2)' : 'var(--accent)';
el.style.color = err ? 'var(--accent2)' : 'var(--accent)';
el.classList.add('show');
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove('show'), 2800);
}
// Deselect on stage background click
document.getElementById('stage').addEventListener('mousedown', e => {
if (e.target === document.getElementById('stage')) deselect();
});
// Init
window.addEventListener('load', () => { resizeStage(); updateCode(); });
</script>
</body>
</html>