@displayxr/inline3d 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,589 @@
1
+ // call/transport.js — the `Transport` seam and its P1 implementation, a WebRTC full mesh.
2
+ //
3
+ // A Transport moves media and small reliable messages between this page and up to N−1 others. The
4
+ // call module only ever talks to this interface, so an SFU adapter (RFC §2, P3) can replace the
5
+ // mesh without the module changing:
6
+ //
7
+ // transport.start({ room, localStream, onPeer, onPeerLeft, onStream, onMessage, onPeerState })
8
+ // transport.send(peerId, msg) / broadcast(msg)
9
+ // transport.replaceVideoTrack(track) / setSendFormat(format)
10
+ // transport.peerConnection(peerId) // for getStats (null on an SFU)
11
+ // transport.stop()
12
+ //
13
+ // ── the mesh, pair by pair ───────────────────────────────────────────────────────────────────
14
+ //
15
+ // ROLES ARE DETERMINISTIC: of each pair, the peer with the lexically smaller id is the OFFERER. It
16
+ // builds the RTCPeerConnection, adds sendrecv audio + video transceivers and a negotiated data
17
+ // channel (id 0, so both ends create it without an in-band handshake), and sends the offer. The
18
+ // answerer builds its connection when the offer arrives and attaches its tracks to the offered
19
+ // transceivers. Nobody ever offers at the same time as the other side, so there is no glare to
20
+ // resolve, and every renegotiation (ICE restart, rebuild) is the offerer's job — an answerer that
21
+ // sees trouble just asks for one.
22
+ //
23
+ // GENERATIONS: every signal carries the connection generation it belongs to. A rebuild bumps it,
24
+ // and a late ICE candidate or answer from a dead connection is dropped instead of poisoning the
25
+ // new one.
26
+ //
27
+ // RECOVERY LADDER on a pair that stops working: 'disconnected' for 4 s, or 'failed' → ICE restart
28
+ // on the SAME connection (cheap: keeps the transceivers, renegotiates candidates) → if that has
29
+ // not connected within 10 s, tear down and rebuild with exponential backoff (1 s, 2 s, 4 s … 30 s,
30
+ // jittered). A peer that ALSO left the signalling room is dropped for good instead.
31
+
32
+ import { preferVideoCodecs, sortCodecCapabilities } from './sdp.js';
33
+ import { backoffMs, maxBitrateKbps, DEFAULT_MAX_PEERS, MESH_HARD_CAP } from './wire.js';
34
+
35
+ export const DEFAULT_ICE_SERVERS = Object.freeze([{ urls: 'stun:stun.l.google.com:19302' }]);
36
+ const DISCONNECTED_GRACE_MS = 4000;
37
+ const ICE_RESTART_TIMEOUT_MS = 10000;
38
+ const CONNECT_TIMEOUT_MS = 20000;
39
+ const ANSWERER_WAIT_MS = 8000;
40
+ /**
41
+ * A peer known to exist (signalling presence) with no working connection for this long is
42
+ * UNREACHABLE: almost always a network that needs a relay (TURN) — ICE sits in 'checking' or
43
+ * fails on every attempt. The state is sticky until a connection comes up; the recovery ladder
44
+ * keeps retrying underneath it the whole time.
45
+ */
46
+ export const UNREACHABLE_MS = 10000;
47
+
48
+ /**
49
+ * Clamp the requested room size to what a full mesh can carry.
50
+ * @param {number} [n]
51
+ */
52
+ export function clampMaxPeers(n) {
53
+ const v = Number.isInteger(n) ? n : DEFAULT_MAX_PEERS;
54
+ return Math.max(2, Math.min(MESH_HARD_CAP, v));
55
+ }
56
+
57
+ /** Of a pair, who offers: the lexically smaller id. Pure. */
58
+ export function isOfferer(myId, theirId) {
59
+ return myId < theirId;
60
+ }
61
+
62
+ export class MeshTransport {
63
+ /**
64
+ * @param {object} o
65
+ * @param {any} o.signaling a SignalingAdapter
66
+ * @param {string} o.id this peer's id
67
+ * @param {number} [o.maxPeers] room size INCLUDING this peer; clamped to 2..4
68
+ * @param {RTCIceServer[]} [o.iceServers] overrides what the signalling server hands out
69
+ * @param {any} [o.RTCPeerConnection] injectable for tests
70
+ * @param {(tag: string, obj: object) => void} [o.log]
71
+ */
72
+ constructor({ signaling, id, maxPeers, iceServers, RTCPeerConnection: PC, log, unreachableMs } = {}) {
73
+ this.unreachableMs = unreachableMs > 0 ? unreachableMs : UNREACHABLE_MS;
74
+ this.signaling = signaling;
75
+ this.id = id;
76
+ this.maxPeers = clampMaxPeers(maxPeers);
77
+ this.pageIce = iceServers || null;
78
+ this.iceServers = iceServers || DEFAULT_ICE_SERVERS;
79
+ this.PC = PC || globalThis.RTCPeerConnection;
80
+ this.log = log || (() => {});
81
+ /** @type {Map<string, any>} */
82
+ this.peers = new Map();
83
+ this.session = null;
84
+ this.ready = false;
85
+ this._pending = [];
86
+ this.stopped = false;
87
+ this.sendFormat = 'mono';
88
+ this.localStream = null;
89
+ }
90
+
91
+ /** Remote peers currently in the mesh (not counting this one). */
92
+ get size() {
93
+ return this.peers.size;
94
+ }
95
+
96
+ async start({ room, localStream, sendFormat, onPeer, onPeerLeft, onStream, onMessage, onPeerState, onRefused, onSignalingState, onGhost }) {
97
+ this.localStream = localStream;
98
+ this.sendFormat = sendFormat || 'mono';
99
+ this.cb = { onPeer, onPeerLeft, onStream, onMessage, onPeerState, onRefused, onSignalingState, onGhost };
100
+ // Hooks can fire before join() resolves (the PeerJS adapter discovers peers while joining);
101
+ // queue them so every one runs against a fully-initialised transport.
102
+ const later = (fn) => (...a) => (this.ready ? fn(...a) : this._pending.push(() => fn(...a)));
103
+ this.session = await this.signaling.join(room, {
104
+ id: this.id,
105
+ maxPeers: this.maxPeers,
106
+ onPeerJoined: later((pid) => this._onPeerJoined(pid)),
107
+ onPeerLeft: later((pid) => this._onPeerLeftSignaling(pid)),
108
+ onSignal: later((from, data) => this._onSignal(from, data)),
109
+ onDisconnect: (err) => onSignalingState?.(err ? 'closed' : 'reconnecting', err),
110
+ // A participant the signalling layer knows EXISTS but cannot even exchange signalling with
111
+ // (the PeerJS adapter: a taken slot whose data connection never opens). No connection can be
112
+ // attempted, so it is reported as a ghost the UI can show as unreachable.
113
+ onPeerUnreachable: later((gid) => onGhost?.(gid, true)),
114
+ onPeerReachable: later((gid) => onGhost?.(gid, false)),
115
+ onReconnect: later((ids) => {
116
+ onSignalingState?.('connected');
117
+ for (const pid of ids) this._onPeerJoined(pid);
118
+ }),
119
+ });
120
+ if (!this.pageIce && this.session.iceServers && this.session.iceServers.length) {
121
+ // Relays from the signalling adapter (short-lived TURN from dxr-signal, or PeerJS's own), plus public STUN.
122
+ this.iceServers = [...DEFAULT_ICE_SERVERS, ...this.session.iceServers];
123
+ }
124
+ // The client enforces the cap too: a server that allows more than this page asked for does
125
+ // not get to grow the mesh. The newest arrivals are the ones refused.
126
+ const existing = this.session.peers || [];
127
+ if (existing.length >= this.maxPeers) {
128
+ this.session.leave();
129
+ const e = new Error(`@displayxr/inline3d/call: this call is full (${this.maxPeers} participants)`);
130
+ /** @type {any} */ (e).code = 'room-full';
131
+ throw e;
132
+ }
133
+ this.ready = true;
134
+ for (const pid of existing) this._onPeerJoined(pid);
135
+ const q = this._pending;
136
+ this._pending = [];
137
+ for (const fn of q) fn();
138
+ return this.session;
139
+ }
140
+
141
+ // ── presence ────────────────────────────────────────────────────────────────────────────
142
+
143
+ _onPeerJoined(pid) {
144
+ if (this.stopped || pid === this.id) return;
145
+ const rec = this.peers.get(pid);
146
+ if (rec) {
147
+ rec.absent = false; // back in the signalling room (a reconnect); media may never have stopped
148
+ if (rec.state !== 'connected' && isOfferer(this.id, pid)) this._rebuild(rec, 'rejoined');
149
+ return;
150
+ }
151
+ if (this.peers.size >= this.maxPeers - 1) {
152
+ this.log('refused', { peer: pid, reason: 'maxPeers', maxPeers: this.maxPeers });
153
+ this.cb.onRefused?.(pid);
154
+ return;
155
+ }
156
+ const r = this._newRecord(pid);
157
+ this.cb.onPeer?.(pid);
158
+ if (r.offerer) this._build(r, 1);
159
+ else r.waitTimer = setTimeout(() => this._askRestart(r), ANSWERER_WAIT_MS);
160
+ }
161
+
162
+ _onPeerLeftSignaling(pid) {
163
+ const rec = this.peers.get(pid);
164
+ if (!rec) return;
165
+ rec.absent = true;
166
+ // Media still flowing peer-to-peer outlives a signalling blip; a dead pair does not.
167
+ if (rec.state !== 'connected') this._drop(rec, 'left');
168
+ }
169
+
170
+ _newRecord(pid) {
171
+ const rec = {
172
+ id: pid,
173
+ offerer: isOfferer(this.id, pid),
174
+ pc: null,
175
+ builtAt: 0,
176
+ offerAt: 0,
177
+ dc: null,
178
+ gen: 0,
179
+ state: 'new',
180
+ stream: null,
181
+ attempts: 0,
182
+ iceRestarted: false,
183
+ absent: false,
184
+ pendingCandidates: [],
185
+ timers: new Set(),
186
+ waitTimer: null,
187
+ unreachable: false,
188
+ unreachTimer: null,
189
+ };
190
+ this.peers.set(pid, rec);
191
+ this._armUnreachable(rec);
192
+ return rec;
193
+ }
194
+
195
+ // ── connection lifecycle ────────────────────────────────────────────────────────────────
196
+
197
+ _build(rec, gen) {
198
+ this._closePc(rec);
199
+ rec.gen = gen;
200
+ rec.builtAt = Date.now();
201
+ rec.iceRestarted = false;
202
+ rec.pendingCandidates = [];
203
+ rec.stream = null; // a new connection's tracks arrive in a new stream (a fresh srcObject)
204
+ const pc = new this.PC({ iceServers: this.iceServers });
205
+ rec.pc = pc;
206
+ const dc = pc.createDataChannel('dxr-call', { negotiated: true, id: 0, ordered: true });
207
+ this._wireDc(rec, dc);
208
+ if (rec.offerer) {
209
+ const tracks = this.localStream ? this.localStream.getTracks() : [];
210
+ const audio = tracks.find((t) => t.kind === 'audio');
211
+ const video = tracks.find((t) => t.kind === 'video');
212
+ const streams = this.localStream ? [this.localStream] : [];
213
+ pc.addTransceiver(audio || 'audio', { direction: 'sendrecv', streams });
214
+ const vt = pc.addTransceiver(video || 'video', { direction: 'sendrecv', streams });
215
+ this._preferCodecs(vt);
216
+ }
217
+ pc.onicecandidate = (e) => {
218
+ if (e.candidate) this._signal(rec, { kind: 'candidate', candidate: e.candidate.toJSON ? e.candidate.toJSON() : e.candidate });
219
+ };
220
+ pc.ontrack = (e) => {
221
+ if (!rec.stream) rec.stream = new MediaStream();
222
+ if (!rec.stream.getTracks().includes(e.track)) {
223
+ for (const t of rec.stream.getTracks()) if (t.kind === e.track.kind) rec.stream.removeTrack(t);
224
+ rec.stream.addTrack(e.track);
225
+ }
226
+ this.cb.onStream?.(rec.id, rec.stream);
227
+ };
228
+ pc.onconnectionstatechange = () => this._onConnState(rec, pc);
229
+ this._arm(rec, CONNECT_TIMEOUT_MS, () => {
230
+ if (rec.pc === pc && pc.connectionState !== 'connected') this._fail(rec, 'connect-timeout');
231
+ });
232
+ if (rec.offerer) this._offer(rec).catch((err) => this.log('offer-error', { peer: rec.id, err: String(err) }));
233
+ this._setState(rec, rec.attempts > 0 ? 'reconnecting' : 'connecting');
234
+ return pc;
235
+ }
236
+
237
+ async _offer(rec, { iceRestart = false } = {}) {
238
+ const pc = rec.pc;
239
+ const offer = await pc.createOffer(iceRestart ? { iceRestart: true } : undefined);
240
+ if (!this._canPreferCodecs) offer.sdp = preferVideoCodecs(offer.sdp);
241
+ await pc.setLocalDescription(offer);
242
+ this._signal(rec, { kind: 'offer', sdp: pc.localDescription.sdp });
243
+ }
244
+
245
+ async _onSignal(from, data) {
246
+ if (this.stopped || !data || typeof data !== 'object') return;
247
+ let rec = this.peers.get(from);
248
+ if (!rec) {
249
+ // An offer from someone we have not seen join (the join event can trail the offer).
250
+ if (data.kind !== 'offer') return;
251
+ this._onPeerJoined(from);
252
+ rec = this.peers.get(from);
253
+ if (!rec) return; // refused by the cap
254
+ }
255
+ const gen = data.gen | 0;
256
+ try {
257
+ switch (data.kind) {
258
+ case 'offer': {
259
+ if (rec.offerer) return; // never happens with deterministic roles; ignore rather than glare
260
+ clearTimeout(rec.waitTimer);
261
+ if (gen < rec.gen) return;
262
+ rec.offerAt = Date.now();
263
+ if (gen > rec.gen || !rec.pc) this._build(rec, gen);
264
+ const pc = rec.pc;
265
+ await pc.setRemoteDescription({ type: 'offer', sdp: data.sdp });
266
+ this._attachAnswererTracks(rec);
267
+ const answer = await pc.createAnswer();
268
+ if (!this._canPreferCodecs) answer.sdp = preferVideoCodecs(answer.sdp);
269
+ await pc.setLocalDescription(answer);
270
+ this._signal(rec, { kind: 'answer', sdp: pc.localDescription.sdp });
271
+ this._flushCandidates(rec);
272
+ break;
273
+ }
274
+ case 'answer':
275
+ if (!rec.offerer || gen !== rec.gen || !rec.pc || rec.pc.signalingState !== 'have-local-offer') return;
276
+ await rec.pc.setRemoteDescription({ type: 'answer', sdp: data.sdp });
277
+ this._flushCandidates(rec);
278
+ break;
279
+ case 'candidate':
280
+ if (gen !== rec.gen || !rec.pc) return;
281
+ if (rec.pc.remoteDescription) await rec.pc.addIceCandidate(data.candidate).catch(() => {});
282
+ else rec.pendingCandidates.push(data.candidate);
283
+ break;
284
+ case 'restart':
285
+ // The answerer lost (or never got) its connection, and only it can know: this side's
286
+ // own view can read 'connected' for ~10 s after the far end vanished (until ICE consent
287
+ // fails). So the ask is authoritative — rebuild now; an ICE restart cannot help a peer
288
+ // whose connection is gone. The one exception is a fresh build already in flight.
289
+ if (!rec.offerer) return;
290
+ if (rec.pc && rec.state !== 'connected' && Date.now() - rec.builtAt < 5000) return;
291
+ this._rebuild(rec, 'peer-asked');
292
+ break;
293
+ case 'bye':
294
+ this._drop(rec, 'left');
295
+ break;
296
+ }
297
+ } catch (err) {
298
+ this.log('signal-error', { peer: from, kind: data.kind, err: String(err) });
299
+ }
300
+ }
301
+
302
+ _flushCandidates(rec) {
303
+ const list = rec.pendingCandidates;
304
+ rec.pendingCandidates = [];
305
+ for (const c of list) rec.pc.addIceCandidate(c).catch(() => {});
306
+ }
307
+
308
+ /** Answerer side: put our tracks on the transceivers the offer created, and make them sendrecv. */
309
+ _attachAnswererTracks(rec) {
310
+ const pc = rec.pc;
311
+ const tracks = this.localStream ? this.localStream.getTracks() : [];
312
+ for (const tr of pc.getTransceivers()) {
313
+ const kind = tr.receiver && tr.receiver.track ? tr.receiver.track.kind : null;
314
+ if (!kind || tr.sender.track) continue;
315
+ const t = tracks.find((x) => x.kind === kind);
316
+ if (t) {
317
+ tr.sender.replaceTrack(t).catch(() => {});
318
+ if (this.localStream && tr.sender.setStreams) tr.sender.setStreams(this.localStream);
319
+ }
320
+ tr.direction = 'sendrecv';
321
+ if (kind === 'video') this._preferCodecs(tr);
322
+ }
323
+ }
324
+
325
+ _preferCodecs(transceiver) {
326
+ const caps = globalThis.RTCRtpReceiver && RTCRtpReceiver.getCapabilities && RTCRtpReceiver.getCapabilities('video');
327
+ this._canPreferCodecs = !!(transceiver && transceiver.setCodecPreferences && caps);
328
+ if (!this._canPreferCodecs) return;
329
+ try {
330
+ transceiver.setCodecPreferences(sortCodecCapabilities(caps.codecs));
331
+ } catch {
332
+ this._canPreferCodecs = false; // fall back to munging the SDP
333
+ }
334
+ }
335
+
336
+ _onConnState(rec, pc) {
337
+ if (rec.pc !== pc) return;
338
+ const s = pc.connectionState;
339
+ this.log('pc-state', { peer: rec.id, state: s, gen: rec.gen });
340
+ if (s === 'connected') {
341
+ this._clearTimers(rec);
342
+ rec.attempts = 0;
343
+ rec.iceRestarted = false;
344
+ this._setState(rec, 'connected');
345
+ this.tuneSenders();
346
+ } else if (s === 'disconnected') {
347
+ this._setState(rec, 'reconnecting');
348
+ this._arm(rec, DISCONNECTED_GRACE_MS, () => {
349
+ if (rec.pc === pc && pc.connectionState !== 'connected') this._fail(rec, 'disconnected');
350
+ });
351
+ } else if (s === 'failed') {
352
+ this._fail(rec, 'failed');
353
+ } else if (s === 'closed' && !this.stopped && this.peers.get(rec.id) === rec) {
354
+ this._fail(rec, 'closed');
355
+ }
356
+ }
357
+
358
+ /** The recovery ladder: ICE restart first, then a rebuild with backoff (offerer only). */
359
+ _fail(rec, why) {
360
+ if (this.stopped || this.peers.get(rec.id) !== rec) return;
361
+ if (rec.absent) return this._drop(rec, 'left');
362
+ this._setState(rec, 'reconnecting');
363
+ if (!rec.offerer) return this._askRestart(rec);
364
+ const pc = rec.pc;
365
+ if (pc && !rec.iceRestarted && pc.connectionState !== 'closed' && pc.signalingState !== 'closed') {
366
+ rec.iceRestarted = true;
367
+ this.log('ice-restart', { peer: rec.id, why, gen: rec.gen });
368
+ this._offer(rec, { iceRestart: true }).catch(() => this._rebuild(rec, 'ice-restart-failed'));
369
+ this._arm(rec, ICE_RESTART_TIMEOUT_MS, () => {
370
+ if (rec.pc === pc && pc.connectionState !== 'connected') this._rebuild(rec, 'ice-restart-timeout');
371
+ });
372
+ return;
373
+ }
374
+ this._rebuild(rec, why);
375
+ }
376
+
377
+ _rebuild(rec, why) {
378
+ if (this.stopped || this.peers.get(rec.id) !== rec) return;
379
+ this._clearTimers(rec);
380
+ this._closePc(rec);
381
+ this._setState(rec, 'reconnecting');
382
+ const delay = backoffMs(rec.attempts++);
383
+ this.log('redial', { peer: rec.id, why, attempt: rec.attempts, inMs: delay });
384
+ this._arm(rec, delay, () => this._build(rec, rec.gen + 1));
385
+ }
386
+
387
+ _askRestart(rec) {
388
+ if (this.stopped || this.peers.get(rec.id) !== rec || rec.offerer) return;
389
+ const askedAt = Date.now();
390
+ this._signal(rec, { kind: 'restart' });
391
+ // Ask again, backing off, only while no offer has answered the last ask — a connection the
392
+ // offerer is already rebuilding must be given its chance (its own timeout covers it).
393
+ this._arm(rec, Math.max(3000, backoffMs(rec.attempts++)), () => {
394
+ if (rec.state !== 'connected' && !(rec.offerAt >= askedAt)) this._askRestart(rec);
395
+ });
396
+ }
397
+
398
+ _drop(rec, reason) {
399
+ if (this.peers.get(rec.id) !== rec) return;
400
+ this._clearTimers(rec);
401
+ clearTimeout(rec.unreachTimer);
402
+ clearTimeout(rec.waitTimer);
403
+ this._closePc(rec);
404
+ this.peers.delete(rec.id);
405
+ this._setState(rec, 'left');
406
+ this.cb.onPeerLeft?.(rec.id, reason);
407
+ this.tuneSenders();
408
+ }
409
+
410
+ _closePc(rec) {
411
+ if (rec.dc) {
412
+ try {
413
+ rec.dc.close();
414
+ } catch {
415
+ /* ignore */
416
+ }
417
+ rec.dc = null;
418
+ }
419
+ if (rec.pc) {
420
+ const pc = rec.pc;
421
+ rec.pc = null; // before close(): its 'closed' state change must not re-enter _fail
422
+ try {
423
+ pc.close();
424
+ } catch {
425
+ /* ignore */
426
+ }
427
+ }
428
+ }
429
+
430
+ _arm(rec, ms, fn) {
431
+ const t = setTimeout(() => {
432
+ rec.timers.delete(t);
433
+ fn();
434
+ }, ms);
435
+ rec.timers.add(t);
436
+ }
437
+
438
+ _clearTimers(rec) {
439
+ for (const t of rec.timers) clearTimeout(t);
440
+ rec.timers.clear();
441
+ }
442
+
443
+ /** Start the unreachable clock (idempotent); a connection clears it (see _setState). */
444
+ _armUnreachable(rec) {
445
+ if (rec.unreachTimer || rec.unreachable) return;
446
+ rec.unreachTimer = setTimeout(() => {
447
+ rec.unreachTimer = null;
448
+ if (this.stopped || this.peers.get(rec.id) !== rec || rec.state === 'connected') return;
449
+ rec.unreachable = true;
450
+ this.log('unreachable', { peer: rec.id, afterMs: this.unreachableMs, pc: rec.pc ? rec.pc.iceConnectionState : null });
451
+ this._setState(rec, 'unreachable');
452
+ }, this.unreachableMs);
453
+ }
454
+
455
+ _setState(rec, state) {
456
+ if (state === 'connected') {
457
+ clearTimeout(rec.unreachTimer);
458
+ rec.unreachTimer = null;
459
+ rec.unreachable = false;
460
+ } else if (state === 'connecting' || state === 'reconnecting') {
461
+ // Sticky: retries continue underneath, but the page keeps being told the truth.
462
+ if (rec.unreachable) state = 'unreachable';
463
+ else this._armUnreachable(rec);
464
+ }
465
+ if (rec.state === state) return;
466
+ rec.state = state;
467
+ this.cb.onPeerState?.(rec.id, state);
468
+ }
469
+
470
+ _signal(rec, data) {
471
+ this.session?.send(rec.id, { ...data, gen: rec.gen });
472
+ }
473
+
474
+ // ── data channel ────────────────────────────────────────────────────────────────────────
475
+
476
+ _wireDc(rec, dc) {
477
+ rec.dc = dc;
478
+ dc.onopen = () => this.cb.onMessage?.(rec.id, { type: '__open' });
479
+ dc.onmessage = (e) => {
480
+ let msg;
481
+ try {
482
+ msg = JSON.parse(e.data);
483
+ } catch {
484
+ return;
485
+ }
486
+ if (msg && msg.type === 'bye') return this._drop(rec, 'left');
487
+ this.cb.onMessage?.(rec.id, msg);
488
+ };
489
+ }
490
+
491
+ send(pid, msg) {
492
+ const rec = this.peers.get(pid);
493
+ if (rec && rec.dc && rec.dc.readyState === 'open') {
494
+ rec.dc.send(JSON.stringify(msg));
495
+ return true;
496
+ }
497
+ return false;
498
+ }
499
+
500
+ broadcast(msg) {
501
+ for (const pid of this.peers.keys()) this.send(pid, msg);
502
+ }
503
+
504
+ peerConnection(pid) {
505
+ const rec = this.peers.get(pid);
506
+ return rec ? rec.pc : null;
507
+ }
508
+
509
+ peerState(pid) {
510
+ const rec = this.peers.get(pid);
511
+ return rec ? rec.state : null;
512
+ }
513
+
514
+ // ── sending ─────────────────────────────────────────────────────────────────────────────
515
+
516
+ /** Swap the outgoing video (setCamera). No renegotiation: replaceTrack on every sender. */
517
+ async replaceTrack(kind, track, stream) {
518
+ if (stream) this.localStream = stream;
519
+ for (const rec of this.peers.values()) {
520
+ if (!rec.pc) continue;
521
+ for (const tr of rec.pc.getTransceivers()) {
522
+ const k = tr.receiver && tr.receiver.track ? tr.receiver.track.kind : null;
523
+ if (k === kind) await tr.sender.replaceTrack(track).catch(() => {});
524
+ }
525
+ }
526
+ await this.tuneSenders();
527
+ }
528
+
529
+ setSendFormat(format) {
530
+ this.sendFormat = format;
531
+ return this.tuneSenders();
532
+ }
533
+
534
+ /**
535
+ * Hold resolution, drop frame rate under pressure (a halved SBS width halves each eye), and
536
+ * share the uplink across the mesh: `maxBitrate` falls as peers join.
537
+ */
538
+ async tuneSenders() {
539
+ const n = this.peers.size;
540
+ const kbps = maxBitrateKbps(this.sendFormat, n);
541
+ for (const rec of this.peers.values()) {
542
+ if (!rec.pc) continue;
543
+ for (const s of rec.pc.getSenders()) {
544
+ if (!s.track || s.track.kind !== 'video') continue;
545
+ try {
546
+ const p = s.getParameters();
547
+ p.degradationPreference = 'maintain-resolution';
548
+ if (!p.encodings || !p.encodings.length) p.encodings = [{}];
549
+ p.encodings[0].maxBitrate = kbps * 1000;
550
+ await s.setParameters(p);
551
+ } catch (err) {
552
+ this.log('setParameters-error', { peer: rec.id, err: String(err) });
553
+ }
554
+ }
555
+ }
556
+ return kbps;
557
+ }
558
+
559
+ // ── teardown ────────────────────────────────────────────────────────────────────────────
560
+
561
+ stop() {
562
+ if (this.stopped) return;
563
+ this.broadcast({ type: 'bye' });
564
+ this.stopped = true;
565
+ for (const rec of this.peers.values()) {
566
+ this._clearTimers(rec);
567
+ clearTimeout(rec.waitTimer);
568
+ clearTimeout(rec.unreachTimer);
569
+ this._signal(rec, { kind: 'bye' });
570
+ this._closePc(rec);
571
+ }
572
+ this.peers.clear();
573
+ try {
574
+ this.session?.leave();
575
+ } catch {
576
+ /* ignore */
577
+ }
578
+ }
579
+
580
+ /** Test/diagnostic hook: kill one pair's connection as a network failure would. */
581
+ _debugKill(pid) {
582
+ const rec = this.peers.get(pid);
583
+ if (!rec || !rec.pc) return false;
584
+ // close() fires no connectionstatechange, so walk the ladder by hand, exactly as 'failed' would.
585
+ this._closePc(rec);
586
+ this._fail(rec, 'debug-kill');
587
+ return true;
588
+ }
589
+ }