fix(rc+guest): coordinate/keyboard/UI for remote control; guest media + panel fixes (batch71)
Remote control (connect.html viewer): - Coordinate offset fixed: map clicks to the actual video CONTENT rect (object-fit letterbox-aware), not the element rect — cursor now lands where you click. - Keyboard: capture at document level while a session is live (the <video> lost focus on bar clicks → keys did nothing). Skips the chat input. - Control bar moved to bottom-right with tiny modern icons; video fills the viewport. - Smoother cursor (mousemove ~60/s). Guest meetings: - Guest mic inaudible + guest invisible in the participant list + outsider screen share not showing (#1/#8/#11): root cause was the SFU media→tile map keyed on identity==uid, but guests had a random LiveKit identity and a null signaling uid. Guests now carry ONE stable id across signaling (meeting-join guestId) and the LiveKit token identity, so their media attaches and they appear to everyone. - Guests can't add participants (#9) and don't see the transcript button (#12). - Search box in the schedule participant list (#10). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+36
-15
@@ -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{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);}
|
.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;}
|
#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;}
|
#video{width:100vw;height:100vh;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
|
/* The control bar floats over the bottom-right corner (tiny icons), so the shared screen uses the
|
||||||
height/width otherwise (maximised view) and the icons never overlay the shared content. */
|
FULL viewport underneath it. */
|
||||||
body.has-bar #video{width:calc(100vw - 76px);}
|
body.has-bar #video{width:100vw;height:100vh;}
|
||||||
body.has-bar{background:#0b1220;}
|
body.has-bar{background:#0b1220;}
|
||||||
.profile{position:relative}
|
.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{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'; }
|
{ const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
|
||||||
bzcSession(true);
|
bzcSession(true);
|
||||||
const bar=document.createElement('div'); bar.id='sessionBar';
|
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)';
|
// Floats over the bottom-right corner with tiny icons, so the shared screen fills the whole viewport.
|
||||||
const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb');
|
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 chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569');
|
const I=(n)=>(window.ic?window.ic(n,16):'');
|
||||||
const rec=_btn('recBtn',SVG_REC,'','#0ea5e9'); rec.title='Record'; rec.querySelectorAll('span').forEach((s,i)=>{ if(i>0) s.remove(); });
|
const mic=_btn('micBtn',I('mic'),'Mic','#2563eb');
|
||||||
const end=_btn('endBtn2',SVG_END,'End','#dc2626');
|
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(chat);bar.appendChild(rec);bar.appendChild(end);
|
||||||
document.body.appendChild(bar);
|
document.body.appendChild(bar);
|
||||||
document.body.classList.add('has-bar'); // reserve space so the bar never overlays the shared screen
|
document.body.classList.add('has-bar');
|
||||||
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">'+(t.enabled?SVG_MIC:SVG_MICOFF)+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
|
// 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='<span style="display:inline-flex">'+I(t.enabled?'mic':'micOff')+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
|
||||||
chat.onclick=toggleChat;
|
chat.onclick=toggleChat;
|
||||||
rec.onclick=()=>{ if(mediaRecorder&&mediaRecorder.state==='recording') stopRecording(); else startRecording(); };
|
rec.onclick=()=>{ if(mediaRecorder&&mediaRecorder.state==='recording') stopRecording(); else startRecording(); };
|
||||||
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}catch(_){} };
|
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}catch(_){} };
|
||||||
@@ -378,16 +382,33 @@ async function setupPeer(){
|
|||||||
else if(s==='connected'){ clearTimeout(pc._dt); } };
|
else if(s==='connected'){ clearTimeout(pc._dt); } };
|
||||||
}
|
}
|
||||||
const send=(o)=>{if(inputChannel&&inputChannel.readyState==='open')inputChannel.send(JSON.stringify(o));};
|
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 <video> 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;
|
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('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('mouseup',e=>send({kind:'mouseup',button:e.button,...rel(e)}));
|
||||||
video.addEventListener('dblclick',e=>send({kind:'dblclick',...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('wheel',e=>{e.preventDefault();send({kind:'scroll',dx:e.deltaX,dy:e.deltaY});},{passive:false});
|
||||||
video.addEventListener('contextmenu',e=>e.preventDefault());
|
video.addEventListener('contextmenu',e=>e.preventDefault());
|
||||||
video.addEventListener('keydown',e=>{e.preventDefault();send({kind:'keydown',key:e.key,code:e.code});});
|
// Keyboard: capture at the DOCUMENT level while a session is live so keys work regardless of which
|
||||||
video.addEventListener('keyup',e=>{e.preventDefault();send({kind:'keyup',key:e.key,code:e.code});});
|
// 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.
|
||||||
|
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'}));};
|
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]));}
|
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+13
-8
@@ -884,7 +884,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<script src="/icons.js?v=5"></script>
|
<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 src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
|
||||||
<script>window.__BUILD='2026-07-10-batch70';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
<script>window.__BUILD='2026-07-11-batch71';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||||
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
|
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
|
||||||
// (emojis stay as plain Unicode). (#5)
|
// (emojis stay as plain Unicode). (#5)
|
||||||
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
|
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
|
||||||
@@ -1987,7 +1987,9 @@ function renderMeetPanel(){
|
|||||||
const list=meetParticipantsList();
|
const list=meetParticipantsList();
|
||||||
const isHostRow=pp=> pp.id==='__local' ? meetIsHost : pp.id===meetHostId; // YOUR row uses meetIsHost
|
const isHostRow=pp=> pp.id==='__local' ? meetIsHost : pp.id===meetHostId; // YOUR row uses meetIsHost
|
||||||
const head='<div class="mp-head"><b>Participants</b><span style="flex:1"></span>'+((meetIsHost&&meetPanelTab==='people')?'<button class="mp-muteall" title="Mute everyone">'+ic('micOff',14)+' Mute all</button>':'')+'<button class="mp-x" title="Close">'+ic('x',16)+'</button></div>';
|
const head='<div class="mp-head"><b>Participants</b><span style="flex:1"></span>'+((meetIsHost&&meetPanelTab==='people')?'<button class="mp-muteall" title="Mute everyone">'+ic('micOff',14)+' Mute all</button>':'')+'<button class="mp-x" title="Close">'+ic('x',16)+'</button></div>';
|
||||||
const tabs='<div class="mp-tabs"><button class="mp-tab'+(meetPanelTab==='people'?' on':'')+'" data-tab="people">In call ('+list.length+')</button><button class="mp-tab'+(meetPanelTab==='add'?' on':'')+'" data-tab="add">'+ic('userPlus',13)+' Add people</button></div>';
|
// 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='<div class="mp-tabs"><button class="mp-tab'+(meetPanelTab==='people'?' on':'')+'" data-tab="people">In call ('+list.length+')</button>'+(isGuest?'':'<button class="mp-tab'+(meetPanelTab==='add'?' on':'')+'" data-tab="add">'+ic('userPlus',13)+' Add people</button>')+'</div>';
|
||||||
let body;
|
let body;
|
||||||
if(meetPanelTab==='add'){
|
if(meetPanelTab==='add'){
|
||||||
// Group call → only that group's members may be added. 1:1/ad-hoc → all team contacts.
|
// 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;
|
const LK=await sfuLoadLib(); SFU.lib=LK;
|
||||||
// Guests (external link joiners, not signed in) get an unauthenticated guest token for this room.
|
// Guests (external link joiners, not signed in) get an unauthenticated guest token for this room.
|
||||||
const tk=(ME&&ME.guest)
|
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
|
: 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
|
// 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
|
// 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){
|
|||||||
+'<div class="sch-days hidden" id="schDays">'+DAY1.map((d,i)=>'<button type="button" class="day-chip" data-d="'+i+'" title="'+DAYW[i]+'">'+d+'</button>').join('')+'<button type="button" class="day-all" data-all="1">Everyday</button></div>'
|
+'<div class="sch-days hidden" id="schDays">'+DAY1.map((d,i)=>'<button type="button" class="day-chip" data-d="'+i+'" title="'+DAYW[i]+'">'+d+'</button>').join('')+'<button type="button" class="day-all" data-all="1">Everyday</button></div>'
|
||||||
+'<label class="flbl">Description <span class="opt">(optional)</span></label><textarea id="schDesc" class="finput" rows="2" placeholder="What\'s this call about?"></textarea>'
|
+'<label class="flbl">Description <span class="opt">(optional)</span></label><textarea id="schDesc" class="finput" rows="2" placeholder="What\'s this call about?"></textarea>'
|
||||||
+'<label class="flbl">Invite participants</label>'
|
+'<label class="flbl">Invite participants</label>'
|
||||||
+'<div class="gi-list" id="schPeople" style="max-height:20vh;overflow:auto">'+(CONTACTS.length?CONTACTS.map(c=>'<label class="chk"><input type="checkbox" value="'+pEsc(c.id)+'"'+(invitedIds.has(c.id)?' checked':'')+'><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">No contacts to invite</div>')+'</div>'
|
+(CONTACTS.length>6?'<div class="gi-search" style="margin:.15rem 0 .35rem"><input id="schPplSearch" placeholder="Search people…" autocomplete="off"></div>':'')
|
||||||
|
+'<div class="gi-list" id="schPeople" style="max-height:20vh;overflow:auto">'+(CONTACTS.length?CONTACTS.map(c=>'<label class="chk" data-nm="'+pEsc((c.name||'').toLowerCase())+'"><input type="checkbox" value="'+pEsc(c.id)+'"'+(invitedIds.has(c.id)?' checked':'')+'><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">No contacts to invite</div>')+'</div>'
|
||||||
+'<label class="flbl" style="margin-top:.7rem">Invite by email <span class="opt">(guests — no Connect account needed)</span></label>'
|
+'<label class="flbl" style="margin-top:.7rem">Invite by email <span class="opt">(guests — no Connect account needed)</span></label>'
|
||||||
+'<div class="email-invite"><input id="schEmail" class="finput" type="email" placeholder="name@example.com" autocomplete="off"><button type="button" class="email-add" id="schEmailAdd">'+ic('userPlus',15)+' Add</button></div>'
|
+'<div class="email-invite"><input id="schEmail" class="finput" type="email" placeholder="name@example.com" autocomplete="off"><button type="button" class="email-add" id="schEmailAdd">'+ic('userPlus',15)+' Add</button></div>'
|
||||||
+'<div class="email-chips" id="schEmailChips"></div>'
|
+'<div class="email-chips" id="schEmailChips"></div>'
|
||||||
@@ -3225,6 +3228,8 @@ function openScheduleModal(gid, editMtg){
|
|||||||
$('schEmailAdd').onclick=addEmail;
|
$('schEmailAdd').onclick=addEmail;
|
||||||
$('schEmail').addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); addEmail(); } });
|
$('schEmail').addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); addEmail(); } });
|
||||||
renderEmailChips();
|
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()=>{
|
$('schSave').onclick=async()=>{
|
||||||
const title=$('schTitle').value.trim();
|
const title=$('schTitle').value.trim();
|
||||||
const desc=$('schDesc').value.trim();
|
const desc=$('schDesc').value.trim();
|
||||||
@@ -3258,14 +3263,14 @@ function renderCall(){
|
|||||||
+ '<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'+(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" 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>'
|
+ '<button class="meet-ic host-only" id="meetRecBtn" title="Record meeting" style="display:none">'+ic('record',20)+'</button>'
|
||||||
+ '<button class="meet-ic" id="meetTransBtn" title="Live transcript">'+ic('fileText',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="meetPplBtn" title="Participants">'+ic('users',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>';
|
+ '<button class="meet-ic leave" id="meetLeaveBtn" title="Leave">'+ic('callEnd',20)+'</button></div></div>';
|
||||||
document.getElementById('meetMicBtn').onclick=toggleMic;
|
document.getElementById('meetMicBtn').onclick=toggleMic;
|
||||||
document.getElementById('meetCamBtn').onclick=toggleCam;
|
document.getElementById('meetCamBtn').onclick=toggleCam;
|
||||||
document.getElementById('meetScreenBtn').onclick=toggleScreen;
|
document.getElementById('meetScreenBtn').onclick=toggleScreen;
|
||||||
document.getElementById('meetRecBtn').onclick=toggleRecord;
|
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('meetPplBtn').onclick=toggleMeetPanel;
|
||||||
document.getElementById('meetLeaveBtn').onclick=leaveMeeting;
|
document.getElementById('meetLeaveBtn').onclick=leaveMeeting;
|
||||||
updateHostControls();
|
updateHostControls();
|
||||||
@@ -3481,11 +3486,11 @@ async function enterMeeting(code, audioOnly){
|
|||||||
renderCallConnecting(); // branded "Connecting…" until the room is created/joined (esp. on slow links)
|
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=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
||||||
meetWs.onmessage=onMeetMsg;
|
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){
|
async function onMeetMsg(e){
|
||||||
let m; try{ m=JSON.parse(e.data); }catch(_){ return; }
|
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'){
|
if(m.type==='meeting-joined'){
|
||||||
meetMyId=m.peerId;
|
meetMyId=m.peerId;
|
||||||
if(m.isHost){ meetIsHost=true; meetHostId=meetMyId; } // host = the meeting creator (server-decided)
|
if(m.isHost){ meetIsHost=true; meetHostId=meetMyId; } // host = the meeting creator (server-decided)
|
||||||
|
|||||||
+4
-2
@@ -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.
|
// 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) => {
|
route('POST', '/api/meetings/guest-token', async (req, res) => {
|
||||||
if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' });
|
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();
|
const rm = String(room || '').trim();
|
||||||
if (!/^\d{6}$/.test(rm)) return json(res, 400, { error: 'invalid room' });
|
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 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; } })();
|
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' });
|
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 gname = String(name || 'Guest').slice(0, 60);
|
||||||
const token = livekitToken(gid, gname, rm, JSON.stringify({ guest: true }));
|
const token = livekitToken(gid, gname, rm, JSON.stringify({ guest: true }));
|
||||||
json(res, 200, { token, url: LIVEKIT_URL, identity: gid, name: gname });
|
json(res, 200, { token, url: LIVEKIT_URL, identity: gid, name: gname });
|
||||||
|
|||||||
+7
-1
@@ -79,7 +79,13 @@ function handle(ws, m, req) {
|
|||||||
let hostUserId = roomHost.get(room);
|
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 (_) {} }
|
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);
|
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 avatar = (ju && ju.avatar_url) ? ju.avatar_url : null; // for participant-tile profile pics
|
||||||
const isHost = !!(ju && hostUserId && ju.id === hostUserId);
|
const isHost = !!(ju && hostUserId && ju.id === hostUserId);
|
||||||
// Tell the newcomer who's already here (they initiate offers to existing peers)…
|
// Tell the newcomer who's already here (they initiate offers to existing peers)…
|
||||||
|
|||||||
Reference in New Issue
Block a user