@defcomm/meetingsdk 0.5.5 → 0.5.6

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 CHANGED
@@ -24,10 +24,12 @@ __export(index_exports, {
24
24
  MeetingProvider: () => MeetingProvider,
25
25
  MeetingState: () => MeetingState,
26
26
  VideoSDKCore: () => VideoSDKCore,
27
+ useLiveTranscript: () => useLiveTranscript,
27
28
  useLocalParticipant: () => useLocalParticipant,
28
29
  useMeeting: () => useMeeting,
29
30
  useMeetingContext: () => useMeetingContext,
30
31
  useMeetingPreview: () => useMeetingPreview,
32
+ useNetworkQuality: () => useNetworkQuality,
31
33
  useParticipants: () => useParticipants,
32
34
  usePubSub: () => usePubSub,
33
35
  useRemoteMedia: () => useRemoteMedia
@@ -227,6 +229,8 @@ var MeetingState = class {
227
229
  this.presenterId = null;
228
230
  this.activeSpeakerIds = /* @__PURE__ */ new Set();
229
231
  this.recording = null;
232
+ this.liveTranscripts = [];
233
+ this.networkQuality = null;
230
234
  this.listeners = /* @__PURE__ */ new Map();
231
235
  }
232
236
  // ---- reactive system ----
@@ -264,6 +268,21 @@ var MeetingState = class {
264
268
  this.recording = null;
265
269
  this.notify("recording");
266
270
  }
271
+ addLiveTranscript(transcript) {
272
+ this.liveTranscripts = [...this.liveTranscripts.slice(-499), transcript];
273
+ this.notify("transcript");
274
+ }
275
+ getLiveTranscripts() {
276
+ return this.liveTranscripts;
277
+ }
278
+ clearLiveTranscripts() {
279
+ this.liveTranscripts = [];
280
+ this.notify("transcript");
281
+ }
282
+ setNetworkQuality(quality) {
283
+ this.networkQuality = quality;
284
+ this.notify("networkQuality");
285
+ }
267
286
  // ---- participants ----
268
287
  addParticipant(p) {
269
288
  if (this.participants.has(p.id)) return false;
@@ -384,10 +403,14 @@ var MeetingState = class {
384
403
  this.chatMessages.clear();
385
404
  this.presenterId = null;
386
405
  this.activeSpeakerIds = /* @__PURE__ */ new Set();
406
+ this.liveTranscripts = [];
407
+ this.networkQuality = null;
387
408
  this.notify("participants");
388
409
  this.notify("chat");
389
410
  this.notify("presenter");
390
411
  this.notify("activeSpeakers");
412
+ this.notify("transcript");
413
+ this.notify("networkQuality");
391
414
  }
392
415
  };
393
416
 
@@ -449,6 +472,12 @@ var SFUClient = class {
449
472
  this.trackToSource = /* @__PURE__ */ new Map();
450
473
  this.localTrackIds = /* @__PURE__ */ new Map();
451
474
  this.subscriberStatsTimer = null;
475
+ this.liveTranscriptionActive = false;
476
+ this.liveTranscriptionSequence = 0;
477
+ this.liveTranscriptionStartedAt = 0;
478
+ this.liveTranscriptionRecorder = null;
479
+ this.liveTranscriptionLoopPromise = null;
480
+ this.networkProfile = null;
452
481
  this.heartbeatTimer = null;
453
482
  this.apiBase = options.apiBase || "https://api.example.com/api";
454
483
  this.wsBase = options.wsBase || "wss://api.example.com/ws";
@@ -505,6 +534,10 @@ var SFUClient = class {
505
534
  });
506
535
  this.onRoomUpdate = options.onRoomUpdate || (() => {
507
536
  });
537
+ this.onLiveTranscript = options.onLiveTranscript || (() => {
538
+ });
539
+ this.onNetworkQuality = options.onNetworkQuality || (() => {
540
+ });
508
541
  this.onParticipantMediaState = options.onParticipantMediaState || (() => {
509
542
  });
510
543
  this.autoSubscribe = options.autoSubscribe !== false;
@@ -1099,6 +1132,40 @@ var SFUClient = class {
1099
1132
  if (reaction?.id && reaction?.emoji) this.onReaction(reaction);
1100
1133
  break;
1101
1134
  }
1135
+ case "live_transcript": {
1136
+ const transcript = {
1137
+ transcript_id: String(payload.transcript_id),
1138
+ participant_id: String(payload.participant_id),
1139
+ speaker_name: payload.speaker_name ?? null,
1140
+ sequence: Number(payload.sequence ?? 0),
1141
+ text: String(payload.text ?? ""),
1142
+ offset_ms: payload.offset_ms == null ? null : Number(payload.offset_ms),
1143
+ start_ms: payload.start_ms == null ? null : Number(payload.start_ms),
1144
+ end_ms: payload.end_ms == null ? null : Number(payload.end_ms),
1145
+ timestamp: String(payload.timestamp ?? (/* @__PURE__ */ new Date()).toISOString())
1146
+ };
1147
+ if (transcript.text.trim()) this.onLiveTranscript(transcript);
1148
+ break;
1149
+ }
1150
+ case "network_quality": {
1151
+ const raw = payload.quality || payload;
1152
+ const quality = {
1153
+ participant_id: String(raw.participant_id ?? this.currentParticipantId ?? this.participantId),
1154
+ quality: String(raw.quality ?? "unknown"),
1155
+ score: Number(raw.score ?? 0),
1156
+ recommended_action: String(raw.recommended_action ?? "maintain"),
1157
+ packet_loss_percent: Number(raw.packet_loss_percent ?? 0),
1158
+ upload_kbps: Number(raw.upload_kbps ?? 0),
1159
+ forwarded_drop_percent: Number(raw.forwarded_drop_percent ?? 0),
1160
+ queued_packets: Number(raw.queued_packets ?? 0),
1161
+ packets_received: Number(raw.packets_received ?? 0),
1162
+ packets_lost: Number(raw.packets_lost ?? 0),
1163
+ observed_at: String(raw.observed_at ?? (/* @__PURE__ */ new Date()).toISOString())
1164
+ };
1165
+ this.onNetworkQuality(quality);
1166
+ void this.applyNetworkRecommendation(quality.recommended_action);
1167
+ break;
1168
+ }
1102
1169
  case "error": {
1103
1170
  const message2 = payload.message || "SFU signaling error";
1104
1171
  if (this.state === "reconnecting" && message2.includes("participant session is stale")) {
@@ -1758,6 +1825,147 @@ var SFUClient = class {
1758
1825
  }
1759
1826
  });
1760
1827
  }
1828
+ getLiveTranscriptionMimeType() {
1829
+ if (typeof MediaRecorder === "undefined") {
1830
+ throw new Error("MediaRecorder is not supported by this browser");
1831
+ }
1832
+ const candidates = [
1833
+ "audio/webm;codecs=opus",
1834
+ "audio/webm",
1835
+ "audio/ogg;codecs=opus"
1836
+ ];
1837
+ return candidates.find((type) => MediaRecorder.isTypeSupported(type)) || "";
1838
+ }
1839
+ async startLiveTranscription(audioTrack, speakerName) {
1840
+ if (this.liveTranscriptionActive) return;
1841
+ if (!this.room) throw new Error("Cannot start live transcription without a room");
1842
+ if (!this.authToken) throw new Error("Cannot start live transcription without an authenticated token");
1843
+ if (!audioTrack || audioTrack.kind !== "audio") {
1844
+ throw new Error("A live microphone audio track is required");
1845
+ }
1846
+ const mimeType = this.getLiveTranscriptionMimeType();
1847
+ this.liveTranscriptionActive = true;
1848
+ this.liveTranscriptionSequence = 0;
1849
+ this.liveTranscriptionStartedAt = Date.now();
1850
+ const stream = new MediaStream([audioTrack]);
1851
+ this.liveTranscriptionLoopPromise = this.runLiveTranscriptionLoop(
1852
+ stream,
1853
+ mimeType
1854
+ ).finally(() => {
1855
+ this.liveTranscriptionRecorder = null;
1856
+ this.liveTranscriptionLoopPromise = null;
1857
+ });
1858
+ void speakerName;
1859
+ await Promise.resolve();
1860
+ }
1861
+ async captureLiveChunk(stream, mimeType) {
1862
+ return new Promise((resolve, reject) => {
1863
+ const chunks = [];
1864
+ const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
1865
+ this.liveTranscriptionRecorder = recorder;
1866
+ const timeout = setTimeout(() => {
1867
+ if (recorder.state !== "inactive") recorder.stop();
1868
+ }, 4500);
1869
+ recorder.ondataavailable = (event) => {
1870
+ if (event.data.size > 0) chunks.push(event.data);
1871
+ };
1872
+ recorder.onerror = () => {
1873
+ clearTimeout(timeout);
1874
+ reject(new Error("Live transcription recorder failed"));
1875
+ };
1876
+ recorder.onstop = () => {
1877
+ clearTimeout(timeout);
1878
+ this.liveTranscriptionRecorder = null;
1879
+ resolve(
1880
+ chunks.length ? new Blob(chunks, { type: recorder.mimeType || mimeType || "audio/webm" }) : null
1881
+ );
1882
+ };
1883
+ recorder.start();
1884
+ });
1885
+ }
1886
+ async runLiveTranscriptionLoop(stream, mimeType) {
1887
+ while (this.liveTranscriptionActive && stream.getAudioTracks()[0]?.readyState === "live") {
1888
+ try {
1889
+ const blob = await this.captureLiveChunk(stream, mimeType);
1890
+ if (!this.liveTranscriptionActive) break;
1891
+ if (!blob || blob.size === 0) continue;
1892
+ const sequence = this.liveTranscriptionSequence++;
1893
+ const offsetMs = Math.max(0, Date.now() - this.liveTranscriptionStartedAt);
1894
+ await this.uploadLiveTranscriptionChunk(blob, sequence, offsetMs, mimeType);
1895
+ } catch (error) {
1896
+ this.onError(error);
1897
+ await new Promise((resolve) => setTimeout(resolve, 750));
1898
+ }
1899
+ }
1900
+ }
1901
+ async uploadLiveTranscriptionChunk(blob, sequence, offsetMs, mimeType) {
1902
+ if (!this.room || !this.authToken) return;
1903
+ const form = new FormData();
1904
+ form.append("sequence", String(sequence));
1905
+ form.append("offset_ms", String(offsetMs));
1906
+ const extension = mimeType.includes("ogg") || blob.type.includes("ogg") ? "ogg" : "webm";
1907
+ form.append("audio", blob, `live-${sequence}.${extension}`);
1908
+ const response = await fetch(
1909
+ `${this.apiBase}/rooms/${encodeURIComponent(this.room)}/live-transcription`,
1910
+ {
1911
+ method: "POST",
1912
+ headers: {
1913
+ Authorization: `Bearer ${this.authToken}`,
1914
+ Accept: "application/json"
1915
+ },
1916
+ body: form
1917
+ }
1918
+ );
1919
+ if (!response.ok) {
1920
+ let detail = `Live transcription request failed: ${response.status}`;
1921
+ try {
1922
+ const body = await response.json();
1923
+ detail = body?.message || body?.detail || detail;
1924
+ } catch {
1925
+ }
1926
+ throw new Error(detail);
1927
+ }
1928
+ }
1929
+ stopLiveTranscription() {
1930
+ this.liveTranscriptionActive = false;
1931
+ try {
1932
+ if (this.liveTranscriptionRecorder?.state !== "inactive") {
1933
+ this.liveTranscriptionRecorder?.stop();
1934
+ }
1935
+ } catch {
1936
+ }
1937
+ this.liveTranscriptionRecorder = null;
1938
+ }
1939
+ isLiveTranscriptionActive() {
1940
+ return this.liveTranscriptionActive;
1941
+ }
1942
+ async applyNetworkRecommendation(action) {
1943
+ if (!this.videoSender || this.networkProfile === action) return;
1944
+ const params = this.videoSender.getParameters();
1945
+ const encodings = params.encodings?.length ? params.encodings : [{}];
1946
+ const encoding = encodings[0];
1947
+ switch (action) {
1948
+ case "reduce_video":
1949
+ encoding.maxBitrate = 7e5;
1950
+ encoding.maxFramerate = 20;
1951
+ break;
1952
+ case "audio_priority":
1953
+ encoding.maxBitrate = 35e4;
1954
+ encoding.maxFramerate = 10;
1955
+ break;
1956
+ default:
1957
+ encoding.maxBitrate = 15e5;
1958
+ encoding.maxFramerate = 30;
1959
+ break;
1960
+ }
1961
+ params.encodings = encodings;
1962
+ try {
1963
+ await this.videoSender.setParameters(params);
1964
+ this.networkProfile = action;
1965
+ } catch (error) {
1966
+ this.onError(error);
1967
+ }
1968
+ }
1761
1969
  async startRecording() {
1762
1970
  if (!this.send({ type: "start_recording" })) {
1763
1971
  throw new Error("SFU signaling is not connected");
@@ -1890,6 +2098,7 @@ var SFUClient = class {
1890
2098
  }
1891
2099
  }
1892
2100
  async leave() {
2101
+ this.stopLiveTranscription();
1893
2102
  this.stopHeartbeat();
1894
2103
  this.intentionalClose = true;
1895
2104
  this.setState("leaving");
@@ -2298,6 +2507,14 @@ var VideoSDKCore = class {
2298
2507
  console.warn(`[SFU] Reconnecting, attempt ${attempt}`);
2299
2508
  },
2300
2509
  // ADMISSION CONTROL
2510
+ onLiveTranscript: (transcript) => {
2511
+ this.state.addLiveTranscript(transcript);
2512
+ this.events.onLiveTranscript?.(transcript);
2513
+ },
2514
+ onNetworkQuality: (quality) => {
2515
+ this.state.setNetworkQuality(quality);
2516
+ this.events.onNetworkQuality?.(quality);
2517
+ },
2301
2518
  onRoomUpdate: (payload) => {
2302
2519
  const participants = payload?.participants || [];
2303
2520
  const local = participants.find(
@@ -2942,6 +3159,28 @@ var VideoSDKCore = class {
2942
3159
  }
2943
3160
  }
2944
3161
  }
3162
+ async startLiveTranscription() {
3163
+ if (!this.sfuClient) {
3164
+ throw new Error("SFU client is not connected");
3165
+ }
3166
+ const audioTrack = this.localStream?.getAudioTracks()[0];
3167
+ if (!audioTrack) {
3168
+ throw new Error("Microphone audio track is not available");
3169
+ }
3170
+ await this.sfuClient.startLiveTranscription(
3171
+ audioTrack,
3172
+ this.participantName
3173
+ );
3174
+ }
3175
+ stopLiveTranscription() {
3176
+ this.sfuClient?.stopLiveTranscription();
3177
+ }
3178
+ isLiveTranscriptionActive() {
3179
+ return this.sfuClient?.isLiveTranscriptionActive() ?? false;
3180
+ }
3181
+ getNetworkQuality() {
3182
+ return this.state.networkQuality;
3183
+ }
2945
3184
  // CLEANUP
2946
3185
  cleanupRemoteParticipant(participantId) {
2947
3186
  this.activeSpeakerDetector.removeParticipant(participantId);
@@ -2990,6 +3229,8 @@ var VideoSDKCore = class {
2990
3229
  this.room.id = null;
2991
3230
  this.room.name = null;
2992
3231
  this.state.localParticipant = null;
3232
+ this.state.clearLiveTranscripts();
3233
+ this.state.setNetworkQuality(null);
2993
3234
  this.state.notify("localParticipant");
2994
3235
  this.state.resetRemoteState();
2995
3236
  this.events.onMeetingLeft?.();
@@ -3022,9 +3263,15 @@ var MeetingProvider = ({
3022
3263
  const entryResponseListeners = (0, import_react2.useRef)(
3023
3264
  /* @__PURE__ */ new Set()
3024
3265
  );
3025
- const joinRequestCanceledListeners = (0, import_react2.useRef)(/* @__PURE__ */ new Set());
3026
- const participantRemovedListeners = (0, import_react2.useRef)(/* @__PURE__ */ new Set());
3027
- const participantPermissionsChangedListeners = (0, import_react2.useRef)(/* @__PURE__ */ new Set());
3266
+ const joinRequestCanceledListeners = (0, import_react2.useRef)(
3267
+ /* @__PURE__ */ new Set()
3268
+ );
3269
+ const participantRemovedListeners = (0, import_react2.useRef)(
3270
+ /* @__PURE__ */ new Set()
3271
+ );
3272
+ const participantPermissionsChangedListeners = (0, import_react2.useRef)(
3273
+ /* @__PURE__ */ new Set()
3274
+ );
3028
3275
  const meetingLeftListeners = (0, import_react2.useRef)(/* @__PURE__ */ new Set());
3029
3276
  const trackStateListeners = (0, import_react2.useRef)(/* @__PURE__ */ new Set());
3030
3277
  const recordingStartedListeners = (0, import_react2.useRef)(/* @__PURE__ */ new Set());
@@ -3037,6 +3284,12 @@ var MeetingProvider = ({
3037
3284
  const activeSpeakerListeners = (0, import_react2.useRef)(
3038
3285
  /* @__PURE__ */ new Set()
3039
3286
  );
3287
+ const liveTranscriptListeners = (0, import_react2.useRef)(
3288
+ /* @__PURE__ */ new Set()
3289
+ );
3290
+ const networkQualityListeners = (0, import_react2.useRef)(
3291
+ /* @__PURE__ */ new Set()
3292
+ );
3040
3293
  const connectionStateListeners = (0, import_react2.useRef)(
3041
3294
  /* @__PURE__ */ new Set()
3042
3295
  );
@@ -3060,10 +3313,14 @@ var MeetingProvider = ({
3060
3313
  joinRequestCanceledListeners.current.forEach((fn) => fn(requestId));
3061
3314
  },
3062
3315
  onParticipantRemoved: (participantId, removedBy) => {
3063
- participantRemovedListeners.current.forEach((fn) => fn(participantId, removedBy));
3316
+ participantRemovedListeners.current.forEach(
3317
+ (fn) => fn(participantId, removedBy)
3318
+ );
3064
3319
  },
3065
3320
  onParticipantPermissionsChanged: (participantId, permissions) => {
3066
- participantPermissionsChangedListeners.current.forEach((fn) => fn(participantId, permissions));
3321
+ participantPermissionsChangedListeners.current.forEach(
3322
+ (fn) => fn(participantId, permissions)
3323
+ );
3067
3324
  },
3068
3325
  onMeetingLeft: () => {
3069
3326
  meetingLeftListeners.current.forEach((fn) => fn());
@@ -3116,6 +3373,12 @@ var MeetingProvider = ({
3116
3373
  );
3117
3374
  pubSubListeners.current.get("REACTION")?.forEach((fn) => fn(message));
3118
3375
  },
3376
+ onLiveTranscript: (transcript) => {
3377
+ liveTranscriptListeners.current.forEach((fn) => fn(transcript));
3378
+ },
3379
+ onNetworkQuality: (quality) => {
3380
+ networkQualityListeners.current.forEach((fn) => fn(quality));
3381
+ },
3119
3382
  onConnectionStateChanged: (state) => {
3120
3383
  connectionStateListeners.current.forEach(
3121
3384
  (fn) => fn(state)
@@ -3151,6 +3414,16 @@ var MeetingProvider = ({
3151
3414
  "chat",
3152
3415
  (s) => s.getChatMessages()
3153
3416
  );
3417
+ const liveTranscripts = useMeetingStore(
3418
+ sdk.state,
3419
+ "transcript",
3420
+ (s) => s.getLiveTranscripts()
3421
+ );
3422
+ const networkQuality = useMeetingStore(
3423
+ sdk.state,
3424
+ "networkQuality",
3425
+ (s) => s.networkQuality
3426
+ );
3154
3427
  const value = (0, import_react2.useMemo)(() => {
3155
3428
  return {
3156
3429
  sdk,
@@ -3175,6 +3448,11 @@ var MeetingProvider = ({
3175
3448
  messages,
3176
3449
  presenterId,
3177
3450
  activeSpeakerIds,
3451
+ liveTranscripts,
3452
+ networkQuality,
3453
+ startLiveTranscription: sdk.startLiveTranscription.bind(sdk),
3454
+ stopLiveTranscription: sdk.stopLiveTranscription.bind(sdk),
3455
+ isLiveTranscriptionActive: sdk.isLiveTranscriptionActive.bind(sdk),
3178
3456
  recording,
3179
3457
  usePubSub: (topic) => {
3180
3458
  if (topic === "SECURE_CHAT") {
@@ -3293,6 +3571,14 @@ var MeetingProvider = ({
3293
3571
  reactionListeners.current.add(cb);
3294
3572
  return () => reactionListeners.current.delete(cb);
3295
3573
  },
3574
+ onLiveTranscript: (cb) => {
3575
+ liveTranscriptListeners.current.add(cb);
3576
+ return () => liveTranscriptListeners.current.delete(cb);
3577
+ },
3578
+ onNetworkQuality: (cb) => {
3579
+ networkQualityListeners.current.add(cb);
3580
+ return () => networkQualityListeners.current.delete(cb);
3581
+ },
3296
3582
  onConnectionStateChanged: (cb) => {
3297
3583
  connectionStateListeners.current.add(cb);
3298
3584
  return () => {
@@ -3315,6 +3601,8 @@ var MeetingProvider = ({
3315
3601
  presenterId,
3316
3602
  recording,
3317
3603
  activeSpeakerIds,
3604
+ liveTranscripts,
3605
+ networkQuality,
3318
3606
  reactionPubSubMessages
3319
3607
  ]);
3320
3608
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MeetingContext.Provider, { value, children });
@@ -3399,7 +3687,9 @@ var useMeeting = (handlers) => {
3399
3687
  }, [handlers?.onParticipantRemoved]);
3400
3688
  (0, import_react4.useEffect)(() => {
3401
3689
  if (!handlers?.onParticipantPermissionsChanged) return;
3402
- return ctx.onParticipantPermissionsChanged(handlers.onParticipantPermissionsChanged);
3690
+ return ctx.onParticipantPermissionsChanged(
3691
+ handlers.onParticipantPermissionsChanged
3692
+ );
3403
3693
  }, [handlers?.onParticipantPermissionsChanged]);
3404
3694
  (0, import_react4.useEffect)(() => {
3405
3695
  if (!handlers?.onMeetingLeft) return;
@@ -3421,6 +3711,14 @@ var useMeeting = (handlers) => {
3421
3711
  if (!handlers?.onConnectionStateChanged) return;
3422
3712
  return ctx.onConnectionStateChanged(handlers.onConnectionStateChanged);
3423
3713
  }, [handlers?.onConnectionStateChanged]);
3714
+ (0, import_react4.useEffect)(() => {
3715
+ if (!handlers?.onLiveTranscript) return;
3716
+ return ctx.onLiveTranscript(handlers.onLiveTranscript);
3717
+ }, [handlers?.onLiveTranscript]);
3718
+ (0, import_react4.useEffect)(() => {
3719
+ if (!handlers?.onNetworkQuality) return;
3720
+ return ctx.onNetworkQuality(handlers.onNetworkQuality);
3721
+ }, [handlers?.onNetworkQuality]);
3424
3722
  (0, import_react4.useEffect)(() => {
3425
3723
  if (!handlers?.onReaction) return;
3426
3724
  return ctx.onReaction(handlers.onReaction);
@@ -3558,6 +3856,26 @@ function useMeetingPreview(roomId, participantId) {
3558
3856
  };
3559
3857
  }
3560
3858
 
3859
+ // src/react/useLiveTranscript.ts
3860
+ var useLiveTranscript = () => {
3861
+ const { sdk } = useMeetingContext();
3862
+ return useMeetingStore(
3863
+ sdk.state,
3864
+ "transcript",
3865
+ (state) => state.getLiveTranscripts()
3866
+ );
3867
+ };
3868
+
3869
+ // src/react/useNetworkQuality.ts
3870
+ var useNetworkQuality = () => {
3871
+ const { sdk } = useMeetingContext();
3872
+ return useMeetingStore(
3873
+ sdk.state,
3874
+ "networkQuality",
3875
+ (state) => state.networkQuality
3876
+ );
3877
+ };
3878
+
3561
3879
  // src/react/usePubSub.ts
3562
3880
  var import_react8 = require("react");
3563
3881
  function usePubSub(topic, options) {
@@ -3577,10 +3895,12 @@ function usePubSub(topic, options) {
3577
3895
  MeetingProvider,
3578
3896
  MeetingState,
3579
3897
  VideoSDKCore,
3898
+ useLiveTranscript,
3580
3899
  useLocalParticipant,
3581
3900
  useMeeting,
3582
3901
  useMeetingContext,
3583
3902
  useMeetingPreview,
3903
+ useNetworkQuality,
3584
3904
  useParticipants,
3585
3905
  usePubSub,
3586
3906
  useRemoteMedia