Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@webex/plugin-meetings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,7 @@ meeting.on(...)
| `meeting:recording:stopped` | Fired when member stops recording |
| `meeting:recording:paused` | Fired when member pauses recording |
| `meeting:recording:resumed` | Fired when member resumes recording |
| `meeting:recording:durationUpdated` | Fired after the SDK hydrates the recording duration from the recording stream service (e.g. on rejoin). Distinct from the transition events above so consumers can update timer state without re-running side effects tied to start/pause/resume. |
| `meeting:receiveTranscription:started` | Fired when transcription is received |
| `meeting:receiveTranscription:stopped` | Fired when transcription has stopped from being received |
| `meeting:meetingContainer:update` | Fired when the meetingContainerUrl is updated |
Expand Down
1 change: 1 addition & 0 deletions packages/@webex/plugin-meetings/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ export const EVENT_TRIGGERS = {
MEETING_RECEIVE_REACTIONS: 'meeting:receiveReactions',
MEETING_PAUSED_RECORDING: 'meeting:recording:paused',
MEETING_RESUMED_RECORDING: 'meeting:recording:resumed',
MEETING_RECORDING_DURATION_UPDATED: 'meeting:recording:durationUpdated',
MEETING_ADDED: 'meeting:added',
MEETING_REMOVED: 'meeting:removed',
MEETING_RINGING: 'meeting:ringing',
Expand Down
14 changes: 14 additions & 0 deletions packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,27 @@ ControlsUtils.parse = (controls: any) => {
const parsedControls = {...controls};

if (controls && controls.record) {
// Passthrough any duration fields Locus may send so consumers can compute
// accumulated recording time on rejoin (parity with native's
// RecordingStreamSession::RecordingTime). Falls back to undefined when the
// server does not include them on this payload.
const rawDuration = controls.record.meta?.duration ?? controls.record.duration;

parsedControls.record = {
modifiedBy: ControlsUtils.getId(controls),
paused: controls.record.paused ? controls.record.paused : false,
recording: controls.record.recording,
lastModified: controls.record.meta?.lastModified,
modifiedByServiceAppName: controls.record.meta?.modifiedByServiceAppName,
modifiedByServiceAppId: controls.record.meta?.modifiedByServiceAppId,
// Duration metadata (parity with native iOS/Android). All optional.
// - lastDuration: accumulated recorded ms before the current segment
// - lastTime: server timestamp when the current segment/state started
// - needCalculate: true while RECORDING (UI must add now - lastTime),
// false while PAUSED (UI shows lastDuration as-is)
lastDuration: rawDuration?.lastDuration,
lastTime: rawDuration?.lastTime,
needCalculate: rawDuration?.needCalculate,
};
}

Expand Down
5 changes: 5 additions & 0 deletions packages/@webex/plugin-meetings/src/locus-info/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2100,6 +2100,11 @@ export default class LocusInfo extends EventsScope {
lastModified: current.record.lastModified,
modifiedByServiceAppName: current.record.modifiedByServiceAppName,
modifiedByServiceAppId: current.record.modifiedByServiceAppId,
// Duration metadata (parity with native). All optional — present
// only when Locus includes them in `controls.record.meta.duration`.
lastDuration: current.record.lastDuration,
lastTime: current.record.lastTime,
needCalculate: current.record.needCalculate,
}
);
}
Expand Down
186 changes: 185 additions & 1 deletion packages/@webex/plugin-meetings/src/meeting/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,13 @@ export default class Meeting extends StatelessWebexPlugin {
recording: any;
remoteMediaManager: RemoteMediaManager | null;
recordingController: RecordingController;
pendingRecordingHydrationEvent?: string;
// Monotonic counter bumped on every recording state transition. Used to
// discard stale `getRecordingStatus` responses that resolve after the
// recording has been stopped (and possibly restarted) — preventing the
// previous segment's `lastDuration`/`lastTime` from being merged onto
// the new RECORDING state.
private recordingHydrationEpoch = 0;
controlsOptionsManager: ControlsOptionsManager;
requiredCaptcha: any;
receiveSlotManager: ReceiveSlotManager;
Expand Down Expand Up @@ -2809,6 +2816,102 @@ export default class Meeting extends StatelessWebexPlugin {
});
}

