@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.
- package/build/core/index.d.ts +1 -1
- package/build/core/index.d.ts.map +1 -1
- package/build/core/index.js.map +1 -1
- package/build/core/reconnect.d.ts +29 -0
- package/build/core/reconnect.d.ts.map +1 -1
- package/build/core/reconnect.js +49 -9
- package/build/core/reconnect.js.map +1 -1
- package/build/core/signal.d.ts +9 -0
- package/build/core/signal.d.ts.map +1 -1
- package/build/core/signal.js +18 -2
- package/build/core/signal.js.map +1 -1
- package/build/core/state.d.ts +28 -0
- package/build/core/state.d.ts.map +1 -1
- package/build/core/state.js +106 -7
- package/build/core/state.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js.map +1 -1
- package/build/react/index.d.ts +63 -11
- package/build/react/index.d.ts.map +1 -1
- package/build/react/index.js +229 -5
- package/build/react/index.js.map +1 -1
- package/build/room/device.d.ts +6 -0
- package/build/room/device.d.ts.map +1 -1
- package/build/room/room.d.ts +235 -1
- package/build/room/room.d.ts.map +1 -1
- package/build/room/room.js +627 -24
- package/build/room/room.js.map +1 -1
- package/package.json +2 -2
- package/src/core/_tests/reconnect.spec.ts +92 -0
- package/src/core/_tests/state.spec.ts +92 -0
- package/src/core/index.ts +2 -0
- package/src/core/reconnect.ts +76 -9
- package/src/core/signal.ts +16 -2
- package/src/core/state.ts +153 -10
- package/src/index.ts +2 -0
- package/src/react/index.tsx +261 -7
- package/src/room/_tests/room.spec.ts +720 -3
- package/src/room/device.ts +6 -0
- package/src/room/room.ts +733 -25
package/build/room/room.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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");
|
|
6
7
|
/**
|
|
@@ -10,12 +11,40 @@ const core_1 = require("../core");
|
|
|
10
11
|
* whatever resolution the caller is publishing. The rids are the conventional
|
|
11
12
|
* one-letter names every SFU and every browser log uses, which is worth more
|
|
12
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.
|
|
13
24
|
*/
|
|
14
25
|
const SIMULCAST_LAYERS = [
|
|
15
|
-
{ rid: "q", scaleResolutionDownBy: 4, scalabilityMode: "
|
|
16
|
-
{ rid: "h", scaleResolutionDownBy: 2, scalabilityMode: "
|
|
17
|
-
{ rid: "f", scaleResolutionDownBy: 1, scalabilityMode: "
|
|
26
|
+
{ rid: "q", scaleResolutionDownBy: 4, scalabilityMode: "L1T3" },
|
|
27
|
+
{ rid: "h", scaleResolutionDownBy: 2, scalabilityMode: "L1T3" },
|
|
28
|
+
{ rid: "f", scaleResolutionDownBy: 1, scalabilityMode: "L1T3" },
|
|
18
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
|
+
}
|
|
19
48
|
/**
|
|
20
49
|
* What each layer may spend, given the caller's cap for the top one.
|
|
21
50
|
*
|
|
@@ -50,14 +79,45 @@ class MediaRoom {
|
|
|
50
79
|
/** Per publication: stop listening for the track ending on its own. */
|
|
51
80
|
trackEndWatchers = new Map();
|
|
52
81
|
listeners = new Set();
|
|
82
|
+
broadcastListeners = new Set();
|
|
53
83
|
/** One in-flight subscribe per producer, so a burst of activeSpeakers frames
|
|
54
84
|
* does not race itself into two consumers for one producer. */
|
|
55
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();
|
|
56
104
|
stateValue = core_1.initialRoomState;
|
|
57
105
|
connection = "idle";
|
|
58
106
|
lastError;
|
|
59
107
|
/** Consecutive failed attempts since the last successful join. */
|
|
60
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;
|
|
61
121
|
reconnectTimer;
|
|
62
122
|
recovering = false;
|
|
63
123
|
/** `close()` has been called. Nothing may open a socket after that. */
|
|
@@ -78,6 +138,68 @@ class MediaRoom {
|
|
|
78
138
|
* it ended after an hour or before a socket was ever opened.
|
|
79
139
|
*/
|
|
80
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
|
+
}
|
|
81
203
|
this.lastError = cause;
|
|
82
204
|
// What the person was sending, before it is torn down. Recorded so the
|
|
83
205
|
// screen can say so once the room is back (see `lostPublicationSources`).
|
|
@@ -90,17 +212,15 @@ class MediaRoom {
|
|
|
90
212
|
];
|
|
91
213
|
// Before the decision, and unconditionally. The transports are dead
|
|
92
214
|
// whatever happens next, and so is every capture that was feeding them.
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (this.stateValue.activeSpeakers.length > 0) {
|
|
98
|
-
this.stateValue = { ...this.stateValue, activeSpeakers: [] };
|
|
99
|
-
}
|
|
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();
|
|
100
219
|
// `disposed` as well as the cause: `close()` is a client close whatever the
|
|
101
220
|
// socket's own account of it, and a room that has been closed must never
|
|
102
221
|
// report itself as coming back.
|
|
103
222
|
if (cause.type === "closed_by_client" || this.disposed) {
|
|
223
|
+
this.stopStash();
|
|
104
224
|
this.connection = "closed";
|
|
105
225
|
this.recovering = false;
|
|
106
226
|
this.emit();
|
|
@@ -110,8 +230,72 @@ class MediaRoom {
|
|
|
110
230
|
this.lostSources = wasPublishing;
|
|
111
231
|
this.connection = "reconnecting";
|
|
112
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
|
+
}
|
|
113
245
|
this.emit();
|
|
114
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
|
+
}
|
|
115
299
|
get state() {
|
|
116
300
|
return this.stateValue;
|
|
117
301
|
}
|
|
@@ -175,6 +359,90 @@ class MediaRoom {
|
|
|
175
359
|
get lostPublicationSources() {
|
|
176
360
|
return this.lostSources;
|
|
177
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
|
+
}
|
|
178
446
|
/** Subscribe to changes. Returns an unsubscribe. */
|
|
179
447
|
onChange(listener) {
|
|
180
448
|
this.listeners.add(listener);
|
|
@@ -191,8 +459,10 @@ class MediaRoom {
|
|
|
191
459
|
this.cancelReconnect();
|
|
192
460
|
// A retry is not a first connection, and saying "Connecting to the call"
|
|
193
461
|
// over a call somebody is already in reads as though they had been thrown
|
|
194
|
-
// out of it.
|
|
195
|
-
|
|
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";
|
|
196
466
|
this.emit();
|
|
197
467
|
// A failed attempt normally reports itself through `signal.onClose`, which
|
|
198
468
|
// routes to `onDisconnect`. This watches for that report so a rejection
|
|
@@ -228,14 +498,57 @@ class MediaRoom {
|
|
|
228
498
|
// A successful join resets the ladder: an hour-long call that drops once
|
|
229
499
|
// should not start at a 30-second delay because of a blip at minute two.
|
|
230
500
|
this.reconnectAttempt = 0;
|
|
501
|
+
this.outageStartedAt = null;
|
|
231
502
|
this.recovering = false;
|
|
232
503
|
this.emit();
|
|
233
504
|
if (this.options.autoSubscribe !== false)
|
|
234
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
|
+
}
|
|
235
542
|
}
|
|
236
543
|
async close() {
|
|
237
544
|
this.disposed = true;
|
|
238
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
|
+
}
|
|
239
552
|
this.recovering = false;
|
|
240
553
|
this.connection = "closed";
|
|
241
554
|
this.lostSources = NO_SOURCES;
|
|
@@ -263,9 +576,16 @@ class MediaRoom {
|
|
|
263
576
|
scheduleReconnect(cause) {
|
|
264
577
|
if (this.disposed)
|
|
265
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();
|
|
266
585
|
const decision = (0, core_1.decideReconnect)({
|
|
267
586
|
cause,
|
|
268
587
|
attempt: this.reconnectAttempt,
|
|
588
|
+
elapsedMs: Date.now() - this.outageStartedAt,
|
|
269
589
|
...(this.options.reconnect ? { options: this.options.reconnect } : {}),
|
|
270
590
|
});
|
|
271
591
|
if (decision.action === "stop") {
|
|
@@ -307,12 +627,21 @@ class MediaRoom {
|
|
|
307
627
|
if (!this.device.canProduce(kind)) {
|
|
308
628
|
throw new Error(`this browser cannot produce ${kind}`);
|
|
309
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
|
+
}
|
|
310
639
|
const transport = await this.ensureSendTransport();
|
|
311
640
|
const handle = await transport.produce({
|
|
312
641
|
track,
|
|
313
642
|
appData: { source },
|
|
314
643
|
...(options.codec ? { codec: options.codec } : {}),
|
|
315
|
-
...(
|
|
644
|
+
...(simulcast
|
|
316
645
|
? { encodings: simulcastEncodings(options.maxBitrateKbps) }
|
|
317
646
|
: options.maxBitrateKbps
|
|
318
647
|
? { encodings: [{ maxBitrate: options.maxBitrateKbps * 1000 }] }
|
|
@@ -326,6 +655,7 @@ class MediaRoom {
|
|
|
326
655
|
source,
|
|
327
656
|
track,
|
|
328
657
|
handle,
|
|
658
|
+
options,
|
|
329
659
|
paused: false,
|
|
330
660
|
};
|
|
331
661
|
this.publications.set(handle.id, publication);
|
|
@@ -426,21 +756,178 @@ class MediaRoom {
|
|
|
426
756
|
* are the same thing and this needs no special case.
|
|
427
757
|
*/
|
|
428
758
|
async syncSubscriptions() {
|
|
429
|
-
const
|
|
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]);
|
|
430
774
|
for (const [producerId, consumer] of this.consumers) {
|
|
431
|
-
if (wanted.has(producerId))
|
|
775
|
+
if (wanted.has(producerId)) {
|
|
776
|
+
this.cancelViewportPause(producerId, consumer);
|
|
432
777
|
continue;
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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);
|
|
440
789
|
}
|
|
441
790
|
await Promise.all([...wanted].map((producerId) => this.subscribe(producerId)));
|
|
791
|
+
if (this.viewport !== null)
|
|
792
|
+
this.applyPreferredLayers();
|
|
442
793
|
this.emit();
|
|
443
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
|
+
}
|
|
444
931
|
async subscribe(producerId) {
|
|
445
932
|
if (this.consumers.has(producerId))
|
|
446
933
|
return;
|
|
@@ -458,11 +945,19 @@ class MediaRoom {
|
|
|
458
945
|
if (!entry)
|
|
459
946
|
return;
|
|
460
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);
|
|
461
955
|
const response = (await this.signal.request({
|
|
462
956
|
method: "consume",
|
|
463
957
|
transportId: transport.id,
|
|
464
958
|
producerId,
|
|
465
959
|
rtpCapabilities: this.device.rtpCapabilities,
|
|
960
|
+
...(layer !== undefined ? { preferredLayers: { spatialLayer: layer } } : {}),
|
|
466
961
|
}));
|
|
467
962
|
const consumer = await transport.consume({
|
|
468
963
|
id: response.consumerId,
|
|
@@ -487,6 +982,86 @@ class MediaRoom {
|
|
|
487
982
|
this.emit();
|
|
488
983
|
}
|
|
489
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
|
+
// -------------------------------------------------------------------------
|
|
490
1065
|
// transports
|
|
491
1066
|
// -------------------------------------------------------------------------
|
|
492
1067
|
/**
|
|
@@ -573,6 +1148,19 @@ class MediaRoom {
|
|
|
573
1148
|
this.stateValue = next;
|
|
574
1149
|
if (frame.event === "joined")
|
|
575
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
|
+
}
|
|
576
1164
|
if (frame.event === "producerClosed") {
|
|
577
1165
|
const consumer = this.consumers.get(frame.producerId);
|
|
578
1166
|
consumer?.close();
|
|
@@ -605,17 +1193,30 @@ class MediaRoom {
|
|
|
605
1193
|
* node tears the session down on close anyway. Ending one publication on a
|
|
606
1194
|
* LIVE connection is `unpublish`, which does tell it.
|
|
607
1195
|
*/
|
|
608
|
-
teardownMedia() {
|
|
1196
|
+
teardownMedia(options = {}) {
|
|
609
1197
|
for (const consumer of this.consumers.values())
|
|
610
1198
|
consumer.close();
|
|
611
1199
|
this.consumers.clear();
|
|
612
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();
|
|
613
1209
|
for (const detach of this.trackEndWatchers.values())
|
|
614
1210
|
detach();
|
|
615
1211
|
this.trackEndWatchers.clear();
|
|
616
1212
|
for (const publication of this.publications.values()) {
|
|
617
1213
|
publication.handle.close();
|
|
618
|
-
|
|
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();
|
|
619
1220
|
}
|
|
620
1221
|
this.publications.clear();
|
|
621
1222
|
for (const slot of Object.values(this.transports)) {
|
|
@@ -645,6 +1246,8 @@ class MediaRoom {
|
|
|
645
1246
|
}
|
|
646
1247
|
exports.MediaRoom = MediaRoom;
|
|
647
1248
|
const NO_SOURCES = [];
|
|
1249
|
+
/** Placeholder until the close handler builds the real stash. */
|
|
1250
|
+
const NO_STASH = [];
|
|
648
1251
|
/**
|
|
649
1252
|
* Call `onEnded` when a track ends on its own, and return the detach.
|
|
650
1253
|
*
|