Compare commits

...

3 Commits

Author SHA1 Message Date
Sravan 620039a2ff iOS remote support: Share Screen -> Connect Screen over LiveKit (core)
Rebuild the iOS remote-support screen share the RIGHT way: keep the exact
Share/Connect UX + session (consent + symmetric session-ended), swap only the
media to LiveKit since WKWebView can't getDisplayMedia. (Replaces the earlier
"route to a meeting" detour, which was reverted.)

Flow: customer taps Share Screen -> gets a 6-digit code (unchanged UI) -> helper
enters it in Connect Screen -> on the customer's "Allow", the app publishes the
screen natively (ReplayKit -> LiveKit) into a per-session room and tells the
agent it's a LiveKit session -> connect.html joins that room and shows the screen
in its existing viewer (recording/controls intact). Chat runs over the session
socket (no P2P data channel in this mode). Either side ending fires the existing
session-ended -> both tear down (symmetric disconnect).

- signaling.js: relay 'rs-livekit' + 'rs-chat' between the two ends.
- home.html: parent bridge so the /share iframe can drive startMeetingScreenShare/
  stop on the native plugin (+ a capability handshake).
- share.html (iOS): publish via native LiveKit instead of getDisplayMedia; chat
  over WS; hide mic (voice = next iteration) + remote-control (impossible on iOS).
- connect.html: LiveKit viewer for iOS-shared sessions, reusing the P2P viewer.

