streemo-video-call-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +138 -0
  2. package/dist/VideoCallWidget.d.ts +16 -0
  3. package/dist/VideoCallWidget.d.ts.map +1 -0
  4. package/dist/VideoCallWidget.js +19 -0
  5. package/dist/VideoCallWidget.js.map +1 -0
  6. package/dist/VideoTile.d.ts +15 -0
  7. package/dist/VideoTile.d.ts.map +1 -0
  8. package/dist/VideoTile.js +76 -0
  9. package/dist/VideoTile.js.map +1 -0
  10. package/dist/auth.d.ts +10 -0
  11. package/dist/auth.d.ts.map +1 -0
  12. package/dist/auth.js +11 -0
  13. package/dist/auth.js.map +1 -0
  14. package/dist/client.d.ts +10 -0
  15. package/dist/client.d.ts.map +1 -0
  16. package/dist/client.js +5 -0
  17. package/dist/client.js.map +1 -0
  18. package/dist/index.d.ts +13 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +7 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/sdkConfig.d.ts +9 -0
  23. package/dist/sdkConfig.d.ts.map +1 -0
  24. package/dist/sdkConfig.js +17 -0
  25. package/dist/sdkConfig.js.map +1 -0
  26. package/dist/sdkErrors.d.ts +8 -0
  27. package/dist/sdkErrors.d.ts.map +1 -0
  28. package/dist/sdkErrors.js +32 -0
  29. package/dist/sdkErrors.js.map +1 -0
  30. package/dist/signalingClient.d.ts +33 -0
  31. package/dist/signalingClient.d.ts.map +1 -0
  32. package/dist/signalingClient.js +95 -0
  33. package/dist/signalingClient.js.map +1 -0
  34. package/dist/styles.css +99 -0
  35. package/dist/tokenProvider.d.ts +16 -0
  36. package/dist/tokenProvider.d.ts.map +1 -0
  37. package/dist/tokenProvider.js +89 -0
  38. package/dist/tokenProvider.js.map +1 -0
  39. package/dist/useWebRTCCall.d.ts +32 -0
  40. package/dist/useWebRTCCall.d.ts.map +1 -0
  41. package/dist/useWebRTCCall.js +775 -0
  42. package/dist/useWebRTCCall.js.map +1 -0
  43. package/package.json +36 -0
