diff --git a/server/calls.js b/server/calls.js index 2a68ade..bdd6785 100644 --- a/server/calls.js +++ b/server/calls.js @@ -133,6 +133,10 @@ function endCallByRoom(room) { endGroupCallByRoom(room); endDmCallByRoom(room); function declineDmCall(room, byUser) { const key = roomToDmCall.get(room); if (!key) return { ok: false }; const call = dmCalls.get(key); if (!call) return { ok: false }; + // #8: if this user has ALREADY accepted on another device (they're in the room), this is just the + // ringing invite on a second device — dismiss it silently, do NOT tear down the active call. + const inRoom = meetingRooms.get(room); + if (inRoom) { for (const [, p] of inRoom) { if (p.ws && p.ws._meetingUserId === byUser.id) return { ok: true, alreadyJoined: true }; } } const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy; try { const mid = A.id(); diff --git a/server/public/home.html b/server/public/home.html index 29c47c8..efd6fbc 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -349,6 +349,9 @@ .meet{display:flex;flex-direction:column;width:100%;height:100%;} .meet-grid{flex:1;min-height:0;display:grid;gap:.6rem;padding:.8rem;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));align-content:center;overflow:hidden;background:#0b1220;} /* #16: center tiles + no stray scrollbar on 1:1/small calls */ .meet-tile{position:relative;background:#0b1220;border-radius:12px;overflow:hidden;aspect-ratio:4/3;border:1px solid #1e293b;transition:box-shadow .12s,border-color .12s;} + .meet-tile.waiting{border-color:#334155;} + .meet-tile.waiting .ringing{position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;gap:.3rem;font-size:.8rem;color:#cbd5e1;background:rgba(0,0,0,.35);padding:.35rem;animation:ringPulse 1.4s ease-in-out infinite;} + @keyframes ringPulse{0%,100%{opacity:.6;}50%{opacity:1;}} .meet-tile.speaking{border-color:#22c55e;box-shadow:0 0 0 2px #22c55e, 0 0 14px rgba(34,197,94,.5);} .meet-tile .meet-screen{position:absolute;right:.5rem;top:.5rem;display:inline-flex;align-items:center;gap:.25rem;background:rgba(37,99,235,.92);color:#fff;font-size:.66rem;font-weight:700;padding:.14rem .42rem;border-radius:6px;} .meet-tile.sharing{grid-column:span 2;border-color:#2563eb;} @@ -823,7 +826,7 @@ - @@ -1717,7 +1720,7 @@ function onGroupCall(d){ function dismissCallInvite(room){ if(!room) return; const el=document.getElementById('ci-'+room); if(el){ try{ el.remove(); }catch(_){} stopRing(); } } // 1:1 call: start/join from the DM header; live state updates the button + shows an incoming invite. async function startOrJoinDmCall(otherId){ - try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ meetReturn={kind:'dm',id:otherId}; switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer */ } } + try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ const _r=rowFor('dm',otherId); _dmCallWaiting={ name:(_r&&_r.name)||'Contact', avatar:(_r&&_r.avatar)||null }; meetReturn={kind:'dm',id:otherId}; switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer */ } } catch(e){ toast(e.message||'Could not start the call'); } } // Live presence: a contact came online/offline or entered/left a call — update their dot + the @@ -2719,8 +2722,12 @@ function sfuRebuild(pid){ const vid=p.screen||p.cam; if(vid) st.addTrack(vid); if(p.audio) st.addTrack(p.audio); addTile(pid, st, meetNames.get(pid)||'Guest', false); setTileScreen(pid, !!p.screen); meetWatchStream(pid, st); } -function sfuAttach(pub, track, participant){ - const pid=peerIdForUid(participant.identity); if(!pid) return; +function sfuAttach(pub, track, participant, _try){ + const pid=peerIdForUid(participant.identity); + if(!pid){ // #9: uid→peerId map (from the mesh join) may lag the LiveKit track — retry a few times + if((_try||0)<10){ setTimeout(()=>sfuAttach(pub,track,participant,(_try||0)+1), 400); } + return; + } const p=SFU.peers.get(pid)||{}; SFU.peers.set(pid,p); const mt=track.mediaStreamTrack; if(track.kind==='audio') p.audio=mt; else if(pub.source===SFU.lib.Track.Source.ScreenShare){ p.screen=mt; meetSharers.add(pid); } @@ -2984,6 +2991,16 @@ function addTile(id, stream, label, muted){ } function setTileMute(id, muted){ meetMuted.set(id, !!muted); const t=document.getElementById('meet-tile-'+id); if(t){ const b=t.querySelector('.meet-mute'); if(b) b.style.display=muted?'grid':'none'; if(muted) t.classList.remove('speaking'); } } function removeTile(id){ const t=document.getElementById('meet-tile-'+id); if(t) t.remove(); meetMuted.delete(id); meetCamOff.delete(id); meetUnwatch(id); } +// #7: on an outgoing 1:1 call, show the person you're calling (DP + name + "Ringing…") so it's clear +// who the call is to, instead of only your own tile. Removed as soon as they answer. +let _dmCallWaiting=null; +function addWaitingTile(name, avatar){ + const grid=document.getElementById('meetGrid'); if(!grid || document.getElementById('meet-tile-__waiting')) return; + const tile=document.createElement('div'); tile.className='meet-tile novid waiting'; tile.id='meet-tile-__waiting'; + tile.innerHTML='
'+pEsc(initials(name||'?'))+(avatar?'':'')+'
'+pEsc(name||'')+'
'+ic('phone',13)+' Ringing…
'; + grid.appendChild(tile); +} +function removeWaitingTile(){ const t=document.getElementById('meet-tile-__waiting'); if(t) t.remove(); } // ---- Active-speaker meter: highlight a tile while its audio is above a threshold ---- function meetWatchStream(id, stream){ if(!stream || !stream.getAudioTracks || !stream.getAudioTracks().length) return; // no audio yet @@ -3166,6 +3183,7 @@ async function onMeetMsg(e){ // Existing peers OFFER to me (their offers carry their tracks incl. any active screen share); // I just set up the connections and wait. Avoids the "newcomer can't receive screen" bug. for(const p of (m.peers||[])){ meetNames.set(p.peerId,p.name); if(p.avatar) meetAvatars.set(p.peerId,p.avatar); if(p.uid){ meetPeerUids.set(p.peerId,p.uid); meetInviteJoined(p.uid); } meetMakePeer(p.peerId,p.name); } + if(_dmCallWaiting && !(m.peers&&m.peers.length)) addWaitingTile(_dmCallWaiting.name, _dmCallWaiting.avatar); // #7: show who we're calling while it rings // SFU: connect to LiveKit for media once (peer uid→peerId map is populated above). Mic/cam are // off at join, so nothing publishes yet — toggleMic/toggleCam publish on demand. if(SFU.on){ try{ await sfuConnect(); }catch(err){ const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } } @@ -3176,7 +3194,7 @@ async function onMeetMsg(e){ } if(m.type==='meeting-ended'){ toast('Call ended'); leaveMeeting(true); return; } // 1:1 hangup, or host ended if(m.type==='meeting-peer-joined'){ - stopRingback(); // #17: they answered → stop the outgoing ring + stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: they answered → stop ring + drop the ringing tile meetNames.set(m.peerId,m.name); if(m.avatar) meetAvatars.set(m.peerId,m.avatar); if(m.uid){ meetPeerUids.set(m.peerId,m.uid); meetInviteJoined(m.uid); } const pc=meetMakePeer(m.peerId,m.name); // I'm an existing peer → I OFFER to the newcomer (carries my screen) if(pc){ try{ const offer=await pc.createOffer(); await pc.setLocalDescription(offer); meetSend({type:'meeting-signal',to:m.peerId,data:{sdp:pc.localDescription}}); }catch(_){} } // (SFU: LiveKit handles media, no offer) @@ -3251,7 +3269,7 @@ let meetLeaving=false; // there wrongly announced "Host handed to " (bug). function leaveMeeting(forced){ if(meetLeaving) return; meetLeaving=true; - stopRingback(); // #17: any call exit stops the outgoing ring + stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: any call exit stops the ring + ringing tile const isDm=!!(meetReturn && meetReturn.kind==='dm'); if(!forced && !isDm && meetIsHost && meetPeers.size>0){ const next=meetPeers.keys().next().value; // first remaining participant