diff --git a/web/flat-web/package.json b/web/flat-web/package.json index e8f8736b51f..b348ee12b8d 100644 --- a/web/flat-web/package.json +++ b/web/flat-web/package.json @@ -32,6 +32,7 @@ "@ant-design/icons": "^4.6.2", "@loadable/component": "^5.15.0", "agora-rtc-sdk-ng": "^4.5.0", + "agora-rtm-sdk": "^1.4.3", "antd": "^4.15.4", "eventemitter3": "^4.0.7", "mobx-react-lite": "^3.2.0", @@ -42,7 +43,7 @@ "white-web-sdk": "^2.12.15" }, "scripts": { - "postinstall": "esbuild-dev ./scripts/white-web-sdk.ts", + "postinstall": "esbuild-dev ./scripts/post-install.ts", "start": "vite --open", "build": "vite build", "serve": "vite preview", diff --git a/web/flat-web/scripts/agora-rtm-sdk.ts b/web/flat-web/scripts/agora-rtm-sdk.ts new file mode 100644 index 00000000000..74401f6f070 --- /dev/null +++ b/web/flat-web/scripts/agora-rtm-sdk.ts @@ -0,0 +1,17 @@ +/** + * Get rid of "process.env.NODE_ENV" replace error in agora-rtm-sdk. + * + * @TODO: Remove this file when agora-rtm-sdk fix the code. + */ + +/// + +// https://vitejs.dev/guide/env-and-mode.html#production-replacement + +import fs from "fs"; +// NOTE: `import.meta.resolve` is still experimental +const file = require.resolve("agora-rtm-sdk"); +const code = fs.readFileSync(file, "utf-8"); +const modified = code.replace("process.env.NODE_ENV", "process\u200b.env.NODE_ENV"); +fs.writeFileSync(file, modified); +console.log("agora-rtm-sdk: done!"); diff --git a/web/flat-web/scripts/post-install.ts b/web/flat-web/scripts/post-install.ts new file mode 100644 index 00000000000..37d2148b519 --- /dev/null +++ b/web/flat-web/scripts/post-install.ts @@ -0,0 +1,2 @@ +import "./white-web-sdk"; +import "./agora-rtm-sdk"; diff --git a/web/flat-web/scripts/white-web-sdk.ts b/web/flat-web/scripts/white-web-sdk.ts index 9f365fc8c9a..8f63027fbed 100644 --- a/web/flat-web/scripts/white-web-sdk.ts +++ b/web/flat-web/scripts/white-web-sdk.ts @@ -47,7 +47,7 @@ function hackAndReplaceMainScript(script: string, main: string): void { // webpack will add polyfill under the hood so it is ok, but not rollup/esbuild script = script.replace(/=require\([^)]+\)/g, "=void 0"); fs.writeFileSync(path.resolve(sdkPath, main), script); - console.log("hack: done!"); + console.log("white-web-sdk: done!"); } if (fs.existsSync(pkgJSON)) { diff --git a/web/flat-web/src/apiMiddleware/CloudRecording.ts b/web/flat-web/src/apiMiddleware/CloudRecording.ts index 12fecd57432..5ffef5ed731 100644 --- a/web/flat-web/src/apiMiddleware/CloudRecording.ts +++ b/web/flat-web/src/apiMiddleware/CloudRecording.ts @@ -76,7 +76,7 @@ export class CloudRecording { height: 360, fps: 15, bitrate: 500, - defaultUserBackgroundImage: process.env.CLOUD_RECORDING_DEFAULT_AVATAR, + defaultUserBackgroundImage: import.meta.env.CLOUD_RECORDING_DEFAULT_AVATAR, }, }, }, diff --git a/web/flat-web/src/apiMiddleware/Rtm.ts b/web/flat-web/src/apiMiddleware/Rtm.ts index 6b59ceecaea..58a63bb5210 100644 --- a/web/flat-web/src/apiMiddleware/Rtm.ts +++ b/web/flat-web/src/apiMiddleware/Rtm.ts @@ -2,7 +2,7 @@ import AgoraRTM, { RtmChannel, RtmClient } from "agora-rtm-sdk"; import polly from "polly-js"; import { v4 as uuidv4 } from "uuid"; import { AGORA, NODE_ENV } from "../constants/Process"; -import { EventEmitter } from "events"; +import { EventEmitter } from "eventemitter3"; import { RoomStatus } from "./flatServer/constants"; import { generateRTMToken } from "./flatServer/agora"; import { globalStore } from "../stores/GlobalStore"; @@ -145,7 +145,7 @@ export declare interface Rtm { } // eslint-disable-next-line no-redeclare -export class Rtm extends EventEmitter { +export class Rtm extends EventEmitter { public static MessageType = AgoraRTM.MessageType; public client: RtmClient; diff --git a/web/flat-web/src/apiMiddleware/rtc/avatar.ts b/web/flat-web/src/apiMiddleware/rtc/avatar.ts new file mode 100644 index 00000000000..a6bab91cb6a --- /dev/null +++ b/web/flat-web/src/apiMiddleware/rtc/avatar.ts @@ -0,0 +1,154 @@ +import type { + IAgoraRTCClient, + IAgoraRTCRemoteUser, + ICameraVideoTrack, + IMicrophoneAudioTrack, + IRemoteAudioTrack, + IRemoteVideoTrack, + ITrack, +} from "agora-rtc-sdk-ng"; +import AgoraRTC from "agora-rtc-sdk-ng"; +import type { User } from "../../stores/UserStore"; +import type { RtcRoom } from "./room"; + +export interface RtcAvatarParams { + rtc: RtcRoom; + userUUID: string; + avatarUser: User; +} + +/** + * @example + * const avatar = new RtcAvatar({ rtc, userUUID, avatarUser }) + * avatar.element = el + * avatar.setCamera(true) + */ +export class RtcAvatar { + private readonly rtc: RtcRoom; + + public readonly userUUID: string; + public readonly avatarUser: User; + public element?: HTMLElement; + public audioTrack?: ITrack; + public videoTrack?: ITrack; + + private readonly isLocal: boolean; + private readonly remoteAudioTrack: Promise; + private readonly remoteVideoTrack: Promise; + + private resolveRemoteAudioTrack?: (value: IRemoteAudioTrack) => void; + private resolveRemoteVideoTrack?: (value: IRemoteVideoTrack) => void; + + constructor({ rtc, userUUID, avatarUser }: RtcAvatarParams) { + this.rtc = rtc; + this.userUUID = userUUID; + this.avatarUser = avatarUser; + this.isLocal = userUUID === avatarUser.userUUID; + this.remoteAudioTrack = new Promise(resolve => { + this.resolveRemoteAudioTrack = resolve; + }); + this.remoteVideoTrack = new Promise(resolve => { + this.resolveRemoteVideoTrack = resolve; + }); + if (!this.isLocal) { + this.setupExistingTracks(); + this.client.on("user-published", this.onUserPublished); + } + } + + private get client(): IAgoraRTCClient { + return this.rtc.client!; + } + + private async setupExistingTracks(): Promise { + const exist = this.client.remoteUsers.find(e => e.uid === this.avatarUser.rtcUID); + if (exist) { + if (exist.hasAudio) { + const audioTrack = await this.client.subscribe(exist, "audio"); + this.resolveRemoteAudioTrack?.(audioTrack); + this.resolveRemoteAudioTrack = undefined; + } + if (exist.hasVideo) { + const videoTrack = await this.client.subscribe(exist, "video"); + this.resolveRemoteVideoTrack?.(videoTrack); + this.resolveRemoteVideoTrack = undefined; + } + } + } + + public destroy(): void { + if (!this.isLocal && this.client) { + this.client.off("user-published", this.onUserPublished); + } + this.resolveRemoteAudioTrack = undefined; + this.resolveRemoteVideoTrack = undefined; + } + + private onUserPublished = async ( + user: IAgoraRTCRemoteUser, + mediaType: "video" | "audio", + ): Promise => { + if (user.uid === this.avatarUser.rtcUID) { + const track = await this.client.subscribe(user, mediaType); + if (mediaType === "audio") { + this.resolveRemoteAudioTrack?.(track as IRemoteAudioTrack); + this.resolveRemoteAudioTrack = undefined; + } else { + this.resolveRemoteVideoTrack?.(track as IRemoteVideoTrack); + this.resolveRemoteVideoTrack = undefined; + } + } + }; + + public async setCamera(enable: boolean): Promise { + try { + if (this.isLocal) { + const videoTrack = this.videoTrack as ICameraVideoTrack | undefined; + if (videoTrack) { + videoTrack.setEnabled(enable); + } else if (enable) { + const videoTrack = await AgoraRTC.createCameraVideoTrack({ + encoderConfig: { width: 288, height: 216 }, + }); + this.videoTrack = videoTrack; + this.element && videoTrack.play(this.element); + await this.client.publish([videoTrack]); + } + } else { + if (!this.videoTrack && enable) { + const videoTrack = await this.remoteVideoTrack; + this.videoTrack = videoTrack; + this.element && videoTrack.play(this.element); + } + } + } catch (error) { + console.info("setCamera failed", error); + } + } + + public async setMic(enable: boolean): Promise { + try { + if (this.isLocal) { + const audioTrack = this.audioTrack as IMicrophoneAudioTrack | undefined; + if (audioTrack) { + audioTrack.setEnabled(enable); + } else if (enable) { + const audioTrack = await AgoraRTC.createMicrophoneAudioTrack(); + this.audioTrack = audioTrack; + audioTrack.play(); + await this.client.publish(audioTrack); + } + } else { + if (!this.audioTrack && enable) { + const audioTrack = await this.remoteAudioTrack; + this.audioTrack = audioTrack; + audioTrack.play(); + } + } + } catch (error) { + console.info("setMic failed", error); + } + } +} + +(window as any).RtcAvatar = RtcAvatar; diff --git a/web/flat-web/src/apiMiddleware/rtc/room.ts b/web/flat-web/src/apiMiddleware/rtc/room.ts new file mode 100644 index 00000000000..6281a2b27f7 --- /dev/null +++ b/web/flat-web/src/apiMiddleware/rtc/room.ts @@ -0,0 +1,75 @@ +import AgoraRTC, { IAgoraRTCClient } from "agora-rtc-sdk-ng"; +import { AGORA } from "../../constants/Process"; +import { globalStore } from "../../stores/GlobalStore"; +import { generateRTCToken } from "../flatServer/agora"; + +AgoraRTC.setLogLevel(/* WARNING */ 2); + +export enum RtcChannelType { + Communication = 0, + Broadcast = 1, +} + +/** + * Flow: + * ``` + * join() -> now it has `client` + * getLatency() -> number + * destroy() + * ``` + */ +export class RtcRoom { + public client?: IAgoraRTCClient; + + private roomUUID?: string; + + public async join({ + roomUUID, + isCreator, + rtcUID, + channelType, + }: { + roomUUID: string; + isCreator: boolean; + rtcUID: number; + channelType: RtcChannelType; + }): Promise { + if (this.client) { + await this.destroy(); + } + + const mode = channelType === RtcChannelType.Communication ? "rtc" : "live"; + this.client = AgoraRTC.createClient({ mode, codec: "vp8" }); + + this.client.on("token-privilege-will-expire", this.renewToken); + + await this.client.setClientRole( + channelType === RtcChannelType.Broadcast && !isCreator ? "audience" : "host", + ); + const token = globalStore.rtcToken || (await generateRTCToken(roomUUID)); + await this.client.join(AGORA.APP_ID, roomUUID, token, rtcUID); + + this.roomUUID = roomUUID; + } + + public getLatency(): number { + return this.client?.getRTCStats().RTT ?? NaN; + } + + public async destroy(): Promise { + if (this.client) { + this.client.off("token-privilege-will-expire", this.renewToken); + await this.client.leave(); + this.client = undefined; + } + } + + private renewToken = async (): Promise => { + if (this.client && this.roomUUID) { + const token = await generateRTCToken(this.roomUUID); + await this.client.renewToken(token); + } + }; +} + +(window as any).RtcRoom = RtcRoom; diff --git a/web/flat-web/src/apiMiddleware/rtc/testing.ts b/web/flat-web/src/apiMiddleware/rtc/testing.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/web/flat-web/src/constants/Process.ts b/web/flat-web/src/constants/Process.ts index 8770e081b41..d35d0d9e0fb 100644 --- a/web/flat-web/src/constants/Process.ts +++ b/web/flat-web/src/constants/Process.ts @@ -1,4 +1,4 @@ -export const NODE_ENV = process.env.NODE_ENV; +export const NODE_ENV = import.meta.env.MODE; export const NETLESS = Object.freeze({ APP_IDENTIFIER: import.meta.env.NETLESS_APP_IDENTIFIER, diff --git a/web/flat-web/src/stores/ClassRoomStore.ts b/web/flat-web/src/stores/ClassRoomStore.ts index c618d90285a..9348aae6caf 100644 --- a/web/flat-web/src/stores/ClassRoomStore.ts +++ b/web/flat-web/src/stores/ClassRoomStore.ts @@ -3,7 +3,7 @@ import { message } from "antd"; import { action, autorun, makeAutoObservable, observable, runInAction } from "mobx"; import { v4 as uuidv4 } from "uuid"; import dateSub from "date-fns/sub"; -import { Rtc as RTCAPI, RtcChannelType } from "../apiMiddleware/Rtc"; +import { RtcRoom as RTCAPI, RtcChannelType } from "../apiMiddleware/rtc/room"; import { ClassModeType, NonDefaultUserProp, @@ -30,11 +30,11 @@ import { globalStore } from "./GlobalStore"; import { NODE_ENV } from "../constants/Process"; import { useAutoRun } from "../utils/mobx"; import { User, UserStore } from "./UserStore"; -import type { AgoraNetworkQuality, RtcStats } from "agora-electron-sdk/types/Api/native_type"; import { errorTips } from "../components/Tips/ErrorTips"; import { WhiteboardStore } from "./WhiteboardStore"; import { RouteNameType, usePushHistory } from "../utils/routes"; import { useSafePromise } from "../utils/hooks/lifecycle"; +import { NetworkQuality } from "agora-rtc-sdk-ng"; export type { User } from "./UserStore"; @@ -118,6 +118,7 @@ export class ClassRoomStore { this.rtcChannelType = config.recordingConfig.channelType ?? RtcChannelType.Communication; this.rtc = new RTCAPI(); + (window as any).rtc = this.rtc; this.rtm = new RTMAPI(); this.cloudRecording = new CloudRecording({ roomUUID: config.roomUUID }); @@ -229,7 +230,6 @@ export class ClassRoomStore { if (this.isRecording) { await this.stopRecording(); } - this.rtc.leave(); } catch (e) { console.error(e); this.updateCalling(true); @@ -496,13 +496,11 @@ export class ClassRoomStore { } public onRTCEvents(): void { - this.rtc.rtcEngine.on("rtcStats", this.checkDelay); - this.rtc.rtcEngine.on("networkQuality", this.checkNetworkQuality); + this.rtc.client?.on("network-quality", this.checkNetworkQuality); } public offRTCEvents(): void { - this.rtc.rtcEngine.off("rtcStats", this.checkDelay); - this.rtc.rtcEngine.off("networkQuality", this.checkNetworkQuality); + this.rtc.client?.off("network-quality", this.checkNetworkQuality); } public async destroy(): Promise { @@ -953,21 +951,11 @@ export class ClassRoomStore { window.clearTimeout(this._collectChannelStatusTimeout); } - private checkDelay = action((stats: RtcStats): void => { - this.networkQuality.delay = stats.lastmileDelay; - }); - private checkNetworkQuality = action( - ( - uid: number, - uplinkQuality: AgoraNetworkQuality, - downlinkQuality: AgoraNetworkQuality, - ): void => { - if (uid === 0) { - // current user - this.networkQuality.uplink = uplinkQuality; - this.networkQuality.downlink = downlinkQuality; - } + ({ uplinkNetworkQuality, downlinkNetworkQuality }: NetworkQuality): void => { + this.networkQuality.uplink = uplinkNetworkQuality; + this.networkQuality.downlink = downlinkNetworkQuality; + this.networkQuality.delay = this.rtc.getLatency(); }, ); } diff --git a/web/flat-web/src/stores/WhiteboardStore.ts b/web/flat-web/src/stores/WhiteboardStore.ts index dd8eb2a538a..12ac388faa1 100644 --- a/web/flat-web/src/stores/WhiteboardStore.ts +++ b/web/flat-web/src/stores/WhiteboardStore.ts @@ -126,7 +126,7 @@ export class WhiteboardStore { }, floatBar: true, isWritable: this.isWritable, - disableNewPencil: false, + disableNewPencil: true, hotKeys: { ...DefaultHotKeys, changeToSelector: "s", diff --git a/web/flat-web/src/utils/UploadTaskManager/index.ts b/web/flat-web/src/utils/UploadTaskManager/index.ts index 8204b9b5495..dece9f190c2 100644 --- a/web/flat-web/src/utils/UploadTaskManager/index.ts +++ b/web/flat-web/src/utils/UploadTaskManager/index.ts @@ -58,7 +58,7 @@ export class UploadTaskManager { runInAction(() => { this.uploadingMap.set(task.uploadID, task); }); - if (process.env.NODE_ENV === "development") { + if (import.meta.env.DEV) { console.log(`[cloud storage]: UploadTaskManager uploads "${task.file.name}"`); } void task.upload().then(() => this.finishUpload(task)); diff --git a/web/flat-web/tsconfig.json b/web/flat-web/tsconfig.json index 27cc31d742b..ef731cffac2 100644 --- a/web/flat-web/tsconfig.json +++ b/web/flat-web/tsconfig.json @@ -10,7 +10,7 @@ "esModuleInterop": true, "resolveJsonModule": true, "skipLibCheck": true, - "types": ["vite/client", "@types/node"] + "types": ["vite/client"] }, "exclude": ["node_modules"], "include": ["src/**/*.ts", "src/**/*.tsx", "typings/*.ts"] diff --git a/yarn.lock b/yarn.lock index 3842e6ad44e..acaad2f9090 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1342,6 +1342,11 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/standalone@7.13.9": + version "7.13.9" + resolved "https://registry.npmjs.org/@babel/standalone/-/standalone-7.13.9.tgz#38e78673e19f5077f4b294ad346780634e59e276" + integrity sha512-9ZpIl8rXXQqm5OdsQVhlBSPttwabiHGPGrl5N2bIxB9XS2NJW+1oiPFgMMNd+57C/xVwsEwD2mzv0KValOwlQA== + "@babel/standalone@7.14.1", "@babel/standalone@^7.13.12": version "7.14.1" resolved "https://registry.npmjs.org/@babel/standalone/-/standalone-7.14.1.tgz#2c5f6908f03108583eea75bdcc94eb29e720fbac" @@ -3900,6 +3905,11 @@ agora-rtm-sdk@^1.4.1: resolved "https://registry.npmjs.org/agora-rtm-sdk/-/agora-rtm-sdk-1.4.1.tgz#be10d1cdfd52cf527ea714146287bdc7ff6efaa1" integrity sha512-D27CSWIOCS1RDYxH7dtnCJNsM7VIuBYX8W4mqt3yIkPnTpoRFGB1n43ao3eSHaFIRpsdCefjw3KcV5xIAAYvdA== +agora-rtm-sdk@^1.4.3: + version "1.4.3" + resolved "https://registry.npmjs.org/agora-rtm-sdk/-/agora-rtm-sdk-1.4.3.tgz#cfdbf95b65a1b36ae83d1cd857d96e6e3fed3734" + integrity sha512-0CwobiGaT+vlc594vby6vOAzbkhbW9D/v7rBYcTwC/2mDfDgPR9ZWAU/xHPW/vJkVHMMPxQ2J7WY0Gtag65U8w== + airbnb-js-shims@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/airbnb-js-shims/-/airbnb-js-shims-2.2.1.tgz#db481102d682b98ed1daa4c5baa697a05ce5c040"