@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.
Files changed (38) hide show
  1. package/README.md +20 -4
  2. package/build/core/reconnect.d.ts +8 -2
  3. package/build/core/reconnect.d.ts.map +1 -1
  4. package/build/core/reconnect.js +8 -2
  5. package/build/core/reconnect.js.map +1 -1
  6. package/build/core/signal.d.ts +10 -1
  7. package/build/core/signal.d.ts.map +1 -1
  8. package/build/core/signal.js +39 -10
  9. package/build/core/signal.js.map +1 -1
  10. package/build/core/state.d.ts +3 -0
  11. package/build/core/state.d.ts.map +1 -1
  12. package/build/core/state.js +21 -4
  13. package/build/core/state.js.map +1 -1
  14. package/build/react/index.d.ts +38 -7
  15. package/build/react/index.d.ts.map +1 -1
  16. package/build/react/index.js +86 -7
  17. package/build/react/index.js.map +1 -1
  18. package/build/room/browserDevice.d.ts.map +1 -1
  19. package/build/room/browserDevice.js +13 -4
  20. package/build/room/browserDevice.js.map +1 -1
  21. package/build/room/device.d.ts +13 -0
  22. package/build/room/device.d.ts.map +1 -1
  23. package/build/room/room.d.ts +129 -5
  24. package/build/room/room.d.ts.map +1 -1
  25. package/build/room/room.js +330 -58
  26. package/build/room/room.js.map +1 -1
  27. package/package.json +3 -1
  28. package/src/core/_tests/signal.spec.ts +115 -22
  29. package/src/core/_tests/state.spec.ts +68 -2
  30. package/src/core/reconnect.ts +8 -2
  31. package/src/core/signal.ts +48 -12
  32. package/src/core/state.ts +31 -5
  33. package/src/react/_tests/hooks.spec.tsx +91 -3
  34. package/src/react/index.tsx +117 -20
  35. package/src/room/_tests/room.spec.ts +747 -12
  36. package/src/room/browserDevice.ts +14 -4
  37. package/src/room/device.ts +13 -0
  38. 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: string;
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
- private sendTransport: MediaTransport | undefined;
129
- private recvTransport: MediaTransport | undefined;
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
- this.lastError = cause;
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
- }
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
- this.connection = "reconnecting";
175
- this.recovering = this.scheduleReconnect(cause);
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
- const joined = await this.signal.connect();
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 `signal.onClose`, which is the only
312
- // place that decides. Swallowed here so a retry does not surface as an
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(track: MediaStreamTrack, source: "camera" | "microphone" | "screen"): Promise<LocalPublication> {
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({ track, appData: { source } });
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
- /** RULE 2: lazily, and once. */
467
- private async ensureSendTransport(): Promise<MediaTransport> {
468
- if (this.sendTransport) return this.sendTransport;
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
- const description = (await this.signal.request({
471
- method: "createTransport",
472
- direction: "send",
473
- })) as TransportDescription;
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
- this.sendTransport = this.device.createSendTransport({
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: async (dtlsParameters) => {
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.sendTransport?.close();
574
- this.recvTransport?.close();
575
- this.sendTransport = undefined;
576
- this.recvTransport = undefined;
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
+ }