@tribe-nest/media-client 0.1.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 (44) 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 +31 -0
  13. package/build/core/state.d.ts.map +1 -1
  14. package/build/core/state.js +113 -8
  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 +78 -16
  20. package/build/react/index.d.ts.map +1 -1
  21. package/build/react/index.js +289 -12
  22. package/build/react/index.js.map +1 -1
  23. package/build/room/browserDevice.d.ts.map +1 -1
  24. package/build/room/browserDevice.js +13 -4
  25. package/build/room/browserDevice.js.map +1 -1
  26. package/build/room/device.d.ts +19 -0
  27. package/build/room/device.d.ts.map +1 -1
  28. package/build/room/room.d.ts +298 -3
  29. package/build/room/room.d.ts.map +1 -1
  30. package/build/room/room.js +742 -24
  31. package/build/room/room.js.map +1 -1
  32. package/package.json +2 -2
  33. package/src/core/_tests/reconnect.spec.ts +92 -0
  34. package/src/core/_tests/state.spec.ts +138 -0
  35. package/src/core/index.ts +2 -0
  36. package/src/core/reconnect.ts +76 -9
  37. package/src/core/signal.ts +16 -2
  38. package/src/core/state.ts +163 -11
  39. package/src/index.ts +2 -0
  40. package/src/react/index.tsx +323 -19
  41. package/src/room/_tests/room.spec.ts +954 -4
  42. package/src/room/browserDevice.ts +14 -4
  43. package/src/room/device.ts +19 -0
  44. package/src/room/room.ts +913 -26
package/src/core/state.ts CHANGED
@@ -24,6 +24,24 @@ export type ProducerEntry = {
24
24
  identity: string;
25
25
  kind: "audio" | "video";
26
26
  paused: boolean;
27
+ /** The publisher's declared label ("camera", "screen", "rtmp", "program"),
28
+ * when the node relayed one. For rendering only. */
29
+ source?: string;
30
+ };
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;
27
45
  };
28
46
 
29
47
  export type RoomState = {
@@ -41,6 +59,21 @@ export type RoomState = {
41
59
  producers: readonly ProducerEntry[];
42
60
  /** The set the NODE decided we should consume, in its order. */
43
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[];
44
77
  /** True while any `kind: "egress"` participant is present. A consent flag. */
45
78
  recording: boolean;
46
79
  /** The latest rule pushed for us, if one ever was. */
@@ -57,6 +90,11 @@ export const initialRoomState: RoomState = {
57
90
  peers: [],
58
91
  producers: [],
59
92
  activeSpeakers: [],
93
+ activeAudio: [],
94
+ activeVideo: [],
95
+ speakers: [],
96
+ activeSetEnforced: false,
97
+ raisedHands: [],
60
98
  recording: false,
61
99
  subscribeRule: null,
62
100
  draining: null,
@@ -91,7 +129,13 @@ export function reduceRoomState(state: RoomState, frame: EventFrame): RoomState
91
129
  room: frame.room,
92
130
  peers: frame.peers,
93
131
  producers,
94
- 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 ?? []),
95
139
  recording: frame.recording,
96
140
  subscribeRule: null,
97
141
  draining: null,
@@ -115,11 +159,15 @@ export function reduceRoomState(state: RoomState, frame: EventFrame): RoomState
115
159
  // guaranteed when a peer's socket simply dies, and a producer left behind
116
160
  // is a tile that never clears.
117
161
  const producers = state.producers.filter((p) => p.identity !== frame.identity);
162
+ const hands = state.raisedHands.filter((h) => h.identity !== frame.identity);
118
163
  return {
119
164
  ...state,
120
165
  peers: state.peers.filter((p) => p.identity !== frame.identity),
121
166
  producers,
122
- 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,
123
171
  };
124
172
  }
125
173
 
@@ -129,7 +177,13 @@ export function reduceRoomState(state: RoomState, frame: EventFrame): RoomState
129
177
  ...state,
130
178
  producers: [
131
179
  ...state.producers,
132
- { producerId: frame.producerId, identity: frame.identity, kind: frame.kind, paused: false },
180
+ {
181
+ producerId: frame.producerId,
182
+ identity: frame.identity,
183
+ kind: frame.kind,
184
+ paused: false,
185
+ ...(frame.source ? { source: frame.source } : {}),
186
+ },
133
187
  ],
134
188
  };
135
189
  }
@@ -137,7 +191,7 @@ export function reduceRoomState(state: RoomState, frame: EventFrame): RoomState
137
191
  case "producerClosed": {
138
192
  if (!state.producers.some((p) => p.producerId === frame.producerId)) return state;
139
193
  const producers = state.producers.filter((p) => p.producerId !== frame.producerId);
140
- return { ...state, producers, activeSpeakers: pruneSpeakers(state.activeSpeakers, producers) };
194
+ return { ...state, producers, ...pruneActiveSets(state, producers) };
141
195
  }
142
196
 
143
197
  case "producerPaused": {
@@ -150,11 +204,44 @@ export function reduceRoomState(state: RoomState, frame: EventFrame): RoomState
150
204
  return changed ? { ...state, producers } : state;
151
205
  }
