@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/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: string;
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
- private sendTransport: MediaTransport | undefined;
129
- private recvTransport: MediaTransport | undefined;
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
- 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
- }
180
+ this.signal.onClose((cause) => this.onDisconnect(cause));
181
+ }
173
182
 
174
- this.connection = "reconnecting";
175
- this.recovering = this.scheduleReconnect(cause);
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
- const joined = await this.signal.connect();
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 `signal.onClose`, which is the only
312
- // place that decides. Swallowed here so a retry does not surface as an
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: "camera" | "microphone" | "screen"): Promise<LocalPublication> {
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
- /** RULE 2: lazily, and once. */
467
- private async ensureSendTransport(): Promise<MediaTransport> {
468
- if (this.sendTransport) return this.sendTransport;
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
- const description = (await this.signal.request({
471
- method: "createTransport",
472
- direction: "send",
473
- })) as TransportDescription;
609
+ private ensureRecvTransport(): Promise<MediaTransport> {
610
+ return this.ensureTransport("recv");
611
+ }
474
612
 
475
- this.sendTransport = this.device.createSendTransport({
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: async (dtlsParameters) => {
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.sendTransport?.close();
574
- this.recvTransport?.close();
575
- this.sendTransport = undefined;
576
- this.recvTransport = undefined;
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();