diff --git a/desktop/input/inject.js b/desktop/input/inject.js index cc94701..b1c627b 100644 --- a/desktop/input/inject.js +++ b/desktop/input/inject.js @@ -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; diff --git a/desktop/package.json b/desktop/package.json index f04de2b..96093b3 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/server/public/connect.html b/server/public/connect.html index a69a3d4..afbb5f3 100644 --- a/server/public/connect.html +++ b/server/public/connect.html @@ -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','','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=''+I(t.enabled?'mic':'micOff')+'';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 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}); diff --git a/server/public/home.html b/server/public/home.html index 682d99b..5e0afac 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -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 @@ - @@ -3330,8 +3361,12 @@ function renderCallConnecting(){ const el=document.getElementById('meetingPanel'); if(!el) return; el.innerHTML='Connecting to call…'; } -// #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='Waiting for the host to let you in…You’ll join automatically once they admit you.'; } @@ -3352,13 +3387,14 @@ function renderCall(){ const el=document.getElementById('meetingPanel'); if(!el) return; el.innerHTML='' + 'Room '+pEsc(meetRoom||'')+' · share to invite' - + ''+ic(meetMic?'mic':'micOff',20)+'' + + ''+ic(meetMic?'mic':'micOff',20)+'' + + ''+ic('chevronUp',12)+'' + ''+ic(meetCam?'video':'videoOff',20)+'' + ''+ic('monitor',20)+'' + ''+ic('record',20)+'' + ((ME&&ME.guest)?'':''+ic('fileText',20)+'') // #12: transcript is a signed-in feature (guests can't download it) + '0'+ic('chat',20)+'' - + ''+ic('headphones',20)+'' + + (isMobileUA()?''+ic('headphones',20)+'':'') + ''+ic('users',20)+'' + ''+ic('callEnd',20)+''; 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='Your browser can’t switch audio output here. Set it in the OS sound settings.'; } - else menu.innerHTML=''+ic('headphones',13)+' Speaker'+devs.map((d,i)=>''+pEsc(d.label||('Output '+(i+1)))+'').join(''); + const sel=(id, cur)=> (id===cur || (!cur && (id==='default'||id===''))) ? ' on' : ''; + let html=''; + html+=''+ic('headphones',13)+' Speaker'; + html+= outs.length ? outs.map((d,i)=>''+pEsc(d.label||('Output '+(i+1)))+'').join('') + : 'Output is controlled by your system.'; + html+=''+ic('mic',13)+' Microphone'; + html+= ins.length ? ins.map((d,i)=>''+pEsc(d.label||('Microphone '+(i+1)))+'').join('') + : 'Allow microphone access to list devices.'; + 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='' - +'' - +'Join the meeting' - +'You’re joining meeting '+pEsc(code)+' as a guest.' - +'' - +'Join meeting'; + 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='' + +'Biz Connect' + +'' + +'?' + +'Camera is off' + +'' + +''+ic('micOff',18)+'Mic off' + +''+ic('videoOff',18)+'Camera off' + +'' + +'Ready to join?' + +'Meeting '+pEsc(code)+' · joining as a guest' + +'' + +'Join meeting' + +''+ic('info',13)+' The host may need to admit you.' + +''; 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)+'Camera off'; 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)+'Camera on'; 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)+'Mic off'; 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)+'Mic on'; } + 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')); } diff --git a/server/public/share.html b/server/public/share.html index 49076af..df7e8ab 100644 --- a/server/public/share.html +++ b/server/public/share.html @@ -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='Allow remote control?Your agent is asking to control your mouse & keyboard to help you. You stay in charge — stop anytime.Not nowAllow control'; 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=''+(on?SVG_MIC:SVG_MICOFF)+'';mic.style.background=on?'#2563eb':'#6b7280';}; mic.onclick=async()=>{ if(!window.__mic){
You’re joining meeting '+pEsc(code)+' as a guest.
Meeting '+pEsc(code)+' · joining as a guest
Your agent is asking to control your mouse & keyboard to help you. You stay in charge — stop anytime.