@tribe-nest/media-client 0.2.1 → 0.4.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 (40) hide show
  1. package/build/core/index.d.ts +1 -1
  2. package/build/core/index.d.ts.map +1 -1
  3. package/build/core/index.js.map +1 -1
  4. package/build/core/reconnect.d.ts +29 -0
  5. package/build/core/reconnect.d.ts.map +1 -1
  6. package/build/core/reconnect.js +49 -9
  7. package/build/core/reconnect.js.map +1 -1
  8. package/build/core/signal.d.ts +9 -0
  9. package/build/core/signal.d.ts.map +1 -1
  10. package/build/core/signal.js +18 -2
  11. package/build/core/signal.js.map +1 -1
  12. package/build/core/state.d.ts +28 -0
  13. package/build/core/state.d.ts.map +1 -1
  14. package/build/core/state.js +106 -7
  15. package/build/core/state.js.map +1 -1
  16. package/build/index.d.ts +1 -1
  17. package/build/index.d.ts.map +1 -1
  18. package/build/index.js.map +1 -1
  19. package/build/react/index.d.ts +63 -11
  20. package/build/react/index.d.ts.map +1 -1
  21. package/build/react/index.js +229 -5
  22. package/build/react/index.js.map +1 -1
  23. package/build/room/device.d.ts +6 -0
  24. package/build/room/device.d.ts.map +1 -1
  25. package/build/room/room.d.ts +235 -1
  26. package/build/room/room.d.ts.map +1 -1
  27. package/build/room/room.js +627 -24
  28. package/build/room/room.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/core/_tests/reconnect.spec.ts +92 -0
  31. package/src/core/_tests/state.spec.ts +92 -0
  32. package/src/core/index.ts +2 -0
  33. package/src/core/reconnect.ts +76 -9
  34. package/src/core/signal.ts +16 -2
  35. package/src/core/state.ts +153 -10
  36. package/src/index.ts +2 -0
  37. package/src/react/index.tsx +261 -7
  38. package/src/room/_tests/room.spec.ts +720 -3
  39. package/src/room/device.ts +6 -0
  40. package/src/room/room.ts +733 -25
package/src/core/state.ts CHANGED
@@ -29,6 +29,21 @@ export type ProducerEntry = {
29
29
  source?: string;
30
30
  };
31
31
 
