@conference-kit/react 0.0.1
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/package.json +26 -0
- package/src/components/AudioPlayer.tsx +23 -0
- package/src/components/ErrorBanner.tsx +20 -0
- package/src/components/StatusBadge.tsx +41 -0
- package/src/components/VideoPlayer.tsx +33 -0
- package/src/config/features.ts +15 -0
- package/src/context/WebRTCProvider.tsx +34 -0
- package/src/hooks/useCall.ts +239 -0
- package/src/hooks/useCallState.ts +55 -0
- package/src/hooks/useDataChannel.ts +49 -0
- package/src/hooks/useDataChannelMessages.ts +73 -0
- package/src/hooks/useMediaStream.ts +103 -0
- package/src/hooks/useMeshRoom.ts +247 -0
- package/src/hooks/useScreenShare.ts +46 -0
- package/src/hooks/useWebRTC.ts +128 -0
- package/src/index.ts +15 -0
- package/src/signaling/SignalingClient.ts +178 -0
- package/tsconfig.json +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@conference-kit/react",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc -p tsconfig.json"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@conference-kit/core": "^0.0.1"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"react": ">=18.2.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/react": "^18.3.27",
|
|
24
|
+
"typescript": "^5.9.3"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
export type AudioPlayerProps = React.AudioHTMLAttributes<HTMLAudioElement> & {
|
|
4
|
+
stream?: MediaStream | null;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function AudioPlayer({
|
|
8
|
+
stream,
|
|
9
|
+
autoPlay = true,
|
|
10
|
+
...props
|
|
11
|
+
}: AudioPlayerProps) {
|
|
12
|
+
const ref = useRef<HTMLAudioElement | null>(null);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!ref.current || !stream) return;
|
|
16
|
+
ref.current.srcObject = stream;
|
|
17
|
+
return () => {
|
|
18
|
+
if (ref.current) ref.current.srcObject = null;
|
|
19
|
+
};
|
|
20
|
+
}, [stream]);
|
|
21
|
+
|
|
22
|
+
return <audio ref={ref} autoPlay={autoPlay} {...props} />;
|
|
23
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
type ErrorBannerProps = {
|
|
2
|
+
message: string;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
export function ErrorBanner({ message }: ErrorBannerProps) {
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
style={{
|
|
9
|
+
background: "#fef2f2",
|
|
10
|
+
color: "#991b1b",
|
|
11
|
+
padding: "8px 12px",
|
|
12
|
+
borderRadius: 8,
|
|
13
|
+
border: "1px solid #fecdd3",
|
|
14
|
+
fontSize: 14,
|
|
15
|
+
}}
|
|
16
|
+
>
|
|
17
|
+
{message}
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
type StatusBadgeProps = {
|
|
2
|
+
label: string;
|
|
3
|
+
tone?: "neutral" | "success" | "warn" | "error";
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
const toneStyles: Record<NonNullable<StatusBadgeProps["tone"]>, string> = {
|
|
7
|
+
neutral: "#e5e7eb",
|
|
8
|
+
success: "#d1fae5",
|
|
9
|
+
warn: "#fef3c7",
|
|
10
|
+
error: "#fee2e2",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const toneText: Record<NonNullable<StatusBadgeProps["tone"]>, string> = {
|
|
14
|
+
neutral: "#111827",
|
|
15
|
+
success: "#065f46",
|
|
16
|
+
warn: "#92400e",
|
|
17
|
+
error: "#991b1b",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function StatusBadge({ label, tone = "neutral" }: StatusBadgeProps) {
|
|
21
|
+
const bg = toneStyles[tone];
|
|
22
|
+
const color = toneText[tone];
|
|
23
|
+
return (
|
|
24
|
+
<span
|
|
25
|
+
style={{
|
|
26
|
+
display: "inline-flex",
|
|
27
|
+
alignItems: "center",
|
|
28
|
+
padding: "4px 8px",
|
|
29
|
+
borderRadius: 999,
|
|
30
|
+
background: bg,
|
|
31
|
+
color,
|
|
32
|
+
fontSize: 12,
|
|
33
|
+
fontWeight: 600,
|
|
34
|
+
textTransform: "uppercase",
|
|
35
|
+
letterSpacing: 0.3,
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
{label}
|
|
39
|
+
</span>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
export type VideoPlayerProps = React.VideoHTMLAttributes<HTMLVideoElement> & {
|
|
4
|
+
stream?: MediaStream | null;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function VideoPlayer({
|
|
8
|
+
stream,
|
|
9
|
+
autoPlay = true,
|
|
10
|
+
playsInline = true,
|
|
11
|
+
muted,
|
|
12
|
+
...props
|
|
13
|
+
}: VideoPlayerProps) {
|
|
14
|
+
const ref = useRef<HTMLVideoElement | null>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!ref.current || !stream) return;
|
|
18
|
+
ref.current.srcObject = stream;
|
|
19
|
+
return () => {
|
|
20
|
+
if (ref.current) ref.current.srcObject = null;
|
|
21
|
+
};
|
|
22
|
+
}, [stream]);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<video
|
|
26
|
+
ref={ref}
|
|
27
|
+
autoPlay={autoPlay}
|
|
28
|
+
playsInline={playsInline}
|
|
29
|
+
muted={muted ?? stream == null}
|
|
30
|
+
{...props}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type FeatureConfig = {
|
|
2
|
+
enableDataChannel?: boolean;
|
|
3
|
+
enableScreenShare?: boolean;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export const defaultFeatures: Required<FeatureConfig> = {
|
|
7
|
+
enableDataChannel: true,
|
|
8
|
+
enableScreenShare: true,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function mergeFeatures(
|
|
12
|
+
features?: FeatureConfig
|
|
13
|
+
): Required<FeatureConfig> {
|
|
14
|
+
return { ...defaultFeatures, ...(features ?? {}) };
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import type { SignalData } from "@conference-kit/core";
|
|
3
|
+
|
|
4
|
+
export type WebRTCContextValue = {
|
|
5
|
+
onSignal?: (data: SignalData) => void;
|
|
6
|
+
config?: RTCConfiguration;
|
|
7
|
+
trickle?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const WebRTCContext = createContext<WebRTCContextValue | undefined>(undefined);
|
|
11
|
+
|
|
12
|
+
export type WebRTCProviderProps = WebRTCContextValue & {
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function WebRTCProvider({
|
|
17
|
+
children,
|
|
18
|
+
onSignal,
|
|
19
|
+
config,
|
|
20
|
+
trickle,
|
|
21
|
+
}: WebRTCProviderProps) {
|
|
22
|
+
return (
|
|
23
|
+
<WebRTCContext.Provider value={{ onSignal, config, trickle }}>
|
|
24
|
+
{children}
|
|
25
|
+
</WebRTCContext.Provider>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useWebRTCContext() {
|
|
30
|
+
const value = useContext(WebRTCContext);
|
|
31
|
+
if (!value)
|
|
32
|
+
throw new Error("useWebRTCContext must be used within a WebRTCProvider");
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { type PeerSide, type SignalData } from "@conference-kit/core";
|
|
3
|
+
import { useMediaStream } from "./useMediaStream";
|
|
4
|
+
import { useWebRTC } from "./useWebRTC";
|
|
5
|
+
import { SignalingClient } from "../signaling/SignalingClient";
|
|
6
|
+
|
|
7
|
+
type CallState = "idle" | "calling" | "ringing" | "connected" | "ended";
|
|
8
|
+
|
|
9
|
+
type UseCallOptions = {
|
|
10
|
+
peerId: string;
|
|
11
|
+
signalingUrl: string;
|
|
12
|
+
room?: string | null;
|
|
13
|
+
autoReconnect?: boolean;
|
|
14
|
+
mediaConstraints?: MediaStreamConstraints;
|
|
15
|
+
rtcConfig?: RTCConfiguration;
|
|
16
|
+
trickle?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type IncomingSignal = { from: string; data: SignalData };
|
|
20
|
+
|
|
21
|
+
export type ReturnTypeUseCall = ReturnType<typeof useCall>;
|
|
22
|
+
|
|
23
|
+
export function useCall(options: UseCallOptions) {
|
|
24
|
+
const {
|
|
25
|
+
peerId,
|
|
26
|
+
signalingUrl,
|
|
27
|
+
room,
|
|
28
|
+
autoReconnect = true,
|
|
29
|
+
mediaConstraints,
|
|
30
|
+
rtcConfig,
|
|
31
|
+
trickle,
|
|
32
|
+
} = options;
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
stream: localStream,
|
|
36
|
+
ready,
|
|
37
|
+
requesting,
|
|
38
|
+
error: mediaError,
|
|
39
|
+
requestStream,
|
|
40
|
+
stopStream,
|
|
41
|
+
} = useMediaStream({ constraints: mediaConstraints });
|
|
42
|
+
|
|
43
|
+
const [callState, setCallState] = useState<CallState>("idle");
|
|
44
|
+
const [targetId, setTargetId] = useState<string | null>(null);
|
|
45
|
+
const [side, setSide] = useState<PeerSide>("initiator");
|
|
46
|
+
const [callError, setCallError] = useState<Error | null>(null);
|
|
47
|
+
const pendingSignals = useRef<SignalData[]>([]);
|
|
48
|
+
const pendingOutbound = useRef<SignalData[]>([]);
|
|
49
|
+
|
|
50
|
+
const shouldEnablePeer = targetId !== null || callState !== "idle";
|
|
51
|
+
|
|
52
|
+
const signaling = useMemo(
|
|
53
|
+
() =>
|
|
54
|
+
new SignalingClient({
|
|
55
|
+
url: signalingUrl,
|
|
56
|
+
peerId,
|
|
57
|
+
room,
|
|
58
|
+
autoReconnect,
|
|
59
|
+
}),
|
|
60
|
+
[autoReconnect, peerId, room, signalingUrl]
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
signaling.connect();
|
|
65
|
+
return () => signaling.close();
|
|
66
|
+
}, [signaling]);
|
|
67
|
+
|
|
68
|
+
const {
|
|
69
|
+
peer,
|
|
70
|
+
remoteStream,
|
|
71
|
+
connectionState,
|
|
72
|
+
iceState,
|
|
73
|
+
error: webrtcError,
|
|
74
|
+
signal,
|
|
75
|
+
sendData,
|
|
76
|
+
destroy,
|
|
77
|
+
} = useWebRTC({
|
|
78
|
+
side,
|
|
79
|
+
stream: localStream ?? undefined,
|
|
80
|
+
config: rtcConfig,
|
|
81
|
+
trickle,
|
|
82
|
+
enabled: shouldEnablePeer,
|
|
83
|
+
onSignal: (data) => {
|
|
84
|
+
if (!targetId) {
|
|
85
|
+
pendingOutbound.current.push(data);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
signaling.sendSignal(targetId, data);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!webrtcError) return;
|
|
94
|
+
destroy();
|
|
95
|
+
setCallState("idle");
|
|
96
|
+
setTargetId(null);
|
|
97
|
+
pendingSignals.current = [];
|
|
98
|
+
pendingOutbound.current = [];
|
|
99
|
+
stopStream();
|
|
100
|
+
}, [destroy, stopStream, webrtcError]);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!targetId) return;
|
|
104
|
+
if (!pendingOutbound.current.length) return;
|
|
105
|
+
for (const msg of pendingOutbound.current) {
|
|
106
|
+
signaling.sendSignal(targetId, msg);
|
|
107
|
+
}
|
|
108
|
+
pendingOutbound.current = [];
|
|
109
|
+
}, [signaling, targetId]);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!peer) return;
|
|
113
|
+
if (pendingSignals.current.length === 0) return;
|
|
114
|
+
for (const msg of pendingSignals.current) {
|
|
115
|
+
void signal(msg);
|
|
116
|
+
}
|
|
117
|
+
pendingSignals.current = [];
|
|
118
|
+
}, [peer, signal]);
|
|
119
|
+
|
|
120
|
+
const handleIncoming = useCallback(
|
|
121
|
+
({ from, data }: IncomingSignal) => {
|
|
122
|
+
setTargetId((prev) => prev ?? from);
|
|
123
|
+
if (callState === "idle") setCallState("ringing");
|
|
124
|
+
if (callState === "ended") setCallState("ringing");
|
|
125
|
+
|
|
126
|
+
// If peer not ready yet, queue and ensure responder side
|
|
127
|
+
if (!peer) {
|
|
128
|
+
pendingSignals.current.push(data);
|
|
129
|
+
setSide("responder");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
void signal(data);
|
|
134
|
+
},
|
|
135
|
+
[callState, peer, signal]
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const onSignal = ({ from, data }: { from: string; data: unknown }) =>
|
|
140
|
+
handleIncoming({ from, data: data as SignalData });
|
|
141
|
+
signaling.on("signal", onSignal);
|
|
142
|
+
return () => signaling.off("signal", onSignal);
|
|
143
|
+
}, [handleIncoming, signaling]);
|
|
144
|
+
|
|
145
|
+
const call = useCallback(
|
|
146
|
+
async (to: string) => {
|
|
147
|
+
setTargetId(to);
|
|
148
|
+
setSide("initiator");
|
|
149
|
+
setCallState("calling");
|
|
150
|
+
try {
|
|
151
|
+
if (!ready && !requesting) {
|
|
152
|
+
await requestStream();
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
setCallError(error as Error);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
[ready, requesting, requestStream]
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const answer = useCallback(async () => {
|
|
162
|
+
setSide("responder");
|
|
163
|
+
setCallState("connected");
|
|
164
|
+
try {
|
|
165
|
+
if (!ready && !requesting) {
|
|
166
|
+
await requestStream();
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
setCallError(error as Error);
|
|
170
|
+
}
|
|
171
|
+
}, [ready, requesting, requestStream]);
|
|
172
|
+
|
|
173
|
+
const hangUp = useCallback(() => {
|
|
174
|
+
destroy();
|
|
175
|
+
setCallState("idle");
|
|
176
|
+
setTargetId(null);
|
|
177
|
+
stopStream();
|
|
178
|
+
}, [destroy, stopStream]);
|
|
179
|
+
|
|
180
|
+
const reset = useCallback(() => {
|
|
181
|
+
destroy();
|
|
182
|
+
pendingSignals.current = [];
|
|
183
|
+
pendingOutbound.current = [];
|
|
184
|
+
setCallState("idle");
|
|
185
|
+
setTargetId(null);
|
|
186
|
+
setSide("initiator");
|
|
187
|
+
setCallError(null);
|
|
188
|
+
stopStream();
|
|
189
|
+
}, [destroy, stopStream]);
|
|
190
|
+
|
|
191
|
+
const muteAudio = useCallback(
|
|
192
|
+
(muted: boolean) => {
|
|
193
|
+
localStream?.getAudioTracks().forEach((track) => {
|
|
194
|
+
track.enabled = !muted;
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
[localStream]
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const muteVideo = useCallback(
|
|
201
|
+
(muted: boolean) => {
|
|
202
|
+
localStream?.getVideoTracks().forEach((track) => {
|
|
203
|
+
track.enabled = !muted;
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
[localStream]
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const currentError = callError ?? webrtcError ?? mediaError ?? null;
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
peer,
|
|
213
|
+
callState,
|
|
214
|
+
connectionState,
|
|
215
|
+
iceState,
|
|
216
|
+
localStream,
|
|
217
|
+
remoteStream,
|
|
218
|
+
ready,
|
|
219
|
+
requesting,
|
|
220
|
+
error: currentError,
|
|
221
|
+
call,
|
|
222
|
+
answer,
|
|
223
|
+
hangUp,
|
|
224
|
+
reset,
|
|
225
|
+
muteAudio,
|
|
226
|
+
muteVideo,
|
|
227
|
+
sendData: useCallback(
|
|
228
|
+
(payload: string | ArrayBufferView | ArrayBuffer | Blob) => {
|
|
229
|
+
try {
|
|
230
|
+
if (!peer) return;
|
|
231
|
+
peer.send(payload);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
setCallError(error as Error);
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
[peer]
|
|
237
|
+
),
|
|
238
|
+
} as const;
|
|
239
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { ReturnTypeUseCall } from "./useCall";
|
|
3
|
+
|
|
4
|
+
type UseCallStateOptions = {
|
|
5
|
+
hasTarget?: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type CallStateView = {
|
|
9
|
+
callLabel: string;
|
|
10
|
+
connLabel: string;
|
|
11
|
+
iceLabel: string;
|
|
12
|
+
canCall: boolean;
|
|
13
|
+
canAnswer: boolean;
|
|
14
|
+
canHangUp: boolean;
|
|
15
|
+
canReset: boolean;
|
|
16
|
+
inCall: boolean;
|
|
17
|
+
errorMessage: string | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function useCallState(
|
|
21
|
+
call: ReturnTypeUseCall,
|
|
22
|
+
options: UseCallStateOptions = {}
|
|
23
|
+
): CallStateView {
|
|
24
|
+
const hasTarget = options.hasTarget ?? false;
|
|
25
|
+
|
|
26
|
+
return useMemo(() => {
|
|
27
|
+
const callLabel = call.callState;
|
|
28
|
+
const connLabel = call.connectionState;
|
|
29
|
+
const iceLabel = call.iceState;
|
|
30
|
+
const canCall = hasTarget && call.callState === "idle";
|
|
31
|
+
const canAnswer = call.callState === "ringing";
|
|
32
|
+
const canHangUp = call.callState !== "idle";
|
|
33
|
+
const canReset = true;
|
|
34
|
+
const inCall = call.callState === "connected";
|
|
35
|
+
const errorMessage = call.error?.message ?? null;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
callLabel,
|
|
39
|
+
connLabel,
|
|
40
|
+
iceLabel,
|
|
41
|
+
canCall,
|
|
42
|
+
canAnswer,
|
|
43
|
+
canHangUp,
|
|
44
|
+
canReset,
|
|
45
|
+
inCall,
|
|
46
|
+
errorMessage,
|
|
47
|
+
};
|
|
48
|
+
}, [
|
|
49
|
+
call.callState,
|
|
50
|
+
call.connectionState,
|
|
51
|
+
call.error,
|
|
52
|
+
call.iceState,
|
|
53
|
+
hasTarget,
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import type { Peer } from "@conference-kit/core";
|
|
3
|
+
|
|
4
|
+
export type UseDataChannelState = {
|
|
5
|
+
ready: boolean;
|
|
6
|
+
lastMessage: unknown;
|
|
7
|
+
error: Error | null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function useDataChannel(peer: Peer | null) {
|
|
11
|
+
const [state, setState] = useState<UseDataChannelState>({
|
|
12
|
+
ready: false,
|
|
13
|
+
lastMessage: null,
|
|
14
|
+
error: null,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!peer) return;
|
|
19
|
+
|
|
20
|
+
const handleConnect = () => setState((prev) => ({ ...prev, ready: true }));
|
|
21
|
+
const handleData = (data: unknown) =>
|
|
22
|
+
setState((prev) => ({ ...prev, lastMessage: data }));
|
|
23
|
+
const handleError = (error: Error) =>
|
|
24
|
+
setState((prev) => ({ ...prev, error }));
|
|
25
|
+
const handleClose = () => setState((prev) => ({ ...prev, ready: false }));
|
|
26
|
+
|
|
27
|
+
peer.on("connect", handleConnect);
|
|
28
|
+
peer.on("data", handleData as any);
|
|
29
|
+
peer.on("error", handleError);
|
|
30
|
+
peer.on("close", handleClose);
|
|
31
|
+
|
|
32
|
+
return () => {
|
|
33
|
+
peer.off("connect", handleConnect);
|
|
34
|
+
peer.off("data", handleData as any);
|
|
35
|
+
peer.off("error", handleError);
|
|
36
|
+
peer.off("close", handleClose);
|
|
37
|
+
};
|
|
38
|
+
}, [peer]);
|
|
39
|
+
|
|
40
|
+
const send = useCallback(
|
|
41
|
+
(payload: string | ArrayBufferView | ArrayBuffer | Blob) => {
|
|
42
|
+
if (!peer) throw new Error("Peer not ready");
|
|
43
|
+
peer.send(payload);
|
|
44
|
+
},
|
|
45
|
+
[peer]
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return { ...state, send } as const;
|
|
49
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, useCallback } from "react";
|
|
2
|
+
import type { Peer } from "@conference-kit/core";
|
|
3
|
+
import { useDataChannel } from "./useDataChannel";
|
|
4
|
+
|
|
5
|
+
const makeId = () => {
|
|
6
|
+
if (
|
|
7
|
+
typeof crypto !== "undefined" &&
|
|
8
|
+
typeof crypto.randomUUID === "function"
|
|
9
|
+
) {
|
|
10
|
+
return crypto.randomUUID();
|
|
11
|
+
}
|
|
12
|
+
const rand = Math.random().toString(36).slice(2, 10);
|
|
13
|
+
const time = Date.now().toString(36);
|
|
14
|
+
return `${time}-${rand}`;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Options = {
|
|
18
|
+
limit?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type MessageEntry = {
|
|
22
|
+
id: string;
|
|
23
|
+
direction: "in" | "out";
|
|
24
|
+
payload: unknown;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function useDataChannelMessages(peer: Peer | null, options?: Options) {
|
|
28
|
+
const limit = options?.limit ?? 50;
|
|
29
|
+
const channel = useDataChannel(peer);
|
|
30
|
+
const [messages, setMessages] = useState<MessageEntry[]>([]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (channel.lastMessage === null || channel.lastMessage === undefined)
|
|
34
|
+
return;
|
|
35
|
+
setMessages((prev) => {
|
|
36
|
+
const next = [
|
|
37
|
+
{
|
|
38
|
+
id: makeId(),
|
|
39
|
+
direction: "in" as const,
|
|
40
|
+
payload: channel.lastMessage,
|
|
41
|
+
},
|
|
42
|
+
...prev,
|
|
43
|
+
];
|
|
44
|
+
return next.slice(0, limit);
|
|
45
|
+
});
|
|
46
|
+
}, [channel.lastMessage, limit]);
|
|
47
|
+
|
|
48
|
+
const sendMessage = useCallback(
|
|
49
|
+
(payload: string | ArrayBufferView | ArrayBuffer | Blob) => {
|
|
50
|
+
channel.send(payload);
|
|
51
|
+
setMessages((prev) => {
|
|
52
|
+
const next = [
|
|
53
|
+
{ id: makeId(), direction: "out" as const, payload },
|
|
54
|
+
...prev,
|
|
55
|
+
];
|
|
56
|
+
return next.slice(0, limit);
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
[channel, limit]
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const state = useMemo(
|
|
63
|
+
() => ({
|
|
64
|
+
ready: channel.ready,
|
|
65
|
+
error: channel.error,
|
|
66
|
+
messages,
|
|
67
|
+
sendMessage,
|
|
68
|
+
}),
|
|
69
|
+
[channel.error, channel.ready, messages, sendMessage]
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return state;
|
|
73
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
type UseMediaStreamState = {
|
|
4
|
+
stream: MediaStream | null;
|
|
5
|
+
ready: boolean;
|
|
6
|
+
requesting: boolean;
|
|
7
|
+
error: Error | null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type UseMediaStreamOptions = {
|
|
11
|
+
constraints?: MediaStreamConstraints;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function useMediaStream(options?: UseMediaStreamOptions) {
|
|
15
|
+
const { constraints = { audio: true, video: true } } = options || {};
|
|
16
|
+
const [state, setState] = useState<UseMediaStreamState>({
|
|
17
|
+
stream: null,
|
|
18
|
+
ready: false,
|
|
19
|
+
requesting: false,
|
|
20
|
+
error: null,
|
|
21
|
+
});
|
|
22
|
+
const mounted = useRef(true);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
return () => {
|
|
26
|
+
mounted.current = false;
|
|
27
|
+
if (state.stream) {
|
|
28
|
+
state.stream.getTracks().forEach((track) => track.stop());
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
const stopStream = useCallback(() => {
|
|
35
|
+
setState((prev) => {
|
|
36
|
+
prev.stream?.getTracks().forEach((t) => t.stop());
|
|
37
|
+
return { ...prev, stream: null, ready: false };
|
|
38
|
+
});
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
const requestStream = useCallback(async () => {
|
|
42
|
+
const wantsAudio = Boolean(constraints && constraints.audio);
|
|
43
|
+
const wantsVideo = Boolean(constraints && constraints.video);
|
|
44
|
+
|
|
45
|
+
if (!wantsAudio && !wantsVideo) {
|
|
46
|
+
setState((prev) => ({
|
|
47
|
+
...prev,
|
|
48
|
+
stream: null,
|
|
49
|
+
ready: true,
|
|
50
|
+
requesting: false,
|
|
51
|
+
error: null,
|
|
52
|
+
}));
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (typeof window !== "undefined") {
|
|
57
|
+
const host = window.location.hostname;
|
|
58
|
+
const isLocal =
|
|
59
|
+
host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
60
|
+
const isSecure = window.isSecureContext;
|
|
61
|
+
if (!isSecure && !isLocal) {
|
|
62
|
+
setState((prev) => ({
|
|
63
|
+
...prev,
|
|
64
|
+
error: new Error(
|
|
65
|
+
"Media devices require a secure origin (https) or localhost. Use https or the Chrome flag --unsafely-treat-insecure-origin-as-secure."
|
|
66
|
+
),
|
|
67
|
+
}));
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (
|
|
73
|
+
typeof navigator === "undefined" ||
|
|
74
|
+
!navigator.mediaDevices?.getUserMedia
|
|
75
|
+
) {
|
|
76
|
+
setState((prev) => ({
|
|
77
|
+
...prev,
|
|
78
|
+
error: new Error("Media devices are not available in this environment"),
|
|
79
|
+
}));
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
setState((prev) => ({ ...prev, requesting: true, error: null }));
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
87
|
+
if (!mounted.current) return null;
|
|
88
|
+
setState({ stream, ready: true, requesting: false, error: null });
|
|
89
|
+
return stream;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (!mounted.current) return null;
|
|
92
|
+
setState({
|
|
93
|
+
stream: null,
|
|
94
|
+
ready: false,
|
|
95
|
+
requesting: false,
|
|
96
|
+
error: error as Error,
|
|
97
|
+
});
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}, [constraints]);
|
|
101
|
+
|
|
102
|
+
return { ...state, requestStream, stopStream } as const;
|
|
103
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Peer, type PeerSide, type SignalData } from "@conference-kit/core";
|
|
3
|
+
import { mergeFeatures, type FeatureConfig } from "../config/features";
|
|
4
|
+
import { useMediaStream } from "./useMediaStream";
|
|
5
|
+
import { SignalingClient } from "../signaling/SignalingClient";
|
|
6
|
+
|
|
7
|
+
export type MeshParticipant = {
|
|
8
|
+
id: string;
|
|
9
|
+
peer: Peer;
|
|
10
|
+
remoteStream: MediaStream | null;
|
|
11
|
+
connectionState: RTCPeerConnectionState;
|
|
12
|
+
iceState: RTCIceConnectionState;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type UseMeshRoomOptions = {
|
|
16
|
+
peerId: string;
|
|
17
|
+
room: string;
|
|
18
|
+
signalingUrl: string;
|
|
19
|
+
mediaConstraints?: MediaStreamConstraints;
|
|
20
|
+
rtcConfig?: RTCConfiguration;
|
|
21
|
+
trickle?: boolean;
|
|
22
|
+
autoReconnect?: boolean;
|
|
23
|
+
features?: FeatureConfig;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
27
|
+
const {
|
|
28
|
+
peerId,
|
|
29
|
+
room,
|
|
30
|
+
signalingUrl,
|
|
31
|
+
mediaConstraints,
|
|
32
|
+
rtcConfig,
|
|
33
|
+
trickle,
|
|
34
|
+
autoReconnect,
|
|
35
|
+
} = options;
|
|
36
|
+
|
|
37
|
+
const features = useMemo(
|
|
38
|
+
() => mergeFeatures(options.features),
|
|
39
|
+
[options.features]
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const {
|
|
43
|
+
stream: localStream,
|
|
44
|
+
ready,
|
|
45
|
+
requesting,
|
|
46
|
+
error: mediaError,
|
|
47
|
+
requestStream,
|
|
48
|
+
stopStream,
|
|
49
|
+
} = useMediaStream({ constraints: mediaConstraints });
|
|
50
|
+
|
|
51
|
+
const signaling = useMemo(
|
|
52
|
+
() =>
|
|
53
|
+
new SignalingClient({
|
|
54
|
+
url: signalingUrl,
|
|
55
|
+
peerId,
|
|
56
|
+
room,
|
|
57
|
+
autoReconnect,
|
|
58
|
+
}),
|
|
59
|
+
[autoReconnect, peerId, room, signalingUrl]
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const peers = useRef<Map<string, Peer>>(new Map());
|
|
63
|
+
const previousStream = useRef<MediaStream | null>(null);
|
|
64
|
+
const [roster, setRoster] = useState<string[]>([]);
|
|
65
|
+
const [participants, setParticipants] = useState<MeshParticipant[]>([]);
|
|
66
|
+
const [error, setError] = useState<Error | null>(null);
|
|
67
|
+
const [signalingStatus, setSignalingStatus] = useState<
|
|
68
|
+
"idle" | "connecting" | "open" | "closed"
|
|
69
|
+
>("idle");
|
|
70
|
+
|
|
71
|
+
const sideForPeer = useCallback(
|
|
72
|
+
(otherId: string): PeerSide => {
|
|
73
|
+
return peerId > otherId ? "initiator" : "responder";
|
|
74
|
+
},
|
|
75
|
+
[peerId]
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const destroyPeer = useCallback((id: string) => {
|
|
79
|
+
const peer = peers.current.get(id);
|
|
80
|
+
if (peer) {
|
|
81
|
+
peer.destroy();
|
|
82
|
+
peers.current.delete(id);
|
|
83
|
+
}
|
|
84
|
+
setParticipants((prev) => prev.filter((p) => p.id !== id));
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const upsertParticipant = useCallback(
|
|
88
|
+
(id: string, patch: Partial<MeshParticipant>) => {
|
|
89
|
+
setParticipants((prev) => {
|
|
90
|
+
const existing = prev.find((p) => p.id === id);
|
|
91
|
+
if (!existing) {
|
|
92
|
+
return [
|
|
93
|
+
...prev,
|
|
94
|
+
{
|
|
95
|
+
id,
|
|
96
|
+
peer: patch.peer as MeshParticipant["peer"],
|
|
97
|
+
remoteStream: patch.remoteStream ?? null,
|
|
98
|
+
connectionState: patch.connectionState ?? "new",
|
|
99
|
+
iceState: patch.iceState ?? "new",
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
return prev.map((p) => (p.id === id ? { ...p, ...patch } : p));
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
[]
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const ensurePeer = useCallback(
|
|
110
|
+
(id: string, side?: PeerSide) => {
|
|
111
|
+
if (id === peerId) return null;
|
|
112
|
+
const existing = peers.current.get(id);
|
|
113
|
+
if (existing) return existing;
|
|
114
|
+
// enableDataChannel is supported in the runtime PeerConfig but may lag in published typings; cast to satisfy TS.
|
|
115
|
+
const peerConfig: any = {
|
|
116
|
+
side: side ?? sideForPeer(id),
|
|
117
|
+
stream: localStream ?? undefined,
|
|
118
|
+
config: rtcConfig,
|
|
119
|
+
trickle,
|
|
120
|
+
enableDataChannel: features.enableDataChannel,
|
|
121
|
+
};
|
|
122
|
+
const peer = new Peer(peerConfig);
|
|
123
|
+
peers.current.set(id, peer);
|
|
124
|
+
upsertParticipant(id, {
|
|
125
|
+
peer,
|
|
126
|
+
remoteStream: null,
|
|
127
|
+
connectionState: "new",
|
|
128
|
+
iceState: "new",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const handlers = {
|
|
132
|
+
signal: (data: SignalData) => signaling.sendSignal(id, data),
|
|
133
|
+
stream: (remote: MediaStream) =>
|
|
134
|
+
upsertParticipant(id, { remoteStream: remote }),
|
|
135
|
+
error: (err: Error) => setError(err),
|
|
136
|
+
connectionStateChange: (state: RTCPeerConnectionState) =>
|
|
137
|
+
upsertParticipant(id, { connectionState: state }),
|
|
138
|
+
iceStateChange: (state: RTCIceConnectionState) =>
|
|
139
|
+
upsertParticipant(id, { iceState: state }),
|
|
140
|
+
close: () => destroyPeer(id),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
peer.on("signal", handlers.signal);
|
|
144
|
+
peer.on("stream", handlers.stream);
|
|
145
|
+
peer.on("error", handlers.error);
|
|
146
|
+
peer.on("connectionStateChange", handlers.connectionStateChange);
|
|
147
|
+
peer.on("iceStateChange", handlers.iceStateChange);
|
|
148
|
+
peer.on("close", handlers.close);
|
|
149
|
+
|
|
150
|
+
return peer;
|
|
151
|
+
},
|
|
152
|
+
[
|
|
153
|
+
destroyPeer,
|
|
154
|
+
localStream,
|
|
155
|
+
peerId,
|
|
156
|
+
rtcConfig,
|
|
157
|
+
sideForPeer,
|
|
158
|
+
signaling,
|
|
159
|
+
trickle,
|
|
160
|
+
upsertParticipant,
|
|
161
|
+
]
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
setSignalingStatus("connecting");
|
|
166
|
+
signaling.connect();
|
|
167
|
+
return () => signaling.close();
|
|
168
|
+
}, [signaling]);
|
|
169
|
+
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
const handleOpen = () => setSignalingStatus("open");
|
|
172
|
+
const handleClose = () => setSignalingStatus("closed");
|
|
173
|
+
const handleError = (err: Error) => setError(err);
|
|
174
|
+
|
|
175
|
+
signaling.on("open", handleOpen as any);
|
|
176
|
+
signaling.on("close", handleClose as any);
|
|
177
|
+
signaling.on("error", handleError as any);
|
|
178
|
+
|
|
179
|
+
return () => {
|
|
180
|
+
signaling.off("open", handleOpen as any);
|
|
181
|
+
signaling.off("close", handleClose as any);
|
|
182
|
+
signaling.off("error", handleError as any);
|
|
183
|
+
};
|
|
184
|
+
}, [signaling]);
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
const onPresence = (payload: {
|
|
188
|
+
peers: string[];
|
|
189
|
+
peerId: string;
|
|
190
|
+
room?: string | null;
|
|
191
|
+
action: "join" | "leave";
|
|
192
|
+
}) => {
|
|
193
|
+
const ids = payload.peers;
|
|
194
|
+
setRoster(ids);
|
|
195
|
+
ids.filter((id) => id !== peerId).forEach((id) => ensurePeer(id));
|
|
196
|
+
// Remove peers no longer present
|
|
197
|
+
setParticipants((prev) => prev.filter((p) => ids.includes(p.id)));
|
|
198
|
+
Array.from(peers.current.keys()).forEach((id) => {
|
|
199
|
+
if (!ids.includes(id)) destroyPeer(id);
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
signaling.on("presence", onPresence as any);
|
|
203
|
+
return () => signaling.off("presence", onPresence as any);
|
|
204
|
+
}, [destroyPeer, ensurePeer, peerId, signaling]);
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
const onSignal = ({ from, data }: { from: string; data: unknown }) => {
|
|
208
|
+
const peer = ensurePeer(from, sideForPeer(from));
|
|
209
|
+
void peer?.signal(data as SignalData);
|
|
210
|
+
};
|
|
211
|
+
signaling.on("signal", onSignal as any);
|
|
212
|
+
return () => signaling.off("signal", onSignal as any);
|
|
213
|
+
}, [ensurePeer, signaling, sideForPeer]);
|
|
214
|
+
|
|
215
|
+
const leave = useCallback(() => {
|
|
216
|
+
Array.from(peers.current.values()).forEach((p) => p.destroy());
|
|
217
|
+
peers.current.clear();
|
|
218
|
+
setParticipants([]);
|
|
219
|
+
setRoster([]);
|
|
220
|
+
stopStream();
|
|
221
|
+
signaling.close();
|
|
222
|
+
}, [signaling, stopStream]);
|
|
223
|
+
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (previousStream.current === localStream) return;
|
|
226
|
+
const prev = previousStream.current;
|
|
227
|
+
peers.current.forEach((peer) => {
|
|
228
|
+
if (prev) peer.removeStream(prev);
|
|
229
|
+
if (localStream) peer.addStream(localStream);
|
|
230
|
+
});
|
|
231
|
+
previousStream.current = localStream ?? null;
|
|
232
|
+
}, [localStream]);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
localStream,
|
|
236
|
+
ready,
|
|
237
|
+
requesting,
|
|
238
|
+
mediaError,
|
|
239
|
+
participants,
|
|
240
|
+
roster,
|
|
241
|
+
signalingStatus,
|
|
242
|
+
requestStream,
|
|
243
|
+
stopStream,
|
|
244
|
+
leave,
|
|
245
|
+
error,
|
|
246
|
+
} as const;
|
|
247
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useCallback, useState, useRef, useEffect } from "react";
|
|
2
|
+
import { mergeFeatures, type FeatureConfig } from "../config/features";
|
|
3
|
+
|
|
4
|
+
export function useScreenShare(options?: FeatureConfig) {
|
|
5
|
+
const features = mergeFeatures(options);
|
|
6
|
+
const [stream, setStream] = useState<MediaStream | null>(null);
|
|
7
|
+
const [error, setError] = useState<Error | null>(null);
|
|
8
|
+
const active = useRef<MediaStream | null>(null);
|
|
9
|
+
|
|
10
|
+
const start = useCallback(async () => {
|
|
11
|
+
if (!features.enableScreenShare) {
|
|
12
|
+
setError(new Error("Screen share is disabled by configuration"));
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
if (
|
|
16
|
+
typeof navigator === "undefined" ||
|
|
17
|
+
!navigator.mediaDevices?.getDisplayMedia
|
|
18
|
+
) {
|
|
19
|
+
setError(new Error("Screen share is not supported in this environment"));
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const displayStream = await navigator.mediaDevices.getDisplayMedia({
|
|
24
|
+
video: true,
|
|
25
|
+
audio: false,
|
|
26
|
+
});
|
|
27
|
+
active.current = displayStream;
|
|
28
|
+
setStream(displayStream);
|
|
29
|
+
setError(null);
|
|
30
|
+
return displayStream;
|
|
31
|
+
} catch (err) {
|
|
32
|
+
setError(err as Error);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}, [features.enableScreenShare]);
|
|
36
|
+
|
|
37
|
+
const stop = useCallback(() => {
|
|
38
|
+
active.current?.getTracks().forEach((t) => t.stop());
|
|
39
|
+
active.current = null;
|
|
40
|
+
setStream(null);
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
useEffect(() => () => stop(), [stop]);
|
|
44
|
+
|
|
45
|
+
return { stream, start, stop, error } as const;
|
|
46
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Peer, type PeerSide, type SignalData } from "@conference-kit/core";
|
|
3
|
+
|
|
4
|
+
export type UseWebRTCOptions = {
|
|
5
|
+
side: PeerSide;
|
|
6
|
+
stream?: MediaStream | null;
|
|
7
|
+
config?: RTCConfiguration;
|
|
8
|
+
channelLabel?: string;
|
|
9
|
+
trickle?: boolean;
|
|
10
|
+
onSignal?: (data: SignalData) => void;
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function useWebRTC(options: UseWebRTCOptions) {
|
|
15
|
+
const {
|
|
16
|
+
side,
|
|
17
|
+
stream,
|
|
18
|
+
config,
|
|
19
|
+
channelLabel,
|
|
20
|
+
trickle = true,
|
|
21
|
+
onSignal,
|
|
22
|
+
enabled = true,
|
|
23
|
+
} = options;
|
|
24
|
+
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
|
|
25
|
+
const [connectionState, setConnectionState] =
|
|
26
|
+
useState<RTCPeerConnectionState>("new");
|
|
27
|
+
const [iceState, setIceState] = useState<RTCIceConnectionState>("new");
|
|
28
|
+
const [error, setError] = useState<Error | null>(null);
|
|
29
|
+
const [peerInstance, setPeerInstance] = useState<Peer | null>(null);
|
|
30
|
+
const peerRef = useRef<Peer | null>(null);
|
|
31
|
+
const creationFailed = useRef(false);
|
|
32
|
+
const onSignalRef = useRef<typeof onSignal>();
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
onSignalRef.current = onSignal;
|
|
36
|
+
}, [onSignal]);
|
|
37
|
+
|
|
38
|
+
const isClient = useMemo(() => typeof window !== "undefined", []);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!isClient || !enabled) {
|
|
42
|
+
peerRef.current?.destroy();
|
|
43
|
+
peerRef.current = null;
|
|
44
|
+
setPeerInstance(null);
|
|
45
|
+
creationFailed.current = false;
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (peerRef.current || creationFailed.current) {
|
|
50
|
+
return () => undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const peer = new Peer({
|
|
55
|
+
side,
|
|
56
|
+
stream: stream ?? undefined,
|
|
57
|
+
config,
|
|
58
|
+
channelLabel,
|
|
59
|
+
trickle,
|
|
60
|
+
});
|
|
61
|
+
peerRef.current = peer;
|
|
62
|
+
setPeerInstance(peer);
|
|
63
|
+
setError(null);
|
|
64
|
+
|
|
65
|
+
const handlers = {
|
|
66
|
+
signal: (data: SignalData) => onSignalRef.current?.(data),
|
|
67
|
+
stream: (remote: MediaStream) => setRemoteStream(remote),
|
|
68
|
+
error: (err: Error) => setError(err),
|
|
69
|
+
connectionStateChange: (state: RTCPeerConnectionState) =>
|
|
70
|
+
setConnectionState(state),
|
|
71
|
+
iceStateChange: (state: RTCIceConnectionState) => setIceState(state),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
peer.on("signal", handlers.signal);
|
|
75
|
+
peer.on("stream", handlers.stream);
|
|
76
|
+
peer.on("error", handlers.error);
|
|
77
|
+
peer.on("connectionStateChange", handlers.connectionStateChange);
|
|
78
|
+
peer.on("iceStateChange", handlers.iceStateChange);
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
peer.off("signal", handlers.signal);
|
|
82
|
+
peer.off("stream", handlers.stream);
|
|
83
|
+
peer.off("error", handlers.error);
|
|
84
|
+
peer.off("connectionStateChange", handlers.connectionStateChange);
|
|
85
|
+
peer.off("iceStateChange", handlers.iceStateChange);
|
|
86
|
+
peer.destroy();
|
|
87
|
+
peerRef.current = null;
|
|
88
|
+
setPeerInstance(null);
|
|
89
|
+
creationFailed.current = false;
|
|
90
|
+
};
|
|
91
|
+
} catch (err) {
|
|
92
|
+
creationFailed.current = true;
|
|
93
|
+
setError(err as Error);
|
|
94
|
+
return () => undefined;
|
|
95
|
+
}
|
|
96
|
+
}, [channelLabel, config, enabled, isClient, side, stream, trickle]);
|
|
97
|
+
|
|
98
|
+
const signal = useCallback(async (data: SignalData) => {
|
|
99
|
+
if (!peerRef.current) return;
|
|
100
|
+
await peerRef.current.signal(data);
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
const sendData = useCallback(
|
|
104
|
+
(payload: string | ArrayBufferView | ArrayBuffer | Blob) => {
|
|
105
|
+
if (!peerRef.current) throw new Error("Peer not ready");
|
|
106
|
+
peerRef.current.send(payload);
|
|
107
|
+
},
|
|
108
|
+
[]
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const destroy = useCallback(() => {
|
|
112
|
+
peerRef.current?.destroy();
|
|
113
|
+
peerRef.current = null;
|
|
114
|
+
creationFailed.current = false;
|
|
115
|
+
setPeerInstance(null);
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
peer: peerInstance,
|
|
120
|
+
remoteStream,
|
|
121
|
+
connectionState,
|
|
122
|
+
iceState,
|
|
123
|
+
error,
|
|
124
|
+
signal,
|
|
125
|
+
sendData,
|
|
126
|
+
destroy,
|
|
127
|
+
} as const;
|
|
128
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export * from "./hooks/useMediaStream";
|
|
2
|
+
export * from "./hooks/useWebRTC";
|
|
3
|
+
export * from "./hooks/useDataChannel";
|
|
4
|
+
export * from "./hooks/useDataChannelMessages";
|
|
5
|
+
export * from "./hooks/useCall";
|
|
6
|
+
export * from "./hooks/useCallState";
|
|
7
|
+
export * from "./hooks/useMeshRoom";
|
|
8
|
+
export * from "./hooks/useScreenShare";
|
|
9
|
+
export * from "./context/WebRTCProvider";
|
|
10
|
+
export * from "./components/VideoPlayer";
|
|
11
|
+
export * from "./components/AudioPlayer";
|
|
12
|
+
export * from "./components/StatusBadge";
|
|
13
|
+
export * from "./components/ErrorBanner";
|
|
14
|
+
export * from "./signaling/SignalingClient";
|
|
15
|
+
export * from "./config/features";
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
export type EventMap = {
|
|
2
|
+
open: void;
|
|
3
|
+
close: CloseEvent | undefined;
|
|
4
|
+
error: Error;
|
|
5
|
+
signal: { from: string; data: unknown };
|
|
6
|
+
broadcast: { from: string; room?: string | null; data: unknown };
|
|
7
|
+
presence: {
|
|
8
|
+
room?: string | null;
|
|
9
|
+
peerId: string;
|
|
10
|
+
peers: string[];
|
|
11
|
+
action: "join" | "leave";
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type EventKey = keyof EventMap;
|
|
16
|
+
|
|
17
|
+
type Handler<K extends EventKey> = (payload: EventMap[K]) => void;
|
|
18
|
+
|
|
19
|
+
function createEmitter() {
|
|
20
|
+
const listeners = new Map<EventKey, Set<Handler<any>>>();
|
|
21
|
+
return {
|
|
22
|
+
on<K extends EventKey>(event: K, handler: Handler<K>) {
|
|
23
|
+
const set = listeners.get(event) ?? new Set();
|
|
24
|
+
set.add(handler as Handler<any>);
|
|
25
|
+
listeners.set(event, set);
|
|
26
|
+
},
|
|
27
|
+
off<K extends EventKey>(event: K, handler: Handler<K>) {
|
|
28
|
+
const set = listeners.get(event);
|
|
29
|
+
if (!set) return;
|
|
30
|
+
set.delete(handler as Handler<any>);
|
|
31
|
+
if (set.size === 0) listeners.delete(event);
|
|
32
|
+
},
|
|
33
|
+
emit<K extends EventKey>(event: K, payload: EventMap[K]) {
|
|
34
|
+
const set = listeners.get(event);
|
|
35
|
+
if (!set) return;
|
|
36
|
+
for (const handler of Array.from(set)) handler(payload);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SignalingClientOptions = {
|
|
42
|
+
url: string;
|
|
43
|
+
peerId: string;
|
|
44
|
+
room?: string | null;
|
|
45
|
+
autoReconnect?: boolean;
|
|
46
|
+
reconnectDelayMs?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type IncomingMessage =
|
|
50
|
+
| { type: "signal"; from: string; data: unknown }
|
|
51
|
+
| { type: "broadcast"; from: string; room?: string | null; data: unknown }
|
|
52
|
+
| {
|
|
53
|
+
type: "presence";
|
|
54
|
+
room?: string | null;
|
|
55
|
+
peerId: string;
|
|
56
|
+
peers: string[];
|
|
57
|
+
action: "join" | "leave";
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type OutgoingMessage =
|
|
61
|
+
| { type: "signal"; to: string; data: unknown }
|
|
62
|
+
| { type: "broadcast"; data: unknown };
|
|
63
|
+
|
|
64
|
+
export class SignalingClient {
|
|
65
|
+
private ws: WebSocket | null = null;
|
|
66
|
+
private options: SignalingClientOptions;
|
|
67
|
+
private emitter = createEmitter();
|
|
68
|
+
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
69
|
+
private shouldReconnect: boolean;
|
|
70
|
+
private pendingQueue: OutgoingMessage[] = [];
|
|
71
|
+
|
|
72
|
+
constructor(options: SignalingClientOptions) {
|
|
73
|
+
this.options = options;
|
|
74
|
+
this.shouldReconnect = options.autoReconnect ?? true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
connect() {
|
|
78
|
+
if (typeof window === "undefined") return;
|
|
79
|
+
if (
|
|
80
|
+
this.ws &&
|
|
81
|
+
(this.ws.readyState === WebSocket.OPEN ||
|
|
82
|
+
this.ws.readyState === WebSocket.CONNECTING)
|
|
83
|
+
) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { url, peerId, room } = this.options;
|
|
88
|
+
const wsUrl = `${url}?peerId=${encodeURIComponent(peerId)}${
|
|
89
|
+
room ? `&room=${encodeURIComponent(room)}` : ""
|
|
90
|
+
}`;
|
|
91
|
+
this.ws = new WebSocket(wsUrl);
|
|
92
|
+
|
|
93
|
+
this.ws.addEventListener("open", () => {
|
|
94
|
+
this.emitter.emit("open", undefined as void);
|
|
95
|
+
this.flushQueue();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
this.ws.addEventListener("message", (event) => {
|
|
99
|
+
const payload = typeof event.data === "string" ? event.data : "";
|
|
100
|
+
try {
|
|
101
|
+
const parsed: IncomingMessage = JSON.parse(payload);
|
|
102
|
+
if (parsed.type === "signal") {
|
|
103
|
+
this.emitter.emit("signal", { from: parsed.from, data: parsed.data });
|
|
104
|
+
} else if (parsed.type === "broadcast") {
|
|
105
|
+
this.emitter.emit("broadcast", {
|
|
106
|
+
from: parsed.from,
|
|
107
|
+
room: parsed.room,
|
|
108
|
+
data: parsed.data,
|
|
109
|
+
});
|
|
110
|
+
} else if (parsed.type === "presence") {
|
|
111
|
+
this.emitter.emit("presence", {
|
|
112
|
+
room: parsed.room,
|
|
113
|
+
peerId: parsed.peerId,
|
|
114
|
+
peers: parsed.peers,
|
|
115
|
+
action: parsed.action,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
this.emitter.emit("error", error as Error);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
this.ws.addEventListener("close", (event) => {
|
|
124
|
+
this.emitter.emit("close", event);
|
|
125
|
+
this.scheduleReconnect();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
this.ws.addEventListener("error", () => {
|
|
129
|
+
this.emitter.emit("error", new Error("WebSocket error"));
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
close() {
|
|
134
|
+
this.shouldReconnect = false;
|
|
135
|
+
if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
|
|
136
|
+
this.ws?.close();
|
|
137
|
+
this.ws = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
sendSignal(to: string, data: unknown) {
|
|
141
|
+
this.enqueue({ type: "signal", to, data });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
broadcast(data: unknown) {
|
|
145
|
+
this.enqueue({ type: "broadcast", data });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
on<K extends EventKey>(event: K, handler: Handler<K>) {
|
|
149
|
+
this.emitter.on(event, handler);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
off<K extends EventKey>(event: K, handler: Handler<K>) {
|
|
153
|
+
this.emitter.off(event, handler);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private enqueue(message: OutgoingMessage) {
|
|
157
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
158
|
+
this.ws.send(JSON.stringify(message));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
this.pendingQueue.push(message);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private flushQueue() {
|
|
165
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
166
|
+
while (this.pendingQueue.length) {
|
|
167
|
+
const msg = this.pendingQueue.shift();
|
|
168
|
+
if (msg) this.ws.send(JSON.stringify(msg));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private scheduleReconnect() {
|
|
173
|
+
if (!this.shouldReconnect) return;
|
|
174
|
+
const delay = this.options.reconnectDelayMs ?? 1000;
|
|
175
|
+
if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
|
|
176
|
+
this.reconnectTimeout = setTimeout(() => this.connect(), delay);
|
|
177
|
+
}
|
|
178
|
+
}
|