Web-only, no new build (reuses the shipped startMeetingScreenShare). Desktop
Share/Connect P2P unchanged. Two-way voice is the planned follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 13:16:06 +05:30
Sravan 224a2800c5 Revert "iOS "Share Screen": route to a screen-share meeting (remote-support on iPhone)"
This reverts commit eb685b42ca.
2026-08-22 12:21:36 +05:30
Sravan ba51a3c5d7 Revert "iOS Share Screen: show the code and wait; start sharing when the helper joins"
This reverts commit 1a089c0349.
2026-08-22 12:21:36 +05:30
4 changed files with 70 additions and 46 deletions
+27 -1
View File
@@ -106,6 +106,26 @@ const card=document.getElementById('card'), wrap=document.getElementById('wrap')
agentChip=document.getElementById('agentChip'), bar=document.getElementById('bar'),
topbar=document.getElementById('topbar'), video=document.getElementById('video'), barStatus=document.getElementById('barStatus');
let ws,pc,inputChannel,chatChannel,sessionId,me=null;
let RS_LK=false, lkRoom=null; // iOS-shared session: view the screen over LiveKit instead of P2P
// Load the LiveKit browser SDK on demand (same vendored build the meeting UI uses).
function sfuLoadLib(){ return new Promise((res,rej)=>{ if(window.LivekitClient) return res(window.LivekitClient); const s=document.createElement('script'); s.src='/vendor/livekit-client.umd.min.js'; s.onload=()=>res(window.LivekitClient); s.onerror=()=>rej(new Error('livekit sdk failed to load')); document.head.appendChild(s); }); }
// The customer is on iPhone (WKWebView can't getDisplayMedia), so they publish their screen over LiveKit.
// Join that room and show the screen in the SAME viewer we use for P2P (recording/chat/controls unchanged).
async function startLiveKitView(room){
const statusEl=document.getElementById('status');
if(statusEl){ statusEl.className='status'; statusEl.innerHTML='<img src="/loaders/loader-orbit.svg" width="20" height="20" style="vertical-align:-5px;margin-right:7px" alt="">Connecting to the shared screen…'; }
try{
const LK=await sfuLoadLib();
const tk=await fetch('/api/meetings/token',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({room})}).then(r=>r.json());
if(!tk||!tk.token) throw new Error('no token');
const r=new LK.Room({adaptiveStream:false,dynacast:false}); lkRoom=r;
const showVideo=(mst)=>{ video.srcObject=new MediaStream([mst]); if(typeof wrap!=='undefined'&&wrap) wrap.style.display='none'; if(typeof topbar!=='undefined'&&topbar) topbar.style.display='none'; video.style.display='block'; try{ video.play(); }catch(_){}; try{ video.focus(); }catch(_){}; buildBar(); };
const attach=(track)=>{ if(!track) return; const mst=track.mediaStreamTrack; if(track.kind==='video'){ showVideo(mst); } else { let a=document.getElementById('remoteAudio'); if(!a){ a=document.createElement('audio'); a.id='remoteAudio'; a.autoplay=true; document.body.appendChild(a); } a.srcObject=new MediaStream([mst]); } };
r.on(LK.RoomEvent.TrackSubscribed,(track)=>attach(track));
await r.connect(tk.url, tk.token);
try{ r.remoteParticipants.forEach((p)=>{ p.trackPublications.forEach((pub)=>{ if(pub.track) attach(pub.track); }); }); }catch(_){}
}catch(e){ if(statusEl){ statusEl.className='status err'; statusEl.textContent='Could not connect to the shared screen.'; } }
}
async function api(path,body,method='POST'){
const opt={method,headers:{'Content-Type':'application/json'}};
@@ -191,6 +211,8 @@ function connectWS(){
const ans=await pc.createAnswer(); await pc.setLocalDescription(ans);
ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break;
case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
case 'rs-livekit': RS_LK=true; startLiveKitView(m.room); break; // iOS customer shares over LiveKit — view it there
case 'rs-chat': if(m.msg) addChat({from:'other',name:m.msg.name||'Customer',text:m.msg.text}); break; // chat over the socket in LiveKit mode
case 'transcript': if(recogActive&&m.text) addLine('customer', m.name||'Customer', m.text, !!m.chat); break;
case 'session-denied': renderEnded('The customer declined the request.'); break;
case 'session-ended': {
@@ -213,6 +235,7 @@ function renderWaiting(){
function renderEnded(msg){
bzcSession(false);
try{ if(lkRoom){ lkRoom.disconnect(); lkRoom=null; } }catch(_){} // tear down the LiveKit view (symmetric disconnect)
try{ stopRecording(); }catch(_){}
removeSessionUI();
document.body.classList.remove('has-bar');
@@ -385,7 +408,10 @@ let __ac=null;
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){}
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:(me&&(me.name||me.email))||'Support agent',text:t}));}addChat({from:'__self',name:'You',text:t});if(recogActive)addLine('agent',(me&&(me.name||me.email))||'Agent',t,true);i.value='';}
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;
if(RS_LK){ try{ ws.send(JSON.stringify({type:'rs-chat',sessionId,msg:{name:(me&&(me.name||me.email))||'Support agent',text:t}})); }catch(_){} }
else if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:(me&&(me.name||me.email))||'Support agent',text:t}));}
addChat({from:'__self',name:'You',text:t});if(recogActive)addLine('agent',(me&&(me.name||me.email))||'Agent',t,true);i.value='';}
function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
async function setupPeer(){
+6 -40
View File
@@ -6153,9 +6153,6 @@ async function onMeetMsg(e){
if(_pendingJoinMic){ _pendingJoinMic=false; if(!meetMic){ try{ await toggleMic(); }catch(_){} } }
if(_pendingJoinCam){ _pendingJoinCam=false; if(!meetCam){ try{ await toggleCam(); }catch(_){} } }
refreshMeetPanel(); updateHostControls();
// iOS "Share Screen" entry: show the code + a "waiting for them to join" screen. The screen broadcast
// only STARTS once the helper joins (see meeting-peer-joined) — matching the remote-support mental model.
if(_shareWaitMode){ showShareWaitOverlay(meetRoom); }
return;
}
// The room is gone (host ended it / code expired). Say so plainly instead of hanging on "Connecting…"
@@ -6176,8 +6173,6 @@ async function onMeetMsg(e){
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
if(meetIsHost){ meetSend({type:'meeting-host', to:meetHostId}); meetSend({type:'meeting-sharemode', multi:meetMultiShare}); if(meetRec) meetSend({type:'meeting-recording', on:true}); }
if(meetScreen) meetSend({type:'meeting-screen', on:true});
// iOS "Share Screen": the helper just joined → dismiss the waiting screen and start the screen broadcast now.
if(_shareWaitMode){ _shareWaitMode=false; hideShareWaitOverlay(); setTimeout(()=>{ try{ toggleScreen(); }catch(_){} }, 300); }
refreshMeetPanel(); return;
}
if(m.type==='meeting-peer-state'){ setTileMute(m.peerId, !!m.muted); meetCamOff.set(m.peerId, !!m.camOff); const _t=document.getElementById('meet-tile-'+m.peerId); if(_t){ const v=_t.querySelector('video'), s=v&&v.srcObject; const hv=!!(s&&s.getVideoTracks&&s.getVideoTracks().some(tr=>tr.enabled&&tr.readyState!=='ended')); _t.classList.toggle('novid', !hv || (!!m.camOff && !meetSharers.has(m.peerId))); } refreshMeetPanel(); return; } // camOff -> avatar, unless sharing a screen (#9)
@@ -6316,42 +6311,7 @@ const panels=document.querySelectorAll('.panel');
const chatcol=document.getElementById('chatcol');
let loaded={share:false,connect:false};
function currentTab(){ const b=document.querySelector('.railbtn.active'); return b?b.dataset.tab:'chat'; }
let _shareWaitMode=false;
// iOS "Share Screen": WKWebView can't capture the P2P remote-support flow, so route it to a screen-share
// MEETING — the native ReplayKit path publishes into LiveKit, and a helper joins by code to watch (+ talk/chat).
// (Viewing a shared screen already works in the webview; only capture is blocked, so only the SHARE side moves.)
// UX: show the code + "waiting" first; screen sharing only STARTS once the helper joins (like remote support).
function startIosScreenShareMeeting(){
const NC=nativeCallPlugin();
if(!NC || typeof NC.startMeetingScreenShare!=='function'){ switchTab('meeting'); toast('Update the app to share your screen'); return; }
if(meetState==='call'){ switchTab('meeting'); toast('Youre already in a meeting — tap Share screen there.'); return; }
_shareWaitMode=true;
switchTab('meeting');
enterMeeting(null); // instant meeting; on join we show the code + wait, then auto-share when the helper joins
}
// Full-screen "give this code, waiting to join" screen shown to the iOS sharer before anyone connects.
function showShareWaitOverlay(code){
hideShareWaitOverlay();
const ov=document.createElement('div'); ov.id='shareWaitOv';
ov.style.cssText='position:fixed;inset:0;z-index:9200;background:linear-gradient(180deg,#20396f,#16294f);color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:24px;text-align:center';
ov.innerHTML='<div style="max-width:360px;width:100%">'
+'<div style="font-size:1.35rem;font-weight:700;margin-bottom:.35rem">Share your screen</div>'
+'<div style="color:#c9d4ec;font-size:.95rem;line-height:1.5;margin-bottom:1.6rem">Give this code to the person who will view your screen. Sharing starts automatically when they join.</div>'
+'<div style="background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.18);border-radius:16px;padding:1.2rem 1rem;margin-bottom:1.3rem">'
+ '<div style="font-size:.72rem;letter-spacing:.14em;color:#9fb2d8;text-transform:uppercase;margin-bottom:.55rem">Your code</div>'
+ '<div style="font-size:2.6rem;font-weight:800;letter-spacing:.28em;font-variant-numeric:tabular-nums">'+pEsc(code)+'</div>'
+ '<button id="shareWaitCopy" style="margin-top:1rem;background:#FFC708;color:#16294f;border:none;border-radius:10px;padding:.55rem 1.1rem;font-weight:700;font-size:.9rem;cursor:pointer">Copy code</button>'
+'</div>'
+'<div style="display:flex;align-items:center;justify-content:center;gap:.5rem;color:#c9d4ec;font-size:.9rem;margin-bottom:1.6rem"><img src="/loaders/loader-ring.svg" width="22" height="22" alt=""> Waiting for them to join…</div>'
+'<button id="shareWaitCancel" style="background:transparent;border:1px solid rgba(255,255,255,.4);color:#fff;border-radius:10px;padding:.55rem 1.3rem;font-size:.9rem;cursor:pointer">Cancel</button>'
+'</div>';
document.body.appendChild(ov);
const cp=ov.querySelector('#shareWaitCopy'); if(cp) cp.onclick=async()=>{ try{ await navigator.clipboard.writeText(code); cp.textContent='Copied!'; setTimeout(()=>{ cp.textContent='Copy code'; },1500); }catch(_){} };
const cx=ov.querySelector('#shareWaitCancel'); if(cx) cx.onclick=()=>{ hideShareWaitOverlay(); _shareWaitMode=false; try{ leaveMeeting(true); }catch(_){} };
}
function hideShareWaitOverlay(){ const o=document.getElementById('shareWaitOv'); if(o) o.remove(); }
function switchTab(tab){
if(tab==='share' && SFU.on && bzIsIOS()){ startIosScreenShareMeeting(); return; } // iOS: screen-share via a meeting, not the P2P iframe
railBtns.forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));
panels.forEach(p=>p.classList.toggle('active',p.dataset.panel===tab));
chatcol.classList.toggle('hidden', tab!=='chat');
@@ -6627,6 +6587,12 @@ async function doRegister(){
window.addEventListener('message',(e)=>{
if(e.origin!==location.origin) return; const d=e.data||{}; const n=window.bizConnectNative;
if(d.type==='rc-ping'){ const desktop=!!(n&&n.rcAvailable&&n.rcAvailable()); try{ e.source&&e.source.postMessage({type:'rc-pong', desktop}, location.origin); }catch(_){} return; }
// iOS remote-support: the /share iframe can't reach the native plugin, so it asks THIS top frame to
// publish the screen over LiveKit (WKWebView can't getDisplayMedia). Answer the capability handshake and
// drive startMeetingScreenShare/stopMeetingScreenShare (the same native method meetings use).
if(d.type==='bzc-native-ping'){ const ok=bzIsIOS() && !!(nativeCallPlugin() && nativeCallPlugin().startMeetingScreenShare); try{ e.source&&e.source.postMessage({type:'bzc-native', ok}, location.origin); }catch(_){} return; }
if(d.type==='rs-native-share'){ (async()=>{ try{ const NC=nativeCallPlugin(); if(!NC||!NC.startMeetingScreenShare||!d.room) return; const tk=await postJSON('/api/meetings/token',{ room:d.room, screen:true }); await NC.startMeetingScreenShare({ url:(tk.url||SFU.url), token:tk.token }); }catch(_){} })(); return; }
if(d.type==='rs-native-stop'){ try{ const NC=nativeCallPlugin(); if(NC&&NC.stopMeetingScreenShare) NC.stopMeetingScreenShare(); }catch(_){} return; }
if(!n) return;
if(d.type==='rc-arm'){ try{ n.rcArm&&n.rcArm(!!d.on); }catch(_){} return; }
if(d.type==='rc-input'){ try{ n.rcInput&&n.rcInput(d.evt); }catch(_){} return; }
+27 -5
View File
@@ -106,6 +106,10 @@ let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
let SHARER_NAME='Customer';
try{fetch('/api/me').then(r=>r.ok?r.json():null).then(m=>{if(m&&(m.name||m.email))SHARER_NAME=m.name||m.email;}).catch(()=>{});}catch(_){}
const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||'');
// iOS app: WKWebView can't getDisplayMedia. Ask the top frame (home.html) if it can publish the screen
// natively (ReplayKit -> LiveKit). If so, we run this session over LiveKit instead of P2P.
let NATIVE_IOS=false, RS_ROOM=null;
try{ if(window.parent && window.parent!==window){ window.addEventListener('message',(e)=>{ if(e.origin!==location.origin) return; const d=e.data||{}; if(d.type==='bzc-native'){ NATIVE_IOS=!!d.ok; } }); window.parent.postMessage({type:'bzc-native-ping'}, location.origin); } }catch(_){}
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
@@ -138,6 +142,7 @@ ws.onmessage=async(e)=>{const m=JSON.parse(e.data);switch(m.type){
case 'answer': if(pc) await pc.setRemoteDescription(new RTCSessionDescription(m.sdp)); break;
case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
case 'recording': recNotice(m.on); if(m.on) startCustTranscription(); else stopCustTranscription(); break;
case 'rs-chat': if(m.msg) addChat({from:'other', name:m.msg.name||'Agent', text:m.msg.text}); break; // chat over the socket in LiveKit mode (no P2P data channel)
case 'session-ended': endShareSession('Your support agent ended the session. Tap below for a new code if you still need help.'); break;
case 'error': setStatus(m.message,''); break;
}};
@@ -169,6 +174,7 @@ function showConsent(m){
// getDisplayMedia unless it is called from a user gesture, so this must not run
// after a server round-trip. getDisplayMedia is called first to keep the gesture.
async function beginCapture(){
if(NATIVE_IOS){ return true; } // iOS: the app captures via ReplayKit on start-stream (no getDisplayMedia in WKWebView)
try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
catch(err){ return false; }
// Mic is OFF by default — we do NOT prompt for it here. Asking for the screen and the
@@ -178,6 +184,18 @@ async function beginCapture(){
return true;
}
async function startStreaming(){
// iOS: publish the screen NATIVELY over LiveKit (WKWebView can't getDisplayMedia). Tell the app to start the
// ReplayKit broadcast into a room derived from this session, and tell the agent to view over LiveKit.
if(NATIVE_IOS){
RS_ROOM='rs'+String(sessionId||'').replace(/[^A-Za-z0-9]/g,'').slice(0,60);
try{ window.parent.postMessage({type:'rs-native-share', room:RS_ROOM}, location.origin); }catch(_){}
try{ ws.send(JSON.stringify({type:'rs-livekit', sessionId, room:RS_ROOM})); }catch(_){}
indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on'); bzcSession(true);
{ const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
window.onbeforeunload=function(){ if(!sessionOver){ return 'Leaving this page will end your screen sharing session.'; } };
buildBar();
return;
}
// If the Allow tap already captured the screen (mobile path), reuse it.
if(!localStream){
await ensureIce();
@@ -320,6 +338,7 @@ function recNotice(on){
} else { clearInterval(recTimerInt); recTimerInt=null; if(n) n.remove(); }
}
function endShareSession(msgText){
if(NATIVE_IOS){ try{ window.parent.postMessage({type:'rs-native-stop'}, location.origin); }catch(_){} } // stop the native ReplayKit broadcast + LiveKit
try{ rcStopControl(); }catch(_){} // release remote control when the session ends
sessionOver=true; window.onbeforeunload=null; bzcSession(false); { const hl=document.getElementById('homeLink'); if(hl) hl.style.display=''; } try{recNotice(false);stopCustTranscription();}catch(_){}
removeSessionUI();
@@ -330,7 +349,7 @@ function endShareSession(msgText){
var card=document.querySelector('.panelside .card');
if(card){ card.innerHTML='<h1 style="color:var(--blue)">Session ended</h1><div class="sub">'+esc(msgText||'The session has ended.')+'</div><button onclick="location.reload()" style="width:100%;margin-top:.4rem">Get a new code</button>'; }
}
function teardown(){try{rcStopControl();}catch(_){}sessionOver=true;window.onbeforeunload=null;bzcSession(false);{const hl=document.getElementById('homeLink');if(hl)hl.style.display='';}try{recNotice(false);stopCustTranscription();}catch(_){}indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');}
function teardown(){if(NATIVE_IOS){try{window.parent.postMessage({type:'rs-native-stop'},location.origin);}catch(_){}}try{rcStopControl();}catch(_){}sessionOver=true;window.onbeforeunload=null;bzcSession(false);{const hl=document.getElementById('homeLink');if(hl)hl.style.display='';}try{recNotice(false);stopCustTranscription();}catch(_){}indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');}
let chatOpen=false;
const SVG_MIC='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
@@ -349,10 +368,10 @@ function buildBar(){
const rcb=_btn('rcBtn',I('monitor'),'Remote control is OFF','#6b7280');
const chat=_btn('chatBtn',I('chat'),'Chat','#475569');
const end=_btn('endBtn2',I('callEnd'),'End','#dc2626');
bar.appendChild(mic);bar.appendChild(rcb);bar.appendChild(chat);bar.appendChild(end);
if(!NATIVE_IOS){ bar.appendChild(mic); bar.appendChild(rcb); } // iOS: two-way voice is a follow-up; remote control is impossible on iOS
bar.appendChild(chat);bar.appendChild(end);
document.body.appendChild(bar);
rcb.onclick=()=>{ if(rcAllowed) rcStopControl(); else rcGrantControl(); };
updateRcBtn();
if(!NATIVE_IOS){ rcb.onclick=()=>{ if(rcAllowed) rcStopControl(); else rcGrantControl(); }; updateRcBtn(); }
makeBarDraggable(bar,'bzc_sharebar_pos'); // new #4: let the customer move the bar off their content
const setMic=(on)=>{mic.title=on?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+I(on?'mic':'micOff')+'</span>';mic.style.background=on?'#2563eb':'#6b7280';};
mic.onclick=async()=>{
@@ -391,7 +410,10 @@ let __ac=null;
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){}
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:SHARER_NAME,text:t}));}addChat({from:'__self',name:'You',text:t});i.value='';}
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;
if(NATIVE_IOS){ try{ ws.send(JSON.stringify({type:'rs-chat',sessionId,msg:{name:SHARER_NAME,text:t}})); }catch(_){} }
else if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:SHARER_NAME,text:t}));}
addChat({from:'__self',name:'You',text:t});i.value='';}
function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
+10
View File
@@ -373,6 +373,16 @@ async function handle(ws, m, req) {
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
break;
}
// iOS remote-support over LiveKit: 'rs-livekit' tells the OTHER end this session's media runs over a
// LiveKit room (WKWebView can't getDisplayMedia); 'rs-chat' carries chat since there's no P2P data
// channel in that mode. Relayed between the two ends exactly like offer/answer/transcript.
case 'rs-livekit': case 'rs-chat': {
const sess = liveSessions.get(m.sessionId || ws.sessionId);
if (!sess) return;
const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
break;
}
case 'end-session': {
await endSession(ws.sessionId, m.reason || null);
break;