@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
@@ -17,6 +17,7 @@ import {
17
17
  MediaRoom,
18
18
  type ConnectionState,
19
19
  type LocalPublication,
20
+ type LocalPublicationSource,
20
21
  type MediaRoomOptions,
21
22
  type MediaTrack,
22
23
  } from "../room/room";
@@ -140,10 +141,7 @@ function useRoomSnapshot<T>(select: (room: MediaRoom) => T, fallback: T): T {
140
141
  // module-level selectors below.
141
142
  const { room } = useRoomContext();
142
143
 
143
- const subscribe = useCallback(
144
- (onChange: () => void) => (room ? room.onChange(onChange) : () => undefined),
145
- [room],
146
- );
144
+ const subscribe = useCallback((onChange: () => void) => (room ? room.onChange(onChange) : () => undefined), [room]);
147
145
 
148
146
  // The selector must return a STABLE reference for unchanged state, or
149
147
  // `useSyncExternalStore` re-renders forever. `RoomState` is produced by a
@@ -250,11 +248,21 @@ export function useRecording(): boolean {
250
248
  * renders one only after the track arrives, and every remote `<video>` is
251
249
  * `muted`, so a call had picture and no sound in either direction.
252
250
  *
253
- * Depending on `track` is what makes React re-run it: the identity changes when
254
- * the track appears, React calls the old callback with `null` and the new one
255
- * with the element. `MediaTrack` objects are created once per consumer and the
256
- * room's snapshot array is rebuilt only when a track changes, so this settles
257
- * rather than churning.
251
+ * ## Why the callback is STABLE, and an effect does the re-attaching
252
+ *
253
+ * The callback used to be memoised on `[track]`, so a new track identity made
254
+ * React run the OLD callback with `null` (which pauses the element and clears
255
+ * `srcObject`) and then the new one with the element. That is a black frame and
256
+ * a stall on the viewer's screen, and it fires for reasons that have nothing to
257
+ * do with the picture: the room's snapshot is rebuilt on every change, and a
258
+ * consumer that is closed and re-created (`syncSubscriptions` following the
259
+ * active set) produces a fresh `MediaTrack` for the SAME underlying
260
+ * `MediaStreamTrack`.
261
+ *
262
+ * So the ref callback is now stable - it mounts and unmounts, nothing else -
263
+ * and an effect assigns `srcObject` whenever the underlying track actually
264
+ * changes. `applied` is what makes that idempotent: assigning the same track
265
+ * again is not free, it restarts the element's load and shows as a flicker.
258
266
  */
259
267
  export function useRemoteTrack(producerId: string): {
260
268
  track: MediaTrack | undefined;
@@ -262,28 +270,89 @@ export function useRemoteTrack(producerId: string): {
262
270
  } {
263
271
  const tracks = useRoomSnapshot(selectTracks, EMPTY_TRACKS);
264
272
  const track = tracks.find((t) => t.producerId === producerId);
273
+ const mediaTrack = track?.track ?? null;
274
+
275
+ // The element this callback last attached to, so DETACHING can undo it.
276
+ const attached = useRef<HTMLMediaElement | null>(null);
277
+ // The `MediaStreamTrack` currently on that element. Compared by identity, so
278
+ // the same track arriving inside a new `MediaTrack` record is a no-op.
279
+ const applied = useRef<MediaStreamTrack | null>(null);
280
+ // Read by the ref callback, which must not depend on the track (see above).
281
+ const latest = useRef<MediaStreamTrack | null>(mediaTrack);
282
+ latest.current = mediaTrack;
283
+
284
+ const attach = useCallback((element: HTMLMediaElement | null) => {
285
+ // React calls a ref callback with null when the element goes away, and
286
+ // ignoring that leaves a detached element holding the stream and still
287
+ // PLAYING it. Nothing on screen shows the orphan; you only hear it,
288
+ // as the same audio a second time, slightly out of step with itself.
289
+ // A remount is enough to cause it, and StrictMode remounts everything
290
+ // once in development, so this reproduced on every dev page load.
291
+ if (!element) {
292
+ const previous = attached.current;
293
+ attached.current = null;
294
+ applied.current = null;
295
+ if (previous) {
296
+ previous.pause();
297
+ previous.srcObject = null;
298
+ }
299
+ return;
300
+ }
301
+ // A newly mounted element is always written to, even with no track yet: an
302
+ // explicit `null` is what says "this element holds nothing", and skipping
303
+ // it would leave whatever a reused element was carrying.
304
+ const previousElement = attached.current;
305
+ attached.current = element;
306
+ if (previousElement === element && applied.current === latest.current) return;
307
+ applied.current = latest.current;
308
+ element.srcObject = latest.current ? new MediaStream([latest.current]) : null;
309
+ }, []);
265
310
 
266
- const attach = useCallback(
267
- (element: HTMLMediaElement | null) => {
268
- if (!element) return;
269
- element.srcObject = track ? new MediaStream([track.track]) : null;
270
- },
271
- [track],
272
- );
311
+ // The track almost never exists when the element mounts: the consume round
312
+ // trip lands milliseconds later. This is what puts it on the element then,
313
+ // and what swaps it if the consumer is ever rebuilt.
314
+ useEffect(() => {
315
+ const element = attached.current;
316
+ if (!element) return;
317
+ if (applied.current === mediaTrack) return;
318
+ applied.current = mediaTrack;
319
+ element.srcObject = mediaTrack ? new MediaStream([mediaTrack]) : null;
320
+ }, [mediaTrack]);
273
321
 
274
322
  return { track, attach };
275
323
  }
276
324
 
277
- export type LocalSource = "camera" | "microphone" | "screen";
325
+ export type LocalSource = LocalPublicationSource;
278
326
 
279
327
  export type LocalMediaControls = {
280
328
  publishCamera: () => Promise<void>;
281
329
  publishMicrophone: () => Promise<void>;
282
330
  publishScreen: () => Promise<void>;
283
331
  unpublish: (source: LocalSource) => Promise<void>;
332
+ /**
333
+ * Mute or unmute a source that is already published, keeping its capture.
334
+ *
335
+ * What a Mute button and a camera-off button call. NOT `unpublish` and a
336
+ * later re-publish: that path starts with `getUserMedia` again, and Safari
337
+ * puts a permission prompt in front of every call to it, so a toggle built
338
+ * that way asked permission on every unmute. No-op for a source that is not
339
+ * published.
340
+ */
341
+ setPaused: (source: LocalSource, paused: boolean) => Promise<void>;
342
+ /** Published, whether or not currently paused. */
284
343
  isCameraEnabled: boolean;
285
344
  isMicrophoneEnabled: boolean;
286
345
  screenSharing: boolean;
346
+ /** Published AND paused at the source: muted, camera off. */
347
+ paused: Record<LocalSource, boolean>;
348
+ /**
349
+ * What the person was sending when the connection dropped, and is not now.
350
+ *
351
+ * A drop stops every local capture (the camera light goes out, on purpose)
352
+ * and the SDK does not turn it back on by itself. This is what a screen tells
353
+ * the person so they press the button. Cleared per source on republish.
354
+ */
355
+ lostSources: readonly LocalSource[];
287
356
  /**
288
357
  * A capture or a publish is in flight for this source.
289
358
  *
@@ -297,6 +366,8 @@ export type LocalMediaControls = {
297
366
  };
298
367
 
299
368
  const NOTHING_PENDING: Record<LocalSource, boolean> = { camera: false, microphone: false, screen: false };
369
+ const NOTHING_PAUSED: Record<LocalSource, boolean> = NOTHING_PENDING;
370
+ const selectLostSources = (room: MediaRoom): readonly LocalSource[] => room.lostPublicationSources;
300
371
 
301
372
  /**
302
373
  * Capture and publish.
@@ -310,8 +381,19 @@ export function useLocalMedia(): LocalMediaControls {
310
381
  const [error, setError] = useState<Error | undefined>(undefined);
311
382
  const [pending, setPending] = useState<Record<LocalSource, boolean>>(NOTHING_PENDING);
312
383
  const publications = useRoomSnapshot(selectPublications, EMPTY_PUBLICATIONS);
384
+ const lostSources = useRoomSnapshot(selectLostSources, EMPTY_SOURCES);
313
385
 
314
386
  const has = (source: string) => publications.some((p) => p.source === source);
387
+ // Rebuilt only when the publications snapshot moves, which is the only time
388
+ // a pause flag can have changed.
389
+ const paused = useMemo<Record<LocalSource, boolean>>(() => {
390
+ const isPaused = (source: LocalSource) => {
391
+ const mine = publications.filter((p) => p.source === source);
392
+ return mine.length > 0 && mine.every((p) => p.paused);
393
+ };
394
+ const next = { camera: isPaused("camera"), microphone: isPaused("microphone"), screen: isPaused("screen") };
395
+ return next.camera || next.microphone || next.screen ? next : NOTHING_PAUSED;
396
+ }, [publications]);
315
397
 
316
398
  /**
317
399
  * One operation per source at a time, held in a REF.
@@ -372,9 +454,7 @@ export function useLocalMedia(): LocalMediaControls {
372
454
  const stream =
373
455
  source === "screen"
374
456
  ? await navigator.mediaDevices.getDisplayMedia({ video: true })
375
- : await navigator.mediaDevices.getUserMedia(
376
- source === "camera" ? { video: true } : { audio: true },
377
- );
457
+ : await navigator.mediaDevices.getUserMedia(source === "camera" ? { video: true } : { audio: true });
378
458
  captured = stream.getTracks();
379
459
  const track = captured[0];
380
460
  if (!track) throw new Error(`no ${source} track was captured`);
@@ -408,14 +488,30 @@ export function useLocalMedia(): LocalMediaControls {
408
488
  [room, exclusively],
409
489
  );
410
490
 
491
+ // Under the same lock, for the same reason: a mute pressed while the publish
492
+ // it is muting is still capturing would find nothing to pause.
493
+ const setPaused = useCallback(
494
+ async (source: LocalSource, value: boolean) =>
495
+ exclusively(source, async () => {
496
+ if (!room) return;
497
+ for (const publication of room.localPublications.filter((p) => p.source === source)) {
498
+ await room.setPaused(publication.producerId, value);
499
+ }
500
+ }),
501
+ [room, exclusively],
502
+ );
503
+
411
504
  return {
412
505
  publishCamera: useCallback(() => publish("camera"), [publish]),
413
506
  publishMicrophone: useCallback(() => publish("microphone"), [publish]),
414
507
  publishScreen: useCallback(() => publish("screen"), [publish]),
415
508
  unpublish,
509
+ setPaused,
416
510
  isCameraEnabled: has("camera"),
417
511
  isMicrophoneEnabled: has("microphone"),
418
512
  screenSharing: has("screen"),
513
+ paused,
514
+ lostSources,
419
515
  pending,
420
516
  error,
421
517
  };
@@ -425,6 +521,7 @@ export function useLocalMedia(): LocalMediaControls {
425
521
  * `useSyncExternalStore` a new array on every read and loop it. */
426
522
  const EMPTY_TRACKS: readonly MediaTrack[] = [];
427
523
  const EMPTY_PUBLICATIONS: MediaRoom["localPublications"] = [];
524
+ const EMPTY_SOURCES: readonly LocalSource[] = [];
428
525
  const EMPTY_STATE: RoomState = {
429
526
  phase: "idle",
430
527
  identity: null,