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:
@@ -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 'offer': await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
|
||||
// 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);
|
||||
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;
|
||||
@@ -329,7 +331,7 @@ function buildBar(){
|
||||
// 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)';
|
||||
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 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');
|
||||
@@ -340,13 +342,22 @@ function buildBar(){
|
||||
// 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'; });
|
||||
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.
|
||||
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';};
|
||||
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;
|
||||
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(_){} };
|
||||
@@ -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
|
||||
// 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'); }
|
||||
// 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;
|
||||
function setEngaged(on){
|
||||
const next=!!on; if(next===rcEngaged) return;
|
||||
|
||||
+125
-44
@@ -19,8 +19,11 @@
|
||||
<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; }
|
||||
*{box-sizing:border-box;}
|
||||
html,body{height:100%;}
|
||||
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;}
|
||||
/* #8a: iOS rubber-band scrolling bounced the whole page, exposing the background BELOW the fixed
|
||||
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 ---- */
|
||||
/* #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;}
|
||||
*{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::-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;}
|
||||
@@ -328,9 +334,11 @@
|
||||
.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 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;}
|
||||
.lightbox .lb-close{right:18px;}
|
||||
.lightbox .lb-dl{right:74px;}
|
||||
/* new #2: on iOS these sat UNDER the Dynamic Island / notch and couldn't be tapped. Push them below
|
||||
the safe area (and clear of the right-side inset in landscape). */
|
||||
.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 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;}
|
||||
@@ -414,23 +422,28 @@
|
||||
.meet-panel .mp-copylink:hover{filter:brightness(1.05);}
|
||||
/* In-meeting chat panel (brand-styled) */
|
||||
.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);}
|
||||
.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);}
|
||||
/* Compose block at the BOTTOM: recipient picker sits right above the input (new #3). */
|
||||
.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-empty{color:var(--muted);font-size:.82rem;text-align:center;margin:auto;padding:1rem;}
|
||||
.meet-chat .mc-row{display:flex;}
|
||||
.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.priv .mc-bub{outline:1.5px dashed rgba(31,59,115,.4);}
|
||||
.meet-chat .mc-row.mine.priv .mc-bub{outline-color:rgba(255,255,255,.55);}
|
||||
.meet-chat .mc-row.priv .mc-bub{background:#fef3c7;border-color:#fcd34d;color:#1f2430;}
|
||||
.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-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-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-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: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;}
|
||||
@@ -589,10 +602,11 @@
|
||||
/* 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-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, .guest-left .gp-brand{display:flex;align-items:center;justify-content:center;gap:.5rem;margin-bottom:1rem;}
|
||||
.guest-prejoin .gp-brand img, .guest-left .gp-brand img{height:34px;width:auto;object-fit:contain;}
|
||||
.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 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;}
|
||||
@@ -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.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 .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: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, .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-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{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%);}
|
||||
@@ -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:hover{filter:brightness(1.12);}
|
||||
.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:hover{background:var(--blue-soft);}
|
||||
.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 .sec-btn{display:none;} /* screen, record, transcript, chat, participants → ⋮ */
|
||||
.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-txt{font-size:.88rem;color:var(--ink);line-height:1.25;}
|
||||
@@ -967,7 +989,7 @@
|
||||
<body>
|
||||
<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>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
|
||||
// (emojis stay as plain Unicode). (#5)
|
||||
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';
|
||||
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;
|
||||
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('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();
|
||||
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 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 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>';
|
||||
p.innerHTML=head+toSel+body+foot;
|
||||
// The recipient picker lives at the BOTTOM, right where you type (new #3).
|
||||
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();
|
||||
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 log=p.querySelector('#mcLog'); if(log) log.scrollTop=log.scrollHeight;
|
||||
const inp=p.querySelector('#mcInput'); if(inp) setTimeout(()=>inp.focus(),0);
|
||||
@@ -2083,8 +2109,16 @@ function meetChatSend(){
|
||||
}
|
||||
function meetChatAppend(m){
|
||||
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');
|
||||
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(_){} }
|
||||
}
|
||||
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
|
||||
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
|
||||
// #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 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; }
|
||||
@@ -3445,11 +3483,43 @@ function renderCall(){
|
||||
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true);
|
||||
setTileMute('__local', !meetMic);
|
||||
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),
|
||||
// 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||''); }
|
||||
// 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 meetMicId=(()=>{ try{ return localStorage.getItem('bzc_micdev')||''; }catch(_){ return ''; } })();
|
||||
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); }
|
||||
// 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||''); }
|
||||
// #8d: the speaker button's icon reflects the ACTUAL route — bluetooth/headset when one is in use,
|
||||
// otherwise speaker-on / speaker-off (dimmed when off).
|
||||
// #8c/#8e MOBILE audio route: ONE button that cycles Speaker → Earpiece → Bluetooth (only when a headset
|
||||
// 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;
|
||||
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(){
|
||||
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.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(){
|
||||
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();
|
||||
}
|
||||
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});
|
||||
items.push({ic:'chat', label:'Chat'+(meetChatUnread?(' ('+meetChatUnread+')'):''), fn:toggleMeetChat});
|
||||
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';
|
||||
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);
|
||||
@@ -3557,18 +3632,21 @@ async function openAudioMenu(anchor){
|
||||
// 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.
|
||||
// Tap = advance to the next available route. Bluetooth is only in the cycle while a headset is connected.
|
||||
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)); _btConnected=!!bt;
|
||||
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); } // speaker off + headset present → route to the headset
|
||||
else { await setSpeakerDevice(''); } // no headset → back to the system default (earpiece on mobile)
|
||||
const routes=['speaker','earpiece'].concat(bt?['bt']:[]);
|
||||
const i=routes.indexOf(meetRoute);
|
||||
meetRoute=routes[(i<0?0:(i+1)%routes.length)];
|
||||
try{ localStorage.setItem('bzc_route', meetRoute); }catch(_){}
|
||||
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();
|
||||
toast(meetSpeakerOn?'Speaker on':(bt?'Using headset':'Speaker off'));
|
||||
toast(routeLabel());
|
||||
}
|
||||
function addTile(id, stream, label, muted){
|
||||
const grid=document.getElementById('meetGrid'); if(!grid) return;
|
||||
@@ -3814,6 +3892,9 @@ async function onMeetMsg(e){
|
||||
refreshMeetPanel(); updateHostControls();
|
||||
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-rejected'){ toast('The host didn’t let you in.'); leaveMeeting(true); return; }
|
||||
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">'
|
||||
+'<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>'
|
||||
+'<h2>You’ve left the meeting</h2>'
|
||||
+'<p class="gp-sub">Thanks for joining'+((ME&&ME.name)?(', '+pEsc(firstName(ME.name))):'')+'.</p>'
|
||||
+'<h2>'+(_guestRoom?'You’ve left the meeting':'This meeting has ended')+'</h2>'
|
||||
+'<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>':'')
|
||||
+'<div class="gp-note">'+ic('info',13)+' You can close this tab safely.</div>'
|
||||
+'</div>';
|
||||
|
||||
@@ -264,6 +264,31 @@ function rcGrantControl(){
|
||||
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(); }
|
||||
// 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(){
|
||||
const b=document.getElementById('rcBtn'); if(!b) return;
|
||||
b.style.background=rcAllowed?'#16a34a':'#6b7280';
|
||||
@@ -328,6 +353,7 @@ function buildBar(){
|
||||
document.body.appendChild(bar);
|
||||
rcb.onclick=()=>{ if(rcAllowed) rcStopControl(); else rcGrantControl(); };
|
||||
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';};
|
||||
mic.onclick=async()=>{
|
||||
if(!window.__mic){
|
||||
|
||||
Reference in New Issue
Block a user