diff --git a/CHANGELOG.md b/CHANGELOG.md index 88ed662c2c..89cb433954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added PPS metrics on the Encoded Transform when used. +- Added meeting session lifecycle timing tracking and signaling via `MeetingSessionTimingManager`. ### Removed diff --git a/demos/browser/package-lock.json b/demos/browser/package-lock.json index 0878a086ff..9555c163ee 100644 --- a/demos/browser/package-lock.json +++ b/demos/browser/package-lock.json @@ -34,7 +34,6 @@ } }, "../..": { - "name": "amazon-chime-sdk-js", "version": "3.31.0", "license": "Apache-2.0", "dependencies": { diff --git a/demos/browser/package.json b/demos/browser/package.json index 42659a96ad..9aff977b15 100644 --- a/demos/browser/package.json +++ b/demos/browser/package.json @@ -7,7 +7,7 @@ "build:fast": "vite build --", "build": "npm run deps && npm install && tsc --noEmit && npm run build:fast", "start:fast": "node dev-server.js", - "start:watch": "WATCH_SDK=true node dev-server.js", + "start:sdk-autorefresh": "SDK_AUTOREFRESH=true node dev-server.js", "start": "npm run deps && npm install && npm run start:fast" }, "devDependencies": { diff --git a/demos/browser/vite.config.ts b/demos/browser/vite.config.ts index 7221c32256..2d46d4af2a 100644 --- a/demos/browser/vite.config.ts +++ b/demos/browser/vite.config.ts @@ -3,13 +3,12 @@ import { defineConfig, type Plugin } from 'vite'; import { resolve } from 'path'; -import { rmSync } from 'fs'; import { watch } from 'chokidar'; import { viteSingleFile } from 'vite-plugin-singlefile'; import ejsSvgPlugin from './plugins/vite-plugin-ejs-svg'; const app = process.env.npm_config_app || process.env.APP || 'meetingV2'; -const watchSdk = process.env.WATCH_SDK === 'true'; +const watchSdk = process.env.SDK_AUTOREFRESH === 'true'; /** * This is exactly what we document in the CSP guide. @@ -89,12 +88,11 @@ function fullReloadPlugin(): Plugin { sdkWatcher.on('change', () => { if (debounce) clearTimeout(debounce); debounce = setTimeout(() => { - console.log('[full-reload] SDK build changed, clearing cache and restarting...'); - try { rmSync(resolve(__dirname, 'node_modules/.vite'), { recursive: true, force: true }); } catch {} - server.restart(); + console.log('[full-reload] SDK build changed, reloading browser...'); + server.ws.send({ type: 'full-reload' }); }, 1000); }); - console.log('[full-reload] Watching SDK build output for changes.'); + console.log('[full-reload] Watching SDK build output for changes (SDK_AUTOREFRESH).'); } server.watcher.on('change', () => { @@ -118,6 +116,7 @@ export default defineConfig({ }, optimizeDeps: { include: ['amazon-chime-sdk-js'], + force: true, }, define: { global: 'globalThis', diff --git a/package-lock.json b/package-lock.json index 64bcc361e3..c5f312666b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ }, "engines": { "node": ">=20", - "npm": ">=8" + "npm": ">=10" } }, "node_modules/@ampproject/remapping": { @@ -3922,17 +3922,18 @@ "dev": true }, "node_modules/fast-xml-parser": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", - "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", + "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], + "license": "MIT", "dependencies": { - "strnum": "^2.1.0" + "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" diff --git a/package.json b/package.json index ed1e3d321c..fcd2f92a21 100644 --- a/package.json +++ b/package.json @@ -134,5 +134,8 @@ "singleQuote": true, "trailingComma": "es5", "printWidth": 100 + }, + "overrides": { + "fast-xml-parser": "5.3.6" } } diff --git a/protocol/SignalingProtocol.proto b/protocol/SignalingProtocol.proto index c074c176dd..bba90ceb72 100644 --- a/protocol/SignalingProtocol.proto +++ b/protocol/SignalingProtocol.proto @@ -27,6 +27,7 @@ message SdkSignalFrame { PRIMARY_MEETING_JOIN_ACK = 26; PRIMARY_MEETING_LEAVE = 27; NOTIFICATION = 34; + MEETING_SESSION_TIMING = 43; } required uint64 timestamp_ms = 1; required Type type = 2; @@ -52,6 +53,7 @@ message SdkSignalFrame { optional SdkPrimaryMeetingJoinAckFrame primary_meeting_join_ack = 27; optional SdkPrimaryMeetingLeaveFrame primary_meeting_leave = 28; optional SdkNotificationFrame notification = 35; + optional SdkMeetingSessionTimingFrame meeting_session_timing = 44; } message SdkErrorFrame { @@ -283,6 +285,8 @@ message SdkMetric { VIDEO_DISCARDED_PPS = 47; VIDEO_PLIS_SENT = 48; VIDEO_RECEIVED_JITTER_MS = 49; + VIDEO_LOCAL_RENDER_FPS = 52; + VIDEO_REMOTE_RENDER_FPS = 56; VIDEO_INPUT_HEIGHT = 60; VIDEO_ENCODE_HEIGHT = 64; VIDEO_SENT_QP_SUM = 66; @@ -503,3 +507,60 @@ enum SdkVideoCodecCapability { VP9_PROFILE_0 = 8; AV1_MAIN_PROFILE = 11; }; + +message SdkMeetingSessionTimingFrame { + repeated SdkMeetingSessionSignalingTiming signaling = 1; + repeated SdkMeetingSessionRemoteAudioTiming remote_audio = 2; + repeated SdkMeetingSessionLocalAudioTiming local_audio = 3; + repeated SdkMeetingSessionLocalVideoTiming local_video = 4; + repeated SdkMeetingSessionRemoteVideoTiming remote_videos = 5; +} + +message SdkMeetingSessionSignalingTiming { + optional int64 start_ms = 1; + optional int64 join_sent_ms = 2; + optional int64 join_ack_received_ms = 3; + optional int64 transport_connected_ms = 4; + optional int64 create_offer_ms = 5; + optional int64 set_local_description_ms = 6; + optional int64 set_remote_description_ms = 7; + optional int64 ice_gathering_start_ms = 8; + optional int64 ice_gathering_complete_ms = 9; + optional int64 ice_connected_ms = 10; + optional int64 subscribe_sent_ms = 11; + optional int64 subscribe_ack_ms = 12; + optional bool timed_out = 13; +} + +message SdkMeetingSessionRemoteAudioTiming { + optional int64 added_ms = 1; + optional int64 first_packet_received_ms = 2; + optional int64 first_frame_rendered_ms = 3; + optional bool timed_out = 4; + optional bool removed = 5; +} + +message SdkMeetingSessionLocalAudioTiming { + optional int64 added_ms = 1; + optional int64 first_frame_captured_ms = 2; + optional int64 first_packet_sent_ms = 3; + optional bool timed_out = 4; + optional bool removed = 5; +} + +message SdkMeetingSessionLocalVideoTiming { + optional int64 added_ms = 1; + optional int64 first_frame_captured_ms = 2; + optional int64 first_frame_sent_ms = 3; + optional bool timed_out = 4; + optional bool removed = 5; +} + +message SdkMeetingSessionRemoteVideoTiming { + optional uint32 group_id = 1; + optional int64 added_ms = 2; + optional int64 first_packet_received_ms = 3; + optional int64 first_frame_rendered_ms = 4; + optional bool timed_out = 5; + optional bool removed = 6; +} diff --git a/script/audit-deps b/script/audit-deps index 121447f300..be45fe911b 100755 --- a/script/audit-deps +++ b/script/audit-deps @@ -6,7 +6,7 @@ end # Just so the devs can see. puts 'Auditing development dependencies. You should address any findings.' -system('npm audit --omit=prod') +system('npm audit --include=dev') puts '---' diff --git a/script/barrelize.js b/script/barrelize.js index 923aa7770b..2959d6399e 100755 --- a/script/barrelize.js +++ b/script/barrelize.js @@ -120,6 +120,16 @@ walk('src') importStrings.push(importLine); exportStrings.push(exportLine); + if (typeToImport === 'MeetingSessionTiming') { + importStrings.push(`import { MeetingSessionSignalingTiming, MeetingSessionRemoteAudioTiming, MeetingSessionLocalAudioTiming, MeetingSessionLocalVideoTiming, MeetingSessionRemoteVideoTiming, MeetingSessionTimingObserver } from '${pathToImport}/MeetingSessionTiming';`); + exportStrings.push('MeetingSessionSignalingTiming'); + exportStrings.push('MeetingSessionRemoteAudioTiming'); + exportStrings.push('MeetingSessionLocalAudioTiming'); + exportStrings.push('MeetingSessionLocalVideoTiming'); + exportStrings.push('MeetingSessionRemoteVideoTiming'); + exportStrings.push('MeetingSessionTimingObserver'); + } + // Because these two types are very intertwined. if (typeToImport === 'VideoPreferences') { importStrings.push(`import { MutableVideoPreferences } from '${pathToImport}/VideoPreferences';`); diff --git a/script/generate-media-transform-worker-code.js b/script/generate-media-transform-worker-code.js index d349423088..8c1feee843 100755 --- a/script/generate-media-transform-worker-code.js +++ b/script/generate-media-transform-worker-code.js @@ -20,10 +20,13 @@ const workerTsconfig = 'tsconfig.mediatransformworker.json'; const workerTsconfigContent = `{ "extends": "./tsconfig.base.json", "compilerOptions": { - "module": "ES2015", + "module": "es2015", "moduleResolution": "node", - "outDir": "../build", - "rootDir": "../src" + "outDir": "../build/mediatransformworker", + "rootDir": "../src", + "tsBuildInfoFile": "./tsconfig.mediatransformworker.tsbuildinfo", + "incremental": true, + "composite": false }, "include": [ "../src/encodedtransformworker/**/*.ts" @@ -55,7 +58,7 @@ fs.unlinkSync(`${configDir}/${workerTsconfig}`); // Read all transpiled worker files and bundle them inline // Order matters: dependencies must come before classes that use them -const buildDir = './build/encodedtransformworker'; +const buildDir = './build/mediatransformworker/encodedtransformworker'; const workerFiles = [ 'EncodedTransform.js', 'RedundantAudioEncodedTransform.js', diff --git a/src/audiovideocontroller/AudioVideoController.ts b/src/audiovideocontroller/AudioVideoController.ts index aac9894571..b6a40e3d50 100644 --- a/src/audiovideocontroller/AudioVideoController.ts +++ b/src/audiovideocontroller/AudioVideoController.ts @@ -5,6 +5,7 @@ import ActiveSpeakerDetector from '../activespeakerdetector/ActiveSpeakerDetecto import AudioMixController from '../audiomixcontroller/AudioMixController'; import AudioVideoControllerFacade from '../audiovideocontroller/AudioVideoControllerFacade'; import AudioVideoObserver from '../audiovideoobserver/AudioVideoObserver'; +import { EncodedTransformMediaMetricsObserver } from '../encodedtransformmanager/MediaMetricsEncodedTransformManager'; import EventController from '../eventcontroller/EventController'; import Logger from '../logger/Logger'; import MediaStreamBroker from '../mediastreambroker/MediaStreamBroker'; @@ -16,7 +17,8 @@ import VideoTileController from '../videotilecontroller/VideoTileController'; /** * [[AudioVideoController]] manages the signaling and peer connections. */ -export default interface AudioVideoController extends AudioVideoControllerFacade { +export default interface AudioVideoController + extends AudioVideoControllerFacade, EncodedTransformMediaMetricsObserver { /** * Iterates through each observer, so that their notification functions may * be called. diff --git a/src/audiovideocontroller/AudioVideoControllerState.ts b/src/audiovideocontroller/AudioVideoControllerState.ts index 15024ed93b..1661d9f7c0 100644 --- a/src/audiovideocontroller/AudioVideoControllerState.ts +++ b/src/audiovideocontroller/AudioVideoControllerState.ts @@ -13,6 +13,7 @@ import MediaStreamBroker from '../mediastreambroker/MediaStreamBroker'; import MeetingSessionConfiguration from '../meetingsession/MeetingSessionConfiguration'; import MeetingSessionTURNCredentials from '../meetingsession/MeetingSessionTURNCredentials'; import MeetingSessionVideoAvailability from '../meetingsession/MeetingSessionVideoAvailability'; +import MeetingSessionTimingManager from '../meetingsessiontiming/MeetingSessionTimingManager'; import RealtimeController from '../realtimecontroller/RealtimeController'; import ReconnectController from '../reconnectcontroller/ReconnectController'; import RemovableObserver from '../removableobserver/RemovableObserver'; @@ -74,6 +75,8 @@ export default class AudioVideoControllerState { encodedTransformWorkerManager: EncodedTransformWorkerManager | null = null; + meetingSessionTimingManager: MeetingSessionTimingManager | null = null; + indexFrame: SdkIndexFrame | null = null; iceCandidates: RTCIceCandidate[] = []; diff --git a/src/audiovideocontroller/DefaultAudioVideoController.ts b/src/audiovideocontroller/DefaultAudioVideoController.ts index 118af231dc..5a2285cb3f 100644 --- a/src/audiovideocontroller/DefaultAudioVideoController.ts +++ b/src/audiovideocontroller/DefaultAudioVideoController.ts @@ -17,6 +17,7 @@ import VideoQualitySettings from '../devicecontroller/VideoQualitySettings'; import EncodedTransformWorkerManager, { EncodedTransformWorkerManagerObserver, } from '../encodedtransformmanager/EncodedTransformWorkerManager'; +import { EncodedTransformMediaMetricsObserver } from '../encodedtransformmanager/MediaMetricsEncodedTransformManager'; import AudioVideoEventAttributes, { audioVideoEventAttributesFromState, } from '../eventcontroller/AudioVideoEventAttributes'; @@ -28,6 +29,10 @@ import MeetingSessionConfiguration from '../meetingsession/MeetingSessionConfigu import MeetingSessionStatus from '../meetingsession/MeetingSessionStatus'; import MeetingSessionStatusCode from '../meetingsession/MeetingSessionStatusCode'; import MeetingSessionVideoAvailability from '../meetingsession/MeetingSessionVideoAvailability'; +import MeetingSessionTiming, { + MeetingSessionTimingObserver, +} from '../meetingsessiontiming/MeetingSessionTiming'; +import MeetingSessionTimingManager from '../meetingsessiontiming/MeetingSessionTimingManager'; import DefaultModality from '../modality/DefaultModality'; import DefaultPingPong from '../pingpong/DefaultPingPong'; import DefaultRealtimeController from '../realtimecontroller/DefaultRealtimeController'; @@ -91,7 +96,9 @@ import DefaultVideoStreamIdSet from '../videostreamidset/DefaultVideoStreamIdSet import DefaultVideoStreamIndex from '../videostreamindex/DefaultVideoStreamIndex'; import SimulcastVideoStreamIndex from '../videostreamindex/SimulcastVideoStreamIndex'; import DefaultVideoTileController from '../videotilecontroller/DefaultVideoTileController'; -import VideoTileController from '../videotilecontroller/VideoTileController'; +import VideoTileController, { + VideoTileResolutionObserver, +} from '../videotilecontroller/VideoTileController'; import DefaultVideoTileFactory from '../videotilefactory/DefaultVideoTileFactory'; import DefaultSimulcastUplinkPolicy from '../videouplinkbandwidthpolicy/DefaultSimulcastUplinkPolicy'; import NScaleVideoUplinkBandwidthPolicy from '../videouplinkbandwidthpolicy/NScaleVideoUplinkBandwidthPolicy'; @@ -107,6 +114,8 @@ export default class DefaultAudioVideoController SimulcastUplinkObserver, MediaStreamBrokerObserver, EncodedTransformWorkerManagerObserver, + EncodedTransformMediaMetricsObserver, + MeetingSessionTimingObserver, Destroyable { private _logger: Logger; @@ -121,6 +130,9 @@ export default class DefaultAudioVideoController private _eventController: EventController; private _audioProfile: AudioProfile = new AudioProfile(); private _encodedTransformWorkerManager: EncodedTransformWorkerManager; + private _meetingSessionTimingManager: MeetingSessionTimingManager; + + private _timingResolutionObserver: VideoTileResolutionObserver | null = null; private connectionHealthData = new ConnectionHealthData(); private observerQueue: Set = new Set(); @@ -175,6 +187,7 @@ export default class DefaultAudioVideoController this._configuration = configuration; this._encodedTransformWorkerManager = encodedTransformWorkerManager; + this._meetingSessionTimingManager = new MeetingSessionTimingManager(logger); this._webSocketAdapter = webSocketAdapter; this._realtimeController = new DefaultRealtimeController(mediaStreamBroker); @@ -220,6 +233,9 @@ export default class DefaultAudioVideoController async destroy(): Promise { this.observerQueue.clear(); this._mediaStreamBroker.removeMediaStreamBrokerObserver(this._audioMixController); + this._meetingSessionTimingManager.removeObserver(this); + this._meetingSessionTimingManager.destroy(); + this._encodedTransformWorkerManager?.removeObserver(this); this.destroyed = true; } @@ -332,8 +348,13 @@ export default class DefaultAudioVideoController this.meetingSessionContext.meetingSessionConfiguration = this.configuration; this.meetingSessionContext.signalingClient = new DefaultSignalingClient( this._webSocketAdapter, - this.logger + this.logger, + this._meetingSessionTimingManager ); + + this._meetingSessionTimingManager.reset(); + this.meetingSessionContext.meetingSessionTimingManager = this._meetingSessionTimingManager; + this._meetingSessionTimingManager.addObserver(this); } private uninstallPreStartObserver(): void { @@ -516,6 +537,13 @@ export default class DefaultAudioVideoController const useAudioConnection: boolean = !!this.configuration.urls.audioHostURL; + this._meetingSessionTimingManager.onStart(); + if (useAudioConnection) { + // Audio always negotiates sendrecv so local and remote are added together + this._meetingSessionTimingManager.onLocalAudioAdded(); + this._meetingSessionTimingManager.onRemoteAudioAdded(); + } + if (!useAudioConnection) { this.logger.info(`Using video only transceiver controller`); this.meetingSessionContext.transceiverController = new VideoOnlyTransceiverController( @@ -685,6 +713,25 @@ export default class DefaultAudioVideoController this.meetingSessionContext.statsCollector ); + this._timingResolutionObserver = { + videoTileResolutionDidChange: (): void => {}, + videoTileUnbound: (_attendeeId: string, groupId?: number): void => { + if (groupId !== undefined) { + this._meetingSessionTimingManager.onRemoteVideoUnbound(groupId); + } + }, + videoTileBound: (groupId: number): void => { + this._meetingSessionTimingManager.onRemoteVideoBound(groupId); + }, + videoTileFirstFrameDidRender: ( + groupId: number, + metadata?: VideoFrameCallbackMetadata + ): void => { + this._meetingSessionTimingManager.onRemoteVideoFirstFrameRendered(groupId, metadata); + }, + }; + this._videoTileController.registerVideoTileResolutionObserver(this._timingResolutionObserver); + if (!reconnecting) { this.meetingSessionContext.retryCount = 0; this._reconnectController.reset(); @@ -778,6 +825,7 @@ export default class DefaultAudioVideoController private actionFinishConnecting(): void { this.signalingTask = undefined; this.meetingSessionContext.videoDuplexMode = SdkStreamServiceType.RX; + if (!this.meetingSessionContext.enableSimulcast) { if (this.useUpdateTransceiverControllerForUplink) { this.meetingSessionContext.videoUplinkBandwidthPolicy.updateTransceiverController(); @@ -880,6 +928,10 @@ export default class DefaultAudioVideoController this._videoTileController.removeVideoTileResolutionObserver( this.meetingSessionContext.statsCollector ); + if (this._timingResolutionObserver) { + this._videoTileController.removeVideoTileResolutionObserver(this._timingResolutionObserver); + this._timingResolutionObserver = null; + } } update(options: { needsRenegotiation: boolean } = { needsRenegotiation: true }): boolean { @@ -1770,4 +1822,48 @@ export default class DefaultAudioVideoController const status = new MeetingSessionStatus(MeetingSessionStatusCode.EncodedTransformManagerFailed); this.reconnect(status, error); } + + // Routes first-packet events from the encoded transform layer into the timing manager. + onFirstPacketReceived( + mediaType: 'audio' | 'video', + direction: 'send' | 'receive', + ssrc: number + ): void { + if (mediaType === 'audio') { + if (direction === 'receive') { + this._meetingSessionTimingManager.onRemoteAudioFirstPacketReceived(); + } else if (direction === 'send') { + this._meetingSessionTimingManager.onLocalAudioFirstPacketSent(); + } + } else if (mediaType === 'video') { + if (direction === 'receive') { + /* istanbul ignore next */ + const streamId = this.meetingSessionContext.videoStreamIndex?.streamIdForSSRC(ssrc); + if (streamId === undefined) { + this.logger.warn(`Received first video packet but no stream ID found for ssrc=${ssrc}`); + return; + } + /* istanbul ignore next */ + const groupId = this.meetingSessionContext.videoStreamIndex?.groupIdForStreamId(streamId); + if (groupId === undefined) { + this.logger.warn( + `Received first video packet but no group ID found for streamId=${streamId} ssrc=${ssrc}` + ); + return; + } + this.logger.debug(`Received first video packet for groupId=${groupId} ssrc=${ssrc}`); + this._meetingSessionTimingManager.onRemoteVideoFirstPacketReceived(groupId); + } else if (direction === 'send') { + this._meetingSessionTimingManager.onLocalVideoFirstFrameSent(); + } + } + } + + onMeetingSessionTimingReady(timing: MeetingSessionTiming): void { + if (!this.meetingSessionContext.signalingClient) { + this.logger.warn('Timing data ready but signaling client is not available, discarding'); + return; + } + this.meetingSessionContext.signalingClient.sendMeetingSessionTiming(timing); + } } diff --git a/src/clientmetricreport/ClientMetricReport.ts b/src/clientmetricreport/ClientMetricReport.ts index 350b6c7641..0f285f05d6 100644 --- a/src/clientmetricreport/ClientMetricReport.ts +++ b/src/clientmetricreport/ClientMetricReport.ts @@ -481,6 +481,10 @@ export default class ClientMetricReport { transform: this.countPerSecond, type: SdkMetric.Type.VIDEO_RECEIVED_TRANSFORM_PPS, }, + videoRemoteRenderFps: { + transform: this.identityValue, + type: SdkMetric.Type.VIDEO_REMOTE_RENDER_FPS, + }, }; getMetricMap( diff --git a/src/encodedtransformmanager/MediaMetricsEncodedTransformManager.ts b/src/encodedtransformmanager/MediaMetricsEncodedTransformManager.ts index b3339dbbe3..fc4492b239 100644 --- a/src/encodedtransformmanager/MediaMetricsEncodedTransformManager.ts +++ b/src/encodedtransformmanager/MediaMetricsEncodedTransformManager.ts @@ -47,7 +47,16 @@ export interface EncodedTransformMediaMetricsObserver { /** * Called when new encoded transform media metrics become available. */ - encodedTransformMediaMetricsDidReceive(metrics: EncodedTransformMediaMetrics): void; + encodedTransformMediaMetricsDidReceive?(metrics: EncodedTransformMediaMetrics): void; + + /** + * Called when the first packet is seen for a new SSRC via encoded transform. + */ + onFirstPacketReceived?( + mediaType: 'audio' | 'video', + direction: 'send' | 'receive', + ssrc: number + ): void; } /** @@ -93,6 +102,46 @@ export default class MediaMetricsTransformManager extends EncodedTransformManage */ handleWorkerMessage(message: EncodedTransformMessage): void { if (message.type === MEDIA_METRICS_MESSAGE_TYPES.NEW_SSRC) { + /* istanbul ignore next */ + const ssrcStr = message.message?.ssrc; + if (!ssrcStr) { + this.logger.warn('Received NEW_SSRC message without ssrc, ignoring'); + return; + } + const ssrc = parseInt(ssrcStr, 10); + if (isNaN(ssrc)) { + this.logger.warn(`Received NEW_SSRC message with invalid ssrc: ${ssrcStr}, ignoring`); + return; + } + let mediaType: 'audio' | 'video' | undefined; + let direction: 'send' | 'receive' | undefined; + switch (message.transformName) { + case TRANSFORM_NAMES.AUDIO_SENDER: + mediaType = 'audio'; + direction = 'send'; + break; + case TRANSFORM_NAMES.AUDIO_RECEIVER: + mediaType = 'audio'; + direction = 'receive'; + break; + case TRANSFORM_NAMES.VIDEO_SENDER: + mediaType = 'video'; + direction = 'send'; + break; + case TRANSFORM_NAMES.VIDEO_RECEIVER: + mediaType = 'video'; + direction = 'receive'; + break; + } + if (mediaType && direction) { + for (const observer of this.observers) { + try { + observer.onFirstPacketReceived?.(mediaType, direction, ssrc); + } catch (e) { + this.logger.error(`Error notifying first packet observer: ${e}`); + } + } + } return; } @@ -161,7 +210,7 @@ export default class MediaMetricsTransformManager extends EncodedTransformManage for (const observer of this.observers) { try { - observer.encodedTransformMediaMetricsDidReceive(metrics); + observer.encodedTransformMediaMetricsDidReceive?.(metrics); } catch (e) { this.logger.error(`Error notifying media metrics observer: ${e}`); } diff --git a/src/index.ts b/src/index.ts index 8176e3383f..01d405b447 100644 --- a/src/index.ts +++ b/src/index.ts @@ -175,6 +175,8 @@ import MeetingSessionLifecycleEventCondition from './meetingsession/MeetingSessi import MeetingSessionStatus from './meetingsession/MeetingSessionStatus'; import MeetingSessionStatusCode from './meetingsession/MeetingSessionStatusCode'; import MeetingSessionTURNCredentials from './meetingsession/MeetingSessionTURNCredentials'; +import MeetingSessionTiming from './meetingsessiontiming/MeetingSessionTiming'; +import MeetingSessionTimingManager from './meetingsessiontiming/MeetingSessionTimingManager'; import MeetingSessionURLs from './meetingsession/MeetingSessionURLs'; import MeetingSessionVideoAvailability from './meetingsession/MeetingSessionVideoAvailability'; import Message from './message/Message'; @@ -297,6 +299,7 @@ import VideoCodecCapability from './sdp/VideoCodecCapability'; import VideoDownlinkBandwidthPolicy from './videodownlinkbandwidthpolicy/VideoDownlinkBandwidthPolicy'; import VideoDownlinkObserver from './videodownlinkbandwidthpolicy/VideoDownlinkObserver'; import VideoElementFactory from './videoelementfactory/VideoElementFactory'; +import VideoElementFrameMonitor from './videotile/VideoElementFrameMonitor'; import VideoElementResolutionMonitor from './videotile/VideoElementResolutionMonitor'; import VideoEncodingConcurrentSendersHealthPolicy from './connectionhealthpolicy/VideoEncodingConcurrentSendersHealthPolicy'; import VideoEncodingConnectionHealthPolicyName from './connectionhealthpolicy/VideoEncodingConnectionHealthPolicyName'; @@ -349,6 +352,7 @@ import WaitForAttendeePresenceTask from './task/WaitForAttendeePresenceTask'; import WebSocketAdapter from './websocketadapter/WebSocketAdapter'; import WebSocketReadyState from './websocketadapter/WebSocketReadyState'; import ZLIBTextCompressor from './sdp/ZLIBTextCompressor'; +import { MeetingSessionSignalingTiming, MeetingSessionRemoteAudioTiming, MeetingSessionLocalAudioTiming, MeetingSessionLocalVideoTiming, MeetingSessionRemoteVideoTiming, MeetingSessionTimingObserver } from './meetingsessiontiming/MeetingSessionTiming'; import { MutableVideoPreferences } from './videodownlinkbandwidthpolicy/VideoPreferences'; import { Some, None, Maybe, MaybeProvider, Eq, PartialOrd } from './utils/Types'; import { isAudioTransformDevice } from './devicecontroller/AudioTransformDevice'; @@ -533,9 +537,17 @@ export { MeetingSessionCredentials, MeetingSessionLifecycleEvent, MeetingSessionLifecycleEventCondition, + MeetingSessionLocalAudioTiming, + MeetingSessionLocalVideoTiming, + MeetingSessionRemoteAudioTiming, + MeetingSessionRemoteVideoTiming, + MeetingSessionSignalingTiming, MeetingSessionStatus, MeetingSessionStatusCode, MeetingSessionTURNCredentials, + MeetingSessionTiming, + MeetingSessionTimingManager, + MeetingSessionTimingObserver, MeetingSessionURLs, MeetingSessionVideoAvailability, Message, @@ -662,6 +674,7 @@ export { VideoDownlinkBandwidthPolicy, VideoDownlinkObserver, VideoElementFactory, + VideoElementFrameMonitor, VideoElementResolutionMonitor, VideoEncodingConcurrentSendersHealthPolicy, VideoEncodingConnectionHealthPolicyName, diff --git a/src/meetingsessiontiming/MeetingSessionTiming.ts b/src/meetingsessiontiming/MeetingSessionTiming.ts new file mode 100644 index 0000000000..1fd529788b --- /dev/null +++ b/src/meetingsessiontiming/MeetingSessionTiming.ts @@ -0,0 +1,85 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Timing data for signaling and connection establishment. + * All timestamps are in milliseconds since epoch (undefined if not measured). + */ +export interface MeetingSessionSignalingTiming { + startMs?: number; + joinSentMs?: number; + joinAckReceivedMs?: number; + transportConnectedMs?: number; + createOfferMs?: number; + setLocalDescriptionMs?: number; + setRemoteDescriptionMs?: number; + iceGatheringStartMs?: number; + iceGatheringCompleteMs?: number; + iceConnectedMs?: number; + subscribeSentMs?: number; + subscribeAckMs?: number; + timedOut?: boolean; +} + +/** + * Timing data for remote audio receive path. + */ +export interface MeetingSessionRemoteAudioTiming { + addedMs?: number; + firstPacketReceivedMs?: number; + firstFrameRenderedMs?: number; + timedOut?: boolean; + removed?: boolean; +} + +/** + * Timing data for local audio send path. + */ +export interface MeetingSessionLocalAudioTiming { + addedMs?: number; + firstFrameCapturedMs?: number; + firstPacketSentMs?: number; + timedOut?: boolean; + removed?: boolean; +} + +/** + * Timing data for local video send path. + */ +export interface MeetingSessionLocalVideoTiming { + addedMs?: number; + firstFrameCapturedMs?: number; + firstFrameSentMs?: number; + timedOut?: boolean; + removed?: boolean; +} + +/** + * Per-group timing data for remote video receive path. + */ +export interface MeetingSessionRemoteVideoTiming { + groupId?: number; + addedMs?: number; + firstPacketReceivedMs?: number; + firstFrameRenderedMs?: number; + timedOut?: boolean; + removed?: boolean; +} + +/** + * Contains all collected timing data from the timing manager. + */ +export default interface MeetingSessionTiming { + signaling: MeetingSessionSignalingTiming[]; + remoteAudio: MeetingSessionRemoteAudioTiming[]; + localAudio: MeetingSessionLocalAudioTiming[]; + localVideo: MeetingSessionLocalVideoTiming[]; + remoteVideos: MeetingSessionRemoteVideoTiming[]; +} + +/** + * Observer interface for receiving meeting session timing data. + */ +export interface MeetingSessionTimingObserver { + onMeetingSessionTimingReady(timing: MeetingSessionTiming): void; +} diff --git a/src/meetingsessiontiming/MeetingSessionTimingManager.ts b/src/meetingsessiontiming/MeetingSessionTimingManager.ts new file mode 100644 index 0000000000..26f5001a0b --- /dev/null +++ b/src/meetingsessiontiming/MeetingSessionTimingManager.ts @@ -0,0 +1,806 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import Logger from '../logger/Logger'; +import MeetingSessionTiming, { + MeetingSessionLocalAudioTiming, + MeetingSessionLocalVideoTiming, + MeetingSessionRemoteAudioTiming, + MeetingSessionRemoteVideoTiming, + MeetingSessionSignalingTiming, + MeetingSessionTimingObserver, +} from './MeetingSessionTiming'; + +/** + * MeetingSessionTimingManager tracks all lifecycle timestamps for a meeting session + * and emits them in batches via the observer. + * + * A batch begins when the first event is recorded (e.g. onStart, onRemoteVideoAdded) + * and completes when all tracked categories have reached their terminal state + * (e.g. signaling fully connected, audio first packet received, video first frame + * rendered). If a batch does not complete within TIMEOUT_THRESHOLD_MS (15 s), + * it is emitted with per-category timedOut flags. + * + * After each emission, already-reported state is cleared so that subsequent events + * (e.g. a mid-call remote video add) trigger a new batch containing only new data. + * + * Categories are only included in a batch if their corresponding on*Added method + * was called. For example, if local video is never started, the batch will not + * wait for local video timing. Remote video entries that were never bound to a + * video element are silently omitted. + */ +export default class MeetingSessionTimingManager { + private static readonly TIMEOUT_THRESHOLD_MS = 15000; + + private observers = new Set(); + private logger: Logger; + private batchTimeout: ReturnType | null = null; + + private signalingTiming: MeetingSessionSignalingTiming = {}; + private isResubscribe: boolean = false; + private remoteAudioTiming: MeetingSessionRemoteAudioTiming = {}; + private localAudioTiming: MeetingSessionLocalAudioTiming = {}; + private localVideoTiming: MeetingSessionLocalVideoTiming = {}; + private localVideoHasEmitted: boolean = false; + private expectingRemoteVideo: boolean = false; + private remoteVideoTiming: Map = new Map(); + private boundRemoteVideoGroupIds: Set = new Set(); + + constructor(logger: Logger) { + this.logger = logger; + } + + /** + * Adds an observer to receive timing data notifications. + */ + addObserver(observer: MeetingSessionTimingObserver): void { + this.observers.add(observer); + } + + removeObserver(observer: MeetingSessionTimingObserver): void { + this.observers.delete(observer); + } + + /** + * Starts the batch timer if not already started. + * Called by onStart() and any on*Added() method. + */ + private startBatchIfNeeded(): void { + if (this.batchTimeout === null) { + this.scheduleBatchTimeout(); + } + } + + /** + * Records the timestamp when audioVideo.start() was called. + */ + onStart(): void { + if (this.signalingTiming.startMs !== undefined) { + this.logger.debug('onStart called multiple times, ignoring'); + return; + } + this.signalingTiming.startMs = this.getCurrentTimestamp(); + this.startBatchIfNeeded(); + this.logger.info(`MeetingSessionTimingManager: onStart at ${this.signalingTiming.startMs}`); + } + + /** + * Starts a resubscribe signaling timing entry. + * Used for mid-meeting resubscribes (e.g. new remote video joins) to capture + * the resubscribe latency (subscribe sent → ack → set remote description). + * Only the resubscribe-relevant signaling fields are required for completion. + */ + onResubscribeStart(): void { + if (this.signalingTiming.startMs !== undefined) { + this.logger.debug('onResubscribeStart: signaling timing already active, ignoring'); + return; + } + this.isResubscribe = true; + this.signalingTiming.startMs = this.getCurrentTimestamp(); + this.startBatchIfNeeded(); + this.logger.debug( + `MeetingSessionTimingManager: resubscribe started at ${this.signalingTiming.startMs}` + ); + } + + /** + * Indicates that remote video is expected in the current batch. + * The batch will not complete until at least one remote video entry + * has been added and completed (or the batch times out). + * + * This method exists because the SDK's initial subscribe does not include + * remote video — index ingestion is paused during the first subscribe, + * so the downlink policy cannot select video streams until the connection + * is established and a second subscribe (resubscribe) is triggered. + */ + setExpectingRemoteVideo(): void { + if (this.expectingRemoteVideo) { + return; + } + this.expectingRemoteVideo = true; + this.logger.debug('MeetingSessionTimingManager: expecting remote video in current batch'); + } + + /** + * Clears the expectation that remote video will be part of the current batch. + * Called when the downlink policy decides not to subscribe to any video, + * so the batch is not held open waiting for remote video that will never arrive. + */ + clearExpectingRemoteVideo(): void { + if (!this.expectingRemoteVideo) { + return; + } + this.expectingRemoteVideo = false; + this.logger.debug('MeetingSessionTimingManager: no longer expecting remote video'); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when join frame was sent. + */ + onJoinSent(): void { + if (this.signalingTiming.startMs === undefined) { + this.logger.debug('Received join sent event after initial batch was sent, ignoring'); + return; + } + if (this.signalingTiming.joinSentMs !== undefined) { + this.logger.debug('onJoinSent called multiple times, ignoring'); + return; + } + this.signalingTiming.joinSentMs = this.getCurrentTimestamp(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when join ack was received. + */ + onJoinAckReceived(): void { + if (this.signalingTiming.startMs === undefined) { + this.logger.debug('Received join ack event after initial batch was sent, ignoring'); + return; + } + if (this.signalingTiming.joinAckReceivedMs !== undefined) { + this.logger.debug('onJoinAckReceived called multiple times, ignoring'); + return; + } + this.signalingTiming.joinAckReceivedMs = this.getCurrentTimestamp(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when signaling WebSocket connection was established. + */ + onTransportConnected(): void { + if (this.signalingTiming.startMs === undefined) { + this.logger.debug( + 'Received transport connected event after initial batch was sent, ignoring' + ); + return; + } + if (this.signalingTiming.transportConnectedMs !== undefined) { + this.logger.debug('onTransportConnected called multiple times, ignoring'); + return; + } + this.signalingTiming.transportConnectedMs = this.getCurrentTimestamp(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when SDP offer was created. + */ + onCreateOfferCalled(): void { + if (this.signalingTiming.startMs === undefined) { + this.logger.debug('Received create offer event after initial batch was sent, ignoring'); + return; + } + if (this.signalingTiming.createOfferMs !== undefined) { + this.logger.debug('onCreateOfferCalled called multiple times, ignoring'); + return; + } + this.signalingTiming.createOfferMs = this.getCurrentTimestamp(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when local description was set. + */ + onSetLocalDescription(): void { + if (this.signalingTiming.startMs === undefined) { + this.logger.debug( + 'Received set local description event after initial batch was sent, ignoring' + ); + return; + } + if (this.signalingTiming.setLocalDescriptionMs !== undefined) { + this.logger.debug('onSetLocalDescription called multiple times, ignoring'); + return; + } + this.signalingTiming.setLocalDescriptionMs = this.getCurrentTimestamp(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when remote description was set. + */ + onSetRemoteDescription(): void { + if (this.signalingTiming.startMs === undefined) { + this.logger.debug( + 'Received set remote description event after initial batch was sent, ignoring' + ); + return; + } + if (this.signalingTiming.setRemoteDescriptionMs !== undefined) { + this.logger.debug('onSetRemoteDescription called multiple times, ignoring'); + return; + } + this.signalingTiming.setRemoteDescriptionMs = this.getCurrentTimestamp(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when ICE gathering started. + */ + onIceGatheringStarted(): void { + if (this.signalingTiming.startMs === undefined) { + this.logger.debug( + 'Received ICE gathering started event after initial batch was sent, ignoring' + ); + return; + } + if (this.signalingTiming.iceGatheringStartMs !== undefined) { + this.logger.debug('onIceGatheringStarted called multiple times, ignoring'); + return; + } + this.signalingTiming.iceGatheringStartMs = this.getCurrentTimestamp(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when ICE gathering completed. + */ + onIceGatheringComplete(): void { + if (this.signalingTiming.startMs === undefined) { + this.logger.debug( + 'Received ICE gathering complete event after initial batch was sent, ignoring' + ); + return; + } + if (this.signalingTiming.iceGatheringCompleteMs !== undefined) { + this.logger.debug('onIceGatheringComplete called multiple times, ignoring'); + return; + } + this.signalingTiming.iceGatheringCompleteMs = this.getCurrentTimestamp(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when ICE connection was established. + */ + onIceConnected(): void { + if (this.signalingTiming.startMs === undefined) { + this.logger.debug('Received ICE connected event after initial batch was sent, ignoring'); + return; + } + if (this.signalingTiming.iceConnectedMs !== undefined) { + this.logger.debug('onIceConnected called multiple times, ignoring'); + return; + } + this.signalingTiming.iceConnectedMs = this.getCurrentTimestamp(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when subscribe frame was sent. + */ + onSubscribeSent(): void { + if (this.signalingTiming.startMs === undefined) { + this.logger.debug('Received subscribe sent event after initial batch was sent, ignoring'); + return; + } + if (this.signalingTiming.subscribeSentMs !== undefined) { + this.logger.debug('onSubscribeSent called multiple times, ignoring'); + return; + } + this.signalingTiming.subscribeSentMs = this.getCurrentTimestamp(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when subscribe ack was received. + */ + onSubscribeAckReceived(): void { + if (this.signalingTiming.startMs === undefined) { + this.logger.debug('Received subscribe ack event after initial batch was sent, ignoring'); + return; + } + if (this.signalingTiming.subscribeAckMs !== undefined) { + this.logger.debug('onSubscribeAckReceived called multiple times, ignoring'); + return; + } + this.signalingTiming.subscribeAckMs = this.getCurrentTimestamp(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when remote audio track was added. + */ + onRemoteAudioAdded(): void { + if (this.remoteAudioTiming.addedMs !== undefined) { + this.logger.debug('onRemoteAudioAdded called multiple times, ignoring'); + return; + } + this.remoteAudioTiming.addedMs = this.getCurrentTimestamp(); + this.startBatchIfNeeded(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when first audio packet was received. + */ + onRemoteAudioFirstPacketReceived(): void { + if (this.remoteAudioTiming.addedMs === undefined) { + this.logger.warn('Received remote audio first packet before audio was added, ignoring'); + return; + } + if (this.remoteAudioTiming.firstPacketReceivedMs !== undefined) { + this.logger.debug('onRemoteAudioFirstPacketReceived called multiple times, ignoring'); + return; + } + this.remoteAudioTiming.firstPacketReceivedMs = this.getCurrentTimestamp(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when local audio track was added. + */ + onLocalAudioAdded(): void { + if (this.localAudioTiming.addedMs !== undefined) { + this.logger.debug('onLocalAudioAdded called multiple times, ignoring'); + return; + } + this.localAudioTiming.addedMs = this.getCurrentTimestamp(); + this.startBatchIfNeeded(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when first audio packet was sent. + */ + onLocalAudioFirstPacketSent(): void { + if (this.localAudioTiming.addedMs === undefined) { + this.logger.warn('Received local audio first packet sent before audio was added, ignoring'); + return; + } + if (this.localAudioTiming.firstPacketSentMs !== undefined) { + this.logger.debug('onLocalAudioFirstPacketSent called multiple times, ignoring'); + return; + } + this.localAudioTiming.firstPacketSentMs = this.getCurrentTimestamp(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when local video track was added. + */ + onLocalVideoAdded(): void { + if (this.localVideoTiming.addedMs !== undefined || this.localVideoHasEmitted) { + this.logger.debug('onLocalVideoAdded called multiple times, ignoring'); + return; + } + this.localVideoTiming.addedMs = this.getCurrentTimestamp(); + this.startBatchIfNeeded(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when first video frame was sent. + */ + onLocalVideoFirstFrameSent(): void { + if (this.localVideoTiming.addedMs === undefined) { + this.logger.warn('Received local video first frame sent before video was added, ignoring'); + return; + } + if (this.localVideoTiming.firstFrameSentMs !== undefined) { + this.logger.debug('onLocalVideoFirstFrameSent called multiple times, ignoring'); + return; + } + this.localVideoTiming.firstFrameSentMs = this.getCurrentTimestamp(); + this.maybeEmitBatch(); + } + + /** + * Marks local video timing as removed and triggers batch completion check. + * This allows the timing data to be emitted with the removed flag. + */ + onLocalVideoRemoved(): void { + if (this.localVideoTiming.addedMs === undefined) { + this.logger.debug('onLocalVideoRemoved called without prior add, ignoring'); + return; + } + this.localVideoTiming.removed = true; + this.localVideoHasEmitted = false; + this.logger.debug('Local video timing marked as removed'); + this.maybeEmitBatch(); + } + + /** + * Starts tracking timing for a remote video subscription. + * Records the added timestamp for the given group_id. + * If a timer already exists for this group_id, it is replaced. + * + * @param groupId The group ID of the remote video subscription + */ + onRemoteVideoAdded(groupId: number): void { + this.remoteVideoTiming.set(groupId, { + addedMs: this.getCurrentTimestamp(), + }); + this.startBatchIfNeeded(); + this.logger.debug(`Remote video timer started for group_id=${groupId}`); + } + + /** + * Records that a remote video tile has been bound to a video element. + * Only bound remote videos are included in timing emissions. + * Unbound remote videos are silently omitted from the batch. + * + * @param groupId The group ID of the remote video subscription + */ + onRemoteVideoBound(groupId: number): void { + this.boundRemoteVideoGroupIds.add(groupId); + } + + /** + * Records that a remote video tile has been unbound from its video element. + * The group ID is removed from the bound set so it no longer blocks batch emission. + * + * @param groupId The group ID of the remote video subscription + */ + onRemoteVideoUnbound(groupId: number): void { + this.boundRemoteVideoGroupIds.delete(groupId); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when first video packet was received for a group_id. + * Only the first call for each group_id records the timestamp. + * @param groupId The group ID of the remote video subscription + */ + onRemoteVideoFirstPacketReceived(groupId: number): void { + const state = this.remoteVideoTiming.get(groupId); + if (!state) { + this.logger.warn(`onRemoteVideoFirstPacketReceived: No timer found for group_id=${groupId}`); + return; + } + if (state.firstPacketReceivedMs !== undefined) { + this.logger.debug( + `onRemoteVideoFirstPacketReceived called multiple times for group_id=${groupId}, ignoring` + ); + return; + } + state.firstPacketReceivedMs = this.getCurrentTimestamp(); + this.maybeEmitBatch(); + } + + /** + * Records the timestamp when first video frame was rendered for a group_id. + * @param groupId The group ID of the remote video subscription + * @param metadata The VideoFrameCallbackMetadata from requestVideoFrameCallback, if available + */ + onRemoteVideoFirstFrameRendered(groupId: number, metadata?: VideoFrameCallbackMetadata): void { + const state = this.remoteVideoTiming.get(groupId); + if (!state) { + this.logger.warn(`onRemoteVideoFirstFrameRendered: No timer found for group_id=${groupId}`); + return; + } + if (state.firstFrameRenderedMs !== undefined) { + this.logger.debug( + `onRemoteVideoFirstFrameRendered called multiple times for group_id=${groupId}, ignoring` + ); + return; + } + // Use expectedDisplayTime from RVFC metadata when available for a more + // accurate render timestamp; fall back to Date.now(). + state.firstFrameRenderedMs = + metadata?.expectedDisplayTime !== undefined + ? Math.round(performance.timeOrigin + metadata.expectedDisplayTime) + : this.getCurrentTimestamp(); + this.maybeEmitBatch(); + } + + /** + * Marks timing state for a remote video subscription as removed. + * The timing data will be emitted with the removed flag. + * @param groupId The group ID of the remote video subscription + */ + onRemoteVideoRemoved(groupId: number): void { + const state = this.remoteVideoTiming.get(groupId); + if (state) { + state.removed = true; + this.logger.debug(`Remote video timing marked as removed for group_id=${groupId}`); + this.maybeEmitBatch(); + } + } + + /** + * Clears all timing state and resets the manager for a new session. + * This should be called when starting a new meeting session. + */ + reset(): void { + this.cancelBatchTimeout(); + + this.signalingTiming = {}; + this.isResubscribe = false; + this.remoteAudioTiming = {}; + this.localAudioTiming = {}; + this.localVideoTiming = {}; + this.localVideoHasEmitted = false; + this.expectingRemoteVideo = false; + this.remoteVideoTiming.clear(); + this.boundRemoteVideoGroupIds.clear(); + + this.logger.info('MeetingSessionTimingManager: reset'); + } + + /** + * Stops the timeout check interval and cleans up resources. + * This should be called when the meeting session ends. + */ + destroy(): void { + this.cancelBatchTimeout(); + this.observers.clear(); + this.logger.info('MeetingSessionTimingManager: destroyed'); + } + + /** + * Returns the current timestamp in milliseconds since epoch. + */ + private getCurrentTimestamp(): number { + return Date.now(); + } + + /** + * Checks if signaling timing is complete. + * Complete when all signaling timestamps are set. + */ + private isSignalingComplete(): boolean { + const s = this.signalingTiming; + const resubscribeComplete = + s.createOfferMs !== undefined && + s.setLocalDescriptionMs !== undefined && + s.subscribeSentMs !== undefined && + s.subscribeAckMs !== undefined && + s.setRemoteDescriptionMs !== undefined; + /* istanbul ignore next */ + if (this.isResubscribe) { + /* istanbul ignore next */ + return resubscribeComplete; + } + return ( + resubscribeComplete && + s.joinSentMs !== undefined && + s.joinAckReceivedMs !== undefined && + s.transportConnectedMs !== undefined && + s.iceGatheringStartMs !== undefined && + s.iceGatheringCompleteMs !== undefined && + s.iceConnectedMs !== undefined + ); + } + + /** + * Checks if remote audio timing is complete. + * Only checks added_ms && first_packet_received_ms. + * first_frame_rendered_ms is optional (not required for completion). + */ + private isRemoteAudioComplete(): boolean { + return ( + this.remoteAudioTiming.removed === true || + (this.remoteAudioTiming.addedMs !== undefined && + this.remoteAudioTiming.firstPacketReceivedMs !== undefined) + ); + } + + /** + * Checks if local audio timing is complete. + * Only checks added_ms && first_packet_sent_ms. + * first_frame_captured_ms is optional (not required for completion). + */ + private isLocalAudioComplete(): boolean { + return ( + this.localAudioTiming.removed === true || + (this.localAudioTiming.addedMs !== undefined && + this.localAudioTiming.firstPacketSentMs !== undefined) + ); + } + + /** + * Checks if local video timing is complete. + * Only checks added_ms && first_frame_sent_ms. + * first_frame_captured_ms is optional. + */ + private isLocalVideoComplete(): boolean { + return ( + this.localVideoTiming.removed === true || + (this.localVideoTiming.addedMs !== undefined && + this.localVideoTiming.firstFrameSentMs !== undefined) + ); + } + + /** + * Checks if a remote video timing entry is complete. + * Complete when all three timestamps are set, or when removed. + */ + private isRemoteVideoComplete(state: MeetingSessionRemoteVideoTiming): boolean { + return ( + state.removed === true || + (state.addedMs !== undefined && state.firstFrameRenderedMs !== undefined) + ); + } + + /** + * Checks if all batch timings are complete. + * Each category is only required if its corresponding on*Added was called. + */ + private areAllBatchTimingsComplete(): boolean { + if (this.signalingTiming.startMs !== undefined && !this.isSignalingComplete()) { + return false; + } + if (this.remoteAudioTiming.addedMs !== undefined && !this.isRemoteAudioComplete()) { + return false; + } + if (this.localAudioTiming.addedMs !== undefined && !this.isLocalAudioComplete()) { + return false; + } + if (this.localVideoTiming.addedMs !== undefined && !this.isLocalVideoComplete()) { + return false; + } + // If we're expecting remote video, hold the batch until at least one + // bound remote video entry has completed. + if (this.expectingRemoteVideo) { + let hasBoundComplete = false; + for (const [groupId, state] of this.remoteVideoTiming) { + if (this.boundRemoteVideoGroupIds.has(groupId) && this.isRemoteVideoComplete(state)) { + hasBoundComplete = true; + break; + } + } + if (!hasBoundComplete) { + return false; + } + } + for (const [groupId, state] of this.remoteVideoTiming) { + if (!this.boundRemoteVideoGroupIds.has(groupId)) { + continue; + } + if (!this.isRemoteVideoComplete(state)) { + return false; + } + } + return true; + } + + /** + * Checks if the batch is complete and emits if so. + */ + private maybeEmitBatch(): void { + if (this.areAllBatchTimingsComplete()) { + this.emitAndReset(false); + } + } + + /** + * Builds and notifies the observer with timing data, then clears + * the reported state so future events start a fresh batch. + */ + private emitAndReset(batchTimedOut: boolean): void { + const timing = this.buildMeetingSessionTiming(batchTimedOut); + + // Track that local video has been emitted so resubscribes don't re-add it + if ( + this.localVideoTiming.addedMs !== undefined && + this.isLocalVideoComplete() && + !this.localVideoTiming.removed + ) { + this.localVideoHasEmitted = true; + } + + // Clear everything that was just reported + this.clearReportedState(); + + if (this.observers.size === 0) { + this.logger.warn('MeetingSessionTimingManager: No observers set, timing data discarded'); + return; + } + + for (const observer of this.observers) { + try { + observer.onMeetingSessionTimingReady(timing); + } catch (error) { + this.logger.error(`MeetingSessionTimingManager: Error notifying observer: ${error}`); + } + } + } + + /** + * Clears state that was included in the last emission so it is not re-sent. + * Resets the batch timer so new events can start a fresh batch. + */ + private clearReportedState(): void { + this.cancelBatchTimeout(); + this.signalingTiming = {}; + this.isResubscribe = false; + this.remoteAudioTiming = {}; + this.localAudioTiming = {}; + this.localVideoTiming = {}; + this.expectingRemoteVideo = false; + this.remoteVideoTiming.clear(); + this.boundRemoteVideoGroupIds.clear(); + } + + /** + * Builds the MeetingSessionTiming structure from current state. + * Per-struct timedOut flags: a struct is timed out if the batch timed out AND that struct is not complete. + * @param batchTimedOut Whether the batch timed out + */ + private buildMeetingSessionTiming(batchTimedOut: boolean): MeetingSessionTiming { + const signaling: MeetingSessionSignalingTiming[] = []; + const remoteAudio: MeetingSessionRemoteAudioTiming[] = []; + const localAudio: MeetingSessionLocalAudioTiming[] = []; + const localVideo: MeetingSessionLocalVideoTiming[] = []; + const remoteVideos: MeetingSessionRemoteVideoTiming[] = []; + + if (this.signalingTiming.startMs !== undefined) { + signaling.push({ + ...this.signalingTiming, + timedOut: batchTimedOut && !this.isSignalingComplete(), + }); + } + + if (this.remoteAudioTiming.addedMs !== undefined) { + remoteAudio.push({ + ...this.remoteAudioTiming, + timedOut: batchTimedOut && !this.isRemoteAudioComplete(), + }); + } + + if (this.localAudioTiming.addedMs !== undefined) { + localAudio.push({ + ...this.localAudioTiming, + timedOut: batchTimedOut && !this.isLocalAudioComplete(), + }); + } + + if (this.localVideoTiming.addedMs !== undefined) { + localVideo.push({ + ...this.localVideoTiming, + timedOut: batchTimedOut && !this.isLocalVideoComplete(), + }); + } + + for (const [groupId, state] of this.remoteVideoTiming) { + if (state.addedMs !== undefined && this.boundRemoteVideoGroupIds.has(groupId)) { + remoteVideos.push({ + ...state, + groupId, + timedOut: batchTimedOut && !this.isRemoteVideoComplete(state), + }); + } + } + + return { signaling, remoteAudio, localAudio, localVideo, remoteVideos }; + } + + private scheduleBatchTimeout(): void { + this.batchTimeout = setTimeout(() => { + this.batchTimeout = null; + this.logger.warn('Batch timing timeout'); + this.emitAndReset(true); + }, MeetingSessionTimingManager.TIMEOUT_THRESHOLD_MS); + } + + private cancelBatchTimeout(): void { + if (this.batchTimeout !== null) { + clearTimeout(this.batchTimeout); + this.batchTimeout = null; + } + } +} diff --git a/src/signalingclient/DefaultSignalingClient.ts b/src/signalingclient/DefaultSignalingClient.ts index 806ec5ad0a..bba9afb225 100644 --- a/src/signalingclient/DefaultSignalingClient.ts +++ b/src/signalingclient/DefaultSignalingClient.ts @@ -4,6 +4,8 @@ import { MeetingSessionCredentials } from '..'; import DefaultBrowserBehavior from '../browserbehavior/DefaultBrowserBehavior'; import Logger from '../logger/Logger'; +import MeetingSessionTiming from '../meetingsessiontiming/MeetingSessionTiming'; +import MeetingSessionTimingManager from '../meetingsessiontiming/MeetingSessionTimingManager'; import TimeoutScheduler from '../scheduler/TimeoutScheduler'; import SignalingClientObserver from '../signalingclientobserver/SignalingClientObserver'; import { @@ -16,6 +18,12 @@ import { SdkJoinFrame, SdkLeaveFrame, SdkMeetingSessionCredentials, + SdkMeetingSessionLocalAudioTiming, + SdkMeetingSessionLocalVideoTiming, + SdkMeetingSessionRemoteAudioTiming, + SdkMeetingSessionRemoteVideoTiming, + SdkMeetingSessionSignalingTiming, + SdkMeetingSessionTimingFrame, SdkPauseResumeFrame, SdkPingPongFrame, SdkPrimaryMeetingJoinFrame, @@ -59,7 +67,8 @@ export default class DefaultSignalingClient implements SignalingClient { constructor( private webSocket: WebSocketAdapter, - private logger: Logger + private logger: Logger, + private meetingSessionTimingManager?: MeetingSessionTimingManager ) { this.observerQueue = new Set(); this.connectionRequestQueue = []; @@ -137,6 +146,7 @@ export default class DefaultSignalingClient implements SignalingClient { message.type = SdkSignalFrame.Type.JOIN; message.join = joinFrame; this.sendMessage(message); + this.meetingSessionTimingManager?.onJoinSent(); } subscribe(settings: SignalingClientSubscribe): void { @@ -187,6 +197,7 @@ export default class DefaultSignalingClient implements SignalingClient { message.type = SdkSignalFrame.Type.SUBSCRIBE; message.sub = subscribeFrame; this.sendMessage(message); + this.meetingSessionTimingManager?.onSubscribeSent(); } remoteVideoUpdate( @@ -446,6 +457,7 @@ export default class DefaultSignalingClient implements SignalingClient { this.webSocket.addEventListener('open', () => { this.wasOpened = true; this.sendEvent(new SignalingClientEvent(this, SignalingClientEventType.WebSocketOpen, null)); + this.meetingSessionTimingManager?.onTransportConnected(); }); this.webSocket.addEventListener('message', (event: MessageEvent) => { this.sendEvent( @@ -494,6 +506,70 @@ export default class DefaultSignalingClient implements SignalingClient { this.serviceConnectionRequestQueue(); }; + sendMeetingSessionTiming(timing: MeetingSessionTiming): void { + const timingFrame = SdkMeetingSessionTimingFrame.create(); + + timingFrame.signaling = timing.signaling.map(s => { + return SdkMeetingSessionSignalingTiming.create({ + startMs: s.startMs, + joinSentMs: s.joinSentMs, + joinAckReceivedMs: s.joinAckReceivedMs, + transportConnectedMs: s.transportConnectedMs, + createOfferMs: s.createOfferMs, + setLocalDescriptionMs: s.setLocalDescriptionMs, + setRemoteDescriptionMs: s.setRemoteDescriptionMs, + iceGatheringStartMs: s.iceGatheringStartMs, + iceGatheringCompleteMs: s.iceGatheringCompleteMs, + iceConnectedMs: s.iceConnectedMs, + subscribeSentMs: s.subscribeSentMs, + subscribeAckMs: s.subscribeAckMs, + timedOut: s.timedOut, + }); + }); + timingFrame.remoteAudio = timing.remoteAudio.map(ra => { + return SdkMeetingSessionRemoteAudioTiming.create({ + addedMs: ra.addedMs, + firstPacketReceivedMs: ra.firstPacketReceivedMs, + firstFrameRenderedMs: ra.firstFrameRenderedMs, + timedOut: ra.timedOut, + removed: ra.removed, + }); + }); + timingFrame.localAudio = timing.localAudio.map(la => { + return SdkMeetingSessionLocalAudioTiming.create({ + addedMs: la.addedMs, + firstFrameCapturedMs: la.firstFrameCapturedMs, + firstPacketSentMs: la.firstPacketSentMs, + timedOut: la.timedOut, + removed: la.removed, + }); + }); + timingFrame.localVideo = timing.localVideo.map(lv => { + return SdkMeetingSessionLocalVideoTiming.create({ + addedMs: lv.addedMs, + firstFrameCapturedMs: lv.firstFrameCapturedMs, + firstFrameSentMs: lv.firstFrameSentMs, + timedOut: lv.timedOut, + removed: lv.removed, + }); + }); + timingFrame.remoteVideos = timing.remoteVideos.map(rv => { + return SdkMeetingSessionRemoteVideoTiming.create({ + groupId: rv.groupId, + addedMs: rv.addedMs, + firstPacketReceivedMs: rv.firstPacketReceivedMs, + firstFrameRenderedMs: rv.firstFrameRenderedMs, + timedOut: rv.timedOut, + removed: rv.removed, + }); + }); + + const message = SdkSignalFrame.create(); + message.type = SdkSignalFrame.Type.MEETING_SESSION_TIMING; + message.meetingSessionTiming = timingFrame; + this.sendMessage(message); + } + promoteToPrimaryMeeting(credentials: MeetingSessionCredentials): void { const signaledCredentials = SdkMeetingSessionCredentials.create(); signaledCredentials.attendeeId = credentials.attendeeId; diff --git a/src/signalingclient/SignalingClient.ts b/src/signalingclient/SignalingClient.ts index bfa945446d..aed3861e92 100644 --- a/src/signalingclient/SignalingClient.ts +++ b/src/signalingclient/SignalingClient.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { MeetingSessionCredentials } from '..'; +import MeetingSessionTiming from '../meetingsessiontiming/MeetingSessionTiming'; import SignalingClientObserver from '../signalingclientobserver/SignalingClientObserver'; import { SdkClientMetricFrame, @@ -148,4 +149,9 @@ export default interface SignalingClient { * Leave the primary meeting and stop sharing audio, video (if started), and data messages. */ demoteFromPrimaryMeeting(): void; + + /** + * Sends meeting session timing data to the backend. + */ + sendMeetingSessionTiming(timing: MeetingSessionTiming): void; } diff --git a/src/signalingprotocol/SignalingProtocol.d.ts b/src/signalingprotocol/SignalingProtocol.d.ts index 5c014d48cf..43ae127f6a 100644 --- a/src/signalingprotocol/SignalingProtocol.d.ts +++ b/src/signalingprotocol/SignalingProtocol.d.ts @@ -74,6 +74,9 @@ export interface ISdkSignalFrame { /** SdkSignalFrame notification */ notification?: (ISdkNotificationFrame|null); + + /** SdkSignalFrame meetingSessionTiming */ + meetingSessionTiming?: (ISdkMeetingSessionTimingFrame|null); } /** Represents a SdkSignalFrame. */ @@ -157,6 +160,9 @@ export class SdkSignalFrame implements ISdkSignalFrame { /** SdkSignalFrame notification. */ public notification?: (ISdkNotificationFrame|null); + /** SdkSignalFrame meetingSessionTiming. */ + public meetingSessionTiming?: (ISdkMeetingSessionTimingFrame|null); + /** * Creates a new SdkSignalFrame instance using the specified properties. * @param [properties] Properties to set @@ -260,7 +266,8 @@ export namespace SdkSignalFrame { PRIMARY_MEETING_JOIN = 25, PRIMARY_MEETING_JOIN_ACK = 26, PRIMARY_MEETING_LEAVE = 27, - NOTIFICATION = 34 + NOTIFICATION = 34, + MEETING_SESSION_TIMING = 43 } } @@ -2960,6 +2967,8 @@ export namespace SdkMetric { VIDEO_DISCARDED_PPS = 47, VIDEO_PLIS_SENT = 48, VIDEO_RECEIVED_JITTER_MS = 49, + VIDEO_LOCAL_RENDER_FPS = 52, + VIDEO_REMOTE_RENDER_FPS = 56, VIDEO_INPUT_HEIGHT = 60, VIDEO_ENCODE_HEIGHT = 64, VIDEO_SENT_QP_SUM = 66, @@ -5599,3 +5608,783 @@ export enum SdkVideoCodecCapability { VP9_PROFILE_0 = 8, AV1_MAIN_PROFILE = 11 } + +/** Properties of a SdkMeetingSessionTimingFrame. */ +export interface ISdkMeetingSessionTimingFrame { + + /** SdkMeetingSessionTimingFrame signaling */ + signaling?: (ISdkMeetingSessionSignalingTiming[]|null); + + /** SdkMeetingSessionTimingFrame remoteAudio */ + remoteAudio?: (ISdkMeetingSessionRemoteAudioTiming[]|null); + + /** SdkMeetingSessionTimingFrame localAudio */ + localAudio?: (ISdkMeetingSessionLocalAudioTiming[]|null); + + /** SdkMeetingSessionTimingFrame localVideo */ + localVideo?: (ISdkMeetingSessionLocalVideoTiming[]|null); + + /** SdkMeetingSessionTimingFrame remoteVideos */ + remoteVideos?: (ISdkMeetingSessionRemoteVideoTiming[]|null); +} + +/** Represents a SdkMeetingSessionTimingFrame. */ +export class SdkMeetingSessionTimingFrame implements ISdkMeetingSessionTimingFrame { + + /** + * Constructs a new SdkMeetingSessionTimingFrame. + * @param [properties] Properties to set + */ + constructor(properties?: ISdkMeetingSessionTimingFrame); + + /** SdkMeetingSessionTimingFrame signaling. */ + public signaling: ISdkMeetingSessionSignalingTiming[]; + + /** SdkMeetingSessionTimingFrame remoteAudio. */ + public remoteAudio: ISdkMeetingSessionRemoteAudioTiming[]; + + /** SdkMeetingSessionTimingFrame localAudio. */ + public localAudio: ISdkMeetingSessionLocalAudioTiming[]; + + /** SdkMeetingSessionTimingFrame localVideo. */ + public localVideo: ISdkMeetingSessionLocalVideoTiming[]; + + /** SdkMeetingSessionTimingFrame remoteVideos. */ + public remoteVideos: ISdkMeetingSessionRemoteVideoTiming[]; + + /** + * Creates a new SdkMeetingSessionTimingFrame instance using the specified properties. + * @param [properties] Properties to set + * @returns SdkMeetingSessionTimingFrame instance + */ + public static create(properties?: ISdkMeetingSessionTimingFrame): SdkMeetingSessionTimingFrame; + + /** + * Encodes the specified SdkMeetingSessionTimingFrame message. Does not implicitly {@link SdkMeetingSessionTimingFrame.verify|verify} messages. + * @param message SdkMeetingSessionTimingFrame message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: ISdkMeetingSessionTimingFrame, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Encodes the specified SdkMeetingSessionTimingFrame message, length delimited. Does not implicitly {@link SdkMeetingSessionTimingFrame.verify|verify} messages. + * @param message SdkMeetingSessionTimingFrame message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encodeDelimited(message: ISdkMeetingSessionTimingFrame, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a SdkMeetingSessionTimingFrame message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns SdkMeetingSessionTimingFrame + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): SdkMeetingSessionTimingFrame; + + /** + * Decodes a SdkMeetingSessionTimingFrame message from the specified reader or buffer, length delimited. + * @param reader Reader or buffer to decode from + * @returns SdkMeetingSessionTimingFrame + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): SdkMeetingSessionTimingFrame; + + /** + * Verifies a SdkMeetingSessionTimingFrame message. + * @param message Plain object to verify + * @returns `null` if valid, otherwise the reason why it is not + */ + public static verify(message: { [k: string]: any }): (string|null); + + /** + * Creates a SdkMeetingSessionTimingFrame message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns SdkMeetingSessionTimingFrame + */ + public static fromObject(object: { [k: string]: any }): SdkMeetingSessionTimingFrame; + + /** + * Creates a plain object from a SdkMeetingSessionTimingFrame message. Also converts values to other types if specified. + * @param message SdkMeetingSessionTimingFrame + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: SdkMeetingSessionTimingFrame, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this SdkMeetingSessionTimingFrame to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for SdkMeetingSessionTimingFrame + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; +} + +/** Properties of a SdkMeetingSessionSignalingTiming. */ +export interface ISdkMeetingSessionSignalingTiming { + + /** SdkMeetingSessionSignalingTiming startMs */ + startMs?: (number|Long|null); + + /** SdkMeetingSessionSignalingTiming joinSentMs */ + joinSentMs?: (number|Long|null); + + /** SdkMeetingSessionSignalingTiming joinAckReceivedMs */ + joinAckReceivedMs?: (number|Long|null); + + /** SdkMeetingSessionSignalingTiming transportConnectedMs */ + transportConnectedMs?: (number|Long|null); + + /** SdkMeetingSessionSignalingTiming createOfferMs */ + createOfferMs?: (number|Long|null); + + /** SdkMeetingSessionSignalingTiming setLocalDescriptionMs */ + setLocalDescriptionMs?: (number|Long|null); + + /** SdkMeetingSessionSignalingTiming setRemoteDescriptionMs */ + setRemoteDescriptionMs?: (number|Long|null); + + /** SdkMeetingSessionSignalingTiming iceGatheringStartMs */ + iceGatheringStartMs?: (number|Long|null); + + /** SdkMeetingSessionSignalingTiming iceGatheringCompleteMs */ + iceGatheringCompleteMs?: (number|Long|null); + + /** SdkMeetingSessionSignalingTiming iceConnectedMs */ + iceConnectedMs?: (number|Long|null); + + /** SdkMeetingSessionSignalingTiming subscribeSentMs */ + subscribeSentMs?: (number|Long|null); + + /** SdkMeetingSessionSignalingTiming subscribeAckMs */ + subscribeAckMs?: (number|Long|null); + + /** SdkMeetingSessionSignalingTiming timedOut */ + timedOut?: (boolean|null); +} + +/** Represents a SdkMeetingSessionSignalingTiming. */ +export class SdkMeetingSessionSignalingTiming implements ISdkMeetingSessionSignalingTiming { + + /** + * Constructs a new SdkMeetingSessionSignalingTiming. + * @param [properties] Properties to set + */ + constructor(properties?: ISdkMeetingSessionSignalingTiming); + + /** SdkMeetingSessionSignalingTiming startMs. */ + public startMs: (number|Long); + + /** SdkMeetingSessionSignalingTiming joinSentMs. */ + public joinSentMs: (number|Long); + + /** SdkMeetingSessionSignalingTiming joinAckReceivedMs. */ + public joinAckReceivedMs: (number|Long); + + /** SdkMeetingSessionSignalingTiming transportConnectedMs. */ + public transportConnectedMs: (number|Long); + + /** SdkMeetingSessionSignalingTiming createOfferMs. */ + public createOfferMs: (number|Long); + + /** SdkMeetingSessionSignalingTiming setLocalDescriptionMs. */ + public setLocalDescriptionMs: (number|Long); + + /** SdkMeetingSessionSignalingTiming setRemoteDescriptionMs. */ + public setRemoteDescriptionMs: (number|Long); + + /** SdkMeetingSessionSignalingTiming iceGatheringStartMs. */ + public iceGatheringStartMs: (number|Long); + + /** SdkMeetingSessionSignalingTiming iceGatheringCompleteMs. */ + public iceGatheringCompleteMs: (number|Long); + + /** SdkMeetingSessionSignalingTiming iceConnectedMs. */ + public iceConnectedMs: (number|Long); + + /** SdkMeetingSessionSignalingTiming subscribeSentMs. */ + public subscribeSentMs: (number|Long); + + /** SdkMeetingSessionSignalingTiming subscribeAckMs. */ + public subscribeAckMs: (number|Long); + + /** SdkMeetingSessionSignalingTiming timedOut. */ + public timedOut: boolean; + + /** + * Creates a new SdkMeetingSessionSignalingTiming instance using the specified properties. + * @param [properties] Properties to set + * @returns SdkMeetingSessionSignalingTiming instance + */ + public static create(properties?: ISdkMeetingSessionSignalingTiming): SdkMeetingSessionSignalingTiming; + + /** + * Encodes the specified SdkMeetingSessionSignalingTiming message. Does not implicitly {@link SdkMeetingSessionSignalingTiming.verify|verify} messages. + * @param message SdkMeetingSessionSignalingTiming message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: ISdkMeetingSessionSignalingTiming, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Encodes the specified SdkMeetingSessionSignalingTiming message, length delimited. Does not implicitly {@link SdkMeetingSessionSignalingTiming.verify|verify} messages. + * @param message SdkMeetingSessionSignalingTiming message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encodeDelimited(message: ISdkMeetingSessionSignalingTiming, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a SdkMeetingSessionSignalingTiming message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns SdkMeetingSessionSignalingTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): SdkMeetingSessionSignalingTiming; + + /** + * Decodes a SdkMeetingSessionSignalingTiming message from the specified reader or buffer, length delimited. + * @param reader Reader or buffer to decode from + * @returns SdkMeetingSessionSignalingTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): SdkMeetingSessionSignalingTiming; + + /** + * Verifies a SdkMeetingSessionSignalingTiming message. + * @param message Plain object to verify + * @returns `null` if valid, otherwise the reason why it is not + */ + public static verify(message: { [k: string]: any }): (string|null); + + /** + * Creates a SdkMeetingSessionSignalingTiming message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns SdkMeetingSessionSignalingTiming + */ + public static fromObject(object: { [k: string]: any }): SdkMeetingSessionSignalingTiming; + + /** + * Creates a plain object from a SdkMeetingSessionSignalingTiming message. Also converts values to other types if specified. + * @param message SdkMeetingSessionSignalingTiming + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: SdkMeetingSessionSignalingTiming, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this SdkMeetingSessionSignalingTiming to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for SdkMeetingSessionSignalingTiming + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; +} + +/** Properties of a SdkMeetingSessionRemoteAudioTiming. */ +export interface ISdkMeetingSessionRemoteAudioTiming { + + /** SdkMeetingSessionRemoteAudioTiming addedMs */ + addedMs?: (number|Long|null); + + /** SdkMeetingSessionRemoteAudioTiming firstPacketReceivedMs */ + firstPacketReceivedMs?: (number|Long|null); + + /** SdkMeetingSessionRemoteAudioTiming firstFrameRenderedMs */ + firstFrameRenderedMs?: (number|Long|null); + + /** SdkMeetingSessionRemoteAudioTiming timedOut */ + timedOut?: (boolean|null); + + /** SdkMeetingSessionRemoteAudioTiming removed */ + removed?: (boolean|null); +} + +/** Represents a SdkMeetingSessionRemoteAudioTiming. */ +export class SdkMeetingSessionRemoteAudioTiming implements ISdkMeetingSessionRemoteAudioTiming { + + /** + * Constructs a new SdkMeetingSessionRemoteAudioTiming. + * @param [properties] Properties to set + */ + constructor(properties?: ISdkMeetingSessionRemoteAudioTiming); + + /** SdkMeetingSessionRemoteAudioTiming addedMs. */ + public addedMs: (number|Long); + + /** SdkMeetingSessionRemoteAudioTiming firstPacketReceivedMs. */ + public firstPacketReceivedMs: (number|Long); + + /** SdkMeetingSessionRemoteAudioTiming firstFrameRenderedMs. */ + public firstFrameRenderedMs: (number|Long); + + /** SdkMeetingSessionRemoteAudioTiming timedOut. */ + public timedOut: boolean; + + /** SdkMeetingSessionRemoteAudioTiming removed. */ + public removed: boolean; + + /** + * Creates a new SdkMeetingSessionRemoteAudioTiming instance using the specified properties. + * @param [properties] Properties to set + * @returns SdkMeetingSessionRemoteAudioTiming instance + */ + public static create(properties?: ISdkMeetingSessionRemoteAudioTiming): SdkMeetingSessionRemoteAudioTiming; + + /** + * Encodes the specified SdkMeetingSessionRemoteAudioTiming message. Does not implicitly {@link SdkMeetingSessionRemoteAudioTiming.verify|verify} messages. + * @param message SdkMeetingSessionRemoteAudioTiming message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: ISdkMeetingSessionRemoteAudioTiming, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Encodes the specified SdkMeetingSessionRemoteAudioTiming message, length delimited. Does not implicitly {@link SdkMeetingSessionRemoteAudioTiming.verify|verify} messages. + * @param message SdkMeetingSessionRemoteAudioTiming message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encodeDelimited(message: ISdkMeetingSessionRemoteAudioTiming, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a SdkMeetingSessionRemoteAudioTiming message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns SdkMeetingSessionRemoteAudioTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): SdkMeetingSessionRemoteAudioTiming; + + /** + * Decodes a SdkMeetingSessionRemoteAudioTiming message from the specified reader or buffer, length delimited. + * @param reader Reader or buffer to decode from + * @returns SdkMeetingSessionRemoteAudioTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): SdkMeetingSessionRemoteAudioTiming; + + /** + * Verifies a SdkMeetingSessionRemoteAudioTiming message. + * @param message Plain object to verify + * @returns `null` if valid, otherwise the reason why it is not + */ + public static verify(message: { [k: string]: any }): (string|null); + + /** + * Creates a SdkMeetingSessionRemoteAudioTiming message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns SdkMeetingSessionRemoteAudioTiming + */ + public static fromObject(object: { [k: string]: any }): SdkMeetingSessionRemoteAudioTiming; + + /** + * Creates a plain object from a SdkMeetingSessionRemoteAudioTiming message. Also converts values to other types if specified. + * @param message SdkMeetingSessionRemoteAudioTiming + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: SdkMeetingSessionRemoteAudioTiming, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this SdkMeetingSessionRemoteAudioTiming to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for SdkMeetingSessionRemoteAudioTiming + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; +} + +/** Properties of a SdkMeetingSessionLocalAudioTiming. */ +export interface ISdkMeetingSessionLocalAudioTiming { + + /** SdkMeetingSessionLocalAudioTiming addedMs */ + addedMs?: (number|Long|null); + + /** SdkMeetingSessionLocalAudioTiming firstFrameCapturedMs */ + firstFrameCapturedMs?: (number|Long|null); + + /** SdkMeetingSessionLocalAudioTiming firstPacketSentMs */ + firstPacketSentMs?: (number|Long|null); + + /** SdkMeetingSessionLocalAudioTiming timedOut */ + timedOut?: (boolean|null); + + /** SdkMeetingSessionLocalAudioTiming removed */ + removed?: (boolean|null); +} + +/** Represents a SdkMeetingSessionLocalAudioTiming. */ +export class SdkMeetingSessionLocalAudioTiming implements ISdkMeetingSessionLocalAudioTiming { + + /** + * Constructs a new SdkMeetingSessionLocalAudioTiming. + * @param [properties] Properties to set + */ + constructor(properties?: ISdkMeetingSessionLocalAudioTiming); + + /** SdkMeetingSessionLocalAudioTiming addedMs. */ + public addedMs: (number|Long); + + /** SdkMeetingSessionLocalAudioTiming firstFrameCapturedMs. */ + public firstFrameCapturedMs: (number|Long); + + /** SdkMeetingSessionLocalAudioTiming firstPacketSentMs. */ + public firstPacketSentMs: (number|Long); + + /** SdkMeetingSessionLocalAudioTiming timedOut. */ + public timedOut: boolean; + + /** SdkMeetingSessionLocalAudioTiming removed. */ + public removed: boolean; + + /** + * Creates a new SdkMeetingSessionLocalAudioTiming instance using the specified properties. + * @param [properties] Properties to set + * @returns SdkMeetingSessionLocalAudioTiming instance + */ + public static create(properties?: ISdkMeetingSessionLocalAudioTiming): SdkMeetingSessionLocalAudioTiming; + + /** + * Encodes the specified SdkMeetingSessionLocalAudioTiming message. Does not implicitly {@link SdkMeetingSessionLocalAudioTiming.verify|verify} messages. + * @param message SdkMeetingSessionLocalAudioTiming message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: ISdkMeetingSessionLocalAudioTiming, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Encodes the specified SdkMeetingSessionLocalAudioTiming message, length delimited. Does not implicitly {@link SdkMeetingSessionLocalAudioTiming.verify|verify} messages. + * @param message SdkMeetingSessionLocalAudioTiming message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encodeDelimited(message: ISdkMeetingSessionLocalAudioTiming, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a SdkMeetingSessionLocalAudioTiming message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns SdkMeetingSessionLocalAudioTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): SdkMeetingSessionLocalAudioTiming; + + /** + * Decodes a SdkMeetingSessionLocalAudioTiming message from the specified reader or buffer, length delimited. + * @param reader Reader or buffer to decode from + * @returns SdkMeetingSessionLocalAudioTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): SdkMeetingSessionLocalAudioTiming; + + /** + * Verifies a SdkMeetingSessionLocalAudioTiming message. + * @param message Plain object to verify + * @returns `null` if valid, otherwise the reason why it is not + */ + public static verify(message: { [k: string]: any }): (string|null); + + /** + * Creates a SdkMeetingSessionLocalAudioTiming message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns SdkMeetingSessionLocalAudioTiming + */ + public static fromObject(object: { [k: string]: any }): SdkMeetingSessionLocalAudioTiming; + + /** + * Creates a plain object from a SdkMeetingSessionLocalAudioTiming message. Also converts values to other types if specified. + * @param message SdkMeetingSessionLocalAudioTiming + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: SdkMeetingSessionLocalAudioTiming, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this SdkMeetingSessionLocalAudioTiming to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for SdkMeetingSessionLocalAudioTiming + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; +} + +/** Properties of a SdkMeetingSessionLocalVideoTiming. */ +export interface ISdkMeetingSessionLocalVideoTiming { + + /** SdkMeetingSessionLocalVideoTiming addedMs */ + addedMs?: (number|Long|null); + + /** SdkMeetingSessionLocalVideoTiming firstFrameCapturedMs */ + firstFrameCapturedMs?: (number|Long|null); + + /** SdkMeetingSessionLocalVideoTiming firstFrameSentMs */ + firstFrameSentMs?: (number|Long|null); + + /** SdkMeetingSessionLocalVideoTiming timedOut */ + timedOut?: (boolean|null); + + /** SdkMeetingSessionLocalVideoTiming removed */ + removed?: (boolean|null); +} + +/** Represents a SdkMeetingSessionLocalVideoTiming. */ +export class SdkMeetingSessionLocalVideoTiming implements ISdkMeetingSessionLocalVideoTiming { + + /** + * Constructs a new SdkMeetingSessionLocalVideoTiming. + * @param [properties] Properties to set + */ + constructor(properties?: ISdkMeetingSessionLocalVideoTiming); + + /** SdkMeetingSessionLocalVideoTiming addedMs. */ + public addedMs: (number|Long); + + /** SdkMeetingSessionLocalVideoTiming firstFrameCapturedMs. */ + public firstFrameCapturedMs: (number|Long); + + /** SdkMeetingSessionLocalVideoTiming firstFrameSentMs. */ + public firstFrameSentMs: (number|Long); + + /** SdkMeetingSessionLocalVideoTiming timedOut. */ + public timedOut: boolean; + + /** SdkMeetingSessionLocalVideoTiming removed. */ + public removed: boolean; + + /** + * Creates a new SdkMeetingSessionLocalVideoTiming instance using the specified properties. + * @param [properties] Properties to set + * @returns SdkMeetingSessionLocalVideoTiming instance + */ + public static create(properties?: ISdkMeetingSessionLocalVideoTiming): SdkMeetingSessionLocalVideoTiming; + + /** + * Encodes the specified SdkMeetingSessionLocalVideoTiming message. Does not implicitly {@link SdkMeetingSessionLocalVideoTiming.verify|verify} messages. + * @param message SdkMeetingSessionLocalVideoTiming message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: ISdkMeetingSessionLocalVideoTiming, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Encodes the specified SdkMeetingSessionLocalVideoTiming message, length delimited. Does not implicitly {@link SdkMeetingSessionLocalVideoTiming.verify|verify} messages. + * @param message SdkMeetingSessionLocalVideoTiming message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encodeDelimited(message: ISdkMeetingSessionLocalVideoTiming, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a SdkMeetingSessionLocalVideoTiming message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns SdkMeetingSessionLocalVideoTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): SdkMeetingSessionLocalVideoTiming; + + /** + * Decodes a SdkMeetingSessionLocalVideoTiming message from the specified reader or buffer, length delimited. + * @param reader Reader or buffer to decode from + * @returns SdkMeetingSessionLocalVideoTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): SdkMeetingSessionLocalVideoTiming; + + /** + * Verifies a SdkMeetingSessionLocalVideoTiming message. + * @param message Plain object to verify + * @returns `null` if valid, otherwise the reason why it is not + */ + public static verify(message: { [k: string]: any }): (string|null); + + /** + * Creates a SdkMeetingSessionLocalVideoTiming message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns SdkMeetingSessionLocalVideoTiming + */ + public static fromObject(object: { [k: string]: any }): SdkMeetingSessionLocalVideoTiming; + + /** + * Creates a plain object from a SdkMeetingSessionLocalVideoTiming message. Also converts values to other types if specified. + * @param message SdkMeetingSessionLocalVideoTiming + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: SdkMeetingSessionLocalVideoTiming, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this SdkMeetingSessionLocalVideoTiming to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for SdkMeetingSessionLocalVideoTiming + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; +} + +/** Properties of a SdkMeetingSessionRemoteVideoTiming. */ +export interface ISdkMeetingSessionRemoteVideoTiming { + + /** SdkMeetingSessionRemoteVideoTiming groupId */ + groupId?: (number|null); + + /** SdkMeetingSessionRemoteVideoTiming addedMs */ + addedMs?: (number|Long|null); + + /** SdkMeetingSessionRemoteVideoTiming firstPacketReceivedMs */ + firstPacketReceivedMs?: (number|Long|null); + + /** SdkMeetingSessionRemoteVideoTiming firstFrameRenderedMs */ + firstFrameRenderedMs?: (number|Long|null); + + /** SdkMeetingSessionRemoteVideoTiming timedOut */ + timedOut?: (boolean|null); + + /** SdkMeetingSessionRemoteVideoTiming removed */ + removed?: (boolean|null); +} + +/** Represents a SdkMeetingSessionRemoteVideoTiming. */ +export class SdkMeetingSessionRemoteVideoTiming implements ISdkMeetingSessionRemoteVideoTiming { + + /** + * Constructs a new SdkMeetingSessionRemoteVideoTiming. + * @param [properties] Properties to set + */ + constructor(properties?: ISdkMeetingSessionRemoteVideoTiming); + + /** SdkMeetingSessionRemoteVideoTiming groupId. */ + public groupId: number; + + /** SdkMeetingSessionRemoteVideoTiming addedMs. */ + public addedMs: (number|Long); + + /** SdkMeetingSessionRemoteVideoTiming firstPacketReceivedMs. */ + public firstPacketReceivedMs: (number|Long); + + /** SdkMeetingSessionRemoteVideoTiming firstFrameRenderedMs. */ + public firstFrameRenderedMs: (number|Long); + + /** SdkMeetingSessionRemoteVideoTiming timedOut. */ + public timedOut: boolean; + + /** SdkMeetingSessionRemoteVideoTiming removed. */ + public removed: boolean; + + /** + * Creates a new SdkMeetingSessionRemoteVideoTiming instance using the specified properties. + * @param [properties] Properties to set + * @returns SdkMeetingSessionRemoteVideoTiming instance + */ + public static create(properties?: ISdkMeetingSessionRemoteVideoTiming): SdkMeetingSessionRemoteVideoTiming; + + /** + * Encodes the specified SdkMeetingSessionRemoteVideoTiming message. Does not implicitly {@link SdkMeetingSessionRemoteVideoTiming.verify|verify} messages. + * @param message SdkMeetingSessionRemoteVideoTiming message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: ISdkMeetingSessionRemoteVideoTiming, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Encodes the specified SdkMeetingSessionRemoteVideoTiming message, length delimited. Does not implicitly {@link SdkMeetingSessionRemoteVideoTiming.verify|verify} messages. + * @param message SdkMeetingSessionRemoteVideoTiming message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encodeDelimited(message: ISdkMeetingSessionRemoteVideoTiming, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a SdkMeetingSessionRemoteVideoTiming message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns SdkMeetingSessionRemoteVideoTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): SdkMeetingSessionRemoteVideoTiming; + + /** + * Decodes a SdkMeetingSessionRemoteVideoTiming message from the specified reader or buffer, length delimited. + * @param reader Reader or buffer to decode from + * @returns SdkMeetingSessionRemoteVideoTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): SdkMeetingSessionRemoteVideoTiming; + + /** + * Verifies a SdkMeetingSessionRemoteVideoTiming message. + * @param message Plain object to verify + * @returns `null` if valid, otherwise the reason why it is not + */ + public static verify(message: { [k: string]: any }): (string|null); + + /** + * Creates a SdkMeetingSessionRemoteVideoTiming message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns SdkMeetingSessionRemoteVideoTiming + */ + public static fromObject(object: { [k: string]: any }): SdkMeetingSessionRemoteVideoTiming; + + /** + * Creates a plain object from a SdkMeetingSessionRemoteVideoTiming message. Also converts values to other types if specified. + * @param message SdkMeetingSessionRemoteVideoTiming + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: SdkMeetingSessionRemoteVideoTiming, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this SdkMeetingSessionRemoteVideoTiming to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for SdkMeetingSessionRemoteVideoTiming + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; +} diff --git a/src/signalingprotocol/SignalingProtocol.js b/src/signalingprotocol/SignalingProtocol.js index b96824494e..d5c5d70a01 100644 --- a/src/signalingprotocol/SignalingProtocol.js +++ b/src/signalingprotocol/SignalingProtocol.js @@ -39,6 +39,7 @@ $root.SdkSignalFrame = (function() { * @property {ISdkPrimaryMeetingJoinAckFrame|null} [primaryMeetingJoinAck] SdkSignalFrame primaryMeetingJoinAck * @property {ISdkPrimaryMeetingLeaveFrame|null} [primaryMeetingLeave] SdkSignalFrame primaryMeetingLeave * @property {ISdkNotificationFrame|null} [notification] SdkSignalFrame notification + * @property {ISdkMeetingSessionTimingFrame|null} [meetingSessionTiming] SdkSignalFrame meetingSessionTiming */ /** @@ -248,6 +249,14 @@ $root.SdkSignalFrame = (function() { */ SdkSignalFrame.prototype.notification = null; + /** + * SdkSignalFrame meetingSessionTiming. + * @member {ISdkMeetingSessionTimingFrame|null|undefined} meetingSessionTiming + * @memberof SdkSignalFrame + * @instance + */ + SdkSignalFrame.prototype.meetingSessionTiming = null; + /** * Creates a new SdkSignalFrame instance using the specified properties. * @function create @@ -318,6 +327,8 @@ $root.SdkSignalFrame = (function() { $root.SdkPrimaryMeetingLeaveFrame.encode(message.primaryMeetingLeave, writer.uint32(/* id 28, wireType 2 =*/226).fork()).ldelim(); if (message.notification != null && Object.hasOwnProperty.call(message, "notification")) $root.SdkNotificationFrame.encode(message.notification, writer.uint32(/* id 35, wireType 2 =*/282).fork()).ldelim(); + if (message.meetingSessionTiming != null && Object.hasOwnProperty.call(message, "meetingSessionTiming")) + $root.SdkMeetingSessionTimingFrame.encode(message.meetingSessionTiming, writer.uint32(/* id 44, wireType 2 =*/354).fork()).ldelim(); return writer; }; @@ -448,6 +459,10 @@ $root.SdkSignalFrame = (function() { message.notification = $root.SdkNotificationFrame.decode(reader, reader.uint32()); break; } + case 44: { + message.meetingSessionTiming = $root.SdkMeetingSessionTimingFrame.decode(reader, reader.uint32()); + break; + } default: reader.skipType(tag & 7); break; @@ -514,6 +529,7 @@ $root.SdkSignalFrame = (function() { case 26: case 27: case 34: + case 43: break; } if (message.error != null && message.hasOwnProperty("error")) { @@ -626,6 +642,11 @@ $root.SdkSignalFrame = (function() { if (error) return "notification." + error; } + if (message.meetingSessionTiming != null && message.hasOwnProperty("meetingSessionTiming")) { + var error = $root.SdkMeetingSessionTimingFrame.verify(message.meetingSessionTiming); + if (error) + return "meetingSessionTiming." + error; + } return null; }; @@ -745,6 +766,10 @@ $root.SdkSignalFrame = (function() { case 34: message.type = 34; break; + case "MEETING_SESSION_TIMING": + case 43: + message.type = 43; + break; } if (object.error != null) { if (typeof object.error !== "object") @@ -856,6 +881,11 @@ $root.SdkSignalFrame = (function() { throw TypeError(".SdkSignalFrame.notification: object expected"); message.notification = $root.SdkNotificationFrame.fromObject(object.notification); } + if (object.meetingSessionTiming != null) { + if (typeof object.meetingSessionTiming !== "object") + throw TypeError(".SdkSignalFrame.meetingSessionTiming: object expected"); + message.meetingSessionTiming = $root.SdkMeetingSessionTimingFrame.fromObject(object.meetingSessionTiming); + } return message; }; @@ -901,6 +931,7 @@ $root.SdkSignalFrame = (function() { object.primaryMeetingJoinAck = null; object.primaryMeetingLeave = null; object.notification = null; + object.meetingSessionTiming = null; } if (message.timestampMs != null && message.hasOwnProperty("timestampMs")) if (typeof message.timestampMs === "number") @@ -953,6 +984,8 @@ $root.SdkSignalFrame = (function() { object.primaryMeetingLeave = $root.SdkPrimaryMeetingLeaveFrame.toObject(message.primaryMeetingLeave, options); if (message.notification != null && message.hasOwnProperty("notification")) object.notification = $root.SdkNotificationFrame.toObject(message.notification, options); + if (message.meetingSessionTiming != null && message.hasOwnProperty("meetingSessionTiming")) + object.meetingSessionTiming = $root.SdkMeetingSessionTimingFrame.toObject(message.meetingSessionTiming, options); return object; }; @@ -1008,6 +1041,7 @@ $root.SdkSignalFrame = (function() { * @property {number} PRIMARY_MEETING_JOIN_ACK=26 PRIMARY_MEETING_JOIN_ACK value * @property {number} PRIMARY_MEETING_LEAVE=27 PRIMARY_MEETING_LEAVE value * @property {number} NOTIFICATION=34 NOTIFICATION value + * @property {number} MEETING_SESSION_TIMING=43 MEETING_SESSION_TIMING value */ SdkSignalFrame.Type = (function() { var valuesById = {}, values = Object.create(valuesById); @@ -1033,6 +1067,7 @@ $root.SdkSignalFrame = (function() { values[valuesById[26] = "PRIMARY_MEETING_JOIN_ACK"] = 26; values[valuesById[27] = "PRIMARY_MEETING_LEAVE"] = 27; values[valuesById[34] = "NOTIFICATION"] = 34; + values[valuesById[43] = "MEETING_SESSION_TIMING"] = 43; return values; })(); @@ -7779,6 +7814,8 @@ $root.SdkMetric = (function() { case 47: case 48: case 49: + case 52: + case 56: case 60: case 64: case 66: @@ -8034,6 +8071,14 @@ $root.SdkMetric = (function() { case 49: message.type = 49; break; + case "VIDEO_LOCAL_RENDER_FPS": + case 52: + message.type = 52; + break; + case "VIDEO_REMOTE_RENDER_FPS": + case 56: + message.type = 56; + break; case "VIDEO_INPUT_HEIGHT": case 60: message.type = 60; @@ -8271,6 +8316,8 @@ $root.SdkMetric = (function() { * @property {number} VIDEO_DISCARDED_PPS=47 VIDEO_DISCARDED_PPS value * @property {number} VIDEO_PLIS_SENT=48 VIDEO_PLIS_SENT value * @property {number} VIDEO_RECEIVED_JITTER_MS=49 VIDEO_RECEIVED_JITTER_MS value + * @property {number} VIDEO_LOCAL_RENDER_FPS=52 VIDEO_LOCAL_RENDER_FPS value + * @property {number} VIDEO_REMOTE_RENDER_FPS=56 VIDEO_REMOTE_RENDER_FPS value * @property {number} VIDEO_INPUT_HEIGHT=60 VIDEO_INPUT_HEIGHT value * @property {number} VIDEO_ENCODE_HEIGHT=64 VIDEO_ENCODE_HEIGHT value * @property {number} VIDEO_SENT_QP_SUM=66 VIDEO_SENT_QP_SUM value @@ -8355,6 +8402,8 @@ $root.SdkMetric = (function() { values[valuesById[47] = "VIDEO_DISCARDED_PPS"] = 47; values[valuesById[48] = "VIDEO_PLIS_SENT"] = 48; values[valuesById[49] = "VIDEO_RECEIVED_JITTER_MS"] = 49; + values[valuesById[52] = "VIDEO_LOCAL_RENDER_FPS"] = 52; + values[valuesById[56] = "VIDEO_REMOTE_RENDER_FPS"] = 56; values[valuesById[60] = "VIDEO_INPUT_HEIGHT"] = 60; values[valuesById[64] = "VIDEO_ENCODE_HEIGHT"] = 64; values[valuesById[66] = "VIDEO_SENT_QP_SUM"] = 66; @@ -14980,6 +15029,2430 @@ $root.SdkVideoCodecCapability = (function() { return values; })(); +$root.SdkMeetingSessionTimingFrame = (function() { + + /** + * Properties of a SdkMeetingSessionTimingFrame. + * @name ISdkMeetingSessionTimingFrame + * @interface ISdkMeetingSessionTimingFrame + * @property {Array.|null} [signaling] SdkMeetingSessionTimingFrame signaling + * @property {Array.|null} [remoteAudio] SdkMeetingSessionTimingFrame remoteAudio + * @property {Array.|null} [localAudio] SdkMeetingSessionTimingFrame localAudio + * @property {Array.|null} [localVideo] SdkMeetingSessionTimingFrame localVideo + * @property {Array.|null} [remoteVideos] SdkMeetingSessionTimingFrame remoteVideos + */ + + /** + * Constructs a new SdkMeetingSessionTimingFrame. + * @name SdkMeetingSessionTimingFrame + * @classdesc Represents a SdkMeetingSessionTimingFrame. + * @implements ISdkMeetingSessionTimingFrame + * @constructor + * @param {ISdkMeetingSessionTimingFrame=} [properties] Properties to set + */ + function SdkMeetingSessionTimingFrame(properties) { + this.signaling = []; + this.remoteAudio = []; + this.localAudio = []; + this.localVideo = []; + this.remoteVideos = []; + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + + /** + * SdkMeetingSessionTimingFrame signaling. + * @member {Array.} signaling + * @memberof SdkMeetingSessionTimingFrame + * @instance + */ + SdkMeetingSessionTimingFrame.prototype.signaling = $util.emptyArray; + + /** + * SdkMeetingSessionTimingFrame remoteAudio. + * @member {Array.} remoteAudio + * @memberof SdkMeetingSessionTimingFrame + * @instance + */ + SdkMeetingSessionTimingFrame.prototype.remoteAudio = $util.emptyArray; + + /** + * SdkMeetingSessionTimingFrame localAudio. + * @member {Array.} localAudio + * @memberof SdkMeetingSessionTimingFrame + * @instance + */ + SdkMeetingSessionTimingFrame.prototype.localAudio = $util.emptyArray; + + /** + * SdkMeetingSessionTimingFrame localVideo. + * @member {Array.} localVideo + * @memberof SdkMeetingSessionTimingFrame + * @instance + */ + SdkMeetingSessionTimingFrame.prototype.localVideo = $util.emptyArray; + + /** + * SdkMeetingSessionTimingFrame remoteVideos. + * @member {Array.} remoteVideos + * @memberof SdkMeetingSessionTimingFrame + * @instance + */ + SdkMeetingSessionTimingFrame.prototype.remoteVideos = $util.emptyArray; + + /** + * Creates a new SdkMeetingSessionTimingFrame instance using the specified properties. + * @function create + * @memberof SdkMeetingSessionTimingFrame + * @static + * @param {ISdkMeetingSessionTimingFrame=} [properties] Properties to set + * @returns {SdkMeetingSessionTimingFrame} SdkMeetingSessionTimingFrame instance + */ + SdkMeetingSessionTimingFrame.create = function create(properties) { + return new SdkMeetingSessionTimingFrame(properties); + }; + + /** + * Encodes the specified SdkMeetingSessionTimingFrame message. Does not implicitly {@link SdkMeetingSessionTimingFrame.verify|verify} messages. + * @function encode + * @memberof SdkMeetingSessionTimingFrame + * @static + * @param {ISdkMeetingSessionTimingFrame} message SdkMeetingSessionTimingFrame message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SdkMeetingSessionTimingFrame.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.signaling != null && message.signaling.length) + for (var i = 0; i < message.signaling.length; ++i) + $root.SdkMeetingSessionSignalingTiming.encode(message.signaling[i], writer.uint32(/* id 1, wireType 2 =*/10).fork()).ldelim(); + if (message.remoteAudio != null && message.remoteAudio.length) + for (var i = 0; i < message.remoteAudio.length; ++i) + $root.SdkMeetingSessionRemoteAudioTiming.encode(message.remoteAudio[i], writer.uint32(/* id 2, wireType 2 =*/18).fork()).ldelim(); + if (message.localAudio != null && message.localAudio.length) + for (var i = 0; i < message.localAudio.length; ++i) + $root.SdkMeetingSessionLocalAudioTiming.encode(message.localAudio[i], writer.uint32(/* id 3, wireType 2 =*/26).fork()).ldelim(); + if (message.localVideo != null && message.localVideo.length) + for (var i = 0; i < message.localVideo.length; ++i) + $root.SdkMeetingSessionLocalVideoTiming.encode(message.localVideo[i], writer.uint32(/* id 4, wireType 2 =*/34).fork()).ldelim(); + if (message.remoteVideos != null && message.remoteVideos.length) + for (var i = 0; i < message.remoteVideos.length; ++i) + $root.SdkMeetingSessionRemoteVideoTiming.encode(message.remoteVideos[i], writer.uint32(/* id 5, wireType 2 =*/42).fork()).ldelim(); + return writer; + }; + + /** + * Encodes the specified SdkMeetingSessionTimingFrame message, length delimited. Does not implicitly {@link SdkMeetingSessionTimingFrame.verify|verify} messages. + * @function encodeDelimited + * @memberof SdkMeetingSessionTimingFrame + * @static + * @param {ISdkMeetingSessionTimingFrame} message SdkMeetingSessionTimingFrame message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SdkMeetingSessionTimingFrame.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + + /** + * Decodes a SdkMeetingSessionTimingFrame message from the specified reader or buffer. + * @function decode + * @memberof SdkMeetingSessionTimingFrame + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {SdkMeetingSessionTimingFrame} SdkMeetingSessionTimingFrame + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SdkMeetingSessionTimingFrame.decode = function decode(reader, length) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.SdkMeetingSessionTimingFrame(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (!(message.signaling && message.signaling.length)) + message.signaling = []; + message.signaling.push($root.SdkMeetingSessionSignalingTiming.decode(reader, reader.uint32())); + break; + } + case 2: { + if (!(message.remoteAudio && message.remoteAudio.length)) + message.remoteAudio = []; + message.remoteAudio.push($root.SdkMeetingSessionRemoteAudioTiming.decode(reader, reader.uint32())); + break; + } + case 3: { + if (!(message.localAudio && message.localAudio.length)) + message.localAudio = []; + message.localAudio.push($root.SdkMeetingSessionLocalAudioTiming.decode(reader, reader.uint32())); + break; + } + case 4: { + if (!(message.localVideo && message.localVideo.length)) + message.localVideo = []; + message.localVideo.push($root.SdkMeetingSessionLocalVideoTiming.decode(reader, reader.uint32())); + break; + } + case 5: { + if (!(message.remoteVideos && message.remoteVideos.length)) + message.remoteVideos = []; + message.remoteVideos.push($root.SdkMeetingSessionRemoteVideoTiming.decode(reader, reader.uint32())); + break; + } + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + + /** + * Decodes a SdkMeetingSessionTimingFrame message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof SdkMeetingSessionTimingFrame + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {SdkMeetingSessionTimingFrame} SdkMeetingSessionTimingFrame + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SdkMeetingSessionTimingFrame.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a SdkMeetingSessionTimingFrame message. + * @function verify + * @memberof SdkMeetingSessionTimingFrame + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + SdkMeetingSessionTimingFrame.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.signaling != null && message.hasOwnProperty("signaling")) { + if (!Array.isArray(message.signaling)) + return "signaling: array expected"; + for (var i = 0; i < message.signaling.length; ++i) { + var error = $root.SdkMeetingSessionSignalingTiming.verify(message.signaling[i]); + if (error) + return "signaling." + error; + } + } + if (message.remoteAudio != null && message.hasOwnProperty("remoteAudio")) { + if (!Array.isArray(message.remoteAudio)) + return "remoteAudio: array expected"; + for (var i = 0; i < message.remoteAudio.length; ++i) { + var error = $root.SdkMeetingSessionRemoteAudioTiming.verify(message.remoteAudio[i]); + if (error) + return "remoteAudio." + error; + } + } + if (message.localAudio != null && message.hasOwnProperty("localAudio")) { + if (!Array.isArray(message.localAudio)) + return "localAudio: array expected"; + for (var i = 0; i < message.localAudio.length; ++i) { + var error = $root.SdkMeetingSessionLocalAudioTiming.verify(message.localAudio[i]); + if (error) + return "localAudio." + error; + } + } + if (message.localVideo != null && message.hasOwnProperty("localVideo")) { + if (!Array.isArray(message.localVideo)) + return "localVideo: array expected"; + for (var i = 0; i < message.localVideo.length; ++i) { + var error = $root.SdkMeetingSessionLocalVideoTiming.verify(message.localVideo[i]); + if (error) + return "localVideo." + error; + } + } + if (message.remoteVideos != null && message.hasOwnProperty("remoteVideos")) { + if (!Array.isArray(message.remoteVideos)) + return "remoteVideos: array expected"; + for (var i = 0; i < message.remoteVideos.length; ++i) { + var error = $root.SdkMeetingSessionRemoteVideoTiming.verify(message.remoteVideos[i]); + if (error) + return "remoteVideos." + error; + } + } + return null; + }; + + /** + * Creates a SdkMeetingSessionTimingFrame message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof SdkMeetingSessionTimingFrame + * @static + * @param {Object.} object Plain object + * @returns {SdkMeetingSessionTimingFrame} SdkMeetingSessionTimingFrame + */ + SdkMeetingSessionTimingFrame.fromObject = function fromObject(object) { + if (object instanceof $root.SdkMeetingSessionTimingFrame) + return object; + var message = new $root.SdkMeetingSessionTimingFrame(); + if (object.signaling) { + if (!Array.isArray(object.signaling)) + throw TypeError(".SdkMeetingSessionTimingFrame.signaling: array expected"); + message.signaling = []; + for (var i = 0; i < object.signaling.length; ++i) { + if (typeof object.signaling[i] !== "object") + throw TypeError(".SdkMeetingSessionTimingFrame.signaling: object expected"); + message.signaling[i] = $root.SdkMeetingSessionSignalingTiming.fromObject(object.signaling[i]); + } + } + if (object.remoteAudio) { + if (!Array.isArray(object.remoteAudio)) + throw TypeError(".SdkMeetingSessionTimingFrame.remoteAudio: array expected"); + message.remoteAudio = []; + for (var i = 0; i < object.remoteAudio.length; ++i) { + if (typeof object.remoteAudio[i] !== "object") + throw TypeError(".SdkMeetingSessionTimingFrame.remoteAudio: object expected"); + message.remoteAudio[i] = $root.SdkMeetingSessionRemoteAudioTiming.fromObject(object.remoteAudio[i]); + } + } + if (object.localAudio) { + if (!Array.isArray(object.localAudio)) + throw TypeError(".SdkMeetingSessionTimingFrame.localAudio: array expected"); + message.localAudio = []; + for (var i = 0; i < object.localAudio.length; ++i) { + if (typeof object.localAudio[i] !== "object") + throw TypeError(".SdkMeetingSessionTimingFrame.localAudio: object expected"); + message.localAudio[i] = $root.SdkMeetingSessionLocalAudioTiming.fromObject(object.localAudio[i]); + } + } + if (object.localVideo) { + if (!Array.isArray(object.localVideo)) + throw TypeError(".SdkMeetingSessionTimingFrame.localVideo: array expected"); + message.localVideo = []; + for (var i = 0; i < object.localVideo.length; ++i) { + if (typeof object.localVideo[i] !== "object") + throw TypeError(".SdkMeetingSessionTimingFrame.localVideo: object expected"); + message.localVideo[i] = $root.SdkMeetingSessionLocalVideoTiming.fromObject(object.localVideo[i]); + } + } + if (object.remoteVideos) { + if (!Array.isArray(object.remoteVideos)) + throw TypeError(".SdkMeetingSessionTimingFrame.remoteVideos: array expected"); + message.remoteVideos = []; + for (var i = 0; i < object.remoteVideos.length; ++i) { + if (typeof object.remoteVideos[i] !== "object") + throw TypeError(".SdkMeetingSessionTimingFrame.remoteVideos: object expected"); + message.remoteVideos[i] = $root.SdkMeetingSessionRemoteVideoTiming.fromObject(object.remoteVideos[i]); + } + } + return message; + }; + + /** + * Creates a plain object from a SdkMeetingSessionTimingFrame message. Also converts values to other types if specified. + * @function toObject + * @memberof SdkMeetingSessionTimingFrame + * @static + * @param {SdkMeetingSessionTimingFrame} message SdkMeetingSessionTimingFrame + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + SdkMeetingSessionTimingFrame.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.arrays || options.defaults) { + object.signaling = []; + object.remoteAudio = []; + object.localAudio = []; + object.localVideo = []; + object.remoteVideos = []; + } + if (message.signaling && message.signaling.length) { + object.signaling = []; + for (var j = 0; j < message.signaling.length; ++j) + object.signaling[j] = $root.SdkMeetingSessionSignalingTiming.toObject(message.signaling[j], options); + } + if (message.remoteAudio && message.remoteAudio.length) { + object.remoteAudio = []; + for (var j = 0; j < message.remoteAudio.length; ++j) + object.remoteAudio[j] = $root.SdkMeetingSessionRemoteAudioTiming.toObject(message.remoteAudio[j], options); + } + if (message.localAudio && message.localAudio.length) { + object.localAudio = []; + for (var j = 0; j < message.localAudio.length; ++j) + object.localAudio[j] = $root.SdkMeetingSessionLocalAudioTiming.toObject(message.localAudio[j], options); + } + if (message.localVideo && message.localVideo.length) { + object.localVideo = []; + for (var j = 0; j < message.localVideo.length; ++j) + object.localVideo[j] = $root.SdkMeetingSessionLocalVideoTiming.toObject(message.localVideo[j], options); + } + if (message.remoteVideos && message.remoteVideos.length) { + object.remoteVideos = []; + for (var j = 0; j < message.remoteVideos.length; ++j) + object.remoteVideos[j] = $root.SdkMeetingSessionRemoteVideoTiming.toObject(message.remoteVideos[j], options); + } + return object; + }; + + /** + * Converts this SdkMeetingSessionTimingFrame to JSON. + * @function toJSON + * @memberof SdkMeetingSessionTimingFrame + * @instance + * @returns {Object.} JSON object + */ + SdkMeetingSessionTimingFrame.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for SdkMeetingSessionTimingFrame + * @function getTypeUrl + * @memberof SdkMeetingSessionTimingFrame + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + SdkMeetingSessionTimingFrame.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/SdkMeetingSessionTimingFrame"; + }; + + return SdkMeetingSessionTimingFrame; +})(); + +$root.SdkMeetingSessionSignalingTiming = (function() { + + /** + * Properties of a SdkMeetingSessionSignalingTiming. + * @name ISdkMeetingSessionSignalingTiming + * @interface ISdkMeetingSessionSignalingTiming + * @property {number|Long|null} [startMs] SdkMeetingSessionSignalingTiming startMs + * @property {number|Long|null} [joinSentMs] SdkMeetingSessionSignalingTiming joinSentMs + * @property {number|Long|null} [joinAckReceivedMs] SdkMeetingSessionSignalingTiming joinAckReceivedMs + * @property {number|Long|null} [transportConnectedMs] SdkMeetingSessionSignalingTiming transportConnectedMs + * @property {number|Long|null} [createOfferMs] SdkMeetingSessionSignalingTiming createOfferMs + * @property {number|Long|null} [setLocalDescriptionMs] SdkMeetingSessionSignalingTiming setLocalDescriptionMs + * @property {number|Long|null} [setRemoteDescriptionMs] SdkMeetingSessionSignalingTiming setRemoteDescriptionMs + * @property {number|Long|null} [iceGatheringStartMs] SdkMeetingSessionSignalingTiming iceGatheringStartMs + * @property {number|Long|null} [iceGatheringCompleteMs] SdkMeetingSessionSignalingTiming iceGatheringCompleteMs + * @property {number|Long|null} [iceConnectedMs] SdkMeetingSessionSignalingTiming iceConnectedMs + * @property {number|Long|null} [subscribeSentMs] SdkMeetingSessionSignalingTiming subscribeSentMs + * @property {number|Long|null} [subscribeAckMs] SdkMeetingSessionSignalingTiming subscribeAckMs + * @property {boolean|null} [timedOut] SdkMeetingSessionSignalingTiming timedOut + */ + + /** + * Constructs a new SdkMeetingSessionSignalingTiming. + * @name SdkMeetingSessionSignalingTiming + * @classdesc Represents a SdkMeetingSessionSignalingTiming. + * @implements ISdkMeetingSessionSignalingTiming + * @constructor + * @param {ISdkMeetingSessionSignalingTiming=} [properties] Properties to set + */ + function SdkMeetingSessionSignalingTiming(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + + /** + * SdkMeetingSessionSignalingTiming startMs. + * @member {number|Long} startMs + * @memberof SdkMeetingSessionSignalingTiming + * @instance + */ + SdkMeetingSessionSignalingTiming.prototype.startMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionSignalingTiming joinSentMs. + * @member {number|Long} joinSentMs + * @memberof SdkMeetingSessionSignalingTiming + * @instance + */ + SdkMeetingSessionSignalingTiming.prototype.joinSentMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionSignalingTiming joinAckReceivedMs. + * @member {number|Long} joinAckReceivedMs + * @memberof SdkMeetingSessionSignalingTiming + * @instance + */ + SdkMeetingSessionSignalingTiming.prototype.joinAckReceivedMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionSignalingTiming transportConnectedMs. + * @member {number|Long} transportConnectedMs + * @memberof SdkMeetingSessionSignalingTiming + * @instance + */ + SdkMeetingSessionSignalingTiming.prototype.transportConnectedMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionSignalingTiming createOfferMs. + * @member {number|Long} createOfferMs + * @memberof SdkMeetingSessionSignalingTiming + * @instance + */ + SdkMeetingSessionSignalingTiming.prototype.createOfferMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionSignalingTiming setLocalDescriptionMs. + * @member {number|Long} setLocalDescriptionMs + * @memberof SdkMeetingSessionSignalingTiming + * @instance + */ + SdkMeetingSessionSignalingTiming.prototype.setLocalDescriptionMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionSignalingTiming setRemoteDescriptionMs. + * @member {number|Long} setRemoteDescriptionMs + * @memberof SdkMeetingSessionSignalingTiming + * @instance + */ + SdkMeetingSessionSignalingTiming.prototype.setRemoteDescriptionMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionSignalingTiming iceGatheringStartMs. + * @member {number|Long} iceGatheringStartMs + * @memberof SdkMeetingSessionSignalingTiming + * @instance + */ + SdkMeetingSessionSignalingTiming.prototype.iceGatheringStartMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionSignalingTiming iceGatheringCompleteMs. + * @member {number|Long} iceGatheringCompleteMs + * @memberof SdkMeetingSessionSignalingTiming + * @instance + */ + SdkMeetingSessionSignalingTiming.prototype.iceGatheringCompleteMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionSignalingTiming iceConnectedMs. + * @member {number|Long} iceConnectedMs + * @memberof SdkMeetingSessionSignalingTiming + * @instance + */ + SdkMeetingSessionSignalingTiming.prototype.iceConnectedMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionSignalingTiming subscribeSentMs. + * @member {number|Long} subscribeSentMs + * @memberof SdkMeetingSessionSignalingTiming + * @instance + */ + SdkMeetingSessionSignalingTiming.prototype.subscribeSentMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionSignalingTiming subscribeAckMs. + * @member {number|Long} subscribeAckMs + * @memberof SdkMeetingSessionSignalingTiming + * @instance + */ + SdkMeetingSessionSignalingTiming.prototype.subscribeAckMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionSignalingTiming timedOut. + * @member {boolean} timedOut + * @memberof SdkMeetingSessionSignalingTiming + * @instance + */ + SdkMeetingSessionSignalingTiming.prototype.timedOut = false; + + /** + * Creates a new SdkMeetingSessionSignalingTiming instance using the specified properties. + * @function create + * @memberof SdkMeetingSessionSignalingTiming + * @static + * @param {ISdkMeetingSessionSignalingTiming=} [properties] Properties to set + * @returns {SdkMeetingSessionSignalingTiming} SdkMeetingSessionSignalingTiming instance + */ + SdkMeetingSessionSignalingTiming.create = function create(properties) { + return new SdkMeetingSessionSignalingTiming(properties); + }; + + /** + * Encodes the specified SdkMeetingSessionSignalingTiming message. Does not implicitly {@link SdkMeetingSessionSignalingTiming.verify|verify} messages. + * @function encode + * @memberof SdkMeetingSessionSignalingTiming + * @static + * @param {ISdkMeetingSessionSignalingTiming} message SdkMeetingSessionSignalingTiming message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SdkMeetingSessionSignalingTiming.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.startMs != null && Object.hasOwnProperty.call(message, "startMs")) + writer.uint32(/* id 1, wireType 0 =*/8).int64(message.startMs); + if (message.joinSentMs != null && Object.hasOwnProperty.call(message, "joinSentMs")) + writer.uint32(/* id 2, wireType 0 =*/16).int64(message.joinSentMs); + if (message.joinAckReceivedMs != null && Object.hasOwnProperty.call(message, "joinAckReceivedMs")) + writer.uint32(/* id 3, wireType 0 =*/24).int64(message.joinAckReceivedMs); + if (message.transportConnectedMs != null && Object.hasOwnProperty.call(message, "transportConnectedMs")) + writer.uint32(/* id 4, wireType 0 =*/32).int64(message.transportConnectedMs); + if (message.createOfferMs != null && Object.hasOwnProperty.call(message, "createOfferMs")) + writer.uint32(/* id 5, wireType 0 =*/40).int64(message.createOfferMs); + if (message.setLocalDescriptionMs != null && Object.hasOwnProperty.call(message, "setLocalDescriptionMs")) + writer.uint32(/* id 6, wireType 0 =*/48).int64(message.setLocalDescriptionMs); + if (message.setRemoteDescriptionMs != null && Object.hasOwnProperty.call(message, "setRemoteDescriptionMs")) + writer.uint32(/* id 7, wireType 0 =*/56).int64(message.setRemoteDescriptionMs); + if (message.iceGatheringStartMs != null && Object.hasOwnProperty.call(message, "iceGatheringStartMs")) + writer.uint32(/* id 8, wireType 0 =*/64).int64(message.iceGatheringStartMs); + if (message.iceGatheringCompleteMs != null && Object.hasOwnProperty.call(message, "iceGatheringCompleteMs")) + writer.uint32(/* id 9, wireType 0 =*/72).int64(message.iceGatheringCompleteMs); + if (message.iceConnectedMs != null && Object.hasOwnProperty.call(message, "iceConnectedMs")) + writer.uint32(/* id 10, wireType 0 =*/80).int64(message.iceConnectedMs); + if (message.subscribeSentMs != null && Object.hasOwnProperty.call(message, "subscribeSentMs")) + writer.uint32(/* id 11, wireType 0 =*/88).int64(message.subscribeSentMs); + if (message.subscribeAckMs != null && Object.hasOwnProperty.call(message, "subscribeAckMs")) + writer.uint32(/* id 12, wireType 0 =*/96).int64(message.subscribeAckMs); + if (message.timedOut != null && Object.hasOwnProperty.call(message, "timedOut")) + writer.uint32(/* id 13, wireType 0 =*/104).bool(message.timedOut); + return writer; + }; + + /** + * Encodes the specified SdkMeetingSessionSignalingTiming message, length delimited. Does not implicitly {@link SdkMeetingSessionSignalingTiming.verify|verify} messages. + * @function encodeDelimited + * @memberof SdkMeetingSessionSignalingTiming + * @static + * @param {ISdkMeetingSessionSignalingTiming} message SdkMeetingSessionSignalingTiming message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SdkMeetingSessionSignalingTiming.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + + /** + * Decodes a SdkMeetingSessionSignalingTiming message from the specified reader or buffer. + * @function decode + * @memberof SdkMeetingSessionSignalingTiming + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {SdkMeetingSessionSignalingTiming} SdkMeetingSessionSignalingTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SdkMeetingSessionSignalingTiming.decode = function decode(reader, length) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.SdkMeetingSessionSignalingTiming(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + message.startMs = reader.int64(); + break; + } + case 2: { + message.joinSentMs = reader.int64(); + break; + } + case 3: { + message.joinAckReceivedMs = reader.int64(); + break; + } + case 4: { + message.transportConnectedMs = reader.int64(); + break; + } + case 5: { + message.createOfferMs = reader.int64(); + break; + } + case 6: { + message.setLocalDescriptionMs = reader.int64(); + break; + } + case 7: { + message.setRemoteDescriptionMs = reader.int64(); + break; + } + case 8: { + message.iceGatheringStartMs = reader.int64(); + break; + } + case 9: { + message.iceGatheringCompleteMs = reader.int64(); + break; + } + case 10: { + message.iceConnectedMs = reader.int64(); + break; + } + case 11: { + message.subscribeSentMs = reader.int64(); + break; + } + case 12: { + message.subscribeAckMs = reader.int64(); + break; + } + case 13: { + message.timedOut = reader.bool(); + break; + } + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + + /** + * Decodes a SdkMeetingSessionSignalingTiming message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof SdkMeetingSessionSignalingTiming + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {SdkMeetingSessionSignalingTiming} SdkMeetingSessionSignalingTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SdkMeetingSessionSignalingTiming.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a SdkMeetingSessionSignalingTiming message. + * @function verify + * @memberof SdkMeetingSessionSignalingTiming + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + SdkMeetingSessionSignalingTiming.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.startMs != null && message.hasOwnProperty("startMs")) + if (!$util.isInteger(message.startMs) && !(message.startMs && $util.isInteger(message.startMs.low) && $util.isInteger(message.startMs.high))) + return "startMs: integer|Long expected"; + if (message.joinSentMs != null && message.hasOwnProperty("joinSentMs")) + if (!$util.isInteger(message.joinSentMs) && !(message.joinSentMs && $util.isInteger(message.joinSentMs.low) && $util.isInteger(message.joinSentMs.high))) + return "joinSentMs: integer|Long expected"; + if (message.joinAckReceivedMs != null && message.hasOwnProperty("joinAckReceivedMs")) + if (!$util.isInteger(message.joinAckReceivedMs) && !(message.joinAckReceivedMs && $util.isInteger(message.joinAckReceivedMs.low) && $util.isInteger(message.joinAckReceivedMs.high))) + return "joinAckReceivedMs: integer|Long expected"; + if (message.transportConnectedMs != null && message.hasOwnProperty("transportConnectedMs")) + if (!$util.isInteger(message.transportConnectedMs) && !(message.transportConnectedMs && $util.isInteger(message.transportConnectedMs.low) && $util.isInteger(message.transportConnectedMs.high))) + return "transportConnectedMs: integer|Long expected"; + if (message.createOfferMs != null && message.hasOwnProperty("createOfferMs")) + if (!$util.isInteger(message.createOfferMs) && !(message.createOfferMs && $util.isInteger(message.createOfferMs.low) && $util.isInteger(message.createOfferMs.high))) + return "createOfferMs: integer|Long expected"; + if (message.setLocalDescriptionMs != null && message.hasOwnProperty("setLocalDescriptionMs")) + if (!$util.isInteger(message.setLocalDescriptionMs) && !(message.setLocalDescriptionMs && $util.isInteger(message.setLocalDescriptionMs.low) && $util.isInteger(message.setLocalDescriptionMs.high))) + return "setLocalDescriptionMs: integer|Long expected"; + if (message.setRemoteDescriptionMs != null && message.hasOwnProperty("setRemoteDescriptionMs")) + if (!$util.isInteger(message.setRemoteDescriptionMs) && !(message.setRemoteDescriptionMs && $util.isInteger(message.setRemoteDescriptionMs.low) && $util.isInteger(message.setRemoteDescriptionMs.high))) + return "setRemoteDescriptionMs: integer|Long expected"; + if (message.iceGatheringStartMs != null && message.hasOwnProperty("iceGatheringStartMs")) + if (!$util.isInteger(message.iceGatheringStartMs) && !(message.iceGatheringStartMs && $util.isInteger(message.iceGatheringStartMs.low) && $util.isInteger(message.iceGatheringStartMs.high))) + return "iceGatheringStartMs: integer|Long expected"; + if (message.iceGatheringCompleteMs != null && message.hasOwnProperty("iceGatheringCompleteMs")) + if (!$util.isInteger(message.iceGatheringCompleteMs) && !(message.iceGatheringCompleteMs && $util.isInteger(message.iceGatheringCompleteMs.low) && $util.isInteger(message.iceGatheringCompleteMs.high))) + return "iceGatheringCompleteMs: integer|Long expected"; + if (message.iceConnectedMs != null && message.hasOwnProperty("iceConnectedMs")) + if (!$util.isInteger(message.iceConnectedMs) && !(message.iceConnectedMs && $util.isInteger(message.iceConnectedMs.low) && $util.isInteger(message.iceConnectedMs.high))) + return "iceConnectedMs: integer|Long expected"; + if (message.subscribeSentMs != null && message.hasOwnProperty("subscribeSentMs")) + if (!$util.isInteger(message.subscribeSentMs) && !(message.subscribeSentMs && $util.isInteger(message.subscribeSentMs.low) && $util.isInteger(message.subscribeSentMs.high))) + return "subscribeSentMs: integer|Long expected"; + if (message.subscribeAckMs != null && message.hasOwnProperty("subscribeAckMs")) + if (!$util.isInteger(message.subscribeAckMs) && !(message.subscribeAckMs && $util.isInteger(message.subscribeAckMs.low) && $util.isInteger(message.subscribeAckMs.high))) + return "subscribeAckMs: integer|Long expected"; + if (message.timedOut != null && message.hasOwnProperty("timedOut")) + if (typeof message.timedOut !== "boolean") + return "timedOut: boolean expected"; + return null; + }; + + /** + * Creates a SdkMeetingSessionSignalingTiming message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof SdkMeetingSessionSignalingTiming + * @static + * @param {Object.} object Plain object + * @returns {SdkMeetingSessionSignalingTiming} SdkMeetingSessionSignalingTiming + */ + SdkMeetingSessionSignalingTiming.fromObject = function fromObject(object) { + if (object instanceof $root.SdkMeetingSessionSignalingTiming) + return object; + var message = new $root.SdkMeetingSessionSignalingTiming(); + if (object.startMs != null) + if ($util.Long) + (message.startMs = $util.Long.fromValue(object.startMs)).unsigned = false; + else if (typeof object.startMs === "string") + message.startMs = parseInt(object.startMs, 10); + else if (typeof object.startMs === "number") + message.startMs = object.startMs; + else if (typeof object.startMs === "object") + message.startMs = new $util.LongBits(object.startMs.low >>> 0, object.startMs.high >>> 0).toNumber(); + if (object.joinSentMs != null) + if ($util.Long) + (message.joinSentMs = $util.Long.fromValue(object.joinSentMs)).unsigned = false; + else if (typeof object.joinSentMs === "string") + message.joinSentMs = parseInt(object.joinSentMs, 10); + else if (typeof object.joinSentMs === "number") + message.joinSentMs = object.joinSentMs; + else if (typeof object.joinSentMs === "object") + message.joinSentMs = new $util.LongBits(object.joinSentMs.low >>> 0, object.joinSentMs.high >>> 0).toNumber(); + if (object.joinAckReceivedMs != null) + if ($util.Long) + (message.joinAckReceivedMs = $util.Long.fromValue(object.joinAckReceivedMs)).unsigned = false; + else if (typeof object.joinAckReceivedMs === "string") + message.joinAckReceivedMs = parseInt(object.joinAckReceivedMs, 10); + else if (typeof object.joinAckReceivedMs === "number") + message.joinAckReceivedMs = object.joinAckReceivedMs; + else if (typeof object.joinAckReceivedMs === "object") + message.joinAckReceivedMs = new $util.LongBits(object.joinAckReceivedMs.low >>> 0, object.joinAckReceivedMs.high >>> 0).toNumber(); + if (object.transportConnectedMs != null) + if ($util.Long) + (message.transportConnectedMs = $util.Long.fromValue(object.transportConnectedMs)).unsigned = false; + else if (typeof object.transportConnectedMs === "string") + message.transportConnectedMs = parseInt(object.transportConnectedMs, 10); + else if (typeof object.transportConnectedMs === "number") + message.transportConnectedMs = object.transportConnectedMs; + else if (typeof object.transportConnectedMs === "object") + message.transportConnectedMs = new $util.LongBits(object.transportConnectedMs.low >>> 0, object.transportConnectedMs.high >>> 0).toNumber(); + if (object.createOfferMs != null) + if ($util.Long) + (message.createOfferMs = $util.Long.fromValue(object.createOfferMs)).unsigned = false; + else if (typeof object.createOfferMs === "string") + message.createOfferMs = parseInt(object.createOfferMs, 10); + else if (typeof object.createOfferMs === "number") + message.createOfferMs = object.createOfferMs; + else if (typeof object.createOfferMs === "object") + message.createOfferMs = new $util.LongBits(object.createOfferMs.low >>> 0, object.createOfferMs.high >>> 0).toNumber(); + if (object.setLocalDescriptionMs != null) + if ($util.Long) + (message.setLocalDescriptionMs = $util.Long.fromValue(object.setLocalDescriptionMs)).unsigned = false; + else if (typeof object.setLocalDescriptionMs === "string") + message.setLocalDescriptionMs = parseInt(object.setLocalDescriptionMs, 10); + else if (typeof object.setLocalDescriptionMs === "number") + message.setLocalDescriptionMs = object.setLocalDescriptionMs; + else if (typeof object.setLocalDescriptionMs === "object") + message.setLocalDescriptionMs = new $util.LongBits(object.setLocalDescriptionMs.low >>> 0, object.setLocalDescriptionMs.high >>> 0).toNumber(); + if (object.setRemoteDescriptionMs != null) + if ($util.Long) + (message.setRemoteDescriptionMs = $util.Long.fromValue(object.setRemoteDescriptionMs)).unsigned = false; + else if (typeof object.setRemoteDescriptionMs === "string") + message.setRemoteDescriptionMs = parseInt(object.setRemoteDescriptionMs, 10); + else if (typeof object.setRemoteDescriptionMs === "number") + message.setRemoteDescriptionMs = object.setRemoteDescriptionMs; + else if (typeof object.setRemoteDescriptionMs === "object") + message.setRemoteDescriptionMs = new $util.LongBits(object.setRemoteDescriptionMs.low >>> 0, object.setRemoteDescriptionMs.high >>> 0).toNumber(); + if (object.iceGatheringStartMs != null) + if ($util.Long) + (message.iceGatheringStartMs = $util.Long.fromValue(object.iceGatheringStartMs)).unsigned = false; + else if (typeof object.iceGatheringStartMs === "string") + message.iceGatheringStartMs = parseInt(object.iceGatheringStartMs, 10); + else if (typeof object.iceGatheringStartMs === "number") + message.iceGatheringStartMs = object.iceGatheringStartMs; + else if (typeof object.iceGatheringStartMs === "object") + message.iceGatheringStartMs = new $util.LongBits(object.iceGatheringStartMs.low >>> 0, object.iceGatheringStartMs.high >>> 0).toNumber(); + if (object.iceGatheringCompleteMs != null) + if ($util.Long) + (message.iceGatheringCompleteMs = $util.Long.fromValue(object.iceGatheringCompleteMs)).unsigned = false; + else if (typeof object.iceGatheringCompleteMs === "string") + message.iceGatheringCompleteMs = parseInt(object.iceGatheringCompleteMs, 10); + else if (typeof object.iceGatheringCompleteMs === "number") + message.iceGatheringCompleteMs = object.iceGatheringCompleteMs; + else if (typeof object.iceGatheringCompleteMs === "object") + message.iceGatheringCompleteMs = new $util.LongBits(object.iceGatheringCompleteMs.low >>> 0, object.iceGatheringCompleteMs.high >>> 0).toNumber(); + if (object.iceConnectedMs != null) + if ($util.Long) + (message.iceConnectedMs = $util.Long.fromValue(object.iceConnectedMs)).unsigned = false; + else if (typeof object.iceConnectedMs === "string") + message.iceConnectedMs = parseInt(object.iceConnectedMs, 10); + else if (typeof object.iceConnectedMs === "number") + message.iceConnectedMs = object.iceConnectedMs; + else if (typeof object.iceConnectedMs === "object") + message.iceConnectedMs = new $util.LongBits(object.iceConnectedMs.low >>> 0, object.iceConnectedMs.high >>> 0).toNumber(); + if (object.subscribeSentMs != null) + if ($util.Long) + (message.subscribeSentMs = $util.Long.fromValue(object.subscribeSentMs)).unsigned = false; + else if (typeof object.subscribeSentMs === "string") + message.subscribeSentMs = parseInt(object.subscribeSentMs, 10); + else if (typeof object.subscribeSentMs === "number") + message.subscribeSentMs = object.subscribeSentMs; + else if (typeof object.subscribeSentMs === "object") + message.subscribeSentMs = new $util.LongBits(object.subscribeSentMs.low >>> 0, object.subscribeSentMs.high >>> 0).toNumber(); + if (object.subscribeAckMs != null) + if ($util.Long) + (message.subscribeAckMs = $util.Long.fromValue(object.subscribeAckMs)).unsigned = false; + else if (typeof object.subscribeAckMs === "string") + message.subscribeAckMs = parseInt(object.subscribeAckMs, 10); + else if (typeof object.subscribeAckMs === "number") + message.subscribeAckMs = object.subscribeAckMs; + else if (typeof object.subscribeAckMs === "object") + message.subscribeAckMs = new $util.LongBits(object.subscribeAckMs.low >>> 0, object.subscribeAckMs.high >>> 0).toNumber(); + if (object.timedOut != null) + message.timedOut = Boolean(object.timedOut); + return message; + }; + + /** + * Creates a plain object from a SdkMeetingSessionSignalingTiming message. Also converts values to other types if specified. + * @function toObject + * @memberof SdkMeetingSessionSignalingTiming + * @static + * @param {SdkMeetingSessionSignalingTiming} message SdkMeetingSessionSignalingTiming + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + SdkMeetingSessionSignalingTiming.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.defaults) { + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.startMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.startMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.joinSentMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.joinSentMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.joinAckReceivedMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.joinAckReceivedMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.transportConnectedMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.transportConnectedMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.createOfferMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.createOfferMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.setLocalDescriptionMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.setLocalDescriptionMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.setRemoteDescriptionMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.setRemoteDescriptionMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.iceGatheringStartMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.iceGatheringStartMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.iceGatheringCompleteMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.iceGatheringCompleteMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.iceConnectedMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.iceConnectedMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.subscribeSentMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.subscribeSentMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.subscribeAckMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.subscribeAckMs = options.longs === String ? "0" : 0; + object.timedOut = false; + } + if (message.startMs != null && message.hasOwnProperty("startMs")) + if (typeof message.startMs === "number") + object.startMs = options.longs === String ? String(message.startMs) : message.startMs; + else + object.startMs = options.longs === String ? $util.Long.prototype.toString.call(message.startMs) : options.longs === Number ? new $util.LongBits(message.startMs.low >>> 0, message.startMs.high >>> 0).toNumber() : message.startMs; + if (message.joinSentMs != null && message.hasOwnProperty("joinSentMs")) + if (typeof message.joinSentMs === "number") + object.joinSentMs = options.longs === String ? String(message.joinSentMs) : message.joinSentMs; + else + object.joinSentMs = options.longs === String ? $util.Long.prototype.toString.call(message.joinSentMs) : options.longs === Number ? new $util.LongBits(message.joinSentMs.low >>> 0, message.joinSentMs.high >>> 0).toNumber() : message.joinSentMs; + if (message.joinAckReceivedMs != null && message.hasOwnProperty("joinAckReceivedMs")) + if (typeof message.joinAckReceivedMs === "number") + object.joinAckReceivedMs = options.longs === String ? String(message.joinAckReceivedMs) : message.joinAckReceivedMs; + else + object.joinAckReceivedMs = options.longs === String ? $util.Long.prototype.toString.call(message.joinAckReceivedMs) : options.longs === Number ? new $util.LongBits(message.joinAckReceivedMs.low >>> 0, message.joinAckReceivedMs.high >>> 0).toNumber() : message.joinAckReceivedMs; + if (message.transportConnectedMs != null && message.hasOwnProperty("transportConnectedMs")) + if (typeof message.transportConnectedMs === "number") + object.transportConnectedMs = options.longs === String ? String(message.transportConnectedMs) : message.transportConnectedMs; + else + object.transportConnectedMs = options.longs === String ? $util.Long.prototype.toString.call(message.transportConnectedMs) : options.longs === Number ? new $util.LongBits(message.transportConnectedMs.low >>> 0, message.transportConnectedMs.high >>> 0).toNumber() : message.transportConnectedMs; + if (message.createOfferMs != null && message.hasOwnProperty("createOfferMs")) + if (typeof message.createOfferMs === "number") + object.createOfferMs = options.longs === String ? String(message.createOfferMs) : message.createOfferMs; + else + object.createOfferMs = options.longs === String ? $util.Long.prototype.toString.call(message.createOfferMs) : options.longs === Number ? new $util.LongBits(message.createOfferMs.low >>> 0, message.createOfferMs.high >>> 0).toNumber() : message.createOfferMs; + if (message.setLocalDescriptionMs != null && message.hasOwnProperty("setLocalDescriptionMs")) + if (typeof message.setLocalDescriptionMs === "number") + object.setLocalDescriptionMs = options.longs === String ? String(message.setLocalDescriptionMs) : message.setLocalDescriptionMs; + else + object.setLocalDescriptionMs = options.longs === String ? $util.Long.prototype.toString.call(message.setLocalDescriptionMs) : options.longs === Number ? new $util.LongBits(message.setLocalDescriptionMs.low >>> 0, message.setLocalDescriptionMs.high >>> 0).toNumber() : message.setLocalDescriptionMs; + if (message.setRemoteDescriptionMs != null && message.hasOwnProperty("setRemoteDescriptionMs")) + if (typeof message.setRemoteDescriptionMs === "number") + object.setRemoteDescriptionMs = options.longs === String ? String(message.setRemoteDescriptionMs) : message.setRemoteDescriptionMs; + else + object.setRemoteDescriptionMs = options.longs === String ? $util.Long.prototype.toString.call(message.setRemoteDescriptionMs) : options.longs === Number ? new $util.LongBits(message.setRemoteDescriptionMs.low >>> 0, message.setRemoteDescriptionMs.high >>> 0).toNumber() : message.setRemoteDescriptionMs; + if (message.iceGatheringStartMs != null && message.hasOwnProperty("iceGatheringStartMs")) + if (typeof message.iceGatheringStartMs === "number") + object.iceGatheringStartMs = options.longs === String ? String(message.iceGatheringStartMs) : message.iceGatheringStartMs; + else + object.iceGatheringStartMs = options.longs === String ? $util.Long.prototype.toString.call(message.iceGatheringStartMs) : options.longs === Number ? new $util.LongBits(message.iceGatheringStartMs.low >>> 0, message.iceGatheringStartMs.high >>> 0).toNumber() : message.iceGatheringStartMs; + if (message.iceGatheringCompleteMs != null && message.hasOwnProperty("iceGatheringCompleteMs")) + if (typeof message.iceGatheringCompleteMs === "number") + object.iceGatheringCompleteMs = options.longs === String ? String(message.iceGatheringCompleteMs) : message.iceGatheringCompleteMs; + else + object.iceGatheringCompleteMs = options.longs === String ? $util.Long.prototype.toString.call(message.iceGatheringCompleteMs) : options.longs === Number ? new $util.LongBits(message.iceGatheringCompleteMs.low >>> 0, message.iceGatheringCompleteMs.high >>> 0).toNumber() : message.iceGatheringCompleteMs; + if (message.iceConnectedMs != null && message.hasOwnProperty("iceConnectedMs")) + if (typeof message.iceConnectedMs === "number") + object.iceConnectedMs = options.longs === String ? String(message.iceConnectedMs) : message.iceConnectedMs; + else + object.iceConnectedMs = options.longs === String ? $util.Long.prototype.toString.call(message.iceConnectedMs) : options.longs === Number ? new $util.LongBits(message.iceConnectedMs.low >>> 0, message.iceConnectedMs.high >>> 0).toNumber() : message.iceConnectedMs; + if (message.subscribeSentMs != null && message.hasOwnProperty("subscribeSentMs")) + if (typeof message.subscribeSentMs === "number") + object.subscribeSentMs = options.longs === String ? String(message.subscribeSentMs) : message.subscribeSentMs; + else + object.subscribeSentMs = options.longs === String ? $util.Long.prototype.toString.call(message.subscribeSentMs) : options.longs === Number ? new $util.LongBits(message.subscribeSentMs.low >>> 0, message.subscribeSentMs.high >>> 0).toNumber() : message.subscribeSentMs; + if (message.subscribeAckMs != null && message.hasOwnProperty("subscribeAckMs")) + if (typeof message.subscribeAckMs === "number") + object.subscribeAckMs = options.longs === String ? String(message.subscribeAckMs) : message.subscribeAckMs; + else + object.subscribeAckMs = options.longs === String ? $util.Long.prototype.toString.call(message.subscribeAckMs) : options.longs === Number ? new $util.LongBits(message.subscribeAckMs.low >>> 0, message.subscribeAckMs.high >>> 0).toNumber() : message.subscribeAckMs; + if (message.timedOut != null && message.hasOwnProperty("timedOut")) + object.timedOut = message.timedOut; + return object; + }; + + /** + * Converts this SdkMeetingSessionSignalingTiming to JSON. + * @function toJSON + * @memberof SdkMeetingSessionSignalingTiming + * @instance + * @returns {Object.} JSON object + */ + SdkMeetingSessionSignalingTiming.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for SdkMeetingSessionSignalingTiming + * @function getTypeUrl + * @memberof SdkMeetingSessionSignalingTiming + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + SdkMeetingSessionSignalingTiming.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/SdkMeetingSessionSignalingTiming"; + }; + + return SdkMeetingSessionSignalingTiming; +})(); + +$root.SdkMeetingSessionRemoteAudioTiming = (function() { + + /** + * Properties of a SdkMeetingSessionRemoteAudioTiming. + * @name ISdkMeetingSessionRemoteAudioTiming + * @interface ISdkMeetingSessionRemoteAudioTiming + * @property {number|Long|null} [addedMs] SdkMeetingSessionRemoteAudioTiming addedMs + * @property {number|Long|null} [firstPacketReceivedMs] SdkMeetingSessionRemoteAudioTiming firstPacketReceivedMs + * @property {number|Long|null} [firstFrameRenderedMs] SdkMeetingSessionRemoteAudioTiming firstFrameRenderedMs + * @property {boolean|null} [timedOut] SdkMeetingSessionRemoteAudioTiming timedOut + * @property {boolean|null} [removed] SdkMeetingSessionRemoteAudioTiming removed + */ + + /** + * Constructs a new SdkMeetingSessionRemoteAudioTiming. + * @name SdkMeetingSessionRemoteAudioTiming + * @classdesc Represents a SdkMeetingSessionRemoteAudioTiming. + * @implements ISdkMeetingSessionRemoteAudioTiming + * @constructor + * @param {ISdkMeetingSessionRemoteAudioTiming=} [properties] Properties to set + */ + function SdkMeetingSessionRemoteAudioTiming(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + + /** + * SdkMeetingSessionRemoteAudioTiming addedMs. + * @member {number|Long} addedMs + * @memberof SdkMeetingSessionRemoteAudioTiming + * @instance + */ + SdkMeetingSessionRemoteAudioTiming.prototype.addedMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionRemoteAudioTiming firstPacketReceivedMs. + * @member {number|Long} firstPacketReceivedMs + * @memberof SdkMeetingSessionRemoteAudioTiming + * @instance + */ + SdkMeetingSessionRemoteAudioTiming.prototype.firstPacketReceivedMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionRemoteAudioTiming firstFrameRenderedMs. + * @member {number|Long} firstFrameRenderedMs + * @memberof SdkMeetingSessionRemoteAudioTiming + * @instance + */ + SdkMeetingSessionRemoteAudioTiming.prototype.firstFrameRenderedMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionRemoteAudioTiming timedOut. + * @member {boolean} timedOut + * @memberof SdkMeetingSessionRemoteAudioTiming + * @instance + */ + SdkMeetingSessionRemoteAudioTiming.prototype.timedOut = false; + + /** + * SdkMeetingSessionRemoteAudioTiming removed. + * @member {boolean} removed + * @memberof SdkMeetingSessionRemoteAudioTiming + * @instance + */ + SdkMeetingSessionRemoteAudioTiming.prototype.removed = false; + + /** + * Creates a new SdkMeetingSessionRemoteAudioTiming instance using the specified properties. + * @function create + * @memberof SdkMeetingSessionRemoteAudioTiming + * @static + * @param {ISdkMeetingSessionRemoteAudioTiming=} [properties] Properties to set + * @returns {SdkMeetingSessionRemoteAudioTiming} SdkMeetingSessionRemoteAudioTiming instance + */ + SdkMeetingSessionRemoteAudioTiming.create = function create(properties) { + return new SdkMeetingSessionRemoteAudioTiming(properties); + }; + + /** + * Encodes the specified SdkMeetingSessionRemoteAudioTiming message. Does not implicitly {@link SdkMeetingSessionRemoteAudioTiming.verify|verify} messages. + * @function encode + * @memberof SdkMeetingSessionRemoteAudioTiming + * @static + * @param {ISdkMeetingSessionRemoteAudioTiming} message SdkMeetingSessionRemoteAudioTiming message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SdkMeetingSessionRemoteAudioTiming.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.addedMs != null && Object.hasOwnProperty.call(message, "addedMs")) + writer.uint32(/* id 1, wireType 0 =*/8).int64(message.addedMs); + if (message.firstPacketReceivedMs != null && Object.hasOwnProperty.call(message, "firstPacketReceivedMs")) + writer.uint32(/* id 2, wireType 0 =*/16).int64(message.firstPacketReceivedMs); + if (message.firstFrameRenderedMs != null && Object.hasOwnProperty.call(message, "firstFrameRenderedMs")) + writer.uint32(/* id 3, wireType 0 =*/24).int64(message.firstFrameRenderedMs); + if (message.timedOut != null && Object.hasOwnProperty.call(message, "timedOut")) + writer.uint32(/* id 4, wireType 0 =*/32).bool(message.timedOut); + if (message.removed != null && Object.hasOwnProperty.call(message, "removed")) + writer.uint32(/* id 5, wireType 0 =*/40).bool(message.removed); + return writer; + }; + + /** + * Encodes the specified SdkMeetingSessionRemoteAudioTiming message, length delimited. Does not implicitly {@link SdkMeetingSessionRemoteAudioTiming.verify|verify} messages. + * @function encodeDelimited + * @memberof SdkMeetingSessionRemoteAudioTiming + * @static + * @param {ISdkMeetingSessionRemoteAudioTiming} message SdkMeetingSessionRemoteAudioTiming message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SdkMeetingSessionRemoteAudioTiming.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + + /** + * Decodes a SdkMeetingSessionRemoteAudioTiming message from the specified reader or buffer. + * @function decode + * @memberof SdkMeetingSessionRemoteAudioTiming + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {SdkMeetingSessionRemoteAudioTiming} SdkMeetingSessionRemoteAudioTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SdkMeetingSessionRemoteAudioTiming.decode = function decode(reader, length) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.SdkMeetingSessionRemoteAudioTiming(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + message.addedMs = reader.int64(); + break; + } + case 2: { + message.firstPacketReceivedMs = reader.int64(); + break; + } + case 3: { + message.firstFrameRenderedMs = reader.int64(); + break; + } + case 4: { + message.timedOut = reader.bool(); + break; + } + case 5: { + message.removed = reader.bool(); + break; + } + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + + /** + * Decodes a SdkMeetingSessionRemoteAudioTiming message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof SdkMeetingSessionRemoteAudioTiming + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {SdkMeetingSessionRemoteAudioTiming} SdkMeetingSessionRemoteAudioTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SdkMeetingSessionRemoteAudioTiming.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a SdkMeetingSessionRemoteAudioTiming message. + * @function verify + * @memberof SdkMeetingSessionRemoteAudioTiming + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + SdkMeetingSessionRemoteAudioTiming.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.addedMs != null && message.hasOwnProperty("addedMs")) + if (!$util.isInteger(message.addedMs) && !(message.addedMs && $util.isInteger(message.addedMs.low) && $util.isInteger(message.addedMs.high))) + return "addedMs: integer|Long expected"; + if (message.firstPacketReceivedMs != null && message.hasOwnProperty("firstPacketReceivedMs")) + if (!$util.isInteger(message.firstPacketReceivedMs) && !(message.firstPacketReceivedMs && $util.isInteger(message.firstPacketReceivedMs.low) && $util.isInteger(message.firstPacketReceivedMs.high))) + return "firstPacketReceivedMs: integer|Long expected"; + if (message.firstFrameRenderedMs != null && message.hasOwnProperty("firstFrameRenderedMs")) + if (!$util.isInteger(message.firstFrameRenderedMs) && !(message.firstFrameRenderedMs && $util.isInteger(message.firstFrameRenderedMs.low) && $util.isInteger(message.firstFrameRenderedMs.high))) + return "firstFrameRenderedMs: integer|Long expected"; + if (message.timedOut != null && message.hasOwnProperty("timedOut")) + if (typeof message.timedOut !== "boolean") + return "timedOut: boolean expected"; + if (message.removed != null && message.hasOwnProperty("removed")) + if (typeof message.removed !== "boolean") + return "removed: boolean expected"; + return null; + }; + + /** + * Creates a SdkMeetingSessionRemoteAudioTiming message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof SdkMeetingSessionRemoteAudioTiming + * @static + * @param {Object.} object Plain object + * @returns {SdkMeetingSessionRemoteAudioTiming} SdkMeetingSessionRemoteAudioTiming + */ + SdkMeetingSessionRemoteAudioTiming.fromObject = function fromObject(object) { + if (object instanceof $root.SdkMeetingSessionRemoteAudioTiming) + return object; + var message = new $root.SdkMeetingSessionRemoteAudioTiming(); + if (object.addedMs != null) + if ($util.Long) + (message.addedMs = $util.Long.fromValue(object.addedMs)).unsigned = false; + else if (typeof object.addedMs === "string") + message.addedMs = parseInt(object.addedMs, 10); + else if (typeof object.addedMs === "number") + message.addedMs = object.addedMs; + else if (typeof object.addedMs === "object") + message.addedMs = new $util.LongBits(object.addedMs.low >>> 0, object.addedMs.high >>> 0).toNumber(); + if (object.firstPacketReceivedMs != null) + if ($util.Long) + (message.firstPacketReceivedMs = $util.Long.fromValue(object.firstPacketReceivedMs)).unsigned = false; + else if (typeof object.firstPacketReceivedMs === "string") + message.firstPacketReceivedMs = parseInt(object.firstPacketReceivedMs, 10); + else if (typeof object.firstPacketReceivedMs === "number") + message.firstPacketReceivedMs = object.firstPacketReceivedMs; + else if (typeof object.firstPacketReceivedMs === "object") + message.firstPacketReceivedMs = new $util.LongBits(object.firstPacketReceivedMs.low >>> 0, object.firstPacketReceivedMs.high >>> 0).toNumber(); + if (object.firstFrameRenderedMs != null) + if ($util.Long) + (message.firstFrameRenderedMs = $util.Long.fromValue(object.firstFrameRenderedMs)).unsigned = false; + else if (typeof object.firstFrameRenderedMs === "string") + message.firstFrameRenderedMs = parseInt(object.firstFrameRenderedMs, 10); + else if (typeof object.firstFrameRenderedMs === "number") + message.firstFrameRenderedMs = object.firstFrameRenderedMs; + else if (typeof object.firstFrameRenderedMs === "object") + message.firstFrameRenderedMs = new $util.LongBits(object.firstFrameRenderedMs.low >>> 0, object.firstFrameRenderedMs.high >>> 0).toNumber(); + if (object.timedOut != null) + message.timedOut = Boolean(object.timedOut); + if (object.removed != null) + message.removed = Boolean(object.removed); + return message; + }; + + /** + * Creates a plain object from a SdkMeetingSessionRemoteAudioTiming message. Also converts values to other types if specified. + * @function toObject + * @memberof SdkMeetingSessionRemoteAudioTiming + * @static + * @param {SdkMeetingSessionRemoteAudioTiming} message SdkMeetingSessionRemoteAudioTiming + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + SdkMeetingSessionRemoteAudioTiming.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.defaults) { + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.addedMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.addedMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.firstPacketReceivedMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.firstPacketReceivedMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.firstFrameRenderedMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.firstFrameRenderedMs = options.longs === String ? "0" : 0; + object.timedOut = false; + object.removed = false; + } + if (message.addedMs != null && message.hasOwnProperty("addedMs")) + if (typeof message.addedMs === "number") + object.addedMs = options.longs === String ? String(message.addedMs) : message.addedMs; + else + object.addedMs = options.longs === String ? $util.Long.prototype.toString.call(message.addedMs) : options.longs === Number ? new $util.LongBits(message.addedMs.low >>> 0, message.addedMs.high >>> 0).toNumber() : message.addedMs; + if (message.firstPacketReceivedMs != null && message.hasOwnProperty("firstPacketReceivedMs")) + if (typeof message.firstPacketReceivedMs === "number") + object.firstPacketReceivedMs = options.longs === String ? String(message.firstPacketReceivedMs) : message.firstPacketReceivedMs; + else + object.firstPacketReceivedMs = options.longs === String ? $util.Long.prototype.toString.call(message.firstPacketReceivedMs) : options.longs === Number ? new $util.LongBits(message.firstPacketReceivedMs.low >>> 0, message.firstPacketReceivedMs.high >>> 0).toNumber() : message.firstPacketReceivedMs; + if (message.firstFrameRenderedMs != null && message.hasOwnProperty("firstFrameRenderedMs")) + if (typeof message.firstFrameRenderedMs === "number") + object.firstFrameRenderedMs = options.longs === String ? String(message.firstFrameRenderedMs) : message.firstFrameRenderedMs; + else + object.firstFrameRenderedMs = options.longs === String ? $util.Long.prototype.toString.call(message.firstFrameRenderedMs) : options.longs === Number ? new $util.LongBits(message.firstFrameRenderedMs.low >>> 0, message.firstFrameRenderedMs.high >>> 0).toNumber() : message.firstFrameRenderedMs; + if (message.timedOut != null && message.hasOwnProperty("timedOut")) + object.timedOut = message.timedOut; + if (message.removed != null && message.hasOwnProperty("removed")) + object.removed = message.removed; + return object; + }; + + /** + * Converts this SdkMeetingSessionRemoteAudioTiming to JSON. + * @function toJSON + * @memberof SdkMeetingSessionRemoteAudioTiming + * @instance + * @returns {Object.} JSON object + */ + SdkMeetingSessionRemoteAudioTiming.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for SdkMeetingSessionRemoteAudioTiming + * @function getTypeUrl + * @memberof SdkMeetingSessionRemoteAudioTiming + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + SdkMeetingSessionRemoteAudioTiming.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/SdkMeetingSessionRemoteAudioTiming"; + }; + + return SdkMeetingSessionRemoteAudioTiming; +})(); + +$root.SdkMeetingSessionLocalAudioTiming = (function() { + + /** + * Properties of a SdkMeetingSessionLocalAudioTiming. + * @name ISdkMeetingSessionLocalAudioTiming + * @interface ISdkMeetingSessionLocalAudioTiming + * @property {number|Long|null} [addedMs] SdkMeetingSessionLocalAudioTiming addedMs + * @property {number|Long|null} [firstFrameCapturedMs] SdkMeetingSessionLocalAudioTiming firstFrameCapturedMs + * @property {number|Long|null} [firstPacketSentMs] SdkMeetingSessionLocalAudioTiming firstPacketSentMs + * @property {boolean|null} [timedOut] SdkMeetingSessionLocalAudioTiming timedOut + * @property {boolean|null} [removed] SdkMeetingSessionLocalAudioTiming removed + */ + + /** + * Constructs a new SdkMeetingSessionLocalAudioTiming. + * @name SdkMeetingSessionLocalAudioTiming + * @classdesc Represents a SdkMeetingSessionLocalAudioTiming. + * @implements ISdkMeetingSessionLocalAudioTiming + * @constructor + * @param {ISdkMeetingSessionLocalAudioTiming=} [properties] Properties to set + */ + function SdkMeetingSessionLocalAudioTiming(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + + /** + * SdkMeetingSessionLocalAudioTiming addedMs. + * @member {number|Long} addedMs + * @memberof SdkMeetingSessionLocalAudioTiming + * @instance + */ + SdkMeetingSessionLocalAudioTiming.prototype.addedMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionLocalAudioTiming firstFrameCapturedMs. + * @member {number|Long} firstFrameCapturedMs + * @memberof SdkMeetingSessionLocalAudioTiming + * @instance + */ + SdkMeetingSessionLocalAudioTiming.prototype.firstFrameCapturedMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionLocalAudioTiming firstPacketSentMs. + * @member {number|Long} firstPacketSentMs + * @memberof SdkMeetingSessionLocalAudioTiming + * @instance + */ + SdkMeetingSessionLocalAudioTiming.prototype.firstPacketSentMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionLocalAudioTiming timedOut. + * @member {boolean} timedOut + * @memberof SdkMeetingSessionLocalAudioTiming + * @instance + */ + SdkMeetingSessionLocalAudioTiming.prototype.timedOut = false; + + /** + * SdkMeetingSessionLocalAudioTiming removed. + * @member {boolean} removed + * @memberof SdkMeetingSessionLocalAudioTiming + * @instance + */ + SdkMeetingSessionLocalAudioTiming.prototype.removed = false; + + /** + * Creates a new SdkMeetingSessionLocalAudioTiming instance using the specified properties. + * @function create + * @memberof SdkMeetingSessionLocalAudioTiming + * @static + * @param {ISdkMeetingSessionLocalAudioTiming=} [properties] Properties to set + * @returns {SdkMeetingSessionLocalAudioTiming} SdkMeetingSessionLocalAudioTiming instance + */ + SdkMeetingSessionLocalAudioTiming.create = function create(properties) { + return new SdkMeetingSessionLocalAudioTiming(properties); + }; + + /** + * Encodes the specified SdkMeetingSessionLocalAudioTiming message. Does not implicitly {@link SdkMeetingSessionLocalAudioTiming.verify|verify} messages. + * @function encode + * @memberof SdkMeetingSessionLocalAudioTiming + * @static + * @param {ISdkMeetingSessionLocalAudioTiming} message SdkMeetingSessionLocalAudioTiming message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SdkMeetingSessionLocalAudioTiming.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.addedMs != null && Object.hasOwnProperty.call(message, "addedMs")) + writer.uint32(/* id 1, wireType 0 =*/8).int64(message.addedMs); + if (message.firstFrameCapturedMs != null && Object.hasOwnProperty.call(message, "firstFrameCapturedMs")) + writer.uint32(/* id 2, wireType 0 =*/16).int64(message.firstFrameCapturedMs); + if (message.firstPacketSentMs != null && Object.hasOwnProperty.call(message, "firstPacketSentMs")) + writer.uint32(/* id 3, wireType 0 =*/24).int64(message.firstPacketSentMs); + if (message.timedOut != null && Object.hasOwnProperty.call(message, "timedOut")) + writer.uint32(/* id 4, wireType 0 =*/32).bool(message.timedOut); + if (message.removed != null && Object.hasOwnProperty.call(message, "removed")) + writer.uint32(/* id 5, wireType 0 =*/40).bool(message.removed); + return writer; + }; + + /** + * Encodes the specified SdkMeetingSessionLocalAudioTiming message, length delimited. Does not implicitly {@link SdkMeetingSessionLocalAudioTiming.verify|verify} messages. + * @function encodeDelimited + * @memberof SdkMeetingSessionLocalAudioTiming + * @static + * @param {ISdkMeetingSessionLocalAudioTiming} message SdkMeetingSessionLocalAudioTiming message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SdkMeetingSessionLocalAudioTiming.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + + /** + * Decodes a SdkMeetingSessionLocalAudioTiming message from the specified reader or buffer. + * @function decode + * @memberof SdkMeetingSessionLocalAudioTiming + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {SdkMeetingSessionLocalAudioTiming} SdkMeetingSessionLocalAudioTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SdkMeetingSessionLocalAudioTiming.decode = function decode(reader, length) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.SdkMeetingSessionLocalAudioTiming(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + message.addedMs = reader.int64(); + break; + } + case 2: { + message.firstFrameCapturedMs = reader.int64(); + break; + } + case 3: { + message.firstPacketSentMs = reader.int64(); + break; + } + case 4: { + message.timedOut = reader.bool(); + break; + } + case 5: { + message.removed = reader.bool(); + break; + } + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + + /** + * Decodes a SdkMeetingSessionLocalAudioTiming message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof SdkMeetingSessionLocalAudioTiming + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {SdkMeetingSessionLocalAudioTiming} SdkMeetingSessionLocalAudioTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SdkMeetingSessionLocalAudioTiming.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a SdkMeetingSessionLocalAudioTiming message. + * @function verify + * @memberof SdkMeetingSessionLocalAudioTiming + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + SdkMeetingSessionLocalAudioTiming.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.addedMs != null && message.hasOwnProperty("addedMs")) + if (!$util.isInteger(message.addedMs) && !(message.addedMs && $util.isInteger(message.addedMs.low) && $util.isInteger(message.addedMs.high))) + return "addedMs: integer|Long expected"; + if (message.firstFrameCapturedMs != null && message.hasOwnProperty("firstFrameCapturedMs")) + if (!$util.isInteger(message.firstFrameCapturedMs) && !(message.firstFrameCapturedMs && $util.isInteger(message.firstFrameCapturedMs.low) && $util.isInteger(message.firstFrameCapturedMs.high))) + return "firstFrameCapturedMs: integer|Long expected"; + if (message.firstPacketSentMs != null && message.hasOwnProperty("firstPacketSentMs")) + if (!$util.isInteger(message.firstPacketSentMs) && !(message.firstPacketSentMs && $util.isInteger(message.firstPacketSentMs.low) && $util.isInteger(message.firstPacketSentMs.high))) + return "firstPacketSentMs: integer|Long expected"; + if (message.timedOut != null && message.hasOwnProperty("timedOut")) + if (typeof message.timedOut !== "boolean") + return "timedOut: boolean expected"; + if (message.removed != null && message.hasOwnProperty("removed")) + if (typeof message.removed !== "boolean") + return "removed: boolean expected"; + return null; + }; + + /** + * Creates a SdkMeetingSessionLocalAudioTiming message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof SdkMeetingSessionLocalAudioTiming + * @static + * @param {Object.} object Plain object + * @returns {SdkMeetingSessionLocalAudioTiming} SdkMeetingSessionLocalAudioTiming + */ + SdkMeetingSessionLocalAudioTiming.fromObject = function fromObject(object) { + if (object instanceof $root.SdkMeetingSessionLocalAudioTiming) + return object; + var message = new $root.SdkMeetingSessionLocalAudioTiming(); + if (object.addedMs != null) + if ($util.Long) + (message.addedMs = $util.Long.fromValue(object.addedMs)).unsigned = false; + else if (typeof object.addedMs === "string") + message.addedMs = parseInt(object.addedMs, 10); + else if (typeof object.addedMs === "number") + message.addedMs = object.addedMs; + else if (typeof object.addedMs === "object") + message.addedMs = new $util.LongBits(object.addedMs.low >>> 0, object.addedMs.high >>> 0).toNumber(); + if (object.firstFrameCapturedMs != null) + if ($util.Long) + (message.firstFrameCapturedMs = $util.Long.fromValue(object.firstFrameCapturedMs)).unsigned = false; + else if (typeof object.firstFrameCapturedMs === "string") + message.firstFrameCapturedMs = parseInt(object.firstFrameCapturedMs, 10); + else if (typeof object.firstFrameCapturedMs === "number") + message.firstFrameCapturedMs = object.firstFrameCapturedMs; + else if (typeof object.firstFrameCapturedMs === "object") + message.firstFrameCapturedMs = new $util.LongBits(object.firstFrameCapturedMs.low >>> 0, object.firstFrameCapturedMs.high >>> 0).toNumber(); + if (object.firstPacketSentMs != null) + if ($util.Long) + (message.firstPacketSentMs = $util.Long.fromValue(object.firstPacketSentMs)).unsigned = false; + else if (typeof object.firstPacketSentMs === "string") + message.firstPacketSentMs = parseInt(object.firstPacketSentMs, 10); + else if (typeof object.firstPacketSentMs === "number") + message.firstPacketSentMs = object.firstPacketSentMs; + else if (typeof object.firstPacketSentMs === "object") + message.firstPacketSentMs = new $util.LongBits(object.firstPacketSentMs.low >>> 0, object.firstPacketSentMs.high >>> 0).toNumber(); + if (object.timedOut != null) + message.timedOut = Boolean(object.timedOut); + if (object.removed != null) + message.removed = Boolean(object.removed); + return message; + }; + + /** + * Creates a plain object from a SdkMeetingSessionLocalAudioTiming message. Also converts values to other types if specified. + * @function toObject + * @memberof SdkMeetingSessionLocalAudioTiming + * @static + * @param {SdkMeetingSessionLocalAudioTiming} message SdkMeetingSessionLocalAudioTiming + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + SdkMeetingSessionLocalAudioTiming.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.defaults) { + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.addedMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.addedMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.firstFrameCapturedMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.firstFrameCapturedMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.firstPacketSentMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.firstPacketSentMs = options.longs === String ? "0" : 0; + object.timedOut = false; + object.removed = false; + } + if (message.addedMs != null && message.hasOwnProperty("addedMs")) + if (typeof message.addedMs === "number") + object.addedMs = options.longs === String ? String(message.addedMs) : message.addedMs; + else + object.addedMs = options.longs === String ? $util.Long.prototype.toString.call(message.addedMs) : options.longs === Number ? new $util.LongBits(message.addedMs.low >>> 0, message.addedMs.high >>> 0).toNumber() : message.addedMs; + if (message.firstFrameCapturedMs != null && message.hasOwnProperty("firstFrameCapturedMs")) + if (typeof message.firstFrameCapturedMs === "number") + object.firstFrameCapturedMs = options.longs === String ? String(message.firstFrameCapturedMs) : message.firstFrameCapturedMs; + else + object.firstFrameCapturedMs = options.longs === String ? $util.Long.prototype.toString.call(message.firstFrameCapturedMs) : options.longs === Number ? new $util.LongBits(message.firstFrameCapturedMs.low >>> 0, message.firstFrameCapturedMs.high >>> 0).toNumber() : message.firstFrameCapturedMs; + if (message.firstPacketSentMs != null && message.hasOwnProperty("firstPacketSentMs")) + if (typeof message.firstPacketSentMs === "number") + object.firstPacketSentMs = options.longs === String ? String(message.firstPacketSentMs) : message.firstPacketSentMs; + else + object.firstPacketSentMs = options.longs === String ? $util.Long.prototype.toString.call(message.firstPacketSentMs) : options.longs === Number ? new $util.LongBits(message.firstPacketSentMs.low >>> 0, message.firstPacketSentMs.high >>> 0).toNumber() : message.firstPacketSentMs; + if (message.timedOut != null && message.hasOwnProperty("timedOut")) + object.timedOut = message.timedOut; + if (message.removed != null && message.hasOwnProperty("removed")) + object.removed = message.removed; + return object; + }; + + /** + * Converts this SdkMeetingSessionLocalAudioTiming to JSON. + * @function toJSON + * @memberof SdkMeetingSessionLocalAudioTiming + * @instance + * @returns {Object.} JSON object + */ + SdkMeetingSessionLocalAudioTiming.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for SdkMeetingSessionLocalAudioTiming + * @function getTypeUrl + * @memberof SdkMeetingSessionLocalAudioTiming + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + SdkMeetingSessionLocalAudioTiming.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/SdkMeetingSessionLocalAudioTiming"; + }; + + return SdkMeetingSessionLocalAudioTiming; +})(); + +$root.SdkMeetingSessionLocalVideoTiming = (function() { + + /** + * Properties of a SdkMeetingSessionLocalVideoTiming. + * @name ISdkMeetingSessionLocalVideoTiming + * @interface ISdkMeetingSessionLocalVideoTiming + * @property {number|Long|null} [addedMs] SdkMeetingSessionLocalVideoTiming addedMs + * @property {number|Long|null} [firstFrameCapturedMs] SdkMeetingSessionLocalVideoTiming firstFrameCapturedMs + * @property {number|Long|null} [firstFrameSentMs] SdkMeetingSessionLocalVideoTiming firstFrameSentMs + * @property {boolean|null} [timedOut] SdkMeetingSessionLocalVideoTiming timedOut + * @property {boolean|null} [removed] SdkMeetingSessionLocalVideoTiming removed + */ + + /** + * Constructs a new SdkMeetingSessionLocalVideoTiming. + * @name SdkMeetingSessionLocalVideoTiming + * @classdesc Represents a SdkMeetingSessionLocalVideoTiming. + * @implements ISdkMeetingSessionLocalVideoTiming + * @constructor + * @param {ISdkMeetingSessionLocalVideoTiming=} [properties] Properties to set + */ + function SdkMeetingSessionLocalVideoTiming(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + + /** + * SdkMeetingSessionLocalVideoTiming addedMs. + * @member {number|Long} addedMs + * @memberof SdkMeetingSessionLocalVideoTiming + * @instance + */ + SdkMeetingSessionLocalVideoTiming.prototype.addedMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionLocalVideoTiming firstFrameCapturedMs. + * @member {number|Long} firstFrameCapturedMs + * @memberof SdkMeetingSessionLocalVideoTiming + * @instance + */ + SdkMeetingSessionLocalVideoTiming.prototype.firstFrameCapturedMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionLocalVideoTiming firstFrameSentMs. + * @member {number|Long} firstFrameSentMs + * @memberof SdkMeetingSessionLocalVideoTiming + * @instance + */ + SdkMeetingSessionLocalVideoTiming.prototype.firstFrameSentMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionLocalVideoTiming timedOut. + * @member {boolean} timedOut + * @memberof SdkMeetingSessionLocalVideoTiming + * @instance + */ + SdkMeetingSessionLocalVideoTiming.prototype.timedOut = false; + + /** + * SdkMeetingSessionLocalVideoTiming removed. + * @member {boolean} removed + * @memberof SdkMeetingSessionLocalVideoTiming + * @instance + */ + SdkMeetingSessionLocalVideoTiming.prototype.removed = false; + + /** + * Creates a new SdkMeetingSessionLocalVideoTiming instance using the specified properties. + * @function create + * @memberof SdkMeetingSessionLocalVideoTiming + * @static + * @param {ISdkMeetingSessionLocalVideoTiming=} [properties] Properties to set + * @returns {SdkMeetingSessionLocalVideoTiming} SdkMeetingSessionLocalVideoTiming instance + */ + SdkMeetingSessionLocalVideoTiming.create = function create(properties) { + return new SdkMeetingSessionLocalVideoTiming(properties); + }; + + /** + * Encodes the specified SdkMeetingSessionLocalVideoTiming message. Does not implicitly {@link SdkMeetingSessionLocalVideoTiming.verify|verify} messages. + * @function encode + * @memberof SdkMeetingSessionLocalVideoTiming + * @static + * @param {ISdkMeetingSessionLocalVideoTiming} message SdkMeetingSessionLocalVideoTiming message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SdkMeetingSessionLocalVideoTiming.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.addedMs != null && Object.hasOwnProperty.call(message, "addedMs")) + writer.uint32(/* id 1, wireType 0 =*/8).int64(message.addedMs); + if (message.firstFrameCapturedMs != null && Object.hasOwnProperty.call(message, "firstFrameCapturedMs")) + writer.uint32(/* id 2, wireType 0 =*/16).int64(message.firstFrameCapturedMs); + if (message.firstFrameSentMs != null && Object.hasOwnProperty.call(message, "firstFrameSentMs")) + writer.uint32(/* id 3, wireType 0 =*/24).int64(message.firstFrameSentMs); + if (message.timedOut != null && Object.hasOwnProperty.call(message, "timedOut")) + writer.uint32(/* id 4, wireType 0 =*/32).bool(message.timedOut); + if (message.removed != null && Object.hasOwnProperty.call(message, "removed")) + writer.uint32(/* id 5, wireType 0 =*/40).bool(message.removed); + return writer; + }; + + /** + * Encodes the specified SdkMeetingSessionLocalVideoTiming message, length delimited. Does not implicitly {@link SdkMeetingSessionLocalVideoTiming.verify|verify} messages. + * @function encodeDelimited + * @memberof SdkMeetingSessionLocalVideoTiming + * @static + * @param {ISdkMeetingSessionLocalVideoTiming} message SdkMeetingSessionLocalVideoTiming message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SdkMeetingSessionLocalVideoTiming.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + + /** + * Decodes a SdkMeetingSessionLocalVideoTiming message from the specified reader or buffer. + * @function decode + * @memberof SdkMeetingSessionLocalVideoTiming + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {SdkMeetingSessionLocalVideoTiming} SdkMeetingSessionLocalVideoTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SdkMeetingSessionLocalVideoTiming.decode = function decode(reader, length) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.SdkMeetingSessionLocalVideoTiming(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + message.addedMs = reader.int64(); + break; + } + case 2: { + message.firstFrameCapturedMs = reader.int64(); + break; + } + case 3: { + message.firstFrameSentMs = reader.int64(); + break; + } + case 4: { + message.timedOut = reader.bool(); + break; + } + case 5: { + message.removed = reader.bool(); + break; + } + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + + /** + * Decodes a SdkMeetingSessionLocalVideoTiming message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof SdkMeetingSessionLocalVideoTiming + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {SdkMeetingSessionLocalVideoTiming} SdkMeetingSessionLocalVideoTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SdkMeetingSessionLocalVideoTiming.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a SdkMeetingSessionLocalVideoTiming message. + * @function verify + * @memberof SdkMeetingSessionLocalVideoTiming + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + SdkMeetingSessionLocalVideoTiming.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.addedMs != null && message.hasOwnProperty("addedMs")) + if (!$util.isInteger(message.addedMs) && !(message.addedMs && $util.isInteger(message.addedMs.low) && $util.isInteger(message.addedMs.high))) + return "addedMs: integer|Long expected"; + if (message.firstFrameCapturedMs != null && message.hasOwnProperty("firstFrameCapturedMs")) + if (!$util.isInteger(message.firstFrameCapturedMs) && !(message.firstFrameCapturedMs && $util.isInteger(message.firstFrameCapturedMs.low) && $util.isInteger(message.firstFrameCapturedMs.high))) + return "firstFrameCapturedMs: integer|Long expected"; + if (message.firstFrameSentMs != null && message.hasOwnProperty("firstFrameSentMs")) + if (!$util.isInteger(message.firstFrameSentMs) && !(message.firstFrameSentMs && $util.isInteger(message.firstFrameSentMs.low) && $util.isInteger(message.firstFrameSentMs.high))) + return "firstFrameSentMs: integer|Long expected"; + if (message.timedOut != null && message.hasOwnProperty("timedOut")) + if (typeof message.timedOut !== "boolean") + return "timedOut: boolean expected"; + if (message.removed != null && message.hasOwnProperty("removed")) + if (typeof message.removed !== "boolean") + return "removed: boolean expected"; + return null; + }; + + /** + * Creates a SdkMeetingSessionLocalVideoTiming message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof SdkMeetingSessionLocalVideoTiming + * @static + * @param {Object.} object Plain object + * @returns {SdkMeetingSessionLocalVideoTiming} SdkMeetingSessionLocalVideoTiming + */ + SdkMeetingSessionLocalVideoTiming.fromObject = function fromObject(object) { + if (object instanceof $root.SdkMeetingSessionLocalVideoTiming) + return object; + var message = new $root.SdkMeetingSessionLocalVideoTiming(); + if (object.addedMs != null) + if ($util.Long) + (message.addedMs = $util.Long.fromValue(object.addedMs)).unsigned = false; + else if (typeof object.addedMs === "string") + message.addedMs = parseInt(object.addedMs, 10); + else if (typeof object.addedMs === "number") + message.addedMs = object.addedMs; + else if (typeof object.addedMs === "object") + message.addedMs = new $util.LongBits(object.addedMs.low >>> 0, object.addedMs.high >>> 0).toNumber(); + if (object.firstFrameCapturedMs != null) + if ($util.Long) + (message.firstFrameCapturedMs = $util.Long.fromValue(object.firstFrameCapturedMs)).unsigned = false; + else if (typeof object.firstFrameCapturedMs === "string") + message.firstFrameCapturedMs = parseInt(object.firstFrameCapturedMs, 10); + else if (typeof object.firstFrameCapturedMs === "number") + message.firstFrameCapturedMs = object.firstFrameCapturedMs; + else if (typeof object.firstFrameCapturedMs === "object") + message.firstFrameCapturedMs = new $util.LongBits(object.firstFrameCapturedMs.low >>> 0, object.firstFrameCapturedMs.high >>> 0).toNumber(); + if (object.firstFrameSentMs != null) + if ($util.Long) + (message.firstFrameSentMs = $util.Long.fromValue(object.firstFrameSentMs)).unsigned = false; + else if (typeof object.firstFrameSentMs === "string") + message.firstFrameSentMs = parseInt(object.firstFrameSentMs, 10); + else if (typeof object.firstFrameSentMs === "number") + message.firstFrameSentMs = object.firstFrameSentMs; + else if (typeof object.firstFrameSentMs === "object") + message.firstFrameSentMs = new $util.LongBits(object.firstFrameSentMs.low >>> 0, object.firstFrameSentMs.high >>> 0).toNumber(); + if (object.timedOut != null) + message.timedOut = Boolean(object.timedOut); + if (object.removed != null) + message.removed = Boolean(object.removed); + return message; + }; + + /** + * Creates a plain object from a SdkMeetingSessionLocalVideoTiming message. Also converts values to other types if specified. + * @function toObject + * @memberof SdkMeetingSessionLocalVideoTiming + * @static + * @param {SdkMeetingSessionLocalVideoTiming} message SdkMeetingSessionLocalVideoTiming + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + SdkMeetingSessionLocalVideoTiming.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.defaults) { + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.addedMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.addedMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.firstFrameCapturedMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.firstFrameCapturedMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.firstFrameSentMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.firstFrameSentMs = options.longs === String ? "0" : 0; + object.timedOut = false; + object.removed = false; + } + if (message.addedMs != null && message.hasOwnProperty("addedMs")) + if (typeof message.addedMs === "number") + object.addedMs = options.longs === String ? String(message.addedMs) : message.addedMs; + else + object.addedMs = options.longs === String ? $util.Long.prototype.toString.call(message.addedMs) : options.longs === Number ? new $util.LongBits(message.addedMs.low >>> 0, message.addedMs.high >>> 0).toNumber() : message.addedMs; + if (message.firstFrameCapturedMs != null && message.hasOwnProperty("firstFrameCapturedMs")) + if (typeof message.firstFrameCapturedMs === "number") + object.firstFrameCapturedMs = options.longs === String ? String(message.firstFrameCapturedMs) : message.firstFrameCapturedMs; + else + object.firstFrameCapturedMs = options.longs === String ? $util.Long.prototype.toString.call(message.firstFrameCapturedMs) : options.longs === Number ? new $util.LongBits(message.firstFrameCapturedMs.low >>> 0, message.firstFrameCapturedMs.high >>> 0).toNumber() : message.firstFrameCapturedMs; + if (message.firstFrameSentMs != null && message.hasOwnProperty("firstFrameSentMs")) + if (typeof message.firstFrameSentMs === "number") + object.firstFrameSentMs = options.longs === String ? String(message.firstFrameSentMs) : message.firstFrameSentMs; + else + object.firstFrameSentMs = options.longs === String ? $util.Long.prototype.toString.call(message.firstFrameSentMs) : options.longs === Number ? new $util.LongBits(message.firstFrameSentMs.low >>> 0, message.firstFrameSentMs.high >>> 0).toNumber() : message.firstFrameSentMs; + if (message.timedOut != null && message.hasOwnProperty("timedOut")) + object.timedOut = message.timedOut; + if (message.removed != null && message.hasOwnProperty("removed")) + object.removed = message.removed; + return object; + }; + + /** + * Converts this SdkMeetingSessionLocalVideoTiming to JSON. + * @function toJSON + * @memberof SdkMeetingSessionLocalVideoTiming + * @instance + * @returns {Object.} JSON object + */ + SdkMeetingSessionLocalVideoTiming.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for SdkMeetingSessionLocalVideoTiming + * @function getTypeUrl + * @memberof SdkMeetingSessionLocalVideoTiming + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + SdkMeetingSessionLocalVideoTiming.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/SdkMeetingSessionLocalVideoTiming"; + }; + + return SdkMeetingSessionLocalVideoTiming; +})(); + +$root.SdkMeetingSessionRemoteVideoTiming = (function() { + + /** + * Properties of a SdkMeetingSessionRemoteVideoTiming. + * @name ISdkMeetingSessionRemoteVideoTiming + * @interface ISdkMeetingSessionRemoteVideoTiming + * @property {number|null} [groupId] SdkMeetingSessionRemoteVideoTiming groupId + * @property {number|Long|null} [addedMs] SdkMeetingSessionRemoteVideoTiming addedMs + * @property {number|Long|null} [firstPacketReceivedMs] SdkMeetingSessionRemoteVideoTiming firstPacketReceivedMs + * @property {number|Long|null} [firstFrameRenderedMs] SdkMeetingSessionRemoteVideoTiming firstFrameRenderedMs + * @property {boolean|null} [timedOut] SdkMeetingSessionRemoteVideoTiming timedOut + * @property {boolean|null} [removed] SdkMeetingSessionRemoteVideoTiming removed + */ + + /** + * Constructs a new SdkMeetingSessionRemoteVideoTiming. + * @name SdkMeetingSessionRemoteVideoTiming + * @classdesc Represents a SdkMeetingSessionRemoteVideoTiming. + * @implements ISdkMeetingSessionRemoteVideoTiming + * @constructor + * @param {ISdkMeetingSessionRemoteVideoTiming=} [properties] Properties to set + */ + function SdkMeetingSessionRemoteVideoTiming(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + + /** + * SdkMeetingSessionRemoteVideoTiming groupId. + * @member {number} groupId + * @memberof SdkMeetingSessionRemoteVideoTiming + * @instance + */ + SdkMeetingSessionRemoteVideoTiming.prototype.groupId = 0; + + /** + * SdkMeetingSessionRemoteVideoTiming addedMs. + * @member {number|Long} addedMs + * @memberof SdkMeetingSessionRemoteVideoTiming + * @instance + */ + SdkMeetingSessionRemoteVideoTiming.prototype.addedMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionRemoteVideoTiming firstPacketReceivedMs. + * @member {number|Long} firstPacketReceivedMs + * @memberof SdkMeetingSessionRemoteVideoTiming + * @instance + */ + SdkMeetingSessionRemoteVideoTiming.prototype.firstPacketReceivedMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionRemoteVideoTiming firstFrameRenderedMs. + * @member {number|Long} firstFrameRenderedMs + * @memberof SdkMeetingSessionRemoteVideoTiming + * @instance + */ + SdkMeetingSessionRemoteVideoTiming.prototype.firstFrameRenderedMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * SdkMeetingSessionRemoteVideoTiming timedOut. + * @member {boolean} timedOut + * @memberof SdkMeetingSessionRemoteVideoTiming + * @instance + */ + SdkMeetingSessionRemoteVideoTiming.prototype.timedOut = false; + + /** + * SdkMeetingSessionRemoteVideoTiming removed. + * @member {boolean} removed + * @memberof SdkMeetingSessionRemoteVideoTiming + * @instance + */ + SdkMeetingSessionRemoteVideoTiming.prototype.removed = false; + + /** + * Creates a new SdkMeetingSessionRemoteVideoTiming instance using the specified properties. + * @function create + * @memberof SdkMeetingSessionRemoteVideoTiming + * @static + * @param {ISdkMeetingSessionRemoteVideoTiming=} [properties] Properties to set + * @returns {SdkMeetingSessionRemoteVideoTiming} SdkMeetingSessionRemoteVideoTiming instance + */ + SdkMeetingSessionRemoteVideoTiming.create = function create(properties) { + return new SdkMeetingSessionRemoteVideoTiming(properties); + }; + + /** + * Encodes the specified SdkMeetingSessionRemoteVideoTiming message. Does not implicitly {@link SdkMeetingSessionRemoteVideoTiming.verify|verify} messages. + * @function encode + * @memberof SdkMeetingSessionRemoteVideoTiming + * @static + * @param {ISdkMeetingSessionRemoteVideoTiming} message SdkMeetingSessionRemoteVideoTiming message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SdkMeetingSessionRemoteVideoTiming.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.groupId != null && Object.hasOwnProperty.call(message, "groupId")) + writer.uint32(/* id 1, wireType 0 =*/8).uint32(message.groupId); + if (message.addedMs != null && Object.hasOwnProperty.call(message, "addedMs")) + writer.uint32(/* id 2, wireType 0 =*/16).int64(message.addedMs); + if (message.firstPacketReceivedMs != null && Object.hasOwnProperty.call(message, "firstPacketReceivedMs")) + writer.uint32(/* id 3, wireType 0 =*/24).int64(message.firstPacketReceivedMs); + if (message.firstFrameRenderedMs != null && Object.hasOwnProperty.call(message, "firstFrameRenderedMs")) + writer.uint32(/* id 4, wireType 0 =*/32).int64(message.firstFrameRenderedMs); + if (message.timedOut != null && Object.hasOwnProperty.call(message, "timedOut")) + writer.uint32(/* id 5, wireType 0 =*/40).bool(message.timedOut); + if (message.removed != null && Object.hasOwnProperty.call(message, "removed")) + writer.uint32(/* id 6, wireType 0 =*/48).bool(message.removed); + return writer; + }; + + /** + * Encodes the specified SdkMeetingSessionRemoteVideoTiming message, length delimited. Does not implicitly {@link SdkMeetingSessionRemoteVideoTiming.verify|verify} messages. + * @function encodeDelimited + * @memberof SdkMeetingSessionRemoteVideoTiming + * @static + * @param {ISdkMeetingSessionRemoteVideoTiming} message SdkMeetingSessionRemoteVideoTiming message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SdkMeetingSessionRemoteVideoTiming.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + + /** + * Decodes a SdkMeetingSessionRemoteVideoTiming message from the specified reader or buffer. + * @function decode + * @memberof SdkMeetingSessionRemoteVideoTiming + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {SdkMeetingSessionRemoteVideoTiming} SdkMeetingSessionRemoteVideoTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SdkMeetingSessionRemoteVideoTiming.decode = function decode(reader, length) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.SdkMeetingSessionRemoteVideoTiming(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + message.groupId = reader.uint32(); + break; + } + case 2: { + message.addedMs = reader.int64(); + break; + } + case 3: { + message.firstPacketReceivedMs = reader.int64(); + break; + } + case 4: { + message.firstFrameRenderedMs = reader.int64(); + break; + } + case 5: { + message.timedOut = reader.bool(); + break; + } + case 6: { + message.removed = reader.bool(); + break; + } + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + + /** + * Decodes a SdkMeetingSessionRemoteVideoTiming message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof SdkMeetingSessionRemoteVideoTiming + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {SdkMeetingSessionRemoteVideoTiming} SdkMeetingSessionRemoteVideoTiming + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SdkMeetingSessionRemoteVideoTiming.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a SdkMeetingSessionRemoteVideoTiming message. + * @function verify + * @memberof SdkMeetingSessionRemoteVideoTiming + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + SdkMeetingSessionRemoteVideoTiming.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.groupId != null && message.hasOwnProperty("groupId")) + if (!$util.isInteger(message.groupId)) + return "groupId: integer expected"; + if (message.addedMs != null && message.hasOwnProperty("addedMs")) + if (!$util.isInteger(message.addedMs) && !(message.addedMs && $util.isInteger(message.addedMs.low) && $util.isInteger(message.addedMs.high))) + return "addedMs: integer|Long expected"; + if (message.firstPacketReceivedMs != null && message.hasOwnProperty("firstPacketReceivedMs")) + if (!$util.isInteger(message.firstPacketReceivedMs) && !(message.firstPacketReceivedMs && $util.isInteger(message.firstPacketReceivedMs.low) && $util.isInteger(message.firstPacketReceivedMs.high))) + return "firstPacketReceivedMs: integer|Long expected"; + if (message.firstFrameRenderedMs != null && message.hasOwnProperty("firstFrameRenderedMs")) + if (!$util.isInteger(message.firstFrameRenderedMs) && !(message.firstFrameRenderedMs && $util.isInteger(message.firstFrameRenderedMs.low) && $util.isInteger(message.firstFrameRenderedMs.high))) + return "firstFrameRenderedMs: integer|Long expected"; + if (message.timedOut != null && message.hasOwnProperty("timedOut")) + if (typeof message.timedOut !== "boolean") + return "timedOut: boolean expected"; + if (message.removed != null && message.hasOwnProperty("removed")) + if (typeof message.removed !== "boolean") + return "removed: boolean expected"; + return null; + }; + + /** + * Creates a SdkMeetingSessionRemoteVideoTiming message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof SdkMeetingSessionRemoteVideoTiming + * @static + * @param {Object.} object Plain object + * @returns {SdkMeetingSessionRemoteVideoTiming} SdkMeetingSessionRemoteVideoTiming + */ + SdkMeetingSessionRemoteVideoTiming.fromObject = function fromObject(object) { + if (object instanceof $root.SdkMeetingSessionRemoteVideoTiming) + return object; + var message = new $root.SdkMeetingSessionRemoteVideoTiming(); + if (object.groupId != null) + message.groupId = object.groupId >>> 0; + if (object.addedMs != null) + if ($util.Long) + (message.addedMs = $util.Long.fromValue(object.addedMs)).unsigned = false; + else if (typeof object.addedMs === "string") + message.addedMs = parseInt(object.addedMs, 10); + else if (typeof object.addedMs === "number") + message.addedMs = object.addedMs; + else if (typeof object.addedMs === "object") + message.addedMs = new $util.LongBits(object.addedMs.low >>> 0, object.addedMs.high >>> 0).toNumber(); + if (object.firstPacketReceivedMs != null) + if ($util.Long) + (message.firstPacketReceivedMs = $util.Long.fromValue(object.firstPacketReceivedMs)).unsigned = false; + else if (typeof object.firstPacketReceivedMs === "string") + message.firstPacketReceivedMs = parseInt(object.firstPacketReceivedMs, 10); + else if (typeof object.firstPacketReceivedMs === "number") + message.firstPacketReceivedMs = object.firstPacketReceivedMs; + else if (typeof object.firstPacketReceivedMs === "object") + message.firstPacketReceivedMs = new $util.LongBits(object.firstPacketReceivedMs.low >>> 0, object.firstPacketReceivedMs.high >>> 0).toNumber(); + if (object.firstFrameRenderedMs != null) + if ($util.Long) + (message.firstFrameRenderedMs = $util.Long.fromValue(object.firstFrameRenderedMs)).unsigned = false; + else if (typeof object.firstFrameRenderedMs === "string") + message.firstFrameRenderedMs = parseInt(object.firstFrameRenderedMs, 10); + else if (typeof object.firstFrameRenderedMs === "number") + message.firstFrameRenderedMs = object.firstFrameRenderedMs; + else if (typeof object.firstFrameRenderedMs === "object") + message.firstFrameRenderedMs = new $util.LongBits(object.firstFrameRenderedMs.low >>> 0, object.firstFrameRenderedMs.high >>> 0).toNumber(); + if (object.timedOut != null) + message.timedOut = Boolean(object.timedOut); + if (object.removed != null) + message.removed = Boolean(object.removed); + return message; + }; + + /** + * Creates a plain object from a SdkMeetingSessionRemoteVideoTiming message. Also converts values to other types if specified. + * @function toObject + * @memberof SdkMeetingSessionRemoteVideoTiming + * @static + * @param {SdkMeetingSessionRemoteVideoTiming} message SdkMeetingSessionRemoteVideoTiming + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + SdkMeetingSessionRemoteVideoTiming.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.defaults) { + object.groupId = 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.addedMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.addedMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.firstPacketReceivedMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.firstPacketReceivedMs = options.longs === String ? "0" : 0; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.firstFrameRenderedMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.firstFrameRenderedMs = options.longs === String ? "0" : 0; + object.timedOut = false; + object.removed = false; + } + if (message.groupId != null && message.hasOwnProperty("groupId")) + object.groupId = message.groupId; + if (message.addedMs != null && message.hasOwnProperty("addedMs")) + if (typeof message.addedMs === "number") + object.addedMs = options.longs === String ? String(message.addedMs) : message.addedMs; + else + object.addedMs = options.longs === String ? $util.Long.prototype.toString.call(message.addedMs) : options.longs === Number ? new $util.LongBits(message.addedMs.low >>> 0, message.addedMs.high >>> 0).toNumber() : message.addedMs; + if (message.firstPacketReceivedMs != null && message.hasOwnProperty("firstPacketReceivedMs")) + if (typeof message.firstPacketReceivedMs === "number") + object.firstPacketReceivedMs = options.longs === String ? String(message.firstPacketReceivedMs) : message.firstPacketReceivedMs; + else + object.firstPacketReceivedMs = options.longs === String ? $util.Long.prototype.toString.call(message.firstPacketReceivedMs) : options.longs === Number ? new $util.LongBits(message.firstPacketReceivedMs.low >>> 0, message.firstPacketReceivedMs.high >>> 0).toNumber() : message.firstPacketReceivedMs; + if (message.firstFrameRenderedMs != null && message.hasOwnProperty("firstFrameRenderedMs")) + if (typeof message.firstFrameRenderedMs === "number") + object.firstFrameRenderedMs = options.longs === String ? String(message.firstFrameRenderedMs) : message.firstFrameRenderedMs; + else + object.firstFrameRenderedMs = options.longs === String ? $util.Long.prototype.toString.call(message.firstFrameRenderedMs) : options.longs === Number ? new $util.LongBits(message.firstFrameRenderedMs.low >>> 0, message.firstFrameRenderedMs.high >>> 0).toNumber() : message.firstFrameRenderedMs; + if (message.timedOut != null && message.hasOwnProperty("timedOut")) + object.timedOut = message.timedOut; + if (message.removed != null && message.hasOwnProperty("removed")) + object.removed = message.removed; + return object; + }; + + /** + * Converts this SdkMeetingSessionRemoteVideoTiming to JSON. + * @function toJSON + * @memberof SdkMeetingSessionRemoteVideoTiming + * @instance + * @returns {Object.} JSON object + */ + SdkMeetingSessionRemoteVideoTiming.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for SdkMeetingSessionRemoteVideoTiming + * @function getTypeUrl + * @memberof SdkMeetingSessionRemoteVideoTiming + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + SdkMeetingSessionRemoteVideoTiming.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/SdkMeetingSessionRemoteVideoTiming"; + }; + + return SdkMeetingSessionRemoteVideoTiming; +})(); + module.exports = $root; $util.Long = undefined; $protobuf.configure(); \ No newline at end of file diff --git a/src/statscollector/StatsCollector.ts b/src/statscollector/StatsCollector.ts index e5995c69f4..d7cfb6aae8 100644 --- a/src/statscollector/StatsCollector.ts +++ b/src/statscollector/StatsCollector.ts @@ -25,6 +25,7 @@ import { } from '../signalingprotocol/SignalingProtocol.js'; import { Maybe } from '../utils/Types'; import VideoStreamIndex from '../videostreamindex/VideoStreamIndex'; +import { VideoElementFrameMetrics } from '../videotile/VideoElementFrameMonitor'; import { VideoTileResolutionObserver } from '../videotilecontroller/VideoTileController'; import AudioLogEvent from './AudioLogEvent'; import VideoLogEvent from './VideoLogEvent'; @@ -64,6 +65,7 @@ export default class StatsCollector private videoCodecDegradationEncodeFailureCount: number = 0; private videoCodecDegradationConcurrentSendersCount: number = 0; private resolutionMap = new Map(); + private remoteRenderFpsMap = new Map(); private encodedTransformMediaMetrics: EncodedTransformMediaMetrics | null = null; private encodedTransformMediaMetricsTimestamp: number = 0; private lastEncodedTransformMediaMetricsTimestamp: number = 0; @@ -259,8 +261,18 @@ export default class StatsCollector this.resolutionMap.set(attendeeId, { width: newWidth, height: newHeight }); } - videoTileUnbound(attendeeId: string): void { + videoTileUnbound(attendeeId: string, groupId?: number): void { this.resolutionMap.delete(attendeeId); + if (groupId !== undefined) { + this.remoteRenderFpsMap.delete(groupId); + } + } + + videoTileRenderMetricsDidReceive(groupId: number, metrics: VideoElementFrameMetrics): void { + if (groupId === 0) { + return; + } + this.remoteRenderFpsMap.set(groupId, metrics.fps); } /** @@ -844,6 +856,10 @@ export default class StatsCollector metricReport.currentMetrics['videoRenderWidth'] = this.resolutionMap.get(attendeeId).width; metricReport.currentMetrics['videoRenderHeight'] = this.resolutionMap.get(attendeeId).height; } + const groupId = metricReport.groupId; + if (groupId !== undefined && this.remoteRenderFpsMap.has(groupId)) { + metricReport.currentMetrics['videoRemoteRenderFps'] = this.remoteRenderFpsMap.get(groupId); + } } private updateVideoSourceMetrics(rawMetricReports: RawMetricReport[]): void { diff --git a/src/task/CreatePeerConnectionTask.ts b/src/task/CreatePeerConnectionTask.ts index ab6847aff2..c5f5db8b1f 100644 --- a/src/task/CreatePeerConnectionTask.ts +++ b/src/task/CreatePeerConnectionTask.ts @@ -60,6 +60,10 @@ export default class CreatePeerConnectionTask extends BaseTask implements Remova this.context.logger.info( `peer connection ice connection state changed: ${peer.iceConnectionState}` ); + if (peer.iceConnectionState === 'connected') { + /* istanbul ignore next */ + this.context.meetingSessionTimingManager?.onIceConnected(); + } }); } @@ -257,6 +261,10 @@ export default class CreatePeerConnectionTask extends BaseTask implements Remova } else { this.logger.warn(`no stream found for tile=${tileState.tileId}`); } + if (tileState.groupId !== null) { + /* istanbul ignore next */ + this.context.meetingSessionTimingManager?.onRemoteVideoRemoved(tileState.groupId); + } this.context.videoTileController.removeVideoTile(tileState.tileId); } } diff --git a/src/task/CreateSDPTask.ts b/src/task/CreateSDPTask.ts index 87724e2cac..a6918b734c 100644 --- a/src/task/CreateSDPTask.ts +++ b/src/task/CreateSDPTask.ts @@ -62,6 +62,7 @@ export default class CreateSDPTask extends BaseTask { try { this.context.sdpOfferInit = await this.context.peer.createOffer(offerOptions); + this.context.meetingSessionTimingManager?.onCreateOfferCalled(); this.context.logger.info('peer connection created offer'); if (this.context.previousSdpOffer) { if ( diff --git a/src/task/FinishGatheringICECandidatesTask.ts b/src/task/FinishGatheringICECandidatesTask.ts index 9e9139ed0c..04bc918dfc 100644 --- a/src/task/FinishGatheringICECandidatesTask.ts +++ b/src/task/FinishGatheringICECandidatesTask.ts @@ -96,6 +96,7 @@ export default class FinishGatheringICECandidatesTask extends BaseTask { ); return; } + this.context.meetingSessionTimingManager?.onIceGatheringStarted(); try { await new Promise((resolve, reject) => { this.cancelPromise = (error: Error) => { @@ -171,6 +172,7 @@ export default class FinishGatheringICECandidatesTask extends BaseTask { if (this.startTimestampMs) { this.context.iceGatheringDurationMs = Math.round(Date.now() - this.startTimestampMs); } + this.context.meetingSessionTimingManager?.onIceGatheringComplete(); } } } diff --git a/src/task/JoinAndReceiveIndexTask.ts b/src/task/JoinAndReceiveIndexTask.ts index e601ec55c8..c2f3a9ec3f 100644 --- a/src/task/JoinAndReceiveIndexTask.ts +++ b/src/task/JoinAndReceiveIndexTask.ts @@ -89,6 +89,8 @@ export default class JoinAndReceiveIndexTask extends BaseTask { return; } if (event.message.type === SdkSignalFrame.Type.JOIN_ACK) { + context.meetingSessionTimingManager?.onJoinAckReceived(); + // @ts-ignore: force cast to SdkJoinAckFrame const joinAckFrame: SdkJoinAckFrame = event.message.joinack; if (!joinAckFrame) { @@ -177,5 +179,12 @@ export default class JoinAndReceiveIndexTask extends BaseTask { // after this task completes and the state isn't quite in the right place to make it work without some refactoring. However that // means that we will always have an initial subscribe without any received videos. this.context.indexFrame = indexFrame; + + // If the first index contains video sources, tell the timing manager to hold + // the initial batch open until remote video timing completes. + if (indexFrame.sources && indexFrame.sources.length > 0) { + /* istanbul ignore next */ + this.context.meetingSessionTimingManager?.setExpectingRemoteVideo(); + } } } diff --git a/src/task/ReceiveVideoInputTask.ts b/src/task/ReceiveVideoInputTask.ts index bf40f41cfd..6f996b913c 100644 --- a/src/task/ReceiveVideoInputTask.ts +++ b/src/task/ReceiveVideoInputTask.ts @@ -82,6 +82,7 @@ export default class ReceiveVideoInputTask extends BaseTask { // no longer be tracking irrelevant local sending bitrates sent via received Bitrate message, nor will // we track any spurious allocated stream IDs from the backend. this.context.videoStreamIndex.integrateUplinkPolicyDecision([]); + this.context.meetingSessionTimingManager?.onLocalVideoRemoved(); } return; } @@ -144,6 +145,8 @@ export default class ReceiveVideoInputTask extends BaseTask { this.context.videoDeviceInformation['current_camera_name'] = track.label; this.context.videoDeviceInformation['current_camera_id'] = track.id; } + + this.context.meetingSessionTimingManager?.onLocalVideoAdded(); } } } diff --git a/src/task/ReceiveVideoStreamIndexTask.ts b/src/task/ReceiveVideoStreamIndexTask.ts index 9c0d1aed53..70be3e770d 100644 --- a/src/task/ReceiveVideoStreamIndexTask.ts +++ b/src/task/ReceiveVideoStreamIndexTask.ts @@ -184,6 +184,17 @@ export default class ReceiveVideoStreamIndexTask this.context.videoCaptureAndEncodeParameter )}` ); + if (resubscribeForDownlink && !this.context.videosToReceive.empty()) { + this.context.videosToReceive.forEach((streamId: number) => { + const groupId = this.context.videoStreamIndex.groupIdForStreamId(streamId); + if (groupId !== undefined) { + this.context.meetingSessionTimingManager?.onRemoteVideoAdded(groupId); + } + }); + } else { + this.context.meetingSessionTimingManager?.clearExpectingRemoteVideo(); + } + this.context.meetingSessionTimingManager?.onResubscribeStart(); this.context.audioVideoController.update({ needsRenegotiation: false }); } diff --git a/src/task/SetLocalDescriptionTask.ts b/src/task/SetLocalDescriptionTask.ts index 48256ac7a7..727ea2b998 100644 --- a/src/task/SetLocalDescriptionTask.ts +++ b/src/task/SetLocalDescriptionTask.ts @@ -116,6 +116,7 @@ export default class SetLocalDescriptionTask extends BaseTask { try { await peer.setLocalDescription(sdpOffer); + this.context.meetingSessionTimingManager?.onSetLocalDescription(); resolve(); } catch (error) { reject(error); diff --git a/src/task/SetRemoteDescriptionTask.ts b/src/task/SetRemoteDescriptionTask.ts index dd70b924bc..710ae5fd17 100644 --- a/src/task/SetRemoteDescriptionTask.ts +++ b/src/task/SetRemoteDescriptionTask.ts @@ -124,6 +124,7 @@ export default class SetRemoteDescriptionTask extends BaseTask { try { await this.context.peer.setRemoteDescription(remoteDescription); + this.context.meetingSessionTimingManager?.onSetRemoteDescription(); this.logger.info('set remote description, waiting for ICE connection'); checkConnectionCompleted(); } catch (err) { diff --git a/src/task/StartEncodedTransformWorkerTask.ts b/src/task/StartEncodedTransformWorkerTask.ts index 9686e2e8e1..4cd90668d9 100644 --- a/src/task/StartEncodedTransformWorkerTask.ts +++ b/src/task/StartEncodedTransformWorkerTask.ts @@ -39,8 +39,13 @@ export default class StartEncodedTransformWorkerTask extends BaseTask { // Add observers after start() so the managers are initialized const metricsTransformManager = this.context.encodedTransformWorkerManager.metricsTransformManager(); - if (metricsTransformManager && this.context.statsCollector) { - metricsTransformManager.addObserver(this.context.statsCollector); + if (metricsTransformManager) { + if (this.context.statsCollector) { + metricsTransformManager.addObserver(this.context.statsCollector); + } + if (this.context.audioVideoController) { + metricsTransformManager.addObserver(this.context.audioVideoController); + } } const redundantAudioEncodeTransformManager = diff --git a/src/task/SubscribeAndReceiveSubscribeAckTask.ts b/src/task/SubscribeAndReceiveSubscribeAckTask.ts index bbea9f8da5..9f0720fe70 100644 --- a/src/task/SubscribeAndReceiveSubscribeAckTask.ts +++ b/src/task/SubscribeAndReceiveSubscribeAckTask.ts @@ -135,6 +135,7 @@ export default class SubscribeAndReceiveSubscribeAckTask extends BaseTask { this.context.signalingClient.subscribe(subscribe); const subscribeAckFrame = await this.receiveSubscribeAck(); + this.context.meetingSessionTimingManager?.onSubscribeAckReceived(); this.context.logger.info(`got subscribe ack: ${JSON.stringify(subscribeAckFrame)}`); let decompressedText = ''; diff --git a/src/videoelementfactory/NoOpVideoElementFactory.ts b/src/videoelementfactory/NoOpVideoElementFactory.ts index 8e4f1a3334..fe6f008dcf 100644 --- a/src/videoelementfactory/NoOpVideoElementFactory.ts +++ b/src/videoelementfactory/NoOpVideoElementFactory.ts @@ -20,6 +20,12 @@ export default class NoOpVideoElementFactory implements VideoElementFactory { }, removeAttribute: (): void => {}, setAttribute: (): void => {}, + addEventListener: (): void => {}, + removeEventListener: (): void => {}, + requestVideoFrameCallback: (_callback: () => void): number => { + return 0; + }, + cancelVideoFrameCallback: (): void => {}, srcObject: false, paused: true, pause: (): void => { diff --git a/src/videotile/DefaultVideoElementResolutionMonitor.ts b/src/videotile/DefaultVideoElementResolutionMonitor.ts index 6e9283d112..14da561ba1 100644 --- a/src/videotile/DefaultVideoElementResolutionMonitor.ts +++ b/src/videotile/DefaultVideoElementResolutionMonitor.ts @@ -1,29 +1,28 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import VideoElementFrameMonitor from './VideoElementFrameMonitor'; import VideoElementResolutionMonitor, { VideoElementResolutionObserver, } from './VideoElementResolutionMonitor'; + export default class DefaultVideoElementResolutionMonitor implements VideoElementResolutionMonitor { private observerQueue = new Set(); private resizeObserver: ResizeObserver; private element: HTMLVideoElement | null = null; + private frameMonitor: VideoElementFrameMonitor = new VideoElementFrameMonitor(); constructor() { this.resizeObserver = new ResizeObserver(entries => { for (const entry of entries) { const { width, height } = entry.contentRect; - this.notifyObservers(width, height); + for (const observer of this.observerQueue) { + observer.videoElementResolutionChanged(width, height); + } } }); } - private notifyObservers(newWidth: number, newHeight: number): void { - for (const observer of this.observerQueue) { - observer.videoElementResolutionChanged(newWidth, newHeight); - } - } - registerObserver(observer: VideoElementResolutionObserver): void { this.observerQueue.add(observer); } @@ -38,10 +37,23 @@ export default class DefaultVideoElementResolutionMonitor implements VideoElemen } if (this.element) { this.resizeObserver.unobserve(this.element); + this.frameMonitor.stop(); } this.element = newElement; if (this.element) { this.resizeObserver.observe(this.element); + this.frameMonitor.start(this.element, { + firstVideoElementFrameDidRender: (metadata?: VideoFrameCallbackMetadata): void => { + for (const observer of this.observerQueue) { + observer.videoElementFirstFrameDidRender?.(metadata); + } + }, + videoElementFrameMetricsDidReceive: (metrics): void => { + for (const observer of this.observerQueue) { + observer.videoElementMetricsDidReceive?.(metrics); + } + }, + }); } } } diff --git a/src/videotile/VideoElementFrameMonitor.ts b/src/videotile/VideoElementFrameMonitor.ts new file mode 100644 index 0000000000..bf01d70981 --- /dev/null +++ b/src/videotile/VideoElementFrameMonitor.ts @@ -0,0 +1,159 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Metrics collected from a video element's rendered frames. + */ +export interface VideoElementFrameMetrics { + /** + * Current frames per second. + */ + fps: number; + + /** + * Timestamp of the measurement in milliseconds since epoch. + */ + timestampMs: number; +} + +/** + * Observer for frame-level events on a video element. + */ +export interface VideoElementFrameObserver { + /** + * Called when the first video frame is rendered. + * @param metadata The VideoFrameCallbackMetadata, if available (only on browsers supporting requestVideoFrameCallback) + */ + firstVideoElementFrameDidRender?(metadata?: VideoFrameCallbackMetadata): void; + + /** + * Called periodically with render metrics (e.g. FPS). + * Only fires on browsers that support requestVideoFrameCallback. + * @param metrics The collected metrics + */ + videoElementFrameMetricsDidReceive?(metrics: VideoElementFrameMetrics): void; +} + +/** + * Monitors a video element for first-frame rendering and ongoing FPS metrics. + * + * Internally uses requestVideoFrameCallback when available (Chrome/Edge) and + * falls back to the 'resize' event for first-frame detection on other browsers. + * FPS tracking is only available when requestVideoFrameCallback is supported. + */ +export default class VideoElementFrameMonitor { + private static readonly METRICS_INTERVAL_MS = 1000; + + private element: HTMLVideoElement | null = null; + private observer: VideoElementFrameObserver | null = null; + private firstFrameRendered: boolean = false; + + // requestVideoFrameCallback-based tracking + private videoFrameCallbackId: number | null = null; + private metricsCallbackId: number | null = null; + private frameCount: number = 0; + private metricsWindowStartMs: number = 0; + + // Resize fallback + private resizeListener: (() => void) | null = null; + + private get supportsVideoFrameCallback(): boolean { + return this.element !== null && 'requestVideoFrameCallback' in this.element; + } + + /** + * Start monitoring the given element with the given observer. + * Any previous monitoring is stopped first. + */ + start(element: HTMLVideoElement, observer: VideoElementFrameObserver): void { + this.stop(); + this.element = element; + this.observer = observer; + this.firstFrameRendered = false; + this.startFirstFrameDetection(); + } + + /** + * Stop monitoring and release all resources. + */ + stop(): void { + if (!this.element) { + return; + } + this.stopMetricsTracking(); + if (this.videoFrameCallbackId !== null) { + this.element.cancelVideoFrameCallback(this.videoFrameCallbackId); + this.videoFrameCallbackId = null; + } + if (this.resizeListener) { + this.element.removeEventListener('resize', this.resizeListener); + this.resizeListener = null; + } + this.element = null; + this.observer = null; + } + + private startFirstFrameDetection(): void { + if (this.supportsVideoFrameCallback) { + this.videoFrameCallbackId = this.element.requestVideoFrameCallback((_now, metadata) => { + this.videoFrameCallbackId = null; + this.onFirstFrame(metadata); + }); + } else { + this.resizeListener = (): void => { + if (this.element.videoWidth > 0 && this.element.videoHeight > 0) { + this.element.removeEventListener('resize', this.resizeListener); + this.resizeListener = null; + this.onFirstFrame(); + } + }; + this.element.addEventListener('resize', this.resizeListener); + } + } + + private onFirstFrame(metadata?: VideoFrameCallbackMetadata): void { + if (this.firstFrameRendered) return; + this.firstFrameRendered = true; + /* istanbul ignore next */ + this.observer?.firstVideoElementFrameDidRender?.(metadata); + if (this.supportsVideoFrameCallback) { + this.startMetricsTracking(); + } + } + + private startMetricsTracking(): void { + this.frameCount = 0; + this.metricsWindowStartMs = Date.now(); + this.scheduleMetricsCallback(); + } + + private scheduleMetricsCallback(): void { + if (!this.element) return; + this.metricsCallbackId = this.element.requestVideoFrameCallback(() => { + this.frameCount++; + const now = Date.now(); + const elapsed = now - this.metricsWindowStartMs; + if (elapsed >= VideoElementFrameMonitor.METRICS_INTERVAL_MS) { + const fps = Math.round((this.frameCount * 1000) / elapsed); + /* istanbul ignore next */ + this.observer?.videoElementFrameMetricsDidReceive?.({ fps, timestampMs: now }); + this.frameCount = 0; + this.metricsWindowStartMs = now; + } + this.scheduleMetricsCallback(); + }); + } + + private stopMetricsTracking(): void { + if ( + this.metricsCallbackId !== null && + this.element && + 'cancelVideoFrameCallback' in this.element + ) { + this.element.cancelVideoFrameCallback(this.metricsCallbackId); + this.metricsCallbackId = null; + } + this.frameCount = 0; + this.metricsWindowStartMs = 0; + } +} diff --git a/src/videotile/VideoElementResolutionMonitor.ts b/src/videotile/VideoElementResolutionMonitor.ts index e26b96e445..759a19ffe2 100644 --- a/src/videotile/VideoElementResolutionMonitor.ts +++ b/src/videotile/VideoElementResolutionMonitor.ts @@ -1,6 +1,14 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { VideoElementFrameMetrics } from './VideoElementFrameMonitor'; + +/** + * Observer for video element events including resolution changes, first-frame detection, + * and render metrics. + * + * This interface has expanded beyond resolution and should eventually be renamed. + */ export interface VideoElementResolutionObserver { /** * Called when the resolution of the video element changes. @@ -8,11 +16,26 @@ export interface VideoElementResolutionObserver { * @param newHeight The new height of the video element. */ videoElementResolutionChanged(newWidth: number, newHeight: number): void; + + /** + * Called when the first video frame is rendered. + * @param metadata The VideoFrameCallbackMetadata from requestVideoFrameCallback, if available + */ + videoElementFirstFrameDidRender?(metadata?: VideoFrameCallbackMetadata): void; + + /** + * Called periodically with video element render metrics (e.g. rendered FPS). + * Only fires on browsers that support requestVideoFrameCallback. + * @param metrics The collected metrics + */ + videoElementMetricsDidReceive?(metrics: VideoElementFrameMetrics): void; } /** - * [[VideoElementResolutionMonitor]] monitors changes in the resolution of a video element - * and relays that information to observers. + * [[VideoElementResolutionMonitor]] monitors a video element for resolution changes, + * first-frame rendering, and render metrics. + * + * This interface has expanded beyond resolution and should eventually be renamed. */ export default interface VideoElementResolutionMonitor { /** diff --git a/src/videotilecontroller/DefaultVideoTileController.ts b/src/videotilecontroller/DefaultVideoTileController.ts index d4a8f877ff..b61bd863e6 100644 --- a/src/videotilecontroller/DefaultVideoTileController.ts +++ b/src/videotilecontroller/DefaultVideoTileController.ts @@ -10,6 +10,7 @@ import Logger from '../logger/Logger'; import { Maybe } from '../utils/Types'; import DefaultVideoElementResolutionMonitor from '../videotile/DefaultVideoElementResolutionMonitor'; import DefaultVideoTile from '../videotile/DefaultVideoTile'; +import { VideoElementFrameMetrics } from '../videotile/VideoElementFrameMonitor'; import VideoElementResolutionMonitor, { VideoElementResolutionObserver, } from '../videotile/VideoElementResolutionMonitor'; @@ -61,6 +62,14 @@ export default class DefaultVideoTileController implements VideoTileController { return; } tile.bindVideoElement(videoElement); + if (videoElement && !tile.state().localTile) { + const groupId = tile.state().groupId ?? null; + if (groupId !== null) { + for (const observer of this.resolutionObservers) { + observer.videoTileBound?.(groupId); + } + } + } } unbindVideoElement(tileId: number, cleanUpVideoElement: boolean = true): void { @@ -70,7 +79,9 @@ export default class DefaultVideoTileController implements VideoTileController { return; } this.logger.info('Unbinding the video element'); - this.notifyRemoteObserversOfUnbound(tile.state().boundAttendeeId); + for (const observer of this.resolutionObservers) { + observer.videoTileUnbound(tile.state().boundAttendeeId, tile.state().groupId); + } const videoElement = tile.stateRef().boundVideoElement; tile.bindVideoElement(null); if (cleanUpVideoElement) { @@ -271,22 +282,6 @@ export default class DefaultVideoTileController implements VideoTileController { this.resolutionObservers.delete(observer); } - private notifyResolutionObserversOfChange( - attendeeId: string, - newWidth: number, - newHeight: number - ): void { - for (const observer of this.resolutionObservers) { - observer.videoTileResolutionDidChange(attendeeId, newWidth, newHeight); - } - } - - private notifyRemoteObserversOfUnbound(attendeeId: string): void { - for (const observer of this.resolutionObservers) { - observer.videoTileUnbound(attendeeId); - } - } - private createResolutionMonitor(tileId: number): VideoElementResolutionMonitor { const observer = new (class implements VideoElementResolutionObserver { constructor( @@ -295,11 +290,15 @@ export default class DefaultVideoTileController implements VideoTileController { ) {} videoElementResolutionChanged(newWidth: number, newHeight: number): void { - const tile = this.controller.getVideoTile(this.tileId); - if (tile) { - const attendeeId = tile.state().boundAttendeeId; - this.controller.notifyResolutionObserversOfChange(attendeeId, newWidth, newHeight); - } + this.controller.handleVideoElementResolutionChanged(this.tileId, newWidth, newHeight); + } + + videoElementFirstFrameDidRender(metadata?: VideoFrameCallbackMetadata): void { + this.controller.handleVideoFirstFrameDidRender(this.tileId, metadata); + } + + videoElementMetricsDidReceive(metrics: VideoElementFrameMetrics): void { + this.controller.handleVideoElementFrameMetrics(this.tileId, metrics); } })(this, tileId); @@ -308,6 +307,45 @@ export default class DefaultVideoTileController implements VideoTileController { return resolutionMonitor; } + private handleVideoElementResolutionChanged( + tileId: number, + newWidth: number, + newHeight: number + ): void { + const tile = this.getVideoTile(tileId); + if (tile) { + const attendeeId = tile.state().boundAttendeeId; + for (const observer of this.resolutionObservers) { + observer.videoTileResolutionDidChange(attendeeId, newWidth, newHeight); + } + } + } + + private handleVideoFirstFrameDidRender( + tileId: number, + metadata?: VideoFrameCallbackMetadata + ): void { + const tile = this.getVideoTile(tileId); + if (!tile) return; + if (tile.state().localTile) return; + const groupId = tile.state().groupId ?? null; + if (groupId === null) return; + for (const observer of this.resolutionObservers) { + observer.videoTileFirstFrameDidRender?.(groupId, metadata); + } + } + + private handleVideoElementFrameMetrics(tileId: number, metrics: VideoElementFrameMetrics): void { + const tile = this.getVideoTile(tileId); + if (!tile) return; + if (tile.state().localTile) return; + const groupId = tile.state().groupId ?? null; + if (groupId === null) return; + for (const observer of this.resolutionObservers) { + observer.videoTileRenderMetricsDidReceive?.(groupId, metrics); + } + } + private findOrCreateLocalVideoTile(): VideoTile | null { if (this.currentLocalTile) { return this.currentLocalTile; diff --git a/src/videotilecontroller/VideoTileController.ts b/src/videotilecontroller/VideoTileController.ts index e86e9acea4..bc2d0f0118 100644 --- a/src/videotilecontroller/VideoTileController.ts +++ b/src/videotilecontroller/VideoTileController.ts @@ -1,9 +1,16 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { VideoElementFrameMetrics } from '../videotile/VideoElementFrameMonitor'; import VideoTile from '../videotile/VideoTile'; import VideoTileState from '../videotile/VideoTileState'; +/** + * Observer for video tile events including resolution changes, unbinding, first-frame detection, + * and render metrics. + * + * This interface has expanded beyond resolution and eventually should be renamed (e.g. to just VideoTileObserver) + */ export interface VideoTileResolutionObserver { /** * Called when the resolution of a video tile changes. @@ -18,8 +25,35 @@ export interface VideoTileResolutionObserver { * Called when a video tile is unbound from the video element. * * @param attendeeId The unique identifier for the attendee whose video tile has been unbound. + * @param groupId The group ID of the video subscription, if available. + */ + videoTileUnbound(attendeeId: string, groupId?: number): void; + + /** + * Called when a remote video tile is bound to a video element. + * @param groupId The group ID of the remote video subscription + */ + videoTileBound?(groupId: number): void; + + /** + * Called when the first video frame is rendered for a video tile. + * For local video, groupId is 0. For remote video, groupId maps to the + * remote video subscription group. + * + * @param groupId The group ID (0 for local, remote group ID otherwise) + * @param metadata The VideoFrameCallbackMetadata from requestVideoFrameCallback, if available + */ + videoTileFirstFrameDidRender?(groupId: number, metadata?: VideoFrameCallbackMetadata): void; + + /** + * Called periodically with video element metrics for a video tile. + * For local video, groupId is 0. For remote video, groupId maps to the + * remote video subscription group. + * + * @param groupId The group ID (0 for local, remote group ID otherwise) + * @param metrics The collected metrics */ - videoTileUnbound(attendeeId: string): void; + videoTileRenderMetricsDidReceive?(groupId: number, metrics: VideoElementFrameMetrics): void; } /** diff --git a/test/audiovideocontroller/DefaultAudioVideoController.test.ts b/test/audiovideocontroller/DefaultAudioVideoController.test.ts index 4dfbc94e1c..1b36c21a77 100644 --- a/test/audiovideocontroller/DefaultAudioVideoController.test.ts +++ b/test/audiovideocontroller/DefaultAudioVideoController.test.ts @@ -5963,4 +5963,255 @@ describe('DefaultAudioVideoController', () => { await stop(); }); }); + + describe('onFirstPacketReceived', () => { + it('routes audio receive to timing manager', async () => { + audioVideoController = new DefaultAudioVideoController( + configuration, + new NoOpDebugLogger(), + webSocketAdapter, + new NoOpMediaStreamBroker(), + reconnectController, + eventController + ); + await start(); + const spy = sinon.spy( + // @ts-ignore + audioVideoController.meetingSessionContext.meetingSessionTimingManager, + 'onRemoteAudioFirstPacketReceived' + ); + audioVideoController.onFirstPacketReceived('audio', 'receive', 0); + expect(spy.calledOnce).to.be.true; + await stop(); + }); + + it('routes audio send to timing manager', async () => { + audioVideoController = new DefaultAudioVideoController( + configuration, + new NoOpDebugLogger(), + webSocketAdapter, + new NoOpMediaStreamBroker(), + reconnectController, + eventController + ); + await start(); + const spy = sinon.spy( + // @ts-ignore + audioVideoController.meetingSessionContext.meetingSessionTimingManager, + 'onLocalAudioFirstPacketSent' + ); + audioVideoController.onFirstPacketReceived('audio', 'send', 0); + expect(spy.calledOnce).to.be.true; + await stop(); + }); + + it('warns when video receive has no stream ID for ssrc', async () => { + audioVideoController = new DefaultAudioVideoController( + configuration, + new NoOpDebugLogger(), + webSocketAdapter, + new NoOpMediaStreamBroker(), + reconnectController, + eventController + ); + await start(); + const logSpy = sinon.spy(audioVideoController.logger, 'warn'); + audioVideoController.onFirstPacketReceived('video', 'receive', 99999); + expect(logSpy.calledWith(sinon.match('no stream ID found'))).to.be.true; + await stop(); + }); + + it('warns when video receive has no group ID for stream', async () => { + audioVideoController = new DefaultAudioVideoController( + configuration, + new NoOpDebugLogger(), + webSocketAdapter, + new NoOpMediaStreamBroker(), + reconnectController, + eventController + ); + await start(); + const videoStreamIndex = new DefaultVideoStreamIndex(new NoOpDebugLogger()); + // @ts-ignore + audioVideoController.meetingSessionContext.videoStreamIndex = videoStreamIndex; + sinon.stub(videoStreamIndex, 'streamIdForSSRC').returns(42); + sinon.stub(videoStreamIndex, 'groupIdForStreamId').returns(undefined); + const logSpy = sinon.spy(audioVideoController.logger, 'warn'); + audioVideoController.onFirstPacketReceived('video', 'receive', 12345); + expect(logSpy.calledWith(sinon.match('no group ID found'))).to.be.true; + await stop(); + }); + + it('routes video receive with valid groupId to timing manager', async () => { + audioVideoController = new DefaultAudioVideoController( + configuration, + new NoOpDebugLogger(), + webSocketAdapter, + new NoOpMediaStreamBroker(), + reconnectController, + eventController + ); + await start(); + const videoStreamIndex = new DefaultVideoStreamIndex(new NoOpDebugLogger()); + // @ts-ignore + audioVideoController.meetingSessionContext.videoStreamIndex = videoStreamIndex; + sinon.stub(videoStreamIndex, 'streamIdForSSRC').returns(42); + sinon.stub(videoStreamIndex, 'groupIdForStreamId').returns(5); + const spy = sinon.spy( + // @ts-ignore + audioVideoController.meetingSessionContext.meetingSessionTimingManager, + 'onRemoteVideoFirstPacketReceived' + ); + audioVideoController.onFirstPacketReceived('video', 'receive', 12345); + expect(spy.calledWith(5)).to.be.true; + await stop(); + }); + + it('routes video send to timing manager', async () => { + audioVideoController = new DefaultAudioVideoController( + configuration, + new NoOpDebugLogger(), + webSocketAdapter, + new NoOpMediaStreamBroker(), + reconnectController, + eventController + ); + await start(); + const spy = sinon.spy( + // @ts-ignore + audioVideoController.meetingSessionContext.meetingSessionTimingManager, + 'onLocalVideoFirstFrameSent' + ); + audioVideoController.onFirstPacketReceived('video', 'send', 0); + expect(spy.calledOnce).to.be.true; + await stop(); + }); + }); + + describe('onMeetingSessionTimingReady', () => { + it('sends timing via signaling client', async () => { + audioVideoController = new DefaultAudioVideoController( + configuration, + new NoOpDebugLogger(), + webSocketAdapter, + new NoOpMediaStreamBroker(), + reconnectController, + eventController + ); + await start(); + // @ts-ignore + const signalingClient = audioVideoController.meetingSessionContext.signalingClient; + const spy = sinon.spy(signalingClient, 'sendMeetingSessionTiming'); + audioVideoController.onMeetingSessionTimingReady({ + signaling: [], + remoteAudio: [], + localAudio: [], + localVideo: [], + remoteVideos: [], + }); + expect(spy.calledOnce).to.be.true; + await stop(); + }); + + it('warns when signaling client is not available', () => { + audioVideoController = new DefaultAudioVideoController( + configuration, + new NoOpDebugLogger(), + webSocketAdapter, + new NoOpMediaStreamBroker(), + reconnectController, + eventController + ); + const logSpy = sinon.spy(audioVideoController.logger, 'warn'); + audioVideoController.onMeetingSessionTimingReady({ + signaling: [], + remoteAudio: [], + localAudio: [], + localVideo: [], + remoteVideos: [], + }); + expect(logSpy.calledWith(sinon.match('signaling client is not available'))).to.be.true; + }); + }); + + describe('resolution observer callbacks via tile controller', () => { + it('videoTileBound and videoTileFirstFrameDidRender route to timing manager', async () => { + audioVideoController = new DefaultAudioVideoController( + configuration, + new NoOpDebugLogger(), + webSocketAdapter, + new NoOpMediaStreamBroker(), + reconnectController, + eventController + ); + await start(); + const boundSpy = sinon.spy( + // @ts-ignore + audioVideoController.meetingSessionContext.meetingSessionTimingManager, + 'onRemoteVideoBound' + ); + const renderSpy = sinon.spy( + // @ts-ignore + audioVideoController.meetingSessionContext.meetingSessionTimingManager, + 'onRemoteVideoFirstFrameRendered' + ); + const tile = audioVideoController.videoTileController.addVideoTile(); + const stream = new MediaStream(); + // @ts-ignore + stream.addTrack(new MediaStreamTrack('mock', 'video')); + tile.bindVideoStream('attendee', false, stream, 1, 1, 1, 'ext', 5); + const videoElement = document.createElement('video'); + audioVideoController.videoTileController.bindVideoElement(tile.id(), videoElement); + expect(boundSpy.calledWith(5)).to.be.true; + // Tick to let RVFC fire + await clock.tickAsync(10); + expect(renderSpy.calledWith(5)).to.be.true; + await stop(); + }); + + it('timing resolution observer videoTileResolutionDidChange is a no-op', async () => { + audioVideoController = new DefaultAudioVideoController( + configuration, + new NoOpDebugLogger(), + webSocketAdapter, + new NoOpMediaStreamBroker(), + reconnectController, + eventController + ); + await start(); + // @ts-ignore: access private field + const obs = audioVideoController._timingResolutionObserver; + expect(obs).to.not.be.null; + // Should not throw + obs.videoTileResolutionDidChange('attendee', 100, 100); + await stop(); + }); + + it('videoTileUnbound routes groupId to timing manager', async () => { + audioVideoController = new DefaultAudioVideoController( + configuration, + new NoOpDebugLogger(), + webSocketAdapter, + new NoOpMediaStreamBroker(), + reconnectController, + eventController + ); + await start(); + const unboundSpy = sinon.spy( + // @ts-ignore + audioVideoController.meetingSessionContext.meetingSessionTimingManager, + 'onRemoteVideoUnbound' + ); + const tile = audioVideoController.videoTileController.addVideoTile(); + const stream = new MediaStream(); + // @ts-ignore + stream.addTrack(new MediaStreamTrack('mock', 'video')); + tile.bindVideoStream('attendee', false, stream, 1, 1, 1, 'ext', 5); + const videoElement = document.createElement('video'); + audioVideoController.videoTileController.bindVideoElement(tile.id(), videoElement); + audioVideoController.videoTileController.unbindVideoElement(tile.id()); + expect(unboundSpy.calledWith(5)).to.be.true; + await stop(); + }); + }); }); diff --git a/test/dommock/DOMMockBuilder.ts b/test/dommock/DOMMockBuilder.ts index 752f9a1f7c..a9439abf9f 100644 --- a/test/dommock/DOMMockBuilder.ts +++ b/test/dommock/DOMMockBuilder.ts @@ -1624,6 +1624,8 @@ export default class DOMMockBuilder { videoHeight: number; videoWidth: number; private listeners: { [type: string]: MockListener[] } = {}; + private videoFrameCallbacks: Map void> = new Map(); + private videoFrameCallbackNextId: number = 1; style: { [key: string]: string } = { transform: '', }; @@ -1699,6 +1701,23 @@ export default class DOMMockBuilder { } } + requestVideoFrameCallback(callback: (now: number, metadata: object) => void): number { + const id = this.videoFrameCallbackNextId++; + this.videoFrameCallbacks.set(id, callback); + // Fire asynchronously via TimeoutScheduler so it works with fake timers + new TimeoutScheduler(1).start(() => { + if (this.videoFrameCallbacks.has(id)) { + this.videoFrameCallbacks.delete(id); + callback(Date.now(), { expectedDisplayTime: Date.now() }); + } + }); + return id; + } + + cancelVideoFrameCallback(id: number): void { + this.videoFrameCallbacks.delete(id); + } + attributes: { [index: string]: string } = {}; setAttribute(qualifiedName: string, value: string): void { this.attributes[qualifiedName] = value; diff --git a/test/encodedtransformmanager/MediaMetricsEncodedTransformManager.test.ts b/test/encodedtransformmanager/MediaMetricsEncodedTransformManager.test.ts index 804fd07966..8d6a03d6ae 100644 --- a/test/encodedtransformmanager/MediaMetricsEncodedTransformManager.test.ts +++ b/test/encodedtransformmanager/MediaMetricsEncodedTransformManager.test.ts @@ -66,12 +66,140 @@ describe('MediaMetricsTransformManager', () => { }); describe('handleWorkerMessage', () => { - it('ignores NEW_SSRC message type', () => { + it('notifies observer on NEW_SSRC for audio sender', () => { + const spy = sinon.stub(); + const obs: EncodedTransformMediaMetricsObserver = { onFirstPacketReceived: spy }; + manager.addObserver(obs); manager.handleWorkerMessage({ type: MEDIA_METRICS_MESSAGE_TYPES.NEW_SSRC, transformName: TRANSFORM_NAMES.AUDIO_SENDER, message: { ssrc: '12345' }, }); + expect(spy.calledOnce).to.be.true; + expect(spy.firstCall.args).to.deep.equal(['audio', 'send', 12345]); + }); + + it('notifies observer on NEW_SSRC for audio receiver', () => { + const spy = sinon.stub(); + const obs: EncodedTransformMediaMetricsObserver = { onFirstPacketReceived: spy }; + manager.addObserver(obs); + manager.handleWorkerMessage({ + type: MEDIA_METRICS_MESSAGE_TYPES.NEW_SSRC, + transformName: TRANSFORM_NAMES.AUDIO_RECEIVER, + message: { ssrc: '100' }, + }); + expect(spy.calledWith('audio', 'receive', 100)).to.be.true; + }); + + it('notifies observer on NEW_SSRC for video sender', () => { + const spy = sinon.stub(); + const obs: EncodedTransformMediaMetricsObserver = { onFirstPacketReceived: spy }; + manager.addObserver(obs); + manager.handleWorkerMessage({ + type: MEDIA_METRICS_MESSAGE_TYPES.NEW_SSRC, + transformName: TRANSFORM_NAMES.VIDEO_SENDER, + message: { ssrc: '200' }, + }); + expect(spy.calledWith('video', 'send', 200)).to.be.true; + }); + + it('notifies observer on NEW_SSRC for video receiver', () => { + const spy = sinon.stub(); + const obs: EncodedTransformMediaMetricsObserver = { onFirstPacketReceived: spy }; + manager.addObserver(obs); + manager.handleWorkerMessage({ + type: MEDIA_METRICS_MESSAGE_TYPES.NEW_SSRC, + transformName: TRANSFORM_NAMES.VIDEO_RECEIVER, + message: { ssrc: '300' }, + }); + expect(spy.calledWith('video', 'receive', 300)).to.be.true; + }); + + it('ignores NEW_SSRC without ssrc field', () => { + const spy = sinon.stub(); + const obs: EncodedTransformMediaMetricsObserver = { onFirstPacketReceived: spy }; + manager.addObserver(obs); + manager.handleWorkerMessage({ + type: MEDIA_METRICS_MESSAGE_TYPES.NEW_SSRC, + transformName: TRANSFORM_NAMES.AUDIO_SENDER, + message: {}, + }); + expect(spy.called).to.be.false; + }); + + it('ignores NEW_SSRC with null message', () => { + const spy = sinon.stub(); + const obs: EncodedTransformMediaMetricsObserver = { onFirstPacketReceived: spy }; + manager.addObserver(obs); + manager.handleWorkerMessage({ + type: MEDIA_METRICS_MESSAGE_TYPES.NEW_SSRC, + transformName: TRANSFORM_NAMES.AUDIO_SENDER, + message: undefined, + }); + expect(spy.called).to.be.false; + }); + + it('ignores NEW_SSRC with invalid ssrc', () => { + const spy = sinon.stub(); + const obs: EncodedTransformMediaMetricsObserver = { onFirstPacketReceived: spy }; + manager.addObserver(obs); + manager.handleWorkerMessage({ + type: MEDIA_METRICS_MESSAGE_TYPES.NEW_SSRC, + transformName: TRANSFORM_NAMES.AUDIO_SENDER, + message: { ssrc: 'not-a-number' }, + }); + expect(spy.called).to.be.false; + }); + + it('ignores NEW_SSRC with unknown transform name', () => { + const spy = sinon.stub(); + const obs: EncodedTransformMediaMetricsObserver = { onFirstPacketReceived: spy }; + manager.addObserver(obs); + manager.handleWorkerMessage({ + type: MEDIA_METRICS_MESSAGE_TYPES.NEW_SSRC, + transformName: 'UnknownTransform', + message: { ssrc: '12345' }, + }); + expect(spy.called).to.be.false; + }); + + it('catches observer error on NEW_SSRC notification', () => { + const obs: EncodedTransformMediaMetricsObserver = { + onFirstPacketReceived: () => { + throw new Error('test'); + }, + }; + manager.addObserver(obs); + // Should not throw + manager.handleWorkerMessage({ + type: MEDIA_METRICS_MESSAGE_TYPES.NEW_SSRC, + transformName: TRANSFORM_NAMES.AUDIO_SENDER, + message: { ssrc: '12345' }, + }); + }); + + it('handles observer without onFirstPacketReceived on NEW_SSRC', () => { + const obs: EncodedTransformMediaMetricsObserver = {}; + manager.addObserver(obs); + manager.handleWorkerMessage({ + type: MEDIA_METRICS_MESSAGE_TYPES.NEW_SSRC, + transformName: TRANSFORM_NAMES.AUDIO_SENDER, + message: { ssrc: '12345' }, + }); + }); + it('calls encodedTransformMediaMetricsDidReceive as optional', async () => { + await manager.start(); + const obs: EncodedTransformMediaMetricsObserver = {}; + manager.addObserver(obs); + manager.handleWorkerMessage({ + type: COMMON_MESSAGE_TYPES.METRICS, + transformName: TRANSFORM_NAMES.AUDIO_SENDER, + message: { + metrics: JSON.stringify({ 12345: { ssrc: 12345, packetCount: 100, timestamp: 1000 } }), + }, + }); + await clock.tickAsync(1100); + // Should not throw even though observer has no encodedTransformMediaMetricsDidReceive }); it('ignores non-METRICS message type', () => { diff --git a/test/meetingsessiontiming/MeetingSessionTimingManager.test.ts b/test/meetingsessiontiming/MeetingSessionTimingManager.test.ts new file mode 100644 index 0000000000..572a587ad2 --- /dev/null +++ b/test/meetingsessiontiming/MeetingSessionTimingManager.test.ts @@ -0,0 +1,780 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as chai from 'chai'; +import * as sinon from 'sinon'; + +import NoOpDebugLogger from '../../src/logger/NoOpDebugLogger'; +import MeetingSessionTiming, { + MeetingSessionTimingObserver, +} from '../../src/meetingsessiontiming/MeetingSessionTiming'; +import MeetingSessionTimingManager from '../../src/meetingsessiontiming/MeetingSessionTimingManager'; + +describe('MeetingSessionTimingManager', () => { + const expect: Chai.ExpectStatic = chai.expect; + const logger = new NoOpDebugLogger(); + let manager: MeetingSessionTimingManager; + let clock: sinon.SinonFakeTimers; + let observerSpy: sinon.SinonStub; + let observer: MeetingSessionTimingObserver; + + beforeEach(() => { + clock = sinon.useFakeTimers({ now: 1000 }); + manager = new MeetingSessionTimingManager(logger); + observerSpy = sinon.stub(); + observer = { onMeetingSessionTimingReady: observerSpy }; + manager.addObserver(observer); + }); + + afterEach(() => { + manager.destroy(); + clock.restore(); + }); + + // Helper: complete all signaling events + function completeSignaling(): void { + manager.onStart(); + manager.onJoinSent(); + manager.onJoinAckReceived(); + manager.onTransportConnected(); + manager.onCreateOfferCalled(); + manager.onSetLocalDescription(); + manager.onSetRemoteDescription(); + manager.onIceGatheringStarted(); + manager.onIceGatheringComplete(); + manager.onIceConnected(); + manager.onSubscribeSent(); + manager.onSubscribeAckReceived(); + } + + // Helper: complete resubscribe signaling events + function completeResubscribeSignaling(): void { + manager.onResubscribeStart(); + manager.onCreateOfferCalled(); + manager.onSetLocalDescription(); + manager.onSubscribeSent(); + manager.onSubscribeAckReceived(); + manager.onSetRemoteDescription(); + } + + describe('addObserver / removeObserver', () => { + it('emits to observer', () => { + completeSignaling(); + expect(observerSpy.calledOnce).to.be.true; + }); + + it('does not emit after removeObserver', () => { + manager.removeObserver(observer); + completeSignaling(); + expect(observerSpy.called).to.be.false; + }); + + it('removeObserver ignores non-matching observer', () => { + const other: MeetingSessionTimingObserver = { onMeetingSessionTimingReady: sinon.stub() }; + manager.removeObserver(other); + completeSignaling(); + expect(observerSpy.calledOnce).to.be.true; + }); + + it('logs warning when no observer set', () => { + manager.removeObserver(observer); + completeSignaling(); + // Should not throw, just logs warning + }); + }); + + describe('signaling lifecycle', () => { + it('emits batch when all signaling events complete', () => { + completeSignaling(); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.signaling).to.have.lengthOf(1); + expect(timing.signaling[0].startMs).to.not.be.undefined; + expect(timing.signaling[0].timedOut).to.be.false; + }); + + it('ignores duplicate onStart', () => { + manager.onStart(); + manager.onStart(); + }); + + it('ignores signaling events before onStart', () => { + manager.onJoinSent(); + manager.onJoinAckReceived(); + manager.onTransportConnected(); + manager.onCreateOfferCalled(); + manager.onSetLocalDescription(); + manager.onSetRemoteDescription(); + manager.onIceGatheringStarted(); + manager.onIceGatheringComplete(); + manager.onIceConnected(); + manager.onSubscribeSent(); + manager.onSubscribeAckReceived(); + expect(observerSpy.called).to.be.false; + }); + + it('ignores duplicate signaling events', () => { + manager.onStart(); + manager.onJoinSent(); + manager.onJoinSent(); + manager.onJoinAckReceived(); + manager.onJoinAckReceived(); + manager.onTransportConnected(); + manager.onTransportConnected(); + manager.onCreateOfferCalled(); + manager.onCreateOfferCalled(); + manager.onSetLocalDescription(); + manager.onSetLocalDescription(); + manager.onSetRemoteDescription(); + manager.onSetRemoteDescription(); + manager.onIceGatheringStarted(); + manager.onIceGatheringStarted(); + manager.onIceGatheringComplete(); + manager.onIceGatheringComplete(); + manager.onIceConnected(); + manager.onIceConnected(); + manager.onSubscribeSent(); + manager.onSubscribeSent(); + manager.onSubscribeAckReceived(); + expect(observerSpy.calledOnce).to.be.true; + }); + + it('ignores signaling events after batch was sent', () => { + completeSignaling(); + expect(observerSpy.calledOnce).to.be.true; + manager.onJoinSent(); + manager.onJoinAckReceived(); + expect(observerSpy.calledOnce).to.be.true; + }); + }); + + describe('resubscribe signaling', () => { + it('emits batch with resubscribe signaling subset', () => { + completeResubscribeSignaling(); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.signaling).to.have.lengthOf(1); + expect(timing.signaling[0].startMs).to.not.be.undefined; + expect(timing.signaling[0].createOfferMs).to.not.be.undefined; + expect(timing.signaling[0].setLocalDescriptionMs).to.not.be.undefined; + expect(timing.signaling[0].subscribeSentMs).to.not.be.undefined; + expect(timing.signaling[0].subscribeAckMs).to.not.be.undefined; + expect(timing.signaling[0].setRemoteDescriptionMs).to.not.be.undefined; + expect(timing.signaling[0].timedOut).to.be.false; + // Initial-only fields should be undefined + expect(timing.signaling[0].joinSentMs).to.be.undefined; + expect(timing.signaling[0].joinAckReceivedMs).to.be.undefined; + }); + + it('ignores onResubscribeStart when signaling is already active', () => { + manager.onStart(); + manager.onResubscribeStart(); + // Should still require full signaling, not resubscribe subset + manager.onCreateOfferCalled(); + manager.onSetLocalDescription(); + manager.onSubscribeSent(); + manager.onSubscribeAckReceived(); + manager.onSetRemoteDescription(); + expect(observerSpy.called).to.be.false; // Still waiting for join/ICE events + }); + }); + + describe('remote audio lifecycle', () => { + it('emits when remote audio completes', () => { + manager.onRemoteAudioAdded(); + manager.onRemoteAudioFirstPacketReceived(); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.remoteAudio).to.have.lengthOf(1); + expect(timing.remoteAudio[0].addedMs).to.not.be.undefined; + expect(timing.remoteAudio[0].firstPacketReceivedMs).to.not.be.undefined; + }); + + it('ignores duplicate onRemoteAudioAdded', () => { + manager.onRemoteAudioAdded(); + manager.onRemoteAudioAdded(); + }); + + it('ignores first packet before added', () => { + manager.onRemoteAudioFirstPacketReceived(); + expect(observerSpy.called).to.be.false; + }); + + it('ignores duplicate first packet', () => { + manager.onStart(); // keeps batch open + manager.onRemoteAudioAdded(); + manager.onRemoteAudioFirstPacketReceived(); + manager.onRemoteAudioFirstPacketReceived(); + }); + }); + + describe('local audio lifecycle', () => { + it('emits when local audio completes', () => { + manager.onLocalAudioAdded(); + manager.onLocalAudioFirstPacketSent(); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.localAudio).to.have.lengthOf(1); + }); + + it('ignores duplicate onLocalAudioAdded', () => { + manager.onLocalAudioAdded(); + manager.onLocalAudioAdded(); + }); + + it('ignores first packet sent before added', () => { + manager.onLocalAudioFirstPacketSent(); + expect(observerSpy.called).to.be.false; + }); + + it('ignores duplicate first packet sent', () => { + manager.onStart(); // keeps batch open + manager.onLocalAudioAdded(); + manager.onLocalAudioFirstPacketSent(); + manager.onLocalAudioFirstPacketSent(); + }); + }); + + describe('local video lifecycle', () => { + it('emits when local video completes', () => { + manager.onLocalVideoAdded(); + manager.onLocalVideoFirstFrameSent(); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.localVideo).to.have.lengthOf(1); + }); + + it('ignores duplicate onLocalVideoAdded', () => { + manager.onLocalVideoAdded(); + manager.onLocalVideoAdded(); + }); + + it('ignores first frame sent before added', () => { + manager.onLocalVideoFirstFrameSent(); + expect(observerSpy.called).to.be.false; + }); + + it('ignores duplicate first frame sent', () => { + manager.onStart(); // keeps batch open + manager.onLocalVideoAdded(); + manager.onLocalVideoFirstFrameSent(); + manager.onLocalVideoFirstFrameSent(); + }); + + it('emits removed flag on onLocalVideoRemoved', () => { + manager.onLocalVideoAdded(); + manager.onLocalVideoRemoved(); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.localVideo[0].removed).to.be.true; + }); + + it('ignores onLocalVideoRemoved without prior add', () => { + manager.onLocalVideoRemoved(); + expect(observerSpy.called).to.be.false; + }); + + it('allows re-add after removal', () => { + manager.onLocalVideoAdded(); + manager.onLocalVideoRemoved(); + expect(observerSpy.calledOnce).to.be.true; + // After removal, localVideoHasEmitted is reset, so re-add works + manager.onLocalVideoAdded(); + manager.onLocalVideoFirstFrameSent(); + expect(observerSpy.calledTwice).to.be.true; + }); + + it('localVideoHasEmitted prevents re-add on resubscribe', () => { + manager.onLocalVideoAdded(); + manager.onLocalVideoFirstFrameSent(); + expect(observerSpy.calledOnce).to.be.true; + // After emission, localVideoHasEmitted is true + // Resubscribe calls onLocalVideoAdded again — should be ignored + manager.onLocalVideoAdded(); + // No new batch should be started for local video + clock.tick(15000); + // Only the original emission + expect(observerSpy.calledOnce).to.be.true; + }); + + it('localVideoHasEmitted resets on removal', () => { + manager.onLocalVideoAdded(); + manager.onLocalVideoFirstFrameSent(); + expect(observerSpy.calledOnce).to.be.true; + // Remove resets the flag + manager.onLocalVideoAdded(); // ignored due to localVideoHasEmitted + manager.onLocalVideoRemoved(); // no-op since addedMs is undefined + // But if we reset the manager and re-add, it works + manager.reset(); + manager.onLocalVideoAdded(); + manager.onLocalVideoFirstFrameSent(); + expect(observerSpy.calledTwice).to.be.true; + }); + }); + + describe('remote video lifecycle', () => { + it('emits when remote video first frame renders', () => { + manager.onRemoteVideoAdded(1); + manager.onRemoteVideoBound(1); + manager.onRemoteVideoFirstFrameRendered(1); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.remoteVideos).to.have.lengthOf(1); + expect(timing.remoteVideos[0].groupId).to.equal(1); + expect(timing.remoteVideos[0].addedMs).to.not.be.undefined; + expect(timing.remoteVideos[0].firstFrameRenderedMs).to.not.be.undefined; + expect(timing.remoteVideos[0].timedOut).to.be.false; + }); + + it('tracks multiple remote videos', () => { + manager.onRemoteVideoAdded(1); + manager.onRemoteVideoAdded(2); + manager.onRemoteVideoBound(1); + manager.onRemoteVideoBound(2); + manager.onRemoteVideoFirstFrameRendered(2); + expect(observerSpy.called).to.be.false; // group 1 not complete + manager.onRemoteVideoFirstFrameRendered(1); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.remoteVideos).to.have.lengthOf(2); + }); + + it('ignores first packet before added', () => { + manager.onRemoteVideoFirstPacketReceived(1); + expect(observerSpy.called).to.be.false; + }); + + it('ignores duplicate first packet', () => { + manager.onRemoteVideoAdded(1); + manager.onRemoteVideoBound(1); + manager.onRemoteVideoFirstPacketReceived(1); + manager.onRemoteVideoFirstPacketReceived(1); + }); + + it('ignores first frame before added', () => { + manager.onRemoteVideoFirstFrameRendered(1); + expect(observerSpy.called).to.be.false; + }); + + it('ignores duplicate first frame', () => { + manager.onRemoteVideoAdded(1); + manager.onRemoteVideoBound(1); + manager.onRemoteVideoFirstFrameRendered(1); + manager.onRemoteVideoFirstFrameRendered(1); + }); + + it('emits removed flag on onRemoteVideoRemoved', () => { + manager.onRemoteVideoAdded(1); + manager.onRemoteVideoBound(1); + manager.onRemoteVideoRemoved(1); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.remoteVideos[0].removed).to.be.true; + expect(timing.remoteVideos[0].timedOut).to.be.false; + }); + + it('ignores onRemoteVideoRemoved for unknown groupId', () => { + manager.onRemoteVideoRemoved(999); + expect(observerSpy.called).to.be.false; + }); + + it('onRemoteVideoUnbound removes groupId from bound set and triggers emission', () => { + manager.onRemoteVideoAdded(1); + manager.onRemoteVideoAdded(2); + manager.onRemoteVideoBound(1); + manager.onRemoteVideoBound(2); + manager.onRemoteVideoFirstFrameRendered(1); + expect(observerSpy.called).to.be.false; // group 2 not complete + manager.onRemoteVideoUnbound(2); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.remoteVideos).to.have.lengthOf(1); + expect(timing.remoteVideos[0].groupId).to.equal(1); + }); + + it('onRemoteVideoUnbound triggers batch completion for unbound entries', () => { + manager.onRemoteVideoAdded(1); + manager.onRemoteVideoUnbound(1); + // Batch completes because unbound entries are skipped + expect(observerSpy.calledOnce).to.be.true; + const timing = observerSpy.firstCall.args[0]; + expect(timing.remoteVideos).to.have.lengthOf(0); + }); + + it('omits unbound remote video from emission', () => { + manager.onRemoteVideoAdded(1); + // Never call onRemoteVideoBound(1) + // Unbound entries are skipped — batch completes on timeout + clock.tick(15000); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.remoteVideos).to.have.lengthOf(0); + }); + + it('omits unbound remote video but includes bound ones', () => { + manager.onRemoteVideoAdded(1); + manager.onRemoteVideoAdded(2); + manager.onRemoteVideoBound(1); + // group 2 never bound + manager.onRemoteVideoFirstFrameRendered(1); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.remoteVideos).to.have.lengthOf(1); + expect(timing.remoteVideos[0].groupId).to.equal(1); + }); + + it('replaces timer on re-add of same groupId', () => { + manager.onRemoteVideoAdded(1); + manager.onRemoteVideoBound(1); + clock.tick(100); + manager.onRemoteVideoAdded(1); // replaces + manager.onRemoteVideoFirstFrameRendered(1); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + // addedMs should be the second add time (1000 + 100 = 1100) + expect(timing.remoteVideos[0].addedMs).to.equal(1100); + }); + }); + + describe('expectingRemoteVideo', () => { + it('holds batch open when expecting remote video', () => { + manager.onStart(); + manager.setExpectingRemoteVideo(); + // Complete signaling but no remote video added yet + manager.onJoinSent(); + manager.onJoinAckReceived(); + manager.onTransportConnected(); + manager.onCreateOfferCalled(); + manager.onSetLocalDescription(); + manager.onSetRemoteDescription(); + manager.onIceGatheringStarted(); + manager.onIceGatheringComplete(); + manager.onIceConnected(); + manager.onSubscribeSent(); + manager.onSubscribeAckReceived(); + expect(observerSpy.called).to.be.false; // held open + // Now add and complete remote video + manager.onRemoteVideoAdded(1); + manager.onRemoteVideoBound(1); + manager.onRemoteVideoFirstFrameRendered(1); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.signaling).to.have.lengthOf(1); + expect(timing.remoteVideos).to.have.lengthOf(1); + }); + + it('duplicate setExpectingRemoteVideo is no-op', () => { + manager.setExpectingRemoteVideo(); + manager.setExpectingRemoteVideo(); + }); + + it('clearExpectingRemoteVideo releases the batch', () => { + manager.onStart(); + manager.setExpectingRemoteVideo(); + manager.onJoinSent(); + manager.onJoinAckReceived(); + manager.onTransportConnected(); + manager.onCreateOfferCalled(); + manager.onSetLocalDescription(); + manager.onSetRemoteDescription(); + manager.onIceGatheringStarted(); + manager.onIceGatheringComplete(); + manager.onIceConnected(); + manager.onSubscribeSent(); + manager.onSubscribeAckReceived(); + expect(observerSpy.called).to.be.false; + manager.clearExpectingRemoteVideo(); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.remoteVideos).to.have.lengthOf(0); + }); + + it('clearExpectingRemoteVideo is no-op when not expecting', () => { + manager.clearExpectingRemoteVideo(); + expect(observerSpy.called).to.be.false; + }); + }); + + describe('timeout', () => { + it('emits with timedOut after 15s', () => { + manager.onStart(); + clock.tick(15000); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.signaling[0].timedOut).to.be.true; + }); + + it('sets timedOut per-category', () => { + manager.onStart(); + manager.onRemoteAudioAdded(); // Add before signaling completes + manager.onJoinSent(); + manager.onJoinAckReceived(); + manager.onTransportConnected(); + manager.onCreateOfferCalled(); + manager.onSetLocalDescription(); + manager.onSetRemoteDescription(); + manager.onIceGatheringStarted(); + manager.onIceGatheringComplete(); + manager.onIceConnected(); + manager.onSubscribeSent(); + manager.onSubscribeAckReceived(); + // Signaling complete, remote audio incomplete — batch held open + expect(observerSpy.called).to.be.false; + clock.tick(15000); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.signaling[0].timedOut).to.be.false; // signaling was complete + expect(timing.remoteAudio[0].timedOut).to.be.true; // remote audio was not + }); + + it('does not emit timeout for unbound remote video', () => { + manager.onRemoteVideoAdded(1); + // Never bound — should not block or appear in timeout + clock.tick(15000); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.remoteVideos).to.have.lengthOf(0); + }); + + it('times out bound remote video that never renders', () => { + manager.onRemoteVideoAdded(1); + manager.onRemoteVideoBound(1); + clock.tick(15000); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.remoteVideos).to.have.lengthOf(1); + expect(timing.remoteVideos[0].timedOut).to.be.true; + }); + }); + + describe('batch composition', () => { + it('combines signaling, audio, and video in one batch', () => { + manager.onStart(); + manager.onRemoteAudioAdded(); + manager.onLocalAudioAdded(); + manager.onLocalVideoAdded(); + // Complete signaling + manager.onJoinSent(); + manager.onJoinAckReceived(); + manager.onTransportConnected(); + manager.onCreateOfferCalled(); + manager.onSetLocalDescription(); + manager.onSetRemoteDescription(); + manager.onIceGatheringStarted(); + manager.onIceGatheringComplete(); + manager.onIceConnected(); + manager.onSubscribeSent(); + manager.onSubscribeAckReceived(); + // Complete audio + manager.onRemoteAudioFirstPacketReceived(); + manager.onLocalAudioFirstPacketSent(); + // Complete local video + manager.onLocalVideoFirstFrameSent(); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.signaling).to.have.lengthOf(1); + expect(timing.remoteAudio).to.have.lengthOf(1); + expect(timing.localAudio).to.have.lengthOf(1); + expect(timing.localVideo).to.have.lengthOf(1); + }); + + it('clears state after emission for fresh batches', () => { + manager.onRemoteAudioAdded(); + manager.onRemoteAudioFirstPacketReceived(); + expect(observerSpy.calledOnce).to.be.true; + // New batch + manager.onRemoteVideoAdded(1); + manager.onRemoteVideoBound(1); + manager.onRemoteVideoFirstFrameRendered(1); + expect(observerSpy.calledTwice).to.be.true; + const timing2: MeetingSessionTiming = observerSpy.secondCall.args[0]; + expect(timing2.remoteAudio).to.have.lengthOf(0); + expect(timing2.remoteVideos).to.have.lengthOf(1); + }); + + it('resubscribe batch includes signaling and remote video', () => { + manager.onResubscribeStart(); + manager.onRemoteVideoAdded(1); + manager.onRemoteVideoBound(1); + manager.onCreateOfferCalled(); + manager.onSetLocalDescription(); + manager.onSubscribeSent(); + manager.onSubscribeAckReceived(); + manager.onSetRemoteDescription(); + manager.onRemoteVideoFirstFrameRendered(1); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.signaling).to.have.lengthOf(1); + expect(timing.remoteVideos).to.have.lengthOf(1); + }); + }); + + describe('reset', () => { + it('clears all state', () => { + manager.onStart(); + manager.reset(); + // After reset, onStart works again + completeSignaling(); + expect(observerSpy.calledOnce).to.be.true; + }); + + it('cancels pending timeout', () => { + manager.onStart(); + manager.reset(); + clock.tick(15000); + expect(observerSpy.called).to.be.false; + }); + + it('resets localVideoHasEmitted', () => { + manager.onLocalVideoAdded(); + manager.onLocalVideoFirstFrameSent(); + expect(observerSpy.calledOnce).to.be.true; + manager.reset(); + manager.onLocalVideoAdded(); + manager.onLocalVideoFirstFrameSent(); + expect(observerSpy.calledTwice).to.be.true; + }); + + it('resets expectingRemoteVideo', () => { + manager.setExpectingRemoteVideo(); + manager.reset(); + // After reset, batch should not be held open + manager.onRemoteAudioAdded(); + manager.onRemoteAudioFirstPacketReceived(); + expect(observerSpy.calledOnce).to.be.true; + }); + }); + + describe('destroy', () => { + it('cancels timeout and clears observer', () => { + manager.onStart(); + manager.destroy(); + clock.tick(15000); + expect(observerSpy.called).to.be.false; + }); + }); + + describe('observer error handling', () => { + it('catches observer errors without crashing', () => { + const throwingObserver: MeetingSessionTimingObserver = { + onMeetingSessionTimingReady: () => { + throw new Error('observer error'); + }, + }; + manager.removeObserver(observer); + manager.addObserver(throwingObserver); + // Should not throw + completeSignaling(); + }); + }); + + describe('edge cases', () => { + it('duplicate onRemoteVideoFirstFrameRendered is ignored', () => { + manager.onStart(); // keeps batch open + manager.onRemoteVideoAdded(1); + manager.onRemoteVideoBound(1); + manager.onRemoteVideoFirstFrameRendered(1); + manager.onRemoteVideoFirstFrameRendered(1); // hits duplicate check + }); + + it('scheduleBatchTimeout is no-op when timeout already exists', () => { + // onStart schedules a timeout, onRemoteAudioAdded tries to schedule again + manager.onStart(); + manager.onRemoteAudioAdded(); + // Should not throw or create duplicate timeouts + clock.tick(15000); + expect(observerSpy.calledOnce).to.be.true; + }); + + it('onRemoteVideoFirstFrameRendered warns when no timer found', () => { + manager.onRemoteVideoFirstFrameRendered(999); + expect(observerSpy.called).to.be.false; + }); + + it('onRemoteVideoFirstFrameRendered uses expectedDisplayTime from metadata', () => { + manager.onRemoteVideoAdded(1); + manager.onRemoteVideoBound(1); + manager.onRemoteVideoFirstFrameRendered(1, { + expectedDisplayTime: 500, + } as VideoFrameCallbackMetadata); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.remoteVideos[0].firstFrameRenderedMs).to.be.a('number'); + }); + + it('onRemoteVideoFirstFrameRendered falls back to Date.now without metadata', () => { + manager.onRemoteVideoAdded(1); + manager.onRemoteVideoBound(1); + manager.onRemoteVideoFirstFrameRendered(1); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.remoteVideos[0].firstFrameRenderedMs).to.equal(1000); + }); + + it('resubscribe signaling completes with subset of fields', () => { + manager.onResubscribeStart(); + manager.onCreateOfferCalled(); + manager.onSetLocalDescription(); + manager.onSubscribeSent(); + manager.onSubscribeAckReceived(); + manager.onSetRemoteDescription(); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.signaling[0].timedOut).to.be.false; + }); + + it('isSignalingComplete returns false for incomplete resubscribe', () => { + // Add an incomplete category to keep the batch open + manager.onLocalAudioAdded(); + // Start resubscribe — isSignalingComplete will be called with isResubscribe=true + // but incomplete fields, exercising the resubscribe branch returning false + manager.onResubscribeStart(); + manager.onCreateOfferCalled(); + // Batch still open because local audio and signaling are incomplete + expect(observerSpy.called).to.be.false; + }); + + it('timeout sets timedOut on incomplete local audio', () => { + manager.onLocalAudioAdded(); + clock.tick(15000); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.localAudio[0].timedOut).to.be.true; + }); + + it('timeout sets timedOut on incomplete local video', () => { + manager.onLocalVideoAdded(); + clock.tick(15000); + expect(observerSpy.calledOnce).to.be.true; + const timing: MeetingSessionTiming = observerSpy.firstCall.args[0]; + expect(timing.localVideo[0].timedOut).to.be.true; + }); + + it('ignores onLocalVideoFirstFrameSent before added', () => { + manager.onLocalVideoFirstFrameSent(); + expect(observerSpy.called).to.be.false; + }); + + it('ignores duplicate onLocalVideoFirstFrameSent', () => { + manager.onLocalVideoAdded(); + manager.onLocalVideoFirstFrameSent(); + manager.onLocalVideoFirstFrameSent(); + }); + + it('ignores onLocalAudioFirstPacketSent before added', () => { + manager.onLocalAudioFirstPacketSent(); + expect(observerSpy.called).to.be.false; + }); + + it('ignores duplicate onLocalAudioFirstPacketSent', () => { + manager.onLocalAudioAdded(); + manager.onLocalAudioFirstPacketSent(); + manager.onLocalAudioFirstPacketSent(); + }); + + it('ignores onRemoteAudioFirstPacketReceived before added', () => { + manager.onRemoteAudioFirstPacketReceived(); + expect(observerSpy.called).to.be.false; + }); + }); +}); diff --git a/test/signalingclient/DefaultSignalingClient.test.ts b/test/signalingclient/DefaultSignalingClient.test.ts index c573e7fc2f..a7dde335e0 100644 --- a/test/signalingclient/DefaultSignalingClient.test.ts +++ b/test/signalingclient/DefaultSignalingClient.test.ts @@ -1172,6 +1172,82 @@ describe('DefaultSignalingClient', () => { }); }); + describe('sendMeetingSessionTiming', () => { + it('sends a timing frame with all categories', done => { + const testObjects = new TestObjects(); + activeTestObjects.push(testObjects); + testObjects.signalingClient.openConnection(testObjects.request); + setTimeout(() => { + testObjects.signalingClient.sendMeetingSessionTiming({ + signaling: [ + { + startMs: 1000, + joinSentMs: 1010, + joinAckReceivedMs: 1020, + transportConnectedMs: 1015, + createOfferMs: 1025, + setLocalDescriptionMs: 1030, + setRemoteDescriptionMs: 1040, + iceGatheringStartMs: 1030, + iceGatheringCompleteMs: 1031, + iceConnectedMs: 1050, + subscribeSentMs: 1035, + subscribeAckMs: 1038, + timedOut: false, + }, + ], + remoteAudio: [ + { + addedMs: 1000, + firstPacketReceivedMs: 1100, + timedOut: false, + }, + ], + localAudio: [ + { + addedMs: 1000, + firstPacketSentMs: 1110, + timedOut: false, + }, + ], + localVideo: [ + { + addedMs: 1200, + firstFrameSentMs: 1300, + timedOut: false, + }, + ], + remoteVideos: [ + { + groupId: 1, + addedMs: 1400, + firstPacketReceivedMs: 1450, + firstFrameRenderedMs: 1500, + timedOut: false, + }, + ], + }); + done(); + }, 100); + }); + + it('sends a timing frame with empty categories', done => { + const testObjects = new TestObjects(); + activeTestObjects.push(testObjects); + testObjects.signalingClient.openConnection(testObjects.request); + setTimeout(() => { + testObjects.signalingClient.sendMeetingSessionTiming({ + signaling: [], + remoteAudio: [], + localAudio: [], + localVideo: [], + remoteVideos: [], + }); + done(); + }, 100); + }); + }); + describe('generateNewAudioSessionId', () => { it('will generate a random audio session id', done => { const randomNumSet = new Set(); diff --git a/test/statscollector/StatsCollector.test.ts b/test/statscollector/StatsCollector.test.ts index 5bb9ad83cd..43bc8af782 100644 --- a/test/statscollector/StatsCollector.test.ts +++ b/test/statscollector/StatsCollector.test.ts @@ -1051,6 +1051,75 @@ describe('StatsCollector', () => { }); }); + describe('videoTileRenderMetricsDidReceive', () => { + it('ignores local video metrics (groupId 0)', () => { + statsCollector.videoTileRenderMetricsDidReceive(0, { fps: 30, timestampMs: Date.now() }); + // Should not throw, just returns early + }); + + it('stores remote video FPS by groupId', () => { + statsCollector.videoTileRenderMetricsDidReceive(1, { fps: 25, timestampMs: Date.now() }); + statsCollector.videoTileRenderMetricsDidReceive(2, { fps: 15, timestampMs: Date.now() }); + // Values are stored internally and used in metric reports + }); + + it('adds videoRemoteRenderFps to downstream metric report', done => { + class TestVideoStreamIndex extends DefaultVideoStreamIndex { + allStreams(): DefaultVideoStreamIdSet { + return new DefaultVideoStreamIdSet([1]); + } + + streamIdForSSRC(_ssrcId: number): number { + return 1; + } + + attendeeIdForStreamId(_streamId: number): string { + return 'attendee-1'; + } + + groupIdForSSRC(_ssrcId: number): number { + return 5; + } + } + + domMockBehavior.rtcPeerConnectionGetStatsReports = [ + { + id: 'RTCInboundRTPVideoStream', + type: 'inbound-rtp', + kind: 'video', + packetsLost: 0, + jitterBufferDelay: 0, + decoderImplementation: 'FFmpeg', + }, + ]; + + statsCollector = new StatsCollector(audioVideoController, logger, interval); + statsCollector.videoTileRenderMetricsDidReceive(5, { fps: 25, timestampMs: Date.now() }); + statsCollector.start(signalingClient, new TestVideoStreamIndex(logger)); + + const streamMetricReport = new StreamMetricReport(); + streamMetricReport.streamId = 1; + streamMetricReport.groupId = 5; + streamMetricReport.mediaType = ClientMetricReportMediaType.VIDEO; + streamMetricReport.direction = ClientMetricReportDirection.DOWNSTREAM; + // @ts-ignore + statsCollector.clientMetricReport.streamMetricReports[1] = streamMetricReport; + + new TimeoutScheduler(interval + 5).start(() => { + expect(streamMetricReport.currentMetrics['videoRemoteRenderFps']).to.equal(25); + statsCollector.stop(); + done(); + }); + }); + + it('cleans up remoteRenderFpsMap on videoTileUnbound with groupId', () => { + statsCollector.videoTileRenderMetricsDidReceive(5, { fps: 25, timestampMs: Date.now() }); + statsCollector.videoTileUnbound('attendee-1', 5); + // After unbind, the fps entry should be removed (no way to directly check the map, + // but we verify it doesn't crash and the entry is cleaned) + }); + }); + describe('stop', () => { it('stops without an error even if it has not started', done => { const spy = sinon.spy(audioVideoController.rtcPeerConnection, 'getStats'); diff --git a/test/task/CreatePeerConnectionTask.test.ts b/test/task/CreatePeerConnectionTask.test.ts index 40181d923f..60cd6b725c 100644 --- a/test/task/CreatePeerConnectionTask.test.ts +++ b/test/task/CreatePeerConnectionTask.test.ts @@ -12,6 +12,7 @@ import DefaultBrowserBehavior from '../../src/browserbehavior/DefaultBrowserBeha import EncodedTransformWorkerManager from '../../src/encodedtransformmanager/EncodedTransformWorkerManager'; import NoOpLogger from '../../src/logger/NoOpLogger'; import MeetingSessionTURNCredentials from '../../src/meetingsession/MeetingSessionTURNCredentials'; +import MeetingSessionTimingManager from '../../src/meetingsessiontiming/MeetingSessionTimingManager'; import CreatePeerConnectionTask from '../../src/task/CreatePeerConnectionTask'; import Task from '../../src/task/Task'; import DefaultTransceiverController from '../../src/transceivercontroller/DefaultTransceiverController'; @@ -154,17 +155,27 @@ describe('CreatePeerConnectionTask', () => { }); }); - it('do not log events if peer is null', done => { - task.run().then(() => {}); - - context.peer.dispatchEvent(makeICEEvent(null)); - context.peer.dispatchEvent(makeICEEvent('a=candidate something')); - context.peer.dispatchEvent(new Event('iceconnectionstatechange')); - context.peer.dispatchEvent(new Event('icegatheringstatechange')); - context.peer.dispatchEvent(new Event('negotiationneeded')); - context.peer.dispatchEvent(new Event('connectionstatechange')); - context.peer = null; - done(); + it('calls timing manager onIceConnected when ice state is connected', done => { + const timingManager = new MeetingSessionTimingManager(logger); + context.meetingSessionTimingManager = timingManager; + const spy = sinon.spy(timingManager, 'onIceConnected'); + task.run().then(() => { + // @ts-ignore + context.peer.iceConnectionState = 'connected'; + context.peer.dispatchEvent(new Event('iceconnectionstatechange')); + expect(spy.calledOnce).to.be.true; + timingManager.destroy(); + done(); + }); + }); + + it('handles ice connected without timing manager', done => { + task.run().then(() => { + // @ts-ignore + context.peer.iceConnectionState = 'connected'; + context.peer.dispatchEvent(new Event('iceconnectionstatechange')); + done(); + }); }); }); @@ -741,6 +752,53 @@ describe('CreatePeerConnectionTask', () => { expect(removeVideoTileCalled).to.be.true; }); + it('calls timing manager onRemoteVideoRemoved when track ends with groupId', async () => { + const timingManager = new MeetingSessionTimingManager(logger); + context.meetingSessionTimingManager = timingManager; + const spy = sinon.spy(timingManager, 'onRemoteVideoRemoved'); + const attendeeIdForTrack = 'attendee-id'; + class TestVideoStreamIndex extends DefaultVideoStreamIndex { + streamIdForTrack(_trackId: string): number { + return 1; + } + attendeeIdForTrack(_trackId: string): string { + return attendeeIdForTrack; + } + groupIdForStreamId(_streamId: number): number { + return 5; + } + } + context.videoStreamIndex = new TestVideoStreamIndex(logger); + + let tile: VideoTile; + class TestVideoTileController extends DefaultVideoTileController { + addVideoTile(): VideoTile { + tile = super.addVideoTile(); + // Set groupId on the tile state + tile.stateRef().groupId = 5; + return tile; + } + removeVideoTile(_tileId: number): void {} + } + context.videoTileController = new TestVideoTileController( + new DefaultVideoTileFactory(), + context.audioVideoController, + logger + ); + + await task.run(); + context.peer.addEventListener('track', (event: RTCTrackEvent) => { + const track = event.track; + const stream = event.streams[0]; + stream.removeTrack(track); + }); + const setRemotePromise = context.peer.setRemoteDescription(videoRemoteDescription); + await tick(clock, domMockBehavior.asyncWaitMs + 10); + await setRemotePromise; + expect(spy.calledWith(5)).to.be.true; + timingManager.destroy(); + }); + it('uses a stream for handling the "removetrack" event and removing stream ID from the paused video stream ID set', async () => { let removeVideoTileCalled = false; class TestTransceiverController extends DefaultTransceiverController { diff --git a/test/task/JoinAndReceiveIndexTask.test.ts b/test/task/JoinAndReceiveIndexTask.test.ts index cc05f280ab..16f8e1a2c8 100644 --- a/test/task/JoinAndReceiveIndexTask.test.ts +++ b/test/task/JoinAndReceiveIndexTask.test.ts @@ -15,6 +15,7 @@ import MeetingSessionConfiguration from '../../src/meetingsession/MeetingSession import MeetingSessionStatus from '../../src/meetingsession/MeetingSessionStatus'; import MeetingSessionStatusCode from '../../src/meetingsession/MeetingSessionStatusCode'; import MeetingSessionURLs from '../../src/meetingsession/MeetingSessionURLs'; +import MeetingSessionTimingManager from '../../src/meetingsessiontiming/MeetingSessionTimingManager'; import DefaultSignalingClient from '../../src/signalingclient/DefaultSignalingClient'; import ServerSideNetworkAdaption from '../../src/signalingclient/ServerSideNetworkAdaption'; import SignalingClientConnectionRequest from '../../src/signalingclient/SignalingClientConnectionRequest'; @@ -23,6 +24,7 @@ import { SdkJoinAckFrame, SdkServerSideNetworkAdaption, SdkSignalFrame, + SdkStreamDescriptor, SdkTurnCredentials, } from '../../src/signalingprotocol/SignalingProtocol.js'; import JoinAndReceiveIndexTask from '../../src/task/JoinAndReceiveIndexTask'; @@ -543,6 +545,37 @@ describe('JoinAndReceiveIndexTask', () => { }); }); + describe('timing manager integration', () => { + it('calls setExpectingRemoteVideo when index has sources', async () => { + const timingManager = new MeetingSessionTimingManager(logger); + context.meetingSessionTimingManager = timingManager; + const spy = sinon.spy(timingManager, 'setExpectingRemoteVideo'); + + const indexFrame = SdkIndexFrame.create(); + indexFrame.sources = [SdkStreamDescriptor.create({ streamId: 1, groupId: 1 })]; + const indexSignal = SdkSignalFrame.create(); + indexSignal.type = SdkSignalFrame.Type.INDEX; + indexSignal.index = indexFrame; + const indexBuffer = SdkSignalFrame.encode(indexSignal).finish(); + const indexWithSources = new Uint8Array(indexBuffer.length + 1); + indexWithSources[0] = 0x5; + indexWithSources.set(indexBuffer, 1); + + await tick(clock, behavior.asyncWaitMs + 10); + setTimeout(() => { + webSocketAdapter.send(joinAckSignalBuffer); + }, 100); + setTimeout(() => { + webSocketAdapter.send(indexWithSources); + }, 200); + const taskPromise = task.run(); + await tick(clock, 250); + await taskPromise; + expect(spy.calledOnce).to.be.true; + timingManager.destroy(); + }); + }); + describe('cancel', () => { it('should cancel the task and throw the reject', async () => { await tick(clock, behavior.asyncWaitMs + 10); diff --git a/test/task/ReceiveVideoStreamIndexTask.test.ts b/test/task/ReceiveVideoStreamIndexTask.test.ts index d031c8017b..5f104177db 100644 --- a/test/task/ReceiveVideoStreamIndexTask.test.ts +++ b/test/task/ReceiveVideoStreamIndexTask.test.ts @@ -9,6 +9,7 @@ import NoOpAudioVideoController from '../../src/audiovideocontroller/NoOpAudioVi import AudioVideoObserver from '../../src/audiovideoobserver/AudioVideoObserver'; import NoOpLogger from '../../src/logger/NoOpLogger'; import MeetingSessionVideoAvailability from '../../src/meetingsession/MeetingSessionVideoAvailability'; +import MeetingSessionTimingManager from '../../src/meetingsessiontiming/MeetingSessionTimingManager'; import VideoCodecCapability from '../../src/sdp/VideoCodecCapability'; import DefaultSignalingClient from '../../src/signalingclient/DefaultSignalingClient'; import SignalingClientConnectionRequest from '../../src/signalingclient/SignalingClientConnectionRequest'; @@ -249,6 +250,29 @@ describe('ReceiveVideoStreamIndexTask', () => { expect(context.videosToReceive.equal(new DefaultVideoStreamIdSet(ids))).to.be.true; }); + it('calls clearExpectingRemoteVideo when no videos to receive', async () => { + const timingManager = new MeetingSessionTimingManager(logger); + context.meetingSessionTimingManager = timingManager; + const spy = sinon.spy(timingManager, 'clearExpectingRemoteVideo'); + + class TestVideoDownlinkBandwidthPolicy extends NoVideoDownlinkBandwidthPolicy { + wantsResubscribe(): boolean { + return true; + } + chooseSubscriptions(): DefaultVideoStreamIdSet { + return new DefaultVideoStreamIdSet(); + } + } + context.videoDownlinkBandwidthPolicy = new TestVideoDownlinkBandwidthPolicy(); + + task.run(); + await tick(clock, behavior.asyncWaitMs); + webSocketAdapter.send(createIndexSignalBuffer()); + await tick(clock, behavior.asyncWaitMs + 10); + expect(spy.calledOnce).to.be.true; + timingManager.destroy(); + }); + it('Truncates the videos to receive to a specified limit', async () => { const ids: number[] = [1, 2, 3]; const truncatedRecieveSetIds: number[] = [1]; diff --git a/test/task/StartEncodedTransformWorkerTask.test.ts b/test/task/StartEncodedTransformWorkerTask.test.ts index 8e154046bd..6eb7ee3ccb 100644 --- a/test/task/StartEncodedTransformWorkerTask.test.ts +++ b/test/task/StartEncodedTransformWorkerTask.test.ts @@ -6,6 +6,7 @@ import chaiAsPromised from 'chai-as-promised'; import * as sinon from 'sinon'; import AudioProfile from '../../src/audioprofile/AudioProfile'; +import AudioVideoController from '../../src/audiovideocontroller/AudioVideoController'; import AudioVideoControllerState from '../../src/audiovideocontroller/AudioVideoControllerState'; import NoOpAudioVideoController from '../../src/audiovideocontroller/NoOpAudioVideoController'; import EncodedTransformWorkerManager from '../../src/encodedtransformmanager/EncodedTransformWorkerManager'; @@ -205,6 +206,7 @@ describe('StartEncodedTransformWorkerTask', () => { addObserver: sinon.stub(), }; const mockStatsCollector = {}; + const mockAudioVideoController = {}; const mockManager: Partial = { isEnabled: () => true, start: sinon.stub().resolves(), @@ -215,11 +217,13 @@ describe('StartEncodedTransformWorkerTask', () => { context.encodedTransformWorkerManager = mockManager as EncodedTransformWorkerManager; context.audioProfile = new AudioProfile(null, false); context.statsCollector = mockStatsCollector as unknown as StatsCollector; + context.audioVideoController = mockAudioVideoController as unknown as AudioVideoController; await task.run(); - expect(mockMetricsManager.addObserver.calledOnce).to.be.true; + expect(mockMetricsManager.addObserver.calledTwice).to.be.true; expect(mockMetricsManager.addObserver.calledWith(mockStatsCollector)).to.be.true; + expect(mockMetricsManager.addObserver.calledWith(mockAudioVideoController)).to.be.true; }); it('does not add metrics observer when metricsTransformManager is null', async () => { @@ -242,6 +246,7 @@ describe('StartEncodedTransformWorkerTask', () => { const mockMetricsManager = { addObserver: sinon.stub(), }; + const mockAudioVideoController = {}; const mockManager: Partial = { isEnabled: () => true, start: sinon.stub().resolves(), @@ -252,10 +257,12 @@ describe('StartEncodedTransformWorkerTask', () => { context.encodedTransformWorkerManager = mockManager as EncodedTransformWorkerManager; context.audioProfile = new AudioProfile(null, false); context.statsCollector = null; + context.audioVideoController = mockAudioVideoController as unknown as AudioVideoController; await task.run(); - expect(mockMetricsManager.addObserver.called).to.be.false; + expect(mockMetricsManager.addObserver.calledOnce).to.be.true; + expect(mockMetricsManager.addObserver.calledWith(mockAudioVideoController)).to.be.true; }); it('adds redundantAudioEncodeTransformManager observer when statsCollector is available', async () => { diff --git a/test/videoelementfactory/NoOpVideoElementFactory.test.ts b/test/videoelementfactory/NoOpVideoElementFactory.test.ts index 9212fbfa69..2d752c9ecb 100644 --- a/test/videoelementfactory/NoOpVideoElementFactory.test.ts +++ b/test/videoelementfactory/NoOpVideoElementFactory.test.ts @@ -50,5 +50,20 @@ describe('NoOpVideoElementFactory', () => { it('can call play method', async () => { await expect(element.play()).not.to.be.rejected; }); + + it('can call requestVideoFrameCallback and cancelVideoFrameCallback', () => { + // @ts-ignore + const id = element.requestVideoFrameCallback(() => {}); + expect(id).to.equal(0); + // @ts-ignore + element.cancelVideoFrameCallback(id); + }); + + it('can call addEventListener and removeEventListener', () => { + // @ts-ignore + element.addEventListener('resize', () => {}); + // @ts-ignore + element.removeEventListener('resize', () => {}); + }); }); }); diff --git a/test/videotile/DefaultVideoElementResolutionMonitor.test.ts b/test/videotile/DefaultVideoElementResolutionMonitor.test.ts index 1a40284ada..3f259dccc6 100644 --- a/test/videotile/DefaultVideoElementResolutionMonitor.test.ts +++ b/test/videotile/DefaultVideoElementResolutionMonitor.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import * as chai from 'chai'; +import * as sinon from 'sinon'; import NoOpVideoElementFactory from '../../src/videoelementfactory/NoOpVideoElementFactory'; import DefaultVideoElementResolutionMonitor from '../../src/videotile/DefaultVideoElementResolutionMonitor'; @@ -17,10 +18,74 @@ describe('DefaultVideoElementResolutionMonitor', () => { let resizeCallback: (entries: ResizeObserverEntry[]) => void; let observeCalled = false; let unobserveCalled = false; + let clock: sinon.SinonFakeTimers; + + // Track requestVideoFrameCallback registrations + let videoFrameCallbacks: Map void>; + let videoFrameCallbackNextId: number; + + interface MockVideoElement extends HTMLVideoElement { + _listeners?: Map; + } + + function createVideoElement(): MockVideoElement { + const factory = new NoOpVideoElementFactory(); + const el = factory.create() as MockVideoElement; + videoFrameCallbacks = new Map(); + videoFrameCallbackNextId = 1; + // Override with working requestVideoFrameCallback + el.requestVideoFrameCallback = ( + cb: (now: number, metadata: VideoFrameCallbackMetadata) => void + ): number => { + const id = videoFrameCallbackNextId++; + videoFrameCallbacks.set(id, cb as (now: number, metadata: object) => void); + return id; + }; + el.cancelVideoFrameCallback = (id: number): void => { + videoFrameCallbacks.delete(id); + }; + el.id = 'test-video'; + return el; + } + + function createVideoElementWithoutVideoFrameCallback(): MockVideoElement { + const factory = new NoOpVideoElementFactory(); + const el = factory.create() as MockVideoElement; + delete (el as { requestVideoFrameCallback?: unknown }).requestVideoFrameCallback; + delete (el as { cancelVideoFrameCallback?: unknown }).cancelVideoFrameCallback; + el.id = 'test-video-no-vfc'; + // Track resize event listeners + const listeners: Map = new Map(); + el.addEventListener = (event: string, cb: unknown): void => { + if (!listeners.has(event)) listeners.set(event, []); + listeners.get(event).push(cb as Function); + }; + el.removeEventListener = (event: string, cb: unknown): void => { + const cbs = listeners.get(event); + if (cbs) { + const idx = cbs.indexOf(cb as Function); + if (idx >= 0) cbs.splice(idx, 1); + } + }; + el._listeners = listeners; + return el; + } + + // Fire all pending video frame callbacks + function fireVideoFrameCallbacks(): void { + const cbs = new Map(videoFrameCallbacks); + videoFrameCallbacks.clear(); + for (const [, cb] of cbs) { + cb(performance.now(), { captureTime: 100 }); + } + } beforeEach(() => { behavior = new DOMMockBehavior(); domMockBuilder = new DOMMockBuilder(behavior); + clock = sinon.useFakeTimers({ now: 1000 }); + observeCalled = false; + unobserveCalled = false; global.ResizeObserver = class MockResizeObserver { constructor(callback: (entries: ResizeObserverEntry[]) => void) { @@ -38,6 +103,7 @@ describe('DefaultVideoElementResolutionMonitor', () => { afterEach(() => { domMockBuilder.cleanup(); + clock.restore(); }); describe('constructor', () => { @@ -66,39 +132,33 @@ describe('DefaultVideoElementResolutionMonitor', () => { it('should register and remove an observer', () => { monitor.registerObserver(mockObserver); - - // Simulate resize event - resizeCallback([ - { - contentRect: { width: 1280, height: 720 }, - } as ResizeObserverEntry, - ]); - + resizeCallback([{ contentRect: { width: 1280, height: 720 } } as ResizeObserverEntry]); expect(width).to.equal(1280); expect(height).to.equal(720); - monitor.removeObserver(mockObserver); }); it('should bind and unbind video elements', () => { - const videoElementFactory = new NoOpVideoElementFactory(); - const videoElement = videoElementFactory.create(); + const videoElement = createVideoElement(); expect(() => monitor.bindVideoElement(videoElement)).to.not.throw(); expect(observeCalled).to.be.true; expect(unobserveCalled).to.be.false; observeCalled = false; unobserveCalled = false; + // Same element — no-op expect(() => monitor.bindVideoElement(videoElement)).to.not.throw(); expect(observeCalled).to.be.false; expect(unobserveCalled).to.be.false; observeCalled = false; unobserveCalled = false; - const newVideoElement = videoElementFactory.create(); + // Different element + const newVideoElement = createVideoElement(); expect(() => monitor.bindVideoElement(newVideoElement)).to.not.throw(); expect(observeCalled).to.be.true; expect(unobserveCalled).to.be.true; observeCalled = false; unobserveCalled = false; + // Null expect(() => monitor.bindVideoElement(null)).to.not.throw(); expect(observeCalled).to.be.false; expect(unobserveCalled).to.be.true; @@ -110,4 +170,206 @@ describe('DefaultVideoElementResolutionMonitor', () => { expect(unobserveCalled).to.be.false; }); }); + + describe('first frame detection with requestVideoFrameCallback', () => { + it('fires videoElementFirstFrameDidRender on first frame', () => { + monitor = new DefaultVideoElementResolutionMonitor(); + const el = createVideoElement(); + let renderedMetadata: object | undefined; + monitor.registerObserver({ + videoElementResolutionChanged: () => {}, + videoElementFirstFrameDidRender: (metadata?: object) => { + renderedMetadata = metadata; + }, + }); + monitor.bindVideoElement(el); + expect(videoFrameCallbacks.size).to.equal(1); + fireVideoFrameCallbacks(); + expect(renderedMetadata).to.not.be.undefined; + }); + + it('does not fire first frame twice', () => { + monitor = new DefaultVideoElementResolutionMonitor(); + const el = createVideoElement(); + let callCount = 0; + monitor.registerObserver({ + videoElementResolutionChanged: () => {}, + videoElementFirstFrameDidRender: () => { + callCount++; + }, + }); + monitor.bindVideoElement(el); + fireVideoFrameCallbacks(); // first frame + // Metrics tracking registers another callback + fireVideoFrameCallbacks(); // second frame — should not fire first frame again + expect(callCount).to.equal(1); + }); + + it('stops first frame detection when element is unbound', () => { + monitor = new DefaultVideoElementResolutionMonitor(); + const el = createVideoElement(); + let callCount = 0; + monitor.registerObserver({ + videoElementResolutionChanged: () => {}, + videoElementFirstFrameDidRender: () => { + callCount++; + }, + }); + monitor.bindVideoElement(el); + monitor.bindVideoElement(null); // unbind before callback fires + fireVideoFrameCallbacks(); // should not fire + expect(callCount).to.equal(0); + }); + + it('resets first frame detection on rebind', () => { + monitor = new DefaultVideoElementResolutionMonitor(); + const el = createVideoElement(); + let callCount = 0; + monitor.registerObserver({ + videoElementResolutionChanged: () => {}, + videoElementFirstFrameDidRender: () => { + callCount++; + }, + }); + monitor.bindVideoElement(el); + fireVideoFrameCallbacks(); + expect(callCount).to.equal(1); + // Rebind to new element + const el2 = createVideoElement(); + monitor.bindVideoElement(el2); + fireVideoFrameCallbacks(); + expect(callCount).to.equal(2); + }); + }); + + describe('first frame detection with resize fallback', () => { + it('fires on resize when videoWidth > 0', () => { + monitor = new DefaultVideoElementResolutionMonitor(); + const el = createVideoElementWithoutVideoFrameCallback(); + let fired = false; + monitor.registerObserver({ + videoElementResolutionChanged: () => {}, + videoElementFirstFrameDidRender: () => { + fired = true; + }, + }); + monitor.bindVideoElement(el); + const resizeListeners = el._listeners.get('resize') || []; + expect(resizeListeners.length).to.be.greaterThan(0); + resizeListeners[0](); + expect(fired).to.be.true; + }); + + it('does not fire resize if videoWidth is 0', () => { + monitor = new DefaultVideoElementResolutionMonitor(); + const el = createVideoElementWithoutVideoFrameCallback(); + Object.defineProperty(el, 'videoWidth', { value: 0, writable: true }); + Object.defineProperty(el, 'videoHeight', { value: 0, writable: true }); + let fired = false; + monitor.registerObserver({ + videoElementResolutionChanged: () => {}, + videoElementFirstFrameDidRender: () => { + fired = true; + }, + }); + monitor.bindVideoElement(el); + const resizeListeners = el._listeners.get('resize') || []; + resizeListeners[0](); + expect(fired).to.be.false; + }); + + it('stops resize listener on unbind', () => { + monitor = new DefaultVideoElementResolutionMonitor(); + const el = createVideoElementWithoutVideoFrameCallback(); + monitor.registerObserver({ + videoElementResolutionChanged: () => {}, + }); + monitor.bindVideoElement(el); + monitor.bindVideoElement(null); + // Listener should have been removed + }); + + it('does not crash when observer lacks optional frame callbacks', async () => { + monitor = new DefaultVideoElementResolutionMonitor(); + const el = createVideoElement(); + monitor.registerObserver({ + videoElementResolutionChanged: () => {}, + }); + monitor.bindVideoElement(el); + // Fire first frame — observer has no videoElementFirstFrameDidRender + fireVideoFrameCallbacks(); + // Fire metrics — observer has no videoElementMetricsDidReceive + for (let i = 0; i < 15; i++) { + clock.tick(67); + fireVideoFrameCallbacks(); + } + // Should not throw + }); + }); + + describe('metrics tracking', () => { + it('reports metrics after 1 second of frames', () => { + monitor = new DefaultVideoElementResolutionMonitor(); + const el = createVideoElement(); + let reportedFps: number | undefined; + monitor.registerObserver({ + videoElementResolutionChanged: () => {}, + videoElementFirstFrameDidRender: () => {}, + videoElementMetricsDidReceive: metrics => { + reportedFps = metrics.fps; + }, + }); + monitor.bindVideoElement(el); + // Fire first frame + fireVideoFrameCallbacks(); + // Now metrics tracking is active — simulate frames over 1 second + for (let i = 0; i < 15; i++) { + clock.tick(67); // ~15fps + fireVideoFrameCallbacks(); + } + expect(reportedFps).to.not.be.undefined; + expect(reportedFps).to.be.greaterThan(0); + }); + + it('stops metrics tracking on unbind', () => { + monitor = new DefaultVideoElementResolutionMonitor(); + const el = createVideoElement(); + let metricsCount = 0; + monitor.registerObserver({ + videoElementResolutionChanged: () => {}, + videoElementFirstFrameDidRender: () => {}, + videoElementMetricsDidReceive: () => { + metricsCount++; + }, + }); + monitor.bindVideoElement(el); + fireVideoFrameCallbacks(); // first frame + monitor.bindVideoElement(null); // unbind stops metrics tracking + // No more callbacks should fire + for (let i = 0; i < 30; i++) { + clock.tick(67); + fireVideoFrameCallbacks(); + } + expect(metricsCount).to.equal(0); + }); + + it('does not start metrics tracking without requestVideoFrameCallback', () => { + monitor = new DefaultVideoElementResolutionMonitor(); + const el = createVideoElementWithoutVideoFrameCallback(); + let metricsCount = 0; + monitor.registerObserver({ + videoElementResolutionChanged: () => {}, + videoElementFirstFrameDidRender: () => {}, + videoElementMetricsDidReceive: () => { + metricsCount++; + }, + }); + monitor.bindVideoElement(el); + // Trigger first frame via resize + const resizeListeners = el._listeners.get('resize') || []; + resizeListeners[0]?.(); + clock.tick(2000); + expect(metricsCount).to.equal(0); + }); + }); }); diff --git a/test/videotile/VideoElementFrameMonitor.test.ts b/test/videotile/VideoElementFrameMonitor.test.ts new file mode 100644 index 0000000000..7ed0d67dcb --- /dev/null +++ b/test/videotile/VideoElementFrameMonitor.test.ts @@ -0,0 +1,304 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as chai from 'chai'; +import * as sinon from 'sinon'; + +import VideoElementFrameMonitor from '../../src/videotile/VideoElementFrameMonitor'; +import DOMMockBehavior from '../dommock/DOMMockBehavior'; +import DOMMockBuilder from '../dommock/DOMMockBuilder'; + +describe('VideoElementFrameMonitor', () => { + const expect: Chai.ExpectStatic = chai.expect; + let domMockBuilder: DOMMockBuilder; + let clock: sinon.SinonFakeTimers; + let monitor: VideoElementFrameMonitor; + + // Video frame callback mock helpers + let videoFrameCallbacks: Map void>; + let videoFrameCallbackNextId: number; + + interface MockVideoElement { + id: string; + videoWidth: number; + videoHeight: number; + requestVideoFrameCallback?: (cb: (now: number, metadata: object) => void) => number; + cancelVideoFrameCallback?: (id: number) => void; + addEventListener: (event: string, cb: Function) => void; + removeEventListener: (event: string, cb: Function) => void; + _listeners?: Map; + } + + function createVideoElement(): HTMLVideoElement { + videoFrameCallbacks = new Map(); + videoFrameCallbackNextId = 1; + const el: MockVideoElement = { + id: 'test-video', + videoWidth: 400, + videoHeight: 300, + requestVideoFrameCallback: (cb: (now: number, metadata: object) => void): number => { + const id = videoFrameCallbackNextId++; + videoFrameCallbacks.set(id, cb); + return id; + }, + cancelVideoFrameCallback: (id: number): void => { + videoFrameCallbacks.delete(id); + }, + addEventListener: (): void => {}, + removeEventListener: (): void => {}, + }; + return el as unknown as HTMLVideoElement; + } + + function createVideoElementWithoutVideoFrameCallback(): HTMLVideoElement & { + _listeners: Map; + } { + const listeners: Map = new Map(); + const el: MockVideoElement = { + id: 'test-video-no-vfc', + videoWidth: 400, + videoHeight: 300, + addEventListener: (event: string, cb: Function): void => { + if (!listeners.has(event)) listeners.set(event, []); + listeners.get(event).push(cb); + }, + removeEventListener: (event: string, cb: Function): void => { + const cbs = listeners.get(event); + if (cbs) { + const idx = cbs.indexOf(cb); + if (idx >= 0) cbs.splice(idx, 1); + } + }, + _listeners: listeners, + }; + return el as unknown as HTMLVideoElement & { _listeners: Map }; + } + + function fireVideoFrameCallbacks(): void { + const cbs = new Map(videoFrameCallbacks); + videoFrameCallbacks.clear(); + for (const [, cb] of cbs) { + cb(performance.now(), { expectedDisplayTime: 123.456 }); + } + } + + beforeEach(() => { + domMockBuilder = new DOMMockBuilder(new DOMMockBehavior()); + clock = sinon.useFakeTimers({ now: 1000 }); + monitor = new VideoElementFrameMonitor(); + }); + + afterEach(() => { + monitor.stop(); + domMockBuilder.cleanup(); + clock.restore(); + }); + + describe('first frame detection with requestVideoFrameCallback', () => { + it('fires firstVideoElementFrameDidRender with metadata', () => { + const el = createVideoElement(); + let receivedMetadata: object | undefined; + monitor.start(el, { + firstVideoElementFrameDidRender: metadata => { + receivedMetadata = metadata; + }, + }); + expect(videoFrameCallbacks.size).to.equal(1); + fireVideoFrameCallbacks(); + expect(receivedMetadata).to.not.be.undefined; + expect((receivedMetadata as { expectedDisplayTime: number }).expectedDisplayTime).to.equal( + 123.456 + ); + }); + + it('does not fire first frame twice', () => { + const el = createVideoElement(); + let callCount = 0; + monitor.start(el, { + firstVideoElementFrameDidRender: () => { + callCount++; + }, + }); + fireVideoFrameCallbacks(); + fireVideoFrameCallbacks(); // metrics callback, not first frame + expect(callCount).to.equal(1); + }); + + it('cancels video frame callback on stop before callback fires', () => { + const el = createVideoElement(); + let callCount = 0; + monitor.start(el, { + firstVideoElementFrameDidRender: () => { + callCount++; + }, + }); + monitor.stop(); + fireVideoFrameCallbacks(); + expect(callCount).to.equal(0); + }); + }); + + describe('first frame detection with resize fallback', () => { + it('fires on resize when videoWidth > 0', () => { + const el = createVideoElementWithoutVideoFrameCallback(); + let fired = false; + monitor.start(el, { + firstVideoElementFrameDidRender: () => { + fired = true; + }, + }); + const resizeListeners = + (el as unknown as { _listeners: Map })._listeners.get('resize') || []; + expect(resizeListeners.length).to.be.greaterThan(0); + resizeListeners[0](); + expect(fired).to.be.true; + }); + + it('does not fire if videoWidth is 0', () => { + const el = createVideoElementWithoutVideoFrameCallback(); + Object.defineProperty(el, 'videoWidth', { value: 0, writable: true }); + Object.defineProperty(el, 'videoHeight', { value: 0, writable: true }); + let fired = false; + monitor.start(el, { + firstVideoElementFrameDidRender: () => { + fired = true; + }, + }); + const resizeListeners = el._listeners.get('resize') || []; + resizeListeners[0](); + expect(fired).to.be.false; + }); + + it('removes resize listener on stop', () => { + const el = createVideoElementWithoutVideoFrameCallback(); + monitor.start(el, {}); + const before = (el._listeners.get('resize') || []).length; + monitor.stop(); + const after = (el._listeners.get('resize') || []).length; + expect(after).to.be.lessThan(before); + }); + + it('does not start metrics tracking without requestVideoFrameCallback', () => { + const el = createVideoElementWithoutVideoFrameCallback(); + let metricsCount = 0; + monitor.start(el, { + firstVideoElementFrameDidRender: () => {}, + videoElementFrameMetricsDidReceive: () => { + metricsCount++; + }, + }); + const resizeListeners = el._listeners.get('resize') || []; + resizeListeners[0](); + clock.tick(2000); + expect(metricsCount).to.equal(0); + }); + }); + + describe('metrics tracking', () => { + it('reports metrics after 1 second of frames', () => { + const el = createVideoElement(); + let reportedFps: number | undefined; + monitor.start(el, { + firstVideoElementFrameDidRender: () => {}, + videoElementFrameMetricsDidReceive: metrics => { + reportedFps = metrics.fps; + }, + }); + fireVideoFrameCallbacks(); // first frame + for (let i = 0; i < 15; i++) { + clock.tick(67); + fireVideoFrameCallbacks(); + } + expect(reportedFps).to.not.be.undefined; + expect(reportedFps).to.be.greaterThan(0); + }); + + it('stops metrics tracking on stop', () => { + const el = createVideoElement(); + let metricsCount = 0; + monitor.start(el, { + firstVideoElementFrameDidRender: () => {}, + videoElementFrameMetricsDidReceive: () => { + metricsCount++; + }, + }); + fireVideoFrameCallbacks(); // first frame + monitor.stop(); + for (let i = 0; i < 30; i++) { + clock.tick(67); + fireVideoFrameCallbacks(); + } + expect(metricsCount).to.equal(0); + }); + }); + + describe('start/stop lifecycle', () => { + it('stop is safe when not started', () => { + monitor.stop(); // should not throw + }); + + it('start replaces previous monitoring', () => { + const el1 = createVideoElement(); + let count1 = 0; + monitor.start(el1, { + firstVideoElementFrameDidRender: () => { + count1++; + }, + }); + const el2 = createVideoElement(); + let count2 = 0; + monitor.start(el2, { + firstVideoElementFrameDidRender: () => { + count2++; + }, + }); + fireVideoFrameCallbacks(); + expect(count1).to.equal(0); + expect(count2).to.equal(1); + }); + + it('does not crash when observer has no optional methods', () => { + const el = createVideoElement(); + monitor.start(el, {}); + fireVideoFrameCallbacks(); // first frame — no firstVideoElementFrameDidRender + for (let i = 0; i < 15; i++) { + clock.tick(67); + fireVideoFrameCallbacks(); // metrics — no videoElementFrameMetricsDidReceive + } + // Should not throw + }); + + it('onFirstFrame guard prevents double firing', () => { + const el = createVideoElement(); + let count = 0; + monitor.start(el, { + firstVideoElementFrameDidRender: () => { + count++; + }, + }); + fireVideoFrameCallbacks(); // triggers onFirstFrame + // @ts-ignore: access private method to test guard + monitor.onFirstFrame(); // should be no-op + expect(count).to.equal(1); + }); + + it('scheduleMetricsCallback is no-op after stop', () => { + const el = createVideoElement(); + monitor.start(el, {}); + fireVideoFrameCallbacks(); // first frame, starts metrics + monitor.stop(); + // @ts-ignore: access private method to test guard + monitor.scheduleMetricsCallback(); // element is null, should be no-op + }); + + it('onFirstFrame is safe when observer is null', () => { + const el = createVideoElement(); + monitor.start(el, {}); + monitor.stop(); // clears observer + // @ts-ignore: access private to test null observer path + monitor.firstFrameRendered = false; + // @ts-ignore + monitor.onFirstFrame(); // observer is null, should not throw + }); + }); +}); diff --git a/test/videotilecontroller/DefaultVideoTileController.test.ts b/test/videotilecontroller/DefaultVideoTileController.test.ts index a5b0b86746..8f79127f0f 100644 --- a/test/videotilecontroller/DefaultVideoTileController.test.ts +++ b/test/videotilecontroller/DefaultVideoTileController.test.ts @@ -518,4 +518,222 @@ describe('DefaultVideoTileController', () => { it('returns null if a tile does not exist', () => { expect(tileController.captureVideoTile(0)).to.equal(null); }); + + describe('videoTileFirstFrameDidRender via DOM mock', () => { + it('fires for remote tile with groupId', async () => { + let firedGroupId: number | undefined; + const obs: VideoTileResolutionObserver = { + videoTileResolutionDidChange: () => {}, + videoTileUnbound: () => {}, + videoTileFirstFrameDidRender: (groupId: number) => { + firedGroupId = groupId; + }, + }; + tileController.registerVideoTileResolutionObserver(obs); + const tile = tileController.addVideoTile(); + tile.bindVideoStream('attendee', false, mockMediaStream, 1, 1, 1, 'ext', 5); + const videoElement = document.createElement('video'); + tileController.bindVideoElement(tile.id(), videoElement); + await clock.tickAsync(10); + expect(firedGroupId).to.equal(5); + tileController.removeVideoTileResolutionObserver(obs); + }); + + it('does not fire for tile with null groupId', async () => { + let firedGroupId: number | undefined; + const obs: VideoTileResolutionObserver = { + videoTileResolutionDidChange: () => {}, + videoTileUnbound: () => {}, + videoTileFirstFrameDidRender: (groupId: number) => { + firedGroupId = groupId; + }, + }; + tileController.registerVideoTileResolutionObserver(obs); + const tile = tileController.addVideoTile(); + tile.bindVideoStream('attendee', false, mockMediaStream, 1, 1, 1, 'ext'); + const videoElement = document.createElement('video'); + tileController.bindVideoElement(tile.id(), videoElement); + await clock.tickAsync(10); + expect(firedGroupId).to.be.undefined; + tileController.removeVideoTileResolutionObserver(obs); + }); + }); + + describe('handleVideoElementMetrics via DOM mock', () => { + it('does not fire for local tile', async () => { + let metricsCalled = false; + const obs: VideoTileResolutionObserver = { + videoTileResolutionDidChange: () => {}, + videoTileUnbound: () => {}, + videoTileRenderMetricsDidReceive: () => { + metricsCalled = true; + }, + }; + tileController.registerVideoTileResolutionObserver(obs); + tileController.startLocalVideoTile(); + const tile = tileController.getLocalVideoTile(); + tile.bindVideoStream('attendee', true, mockMediaStream, 1, 1, 1, 'ext'); + const videoElement = document.createElement('video'); + tileController.bindVideoElement(tile.id(), videoElement); + // Tick enough for first frame + FPS window + await clock.tickAsync(1100); + expect(metricsCalled).to.be.false; + tileController.removeVideoTileResolutionObserver(obs); + }); + + it('fires for remote tile', async () => { + let firedGroupId: number | undefined; + const obs: VideoTileResolutionObserver = { + videoTileResolutionDidChange: () => {}, + videoTileUnbound: () => {}, + videoTileRenderMetricsDidReceive: (groupId: number) => { + firedGroupId = groupId; + }, + }; + tileController.registerVideoTileResolutionObserver(obs); + const tile = tileController.addVideoTile(); + tile.bindVideoStream('attendee', false, mockMediaStream, 1, 1, 1, 'ext', 5); + const videoElement = document.createElement('video'); + tileController.bindVideoElement(tile.id(), videoElement); + // Tick enough for first frame + FPS window + await clock.tickAsync(1100); + expect(firedGroupId).to.equal(5); + tileController.removeVideoTileResolutionObserver(obs); + }); + }); + + describe('videoTileBound callback', () => { + it('fires videoTileBound when binding a remote tile', () => { + let boundGroupId: number | undefined; + const boundObserver: VideoTileResolutionObserver = { + videoTileResolutionDidChange: () => {}, + videoTileUnbound: () => {}, + videoTileBound: (groupId: number) => { + boundGroupId = groupId; + }, + }; + tileController.registerVideoTileResolutionObserver(boundObserver); + const tile = tileController.addVideoTile(); + tile.bindVideoStream('attendee', false, mockMediaStream, 1, 1, 1, 'ext', 5); + const videoElement = videoElementFactory.create(); + tileController.bindVideoElement(tile.id(), videoElement); + expect(boundGroupId).to.equal(5); + tileController.removeVideoTileResolutionObserver(boundObserver); + }); + + it('does not fire videoTileBound for local tile', () => { + let boundCalled = false; + const boundObserver: VideoTileResolutionObserver = { + videoTileResolutionDidChange: () => {}, + videoTileUnbound: () => {}, + videoTileBound: () => { + boundCalled = true; + }, + }; + tileController.registerVideoTileResolutionObserver(boundObserver); + tileController.startLocalVideoTile(); + const tile = tileController.getLocalVideoTile(); + tileController.bindVideoElement(tile.id(), videoElementFactory.create()); + expect(boundCalled).to.be.false; + tileController.removeVideoTileResolutionObserver(boundObserver); + }); + + it('does not fire videoTileBound when groupId is null', () => { + let boundCalled = false; + const boundObserver: VideoTileResolutionObserver = { + videoTileResolutionDidChange: () => {}, + videoTileUnbound: () => {}, + videoTileBound: () => { + boundCalled = true; + }, + }; + tileController.registerVideoTileResolutionObserver(boundObserver); + const tile = tileController.addVideoTile(); + // Don't set groupId (it defaults to null) + tile.bindVideoStream('attendee', false, mockMediaStream, 1, 1, 1, 'ext'); + tileController.bindVideoElement(tile.id(), videoElementFactory.create()); + expect(boundCalled).to.be.false; + tileController.removeVideoTileResolutionObserver(boundObserver); + }); + + it('does not fire videoTileBound when videoElement is null', () => { + let boundCalled = false; + const boundObserver: VideoTileResolutionObserver = { + videoTileResolutionDidChange: () => {}, + videoTileUnbound: () => {}, + videoTileBound: () => { + boundCalled = true; + }, + }; + tileController.registerVideoTileResolutionObserver(boundObserver); + const tile = tileController.addVideoTile(); + tile.bindVideoStream('attendee', false, mockMediaStream, 1, 1, 1, 'ext', 5); + tileController.bindVideoElement(tile.id(), null); + expect(boundCalled).to.be.false; + tileController.removeVideoTileResolutionObserver(boundObserver); + }); + }); + + describe('handleVideoFirstFrameDidRender edge cases', () => { + it('does not fire when tile is removed before callback', async () => { + let firedGroupId: number | undefined; + const obs: VideoTileResolutionObserver = { + videoTileResolutionDidChange: () => {}, + videoTileUnbound: () => {}, + videoTileFirstFrameDidRender: (groupId: number) => { + firedGroupId = groupId; + }, + }; + tileController.registerVideoTileResolutionObserver(obs); + const tile = tileController.addVideoTile(); + tile.bindVideoStream('attendee', false, mockMediaStream, 1, 1, 1, 'ext', 5); + const videoElement = document.createElement('video'); + tileController.bindVideoElement(tile.id(), videoElement); + tileController.removeVideoTile(tile.id()); + await clock.tickAsync(10); + expect(firedGroupId).to.be.undefined; + tileController.removeVideoTileResolutionObserver(obs); + }); + }); + + describe('handleVideoElementFrameMetrics edge cases', () => { + it('does not fire metrics when tile is removed before callback', async () => { + let metricsCalled = false; + const obs: VideoTileResolutionObserver = { + videoTileResolutionDidChange: () => {}, + videoTileUnbound: () => {}, + videoTileRenderMetricsDidReceive: () => { + metricsCalled = true; + }, + }; + tileController.registerVideoTileResolutionObserver(obs); + const tile = tileController.addVideoTile(); + tile.bindVideoStream('attendee', false, mockMediaStream, 1, 1, 1, 'ext', 5); + const videoElement = document.createElement('video'); + tileController.bindVideoElement(tile.id(), videoElement); + tileController.removeVideoTile(tile.id()); + await clock.tickAsync(1100); + expect(metricsCalled).to.be.false; + tileController.removeVideoTileResolutionObserver(obs); + }); + + it('does not fire metrics for tile with null groupId', async () => { + let metricsCalled = false; + const obs: VideoTileResolutionObserver = { + videoTileResolutionDidChange: () => {}, + videoTileUnbound: () => {}, + videoTileRenderMetricsDidReceive: () => { + metricsCalled = true; + }, + }; + tileController.registerVideoTileResolutionObserver(obs); + const tile = tileController.addVideoTile(); + tile.bindVideoStream('attendee', false, mockMediaStream, 1, 1, 1, 'ext'); + const videoElement = document.createElement('video'); + tileController.bindVideoElement(tile.id(), videoElement); + await clock.tickAsync(1100); + expect(metricsCalled).to.be.false; + tileController.removeVideoTileResolutionObserver(obs); + }); + }); });