152
206
 
153
- case "activeSpeakers":
207
+ case "activeSpeakers": {
154
208
  // Stored as sent, INCLUDING ids we have not seen a `producerAppeared` for
155
209
  // yet. The node decides the active set; dropping an id because our view
156
210
  // is a frame behind would silently discard a stream we were told to take.
157
- 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
+ }
158
245
 
159
246
  case "recordingChanged":
160
247
  return state.recording === frame.recording ? state : { ...state, recording: frame.recording };
@@ -165,7 +252,17 @@ export function reduceRoomState(state: RoomState, frame: EventFrame): RoomState
165
252
  case "roomClosed":
166
253
  // Producers and speakers are gone for good; peers are kept so a caller can
167
254
  // still say who was in the room on the "call ended" screen.
168
- 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
+ };
169
266
 
170
267
  case "subscribeRuleChanged":
171
268
  /**
@@ -185,10 +282,7 @@ export function reduceRoomState(state: RoomState, frame: EventFrame): RoomState
185
282
  return {
186
283
  ...state,
187
284
  subscribeRule: frame.subscribe,
188
- activeSpeakers: pruneSpeakers(
189
- state.activeSpeakers,
190
- visibleUnder(frame.subscribe, state.identity, state.producers),
191
- ),
285
+ ...pruneActiveSets(state, visibleUnder(frame.subscribe, state.identity, state.producers)),
192
286
  };
193
287
 
194
288
  default:
@@ -246,6 +340,64 @@ function pruneSpeakers(speakers: readonly string[], producers: readonly Producer
246
340
  return kept.length === speakers.length ? speakers : kept;
247
341
  }
248
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
+
249
401
  /**
250
402
  * Asks the CONTRACT's decision function rather than reading the rule here.
251
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
  *
@@ -248,11 +428,21 @@ export function useRecording(): boolean {
248
428
  * renders one only after the track arrives, and every remote `<video>` is
249
429
  * `muted`, so a call had picture and no sound in either direction.
250
430
  *
251
- * Depending on `track` is what makes React re-run it: the identity changes when
252
- * the track appears, React calls the old callback with `null` and the new one
253
- * with the element. `MediaTrack` objects are created once per consumer and the
254
- * room's snapshot array is rebuilt only when a track changes, so this settles
255
- * rather than churning.
431
+ * ## Why the callback is STABLE, and an effect does the re-attaching
432
+ *
433
+ * The callback used to be memoised on `[track]`, so a new track identity made
434
+ * React run the OLD callback with `null` (which pauses the element and clears
435
+ * `srcObject`) and then the new one with the element. That is a black frame and
436
+ * a stall on the viewer's screen, and it fires for reasons that have nothing to
437
+ * do with the picture: the room's snapshot is rebuilt on every change, and a
438
+ * consumer that is closed and re-created (`syncSubscriptions` following the
439
+ * active set) produces a fresh `MediaTrack` for the SAME underlying
440
+ * `MediaStreamTrack`.
441
+ *
442
+ * So the ref callback is now stable - it mounts and unmounts, nothing else -
443
+ * and an effect assigns `srcObject` whenever the underlying track actually
444
+ * changes. `applied` is what makes that idempotent: assigning the same track
445
+ * again is not free, it restarts the element's load and shows as a flicker.
256
446
  */
