@tribe-nest/media-client 0.1.0 → 0.1.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.map +1 -1
- package/build/core/state.js +14 -3
- package/build/core/state.js.map +1 -1
- package/build/react/index.d.ts +23 -2
- package/build/react/index.d.ts.map +1 -1
- package/build/react/index.js +26 -0
- package/build/react/index.js.map +1 -1
- package/build/room/room.d.ts +68 -5
- package/build/room/room.d.ts.map +1 -1
- package/build/room/room.js +213 -56
- 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 +22 -2
- package/src/core/reconnect.ts +8 -2
- package/src/core/signal.ts +48 -12
- package/src/core/state.ts +21 -4
- package/src/react/_tests/hooks.spec.tsx +91 -3
- package/src/react/index.tsx +55 -8
- package/src/room/_tests/room.spec.ts +510 -8
- package/src/room/room.ts +236 -60
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,
|
|
@@ -82,12 +83,20 @@ export type MediaTrack = {
|
|
|
82
83
|
paused: boolean;
|
|
83
84
|
};
|
|
84
85
|
|
|
86
|
+
/** What a local track is, in the vocabulary the node checks against `publishKinds`. */
|
|
87
|
+
export type LocalPublicationSource = "camera" | "microphone" | "screen";
|
|
88
|
+
|
|
85
89
|
export type LocalPublication = {
|
|
86
90
|
producerId: string;
|
|
87
91
|
kind: "audio" | "video";
|
|
88
|
-
source:
|
|
92
|
+
source: LocalPublicationSource;
|
|
89
93
|
track: MediaStreamTrack;
|
|
90
94
|
handle: MediaProducerHandle;
|
|
95
|
+
/**
|
|
96
|
+
* Paused at the source by `setPaused`. The capture is still open and the
|
|
97
|
+
* producer still exists; nothing is being sent. This is what a mute is.
|
|
98
|
+
*/
|
|
99
|
+
paused: boolean;
|
|
91
100
|
};
|
|
92
101
|
|
|
93
102
|
export type MediaRoomOptions = {
|
|
@@ -122,17 +131,27 @@ export type MediaRoomOptions = {
|
|
|
122
131
|
|
|
123
132
|
type Listener = () => void;
|
|
124
133
|
|
|
134
|
+
type TransportSlot = {
|
|
135
|
+
ready: MediaTransport | undefined;
|
|
136
|
+
creating: Promise<MediaTransport> | undefined;
|
|
137
|
+
};
|
|
138
|
+
|
|
125
139
|
export class MediaRoom {
|
|
126
140
|
private readonly signal: MediaSignal;
|
|
127
141
|
private readonly device: MediaDevice;
|
|
128
|
-
|
|
129
|
-
private
|
|
142
|
+
/** One slot per direction: the finished transport, or the creation in flight. */
|
|
143
|
+
private readonly transports: Record<"send" | "recv", TransportSlot> = {
|
|
144
|
+
send: { ready: undefined, creating: undefined },
|
|
145
|
+
recv: { ready: undefined, creating: undefined },
|
|
146
|
+
};
|
|
130
147
|
private iceServers: IceServer[] = [];
|
|
131
148
|
private grantsValue: MediaGrants | undefined;
|
|
132
149
|
|
|
133
150
|
private readonly consumers = new Map<string, MediaConsumerHandle>();
|
|
134
151
|
private readonly tracksByProducer = new Map<string, MediaTrack>();
|
|
135
152
|
private readonly publications = new Map<string, LocalPublication>();
|
|
153
|
+
/** Per publication: stop listening for the track ending on its own. */
|
|
154
|
+
private readonly trackEndWatchers = new Map<string, () => void>();
|
|
136
155
|
private readonly listeners = new Set<Listener>();
|
|
137
156
|
/** One in-flight subscribe per producer, so a burst of activeSpeakers frames
|
|
138
157
|
* does not race itself into two consumers for one producer. */
|
|
@@ -158,23 +177,42 @@ export class MediaRoom {
|
|
|
158
177
|
});
|
|
159
178
|
|
|
160
179
|
this.signal.onAny((frame) => this.onFrame(frame));
|
|
161
|
-
this.signal.onClose((cause) =>
|
|
162
|
-
|
|
163
|
-
// Before the decision, and unconditionally. The transports are dead
|
|
164
|
-
// whatever happens next, and so is every capture that was feeding them.
|
|
165
|
-
this.teardownMedia();
|
|
166
|
-
|
|
167
|
-
if (cause.type === "closed_by_client") {
|
|
168
|
-
this.connection = "closed";
|
|
169
|
-
this.recovering = false;
|
|
170
|
-
this.emit();
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
180
|
+
this.signal.onClose((cause) => this.onDisconnect(cause));
|
|
181
|
+
}
|
|
173
182
|
|
|
174
|
-
|
|
175
|
-
|
|
183
|
+
/**
|
|
184
|
+
* The one place that decides what happens after a connection ends, whether
|
|
185
|
+
* it ended after an hour or before a socket was ever opened.
|
|
186
|
+
*/
|
|
187
|
+
private onDisconnect(cause: DisconnectCause): void {
|
|
188
|
+
this.lastError = cause;
|
|
189
|
+
// What the person was sending, before it is torn down. Recorded so the
|
|
190
|
+
// screen can say so once the room is back (see `lostPublicationSources`).
|
|
191
|
+
const wasPublishing = [...new Set([...this.publications.values()].map((p) => p.source))];
|
|
192
|
+
// Before the decision, and unconditionally. The transports are dead
|
|
193
|
+
// whatever happens next, and so is every capture that was feeding them.
|
|
194
|
+
this.teardownMedia();
|
|
195
|
+
// The active set was an instruction about consumers on THIS connection,
|
|
196
|
+
// and those are gone. Left standing, it would be mistaken by the next
|
|
197
|
+
// `joined` for a set the new node had sent ahead of the snapshot.
|
|
198
|
+
if (this.stateValue.activeSpeakers.length > 0) {
|
|
199
|
+
this.stateValue = { ...this.stateValue, activeSpeakers: [] };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// `disposed` as well as the cause: `close()` is a client close whatever the
|
|
203
|
+
// socket's own account of it, and a room that has been closed must never
|
|
204
|
+
// report itself as coming back.
|
|
205
|
+
if (cause.type === "closed_by_client" || this.disposed) {
|
|
206
|
+
this.connection = "closed";
|
|
207
|
+
this.recovering = false;
|
|
176
208
|
this.emit();
|
|
177
|
-
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (wasPublishing.length > 0) this.lostSources = wasPublishing;
|
|
213
|
+
this.connection = "reconnecting";
|
|
214
|
+
this.recovering = this.scheduleReconnect(cause);
|
|
215
|
+
this.emit();
|
|
178
216
|
}
|
|
179
217
|
|
|
180
218
|
get state(): RoomState {
|
|
@@ -219,6 +257,7 @@ export class MediaRoom {
|
|
|
219
257
|
*/
|
|
220
258
|
private trackSnapshot: MediaTrack[] = [];
|
|
221
259
|
private publicationSnapshot: LocalPublication[] = [];
|
|
260
|
+
private lostSources: readonly LocalPublicationSource[] = NO_SOURCES;
|
|
222
261
|
|
|
223
262
|
get tracks(): MediaTrack[] {
|
|
224
263
|
return this.trackSnapshot;
|
|
@@ -228,6 +267,26 @@ export class MediaRoom {
|
|
|
228
267
|
return this.publicationSnapshot;
|
|
229
268
|
}
|
|
230
269
|
|
|
270
|
+
/**
|
|
271
|
+
* What the person was sending when the connection dropped, and is not now.
|
|
272
|
+
*
|
|
273
|
+
* A drop tears down every local publication and STOPS its capture (see
|
|
274
|
+
* `teardownMedia`): the camera light goes out, which is the right thing for a
|
|
275
|
+
* capture whose session has ended and the wrong thing to do silently to a
|
|
276
|
+
* capture whose session is about to resume. The room does not republish on
|
|
277
|
+
* its own. Capture is a browser permission with a UI consequence, and turning
|
|
278
|
+
* a person's camera back on without a press, possibly minutes later, is not a
|
|
279
|
+
* decision this layer gets to make. So instead it says what was lost, and the
|
|
280
|
+
* screen tells the person, who presses the button.
|
|
281
|
+
*
|
|
282
|
+
* Cleared per source when that source is published again, and wholesale by
|
|
283
|
+
* `close()`. A stable reference while unchanged, like every other snapshot
|
|
284
|
+
* here, because `useSyncExternalStore` reads it.
|
|
285
|
+
*/
|
|
286
|
+
get lostPublicationSources(): readonly LocalPublicationSource[] {
|
|
287
|
+
return this.lostSources;
|
|
288
|
+
}
|
|
289
|
+
|
|
231
290
|
/** Subscribe to changes. Returns an unsubscribe. */
|
|
232
291
|
onChange(listener: Listener): () => void {
|
|
233
292
|
this.listeners.add(listener);
|
|
@@ -235,6 +294,14 @@ export class MediaRoom {
|
|
|
235
294
|
}
|
|
236
295
|
|
|
237
296
|
async connect(): Promise<void> {
|
|
297
|
+
// Refused BEFORE touching any state. The signal refuses this too, but by
|
|
298
|
+
// then `connection` would already read "connecting" over a socket that is
|
|
299
|
+
// joined, and nothing would ever put it back.
|
|
300
|
+
const phase = this.signal.phase;
|
|
301
|
+
if (phase === "connecting" || phase === "joining" || phase === "joined") {
|
|
302
|
+
throw new Error("connect() called on a room that is already connecting or connected");
|
|
303
|
+
}
|
|
304
|
+
|
|
238
305
|
this.cancelReconnect();
|
|
239
306
|
// A retry is not a first connection, and saying "Connecting to the call"
|
|
240
307
|
// over a call somebody is already in reads as though they had been thrown
|
|
@@ -242,7 +309,26 @@ export class MediaRoom {
|
|
|
242
309
|
this.connection = this.reconnectAttempt > 0 ? "reconnecting" : "connecting";
|
|
243
310
|
this.emit();
|
|
244
311
|
|
|
245
|
-
|
|
312
|
+
// A failed attempt normally reports itself through `signal.onClose`, which
|
|
313
|
+
// routes to `onDisconnect`. This watches for that report so a rejection
|
|
314
|
+
// that arrives WITHOUT one (nothing in the signal does that today; this is
|
|
315
|
+
// the belt to its braces) is still counted as a failed attempt rather than
|
|
316
|
+
// leaving `connection` on "connecting" with no retry booked and no error
|
|
317
|
+
// to show.
|
|
318
|
+
let disconnectReported = false;
|
|
319
|
+
const stopWatching = this.signal.onClose(() => {
|
|
320
|
+
disconnectReported = true;
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
let joined: Awaited<ReturnType<MediaSignal["connect"]>>;
|
|
324
|
+
try {
|
|
325
|
+
joined = await this.signal.connect();
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (!disconnectReported) this.onDisconnect(causeFromError(error));
|
|
328
|
+
throw error;
|
|
329
|
+
} finally {
|
|
330
|
+
stopWatching();
|
|
331
|
+
}
|
|
246
332
|
|
|
247
333
|
// RULE 1: the device loads first. Everything below needs its capabilities.
|
|
248
334
|
if (!this.device.loaded) {
|
|
@@ -268,6 +354,7 @@ export class MediaRoom {
|
|
|
268
354
|
this.cancelReconnect();
|
|
269
355
|
this.recovering = false;
|
|
270
356
|
this.connection = "closed";
|
|
357
|
+
this.lostSources = NO_SOURCES;
|
|
271
358
|
await this.signal.leave().catch(() => undefined);
|
|
272
359
|
this.teardownMedia();
|
|
273
360
|
this.emit();
|
|
@@ -308,8 +395,10 @@ export class MediaRoom {
|
|
|
308
395
|
this.reconnectTimer = undefined;
|
|
309
396
|
if (this.disposed) return;
|
|
310
397
|
this.reconnectAttempt += 1;
|
|
311
|
-
// A failed attempt comes back through `
|
|
312
|
-
// place that decides
|
|
398
|
+
// A failed attempt comes back through `onDisconnect`, which is the only
|
|
399
|
+
// place that decides, and that holds for an attempt that fails BEFORE it
|
|
400
|
+
// has a socket (the ticket endpoint refusing) as much as for one that
|
|
401
|
+
// drops after an hour. Swallowed here so a retry does not surface as an
|
|
313
402
|
// unhandled rejection in the host application's console.
|
|
314
403
|
void this.connect().catch(() => undefined);
|
|
315
404
|
}, decision.delayMs);
|
|
@@ -333,7 +422,7 @@ export class MediaRoom {
|
|
|
333
422
|
* `publishKinds`: a token granting audio only must not be able to publish a
|
|
334
423
|
* screen share by relabelling it, and the node is where that is decided.
|
|
335
424
|
*/
|
|
336
|
-
async publish(track: MediaStreamTrack, source:
|
|
425
|
+
async publish(track: MediaStreamTrack, source: LocalPublicationSource): Promise<LocalPublication> {
|
|
337
426
|
const kind = track.kind === "audio" ? "audio" : "video";
|
|
338
427
|
if (!this.device.canProduce(kind)) {
|
|
339
428
|
throw new Error(`this browser cannot produce ${kind}`);
|
|
@@ -348,8 +437,23 @@ export class MediaRoom {
|
|
|
348
437
|
source,
|
|
349
438
|
track,
|
|
350
439
|
handle,
|
|
440
|
+
paused: false,
|
|
351
441
|
};
|
|
352
442
|
this.publications.set(handle.id, publication);
|
|
443
|
+
// A track can end WITHOUT us: the browser's own "Stop sharing" bar, a
|
|
444
|
+
// camera unplugged, a device revoked from the OS. Nothing about that reaches
|
|
445
|
+
// `unpublish`, so the node kept a producer nobody was feeding and the
|
|
446
|
+
// control above still read "Stop sharing" about a share that had ended.
|
|
447
|
+
this.trackEndWatchers.set(
|
|
448
|
+
handle.id,
|
|
449
|
+
whenTrackEnds(track, () => {
|
|
450
|
+
void this.unpublish(handle.id);
|
|
451
|
+
}),
|
|
452
|
+
);
|
|
453
|
+
if (this.lostSources.includes(source)) {
|
|
454
|
+
this.lostSources = this.lostSources.filter((s) => s !== source);
|
|
455
|
+
if (this.lostSources.length === 0) this.lostSources = NO_SOURCES;
|
|
456
|
+
}
|
|
353
457
|
this.emit();
|
|
354
458
|
return publication;
|
|
355
459
|
}
|
|
@@ -358,6 +462,8 @@ export class MediaRoom {
|
|
|
358
462
|
const publication = this.publications.get(producerId);
|
|
359
463
|
if (!publication) return;
|
|
360
464
|
|
|
465
|
+
this.trackEndWatchers.get(producerId)?.();
|
|
466
|
+
this.trackEndWatchers.delete(producerId);
|
|
361
467
|
publication.handle.close();
|
|
362
468
|
publication.track.stop();
|
|
363
469
|
this.publications.delete(producerId);
|
|
@@ -367,16 +473,30 @@ export class MediaRoom {
|
|
|
367
473
|
this.emit();
|
|
368
474
|
}
|
|
369
475
|
|
|
476
|
+
/**
|
|
477
|
+
* Mute or unmute at the source, keeping the capture and the producer.
|
|
478
|
+
*
|
|
479
|
+
* This is what a Mute button does, and it is deliberately NOT unpublish plus
|
|
480
|
+
* publish. Every publish begins with `getUserMedia`, and Safari asks
|
|
481
|
+
* permission on every call to it, so a toggle built on republishing put a
|
|
482
|
+
* permission prompt in front of the person on every unmute. The producer is
|
|
483
|
+
* paused (mediasoup disables the track, so nothing is sent), the node is told
|
|
484
|
+
* so the other side sees `producerPaused`, and resuming is instant and asks
|
|
485
|
+
* nobody anything.
|
|
486
|
+
*/
|
|
370
487
|
async setPaused(producerId: string, paused: boolean): Promise<void> {
|
|
371
488
|
const publication = this.publications.get(producerId);
|
|
372
|
-
if (!publication) return;
|
|
489
|
+
if (!publication || publication.paused === paused) return;
|
|
373
490
|
|
|
374
491
|
if (paused) publication.handle.pause();
|
|
375
492
|
else publication.handle.resume();
|
|
493
|
+
// A new object, so the snapshot moves and a screen reading `paused` follows.
|
|
494
|
+
// Emitted before the round trip: the local effect is already true.
|
|
495
|
+
this.publications.set(producerId, { ...publication, paused });
|
|
496
|
+
this.emit();
|
|
376
497
|
await this.signal
|
|
377
498
|
.request({ method: paused ? "pauseProducer" : "resumeProducer", producerId })
|
|
378
499
|
.catch(() => undefined);
|
|
379
|
-
this.emit();
|
|
380
500
|
}
|
|
381
501
|
|
|
382
502
|
// -------------------------------------------------------------------------
|
|
@@ -463,22 +583,73 @@ export class MediaRoom {
|
|
|
463
583
|
// transports
|
|
464
584
|
// -------------------------------------------------------------------------
|
|
465
585
|
|
|
466
|
-
/**
|
|
467
|
-
|
|
468
|
-
|
|
586
|
+
/**
|
|
587
|
+
* RULE 2: lazily, and once.
|
|
588
|
+
*
|
|
589
|
+
* "Once" has to hold under CONCURRENCY, not just in sequence. `createTransport`
|
|
590
|
+
* is a round trip to the node, and `syncSubscriptions` fans out with
|
|
591
|
+
* `Promise.all`, so one `activeSpeakers` frame naming four producers puts
|
|
592
|
+
* four callers through here in the same tick. A check of the finished
|
|
593
|
+
* transport followed by an await let all four pass the check and send four
|
|
594
|
+
* `createTransport(recv)`: the node caps transports per session, so the
|
|
595
|
+
* fourth was the last one this session would ever get and the person's own
|
|
596
|
+
* Unmute was refused with `capacity` for the rest of the call. So the thing
|
|
597
|
+
* memoised is the in-flight PROMISE, and it is memoised before anything is
|
|
598
|
+
* awaited.
|
|
599
|
+
*
|
|
600
|
+
* A creation that fails is forgotten, so the next caller tries again rather
|
|
601
|
+
* than inheriting a rejection for ever; and `teardownMedia` forgets both,
|
|
602
|
+
* because a transport created against a connection that has since died
|
|
603
|
+
* belongs to that connection, not to the one that replaces it.
|
|
604
|
+
*/
|
|
605
|
+
private ensureSendTransport(): Promise<MediaTransport> {
|
|
606
|
+
return this.ensureTransport("send");
|
|
607
|
+
}
|
|
469
608
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
})) as TransportDescription;
|
|
609
|
+
private ensureRecvTransport(): Promise<MediaTransport> {
|
|
610
|
+
return this.ensureTransport("recv");
|
|
611
|
+
}
|
|
474
612
|
|
|
475
|
-
|
|
613
|
+
private ensureTransport(direction: "send" | "recv"): Promise<MediaTransport> {
|
|
614
|
+
const slot = this.transports[direction];
|
|
615
|
+
if (slot.ready) return Promise.resolve(slot.ready);
|
|
616
|
+
if (slot.creating) return slot.creating;
|
|
617
|
+
|
|
618
|
+
const creating = this.createTransport(direction);
|
|
619
|
+
slot.creating = creating;
|
|
620
|
+
creating.then(
|
|
621
|
+
(transport) => {
|
|
622
|
+
if (slot.creating !== creating) {
|
|
623
|
+
// Torn down while the round trip was in flight. The connection this
|
|
624
|
+
// transport was created for is gone, so it is closed rather than kept
|
|
625
|
+
// for the next one.
|
|
626
|
+
transport.close();
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
slot.ready = transport;
|
|
630
|
+
slot.creating = undefined;
|
|
631
|
+
},
|
|
632
|
+
() => {
|
|
633
|
+
if (slot.creating === creating) slot.creating = undefined;
|
|
634
|
+
},
|
|
635
|
+
);
|
|
636
|
+
return creating;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
private async createTransport(direction: "send" | "recv"): Promise<MediaTransport> {
|
|
640
|
+
const description = (await this.signal.request({ method: "createTransport", direction })) as TransportDescription;
|
|
641
|
+
const onConnect = async (dtlsParameters: unknown) => {
|
|
642
|
+
await this.signal.request({ method: "connectTransport", transportId: description.transportId, dtlsParameters });
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
if (direction === "recv") {
|
|
646
|
+
return this.device.createRecvTransport({ description, iceServers: this.iceServers, handlers: { onConnect } });
|
|
647
|
+
}
|
|
648
|
+
return this.device.createSendTransport({
|
|
476
649
|
description,
|
|
477
650
|
iceServers: this.iceServers,
|
|
478
651
|
handlers: {
|
|
479
|
-
onConnect
|
|
480
|
-
await this.signal.request({ method: "connectTransport", transportId: description.transportId, dtlsParameters });
|
|
481
|
-
},
|
|
652
|
+
onConnect,
|
|
482
653
|
onProduce: async ({ kind, rtpParameters, appData }) => {
|
|
483
654
|
const produced = (await this.signal.request({
|
|
484
655
|
method: "produce",
|
|
@@ -491,27 +662,6 @@ export class MediaRoom {
|
|
|
491
662
|
},
|
|
492
663
|
},
|
|
493
664
|
});
|
|
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
665
|
}
|
|
516
666
|
|
|
517
667
|
// -------------------------------------------------------------------------
|
|
@@ -564,16 +714,22 @@ export class MediaRoom {
|
|
|
564
714
|
this.consumers.clear();
|
|
565
715
|
this.tracksByProducer.clear();
|
|
566
716
|
|
|
717
|
+
for (const detach of this.trackEndWatchers.values()) detach();
|
|
718
|
+
this.trackEndWatchers.clear();
|
|
567
719
|
for (const publication of this.publications.values()) {
|
|
568
720
|
publication.handle.close();
|
|
569
721
|
publication.track.stop();
|
|
570
722
|
}
|
|
571
723
|
this.publications.clear();
|
|
572
724
|
|
|
573
|
-
this.
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
725
|
+
for (const slot of Object.values(this.transports)) {
|
|
726
|
+
slot.ready?.close();
|
|
727
|
+
slot.ready = undefined;
|
|
728
|
+
// A creation still in flight belongs to the connection that just died.
|
|
729
|
+
// Forgetting it here is what makes `ensureTransport` close the result
|
|
730
|
+
// when it lands, and what lets the next connection create its own.
|
|
731
|
+
slot.creating = undefined;
|
|
732
|
+
}
|
|
577
733
|
}
|
|
578
734
|
|
|
579
735
|
private emit(): void {
|
|
@@ -593,6 +749,26 @@ export class MediaRoom {
|
|
|
593
749
|
}
|
|
594
750
|
}
|
|
595
751
|
|
|
752
|
+
const NO_SOURCES: readonly LocalPublicationSource[] = [];
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Call `onEnded` when a track ends on its own, and return the detach.
|
|
756
|
+
*
|
|
757
|
+
* `ended` fires when the SOURCE goes away (the browser's share bar, a device
|
|
758
|
+
* unplugged), and not for our own `track.stop()`, which is what lets
|
|
759
|
+
* `unpublish` stop the track without re-entering itself. Guarded because a
|
|
760
|
+
* track outside a browser (this package's specs hand the room bare objects) is
|
|
761
|
+
* not an `EventTarget`; in every browser it is.
|
|
762
|
+
*/
|
|
763
|
+
function whenTrackEnds(track: MediaStreamTrack, onEnded: () => void): () => void {
|
|
764
|
+
const target = track as Partial<Pick<EventTarget, "addEventListener" | "removeEventListener">>;
|
|
765
|
+
if (typeof target.addEventListener !== "function" || typeof target.removeEventListener !== "function") {
|
|
766
|
+
return () => undefined;
|
|
767
|
+
}
|
|
768
|
+
target.addEventListener("ended", onEnded);
|
|
769
|
+
return () => target.removeEventListener?.("ended", onEnded);
|
|
770
|
+
}
|
|
771
|
+
|
|
596
772
|
export async function connectToRoom(options: MediaRoomOptions): Promise<MediaRoom> {
|
|
597
773
|
const room = new MediaRoom(options);
|
|
598
774
|
await room.connect();
|