fix: off-state colors, mobile audio route, iOS rail/PTR, in-place join, draggable bars (0.1.16/batch76)

Regressions I introduced, now fixed:
#8d Mic/Camera OFF went DIM — my new `.meet-ic.off` rule overrode the original RED.
    Scoped the lite style to the speaker button only; mic/cam OFF are red again.
#7  Guest "left the meeting" was unstyled (dark text on blue, no card): the card CSS was
    scoped to .guest-prejoin only. Shared with .guest-left. Rejoin no longer dead-ends —
    if the room is gone we say "This meeting has ended" and hide the button.

Reported:
#8d Speaker button no longer shows on desktop (devices already live under the mic ▾).
#8c/#8e Mobile speaker button now CYCLES Speaker → Earpiece → Bluetooth (icon follows the
    route); dropped the redundant "Audio devices" entry from the ⋮ menu.
#8a iOS gap under the bottom rail: the page rubber-band-bounced, exposing background beneath
    the fixed bar. overscroll-behavior:none + fixed body pins it.
#8b Pull-to-refresh now fires on iOS too (overscroll-behavior:contain on the lists so Safari's
    rubber band stops swallowing the gesture; scrollTop>2 tolerance for momentum).
#5  A meeting link no longer opens a whole new window: same-origin urls navigate the main
    window in the shell, and a link clicked in chat joins the meeting IN PLACE.

New observations:
1. The viewer's mic now starts MUTED on a screen session (and can actually be un/muted).
2. iOS lightbox close/download buttons moved below the Dynamic Island (safe-area insets).
3. Meeting chat: recipient picker moved to the BOTTOM next to the input; a private message
   auto-targets your reply back to that person; private vs everyone are visibly different
   (brand amber vs blue/neutral) and the compose area tints in private mode.
4. Meeting bar + screen-session bars are DRAGGABLE (position remembered) so they stop
   covering the shared screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 22:24:36 +05:30
