@webex/plugin-meetings 3.12.0-next.1 → 3.12.0-next.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/aiEnableRequest/index.js +1 -1
  2. package/dist/breakouts/breakout.js +1 -1
  3. package/dist/breakouts/index.js +1 -1
  4. package/dist/hashTree/constants.js +10 -1
  5. package/dist/hashTree/constants.js.map +1 -1
  6. package/dist/hashTree/hashTreeParser.js +20 -11
  7. package/dist/hashTree/hashTreeParser.js.map +1 -1
  8. package/dist/hashTree/utils.js +22 -0
  9. package/dist/hashTree/utils.js.map +1 -1
  10. package/dist/interpretation/index.js +1 -1
  11. package/dist/interpretation/siLanguage.js +1 -1
  12. package/dist/meeting/index.js +427 -323
  13. package/dist/meeting/index.js.map +1 -1
  14. package/dist/metrics/constants.js +5 -1
  15. package/dist/metrics/constants.js.map +1 -1
  16. package/dist/multistream/sendSlotManager.js +116 -2
  17. package/dist/multistream/sendSlotManager.js.map +1 -1
  18. package/dist/types/hashTree/constants.d.ts +1 -0
  19. package/dist/types/hashTree/utils.d.ts +11 -0
  20. package/dist/types/meeting/index.d.ts +24 -1
  21. package/dist/types/metrics/constants.d.ts +4 -0
  22. package/dist/types/multistream/sendSlotManager.d.ts +23 -1
  23. package/dist/webinar/index.js +325 -220
  24. package/dist/webinar/index.js.map +1 -1
  25. package/package.json +15 -15
  26. package/src/hashTree/constants.ts +9 -0
  27. package/src/hashTree/hashTreeParser.ts +21 -14
  28. package/src/hashTree/utils.ts +17 -0
  29. package/src/meeting/index.ts +165 -57
  30. package/src/metrics/constants.ts +5 -0
  31. package/src/multistream/sendSlotManager.ts +97 -3
  32. package/src/webinar/index.ts +120 -18
  33. package/test/unit/spec/hashTree/hashTreeParser.ts +238 -0
  34. package/test/unit/spec/hashTree/utils.ts +88 -1
  35. package/test/unit/spec/meeting/index.js +179 -48
  36. package/test/unit/spec/meetings/index.js +3 -3
  37. package/test/unit/spec/multistream/sendSlotManager.ts +135 -36
  38. package/test/unit/spec/webinar/index.ts +193 -8
@@ -22,6 +22,7 @@ import {
22
22
  MediaConnectionEventNames,
23
23
  MediaContent,
24
24
  MediaType,
25
+ MediaCodecMimeType,
25
26
  RemoteTrackType,
26
27
  RoapMessage,
27
28
  StatsAnalyzer,
@@ -3733,7 +3734,7 @@ export default class Meeting extends StatelessWebexPlugin {
3733
3734
  });
3734
3735
  this.updateLLMConnection();
3735
3736
  });
