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