parent 48b6a4050a
commit e8c0b1889f
5 changed files with 203 additions and 50 deletions
+4 -2
View File
@@ -273,10 +273,12 @@ function createWindow() {
// no-login "Share my screen" option + sign-in. It redirects logged-in users straight to /home. // no-login "Share my screen" option + sign-in. It redirects logged-in users straight to /home.
win.loadURL(SERVER_URL + '/'); win.loadURL(SERVER_URL + '/');
// Open target=_blank / external links in the system browser instead of a new Electron window. // Links: EXTERNAL ones go to the system browser. OUR OWN urls (e.g. a meeting invite link clicked in
// chat) must NOT spawn a second app window (#5) — navigate the main window instead.
win.webContents.setWindowOpenHandler(({ url }) => { win.webContents.setWindowOpenHandler(({ url }) => {
if (!url.startsWith(SERVER_URL)) { shell.openExternal(url); return { action: 'deny' }; } if (!url.startsWith(SERVER_URL)) { shell.openExternal(url); return { action: 'deny' }; }
return { action: 'allow' }; try { if (win && !win.isDestroyed()) { if (win.isMinimized()) win.restore(); win.show(); win.focus(); win.loadURL(url); } } catch (_) {}
return { action: 'deny' };
}); });
// Spell-check menu. Two ways in: // Spell-check menu. Two ways in:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "biz-connect-desktop", "name": "biz-connect-desktop",
"version": "0.1.15", "version": "0.1.16",
"description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture", "description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture",
"author": { "author": {
"name": "BizGaze", "name": "BizGaze",
+47 -3
View File
@@ -185,7 +185,9 @@ function connectWS(){
case 'session-ready': if(statusEl)statusEl.innerHTML='<img src="/loaders/loader-orbit.svg" width="20" height="20" style="vertical-align:-5px;margin-right:7px" alt="">Allowed — connecting…'; break; case 'session-ready': if(statusEl)statusEl.innerHTML='<img src="/loaders/loader-orbit.svg" width="20" height="20" style="vertical-align:-5px;margin-right:7px" alt="">Allowed — connecting…'; break;
case 'offer': await pc.setRemoteDescription(new RTCSessionDescription(m.sdp)); case 'offer': await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
// Acquire the agent mic once; on renegotiation (e.g. customer unmutes) just answer. // Acquire the agent mic once; on renegotiation (e.g. customer unmutes) just answer.
if(!window.__mic){ try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>pc.addTrack(t,mic)); }catch(e){} } // Acquire the agent mic MUTED by default — joining a screen session shouldn't open a hot mic on the
// customer without the agent choosing to speak (new #1). The Mic button unmutes it.
if(!window.__mic){ try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>{ t.enabled=false; pc.addTrack(t,mic); }); try{ setMicBtn(false); }catch(_){} }catch(e){} }
const ans=await pc.createAnswer(); await pc.setLocalDescription(ans); const ans=await pc.createAnswer(); await pc.setLocalDescription(ans);
ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break; ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break;
case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break; case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
@@ -329,7 +331,7 @@ function buildBar(){
// Floats over the bottom-right corner with tiny icons, so the shared screen fills the whole viewport. // Floats over the bottom-right corner with tiny icons, so the shared screen fills the whole viewport.
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)'; 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 I=(n)=>(window.ic?window.ic(n,16):'');
const mic=_btn('micBtn',I('mic'),'Mic','#2563eb'); const mic=_btn('micBtn',I('micOff'),'Unmute','#6b7280'); // starts MUTED (new #1)
const ctrl=_btn('ctrlBtn',I('monitor'),'Control OFF — click their screen to take control','#6b7280'); const ctrl=_btn('ctrlBtn',I('monitor'),'Control OFF — click their screen to take control','#6b7280');
const chat=_btn('chatBtn',I('chat'),'Chat','#334155'); 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 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');
@@ -340,13 +342,22 @@ function buildBar(){
// Shrink from the default 48px round to a compact 38px so they read as "tiny icons". // Shrink from the default 48px round to a compact 38px so they read as "tiny icons".
[mic,ctrl,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); ctrl.onclick=()=>setEngaged(!rcEngaged);
makeBarDraggable(bar,'bzc_connectbar_pos'); // new #4: the bar hides part of the shared screen → move it
// A hint over the screen until they take control, so it's obvious how to start driving. // A hint over the screen until they take control, so it's obvious how to start driving.
if(!document.getElementById('ctrlHint')){ if(!document.getElementById('ctrlHint')){
const h=document.createElement('div'); h.id='ctrlHint'; const h=document.createElement('div'); h.id='ctrlHint';
h.textContent='Click the screen to take control · Esc to release'; h.textContent='Click the screen to take control · Esc to release';
document.body.appendChild(h); 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';}; mic.onclick=async()=>{
// If the mic wasn't acquired yet (e.g. the customer never renegotiated), get it now, then toggle.
if(!window.__mic){
try{ const s=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=s; s.getAudioTracks().forEach(t=>{ t.enabled=false; if(pc) pc.addTrack(t,s); }); }
catch(e){ toast('Microphone permission was blocked.'); return; }
}
const t=window.__mic.getAudioTracks()[0]; if(!t) return;
t.enabled=!t.enabled; setMicBtn(t.enabled);
};
chat.onclick=toggleChat; chat.onclick=toggleChat;
rec.onclick=()=>{ if(mediaRecorder&&mediaRecorder.state==='recording') stopRecording(); else startRecording(); }; rec.onclick=()=>{ if(mediaRecorder&&mediaRecorder.state==='recording') stopRecording(); else startRecording(); };
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}catch(_){} }; end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}catch(_){} };
@@ -420,6 +431,39 @@ video.addEventListener('contextmenu',e=>e.preventDefault());
// in chat, never leaks keystrokes to the remote desktop). Click the screen (or the Control button) to // 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. // 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'); } function rcTyping(){ const a=document.activeElement; return a && (a.tagName==='INPUT'||a.tagName==='TEXTAREA'); }
// new #4: drag the floating control bar anywhere — it otherwise sits over part of the shared screen.
// Drag from the bar background (not the buttons); position is remembered.
function makeBarDraggable(el,key){
if(!el||el._drag) return; el._drag=true;
let sx=0,sy=0,ox=0,oy=0,dragging=false;
const pt=(e)=>e.touches?e.touches[0]:e;
const clamp=()=>{ const w=el.offsetWidth,h=el.offsetHeight;
let x=parseFloat(el.style.left||'0'), y=parseFloat(el.style.top||'0');
x=Math.max(4,Math.min(x,window.innerWidth-w-4)); y=Math.max(4,Math.min(y,window.innerHeight-h-4));
el.style.left=x+'px'; el.style.top=y+'px'; };
const pin=(r)=>{ el.style.left=r.left+'px'; el.style.top=r.top+'px'; el.style.right='auto'; el.style.bottom='auto'; };
try{ const s=JSON.parse(localStorage.getItem(key)||'null'); if(s&&typeof s.x==='number'){ pin({left:s.x,top:s.y}); clamp(); } }catch(_){}
const move=(e)=>{ if(!dragging) return; const p=pt(e); el.style.left=(ox+(p.clientX-sx))+'px'; el.style.top=(oy+(p.clientY-sy))+'px'; clamp(); if(e.cancelable) e.preventDefault(); };
const up=()=>{ if(!dragging) return; dragging=false; el.style.opacity='';
document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up);
document.removeEventListener('touchmove',move); document.removeEventListener('touchend',up);
try{ localStorage.setItem(key, JSON.stringify({x:parseFloat(el.style.left), y:parseFloat(el.style.top)})); }catch(_){} };
const down=(e)=>{ if(e.target.closest('button,select,input,a')) return;
const r=el.getBoundingClientRect(); pin(r);
const p=pt(e); sx=p.clientX; sy=p.clientY; ox=r.left; oy=r.top; dragging=true; el.style.opacity='.92';
document.addEventListener('mousemove',move); document.addEventListener('mouseup',up);
document.addEventListener('touchmove',move,{passive:false}); document.addEventListener('touchend',up);
if(e.cancelable) e.preventDefault(); };
el.addEventListener('mousedown',down); el.addEventListener('touchstart',down,{passive:false});
el.style.cursor='move'; el.title='Drag to move';
}
// Mic button state (kept global so the offer handler can set it once the mic is acquired, muted).
function setMicBtn(on){
const b=document.getElementById('micBtn'); if(!b) return;
b.title=on?'Mute':'Unmute';
b.innerHTML='<span style="display:inline-flex">'+(window.ic?window.ic(on?'mic':'micOff',16):'')+'</span>';
b.style.background=on?'#2563eb':'#6b7280';
}
let rcEngaged=false; let rcEngaged=false;
function setEngaged(on){ function setEngaged(on){
const next=!!on; if(next===rcEngaged) return; const next=!!on; if(next===rcEngaged) return;
+125 -44
View File
@@ -19,8 +19,11 @@
<style> <style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; } :root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
*{box-sizing:border-box;} *{box-sizing:border-box;}
html,body{height:100%;} /* #8a: iOS rubber-band scrolling bounced the whole page, exposing the background BELOW the fixed
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;display:flex;flex-direction:column;height:100vh;height:100dvh;overflow:hidden;} bottom rail (the "gap"). overscroll-behavior:none pins the page; the rail's own height already
includes the home-indicator inset. */
html,body{height:100%;overscroll-behavior:none;}
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;display:flex;flex-direction:column;height:100vh;height:100dvh;overflow:hidden;position:fixed;inset:0;width:100%;}
/* ---- Top bar ---- */ /* ---- Top bar ---- */
/* #4: the top blue bar is removed everywhere — bell+profile live in the chat-list header. */ /* #4: the top blue bar is removed everywhere — bell+profile live in the chat-list header. */
@@ -153,7 +156,10 @@
::-webkit-scrollbar-corner{background:transparent;} ::-webkit-scrollbar-corner{background:transparent;}
*{scrollbar-width:thin;scrollbar-color:#c7d0dd transparent;} *{scrollbar-width:thin;scrollbar-color:#c7d0dd transparent;}
.chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;scrollbar-width:thin;scrollbar-color:transparent transparent;} /* #8b: overscroll-behavior:contain stops Safari/Chrome's own rubber-band + page pull-to-refresh from
swallowing the gesture, so OUR pull-to-refresh fires on iOS as well as Android. */
.chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;scrollbar-width:thin;scrollbar-color:transparent transparent;overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch;}
.convo-msgs{overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch;}
.chatlist:hover{scrollbar-color:#c7d0dd transparent;} .chatlist:hover{scrollbar-color:#c7d0dd transparent;}
.chatlist::-webkit-scrollbar{width:6px;} .chatlist::-webkit-scrollbar{width:6px;}
.chatlist::-webkit-scrollbar-button,.chatlist::-webkit-scrollbar-button:start:decrement,.chatlist::-webkit-scrollbar-button:end:increment{display:none!important;height:0!important;width:0!important;} .chatlist::-webkit-scrollbar-button,.chatlist::-webkit-scrollbar-button:start:decrement,.chatlist::-webkit-scrollbar-button:end:increment{display:none!important;height:0!important;width:0!important;}
@@ -328,9 +334,11 @@
.bubble.mine code{background:rgba(255,255,255,.2);} .bubble.mine code{background:rgba(255,255,255,.2);}
.lightbox{position:fixed;inset:0;z-index:9900;background:rgba(8,12,22,.88);display:flex;align-items:center;justify-content:center;} /* above modals (9800) so the media→image preview opens on top */ .lightbox{position:fixed;inset:0;z-index:9900;background:rgba(8,12,22,.88);display:flex;align-items:center;justify-content:center;} /* above modals (9800) so the media→image preview opens on top */
.lightbox img{max-width:92vw;max-height:88vh;border-radius:10px;box-shadow:0 16px 50px rgba(0,0,0,.5);} .lightbox img{max-width:92vw;max-height:88vh;border-radius:10px;box-shadow:0 16px 50px rgba(0,0,0,.5);}
.lightbox .lb-close,.lightbox .lb-dl{position:absolute;top:18px;border:none;background:rgba(255,255,255,.14);color:#fff;width:44px;height:44px;border-radius:50%;display:grid;place-items:center;cursor:pointer;text-decoration:none;} /* new #2: on iOS these sat UNDER the Dynamic Island / notch and couldn't be tapped. Push them below
.lightbox .lb-close{right:18px;} the safe area (and clear of the right-side inset in landscape). */
.lightbox .lb-dl{right:74px;} .lightbox .lb-close,.lightbox .lb-dl{position:absolute;top:calc(18px + env(safe-area-inset-top,0px));border:none;background:rgba(255,255,255,.14);color:#fff;width:44px;height:44px;border-radius:50%;display:grid;place-items:center;cursor:pointer;text-decoration:none;z-index:2;}
.lightbox .lb-close{right:calc(18px + env(safe-area-inset-right,0px));}
.lightbox .lb-dl{right:calc(74px + env(safe-area-inset-right,0px));}
.lightbox .lb-close:hover,.lightbox .lb-dl:hover{background:rgba(255,255,255,.28);} .lightbox .lb-close:hover,.lightbox .lb-dl:hover{background:rgba(255,255,255,.28);}
.lightbox img{max-width:82vw;} .lightbox img{max-width:82vw;}
.lightbox .lb-nav{position:absolute;top:50%;transform:translateY(-50%);border:none;background:rgba(255,255,255,.16);color:#fff;width:32px;height:32px;border-radius:50%;display:grid;place-items:center;cursor:pointer;} .lightbox .lb-nav{position:absolute;top:50%;transform:translateY(-50%);border:none;background:rgba(255,255,255,.16);color:#fff;width:32px;height:32px;border-radius:50%;display:grid;place-items:center;cursor:pointer;}
@@ -414,23 +422,28 @@
.meet-panel .mp-copylink:hover{filter:brightness(1.05);} .meet-panel .mp-copylink:hover{filter:brightness(1.05);}
/* In-meeting chat panel (brand-styled) */ /* In-meeting chat panel (brand-styled) */
.meet-chat{width:320px;} .meet-chat{width:320px;}
.meet-chat .mc-to{display:flex;align-items:center;gap:.5rem;padding:.5rem .8rem;border-bottom:1px solid var(--line);font-size:.8rem;color:var(--muted);} /* Compose block at the BOTTOM: recipient picker sits right above the input (new #3). */
.meet-chat .mc-to select{flex:1;border:1px solid var(--line);border-radius:8px;padding:.35rem .5rem;font:inherit;font-size:.82rem;background:#fbfcfe;color:var(--ink);} .meet-chat .mc-compose{border-top:1px solid var(--line);background:var(--card);}
.meet-chat .mc-compose.private{background:#fffbeb;} /* private mode is visibly different */
.meet-chat .mc-to{display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem .1rem;font-size:.75rem;color:var(--muted);}
.meet-chat .mc-to select{flex:1;border:1px solid var(--line);border-radius:8px;padding:.3rem .45rem;font:inherit;font-size:.8rem;background:#fbfcfe;color:var(--ink);}
.meet-chat .mc-compose.private .mc-to select{border-color:#f5c518;background:#fffdf5;color:#7a5c00;font-weight:600;}
.meet-chat .mc-log{flex:1;overflow-y:auto;padding:.6rem .7rem;display:flex;flex-direction:column;gap:.4rem;} .meet-chat .mc-log{flex:1;overflow-y:auto;padding:.6rem .7rem;display:flex;flex-direction:column;gap:.4rem;}
.meet-chat .mc-empty{color:var(--muted);font-size:.82rem;text-align:center;margin:auto;padding:1rem;} .meet-chat .mc-empty{color:var(--muted);font-size:.82rem;text-align:center;margin:auto;padding:1rem;}
.meet-chat .mc-row{display:flex;} .meet-chat .mc-row{display:flex;}
.meet-chat .mc-row.mine{justify-content:flex-end;} .meet-chat .mc-row.mine{justify-content:flex-end;}
.meet-chat .mc-bub{max-width:82%;background:#f1f5f9;border-radius:12px;padding:.4rem .6rem;} /* Everyone = neutral/blue. Private = brand amber, clearly a different conversation (new #3). */
.meet-chat .mc-bub{max-width:82%;background:#f1f5f9;border:1px solid transparent;border-radius:12px;padding:.4rem .6rem;}
.meet-chat .mc-row.mine .mc-bub{background:var(--blue);color:#fff;} .meet-chat .mc-row.mine .mc-bub{background:var(--blue);color:#fff;}
.meet-chat .mc-row.priv .mc-bub{outline:1.5px dashed rgba(31,59,115,.4);} .meet-chat .mc-row.priv .mc-bub{background:#fef3c7;border-color:#fcd34d;color:#1f2430;}
.meet-chat .mc-row.mine.priv .mc-bub{outline-color:rgba(255,255,255,.55);} .meet-chat .mc-row.mine.priv .mc-bub{background:#7a5c00;border-color:var(--brand,#FFC708);color:#fff;}
.meet-chat .mc-nm{font-size:.7rem;font-weight:700;color:var(--blue);margin-bottom:.1rem;display:flex;gap:.35rem;align-items:center;} .meet-chat .mc-nm{font-size:.7rem;font-weight:700;color:var(--blue);margin-bottom:.1rem;display:flex;gap:.35rem;align-items:center;}
.meet-chat .mc-row.mine .mc-nm{color:rgba(255,255,255,.9);} .meet-chat .mc-row.mine .mc-nm{color:rgba(255,255,255,.9);}
.meet-chat .mc-priv{font-weight:600;font-size:.62rem;text-transform:uppercase;letter-spacing:.03em;background:rgba(31,59,115,.1);color:var(--blue);border-radius:99px;padding:.02rem .35rem;} .meet-chat .mc-priv{font-weight:600;font-size:.62rem;text-transform:uppercase;letter-spacing:.03em;background:rgba(31,59,115,.1);color:var(--blue);border-radius:99px;padding:.02rem .35rem;}
.meet-chat .mc-row.mine .mc-priv{background:rgba(255,255,255,.2);color:#fff;} .meet-chat .mc-row.mine .mc-priv{background:rgba(255,255,255,.2);color:#fff;}
.meet-chat .mc-tx{font-size:.88rem;line-height:1.35;word-wrap:break-word;overflow-wrap:anywhere;} .meet-chat .mc-tx{font-size:.88rem;line-height:1.35;word-wrap:break-word;overflow-wrap:anywhere;}
.meet-chat .mc-at{font-size:.62rem;opacity:.6;margin-top:.15rem;text-align:right;} .meet-chat .mc-at{font-size:.62rem;opacity:.6;margin-top:.15rem;text-align:right;}
.meet-chat .mc-foot{display:flex;gap:.4rem;padding:.5rem .6rem;border-top:1px solid var(--line);} .meet-chat .mc-foot{display:flex;gap:.4rem;padding:.4rem .6rem .55rem;}
.meet-chat .mc-foot input{flex:1;border:1px solid var(--line);border-radius:9px;padding:.5rem .6rem;font:inherit;font-size:.86rem;background:#fbfcfe;color:var(--ink);outline:none;} .meet-chat .mc-foot input{flex:1;border:1px solid var(--line);border-radius:9px;padding:.5rem .6rem;font:inherit;font-size:.86rem;background:#fbfcfe;color:var(--ink);outline:none;}
.meet-chat .mc-foot input:focus{border-color:var(--blue);} .meet-chat .mc-foot input:focus{border-color:var(--blue);}
.meet-chat .mc-send{flex:0 0 auto;width:38px;border:none;border-radius:9px;background:var(--brand,#FFC708);color:#1f2430;display:grid;place-items:center;cursor:pointer;} .meet-chat .mc-send{flex:0 0 auto;width:38px;border:none;border-radius:9px;background:var(--brand,#FFC708);color:#1f2430;display:grid;place-items:center;cursor:pointer;}
@@ -589,10 +602,11 @@
/* Guest pre-join: brand backdrop, camera preview, mic/cam toggles, name. */ /* 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; .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%);} 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-card, .guest-left .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, .guest-left .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 img, .guest-left .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-brand span, .guest-left .gp-brand span{font-size:1rem;font-weight:600;color:var(--blue);} .guest-prejoin .gp-brand b, .guest-left .gp-brand b{color:var(--blue);}
.guest-left .gp-card h2{margin:.2rem 0 .3rem;font-size:1.2rem;color:var(--ink);}
.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{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-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-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;}
@@ -601,13 +615,13 @@
.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{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 .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 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 .gp-sub, .guest-left .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{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:focus{border-color:var(--blue);}
.guest-prejoin input.err{border-color:#dc2626;background:#fef2f2;} .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, .guest-left .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-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-prejoin .gp-note, .guest-left .gp-note{display:flex;align-items:center;justify-content:center;gap:.3rem;margin-top:.7rem;font-size:.74rem;color:var(--muted);}
/* Guest "left the meeting" — same brand language as the pre-join card. */ /* Guest "left the meeting" — same brand language as the pre-join card. */
.guest-left{position:fixed;inset:0;z-index:9900;display:flex;align-items:center;justify-content:center;padding:1rem; .guest-left{position:fixed;inset:0;z-index:9900;display:flex;align-items:center;justify-content:center;padding:1rem;
background:radial-gradient(1200px 600px at 50% -10%, #2b4f96 0%, #1F3B73 55%, #16294f 100%);} background:radial-gradient(1200px 600px at 50% -10%, #2b4f96 0%, #1F3B73 55%, #16294f 100%);}
@@ -691,7 +705,14 @@
.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{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);} .meet-bar .mic-caret:hover{filter:brightness(1.12);}
.meet-bar .more-btn{display:none;} /* desktop: everything is on the bar */ .meet-bar .more-btn{display:none;} /* desktop: everything is on the bar */
.meet-bar .meet-ic.off{background:#e2e8f0;color:#94a3b8;} /* speaker off → lite colour (#8d) */ /* new #4: the bar can be dragged clear of the shared screen */
.draggable{cursor:move;}
.draggable.dragging{opacity:.92;box-shadow:0 18px 40px rgba(0,0,0,.35);z-index:9500;}
.meet-bar.draggable{border-radius:14px;}
/* Desktop already has speaker+mic devices behind the mic ▾ — no separate speaker button (#8d). */
.meet-bar .spk-btn{display:none;}
/* Speaker OFF is a "lite" state; mic/cam OFF stay RED (see .meet-ic.off above — do NOT override it). */
.meet-bar .spk-btn.off{background:#e2e8f0;color:#94a3b8;}
.gi-list .mrow.dm-able{cursor:pointer;border-radius:9px;} .gi-list .mrow.dm-able{cursor:pointer;border-radius:9px;}
.gi-list .mrow.dm-able:hover{background:var(--blue-soft);} .gi-list .mrow.dm-able:hover{background:var(--blue-soft);}
.spk-menu .mm-opt{display:flex;align-items:center;gap:.55rem;} .spk-menu .mm-opt{display:flex;align-items:center;gap:.55rem;}
@@ -701,6 +722,7 @@
.meet-bar .mic-caret{display:none;} /* #8c: no device dropdown on mobile — plain on/off */ .meet-bar .mic-caret{display:none;} /* #8c: no device dropdown on mobile — plain on/off */
.meet-bar .sec-btn{display:none;} /* screen, record, transcript, chat, participants → ⋮ */ .meet-bar .sec-btn{display:none;} /* screen, record, transcript, chat, participants → ⋮ */
.meet-bar .more-btn{display:inline-flex;} .meet-bar .more-btn{display:inline-flex;}
.meet-bar .spk-btn{display:inline-flex;} /* speaker/earpiece/bluetooth toggle is MOBILE-only */
} }
.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-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-txt{font-size:.88rem;color:var(--ink);line-height:1.25;}
@@ -967,7 +989,7 @@
<body> <body>
<script src="/icons.js?v=6"></script> <script src="/icons.js?v=6"></script>
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></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-13-batch75';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-13-batch76';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 // 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) // (emojis stay as plain Unicode). (#5)
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script> function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
@@ -1231,7 +1253,7 @@ function enablePullRefresh(el, onRefresh){
if(getComputedStyle(el).position==='static') el.style.position='relative'; if(getComputedStyle(el).position==='static') el.style.position='relative';
const ind=document.createElement('div'); ind.className='ptr-ind'; ind.innerHTML='<span class="ptr-g"></span>'; el.appendChild(ind); const ind=document.createElement('div'); ind.className='ptr-ind'; ind.innerHTML='<span class="ptr-g"></span>'; el.appendChild(ind);
let startY=0, pulling=false, dist=0, busy=false; const TRIGGER=70; let startY=0, pulling=false, dist=0, busy=false; const TRIGGER=70;
el.addEventListener('touchstart',(e)=>{ if(busy||el.scrollTop>0||e.touches.length!==1) return; startY=e.touches[0].clientY; pulling=true; dist=0; },{passive:true}); el.addEventListener('touchstart',(e)=>{ if(busy||el.scrollTop>2||e.touches.length!==1) return; startY=e.touches[0].clientY; pulling=true; dist=0; },{passive:true}); // >2 (not >0): iOS momentum leaves a sub-pixel scrollTop
el.addEventListener('touchmove',(e)=>{ if(!pulling||busy) return; dist=e.touches[0].clientY-startY; if(dist<=0||el.scrollTop>0){ pulling=false; ind.style.opacity='0'; ind.style.transform='translateY(-48px)'; return; } const d=Math.min(dist*0.5,84); ind.style.opacity=String(Math.min(d/56,1)); ind.style.transform='translateY('+(d-48)+'px)'; if(dist>10) e.preventDefault(); },{passive:false}); el.addEventListener('touchmove',(e)=>{ if(!pulling||busy) return; dist=e.touches[0].clientY-startY; if(dist<=0||el.scrollTop>0){ pulling=false; ind.style.opacity='0'; ind.style.transform='translateY(-48px)'; return; } const d=Math.min(dist*0.5,84); ind.style.opacity=String(Math.min(d/56,1)); ind.style.transform='translateY('+(d-48)+'px)'; if(dist>10) e.preventDefault(); },{passive:false});
el.addEventListener('touchend',async()=>{ if(!pulling) return; pulling=false; if(dist>=TRIGGER){ busy=true; ind.classList.add('spin'); ind.style.opacity='1'; ind.style.transform='translateY(14px)'; try{ await onRefresh(); }catch(_){} await new Promise(r=>setTimeout(r,250)); ind.classList.remove('spin'); busy=false; } ind.style.opacity='0'; ind.style.transform='translateY(-48px)'; dist=0; }); el.addEventListener('touchend',async()=>{ if(!pulling) return; pulling=false; if(dist>=TRIGGER){ busy=true; ind.classList.add('spin'); ind.style.opacity='1'; ind.style.transform='translateY(14px)'; try{ await onRefresh(); }catch(_){} await new Promise(r=>setTimeout(r,250)); ind.classList.remove('spin'); busy=false; } ind.style.opacity='0'; ind.style.transform='translateY(-48px)'; dist=0; });
} }
@@ -2058,12 +2080,16 @@ function renderMeetChat(){
const recips=meetChatRecipients(); const recips=meetChatRecipients();
if(meetChatTo && !recips.some(r=>r.id===meetChatTo)) meetChatTo=''; // recipient left → fall back to Everyone if(meetChatTo && !recips.some(r=>r.id===meetChatTo)) meetChatTo=''; // recipient left → fall back to Everyone
const head='<div class="mp-head"><b>'+ic('chat',15)+' Meeting chat</b><span style="flex:1"></span><button class="mp-x" title="Close">'+ic('x',16)+'</button></div>'; const head='<div class="mp-head"><b>'+ic('chat',15)+' Meeting chat</b><span style="flex:1"></span><button class="mp-x" title="Close">'+ic('x',16)+'</button></div>';
const toSel='<div class="mc-to"><span>To</span><select id="mcTo"><option value=""'+(!meetChatTo?' selected':'')+'>Everyone</option>'+recips.map(r=>'<option value="'+pEsc(r.id)+'"'+(meetChatTo===r.id?' selected':'')+'>'+pEsc(r.name)+' (private)</option>').join('')+'</select></div>';
const body='<div class="mc-log" id="mcLog">'+(meetChatLog.length?meetChatLog.map(mcRowHTML).join(''):'<div class="mc-empty">No messages yet — say hello 👋</div>')+'</div>'; const body='<div class="mc-log" id="mcLog">'+(meetChatLog.length?meetChatLog.map(mcRowHTML).join(''):'<div class="mc-empty">No messages yet — say hello 👋</div>')+'</div>';
const foot='<form class="mc-foot" id="mcForm"><input id="mcInput" placeholder="Type a message…" autocomplete="off" maxlength="2000"><button type="submit" class="mc-send" title="Send">'+ic('send',16)+'</button></form>'; // The recipient picker lives at the BOTTOM, right where you type (new #3).
p.innerHTML=head+toSel+body+foot; const foot='<div class="mc-compose'+(meetChatTo?' private':'')+'">'
+'<div class="mc-to"><span>To</span><select id="mcTo"><option value=""'+(!meetChatTo?' selected':'')+'>Everyone</option>'
+recips.map(r=>'<option value="'+pEsc(r.id)+'"'+(meetChatTo===r.id?' selected':'')+'>'+pEsc(r.name)+' (private)</option>').join('')+'</select></div>'
+'<form class="mc-foot" id="mcForm"><input id="mcInput" placeholder="'+(meetChatTo?'Private message…':'Message everyone…')+'" autocomplete="off" maxlength="2000"><button type="submit" class="mc-send" title="Send">'+ic('send',16)+'</button></form>'
+'</div>';
p.innerHTML=head+body+foot;
p.querySelector('.mp-x').onclick=()=>p.remove(); p.querySelector('.mp-x').onclick=()=>p.remove();
const sel=p.querySelector('#mcTo'); if(sel) sel.onchange=()=>{ meetChatTo=sel.value; }; const sel=p.querySelector('#mcTo'); if(sel) sel.onchange=()=>{ meetChatTo=sel.value; renderMeetChat(); };
const form=p.querySelector('#mcForm'); if(form) form.onsubmit=(e)=>{ e.preventDefault(); meetChatSend(); }; const form=p.querySelector('#mcForm'); if(form) form.onsubmit=(e)=>{ e.preventDefault(); meetChatSend(); };
const log=p.querySelector('#mcLog'); if(log) log.scrollTop=log.scrollHeight; const log=p.querySelector('#mcLog'); if(log) log.scrollTop=log.scrollHeight;
const inp=p.querySelector('#mcInput'); if(inp) setTimeout(()=>inp.focus(),0); const inp=p.querySelector('#mcInput'); if(inp) setTimeout(()=>inp.focus(),0);
@@ -2083,8 +2109,16 @@ function meetChatSend(){
} }
function meetChatAppend(m){ function meetChatAppend(m){
meetChatLog.push(m); if(meetChatLog.length>500) meetChatLog.shift(); meetChatLog.push(m); if(meetChatLog.length>500) meetChatLog.shift();
// A PRIVATE message to me → my reply defaults back to that person (new #3), so you don't accidentally
// answer a private note in front of everyone.
const privFrom=(m.direct && m.from!=='__self' && meetPeers.has(m.from)) ? m.from : null;
if(privFrom) meetChatTo=privFrom;
const panel=document.getElementById('meetChat'); const panel=document.getElementById('meetChat');
if(panel){ const log=document.getElementById('mcLog'); if(log){ const empty=log.querySelector('.mc-empty'); if(empty) empty.remove(); log.insertAdjacentHTML('beforeend', mcRowHTML(m)); log.scrollTop=log.scrollHeight; } } if(panel){
if(privFrom){ renderMeetChat(); return; } // re-render so the "To" picker + compose tint follow
const log=document.getElementById('mcLog');
if(log){ const empty=log.querySelector('.mc-empty'); if(empty) empty.remove(); log.insertAdjacentHTML('beforeend', mcRowHTML(m)); log.scrollTop=log.scrollHeight; }
}
else if(m.from!=='__self'){ meetChatUnread++; updateMeetChatBadge(); try{ playPing(); }catch(_){} } else if(m.from!=='__self'){ meetChatUnread++; updateMeetChatBadge(); try{ playPing(); }catch(_){} }
} }
function refreshMeetPanel(){ if(document.getElementById('meetPanel')) renderMeetPanel(); } function refreshMeetPanel(){ if(document.getElementById('meetPanel')) renderMeetPanel(); }
@@ -2284,6 +2318,10 @@ async function openConvo(kind,id){
if(_selMode){ const bb=e.target.closest('.bubble'); if(bb) toggleSel(bb); return; } // #1: selection mode — tap toggles if(_selMode){ const bb=e.target.closest('.bubble'); if(bb) toggleSel(bb); return; } // #1: selection mode — tap toggles
const fw=e.target.closest('.fwd-btn'); if(fw){ enterSelect(fw.dataset.fwd); return; } // #1: enter forward-selection const fw=e.target.closest('.fwd-btn'); if(fw){ enterSelect(fw.dataset.fwd); return; } // #1: enter forward-selection
const qz=e.target.closest('.quote'); if(qz && qz.dataset.jid){ jumpToMessage(qz.dataset.jid, +qz.dataset.jat||0); return; } // #8: tap a reply → go to the original const qz=e.target.closest('.quote'); if(qz && qz.dataset.jid){ jumpToMessage(qz.dataset.jid, +qz.dataset.jat||0); return; } // #8: tap a reply → go to the original
// #5: a Biz Connect meeting link posted in chat should JOIN IN PLACE — not open a new tab/window.
const ml=e.target.closest('a.msg-link');
if(ml && ml.href){ try{ const u=new URL(ml.href, location.origin); const code=u.searchParams.get('meet');
if(u.origin===location.origin && code && /^\d{6}$/.test(code)){ e.preventDefault(); switchTab('meeting'); enterMeeting(code); return; } }catch(_){} }
const im=e.target.closest('.att-img'); if(im && im.dataset.img){ openLightbox(im.dataset.img); return; } const im=e.target.closest('.att-img'); if(im && im.dataset.img){ openLightbox(im.dataset.img); return; }
const po=e.target.closest('.poll-opt'); if(po){ if(!po.disabled) votePoll(po.dataset.poll, +po.dataset.idx); return; } const po=e.target.closest('.poll-opt'); if(po){ if(!po.disabled) votePoll(po.dataset.poll, +po.dataset.idx); return; }
const pcl=e.target.closest('.poll-close'); if(pcl){ closePoll(pcl.dataset.poll); return; } const pcl=e.target.closest('.poll-close'); if(pcl){ closePoll(pcl.dataset.poll); return; }
@@ -3445,11 +3483,43 @@ function renderCall(){
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true);
setTileMute('__local', !meetMic); setTileMute('__local', !meetMic);
detectBtOutput(); // #8d: pick the right speaker/bluetooth glyph for the current route detectBtOutput(); // #8d: pick the right speaker/bluetooth glyph for the current route
makeDraggable(document.querySelector('.meet-bar'), 'bzc_meetbar_pos'); // new #4: bar covers the shared screen → let them move it
} }
// ---- Audio devices (#3): one menu behind the MIC's ▾ caret, listing Speaker + Microphone (like Teams), // ---- 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 // instead of a separate headphones button. Choosing a speaker applies setSinkId to every meeting media
// element; choosing a mic switches the live input device. // element; choosing a mic switches the live input device.
function isMobileUA(){ return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent||''); } function isMobileUA(){ return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent||''); }
// new #4: let the user drag a floating bar out of the way — it otherwise covers part of the shared
// screen. Drag from anywhere on the bar except a control. Position is remembered per bar.
function makeDraggable(el, key){
if(!el || el._drag) return; el._drag=true;
let sx=0, sy=0, ox=0, oy=0, dragging=false;
const clamp=()=>{ const w=el.offsetWidth, h=el.offsetHeight;
let x=parseFloat(el.style.left||'0'), y=parseFloat(el.style.top||'0');
x=Math.max(4, Math.min(x, window.innerWidth-w-4)); y=Math.max(4, Math.min(y, window.innerHeight-h-4));
el.style.left=x+'px'; el.style.top=y+'px'; };
const pin=(r)=>{ el.style.position='fixed'; el.style.left=r.left+'px'; el.style.top=r.top+'px'; el.style.right='auto'; el.style.bottom='auto'; el.style.margin='0'; el.style.width=r.width+'px'; };
try{ const s=JSON.parse(localStorage.getItem(key)||'null'); if(s&&typeof s.x==='number'){ const r=el.getBoundingClientRect(); pin({left:s.x, top:s.y, width:r.width}); clamp(); } }catch(_){}
const pt=(e)=>e.touches?e.touches[0]:e;
const move=(e)=>{ if(!dragging) return; const p=pt(e);
el.style.left=(ox+(p.clientX-sx))+'px'; el.style.top=(oy+(p.clientY-sy))+'px'; clamp();
if(e.cancelable) e.preventDefault(); };
const up=()=>{ if(!dragging) return; dragging=false; el.classList.remove('dragging');
document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up);
document.removeEventListener('touchmove',move); document.removeEventListener('touchend',up);
try{ localStorage.setItem(key, JSON.stringify({x:parseFloat(el.style.left), y:parseFloat(el.style.top)})); }catch(_){} };
const down=(e)=>{
if(e.target.closest('button,select,input,a')) return; // don't hijack the controls
const r=el.getBoundingClientRect(); pin(r);
const p=pt(e); sx=p.clientX; sy=p.clientY; ox=r.left; oy=r.top; dragging=true; el.classList.add('dragging');
document.addEventListener('mousemove',move); document.addEventListener('mouseup',up);
document.addEventListener('touchmove',move,{passive:false}); document.addEventListener('touchend',up);
if(e.cancelable) e.preventDefault();
};
el.addEventListener('mousedown',down);
el.addEventListener('touchstart',down,{passive:false});
el.classList.add('draggable');
}
let meetSinkId=(()=>{ try{ return localStorage.getItem('bzc_sink')||''; }catch(_){ return ''; } })(); let meetSinkId=(()=>{ try{ return localStorage.getItem('bzc_sink')||''; }catch(_){ return ''; } })();
let meetMicId=(()=>{ try{ return localStorage.getItem('bzc_micdev')||''; }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 let meetSpeakerOn=(()=>{ try{ return localStorage.getItem('bzc_spk')!=='0'; }catch(_){ return true; } })(); // mobile speakerphone
@@ -3457,19 +3527,24 @@ function applySink(el){ try{ if(el && meetSinkId && typeof el.setSinkId==='funct
function applySinkAll(){ document.querySelectorAll('#meetGrid video').forEach(applySink); } function applySinkAll(){ document.querySelectorAll('#meetGrid video').forEach(applySink); }
// Bluetooth/headset devices report themselves in the label — used to pick a sensible icon on mobile. // 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||''); } function isBtLabel(l){ return /bluetooth|airpod|buds|headset|headphone|wh-|wf-/i.test(l||''); }
// #8d: the speaker button's icon reflects the ACTUAL route — bluetooth/headset when one is in use, // #8c/#8e MOBILE audio route: ONE button that cycles Speaker → Earpiece → Bluetooth (only when a headset
// otherwise speaker-on / speaker-off (dimmed when off). // is connected) and back. The icon always shows the route in use, so no separate "Audio devices" entry is
// needed on mobile. meetRoute is the source of truth; meetSpeakerOn stays in sync for the lite/off styling.
let _btConnected=false; let _btConnected=false;
function spkIcon(){ if(_btConnected && !meetSpeakerOn) return 'bluetooth'; return meetSpeakerOn?'speaker':'speakerOff'; } let meetRoute=(()=>{ try{ return localStorage.getItem('bzc_route')||'speaker'; }catch(_){ return 'speaker'; } })(); // 'speaker'|'earpiece'|'bt'
function spkIcon(){ return meetRoute==='bt' ? 'bluetooth' : (meetRoute==='speaker' ? 'speaker' : 'speakerOff'); }
function routeLabel(){ return meetRoute==='bt' ? 'Bluetooth / headset' : (meetRoute==='speaker' ? 'Speaker' : 'Earpiece'); }
function refreshSpkBtn(){ function refreshSpkBtn(){
const b=document.getElementById('meetSpkBtn'); if(!b) return; const b=document.getElementById('meetSpkBtn'); if(!b) return;
b.classList.toggle('off', !meetSpeakerOn); meetSpeakerOn=(meetRoute==='speaker');
b.classList.toggle('off', meetRoute!=='speaker');
b.innerHTML=ic(spkIcon(),20); b.innerHTML=ic(spkIcon(),20);
b.title=(_btConnected&&!meetSpeakerOn)?'Using headset / Bluetooth':(meetSpeakerOn?'Speaker on':'Speaker off'); b.title=routeLabel()+' — tap to switch';
} }
// Detect a connected BT/headset output so the icon can switch to the bluetooth glyph. // Detect a connected BT/headset output so the cycle can include it (and the icon can show it).
async function detectBtOutput(){ async function detectBtOutput(){
try{ const d=await navigator.mediaDevices.enumerateDevices(); _btConnected=d.some(x=>x.kind==='audiooutput' && isBtLabel(x.label)); }catch(_){ _btConnected=false; } try{ const d=await navigator.mediaDevices.enumerateDevices(); _btConnected=d.some(x=>x.kind==='audiooutput' && isBtLabel(x.label)); }catch(_){ _btConnected=false; }
if(meetRoute==='bt' && !_btConnected) meetRoute='speaker'; // headset unplugged → fall back
refreshSpkBtn(); refreshSpkBtn();
} }
try{ if(navigator.mediaDevices && navigator.mediaDevices.addEventListener) navigator.mediaDevices.addEventListener('devicechange', ()=>{ detectBtOutput(); }); }catch(_){} try{ if(navigator.mediaDevices && navigator.mediaDevices.addEventListener) navigator.mediaDevices.addEventListener('devicechange', ()=>{ detectBtOutput(); }); }catch(_){}
@@ -3482,7 +3557,7 @@ function openMeetMore(anchor){
if(!(ME&&ME.guest)) items.push({ic:'fileText', label:(meetTranscribe?'Stop transcript':'Live transcript'), fn:toggleTranscribe}); if(!(ME&&ME.guest)) items.push({ic:'fileText', label:(meetTranscribe?'Stop transcript':'Live transcript'), fn:toggleTranscribe});
items.push({ic:'chat', label:'Chat'+(meetChatUnread?(' ('+meetChatUnread+')'):''), fn:toggleMeetChat}); items.push({ic:'chat', label:'Chat'+(meetChatUnread?(' ('+meetChatUnread+')'):''), fn:toggleMeetChat});
items.push({ic:'users', label:'Participants', fn:toggleMeetPanel}); items.push({ic:'users', label:'Participants', fn:toggleMeetPanel});
items.push({ic:'headphones', label:'Audio devices', fn:()=>openAudioMenu(anchor)}); // No "Audio devices" here (#8e): on mobile the speaker button itself cycles Speaker/Earpiece/Bluetooth.
const menu=document.createElement('div'); menu.className='spk-menu'; const menu=document.createElement('div'); menu.className='spk-menu';
menu.innerHTML=items.map((it,i)=>'<button class="spk-opt mm-opt" data-i="'+i+'">'+ic(it.ic,15)+'<span>'+pEsc(it.label)+'</span></button>').join(''); menu.innerHTML=items.map((it,i)=>'<button class="spk-opt mm-opt" data-i="'+i+'">'+ic(it.ic,15)+'<span>'+pEsc(it.label)+'</span></button>').join('');
document.body.appendChild(menu); document.body.appendChild(menu);
@@ -3557,18 +3632,21 @@ async function openAudioMenu(anchor){
// headset icon; otherwise we toggle between the loudspeaker and the default (earpiece) route. NOTE: true // 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 // 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. // available output device. In the native mobile build this maps to the OS audio route.
// Tap = advance to the next available route. Bluetooth is only in the cycle while a headset is connected.
async function toggleSpeakerphone(){ 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(_){} let outs=[]; try{ outs=(await navigator.mediaDevices.enumerateDevices()).filter(x=>x.kind==='audiooutput'); }catch(_){}
const bt=outs.find(d=>isBtLabel(d.label)); _btConnected=!!bt; const bt=outs.find(d=>isBtLabel(d.label)); _btConnected=!!bt;
if(meetSpeakerOn){ const routes=['speaker','earpiece'].concat(bt?['bt']:[]);
const spk=outs.find(d=>/speaker/i.test(d.label)) || outs.find(d=>d.deviceId==='default') || outs[0]; const i=routes.indexOf(meetRoute);
if(spk) await setSpeakerDevice(spk.deviceId); meetRoute=routes[(i<0?0:(i+1)%routes.length)];
} else if(bt){ await setSpeakerDevice(bt.deviceId); } // speaker off + headset present → route to the headset try{ localStorage.setItem('bzc_route', meetRoute); }catch(_){}
else { await setSpeakerDevice(''); } // no headset → back to the system default (earpiece on mobile) if(meetRoute==='bt' && bt) await setSpeakerDevice(bt.deviceId);
else if(meetRoute==='speaker'){
const spk=outs.find(d=>/speaker/i.test(d.label)) || outs.find(d=>d.deviceId==='default') || (outs[0]&&outs[0].deviceId);
await setSpeakerDevice(typeof spk==='string'?spk:(spk&&spk.deviceId)||'');
} else { await setSpeakerDevice(''); } // earpiece → system default route
refreshSpkBtn(); refreshSpkBtn();
toast(meetSpeakerOn?'Speaker on':(bt?'Using headset':'Speaker off')); toast(routeLabel());
} }
function addTile(id, stream, label, muted){ function addTile(id, stream, label, muted){
const grid=document.getElementById('meetGrid'); if(!grid) return; const grid=document.getElementById('meetGrid'); if(!grid) return;
@@ -3814,6 +3892,9 @@ async function onMeetMsg(e){
refreshMeetPanel(); updateHostControls(); refreshMeetPanel(); updateHostControls();
return; return;
} }
// The room is gone (host ended it / code expired). Say so plainly instead of hanging on "Connecting…"
// — and for a guest, don't offer a Rejoin that can never work (#7).
if(m.type==='error'){ const gone=/not found/i.test(m.message||''); if(ME&&ME.guest){ _guestRoom=gone?null:_guestRoom; toast(gone?'This meeting has ended.':(m.message||'Could not join')); leaveMeeting(true); return; } toast(m.message||'Could not join the meeting'); leaveMeeting(true); return; }
if(m.type==='meeting-lobby-wait'){ renderLobbyWait(); return; } // #4: guest waits for host to admit if(m.type==='meeting-lobby-wait'){ renderLobbyWait(); return; } // #4: guest waits for host to admit
if(m.type==='meeting-rejected'){ toast('The host didnt let you in.'); leaveMeeting(true); return; } if(m.type==='meeting-rejected'){ toast('The host didnt let you in.'); leaveMeeting(true); return; }
if(m.type==='meeting-lobby-request'){ showLobbyRequest(m.peerId, m.name); return; } // host: someone wants in if(m.type==='meeting-lobby-request'){ showLobbyRequest(m.peerId, m.name); return; } // host: someone wants in
@@ -3928,8 +4009,8 @@ function renderGuestLeft(){
el.innerHTML='<div class="gp-card">' el.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-brand"><img src="/mark-light.png" alt="" onerror="this.remove()"><span>Biz <b>Connect</b></span></div>'
+'<div class="gl-tick">'+ic('check',30)+'</div>' +'<div class="gl-tick">'+ic('check',30)+'</div>'
+'<h2>Youve left the meeting</h2>' +'<h2>'+(_guestRoom?'Youve left the meeting':'This meeting has ended')+'</h2>'
+'<p class="gp-sub">Thanks for joining'+((ME&&ME.name)?(', '+pEsc(firstName(ME.name))):'')+'.</p>' +'<p class="gp-sub">'+(_guestRoom?('Thanks for joining'+((ME&&ME.name)?(', '+pEsc(firstName(ME.name))):'')+'.'):'Thanks for joining.')+'</p>'
+(_guestRoom?'<button class="gp-join" id="glRejoin">Rejoin meeting</button>':'') +(_guestRoom?'<button class="gp-join" id="glRejoin">Rejoin meeting</button>':'')
+'<div class="gp-note">'+ic('info',13)+' You can close this tab safely.</div>' +'<div class="gp-note">'+ic('info',13)+' You can close this tab safely.</div>'
+'</div>'; +'</div>';
+26
View File
@@ -264,6 +264,31 @@ function rcGrantControl(){
rcAllowed=true; rcPost({type:'rc-arm', on:true}); showControlBanner(); updateRcBtn(); 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 rcStopControl(){ rcAllowed=false; rcPrompted=false; rcPost({type:'rc-arm', on:false}); const b=document.getElementById('rcBanner'); if(b) b.remove(); updateRcBtn(); }
// new #4: drag the floating bar off whatever it's covering. Position is remembered.
function makeBarDraggable(el,key){
if(!el||el._drag) return; el._drag=true;
let sx=0,sy=0,ox=0,oy=0,dragging=false;
const pt=(e)=>e.touches?e.touches[0]:e;
const clamp=()=>{ const w=el.offsetWidth,h=el.offsetHeight;
let x=parseFloat(el.style.left||'0'), y=parseFloat(el.style.top||'0');
x=Math.max(4,Math.min(x,window.innerWidth-w-4)); y=Math.max(4,Math.min(y,window.innerHeight-h-4));
el.style.left=x+'px'; el.style.top=y+'px'; };
const pin=(r)=>{ el.style.left=r.left+'px'; el.style.top=r.top+'px'; el.style.right='auto'; el.style.bottom='auto'; };
try{ const s=JSON.parse(localStorage.getItem(key)||'null'); if(s&&typeof s.x==='number'){ pin({left:s.x,top:s.y}); clamp(); } }catch(_){}
const move=(e)=>{ if(!dragging) return; const p=pt(e); el.style.left=(ox+(p.clientX-sx))+'px'; el.style.top=(oy+(p.clientY-sy))+'px'; clamp(); if(e.cancelable) e.preventDefault(); };
const up=()=>{ if(!dragging) return; dragging=false; el.style.opacity='';
document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up);
document.removeEventListener('touchmove',move); document.removeEventListener('touchend',up);
try{ localStorage.setItem(key, JSON.stringify({x:parseFloat(el.style.left), y:parseFloat(el.style.top)})); }catch(_){} };
const down=(e)=>{ if(e.target.closest('button,select,input,a')) return;
const r=el.getBoundingClientRect(); pin(r);
const p=pt(e); sx=p.clientX; sy=p.clientY; ox=r.left; oy=r.top; dragging=true; el.style.opacity='.92';
document.addEventListener('mousemove',move); document.addEventListener('mouseup',up);
document.addEventListener('touchmove',move,{passive:false}); document.addEventListener('touchend',up);
if(e.cancelable) e.preventDefault(); };
el.addEventListener('mousedown',down); el.addEventListener('touchstart',down,{passive:false});
el.style.cursor='move'; el.title='Drag to move';
}
function updateRcBtn(){ function updateRcBtn(){
const b=document.getElementById('rcBtn'); if(!b) return; const b=document.getElementById('rcBtn'); if(!b) return;
b.style.background=rcAllowed?'#16a34a':'#6b7280'; b.style.background=rcAllowed?'#16a34a':'#6b7280';
@@ -328,6 +353,7 @@ function buildBar(){
document.body.appendChild(bar); document.body.appendChild(bar);
rcb.onclick=()=>{ if(rcAllowed) rcStopControl(); else rcGrantControl(); }; rcb.onclick=()=>{ if(rcAllowed) rcStopControl(); else rcGrantControl(); };
updateRcBtn(); updateRcBtn();
makeBarDraggable(bar,'bzc_sharebar_pos'); // new #4: let the customer move the bar off their content
const setMic=(on)=>{mic.title=on?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+I(on?'mic':'micOff')+'</span>';mic.style.background=on?'#2563eb':'#6b7280';}; const setMic=(on)=>{mic.title=on?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+I(on?'mic':'micOff')+'</span>';mic.style.background=on?'#2563eb':'#6b7280';};
mic.onclick=async()=>{ mic.onclick=async()=>{
if(!window.__mic){ if(!window.__mic){