diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a98656d..7cabbcfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: run: yarn install - name: Build package run: | - touch .version.js && echo "echo \"export const version = 'beta'\" > .version.js" | bash - + touch .version.js && echo "echo \"export const version = 'lab'\" > .version.js" | bash - yarn build - name: Push uses: s0/git-publish-subdir-action@develop diff --git a/__mocks__/plugins.mock.ts b/__mocks__/plugins.mock.ts index a1a6b314..b42a131a 100644 --- a/__mocks__/plugins.mock.ts +++ b/__mocks__/plugins.mock.ts @@ -9,7 +9,7 @@ import { import { DefaultIntegrationManagerOptions } from '../src/services/integration/types'; import { MOCK_AVATAR, MOCK_LOCAL_PARTICIPANT } from './participants.mock'; -import { MOCK_REALTIME_SERVICE } from './realtime.mock'; +import { ABLY_REALTIME_MOCK } from './realtime.mock'; export const MOCK_AVATAR_CONFIG: AvatarConfig = { height: 1.8, @@ -76,5 +76,5 @@ export const MOCK_INTEGRATION_MANAGER_OPTIONS: DefaultIntegrationManagerOptions avatarConfig: MOCK_AVATAR_CONFIG, participantList: MOCK_PARTICIPANT_TO_3D_LIST, plugin: MOCK_PLUGIN, - RealtimeService: MOCK_REALTIME_SERVICE, + RealtimeService: ABLY_REALTIME_MOCK, }; diff --git a/__mocks__/realtime.mock.ts b/__mocks__/realtime.mock.ts index 99e1384a..43e4d651 100644 --- a/__mocks__/realtime.mock.ts +++ b/__mocks__/realtime.mock.ts @@ -18,25 +18,17 @@ export const createRealtimeHistory = () => ({ 'unit-test-message-3': new Array(10).fill(createRealtimeMessage('unit-test-message-3')), }); -export const MOCK_REALTIME_SERVICE: AblyRealtimeService = { - participantJoinedObserver: MOCK_OBSERVER_HELPER, - participantLeaveObserver: MOCK_OBSERVER_HELPER, - roomInfoUpdatedObserver: MOCK_OBSERVER_HELPER, - syncPropertiesObserver: MOCK_OBSERVER_HELPER, - participantsObserver: MOCK_OBSERVER_HELPER, - subscribeToParticipantUpdate: jest.fn(), - unsubscribeFromParticipantUpdate: jest.fn(), - updateMyProperties: jest.fn(), - subscribe: MOCK_OBSERVER_HELPER.subscribe, - unsubscribe: MOCK_OBSERVER_HELPER.unsubscribe, - getParticipantSlot: jest.fn(), -} as unknown as AblyRealtimeService; - -export const ABLY_REALTIME_MOCK = { +export const ABLY_REALTIME_MOCK: AblyRealtimeService = { isLocalParticipantHost: true, setGather: jest.fn(), + setHost: jest.fn(), + setGridMode: jest.fn(), + setDrawing: jest.fn(), + freezeSync: jest.fn(), setParticipantData: jest.fn(), setSyncProperty: jest.fn(), + setKickParticipant: jest.fn(), + setTranscript: jest.fn(), start: jest.fn(), join: jest.fn(), leave: jest.fn(), @@ -48,8 +40,9 @@ export const ABLY_REALTIME_MOCK = { return createRealtimeHistory(); }), - getSlotColor: jest.fn(() => { - return { color: MeetingColorsHex['#FFEF33'], name: MeetingColors.yellow }; + getSlotColor: jest.fn().mockReturnValue({ + color: MeetingColorsHex[0], + name: MeetingColors[0], }), roomInfoUpdatedObserver: MOCK_OBSERVER_HELPER, participantsObserver: MOCK_OBSERVER_HELPER, @@ -58,5 +51,10 @@ export const ABLY_REALTIME_MOCK = { hostObserver: MOCK_OBSERVER_HELPER, syncPropertiesObserver: MOCK_OBSERVER_HELPER, kickAllParticipantsObserver: MOCK_OBSERVER_HELPER, + kickParticipantObserver: MOCK_OBSERVER_HELPER, authenticationObserver: MOCK_OBSERVER_HELPER, -}; + subscribeToParticipantUpdate: jest.fn(), + unsubscribeFromParticipantUpdate: jest.fn(), + updateMyProperties: jest.fn(), + getParticipantSlot: jest.fn(), +} as unknown as AblyRealtimeService; diff --git a/jest.config.js b/jest.config.js index e76a5111..a67d5ac9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,6 +9,7 @@ module.exports = { '/src/**/*.ts', '/src/**/*.js', '!/src/web-components/**/*', + '!/src/index.ts', ], coverageDirectory: '/coverage', coverageReporters: ['html', 'lcov'].concat(argv.coverage ? ['text'] : []), diff --git a/src/common/types/cdn.types.ts b/src/common/types/cdn.types.ts index 26290824..a4deb681 100644 --- a/src/common/types/cdn.types.ts +++ b/src/common/types/cdn.types.ts @@ -1,4 +1,5 @@ -import { SuperVizSdk } from '../../types'; +import { VideoComponent } from '../../components'; +import { LauncherFacade } from '../../core/launcher/types'; import { DeviceEvent, @@ -12,7 +13,7 @@ import { import { SuperVizSdkOptions } from './sdk-options.types'; export interface SuperVizCdn { - init: (apiKey: string, options: SuperVizSdkOptions) => Promise; + init: (apiKey: string, options: SuperVizSdkOptions) => Promise; MeetingEvent: typeof MeetingEvent; RealtimeEvent: typeof RealtimeEvent; DeviceEvent: typeof DeviceEvent; @@ -20,4 +21,5 @@ export interface SuperVizCdn { MeetingConnectionStatus: typeof MeetingConnectionStatus; MeetingControlsEvent: typeof MeetingControlsEvent; ParticipantEvent: typeof ParticipantEvent; + VideoComponent: typeof VideoComponent; } diff --git a/src/common/types/events.types.ts b/src/common/types/events.types.ts index 227641bc..ff265727 100644 --- a/src/common/types/events.types.ts +++ b/src/common/types/events.types.ts @@ -17,6 +17,7 @@ export enum MeetingEvent { MY_PARTICIPANT_UPDATED = 'my-participant.update', MY_PARTICIPANT_LEFT = 'my-participant.left', MY_PARTICIPANT_JOINED = 'my-participant.joined', + MEETING_KICK_PARTICIPANT = 'meeting.kick-participant', DESTROY = 'destroy', } @@ -40,7 +41,6 @@ export enum MeetingControlsEvent { } export enum RealtimeEvent { - REALTIME_JOIN = 'realtime.join', REALTIME_PARTICIPANT_LIST_UPDATE = 'realtime.participant-list-update', REALTIME_HOST_CHANGE = 'realtime.host-change', REALTIME_GRID_MODE_CHANGE = 'realtime.grid-mode-change', @@ -51,11 +51,13 @@ export enum RealtimeEvent { REALTIME_FOLLOW_PARTICIPANT = 'realtime.follow-participant', REALTIME_SET_AVATAR = 'realtime.set-avatar', REALTIME_DRAWING_CHANGE = 'realtime.drawing-change', + REALTIME_TRANSCRIPT_CHANGE = 'realtime.transcript-change', } -export enum TranscriptionEvent { - TRANSCRIPTION_START = 'transcription.start', - TRANSCRIPTION_STOP = 'transcription.stop', +export enum TranscriptState { + TRANSCRIPT_START = 'transcript.start', + TRANSCRIPT_RUNNING = 'transcript.running', + TRANSCRIPT_STOP = 'transcript.stop', } export enum MeetingState { diff --git a/src/components/base/index.test.ts b/src/components/base/index.test.ts index 89a766a9..83202092 100644 --- a/src/components/base/index.test.ts +++ b/src/components/base/index.test.ts @@ -1,7 +1,10 @@ -import { MOCK_LOCAL_PARTICIPANT } from '../../../__mocks__/participants.mock'; -import { MOCK_REALTIME_SERVICE } from '../../../__mocks__/realtime.mock'; -import { Participant } from '../../common/types/participant.types'; +import { MOCK_CONFIG } from '../../../__mocks__/config.mock'; +import { MOCK_OBSERVER_HELPER } from '../../../__mocks__/observer-helper.mock'; +import { MOCK_GROUP, MOCK_LOCAL_PARTICIPANT } from '../../../__mocks__/participants.mock'; +import { ABLY_REALTIME_MOCK } from '../../../__mocks__/realtime.mock'; +import { Group, Participant } from '../../common/types/participant.types'; import { Logger } from '../../common/utils'; +import { Configuration } from '../../services/config/types'; import { AblyRealtimeService } from '../../services/realtime'; import { BaseComponent } from '.'; @@ -45,11 +48,13 @@ describe('BaseComponent', () => { DummyComponentInstance.attach({ localParticipant: MOCK_LOCAL_PARTICIPANT, - realtime: MOCK_REALTIME_SERVICE, + realtime: ABLY_REALTIME_MOCK, + group: MOCK_GROUP, + config: MOCK_CONFIG, }); expect(DummyComponentInstance['localParticipant']).toEqual(MOCK_LOCAL_PARTICIPANT); - expect(DummyComponentInstance['realtime']).toEqual(MOCK_REALTIME_SERVICE); + expect(DummyComponentInstance['realtime']).toEqual(ABLY_REALTIME_MOCK); expect(DummyComponentInstance['isAttached']).toBeTruthy(); expect(DummyComponentInstance['start']).toBeCalled(); }); @@ -61,6 +66,8 @@ describe('BaseComponent', () => { DummyComponentInstance.attach({ localParticipant: null as unknown as Participant, realtime: null as unknown as AblyRealtimeService, + group: null as unknown as Group, + config: null as unknown as Configuration, }); }).toThrowError(); }); @@ -73,7 +80,9 @@ describe('BaseComponent', () => { DummyComponentInstance.attach({ localParticipant: MOCK_LOCAL_PARTICIPANT, - realtime: MOCK_REALTIME_SERVICE, + realtime: ABLY_REALTIME_MOCK, + group: MOCK_GROUP, + config: MOCK_CONFIG, }); DummyComponentInstance.detach(); @@ -84,6 +93,26 @@ describe('BaseComponent', () => { expect(DummyComponentInstance['destroy']).toBeCalled(); }); + test('should unsubscribe from all events', () => { + const callback = jest.fn(); + DummyComponentInstance['destroy'] = jest.fn(); + + DummyComponentInstance.attach({ + localParticipant: MOCK_LOCAL_PARTICIPANT, + realtime: ABLY_REALTIME_MOCK, + group: MOCK_GROUP, + config: MOCK_CONFIG, + }); + + DummyComponentInstance.subscribe('test', callback); + + expect(DummyComponentInstance['observers']['test']).toBeDefined(); + + DummyComponentInstance.detach(); + + expect(DummyComponentInstance['observers']['test']).toBeUndefined(); + }); + test('should not detach the component if it is not attached', () => { DummyComponentInstance['logger'].log = jest.fn(); expect(DummyComponentInstance.detach).toBeDefined(); @@ -93,4 +122,48 @@ describe('BaseComponent', () => { expect(DummyComponentInstance['logger'].log).toBeCalled(); }); }); + + describe('subscribe', () => { + test('should subscribe to the event component with success', () => { + const callback = jest.fn(); + + expect(DummyComponentInstance.subscribe).toBeDefined(); + + DummyComponentInstance.subscribe('test', callback); + + DummyComponentInstance['publish']('test', 'test'); + + expect(callback).toBeCalledWith('test'); + }); + + test('should unsubscribe to the event component with success', () => { + const callback = jest.fn(); + + expect(DummyComponentInstance.subscribe).toBeDefined(); + + DummyComponentInstance.subscribe('test', callback); + + DummyComponentInstance['publish']('test', 'test'); + + expect(callback).toBeCalledWith('test'); + + DummyComponentInstance.unsubscribe('test'); + + DummyComponentInstance['publish']('test', 'test'); + + expect(callback).toBeCalledTimes(1); + }); + + test('should skip unsubscribe if the event is not subscribed', () => { + expect(DummyComponentInstance.subscribe).toBeDefined(); + + DummyComponentInstance.unsubscribe('test'); + }); + + test('should skip publish if the event is not subscribed', () => { + expect(DummyComponentInstance.subscribe).toBeDefined(); + + DummyComponentInstance['publish']('test', 'test'); + }); + }); }); diff --git a/src/components/base/index.ts b/src/components/base/index.ts index 6f603fc6..5a27bdb0 100644 --- a/src/components/base/index.ts +++ b/src/components/base/index.ts @@ -1,11 +1,15 @@ -import { Participant } from '../../common/types/participant.types'; -import { Logger } from '../../common/utils'; +import { Group, Participant } from '../../common/types/participant.types'; +import { Logger, Observer } from '../../common/utils'; +import config from '../../services/config'; import { AblyRealtimeService } from '../../services/realtime'; import { DefaultAttachComponentOptions } from './types'; export abstract class BaseComponent { + private observers: Record = {}; + protected localParticipant: Participant; + protected group: Group; protected realtime: AblyRealtimeService; protected abstract name: string; protected abstract logger: Logger; @@ -17,18 +21,25 @@ export abstract class BaseComponent { * @description attach component * @returns {void} */ - public attach = ({ realtime, localParticipant }: DefaultAttachComponentOptions): void => { - if (!realtime || !localParticipant) { - const message = `${this.name} @ attach - realtime and localParticipant are required`; + public attach = (params: DefaultAttachComponentOptions): void => { + if (Object.values(params).includes(null) || Object.values(params).includes(undefined)) { + const message = `${this.name} @ attach - params are required`; this.logger.log(message); throw new Error(message); } - this.logger.log('attached'); + const { realtime, localParticipant, group, config: globalConfig } = params; + + config.setConfig(globalConfig); + this.realtime = realtime; this.localParticipant = localParticipant; + this.group = group; this.isAttached = true; + + this.logger.log('attached'); + this.start(); }; @@ -43,12 +54,61 @@ export abstract class BaseComponent { return; } + this.logger.log('detached'); + this.destroy(); + this.realtime = undefined; this.localParticipant = undefined; this.isAttached = false; - this.logger.log('detached'); - this.destroy(); + Object.keys(this.observers).forEach((type) => this.unsubscribe(type)); + }; + + /** + * @function subscribe + * @description Subscribe to an event + * @param type - event type + * @param listener - event callback + * @returns {void} + */ + public subscribe = (type: string, listener: Function): void => { + this.logger.log(`subscribed to ${type} event`); + + if (!this.observers[type]) { + this.observers[type] = new Observer({ logger: this.logger }); + } + + this.observers[type].subscribe(listener); + }; + + /** + * @function unsubscribe + * @description Unsubscribe from an event + * @param type - event type + * @returns {void} + */ + public unsubscribe = (type: string): void => { + this.logger.log(`unsubscribed from ${type} event`); + + if (!this.observers[type]) return; + + this.observers[type].reset(); + delete this.observers[type]; + }; + + /** + * @function publish + * @description Publish an event to client + * @param type - event type + * @param data - event data + * @returns {void} + */ + protected publish = (type: string, data?: unknown): void => { + const hasListenerRegistered = type in this.observers; + + if (!hasListenerRegistered) return; + + this.observers[type].publish(data); }; protected abstract destroy(): void; diff --git a/src/components/base/types.ts b/src/components/base/types.ts index d2a8d6e4..1fa27392 100644 --- a/src/components/base/types.ts +++ b/src/components/base/types.ts @@ -1,8 +1,10 @@ -import { Participant } from '../../common/types/participant.types'; +import { Group, Participant } from '../../common/types/participant.types'; +import { Configuration } from '../../services/config/types'; import { AblyRealtimeService } from '../../services/realtime'; -import { AblyRealtime } from '../../services/realtime/ably/types'; export interface DefaultAttachComponentOptions { realtime: AblyRealtimeService; localParticipant: Participant; + group: Group; + config: Configuration; } diff --git a/src/components/index.test.ts b/src/components/index.test.ts new file mode 100644 index 00000000..dcf85a4e --- /dev/null +++ b/src/components/index.test.ts @@ -0,0 +1,10 @@ +import { VideoComponent } from './video'; + +import * as Components from '.'; + +describe('Components', () => { + test('should be export VideoComponent', () => { + expect(Components.VideoComponent).toBeDefined(); + expect(Components.VideoComponent).toBe(VideoComponent); + }); +}); diff --git a/src/components/index.ts b/src/components/index.ts index cb0ff5c3..0d1f1108 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1 +1 @@ -export {}; +export { VideoComponent } from './video'; diff --git a/src/components/video/index.test.ts b/src/components/video/index.test.ts new file mode 100644 index 00000000..b0001899 --- /dev/null +++ b/src/components/video/index.test.ts @@ -0,0 +1,498 @@ +import { MOCK_CONFIG } from '../../../__mocks__/config.mock'; +import { MOCK_OBSERVER_HELPER } from '../../../__mocks__/observer-helper.mock'; +import { + MOCK_AVATAR, + MOCK_GROUP, + MOCK_LOCAL_PARTICIPANT, +} from '../../../__mocks__/participants.mock'; +import { ABLY_REALTIME_MOCK } from '../../../__mocks__/realtime.mock'; +import { + DeviceEvent, + FrameEvent, + MeetingConnectionStatus, + MeetingEvent, + MeetingState, + RealtimeEvent, + TranscriptState, +} from '../../common/types/events.types'; +import { MeetingColors } from '../../common/types/meeting-colors.types'; +import { AblyParticipant, AblyRealtimeData } from '../../services/realtime/ably/types'; +import { VideoFrameState } from '../../services/video-conference-manager/types'; + +import { VideoComponent } from '.'; + +const VIDEO_MANAGER_MOCK = { + start: jest.fn(), + leave: jest.fn(), + publishMessageToFrame: jest.fn(), + frameStateObserver: MOCK_OBSERVER_HELPER, + frameSizeObserver: MOCK_OBSERVER_HELPER, + realtimeEventsObserver: MOCK_OBSERVER_HELPER, + waitingForHostObserver: MOCK_OBSERVER_HELPER, + sameAccountErrorObserver: MOCK_OBSERVER_HELPER, + devicesObserver: MOCK_OBSERVER_HELPER, + meetingStateObserver: MOCK_OBSERVER_HELPER, + meetingConnectionObserver: MOCK_OBSERVER_HELPER, + participantJoinedObserver: MOCK_OBSERVER_HELPER, + participantLeftObserver: MOCK_OBSERVER_HELPER, +}; + +const MOCK_DRAW_DATA = { + name: 'participant1', + lineColor: '255, 239, 51', + textColor: '#000000', + pencil: 'blob:http://localhost:8080/b3cde217-c2cc-4092-a2e5-cf4c498f744e', + clickX: [0, 109], + clickY: [0, 109], + clickDrag: [], + drawingWidth: 300, + drawingHeight: 600, + externalClickX: 566, + externalClickY: 300, + fadeOut: false, +}; + +jest.mock('../../services/video-conference-manager', () => { + return jest.fn().mockImplementation(() => VIDEO_MANAGER_MOCK); +}); + +describe('VideoComponent', () => { + let VideoComponentInstance: VideoComponent; + + beforeEach(() => { + jest.clearAllMocks(); + + VideoComponentInstance = new VideoComponent(); + VideoComponentInstance.attach({ + realtime: ABLY_REALTIME_MOCK, + localParticipant: MOCK_LOCAL_PARTICIPANT, + group: MOCK_GROUP, + config: MOCK_CONFIG, + }); + }); + + afterEach(() => { + VideoComponentInstance.detach(); + }); + + test('should not show avatar settings if local participant has avatar', () => { + expect(VideoComponentInstance['videoConfig'].canUseDefaultAvatars).toBeFalsy(); + }); + + test('should subscribe to realtime events', () => { + expect(ABLY_REALTIME_MOCK.roomInfoUpdatedObserver.subscribe).toHaveBeenCalled(); + expect(ABLY_REALTIME_MOCK.participantsObserver.subscribe).toHaveBeenCalled(); + expect(ABLY_REALTIME_MOCK.hostObserver.subscribe).toHaveBeenCalled(); + expect(ABLY_REALTIME_MOCK.participantJoinedObserver.subscribe).toHaveBeenCalled(); + expect(ABLY_REALTIME_MOCK.participantLeaveObserver.subscribe).toHaveBeenCalled(); + }); + + test('should subscribe from video events', () => { + expect(VIDEO_MANAGER_MOCK.meetingStateObserver.subscribe).toHaveBeenCalled(); + expect(VIDEO_MANAGER_MOCK.frameStateObserver.subscribe).toHaveBeenCalled(); + expect(VIDEO_MANAGER_MOCK.realtimeEventsObserver.subscribe).toHaveBeenCalled(); + expect(VIDEO_MANAGER_MOCK.participantJoinedObserver.subscribe).toHaveBeenCalled(); + expect(VIDEO_MANAGER_MOCK.participantLeftObserver.subscribe).toHaveBeenCalled(); + }); + + describe('detach', () => { + beforeEach(() => { + VideoComponentInstance.detach(); + }); + + test('should unsubscribe from realtime events', () => { + expect(ABLY_REALTIME_MOCK.roomInfoUpdatedObserver.unsubscribe).toHaveBeenCalled(); + expect(ABLY_REALTIME_MOCK.participantsObserver.unsubscribe).toHaveBeenCalled(); + expect(ABLY_REALTIME_MOCK.hostObserver.unsubscribe).toHaveBeenCalled(); + expect(ABLY_REALTIME_MOCK.participantJoinedObserver.unsubscribe).toHaveBeenCalled(); + expect(ABLY_REALTIME_MOCK.participantLeaveObserver.unsubscribe).toHaveBeenCalled(); + }); + + test('should unsubscribe from video events', () => { + expect(VIDEO_MANAGER_MOCK.meetingStateObserver.unsubscribe).toHaveBeenCalled(); + expect(VIDEO_MANAGER_MOCK.frameStateObserver.unsubscribe).toHaveBeenCalled(); + expect(VIDEO_MANAGER_MOCK.realtimeEventsObserver.unsubscribe).toHaveBeenCalled(); + expect(VIDEO_MANAGER_MOCK.participantJoinedObserver.unsubscribe).toHaveBeenCalled(); + expect(VIDEO_MANAGER_MOCK.participantLeftObserver.unsubscribe).toHaveBeenCalled(); + }); + }); + + describe('connection events', () => { + test('should freeze sync if the connection status is bad', () => { + VideoComponentInstance['onConnectionStatusChange'](MeetingConnectionStatus.BAD); + + expect(ABLY_REALTIME_MOCK.freezeSync).toBeCalledWith(true); + }); + + test('should enable sync if the connection status becomes good', () => { + VideoComponentInstance['connectionService'].oldConnectionStatus = MeetingConnectionStatus.BAD; + + VideoComponentInstance['onConnectionStatusChange'](MeetingConnectionStatus.GOOD); + + expect(ABLY_REALTIME_MOCK.freezeSync).toBeCalledWith(false); + }); + }); + + describe('video events', () => { + test('should initialize video when frame is initialized', () => { + VideoComponentInstance['onFrameStateChange'](VideoFrameState.INITIALIZED); + + expect(VIDEO_MANAGER_MOCK.start).toHaveBeenCalledWith({ + participant: VideoComponentInstance['localParticipant'], + group: VideoComponentInstance['group'], + roomId: MOCK_CONFIG.roomId, + }); + }); + + test('should not initialize video when frame is not initialized', () => { + VideoComponentInstance['onFrameStateChange'](VideoFrameState.INITIALIZING); + + expect(VIDEO_MANAGER_MOCK.start).not.toHaveBeenCalled(); + }); + + test('should change host from video frame', () => { + VideoComponentInstance['onRealtimeEventFromFrame']({ + event: RealtimeEvent.REALTIME_HOST_CHANGE, + data: MOCK_LOCAL_PARTICIPANT.id, + }); + + expect(ABLY_REALTIME_MOCK.setHost).toBeCalledWith(MOCK_LOCAL_PARTICIPANT.id); + }); + + test('should change grid mode from video frame', () => { + VideoComponentInstance['onRealtimeEventFromFrame']({ + event: RealtimeEvent.REALTIME_GRID_MODE_CHANGE, + data: true, + }); + + expect(ABLY_REALTIME_MOCK.setGridMode).toBeCalledWith(true); + }); + + test('should set gather from video frame', () => { + VideoComponentInstance['onRealtimeEventFromFrame']({ + event: RealtimeEvent.REALTIME_GATHER, + data: true, + }); + + expect(ABLY_REALTIME_MOCK.setGather).toBeCalledWith(true); + }); + + test('should set draw data from video frame', () => { + VideoComponentInstance['onRealtimeEventFromFrame']({ + event: RealtimeEvent.REALTIME_DRAWING_CHANGE, + data: MOCK_DRAW_DATA, + }); + + expect(ABLY_REALTIME_MOCK.setDrawing).toBeCalledWith(MOCK_DRAW_DATA); + }); + + test('should set follow participant from video frame', () => { + VideoComponentInstance['onRealtimeEventFromFrame']({ + event: RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT, + data: MOCK_LOCAL_PARTICIPANT.id, + }); + + expect(ABLY_REALTIME_MOCK.setFollowParticipant).toBeCalledWith(MOCK_LOCAL_PARTICIPANT.id); + }); + + test('should set follow participant from video frame', () => { + VideoComponentInstance['onRealtimeEventFromFrame']({ + event: MeetingEvent.MEETING_KICK_PARTICIPANT, + data: MOCK_LOCAL_PARTICIPANT.id, + }); + + expect(ABLY_REALTIME_MOCK.setKickParticipant).toBeCalledWith(MOCK_LOCAL_PARTICIPANT.id); + }); + + test('should set follow participant from video frame', () => { + VideoComponentInstance['onRealtimeEventFromFrame']({ + event: RealtimeEvent.REALTIME_TRANSCRIPT_CHANGE, + data: TranscriptState.TRANSCRIPT_START, + }); + + expect(ABLY_REALTIME_MOCK.setTranscript).toBeCalledWith(TranscriptState.TRANSCRIPT_START); + }); + + test('should update participant properties from video frame', () => { + const participant = { + ...MOCK_LOCAL_PARTICIPANT, + name: 'John Doe', + }; + + VideoComponentInstance['onParticipantJoined'](participant); + + expect(ABLY_REALTIME_MOCK.updateMyProperties).toBeCalledWith({ name: 'John Doe' }); + }); + + test('should update participant avatar if it is not set and video frame has default avatars', () => { + const participant = { + ...MOCK_LOCAL_PARTICIPANT, + name: 'John Doe', + avatar: MOCK_AVATAR, + }; + + VideoComponentInstance['videoConfig'].canUseDefaultAvatars = true; + VideoComponentInstance['onParticipantJoined'](participant); + + expect(ABLY_REALTIME_MOCK.updateMyProperties).toBeCalledWith({ + name: 'John Doe', + avatar: MOCK_AVATAR, + }); + }); + + test('should publish message to client when my participant left', () => { + VideoComponentInstance['publish'] = jest.fn(); + + VideoComponentInstance['onParticipantLeft'](MOCK_LOCAL_PARTICIPANT); + + expect(VideoComponentInstance['publish']).toBeCalledWith( + MeetingEvent.MY_PARTICIPANT_LEFT, + MOCK_LOCAL_PARTICIPANT, + ); + }); + + test('should publish message to client when meeting state changed', () => { + VideoComponentInstance['publish'] = jest.fn(); + + VideoComponentInstance['onMeetingStateChange'](MeetingState.MEETING_CONNECTED); + + expect(VideoComponentInstance['publish']).toBeCalledWith( + MeetingEvent.MEETING_STATE_UPDATE, + MeetingState.MEETING_CONNECTED, + ); + }); + + test('should publish message to client and detach when same account error happened', () => { + VideoComponentInstance['publish'] = jest.fn(); + VideoComponentInstance['detach'] = jest.fn(); + + VideoComponentInstance['onSameAccountError']('same-account-error'); + + expect(VideoComponentInstance['publish']).toBeCalledWith( + MeetingEvent.MEETING_SAME_PARTICIPANT_ERROR, + 'same-account-error', + ); + expect(VideoComponentInstance['detach']).toBeCalled(); + }); + + test('should publish a message to client when devices change', () => { + VideoComponentInstance['publish'] = jest.fn(); + + VideoComponentInstance['onDevicesChange'](DeviceEvent.DEVICES_BLOCKED); + + expect(VideoComponentInstance['publish']).toBeCalledWith( + MeetingEvent.MEETING_DEVICES_CHANGE, + DeviceEvent.DEVICES_BLOCKED, + ); + }); + + test('should publish a message to client when waiting for host', () => { + VideoComponentInstance['publish'] = jest.fn(); + + VideoComponentInstance['onWaitingForHost'](true); + + expect(VideoComponentInstance['publish']).toBeCalledWith( + MeetingEvent.MEETING_WAITING_FOR_HOST, + true, + ); + }); + + test('should publish a message to client when frame size change', () => { + VideoComponentInstance['publish'] = jest.fn(); + + VideoComponentInstance['onFrameSizeDidChange']({ + height: 100, + width: 100, + }); + + expect(VideoComponentInstance['publish']).toBeCalledWith(FrameEvent.FRAME_DIMENSIONS_UPDATE, { + height: 100, + width: 100, + }); + }); + }); + + describe('realtime events', () => { + test('should update host participant', () => { + VideoComponentInstance['onHostParticipantDidChange']({ + oldHostParticipantId: '', + newHostParticipantId: MOCK_LOCAL_PARTICIPANT.id, + }); + + expect(VIDEO_MANAGER_MOCK.publishMessageToFrame).toBeCalledWith( + RealtimeEvent.REALTIME_HOST_CHANGE, + MOCK_LOCAL_PARTICIPANT.id, + ); + }); + + test('should update participants', () => { + const ablyParticipant: AblyParticipant = { + clientId: MOCK_LOCAL_PARTICIPANT.id, + action: 'present', + connectionId: 'connection1', + encoding: 'h264', + id: 'unit-test-participant-ably-id', + timestamp: new Date().getTime(), + data: { + participantId: MOCK_LOCAL_PARTICIPANT.id, + slotIndex: 0, + }, + }; + + VideoComponentInstance['onParticipantsDidChange']({ + [MOCK_LOCAL_PARTICIPANT.id]: ablyParticipant, + }); + + const expectedParticipants = { + timestamp: ablyParticipant.timestamp, + connectionId: ablyParticipant.connectionId, + participantId: ablyParticipant.data.participantId, + color: MeetingColors[ablyParticipant.data.slotIndex], + name: ablyParticipant.data.name, + }; + + expect(VIDEO_MANAGER_MOCK.publishMessageToFrame).toBeCalledWith( + RealtimeEvent.REALTIME_PARTICIPANT_LIST_UPDATE, + [expectedParticipants], + ); + }); + + test('should update room info', () => { + const realtimeData: AblyRealtimeData = { + drawing: MOCK_DRAW_DATA, + followParticipantId: MOCK_LOCAL_PARTICIPANT.id, + gather: true, + hostClientId: MOCK_LOCAL_PARTICIPANT.id, + isGridModeEnable: true, + }; + + // @ts-ignore + VideoComponentInstance['realtime'].hostClientId = MOCK_LOCAL_PARTICIPANT.id; + VideoComponentInstance['onRoomInfoUpdated'](realtimeData); + + expect(VIDEO_MANAGER_MOCK.publishMessageToFrame).toBeCalledWith( + RealtimeEvent.REALTIME_GRID_MODE_CHANGE, + realtimeData.isGridModeEnable, + ); + + expect(VIDEO_MANAGER_MOCK.publishMessageToFrame).toBeCalledWith( + RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT, + realtimeData.followParticipantId, + ); + + expect(VIDEO_MANAGER_MOCK.publishMessageToFrame).toBeCalledWith( + RealtimeEvent.REALTIME_DRAWING_CHANGE, + realtimeData.drawing, + ); + + expect(ABLY_REALTIME_MOCK.setGather).toBeCalledWith(false); + }); + + test('should update room info and not update gather', () => { + const realtimeData: AblyRealtimeData = { + drawing: { + name: 'participant1', + lineColor: '255, 239, 51', + textColor: '#000000', + pencil: 'blob:http://localhost:8080/b3cde217-c2cc-4092-a2e5-cf4c498f744e', + clickX: [0, 109], + clickY: [0, 109], + clickDrag: [], + drawingWidth: 300, + drawingHeight: 600, + externalClickX: 566, + externalClickY: 300, + fadeOut: false, + }, + followParticipantId: MOCK_LOCAL_PARTICIPANT.id, + gather: false, + hostClientId: MOCK_LOCAL_PARTICIPANT.id, + isGridModeEnable: true, + }; + + VideoComponentInstance['onRoomInfoUpdated'](realtimeData); + + expect(ABLY_REALTIME_MOCK.setGather).not.toHaveBeenCalled(); + }); + + test('should publish message to client when participant joined', () => { + VideoComponentInstance['publish'] = jest.fn(); + + const ablyParticipant: AblyParticipant = { + clientId: MOCK_LOCAL_PARTICIPANT.id, + action: 'present', + connectionId: 'connection1', + encoding: 'h264', + id: 'unit-test-participant-ably-id', + timestamp: new Date().getTime(), + data: { + participantId: MOCK_LOCAL_PARTICIPANT.id, + slotIndex: 0, + avatar: MOCK_AVATAR, + ...MOCK_LOCAL_PARTICIPANT, + }, + }; + + VideoComponentInstance['onParticipantJoinedOnRealtime'](ablyParticipant); + + expect(VideoComponentInstance['publish']).toBeCalledWith( + MeetingEvent.MEETING_PARTICIPANT_JOINED, + VideoComponentInstance['createParticipantFromAblyPresence'](ablyParticipant), + ); + }); + + test('should publish message to client when participant left', () => { + VideoComponentInstance['publish'] = jest.fn(); + + const ablyParticipant: AblyParticipant = { + clientId: MOCK_LOCAL_PARTICIPANT.id, + action: 'present', + connectionId: 'connection1', + encoding: 'h264', + id: 'unit-test-participant-ably-id', + timestamp: new Date().getTime(), + data: { + participantId: MOCK_LOCAL_PARTICIPANT.id, + slotIndex: 0, + avatar: MOCK_AVATAR, + ...MOCK_LOCAL_PARTICIPANT, + }, + }; + + VideoComponentInstance['onParticipantLeftOnRealtime'](ablyParticipant); + + expect(VideoComponentInstance['publish']).toBeCalledWith( + MeetingEvent.MEETING_PARTICIPANT_LEFT, + VideoComponentInstance['createParticipantFromAblyPresence'](ablyParticipant), + ); + }); + + test('should publish a message to client and detach when kick participants happend', () => { + VideoComponentInstance['publish'] = jest.fn(); + VideoComponentInstance['detach'] = jest.fn(); + + VideoComponentInstance['onKickAllParticipantsDidChange'](true); + + expect(VideoComponentInstance['publish']).toBeCalledWith( + MeetingEvent.MEETING_KICK_PARTICIPANTS, + true, + ); + + expect(VideoComponentInstance['detach']).toBeCalled(); + }); + + test('should publish a message to client and detach when kick local participant happend', () => { + VideoComponentInstance['publish'] = jest.fn(); + VideoComponentInstance['detach'] = jest.fn(); + + VideoComponentInstance['onKickLocalParticipant'](); + + expect(VideoComponentInstance['publish']).toBeCalledWith( + MeetingEvent.MEETING_KICK_PARTICIPANT, + VideoComponentInstance['localParticipant'], + ); + + expect(VideoComponentInstance['detach']).toBeCalled(); + }); + }); +}); diff --git a/src/components/video/index.ts b/src/components/video/index.ts new file mode 100644 index 00000000..973bf26c --- /dev/null +++ b/src/components/video/index.ts @@ -0,0 +1,542 @@ +import { isEqual } from 'lodash'; + +import { + DeviceEvent, + Dimensions, + FrameEvent, + MeetingConnectionStatus, + MeetingEvent, + MeetingState, + RealtimeEvent, + TranscriptState, +} from '../../common/types/events.types'; +import { Participant } from '../../common/types/participant.types'; +import { Logger } from '../../common/utils'; +import { BrowserService } from '../../services/browser'; +import config from '../../services/config'; +import { ConnectionService } from '../../services/connection-status'; +import { AblyParticipant, AblyRealtimeData } from '../../services/realtime/ably/types'; +import { HostObserverCallbackResponse } from '../../services/realtime/base/types'; +import VideoConfereceManager from '../../services/video-conference-manager'; +import { + DrawingData, + RealtimeObserverPayload, + VideoFrameState, + VideoManagerOptions, +} from '../../services/video-conference-manager/types'; +import { BaseComponent } from '../base'; + +import { ParticipandToFrame, VideoComponentOptions } from './types'; + +export class VideoComponent extends BaseComponent { + protected name: string; + protected logger: Logger; + private participantList: ParticipandToFrame[] = []; + + private videoManager: VideoConfereceManager; + private connectionService: ConnectionService; + private browserService: BrowserService; + + private videoConfig: VideoManagerOptions; + + constructor(params?: VideoComponentOptions) { + super(); + + const { + language, + camsOff, + screenshareOff, + chatOff, + defaultAvatars, + offset, + enableFollow, + enableGoTo, + enableGather, + defaultToolbar, + locales, + avatars, + devices, + customColors, + camerasPosition, + skipMeetingSettings = false, + disableCameraOverlay = false, + layoutPosition, + } = params ?? {}; + + this.name = 'video-component'; + this.logger = new Logger('@superviz/sdk/video-component'); + + this.browserService = new BrowserService(); + this.connectionService = new ConnectionService(); + this.connectionService.addListeners(); + + // Connection observers + this.connectionService.connectionStatusObserver.subscribe(this.onConnectionStatusChange); + + this.videoConfig = { + language, + canUseChat: !chatOff, + canUseCams: !camsOff, + canUseScreenshare: !screenshareOff, + canUseDefaultAvatars: !!defaultAvatars && !this.localParticipant?.avatar?.model, + canUseGather: !!enableGather, + canUseFollow: !!enableFollow, + canUseGoTo: !!enableGoTo, + canUseDefaultToolbar: defaultToolbar ?? true, + camerasPosition, + devices, + skipMeetingSettings, + disableCameraOverlay, + browserService: this.browserService, + offset, + locales: locales ?? [], + avatars: avatars ?? [], + customColors, + layoutPosition, + }; + } + + /** + * @function start + * @description start video component + * @returns {void} + */ + protected start(): void { + this.logger.log('video component @ start'); + + this.publish(MeetingEvent.MEETING_START); + + this.suscribeToRealtimeEvents(); + this.startVideo(); + } + + /** + * @function destroy + * @description destroy video component + * @returns {void} + */ + protected destroy(): void { + this.logger.log('video component @ destroy'); + + this.publish(MeetingEvent.DESTROY); + + this.unsubscribeFromRealtimeEvents(); + this.unsubscribeFromVideoEvents(); + + this.videoManager.leave(); + this.connectionService.removeListeners(); + } + + /** + * @function startVideo + * @description start video manager + * @param {VideoManagerOptions} options - video manager params + * @returns {void} + */ + private startVideo = (): void => { + this.logger.log('video component @ start video'); + this.videoManager = new VideoConfereceManager(this.videoConfig); + + this.subscribeToVideoEvents(); + }; + + /** + * @function subscribeToVideoEvents + * @description subscribe to video events + * @returns {void} + */ + private subscribeToVideoEvents = (): void => { + this.logger.log('video component @ subscribe to video events'); + + this.videoManager.meetingConnectionObserver.subscribe( + this.connectionService.updateMeetingConnectionStatus, + ); + this.videoManager.waitingForHostObserver.subscribe(this.onWaitingForHost); + this.videoManager.frameSizeObserver.subscribe(this.onFrameSizeDidChange); + this.videoManager.meetingStateObserver.subscribe(this.onMeetingStateChange); + this.videoManager.frameStateObserver.subscribe(this.onFrameStateChange); + this.videoManager.realtimeEventsObserver.subscribe(this.onRealtimeEventFromFrame); + this.videoManager.participantJoinedObserver.subscribe(this.onParticipantJoined); + this.videoManager.participantLeftObserver.subscribe(this.onParticipantLeft); + this.videoManager.sameAccountErrorObserver.subscribe(this.onSameAccountError); + this.videoManager.devicesObserver.subscribe(this.onDevicesChange); + }; + + /** + * @function unsubscribeFromVideoEvents + * @description unsubscribe from video events + * @returns {void} + * */ + private unsubscribeFromVideoEvents = (): void => { + this.logger.log('video component @ unsubscribe from video events'); + + this.videoManager.meetingConnectionObserver.unsubscribe( + this.connectionService.updateMeetingConnectionStatus, + ); + this.videoManager.waitingForHostObserver.unsubscribe(this.onWaitingForHost); + this.videoManager.frameSizeObserver.unsubscribe(this.onFrameSizeDidChange); + this.videoManager.meetingStateObserver.unsubscribe(this.onMeetingStateChange); + this.videoManager.frameStateObserver.unsubscribe(this.onFrameStateChange); + this.videoManager.realtimeEventsObserver.unsubscribe(this.onRealtimeEventFromFrame); + this.videoManager.participantJoinedObserver.unsubscribe(this.onParticipantJoined); + this.videoManager.participantLeftObserver.unsubscribe(this.onParticipantLeft); + this.videoManager.sameAccountErrorObserver.unsubscribe(this.onSameAccountError); + this.videoManager.devicesObserver.unsubscribe(this.onDevicesChange); + }; + + /** + * @function suscribeToRealtimeEvents + * @description subscribe to realtime events + * @returns {void} + */ + private suscribeToRealtimeEvents = (): void => { + this.logger.log('video component @ subscribe to realtime events'); + this.realtime.kickAllParticipantsObserver.subscribe(this.onKickAllParticipantsDidChange); + this.realtime.kickParticipantObserver.subscribe(this.onKickLocalParticipant); + this.realtime.participantJoinedObserver.subscribe(this.onParticipantJoinedOnRealtime); + this.realtime.participantLeaveObserver.subscribe(this.onParticipantLeftOnRealtime); + this.realtime.roomInfoUpdatedObserver.subscribe(this.onRoomInfoUpdated); + this.realtime.participantsObserver.subscribe(this.onParticipantsDidChange); + this.realtime.hostObserver.subscribe(this.onHostParticipantDidChange); + }; + + /** + * @function unsubscribeFromRealtimeEvents + * @description subscribe to realtime events + * @returns {void} + */ + private unsubscribeFromRealtimeEvents = (): void => { + this.logger.log('video component @ unsubscribe from realtime events'); + this.realtime.kickAllParticipantsObserver.unsubscribe(this.onKickAllParticipantsDidChange); + this.realtime.participantJoinedObserver.unsubscribe(this.onParticipantJoinedOnRealtime); + this.realtime.participantLeaveObserver.unsubscribe(this.onParticipantLeftOnRealtime); + this.realtime.roomInfoUpdatedObserver.unsubscribe(this.onRoomInfoUpdated); + this.realtime.participantsObserver.unsubscribe(this.onParticipantsDidChange); + this.realtime.hostObserver.unsubscribe(this.onHostParticipantDidChange); + }; + + /** + * @function createParticipantListFromAblyList + * @description update participant list from ably participant list + * @param {Record} participants - ably participant list + * @returns {Participant[]} participant list + * */ + private createParticipantFromAblyPresence = (participant: AblyParticipant): Participant => { + return { + id: participant.clientId, + color: this.realtime.getSlotColor(participant.data?.slotIndex).color, + avatar: participant.data.avatar, + type: participant.data.type, + name: participant.data.name, + isHost: this.realtime.hostClientId === participant.clientId, + }; + }; + + /** Video Events */ + + /** + * @function onFrameSizeDidChange + * @description handler for frame size change event + * @param {Dimensions} dimensions - frame dimensions + * @returns {void} + * */ + private onFrameSizeDidChange = (dimensions: Dimensions): void => { + this.publish(FrameEvent.FRAME_DIMENSIONS_UPDATE, dimensions); + }; + + /** + * @function onWaitingForHost + * @description handler for waiting for host event + * @param {boolean} waiting - whether or not waiting for host + * @returns {void} + */ + private onWaitingForHost = (waiting: boolean): void => { + this.publish(MeetingEvent.MEETING_WAITING_FOR_HOST, waiting); + }; + + /** + * @function onCOnnectionStatusChange + * @description handler for connection status change event + * @param {MeetingConnectionStatus} newStatus - new connection status + * @returns {void} + */ + private onConnectionStatusChange = (newStatus: MeetingConnectionStatus): void => { + this.logger.log('video component @ on connection status change', newStatus); + + const connectionProblemStatus = [ + MeetingConnectionStatus.BAD, + MeetingConnectionStatus.DISCONNECTED, + MeetingConnectionStatus.POOR, + MeetingConnectionStatus.LOST_CONNECTION, + ]; + + if (connectionProblemStatus.includes(newStatus)) { + this.realtime.freezeSync(true); + } + + if ( + connectionProblemStatus.includes(this.connectionService.oldConnectionStatus) && + !connectionProblemStatus.includes(newStatus) + ) { + this.realtime.freezeSync(false); + } + + this.publish(MeetingEvent.MEETING_CONNECTION_STATUS_CHANGE, newStatus); + }; + + /** + * @function onMeetingStateChange + * @description handler for meeting state change event + * @param {MeetingState} state - meeting state + * @returns {void} + */ + private onMeetingStateChange = (state: MeetingState): void => { + this.logger.log('video component @ on meeting state change', state); + this.publish(MeetingEvent.MEETING_STATE_UPDATE, state); + }; + + /** + * @function onSameAccountError + * @description handler for same account error event + * @param {string} error - error message + * @returns {void} + * */ + private onSameAccountError = (error: string): void => { + this.publish(MeetingEvent.MEETING_SAME_PARTICIPANT_ERROR, error); + this.detach(); + }; + + /** + * @function onDevicesChange + * @description handler for devices change event + * @param {DeviceEvent} state - device state + * @returns {void} + * */ + private onDevicesChange = (state: DeviceEvent): void => { + this.publish(MeetingEvent.MEETING_DEVICES_CHANGE, state); + }; + + /** + * @function onFrameStateChange + * @description handler for frame state change event + * @param {VideoFrameState} state - frame state + * @returns + */ + private onFrameStateChange = (state: VideoFrameState): void => { + this.logger.log('video component @ on frame state change', state); + + if (state !== VideoFrameState.INITIALIZED) return; + + this.videoManager.start({ + group: this.group, + participant: this.localParticipant, + roomId: config.get('roomId'), + }); + }; + + /** + * @function onRealtimeEventFromFrame + * @description handler for realtime event + * @param {RealtimeObserverPayload} payload - realtime event payload + * @returns {void} + * */ + private onRealtimeEventFromFrame = ({ event, data }: RealtimeObserverPayload): void => { + this.logger.log('video component @ on realtime event from frame', event, data); + + const _ = { + [RealtimeEvent.REALTIME_HOST_CHANGE]: (data: string) => this.realtime.setHost(data), + [RealtimeEvent.REALTIME_GATHER]: (data: boolean) => this.realtime.setGather(data), + [RealtimeEvent.REALTIME_GRID_MODE_CHANGE]: (data: boolean) => this.realtime.setGridMode(data), + [RealtimeEvent.REALTIME_DRAWING_CHANGE]: (data: DrawingData) => { + this.realtime.setDrawing(data); + }, + [RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT]: (data: string) => { + this.realtime.setFollowParticipant(data); + }, + [MeetingEvent.MEETING_KICK_PARTICIPANT]: (data: string) => { + this.realtime.setKickParticipant(data); + }, + [RealtimeEvent.REALTIME_TRANSCRIPT_CHANGE]: (data: TranscriptState) => { + this.realtime.setTranscript(data); + }, + }[event](data); + + this.publish(event, data); + }; + + /** + * @function onParticipantJoined + * @description handler for participant joined event + * @param {Participant} participant - participant + * @returns {void} + */ + private onParticipantJoined = (participant: Participant): void => { + this.logger.log('video component @ on participant joined', participant); + + this.localParticipant = participant; + + this.publish(MeetingEvent.MEETING_PARTICIPANT_JOINED, participant); + this.publish(MeetingEvent.MY_PARTICIPANT_JOINED, participant); + + if (this.videoConfig.canUseDefaultAvatars) { + this.realtime.updateMyProperties({ + avatar: participant.avatar, + name: participant.name, + }); + + return; + } + + this.realtime.updateMyProperties({ + name: participant.name, + }); + }; + + private onParticipantLeft = (_: Participant): void => { + this.logger.log('video component @ on participant left', this.localParticipant); + + this.publish(MeetingEvent.MY_PARTICIPANT_LEFT, this.localParticipant); + this.detach(); + }; + + /** Realtime Events */ + + /** + * @function onKickAllParticipantsDidChange + * @description handler for kick all participants event + * @param {boolean} kick - whether or not to kick all participants + * @returns {void} + */ + private onKickAllParticipantsDidChange = (kick: boolean): void => { + this.logger.log('video component @ on kick all participants did change', kick); + + this.publish(MeetingEvent.MEETING_KICK_PARTICIPANTS, kick); + this.detach(); + }; + + /** + * @function onKickLocalParticipant + * @description handler for kick local participant event + * @param {string} participantId - participant id + * @returns {void} + */ + + private onKickLocalParticipant = (): void => { + this.logger.log('video component @ on kick local participant'); + + this.publish(MeetingEvent.MEETING_KICK_PARTICIPANT, this.localParticipant); + this.detach(); + }; + + /** + * @function onRoomInfoUpdated + * @description handler for room info update event + * @param {AblyRealtimeData} room - room info + * @returns {void} + * */ + private onRoomInfoUpdated = (room: AblyRealtimeData): void => { + this.logger.log('video component @ on room info updated', room); + const { isGridModeEnable, followParticipantId, gather, drawing, transcript } = room; + + this.videoManager.publishMessageToFrame( + RealtimeEvent.REALTIME_GRID_MODE_CHANGE, + isGridModeEnable, + ); + this.videoManager.publishMessageToFrame(RealtimeEvent.REALTIME_DRAWING_CHANGE, drawing); + this.videoManager.publishMessageToFrame( + RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT, + followParticipantId, + ); + this.videoManager.publishMessageToFrame(RealtimeEvent.REALTIME_TRANSCRIPT_CHANGE, transcript); + + if (this.realtime.hostClientId === this.localParticipant.id && gather) { + this.realtime.setGather(false); + } + }; + + /** + * @function onParticipantsDidChange + * @description handler for participant list update event + * @param {Record} participants - participants + * @returns {void} + */ + private onParticipantsDidChange = (participants: Record): void => { + this.logger.log('video component @ on participants did change', participants); + + const participantList: ParticipandToFrame[] = Object.values(participants).map( + (participant: AblyParticipant) => { + return { + timestamp: participant.timestamp, + connectionId: participant.connectionId, + participantId: participant.clientId, + color: this.realtime.getSlotColor(participant.data.slotIndex).name, + name: participant.data.name, + }; + }, + ); + + if (!isEqual(this.participantList, participantList)) { + this.videoManager.publishMessageToFrame( + RealtimeEvent.REALTIME_PARTICIPANT_LIST_UPDATE, + participantList, + ); + + const participantsToPublish = Object.values(participants).map((participant) => { + return this.createParticipantFromAblyPresence(participant); + }); + + this.publish(MeetingEvent.MEETING_PARTICIPANT_LIST_UPDATE, participantsToPublish); + } + + if (this.participantList.length !== participantList.length) { + this.publish(MeetingEvent.MEETING_PARTICIPANT_AMOUNT_UPDATE, participantList.length); + } + + this.participantList = participantList; + }; + + /** + * @function onHostParticipantDidChange + * @description handler for host participant change event + * @param {HostObserverCallbackResponse} data - host change data + * @returns {void} + * */ + private onHostParticipantDidChange = (data: HostObserverCallbackResponse): void => { + this.logger.log('video component @ on host participant did change', data); + + this.videoManager.publishMessageToFrame( + RealtimeEvent.REALTIME_HOST_CHANGE, + data?.newHostParticipantId, + ); + }; + + /** + * @function onParticipantJoinedOnRealtime + * @description handler for participant joined event + * @param {AblyParticipant} participant - participant + * @returns {void} + */ + private onParticipantJoinedOnRealtime = (participant: AblyParticipant): void => { + this.logger.log('video component @ on participant joined on realtime', participant); + + this.publish( + MeetingEvent.MEETING_PARTICIPANT_JOINED, + this.createParticipantFromAblyPresence(participant), + ); + }; + + /** + * @function onParticipantLeftOnRealtime + * @description handler for participant left event + * @param {AblyParticipant} participant + * @returns {void} + */ + private onParticipantLeftOnRealtime = (participant: AblyParticipant): void => { + this.logger.log('video component @ on participant left on realtime', participant); + + this.publish( + MeetingEvent.MEETING_PARTICIPANT_LEFT, + this.createParticipantFromAblyPresence(participant), + ); + }; +} diff --git a/src/components/video/types.ts b/src/components/video/types.ts new file mode 100644 index 00000000..36c56f6c --- /dev/null +++ b/src/components/video/types.ts @@ -0,0 +1,40 @@ +import { Avatar } from '../../common/types/participant.types'; +import { DevicesOptions } from '../../common/types/sdk-options.types'; +import { + CamerasPosition, + ColorsVariables, + LayoutPosition, + Locale, + Offset, +} from '../../services/video-conference-manager/types'; + +export interface VideoComponentOptions { + camsOff?: boolean; + screenshareOff?: boolean; + chatOff?: boolean; + defaultAvatars?: boolean; + offset?: Offset; + camerasPosition?: CamerasPosition; + enableFollow?: boolean; + enableGoTo?: boolean; + enableGather?: boolean; + defaultToolbar?: boolean; + isMouseEnabled?: boolean; + isLaserEnabled?: boolean; + devices?: DevicesOptions; + language?: string; + locales?: Locale[]; + avatars?: Avatar[]; + customColors?: ColorsVariables; + skipMeetingSettings?: boolean; + disableCameraOverlay?: boolean; + layoutPosition?: LayoutPosition; +} + +export type ParticipandToFrame = { + timestamp: number; + connectionId: string; + participantId: string; + color: string; + name: string; +}; diff --git a/src/core/index.test.ts b/src/core/index.test.ts index e6d8c1a8..6a279b86 100644 --- a/src/core/index.test.ts +++ b/src/core/index.test.ts @@ -10,27 +10,6 @@ const REMOTE_CONFIG_MOCK = { conferenceLayerUrl: 'https://video-conference-layer.superviz.com/14.0.1-rc.2/index.html', }; -const COMMUNICATOR_INSTANCE_MOCK = { - setSyncProperty: jest.fn(), - subscribe: jest.fn(), - unsubscribe: jest.fn(), - destroy: jest.fn(), - follow: jest.fn(), - fetchSyncProperty: jest.fn(), - gather: jest.fn(), - goTo: jest.fn(), - toggleMeetingSetup: jest.fn(), - toggleMicrophone: jest.fn(), - toggleCam: jest.fn(), - toggleScreenShare: jest.fn(), - hangUp: jest.fn(), - toggleChat: jest.fn(), - startTranscription: jest.fn(), - stopTranscription: jest.fn(), - loadPlugin: jest.fn(), - unloadPlugin: jest.fn(), -}; - const UNIT_TEST_API_KEY = 'unit-test-api-key'; const SIMPLE_INITIALIZATION_MOCK: SuperVizSdkOptions = { @@ -57,12 +36,6 @@ jest.mock('../services/auth-service', () => ({ }), })); jest.mock('../services/remote-config-service'); -jest.mock('../services/communicator', () => ({ - __esModule: true, - default: jest.fn().mockImplementation(() => { - return COMMUNICATOR_INSTANCE_MOCK; - }), -})); beforeEach(() => { jest.clearAllMocks(); diff --git a/src/core/index.ts b/src/core/index.ts index 8752f655..e0960928 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -6,9 +6,9 @@ import AuthService from '../services/auth-service'; import { BrowserService } from '../services/browser'; import config from '../services/config'; import RemoteConfigService from '../services/remote-config-service'; -import { SuperVizSdk } from '../types'; import LauncherFacade from './launcher'; +import { LauncherFacade as LauncherFacadeType } from './launcher/types'; /** * @function validateOptions @@ -35,9 +35,9 @@ const validateOptions = ({ group, participant, roomId }: SuperVizSdkOptions): vo * @description Initialize the SDK * @param apiKey - API key * @param options - SDK options - * @returns {SuperVizSdk} + * @returns {LauncherFacadeType} */ -const init = async (apiKey: string, options: SuperVizSdkOptions): Promise => { +const init = async (apiKey: string, options: SuperVizSdkOptions): Promise => { const validApiKey = apiKey && apiKey.trim(); if (!validApiKey) throw new Error('API key is required'); diff --git a/src/core/launcher/index.test.ts b/src/core/launcher/index.test.ts index 0169049d..4a94a95a 100644 --- a/src/core/launcher/index.test.ts +++ b/src/core/launcher/index.test.ts @@ -89,6 +89,8 @@ describe('Launcher', () => { expect(MOCK_COMPONENT.attach).toHaveBeenCalledWith({ localParticipant: MOCK_LOCAL_PARTICIPANT, realtime: ABLY_REALTIME_MOCK, + group: MOCK_GROUP, + config: MOCK_CONFIG, }); }); diff --git a/src/core/launcher/index.ts b/src/core/launcher/index.ts index f708b564..b2340182 100644 --- a/src/core/launcher/index.ts +++ b/src/core/launcher/index.ts @@ -22,7 +22,6 @@ export class Launcher implements DefaultLauncher { private readonly realtime: AblyRealtimeService; private readonly pubsub: PubSub; - private components: BaseComponent[] = []; private participants: Participant[] = []; constructor({ participant, group, shouldKickParticipantsOnHostLeave }: LauncherOptions) { @@ -52,6 +51,8 @@ export class Launcher implements DefaultLauncher { component.attach({ localParticipant: this.participant, realtime: this.realtime, + group: this.group, + config: config.configuration, }); }; diff --git a/src/index.ts b/src/index.ts index 1b8dfc31..9ad979ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { MeetingControlsEvent, ParticipantEvent, } from './common/types/events.types'; +import { VideoComponent } from './components'; import init from './core'; import './web-components'; @@ -14,11 +15,11 @@ export { Participant, Group, Avatar, ParticipantType } from './common/types/part export { SuperVizSdkOptions, DevicesOptions } from './common/types/sdk-options.types'; export { BrowserService } from './services/browser'; export { BrowserStats } from './services/browser/types'; -export { PluginOptions } from './services/communicator/types'; export { PluginMethods, Plugin } from './services/integration/base-plugin/types'; export { ParticipantOn3D, ParticipantTo3D } from './services/integration/participants/types'; export { RealtimeMessage } from './services/realtime/ably/types'; -export { SuperVizSdk } from './types'; +export { LauncherFacade } from './core/launcher/types'; +export { DefaultPluginOptions as PluginOptions } from './services/integration/base-plugin/types'; if (window) { window.SuperVizSdk = { @@ -30,6 +31,7 @@ if (window) { MeetingConnectionStatus, MeetingControlsEvent, ParticipantEvent, + VideoComponent, }; } diff --git a/src/services/communicator/ index.test.ts b/src/services/communicator/ index.test.ts deleted file mode 100644 index a6bed072..00000000 --- a/src/services/communicator/ index.test.ts +++ /dev/null @@ -1,512 +0,0 @@ -import { MOCK_OBSERVER_HELPER } from '../../../__mocks__/observer-helper.mock'; -import { - MOCK_AVATAR_CONFIG, - MOCK_PARTICIPANT_TO_3D, - MOCK_PLUGIN, -} from '../../../__mocks__/plugins.mock'; -import { - ABLY_REALTIME_MOCK, - createRealtimeHistory, - createRealtimeMessage, -} from '../../../__mocks__/realtime.mock'; -import { - MeetingControlsEvent, - RealtimeEvent, - TranscriptionEvent, -} from '../../common/types/events.types'; -import { AblyRealtimeService } from '../realtime'; - -import { CommunicatorOptions, CommunicatorFacade } from './types'; - -import Communicator from '.'; - -const COMMUNICATOR_INITIALIZATION_MOCK: CommunicatorOptions = { - apiKey: 'unit-test-api-key', - ablyKey: 'unit-test-ably-key', - conferenceLayerUrl: 'https://unit-test-conference-layer-url.com', - apiUrl: 'https://unit-test-apiurl.com', - roomId: 'unit-test-room-id', - participant: { - id: 'unit-test-participant-id', - name: 'unit-test-participant-name', - }, - group: { - name: 'unit-test-group-test-name', - id: 'unit-test-group-test-id', - }, -}; - -const VideoManagerMock = { - leave: jest.fn(), - publishMessageToFrame: jest.fn(), - frameStateObserver: MOCK_OBSERVER_HELPER, - frameSizeObserver: MOCK_OBSERVER_HELPER, - realtimeObserver: MOCK_OBSERVER_HELPER, - hostChangeObserver: MOCK_OBSERVER_HELPER, - gridModeChangeObserver: MOCK_OBSERVER_HELPER, - drawingChangeObserver: MOCK_OBSERVER_HELPER, - followParticipantObserver: MOCK_OBSERVER_HELPER, - goToParticipantObserver: MOCK_OBSERVER_HELPER, - gatherParticipantsObserver: MOCK_OBSERVER_HELPER, - waitingForHostObserver: MOCK_OBSERVER_HELPER, - sameAccountErrorObserver: MOCK_OBSERVER_HELPER, - devicesObserver: MOCK_OBSERVER_HELPER, - meetingStateObserver: MOCK_OBSERVER_HELPER, - meetingConnectionObserver: MOCK_OBSERVER_HELPER, - participantJoinedObserver: MOCK_OBSERVER_HELPER, - participantLeftObserver: MOCK_OBSERVER_HELPER, -}; - -jest.mock('../realtime', () => ({ - AblyRealtimeService: jest.fn().mockImplementation(() => ABLY_REALTIME_MOCK), -})); - -jest.mock('../video-conference-manager', () => { - return jest.fn().mockImplementation(() => VideoManagerMock); -}); - -describe('Communicator', () => { - test('should be defined', () => { - expect(Communicator).toBeDefined(); - }); - - test('should exprt a function', () => { - expect(typeof Communicator).toBe('function'); - }); - - test('should return an object', () => { - expect(typeof Communicator(COMMUNICATOR_INITIALIZATION_MOCK)).toBe('object'); - }); - - test('should return an object with the expected properties', () => { - const communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - expect(communicator).toHaveProperty('setSyncProperty'); - expect(communicator).toHaveProperty('subscribe'); - expect(communicator).toHaveProperty('unsubscribe'); - expect(communicator).toHaveProperty('destroy'); - expect(communicator).toHaveProperty('follow'); - expect(communicator).toHaveProperty('fetchSyncProperty'); - expect(communicator).toHaveProperty('gather'); - expect(communicator).toHaveProperty('goTo'); - expect(communicator).toHaveProperty('toggleMeetingSetup'); - expect(communicator).toHaveProperty('toggleMicrophone'); - expect(communicator).toHaveProperty('toggleScreenShare'); - expect(communicator).toHaveProperty('hangUp'); - expect(communicator).toHaveProperty('toggleCam'); - expect(communicator).toHaveProperty('toggleChat'); - expect(communicator).toHaveProperty('startTranscription'); - expect(communicator).toHaveProperty('stopTranscription'); - expect(communicator).toHaveProperty('loadPlugin'); - expect(communicator).toHaveProperty('unloadPlugin'); - }); - - describe('setSyncProperty', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the setSyncProperty method of the realtime service', () => { - communicator.setSyncProperty('test', 'test'); - - expect(ABLY_REALTIME_MOCK.setSyncProperty).toHaveBeenCalledTimes(1); - }); - - test('should call the setSyncProperty method of the realtime service with the expected arguments', () => { - communicator.setSyncProperty('test', 'test'); - - expect(ABLY_REALTIME_MOCK.setSyncProperty).toHaveBeenCalledWith('test', 'test'); - }); - }); - - describe('subscribe', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the subscribe method', () => { - const callback = jest.fn(); - jest.spyOn(communicator, 'subscribe'); - communicator.subscribe('test', callback); - - expect(communicator.subscribe).toBeCalledWith('test', callback); - }); - }); - - describe('unsubscribe', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the unsubscribe method', () => { - jest.spyOn(communicator, 'unsubscribe'); - communicator.subscribe('test', jest.fn()); - communicator.unsubscribe('test'); - - expect(communicator.unsubscribe).toBeCalledWith('test'); - }); - - test('should skip the unsubscribe method if the event is not subscribed', () => { - jest.spyOn(communicator, 'unsubscribe'); - communicator.unsubscribe('test'); - - expect(communicator.unsubscribe).toBeCalledWith('test'); - }); - }); - - describe('destroy', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the destroy method', () => { - jest.spyOn(communicator, 'destroy'); - communicator.destroy(); - - expect(communicator.destroy).toBeCalledTimes(1); - expect(ABLY_REALTIME_MOCK.leave).toBeCalledTimes(1); - expect(VideoManagerMock.leave).toBeCalledTimes(1); - expect(ABLY_REALTIME_MOCK.roomInfoUpdatedObserver.unsubscribe).toBeCalled(); - expect(ABLY_REALTIME_MOCK.participantsObserver.unsubscribe).toBeCalled(); - expect(ABLY_REALTIME_MOCK.participantJoinedObserver.unsubscribe).toBeCalled(); - expect(ABLY_REALTIME_MOCK.participantLeaveObserver.unsubscribe).toBeCalled(); - expect(ABLY_REALTIME_MOCK.hostObserver.unsubscribe).toBeCalled(); - expect(ABLY_REALTIME_MOCK.syncPropertiesObserver.unsubscribe).toBeCalled(); - expect(ABLY_REALTIME_MOCK.kickAllParticipantsObserver.unsubscribe).toBeCalled(); - expect(ABLY_REALTIME_MOCK.authenticationObserver.unsubscribe).toBeCalled(); - expect(VideoManagerMock.frameStateObserver.unsubscribe).toBeCalled(); - expect(VideoManagerMock.frameSizeObserver.unsubscribe).toBeCalled(); - expect(VideoManagerMock.realtimeObserver.unsubscribe).toBeCalled(); - expect(VideoManagerMock.hostChangeObserver.unsubscribe).toBeCalled(); - expect(VideoManagerMock.followParticipantObserver.unsubscribe).toBeCalled(); - expect(VideoManagerMock.goToParticipantObserver.unsubscribe).toBeCalled(); - expect(VideoManagerMock.gatherParticipantsObserver.unsubscribe).toBeCalled(); - expect(VideoManagerMock.gridModeChangeObserver.unsubscribe).toBeCalled(); - expect(VideoManagerMock.drawingChangeObserver.unsubscribe).toBeCalled(); - expect(VideoManagerMock.sameAccountErrorObserver.unsubscribe).toBeCalled(); - expect(VideoManagerMock.devicesObserver.unsubscribe).toBeCalled(); - expect(VideoManagerMock.participantLeftObserver.unsubscribe).toBeCalled(); - expect(VideoManagerMock.meetingStateObserver.unsubscribe).toBeCalled(); - expect(VideoManagerMock.meetingConnectionObserver.unsubscribe).toBeCalled(); - }); - }); - - describe('follow', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the follow method', () => { - jest.spyOn(communicator, 'follow'); - communicator.follow('test'); - - expect(communicator.follow).toBeCalledWith('test'); - expect(VideoManagerMock.publishMessageToFrame).toBeCalledWith( - RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT, - 'test', - ); - expect(ABLY_REALTIME_MOCK.setFollowParticipant).toBeCalledWith('test'); - }); - }); - - describe('fetchSyncProperty', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the fetchSyncProperty and return the last message of type test', () => { - jest.spyOn(communicator, 'fetchSyncProperty'); - const history = communicator.fetchSyncProperty('test'); - - expect(communicator.fetchSyncProperty).toBeCalledWith('test'); - expect(ABLY_REALTIME_MOCK.fetchSyncClientProperty).toBeCalledWith('test'); - expect(history).toEqual(createRealtimeMessage('test')); - }); - - test('should call the fetchSyncProperty and return the realtime history', () => { - jest.spyOn(communicator, 'fetchSyncProperty'); - const history = communicator.fetchSyncProperty(); - - expect(communicator.fetchSyncProperty).toBeCalled(); - expect(ABLY_REALTIME_MOCK.fetchSyncClientProperty).toBeCalled(); - expect(history).toEqual(createRealtimeHistory()); - }); - }); - - describe('toggleMeetingSetup', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the toggleMeetingSetup method', () => { - jest.spyOn(communicator, 'toggleMeetingSetup'); - communicator.toggleMeetingSetup(); - - expect(communicator.toggleMeetingSetup).toBeCalled(); - expect(VideoManagerMock.publishMessageToFrame).toBeCalledWith( - MeetingControlsEvent.TOGGLE_MEETING_SETUP, - ); - }); - }); - - describe('toggleMicrophone', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the toggleMicrophone method', () => { - jest.spyOn(communicator, 'toggleMicrophone'); - communicator.toggleMicrophone(); - - expect(communicator.toggleMicrophone).toBeCalled(); - expect(VideoManagerMock.publishMessageToFrame).toBeCalledWith( - MeetingControlsEvent.TOGGLE_MICROPHONE, - ); - }); - }); - - describe('toggleCam', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the toggleCam method', () => { - jest.spyOn(communicator, 'toggleCam'); - communicator.toggleCam(); - - expect(communicator.toggleCam).toBeCalled(); - expect(VideoManagerMock.publishMessageToFrame).toBeCalledWith( - MeetingControlsEvent.TOGGLE_CAM, - ); - }); - }); - - describe('toggleScreenShare', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the toggleScreenShare method', () => { - jest.spyOn(communicator, 'toggleScreenShare'); - communicator.toggleScreenShare(); - - expect(communicator.toggleScreenShare).toBeCalled(); - expect(VideoManagerMock.publishMessageToFrame).toBeCalledWith( - MeetingControlsEvent.TOGGLE_SCREENSHARE, - ); - }); - }); - - describe('hangUp', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the hangUp method', () => { - jest.spyOn(communicator, 'hangUp'); - communicator.hangUp(); - - expect(communicator.hangUp).toBeCalled(); - expect(VideoManagerMock.publishMessageToFrame).toBeCalledWith(MeetingControlsEvent.HANG_UP); - }); - }); - - describe('toggleChat', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the toggleChat method', () => { - jest.spyOn(communicator, 'toggleChat'); - communicator.toggleChat(); - - expect(communicator.toggleChat).toBeCalled(); - expect(VideoManagerMock.publishMessageToFrame).toBeCalledWith( - MeetingControlsEvent.TOGGLE_MEETING_CHAT, - ); - }); - }); - - describe('startTranscription', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the startTranscription method', () => { - jest.spyOn(communicator, 'startTranscription'); - communicator.startTranscription('en-US'); - - expect(communicator.startTranscription).toBeCalled(); - expect(VideoManagerMock.publishMessageToFrame).toBeCalledWith( - TranscriptionEvent.TRANSCRIPTION_START, - 'en-US', - ); - }); - }); - - describe('stopTranscription', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the stopTranscription method', () => { - jest.spyOn(communicator, 'stopTranscription'); - communicator.stopTranscription(); - - expect(communicator.stopTranscription).toBeCalled(); - expect(VideoManagerMock.publishMessageToFrame).toBeCalledWith( - TranscriptionEvent.TRANSCRIPTION_STOP, - undefined, - ); - }); - }); - - describe('loadPlugin', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the loadPlugin method', () => { - jest.spyOn(communicator, 'loadPlugin'); - communicator.loadPlugin(MOCK_PLUGIN, { - plugin: MOCK_PLUGIN, - avatarConfig: MOCK_AVATAR_CONFIG, - localParticipant: MOCK_PARTICIPANT_TO_3D, - RealtimeService: ABLY_REALTIME_MOCK as unknown as AblyRealtimeService, - }); - - expect(communicator.loadPlugin).toBeCalled(); - }); - - test('should throw an error if the plugin is loaded twice', () => { - jest.spyOn(communicator, 'loadPlugin'); - - communicator.loadPlugin(MOCK_PLUGIN, { - plugin: MOCK_PLUGIN, - avatarConfig: MOCK_AVATAR_CONFIG, - localParticipant: MOCK_PARTICIPANT_TO_3D, - RealtimeService: ABLY_REALTIME_MOCK as unknown as AblyRealtimeService, - }); - - expect(communicator.loadPlugin).toBeCalled(); - expect(communicator.loadPlugin).toThrowError('the 3D plugin has already been started'); - }); - }); - - describe('unloadPlugin', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the unloadPlugin method', () => { - jest.spyOn(communicator, 'unloadPlugin'); - communicator.loadPlugin(MOCK_PLUGIN, { - plugin: MOCK_PLUGIN, - avatarConfig: MOCK_AVATAR_CONFIG, - localParticipant: MOCK_PARTICIPANT_TO_3D, - RealtimeService: ABLY_REALTIME_MOCK as unknown as AblyRealtimeService, - }); - - communicator.unloadPlugin(); - - expect(communicator.unloadPlugin).toBeCalled(); - expect(MOCK_PLUGIN.destroy).toBeCalled(); - }); - - test('should skip the unloadPlugin method if the plugin is not loaded', () => { - jest.spyOn(communicator, 'unloadPlugin'); - communicator.unloadPlugin(); - - expect(communicator.unloadPlugin).toBeCalled(); - expect(MOCK_PLUGIN.destroy).not.toBeCalled(); - }); - }); - - describe('gather', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the gather method', () => { - jest.spyOn(communicator, 'gather'); - communicator.gather(); - - expect(communicator.gather).toBeCalled(); - expect(ABLY_REALTIME_MOCK.setGather).toBeCalled(); - }); - }); - - describe('goTo', () => { - let communicator: CommunicatorFacade; - - beforeEach(() => { - jest.clearAllMocks(); - communicator = Communicator(COMMUNICATOR_INITIALIZATION_MOCK); - }); - - test('should call the goTo method', () => { - jest.spyOn(communicator, 'goTo'); - communicator.loadPlugin(MOCK_PLUGIN, { - plugin: MOCK_PLUGIN, - avatarConfig: MOCK_AVATAR_CONFIG, - localParticipant: MOCK_PARTICIPANT_TO_3D, - RealtimeService: ABLY_REALTIME_MOCK as unknown as AblyRealtimeService, - }); - - communicator.goTo('test'); - - expect(communicator.goTo).toBeCalled(); - expect(MOCK_PLUGIN.goToParticipant).toBeCalled(); - }); - }); -}); diff --git a/src/services/communicator/index.ts b/src/services/communicator/index.ts deleted file mode 100644 index 8410cd9d..00000000 --- a/src/services/communicator/index.ts +++ /dev/null @@ -1,845 +0,0 @@ -import isEqual from 'lodash.isequal'; - -import { - DeviceEvent, - Dimensions, - FrameEvent, - MeetingConnectionStatus, - MeetingControlsEvent, - MeetingEvent, - MeetingState, - RealtimeEvent, - TranscriptionEvent, -} from '../../common/types/events.types'; -import { Participant, Group } from '../../common/types/participant.types'; -import { Observer, Logger } from '../../common/utils'; -import { BrowserService } from '../browser'; -import { ConnectionService } from '../connection-status'; -import { IntegrationManager } from '../integration'; -import { Plugin, PluginMethods } from '../integration/base-plugin/types'; -import { AblyRealtimeService } from '../realtime'; -import { AblyRealtimeData, AblyParticipant, RealtimeMessage } from '../realtime/ably/types'; -import { HostObserverCallbackResponse, ParticipantInfo } from '../realtime/base/types'; -import VideoConferencingManager from '../video-conference-manager'; -import { - DrawingData, - VideoFrameState, - VideoManagerOptions, -} from '../video-conference-manager/types'; - -import { - CommunicatorFacade, - CommunicatorOptions, - PluginOptions, - ParticipandToFrame, -} from './types'; - -class Communicator { - private readonly logger: Logger; - private readonly realtime: AblyRealtimeService; - private readonly connectionService: ConnectionService; - private readonly browserService: BrowserService; - private integrationManager: IntegrationManager | null = null; - private videoManager: VideoConferencingManager; - - private readonly roomId: string; - private readonly group: Group; - - private observers: Record = {}; - private participant: Participant; - private participantList: Participant[] = []; - private isBroadcast: boolean = false; - - constructor({ - conferenceLayerUrl, - apiUrl, - apiKey, - debug = false, - language, - roomId, - ablyKey, - group, - participant, - shouldKickParticipantsOnHostLeave, - camsOff, - screenshareOff, - chatOff, - defaultAvatars, - offset, - enableFollow, - enableGoTo, - enableGather, - defaultToolbar, - locales, - avatars, - devices, - customColors, - waterMark, - camerasPosition, - skipMeetingSettings = false, - disableCameraOverlay = false, - layoutPosition, - }: CommunicatorOptions) { - this.roomId = roomId; - this.group = group; - this.participant = participant; - - this.logger = new Logger('@superviz/sdk/communicator'); - this.realtime = new AblyRealtimeService(apiUrl, ablyKey); - this.browserService = new BrowserService(); - - const canUseCams = !camsOff; - const canUseScreenshare = !screenshareOff; - const canUseChat = !chatOff; - const canUseDefaultAvatars = !!defaultAvatars && !participant?.avatar?.model; - const canUseDefaultToolbar = defaultToolbar ?? true; - - const canUseFollow = !!enableFollow; - const canUseGoTo = !!enableGoTo; - const canUseGather = !!enableGather; - - if (participant?.avatar === undefined) { - this.participant = Object.assign({}, this.participant, { - avatar: { - model: '', - }, - }); - } - - this.connectionService = new ConnectionService(); - this.connectionService.addListeners(); - - // Connection observers - this.connectionService.connectionStatusObserver.subscribe(this.onConnectionStatusChange); - - this.startVideo({ - conferenceLayerUrl, - canUseChat, - canUseCams, - canUseScreenshare, - canUseDefaultAvatars, - canUseFollow, - canUseGoTo, - canUseGather, - canUseDefaultToolbar, - devices, - ablyKey, - apiKey, - apiUrl, - debug, - language, - roomId, - browserService: this.browserService, - offset, - locales: locales ?? [], - avatars: avatars ?? [], - customColors, - waterMark, - camerasPosition, - skipMeetingSettings, - disableCameraOverlay, - layoutPosition, - }); - - // Realtime observers - this.realtime.roomInfoUpdatedObserver.subscribe(this.onRoomInfoUpdated); - this.realtime.participantsObserver.subscribe(this.onParticipantsDidChange); - this.realtime.participantJoinedObserver.subscribe(this.onParticipantJoined); - this.realtime.participantLeaveObserver.subscribe(this.onParticipantLeft); - this.realtime.hostObserver.subscribe(this.onHostParticipantDidChange); - this.realtime.syncPropertiesObserver.subscribe(this.onSyncPropertiesDidChange); - this.realtime.kickAllParticipantsObserver.subscribe(this.onKickAllParticipantsDidChange); - this.realtime.authenticationObserver.subscribe(this.onAuthenticationFailed); - - this.realtime.start({ - participant: this.participant, - roomId: this.roomId, - apiKey, - shouldKickParticipantsOnHostLeave: shouldKickParticipantsOnHostLeave ?? true, - }); - } - - private get isIntegrationManagerInitialized(): boolean { - return !!this.integrationManager; - } - - private checkBroadcastMode(): void { - this.isBroadcast = this.participantList.some((participant) => participant.type === 'audience'); - } - - public start() { - this.videoManager.start({ - roomId: this.roomId, - participant: this.participant, - group: this.group, - }); - - this.publish(MeetingEvent.MEETING_START); - } - - /** - * @function destroy - * @description Destroy the communicator instance - * @return {void} - */ - public destroy(): void { - this.publish(MeetingEvent.DESTROY, undefined); - - this.videoManager.frameStateObserver.unsubscribe(this.onFrameStateDidChange); - this.videoManager.frameSizeObserver.unsubscribe(this.onFrameSizeDidChange); - - this.videoManager.realtimeObserver.unsubscribe(this.onRealtimeJoin); - this.videoManager.hostChangeObserver.unsubscribe(this.onHostDidChange); - this.videoManager.followParticipantObserver.unsubscribe(this.onFollowParticipantDidChange); - this.videoManager.gridModeChangeObserver.unsubscribe(this.onGridModeDidChange); - this.videoManager.drawingChangeObserver.unsubscribe(this.onDrawingDidChange); - - this.videoManager.goToParticipantObserver.unsubscribe(this.onGoToParticipantDidChange); - this.videoManager.gatherParticipantsObserver.unsubscribe(this.onGatherDidChange); - this.videoManager.sameAccountErrorObserver.unsubscribe(this.onSameAccountError); - this.videoManager.devicesObserver.unsubscribe(this.onDevicesChange); - this.videoManager.participantLeftObserver.unsubscribe(this.onMyParticipantLeft); - - this.videoManager.meetingStateObserver.unsubscribe(this.onMeetingStateUpdate); - this.videoManager.meetingConnectionObserver.unsubscribe( - this.connectionService.updateMeetingConnectionStatus, - ); - - this.realtime.roomInfoUpdatedObserver.unsubscribe(this.onRoomInfoUpdated); - this.realtime.participantsObserver.unsubscribe(this.onParticipantsDidChange); - this.realtime.hostObserver.unsubscribe(this.onHostParticipantDidChange); - this.realtime.syncPropertiesObserver.unsubscribe(this.onSyncPropertiesDidChange); - this.realtime.kickAllParticipantsObserver.unsubscribe(this.onKickAllParticipantsDidChange); - this.realtime.authenticationObserver.unsubscribe(this.onAuthenticationFailed); - this.realtime.participantJoinedObserver.unsubscribe(this.onParticipantJoined); - this.realtime.participantLeaveObserver.unsubscribe(this.onParticipantLeft); - - this.connectionService.connectionStatusObserver.unsubscribe(this.onConnectionStatusChange); - - Object.keys(this.observers).forEach((type) => this.unsubscribe(type)); - this.videoManager.leave(); - this.realtime.leave(); - this.connectionService.removeListeners(); - this.unloadPlugin(); - } - - /** - * @function setSyncProperty - * @description Set a property to be synced with other participants - * @param name - name of the property - * @param value - value of the property - * @returns {void} - */ - public setSyncProperty = (name: string, value?: unknown): void => { - this.realtime.setSyncProperty(name, value); - }; - - /** - * @function subscribe - * @description Subscribe to an event - * @param type - event type - * @param listener - event callback - * @returns {void} - */ - public subscribe = (type: string, listener: Function): void => { - if (!this.observers[type]) { - this.observers[type] = new Observer({ logger: this.logger }); - } - - this.observers[type].subscribe(listener); - }; - - /** - * @function unsubscribe - * @description Unsubscribe from an event - * @param type - event type - * @returns {void} - */ - public unsubscribe = (type: string): void => { - if (this.observers[type]) { - this.observers[type].reset(); - delete this.observers[type]; - } - }; - - /** - * @function publishMeetingControlEvent - * @param event: MeetingControlsEvent - * @description publish event to meeting controls - * @returns {void} - */ - public publishMeetingControlEvent(event: MeetingControlsEvent): void { - this.videoManager.publishMessageToFrame(event); - } - - /** - * @function fetchSyncClientProperty - * @description get realtime client data history - * @returns {RealtimeMessage | Record} - */ - public fetchSyncClientProperty( - eventName?: string, - ): Promise> { - return this.realtime.fetchSyncClientProperty(eventName); - } - - /** - * @function follow - * @description - send follow message to all participants on plugin - * @param participantId: string - * @returns {void} - */ - public follow(participantId?: string): void { - this.videoManager.publishMessageToFrame( - RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT, - participantId, - ); - this.realtime.setFollowParticipant(participantId); - } - - /** - * @function gather - * @description send gather message to all participants on plugin - * @returns {void} - */ - public gather(): void { - this.realtime.setGather(true); - } - - /** - * @function goTo - * @description call goTo on plugin - * @param participantId: string - * @returns {void} - */ - public goTo(participantId: string): void { - this.integrationManager.goToParticipant(participantId); - } - - /** - * @function publishTranscriptionEvent - * @description publish transcription event to transcription service - * @param {TranscriptionEvent} event - event to be published - * @param {unknown} payload - payload to be sent to transcription service - */ - public publishTranscriptionEvent = (event: TranscriptionEvent, payload?: unknown): void => { - this.videoManager.publishMessageToFrame(event, payload); - }; - - /** - * @function startVideo - * @description start video manager - * @param {VideoManagerOptions} options - video manager params - * @returns {void} - */ - private startVideo = (options: VideoManagerOptions): void => { - this.videoManager = new VideoConferencingManager(options); - - // Video observers - this.videoManager.frameStateObserver.subscribe(this.onFrameStateDidChange); - this.videoManager.frameSizeObserver.subscribe(this.onFrameSizeDidChange); - - this.videoManager.realtimeObserver.subscribe(this.onRealtimeJoin); - this.videoManager.hostChangeObserver.subscribe(this.onHostDidChange); - this.videoManager.followParticipantObserver.subscribe(this.onFollowParticipantDidChange); - this.videoManager.goToParticipantObserver.subscribe(this.onGoToParticipantDidChange); - this.videoManager.gatherParticipantsObserver.subscribe(this.onGatherDidChange); - this.videoManager.gridModeChangeObserver.subscribe(this.onGridModeDidChange); - this.videoManager.drawingChangeObserver.subscribe(this.onDrawingDidChange); - - this.videoManager.sameAccountErrorObserver.subscribe(this.onSameAccountError); - this.videoManager.waitingForHostObserver.subscribe(this.onWaitingForHost); - this.videoManager.devicesObserver.subscribe(this.onDevicesChange); - this.videoManager.participantLeftObserver.subscribe(this.onMyParticipantLeft); - this.videoManager.meetingStateObserver.subscribe(this.onMeetingStateUpdate); - this.videoManager.meetingConnectionObserver.subscribe( - this.connectionService.updateMeetingConnectionStatus, - ); - }; - - /** - * @function publish - * @description Publish an event to client - * @param type - event type - * @param data - event data - * @returns {void} - */ - private publish = (type: string, data?: unknown): void => { - const hasListenerRegistered = type in this.observers; - - if (hasListenerRegistered) { - this.observers[type].publish(data); - } - }; - - /** - * @function onSyncPropertiesDidChange - * @property {Record} properties - properties that changed - * @description handler for client sync properties change - * @returns {void} - */ - private onSyncPropertiesDidChange = (properties: Record): void => { - Object.entries(properties).forEach(([key, value]) => { - this.publish(key, value); - }); - }; - - /** - * @function onKickAllParticipantsDidChange - * @description handler for kick all participants event - * @param {boolean} kick - whether or not to kick all participants - * @returns {void} - */ - private onKickAllParticipantsDidChange = (kick: boolean): void => { - this.publish(MeetingEvent.MEETING_KICK_PARTICIPANTS, kick); - this.destroy(); - }; - - /** - * @function onRealtimeJoin - * @description handler for realtime join event - * @param {ParticipantInfo} participantInfo - participant info - * @returns {void} - */ - private onRealtimeJoin = (participant: Participant): void => { - this.realtime.join(participant); - }; - - /** - * @function onHostDidChange - * @description handler for host change event - * @param {string} hostId - host id - * @returns {void} - */ - private onHostDidChange = (hostId: string): void => { - this.realtime.setHost(hostId); - }; - - /** - * @function onFollowParticipantDidChange - * @description handler for follow participant change event - * @param {string} participantId - participant id - * @returns {void} - * */ - private onFollowParticipantDidChange = (participantId?: string): void => { - this.realtime.setFollowParticipant(participantId); - this.setSyncProperty(RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT, participantId); - }; - - /** - * @function onGoToParticipantDidChange - * @description handler for go to participant change event - * @param {string} participantId - participant id - * @returns {void} - * */ - private onGoToParticipantDidChange = (participantId: string): void => { - this.integrationManager?.goToParticipant(participantId); - this.setSyncProperty(RealtimeEvent.REALTIME_GO_TO_PARTICIPANT, participantId); - }; - - /** - * @function onGatherDidChange - * @description handler for gather change event - * @returns {void} - * */ - private onGatherDidChange = (): void => { - this.realtime.setGather(true); - this.setSyncProperty(RealtimeEvent.REALTIME_GATHER); - }; - - /** - * @function onFrameStateDidChange - * @param {VideoFrameState} state - frame state - * @returns {void} - */ - private onFrameStateDidChange = (state: VideoFrameState): void => { - if (state !== VideoFrameState.INITIALIZED) return; - - this.start(); - }; - - /** - * @function onFrameSizeDidChange - * @description handler for frame size change event - * @param {Dimensions} dimensions - frame dimensions - * @returns {void} - * */ - private onFrameSizeDidChange = (dimensions: Dimensions): void => { - this.publish(FrameEvent.FRAME_DIMENSIONS_UPDATE, dimensions); - }; - - /** - * @function onWaitingForHost - * @description handler for waiting for host event - * @param {boolean} waiting - whether or not waiting for host - * @returns {void} - */ - private onWaitingForHost = (waiting: boolean): void => { - this.publish(MeetingEvent.MEETING_WAITING_FOR_HOST, waiting); - }; - - /** - * @function onRoomInfoUpdated - * @description handler for room info update event - * @param {AblyRealtimeData} room - room info - * @returns {void} - * */ - private onRoomInfoUpdated = (room: AblyRealtimeData): void => { - const { isGridModeEnable, followParticipantId, gather, drawing } = room; - - this.videoManager.publishMessageToFrame( - RealtimeEvent.REALTIME_GRID_MODE_CHANGE, - isGridModeEnable, - ); - this.videoManager.publishMessageToFrame(RealtimeEvent.REALTIME_DRAWING_CHANGE, drawing); - this.videoManager.publishMessageToFrame( - RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT, - followParticipantId, - ); - - if (this.realtime.hostClientId === this.participant.id && gather) { - this.realtime.setGather(false); - } - }; - - /** - * @function onParticipantsDidChange - * @description handler for participant list update event - * @param {Record} participants - participants - * @returns {void} - */ - private onParticipantsDidChange = (participants: Record): void => { - const participantListForVideoFrame: ParticipandToFrame[] = Object.values(participants).map( - (participant: AblyParticipant) => { - return { - timestamp: participant.timestamp, - connectionId: participant.connectionId, - participantId: participant.clientId, - color: this.realtime.getSlotColor(participant.data.slotIndex).name, - name: participant.data.name, - }; - }, - ); - - // update participant list - const participantList = this.updateParticipantListFromAblyList(participants); - const localParticipant = participantList.find((participant) => { - return participant?.id === this.participant?.id; - }); - - if (!isEqual(this.participantList, participantList)) { - this.publish(MeetingEvent.MEETING_PARTICIPANT_LIST_UPDATE, this.participantList); - this.videoManager.publishMessageToFrame( - RealtimeEvent.REALTIME_PARTICIPANT_LIST_UPDATE, - participantListForVideoFrame, - ); - } - - if (this.participantList.length !== participantList.length) { - this.publish(MeetingEvent.MEETING_PARTICIPANT_AMOUNT_UPDATE, participantList.length); - } - - if (localParticipant && !isEqual(this.participant, localParticipant)) { - this.participant = localParticipant; - this.publish(MeetingEvent.MY_PARTICIPANT_UPDATED, this.participant); - } - - this.participantList = participantList; - }; - - /** - * @function onHostParticipantDidChange - * @description handler for host participant change event - * @param {HostObserverCallbackResponse} data - host change data - * @returns {void} - * */ - private onHostParticipantDidChange = (data: HostObserverCallbackResponse): void => { - const newHost = this.participantList.find((participant: Participant) => { - return participant.id === data?.newHostParticipantId; - }); - - this.videoManager.publishMessageToFrame( - RealtimeEvent.REALTIME_HOST_CHANGE, - data?.newHostParticipantId, - ); - - if (this.realtime.isLocalParticipantHost) { - this.setSyncProperty(MeetingEvent.MEETING_HOST_CHANGE, newHost); - } - }; - - /** - * @function onGridModeDidChange - * @description handler for grid mode change event - * @param {boolean} isGridModeEnable - is grid mode enable - * @returns {void} - * */ - private onGridModeDidChange = (isGridModeEnable: boolean): void => { - this.realtime.setGridMode(isGridModeEnable); - }; - - /** - * @function onDrawingDidChange - * @description handler when drawing event - * @param drawing {DrawingData} - drawing payload - * @returns {void} - * */ - private onDrawingDidChange = (drawing: DrawingData): void => { - this.realtime.setDrawing(drawing); - }; - - /** - * @function updateParticipantListFromAblyList - * @description update participant list from ably participant list - * @param {Record} participants - ably participant list - * @returns {Participant[]} participant list - * */ - private updateParticipantListFromAblyList = ( - participants: Record, - ): Participant[] => { - const participantList = Object.values(participants).map((participant: AblyParticipant) => ({ - id: participant.clientId, - color: this.realtime.getSlotColor(participant.data?.slotIndex).color, - avatarConfig: participant.data.avatarConfig, - avatar: participant.data.avatar, - type: participant.data.type, - name: participant.data.name, - isHost: this.realtime.hostClientId === participant.clientId, - })); - - return participantList; - }; - - /** - * @function onSameAccountError - * @description handler for same account error event - * @param {string} error - error message - * @returns {void} - * */ - private onSameAccountError = (error: string): void => { - this.publish(MeetingEvent.MEETING_SAME_PARTICIPANT_ERROR, error); - this.destroy(); - }; - - /** - * @function onDevicesChange - * @description handler for devices change event - * @param {DeviceEvent} state - device state - * @returns {void} - * */ - private onDevicesChange = (state: DeviceEvent): void => { - this.publish(MeetingEvent.MEETING_DEVICES_CHANGE, state); - }; - - /** - * @function onParticipantJoined - * @description handler for participant joined event - * @param {AblyParticipant} ablyParticipant - ably participant - * @returns {void} - * */ - private onParticipantJoined = (ablyParticipant: AblyParticipant): void => { - this.checkBroadcastMode(); - const participant = this.participantList.find( - (participantItem) => participantItem.id === ablyParticipant.data.id, - ); - - if (!participant) return; - - if (participant.id === this.participant.id) { - this.publish(MeetingEvent.MY_PARTICIPANT_JOINED, participant); - } - this.publish(MeetingEvent.MEETING_PARTICIPANT_JOINED, participant); - }; - - /** - * @function onParticipantLeft - * @description handler for participant left event - * @param {AblyParticipant} ablyParticipant - ably participant - * @returns {void} - * */ - private onParticipantLeft = (ablyParticipant: AblyParticipant): void => { - const participant = this.participantList.find( - (participantItem) => participantItem.id === ablyParticipant.data.id, - ); - this.publish(MeetingEvent.MEETING_PARTICIPANT_LEFT, participant); - }; - - /** - * @function onMyParticipantLeft - * @description handler for my participant left event - * @returns {void} - * */ - private onMyParticipantLeft = (): void => { - this.publish(MeetingEvent.MY_PARTICIPANT_LEFT, this.participant); - this.destroy(); - }; - - /** - * @function onAuthenticationFailed - * @description handler for authentication failed event - * @param {RealtimeEvent} event - event name - * @returns {void} - */ - private onAuthenticationFailed = (event: RealtimeEvent): void => { - this.publish(RealtimeEvent.REALTIME_AUTHENTICATION_FAILED, event); - this.destroy(); - }; - - /** - * @function onMeetingStateUpdate - * @description handler for meeting state update event - * @param {MeetingState} newState - new meeting state - * @returns {void} - */ - private onMeetingStateUpdate = (newState: MeetingState): void => { - this.logger.log('MEETING STATE', newState); - this.publish(MeetingEvent.MEETING_STATE_UPDATE, newState); - }; - - /** - * @function onCOnnectionStatusChange - * @description handler for connection status change event - * @param {MeetingConnectionStatus} newStatus - new connection status - * @returns {void} - */ - private onConnectionStatusChange = (newStatus: MeetingConnectionStatus): void => { - const connectionProblemStatus = [ - MeetingConnectionStatus.BAD, - MeetingConnectionStatus.DISCONNECTED, - MeetingConnectionStatus.POOR, - MeetingConnectionStatus.LOST_CONNECTION, - ]; - - if (connectionProblemStatus.includes(newStatus)) { - this.realtime.freezeSync(true); - } - - if ( - connectionProblemStatus.includes(this.connectionService.oldConnectionStatus) && - !connectionProblemStatus.includes(newStatus) - ) { - this.realtime.freezeSync(false); - } - - this.publish(MeetingEvent.MEETING_CONNECTION_STATUS_CHANGE, newStatus); - }; - - /** - * @function loadPlugin - * @description load plugin - * @param {Plugin} plugin - plugin - * @param {PluginOptions} pluginOptions - plugin options - * @returns {PluginMethods} - * */ - public loadPlugin(plugin: Plugin, pluginOptions: PluginOptions): PluginMethods { - if (this.isIntegrationManagerInitialized) { - throw new Error('the 3D plugin has already been started'); - } - - if (pluginOptions.avatarConfig) { - this.realtime.setParticipantData({ avatarConfig: pluginOptions.avatarConfig }); - } - - if (this.participant.avatar && this.participant.avatar.model) { - this.realtime.setParticipantData({ avatar: this.participant.avatar }); - } - - let participants = []; - - if (this.realtime.getParticipants) { - participants = Object.values(this.realtime.getParticipants); - } - - this.integrationManager = new IntegrationManager({ - plugin, - ...pluginOptions, - localParticipant: { - id: this.participant.id, - name: this.participant.name, - avatar: this.participant.avatar, - avatarConfig: pluginOptions.avatarConfig, - }, - participantList: participants.map((participant) => { - const id = participant.clientId; - const { name, avatar, avatarConfig, slotIndex, isAudience } = participant.data; - return { - id, - name, - avatar, - avatarConfig, - slotIndex, - isAudience, - }; - }), - RealtimeService: this.realtime, - }); - - return { - enableAvatars: this.integrationManager?.enableAvatars, - disableAvatars: this.integrationManager?.disableAvatars, - enableMouse: this.integrationManager?.enableMouse, - disableMouse: this.integrationManager?.disableMouse, - enableLaser: this.integrationManager?.enableLaser, - disableLaser: this.integrationManager?.disableLaser, - getParticipantsOn3D: () => { - return this.integrationManager?.participants ? this.integrationManager.participants : []; - }, - getAvatars: () => this.integrationManager?.getAvatars, - }; - } - - /** - * @function unloadPlugin - * @description unload plugin - * @returns {void} - * */ - public unloadPlugin(): void { - if (this.integrationManager) { - this.integrationManager.plugin.destroy(); - this.integrationManager = null; - } - } -} - -export default (params: CommunicatorOptions): CommunicatorFacade => { - const communicator = new Communicator(params); - - return { - setSyncProperty: (name, property) => communicator.setSyncProperty(name, property), - subscribe: (propertyName, listener) => communicator.subscribe(propertyName, listener), - unsubscribe: (propertyName) => communicator.unsubscribe(propertyName), - destroy: () => communicator.destroy(), - follow: (participantId) => communicator.follow(participantId), - fetchSyncProperty: (eventName?: string) => communicator.fetchSyncClientProperty(eventName), - gather: () => communicator.gather(), - goTo: (participantId) => communicator.goTo(participantId), - - toggleMeetingSetup: () => { - return communicator.publishMeetingControlEvent(MeetingControlsEvent.TOGGLE_MEETING_SETUP); - }, - toggleMicrophone: () => { - return communicator.publishMeetingControlEvent(MeetingControlsEvent.TOGGLE_MICROPHONE); - }, - toggleCam: () => communicator.publishMeetingControlEvent(MeetingControlsEvent.TOGGLE_CAM), - toggleScreenShare: () => { - return communicator.publishMeetingControlEvent(MeetingControlsEvent.TOGGLE_SCREENSHARE); - }, - hangUp: () => communicator.publishMeetingControlEvent(MeetingControlsEvent.HANG_UP), - toggleChat: () => { - return communicator.publishMeetingControlEvent(MeetingControlsEvent.TOGGLE_MEETING_CHAT); - }, - - startTranscription: (language) => { - return communicator.publishTranscriptionEvent( - TranscriptionEvent.TRANSCRIPTION_START, - language, - ); - }, - stopTranscription: () => { - return communicator.publishTranscriptionEvent(TranscriptionEvent.TRANSCRIPTION_STOP); - }, - - loadPlugin: (plugin, props) => communicator.loadPlugin(plugin, props), - unloadPlugin: () => communicator.unloadPlugin(), - }; -}; diff --git a/src/services/communicator/types.ts b/src/services/communicator/types.ts deleted file mode 100644 index bd21a4cb..00000000 --- a/src/services/communicator/types.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { OldSuperVizSdkOptions } from '../../common/types/sdk-options.types'; -import { Plugin, PluginMethods, DefaultPluginOptions } from '../integration/base-plugin/types'; -import { AvatarConfig } from '../integration/participants/types'; -import { RealtimeMessage } from '../realtime/ably/types'; -import { LayoutPosition, WaterMark } from '../video-conference-manager/types'; - -export interface CommunicatorOptions extends OldSuperVizSdkOptions { - apiKey: string; - ablyKey: string; - conferenceLayerUrl: string; - apiUrl: string; - waterMark?: WaterMark; -} - -export interface PluginOptions extends DefaultPluginOptions { - avatarConfig: AvatarConfig; -} - -export type ParticipandToFrame = { - timestamp: number; - connectionId: string; - participantId: string; - color: string; - name: string; -}; - -export type CommunicatorFacade = { - setSyncProperty: (name: string, property: T) => void; - subscribe: (propertyName: string, listener: (property?: T) => void) => void; - unsubscribe: (propertyName: string) => void; - destroy: () => void; - follow: (participantId?: string) => void; - fetchSyncProperty: ( - eventName?: string, - ) => Promise>; - gather: () => void; - goTo: (participantId: string) => void; - - toggleMeetingSetup: () => void; - toggleMicrophone: () => void; - toggleScreenShare: () => void; - hangUp: () => void; - toggleCam: () => void; - toggleChat: () => void; - - startTranscription: (language: string) => void; - stopTranscription: () => void; - - loadPlugin: (plugin: Plugin, props: PluginOptions) => PluginMethods; - unloadPlugin: () => void; -}; diff --git a/src/services/config/index.ts b/src/services/config/index.ts index f274cd0d..30c55dc6 100644 --- a/src/services/config/index.ts +++ b/src/services/config/index.ts @@ -5,7 +5,7 @@ import { Nullable } from '../../common/types/global.types'; import type { Configuration } from './types'; export class ConfigurationService { - private configuration: Nullable; + public configuration: Nullable; public setConfig(config: Configuration): void { this.configuration = config; @@ -24,6 +24,4 @@ export class ConfigurationService { } } -const configService = new ConfigurationService(); - -export default configService; +export default new ConfigurationService(); diff --git a/src/services/integration/base-plugin/index.ts b/src/services/integration/base-plugin/index.ts index e0f52faf..96e9c350 100644 --- a/src/services/integration/base-plugin/index.ts +++ b/src/services/integration/base-plugin/index.ts @@ -1,8 +1,7 @@ -import { PluginOptions } from '../../communicator/types'; import { AblyRealtimeService } from '../../realtime'; import { ParticipantOn3D, ParticipantTo3D } from '../participants/types'; -import { DefaultPluginManager, Plugin } from './types'; +import { DefaultPluginManager, DefaultPluginOptions, Plugin } from './types'; export class BasePluginManager implements DefaultPluginManager { private _isAvatarsEnabled: boolean; @@ -24,7 +23,7 @@ export class BasePluginManager implements DefaultPluginManager { plugin, RealtimeService, localParticipant, - }: PluginOptions) { + }: DefaultPluginOptions) { this._isAvatarsEnabled = isAvatarsEnabled; this._isMouseEnabled = isMouseEnabled; this._isLaserEnabled = isLaserEnabled; diff --git a/src/services/integration/base-plugin/types.ts b/src/services/integration/base-plugin/types.ts index d5c5af2e..189dde4b 100644 --- a/src/services/integration/base-plugin/types.ts +++ b/src/services/integration/base-plugin/types.ts @@ -1,6 +1,6 @@ import { AblyRealtimeService } from '../../realtime'; import { AblyParticipant } from '../../realtime/ably/types'; -import { ParticipantOn3D, ParticipantTo3D } from '../participants/types'; +import { AvatarConfig, ParticipantOn3D, ParticipantTo3D } from '../participants/types'; export interface DefaultPluginManager { isAvatarsEnabled: boolean; @@ -19,6 +19,7 @@ export interface DefaultPluginOptions { plugin: Plugin; localParticipant: ParticipantTo3D; RealtimeService: AblyRealtimeService; + avatarConfig: AvatarConfig; } export interface PluginMethods { diff --git a/src/services/integration/types.ts b/src/services/integration/types.ts index 5117e932..8d3eb23c 100644 --- a/src/services/integration/types.ts +++ b/src/services/integration/types.ts @@ -1,6 +1,4 @@ -import { PluginOptions } from '../communicator/types'; - -import { DefaultPluginManager } from './base-plugin/types'; +import { DefaultPluginManager, DefaultPluginOptions } from './base-plugin/types'; import { ParticipantOn3D, ParticipantTo3D } from './participants/types'; export interface DefaultIntegrationManager extends DefaultPluginManager { @@ -12,6 +10,6 @@ export interface DefaultIntegrationManager extends DefaultPluginManager { isLaserEnabled: boolean; } -export interface DefaultIntegrationManagerOptions extends PluginOptions { +export interface DefaultIntegrationManagerOptions extends DefaultPluginOptions { participantList: ParticipantTo3D[]; } diff --git a/src/services/realtime/ably/index.test.ts b/src/services/realtime/ably/index.test.ts index 00984095..24bc6b1a 100644 --- a/src/services/realtime/ably/index.test.ts +++ b/src/services/realtime/ably/index.test.ts @@ -3,6 +3,7 @@ import { TextEncoder } from 'util'; import Ably from 'ably'; import { MOCK_LOCAL_PARTICIPANT } from '../../../../__mocks__/participants.mock'; +import { TranscriptState } from '../../../common/types/events.types'; import { ParticipantType } from '../../../common/types/participant.types'; import { RealtimeStateTypes } from '../../../common/types/realtime.types'; @@ -166,7 +167,7 @@ describe('AblyRealtimeService', () => { expect(AblyRealtimeMock.connection.on).toHaveBeenCalledTimes(1); }); - test('should subscribe to broadcast channe if participant is audience', () => { + test('should subscribe to broadcast channel if participant is audience', () => { expect(AblyRealtimeServiceInstance.join).toBeDefined(); MOCK_LOCAL_PARTICIPANT.type = ParticipantType.AUDIENCE; @@ -307,6 +308,19 @@ describe('AblyRealtimeService', () => { drawing, }); }); + + test('should update the room properties with transcript state', () => { + AblyRealtimeServiceInstance['updateRoomProperties'] = jest.fn(); + + const transcriptionState = TranscriptState.TRANSCRIPT_START; + + AblyRealtimeServiceInstance.setTranscript(transcriptionState); + + expect(AblyRealtimeServiceInstance['updateRoomProperties']).toHaveBeenCalledWith({ + transcript: transcriptionState, + }); + }); + /** * initializeRoomProperties */ @@ -1040,6 +1054,30 @@ describe('AblyRealtimeService', () => { }); }); + describe('kick participant event', () => { + test('should update the kickParticipant in the room properties', async () => { + const participantId = 'participant1'; + const participant: AblyParticipant = { + clientId: 'client1', + action: 'present', + connectionId: 'connection1', + encoding: 'h264', + id: 'unit-test-participant-ably-id', + timestamp: new Date().getTime(), + data: { + participantId, + }, + }; + AblyRealtimeServiceInstance['participants'][participantId] = participant; + AblyRealtimeServiceInstance['updateRoomProperties'] = jest.fn(); + await AblyRealtimeServiceInstance.setKickParticipant(participantId); + + expect(AblyRealtimeServiceInstance['updateRoomProperties']).toHaveBeenCalledWith({ + kickParticipant: participant, + }); + }); + }); + describe('presence events handlers', () => { beforeEach(() => { AblyRealtimeServiceInstance.start({ diff --git a/src/services/realtime/ably/index.ts b/src/services/realtime/ably/index.ts index 09b18343..23906eef 100644 --- a/src/services/realtime/ably/index.ts +++ b/src/services/realtime/ably/index.ts @@ -1,7 +1,7 @@ import Ably from 'ably'; import throttle from 'lodash/throttle'; -import { RealtimeEvent } from '../../../common/types/events.types'; +import { RealtimeEvent, TranscriptState } from '../../../common/types/events.types'; import { Participant, ParticipantType } from '../../../common/types/participant.types'; import { RealtimeStateTypes } from '../../../common/types/realtime.types'; import { DrawingData } from '../../video-conference-manager/types'; @@ -169,7 +169,7 @@ export default class AblyRealtimeService extends RealtimeService implements Ably } /** - * @function Join + * @function join * @description join realtime room * @returns {void} * @param joinProperties @@ -244,6 +244,21 @@ export default class AblyRealtimeService extends RealtimeService implements Ably }); }; + /** + * @function setKickParticipant + * @param {string} kickParticipantId + * @description set a participant to be kicked from the room + * @returns {void} + */ + public setKickParticipant = (kickParticipantId: string): Promise => { + if (!kickParticipantId) return; + + const participant = this.participants[kickParticipantId]; + this.updateRoomProperties({ + kickParticipant: participant, + }); + }; + /** * @function setGridMode * @param {boolean} isGridModeEnable @@ -266,6 +281,17 @@ export default class AblyRealtimeService extends RealtimeService implements Ably this.updateRoomProperties(Object.assign({}, roomProperties, { drawing })); } + /** + * @function setTranscript + * @param state {TranscriptState} + * @description synchronizes the transcript state in the room + * @returns {void} + */ + public setTranscript(state: TranscriptState): void { + const roomProperties = this.localRoomProperties; + this.updateRoomProperties(Object.assign({}, roomProperties, { transcript: state })); + } + /** * @function setSyncProperty * @param {string} name @@ -530,6 +556,11 @@ export default class AblyRealtimeService extends RealtimeService implements Ably } this.updateParticipants(); + + if (data.kickParticipant && data.kickParticipant.clientId === this.myParticipant.clientId) { + this.updateRoomProperties({ kickParticipant: null }); + this.kickParticipantObserver.publish(this.myParticipant.clientId); + } }; /** @@ -785,13 +816,13 @@ export default class AblyRealtimeService extends RealtimeService implements Ably * @function fetchSyncClientProperty * @description * @param {string} eventName - name event to be fetched - * @returns {ClientRealtimeData} + * @returns {Promise} */ public async fetchSyncClientProperty( eventName?: string, ): Promise> { try { - const clienthistory: Record = await new Promise( + const clientHistory: Record = await new Promise( (resolve, reject) => { this.clientRoomStateChannel.history((error, resultPage) => { if (error) reject(error); @@ -807,15 +838,15 @@ export default class AblyRealtimeService extends RealtimeService implements Ably }, ); - if (eventName && !clienthistory[eventName]) { + if (eventName && !clientHistory[eventName]) { throw new Error(`Event ${eventName} not found in the history`); } if (eventName) { - return clienthistory[eventName]; + return clientHistory[eventName]; } - return clienthistory; + return clientHistory; } catch (error) { this.logger.log('REALTIME', 'Error in fetch client realtime data', error.message); this.throw(error.message); @@ -1108,6 +1139,7 @@ export default class AblyRealtimeService extends RealtimeService implements Ably * @function isMessageTooBig * @description calculates the size of a sync message and checks if it's bigger than limit * @param {unknown} msg + * @param {number} limit * @returns {boolean} */ private isMessageTooBig = (msg: unknown, limit: number = MESSAGE_SIZE_LIMIT): boolean => { diff --git a/src/services/realtime/ably/types.ts b/src/services/realtime/ably/types.ts index 452262fd..3d86eeb0 100644 --- a/src/services/realtime/ably/types.ts +++ b/src/services/realtime/ably/types.ts @@ -1,5 +1,6 @@ import type Ably from 'ably'; +import { TranscriptState } from '../../../common/types/events.types'; import { DrawingData } from '../../video-conference-manager/types'; import { DefaultRealtimeMethods } from '../base/types'; @@ -17,6 +18,8 @@ export interface AblyRealtimeData { followParticipantId?: string; gather?: boolean; drawing?: DrawingData; + kickParticipant?: AblyParticipant; + transcript?: TranscriptState; } export type AblyTokenCallBack = ( diff --git a/src/services/realtime/base/index.ts b/src/services/realtime/base/index.ts index d3a6aca8..79dfd23e 100644 --- a/src/services/realtime/base/index.ts +++ b/src/services/realtime/base/index.ts @@ -17,6 +17,7 @@ export class RealtimeService implements DefaultRealtimeService { public realtimeStateObserver: Observer; public syncPropertiesObserver: Observer; public kickAllParticipantsObserver: Observer; + public kickParticipantObserver: Observer; public authenticationObserver: Observer; constructor() { @@ -36,6 +37,7 @@ export class RealtimeService implements DefaultRealtimeService { this.hostObserver = new Observer({ logger: this.logger }); this.realtimeStateObserver = new Observer({ logger: this.logger }); this.kickAllParticipantsObserver = new Observer({ logger: this.logger }); + this.kickParticipantObserver = new Observer({ logger: this.logger }); this.authenticationObserver = new Observer({ logger: this.logger }); } diff --git a/src/services/realtime/base/types.ts b/src/services/realtime/base/types.ts index e62acab1..3925d102 100644 --- a/src/services/realtime/base/types.ts +++ b/src/services/realtime/base/types.ts @@ -14,6 +14,7 @@ export interface DefaultRealtimeService { realtimeStateObserver: Observer; syncPropertiesObserver: Observer; kickAllParticipantsObserver: Observer; + kickParticipantObserver: Observer; authenticationObserver: Observer; } diff --git a/src/services/video-conference-manager/index.test.ts b/src/services/video-conference-manager/index.test.ts index 5d733435..086f8fc1 100644 --- a/src/services/video-conference-manager/index.test.ts +++ b/src/services/video-conference-manager/index.test.ts @@ -6,7 +6,7 @@ import { MeetingControlsEvent, MeetingEvent, MeetingState, - TranscriptionEvent, + RealtimeEvent, } from '../../common/types/events.types'; import { Participant } from '../../common/types/participant.types'; import { BrowserService } from '../browser'; @@ -17,11 +17,6 @@ import VideoConferenceManager from './index'; const createVideoConfrenceManager = (options?: VideoManagerOptions) => { const defaultOptions: VideoManagerOptions = { - ablyKey: 'unit-test-ably-key', - apiKey: 'unit-test-api-key', - apiUrl: 'https://unit-test-api-url', - conferenceLayerUrl: 'https://unit-test-conference-layer-url/', - roomId: 'unit-test-room-id', browserService: new BrowserService(), camerasPosition: CamerasPosition.RIGHT, canUseCams: true, @@ -32,7 +27,6 @@ const createVideoConfrenceManager = (options?: VideoManagerOptions) => { canUseFollow: true, canUseGather: true, canUseGoTo: true, - debug: true, devices: { audioInput: true, audioOutput: true, @@ -110,28 +104,8 @@ describe('VideoConferenceManager', () => { VideoConferenceManagerInstance.frameSizeObserver, 'destroy', ); - const realtimeObserverSpy = jest.spyOn( - VideoConferenceManagerInstance.realtimeObserver, - 'destroy', - ); - const hostChangeObserverSpy = jest.spyOn( - VideoConferenceManagerInstance.hostChangeObserver, - 'destroy', - ); - const gridModeChangeObserverSpy = jest.spyOn( - VideoConferenceManagerInstance.gridModeChangeObserver, - 'destroy', - ); - const followParticipantObserverrSpy = jest.spyOn( - VideoConferenceManagerInstance.followParticipantObserver, - 'destroy', - ); - const goToParticipantObserverSpy = jest.spyOn( - VideoConferenceManagerInstance.goToParticipantObserver, - 'destroy', - ); - const gatherParticipantsObserverSpy = jest.spyOn( - VideoConferenceManagerInstance.gatherParticipantsObserver, + const realtimeEventsObserverSpy = jest.spyOn( + VideoConferenceManagerInstance.realtimeEventsObserver, 'destroy', ); const sameAccountErrorSpy = jest.spyOn( @@ -162,12 +136,7 @@ describe('VideoConferenceManager', () => { VideoConferenceManagerInstance.destroy(); expect(frameSizeObserverSpy).toBeCalled(); - expect(realtimeObserverSpy).toBeCalled(); - expect(hostChangeObserverSpy).toBeCalled(); - expect(gridModeChangeObserverSpy).toBeCalled(); - expect(followParticipantObserverrSpy).toBeCalled(); - expect(goToParticipantObserverSpy).toBeCalled(); - expect(gatherParticipantsObserverSpy).toBeCalled(); + expect(realtimeEventsObserverSpy).toBeCalled(); expect(sameAccountErrorSpy).toBeCalled(); expect(devicesObserverSpy).toBeCalled(); expect(meetingStateObserverSpy).toBeCalled(); @@ -482,68 +451,86 @@ describe('VideoConferenceManager', () => { }); }); - describe('realtimeJoin', () => { - test('should publish the participant info', () => { - const participantInfo = { id: '1', name: 'Alice' }; - const spy = jest.spyOn(VideoConferenceManagerInstance.realtimeObserver, 'publish'); + describe('onMeetingHostChange', () => { + test('should publish the new host ID', () => { + const hostId = '1'; + const spy = jest.spyOn(VideoConferenceManagerInstance.realtimeEventsObserver, 'publish'); - VideoConferenceManagerInstance['realtimeJoin'](participantInfo); + VideoConferenceManagerInstance['onMeetingHostChange'](hostId); - expect(spy).toHaveBeenCalledWith(participantInfo); + expect(spy).toHaveBeenCalledWith({ + event: RealtimeEvent.REALTIME_HOST_CHANGE, + data: hostId, + }); }); }); - describe('onMeetingHostChange', () => { - test('should publish the new host ID', () => { - const hostId = '1'; - const spy = jest.spyOn(VideoConferenceManagerInstance.hostChangeObserver, 'publish'); + describe('onMeetingKickParticipant', () => { + test('should publish the participant ID to be kicked', () => { + const participantId = '1'; + const spy = jest.spyOn(VideoConferenceManagerInstance.realtimeEventsObserver, 'publish'); - VideoConferenceManagerInstance['onMeetingHostChange'](hostId); + VideoConferenceManagerInstance['onMeetingKickParticipant'](participantId); - expect(spy).toHaveBeenCalledWith(hostId); + expect(spy).toHaveBeenCalledWith({ + event: MeetingEvent.MEETING_KICK_PARTICIPANT, + data: participantId, + }); }); }); describe('onFollowParticipantDidChange', () => { test('should publish the new participant ID', () => { const participantId = '1'; - const spy = jest.spyOn(VideoConferenceManagerInstance.followParticipantObserver, 'publish'); + const spy = jest.spyOn(VideoConferenceManagerInstance.realtimeEventsObserver, 'publish'); VideoConferenceManagerInstance['onFollowParticipantDidChange'](participantId); - expect(spy).toHaveBeenCalledWith(participantId); + expect(spy).toHaveBeenCalledWith({ + event: RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT, + data: participantId, + }); }); }); describe('onGoToDidChange', () => { test('should publish the new participant ID', () => { const participantId = '1'; - const spy = jest.spyOn(VideoConferenceManagerInstance.goToParticipantObserver, 'publish'); + const spy = jest.spyOn(VideoConferenceManagerInstance.realtimeEventsObserver, 'publish'); VideoConferenceManagerInstance['onGoToDidChange'](participantId); - expect(spy).toHaveBeenCalledWith(participantId); + expect(spy).toHaveBeenCalledWith({ + event: RealtimeEvent.REALTIME_GO_TO_PARTICIPANT, + data: participantId, + }); }); }); describe('onGather', () => { test('should publish an empty message', () => { - const spy = jest.spyOn(VideoConferenceManagerInstance.gatherParticipantsObserver, 'publish'); + const spy = jest.spyOn(VideoConferenceManagerInstance.realtimeEventsObserver, 'publish'); VideoConferenceManagerInstance['onGather'](); - expect(spy).toHaveBeenCalledWith(); + expect(spy).toHaveBeenCalledWith({ + event: RealtimeEvent.REALTIME_GATHER, + data: true, + }); }); }); describe('onGridModeChange', () => { test('should publish the new grid mode state', () => { const isGridModeEnable = true; - const spy = jest.spyOn(VideoConferenceManagerInstance.gridModeChangeObserver, 'publish'); + const spy = jest.spyOn(VideoConferenceManagerInstance.realtimeEventsObserver, 'publish'); VideoConferenceManagerInstance['onGridModeChange'](isGridModeEnable); - expect(spy).toHaveBeenCalledWith(isGridModeEnable); + expect(spy).toHaveBeenCalledWith({ + event: RealtimeEvent.REALTIME_GRID_MODE_CHANGE, + data: isGridModeEnable, + }); }); }); diff --git a/src/services/video-conference-manager/index.ts b/src/services/video-conference-manager/index.ts index 6b5095af..733b7d4b 100644 --- a/src/services/video-conference-manager/index.ts +++ b/src/services/video-conference-manager/index.ts @@ -8,12 +8,13 @@ import { Dimensions, MeetingControlsEvent, FrameEvent, - TranscriptionEvent, + TranscriptState, } from '../../common/types/events.types'; import { StartMeetingOptions } from '../../common/types/meeting.types'; import { Participant, Avatar } from '../../common/types/participant.types'; import { Logger, Observer } from '../../common/utils'; import { BrowserService } from '../browser'; +import config from '../config'; import { FrameBricklayer } from '../frame-brick-layer'; import { MessageBridge } from '../message-bridge'; @@ -50,14 +51,8 @@ export default class VideoConfereceManager { public readonly frameStateObserver = new Observer({ logger: this.logger }); public readonly frameSizeObserver = new Observer({ logger: this.logger }); - public readonly realtimeObserver = new Observer({ logger: this.logger }); - public readonly hostChangeObserver = new Observer({ logger: this.logger }); - public readonly gridModeChangeObserver = new Observer({ logger: this.logger }); - public readonly followParticipantObserver = new Observer({ logger: this.logger }); - public readonly goToParticipantObserver = new Observer({ logger: this.logger }); - public readonly gatherParticipantsObserver = new Observer({ logger: this.logger }); public readonly waitingForHostObserver = new Observer({ logger: this.logger }); - public readonly drawingChangeObserver = new Observer({ logger: this.logger }); + public readonly realtimeEventsObserver = new Observer({ logger: this.logger }); public readonly sameAccountErrorObserver = new Observer({ logger: this.logger }); public readonly devicesObserver = new Observer({ logger: this.logger }); @@ -71,13 +66,7 @@ export default class VideoConfereceManager { constructor(options: VideoManagerOptions) { const { - conferenceLayerUrl, - ablyKey, - apiKey, - apiUrl, - debug, language, - roomId, canUseCams, canUseChat, canUseScreenshare, @@ -115,10 +104,10 @@ export default class VideoConfereceManager { const wrapper = document.createElement('div'); this.frameConfig = { - apiKey, - apiUrl, - ablyKey, - debug, + apiKey: config.get('apiKey'), + apiUrl: config.get('apiUrl'), + ablyKey: config.get('ablyKey'), + debug: config.get('debug'), canUseFollow, canUseGoTo, canUseCams, @@ -128,7 +117,7 @@ export default class VideoConfereceManager { canUseDefaultAvatars, camerasPosition: positions.camerasPosition ?? CamerasPosition.RIGHT, canUseDefaultToolbar, - roomId, + roomId: config.get('roomId'), devices: { audioInput: devices?.audioInput ?? true, audioOutput: devices?.audioOutput ?? true, @@ -150,9 +139,15 @@ export default class VideoConfereceManager { this.updateFrameState(VideoFrameState.INITIALIZING); this.bricklayer = new FrameBricklayer(); - this.bricklayer.build(wrapper.id, conferenceLayerUrl, FRAME_ID, undefined, { - allow: 'camera *;microphone *; display-capture *;', - }); + this.bricklayer.build( + wrapper.id, + config.get('conferenceLayerUrl'), + FRAME_ID, + undefined, + { + allow: 'camera *;microphone *; display-capture *;', + }, + ); this.setFrameOffset(offset); this.setFrameStyle(positions.camerasPosition); @@ -180,9 +175,15 @@ export default class VideoConfereceManager { /** * @function layoutModalsAndCamerasConfig - * @returns {any} + * @description returns the correct layout and cameras position + * @param {LayoutPosition} layout - layout position + * @param {CamerasPosition} cameras - cameras position + * @returns {LayoutModalsAndCameras} */ - private layoutModalsAndCamerasConfig = (layout, cameras): LayoutModalsAndCameras => { + private layoutModalsAndCamerasConfig = ( + layout: LayoutPosition, + cameras: CamerasPosition, + ): LayoutModalsAndCameras => { let layoutPosition = layout; let camerasPosition = cameras; @@ -250,8 +251,10 @@ export default class VideoConfereceManager { MeetingEvent.MEETING_WAITING_FOR_HOST, this.onWaitingForHostDidChange, ); + this.messageBridge.listen(MeetingEvent.MEETING_PARTICIPANT_JOINED, this.onParticipantJoined); this.messageBridge.listen(MeetingEvent.MEETING_PARTICIPANT_LEFT, this.onParticipantLeft); this.messageBridge.listen(MeetingEvent.MEETING_HOST_CHANGE, this.onMeetingHostChange); + this.messageBridge.listen(MeetingEvent.MEETING_KICK_PARTICIPANT, this.onMeetingKickParticipant); this.messageBridge.listen(MeetingEvent.MEETING_SAME_PARTICIPANT_ERROR, this.onSameAccountError); this.messageBridge.listen(MeetingEvent.MEETING_STATE_UPDATE, this.meetingStateUpdate); this.messageBridge.listen( @@ -259,9 +262,9 @@ export default class VideoConfereceManager { this.onConnectionStatusChange, ); this.messageBridge.listen(MeetingEvent.MEETING_DEVICES_CHANGE, this.onDevicesChange); - this.messageBridge.listen(RealtimeEvent.REALTIME_JOIN, this.realtimeJoin); this.messageBridge.listen(RealtimeEvent.REALTIME_GRID_MODE_CHANGE, this.onGridModeChange); this.messageBridge.listen(RealtimeEvent.REALTIME_DRAWING_CHANGE, this.onDrawingChange); + this.messageBridge.listen(RealtimeEvent.REALTIME_TRANSCRIPT_CHANGE, this.onTranscriptChange); this.messageBridge.listen(FrameEvent.FRAME_DIMENSIONS_UPDATE, this.onFrameDimensionsUpdate); this.messageBridge.listen( @@ -406,7 +409,7 @@ export default class VideoConfereceManager { }; /** - * @function updateMeetingAvatar + * @function updateMeetingAvatars * @description update list of avatars * @returns {void} */ @@ -414,6 +417,10 @@ export default class VideoConfereceManager { this.messageBridge.publish(FrameEvent.FRAME_AVATAR_LIST_UPDATE, this.meetingAvatars); }; + private onParticipantJoined = (participant: Participant): void => { + this.participantJoinedObserver.publish(participant); + }; + /** * @function onParticipantLeft * @param {Participant} participant @@ -446,21 +453,27 @@ export default class VideoConfereceManager { } /** - * @function realtimeJoin - * @param participantInfo + * @function onMeetingHostChange + * @param {string} hostId * @returns {void} */ - private realtimeJoin = (participantInfo = {}): void => { - this.realtimeObserver.publish(participantInfo); + private onMeetingHostChange = (hostId: string): void => { + this.realtimeEventsObserver.publish({ + event: RealtimeEvent.REALTIME_HOST_CHANGE, + data: hostId, + }); }; /** - * @function onMeetingHostChange - * @param {string} hostId + * @function onMeetingKickParticipant + * @param {string} participantId - ID of the participant * @returns {void} */ - private onMeetingHostChange = (hostId: string): void => { - this.hostChangeObserver.publish(hostId); + private onMeetingKickParticipant = (participantId: string): void => { + this.realtimeEventsObserver.publish({ + event: MeetingEvent.MEETING_KICK_PARTICIPANT, + data: participantId, + }); }; /** @@ -469,7 +482,10 @@ export default class VideoConfereceManager { * @returns {void} */ private onFollowParticipantDidChange = (participantId?: string): void => { - this.followParticipantObserver.publish(participantId); + this.realtimeEventsObserver.publish({ + event: RealtimeEvent.REALTIME_FOLLOW_PARTICIPANT, + data: participantId, + }); }; /** @@ -478,7 +494,10 @@ export default class VideoConfereceManager { * @returns {void} */ private onGoToDidChange = (participantId: string): void => { - this.goToParticipantObserver.publish(participantId); + this.realtimeEventsObserver.publish({ + event: RealtimeEvent.REALTIME_GO_TO_PARTICIPANT, + data: participantId, + }); }; /** @@ -486,7 +505,10 @@ export default class VideoConfereceManager { * @returns {void} */ private onGather = (): void => { - this.gatherParticipantsObserver.publish(); + this.realtimeEventsObserver.publish({ + event: RealtimeEvent.REALTIME_GATHER, + data: true, + }); }; /** @@ -495,7 +517,10 @@ export default class VideoConfereceManager { * @returns {void} */ private onGridModeChange = (isGridModeEnable: boolean): void => { - this.gridModeChangeObserver.publish(isGridModeEnable); + this.realtimeEventsObserver.publish({ + event: RealtimeEvent.REALTIME_GRID_MODE_CHANGE, + data: isGridModeEnable, + }); }; /** @@ -504,7 +529,22 @@ export default class VideoConfereceManager { * @returns {void} */ private onDrawingChange = (drawing: DrawingData): void => { - this.drawingChangeObserver.publish(drawing); + this.realtimeEventsObserver.publish({ + event: RealtimeEvent.REALTIME_DRAWING_CHANGE, + data: drawing, + }); + }; + + /** + * @function onTranscriptChange + * @param state {TranscriptState} + * @returns {void} + */ + private onTranscriptChange = (state: TranscriptState): void => { + this.realtimeEventsObserver.publish({ + event: RealtimeEvent.REALTIME_TRANSCRIPT_CHANGE, + data: state, + }); }; /** @@ -578,14 +618,8 @@ export default class VideoConfereceManager { this.bricklayer?.destroy(); this.frameSizeObserver.destroy(); - this.realtimeObserver.destroy(); - this.hostChangeObserver.destroy(); - this.gridModeChangeObserver.destroy(); - this.drawingChangeObserver.destroy(); - - this.followParticipantObserver.destroy(); - this.goToParticipantObserver.destroy(); - this.gatherParticipantsObserver.destroy(); + + this.realtimeEventsObserver.destroy(); this.sameAccountErrorObserver.destroy(); this.devicesObserver.destroy(); this.meetingStateObserver.destroy(); @@ -600,11 +634,11 @@ export default class VideoConfereceManager { /** * @function publishMessageToFrame * @description Publishes a message to the frame - * @param message - The event to publish + * @param event - The event to publish * @param payload - The payload to publish */ public publishMessageToFrame( - event: MeetingControlsEvent | MeetingEvent | RealtimeEvent | TranscriptionEvent, + event: MeetingControlsEvent | MeetingEvent | RealtimeEvent, payload?: unknown, ): void { this.messageBridge.publish(event, payload); diff --git a/src/services/video-conference-manager/types.ts b/src/services/video-conference-manager/types.ts index dec1b21a..76ba7391 100644 --- a/src/services/video-conference-manager/types.ts +++ b/src/services/video-conference-manager/types.ts @@ -1,14 +1,9 @@ +import { MeetingEvent, RealtimeEvent } from '../../common/types/events.types'; import type { Avatar } from '../../common/types/participant.types'; import { BrowserService } from '../browser'; export interface VideoManagerOptions { - conferenceLayerUrl: string; - ablyKey: string; - apiKey: string; - apiUrl: string; - debug: boolean; language?: string; - roomId: string; canUseChat: boolean; canUseCams: boolean; canUseScreenshare: boolean; @@ -144,6 +139,11 @@ export interface DrawingData { fadeOut: boolean; } +export interface RealtimeObserverPayload { + event: RealtimeEvent | MeetingEvent; + data: unknown; +} + export enum WaterMark { ALL = 'all', CAMERA = 'camera', diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 4b18ecd0..00000000 --- a/src/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { LauncherFacade } from './core/launcher/types'; -import { CommunicatorFacade } from './services/communicator/types'; - -export type SuperVizSdk = CommunicatorFacade | LauncherFacade;