@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.
- package/build/core/index.d.ts +1 -1
- package/build/core/index.d.ts.map +1 -1
- package/build/core/index.js.map +1 -1
- package/build/core/reconnect.d.ts +29 -0
- package/build/core/reconnect.d.ts.map +1 -1
- package/build/core/reconnect.js +49 -9
- package/build/core/reconnect.js.map +1 -1
- package/build/core/signal.d.ts +9 -0
- package/build/core/signal.d.ts.map +1 -1
- package/build/core/signal.js +18 -2
- package/build/core/signal.js.map +1 -1
- package/build/core/state.d.ts +28 -0
- package/build/core/state.d.ts.map +1 -1
- package/build/core/state.js +106 -7
- package/build/core/state.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js.map +1 -1
- package/build/react/index.d.ts +63 -11
- package/build/react/index.d.ts.map +1 -1
- package/build/react/index.js +229 -5
- package/build/react/index.js.map +1 -1
- package/build/room/device.d.ts +6 -0
- package/build/room/device.d.ts.map +1 -1
- package/build/room/room.d.ts +235 -1
- package/build/room/room.d.ts.map +1 -1
- package/build/room/room.js +627 -24
- package/build/room/room.js.map +1 -1
- package/package.json +2 -2
- package/src/core/_tests/reconnect.spec.ts +92 -0
- package/src/core/_tests/state.spec.ts +92 -0
- package/src/core/index.ts +2 -0
- package/src/core/reconnect.ts +76 -9
- package/src/core/signal.ts +16 -2
- package/src/core/state.ts +153 -10
- package/src/index.ts +2 -0
- package/src/react/index.tsx +261 -7
- package/src/room/_tests/room.spec.ts +720 -3
- package/src/room/device.ts +6 -0
- 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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
package/src/react/index.tsx
CHANGED
|
@@ -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(
|
|
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,
|