- 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.
1309 lines
67 KiB
HTML
1309 lines
67 KiB
HTML
<!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,'<').replace(/>/g,'>');
|
||
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 <JWT>"</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 <JWT_KEY_${smask}>"</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, &lkproto.CreateRoomRequest{Name:<span class="str">"${p.room_name}"</span>})
|
||
info, _ := egress.<span class="fn">StartRoomCompositeEgress</span>(ctx, &lkproto.RoomCompositeEgressRequest{
|
||
RoomName: <span class="str">"${p.room_name}"</span>, Layout: <span class="str">"speaker-dark"</span>,
|
||
Output: &lkproto.RoomCompositeEgressRequest_Stream{
|
||
Stream: &lkproto.StreamOutput{Urls:[]<span class="kw">string</span>{<span class="str">"${p.stream_outputs[0].urls[0]}"</span>}},
|
||
},
|
||
Options: &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,'<')}</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>
|