phone-stream-e2e / index.html
kylsprt's picture
Update index.html
66c3e0a verified
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Intercom E2EE</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔒</text></svg>">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
<style>
:root{
--bg:#060606;
--s1:#0c0c0c;
--s2:#111;
--s3:#191919;
--b1:#1a1a1a;
--b2:#252525;
--t1:#eee;
--t2:#888;
--t3:#4a4a4a;
--g:#00e676;
--gd:rgba(0,230,118,.07);
--gb:rgba(0,230,118,.14);
--r:#ff3d57;
--rd:rgba(255,61,87,.07);
--rb:rgba(255,61,87,.18);
--bl:#448aff;
--bld:rgba(68,138,255,.07);
--blb:rgba(68,138,255,.16);
--yl:#ffc400;
--yld:rgba(255,196,0,.07);
--ylb:rgba(255,196,0,.16);
--rad:12px;
--tr:.2s cubic-bezier(.4,0,.2,1);
}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{height:100%;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',sans-serif;background:var(--bg);color:var(--t1);-webkit-font-smoothing:antialiased}
body{display:flex;justify-content:center}
.app{
width:100%;max-width:400px;height:100vh;height:100dvh;
display:flex;flex-direction:column;gap:8px;
padding:0 14px;overflow-y:auto;overflow-x:hidden;
scrollbar-width:none;
}
.app::-webkit-scrollbar{display:none}
.head{padding:40px 0 12px;text-align:center;flex-shrink:0}
.head-badge{
display:inline-flex;align-items:center;gap:6px;
padding:6px 14px;border-radius:20px;
background:var(--gd);border:1px solid var(--gb);
font-size:11px;font-weight:600;color:var(--g);
margin-bottom:14px;
}
.head-badge i{width:13px;height:13px}
.head h1{font-size:19px;font-weight:700;letter-spacing:-.3px;margin-bottom:2px}
.head p{font-size:11px;color:var(--t3)}
.c{
background:var(--s1);border:1px solid var(--b1);
border-radius:var(--rad);padding:14px;flex-shrink:0;
}
.cl{
font-size:9px;font-weight:700;text-transform:uppercase;
letter-spacing:1px;color:var(--t3);margin-bottom:10px;
display:flex;align-items:center;gap:5px;
}
.cl i{width:12px;height:12px}
.row{display:flex;gap:8px}
.inp{
flex:1;padding:11px 12px;background:var(--s2);
border:1.5px solid var(--b2);border-radius:8px;
color:var(--t1);font-size:14px;outline:none;transition:var(--tr);
}
.inp:focus{border-color:var(--g);background:var(--gd)}
.inp::placeholder{color:var(--t3)}
.inp:disabled{opacity:.3;pointer-events:none}
.btn{
display:inline-flex;align-items:center;justify-content:center;gap:6px;
padding:11px 14px;border:1px solid var(--b2);border-radius:8px;
background:var(--s2);color:var(--t2);
font-size:12px;font-weight:600;cursor:pointer;
transition:var(--tr);flex-shrink:0;
}
.btn:active{transform:scale(.95)}
.btn i{width:15px;height:15px;flex-shrink:0;pointer-events:none}
.btn:disabled{opacity:.25;pointer-events:none;transform:none}
.btn-go{background:var(--g);color:#000;border-color:var(--g)}
.btn-go:hover{background:#00f082}
.btn-go.live{background:var(--rd);color:var(--r);border-color:var(--rb)}
.status{
display:flex;align-items:center;gap:8px;
padding:10px 12px;border-radius:8px;
background:var(--s2);border:1px solid var(--b1);
}
.status-dot{
width:6px;height:6px;border-radius:50%;
background:var(--t3);flex-shrink:0;transition:var(--tr);
}
.status-dot.on{background:var(--g);box-shadow:0 0 6px var(--gb);animation:p 2s infinite}
.status-dot.err{background:var(--r)}
@keyframes p{0%,100%{opacity:1}50%{opacity:.3}}
.status-text{font-size:11px;color:var(--t2);flex:1}
.controls{display:none;flex-direction:column;gap:8px}
.controls.show{display:flex}
.mode-btn{width:100%}
.mode-btn.relay{background:var(--bld);color:var(--bl);border-color:var(--blb)}
.mode-btn.p2p{background:var(--yld);color:var(--yl);border-color:var(--ylb)}
.vid-box{
position:relative;width:100%;aspect-ratio:16/9;
background:#000;border-radius:var(--rad);overflow:hidden;
border:1px solid var(--b1);flex-shrink:0;
}
.vid-box video,.vid-box img{width:100%;height:100%;object-fit:contain;display:block}
.vid-ph{
position:absolute;inset:0;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:8px;color:var(--t3);
}
.vid-ph i{opacity:.2}
.vid-ph span{font-size:11px}
.live-pill{
position:absolute;top:8px;left:8px;display:flex;align-items:center;gap:4px;
padding:3px 9px;background:rgba(255,61,87,.8);color:#fff;
font-size:9px;font-weight:700;letter-spacing:.6px;
border-radius:5px;backdrop-filter:blur(4px);z-index:2;
}
.live-pill-dot{width:5px;height:5px;border-radius:50%;background:#fff;animation:p 1.2s infinite}
.peer-pill{
position:absolute;top:8px;right:8px;display:flex;align-items:center;gap:3px;
padding:3px 9px;background:rgba(255,255,255,.06);color:var(--t2);
font-size:9px;font-weight:600;border-radius:5px;backdrop-filter:blur(4px);z-index:2;
}
.peer-pill i{width:11px;height:11px}
.role-btn{flex:1;transition:var(--tr)}
.role-btn.active{background:var(--gd);color:var(--g);border-color:var(--gb)}
.cam-btn{}
.cam-btn i{width:15px;height:15px}
.aud-btn{flex:1}
.aud-btn.on{background:var(--gd);color:var(--g);border-color:var(--gb)}
.aud-btn.off{background:var(--rd);color:var(--r);border-color:var(--rb)}
.foot{
padding:10px 0 28px;text-align:center;flex-shrink:0;
}
.foot-e2ee{
display:inline-flex;align-items:center;gap:5px;
font-size:10px;color:var(--t3);
}
.foot-e2ee i{width:11px;height:11px;color:var(--g)}
.hidden{display:none!important}
@media(min-width:768px){
.app{max-height:unset;justify-content:center;padding:24px 14px}
.head{padding-top:0}
}
@media(max-height:560px){
.head{padding:12px 0 8px}
.head-badge{margin-bottom:8px;padding:4px 10px;font-size:10px}
.head h1{font-size:16px}
.vid-box{aspect-ratio:16/10}
}
</style>
</head>
<body>
<div class="app">
<div class="head">
<div class="head-badge"><i data-lucide="shield-check"></i>End-to-End Encrypted</div>
<h1>Intercom</h1>
<p>Audio + Video &bull; E2EE &bull; P2P Ready</p>
</div>
<div class="c">
<div class="cl"><i data-lucide="hash"></i>Room</div>
<div class="row">
<input type="text" class="inp" id="room" placeholder="Room name = encryption key" value="" autocomplete="off" spellcheck="false">
<button class="btn btn-go" id="connectBtn"><i data-lucide="plug"></i></button>
</div>
</div>
<div class="c">
<div class="status">
<div class="status-dot" id="statusDot"></div>
<span class="status-text" id="statusText">Disconnected</span>
</div>
</div>
<div class="controls" id="mainUI">
<div class="c">
<div class="cl"><i data-lucide="route"></i>Mode</div>
<button class="btn mode-btn relay" id="modeBtn"><i data-lucide="server"></i><span>Relay</span></button>
</div>
<div class="vid-box">
<div class="live-pill hidden" id="livePill"><div class="live-pill-dot"></div>LIVE</div>
<div class="peer-pill"><i data-lucide="users"></i><span id="peerCountText">0</span></div>
<div class="vid-ph" id="placeholder"><i data-lucide="video-off" style="width:28px;height:28px"></i><span>No camera active</span></div>
<video id="localVideo" autoplay muted playsinline class="hidden"></video>
<video id="remoteVideo" autoplay playsinline class="hidden"></video>
<img id="remoteImg" class="hidden" alt="">
</div>
<div class="c">
<div class="cl"><i data-lucide="user-cog"></i>Role</div>
<div class="row">
<button class="btn role-btn" id="broadcastBtn"><i data-lucide="radio"></i><span>Broadcast</span></button>
<button class="btn role-btn" id="viewBtn"><i data-lucide="eye"></i><span>View</span></button>
<button class="btn cam-btn hidden" id="camBtn"><i data-lucide="switch-camera"></i><span>Back Cam</span></button>
</div>
</div>
<div class="c">
<div class="cl"><i data-lucide="audio-lines"></i>Audio</div>
<div class="row">
<button class="btn aud-btn on" id="micBtn"><i data-lucide="mic"></i><span>Mic ON</span></button>
<button class="btn aud-btn on" id="spkBtn"><i data-lucide="volume-2"></i><span>Speaker</span></button>
</div>
</div>
</div>
<div class="foot">
<div class="foot-e2ee">
<i data-lucide="lock"></i>
<span id="e2eeText">E2EE: AES-256-GCM</span>
</div>
</div>
</div>
<script>
var $=function(id){return document.getElementById(id)};
var ws,myId,roomName,encKey;
var audioCtx,micStream,videoStream;
var captureNode,playNode,sourceNode,silentGain;
var playQueue=[];
var isMuted=false,isSpkMuted=false,isBroadcaster=false,isConnected=false,isP2P=false,p2pActive=false;
var pcs={},pendingIce={},peerList=[],broadcasterPid=null;
var videoTimer=null,sendingFrame=false,currentFacing="environment";
var reconnectTimer=null,prevImgUrl=null;
var rtcCfg=null;
var BUF=4096;
var P2P_TIMEOUT=10000;
lucide.createIcons();
$("connectBtn").onclick=function(){if(isConnected)doDisconnect();else doConnect()};
$("room").onkeydown=function(e){if(e.key==="Enter")$("connectBtn").click()};
$("modeBtn").onclick=toggleMode;
$("broadcastBtn").onclick=function(){setRole("broadcaster")};
$("viewBtn").onclick=function(){setRole("viewer")};
$("camBtn").onclick=switchCamera;
$("micBtn").onclick=toggleMic;
$("spkBtn").onclick=toggleSpk;
async function fetchICE(){
try{
var r=await fetch("/ice");
var d=await r.json();
rtcCfg={iceServers:d.iceServers,iceCandidatePoolSize:4};
}catch(e){
rtcCfg={iceServers:[{urls:"stun:stun.l.google.com:19302"},{urls:"stun:stun1.l.google.com:19302"}]}
}
}
async function deriveKey(room){
var e=new TextEncoder();
var m=await crypto.subtle.importKey("raw",e.encode(room),"PBKDF2",false,["deriveKey"]);
return crypto.subtle.deriveKey({name:"PBKDF2",salt:e.encode("e2ee-intercom-v1"),iterations:100000,hash:"SHA-256"},m,{name:"AES-GCM",length:256},false,["encrypt","decrypt"])
}
async function enc(key,data){
var iv=crypto.getRandomValues(new Uint8Array(12));
var ct=await crypto.subtle.encrypt({name:"AES-GCM",iv:iv},key,data);
var r=new Uint8Array(12+ct.byteLength);
r.set(iv,0);r.set(new Uint8Array(ct),12);return r
}
async function dec(key,data){
return crypto.subtle.decrypt({name:"AES-GCM",iv:data.slice(0,12)},key,data.slice(12))
}
async function doConnect(){
roomName=$("room").value.trim();
if(!roomName)return;
try{
await fetchICE();
encKey=await deriveKey(roomName);
micStream=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true}});
audioCtx=new(window.AudioContext||window.webkitAudioContext)();
if(audioCtx.state==="suspended")await audioCtx.resume();
setupRelayAudio();
connectWS()
}catch(e){setStatus("err","Error: "+e.message)}
}
function connectWS(){
var proto=location.protocol==="https:"?"wss:":"ws:";
ws=new WebSocket(proto+"//"+location.host+"/ws/"+roomName);
ws.binaryType="arraybuffer";
ws.onopen=function(){
isConnected=true;
setStatus("on","Connected");
$("mainUI").classList.add("show");
$("connectBtn").classList.add("live");
ri($("connectBtn"),"zap");
$("room").disabled=true;
if(isBroadcaster)ws.send(JSON.stringify({type:"set_broadcaster"}));
tryWakeLock()
};
ws.onmessage=handleMsg;
ws.onclose=function(){
if(!isConnected)return;
cleanupAllPeers();
setStatus("err","Reconnecting...");
reconnectTimer=setTimeout(connectWS,3000)
};
ws.onerror=function(){}
}
function doDisconnect(){
isConnected=false;
if(reconnectTimer){clearTimeout(reconnectTimer);reconnectTimer=null}
if(ws){ws.onclose=null;ws.close();ws=null}
stopVideo();cleanupAllPeers();stopAudio();
playQueue=[];isP2P=false;p2pActive=false;isBroadcaster=false;isMuted=false;isSpkMuted=false;
myId=null;encKey=null;broadcasterPid=null;
$("mainUI").classList.remove("show");
$("connectBtn").classList.remove("live");
ri($("connectBtn"),"plug");
$("room").disabled=false;
setStatus("","Disconnected");
resetUI()
}
function stopAudio(){
if(captureNode){captureNode.disconnect();captureNode=null}
if(playNode){playNode.disconnect();playNode=null}
if(sourceNode){sourceNode.disconnect();sourceNode=null}
if(silentGain){silentGain.disconnect();silentGain=null}
if(audioCtx){audioCtx.close();audioCtx=null}
if(micStream){micStream.getTracks().forEach(function(t){t.stop()});micStream=null}
}
function handleMsg(e){
if(typeof e.data==="string"){handleSignal(JSON.parse(e.data));return}
if(p2pActive)return;
var raw=new Uint8Array(e.data);
if(raw.length<14)return;
var tag=raw[0];
var encrypted=raw.slice(1);
dec(encKey,encrypted).then(function(plain){
if(tag===1){
var ab=new ArrayBuffer(plain.byteLength);
new Uint8Array(ab).set(new Uint8Array(plain));
var i16=new Int16Array(ab);
var f32=new Float32Array(i16.length);
for(var i=0;i<i16.length;i++)f32[i]=i16[i]/32768.0;
playQueue.push(f32);
if(playQueue.length>15)playQueue.splice(0,playQueue.length-3)
}else if(tag===2&&!isBroadcaster){
var blob=new Blob([plain],{type:"image/jpeg"});
var url=URL.createObjectURL(blob);
if(prevImgUrl)URL.revokeObjectURL(prevImgUrl);
$("remoteImg").src=url;prevImgUrl=url
}
}).catch(function(){})
}
function handleSignal(d){
var t=d.type;
if(t==="assigned"){myId=d.id}
else if(t==="state"){
peerList=d.peers;
broadcasterPid=d.broadcaster;
isBroadcaster=d.is_broadcaster;
$("peerCountText").textContent=d.peer_count;
updateRoleUI();updateVideoDisplay();
if(isP2P)syncP2P()
}
else if(t==="force_viewer"){stopVideo();isBroadcaster=false;updateRoleUI();updateVideoDisplay()}
else if(t==="peer_left"){cleanupPeer(d.id)}
else if(t==="offer")onOffer(d);
else if(t==="answer")onAnswer(d);
else if(t==="ice")onIce(d);
else if(t==="negotiate")onNegotiate(d)
}
function setupRelayAudio(){
sourceNode=audioCtx.createMediaStreamSource(micStream);
captureNode=audioCtx.createScriptProcessor(BUF,1,1);
silentGain=audioCtx.createGain();
silentGain.gain.value=0;
captureNode.onaudioprocess=function(e){
if(!ws||ws.readyState!==1||isMuted||p2pActive)return;
var pcm=e.inputBuffer.getChannelData(0);
var i16=new Int16Array(pcm.length);
for(var i=0;i<pcm.length;i++){var s=Math.max(-1,Math.min(1,pcm[i]));i16[i]=s<0?s*0x8000:s*0x7FFF}
enc(encKey,i16.buffer).then(function(ct){
var t=new Uint8Array(1+ct.length);t[0]=1;t.set(ct,1);
if(ws&&ws.readyState===1)ws.send(t)
})
};
sourceNode.connect(captureNode);
captureNode.connect(silentGain);
silentGain.connect(audioCtx.destination);
playNode=audioCtx.createScriptProcessor(BUF,1,1);
playNode.onaudioprocess=function(e){
var out=e.outputBuffer.getChannelData(0);
if(playQueue.length>0&&!isSpkMuted&&!p2pActive){
var ch=playQueue.shift();
var len=Math.min(out.length,ch.length);
for(var i=0;i<len;i++)out[i]=ch[i];
for(var i=len;i<out.length;i++)out[i]=0
}else{for(var i=0;i<out.length;i++)out[i]=0}
};
playNode.connect(audioCtx.destination)
}
async function startBroadcastVideo(){
stopVideo();
try{
videoStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:currentFacing,width:{ideal:640},height:{ideal:480}}});
$("localVideo").srcObject=videoStream;
if(!isP2P)startVideoTimer();
}catch(e){setStatus("err","Camera error")}
}
function startVideoTimer(){
if(videoTimer)clearInterval(videoTimer);
var cv=document.createElement("canvas");
var cx=cv.getContext("2d");
sendingFrame=false;
videoTimer=setInterval(function(){
if(sendingFrame||!ws||ws.readyState!==1)return;
var v=$("localVideo");if(!v.videoWidth)return;
var sc=Math.min(1,480/v.videoHeight);
cv.width=Math.floor(v.videoWidth*sc);cv.height=Math.floor(v.videoHeight*sc);
cx.drawImage(v,0,0,cv.width,cv.height);
sendingFrame=true;
cv.toBlob(function(blob){
if(!blob||!ws||ws.readyState!==1){sendingFrame=false;return}
blob.arrayBuffer().then(function(buf){
enc(encKey,buf).then(function(ct){
var d=new Uint8Array(1+ct.length);d[0]=2;d.set(ct,1);
if(ws&&ws.readyState===1)ws.send(d);
sendingFrame=false
})
})
},"image/jpeg",0.5)
},100)
}
function stopVideo(){
if(videoTimer){clearInterval(videoTimer);videoTimer=null}
if(videoStream){videoStream.getTracks().forEach(function(t){t.stop()});videoStream=null}
$("localVideo").srcObject=null;sendingFrame=false
}
function makePC(pid){
if(pcs[pid])return pcs[pid];
var pc=new RTCPeerConnection(rtcCfg);
pcs[pid]=pc;
pendingIce[pid]=[];
if(micStream){
micStream.getTracks().forEach(function(t){pc.addTrack(t,micStream)})
}
if(isBroadcaster&&videoStream){
videoStream.getTracks().forEach(function(t){pc.addTrack(t,videoStream)})
}
pc.onicecandidate=function(e){
if(e.candidate&&ws&&ws.readyState===1){
ws.send(JSON.stringify({type:"ice",to:pid,candidate:e.candidate}))
}
};
pc.ontrack=function(e){
if(e.track.kind==="audio"){
var a=new Audio();
a.srcObject=e.streams[0];
a.muted=isSpkMuted;
a.play().catch(function(){});
pc._audio=a
}else if(e.track.kind==="video"){
$("remoteVideo").srcObject=e.streams[0];
$("remoteVideo").play().catch(function(){});
updateVideoDisplay()
}
};
pc.onconnectionstatechange=function(){
var s=pc.connectionState;
if(s==="connected"){
p2pActive=true;
playQueue=[];
setStatus("on","P2P Connected");
updateModeUI()
}else if(s==="failed"){
p2pFailed(pid)
}else if(s==="closed"||s==="disconnected"){
cleanupPeer(pid)
}
};
pc.onicecandidateerror=function(){};
var timeout=setTimeout(function(){
if(pc.connectionState!=="connected"){
p2pFailed(pid)
}
},P2P_TIMEOUT);
pc._timeout=timeout;
return pc
}
function p2pFailed(pid){
if(ws&&ws.readyState===1){
ws.send(JSON.stringify({type:"p2p_failed"}))
}
cleanupPeer(pid);
if(Object.keys(pcs).length===0&&isP2P){
isP2P=false;
p2pActive=false;
setStatus("on","P2P failed \u2192 Relay");
updateModeUI();
if(isBroadcaster&&videoStream)startVideoTimer()
}
}
function flushIce(pid){
if(!pendingIce[pid]||!pcs[pid])return;
var buf=pendingIce[pid];
pendingIce[pid]=[];
for(var i=0;i<buf.length;i++){
try{pcs[pid].addIceCandidate(buf[i]).catch(function(){})}catch(e){}
}
}
async function syncP2P(){
if(!isP2P)return;
for(var i=0;i<peerList.length;i++){
var pid=peerList[i];
if(pid===myId||pcs[pid])continue;
if(myId>pid){
var pc=makePC(pid);
try{
var offer=await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({type:"offer",to:pid,sdp:{type:pc.localDescription.type,sdp:pc.localDescription.sdp}}))
}catch(e){cleanupPeer(pid)}
}
}
var active=Object.keys(pcs);
for(var j=0;j<active.length;j++){
if(peerList.indexOf(active[j])===-1)cleanupPeer(active[j])
}
}
async function onOffer(d){
if(!isP2P)return;
var pc=makePC(d.from);
try{
await pc.setRemoteDescription(new RTCSessionDescription(d.sdp));
flushIce(d.from);
var ans=await pc.createAnswer();
await pc.setLocalDescription(ans);
ws.send(JSON.stringify({type:"answer",to:d.from,sdp:{type:pc.localDescription.type,sdp:pc.localDescription.sdp}}))
}catch(e){cleanupPeer(d.from)}
}
async function onAnswer(d){
var pc=pcs[d.from];
if(!pc)return;
try{
await pc.setRemoteDescription(new RTCSessionDescription(d.sdp));
flushIce(d.from)
}catch(e){cleanupPeer(d.from)}
}
async function onIce(d){
var pc=pcs[d.from];
if(!pc){
if(!pendingIce[d.from])pendingIce[d.from]=[];
pendingIce[d.from].push(d.candidate);
return
}
if(!pc.remoteDescription||!pc.remoteDescription.type){
if(!pendingIce[d.from])pendingIce[d.from]=[];
pendingIce[d.from].push(d.candidate);
return
}
try{await pc.addIceCandidate(d.candidate)}catch(e){}
}
async function onNegotiate(d){
var pc=pcs[d.from];
if(!pc)return;
try{
await pc.setRemoteDescription(new RTCSessionDescription(d.sdp));
flushIce(d.from);
if(d.sdp.type==="offer"){
var ans=await pc.createAnswer();
await pc.setLocalDescription(ans);
ws.send(JSON.stringify({type:"negotiate",to:d.from,sdp:{type:pc.localDescription.type,sdp:pc.localDescription.sdp}}))
}
}catch(e){}
}
function cleanupPeer(pid){
var pc=pcs[pid];
if(!pc)return;
if(pc._timeout)clearTimeout(pc._timeout);
if(pc._audio){pc._audio.pause();pc._audio.srcObject=null}
try{pc.close()}catch(e){}
delete pcs[pid];
delete pendingIce[pid];
if($("remoteVideo").srcObject){
var tracks=$("remoteVideo").srcObject.getTracks();
var alive=false;
for(var i=0;i<tracks.length;i++){if(tracks[i].readyState==="live")alive=true}
if(!alive)$("remoteVideo").srcObject=null
}
if(Object.keys(pcs).length===0)p2pActive=false;
updateVideoDisplay()
}
function cleanupAllPeers(){
var keys=Object.keys(pcs);
for(var i=0;i<keys.length;i++)cleanupPeer(keys[i]);
$("remoteVideo").srcObject=null;
p2pActive=false
}
function toggleMode(){
if(isP2P){
isP2P=false;
p2pActive=false;
cleanupAllPeers();
playQueue=[];
if(isBroadcaster&&videoStream)startVideoTimer();
setStatus("on","Relay mode")
}else{
isP2P=true;
if(videoTimer){clearInterval(videoTimer);videoTimer=null}
playQueue=[];
setStatus("on","P2P connecting...");
syncP2P()
}
updateModeUI();updateVideoDisplay()
}
async function setRole(role){
if(!ws||ws.readyState!==1)return;
if(role==="broadcaster"){
isBroadcaster=true;
await startBroadcastVideo();
ws.send(JSON.stringify({type:"set_broadcaster"}));
if(isP2P){
cleanupAllPeers();
setTimeout(syncP2P,500)
}
}else{
isBroadcaster=false;
stopVideo();
ws.send(JSON.stringify({type:"set_viewer"}));
if(isP2P){
cleanupAllPeers();
setTimeout(syncP2P,500)
}
}
updateRoleUI();updateVideoDisplay()
}
function toggleMic(){
isMuted=!isMuted;
if(micStream)micStream.getAudioTracks().forEach(function(t){t.enabled=!isMuted});
updateMicUI()
}
function toggleSpk(){
isSpkMuted=!isSpkMuted;
var keys=Object.keys(pcs);
for(var i=0;i<keys.length;i++){
var pc=pcs[keys[i]];
if(pc._audio)pc._audio.muted=isSpkMuted
}
updateSpkUI()
}
async function switchCamera(){
currentFacing=currentFacing==="environment"?"user":"environment";
if(isBroadcaster){
await startBroadcastVideo();
if(isP2P){
var keys=Object.keys(pcs);
for(var i=0;i<keys.length;i++){
var pc=pcs[keys[i]];
if(!pc||!videoStream)continue;
var newTrack=videoStream.getVideoTracks()[0];
var senders=pc.getSenders();
for(var j=0;j<senders.length;j++){
if(senders[j].track&&senders[j].track.kind==="video"){
try{await senders[j].replaceTrack(newTrack)}catch(e){}
}
}
}
}
}
updateCamUI()
}
function se(id,show){$(id).classList.toggle("hidden",!show)}
function updateVideoDisplay(){
if(isBroadcaster){
se("localVideo",true);se("remoteVideo",false);se("remoteImg",false);se("placeholder",false);se("livePill",true)
}else if(broadcasterPid){
se("localVideo",false);se("livePill",true);se("placeholder",false);
if(isP2P||p2pActive){
se("remoteImg",false);
se("remoteVideo",!!$("remoteVideo").srcObject)
}else{
se("remoteVideo",false);
se("remoteImg",true)
}
}else{
se("localVideo",false);se("remoteVideo",false);se("remoteImg",false);se("livePill",false);se("placeholder",true)
}
}
function updateRoleUI(){
$("broadcastBtn").classList.toggle("active",isBroadcaster);
$("viewBtn").classList.toggle("active",!isBroadcaster);
se("camBtn",isBroadcaster)
}
function updateModeUI(){
var b=$("modeBtn");
var active=isP2P||p2pActive;
b.classList.toggle("p2p",active);
b.classList.toggle("relay",!active);
b.querySelector("span").textContent=active?"P2P":"Relay";
ri(b,active?"wifi":"server");
$("e2eeText").textContent=active?"E2EE: DTLS-SRTP":"E2EE: AES-256-GCM"
}
function updateMicUI(){
var b=$("micBtn");
b.classList.toggle("off",isMuted);b.classList.toggle("on",!isMuted);
b.querySelector("span").textContent=isMuted?"Mic OFF":"Mic ON";
ri(b,isMuted?"mic-off":"mic")
}
function updateSpkUI(){
var b=$("spkBtn");
b.classList.toggle("off",isSpkMuted);b.classList.toggle("on",!isSpkMuted);
b.querySelector("span").textContent=isSpkMuted?"Muted":"Speaker";
ri(b,isSpkMuted?"volume-x":"volume-2")
}
function updateCamUI(){
$("camBtn").querySelector("span").textContent=currentFacing==="environment"?"Back Cam":"Front Cam"
}
function resetUI(){
se("localVideo",false);se("remoteVideo",false);se("remoteImg",false);se("placeholder",true);se("livePill",false);se("camBtn",false);
$("broadcastBtn").classList.remove("active");$("viewBtn").classList.remove("active");
isMuted=false;isSpkMuted=false;isP2P=false;p2pActive=false;
updateMicUI();updateSpkUI();updateModeUI()
}
function setStatus(state,text){
var d=$("statusDot");d.className="status-dot";
if(state==="on")d.classList.add("on");
if(state==="err")d.classList.add("err");
$("statusText").textContent=text
}
function ri(el,name){
var i=el.querySelector("i[data-lucide]");
if(i){i.setAttribute("data-lucide",name);lucide.createIcons({nodes:[el]})}
}
setInterval(function(){if(isConnected)fetch("/ping").catch(function(){})},55000);
setInterval(function(){if(ws&&ws.readyState===1)ws.send(new Uint8Array([0]))},25000);
document.addEventListener("visibilitychange",function(){
if(document.visibilityState==="visible"&&isConnected){
if(audioCtx&&audioCtx.state==="suspended")audioCtx.resume();
if(!ws||ws.readyState>1)connectWS()
}
});
async function tryWakeLock(){try{if("wakeLock" in navigator)await navigator.wakeLock.request("screen")}catch(e){}}
(function(){document.addEventListener("click",function o(){try{var c=new(window.AudioContext||window.webkitAudioContext)();var s=c.createOscillator();var g=c.createGain();g.gain.value=0.00001;s.connect(g);g.connect(c.destination);s.start()}catch(e){}document.removeEventListener("click",o)})})();
(function(){var v=document.createElement("video");v.setAttribute("playsinline","");v.setAttribute("muted","");v.style.cssText="position:fixed;top:-1px;left:-1px;width:1px;height:1px;opacity:0.01";var b=new Blob([new Uint8Array([0,0,0,28,102,116,121,112,105,115,111,109,0,0,2,0,105,115,111,109,105,115,111,50,109,112,52,49,0,0,0,8,102,114,101,101,0,0,2,239,109,100,97,116,0,0,0,0])],{type:"video/mp4"});v.src=URL.createObjectURL(b);document.body.appendChild(v);document.addEventListener("click",function(){v.play().catch(function(){})},{once:true})})();
</script>
</body>
</html>