Native calls: open the REAL meeting window (join the mesh), drop the bespoke overlay
The custom call overlay was wrong — the native call must use the app's actual
meeting UI. Fix: a native call now JOINS the mesh room like any participant, so
the caller/callee tiles, roster, mute state and the whole answer/end lifecycle
run through the existing (tested) meeting code. The only native-specific bit is
meetNative=true → the WebView does NOT open its own SFU media connection (the
plugin already owns this identity's one LiveKit connection); mic/hang-up bridge
to the plugin. This fixes, via existing server code, all the reported bugs:
- "no meeting window" → the real meeting window opens on answer/outgoing.
- "caller stuck Ringing after pickup" → mesh peer-join clears the waiting tile
and finishMeetingJoin marks the call answered.
- "call still running after the other side hung up" → mesh leave ends the DM
for both (signaling leaveMeeting); plus an idempotent endDmCallByRoom backup
kicks a stuck peer when the ending side's WebSocket is down.
- "accept on one device doesn't stop the other" → markDmAnswered (fired on mesh
join) emits call-taken to the user's other sockets; deliverLocal fans to all.
- "second-device accept wins / collision" → the other device's ring is dismissed
so it can't double-join the same identity.
home.html: enterMeeting(code, audioOnly, {native, uuid}); skip sfuConnect when
native; toggleMic->plugin; toggleCam blocked (video is the next phase); leave ->
callkitEnd (guarded against the plugin's endCall re-firing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -137,6 +137,11 @@ async function endDmCallByRoom(room, silent) {
|
||||
if (!call) return;
|
||||
if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} }
|
||||
if (call.historyId && call.teamId) { try { await R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past
|
||||
// A native side that ends the call (POST /api/calls/end) may have a dropped WebSocket, so the OTHER party
|
||||
// can be left sitting in the mesh meeting "still in the call". Close their window. Idempotent: when this
|
||||
// runs from the mesh emptying normally, the room is already gone → no-op (so it never double-ends).
|
||||
const stuck = meetingRooms.get(room);
|
||||
if (stuck) { for (const [, p] of stuck) { if (p.ws && p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p.ws._meetingRoom = null; } } meetingRooms.delete(room); }
|
||||
// Activity line: a duration ONLY if the call was answered; otherwise "Missed call" (#7/#9).
|
||||
if (!silent) try {
|
||||
const mid = A.id(); const body = call.answered ? ('📞 Call ended · ' + fmtDur(now() - (call.answeredAt || call.startedAt))) : '📞 Missed call';
|
||||
|
||||
+39
-63
@@ -2546,7 +2546,7 @@ function refreshGroupRowTick(gid){
|
||||
}
|
||||
// Shared group call: start it (or join the live one — the server returns the existing room).
|
||||
async function startOrJoinGroupCall(group){
|
||||
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ const _g=rowFor('group',group); if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'group', (_g&&_g.name)||'Group call', false); return; } /* native: CallKit + native LiveKit, no WebView join */ meetReturn={kind:'group',id:group}; switchTab('meeting'); enterMeeting(r.room); } }
|
||||
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ const _g=rowFor('group',group); meetReturn={kind:'group',id:group}; if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'group', (_g&&_g.name)||'Group call', false); switchTab('meeting'); enterMeeting(r.room, false, { native:true, uuid:r.uuid }); return; } /* native: CallKit ring + native LiveKit + the REAL meeting window (mesh, no 2nd SFU) */ switchTab('meeting'); enterMeeting(r.room); } }
|
||||
catch(e){ toast(e.message||'Could not start the call'); }
|
||||
}
|
||||
function updateCallBtn(active){ const cc=document.getElementById('convoCall'); if(!cc) return; cc.classList.toggle('joinable',active); cc.title=active?'Join call':'Start call'; cc.innerHTML=ic(active?'video':'phone',18)+(active?'<span>Join</span>':''); }
|
||||
@@ -2562,7 +2562,7 @@ function onGroupCall(d){
|
||||
function dismissCallInvite(room){ if(!room) return; const el=document.getElementById('ci-'+room); if(el){ try{ el.remove(); }catch(_){} stopRing(); } }
|
||||
// 1:1 call: start/join from the DM header; live state updates the button + shows an incoming invite.
|
||||
async function startOrJoinDmCall(otherId){
|
||||
try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ const _r=rowFor('dm',otherId); if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'dm', (_r&&_r.name)||'Call', false, (_r&&_r.avatar)||null); return; } /* native: CallKit + native LiveKit, no WebView join */ _dmCallWaiting={ name:(_r&&_r.name)||'Contact', avatar:(_r&&_r.avatar)||null }; meetReturn={kind:'dm',id:otherId}; switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer */ } }
|
||||
try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ const _r=rowFor('dm',otherId); _dmCallWaiting={ name:(_r&&_r.name)||'Contact', avatar:(_r&&_r.avatar)||null }; meetReturn={kind:'dm',id:otherId}; if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'dm', (_r&&_r.name)||'Call', false); switchTab('meeting'); enterMeeting(r.room, false, { native:true, uuid:r.uuid }); startRingback(); return; } /* native: CallKit ring + native LiveKit media + the REAL meeting window (mesh, no 2nd SFU) */ switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer */ } }
|
||||
catch(e){ toast(e.message||'Could not start the call'); }
|
||||
}
|
||||
// Live presence: a contact came online/offline or entered/left a call — update their dot + the
|
||||
@@ -3803,26 +3803,25 @@ async function setupNativeCall(){
|
||||
if(!d||!d.room) return;
|
||||
_nativeAnswered.add(d.room);
|
||||
dismissCallInvite(d.room);
|
||||
postJSON('/api/calls/answered',{ room:d.room }).catch(()=>{});
|
||||
// Show the in-app call screen for the callee (native media has no meeting window of its own).
|
||||
const isGroup=(d.kind==='group'); const c=(CONTACTS||[]).find(x=>x.id===d.callerId);
|
||||
ncShowCall({ role:'callee', room:d.room, uuid:d.callUUID, video:!!d.hasVideo,
|
||||
name: isGroup ? (d.groupName||'Group call') : (d.callerName||(c&&c.name)||'Call'),
|
||||
avatar: isGroup ? null : (c&&c.avatar)||null, state:'Connecting…' });
|
||||
// Open the REAL meeting window. Joining the mesh marks the call answered server-side, shows the caller's
|
||||
// tile, and wires the end-both-sides lifecycle. The plugin already holds the LiveKit media, so this joins
|
||||
// native (no 2nd SFU connection). meetReturn = the chat to land back on when the call ends.
|
||||
const isGroup=(d.kind==='group');
|
||||
meetReturn = isGroup ? { kind:'group', id:d.groupId } : { kind:'dm', id:d.callerId };
|
||||
switchTab('meeting'); enterMeeting(d.room, false, { native:true, uuid:d.callUUID });
|
||||
}catch(e){ pdbg('nc-answer-err', { err:String((e&&e.message)||e) }); } });
|
||||
// The plugin fires callConnected once its LiveKit room connects. For the CALLEE that IS the pickup → mark
|
||||
// the in-app screen connected + start the timer. (The caller flips on the server's "call-answered".)
|
||||
NC.addListener('callConnected', ()=>{ pdbg('nc-connected'); if(_ncCall && _ncCall.role==='callee') ncMarkConnected(); });
|
||||
NC.addListener('callError', (e)=>{ pdbg('nc-error', { err:(e&&e.error)||'' }); if(_ncCall){ ncSetState('Call failed'); setTimeout(()=>{ if(_ncCall) ncHideCall(); }, 1500); } });
|
||||
// Keep the in-app mute button in sync when muted from the CallKit system UI.
|
||||
NC.addListener('setMuted', (e)=>{ if(_ncCall && e && typeof e.muted!=='undefined') ncSetMuted(!!e.muted, false); });
|
||||
// Ended/declined on the CallKit screen (or the plugin ended it on remote hang-up). Tell the server: END an
|
||||
// answered call, DECLINE one that was still ringing.
|
||||
NC.addListener('callConnected', ()=>{ pdbg('nc-connected'); });
|
||||
NC.addListener('callError', (e)=>{ pdbg('nc-error', { err:(e&&e.error)||'' }); });
|
||||
// Muted from the CallKit system UI → reflect it in the meeting window's mic button.
|
||||
NC.addListener('setMuted', (e)=>{ if(!e||meetState!=='call'||!meetNative) return; meetMic=!e.muted; try{ updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); }catch(_){} });
|
||||
// Ended on the CallKit system screen / plugin ended on remote hang-up. Leave the meeting window too (guarded
|
||||
// so our own in-app hang-up, which already called the plugin, doesn't loop). Then tell the server.
|
||||
NC.addListener('endCall', (d)=>{ try{
|
||||
const room=d&&d.room; pdbg('nc-end', { room:room||'', answered:_nativeAnswered.has(room) });
|
||||
if(!room) return;
|
||||
dismissCallInvite(room);
|
||||
if(_ncCall && _ncCall.room===room) ncHideCall();
|
||||
if(_ncEnding){ _ncEnding=false; }
|
||||
else if(meetState==='call' && meetRoom===room){ leaveMeeting(true); }
|
||||
if(_nativeAnswered.has(room)){ _nativeAnswered.delete(room); postJSON('/api/calls/end',{ room }).catch(()=>{}); }
|
||||
else { postJSON('/api/calls/decline',{ room }).catch(()=>{}); }
|
||||
}catch(_){} });
|
||||
@@ -3830,57 +3829,24 @@ async function setupNativeCall(){
|
||||
}
|
||||
// Start an OUTGOING native call: fetch a LiveKit join token for the room, then have the plugin start the
|
||||
// CallKit call AND connect the LiveKit room natively (the WebView does NOT join — one connection per identity).
|
||||
async function callkitReportOutgoing(uuid, room, kind, peerName, hasVideo, avatar){
|
||||
async function callkitReportOutgoing(uuid, room, kind, peerName, hasVideo){
|
||||
const NC=nativeCallPlugin(); if(!NC||!uuid) return;
|
||||
let tk={}; try{ tk=await postJSON('/api/meetings/token',{ room }); }catch(_){}
|
||||
pdbg('nc-outgoing', { room, hasUrl:!!(tk&&tk.url), hasToken:!!(tk&&tk.token) });
|
||||
ncShowCall({ role:'caller', room, uuid, video:!!hasVideo, name:peerName||'Call', avatar:avatar||null, state:'Calling…' });
|
||||
try{ NC.reportOutgoingCall({ callUUID:uuid, room, kind:kind||'dm', peerName:peerName||'Call', hasVideo:!!hasVideo, url:tk.url||'', token:tk.token||'' }); }catch(_){}
|
||||
}
|
||||
// End the CallKit call (remote hung up / we left / call ended). Safe no-op off-CallKit.
|
||||
function callkitEnd(uuid){ const NC=nativeCallPlugin(); if(!NC||!uuid) return; try{ NC.endCall({ callUUID:uuid }); }catch(_){} }
|
||||
|
||||
// --- In-app call screen for NATIVE calls. Native media runs over LiveKit (no mesh/WebView meeting window),
|
||||
// so this is a UI-only overlay: it shows who you're on with + a timer, and drives mute/end through the
|
||||
// plugin (CallKit stays the source of truth). Shown for both the caller and the callee. ---
|
||||
let _ncCall=null; // { role, room, uuid, name, avatar, video, muted, connected, t0, timer }
|
||||
function ncFmt(s){ s=Math.max(0,Math.floor(s)); const m=Math.floor(s/60), ss=String(s%60).padStart(2,'0'); return m+':'+ss; }
|
||||
function ncEnsureEl(){
|
||||
let el=document.getElementById('nc-call'); if(el) return el;
|
||||
el=document.createElement('div'); el.id='nc-call';
|
||||
el.style.cssText='position:fixed;inset:0;z-index:100000;display:none;flex-direction:column;align-items:center;justify-content:space-between;background:linear-gradient(165deg,#0b1220,#111827 55%,#0b1220);color:#fff;padding:calc(env(safe-area-inset-top,0px) + 52px) 20px calc(env(safe-area-inset-bottom,0px) + 40px);';
|
||||
el.innerHTML='<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;text-align:center">'
|
||||
+'<div id="nc-ava" style="width:118px;height:118px;border-radius:50%;background:#374151;display:flex;align-items:center;justify-content:center;font-size:44px;font-weight:600;overflow:hidden"></div>'
|
||||
+'<div id="nc-name" style="font-size:23px;font-weight:600"></div>'
|
||||
+'<div id="nc-state" style="font-size:15px;opacity:.72;font-variant-numeric:tabular-nums"></div></div>'
|
||||
+'<div style="display:flex;gap:30px;align-items:center;justify-content:center">'
|
||||
+'<button id="nc-mute" style="width:64px;height:64px;border-radius:50%;border:none;background:#374151;color:#fff;font-size:11px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;cursor:pointer"></button>'
|
||||
+'<button id="nc-end" style="width:72px;height:72px;border-radius:50%;border:none;background:#ef4444;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center"></button></div>';
|
||||
document.body.appendChild(el);
|
||||
el.querySelector('#nc-end').innerHTML=ic('callEnd',26);
|
||||
el.querySelector('#nc-end').onclick=()=>{ const u=_ncCall&&_ncCall.uuid; ncHideCall(); if(u) callkitEnd(u); };
|
||||
el.querySelector('#nc-mute').onclick=()=>{ if(_ncCall) ncSetMuted(!_ncCall.muted, true); };
|
||||
return el;
|
||||
}
|
||||
function ncRenderMute(){ const b=document.getElementById('nc-mute'); if(!b||!_ncCall) return; b.innerHTML=ic(_ncCall.muted?'micOff':'mic',22)+'<span>'+(_ncCall.muted?'Unmute':'Mute')+'</span>'; b.style.background=_ncCall.muted?'#fff':'#374151'; b.style.color=_ncCall.muted?'#111827':'#fff'; }
|
||||
function ncSetMuted(m, push){ if(!_ncCall) return; _ncCall.muted=!!m; ncRenderMute(); if(push){ const NC=nativeCallPlugin(); if(NC){ try{ NC.setMuted({ muted:!!m }); }catch(_){} } } }
|
||||
function ncSetState(txt){ const s=document.getElementById('nc-state'); if(s) s.textContent=txt||''; }
|
||||
function ncTick(){ if(!_ncCall||!_ncCall.connected) return; ncSetState(ncFmt((Date.now()-_ncCall.t0)/1000)); }
|
||||
function ncShowCall(o){
|
||||
if(!o||!o.room) return; ncEnsureEl();
|
||||
_ncCall={ role:o.role||'callee', room:o.room, uuid:o.uuid, name:o.name||'Call', avatar:o.avatar||null, video:!!o.video, muted:false, connected:false, t0:0, timer:null };
|
||||
const el=document.getElementById('nc-call');
|
||||
el.querySelector('#nc-ava').innerHTML = o.avatar ? ('<img src="'+pEsc(o.avatar)+'" style="width:100%;height:100%;object-fit:cover">') : pEsc(((o.name||'?').trim().charAt(0)||'?').toUpperCase());
|
||||
el.querySelector('#nc-name').textContent=o.name||'Call';
|
||||
ncSetState(o.state||'Connecting…'); ncRenderMute();
|
||||
el.style.display='flex';
|
||||
}
|
||||
function ncMarkConnected(){ if(!_ncCall||_ncCall.connected) return; _ncCall.connected=true; _ncCall.t0=Date.now(); if(_ncCall.timer) clearInterval(_ncCall.timer); _ncCall.timer=setInterval(ncTick,1000); ncTick(); }
|
||||
function ncHideCall(){ const el=document.getElementById('nc-call'); if(el) el.style.display='none'; if(_ncCall&&_ncCall.timer) clearInterval(_ncCall.timer); _ncCall=null; }
|
||||
// Another of the callee's devices answered — stop ringing here (but never on the device that answered).
|
||||
function onCallTaken(d){ if(!d||!d.room) return; if(_nativeAnswered.has(d.room)) return; dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); if(_ncCall && _ncCall.room===d.room) ncHideCall(); }
|
||||
// The callee picked up — flip the caller's in-app screen from "Calling…" to a running timer.
|
||||
function onCallAnswered(d){ if(!d) return; if(_ncCall && _ncCall.role==='caller' && (!d.room || _ncCall.room===d.room)) ncMarkConnected(); }
|
||||
// NATIVE calls use your REAL meeting window: the plugin owns this user's ONE LiveKit connection (media +
|
||||
// CallKit ring/background/lock-screen), and the WebView joins the same mesh room for the UI — so the caller/
|
||||
// callee tiles, roster, mute state and the whole answer/end lifecycle are the normal meeting code. The only
|
||||
// difference vs a web call is meetNative=true → the WebView does NOT open its own SFU media connection (that
|
||||
// would be a 2nd connection for the same identity), and mute/hang-up bridge to the plugin.
|
||||
// Another of the callee's devices answered — stop ringing here (never on the device that answered).
|
||||
function onCallTaken(d){ if(!d||!d.room) return; if(_nativeAnswered.has(d.room)) return; dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); }
|
||||
// The callee picked up (server signal; the mesh peer-join already clears these on the caller — belt & braces).
|
||||
function onCallAnswered(d){ if(d&&d.room && meetRoom===d.room){ stopRingback(); removeWaitingTile(); _dmCallWaiting=null; } }
|
||||
|
||||
// Open the chat from an in-page notification. Navigation reliably repaints across browsers (a
|
||||
// notification click is not an in-page gesture, so an in-place open won't paint until you
|
||||
@@ -5327,9 +5293,11 @@ function bzUnlockAudio(){
|
||||
}
|
||||
document.addEventListener('touchend', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true}); // fallback: any tap during a call restarts silent remote audio
|
||||
document.addEventListener('click', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true});
|
||||
async function enterMeeting(code, audioOnly){
|
||||
let meetNative=false, meetNativeUuid=null, _ncEnding=false; // native call: WebView joins the mesh for UI; the plugin owns media/CallKit
|
||||
async function enterMeeting(code, audioOnly, opts){
|
||||
bzUnlockAudio(); // runs inside the Join tap → unlock playback so remote audio isn't silent until you tap
|
||||
if(meetState==='call'){ switchTab('meeting'); return; } // already in a call — ignore double-join
|
||||
meetNative=!!(opts&&opts.native); meetNativeUuid=(opts&&opts.uuid)||null; // native → skip our own SFU media; mute/end bridge to the plugin
|
||||
// Joining this room → clear any lingering incoming-call invite popup for it (and stop its ring).
|
||||
// Fixes: joining via the header "Join" button left the Join/Decline popup on screen. Belt-and-braces
|
||||
// we clear ALL open invites, since you can only be in one call at a time.
|
||||
@@ -5339,7 +5307,7 @@ async function enterMeeting(code, audioOnly){
|
||||
// Start with NO media — mic & cam OFF by default (no permission prompt until the user
|
||||
// turns one on). Tracks are acquired on demand by toggleMic / toggleCam.
|
||||
meetLocalStream=new MediaStream();
|
||||
meetMic=false; meetCam=false; meetIsHost=false; meetHostId=null;
|
||||
meetMic=meetNative; meetCam=false; meetIsHost=false; meetHostId=null; // native: the plugin connects with the mic LIVE → show unmuted
|
||||
meetScreen=false; meetScreenStream=null; meetSharers.clear(); meetMultiShare=false;
|
||||
meetRec=null; meetTranscribe=false; meetRoomTx=false; meetSR=null; _addPool=null; meetStageId=null;
|
||||
try{ const c=await fetch('/api/ice').then(r=>r.json()); if(c&&c.iceServers) MEET_ICE=c; }catch(_){}
|
||||
@@ -5363,7 +5331,9 @@ async function onMeetMsg(e){
|
||||
if(_dmCallWaiting && !(m.peers&&m.peers.length)) addWaitingTile(_dmCallWaiting.name, _dmCallWaiting.avatar); // #7: show who we're calling while it rings
|
||||
// SFU: connect to LiveKit for media once (peer uid→peerId map is populated above). Mic/cam are
|
||||
// off at join, so nothing publishes yet — toggleMic/toggleCam publish on demand.
|
||||
if(SFU.on){ try{ await sfuConnect(); }catch(err){ if(ME&&ME.guest){ toast((err&&err.message)||'This meeting link has expired or isn’t active.'); leaveMeeting(true); return; } const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } }
|
||||
if(SFU.on && !meetNative){ try{ await sfuConnect(); }catch(err){ if(ME&&ME.guest){ toast((err&&err.message)||'This meeting link has expired or isn’t active.'); leaveMeeting(true); return; } const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } }
|
||||
// native: the plugin already holds the LiveKit media (one connection per identity) — we joined the mesh
|
||||
// for the UI only. Media is native; tell peers our mic is live.
|
||||
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); // tell existing peers my state
|
||||
if(meetIsHost) meetSend({type:'meeting-host', to:meetMyId}); // announce host so others know
|
||||
refreshMeetPanel(); updateHostControls();
|
||||
@@ -5414,6 +5384,7 @@ function updateCamBtn(){ const b=document.getElementById('meetCamBtn'); if(b){ b
|
||||
// Unmute acquires the mic on demand (no prompt until then) and renegotiates with peers.
|
||||
async function toggleMic(){
|
||||
if(!meetLocalStream) return;
|
||||
if(meetNative){ const next=!meetMic; const NC=nativeCallPlugin(); if(NC){ try{ NC.setMuted({ muted:!next }); }catch(_){} } meetMic=next; updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; } // native: mute the plugin's mic
|
||||
if(SFU.on){ const next=!meetMic; try{ await sfuSetMic(next); meetMic=next; }catch(e){ toast(mediaErrMsg(e,'microphone')); return; } updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
|
||||
const hasTrack=meetLocalStream.getAudioTracks().length>0;
|
||||
if(!hasTrack){
|
||||
@@ -5431,6 +5402,7 @@ async function toggleMic(){
|
||||
// renegotiates with every peer, so you can always turn video on once a meeting has started.
|
||||
async function toggleCam(){
|
||||
if(!meetLocalStream) return;
|
||||
if(meetNative){ toast('Video isn’t available on native calls yet'); return; } // native path is audio-only for now (video rendering is the next phase)
|
||||
if(SFU.on){ const next=!meetCam; try{ await sfuSetCam(next); meetCam=next; meetAudioOnly=false; }catch(e){ toast(mediaErrMsg(e,'camera')); return; } updateCamBtn(); addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
|
||||
const hasTrack=meetLocalStream.getVideoTracks().length>0;
|
||||
if(!hasTrack){
|
||||
@@ -5454,6 +5426,10 @@ let meetLeaving=false;
|
||||
// there wrongly announced "Host handed to <the person who just left>" (bug).
|
||||
function leaveMeeting(forced){
|
||||
if(meetLeaving) return; meetLeaving=true;
|
||||
// Native call: also end the CallKit/plugin call. _ncEnding tells the plugin's endCall listener this leave
|
||||
// originated here, so it doesn't call leaveMeeting again (the plugin ending the call re-fires endCall).
|
||||
if(meetNative && meetNativeUuid){ _ncEnding=true; callkitEnd(meetNativeUuid); }
|
||||
meetNative=false; meetNativeUuid=null;
|
||||
stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: any call exit stops the ring + ringing tile
|
||||
const isDm=!!(meetReturn && meetReturn.kind==='dm');
|
||||
if(!forced && !isDm && meetIsHost && meetPeers.size>0){
|
||||
|
||||
Reference in New Issue
Block a user