3736
- this.locusInfo.on(LOCUSINFO.EVENTS.SELF_ADMITTED_GUEST, async (payload) => {
3737
+ this.locusInfo.on(LOCUSINFO.EVENTS.SELF_ADMITTED_GUEST, (payload) => {
3737
3738
  this.stopKeepAlive();
3738
3739
 
3739
3740
  if (payload) {
@@ -3759,6 +3760,15 @@ export default class Meeting extends StatelessWebexPlugin {
3759
3760
  });
3760
3761
  }
3761
3762
  this.rtcMetrics?.sendNextMetrics();
3763
+
3764
+ this.ensureDefaultDatachannelTokenAfterAdmit().catch((error) => {
3765
+ LoggerProxy.logger.warn(
3766
+ `Meeting:index#setUpLocusInfoSelfListener --> failed post-admit token prefetch flow: ${
3767
+ error?.message || String(error)
3768
+ }`
3769
+ );
3770
+ });
3771
+
3762
3772
  this.updateLLMConnection();
3763
3773
  });
3764
3774
 
@@ -5907,37 +5917,35 @@ export default class Meeting extends StatelessWebexPlugin {
5907
5917
  * @returns {void}
5908
5918
  */
5909
5919
  stopTranscription() {
5910
- if (this.transcription) {
5911
- // @ts-ignore
5912
- this.webex.internal.voicea.off(
5913
- VOICEAEVENTS.VOICEA_ANNOUNCEMENT,
5914
- this.voiceaListenerCallbacks[VOICEAEVENTS.VOICEA_ANNOUNCEMENT]
5915
- );
5920
+ // @ts-ignore
5921
+ this.webex.internal.voicea.off(
5922
+ VOICEAEVENTS.VOICEA_ANNOUNCEMENT,
5923
+ this.voiceaListenerCallbacks[VOICEAEVENTS.VOICEA_ANNOUNCEMENT]
5924
+ );
5916
5925
 
5917
- // @ts-ignore
5918
- this.webex.internal.voicea.off(
5919
- VOICEAEVENTS.CAPTIONS_TURNED_ON,
5920
- this.voiceaListenerCallbacks[VOICEAEVENTS.CAPTIONS_TURNED_ON]
5921
- );
5926
+ // @ts-ignore
5927
+ this.webex.internal.voicea.off(
5928
+ VOICEAEVENTS.CAPTIONS_TURNED_ON,
5929
+ this.voiceaListenerCallbacks[VOICEAEVENTS.CAPTIONS_TURNED_ON]
5930
+ );
5922
5931
 
5923
- // @ts-ignore
5924
- this.webex.internal.voicea.off(
5925
- VOICEAEVENTS.EVA_COMMAND,
5926
- this.voiceaListenerCallbacks[VOICEAEVENTS.EVA_COMMAND]
5927
- );
5932
+ // @ts-ignore
5933
+ this.webex.internal.voicea.off(
5934
+ VOICEAEVENTS.EVA_COMMAND,
5935
+ this.voiceaListenerCallbacks[VOICEAEVENTS.EVA_COMMAND]
5936
+ );
5928
5937
 
5929
- // @ts-ignore
5930
- this.webex.internal.voicea.off(
5931
- VOICEAEVENTS.NEW_CAPTION,
5932
- this.voiceaListenerCallbacks[VOICEAEVENTS.NEW_CAPTION]
5933
- );
5938
+ // @ts-ignore
5939
+ this.webex.internal.voicea.off(
5940
+ VOICEAEVENTS.NEW_CAPTION,
5941
+ this.voiceaListenerCallbacks[VOICEAEVENTS.NEW_CAPTION]
5942
+ );
5934
5943
 
5935
- // @ts-ignore
5936
- this.webex.internal.voicea.deregisterEvents();
5944
+ // @ts-ignore
5945
+ this.webex.internal.voicea.deregisterEvents();
5937
5946
 
5938
- this.areVoiceaEventsSetup = false;
5939
- this.triggerStopReceivingTranscriptionEvent();
5940
- }
5947
+ this.areVoiceaEventsSetup = false;
5948
+ this.triggerStopReceivingTranscriptionEvent();
5941
5949
  }
5942
5950
 
5943
5951
  /**
@@ -5961,6 +5969,30 @@ export default class Meeting extends StatelessWebexPlugin {
5961
5969
  );
5962
5970
  }
5963
5971
 
5972
+ /**
5973
+ * Restores LLM subchannel subscriptions after reconnect when captions are active.
5974
+ * @returns {void}
5975
+ */
5976
+ private restoreLLMSubscriptionsIfNeeded(): void {
5977
+ try {
5978
+ // @ts-ignore
5979
+ const isCaptionBoxOn = this.webex.internal.voicea?.getIsCaptionBoxOn?.();
5980
+
5981
+ if (!isCaptionBoxOn) {
5982
+ return;
5983
+ }
5984
+
5985
+ // @ts-ignore
5986
+ this.webex.internal.voicea.updateSubchannelSubscriptions({subscribe: ['transcription']});
5987
+ } catch (error) {
5988
+ const msg = error?.message || String(error);
5989
+
5990
+ LoggerProxy.logger.warn(
5991
+ `Meeting:index#restoreLLMSubscriptionsIfNeeded --> failed to restore subscriptions after LLM online: ${msg}`
5992
+ );
5993
+ }
5994
+ }
5995
+
5964
5996
  /**
5965
5997
  * This is a callback for the LLM event that is triggered when it comes online
5966
5998
  * This method in turn will trigger an event to the developers that the LLM is connected
@@ -5969,8 +6001,8 @@ export default class Meeting extends StatelessWebexPlugin {
5969
6001
  * @returns {null}
5970
6002
  */
5971
6003
  private handleLLMOnline = (): void => {
5972
- // @ts-ignore
5973
- this.webex.internal.llm.off('online', this.handleLLMOnline);
6004
+ this.restoreLLMSubscriptionsIfNeeded();
6005
+
5974
6006
  Trigger.trigger(
5975
6007
  this,
5976
6008
  {
@@ -6198,8 +6230,11 @@ export default class Meeting extends StatelessWebexPlugin {
6198
6230
  return Promise.reject(error);
6199
6231
  })
6200
6232
  .then((join) => {
6233
+ this.saveDataChannelToken(join);
6201
6234
  // @ts-ignore - config coming from registerPlugin
6202
6235
  if (this.config.enableAutomaticLLM) {
6236
+ // @ts-ignore
6237
+ this.webex.internal.llm.off('online', this.handleLLMOnline);
6203
6238
  // @ts-ignore
6204
6239
  this.webex.internal.llm.on('online', this.handleLLMOnline);
6205
6240
  this.updateLLMConnection()
@@ -6309,33 +6344,102 @@ export default class Meeting extends StatelessWebexPlugin {
6309
6344
  }
6310
6345
  };
6311
6346
 
6347
+ /**
6348
+ * Clears all data channel tokens stored in LLM.
6349
+ * Called during meeting cleanup to ensure stale tokens are not reused.
6350
+ * @returns {void}
6351
+ */
6352
+ clearDataChannelToken(): void {
6353
+ // @ts-ignore
6354
+ this.webex.internal.llm.resetDatachannelTokens();
6355
+ }
6356
+
6357
+ /**
6358
+ * Saves the data channel tokens from the join response into LLM so that
6359
+ * updateLLMConnection / updatePSDataChannel don't need to fetch them from locusInfo.
6360
+ * @param {Object} join - The parsed join response (from MeetingUtil.parseLocusJoin)
6361
+ * @returns {void}
6362
+ */
6363
+ saveDataChannelToken(join: any): void {
6364
+ const datachannelToken = join?.locus?.self?.datachannelToken;
6365
+ const practiceSessionDatachannelToken = join?.locus?.self?.practiceSessionDatachannelToken;
6366
+
6367
+ if (datachannelToken) {
6368
+ // @ts-ignore
6369
+ this.webex.internal.llm.setDatachannelToken(datachannelToken, DataChannelTokenType.Default);
6370
+ }
6371
+
6372
+ if (practiceSessionDatachannelToken) {
6373
+ // @ts-ignore
6374
+ this.webex.internal.llm.setDatachannelToken(
6375
+ practiceSessionDatachannelToken,
6376
+ DataChannelTokenType.PracticeSession
6377
+ );
6378
+ }
6379
+ }
6380
+
6381
+ /**
6382
+ * Ensures default-session data channel token exists after lobby admission.
6383
+ * Some lobby users do not receive a token until they are admitted.
6384
+ * @returns {Promise<boolean>} true when a new token is fetched and cached
6385
+ */
6386
+ private async ensureDefaultDatachannelTokenAfterAdmit(): Promise<boolean> {
6387
+ try {
6388
+ // @ts-ignore
6389
+ const datachannelToken = this.webex.internal.llm.getDatachannelToken();
6390
+ // @ts-ignore
6391
+ const isDataChannelTokenEnabled = await this.webex.internal.llm.isDataChannelTokenEnabled();
6392
+
6393
+ if (!isDataChannelTokenEnabled || datachannelToken) {
6394
+ return false;
6395
+ }
6396
+
6397
+ const response = await this.meetingRequest.fetchDatachannelToken({
6398
+ locusUrl: this.locusUrl,
6399
+ requestingParticipantId: this.members.selfId,
6400
+ isPracticeSession: false,
6401
+ });
6402
+ const fetchedDatachannelToken = response?.body?.datachannelToken;
6403
+
6404
+ if (!fetchedDatachannelToken) {
6405
+ return false;
6406
+ }
6407
+
6408
+ // @ts-ignore
6409
+ this.webex.internal.llm.setDatachannelToken(
6410
+ fetchedDatachannelToken,
6411
+ DataChannelTokenType.Default
6412
+ );
6413
+
6414
+ return true;
6415
+ } catch (error) {
6416
+ const msg = error?.message || String(error);
6417
+
6418
+ LoggerProxy.logger.warn(
6419
+ `Meeting:index#ensureDefaultDatachannelTokenAfterAdmit --> failed to proactively fetch default data channel token after admit: ${msg}`,
6420
+ {statusCode: error?.statusCode}
6421
+ );
6422
+
6423
+ return false;
6424
+ }
6425
+ }
6426
+
6312
6427
  /**
6313
6428
  * Connects to low latency mercury and reconnects if the address has changed
6314
6429
  * It will also disconnect if called when the meeting has ended
6315
- * @param {String} datachannelUrl
6316
6430
  * @returns {Promise}
6317
6431
  */
6318
6432
  async updateLLMConnection() {
6319
6433
  // @ts-ignore - Fix type
6320
- const {
6321
- url = undefined,
6322
- info: {datachannelUrl = undefined} = {},
6323
- self: {datachannelToken = undefined} = {},
6324
- } = this.locusInfo || {};
6434
+ const {url = undefined, info: {datachannelUrl = undefined} = {}} = this.locusInfo || {};
6325
6435
 
6326
6436
  const isJoined = this.isJoined();
6327
6437
 
6328
6438
  // @ts-ignore
6329
- const currentToken = this.webex.internal.llm.getDatachannelToken(DataChannelTokenType.Default);
6330
-
6331
- const finalToken = currentToken ?? datachannelToken;
6332
-
6333
- if (!currentToken && datachannelToken) {
6334
- // @ts-ignore
6335
- this.webex.internal.llm.setDatachannelToken(datachannelToken, DataChannelTokenType.Default);
6336
- }
6439
+ const datachannelToken = this.webex.internal.llm.getDatachannelToken(
6440
+ DataChannelTokenType.Default
6441
+ );
6337
6442
 
6338
- // webinar panelist should use new data channel in practice session
6339
6443
  const dataChannelUrl = datachannelUrl;
6340
6444
 
6341
6445
  // @ts-ignore - Fix type
@@ -6358,7 +6462,7 @@ export default class Meeting extends StatelessWebexPlugin {
6358
6462
 
6359
6463
  // @ts-ignore - Fix type
6360
6464
  return this.webex.internal.llm
6361
- .registerAndConnect(url, dataChannelUrl, finalToken)
6465
+ .registerAndConnect(url, dataChannelUrl, datachannelToken)
6362
6466
  .then((registerAndConnectResult) => {
6363
6467
  // @ts-ignore - Fix type
6364
6468
  this.webex.internal.llm.off('event:relay.event', this.processRelayEvent);
@@ -9618,13 +9722,12 @@ export default class Meeting extends StatelessWebexPlugin {
9618
9722
  }
9619
9723
  this.queuedMediaUpdates = [];
9620
9724
 
9621
- if (this.transcription) {
9622
- this.stopTranscription();
9623
- this.transcription = undefined;
9624
- }
9725
+ this.stopTranscription();
9726
+ this.transcription = undefined;
9625
9727
 
9626
9728
  this.annotation.deregisterEvents();
9627
9729
 
9730
+ this.clearDataChannelToken();
9628
9731
  await this.cleanupLLMConneciton({throwOnError: false});
9629
9732
  };
9630
9733
 
@@ -9818,15 +9921,20 @@ export default class Meeting extends StatelessWebexPlugin {
9818
9921
  }
9819
9922
 
9820
9923
  if (shouldEnableMusicMode) {
9821
- await this.sendSlotManager.setCodecParameters(MediaType.AudioMain, {
9822
- maxaveragebitrate: '64000',
9823
- maxplaybackrate: '48000',
9824
- });
9924
+ await this.sendSlotManager.setCustomCodecParameters(
9925
+ MediaType.AudioMain,
9926
+ MediaCodecMimeType.OPUS,
9927
+ {
9928
+ maxaveragebitrate: '64000',
9929
+ maxplaybackrate: '48000',
9930
+ }
9931
+ );
9825
9932
  } else {
9826
- await this.sendSlotManager.deleteCodecParameters(MediaType.AudioMain, [
9827
- 'maxaveragebitrate',
9828
- 'maxplaybackrate',
9829
- ]);
9933
+ await this.sendSlotManager.markCustomCodecParametersForDeletion(
9934
+ MediaType.AudioMain,
9935
+ MediaCodecMimeType.OPUS,
9936
+ ['maxaveragebitrate', 'maxplaybackrate']
9937
+ );
9830
9938
  }
9831
9939
  }
9832
9940
 
@@ -91,6 +91,11 @@ const BEHAVIORAL_METRICS = {
91
91
  LOCUS_CLASSIC_VS_HASH_TREE_MISMATCH: 'js_sdk_locus_classic_vs_hash_tree_mismatch',
92
92
  LOCUS_HASH_TREE_UNSUPPORTED_OPERATION: 'js_sdk_locus_hash_tree_unsupported_operation',
93
93
  MEDIA_STILL_NOT_CONNECTED: 'js_sdk_media_still_not_connected',
94
+ DEPRECATED_SET_CODEC_PARAMETERS_USED: 'js_sdk_deprecated_set_codec_parameters_used',
95
+ DEPRECATED_DELETE_CODEC_PARAMETERS_USED: 'js_sdk_deprecated_delete_codec_parameters_used',
96
+ SET_CUSTOM_CODEC_PARAMETERS_USED: 'js_sdk_set_custom_codec_parameters_used',
97
+ MARK_CUSTOM_CODEC_PARAMETERS_FOR_DELETION_USED:
98
+ 'js_sdk_mark_custom_codec_parameters_for_deletion_used',
94
99
  };
95
100
 
96
101
  export {BEHAVIORAL_METRICS as default};
@@ -5,7 +5,11 @@ import {
5
5
  MultistreamRoapMediaConnection,
6
6
  NamedMediaGroup,
7
7
  StreamState,
8
+ MediaCodecMimeType,
9
+ CodecParameters,
8
10
  } from '@webex/internal-media-core';
11
+ import Metrics from '../metrics';
12
+ import BEHAVIORAL_METRICS from '../metrics/constants';
9
13
 
10
14
  /**
11
15
  * This class is used to manage the sendSlots for the given media types.
@@ -206,6 +210,8 @@ export default class SendSlotManager {
206
210
  }
207
211
 
208
212
  /**
213
+ * @deprecated Use {@link setCustomCodecParameters} instead, which requires specifying the codec MIME type.
214
+ *
209
215
  * This method is used to set the codec parameters for the sendSlot of the given mediaType
210
216
  * @param {MediaType} mediaType MediaType of the sendSlot for which the codec parameters needs to be set (AUDIO_MAIN/VIDEO_MAIN/AUDIO_SLIDES/VIDEO_SLIDES)
211
217
  * @param {Object} codecParameters
@@ -226,12 +232,19 @@ export default class SendSlotManager {
226
232
 
227
233
  await slot.setCodecParameters(codecParameters);
228
234
 
229
- this.LoggerProxy.logger.info(
230
- `SendSlotsManager->setCodecParameters#Set codec parameters for ${mediaType} to ${codecParameters}`
235
+ this.LoggerProxy.logger.warn(
236
+ 'SendSlotsManager->setCodecParameters --> [DEPRECATION WARNING]: setCodecParameters has been deprecated, use setCustomCodecParameters instead'
231
237
  );
238
+
239
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.DEPRECATED_SET_CODEC_PARAMETERS_USED, {
240
+ mediaType,
241
+ codecParameters,
242
+ });
232
243
  }
233
244
 
234
245
  /**
246
+ * @deprecated Use {@link markCustomCodecParametersForDeletion} instead, which requires specifying the codec MIME type.
247
+ *
235
248
  * This method is used to delete the codec parameters for the sendSlot of the given mediaType
236
249
  * @param {MediaType} mediaType MediaType of the sendSlot for which the codec parameters needs to be deleted (AUDIO_MAIN/VIDEO_MAIN/AUDIO_SLIDES/VIDEO_SLIDES)
237
250
  * @param {Array<String>} parameters Array of keys of the codec parameters to be deleted
@@ -246,8 +259,89 @@ export default class SendSlotManager {
246
259
 
247
260
  await slot.deleteCodecParameters(parameters);
248
261
 
262
+ this.LoggerProxy.logger.warn(
263
+ 'SendSlotsManager->deleteCodecParameters --> [DEPRECATION WARNING]: deleteCodecParameters has been deprecated, use markCustomCodecParametersForDeletion instead'
264
+ );
265
+
266
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.DEPRECATED_DELETE_CODEC_PARAMETERS_USED, {
267
+ mediaType,
268
+ parameters,
269
+ });
270
+ }
271
+
272
+ /**
273
+ * Sets custom codec parameters for the sendSlot of the given mediaType, scoped to a specific codec MIME type.
274
+ * Delegates to WCME's setCustomCodecParameters API.
275
+ * @param {MediaType} mediaType MediaType of the sendSlot
276
+ * @param {MediaCodecMimeType} codecMimeType The codec MIME type to apply parameters to (e.g. OPUS, H264, AV1)
277
+ * @param {CodecParameters} parameters Key-value pairs of codec parameters to set
278
+ * @returns {Promise<void>}
279
+ */
280
+ public async setCustomCodecParameters(
281
+ mediaType: MediaType,
282
+ codecMimeType: MediaCodecMimeType,
283
+ parameters: CodecParameters
284
+ ): Promise<void> {
285
+ const slot = this.slots.get(mediaType);
286
+
287
+ if (!slot) {
288
+ throw new Error(`Slot for ${mediaType} does not exist`);
289
+ }
290
+
291
+ try {
292
+ await slot.setCustomCodecParameters(codecMimeType, parameters);
293
+
294
+ this.LoggerProxy.logger.info(
295
+ `SendSlotsManager->setCustomCodecParameters#Set custom codec parameters for ${mediaType} (codec: ${codecMimeType}) to ${JSON.stringify(
296
+ parameters
297
+ )}`
298
+ );
299
+ } catch (error) {
300
+ this.LoggerProxy.logger.error(
301
+ `SendSlotsManager->setCustomCodecParameters#Failed to set custom codec parameters for ${mediaType} (codec: ${codecMimeType}): ${error}`
302
+ );
303
+ throw error;
304
+ } finally {
305
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.SET_CUSTOM_CODEC_PARAMETERS_USED, {
306
+ mediaType,
307
+ codecMimeType,
308
+ parameters,
309
+ });
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Marks custom codec parameters for deletion on the sendSlot of the given mediaType, scoped to a specific codec MIME type.
315
+ * Delegates to WCME's markCustomCodecParametersForDeletion API.
316
+ * @param {MediaType} mediaType MediaType of the sendSlot
317
+ * @param {MediaCodecMimeType} codecMimeType The codec MIME type whose parameters should be deleted (e.g. OPUS, H264, AV1)
318
+ * @param {string[]} parameters Array of parameter keys to delete
319
+ * @returns {Promise<void>}
320
+ */
321
+ public async markCustomCodecParametersForDeletion(
322
+ mediaType: MediaType,
323
+ codecMimeType: MediaCodecMimeType,
324
+ parameters: string[]
325
+ ): Promise<void> {
326
+ const slot = this.slots.get(mediaType);
327
+
328
+ if (!slot) {
329
+ throw new Error(`Slot for ${mediaType} does not exist`);
330
+ }
331
+
332
+ await slot.markCustomCodecParametersForDeletion(codecMimeType, parameters);
333
+
249
334
  this.LoggerProxy.logger.info(
250
- `SendSlotsManager->deleteCodecParameters#Deleted the following codec parameters -> ${parameters} for ${mediaType}`
335
+ `SendSlotsManager->markCustomCodecParametersForDeletion#Marked codec parameters for deletion -> ${parameters} for ${mediaType} (codec: ${codecMimeType})`
336
+ );
337
+
338
+ Metrics.sendBehavioralMetric(
339
+ BEHAVIORAL_METRICS.MARK_CUSTOM_CODEC_PARAMETERS_FOR_DELETION_USED,
340
+ {
341
+ mediaType,
342
+ codecMimeType,
343
+ parameters,
344
+ }
251
345
  );
252
346
  }
253
347
 
@@ -131,6 +131,12 @@ const Webinar = WebexPlugin.extend({
131
131
  * @returns {Promise<void>}
132
132
  */
133
133
  async cleanupPSDataChannel() {
134
+ if (this._pendingOnlineListener) {
135
+ // @ts-ignore - Fix type
136
+ this.webex.internal.llm.off('online', this._pendingOnlineListener);
137
+ this._pendingOnlineListener = null;
138
+ }
139
+
134
140
  const meeting = this.webex.meetings.getMeetingByType(_ID_, this.meetingId);
135
141
 
136
142
  // @ts-ignore - Fix type
@@ -148,12 +154,63 @@ const Webinar = WebexPlugin.extend({
148
154
  );
149
155
  },
150
156
 
157
+ /**
158
+ * Ensures practice-session token exists before registering the practice LLM channel.
159
+ * @param {object} meeting
160
+ * @returns {Promise<string|undefined>}
161
+ */
162
+ async ensurePracticeSessionDatachannelToken(meeting) {
163
+ // @ts-ignore
164
+ const isDataChannelTokenEnabled = await this.webex.internal.llm.isDataChannelTokenEnabled();
165
+
166
+ if (!isDataChannelTokenEnabled) {
167
+ return undefined;
168
+ }
169
+
170
+ // @ts-ignore
171
+ const cachedToken = this.webex.internal.llm.getDatachannelToken(
172
+ DataChannelTokenType.PracticeSession
173
+ );
174
+
175
+ if (cachedToken) {
176
+ return cachedToken;
177
+ }
178
+
179
+ try {
180
+ const refreshResponse = await meeting.refreshDataChannelToken();
181
+ const {datachannelToken, dataChannelTokenType} = refreshResponse?.body ?? {};
182
+
183
+ if (!datachannelToken) {
184
+ return undefined;
185
+ }
186
+
187
+ // @ts-ignore
188
+ this.webex.internal.llm.setDatachannelToken(
189
+ datachannelToken,
190
+ dataChannelTokenType || DataChannelTokenType.PracticeSession
191
+ );
192
+
193
+ return datachannelToken;
194
+ } catch (error) {
195
+ LoggerProxy.logger.warn(
196
+ `Webinar:index#ensurePracticeSessionDatachannelToken --> failed to proactively refresh practice-session token: ${
197
+ error?.message || String(error)
198
+ }`
199
+ );
200
+
201
+ return undefined;
202
+ }
203
+ },
204
+
151
205
  /**
152
206
  * Connects to low latency mercury and reconnects if the address has changed
153
207
  * It will also disconnect if called when the meeting has ended
154
208
  * @returns {Promise}
155
209
  */
156
210
  async updatePSDataChannel() {
211
+ this._updatePSDataChannelSequence = (this._updatePSDataChannelSequence || 0) + 1;
212
+ const invocationSequence = this._updatePSDataChannelSequence;
213
+
157
214
  const meeting = this.webex.meetings.getMeetingByType(_ID_, this.meetingId);
158
215
  const isPracticeSession = meeting?.isJoined() && this.isJoinPracticeSessionDataChannel();
159
216
 
@@ -164,29 +221,16 @@ const Webinar = WebexPlugin.extend({
164
221
  }
165
222
 
166
223
  // @ts-ignore - Fix type
167
- const {
168
- url = undefined,
169
- info: {practiceSessionDatachannelUrl = undefined} = {},
170
- self: {practiceSessionDatachannelToken = undefined} = {},
171
- } = meeting?.locusInfo || {};
224
+ const {url = undefined, info: {practiceSessionDatachannelUrl = undefined} = {}} =
225
+ meeting?.locusInfo || {};
172
226
 
173
227
  // @ts-ignore
174
- const currentToken = this.webex.internal.llm.getDatachannelToken(
228
+ let practiceSessionDatachannelToken = this.webex.internal.llm.getDatachannelToken(
175
229
  DataChannelTokenType.PracticeSession
176
230
  );
177
231
 
178
- const finalToken = currentToken ?? practiceSessionDatachannelToken;
179
-
180
232
  const isCaptionBoxOn = this.webex.internal.voicea.getIsCaptionBoxOn();
181
233
 
182
- if (!currentToken && practiceSessionDatachannelToken) {
183
- // @ts-ignore
184
- this.webex.internal.llm.setDatachannelToken(
185
- practiceSessionDatachannelToken,
186
- DataChannelTokenType.PracticeSession
187
- );
188
- }
189
-
190
234
  if (!practiceSessionDatachannelUrl) {
191
235
  return undefined;
192
236
  }
@@ -205,9 +249,68 @@ const Webinar = WebexPlugin.extend({
205
249
  await this.cleanupPSDataChannel();
206
250
  }
207
251
 
252
+ // Ensure the default session data channel is connected before connecting the practice session.
253
+ // Subscribe before checking isConnected() to avoid a race where the 'online' event fires
254
+ // between the check and the subscription — Mercury does not replay missed events.
255
+ if (!this._pendingOnlineListener) {
256
+ const onDefaultSessionConnected = () => {
257
+ this._pendingOnlineListener = null;
258
+ // @ts-ignore - Fix type
259
+ this.webex.internal.llm.off('online', onDefaultSessionConnected);
260
+ this.updatePSDataChannel();
261
+ };
262
+ this._pendingOnlineListener = onDefaultSessionConnected;
263
+ // @ts-ignore - Fix type
264
+ this.webex.internal.llm.on('online', onDefaultSessionConnected);
265
+ }
266
+
267
+ // @ts-ignore - Fix type
268
+ if (!this.webex.internal.llm.isConnected()) {
269
+ LoggerProxy.logger.info(
270
+ 'Webinar:index#updatePSDataChannel --> default session not yet connected, deferring practice session connect.'
271
+ );
272
+
273
+ return undefined;
274
+ }
275
+
276
+ // Default session is already connected — cancel the pending listener and proceed
277
+ if (this._pendingOnlineListener) {
278
+ // @ts-ignore - Fix type
279
+ this.webex.internal.llm.off('online', this._pendingOnlineListener);
280
+ this._pendingOnlineListener = null;
281
+ }
282
+
283
+ const refreshedPracticeSessionToken = await this.ensurePracticeSessionDatachannelToken(meeting);
284
+
285
+ const latestPracticeSessionDatachannelUrl = get(
286
+ meeting,
287
+ 'locusInfo.info.practiceSessionDatachannelUrl'
288
+ );
289
+ const isStillPracticeSession = meeting?.isJoined() && this.isJoinPracticeSessionDataChannel();
290
+
291
+ // Skip stale invocations after async refresh to avoid reconnecting a session
292
+ // that was already updated/cleaned by a newer state transition.
293
+ if (
294
+ invocationSequence !== this._updatePSDataChannelSequence ||
295
+ !isStillPracticeSession ||
296
+ !latestPracticeSessionDatachannelUrl ||
297
+ latestPracticeSessionDatachannelUrl !== practiceSessionDatachannelUrl
298
+ ) {
299
+ return undefined;
300
+ }
301
+
302
+ if (refreshedPracticeSessionToken) {
303
+ practiceSessionDatachannelToken = refreshedPracticeSessionToken;
304
+ }
305
+
208
306
  // @ts-ignore - Fix type
209
307
  return this.webex.internal.llm
210
- .registerAndConnect(url, practiceSessionDatachannelUrl, finalToken, LLM_PRACTICE_SESSION)
308
+ .registerAndConnect(
309
+ url,
310
+ practiceSessionDatachannelUrl,
311
+ practiceSessionDatachannelToken,
312
+ LLM_PRACTICE_SESSION
313
+ )
211
314
  .then((registerAndConnectResult) => {
212
315
  // @ts-ignore - Fix type
213
316
  this.webex.internal.llm.off(
@@ -366,7 +469,6 @@ const Webinar = WebexPlugin.extend({
366
469
 
367
470
  /**
368
471
  * view all webcast attendees
369
- * @param {string} queryString
370
472
  * @returns {Promise}
371
473
  */
372
474
  async viewAllWebcastAttendees() {