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:
2026-07-13 13:18:01 +05:30
parent 82fa4a24d3
commit 94b8fac32f
5 changed files with 255 additions and 41 deletions
+37 -4
View File
@@ -18,7 +18,39 @@ try {
const available = !!nut;
// Map browser KeyboardEvent.key values to nut-js Key enum names.
// Map the PHYSICAL key (KeyboardEvent.code) to a nut-js Key. This is the correct way to drive a remote
// keyboard: press the same physical key the viewer pressed and let the remote OS apply its own modifier
// state. Mapping by CHARACTER (mapKey below) broke shifted keys — e.g. Shift+1 typed "1" instead of "!"
// and symbols came out wrong ("keyboard performs differently on the sharer's device").
const CODE_MAP = {
Backspace: 'Backspace', Tab: 'Tab', Enter: 'Enter', NumpadEnter: 'Enter', Escape: 'Escape', Space: 'Space',
ShiftLeft: 'LeftShift', ShiftRight: 'RightShift',
ControlLeft: 'LeftControl', ControlRight: 'RightControl',
AltLeft: 'LeftAlt', AltRight: 'RightAlt',
MetaLeft: 'LeftSuper', MetaRight: 'RightSuper',
CapsLock: 'CapsLock',
PageUp: 'PageUp', PageDown: 'PageDown', End: 'End', Home: 'Home',
ArrowLeft: 'Left', ArrowUp: 'Up', ArrowRight: 'Right', ArrowDown: 'Down',
Insert: 'Insert', Delete: 'Delete',
Minus: 'Minus', Equal: 'Equal', BracketLeft: 'LeftBracket', BracketRight: 'RightBracket',
Backslash: 'Backslash', Semicolon: 'Semicolon', Quote: 'Quote', Backquote: 'Grave',
Comma: 'Comma', Period: 'Period', Slash: 'Slash',
NumpadAdd: 'Add', NumpadSubtract: 'Subtract', NumpadMultiply: 'Multiply', NumpadDivide: 'Divide', NumpadDecimal: 'Decimal',
};
function mapCode(code) {
if (!nut || !code) return null;
const K = nut.Key;
const named = CODE_MAP[code];
if (named && K[named] !== undefined) return [K[named]];
let m;
if ((m = /^Key([A-Z])$/.exec(code)) && K[m[1]] !== undefined) return [K[m[1]]];
if ((m = /^Digit([0-9])$/.exec(code)) && K['Num' + m[1]] !== undefined) return [K['Num' + m[1]]];
if ((m = /^Numpad([0-9])$/.exec(code)) && K['NumPad' + m[1]] !== undefined) return [K['NumPad' + m[1]]];
if ((m = /^(F\d{1,2})$/.exec(code)) && K[m[1]] !== undefined) return [K[m[1]]];
return null;
}
// Map browser KeyboardEvent.key values to nut-js Key enum names. (Fallback when there's no usable code.)
function mapKey(key, code) {
if (!nut) return null;
const K = nut.Key;
@@ -82,14 +114,15 @@ async function inject(evt) {
if (evt.dx) await (evt.dx > 0 ? nut.mouse.scrollRight(Math.abs(evt.dx)) : nut.mouse.scrollLeft(Math.abs(evt.dx)));
break;
case 'keydown': {
const m = mapKey(evt.key, evt.code);
// Prefer the PHYSICAL key so the remote OS applies its own shift/altgr state (correct symbols).
const m = mapCode(evt.code) || mapKey(evt.key, evt.code);
if (!m) break;
if (m.type) { await nut.keyboard.type(m.type); break; }
if (m.type) { await nut.keyboard.type(m.type); break; } // last-resort: type the literal character
await nut.keyboard.pressKey(...m); m.forEach((k) => pressed.add(k));
break;
}
case 'keyup': {
const m = mapKey(evt.key, evt.code);
const m = mapCode(evt.code) || mapKey(evt.key, evt.code);
if (!m || m.type) break;
await nut.keyboard.releaseKey(...m); m.forEach((k) => pressed.delete(k));
break;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "biz-connect-desktop",
"version": "0.1.14",
"version": "0.1.15",
"description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture",
"author": {
"name": "BizGaze",
+45 -7
View File
@@ -48,6 +48,9 @@
FULL viewport underneath it. */
body.has-bar #video{width:100vw;height:100vh;}
body.has-bar{background:#0b1220;}
/* Control engaged: a green inset ring makes it obvious your keyboard now drives THEIR machine. */
#video.engaged{box-shadow:inset 0 0 0 3px #16a34a;}
#ctrlHint{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:2147483000;background:rgba(15,23,42,.78);color:#fff;font-family:'Segoe UI',system-ui,sans-serif;font-size:.78rem;padding:.4rem .8rem;border-radius:999px;pointer-events:none;}
.profile{position:relative}
.profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer}
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
@@ -327,14 +330,22 @@ function buildBar(){
bar.style.cssText='position:fixed;right:16px;bottom:16px;z-index:2147483000;display:flex;flex-direction:row;gap:8px;align-items:center;background:rgba(15,23,42,.72);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);padding:7px 9px;border-radius:14px;box-shadow:0 8px 22px rgba(0,0,0,.35)';
const I=(n)=>(window.ic?window.ic(n,16):'');
const mic=_btn('micBtn',I('mic'),'Mic','#2563eb');
const ctrl=_btn('ctrlBtn',I('monitor'),'Control OFF — click their screen to take control','#6b7280');
const chat=_btn('chatBtn',I('chat'),'Chat','#334155');
const rec=_btn('recBtn','<svg viewBox="0 0 24 24" width="15" height="15"><circle cx="12" cy="12" r="7" fill="#ef4444"/></svg>','Record','#334155');
const end=_btn('endBtn2',I('callEnd'),'End','#dc2626');
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(rec);bar.appendChild(end);
bar.appendChild(mic);bar.appendChild(ctrl);bar.appendChild(chat);bar.appendChild(rec);bar.appendChild(end);
document.body.appendChild(bar);
document.body.classList.add('has-bar');
// Shrink from the default 48px round to a compact 38px so they read as "tiny icons".
[mic,chat,rec,end].forEach(b=>{ b.style.width='38px'; b.style.height='38px'; b.style.boxShadow='none'; });
[mic,ctrl,chat,rec,end].forEach(b=>{ b.style.width='38px'; b.style.height='38px'; b.style.boxShadow='none'; });
ctrl.onclick=()=>setEngaged(!rcEngaged);
// A hint over the screen until they take control, so it's obvious how to start driving.
if(!document.getElementById('ctrlHint')){
const h=document.createElement('div'); h.id='ctrlHint';
h.textContent='Click the screen to take control · Esc to release';
document.body.appendChild(h);
}
mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.title=t.enabled?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+I(t.enabled?'mic':'micOff')+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
chat.onclick=toggleChat;
rec.onclick=()=>{ if(mediaRecorder&&mediaRecorder.state==='recording') stopRecording(); else startRecording(); };
@@ -403,12 +414,39 @@ video.addEventListener('mouseup',e=>send({kind:'mouseup',button:e.button,...rel(
video.addEventListener('dblclick',e=>send({kind:'dblclick',...rel(e)}));
video.addEventListener('wheel',e=>{e.preventDefault();send({kind:'scroll',dx:e.deltaX,dy:e.deltaY});},{passive:false});
video.addEventListener('contextmenu',e=>e.preventDefault());
// Keyboard: capture at the DOCUMENT level while a session is live so keys work regardless of which
// element has focus (the <video> lost focus as soon as you clicked the control bar → "keyboard doesn't
// work"). Skip when typing in the chat box so chat still works.
// ---- Keyboard control gating ----
// Keys reach the SHARER only while control is ENGAGED: the window is focused AND you clicked into the
// shared screen. Otherwise your typing stays on YOUR machine (so a minimised/unfocused window, or typing
// in chat, never leaks keystrokes to the remote desktop). Click the screen (or the Control button) to
// take control; click away, press Esc, or leave the window to release it.
function rcTyping(){ const a=document.activeElement; return a && (a.tagName==='INPUT'||a.tagName==='TEXTAREA'); }
document.addEventListener('keydown',e=>{ if(!video||video.style.display!=='block'||rcTyping()) return; e.preventDefault(); send({kind:'keydown',key:e.key,code:e.code}); });
document.addEventListener('keyup',e=>{ if(!video||video.style.display!=='block'||rcTyping()) return; e.preventDefault(); send({kind:'keyup',key:e.key,code:e.code}); });
let rcEngaged=false;
function setEngaged(on){
const next=!!on; if(next===rcEngaged) return;
rcEngaged=next;
if(video) video.classList.toggle('engaged', rcEngaged);
const b=document.getElementById('ctrlBtn');
if(b){ b.style.background=rcEngaged?'#16a34a':'#6b7280'; b.title=rcEngaged?'Control ON — your mouse & keyboard drive their screen (Esc to release)':'Control OFF — click their screen to take control'; }
const hint=document.getElementById('ctrlHint'); if(hint) hint.style.display=rcEngaged?'none':'block';
// Releasing control must not leave modifiers stuck down on the remote machine.
if(!rcEngaged){ ['ShiftLeft','ControlLeft','AltLeft','MetaLeft'].forEach(code=>send({kind:'keyup',key:code.replace(/Left$/,''),code})); }
}
document.addEventListener('mousedown',(e)=>{
if(!video || video.style.display!=='block') return;
if(e.target===video){ setEngaged(true); return; } // clicked the shared screen → take control
if(e.target.closest && e.target.closest('#sessionBar')) return; // control bar clicks don't release
setEngaged(false); // clicked anywhere else → release
});
window.addEventListener('blur',()=>setEngaged(false)); // window minimised / lost focus → release
document.addEventListener('keydown',e=>{
if(e.key==='Escape' && rcEngaged){ e.preventDefault(); setEngaged(false); return; }
if(!video||video.style.display!=='block'||!rcEngaged||!document.hasFocus()||rcTyping()) return;
e.preventDefault(); send({kind:'keydown',key:e.key,code:e.code});
});
document.addEventListener('keyup',e=>{
if(!video||video.style.display!=='block'||!rcEngaged||!document.hasFocus()||rcTyping()) return;
e.preventDefault(); send({kind:'keyup',key:e.key,code:e.code});
});
// Mobile viewer (#4): map touch → mouse so a phone/tablet can control too. Tap = move+click; drag = move.
const relT=(t)=>{ const c=contentRect(); return {x:Math.max(0,Math.min(1,(t.clientX-c.left)/c.width)), y:Math.max(0,Math.min(1,(t.clientY-c.top)/c.height))}; };
video.addEventListener('touchstart',e=>{ if(!e.touches.length) return; e.preventDefault(); const p=relT(e.touches[0]); send({kind:'mousemove',...p}); send({kind:'mousedown',button:0,...p}); },{passive:false});
+152 -25
View File
@@ -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">Youll 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 cant 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">Youre 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>
+20 -4
View File
@@ -226,7 +226,7 @@ function stopCustTranscription(){ crecogActive=false; if(crecog){ try{crecog.sto
// after the user explicitly taps "Allow control", and only inside the desktop app. In a plain browser
// there's no OS injection possible, so it stays view-only. The user can stop control at any time.
let rcDesktop=false, rcAllowed=false, rcPrompted=false;
try{ window.addEventListener('message',(e)=>{ if(e.origin!==location.origin) return; const d=e.data||{}; if(d.type==='rc-pong') rcDesktop=!!d.desktop; }); }catch(_){}
try{ window.addEventListener('message',(e)=>{ if(e.origin!==location.origin) return; const d=e.data||{}; if(d.type==='rc-pong'){ rcDesktop=!!d.desktop; try{ updateRcBtn(); }catch(_){} } }); }catch(_){}
try{ if(window.parent && window.parent!==window) window.parent.postMessage({type:'rc-ping'}, location.origin); }catch(_){}
function rcPost(msg){ try{ if(window.parent && window.parent!==window) window.parent.postMessage(msg, location.origin); }catch(_){} }
function rcOnInput(data){
@@ -241,9 +241,22 @@ function showControlConsent(){
const el=document.createElement('div'); el.id='rcConsent'; el.className='rc-consent';
el.innerHTML='<div class="rc-card"><div class="rc-h">Allow remote control?</div><p>Your agent is asking to control your mouse &amp; keyboard to help you. You stay in charge — stop anytime.</p><div class="rc-btns"><button id="rcDeny" class="rc-deny">Not now</button><button id="rcAllow" class="rc-allow">Allow control</button></div></div>';
document.body.appendChild(el);
el.querySelector('#rcAllow').onclick=()=>{ rcAllowed=true; rcPost({type:'rc-arm', on:true}); el.remove(); showControlBanner(); };
el.querySelector('#rcAllow').onclick=()=>{ el.remove(); rcGrantControl(); };
el.querySelector('#rcDeny').onclick=()=>{ el.remove(); };
}
// Grant / revoke control. Both the consent dialog and the bar's control icon route through here, so the
// icon, banner and the shell's injection gate always agree.
function rcGrantControl(){
if(!rcDesktop){ setStatus('Remote control needs the Biz Connect desktop app. Your agent can still see your screen.'); return; }
rcAllowed=true; rcPost({type:'rc-arm', on:true}); showControlBanner(); updateRcBtn();
}
function rcStopControl(){ rcAllowed=false; rcPrompted=false; rcPost({type:'rc-arm', on:false}); const b=document.getElementById('rcBanner'); if(b) b.remove(); updateRcBtn(); }
function updateRcBtn(){
const b=document.getElementById('rcBtn'); if(!b) return;
b.style.background=rcAllowed?'#16a34a':'#6b7280';
b.style.opacity=rcDesktop?'1':'.55';
b.title=!rcDesktop ? 'Remote control needs the desktop app' : (rcAllowed?'Remote control is ON — tap to stop':'Remote control is OFF — tap to allow');
}
function showControlBanner(){
if(document.getElementById('rcBanner')) return;
const el=document.createElement('div'); el.id='rcBanner'; el.className='rc-banner';
@@ -251,7 +264,6 @@ function showControlBanner(){
document.body.appendChild(el);
el.querySelector('#rcStop').onclick=rcStopControl;
}
function rcStopControl(){ rcAllowed=false; rcPrompted=false; rcPost({type:'rc-arm', on:false}); const b=document.getElementById('rcBanner'); if(b) b.remove(); }
let recTimerInt=null, recStartTs=0;
function fmtElapsed(ms){const s=Math.max(0,Math.floor(ms/1000));return String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');}
function recNotice(on){
@@ -293,10 +305,14 @@ function buildBar(){
const bar=document.createElement('div'); bar.id='sessionBar';
bar.style.cssText='position:fixed;right:18px;bottom:18px;z-index:2147483000;display:flex;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:8px 12px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)';
const mic=_btn('micBtn',SVG_MICOFF,'Muted','#6b7280');
// Remote-control access, right next to mic/chat, so the customer can see AND flip it at a glance.
const rcb=_btn('rcBtn',(window.ic?window.ic('monitor',18):''),'Remote control is OFF','#6b7280');
const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569');
const end=_btn('endBtn2',SVG_END,'End','#dc2626');
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(end);
bar.appendChild(mic);bar.appendChild(rcb);bar.appendChild(chat);bar.appendChild(end);
document.body.appendChild(bar);
rcb.onclick=()=>{ if(rcAllowed) rcStopControl(); else rcGrantControl(); };
updateRcBtn();
const setMic=(on)=>{mic.title=on?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+(on?SVG_MIC:SVG_MICOFF)+'</span>';mic.style.background=on?'#2563eb':'#6b7280';};
mic.onclick=async()=>{
if(!window.__mic){