@tribe-nest/media-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +68 -0
  2. package/build/core/index.d.ts +17 -0
  3. package/build/core/index.d.ts.map +1 -0
  4. package/build/core/index.js +41 -0
  5. package/build/core/index.js.map +1 -0
  6. package/build/core/reconnect.d.ts +95 -0
  7. package/build/core/reconnect.d.ts.map +1 -0
  8. package/build/core/reconnect.js +160 -0
  9. package/build/core/reconnect.js.map +1 -0
  10. package/build/core/signal.d.ts +184 -0
  11. package/build/core/signal.d.ts.map +1 -0
  12. package/build/core/signal.js +416 -0
  13. package/build/core/signal.js.map +1 -0
  14. package/build/core/socket.d.ts +57 -0
  15. package/build/core/socket.d.ts.map +1 -0
  16. package/build/core/socket.js +37 -0
  17. package/build/core/socket.js.map +1 -0
  18. package/build/core/state.d.ts +67 -0
  19. package/build/core/state.d.ts.map +1 -0
  20. package/build/core/state.js +193 -0
  21. package/build/core/state.js.map +1 -0
  22. package/build/index.d.ts +29 -0
  23. package/build/index.d.ts.map +1 -0
  24. package/build/index.js +51 -0
  25. package/build/index.js.map +1 -0
  26. package/build/protocol.d.ts +10 -0
  27. package/build/protocol.d.ts.map +1 -0
  28. package/build/protocol.js +26 -0
  29. package/build/protocol.js.map +1 -0
  30. package/build/react/index.d.ts +147 -0
  31. package/build/react/index.d.ts.map +1 -0
  32. package/build/react/index.js +319 -0
  33. package/build/react/index.js.map +1 -0
  34. package/build/room/browserDevice.d.ts +3 -0
  35. package/build/room/browserDevice.d.ts.map +1 -0
  36. package/build/room/browserDevice.js +94 -0
  37. package/build/room/browserDevice.js.map +1 -0
  38. package/build/room/device.d.ts +114 -0
  39. package/build/room/device.d.ts.map +1 -0
  40. package/build/room/device.js +3 -0
  41. package/build/room/device.js.map +1 -0
  42. package/build/room/room.d.ts +219 -0
  43. package/build/room/room.d.ts.map +1 -0
  44. package/build/room/room.js +438 -0
  45. package/build/room/room.js.map +1 -0
  46. package/package.json +69 -0
  47. package/src/_tests/clientBoundary.spec.ts +110 -0
  48. package/src/core/_tests/coreBoundary.spec.ts +70 -0
  49. package/src/core/_tests/fakeSignalServer.ts +188 -0
  50. package/src/core/_tests/reconnect.spec.ts +180 -0
  51. package/src/core/_tests/signal.spec.ts +347 -0
  52. package/src/core/_tests/state.spec.ts +226 -0
  53. package/src/core/index.ts +63 -0
  54. package/src/core/reconnect.ts +233 -0
  55. package/src/core/signal.ts +527 -0
  56. package/src/core/socket.ts +58 -0
  57. package/src/core/state.ts +251 -0
  58. package/src/index.ts +54 -0
  59. package/src/protocol.ts +9 -0
  60. package/src/react/_tests/hooks.spec.tsx +509 -0
  61. package/src/react/index.tsx +439 -0
  62. package/src/room/_tests/room.spec.ts +595 -0
  63. package/src/room/browserDevice.ts +114 -0
  64. package/src/room/device.ts +119 -0
  65. package/src/room/room.ts +600 -0
