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>
This commit is contained in:
@@ -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(){
|
||||
|
||||
Reference in New Issue
Block a user