packages/calling-stateful-client/src/StatefulCallClient.ts (233 lines of code) (raw):

// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import { deviceManagerDeclaratify } from './DeviceManagerDeclarative'; import { CallClient, CallClientOptions, CreateViewOptions, DeviceManager, Features } from '@azure/communication-calling'; import { CallClientState, LocalVideoStreamState, RemoteVideoStreamState } from './CallClientState'; /* @conditional-compile-remove(together-mode) */ import { CallFeatureStreamState } from './CallClientState'; import { CallContext } from './CallContext'; import { callAgentDeclaratify, DeclarativeCallAgent } from './CallAgentDeclarative'; import { InternalCallContext } from './InternalCallContext'; import { createView, disposeView, CreateViewResult } from './StreamUtils'; import { CommunicationIdentifier, CommunicationUserIdentifier, getIdentifierKind } from '@azure/communication-common'; import { toFlatCommunicationIdentifier, _getApplicationId, _TelemetryImplementationHint } from '@internal/acs-ui-common'; import { callingStatefulLogger } from './Logger'; import { DeclarativeTeamsCallAgent, teamsCallAgentDeclaratify } from './TeamsCallAgentDeclarative'; import { MicrosoftTeamsUserIdentifier } from '@azure/communication-common'; import { videoStreamRendererViewDeclaratify } from './VideoStreamRendererViewDeclarative'; /* @conditional-compile-remove(together-mode) */ import { createView as createCallFeatureView, disposeView as disposeCallFeatureView } from './CallFeatureStreamUtils'; /** * Defines the methods that allow CallClient {@link @azure/communication-calling#CallClient} to be used statefully. * The interface provides access to proxied state and also allows registering a handler for state change events. For * state definition see {@link CallClientState}. * * State change events are driven by: * - Returned data from {@link @azure/communication-calling#DeviceManager} APIs. * - Returned data from {@link @azure/communication-calling#CallAgent} APIs. * - Listeners automatically attached to various azure communication-calling objects: * - CallAgent 'incomingCall' * - CallAgent 'callsUpdated' * - DeviceManager 'videoDevicesUpdated' * - DeviceManager 'audioDevicesUpdated * - DeviceManager 'selectedMicrophoneChanged' * - DeviceManager 'selectedSpeakerChanged' * - Call 'stateChanged' * - Call 'idChanged' * - Call 'isMutedChanged' * - Call 'isScreenSharingOnChanged' * - Call 'remoteParticipantsUpdated' * - Call 'localVideoStreamsUpdated' * - IncomingCall 'callEnded' * - RemoteParticipant 'stateChanged' * - RemoteParticipant 'isMutedChanged' * - RemoteParticipant 'displayNameChanged' * - RemoteParticipant 'isSpeakingChanged' * - RemoteParticipant 'videoStreamsUpdated' * - RemoteVideoStream 'isAvailableChanged' * - TranscriptionCallFeature 'isTranscriptionActiveChanged' * - RecordingCallFeature 'isRecordingActiveChanged' * - LocalRecordingCallFeature 'isLocalRecordingActiveChanged' * - RaiseHandCallFeature 'raisedHandEvent' * - RaiseHandCallFeature 'loweredHandEvent' * - PPTLiveCallFeature 'isAciveChanged' * - ReactionCallFeature 'reaction' * * @public */ export interface StatefulCallClient extends CallClient { /** * Holds all the state that we could proxy from CallClient {@link @azure/communication-calling#CallClient} as * CallClientState {@link CallClientState}. */ getState(): CallClientState; /** * Allows a handler to be registered for 'stateChanged' events. * * @param handler - Callback to receive the state. */ onStateChange(handler: (state: CallClientState) => void): void; /** * Allows unregistering for 'stateChanged' events. * * @param handler - Original callback to be unsubscribed. */ offStateChange(handler: (state: CallClientState) => void): void; /** * Renders a {@link RemoteVideoStreamState} or {@link LocalVideoStreamState} and stores the resulting * {@link VideoStreamRendererViewState} under the relevant {@link RemoteVideoStreamState} or * {@link LocalVideoStreamState} or as unparented view in the state. Under the hood calls * {@link @azure/communication-calling#VideoStreamRenderer.createView}. * * Scenario 1: Render RemoteVideoStreamState * - CallId is required, participantId is required, and stream of type RemoteVideoStreamState is required * - Resulting {@link VideoStreamRendererViewState} is stored in the given callId and participantId in * {@link CallClientState} * * Scenario 2: Render LocalVideoStreamState for a call * - CallId is required, participantId must be undefined, and stream of type LocalVideoStreamState is required. * - The {@link @azure/communication-calling#Call.localVideoStreams} must already be started using * {@link @azure/communication-calling#Call.startVideo}. * - Resulting {@link VideoStreamRendererViewState} is stored in the given callId {@link CallState.localVideoStreams} * in {@link CallClientState}. * * - Scenario 2: Render LocalVideoStreamState not part of a call (example rendering camera for local preview) * - CallId must be undefined, participantId must be undefined, and stream of type LocalVideoStreamState is required. * - Resulting {@link VideoStreamRendererViewState} is stored in under the given LocalVideoStreamState in * {@link CallClientState.deviceManager.unparentedViews} * * @param callId - CallId for the given stream. Can be undefined if the stream is not part of any call. * @param participantId - {@link RemoteParticipant.identifier} associated with the given RemoteVideoStreamState. Could * be undefined if rendering LocalVideoStreamState. * @param stream - The LocalVideoStreamState or RemoteVideoStreamState to start rendering. * @param options - Options that are passed to the {@link @azure/communication-calling#VideoStreamRenderer}. */ createView( callId: string | undefined, participantId: CommunicationIdentifier | undefined, stream: | LocalVideoStreamState | RemoteVideoStreamState | /* @conditional-compile-remove(together-mode) */ CallFeatureStreamState, options?: CreateViewOptions ): Promise<CreateViewResult | undefined>; /** * Stops rendering a {@link RemoteVideoStreamState} or {@link LocalVideoStreamState} and removes the * {@link VideoStreamRendererView} from the relevant {@link RemoteVideoStreamState} in {@link CallClientState} or * {@link LocalVideoStream} in {@link CallClientState} or appropriate * {@link CallClientState.deviceManager.unparentedViews} Under the hood calls * {@link @azure/communication-calling#VideoStreamRenderer.dispose}. * * Its important to disposeView to clean up resources properly. * * Scenario 1: Dispose RemoteVideoStreamState * - CallId is required, participantId is required, and stream of type RemoteVideoStreamState is required * * Scenario 2: Dispose LocalVideoStreamState for a call * - CallId is required, participantId must be undefined, and stream of type LocalVideoStreamState is required. * * - Scenario 2: Dispose LocalVideoStreamState not part of a call * - CallId must be undefined, participantId must be undefined, and stream of type LocalVideoStreamState is required. * - LocalVideoStreamState must be the original one passed to createView. * * @param callId - CallId for the given stream. Can be undefined if the stream is not part of any call. * @param participantId - {@link RemoteParticipant.identifier} associated with the given RemoteVideoStreamState. Could * be undefined if disposing LocalVideoStreamState. * @param stream - The LocalVideoStreamState or RemoteVideoStreamState to dispose. */ disposeView( callId: string | undefined, participantId: CommunicationIdentifier | undefined, stream: | LocalVideoStreamState | RemoteVideoStreamState | /* @conditional-compile-remove(together-mode) */ CallFeatureStreamState ): void; /** * The CallAgent is used to handle calls. * To create the CallAgent, pass a CommunicationTokenCredential object provided from SDK. * - The CallClient can only have one active CallAgent instance at a time. * - You can create a new CallClient instance to create a new CallAgent. * - You can dispose of a CallClient's current active CallAgent, and call the CallClient's * createCallAgent() method again to create a new CallAgent. * @param tokenCredential - The token credential. Use AzureCommunicationTokenCredential from `@azure/communication-common` to create a credential. * @param options - The CallAgentOptions for additional options like display name. * @public */ createCallAgent(...args: Parameters<CallClient['createCallAgent']>): Promise<DeclarativeCallAgent>; /** * The TeamsCallAgent is used to handle calls. * To create the TeamsCallAgent, pass a CommunicationTokenCredential object provided from SDK. * - The CallClient can only have one active TeamsCallAgent instance at a time. * - You can create a new CallClient instance to create a new TeamsCallAgent. * - You can dispose of a CallClient's current active TeamsCallAgent, and call the CallClient's * createTeamsCallAgent() method again to create a new TeamsCallAgent. * @param tokenCredential - The token credential. Use AzureCommunicationTokenCredential from `@azure/communication-common` to create a credential. * @param options - The TeamsCallAgentOptions for additional options like display name. * @public */ createTeamsCallAgent(...args: Parameters<CallClient['createTeamsCallAgent']>): Promise<DeclarativeTeamsCallAgent>; } /** * A function to modify the state of the StatefulCallClient. * * Provided as a callback to the {@link StatefulCallClient.modifyState} method. * * The function must modify the provided state in place as much as possible. * Making large modifications can lead to bad performance by causing spurious rerendering of the UI. * * Consider using commonly used modifier functions exported from this package. */ export type CallStateModifier = (state: CallClientState) => void; /** * ProxyCallClient proxies CallClient {@link @azure/communication-calling#CallClient} and subscribes to all events that * affect state. ProxyCallClient keeps its own copy of the call state and when state is updated, ProxyCallClient emits * the event 'stateChanged'. */ class ProxyCallClient implements ProxyHandler<CallClient> { private _context: CallContext; private _internalContext: InternalCallContext; private _callAgent: DeclarativeCallAgent | DeclarativeTeamsCallAgent | undefined; private _deviceManager: DeviceManager | undefined; private _sdkDeviceManager: DeviceManager | undefined; constructor(context: CallContext, internalContext: InternalCallContext) { this._context = context; this._internalContext = internalContext; } public get<P extends keyof CallClient>(target: CallClient, prop: P): any { switch (prop) { case 'createCallAgent': { return this._context.withAsyncErrorTeedToState( async (...args: Parameters<CallClient['createCallAgent']>): Promise<DeclarativeCallAgent> => { // createCallAgent will throw an exception if the previous callAgent was not disposed. If the previous // callAgent was disposed then it would have unsubscribed to events so we can just create a new declarative // callAgent if the createCallAgent succeeds. const callAgent = await target.createCallAgent(...args); this._callAgent = callAgentDeclaratify(callAgent, this._context, this._internalContext); this._context.setCallAgent({ displayName: this._callAgent.displayName }); return this._callAgent; }, 'CallClient.createCallAgent' ); } case 'createTeamsCallAgent': { return this._context.withAsyncErrorTeedToState( async (...args: Parameters<CallClient['createTeamsCallAgent']>): Promise<DeclarativeTeamsCallAgent> => { // createCallAgent will throw an exception if the previous callAgent was not disposed. If the previous // callAgent was disposed then it would have unsubscribed to events so we can just create a new declarative // callAgent if the createCallAgent succeeds. const callAgent = await target.createTeamsCallAgent(...args); this._callAgent = teamsCallAgentDeclaratify(callAgent, this._context, this._internalContext); this._context.setCallAgent({ displayName: undefined }); return this._callAgent; }, 'CallClient.createTeamsCallAgent' ); } case 'getDeviceManager': { return this._context.withAsyncErrorTeedToState(async () => { // As of writing, the SDK always returns the same instance of DeviceManager so we keep a reference of // DeviceManager and if it does not change we return the cached DeclarativeDeviceManager. If it does not we'll // throw an error that indicate we need to fix this issue as our implementation has diverged from the SDK. const deviceManager = await target.getDeviceManager(); if (this._sdkDeviceManager) { if (this._sdkDeviceManager === deviceManager) { return this._deviceManager; } else { throw new Error( 'Multiple DeviceManager not supported. This means a incompatible version of communication-calling is ' + 'used OR calling declarative was not properly updated to communication-calling version.' ); } } else { this._sdkDeviceManager = deviceManager; } this._deviceManager = deviceManagerDeclaratify(deviceManager, this._context, this._internalContext); return this._deviceManager; }, 'CallClient.getDeviceManager'); } case 'feature': { return this._context.withErrorTeedToState((...args: Parameters<CallClient['feature']>) => { if (args[0] === Features.DebugInfo) { const feature = target.feature(Features.DebugInfo); /** * add to this object if we want to proxy anything else off the DebugInfo feature object. */ return { ...feature, getEnvironmentInfo: async () => { const environmentInfo = await feature.getEnvironmentInfo(); this._context.setEnvironmentInfo(environmentInfo); return environmentInfo; } }; } return Reflect.get(target, prop); }, 'CallClient.feature'); } default: return Reflect.get(target, prop); } } } /** * Arguments to construct the StatefulCallClient. * * @public */ export type StatefulCallClientArgs = { /** * UserId from SDK. This is provided for developer convenience to easily access the userId from the * state. It is not used by StatefulCallClient. */ userId: CommunicationUserIdentifier | MicrosoftTeamsUserIdentifier; }; /** * Options to construct the StatefulCallClient with. * * @public */ export type StatefulCallClientOptions = { /** * Options to construct the {@link @axure/communication-calling#CallClient} with. */ callClientOptions: CallClientOptions; /** * Sets the max listeners limit of the 'stateChange' event. Defaults to the node.js EventEmitter.defaultMaxListeners * if not specified. */ maxStateChangeListeners?: number; }; /** * Creates a StatefulCallClient {@link StatefulCallClient} by proxying CallClient * {@link @azure/communication-calling#CallClient} with ProxyCallClient {@link ProxyCallClient} which then allows access * to state in a declarative way. * * It is important to use the {@link @azure/communication-calling#DeviceManager} and * {@link @azure/communication-calling#CallAgent} and {@link @azure/communication-calling#Call} (and etc.) that are * obtained from the StatefulCallClient in order for their state changes to be proxied properly. * * @param args - {@link StatefulCallClientArgs} * @param options - {@link StatefulCallClientOptions} * * @public */ export const createStatefulCallClient = ( args: StatefulCallClientArgs, options?: StatefulCallClientOptions ): StatefulCallClient => { return _createStatefulCallClientInner(args, options); }; /** * This inner function is used to allow injection of TelemetryImplementationHint without changing the public API. * * @internal */ export const _createStatefulCallClientInner = ( args: StatefulCallClientArgs, options?: StatefulCallClientOptions, telemetryImplementationHint: _TelemetryImplementationHint = 'StatefulComponents' ): StatefulCallClient => { callingStatefulLogger.info( `Creating calling stateful client using library version: ${_getApplicationId(telemetryImplementationHint)}` ); return createStatefulCallClientWithDeps( new CallClient(withTelemetryTag(telemetryImplementationHint, options?.callClientOptions)), new CallContext(getIdentifierKind(args.userId), options?.maxStateChangeListeners), new InternalCallContext() ); }; /** * Package-internal version of createStatefulCallClient that allows dependency injection. * * This function should not be exported from the package. */ export const createStatefulCallClientWithDeps = ( callClient: CallClient, context: CallContext, internalContext: InternalCallContext ): StatefulCallClient => { Object.defineProperty(callClient, 'getState', { configurable: false, value: () => context.getState() }); Object.defineProperty(callClient, 'onStateChange', { configurable: false, value: (handler: (state: CallClientState) => void) => context.onStateChange(handler) }); Object.defineProperty(callClient, 'offStateChange', { configurable: false, value: (handler: (state: CallClientState) => void) => context.offStateChange(handler) }); Object.defineProperty(callClient, 'createView', { configurable: false, value: async ( callId: string | undefined, participantId: CommunicationIdentifier | undefined, stream: | LocalVideoStreamState | RemoteVideoStreamState | /* @conditional-compile-remove(together-mode) */ CallFeatureStreamState, options?: CreateViewOptions ): Promise<CreateViewResult | undefined> => { /* @conditional-compile-remove(together-mode) */ if ('feature' in stream) { return await createCallFeatureView(context, internalContext, callId, stream, options); } const participantIdKind = participantId ? getIdentifierKind(participantId) : undefined; const result = await createView(context, internalContext, callId, participantIdKind, stream, options); // We only need to declaratify the VideoStreamRendererView object for remote participants. Because the updateScalingMode only needs to be called on remote participant stream views. if ('id' in stream && callId && participantId && result) { const participantKey = toFlatCommunicationIdentifier(participantId); result.view = videoStreamRendererViewDeclaratify(result.view, context, callId, participantKey, stream.id); } return result; } }); Object.defineProperty(callClient, 'disposeView', { configurable: false, value: ( callId: string | undefined, participantId: CommunicationIdentifier | undefined, stream: | LocalVideoStreamState | RemoteVideoStreamState | /* @conditional-compile-remove(together-mode) */ CallFeatureStreamState ): void => { /* @conditional-compile-remove(together-mode) */ if ('feature' in stream) { disposeCallFeatureView(context, internalContext, callId, stream); } const participantIdKind = participantId ? getIdentifierKind(participantId) : undefined; disposeView(context, internalContext, callId, participantIdKind, stream); } }); const newStatefulCallClient = new Proxy( callClient, new ProxyCallClient(context, internalContext) ) as StatefulCallClient; // Populate initial state newStatefulCallClient.feature(Features.DebugInfo).getEnvironmentInfo(); return newStatefulCallClient; }; const withTelemetryTag = ( telemetryImplementationHint: _TelemetryImplementationHint, options?: CallClientOptions ): CallClientOptions => { const tags = options?.diagnostics?.tags ?? []; tags.push(_getApplicationId(telemetryImplementationHint)); return { ...options, diagnostics: { ...options?.diagnostics, tags } }; };