@tribe-nest/media-client 0.2.1 → 0.4.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.
Files changed (40) hide show
  1. package/build/core/index.d.ts +1 -1
  2. package/build/core/index.d.ts.map +1 -1
  3. package/build/core/index.js.map +1 -1
  4. package/build/core/reconnect.d.ts +29 -0
  5. package/build/core/reconnect.d.ts.map +1 -1
  6. package/build/core/reconnect.js +49 -9
  7. package/build/core/reconnect.js.map +1 -1
  8. package/build/core/signal.d.ts +9 -0
  9. package/build/core/signal.d.ts.map +1 -1
  10. package/build/core/signal.js +18 -2
  11. package/build/core/signal.js.map +1 -1
  12. package/build/core/state.d.ts +28 -0
  13. package/build/core/state.d.ts.map +1 -1
  14. package/build/core/state.js +106 -7
  15. package/build/core/state.js.map +1 -1
  16. package/build/index.d.ts +1 -1
  17. package/build/index.d.ts.map +1 -1
  18. package/build/index.js.map +1 -1
  19. package/build/react/index.d.ts +63 -11
  20. package/build/react/index.d.ts.map +1 -1
  21. package/build/react/index.js +229 -5
  22. package/build/react/index.js.map +1 -1
  23. package/build/room/device.d.ts +6 -0
  24. package/build/room/device.d.ts.map +1 -1
  25. package/build/room/room.d.ts +235 -1
  26. package/build/room/room.d.ts.map +1 -1
  27. package/build/room/room.js +627 -24
  28. package/build/room/room.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/core/_tests/reconnect.spec.ts +92 -0
  31. package/src/core/_tests/state.spec.ts +92 -0
  32. package/src/core/index.ts +2 -0
  33. package/src/core/reconnect.ts +76 -9
  34. package/src/core/signal.ts +16 -2
  35. package/src/core/state.ts +153 -10
  36. package/src/index.ts +2 -0
  37. package/src/react/index.tsx +261 -7
  38. package/src/room/_tests/room.spec.ts +720 -3
  39. package/src/room/device.ts +6 -0
  40. package/src/room/room.ts +733 -25
package/src/room/room.ts CHANGED
@@ -145,13 +145,112 @@ export type PublishOptions = {
145
145
  * whatever resolution the caller is publishing. The rids are the conventional
146
146
  * one-letter names every SFU and every browser log uses, which is worth more
147
147
  * than a more descriptive name nobody would recognise in a WebRTC dump.
148
+ *
149
+ * `L1T3` (three temporal layers per encoding), not `L1T1`, and the difference
150
+ * decides whether anyone ever LEAVES the quarter layer. The server's
151
+ * congestion controller only steps a consumer up when the estimate has room
152
+ * for the next step, and with T1 the only step that exists is a whole
153
+ * spatial layer's full bitrate - a cliff the estimate must clear in one
154
+ * probe. Temporal layers cut each spatial step into thirds, which is the
155
+ * conventional simulcast shape for exactly this reason. Confirmed in the
156
+ * field by the health panel: a big tile asking `f` and being held at `h`
157
+ * for a whole call.
148
158
  */
149
159
  const SIMULCAST_LAYERS = [
150
- { rid: "q", scaleResolutionDownBy: 4, scalabilityMode: "L1T1" },
151
- { rid: "h", scaleResolutionDownBy: 2, scalabilityMode: "L1T1" },
152
- { rid: "f", scaleResolutionDownBy: 1, scalabilityMode: "L1T1" },
160
+ { rid: "q", scaleResolutionDownBy: 4, scalabilityMode: "L1T3" },
161
+ { rid: "h", scaleResolutionDownBy: 2, scalabilityMode: "L1T3" },
162
+ { rid: "f", scaleResolutionDownBy: 1, scalabilityMode: "L1T3" },
153
163
  ] as const;
154
164
 
165
+ /**
166
+ * Which simulcast layer a tile of `widthPx` deserves: 0 = q, 1 = h, 2 = f.
167
+ *
168
+ * The policy lives HERE, not on the node, because the tile width is a fact
169
+ * only the renderer has; the node obeys. Buckets, not a formula, so a resize
170
+ * only crosses a boundary occasionally and a boundary crossing is the only
171
+ * thing that costs a request. Screen shares always get the full layer:
172
+ * downscaled text is unreadable, and shares are rarely simulcast anyway.
173
+ */
174
+ export function spatialLayerForWidth(widthPx: number, source?: string): 0 | 1 | 2 {
175
+ if (source === "screen") return 2;
176
+ if (widthPx <= 240) return 0;
177
+ if (widthPx <= 480) return 1;
178
+ return 2;
179
+ }
180
+
181
+ /** One tile the UI is currently rendering, reported through `setViewport`. */
182
+ export type ViewportEntry = {
183
+ producerId: string;
184
+ /** Rendered width, used to pick a simulcast layer. 0 is a valid "tiny". */
185
+ widthPx: number;
186
+ };
187
+
188
+ /** One rendered tile's health, local view merged with the node's answer. */
189
+ export type TileDiagnostics = {
190
+ producerId: string;
191
+ identity: string;
192
+ kind: "audio" | "video";
193
+ source?: string;
194
+ /** The simulcast layer this client last ASKED for: 0 q, 1 h, 2 f. */
195
+ requestedLayer?: number;
196
+ /** What the node is actually GIVING, from its consumer's currentLayers. */
197
+ currentLayer?: number;
198
+ viewportWidthPx?: number;
199
+ pausedByViewport: boolean;
200
+ /** Decoded picture, from the browser's inbound-rtp stats. */
201
+ frameWidth?: number;
202
+ frameHeight?: number;
203
+ framesPerSecond?: number;
204
+ /** Delta-computed between successive `getDiagnostics` calls; absent on the first. */
205
+ bitrateKbps?: number;
206
+ };
207
+
208
+ /** The node's half of `getDiagnostics`, absent against an older node. */
209
+ export type NodeDiagnostics = {
210
+ transports: { transportId: string; availableOutgoingBitrate?: number; iceState?: string; dtlsState?: string }[];
211
+ producers: {
212
+ producerId: string;
213
+ kind: string;
214
+ paused: boolean;
215
+ source?: string;
216
+ layers: { rid?: string; score: number }[];
217
+ }[];
218
+ consumers: {
219
+ consumerId: string;
220
+ producerId: string;
221
+ kind: string;
222
+ paused: boolean;
223
+ preferredLayers?: { spatialLayer: number; temporalLayer?: number };
224
+ currentLayers?: { spatialLayer: number; temporalLayer?: number };
225
+ score?: unknown;
226
+ }[];
227
+ };
228
+
229
+ export type CallDiagnostics = {
230
+ tiles: TileDiagnostics[];
231
+ /**
232
+ * The recv transport's congestion estimate in bits per second, from the
233
+ * node. THE number to look at first: every video consumer shares it, and
234
+ * an estimate sitting at the low hundreds of kbps in a group call is the
235
+ * estimator pinning everyone to the quarter layer, whatever anyone asked.
236
+ */
237
+ availableOutgoingBitrate?: number;
238
+ /** This participant's own publishers, per-encoding arrival scores included:
239
+ * a layer scored 0 is a layer the node is not receiving. */
240
+ localProducers?: NodeDiagnostics["producers"];
241
+ /** The raw node answer, for anything the shaped fields above leave out. */
242
+ node?: NodeDiagnostics;
243
+ };
244
+
245
+ /** How an incoming ephemeral broadcast reaches the application. */
246
+ export type BroadcastEvent = {
247
+ type: string;
248
+ identity: string;
249
+ data: Record<string, unknown>;
250
+ /** Origin node clock. Animation ordering only. */
251
+ at: number;
252
+ };
253
+
155
254
  /**
156
255
  * What each layer may spend, given the caller's cap for the top one.
157
256
  *
@@ -176,6 +275,9 @@ export type LocalPublication = {
176
275
  source: PublishSourceLabel;
177
276
  track: MediaStreamTrack;
178
277
  handle: MediaProducerHandle;
278
+ /** As given to `publish`, kept so a planned server move can republish the
279
+ * same encoding (codec, simulcast, bitrate) without asking anyone. */
280
+ options: PublishOptions;
179
281
  /**
180
282
  * Paused at the source by `setPaused`. The capture is still open and the
181
283
  * producer still exists; nothing is being sent. This is what a mute is.
@@ -205,11 +307,25 @@ export type MediaRoomOptions = {
205
307
  webSocket?: MediaWebSocketFactory;
206
308
  /**
207
309
  * How hard to try to get back in after a drop. Defaults to
208
- * `DEFAULT_RECONNECT_OPTIONS`; `{ maxAttempts: 0 }` turns automatic recovery
310
+ * `DEFAULT_RECONNECT_OPTIONS`; `{ maxAttempts: 0 }` turns FAILURE recovery
209
311
  * off, which leaves `isRecovering` false and is what tells a UI to offer a
210
312
  * control instead of a spinner.
313
+ *
314
+ * A server-ordered drain MOVE is deliberately not a failure and happens
315
+ * even at `maxAttempts: 0`: the node asked to be left and named where to
316
+ * go, and honoring that is what keeps the call alive through a deploy.
317
+ * If the move's rejoin then FAILS, the off switch applies to every retry
318
+ * after it.
211
319
  */
212
320
  reconnect?: Partial<ReconnectOptions>;
321
+ /**
322
+ * How long a video consumer that fell out of the viewport stays PAUSED
323
+ * before it is closed. Pause is cheap and reversible (a page flip back
324
+ * costs a resume, not a consume plus a keyframe); close frees the node's
325
+ * consumer slot and eventually the mirror and pipe. Mirrors the
326
+ * `mirrorGraceMs` reasoning on the node.
327
+ */
328
+ viewportCloseGraceMs?: number;
213
329
  onLog?: (level: SignalLogLevel, message: string, detail?: unknown) => void;
214
330
  };
