diff --git a/server/public/connect.html b/server/public/connect.html index 9e7612d..34805d1 100644 --- a/server/public/connect.html +++ b/server/public/connect.html @@ -43,10 +43,10 @@ .topbar2{background:var(--card);border-bottom:1px solid var(--line);padding:.5rem 1rem;display:none;justify-content:space-between;align-items:center;} .topbar2.show{display:flex;} #barStatus{font-weight:600;font-size:.9rem;color:var(--blue);} #endBtn{padding:.45rem 1rem;background:#fee2e2;color:#b91c1c;border:none;border-radius:8px;font-weight:600;cursor:pointer;} - #video{width:100vw;height:calc(100vh - 46px);background:#0b1220;object-fit:contain;display:none;cursor:crosshair;outline:none;} - /* Control bar sits vertically on the RIGHT; reserve a thin right strip so the screen uses the full - height/width otherwise (maximised view) and the icons never overlay the shared content. */ - body.has-bar #video{width:calc(100vw - 76px);} + #video{width:100vw;height:100vh;background:#0b1220;object-fit:contain;display:none;cursor:crosshair;outline:none;} + /* The control bar floats over the bottom-right corner (tiny icons), so the shared screen uses the + FULL viewport underneath it. */ + body.has-bar #video{width:100vw;height:100vh;} body.has-bar{background:#0b1220;} .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} @@ -323,15 +323,19 @@ function buildBar(){ { const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; } bzcSession(true); const bar=document.createElement('div'); bar.id='sessionBar'; - bar.style.cssText='position:fixed;right:14px;top:50%;transform:translateY(-50%);z-index:2147483000;display:flex;flex-direction:column;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:12px 8px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)'; - const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb'); - const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569'); - const rec=_btn('recBtn',SVG_REC,'','#0ea5e9'); rec.title='Record'; rec.querySelectorAll('span').forEach((s,i)=>{ if(i>0) s.remove(); }); - const end=_btn('endBtn2',SVG_END,'End','#dc2626'); + // 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 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); document.body.appendChild(bar); - document.body.classList.add('has-bar'); // reserve space so the bar never overlays the shared screen - 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=''+(t.enabled?SVG_MIC:SVG_MICOFF)+'';mic.style.background=t.enabled?'#2563eb':'#6b7280';}; + 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.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(); }; end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}catch(_){} }; @@ -378,16 +382,33 @@ async function setupPeer(){ else if(s==='connected'){ clearTimeout(pc._dt); } }; } const send=(o)=>{if(inputChannel&&inputChannel.readyState==='open')inputChannel.send(JSON.stringify(o));}; -const rel=(e)=>{const r=video.getBoundingClientRect();return{x:(e.clientX-r.left)/r.width,y:(e.clientY-r.top)/r.height};}; +// Map a pointer event to NORMALIZED coords over the actual VIDEO CONTENT, not the element. +// object-fit:contain letterboxes the shared screen inside the element (bars top/bottom or sides), so +// mapping against the element rect put the cursor off by the bar size (the "must aim lower" bug). We +// compute the content rect from the source resolution (videoWidth/Height) and map against that. +function contentRect(){ + const r=video.getBoundingClientRect(); + const vw=video.videoWidth||16, vh=video.videoHeight||9; + const elAR=r.width/r.height, vidAR=vw/vh; + let cw, ch, ox=0, oy=0; + if(vidAR>elAR){ cw=r.width; ch=r.width/vidAR; oy=(r.height-ch)/2; } // letterbox: bars top & bottom + else { ch=r.height; cw=r.height*vidAR; ox=(r.width-cw)/2; } // pillarbox: bars left & right + return {left:r.left+ox, top:r.top+oy, width:cw, height:ch}; +} +const rel=(e)=>{ const c=contentRect(); let x=(e.clientX-c.left)/c.width, y=(e.clientY-c.top)/c.height; return {x:Math.max(0,Math.min(1,x)), y:Math.max(0,Math.min(1,y))}; }; let lm=0; -video.addEventListener('mousemove',e=>{const t=performance.now();if(t-lm<30)return;lm=t;send({kind:'mousemove',...rel(e)});}); +video.addEventListener('mousemove',e=>{const t=performance.now();if(t-lm<16)return;lm=t;send({kind:'mousemove',...rel(e)});}); // ~60/s for a smoother cursor video.addEventListener('mousedown',e=>{video.focus();send({kind:'mousedown',button:e.button,...rel(e)});}); video.addEventListener('mouseup',e=>send({kind:'mouseup',button:e.button,...rel(e)})); 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()); -video.addEventListener('keydown',e=>{e.preventDefault();send({kind:'keydown',key:e.key,code:e.code});}); -video.addEventListener('keyup',e=>{e.preventDefault();send({kind:'keyup',key:e.key,code:e.code});}); +// 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. +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}); }); document.getElementById('endBtn').onclick=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}; function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));} diff --git a/server/public/home.html b/server/public/home.html index a26a1d1..46903ae 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -884,7 +884,7 @@ - @@ -1987,7 +1987,9 @@ function renderMeetPanel(){ const list=meetParticipantsList(); const isHostRow=pp=> pp.id==='__local' ? meetIsHost : pp.id===meetHostId; // YOUR row uses meetIsHost const head='Participants'+((meetIsHost&&meetPanelTab==='people')?''+ic('micOff',14)+' Mute all':'')+''+ic('x',16)+''; - const tabs='In call ('+list.length+')'+ic('userPlus',13)+' Add people'; + // Guests can't add participants (#9) — show only the "In call" list, no Add-people tab. + const isGuest=!!(ME&&ME.guest); if(isGuest && meetPanelTab==='add') meetPanelTab='people'; + const tabs='In call ('+list.length+')'+(isGuest?'':''+ic('userPlus',13)+' Add people')+''; let body; if(meetPanelTab==='add'){ // Group call → only that group's members may be added. 1:1/ad-hoc → all team contacts. @@ -2986,7 +2988,7 @@ async function sfuConnect(){ const LK=await sfuLoadLib(); SFU.lib=LK; // Guests (external link joiners, not signed in) get an unauthenticated guest token for this room. const tk=(ME&&ME.guest) - ? await postJSON('/api/meetings/guest-token',{ room:meetRoom, name:ME.name }) + ? await postJSON('/api/meetings/guest-token',{ room:meetRoom, name:ME.name, identity:ME.id }) // identity must match the guestId sent over signaling : await postJSON('/api/meetings/token',{ room:meetRoom }); // per-user, per-room join credential // adaptiveStream + dynacast BOTH off: we attach media via manual srcObject (not track.attach), so // LiveKit can't observe tile visibility. With dynacast on, a screen-share layer was paused unless a @@ -3169,7 +3171,8 @@ function openScheduleModal(gid, editMtg){ +''+DAY1.map((d,i)=>''+d+'').join('')+'Everyday' +'Description (optional)' +'Invite participants' - +''+(CONTACTS.length?CONTACTS.map(c=>''+pEsc(initials(c.name))+''+pEsc(c.name)+'').join(''):'No contacts to invite')+'' + +(CONTACTS.length>6?'':'') + +''+(CONTACTS.length?CONTACTS.map(c=>''+pEsc(initials(c.name))+''+pEsc(c.name)+'').join(''):'No contacts to invite')+'' +'Invite by email (guests — no Connect account needed)' +''+ic('userPlus',15)+' Add' +'' @@ -3225,6 +3228,8 @@ function openScheduleModal(gid, editMtg){ $('schEmailAdd').onclick=addEmail; $('schEmail').addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); addEmail(); } }); renderEmailChips(); + // #10: filter the participant list as you type. + { const ps=$('schPplSearch'); if(ps) ps.addEventListener('input',()=>{ const q=ps.value.trim().toLowerCase(); ov.querySelectorAll('#schPeople .chk').forEach(l=>{ l.style.display=(!q||(l.dataset.nm||'').includes(q))?'':'none'; }); }); } $('schSave').onclick=async()=>{ const title=$('schTitle').value.trim(); const desc=$('schDesc').value.trim(); @@ -3258,14 +3263,14 @@ function renderCall(){ + ''+ic(meetCam?'video':'videoOff',20)+'' + ''+ic('monitor',20)+'' + ''+ic('record',20)+'' - + ''+ic('fileText',20)+'' + + ((ME&&ME.guest)?'':''+ic('fileText',20)+'') // #12: transcript is a signed-in feature (guests can't download it) + ''+ic('users',20)+'' + ''+ic('callEnd',20)+''; document.getElementById('meetMicBtn').onclick=toggleMic; document.getElementById('meetCamBtn').onclick=toggleCam; document.getElementById('meetScreenBtn').onclick=toggleScreen; document.getElementById('meetRecBtn').onclick=toggleRecord; - document.getElementById('meetTransBtn').onclick=toggleTranscribe; + { const tb=document.getElementById('meetTransBtn'); if(tb) tb.onclick=toggleTranscribe; } document.getElementById('meetPplBtn').onclick=toggleMeetPanel; document.getElementById('meetLeaveBtn').onclick=leaveMeeting; updateHostControls(); @@ -3481,11 +3486,11 @@ async function enterMeeting(code, audioOnly){ renderCallConnecting(); // branded "Connecting…" until the room is created/joined (esp. on slow links) meetWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws'); meetWs.onmessage=onMeetMsg; - meetWs.onopen=()=>{ if(code){ meetRoom=code; renderCall(); meetSend({type:'meeting-join', room:code, name:(ME.name||ME.email||'Guest')}); } else { meetSend({type:'meeting-create'}); } }; + meetWs.onopen=()=>{ if(code){ meetRoom=code; renderCall(); meetSend({type:'meeting-join', room:code, name:(ME.name||ME.email||'Guest'), guestId:(ME&&ME.guest)?ME.id:undefined}); } else { meetSend({type:'meeting-create'}); } }; } async function onMeetMsg(e){ let m; try{ m=JSON.parse(e.data); }catch(_){ return; } - if(m.type==='meeting-created'){ meetRoom=m.room; renderCall(); meetSend({type:'meeting-join', room:m.room, name:(ME.name||ME.email||'Guest')}); 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-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(m.isHost){ meetIsHost=true; meetHostId=meetMyId; } // host = the meeting creator (server-decided) diff --git a/server/routes.js b/server/routes.js index abcb7ac..35a3a12 100644 --- a/server/routes.js +++ b/server/routes.js @@ -937,13 +937,15 @@ route('POST', '/api/meetings/token', async (req, res) => { // meeting, so a token can't be created for an arbitrary/expired code. Identity is a throwaway guest id. route('POST', '/api/meetings/guest-token', async (req, res) => { if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' }); - const { room, name } = await readBody(req); + const { room, name, identity } = await readBody(req); const rm = String(room || '').trim(); if (!/^\d{6}$/.test(rm)) return json(res, 400, { error: 'invalid room' }); const live = (() => { try { return require('./presence').meetingRooms.has(rm); } catch (_) { return false; } })(); const sched = (() => { try { const s = R.scheduledMeetings.byCode(rm); return !!(s && !s.ended_at); } catch (_) { return false; } })(); if (!live && !sched) return json(res, 404, { error: 'meeting not found or not active' }); - const gid = 'guest-' + crypto.randomBytes(8).toString('hex'); + // Reuse the guest's client id as the LiveKit identity so it matches the id they announced over + // signaling (meeting-join guestId) — that mapping is how their media attaches to their tile. + const gid = (typeof identity === 'string' && /^guest-[a-z0-9]+$/i.test(identity)) ? identity.slice(0, 64) : ('guest-' + crypto.randomBytes(8).toString('hex')); const gname = String(name || 'Guest').slice(0, 60); const token = livekitToken(gid, gname, rm, JSON.stringify({ guest: true })); json(res, 200, { token, url: LIVEKIT_URL, identity: gid, name: gname }); diff --git a/server/signaling.js b/server/signaling.js index 97e1c42..df1aec3 100644 --- a/server/signaling.js +++ b/server/signaling.js @@ -79,7 +79,13 @@ function handle(ws, m, req) { let hostUserId = roomHost.get(room); if (hostUserId === undefined) { try { const s = R.scheduledMeetings.byCode(room); if (s) { hostUserId = s.created_by; roomHost.set(room, hostUserId); } } catch (_) {} } const ju = currentUser(req); - ws._meetingUserId = ju ? ju.id : null; // for per-user transcript ownership + // Identity used to map LiveKit media → this tile (peerIdForUid). Logged-in users use their user id + // (their LiveKit token identity is the same). GUESTS have no session, so they pass a stable client + // guest id here that ALSO becomes their LiveKit token identity — otherwise their media never maps + // to a tile and they're invisible/inaudible to others (and vice-versa for their screen share). + let mUid = ju ? ju.id : null; + if (!mUid && typeof m.guestId === 'string' && /^guest-[a-z0-9]+$/i.test(m.guestId)) mUid = m.guestId.slice(0, 64); + ws._meetingUserId = mUid; // for per-user transcript ownership + SFU media mapping const avatar = (ju && ju.avatar_url) ? ju.avatar_url : null; // for participant-tile profile pics const isHost = !!(ju && hostUserId && ju.id === hostUserId); // Tell the newcomer who's already here (they initiate offers to existing peers)…