/**
* Locus deltas do not include the accumulated recording duration. Native
* clients fetch it from the recording stream service
* (`GET {serviceUrl}/loci/{locusId}/resource`). This helper performs the
* same fetch and, on success, merges the duration metadata into
* `this.recording` and emits a dedicated `meeting:recording:durationUpdated`
* event so the consumer can hydrate its timer with the correct elapsed
* time (especially on rejoin while the meeting is paused). The original
* transition event (started / paused / resumed) is intentionally NOT
* replayed — it has already fired from `setupLocusControlsListener` and
* replaying it would cause notification / analytics consumers to process
* the same transition twice.
*
* @param {string} event - the recording transition event that triggered
* this hydration (used only for logging)
* @returns {Promise<void>}
* @private
* @memberof Meeting
*/
private async hydrateRecordingDuration(event: string): Promise<void> {
if (!this.recordingController) {
return;
}

// The recording stream service URL is set asynchronously via the
// LINKS_SERVICES locus event. On initial join into an already RECORDING
// / PAUSED meeting, the controls update can fire before that. In that
// case, defer the hydration: it will be retried from
// `setUpLocusServicesListener` once the URL is available.
if (!this.recordingController.getServiceUrl() || !this.recordingController.getLocusId()) {
Comment thread
junhao3268 marked this conversation as resolved.
LoggerProxy.logger.debug(
`Meeting:index#hydrateRecordingDuration --> deferred (event=${event}); serviceUrl/locusId not yet available`
);
this.pendingRecordingHydrationEvent = event;

return;
}

this.pendingRecordingHydrationEvent = undefined;

// Capture the current epoch before issuing the request. If the recording
// state changes (e.g. stop, or stop -> start of a new segment) before the
// response arrives, the epoch bumps and we drop the response so we don't
// apply the previous segment's duration onto the new RECORDING state.
const epochAtRequest = this.recordingHydrationEpoch;

try {
const duration = await this.recordingController.getRecordingStatus();

LoggerProxy.logger.debug(
`Meeting:index#hydrateRecordingDuration --> event=${event} duration=${JSON.stringify(
duration
)}`
);

if (this.recordingHydrationEpoch !== epochAtRequest) {
LoggerProxy.logger.debug(
`Meeting:index#hydrateRecordingDuration --> dropping stale response (event=${event}); epoch changed`
);

return;
}

if (!duration || (duration.lastDuration === undefined && duration.lastTime === undefined)) {
return;
}

// Belt-and-suspenders: even if the epoch is unchanged, never resurrect
// duration metadata onto an IDLE state.
if (this.recording?.state === RECORDING_STATE.IDLE) {
return;
}

this.recording = {
...this.recording,
lastDuration: duration.lastDuration,
lastTime: duration.lastTime,
needCalculate: duration.needCalculate,
Comment on lines +2912 to +2916

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the hydrated recording status before merging

When the recording resource service lags behind a Locus transition, this merges whatever duration the GET returns into the current this.recording without checking duration.status. For example, immediately after pause/resume or a quick stop/start, the request issued for the new state can still return the previous resource status and its lastDuration/needCalculate; because the epoch only proves no newer Locus control event arrived after this request started, stale service data can still seed the current timer with the wrong baseline. Please discard or retry responses whose status does not match the current recording state before applying these fields.

Useful? React with 👍 / 👎.

};
Comment thread
junhao3268 marked this conversation as resolved.

Trigger.trigger(
this,
{
file: 'meeting/index',
function: 'hydrateRecordingDuration',
},
EVENT_TRIGGERS.MEETING_RECORDING_DURATION_UPDATED,
this.recording
Comment thread
junhao3268 marked this conversation as resolved.
);
} catch (error) {
LoggerProxy.logger.warn(
`Meeting:index#hydrateRecordingDuration --> failed to fetch duration: ${error}`
);
}
}