32
+ /** One entry of the node's speaker ranking, loudest first. */
33
+ export type SpeakerInfo = {
34
+ identity: string;
35
+ producerId: string;
36
+ /** Quantized by the node to 5 dB steps, so most ticks are value-identical. */
37
+ volumeDb: number;
38
+ };
39
+
40
+ /** A hand currently up, oldest first: the order is the queue. */
41
+ export type RaisedHand = {
42
+ identity: string;
43
+ /** Node clock, for ordering only. */
44
+ raisedAt: number;
45
+ };
46
+
32
47
  export type RoomState = {
33
48
  phase: "idle" | "joined" | "closed";
34
49
  /** Our own identity, as the node reported it. */
@@ -44,6 +59,21 @@ export type RoomState = {
44
59
  producers: readonly ProducerEntry[];
45
60
  /** The set the NODE decided we should consume, in its order. */
46
61
  activeSpeakers: readonly string[];
62
+ /**
63
+ * The same set split by kind, because the two halves are treated
64
+ * differently: audio is never narrowed by a viewport, video may be. Derived
65
+ * from producer kinds when an older node sends no split, with unknown ids
66
+ * kept on the AUDIO side so nothing a node told us to take is ever narrowed
67
+ * away by our own ignorance.
68
+ */
69
+ activeAudio: readonly string[];
70
+ activeVideo: readonly string[];
71
+ /** The node's ranking with levels, loudest first. Meters and layouts. */
72
+ speakers: readonly SpeakerInfo[];
73
+ /** Whether a consume outside the set would actually be refused. */
74
+ activeSetEnforced: boolean;
75
+ /** Hands currently up, oldest first. Replaced wholesale by `joined`. */
76
+ raisedHands: readonly RaisedHand[];
47
77
  /** True while any `kind: "egress"` participant is present. A consent flag. */
48
78
  recording: boolean;
49
79
  /** The latest rule pushed for us, if one ever was. */
@@ -60,6 +90,11 @@ export const initialRoomState: RoomState = {
60
90
  peers: [],
61
91
  producers: [],
62
92
  activeSpeakers: [],
93
+ activeAudio: [],
94
+ activeVideo: [],
95
+ speakers: [],
96
+ activeSetEnforced: false,
97
+ raisedHands: [],
63
98
  recording: false,
64
99
  subscribeRule: null,
65
100
  draining: null,
@@ -94,7 +129,13 @@ export function reduceRoomState(state: RoomState, frame: EventFrame): RoomState
94
129
  room: frame.room,
95
130
  peers: frame.peers,
96
131
  producers,
97
- activeSpeakers: pruneSpeakers(state.activeSpeakers, producers),
132
+ ...pruneActiveSets(state, producers),
133
+ // Carried like the set itself: the flag describes the set, and the
134
+ // room clears both on disconnect.
135
+ activeSetEnforced: state.activeSetEnforced,
136
+ // Wholesale, like everything else in the snapshot: a hand lowered
137
+ // while we were away must not survive the reconnect.
138
+ raisedHands: sortHands(frame.raisedHands ?? []),
98
139
  recording: frame.recording,
99
140
  subscribeRule: null,
100
141
  draining: null,
@@ -118,11 +159,15 @@ export function reduceRoomState(state: RoomState, frame: EventFrame): RoomState
118
159
  // guaranteed when a peer's socket simply dies, and a producer left behind
119
160
  // is a tile that never clears.
120
161
  const producers = state.producers.filter((p) => p.identity !== frame.identity);
162
+ const hands = state.raisedHands.filter((h) => h.identity !== frame.identity);
121
163
  return {
122
164
  ...state,
123
165
  peers: state.peers.filter((p) => p.identity !== frame.identity),
124
166
  producers,
125
- activeSpeakers: pruneSpeakers(state.activeSpeakers, producers),
167
+ ...pruneActiveSets(state, producers),
168
+ // Their hand goes with them, whether or not the node's own
169
+ // `handChanged` for the departure ever arrives.
170
+ raisedHands: hands.length === state.raisedHands.length ? state.raisedHands : hands,
126
171
  };
127
172
  }
128
173
 
@@ -146,7 +191,7 @@ export function reduceRoomState(state: RoomState, frame: EventFrame): RoomState
146
191
  case "producerClosed": {
147
192
  if (!state.producers.some((p) => p.producerId === frame.producerId)) return state;
148
193
  const producers = state.producers.filter((p) => p.producerId !== frame.producerId);
149
- return { ...state, producers, activeSpeakers: pruneSpeakers(state.activeSpeakers, producers) };
194
+ return { ...state, producers, ...pruneActiveSets(state, producers) };
150
195
  }
151
196
 
152
197
  case "producerPaused": {
@@ -159,11 +204,44 @@ export function reduceRoomState(state: RoomState, frame: EventFrame): RoomState
159
204
  return changed ? { ...state, producers } : state;
160
205
  }
161
206
 
162
- case "activeSpeakers":
207
+ case "activeSpeakers": {
163
208
  // Stored as sent, INCLUDING ids we have not seen a `producerAppeared` for
164
209
  // yet. The node decides the active set; dropping an id because our view
165
210
  // is a frame behind would silently discard a stream we were told to take.
166
- return { ...state, activeSpeakers: frame.producerIds };
211
+ const split = splitActiveSet(frame, state.producers);
212
+ const speakers = frame.speakers ?? [];
213
+ const enforced = frame.enforced ?? false;
214
+ // Value-equal frames return the SAME reference. The node ticks every
215
+ // 400ms and quantizes levels for exactly this comparison; without it a
216
+ // grid of tiles re-renders on every tick of a steady conversation.
217
+ if (
218
+ sameStrings(state.activeSpeakers, frame.producerIds) &&
219
+ sameStrings(state.activeAudio, split.audio) &&
220
+ sameStrings(state.activeVideo, split.video) &&
221
+ state.activeSetEnforced === enforced &&
222
+ sameSpeakers(state.speakers, speakers)
223
+ ) {
224
+ return state;
225
+ }
226
+ return {
227
+ ...state,
228
+ activeSpeakers: frame.producerIds,
229
+ activeAudio: split.audio,
230
+ activeVideo: split.video,
231
+ speakers,
232
+ activeSetEnforced: enforced,
233
+ };
234
+ }
235
+
236
+ case "handChanged": {
237
+ const without = state.raisedHands.filter((h) => h.identity !== frame.identity);
238
+ if (frame.raisedAt === null) {
239
+ return without.length === state.raisedHands.length ? state : { ...state, raisedHands: without };
240
+ }
241
+ const existing = state.raisedHands.find((h) => h.identity === frame.identity);
242
+ if (existing && existing.raisedAt === frame.raisedAt) return state;
243
+ return { ...state, raisedHands: sortHands([...without, { identity: frame.identity, raisedAt: frame.raisedAt }]) };
244
+ }
167
245
 
168
246
  case "recordingChanged":
169
247
  return state.recording === frame.recording ? state : { ...state, recording: frame.recording };
@@ -174,7 +252,17 @@ export function reduceRoomState(state: RoomState, frame: EventFrame): RoomState
174
252
  case "roomClosed":
175
253
  // Producers and speakers are gone for good; peers are kept so a caller can
176
254
  // still say who was in the room on the "call ended" screen.
177
- return { ...state, phase: "closed", closedReason: frame.reason, producers: [], activeSpeakers: [] };
255
+ return {
256
+ ...state,
257
+ phase: "closed",
258
+ closedReason: frame.reason,
259
+ producers: [],
260
+ activeSpeakers: [],
261
+ activeAudio: [],
262
+ activeVideo: [],
263
+ speakers: [],
264
+ raisedHands: [],
265
+ };
178
266
 
179
267
  case "subscribeRuleChanged":
180
268
  /**
@@ -194,10 +282,7 @@ export function reduceRoomState(state: RoomState, frame: EventFrame): RoomState
194
282
  return {
195
283
  ...state,
196
284
  subscribeRule: frame.subscribe,
197
- activeSpeakers: pruneSpeakers(
198
- state.activeSpeakers,
199
- visibleUnder(frame.subscribe, state.identity, state.producers),
200
- ),
285
+ ...pruneActiveSets(state, visibleUnder(frame.subscribe, state.identity, state.producers)),
201
286
  };
202
287
 
203
288
  default:
@@ -255,6 +340,64 @@ function pruneSpeakers(speakers: readonly string[], producers: readonly Producer
255
340
  return kept.length === speakers.length ? speakers : kept;
256
341
  }
257
342
 
343
+ /**
344
+ * Every active-set field pruned against `producers` in one move, each array
345
+ * keeping its reference when nothing fell out. Used everywhere a producer can
346
+ * disappear, so no field can be pruned in one place and forgotten in another.
347
+ */
348
+ function pruneActiveSets(
349
+ state: Pick<RoomState, "activeSpeakers" | "activeAudio" | "activeVideo" | "speakers">,
350
+ producers: readonly ProducerEntry[],
351
+ ): Pick<RoomState, "activeSpeakers" | "activeAudio" | "activeVideo" | "speakers"> {
352
+ const live = new Set(producers.map((p) => p.producerId));
353
+ const keptSpeakers = state.speakers.filter((s) => live.has(s.producerId));
354
+ return {
355
+ activeSpeakers: pruneSpeakers(state.activeSpeakers, producers),
356
+ activeAudio: pruneSpeakers(state.activeAudio, producers),
357
+ activeVideo: pruneSpeakers(state.activeVideo, producers),
358
+ speakers: keptSpeakers.length === state.speakers.length ? state.speakers : keptSpeakers,
359
+ };
360
+ }
361
+
362
+ /**
363
+ * The audio/video split of an active-speakers frame.
364
+ *
365
+ * The frame's own split wins. An older node sends none; the fallback derives
366
+ * it from the producer kinds we know, and an id we know NOTHING about lands on
367
+ * the AUDIO side deliberately: audio is the side a viewport never narrows, so
368
+ * our own ignorance can delay a subscription but never suppress one.
369
+ */
370
+ function splitActiveSet(
371
+ frame: { producerIds: readonly string[]; audio?: readonly string[]; video?: readonly string[] },
372
+ producers: readonly ProducerEntry[],
373
+ ): { audio: readonly string[]; video: readonly string[] } {
374
+ if (frame.audio || frame.video) {
375
+ return { audio: frame.audio ?? [], video: frame.video ?? [] };
376
+ }
377
+ const kinds = new Map(producers.map((p) => [p.producerId, p.kind]));
378
+ return {
379
+ audio: frame.producerIds.filter((id) => kinds.get(id) !== "video"),
380
+ video: frame.producerIds.filter((id) => kinds.get(id) === "video"),
381
+ };
382
+ }
383
+
384
+ function sameStrings(a: readonly string[], b: readonly string[]): boolean {
385
+ return a.length === b.length && a.every((value, index) => value === b[index]);
386
+ }
387
+
388
+ function sameSpeakers(a: readonly SpeakerInfo[], b: readonly SpeakerInfo[]): boolean {
389
+ return (
390
+ a.length === b.length &&
391
+ a.every(
392
+ (s, i) => s.identity === b[i]!.identity && s.producerId === b[i]!.producerId && s.volumeDb === b[i]!.volumeDb,
393
+ )
394
+ );
395
+ }
396
+
397
+ function sortHands(hands: readonly RaisedHand[]): readonly RaisedHand[] {
398
+ return [...hands].sort((a, b) => a.raisedAt - b.raisedAt || a.identity.localeCompare(b.identity));
399
+ }
400
+
258
401
  /**
259
402
  * Asks the CONTRACT's decision function rather than reading the rule here.
260
403
  *
package/src/index.ts CHANGED
@@ -34,8 +34,10 @@ export {
34
34
  type DisconnectCause,
35
35
  type MediaCoreCredentials,
36
36
  type ProducerEntry,
37
+ type RaisedHand,
37
38
  type RoomState,
38
39
  type SignalLogLevel,
40
+ type SpeakerInfo,
39
41
  } from "./core";
40
42
 
41
43
  /**
@@ -15,6 +15,8 @@ import type { MediaGrants, Peer } from "@tribe-nest/media-protocol";
15
15
  import { createBrowserDevice } from "../room/browserDevice";
16
16
  import {
17
17
  MediaRoom,
18
+ type BroadcastEvent,
19
+ type CallDiagnostics,
18
20
  type ConnectionState,
19
21
  type LocalPublication,
20
22
  type LocalPublicationSource,
@@ -228,6 +230,184 @@ export function useRecording(): boolean {
228
230
  return useRoomState().recording;
229
231
  }
230
232
 
233
+ /**
234
+ * The node's speaker ranking with quantized levels, loudest first.
235
+ *
236
+ * Read this from the SMALLEST component that renders it (a meter inside a
237
+ * name badge), not from the grid: the list moves on the observer's tick, and
238
+ * a grid subscribed to it re-renders every tile a few times a second.
239
+ */
240
+ export function useSpeakers(): RoomState["speakers"] {
241
+ return useRoomState().speakers;
242
+ }
243
+
244
+ /** Hands currently up, oldest first: the order IS the queue. */
245
+ export function useRaisedHands(): RoomState["raisedHands"] {
246
+ return useRoomState().raisedHands;
247
+ }
248
+
249
+ /** Whether a consume outside the active set would actually be refused. */
250
+ export function useActiveSetEnforced(): boolean {
251
+ return useRoomState().activeSetEnforced;
252
+ }
253
+
254
+ /**
255
+ * Incoming ephemeral broadcasts (reactions, courtesy lower-hands), as events.
256
+ *
257
+ * The handler lives in a ref, so an inline arrow does not resubscribe per
258
+ * render. Unknown `type` values are delivered; ignore what you do not know.
259
+ */
260
+ export function useBroadcast(handler: (event: BroadcastEvent) => void): void {
261
+ const { room } = useRoomContext();
262
+ const handlerRef = useRef(handler);
263
+ handlerRef.current = handler;
264
+ useEffect(() => {
265
+ if (!room) return undefined;
266
+ return room.onBroadcast((event) => handlerRef.current(event));
267
+ }, [room]);
268
+ }
269
+
270
+ /**
271
+ * Poll the call's diagnostics while `enabled`: per tile, the layer asked for,
272
+ * the layer the node is giving, and the decoded picture; plus the node's
273
+ * congestion estimate and this side's publish health. Null until the first
274
+ * answer, and null again when disabled. One request a second against the
275
+ * node while open, which is why it is gated rather than always on.
276
+ */
277
+ export function useCallDiagnostics(enabled: boolean, intervalMs = 1000): CallDiagnostics | null {
278
+ const { room } = useRoomContext();
279
+ const [diagnostics, setDiagnostics] = useState<CallDiagnostics | null>(null);
280
+ useEffect(() => {
281
+ if (!enabled || !room) {
282
+ setDiagnostics(null);
283
+ return undefined;
284
+ }
285
+ let cancelled = false;
286
+ const tick = () =>
287
+ void room
288
+ .getDiagnostics()
289
+ .then((d) => {
290
+ if (!cancelled) setDiagnostics(d);
291
+ })
292
+ .catch(() => undefined);
293
+ tick();
294
+ const timer = setInterval(tick, intervalMs);
295
+ return () => {
296
+ cancelled = true;
297
+ clearInterval(timer);
298
+ };
299
+ }, [enabled, room, intervalMs]);
300
+ return diagnostics;
301
+ }
302
+
303
+ export type { CallDiagnostics, TileDiagnostics, NodeDiagnostics } from "../room/room";
304
+
305
+ export type TileViewportControls = {
306
+ /**
307
+ * A ref-callback factory: `<video ref={observeTile(producerId)} />`.
308
+ * Registers the element as rendering that producer; passing a null element
309
+ * (unmount) unregisters it.
310
+ */
311
+ observeTile: (producerId: string) => (element: HTMLElement | null) => void;
312
+ };
313
+
314
+ /**
315
+ * Report what the UI is actually rendering, so the room narrows its video
316
+ * subscriptions to it (audio never narrows) and picks simulcast layers from
317
+ * the real tile sizes.
318
+ *
319
+ * One shared IntersectionObserver and ResizeObserver for every tile, plus
320
+ * `visibilitychange` for the backgrounded tab. Where `IntersectionObserver`
321
+ * does not exist (jsdom, SSR) this never calls `setViewport`, which IS the
322
+ * legacy subscribe-everything behavior.
323
+ */
324
+ export function useTileViewport(): TileViewportControls {
325
+ const { room } = useRoomContext();
326
+
327
+ const internals = useMemo(() => {
328
+ if (typeof IntersectionObserver === "undefined" || typeof ResizeObserver === "undefined") return null;
329
+ const byElement = new Map<Element, { producerId: string; visible: boolean; widthPx: number }>();
330
+ const report = () => {
331
+ if (!room) return;
332
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
333
+ room.setViewport([]);
334
+ return;
335
+ }
336
+ const entries: { producerId: string; widthPx: number }[] = [];
337
+ for (const tile of byElement.values()) {
338
+ if (tile.visible) entries.push({ producerId: tile.producerId, widthPx: tile.widthPx });
339
+ }
340
+ room.setViewport(entries);
341
+ };
342
+ const intersection = new IntersectionObserver(
343
+ (observed) => {
344
+ for (const entry of observed) {
345
+ const tile = byElement.get(entry.target);
346
+ if (!tile) continue;
347
+ tile.visible = entry.isIntersecting;
348
+ if (entry.isIntersecting) tile.widthPx = Math.round(entry.boundingClientRect.width) || tile.widthPx;
349
+ }
350
+ report();
351
+ },
352
+ // A sliver of a tile at a page edge is not "rendering it".
353
+ { threshold: 0.05 },
354
+ );
355
+ const resize = new ResizeObserver((observed) => {
356
+ for (const entry of observed) {
357
+ const tile = byElement.get(entry.target);
358
+ if (!tile) continue;
359
+ tile.widthPx = Math.round(entry.contentRect.width) || tile.widthPx;
360
+ }
361
+ report();
362
+ });
363
+ return { byElement, report, intersection, resize };
364
+ }, [room]);
365
+
366
+ useEffect(() => {
367
+ if (!internals || !room) return undefined;
368
+ const onVisibility = () => internals.report();
369
+ document.addEventListener("visibilitychange", onVisibility);
370
+ return () => {
371
+ document.removeEventListener("visibilitychange", onVisibility);
372
+ internals.intersection.disconnect();
373
+ internals.resize.disconnect();
374
+ internals.byElement.clear();
375
+ // The tiles are gone with the component that rendered them; the room
376
+ // goes back to following the whole set rather than an empty viewport.
377
+ room.clearViewport();
378
+ };
379
+ }, [internals, room]);
380
+
381
+ const observeTile = useCallback(
382
+ (producerId: string) => (element: HTMLElement | null) => {
383
+ if (!internals) return;
384
+ if (element === null) {
385
+ for (const [el, tile] of internals.byElement) {
386
+ if (tile.producerId !== producerId) continue;
387
+ internals.intersection.unobserve(el);
388
+ internals.resize.unobserve(el);
389
+ internals.byElement.delete(el);
390
+ }
391
+ internals.report();
392
+ return;
393
+ }
394
+ internals.byElement.set(element, {
395
+ producerId,
396
+ // Visible until the observer's first callback says otherwise: the
397
+ // pessimistic default would pause every tile for a beat on mount.
398
+ visible: true,
399
+ widthPx: Math.round(element.getBoundingClientRect().width) || 320,
400
+ });
401
+ internals.intersection.observe(element);
402
+ internals.resize.observe(element);
403
+ internals.report();
404
+ },
405
+ [internals],
406
+ );
407
+
408
+ return { observeTile };
409
+ }
410
+
231
411
  /**
232
412
  * One remote track, and a ref callback that attaches it.
233
413
  *
@@ -324,10 +504,24 @@ export function useRemoteTrack(producerId: string): {
324
504
 
325
505
  export type LocalSource = LocalPublicationSource;
326
506
 
507
+ export type PublishDeviceOptions = {
508
+ /** Capture from this device rather than the browser's default. */
509
+ deviceId?: string;
510
+ /** Publish three spatial layers (VP8 only). For group calls whose viewers
511
+ * pick a layer per tile; wrong for a 1:1 where one encoding carries. */
512
+ simulcast?: boolean;
513
+ };
514
+
327
515
  export type LocalMediaControls = {
328
- publishCamera: () => Promise<void>;
329
- publishMicrophone: () => Promise<void>;
516
+ publishCamera: (options?: PublishDeviceOptions) => Promise<void>;
517
+ publishMicrophone: (options?: PublishDeviceOptions) => Promise<void>;
330
518
  publishScreen: () => Promise<void>;
519
+ /**
520
+ * Swap the capture behind a live publication to another device, keeping the
521
+ * producer: remote tiles do not blink, and Safari is not re-prompted. No-op
522
+ * for a source that is not published.
523
+ */
524
+ switchDevice: (source: "camera" | "microphone", deviceId: string) => Promise<void>;
331
525
  unpublish: (source: LocalSource) => Promise<void>;
332
526
  /**
333
527
  * Mute or unmute a source that is already published, keeping its capture.
@@ -376,6 +570,24 @@ const selectLostSources = (room: MediaRoom): readonly LocalSource[] => room.lost
376
570
  * BROWSER concern with a UI consequence - a denied prompt is something a person
377
571
  * has to be told about - and the room has no way to say so.
378
572
  */
573
+ /**
574
+ * The camera ask: 720p wanted, nothing REQUIRED.
575
+ *
576
+ * Without constraints the browser answers `getUserMedia({ video: true })`
577
+ * with 640x480 - the health panel caught a whole call whose "full" layer
578
+ * was SD 4:3 because of exactly this line. `ideal` rather than `exact`/`min`
579
+ * on purpose: a laptop whose camera tops out lower must still join the call,
580
+ * not be refused with OverconstrainedError over a resolution.
581
+ */
582
+ function cameraConstraints(deviceId?: string): MediaTrackConstraints {
583
+ return {
584
+ width: { ideal: 1280 },
585
+ height: { ideal: 720 },
586
+ frameRate: { ideal: 30 },
587
+ ...(deviceId ? { deviceId: { exact: deviceId } } : {}),
588
+ };
589
+ }
590
+
379
591
  export function useLocalMedia(): LocalMediaControls {
380
592
  const { room } = useRoomContext();
381
593
  const [error, setError] = useState<Error | undefined>(undefined);
@@ -444,7 +656,7 @@ export function useLocalMedia(): LocalMediaControls {
444
656
  * published).
445
657
  */
446
658
  const publish = useCallback(
447
- async (source: LocalSource) =>
659
+ async (source: LocalSource, options?: PublishDeviceOptions) =>
448
660
  exclusively(source, async () => {
449
661
  if (!room) return;
450
662
  setError(undefined);
@@ -454,11 +666,15 @@ export function useLocalMedia(): LocalMediaControls {
454
666
  const stream =
455
667
  source === "screen"
456
668
  ? await navigator.mediaDevices.getDisplayMedia({ video: true })
457
- : await navigator.mediaDevices.getUserMedia(source === "camera" ? { video: true } : { audio: true });
669
+ : await navigator.mediaDevices.getUserMedia(
670
+ source === "camera"
671
+ ? { video: cameraConstraints(options?.deviceId) }
672
+ : { audio: options?.deviceId ? { deviceId: { exact: options.deviceId } } : true },
673
+ );
458
674
  captured = stream.getTracks();
459
675
  const track = captured[0];
460
676
  if (!track) throw new Error(`no ${source} track was captured`);
461
- await room.publish(track, source);
677
+ await room.publish(track, source, options?.simulcast && source === "camera" ? { simulcast: true } : {});
462
678
  published = track;
463
679
  } catch (err) {
464
680
  // Surfaced rather than thrown: a denied permission prompt is an
@@ -473,6 +689,38 @@ export function useLocalMedia(): LocalMediaControls {
473
689
  [room, exclusively],
474
690
  );
475
691
 
692
+ // Same lock as publish: a switch pressed twice must not open two captures,
693
+ // and a switch racing a publish must not replace a track that is not there
694
+ // yet. The new capture is owned here until `replaceTrack` takes it.
695
+ const switchDevice = useCallback(
696
+ async (source: "camera" | "microphone", deviceId: string) =>
697
+ exclusively(source, async () => {
698
+ if (!room) return;
699
+ const publication = room.localPublications.find((p) => p.source === source);
700
+ if (!publication) return;
701
+ setError(undefined);
702
+ let captured: MediaStreamTrack[] = [];
703
+ let handedOver: MediaStreamTrack | undefined;
704
+ try {
705
+ const stream = await navigator.mediaDevices.getUserMedia(
706
+ source === "camera" ? { video: cameraConstraints(deviceId) } : { audio: { deviceId: { exact: deviceId } } },
707
+ );
708
+ captured = stream.getTracks();
709
+ const track = captured[0];
710
+ if (!track) throw new Error(`no ${source} track was captured`);
711
+ await room.replaceTrack(publication.producerId, track);
712
+ handedOver = track;
713
+ } catch (err) {
714
+ setError(err instanceof Error ? err : new Error(String(err)));
715
+ } finally {
716
+ for (const track of captured) {
717
+ if (track !== handedOver) track.stop();
718
+ }
719
+ }
720
+ }),
721
+ [room, exclusively],
722
+ );
723
+
476
724
  // Under the same lock as `publish`, because the pair is what a toggle
477
725
  // alternates between: a stop pressed while a start is still capturing would
478
726
  // otherwise find no publication to close and leave the capture that lands a
@@ -502,9 +750,10 @@ export function useLocalMedia(): LocalMediaControls {
502
750
  );
503
751
 
504
752
  return {
505
- publishCamera: useCallback(() => publish("camera"), [publish]),
506
- publishMicrophone: useCallback(() => publish("microphone"), [publish]),
753
+ publishCamera: useCallback((options?: PublishDeviceOptions) => publish("camera", options), [publish]),
754
+ publishMicrophone: useCallback((options?: PublishDeviceOptions) => publish("microphone", options), [publish]),
507
755
  publishScreen: useCallback(() => publish("screen"), [publish]),
756
+ switchDevice,
508
757
  unpublish,
509
758
  setPaused,
510
759
  isCameraEnabled: has("camera"),
@@ -529,6 +778,11 @@ const EMPTY_STATE: RoomState = {
529
778
  peers: [],
530
779
  producers: [],
531
780
  activeSpeakers: [],
781
+ activeAudio: [],
782
+ activeVideo: [],
783
+ speakers: [],
784
+ activeSetEnforced: false,
785
+ raisedHands: [],
532
786
  recording: false,
533
787
  subscribeRule: null,
534
788
  draining: null,