concept-collection / commoncall
video quality settings
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 1eb988440711 parent 558b70b Browse files
6 changed files+230−5
CLAUDE.mdmodified+10−0View file
@@ -12,6 +12,7 @@ src/p2p/
1212 nostr.ts minimal relay client + topic scheme — ported (adds event-id dedup)
1313 peer.ts WebRTC wrapper: media tracks + a control data channel
1414 network.ts the heart: presence roster + the call state machine
15+ settings.ts shared per-call settings: types, quality presets, validators
1516 src/App.tsx join form, roster, ring/accept UI, video views
1617 ```
1718
@@ -35,6 +36,15 @@ src/App.tsx join form, roster, ring/accept UI, video views
3536 replaces the camera track in place (screen instead of camera, not alongside).
3637 Same-kind replacement avoids renegotiation, which the one-offer design cannot
3738 do — never addTrack mid-call.
39+- **Shared call settings ride the control channel.** One settings object per
40+ call (reset each call), editable by either side; sync is per-key
41+ last-writer-wins via `{t:'set', key, value, rev}` — a same-rev tie resolves
42+ to the smaller peer ID's value on both sides. The video-quality presets map
43+ to `RTCRtpSender.setParameters` caps (maxBitrate / scaleResolutionDownBy /
44+ maxFramerate), which each side applies to its OWN sender — live, no
45+ renegotiation. While screen sharing, resolution downscaling is skipped
46+ (downscaled text is unreadable) and degradationPreference is
47+ maintain-resolution; caps are re-derived on every share start/stop.
3848
3949 ## Testing
4050
src/App.tsxmodified+34−0View file
@@ -1,4 +1,5 @@
11 import {useEffect, useRef, useState} from 'react'
2+import {VIDEO_QUALITIES, type VideoQuality} from './p2p/settings'
23 import {useNetwork} from './useNetwork'
34
45 const short = (id: string) => id.slice(0, 8) + '…'
@@ -227,6 +228,7 @@ export default function App() {
227228 display: 'flex',
228229 justifyContent: 'space-between',
229230 alignItems: 'center',
231+ flexWrap: 'wrap',
230232 gap: '0.5rem',
231233 marginTop: '0.5rem'
232234 }}
@@ -238,6 +240,38 @@ export default function App() {
238240 : `In a call with ${call.peerName}`
239241 : `Connecting to ${call.peerName}…`}
240242 </span>
243+ <label
244+ title="Video quality for both directions — either of you can change it"
245+ style={{
246+ display: 'flex',
247+ alignItems: 'center',
248+ gap: '0.35rem',
249+ fontSize: '0.9rem'
250+ }}
251+ >
252+ Quality
253+ <select
254+ value={call.settings.videoQuality}
255+ disabled={call.phase !== 'connected'}
256+ onChange={e =>
257+ network.setVideoQuality(e.target.value as VideoQuality)
258+ }
259+ style={{
260+ padding: '0.3rem',
261+ borderRadius: 6,
262+ border: '1px solid #888',
263+ background: '#fff',
264+ fontSize: '0.9rem',
265+ ...(call.phase !== 'connected' ? disabledStyle : null)
266+ }}
267+ >
268+ {VIDEO_QUALITIES.map(q => (
269+ <option key={q} value={q}>
270+ {q[0].toUpperCase() + q.slice(1)}
271+ </option>
272+ ))}
273+ </select>
274+ </label>
241275 {canShareScreen && (
242276 <button
243277 style={
src/p2p/network.tsmodified+100−4View file
@@ -1,6 +1,13 @@
11 import {selfId} from './identity'
22 import {Nostr, peerTopic, rootTopic} from './nostr'
33 import {Peer, type Signal} from './peer'
4+import {
5+ DEFAULT_SETTINGS,
6+ QUALITY_PARAMS,
7+ SETTING_VALIDATORS,
8+ type CallSettings,
9+ type VideoQuality
10+} from './settings'
411
512 // ---------------------------------------------------------------------------
613 // CommonCall network layer.
@@ -33,6 +40,11 @@ type PeerMsg =
3340 | {t: 'hang-up'}
3441 | {t: 'signal'; signal: Signal}
3542
43+// Messages on the in-call control data channel (WebRTC, not nostr).
44+type ControlMsg =
45+ | {t: 'hang-up'}
46+ | {t: 'set'; key: string; value: unknown; rev: number}
47+
3648 const ROOM_ID = 'default'
3749 const ANNOUNCE_INTERVAL_MS = 5000
3850 const PRESENCE_TTL_MS = 15000
@@ -57,6 +69,10 @@ interface Call {
5769 screenStream: MediaStream | null
5870 /** Signals that arrived before our getUserMedia resolved. */
5971 pendingSignals: Signal[]
72+ /** Shared settings for this call, synced over the control channel. */
73+ settings: CallSettings
74+ /** Per-key revision counters for the last-writer-wins settings sync. */
75+ settingsRevs: Partial<Record<keyof CallSettings, number>>
6076 ringInterval: number | null
6177 ringTimeout: number | null
6278 connectTimeout: number | null
@@ -75,6 +91,7 @@ export interface CallInfo {
7591 localStream: MediaStream | null
7692 remoteStream: MediaStream | null
7793 screenStream: MediaStream | null
94+ settings: CallSettings
7895 }
7996
8097 export interface Snapshot {
@@ -268,6 +285,8 @@ export class Network {
268285 remoteStream: null,
269286 screenStream: null,
270287 pendingSignals: [],
288+ settings: {...DEFAULT_SETTINGS},
289+ settingsRevs: {},
271290 ringInterval: null,
272291 ringTimeout: null,
273292 connectTimeout: null
@@ -341,14 +360,17 @@ export class Network {
341360 this.rebuildSnapshot()
342361 },
343362 data: raw => {
344- let msg: {t?: string}
363+ if (this.call !== call) return
364+ let msg: ControlMsg
345365 try {
346366 msg = JSON.parse(raw)
347367 } catch {
348368 return
349369 }
350- if (msg.t === 'hang-up' && this.call === call) {
370+ if (msg.t === 'hang-up') {
351371 this.teardown(`${call.peerName} hung up.`)
372+ } else if (msg.t === 'set') {
373+ this.applyRemoteSetting(call, msg)
352374 }
353375 },
354376 close: () => {
@@ -361,6 +383,69 @@ export class Network {
361383 this.rebuildSnapshot()
362384 }
363385
386+ // ---- shared call settings --------------------------------------------
387+ //
388+ // One settings object per call, visible and editable by BOTH parties.
389+ // Sync is per-key last-writer-wins over the control channel: every change
390+ // bumps that key's revision counter and is sent as {t:'set'}. The channel
391+ // is reliable and ordered, so divergence only happens when both sides
392+ // change the same key concurrently (same revision) — that tie must resolve
393+ // identically on both sides, so the smaller peer ID's value wins.
394+
395+ private setSetting<K extends keyof CallSettings>(
396+ key: K,
397+ value: CallSettings[K]
398+ ) {
399+ const call = this.call
400+ if (!call || !call.peer || call.settings[key] === value) return
401+ const rev = (call.settingsRevs[key] ?? 0) + 1
402+ call.settingsRevs[key] = rev
403+ call.settings = {...call.settings}
404+ call.settings[key] = value
405+ call.peer.send(JSON.stringify({t: 'set', key, value, rev}))
406+ this.settingChanged(call, key)
407+ this.rebuildSnapshot()
408+ }
409+
410+ private applyRemoteSetting(
411+ call: Call,
412+ msg: {key: string; value: unknown; rev: number}
413+ ) {
414+ if (!(msg.key in SETTING_VALIDATORS)) return
415+ const key = msg.key as keyof CallSettings
416+ if (!SETTING_VALIDATORS[key](msg.value)) return
417+ if (!Number.isInteger(msg.rev) || msg.rev < 1) return
418+ const localRev = call.settingsRevs[key] ?? 0
419+ if (msg.rev < localRev) return // stale
420+ if (msg.rev === localRev && selfId < call.peerId) return // tie: we win
421+ call.settingsRevs[key] = msg.rev
422+ if (call.settings[key] === msg.value) return
423+ call.settings = {...call.settings}
424+ call.settings[key] = msg.value
425+ this.settingChanged(call, key)
426+ this.rebuildSnapshot()
427+ }
428+
429+ /** Side effects of a setting taking a new value (local or remote). */
430+ private settingChanged(call: Call, key: keyof CallSettings) {
431+ if (key === 'videoQuality') this.applyVideoParams(call)
432+ }
433+
434+ /** Push the current quality preset into the outgoing video sender. */
435+ private applyVideoParams(call: Call) {
436+ if (!call.peer) return
437+ const p = QUALITY_PARAMS[call.settings.videoQuality]
438+ const sharing = call.screenStream !== null
439+ void call.peer.setVideoParameters({
440+ maxBitrate: p.maxBitrate,
441+ // Downscaled screen text is unreadable: while sharing, send full
442+ // resolution and let the bitrate/framerate caps do the limiting.
443+ scaleResolutionDownBy: sharing ? undefined : p.scaleResolutionDownBy,
444+ maxFramerate: p.maxFramerate,
445+ degradationPreference: sharing ? 'maintain-resolution' : undefined
446+ })
447+ }
448+
364449 private teardown(notice: string | null) {
365450 const call = this.call
366451 if (!call) return
@@ -448,6 +533,12 @@ export class Network {
448533 this.teardown(null)
449534 }
450535
536+ /** Change the shared video-quality preset. It applies to BOTH senders:
537+ * each side caps its own outgoing video, and the change syncs across. */
538+ setVideoQuality(quality: VideoQuality) {
539+ this.setSetting('videoQuality', quality)
540+ }
541+
451542 /** Swap the outgoing camera track for a screen capture. The remote side
452543 * sees the screen in place of the camera; no renegotiation involved. */
453544 async startScreenShare() {
@@ -469,6 +560,7 @@ export class Network {
469560 return
470561 }
471562 call.screenStream = stream
563+ this.applyVideoParams(call) // re-derive caps for screen-share mode
472564 // The browser's own "Stop sharing" bar ends the track; swap back then.
473565 track.onended = () => void this.stopScreenShare()
474566 this.rebuildSnapshot()
@@ -482,7 +574,10 @@ export class Network {
482574 const camTrack = call.localStream?.getVideoTracks()[0]
483575 if (call.peer && camTrack) await call.peer.replaceVideoTrack(camTrack)
484576 for (const t of screen.getTracks()) t.stop()
485- if (this.call === call) this.rebuildSnapshot()
577+ if (this.call === call) {
578+ this.applyVideoParams(call) // restore camera-mode caps
579+ this.rebuildSnapshot()
580+ }
486581 }
487582
488583 dismissNotice() {
@@ -511,7 +606,8 @@ export class Network {
511606 peerName: this.call.peerName,
512607 localStream: this.call.localStream,
513608 remoteStream: this.call.remoteStream,
514- screenStream: this.call.screenStream
609+ screenStream: this.call.screenStream,
610+ settings: this.call.settings
515611 }
516612 : null
517613 this.snapshot = {
src/p2p/peer.tsmodified+48−0View file
@@ -9,6 +9,14 @@ export type Signal =
99 | {type: 'answer'; sdp: string}
1010 | {type: 'candidate'; candidate: RTCIceCandidateInit}
1111
12+/** Caps for the outgoing video encoding; an undefined field CLEARS that cap. */
13+export interface VideoSendParams {
14+ maxBitrate?: number
15+ scaleResolutionDownBy?: number
16+ maxFramerate?: number
17+ degradationPreference?: 'balanced' | 'maintain-framerate' | 'maintain-resolution'
18+}
19+
1220 export interface PeerHandlers {
1321 signal: (signal: Signal) => void
1422 /** Connection reached the 'connected' state. */
@@ -44,6 +52,8 @@ const DISCONNECT_GRACE_MS = 5000
4452 export class Peer {
4553 private pc: RTCPeerConnection
4654 private channel: RTCDataChannel | null = null
55+ /** Control messages sent before the channel opens; flushed on open. */
56+ private outbox: string[] = []
4757 private handlers: Partial<PeerHandlers> = {}
4858 private pendingCandidates: RTCIceCandidateInit[] = []
4959 private disconnectTimer: number | null = null
@@ -104,6 +114,11 @@ export class Peer {
104114
105115 private setupChannel(channel: RTCDataChannel) {
106116 this.channel = channel
117+ const flush = () => {
118+ for (const data of this.outbox.splice(0)) channel.send(data)
119+ }
120+ if (channel.readyState === 'open') flush()
121+ else channel.onopen = flush
107122 channel.onclose = () => this.destroy()
108123 channel.onmessage = e => {
109124 if (typeof e.data === 'string') this.handlers.data?.(e.data)
@@ -169,6 +184,7 @@ export class Peer {
169184
170185 send(data: string) {
171186 if (this.channel?.readyState === 'open') this.channel.send(data)
187+ else if (!this.closed) this.outbox.push(data)
172188 }
173189
174190 /** Swap the outgoing video track in place (camera ↔ screen). A same-kind
@@ -186,6 +202,38 @@ export class Peer {
186202 }
187203 }
188204
205+ /** Cap (or uncap) the outgoing video encoding. Like replaceTrack,
206+ * setParameters applies live with no renegotiation, so it fits the
207+ * one-offer design. */
208+ async setVideoParameters(opts: VideoSendParams): Promise<boolean> {
209+ if (this.closed) return false
210+ const sender = this.pc.getSenders().find(s => s.track?.kind === 'video')
211+ if (!sender) return false
212+ const params = sender.getParameters()
213+ const enc = params.encodings[0]
214+ if (!enc) return false // no negotiated encoding yet
215+ if (opts.maxBitrate === undefined) delete enc.maxBitrate
216+ else enc.maxBitrate = opts.maxBitrate
217+ if (opts.scaleResolutionDownBy === undefined) {
218+ delete enc.scaleResolutionDownBy
219+ } else {
220+ enc.scaleResolutionDownBy = opts.scaleResolutionDownBy
221+ }
222+ if (opts.maxFramerate === undefined) delete enc.maxFramerate
223+ else enc.maxFramerate = opts.maxFramerate
224+ // Not in all TS dom typings, but supported by Chrome/Safari; harmless
225+ // where ignored.
226+ const p = params as {degradationPreference?: string}
227+ if (opts.degradationPreference === undefined) delete p.degradationPreference
228+ else p.degradationPreference = opts.degradationPreference
229+ try {
230+ await sender.setParameters(params)
231+ return true
232+ } catch {
233+ return false
234+ }
235+ }
236+
189237 get isConnected(): boolean {
190238 return this.pc.connectionState === 'connected'
191239 }
src/p2p/settings.tsadded+37−0View file
@@ -0,0 +1,37 @@
1+// Shared per-call settings. Both parties see and control ONE settings object
2+// per call (reset to defaults each call); changes sync over the call's control
3+// data channel with per-key last-writer-wins (see network.ts).
4+//
5+// To add a future setting: extend CallSettings, DEFAULT_SETTINGS, and
6+// SETTING_VALIDATORS, then handle its side effect in Network.settingChanged.
7+
8+export const VIDEO_QUALITIES = ['auto', 'high', 'medium', 'low'] as const
9+export type VideoQuality = (typeof VIDEO_QUALITIES)[number]
10+
11+export interface CallSettings {
12+ videoQuality: VideoQuality
13+}
14+
15+export const DEFAULT_SETTINGS: CallSettings = {videoQuality: 'auto'}
16+
17+/** Encoder caps for each preset, applied by EACH side to its own outgoing
18+ * video (the setting is symmetric). 'auto' clears all caps and leaves
19+ * adaptation entirely to the browser's congestion control; the others are
20+ * proactive ceilings for slow or metered links. */
21+export const QUALITY_PARAMS: Record<
22+ VideoQuality,
23+ {maxBitrate?: number; scaleResolutionDownBy?: number; maxFramerate?: number}
24+> = {
25+ auto: {},
26+ high: {maxBitrate: 2_500_000, maxFramerate: 30},
27+ medium: {maxBitrate: 800_000, scaleResolutionDownBy: 2, maxFramerate: 24},
28+ low: {maxBitrate: 200_000, scaleResolutionDownBy: 4, maxFramerate: 15}
29+}
30+
31+/** Settings arrive over the network, so every value is validated before use. */
32+export const SETTING_VALIDATORS: {
33+ [K in keyof CallSettings]: (v: unknown) => v is CallSettings[K]
34+} = {
35+ videoQuality: (v): v is VideoQuality =>
36+ (VIDEO_QUALITIES as readonly unknown[]).includes(v)
37+}
tsconfig.tsbuildinfomodified+1−1View file
@@ -1 +1 @@
1-{"root":["./src/App.tsx","./src/main.tsx","./src/useNetwork.ts","./src/p2p/identity.ts","./src/p2p/network.ts","./src/p2p/nostr.ts","./src/p2p/peer.ts"],"version":"5.9.3"}
\ No newline at end of file
1+{"root":["./src/App.tsx","./src/main.tsx","./src/useNetwork.ts","./src/p2p/identity.ts","./src/p2p/network.ts","./src/p2p/nostr.ts","./src/p2p/peer.ts","./src/p2p/settings.ts"],"version":"5.9.3"}
\ No newline at end of file