@tribe-nest/media-client 0.1.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/README.md +68 -0
- package/build/core/index.d.ts +17 -0
- package/build/core/index.d.ts.map +1 -0
- package/build/core/index.js +41 -0
- package/build/core/index.js.map +1 -0
- package/build/core/reconnect.d.ts +95 -0
- package/build/core/reconnect.d.ts.map +1 -0
- package/build/core/reconnect.js +160 -0
- package/build/core/reconnect.js.map +1 -0
- package/build/core/signal.d.ts +184 -0
- package/build/core/signal.d.ts.map +1 -0
- package/build/core/signal.js +416 -0
- package/build/core/signal.js.map +1 -0
- package/build/core/socket.d.ts +57 -0
- package/build/core/socket.d.ts.map +1 -0
- package/build/core/socket.js +37 -0
- package/build/core/socket.js.map +1 -0
- package/build/core/state.d.ts +67 -0
- package/build/core/state.d.ts.map +1 -0
- package/build/core/state.js +193 -0
- package/build/core/state.js.map +1 -0
- package/build/index.d.ts +29 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +51 -0
- package/build/index.js.map +1 -0
- package/build/protocol.d.ts +10 -0
- package/build/protocol.d.ts.map +1 -0
- package/build/protocol.js +26 -0
- package/build/protocol.js.map +1 -0
- package/build/react/index.d.ts +147 -0
- package/build/react/index.d.ts.map +1 -0
- package/build/react/index.js +319 -0
- package/build/react/index.js.map +1 -0
- package/build/room/browserDevice.d.ts +3 -0
- package/build/room/browserDevice.d.ts.map +1 -0
- package/build/room/browserDevice.js +94 -0
- package/build/room/browserDevice.js.map +1 -0
- package/build/room/device.d.ts +114 -0
- package/build/room/device.d.ts.map +1 -0
- package/build/room/device.js +3 -0
- package/build/room/device.js.map +1 -0
- package/build/room/room.d.ts +219 -0
- package/build/room/room.d.ts.map +1 -0
- package/build/room/room.js +438 -0
- package/build/room/room.js.map +1 -0
- package/package.json +69 -0
- package/src/_tests/clientBoundary.spec.ts +110 -0
- package/src/core/_tests/coreBoundary.spec.ts +70 -0
- package/src/core/_tests/fakeSignalServer.ts +188 -0
- package/src/core/_tests/reconnect.spec.ts +180 -0
- package/src/core/_tests/signal.spec.ts +347 -0
- package/src/core/_tests/state.spec.ts +226 -0
- package/src/core/index.ts +63 -0
- package/src/core/reconnect.ts +233 -0
- package/src/core/signal.ts +527 -0
- package/src/core/socket.ts +58 -0
- package/src/core/state.ts +251 -0
- package/src/index.ts +54 -0
- package/src/protocol.ts +9 -0
- package/src/react/_tests/hooks.spec.tsx +509 -0
- package/src/react/index.tsx +439 -0
- package/src/room/_tests/room.spec.ts +595 -0
- package/src/room/browserDevice.ts +114 -0
- package/src/room/device.ts +119 -0
- package/src/room/room.ts +600 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
useSyncExternalStore,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
|
|
13
|
+
import type { MediaGrants, Peer } from "@tribe-nest/media-protocol";
|
|
14
|
+
|
|
15
|
+
import { createBrowserDevice } from "../room/browserDevice";
|
|
16
|
+
import {
|
|
17
|
+
MediaRoom,
|
|
18
|
+
type ConnectionState,
|
|
19
|
+
type LocalPublication,
|
|
20
|
+
type MediaRoomOptions,
|
|
21
|
+
type MediaTrack,
|
|
22
|
+
} from "../room/room";
|
|
23
|
+
import { visibleProducers } from "../core";
|
|
24
|
+
import type { DisconnectCause, ProducerEntry, RoomState } from "../core";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The React surface.
|
|
28
|
+
*
|
|
29
|
+
* ## Why `useSyncExternalStore` rather than state in the provider
|
|
30
|
+
*
|
|
31
|
+
* The room is an external mutable object with its own event loop, which is
|
|
32
|
+
* precisely the case React 18 added that hook for. The alternative - mirroring
|
|
33
|
+
* room state into `useState` on every event - tears: a component reading
|
|
34
|
+
* `room.tracks` directly during a render sees a newer value than the mirrored
|
|
35
|
+
* copy it was rendered from, and the bug shows up as a tile that is one event
|
|
36
|
+
* behind exactly when the room is busiest.
|
|
37
|
+
*
|
|
38
|
+
* ## Why every hook takes NO arguments
|
|
39
|
+
*
|
|
40
|
+
* `useMediaRoom()` reads the provider. A hook that took credentials would let
|
|
41
|
+
* two components mount two rooms against one call, and the second would take
|
|
42
|
+
* the identity from the first and be refused with `duplicate_identity`. One
|
|
43
|
+
* room per provider, and the provider owns its lifetime.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
type MediaRoomContextValue = {
|
|
47
|
+
room: MediaRoom | null;
|
|
48
|
+
connectionState: ConnectionState;
|
|
49
|
+
error: DisconnectCause | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Is the SDK actually coming back on its own?
|
|
52
|
+
*
|
|
53
|
+
* `connectionState` is `reconnecting` both while an attempt is booked and
|
|
54
|
+
* after the policy has given up, so a screen reading only that spins for ever
|
|
55
|
+
* over a call that is finished. This is the half that says which, and it is
|
|
56
|
+
* what a UI turns "wait" against "press something" on.
|
|
57
|
+
*/
|
|
58
|
+
recovering: boolean;
|
|
59
|
+
retry: () => void;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const MediaRoomContext = createContext<MediaRoomContextValue | null>(null);
|
|
63
|
+
|
|
64
|
+
export type MediaRoomProviderProps = Omit<MediaRoomOptions, "device"> &
|
|
65
|
+
Partial<Pick<MediaRoomOptions, "device">> & {
|
|
66
|
+
children: ReactNode;
|
|
67
|
+
/** Rendered while the first connection is in flight. */
|
|
68
|
+
fallback?: ReactNode;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function MediaRoomProvider({ children, fallback, device, ...options }: MediaRoomProviderProps): ReactNode {
|
|
72
|
+
const [room, setRoom] = useState<MediaRoom | null>(null);
|
|
73
|
+
const [connectionState, setConnectionState] = useState<ConnectionState>("idle");
|
|
74
|
+
const [error, setError] = useState<DisconnectCause | undefined>(undefined);
|
|
75
|
+
const [recovering, setRecovering] = useState(false);
|
|
76
|
+
const [attempt, setAttempt] = useState(0);
|
|
77
|
+
|
|
78
|
+
// Held in a ref so changing a callback identity does not tear down a live
|
|
79
|
+
// call. A parent that re-renders every second must not reconnect the room
|
|
80
|
+
// every second, and inline arrow props make that the DEFAULT mistake.
|
|
81
|
+
const optionsRef = useRef(options);
|
|
82
|
+
optionsRef.current = options;
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
let cancelled = false;
|
|
86
|
+
const instance = new MediaRoom({
|
|
87
|
+
...optionsRef.current,
|
|
88
|
+
getCredentials: () => optionsRef.current.getCredentials(),
|
|
89
|
+
device: device ?? createBrowserDevice,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const unsubscribe = instance.onChange(() => {
|
|
93
|
+
if (cancelled) return;
|
|
94
|
+
setConnectionState(instance.connectionState);
|
|
95
|
+
setError(instance.error);
|
|
96
|
+
setRecovering(instance.isRecovering);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
setRoom(instance);
|
|
100
|
+
instance.connect().catch(() => {
|
|
101
|
+
// The cause is on the room; `error` is already mirrored by the listener
|
|
102
|
+
// above. Swallowed here so a failed connect does not surface as an
|
|
103
|
+
// unhandled rejection in the host application's console.
|
|
104
|
+
if (!cancelled) setConnectionState(instance.connectionState);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return () => {
|
|
108
|
+
cancelled = true;
|
|
109
|
+
unsubscribe();
|
|
110
|
+
void instance.close().catch(() => undefined);
|
|
111
|
+
};
|
|
112
|
+
// `attempt` is what `retry()` bumps. `device` is read once per room.
|
|
113
|
+
}, [attempt, device]);
|
|
114
|
+
|
|
115
|
+
const value = useMemo<MediaRoomContextValue>(
|
|
116
|
+
() => ({ room, connectionState, error, recovering, retry: () => setAttempt((n) => n + 1) }),
|
|
117
|
+
[room, connectionState, error, recovering],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<MediaRoomContext.Provider value={value}>
|
|
122
|
+
{connectionState === "connecting" && fallback ? fallback : children}
|
|
123
|
+
</MediaRoomContext.Provider>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function useRoomContext(): MediaRoomContextValue {
|
|
128
|
+
const value = useContext(MediaRoomContext);
|
|
129
|
+
if (!value) throw new Error("useMediaRoom must be used inside a <MediaRoomProvider>");
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function useMediaRoom(): MediaRoomContextValue {
|
|
134
|
+
return useRoomContext();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Subscribe to the room's own change notifications. */
|
|
138
|
+
function useRoomSnapshot<T>(select: (room: MediaRoom) => T, fallback: T): T {
|
|
139
|
+
// `select` and `fallback` must be stable across renders - see the note on the
|
|
140
|
+
// module-level selectors below.
|
|
141
|
+
const { room } = useRoomContext();
|
|
142
|
+
|
|
143
|
+
const subscribe = useCallback(
|
|
144
|
+
(onChange: () => void) => (room ? room.onChange(onChange) : () => undefined),
|
|
145
|
+
[room],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// The selector must return a STABLE reference for unchanged state, or
|
|
149
|
+
// `useSyncExternalStore` re-renders forever. `RoomState` is produced by a
|
|
150
|
+
// reducer that returns the same object when nothing changed, which is what
|
|
151
|
+
// makes reading it directly safe.
|
|
152
|
+
const getSnapshot = useCallback(() => (room ? select(room) : fallback), [room, select, fallback]);
|
|
153
|
+
|
|
154
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Module-level selectors, and that is not a style preference.
|
|
159
|
+
*
|
|
160
|
+
* `useRoomSnapshot` memoises `getSnapshot` on the selector's identity. An
|
|
161
|
+
* inline arrow is a new function every render, so the memo never holds, and
|
|
162
|
+
* `useSyncExternalStore` re-subscribes and re-reads on every pass.
|
|
163
|
+
*/
|
|
164
|
+
const selectState = (room: MediaRoom): RoomState => room.state;
|
|
165
|
+
const selectTracks = (room: MediaRoom): readonly MediaTrack[] => room.tracks;
|
|
166
|
+
const selectPublications = (room: MediaRoom): MediaRoom["localPublications"] => room.localPublications;
|
|
167
|
+
|
|
168
|
+
export function useRoomState(): RoomState {
|
|
169
|
+
return useRoomSnapshot(selectState, EMPTY_STATE);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function useParticipants(): readonly Peer[] {
|
|
173
|
+
return useRoomState().peers;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function useActiveSpeakers(): readonly string[] {
|
|
177
|
+
return useRoomState().activeSpeakers;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The producers this participant may actually receive. What a UI renders.
|
|
182
|
+
*
|
|
183
|
+
* NOT `useRoomState().producers`, and the difference is the subscribe barrier.
|
|
184
|
+
* The reducer keeps every producer the node has ever announced, deliberately,
|
|
185
|
+
* so that a rule which narrows mid-call and later widens is reversible - there
|
|
186
|
+
* is no `producerAppeared` replay to bring back a producer it had deleted. The
|
|
187
|
+
* price of keeping them is that the raw list contains people this seat has been
|
|
188
|
+
* barred from, and on a sealed consultation room the mere PRESENCE of the other
|
|
189
|
+
* party is most of what the barrier was hiding. A UI mapping over the raw list
|
|
190
|
+
* therefore draws a tile carrying their identity and mounts an `<audio>` for
|
|
191
|
+
* them, and the node's own filtering does not save it: `revokeNewlyForbidden`
|
|
192
|
+
* only closes producers this session had a live consumer for, so one that was
|
|
193
|
+
* announced and never consumed stays in the list for the rest of the call.
|
|
194
|
+
*
|
|
195
|
+
* Memoised on the state OBJECT rather than used as a store selector: the filter
|
|
196
|
+
* builds a new array per call, and `useSyncExternalStore` compares snapshots
|
|
197
|
+
* with `Object.is`, so reading it through `useRoomSnapshot` would re-render for
|
|
198
|
+
* ever. The reducer returns the same state object when nothing changed, which
|
|
199
|
+
* is what makes this settle.
|
|
200
|
+
*/
|
|
201
|
+
export function useVisibleProducers(): readonly ProducerEntry[] {
|
|
202
|
+
const state = useRoomState();
|
|
203
|
+
return useMemo(() => visibleProducers(state), [state]);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* What THIS participant is publishing right now, with the live tracks.
|
|
208
|
+
*
|
|
209
|
+
* `useLocalMedia()` answers "is the camera on" and that is enough for a button,
|
|
210
|
+
* but a local preview needs the `MediaStreamTrack` itself, and reading
|
|
211
|
+
* `room.localPublications` from `useMediaRoom()` is not reactive: the preview
|
|
212
|
+
* would stay black until something else happened to re-render it.
|
|
213
|
+
*
|
|
214
|
+
* The snapshot is the room's cached array, rebuilt only when a publication
|
|
215
|
+
* changed, which is what keeps this out of the `getSnapshot` loop.
|
|
216
|
+
*/
|
|
217
|
+
export function useLocalPublications(): readonly LocalPublication[] {
|
|
218
|
+
return useRoomSnapshot(selectPublications, EMPTY_PUBLICATIONS);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** What the NODE says this token may do. For rendering, never for a decision:
|
|
222
|
+
* the node enforces independently and is the only side that counts. */
|
|
223
|
+
export function useGrants(): MediaGrants | undefined {
|
|
224
|
+
const { room } = useRoomContext();
|
|
225
|
+
return room?.grants;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** True while any egress participant is present. A consent signal. */
|
|
229
|
+
export function useRecording(): boolean {
|
|
230
|
+
return useRoomState().recording;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* One remote track, and a ref callback that attaches it.
|
|
235
|
+
*
|
|
236
|
+
* `attach` exists because `srcObject` cannot be set through JSX: it takes a
|
|
237
|
+
* `MediaStream` object, not a string, so every consumer of this hook would
|
|
238
|
+
* otherwise write the same `useEffect`.
|
|
239
|
+
*
|
|
240
|
+
* ## Why it is keyed on the TRACK
|
|
241
|
+
*
|
|
242
|
+
* A ref callback runs when the element mounts and again only when the callback
|
|
243
|
+
* IDENTITY changes. The track almost never exists at mount: a producer reaches
|
|
244
|
+
* `state.producers` the moment the node announces it, and the consume round
|
|
245
|
+
* trip that produces the track finishes some milliseconds later. So a callback
|
|
246
|
+
* memoised on `[room, producerId]` - both stable - ran exactly once, against no
|
|
247
|
+
* track, set `srcObject` to `null` and was never called again. An element that
|
|
248
|
+
* is only ever mounted once therefore stayed silent for the whole call, which
|
|
249
|
+
* is what happened to remote AUDIO: a `<video>` escapes it because a tile
|
|
250
|
+
* renders one only after the track arrives, and every remote `<video>` is
|
|
251
|
+
* `muted`, so a call had picture and no sound in either direction.
|
|
252
|
+
*
|
|
253
|
+
* Depending on `track` is what makes React re-run it: the identity changes when
|
|
254
|
+
* the track appears, React calls the old callback with `null` and the new one
|
|
255
|
+
* with the element. `MediaTrack` objects are created once per consumer and the
|
|
256
|
+
* room's snapshot array is rebuilt only when a track changes, so this settles
|
|
257
|
+
* rather than churning.
|
|
258
|
+
*/
|
|
259
|
+
export function useRemoteTrack(producerId: string): {
|
|
260
|
+
track: MediaTrack | undefined;
|
|
261
|
+
attach: (element: HTMLMediaElement | null) => void;
|
|
262
|
+
} {
|
|
263
|
+
const tracks = useRoomSnapshot(selectTracks, EMPTY_TRACKS);
|
|
264
|
+
const track = tracks.find((t) => t.producerId === producerId);
|
|
265
|
+
|
|
266
|
+
const attach = useCallback(
|
|
267
|
+
(element: HTMLMediaElement | null) => {
|
|
268
|
+
if (!element) return;
|
|
269
|
+
element.srcObject = track ? new MediaStream([track.track]) : null;
|
|
270
|
+
},
|
|
271
|
+
[track],
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
return { track, attach };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export type LocalSource = "camera" | "microphone" | "screen";
|
|
278
|
+
|
|
279
|
+
export type LocalMediaControls = {
|
|
280
|
+
publishCamera: () => Promise<void>;
|
|
281
|
+
publishMicrophone: () => Promise<void>;
|
|
282
|
+
publishScreen: () => Promise<void>;
|
|
283
|
+
unpublish: (source: LocalSource) => Promise<void>;
|
|
284
|
+
isCameraEnabled: boolean;
|
|
285
|
+
isMicrophoneEnabled: boolean;
|
|
286
|
+
screenSharing: boolean;
|
|
287
|
+
/**
|
|
288
|
+
* A capture or a publish is in flight for this source.
|
|
289
|
+
*
|
|
290
|
+
* Exposed so a control can be disabled rather than silently ignoring the
|
|
291
|
+
* second press: a permission prompt takes seconds, during which every flag
|
|
292
|
+
* above still reads `false`, and a button that looks unchanged is a button
|
|
293
|
+
* people press again.
|
|
294
|
+
*/
|
|
295
|
+
pending: Record<LocalSource, boolean>;
|
|
296
|
+
error: Error | undefined;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const NOTHING_PENDING: Record<LocalSource, boolean> = { camera: false, microphone: false, screen: false };
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Capture and publish.
|
|
303
|
+
*
|
|
304
|
+
* `getUserMedia` lives here rather than in the room because permission is a
|
|
305
|
+
* BROWSER concern with a UI consequence - a denied prompt is something a person
|
|
306
|
+
* has to be told about - and the room has no way to say so.
|
|
307
|
+
*/
|
|
308
|
+
export function useLocalMedia(): LocalMediaControls {
|
|
309
|
+
const { room } = useRoomContext();
|
|
310
|
+
const [error, setError] = useState<Error | undefined>(undefined);
|
|
311
|
+
const [pending, setPending] = useState<Record<LocalSource, boolean>>(NOTHING_PENDING);
|
|
312
|
+
const publications = useRoomSnapshot(selectPublications, EMPTY_PUBLICATIONS);
|
|
313
|
+
|
|
314
|
+
const has = (source: string) => publications.some((p) => p.source === source);
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* One operation per source at a time, held in a REF.
|
|
318
|
+
*
|
|
319
|
+
* Every control above this is a read-then-write: it reads
|
|
320
|
+
* `isMicrophoneEnabled` and calls publish or unpublish accordingly. That flag
|
|
321
|
+
* is derived from `publications`, which does not move until the publish has
|
|
322
|
+
* resolved - and a publish begins with a `getUserMedia` permission prompt
|
|
323
|
+
* that can sit on screen for seconds. So the ordinary human response to a
|
|
324
|
+
* button that appears to have done nothing (press it again) put two captures
|
|
325
|
+
* through two `room.publish` calls and produced TWO audio producers from one
|
|
326
|
+
* identity: everyone else mounts two `<audio>` elements and hears the person
|
|
327
|
+
* doubled, and unpublishing both later does not undo the seconds of garbled
|
|
328
|
+
* audio. The screen-share shape is worse, opening a second picker.
|
|
329
|
+
*
|
|
330
|
+
* A ref rather than the `pending` state because the check has to be true
|
|
331
|
+
* SYNCHRONOUSLY: two clicks in one tick are both handled before React has
|
|
332
|
+
* re-rendered anything, so a state flag would be `false` in both of them.
|
|
333
|
+
* `pending` exists to grey the button out, not to guard it.
|
|
334
|
+
*/
|
|
335
|
+
const inFlight = useRef<Set<LocalSource>>(new Set());
|
|
336
|
+
|
|
337
|
+
const exclusively = useCallback(async (source: LocalSource, work: () => Promise<void>): Promise<void> => {
|
|
338
|
+
if (inFlight.current.has(source)) return;
|
|
339
|
+
inFlight.current.add(source);
|
|
340
|
+
setPending((current) => ({ ...current, [source]: true }));
|
|
341
|
+
try {
|
|
342
|
+
await work();
|
|
343
|
+
} finally {
|
|
344
|
+
inFlight.current.delete(source);
|
|
345
|
+
setPending((current) => ({ ...current, [source]: false }));
|
|
346
|
+
}
|
|
347
|
+
}, []);
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Captured tracks are OWNED here until the room takes one.
|
|
351
|
+
*
|
|
352
|
+
* `getUserMedia` turns the camera light on. If the publish that follows
|
|
353
|
+
* fails - the node refusing the grant, the transport failing, the browser
|
|
354
|
+
* unable to produce the kind - the capture is still live, and nothing in the
|
|
355
|
+
* UI can stop it: no publication was recorded, so the button still reads
|
|
356
|
+
* "Start camera" and `unpublish("camera")` finds nothing to close. The light
|
|
357
|
+
* stays on until the provider unmounts, and every retry opens another one.
|
|
358
|
+
*
|
|
359
|
+
* So everything captured is stopped unless it was handed over, including the
|
|
360
|
+
* extra tracks a capture can carry (Chrome adds a system-audio track to a
|
|
361
|
+
* screen share when the person ticks "share audio", and only one track is
|
|
362
|
+
* published).
|
|
363
|
+
*/
|
|
364
|
+
const publish = useCallback(
|
|
365
|
+
async (source: LocalSource) =>
|
|
366
|
+
exclusively(source, async () => {
|
|
367
|
+
if (!room) return;
|
|
368
|
+
setError(undefined);
|
|
369
|
+
let captured: MediaStreamTrack[] = [];
|
|
370
|
+
let published: MediaStreamTrack | undefined;
|
|
371
|
+
try {
|
|
372
|
+
const stream =
|
|
373
|
+
source === "screen"
|
|
374
|
+
? await navigator.mediaDevices.getDisplayMedia({ video: true })
|
|
375
|
+
: await navigator.mediaDevices.getUserMedia(
|
|
376
|
+
source === "camera" ? { video: true } : { audio: true },
|
|
377
|
+
);
|
|
378
|
+
captured = stream.getTracks();
|
|
379
|
+
const track = captured[0];
|
|
380
|
+
if (!track) throw new Error(`no ${source} track was captured`);
|
|
381
|
+
await room.publish(track, source);
|
|
382
|
+
published = track;
|
|
383
|
+
} catch (err) {
|
|
384
|
+
// Surfaced rather than thrown: a denied permission prompt is an
|
|
385
|
+
// ordinary outcome that the UI has to explain, not an exception.
|
|
386
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
387
|
+
} finally {
|
|
388
|
+
for (const track of captured) {
|
|
389
|
+
if (track !== published) track.stop();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}),
|
|
393
|
+
[room, exclusively],
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
// Under the same lock as `publish`, because the pair is what a toggle
|
|
397
|
+
// alternates between: a stop pressed while a start is still capturing would
|
|
398
|
+
// otherwise find no publication to close and leave the capture that lands a
|
|
399
|
+
// moment later running with the button reading "Mute".
|
|
400
|
+
const unpublish = useCallback(
|
|
401
|
+
async (source: LocalSource) =>
|
|
402
|
+
exclusively(source, async () => {
|
|
403
|
+
if (!room) return;
|
|
404
|
+
for (const publication of room.localPublications.filter((p) => p.source === source)) {
|
|
405
|
+
await room.unpublish(publication.producerId);
|
|
406
|
+
}
|
|
407
|
+
}),
|
|
408
|
+
[room, exclusively],
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
publishCamera: useCallback(() => publish("camera"), [publish]),
|
|
413
|
+
publishMicrophone: useCallback(() => publish("microphone"), [publish]),
|
|
414
|
+
publishScreen: useCallback(() => publish("screen"), [publish]),
|
|
415
|
+
unpublish,
|
|
416
|
+
isCameraEnabled: has("camera"),
|
|
417
|
+
isMicrophoneEnabled: has("microphone"),
|
|
418
|
+
screenSharing: has("screen"),
|
|
419
|
+
pending,
|
|
420
|
+
error,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Stable empty values, so an unmounted or pre-connect room does not hand
|
|
425
|
+
* `useSyncExternalStore` a new array on every read and loop it. */
|
|
426
|
+
const EMPTY_TRACKS: readonly MediaTrack[] = [];
|
|
427
|
+
const EMPTY_PUBLICATIONS: MediaRoom["localPublications"] = [];
|
|
428
|
+
const EMPTY_STATE: RoomState = {
|
|
429
|
+
phase: "idle",
|
|
430
|
+
identity: null,
|
|
431
|
+
room: null,
|
|
432
|
+
peers: [],
|
|
433
|
+
producers: [],
|
|
434
|
+
activeSpeakers: [],
|
|
435
|
+
recording: false,
|
|
436
|
+
subscribeRule: null,
|
|
437
|
+
draining: null,
|
|
438
|
+
closedReason: null,
|
|
439
|
+
};
|