diff --git a/packages/flat-i18n/locales/en.json b/packages/flat-i18n/locales/en.json index 15736cc1721..58261bca51b 100644 --- a/packages/flat-i18n/locales/en.json +++ b/packages/flat-i18n/locales/en.json @@ -488,6 +488,7 @@ "user-guide-button": "Check it out now", "start-recording": "Start recording", "stop-recording": "Stop recording", + "recording-end": "Recording ended", "recording": "Recording", "open-in-browser": "Open in Browser", "room-started": "Started: ", diff --git a/packages/flat-i18n/locales/zh-CN.json b/packages/flat-i18n/locales/zh-CN.json index 3c78e51b6a6..a5d475857d4 100644 --- a/packages/flat-i18n/locales/zh-CN.json +++ b/packages/flat-i18n/locales/zh-CN.json @@ -488,6 +488,7 @@ "user-guide-button": "立即查看", "start-recording": "开始录制", "stop-recording": "停止录制", + "recording-end": "录制已停止", "recording": "录制", "open-in-browser": "请在浏览器中打开", "room-started": "已上课:", diff --git a/packages/flat-services/src/services/recording/recording.ts b/packages/flat-services/src/services/recording/recording.ts index 93e0de4086e..b7b02ada647 100644 --- a/packages/flat-services/src/services/recording/recording.ts +++ b/packages/flat-services/src/services/recording/recording.ts @@ -33,6 +33,9 @@ export abstract class IServiceRecording implements IService { /** Use with try-catch. */ public abstract startRecording(): Promise; + /** Refresh `isRecording` state. */ + public abstract checkIsRecording(): Promise; + /** Use with try-catch. */ public abstract stopRecording(): Promise; diff --git a/packages/flat-stores/src/classroom-store/index.ts b/packages/flat-stores/src/classroom-store/index.ts index 1b9655dd874..f236e01089c 100644 --- a/packages/flat-stores/src/classroom-store/index.ts +++ b/packages/flat-stores/src/classroom-store/index.ts @@ -68,6 +68,9 @@ export class ClassroomStore { private readonly sideEffect = new SideEffectManager(); private readonly rewardCooldown = new Map(); + // If it is `true`, the stop-recording is triggered by the user, do not show the message. + private recordingEndSentinel = false; + public readonly roomUUID: string; public readonly ownerUUID: string; /** User uuid of the current user */ @@ -181,11 +184,12 @@ export class ClassroomStore { onDrop: this.onDrop, }); - makeAutoObservable(this, { + makeAutoObservable(this, { rtc: observable.ref, rtm: observable.ref, sideEffect: false, rewardCooldown: false, + recordingEndSentinel: false, deviceStateStorage: false, classroomStorage: false, onStageUsersStorage: false, @@ -206,6 +210,8 @@ export class ClassroomStore { (isRecording: boolean) => { if (isRecording) { void message.success(FlatI18n.t("start-recording")); + } else if (!this.recordingEndSentinel) { + void message.info(FlatI18n.t("recording-end")); } }, ), @@ -777,6 +783,29 @@ export class ClassroomStore { user.camera = false; } }); + // When there's no active stream in the channel, the recording service + // stops automatically after `maxIdleTime`. + if (this.isRecording) { + let hasStream = false; + for (const userUUID in deviceStateStorage.state) { + const deviceState = deviceStateStorage.state[userUUID]; + if (deviceState.camera || deviceState.mic) { + hasStream = true; + break; + } + } + if (!hasStream) { + this.sideEffect.setTimeout( + () => { + if (this.isRecording) { + this.recording.checkIsRecording().catch(console.warn); + } + }, + // Roughly 5 minutes later, see cloud-recording.ts. + 5 * 61 * 1000, + ); + } + } }), ); @@ -1430,7 +1459,9 @@ export class ClassroomStore { } private async stopRecording(): Promise { + this.recordingEndSentinel = true; await this.recording.stopRecording(); + this.recordingEndSentinel = false; } private async initRTC(): Promise { diff --git a/service-providers/agora-cloud-recording/src/cloud-recording.ts b/service-providers/agora-cloud-recording/src/cloud-recording.ts index 8454530162c..d76465eccfd 100644 --- a/service-providers/agora-cloud-recording/src/cloud-recording.ts +++ b/service-providers/agora-cloud-recording/src/cloud-recording.ts @@ -21,6 +21,7 @@ export type AgoraCloudRecordingRoomInfo = IServiceRecordingJoinRoomConfig; // TODO: We should save the recording state at the server side. export class AgoraCloudRecording extends IServiceRecording { private static readonly ReportingEndTimeKey = "reportingEndTime"; + private static readonly LoopQueryKey = "loopQuery"; private roomInfo: AgoraCloudRecordingRoomInfo | null; private recordingState: AgoraCloudRecordingState | null; @@ -132,6 +133,7 @@ export class AgoraCloudRecording extends IServiceRecording { const { roomID } = this.roomInfo; const { resourceId, sid, mode } = this.recordingState; + this.sideEffect.flush(AgoraCloudRecording.LoopQueryKey); this.sideEffect.flush(AgoraCloudRecording.ReportingEndTimeKey); this.recordingState = null; @@ -170,6 +172,10 @@ export class AgoraCloudRecording extends IServiceRecording { }); } + public async checkIsRecording(): Promise { + await this.queryRecordingStatus(); + } + private async queryRecordingStatus(joinRoom = false): Promise { if (this.recordingState === null || this.roomID === null) { this.$Val.isRecording$.setValue(false); @@ -212,12 +218,25 @@ export class AgoraCloudRecording extends IServiceRecording { 10 * 1000, AgoraCloudRecording.ReportingEndTimeKey, ); + // Loop query status in each 2 minutes. + // https://doc.shengwang.cn/doc/cloud-recording/restful/best-practices/integration#%E5%91%A8%E6%9C%9F%E6%80%A7%E9%A2%91%E9%81%93%E6%9F%A5%E8%AF%A2 + this.sideEffect.setInterval( + () => { + if (this.isRecording) { + this.queryRecordingStatus(); + } else { + this.sideEffect.flush(AgoraCloudRecording.LoopQueryKey); + } + }, + 120 * 1000, + AgoraCloudRecording.LoopQueryKey, + ); } } /** * @see {@link https://docs.agora.io/en/cloud-recording/reference/rest-api/rest} - * @see {@link https://docs.agora.io/cn/cloud-recording/cloud_recording_api_rest} + * @see {@link https://doc.shengwang.cn/doc/cloud-recording/restful/landing-page} */ export type AgoraCloudRecordingMode = "individual" | "mix";