@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.
- 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 +31 -0
- package/build/core/state.d.ts.map +1 -1
- package/build/core/state.js +113 -8
- 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 +78 -16
- package/build/react/index.d.ts.map +1 -1
- package/build/react/index.js +289 -12
- package/build/react/index.js.map +1 -1
- package/build/room/browserDevice.d.ts.map +1 -1
- package/build/room/browserDevice.js +13 -4
- package/build/room/browserDevice.js.map +1 -1
- package/build/room/device.d.ts +19 -0
- package/build/room/device.d.ts.map +1 -1
- package/build/room/room.d.ts +298 -3
- package/build/room/room.d.ts.map +1 -1
- package/build/room/room.js +742 -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 +138 -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 +163 -11
- package/src/index.ts +2 -0
- package/src/react/index.tsx +323 -19
- package/src/room/_tests/room.spec.ts +954 -4
- package/src/room/browserDevice.ts +14 -4
- package/src/room/device.ts +19 -0
- package/src/room/room.ts +913 -26
package/src/room/room.ts
CHANGED
|
@@ -81,17 +81,203 @@ export type MediaTrack = {
|
|
|
81
81
|
track: MediaStreamTrack;
|
|
82
82
|
/** Paused at the SOURCE, as the publisher left it. */
|
|
83
83
|
paused: boolean;
|
|
84
|
+
/** The publisher's declared label, when the node relayed one. Rendering only. */
|
|
85
|
+
source?: string;
|
|
84
86
|
};
|
|
85
87
|
|
|
86
88
|
/** What a local track is, in the vocabulary the node checks against `publishKinds`. */
|
|
87
89
|
export type LocalPublicationSource = "camera" | "microphone" | "screen";
|
|
88
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Every label this SDK publishes under.
|
|
93
|
+
*
|
|
94
|
+
* `program` is a COMPOSED feed rather than a device: a broadcast studio's
|
|
95
|
+
* rendered output, published so a server-side egress can restream it. It is
|
|
96
|
+
* not a capture, so it is never a "lost source" to re-offer a person after a
|
|
97
|
+
* drop, and `useLocalMedia`'s device controls never touch it. The node treats
|
|
98
|
+
* it as its track's kind (`requiredPublishKind`), so a token needs only
|
|
99
|
+
* `publishKinds: ["audio", "video"]`.
|
|
100
|
+
*/
|
|
101
|
+
export type PublishSourceLabel = LocalPublicationSource | "program";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* How to encode one publication, where the default is wrong.
|
|
105
|
+
*
|
|
106
|
+
* A camera takes the browser's defaults. A composed program feed does not:
|
|
107
|
+
* an RTMP restream needs H.264 (RTMP carries nothing else, and the egress
|
|
108
|
+
* copies rather than transcodes), and a 1080p canvas at the default bitrate
|
|
109
|
+
* is a smear. Both are per-publication facts, not per-room ones.
|
|
110
|
+
*/
|
|
111
|
+
export type PublishOptions = {
|
|
112
|
+
/** Pick this codec from the router's list. Ignored when the router lacks it. */
|
|
113
|
+
codec?: "h264" | "vp8";
|
|
114
|
+
/** Cap for the encoder, in kbit/s. Read as the TOP layer under simulcast. */
|
|
115
|
+
maxBitrateKbps?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Send three spatial layers instead of one, so each consumer can be served
|
|
118
|
+
* the one its connection can carry.
|
|
119
|
+
*
|
|
120
|
+
* The choosing is not ours and does not need to be: mediasoup picks a layer
|
|
121
|
+
* per consumer from that transport's own bandwidth estimate, so a viewer on
|
|
122
|
+
* a poor line gets 360p rather than a stall, with nothing to implement on
|
|
123
|
+
* either end. What we decide is only whether the layers exist to choose
|
|
124
|
+
* from.
|
|
125
|
+
*
|
|
126
|
+
* Use it for a feed with an AUDIENCE. It is wrong for a camera in a small
|
|
127
|
+
* call, where everyone can carry the one encoding and the extra layers are
|
|
128
|
+
* encoder time spent on nobody.
|
|
129
|
+
*
|
|
130
|
+
* ## Why VP8 and not H.264
|
|
131
|
+
*
|
|
132
|
+
* Browser simulcast is reliable on VP8 and is not on H.264, where it depends
|
|
133
|
+
* on the hardware encoder and quietly degrades to one layer. A caller that
|
|
134
|
+
* needs H.264 downstream (an RTMP restream copies rather than transcodes)
|
|
135
|
+
* should publish one encoding and leave this off. The two are exclusive by
|
|
136
|
+
* design rather than by accident.
|
|
137
|
+
*/
|
|
138
|
+
simulcast?: boolean;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The three layers, quarter / half / full.
|
|
143
|
+
*
|
|
144
|
+
* `scaleResolutionDownBy` rather than three bitrates, so the shape holds
|
|
145
|
+
* whatever resolution the caller is publishing. The rids are the conventional
|
|
146
|
+
* one-letter names every SFU and every browser log uses, which is worth more
|
|
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.
|
|
158
|
+
*/
|
|
159
|
+
const SIMULCAST_LAYERS = [
|
|
160
|
+
{ rid: "q", scaleResolutionDownBy: 4, scalabilityMode: "L1T3" },
|
|
161
|
+
{ rid: "h", scaleResolutionDownBy: 2, scalabilityMode: "L1T3" },
|
|
162
|
+
{ rid: "f", scaleResolutionDownBy: 1, scalabilityMode: "L1T3" },
|
|
163
|
+
] as const;
|
|
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
|
+
|
|
254
|
+
/**
|
|
255
|
+
* What each layer may spend, given the caller's cap for the top one.
|
|
256
|
+
*
|
|
257
|
+
* Quarter resolution is a sixteenth of the pixels, but not a sixteenth of the
|
|
258
|
+
* bits: an encoder needs proportionally more per pixel at small sizes, and a
|
|
259
|
+
* layer starved below what its resolution costs is a layer that looks worse
|
|
260
|
+
* than it should while still being sent. A quarter and a tenth are the
|
|
261
|
+
* conventional split and are what mediasoup's own examples use.
|
|
262
|
+
*/
|
|
263
|
+
function simulcastEncodings(maxBitrateKbps: number | undefined): unknown[] {
|
|
264
|
+
const top = (maxBitrateKbps ?? 0) * 1000;
|
|
265
|
+
const share = [0.1, 0.25, 1];
|
|
266
|
+
return SIMULCAST_LAYERS.map((layer, index) => ({
|
|
267
|
+
...layer,
|
|
268
|
+
...(top ? { maxBitrate: Math.round(top * share[index]!) } : {}),
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
|
|
89
272
|
export type LocalPublication = {
|
|
90
273
|
producerId: string;
|
|
91
274
|
kind: "audio" | "video";
|
|
92
|
-
source:
|
|
275
|
+
source: PublishSourceLabel;
|
|
93
276
|
track: MediaStreamTrack;
|
|
94
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;
|
|
95
281
|
/**
|
|
96
282
|
* Paused at the source by `setPaused`. The capture is still open and the
|
|
97
283
|
* producer still exists; nothing is being sent. This is what a mute is.
|
|
@@ -121,11 +307,25 @@ export type MediaRoomOptions = {
|
|
|
121
307
|
webSocket?: MediaWebSocketFactory;
|
|
122
308
|
/**
|
|
123
309
|
* How hard to try to get back in after a drop. Defaults to
|
|
124
|
-
* `DEFAULT_RECONNECT_OPTIONS`; `{ maxAttempts: 0 }` turns
|
|
310
|
+
* `DEFAULT_RECONNECT_OPTIONS`; `{ maxAttempts: 0 }` turns FAILURE recovery
|
|
125
311
|
* off, which leaves `isRecovering` false and is what tells a UI to offer a
|
|
126
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.
|
|
127
319
|
*/
|
|
128
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;
|
|
129
329
|
onLog?: (level: SignalLogLevel, message: string, detail?: unknown) => void;
|
|
130
330
|
};
|
|
131
331
|
|
|
@@ -153,16 +353,51 @@ export class MediaRoom {
|
|
|
153
353
|
/** Per publication: stop listening for the track ending on its own. */
|
|
154
354
|
private readonly trackEndWatchers = new Map<string, () => void>();
|
|
155
355
|
private readonly listeners = new Set<Listener>();
|
|
356
|
+
private readonly broadcastListeners = new Set<(event: BroadcastEvent) => void>();
|
|
156
357
|
/** One in-flight subscribe per producer, so a burst of activeSpeakers frames
|
|
157
358
|
* does not race itself into two consumers for one producer. */
|
|
158
359
|
private readonly subscribing = new Map<string, Promise<void>>();
|
|
159
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
|
+
|
|
160
380
|
private stateValue: RoomState = initialRoomState;
|
|
161
381
|
private connection: ConnectionState = "idle";
|
|
162
382
|
private lastError: DisconnectCause | undefined;
|
|
163
383
|
|
|
164
384
|
/** Consecutive failed attempts since the last successful join. */
|
|
165
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;
|
|
166
401
|
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
167
402
|
private recovering = false;
|
|
168
403
|
/** `close()` has been called. Nothing may open a socket after that. */
|
|
@@ -185,24 +420,92 @@ export class MediaRoom {
|
|
|
185
420
|
* it ended after an hour or before a socket was ever opened.
|
|
186
421
|
*/
|
|
187
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
|
+
|
|
188
485
|
this.lastError = cause;
|
|
189
486
|
// What the person was sending, before it is torn down. Recorded so the
|
|
190
487
|
// screen can say so once the room is back (see `lostPublicationSources`).
|
|
191
|
-
|
|
488
|
+
// A program feed is excluded: it is a studio's output, not something a
|
|
489
|
+
// person switched on, so nobody should be asked to switch it back on.
|
|
490
|
+
const wasPublishing = [
|
|
491
|
+
...new Set(
|
|
492
|
+
[...this.publications.values()]
|
|
493
|
+
.map((p) => p.source)
|
|
494
|
+
.filter((s): s is LocalPublicationSource => s !== "program"),
|
|
495
|
+
),
|
|
496
|
+
];
|
|
192
497
|
// Before the decision, and unconditionally. The transports are dead
|
|
193
498
|
// whatever happens next, and so is every capture that was feeding them.
|
|
194
|
-
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (this.stateValue.activeSpeakers.length > 0) {
|
|
199
|
-
this.stateValue = { ...this.stateValue, activeSpeakers: [] };
|
|
200
|
-
}
|
|
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();
|
|
201
503
|
|
|
202
504
|
// `disposed` as well as the cause: `close()` is a client close whatever the
|
|
203
505
|
// socket's own account of it, and a room that has been closed must never
|
|
204
506
|
// report itself as coming back.
|
|
205
507
|
if (cause.type === "closed_by_client" || this.disposed) {
|
|
508
|
+
this.stopStash();
|
|
206
509
|
this.connection = "closed";
|
|
207
510
|
this.recovering = false;
|
|
208
511
|
this.emit();
|
|
@@ -212,9 +515,75 @@ export class MediaRoom {
|
|
|
212
515
|
if (wasPublishing.length > 0) this.lostSources = wasPublishing;
|
|
213
516
|
this.connection = "reconnecting";
|
|
214
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
|
+
}
|
|
215
529
|
this.emit();
|
|
216
530
|
}
|
|
217
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
|
+
|
|
218
587
|
get state(): RoomState {
|
|
219
588
|
return this.stateValue;
|
|
220
589
|
}
|
|
@@ -287,6 +656,95 @@ export class MediaRoom {
|
|
|
287
656
|
return this.lostSources;
|
|
288
657
|
}
|
|
289
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
|
+
|
|
290
748
|
/** Subscribe to changes. Returns an unsubscribe. */
|
|
291
749
|
onChange(listener: Listener): () => void {
|
|
292
750
|
this.listeners.add(listener);
|
|
@@ -305,8 +763,10 @@ export class MediaRoom {
|
|
|
305
763
|
this.cancelReconnect();
|
|
306
764
|
// A retry is not a first connection, and saying "Connecting to the call"
|
|
307
765
|
// over a call somebody is already in reads as though they had been thrown
|
|
308
|
-
// out of it.
|
|
309
|
-
|
|
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";
|
|
310
770
|
this.emit();
|
|
311
771
|
|
|
312
772
|
// A failed attempt normally reports itself through `signal.onClose`, which
|
|
@@ -343,15 +803,58 @@ export class MediaRoom {
|
|
|
343
803
|
// A successful join resets the ladder: an hour-long call that drops once
|
|
344
804
|
// should not start at a 30-second delay because of a blip at minute two.
|
|
345
805
|
this.reconnectAttempt = 0;
|
|
806
|
+
this.outageStartedAt = null;
|
|
346
807
|
this.recovering = false;
|
|
347
808
|
this.emit();
|
|
348
809
|
|
|
349
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
|
+
}
|
|
350
847
|
}
|
|
351
848
|
|
|
352
849
|
async close(): Promise<void> {
|
|
353
850
|
this.disposed = true;
|
|
354
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
|
+
}
|
|
355
858
|
this.recovering = false;
|
|
356
859
|
this.connection = "closed";
|
|
357
860
|
this.lostSources = NO_SOURCES;
|
|
@@ -381,9 +884,15 @@ export class MediaRoom {
|
|
|
381
884
|
private scheduleReconnect(cause: DisconnectCause): boolean {
|
|
382
885
|
if (this.disposed) return false;
|
|
383
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();
|
|
384
892
|
const decision = decideReconnect({
|
|
385
893
|
cause,
|
|
386
894
|
attempt: this.reconnectAttempt,
|
|
895
|
+
elapsedMs: Date.now() - this.outageStartedAt,
|
|
387
896
|
...(this.options.reconnect ? { options: this.options.reconnect } : {}),
|
|
388
897
|
});
|
|
389
898
|
if (decision.action === "stop") {
|
|
@@ -422,14 +931,39 @@ export class MediaRoom {
|
|
|
422
931
|
* `publishKinds`: a token granting audio only must not be able to publish a
|
|
423
932
|
* screen share by relabelling it, and the node is where that is decided.
|
|
424
933
|
*/
|
|
425
|
-
async publish(
|
|
934
|
+
async publish(
|
|
935
|
+
track: MediaStreamTrack,
|
|
936
|
+
source: PublishSourceLabel,
|
|
937
|
+
options: PublishOptions = {},
|
|
938
|
+
): Promise<LocalPublication> {
|
|
426
939
|
const kind = track.kind === "audio" ? "audio" : "video";
|
|
427
940
|
if (!this.device.canProduce(kind)) {
|
|
428
941
|
throw new Error(`this browser cannot produce ${kind}`);
|
|
429
942
|
}
|
|
430
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
|
+
|
|
431
954
|
const transport = await this.ensureSendTransport();
|
|
432
|
-
const handle = await transport.produce({
|
|
955
|
+
const handle = await transport.produce({
|
|
956
|
+
track,
|
|
957
|
+
appData: { source },
|
|
958
|
+
...(options.codec ? { codec: options.codec } : {}),
|
|
959
|
+
...(simulcast
|
|
960
|
+
? { encodings: simulcastEncodings(options.maxBitrateKbps) }
|
|
961
|
+
: options.maxBitrateKbps
|
|
962
|
+
? { encodings: [{ maxBitrate: options.maxBitrateKbps * 1000 }] }
|
|
963
|
+
: {}),
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
if (source === "program") await keepProgramResolution(handle, this.options.onLog);
|
|
433
967
|
|
|
434
968
|
const publication: LocalPublication = {
|
|
435
969
|
producerId: handle.id,
|
|
@@ -437,6 +971,7 @@ export class MediaRoom {
|
|
|
437
971
|
source,
|
|
438
972
|
track,
|
|
439
973
|
handle,
|
|
974
|
+
options,
|
|
440
975
|
paused: false,
|
|
441
976
|
};
|
|
442
977
|
this.publications.set(handle.id, publication);
|
|
@@ -450,7 +985,7 @@ export class MediaRoom {
|
|
|
450
985
|
void this.unpublish(handle.id);
|
|
451
986
|
}),
|
|
452
987
|
);
|
|
453
|
-
if (this.lostSources.includes(source)) {
|
|
988
|
+
if (source !== "program" && this.lostSources.includes(source)) {
|
|
454
989
|
this.lostSources = this.lostSources.filter((s) => s !== source);
|
|
455
990
|
if (this.lostSources.length === 0) this.lostSources = NO_SOURCES;
|
|
456
991
|
}
|
|
@@ -458,6 +993,35 @@ export class MediaRoom {
|
|
|
458
993
|
return publication;
|
|
459
994
|
}
|
|
460
995
|
|
|
996
|
+
/**
|
|
997
|
+
* Swap the capture behind a publication, keeping the producer.
|
|
998
|
+
*
|
|
999
|
+
* A device change (another camera, another microphone) is this, and it is
|
|
1000
|
+
* deliberately NOT unpublish plus publish: that changes the producer id,
|
|
1001
|
+
* which is the key every consumer's tile and every studio's scene holds, so
|
|
1002
|
+
* a switched camera vanished from the stage until somebody re-added it.
|
|
1003
|
+
* The old capture is stopped here; the new one is watched for ending the
|
|
1004
|
+
* same way the first was.
|
|
1005
|
+
*/
|
|
1006
|
+
async replaceTrack(producerId: string, track: MediaStreamTrack): Promise<void> {
|
|
1007
|
+
const publication = this.publications.get(producerId);
|
|
1008
|
+
if (!publication) throw new Error("no such publication");
|
|
1009
|
+
if (track.kind !== publication.kind) throw new Error(`a ${publication.kind} publication cannot carry ${track.kind}`);
|
|
1010
|
+
if (track === publication.track) return;
|
|
1011
|
+
|
|
1012
|
+
await publication.handle.replaceTrack(track);
|
|
1013
|
+
this.trackEndWatchers.get(producerId)?.();
|
|
1014
|
+
publication.track.stop();
|
|
1015
|
+
this.trackEndWatchers.set(
|
|
1016
|
+
producerId,
|
|
1017
|
+
whenTrackEnds(track, () => {
|
|
1018
|
+
void this.unpublish(producerId);
|
|
1019
|
+
}),
|
|
1020
|
+
);
|
|
1021
|
+
this.publications.set(producerId, { ...publication, track });
|
|
1022
|
+
this.emit();
|
|
1023
|
+
}
|
|
1024
|
+
|
|
461
1025
|
async unpublish(producerId: string): Promise<void> {
|
|
462
1026
|
const publication = this.publications.get(producerId);
|
|
463
1027
|
if (!publication) return;
|
|
@@ -514,23 +1078,176 @@ export class MediaRoom {
|
|
|
514
1078
|
* are the same thing and this needs no special case.
|
|
515
1079
|
*/
|
|
516
1080
|
async syncSubscriptions(): Promise<void> {
|
|
517
|
-
const
|
|
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]);
|
|
518
1097
|
|
|
519
1098
|
for (const [producerId, consumer] of this.consumers) {
|
|
520
|
-
if (wanted.has(producerId))
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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);
|
|
528
1113
|
}
|
|
529
1114
|
|
|
530
1115
|
await Promise.all([...wanted].map((producerId) => this.subscribe(producerId)));
|
|
1116
|
+
if (this.viewport !== null) this.applyPreferredLayers();
|
|
531
1117
|
this.emit();
|
|
532
1118
|
}
|
|
533
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
|
+
|
|
534
1251
|
async subscribe(producerId: string): Promise<void> {
|
|
535
1252
|
if (this.consumers.has(producerId)) return;
|
|
536
1253
|
const inFlight = this.subscribing.get(producerId);
|
|
@@ -548,11 +1265,18 @@ export class MediaRoom {
|
|
|
548
1265
|
if (!entry) return;
|
|
549
1266
|
|
|
550
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);
|
|
551
1274
|
const response = (await this.signal.request({
|
|
552
1275
|
method: "consume",
|
|
553
1276
|
transportId: transport.id,
|
|
554
1277
|
producerId,
|
|
555
1278
|
rtpCapabilities: this.device.rtpCapabilities,
|
|
1279
|
+
...(layer !== undefined ? { preferredLayers: { spatialLayer: layer } } : {}),
|
|
556
1280
|
})) as { consumerId: string; producerId: string; kind: "audio" | "video"; rtpParameters: RtpParameters };
|
|
557
1281
|
|
|
558
1282
|
const consumer = await transport.consume({
|
|
@@ -569,6 +1293,7 @@ export class MediaRoom {
|
|
|
569
1293
|
kind: response.kind,
|
|
570
1294
|
track: consumer.track,
|
|
571
1295
|
paused: entry.paused,
|
|
1296
|
+
...(entry.source ? { source: entry.source } : {}),
|
|
572
1297
|
});
|
|
573
1298
|
|
|
574
1299
|
// RULE 3: resume LAST. The node creates every consumer paused, so media
|
|
@@ -579,6 +1304,91 @@ export class MediaRoom {
|
|
|
579
1304
|
this.emit();
|
|
580
1305
|
}
|
|
581
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
|
+
|
|
582
1392
|
// -------------------------------------------------------------------------
|
|
583
1393
|
// transports
|
|
584
1394
|
// -------------------------------------------------------------------------
|
|
@@ -675,6 +1485,19 @@ export class MediaRoom {
|
|
|
675
1485
|
|
|
676
1486
|
if (frame.event === "joined") this.grantsValue = frame.grants ?? this.grantsValue;
|
|
677
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
|
+
|
|
678
1501
|
if (frame.event === "producerClosed") {
|
|
679
1502
|
const consumer = this.consumers.get(frame.producerId);
|
|
680
1503
|
consumer?.close();
|
|
@@ -709,16 +1532,27 @@ export class MediaRoom {
|
|
|
709
1532
|
* node tears the session down on close anyway. Ending one publication on a
|
|
710
1533
|
* LIVE connection is `unpublish`, which does tell it.
|
|
711
1534
|
*/
|
|
712
|
-
private teardownMedia(): void {
|
|
1535
|
+
private teardownMedia(options: { preserveCaptures?: boolean } = {}): void {
|
|
713
1536
|
for (const consumer of this.consumers.values()) consumer.close();
|
|
714
1537
|
this.consumers.clear();
|
|
715
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();
|
|
716
1546
|
|
|
717
1547
|
for (const detach of this.trackEndWatchers.values()) detach();
|
|
718
1548
|
this.trackEndWatchers.clear();
|
|
719
1549
|
for (const publication of this.publications.values()) {
|
|
720
1550
|
publication.handle.close();
|
|
721
|
-
|
|
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();
|
|
722
1556
|
}
|
|
723
1557
|
this.publications.clear();
|
|
724
1558
|
|
|
@@ -750,6 +1584,17 @@ export class MediaRoom {
|
|
|
750
1584
|
}
|
|
751
1585
|
|
|
752
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
|
+
};
|
|
753
1598
|
|
|
754
1599
|
/**
|
|
755
1600
|
* Call `onEnded` when a track ends on its own, and return the detach.
|
|
@@ -774,3 +1619,45 @@ export async function connectToRoom(options: MediaRoomOptions): Promise<MediaRoo
|
|
|
774
1619
|
await room.connect();
|
|
775
1620
|
return room;
|
|
776
1621
|
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* The program feed keeps its RESOLUTION and gives up frame rate instead.
|
|
1625
|
+
*
|
|
1626
|
+
* Chrome's default for a video sender is `balanced`, which under a low
|
|
1627
|
+
* bandwidth estimate or CPU pressure scales the encode down. For a camera tile
|
|
1628
|
+
* that is the right trade. For the program feed it is wrong twice over.
|
|
1629
|
+
*
|
|
1630
|
+
* First, this feed is the broadcast: a 1080p composite arriving at Twitch as
|
|
1631
|
+
* 270p is the product, not a degraded preview. Twitch reported exactly that.
|
|
1632
|
+
*
|
|
1633
|
+
* Second, and worse, the RTMP leg cannot survive the CHANGE. The egress muxes
|
|
1634
|
+
* the feed with `-c:v copy`, and an FLV header carries the dimensions once, at
|
|
1635
|
+
* the start. When Chrome adapts resolution mid-stream the SPS changes under a
|
|
1636
|
+
* header that still describes the old size, and the player refuses the stream:
|
|
1637
|
+
*
|
|
1638
|
+
* Your browser encountered an error while decoding the video. (Error #3000)
|
|
1639
|
+
*
|
|
1640
|
+
* while ffmpeg reports `drop_frames=0`, a healthy bitrate, and an established
|
|
1641
|
+
* socket. Nothing on the server can see it.
|
|
1642
|
+
*
|
|
1643
|
+
* `maintain-resolution` makes the encoder drop frames rather than pixels, so
|
|
1644
|
+
* the SPS stays put for the life of the broadcast.
|
|
1645
|
+
*
|
|
1646
|
+
* Best-effort: the setting is not universally implemented, and a browser that
|
|
1647
|
+
* ignores it is no worse off than before. Only the program source, because a
|
|
1648
|
+
* camera is better off shrinking than freezing.
|
|
1649
|
+
*/
|
|
1650
|
+
async function keepProgramResolution(
|
|
1651
|
+
handle: MediaProducerHandle,
|
|
1652
|
+
onLog?: (level: "warn" | "debug", message: string, detail?: unknown) => void,
|
|
1653
|
+
): Promise<void> {
|
|
1654
|
+
const sender = handle.rtpSender;
|
|
1655
|
+
if (!sender || handle.kind !== "video") return;
|
|
1656
|
+
try {
|
|
1657
|
+
const params = sender.getParameters() as RTCRtpSendParameters & { degradationPreference?: string };
|
|
1658
|
+
params.degradationPreference = "maintain-resolution";
|
|
1659
|
+
await sender.setParameters(params);
|
|
1660
|
+
} catch (err) {
|
|
1661
|
+
onLog?.("warn", "could not pin the program feed's resolution", err);
|
|
1662
|
+
}
|
|
1663
|
+
}
|