@defcomm/meetingsdk 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.
package/dist/index.mjs ADDED
@@ -0,0 +1,2253 @@
1
+ // src/react/useLocalParticipant.tsx
2
+ import { useCallback, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
3
+
4
+ // src/react/MeetingProvider.tsx
5
+ import { createContext, useContext, useMemo, useRef } from "react";
6
+
7
+ // src/config/ws.ts
8
+ var SDK_CONFIG = {
9
+ // SFU API endpoint for token generation
10
+ apiBase: "https://sfu-server.duckdns.org/api",
11
+ // SFU WebSocket endpoint for signaling
12
+ wsBase: "wss://sfu-server.duckdns.org/ws",
13
+ // Public STUN/TURN servers (fallback)
14
+ iceServers: [
15
+ { urls: ["stun:stun.l.google.com:19302"] },
16
+ { urls: ["stun:stun1.l.google.com:19302"] }
17
+ ],
18
+ // Connection and reconnection settings
19
+ connectionTimeout: 1e4,
20
+ maxReconnectAttempts: 5,
21
+ // Legacy WebSocket URL (if using the old mesh architecture)
22
+ wsUrl: "wss://sfu-server.duckdns.org/ws"
23
+ // kept for backward compatibility
24
+ };
25
+
26
+ // src/core/MeetingState.ts
27
+ var MeetingState = class {
28
+ constructor() {
29
+ this.participants = /* @__PURE__ */ new Map();
30
+ this.localParticipant = null;
31
+ this.localStream = null;
32
+ this.chatMessages = /* @__PURE__ */ new Map();
33
+ this.presenterId = null;
34
+ this.listeners = /* @__PURE__ */ new Map();
35
+ }
36
+ // ---- reactive system ----
37
+ subscribe(scope, fn) {
38
+ if (!this.listeners.has(scope)) {
39
+ this.listeners.set(scope, /* @__PURE__ */ new Set());
40
+ }
41
+ this.listeners.get(scope).add(fn);
42
+ return () => {
43
+ this.listeners.get(scope)?.delete(fn);
44
+ };
45
+ }
46
+ notify(scope) {
47
+ this.listeners.get(scope)?.forEach((fn) => fn());
48
+ }
49
+ setPresenterId(id) {
50
+ if (this.presenterId === id) return;
51
+ this.presenterId = id;
52
+ this.notify("presenter");
53
+ this.notify("participants");
54
+ }
55
+ // ---- participants ----
56
+ addParticipant(p) {
57
+ if (this.participants.has(p.id)) return false;
58
+ const next = new Map(this.participants);
59
+ next.set(p.id, p);
60
+ this.participants = next;
61
+ this.notify("participants");
62
+ return true;
63
+ }
64
+ removeParticipant(id) {
65
+ const next = new Map(this.participants);
66
+ next.delete(id);
67
+ this.participants = next;
68
+ this.notify("participants");
69
+ }
70
+ updateParticipantMedia(id, patch) {
71
+ const p = this.participants.get(id);
72
+ if (!p) return;
73
+ const updated = {
74
+ ...p,
75
+ media: {
76
+ stream: null,
77
+ screenStream: void 0,
78
+ cameraTrack: void 0,
79
+ screenTrack: void 0,
80
+ audioTrack: void 0,
81
+ micEnabled: true,
82
+ camEnabled: true,
83
+ isScreenSharing: false,
84
+ ...p.media,
85
+ ...patch
86
+ }
87
+ };
88
+ const next = new Map(this.participants);
89
+ next.set(id, updated);
90
+ this.participants = next;
91
+ this.notify(`participant:${id}`);
92
+ this.notify("participants");
93
+ }
94
+ updateLocalParticipant(patch) {
95
+ const prev = this.localParticipant;
96
+ if (!prev) {
97
+ this.localParticipant = {
98
+ id: patch.id ?? "",
99
+ name: patch.name ?? "",
100
+ media: {
101
+ stream: patch.media?.stream ?? null,
102
+ // ◄ FIX: Capture the stream from the patch here
103
+ screenStream: patch.media?.screenStream,
104
+ cameraTrack: patch.media?.cameraTrack,
105
+ screenTrack: patch.media?.screenTrack,
106
+ audioTrack: patch.media?.audioTrack,
107
+ micEnabled: patch.media?.micEnabled ?? true,
108
+ camEnabled: patch.media?.camEnabled ?? true,
109
+ isScreenSharing: patch.media?.isScreenSharing ?? false
110
+ }
111
+ };
112
+ this.notify("localParticipant");
113
+ return;
114
+ }
115
+ const prevMedia = prev.media ?? {
116
+ stream: null,
117
+ screenStream: void 0,
118
+ cameraTrack: void 0,
119
+ screenTrack: void 0,
120
+ audioTrack: void 0,
121
+ micEnabled: true,
122
+ camEnabled: true,
123
+ isScreenSharing: false
124
+ };
125
+ const nextMedia = {
126
+ stream: patch.media?.stream ?? prevMedia.stream,
127
+ screenStream: patch.media?.screenStream ?? prevMedia.screenStream,
128
+ cameraTrack: patch.media?.cameraTrack ?? prevMedia.cameraTrack,
129
+ screenTrack: patch.media?.screenTrack ?? prevMedia.screenTrack,
130
+ audioTrack: patch.media?.audioTrack ?? prevMedia.audioTrack,
131
+ micEnabled: patch.media?.micEnabled ?? prevMedia.micEnabled,
132
+ camEnabled: patch.media?.camEnabled ?? prevMedia.camEnabled,
133
+ isScreenSharing: patch.media?.isScreenSharing ?? prevMedia.isScreenSharing
134
+ };
135
+ this.localParticipant = {
136
+ ...prev,
137
+ id: patch.id ?? prev.id,
138
+ name: patch.name ?? prev.name,
139
+ media: nextMedia
140
+ };
141
+ this.notify("localParticipant");
142
+ }
143
+ // ---- chat ----
144
+ addChatMessage(msg) {
145
+ this.chatMessages.set(msg.id, msg);
146
+ this.notify("chat");
147
+ }
148
+ getChatMessages() {
149
+ return Array.from(this.chatMessages.values()).sort(
150
+ (a, b) => a.timestamp - b.timestamp
151
+ );
152
+ }
153
+ clearChat() {
154
+ this.chatMessages.clear();
155
+ this.notify("chat");
156
+ }
157
+ // ---- helpers ----
158
+ getParticipants() {
159
+ return Array.from(this.participants.values());
160
+ }
161
+ getParticipant(id) {
162
+ return this.participants.get(id) ?? null;
163
+ }
164
+ resetRemoteState() {
165
+ this.participants.clear();
166
+ this.chatMessages.clear();
167
+ this.presenterId = null;
168
+ this.notify("participants");
169
+ this.notify("chat");
170
+ this.notify("presenter");
171
+ }
172
+ };
173
+
174
+ // src/core/SFUClient.ts
175
+ var SFUClient = class {
176
+ constructor(options = {}) {
177
+ this.ws = null;
178
+ this.publisher = null;
179
+ this.subscriber = null;
180
+ this.localStream = null;
181
+ this.screenStream = null;
182
+ this.videoSender = null;
183
+ this.audioSender = null;
184
+ this.screenSender = null;
185
+ this.iceServers = [];
186
+ this.requestId = 0;
187
+ this.publisherRevision = 0;
188
+ this.currentParticipantId = null;
189
+ this.token = null;
190
+ this.room = null;
191
+ this.resumeToken = null;
192
+ this.pendingJoinRequestId = null;
193
+ this.joinOptions = {};
194
+ this.state = "idle";
195
+ this.intentionalClose = false;
196
+ this.leaveAcknowledged = false;
197
+ this.joiningWithResume = false;
198
+ this.reconnectTimer = null;
199
+ this.reconnectAttempt = 0;
200
+ this.maxReconnectAttempts = 8;
201
+ this.reconnectBaseMs = 750;
202
+ this.joinWaiter = null;
203
+ this.pendingIce = {
204
+ publisher: [],
205
+ subscriber: []
206
+ };
207
+ this.subscriberNegotiating = false;
208
+ this.offerQueue = [];
209
+ this.subscriptionQueue = [];
210
+ this.subscriptionRunning = false;
211
+ this.subscriptionWaiters = [];
212
+ this.subscribedTracks = /* @__PURE__ */ new Set();
213
+ this.trackMetadata = /* @__PURE__ */ new Map();
214
+ this.participants = /* @__PURE__ */ new Map();
215
+ this.remoteStreams = /* @__PURE__ */ new Map();
216
+ this.trackToParticipant = /* @__PURE__ */ new Map();
217
+ this.trackToSource = /* @__PURE__ */ new Map();
218
+ this.localTrackIds = /* @__PURE__ */ new Map();
219
+ this.subscriberStatsTimer = null;
220
+ this.apiBase = options.apiBase || "https://api.example.com/api";
221
+ this.wsBase = options.wsBase || "wss://api.example.com/ws";
222
+ this.userId = options.userId || this.createId();
223
+ this.onStateChange = options.onStateChange || (() => {
224
+ });
225
+ this.onParticipantJoined = options.onParticipantJoined || (() => {
226
+ });
227
+ this.onParticipantLeft = options.onParticipantLeft || (() => {
228
+ });
229
+ this.onParticipantPresence = options.onParticipantPresence || (() => {
230
+ });
231
+ this.onTrackPublished = options.onTrackPublished || (() => {
232
+ });
233
+ this.onTrackUnpublished = options.onTrackUnpublished || (() => {
234
+ });
235
+ this.onRemoteTrack = options.onRemoteTrack || (() => {
236
+ });
237
+ this.onConnected = options.onConnected || (() => {
238
+ });
239
+ this.onDisconnected = options.onDisconnected || (() => {
240
+ });
241
+ this.onReconnecting = options.onReconnecting || (() => {
242
+ });
243
+ this.onJoinPending = options.onJoinPending || (() => {
244
+ });
245
+ this.onJoinApproved = options.onJoinApproved || (() => {
246
+ });
247
+ this.onJoinRejected = options.onJoinRejected || (() => {
248
+ });
249
+ this.onJoinRequested = options.onJoinRequested || (() => {
250
+ });
251
+ this.onScreenShareStarted = options.onScreenShareStarted || (() => {
252
+ });
253
+ this.onScreenShareStopped = options.onScreenShareStopped || (() => {
254
+ });
255
+ this.onTrackStateChanged = options.onTrackStateChanged || (() => {
256
+ });
257
+ this.onRecordingStarted = options.onRecordingStarted || (() => {
258
+ });
259
+ this.onRecordingStopped = options.onRecordingStopped || (() => {
260
+ });
261
+ this.onError = options.onError || console.error;
262
+ this.autoSubscribe = options.autoSubscribe !== false;
263
+ }
264
+ createId() {
265
+ return typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `id_${Math.random().toString(36).slice(2)}_${Date.now()}`;
266
+ }
267
+ resumeStorageKey(roomCode) {
268
+ return `defcomm:sfu:resume:${roomCode}`;
269
+ }
270
+ loadResumeToken(roomCode) {
271
+ try {
272
+ return localStorage.getItem(this.resumeStorageKey(roomCode));
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+ saveResumeToken(roomCode, token) {
278
+ try {
279
+ localStorage.setItem(this.resumeStorageKey(roomCode), token);
280
+ } catch {
281
+ }
282
+ }
283
+ clearResumeToken(roomCode) {
284
+ if (!roomCode) return;
285
+ try {
286
+ localStorage.removeItem(this.resumeStorageKey(roomCode));
287
+ } catch {
288
+ }
289
+ }
290
+ setState(next) {
291
+ if (this.state === next) return;
292
+ this.state = next;
293
+ this.onStateChange(next);
294
+ }
295
+ getConnectionState() {
296
+ return this.state;
297
+ }
298
+ getParticipantId() {
299
+ return this.currentParticipantId;
300
+ }
301
+ getResumeToken() {
302
+ return this.resumeToken;
303
+ }
304
+ getLocalStream() {
305
+ return this.localStream;
306
+ }
307
+ getScreenStream() {
308
+ return this.screenStream;
309
+ }
310
+ setLocalStream(stream) {
311
+ this.localStream = stream;
312
+ }
313
+ nextRequestId() {
314
+ return (++this.requestId).toString();
315
+ }
316
+ async request(url, options = {}) {
317
+ const res = await fetch(url, {
318
+ headers: { "Content-Type": "application/json" },
319
+ ...options
320
+ });
321
+ if (!res.ok) {
322
+ let body = {};
323
+ try {
324
+ body = await res.json();
325
+ } catch {
326
+ }
327
+ throw new Error(body.message || res.statusText);
328
+ }
329
+ return res.json();
330
+ }
331
+ async createRoom(input) {
332
+ return this.request(`${this.apiBase}/rooms`, {
333
+ method: "POST",
334
+ body: JSON.stringify({
335
+ name: input.name,
336
+ description: input.description ?? "",
337
+ capacity: input.capacity ?? 20,
338
+ is_private: input.is_private ?? false
339
+ })
340
+ });
341
+ }
342
+ async getRoom(roomCode) {
343
+ return this.request(`${this.apiBase}/rooms/${encodeURIComponent(roomCode)}`);
344
+ }
345
+ async deleteRoom(roomCode) {
346
+ return this.request(`${this.apiBase}/rooms/${encodeURIComponent(roomCode)}`, {
347
+ method: "DELETE"
348
+ });
349
+ }
350
+ async getToken(roomCode, options = {}) {
351
+ const nextRoom = roomCode.trim();
352
+ if (this.room && this.room !== nextRoom) {
353
+ this.resumeToken = null;
354
+ }
355
+ this.room = nextRoom;
356
+ this.joinOptions = {
357
+ displayName: options.displayName,
358
+ avatarUrl: options.avatarUrl
359
+ };
360
+ if (options.userId) this.userId = options.userId;
361
+ if (!this.resumeToken) {
362
+ this.resumeToken = this.loadResumeToken(this.room);
363
+ }
364
+ const data = await this.request(`${this.apiBase}/token`, {
365
+ method: "POST",
366
+ body: JSON.stringify({
367
+ room_code: this.room,
368
+ user_id: this.userId,
369
+ display_name: options.displayName,
370
+ avatar_url: options.avatarUrl
371
+ })
372
+ });
373
+ this.token = data?.token;
374
+ if (!this.token) throw new Error("Token endpoint returned no token");
375
+ return this.connect(this.token, this.joinOptions);
376
+ }
377
+ async refreshAccessToken() {
378
+ if (!this.room) throw new Error("Cannot refresh token without a room");
379
+ const data = await this.request(`${this.apiBase}/token`, {
380
+ method: "POST",
381
+ body: JSON.stringify({
382
+ room_code: this.room,
383
+ user_id: this.userId,
384
+ display_name: this.joinOptions.displayName,
385
+ avatar_url: this.joinOptions.avatarUrl
386
+ })
387
+ });
388
+ if (!data?.token) throw new Error("Token refresh returned no token");
389
+ this.token = data.token;
390
+ return this.token;
391
+ }
392
+ connect(token, options = {}) {
393
+ this.token = token;
394
+ this.intentionalClose = false;
395
+ this.setState(this.reconnectAttempt > 0 ? "reconnecting" : "connecting");
396
+ return new Promise((resolve, reject) => {
397
+ this.joinWaiter = { resolve, reject };
398
+ this.openWebSocket(options);
399
+ });
400
+ }
401
+ openWebSocket(options = {}) {
402
+ if (!this.token || !this.room) {
403
+ const error = new Error("Cannot connect without token and room");
404
+ this.failJoin(error);
405
+ return;
406
+ }
407
+ if (this.ws) {
408
+ try {
409
+ this.ws.onclose = null;
410
+ this.ws.close();
411
+ } catch {
412
+ }
413
+ }
414
+ const ws = new WebSocket(
415
+ `${this.wsBase}?access_token=${encodeURIComponent(this.token)}`
416
+ );
417
+ this.ws = ws;
418
+ ws.onopen = () => {
419
+ this.setState("joining");
420
+ this.joiningWithResume = Boolean(this.resumeToken);
421
+ const joinPayload = {
422
+ token: this.token,
423
+ room_code: this.room,
424
+ resume_token: this.resumeToken,
425
+ display_name: options.displayName,
426
+ avatar_url: options.avatarUrl
427
+ };
428
+ if (this.pendingJoinRequestId) {
429
+ this.send({
430
+ type: "join",
431
+ request_id: this.pendingJoinRequestId,
432
+ payload: joinPayload
433
+ });
434
+ } else {
435
+ this.send({ type: "join", payload: joinPayload });
436
+ }
437
+ };
438
+ ws.onerror = (event) => {
439
+ this.onError(event);
440
+ };
441
+ ws.onclose = () => {
442
+ if (this.ws !== ws) return;
443
+ this.ws = null;
444
+ if (this.intentionalClose || this.state === "leaving") {
445
+ this.setState("disconnected");
446
+ this.onDisconnected();
447
+ return;
448
+ }
449
+ this.setState("reconnecting");
450
+ this.onDisconnected();
451
+ this.scheduleReconnect();
452
+ };
453
+ ws.onmessage = async (event) => {
454
+ if (this.ws !== ws) return;
455
+ try {
456
+ const message = JSON.parse(event.data);
457
+ await this.handleServerMessage(message);
458
+ } catch (error) {
459
+ this.onError(error);
460
+ }
461
+ };
462
+ }
463
+ scheduleReconnect() {
464
+ if (this.reconnectTimer || this.intentionalClose) return;
465
+ if (this.reconnectAttempt >= this.maxReconnectAttempts) {
466
+ this.setState("disconnected");
467
+ this.failJoin(new Error("SFU reconnect attempts exhausted"));
468
+ return;
469
+ }
470
+ this.reconnectAttempt += 1;
471
+ const delay = Math.min(
472
+ 15e3,
473
+ this.reconnectBaseMs * Math.pow(2, this.reconnectAttempt - 1)
474
+ );
475
+ this.onReconnecting(this.reconnectAttempt);
476
+ this.reconnectTimer = setTimeout(async () => {
477
+ this.reconnectTimer = null;
478
+ try {
479
+ await this.refreshAccessToken();
480
+ this.openWebSocket(this.joinOptions);
481
+ } catch (error) {
482
+ this.onError(error);
483
+ this.scheduleReconnect();
484
+ }
485
+ }, delay);
486
+ }
487
+ failJoin(error) {
488
+ if (this.joinWaiter) {
489
+ const waiter = this.joinWaiter;
490
+ this.joinWaiter = null;
491
+ waiter.reject(error);
492
+ }
493
+ }
494
+ resolveJoin(value) {
495
+ if (!this.joinWaiter) return;
496
+ const waiter = this.joinWaiter;
497
+ this.joinWaiter = null;
498
+ waiter.resolve(value);
499
+ }
500
+ async handleServerMessage(message) {
501
+ const type = message?.type;
502
+ const payload = message?.payload || {};
503
+ switch (type) {
504
+ case "joined": {
505
+ await this.handleJoined(payload);
506
+ break;
507
+ }
508
+ case "left": {
509
+ this.leaveAcknowledged = true;
510
+ break;
511
+ }
512
+ case "join_pending": {
513
+ const requestId = payload.request_id || message.request_id;
514
+ if (!requestId) throw new Error("join_pending has no request_id");
515
+ this.pendingJoinRequestId = requestId;
516
+ this.setState("waiting_approval");
517
+ this.onJoinPending({
518
+ request_id: requestId,
519
+ room_code: payload.room_code || this.room || void 0,
520
+ message: payload.message
521
+ });
522
+ break;
523
+ }
524
+ case "join_approved": {
525
+ const requestId = payload.request_id || message.request_id;
526
+ if (!requestId) break;
527
+ this.pendingJoinRequestId = requestId;
528
+ this.onJoinApproved(requestId);
529
+ if (this.ws?.readyState === WebSocket.OPEN) {
530
+ this.setState("joining");
531
+ this.send({
532
+ type: "join",
533
+ request_id: requestId,
534
+ payload: {
535
+ token: this.token,
536
+ room_code: this.room,
537
+ resume_token: this.resumeToken,
538
+ display_name: this.joinOptions.displayName,
539
+ avatar_url: this.joinOptions.avatarUrl
540
+ }
541
+ });
542
+ }
543
+ break;
544
+ }
545
+ case "join_rejected": {
546
+ const requestId = payload.request_id || message.request_id;
547
+ const reason = payload.reason;
548
+ this.onJoinRejected(requestId, reason);
549
+ this.pendingJoinRequestId = null;
550
+ this.intentionalClose = true;
551
+ this.failJoin(new Error(reason || "Join request rejected"));
552
+ break;
553
+ }
554
+ case "join_requested":
555
+ case "join_request": {
556
+ this.onJoinRequested(payload.request || payload);
557
+ break;
558
+ }
559
+ case "publish_answer": {
560
+ if (!this.publisher) break;
561
+ await this.publisher.setRemoteDescription({
562
+ type: "answer",
563
+ sdp: payload.sdp
564
+ });
565
+ await this.flushPendingIce("publisher");
566
+ break;
567
+ }
568
+ case "subscribe_offer": {
569
+ this.offerQueue.push(payload);
570
+ if (!this.subscriberNegotiating) await this.processOfferQueue();
571
+ break;
572
+ }
573
+ case "ice_candidate":
574
+ case "publisher_ice_candidate":
575
+ case "subscriber_ice_candidate": {
576
+ const candidatePayload = {
577
+ ...payload,
578
+ peer: payload.peer || (type === "publisher_ice_candidate" ? "publisher" : "subscriber")
579
+ };
580
+ await this.handleIce(candidatePayload);
581
+ break;
582
+ }
583
+ case "participant_joined": {
584
+ const participant = payload.participant || payload;
585
+ const participantId = participant?.participant_id || payload.participant_id;
586
+ if (!participantId || participantId === this.currentParticipantId) break;
587
+ if (participant) this.participants.set(participantId, participant);
588
+ this.onParticipantJoined(participant);
589
+ break;
590
+ }
591
+ case "participant_left": {
592
+ const participantId = payload.participant_id;
593
+ if (!participantId) break;
594
+ this.participants.delete(participantId);
595
+ this.removeRemoteParticipant(participantId);
596
+ this.onParticipantLeft(participantId);
597
+ break;
598
+ }
599
+ case "participant_presence":
600
+ case "participant_presence_changed": {
601
+ const participantId = payload.participant_id;
602
+ const presence = payload.presence;
603
+ if (participantId && presence) {
604
+ this.onParticipantPresence(participantId, String(presence));
605
+ }
606
+ break;
607
+ }
608
+ case "track_published": {
609
+ const track = payload.track || payload;
610
+ if (!track?.track_id) break;
611
+ this.rememberTrack(track);
612
+ if (track.participant_id === this.currentParticipantId) {
613
+ if (track.source === "audio") {
614
+ this.localTrackIds.set("audio", track.track_id);
615
+ const muted = !(this.localStream?.getAudioTracks()[0]?.enabled ?? true);
616
+ this.send({ type: "track_state", payload: { track_id: track.track_id, muted } });
617
+ }
618
+ if (track.source === "camera") {
619
+ this.localTrackIds.set("camera", track.track_id);
620
+ const muted = !(this.localStream?.getVideoTracks()[0]?.enabled ?? true);
621
+ this.send({ type: "track_state", payload: { track_id: track.track_id, muted } });
622
+ }
623
+ if (track.source === "screen") this.localTrackIds.set("screen", track.track_id);
624
+ break;
625
+ }
626
+ this.onTrackPublished(track);
627
+ if (this.autoSubscribe) await this.subscribe(track.track_id);
628
+ break;
629
+ }
630
+ case "track_unpublished": {
631
+ const trackId = payload.track_id || payload.track?.track_id;
632
+ if (!trackId) break;
633
+ const track = this.trackMetadata.get(trackId) || {
634
+ track_id: trackId,
635
+ participant_id: this.trackToParticipant.get(trackId) || "",
636
+ source: this.trackToSource.get(trackId),
637
+ kind: "video"
638
+ };
639
+ this.trackMetadata.delete(trackId);
640
+ this.subscribedTracks.delete(trackId);
641
+ this.trackToParticipant.delete(trackId);
642
+ this.trackToSource.delete(trackId);
643
+ for (const [source, localId] of this.localTrackIds) {
644
+ if (localId === trackId) this.localTrackIds.delete(source);
645
+ }
646
+ if (track.participant_id === this.currentParticipantId) break;
647
+ this.onTrackUnpublished(track);
648
+ break;
649
+ }
650
+ case "track_state_changed": {
651
+ const trackId = payload.track_id || payload.track?.track_id;
652
+ if (!trackId) break;
653
+ const existing = this.trackMetadata.get(trackId);
654
+ const track = {
655
+ ...existing || {},
656
+ ...payload.track || {},
657
+ track_id: trackId,
658
+ participant_id: payload.participant_id || existing?.participant_id || payload.track?.participant_id,
659
+ kind: payload.kind || payload.track?.kind || existing?.kind || "video",
660
+ source: payload.source || payload.track?.source || existing?.source,
661
+ muted: Boolean(payload.muted)
662
+ };
663
+ if (!track.participant_id) break;
664
+ this.rememberTrack(track);
665
+ this.onTrackStateChanged(track);
666
+ break;
667
+ }
668
+ case "subscription_updated": {
669
+ break;
670
+ }
671
+ case "recording_started": {
672
+ this.onRecordingStarted({
673
+ recording_id: payload.recording_id,
674
+ started_at: payload.started_at
675
+ });
676
+ break;
677
+ }
678
+ case "recording_stopped": {
679
+ this.onRecordingStopped({
680
+ recording_id: payload.recording_id,
681
+ stopped_at: payload.stopped_at
682
+ });
683
+ break;
684
+ }
685
+ case "reconnecting": {
686
+ this.setState("reconnecting");
687
+ break;
688
+ }
689
+ case "screen_share_started": {
690
+ const participantId = payload.participant_id;
691
+ const track = payload.track;
692
+ if (track) this.rememberTrack(track);
693
+ if (participantId) this.onScreenShareStarted(participantId, track);
694
+ break;
695
+ }
696
+ case "screen_share_stopped": {
697
+ const participantId = payload.participant_id;
698
+ const stream = participantId ? this.remoteStreams.get(participantId) : null;
699
+ const trackId = payload.track_id;
700
+ if (stream) {
701
+ for (const track of stream.getVideoTracks()) {
702
+ const source = this.trackToSource.get(this.normalizeRemoteTrackId(track.id));
703
+ if (!trackId || this.normalizeRemoteTrackId(track.id) === String(trackId) || source === "screen") {
704
+ stream.removeTrack(track);
705
+ track.stop();
706
+ }
707
+ }
708
+ }
709
+ this.onScreenShareStopped(participantId, trackId);
710
+ break;
711
+ }
712
+ case "error": {
713
+ const error = new Error(payload.message || "SFU signaling error");
714
+ this.onError(error);
715
+ if (this.state === "joining" || this.state === "waiting_approval") {
716
+ this.failJoin(error);
717
+ }
718
+ break;
719
+ }
720
+ default:
721
+ console.debug("[SFU] Ignoring unknown server message", message);
722
+ }
723
+ }
724
+ rememberTrack(track) {
725
+ this.trackMetadata.set(track.track_id, track);
726
+ this.trackToParticipant.set(track.track_id, track.participant_id);
727
+ if (track.source) this.trackToSource.set(track.track_id, track.source);
728
+ }
729
+ async handleJoined(payload) {
730
+ const resumed = this.joiningWithResume;
731
+ this.joiningWithResume = false;
732
+ this.iceServers = payload.ice_servers || this.iceServers || [];
733
+ this.currentParticipantId = payload.participant_id;
734
+ this.resumeToken = payload.resume_token || payload.resumeToken || this.resumeToken;
735
+ if (this.room && this.resumeToken) {
736
+ this.saveResumeToken(this.room, this.resumeToken);
737
+ }
738
+ this.pendingJoinRequestId = null;
739
+ this.reconnectAttempt = 0;
740
+ this.participants.clear();
741
+ this.trackMetadata.clear();
742
+ this.trackToParticipant.clear();
743
+ this.trackToSource.clear();
744
+ this.localTrackIds.clear();
745
+ for (const participant of payload.participants || []) {
746
+ if (participant?.participant_id) {
747
+ this.participants.set(participant.participant_id, participant);
748
+ }
749
+ }
750
+ for (const track of payload.tracks || []) {
751
+ if (track?.track_id) this.rememberTrack(track);
752
+ }
753
+ await this.initializePeerConnections();
754
+ this.setState("connected");
755
+ this.onConnected(payload, resumed);
756
+ for (const participant of this.participants.values()) {
757
+ if (participant.participant_id !== this.currentParticipantId) {
758
+ this.onParticipantJoined(participant);
759
+ }
760
+ }
761
+ if (this.localStream) {
762
+ await this.publishExistingTracks();
763
+ }
764
+ if (this.autoSubscribe) {
765
+ const trackIds = [...this.trackMetadata.values()].filter((track) => track.participant_id !== this.currentParticipantId).map((track) => track.track_id);
766
+ if (trackIds.length) await this.subscribeMultiple(trackIds);
767
+ }
768
+ this.resolveJoin(payload);
769
+ }
770
+ async initializePeerConnections() {
771
+ this.closePeerConnectionsOnly();
772
+ this.publisherRevision = 0;
773
+ const config = { iceServers: this.iceServers };
774
+ this.publisher = new RTCPeerConnection(config);
775
+ this.subscriber = new RTCPeerConnection(config);
776
+ this.publisher.onicecandidate = (event) => {
777
+ if (event.candidate) this.sendCandidate("publisher", event.candidate);
778
+ };
779
+ this.subscriber.onicecandidate = (event) => {
780
+ if (event.candidate) this.sendCandidate("subscriber", event.candidate);
781
+ };
782
+ this.publisher.onconnectionstatechange = () => {
783
+ const state = this.publisher?.connectionState;
784
+ if (state === "failed") {
785
+ this.onError(new Error("Publisher PeerConnection failed"));
786
+ }
787
+ };
788
+ this.subscriber.onconnectionstatechange = () => {
789
+ const state = this.subscriber?.connectionState;
790
+ if (state === "failed") {
791
+ this.onError(new Error("Subscriber PeerConnection failed"));
792
+ }
793
+ };
794
+ this.subscriber.ontrack = (event) => {
795
+ void this.handleRemoteTrack(event);
796
+ };
797
+ if (!this.subscriberStatsTimer) {
798
+ this.subscriberStatsTimer = setInterval(() => {
799
+ void this.logSubscriberVideoStats();
800
+ }, 3e3);
801
+ }
802
+ }
803
+ async handleRemoteTrack(event) {
804
+ const stream = event.streams?.[0] || null;
805
+ const metadata = this.resolveRemoteTrackMetadata(event.track, stream);
806
+ if (!metadata?.participant_id) {
807
+ this.onError(
808
+ new Error(
809
+ `Remote ${event.track.kind} track ${event.track.id} has no SFU participant mapping`
810
+ )
811
+ );
812
+ return;
813
+ }
814
+ const participantId = metadata.participant_id;
815
+ let remoteStream = this.remoteStreams.get(participantId);
816
+ if (!remoteStream) {
817
+ remoteStream = new MediaStream();
818
+ this.remoteStreams.set(participantId, remoteStream);
819
+ }
820
+ const source = metadata.source || this.trackToSource.get(metadata.track_id);
821
+ if (source === "screen") {
822
+ for (const existing of remoteStream.getVideoTracks()) {
823
+ if (existing.id !== event.track.id && this.trackToSource.get(existing.id) === "screen") {
824
+ remoteStream.removeTrack(existing);
825
+ existing.stop();
826
+ }
827
+ }
828
+ } else {
829
+ for (const existing of remoteStream.getTracks()) {
830
+ if (existing.kind === event.track.kind && existing.id !== event.track.id) {
831
+ remoteStream.removeTrack(existing);
832
+ existing.stop();
833
+ }
834
+ }
835
+ }
836
+ if (!remoteStream.getTracks().some((track) => track.id === event.track.id)) {
837
+ remoteStream.addTrack(event.track);
838
+ }
839
+ event.track.onended = () => {
840
+ try {
841
+ remoteStream?.removeTrack(event.track);
842
+ } catch {
843
+ }
844
+ };
845
+ if (source === "screen") {
846
+ this.onScreenShareStarted(participantId, metadata);
847
+ }
848
+ this.onRemoteTrack(event.track, remoteStream, metadata);
849
+ }
850
+ normalizeRemoteTrackId(browserTrackId) {
851
+ return browserTrackId?.startsWith("track-") ? browserTrackId.slice("track-".length) : browserTrackId;
852
+ }
853
+ resolveRemoteTrackMetadata(track, stream) {
854
+ const sfuTrackId = this.normalizeRemoteTrackId(track.id);
855
+ const exact = this.trackMetadata.get(sfuTrackId);
856
+ if (exact) return exact;
857
+ const byTrackId = this.trackMetadata.get(track.id);
858
+ if (byTrackId) return byTrackId;
859
+ const matching = [...this.trackMetadata.values()].find(
860
+ (entry) => entry.kind === track.kind
861
+ );
862
+ if (matching) return matching;
863
+ if (stream?.id) {
864
+ const participant = this.participants.get(stream.id);
865
+ if (participant) {
866
+ return [...this.trackMetadata.values()].find(
867
+ (entry) => entry.participant_id === participant.participant_id && entry.kind === track.kind
868
+ ) || null;
869
+ }
870
+ }
871
+ return null;
872
+ }
873
+ sendCandidate(peer, candidate) {
874
+ this.send({
875
+ type: "ice_candidate",
876
+ payload: {
877
+ peer,
878
+ candidate: candidate.candidate,
879
+ sdp_mid: candidate.sdpMid,
880
+ sdp_mline_index: candidate.sdpMLineIndex,
881
+ username_fragment: candidate.usernameFragment
882
+ }
883
+ });
884
+ }
885
+ async handleIce(payload) {
886
+ const peer = payload.peer === "publisher" ? "publisher" : "subscriber";
887
+ const pc = peer === "publisher" ? this.publisher : this.subscriber;
888
+ const candidate = {
889
+ candidate: payload.candidate,
890
+ sdpMid: payload.sdp_mid ?? payload.sdpMid ?? null,
891
+ sdpMLineIndex: payload.sdp_mline_index ?? payload.sdpMLineIndex ?? null,
892
+ usernameFragment: payload.username_fragment ?? payload.usernameFragment ?? null
893
+ };
894
+ if (!pc || !pc.remoteDescription) {
895
+ this.pendingIce[peer].push(candidate);
896
+ return;
897
+ }
898
+ try {
899
+ await pc.addIceCandidate(candidate);
900
+ } catch (error) {
901
+ this.onError(new Error(`Failed to add ${peer} ICE candidate: ${String(error)}`));
902
+ }
903
+ }
904
+ async flushPendingIce(peer) {
905
+ const pc = peer === "publisher" ? this.publisher : this.subscriber;
906
+ if (!pc || !pc.remoteDescription) return;
907
+ const queue = this.pendingIce[peer];
908
+ while (queue.length) {
909
+ const candidate = queue.shift();
910
+ try {
911
+ await pc.addIceCandidate(candidate);
912
+ } catch (error) {
913
+ this.onError(new Error(`Failed to add queued ${peer} ICE candidate: ${String(error)}`));
914
+ }
915
+ }
916
+ }
917
+ async processOfferQueue() {
918
+ if (!this.subscriber) return;
919
+ while (this.offerQueue.length) {
920
+ if (this.subscriberNegotiating) return;
921
+ this.subscriberNegotiating = true;
922
+ const offer = this.offerQueue.shift();
923
+ try {
924
+ await this.subscriber.setRemoteDescription({
925
+ type: "offer",
926
+ sdp: offer.sdp
927
+ });
928
+ await this.flushPendingIce("subscriber");
929
+ const answer = await this.subscriber.createAnswer();
930
+ await this.subscriber.setLocalDescription(answer);
931
+ this.send({
932
+ type: "subscribe_answer",
933
+ payload: {
934
+ revision: offer.revision,
935
+ sdp: answer.sdp
936
+ }
937
+ });
938
+ const waiters = this.subscriptionWaiters.splice(0);
939
+ waiters.forEach((w) => w.resolve());
940
+ } catch (error) {
941
+ const waiters = this.subscriptionWaiters.splice(0);
942
+ waiters.forEach((w) => w.reject(error));
943
+ this.onError(new Error(`Failed to process subscriber offer: ${String(error)}`));
944
+ } finally {
945
+ this.subscriberNegotiating = false;
946
+ }
947
+ }
948
+ }
949
+ async subscribe(trackId) {
950
+ if (!trackId || this.subscribedTracks.has(trackId)) return;
951
+ this.subscribedTracks.add(trackId);
952
+ this.subscriptionQueue.push(trackId);
953
+ if (!this.subscriptionRunning) await this.processSubscriptionQueue();
954
+ }
955
+ async subscribeMultiple(trackIds) {
956
+ const uniqueIds = [...new Set(trackIds.filter(Boolean))].filter(
957
+ (id) => !this.subscribedTracks.has(id)
958
+ );
959
+ if (!uniqueIds.length || !this.isSocketOpen()) return;
960
+ uniqueIds.forEach((id) => this.subscribedTracks.add(id));
961
+ return new Promise((resolve, reject) => {
962
+ this.subscriptionWaiters.push({ resolve, reject });
963
+ this.send({
964
+ type: "subscribe",
965
+ payload: { track_ids: uniqueIds }
966
+ });
967
+ });
968
+ }
969
+ async processSubscriptionQueue() {
970
+ this.subscriptionRunning = true;
971
+ try {
972
+ while (this.subscriptionQueue.length) {
973
+ const trackId = this.subscriptionQueue.shift();
974
+ if (!this.isSocketOpen()) {
975
+ this.subscribedTracks.delete(trackId);
976
+ continue;
977
+ }
978
+ await new Promise((resolve, reject) => {
979
+ this.subscriptionWaiters.push({ resolve, reject });
980
+ this.send({
981
+ type: "subscribe",
982
+ payload: { track_ids: [trackId] }
983
+ });
984
+ });
985
+ }
986
+ } finally {
987
+ this.subscriptionRunning = false;
988
+ }
989
+ }
990
+ async publish({ audio = true, video = true } = {}) {
991
+ if (!this.publisher) throw new Error("Publisher PeerConnection is not initialized");
992
+ if (!this.localStream) {
993
+ this.localStream = await navigator.mediaDevices.getUserMedia({ audio, video });
994
+ }
995
+ await this.publishExistingTracks(audio, video);
996
+ return this.localStream;
997
+ }
998
+ async publishExistingTracks(audio = true, video = true) {
999
+ if (!this.publisher || !this.localStream) return;
1000
+ this.audioSender = null;
1001
+ this.videoSender = null;
1002
+ for (const track of this.localStream.getTracks()) {
1003
+ if (track.kind === "audio" && !audio) continue;
1004
+ if (track.kind === "video" && !video) continue;
1005
+ const sender = this.publisher.addTrack(track, this.localStream);
1006
+ if (track.kind === "audio") this.audioSender = sender;
1007
+ if (track.kind === "video") this.videoSender = sender;
1008
+ }
1009
+ if (this.screenStream?.getVideoTracks()[0]) {
1010
+ const screenTrack = this.screenStream.getVideoTracks()[0];
1011
+ this.screenSender = this.publisher.addTrack(screenTrack, this.screenStream);
1012
+ }
1013
+ if (!this.publisher.getSenders().length) return;
1014
+ const offer = await this.publisher.createOffer();
1015
+ await this.publisher.setLocalDescription(offer);
1016
+ this.send({
1017
+ type: "publish_offer",
1018
+ payload: {
1019
+ revision: ++this.publisherRevision,
1020
+ sdp: offer.sdp
1021
+ }
1022
+ });
1023
+ if (this.screenStream?.getVideoTracks()[0]) {
1024
+ this.send({ type: "start_screen_share" });
1025
+ }
1026
+ }
1027
+ async shareScreen() {
1028
+ if (!this.publisher) throw new Error("Publisher PeerConnection is not initialized");
1029
+ if (this.screenStream) return this.screenStream;
1030
+ this.screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
1031
+ const screenTrack = this.screenStream.getVideoTracks()[0];
1032
+ if (!screenTrack) throw new Error("Screen share returned no video track");
1033
+ this.screenSender = this.publisher.addTrack(screenTrack, this.screenStream);
1034
+ const offer = await this.publisher.createOffer();
1035
+ await this.publisher.setLocalDescription(offer);
1036
+ this.send({
1037
+ type: "publish_offer",
1038
+ payload: {
1039
+ revision: ++this.publisherRevision,
1040
+ sdp: offer.sdp
1041
+ }
1042
+ });
1043
+ this.send({ type: "start_screen_share" });
1044
+ screenTrack.onended = () => {
1045
+ void this.stopScreenShare();
1046
+ };
1047
+ return this.screenStream;
1048
+ }
1049
+ async stopScreenShare() {
1050
+ if (!this.screenStream) return;
1051
+ const screenTrack = this.screenStream.getVideoTracks()[0];
1052
+ if (screenTrack) screenTrack.onended = null;
1053
+ if (this.screenSender && this.publisher) {
1054
+ try {
1055
+ this.publisher.removeTrack(this.screenSender);
1056
+ } catch {
1057
+ }
1058
+ this.screenSender = null;
1059
+ try {
1060
+ const offer = await this.publisher.createOffer();
1061
+ await this.publisher.setLocalDescription(offer);
1062
+ this.send({
1063
+ type: "publish_offer",
1064
+ payload: {
1065
+ revision: ++this.publisherRevision,
1066
+ sdp: offer.sdp
1067
+ }
1068
+ });
1069
+ this.send({ type: "stop_screen_share" });
1070
+ } catch (error) {
1071
+ this.onError(error);
1072
+ }
1073
+ }
1074
+ this.screenStream.getTracks().forEach((track) => track.stop());
1075
+ this.screenStream = null;
1076
+ }
1077
+ muteAudio(muted) {
1078
+ this.localStream?.getAudioTracks().forEach((track) => {
1079
+ track.enabled = !muted;
1080
+ });
1081
+ const trackId = this.localTrackIds.get("audio");
1082
+ if (trackId) {
1083
+ this.send({
1084
+ type: "track_state",
1085
+ payload: {
1086
+ track_id: trackId,
1087
+ muted
1088
+ }
1089
+ });
1090
+ }
1091
+ }
1092
+ muteVideo(muted) {
1093
+ this.localStream?.getVideoTracks().forEach((track) => {
1094
+ track.enabled = !muted;
1095
+ });
1096
+ const trackId = this.localTrackIds.get("camera");
1097
+ if (trackId) {
1098
+ this.send({
1099
+ type: "track_state",
1100
+ payload: {
1101
+ track_id: trackId,
1102
+ muted
1103
+ }
1104
+ });
1105
+ }
1106
+ }
1107
+ async startRecording() {
1108
+ if (!this.send({ type: "start_recording" })) {
1109
+ throw new Error("SFU signaling is not connected");
1110
+ }
1111
+ }
1112
+ async stopRecording() {
1113
+ if (!this.send({ type: "stop_recording" })) {
1114
+ throw new Error("SFU signaling is not connected");
1115
+ }
1116
+ }
1117
+ async getRecordingStatus() {
1118
+ if (!this.room || !this.token) {
1119
+ throw new Error("SFU client is not connected");
1120
+ }
1121
+ return this.request(
1122
+ `${this.apiBase}/rooms/${encodeURIComponent(this.room)}/recording`,
1123
+ {
1124
+ headers: {
1125
+ Authorization: `Bearer ${this.token}`
1126
+ }
1127
+ }
1128
+ );
1129
+ }
1130
+ async approveJoinRequest(requestId) {
1131
+ if (!requestId) throw new Error("requestId is required");
1132
+ if (!this.send({ type: "approve_join", payload: { request_id: requestId } })) {
1133
+ throw new Error("SFU signaling is not connected");
1134
+ }
1135
+ }
1136
+ async rejectJoinRequest(requestId, reason) {
1137
+ if (!requestId) throw new Error("requestId is required");
1138
+ if (!this.send({ type: "reject_join", payload: { request_id: requestId, reason } })) {
1139
+ throw new Error("SFU signaling is not connected");
1140
+ }
1141
+ }
1142
+ isSocketOpen() {
1143
+ return this.ws?.readyState === WebSocket.OPEN;
1144
+ }
1145
+ send(message) {
1146
+ if (!this.isSocketOpen()) return false;
1147
+ this.ws.send(
1148
+ JSON.stringify({
1149
+ request_id: this.nextRequestId(),
1150
+ ...message
1151
+ })
1152
+ );
1153
+ return true;
1154
+ }
1155
+ removeRemoteParticipant(participantId) {
1156
+ const stream = this.remoteStreams.get(participantId);
1157
+ if (stream) {
1158
+ stream.getTracks().forEach((track) => track.stop());
1159
+ this.remoteStreams.delete(participantId);
1160
+ }
1161
+ for (const [trackId, metadata] of this.trackMetadata) {
1162
+ if (metadata.participant_id === participantId) {
1163
+ this.trackMetadata.delete(trackId);
1164
+ this.subscribedTracks.delete(trackId);
1165
+ this.trackToParticipant.delete(trackId);
1166
+ this.trackToSource.delete(trackId);
1167
+ }
1168
+ }
1169
+ }
1170
+ async leave() {
1171
+ this.intentionalClose = true;
1172
+ this.setState("leaving");
1173
+ if (this.reconnectTimer) {
1174
+ clearTimeout(this.reconnectTimer);
1175
+ this.reconnectTimer = null;
1176
+ }
1177
+ const roomCode = this.room;
1178
+ const ws = this.ws;
1179
+ this.leaveAcknowledged = false;
1180
+ if (ws?.readyState === WebSocket.OPEN) {
1181
+ await new Promise((resolve) => {
1182
+ let settled = false;
1183
+ const finish = () => {
1184
+ if (settled) return;
1185
+ settled = true;
1186
+ resolve();
1187
+ };
1188
+ const timeout = setTimeout(finish, 1200);
1189
+ const check = () => {
1190
+ if (this.leaveAcknowledged || this.ws !== ws) {
1191
+ clearTimeout(timeout);
1192
+ finish();
1193
+ return;
1194
+ }
1195
+ if (ws.readyState !== WebSocket.OPEN) {
1196
+ clearTimeout(timeout);
1197
+ finish();
1198
+ return;
1199
+ }
1200
+ setTimeout(check, 25);
1201
+ };
1202
+ ws.addEventListener("close", () => {
1203
+ clearTimeout(timeout);
1204
+ finish();
1205
+ }, { once: true });
1206
+ this.send({ type: "leave" });
1207
+ check();
1208
+ });
1209
+ try {
1210
+ ws.close(1e3, "client_leave");
1211
+ } catch {
1212
+ }
1213
+ }
1214
+ this.clearResumeToken(roomCode);
1215
+ this.cleanupAll();
1216
+ this.setState("disconnected");
1217
+ }
1218
+ closePeerConnectionsOnly() {
1219
+ if (this.publisher) {
1220
+ try {
1221
+ this.publisher.close();
1222
+ } catch {
1223
+ }
1224
+ this.publisher = null;
1225
+ }
1226
+ if (this.subscriber) {
1227
+ try {
1228
+ this.subscriber.close();
1229
+ } catch {
1230
+ }
1231
+ this.subscriber = null;
1232
+ }
1233
+ this.videoSender = null;
1234
+ this.audioSender = null;
1235
+ this.screenSender = null;
1236
+ this.pendingIce = { publisher: [], subscriber: [] };
1237
+ this.offerQueue = [];
1238
+ this.subscriberNegotiating = false;
1239
+ this.subscriptionQueue = [];
1240
+ this.subscriptionRunning = false;
1241
+ this.subscriptionWaiters.splice(0).forEach((w) => w.reject(new Error("PeerConnection recreated")));
1242
+ this.subscribedTracks.clear();
1243
+ }
1244
+ cleanupAll() {
1245
+ if (this.subscriberStatsTimer) {
1246
+ clearInterval(this.subscriberStatsTimer);
1247
+ this.subscriberStatsTimer = null;
1248
+ }
1249
+ this.closePeerConnectionsOnly();
1250
+ this.localStream?.getTracks().forEach((track) => track.stop());
1251
+ this.screenStream?.getTracks().forEach((track) => track.stop());
1252
+ this.localStream = null;
1253
+ this.screenStream = null;
1254
+ for (const stream of this.remoteStreams.values()) {
1255
+ stream.getTracks().forEach((track) => track.stop());
1256
+ }
1257
+ this.remoteStreams.clear();
1258
+ this.trackMetadata.clear();
1259
+ this.trackToParticipant.clear();
1260
+ this.trackToSource.clear();
1261
+ this.localTrackIds.clear();
1262
+ this.participants.clear();
1263
+ this.currentParticipantId = null;
1264
+ this.resumeToken = null;
1265
+ this.pendingJoinRequestId = null;
1266
+ this.token = null;
1267
+ this.room = null;
1268
+ if (this.ws) {
1269
+ try {
1270
+ this.ws.close();
1271
+ } catch {
1272
+ }
1273
+ this.ws = null;
1274
+ }
1275
+ }
1276
+ async logSubscriberVideoStats() {
1277
+ if (!this.subscriber || this.subscriber.connectionState === "closed") return;
1278
+ try {
1279
+ const stats = await this.subscriber.getStats();
1280
+ stats.forEach((report) => {
1281
+ if (report.type === "inbound-rtp" && (report.kind === "video" || report.mediaType === "video")) {
1282
+ console.debug("[SFU] subscriber video", {
1283
+ packetsReceived: report.packetsReceived,
1284
+ packetsLost: report.packetsLost,
1285
+ framesDecoded: report.framesDecoded,
1286
+ frameWidth: report.frameWidth,
1287
+ frameHeight: report.frameHeight
1288
+ });
1289
+ }
1290
+ });
1291
+ } catch {
1292
+ }
1293
+ }
1294
+ };
1295
+
1296
+ // src/core/VideoCore.ts
1297
+ var VideoSDKCore = class {
1298
+ constructor(events = {}, apiBase = SDK_CONFIG.apiBase || "https://api.example.com/api", wsBase = SDK_CONFIG.wsBase || "wss://api.example.com/ws") {
1299
+ this.events = events;
1300
+ this.apiBase = apiBase;
1301
+ this.wsBase = wsBase;
1302
+ this.sfuClient = null;
1303
+ this.room = {
1304
+ id: null,
1305
+ name: null
1306
+ };
1307
+ this.localStream = null;
1308
+ this.screenStream = null;
1309
+ this.isScreenSharing = false;
1310
+ this.participantName = "";
1311
+ this.intentionalDisconnect = false;
1312
+ this.remoteStreams = /* @__PURE__ */ new Map();
1313
+ this.remoteScreenStreams = /* @__PURE__ */ new Map();
1314
+ this.state = new MeetingState();
1315
+ this.events = events;
1316
+ let storedId = null;
1317
+ try {
1318
+ storedId = typeof localStorage !== "undefined" ? localStorage.getItem("defcomm:participant_id") : null;
1319
+ } catch {
1320
+ }
1321
+ this.myId = storedId || (typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `user_${Date.now()}`);
1322
+ try {
1323
+ if (typeof localStorage !== "undefined") {
1324
+ localStorage.setItem("defcomm:participant_id", this.myId);
1325
+ }
1326
+ } catch {
1327
+ }
1328
+ }
1329
+ emitError(code, message, raw, recoverable = true) {
1330
+ const err = {
1331
+ code,
1332
+ message,
1333
+ raw,
1334
+ roomId: this.room.id,
1335
+ userId: this.myId,
1336
+ recoverable
1337
+ };
1338
+ this.events.onError?.(err);
1339
+ console.error("[MeetingSDK Error]", err);
1340
+ }
1341
+ // ============ LOCAL MEDIA ============
1342
+ async initLocal(video, name) {
1343
+ this.participantName = name;
1344
+ try {
1345
+ this.localStream = await navigator.mediaDevices.getUserMedia({
1346
+ video: {
1347
+ width: { ideal: 1280 },
1348
+ height: { ideal: 720 }
1349
+ },
1350
+ audio: {
1351
+ echoCancellation: true,
1352
+ noiseSuppression: true,
1353
+ autoGainControl: true
1354
+ }
1355
+ });
1356
+ const hasVideo = this.localStream.getVideoTracks().some((t) => t.readyState === "live");
1357
+ const hasAudio = this.localStream.getAudioTracks().some((t) => t.readyState === "live");
1358
+ if (!hasVideo || !hasAudio) {
1359
+ throw new Error(`Missing tracks: video=${hasVideo}, audio=${hasAudio}`);
1360
+ }
1361
+ video.srcObject = this.localStream;
1362
+ this.updateLocalMedia();
1363
+ } catch (err) {
1364
+ this.emitError("GET_USER_MEDIA_FAILED", err?.message, err, false);
1365
+ throw err;
1366
+ }
1367
+ }
1368
+ updateLocalMedia(audioMuted = false, videoMuted = false) {
1369
+ if (!this.localStream) return;
1370
+ this.state.updateLocalParticipant({
1371
+ id: this.myId,
1372
+ name: this.participantName,
1373
+ media: {
1374
+ stream: this.localStream,
1375
+ cameraTrack: this.localStream.getVideoTracks()[0],
1376
+ audioTrack: this.localStream.getAudioTracks()[0],
1377
+ micEnabled: this.localStream.getAudioTracks()[0]?.enabled ?? !audioMuted,
1378
+ camEnabled: this.localStream.getVideoTracks()[0]?.enabled ?? !videoMuted,
1379
+ isScreenSharing: this.isScreenSharing,
1380
+ screenStream: this.screenStream,
1381
+ screenTrack: this.screenStream?.getVideoTracks()[0]
1382
+ }
1383
+ });
1384
+ this.state.localStream = this.localStream;
1385
+ }
1386
+ // ============ CONNECTION / SIGNALLING ============
1387
+ async createRoom(input) {
1388
+ const client = this.sfuClient ?? new SFUClient({
1389
+ apiBase: this.apiBase,
1390
+ wsBase: this.wsBase,
1391
+ userId: this.myId
1392
+ });
1393
+ return client.createRoom(input);
1394
+ }
1395
+ async getRoom(roomCode) {
1396
+ const client = this.sfuClient ?? new SFUClient({
1397
+ apiBase: this.apiBase,
1398
+ wsBase: this.wsBase,
1399
+ userId: this.myId
1400
+ });
1401
+ return client.getRoom(roomCode);
1402
+ }
1403
+ async deleteRoom(roomCode) {
1404
+ const client = this.sfuClient ?? new SFUClient({
1405
+ apiBase: this.apiBase,
1406
+ wsBase: this.wsBase,
1407
+ userId: this.myId
1408
+ });
1409
+ return client.deleteRoom(roomCode);
1410
+ }
1411
+ async joinMeeting(config) {
1412
+ const { roomId, name, audioMuted = false, videoMuted = false } = config;
1413
+ if (!roomId || !name) {
1414
+ throw new Error("roomId and name are required to join meeting");
1415
+ }
1416
+ this.intentionalDisconnect = false;
1417
+ this.participantName = name;
1418
+ this.room.id = roomId;
1419
+ this.room.name = name;
1420
+ if (!this.localStream) {
1421
+ this.localStream = await navigator.mediaDevices.getUserMedia({
1422
+ video: true,
1423
+ audio: true
1424
+ });
1425
+ }
1426
+ this.localStream.getAudioTracks().forEach((track) => {
1427
+ track.enabled = !audioMuted;
1428
+ });
1429
+ this.localStream.getVideoTracks().forEach((track) => {
1430
+ track.enabled = !videoMuted;
1431
+ });
1432
+ this.updateLocalMedia(audioMuted, videoMuted);
1433
+ return this.connect(roomId, name);
1434
+ }
1435
+ async connect(roomId, name) {
1436
+ if (this.sfuClient) {
1437
+ await this.sfuClient.leave();
1438
+ this.sfuClient = null;
1439
+ }
1440
+ const client = new SFUClient({
1441
+ apiBase: this.apiBase,
1442
+ wsBase: this.wsBase,
1443
+ userId: this.myId,
1444
+ autoSubscribe: true,
1445
+ onStateChange: (state) => this.handleSFUState(state),
1446
+ onConnected: (payload, resumed) => {
1447
+ console.log(
1448
+ `[SFU] ${resumed ? "Resumed" : "Joined"} room`,
1449
+ payload
1450
+ );
1451
+ this.state.updateLocalParticipant({
1452
+ id: this.myId,
1453
+ name: this.participantName
1454
+ });
1455
+ },
1456
+ onDisconnected: () => {
1457
+ console.log("[SFU] Signaling WebSocket disconnected; reconnecting");
1458
+ },
1459
+ onReconnecting: (attempt) => {
1460
+ console.warn(`[SFU] Reconnecting, attempt ${attempt}`);
1461
+ },
1462
+ onJoinPending: (info) => {
1463
+ console.log("[SFU] Join request pending", info);
1464
+ this.events.onEntryRequested?.({
1465
+ requestId: info.request_id,
1466
+ userId: this.myId,
1467
+ name: this.participantName
1468
+ });
1469
+ },
1470
+ onJoinApproved: (requestId) => {
1471
+ console.log("[SFU] Join approved", requestId);
1472
+ this.events.onJoinApproved?.(requestId);
1473
+ this.events.onEntryResponded?.(
1474
+ { participantId: this.myId, decision: "approved" },
1475
+ "approved"
1476
+ );
1477
+ },
1478
+ onJoinRejected: (requestId, reason) => {
1479
+ console.warn("[SFU] Join rejected", requestId, reason);
1480
+ this.events.onJoinRejected?.(requestId);
1481
+ this.events.onEntryResponded?.(
1482
+ { participantId: this.myId, decision: "rejected" },
1483
+ "rejected"
1484
+ );
1485
+ this.emitError(
1486
+ "JOIN_REJECTED",
1487
+ reason || "Your request to join was rejected",
1488
+ { requestId, reason },
1489
+ false
1490
+ );
1491
+ },
1492
+ onJoinRequested: (request) => {
1493
+ const requestId = request?.request_id || request?.requestId;
1494
+ const userId = request?.user_id || request?.userId;
1495
+ const nameValue = request?.user_metadata?.name || request?.display_name || request?.name || "Participant";
1496
+ if (!requestId || !userId) return;
1497
+ this.events.onEntryRequested?.({
1498
+ requestId,
1499
+ userId,
1500
+ name: nameValue
1501
+ });
1502
+ },
1503
+ onParticipantJoined: (participant) => {
1504
+ const pid = participant.participant_id;
1505
+ if (!pid || pid === this.myId) return;
1506
+ const pname = participant.user_metadata?.name || participant.display_name || "Participant";
1507
+ this.state.addParticipant({
1508
+ id: pid,
1509
+ name: pname,
1510
+ connectionState: "connected",
1511
+ media: {
1512
+ micEnabled: true,
1513
+ camEnabled: true,
1514
+ isScreenSharing: false,
1515
+ stream: null
1516
+ }
1517
+ });
1518
+ this.events.onUserJoined?.({
1519
+ id: pid,
1520
+ name: pname
1521
+ });
1522
+ },
1523
+ onParticipantPresence: (participantId, presence) => {
1524
+ if (participantId === this.myId) return;
1525
+ const current = this.state.getParticipant(participantId);
1526
+ if (!current) return;
1527
+ const connectionState = presence === "reconnecting" ? "reconnecting" : presence === "connected" || presence === "online" ? "connected" : presence === "disconnected" ? "disconnected" : current.connectionState;
1528
+ this.state.updateParticipantMedia(participantId, {
1529
+ stream: current.media?.stream ?? null
1530
+ });
1531
+ const next = new Map(this.state.participants);
1532
+ next.set(participantId, {
1533
+ ...current,
1534
+ connectionState
1535
+ });
1536
+ this.state.participants = next;
1537
+ this.state.notify(`participant:${participantId}`);
1538
+ this.state.notify("participants");
1539
+ },
1540
+ onParticipantLeft: (participant) => {
1541
+ const pid = typeof participant === "string" ? participant : participant.participant_id;
1542
+ if (!pid) return;
1543
+ console.log("[SFU] Participant permanently left", pid);
1544
+ this.cleanupRemoteParticipant(pid);
1545
+ this.state.removeParticipant(pid);
1546
+ this.events.onUserLeft?.(pid);
1547
+ },
1548
+ onTrackPublished: (track) => {
1549
+ console.log("[SFU] Track published", track);
1550
+ },
1551
+ onTrackUnpublished: (track) => {
1552
+ console.log("[SFU] Track unpublished", track);
1553
+ const pid = track.participant_id;
1554
+ if (!pid) return;
1555
+ const current = this.state.getParticipant(pid);
1556
+ if (track.source === "screen") {
1557
+ const screen = this.remoteScreenStreams.get(pid);
1558
+ screen?.getTracks().forEach((t) => t.stop());
1559
+ this.remoteScreenStreams.delete(pid);
1560
+ this.state.updateParticipantMedia(pid, {
1561
+ isScreenSharing: false,
1562
+ screenStream: null,
1563
+ screenTrack: void 0
1564
+ });
1565
+ this.events.onScreenShareStopped?.(pid);
1566
+ return;
1567
+ }
1568
+ if (!current) return;
1569
+ this.state.updateParticipantMedia(pid, {
1570
+ stream: current.media?.stream ?? null,
1571
+ micEnabled: track.kind === "audio" ? false : current.media?.micEnabled ?? true,
1572
+ camEnabled: track.kind === "video" ? false : current.media?.camEnabled ?? true
1573
+ });
1574
+ },
1575
+ onTrackStateChanged: (track) => {
1576
+ const pid = track.participant_id;
1577
+ if (!pid || pid === this.myId) return;
1578
+ const current = this.state.getParticipant(pid);
1579
+ if (!current) return;
1580
+ if (track.source === "audio") {
1581
+ this.state.updateParticipantMedia(pid, {
1582
+ stream: current.media?.stream ?? null,
1583
+ micEnabled: !track.muted
1584
+ });
1585
+ this.events.onMuteStateChanged?.(pid, "audio", track.muted);
1586
+ } else if (track.source === "camera") {
1587
+ this.state.updateParticipantMedia(pid, {
1588
+ stream: current.media?.stream ?? null,
1589
+ camEnabled: !track.muted
1590
+ });
1591
+ this.events.onMuteStateChanged?.(pid, "video", track.muted);
1592
+ }
1593
+ this.events.onTrackStateChanged?.(track);
1594
+ },
1595
+ onRecordingStarted: (recording) => {
1596
+ this.events.onRecordingStarted?.(recording);
1597
+ },
1598
+ onRecordingStopped: (recording) => {
1599
+ this.events.onRecordingStopped?.(recording);
1600
+ },
1601
+ onRemoteTrack: (track, stream, metadata) => {
1602
+ this.handleRemoteTrack(track, stream, metadata);
1603
+ },
1604
+ onScreenShareStarted: (participantId, track) => {
1605
+ if (participantId === this.myId) return;
1606
+ console.log("[SFU] Screen share started", participantId, track);
1607
+ const current = this.state.getParticipant(participantId);
1608
+ if (current) {
1609
+ this.state.updateParticipantMedia(participantId, {
1610
+ isScreenSharing: true,
1611
+ screenStream: current.media?.screenStream ?? null
1612
+ });
1613
+ }
1614
+ },
1615
+ onScreenShareStopped: (participantId) => {
1616
+ if (participantId === this.myId) return;
1617
+ const screen = this.remoteScreenStreams.get(participantId);
1618
+ screen?.getTracks().forEach((track) => track.stop());
1619
+ this.remoteScreenStreams.delete(participantId);
1620
+ this.state.updateParticipantMedia(participantId, {
1621
+ isScreenSharing: false,
1622
+ screenStream: null,
1623
+ screenTrack: void 0
1624
+ });
1625
+ this.events.onScreenShareStopped?.(participantId);
1626
+ },
1627
+ onError: (error) => {
1628
+ console.error("[SFU] Error:", error);
1629
+ this.emitError("SFU_ERROR", error?.message || String(error), error, true);
1630
+ }
1631
+ });
1632
+ this.sfuClient = client;
1633
+ client.setLocalStream(this.localStream);
1634
+ return client.getToken(roomId, {
1635
+ displayName: name,
1636
+ userId: this.myId
1637
+ });
1638
+ }
1639
+ handleSFUState(state) {
1640
+ this.events.onConnectionStateChanged?.(state);
1641
+ if (state === "reconnecting") {
1642
+ this.state.updateLocalParticipant({
1643
+ id: this.myId,
1644
+ name: this.participantName
1645
+ });
1646
+ return;
1647
+ }
1648
+ if (state === "disconnected" && !this.intentionalDisconnect) {
1649
+ this.emitError(
1650
+ "CONNECTION_LOST",
1651
+ "Unable to reconnect to SFU server",
1652
+ null,
1653
+ true
1654
+ );
1655
+ }
1656
+ }
1657
+ handleRemoteTrack(track, stream, metadata) {
1658
+ const participantId = metadata?.participant_id;
1659
+ if (!participantId || participantId === this.myId) return;
1660
+ const source = metadata.source;
1661
+ if (source === "screen") {
1662
+ let screenStream = this.remoteScreenStreams.get(participantId);
1663
+ if (!screenStream) {
1664
+ screenStream = new MediaStream();
1665
+ this.remoteScreenStreams.set(participantId, screenStream);
1666
+ }
1667
+ for (const old of screenStream.getVideoTracks()) {
1668
+ if (old.id !== track.id) {
1669
+ screenStream.removeTrack(old);
1670
+ old.stop();
1671
+ }
1672
+ }
1673
+ if (!screenStream.getTracks().some((t) => t.id === track.id)) {
1674
+ screenStream.addTrack(track);
1675
+ }
1676
+ this.state.updateParticipantMedia(participantId, {
1677
+ screenStream,
1678
+ screenTrack: track,
1679
+ isScreenSharing: true
1680
+ });
1681
+ this.events.onScreenShareStarted?.(participantId, screenStream);
1682
+ return;
1683
+ }
1684
+ let remoteStream = this.remoteStreams.get(participantId);
1685
+ if (!remoteStream) {
1686
+ remoteStream = new MediaStream();
1687
+ this.remoteStreams.set(participantId, remoteStream);
1688
+ }
1689
+ for (const old of remoteStream.getTracks()) {
1690
+ if (old.kind === track.kind && old.id !== track.id) {
1691
+ remoteStream.removeTrack(old);
1692
+ old.stop();
1693
+ }
1694
+ }
1695
+ if (!remoteStream.getTracks().some((t) => t.id === track.id)) {
1696
+ remoteStream.addTrack(track);
1697
+ }
1698
+ const current = this.state.getParticipant(participantId);
1699
+ this.state.updateParticipantMedia(participantId, {
1700
+ stream: remoteStream,
1701
+ cameraTrack: track.kind === "video" ? track : current?.media?.cameraTrack,
1702
+ audioTrack: track.kind === "audio" ? track : current?.media?.audioTrack
1703
+ });
1704
+ this.events.onTrack?.(remoteStream, participantId);
1705
+ track.onended = () => {
1706
+ if (track.kind === "video") {
1707
+ const participant2 = this.state.getParticipant(participantId);
1708
+ this.state.updateParticipantMedia(participantId, {
1709
+ camEnabled: false,
1710
+ stream: remoteStream
1711
+ });
1712
+ return;
1713
+ }
1714
+ const participant = this.state.getParticipant(participantId);
1715
+ this.state.updateParticipantMedia(participantId, {
1716
+ micEnabled: false,
1717
+ stream: remoteStream
1718
+ });
1719
+ };
1720
+ }
1721
+ getMeeting() {
1722
+ return this.room;
1723
+ }
1724
+ // ============ ADMISSION CONTROL ============
1725
+ async approveJoinRequest(requestId) {
1726
+ if (!this.sfuClient) throw new Error("SFU client is not connected");
1727
+ return this.sfuClient.approveJoinRequest(requestId);
1728
+ }
1729
+ async rejectJoinRequest(requestId, reason) {
1730
+ if (!this.sfuClient) throw new Error("SFU client is not connected");
1731
+ return this.sfuClient.rejectJoinRequest(requestId, reason);
1732
+ }
1733
+ // ============ MEDIA CONTROL ============
1734
+ toggleMic() {
1735
+ const mediaState = this.state.localParticipant?.media;
1736
+ if (!mediaState || !this.localStream) return;
1737
+ const nextEnabled = !mediaState.micEnabled;
1738
+ this.localStream.getAudioTracks().forEach((track) => {
1739
+ track.enabled = nextEnabled;
1740
+ });
1741
+ this.state.updateLocalParticipant({
1742
+ id: this.myId,
1743
+ name: this.participantName,
1744
+ media: { micEnabled: nextEnabled }
1745
+ });
1746
+ this.sfuClient?.muteAudio(!nextEnabled);
1747
+ this.events.onMicToggled?.(this.myId, nextEnabled);
1748
+ }
1749
+ toggleCam() {
1750
+ const mediaState = this.state.localParticipant?.media;
1751
+ if (!mediaState || !this.localStream) return;
1752
+ const nextEnabled = !mediaState.camEnabled;
1753
+ this.localStream.getVideoTracks().forEach((track) => {
1754
+ track.enabled = nextEnabled;
1755
+ });
1756
+ this.state.updateLocalParticipant({
1757
+ id: this.myId,
1758
+ name: this.participantName,
1759
+ media: { camEnabled: nextEnabled }
1760
+ });
1761
+ this.sfuClient?.muteVideo(!nextEnabled);
1762
+ this.events.onCamToggled?.(this.myId, nextEnabled);
1763
+ }
1764
+ // ============ SCREEN SHARING ============
1765
+ async startScreenShare() {
1766
+ try {
1767
+ if (!navigator.mediaDevices?.getDisplayMedia) {
1768
+ throw new Error("Screen sharing not supported on this device");
1769
+ }
1770
+ if (!this.sfuClient) {
1771
+ throw new Error("Cannot share screen before joining the meeting");
1772
+ }
1773
+ this.screenStream = await this.sfuClient.shareScreen();
1774
+ this.isScreenSharing = true;
1775
+ this.state.updateLocalParticipant({
1776
+ media: {
1777
+ isScreenSharing: true,
1778
+ screenStream: this.screenStream,
1779
+ screenTrack: this.screenStream.getVideoTracks()[0]
1780
+ }
1781
+ });
1782
+ return this.screenStream;
1783
+ } catch (err) {
1784
+ this.emitError(
1785
+ "SCREEN_SHARE_FAILED",
1786
+ err?.message || "Failed to start screen sharing",
1787
+ err,
1788
+ true
1789
+ );
1790
+ this.isScreenSharing = false;
1791
+ this.screenStream = null;
1792
+ throw err;
1793
+ }
1794
+ }
1795
+ stopScreenShare() {
1796
+ if (!this.screenStream) return;
1797
+ void this.sfuClient?.stopScreenShare();
1798
+ this.screenStream.getTracks().forEach((track) => track.stop());
1799
+ this.screenStream = null;
1800
+ this.isScreenSharing = false;
1801
+ this.state.updateLocalParticipant({
1802
+ media: {
1803
+ isScreenSharing: false,
1804
+ screenStream: null,
1805
+ screenTrack: void 0
1806
+ }
1807
+ });
1808
+ }
1809
+ // ============ RECORDING ============
1810
+ async startRecording() {
1811
+ if (!this.sfuClient) {
1812
+ throw new Error("SFU client is not connected");
1813
+ }
1814
+ await this.sfuClient.startRecording();
1815
+ }
1816
+ async stopRecording() {
1817
+ if (!this.sfuClient) {
1818
+ throw new Error("SFU client is not connected");
1819
+ }
1820
+ await this.sfuClient.stopRecording();
1821
+ }
1822
+ async getRecordingStatus() {
1823
+ if (!this.sfuClient) {
1824
+ throw new Error("SFU client is not connected");
1825
+ }
1826
+ return this.sfuClient.getRecordingStatus();
1827
+ }
1828
+ // ============ CHAT ============
1829
+ sendChatMessage(payload) {
1830
+ if (!this.sfuClient) {
1831
+ console.warn("SFU Client not connected");
1832
+ return;
1833
+ }
1834
+ const senderName = this.state.localParticipant?.name || "Anonymous";
1835
+ const msg = {
1836
+ id: crypto.randomUUID(),
1837
+ sender_id: this.myId,
1838
+ sender_name: senderName,
1839
+ text: payload.message.trim(),
1840
+ timestamp: Date.now(),
1841
+ reply_to: payload.reply_to ?? null,
1842
+ target: payload.target ?? null
1843
+ };
1844
+ this.state.addChatMessage(msg);
1845
+ this.events.onChatMessage?.(msg);
1846
+ }
1847
+ // ============ CLEANUP ============
1848
+ cleanupRemoteParticipant(participantId) {
1849
+ const stream = this.remoteStreams.get(participantId);
1850
+ stream?.getTracks().forEach((track) => track.stop());
1851
+ this.remoteStreams.delete(participantId);
1852
+ const screen = this.remoteScreenStreams.get(participantId);
1853
+ screen?.getTracks().forEach((track) => track.stop());
1854
+ this.remoteScreenStreams.delete(participantId);
1855
+ }
1856
+ async disconnect() {
1857
+ this.intentionalDisconnect = true;
1858
+ await this.sfuClient?.leave();
1859
+ this.sfuClient = null;
1860
+ if (this.localStream) {
1861
+ this.localStream.getTracks().forEach((track) => track.stop());
1862
+ this.localStream = null;
1863
+ }
1864
+ this.screenStream?.getTracks().forEach((track) => track.stop());
1865
+ this.screenStream = null;
1866
+ this.isScreenSharing = false;
1867
+ for (const stream of this.remoteStreams.values()) {
1868
+ stream.getTracks().forEach((track) => track.stop());
1869
+ }
1870
+ this.remoteStreams.clear();
1871
+ for (const stream of this.remoteScreenStreams.values()) {
1872
+ stream.getTracks().forEach((track) => track.stop());
1873
+ }
1874
+ this.remoteScreenStreams.clear();
1875
+ this.room.id = null;
1876
+ this.room.name = null;
1877
+ this.state.localParticipant = null;
1878
+ this.state.notify("localParticipant");
1879
+ this.state.resetRemoteState();
1880
+ this.events.onMeetingLeft?.();
1881
+ }
1882
+ };
1883
+
1884
+ // src/react/useMeetingStore.ts
1885
+ import { useEffect, useState } from "react";
1886
+ function useMeetingStore(stateManager, scope, selector) {
1887
+ const [state, setState] = useState(() => selector(stateManager));
1888
+ useEffect(() => {
1889
+ const unsubscribe = stateManager.subscribe(scope, () => {
1890
+ setState(selector(stateManager));
1891
+ });
1892
+ return unsubscribe;
1893
+ }, [stateManager, scope, selector]);
1894
+ return state;
1895
+ }
1896
+
1897
+ // src/react/MeetingProvider.tsx
1898
+ import { jsx } from "react/jsx-runtime";
1899
+ var MeetingContext = createContext(null);
1900
+ var MeetingProvider = ({
1901
+ config,
1902
+ children
1903
+ }) => {
1904
+ const sdkRef = useRef(null);
1905
+ const errorListeners = useRef(/* @__PURE__ */ new Set());
1906
+ const entryRequestListeners = useRef(/* @__PURE__ */ new Set());
1907
+ const entryResponseListeners = useRef(
1908
+ /* @__PURE__ */ new Set()
1909
+ );
1910
+ const meetingLeftListeners = useRef(/* @__PURE__ */ new Set());
1911
+ const trackStateListeners = useRef(/* @__PURE__ */ new Set());
1912
+ const recordingStartedListeners = useRef(/* @__PURE__ */ new Set());
1913
+ const recordingStoppedListeners = useRef(/* @__PURE__ */ new Set());
1914
+ if (!sdkRef.current) {
1915
+ sdkRef.current = new VideoSDKCore({
1916
+ onError: (err) => errorListeners.current.forEach((fn) => fn(err)),
1917
+ onEntryRequested: (req) => entryRequestListeners.current.forEach((fn) => fn(req)),
1918
+ onEntryResponded: (p, d) => entryResponseListeners.current.forEach((fn) => fn(p, d)),
1919
+ onMeetingLeft: () => meetingLeftListeners.current.forEach((fn) => fn()),
1920
+ onTrackStateChanged: (track) => trackStateListeners.current.forEach((fn) => fn(track)),
1921
+ onRecordingStarted: (recording) => recordingStartedListeners.current.forEach((fn) => fn(recording)),
1922
+ onRecordingStopped: (recording) => recordingStoppedListeners.current.forEach((fn) => fn(recording))
1923
+ });
1924
+ }
1925
+ const sdk = sdkRef.current;
1926
+ const presenterId = useMeetingStore(
1927
+ sdk.state,
1928
+ "presenter",
1929
+ (s) => s.presenterId
1930
+ );
1931
+ const participants = useMeetingStore(
1932
+ sdk.state,
1933
+ "participants",
1934
+ (s) => s.participants
1935
+ );
1936
+ const localParticipant = useMeetingStore(
1937
+ sdk.state,
1938
+ "localParticipant",
1939
+ (s) => s.localParticipant
1940
+ );
1941
+ const messages = useMeetingStore(
1942
+ sdk.state,
1943
+ "chat",
1944
+ (s) => s.getChatMessages()
1945
+ );
1946
+ const value = useMemo(() => {
1947
+ if (!sdkRef.current) {
1948
+ sdkRef.current = new VideoSDKCore({
1949
+ onError: (err) => {
1950
+ errorListeners.current.forEach((fn) => fn(err));
1951
+ }
1952
+ });
1953
+ }
1954
+ return {
1955
+ sdk,
1956
+ join: (joinConfig) => sdk.joinMeeting({
1957
+ ...config,
1958
+ ...joinConfig
1959
+ }),
1960
+ leave: () => sdk.disconnect(),
1961
+ createRoom: sdk.createRoom.bind(sdk),
1962
+ getRoom: sdk.getRoom.bind(sdk),
1963
+ deleteRoom: sdk.deleteRoom.bind(sdk),
1964
+ toggleMic: sdk.toggleMic.bind(sdk),
1965
+ toggleCam: sdk.toggleCam.bind(sdk),
1966
+ startScreenShare: sdk.startScreenShare.bind(sdk),
1967
+ stopScreenShare: sdk.stopScreenShare.bind(sdk),
1968
+ sendMessage: sdk.sendChatMessage.bind(sdk),
1969
+ room: sdk.getMeeting(),
1970
+ localParticipant,
1971
+ participants,
1972
+ messages,
1973
+ presenterId,
1974
+ usePubSub: (topic) => {
1975
+ if (topic !== "SECURE_CHAT") {
1976
+ throw new Error(`Unsupported PubSub argument: "${topic}"`);
1977
+ }
1978
+ return {
1979
+ messages: sdk.state.getChatMessages(),
1980
+ publish: sdk.sendChatMessage.bind(sdk)
1981
+ };
1982
+ },
1983
+ approveJoinRequest: sdk.approveJoinRequest.bind(sdk),
1984
+ rejectJoinRequest: sdk.rejectJoinRequest.bind(sdk),
1985
+ startRecording: sdk.startRecording.bind(sdk),
1986
+ stopRecording: sdk.stopRecording.bind(sdk),
1987
+ getRecordingStatus: sdk.getRecordingStatus.bind(sdk),
1988
+ onError: (cb) => {
1989
+ errorListeners.current.add(cb);
1990
+ return () => {
1991
+ errorListeners.current.delete(cb);
1992
+ };
1993
+ },
1994
+ onEntryRequested: (cb) => {
1995
+ entryRequestListeners.current.add(cb);
1996
+ return () => {
1997
+ entryRequestListeners.current.delete(cb);
1998
+ };
1999
+ },
2000
+ onEntryResponded: (cb) => {
2001
+ entryResponseListeners.current.add(cb);
2002
+ return () => {
2003
+ entryResponseListeners.current.delete(cb);
2004
+ };
2005
+ },
2006
+ onMeetingLeft: (cb) => {
2007
+ meetingLeftListeners.current.add(cb);
2008
+ return () => {
2009
+ meetingLeftListeners.current.delete(cb);
2010
+ };
2011
+ },
2012
+ onTrackStateChanged: (cb) => {
2013
+ trackStateListeners.current.add(cb);
2014
+ return () => trackStateListeners.current.delete(cb);
2015
+ },
2016
+ onRecordingStarted: (cb) => {
2017
+ recordingStartedListeners.current.add(cb);
2018
+ return () => recordingStartedListeners.current.delete(cb);
2019
+ },
2020
+ onRecordingStopped: (cb) => {
2021
+ recordingStoppedListeners.current.add(cb);
2022
+ return () => recordingStoppedListeners.current.delete(cb);
2023
+ }
2024
+ };
2025
+ }, [config, sdk, localParticipant, participants, messages, presenterId]);
2026
+ return /* @__PURE__ */ jsx(MeetingContext.Provider, { value, children });
2027
+ };
2028
+ var useMeetingContext = () => {
2029
+ const ctx = useContext(MeetingContext);
2030
+ if (!ctx)
2031
+ throw new Error("useMeetingContext must be used inside <MeetingProvider>");
2032
+ return ctx;
2033
+ };
2034
+
2035
+ // src/react/useLocalParticipant.tsx
2036
+ var useLocalParticipant = () => {
2037
+ const { sdk } = useMeetingContext();
2038
+ const [localParticipant, setLocalParticipant] = useState2(
2039
+ () => {
2040
+ const current = sdk.state.localParticipant;
2041
+ return current && current.id ? current : null;
2042
+ }
2043
+ );
2044
+ useEffect2(() => {
2045
+ const unsubscribe = sdk.state.subscribe("localParticipant", () => {
2046
+ const current = sdk.state.localParticipant;
2047
+ if (current && current.id) {
2048
+ setLocalParticipant({ ...current });
2049
+ } else {
2050
+ setLocalParticipant(null);
2051
+ }
2052
+ });
2053
+ return unsubscribe;
2054
+ }, [sdk]);
2055
+ const lastStreamRef = useRef2(null);
2056
+ const videoRef = useCallback(
2057
+ (video) => {
2058
+ if (!video) return;
2059
+ const stream = localParticipant?.media?.stream;
2060
+ if (!stream) return;
2061
+ if (lastStreamRef.current === stream) return;
2062
+ lastStreamRef.current = stream;
2063
+ video.srcObject = stream;
2064
+ video.autoplay = true;
2065
+ video.playsInline = true;
2066
+ video.play().catch((err) => {
2067
+ console.warn(`Autoplay failed for local view:`, err);
2068
+ });
2069
+ },
2070
+ [localParticipant?.media?.stream]
2071
+ );
2072
+ return {
2073
+ participant: localParticipant,
2074
+ videoRef
2075
+ };
2076
+ };
2077
+
2078
+ // src/react/useMeeting.ts
2079
+ import { useEffect as useEffect3 } from "react";
2080
+ var useMeeting = (handlers) => {
2081
+ const ctx = useMeetingContext();
2082
+ useEffect3(() => {
2083
+ if (!handlers?.onError) return;
2084
+ return ctx.onError(handlers.onError);
2085
+ }, [handlers?.onError]);
2086
+ useEffect3(() => {
2087
+ if (!handlers?.onEntryRequested) return;
2088
+ return ctx.onEntryRequested(handlers.onEntryRequested);
2089
+ }, [handlers?.onEntryRequested]);
2090
+ useEffect3(() => {
2091
+ if (!handlers?.onEntryResponded) return;
2092
+ return ctx.onEntryResponded(handlers.onEntryResponded);
2093
+ }, [handlers?.onEntryResponded]);
2094
+ useEffect3(() => {
2095
+ if (!handlers?.onMeetingLeft) return;
2096
+ return ctx.onMeetingLeft(handlers.onMeetingLeft);
2097
+ }, [handlers?.onMeetingLeft]);
2098
+ useEffect3(() => {
2099
+ if (!handlers?.onTrackStateChanged) return;
2100
+ return ctx.onTrackStateChanged(handlers.onTrackStateChanged);
2101
+ }, [handlers?.onTrackStateChanged]);
2102
+ useEffect3(() => {
2103
+ if (!handlers?.onRecordingStarted) return;
2104
+ return ctx.onRecordingStarted(handlers.onRecordingStarted);
2105
+ }, [handlers?.onRecordingStarted]);
2106
+ useEffect3(() => {
2107
+ if (!handlers?.onRecordingStopped) return;
2108
+ return ctx.onRecordingStopped(handlers.onRecordingStopped);
2109
+ }, [handlers?.onRecordingStopped]);
2110
+ const { sdk: _, ...publicApi } = ctx;
2111
+ return publicApi;
2112
+ };
2113
+
2114
+ // src/react/useParticipants.ts
2115
+ import { useEffect as useEffect4, useState as useState3 } from "react";
2116
+ var useParticipants = () => {
2117
+ const { sdk } = useMeetingContext();
2118
+ const [participants, setParticipants] = useState3(
2119
+ () => sdk.state.getParticipants()
2120
+ );
2121
+ useEffect4(() => {
2122
+ const update = () => {
2123
+ setParticipants(sdk.state.getParticipants());
2124
+ };
2125
+ update();
2126
+ const unsub = sdk.state.subscribe("participants", update);
2127
+ return unsub;
2128
+ }, [sdk]);
2129
+ return participants;
2130
+ };
2131
+
2132
+ // src/react/useRemoteMedia.ts
2133
+ import { useEffect as useEffect5, useRef as useRef3, useState as useState4 } from "react";
2134
+ var useRemoteMedia = (participantId) => {
2135
+ const { sdk } = useMeetingContext();
2136
+ const videoRef = useRef3(null);
2137
+ const audioRef = useRef3(null);
2138
+ const [participant, setParticipant] = useState4(
2139
+ () => sdk.state.getParticipant(participantId) || null
2140
+ );
2141
+ useEffect5(() => {
2142
+ const unsub = sdk.state.subscribe(`participant:${participantId}`, () => {
2143
+ const p = sdk.state.getParticipant(participantId);
2144
+ setParticipant(p ? { ...p } : null);
2145
+ });
2146
+ return unsub;
2147
+ }, [participantId, sdk]);
2148
+ useEffect5(() => {
2149
+ const stream = participant?.media?.stream;
2150
+ if (!stream) return;
2151
+ if (videoRef.current) {
2152
+ videoRef.current.srcObject = stream;
2153
+ videoRef.current.muted = true;
2154
+ videoRef.current.playsInline = true;
2155
+ videoRef.current.play().catch(() => {
2156
+ });
2157
+ }
2158
+ if (audioRef.current) {
2159
+ audioRef.current.srcObject = stream;
2160
+ audioRef.current.play().catch(() => {
2161
+ });
2162
+ }
2163
+ }, [participant?.media?.stream]);
2164
+ return {
2165
+ videoRef,
2166
+ audioRef,
2167
+ isCamActive: !!participant?.media?.camEnabled,
2168
+ isMicEnabled: !!participant?.media?.micEnabled
2169
+ };
2170
+ };
2171
+
2172
+ // src/react/useMeetingPreview.ts
2173
+ import { useEffect as useEffect6, useState as useState5 } from "react";
2174
+ function useMeetingPreview(roomId, userId) {
2175
+ const [room, setRoom] = useState5(null);
2176
+ const [error, setError] = useState5(null);
2177
+ const [isConnected, setIsConnected] = useState5(false);
2178
+ const [isLoading, setIsLoading] = useState5(true);
2179
+ useEffect6(() => {
2180
+ if (!roomId) {
2181
+ setIsLoading(false);
2182
+ return;
2183
+ }
2184
+ let stopped = false;
2185
+ let timer = null;
2186
+ const load = async () => {
2187
+ try {
2188
+ const response = await fetch(
2189
+ `${SDK_CONFIG.apiBase}/rooms/${encodeURIComponent(roomId)}`
2190
+ );
2191
+ if (!response.ok) {
2192
+ throw new Error(`Room lookup failed (${response.status})`);
2193
+ }
2194
+ const data = await response.json();
2195
+ if (stopped) return;
2196
+ setRoom({
2197
+ active: true,
2198
+ // The current REST room model intentionally does not expose a live
2199
+ // participant count. Do not fabricate one.
2200
+ count: 0,
2201
+ canJoin: !data.is_private,
2202
+ approved: false,
2203
+ isHost: false,
2204
+ hasMoreParticipants: false,
2205
+ participants: [],
2206
+ room: {
2207
+ id: data.id,
2208
+ name: data.name,
2209
+ room_code: data.room_code,
2210
+ capacity: data.capacity,
2211
+ is_private: data.is_private
2212
+ }
2213
+ });
2214
+ setError(null);
2215
+ setIsConnected(true);
2216
+ setIsLoading(false);
2217
+ } catch (err) {
2218
+ if (stopped) return;
2219
+ setError(err?.message || "Failed to load room");
2220
+ setIsConnected(false);
2221
+ setIsLoading(false);
2222
+ } finally {
2223
+ if (!stopped) {
2224
+ timer = setTimeout(load, 5e3);
2225
+ }
2226
+ }
2227
+ };
2228
+ void load();
2229
+ return () => {
2230
+ stopped = true;
2231
+ if (timer) clearTimeout(timer);
2232
+ };
2233
+ }, [roomId, userId]);
2234
+ return {
2235
+ room,
2236
+ isConnected,
2237
+ isLoading,
2238
+ error
2239
+ };
2240
+ }
2241
+ export {
2242
+ MeetingProvider,
2243
+ MeetingState,
2244
+ SFUClient,
2245
+ VideoSDKCore,
2246
+ useLocalParticipant,
2247
+ useMeeting,
2248
+ useMeetingContext,
2249
+ useMeetingPreview,
2250
+ useParticipants,
2251
+ useRemoteMedia
2252
+ };
2253
+ //# sourceMappingURL=index.mjs.map