diff --git a/server/public/home.html b/server/public/home.html
index 32569b6..3fd4cff 100644
--- a/server/public/home.html
+++ b/server/public/home.html
@@ -4469,6 +4469,7 @@ let meetReturn=null; // {kind:'dm'|'group', id} — chat to land on when
const meetVU=new Map(); // peerId|'__local' -> {ctx,analyser,data,raf} active-speaker meters
let MEET_ICE={ iceServers:[{ urls:'stun:stun.l.google.com:19302' }] };
let meetMic=true, meetCam=true;
+let _pendingJoinMic=false, _pendingJoinCam=false; // #4a: pre-join (lobby) mic/cam choices, applied once actually admitted
let meetIsHost=false, meetHostId=null; // host = the meeting creator (transferable by the host only)
let meetScreen=false, meetScreenStream=null; // am I sharing my screen + the display stream
const meetSharers=new Set(); // peerIds of OTHERS currently sharing their screen
@@ -5541,6 +5542,10 @@ async function onMeetMsg(e){
// 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
+ // #4a: now that we're actually in the call (post-admission, media connected), honor the mic/cam the
+ // user turned on at the pre-join/lobby screen — so they don't have to re-tap after being admitted.
+ if(_pendingJoinMic){ _pendingJoinMic=false; if(!meetMic){ try{ await toggleMic(); }catch(_){} } }
+ if(_pendingJoinCam){ _pendingJoinCam=false; if(!meetCam){ try{ await toggleCam(); }catch(_){} } }
refreshMeetPanel(); updateHostControls();
return;
}
@@ -5570,7 +5575,7 @@ async function onMeetMsg(e){
if(m.type==='meeting-peer-screen'){ if(m.on) meetSharers.add(m.from); else meetSharers.delete(m.from); setTileScreen(m.from, !!m.on); refreshMeetPanel(); return; }
if(m.type==='meeting-sharemode'){ meetMultiShare=!!m.multi; refreshMeetPanel(); return; }
if(m.type==='meeting-muteall'){ if(meetMic && meetLocalStream){ meetMic=false; meetLocalStream.getAudioTracks().forEach(t=>t.enabled=false); updateMicBtn(); setTileMute('__local', true); meetSend({type:'meeting-state', muted:true, camOff:!meetCam}); } toast('You were muted by the host'); return; }
- if(m.type==='meeting-peer-left'){ const p=meetPeers.get(m.peerId); if(p){ try{p.pc.close();}catch(_){} meetPeers.delete(m.peerId);} if(SFU.on) sfuDropPeer(m.peerId); meetSharers.delete(m.peerId); removeTile(m.peerId); refreshMeetPanel(); return; }
+ if(m.type==='meeting-peer-left'){ const p=meetPeers.get(m.peerId); if(p){ try{p.pc.close();}catch(_){} meetPeers.delete(m.peerId);} if(SFU.on) sfuDropPeer(m.peerId); meetSharers.delete(m.peerId); meetPeerUids.delete(m.peerId); meetNames.delete(m.peerId); meetAvatars.delete(m.peerId); removeTile(m.peerId); refreshMeetPanel(); return; } // #4b: drop uid/name too, else they stay in hereUids and never reappear under "Add people"
if(m.type==='meeting-signal'){
const from=m.from, d=m.data||{};
if(d.sdp){
@@ -6071,9 +6076,10 @@ async function startGuestMeeting(code){
ME={ id:'guest-'+Math.random().toString(36).slice(2,10), name:nm, email:'', guest:true, avatarUrl:null };
_guestRoom=code; stopPrev(); ov.remove();
try{ await sfuInit(); }catch(_){}
+ // #4a: remember the pre-join choices; they're applied in the meeting-joined handler — i.e. AFTER the
+ // host admits the guest and media is connected — not on a blind timer that fires while still in the lobby.
+ _pendingJoinMic=joinMic; _pendingJoinCam=joinCam;
switchTab('meeting'); await enterMeeting(code);
- // Apply their pre-join choices once the call is up.
- setTimeout(()=>{ try{ if(joinMic && !meetMic) toggleMic(); if(joinCam && !meetCam) toggleCam(); }catch(_){} }, 900);
};
go.onclick=join;
inp.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); join(); } });
diff --git a/server/signaling.js b/server/signaling.js
index 9bfd044..40a5e59 100644
--- a/server/signaling.js
+++ b/server/signaling.js
@@ -406,8 +406,11 @@ async function leaveMeeting(ws) {
if (!peers) { if (leaverId) CHAT.broadcastPresence(leaverId); return; }
try { await require('./calls').finalizeTranscript(room, ws._meetingUserId); } catch (_) {} // save THIS user's transcript
peers.delete(pid);
- // 1:1 call: when either party leaves, end it for everyone (a DM call has no "remaining" call).
- if (roomToDmCall.has(room)) {
+ // 1:1 call: end it for everyone ONLY when fewer than two people would remain. A DM call that had
+ // extra people ADDED (via "Add people") is effectively a group now — one participant closing their
+ // app must NOT hang up the call for the rest (#11). We only tear the whole thing down when ≤1 person
+ // is left (nobody to talk to). With 2+ remaining we fall through to the normal peer-left path below.
+ if (roomToDmCall.has(room) && peers.size < 2) {
const others = [...peers.values()].map((p) => p.ws && p.ws._meetingUserId).filter(Boolean);
for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p.ws._meetingRoom = null; } }
await persistCallHistory(room); // #7: log the call BEFORE endCallByRoom clears roomToDmCall/roomToGroupCall