import {selfId} from './identity' import {Nostr, peerTopic, roomTopic} from './nostr' import {Peer, type Signal, type VideoSendParams} from './peer' import { DEFAULT_SETTINGS, QUALITY_PARAMS, SETTING_VALIDATORS, type RoomSettings, type VideoQuality } from './settings' import { BASE_ICE_SERVERS, STUN_SERVERS, TURN_CONFIGURED, fetchIceConfig, sanitizeIceConfig, type IceConfig, type RelayStatus } from './turn' import {isUsableApiKey} from '../transcribe/deepgram' import {TranscriptStore, type TranscriptItem} from '../transcribe/store' import {Transcriber, type TranscriptSource} from '../transcribe/transcriber' // --------------------------------------------------------------------------- // CommonRoom network layer: a full-mesh group video call. // // Rooms: the room ID is any string (no spaces); it is hashed into a nostr // topic, so there is no room registry anywhere — knowing the name IS the key. // // Presence: everyone in the room announces {peerId, name} on the room topic // every few seconds; entries expire when announcements stop. // // Mesh: unlike commoncall (mutual consent, one call at a time), being in the // room IS the consent — every participant automatically brings up a WebRTC // connection with every other participant (commonview's approach, but carrying // media). Camera/mic are requested on entry, but both start MUTED; if access // is denied you still join, sending synthetic silent/black placeholder tracks, // and unmuting retries the device and upgrades the tracks in place. // // Everything else (deterministic initiator = smaller peer ID, per-peer nostr // signaling topics, control data channel, track-swap screen share, quality // caps via setParameters) is the commoncall design, applied per-peer. // --------------------------------------------------------------------------- /** Soft cap: peers at capacity turn newcomers away with {t:'room-full'}. */ export const MAX_PARTICIPANTS = 8 interface Announcement { peerId: string name: string } // Messages on per-peer nostr topics (pre-connection). type PeerMsg = {t: 'signal'; signal: Signal} | {t: 'room-full'} // Messages on the per-peer control data channels (WebRTC, not nostr). interface SettingEntry { key: string value: unknown rev: number by: string } type ControlMsg = | { t: 'hello' name: string audioMuted: boolean videoMuted: boolean /** Self-reported epoch ms of when they entered the room, so receivers * can tell "was already here when I arrived" from "joined after me" * for the chat's join lines. */ joinedAt: number transcribing: boolean settings: SettingEntry[] } | ({t: 'set'} & SettingEntry) | {t: 'mute'; audio: boolean; video: boolean} | {t: 'chat'; text: string} /** Whether we are sending the room's audio to a transcription service. */ | {t: 'tx'; on: boolean} /** TURN credentials, so one person's token covers the whole room. */ | {t: 'ice'; iceServers: RTCIceServer[]; expiresAt: number} | {t: 'bye'} const ANNOUNCE_INTERVAL_MS = 5000 const PRESENCE_TTL_MS = 15000 // A connection attempt that hasn't opened after this long is torn down and // retried on the peer's next announcement. Signaling events are ephemeral, so // an offer published before the other side was listening is simply lost — // without a retry the pair would deadlock forever. const CONNECT_RETRY_MS = 15000 const NAME_KEY = 'commonroom:name' const TURN_TOKEN_KEY = 'commonroom:turnToken' const DEEPGRAM_KEY = 'commonroom:deepgramKey' /** Re-mint our relay credentials this long before they lapse, so a long call * never runs out mid-session. */ const ICE_REFRESH_MARGIN_MS = 5 * 60 * 1000 const CHAT_MAX_LENGTH = 2000 const CHAT_LOG_CAP = 500 export type Phase = 'landing' | 'joining' | 'room' interface Conn { peer: Peer /** When this connection attempt started (local clock), for retry pacing. */ createdAt: number /** Name from the hello message (presence announcements may lag behind). */ name: string | null connected: boolean stream: MediaStream | null /** Their reported effective outgoing mute state (muted until told otherwise * — everyone starts muted). */ audioMuted: boolean videoMuted: boolean /** Whether they told us they are transcribing the room. */ transcribing: boolean } export interface ChatItem { /** Monotonic per-session sequence number; stable React key. */ seq: number kind: 'chat' | 'system' /** The author's peer ID; null for system lines. */ peerId: string | null name: string text: string /** Local arrival time (epoch ms). */ time: number } export interface ParticipantInfo { peerId: string name: string connected: boolean stream: MediaStream | null audioMuted: boolean videoMuted: boolean transcribing: boolean } export interface Snapshot { selfId: string phase: Phase roomId: string | null name: string | null /** Everyone else in the room (connected or still connecting). */ participants: ParticipantInfo[] audioMuted: boolean videoMuted: boolean micAvailable: boolean camAvailable: boolean localStream: MediaStream | null screenStream: MediaStream | null settings: RoomSettings chat: ChatItem[] /** Where our TURN credentials came from (ours, a peer's, or none). */ relay: RelayStatus /** Whether WE are transcribing (peers report their own in participants). */ transcribing: boolean /** This room's transcript, restored from previous sittings and appended to * while transcription runs. */ transcript: TranscriptItem[] /** Audio sent to Deepgram for this room, in seconds — what it is billed on. * Cumulative across sittings, like the transcript itself. */ transcriptSeconds: number /** Whether a Deepgram key is stored in this browser. The key itself never * reaches the UI, and never leaves this browser. */ hasDeepgramKey: boolean notice: string | null } export class Network { private nostr = new Nostr() private phase: Phase = 'landing' private roomId: string | null = null private root = '' private name: string | null = null private presence = new Map() private conns = new Map() private unsubs: (() => void)[] = [] private announceTimer: number | null = null private sweepTimer: number | null = null /** Bumped on every join/leave so stale async work can detect it's obsolete. */ private joinSeq = 0 private localStream: MediaStream | null = null private screenStream: MediaStream | null = null private micAvailable = false private camAvailable = false private audioMuted = true private videoMuted = true private audioCtx: AudioContext | null = null // Relay (TURN) credentials for this room, if anyone in it has a token. See // turn.ts for the scheme; the sharing itself is below under "relay". private ice: IceConfig | null = null /** True when `ice` was minted with OUR token rather than shared with us. */ private iceFromSelf = false private iceTimer: number | null = null private turnToken = '' private settings: RoomSettings = {...DEFAULT_SETTINGS} /** Per-key revision + setter for the last-writer-wins settings sync. */ private settingsMeta: Partial< Record > = {} // Chat is ephemeral: you only see what's said while you're in the room. // Messages arrive directly from their author over the authenticated // channel, so there's no relaying and nothing to forge. private chat: ChatItem[] = [] private chatSeq = 0 /** When WE entered the room (epoch ms), reported in our hello. */ private joinedAtMs = 0 /** peerId -> name for peers currently counted present in the chat. */ private chatPresent = new Map() /** Peers we've ever logged a join/left line for (so a reconnect after a * network blip gets a "joined" line to match its "left" line). */ private chatSeen = new Set() // Transcription is entirely local: it belongs to whoever entered a Deepgram // key, it is not a room setting, and the only thing that crosses the mesh is // the fact that it is running. The transcript itself outlives the call and // belongs to the room, so its store is opened on entry whether or not // anything is being transcribed this time. private transcript: TranscriptStore | null = null private transcriber: Transcriber | null = null private transcribing = false private deepgramKey = localStorage.getItem(DEEPGRAM_KEY) ?? '' private notice: string | null = null private snapshot!: Snapshot private listeners = new Set<() => void>() /** Last name used on this browser, for prefilling the join form. */ readonly savedName: string = localStorage.getItem(NAME_KEY) ?? '' /** Last relay token used on this browser, likewise. It stays on this device: * what gets shared with the room is the credential it buys, never the * token itself. */ readonly savedTurnToken: string = localStorage.getItem(TURN_TOKEN_KEY) ?? '' constructor() { this.rebuildSnapshot() window.addEventListener('online', () => void this.announce()) // Best-effort goodbye so tiles vanish immediately instead of after the // presence TTL when a tab closes. window.addEventListener('pagehide', () => { if (this.phase === 'room') this.broadcastControl({t: 'bye'}) // Closing the tab is the most likely way to leave, and the transcript // is the one thing here meant to survive it. this.transcript?.flush() }) } // ---- joining and leaving ---------------------------------------------- async enterRoom(name: string, room: string, turnToken = '') { if (this.phase !== 'landing') return const nm = name.trim().slice(0, 40) const rm = room.replace(/\s+/g, '').slice(0, 100) if (!nm || !rm) return this.name = nm this.roomId = rm this.turnToken = turnToken.trim() localStorage.setItem(NAME_KEY, nm) localStorage.setItem(TURN_TOKEN_KEY, this.turnToken) // Put the room in the URL so the address bar is the invite link. try { location.hash = encodeURIComponent(rm) } catch { /* ignore */ } this.notice = null // Whatever was transcribed in this room before is part of the room, so it // is back on screen from the moment you enter. this.transcript = new TranscriptStore(rm, () => this.rebuildSnapshot()) this.phase = 'joining' this.rebuildSnapshot() const seq = ++this.joinSeq const media = await this.acquireMedia() if (this.joinSeq !== seq) { for (const t of media.stream.getTracks()) t.stop() return } this.localStream = media.stream this.micAvailable = media.mic this.camAvailable = media.cam // Tell the user right away when a device didn't come up (and why), so // they aren't surprised at unmute time. if (!media.mic && !media.cam) { this.notice = `${mediaErrorMessage('camera or microphone', media.camError)} You've still joined — the mic/camera buttons will retry.` } else if (!media.cam) { this.notice = `${mediaErrorMessage('camera', media.camError)} The camera button will retry.` } else if (!media.mic) { this.notice = `${mediaErrorMessage('microphone', media.micError)} The mic button will retry.` } // Everyone enters muted. this.audioMuted = true this.videoMuted = true for (const t of media.stream.getTracks()) t.enabled = false this.root = await roomTopic(rm) if (this.joinSeq !== seq) return // Mint relay credentials BEFORE the mesh starts, so our very first // connections already offer relay candidates. Peers without a token pick // these up over the control channel once they are connected to someone. if (TURN_CONFIGURED && this.turnToken) { await this.mintIce(seq) if (this.joinSeq !== seq) return } const selfTopic = await peerTopic(this.root, selfId) if (this.joinSeq !== seq) return // WebRTC signaling (and room-full notices) addressed to us. this.unsubs.push( this.nostr.subscribe(selfTopic, (content, from) => { if (from === selfId) return let msg: PeerMsg try { msg = JSON.parse(content) } catch { return } this.handlePeerMsg(from, msg) }) ) // Presence announcements on the room topic. this.unsubs.push( this.nostr.subscribe(this.root, (content, from) => { if (from === selfId) return let ann: Partial try { ann = JSON.parse(content) } catch { return } if (ann.peerId !== from || typeof ann.name !== 'string') return const prev = this.presence.get(from) const annName = ann.name.slice(0, 40) this.presence.set(from, {name: annName, lastSeen: Date.now()}) if (!prev || prev.name !== annName) this.rebuildSnapshot() this.maybeConnect(from) }) ) this.phase = 'room' this.joinedAtMs = Date.now() this.pushSystem('You joined') void this.announce() this.announceTimer = window.setInterval( () => void this.announce(), ANNOUNCE_INTERVAL_MS ) this.sweepTimer = window.setInterval( () => this.sweepPresence(), ANNOUNCE_INTERVAL_MS ) this.rebuildSnapshot() } leave() { if (this.phase === 'landing') return this.teardown() this.notice = null this.rebuildSnapshot() } private teardown() { this.joinSeq++ this.broadcastControl({t: 'bye'}) const conns = [...this.conns.values()] this.conns.clear() // cleared first so close handlers no-op for (const c of conns) c.peer.destroy() this.presence.clear() for (const u of this.unsubs.splice(0)) u() if (this.announceTimer !== null) clearInterval(this.announceTimer) if (this.sweepTimer !== null) clearInterval(this.sweepTimer) if (this.iceTimer !== null) clearTimeout(this.iceTimer) this.announceTimer = null this.sweepTimer = null this.iceTimer = null // Credentials are per-room (they carry a per-room analytics tag) and, when // shared, belong to whoever was in that room — don't carry them onward. this.ice = null this.iceFromSelf = false this.transcribing = false if (this.transcriber) { this.transcriber.dispose() this.transcriber = null } // The transcript stays on disk under its room; only the open handle goes. this.transcript?.flush() this.transcript = null if (this.screenStream) { for (const t of this.screenStream.getTracks()) t.stop() this.screenStream = null } if (this.localStream) { for (const t of this.localStream.getTracks()) t.stop() this.localStream = null } if (this.audioCtx) { void this.audioCtx.close().catch(() => undefined) this.audioCtx = null } this.micAvailable = false this.camAvailable = false this.audioMuted = true this.videoMuted = true this.settings = {...DEFAULT_SETTINGS} this.settingsMeta = {} this.chat = [] this.chatPresent.clear() this.chatSeen.clear() this.root = '' this.roomId = null this.phase = 'landing' } // ---- local media ------------------------------------------------------- // // Every participant always carries exactly one audio and one video track so // the WebRTC offer/answer is symmetric for everyone. If a device is missing // or permission is denied, a synthetic placeholder (silent audio / black // video) stands in; unmuting later retries getUserMedia and upgrades the // placeholder via replaceTrack on every connection — no renegotiation. private async acquireMedia(): Promise<{ stream: MediaStream mic: boolean cam: boolean micError: unknown camError: unknown }> { try { const s = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }) return {stream: s, mic: true, cam: true, micError: null, camError: null} } catch { // The combined request fails as a whole if EITHER device is unusable // (in Firefox, e.g., a camera held by another app fails it even though // the mic is fine) — retry each kind on its own to keep what works. } let audio: MediaStreamTrack | null = null let video: MediaStreamTrack | null = null let micError: unknown = null let camError: unknown = null try { const s = await navigator.mediaDevices.getUserMedia({audio: true}) audio = s.getAudioTracks()[0] ?? null } catch (err) { micError = err } try { const s = await navigator.mediaDevices.getUserMedia({video: true}) video = s.getVideoTracks()[0] ?? null } catch (err) { camError = err } const stream = new MediaStream() stream.addTrack(audio ?? this.silentAudioTrack()) stream.addTrack(video ?? blackVideoTrack()) return {stream, mic: audio !== null, cam: video !== null, micError, camError} } private silentAudioTrack(): MediaStreamTrack { if (!this.audioCtx) this.audioCtx = new AudioContext() const dst = this.audioCtx.createMediaStreamDestination() return dst.stream.getAudioTracks()[0] } /** The tracks we send to a (new) peer: mic audio plus screen or camera. */ private outgoingStream(): MediaStream { const s = new MediaStream() const audio = this.localStream?.getAudioTracks()[0] if (audio) s.addTrack(audio) const video = this.screenStream?.getVideoTracks()[0] ?? this.localStream?.getVideoTracks()[0] if (video) s.addTrack(video) return s } // ---- presence and the mesh ---------------------------------------------- private async announce() { if (this.phase !== 'room' || !this.name || !this.root) return const ann: Announcement = {peerId: selfId, name: this.name} void this.nostr.publish(this.root, JSON.stringify(ann)) } private sweepPresence() { const cutoff = Date.now() - PRESENCE_TTL_MS let changed = false for (const [peerId, p] of this.presence) { if (p.lastSeen < cutoff) { this.presence.delete(peerId) changed = true } } if (changed) this.rebuildSnapshot() } private async sendToPeer(peerId: string, msg: PeerMsg) { if (!this.root) return const topic = await peerTopic(this.root, peerId) void this.nostr.publish(topic, JSON.stringify(msg)) } private atCapacity(): boolean { return this.conns.size >= MAX_PARTICIPANTS - 1 } private maybeConnect(peerId: string) { if (this.phase !== 'room' || !this.localStream || peerId === selfId) return const existing = this.conns.get(peerId) if (existing) { const stalled = !existing.connected && Date.now() - existing.createdAt > CONNECT_RETRY_MS if (!stalled) return this.conns.delete(peerId) // deleted first so the close handler no-ops existing.peer.destroy() } if (this.atCapacity()) { // The room is full from our point of view: turn the newcomer away. void this.sendToPeer(peerId, {t: 'room-full'}) return } // Deterministic initiator: the peer with the smaller ID makes the offer. this.createPeer(peerId, selfId < peerId) } private createPeer(peerId: string, initiator: boolean): Conn { const peer = new Peer(initiator, this.outgoingStream(), this.iceServers()) const conn: Conn = { peer, createdAt: Date.now(), name: null, connected: false, stream: null, audioMuted: true, videoMuted: true, transcribing: false } this.conns.set(peerId, conn) peer.setHandlers({ signal: signal => { void this.sendToPeer(peerId, {t: 'signal', signal}) }, track: stream => { conn.stream = stream this.rebuildSnapshot() }, connect: () => { conn.connected = true this.sendHello(conn) this.sendIce(conn) this.applyVideoParamsTo(conn) this.rebuildSnapshot() }, data: raw => this.handleControl(peerId, conn, raw), close: () => { if (this.conns.get(peerId) === conn) { this.conns.delete(peerId) const chatName = this.chatPresent.get(peerId) if (chatName !== undefined) { this.chatPresent.delete(peerId) this.pushSystem(`${chatName} left`) } this.rebuildSnapshot() } } }) this.rebuildSnapshot() return conn } private handlePeerMsg(from: string, msg: PeerMsg) { if (this.phase !== 'room') return switch (msg.t) { case 'signal': { let conn = this.conns.get(from) if (!conn) { // An offer can arrive before we've seen the peer's announcement. if (msg.signal?.type !== 'offer') return if (this.atCapacity()) { void this.sendToPeer(from, {t: 'room-full'}) return } conn = this.createPeer(from, false) } void conn.peer.signal(msg.signal) return } case 'room-full': { // Only honor this while we haven't gotten a foothold in the room — // once we have any connection, we're in. if (this.conns.size === 0) { this.teardown() this.notice = `That room is full — up to ${MAX_PARTICIPANTS} people can be in a room.` this.rebuildSnapshot() } return } } } // ---- relay (TURN) --------------------------------------------------------- // // Relaying costs bandwidth, so the credentials are bought with a token that // only some participants have. Rather than require the token from everyone, // whoever has one mints a short-lived ICE configuration and shares it over // the control channels; everyone else adopts it and gains relay candidates // of their own. One person's token therefore covers the whole room. // // Sharing the credential rather than the token is what makes this safe to do // over the mesh: the token never leaves the browser it was typed into, and // what does travel expires on its own and can be revoked at the Worker. // // Note the bootstrapping order. Credentials arrive over a connection, so they // cannot help the connection that carried them — a peer learns them from the // first peer it manages to reach (usually the token holder, whose relay // candidates make that first connection work even for the peer that has // none) and uses them for every connection after that. A pair that stalls in // the meantime is rebuilt by the CONNECT_RETRY_MS retry in maybeConnect, // which reads iceServers() afresh, so it picks up whatever has arrived since. /** The ICE configuration for a NEW connection. */ private iceServers(): RTCIceServer[] { const ice = this.ice if (!ice || ice.expiresAt <= Date.now()) return BASE_ICE_SERVERS // Keep the plain STUN servers alongside the relay: reflexive candidates // are what let most pairs avoid the relay altogether. return [...STUN_SERVERS, ...ice.iceServers] } private relayStatus(): RelayStatus { if (!this.ice || this.ice.expiresAt <= Date.now()) return 'off' return this.iceFromSelf ? 'self' : 'shared' } /** Buy credentials with our token and share them with the room. */ private async mintIce(seq: number, refresh = false) { let cfg: IceConfig try { // The Worker tags the credential with this for per-room usage analytics. // It is a prefix of the hashed room topic, so the room name itself is // never sent anywhere. cfg = await fetchIceConfig(this.turnToken, this.root.slice(0, 16)) } catch (err) { if (this.joinSeq !== seq) return if (refresh) { // Mid-call, and the credential we already have is still good for a few // more minutes: keep it, say nothing, and try again shortly. this.scheduleIceRefresh(seq) return } const why = err instanceof Error ? err.message : 'the request failed' const msg = `Relay unavailable — ${why}. Calls will use direct connections only, which may not work for everyone.` this.notice = this.notice ? `${this.notice} ${msg}` : msg this.rebuildSnapshot() return } if (this.joinSeq !== seq) return this.ice = cfg this.iceFromSelf = true this.scheduleIceRefresh(seq) // No-op at join time (no peers yet); this is what carries a REFRESHED // credential out to a room that is already assembled. this.broadcastControl({t: 'ice', ...cfg}) this.rebuildSnapshot() } private scheduleIceRefresh(seq: number) { if (this.iceTimer !== null) clearTimeout(this.iceTimer) const due = (this.ice?.expiresAt ?? 0) - Date.now() - ICE_REFRESH_MARGIN_MS this.iceTimer = window.setTimeout( () => { this.iceTimer = null if (this.joinSeq === seq && this.phase === 'room') { void this.mintIce(seq, true) } }, // The floor also paces retries after a failed refresh, which reschedules // itself with an expiry already in the past. Math.max(due, 60_000) ) } private sendIce(conn: Conn) { const ice = this.ice if (!ice || ice.expiresAt <= Date.now()) return conn.peer.send(JSON.stringify({t: 'ice', ...ice} satisfies ControlMsg)) } /** Take on a configuration another participant shared with us. Our own * credentials always win: they are the ones we can refresh. */ private adoptIce(cfg: IceConfig) { if (this.iceFromSelf && this.ice && this.ice.expiresAt > Date.now()) return if (this.ice && this.ice.expiresAt >= cfg.expiresAt) return // no better this.ice = cfg this.iceFromSelf = false this.rebuildSnapshot() } // ---- control channel ---------------------------------------------------- private broadcastControl(msg: ControlMsg) { const payload = JSON.stringify(msg) for (const conn of this.conns.values()) conn.peer.send(payload) } private sendHello(conn: Conn) { const settings: SettingEntry[] = [] for (const [key, meta] of Object.entries(this.settingsMeta)) { settings.push({ key, value: this.settings[key as keyof RoomSettings], rev: meta.rev, by: meta.by }) } conn.peer.send( JSON.stringify({ t: 'hello', name: this.name ?? '', audioMuted: this.audioMuted, videoMuted: this.effectiveVideoMuted(), joinedAt: this.joinedAtMs, transcribing: this.transcribing, settings } satisfies ControlMsg) ) } private handleControl(peerId: string, conn: Conn, raw: string) { if (this.conns.get(peerId) !== conn) return let msg: ControlMsg try { msg = JSON.parse(raw) } catch { return } switch (msg.t) { case 'hello': { if (typeof msg.name === 'string') conn.name = msg.name.slice(0, 40) conn.audioMuted = msg.audioMuted !== false conn.videoMuted = msg.videoMuted !== false conn.transcribing = msg.transcribing === true if (Array.isArray(msg.settings)) { for (const entry of msg.settings) this.applyRemoteSetting(entry) } if (!this.chatPresent.has(peerId)) { const name = this.presence.get(peerId)?.name ?? conn.name ?? peerId.slice(0, 8) // No join line for people who were already here when we arrived // (their self-reported join predates ours) — unless we've logged a // "left" for them before, in which case this is a return. const joinedAt = typeof msg.joinedAt === 'number' ? msg.joinedAt : 0 const preexisting = joinedAt <= this.joinedAtMs && !this.chatSeen.has(peerId) this.chatPresent.set(peerId, name) this.chatSeen.add(peerId) if (!preexisting) this.pushSystem(`${name} joined`) // Walking into a room that is already being transcribed is exactly // the case where nobody has seen the announcement, so say it here. if (conn.transcribing) { this.pushSystem(`${name} is transcribing this meeting`) } } this.rebuildSnapshot() return } case 'set': { this.applyRemoteSetting(msg) return } case 'mute': { if (typeof msg.audio !== 'boolean' || typeof msg.video !== 'boolean') { return } conn.audioMuted = msg.audio conn.videoMuted = msg.video this.rebuildSnapshot() return } case 'chat': { if (typeof msg.text !== 'string') return const text = msg.text.slice(0, CHAT_MAX_LENGTH) if (!text.trim()) return this.pushChatItem({ kind: 'chat', peerId, name: this.presence.get(peerId)?.name ?? conn.name ?? peerId.slice(0, 8), text }) this.rebuildSnapshot() return } case 'tx': { if (typeof msg.on !== 'boolean' || conn.transcribing === msg.on) return conn.transcribing = msg.on const name = this.presence.get(peerId)?.name ?? conn.name ?? peerId.slice(0, 8) this.pushSystem( msg.on ? `${name} started transcribing this meeting` : `${name} stopped transcribing` ) this.rebuildSnapshot() return } case 'ice': { // Untrusted input: a peer could send anything here, so the list is // validated down to well-formed ICE URLs before it goes near a // RTCPeerConnection. const cfg = sanitizeIceConfig(msg.iceServers, msg.expiresAt) if (cfg) this.adoptIce(cfg) return } case 'bye': { this.presence.delete(peerId) conn.peer.destroy() // its close handler removes it and rebuilds return } } } // ---- chat ------------------------------------------------------------------ private pushChatItem(item: Omit) { this.chat.push({...item, seq: this.chatSeq++, time: Date.now()}) if (this.chat.length > CHAT_LOG_CAP) { this.chat.splice(0, this.chat.length - CHAT_LOG_CAP) } } private pushSystem(text: string) { this.pushChatItem({kind: 'system', peerId: null, name: '', text}) } /** Send a chat message to everyone in the room (and our own log). */ sendChat(text: string) { if (this.phase !== 'room') return const trimmed = text.trim().slice(0, CHAT_MAX_LENGTH) if (!trimmed) return this.broadcastControl({t: 'chat', text: trimmed}) this.pushChatItem({ kind: 'chat', peerId: selfId, name: this.name ?? '', text: trimmed }) this.rebuildSnapshot() } // ---- shared room settings ------------------------------------------------ // // ONE settings object for the whole room, editable by anyone. Sync is // per-key last-writer-wins: every change bumps that key's revision and is // broadcast as {t:'set'} to every peer (the mesh is a complete graph, so no // relaying is needed). Late joiners receive the current entries in each // hello. Concurrent changes at the same revision must resolve identically // everywhere, so the SETTER with the smaller peer ID wins the tie. private setSetting( key: K, value: RoomSettings[K] ) { if (this.phase !== 'room' || this.settings[key] === value) return const rev = (this.settingsMeta[key]?.rev ?? 0) + 1 this.settingsMeta[key] = {rev, by: selfId} this.settings = {...this.settings} this.settings[key] = value this.broadcastControl({t: 'set', key, value, rev, by: selfId}) this.settingChanged(key) this.rebuildSnapshot() } private applyRemoteSetting(entry: SettingEntry) { if (typeof entry !== 'object' || entry === null) return if (typeof entry.key !== 'string' || !(entry.key in SETTING_VALIDATORS)) { return } const key = entry.key as keyof RoomSettings if (!SETTING_VALIDATORS[key](entry.value)) return if (!Number.isInteger(entry.rev) || entry.rev < 1) return if (typeof entry.by !== 'string' || entry.by.length !== 64) return const cur = this.settingsMeta[key] const curRev = cur?.rev ?? 0 if (entry.rev < curRev) return // stale if (entry.rev === curRev && cur && cur.by <= entry.by) return // tie: they lose this.settingsMeta[key] = {rev: entry.rev, by: entry.by} if (this.settings[key] !== entry.value) { this.settings = {...this.settings} this.settings[key] = entry.value this.settingChanged(key) } this.rebuildSnapshot() } /** Side effects of a setting taking a new value (local or remote). */ private settingChanged(key: keyof RoomSettings) { if (key === 'videoQuality') this.applyVideoParamsAll() } private videoParams(): VideoSendParams { const p = QUALITY_PARAMS[this.settings.videoQuality] const sharing = this.screenStream !== null return { maxBitrate: p.maxBitrate, // Downscaled screen text is unreadable: while sharing, send full // resolution and let the bitrate/framerate caps do the limiting. scaleResolutionDownBy: sharing ? undefined : p.scaleResolutionDownBy, maxFramerate: p.maxFramerate, degradationPreference: sharing ? 'maintain-resolution' : undefined } } private applyVideoParamsAll() { for (const conn of this.conns.values()) this.applyVideoParamsTo(conn) } private applyVideoParamsTo(conn: Conn) { void conn.peer.setVideoParameters(this.videoParams()).then(ok => { if (!ok) { // Right at 'connected' the encoding may not be negotiated yet. window.setTimeout( () => void conn.peer.setVideoParameters(this.videoParams()), 1500 ) } }) } // ---- mute ----------------------------------------------------------------- // // Mute is per-participant state, not a shared setting: each participant owns // its own flags and just notifies the others (the ordered channel makes // last-sent win). Toggling track.enabled sends silence/black without // renegotiation. Unmuting without a usable device retries getUserMedia and, // on success, upgrades the placeholder track in place on every connection. setAudioMuted(muted: boolean) { if (this.phase !== 'room' || !this.localStream) return if (this.audioMuted === muted) return if (!muted && !this.micAvailable) { void this.enableAudioWithRetry() return } this.audioMuted = muted for (const t of this.localStream.getAudioTracks()) t.enabled = !muted this.broadcastMuteNotice() this.rebuildSnapshot() } setVideoMuted(muted: boolean) { if (this.phase !== 'room' || !this.localStream) return if (this.videoMuted === muted) return if (!muted && !this.camAvailable) { void this.enableVideoWithRetry() return } this.videoMuted = muted for (const t of this.localStream.getVideoTracks()) t.enabled = !muted this.broadcastMuteNotice() this.rebuildSnapshot() } private async enableAudioWithRetry() { const seq = this.joinSeq let stream: MediaStream try { stream = await navigator.mediaDevices.getUserMedia({audio: true}) } catch (err) { this.notice = mediaErrorMessage('microphone', err) this.rebuildSnapshot() return } const track = stream.getAudioTracks()[0] if (!track || this.joinSeq !== seq || !this.localStream) { for (const t of stream.getTracks()) t.stop() return } const old = this.localStream.getAudioTracks()[0] ?? null for (const conn of this.conns.values()) { void conn.peer.replaceTrack('audio', track) } if (old) { this.localStream.removeTrack(old) old.stop() } this.localStream.addTrack(track) this.micAvailable = true this.audioMuted = false track.enabled = true this.broadcastMuteNotice() this.rebuildSnapshot() } private async enableVideoWithRetry() { const seq = this.joinSeq let stream: MediaStream try { stream = await navigator.mediaDevices.getUserMedia({video: true}) } catch (err) { this.notice = mediaErrorMessage('camera', err) this.rebuildSnapshot() return } const track = stream.getVideoTracks()[0] if (!track || this.joinSeq !== seq || !this.localStream) { for (const t of stream.getTracks()) t.stop() return } const old = this.localStream.getVideoTracks()[0] ?? null // While screen sharing, the connections carry the screen track; the new // camera track takes over when the share stops. if (!this.screenStream) { for (const conn of this.conns.values()) { void conn.peer.replaceTrack('video', track) } } if (old) { this.localStream.removeTrack(old) old.stop() } this.localStream.addTrack(track) this.camAvailable = true this.videoMuted = false track.enabled = true this.broadcastMuteNotice() this.rebuildSnapshot() } /** While screen sharing the outgoing video is the (always live) screen, so * a muted camera is latent until the share ends. */ private effectiveVideoMuted(): boolean { return this.videoMuted && !this.screenStream } private broadcastMuteNotice() { this.broadcastControl({ t: 'mute', audio: this.audioMuted, video: this.effectiveVideoMuted() }) } // ---- screen share --------------------------------------------------------- /** Swap the outgoing camera track for a screen capture on EVERY connection. * Everyone sees the screen in place of the camera; no renegotiation. */ async startScreenShare() { if (this.phase !== 'room' || this.screenStream) return const seq = this.joinSeq let stream: MediaStream try { stream = await navigator.mediaDevices.getDisplayMedia({video: true}) } catch { return // user canceled the picker (or capture is unsupported) } const track = stream.getVideoTracks()[0] if (!track || this.joinSeq !== seq) { for (const t of stream.getTracks()) t.stop() return } this.screenStream = stream for (const conn of this.conns.values()) { void conn.peer.replaceTrack('video', track) } this.applyVideoParamsAll() // re-derive caps for screen-share mode this.broadcastMuteNotice() // outgoing video is now the live screen // The browser's own "Stop sharing" bar ends the track; swap back then. track.onended = () => void this.stopScreenShare() this.rebuildSnapshot() } async stopScreenShare() { if (!this.screenStream) return const screen = this.screenStream this.screenStream = null const camTrack = this.localStream?.getVideoTracks()[0] if (camTrack) { for (const conn of this.conns.values()) { void conn.peer.replaceTrack('video', camTrack) } } for (const t of screen.getTracks()) t.stop() if (this.phase === 'room') { this.applyVideoParamsAll() // restore camera-mode caps this.broadcastMuteNotice() // the camera, with its mute state, is back this.rebuildSnapshot() } } // ---- transcription --------------------------------------------------------- // // Whoever has a Deepgram key can transcribe the room from their own browser, // since a mesh call already delivers everyone's audio to everyone. The key // stays in that browser: unlike the relay token, there is nothing to share, // because the transcription is done by one participant on behalf of all. // // Two things are deliberately NOT done here. The transcript is not sent to // the other participants — a transcriber relaying text attributed to other // people is text those people cannot vouch for, which is the same objection // that keeps chat history from being replayed (see the chat section). And it // is not a room setting: nobody else can turn it on or off. What IS shared is // the fact that it is running, both as a badge on the tile and as a line in // the chat, because recording people without telling them is not acceptable. async startTranscription(apiKey: string) { if (this.phase !== 'room' || this.transcribing) return const key = apiKey.trim() || this.deepgramKey if (!key) return if (!isUsableApiKey(key)) { this.notice = 'That Deepgram API key contains characters that cannot be sent in a browser connection — check for spaces or line breaks.' this.rebuildSnapshot() return } if (key !== this.deepgramKey) { this.deepgramKey = key localStorage.setItem(DEEPGRAM_KEY, key) } const store = this.transcript if (!store) return if (!this.transcriber || this.transcriber.apiKey !== key) { this.transcriber?.dispose() this.transcriber = new Transcriber( key, store, () => this.rebuildSnapshot(), (message, fatal) => this.transcriptionFailed(message, fatal) ) } const tr = this.transcriber const seq = this.joinSeq const ok = await tr.start() if (this.joinSeq !== seq || this.transcriber !== tr) { tr.dispose() return } if (!ok) { this.rebuildSnapshot() return } this.transcribing = true this.broadcastControl({t: 'tx', on: true}) this.pushSystem('You started transcribing this meeting') this.rebuildSnapshot() } stopTranscription() { if (!this.transcribing) return this.transcribing = false this.transcriber?.stop() if (this.phase === 'room') { this.broadcastControl({t: 'tx', on: false}) this.pushSystem('You stopped transcribing') } this.rebuildSnapshot() } /** Drop the stored key. The transcript already produced is kept. */ forgetDeepgramKey() { this.stopTranscription() localStorage.removeItem(DEEPGRAM_KEY) this.deepgramKey = '' this.rebuildSnapshot() } /** Discard this room's transcript, here and on disk. The panel confirms * first: unlike the chat, this is the one thing here that was being kept. */ clearTranscript() { this.transcript?.clear() this.rebuildSnapshot() } private transcriptionFailed(message: string, fatal: boolean) { this.notice = message // Unconditionally rebuild: stopTranscription is a no-op if we had already // stopped, and the notice still has to reach the screen. if (fatal) this.stopTranscription() this.rebuildSnapshot() } /** Keep the transcriber's per-speaker pipelines in step with the room. This * runs on every snapshot; the transcriber ignores sources it already has. */ private syncTranscriptionSources() { const tr = this.transcriber if (!tr?.active) return const sources: TranscriptSource[] = [ {id: selfId, name: this.name ?? 'You', stream: this.localStream} ] for (const [peerId, conn] of this.conns) { if (!conn.stream) continue sources.push({ id: peerId, name: this.presence.get(peerId)?.name ?? conn.name ?? peerId.slice(0, 8), stream: conn.stream }) } tr.setSources(sources) } // ---- public API ------------------------------------------------------- /** Change the room-wide video-quality preset. Anyone can change it; every * participant caps its own outgoing video, and the change syncs across. */ setVideoQuality(quality: VideoQuality) { this.setSetting('videoQuality', quality) } dismissNotice() { this.notice = null this.rebuildSnapshot() } getSnapshot = (): Snapshot => this.snapshot subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener) return () => this.listeners.delete(listener) } private rebuildSnapshot() { this.syncTranscriptionSources() const ids = new Set([...this.conns.keys(), ...this.presence.keys()]) const participants: ParticipantInfo[] = [...ids] .map(peerId => { const conn = this.conns.get(peerId) return { peerId, name: this.presence.get(peerId)?.name ?? conn?.name ?? peerId.slice(0, 8), connected: conn?.connected ?? false, stream: conn?.stream ?? null, audioMuted: conn?.audioMuted ?? true, videoMuted: conn?.videoMuted ?? true, transcribing: conn?.transcribing ?? false } }) .sort( (a, b) => a.name.localeCompare(b.name) || a.peerId.localeCompare(b.peerId) ) this.snapshot = { selfId, phase: this.phase, roomId: this.roomId, name: this.name, participants, audioMuted: this.audioMuted, videoMuted: this.videoMuted, micAvailable: this.micAvailable, camAvailable: this.camAvailable, localStream: this.localStream, screenStream: this.screenStream, settings: this.settings, chat: this.chat, relay: this.relayStatus(), transcribing: this.transcribing, transcript: this.transcript?.items ?? [], transcriptSeconds: this.transcript?.audioSeconds ?? 0, hasDeepgramKey: this.deepgramKey.length > 0, notice: this.notice } for (const l of this.listeners) l() } } /** A human-readable reason for a getUserMedia failure. The error name is * included so the real cause is visible — "permission denied" and "another * app is holding the camera" need entirely different fixes. */ const mediaErrorMessage = (what: string, err: unknown): string => { const rawName = (err as {name?: unknown} | null)?.name const name = typeof rawName === 'string' ? rawName : '' switch (name) { case 'NotAllowedError': case 'SecurityError': return `Access to your ${what} was blocked — check this site's permissions in your browser.` case 'NotFoundError': case 'OverconstrainedError': return `No ${what} was found on this device.` case 'NotReadableError': case 'AbortError': return `Your ${what} could not be started — it may be in use by another app or browser (${name}).` default: return `Could not access your ${what}${name ? ` (${name})` : ''}.` } } /** A tiny black video track, used as a placeholder when there is no camera. */ const blackVideoTrack = (): MediaStreamTrack => { const canvas = document.createElement('canvas') canvas.width = 320 canvas.height = 240 canvas.getContext('2d')?.fillRect(0, 0, canvas.width, canvas.height) return canvas.captureStream(2).getVideoTracks()[0] }