/ concept-collection / commonroom
Sign in
concept-collection / commonroom
commonroom / src / p2p / network.ts
845 lines · 26.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'
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
57 settings: SettingEntry[]
58 }
59 | ({t: 'set'} & SettingEntry)
60 | {t: 'mute'; audio: boolean; video: boolean}
61 | {t: 'bye'}
63const ANNOUNCE_INTERVAL_MS = 5000
64const PRESENCE_TTL_MS = 15000
65// A connection attempt that hasn't opened after this long is torn down and
66// retried on the peer's next announcement. Signaling events are ephemeral, so
67// an offer published before the other side was listening is simply lost —
68// without a retry the pair would deadlock forever.
69const CONNECT_RETRY_MS = 15000
71const NAME_KEY = 'commonroom:name'
73export type Phase = 'landing' | 'joining' | 'room'
75interface Conn {
76 peer: Peer
77 /** When this connection attempt started (local clock), for retry pacing. */
78 createdAt: number
79 /** Name from the hello message (presence announcements may lag behind). */
80 name: string | null
81 connected: boolean
82 stream: MediaStream | null
83 /** Their reported effective outgoing mute state (muted until told otherwise
84 * — everyone starts muted). */
85 audioMuted: boolean
86 videoMuted: boolean
89export interface ParticipantInfo {
90 peerId: string
91 name: string
92 connected: boolean
93 stream: MediaStream | null
94 audioMuted: boolean
95 videoMuted: boolean
98export interface Snapshot {
99 selfId: string
100 phase: Phase
101 roomId: string | null
102 name: string | null
103 /** Everyone else in the room (connected or still connecting). */
104 participants: ParticipantInfo[]
105 audioMuted: boolean
106 videoMuted: boolean
107 micAvailable: boolean
108 camAvailable: boolean
109 localStream: MediaStream | null
110 screenStream: MediaStream | null
111 settings: RoomSettings
112 notice: string | null
115export class Network {
116 private nostr = new Nostr()
117 private phase: Phase = 'landing'
118 private roomId: string | null = null
119 private root = ''
120 private name: string | null = null
121 private presence = new Map<string, {name: string; lastSeen: number}>()
122 private conns = new Map<string, Conn>()
123 private unsubs: (() => void)[] = []
124 private announceTimer: number | null = null
125 private sweepTimer: number | null = null
126 /** Bumped on every join/leave so stale async work can detect it's obsolete. */
127 private joinSeq = 0
129 private localStream: MediaStream | null = null
130 private screenStream: MediaStream | null = null
131 private micAvailable = false
132 private camAvailable = false
133 private audioMuted = true
134 private videoMuted = true
135 private audioCtx: AudioContext | null = null
137 private settings: RoomSettings = {...DEFAULT_SETTINGS}
138 /** Per-key revision + setter for the last-writer-wins settings sync. */
139 private settingsMeta: Partial<
140 Record<keyof RoomSettings, {rev: number; by: string}>
141 > = {}
143 private notice: string | null = null
145 private snapshot!: Snapshot
146 private listeners = new Set<() => void>()
148 /** Last name used on this browser, for prefilling the join form. */
149 readonly savedName: string = localStorage.getItem(NAME_KEY) ?? ''
151 constructor() {
152 this.rebuildSnapshot()
153 window.addEventListener('online', () => void this.announce())
154 // Best-effort goodbye so tiles vanish immediately instead of after the
155 // presence TTL when a tab closes.
156 window.addEventListener('pagehide', () => {
157 if (this.phase === 'room') this.broadcastControl({t: 'bye'})
158 })
159 }
161 // ---- joining and leaving ----------------------------------------------
163 async enterRoom(name: string, room: string) {
164 if (this.phase !== 'landing') return
165 const nm = name.trim().slice(0, 40)
166 const rm = room.replace(/\s+/g, '').slice(0, 100)
167 if (!nm || !rm) return
168 this.name = nm
169 this.roomId = rm
170 localStorage.setItem(NAME_KEY, nm)
171 // Put the room in the URL so the address bar is the invite link.
172 try {
173 location.hash = encodeURIComponent(rm)
174 } catch {
175 /* ignore */
176 }
177 this.notice = null
178 this.phase = 'joining'
179 this.rebuildSnapshot()
180 const seq = ++this.joinSeq
182 const media = await this.acquireMedia()
183 if (this.joinSeq !== seq) {
184 for (const t of media.stream.getTracks()) t.stop()
185 return
186 }
187 this.localStream = media.stream
188 this.micAvailable = media.mic
189 this.camAvailable = media.cam
190 // Everyone enters muted.
191 this.audioMuted = true
192 this.videoMuted = true
193 for (const t of media.stream.getTracks()) t.enabled = false
195 this.root = await roomTopic(rm)
196 if (this.joinSeq !== seq) return
197 const selfTopic = await peerTopic(this.root, selfId)
198 if (this.joinSeq !== seq) return
200 // WebRTC signaling (and room-full notices) addressed to us.
201 this.unsubs.push(
202 this.nostr.subscribe(selfTopic, (content, from) => {
203 if (from === selfId) return
204 let msg: PeerMsg
205 try {
206 msg = JSON.parse(content)
207 } catch {
208 return
209 }
210 this.handlePeerMsg(from, msg)
211 })
212 )
214 // Presence announcements on the room topic.
215 this.unsubs.push(
216 this.nostr.subscribe(this.root, (content, from) => {
217 if (from === selfId) return
218 let ann: Partial<Announcement>
219 try {
220 ann = JSON.parse(content)
221 } catch {
222 return
223 }
224 if (ann.peerId !== from || typeof ann.name !== 'string') return
225 const prev = this.presence.get(from)
226 const annName = ann.name.slice(0, 40)
227 this.presence.set(from, {name: annName, lastSeen: Date.now()})
228 if (!prev || prev.name !== annName) this.rebuildSnapshot()
229 this.maybeConnect(from)
230 })
231 )
233 this.phase = 'room'
234 void this.announce()
235 this.announceTimer = window.setInterval(
236 () => void this.announce(),
237 ANNOUNCE_INTERVAL_MS
238 )
239 this.sweepTimer = window.setInterval(
240 () => this.sweepPresence(),
241 ANNOUNCE_INTERVAL_MS
242 )
243 this.rebuildSnapshot()
244 }
246 leave() {
247 if (this.phase === 'landing') return
248 this.teardown()
249 this.notice = null
250 this.rebuildSnapshot()
251 }
253 private teardown() {
254 this.joinSeq++
255 this.broadcastControl({t: 'bye'})
256 const conns = [...this.conns.values()]
257 this.conns.clear() // cleared first so close handlers no-op
258 for (const c of conns) c.peer.destroy()
259 this.presence.clear()
260 for (const u of this.unsubs.splice(0)) u()
261 if (this.announceTimer !== null) clearInterval(this.announceTimer)
262 if (this.sweepTimer !== null) clearInterval(this.sweepTimer)
263 this.announceTimer = null
264 this.sweepTimer = null
265 if (this.screenStream) {
266 for (const t of this.screenStream.getTracks()) t.stop()
267 this.screenStream = null
268 }
269 if (this.localStream) {
270 for (const t of this.localStream.getTracks()) t.stop()
271 this.localStream = null
272 }
273 if (this.audioCtx) {
274 void this.audioCtx.close().catch(() => undefined)
275 this.audioCtx = null
276 }
277 this.micAvailable = false
278 this.camAvailable = false
279 this.audioMuted = true
280 this.videoMuted = true
281 this.settings = {...DEFAULT_SETTINGS}
282 this.settingsMeta = {}
283 this.root = ''
284 this.roomId = null
285 this.phase = 'landing'
286 }
288 // ---- local media -------------------------------------------------------
289 //
290 // Every participant always carries exactly one audio and one video track so
291 // the WebRTC offer/answer is symmetric for everyone. If a device is missing
292 // or permission is denied, a synthetic placeholder (silent audio / black
293 // video) stands in; unmuting later retries getUserMedia and upgrades the
294 // placeholder via replaceTrack on every connection — no renegotiation.
296 private async acquireMedia(): Promise<{
297 stream: MediaStream
298 mic: boolean
299 cam: boolean
300 }> {
301 try {
302 const s = await navigator.mediaDevices.getUserMedia({
303 audio: true,
304 video: true
305 })
306 return {stream: s, mic: true, cam: true}
307 } catch {
308 /* fall through to per-kind attempts */
309 }
310 let audio: MediaStreamTrack | null = null
311 let video: MediaStreamTrack | null = null
312 try {
313 const s = await navigator.mediaDevices.getUserMedia({audio: true})
314 audio = s.getAudioTracks()[0] ?? null
315 } catch {
316 /* no mic */
317 }
318 try {
319 const s = await navigator.mediaDevices.getUserMedia({video: true})
320 video = s.getVideoTracks()[0] ?? null
321 } catch {
322 /* no camera */
323 }
324 const stream = new MediaStream()
325 stream.addTrack(audio ?? this.silentAudioTrack())
326 stream.addTrack(video ?? blackVideoTrack())
327 return {stream, mic: audio !== null, cam: video !== null}
328 }
330 private silentAudioTrack(): MediaStreamTrack {
331 if (!this.audioCtx) this.audioCtx = new AudioContext()
332 const dst = this.audioCtx.createMediaStreamDestination()
333 return dst.stream.getAudioTracks()[0]
334 }
336 /** The tracks we send to a (new) peer: mic audio plus screen or camera. */
337 private outgoingStream(): MediaStream {
338 const s = new MediaStream()
339 const audio = this.localStream?.getAudioTracks()[0]
340 if (audio) s.addTrack(audio)
341 const video =
342 this.screenStream?.getVideoTracks()[0] ??
343 this.localStream?.getVideoTracks()[0]
344 if (video) s.addTrack(video)
345 return s
346 }
348 // ---- presence and the mesh ----------------------------------------------
350 private async announce() {
351 if (this.phase !== 'room' || !this.name || !this.root) return
352 const ann: Announcement = {peerId: selfId, name: this.name}
353 void this.nostr.publish(this.root, JSON.stringify(ann))
354 }
356 private sweepPresence() {
357 const cutoff = Date.now() - PRESENCE_TTL_MS
358 let changed = false
359 for (const [peerId, p] of this.presence) {
360 if (p.lastSeen < cutoff) {
361 this.presence.delete(peerId)
362 changed = true
363 }
364 }
365 if (changed) this.rebuildSnapshot()
366 }
368 private async sendToPeer(peerId: string, msg: PeerMsg) {
369 if (!this.root) return
370 const topic = await peerTopic(this.root, peerId)
371 void this.nostr.publish(topic, JSON.stringify(msg))
372 }
374 private atCapacity(): boolean {
375 return this.conns.size >= MAX_PARTICIPANTS - 1
376 }
378 private maybeConnect(peerId: string) {
379 if (this.phase !== 'room' || !this.localStream || peerId === selfId) return
380 const existing = this.conns.get(peerId)
381 if (existing) {
382 const stalled =
383 !existing.connected &&
384 Date.now() - existing.createdAt > CONNECT_RETRY_MS
385 if (!stalled) return
386 this.conns.delete(peerId) // deleted first so the close handler no-ops
387 existing.peer.destroy()
388 }
389 if (this.atCapacity()) {
390 // The room is full from our point of view: turn the newcomer away.
391 void this.sendToPeer(peerId, {t: 'room-full'})
392 return
393 }
394 // Deterministic initiator: the peer with the smaller ID makes the offer.
395 this.createPeer(peerId, selfId < peerId)
396 }
398 private createPeer(peerId: string, initiator: boolean): Conn {
399 const peer = new Peer(initiator, this.outgoingStream())
400 const conn: Conn = {
401 peer,
402 createdAt: Date.now(),
403 name: null,
404 connected: false,
405 stream: null,
406 audioMuted: true,
407 videoMuted: true
408 }
409 this.conns.set(peerId, conn)
411 peer.setHandlers({
412 signal: signal => {
413 void this.sendToPeer(peerId, {t: 'signal', signal})
414 },
415 track: stream => {
416 conn.stream = stream
417 this.rebuildSnapshot()
418 },
419 connect: () => {
420 conn.connected = true
421 this.sendHello(conn)
422 this.applyVideoParamsTo(conn)
423 this.rebuildSnapshot()
424 },
425 data: raw => this.handleControl(peerId, conn, raw),
426 close: () => {
427 if (this.conns.get(peerId) === conn) {
428 this.conns.delete(peerId)
429 this.rebuildSnapshot()
430 }
431 }
432 })
434 this.rebuildSnapshot()
435 return conn
436 }
438 private handlePeerMsg(from: string, msg: PeerMsg) {
439 if (this.phase !== 'room') return
440 switch (msg.t) {
441 case 'signal': {
442 let conn = this.conns.get(from)
443 if (!conn) {
444 // An offer can arrive before we've seen the peer's announcement.
445 if (msg.signal?.type !== 'offer') return
446 if (this.atCapacity()) {
447 void this.sendToPeer(from, {t: 'room-full'})
448 return
449 }
450 conn = this.createPeer(from, false)
451 }
452 void conn.peer.signal(msg.signal)
453 return
454 }
455 case 'room-full': {
456 // Only honor this while we haven't gotten a foothold in the room —
457 // once we have any connection, we're in.
458 if (this.conns.size === 0) {
459 this.teardown()
460 this.notice = `That room is full — up to ${MAX_PARTICIPANTS} people can be in a room.`
461 this.rebuildSnapshot()
462 }
463 return
464 }
465 }
466 }
468 // ---- control channel ----------------------------------------------------
470 private broadcastControl(msg: ControlMsg) {
471 const payload = JSON.stringify(msg)
472 for (const conn of this.conns.values()) conn.peer.send(payload)
473 }
475 private sendHello(conn: Conn) {
476 const settings: SettingEntry[] = []
477 for (const [key, meta] of Object.entries(this.settingsMeta)) {
478 settings.push({
479 key,
480 value: this.settings[key as keyof RoomSettings],
481 rev: meta.rev,
482 by: meta.by
483 })
484 }
485 conn.peer.send(
486 JSON.stringify({
487 t: 'hello',
488 name: this.name ?? '',
489 audioMuted: this.audioMuted,
490 videoMuted: this.effectiveVideoMuted(),
491 settings
492 } satisfies ControlMsg)
493 )
494 }
496 private handleControl(peerId: string, conn: Conn, raw: string) {
497 if (this.conns.get(peerId) !== conn) return
498 let msg: ControlMsg
499 try {
500 msg = JSON.parse(raw)
501 } catch {
502 return
503 }
504 switch (msg.t) {
505 case 'hello': {
506 if (typeof msg.name === 'string') conn.name = msg.name.slice(0, 40)
507 conn.audioMuted = msg.audioMuted !== false
508 conn.videoMuted = msg.videoMuted !== false
509 if (Array.isArray(msg.settings)) {
510 for (const entry of msg.settings) this.applyRemoteSetting(entry)
511 }
512 this.rebuildSnapshot()
513 return
514 }
515 case 'set': {
516 this.applyRemoteSetting(msg)
517 return
518 }
519 case 'mute': {
520 if (typeof msg.audio !== 'boolean' || typeof msg.video !== 'boolean') {
521 return
522 }
523 conn.audioMuted = msg.audio
524 conn.videoMuted = msg.video
525 this.rebuildSnapshot()
526 return
527 }
528 case 'bye': {
529 this.presence.delete(peerId)
530 conn.peer.destroy() // its close handler removes it and rebuilds
531 return
532 }
533 }
534 }
536 // ---- shared room settings ------------------------------------------------
537 //
538 // ONE settings object for the whole room, editable by anyone. Sync is
539 // per-key last-writer-wins: every change bumps that key's revision and is
540 // broadcast as {t:'set'} to every peer (the mesh is a complete graph, so no
541 // relaying is needed). Late joiners receive the current entries in each
542 // hello. Concurrent changes at the same revision must resolve identically
543 // everywhere, so the SETTER with the smaller peer ID wins the tie.
545 private setSetting<K extends keyof RoomSettings>(
546 key: K,
547 value: RoomSettings[K]
548 ) {
549 if (this.phase !== 'room' || this.settings[key] === value) return
550 const rev = (this.settingsMeta[key]?.rev ?? 0) + 1
551 this.settingsMeta[key] = {rev, by: selfId}
552 this.settings = {...this.settings}
553 this.settings[key] = value
554 this.broadcastControl({t: 'set', key, value, rev, by: selfId})
555 this.settingChanged(key)
556 this.rebuildSnapshot()
557 }
559 private applyRemoteSetting(entry: SettingEntry) {
560 if (typeof entry !== 'object' || entry === null) return
561 if (typeof entry.key !== 'string' || !(entry.key in SETTING_VALIDATORS)) {
562 return
563 }
564 const key = entry.key as keyof RoomSettings
565 if (!SETTING_VALIDATORS[key](entry.value)) return
566 if (!Number.isInteger(entry.rev) || entry.rev < 1) return
567 if (typeof entry.by !== 'string' || entry.by.length !== 64) return
568 const cur = this.settingsMeta[key]
569 const curRev = cur?.rev ?? 0
570 if (entry.rev < curRev) return // stale
571 if (entry.rev === curRev && cur && cur.by <= entry.by) return // tie: they lose
572 this.settingsMeta[key] = {rev: entry.rev, by: entry.by}
573 if (this.settings[key] !== entry.value) {
574 this.settings = {...this.settings}
575 this.settings[key] = entry.value
576 this.settingChanged(key)
577 }
578 this.rebuildSnapshot()
579 }
581 /** Side effects of a setting taking a new value (local or remote). */
582 private settingChanged(key: keyof RoomSettings) {
583 if (key === 'videoQuality') this.applyVideoParamsAll()
584 }
586 private videoParams(): VideoSendParams {
587 const p = QUALITY_PARAMS[this.settings.videoQuality]
588 const sharing = this.screenStream !== null
589 return {
590 maxBitrate: p.maxBitrate,
591 // Downscaled screen text is unreadable: while sharing, send full
592 // resolution and let the bitrate/framerate caps do the limiting.
593 scaleResolutionDownBy: sharing ? undefined : p.scaleResolutionDownBy,
594 maxFramerate: p.maxFramerate,
595 degradationPreference: sharing ? 'maintain-resolution' : undefined
596 }
597 }
599 private applyVideoParamsAll() {
600 for (const conn of this.conns.values()) this.applyVideoParamsTo(conn)
601 }
603 private applyVideoParamsTo(conn: Conn) {
604 void conn.peer.setVideoParameters(this.videoParams()).then(ok => {
605 if (!ok) {
606 // Right at 'connected' the encoding may not be negotiated yet.
607 window.setTimeout(
608 () => void conn.peer.setVideoParameters(this.videoParams()),
609 1500
610 )
611 }
612 })
613 }
615 // ---- mute -----------------------------------------------------------------
616 //
617 // Mute is per-participant state, not a shared setting: each participant owns
618 // its own flags and just notifies the others (the ordered channel makes
619 // last-sent win). Toggling track.enabled sends silence/black without
620 // renegotiation. Unmuting without a usable device retries getUserMedia and,
621 // on success, upgrades the placeholder track in place on every connection.
623 setAudioMuted(muted: boolean) {
624 if (this.phase !== 'room' || !this.localStream) return
625 if (this.audioMuted === muted) return
626 if (!muted && !this.micAvailable) {
627 void this.enableAudioWithRetry()
628 return
629 }
630 this.audioMuted = muted
631 for (const t of this.localStream.getAudioTracks()) t.enabled = !muted
632 this.broadcastMuteNotice()
633 this.rebuildSnapshot()
634 }
636 setVideoMuted(muted: boolean) {
637 if (this.phase !== 'room' || !this.localStream) return
638 if (this.videoMuted === muted) return
639 if (!muted && !this.camAvailable) {
640 void this.enableVideoWithRetry()
641 return
642 }
643 this.videoMuted = muted
644 for (const t of this.localStream.getVideoTracks()) t.enabled = !muted
645 this.broadcastMuteNotice()
646 this.rebuildSnapshot()
647 }
649 private async enableAudioWithRetry() {
650 const seq = this.joinSeq
651 let stream: MediaStream
652 try {
653 stream = await navigator.mediaDevices.getUserMedia({audio: true})
654 } catch {
655 this.notice =
656 'Could not access your microphone — check browser permissions.'
657 this.rebuildSnapshot()
658 return
659 }
660 const track = stream.getAudioTracks()[0]
661 if (!track || this.joinSeq !== seq || !this.localStream) {
662 for (const t of stream.getTracks()) t.stop()
663 return
664 }
665 const old = this.localStream.getAudioTracks()[0] ?? null
666 for (const conn of this.conns.values()) {
667 void conn.peer.replaceTrack('audio', track)
668 }
669 if (old) {
670 this.localStream.removeTrack(old)
671 old.stop()
672 }
673 this.localStream.addTrack(track)
674 this.micAvailable = true
675 this.audioMuted = false
676 track.enabled = true
677 this.broadcastMuteNotice()
678 this.rebuildSnapshot()
679 }
681 private async enableVideoWithRetry() {
682 const seq = this.joinSeq
683 let stream: MediaStream
684 try {
685 stream = await navigator.mediaDevices.getUserMedia({video: true})
686 } catch {
687 this.notice = 'Could not access your camera — check browser permissions.'
688 this.rebuildSnapshot()
689 return
690 }
691 const track = stream.getVideoTracks()[0]
692 if (!track || this.joinSeq !== seq || !this.localStream) {
693 for (const t of stream.getTracks()) t.stop()
694 return
695 }
696 const old = this.localStream.getVideoTracks()[0] ?? null
697 // While screen sharing, the connections carry the screen track; the new
698 // camera track takes over when the share stops.
699 if (!this.screenStream) {
700 for (const conn of this.conns.values()) {
701 void conn.peer.replaceTrack('video', track)
702 }
703 }
704 if (old) {
705 this.localStream.removeTrack(old)
706 old.stop()
707 }
708 this.localStream.addTrack(track)
709 this.camAvailable = true
710 this.videoMuted = false
711 track.enabled = true
712 this.broadcastMuteNotice()
713 this.rebuildSnapshot()
714 }
716 /** While screen sharing the outgoing video is the (always live) screen, so
717 * a muted camera is latent until the share ends. */
718 private effectiveVideoMuted(): boolean {
719 return this.videoMuted && !this.screenStream
720 }
722 private broadcastMuteNotice() {
723 this.broadcastControl({
724 t: 'mute',
725 audio: this.audioMuted,
726 video: this.effectiveVideoMuted()
727 })
728 }
730 // ---- screen share ---------------------------------------------------------
732 /** Swap the outgoing camera track for a screen capture on EVERY connection.
733 * Everyone sees the screen in place of the camera; no renegotiation. */
734 async startScreenShare() {
735 if (this.phase !== 'room' || this.screenStream) return
736 const seq = this.joinSeq
737 let stream: MediaStream
738 try {
739 stream = await navigator.mediaDevices.getDisplayMedia({video: true})
740 } catch {
741 return // user canceled the picker (or capture is unsupported)
742 }
743 const track = stream.getVideoTracks()[0]
744 if (!track || this.joinSeq !== seq) {
745 for (const t of stream.getTracks()) t.stop()
746 return
747 }
748 this.screenStream = stream
749 for (const conn of this.conns.values()) {
750 void conn.peer.replaceTrack('video', track)
751 }
752 this.applyVideoParamsAll() // re-derive caps for screen-share mode
753 this.broadcastMuteNotice() // outgoing video is now the live screen
754 // The browser's own "Stop sharing" bar ends the track; swap back then.
755 track.onended = () => void this.stopScreenShare()
756 this.rebuildSnapshot()
757 }
759 async stopScreenShare() {
760 if (!this.screenStream) return
761 const screen = this.screenStream
762 this.screenStream = null
763 const camTrack = this.localStream?.getVideoTracks()[0]
764 if (camTrack) {
765 for (const conn of this.conns.values()) {
766 void conn.peer.replaceTrack('video', camTrack)
767 }
768 }
769 for (const t of screen.getTracks()) t.stop()
770 if (this.phase === 'room') {
771 this.applyVideoParamsAll() // restore camera-mode caps
772 this.broadcastMuteNotice() // the camera, with its mute state, is back
773 this.rebuildSnapshot()
774 }
775 }
777 // ---- public API -------------------------------------------------------
779 /** Change the room-wide video-quality preset. Anyone can change it; every
780 * participant caps its own outgoing video, and the change syncs across. */
781 setVideoQuality(quality: VideoQuality) {
782 this.setSetting('videoQuality', quality)
783 }
785 dismissNotice() {
786 this.notice = null
787 this.rebuildSnapshot()
788 }
790 getSnapshot = (): Snapshot => this.snapshot
792 subscribe = (listener: () => void): (() => void) => {
793 this.listeners.add(listener)
794 return () => this.listeners.delete(listener)
795 }
797 private rebuildSnapshot() {
798 const ids = new Set<string>([...this.conns.keys(), ...this.presence.keys()])
799 const participants: ParticipantInfo[] = [...ids]
800 .map(peerId => {
801 const conn = this.conns.get(peerId)
802 return {
803 peerId,
804 name:
805 this.presence.get(peerId)?.name ??
806 conn?.name ??
807 peerId.slice(0, 8),
808 connected: conn?.connected ?? false,
809 stream: conn?.stream ?? null,
810 audioMuted: conn?.audioMuted ?? true,
811 videoMuted: conn?.videoMuted ?? true
812 }
813 })
814 .sort(
815 (a, b) =>
816 a.name.localeCompare(b.name) || a.peerId.localeCompare(b.peerId)
817 )
819 this.snapshot = {
820 selfId,
821 phase: this.phase,
822 roomId: this.roomId,
823 name: this.name,
824 participants,
825 audioMuted: this.audioMuted,
826 videoMuted: this.videoMuted,
827 micAvailable: this.micAvailable,
828 camAvailable: this.camAvailable,
829 localStream: this.localStream,
830 screenStream: this.screenStream,
831 settings: this.settings,
832 notice: this.notice
833 }
834 for (const l of this.listeners) l()
835 }
838/** A tiny black video track, used as a placeholder when there is no camera. */
839const blackVideoTrack = (): MediaStreamTrack => {
840 const canvas = document.createElement('canvas')
841 canvas.width = 320
842 canvas.height = 240
843 canvas.getContext('2d')?.fillRect(0, 0, canvas.width, canvas.height)
844 return canvas.captureStream(2).getVideoTracks()[0]
moveopenescclose