@tribe-nest/media-client 0.1.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 (44) 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 +31 -0
  13. package/build/core/state.d.ts.map +1 -1
  14. package/build/core/state.js +113 -8
  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 +78 -16
  20. package/build/react/index.d.ts.map +1 -1
  21. package/build/react/index.js +289 -12
  22. package/build/react/index.js.map +1 -1
  23. package/build/room/browserDevice.d.ts.map +1 -1
  24. package/build/room/browserDevice.js +13 -4
  25. package/build/room/browserDevice.js.map +1 -1
  26. package/build/room/device.d.ts +19 -0
  27. package/build/room/device.d.ts.map +1 -1
  28. package/build/room/room.d.ts +298 -3
  29. package/build/room/room.d.ts.map +1 -1
  30. package/build/room/room.js +742 -24
  31. package/build/room/room.js.map +1 -1
  32. package/package.json +2 -2
  33. package/src/core/_tests/reconnect.spec.ts +92 -0
  34. package/src/core/_tests/state.spec.ts +138 -0
  35. package/src/core/index.ts +2 -0
  36. package/src/core/reconnect.ts +76 -9
  37. package/src/core/signal.ts +16 -2
  38. package/src/core/state.ts +163 -11
  39. package/src/index.ts +2 -0
  40. package/src/react/index.tsx +323 -19
  41. package/src/room/_tests/room.spec.ts +954 -4
  42. package/src/room/browserDevice.ts +14 -4
  43. package/src/room/device.ts +19 -0
  44. package/src/room/room.ts +913 -26
@@ -1,8 +1,67 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MediaRoom = void 0;
4
+ exports.spatialLayerForWidth = spatialLayerForWidth;
4
5
  exports.connectToRoom = connectToRoom;
5
6
  const core_1 = require("../core");
