fix: guest pre-join, post-admit lobby, audio device menu, RC keyboard (0.1.15/batch74)
1. Guest pre-join redesigned: brand backdrop, live camera preview, mic/cam toggles
applied on entry, initials avatar, name required, "host may admit you" note.
2. Post-admit bug: the guest stayed on "Waiting for the host…" forever — the lobby
screen had replaced the call UI and meeting-joined never re-rendered it. Now it
rebuilds the call on admission (_inLobby).
3. Audio devices: dropped the standalone headphones button. The MIC now has a ▾ caret
opening one Teams-style menu with Speaker + Microphone sections (radio-selected);
speaker uses setSinkId/LiveKit switchActiveDevice, mic switches the live input.
Mobile gets a speakerphone toggle that prefers a connected BT/headset when off.
4. Remote-control keyboard:
- Injector now maps the PHYSICAL key (KeyboardEvent.code) instead of the character,
so Shift+1 types "!" etc. Character mapping was why typing "performed differently".
- Keys reach the sharer ONLY while control is ENGAGED (window focused AND you clicked
their screen). Minimised/unfocused/chat typing stays local. Esc or clicking away
releases; modifiers are released on disengage so nothing sticks.
- Explicit control icons: viewer gets a Control ON/OFF button (green when engaged) +
an on-screen hint; the SHARER gets a control icon beside mic/chat to allow/stop
access at a glance, synced with the consent dialog and banner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+37
-4
@@ -18,7 +18,39 @@ try {
|
||||
|
||||
const available = !!nut;
|
||||
|
||||
// Map browser KeyboardEvent.key values to nut-js Key enum names.
|
||||
// Map the PHYSICAL key (KeyboardEvent.code) to a nut-js Key. This is the correct way to drive a remote
|
||||
// keyboard: press the same physical key the viewer pressed and let the remote OS apply its own modifier
|
||||
// state. Mapping by CHARACTER (mapKey below) broke shifted keys — e.g. Shift+1 typed "1" instead of "!"
|
||||
// and symbols came out wrong ("keyboard performs differently on the sharer's device").
|
||||
const CODE_MAP = {
|
||||
Backspace: 'Backspace', Tab: 'Tab', Enter: 'Enter', NumpadEnter: 'Enter', Escape: 'Escape', Space: 'Space',
|
||||
ShiftLeft: 'LeftShift', ShiftRight: 'RightShift',
|
||||
ControlLeft: 'LeftControl', ControlRight: 'RightControl',
|
||||
AltLeft: 'LeftAlt', AltRight: 'RightAlt',
|
||||
MetaLeft: 'LeftSuper', MetaRight: 'RightSuper',
|
||||
CapsLock: 'CapsLock',
|
||||
PageUp: 'PageUp', PageDown: 'PageDown', End: 'End', Home: 'Home',
|
||||
ArrowLeft: 'Left', ArrowUp: 'Up', ArrowRight: 'Right', ArrowDown: 'Down',
|
||||
Insert: 'Insert', Delete: 'Delete',
|
||||
Minus: 'Minus', Equal: 'Equal', BracketLeft: 'LeftBracket', BracketRight: 'RightBracket',
|
||||
Backslash: 'Backslash', Semicolon: 'Semicolon', Quote: 'Quote', Backquote: 'Grave',
|
||||
Comma: 'Comma', Period: 'Period', Slash: 'Slash',
|
||||
NumpadAdd: 'Add', NumpadSubtract: 'Subtract', NumpadMultiply: 'Multiply', NumpadDivide: 'Divide', NumpadDecimal: 'Decimal',
|
||||
};
|
||||
function mapCode(code) {
|
||||
if (!nut || !code) return null;
|
||||
const K = nut.Key;
|
||||
const named = CODE_MAP[code];
|
||||
if (named && K[named] !== undefined) return [K[named]];
|
||||
let m;
|
||||
if ((m = /^Key([A-Z])$/.exec(code)) && K[m[1]] !== undefined) return [K[m[1]]];
|
||||
if ((m = /^Digit([0-9])$/.exec(code)) && K['Num' + m[1]] !== undefined) return [K['Num' + m[1]]];
|
||||
if ((m = /^Numpad([0-9])$/.exec(code)) && K['NumPad' + m[1]] !== undefined) return [K['NumPad' + m[1]]];
|
||||
if ((m = /^(F\d{1,2})$/.exec(code)) && K[m[1]] !== undefined) return [K[m[1]]];
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map browser KeyboardEvent.key values to nut-js Key enum names. (Fallback when there's no usable code.)
|
||||
function mapKey(key, code) {
|
||||
if (!nut) return null;
|
||||
const K = nut.Key;
|
||||
@@ -82,14 +114,15 @@ async function inject(evt) {
|
||||
if (evt.dx) await (evt.dx > 0 ? nut.mouse.scrollRight(Math.abs(evt.dx)) : nut.mouse.scrollLeft(Math.abs(evt.dx)));
|
||||
break;
|
||||
case 'keydown': {
|
||||
const m = mapKey(evt.key, evt.code);
|
||||
// Prefer the PHYSICAL key so the remote OS applies its own shift/altgr state (correct symbols).
|
||||
const m = mapCode(evt.code) || mapKey(evt.key, evt.code);
|
||||
if (!m) break;
|
||||
if (m.type) { await nut.keyboard.type(m.type); break; }
|
||||
if (m.type) { await nut.keyboard.type(m.type); break; } // last-resort: type the literal character
|
||||
await nut.keyboard.pressKey(...m); m.forEach((k) => pressed.add(k));
|
||||
break;
|
||||
}
|
||||
case 'keyup': {
|
||||
const m = mapKey(evt.key, evt.code);
|
||||
const m = mapCode(evt.code) || mapKey(evt.key, evt.code);
|
||||
if (!m || m.type) break;
|
||||
await nut.keyboard.releaseKey(...m); m.forEach((k) => pressed.delete(k));
|
||||
break;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "biz-connect-desktop",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture",
|
||||
"author": {
|
||||
"name": "BizGaze",
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
FULL viewport underneath it. */
|
||||
body.has-bar #video{width:100vw;height:100vh;}
|
||||
body.has-bar{background:#0b1220;}
|
||||
/* Control engaged: a green inset ring makes it obvious your keyboard now drives THEIR machine. */
|
||||
#video.engaged{box-shadow:inset 0 0 0 3px #16a34a;}
|
||||
#ctrlHint{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:2147483000;background:rgba(15,23,42,.78);color:#fff;font-family:'Segoe UI',system-ui,sans-serif;font-size:.78rem;padding:.4rem .8rem;border-radius:999px;pointer-events:none;}
|
||||
.profile{position:relative}
|
||||
.profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer}
|
||||
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
|
||||
@@ -327,14 +330,22 @@ function buildBar(){
|
||||
bar.style.cssText='position:fixed;right:16px;bottom:16px;z-index:2147483000;display:flex;flex-direction:row;gap:8px;align-items:center;background:rgba(15,23,42,.72);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);padding:7px 9px;border-radius:14px;box-shadow:0 8px 22px rgba(0,0,0,.35)';
|
||||
const I=(n)=>(window.ic?window.ic(n,16):'');
|
||||
const mic=_btn('micBtn',I('mic'),'Mic','#2563eb');
|
||||
const ctrl=_btn('ctrlBtn',I('monitor'),'Control OFF — click their screen to take control','#6b7280');
|
||||
const chat=_btn('chatBtn',I('chat'),'Chat','#334155');
|
||||
const rec=_btn('recBtn','<svg viewBox="0 0 24 24" width="15" height="15"><circle cx="12" cy="12" r="7" fill="#ef4444"/></svg>','Record','#334155');
|
||||
const end=_btn('endBtn2',I('callEnd'),'End','#dc2626');
|
||||
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(rec);bar.appendChild(end);
|
||||
bar.appendChild(mic);bar.appendChild(ctrl);bar.appendChild(chat);bar.appendChild(rec);bar.appendChild(end);
|
||||
document.body.appendChild(bar);
|
||||
document.body.classList.add('has-bar');
|
||||
// Shrink from the default 48px round to a compact 38px so they read as "tiny icons".
|
||||
[mic,chat,rec,end].forEach(b=>{ b.style.width='38px'; b.style.height='38px'; b.style.boxShadow='none'; });
|
||||
[mic,ctrl,chat,rec,end].forEach(b=>{ b.style.width='38px'; b.style.height='38px'; b.style.boxShadow='none'; });
|
||||
ctrl.onclick=()=>setEngaged(!rcEngaged);
|
||||
// A hint over the screen until they take control, so it's obvious how to start driving.
|
||||
if(!document.getElementById('ctrlHint')){
|
||||
const h=document.createElement('div'); h.id='ctrlHint';
|
||||
h.textContent='Click the screen to take control · Esc to release';
|
||||
document.body.appendChild(h);
|
||||
}
|
||||
mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.title=t.enabled?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+I(t.enabled?'mic':'micOff')+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
|
||||
chat.onclick=toggleChat;
|
||||
rec.onclick=()=>{ if(mediaRecorder&&mediaRecorder.state==='recording') stopRecording(); else startRecording(); };
|
||||
@@ -403,12 +414,39 @@ video.addEventListener('mouseup',e=>send({kind:'mouseup',button:e.button,...rel(
|
||||
video.addEventListener('dblclick',e=>send({kind:'dblclick',...rel(e)}));
|
||||
video.addEventListener('wheel',e=>{e.preventDefault();send({kind:'scroll',dx:e.deltaX,dy:e.deltaY});},{passive:false});
|
||||
video.addEventListener('contextmenu',e=>e.preventDefault());
|
||||
// Keyboard: capture at the DOCUMENT level while a session is live so keys work regardless of which
|
||||
// element has focus (the <video> lost focus as soon as you clicked the control bar → "keyboard doesn't
|
||||
// work"). Skip when typing in the chat box so chat still works.
|
||||
// ---- Keyboard control gating ----
|
||||
// Keys reach the SHARER only while control is ENGAGED: the window is focused AND you clicked into the
|
||||
// shared screen. Otherwise your typing stays on YOUR machine (so a minimised/unfocused window, or typing
|
||||
// in chat, never leaks keystrokes to the remote desktop). Click the screen (or the Control button) to
|
||||
// take control; click away, press Esc, or leave the window to release it.
|
||||
function rcTyping(){ const a=document.activeElement; return a && (a.tagName==='INPUT'||a.tagName==='TEXTAREA'); }
|
||||
document.addEventListener('keydown',e=>{ if(!video||video.style.display!=='block'||rcTyping()) return; e.preventDefault(); send({kind:'keydown',key:e.key,code:e.code}); });
|
||||
document.addEventListener('keyup',e=>{ if(!video||video.style.display!=='block'||rcTyping()) return; e.preventDefault(); send({kind:'keyup',key:e.key,code:e.code}); });
|
||||
let rcEngaged=false;
|
||||
function setEngaged(on){
|
||||
const next=!!on; if(next===rcEngaged) return;
|
||||
rcEngaged=next;
|
||||
if(video) video.classList.toggle('engaged', rcEngaged);
|
||||
const b=document.getElementById('ctrlBtn');
|
||||
if(b){ b.style.background=rcEngaged?'#16a34a':'#6b7280'; b.title=rcEngaged?'Control ON — your mouse & keyboard drive their screen (Esc to release)':'Control OFF — click their screen to take control'; }
|
||||
const hint=document.getElementById('ctrlHint'); if(hint) hint.style.display=rcEngaged?'none':'block';
|
||||
// Releasing control must not leave modifiers stuck down on the remote machine.
|
||||
if(!rcEngaged){ ['ShiftLeft','ControlLeft','AltLeft','MetaLeft'].forEach(code=>send({kind:'keyup',key:code.replace(/Left$/,''),code})); }
|
||||
}
|
||||
document.addEventListener('mousedown',(e)=>{
|
||||
if(!video || video.style.display!=='block') return;
|
||||
if(e.target===video){ setEngaged(true); return; } // clicked the shared screen → take control
|
||||
if(e.target.closest && e.target.closest('#sessionBar')) return; // control bar clicks don't release
|
||||
setEngaged(false); // clicked anywhere else → release
|
||||
});
|
||||
window.addEventListener('blur',()=>setEngaged(false)); // window minimised / lost focus → release
|
||||
document.addEventListener('keydown',e=>{
|
||||
if(e.key==='Escape' && rcEngaged){ e.preventDefault(); setEngaged(false); return; }
|
||||
if(!video||video.style.display!=='block'||!rcEngaged||!document.hasFocus()||rcTyping()) return;
|
||||
e.preventDefault(); send({kind:'keydown',key:e.key,code:e.code});
|
||||
});
|
||||
document.addEventListener('keyup',e=>{
|
||||
if(!video||video.style.display!=='block'||!rcEngaged||!document.hasFocus()||rcTyping()) return;
|
||||
e.preventDefault(); send({kind:'keyup',key:e.key,code:e.code});
|
||||
});
|
||||
// Mobile viewer (#4): map touch → mouse so a phone/tablet can control too. Tap = move+click; drag = move.
|
||||
const relT=(t)=>{ const c=contentRect(); return {x:Math.max(0,Math.min(1,(t.clientX-c.left)/c.width)), y:Math.max(0,Math.min(1,(t.clientY-c.top)/c.height))}; };
|
||||
video.addEventListener('touchstart',e=>{ if(!e.touches.length) return; e.preventDefault(); const p=relT(e.touches[0]); send({kind:'mousemove',...p}); send({kind:'mousedown',button:0,...p}); },{passive:false});
|
||||
|
||||
+152
-25
@@ -586,6 +586,28 @@
|
||||
/* Guest meeting mode: an external link-joiner sees ONLY the meeting — no rail, sidebar, or profile. */
|
||||
body.guest-mode .rail, body.guest-mode #chatcol, body.guest-mode #hdrRight, body.guest-mode #navToggle, body.guest-mode .demo-note{display:none!important;}
|
||||
body.guest-mode .content{width:100%;flex:1 1 auto;}
|
||||
/* Guest pre-join: brand backdrop, camera preview, mic/cam toggles, name. */
|
||||
.guest-prejoin{position:fixed;inset:0;z-index:9900;display:flex;align-items:center;justify-content:center;padding:1rem;overflow:auto;
|
||||
background:radial-gradient(1200px 600px at 50% -10%, #2b4f96 0%, #1F3B73 55%, #16294f 100%);}
|
||||
.guest-prejoin .gp-card{background:#fff;border-radius:20px;padding:1.5rem 1.5rem 1.3rem;width:100%;max-width:420px;box-shadow:0 24px 60px rgba(0,0,0,.35);text-align:center;}
|
||||
.guest-prejoin .gp-brand{display:flex;align-items:center;justify-content:center;gap:.5rem;margin-bottom:1rem;}
|
||||
.guest-prejoin .gp-brand img{height:34px;width:auto;object-fit:contain;}
|
||||
.guest-prejoin .gp-brand span{font-size:1rem;font-weight:600;color:var(--blue);} .guest-prejoin .gp-brand b{color:var(--blue);}
|
||||
.guest-prejoin .gp-preview{position:relative;width:100%;aspect-ratio:16/10;border-radius:14px;overflow:hidden;background:#0f172a;display:grid;place-items:center;margin-bottom:.8rem;}
|
||||
.guest-prejoin .gp-preview video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transform:scaleX(-1);}
|
||||
.guest-prejoin .gp-avatar{width:76px;height:76px;border-radius:50%;display:grid;place-items:center;font-weight:700;font-size:1.5rem;color:#334155;z-index:1;}
|
||||
.guest-prejoin .gp-camoff{position:absolute;bottom:8px;left:0;right:0;color:rgba(255,255,255,.72);font-size:.75rem;z-index:1;}
|
||||
.guest-prejoin .gp-toggles{display:flex;gap:.55rem;justify-content:center;margin-bottom:1rem;}
|
||||
.guest-prejoin .gp-tg{display:inline-flex;align-items:center;gap:.4rem;border:1px solid var(--line);background:var(--blue-soft);color:var(--blue);border-radius:99px;padding:.45rem .85rem;font:inherit;font-size:.82rem;font-weight:600;cursor:pointer;}
|
||||
.guest-prejoin .gp-tg.off{background:#fee2e2;border-color:#fecaca;color:#b91c1c;}
|
||||
.guest-prejoin h2{margin:0 0 .2rem;font-size:1.2rem;color:var(--ink);}
|
||||
.guest-prejoin .gp-sub{margin:0 0 1rem;font-size:.86rem;color:var(--muted);}
|
||||
.guest-prejoin input{width:100%;box-sizing:border-box;border:1.5px solid var(--line);border-radius:11px;padding:.7rem .8rem;font-size:.95rem;font-family:inherit;background:#fbfcfe;color:var(--ink);outline:none;}
|
||||
.guest-prejoin input:focus{border-color:var(--blue);}
|
||||
.guest-prejoin input.err{border-color:#dc2626;background:#fef2f2;}
|
||||
.guest-prejoin .gp-join{width:100%;margin-top:.75rem;border:none;border-radius:11px;background:var(--blue);color:#fff;font:inherit;font-size:.95rem;font-weight:600;padding:.75rem;cursor:pointer;}
|
||||
.guest-prejoin .gp-join:hover{filter:brightness(1.08);}
|
||||
.guest-prejoin .gp-note{display:flex;align-items:center;justify-content:center;gap:.3rem;margin-top:.7rem;font-size:.74rem;color:var(--muted);}
|
||||
.guest-left{position:fixed;inset:0;background:#1F3B73;display:flex;align-items:center;justify-content:center;z-index:9900;padding:1rem;}
|
||||
.guest-left .gl-card{background:#fff;border-radius:16px;padding:2rem 1.6rem;text-align:center;max-width:360px;width:100%;box-shadow:0 18px 44px rgba(0,0,0,.3);}
|
||||
.guest-left .gl-card h2{margin:.2rem 0 .3rem;font-size:1.15rem;color:var(--ink);}
|
||||
@@ -660,6 +682,15 @@
|
||||
.spk-menu .spk-opt:hover{background:var(--blue-soft);}
|
||||
.spk-menu .spk-opt.on{color:var(--blue);font-weight:600;}
|
||||
.spk-menu .spk-empty{font-size:.8rem;color:var(--muted);padding:.5rem .6rem;line-height:1.4;}
|
||||
/* radio dot, so the menu reads like the OS/Teams device picker */
|
||||
.spk-menu .spk-opt{display:flex;align-items:center;gap:.5rem;}
|
||||
.spk-menu .spk-dot{width:13px;height:13px;border-radius:50%;border:2px solid #cbd5e1;flex:0 0 auto;}
|
||||
.spk-menu .spk-opt.on .spk-dot{border-color:var(--blue);box-shadow:inset 0 0 0 2.5px var(--blue);}
|
||||
.spk-menu .spk-h+.spk-h{margin-top:.2rem;}
|
||||
/* Mic button + ▾ caret grouped into one control (audio devices live behind the caret) */
|
||||
.meet-bar .mic-grp{position:relative;display:inline-flex;align-items:center;}
|
||||
.meet-bar .mic-caret{position:absolute;right:-2px;bottom:-2px;width:20px;height:20px;border-radius:50%;border:2px solid var(--card,#fff);background:var(--blue);color:#fff;display:grid;place-items:center;cursor:pointer;padding:0;z-index:2;}
|
||||
.meet-bar .mic-caret:hover{filter:brightness(1.12);}
|
||||
.call-invite .ci-ico{width:38px;height:38px;border-radius:50%;background:#dcfce7;color:#15803d;display:grid;place-items:center;flex:0 0 auto;}
|
||||
.call-invite .ci-txt{font-size:.88rem;color:var(--ink);line-height:1.25;}
|
||||
.call-invite .ci-join{border:none;background:#15803d;color:#fff;border-radius:9px;padding:.45rem .7rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;flex:0 0 auto;}
|
||||
@@ -917,7 +948,7 @@
|
||||
<body>
|
||||
<script src="/icons.js?v=5"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
|
||||
<script>window.__BUILD='2026-07-11-batch73';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-11-batch74';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
|
||||
// (emojis stay as plain Unicode). (#5)
|
||||
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
|
||||
@@ -3330,8 +3361,12 @@ function renderCallConnecting(){
|
||||
const el=document.getElementById('meetingPanel'); if(!el) return;
|
||||
el.innerHTML='<div class="call-connecting"><img src="/loaders/loader-orbit-dark.svg" width="76" height="76" alt=""><div class="cc-txt">Connecting to call…</div></div>';
|
||||
}
|
||||
// #4 Lobby — guest side: waiting for the host to admit them.
|
||||
// #4 Lobby — guest side: waiting for the host to admit them. _inLobby lets meeting-joined know it must
|
||||
// rebuild the call UI (the waiting screen replaced it, so without this the guest stayed on "waiting…"
|
||||
// forever even after being admitted).
|
||||
let _inLobby=false;
|
||||
function renderLobbyWait(){
|
||||
_inLobby=true;
|
||||
const el=document.getElementById('meetingPanel'); if(!el) return;
|
||||
el.innerHTML='<div class="call-connecting"><img src="/loaders/loader-orbit-dark.svg" width="76" height="76" alt=""><div class="cc-txt">Waiting for the host to let you in…</div><div style="color:var(--muted);font-size:.85rem;margin-top:.3rem">You’ll join automatically once they admit you.</div></div>';
|
||||
}
|
||||
@@ -3352,13 +3387,14 @@ function renderCall(){
|
||||
const el=document.getElementById('meetingPanel'); if(!el) return;
|
||||
el.innerHTML='<div class="meet"><div class="meet-grid" id="meetGrid"></div>'
|
||||
+ '<div class="meet-bar"><span class="code" id="meetCodeChip">Room <b>'+pEsc(meetRoom||'')+'</b> · share to invite</span>'
|
||||
+ '<button class="meet-ic'+(meetMic?'':' off')+'" id="meetMicBtn" title="'+(meetMic?'Mute':'Unmute')+'">'+ic(meetMic?'mic':'micOff',20)+'</button>'
|
||||
+ '<span class="mic-grp"><button class="meet-ic'+(meetMic?'':' off')+'" id="meetMicBtn" title="'+(meetMic?'Mute':'Unmute')+'">'+ic(meetMic?'mic':'micOff',20)+'</button>'
|
||||
+ '<button class="mic-caret" id="meetAudioBtn" title="Audio devices (mic & speaker)">'+ic('chevronUp',12)+'</button></span>'
|
||||
+ '<button class="meet-ic'+(meetCam?'':' off')+'" id="meetCamBtn" title="'+(meetCam?'Turn camera off':'Turn camera on')+'">'+ic(meetCam?'video':'videoOff',20)+'</button>'
|
||||
+ '<button class="meet-ic" id="meetScreenBtn" title="Share screen">'+ic('monitor',20)+'</button>'
|
||||
+ '<button class="meet-ic host-only" id="meetRecBtn" title="Record meeting" style="display:none">'+ic('record',20)+'</button>'
|
||||
+ ((ME&&ME.guest)?'':'<button class="meet-ic" id="meetTransBtn" title="Live transcript">'+ic('fileText',20)+'</button>') // #12: transcript is a signed-in feature (guests can't download it)
|
||||
+ '<button class="meet-ic" id="meetChatBtn" title="Chat"><span class="mc-badge" id="meetChatBadge" style="display:none">0</span>'+ic('chat',20)+'</button>'
|
||||
+ '<button class="meet-ic" id="meetSpkBtn" title="Speaker / headphones">'+ic('headphones',20)+'</button>'
|
||||
+ (isMobileUA()?'<button class="meet-ic'+(meetSpeakerOn?'':' off')+'" id="meetSpkBtn" title="Speakerphone">'+ic('headphones',20)+'</button>':'')
|
||||
+ '<button class="meet-ic" id="meetPplBtn" title="Participants">'+ic('users',20)+'</button>'
|
||||
+ '<button class="meet-ic leave" id="meetLeaveBtn" title="Leave">'+ic('callEnd',20)+'</button></div></div>';
|
||||
document.getElementById('meetMicBtn').onclick=toggleMic;
|
||||
@@ -3368,7 +3404,8 @@ function renderCall(){
|
||||
{ const tb=document.getElementById('meetTransBtn'); if(tb) tb.onclick=toggleTranscribe; }
|
||||
document.getElementById('meetPplBtn').onclick=toggleMeetPanel;
|
||||
{ const cb=document.getElementById('meetChatBtn'); if(cb) cb.onclick=toggleMeetChat; }
|
||||
{ const sb=document.getElementById('meetSpkBtn'); if(sb) sb.onclick=(e)=>{ e.stopPropagation(); openSpeakerMenu(sb); }; }
|
||||
{ const ab=document.getElementById('meetAudioBtn'); if(ab) ab.onclick=(e)=>{ e.stopPropagation(); openAudioMenu(ab); }; } // desktop: mic ▾ → devices
|
||||
{ const sb=document.getElementById('meetSpkBtn'); if(sb) sb.onclick=toggleSpeakerphone; } // mobile: speakerphone toggle
|
||||
document.getElementById('meetLeaveBtn').onclick=leaveMeeting;
|
||||
updateHostControls();
|
||||
// Click another shared screen (in the side column) to bring it onto the stage.
|
||||
@@ -3376,23 +3413,81 @@ function renderCall(){
|
||||
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true);
|
||||
setTileMute('__local', !meetMic);
|
||||
}
|
||||
// #5 Speaker / headphone output selection. Applies the chosen audiooutput device (setSinkId) to every
|
||||
// meeting media element, and remembers it for new tiles. No-op where setSinkId isn't supported.
|
||||
// ---- Audio devices (#3): one menu behind the MIC's ▾ caret, listing Speaker + Microphone (like Teams),
|
||||
// instead of a separate headphones button. Choosing a speaker applies setSinkId to every meeting media
|
||||
// element; choosing a mic switches the live input device.
|
||||
function isMobileUA(){ return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent||''); }
|
||||
let meetSinkId=(()=>{ try{ return localStorage.getItem('bzc_sink')||''; }catch(_){ return ''; } })();
|
||||
let meetMicId=(()=>{ try{ return localStorage.getItem('bzc_micdev')||''; }catch(_){ return ''; } })();
|
||||
let meetSpeakerOn=(()=>{ try{ return localStorage.getItem('bzc_spk')!=='0'; }catch(_){ return true; } })(); // mobile speakerphone
|
||||
function applySink(el){ try{ if(el && meetSinkId && typeof el.setSinkId==='function') el.setSinkId(meetSinkId).catch(()=>{}); }catch(_){} }
|
||||
function applySinkAll(){ document.querySelectorAll('#meetGrid video').forEach(applySink); }
|
||||
async function openSpeakerMenu(anchor){
|
||||
// Bluetooth/headset devices report themselves in the label — used to pick a sensible icon on mobile.
|
||||
function isBtLabel(l){ return /bluetooth|airpod|buds|headset|headphone|wh-|wf-/i.test(l||''); }
|
||||
async function setSpeakerDevice(id){
|
||||
meetSinkId=id||''; try{ localStorage.setItem('bzc_sink', meetSinkId); }catch(_){}
|
||||
try{ if(SFU.room && SFU.room.switchActiveDevice) await SFU.room.switchActiveDevice('audiooutput', meetSinkId); }catch(_){}
|
||||
applySinkAll();
|
||||
}
|
||||
async function setMicDevice(id){
|
||||
meetMicId=id||''; try{ localStorage.setItem('bzc_micdev', meetMicId); }catch(_){}
|
||||
try{ if(SFU.room && SFU.room.switchActiveDevice){ await SFU.room.switchActiveDevice('audioinput', meetMicId); return; } }catch(_){}
|
||||
// Mesh fallback: re-acquire the mic on the chosen device and swap the outgoing track.
|
||||
try{
|
||||
if(!meetMic || !meetLocalStream) return;
|
||||
const s=await navigator.mediaDevices.getUserMedia({audio:{deviceId:{exact:meetMicId}}});
|
||||
const nt=s.getAudioTracks()[0]; if(!nt) return;
|
||||
const old=meetLocalStream.getAudioTracks()[0];
|
||||
if(old){ meetLocalStream.removeTrack(old); try{ old.stop(); }catch(_){} }
|
||||
meetLocalStream.addTrack(nt);
|
||||
meetPeers.forEach(p=>{ try{ const snd=p.pc.getSenders().find(x=>x.track&&x.track.kind==='audio'); if(snd) snd.replaceTrack(nt); }catch(_){} });
|
||||
}catch(e){ toast(mediaErrMsg(e,'microphone')); }
|
||||
}
|
||||
async function openAudioMenu(anchor){
|
||||
document.querySelectorAll('.spk-menu').forEach(x=>x.remove());
|
||||
let devs=[]; try{ devs=(await navigator.mediaDevices.enumerateDevices()).filter(d=>d.kind==='audiooutput'); }catch(_){}
|
||||
let outs=[], ins=[];
|
||||
try{ const d=await navigator.mediaDevices.enumerateDevices(); outs=d.filter(x=>x.kind==='audiooutput'); ins=d.filter(x=>x.kind==='audioinput'); }catch(_){}
|
||||
const menu=document.createElement('div'); menu.className='spk-menu';
|
||||
if(!devs.length){ menu.innerHTML='<div class="spk-empty">Your browser can’t switch audio output here. Set it in the OS sound settings.</div>'; }
|
||||
else menu.innerHTML='<div class="spk-h">'+ic('headphones',13)+' Speaker</div>'+devs.map((d,i)=>'<button class="spk-opt'+((d.deviceId===meetSinkId||(!meetSinkId&&d.deviceId==='default'))?' on':'')+'" data-id="'+pEsc(d.deviceId)+'">'+pEsc(d.label||('Output '+(i+1)))+'</button>').join('');
|
||||
const sel=(id, cur)=> (id===cur || (!cur && (id==='default'||id===''))) ? ' on' : '';
|
||||
let html='';
|
||||
html+='<div class="spk-h">'+ic('headphones',13)+' Speaker</div>';
|
||||
html+= outs.length ? outs.map((d,i)=>'<button class="spk-opt'+sel(d.deviceId,meetSinkId)+'" data-k="out" data-id="'+pEsc(d.deviceId)+'"><span class="spk-dot"></span>'+pEsc(d.label||('Output '+(i+1)))+'</button>').join('')
|
||||
: '<div class="spk-empty">Output is controlled by your system.</div>';
|
||||
html+='<div class="spk-h">'+ic('mic',13)+' Microphone</div>';
|
||||
html+= ins.length ? ins.map((d,i)=>'<button class="spk-opt'+sel(d.deviceId,meetMicId)+'" data-k="in" data-id="'+pEsc(d.deviceId)+'"><span class="spk-dot"></span>'+pEsc(d.label||('Microphone '+(i+1)))+'</button>').join('')
|
||||
: '<div class="spk-empty">Allow microphone access to list devices.</div>';
|
||||
menu.innerHTML=html;
|
||||
document.body.appendChild(menu);
|
||||
const r=anchor.getBoundingClientRect(); menu.style.left=Math.max(8,Math.min(r.left, window.innerWidth-menu.offsetWidth-8))+'px'; menu.style.top=(r.top-menu.offsetHeight-8)+'px';
|
||||
menu.querySelectorAll('.spk-opt').forEach(b=>b.onclick=()=>{ meetSinkId=b.dataset.id; try{ localStorage.setItem('bzc_sink', meetSinkId); }catch(_){} applySinkAll(); menu.remove(); toast('Speaker set'); });
|
||||
const close=(e)=>{ if(!menu.contains(e.target) && e.target!==anchor){ menu.remove(); document.removeEventListener('mousedown',close); } };
|
||||
const r=anchor.getBoundingClientRect();
|
||||
menu.style.left=Math.max(8,Math.min(r.left-60, window.innerWidth-menu.offsetWidth-8))+'px';
|
||||
menu.style.top=Math.max(8,(r.top-menu.offsetHeight-10))+'px';
|
||||
menu.querySelectorAll('.spk-opt').forEach(b=>b.onclick=async()=>{
|
||||
if(b.dataset.k==='out'){ await setSpeakerDevice(b.dataset.id); toast('Speaker set'); }
|
||||
else { await setMicDevice(b.dataset.id); toast('Microphone set'); }
|
||||
menu.remove();
|
||||
});
|
||||
const close=(e)=>{ if(!menu.contains(e.target) && e.target!==anchor && !anchor.contains(e.target)){ menu.remove(); document.removeEventListener('mousedown',close); } };
|
||||
setTimeout(()=>document.addEventListener('mousedown',close),0);
|
||||
}
|
||||
// Mobile: speakerphone on/off. When a Bluetooth/headset output is connected we route to it and show a
|
||||
// headset icon; otherwise we toggle between the loudspeaker and the default (earpiece) route. NOTE: true
|
||||
// earpiece routing is an OS capability — on mobile WEB the browser owns it, so this picks the best
|
||||
// available output device. In the native mobile build this maps to the OS audio route.
|
||||
async function toggleSpeakerphone(){
|
||||
const btn=document.getElementById('meetSpkBtn'); if(!btn) return;
|
||||
meetSpeakerOn=!meetSpeakerOn; try{ localStorage.setItem('bzc_spk', meetSpeakerOn?'1':'0'); }catch(_){}
|
||||
let outs=[]; try{ outs=(await navigator.mediaDevices.enumerateDevices()).filter(x=>x.kind==='audiooutput'); }catch(_){}
|
||||
const bt=outs.find(d=>isBtLabel(d.label));
|
||||
if(meetSpeakerOn){
|
||||
const spk=outs.find(d=>/speaker/i.test(d.label)) || outs.find(d=>d.deviceId==='default') || outs[0];
|
||||
if(spk) await setSpeakerDevice(spk.deviceId);
|
||||
} else if(bt){ await setSpeakerDevice(bt.deviceId); }
|
||||
else { await setSpeakerDevice(''); }
|
||||
btn.classList.toggle('off', !meetSpeakerOn);
|
||||
btn.innerHTML=ic('headphones',20);
|
||||
btn.title=meetSpeakerOn?'Speakerphone on':(bt?('Using '+(bt.label||'headset')):'Speakerphone off');
|
||||
toast(meetSpeakerOn?'Speaker on':(bt?'Using headset':'Speaker off'));
|
||||
}
|
||||
function addTile(id, stream, label, muted){
|
||||
const grid=document.getElementById('meetGrid'); if(!grid) return;
|
||||
let tile=document.getElementById('meet-tile-'+id);
|
||||
@@ -3607,6 +3702,7 @@ async function onMeetMsg(e){
|
||||
if(m.type==='meeting-created'){ meetRoom=m.room; renderCall(); meetSend({type:'meeting-join', room:m.room, name:(ME.name||ME.email||'Guest'), guestId:(ME&&ME.guest)?ME.id:undefined}); if(meetAnnounceGroup){ const g=meetAnnounceGroup; meetAnnounceGroup=null; try{ postJSON('/api/messages',{group:g, body:'📹 Started a group call — join with code '+m.room}); }catch(_){} } return; }
|
||||
if(m.type==='meeting-joined'){
|
||||
meetMyId=m.peerId;
|
||||
if(_inLobby){ _inLobby=false; renderCall(); } // admitted from the lobby → replace the "waiting…" screen with the call
|
||||
if(m.isHost){ meetIsHost=true; meetHostId=meetMyId; } // host = the meeting creator (server-decided)
|
||||
meetWatchStream('__local', meetLocalStream); // active-speaker detection on my own mic
|
||||
// Existing peers OFFER to me (their offers carry their tracks incl. any active screen share);
|
||||
@@ -3720,7 +3816,7 @@ function leaveMeeting(forced){
|
||||
meetPeers.forEach(p=>{ try{p.pc.close();}catch(_){} }); meetPeers.clear(); meetNames.clear(); meetAvatars.clear(); meetPeerUids.clear(); meetCamOff.clear(); meetInvited.forEach(e=>{ if(e.timer) clearTimeout(e.timer); }); meetInvited.clear(); meetMuted.clear();
|
||||
if(meetLocalStream){ try{ meetLocalStream.getTracks().forEach(t=>t.stop()); }catch(_){} meetLocalStream=null; }
|
||||
if(meetWs){ try{ meetWs.close(); }catch(_){} meetWs=null; }
|
||||
meetRoom=null; meetMyId=null; meetState='idle'; meetIsHost=false; meetHostId=null; meetRailLive(false); resetMeetChat();
|
||||
meetRoom=null; meetMyId=null; meetState='idle'; meetIsHost=false; meetHostId=null; meetRailLive(false); resetMeetChat(); _inLobby=false;
|
||||
const ret=meetReturn; meetReturn=null; meetLeaving=false;
|
||||
if(ME&&ME.guest){ renderGuestLeft(); return; } // guests have no chat to return to — show a leave screen
|
||||
if(ret){ switchTab('chat'); selectChat(ret.kind, ret.id); } // land back on the originating chat
|
||||
@@ -3935,26 +4031,57 @@ async function startGuestMeeting(code){
|
||||
document.body.classList.add('guest-mode');
|
||||
try{ history.replaceState(null,'','/home?meet='+code); }catch(_){}
|
||||
const savedName=(()=>{ try{ return localStorage.getItem('bzc_guest_name')||''; }catch(_){ return ''; } })();
|
||||
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='guestJoin'; ov.style.zIndex='9900';
|
||||
ov.innerHTML='<div class="modal" style="max-width:380px;text-align:center">'
|
||||
+'<img src="/mark-light.png" alt="" style="height:44px;width:auto;margin:0 auto .4rem;object-fit:contain" onerror="this.remove()">'
|
||||
+'<h3 style="justify-content:center">Join the meeting</h3>'
|
||||
+'<p class="bzc-msg" style="text-align:center">You’re joining meeting <b>'+pEsc(code)+'</b> as a guest.</p>'
|
||||
+'<input id="guestName" placeholder="Your name" autocomplete="name" value="'+pEsc(savedName)+'" style="width:100%;box-sizing:border-box;border:1px solid var(--line);border-radius:9px;padding:.6rem .7rem;font-size:.95rem;margin:.2rem 0 .9rem">'
|
||||
+'<button class="bzc-ok" id="guestGo" style="width:100%;justify-content:center">Join meeting</button></div>';
|
||||
let joinMic=false, joinCam=false; // pre-join choices, applied right after entering the call
|
||||
const ov=document.createElement('div'); ov.className='guest-prejoin'; ov.id='guestJoin';
|
||||
ov.innerHTML='<div class="gp-card">'
|
||||
+'<div class="gp-brand"><img src="/mark-light.png" alt="" onerror="this.remove()"><span>Biz <b>Connect</b></span></div>'
|
||||
+'<div class="gp-preview" id="gpPrev"><video id="gpVid" autoplay playsinline muted></video>'
|
||||
+'<div class="gp-avatar" id="gpAv">?</div>'
|
||||
+'<div class="gp-camoff" id="gpCamOff">Camera is off</div></div>'
|
||||
+'<div class="gp-toggles">'
|
||||
+'<button type="button" class="gp-tg off" id="gpMic" title="Microphone">'+ic('micOff',18)+'<span>Mic off</span></button>'
|
||||
+'<button type="button" class="gp-tg off" id="gpCam" title="Camera">'+ic('videoOff',18)+'<span>Camera off</span></button>'
|
||||
+'</div>'
|
||||
+'<h2>Ready to join?</h2>'
|
||||
+'<p class="gp-sub">Meeting <b>'+pEsc(code)+'</b> · joining as a guest</p>'
|
||||
+'<input id="guestName" placeholder="Enter your name" autocomplete="name" maxlength="60" value="'+pEsc(savedName)+'">'
|
||||
+'<button class="gp-join" id="guestGo">Join meeting</button>'
|
||||
+'<div class="gp-note">'+ic('info',13)+' The host may need to admit you.</div>'
|
||||
+'</div>';
|
||||
document.body.appendChild(ov);
|
||||
const inp=ov.querySelector('#guestName'), go=ov.querySelector('#guestGo');
|
||||
const vid=ov.querySelector('#gpVid'), av=ov.querySelector('#gpAv'), camOff=ov.querySelector('#gpCamOff');
|
||||
const micBtn=ov.querySelector('#gpMic'), camBtn=ov.querySelector('#gpCam');
|
||||
let prevStream=null;
|
||||
const syncAvatar=()=>{ const n=(inp.value||'').trim(); av.textContent=n?initials(n):'?'; av.style.background=avColor(n||'?'); };
|
||||
syncAvatar(); inp.addEventListener('input', syncAvatar);
|
||||
// Live camera preview so the guest can see themselves before joining (like every other meeting app).
|
||||
const stopPrev=()=>{ if(prevStream){ try{ prevStream.getTracks().forEach(t=>t.stop()); }catch(_){} prevStream=null; } vid.srcObject=null; };
|
||||
camBtn.onclick=async()=>{
|
||||
if(joinCam){ joinCam=false; stopPrev(); camBtn.classList.add('off'); camBtn.innerHTML=ic('videoOff',18)+'<span>Camera off</span>'; camOff.style.display=''; return; }
|
||||
try{ prevStream=await navigator.mediaDevices.getUserMedia({video:true}); vid.srcObject=prevStream; joinCam=true; camBtn.classList.remove('off'); camBtn.innerHTML=ic('video',18)+'<span>Camera on</span>'; camOff.style.display='none'; }
|
||||
catch(e){ toast(mediaErrMsg(e,'camera')); }
|
||||
};
|
||||
micBtn.onclick=async()=>{
|
||||
if(joinMic){ joinMic=false; micBtn.classList.add('off'); micBtn.innerHTML=ic('micOff',18)+'<span>Mic off</span>'; return; }
|
||||
try{ const s=await navigator.mediaDevices.getUserMedia({audio:true}); s.getTracks().forEach(t=>t.stop()); joinMic=true; micBtn.classList.remove('off'); micBtn.innerHTML=ic('mic',18)+'<span>Mic on</span>'; }
|
||||
catch(e){ toast(mediaErrMsg(e,'microphone')); }
|
||||
};
|
||||
setTimeout(()=>{ try{ inp.focus(); }catch(_){} },50);
|
||||
const join=async()=>{
|
||||
const nm=(inp.value||'').trim()||'Guest';
|
||||
const nm=(inp.value||'').trim();
|
||||
if(!nm){ inp.classList.add('err'); inp.focus(); toast('Please enter your name'); return; }
|
||||
try{ localStorage.setItem('bzc_guest_name', nm); }catch(_){}
|
||||
ME={ id:'guest-'+Math.random().toString(36).slice(2,10), name:nm, email:'', guest:true, avatarUrl:null };
|
||||
_guestRoom=code; ov.remove();
|
||||
_guestRoom=code; stopPrev(); ov.remove();
|
||||
try{ await sfuInit(); }catch(_){}
|
||||
switchTab('meeting'); enterMeeting(code);
|
||||
switchTab('meeting'); await enterMeeting(code);
|
||||
// Apply their pre-join choices once the call is up.
|
||||
setTimeout(()=>{ try{ if(joinMic && !meetMic) toggleMic(); if(joinCam && !meetCam) toggleCam(); }catch(_){} }, 900);
|
||||
};
|
||||
go.onclick=join;
|
||||
inp.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); join(); } });
|
||||
inp.addEventListener('input',()=>inp.classList.remove('err'));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -226,7 +226,7 @@ function stopCustTranscription(){ crecogActive=false; if(crecog){ try{crecog.sto
|
||||
// after the user explicitly taps "Allow control", and only inside the desktop app. In a plain browser
|
||||
// there's no OS injection possible, so it stays view-only. The user can stop control at any time.
|
||||
let rcDesktop=false, rcAllowed=false, rcPrompted=false;
|
||||
try{ window.addEventListener('message',(e)=>{ if(e.origin!==location.origin) return; const d=e.data||{}; if(d.type==='rc-pong') rcDesktop=!!d.desktop; }); }catch(_){}
|
||||
try{ window.addEventListener('message',(e)=>{ if(e.origin!==location.origin) return; const d=e.data||{}; if(d.type==='rc-pong'){ rcDesktop=!!d.desktop; try{ updateRcBtn(); }catch(_){} } }); }catch(_){}
|
||||
try{ if(window.parent && window.parent!==window) window.parent.postMessage({type:'rc-ping'}, location.origin); }catch(_){}
|
||||
function rcPost(msg){ try{ if(window.parent && window.parent!==window) window.parent.postMessage(msg, location.origin); }catch(_){} }
|
||||
function rcOnInput(data){
|
||||
@@ -241,9 +241,22 @@ function showControlConsent(){
|
||||
const el=document.createElement('div'); el.id='rcConsent'; el.className='rc-consent';
|
||||
el.innerHTML='<div class="rc-card"><div class="rc-h">Allow remote control?</div><p>Your agent is asking to control your mouse & keyboard to help you. You stay in charge — stop anytime.</p><div class="rc-btns"><button id="rcDeny" class="rc-deny">Not now</button><button id="rcAllow" class="rc-allow">Allow control</button></div></div>';
|
||||
document.body.appendChild(el);
|
||||
el.querySelector('#rcAllow').onclick=()=>{ rcAllowed=true; rcPost({type:'rc-arm', on:true}); el.remove(); showControlBanner(); };
|
||||
el.querySelector('#rcAllow').onclick=()=>{ el.remove(); rcGrantControl(); };
|
||||
el.querySelector('#rcDeny').onclick=()=>{ el.remove(); };
|
||||
}
|
||||
// Grant / revoke control. Both the consent dialog and the bar's control icon route through here, so the
|
||||
// icon, banner and the shell's injection gate always agree.
|
||||
function rcGrantControl(){
|
||||
if(!rcDesktop){ setStatus('Remote control needs the Biz Connect desktop app. Your agent can still see your screen.'); return; }
|
||||
rcAllowed=true; rcPost({type:'rc-arm', on:true}); showControlBanner(); updateRcBtn();
|
||||
}
|
||||
function rcStopControl(){ rcAllowed=false; rcPrompted=false; rcPost({type:'rc-arm', on:false}); const b=document.getElementById('rcBanner'); if(b) b.remove(); updateRcBtn(); }
|
||||
function updateRcBtn(){
|
||||
const b=document.getElementById('rcBtn'); if(!b) return;
|
||||
b.style.background=rcAllowed?'#16a34a':'#6b7280';
|
||||
b.style.opacity=rcDesktop?'1':'.55';
|
||||
b.title=!rcDesktop ? 'Remote control needs the desktop app' : (rcAllowed?'Remote control is ON — tap to stop':'Remote control is OFF — tap to allow');
|
||||
}
|
||||
function showControlBanner(){
|
||||
if(document.getElementById('rcBanner')) return;
|
||||
const el=document.createElement('div'); el.id='rcBanner'; el.className='rc-banner';
|
||||
@@ -251,7 +264,6 @@ function showControlBanner(){
|
||||
document.body.appendChild(el);
|
||||
el.querySelector('#rcStop').onclick=rcStopControl;
|
||||
}
|
||||
function rcStopControl(){ rcAllowed=false; rcPrompted=false; rcPost({type:'rc-arm', on:false}); const b=document.getElementById('rcBanner'); if(b) b.remove(); }
|
||||
let recTimerInt=null, recStartTs=0;
|
||||
function fmtElapsed(ms){const s=Math.max(0,Math.floor(ms/1000));return String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');}
|
||||
function recNotice(on){
|
||||
@@ -293,10 +305,14 @@ function buildBar(){
|
||||
const bar=document.createElement('div'); bar.id='sessionBar';
|
||||
bar.style.cssText='position:fixed;right:18px;bottom:18px;z-index:2147483000;display:flex;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:8px 12px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)';
|
||||
const mic=_btn('micBtn',SVG_MICOFF,'Muted','#6b7280');
|
||||
// Remote-control access, right next to mic/chat, so the customer can see AND flip it at a glance.
|
||||
const rcb=_btn('rcBtn',(window.ic?window.ic('monitor',18):''),'Remote control is OFF','#6b7280');
|
||||
const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569');
|
||||
const end=_btn('endBtn2',SVG_END,'End','#dc2626');
|
||||
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(end);
|
||||
bar.appendChild(mic);bar.appendChild(rcb);bar.appendChild(chat);bar.appendChild(end);
|
||||
document.body.appendChild(bar);
|
||||
rcb.onclick=()=>{ if(rcAllowed) rcStopControl(); else rcGrantControl(); };
|
||||
updateRcBtn();
|
||||
const setMic=(on)=>{mic.title=on?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+(on?SVG_MIC:SVG_MICOFF)+'</span>';mic.style.background=on?'#2563eb':'#6b7280';};
|
||||
mic.onclick=async()=>{
|
||||
if(!window.__mic){
|
||||
|
||||
Reference in New Issue
Block a user