- 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.
1051 lines
53 KiB
HTML
1051 lines
53 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 Egress</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"/>
|
||
<style>
|
||
:root{
|
||
--bg:#080c10;--surface:#0e1419;--panel:#141b24;--border:#1e2d3d;
|
||
--accent:#00d4aa;--accent2:#ff4d6d;--accent3:#ffd60a;
|
||
--text:#cdd6e4;--muted:#4a5568;--live:#ff4d6d;
|
||
}
|
||
*{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{display:flex;align-items:center;justify-content:space-between;
|
||
padding:12px 24px;border-bottom:1px solid var(--border);
|
||
background:rgba(8,12,16,.95);backdrop-filter:blur(12px);
|
||
position:relative;z-index:200;height:52px;flex-shrink:0;}
|
||
.logo{display:flex;align-items:center;gap:8px;font-size:1rem;font-weight:800;color:var(--accent);}
|
||
.hbadge{display:flex;align-items:center;gap:6px;padding:4px 12px;border-radius:20px;
|
||
background:var(--panel);border:1px solid var(--border);
|
||
font-family:'JetBrains Mono',monospace;font-size:.72rem;}
|
||
.dot{width:7px;height:7px;border-radius:50%;background:var(--muted);}
|
||
.dot.live{background:var(--live);box-shadow:0 0 8px var(--live);animation:pulse 1.5s infinite;}
|
||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
||
|
||
.app{display:grid;grid-template-columns:250px 1fr 310px;height:calc(100vh - 52px);position:relative;z-index:1;}
|
||
|
||
/* ── LEFT PANEL ── */
|
||
.left-panel{background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;}
|
||
.panel-head{padding:12px 14px;border-bottom:1px solid var(--border);flex-shrink:0;}
|
||
.panel-title{font-size:.6rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1.5px;}
|
||
.panel-body{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:5px;padding:10px 6px;
|
||
border:1px dashed var(--border);border-radius:8px;background:none;color:var(--muted);
|
||
font-family:'Syne',sans-serif;font-size:.7rem;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:18px;height:18px;}
|
||
|
||
.queue-label{font-size:.58rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:7px;}
|
||
.pending-list{display:flex;flex-direction:column;gap:5px;}
|
||
.pending-item{display:flex;align-items:center;gap:7px;padding:7px 9px;
|
||
background:var(--panel);border:1px solid var(--border);border-radius:7px;transition:all .2s;position:relative;}
|
||
.picon{width:26px;height:26px;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);}
|
||
.pending-info{flex:1;min-width:0;}
|
||
.pending-name{font-size:.75rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.pending-sub{font-size:.6rem;color:var(--muted);font-family:'JetBrains Mono',monospace;}
|
||
.pending-actions{display:flex;gap:3px;flex-shrink:0;}
|
||
.icon-btn{background:none;border:1px solid var(--border);color:var(--muted);
|
||
width:24px;height:24px;border-radius:4px;cursor:pointer;
|
||
display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0;}
|
||
.icon-btn:hover{border-color:var(--accent);color:var(--accent);}
|
||
.icon-btn.del:hover{border-color:var(--accent2);color:var(--accent2);}
|
||
.icon-btn svg{width:11px;height:11px;pointer-events:none;}
|
||
.on-canvas-tag{position:absolute;top:3px;right:3px;
|
||
background:rgba(0,212,170,.12);border:1px solid rgba(0,212,170,.4);
|
||
color:var(--accent);font-size:.52rem;font-weight:700;
|
||
padding:1px 4px;border-radius:3px;font-family:'JetBrains Mono',monospace;pointer-events:none;}
|
||
|
||
/* ── CENTER ── */
|
||
.center{display:flex;flex-direction:column;background:#000;overflow:hidden;}
|
||
.canvas-toolbar{display:flex;align-items:center;gap:7px;padding:7px 12px;
|
||
background:var(--surface);border-bottom:1px solid var(--border);flex-shrink:0;}
|
||
.t-label{font-size:.58rem;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:1px;margin-right:auto;}
|
||
.t-chip{padding:3px 8px;border-radius:4px;background:var(--panel);border:1px solid var(--border);
|
||
font-family:'JetBrains Mono',monospace;font-size:.62rem;color:var(--text);}
|
||
.t-chip.live-chip{color:var(--live);border-color:rgba(255,77,109,.4);}
|
||
|
||
.stage-wrap{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 40px rgba(0,0,0,.8);}
|
||
#stage::before{content:'';position:absolute;inset:0;
|
||
background-image:linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px),
|
||
linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px);
|
||
background-size:20px 20px;pointer-events:none;z-index:0;}
|
||
|
||
.overlay{position:absolute;border:2px solid transparent;border-radius:3px;
|
||
overflow:visible;cursor:move;user-select:none;transition:border-color .12s;z-index:10;}
|
||
.overlay-inner{position:absolute;inset:0;overflow:hidden;border-radius:2px;}
|
||
.overlay-inner video{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;}
|
||
.overlay:hover{border-color:rgba(0,212,170,.45);}
|
||
.overlay.selected{border-color:var(--accent)!important;z-index:100;}
|
||
.overlay.selected .overlay-inner{box-shadow:inset 0 0 0 1px rgba(0,212,170,.2);}
|
||
|
||
/* resize handles */
|
||
.rh{position:absolute;width:9px;height:9px;background:var(--accent);border:2px solid #0c0c0c;
|
||
border-radius:2px;z-index:30;opacity:0;transition:opacity .12s;}
|
||
.overlay.selected .rh{opacity:1;}
|
||
.rh.nw{top:-5px;left:-5px;cursor:nw-resize;}
|
||
.rh.ne{top:-5px;right:-5px;cursor:ne-resize;}
|
||
.rh.sw{bottom:-5px;left:-5px;cursor:sw-resize;}
|
||
.rh.se{bottom:-5px;right:-5px;cursor:se-resize;}
|
||
.rh.n{top:-5px;left:50%;transform:translateX(-50%);cursor:n-resize;}
|
||
.rh.s{bottom:-5px;left:50%;transform:translateX(-50%);cursor:s-resize;}
|
||
.rh.e{right:-5px;top:50%;transform:translateY(-50%);cursor:e-resize;}
|
||
.rh.w{left:-5px;top:50%;transform:translateY(-50%);cursor:w-resize;}
|
||
|
||
/* overlay toolbar */
|
||
.ov-tb{position:absolute;top:-30px;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.selected .ov-tb{display:flex;}
|
||
.ov-btn{background:none;border:none;color:var(--muted);cursor:pointer;
|
||
font-size:.6rem;font-family:'JetBrains Mono',monospace;padding:2px 4px;border-radius:3px;transition:all .12s;}
|
||
.ov-btn:hover{color:var(--accent);background:rgba(0,212,170,.1);}
|
||
.ov-btn.red:hover{color:var(--accent2);background:rgba(255,77,109,.1);}
|
||
.ov-name{font-size:.6rem;color:var(--text);font-family:'JetBrains Mono',monospace;
|
||
padding:0 5px 0 2px;border-right:1px solid var(--border);margin-right:2px;max-width:100px;
|
||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
|
||
/* controls bar */
|
||
.controls-bar{display:flex;align-items:center;gap:6px;padding:8px 12px;
|
||
background:var(--surface);border-top:1px solid var(--border);flex-shrink:0;}
|
||
.ctrl-btn{display:flex;align-items:center;gap:5px;padding:5px 10px;border-radius:5px;
|
||
border:1px solid var(--border);background:var(--panel);color:var(--text);
|
||
font-family:'Syne',sans-serif;font-size:.72rem;font-weight:600;cursor:pointer;transition:all .2s;}
|
||
.ctrl-btn:hover{border-color:var(--accent);color:var(--accent);}
|
||
.ctrl-btn svg{width:13px;height:13px;}
|
||
.ctrl-sep{width:1px;height:20px;background:var(--border);margin:0 2px;}
|
||
|
||
/* canvas hint */
|
||
.canvas-hint{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;z-index:1;}
|
||
.canvas-hint-inner{text-align:center;color:var(--muted);}
|
||
.canvas-hint-inner svg{opacity:.15;margin-bottom:10px;}
|
||
.canvas-hint-inner p{font-size:.78rem;line-height:1.6;}
|
||
|
||
/* ── RIGHT PANEL ── */
|
||
.right-panel{background:var(--surface);border-left:1px solid var(--border);display:flex;flex-direction:column;overflow-y:auto;}
|
||
.section{padding:14px;border-bottom:1px solid var(--border);}
|
||
.sec-title{font-size:.58rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1.5px;
|
||
margin-bottom:10px;display:flex;align-items:center;gap:6px;}
|
||
.sec-title::after{content:'';flex:1;height:1px;background:var(--border);}
|
||
|
||
.layout-grid{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:30px;border-radius:2px;background:var(--border);position:relative;overflow:hidden;}
|
||
.ln{font-size:.56rem;color:var(--muted);font-weight:600;text-align:center;}
|
||
|
||
.prop-row{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:6px;}
|
||
.prop-group{display:flex;flex-direction:column;gap:2px;margin-bottom:6px;}
|
||
.prop-label{font-size:.6rem;color:var(--muted);font-weight:600;}
|
||
.prop-input{padding:5px 8px;background:var(--panel);border:1px solid var(--border);
|
||
border-radius:4px;color:var(--text);font-family:'JetBrains Mono',monospace;
|
||
font-size:.7rem;outline:none;width:100%;transition:border-color .2s;}
|
||
.prop-input:focus{border-color:var(--accent);}
|
||
|
||
.layer-info{background:var(--panel);border:1px solid var(--border);border-radius:5px;padding:8px;margin-bottom:8px;}
|
||
.li-row{display:flex;justify-content:space-between;font-size:.65rem;margin-bottom:3px;}
|
||
.li-row:last-child{margin-bottom:0;}
|
||
.li-k{color:var(--muted);font-family:'JetBrains Mono',monospace;}
|
||
.li-v{color:var(--accent);font-family:'JetBrains Mono',monospace;}
|
||
|
||
.erow{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;font-size:.65rem;font-family:'JetBrains Mono',monospace;}
|
||
.erow:last-child{margin-bottom:0;}
|
||
.ek{color:var(--muted);}.ev{color:var(--accent);}.ev.warn{color:var(--accent3);}.ev.err{color:var(--accent2);}
|
||
|
||
.start-btn{width:100%;padding:11px;border-radius:7px;border:none;
|
||
background:linear-gradient(135deg,var(--accent),#00a884);
|
||
color:#000;font-family:'Syne',sans-serif;font-size:.82rem;font-weight:800;
|
||
cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;gap:7px;margin-top:6px;}
|
||
.start-btn:hover{transform:translateY(-1px);box-shadow:0 6px 20px rgba(0,212,170,.3);}
|
||
.start-btn.streaming{background:linear-gradient(135deg,var(--accent2),#c0392b);}
|
||
|
||
.code-block{background:#0a0f14;border:1px solid var(--border);border-radius:5px;
|
||
padding:10px;font-family:'JetBrains Mono',monospace;font-size:.62rem;
|
||
line-height:1.7;overflow-x:auto;color:#a0c4b0;position:relative;max-height:180px;overflow-y:auto;}
|
||
.kw{color:#ff79c6;}.str{color:#f1fa8c;}.fn{color:#50fa7b;}.cm{color:var(--muted);font-style:italic;}.prop{color:#8be9fd;}
|
||
.copy-code{position:absolute;top:5px;right:5px;background:var(--panel);border:1px solid var(--border);
|
||
color:var(--muted);font-size:.58rem;padding:2px 5px;border-radius:3px;cursor:pointer;
|
||
font-family:'JetBrains Mono',monospace;transition:all .2s;}
|
||
.copy-code:hover{color:var(--accent);border-color:var(--accent);}
|
||
.tabs{display:flex;border:1px solid var(--border);border-radius:4px;overflow:hidden;margin-bottom:8px;}
|
||
.tab{flex:1;padding:5px;text-align:center;font-size:.62rem;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:18px;right:18px;z-index:9999;
|
||
background:var(--panel);border:1px solid var(--accent);border-radius:5px;
|
||
padding:8px 14px;font-size:.72rem;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);}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<div class="logo">
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><rect width="24" height="24" rx="5" fill="rgba(0,212,170,.12)"/><path d="M4 12c0-4.4 3.6-8 8-8s8 3.6 8 8" stroke="#00d4aa" stroke-width="1.8" stroke-linecap="round"/><circle cx="12" cy="12" r="2.5" fill="#00d4aa"/></svg>
|
||
LiveKit Compose → RTMP
|
||
</div>
|
||
<div style="display:flex;gap:7px;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: Pending Sources -->
|
||
<div class="left-panel">
|
||
<div class="panel-head"><div class="panel-title">Fuentes — Cola pendiente</div></div>
|
||
<div class="panel-body">
|
||
<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="queue-label">En espera</div>
|
||
<div class="pending-list" id="pendingList">
|
||
<div style="text-align:center;color:var(--muted);font-size:.7rem;padding:14px 0;line-height:1.6" id="emptyQueue">
|
||
Sin fuentes.<br/>Añade una cámara o pantalla.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CENTER: Canvas -->
|
||
<div class="center">
|
||
<div class="canvas-toolbar">
|
||
<span class="t-label">Compose Canvas</span>
|
||
<span class="t-chip" id="resChip">1920×1080</span>
|
||
<span class="t-chip" id="fpsChip">30 fps</span>
|
||
<span class="t-chip live-chip" id="liveChip" style="display:none">● LIVE</span>
|
||
</div>
|
||
<div class="stage-wrap" id="stageWrap">
|
||
<div id="stage">
|
||
<div class="canvas-hint" id="canvasHint">
|
||
<div class="canvas-hint-inner">
|
||
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width=".8"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||
<p>Pulsa <strong style="color:var(--accent)">+</strong> en una fuente<br/>para añadirla al canvas</p>
|
||
<p style="font-size:.65rem;margin-top:6px;opacity:.6">Luego arrástrala y redimensiona libremente</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="controls-bar">
|
||
<button class="ctrl-btn" onclick="deselect()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3l18 18"/></svg>
|
||
Deselect
|
||
</button>
|
||
<div class="ctrl-sep"></div>
|
||
<button class="ctrl-btn" 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>
|
||
Al frente
|
||
</button>
|
||
<button class="ctrl-btn" 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="ctrl-sep"></div>
|
||
<button class="ctrl-btn" onclick="removeSelected()" style="color:var(--accent2);border-color:rgba(255,77,109,.4)">
|
||
<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-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||
Quitar capa
|
||
</button>
|
||
<button class="ctrl-btn" onclick="clearCanvas()" style="margin-left:auto;color:var(--muted)">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg>
|
||
Limpiar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RIGHT: Config -->
|
||
<div class="right-panel">
|
||
|
||
<!-- Selected layer -->
|
||
<div class="section">
|
||
<div class="sec-title">Capa seleccionada</div>
|
||
<div id="noSelMsg" style="color:var(--muted);font-size:.72rem;text-align:center;padding:6px 0">
|
||
Selecciona un elemento en el canvas
|
||
</div>
|
||
<div id="selProps" style="display:none">
|
||
<div class="layer-info">
|
||
<div class="li-row"><span class="li-k">nombre</span><span class="li-v" id="liName">—</span></div>
|
||
<div class="li-row"><span class="li-k">pos</span><span class="li-v" id="liPos">—</span></div>
|
||
<div class="li-row"><span class="li-k">tamaño</span><span class="li-v" id="liSize">—</span></div>
|
||
<div class="li-row"><span class="li-k">z-index</span><span class="li-v" id="liZ">—</span></div>
|
||
</div>
|
||
<div class="prop-row">
|
||
<div class="prop-group"><div class="prop-label">X (px)</div><input class="prop-input" id="propX" type="number" oninput="applyProp()"/></div>
|
||
<div class="prop-group"><div class="prop-label">Y (px)</div><input class="prop-input" id="propY" type="number" oninput="applyProp()"/></div>
|
||
</div>
|
||
<div class="prop-row">
|
||
<div class="prop-group"><div class="prop-label">W (px)</div><input class="prop-input" id="propW" type="number" oninput="applyProp()"/></div>
|
||
<div class="prop-group"><div class="prop-label">H (px)</div><input class="prop-input" id="propH" type="number" oninput="applyProp()"/></div>
|
||
</div>
|
||
<div class="prop-group">
|
||
<div class="prop-label">Opacidad: <span id="opVal">100</span>%</div>
|
||
<input type="range" min="10" max="100" value="100" id="propOpacity"
|
||
oninput="applyOpacity(this.value);document.getElementById('opVal').textContent=this.value"
|
||
style="width:100%;accent-color:var(--accent)"/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Layouts -->
|
||
<div class="section">
|
||
<div class="sec-title">Layouts predefinidos</div>
|
||
<div class="layout-grid">
|
||
<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,.18);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,.18);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,.18);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,.28);border-radius:1px"></div>
|
||
<div style="flex:1;display:flex;flex-direction:column;gap:2px">
|
||
<div style="flex:1;background:rgba(0,212,170,.18);border-radius:1px"></div>
|
||
<div style="flex:1;background:rgba(255,214,10,.18);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,.28);border-radius:1px"></div>
|
||
<div style="background:rgba(0,212,170,.18);border-radius:1px"></div>
|
||
<div style="background:rgba(255,214,10,.14);border-radius:1px"></div>
|
||
<div style="background:rgba(255,77,109,.14);border-radius:1px"></div>
|
||
</div>
|
||
<div class="ln">2×2</div>
|
||
</div>
|
||
</div>
|
||
<div style="font-size:.6rem;color:var(--muted);margin-top:6px;line-height:1.5">
|
||
Reorganiza las capas activas sin solapamiento. Las fuentes sin slot mantienen su posición.
|
||
</div>
|
||
</div>
|
||
|
||
<!-- LiveKit -->
|
||
<div class="section">
|
||
<div class="sec-title">LiveKit Server</div>
|
||
<div class="prop-group"><div class="prop-label">URL</div><input class="prop-input" id="lvkUrl" value="wss://your-livekit.livekit.cloud" oninput="updateCode()"/></div>
|
||
<div class="prop-row">
|
||
<div class="prop-group"><div class="prop-label">API Key</div><input class="prop-input" id="lvkKey" value="APIxxxxxxx" oninput="updateCode()"/></div>
|
||
<div class="prop-group"><div class="prop-label">Room</div><input class="prop-input" id="lvkRoom" value="broadcast-1" oninput="updateCode()"/></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RTMP -->
|
||
<div class="section">
|
||
<div class="sec-title">Destino RTMP</div>
|
||
<div class="prop-group"><div class="prop-label">URL</div><input class="prop-input" id="rtmpUrl" value="rtmp://live.twitch.tv/app/" oninput="updateCode()"/></div>
|
||
<div class="prop-group"><div class="prop-label">Stream Key</div><input class="prop-input" id="rtmpKey" value="live_xxxxxxxxxx" oninput="updateCode()"/></div>
|
||
</div>
|
||
|
||
<!-- Encode -->
|
||
<div class="section">
|
||
<div class="sec-title">Encode</div>
|
||
<div class="prop-row">
|
||
<div class="prop-group"><div class="prop-label">Ancho</div><input class="prop-input" id="cfgW" type="number" value="1920" oninput="updateCode();updateResChip()"/></div>
|
||
<div class="prop-group"><div class="prop-label">Alto</div><input class="prop-input" id="cfgH" type="number" value="1080" oninput="updateCode();updateResChip()"/></div>
|
||
</div>
|
||
<div class="prop-row">
|
||
<div class="prop-group"><div class="prop-label">FPS</div>
|
||
<select class="prop-input" 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="prop-group"><div class="prop-label">Bitrate kbps</div><input class="prop-input" id="cfgBitrate" type="number" value="4500" oninput="updateCode()"/></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Code -->
|
||
<div class="section">
|
||
<div class="sec-title">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="copy-code" onclick="copyCode()">copy</button><div id="codeContent"></div></div>
|
||
</div>
|
||
|
||
<!-- Egress + start -->
|
||
<div class="section">
|
||
<div class="sec-title">Egress</div>
|
||
<div style="background:var(--panel);border:1px solid var(--border);border-radius:5px;padding:9px;margin-bottom:8px">
|
||
<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 warn" 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">capas activas</span><span class="ev" id="eOverlays">0</span></div>
|
||
</div>
|
||
<button class="start-btn" id="startBtn" onclick="toggleStream()">
|
||
<svg width="15" height="15" 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>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
// ════════════════════════════════════════
|
||
// STATE
|
||
// ════════════════════════════════════════
|
||
const pending = [];
|
||
const layers = [];
|
||
let selectedId = null;
|
||
let streaming = false;
|
||
let timerInt = null, startTime = null;
|
||
let currentTab = 'curl';
|
||
let zCtr = 10;
|
||
const egressId = 'eg_' + Math.random().toString(36).slice(2,10);
|
||
const CW = 1920, CH = 1080;
|
||
|
||
// ════════════════════════════════════════
|
||
// STAGE SCALE
|
||
// ════════════════════════════════════════
|
||
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(l => positionEl(l));
|
||
}
|
||
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();
|
||
pending.push({id, type:'cam', stream, label:(cam?.label||'').split('(')[0].trim()||'Cámara '+(pending.filter(p=>p.type==='cam').length+1)});
|
||
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 pi = pending.findIndex(p=>p.id===id);
|
||
if(pi>=0){pending.splice(pi,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 RENDER
|
||
// ════════════════════════════════════════
|
||
function renderPending() {
|
||
const list = document.getElementById('pendingList');
|
||
document.getElementById('emptyQueue').style.display = pending.length?'none':'block';
|
||
list.querySelectorAll('.pending-item').forEach(e=>e.remove());
|
||
|
||
pending.forEach(src => {
|
||
const onCanvas = layers.some(l=>l.id===src.id);
|
||
const div = document.createElement('div');
|
||
div.className = 'pending-item';
|
||
const camSvg = '<svg width="13" height="13" 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="13" height="13" 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>';
|
||
div.innerHTML = `
|
||
<div class="picon ${src.type}">${src.type==='cam'?camSvg:scrSvg}</div>
|
||
<div class="pending-info">
|
||
<div class="pending-name">${src.label}</div>
|
||
<div class="pending-sub">${onCanvas?'● en canvas':'○ pendiente'}</div>
|
||
</div>
|
||
<div class="pending-actions">
|
||
${!onCanvas
|
||
? `<button class="icon-btn" title="Añadir al canvas" onclick="addToCanvas('${src.id}')">
|
||
<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>
|
||
</button>`
|
||
: `<button class="icon-btn" title="Seleccionar" onclick="selectById('${src.id}')">
|
||
<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>
|
||
</button>`}
|
||
<button class="icon-btn del" title="Eliminar" onclick="deletePending('${src.id}')">
|
||
<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>
|
||
</button>
|
||
</div>
|
||
${onCanvas?'<div class="on-canvas-tag">canvas</div>':''}
|
||
`;
|
||
list.appendChild(div);
|
||
});
|
||
}
|
||
|
||
function deletePending(id) {
|
||
const idx = pending.findIndex(p=>p.id===id);
|
||
if(idx<0) return;
|
||
pending[idx].stream.getTracks().forEach(t=>t.stop());
|
||
pending.splice(idx,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;
|
||
|
||
// Default: 50% width, 16:9. Cascade offset.
|
||
const dw = Math.round(CW * .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 = buildOverlayEl(layer);
|
||
document.getElementById('stage').appendChild(layer.el);
|
||
layers.push(layer);
|
||
positionEl(layer);
|
||
selectLayer(id);
|
||
renderPending();
|
||
updateHint();
|
||
updateCode();
|
||
toast('✓ "'+src.label+'" añadido al canvas');
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// BUILD OVERLAY ELEMENT
|
||
// ════════════════════════════════════════
|
||
function buildOverlayEl(layer) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'overlay';
|
||
wrap.dataset.id = layer.id;
|
||
wrap.style.zIndex = layer.z;
|
||
|
||
// inner clip container
|
||
const inner = document.createElement('div');
|
||
inner.className = 'overlay-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);
|
||
|
||
// toolbar
|
||
const tb = document.createElement('div');
|
||
tb.className = 'ov-tb';
|
||
tb.innerHTML = `<span class="ov-name">${layer.label}</span>
|
||
<button class="ov-btn" onclick="bringFront()" title="Al frente">↑</button>
|
||
<button class="ov-btn" onclick="sendBack()" title="Atrás">↓</button>
|
||
<button class="ov-btn red" onclick="removeSelected()" title="Quitar">✕</button>`;
|
||
wrap.appendChild(tb);
|
||
|
||
// resize handles
|
||
['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')) return;
|
||
if(e.target.closest('.ov-tb')) return;
|
||
selectLayer(layer.id);
|
||
});
|
||
|
||
return wrap;
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// DRAG
|
||
// ════════════════════════════════════════
|
||
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);
|
||
});
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// RESIZE
|
||
// ════════════════════════════════════════
|
||
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) {
|
||
selectedId=id;
|
||
layers.forEach(l=>l.el.classList.toggle('selected',l.id===id));
|
||
updateSelProps();
|
||
}
|
||
function selectById(id){ selectLayer(id); toast('Elemento seleccionado'); }
|
||
function deselect(){ selectedId=null; layers.forEach(l=>l.el.classList.remove('selected')); updateSelProps(); }
|
||
|
||
function updateSelProps() {
|
||
const l=layers.find(x=>x.id===selectedId);
|
||
document.getElementById('noSelMsg').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)+'px, '+Math.round(l.y)+'px';
|
||
document.getElementById('liSize').textContent = Math.round(l.w)+'×'+Math.round(l.h);
|
||
document.getElementById('liZ').textContent = l.z;
|
||
document.getElementById('propX').value = Math.round(l.x);
|
||
document.getElementById('propY').value = Math.round(l.y);
|
||
document.getElementById('propW').value = Math.round(l.w);
|
||
document.getElementById('propH').value = Math.round(l.h);
|
||
document.getElementById('eOverlays').textContent = layers.length;
|
||
}
|
||
|
||
function applyProp() {
|
||
const l=layers.find(x=>x.id===selectedId); if(!l) return;
|
||
l.x=+document.getElementById('propX').value||0;
|
||
l.y=+document.getElementById('propY').value||0;
|
||
l.w=+document.getElementById('propW').value||320;
|
||
l.h=+document.getElementById('propH').value||180;
|
||
positionEl(l); updateSelProps();
|
||
}
|
||
|
||
function applyOpacity(v) {
|
||
const l=layers.find(x=>x.id===selectedId);
|
||
if(l) l.el.style.opacity=v/100;
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// Z-ORDER
|
||
// ════════════════════════════════════════
|
||
function bringFront() {
|
||
const l=layers.find(x=>x.id===selectedId); if(!l) return;
|
||
l.z=++zCtr; l.el.style.zIndex=l.z; updateSelProps();
|
||
}
|
||
function sendBack() {
|
||
const l=layers.find(x=>x.id===selectedId); if(!l) return;
|
||
l.z=Math.max(1,l.z-2); l.el.style.zIndex=l.z; updateSelProps();
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// REMOVE
|
||
// ════════════════════════════════════════
|
||
function removeLayer(id) {
|
||
const idx=layers.findIndex(l=>l.id===id); if(idx<0) return;
|
||
layers[idx].el.remove(); layers.splice(idx,1);
|
||
if(selectedId===id) deselect();
|
||
renderPending(); updateHint(); updateCode();
|
||
}
|
||
function removeSelected(){ if(selectedId) removeLayer(selectedId); }
|
||
function clearCanvas(){ [...layers].forEach(l=>removeLayer(l.id)); }
|
||
|
||
// ════════════════════════════════════════
|
||
// LAYOUT PRESETS — no overlap
|
||
// ════════════════════════════════════════
|
||
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;
|
||
if(rest>0){
|
||
const rh=Math.floor((H-G*(rest-1))/rest);
|
||
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');
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// STREAMING
|
||
// ════════════════════════════════════════
|
||
function toggleStream() {
|
||
streaming=!streaming;
|
||
const btn=document.getElementById('startBtn');
|
||
const dot=document.getElementById('statusDot');
|
||
if(streaming){
|
||
btn.className='start-btn streaming';
|
||
btn.innerHTML='<svg width="15" height="15" 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';
|
||
dot.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(()=>{
|
||
const e=Date.now()-startTime;
|
||
const h=String(Math.floor(e/3600000)).padStart(2,'0');
|
||
const m=String(Math.floor((e%3600000)/60000)).padStart(2,'0');
|
||
const s=String(Math.floor((e%60000)/1000)).padStart(2,'0');
|
||
document.getElementById('timer').textContent=h+':'+m+':'+s;
|
||
},1000);
|
||
document.getElementById('eId').textContent=egressId;
|
||
document.getElementById('eState').textContent='ACTIVE';
|
||
document.getElementById('eState').className='ev';
|
||
document.getElementById('eStart').textContent=new Date().toISOString().slice(0,19).replace('T',' ');
|
||
toast('🔴 Streaming iniciado');
|
||
// start backend relay + WS streaming
|
||
startRelay().catch(err => { console.warn('startRelay failed', err); toast('Error iniciando relay: '+err.message, true); });
|
||
} else {
|
||
btn.className='start-btn';
|
||
btn.innerHTML='<svg width="15" height="15" 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';
|
||
dot.classList.remove('live');
|
||
document.getElementById('statusText').textContent='IDLE';
|
||
document.getElementById('liveChip').style.display='none';
|
||
document.getElementById('timerBadge').style.display='none';
|
||
clearInterval(timerInt);
|
||
document.getElementById('eState').textContent='ENDED';
|
||
document.getElementById('eState').className='ev warn';
|
||
// stop backend relay + WS streaming
|
||
stopRelaySession(egressId);
|
||
toast('⏹ Streaming detenido');
|
||
}
|
||
updateCode();
|
||
}
|
||
|
||
// Lanza el relay en el backend. POST /livekit/relay/start con el payload generado
|
||
async function startRelay() {
|
||
// Note: UI state is handled by toggleStream(); this function only starts the backend relay + ws
|
||
|
||
const payload = buildPayload();
|
||
// Construir URL RTMP destino hacia Core usando el egressId generado
|
||
const coreHost = (window.location.hostname && window.location.hostname !== '') ? window.location.hostname : 'restreamer.nextream.sytes.net';
|
||
const rtmpPort = 1935;
|
||
const rtmpApp = 'live';
|
||
const rtmpUrl = `rtmp://${coreHost}:${rtmpPort}/${rtmpApp}/${egressId}.stream`;
|
||
payload.stream_outputs = [{ urls: [rtmpUrl] }];
|
||
|
||
try {
|
||
const res = await fetch('/livekit/relay/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const json = await res.json().catch(() => ({}));
|
||
if (res.ok) {
|
||
toast('Relay solicitado: ' + (json.message || 'OK'));
|
||
// Después de registrar el relay en el backend, abrir WS y enviar MediaRecorder
|
||
openRelaySession(json.streamName || egressId).catch(err => {
|
||
console.warn('openRelaySession error:', err);
|
||
toast('No se pudo abrir sesión relay: ' + err.message, true);
|
||
});
|
||
} else {
|
||
toast('Error iniciando relay: ' + (json.message || JSON.stringify(json)), true);
|
||
}
|
||
} catch (err) {
|
||
toast('Error de red: ' + err.message, true);
|
||
}
|
||
}
|
||
|
||
// ---------- WebSocket + MediaRecorder relay (client) ----------
|
||
let _relayWs = null;
|
||
let _mediaRecorder = null;
|
||
let _canvasStream = null;
|
||
let _captureAnimation = null;
|
||
let _offscreenCanvas = null;
|
||
|
||
async function openRelaySession(roomName) {
|
||
if (!roomName) roomName = egressId;
|
||
// Open WebSocket to server relay
|
||
const wsUrl = `${location.origin.replace(/^http/, 'ws')}/webrtc-relay/${encodeURIComponent(roomName)}`;
|
||
_relayWs = new WebSocket(wsUrl);
|
||
_relayWs.binaryType = 'arraybuffer';
|
||
|
||
_relayWs.addEventListener('open', () => {
|
||
console.log('[relay] ws open');
|
||
// send config
|
||
const cfg = { type: 'config', room: roomName, videoBitrate: (+document.getElementById('cfgBitrate').value || 2500) * 1000, audioBitrate: 128000, mimeType: 'video/webm;codecs=vp8,opus' };
|
||
_relayWs.send(JSON.stringify(cfg));
|
||
});
|
||
_relayWs.addEventListener('message', (ev) => {
|
||
try { const j = JSON.parse(ev.data); console.log('[relay] msg', j); } catch(e) { }
|
||
});
|
||
_relayWs.addEventListener('close', () => { console.log('[relay] ws closed'); });
|
||
_relayWs.addEventListener('error', (err) => { console.error('[relay] ws error', err); });
|
||
|
||
// Start canvas compositor capture
|
||
startCanvasCapture();
|
||
|
||
// Start MediaRecorder on canvas stream
|
||
if (!_canvasStream) throw new Error('canvas stream not available');
|
||
const options = { mimeType: 'video/webm;codecs=vp8,opus' };
|
||
_mediaRecorder = new MediaRecorder(_canvasStream, options);
|
||
|
||
_mediaRecorder.ondataavailable = async (e) => {
|
||
if (!_relayWs || _relayWs.readyState !== WebSocket.OPEN) return;
|
||
if (e.data && e.data.size > 0) {
|
||
try {
|
||
const buf = await e.data.arrayBuffer();
|
||
_relayWs.send(buf);
|
||
} catch (err) {
|
||
console.warn('relay send error', err);
|
||
}
|
||
}
|
||
};
|
||
_mediaRecorder.onstop = () => { console.log('[relay] recorder stopped'); };
|
||
_mediaRecorder.start(1000);
|
||
console.log('[relay] MediaRecorder started');
|
||
}
|
||
|
||
function stopRelaySession(roomName) {
|
||
// stop MediaRecorder
|
||
try { if (_mediaRecorder && _mediaRecorder.state !== 'inactive') _mediaRecorder.stop(); } catch(_) {}
|
||
// stop canvas capture
|
||
try { stopCanvasCapture(); } catch(_) {}
|
||
// close ws
|
||
try { if (_relayWs) _relayWs.close(); } catch(_) {}
|
||
_relayWs = null; _mediaRecorder = null; _canvasStream = null;
|
||
// notify backend to unregister
|
||
if (!roomName) roomName = egressId;
|
||
fetch('/livekit/relay/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ roomName }) }).catch(()=>{});
|
||
}
|
||
|
||
function startCanvasCapture() {
|
||
// create offscreen canvas sized to logical composition (CW x CH)
|
||
if (_offscreenCanvas) return;
|
||
_offscreenCanvas = document.createElement('canvas');
|
||
_offscreenCanvas.width = CW; _offscreenCanvas.height = CH;
|
||
const ctx = _offscreenCanvas.getContext('2d');
|
||
|
||
// create stream
|
||
_canvasStream = _offscreenCanvas.captureStream(+document.getElementById('cfgFps').value || 30);
|
||
|
||
function draw() {
|
||
ctx.fillStyle = '#000'; ctx.fillRect(0,0,CW,CH);
|
||
// draw each layer's video element at its logical position
|
||
layers.forEach(l => {
|
||
try {
|
||
const v = l.el && l.el.querySelector && l.el.querySelector('video');
|
||
if (v && v.readyState >= 2) {
|
||
ctx.drawImage(v, l.x, l.y, l.w, l.h);
|
||
}
|
||
} catch (_) {}
|
||
});
|
||
_captureAnimation = requestAnimationFrame(draw);
|
||
}
|
||
_captureAnimation = requestAnimationFrame(draw);
|
||
}
|
||
|
||
function stopCanvasCapture() {
|
||
if (_captureAnimation) { cancelAnimationFrame(_captureAnimation); _captureAnimation = null; }
|
||
if (_offscreenCanvas) { _offscreenCanvas.width = 1; _offscreenCanvas.height = 1; _offscreenCanvas = null; }
|
||
if (_canvasStream) {
|
||
_canvasStream.getTracks().forEach(t => t.stop());
|
||
_canvasStream = null;
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════
|
||
// CODE GEN
|
||
// ════════════════════════════════════════
|
||
function buildPayload() {
|
||
return {
|
||
room_name: document.getElementById('lvkRoom').value,
|
||
layout:'custom',
|
||
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]}],
|
||
compose_layers:layers.map(l=>({
|
||
participant_identity:l.id,
|
||
x:Math.round(l.x), y:Math.round(l.y),
|
||
width:Math.round(l.w), height:Math.round(l.h),
|
||
z_index:l.z
|
||
}))
|
||
};
|
||
}
|
||
|
||
function switchTab(t,el){
|
||
currentTab=t;
|
||
document.querySelectorAll('.tab').forEach(b=>b.classList.remove('active'));
|
||
el.classList.add('active'); updateCode();
|
||
}
|
||
|
||
function updateCode() {
|
||
const p=buildPayload();
|
||
const host=document.getElementById('lvkUrl').value.replace(/wss?:\/\//,'');
|
||
const key=document.getElementById('lvkKey').value;
|
||
const layerComments=p.compose_layers.map(l=>` <span class="cm">// ${l.participant_identity}: x=${l.x} y=${l.y} ${l.width}×${l.height} z=${l.z_index}</span>`).join('\n')||' <span class="cm">// (canvas vacío)</span>';
|
||
let html='';
|
||
|
||
if(currentTab==='curl'){
|
||
const body=JSON.stringify({room_name:p.room_name,layout:p.layout,stream_outputs:p.stream_outputs,options:p.options},null,2).replace(/</g,'<').replace(/>/g,'>');
|
||
html=`<span class="cm"># Ejecutar desde tu backend (nunca expongas el secret en cliente)</span>\n\n<span class="kw">curl</span> -X POST https://<span class="str">${host}</span>/twirp/livekit.Egress/StartRoomCompositeEgress \\\n -H <span class="str">"Authorization: Bearer <JWT>"</span> \\\n -H <span class="str">"Content-Type: application/json"</span> \\\n -d '<span class="str">${body}</span>'`;
|
||
} else if(currentTab==='js'){
|
||
html=`<span class="kw">import</span> { EgressClient } <span class="kw">from</span> <span class="str">'livekit-server-sdk'</span>;\n\n<span class="kw">const</span> egress = <span class="kw">new</span> <span class="fn">EgressClient</span>(<span class="str">'https://${host}'</span>, <span class="str">'${key}'</span>, secret);\n\n<span class="kw">const</span> info = <span class="kw">await</span> egress.<span class="fn">startRoomCompositeEgress</span>(\n <span class="str">'${p.room_name}'</span>,\n { <span class="prop">stream</span>: { <span class="prop">urls</span>: [<span class="str">'${p.stream_outputs[0].urls[0]}'</span>] } },\n {\n <span class="prop">layout</span>: <span class="str">'${p.layout}'</span>,\n <span class="prop">width</span>: ${p.options.width}, <span class="prop">height</span>: ${p.options.height},\n <span class="prop">framerate</span>: ${p.options.framerate},\n <span class="prop">videoBitrate</span>: ${p.options.video_bitrate},\n<span class="cm"> /* Capas del compose (posiciones lógicas 1920×1080) */</span>\n${layerComments}\n }\n);\nconsole.<span class="fn">log</span>(<span class="str">'Egress ID:'</span>, info.egressId);`;
|
||
} else {
|
||
html=`<span class="kw">client</span> := lksdk.<span class="fn">NewEgressClient</span>(<span class="str">"https://${host}"</span>, <span class="str">"${key}"</span>, secret)\n\nreq := &lkproto.RoomCompositeEgressRequest{\n RoomName: <span class="str">"${p.room_name}"</span>,\n Layout: <span class="str">"${p.layout}"</span>,\n Output: &lkproto.RoomCompositeEgressRequest_Stream{\n Stream: &lkproto.StreamOutput{\n Urls: []<span class="kw">string</span>{<span class="str">"${p.stream_outputs[0].urls[0]}"</span>},\n },\n },\n Options: &lkproto.RoomCompositeOptions{\n Width: ${p.options.width}, Height: ${p.options.height},\n Framerate: ${p.options.framerate}, VideoBitrate: ${p.options.video_bitrate},\n },\n}<span class="cm">// compose_layers count: ${p.compose_layers.length}</span>\ninfo, _ := client.<span class="fn">StartRoomCompositeEgress</span>(ctx, req)`;
|
||
}
|
||
document.getElementById('codeContent').innerHTML=html;
|
||
document.getElementById('eOverlays').textContent=layers.length;
|
||
}
|
||
|
||
function copyCode(){ navigator.clipboard.writeText(document.getElementById('codeContent').innerText).then(()=>toast('✓ Copiado')); }
|
||
|
||
// ════════════════════════════════════════
|
||
// HELPERS
|
||
// ════════════════════════════════════════
|
||
function updateHint(){ document.getElementById('canvasHint').style.display=layers.length?'none':'flex'; }
|
||
function updateResChip(){
|
||
document.getElementById('resChip').textContent=document.getElementById('cfgW').value+'×'+document.getElementById('cfgH').value;
|
||
}
|
||
|
||
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 empty stage click
|
||
document.getElementById('stage').addEventListener('mousedown',e=>{ if(e.target===document.getElementById('stage')) deselect(); });
|
||
|
||
// INIT
|
||
window.addEventListener('load',()=>{ resizeStage(); updateCode(); });
|
||
</script>
|
||
</body>
|
||
</html>
|