Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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
157 changes: 156 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,7 @@ export default class Meeting extends StatelessWebexPlugin {
recording: any;
remoteMediaManager: RemoteMediaManager | null;
recordingController: RecordingController;
pendingRecordingHydrationEvent?: string;
controlsOptionsManager: ControlsOptionsManager;
requiredCaptcha: any;
receiveSlotManager: ReceiveSlotManager;
Expand Down Expand Up @@ -2809,6 +2810,84 @@ 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 re-emits the supplied event so the consumer can
* hydrate its timer with the correct elapsed time (especially on rejoin
* while the meeting is paused).
*
* @param {string} event - the recording event that was just fired
* @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;

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

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

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

// Guard against a race: if the recording was stopped while the request
// was in flight, do not resurrect stale duration metadata onto the now
// IDLE state — that would re-trigger the timer on the next start.
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,
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 +2909,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 +2939,53 @@ 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 👍 / 👎.


// `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 +2996,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 +3684,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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,60 @@ describe('plugin-meetings', () => {
assert.isUndefined(parsedControls.record.modifiedByServiceAppId);
});

it('should passthrough recording duration metadata from record.meta.duration', () => {
const newControls = {
record: {
recording: true,
paused: false,
meta: {
duration: {
lastDuration: 12345,
lastTime: '2026-06-03T10:00:00Z',
needCalculate: true,
},
},
},
};

const parsedControls = ControlsUtils.parse(newControls);

assert.equal(parsedControls.record.lastDuration, 12345);
assert.equal(parsedControls.record.lastTime, '2026-06-03T10:00:00Z');
assert.equal(parsedControls.record.needCalculate, true);
});

it('should fall back to record.duration when record.meta.duration is absent', () => {
const newControls = {
record: {
recording: false,
paused: true,
duration: {
lastDuration: 7777,
lastTime: '2026-06-03T11:00:00Z',
needCalculate: false,
},
},
};

const parsedControls = ControlsUtils.parse(newControls);

assert.equal(parsedControls.record.lastDuration, 7777);
assert.equal(parsedControls.record.lastTime, '2026-06-03T11:00:00Z');
assert.equal(parsedControls.record.needCalculate, false);
});

it('should leave duration fields undefined when neither source is present', () => {
const newControls = {
record: {recording: true, paused: false, meta: {lastModified: '2026-01-01'}},
};

const parsedControls = ControlsUtils.parse(newControls);

assert.isUndefined(parsedControls.record.lastDuration);
assert.isUndefined(parsedControls.record.lastTime);
assert.isUndefined(parsedControls.record.needCalculate);
});

describe('videoEnabled', () => {
it('returns expected', () => {
const result = ControlsUtils.parse({video: {enabled: true}});
Expand Down
Loading
Loading