fix: guest pre-join, post-admit lobby, audio device menu, RC keyboard (0.1.15/batch74)
1. Guest pre-join redesigned: brand backdrop, live camera preview, mic/cam toggles
applied on entry, initials avatar, name required, "host may admit you" note.
2. Post-admit bug: the guest stayed on "Waiting for the host…" forever — the lobby
screen had replaced the call UI and meeting-joined never re-rendered it. Now it
rebuilds the call on admission (_inLobby).
3. Audio devices: dropped the standalone headphones button. The MIC now has a ▾ caret
opening one Teams-style menu with Speaker + Microphone sections (radio-selected);
speaker uses setSinkId/LiveKit switchActiveDevice, mic switches the live input.
Mobile gets a speakerphone toggle that prefers a connected BT/headset when off.
4. Remote-control keyboard:
- Injector now maps the PHYSICAL key (KeyboardEvent.code) instead of the character,
so Shift+1 types "!" etc. Character mapping was why typing "performed differently".
- Keys reach the sharer ONLY while control is ENGAGED (window focused AND you clicked
their screen). Minimised/unfocused/chat typing stays local. Esc or clicking away
releases; modifiers are released on disengage so nothing sticks.
- Explicit control icons: viewer gets a Control ON/OFF button (green when engaged) +
an on-screen hint; the SHARER gets a control icon beside mic/chat to allow/stop
access at a glance, synced with the consent dialog and banner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+152
-25
@@ -586,6 +586,28 @@
|
||||
/* Guest meeting mode: an external link-joiner sees ONLY the meeting — no rail, sidebar, or profile. */
|
||||
body.guest-mode .rail, body.guest-mode #chatcol, body.guest-mode #hdrRight, body.guest-mode #navToggle, body.guest-mode .demo-note{display:none!important;}
|
||||
body.guest-mode .content{width:100%;flex:1 1 auto;}
|
||||
/* Guest pre-join: brand backdrop, camera preview, mic/cam toggles, name. */
|
||||
.guest-prejoin{position:fixed;inset:0;z-index:9900;display:flex;align-items:center;justify-content:center;padding:1rem;overflow:auto;
|
||||
background:radial-gradient(1200px 600px at 50% -10%, #2b4f96 0%, #1F3B73 55%, #16294f 100%);}
|
||||
.guest-prejoin .gp-card{background:#fff;border-radius:20px;padding:1.5rem 1.5rem 1.3rem;width:100%;max-width:420px;box-shadow:0 24px 60px rgba(0,0,0,.35);text-align:center;}
|
||||
.guest-prejoin .gp-brand{display:flex;align-items:center;justify-content:center;gap:.5rem;margin-bottom:1rem;}
|
||||
.guest-prejoin .gp-brand img{height:34px;width:auto;object-fit:contain;}
|
||||
.guest-prejoin .gp-brand span{font-size:1rem;font-weight:600;color:var(--blue);} .guest-prejoin .gp-brand b{color:var(--blue);}
|
||||
.guest-prejoin .gp-preview{position:relative;width:100%;aspect-ratio:16/10;border-radius:14px;overflow:hidden;background:#0f172a;display:grid;place-items:center;margin-bottom:.8rem;}
|
||||
.guest-prejoin .gp-preview video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transform:scaleX(-1);}
|
||||
.guest-prejoin .gp-avatar{width:76px;height:76px;border-radius:50%;display:grid;place-items:center;font-weight:700;font-size:1.5rem;color:#334155;z-index:1;}
|
||||
.guest-prejoin .gp-camoff{position:absolute;bottom:8px;left:0;right:0;color:rgba(255,255,255,.72);font-size:.75rem;z-index:1;}
|
||||
.guest-prejoin .gp-toggles{display:flex;gap:.55rem;justify-content:center;margin-bottom:1rem;}
|
||||
.guest-prejoin .gp-tg{display:inline-flex;align-items:center;gap:.4rem;border:1px solid var(--line);background:var(--blue-soft);color:var(--blue);border-radius:99px;padding:.45rem .85rem;font:inherit;font-size:.82rem;font-weight:600;cursor:pointer;}
|
||||
.guest-prejoin .gp-tg.off{background:#fee2e2;border-color:#fecaca;color:#b91c1c;}
|
||||
.guest-prejoin h2{margin:0 0 .2rem;font-size:1.2rem;color:var(--ink);}
|
||||
.guest-prejoin .gp-sub{margin:0 0 1rem;font-size:.86rem;color:var(--muted);}
|
||||
.guest-prejoin input{width:100%;box-sizing:border-box;border:1.5px solid var(--line);border-radius:11px;padding:.7rem .8rem;font-size:.95rem;font-family:inherit;background:#fbfcfe;color:var(--ink);outline:none;}
|
||||
.guest-prejoin input:focus{border-color:var(--blue);}
|
||||
.guest-prejoin input.err{border-color:#dc2626;background:#fef2f2;}
|
||||
.guest-prejoin .gp-join{width:100%;margin-top:.75rem;border:none;border-radius:11px;background:var(--blue);color:#fff;font:inherit;font-size:.95rem;font-weight:600;padding:.75rem;cursor:pointer;}
|
||||
.guest-prejoin .gp-join:hover{filter:brightness(1.08);}
|
||||
.guest-prejoin .gp-note{display:flex;align-items:center;justify-content:center;gap:.3rem;margin-top:.7rem;font-size:.74rem;color:var(--muted);}
|
||||
.guest-left{position:fixed;inset:0;background:#1F3B73;display:flex;align-items:center;justify-content:center;z-index:9900;padding:1rem;}
|
||||
.guest-left .gl-card{background:#fff;border-radius:16px;padding:2rem 1.6rem;text-align:center;max-width:360px;width:100%;box-shadow:0 18px 44px rgba(0,0,0,.3);}
|
||||
.guest-left .gl-card h2{margin:.2rem 0 .3rem;font-size:1.15rem;color:var(--ink);}
|
||||
@@ -660,6 +682,15 @@
|
||||
.spk-menu .spk-opt:hover{background:var(--blue-soft);}
|
||||
.spk-menu .spk-opt.on{color:var(--blue);font-weight:600;}
|
||||
.spk-menu .spk-empty{font-size:.8rem;color:var(--muted);padding:.5rem .6rem;line-height:1.4;}
|
||||
/* radio dot, so the menu reads like the OS/Teams device picker */
|
||||
.spk-menu .spk-opt{display:flex;align-items:center;gap:.5rem;}
|
||||
.spk-menu .spk-dot{width:13px;height:13px;border-radius:50%;border:2px solid #cbd5e1;flex:0 0 auto;}
|
||||
.spk-menu .spk-opt.on .spk-dot{border-color:var(--blue);box-shadow:inset 0 0 0 2.5px var(--blue);}
|
||||
.spk-menu .spk-h+.spk-h{margin-top:.2rem;}
|
||||
/* Mic button + ▾ caret grouped into one control (audio devices live behind the caret) */
|
||||
.meet-bar .mic-grp{position:relative;display:inline-flex;align-items:center;}
|
||||
.meet-bar .mic-caret{position:absolute;right:-2px;bottom:-2px;width:20px;height:20px;border-radius:50%;border:2px solid var(--card,#fff);background:var(--blue);color:#fff;display:grid;place-items:center;cursor:pointer;padding:0;z-index:2;}
|
||||
.meet-bar .mic-caret:hover{filter:brightness(1.12);}
|
||||
.call-invite .ci-ico{width:38px;height:38px;border-radius:50%;background:#dcfce7;color:#15803d;display:grid;place-items:center;flex:0 0 auto;}
|
||||
.call-invite .ci-txt{font-size:.88rem;color:var(--ink);line-height:1.25;}
|
||||
.call-invite .ci-join{border:none;background:#15803d;color:#fff;border-radius:9px;padding:.45rem .7rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;flex:0 0 auto;}
|
||||
@@ -917,7 +948,7 @@
|
||||
<body>
|
||||
<script src="/icons.js?v=5"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
|
||||
<script>window.__BUILD='2026-07-11-batch73';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-11-batch74';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
|
||||
// (emojis stay as plain Unicode). (#5)
|
||||
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
|
||||
@@ -3330,8 +3361,12 @@ function renderCallConnecting(){
|
||||
const el=document.getElementById('meetingPanel'); if(!el) return;
|
||||
el.innerHTML='<div class="call-connecting"><img src="/loaders/loader-orbit-dark.svg" width="76" height="76" alt=""><div class="cc-txt">Connecting to call…</div></div>';
|
||||
}
|
||||
// #4 Lobby — guest side: waiting for the host to admit them.
|
||||
// #4 Lobby — guest side: waiting for the host to admit them. _inLobby lets meeting-joined know it must
|
||||
// rebuild the call UI (the waiting screen replaced it, so without this the guest stayed on "waiting…"
|
||||
// forever even after being admitted).
|
||||
let _inLobby=false;
|
||||
function renderLobbyWait(){
|
||||
_inLobby=true;
|
||||
const el=document.getElementById('meetingPanel'); if(!el) return;
|
||||
el.innerHTML='<div class="call-connecting"><img src="/loaders/loader-orbit-dark.svg" width="76" height="76" alt=""><div class="cc-txt">Waiting for the host to let you in…</div><div style="color:var(--muted);font-size:.85rem;margin-top:.3rem">You’ll join automatically once they admit you.</div></div>';
|
||||
}
|
||||
@@ -3352,13 +3387,14 @@ function renderCall(){
|
||||
const el=document.getElementById('meetingPanel'); if(!el) return;
|
||||
el.innerHTML='<div class="meet"><div class="meet-grid" id="meetGrid"></div>'
|
||||
+ '<div class="meet-bar"><span class="code" id="meetCodeChip">Room <b>'+pEsc(meetRoom||'')+'</b> · share to invite</span>'
|
||||
+ '<button class="meet-ic'+(meetMic?'':' off')+'" id="meetMicBtn" title="'+(meetMic?'Mute':'Unmute')+'">'+ic(meetMic?'mic':'micOff',20)+'</button>'
|
||||
+ '<span class="mic-grp"><button class="meet-ic'+(meetMic?'':' off')+'" id="meetMicBtn" title="'+(meetMic?'Mute':'Unmute')+'">'+ic(meetMic?'mic':'micOff',20)+'</button>'
|
||||
+ '<button class="mic-caret" id="meetAudioBtn" title="Audio devices (mic & speaker)">'+ic('chevronUp',12)+'</button></span>'
|
||||
+ '<button class="meet-ic'+(meetCam?'':' off')+'" id="meetCamBtn" title="'+(meetCam?'Turn camera off':'Turn camera on')+'">'+ic(meetCam?'video':'videoOff',20)+'</button>'
|
||||
+ '<button class="meet-ic" id="meetScreenBtn" title="Share screen">'+ic('monitor',20)+'</button>'
|
||||
+ '<button class="meet-ic host-only" id="meetRecBtn" title="Record meeting" style="display:none">'+ic('record',20)+'</button>'
|
||||
+ ((ME&&ME.guest)?'':'<button class="meet-ic" id="meetTransBtn" title="Live transcript">'+ic('fileText',20)+'</button>') // #12: transcript is a signed-in feature (guests can't download it)
|
||||
+ '<button class="meet-ic" id="meetChatBtn" title="Chat"><span class="mc-badge" id="meetChatBadge" style="display:none">0</span>'+ic('chat',20)+'</button>'
|
||||
+ '<button class="meet-ic" id="meetSpkBtn" title="Speaker / headphones">'+ic('headphones',20)+'</button>'
|
||||
+ (isMobileUA()?'<button class="meet-ic'+(meetSpeakerOn?'':' off')+'" id="meetSpkBtn" title="Speakerphone">'+ic('headphones',20)+'</button>':'')
|
||||
+ '<button class="meet-ic" id="meetPplBtn" title="Participants">'+ic('users',20)+'</button>'
|
||||
+ '<button class="meet-ic leave" id="meetLeaveBtn" title="Leave">'+ic('callEnd',20)+'</button></div></div>';
|
||||
document.getElementById('meetMicBtn').onclick=toggleMic;
|
||||
@@ -3368,7 +3404,8 @@ function renderCall(){
|
||||
{ const tb=document.getElementById('meetTransBtn'); if(tb) tb.onclick=toggleTranscribe; }
|
||||
document.getElementById('meetPplBtn').onclick=toggleMeetPanel;
|
||||
{ const cb=document.getElementById('meetChatBtn'); if(cb) cb.onclick=toggleMeetChat; }
|
||||
{ const sb=document.getElementById('meetSpkBtn'); if(sb) sb.onclick=(e)=>{ e.stopPropagation(); openSpeakerMenu(sb); }; }
|
||||
{ const ab=document.getElementById('meetAudioBtn'); if(ab) ab.onclick=(e)=>{ e.stopPropagation(); openAudioMenu(ab); }; } // desktop: mic ▾ → devices
|
||||
{ const sb=document.getElementById('meetSpkBtn'); if(sb) sb.onclick=toggleSpeakerphone; } // mobile: speakerphone toggle
|
||||
document.getElementById('meetLeaveBtn').onclick=leaveMeeting;
|
||||
updateHostControls();
|
||||
// Click another shared screen (in the side column) to bring it onto the stage.
|
||||
@@ -3376,23 +3413,81 @@ function renderCall(){
|
||||
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true);
|
||||
setTileMute('__local', !meetMic);
|
||||
}
|
||||
// #5 Speaker / headphone output selection. Applies the chosen audiooutput device (setSinkId) to every
|
||||
// meeting media element, and remembers it for new tiles. No-op where setSinkId isn't supported.
|
||||
// ---- Audio devices (#3): one menu behind the MIC's ▾ caret, listing Speaker + Microphone (like Teams),
|
||||
// instead of a separate headphones button. Choosing a speaker applies setSinkId to every meeting media
|
||||
// element; choosing a mic switches the live input device.
|
||||
function isMobileUA(){ return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent||''); }
|
||||
let meetSinkId=(()=>{ try{ return localStorage.getItem('bzc_sink')||''; }catch(_){ return ''; } })();
|
||||
let meetMicId=(()=>{ try{ return localStorage.getItem('bzc_micdev')||''; }catch(_){ return ''; } })();
|
||||
let meetSpeakerOn=(()=>{ try{ return localStorage.getItem('bzc_spk')!=='0'; }catch(_){ return true; } })(); // mobile speakerphone
|
||||
function applySink(el){ try{ if(el && meetSinkId && typeof el.setSinkId==='function') el.setSinkId(meetSinkId).catch(()=>{}); }catch(_){} }
|
||||
function applySinkAll(){ document.querySelectorAll('#meetGrid video').forEach(applySink); }
|
||||
async function openSpeakerMenu(anchor){
|
||||
// Bluetooth/headset devices report themselves in the label — used to pick a sensible icon on mobile.
|
||||
function isBtLabel(l){ return /bluetooth|airpod|buds|headset|headphone|wh-|wf-/i.test(l||''); }
|
||||
async function setSpeakerDevice(id){
|
||||
meetSinkId=id||''; try{ localStorage.setItem('bzc_sink', meetSinkId); }catch(_){}
|
||||
try{ if(SFU.room && SFU.room.switchActiveDevice) await SFU.room.switchActiveDevice('audiooutput', meetSinkId); }catch(_){}
|
||||
applySinkAll();
|
||||
}
|
||||
async function setMicDevice(id){
|
||||
meetMicId=id||''; try{ localStorage.setItem('bzc_micdev', meetMicId); }catch(_){}
|
||||
try{ if(SFU.room && SFU.room.switchActiveDevice){ await SFU.room.switchActiveDevice('audioinput', meetMicId); return; } }catch(_){}
|
||||
// Mesh fallback: re-acquire the mic on the chosen device and swap the outgoing track.
|
||||
try{
|
||||
if(!meetMic || !meetLocalStream) return;
|
||||
const s=await navigator.mediaDevices.getUserMedia({audio:{deviceId:{exact:meetMicId}}});
|
||||
const nt=s.getAudioTracks()[0]; if(!nt) return;
|
||||
const old=meetLocalStream.getAudioTracks()[0];
|
||||
if(old){ meetLocalStream.removeTrack(old); try{ old.stop(); }catch(_){} }
|
||||
meetLocalStream.addTrack(nt);
|
||||
meetPeers.forEach(p=>{ try{ const snd=p.pc.getSenders().find(x=>x.track&&x.track.kind==='audio'); if(snd) snd.replaceTrack(nt); }catch(_){} });
|
||||
}catch(e){ toast(mediaErrMsg(e,'microphone')); }
|
||||
}
|
||||
async function openAudioMenu(anchor){
|
||||
document.querySelectorAll('.spk-menu').forEach(x=>x.remove());
|
||||
let devs=[]; try{ devs=(await navigator.mediaDevices.enumerateDevices()).filter(d=>d.kind==='audiooutput'); }catch(_){}
|
||||
let outs=[], ins=[];
|
||||
try{ const d=await navigator.mediaDevices.enumerateDevices(); outs=d.filter(x=>x.kind==='audiooutput'); ins=d.filter(x=>x.kind==='audioinput'); }catch(_){}
|
||||
const menu=document.createElement('div'); menu.className='spk-menu';
|
||||
if(!devs.length){ menu.innerHTML='<div class="spk-empty">Your browser can’t switch audio output here. Set it in the OS sound settings.</div>'; }
|
||||
else menu.innerHTML='<div class="spk-h">'+ic('headphones',13)+' Speaker</div>'+devs.map((d,i)=>'<button class="spk-opt'+((d.deviceId===meetSinkId||(!meetSinkId&&d.deviceId==='default'))?' on':'')+'" data-id="'+pEsc(d.deviceId)+'">'+pEsc(d.label||('Output '+(i+1)))+'</button>').join('');
|
||||
const sel=(id, cur)=> (id===cur || (!cur && (id==='default'||id===''))) ? ' on' : '';
|
||||
let html='';
|
||||
html+='<div class="spk-h">'+ic('headphones',13)+' Speaker</div>';
|
||||
html+= outs.length ? outs.map((d,i)=>'<button class="spk-opt'+sel(d.deviceId,meetSinkId)+'" data-k="out" data-id="'+pEsc(d.deviceId)+'"><span class="spk-dot"></span>'+pEsc(d.label||('Output '+(i+1)))+'</button>').join('')
|
||||
: '<div class="spk-empty">Output is controlled by your system.</div>';
|
||||
html+='<div class="spk-h">'+ic('mic',13)+' Microphone</div>';
|
||||
html+= ins.length ? ins.map((d,i)=>'<button class="spk-opt'+sel(d.deviceId,meetMicId)+'" data-k="in" data-id="'+pEsc(d.deviceId)+'"><span class="spk-dot"></span>'+pEsc(d.label||('Microphone '+(i+1)))+'</button>').join('')
|
||||
: '<div class="spk-empty">Allow microphone access to list devices.</div>';
|
||||
menu.innerHTML=html;
|
||||
document.body.appendChild(menu);
|
||||
const r=anchor.getBoundingClientRect(); menu.style.left=Math.max(8,Math.min(r.left, window.innerWidth-menu.offsetWidth-8))+'px'; menu.style.top=(r.top-menu.offsetHeight-8)+'px';
|
||||
menu.querySelectorAll('.spk-opt').forEach(b=>b.onclick=()=>{ meetSinkId=b.dataset.id; try{ localStorage.setItem('bzc_sink', meetSinkId); }catch(_){} applySinkAll(); menu.remove(); toast('Speaker set'); });
|
||||
const close=(e)=>{ if(!menu.contains(e.target) && e.target!==anchor){ menu.remove(); document.removeEventListener('mousedown',close); } };
|
||||
const r=anchor.getBoundingClientRect();
|
||||
menu.style.left=Math.max(8,Math.min(r.left-60, window.innerWidth-menu.offsetWidth-8))+'px';
|
||||
menu.style.top=Math.max(8,(r.top-menu.offsetHeight-10))+'px';
|
||||
menu.querySelectorAll('.spk-opt').forEach(b=>b.onclick=async()=>{
|
||||
if(b.dataset.k==='out'){ await setSpeakerDevice(b.dataset.id); toast('Speaker set'); }
|
||||
else { await setMicDevice(b.dataset.id); toast('Microphone set'); }
|
||||
menu.remove();
|
||||
});
|
||||
const close=(e)=>{ if(!menu.contains(e.target) && e.target!==anchor && !anchor.contains(e.target)){ menu.remove(); document.removeEventListener('mousedown',close); } };
|
||||
setTimeout(()=>document.addEventListener('mousedown',close),0);
|
||||
}
|
||||
// Mobile: speakerphone on/off. When a Bluetooth/headset output is connected we route to it and show a
|
||||
// headset icon; otherwise we toggle between the loudspeaker and the default (earpiece) route. NOTE: true
|
||||
// earpiece routing is an OS capability — on mobile WEB the browser owns it, so this picks the best
|
||||
// available output device. In the native mobile build this maps to the OS audio route.
|
||||
async function toggleSpeakerphone(){
|
||||
const btn=document.getElementById('meetSpkBtn'); if(!btn) return;
|
||||
meetSpeakerOn=!meetSpeakerOn; try{ localStorage.setItem('bzc_spk', meetSpeakerOn?'1':'0'); }catch(_){}
|
||||
let outs=[]; try{ outs=(await navigator.mediaDevices.enumerateDevices()).filter(x=>x.kind==='audiooutput'); }catch(_){}
|
||||
const bt=outs.find(d=>isBtLabel(d.label));
|
||||
if(meetSpeakerOn){
|
||||
const spk=outs.find(d=>/speaker/i.test(d.label)) || outs.find(d=>d.deviceId==='default') || outs[0];
|
||||
if(spk) await setSpeakerDevice(spk.deviceId);
|
||||
} else if(bt){ await setSpeakerDevice(bt.deviceId); }
|
||||
else { await setSpeakerDevice(''); }
|
||||
btn.classList.toggle('off', !meetSpeakerOn);
|
||||
btn.innerHTML=ic('headphones',20);
|
||||
btn.title=meetSpeakerOn?'Speakerphone on':(bt?('Using '+(bt.label||'headset')):'Speakerphone off');
|
||||
toast(meetSpeakerOn?'Speaker on':(bt?'Using headset':'Speaker off'));
|
||||
}
|
||||
function addTile(id, stream, label, muted){
|
||||
const grid=document.getElementById('meetGrid'); if(!grid) return;
|
||||
let tile=document.getElementById('meet-tile-'+id);
|
||||
@@ -3607,6 +3702,7 @@ async function onMeetMsg(e){
|
||||
if(m.type==='meeting-created'){ meetRoom=m.room; renderCall(); meetSend({type:'meeting-join', room:m.room, name:(ME.name||ME.email||'Guest'), guestId:(ME&&ME.guest)?ME.id:undefined}); if(meetAnnounceGroup){ const g=meetAnnounceGroup; meetAnnounceGroup=null; try{ postJSON('/api/messages',{group:g, body:'📹 Started a group call — join with code '+m.room}); }catch(_){} } return; }
|
||||
if(m.type==='meeting-joined'){
|
||||
meetMyId=m.peerId;
|
||||
if(_inLobby){ _inLobby=false; renderCall(); } // admitted from the lobby → replace the "waiting…" screen with the call
|
||||
if(m.isHost){ meetIsHost=true; meetHostId=meetMyId; } // host = the meeting creator (server-decided)
|
||||
meetWatchStream('__local', meetLocalStream); // active-speaker detection on my own mic
|
||||
// Existing peers OFFER to me (their offers carry their tracks incl. any active screen share);
|
||||
@@ -3720,7 +3816,7 @@ function leaveMeeting(forced){
|
||||
meetPeers.forEach(p=>{ try{p.pc.close();}catch(_){} }); meetPeers.clear(); meetNames.clear(); meetAvatars.clear(); meetPeerUids.clear(); meetCamOff.clear(); meetInvited.forEach(e=>{ if(e.timer) clearTimeout(e.timer); }); meetInvited.clear(); meetMuted.clear();
|
||||
if(meetLocalStream){ try{ meetLocalStream.getTracks().forEach(t=>t.stop()); }catch(_){} meetLocalStream=null; }
|
||||
if(meetWs){ try{ meetWs.close(); }catch(_){} meetWs=null; }
|
||||
meetRoom=null; meetMyId=null; meetState='idle'; meetIsHost=false; meetHostId=null; meetRailLive(false); resetMeetChat();
|
||||
meetRoom=null; meetMyId=null; meetState='idle'; meetIsHost=false; meetHostId=null; meetRailLive(false); resetMeetChat(); _inLobby=false;
|
||||
const ret=meetReturn; meetReturn=null; meetLeaving=false;
|
||||
if(ME&&ME.guest){ renderGuestLeft(); return; } // guests have no chat to return to — show a leave screen
|
||||
if(ret){ switchTab('chat'); selectChat(ret.kind, ret.id); } // land back on the originating chat
|
||||
@@ -3935,26 +4031,57 @@ async function startGuestMeeting(code){
|
||||
document.body.classList.add('guest-mode');
|
||||
try{ history.replaceState(null,'','/home?meet='+code); }catch(_){}
|
||||
const savedName=(()=>{ try{ return localStorage.getItem('bzc_guest_name')||''; }catch(_){ return ''; } })();
|
||||
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='guestJoin'; ov.style.zIndex='9900';
|
||||
ov.innerHTML='<div class="modal" style="max-width:380px;text-align:center">'
|
||||
+'<img src="/mark-light.png" alt="" style="height:44px;width:auto;margin:0 auto .4rem;object-fit:contain" onerror="this.remove()">'
|
||||
+'<h3 style="justify-content:center">Join the meeting</h3>'
|
||||
+'<p class="bzc-msg" style="text-align:center">You’re joining meeting <b>'+pEsc(code)+'</b> as a guest.</p>'
|
||||
+'<input id="guestName" placeholder="Your name" autocomplete="name" value="'+pEsc(savedName)+'" style="width:100%;box-sizing:border-box;border:1px solid var(--line);border-radius:9px;padding:.6rem .7rem;font-size:.95rem;margin:.2rem 0 .9rem">'
|
||||
+'<button class="bzc-ok" id="guestGo" style="width:100%;justify-content:center">Join meeting</button></div>';
|
||||
let joinMic=false, joinCam=false; // pre-join choices, applied right after entering the call
|
||||
const ov=document.createElement('div'); ov.className='guest-prejoin'; ov.id='guestJoin';
|
||||
ov.innerHTML='<div class="gp-card">'
|
||||
+'<div class="gp-brand"><img src="/mark-light.png" alt="" onerror="this.remove()"><span>Biz <b>Connect</b></span></div>'
|
||||
+'<div class="gp-preview" id="gpPrev"><video id="gpVid" autoplay playsinline muted></video>'
|
||||
+'<div class="gp-avatar" id="gpAv">?</div>'
|
||||
+'<div class="gp-camoff" id="gpCamOff">Camera is off</div></div>'
|
||||
+'<div class="gp-toggles">'
|
||||
+'<button type="button" class="gp-tg off" id="gpMic" title="Microphone">'+ic('micOff',18)+'<span>Mic off</span></button>'
|
||||
+'<button type="button" class="gp-tg off" id="gpCam" title="Camera">'+ic('videoOff',18)+'<span>Camera off</span></button>'
|
||||
+'</div>'
|
||||
+'<h2>Ready to join?</h2>'
|
||||
+'<p class="gp-sub">Meeting <b>'+pEsc(code)+'</b> · joining as a guest</p>'
|
||||
+'<input id="guestName" placeholder="Enter your name" autocomplete="name" maxlength="60" value="'+pEsc(savedName)+'">'
|
||||
+'<button class="gp-join" id="guestGo">Join meeting</button>'
|
||||
+'<div class="gp-note">'+ic('info',13)+' The host may need to admit you.</div>'
|
||||
+'</div>';
|
||||
document.body.appendChild(ov);
|
||||
const inp=ov.querySelector('#guestName'), go=ov.querySelector('#guestGo');
|
||||
const vid=ov.querySelector('#gpVid'), av=ov.querySelector('#gpAv'), camOff=ov.querySelector('#gpCamOff');
|
||||
const micBtn=ov.querySelector('#gpMic'), camBtn=ov.querySelector('#gpCam');
|
||||
let prevStream=null;
|
||||
const syncAvatar=()=>{ const n=(inp.value||'').trim(); av.textContent=n?initials(n):'?'; av.style.background=avColor(n||'?'); };
|
||||
syncAvatar(); inp.addEventListener('input', syncAvatar);
|
||||
// Live camera preview so the guest can see themselves before joining (like every other meeting app).
|
||||
const stopPrev=()=>{ if(prevStream){ try{ prevStream.getTracks().forEach(t=>t.stop()); }catch(_){} prevStream=null; } vid.srcObject=null; };
|
||||
camBtn.onclick=async()=>{
|
||||
if(joinCam){ joinCam=false; stopPrev(); camBtn.classList.add('off'); camBtn.innerHTML=ic('videoOff',18)+'<span>Camera off</span>'; camOff.style.display=''; return; }
|
||||
try{ prevStream=await navigator.mediaDevices.getUserMedia({video:true}); vid.srcObject=prevStream; joinCam=true; camBtn.classList.remove('off'); camBtn.innerHTML=ic('video',18)+'<span>Camera on</span>'; camOff.style.display='none'; }
|
||||
catch(e){ toast(mediaErrMsg(e,'camera')); }
|
||||
};
|
||||
micBtn.onclick=async()=>{
|
||||
if(joinMic){ joinMic=false; micBtn.classList.add('off'); micBtn.innerHTML=ic('micOff',18)+'<span>Mic off</span>'; return; }
|
||||
try{ const s=await navigator.mediaDevices.getUserMedia({audio:true}); s.getTracks().forEach(t=>t.stop()); joinMic=true; micBtn.classList.remove('off'); micBtn.innerHTML=ic('mic',18)+'<span>Mic on</span>'; }
|
||||
catch(e){ toast(mediaErrMsg(e,'microphone')); }
|
||||
};
|
||||
setTimeout(()=>{ try{ inp.focus(); }catch(_){} },50);
|
||||
const join=async()=>{
|
||||
const nm=(inp.value||'').trim()||'Guest';
|
||||
const nm=(inp.value||'').trim();
|
||||
if(!nm){ inp.classList.add('err'); inp.focus(); toast('Please enter your name'); return; }
|
||||
try{ localStorage.setItem('bzc_guest_name', nm); }catch(_){}
|
||||
ME={ id:'guest-'+Math.random().toString(36).slice(2,10), name:nm, email:'', guest:true, avatarUrl:null };
|
||||
_guestRoom=code; ov.remove();
|
||||
_guestRoom=code; stopPrev(); ov.remove();
|
||||
try{ await sfuInit(); }catch(_){}
|
||||
switchTab('meeting'); enterMeeting(code);
|
||||
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(); } });
|
||||
inp.addEventListener('input',()=>inp.classList.remove('err'));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user