/ concept-collection / commonroom
Sign in
concept-collection / commonroom
commonroom / src / p2p / network.ts
1321 lines · 45.6 KBBlameHistoryRaw
1import {selfId} from './identity'
2import {Nostr, peerTopic, roomTopic} from './nostr'
3import {Peer, type Signal, type VideoSendParams} from './peer'
4import {
5 DEFAULT_SETTINGS,
6 QUALITY_PARAMS,
7 SETTING_VALIDATORS,
8 type RoomSettings,
9 type VideoQuality
10} from './settings'
11import {
12 BASE_ICE_SERVERS,
13 STUN_SERVERS,
14 TURN_CONFIGURED,
15 fetchIceConfig,
16 sanitizeIceConfig,
17 type IceConfig,
18 type RelayStatus
19} from './turn'
20import {isUsableApiKey} from '../transcribe/deepgram'
21import {TranscriptStore, type TranscriptItem} from '../transcribe/store'
22import {Transcriber, type TranscriptSource} from '../transcribe/transcriber'
24// ---------------------------------------------------------------------------
25// CommonRoom network layer: a full-mesh group video call.
26//
27// Rooms: the room ID is any string (no spaces); it is hashed into a nostr
28// topic, so there is no room registry anywhere — knowing the name IS the key.
29//
30// Presence: everyone in the room announces {peerId, name} on the room topic
31// every few seconds; entries expire when announcements stop.
32//
33// Mesh: unlike commoncall (mutual consent, one call at a time), being in the
34// room IS the consent — every participant automatically brings up a WebRTC
35// connection with every other participant (commonview's approach, but carrying
36// media). Camera/mic are requested on entry, but both start MUTED; if access
37// is denied you still join, sending synthetic silent/black placeholder tracks,
38// and unmuting retries the device and upgrades the tracks in place.
39//
40// Everything else (deterministic initiator = smaller peer ID, per-peer nostr
41// signaling topics, control data channel, track-swap screen share, quality
42// caps via setParameters) is the commoncall design, applied per-peer.
43// ---------------------------------------------------------------------------
45/** Soft cap: peers at capacity turn newcomers away with {t:'room-full'}. */
46export const MAX_PARTICIPANTS = 8
48interface Announcement {
49 peerId: string
50 name: string
53// Messages on per-peer nostr topics (pre-connection).
54type PeerMsg = {t: 'signal'; signal: Signal} | {t: 'room-full'}
56// Messages on the per-peer control data channels (WebRTC, not nostr).
57interface SettingEntry {
58 key: string
59 value: unknown
60 rev: number
61 by: string
63type ControlMsg =
64 | {
65 t: 'hello'
66 name: string
67 audioMuted: boolean
68 videoMuted: boolean
69 /** Self-reported epoch ms of when they entered the room, so receivers
70 * can tell "was already here when I arrived" from "joined after me"
71 * for the chat's join lines. */
72 joinedAt: number
73 transcribing: boolean
74 settings: SettingEntry[]
75 }
76 | ({t: 'set'} & SettingEntry)
77 | {t: 'mute'; audio: boolean; video: boolean}
78 | {t: 'chat'; text: string}
79 /** Whether we are sending the room's audio to a transcription service. */
80 | {t: 'tx'; on: boolean}
81 /** TURN credentials, so one person's token covers the whole room. */
82 | {t: 'ice'; iceServers: RTCIceServer[]; expiresAt: number}
83 | {t: 'bye'}
85const ANNOUNCE_INTERVAL_MS = 5000
86const PRESENCE_TTL_MS = 15000
87// A connection attempt that hasn't opened after this long is torn down and
88// retried on the peer's next announcement. Signaling events are ephemeral, so
89// an offer published before the other side was listening is simply lost —
90// without a retry the pair would deadlock forever.
91const CONNECT_RETRY_MS = 15000
93const NAME_KEY = 'commonroom:name'
94const TURN_TOKEN_KEY = 'commonroom:turnToken'
95const DEEPGRAM_KEY = 'commonroom:deepgramKey'
97/** Re-mint our relay credentials this long before they lapse, so a long call
98 * never runs out mid-session. */
99const ICE_REFRESH_MARGIN_MS = 5 * 60 * 1000
101const CHAT_MAX_LENGTH = 2000
102const CHAT_LOG_CAP = 500
104export type Phase = 'landing' | 'joining' | 'room'
106interface Conn {
107 peer: Peer
108 /** When this connection attempt started (local clock), for retry pacing. */
109 createdAt: number
110 /** Name from the hello message (presence announcements may lag behind). */
111 name: string | null
112 connected: boolean
113 stream: MediaStream | null
114 /** Their reported effective outgoing mute state (muted until told otherwise
115 * — everyone starts muted). */
116 audioMuted: boolean
117 videoMuted: boolean
118 /** Whether they told us they are transcribing the room. */
119 transcribing: boolean
122export interface ChatItem {
123 /** Monotonic per-session sequence number; stable React key. */
124 seq: number
125 kind: 'chat' | 'system'
126 /** The author's peer ID; null for system lines. */
127 peerId: string | null
128 name: string
129 text: string
130 /** Local arrival time (epoch ms). */
131 time: number
134export interface ParticipantInfo {
135 peerId: string
136 name: string
137 connected: boolean
138 stream: MediaStream | null
139 audioMuted: boolean
140 videoMuted: boolean
141 transcribing: boolean
144export interface Snapshot {
145 selfId: string
146 phase: Phase
147 roomId: string | null
148 name: string | null
149 /** Everyone else in the room (connected or still connecting). */
150 participants: ParticipantInfo[]
151 audioMuted: boolean
152 videoMuted: boolean
153 micAvailable: boolean
154 camAvailable: boolean
155 localStream: MediaStream | null
156 screenStream: MediaStream | null
157 settings: RoomSettings
158 chat: ChatItem[]
159 /** Where our TURN credentials came from (ours, a peer's, or none). */
160 relay: RelayStatus
161 /** Whether WE are transcribing (peers report their own in participants). */
162 transcribing: boolean
163 /** This room's transcript, restored from previous sittings and appended to
164 * while transcription runs. */
165 transcript: TranscriptItem[]
166 /** Audio sent to Deepgram for this room, in seconds — what it is billed on.
167 * Cumulative across sittings, like the transcript itself. */
168 transcriptSeconds: number
169 /** Whether a Deepgram key is stored in this browser. The key itself never
170 * reaches the UI, and never leaves this browser. */
171 hasDeepgramKey: boolean
172 notice: string | null
175export class Network {
176 private nostr = new Nostr()
177 private phase: Phase = 'landing'
178 private roomId: string | null = null
179 private root = ''
180 private name: string | null = null
181 private presence = new Map<string, {name: string; lastSeen: number}>()
182 private conns = new Map<string, Conn>()
183 private unsubs: (() => void)[] = []
184 private announceTimer: number | null = null
185 private sweepTimer: number | null = null
186 /** Bumped on every join/leave so stale async work can detect it's obsolete. */
187 private joinSeq = 0
189 private localStream: MediaStream | null = null
190 private screenStream: MediaStream | null = null
191 private micAvailable = false
192 private camAvailable = false
193 private audioMuted = true
194 private videoMuted = true
195 private audioCtx: AudioContext | null = null
197 // Relay (TURN) credentials for this room, if anyone in it has a token. See
198 // turn.ts for the scheme; the sharing itself is below under "relay".
199 private ice: IceConfig | null = null
200 /** True when `ice` was minted with OUR token rather than shared with us. */
201 private iceFromSelf = false
202 private iceTimer: number | null = null
203 private turnToken = ''
205 private settings: RoomSettings = {...DEFAULT_SETTINGS}
206 /** Per-key revision + setter for the last-writer-wins settings sync. */
207 private settingsMeta: Partial<
208 Record<keyof RoomSettings, {rev: number; by: string}>
209 > = {}
211 // Chat is ephemeral: you only see what's said while you're in the room.
212 // Messages arrive directly from their author over the authenticated
213 // channel, so there's no relaying and nothing to forge.
214 private chat: ChatItem[] = []
215 private chatSeq = 0
216 /** When WE entered the room (epoch ms), reported in our hello. */
217 private joinedAtMs = 0
218 /** peerId -> name for peers currently counted present in the chat. */
219 private chatPresent = new Map<string, string>()
220 /** Peers we've ever logged a join/left line for (so a reconnect after a
221 * network blip gets a "joined" line to match its "left" line). */
222 private chatSeen = new Set<string>()
224 // Transcription is entirely local: it belongs to whoever entered a Deepgram
225 // key, it is not a room setting, and the only thing that crosses the mesh is
226 // the fact that it is running. The transcript itself outlives the call and
227 // belongs to the room, so its store is opened on entry whether or not
228 // anything is being transcribed this time.
229 private transcript: TranscriptStore | null = null
230 private transcriber: Transcriber | null = null
231 private transcribing = false
232 private deepgramKey = localStorage.getItem(DEEPGRAM_KEY) ?? ''
234 private notice: string | null = null
236 private snapshot!: Snapshot
237 private listeners = new Set<() => void>()
239 /** Last name used on this browser, for prefilling the join form. */
240 readonly savedName: string = localStorage.getItem(NAME_KEY) ?? ''
242 /** Last relay token used on this browser, likewise. It stays on this device:
243 * what gets shared with the room is the credential it buys, never the
244 * token itself. */
245 readonly savedTurnToken: string = localStorage.getItem(TURN_TOKEN_KEY) ?? ''
247 constructor() {
248 this.rebuildSnapshot()
249 window.addEventListener('online', () => void this.announce())
250 // Best-effort goodbye so tiles vanish immediately instead of after the
251 // presence TTL when a tab closes.
252 window.addEventListener('pagehide', () => {
253 if (this.phase === 'room') this.broadcastControl({t: 'bye'})
254 // Closing the tab is the most likely way to leave, and the transcript
255 // is the one thing here meant to survive it.
256 this.transcript?.flush()
257 })
258 }
260 // ---- joining and leaving ----------------------------------------------
262 async enterRoom(name: string, room: string, turnToken = '') {
263 if (this.phase !== 'landing') return
264 const nm = name.trim().slice(0, 40)
265 const rm = room.replace(/\s+/g, '').slice(0, 100)
266 if (!nm || !rm) return
267 this.name = nm
268 this.roomId = rm
269 this.turnToken = turnToken.trim()
270 localStorage.setItem(NAME_KEY, nm)
271 localStorage.setItem(TURN_TOKEN_KEY, this.turnToken)
272 // Put the room in the URL so the address bar is the invite link.
273 try {
274 location.hash = encodeURIComponent(rm)
275 } catch {
276 /* ignore */
277 }
278 this.notice = null
279 // Whatever was transcribed in this room before is part of the room, so it
280 // is back on screen from the moment you enter.
281 this.transcript = new TranscriptStore(rm, () => this.rebuildSnapshot())
282 this.phase = 'joining'
283 this.rebuildSnapshot()
284 const seq = ++this.joinSeq
286 const media = await this.acquireMedia()
287 if (this.joinSeq !== seq) {
288 for (const t of media.stream.getTracks()) t.stop()
289 return
290 }
291 this.localStream = media.stream
292 this.micAvailable = media.mic
293 this.camAvailable = media.cam
294 // Tell the user right away when a device didn't come up (and why), so
295 // they aren't surprised at unmute time.
296 if (!media.mic && !media.cam) {
297 this.notice = `${mediaErrorMessage('camera or microphone', media.camError)} You've still joined — the mic/camera buttons will retry.`
298 } else if (!media.cam) {
299 this.notice = `${mediaErrorMessage('camera', media.camError)} The camera button will retry.`
300 } else if (!media.mic) {
301 this.notice = `${mediaErrorMessage('microphone', media.micError)} The mic button will retry.`
302 }
303 // Everyone enters muted.
304 this.audioMuted = true
305 this.videoMuted = true
306 for (const t of media.stream.getTracks()) t.enabled = false
308 this.root = await roomTopic(rm)
309 if (this.joinSeq !== seq) return
311 // Mint relay credentials BEFORE the mesh starts, so our very first
312 // connections already offer relay candidates. Peers without a token pick
313 // these up over the control channel once they are connected to someone.
314 if (TURN_CONFIGURED && this.turnToken) {
315 await this.mintIce(seq)
316 if (this.joinSeq !== seq) return
317 }
319 const selfTopic = await peerTopic(this.root, selfId)
320 if (this.joinSeq !== seq) return
322 // WebRTC signaling (and room-full notices) addressed to us.
323 this.unsubs.push(
324 this.nostr.subscribe(selfTopic, (content, from) => {
325 if (from === selfId) return
326 let msg: PeerMsg
327 try {
328 msg = JSON.parse(content)
329 } catch {
330 return
331 }
332 this.handlePeerMsg(from, msg)
333 })
334 )
336 // Presence announcements on the room topic.
337 this.unsubs.push(
338 this.nostr.subscribe(this.root, (content, from) => {
339 if (from === selfId) return
340 let ann: Partial<Announcement>
341 try {
342 ann = JSON.parse(content)
343 } catch {
344 return
345 }
346 if (ann.peerId !== from || typeof ann.name !== 'string') return
347 const prev = this.presence.get(from)
348 const annName = ann.name.slice(0, 40)
349 this.presence.set(from, {name: annName, lastSeen: Date.now()})
350 if (!prev || prev.name !== annName) this.rebuildSnapshot()
351 this.maybeConnect(from)
352 })
353 )
355 this.phase = 'room'
356 this.joinedAtMs = Date.now()
357 this.pushSystem('You joined')
358 void this.announce()
359 this.announceTimer = window.setInterval(
360 () => void this.announce(),
361 ANNOUNCE_INTERVAL_MS
362 )
363 this.sweepTimer = window.setInterval(
364 () => this.sweepPresence(),
365 ANNOUNCE_INTERVAL_MS
366 )
367 this.rebuildSnapshot()
368 }
370 leave() {
371 if (this.phase === 'landing') return
372 this.teardown()
373 this.notice = null
374 this.rebuildSnapshot()
375 }
377 private teardown() {
378 this.joinSeq++
379 this.broadcastControl({t: 'bye'})
380 const conns = [...this.conns.values()]
381 this.conns.clear() // cleared first so close handlers no-op
382 for (const c of conns) c.peer.destroy()
383 this.presence.clear()
384 for (const u of this.unsubs.splice(0)) u()
385 if (this.announceTimer !== null) clearInterval(this.announceTimer)
386 if (this.sweepTimer !== null) clearInterval(this.sweepTimer)
387 if (this.iceTimer !== null) clearTimeout(this.iceTimer)
388 this.announceTimer = null
389 this.sweepTimer = null
390 this.iceTimer = null
391 // Credentials are per-room (they carry a per-room analytics tag) and, when
392 // shared, belong to whoever was in that room — don't carry them onward.
393 this.ice = null
394 this.iceFromSelf = false
395 this.transcribing = false
396 if (this.transcriber) {
397 this.transcriber.dispose()
398 this.transcriber = null
399 }
400 // The transcript stays on disk under its room; only the open handle goes.
401 this.transcript?.flush()
402 this.transcript = null
403 if (this.screenStream) {
404 for (const t of this.screenStream.getTracks()) t.stop()
405 this.screenStream = null
406 }
407 if (this.localStream) {
408 for (const t of this.localStream.getTracks()) t.stop()
409 this.localStream = null
410 }
411 if (this.audioCtx) {
412 void this.audioCtx.close().catch(() => undefined)
413 this.audioCtx = null
414 }
415 this.micAvailable = false
416 this.camAvailable = false
417 this.audioMuted = true
418 this.videoMuted = true
419 this.settings = {...DEFAULT_SETTINGS}
420 this.settingsMeta = {}
421 this.chat = []
422 this.chatPresent.clear()
423 this.chatSeen.clear()
424 this.root = ''
425 this.roomId = null
426 this.phase = 'landing'
427 }
429 // ---- local media -------------------------------------------------------
430 //
431 // Every participant always carries exactly one audio and one video track so
432 // the WebRTC offer/answer is symmetric for everyone. If a device is missing
433 // or permission is denied, a synthetic placeholder (silent audio / black
434 // video) stands in; unmuting later retries getUserMedia and upgrades the
435 // placeholder via replaceTrack on every connection — no renegotiation.
437 private async acquireMedia(): Promise<{
438 stream: MediaStream
439 mic: boolean
440 cam: boolean
441 micError: unknown
442 camError: unknown
443 }> {
444 try {
445 const s = await navigator.mediaDevices.getUserMedia({
446 audio: true,
447 video: true
448 })
449 return {stream: s, mic: true, cam: true, micError: null, camError: null}
450 } catch {
451 // The combined request fails as a whole if EITHER device is unusable
452 // (in Firefox, e.g., a camera held by another app fails it even though
453 // the mic is fine) — retry each kind on its own to keep what works.
454 }
455 let audio: MediaStreamTrack | null = null
456 let video: MediaStreamTrack | null = null
457 let micError: unknown = null
458 let camError: unknown = null
459 try {
460 const s = await navigator.mediaDevices.getUserMedia({audio: true})
461 audio = s.getAudioTracks()[0] ?? null
462 } catch (err) {
463 micError = err
464 }
465 try {
466 const s = await navigator.mediaDevices.getUserMedia({video: true})
467 video = s.getVideoTracks()[0] ?? null
468 } catch (err) {
469 camError = err
470 }
471 const stream = new MediaStream()
472 stream.addTrack(audio ?? this.silentAudioTrack())
473 stream.addTrack(video ?? blackVideoTrack())
474 return {stream, mic: audio !== null, cam: video !== null, micError, camError}
475 }
477 private silentAudioTrack(): MediaStreamTrack {
478 if (!this.audioCtx) this.audioCtx = new AudioContext()
479 const dst = this.audioCtx.createMediaStreamDestination()
480 return dst.stream.getAudioTracks()[0]
481 }
483 /** The tracks we send to a (new) peer: mic audio plus screen or camera. */
484 private outgoingStream(): MediaStream {
485 const s = new MediaStream()
486 const audio = this.localStream?.getAudioTracks()[0]
487 if (audio) s.addTrack(audio)
488 const video =
489 this.screenStream?.getVideoTracks()[0] ??
490 this.localStream?.getVideoTracks()[0]
491 if (video) s.addTrack(video)
492 return s
493 }
495 // ---- presence and the mesh ----------------------------------------------
497 private async announce() {
498 if (this.phase !== 'room' || !this.name || !this.root) return
499 const ann: Announcement = {peerId: selfId, name: this.name}
500 void this.nostr.publish(this.root, JSON.stringify(ann))
501 }
503 private sweepPresence() {
504 const cutoff = Date.now() - PRESENCE_TTL_MS
505 let changed = false
506 for (const [peerId, p] of this.presence) {
507 if (p.lastSeen < cutoff) {
508 this.presence.delete(peerId)
509 changed = true
510 }
511 }
512 if (changed) this.rebuildSnapshot()
513 }
515 private async sendToPeer(peerId: string, msg: PeerMsg) {
516 if (!this.root) return
517 const topic = await peerTopic(this.root, peerId)
518 void this.nostr.publish(topic, JSON.stringify(msg))
519 }
521 private atCapacity(): boolean {
522 return this.conns.size >= MAX_PARTICIPANTS - 1
523 }
525 private maybeConnect(peerId: string) {
526 if (this.phase !== 'room' || !this.localStream || peerId === selfId) return
527 const existing = this.conns.get(peerId)
528 if (existing) {
529 const stalled =
530 !existing.connected &&
531 Date.now() - existing.createdAt > CONNECT_RETRY_MS
532 if (!stalled) return
533 this.conns.delete(peerId) // deleted first so the close handler no-ops
534 existing.peer.destroy()
535 }
536 if (this.atCapacity()) {
537 // The room is full from our point of view: turn the newcomer away.
538 void this.sendToPeer(peerId, {t: 'room-full'})
539 return
540 }
541 // Deterministic initiator: the peer with the smaller ID makes the offer.
542 this.createPeer(peerId, selfId < peerId)
543 }
545 private createPeer(peerId: string, initiator: boolean): Conn {
546 const peer = new Peer(initiator, this.outgoingStream(), this.iceServers())
547 const conn: Conn = {
548 peer,
549 createdAt: Date.now(),
550 name: null,
551 connected: false,
552 stream: null,
553 audioMuted: true,
554 videoMuted: true,
555 transcribing: false
556 }
557 this.conns.set(peerId, conn)
559 peer.setHandlers({
560 signal: signal => {
561 void this.sendToPeer(peerId, {t: 'signal', signal})
562 },
563 track: stream => {
564 conn.stream = stream
565 this.rebuildSnapshot()
566 },
567 connect: () => {
568 conn.connected = true
569 this.sendHello(conn)
570 this.sendIce(conn)
571 this.applyVideoParamsTo(conn)
572 this.rebuildSnapshot()
573 },
574 data: raw => this.handleControl(peerId, conn, raw),
575 close: () => {
576 if (this.conns.get(peerId) === conn) {
577 this.conns.delete(peerId)
578 const chatName = this.chatPresent.get(peerId)
579 if (chatName !== undefined) {
580 this.chatPresent.delete(peerId)
581 this.pushSystem(`${chatName} left`)
582 }
583 this.rebuildSnapshot()
584 }
585 }
586 })
588 this.rebuildSnapshot()
589 return conn
590 }
592 private handlePeerMsg(from: string, msg: PeerMsg) {
593 if (this.phase !== 'room') return
594 switch (msg.t) {
595 case 'signal': {
596 let conn = this.conns.get(from)
597 if (!conn) {
598 // An offer can arrive before we've seen the peer's announcement.
599 if (msg.signal?.type !== 'offer') return
600 if (this.atCapacity()) {
601 void this.sendToPeer(from, {t: 'room-full'})
602 return
603 }
604 conn = this.createPeer(from, false)
605 }
606 void conn.peer.signal(msg.signal)
607 return
608 }
609 case 'room-full': {
610 // Only honor this while we haven't gotten a foothold in the room —
611 // once we have any connection, we're in.
612 if (this.conns.size === 0) {
613 this.teardown()
614 this.notice = `That room is full — up to ${MAX_PARTICIPANTS} people can be in a room.`
615 this.rebuildSnapshot()
616 }
617 return
618 }
619 }
620 }
622 // ---- relay (TURN) ---------------------------------------------------------
623 //
624 // Relaying costs bandwidth, so the credentials are bought with a token that
625 // only some participants have. Rather than require the token from everyone,
626 // whoever has one mints a short-lived ICE configuration and shares it over
627 // the control channels; everyone else adopts it and gains relay candidates
628 // of their own. One person's token therefore covers the whole room.
629 //
630 // Sharing the credential rather than the token is what makes this safe to do
631 // over the mesh: the token never leaves the browser it was typed into, and
632 // what does travel expires on its own and can be revoked at the Worker.
633 //
634 // Note the bootstrapping order. Credentials arrive over a connection, so they
635 // cannot help the connection that carried them — a peer learns them from the
636 // first peer it manages to reach (usually the token holder, whose relay
637 // candidates make that first connection work even for the peer that has
638 // none) and uses them for every connection after that. A pair that stalls in
639 // the meantime is rebuilt by the CONNECT_RETRY_MS retry in maybeConnect,
640 // which reads iceServers() afresh, so it picks up whatever has arrived since.
642 /** The ICE configuration for a NEW connection. */
643 private iceServers(): RTCIceServer[] {
644 const ice = this.ice
645 if (!ice || ice.expiresAt <= Date.now()) return BASE_ICE_SERVERS
646 // Keep the plain STUN servers alongside the relay: reflexive candidates
647 // are what let most pairs avoid the relay altogether.
648 return [...STUN_SERVERS, ...ice.iceServers]
649 }
651 private relayStatus(): RelayStatus {
652 if (!this.ice || this.ice.expiresAt <= Date.now()) return 'off'
653 return this.iceFromSelf ? 'self' : 'shared'
654 }
656 /** Buy credentials with our token and share them with the room. */
657 private async mintIce(seq: number, refresh = false) {
658 let cfg: IceConfig
659 try {
660 // The Worker tags the credential with this for per-room usage analytics.
661 // It is a prefix of the hashed room topic, so the room name itself is
662 // never sent anywhere.
663 cfg = await fetchIceConfig(this.turnToken, this.root.slice(0, 16))
664 } catch (err) {
665 if (this.joinSeq !== seq) return
666 if (refresh) {
667 // Mid-call, and the credential we already have is still good for a few
668 // more minutes: keep it, say nothing, and try again shortly.
669 this.scheduleIceRefresh(seq)
670 return
671 }
672 const why = err instanceof Error ? err.message : 'the request failed'
673 const msg = `Relay unavailable — ${why}. Calls will use direct connections only, which may not work for everyone.`
674 this.notice = this.notice ? `${this.notice} ${msg}` : msg
675 this.rebuildSnapshot()
676 return
677 }
678 if (this.joinSeq !== seq) return
679 this.ice = cfg
680 this.iceFromSelf = true
681 this.scheduleIceRefresh(seq)
682 // No-op at join time (no peers yet); this is what carries a REFRESHED
683 // credential out to a room that is already assembled.
684 this.broadcastControl({t: 'ice', ...cfg})
685 this.rebuildSnapshot()
686 }
688 private scheduleIceRefresh(seq: number) {
689 if (this.iceTimer !== null) clearTimeout(this.iceTimer)
690 const due = (this.ice?.expiresAt ?? 0) - Date.now() - ICE_REFRESH_MARGIN_MS
691 this.iceTimer = window.setTimeout(
692 () => {
693 this.iceTimer = null
694 if (this.joinSeq === seq && this.phase === 'room') {
695 void this.mintIce(seq, true)
696 }
697 },
698 // The floor also paces retries after a failed refresh, which reschedules
699 // itself with an expiry already in the past.
700 Math.max(due, 60_000)
701 )
702 }
704 private sendIce(conn: Conn) {
705 const ice = this.ice
706 if (!ice || ice.expiresAt <= Date.now()) return
707 conn.peer.send(JSON.stringify({t: 'ice', ...ice} satisfies ControlMsg))
708 }
710 /** Take on a configuration another participant shared with us. Our own
711 * credentials always win: they are the ones we can refresh. */
712 private adoptIce(cfg: IceConfig) {
713 if (this.iceFromSelf && this.ice && this.ice.expiresAt > Date.now()) return
714 if (this.ice && this.ice.expiresAt >= cfg.expiresAt) return // no better
715 this.ice = cfg
716 this.iceFromSelf = false
717 this.rebuildSnapshot()
718 }
720 // ---- control channel ----------------------------------------------------
722 private broadcastControl(msg: ControlMsg) {
723 const payload = JSON.stringify(msg)
724 for (const conn of this.conns.values()) conn.peer.send(payload)
725 }
727 private sendHello(conn: Conn) {
728 const settings: SettingEntry[] = []
729 for (const [key, meta] of Object.entries(this.settingsMeta)) {
730 settings.push({
731 key,
732 value: this.settings[key as keyof RoomSettings],
733 rev: meta.rev,
734 by: meta.by
735 })
736 }
737 conn.peer.send(
738 JSON.stringify({
739 t: 'hello',
740 name: this.name ?? '',
741 audioMuted: this.audioMuted,
742 videoMuted: this.effectiveVideoMuted(),
743 joinedAt: this.joinedAtMs,
744 transcribing: this.transcribing,
745 settings
746 } satisfies ControlMsg)
747 )
748 }
750 private handleControl(peerId: string, conn: Conn, raw: string) {
751 if (this.conns.get(peerId) !== conn) return
752 let msg: ControlMsg
753 try {
754 msg = JSON.parse(raw)
755 } catch {
756 return
757 }
758 switch (msg.t) {
759 case 'hello': {
760 if (typeof msg.name === 'string') conn.name = msg.name.slice(0, 40)
761 conn.audioMuted = msg.audioMuted !== false
762 conn.videoMuted = msg.videoMuted !== false
763 conn.transcribing = msg.transcribing === true
764 if (Array.isArray(msg.settings)) {
765 for (const entry of msg.settings) this.applyRemoteSetting(entry)
766 }
767 if (!this.chatPresent.has(peerId)) {
768 const name =
769 this.presence.get(peerId)?.name ??
770 conn.name ??
771 peerId.slice(0, 8)
772 // No join line for people who were already here when we arrived
773 // (their self-reported join predates ours) — unless we've logged a
774 // "left" for them before, in which case this is a return.
775 const joinedAt = typeof msg.joinedAt === 'number' ? msg.joinedAt : 0
776 const preexisting =
777 joinedAt <= this.joinedAtMs && !this.chatSeen.has(peerId)
778 this.chatPresent.set(peerId, name)
779 this.chatSeen.add(peerId)
780 if (!preexisting) this.pushSystem(`${name} joined`)
781 // Walking into a room that is already being transcribed is exactly
782 // the case where nobody has seen the announcement, so say it here.
783 if (conn.transcribing) {
784 this.pushSystem(`${name} is transcribing this meeting`)
785 }
786 }
787 this.rebuildSnapshot()
788 return
789 }
790 case 'set': {
791 this.applyRemoteSetting(msg)
792 return
793 }
794 case 'mute': {
795 if (typeof msg.audio !== 'boolean' || typeof msg.video !== 'boolean') {
796 return
797 }
798 conn.audioMuted = msg.audio
799 conn.videoMuted = msg.video
800 this.rebuildSnapshot()
801 return
802 }
803 case 'chat': {
804 if (typeof msg.text !== 'string') return
805 const text = msg.text.slice(0, CHAT_MAX_LENGTH)
806 if (!text.trim()) return
807 this.pushChatItem({
808 kind: 'chat',
809 peerId,
810 name:
811 this.presence.get(peerId)?.name ?? conn.name ?? peerId.slice(0, 8),
812 text
813 })
814 this.rebuildSnapshot()
815 return
816 }
817 case 'tx': {
818 if (typeof msg.on !== 'boolean' || conn.transcribing === msg.on) return
819 conn.transcribing = msg.on
820 const name =
821 this.presence.get(peerId)?.name ?? conn.name ?? peerId.slice(0, 8)
822 this.pushSystem(
823 msg.on
824 ? `${name} started transcribing this meeting`
825 : `${name} stopped transcribing`
826 )
827 this.rebuildSnapshot()
828 return
829 }
830 case 'ice': {
831 // Untrusted input: a peer could send anything here, so the list is
832 // validated down to well-formed ICE URLs before it goes near a
833 // RTCPeerConnection.
834 const cfg = sanitizeIceConfig(msg.iceServers, msg.expiresAt)
835 if (cfg) this.adoptIce(cfg)
836 return
837 }
838 case 'bye': {
839 this.presence.delete(peerId)
840 conn.peer.destroy() // its close handler removes it and rebuilds
841 return
842 }
843 }
844 }
846 // ---- chat ------------------------------------------------------------------
848 private pushChatItem(item: Omit<ChatItem, 'seq' | 'time'>) {
849 this.chat.push({...item, seq: this.chatSeq++, time: Date.now()})
850 if (this.chat.length > CHAT_LOG_CAP) {
851 this.chat.splice(0, this.chat.length - CHAT_LOG_CAP)
852 }
853 }
855 private pushSystem(text: string) {
856 this.pushChatItem({kind: 'system', peerId: null, name: '', text})
857 }
859 /** Send a chat message to everyone in the room (and our own log). */
860 sendChat(text: string) {
861 if (this.phase !== 'room') return
862 const trimmed = text.trim().slice(0, CHAT_MAX_LENGTH)
863 if (!trimmed) return
864 this.broadcastControl({t: 'chat', text: trimmed})
865 this.pushChatItem({
866 kind: 'chat',
867 peerId: selfId,
868 name: this.name ?? '',
869 text: trimmed
870 })
871 this.rebuildSnapshot()
872 }
874 // ---- shared room settings ------------------------------------------------
875 //
876 // ONE settings object for the whole room, editable by anyone. Sync is
877 // per-key last-writer-wins: every change bumps that key's revision and is
878 // broadcast as {t:'set'} to every peer (the mesh is a complete graph, so no
879 // relaying is needed). Late joiners receive the current entries in each
880 // hello. Concurrent changes at the same revision must resolve identically
881 // everywhere, so the SETTER with the smaller peer ID wins the tie.
883 private setSetting<K extends keyof RoomSettings>(
884 key: K,
885 value: RoomSettings[K]
886 ) {
887 if (this.phase !== 'room' || this.settings[key] === value) return
888 const rev = (this.settingsMeta[key]?.rev ?? 0) + 1
889 this.settingsMeta[key] = {rev, by: selfId}
890 this.settings = {...this.settings}
891 this.settings[key] = value
892 this.broadcastControl({t: 'set', key, value, rev, by: selfId})
893 this.settingChanged(key)
894 this.rebuildSnapshot()
895 }
897 private applyRemoteSetting(entry: SettingEntry) {
898 if (typeof entry !== 'object' || entry === null) return
899 if (typeof entry.key !== 'string' || !(entry.key in SETTING_VALIDATORS)) {
900 return
901 }
902 const key = entry.key as keyof RoomSettings
903 if (!SETTING_VALIDATORS[key](entry.value)) return
904 if (!Number.isInteger(entry.rev) || entry.rev < 1) return
905 if (typeof entry.by !== 'string' || entry.by.length !== 64) return
906 const cur = this.settingsMeta[key]
907 const curRev = cur?.rev ?? 0
908 if (entry.rev < curRev) return // stale
909 if (entry.rev === curRev && cur && cur.by <= entry.by) return // tie: they lose
910 this.settingsMeta[key] = {rev: entry.rev, by: entry.by}
911 if (this.settings[key] !== entry.value) {
912 this.settings = {...this.settings}
913 this.settings[key] = entry.value
914 this.settingChanged(key)
915 }
916 this.rebuildSnapshot()
917 }
919 /** Side effects of a setting taking a new value (local or remote). */
920 private settingChanged(key: keyof RoomSettings) {
921 if (key === 'videoQuality') this.applyVideoParamsAll()
922 }
924 private videoParams(): VideoSendParams {
925 const p = QUALITY_PARAMS[this.settings.videoQuality]
926 const sharing = this.screenStream !== null
927 return {
928 maxBitrate: p.maxBitrate,
929 // Downscaled screen text is unreadable: while sharing, send full
930 // resolution and let the bitrate/framerate caps do the limiting.
931 scaleResolutionDownBy: sharing ? undefined : p.scaleResolutionDownBy,
932 maxFramerate: p.maxFramerate,
933 degradationPreference: sharing ? 'maintain-resolution' : undefined
934 }
935 }
937 private applyVideoParamsAll() {
938 for (const conn of this.conns.values()) this.applyVideoParamsTo(conn)
939 }
941 private applyVideoParamsTo(conn: Conn) {
942 void conn.peer.setVideoParameters(this.videoParams()).then(ok => {
943 if (!ok) {
944 // Right at 'connected' the encoding may not be negotiated yet.
945 window.setTimeout(
946 () => void conn.peer.setVideoParameters(this.videoParams()),
947 1500
948 )
949 }
950 })
951 }
953 // ---- mute -----------------------------------------------------------------
954 //
955 // Mute is per-participant state, not a shared setting: each participant owns
956 // its own flags and just notifies the others (the ordered channel makes
957 // last-sent win). Toggling track.enabled sends silence/black without
958 // renegotiation. Unmuting without a usable device retries getUserMedia and,
959 // on success, upgrades the placeholder track in place on every connection.
961 setAudioMuted(muted: boolean) {
962 if (this.phase !== 'room' || !this.localStream) return
963 if (this.audioMuted === muted) return
964 if (!muted && !this.micAvailable) {
965 void this.enableAudioWithRetry()
966 return
967 }
968 this.audioMuted = muted
969 for (const t of this.localStream.getAudioTracks()) t.enabled = !muted
970 this.broadcastMuteNotice()
971 this.rebuildSnapshot()
972 }
974 setVideoMuted(muted: boolean) {
975 if (this.phase !== 'room' || !this.localStream) return
976 if (this.videoMuted === muted) return
977 if (!muted && !this.camAvailable) {
978 void this.enableVideoWithRetry()
979 return
980 }
981 this.videoMuted = muted
982 for (const t of this.localStream.getVideoTracks()) t.enabled = !muted
983 this.broadcastMuteNotice()
984 this.rebuildSnapshot()
985 }
987 private async enableAudioWithRetry() {
988 const seq = this.joinSeq
989 let stream: MediaStream
990 try {
991 stream = await navigator.mediaDevices.getUserMedia({audio: true})
992 } catch (err) {
993 this.notice = mediaErrorMessage('microphone', err)
994 this.rebuildSnapshot()
995 return
996 }
997 const track = stream.getAudioTracks()[0]
998 if (!track || this.joinSeq !== seq || !this.localStream) {
999 for (const t of stream.getTracks()) t.stop()
1000 return
1002 const old = this.localStream.getAudioTracks()[0] ?? null
1003 for (const conn of this.conns.values()) {
1004 void conn.peer.replaceTrack('audio', track)
1006 if (old) {
1007 this.localStream.removeTrack(old)
1008 old.stop()
1010 this.localStream.addTrack(track)
1011 this.micAvailable = true
1012 this.audioMuted = false
1013 track.enabled = true
1014 this.broadcastMuteNotice()
1015 this.rebuildSnapshot()
1018 private async enableVideoWithRetry() {
1019 const seq = this.joinSeq
1020 let stream: MediaStream
1021 try {
1022 stream = await navigator.mediaDevices.getUserMedia({video: true})
1023 } catch (err) {
1024 this.notice = mediaErrorMessage('camera', err)
1025 this.rebuildSnapshot()
1026 return
1028 const track = stream.getVideoTracks()[0]
1029 if (!track || this.joinSeq !== seq || !this.localStream) {
1030 for (const t of stream.getTracks()) t.stop()
1031 return
1033 const old = this.localStream.getVideoTracks()[0] ?? null
1034 // While screen sharing, the connections carry the screen track; the new
1035 // camera track takes over when the share stops.
1036 if (!this.screenStream) {
1037 for (const conn of this.conns.values()) {
1038 void conn.peer.replaceTrack('video', track)
1041 if (old) {
1042 this.localStream.removeTrack(old)
1043 old.stop()
1045 this.localStream.addTrack(track)
1046 this.camAvailable = true
1047 this.videoMuted = false
1048 track.enabled = true
1049 this.broadcastMuteNotice()
1050 this.rebuildSnapshot()
1053 /** While screen sharing the outgoing video is the (always live) screen, so
1054 * a muted camera is latent until the share ends. */
1055 private effectiveVideoMuted(): boolean {
1056 return this.videoMuted && !this.screenStream
1059 private broadcastMuteNotice() {
1060 this.broadcastControl({
1061 t: 'mute',
1062 audio: this.audioMuted,
1063 video: this.effectiveVideoMuted()
1064 })
1067 // ---- screen share ---------------------------------------------------------
1069 /** Swap the outgoing camera track for a screen capture on EVERY connection.
1070 * Everyone sees the screen in place of the camera; no renegotiation. */
1071 async startScreenShare() {
1072 if (this.phase !== 'room' || this.screenStream) return
1073 const seq = this.joinSeq
1074 let stream: MediaStream
1075 try {
1076 stream = await navigator.mediaDevices.getDisplayMedia({video: true})
1077 } catch {
1078 return // user canceled the picker (or capture is unsupported)
1080 const track = stream.getVideoTracks()[0]
1081 if (!track || this.joinSeq !== seq) {
1082 for (const t of stream.getTracks()) t.stop()
1083 return
1085 this.screenStream = stream
1086 for (const conn of this.conns.values()) {
1087 void conn.peer.replaceTrack('video', track)
1089 this.applyVideoParamsAll() // re-derive caps for screen-share mode
1090 this.broadcastMuteNotice() // outgoing video is now the live screen
1091 // The browser's own "Stop sharing" bar ends the track; swap back then.
1092 track.onended = () => void this.stopScreenShare()
1093 this.rebuildSnapshot()
1096 async stopScreenShare() {
1097 if (!this.screenStream) return
1098 const screen = this.screenStream
1099 this.screenStream = null
1100 const camTrack = this.localStream?.getVideoTracks()[0]
1101 if (camTrack) {
1102 for (const conn of this.conns.values()) {
1103 void conn.peer.replaceTrack('video', camTrack)
1106 for (const t of screen.getTracks()) t.stop()
1107 if (this.phase === 'room') {
1108 this.applyVideoParamsAll() // restore camera-mode caps
1109 this.broadcastMuteNotice() // the camera, with its mute state, is back
1110 this.rebuildSnapshot()
1114 // ---- transcription ---------------------------------------------------------
1115 //
1116 // Whoever has a Deepgram key can transcribe the room from their own browser,
1117 // since a mesh call already delivers everyone's audio to everyone. The key
1118 // stays in that browser: unlike the relay token, there is nothing to share,
1119 // because the transcription is done by one participant on behalf of all.
1120 //
1121 // Two things are deliberately NOT done here. The transcript is not sent to
1122 // the other participants — a transcriber relaying text attributed to other
1123 // people is text those people cannot vouch for, which is the same objection
1124 // that keeps chat history from being replayed (see the chat section). And it
1125 // is not a room setting: nobody else can turn it on or off. What IS shared is
1126 // the fact that it is running, both as a badge on the tile and as a line in
1127 // the chat, because recording people without telling them is not acceptable.
1129 async startTranscription(apiKey: string) {
1130 if (this.phase !== 'room' || this.transcribing) return
1131 const key = apiKey.trim() || this.deepgramKey
1132 if (!key) return
1133 if (!isUsableApiKey(key)) {
1134 this.notice =
1135 'That Deepgram API key contains characters that cannot be sent in a browser connection — check for spaces or line breaks.'
1136 this.rebuildSnapshot()
1137 return
1139 if (key !== this.deepgramKey) {
1140 this.deepgramKey = key
1141 localStorage.setItem(DEEPGRAM_KEY, key)
1143 const store = this.transcript
1144 if (!store) return
1145 if (!this.transcriber || this.transcriber.apiKey !== key) {
1146 this.transcriber?.dispose()
1147 this.transcriber = new Transcriber(
1148 key,
1149 store,
1150 () => this.rebuildSnapshot(),
1151 (message, fatal) => this.transcriptionFailed(message, fatal)
1154 const tr = this.transcriber
1155 const seq = this.joinSeq
1156 const ok = await tr.start()
1157 if (this.joinSeq !== seq || this.transcriber !== tr) {
1158 tr.dispose()
1159 return
1161 if (!ok) {
1162 this.rebuildSnapshot()
1163 return
1165 this.transcribing = true
1166 this.broadcastControl({t: 'tx', on: true})
1167 this.pushSystem('You started transcribing this meeting')
1168 this.rebuildSnapshot()
1171 stopTranscription() {
1172 if (!this.transcribing) return
1173 this.transcribing = false
1174 this.transcriber?.stop()
1175 if (this.phase === 'room') {
1176 this.broadcastControl({t: 'tx', on: false})
1177 this.pushSystem('You stopped transcribing')
1179 this.rebuildSnapshot()
1182 /** Drop the stored key. The transcript already produced is kept. */
1183 forgetDeepgramKey() {
1184 this.stopTranscription()
1185 localStorage.removeItem(DEEPGRAM_KEY)
1186 this.deepgramKey = ''
1187 this.rebuildSnapshot()
1190 /** Discard this room's transcript, here and on disk. The panel confirms
1191 * first: unlike the chat, this is the one thing here that was being kept. */
1192 clearTranscript() {
1193 this.transcript?.clear()
1194 this.rebuildSnapshot()
1197 private transcriptionFailed(message: string, fatal: boolean) {
1198 this.notice = message
1199 // Unconditionally rebuild: stopTranscription is a no-op if we had already
1200 // stopped, and the notice still has to reach the screen.
1201 if (fatal) this.stopTranscription()
1202 this.rebuildSnapshot()
1205 /** Keep the transcriber's per-speaker pipelines in step with the room. This
1206 * runs on every snapshot; the transcriber ignores sources it already has. */
1207 private syncTranscriptionSources() {
1208 const tr = this.transcriber
1209 if (!tr?.active) return
1210 const sources: TranscriptSource[] = [
1211 {id: selfId, name: this.name ?? 'You', stream: this.localStream}
1213 for (const [peerId, conn] of this.conns) {
1214 if (!conn.stream) continue
1215 sources.push({
1216 id: peerId,
1217 name: this.presence.get(peerId)?.name ?? conn.name ?? peerId.slice(0, 8),
1218 stream: conn.stream
1219 })
1221 tr.setSources(sources)
1224 // ---- public API -------------------------------------------------------
1226 /** Change the room-wide video-quality preset. Anyone can change it; every
1227 * participant caps its own outgoing video, and the change syncs across. */
1228 setVideoQuality(quality: VideoQuality) {
1229 this.setSetting('videoQuality', quality)
1232 dismissNotice() {
1233 this.notice = null
1234 this.rebuildSnapshot()
1237 getSnapshot = (): Snapshot => this.snapshot
1239 subscribe = (listener: () => void): (() => void) => {
1240 this.listeners.add(listener)
1241 return () => this.listeners.delete(listener)
1244 private rebuildSnapshot() {
1245 this.syncTranscriptionSources()
1246 const ids = new Set<string>([...this.conns.keys(), ...this.presence.keys()])
1247 const participants: ParticipantInfo[] = [...ids]
1248 .map(peerId => {
1249 const conn = this.conns.get(peerId)
1250 return {
1251 peerId,
1252 name:
1253 this.presence.get(peerId)?.name ??
1254 conn?.name ??
1255 peerId.slice(0, 8),
1256 connected: conn?.connected ?? false,
1257 stream: conn?.stream ?? null,
1258 audioMuted: conn?.audioMuted ?? true,
1259 videoMuted: conn?.videoMuted ?? true,
1260 transcribing: conn?.transcribing ?? false
1262 })
1263 .sort(
1264 (a, b) =>
1265 a.name.localeCompare(b.name) || a.peerId.localeCompare(b.peerId)
1268 this.snapshot = {
1269 selfId,
1270 phase: this.phase,
1271 roomId: this.roomId,
1272 name: this.name,
1273 participants,
1274 audioMuted: this.audioMuted,
1275 videoMuted: this.videoMuted,
1276 micAvailable: this.micAvailable,
1277 camAvailable: this.camAvailable,
1278 localStream: this.localStream,
1279 screenStream: this.screenStream,
1280 settings: this.settings,
1281 chat: this.chat,
1282 relay: this.relayStatus(),
1283 transcribing: this.transcribing,
1284 transcript: this.transcript?.items ?? [],
1285 transcriptSeconds: this.transcript?.audioSeconds ?? 0,
1286 hasDeepgramKey: this.deepgramKey.length > 0,
1287 notice: this.notice
1289 for (const l of this.listeners) l()
1293/** A human-readable reason for a getUserMedia failure. The error name is
1294 * included so the real cause is visible — "permission denied" and "another
1295 * app is holding the camera" need entirely different fixes. */
1296const mediaErrorMessage = (what: string, err: unknown): string => {
1297 const rawName = (err as {name?: unknown} | null)?.name
1298 const name = typeof rawName === 'string' ? rawName : ''
1299 switch (name) {
1300 case 'NotAllowedError':
1301 case 'SecurityError':
1302 return `Access to your ${what} was blocked — check this site's permissions in your browser.`
1303 case 'NotFoundError':
1304 case 'OverconstrainedError':
1305 return `No ${what} was found on this device.`
1306 case 'NotReadableError':
1307 case 'AbortError':
1308 return `Your ${what} could not be started — it may be in use by another app or browser (${name}).`
1309 default:
1310 return `Could not access your ${what}${name ? ` (${name})` : ''}.`
1314/** A tiny black video track, used as a placeholder when there is no camera. */
1315const blackVideoTrack = (): MediaStreamTrack => {
1316 const canvas = document.createElement('canvas')
1317 canvas.width = 320
1318 canvas.height = 240
1319 canvas.getContext('2d')?.fillRect(0, 0, canvas.width, canvas.height)
1320 return canvas.captureStream(2).getVideoTracks()[0]
moveopenescclose