@@ -0,0 +1,600 @@
1
+ import type { EventFrame, MediaGrants } from "@tribe-nest/media-protocol";
2
+
3
+ import {
4
+ MediaSignal,
5
+ decideReconnect,
6
+ initialRoomState,
7
+ reduceRoomState,
8
+ type DisconnectCause,
9
+ type MediaCoreCredentials,
10
+ type MediaWebSocketFactory,
11
+ type ReconnectOptions,
12
+ type RoomState,
13
+ type SignalLogLevel,
14
+ } from "../core";
15
+ import type {
16
+ IceServer,
17
+ MediaConsumerHandle,
18
+ MediaDevice,
19
+ MediaDeviceFactory,
20
+ MediaProducerHandle,
21
+ MediaTransport,
22
+ RtpParameters,
23
+ TransportDescription,
24
+ } from "./device";
25
+
26
+ /**
27
+ * The room: a live call, as an application sees it.
28
+ *
29
+ * ## The ordering rules, which are the whole content of this file
30
+ *
31
+ * Four of them, each with a failure that is invisible until somebody is on a
32
+ * call:
33
+ *
34
+ * 1. **The device loads before anything else.** `consume` on the node needs our
35
+ * `rtpCapabilities`, and those do not exist until the device has been loaded
36
+ * with the router's. Getting this wrong produces a `consume` that is refused
37
+ * for a reason that reads like a codec problem.
38
+ * 2. **A transport is created lazily and ONCE per direction.** Creating one
39
+ * eagerly costs an ICE gather for a participant who may only ever watch.
40
+ * Creating two costs a second DTLS handshake and splits the media across
41
+ * transports for no reason.
42
+ * 3. **A consumer is resumed only after its track is attached.** The node
43
+ * creates every consumer paused, deliberately: media arriving before there
44
+ * is anywhere to put it is dropped, and for video that means waiting for the
45
+ * next keyframe to see anything. So the resume belongs after the application
46
+ * has the track, not at consume time.
47
+ * 4. **`autoSubscribe` follows the ACTIVE SET, not the producer list.** The
48
+ * node refuses a consume outside it, so a client that subscribed to
49
+ * everything would generate a refusal per producer in any room over the
50
+ * threshold and show nothing for its trouble.
51
+ *
52
+ * ## Coming back is part of the room, not part of the application
53
+ *
54
+ * A socket that dies mid-call leaves the room in `reconnecting`, and a state
55
+ * called "reconnecting" that nothing reconnects is worse than one called
56
+ * "closed": the screen above it draws a spinner and promises a recovery no code
57
+ * is performing, so the person waits instead of reloading. So the room itself
58
+ * drives `core/reconnect.ts`'s policy - fresh credentials through `MEDIA_URL`
59
+ * every attempt, the node's own drain window honoured, a closed room never
60
+ * rejoined - and publishes whether an attempt is actually booked
61
+ * (`isRecovering`) so a UI can tell "wait" from "press something".
62
+ *
63
+ * ## What this deliberately does not do
64
+ *
65
+ * Decide whether it is allowed to see somebody. `grants` are carried for
66
+ * RENDERING - so a UI does not offer a control the node is about to refuse -
67
+ * and the node enforces independently. A client copy of an authorization
68
+ * decision is a UI hint; treating it as the decision is how a barrier ends up
69
+ * enforced in the one place an attacker controls.
70
+ */
71
+
72
+ export type MediaRoomCredentials = MediaCoreCredentials;
73
+
74
+ export type ConnectionState = "idle" | "connecting" | "connected" | "reconnecting" | "closed";
75
+
76
+ export type MediaTrack = {
77
+ producerId: string;
78
+ identity: string;
79
+ kind: "audio" | "video";
80
+ track: MediaStreamTrack;
81
+ /** Paused at the SOURCE, as the publisher left it. */
82
+ paused: boolean;
83
+ };
84
+
85
+ export type LocalPublication = {
86
+ producerId: string;
87
+ kind: "audio" | "video";
88
+ source: string;
89
+ track: MediaStreamTrack;
90
+ handle: MediaProducerHandle;
91
+ };
92
+
93
+ export type MediaRoomOptions = {
94
+ /**
95
+ * Called before EVERY attempt. A join ticket expires in minutes and a call
96
+ * lasts an hour, so `{ url, token }` passed once is a defect rather than a
97
+ * naming choice: the first reconnect would present an expired token.
98
+ */
99
+ getCredentials: () => Promise<MediaRoomCredentials> | MediaRoomCredentials;
100
+ /** Follow the node's active set automatically. On by default: a client that
101
+ * does not follow it shows black tiles in any room over the threshold. */
102
+ autoSubscribe?: boolean;
103
+ /**
104
+ * Required, and supplied by the entry point rather than defaulted here.
105
+ *
106
+ * `room.ts` importing the browser device - even lazily - would make this file
107
+ * un-loadable outside a browser and drag `mediasoup-client` into any bundle
108
+ * that wanted the room TYPES. The root barrel is the browser entry and is
109
+ * where `createBrowserDevice` is wired in.
110
+ */
111
+ device: MediaDeviceFactory;
112
+ webSocket?: MediaWebSocketFactory;
113
+ /**
114
+ * How hard to try to get back in after a drop. Defaults to
115
+ * `DEFAULT_RECONNECT_OPTIONS`; `{ maxAttempts: 0 }` turns automatic recovery
116
+ * off, which leaves `isRecovering` false and is what tells a UI to offer a
117
+ * control instead of a spinner.
118
+ */
119
+ reconnect?: Partial<ReconnectOptions>;
120
+ onLog?: (level: SignalLogLevel, message: string, detail?: unknown) => void;
121
+ };
122
+
123
+ type Listener = () => void;
124
+
125
+ export class MediaRoom {
126
+ private readonly signal: MediaSignal;
127
+ private readonly device: MediaDevice;
128
+ private sendTransport: MediaTransport | undefined;
129
+ private recvTransport: MediaTransport | undefined;
130
+ private iceServers: IceServer[] = [];
131
+ private grantsValue: MediaGrants | undefined;
132
+
133
+ private readonly consumers = new Map<string, MediaConsumerHandle>();
134
+ private readonly tracksByProducer = new Map<string, MediaTrack>();
135
+ private readonly publications = new Map<string, LocalPublication>();
136
+ private readonly listeners = new Set<Listener>();
137
+ /** One in-flight subscribe per producer, so a burst of activeSpeakers frames
138
+ * does not race itself into two consumers for one producer. */
139
+ private readonly subscribing = new Map<string, Promise<void>>();
140
+
141
+ private stateValue: RoomState = initialRoomState;
142
+ private connection: ConnectionState = "idle";
143
+ private lastError: DisconnectCause | undefined;
144
+
145
+ /** Consecutive failed attempts since the last successful join. */
146
+ private reconnectAttempt = 0;
147
+ private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
148
+ private recovering = false;
149
+ /** `close()` has been called. Nothing may open a socket after that. */
150
+ private disposed = false;
151
+
152
+ constructor(private readonly options: MediaRoomOptions) {
153
+ this.device = options.device();
154
+ this.signal = new MediaSignal({
155
+ getCredentials: options.getCredentials,
156
+ ...(options.webSocket ? { webSocket: options.webSocket } : {}),
157
+ ...(options.onLog ? { onLog: options.onLog } : {}),
158
+ });
159
+
160
+ 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
+ }
173
+
174
+ this.connection = "reconnecting";
175
+ this.recovering = this.scheduleReconnect(cause);
176
+ this.emit();
177
+ });
178
+ }
179
+
180
+ get state(): RoomState {
181
+ return this.stateValue;
182
+ }
183
+
184
+ get connectionState(): ConnectionState {
185
+ return this.connection;
186
+ }
187
+
188
+ get error(): DisconnectCause | undefined {
189
+ return this.lastError;
190
+ }
191
+
192
+ /**
193
+ * Is another attempt booked?
194
+ *
195
+ * The difference between "wait" and "press something", and the room is the
196
+ * only side that knows: `connectionState` is `reconnecting` both while a
197
+ * retry is in flight and after the policy has given up, and a screen that
198
+ * cannot tell them apart spins forever over a call that is over.
199
+ */
200
+ get isRecovering(): boolean {
201
+ return this.recovering;
202
+ }
203
+
204
+ /** What the NODE says this token may do. For rendering only. */
205
+ get grants(): MediaGrants | undefined {
206
+ return this.grantsValue;
207
+ }
208
+
209
+ /**
210
+ * Snapshots, rebuilt only when something changed.
211
+ *
212
+ * These are read by `useSyncExternalStore`, which compares with `Object.is`.
213
+ * A getter that spread the map on every call would hand it a NEW array every
214
+ * check, so React would see a change every time and re-render for ever -
215
+ * "The result of getSnapshot should be cached to avoid an infinite loop".
216
+ *
217
+ * The reducer in `core/state.ts` returns the same object when nothing changed
218
+ * for exactly this reason; these getters have to hold the same property.
219
+ */
220
+ private trackSnapshot: MediaTrack[] = [];
221
+ private publicationSnapshot: LocalPublication[] = [];
222
+
223
+ get tracks(): MediaTrack[] {
224
+ return this.trackSnapshot;
225
+ }
226
+
227
+ get localPublications(): LocalPublication[] {
228
+ return this.publicationSnapshot;
229
+ }
230
+
231
+ /** Subscribe to changes. Returns an unsubscribe. */
232
+ onChange(listener: Listener): () => void {
233
+ this.listeners.add(listener);
234
+ return () => this.listeners.delete(listener);
235
+ }
236
+
237
+ async connect(): Promise<void> {
238
+ this.cancelReconnect();
239
+ // A retry is not a first connection, and saying "Connecting to the call"
240
+ // over a call somebody is already in reads as though they had been thrown
241
+ // out of it.
242
+ this.connection = this.reconnectAttempt > 0 ? "reconnecting" : "connecting";
243
+ this.emit();
244
+
245
+ const joined = await this.signal.connect();
246
+
247
+ // RULE 1: the device loads first. Everything below needs its capabilities.
248
+ if (!this.device.loaded) {
249
+ await this.device.load(joined.routerRtpCapabilities as never);
250
+ }
251
+
252
+ this.iceServers = (joined.iceServers ?? []) as IceServer[];
253
+ this.grantsValue = joined.grants;
254
+ this.stateValue = reduceRoomState(this.stateValue, joined);
255
+ this.connection = "connected";
256
+ this.lastError = undefined;
257
+ // A successful join resets the ladder: an hour-long call that drops once
258
+ // should not start at a 30-second delay because of a blip at minute two.
259
+ this.reconnectAttempt = 0;
260
+ this.recovering = false;
261
+ this.emit();
262
+
263
+ if (this.options.autoSubscribe !== false) await this.syncSubscriptions();
264
+ }
265
+
266
+ async close(): Promise<void> {
267
+ this.disposed = true;
268
+ this.cancelReconnect();
269
+ this.recovering = false;
270
+ this.connection = "closed";
271
+ await this.signal.leave().catch(() => undefined);
272
+ this.teardownMedia();
273
+ this.emit();
274
+ }
275
+
276
+ // -------------------------------------------------------------------------
277
+ // getting back in
278
+ // -------------------------------------------------------------------------
279
+
280
+ /**
281
+ * Decide whether to come back, and book the attempt. Returns whether one was.
282
+ *
283
+ * The policy is `core/reconnect.ts`'s rather than a second copy of it here:
284
+ * "a drained node is left alone for the window it named", "a closed room is
285
+ * never rejoined", "a terminal refusal is not retried" and "jitter or the
286
+ * whole fleet lands on one replacement node together" each have an outage
287
+ * behind them, and a restatement is a restatement that drifts.
288
+ *
289
+ * The attempt goes through `connect()`, which fetches CREDENTIALS afresh: a
290
+ * join ticket lives minutes, so the one that opened this call is already
291
+ * stale, and the node placement has to be resolved again anyway because the
292
+ * node we just lost is the one node that cannot serve us.
293
+ */
294
+ private scheduleReconnect(cause: DisconnectCause): boolean {
295
+ if (this.disposed) return false;
296
+
297
+ const decision = decideReconnect({
298
+ cause,
299
+ attempt: this.reconnectAttempt,
300
+ ...(this.options.reconnect ? { options: this.options.reconnect } : {}),
301
+ });
302
+ if (decision.action === "stop") {
303
+ this.options.onLog?.("warn", `not reconnecting: ${decision.reason}`, cause);
304
+ return false;
305
+ }
306
+
307
+ this.reconnectTimer = setTimeout(() => {
308
+ this.reconnectTimer = undefined;
309
+ if (this.disposed) return;
310
+ 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
313
+ // unhandled rejection in the host application's console.
314
+ void this.connect().catch(() => undefined);
315
+ }, decision.delayMs);
316
+ return true;
317
+ }
318
+
319
+ private cancelReconnect(): void {
320
+ if (this.reconnectTimer === undefined) return;
321
+ clearTimeout(this.reconnectTimer);
322
+ this.reconnectTimer = undefined;
323
+ }
324
+
325
+ // -------------------------------------------------------------------------
326
+ // publishing
327
+ // -------------------------------------------------------------------------
328
+
329
+ /**
330
+ * Publish a track.
331
+ *
332
+ * `source` travels in `appData` and is what the node checks against
333
+ * `publishKinds`: a token granting audio only must not be able to publish a
334
+ * screen share by relabelling it, and the node is where that is decided.
335
+ */
336
+ async publish(track: MediaStreamTrack, source: "camera" | "microphone" | "screen"): Promise<LocalPublication> {
337
+ const kind = track.kind === "audio" ? "audio" : "video";
338
+ if (!this.device.canProduce(kind)) {
339
+ throw new Error(`this browser cannot produce ${kind}`);
340
+ }
341
+
342
+ const transport = await this.ensureSendTransport();
343
+ const handle = await transport.produce({ track, appData: { source } });
344
+
345
+ const publication: LocalPublication = {
346
+ producerId: handle.id,
347
+ kind,
348
+ source,
349
+ track,
350
+ handle,
351
+ };
352
+ this.publications.set(handle.id, publication);
353
+ this.emit();
354
+ return publication;
355
+ }
356
+
357
+ async unpublish(producerId: string): Promise<void> {
358
+ const publication = this.publications.get(producerId);
359
+ if (!publication) return;
360
+
361
+ publication.handle.close();
362
+ publication.track.stop();
363
+ this.publications.delete(producerId);
364
+ // Told to the node, because closing a local handle stops OUR sending and
365
+ // leaves the node holding a producer nobody is feeding.
366
+ await this.signal.request({ method: "closeProducer", producerId }).catch(() => undefined);
367
+ this.emit();
368
+ }
369
+
370
+ async setPaused(producerId: string, paused: boolean): Promise<void> {
371
+ const publication = this.publications.get(producerId);
372
+ if (!publication) return;
373
+
374
+ if (paused) publication.handle.pause();
375
+ else publication.handle.resume();
376
+ await this.signal
377
+ .request({ method: paused ? "pauseProducer" : "resumeProducer", producerId })
378
+ .catch(() => undefined);
379
+ this.emit();
380
+ }
381
+
382
+ // -------------------------------------------------------------------------
383
+ // subscribing
384
+ // -------------------------------------------------------------------------
385
+
386
+ /**
387
+ * Bring subscriptions in line with what the node says we should be receiving.
388
+ *
389
+ * RULE 4. The target is the ACTIVE SET, not the producer list: the node
390
+ * refuses anything outside it, so subscribing to every producer in a large
391
+ * room produces one refusal per producer and nothing to show.
392
+ *
393
+ * Below the threshold the node reports every producer as active, so the two
394
+ * are the same thing and this needs no special case.
395
+ */
396
+ async syncSubscriptions(): Promise<void> {
397
+ const wanted = new Set(this.stateValue.activeSpeakers);
398
+
399
+ for (const [producerId, consumer] of this.consumers) {
400
+ if (wanted.has(producerId)) continue;
401
+ // Dropped rather than paused. A consumer the node has moved out of the
402
+ // active set is one it may refuse to keep feeding, and holding it costs
403
+ // the node a consumer object for a tile nobody is looking at.
404
+ consumer.close();
405
+ this.consumers.delete(producerId);
406
+ this.tracksByProducer.delete(producerId);
407
+ await this.signal.request({ method: "closeConsumer", consumerId: consumer.id }).catch(() => undefined);
408
+ }
409
+
410
+ await Promise.all([...wanted].map((producerId) => this.subscribe(producerId)));
411
+ this.emit();
412
+ }
413
+
414
+ async subscribe(producerId: string): Promise<void> {
415
+ if (this.consumers.has(producerId)) return;
416
+ const inFlight = this.subscribing.get(producerId);
417
+ // Two `activeSpeakers` frames in the same tick would otherwise both pass the
418
+ // check above and build two consumers for one producer.
419
+ if (inFlight) return inFlight;
420
+
421
+ const work = this.doSubscribe(producerId).finally(() => this.subscribing.delete(producerId));
422
+ this.subscribing.set(producerId, work);
423
+ return work;
424
+ }
425
+
426
+ private async doSubscribe(producerId: string): Promise<void> {
427
+ const entry = this.stateValue.producers.find((p) => p.producerId === producerId);
428
+ if (!entry) return;
429
+
430
+ const transport = await this.ensureRecvTransport();
431
+ const response = (await this.signal.request({
432
+ method: "consume",
433
+ transportId: transport.id,
434
+ producerId,
435
+ rtpCapabilities: this.device.rtpCapabilities,
436
+ })) as { consumerId: string; producerId: string; kind: "audio" | "video"; rtpParameters: RtpParameters };
437
+
438
+ const consumer = await transport.consume({
439
+ id: response.consumerId,
440
+ producerId: response.producerId,
441
+ kind: response.kind,
442
+ rtpParameters: response.rtpParameters,
443
+ });
444
+
445
+ this.consumers.set(producerId, consumer);
446
+ this.tracksByProducer.set(producerId, {
447
+ producerId,
448
+ identity: entry.identity,
449
+ kind: response.kind,
450
+ track: consumer.track,
451
+ paused: entry.paused,
452
+ });
453
+
454
+ // RULE 3: resume LAST. The node creates every consumer paused, so media
455
+ // arriving before the application has the track is dropped - and for video
456
+ // that means a black tile until the next keyframe.
457
+ await this.signal.request({ method: "resumeConsumer", consumerId: consumer.id });
458
+ consumer.resume();
459
+ this.emit();
460
+ }
461
+
462
+ // -------------------------------------------------------------------------
463
+ // transports
464
+ // -------------------------------------------------------------------------
465
+
466
+ /** RULE 2: lazily, and once. */
467
+ private async ensureSendTransport(): Promise<MediaTransport> {
468
+ if (this.sendTransport) return this.sendTransport;
469
+
470
+ const description = (await this.signal.request({
471
+ method: "createTransport",
472
+ direction: "send",
473
+ })) as TransportDescription;
474
+
475
+ this.sendTransport = this.device.createSendTransport({
476
+ description,
477
+ iceServers: this.iceServers,
478
+ handlers: {
479
+ onConnect: async (dtlsParameters) => {
480
+ await this.signal.request({ method: "connectTransport", transportId: description.transportId, dtlsParameters });
481
+ },
482
+ onProduce: async ({ kind, rtpParameters, appData }) => {
483
+ const produced = (await this.signal.request({
484
+ method: "produce",
485
+ transportId: description.transportId,
486
+ kind,
487
+ rtpParameters,
488
+ ...(appData ? { appData } : {}),
489
+ })) as { producerId: string };
490
+ return produced.producerId;
491
+ },
492
+ },
493
+ });
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
+ }
516
+
517
+ // -------------------------------------------------------------------------
518
+ // events
519
+ // -------------------------------------------------------------------------
520
+
521
+ private onFrame(frame: EventFrame): void {
522
+ const next = reduceRoomState(this.stateValue, frame);
523
+ const changed = next !== this.stateValue;
524
+ this.stateValue = next;
525
+
526
+ if (frame.event === "joined") this.grantsValue = frame.grants ?? this.grantsValue;
527
+
528
+ if (frame.event === "producerClosed") {
529
+ const consumer = this.consumers.get(frame.producerId);
530
+ consumer?.close();
531
+ this.consumers.delete(frame.producerId);
532
+ this.tracksByProducer.delete(frame.producerId);
533
+ }
534
+
535
+ // The node has changed what we should be receiving. Following it is the
536
+ // client's whole job in a large room.
537
+ const followsSet = frame.event === "activeSpeakers" || frame.event === "producerAppeared";
538
+ if (followsSet && this.options.autoSubscribe !== false && this.connection === "connected") {
539
+ void this.syncSubscriptions().catch((err) => this.options.onLog?.("warn", "subscription sync failed", err));
540
+ }
541
+
542
+ if (changed) this.emit();
543
+ }
544
+
545
+ /**
546
+ * Everything the connection was carrying, released.
547
+ *
548
+ * The local CAPTURES go with it, and that is the part worth stating. A
549
+ * publication whose transport has closed sends nothing, but the
550
+ * `getUserMedia` track behind it is still capturing: the recording indicator
551
+ * stays lit on the machine and the controls above still read "Mute", claiming
552
+ * a microphone is publishing into a room that has ended. Leaving them running
553
+ * is retained personal capture after the session it was consented to was
554
+ * terminated by the other party, so the room stops them wherever the
555
+ * connection goes - a leave, a drop, a drain, or the host closing the room -
556
+ * rather than only on the one path a person happens to take deliberately.
557
+ *
558
+ * The node is not told: this runs when the socket is already gone, and the
559
+ * node tears the session down on close anyway. Ending one publication on a
560
+ * LIVE connection is `unpublish`, which does tell it.
561
+ */
562
+ private teardownMedia(): void {
563
+ for (const consumer of this.consumers.values()) consumer.close();
564
+ this.consumers.clear();
565
+ this.tracksByProducer.clear();
566
+
567
+ for (const publication of this.publications.values()) {
568
+ publication.handle.close();
569
+ publication.track.stop();
570
+ }
571
+ this.publications.clear();
572
+
573
+ this.sendTransport?.close();
574
+ this.recvTransport?.close();
575
+ this.sendTransport = undefined;
576
+ this.recvTransport = undefined;
577
+ }
578
+
579
+ private emit(): void {
580
+ // Rebuilt HERE, once per change, rather than per read. Every mutation
581
+ // already funnels through this, so nothing can change without the snapshot
582
+ // following it.
583
+ this.trackSnapshot = [...this.tracksByProducer.values()];
584
+ this.publicationSnapshot = [...this.publications.values()];
585
+
586
+ for (const listener of this.listeners) {
587
+ try {
588
+ listener();
589
+ } catch {
590
+ // One subscriber throwing must not stop the others from being told.
591
+ }
592
+ }
593
+ }
594
+ }
595
+
596
+ export async function connectToRoom(options: MediaRoomOptions): Promise<MediaRoom> {
597
+ const room = new MediaRoom(options);
598
+ await room.connect();
599
+ return room;
600
+ }