/**
* Set up the locus info recording update listener
* update recording value for the meeting
Expand All @@ -2830,7 +2933,16 @@ export default class Meeting extends StatelessWebexPlugin {
private setupLocusControlsListener() {
this.locusInfo.on(
LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
({state, modifiedBy, lastModified, modifiedByServiceAppName, modifiedByServiceAppId}) => {
({
state,
modifiedBy,
lastModified,
modifiedByServiceAppName,
modifiedByServiceAppId,
lastDuration,
lastTime,
needCalculate,
}) => {
let event;

switch (state) {
Expand All @@ -2851,13 +2963,58 @@ export default class Meeting extends StatelessWebexPlugin {
break;
}

// Locus deltas do NOT carry duration data. To avoid wiping the values
// we previously hydrated from the recording stream service (which
// would cause the UI to briefly flash 00:00:00 on every state change
// such as PAUSED -> RECORDING), preserve any prior duration metadata
// until hydrate runs again with fresh server data. EXCEPT when we
// transition into IDLE (recording fully stopped) — in that case the
// previous segment's duration is no longer meaningful, and keeping it
// around would cause the next START to briefly show the stale value.
const previousRecording = this.recording || {};
const isStopping = state === RECORDING_STATE.IDLE;
const pickDurationField = <T>(
incoming: T | undefined,
previous: T | undefined
): T | undefined => {
if (isStopping) {
return undefined;
}
if (incoming !== undefined) {
return incoming;
}

return previous;
};
const resolvedLastDuration = pickDurationField(
lastDuration,
previousRecording.lastDuration
);
const resolvedLastTime = pickDurationField(lastTime, previousRecording.lastTime);
const resolvedNeedCalculate = pickDurationField(
needCalculate,
previousRecording.needCalculate
);
Comment on lines +3014 to +3017

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not preserve needCalculate across state changes

When Locus omits duration fields on a pause/resume delta and the hydration request is delayed, unavailable, or fails, this keeps the previous needCalculate value even though the recording state has just changed. For example, a pause after recording emits meeting:recording:paused with needCalculate: true, so consumers using the new duration payload continue adding now - lastTime while paused; a resume after a hydrated pause can similarly emit needCalculate: false and freeze the timer until hydration succeeds. Infer this flag from the new state or leave it unset until fresh duration data arrives instead of carrying it across state transitions.

Useful? React with 👍 / 👎.


// Bump the hydration epoch on every recording state update so that
// any in-flight `getRecordingStatus` request from the previous state
// is discarded when it resolves.
this.recordingHydrationEpoch += 1;

// `RESUMED` state should be converted to `RECORDING` after triggering the event
this.recording = {
state: state === RECORDING_STATE.RESUMED ? RECORDING_STATE.RECORDING : state,
modifiedBy,
lastModified,
modifiedByServiceAppName,
modifiedByServiceAppId,
// Duration metadata for timer hydration on rejoin. Optional —
// present only when Locus includes `controls.record.meta.duration`,
// which today it never does. We therefore fall back to whatever we
// already had so the UI does not lose the timer baseline.
lastDuration: resolvedLastDuration,
lastTime: resolvedLastTime,
needCalculate: resolvedNeedCalculate,
};
Trigger.trigger(
this,
Expand All @@ -2868,6 +3025,23 @@ export default class Meeting extends StatelessWebexPlugin {
event,
this.recording
);

// Locus deltas do not carry the accumulated recording duration.
// When the meeting transitions into RECORDING or PAUSED (which also
// covers the case where we join a meeting that is already in one of
// those states), fetch the duration metadata from the recording
// stream service to mirror what native clients do. Re-emit the same
// event with the hydrated values so the UI can show the correct
// elapsed time instead of `00:00:00`.
if (
event &&
(state === RECORDING_STATE.RECORDING ||
state === RECORDING_STATE.RESUMED ||
state === RECORDING_STATE.PAUSED) &&
(lastDuration === undefined || lastTime === undefined)
) {
this.hydrateRecordingDuration(event);
}
}
);

Expand Down Expand Up @@ -3539,6 +3713,16 @@ export default class Meeting extends StatelessWebexPlugin {
this.annotation.approvalUrlUpdate(payload?.services?.approval?.url);
this.simultaneousInterpretation.approvalUrlUpdate(payload?.services?.approval?.url);
this.aiEnableRequest.approvalUrlUpdate(payload?.services?.approval?.url);

// If a recording event fired before the recording stream service URL
// was known (e.g. joining an already RECORDING/PAUSED meeting), retry
// the deferred hydration now that the URL is available.
if (this.pendingRecordingHydrationEvent) {
const event = this.pendingRecordingHydrationEvent;

this.pendingRecordingHydrationEvent = undefined;
this.hydrateRecordingDuration(event);
}
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,4 +341,63 @@ export default class RecordingController {
public resumeRecording(): Promise<any> {
return this.recordingFacade(RecordingAction.Resume);
}

/**
* Fetches the current recording resource from the recording stream service.
* The response carries the accumulated recording duration metadata that the
* native clients (iOS / Android / Desktop) use to hydrate the timer when
* (re)joining a meeting that is already RECORDING or PAUSED. Locus deltas
* do not include this data, so this REST call is the only source.
*
* Endpoint mirrors native:
* GET {serviceUrl}/loci/{locusId}/resource
*
* @returns {Promise<object | undefined>} duration metadata or undefined
* @public
* @memberof RecordingController
*/
public async getRecordingStatus(): Promise<
| {
lastDuration?: number;
lastTime?: string;
needCalculate?: boolean;
status?: string;
}
| undefined
> {
if (!this.serviceUrl || !this.locusId) {
LoggerProxy.logger.info(
'RecordingController:index#getRecordingStatus --> skipped, missing serviceUrl or locusId'
);

return undefined;
}

try {
// @ts-ignore
const response = await this.request.request({
uri: `${this.serviceUrl}/loci/${this.locusId}/resource`,
method: HTTP_VERBS.GET,
});

// Response shape (matches native parseResource):
// { recording: [ { status, duration: { lastDuration, lastTime, needCalculate }, ... } ] }
const body = response?.body ?? response;
const entry = Array.isArray(body?.recording) ? body.recording[0] : undefined;
const duration = entry?.duration;

return {
status: entry?.status,
lastDuration: duration?.lastDuration,
lastTime: duration?.lastTime,
needCalculate: duration?.needCalculate,
};
} catch (error) {
LoggerProxy.logger.warn(
`RecordingController:index#getRecordingStatus --> request failed: ${error}`
);

return undefined;
}
}
}
Loading
Loading