@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,439 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ useSyncExternalStore,
10
+ type ReactNode,
11
+ } from "react";
12
+
13
+ import type { MediaGrants, Peer } from "@tribe-nest/media-protocol";
14
+
15
+ import { createBrowserDevice } from "../room/browserDevice";
16
+ import {
17
+ MediaRoom,
18
+ type ConnectionState,
19
+ type LocalPublication,
20
+ type MediaRoomOptions,
21
+ type MediaTrack,
22
+ } from "../room/room";
23
+ import { visibleProducers } from "../core";
24
+ import type { DisconnectCause, ProducerEntry, RoomState } from "../core";
25
+
26
+ /**
27
+ * The React surface.
28
+ *
29
+ * ## Why `useSyncExternalStore` rather than state in the provider
30
+ *
31
+ * The room is an external mutable object with its own event loop, which is
32
+ * precisely the case React 18 added that hook for. The alternative - mirroring
33
+ * room state into `useState` on every event - tears: a component reading
34
+ * `room.tracks` directly during a render sees a newer value than the mirrored
35
+ * copy it was rendered from, and the bug shows up as a tile that is one event
36
+ * behind exactly when the room is busiest.
37
+ *
38
+ * ## Why every hook takes NO arguments
39
+ *
40
+ * `useMediaRoom()` reads the provider. A hook that took credentials would let
41
+ * two components mount two rooms against one call, and the second would take
42
+ * the identity from the first and be refused with `duplicate_identity`. One
43
+ * room per provider, and the provider owns its lifetime.
44
+ */
45
+
46
+ type MediaRoomContextValue = {
47
+ room: MediaRoom | null;
48
+ connectionState: ConnectionState;
49
+ error: DisconnectCause | undefined;
50
+ /**
51
+ * Is the SDK actually coming back on its own?
52
+ *
53
+ * `connectionState` is `reconnecting` both while an attempt is booked and
54
+ * after the policy has given up, so a screen reading only that spins for ever
55
+ * over a call that is finished. This is the half that says which, and it is
56
+ * what a UI turns "wait" against "press something" on.
57
+ */
58
+ recovering: boolean;
59
+ retry: () => void;
60
+ };
61
+
62
+ const MediaRoomContext = createContext<MediaRoomContextValue | null>(null);
63
+
64
+ export type MediaRoomProviderProps = Omit<MediaRoomOptions, "device"> &
65
+ Partial<Pick<MediaRoomOptions, "device">> & {
66
+ children: ReactNode;
67
+ /** Rendered while the first connection is in flight. */
68
+ fallback?: ReactNode;
69
+ };
70
+
71
+ export function MediaRoomProvider({ children, fallback, device, ...options }: MediaRoomProviderProps): ReactNode {
72
+ const [room, setRoom] = useState<MediaRoom | null>(null);
73
+ const [connectionState, setConnectionState] = useState<ConnectionState>("idle");
74
+ const [error, setError] = useState<DisconnectCause | undefined>(undefined);
75
+ const [recovering, setRecovering] = useState(false);
76
+ const [attempt, setAttempt] = useState(0);
77
+
78
+ // Held in a ref so changing a callback identity does not tear down a live
79
+ // call. A parent that re-renders every second must not reconnect the room
80
+ // every second, and inline arrow props make that the DEFAULT mistake.
81
+ const optionsRef = useRef(options);
82
+ optionsRef.current = options;
83
+
84
+ useEffect(() => {
85
+ let cancelled = false;
86
+ const instance = new MediaRoom({
87
+ ...optionsRef.current,
88
+ getCredentials: () => optionsRef.current.getCredentials(),
89
+ device: device ?? createBrowserDevice,
90
+ });
91
+
92
+ const unsubscribe = instance.onChange(() => {
93
+ if (cancelled) return;
94
+ setConnectionState(instance.connectionState);
95
+ setError(instance.error);
96
+ setRecovering(instance.isRecovering);
97
+ });
98
+
99
+ setRoom(instance);
100
+ instance.connect().catch(() => {
101
+ // The cause is on the room; `error` is already mirrored by the listener
102
+ // above. Swallowed here so a failed connect does not surface as an
103
+ // unhandled rejection in the host application's console.
104
+ if (!cancelled) setConnectionState(instance.connectionState);
105
+ });
106
+
107
+ return () => {
108
+ cancelled = true;
109
+ unsubscribe();
110
+ void instance.close().catch(() => undefined);
111
+ };
112
+ // `attempt` is what `retry()` bumps. `device` is read once per room.
113
+ }, [attempt, device]);
114
+
115
+ const value = useMemo<MediaRoomContextValue>(
116
+ () => ({ room, connectionState, error, recovering, retry: () => setAttempt((n) => n + 1) }),
117
+ [room, connectionState, error, recovering],
118
+ );
119
+
120
+ return (
121
+ <MediaRoomContext.Provider value={value}>
122
+ {connectionState === "connecting" && fallback ? fallback : children}
123
+ </MediaRoomContext.Provider>
124
+ );
125
+ }
126
+
127
+ function useRoomContext(): MediaRoomContextValue {
128
+ const value = useContext(MediaRoomContext);
129
+ if (!value) throw new Error("useMediaRoom must be used inside a <MediaRoomProvider>");
130
+ return value;
131
+ }
132
+
133
+ export function useMediaRoom(): MediaRoomContextValue {
134
+ return useRoomContext();
135
+ }
136
+
137
+ /** Subscribe to the room's own change notifications. */
138
+ function useRoomSnapshot<T>(select: (room: MediaRoom) => T, fallback: T): T {
139
+ // `select` and `fallback` must be stable across renders - see the note on the
140
+ // module-level selectors below.
141
+ const { room } = useRoomContext();
142
+
143
+ const subscribe = useCallback(
144
+ (onChange: () => void) => (room ? room.onChange(onChange) : () => undefined),
145
+ [room],
146
+ );
147
+
148
+ // The selector must return a STABLE reference for unchanged state, or
149
+ // `useSyncExternalStore` re-renders forever. `RoomState` is produced by a
150
+ // reducer that returns the same object when nothing changed, which is what
151
+ // makes reading it directly safe.
152
+ const getSnapshot = useCallback(() => (room ? select(room) : fallback), [room, select, fallback]);
153
+
154
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
155
+ }
156
+
157
+ /**
158
+ * Module-level selectors, and that is not a style preference.
159
+ *
160
+ * `useRoomSnapshot` memoises `getSnapshot` on the selector's identity. An
161
+ * inline arrow is a new function every render, so the memo never holds, and
162
+ * `useSyncExternalStore` re-subscribes and re-reads on every pass.
163
+ */
164
+ const selectState = (room: MediaRoom): RoomState => room.state;
165
+ const selectTracks = (room: MediaRoom): readonly MediaTrack[] => room.tracks;
166
+ const selectPublications = (room: MediaRoom): MediaRoom["localPublications"] => room.localPublications;
167
+
168
+ export function useRoomState(): RoomState {
169
+ return useRoomSnapshot(selectState, EMPTY_STATE);
170
+ }
171
+
172
+ export function useParticipants(): readonly Peer[] {
173
+ return useRoomState().peers;
174
+ }
175
+
176
+ export function useActiveSpeakers(): readonly string[] {
177
+ return useRoomState().activeSpeakers;
178
+ }
179
+
180
+ /**
181
+ * The producers this participant may actually receive. What a UI renders.
182
+ *
183
+ * NOT `useRoomState().producers`, and the difference is the subscribe barrier.
184
+ * The reducer keeps every producer the node has ever announced, deliberately,
185
+ * so that a rule which narrows mid-call and later widens is reversible - there
186
+ * is no `producerAppeared` replay to bring back a producer it had deleted. The
187
+ * price of keeping them is that the raw list contains people this seat has been
188
+ * barred from, and on a sealed consultation room the mere PRESENCE of the other
189
+ * party is most of what the barrier was hiding. A UI mapping over the raw list
190
+ * therefore draws a tile carrying their identity and mounts an `<audio>` for
191
+ * them, and the node's own filtering does not save it: `revokeNewlyForbidden`
192
+ * only closes producers this session had a live consumer for, so one that was
193
+ * announced and never consumed stays in the list for the rest of the call.
194
+ *
195
+ * Memoised on the state OBJECT rather than used as a store selector: the filter
196
+ * builds a new array per call, and `useSyncExternalStore` compares snapshots
197
+ * with `Object.is`, so reading it through `useRoomSnapshot` would re-render for
198
+ * ever. The reducer returns the same state object when nothing changed, which
199
+ * is what makes this settle.
200
+ */
201
+ export function useVisibleProducers(): readonly ProducerEntry[] {
202
+ const state = useRoomState();
203
+ return useMemo(() => visibleProducers(state), [state]);
204
+ }
205
+
206
+ /**
207
+ * What THIS participant is publishing right now, with the live tracks.
208
+ *
209
+ * `useLocalMedia()` answers "is the camera on" and that is enough for a button,
210
+ * but a local preview needs the `MediaStreamTrack` itself, and reading
211
+ * `room.localPublications` from `useMediaRoom()` is not reactive: the preview
212
+ * would stay black until something else happened to re-render it.
213
+ *
214
+ * The snapshot is the room's cached array, rebuilt only when a publication
215
+ * changed, which is what keeps this out of the `getSnapshot` loop.
216
+ */
217
+ export function useLocalPublications(): readonly LocalPublication[] {
218
+ return useRoomSnapshot(selectPublications, EMPTY_PUBLICATIONS);
219
+ }
220
+
221
+ /** What the NODE says this token may do. For rendering, never for a decision:
222
+ * the node enforces independently and is the only side that counts. */
223
+ export function useGrants(): MediaGrants | undefined {
224
+ const { room } = useRoomContext();
225
+ return room?.grants;
226
+ }
227
+
228
+ /** True while any egress participant is present. A consent signal. */
229
+ export function useRecording(): boolean {
230
+ return useRoomState().recording;
231
+ }
232
+
233
+ /**
234
+ * One remote track, and a ref callback that attaches it.
235
+ *
236
+ * `attach` exists because `srcObject` cannot be set through JSX: it takes a
237
+ * `MediaStream` object, not a string, so every consumer of this hook would
238
+ * otherwise write the same `useEffect`.
239
+ *
240
+ * ## Why it is keyed on the TRACK
241
+ *
242
+ * A ref callback runs when the element mounts and again only when the callback
243
+ * IDENTITY changes. The track almost never exists at mount: a producer reaches
244
+ * `state.producers` the moment the node announces it, and the consume round
245
+ * trip that produces the track finishes some milliseconds later. So a callback
246
+ * memoised on `[room, producerId]` - both stable - ran exactly once, against no
247
+ * track, set `srcObject` to `null` and was never called again. An element that
248
+ * is only ever mounted once therefore stayed silent for the whole call, which
249
+ * is what happened to remote AUDIO: a `<video>` escapes it because a tile
250
+ * renders one only after the track arrives, and every remote `<video>` is
251
+ * `muted`, so a call had picture and no sound in either direction.
252
+ *
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.
258
+ */
259
+ export function useRemoteTrack(producerId: string): {
260
+ track: MediaTrack | undefined;
261
+ attach: (element: HTMLMediaElement | null) => void;
262
+ } {
263
+ const tracks = useRoomSnapshot(selectTracks, EMPTY_TRACKS);
264
+ const track = tracks.find((t) => t.producerId === producerId);
265
+
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
+ );
273
+
274
+ return { track, attach };
275
+ }
276
+
277
+ export type LocalSource = "camera" | "microphone" | "screen";
278
+
279
+ export type LocalMediaControls = {
280
+ publishCamera: () => Promise<void>;
281
+ publishMicrophone: () => Promise<void>;
282
+ publishScreen: () => Promise<void>;
283
+ unpublish: (source: LocalSource) => Promise<void>;
284
+ isCameraEnabled: boolean;
285
+ isMicrophoneEnabled: boolean;
286
+ screenSharing: boolean;
287
+ /**
288
+ * A capture or a publish is in flight for this source.
289
+ *
290
+ * Exposed so a control can be disabled rather than silently ignoring the
291
+ * second press: a permission prompt takes seconds, during which every flag
292
+ * above still reads `false`, and a button that looks unchanged is a button
293
+ * people press again.
294
+ */
295
+ pending: Record<LocalSource, boolean>;
296
+ error: Error | undefined;
297
+ };
298
+
299
+ const NOTHING_PENDING: Record<LocalSource, boolean> = { camera: false, microphone: false, screen: false };
300
+
301
+ /**
302
+ * Capture and publish.
303
+ *
304
+ * `getUserMedia` lives here rather than in the room because permission is a
305
+ * BROWSER concern with a UI consequence - a denied prompt is something a person
306
+ * has to be told about - and the room has no way to say so.
307
+ */
308
+ export function useLocalMedia(): LocalMediaControls {
309
+ const { room } = useRoomContext();
310
+ const [error, setError] = useState<Error | undefined>(undefined);
311
+ const [pending, setPending] = useState<Record<LocalSource, boolean>>(NOTHING_PENDING);
312
+ const publications = useRoomSnapshot(selectPublications, EMPTY_PUBLICATIONS);
313
+
314
+ const has = (source: string) => publications.some((p) => p.source === source);
315
+
316
+ /**
317
+ * One operation per source at a time, held in a REF.
318
+ *
319
+ * Every control above this is a read-then-write: it reads
320
+ * `isMicrophoneEnabled` and calls publish or unpublish accordingly. That flag
321
+ * is derived from `publications`, which does not move until the publish has
322
+ * resolved - and a publish begins with a `getUserMedia` permission prompt
323
+ * that can sit on screen for seconds. So the ordinary human response to a
324
+ * button that appears to have done nothing (press it again) put two captures
325
+ * through two `room.publish` calls and produced TWO audio producers from one
326
+ * identity: everyone else mounts two `<audio>` elements and hears the person
327
+ * doubled, and unpublishing both later does not undo the seconds of garbled
328
+ * audio. The screen-share shape is worse, opening a second picker.
329
+ *
330
+ * A ref rather than the `pending` state because the check has to be true
331
+ * SYNCHRONOUSLY: two clicks in one tick are both handled before React has
332
+ * re-rendered anything, so a state flag would be `false` in both of them.
333
+ * `pending` exists to grey the button out, not to guard it.
334
+ */
335
+ const inFlight = useRef<Set<LocalSource>>(new Set());
336
+
337
+ const exclusively = useCallback(async (source: LocalSource, work: () => Promise<void>): Promise<void> => {
338
+ if (inFlight.current.has(source)) return;
339
+ inFlight.current.add(source);
340
+ setPending((current) => ({ ...current, [source]: true }));
341
+ try {
342
+ await work();
343
+ } finally {
344
+ inFlight.current.delete(source);
345
+ setPending((current) => ({ ...current, [source]: false }));
346
+ }
347
+ }, []);
348
+
349
+ /**
350
+ * Captured tracks are OWNED here until the room takes one.
351
+ *
352
+ * `getUserMedia` turns the camera light on. If the publish that follows
353
+ * fails - the node refusing the grant, the transport failing, the browser
354
+ * unable to produce the kind - the capture is still live, and nothing in the
355
+ * UI can stop it: no publication was recorded, so the button still reads
356
+ * "Start camera" and `unpublish("camera")` finds nothing to close. The light
357
+ * stays on until the provider unmounts, and every retry opens another one.
358
+ *
359
+ * So everything captured is stopped unless it was handed over, including the
360
+ * extra tracks a capture can carry (Chrome adds a system-audio track to a
361
+ * screen share when the person ticks "share audio", and only one track is
362
+ * published).
363
+ */
364
+ const publish = useCallback(
365
+ async (source: LocalSource) =>
366
+ exclusively(source, async () => {
367
+ if (!room) return;
368
+ setError(undefined);
369
+ let captured: MediaStreamTrack[] = [];
370
+ let published: MediaStreamTrack | undefined;
371
+ try {
372
+ const stream =
373
+ source === "screen"
374
+ ? await navigator.mediaDevices.getDisplayMedia({ video: true })
375
+ : await navigator.mediaDevices.getUserMedia(
376
+ source === "camera" ? { video: true } : { audio: true },
377
+ );
378
+ captured = stream.getTracks();
379
+ const track = captured[0];
380
+ if (!track) throw new Error(`no ${source} track was captured`);
381
+ await room.publish(track, source);
382
+ published = track;
383
+ } catch (err) {
384
+ // Surfaced rather than thrown: a denied permission prompt is an
385
+ // ordinary outcome that the UI has to explain, not an exception.
386
+ setError(err instanceof Error ? err : new Error(String(err)));
387
+ } finally {
388
+ for (const track of captured) {
389
+ if (track !== published) track.stop();
390
+ }
391
+ }
392
+ }),
393
+ [room, exclusively],
394
+ );
395
+
396
+ // Under the same lock as `publish`, because the pair is what a toggle
397
+ // alternates between: a stop pressed while a start is still capturing would
398
+ // otherwise find no publication to close and leave the capture that lands a
399
+ // moment later running with the button reading "Mute".
400
+ const unpublish = useCallback(
401
+ async (source: LocalSource) =>
402
+ exclusively(source, async () => {
403
+ if (!room) return;
404
+ for (const publication of room.localPublications.filter((p) => p.source === source)) {
405
+ await room.unpublish(publication.producerId);
406
+ }
407
+ }),
408
+ [room, exclusively],
409
+ );
410
+
411
+ return {
412
+ publishCamera: useCallback(() => publish("camera"), [publish]),
413
+ publishMicrophone: useCallback(() => publish("microphone"), [publish]),
414
+ publishScreen: useCallback(() => publish("screen"), [publish]),
415
+ unpublish,
416
+ isCameraEnabled: has("camera"),
417
+ isMicrophoneEnabled: has("microphone"),
418
+ screenSharing: has("screen"),
419
+ pending,
420
+ error,
421
+ };
422
+ }
423
+
424
+ /** Stable empty values, so an unmounted or pre-connect room does not hand
425
+ * `useSyncExternalStore` a new array on every read and loop it. */
426
+ const EMPTY_TRACKS: readonly MediaTrack[] = [];
427
+ const EMPTY_PUBLICATIONS: MediaRoom["localPublications"] = [];
428
+ const EMPTY_STATE: RoomState = {
429
+ phase: "idle",
430
+ identity: null,
431
+ room: null,
432
+ peers: [],
433
+ producers: [],
434
+ activeSpeakers: [],
435
+ recording: false,
436
+ subscribeRule: null,
437
+ draining: null,
438
+ closedReason: null,
439
+ };