@tribe-nest/media-client 0.1.0 → 0.2.1
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/README.md +20 -4
- package/build/core/reconnect.d.ts +8 -2
- package/build/core/reconnect.d.ts.map +1 -1
- package/build/core/reconnect.js +8 -2
- package/build/core/reconnect.js.map +1 -1
- package/build/core/signal.d.ts +10 -1
- package/build/core/signal.d.ts.map +1 -1
- package/build/core/signal.js +39 -10
- package/build/core/signal.js.map +1 -1
- package/build/core/state.d.ts +3 -0
- package/build/core/state.d.ts.map +1 -1
- package/build/core/state.js +21 -4
- package/build/core/state.js.map +1 -1
- package/build/react/index.d.ts +38 -7
- package/build/react/index.d.ts.map +1 -1
- package/build/react/index.js +86 -7
- 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 +13 -0
- package/build/room/device.d.ts.map +1 -1
- package/build/room/room.d.ts +129 -5
- package/build/room/room.d.ts.map +1 -1
- package/build/room/room.js +330 -58
- package/build/room/room.js.map +1 -1
- package/package.json +3 -1
- package/src/core/_tests/signal.spec.ts +115 -22
- package/src/core/_tests/state.spec.ts +68 -2
- package/src/core/reconnect.ts +8 -2
- package/src/core/signal.ts +48 -12
- package/src/core/state.ts +31 -5
- package/src/react/_tests/hooks.spec.tsx +91 -3
- package/src/react/index.tsx +117 -20
- package/src/room/_tests/room.spec.ts +747 -12
- package/src/room/browserDevice.ts +14 -4
- package/src/room/device.ts +13 -0
- package/src/room/room.ts +416 -61
package/src/room/room.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { EventFrame, MediaGrants } from "@tribe-nest/media-protocol";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
MediaSignal,
|
|
5
|
+
causeFromError,
|
|
5
6
|
decideReconnect,
|
|
6
7
|
initialRoomState,
|
|
7
8
|
reduceRoomState,
|
|
@@ -80,14 +81,106 @@ export type MediaTrack = {
|
|
|
80
81
|
track: MediaStreamTrack;
|
|
81
82
|
/** Paused at the SOURCE, as the publisher left it. */
|
|
82
83
|
paused: boolean;
|
|
84
|
+
/** The publisher's declared label, when the node relayed one. Rendering only. */
|
|
85
|
+
source?: string;
|
|
83
86
|
};
|
|
84
87
|
|
|
88
|
+
/** What a local track is, in the vocabulary the node checks against `publishKinds`. */
|
|
89
|
+
export type LocalPublicationSource = "camera" | "microphone" | "screen";
|
|
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
|
+
const SIMULCAST_LAYERS = [
|
|
150
|
+
{ rid: "q", scaleResolutionDownBy: 4, scalabilityMode: "L1T1" },
|
|
151
|
+
{ rid: "h", scaleResolutionDownBy: 2, scalabilityMode: "L1T1" },
|
|
152
|
+
{ rid: "f", scaleResolutionDownBy: 1, scalabilityMode: "L1T1" },
|
|
153
|
+
] as const;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* What each layer may spend, given the caller's cap for the top one.
|
|
157
|
+
*
|
|
158
|
+
* Quarter resolution is a sixteenth of the pixels, but not a sixteenth of the
|
|
159
|
+
* bits: an encoder needs proportionally more per pixel at small sizes, and a
|
|
160
|
+
* layer starved below what its resolution costs is a layer that looks worse
|
|
161
|
+
* than it should while still being sent. A quarter and a tenth are the
|
|
162
|
+
* conventional split and are what mediasoup's own examples use.
|
|
163
|
+
*/
|
|
164
|
+
function simulcastEncodings(maxBitrateKbps: number | undefined): unknown[] {
|
|
165
|
+
const top = (maxBitrateKbps ?? 0) * 1000;
|
|
166
|
+
const share = [0.1, 0.25, 1];
|
|
167
|
+
return SIMULCAST_LAYERS.map((layer, index) => ({
|
|
168
|
+
...layer,
|
|
169
|
+
...(top ? { maxBitrate: Math.round(top * share[index]!) } : {}),
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
85
173
|
export type LocalPublication = {
|
|
86
174
|
producerId: string;
|
|
87
175
|
kind: "audio" | "video";
|
|
88
|
-
source:
|
|
176
|
+
source: PublishSourceLabel;
|
|
89
177
|
track: MediaStreamTrack;
|
|
90
178
|
handle: MediaProducerHandle;
|
|
179
|
+
/**
|
|
180
|
+
* Paused at the source by `setPaused`. The capture is still open and the
|
|
181
|
+
* producer still exists; nothing is being sent. This is what a mute is.
|
|
182
|
+
*/
|
|
183
|
+
paused: boolean;
|
|
91
184
|
};
|
|
92
185
|
|
|
93
186
|
export type MediaRoomOptions = {
|
|
@@ -122,17 +215,27 @@ export type MediaRoomOptions = {
|
|
|
122
215
|
|
|
123
216
|
type Listener = () => void;
|
|
124
217
|
|
|
218
|
+
type TransportSlot = {
|
|
219
|
+
ready: MediaTransport | undefined;
|
|
220
|
+
creating: Promise<MediaTransport> | undefined;
|
|
221
|
+
};
|
|
222
|
+
|
|
125
223
|
export class MediaRoom {
|
|
126
224
|
private readonly signal: MediaSignal;
|
|
127
225
|
private readonly device: MediaDevice;
|
|
128
|
-
|
|
129
|
-
private
|
|
226
|
+
/** One slot per direction: the finished transport, or the creation in flight. */
|
|
227
|
+
private readonly transports: Record<"send" | "recv", TransportSlot> = {
|
|
228
|
+
send: { ready: undefined, creating: undefined },
|
|
229
|
+
recv: { ready: undefined, creating: undefined },
|
|
230
|
+
};
|
|
130
231
|
private iceServers: IceServer[] = [];
|
|
131
232
|
private grantsValue: MediaGrants | undefined;
|
|
132
233
|
|
|
133
234
|
private readonly consumers = new Map<string, MediaConsumerHandle>();
|
|
134
235
|
private readonly tracksByProducer = new Map<string, MediaTrack>();
|
|
135
236
|
private readonly publications = new Map<string, LocalPublication>();
|
|
237
|
+
/** Per publication: stop listening for the track ending on its own. */
|
|
238
|
+
private readonly trackEndWatchers = new Map<string, () => void>();
|
|
136
239
|
private readonly listeners = new Set<Listener>();
|
|
137
240
|
/** One in-flight subscribe per producer, so a burst of activeSpeakers frames
|
|
138
241
|
* does not race itself into two consumers for one producer. */
|
|
@@ -158,23 +261,50 @@ export class MediaRoom {
|
|
|
158
261
|
});
|
|
159
262
|
|
|
160
263
|
this.signal.onAny((frame) => this.onFrame(frame));
|
|
161
|
-
this.signal.onClose((cause) =>
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
264
|
+
this.signal.onClose((cause) => this.onDisconnect(cause));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The one place that decides what happens after a connection ends, whether
|
|
269
|
+
* it ended after an hour or before a socket was ever opened.
|
|
270
|
+
*/
|
|
271
|
+
private onDisconnect(cause: DisconnectCause): void {
|
|
272
|
+
this.lastError = cause;
|
|
273
|
+
// What the person was sending, before it is torn down. Recorded so the
|
|
274
|
+
// screen can say so once the room is back (see `lostPublicationSources`).
|
|
275
|
+
// A program feed is excluded: it is a studio's output, not something a
|
|
276
|
+
// person switched on, so nobody should be asked to switch it back on.
|
|
277
|
+
const wasPublishing = [
|
|
278
|
+
...new Set(
|
|
279
|
+
[...this.publications.values()]
|
|
280
|
+
.map((p) => p.source)
|
|
281
|
+
.filter((s): s is LocalPublicationSource => s !== "program"),
|
|
282
|
+
),
|
|
283
|
+
];
|
|
284
|
+
// Before the decision, and unconditionally. The transports are dead
|
|
285
|
+
// whatever happens next, and so is every capture that was feeding them.
|
|
286
|
+
this.teardownMedia();
|
|
287
|
+
// The active set was an instruction about consumers on THIS connection,
|
|
288
|
+
// and those are gone. Left standing, it would be mistaken by the next
|
|
289
|
+
// `joined` for a set the new node had sent ahead of the snapshot.
|
|
290
|
+
if (this.stateValue.activeSpeakers.length > 0) {
|
|
291
|
+
this.stateValue = { ...this.stateValue, activeSpeakers: [] };
|
|
292
|
+
}
|
|
173
293
|
|
|
174
|
-
|
|
175
|
-
|
|
294
|
+
// `disposed` as well as the cause: `close()` is a client close whatever the
|
|
295
|
+
// socket's own account of it, and a room that has been closed must never
|
|
296
|
+
// report itself as coming back.
|
|
297
|
+
if (cause.type === "closed_by_client" || this.disposed) {
|
|
298
|
+
this.connection = "closed";
|
|
299
|
+
this.recovering = false;
|
|
176
300
|
this.emit();
|
|
177
|
-
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (wasPublishing.length > 0) this.lostSources = wasPublishing;
|
|
305
|
+
this.connection = "reconnecting";
|
|
306
|
+
this.recovering = this.scheduleReconnect(cause);
|
|
307
|
+
this.emit();
|
|
178
308
|
}
|
|
179
309
|
|
|
180
310
|
get state(): RoomState {
|
|
@@ -219,6 +349,7 @@ export class MediaRoom {
|
|
|
219
349
|
*/
|
|
220
350
|
private trackSnapshot: MediaTrack[] = [];
|
|
221
351
|
private publicationSnapshot: LocalPublication[] = [];
|
|
352
|
+
private lostSources: readonly LocalPublicationSource[] = NO_SOURCES;
|
|
222
353
|
|
|
223
354
|
get tracks(): MediaTrack[] {
|
|
224
355
|
return this.trackSnapshot;
|
|
@@ -228,6 +359,26 @@ export class MediaRoom {
|
|
|
228
359
|
return this.publicationSnapshot;
|
|
229
360
|
}
|
|
230
361
|
|
|
362
|
+
/**
|
|
363
|
+
* What the person was sending when the connection dropped, and is not now.
|
|
364
|
+
*
|
|
365
|
+
* A drop tears down every local publication and STOPS its capture (see
|
|
366
|
+
* `teardownMedia`): the camera light goes out, which is the right thing for a
|
|
367
|
+
* capture whose session has ended and the wrong thing to do silently to a
|
|
368
|
+
* capture whose session is about to resume. The room does not republish on
|
|
369
|
+
* its own. Capture is a browser permission with a UI consequence, and turning
|
|
370
|
+
* a person's camera back on without a press, possibly minutes later, is not a
|
|
371
|
+
* decision this layer gets to make. So instead it says what was lost, and the
|
|
372
|
+
* screen tells the person, who presses the button.
|
|
373
|
+
*
|
|
374
|
+
* Cleared per source when that source is published again, and wholesale by
|
|
375
|
+
* `close()`. A stable reference while unchanged, like every other snapshot
|
|
376
|
+
* here, because `useSyncExternalStore` reads it.
|
|
377
|
+
*/
|
|
378
|
+
get lostPublicationSources(): readonly LocalPublicationSource[] {
|
|
379
|
+
return this.lostSources;
|
|
380
|
+
}
|
|
381
|
+
|
|
231
382
|
/** Subscribe to changes. Returns an unsubscribe. */
|
|
232
383
|
onChange(listener: Listener): () => void {
|
|
233
384
|
this.listeners.add(listener);
|
|
@@ -235,6 +386,14 @@ export class MediaRoom {
|
|
|
235
386
|
}
|
|
236
387
|
|
|
237
388
|
async connect(): Promise<void> {
|
|
389
|
+
// Refused BEFORE touching any state. The signal refuses this too, but by
|
|
390
|
+
// then `connection` would already read "connecting" over a socket that is
|
|
391
|
+
// joined, and nothing would ever put it back.
|
|
392
|
+
const phase = this.signal.phase;
|
|
393
|
+
if (phase === "connecting" || phase === "joining" || phase === "joined") {
|
|
394
|
+
throw new Error("connect() called on a room that is already connecting or connected");
|
|
395
|
+
}
|
|
396
|
+
|
|
238
397
|
this.cancelReconnect();
|
|
239
398
|
// A retry is not a first connection, and saying "Connecting to the call"
|
|
240
399
|
// over a call somebody is already in reads as though they had been thrown
|
|
@@ -242,7 +401,26 @@ export class MediaRoom {
|
|
|
242
401
|
this.connection = this.reconnectAttempt > 0 ? "reconnecting" : "connecting";
|
|
243
402
|
this.emit();
|
|
244
403
|
|
|
245
|
-
|
|
404
|
+
// A failed attempt normally reports itself through `signal.onClose`, which
|
|
405
|
+
// routes to `onDisconnect`. This watches for that report so a rejection
|
|
406
|
+
// that arrives WITHOUT one (nothing in the signal does that today; this is
|
|
407
|
+
// the belt to its braces) is still counted as a failed attempt rather than
|
|
408
|
+
// leaving `connection` on "connecting" with no retry booked and no error
|
|
409
|
+
// to show.
|
|
410
|
+
let disconnectReported = false;
|
|
411
|
+
const stopWatching = this.signal.onClose(() => {
|
|
412
|
+
disconnectReported = true;
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
let joined: Awaited<ReturnType<MediaSignal["connect"]>>;
|
|
416
|
+
try {
|
|
417
|
+
joined = await this.signal.connect();
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (!disconnectReported) this.onDisconnect(causeFromError(error));
|
|
420
|
+
throw error;
|
|
421
|
+
} finally {
|
|
422
|
+
stopWatching();
|
|
423
|
+
}
|
|
246
424
|
|
|
247
425
|
// RULE 1: the device loads first. Everything below needs its capabilities.
|
|
248
426
|
if (!this.device.loaded) {
|
|
@@ -268,6 +446,7 @@ export class MediaRoom {
|
|
|
268
446
|
this.cancelReconnect();
|
|
269
447
|
this.recovering = false;
|
|
270
448
|
this.connection = "closed";
|
|
449
|
+
this.lostSources = NO_SOURCES;
|
|
271
450
|
await this.signal.leave().catch(() => undefined);
|
|
272
451
|
this.teardownMedia();
|
|
273
452
|
this.emit();
|
|
@@ -308,8 +487,10 @@ export class MediaRoom {
|
|
|
308
487
|
this.reconnectTimer = undefined;
|
|
309
488
|
if (this.disposed) return;
|
|
310
489
|
this.reconnectAttempt += 1;
|
|
311
|
-
// A failed attempt comes back through `
|
|
312
|
-
// place that decides
|
|
490
|
+
// A failed attempt comes back through `onDisconnect`, which is the only
|
|
491
|
+
// place that decides, and that holds for an attempt that fails BEFORE it
|
|
492
|
+
// has a socket (the ticket endpoint refusing) as much as for one that
|
|
493
|
+
// drops after an hour. Swallowed here so a retry does not surface as an
|
|
313
494
|
// unhandled rejection in the host application's console.
|
|
314
495
|
void this.connect().catch(() => undefined);
|
|
315
496
|
}, decision.delayMs);
|
|
@@ -333,14 +514,29 @@ export class MediaRoom {
|
|
|
333
514
|
* `publishKinds`: a token granting audio only must not be able to publish a
|
|
334
515
|
* screen share by relabelling it, and the node is where that is decided.
|
|
335
516
|
*/
|
|
336
|
-
async publish(
|
|
517
|
+
async publish(
|
|
518
|
+
track: MediaStreamTrack,
|
|
519
|
+
source: PublishSourceLabel,
|
|
520
|
+
options: PublishOptions = {},
|
|
521
|
+
): Promise<LocalPublication> {
|
|
337
522
|
const kind = track.kind === "audio" ? "audio" : "video";
|
|
338
523
|
if (!this.device.canProduce(kind)) {
|
|
339
524
|
throw new Error(`this browser cannot produce ${kind}`);
|
|
340
525
|
}
|
|
341
526
|
|
|
342
527
|
const transport = await this.ensureSendTransport();
|
|
343
|
-
const handle = await transport.produce({
|
|
528
|
+
const handle = await transport.produce({
|
|
529
|
+
track,
|
|
530
|
+
appData: { source },
|
|
531
|
+
...(options.codec ? { codec: options.codec } : {}),
|
|
532
|
+
...(options.simulcast
|
|
533
|
+
? { encodings: simulcastEncodings(options.maxBitrateKbps) }
|
|
534
|
+
: options.maxBitrateKbps
|
|
535
|
+
? { encodings: [{ maxBitrate: options.maxBitrateKbps * 1000 }] }
|
|
536
|
+
: {}),
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
if (source === "program") await keepProgramResolution(handle, this.options.onLog);
|
|
344
540
|
|
|
345
541
|
const publication: LocalPublication = {
|
|
346
542
|
producerId: handle.id,
|
|
@@ -348,16 +544,62 @@ export class MediaRoom {
|
|
|
348
544
|
source,
|
|
349
545
|
track,
|
|
350
546
|
handle,
|
|
547
|
+
paused: false,
|
|
351
548
|
};
|
|
352
549
|
this.publications.set(handle.id, publication);
|
|
550
|
+
// A track can end WITHOUT us: the browser's own "Stop sharing" bar, a
|
|
551
|
+
// camera unplugged, a device revoked from the OS. Nothing about that reaches
|
|
552
|
+
// `unpublish`, so the node kept a producer nobody was feeding and the
|
|
553
|
+
// control above still read "Stop sharing" about a share that had ended.
|
|
554
|
+
this.trackEndWatchers.set(
|
|
555
|
+
handle.id,
|
|
556
|
+
whenTrackEnds(track, () => {
|
|
557
|
+
void this.unpublish(handle.id);
|
|
558
|
+
}),
|
|
559
|
+
);
|
|
560
|
+
if (source !== "program" && this.lostSources.includes(source)) {
|
|
561
|
+
this.lostSources = this.lostSources.filter((s) => s !== source);
|
|
562
|
+
if (this.lostSources.length === 0) this.lostSources = NO_SOURCES;
|
|
563
|
+
}
|
|
353
564
|
this.emit();
|
|
354
565
|
return publication;
|
|
355
566
|
}
|
|
356
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Swap the capture behind a publication, keeping the producer.
|
|
570
|
+
*
|
|
571
|
+
* A device change (another camera, another microphone) is this, and it is
|
|
572
|
+
* deliberately NOT unpublish plus publish: that changes the producer id,
|
|
573
|
+
* which is the key every consumer's tile and every studio's scene holds, so
|
|
574
|
+
* a switched camera vanished from the stage until somebody re-added it.
|
|
575
|
+
* The old capture is stopped here; the new one is watched for ending the
|
|
576
|
+
* same way the first was.
|
|
577
|
+
*/
|
|
578
|
+
async replaceTrack(producerId: string, track: MediaStreamTrack): Promise<void> {
|
|
579
|
+
const publication = this.publications.get(producerId);
|
|
580
|
+
if (!publication) throw new Error("no such publication");
|
|
581
|
+
if (track.kind !== publication.kind) throw new Error(`a ${publication.kind} publication cannot carry ${track.kind}`);
|
|
582
|
+
if (track === publication.track) return;
|
|
583
|
+
|
|
584
|
+
await publication.handle.replaceTrack(track);
|
|
585
|
+
this.trackEndWatchers.get(producerId)?.();
|
|
586
|
+
publication.track.stop();
|
|
587
|
+
this.trackEndWatchers.set(
|
|
588
|
+
producerId,
|
|
589
|
+
whenTrackEnds(track, () => {
|
|
590
|
+
void this.unpublish(producerId);
|
|
591
|
+
}),
|
|
592
|
+
);
|
|
593
|
+
this.publications.set(producerId, { ...publication, track });
|
|
594
|
+
this.emit();
|
|
595
|
+
}
|
|
596
|
+
|
|
357
597
|
async unpublish(producerId: string): Promise<void> {
|
|
358
598
|
const publication = this.publications.get(producerId);
|
|
359
599
|
if (!publication) return;
|
|
360
600
|
|
|
601
|
+
this.trackEndWatchers.get(producerId)?.();
|
|
602
|
+
this.trackEndWatchers.delete(producerId);
|
|
361
603
|
publication.handle.close();
|
|
362
604
|
publication.track.stop();
|
|
363
605
|
this.publications.delete(producerId);
|
|
@@ -367,16 +609,30 @@ export class MediaRoom {
|
|
|
367
609
|
this.emit();
|
|
368
610
|
}
|
|
369
611
|
|
|
612
|
+
/**
|
|
613
|
+
* Mute or unmute at the source, keeping the capture and the producer.
|
|
614
|
+
*
|
|
615
|
+
* This is what a Mute button does, and it is deliberately NOT unpublish plus
|
|
616
|
+
* publish. Every publish begins with `getUserMedia`, and Safari asks
|
|
617
|
+
* permission on every call to it, so a toggle built on republishing put a
|
|
618
|
+
* permission prompt in front of the person on every unmute. The producer is
|
|
619
|
+
* paused (mediasoup disables the track, so nothing is sent), the node is told
|
|
620
|
+
* so the other side sees `producerPaused`, and resuming is instant and asks
|
|
621
|
+
* nobody anything.
|
|
622
|
+
*/
|
|
370
623
|
async setPaused(producerId: string, paused: boolean): Promise<void> {
|
|
371
624
|
const publication = this.publications.get(producerId);
|
|
372
|
-
if (!publication) return;
|
|
625
|
+
if (!publication || publication.paused === paused) return;
|
|
373
626
|
|
|
374
627
|
if (paused) publication.handle.pause();
|
|
375
628
|
else publication.handle.resume();
|
|
629
|
+
// A new object, so the snapshot moves and a screen reading `paused` follows.
|
|
630
|
+
// Emitted before the round trip: the local effect is already true.
|
|
631
|
+
this.publications.set(producerId, { ...publication, paused });
|
|
632
|
+
this.emit();
|
|
376
633
|
await this.signal
|
|
377
634
|
.request({ method: paused ? "pauseProducer" : "resumeProducer", producerId })
|
|
378
635
|
.catch(() => undefined);
|
|
379
|
-
this.emit();
|
|
380
636
|
}
|
|
381
637
|
|
|
382
638
|
// -------------------------------------------------------------------------
|
|
@@ -449,6 +705,7 @@ export class MediaRoom {
|
|
|
449
705
|
kind: response.kind,
|
|
450
706
|
track: consumer.track,
|
|
451
707
|
paused: entry.paused,
|
|
708
|
+
...(entry.source ? { source: entry.source } : {}),
|
|
452
709
|
});
|
|
453
710
|
|
|
454
711
|
// RULE 3: resume LAST. The node creates every consumer paused, so media
|
|
@@ -463,22 +720,73 @@ export class MediaRoom {
|
|
|
463
720
|
// transports
|
|
464
721
|
// -------------------------------------------------------------------------
|
|
465
722
|
|
|
466
|
-
/**
|
|
467
|
-
|
|
468
|
-
|
|
723
|
+
/**
|
|
724
|
+
* RULE 2: lazily, and once.
|
|
725
|
+
*
|
|
726
|
+
* "Once" has to hold under CONCURRENCY, not just in sequence. `createTransport`
|
|
727
|
+
* is a round trip to the node, and `syncSubscriptions` fans out with
|
|
728
|
+
* `Promise.all`, so one `activeSpeakers` frame naming four producers puts
|
|
729
|
+
* four callers through here in the same tick. A check of the finished
|
|
730
|
+
* transport followed by an await let all four pass the check and send four
|
|
731
|
+
* `createTransport(recv)`: the node caps transports per session, so the
|
|
732
|
+
* fourth was the last one this session would ever get and the person's own
|
|
733
|
+
* Unmute was refused with `capacity` for the rest of the call. So the thing
|
|
734
|
+
* memoised is the in-flight PROMISE, and it is memoised before anything is
|
|
735
|
+
* awaited.
|
|
736
|
+
*
|
|
737
|
+
* A creation that fails is forgotten, so the next caller tries again rather
|
|
738
|
+
* than inheriting a rejection for ever; and `teardownMedia` forgets both,
|
|
739
|
+
* because a transport created against a connection that has since died
|
|
740
|
+
* belongs to that connection, not to the one that replaces it.
|
|
741
|
+
*/
|
|
742
|
+
private ensureSendTransport(): Promise<MediaTransport> {
|
|
743
|
+
return this.ensureTransport("send");
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private ensureRecvTransport(): Promise<MediaTransport> {
|
|
747
|
+
return this.ensureTransport("recv");
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
private ensureTransport(direction: "send" | "recv"): Promise<MediaTransport> {
|
|
751
|
+
const slot = this.transports[direction];
|
|
752
|
+
if (slot.ready) return Promise.resolve(slot.ready);
|
|
753
|
+
if (slot.creating) return slot.creating;
|
|
754
|
+
|
|
755
|
+
const creating = this.createTransport(direction);
|
|
756
|
+
slot.creating = creating;
|
|
757
|
+
creating.then(
|
|
758
|
+
(transport) => {
|
|
759
|
+
if (slot.creating !== creating) {
|
|
760
|
+
// Torn down while the round trip was in flight. The connection this
|
|
761
|
+
// transport was created for is gone, so it is closed rather than kept
|
|
762
|
+
// for the next one.
|
|
763
|
+
transport.close();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
slot.ready = transport;
|
|
767
|
+
slot.creating = undefined;
|
|
768
|
+
},
|
|
769
|
+
() => {
|
|
770
|
+
if (slot.creating === creating) slot.creating = undefined;
|
|
771
|
+
},
|
|
772
|
+
);
|
|
773
|
+
return creating;
|
|
774
|
+
}
|
|
469
775
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
776
|
+
private async createTransport(direction: "send" | "recv"): Promise<MediaTransport> {
|
|
777
|
+
const description = (await this.signal.request({ method: "createTransport", direction })) as TransportDescription;
|
|
778
|
+
const onConnect = async (dtlsParameters: unknown) => {
|
|
779
|
+
await this.signal.request({ method: "connectTransport", transportId: description.transportId, dtlsParameters });
|
|
780
|
+
};
|
|
474
781
|
|
|
475
|
-
|
|
782
|
+
if (direction === "recv") {
|
|
783
|
+
return this.device.createRecvTransport({ description, iceServers: this.iceServers, handlers: { onConnect } });
|
|
784
|
+
}
|
|
785
|
+
return this.device.createSendTransport({
|
|
476
786
|
description,
|
|
477
787
|
iceServers: this.iceServers,
|
|
478
788
|
handlers: {
|
|
479
|
-
onConnect
|
|
480
|
-
await this.signal.request({ method: "connectTransport", transportId: description.transportId, dtlsParameters });
|
|
481
|
-
},
|
|
789
|
+
onConnect,
|
|
482
790
|
onProduce: async ({ kind, rtpParameters, appData }) => {
|
|
483
791
|
const produced = (await this.signal.request({
|
|
484
792
|
method: "produce",
|
|
@@ -491,27 +799,6 @@ export class MediaRoom {
|
|
|
491
799
|
},
|
|
492
800
|
},
|
|
493
801
|
});
|
|
494
|
-
return this.sendTransport;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
private async ensureRecvTransport(): Promise<MediaTransport> {
|
|
498
|
-
if (this.recvTransport) return this.recvTransport;
|
|
499
|
-
|
|
500
|
-
const description = (await this.signal.request({
|
|
501
|
-
method: "createTransport",
|
|
502
|
-
direction: "recv",
|
|
503
|
-
})) as TransportDescription;
|
|
504
|
-
|
|
505
|
-
this.recvTransport = this.device.createRecvTransport({
|
|
506
|
-
description,
|
|
507
|
-
iceServers: this.iceServers,
|
|
508
|
-
handlers: {
|
|
509
|
-
onConnect: async (dtlsParameters) => {
|
|
510
|
-
await this.signal.request({ method: "connectTransport", transportId: description.transportId, dtlsParameters });
|
|
511
|
-
},
|
|
512
|
-
},
|
|
513
|
-
});
|
|
514
|
-
return this.recvTransport;
|
|
515
802
|
}
|
|
516
803
|
|
|
517
804
|
// -------------------------------------------------------------------------
|
|
@@ -564,16 +851,22 @@ export class MediaRoom {
|
|
|
564
851
|
this.consumers.clear();
|
|
565
852
|
this.tracksByProducer.clear();
|
|
566
853
|
|
|
854
|
+
for (const detach of this.trackEndWatchers.values()) detach();
|
|
855
|
+
this.trackEndWatchers.clear();
|
|
567
856
|
for (const publication of this.publications.values()) {
|
|
568
857
|
publication.handle.close();
|
|
569
858
|
publication.track.stop();
|
|
570
859
|
}
|
|
571
860
|
this.publications.clear();
|
|
572
861
|
|
|
573
|
-
this.
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
862
|
+
for (const slot of Object.values(this.transports)) {
|
|
863
|
+
slot.ready?.close();
|
|
864
|
+
slot.ready = undefined;
|
|
865
|
+
// A creation still in flight belongs to the connection that just died.
|
|
866
|
+
// Forgetting it here is what makes `ensureTransport` close the result
|
|
867
|
+
// when it lands, and what lets the next connection create its own.
|
|
868
|
+
slot.creating = undefined;
|
|
869
|
+
}
|
|
577
870
|
}
|
|
578
871
|
|
|
579
872
|
private emit(): void {
|
|
@@ -593,8 +886,70 @@ export class MediaRoom {
|
|
|
593
886
|
}
|
|
594
887
|
}
|
|
595
888
|
|
|
889
|
+
const NO_SOURCES: readonly LocalPublicationSource[] = [];
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Call `onEnded` when a track ends on its own, and return the detach.
|
|
893
|
+
*
|
|
894
|
+
* `ended` fires when the SOURCE goes away (the browser's share bar, a device
|
|
895
|
+
* unplugged), and not for our own `track.stop()`, which is what lets
|
|
896
|
+
* `unpublish` stop the track without re-entering itself. Guarded because a
|
|
897
|
+
* track outside a browser (this package's specs hand the room bare objects) is
|
|
898
|
+
* not an `EventTarget`; in every browser it is.
|
|
899
|
+
*/
|
|
900
|
+
function whenTrackEnds(track: MediaStreamTrack, onEnded: () => void): () => void {
|
|
901
|
+
const target = track as Partial<Pick<EventTarget, "addEventListener" | "removeEventListener">>;
|
|
902
|
+
if (typeof target.addEventListener !== "function" || typeof target.removeEventListener !== "function") {
|
|
903
|
+
return () => undefined;
|
|
904
|
+
}
|
|
905
|
+
target.addEventListener("ended", onEnded);
|
|
906
|
+
return () => target.removeEventListener?.("ended", onEnded);
|
|
907
|
+
}
|
|
908
|
+
|
|
596
909
|
export async function connectToRoom(options: MediaRoomOptions): Promise<MediaRoom> {
|
|
597
910
|
const room = new MediaRoom(options);
|
|
598
911
|
await room.connect();
|
|
599
912
|
return room;
|
|
600
913
|
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* The program feed keeps its RESOLUTION and gives up frame rate instead.
|
|
917
|
+
*
|
|
918
|
+
* Chrome's default for a video sender is `balanced`, which under a low
|
|
919
|
+
* bandwidth estimate or CPU pressure scales the encode down. For a camera tile
|
|
920
|
+
* that is the right trade. For the program feed it is wrong twice over.
|
|
921
|
+
*
|
|
922
|
+
* First, this feed is the broadcast: a 1080p composite arriving at Twitch as
|
|
923
|
+
* 270p is the product, not a degraded preview. Twitch reported exactly that.
|
|
924
|
+
*
|
|
925
|
+
* Second, and worse, the RTMP leg cannot survive the CHANGE. The egress muxes
|
|
926
|
+
* the feed with `-c:v copy`, and an FLV header carries the dimensions once, at
|
|
927
|
+
* the start. When Chrome adapts resolution mid-stream the SPS changes under a
|
|
928
|
+
* header that still describes the old size, and the player refuses the stream:
|
|
929
|
+
*
|
|
930
|
+
* Your browser encountered an error while decoding the video. (Error #3000)
|
|
931
|
+
*
|
|
932
|
+
* while ffmpeg reports `drop_frames=0`, a healthy bitrate, and an established
|
|
933
|
+
* socket. Nothing on the server can see it.
|
|
934
|
+
*
|
|
935
|
+
* `maintain-resolution` makes the encoder drop frames rather than pixels, so
|
|
936
|
+
* the SPS stays put for the life of the broadcast.
|
|
937
|
+
*
|
|
938
|
+
* Best-effort: the setting is not universally implemented, and a browser that
|
|
939
|
+
* ignores it is no worse off than before. Only the program source, because a
|
|
940
|
+
* camera is better off shrinking than freezing.
|
|
941
|
+
*/
|
|
942
|
+
async function keepProgramResolution(
|
|
943
|
+
handle: MediaProducerHandle,
|
|
944
|
+
onLog?: (level: "warn" | "debug", message: string, detail?: unknown) => void,
|
|
945
|
+
): Promise<void> {
|
|
946
|
+
const sender = handle.rtpSender;
|
|
947
|
+
if (!sender || handle.kind !== "video") return;
|
|
948
|
+
try {
|
|
949
|
+
const params = sender.getParameters() as RTCRtpSendParameters & { degradationPreference?: string };
|
|
950
|
+
params.degradationPreference = "maintain-resolution";
|
|
951
|
+
await sender.setParameters(params);
|
|
952
|
+
} catch (err) {
|
|
953
|
+
onLog?.("warn", "could not pin the program feed's resolution", err);
|
|
954
|
+
}
|
|
955
|
+
}
|