Spaces:
Running
Running
| <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 • E2EE • 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> |