@agentuity/react 1.0.0 → 1.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/src/webrtc.tsx ADDED
@@ -0,0 +1,483 @@
1
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ WebRTCManager,
4
+ buildUrl,
5
+ type WebRTCManagerOptions,
6
+ type WebRTCClientCallbacks,
7
+ type TrackSource,
8
+ } from '@agentuity/frontend';
9
+ import type {
10
+ WebRTCConnectionState,
11
+ DataChannelConfig,
12
+ DataChannelState,
13
+ ConnectionQualitySummary,
14
+ RecordingHandle,
15
+ RecordingOptions,
16
+ } from '@agentuity/core';
17
+
18
+ export type {
19
+ WebRTCClientCallbacks,
20
+ DataChannelConfig,
21
+ DataChannelState,
22
+ ConnectionQualitySummary,
23
+ };
24
+ import { AgentuityContext } from './context';
25
+
26
+ export type { WebRTCConnectionState };
27
+
28
+ /**
29
+ * Options for useWebRTCCall hook
30
+ */
31
+ export interface UseWebRTCCallOptions {
32
+ /** Room ID to join */
33
+ roomId: string;
34
+ /** WebSocket signaling URL (e.g., '/call/signal' or full URL) */
35
+ signalUrl: string;
36
+ /** Whether this peer is "polite" in perfect negotiation */
37
+ polite?: boolean;
38
+ /** ICE servers configuration */
39
+ iceServers?: RTCIceServer[];
40
+ /**
41
+ * Media source configuration.
42
+ * - `false`: Data-only mode (no media)
43
+ * - `MediaStreamConstraints`: Use getUserMedia with these constraints
44
+ * - `TrackSource`: Use a custom track source
45
+ * Default: { video: true, audio: true }
46
+ */
47
+ media?: MediaStreamConstraints | TrackSource | false;
48
+ /**
49
+ * Data channels to create when connection is established.
50
+ * Only the offerer (late joiner) creates channels; the answerer receives them.
51
+ */
52
+ dataChannels?: DataChannelConfig[];
53
+ /**
54
+ * Whether to auto-reconnect on WebSocket/ICE failures (default: true)
55
+ */
56
+ autoReconnect?: boolean;
57
+ /**
58
+ * Maximum reconnection attempts before giving up (default: 5)
59
+ */
60
+ maxReconnectAttempts?: number;
61
+ /**
62
+ * Connection timeout in ms for connecting/negotiating (default: 30000)
63
+ */
64
+ connectionTimeout?: number;
65
+ /**
66
+ * ICE gathering timeout in ms (default: 10000)
67
+ */
68
+ iceGatheringTimeout?: number;
69
+ /** Whether to auto-connect on mount (default: true) */
70
+ autoConnect?: boolean;
71
+ /**
72
+ * Optional callbacks for WebRTC events.
73
+ * These are called in addition to the hook's internal state management.
74
+ */
75
+ callbacks?: Partial<WebRTCClientCallbacks>;
76
+ }
77
+
78
+ /**
79
+ * Return type for useWebRTCCall hook
80
+ */
81
+ export interface UseWebRTCCallResult {
82
+ /** Ref to attach to local video element */
83
+ localVideoRef: React.RefObject<HTMLVideoElement | null>;
84
+ /** Current connection state */
85
+ state: WebRTCConnectionState;
86
+ /** Current error if any */
87
+ error: Error | null;
88
+ /** Local peer ID assigned by server */
89
+ peerId: string | null;
90
+ /** Remote peer IDs */
91
+ remotePeerIds: string[];
92
+ /** Remote streams keyed by peer ID */
93
+ remoteStreams: Map<string, MediaStream>;
94
+ /** Whether audio is muted */
95
+ isAudioMuted: boolean;
96
+ /** Whether video is muted */
97
+ isVideoMuted: boolean;
98
+ /** Whether this is a data-only connection (no media) */
99
+ isDataOnly: boolean;
100
+ /** Whether screen sharing is active */
101
+ isScreenSharing: boolean;
102
+ /** Manually start the connection (if autoConnect is false) */
103
+ connect: () => void;
104
+ /** End the call */
105
+ hangup: () => void;
106
+ /** Mute or unmute audio */
107
+ muteAudio: (muted: boolean) => void;
108
+ /** Mute or unmute video */
109
+ muteVideo: (muted: boolean) => void;
110
+
111
+ // Screen sharing
112
+ /** Start screen sharing */
113
+ startScreenShare: (options?: DisplayMediaStreamOptions) => Promise<void>;
114
+ /** Stop screen sharing */
115
+ stopScreenShare: () => Promise<void>;
116
+
117
+ // Data channel methods
118
+ /** Create a new data channel to all peers */
119
+ createDataChannel: (config: DataChannelConfig) => Map<string, RTCDataChannel>;
120
+ /** Get all open data channel labels */
121
+ getDataChannelLabels: () => string[];
122
+ /** Get the state of a data channel for a specific peer */
123
+ getDataChannelState: (peerId: string, label: string) => DataChannelState | null;
124
+ /** Send a string message to all peers */
125
+ sendString: (label: string, data: string) => boolean;
126
+ /** Send a string message to a specific peer */
127
+ sendStringTo: (peerId: string, label: string, data: string) => boolean;
128
+ /** Send binary data to all peers */
129
+ sendBinary: (label: string, data: ArrayBuffer | Uint8Array) => boolean;
130
+ /** Send binary data to a specific peer */
131
+ sendBinaryTo: (peerId: string, label: string, data: ArrayBuffer | Uint8Array) => boolean;
132
+ /** Send JSON data to all peers */
133
+ sendJSON: (label: string, data: unknown) => boolean;
134
+ /** Send JSON data to a specific peer */
135
+ sendJSONTo: (peerId: string, label: string, data: unknown) => boolean;
136
+ /** Close a specific data channel on all peers */
137
+ closeDataChannel: (label: string) => boolean;
138
+
139
+ // Stats
140
+ /** Get quality summary for a peer */
141
+ getQualitySummary: (peerId: string) => Promise<ConnectionQualitySummary | null>;
142
+ /** Get quality summaries for all peers */
143
+ getAllQualitySummaries: () => Promise<Map<string, ConnectionQualitySummary>>;
144
+
145
+ // Recording
146
+ /** Start recording a stream */
147
+ startRecording: (streamId: string, options?: RecordingOptions) => RecordingHandle | null;
148
+ /** Check if a stream is being recorded */
149
+ isRecording: (streamId: string) => boolean;
150
+ /** Stop all recordings */
151
+ stopAllRecordings: () => Promise<Map<string, Blob>>;
152
+ }
153
+
154
+ /**
155
+ * React hook for WebRTC peer-to-peer audio/video/data calls.
156
+ *
157
+ * Supports multi-peer mesh networking, screen sharing, recording, and stats.
158
+ *
159
+ * @example
160
+ * ```tsx
161
+ * function VideoCall({ roomId }: { roomId: string }) {
162
+ * const {
163
+ * localVideoRef,
164
+ * state,
165
+ * remotePeerIds,
166
+ * remoteStreams,
167
+ * hangup,
168
+ * muteAudio,
169
+ * isAudioMuted,
170
+ * startScreenShare,
171
+ * } = useWebRTCCall({
172
+ * roomId,
173
+ * signalUrl: '/call/signal',
174
+ * callbacks: {
175
+ * onStateChange: (from, to, reason) => console.log(`${from} → ${to}`, reason),
176
+ * onRemoteStream: (peerId, stream) => console.log(`Got stream from ${peerId}`),
177
+ * },
178
+ * });
179
+ *
180
+ * return (
181
+ * <div>
182
+ * <video ref={localVideoRef} autoPlay muted playsInline />
183
+ * {remotePeerIds.map((peerId) => (
184
+ * <RemoteVideo key={peerId} stream={remoteStreams.get(peerId)} />
185
+ * ))}
186
+ * <p>State: {state}</p>
187
+ * <button onClick={() => muteAudio(!isAudioMuted)}>
188
+ * {isAudioMuted ? 'Unmute' : 'Mute'}
189
+ * </button>
190
+ * <button onClick={hangup}>Hang Up</button>
191
+ * </div>
192
+ * );
193
+ * }
194
+ * ```
195
+ */
196
+ export function useWebRTCCall(options: UseWebRTCCallOptions): UseWebRTCCallResult {
197
+ const context = useContext(AgentuityContext);
198
+
199
+ const managerRef = useRef<WebRTCManager | null>(null);
200
+ const localVideoRef = useRef<HTMLVideoElement | null>(null);
201
+
202
+ const [state, setState] = useState<WebRTCConnectionState>('idle');
203
+ const [error, setError] = useState<Error | null>(null);
204
+ const [peerId, setPeerId] = useState<string | null>(null);
205
+ const [remotePeerIds, setRemotePeerIds] = useState<string[]>([]);
206
+ const [remoteStreams, setRemoteStreams] = useState<Map<string, MediaStream>>(new Map());
207
+ const [isAudioMuted, setIsAudioMuted] = useState(false);
208
+ const [isVideoMuted, setIsVideoMuted] = useState(false);
209
+ const [isScreenSharing, setIsScreenSharing] = useState(false);
210
+
211
+ const userCallbacksRef = useRef(options.callbacks);
212
+ userCallbacksRef.current = options.callbacks;
213
+
214
+ const signalUrl = useMemo(() => {
215
+ if (options.signalUrl.startsWith('ws://') || options.signalUrl.startsWith('wss://')) {
216
+ return options.signalUrl;
217
+ }
218
+ const base = context?.baseUrl ?? window.location.origin;
219
+ const wsBase = base.replace(/^http(s?):/, 'ws$1:');
220
+ return buildUrl(wsBase, options.signalUrl);
221
+ }, [context?.baseUrl, options.signalUrl]);
222
+
223
+ const managerOptions = useMemo((): WebRTCManagerOptions => {
224
+ return {
225
+ signalUrl,
226
+ roomId: options.roomId,
227
+ polite: options.polite,
228
+ iceServers: options.iceServers,
229
+ media: options.media,
230
+ dataChannels: options.dataChannels,
231
+ autoReconnect: options.autoReconnect,
232
+ maxReconnectAttempts: options.maxReconnectAttempts,
233
+ connectionTimeout: options.connectionTimeout,
234
+ iceGatheringTimeout: options.iceGatheringTimeout,
235
+ callbacks: {
236
+ onStateChange: (from, to, reason) => {
237
+ setState(to);
238
+ if (managerRef.current) {
239
+ const managerState = managerRef.current.getState();
240
+ setPeerId(managerState.peerId);
241
+ setRemotePeerIds(managerState.remotePeerIds);
242
+ setIsScreenSharing(managerState.isScreenSharing);
243
+ }
244
+ userCallbacksRef.current?.onStateChange?.(from, to, reason);
245
+ },
246
+ onConnect: () => {
247
+ userCallbacksRef.current?.onConnect?.();
248
+ },
249
+ onDisconnect: (reason) => {
250
+ userCallbacksRef.current?.onDisconnect?.(reason);
251
+ },
252
+ onLocalStream: (stream) => {
253
+ if (localVideoRef.current) {
254
+ localVideoRef.current.srcObject = stream;
255
+ }
256
+ userCallbacksRef.current?.onLocalStream?.(stream);
257
+ },
258
+ onRemoteStream: (remotePeerId, stream) => {
259
+ setRemoteStreams((prev) => {
260
+ const next = new Map(prev);
261
+ next.set(remotePeerId, stream);
262
+ return next;
263
+ });
264
+ userCallbacksRef.current?.onRemoteStream?.(remotePeerId, stream);
265
+ },
266
+ onTrackAdded: (remotePeerId, track, stream) => {
267
+ userCallbacksRef.current?.onTrackAdded?.(remotePeerId, track, stream);
268
+ },
269
+ onTrackRemoved: (remotePeerId, track) => {
270
+ userCallbacksRef.current?.onTrackRemoved?.(remotePeerId, track);
271
+ },
272
+ onPeerJoined: (id) => {
273
+ setRemotePeerIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
274
+ userCallbacksRef.current?.onPeerJoined?.(id);
275
+ },
276
+ onPeerLeft: (id) => {
277
+ setRemotePeerIds((prev) => prev.filter((p) => p !== id));
278
+ setRemoteStreams((prev) => {
279
+ const next = new Map(prev);
280
+ next.delete(id);
281
+ return next;
282
+ });
283
+ userCallbacksRef.current?.onPeerLeft?.(id);
284
+ },
285
+ onNegotiationStart: (remotePeerId) => {
286
+ userCallbacksRef.current?.onNegotiationStart?.(remotePeerId);
287
+ },
288
+ onNegotiationComplete: (remotePeerId) => {
289
+ userCallbacksRef.current?.onNegotiationComplete?.(remotePeerId);
290
+ },
291
+ onIceCandidate: (remotePeerId, candidate) => {
292
+ userCallbacksRef.current?.onIceCandidate?.(remotePeerId, candidate);
293
+ },
294
+ onIceStateChange: (remotePeerId, iceState) => {
295
+ userCallbacksRef.current?.onIceStateChange?.(remotePeerId, iceState);
296
+ },
297
+ onError: (err, currentState) => {
298
+ setError(err);
299
+ userCallbacksRef.current?.onError?.(err, currentState);
300
+ },
301
+ onDataChannelOpen: (remotePeerId, label) => {
302
+ userCallbacksRef.current?.onDataChannelOpen?.(remotePeerId, label);
303
+ },
304
+ onDataChannelClose: (remotePeerId, label) => {
305
+ userCallbacksRef.current?.onDataChannelClose?.(remotePeerId, label);
306
+ },
307
+ onDataChannelMessage: (remotePeerId, label, data) => {
308
+ userCallbacksRef.current?.onDataChannelMessage?.(remotePeerId, label, data);
309
+ },
310
+ onDataChannelError: (remotePeerId, label, err) => {
311
+ userCallbacksRef.current?.onDataChannelError?.(remotePeerId, label, err);
312
+ },
313
+ onScreenShareStart: () => {
314
+ setIsScreenSharing(true);
315
+ userCallbacksRef.current?.onScreenShareStart?.();
316
+ },
317
+ onScreenShareStop: () => {
318
+ setIsScreenSharing(false);
319
+ userCallbacksRef.current?.onScreenShareStop?.();
320
+ },
321
+ onReconnecting: (attempt) => {
322
+ userCallbacksRef.current?.onReconnecting?.(attempt);
323
+ },
324
+ onReconnected: () => {
325
+ userCallbacksRef.current?.onReconnected?.();
326
+ },
327
+ onReconnectFailed: () => {
328
+ userCallbacksRef.current?.onReconnectFailed?.();
329
+ },
330
+ },
331
+ };
332
+ }, [
333
+ signalUrl,
334
+ options.roomId,
335
+ options.polite,
336
+ options.iceServers,
337
+ options.media,
338
+ options.dataChannels,
339
+ options.autoReconnect,
340
+ options.maxReconnectAttempts,
341
+ options.connectionTimeout,
342
+ options.iceGatheringTimeout,
343
+ ]);
344
+
345
+ useEffect(() => {
346
+ const manager = new WebRTCManager(managerOptions);
347
+ managerRef.current = manager;
348
+
349
+ if (options.autoConnect !== false) {
350
+ manager.connect();
351
+ }
352
+
353
+ return () => {
354
+ manager.dispose();
355
+ managerRef.current = null;
356
+ };
357
+ }, [managerOptions, options.autoConnect]);
358
+
359
+ const connect = useCallback(() => {
360
+ managerRef.current?.connect();
361
+ }, []);
362
+
363
+ const hangup = useCallback(() => {
364
+ managerRef.current?.hangup();
365
+ setRemotePeerIds([]);
366
+ setRemoteStreams(new Map());
367
+ }, []);
368
+
369
+ const muteAudio = useCallback((muted: boolean) => {
370
+ managerRef.current?.muteAudio(muted);
371
+ setIsAudioMuted(muted);
372
+ }, []);
373
+
374
+ const muteVideo = useCallback((muted: boolean) => {
375
+ managerRef.current?.muteVideo(muted);
376
+ setIsVideoMuted(muted);
377
+ }, []);
378
+
379
+ const startScreenShare = useCallback(async (opts?: DisplayMediaStreamOptions) => {
380
+ await managerRef.current?.startScreenShare(opts);
381
+ }, []);
382
+
383
+ const stopScreenShare = useCallback(async () => {
384
+ await managerRef.current?.stopScreenShare();
385
+ }, []);
386
+
387
+ const createDataChannel = useCallback((config: DataChannelConfig) => {
388
+ return managerRef.current?.createDataChannel(config) ?? new Map();
389
+ }, []);
390
+
391
+ const getDataChannelLabels = useCallback(() => {
392
+ return managerRef.current?.getDataChannelLabels() ?? [];
393
+ }, []);
394
+
395
+ const getDataChannelState = useCallback((remotePeerId: string, label: string) => {
396
+ return managerRef.current?.getDataChannelState(remotePeerId, label) ?? null;
397
+ }, []);
398
+
399
+ const sendString = useCallback((label: string, data: string) => {
400
+ return managerRef.current?.sendString(label, data) ?? false;
401
+ }, []);
402
+
403
+ const sendStringTo = useCallback((remotePeerId: string, label: string, data: string) => {
404
+ return managerRef.current?.sendStringTo(remotePeerId, label, data) ?? false;
405
+ }, []);
406
+
407
+ const sendBinary = useCallback((label: string, data: ArrayBuffer | Uint8Array) => {
408
+ return managerRef.current?.sendBinary(label, data) ?? false;
409
+ }, []);
410
+
411
+ const sendBinaryTo = useCallback(
412
+ (remotePeerId: string, label: string, data: ArrayBuffer | Uint8Array) => {
413
+ return managerRef.current?.sendBinaryTo(remotePeerId, label, data) ?? false;
414
+ },
415
+ []
416
+ );
417
+
418
+ const sendJSON = useCallback((label: string, data: unknown) => {
419
+ return managerRef.current?.sendJSON(label, data) ?? false;
420
+ }, []);
421
+
422
+ const sendJSONTo = useCallback((remotePeerId: string, label: string, data: unknown) => {
423
+ return managerRef.current?.sendJSONTo(remotePeerId, label, data) ?? false;
424
+ }, []);
425
+
426
+ const closeDataChannel = useCallback((label: string) => {
427
+ return managerRef.current?.closeDataChannel(label) ?? false;
428
+ }, []);
429
+
430
+ const getQualitySummary = useCallback(async (remotePeerId: string) => {
431
+ return managerRef.current?.getQualitySummary(remotePeerId) ?? null;
432
+ }, []);
433
+
434
+ const getAllQualitySummaries = useCallback(async () => {
435
+ return managerRef.current?.getAllQualitySummaries() ?? new Map();
436
+ }, []);
437
+
438
+ const startRecording = useCallback((streamId: string, opts?: RecordingOptions) => {
439
+ return managerRef.current?.startRecording(streamId, opts) ?? null;
440
+ }, []);
441
+
442
+ const isRecordingFn = useCallback((streamId: string) => {
443
+ return managerRef.current?.isRecording(streamId) ?? false;
444
+ }, []);
445
+
446
+ const stopAllRecordings = useCallback(async () => {
447
+ return managerRef.current?.stopAllRecordings() ?? new Map();
448
+ }, []);
449
+
450
+ return {
451
+ localVideoRef,
452
+ state,
453
+ error,
454
+ peerId,
455
+ remotePeerIds,
456
+ remoteStreams,
457
+ isAudioMuted,
458
+ isVideoMuted,
459
+ isDataOnly: options.media === false,
460
+ isScreenSharing,
461
+ connect,
462
+ hangup,
463
+ muteAudio,
464
+ muteVideo,
465
+ startScreenShare,
466
+ stopScreenShare,
467
+ createDataChannel,
468
+ getDataChannelLabels,
469
+ getDataChannelState,
470
+ sendString,
471
+ sendStringTo,
472
+ sendBinary,
473
+ sendBinaryTo,
474
+ sendJSON,
475
+ sendJSONTo,
476
+ closeDataChannel,
477
+ getQualitySummary,
478
+ getAllQualitySummaries,
479
+ startRecording,
480
+ isRecording: isRecordingFn,
481
+ stopAllRecordings,
482
+ };
483
+ }