@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.
- 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 +31 -0
- package/build/core/state.d.ts.map +1 -1
- package/build/core/state.js +113 -8
- 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 +78 -16
- package/build/react/index.d.ts.map +1 -1
- package/build/react/index.js +289 -12
- 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 +19 -0
- package/build/room/device.d.ts.map +1 -1
- package/build/room/room.d.ts +298 -3
- package/build/room/room.d.ts.map +1 -1
- package/build/room/room.js +742 -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 +138 -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 +163 -11
- package/src/index.ts +2 -0
- package/src/react/index.tsx +323 -19
- package/src/room/_tests/room.spec.ts +954 -4
- package/src/room/browserDevice.ts +14 -4
- package/src/room/device.ts +19 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
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
|
*
|
|
@@ -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
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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(
|
|
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,
|