@defcomm/meetingsdk 0.6.7 → 0.6.9

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 CHANGED
@@ -32,13 +32,16 @@ var ActiveSpeakerDetector = class {
32
32
  this.timer = null;
33
33
  this.lastEmitted = "";
34
34
  this.intervalMs = options.intervalMs ?? 100;
35
- this.threshold = options.threshold ?? 0.025;
36
- this.releaseThreshold = options.releaseThreshold ?? 0.015;
37
- this.releaseMs = options.releaseMs ?? 600;
35
+ this.threshold = options.threshold ?? 0.02;
36
+ this.releaseThreshold = options.releaseThreshold ?? 0.01;
37
+ this.releaseMs = options.releaseMs ?? 650;
38
38
  this.onActiveSpeakersChanged = options.onActiveSpeakersChanged ?? (() => {
39
39
  });
40
40
  }
41
41
  ensureAudioContext() {
42
+ if (typeof window === "undefined") {
43
+ throw new Error("AudioContext is only available in the browser");
44
+ }
42
45
  if (!this.audioContext) {
43
46
  const AudioContextClass = window.AudioContext || window.webkitAudioContext;
44
47
  if (!AudioContextClass) {
@@ -48,28 +51,52 @@ var ActiveSpeakerDetector = class {
48
51
  }
49
52
  return this.audioContext;
50
53
  }
54
+ /**
55
+ * Create/wake the Web Audio context. Call this from a user gesture when
56
+ * possible. Returns true when the context is running after the call.
57
+ */
51
58
  async resume() {
52
- if (!this.audioContext) return;
53
- if (this.audioContext.state === "suspended") {
54
- try {
55
- await this.audioContext.resume();
56
- } catch {
59
+ try {
60
+ const context = this.ensureAudioContext();
61
+ if (context.state === "suspended") {
62
+ try {
63
+ await context.resume();
64
+ } catch (error) {
65
+ console.warn("[ActiveSpeaker] AudioContext resume failed", error);
66
+ }
57
67
  }
68
+ return context.state === "running";
69
+ } catch (error) {
70
+ console.warn("[ActiveSpeaker] AudioContext unavailable", error);
71
+ return false;
58
72
  }
59
73
  }
74
+ getAudioContextState() {
75
+ if (!this.audioContext) return "unavailable";
76
+ return this.audioContext.state;
77
+ }
60
78
  addTrack(participantId, track) {
61
- if (!participantId) return;
62
- if (track.kind !== "audio") return;
79
+ if (!participantId || track.kind !== "audio") return;
63
80
  this.removeParticipant(participantId);
64
- const context = this.ensureAudioContext();
81
+ let context;
82
+ try {
83
+ context = this.ensureAudioContext();
84
+ } catch (error) {
85
+ console.warn("[ActiveSpeaker] Could not create AudioContext", error);
86
+ return;
87
+ }
65
88
  const stream = new MediaStream([track]);
66
89
  const source = context.createMediaStreamSource(stream);
67
90
  const analyser = context.createAnalyser();
68
91
  analyser.fftSize = 512;
69
- analyser.smoothingTimeConstant = 0.65;
92
+ analyser.smoothingTimeConstant = 0.35;
70
93
  analyser.minDecibels = -90;
71
94
  analyser.maxDecibels = -10;
72
95
  source.connect(analyser);
96
+ const sink = context.createGain();
97
+ sink.gain.value = 0;
98
+ analyser.connect(sink);
99
+ sink.connect(context.destination);
73
100
  const data = new Float32Array(analyser.fftSize);
74
101
  const state = {
75
102
  participantId,
@@ -77,15 +104,20 @@ var ActiveSpeakerDetector = class {
77
104
  stream,
78
105
  source,
79
106
  analyser,
107
+ sink,
80
108
  data,
81
109
  active: false,
110
+ level: 0,
82
111
  lastActiveAt: 0
83
112
  };
84
113
  this.speakers.set(participantId, state);
85
114
  track.addEventListener(
86
115
  "ended",
87
116
  () => {
88
- this.removeParticipant(participantId);
117
+ const current = this.speakers.get(participantId);
118
+ if (current?.track === track) {
119
+ this.removeParticipant(participantId);
120
+ }
89
121
  },
90
122
  { once: true }
91
123
  );
@@ -96,15 +128,19 @@ var ActiveSpeakerDetector = class {
96
128
  const state = this.speakers.get(participantId);
97
129
  if (!state) return;
98
130
  state.active = false;
131
+ state.level = 0;
99
132
  try {
100
133
  state.source.disconnect();
101
134
  } catch {
102
135
  }
103
- state.stream.getTracks().forEach((track) => {
104
- if (track !== state.track) {
105
- track.stop();
106
- }
107
- });
136
+ try {
137
+ state.analyser.disconnect();
138
+ } catch {
139
+ }
140
+ try {
141
+ state.sink.disconnect();
142
+ } catch {
143
+ }
108
144
  this.speakers.delete(participantId);
109
145
  this.emitIfChanged();
110
146
  }
@@ -116,7 +152,7 @@ var ActiveSpeakerDetector = class {
116
152
  }
117
153
  }
118
154
  clear() {
119
- for (const participantId of this.speakers.keys()) {
155
+ for (const participantId of [...this.speakers.keys()]) {
120
156
  this.removeParticipant(participantId);
121
157
  }
122
158
  this.speakers.clear();
@@ -136,6 +172,7 @@ var ActiveSpeakerDetector = class {
136
172
  const now = performance.now();
137
173
  for (const state of this.speakers.values()) {
138
174
  if (state.track.readyState !== "live" || state.track.muted || !state.track.enabled) {
175
+ state.level = 0;
139
176
  if (state.active) {
140
177
  state.active = false;
141
178
  }
@@ -148,30 +185,33 @@ var ActiveSpeakerDetector = class {
148
185
  sumSquares += sample * sample;
149
186
  }
150
187
  const rms = Math.sqrt(sumSquares / state.data.length);
188
+ state.level = rms;
151
189
  if (!state.active) {
152
190
  if (rms >= this.threshold) {
153
191
  state.active = true;
154
192
  state.lastActiveAt = now;
155
193
  }
156
- } else {
157
- if (rms >= this.releaseThreshold) {
158
- state.lastActiveAt = now;
159
- } else if (now - state.lastActiveAt >= this.releaseMs) {
160
- state.active = false;
161
- }
194
+ } else if (rms >= this.releaseThreshold) {
195
+ state.lastActiveAt = now;
196
+ } else if (now - state.lastActiveAt >= this.releaseMs) {
197
+ state.active = false;
162
198
  }
163
199
  }
164
200
  this.emitIfChanged();
165
201
  }
202
+ getActiveSpeakers() {
203
+ return Array.from(this.speakers.values()).filter((speaker) => speaker.active).sort((a, b) => {
204
+ if (b.level !== a.level) return b.level - a.level;
205
+ return a.participantId.localeCompare(b.participantId);
206
+ });
207
+ }
166
208
  getActiveSpeakerIds() {
167
- return Array.from(this.speakers.values()).filter((speaker) => speaker.active).map((speaker) => speaker.participantId);
209
+ return this.getActiveSpeakers().map((speaker) => speaker.participantId);
168
210
  }
169
211
  emitIfChanged(force = false) {
170
- const ids = this.getActiveSpeakerIds().sort();
212
+ const ids = this.getActiveSpeakerIds();
171
213
  const key = ids.join("|");
172
- if (!force && key === this.lastEmitted) {
173
- return;
174
- }
214
+ if (!force && key === this.lastEmitted) return;
175
215
  this.lastEmitted = key;
176
216
  this.onActiveSpeakersChanged(ids);
177
217
  }
@@ -248,6 +288,36 @@ var MeetingState = class {
248
288
  this.notify("participants");
249
289
  return true;
250
290
  }
291
+ /**
292
+ * Reconcile a participant received from a room snapshot without emitting a
293
+ * lifecycle/join event. Snapshot data is authoritative for current room
294
+ * membership, including after a reconnect/resume.
295
+ */
296
+ upsertParticipantSnapshot(p) {
297
+ if (!p?.id) return;
298
+ const current = this.participants.get(p.id);
299
+ const updated = {
300
+ ...current ?? {},
301
+ ...p,
302
+ media: {
303
+ stream: current?.media?.stream ?? null,
304
+ screenStream: current?.media?.screenStream,
305
+ cameraTrack: current?.media?.cameraTrack,
306
+ screenTrack: current?.media?.screenTrack,
307
+ audioTrack: current?.media?.audioTrack,
308
+ micEnabled: current?.media?.micEnabled ?? !(p.media?.micEnabled === false),
309
+ camEnabled: current?.media?.camEnabled ?? !(p.media?.camEnabled === false),
310
+ isScreenSharing: current?.media?.isScreenSharing ?? false,
311
+ ...current?.media,
312
+ ...p.media
313
+ }
314
+ };
315
+ const next = new Map(this.participants);
316
+ next.set(p.id, updated);
317
+ this.participants = next;
318
+ this.notify(`participant:${p.id}`);
319
+ this.notify("participants");
320
+ }
251
321
  removeParticipant(id) {
252
322
  const next = new Map(this.participants);
253
323
  next.delete(id);
@@ -1212,7 +1282,6 @@ var SFUClient = class {
1212
1282
  }
1213
1283
  for (const participant of this.participants.values()) {
1214
1284
  if (participant.participant_id !== this.currentParticipantId) {
1215
- this.onParticipantJoined(participant);
1216
1285
  this.onParticipantPresence(
1217
1286
  participant.participant_id,
1218
1287
  String(participant.presence || "connected")
@@ -2287,26 +2356,11 @@ var VideoSDKCore = class {
2287
2356
  async initLocal(video, name) {
2288
2357
  this.participantName = name;
2289
2358
  try {
2290
- this.localStream = await navigator.mediaDevices.getUserMedia({
2291
- video: {
2292
- width: { ideal: 1280 },
2293
- height: { ideal: 720 }
2294
- },
2295
- audio: {
2296
- echoCancellation: true,
2297
- noiseSuppression: true,
2298
- autoGainControl: true
2299
- }
2300
- });
2359
+ this.localStream = await this.acquireOptionalLocalMedia();
2301
2360
  const localAudioTrack = this.localStream.getAudioTracks()[0];
2302
2361
  if (localAudioTrack) {
2303
2362
  this.activeSpeakerDetector.addTrack(this.myId, localAudioTrack);
2304
2363
  }
2305
- const hasVideo = this.localStream.getVideoTracks().some((t) => t.readyState === "live");
2306
- const hasAudio = this.localStream.getAudioTracks().some((t) => t.readyState === "live");
2307
- if (!hasVideo || !hasAudio) {
2308
- throw new Error(`Missing tracks: video=${hasVideo}, audio=${hasAudio}`);
2309
- }
2310
2364
  video.srcObject = this.localStream;
2311
2365
  this.updateLocalMedia();
2312
2366
  } catch (err) {
@@ -2323,8 +2377,8 @@ var VideoSDKCore = class {
2323
2377
  stream: this.localStream,
2324
2378
  cameraTrack: this.localStream.getVideoTracks()[0],
2325
2379
  audioTrack: this.localStream.getAudioTracks()[0],
2326
- micEnabled: this.localStream.getAudioTracks()[0]?.enabled ?? !audioMuted,
2327
- camEnabled: this.localStream.getVideoTracks()[0]?.enabled ?? !videoMuted,
2380
+ micEnabled: this.localStream.getAudioTracks()[0]?.enabled ?? false,
2381
+ camEnabled: this.localStream.getVideoTracks()[0]?.enabled ?? false,
2328
2382
  isScreenSharing: this.isScreenSharing,
2329
2383
  screenStream: this.screenStream,
2330
2384
  screenTrack: this.screenStream?.getVideoTracks()[0]
@@ -2351,6 +2405,70 @@ var VideoSDKCore = class {
2351
2405
  async getMeetingSummary(roomCode, sessionId) {
2352
2406
  return this.sfuClient.getMeetingSummary(roomCode, sessionId);
2353
2407
  }
2408
+ async acquireOptionalLocalMedia(cameraDeviceId, microphoneDeviceId) {
2409
+ const combined = new MediaStream();
2410
+ if (!navigator.mediaDevices?.getUserMedia) {
2411
+ console.warn(
2412
+ "[SDK] Camera/microphone access is not supported; joining without local media."
2413
+ );
2414
+ return combined;
2415
+ }
2416
+ const cameraPromise = navigator.mediaDevices.getUserMedia({
2417
+ video: cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : {
2418
+ width: { ideal: 1280 },
2419
+ height: { ideal: 720 }
2420
+ },
2421
+ audio: false
2422
+ });
2423
+ const microphonePromise = navigator.mediaDevices.getUserMedia({
2424
+ video: false,
2425
+ audio: microphoneDeviceId ? {
2426
+ deviceId: { exact: microphoneDeviceId },
2427
+ echoCancellation: true,
2428
+ noiseSuppression: true,
2429
+ autoGainControl: true
2430
+ } : {
2431
+ echoCancellation: true,
2432
+ noiseSuppression: true,
2433
+ autoGainControl: true
2434
+ }
2435
+ });
2436
+ const [cameraResult, microphoneResult] = await Promise.allSettled([
2437
+ cameraPromise,
2438
+ microphonePromise
2439
+ ]);
2440
+ if (cameraResult.status === "fulfilled") {
2441
+ for (const track of cameraResult.value.getVideoTracks()) {
2442
+ combined.addTrack(track);
2443
+ }
2444
+ } else {
2445
+ console.warn("[SDK] Camera unavailable; continuing without video.", {
2446
+ name: cameraResult.reason?.name,
2447
+ message: cameraResult.reason?.message
2448
+ });
2449
+ }
2450
+ if (microphoneResult.status === "fulfilled") {
2451
+ for (const track of microphoneResult.value.getAudioTracks()) {
2452
+ combined.addTrack(track);
2453
+ }
2454
+ } else {
2455
+ console.warn(
2456
+ "[SDK] Microphone unavailable; continuing without audio.",
2457
+ {
2458
+ name: microphoneResult.reason?.name,
2459
+ message: microphoneResult.reason?.message
2460
+ }
2461
+ );
2462
+ }
2463
+ return combined;
2464
+ }
2465
+ /**
2466
+ * Wakes the active-speaker Web Audio pipeline. Call this from a user gesture
2467
+ * whenever possible (Join, first click inside the meeting, etc.).
2468
+ */
2469
+ async resumeActiveSpeaker() {
2470
+ return this.activeSpeakerDetector.resume();
2471
+ }
2354
2472
  // JOIN
2355
2473
  async joinMeeting(config) {
2356
2474
  const {
@@ -2394,15 +2512,10 @@ var VideoSDKCore = class {
2394
2512
  this.room.id = roomId;
2395
2513
  this.room.name = name;
2396
2514
  if (!this.localStream) {
2397
- this.localStream = await navigator.mediaDevices.getUserMedia({
2398
- video: cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : true,
2399
- audio: microphoneDeviceId ? {
2400
- deviceId: { exact: microphoneDeviceId },
2401
- echoCancellation: true,
2402
- noiseSuppression: true,
2403
- autoGainControl: true
2404
- } : true
2405
- });
2515
+ this.localStream = await this.acquireOptionalLocalMedia(
2516
+ cameraDeviceId,
2517
+ microphoneDeviceId
2518
+ );
2406
2519
  const localAudioTrack = this.localStream.getAudioTracks()[0];
2407
2520
  if (localAudioTrack) {
2408
2521
  this.activeSpeakerDetector.addTrack(this.myId, localAudioTrack);
@@ -2447,8 +2560,8 @@ var VideoSDKCore = class {
2447
2560
  await this.sfuClient.getToken(roomId, {
2448
2561
  displayName: name,
2449
2562
  participantId: this.myId,
2450
- audioMuted: !(this.localStream?.getAudioTracks()[0]?.enabled ?? true),
2451
- videoMuted: !(this.localStream?.getVideoTracks()[0]?.enabled ?? true)
2563
+ audioMuted: !(this.localStream?.getAudioTracks()[0]?.enabled ?? false),
2564
+ videoMuted: !(this.localStream?.getVideoTracks()[0]?.enabled ?? false)
2452
2565
  });
2453
2566
  }
2454
2567
  createSFUClient(token) {
@@ -2497,6 +2610,36 @@ var VideoSDKCore = class {
2497
2610
  },
2498
2611
  onRoomUpdate: (payload) => {
2499
2612
  const participants = payload?.participants || [];
2613
+ const snapshotParticipantIds = /* @__PURE__ */ new Set();
2614
+ for (const participant of participants) {
2615
+ const pid = participant?.participant_id;
2616
+ if (!pid || pid === this.myId) continue;
2617
+ snapshotParticipantIds.add(pid);
2618
+ const current = this.state.getParticipant(pid);
2619
+ const name = participant?.user_metadata?.name || participant?.display_name || current?.name || "Participant";
2620
+ this.state.upsertParticipantSnapshot({
2621
+ id: pid,
2622
+ name,
2623
+ isModerator: Boolean(participant?.permissions?.moderate),
2624
+ isHost: Boolean(
2625
+ participant?.is_host ?? participant?.isHost ?? participant?.permissions?.host
2626
+ ),
2627
+ handRaised: Boolean(participant?.hand_raised),
2628
+ permissions: participant?.permissions,
2629
+ connectionState: participant?.presence === "reconnecting" ? "reconnecting" : participant?.presence === "disconnected" ? "disconnected" : "connected",
2630
+ media: {
2631
+ ...current?.media || {},
2632
+ micEnabled: !Boolean(participant?.audio_muted),
2633
+ camEnabled: !Boolean(participant?.video_muted),
2634
+ isScreenSharing: Boolean(participant?.screen_sharing)
2635
+ }
2636
+ });
2637
+ }
2638
+ for (const [pid] of this.state.participants) {
2639
+ if (pid === this.myId || snapshotParticipantIds.has(pid)) continue;
2640
+ this.cleanupRemoteParticipant(pid);
2641
+ this.state.removeParticipant(pid);
2642
+ }
2500
2643
  const local = participants.find(
2501
2644
  (p) => p?.participant_id === this.myId
2502
2645
  );
@@ -3310,6 +3453,9 @@ var MeetingProvider = ({
3310
3453
  const participantJoinedListeners = useRef(
3311
3454
  /* @__PURE__ */ new Set()
3312
3455
  );
3456
+ const participantPresenceListeners = useRef(
3457
+ /* @__PURE__ */ new Set()
3458
+ );
3313
3459
  const participantLeftListeners = useRef(
3314
3460
  /* @__PURE__ */ new Set()
3315
3461
  );
@@ -3398,7 +3544,9 @@ var MeetingProvider = ({
3398
3544
  pubSubListeners.current.get("REACTION")?.forEach((fn) => fn(message));
3399
3545
  },
3400
3546
  onHandRaised: (participantId, raised) => {
3401
- handRaisedListeners.current.forEach((fn) => fn(participantId, raised));
3547
+ handRaisedListeners.current.forEach(
3548
+ (fn) => fn(participantId, raised)
3549
+ );
3402
3550
  },
3403
3551
  onNetworkQuality: (quality) => {
3404
3552
  networkQualityListeners.current.forEach((fn) => fn(quality));
@@ -3411,6 +3559,11 @@ var MeetingProvider = ({
3411
3559
  onParticipantJoined: (participant) => {
3412
3560
  participantJoinedListeners.current.forEach((fn) => fn(participant));
3413
3561
  },
3562
+ onParticipantPresence: (participantId, presence) => {
3563
+ participantPresenceListeners.current.forEach(
3564
+ (fn) => fn(participantId, presence)
3565
+ );
3566
+ },
3414
3567
  onParticipantLeft: (participant) => {
3415
3568
  participantLeftListeners.current.forEach((fn) => fn(participant));
3416
3569
  }
@@ -3468,6 +3621,7 @@ var MeetingProvider = ({
3468
3621
  getMeetingSummary: sdk.getMeetingSummary.bind(sdk),
3469
3622
  toggleMic: sdk.toggleMic.bind(sdk),
3470
3623
  toggleCam: sdk.toggleCam.bind(sdk),
3624
+ resumeActiveSpeaker: sdk.resumeActiveSpeaker.bind(sdk),
3471
3625
  startScreenShare: sdk.startScreenShare.bind(sdk),
3472
3626
  stopScreenShare: sdk.stopScreenShare.bind(sdk),
3473
3627
  sendMessage: sdk.sendChatMessage.bind(sdk),
@@ -3623,6 +3777,10 @@ var MeetingProvider = ({
3623
3777
  participantJoinedListeners.current.add(cb);
3624
3778
  return () => participantJoinedListeners.current.delete(cb);
3625
3779
  },
3780
+ onParticipantPresence: (cb) => {
3781
+ participantPresenceListeners.current.add(cb);
3782
+ return () => participantPresenceListeners.current.delete(cb);
3783
+ },
3626
3784
  onChatMessage: (cb) => {
3627
3785
  chatMessageListeners.current.add(cb);
3628
3786
  return () => chatMessageListeners.current.delete(cb);