Calling Declarative Recording Notice (#194)
* Add recording notice to Calling Declarative * Change files
This commit is contained in:
Родитель
f0f0b1ea0e
Коммит
63a35955ed
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "prerelease",
|
||||
"comment": "Add recording notice capability to Calling Declarative",
|
||||
"packageName": "@azure/acs-calling-declarative",
|
||||
"email": "allenhwang@microsoft.com",
|
||||
"dependentChangeType": "patch"
|
||||
}
|
|
@ -31,6 +31,7 @@ export interface Call {
|
|||
isMuted: boolean;
|
||||
isScreenSharingOn: boolean;
|
||||
localVideoStreams: LocalVideoStream[];
|
||||
recording: RecordingCallFeature;
|
||||
remoteParticipants: Map<string, RemoteParticipant>;
|
||||
remoteParticipantsEnded: Map<string, RemoteParticipant>;
|
||||
startTime: Date;
|
||||
|
@ -85,6 +86,11 @@ export interface LocalVideoStream {
|
|||
videoStreamRendererView: VideoStreamRendererView | undefined;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface RecordingCallFeature {
|
||||
isRecordingActive: boolean;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface RemoteParticipant {
|
||||
callEndReason?: CallEndReason;
|
||||
|
|
|
@ -95,8 +95,8 @@ class ProxyCallAgent implements ProxyHandler<CallAgent> {
|
|||
|
||||
private addCall = (call: Call): void => {
|
||||
this._callSubscribers.get(call)?.unsubscribe();
|
||||
this._callSubscribers.set(call, new CallSubscriber(call, this._context, this._internalContext));
|
||||
this._context.setCall(convertSdkCallToDeclarativeCall(call));
|
||||
this._callSubscribers.set(call, new CallSubscriber(call, this._context, this._internalContext));
|
||||
};
|
||||
|
||||
public get<P extends keyof CallAgent>(target: CallAgent, prop: P): any {
|
||||
|
|
|
@ -3,9 +3,13 @@ import {
|
|||
Call,
|
||||
CallAgent,
|
||||
CallClient,
|
||||
CallFeatureFactoryType,
|
||||
CreateViewOptions,
|
||||
LocalVideoStream,
|
||||
RecordingCallFeature,
|
||||
RemoteVideoStream,
|
||||
TranscriptionCallFeature,
|
||||
TransferCallFeature,
|
||||
VideoDeviceInfo,
|
||||
VideoStreamRendererView
|
||||
} from '@azure/communication-calling';
|
||||
|
@ -13,6 +17,7 @@ import { callClientDeclaratify, DeclarativeCallClient } from './CallClientDeclar
|
|||
import { getRemoteParticipantKey } from './Converter';
|
||||
import {
|
||||
addMockEmitter,
|
||||
createMockApiFeatures,
|
||||
createMockCall,
|
||||
createMockRemoteParticipant,
|
||||
createMockRemoteVideoStream,
|
||||
|
@ -20,7 +25,11 @@ import {
|
|||
MockCallAgent,
|
||||
MockCommunicationUserCredential,
|
||||
mockoutObjectFreeze,
|
||||
MockRemoteParticipant
|
||||
MockRecordingCallFeatureImpl,
|
||||
MockRemoteParticipant,
|
||||
MockTranscriptionCallFeatureImpl,
|
||||
MockTransferCallFeatureImpl,
|
||||
MOCK_RECORDING_NAME
|
||||
} from './TestUtils';
|
||||
|
||||
mockoutObjectFreeze();
|
||||
|
@ -40,7 +49,18 @@ jest.mock('@azure/communication-calling', () => {
|
|||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
dispose: () => {}
|
||||
};
|
||||
})
|
||||
}),
|
||||
Features: {
|
||||
get Recording(): CallFeatureFactoryType<RecordingCallFeature> {
|
||||
return MockRecordingCallFeatureImpl;
|
||||
},
|
||||
get Transfer(): CallFeatureFactoryType<TransferCallFeature> {
|
||||
return MockTransferCallFeatureImpl;
|
||||
},
|
||||
get Transcription(): CallFeatureFactoryType<TranscriptionCallFeature> {
|
||||
return MockTranscriptionCallFeatureImpl;
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -71,9 +91,17 @@ function createDeclarativeClient(testData: TestData): void {
|
|||
testData.declarativeCallClient = callClientDeclaratify(testData.mockCallClient);
|
||||
}
|
||||
|
||||
async function createMockCallAndEmitCallsUpdated(testData: TestData, waitCondition?: () => boolean): Promise<void> {
|
||||
async function createMockCallAndEmitCallsUpdated(
|
||||
testData: TestData,
|
||||
waitCondition?: () => boolean,
|
||||
mockCall?: MockCall
|
||||
): Promise<void> {
|
||||
await testData.declarativeCallClient.createCallAgent(new MockCommunicationUserCredential());
|
||||
testData.mockCall = createMockCall(mockCallId);
|
||||
if (mockCall) {
|
||||
testData.mockCall = mockCall;
|
||||
} else {
|
||||
testData.mockCall = createMockCall(mockCallId);
|
||||
}
|
||||
testData.mockCallAgent.calls = [testData.mockCall];
|
||||
testData.mockCallAgent.emit('callsUpdated', {
|
||||
added: [testData.mockCall],
|
||||
|
@ -790,4 +818,72 @@ describe('declarative call client', () => {
|
|||
testData.declarativeCallClient.state.calls.get(mockCallId)?.localVideoStreams[0]?.videoStreamRendererView
|
||||
).not.toBeDefined();
|
||||
});
|
||||
|
||||
test('should detect if call already has recording active', async () => {
|
||||
const testData = {} as TestData;
|
||||
createClientAndAgentMocks(testData);
|
||||
createDeclarativeClient(testData);
|
||||
const mockCall = createMockCall(mockCallId);
|
||||
mockCall.api = createMockApiFeatures(true, new Map<string, any>());
|
||||
await createMockCallAndEmitCallsUpdated(testData, undefined, mockCall);
|
||||
|
||||
await waitWithBreakCondition(
|
||||
() => testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive === true
|
||||
);
|
||||
|
||||
expect(testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive).toBe(true);
|
||||
});
|
||||
|
||||
test('should detect recording changes in call', async () => {
|
||||
const testData = {} as TestData;
|
||||
createClientAndAgentMocks(testData);
|
||||
createDeclarativeClient(testData);
|
||||
const mockCall = createMockCall(mockCallId);
|
||||
const featureCache = new Map<string, any>();
|
||||
mockCall.api = createMockApiFeatures(true, featureCache);
|
||||
await createMockCallAndEmitCallsUpdated(testData, undefined, mockCall);
|
||||
|
||||
await waitWithBreakCondition(
|
||||
() => testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive === true
|
||||
);
|
||||
|
||||
expect(testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive).toBe(true);
|
||||
|
||||
const recording = featureCache.get(MOCK_RECORDING_NAME);
|
||||
recording.isRecordingActive = false;
|
||||
recording.emitter.emit('isRecordingActiveChanged');
|
||||
|
||||
await waitWithBreakCondition(
|
||||
() => testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive === false
|
||||
);
|
||||
|
||||
expect(testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive).toBe(false);
|
||||
});
|
||||
|
||||
test('should unsubscribe to recording changes when call ended', async () => {
|
||||
const testData = {} as TestData;
|
||||
createClientAndAgentMocks(testData);
|
||||
createDeclarativeClient(testData);
|
||||
const mockCall = createMockCall(mockCallId);
|
||||
const featureCache = new Map<string, any>();
|
||||
mockCall.api = createMockApiFeatures(true, featureCache);
|
||||
await createMockCallAndEmitCallsUpdated(testData, undefined, mockCall);
|
||||
|
||||
await waitWithBreakCondition(
|
||||
() => testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive === true
|
||||
);
|
||||
|
||||
expect(testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive).toBe(true);
|
||||
|
||||
testData.mockCallAgent.calls = [];
|
||||
testData.mockCallAgent.emit('callsUpdated', {
|
||||
added: [],
|
||||
removed: [testData.mockCall]
|
||||
});
|
||||
|
||||
await waitWithBreakCondition(() => testData.declarativeCallClient.state.calls.size === 0);
|
||||
|
||||
const recording = featureCache.get(MOCK_RECORDING_NAME);
|
||||
expect(recording.emitter.eventNames().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -19,6 +19,16 @@ import {
|
|||
UnknownIdentifierKind
|
||||
} from '@azure/communication-common';
|
||||
|
||||
/**
|
||||
* State only version of {@Link @azure/communication-calling#RecordingCallFeature}.
|
||||
*/
|
||||
export interface RecordingCallFeature {
|
||||
/**
|
||||
* Proxy of {@Link @azure/communication-calling#RecordingCallFeature.isRecordingActive}.
|
||||
*/
|
||||
isRecordingActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* State only version of {@Link @azure/communication-calling#LocalVideoStream}.
|
||||
*/
|
||||
|
@ -162,6 +172,10 @@ export interface Call {
|
|||
* {@Link Converter.getRemoteParticipantKey} to {@Link @azure/communication-calling#RemoteParticipant}
|
||||
*/
|
||||
remoteParticipantsEnded: Map<string, RemoteParticipant>;
|
||||
/**
|
||||
* Proxy of {@Link @azure/communication-calling#RecordingCallFeature}.
|
||||
*/
|
||||
recording: RecordingCallFeature;
|
||||
/**
|
||||
* Stores the local date when the call started on the client. This is not originally in the SDK but provided by the
|
||||
* Declarative layer.
|
||||
|
|
|
@ -78,6 +78,7 @@ export class CallContext {
|
|||
existingCall.isScreenSharingOn = call.isScreenSharingOn;
|
||||
existingCall.localVideoStreams = call.localVideoStreams;
|
||||
existingCall.remoteParticipants = call.remoteParticipants;
|
||||
existingCall.recording.isRecordingActive = call.recording.isRecordingActive;
|
||||
// We don't update the startTime and endTime if we are updating an existing active call
|
||||
} else {
|
||||
draft.calls.set(call.id, call);
|
||||
|
@ -207,6 +208,17 @@ export class CallContext {
|
|||
);
|
||||
}
|
||||
|
||||
public setCallRecordingActive(callId: string, isRecordingActive: boolean): void {
|
||||
this.setState(
|
||||
produce(this._state, (draft: CallClientState) => {
|
||||
const call = draft.calls.get(callId);
|
||||
if (call) {
|
||||
call.recording.isRecordingActive = isRecordingActive;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public setLocalVideoStreamRendererView(callId: string, view: VideoStreamRendererView | undefined): void {
|
||||
this.setState(
|
||||
produce(this._state, (draft: CallClientState) => {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// © Microsoft Corporation. All rights reserved.
|
||||
|
||||
import { Call, LocalVideoStream, RemoteParticipant } from '@azure/communication-calling';
|
||||
import { Call, Features, LocalVideoStream, RemoteParticipant } from '@azure/communication-calling';
|
||||
import { CallContext } from './CallContext';
|
||||
import { CallIdRef } from './CallIdRef';
|
||||
import {
|
||||
|
@ -10,6 +10,7 @@ import {
|
|||
} from './Converter';
|
||||
import { InternalCallContext } from './InternalCallContext';
|
||||
import { ParticipantSubscriber } from './ParticipantSubscriber';
|
||||
import { RecordingSubscriber } from './RecordingSubscriber';
|
||||
import { stopRenderVideo } from './StreamUtils';
|
||||
|
||||
/**
|
||||
|
@ -23,6 +24,7 @@ export class CallSubscriber {
|
|||
private _context: CallContext;
|
||||
private _internalContext: InternalCallContext;
|
||||
private _participantSubscribers: Map<string, ParticipantSubscriber>;
|
||||
private _recordingSubscriber: RecordingSubscriber;
|
||||
|
||||
constructor(call: Call, context: CallContext, internalContext: InternalCallContext) {
|
||||
this._call = call;
|
||||
|
@ -31,6 +33,12 @@ export class CallSubscriber {
|
|||
this._internalContext = internalContext;
|
||||
this._participantSubscribers = new Map<string, ParticipantSubscriber>();
|
||||
|
||||
this._recordingSubscriber = new RecordingSubscriber(
|
||||
this._callIdRef,
|
||||
this._context,
|
||||
this._call.api(Features.Recording)
|
||||
);
|
||||
|
||||
this.subscribe();
|
||||
}
|
||||
|
||||
|
@ -76,6 +84,8 @@ export class CallSubscriber {
|
|||
if (localVideoStreams && localVideoStreams.length === 1) {
|
||||
stopRenderVideo(this._context, this._internalContext, this._callIdRef.callId, localVideoStreams[0]);
|
||||
}
|
||||
|
||||
this._recordingSubscriber.unsubscribe();
|
||||
};
|
||||
|
||||
private addParticipantListener(participant: RemoteParticipant): void {
|
||||
|
|
|
@ -110,6 +110,7 @@ export function convertSdkCallToDeclarativeCall(call: SdkCall): DeclarativeCall
|
|||
localVideoStreams: call.localVideoStreams.map(convertSdkLocalStreamToDeclarativeLocalStream),
|
||||
remoteParticipants: declarativeRemoteParticipants,
|
||||
remoteParticipantsEnded: new Map<string, DeclarativeRemoteParticipant>(),
|
||||
recording: { isRecordingActive: false },
|
||||
startTime: new Date(),
|
||||
endTime: undefined
|
||||
};
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
// © Microsoft Corporation. All rights reserved.
|
||||
|
||||
import { RecordingCallFeature } from '@azure/communication-calling';
|
||||
import { CallContext } from './CallContext';
|
||||
import { CallIdRef } from './CallIdRef';
|
||||
|
||||
export class RecordingSubscriber {
|
||||
private _callIdRef: CallIdRef;
|
||||
private _context: CallContext;
|
||||
private _recording: RecordingCallFeature;
|
||||
|
||||
constructor(callIdRef: CallIdRef, context: CallContext, recording: RecordingCallFeature) {
|
||||
this._callIdRef = callIdRef;
|
||||
this._context = context;
|
||||
this._recording = recording;
|
||||
|
||||
// If recording as already started when we joined the call, make sure it is reflected in state as there may not
|
||||
// be an event for it.
|
||||
if (this._recording.isRecordingActive) {
|
||||
this._context.setCallRecordingActive(this._callIdRef.callId, this._recording.isRecordingActive);
|
||||
}
|
||||
|
||||
this.subscribe();
|
||||
}
|
||||
|
||||
private subscribe = (): void => {
|
||||
this._recording.on('isRecordingActiveChanged', this.isAvailableChanged);
|
||||
};
|
||||
|
||||
public unsubscribe = (): void => {
|
||||
this._recording.off('isRecordingActiveChanged', this.isAvailableChanged);
|
||||
};
|
||||
|
||||
private isAvailableChanged = (): void => {
|
||||
this._context.setCallRecordingActive(this._callIdRef.callId, this._recording.isRecordingActive);
|
||||
};
|
||||
}
|
|
@ -72,6 +72,7 @@ function createMockCall(mockCallId: string): Call {
|
|||
localVideoStreams: [],
|
||||
remoteParticipants: new Map<string, RemoteParticipant>(),
|
||||
remoteParticipantsEnded: new Map<string, RemoteParticipant>(),
|
||||
recording: { isRecordingActive: false },
|
||||
startTime: new Date(),
|
||||
endTime: undefined
|
||||
};
|
||||
|
|
|
@ -2,10 +2,21 @@
|
|||
import {
|
||||
Call,
|
||||
CallAgent,
|
||||
CallApiFeature,
|
||||
CallFeatureFactoryType,
|
||||
Features,
|
||||
IncomingCall,
|
||||
LocalVideoStream,
|
||||
PropertyChangedEvent,
|
||||
RecordingCallFeature,
|
||||
RemoteParticipant,
|
||||
RemoteVideoStream
|
||||
RemoteVideoStream,
|
||||
TranscriptionCallFeature,
|
||||
Transfer,
|
||||
TransferCallFeature,
|
||||
TransferRequestedEvent,
|
||||
TransferToParticipant,
|
||||
TransferToParticipantOptions
|
||||
} from '@azure/communication-calling';
|
||||
import EventEmitter from 'events';
|
||||
|
||||
|
@ -47,9 +58,47 @@ export class MockCommunicationUserCredential {
|
|||
public dispose(): void {}
|
||||
}
|
||||
|
||||
export function addMockEmitter(
|
||||
object: MockCall | MockCallAgent | MockRemoteParticipant | MockRemoteVideoStream | MockIncomingCall
|
||||
): any {
|
||||
export class MockRecordingCallFeatureImpl implements RecordingCallFeature {
|
||||
public name = 'Recording';
|
||||
public isRecordingActive = false;
|
||||
public emitter = new EventEmitter();
|
||||
on(event: 'isRecordingActiveChanged', listener: PropertyChangedEvent): void {
|
||||
this.emitter.on(event, listener);
|
||||
}
|
||||
off(event: 'isRecordingActiveChanged', listener: PropertyChangedEvent): void {
|
||||
this.emitter.off(event, listener);
|
||||
}
|
||||
}
|
||||
|
||||
export class MockTransferCallFeatureImpl implements TransferCallFeature {
|
||||
public name = 'Transfer';
|
||||
public emitter = new EventEmitter();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
transfer(target: TransferToParticipant, transferOptions?: TransferToParticipantOptions): Transfer {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
on(event: 'transferRequested', listener: TransferRequestedEvent): void {
|
||||
this.emitter.on(event, listener);
|
||||
}
|
||||
off(event: 'transferRequested', listener: TransferRequestedEvent): void {
|
||||
this.emitter.off(event, listener);
|
||||
}
|
||||
}
|
||||
|
||||
export class MockTranscriptionCallFeatureImpl implements TranscriptionCallFeature {
|
||||
public name = 'Transcription';
|
||||
public isTranscriptionActive = false;
|
||||
public emitter = new EventEmitter();
|
||||
on(event: 'isTranscriptionActiveChanged', listener: PropertyChangedEvent): void {
|
||||
this.emitter.on(event, listener);
|
||||
}
|
||||
off(event: 'isTranscriptionActiveChanged', listener: PropertyChangedEvent): void {
|
||||
this.emitter.off(event, listener);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export function addMockEmitter(object: any): any {
|
||||
object.emitter = new EventEmitter();
|
||||
object.on = (event: any, listener: any): void => {
|
||||
object.emitter.on(event, listener);
|
||||
|
@ -67,7 +116,8 @@ export function createMockCall(mockCallId: string): MockCall {
|
|||
const mockCall = {
|
||||
id: mockCallId,
|
||||
remoteParticipants: [] as ReadonlyArray<RemoteParticipant>,
|
||||
localVideoStreams: [] as ReadonlyArray<LocalVideoStream>
|
||||
localVideoStreams: [] as ReadonlyArray<LocalVideoStream>,
|
||||
api: createMockApiFeatures(false, new Map<string, any>())
|
||||
} as MockCall;
|
||||
return addMockEmitter(mockCall);
|
||||
}
|
||||
|
@ -90,6 +140,36 @@ export function createMockRemoteVideoStream(mockIsAvailable: boolean): MockRemot
|
|||
return addMockEmitter(mockRemoteVideoStream);
|
||||
}
|
||||
|
||||
export const MOCK_RECORDING_NAME = 'Recording';
|
||||
|
||||
/**
|
||||
* Create a mockApiFeatures function see {@Link @azure/communication-calling#Call.api}. If cache is passed in, will try
|
||||
* to return existing cached TFeatures else create new TFeature, cache it, and then return it.
|
||||
*
|
||||
* @param isRecording
|
||||
* @param cache
|
||||
* @returns
|
||||
*/
|
||||
export function createMockApiFeatures(
|
||||
isRecording: boolean,
|
||||
cache: Map<string, any>
|
||||
): <FeatureT extends CallApiFeature>(cls: CallFeatureFactoryType<FeatureT>) => FeatureT {
|
||||
return <FeatureT extends CallApiFeature>(cls: CallFeatureFactoryType<FeatureT>): FeatureT => {
|
||||
if (typeof cls === typeof Features.Recording) {
|
||||
if (cache.has(MOCK_RECORDING_NAME)) {
|
||||
return cache.get(MOCK_RECORDING_NAME);
|
||||
}
|
||||
const recording = addMockEmitter({
|
||||
isRecordingActive: isRecording
|
||||
});
|
||||
cache.set(MOCK_RECORDING_NAME, recording);
|
||||
return recording;
|
||||
} else {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function waitMilliseconds(duration: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
|
|
|
@ -9,5 +9,6 @@ export type {
|
|||
LocalVideoStream,
|
||||
RemoteVideoStream,
|
||||
RemoteParticipant,
|
||||
VideoStreamRendererView
|
||||
VideoStreamRendererView,
|
||||
RecordingCallFeature
|
||||
} from './CallClientState';
|
||||
|
|
Загрузка…
Ссылка в новой задаче