@@ -0,0 +1,775 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { createServerTokenProvider, defaultApiBaseUrl, defaultWsBaseUrlFromApi, } from './tokenProvider';
3
+ import { mapSDKErrorToUIMessage } from './sdkErrors';
4
+ import { getVideoSDKConfig } from './sdkConfig';
5
+ const RENEGOTIATION_RETRY_DELAY_MS = 6000;
6
+ const RENEGOTIATION_MAX_RETRIES = 3;
7
+ const DISCONNECTED_CLEANUP_DELAY_MS = 10000;
8
+ const NETWORK_STATS_POLL_MS = 5000;
9
+ const AUDIO_CONSTRAINTS = {
10
+ echoCancellation: true,
11
+ noiseSuppression: true,
12
+ autoGainControl: true,
13
+ channelCount: 1,
14
+ };
15
+ const VIDEO_CONSTRAINTS = {
16
+ width: { ideal: 1280, max: 1920 },
17
+ height: { ideal: 720, max: 1080 },
18
+ frameRate: { ideal: 24, max: 30 },
19
+ facingMode: 'user',
20
+ };
21
+ async function tuneLocalStream(stream) {
22
+ const audioTrack = stream.getAudioTracks()[0];
23
+ const videoTrack = stream.getVideoTracks()[0];
24
+ if (audioTrack) {
25
+ audioTrack.contentHint = 'speech';
26
+ try {
27
+ await audioTrack.applyConstraints(AUDIO_CONSTRAINTS);
28
+ }
29
+ catch {
30
+ // Browser may ignore unsupported audio processing constraints.
31
+ }
32
+ }
33
+ if (videoTrack) {
34
+ videoTrack.contentHint = 'motion';
35
+ try {
36
+ await videoTrack.applyConstraints(VIDEO_CONSTRAINTS);
37
+ }
38
+ catch {
39
+ // Browser may ignore unsupported video quality constraints.
40
+ }
41
+ }
42
+ }
43
+ async function tuneSenderEncodings(peer) {
44
+ const tasks = peer.getSenders().map(async (sender) => {
45
+ const track = sender.track;
46
+ if (!track)
47
+ return;
48
+ const params = sender.getParameters();
49
+ if (!params.encodings || params.encodings.length === 0) {
50
+ params.encodings = [{}];
51
+ }
52
+ const encoding = params.encodings[0];
53
+ if (track.kind === 'audio') {
54
+ encoding.maxBitrate = 64000;
55
+ }
56
+ if (track.kind === 'video') {
57
+ encoding.maxBitrate = 1200000;
58
+ encoding.maxFramerate = 24;
59
+ encoding.scaleResolutionDownBy = 1;
60
+ }
61
+ try {
62
+ await sender.setParameters(params);
63
+ }
64
+ catch {
65
+ // Some browsers can reject runtime encoding changes.
66
+ }
67
+ });
68
+ await Promise.all(tasks);
69
+ }
70
+ function getVideoProfile(level) {
71
+ switch (level) {
72
+ case 'low':
73
+ return {
74
+ maxBitrate: 350_000,
75
+ maxFramerate: 15,
76
+ scaleResolutionDownBy: 2,
77
+ };
78
+ case 'medium':
79
+ return {
80
+ maxBitrate: 700_000,
81
+ maxFramerate: 20,
82
+ scaleResolutionDownBy: 1.5,
83
+ };
84
+ case 'high':
85
+ default:
86
+ return {
87
+ maxBitrate: 1_200_000,
88
+ maxFramerate: 24,
89
+ scaleResolutionDownBy: 1,
90
+ };
91
+ }
92
+ }
93
+ async function applyVideoQualityProfile(peer, level) {
94
+ const profile = getVideoProfile(level);
95
+ const tasks = peer.getSenders().map(async (sender) => {
96
+ if (sender.track?.kind !== 'video')
97
+ return;
98
+ const params = sender.getParameters();
99
+ if (!params.encodings || params.encodings.length === 0) {
100
+ params.encodings = [{}];
101
+ }
102
+ const encoding = params.encodings[0];
103
+ encoding.maxBitrate = profile.maxBitrate;
104
+ encoding.maxFramerate = profile.maxFramerate;
105
+ encoding.scaleResolutionDownBy = profile.scaleResolutionDownBy;
106
+ try {
107
+ await sender.setParameters(params);
108
+ }
109
+ catch {
110
+ // Some browsers reject runtime adaptation for sender parameters.
111
+ }
112
+ });
113
+ await Promise.all(tasks);
114
+ }
115
+ function nextQualityLevel(current, target) {
116
+ const levels = ['low', 'medium', 'high'];
117
+ const currentIdx = levels.indexOf(current);
118
+ const targetIdx = levels.indexOf(target);
119
+ if (currentIdx === targetIdx)
120
+ return current;
121
+ if (currentIdx < targetIdx)
122
+ return levels[currentIdx + 1];
123
+ return levels[currentIdx - 1];
124
+ }
125
+ function resolveTokenProvider(params) {
126
+ if (params.tokenProvider)
127
+ return params.tokenProvider;
128
+ if (params.auth) {
129
+ return createServerTokenProvider({
130
+ apiBaseUrl: params.apiBaseUrl || defaultApiBaseUrl(),
131
+ auth: params.auth,
132
+ });
133
+ }
134
+ return null;
135
+ }
136
+ function resolveWsBaseUrl(params) {
137
+ if (params.wsBaseUrl)
138
+ return params.wsBaseUrl.replace(/\/+$/, '');
139
+ const apiBase = params.apiBaseUrl || defaultApiBaseUrl();
140
+ if (apiBase)
141
+ return defaultWsBaseUrlFromApi(apiBase);
142
+ return '';
143
+ }
144
+ async function resolveRoomToken(params) {
145
+ if (params.roomToken)
146
+ return params.roomToken;
147
+ const provider = resolveTokenProvider(params);
148
+ if (!provider) {
149
+ throw new Error('roomToken or tokenProvider/apiBaseUrl+auth is required');
150
+ }
151
+ const ctx = {
152
+ roomId: params.roomId,
153
+ userId: params.userId,
154
+ };
155
+ return provider(ctx);
156
+ }
157
+ export function useWebRTCCall(params) {
158
+ const sdkConfig = getVideoSDKConfig();
159
+ const mergedParams = {
160
+ ...params,
161
+ apiBaseUrl: params.apiBaseUrl ?? sdkConfig.apiBaseUrl,
162
+ wsBaseUrl: params.wsBaseUrl ?? sdkConfig.wsBaseUrl,
163
+ auth: params.auth ?? sdkConfig.auth,
164
+ };
165
+ const { roomId, userId, enabled } = mergedParams;
166
+ const { roomToken, wsBaseUrl, tokenProvider, apiBaseUrl, auth } = mergedParams;
167
+ const wsRef = useRef(null);
168
+ const peersRef = useRef(new Map());
169
+ const localStreamRef = useRef(null);
170
+ const remoteStreamsRef = useRef({});
171
+ const localParticipantIdRef = useRef(null);
172
+ const renegotiationTimersRef = useRef(new Map());
173
+ const renegotiationAttemptsRef = useRef(new Map());
174
+ const disconnectedCleanupTimersRef = useRef(new Map());
175
+ const remoteStreamCleanupRef = useRef(new Map());
176
+ const networkStatsTimersRef = useRef(new Map());
177
+ const qualityLevelRef = useRef(new Map());
178
+ const makingOfferRef = useRef(new Map());
179
+ const ignoreOfferRef = useRef(new Map());
180
+ const isSettingRemoteAnswerPendingRef = useRef(new Map());
181
+ const politeRef = useRef(new Map());
182
+ const [localStream, setLocalStream] = useState(null);
183
+ const [remoteStreams, setRemoteStreams] = useState({});
184
+ const [participants, setParticipants] = useState([]);
185
+ const [connected, setConnected] = useState(false);
186
+ const [error, setError] = useState(null);
187
+ const [remoteTrackStates, setRemoteTrackStates] = useState({});
188
+ const [micEnabled, setMicEnabled] = useState(true);
189
+ const [cameraEnabled, setCameraEnabled] = useState(true);
190
+ const [hasMicTrack, setHasMicTrack] = useState(false);
191
+ const [hasCameraTrack, setHasCameraTrack] = useState(false);
192
+ const clearRenegotiationRetry = useCallback((targetUserId) => {
193
+ const timer = renegotiationTimersRef.current.get(targetUserId);
194
+ if (timer) {
195
+ clearTimeout(timer);
196
+ renegotiationTimersRef.current.delete(targetUserId);
197
+ }
198
+ renegotiationAttemptsRef.current.delete(targetUserId);
199
+ }, []);
200
+ const clearDisconnectedCleanup = useCallback((targetUserId) => {
201
+ const timer = disconnectedCleanupTimersRef.current.get(targetUserId);
202
+ if (timer) {
203
+ clearTimeout(timer);
204
+ disconnectedCleanupTimersRef.current.delete(targetUserId);
205
+ }
206
+ }, []);
207
+ const clearRemoteStreamObservers = useCallback((targetUserId) => {
208
+ const cleanup = remoteStreamCleanupRef.current.get(targetUserId);
209
+ if (cleanup) {
210
+ cleanup();
211
+ remoteStreamCleanupRef.current.delete(targetUserId);
212
+ }
213
+ }, []);
214
+ const clearNetworkAdaptation = useCallback((targetUserId) => {
215
+ const timer = networkStatsTimersRef.current.get(targetUserId);
216
+ if (timer) {
217
+ clearInterval(timer);
218
+ networkStatsTimersRef.current.delete(targetUserId);
219
+ }
220
+ qualityLevelRef.current.delete(targetUserId);
221
+ }, []);
222
+ const computeTargetQualityLevel = useCallback(async (peer) => {
223
+ const stats = await peer.getStats();
224
+ let rttSeconds = null;
225
+ let packetLossPercent = null;
226
+ stats.forEach((report) => {
227
+ const stat = report;
228
+ if (stat.type === 'candidate-pair' && stat.state === 'succeeded') {
229
+ const currentRTT = stat.currentRoundTripTime;
230
+ if (typeof currentRTT === 'number') {
231
+ rttSeconds = rttSeconds === null ? currentRTT : Math.max(rttSeconds, currentRTT);
232
+ }
233
+ }
234
+ if (stat.type === 'remote-inbound-rtp' && stat.kind === 'video') {
235
+ const fractionLost = stat.fractionLost;
236
+ if (typeof fractionLost === 'number') {
237
+ packetLossPercent = Math.max(0, fractionLost * 100);
238
+ }
239
+ }
240
+ });
241
+ if ((rttSeconds ?? 0) > 0.35 || (packetLossPercent ?? 0) > 8) {
242
+ return 'low';
243
+ }
244
+ if ((rttSeconds ?? 0) > 0.2 || (packetLossPercent ?? 0) > 3) {
245
+ return 'medium';
246
+ }
247
+ return 'high';
248
+ }, []);
249
+ const startNetworkAdaptation = useCallback((targetUserId, peer) => {
250
+ clearNetworkAdaptation(targetUserId);
251
+ qualityLevelRef.current.set(targetUserId, 'high');
252
+ const timer = setInterval(() => {
253
+ void (async () => {
254
+ if (peer.connectionState === 'closed' || peer.connectionState === 'failed') {
255
+ clearNetworkAdaptation(targetUserId);
256
+ return;
257
+ }
258
+ const hasVideoSender = peer.getSenders().some((sender) => sender.track?.kind === 'video');
259
+ if (!hasVideoSender)
260
+ return;
261
+ let targetLevel;
262
+ try {
263
+ targetLevel = await computeTargetQualityLevel(peer);
264
+ }
265
+ catch {
266
+ return;
267
+ }
268
+ const currentLevel = qualityLevelRef.current.get(targetUserId) ?? 'high';
269
+ const nextLevel = nextQualityLevel(currentLevel, targetLevel);
270
+ if (nextLevel === currentLevel)
271
+ return;
272
+ qualityLevelRef.current.set(targetUserId, nextLevel);
273
+ await applyVideoQualityProfile(peer, nextLevel);
274
+ })();
275
+ }, NETWORK_STATS_POLL_MS);
276
+ networkStatsTimersRef.current.set(targetUserId, timer);
277
+ }, [clearNetworkAdaptation, computeTargetQualityLevel]);
278
+ const removePeer = useCallback((targetUserId, removeParticipant = false) => {
279
+ clearRenegotiationRetry(targetUserId);
280
+ clearDisconnectedCleanup(targetUserId);
281
+ clearRemoteStreamObservers(targetUserId);
282
+ clearNetworkAdaptation(targetUserId);
283
+ makingOfferRef.current.delete(targetUserId);
284
+ ignoreOfferRef.current.delete(targetUserId);
285
+ isSettingRemoteAnswerPendingRef.current.delete(targetUserId);
286
+ politeRef.current.delete(targetUserId);
287
+ const peer = peersRef.current.get(targetUserId);
288
+ if (peer) {
289
+ peer.ontrack = null;
290
+ peer.onicecandidate = null;
291
+ peer.onconnectionstatechange = null;
292
+ peer.close();
293
+ }
294
+ peersRef.current.delete(targetUserId);
295
+ setRemoteStreams((prev) => {
296
+ const copy = { ...prev };
297
+ delete copy[targetUserId];
298
+ remoteStreamsRef.current = copy;
299
+ return copy;
300
+ });
301
+ setRemoteTrackStates((prev) => {
302
+ const copy = { ...prev };
303
+ delete copy[targetUserId];
304
+ return copy;
305
+ });
306
+ if (removeParticipant) {
307
+ setParticipants((prev) => prev.filter((id) => id !== targetUserId));
308
+ }
309
+ }, [clearDisconnectedCleanup, clearNetworkAdaptation, clearRemoteStreamObservers, clearRenegotiationRetry]);
310
+ const hasRemoteVideoTrack = useCallback((targetUserId) => {
311
+ const stream = remoteStreamsRef.current[targetUserId];
312
+ if (!stream)
313
+ return false;
314
+ return stream
315
+ .getVideoTracks()
316
+ .some((track) => track.readyState === 'live' && track.enabled);
317
+ }, []);
318
+ const syncLocalTrackFlags = useCallback(() => {
319
+ const stream = localStreamRef.current;
320
+ if (!stream) {
321
+ setHasMicTrack(false);
322
+ setHasCameraTrack(false);
323
+ setMicEnabled(false);
324
+ setCameraEnabled(false);
325
+ return;
326
+ }
327
+ const audioTrack = stream.getAudioTracks()[0];
328
+ const videoTrack = stream.getVideoTracks()[0];
329
+ setHasMicTrack(Boolean(audioTrack));
330
+ setHasCameraTrack(Boolean(videoTrack));
331
+ setMicEnabled(Boolean(audioTrack?.enabled));
332
+ setCameraEnabled(Boolean(videoTrack?.enabled));
333
+ }, []);
334
+ const send = useCallback((message) => {
335
+ const ws = wsRef.current;
336
+ if (!ws || ws.readyState !== WebSocket.OPEN)
337
+ return;
338
+ ws.send(JSON.stringify({
339
+ roomId,
340
+ userId,
341
+ ...message,
342
+ }));
343
+ }, [roomId, userId]);
344
+ const gracefulLeave = useCallback(() => {
345
+ const ws = wsRef.current;
346
+ if (!ws || ws.readyState !== WebSocket.OPEN)
347
+ return;
348
+ try {
349
+ ws.send(JSON.stringify({
350
+ roomId,
351
+ userId,
352
+ type: 'participant_left',
353
+ payload: { source: 'page_unload', leftAt: new Date().toISOString() },
354
+ }));
355
+ }
356
+ catch {
357
+ // Ignore unload-time send errors.
358
+ }
359
+ ws.close();
360
+ }, [roomId, userId]);
361
+ const updateRemoteTrackState = useCallback((targetUserId, stream, peer) => {
362
+ const audio = stream.getAudioTracks().some((track) => track.readyState === 'live' && track.enabled);
363
+ const video = stream.getVideoTracks().some((track) => track.readyState === 'live' && track.enabled);
364
+ setRemoteTrackStates((prev) => ({
365
+ ...prev,
366
+ [targetUserId]: {
367
+ audio,
368
+ video,
369
+ connectionState: peer.connectionState,
370
+ },
371
+ }));
372
+ }, []);
373
+ const ensurePeer = useCallback((targetUserId) => {
374
+ const existing = peersRef.current.get(targetUserId);
375
+ if (existing)
376
+ return existing;
377
+ const peer = new RTCPeerConnection({
378
+ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
379
+ });
380
+ const localParticipantID = localParticipantIdRef.current;
381
+ politeRef.current.set(targetUserId, localParticipantID ? localParticipantID > targetUserId : true);
382
+ const localStream = localStreamRef.current;
383
+ const hasLocalAudio = Boolean(localStream?.getAudioTracks().length);
384
+ const hasLocalVideo = Boolean(localStream?.getVideoTracks().length);
385
+ if (!hasLocalAudio) {
386
+ peer.addTransceiver('audio', { direction: 'recvonly' });
387
+ }
388
+ if (!hasLocalVideo) {
389
+ peer.addTransceiver('video', { direction: 'recvonly' });
390
+ }
391
+ localStreamRef.current?.getTracks().forEach((track) => {
392
+ peer.addTrack(track, localStreamRef.current);
393
+ });
394
+ void tuneSenderEncodings(peer);
395
+ startNetworkAdaptation(targetUserId, peer);
396
+ peer.onicecandidate = (event) => {
397
+ if (!event.candidate)
398
+ return;
399
+ send({
400
+ type: 'ice_candidate',
401
+ targetUserId,
402
+ payload: event.candidate.toJSON(),
403
+ });
404
+ };
405
+ peer.ontrack = (event) => {
406
+ const [stream] = event.streams;
407
+ clearRemoteStreamObservers(targetUserId);
408
+ setRemoteStreams((prev) => {
409
+ const next = { ...prev, [targetUserId]: stream };
410
+ remoteStreamsRef.current = next;
411
+ return next;
412
+ });
413
+ updateRemoteTrackState(targetUserId, stream, peer);
414
+ const update = () => updateRemoteTrackState(targetUserId, stream, peer);
415
+ stream.getTracks().forEach((track) => {
416
+ track.addEventListener('mute', update);
417
+ track.addEventListener('unmute', update);
418
+ track.addEventListener('ended', update);
419
+ });
420
+ stream.addEventListener('addtrack', update);
421
+ stream.addEventListener('removetrack', update);
422
+ remoteStreamCleanupRef.current.set(targetUserId, () => {
423
+ stream.getTracks().forEach((track) => {
424
+ track.removeEventListener('mute', update);
425
+ track.removeEventListener('unmute', update);
426
+ track.removeEventListener('ended', update);
427
+ });
428
+ stream.removeEventListener('addtrack', update);
429
+ stream.removeEventListener('removetrack', update);
430
+ });
431
+ if (stream.getVideoTracks().some((track) => track.readyState === 'live' && track.enabled)) {
432
+ clearRenegotiationRetry(targetUserId);
433
+ }
434
+ };
435
+ peer.onconnectionstatechange = () => {
436
+ const stream = remoteStreamsRef.current[targetUserId];
437
+ if (stream) {
438
+ updateRemoteTrackState(targetUserId, stream, peer);
439
+ }
440
+ else {
441
+ setRemoteTrackStates((prev) => ({
442
+ ...prev,
443
+ [targetUserId]: {
444
+ audio: false,
445
+ video: false,
446
+ connectionState: peer.connectionState,
447
+ },
448
+ }));
449
+ }
450
+ if (peer.connectionState === 'failed' || peer.connectionState === 'closed') {
451
+ removePeer(targetUserId, true);
452
+ return;
453
+ }
454
+ if (peer.connectionState === 'disconnected') {
455
+ clearDisconnectedCleanup(targetUserId);
456
+ const timer = setTimeout(() => {
457
+ const current = peersRef.current.get(targetUserId);
458
+ if (!current)
459
+ return;
460
+ if (current.connectionState === 'disconnected') {
461
+ removePeer(targetUserId, true);
462
+ }
463
+ }, DISCONNECTED_CLEANUP_DELAY_MS);
464
+ disconnectedCleanupTimersRef.current.set(targetUserId, timer);
465
+ return;
466
+ }
467
+ clearDisconnectedCleanup(targetUserId);
468
+ };
469
+ peersRef.current.set(targetUserId, peer);
470
+ return peer;
471
+ }, [clearDisconnectedCleanup, clearRemoteStreamObservers, clearRenegotiationRetry, removePeer, send, startNetworkAdaptation, updateRemoteTrackState]);
472
+ const createOffer = useCallback(async (targetUserId) => {
473
+ const peer = ensurePeer(targetUserId);
474
+ if (peer.signalingState !== 'stable') {
475
+ return;
476
+ }
477
+ makingOfferRef.current.set(targetUserId, true);
478
+ try {
479
+ const offer = await peer.createOffer();
480
+ await peer.setLocalDescription(offer);
481
+ send({
482
+ type: 'offer',
483
+ targetUserId,
484
+ payload: offer,
485
+ });
486
+ }
487
+ finally {
488
+ makingOfferRef.current.set(targetUserId, false);
489
+ }
490
+ }, [ensurePeer, send]);
491
+ const scheduleRenegotiationRetry = useCallback((targetUserId) => {
492
+ const existingTimer = renegotiationTimersRef.current.get(targetUserId);
493
+ if (existingTimer) {
494
+ clearTimeout(existingTimer);
495
+ }
496
+ const timer = setTimeout(async () => {
497
+ const peer = peersRef.current.get(targetUserId);
498
+ if (!peer) {
499
+ clearRenegotiationRetry(targetUserId);
500
+ return;
501
+ }
502
+ if (hasRemoteVideoTrack(targetUserId)) {
503
+ clearRenegotiationRetry(targetUserId);
504
+ return;
505
+ }
506
+ const attempts = renegotiationAttemptsRef.current.get(targetUserId) ?? 0;
507
+ if (attempts >= RENEGOTIATION_MAX_RETRIES) {
508
+ clearRenegotiationRetry(targetUserId);
509
+ return;
510
+ }
511
+ renegotiationAttemptsRef.current.set(targetUserId, attempts + 1);
512
+ await createOffer(targetUserId);
513
+ scheduleRenegotiationRetry(targetUserId);
514
+ }, RENEGOTIATION_RETRY_DELAY_MS);
515
+ renegotiationTimersRef.current.set(targetUserId, timer);
516
+ }, [clearRenegotiationRetry, createOffer, hasRemoteVideoTrack]);
517
+ const handleSignalMessage = useCallback(async (message) => {
518
+ if (message.userId === userId)
519
+ return;
520
+ switch (message.type) {
521
+ case 'welcome':
522
+ localParticipantIdRef.current = message.userId;
523
+ break;
524
+ case 'join':
525
+ case 'participant_joined':
526
+ setParticipants((prev) => Array.from(new Set([...prev, message.userId])));
527
+ await createOffer(message.userId);
528
+ scheduleRenegotiationRetry(message.userId);
529
+ break;
530
+ case 'participant_left':
531
+ removePeer(message.userId, true);
532
+ break;
533
+ case 'offer': {
534
+ setParticipants((prev) => Array.from(new Set([...prev, message.userId])));
535
+ let peer = ensurePeer(message.userId);
536
+ const makingOffer = makingOfferRef.current.get(message.userId) ?? false;
537
+ const isSettingRemoteAnswerPending = isSettingRemoteAnswerPendingRef.current.get(message.userId) ?? false;
538
+ const polite = politeRef.current.get(message.userId) ?? true;
539
+ const offerCollision = makingOffer || (peer.signalingState !== 'stable' && !isSettingRemoteAnswerPending);
540
+ const ignoreOffer = !polite && offerCollision;
541
+ ignoreOfferRef.current.set(message.userId, ignoreOffer);
542
+ if (ignoreOffer) {
543
+ return;
544
+ }
545
+ if (offerCollision) {
546
+ try {
547
+ await peer.setLocalDescription({ type: 'rollback' });
548
+ }
549
+ catch {
550
+ removePeer(message.userId);
551
+ peer = ensurePeer(message.userId);
552
+ }
553
+ }
554
+ await peer.setRemoteDescription(new RTCSessionDescription(message.payload));
555
+ const answer = await peer.createAnswer();
556
+ await peer.setLocalDescription(answer);
557
+ send({
558
+ type: 'answer',
559
+ targetUserId: message.userId,
560
+ payload: answer,
561
+ });
562
+ break;
563
+ }
564
+ case 'answer': {
565
+ setParticipants((prev) => Array.from(new Set([...prev, message.userId])));
566
+ const peer = peersRef.current.get(message.userId);
567
+ if (!peer)
568
+ return;
569
+ if (ignoreOfferRef.current.get(message.userId)) {
570
+ return;
571
+ }
572
+ if (peer.signalingState !== 'have-local-offer') {
573
+ return;
574
+ }
575
+ isSettingRemoteAnswerPendingRef.current.set(message.userId, true);
576
+ try {
577
+ await peer.setRemoteDescription(new RTCSessionDescription(message.payload));
578
+ }
579
+ finally {
580
+ isSettingRemoteAnswerPendingRef.current.set(message.userId, false);
581
+ }
582
+ scheduleRenegotiationRetry(message.userId);
583
+ break;
584
+ }
585
+ case 'ice_candidate': {
586
+ setParticipants((prev) => Array.from(new Set([...prev, message.userId])));
587
+ const peer = ensurePeer(message.userId);
588
+ await peer.addIceCandidate(new RTCIceCandidate(message.payload));
589
+ break;
590
+ }
591
+ default:
592
+ break;
593
+ }
594
+ }, [createOffer, ensurePeer, removePeer, scheduleRenegotiationRetry, send, userId]);
595
+ useEffect(() => {
596
+ if (!enabled || !roomId || !userId)
597
+ return;
598
+ const peers = peersRef.current;
599
+ const currentParams = {
600
+ roomId,
601
+ userId,
602
+ enabled,
603
+ roomToken,
604
+ wsBaseUrl,
605
+ tokenProvider,
606
+ apiBaseUrl,
607
+ auth,
608
+ };
609
+ const setup = async () => {
610
+ try {
611
+ const resolvedRoomToken = await resolveRoomToken(currentParams);
612
+ const resolvedWsBaseUrl = resolveWsBaseUrl(currentParams);
613
+ if (!resolvedWsBaseUrl) {
614
+ setError('wsBaseUrl or apiBaseUrl is required');
615
+ return;
616
+ }
617
+ if (!window.isSecureContext) {
618
+ setError('getUserMedia requires HTTPS (or localhost).');
619
+ return;
620
+ }
621
+ if (!navigator.mediaDevices?.getUserMedia) {
622
+ setError('Browser does not support mediaDevices/getUserMedia.');
623
+ return;
624
+ }
625
+ let acquiredLocalStream;
626
+ try {
627
+ acquiredLocalStream = await navigator.mediaDevices.getUserMedia({
628
+ audio: AUDIO_CONSTRAINTS,
629
+ video: VIDEO_CONSTRAINTS,
630
+ });
631
+ }
632
+ catch (err) {
633
+ if (err instanceof DOMException && ['NotFoundError', 'OverconstrainedError'].includes(err.name)) {
634
+ acquiredLocalStream = await navigator.mediaDevices.getUserMedia({
635
+ audio: AUDIO_CONSTRAINTS,
636
+ video: false,
637
+ });
638
+ setError('Camera is unavailable. Joined in audio-only mode.');
639
+ }
640
+ else {
641
+ throw err;
642
+ }
643
+ }
644
+ await tuneLocalStream(acquiredLocalStream);
645
+ localStreamRef.current = acquiredLocalStream;
646
+ setLocalStream(acquiredLocalStream);
647
+ syncLocalTrackFlags();
648
+ const wsURL = `${resolvedWsBaseUrl}/v1/ws?roomId=${encodeURIComponent(roomId)}&token=${encodeURIComponent(resolvedRoomToken)}`;
649
+ const ws = new WebSocket(wsURL);
650
+ wsRef.current = ws;
651
+ ws.onopen = () => {
652
+ setConnected(true);
653
+ send({ type: 'join', payload: { joinedAt: new Date().toISOString() } });
654
+ };
655
+ ws.onclose = () => {
656
+ setConnected(false);
657
+ };
658
+ ws.onerror = () => {
659
+ setError('WebSocket connection error');
660
+ };
661
+ ws.onmessage = (event) => {
662
+ const message = JSON.parse(event.data);
663
+ void handleSignalMessage(message);
664
+ };
665
+ }
666
+ catch (err) {
667
+ const mappedMessage = mapSDKErrorToUIMessage(err);
668
+ if (mappedMessage !== 'Unexpected error while connecting to call server.') {
669
+ setError(mappedMessage);
670
+ return;
671
+ }
672
+ if (err instanceof DOMException && err.name === 'NotAllowedError') {
673
+ setError('No camera/microphone access. Please allow permissions in browser settings.');
674
+ return;
675
+ }
676
+ setError(err instanceof Error ? err.message : 'Call initialization failed');
677
+ }
678
+ };
679
+ void setup();
680
+ return () => {
681
+ wsRef.current?.close();
682
+ peers.forEach((_, peerID) => removePeer(peerID));
683
+ peers.clear();
684
+ renegotiationTimersRef.current.forEach((timer) => clearTimeout(timer));
685
+ renegotiationTimersRef.current.clear();
686
+ renegotiationAttemptsRef.current.clear();
687
+ disconnectedCleanupTimersRef.current.forEach((timer) => clearTimeout(timer));
688
+ disconnectedCleanupTimersRef.current.clear();
689
+ remoteStreamCleanupRef.current.forEach((cleanup) => cleanup());
690
+ remoteStreamCleanupRef.current.clear();
691
+ networkStatsTimersRef.current.forEach((timer) => clearInterval(timer));
692
+ networkStatsTimersRef.current.clear();
693
+ qualityLevelRef.current.clear();
694
+ makingOfferRef.current.clear();
695
+ ignoreOfferRef.current.clear();
696
+ isSettingRemoteAnswerPendingRef.current.clear();
697
+ politeRef.current.clear();
698
+ localParticipantIdRef.current = null;
699
+ remoteStreamsRef.current = {};
700
+ localStreamRef.current?.getTracks().forEach((track) => track.stop());
701
+ localStreamRef.current = null;
702
+ syncLocalTrackFlags();
703
+ };
704
+ }, [
705
+ apiBaseUrl,
706
+ auth,
707
+ enabled,
708
+ handleSignalMessage,
709
+ removePeer,
710
+ roomId,
711
+ roomToken,
712
+ send,
713
+ syncLocalTrackFlags,
714
+ tokenProvider,
715
+ userId,
716
+ wsBaseUrl,
717
+ ]);
718
+ useEffect(() => {
719
+ if (!enabled)
720
+ return;
721
+ const onPageHide = () => {
722
+ gracefulLeave();
723
+ };
724
+ const onBeforeUnload = () => {
725
+ gracefulLeave();
726
+ };
727
+ window.addEventListener('pagehide', onPageHide);
728
+ window.addEventListener('beforeunload', onBeforeUnload);
729
+ return () => {
730
+ window.removeEventListener('pagehide', onPageHide);
731
+ window.removeEventListener('beforeunload', onBeforeUnload);
732
+ };
733
+ }, [enabled, gracefulLeave]);
734
+ const toggleMic = useCallback(() => {
735
+ const stream = localStreamRef.current;
736
+ if (!stream)
737
+ return;
738
+ const audioTracks = stream.getAudioTracks();
739
+ if (audioTracks.length === 0)
740
+ return;
741
+ const nextEnabled = !micEnabled;
742
+ audioTracks.forEach((track) => {
743
+ track.enabled = nextEnabled;
744
+ });
745
+ setMicEnabled(nextEnabled);
746
+ }, [micEnabled]);
747
+ const toggleCamera = useCallback(() => {
748
+ const stream = localStreamRef.current;
749
+ if (!stream)
750
+ return;
751
+ const videoTracks = stream.getVideoTracks();
752
+ if (videoTracks.length === 0)
753
+ return;
754
+ const nextEnabled = !cameraEnabled;
755
+ videoTracks.forEach((track) => {
756
+ track.enabled = nextEnabled;
757
+ });
758
+ setCameraEnabled(nextEnabled);
759
+ }, [cameraEnabled]);
760
+ return {
761
+ localStream,
762
+ remoteStreams,
763
+ participants,
764
+ remoteTrackStates,
765
+ connected,
766
+ error,
767
+ micEnabled,
768
+ cameraEnabled,
769
+ hasMicTrack,
770
+ hasCameraTrack,
771
+ toggleMic,
772
+ toggleCamera,
773
+ };
774
+ }
775
+ //# sourceMappingURL=useWebRTCCall.js.map