@conference-kit/react 0.0.1 → 0.0.3
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 +75 -0
- package/package.json +2 -2
- package/src/config/features.ts +6 -0
- package/src/hooks/useMeshRoom.ts +170 -1
- package/src/signaling/SignalingClient.ts +34 -5
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# @conference-kit/react
|
|
2
|
+
|
|
3
|
+
React hooks and tiny UI primitives for building peer-to-peer calls on top of the `@conference-kit/core` peer and a signaling server.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @conference-kit/react
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peer deps: React 18+.
|
|
12
|
+
|
|
13
|
+
## Key hooks
|
|
14
|
+
|
|
15
|
+
- `useMeshRoom` — mesh everyone in a room; exposes roster, participants, local stream, raised hands, waiting-room/host state, and helpers like `requestStream`, `admitPeer`, `rejectPeer`, `raiseHand`, `leave`.
|
|
16
|
+
- `useCall` / `useCallState` — 1:1 call helper with local/remote streams and call lifecycle.
|
|
17
|
+
- `useDataChannel` / `useDataChannelMessages` — send/receive arbitrary data over an RTC data channel.
|
|
18
|
+
- `useMediaStream`, `useScreenShare`, `useWebRTC` — focused utilities for media capture and connection wiring.
|
|
19
|
+
|
|
20
|
+
## Components
|
|
21
|
+
|
|
22
|
+
- `VideoPlayer`, `AudioPlayer` — attach a `MediaStream` to media elements.
|
|
23
|
+
- `StatusBadge`, `ErrorBanner` — lightweight status UI.
|
|
24
|
+
|
|
25
|
+
## Feature flags
|
|
26
|
+
|
|
27
|
+
`mergeFeatures` and `defaultFeatures` let you toggle capabilities (data channel, screen share, waiting room, host controls, active speaker detection) when calling `useMeshRoom`.
|
|
28
|
+
|
|
29
|
+
## Quick mesh example
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { useMeshRoom, VideoPlayer } from "@conference-kit/react";
|
|
33
|
+
|
|
34
|
+
function Mesh({
|
|
35
|
+
peerId,
|
|
36
|
+
room,
|
|
37
|
+
signalingUrl,
|
|
38
|
+
}: {
|
|
39
|
+
peerId: string;
|
|
40
|
+
room: string;
|
|
41
|
+
signalingUrl: string;
|
|
42
|
+
}) {
|
|
43
|
+
const mesh = useMeshRoom({
|
|
44
|
+
peerId,
|
|
45
|
+
room,
|
|
46
|
+
signalingUrl,
|
|
47
|
+
isHost: false,
|
|
48
|
+
mediaConstraints: { audio: true, video: true },
|
|
49
|
+
features: { enableWaitingRoom: true, enableHostControls: true },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div>
|
|
54
|
+
<button onClick={() => mesh.requestStream()}>Request media</button>
|
|
55
|
+
<div>
|
|
56
|
+
{mesh.participants.map((p) => (
|
|
57
|
+
<VideoPlayer key={p.id} stream={p.remoteStream} muted={false} />
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Signaling client
|
|
66
|
+
|
|
67
|
+
`SignalingClient` is exposed if you need to build a custom hook or UI. It handles reconnects and emits presence, signal, broadcast, and control events.
|
|
68
|
+
|
|
69
|
+
## Build
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npm run build
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Outputs ESM and types to `dist/`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@conference-kit/react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"build": "tsc -p tsconfig.json"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@conference-kit/core": "^0.0.
|
|
17
|
+
"@conference-kit/core": "^0.0.3"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
20
|
"react": ">=18.2.0"
|
package/src/config/features.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
export type FeatureConfig = {
|
|
2
2
|
enableDataChannel?: boolean;
|
|
3
3
|
enableScreenShare?: boolean;
|
|
4
|
+
enableWaitingRoom?: boolean;
|
|
5
|
+
enableHostControls?: boolean;
|
|
6
|
+
enableActiveSpeaker?: boolean;
|
|
4
7
|
};
|
|
5
8
|
|
|
6
9
|
export const defaultFeatures: Required<FeatureConfig> = {
|
|
7
10
|
enableDataChannel: true,
|
|
8
11
|
enableScreenShare: true,
|
|
12
|
+
enableWaitingRoom: false,
|
|
13
|
+
enableHostControls: false,
|
|
14
|
+
enableActiveSpeaker: false,
|
|
9
15
|
};
|
|
10
16
|
|
|
11
17
|
export function mergeFeatures(
|
package/src/hooks/useMeshRoom.ts
CHANGED
|
@@ -16,6 +16,7 @@ export type UseMeshRoomOptions = {
|
|
|
16
16
|
peerId: string;
|
|
17
17
|
room: string;
|
|
18
18
|
signalingUrl: string;
|
|
19
|
+
isHost?: boolean;
|
|
19
20
|
mediaConstraints?: MediaStreamConstraints;
|
|
20
21
|
rtcConfig?: RTCConfiguration;
|
|
21
22
|
trickle?: boolean;
|
|
@@ -28,6 +29,7 @@ export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
|
28
29
|
peerId,
|
|
29
30
|
room,
|
|
30
31
|
signalingUrl,
|
|
32
|
+
isHost,
|
|
31
33
|
mediaConstraints,
|
|
32
34
|
rtcConfig,
|
|
33
35
|
trickle,
|
|
@@ -54,9 +56,18 @@ export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
|
54
56
|
url: signalingUrl,
|
|
55
57
|
peerId,
|
|
56
58
|
room,
|
|
59
|
+
isHost,
|
|
60
|
+
enableWaitingRoom: features.enableWaitingRoom,
|
|
57
61
|
autoReconnect,
|
|
58
62
|
}),
|
|
59
|
-
[
|
|
63
|
+
[
|
|
64
|
+
autoReconnect,
|
|
65
|
+
features.enableWaitingRoom,
|
|
66
|
+
isHost,
|
|
67
|
+
peerId,
|
|
68
|
+
room,
|
|
69
|
+
signalingUrl,
|
|
70
|
+
]
|
|
60
71
|
);
|
|
61
72
|
|
|
62
73
|
const peers = useRef<Map<string, Peer>>(new Map());
|
|
@@ -64,6 +75,12 @@ export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
|
64
75
|
const [roster, setRoster] = useState<string[]>([]);
|
|
65
76
|
const [participants, setParticipants] = useState<MeshParticipant[]>([]);
|
|
66
77
|
const [error, setError] = useState<Error | null>(null);
|
|
78
|
+
const [waitingList, setWaitingList] = useState<string[]>([]);
|
|
79
|
+
const [inWaitingRoom, setInWaitingRoom] = useState(
|
|
80
|
+
features.enableWaitingRoom && !isHost
|
|
81
|
+
);
|
|
82
|
+
const [activeSpeakerId, setActiveSpeakerId] = useState<string | null>(null);
|
|
83
|
+
const [raisedHands, setRaisedHands] = useState<Set<string>>(new Set());
|
|
67
84
|
const [signalingStatus, setSignalingStatus] = useState<
|
|
68
85
|
"idle" | "connecting" | "open" | "closed"
|
|
69
86
|
>("idle");
|
|
@@ -109,6 +126,7 @@ export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
|
109
126
|
const ensurePeer = useCallback(
|
|
110
127
|
(id: string, side?: PeerSide) => {
|
|
111
128
|
if (id === peerId) return null;
|
|
129
|
+
if (features.enableWaitingRoom && inWaitingRoom) return null;
|
|
112
130
|
const existing = peers.current.get(id);
|
|
113
131
|
if (existing) return existing;
|
|
114
132
|
// enableDataChannel is supported in the runtime PeerConfig but may lag in published typings; cast to satisfy TS.
|
|
@@ -203,6 +221,51 @@ export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
|
203
221
|
return () => signaling.off("presence", onPresence as any);
|
|
204
222
|
}, [destroyPeer, ensurePeer, peerId, signaling]);
|
|
205
223
|
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
const onControl = ({ action, data }: { action: string; data?: any }) => {
|
|
226
|
+
if (action === "waiting-list") {
|
|
227
|
+
const waiting = (data?.waiting as string[]) ?? [];
|
|
228
|
+
setWaitingList(waiting);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (action === "waiting") {
|
|
232
|
+
setInWaitingRoom(true);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (action === "admitted") {
|
|
236
|
+
setInWaitingRoom(false);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (action === "rejected") {
|
|
240
|
+
setInWaitingRoom(false);
|
|
241
|
+
setError(new Error("Rejected by host"));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (action === "raise-hand") {
|
|
245
|
+
const peer = (data?.peerId as string) ?? null;
|
|
246
|
+
if (!peer) return;
|
|
247
|
+
setRaisedHands((prev) => {
|
|
248
|
+
const next = new Set(prev);
|
|
249
|
+
next.add(peer);
|
|
250
|
+
return next;
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (action === "hand-lowered") {
|
|
255
|
+
const peer = (data?.peerId as string) ?? null;
|
|
256
|
+
if (!peer) return;
|
|
257
|
+
setRaisedHands((prev) => {
|
|
258
|
+
const next = new Set(prev);
|
|
259
|
+
next.delete(peer);
|
|
260
|
+
return next;
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
signaling.on("control", onControl as any);
|
|
266
|
+
return () => signaling.off("control", onControl as any);
|
|
267
|
+
}, [signaling]);
|
|
268
|
+
|
|
206
269
|
useEffect(() => {
|
|
207
270
|
const onSignal = ({ from, data }: { from: string; data: unknown }) => {
|
|
208
271
|
const peer = ensurePeer(from, sideForPeer(from));
|
|
@@ -217,10 +280,39 @@ export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
|
217
280
|
peers.current.clear();
|
|
218
281
|
setParticipants([]);
|
|
219
282
|
setRoster([]);
|
|
283
|
+
setWaitingList([]);
|
|
284
|
+
setInWaitingRoom(false);
|
|
285
|
+
setRaisedHands(new Set());
|
|
220
286
|
stopStream();
|
|
221
287
|
signaling.close();
|
|
222
288
|
}, [signaling, stopStream]);
|
|
223
289
|
|
|
290
|
+
const admitPeer = useCallback(
|
|
291
|
+
(id: string) => {
|
|
292
|
+
if (!features.enableWaitingRoom || !isHost) return;
|
|
293
|
+
signaling.sendControl("admit", { peerId: id });
|
|
294
|
+
},
|
|
295
|
+
[features.enableWaitingRoom, isHost, signaling]
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const rejectPeer = useCallback(
|
|
299
|
+
(id: string) => {
|
|
300
|
+
if (!features.enableWaitingRoom || !isHost) return;
|
|
301
|
+
signaling.sendControl("reject", { peerId: id });
|
|
302
|
+
},
|
|
303
|
+
[features.enableWaitingRoom, isHost, signaling]
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const raiseHand = useCallback(() => {
|
|
307
|
+
if (!features.enableHostControls) return;
|
|
308
|
+
signaling.sendControl("raise-hand", { peerId });
|
|
309
|
+
}, [features.enableHostControls, peerId, signaling]);
|
|
310
|
+
|
|
311
|
+
const lowerHand = useCallback(() => {
|
|
312
|
+
if (!features.enableHostControls) return;
|
|
313
|
+
signaling.sendControl("hand-lowered", { peerId });
|
|
314
|
+
}, [features.enableHostControls, peerId, signaling]);
|
|
315
|
+
|
|
224
316
|
useEffect(() => {
|
|
225
317
|
if (previousStream.current === localStream) return;
|
|
226
318
|
const prev = previousStream.current;
|
|
@@ -231,6 +323,75 @@ export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
|
231
323
|
previousStream.current = localStream ?? null;
|
|
232
324
|
}, [localStream]);
|
|
233
325
|
|
|
326
|
+
const analyzers = useRef<
|
|
327
|
+
Map<
|
|
328
|
+
string,
|
|
329
|
+
{
|
|
330
|
+
ctx: AudioContext;
|
|
331
|
+
analyser: AnalyserNode;
|
|
332
|
+
source: MediaStreamAudioSourceNode;
|
|
333
|
+
}
|
|
334
|
+
>
|
|
335
|
+
>(new Map());
|
|
336
|
+
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
if (!features.enableActiveSpeaker) return;
|
|
339
|
+
|
|
340
|
+
const ensureAnalyzer = (id: string, stream: MediaStream) => {
|
|
341
|
+
if (analyzers.current.has(id)) return;
|
|
342
|
+
const ctx = new AudioContext();
|
|
343
|
+
const source = ctx.createMediaStreamSource(stream);
|
|
344
|
+
const analyser = ctx.createAnalyser();
|
|
345
|
+
analyser.fftSize = 256;
|
|
346
|
+
source.connect(analyser);
|
|
347
|
+
analyzers.current.set(id, { ctx, analyser, source });
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
participants.forEach((p) => {
|
|
351
|
+
if (p.remoteStream) ensureAnalyzer(p.id, p.remoteStream);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const removedIds: string[] = [];
|
|
355
|
+
analyzers.current.forEach((_value, id) => {
|
|
356
|
+
const stillPresent = participants.find(
|
|
357
|
+
(p) => p.id === id && p.remoteStream
|
|
358
|
+
);
|
|
359
|
+
if (!stillPresent) {
|
|
360
|
+
const entry = analyzers.current.get(id);
|
|
361
|
+
entry?.source.disconnect();
|
|
362
|
+
entry?.analyser.disconnect();
|
|
363
|
+
entry?.ctx.close();
|
|
364
|
+
analyzers.current.delete(id);
|
|
365
|
+
removedIds.push(id);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const interval = window.setInterval(() => {
|
|
370
|
+
let loudestId: string | null = null;
|
|
371
|
+
let loudest = 0;
|
|
372
|
+
analyzers.current.forEach((entry, id) => {
|
|
373
|
+
const data = new Uint8Array(entry.analyser.frequencyBinCount);
|
|
374
|
+
entry.analyser.getByteFrequencyData(data);
|
|
375
|
+
const avg = data.reduce((acc, v) => acc + v, 0) / data.length;
|
|
376
|
+
if (avg > loudest) {
|
|
377
|
+
loudest = avg;
|
|
378
|
+
loudestId = avg > 20 ? id : null;
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
setActiveSpeakerId((prev) => (prev === loudestId ? prev : loudestId));
|
|
382
|
+
}, 500);
|
|
383
|
+
|
|
384
|
+
return () => {
|
|
385
|
+
window.clearInterval(interval);
|
|
386
|
+
analyzers.current.forEach((entry) => {
|
|
387
|
+
entry.source.disconnect();
|
|
388
|
+
entry.analyser.disconnect();
|
|
389
|
+
entry.ctx.close();
|
|
390
|
+
});
|
|
391
|
+
analyzers.current.clear();
|
|
392
|
+
};
|
|
393
|
+
}, [features.enableActiveSpeaker, participants]);
|
|
394
|
+
|
|
234
395
|
return {
|
|
235
396
|
localStream,
|
|
236
397
|
ready,
|
|
@@ -238,7 +399,15 @@ export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
|
238
399
|
mediaError,
|
|
239
400
|
participants,
|
|
240
401
|
roster,
|
|
402
|
+
waitingList,
|
|
403
|
+
inWaitingRoom,
|
|
404
|
+
activeSpeakerId,
|
|
405
|
+
raisedHands,
|
|
241
406
|
signalingStatus,
|
|
407
|
+
admitPeer,
|
|
408
|
+
rejectPeer,
|
|
409
|
+
raiseHand,
|
|
410
|
+
lowerHand,
|
|
242
411
|
requestStream,
|
|
243
412
|
stopStream,
|
|
244
413
|
leave,
|
|
@@ -4,6 +4,12 @@ export type EventMap = {
|
|
|
4
4
|
error: Error;
|
|
5
5
|
signal: { from: string; data: unknown };
|
|
6
6
|
broadcast: { from: string; room?: string | null; data: unknown };
|
|
7
|
+
control: {
|
|
8
|
+
action: string;
|
|
9
|
+
from: string;
|
|
10
|
+
room?: string | null;
|
|
11
|
+
data?: unknown;
|
|
12
|
+
};
|
|
7
13
|
presence: {
|
|
8
14
|
room?: string | null;
|
|
9
15
|
peerId: string;
|
|
@@ -42,6 +48,8 @@ export type SignalingClientOptions = {
|
|
|
42
48
|
url: string;
|
|
43
49
|
peerId: string;
|
|
44
50
|
room?: string | null;
|
|
51
|
+
isHost?: boolean;
|
|
52
|
+
enableWaitingRoom?: boolean;
|
|
45
53
|
autoReconnect?: boolean;
|
|
46
54
|
reconnectDelayMs?: number;
|
|
47
55
|
};
|
|
@@ -49,6 +57,13 @@ export type SignalingClientOptions = {
|
|
|
49
57
|
type IncomingMessage =
|
|
50
58
|
| { type: "signal"; from: string; data: unknown }
|
|
51
59
|
| { type: "broadcast"; from: string; room?: string | null; data: unknown }
|
|
60
|
+
| {
|
|
61
|
+
type: "control";
|
|
62
|
+
from: string;
|
|
63
|
+
room?: string | null;
|
|
64
|
+
action: string;
|
|
65
|
+
data?: unknown;
|
|
66
|
+
}
|
|
52
67
|
| {
|
|
53
68
|
type: "presence";
|
|
54
69
|
room?: string | null;
|
|
@@ -59,7 +74,8 @@ type IncomingMessage =
|
|
|
59
74
|
|
|
60
75
|
type OutgoingMessage =
|
|
61
76
|
| { type: "signal"; to: string; data: unknown }
|
|
62
|
-
| { type: "broadcast"; data: unknown }
|
|
77
|
+
| { type: "broadcast"; data: unknown }
|
|
78
|
+
| { type: "control"; action: string; data?: unknown };
|
|
63
79
|
|
|
64
80
|
export class SignalingClient {
|
|
65
81
|
private ws: WebSocket | null = null;
|
|
@@ -84,10 +100,12 @@ export class SignalingClient {
|
|
|
84
100
|
return;
|
|
85
101
|
}
|
|
86
102
|
|
|
87
|
-
const { url, peerId, room } = this.options;
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
103
|
+
const { url, peerId, room, isHost, enableWaitingRoom } = this.options;
|
|
104
|
+
const params = new URLSearchParams({ peerId });
|
|
105
|
+
if (room) params.set("room", room);
|
|
106
|
+
if (isHost) params.set("host", "1");
|
|
107
|
+
if (enableWaitingRoom) params.set("waitingRoom", "1");
|
|
108
|
+
const wsUrl = `${url}?${params.toString()}`;
|
|
91
109
|
this.ws = new WebSocket(wsUrl);
|
|
92
110
|
|
|
93
111
|
this.ws.addEventListener("open", () => {
|
|
@@ -114,6 +132,13 @@ export class SignalingClient {
|
|
|
114
132
|
peers: parsed.peers,
|
|
115
133
|
action: parsed.action,
|
|
116
134
|
});
|
|
135
|
+
} else if (parsed.type === "control") {
|
|
136
|
+
this.emitter.emit("control", {
|
|
137
|
+
from: parsed.from,
|
|
138
|
+
room: parsed.room,
|
|
139
|
+
action: parsed.action,
|
|
140
|
+
data: parsed.data,
|
|
141
|
+
});
|
|
117
142
|
}
|
|
118
143
|
} catch (error) {
|
|
119
144
|
this.emitter.emit("error", error as Error);
|
|
@@ -145,6 +170,10 @@ export class SignalingClient {
|
|
|
145
170
|
this.enqueue({ type: "broadcast", data });
|
|
146
171
|
}
|
|
147
172
|
|
|
173
|
+
sendControl(action: string, data?: unknown) {
|
|
174
|
+
this.enqueue({ type: "control", action, data });
|
|
175
|
+
}
|
|
176
|
+
|
|
148
177
|
on<K extends EventKey>(event: K, handler: Handler<K>) {
|
|
149
178
|
this.emitter.on(event, handler);
|
|
150
179
|
}
|