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