@conference-kit/react 0.0.1 → 0.0.2

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 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.1",
3
+ "version": "0.0.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -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(
@@ -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
- [autoReconnect, peerId, room, signalingUrl]
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 wsUrl = `${url}?peerId=${encodeURIComponent(peerId)}${
89
- room ? `&room=${encodeURIComponent(room)}` : ""
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
  }