/ tools / commonroom
Sign in
tools / commonroom
commonroom / src / p2p / network.ts
983 lines · 31.9 KBCodeBlameHistory
9813683Serverless group video calls: rooms, WebRTC mesh, shared settingsJeremy Magland 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'
12// ---------------------------------------------------------------------------
13// CommonRoom network layer: a full-mesh group video call.
14//
15// Rooms: the room ID is any string (no spaces); it is hashed into a nostr
16// topic, so there is no room registry anywhere — knowing the name IS the key.
17//
18// Presence: everyone in the room announces {peerId, name} on the room topic
19// every few seconds; entries expire when announcements stop.
20//
21// Mesh: unlike commoncall (mutual consent, one call at a time), being in the
22// room IS the consent — every participant automatically brings up a WebRTC
23// connection with every other participant (commonview's approach, but carrying
24// media). Camera/mic are requested on entry, but both start MUTED; if access
25// is denied you still join, sending synthetic silent/black placeholder tracks,
26// and unmuting retries the device and upgrades the tracks in place.
27//
28// Everything else (deterministic initiator = smaller peer ID, per-peer nostr
29// signaling topics, control data channel, track-swap screen share, quality
30// caps via setParameters) is the commoncall design, applied per-peer.
31// ---------------------------------------------------------------------------
33/** Soft cap: peers at capacity turn newcomers away with {t:'room-full'}. */
34export const MAX_PARTICIPANTS = 8
36interface Announcement {
37 peerId: string
38 name: string
41// Messages on per-peer nostr topics (pre-connection).
42type PeerMsg = {t: 'signal'; signal: Signal} | {t: 'room-full'}
44// Messages on the per-peer control data channels (WebRTC, not nostr).
45interface SettingEntry {
46 key: string
47 value: unknown
48 rev: number
49 by: string
51type ControlMsg =
52 | {
53 t: 'hello'
54 name: string
55 audioMuted: boolean
56 videoMuted: boolean
1ebfe6fAdd spotlight view, room chat, and clearer media-failure messagesJeremy Magland 57 /** Self-reported epoch ms of when they entered the room, so receivers
58 * can tell "was already here when I arrived" from "joined after me"
59 * for the chat's join lines. */
60 joinedAt: number
9813683Serverless group video calls: rooms, WebRTC mesh, shared settingsJeremy Magland 61 settings: SettingEntry[]
62 }
63 | ({t: 'set'} & SettingEntry)
64 | {t: 'mute'; audio: boolean; video: boolean}
1ebfe6fAdd spotlight view, room chat, and clearer media-failure messagesJeremy Magland 65 | {t: 'chat'; text: string}
68const ANNOUNCE_INTERVAL_MS = 5000
69const PRESENCE_TTL_MS = 15000
70// A connection attempt that hasn't opened after this long is torn down and
71// retried on the peer's next announcement. Signaling events are ephemeral, so
72// an offer published before the other side was listening is simply lost —
73// without a retry the pair would deadlock forever.
74const CONNECT_RETRY_MS = 15000
76const NAME_KEY = 'commonroom:name'
1ebfe6fAdd spotlight view, room chat, and clearer media-failure messagesJeremy Magland 78const CHAT_MAX_LENGTH = 2000
79const CHAT_LOG_CAP = 500
9813683Serverless group video calls: rooms, WebRTC mesh, shared settingsJeremy Magland 81export type Phase = 'landing' | 'joining' | 'room'
83interface Conn {
84 peer: Peer
85 /** When this connection attempt started (local clock), for retry pacing. */
86 createdAt: number
87 /** Name from the hello message (presence announcements may lag behind). */
88 name: string | null
89 connected: boolean
90 stream: MediaStream | null
91 /** Their reported effective outgoing mute state (muted until told otherwise
92 * — everyone starts muted). */
93 audioMuted: boolean
94 videoMuted: boolean
1ebfe6fAdd spotlight view, room chat, and clearer media-failure messagesJeremy Magland 97export interface ChatItem {
98 /** Monotonic per-session sequence number; stable React key. */
99 seq: number
100 kind: 'chat' | 'system'
101 /** The author's peer ID; null for system lines. */
102 peerId: string | null
103 name: string
104 text: string
105 /** Local arrival time (epoch ms). */
106 time: number
9813683Serverless group video calls: rooms, WebRTC mesh, shared settingsJeremy Magland 109export interface ParticipantInfo {
110 peerId: string
111 name: string
112 connected: boolean
113 stream: MediaStream | null
114 audioMuted: boolean
115 videoMuted: boolean
118export interface Snapshot {
119 selfId: string
120 phase: Phase
121 roomId: string | null
122 name: string | null
123 /** Everyone else in the room (connected or still connecting). */
124 participants: ParticipantInfo[]
125 audioMuted: boolean
126 videoMuted: boolean
127 micAvailable: boolean
128 camAvailable: boolean
129 localStream: MediaStream | null
130 screenStream: MediaStream | null
131 settings: RoomSettings
136export class Network {
137 private nostr = new Nostr()
138 private phase: Phase = 'landing'
139 private roomId: string | null = null
140 private root = ''
141 private name: string | null = null
142 private presence = new Map<string, {name: string; lastSeen: number}>()
143 private conns = new Map<string, Conn>()
144 private unsubs: (() => void)[] = []
145 private announceTimer: number | null = null
146 private sweepTimer: number | null = null
147 /** Bumped on every join/leave so stale async work can detect it's obsolete. */
148 private joinSeq = 0
150 private localStream: MediaStream | null = null
151 private screenStream: MediaStream | null = null
152 private micAvailable = false
153 private camAvailable = false
154 private audioMuted = true
155 private videoMuted = true
156 private audioCtx: AudioContext | null = null
158 private settings: RoomSettings = {...DEFAULT_SETTINGS}
159 /** Per-key revision + setter for the last-writer-wins settings sync. */
160 private settingsMeta: Partial<
161 Record<keyof RoomSettings, {rev: number; by: string}>
162 > = {}
1ebfe6fAdd spotlight view, room chat, and clearer media-failure messagesJeremy Magland 164 // Chat is ephemeral: you only see what's said while you're in the room.
165 // Messages arrive directly from their author over the authenticated
166 // channel, so there's no relaying and nothing to forge.
167 private chat: ChatItem[] = []
168 private chatSeq = 0
169 /** When WE entered the room (epoch ms), reported in our hello. */
170 private joinedAtMs = 0
171 /** peerId -> name for peers currently counted present in the chat. */
172 private chatPresent = new Map<string, string>()
173 /** Peers we've ever logged a join/left line for (so a reconnect after a
174 * network blip gets a "joined" line to match its "left" line). */
175 private chatSeen = new Set<string>()
9813683Serverless group video calls: rooms, WebRTC mesh, shared settingsJeremy Magland 177 private notice: string | null = null
179 private snapshot!: Snapshot
180 private listeners = new Set<() => void>()
182 /** Last name used on this browser, for prefilling the join form. */
183 readonly savedName: string = localStorage.getItem(NAME_KEY) ?? ''
185 constructor() {
186 this.rebuildSnapshot()
187 window.addEventListener('online', () => void this.announce())
188 // Best-effort goodbye so tiles vanish immediately instead of after the
189 // presence TTL when a tab closes.
190 window.addEventListener('pagehide', () => {
191 if (this.phase === 'room') this.broadcastControl({t: 'bye'})
192 })
193 }
195 // ---- joining and leaving ----------------------------------------------
197 async enterRoom(name: string, room: string) {
198 if (this.phase !== 'landing') return
199 const nm = name.trim().slice(0, 40)
200 const rm = room.replace(/\s+/g, '').slice(0, 100)
201 if (!nm || !rm) return
202 this.name = nm
203 this.roomId = rm
204 localStorage.setItem(NAME_KEY, nm)
205 // Put the room in the URL so the address bar is the invite link.
206 try {
207 location.hash = encodeURIComponent(rm)
208 } catch {
209 /* ignore */
210 }
211 this.notice = null
212 this.phase = 'joining'
213 this.rebuildSnapshot()
214 const seq = ++this.joinSeq
216 const media = await this.acquireMedia()
217 if (this.joinSeq !== seq) {
218 for (const t of media.stream.getTracks()) t.stop()
219 return
220 }
221 this.localStream = media.stream
222 this.micAvailable = media.mic
223 this.camAvailable = media.cam
1ebfe6fAdd spotlight view, room chat, and clearer media-failure messagesJeremy Magland 224 // Tell the user right away when a device didn't come up (and why), so
225 // they aren't surprised at unmute time.
226 if (!media.mic && !media.cam) {
227 this.notice = `${mediaErrorMessage('camera or microphone', media.camError)} You've still joined — the mic/camera buttons will retry.`
228 } else if (!media.cam) {
229 this.notice = `${mediaErrorMessage('camera', media.camError)} The camera button will retry.`
230 } else if (!media.mic) {
231 this.notice = `${mediaErrorMessage('microphone', media.micError)} The mic button will retry.`
232 }
9813683Serverless group video calls: rooms, WebRTC mesh, shared settingsJeremy Magland 233 // Everyone enters muted.
234 this.audioMuted = true
235 this.videoMuted = true
236 for (const t of media.stream.getTracks()) t.enabled = false
238 this.root = await roomTopic(rm)
239 if (this.joinSeq !== seq) return
240 const selfTopic = await peerTopic(this.root, selfId)
241 if (this.joinSeq !== seq) return
243 // WebRTC signaling (and room-full notices) addressed to us.
244 this.unsubs.push(
245 this.nostr.subscribe(selfTopic, (content, from) => {
246 if (from === selfId) return
247 let msg: PeerMsg
248 try {
249 msg = JSON.parse(content)
250 } catch {
251 return
252 }
253 this.handlePeerMsg(from, msg)
254 })
255 )
257 // Presence announcements on the room topic.
258 this.unsubs.push(
259 this.nostr.subscribe(this.root, (content, from) => {
260 if (from === selfId) return
261 let ann: Partial<Announcement>
262 try {
263 ann = JSON.parse(content)
264 } catch {
265 return
266 }
267 if (ann.peerId !== from || typeof ann.name !== 'string') return
268 const prev = this.presence.get(from)
269 const annName = ann.name.slice(0, 40)
270 this.presence.set(from, {name: annName, lastSeen: Date.now()})
271 if (!prev || prev.name !== annName) this.rebuildSnapshot()
272 this.maybeConnect(from)
273 })
274 )
276 this.phase = 'room'
1ebfe6fAdd spotlight view, room chat, and clearer media-failure messagesJeremy Magland 277 this.joinedAtMs = Date.now()
278 this.pushSystem('You joined')
280 this.announceTimer = window.setInterval(
281 () => void this.announce(),
282 ANNOUNCE_INTERVAL_MS
283 )
284 this.sweepTimer = window.setInterval(
285 () => this.sweepPresence(),
286 ANNOUNCE_INTERVAL_MS
287 )
288 this.rebuildSnapshot()
289 }
291 leave() {
292 if (this.phase === 'landing') return
293 this.teardown()
294 this.notice = null
295 this.rebuildSnapshot()
296 }
298 private teardown() {
299 this.joinSeq++
300 this.broadcastControl({t: 'bye'})
301 const conns = [...this.conns.values()]
302 this.conns.clear() // cleared first so close handlers no-op
303 for (const c of conns) c.peer.destroy()
304 this.presence.clear()
305 for (const u of this.unsubs.splice(0)) u()
306 if (this.announceTimer !== null) clearInterval(this.announceTimer)
307 if (this.sweepTimer !== null) clearInterval(this.sweepTimer)
308 this.announceTimer = null
309 this.sweepTimer = null
310 if (this.screenStream) {
311 for (const t of this.screenStream.getTracks()) t.stop()
312 this.screenStream = null
313 }
314 if (this.localStream) {
315 for (const t of this.localStream.getTracks()) t.stop()
316 this.localStream = null
317 }
318 if (this.audioCtx) {
319 void this.audioCtx.close().catch(() => undefined)
320 this.audioCtx = null
321 }
322 this.micAvailable = false
323 this.camAvailable = false
324 this.audioMuted = true
325 this.videoMuted = true
326 this.settings = {...DEFAULT_SETTINGS}
327 this.settingsMeta = {}
329 this.chatPresent.clear()
330 this.chatSeen.clear()
332 this.roomId = null
333 this.phase = 'landing'
334 }
336 // ---- local media -------------------------------------------------------
337 //
338 // Every participant always carries exactly one audio and one video track so
339 // the WebRTC offer/answer is symmetric for everyone. If a device is missing
340 // or permission is denied, a synthetic placeholder (silent audio / black
341 // video) stands in; unmuting later retries getUserMedia and upgrades the
342 // placeholder via replaceTrack on every connection — no renegotiation.
344 private async acquireMedia(): Promise<{
345 stream: MediaStream
346 mic: boolean
347 cam: boolean
349 camError: unknown
351 try {
352 const s = await navigator.mediaDevices.getUserMedia({
353 audio: true,
354 video: true
355 })
1ebfe6fAdd spotlight view, room chat, and clearer media-failure messagesJeremy Magland 356 return {stream: s, mic: true, cam: true, micError: null, camError: null}
1ebfe6fAdd spotlight view, room chat, and clearer media-failure messagesJeremy Magland 358 // The combined request fails as a whole if EITHER device is unusable
359 // (in Firefox, e.g., a camera held by another app fails it even though
360 // the mic is fine) — retry each kind on its own to keep what works.
362 let audio: MediaStreamTrack | null = null
363 let video: MediaStreamTrack | null = null
1ebfe6fAdd spotlight view, room chat, and clearer media-failure messagesJeremy Magland 364 let micError: unknown = null
365 let camError: unknown = null
367 const s = await navigator.mediaDevices.getUserMedia({audio: true})
368 audio = s.getAudioTracks()[0] ?? null
370 micError = err
372 try {
373 const s = await navigator.mediaDevices.getUserMedia({video: true})
374 video = s.getVideoTracks()[0] ?? null
376 camError = err
378 const stream = new MediaStream()
379 stream.addTrack(audio ?? this.silentAudioTrack())
380 stream.addTrack(video ?? blackVideoTrack())
1ebfe6fAdd spotlight view, room chat, and clearer media-failure messagesJeremy Magland 381 return {stream, mic: audio !== null, cam: video !== null, micError, camError}
384 private silentAudioTrack(): MediaStreamTrack {
385 if (!this.audioCtx) this.audioCtx = new AudioContext()
386 const dst = this.audioCtx.createMediaStreamDestination()
387 return dst.stream.getAudioTracks()[0]
388 }
390 /** The tracks we send to a (new) peer: mic audio plus screen or camera. */
391 private outgoingStream(): MediaStream {
392 const s = new MediaStream()
393 const audio = this.localStream?.getAudioTracks()[0]
394 if (audio) s.addTrack(audio)
395 const video =
396 this.screenStream?.getVideoTracks()[0] ??
397 this.localStream?.getVideoTracks()[0]
398 if (video) s.addTrack(video)
399 return s
400 }
402 // ---- presence and the mesh ----------------------------------------------
404 private async announce() {
405 if (this.phase !== 'room' || !this.name || !this.root) return
406 const ann: Announcement = {peerId: selfId, name: this.name}
407 void this.nostr.publish(this.root, JSON.stringify(ann))
408 }
410 private sweepPresence() {
411 const cutoff = Date.now() - PRESENCE_TTL_MS
412 let changed = false
413 for (const [peerId, p] of this.presence) {
414 if (p.lastSeen < cutoff) {
415 this.presence.delete(peerId)
416 changed = true
417 }
418 }
419 if (changed) this.rebuildSnapshot()
420 }
422 private async sendToPeer(peerId: string, msg: PeerMsg) {
423 if (!this.root) return
424 const topic = await peerTopic(this.root, peerId)
425 void this.nostr.publish(topic, JSON.stringify(msg))
426 }
428 private atCapacity(): boolean {
429 return this.conns.size >= MAX_PARTICIPANTS - 1
430 }
432 private maybeConnect(peerId: string) {
433 if (this.phase !== 'room' || !this.localStream || peerId === selfId) return
434 const existing = this.conns.get(peerId)
435 if (existing) {
436 const stalled =
437 !existing.connected &&
438 Date.now() - existing.createdAt > CONNECT_RETRY_MS
439 if (!stalled) return
440 this.conns.delete(peerId) // deleted first so the close handler no-ops
441 existing.peer.destroy()
442 }
443 if (this.atCapacity()) {
444 // The room is full from our point of view: turn the newcomer away.
445 void this.sendToPeer(peerId, {t: 'room-full'})
446 return
447 }
448 // Deterministic initiator: the peer with the smaller ID makes the offer.
449 this.createPeer(peerId, selfId < peerId)
450 }
452 private createPeer(peerId: string, initiator: boolean): Conn {
453 const peer = new Peer(initiator, this.outgoingStream())
454 const conn: Conn = {
455 peer,
456 createdAt: Date.now(),
457 name: null,
458 connected: false,
459 stream: null,
460 audioMuted: true,
461 videoMuted: true
462 }
463 this.conns.set(peerId, conn)
465 peer.setHandlers({
466 signal: signal => {
467 void this.sendToPeer(peerId, {t: 'signal', signal})
468 },
469 track: stream => {
470 conn.stream = stream
471 this.rebuildSnapshot()
472 },
473 connect: () => {
474 conn.connected = true
475 this.sendHello(conn)
476 this.applyVideoParamsTo(conn)
477 this.rebuildSnapshot()
478 },
479 data: raw => this.handleControl(peerId, conn, raw),
480 close: () => {
481 if (this.conns.get(peerId) === conn) {
482 this.conns.delete(peerId)
1ebfe6fAdd spotlight view, room chat, and clearer media-failure messagesJeremy Magland 483 const chatName = this.chatPresent.get(peerId)
484 if (chatName !== undefined) {
485 this.chatPresent.delete(peerId)
486 this.pushSystem(`${chatName} left`)
487 }
9813683Serverless group video calls: rooms, WebRTC mesh, shared settingsJeremy Magland 488 this.rebuildSnapshot()
489 }
490 }
491 })
493 this.rebuildSnapshot()
494 return conn
495 }
497 private handlePeerMsg(from: string, msg: PeerMsg) {
498 if (this.phase !== 'room') return
499 switch (msg.t) {
500 case 'signal': {
501 let conn = this.conns.get(from)
502 if (!conn) {
503 // An offer can arrive before we've seen the peer's announcement.
504 if (msg.signal?.type !== 'offer') return
505 if (this.atCapacity()) {
506 void this.sendToPeer(from, {t: 'room-full'})
507 return
508 }
509 conn = this.createPeer(from, false)
510 }
511 void conn.peer.signal(msg.signal)
512 return
513 }
514 case 'room-full': {
515 // Only honor this while we haven't gotten a foothold in the room —
516 // once we have any connection, we're in.
517 if (this.conns.size === 0) {
518 this.teardown()
519 this.notice = `That room is full — up to ${MAX_PARTICIPANTS} people can be in a room.`
520 this.rebuildSnapshot()
521 }
522 return
523 }
524 }
525 }
527 // ---- control channel ----------------------------------------------------
529 private broadcastControl(msg: ControlMsg) {
530 const payload = JSON.stringify(msg)
531 for (const conn of this.conns.values()) conn.peer.send(payload)
532 }
534 private sendHello(conn: Conn) {
535 const settings: SettingEntry[] = []
536 for (const [key, meta] of Object.entries(this.settingsMeta)) {
537 settings.push({
538 key,
539 value: this.settings[key as keyof RoomSettings],
540 rev: meta.rev,
541 by: meta.by
542 })
543 }
544 conn.peer.send(
545 JSON.stringify({
546 t: 'hello',
547 name: this.name ?? '',
548 audioMuted: this.audioMuted,
549 videoMuted: this.effectiveVideoMuted(),
1ebfe6fAdd spotlight view, room chat, and clearer media-failure messagesJeremy Magland 550 joinedAt: this.joinedAtMs,
552 } satisfies ControlMsg)
553 )
554 }
556 private handleControl(peerId: string, conn: Conn, raw: string) {
557 if (this.conns.get(peerId) !== conn) return
558 let msg: ControlMsg
559 try {
560 msg = JSON.parse(raw)
561 } catch {
562 return
563 }
564 switch (msg.t) {
565 case 'hello': {
566 if (typeof msg.name === 'string') conn.name = msg.name.slice(0, 40)
567 conn.audioMuted = msg.audioMuted !== false
568 conn.videoMuted = msg.videoMuted !== false
569 if (Array.isArray(msg.settings)) {
570 for (const entry of msg.settings) this.applyRemoteSetting(entry)
571 }
1ebfe6fAdd spotlight view, room chat, and clearer media-failure messagesJeremy Magland 572 if (!this.chatPresent.has(peerId)) {
573 const name =
574 this.presence.get(peerId)?.name ??
575 conn.name ??
576 peerId.slice(0, 8)
577 // No join line for people who were already here when we arrived
578 // (their self-reported join predates ours) — unless we've logged a
579 // "left" for them before, in which case this is a return.
580 const joinedAt = typeof msg.joinedAt === 'number' ? msg.joinedAt : 0
581 const preexisting =
582 joinedAt <= this.joinedAtMs && !this.chatSeen.has(peerId)
583 this.chatPresent.set(peerId, name)
584 this.chatSeen.add(peerId)
585 if (!preexisting) this.pushSystem(`${name} joined`)
586 }
9813683Serverless group video calls: rooms, WebRTC mesh, shared settingsJeremy Magland 587 this.rebuildSnapshot()
588 return
589 }
590 case 'set': {
591 this.applyRemoteSetting(msg)
592 return
593 }
594 case 'mute': {
595 if (typeof msg.audio !== 'boolean' || typeof msg.video !== 'boolean') {
596 return
597 }
598 conn.audioMuted = msg.audio
599 conn.videoMuted = msg.video
600 this.rebuildSnapshot()
601 return
602 }
604 if (typeof msg.text !== 'string') return
605 const text = msg.text.slice(0, CHAT_MAX_LENGTH)
606 if (!text.trim()) return
607 this.pushChatItem({
608 kind: 'chat',
609 peerId,
610 name:
611 this.presence.get(peerId)?.name ?? conn.name ?? peerId.slice(0, 8),
612 text
613 })
614 this.rebuildSnapshot()
615 return
616 }
618 this.presence.delete(peerId)
619 conn.peer.destroy() // its close handler removes it and rebuilds
620 return
621 }
622 }
623 }
1ebfe6fAdd spotlight view, room chat, and clearer media-failure messagesJeremy Magland 625 // ---- chat ------------------------------------------------------------------
627 private pushChatItem(item: Omit<ChatItem, 'seq' | 'time'>) {
628 this.chat.push({...item, seq: this.chatSeq++, time: Date.now()})
629 if (this.chat.length > CHAT_LOG_CAP) {
630 this.chat.splice(0, this.chat.length - CHAT_LOG_CAP)
631 }
632 }
634 private pushSystem(text: string) {
635 this.pushChatItem({kind: 'system', peerId: null, name: '', text})
636 }
638 /** Send a chat message to everyone in the room (and our own log). */
639 sendChat(text: string) {
640 if (this.phase !== 'room') return
641 const trimmed = text.trim().slice(0, CHAT_MAX_LENGTH)
642 if (!trimmed) return
643 this.broadcastControl({t: 'chat', text: trimmed})
644 this.pushChatItem({
645 kind: 'chat',
646 peerId: selfId,
647 name: this.name ?? '',
648 text: trimmed
649 })
650 this.rebuildSnapshot()
651 }
9813683Serverless group video calls: rooms, WebRTC mesh, shared settingsJeremy Magland 653 // ---- shared room settings ------------------------------------------------
654 //
655 // ONE settings object for the whole room, editable by anyone. Sync is
656 // per-key last-writer-wins: every change bumps that key's revision and is
657 // broadcast as {t:'set'} to every peer (the mesh is a complete graph, so no
658 // relaying is needed). Late joiners receive the current entries in each
659 // hello. Concurrent changes at the same revision must resolve identically
660 // everywhere, so the SETTER with the smaller peer ID wins the tie.
662 private setSetting<K extends keyof RoomSettings>(
663 key: K,
664 value: RoomSettings[K]
665 ) {
666 if (this.phase !== 'room' || this.settings[key] === value) return
667 const rev = (this.settingsMeta[key]?.rev ?? 0) + 1
668 this.settingsMeta[key] = {rev, by: selfId}
669 this.settings = {...this.settings}
670 this.settings[key] = value
671 this.broadcastControl({t: 'set', key, value, rev, by: selfId})
672 this.settingChanged(key)
673 this.rebuildSnapshot()
674 }
676 private applyRemoteSetting(entry: SettingEntry) {
677 if (typeof entry !== 'object' || entry === null) return
678 if (typeof entry.key !== 'string' || !(entry.key in SETTING_VALIDATORS)) {
679 return
680 }
681 const key = entry.key as keyof RoomSettings
682 if (!SETTING_VALIDATORS[key](entry.value)) return
683 if (!Number.isInteger(entry.rev) || entry.rev < 1) return
684 if (typeof entry.by !== 'string' || entry.by.length !== 64) return
685 const cur = this.settingsMeta[key]
686 const curRev = cur?.rev ?? 0
687 if (entry.rev < curRev) return // stale
688 if (entry.rev === curRev && cur && cur.by <= entry.by) return // tie: they lose
689 this.settingsMeta[key] = {rev: entry.rev, by: entry.by}
690 if (this.settings[key] !== entry.value) {
691 this.settings = {...this.settings}
692 this.settings[key] = entry.value
693 this.settingChanged(key)
694 }
695 this.rebuildSnapshot()
696 }
698 /** Side effects of a setting taking a new value (local or remote). */
699 private settingChanged(key: keyof RoomSettings) {
700 if (key === 'videoQuality') this.applyVideoParamsAll()
701 }
703 private videoParams(): VideoSendParams {
704 const p = QUALITY_PARAMS[this.settings.videoQuality]
705 const sharing = this.screenStream !== null
706 return {
707 maxBitrate: p.maxBitrate,
708 // Downscaled screen text is unreadable: while sharing, send full
709 // resolution and let the bitrate/framerate caps do the limiting.
710 scaleResolutionDownBy: sharing ? undefined : p.scaleResolutionDownBy,
711 maxFramerate: p.maxFramerate,
712 degradationPreference: sharing ? 'maintain-resolution' : undefined
713 }
714 }
716 private applyVideoParamsAll() {
717 for (const conn of this.conns.values()) this.applyVideoParamsTo(conn)
718 }
720 private applyVideoParamsTo(conn: Conn) {
721 void conn.peer.setVideoParameters(this.videoParams()).then(ok => {
722 if (!ok) {
723 // Right at 'connected' the encoding may not be negotiated yet.
724 window.setTimeout(
725 () => void conn.peer.setVideoParameters(this.videoParams()),
726 1500
727 )
728 }
729 })
730 }
732 // ---- mute -----------------------------------------------------------------
733 //
734 // Mute is per-participant state, not a shared setting: each participant owns
735 // its own flags and just notifies the others (the ordered channel makes
736 // last-sent win). Toggling track.enabled sends silence/black without
737 // renegotiation. Unmuting without a usable device retries getUserMedia and,
738 // on success, upgrades the placeholder track in place on every connection.
740 setAudioMuted(muted: boolean) {
741 if (this.phase !== 'room' || !this.localStream) return
742 if (this.audioMuted === muted) return
743 if (!muted && !this.micAvailable) {
744 void this.enableAudioWithRetry()
745 return
746 }
747 this.audioMuted = muted
748 for (const t of this.localStream.getAudioTracks()) t.enabled = !muted
749 this.broadcastMuteNotice()
750 this.rebuildSnapshot()
751 }
753 setVideoMuted(muted: boolean) {
754 if (this.phase !== 'room' || !this.localStream) return
755 if (this.videoMuted === muted) return
756 if (!muted && !this.camAvailable) {
757 void this.enableVideoWithRetry()
758 return
759 }
760 this.videoMuted = muted
761 for (const t of this.localStream.getVideoTracks()) t.enabled = !muted
762 this.broadcastMuteNotice()
763 this.rebuildSnapshot()
764 }
766 private async enableAudioWithRetry() {
767 const seq = this.joinSeq
768 let stream: MediaStream
769 try {
770 stream = await navigator.mediaDevices.getUserMedia({audio: true})
772 this.notice = mediaErrorMessage('microphone', err)
9813683Serverless group video calls: rooms, WebRTC mesh, shared settingsJeremy Magland 773 this.rebuildSnapshot()
774 return
775 }
776 const track = stream.getAudioTracks()[0]
777 if (!track || this.joinSeq !== seq || !this.localStream) {
778 for (const t of stream.getTracks()) t.stop()
779 return
780 }
781 const old = this.localStream.getAudioTracks()[0] ?? null
782 for (const conn of this.conns.values()) {
783 void conn.peer.replaceTrack('audio', track)
784 }
785 if (old) {
786 this.localStream.removeTrack(old)
787 old.stop()
788 }
789 this.localStream.addTrack(track)
790 this.micAvailable = true
791 this.audioMuted = false
792 track.enabled = true
793 this.broadcastMuteNotice()
794 this.rebuildSnapshot()
795 }
797 private async enableVideoWithRetry() {
798 const seq = this.joinSeq
799 let stream: MediaStream
800 try {
801 stream = await navigator.mediaDevices.getUserMedia({video: true})
803 this.notice = mediaErrorMessage('camera', err)
9813683Serverless group video calls: rooms, WebRTC mesh, shared settingsJeremy Magland 804 this.rebuildSnapshot()
805 return
806 }
807 const track = stream.getVideoTracks()[0]
808 if (!track || this.joinSeq !== seq || !this.localStream) {
809 for (const t of stream.getTracks()) t.stop()
810 return
811 }
812 const old = this.localStream.getVideoTracks()[0] ?? null
813 // While screen sharing, the connections carry the screen track; the new
814 // camera track takes over when the share stops.
815 if (!this.screenStream) {
816 for (const conn of this.conns.values()) {
817 void conn.peer.replaceTrack('video', track)
818 }
819 }
820 if (old) {
821 this.localStream.removeTrack(old)
822 old.stop()
823 }
824 this.localStream.addTrack(track)
825 this.camAvailable = true
826 this.videoMuted = false
827 track.enabled = true
828 this.broadcastMuteNotice()
829 this.rebuildSnapshot()
830 }
832 /** While screen sharing the outgoing video is the (always live) screen, so
833 * a muted camera is latent until the share ends. */
834 private effectiveVideoMuted(): boolean {
835 return this.videoMuted && !this.screenStream
836 }
838 private broadcastMuteNotice() {
839 this.broadcastControl({
840 t: 'mute',
841 audio: this.audioMuted,
842 video: this.effectiveVideoMuted()
843 })
844 }
846 // ---- screen share ---------------------------------------------------------
848 /** Swap the outgoing camera track for a screen capture on EVERY connection.
849 * Everyone sees the screen in place of the camera; no renegotiation. */
850 async startScreenShare() {
851 if (this.phase !== 'room' || this.screenStream) return
852 const seq = this.joinSeq
853 let stream: MediaStream
854 try {
855 stream = await navigator.mediaDevices.getDisplayMedia({video: true})
856 } catch {
857 return // user canceled the picker (or capture is unsupported)
858 }
859 const track = stream.getVideoTracks()[0]
860 if (!track || this.joinSeq !== seq) {
861 for (const t of stream.getTracks()) t.stop()
862 return
863 }
864 this.screenStream = stream
865 for (const conn of this.conns.values()) {
866 void conn.peer.replaceTrack('video', track)
867 }
868 this.applyVideoParamsAll() // re-derive caps for screen-share mode
869 this.broadcastMuteNotice() // outgoing video is now the live screen
870 // The browser's own "Stop sharing" bar ends the track; swap back then.
871 track.onended = () => void this.stopScreenShare()
872 this.rebuildSnapshot()
873 }
875 async stopScreenShare() {
876 if (!this.screenStream) return
877 const screen = this.screenStream
878 this.screenStream = null
879 const camTrack = this.localStream?.getVideoTracks()[0]
880 if (camTrack) {
881 for (const conn of this.conns.values()) {
882 void conn.peer.replaceTrack('video', camTrack)
883 }
884 }
885 for (const t of screen.getTracks()) t.stop()
886 if (this.phase === 'room') {
887 this.applyVideoParamsAll() // restore camera-mode caps
888 this.broadcastMuteNotice() // the camera, with its mute state, is back
889 this.rebuildSnapshot()
890 }
891 }
893 // ---- public API -------------------------------------------------------
895 /** Change the room-wide video-quality preset. Anyone can change it; every
896 * participant caps its own outgoing video, and the change syncs across. */
897 setVideoQuality(quality: VideoQuality) {
898 this.setSetting('videoQuality', quality)
899 }
901 dismissNotice() {
902 this.notice = null
903 this.rebuildSnapshot()
904 }
906 getSnapshot = (): Snapshot => this.snapshot
908 subscribe = (listener: () => void): (() => void) => {
909 this.listeners.add(listener)
910 return () => this.listeners.delete(listener)
911 }
913 private rebuildSnapshot() {
914 const ids = new Set<string>([...this.conns.keys(), ...this.presence.keys()])
915 const participants: ParticipantInfo[] = [...ids]
916 .map(peerId => {
917 const conn = this.conns.get(peerId)
918 return {
919 peerId,
920 name:
921 this.presence.get(peerId)?.name ??
922 conn?.name ??
923 peerId.slice(0, 8),
924 connected: conn?.connected ?? false,
925 stream: conn?.stream ?? null,
926 audioMuted: conn?.audioMuted ?? true,
927 videoMuted: conn?.videoMuted ?? true
928 }
929 })
930 .sort(
931 (a, b) =>
932 a.name.localeCompare(b.name) || a.peerId.localeCompare(b.peerId)
933 )
935 this.snapshot = {
936 selfId,
937 phase: this.phase,
938 roomId: this.roomId,
939 name: this.name,
940 participants,
941 audioMuted: this.audioMuted,
942 videoMuted: this.videoMuted,
943 micAvailable: this.micAvailable,
944 camAvailable: this.camAvailable,
945 localStream: this.localStream,
946 screenStream: this.screenStream,
947 settings: this.settings,
950 }
951 for (const l of this.listeners) l()
952 }
1ebfe6fAdd spotlight view, room chat, and clearer media-failure messagesJeremy Magland 955/** A human-readable reason for a getUserMedia failure. The error name is
956 * included so the real cause is visible — "permission denied" and "another
957 * app is holding the camera" need entirely different fixes. */
958const mediaErrorMessage = (what: string, err: unknown): string => {
959 const rawName = (err as {name?: unknown} | null)?.name
960 const name = typeof rawName === 'string' ? rawName : ''
961 switch (name) {
962 case 'NotAllowedError':
963 case 'SecurityError':
964 return `Access to your ${what} was blocked — check this site's permissions in your browser.`
965 case 'NotFoundError':
966 case 'OverconstrainedError':
967 return `No ${what} was found on this device.`
968 case 'NotReadableError':
969 case 'AbortError':
970 return `Your ${what} could not be started — it may be in use by another app or browser (${name}).`
971 default:
972 return `Could not access your ${what}${name ? ` (${name})` : ''}.`
973 }
9813683Serverless group video calls: rooms, WebRTC mesh, shared settingsJeremy Magland 976/** A tiny black video track, used as a placeholder when there is no camera. */
977const blackVideoTrack = (): MediaStreamTrack => {
978 const canvas = document.createElement('canvas')
979 canvas.width = 320
980 canvas.height = 240
981 canvas.getContext('2d')?.fillRect(0, 0, canvas.width, canvas.height)
982 return canvas.captureStream(2).getVideoTracks()[0]
↑↓ move↵openescclose