7
+ /**
8
+ * The three layers, quarter / half / full.
9
+ *
10
+ * `scaleResolutionDownBy` rather than three bitrates, so the shape holds
11
+ * whatever resolution the caller is publishing. The rids are the conventional
12
+ * one-letter names every SFU and every browser log uses, which is worth more
13
+ * than a more descriptive name nobody would recognise in a WebRTC dump.
14
+ *
15
+ * `L1T3` (three temporal layers per encoding), not `L1T1`, and the difference
16
+ * decides whether anyone ever LEAVES the quarter layer. The server's
17
+ * congestion controller only steps a consumer up when the estimate has room
18
+ * for the next step, and with T1 the only step that exists is a whole
19
+ * spatial layer's full bitrate - a cliff the estimate must clear in one
20
+ * probe. Temporal layers cut each spatial step into thirds, which is the
21
+ * conventional simulcast shape for exactly this reason. Confirmed in the
22
+ * field by the health panel: a big tile asking `f` and being held at `h`
23
+ * for a whole call.
24
+ */
25
+ const SIMULCAST_LAYERS = [
26
+ { rid: "q", scaleResolutionDownBy: 4, scalabilityMode: "L1T3" },
27
+ { rid: "h", scaleResolutionDownBy: 2, scalabilityMode: "L1T3" },
28
+ { rid: "f", scaleResolutionDownBy: 1, scalabilityMode: "L1T3" },
29
+ ];
30
+ /**
31
+ * Which simulcast layer a tile of `widthPx` deserves: 0 = q, 1 = h, 2 = f.
32
+ *
33
+ * The policy lives HERE, not on the node, because the tile width is a fact
34
+ * only the renderer has; the node obeys. Buckets, not a formula, so a resize
35
+ * only crosses a boundary occasionally and a boundary crossing is the only
36
+ * thing that costs a request. Screen shares always get the full layer:
37
+ * downscaled text is unreadable, and shares are rarely simulcast anyway.
38
+ */
39
+ function spatialLayerForWidth(widthPx, source) {
40
+ if (source === "screen")
41
+ return 2;
42
+ if (widthPx <= 240)
43
+ return 0;
44
+ if (widthPx <= 480)
45
+ return 1;
46
+ return 2;
47
+ }
48
+ /**
49
+ * What each layer may spend, given the caller's cap for the top one.
50
+ *
51
+ * Quarter resolution is a sixteenth of the pixels, but not a sixteenth of the
52
+ * bits: an encoder needs proportionally more per pixel at small sizes, and a
53
+ * layer starved below what its resolution costs is a layer that looks worse
54
+ * than it should while still being sent. A quarter and a tenth are the
55
+ * conventional split and are what mediasoup's own examples use.
56
+ */
57
+ function simulcastEncodings(maxBitrateKbps) {
58
+ const top = (maxBitrateKbps ?? 0) * 1000;
59
+ const share = [0.1, 0.25, 1];
60
+ return SIMULCAST_LAYERS.map((layer, index) => ({
61
+ ...layer,
62
+ ...(top ? { maxBitrate: Math.round(top * share[index]) } : {}),
63
+ }));
64
+ }
6
65
  class MediaRoom {
7
66
  options;
8
67
  signal;
@@ -20,14 +79,45 @@ class MediaRoom {
20
79
  /** Per publication: stop listening for the track ending on its own. */
21
80
  trackEndWatchers = new Map();
22
81
  listeners = new Set();
82
+ broadcastListeners = new Set();
23
83
  /** One in-flight subscribe per producer, so a burst of activeSpeakers frames
24
84
  * does not race itself into two consumers for one producer. */
25
85
  subscribing = new Map();
86
+ /**
87
+ * What the UI says it is rendering. `null` (never set, or cleared) is the
88
+ * legacy behavior: subscribe the whole active set, which is what every
89
+ * headless consumer (egress, harness, agents) keeps. `[]` is a hidden tab:
90
+ * zero video, audio untouched.
91
+ */
92
+ viewport = null;
93
+ viewportTimer;
94
+ pendingViewport;
95
+ /** Video consumers paused because their tile is off-viewport, each holding
96
+ * its close timer. Single owner of the pause/close decision. */
97
+ pausedByViewport = new Map();
98
+ /** The last spatial layer requested per producer, so a resize storm only
99
+ * speaks up when a bucket boundary is crossed. */
100
+ sentLayer = new Map();
101
+ /** Old-node degradation: each new verb is disabled on its first
102
+ * `bad_request`, never retried into the frame budget. */
103
+ unsupported = new Set();
26
104
  stateValue = core_1.initialRoomState;
27
105
  connection = "idle";
28
106
  lastError;
29
107
  /** Consecutive failed attempts since the last successful join. */
30
108
  reconnectAttempt = 0;
109
+ /** When the current OUTAGE began, for the reconnect time budget. Null while
110
+ * connected. The budget, not the attempt count, is what decides when a
111
+ * room with a person in front of it stops trying. */
112
+ outageStartedAt = null;
113
+ /**
114
+ * The planned-move machine. `scheduled` is the jittered wait after a
115
+ * `draining` frame, during which the call continues untouched; `moving`
116
+ * holds the STASH: the live capture tracks that survive the reconnect and
117
+ * are republished on the far side, which is what makes a drain smooth
118
+ * instead of a mute-everything drop.
119
+ */
120
+ migration = null;
31
121
  reconnectTimer;
32
122
  recovering = false;
33
123
  /** `close()` has been called. Nothing may open a socket after that. */
@@ -48,23 +138,89 @@ class MediaRoom {
48
138
  * it ended after an hour or before a socket was ever opened.
49
139
  */
50
140
  onDisconnect(cause) {
141
+ /**
142
+ * A PLANNED move's own close arrives here first. The signal stamps every
143
+ * client-initiated close `closed_by_client`, which without this flag
144
+ * would route a deliberate server move to the "call over" state; the
145
+ * flag was set before the close, which is what makes it trustworthy.
146
+ */
147
+ if (this.migration?.phase === "moving" && this.migration.plannedClose && !this.disposed) {
148
+ // Stashed HERE, at the instant the connection ends: everything the
149
+ // person did during the leave round trip (mute, unmute, a new share)
150
+ // is in the publications map now and nowhere else.
151
+ this.migration = { phase: "moving", stash: this.buildStash(), plannedClose: false };
152
+ this.lastError = { type: "draining", reconnectAfterMs: 0 };
153
+ this.teardownMedia({ preserveCaptures: true });
154
+ this.clearActiveSetState();
155
+ this.connection = "reconnecting";
156
+ this.recovering = true;
157
+ this.emit();
158
+ // The first attempt of a planned move is NOT a failure: no ladder, no
159
+ // attempt count, straight back through MEDIA_URL with a fresh ticket.
160
+ // Booked on a zero timer rather than called here: this handler runs
161
+ // inside the signal's own close loop, and a connect() started inside
162
+ // it registers its close watcher into the very Set being iterated, so
163
+ // the OLD socket's close would count as the NEW attempt's report. The
164
+ // timer also makes `close()` cancellation uniform.
165
+ this.reconnectTimer = setTimeout(() => {
166
+ this.reconnectTimer = undefined;
167
+ if (this.disposed)
168
+ return;
169
+ void this.connect().catch(() => undefined);
170
+ }, 0);
171
+ return;
172
+ }
173
+ /**
174
+ * The server cut us at the drain deadline (or won the race with our own
175
+ * scheduled move). Same smooth path: stash the captures BEFORE teardown
176
+ * and honor the server's jittered hint before the first attempt, so a
177
+ * whole node's population does not land on the replacement in one burst.
178
+ */
179
+ if (cause.type === "draining" && !this.disposed && this.migration?.phase !== "moving") {
180
+ if (this.migration?.phase === "scheduled")
181
+ clearTimeout(this.migration.timer);
182
+ const stash = this.buildStash();
183
+ this.migration = { phase: "moving", stash, plannedClose: false };
184
+ this.lastError = cause;
185
+ this.teardownMedia({ preserveCaptures: true });
186
+ this.clearActiveSetState();
187
+ this.connection = "reconnecting";
188
+ this.recovering = true;
189
+ this.emit();
190
+ this.reconnectTimer = setTimeout(() => {
191
+ this.reconnectTimer = undefined;
192
+ if (this.disposed)
193
+ return;
194
+ void this.connect().catch(() => undefined);
195
+ }, Math.max(0, cause.reconnectAfterMs));
196
+ return;
197
+ }
198
+ // An ORDINARY drop cancels a scheduled move: the crash path owns it now.
199
+ if (this.migration?.phase === "scheduled") {
200
+ clearTimeout(this.migration.timer);
201
+ this.migration = null;
202
+ }
51
203
  this.lastError = cause;
52
204
  // What the person was sending, before it is torn down. Recorded so the
53
205
  // screen can say so once the room is back (see `lostPublicationSources`).
54
- const wasPublishing = [...new Set([...this.publications.values()].map((p) => p.source))];
206
+ // A program feed is excluded: it is a studio's output, not something a
207
+ // person switched on, so nobody should be asked to switch it back on.
208
+ const wasPublishing = [
209
+ ...new Set([...this.publications.values()]
210
+ .map((p) => p.source)
211
+ .filter((s) => s !== "program")),
212
+ ];
55
213
  // Before the decision, and unconditionally. The transports are dead
56
214
  // whatever happens next, and so is every capture that was feeding them.
57
- this.teardownMedia();
58
- // The active set was an instruction about consumers on THIS connection,
59
- // and those are gone. Left standing, it would be mistaken by the next
60
- // `joined` for a set the new node had sent ahead of the snapshot.
61
- if (this.stateValue.activeSpeakers.length > 0) {
62
- this.stateValue = { ...this.stateValue, activeSpeakers: [] };
63
- }
215
+ // A failed MOVE attempt keeps preserving: the stash is the captures, and
216
+ // it is stopped only when the recovery gives up for good.
217
+ this.teardownMedia({ preserveCaptures: this.migration?.phase === "moving" });
218
+ this.clearActiveSetState();
64
219
  // `disposed` as well as the cause: `close()` is a client close whatever the
65
220
  // socket's own account of it, and a room that has been closed must never
66
221
  // report itself as coming back.
67
222
  if (cause.type === "closed_by_client" || this.disposed) {
223
+ this.stopStash();
68
224
  this.connection = "closed";
69
225
  this.recovering = false;
70
226
  this.emit();
@@ -74,8 +230,72 @@ class MediaRoom {
74
230
  this.lostSources = wasPublishing;
75
231
  this.connection = "reconnecting";
76
232
  this.recovering = this.scheduleReconnect(cause);
233
+ if (!this.recovering) {
234
+ // Read BEFORE stopStash, which clears the migration as it stops it.
235
+ const wasMoving = this.migration?.phase === "moving";
236
+ this.stopStash();
237
+ // A MOVE that dies for good is reported as the move failing, not as
238
+ // whatever refusal happened to end it: the person was in a working
239
+ // call that a deploy interrupted, and "Could not move you" with a
240
+ // retry is the honest account of that. The refusal itself still went
241
+ // to the log via scheduleReconnect's decision line.
242
+ if (wasMoving)
243
+ this.lastError = { type: "draining", reconnectAfterMs: 0 };
244
+ }
77
245
  this.emit();
78
246
  }
247
+ /**
248
+ * The active set was an instruction about consumers on the connection that
249
+ * just ended. Left standing, it would be mistaken by the next `joined` for
250
+ * a set the new node had sent ahead of the snapshot. The split, the levels
251
+ * and the enforcement flag go with it.
252
+ */
253
+ clearActiveSetState() {
254
+ if (this.stateValue.activeSpeakers.length > 0 ||
255
+ this.stateValue.activeAudio.length > 0 ||
256
+ this.stateValue.activeVideo.length > 0 ||
257
+ this.stateValue.speakers.length > 0) {
258
+ this.stateValue = {
259
+ ...this.stateValue,
260
+ activeSpeakers: [],
261
+ activeAudio: [],
262
+ activeVideo: [],
263
+ speakers: [],
264
+ activeSetEnforced: false,
265
+ };
266
+ }
267
+ }
268
+ /** The captures a move was carrying, for the republish on the far side.
269
+ * Program feeds are excluded exactly as they are from `lostSources`. */
270
+ buildStash() {
271
+ return [...this.publications.values()]
272
+ .filter((p) => p.source !== "program")
273
+ .map((p) => ({ track: p.track, kind: p.kind, source: p.source, paused: p.paused, options: p.options }));
274
+ }
275
+ /**
276
+ * A move that is over, one way or the other. On failure the stashed
277
+ * captures are STOPPED and surfaced as lost sources: the camera light must
278
+ * never outlive both its session and its migration, and from here the UX
279
+ * is exactly the ordinary-drop one, a person and a button.
280
+ */
281
+ stopStash() {
282
+ const migration = this.migration;
283
+ if (migration?.phase === "scheduled") {
284
+ clearTimeout(migration.timer);
285
+ this.migration = null;
286
+ return;
287
+ }
288
+ if (migration?.phase !== "moving")
289
+ return;
290
+ const sources = new Set();
291
+ for (const item of migration.stash) {
292
+ item.track.stop();
293
+ sources.add(item.source);
294
+ }
295
+ if (sources.size > 0)
296
+ this.lostSources = [...sources];
297
+ this.migration = null;
298
+ }
79
299
  get state() {
80
300
  return this.stateValue;
81
301
  }
@@ -139,6 +359,90 @@ class MediaRoom {
139
359
  get lostPublicationSources() {
140
360
  return this.lostSources;
141
361
  }
362
+ /**
363
+ * Why does the call look the way it looks, answered without
364
+ * webrtc-internals: per tile, the layer this client ASKED for, the layer
365
+ * the node is GIVING, and the picture the browser is decoding; plus the
366
+ * node's congestion estimate and this side's own publish health. Built for
367
+ * polling (a diagnostics HUD calls it once a second); bitrate is computed
368
+ * from the byte delta between calls. Degrades gracefully everywhere: an
369
+ * old node contributes nothing, a fake device without stats contributes
370
+ * nothing, and the local half always answers.
371
+ */
372
+ async getDiagnostics() {
373
+ const node = ((await this.requestOptional({ method: "diagnostics" })) ?? undefined);
374
+ // Defensive throughout: the verb is optional, and an old or odd node can
375
+ // answer with any shape (an empty object included). A diagnostics call
376
+ // must never be the thing that throws in a call.
377
+ const nodeByProducer = new Map((node?.consumers ?? []).map((c) => [c.producerId, c]));
378
+ const tiles = [];
379
+ for (const [producerId, consumer] of this.consumers) {
380
+ const entry = this.stateValue.producers.find((p) => p.producerId === producerId);
381
+ const width = this.viewportWidth(producerId);
382
+ const fromNode = nodeByProducer.get(producerId);
383
+ const inbound = await this.inboundStats(producerId, consumer);
384
+ tiles.push({
385
+ producerId,
386
+ identity: entry?.identity ?? "",
387
+ kind: consumer.kind,
388
+ ...(entry?.source ? { source: entry.source } : {}),
389
+ ...(this.sentLayer.has(producerId) ? { requestedLayer: this.sentLayer.get(producerId) } : {}),
390
+ ...(fromNode?.currentLayers ? { currentLayer: fromNode.currentLayers.spatialLayer } : {}),
391
+ ...(width !== undefined ? { viewportWidthPx: width } : {}),
392
+ pausedByViewport: this.pausedByViewport.has(producerId),
393
+ ...inbound,
394
+ });
395
+ }
396
+ const estimate = (node?.transports ?? []).find((t) => typeof t.availableOutgoingBitrate === "number");
397
+ return {
398
+ tiles,
399
+ ...(estimate ? { availableOutgoingBitrate: estimate.availableOutgoingBitrate } : {}),
400
+ ...(node?.producers?.length ? { localProducers: node.producers } : {}),
401
+ ...(node ? { node } : {}),
402
+ };
403
+ }
404
+ /** Byte counters from the previous `getDiagnostics`, for the bitrate delta. */
405
+ statsBaseline = new Map();
406
+ async inboundStats(producerId, consumer) {
407
+ if (!consumer.getStats)
408
+ return {};
409
+ let report;
410
+ try {
411
+ report = await consumer.getStats();
412
+ }
413
+ catch {
414
+ return {};
415
+ }
416
+ // An RTCStatsReport is a map; anything iterable of stats objects works.
417
+ const entries = [];
418
+ const iterable = report;
419
+ if (typeof iterable?.values === "function") {
420
+ for (const value of iterable.values())
421
+ entries.push(value);
422
+ }
423
+ else if (Array.isArray(report)) {
424
+ for (const value of report)
425
+ entries.push(value);
426
+ }
427
+ const inbound = entries.find((e) => e["type"] === "inbound-rtp");
428
+ if (!inbound)
429
+ return {};
430
+ const out = {
431
+ ...(typeof inbound["frameWidth"] === "number" ? { frameWidth: inbound["frameWidth"] } : {}),
432
+ ...(typeof inbound["frameHeight"] === "number" ? { frameHeight: inbound["frameHeight"] } : {}),
433
+ ...(typeof inbound["framesPerSecond"] === "number" ? { framesPerSecond: inbound["framesPerSecond"] } : {}),
434
+ };
435
+ const bytes = inbound["bytesReceived"];
436
+ if (typeof bytes === "number") {
437
+ const now = Date.now();
438
+ const baseline = this.statsBaseline.get(producerId);
439
+ this.statsBaseline.set(producerId, { bytes, at: now });
440
+ if (baseline && now > baseline.at && bytes >= baseline.bytes) {
441
+ out.bitrateKbps = Math.round(((bytes - baseline.bytes) * 8) / (now - baseline.at));
442
+ }
443
+ }
444
+ return out;
445
+ }
142
446
  /** Subscribe to changes. Returns an unsubscribe. */
143
447
  onChange(listener) {
144
448
  this.listeners.add(listener);
@@ -155,8 +459,10 @@ class MediaRoom {
155
459
  this.cancelReconnect();
156
460
  // A retry is not a first connection, and saying "Connecting to the call"
157
461
  // over a call somebody is already in reads as though they had been thrown
158
- // out of it.
159
- this.connection = this.reconnectAttempt > 0 ? "reconnecting" : "connecting";
462
+ // out of it. A planned MOVE is the same case with `reconnectAttempt`
463
+ // still 0: the person is mid-call, so the word is "reconnecting".
464
+ this.connection =
465
+ this.reconnectAttempt > 0 || this.migration?.phase === "moving" ? "reconnecting" : "connecting";
160
466
  this.emit();
161
467
  // A failed attempt normally reports itself through `signal.onClose`, which
162
468
  // routes to `onDisconnect`. This watches for that report so a rejection
@@ -192,14 +498,57 @@ class MediaRoom {
192
498
  // A successful join resets the ladder: an hour-long call that drops once
193
499
  // should not start at a 30-second delay because of a blip at minute two.
194
500
  this.reconnectAttempt = 0;
501
+ this.outageStartedAt = null;
195
502
  this.recovering = false;
196
503
  this.emit();
197
504
  if (this.options.autoSubscribe !== false)
198
505
  await this.syncSubscriptions();
506
+ // The far side of a planned move: the same capture tracks, republished
507
+ // with the same sources and options, nobody pressing anything. This is
508
+ // what "smooth" means; a drain used to be a mute-everything drop.
509
+ if (this.migration?.phase === "moving") {
510
+ const stash = this.migration.stash;
511
+ this.migration = null;
512
+ await this.restoreStash(stash);
513
+ }
514
+ }
515
+ async restoreStash(stash) {
516
+ const lost = new Set();
517
+ for (const item of stash) {
518
+ if (item.track.readyState !== "live") {
519
+ // The capture ended during the move (a camera unplugged, a share the
520
+ // browser's own bar stopped). Honest answer: the ordinary lost-source
521
+ // offer, a person and a button.
522
+ lost.add(item.source);
523
+ continue;
524
+ }
525
+ try {
526
+ const publication = await this.publish(item.track, item.source, item.options);
527
+ // Republished PAUSED state follows within one round trip. The wire
528
+ // has no start-paused on produce, so a muted mic is briefly live at
529
+ // the producer level; the capture was live locally the whole time.
530
+ if (item.paused)
531
+ await this.setPaused(publication.producerId, true);
532
+ }
533
+ catch {
534
+ item.track.stop();
535
+ lost.add(item.source);
536
+ }
537
+ }
538
+ if (lost.size > 0) {
539
+ this.lostSources = [...new Set([...this.lostSources, ...lost])];
540
+ this.emit();
541
+ }
199
542
  }
200
543
  async close() {
201
544
  this.disposed = true;
202
545
  this.cancelReconnect();
546
+ // A capture must never outlive both its session and its migration.
547
+ this.stopStash();
548
+ if (this.viewportTimer) {
549
+ clearTimeout(this.viewportTimer);
550
+ this.viewportTimer = undefined;
551
+ }
203
552
  this.recovering = false;
204
553
  this.connection = "closed";
205
554
  this.lostSources = NO_SOURCES;
@@ -227,9 +576,16 @@ class MediaRoom {
227
576
  scheduleReconnect(cause) {
228
577
  if (this.disposed)
229
578
  return false;
579
+ // The room opts into the TIME budget: a person is in front of this
580
+ // surface, and "Connection lost" over a node that restarts in ninety
581
+ // seconds is the failure the budget exists for. Headless callers keep
582
+ // the attempts-only policy by never passing a clock.
583
+ if (this.outageStartedAt === null)
584
+ this.outageStartedAt = Date.now();
230
585
  const decision = (0, core_1.decideReconnect)({
231
586
  cause,
232
587
  attempt: this.reconnectAttempt,
588
+ elapsedMs: Date.now() - this.outageStartedAt,
233
589
  ...(this.options.reconnect ? { options: this.options.reconnect } : {}),
234
590
  });
235
591
  if (decision.action === "stop") {
@@ -266,19 +622,40 @@ class MediaRoom {
266
622
  * `publishKinds`: a token granting audio only must not be able to publish a
267
623
  * screen share by relabelling it, and the node is where that is decided.
268
624
  */
269
- async publish(track, source) {
625
+ async publish(track, source, options = {}) {
270
626
  const kind = track.kind === "audio" ? "audio" : "video";
271
627
  if (!this.device.canProduce(kind)) {
272
628
  throw new Error(`this browser cannot produce ${kind}`);
273
629
  }
630
+ // The documented VP8-only exclusivity, enforced instead of conventional.
631
+ // H.264 simulcast depends on the hardware encoder and quietly degrades to
632
+ // one layer; sending the encodings anyway would claim layers that do not
633
+ // exist and a consumer's setPreferredLayers would silently do nothing.
634
+ let simulcast = options.simulcast ?? false;
635
+ if (simulcast && options.codec === "h264") {
636
+ this.options.onLog?.("warn", "simulcast dropped: it is unreliable on h264, publish vp8 or one encoding");
637
+ simulcast = false;
638
+ }
274
639
  const transport = await this.ensureSendTransport();
275
- const handle = await transport.produce({ track, appData: { source } });
640
+ const handle = await transport.produce({
641
+ track,
642
+ appData: { source },
643
+ ...(options.codec ? { codec: options.codec } : {}),
644
+ ...(simulcast
645
+ ? { encodings: simulcastEncodings(options.maxBitrateKbps) }
646
+ : options.maxBitrateKbps
647
+ ? { encodings: [{ maxBitrate: options.maxBitrateKbps * 1000 }] }
648
+ : {}),
649
+ });
650
+ if (source === "program")
651
+ await keepProgramResolution(handle, this.options.onLog);
276
652
  const publication = {
277
653
  producerId: handle.id,
278
654
  kind,
279
655
  source,
280
656
  track,
281
657
  handle,
658
+ options,
282
659
  paused: false,
283
660
  };
284
661
  this.publications.set(handle.id, publication);
@@ -289,7 +666,7 @@ class MediaRoom {
289
666
  this.trackEndWatchers.set(handle.id, whenTrackEnds(track, () => {
290
667
  void this.unpublish(handle.id);
291
668
  }));
292
- if (this.lostSources.includes(source)) {
669
+ if (source !== "program" && this.lostSources.includes(source)) {
293
670
  this.lostSources = this.lostSources.filter((s) => s !== source);
294
671
  if (this.lostSources.length === 0)
295
672
  this.lostSources = NO_SOURCES;
@@ -297,6 +674,33 @@ class MediaRoom {
297
674
  this.emit();
298
675
  return publication;
299
676
  }
677
+ /**
678
+ * Swap the capture behind a publication, keeping the producer.
679
+ *
680
+ * A device change (another camera, another microphone) is this, and it is
681
+ * deliberately NOT unpublish plus publish: that changes the producer id,
682
+ * which is the key every consumer's tile and every studio's scene holds, so
683
+ * a switched camera vanished from the stage until somebody re-added it.
684
+ * The old capture is stopped here; the new one is watched for ending the
685
+ * same way the first was.
686
+ */
687
+ async replaceTrack(producerId, track) {
688
+ const publication = this.publications.get(producerId);
689
+ if (!publication)
690
+ throw new Error("no such publication");
691
+ if (track.kind !== publication.kind)
692
+ throw new Error(`a ${publication.kind} publication cannot carry ${track.kind}`);
693
+ if (track === publication.track)
694
+ return;
695
+ await publication.handle.replaceTrack(track);
696
+ this.trackEndWatchers.get(producerId)?.();
697
+ publication.track.stop();
698
+ this.trackEndWatchers.set(producerId, whenTrackEnds(track, () => {
699
+ void this.unpublish(producerId);
700
+ }));
701
+ this.publications.set(producerId, { ...publication, track });
702
+ this.emit();
703
+ }
300
704
  async unpublish(producerId) {
301
705
  const publication = this.publications.get(producerId);
302
706
  if (!publication)
@@ -352,21 +756,178 @@ class MediaRoom {
352
756
  * are the same thing and this needs no special case.
353
757
  */
354
758
  async syncSubscriptions() {
355
- const wanted = new Set(this.stateValue.activeSpeakers);
759
+ const state = this.stateValue;
760
+ // AUDIO NEVER NARROWS BY VIEWPORT. You must hear people you cannot see.
761
+ // This asymmetry is deliberate; do not tidy it into symmetry. Only the
762
+ // video half of the set is intersected with what the UI reports visible.
763
+ // The reducer always derives the split (an unknown-kind id lands on the
764
+ // audio side), so the two halves together ARE the active set.
765
+ const audioWanted = new Set(state.activeAudio);
766
+ const videoAllowed = new Set(state.activeVideo);
767
+ const videoWanted = this.viewport === null
768
+ ? videoAllowed
769
+ : new Set(this.viewport.map((e) => e.producerId).filter((id) => videoAllowed.has(id)));
770
+ // NEVER wider than the node's set: the node refuses a consume outside it,
771
+ // and a subset of the shared set is what keeps the pipe math bounded.
772
+ const wanted = new Set([...audioWanted, ...videoWanted]);
773
+ const allowed = new Set([...audioWanted, ...videoAllowed]);
356
774
  for (const [producerId, consumer] of this.consumers) {
357
- if (wanted.has(producerId))
775
+ if (wanted.has(producerId)) {
776
+ this.cancelViewportPause(producerId, consumer);
358
777
  continue;
359
- // Dropped rather than paused. A consumer the node has moved out of the
360
- // active set is one it may refuse to keep feeding, and holding it costs
361
- // the node a consumer object for a tile nobody is looking at.
362
- consumer.close();
363
- this.consumers.delete(producerId);
364
- this.tracksByProducer.delete(producerId);
365
- await this.signal.request({ method: "closeConsumer", consumerId: consumer.id }).catch(() => undefined);
778
+ }
779
+ if (!allowed.has(producerId)) {
780
+ // Out of the ACTIVE SET entirely: dropped rather than paused, today's
781
+ // semantics. The node may refuse to keep feeding it, and holding it
782
+ // costs the node a consumer object for a tile nobody may render.
783
+ this.dropConsumer(producerId, consumer);
784
+ await this.signal.request({ method: "closeConsumer", consumerId: consumer.id }).catch(() => undefined);
785
+ continue;
786
+ }
787
+ // In the set but off the viewport: pause now, close after the grace.
788
+ this.beginViewportPause(producerId, consumer);
366
789
  }
367
790
  await Promise.all([...wanted].map((producerId) => this.subscribe(producerId)));
791
+ if (this.viewport !== null)
792
+ this.applyPreferredLayers();
368
793
  this.emit();
369
794
  }
795
+ /**
796
+ * Tell the room what the UI is rendering: which producers, at what width.
797
+ *
798
+ * Debounced inside the room (a scroll fires many times), trailing 150ms.
799
+ * Never call this from a headless consumer; not calling it IS the legacy
800
+ * subscribe-the-whole-set behavior.
801
+ */
802
+ setViewport(entries) {
803
+ // The FIRST report commits at once: a debounce there would let the join's
804
+ // first active-set frame subscribe the whole set 150ms before the
805
+ // narrowing arrived, which is the cost this API exists to avoid. Updates
806
+ // (scroll, resize) debounce.
807
+ if (this.viewport === null) {
808
+ this.viewport = entries;
809
+ void this.syncSubscriptions().catch((err) => this.options.onLog?.("warn", "viewport sync failed", err));
810
+ return;
811
+ }
812
+ this.pendingViewport = entries;
813
+ if (this.viewportTimer)
814
+ return;
815
+ this.viewportTimer = setTimeout(() => {
816
+ this.viewportTimer = undefined;
817
+ const pending = this.pendingViewport;
818
+ this.pendingViewport = undefined;
819
+ if (pending === undefined)
820
+ return;
821
+ this.viewport = pending;
822
+ void this.syncSubscriptions().catch((err) => this.options.onLog?.("warn", "viewport sync failed", err));
823
+ }, 150);
824
+ }
825
+ /** Back to the legacy behavior: subscribe the whole active set. */
826
+ clearViewport() {
827
+ if (this.viewportTimer) {
828
+ clearTimeout(this.viewportTimer);
829
+ this.viewportTimer = undefined;
830
+ }
831
+ this.pendingViewport = undefined;
832
+ if (this.viewport === null)
833
+ return;
834
+ this.viewport = null;
835
+ void this.syncSubscriptions().catch((err) => this.options.onLog?.("warn", "viewport sync failed", err));
836
+ }
837
+ /** The width the UI reported for a producer's tile, if it reported one. */
838
+ viewportWidth(producerId) {
839
+ return this.viewport?.find((e) => e.producerId === producerId)?.widthPx;
840
+ }
841
+ dropConsumer(producerId, consumer) {
842
+ consumer.close();
843
+ this.consumers.delete(producerId);
844
+ this.tracksByProducer.delete(producerId);
845
+ this.sentLayer.delete(producerId);
846
+ this.statsBaseline.delete(producerId);
847
+ const timer = this.pausedByViewport.get(producerId);
848
+ if (timer)
849
+ clearTimeout(timer);
850
+ this.pausedByViewport.delete(producerId);
851
+ }
852
+ beginViewportPause(producerId, consumer) {
853
+ if (this.pausedByViewport.has(producerId))
854
+ return;
855
+ // Local pause detaches nothing on its own; the server-side pause is what
856
+ // stops the RTP. An old node refuses the verb, which degrades to
857
+ // local-pause-only, and the grace close still frees everything.
858
+ consumer.pause();
859
+ void this.requestOptional({ method: "pauseConsumer", consumerId: consumer.id });
860
+ const timer = setTimeout(() => {
861
+ // Re-checked at fire time: a flip back cancels the timer, but a timer
862
+ // racing its own cancellation must not close a tile somebody is watching.
863
+ if (!this.pausedByViewport.has(producerId))
864
+ return;
865
+ this.pausedByViewport.delete(producerId);
866
+ const current = this.consumers.get(producerId);
867
+ if (!current)
868
+ return;
869
+ this.dropConsumer(producerId, current);
870
+ void this.signal.request({ method: "closeConsumer", consumerId: current.id }).catch(() => undefined);
871
+ this.emit();
872
+ }, this.options.viewportCloseGraceMs ?? 10_000);
873
+ this.pausedByViewport.set(producerId, timer);
874
+ }
875
+ cancelViewportPause(producerId, consumer) {
876
+ const timer = this.pausedByViewport.get(producerId);
877
+ if (timer === undefined)
878
+ return;
879
+ clearTimeout(timer);
880
+ this.pausedByViewport.delete(producerId);
881
+ consumer.resume();
882
+ void this.requestOptional({ method: "resumeConsumer", consumerId: consumer.id });
883
+ // A resumed video consumer shows garbage until an I-frame arrives. Ask,
884
+ // rather than waiting out the encoder's own schedule.
885
+ if (consumer.kind === "video") {
886
+ void this.signal.request({ method: "requestKeyFrame", consumerId: consumer.id }).catch(() => undefined);
887
+ }
888
+ }
889
+ /**
890
+ * Bring every live video consumer onto the layer its reported width implies.
891
+ * Bucket-change only: the last sent layer is remembered, so a resize storm
892
+ * costs nothing until a boundary is crossed. A layer change keeps the
893
+ * consumer, the pipe and the SSRC, which is what makes page flips instant.
894
+ */
895
+ applyPreferredLayers() {
896
+ for (const [producerId, consumer] of this.consumers) {
897
+ if (consumer.kind !== "video" || this.pausedByViewport.has(producerId))
898
+ continue;
899
+ const width = this.viewportWidth(producerId);
900
+ if (width === undefined)
901
+ continue;
902
+ const entry = this.stateValue.producers.find((p) => p.producerId === producerId);
903
+ const layer = spatialLayerForWidth(width, entry?.source);
904
+ if (this.sentLayer.get(producerId) === layer)
905
+ continue;
906
+ this.sentLayer.set(producerId, layer);
907
+ void this.requestOptional({ method: "setPreferredLayers", consumerId: consumer.id, spatialLayer: layer });
908
+ }
909
+ }
910
+ /**
911
+ * A request an OLD node may not understand. Disabled for the session on the
912
+ * first `bad_request` rather than retried: a retry loop meets the node's
913
+ * frame budget, and the frame budget closes sockets.
914
+ */
915
+ async requestOptional(frame) {
916
+ if (this.unsupported.has(frame.method))
917
+ return undefined;
918
+ try {
919
+ return await this.signal.request(frame);
920
+ }
921
+ catch (error) {
922
+ if (error.code === "bad_request") {
923
+ this.unsupported.add(frame.method);
924
+ this.options.onLog?.("warn", `${frame.method} is not supported by this node, feature disabled`);
925
+ return undefined;
926
+ }
927
+ this.options.onLog?.("warn", `${frame.method} failed`, error);
928
+ return undefined;
929
+ }
930
+ }
370
931
  async subscribe(producerId) {
371
932
  if (this.consumers.has(producerId))
372
933
  return;
@@ -384,11 +945,19 @@ class MediaRoom {
384
945
  if (!entry)
385
946
  return;
386
947
  const transport = await this.ensureRecvTransport();
948
+ // The layer the tile's reported width implies, asked for from the FIRST
949
+ // frame. Advisory: a non-simulcast producer has no layers, and an older
950
+ // node strips the key.
951
+ const width = entry.kind === "video" ? this.viewportWidth(producerId) : undefined;
952
+ const layer = width === undefined ? undefined : spatialLayerForWidth(width, entry.source);
953
+ if (layer !== undefined)
954
+ this.sentLayer.set(producerId, layer);
387
955
  const response = (await this.signal.request({
388
956
  method: "consume",
389
957
  transportId: transport.id,
390
958
  producerId,
391
959
  rtpCapabilities: this.device.rtpCapabilities,
960
+ ...(layer !== undefined ? { preferredLayers: { spatialLayer: layer } } : {}),
392
961
  }));
393
962
  const consumer = await transport.consume({
394
963
  id: response.consumerId,
@@ -403,6 +972,7 @@ class MediaRoom {
403
972
  kind: response.kind,
404
973
  track: consumer.track,
405
974
  paused: entry.paused,
975
+ ...(entry.source ? { source: entry.source } : {}),
406
976
  });
407
977
  // RULE 3: resume LAST. The node creates every consumer paused, so media
408
978
  // arriving before the application has the track is dropped - and for video
@@ -412,6 +982,86 @@ class MediaRoom {
412
982
  this.emit();
413
983
  }
414
984
  // -------------------------------------------------------------------------
985
+ // ephemeral signals: reactions, hands
986
+ // -------------------------------------------------------------------------
987
+ /**
988
+ * Send an ephemeral, unstored fan-out to the room: a flying reaction, a
989
+ * courtesy lower-hand. Grant-gated on the node (`canPublishData`) and
990
+ * rate-limited there; NOT a chat transport, and nothing durable may be
991
+ * derived from one.
992
+ */
993
+ async sendReaction(data) {
994
+ await this.requestOptional({ method: "broadcast", type: "reaction", data });
995
+ }
996
+ /**
997
+ * Ask another participant to lower their hand. Honorific by design: the
998
+ * TARGET's client obeys by calling `setHandRaised(false)` itself; the
999
+ * server-held hand state only ever moves on its owner's verb.
1000
+ */
1001
+ async sendLowerHand(target) {
1002
+ await this.requestOptional({ method: "broadcast", type: "lowerHand", data: { target } });
1003
+ }
1004
+ /**
1005
+ * Raise or lower this participant's own hand. Server-materialized room
1006
+ * state: it survives reconnects and appears in late joiners' snapshots, so
1007
+ * `state.raisedHands` is the truth to render, not the reply.
1008
+ */
1009
+ async setHandRaised(raised) {
1010
+ await this.requestOptional({ method: "setHand", raised });
1011
+ }
1012
+ /**
1013
+ * Incoming broadcasts, as EVENTS rather than state: a reaction is an
1014
+ * animation, and holding a list of them in `RoomState` would make every
1015
+ * emoji a whole-room re-render. Unknown types included; ignore what you
1016
+ * do not recognize.
1017
+ */
1018
+ onBroadcast(listener) {
1019
+ this.broadcastListeners.add(listener);
1020
+ return () => this.broadcastListeners.delete(listener);
1021
+ }
1022
+ // -------------------------------------------------------------------------
1023
+ // the planned move
1024
+ // -------------------------------------------------------------------------
1025
+ /**
1026
+ * The node asked to be left, with a jittered window. THE fix for the drain
1027
+ * experience: this frame used to be recorded and ignored, so the "told,
1028
+ * not cut" design never executed and everyone was cut at the deadline.
1029
+ * The call continues untouched during the wait; the UI is not told.
1030
+ */
1031
+ onDrainingFrame(reconnectAfterMs) {
1032
+ if (this.disposed || this.migration !== null)
1033
+ return;
1034
+ if (this.connection !== "connected")
1035
+ return;
1036
+ const timer = setTimeout(() => this.beginPlannedMove(), Math.max(0, reconnectAfterMs));
1037
+ this.migration = { phase: "scheduled", timer };
1038
+ }
1039
+ beginPlannedMove() {
1040
+ if (this.migration?.phase !== "scheduled")
1041
+ return;
1042
+ if (this.connection !== "connected" || this.disposed) {
1043
+ // A real drop got here first; its path owns the recovery.
1044
+ this.migration = null;
1045
+ return;
1046
+ }
1047
+ /**
1048
+ * The stash is NOT built here. `leave()` below is a full round trip (up
1049
+ * to the request timeout against a busy node), and the person can mute,
1050
+ * unmute, start or stop a capture the whole time: a stash taken now
1051
+ * republishes a mic they have since muted, and a share they started in
1052
+ * the window would be orphaned with its light on. The close handler
1053
+ * builds the stash at the moment the connection actually ends, when the
1054
+ * publications map is the truth.
1055
+ */
1056
+ this.migration = { phase: "moving", stash: NO_STASH, plannedClose: true };
1057
+ /**
1058
+ * `leave()`, not a bare close: the leave request makes the OLD node tear
1059
+ * our participant record down synchronously, so the rejoin seconds later
1060
+ * cannot meet its own ghost and be refused `duplicate_identity`.
1061
+ */
1062
+ void this.signal.leave().catch(() => undefined);
1063
+ }
1064
+ // -------------------------------------------------------------------------
415
1065
  // transports
416
1066
  // -------------------------------------------------------------------------
417
1067
  /**
@@ -498,6 +1148,19 @@ class MediaRoom {
498
1148
  this.stateValue = next;
499
1149
  if (frame.event === "joined")
500
1150
  this.grantsValue = frame.grants ?? this.grantsValue;
1151
+ if (frame.event === "draining")
1152
+ this.onDrainingFrame(frame.reconnectAfterMs);
1153
+ if (frame.event === "broadcast") {
1154
+ const event = { type: frame.type, identity: frame.identity, data: frame.data, at: frame.at };
1155
+ for (const listener of this.broadcastListeners) {
1156
+ try {
1157
+ listener(event);
1158
+ }
1159
+ catch {
1160
+ // One listener throwing must not stop the others from being told.
1161
+ }
1162
+ }
1163
+ }
501
1164
  if (frame.event === "producerClosed") {
502
1165
  const consumer = this.consumers.get(frame.producerId);
503
1166
  consumer?.close();
@@ -530,17 +1193,30 @@ class MediaRoom {
530
1193
  * node tears the session down on close anyway. Ending one publication on a
531
1194
  * LIVE connection is `unpublish`, which does tell it.
532
1195
  */
533
- teardownMedia() {
1196
+ teardownMedia(options = {}) {
534
1197
  for (const consumer of this.consumers.values())
535
1198
  consumer.close();
536
1199
  this.consumers.clear();
537
1200
  this.tracksByProducer.clear();
1201
+ // Viewport pause bookkeeping belongs to the consumers that just closed.
1202
+ // The viewport ITSELF is kept: it is the UI's statement about what it
1203
+ // renders, and it applies to the next connection unchanged.
1204
+ for (const timer of this.pausedByViewport.values())
1205
+ clearTimeout(timer);
1206
+ this.pausedByViewport.clear();
1207
+ this.sentLayer.clear();
1208
+ this.statsBaseline.clear();
538
1209
  for (const detach of this.trackEndWatchers.values())
539
1210
  detach();
540
1211
  this.trackEndWatchers.clear();
541
1212
  for (const publication of this.publications.values()) {
542
1213
  publication.handle.close();
543
- publication.track.stop();
1214
+ // A PLANNED move keeps the captures: the stash holds the same track
1215
+ // objects and republishes them on the far side, so the camera light
1216
+ // never blinks for a server deploy. Every other teardown stops them,
1217
+ // for the privacy reasons the docblock above states.
1218
+ if (!options.preserveCaptures)
1219
+ publication.track.stop();
544
1220
  }
545
1221
  this.publications.clear();
546
1222
  for (const slot of Object.values(this.transports)) {
@@ -570,6 +1246,8 @@ class MediaRoom {
570
1246
  }
571
1247
  exports.MediaRoom = MediaRoom;
572
1248
  const NO_SOURCES = [];
1249
+ /** Placeholder until the close handler builds the real stash. */
1250
+ const NO_STASH = [];
573
1251
  /**
574
1252
  * Call `onEnded` when a track ends on its own, and return the detach.
575
1253
  *
@@ -592,4 +1270,44 @@ async function connectToRoom(options) {
592
1270
  await room.connect();
593
1271
  return room;
594
1272
  }
1273
+ /**
1274
+ * The program feed keeps its RESOLUTION and gives up frame rate instead.
1275
+ *
1276
+ * Chrome's default for a video sender is `balanced`, which under a low
1277
+ * bandwidth estimate or CPU pressure scales the encode down. For a camera tile
1278
+ * that is the right trade. For the program feed it is wrong twice over.
1279
+ *
1280
+ * First, this feed is the broadcast: a 1080p composite arriving at Twitch as
1281
+ * 270p is the product, not a degraded preview. Twitch reported exactly that.
1282
+ *
1283
+ * Second, and worse, the RTMP leg cannot survive the CHANGE. The egress muxes
1284
+ * the feed with `-c:v copy`, and an FLV header carries the dimensions once, at
1285
+ * the start. When Chrome adapts resolution mid-stream the SPS changes under a
1286
+ * header that still describes the old size, and the player refuses the stream:
1287
+ *
1288
+ * Your browser encountered an error while decoding the video. (Error #3000)
1289
+ *
1290
+ * while ffmpeg reports `drop_frames=0`, a healthy bitrate, and an established
1291
+ * socket. Nothing on the server can see it.
1292
+ *
1293
+ * `maintain-resolution` makes the encoder drop frames rather than pixels, so
1294
+ * the SPS stays put for the life of the broadcast.
1295
+ *
1296
+ * Best-effort: the setting is not universally implemented, and a browser that
1297
+ * ignores it is no worse off than before. Only the program source, because a
1298
+ * camera is better off shrinking than freezing.
1299
+ */
1300
+ async function keepProgramResolution(handle, onLog) {
1301
+ const sender = handle.rtpSender;
1302
+ if (!sender || handle.kind !== "video")
1303
+ return;
1304
+ try {
1305
+ const params = sender.getParameters();
1306
+ params.degradationPreference = "maintain-resolution";
1307
+ await sender.setParameters(params);
1308
+ }
1309
+ catch (err) {
1310
+ onLog?.("warn", "could not pin the program feed's resolution", err);
1311
+ }
1312
+ }
595
1313
  //# sourceMappingURL=room.js.map