#12 multi-device tiles + #5 native iOS transcript

#12 — same user on two devices now shows as two independent tiles (was: LiveKit
kicked the older connection, "audio jumps to whichever joined last"):
- LiveKit identity is now the per-connection mesh peerId, not the user id.
  /api/meetings/token + guest-token mint identity=peerId when the client supplies
  it (anti-hijack: never mint another live user's peerId). Web maps SFU tracks by
  identity==peerId, keeping peerIdForUid as a fallback for the transition/native.
- Mesh dedup (dropDupPeers) now keys on a stable per-device clientId (persisted,
  sent on meeting-join, echoed by the server) instead of user id — so two real
  devices keep separate tiles while a same-device reconnect ghost still collapses.
  Verified in a real browser: 2 devices -> 2 tiles; same-device reconnect -> 1.
- Native: plugin gains reconnectRoom(); after the native WebView joins the mesh it
  re-homes the LiveKit media onto its peerId identity. syncVideoTiles keys by peerId.
  Token-identity + anti-hijack + clientId echo verified by a server test.

#5 — iOS live transcript (WKWebView has no Web Speech API, so an iOS participant
was never transcribed; desktop already works):
- native-call plugin transcribes the local mic with SFSpeechRecognizer, fed by a
  LiveKit AudioRenderer on the local mic track (reuses the call's open mic — no 2nd
  AVAudioEngine). Finalized segments -> 'transcript' event -> web sends
  meeting-transcript (same server assembly as desktop). startSR/stopSR use the
  native recognizer on native calls; Web Speech API path unchanged elsewhere.
- NSSpeechRecognitionUsageDescription added to the iOS Info.plist.

Native pieces (#12 reconnect, #5 transcript) need a Codemagic build; the web+server
half is verified and deploys now (already fixes the reported laptop+phone case).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 16:36:18 +05:30
parent c322448774
commit 68ff3a878b
5 changed files with 260 additions and 35 deletions
+4 -3
View File
@@ -58,9 +58,9 @@ function finishMeetingJoin(ws, room, peers) {
const hostUserId = roomHost.get(room);
const avatar = ws._meetingAvatar || null;
const isHost = !!(ws._meetingUserId && hostUserId && ws._meetingUserId === hostUserId);
ws.send(JSON.stringify({ type: 'meeting-joined', room, peerId, isHost, peers: [...peers.entries()].map(([id, p]) => ({ peerId: id, name: p.name, avatar: p.avatar || null, uid: p.uid || null })) }));
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name, avatar, uid: ws._meetingUserId || null })); }
peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null });
ws.send(JSON.stringify({ type: 'meeting-joined', room, peerId, isHost, peers: [...peers.entries()].map(([id, p]) => ({ peerId: id, name: p.name, avatar: p.avatar || null, uid: p.uid || null, clientId: p.clientId || null })) }));
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name, avatar, uid: ws._meetingUserId || null, clientId: ws._clientId || null })); }
peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null, clientId: ws._clientId || null });
noteRoomStat(room, ws, peers.size); // #7: track the high-water participant count for the call log
if (ws._meetingUserId) { try { require('./calls').markDmAnswered(room, ws._meetingUserId); } catch (_) {} CHAT.broadcastPresence(ws._meetingUserId); }
const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true }));
@@ -142,6 +142,7 @@ async function handle(ws, m, req) {
const peerId = A.token(6);
const name = String(m.name || 'Guest').slice(0, 60);
ws.kind = 'meeting'; ws._meetingRoom = room; ws._peerId = peerId; ws._peerName = name;
ws._clientId = (typeof m.clientId === 'string' && m.clientId) ? m.clientId.slice(0, 64) : null; // #12: stable per-device id → dedup a reconnecting device without collapsing a real 2nd device
// Host = the meeting's creator. roomHost is set on call/meeting creation; scheduled meetings fall back to created_by.
let hostUserId = roomHost.get(room);
if (hostUserId === undefined) { try { const s = await R.scheduledMeetings.byCode(room); if (s) { hostUserId = s.created_by; roomHost.set(room, hostUserId); } } catch (_) {} }