From c3cd0cc2eba14063144733f768282b7ad6319ba8 Mon Sep 17 00:00:00 2001 From: hyrious Date: Tue, 30 Aug 2022 09:19:02 +0800 Subject: [PATCH] feat(service-providers): add agora cloud recording --- desktop/renderer-app/package.json | 1 + .../src/tasks/init-flat-services.ts | 7 + .../TopBar/TopBarRightBtn/style.less | 13 +- .../src/utils/use-classroom-store.ts | 12 +- packages/flat-services/src/flat-services.ts | 2 + packages/flat-services/src/index.ts | 1 + .../src/services/recording/README.md | 3 + .../src/services/recording/events.ts | 7 + .../src/services/recording}/index.ts | 1 - .../src/services/recording/recording.ts | 43 +++ .../src/services/whiteboard/whiteboard.ts | 2 + .../flat-stores/src/classroom-store/index.ts | 24 +- pnpm-lock.yaml | 178 +++---------- .../.eslintignore | 0 .../agora-cloud-recording/README.md | 3 + .../agora-cloud-recording/package.json | 21 ++ .../src/cloud-recording.ts | 250 ++++++++++++++++++ .../agora-cloud-recording/src/index.ts | 1 + .../tsconfig.json | 3 +- .../flat-rtc-recording-agora/package.json | 18 -- .../flat-rtc-recording-agora/src/index.ts | 17 -- .../flat-rtc-recording-agora/tsconfig.json | 12 - .../flat-rtc-recording/.eslintignore | 4 - .../flat-rtc-recording/package.json | 18 -- .../flat-rtc-recording/src/constants.ts | 4 - .../flat-rtc-recording/src/recording.ts | 13 - web/flat-web/package.json | 1 + web/flat-web/src/tasks/init-flat-services.ts | 7 + 28 files changed, 427 insertions(+), 239 deletions(-) create mode 100644 packages/flat-services/src/services/recording/README.md create mode 100644 packages/flat-services/src/services/recording/events.ts rename {service-providers/rtc-recording/flat-rtc-recording/src => packages/flat-services/src/services/recording}/index.ts (50%) create mode 100644 packages/flat-services/src/services/recording/recording.ts rename service-providers/{rtc-recording/flat-rtc-recording-agora => agora-cloud-recording}/.eslintignore (100%) create mode 100644 service-providers/agora-cloud-recording/README.md create mode 100644 service-providers/agora-cloud-recording/package.json create mode 100644 service-providers/agora-cloud-recording/src/cloud-recording.ts create mode 100644 service-providers/agora-cloud-recording/src/index.ts rename service-providers/{rtc-recording/flat-rtc-recording => agora-cloud-recording}/tsconfig.json (73%) delete mode 100644 service-providers/rtc-recording/flat-rtc-recording-agora/package.json delete mode 100644 service-providers/rtc-recording/flat-rtc-recording-agora/src/index.ts delete mode 100644 service-providers/rtc-recording/flat-rtc-recording-agora/tsconfig.json delete mode 100644 service-providers/rtc-recording/flat-rtc-recording/.eslintignore delete mode 100644 service-providers/rtc-recording/flat-rtc-recording/package.json delete mode 100644 service-providers/rtc-recording/flat-rtc-recording/src/constants.ts delete mode 100644 service-providers/rtc-recording/flat-rtc-recording/src/recording.ts diff --git a/desktop/renderer-app/package.json b/desktop/renderer-app/package.json index 5a9063599c5..8a5ef9cf370 100644 --- a/desktop/renderer-app/package.json +++ b/desktop/renderer-app/package.json @@ -24,6 +24,7 @@ "@netless/flat-i18n": "workspace:*", "@netless/flat-pages": "workspace:*", "@netless/flat-server-api": "workspace:*", + "@netless/flat-service-provider-agora-cloud-recording": "workspace:*", "@netless/flat-service-provider-agora-rtc-electron": "workspace:*", "@netless/flat-service-provider-agora-rtm": "workspace:*", "@netless/flat-service-provider-fastboard": "workspace:*", diff --git a/desktop/renderer-app/src/tasks/init-flat-services.ts b/desktop/renderer-app/src/tasks/init-flat-services.ts index 91da11a2c49..4621838e657 100644 --- a/desktop/renderer-app/src/tasks/init-flat-services.ts +++ b/desktop/renderer-app/src/tasks/init-flat-services.ts @@ -201,6 +201,13 @@ export function initFlatServices(): void { return service; }); + flatServices.register("recording", async () => { + const { AgoraCloudRecording } = await import( + "@netless/flat-service-provider-agora-cloud-recording" + ); + return new AgoraCloudRecording(); + }); + flatServices.register( [ "file-convert:doc", diff --git a/packages/flat-components/src/components/ClassroomPage/TopBar/TopBarRightBtn/style.less b/packages/flat-components/src/components/ClassroomPage/TopBar/TopBarRightBtn/style.less index 6b761c422cf..6a47ed04fdc 100644 --- a/packages/flat-components/src/components/ClassroomPage/TopBar/TopBarRightBtn/style.less +++ b/packages/flat-components/src/components/ClassroomPage/TopBar/TopBarRightBtn/style.less @@ -11,13 +11,7 @@ display: flex; justify-content: center; align-items: center; - margin: 0 - ( - ( - @topbar-right-btn-gap - - (@topbar-right-btn-width - @topbar-icon-width) - ) / 2 - ); + margin: 0 ((@topbar-right-btn-gap - (@topbar-right-btn-width - @topbar-icon-width)) / 2); padding: 4px; cursor: pointer; background-color: white; @@ -28,6 +22,11 @@ &:hover { background-color: var(--blue-1); } + + &:disabled { + cursor: wait; + opacity: 0.5; + } } .flat-color-scheme-dark { diff --git a/packages/flat-pages/src/utils/use-classroom-store.ts b/packages/flat-pages/src/utils/use-classroom-store.ts index 627f60c38cf..39fb148d252 100644 --- a/packages/flat-pages/src/utils/use-classroom-store.ts +++ b/packages/flat-pages/src/utils/use-classroom-store.ts @@ -4,7 +4,10 @@ import { RouteNameType, usePushHistory } from "../utils/routes"; import { errorTips, useSafePromise } from "flat-components"; import { FlatServices } from "@netless/flat-services"; -export type useClassRoomStoreConfig = Omit; +export type useClassRoomStoreConfig = Omit< + ClassroomStoreConfig, + "rtc" | "rtm" | "whiteboard" | "recording" +>; export function useClassroomStore(config: useClassRoomStoreConfig): ClassroomStore | undefined { const [classroomStore, setClassroomStore] = useState(); @@ -29,14 +32,16 @@ export function useClassroomStore(config: useClassRoomStoreConfig): ClassroomSto flatServices.requestService("videoChat"), flatServices.requestService("textChat"), flatServices.requestService("whiteboard"), + flatServices.requestService("recording"), ]), - ).then(([videoChat, textChat, whiteboard]) => { - if (!isUnmounted && videoChat && textChat && whiteboard) { + ).then(([videoChat, textChat, whiteboard, recording]) => { + if (!isUnmounted && videoChat && textChat && whiteboard && recording) { classroomStore = new ClassroomStore({ ...config, rtc: videoChat, rtm: textChat, whiteboard, + recording, }); setClassroomStore(classroomStore); sp(classroomStore.init()).catch(e => { @@ -56,6 +61,7 @@ export function useClassroomStore(config: useClassRoomStoreConfig): ClassroomSto flatServices.shutdownService("videoChat"); flatServices.shutdownService("textChat"); flatServices.shutdownService("whiteboard"); + flatServices.shutdownService("recording"); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/packages/flat-services/src/flat-services.ts b/packages/flat-services/src/flat-services.ts index 522faffd742..ea8196a07f1 100644 --- a/packages/flat-services/src/flat-services.ts +++ b/packages/flat-services/src/flat-services.ts @@ -1,4 +1,5 @@ import { IServiceFile, IServiceFileCatalog } from "./services/file"; +import { IServiceRecording } from "./services/recording"; import { IServiceTextChat } from "./services/text-chat"; import { IService } from "./services/typing"; import { IServiceVideoChat } from "./services/video-chat"; @@ -9,6 +10,7 @@ export type FlatServicesCatalog = IServiceFileCatalog & { videoChat: IServiceVideoChat; textChat: IServiceTextChat; whiteboard: IServiceWhiteboard; + recording: IServiceRecording; }; export type FlatServiceID = Extract; diff --git a/packages/flat-services/src/index.ts b/packages/flat-services/src/index.ts index 5270f8d945a..fa6743e1b6e 100644 --- a/packages/flat-services/src/index.ts +++ b/packages/flat-services/src/index.ts @@ -3,6 +3,7 @@ export * from "./toaster"; export * from "./services/video-chat"; export * from "./services/text-chat"; export * from "./services/whiteboard"; +export * from "./services/recording"; export * from "./services/file"; export * from "./providers/provider-file"; diff --git a/packages/flat-services/src/services/recording/README.md b/packages/flat-services/src/services/recording/README.md new file mode 100644 index 00000000000..c1c762e58bc --- /dev/null +++ b/packages/flat-services/src/services/recording/README.md @@ -0,0 +1,3 @@ +# recording + +Recording service is responsible for recording video chat and whiteboard. diff --git a/packages/flat-services/src/services/recording/events.ts b/packages/flat-services/src/services/recording/events.ts new file mode 100644 index 00000000000..e89e6240c49 --- /dev/null +++ b/packages/flat-services/src/services/recording/events.ts @@ -0,0 +1,7 @@ +import type { Remitter } from "remitter"; + +export interface IServiceRecordingEventData {} + +export type IServiceRecordingEventName = Extract; + +export type IServiceRecordingEvents = Remitter; diff --git a/service-providers/rtc-recording/flat-rtc-recording/src/index.ts b/packages/flat-services/src/services/recording/index.ts similarity index 50% rename from service-providers/rtc-recording/flat-rtc-recording/src/index.ts rename to packages/flat-services/src/services/recording/index.ts index 9bb5350289f..9b82ad1602f 100644 --- a/service-providers/rtc-recording/flat-rtc-recording/src/index.ts +++ b/packages/flat-services/src/services/recording/index.ts @@ -1,2 +1 @@ -export * from "./constants"; export * from "./recording"; diff --git a/packages/flat-services/src/services/recording/recording.ts b/packages/flat-services/src/services/recording/recording.ts new file mode 100644 index 00000000000..a28c197f2c1 --- /dev/null +++ b/packages/flat-services/src/services/recording/recording.ts @@ -0,0 +1,43 @@ +import type { RoomType } from "@netless/flat-server-api"; +import type { ReadonlyVal } from "value-enhancer"; +import type { IServiceRecordingEvents } from "./events"; + +import { Remitter } from "remitter"; +import { SideEffectManager } from "side-effect-manager"; +import { IService } from "../typing"; + +export interface IServiceRecording$Val { + readonly isRecording$: ReadonlyVal; +} + +export interface IServiceRecordingJoinRoomConfig { + roomID: string; + classroomType: RoomType; +} + +export abstract class IServiceRecording implements IService { + public readonly sideEffect = new SideEffectManager(); + + public readonly events: IServiceRecordingEvents = new Remitter(); + + public abstract readonly $Val: IServiceRecording$Val; + + public abstract readonly roomID: string | null; + + public abstract readonly isRecording: boolean; + + public abstract joinRoom(config: IServiceRecordingJoinRoomConfig): Promise; + + public abstract leaveRoom(): Promise; + + /** Use with try-catch. */ + public abstract startRecording(): Promise; + + /** Use with try-catch. */ + public abstract stopRecording(): Promise; + + public async destroy(): Promise { + this.events.destroy(); + this.sideEffect.flushAll(); + } +} diff --git a/packages/flat-services/src/services/whiteboard/whiteboard.ts b/packages/flat-services/src/services/whiteboard/whiteboard.ts index a80a77d08cf..636dabf522f 100644 --- a/packages/flat-services/src/services/whiteboard/whiteboard.ts +++ b/packages/flat-services/src/services/whiteboard/whiteboard.ts @@ -38,6 +38,8 @@ export abstract class IServiceWhiteboard { public abstract joinRoom(config: IServiceWhiteboardJoinRoomConfig): Promise; + public abstract leaveRoom(): Promise; + public abstract render(el: HTMLElement): void; public abstract setTheme(theme: "light" | "dark"): void; diff --git a/packages/flat-stores/src/classroom-store/index.ts b/packages/flat-stores/src/classroom-store/index.ts index 6d52357c841..772f823abcc 100644 --- a/packages/flat-stores/src/classroom-store/index.ts +++ b/packages/flat-stores/src/classroom-store/index.ts @@ -20,6 +20,7 @@ import { globalStore } from "../global-store"; import { ClassModeType, RoomStatusLoadingType } from "./constants"; import { ChatStore } from "./chat-store"; import { + IServiceRecording, IServiceTextChat, IServiceVideoChat, IServiceVideoChatMode, @@ -37,6 +38,7 @@ export interface ClassroomStoreConfig { rtc: IServiceVideoChat; rtm: IServiceTextChat; whiteboard: IServiceWhiteboard; + recording: IServiceRecording; } export type DeviceStateStorageState = Record; @@ -94,6 +96,7 @@ export class ClassroomStore { public readonly rtm: IServiceTextChat; public readonly chatStore: ChatStore; public readonly whiteboardStore: WhiteboardStore; + public readonly recording: IServiceRecording; public constructor(config: ClassroomStoreConfig) { if (!globalStore.userUUID) { @@ -108,6 +111,8 @@ export class ClassroomStore { this.classMode = ClassModeType.Lecture; this.rtc = config.rtc; this.rtm = config.rtm; + this.recording = config.recording; + this.chatStore = new ChatStore({ roomUUID: this.roomUUID, ownerUUID: this.ownerUUID, @@ -139,6 +144,14 @@ export class ClassroomStore { onStageUsersStorage: false, }); + this.sideEffect.addDisposer( + this.recording.$Val.isRecording$.subscribe(isRecording => { + runInAction(() => { + this.isRecording = isRecording; + }); + }), + ); + this.sideEffect.addDisposer( reaction( () => this.isRecording, @@ -436,6 +449,13 @@ export class ClassroomStore { ); } } + + if (this.isCreator) { + await this.recording.joinRoom({ + roomID: this.roomUUID, + classroomType: this.roomType, + }); + } } public async destroy(): Promise { @@ -669,11 +689,11 @@ export class ClassroomStore { }; private async startRecording(): Promise { - // @TODO add cloud recording + await this.recording.startRecording(); } private async stopRecording(): Promise { - // @TODO add cloud recording + await this.recording.stopRecording(); } private async initRTC(): Promise { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9c4139cdd7..375702bb484 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,7 @@ importers: '@netless/flat-i18n': workspace:* '@netless/flat-pages': workspace:* '@netless/flat-server-api': workspace:* + '@netless/flat-service-provider-agora-cloud-recording': workspace:* '@netless/flat-service-provider-agora-rtc-electron': workspace:* '@netless/flat-service-provider-agora-rtm': workspace:* '@netless/flat-service-provider-fastboard': workspace:* @@ -212,6 +213,7 @@ importers: '@netless/flat-i18n': link:../../packages/flat-i18n '@netless/flat-pages': link:../../packages/flat-pages '@netless/flat-server-api': link:../../packages/flat-server-api + '@netless/flat-service-provider-agora-cloud-recording': link:../../service-providers/agora-cloud-recording '@netless/flat-service-provider-agora-rtc-electron': link:../../service-providers/agora-rtc/agora-rtc-electron '@netless/flat-service-provider-agora-rtm': link:../../service-providers/agora-rtm '@netless/flat-service-provider-fastboard': link:../../service-providers/fastboard @@ -537,6 +539,23 @@ importers: packages/flat-types: specifiers: {} + service-providers/agora-cloud-recording: + specifiers: + '@netless/flat-server-api': workspace:* + '@netless/flat-services': workspace:* + polly-js: ^1.8.3 + prettier: ^2.3.0 + typescript: ^4.7.4 + value-enhancer: ^1.3.2 + dependencies: + '@netless/flat-server-api': link:../../packages/flat-server-api + '@netless/flat-services': link:../../packages/flat-services + polly-js: 1.8.3 + value-enhancer: 1.3.2 + devDependencies: + prettier: 2.6.2 + typescript: 4.7.4 + service-providers/agora-rtc/agora-rtc-electron: specifiers: '@netless/flat-services': workspace:* @@ -705,28 +724,6 @@ importers: prettier: 2.6.2 typescript: 4.7.4 - service-providers/rtc-recording/flat-rtc-recording: - specifiers: - '@netless/flat-services': workspace:* - prettier: ^2.3.0 - typescript: ^4.7.4 - dependencies: - '@netless/flat-services': link:../../../packages/flat-services - devDependencies: - prettier: 2.6.2 - typescript: 4.7.4 - - service-providers/rtc-recording/flat-rtc-recording-agora: - specifiers: - '@netless/flat-video-chat-recording': workspace:* - prettier: ^2.3.0 - typescript: ^4.7.4 - dependencies: - '@netless/flat-video-chat-recording': link:../flat-rtc-recording - devDependencies: - prettier: 2.6.2 - typescript: 4.7.4 - web/flat-web: specifiers: '@ant-design/icons': ^4.7.0 @@ -742,6 +739,7 @@ importers: '@netless/flat-i18n': workspace:* '@netless/flat-pages': workspace:* '@netless/flat-server-api': workspace:* + '@netless/flat-service-provider-agora-cloud-recording': workspace:* '@netless/flat-service-provider-agora-rtc-web': workspace:* '@netless/flat-service-provider-agora-rtm': workspace:* '@netless/flat-service-provider-fastboard': workspace:* @@ -800,6 +798,7 @@ importers: '@netless/flat-i18n': link:../../packages/flat-i18n '@netless/flat-pages': link:../../packages/flat-pages '@netless/flat-server-api': link:../../packages/flat-server-api + '@netless/flat-service-provider-agora-cloud-recording': link:../../service-providers/agora-cloud-recording '@netless/flat-service-provider-agora-rtc-web': link:../../service-providers/agora-rtc/agora-rtc-web '@netless/flat-service-provider-agora-rtm': link:../../service-providers/agora-rtm '@netless/flat-service-provider-fastboard': link:../../service-providers/fastboard @@ -846,7 +845,7 @@ importers: dotenv-expand: 8.0.3 eslint-loader: 4.0.2 mime: 3.0.0 - vite-plugin-compression: registry.npmjs.org/vite-plugin-compression/0.5.1 + vite-plugin-compression: 0.5.1 packages: @@ -14100,7 +14099,7 @@ packages: dev: false /object-assign/4.1.1: - resolution: {integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=} + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} /object-copy/0.1.0: @@ -19139,6 +19138,21 @@ packages: dependencies: global: 4.4.0 + /vite-plugin-compression/0.5.1: + resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==, registry: https://registry.yarnpkg.com/, tarball: https://registry.yarnpkg.com/vite-plugin-compression/-/vite-plugin-compression-0.5.1.tgz} + peerDependencies: + vite: '>=2.0.0' + peerDependenciesMeta: + vite: + optional: true + dependencies: + chalk: 4.1.2 + debug: 4.3.4 + fs-extra: 10.1.0 + transitivePeerDependencies: + - supports-color + dev: true + /vite/2.9.10_less@4.1.2: resolution: {integrity: sha512-TwZRuSMYjpTurLqXspct+HZE7ONiW9d+wSWgvADGxhDPPyoIcNywY+RX4ng+QpK30DCa1l/oZgi2PLZDibhzbQ==} engines: {node: '>=12.2.0'} @@ -19784,122 +19798,8 @@ packages: resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==} dev: true - registry.npmjs.org/ansi-styles/4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz} - name: ansi-styles - version: 4.3.0 - engines: {node: '>=8'} - dependencies: - color-convert: registry.npmjs.org/color-convert/2.0.1 - dev: true - - registry.npmjs.org/chalk/4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz} - name: chalk - version: 4.1.2 - engines: {node: '>=10'} - dependencies: - ansi-styles: registry.npmjs.org/ansi-styles/4.3.0 - supports-color: registry.npmjs.org/supports-color/7.2.0 - dev: true - - registry.npmjs.org/color-convert/2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz} - name: color-convert - version: 2.0.1 - engines: {node: '>=7.0.0'} - dependencies: - color-name: registry.npmjs.org/color-name/1.1.4 - dev: true - - registry.npmjs.org/color-name/1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz} - name: color-name - version: 1.1.4 - dev: true - - registry.npmjs.org/debug/4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/debug/-/debug-4.3.4.tgz} - name: debug - version: 4.3.4 - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: registry.npmjs.org/ms/2.1.2 - dev: true - - registry.npmjs.org/fs-extra/10.1.0: - resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz} - name: fs-extra - version: 10.1.0 - engines: {node: '>=12'} - dependencies: - graceful-fs: registry.npmjs.org/graceful-fs/4.2.10 - jsonfile: registry.npmjs.org/jsonfile/6.1.0 - universalify: registry.npmjs.org/universalify/2.0.0 - dev: true - registry.npmjs.org/graceful-fs/4.2.10: resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz} name: graceful-fs version: 4.2.10 - - registry.npmjs.org/has-flag/4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz} - name: has-flag - version: 4.0.0 - engines: {node: '>=8'} - dev: true - - registry.npmjs.org/jsonfile/6.1.0: - resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz} - name: jsonfile - version: 6.1.0 - dependencies: - universalify: registry.npmjs.org/universalify/2.0.0 - optionalDependencies: - graceful-fs: registry.npmjs.org/graceful-fs/4.2.10 - dev: true - - registry.npmjs.org/ms/2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/ms/-/ms-2.1.2.tgz} - name: ms - version: 2.1.2 - dev: true - - registry.npmjs.org/supports-color/7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz} - name: supports-color - version: 7.2.0 - engines: {node: '>=8'} - dependencies: - has-flag: registry.npmjs.org/has-flag/4.0.0 - dev: true - - registry.npmjs.org/universalify/2.0.0: - resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz} - name: universalify - version: 2.0.0 - engines: {node: '>= 10.0.0'} - dev: true - - registry.npmjs.org/vite-plugin-compression/0.5.1: - resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/vite-plugin-compression/-/vite-plugin-compression-0.5.1.tgz} - name: vite-plugin-compression - version: 0.5.1 - peerDependencies: - vite: '>=2.0.0' - peerDependenciesMeta: - vite: - optional: true - dependencies: - chalk: registry.npmjs.org/chalk/4.1.2 - debug: registry.npmjs.org/debug/4.3.4 - fs-extra: registry.npmjs.org/fs-extra/10.1.0 - transitivePeerDependencies: - - supports-color - dev: true + optional: true diff --git a/service-providers/rtc-recording/flat-rtc-recording-agora/.eslintignore b/service-providers/agora-cloud-recording/.eslintignore similarity index 100% rename from service-providers/rtc-recording/flat-rtc-recording-agora/.eslintignore rename to service-providers/agora-cloud-recording/.eslintignore diff --git a/service-providers/agora-cloud-recording/README.md b/service-providers/agora-cloud-recording/README.md new file mode 100644 index 00000000000..997b703d9a6 --- /dev/null +++ b/service-providers/agora-cloud-recording/README.md @@ -0,0 +1,3 @@ +# @netless/flat-service-provider-agora-cloud-recording + +Implements the `recording` Flat service. diff --git a/service-providers/agora-cloud-recording/package.json b/service-providers/agora-cloud-recording/package.json new file mode 100644 index 00000000000..1d8ef5f45fe --- /dev/null +++ b/service-providers/agora-cloud-recording/package.json @@ -0,0 +1,21 @@ +{ + "name": "@netless/flat-service-provider-agora-cloud-recording", + "version": "0.1.0", + "description": "Agora Cloud Recording", + "main": "src/index.ts", + "private": true, + "license": "MIT", + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@netless/flat-server-api": "workspace:*", + "@netless/flat-services": "workspace:*", + "polly-js": "^1.8.3", + "value-enhancer": "^1.3.2" + }, + "devDependencies": { + "prettier": "^2.3.0", + "typescript": "^4.7.4" + } +} diff --git a/service-providers/agora-cloud-recording/src/cloud-recording.ts b/service-providers/agora-cloud-recording/src/cloud-recording.ts new file mode 100644 index 00000000000..02f8d4dc529 --- /dev/null +++ b/service-providers/agora-cloud-recording/src/cloud-recording.ts @@ -0,0 +1,250 @@ +import { + cloudRecordAcquire, + cloudRecordQuery, + cloudRecordStart, + cloudRecordStop, + RoomType, + updateRecordEndTime, +} from "@netless/flat-server-api"; +import { IServiceRecording, IServiceRecordingJoinRoomConfig } from "@netless/flat-services"; +import { Val } from "value-enhancer"; +import polly from "polly-js"; + +export type AgoraCloudRecordingRoomInfo = IServiceRecordingJoinRoomConfig; + +export enum AgoraCloudRecordingChannelType { + Communication = 0, + Broadcast = 1, +} + +// Caveats: This service saves current recording state in localStorage "recordingStates", +// which means when you enter your browser's private mode or switch to another browser, +// the recording state will be lost and you cannot start recording for a while. +// +// TODO: We should save the recording state at the server side. +export class AgoraCloudRecording extends IServiceRecording { + private static readonly ReportingEndTimeKey = "reportingEndTime"; + + private roomInfo: AgoraCloudRecordingRoomInfo | null; + private recordingState: AgoraCloudRecordingState | null; + + public readonly $Val: Readonly<{ + isRecording$: Val; + }>; + + public get roomID(): string | null { + return this.roomInfo?.roomID ?? null; + } + + public get isRecording(): boolean { + return this.$Val.isRecording$.value; + } + + public constructor() { + super(); + + this.roomInfo = null; + this.recordingState = null; + + this.$Val = { + isRecording$: new Val(false), + }; + } + + public async joinRoom(config: IServiceRecordingJoinRoomConfig): Promise { + this.roomInfo = config; + this.recordingState = loadCloudRecordingState(config.roomID); + await this.queryRecordingStatus(); + } + + public async leaveRoom(): Promise { + if (this.isRecording) { + await this.stopRecording().catch(console.error); + } + this.roomInfo = null; + this.recordingState = null; + this.$Val.isRecording$.setValue(false); + } + + public async startRecording(): Promise { + if (this.roomInfo === null) { + throw new Error("should call joinRoom() before startRecording()"); + } + + await this.queryRecordingStatus(); + + if (this.isRecording) { + return; + } + + const mode: AgoraCloudRecordingMode = "individual"; + const { roomID, classroomType } = this.roomInfo; + const channelType = convertRoomType(classroomType); + try { + const RetryIntervals = [0, 1000, 3000, 5000, 7000]; + const recordingState = await polly() + .waitAndRetry(5) + .executeForPromise(async ({ count }) => { + if (count) { + await sleep(RetryIntervals[count]); + } + const { resourceId } = await cloudRecordAcquire({ + roomUUID: roomID, + agoraData: { + clientRequest: { + resourceExpiredHour: 24, + }, + }, + }); + const { sid } = await cloudRecordStart({ + roomUUID: roomID, + agoraParams: { + resourceid: resourceId, + mode: mode, + }, + agoraData: { + clientRequest: { + recordingConfig: { + subscribeUidGroup: 2, + maxIdleTime: 5 * 60, + channelType: channelType, + }, + }, + }, + }); + return { resourceId, sid, mode }; + }); + + this.recordingState = recordingState; + saveCloudRecordingState(roomID, recordingState); + this.$Val.isRecording$.setValue(true); + this.startReportingEndTime(); + } catch (error) { + this.$Val.isRecording$.setValue(false); + throw error; + } + } + + public async stopRecording(): Promise { + if (this.roomInfo === null) { + throw new Error("should call joinRoom() before stopRecording()"); + } + + await this.queryRecordingStatus(); + + if (this.recordingState === null) { + return; + } + + const { roomID } = this.roomInfo; + const { resourceId, sid, mode } = this.recordingState; + + this.sideEffect.flush(AgoraCloudRecording.ReportingEndTimeKey); + this.recordingState = null; + + try { + saveCloudRecordingState(roomID, null); + await cloudRecordStop({ + roomUUID: roomID, + agoraParams: { + resourceid: resourceId, + sid: sid, + mode: mode, + }, + }); + } finally { + this.$Val.isRecording$.setValue(false); + } + } + + private async queryRecordingStatus(): Promise { + if (this.recordingState === null || this.roomID === null) { + this.$Val.isRecording$.setValue(false); + return; + } + try { + const { resourceId, sid, mode } = this.recordingState; + const { serverResponse } = await cloudRecordQuery({ + roomUUID: this.roomID, + agoraParams: { + resourceid: resourceId, + sid: sid, + mode: mode, + }, + }); + const isRecording = 1 <= serverResponse.status && serverResponse.status <= 5; + this.$Val.isRecording$.setValue(isRecording); + } catch { + this.recordingState = null; + this.$Val.isRecording$.setValue(false); + if (this.roomID) { + saveCloudRecordingState(this.roomID, null); + } + } + } + + private startReportingEndTime(): void { + this.sideEffect.setInterval( + () => { + const roomID = this.roomInfo?.roomID; + if (this.isRecording && roomID) { + updateRecordEndTime(roomID); + } else { + this.sideEffect.flush(AgoraCloudRecording.ReportingEndTimeKey); + } + }, + 10 * 1000, + AgoraCloudRecording.ReportingEndTimeKey, + ); + } +} + +/** + * We always use "individual" mode in Flat. + * + * @see {@link https://docs.agora.io/en/cloud-recording/cloud_recording_manage_files?platform=RESTful#individual-recording} + */ +export type AgoraCloudRecordingMode = "individual" | "mix"; + +export interface AgoraCloudRecordingState { + resourceId: string; + sid: string; + mode: AgoraCloudRecordingMode; +} + +function loadCloudRecordingState(roomID: string): AgoraCloudRecordingState | null { + let data: Record; + try { + data = JSON.parse(localStorage.getItem("recordingStates") || "{}"); + } catch { + data = {}; + } + return data[roomID] ?? null; +} + +function saveCloudRecordingState(roomID: string, state: AgoraCloudRecordingState | null): void { + let data: Record; + try { + data = JSON.parse(localStorage.getItem("recordingStates") || "{}"); + } catch { + data = {}; + } + if (state === null) { + delete data[roomID]; + } else { + data[roomID] = state; + } + localStorage.setItem("recordingStates", JSON.stringify(data)); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function convertRoomType(roomType: RoomType): AgoraCloudRecordingChannelType { + if (roomType === RoomType.BigClass) { + return AgoraCloudRecordingChannelType.Broadcast; + } else { + return AgoraCloudRecordingChannelType.Communication; + } +} diff --git a/service-providers/agora-cloud-recording/src/index.ts b/service-providers/agora-cloud-recording/src/index.ts new file mode 100644 index 00000000000..f9578fe71f4 --- /dev/null +++ b/service-providers/agora-cloud-recording/src/index.ts @@ -0,0 +1 @@ +export * from "./cloud-recording"; diff --git a/service-providers/rtc-recording/flat-rtc-recording/tsconfig.json b/service-providers/agora-cloud-recording/tsconfig.json similarity index 73% rename from service-providers/rtc-recording/flat-rtc-recording/tsconfig.json rename to service-providers/agora-cloud-recording/tsconfig.json index 5a5274e4409..71a68c93efb 100644 --- a/service-providers/rtc-recording/flat-rtc-recording/tsconfig.json +++ b/service-providers/agora-cloud-recording/tsconfig.json @@ -1,10 +1,11 @@ { - "extends": "../../../tsconfig.base.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "declaration": true, "declarationMap": true, "sourceMap": true, "composite": true, + "noImplicitOverride": true, "outDir": "./dist", "rootDir": "./src" }, diff --git a/service-providers/rtc-recording/flat-rtc-recording-agora/package.json b/service-providers/rtc-recording/flat-rtc-recording-agora/package.json deleted file mode 100644 index 23f2d3920cb..00000000000 --- a/service-providers/rtc-recording/flat-rtc-recording-agora/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@netless/agora-rtc-cloud-recording-agora", - "version": "0.1.0", - "description": "Flat classroom video chat recording with Agora RTC Cloud Recording", - "main": "src/index.ts", - "private": true, - "license": "MIT", - "scripts": { - "build": "tsc" - }, - "dependencies": { - "@netless/flat-video-chat-recording": "workspace:*" - }, - "devDependencies": { - "prettier": "^2.3.0", - "typescript": "^4.7.4" - } -} diff --git a/service-providers/rtc-recording/flat-rtc-recording-agora/src/index.ts b/service-providers/rtc-recording/flat-rtc-recording-agora/src/index.ts deleted file mode 100644 index 182833fd9e7..00000000000 --- a/service-providers/rtc-recording/flat-rtc-recording-agora/src/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { - IServiceVideoChatRecording, - IServiceVideoChatRecordingConfig, -} from "@netless/flat-video-chat-recording"; - -export class FlatRTCRecordingAgora implements IServiceVideoChatRecording { - public constructor(config: IServiceVideoChatRecordingConfig) { - console.log(config); - } - - public async startRecording(): Promise { - return; - } - public async stopRecording(): Promise { - return; - } -} diff --git a/service-providers/rtc-recording/flat-rtc-recording-agora/tsconfig.json b/service-providers/rtc-recording/flat-rtc-recording-agora/tsconfig.json deleted file mode 100644 index 5a5274e4409..00000000000 --- a/service-providers/rtc-recording/flat-rtc-recording-agora/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "composite": true, - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["./src"] -} diff --git a/service-providers/rtc-recording/flat-rtc-recording/.eslintignore b/service-providers/rtc-recording/flat-rtc-recording/.eslintignore deleted file mode 100644 index b513f11c280..00000000000 --- a/service-providers/rtc-recording/flat-rtc-recording/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules/ -dist/ -public/ -*.js diff --git a/service-providers/rtc-recording/flat-rtc-recording/package.json b/service-providers/rtc-recording/flat-rtc-recording/package.json deleted file mode 100644 index e2d34844746..00000000000 --- a/service-providers/rtc-recording/flat-rtc-recording/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@netless/flat-video-chat-recording", - "version": "0.1.0", - "description": "Flat video chat recording interface", - "main": "src/index.ts", - "private": false, - "license": "MIT", - "scripts": { - "build": "tsc" - }, - "dependencies": { - "@netless/flat-services": "workspace:*" - }, - "devDependencies": { - "prettier": "^2.3.0", - "typescript": "^4.7.4" - } -} diff --git a/service-providers/rtc-recording/flat-rtc-recording/src/constants.ts b/service-providers/rtc-recording/flat-rtc-recording/src/constants.ts deleted file mode 100644 index af95aca7fb3..00000000000 --- a/service-providers/rtc-recording/flat-rtc-recording/src/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum IServiceVideoChatRecordingMode { - Communication = 0, - Broadcast = 1, -} diff --git a/service-providers/rtc-recording/flat-rtc-recording/src/recording.ts b/service-providers/rtc-recording/flat-rtc-recording/src/recording.ts deleted file mode 100644 index 06bcd0bd67c..00000000000 --- a/service-providers/rtc-recording/flat-rtc-recording/src/recording.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IServiceVideoChatMode } from "@netless/flat-services"; - -export interface IServiceVideoChatRecordingConfig { - roomUUID: string; - avatarWidth: number; - avatarHeight: number; - mode: IServiceVideoChatMode; -} - -export interface IServiceVideoChatRecording { - startRecording(): Promise; - stopRecording(): Promise; -} diff --git a/web/flat-web/package.json b/web/flat-web/package.json index 000cb327a15..65a326a6007 100644 --- a/web/flat-web/package.json +++ b/web/flat-web/package.json @@ -24,6 +24,7 @@ "@netless/flat-i18n": "workspace:*", "@netless/flat-pages": "workspace:*", "@netless/flat-server-api": "workspace:*", + "@netless/flat-service-provider-agora-cloud-recording": "workspace:*", "@netless/flat-service-provider-agora-rtc-web": "workspace:*", "@netless/flat-service-provider-agora-rtm": "workspace:*", "@netless/flat-service-provider-fastboard": "workspace:*", diff --git a/web/flat-web/src/tasks/init-flat-services.ts b/web/flat-web/src/tasks/init-flat-services.ts index cce21bf6a1e..0f301995085 100644 --- a/web/flat-web/src/tasks/init-flat-services.ts +++ b/web/flat-web/src/tasks/init-flat-services.ts @@ -138,6 +138,13 @@ export function initFlatServices(): void { return service; }); + flatServices.register("recording", async () => { + const { AgoraCloudRecording } = await import( + "@netless/flat-service-provider-agora-cloud-recording" + ); + return new AgoraCloudRecording(); + }); + flatServices.register( [ "file-convert:doc",