215
331
 
@@ -237,16 +353,51 @@ export class MediaRoom {
237
353
  /** Per publication: stop listening for the track ending on its own. */
238
354
  private readonly trackEndWatchers = new Map<string, () => void>();
239
355
  private readonly listeners = new Set<Listener>();
356
+ private readonly broadcastListeners = new Set<(event: BroadcastEvent) => void>();
240
357
  /** One in-flight subscribe per producer, so a burst of activeSpeakers frames
241
358
  * does not race itself into two consumers for one producer. */
242
359
  private readonly subscribing = new Map<string, Promise<void>>();
243
360
 
361
+ /**
362
+ * What the UI says it is rendering. `null` (never set, or cleared) is the
363
+ * legacy behavior: subscribe the whole active set, which is what every
364
+ * headless consumer (egress, harness, agents) keeps. `[]` is a hidden tab:
365
+ * zero video, audio untouched.
366
+ */
367
+ private viewport: readonly ViewportEntry[] | null = null;
368
+ private viewportTimer: ReturnType<typeof setTimeout> | undefined;
369
+ private pendingViewport: readonly ViewportEntry[] | null | undefined;
370
+ /** Video consumers paused because their tile is off-viewport, each holding
371
+ * its close timer. Single owner of the pause/close decision. */
372
+ private readonly pausedByViewport = new Map<string, ReturnType<typeof setTimeout>>();
373
+ /** The last spatial layer requested per producer, so a resize storm only
374
+ * speaks up when a bucket boundary is crossed. */
375
+ private readonly sentLayer = new Map<string, number>();
376
+ /** Old-node degradation: each new verb is disabled on its first
377
+ * `bad_request`, never retried into the frame budget. */
378
+ private readonly unsupported = new Set<string>();
379
+
244
380
  private stateValue: RoomState = initialRoomState;
245
381
  private connection: ConnectionState = "idle";
246
382
  private lastError: DisconnectCause | undefined;
247
383
 
248
384
  /** Consecutive failed attempts since the last successful join. */
249
385
  private reconnectAttempt = 0;
386
+ /** When the current OUTAGE began, for the reconnect time budget. Null while
387
+ * connected. The budget, not the attempt count, is what decides when a
388
+ * room with a person in front of it stops trying. */
389
+ private outageStartedAt: number | null = null;
390
+ /**
391
+ * The planned-move machine. `scheduled` is the jittered wait after a
392
+ * `draining` frame, during which the call continues untouched; `moving`
393
+ * holds the STASH: the live capture tracks that survive the reconnect and
394
+ * are republished on the far side, which is what makes a drain smooth
395
+ * instead of a mute-everything drop.
396
+ */
397
+ private migration:
398
+ | { phase: "scheduled"; timer: ReturnType<typeof setTimeout> }
399
+ | { phase: "moving"; stash: readonly StashedPublication[]; plannedClose: boolean }
400
+ | null = null;
250
401
  private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
251
402
  private recovering = false;
252
403
  /** `close()` has been called. Nothing may open a socket after that. */
@@ -269,6 +420,68 @@ export class MediaRoom {
269
420
  * it ended after an hour or before a socket was ever opened.
270
421
  */
271
422
  private onDisconnect(cause: DisconnectCause): void {
423
+ /**
424
+ * A PLANNED move's own close arrives here first. The signal stamps every
425
+ * client-initiated close `closed_by_client`, which without this flag
426
+ * would route a deliberate server move to the "call over" state; the
427
+ * flag was set before the close, which is what makes it trustworthy.
428
+ */
429
+ if (this.migration?.phase === "moving" && this.migration.plannedClose && !this.disposed) {
430
+ // Stashed HERE, at the instant the connection ends: everything the
431
+ // person did during the leave round trip (mute, unmute, a new share)
432
+ // is in the publications map now and nowhere else.
433
+ this.migration = { phase: "moving", stash: this.buildStash(), plannedClose: false };
434
+ this.lastError = { type: "draining", reconnectAfterMs: 0 };
435
+ this.teardownMedia({ preserveCaptures: true });
436
+ this.clearActiveSetState();
437
+ this.connection = "reconnecting";
438
+ this.recovering = true;
439
+ this.emit();
440
+ // The first attempt of a planned move is NOT a failure: no ladder, no
441
+ // attempt count, straight back through MEDIA_URL with a fresh ticket.
442
+ // Booked on a zero timer rather than called here: this handler runs
443
+ // inside the signal's own close loop, and a connect() started inside
444
+ // it registers its close watcher into the very Set being iterated, so
445
+ // the OLD socket's close would count as the NEW attempt's report. The
446
+ // timer also makes `close()` cancellation uniform.
447
+ this.reconnectTimer = setTimeout(() => {
448
+ this.reconnectTimer = undefined;
449
+ if (this.disposed) return;
450
+ void this.connect().catch(() => undefined);
451
+ }, 0);
452
+ return;
453
+ }
454
+
455
+ /**
456
+ * The server cut us at the drain deadline (or won the race with our own
457
+ * scheduled move). Same smooth path: stash the captures BEFORE teardown
458
+ * and honor the server's jittered hint before the first attempt, so a
459
+ * whole node's population does not land on the replacement in one burst.
460
+ */
461
+ if (cause.type === "draining" && !this.disposed && this.migration?.phase !== "moving") {
462
+ if (this.migration?.phase === "scheduled") clearTimeout(this.migration.timer);
463
+ const stash = this.buildStash();
464
+ this.migration = { phase: "moving", stash, plannedClose: false };
465
+ this.lastError = cause;
466
+ this.teardownMedia({ preserveCaptures: true });
467
+ this.clearActiveSetState();
468
+ this.connection = "reconnecting";
469
+ this.recovering = true;
470
+ this.emit();
471
+ this.reconnectTimer = setTimeout(() => {
472
+ this.reconnectTimer = undefined;
473
+ if (this.disposed) return;
474
+ void this.connect().catch(() => undefined);
475
+ }, Math.max(0, cause.reconnectAfterMs));
476
+ return;
477
+ }
478
+
479
+ // An ORDINARY drop cancels a scheduled move: the crash path owns it now.
480
+ if (this.migration?.phase === "scheduled") {
481
+ clearTimeout(this.migration.timer);
482
+ this.migration = null;
483
+ }
484
+
272
485
  this.lastError = cause;
273
486
  // What the person was sending, before it is torn down. Recorded so the
274
487
  // screen can say so once the room is back (see `lostPublicationSources`).
@@ -283,18 +496,16 @@ export class MediaRoom {
283
496
  ];
284
497
  // Before the decision, and unconditionally. The transports are dead
285
498
  // whatever happens next, and so is every capture that was feeding them.
286
- this.teardownMedia();
287
- // The active set was an instruction about consumers on THIS connection,
288
- // and those are gone. Left standing, it would be mistaken by the next
289
- // `joined` for a set the new node had sent ahead of the snapshot.
290
- if (this.stateValue.activeSpeakers.length > 0) {
291
- this.stateValue = { ...this.stateValue, activeSpeakers: [] };
292
- }
499
+ // A failed MOVE attempt keeps preserving: the stash is the captures, and
500
+ // it is stopped only when the recovery gives up for good.
501
+ this.teardownMedia({ preserveCaptures: this.migration?.phase === "moving" });
502
+ this.clearActiveSetState();
293
503
 
294
504
  // `disposed` as well as the cause: `close()` is a client close whatever the
295
505
  // socket's own account of it, and a room that has been closed must never
296
506
  // report itself as coming back.
297
507
  if (cause.type === "closed_by_client" || this.disposed) {
508
+ this.stopStash();
298
509
  this.connection = "closed";
299
510
  this.recovering = false;
300
511
  this.emit();
@@ -304,9 +515,75 @@ export class MediaRoom {
304
515
  if (wasPublishing.length > 0) this.lostSources = wasPublishing;
305
516
  this.connection = "reconnecting";
306
517
  this.recovering = this.scheduleReconnect(cause);
518
+ if (!this.recovering) {
519
+ // Read BEFORE stopStash, which clears the migration as it stops it.
520
+ const wasMoving = this.migration?.phase === "moving";
521
+ this.stopStash();
522
+ // A MOVE that dies for good is reported as the move failing, not as
523
+ // whatever refusal happened to end it: the person was in a working
524
+ // call that a deploy interrupted, and "Could not move you" with a
525
+ // retry is the honest account of that. The refusal itself still went
526
+ // to the log via scheduleReconnect's decision line.
527
+ if (wasMoving) this.lastError = { type: "draining", reconnectAfterMs: 0 };
528
+ }
307
529
  this.emit();
308
530
  }
309
531
 
532
+ /**
533
+ * The active set was an instruction about consumers on the connection that
534
+ * just ended. Left standing, it would be mistaken by the next `joined` for
535
+ * a set the new node had sent ahead of the snapshot. The split, the levels
536
+ * and the enforcement flag go with it.
537
+ */
538
+ private clearActiveSetState(): void {
539
+ if (
540
+ this.stateValue.activeSpeakers.length > 0 ||
541
+ this.stateValue.activeAudio.length > 0 ||
542
+ this.stateValue.activeVideo.length > 0 ||
543
+ this.stateValue.speakers.length > 0
544
+ ) {
545
+ this.stateValue = {
546
+ ...this.stateValue,
547
+ activeSpeakers: [],
548
+ activeAudio: [],
549
+ activeVideo: [],
550
+ speakers: [],
551
+ activeSetEnforced: false,
552
+ };
553
+ }
554
+ }
555
+
556
+ /** The captures a move was carrying, for the republish on the far side.
557
+ * Program feeds are excluded exactly as they are from `lostSources`. */
558
+ private buildStash(): readonly StashedPublication[] {
559
+ return [...this.publications.values()]
560
+ .filter((p): p is LocalPublication & { source: LocalPublicationSource } => p.source !== "program")
561
+ .map((p) => ({ track: p.track, kind: p.kind, source: p.source, paused: p.paused, options: p.options }));
562
+ }
563
+
564
+ /**
565
+ * A move that is over, one way or the other. On failure the stashed
566
+ * captures are STOPPED and surfaced as lost sources: the camera light must
567
+ * never outlive both its session and its migration, and from here the UX
568
+ * is exactly the ordinary-drop one, a person and a button.
569
+ */
570
+ private stopStash(): void {
571
+ const migration = this.migration;
572
+ if (migration?.phase === "scheduled") {
573
+ clearTimeout(migration.timer);
574
+ this.migration = null;
575
+ return;
576
+ }
577
+ if (migration?.phase !== "moving") return;
578
+ const sources = new Set<LocalPublicationSource>();
579
+ for (const item of migration.stash) {
580
+ item.track.stop();
581
+ sources.add(item.source);
582
+ }
583
+ if (sources.size > 0) this.lostSources = [...sources];
584
+ this.migration = null;
585
+ }
586
+
310
587
  get state(): RoomState {
311
588
  return this.stateValue;
312
589
  }
@@ -379,6 +656,95 @@ export class MediaRoom {
379
656
  return this.lostSources;
380
657
  }
381
658
 
659
+ /**
660
+ * Why does the call look the way it looks, answered without
661
+ * webrtc-internals: per tile, the layer this client ASKED for, the layer
662
+ * the node is GIVING, and the picture the browser is decoding; plus the
663
+ * node's congestion estimate and this side's own publish health. Built for
664
+ * polling (a diagnostics HUD calls it once a second); bitrate is computed
665
+ * from the byte delta between calls. Degrades gracefully everywhere: an
666
+ * old node contributes nothing, a fake device without stats contributes
667
+ * nothing, and the local half always answers.
668
+ */
669
+ async getDiagnostics(): Promise<CallDiagnostics> {
670
+ const node = ((await this.requestOptional({ method: "diagnostics" })) ?? undefined) as
671
+ | NodeDiagnostics
672
+ | undefined;
673
+ // Defensive throughout: the verb is optional, and an old or odd node can
674
+ // answer with any shape (an empty object included). A diagnostics call
675
+ // must never be the thing that throws in a call.
676
+ const nodeByProducer = new Map((node?.consumers ?? []).map((c) => [c.producerId, c]));
677
+
678
+ const tiles: TileDiagnostics[] = [];
679
+ for (const [producerId, consumer] of this.consumers) {
680
+ const entry = this.stateValue.producers.find((p) => p.producerId === producerId);
681
+ const width = this.viewportWidth(producerId);
682
+ const fromNode = nodeByProducer.get(producerId);
683
+ const inbound = await this.inboundStats(producerId, consumer);
684
+ tiles.push({
685
+ producerId,
686
+ identity: entry?.identity ?? "",
687
+ kind: consumer.kind,
688
+ ...(entry?.source ? { source: entry.source } : {}),
689
+ ...(this.sentLayer.has(producerId) ? { requestedLayer: this.sentLayer.get(producerId)! } : {}),
690
+ ...(fromNode?.currentLayers ? { currentLayer: fromNode.currentLayers.spatialLayer } : {}),
691
+ ...(width !== undefined ? { viewportWidthPx: width } : {}),
692
+ pausedByViewport: this.pausedByViewport.has(producerId),
693
+ ...inbound,
694
+ });
695
+ }
696
+
697
+ const estimate = (node?.transports ?? []).find((t) => typeof t.availableOutgoingBitrate === "number");
698
+ return {
699
+ tiles,
700
+ ...(estimate ? { availableOutgoingBitrate: estimate.availableOutgoingBitrate! } : {}),
701
+ ...(node?.producers?.length ? { localProducers: node.producers } : {}),
702
+ ...(node ? { node } : {}),
703
+ };
704
+ }
705
+
706
+ /** Byte counters from the previous `getDiagnostics`, for the bitrate delta. */
707
+ private readonly statsBaseline = new Map<string, { bytes: number; at: number }>();
708
+
709
+ private async inboundStats(
710
+ producerId: string,
711
+ consumer: MediaConsumerHandle,
712
+ ): Promise<Partial<TileDiagnostics>> {
713
+ if (!consumer.getStats) return {};
714
+ let report: unknown;
715
+ try {
716
+ report = await consumer.getStats();
717
+ } catch {
718
+ return {};
719
+ }
720
+ // An RTCStatsReport is a map; anything iterable of stats objects works.
721
+ const entries: Record<string, unknown>[] = [];
722
+ const iterable = report as { values?: () => Iterable<unknown> };
723
+ if (typeof iterable?.values === "function") {
724
+ for (const value of iterable.values()) entries.push(value as Record<string, unknown>);
725
+ } else if (Array.isArray(report)) {
726
+ for (const value of report) entries.push(value as Record<string, unknown>);
727
+ }
728
+ const inbound = entries.find((e) => e["type"] === "inbound-rtp");
729
+ if (!inbound) return {};
730
+
731
+ const out: Partial<TileDiagnostics> = {
732
+ ...(typeof inbound["frameWidth"] === "number" ? { frameWidth: inbound["frameWidth"] } : {}),
733
+ ...(typeof inbound["frameHeight"] === "number" ? { frameHeight: inbound["frameHeight"] } : {}),
734
+ ...(typeof inbound["framesPerSecond"] === "number" ? { framesPerSecond: inbound["framesPerSecond"] } : {}),
735
+ };
736
+ const bytes = inbound["bytesReceived"];
737
+ if (typeof bytes === "number") {
738
+ const now = Date.now();
739
+ const baseline = this.statsBaseline.get(producerId);
740
+ this.statsBaseline.set(producerId, { bytes, at: now });
741
+ if (baseline && now > baseline.at && bytes >= baseline.bytes) {
742
+ out.bitrateKbps = Math.round(((bytes - baseline.bytes) * 8) / (now - baseline.at));
743
+ }
744
+ }
745
+ return out;
746
+ }
747
+
382
748
  /** Subscribe to changes. Returns an unsubscribe. */
383
749
  onChange(listener: Listener): () => void {
384
750
  this.listeners.add(listener);
@@ -397,8 +763,10 @@ export class MediaRoom {
397
763
  this.cancelReconnect();
398
764
  // A retry is not a first connection, and saying "Connecting to the call"
399
765
  // over a call somebody is already in reads as though they had been thrown
400
- // out of it.
401
- this.connection = this.reconnectAttempt > 0 ? "reconnecting" : "connecting";
766
+ // out of it. A planned MOVE is the same case with `reconnectAttempt`
767
+ // still 0: the person is mid-call, so the word is "reconnecting".
768
+ this.connection =
769
+ this.reconnectAttempt > 0 || this.migration?.phase === "moving" ? "reconnecting" : "connecting";
402
770
  this.emit();
403
771
 
404
772
  // A failed attempt normally reports itself through `signal.onClose`, which
@@ -435,15 +803,58 @@ export class MediaRoom {
435
803
  // A successful join resets the ladder: an hour-long call that drops once
436
804
  // should not start at a 30-second delay because of a blip at minute two.
437
805
  this.reconnectAttempt = 0;
806
+ this.outageStartedAt = null;
438
807
  this.recovering = false;
439
808
  this.emit();
440
809
 
441
810
  if (this.options.autoSubscribe !== false) await this.syncSubscriptions();
811
+
812
+ // The far side of a planned move: the same capture tracks, republished
813
+ // with the same sources and options, nobody pressing anything. This is
814
+ // what "smooth" means; a drain used to be a mute-everything drop.
815
+ if (this.migration?.phase === "moving") {
816
+ const stash = this.migration.stash;
817
+ this.migration = null;
818
+ await this.restoreStash(stash);
819
+ }
820
+ }
821
+
822
+ private async restoreStash(stash: readonly StashedPublication[]): Promise<void> {
823
+ const lost = new Set<LocalPublicationSource>();
824
+ for (const item of stash) {
825
+ if (item.track.readyState !== "live") {
826
+ // The capture ended during the move (a camera unplugged, a share the
827
+ // browser's own bar stopped). Honest answer: the ordinary lost-source
828
+ // offer, a person and a button.
829
+ lost.add(item.source);
830
+ continue;
831
+ }
832
+ try {
833
+ const publication = await this.publish(item.track, item.source, item.options);
834
+ // Republished PAUSED state follows within one round trip. The wire
835
+ // has no start-paused on produce, so a muted mic is briefly live at
836
+ // the producer level; the capture was live locally the whole time.
837
+ if (item.paused) await this.setPaused(publication.producerId, true);
838
+ } catch {
839
+ item.track.stop();
840
+ lost.add(item.source);
841
+ }
842
+ }
843
+ if (lost.size > 0) {
844
+ this.lostSources = [...new Set([...this.lostSources, ...lost])];
845
+ this.emit();
846
+ }
442
847
  }
443
848
 
444
849
  async close(): Promise<void> {
445
850
  this.disposed = true;
446
851
  this.cancelReconnect();
852
+ // A capture must never outlive both its session and its migration.
853
+ this.stopStash();
854
+ if (this.viewportTimer) {
855
+ clearTimeout(this.viewportTimer);
856
+ this.viewportTimer = undefined;
857
+ }
447
858
  this.recovering = false;
448
859
  this.connection = "closed";
449
860
  this.lostSources = NO_SOURCES;
@@ -473,9 +884,15 @@ export class MediaRoom {
473
884
  private scheduleReconnect(cause: DisconnectCause): boolean {
474
885
  if (this.disposed) return false;
475
886
 
887
+ // The room opts into the TIME budget: a person is in front of this
888
+ // surface, and "Connection lost" over a node that restarts in ninety
889
+ // seconds is the failure the budget exists for. Headless callers keep
890
+ // the attempts-only policy by never passing a clock.
891
+ if (this.outageStartedAt === null) this.outageStartedAt = Date.now();
476
892
  const decision = decideReconnect({
477
893
  cause,
478
894
  attempt: this.reconnectAttempt,
895
+ elapsedMs: Date.now() - this.outageStartedAt,
479
896
  ...(this.options.reconnect ? { options: this.options.reconnect } : {}),
480
897
  });
481
898
  if (decision.action === "stop") {
@@ -524,12 +941,22 @@ export class MediaRoom {
524
941
  throw new Error(`this browser cannot produce ${kind}`);
525
942
  }
526
943
 
944
+ // The documented VP8-only exclusivity, enforced instead of conventional.
945
+ // H.264 simulcast depends on the hardware encoder and quietly degrades to
946
+ // one layer; sending the encodings anyway would claim layers that do not
947
+ // exist and a consumer's setPreferredLayers would silently do nothing.
948
+ let simulcast = options.simulcast ?? false;
949
+ if (simulcast && options.codec === "h264") {
950
+ this.options.onLog?.("warn", "simulcast dropped: it is unreliable on h264, publish vp8 or one encoding");
951
+ simulcast = false;
952
+ }
953
+
527
954
  const transport = await this.ensureSendTransport();
528
955
  const handle = await transport.produce({
529
956
  track,
530
957
  appData: { source },
531
958
  ...(options.codec ? { codec: options.codec } : {}),
532
- ...(options.simulcast
959
+ ...(simulcast
533
960
  ? { encodings: simulcastEncodings(options.maxBitrateKbps) }
534
961
  : options.maxBitrateKbps
535
962
  ? { encodings: [{ maxBitrate: options.maxBitrateKbps * 1000 }] }
@@ -544,6 +971,7 @@ export class MediaRoom {
544
971
  source,
545
972
  track,
546
973
  handle,
974
+ options,
547
975
  paused: false,
548
976
  };
549
977
  this.publications.set(handle.id, publication);
@@ -650,23 +1078,176 @@ export class MediaRoom {
650
1078
  * are the same thing and this needs no special case.
651
1079
  */
652
1080
  async syncSubscriptions(): Promise<void> {
653
- const wanted = new Set(this.stateValue.activeSpeakers);
1081
+ const state = this.stateValue;
1082
+ // AUDIO NEVER NARROWS BY VIEWPORT. You must hear people you cannot see.
1083
+ // This asymmetry is deliberate; do not tidy it into symmetry. Only the
1084
+ // video half of the set is intersected with what the UI reports visible.
1085
+ // The reducer always derives the split (an unknown-kind id lands on the
1086
+ // audio side), so the two halves together ARE the active set.
1087
+ const audioWanted = new Set(state.activeAudio);
1088
+ const videoAllowed = new Set(state.activeVideo);
1089
+ const videoWanted =
1090
+ this.viewport === null
1091
+ ? videoAllowed
1092
+ : new Set(this.viewport.map((e) => e.producerId).filter((id) => videoAllowed.has(id)));
1093
+ // NEVER wider than the node's set: the node refuses a consume outside it,
1094
+ // and a subset of the shared set is what keeps the pipe math bounded.
1095
+ const wanted = new Set([...audioWanted, ...videoWanted]);
1096
+ const allowed = new Set([...audioWanted, ...videoAllowed]);
654
1097
 
655
1098
  for (const [producerId, consumer] of this.consumers) {
656
- if (wanted.has(producerId)) continue;
657
- // Dropped rather than paused. A consumer the node has moved out of the
658
- // active set is one it may refuse to keep feeding, and holding it costs
659
- // the node a consumer object for a tile nobody is looking at.
660
- consumer.close();
661
- this.consumers.delete(producerId);
662
- this.tracksByProducer.delete(producerId);
663
- await this.signal.request({ method: "closeConsumer", consumerId: consumer.id }).catch(() => undefined);
1099
+ if (wanted.has(producerId)) {
1100
+ this.cancelViewportPause(producerId, consumer);
1101
+ continue;
1102
+ }
1103
+ if (!allowed.has(producerId)) {
1104
+ // Out of the ACTIVE SET entirely: dropped rather than paused, today's
1105
+ // semantics. The node may refuse to keep feeding it, and holding it
1106
+ // costs the node a consumer object for a tile nobody may render.
1107
+ this.dropConsumer(producerId, consumer);
1108
+ await this.signal.request({ method: "closeConsumer", consumerId: consumer.id }).catch(() => undefined);
1109
+ continue;
1110
+ }
1111
+ // In the set but off the viewport: pause now, close after the grace.
1112
+ this.beginViewportPause(producerId, consumer);
664
1113
  }
665
1114
 
666
1115
  await Promise.all([...wanted].map((producerId) => this.subscribe(producerId)));
1116
+ if (this.viewport !== null) this.applyPreferredLayers();
667
1117
  this.emit();
668
1118
  }
669
1119
 
1120
+ /**
1121
+ * Tell the room what the UI is rendering: which producers, at what width.
1122
+ *
1123
+ * Debounced inside the room (a scroll fires many times), trailing 150ms.
1124
+ * Never call this from a headless consumer; not calling it IS the legacy
1125
+ * subscribe-the-whole-set behavior.
1126
+ */
1127
+ setViewport(entries: readonly ViewportEntry[]): void {
1128
+ // The FIRST report commits at once: a debounce there would let the join's
1129
+ // first active-set frame subscribe the whole set 150ms before the
1130
+ // narrowing arrived, which is the cost this API exists to avoid. Updates
1131
+ // (scroll, resize) debounce.
1132
+ if (this.viewport === null) {
1133
+ this.viewport = entries;
1134
+ void this.syncSubscriptions().catch((err) => this.options.onLog?.("warn", "viewport sync failed", err));
1135
+ return;
1136
+ }
1137
+ this.pendingViewport = entries;
1138
+ if (this.viewportTimer) return;
1139
+ this.viewportTimer = setTimeout(() => {
1140
+ this.viewportTimer = undefined;
1141
+ const pending = this.pendingViewport;
1142
+ this.pendingViewport = undefined;
1143
+ if (pending === undefined) return;
1144
+ this.viewport = pending;
1145
+ void this.syncSubscriptions().catch((err) => this.options.onLog?.("warn", "viewport sync failed", err));
1146
+ }, 150);
1147
+ }
1148
+
1149
+ /** Back to the legacy behavior: subscribe the whole active set. */
1150
+ clearViewport(): void {
1151
+ if (this.viewportTimer) {
1152
+ clearTimeout(this.viewportTimer);
1153
+ this.viewportTimer = undefined;
1154
+ }
1155
+ this.pendingViewport = undefined;
1156
+ if (this.viewport === null) return;
1157
+ this.viewport = null;
1158
+ void this.syncSubscriptions().catch((err) => this.options.onLog?.("warn", "viewport sync failed", err));
1159
+ }
1160
+
1161
+ /** The width the UI reported for a producer's tile, if it reported one. */
1162
+ private viewportWidth(producerId: string): number | undefined {
1163
+ return this.viewport?.find((e) => e.producerId === producerId)?.widthPx;
1164
+ }
1165
+
1166
+ private dropConsumer(producerId: string, consumer: MediaConsumerHandle): void {
1167
+ consumer.close();
1168
+ this.consumers.delete(producerId);
1169
+ this.tracksByProducer.delete(producerId);
1170
+ this.sentLayer.delete(producerId);
1171
+ this.statsBaseline.delete(producerId);
1172
+ const timer = this.pausedByViewport.get(producerId);
1173
+ if (timer) clearTimeout(timer);
1174
+ this.pausedByViewport.delete(producerId);
1175
+ }
1176
+
1177
+ private beginViewportPause(producerId: string, consumer: MediaConsumerHandle): void {
1178
+ if (this.pausedByViewport.has(producerId)) return;
1179
+ // Local pause detaches nothing on its own; the server-side pause is what
1180
+ // stops the RTP. An old node refuses the verb, which degrades to
1181
+ // local-pause-only, and the grace close still frees everything.
1182
+ consumer.pause();
1183
+ void this.requestOptional({ method: "pauseConsumer", consumerId: consumer.id });
1184
+ const timer = setTimeout(() => {
1185
+ // Re-checked at fire time: a flip back cancels the timer, but a timer
1186
+ // racing its own cancellation must not close a tile somebody is watching.
1187
+ if (!this.pausedByViewport.has(producerId)) return;
1188
+ this.pausedByViewport.delete(producerId);
1189
+ const current = this.consumers.get(producerId);
1190
+ if (!current) return;
1191
+ this.dropConsumer(producerId, current);
1192
+ void this.signal.request({ method: "closeConsumer", consumerId: current.id }).catch(() => undefined);
1193
+ this.emit();
1194
+ }, this.options.viewportCloseGraceMs ?? 10_000);
1195
+ this.pausedByViewport.set(producerId, timer);
1196
+ }
1197
+
1198
+ private cancelViewportPause(producerId: string, consumer: MediaConsumerHandle): void {
1199
+ const timer = this.pausedByViewport.get(producerId);
1200
+ if (timer === undefined) return;
1201
+ clearTimeout(timer);
1202
+ this.pausedByViewport.delete(producerId);
1203
+ consumer.resume();
1204
+ void this.requestOptional({ method: "resumeConsumer", consumerId: consumer.id });
1205
+ // A resumed video consumer shows garbage until an I-frame arrives. Ask,
1206
+ // rather than waiting out the encoder's own schedule.
1207
+ if (consumer.kind === "video") {
1208
+ void this.signal.request({ method: "requestKeyFrame", consumerId: consumer.id }).catch(() => undefined);
1209
+ }
1210
+ }
1211
+
1212
+ /**
1213
+ * Bring every live video consumer onto the layer its reported width implies.
1214
+ * Bucket-change only: the last sent layer is remembered, so a resize storm
1215
+ * costs nothing until a boundary is crossed. A layer change keeps the
1216
+ * consumer, the pipe and the SSRC, which is what makes page flips instant.
1217
+ */
1218
+ private applyPreferredLayers(): void {
1219
+ for (const [producerId, consumer] of this.consumers) {
1220
+ if (consumer.kind !== "video" || this.pausedByViewport.has(producerId)) continue;
1221
+ const width = this.viewportWidth(producerId);
1222
+ if (width === undefined) continue;
1223
+ const entry = this.stateValue.producers.find((p) => p.producerId === producerId);
1224
+ const layer = spatialLayerForWidth(width, entry?.source);
1225
+ if (this.sentLayer.get(producerId) === layer) continue;
1226
+ this.sentLayer.set(producerId, layer);
1227
+ void this.requestOptional({ method: "setPreferredLayers", consumerId: consumer.id, spatialLayer: layer });
1228
+ }
1229
+ }
1230
+
1231
+ /**
1232
+ * A request an OLD node may not understand. Disabled for the session on the
1233
+ * first `bad_request` rather than retried: a retry loop meets the node's
1234
+ * frame budget, and the frame budget closes sockets.
1235
+ */
1236
+ private async requestOptional(frame: { method: string } & Record<string, unknown>): Promise<unknown> {
1237
+ if (this.unsupported.has(frame.method)) return undefined;
1238
+ try {
1239
+ return await this.signal.request(frame as never);
1240
+ } catch (error) {
1241
+ if ((error as { code?: string }).code === "bad_request") {
1242
+ this.unsupported.add(frame.method);
1243
+ this.options.onLog?.("warn", `${frame.method} is not supported by this node, feature disabled`);
1244
+ return undefined;
1245
+ }
1246
+ this.options.onLog?.("warn", `${frame.method} failed`, error);
1247
+ return undefined;
1248
+ }
1249
+ }
1250
+
670
1251
  async subscribe(producerId: string): Promise<void> {
671
1252
  if (this.consumers.has(producerId)) return;
672
1253
  const inFlight = this.subscribing.get(producerId);
@@ -684,11 +1265,18 @@ export class MediaRoom {
684
1265
  if (!entry) return;
685
1266
 
686
1267
  const transport = await this.ensureRecvTransport();
1268
+ // The layer the tile's reported width implies, asked for from the FIRST
1269
+ // frame. Advisory: a non-simulcast producer has no layers, and an older
1270
+ // node strips the key.
1271
+ const width = entry.kind === "video" ? this.viewportWidth(producerId) : undefined;
1272
+ const layer = width === undefined ? undefined : spatialLayerForWidth(width, entry.source);
1273
+ if (layer !== undefined) this.sentLayer.set(producerId, layer);
687
1274
  const response = (await this.signal.request({
688
1275
  method: "consume",
689
1276
  transportId: transport.id,
690
1277
  producerId,
691
1278
  rtpCapabilities: this.device.rtpCapabilities,
1279
+ ...(layer !== undefined ? { preferredLayers: { spatialLayer: layer } } : {}),
692
1280
  })) as { consumerId: string; producerId: string; kind: "audio" | "video"; rtpParameters: RtpParameters };
693
1281
 
694
1282
  const consumer = await transport.consume({
@@ -716,6 +1304,91 @@ export class MediaRoom {
716
1304
  this.emit();
717
1305
  }
718
1306
 
1307
+ // -------------------------------------------------------------------------
1308
+ // ephemeral signals: reactions, hands
1309
+ // -------------------------------------------------------------------------
1310
+
1311
+ /**
1312
+ * Send an ephemeral, unstored fan-out to the room: a flying reaction, a
1313
+ * courtesy lower-hand. Grant-gated on the node (`canPublishData`) and
1314
+ * rate-limited there; NOT a chat transport, and nothing durable may be
1315
+ * derived from one.
1316
+ */
1317
+ async sendReaction(data: Record<string, unknown>): Promise<void> {
1318
+ await this.requestOptional({ method: "broadcast", type: "reaction", data });
1319
+ }
1320
+
1321
+ /**
1322
+ * Ask another participant to lower their hand. Honorific by design: the
1323
+ * TARGET's client obeys by calling `setHandRaised(false)` itself; the
1324
+ * server-held hand state only ever moves on its owner's verb.
1325
+ */
1326
+ async sendLowerHand(target: string): Promise<void> {
1327
+ await this.requestOptional({ method: "broadcast", type: "lowerHand", data: { target } });
1328
+ }
1329
+
1330
+ /**
1331
+ * Raise or lower this participant's own hand. Server-materialized room
1332
+ * state: it survives reconnects and appears in late joiners' snapshots, so
1333
+ * `state.raisedHands` is the truth to render, not the reply.
1334
+ */
1335
+ async setHandRaised(raised: boolean): Promise<void> {
1336
+ await this.requestOptional({ method: "setHand", raised });
1337
+ }
1338
+
1339
+ /**
1340
+ * Incoming broadcasts, as EVENTS rather than state: a reaction is an
1341
+ * animation, and holding a list of them in `RoomState` would make every
1342
+ * emoji a whole-room re-render. Unknown types included; ignore what you
1343
+ * do not recognize.
1344
+ */
1345
+ onBroadcast(listener: (event: BroadcastEvent) => void): () => void {
1346
+ this.broadcastListeners.add(listener);
1347
+ return () => this.broadcastListeners.delete(listener);
1348
+ }
1349
+
1350
+ // -------------------------------------------------------------------------
1351
+ // the planned move
1352
+ // -------------------------------------------------------------------------
1353
+
1354
+ /**
1355
+ * The node asked to be left, with a jittered window. THE fix for the drain
1356
+ * experience: this frame used to be recorded and ignored, so the "told,
1357
+ * not cut" design never executed and everyone was cut at the deadline.
1358
+ * The call continues untouched during the wait; the UI is not told.
1359
+ */
1360
+ private onDrainingFrame(reconnectAfterMs: number): void {
1361
+ if (this.disposed || this.migration !== null) return;
1362
+ if (this.connection !== "connected") return;
1363
+ const timer = setTimeout(() => this.beginPlannedMove(), Math.max(0, reconnectAfterMs));
1364
+ this.migration = { phase: "scheduled", timer };
1365
+ }
1366
+
1367
+ private beginPlannedMove(): void {
1368
+ if (this.migration?.phase !== "scheduled") return;
1369
+ if (this.connection !== "connected" || this.disposed) {
1370
+ // A real drop got here first; its path owns the recovery.
1371
+ this.migration = null;
1372
+ return;
1373
+ }
1374
+ /**
1375
+ * The stash is NOT built here. `leave()` below is a full round trip (up
1376
+ * to the request timeout against a busy node), and the person can mute,
1377
+ * unmute, start or stop a capture the whole time: a stash taken now
1378
+ * republishes a mic they have since muted, and a share they started in
1379
+ * the window would be orphaned with its light on. The close handler
1380
+ * builds the stash at the moment the connection actually ends, when the
1381
+ * publications map is the truth.
1382
+ */
1383
+ this.migration = { phase: "moving", stash: NO_STASH, plannedClose: true };
1384
+ /**
1385
+ * `leave()`, not a bare close: the leave request makes the OLD node tear
1386
+ * our participant record down synchronously, so the rejoin seconds later
1387
+ * cannot meet its own ghost and be refused `duplicate_identity`.
1388
+ */
1389
+ void this.signal.leave().catch(() => undefined);
1390
+ }
1391
+
719
1392
  // -------------------------------------------------------------------------
720
1393
  // transports
721
1394
  // -------------------------------------------------------------------------
@@ -812,6 +1485,19 @@ export class MediaRoom {
812
1485
 
813
1486
  if (frame.event === "joined") this.grantsValue = frame.grants ?? this.grantsValue;
814
1487
 
1488
+ if (frame.event === "draining") this.onDrainingFrame(frame.reconnectAfterMs);
1489
+
1490
+ if (frame.event === "broadcast") {
1491
+ const event: BroadcastEvent = { type: frame.type, identity: frame.identity, data: frame.data, at: frame.at };
1492
+ for (const listener of this.broadcastListeners) {
1493
+ try {
1494
+ listener(event);
1495
+ } catch {
1496
+ // One listener throwing must not stop the others from being told.
1497
+ }
1498
+ }
1499
+ }
1500
+
815
1501
  if (frame.event === "producerClosed") {
816
1502
  const consumer = this.consumers.get(frame.producerId);
817
1503
  consumer?.close();
@@ -846,16 +1532,27 @@ export class MediaRoom {
846
1532
  * node tears the session down on close anyway. Ending one publication on a
847
1533
  * LIVE connection is `unpublish`, which does tell it.
848
1534
  */
849
- private teardownMedia(): void {
1535
+ private teardownMedia(options: { preserveCaptures?: boolean } = {}): void {
850
1536
  for (const consumer of this.consumers.values()) consumer.close();
851
1537
  this.consumers.clear();
852
1538
  this.tracksByProducer.clear();
1539
+ // Viewport pause bookkeeping belongs to the consumers that just closed.
1540
+ // The viewport ITSELF is kept: it is the UI's statement about what it
1541
+ // renders, and it applies to the next connection unchanged.
1542
+ for (const timer of this.pausedByViewport.values()) clearTimeout(timer);
1543
+ this.pausedByViewport.clear();
1544
+ this.sentLayer.clear();
1545
+ this.statsBaseline.clear();
853
1546
 
854
1547
  for (const detach of this.trackEndWatchers.values()) detach();
855
1548
  this.trackEndWatchers.clear();
856
1549
  for (const publication of this.publications.values()) {
857
1550
  publication.handle.close();
858
- publication.track.stop();
1551
+ // A PLANNED move keeps the captures: the stash holds the same track
1552
+ // objects and republishes them on the far side, so the camera light
1553
+ // never blinks for a server deploy. Every other teardown stops them,
1554
+ // for the privacy reasons the docblock above states.
1555
+ if (!options.preserveCaptures) publication.track.stop();
859
1556
  }
860
1557
  this.publications.clear();
861
1558
 
@@ -887,6 +1584,17 @@ export class MediaRoom {
887
1584
  }
888
1585
 
889
1586
  const NO_SOURCES: readonly LocalPublicationSource[] = [];
1587
+ /** Placeholder until the close handler builds the real stash. */
1588
+ const NO_STASH: readonly StashedPublication[] = [];
1589
+
1590
+ /** A live capture carried across a planned server move. */
1591
+ type StashedPublication = {
1592
+ track: MediaStreamTrack;
1593
+ kind: "audio" | "video";
1594
+ source: LocalPublicationSource;
1595
+ paused: boolean;
1596
+ options: PublishOptions;
1597
+ };
890
1598
 
891
1599
  /**
892
1600
  * Call `onEnded` when a track ends on its own, and return the detach.