@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.
- package/README.md +20 -4
- package/build/core/reconnect.d.ts +8 -2
- package/build/core/reconnect.d.ts.map +1 -1
- package/build/core/reconnect.js +8 -2
- package/build/core/reconnect.js.map +1 -1
- package/build/core/signal.d.ts +10 -1
- package/build/core/signal.d.ts.map +1 -1
- package/build/core/signal.js +39 -10
- package/build/core/signal.js.map +1 -1
- package/build/core/state.d.ts +3 -0
- package/build/core/state.d.ts.map +1 -1
- package/build/core/state.js +21 -4
- package/build/core/state.js.map +1 -1
- package/build/react/index.d.ts +38 -7
- package/build/react/index.d.ts.map +1 -1
- package/build/react/index.js +86 -7
- package/build/react/index.js.map +1 -1
- package/build/room/browserDevice.d.ts.map +1 -1
- package/build/room/browserDevice.js +13 -4
- package/build/room/browserDevice.js.map +1 -1
- package/build/room/device.d.ts +13 -0
- package/build/room/device.d.ts.map +1 -1
- package/build/room/room.d.ts +129 -5
- package/build/room/room.d.ts.map +1 -1
- package/build/room/room.js +330 -58
- package/build/room/room.js.map +1 -1
- package/package.json +3 -1
- package/src/core/_tests/signal.spec.ts +115 -22
- package/src/core/_tests/state.spec.ts +68 -2
- package/src/core/reconnect.ts +8 -2
- package/src/core/signal.ts +48 -12
- package/src/core/state.ts +31 -5
- package/src/react/_tests/hooks.spec.tsx +91 -3
- package/src/react/index.tsx +117 -20
- package/src/room/_tests/room.spec.ts +747 -12
- package/src/room/browserDevice.ts +14 -4
- package/src/room/device.ts +13 -0
- package/src/room/room.ts +416 -61
package/src/react/index.tsx
CHANGED
|
@@ -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
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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 =
|
|
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,
|