257
447
  export function useRemoteTrack(producerId: string): {
258
448
  track: MediaTrack | undefined;
@@ -260,24 +450,78 @@ export function useRemoteTrack(producerId: string): {
260
450
  } {
261
451
  const tracks = useRoomSnapshot(selectTracks, EMPTY_TRACKS);
262
452
  const track = tracks.find((t) => t.producerId === producerId);
453
+ const mediaTrack = track?.track ?? null;
454
+
455
+ // The element this callback last attached to, so DETACHING can undo it.
456
+ const attached = useRef<HTMLMediaElement | null>(null);
457
+ // The `MediaStreamTrack` currently on that element. Compared by identity, so
458
+ // the same track arriving inside a new `MediaTrack` record is a no-op.
459
+ const applied = useRef<MediaStreamTrack | null>(null);
460
+ // Read by the ref callback, which must not depend on the track (see above).
461
+ const latest = useRef<MediaStreamTrack | null>(mediaTrack);
462
+ latest.current = mediaTrack;
463
+
464
+ const attach = useCallback((element: HTMLMediaElement | null) => {
465
+ // React calls a ref callback with null when the element goes away, and
466
+ // ignoring that leaves a detached element holding the stream and still
467
+ // PLAYING it. Nothing on screen shows the orphan; you only hear it,
468
+ // as the same audio a second time, slightly out of step with itself.
469
+ // A remount is enough to cause it, and StrictMode remounts everything
470
+ // once in development, so this reproduced on every dev page load.
471
+ if (!element) {
472
+ const previous = attached.current;
473
+ attached.current = null;
474
+ applied.current = null;
475
+ if (previous) {
476
+ previous.pause();
477
+ previous.srcObject = null;
478
+ }
479
+ return;
480
+ }
481
+ // A newly mounted element is always written to, even with no track yet: an
482
+ // explicit `null` is what says "this element holds nothing", and skipping
483
+ // it would leave whatever a reused element was carrying.
484
+ const previousElement = attached.current;
485
+ attached.current = element;
486
+ if (previousElement === element && applied.current === latest.current) return;
487
+ applied.current = latest.current;
488
+ element.srcObject = latest.current ? new MediaStream([latest.current]) : null;
489
+ }, []);
263
490
 
264
- const attach = useCallback(
265
- (element: HTMLMediaElement | null) => {
266
- if (!element) return;
267
- element.srcObject = track ? new MediaStream([track.track]) : null;
268
- },
269
- [track],
270
- );
491
+ // The track almost never exists when the element mounts: the consume round
492
+ // trip lands milliseconds later. This is what puts it on the element then,
493
+ // and what swaps it if the consumer is ever rebuilt.
494
+ useEffect(() => {
495
+ const element = attached.current;
496
+ if (!element) return;
497
+ if (applied.current === mediaTrack) return;
498
+ applied.current = mediaTrack;
499
+ element.srcObject = mediaTrack ? new MediaStream([mediaTrack]) : null;
500
+ }, [mediaTrack]);
271
501
 
272
502
  return { track, attach };
273
503
  }
274
504
 
275
505
  export type LocalSource = LocalPublicationSource;
276
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
+
277
515
  export type LocalMediaControls = {
278
- publishCamera: () => Promise<void>;
279
- publishMicrophone: () => Promise<void>;
516
+ publishCamera: (options?: PublishDeviceOptions) => Promise<void>;
517
+ publishMicrophone: (options?: PublishDeviceOptions) => Promise<void>;
280
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>;
281
525
  unpublish: (source: LocalSource) => Promise<void>;
282
526
  /**
283
527
  * Mute or unmute a source that is already published, keeping its capture.
@@ -326,6 +570,24 @@ const selectLostSources = (room: MediaRoom): readonly LocalSource[] => room.lost
326
570
  * BROWSER concern with a UI consequence - a denied prompt is something a person
327
571
  * has to be told about - and the room has no way to say so.
328
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
+
329
591
  export function useLocalMedia(): LocalMediaControls {
330
592
  const { room } = useRoomContext();
331
593
  const [error, setError] = useState<Error | undefined>(undefined);
@@ -394,7 +656,7 @@ export function useLocalMedia(): LocalMediaControls {
394
656
  * published).
395
657
  */
396
658
  const publish = useCallback(
397
- async (source: LocalSource) =>
659
+ async (source: LocalSource, options?: PublishDeviceOptions) =>
398
660
  exclusively(source, async () => {
399
661
  if (!room) return;
400
662
  setError(undefined);
@@ -404,11 +666,15 @@ export function useLocalMedia(): LocalMediaControls {
404
666
  const stream =
405
667
  source === "screen"
406
668
  ? await navigator.mediaDevices.getDisplayMedia({ video: true })
407
- : 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
+ );
408
674
  captured = stream.getTracks();
409
675
  const track = captured[0];
410
676
  if (!track) throw new Error(`no ${source} track was captured`);
411
- await room.publish(track, source);
677
+ await room.publish(track, source, options?.simulcast && source === "camera" ? { simulcast: true } : {});
412
678
  published = track;
413
679
  } catch (err) {
414
680
  // Surfaced rather than thrown: a denied permission prompt is an
@@ -423,6 +689,38 @@ export function useLocalMedia(): LocalMediaControls {
423
689
  [room, exclusively],
424
690
  );
425
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
+
426
724
  // Under the same lock as `publish`, because the pair is what a toggle
427
725
  // alternates between: a stop pressed while a start is still capturing would
428
726
  // otherwise find no publication to close and leave the capture that lands a
@@ -452,9 +750,10 @@ export function useLocalMedia(): LocalMediaControls {
452
750
  );
453
751
 
454
752
  return {
455
- publishCamera: useCallback(() => publish("camera"), [publish]),
456
- publishMicrophone: useCallback(() => publish("microphone"), [publish]),
753
+ publishCamera: useCallback((options?: PublishDeviceOptions) => publish("camera", options), [publish]),
754
+ publishMicrophone: useCallback((options?: PublishDeviceOptions) => publish("microphone", options), [publish]),
457
755
  publishScreen: useCallback(() => publish("screen"), [publish]),
756
+ switchDevice,
458
757
  unpublish,
459
758
  setPaused,
460
759
  isCameraEnabled: has("camera"),
@@ -479,6 +778,11 @@ const EMPTY_STATE: RoomState = {
479
778
  peers: [],
480
779
  producers: [],
481
780
  activeSpeakers: [],
781
+ activeAudio: [],
782
+ activeVideo: [],
783
+ speakers: [],
784
+ activeSetEnforced: false,
785
+ raisedHands: [],
482
786
  recording: false,
483
787
  subscribeRule: null,
484
788
  draining: null,