diff --git a/src/components/CallView/shared/VideoVue.spec.js b/src/components/CallView/shared/VideoVue.spec.js index 809354a929b..23858fb7fe2 100644 --- a/src/components/CallView/shared/VideoVue.spec.js +++ b/src/components/CallView/shared/VideoVue.spec.js @@ -11,7 +11,7 @@ import { createStore } from 'vuex' import VideoVue from './VideoVue.vue' import storeConfig from '../../../store/storeConfig.js' import EmitterMixin from '../../../utils/EmitterMixin.js' -import CallParticipantModel from '../../../utils/webrtc/models/CallParticipantModel.js' +import { CallParticipantModel } from '../../../utils/webrtc/models/CallParticipantModel.js' describe('VideoVue.vue', () => { let store diff --git a/src/types/index.ts b/src/types/index.ts index 9d12fbae997..0c74f3f7599 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -226,6 +226,65 @@ export type JoinRoomFullResponse = { export type fetchPeersResponse = ApiResponse export type callSIPDialOutResponse = ApiResponse +export type CallParticipantCollection = { + callParticipantModels: Array + + on(event: string, handler: (callParticipantCollection: CallParticipantCollection, ...args: any[]) => void): void + off(event: string, handler: (callParticipantCollection: CallParticipantCollection, ...args: any[]) => void): void + + add(options: CallParticipantModelOptions): CallParticipantModel + get(peerId: string): CallParticipantModel | undefined + remove(peerId: string): boolean +} + +export type CallParticipantModelOptions = { + peerId: string + webRtc: WebRtc +} + +export type CallParticipantModel = { + on(event: string, handler: (callParticipantModel: CallParticipantModel, ...args: any[]) => void): void + off(event: string, handler: (callParticipantModel: CallParticipantModel, ...args: any[]) => void): void + + get(key: string): any + set(key: string, value: any): void +} + +export type LocalCallParticipantModel = { + on(event: string, handler: (localCallParticipantModel: LocalCallParticipantModel, ...args: any[]) => void): void + off(event: string, handler: (localCallParticipantModel: LocalCallParticipantModel, ...args: any[]) => void): void + + get(key: string): any + set(key: string, value: any): void +} + +export type Signaling = { + settings: { + userId: string | null + } +} + +export type InternalWebRtc = { + isAudioEnabled(): boolean + isVideoEnabled(): boolean + isSpeaking(): boolean +} + +export type WebRtc = { + on(event: string, handler: () => void): void + off(event: string, handler: () => void): void + emit(event: string): void + + sendDataChannelToAll(channel: string, message: string, payload?: string | object): void + sendToAll(message: string, payload: object): void + + sendDataChannelTo(peerId: string, channel: string, message: string, payload?: string | object): void + sendTo(peerId: string, messageType: string, payload: object): void + + connection: Signaling + webrtc: InternalWebRtc +} + // Participants export type ParticipantStatus = { status?: string | null diff --git a/src/types/vendor/wildemitter.d.ts b/src/types/vendor/wildemitter.d.ts new file mode 100644 index 00000000000..28345ea8e54 --- /dev/null +++ b/src/types/vendor/wildemitter.d.ts @@ -0,0 +1,6 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +declare module 'wildemitter' diff --git a/src/utils/webrtc/LocalStateBroadcaster.spec.ts b/src/utils/webrtc/LocalStateBroadcaster.spec.ts new file mode 100644 index 00000000000..ff4c4edeabc --- /dev/null +++ b/src/utils/webrtc/LocalStateBroadcaster.spec.ts @@ -0,0 +1,839 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { + CallParticipantModel as CallParticipantModelType, + InternalWebRtc, + WebRtc, +} from '../../types/index.ts' + +import { + afterEach, + beforeEach, + describe, + expect, + test, + vi, +} from 'vitest' +import WildEmitter from 'wildemitter' +import { + LocalStateBroadcaster, + LocalStateBroadcasterMcu, + LocalStateBroadcasterNoMcu, +} from './LocalStateBroadcaster.ts' +import { CallParticipantCollection } from './models/CallParticipantCollection.js' +import { + CallParticipantModel, + ConnectionState, +} from './models/CallParticipantModel.js' +import { LocalCallParticipantModel } from './models/LocalCallParticipantModel.js' + +// Augment models with the public methods added to their prototype by the +// EmitterMixin. +declare module './models/CallParticipantCollection.js' { + interface CallParticipantCollection { + on(event: string, handler: (callParticipantCollection: CallParticipantCollection, ...args: any[]) => void): void + off(event: string, handler: (callParticipantCollection: CallParticipantCollection, ...args: any[]) => void): void + } +} + +declare module './models/CallParticipantModel.js' { + interface CallParticipantModel { + on(event: string, handler: (callParticipantModel: CallParticipantModel, ...args: any[]) => void): void + off(event: string, handler: (callParticipantModel: CallParticipantModel, ...args: any[]) => void): void + } +} + +declare module './models/LocalCallParticipantModel.js' { + interface LocalCallParticipantModel { + on(event: string, handler: (localCallParticipantModel: LocalCallParticipantModel, ...args: any[]) => void): void + off(event: string, handler: (localCallParticipantModel: LocalCallParticipantModel, ...args: any[]) => void): void + } +} + +class BaseLocalStateBroadcaster extends LocalStateBroadcaster { + protected _handleAddCallParticipantModel(callParticipantCollection: CallParticipantCollection, callParticipantModel: CallParticipantModelType): void { + // Not used in base class tests + } + + protected _handleRemoveCallParticipantModel(callParticipantCollection: CallParticipantCollection, callParticipantModel: CallParticipantModelType): void { + // Not used in base class tests + } +} + +class PeerMock { + id: string + parent: object + off: (event: string, handler: () => void) => void + + constructor(id: string) { + this.id = id + this.parent = { + config: { + }, + } + this.off = vi.fn() + } +} + +describe('LocalStateBroadcaster', () => { + let webRtc: WebRtc + let internalWebRtc: InternalWebRtc + let callParticipantCollection: CallParticipantCollection + let localCallParticipantModel: LocalCallParticipantModel + + let localStateBroadcaster: LocalStateBroadcaster + + beforeEach(() => { + vi.useFakeTimers() + + internalWebRtc = new (function(this: InternalWebRtc) { + this.isAudioEnabled = vi.fn() + this.isSpeaking = vi.fn() + this.isVideoEnabled = vi.fn() + } as any)() + + const signaling = { + settings: { + userId: null, + }, + } + + webRtc = new (function(this: WebRtc) { + WildEmitter.mixin(this) + + this.connection = signaling + this.webrtc = internalWebRtc + + this.sendDataChannelToAll = vi.fn() + this.sendToAll = vi.fn() + + this.sendDataChannelTo = vi.fn() + this.sendTo = vi.fn() + } as any)() + + callParticipantCollection = new CallParticipantCollection() + + localCallParticipantModel = new LocalCallParticipantModel() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + test('enable audio', () => { + localStateBroadcaster = new BaseLocalStateBroadcaster(webRtc, callParticipantCollection, localCallParticipantModel) + + webRtc.emit('audioOn') + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(1) + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledWith('status', 'audioOn') + + expect(webRtc.sendToAll).toHaveBeenCalledTimes(1) + expect(webRtc.sendToAll).toHaveBeenCalledWith('unmute', { name: 'audio' }) + }) + + test('disable audio', () => { + localStateBroadcaster = new BaseLocalStateBroadcaster(webRtc, callParticipantCollection, localCallParticipantModel) + + webRtc.emit('audioOff') + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(1) + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledWith('status', 'audioOff') + + expect(webRtc.sendToAll).toHaveBeenCalledTimes(1) + expect(webRtc.sendToAll).toHaveBeenCalledWith('mute', { name: 'audio' }) + }) + + test('enable speaking', () => { + localStateBroadcaster = new BaseLocalStateBroadcaster(webRtc, callParticipantCollection, localCallParticipantModel) + + webRtc.emit('speaking') + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(1) + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledWith('status', 'speaking') + }) + + test('disable speaking', () => { + localStateBroadcaster = new BaseLocalStateBroadcaster(webRtc, callParticipantCollection, localCallParticipantModel) + + webRtc.emit('stoppedSpeaking') + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(1) + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledWith('status', 'stoppedSpeaking') + }) + + test('enable video', () => { + localStateBroadcaster = new BaseLocalStateBroadcaster(webRtc, callParticipantCollection, localCallParticipantModel) + + webRtc.emit('videoOn') + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(1) + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledWith('status', 'videoOn') + + expect(webRtc.sendToAll).toHaveBeenCalledTimes(1) + expect(webRtc.sendToAll).toHaveBeenCalledWith('unmute', { name: 'video' }) + }) + + test('disable video', () => { + localStateBroadcaster = new BaseLocalStateBroadcaster(webRtc, callParticipantCollection, localCallParticipantModel) + + webRtc.emit('videoOff') + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(1) + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledWith('status', 'videoOff') + + expect(webRtc.sendToAll).toHaveBeenCalledTimes(1) + expect(webRtc.sendToAll).toHaveBeenCalledWith('mute', { name: 'video' }) + }) + + test('set nick as user', () => { + webRtc.connection.settings.userId = 'theUserId' + + localStateBroadcaster = new BaseLocalStateBroadcaster(webRtc, callParticipantCollection, localCallParticipantModel) + + localCallParticipantModel.set('guestName', 'theName') + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(1) + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledWith('status', 'nickChanged', { name: 'theName', userid: 'theUserId' }) + + expect(webRtc.sendToAll).toHaveBeenCalledTimes(1) + expect(webRtc.sendToAll).toHaveBeenCalledWith('nickChanged', { name: 'theName' }) + }) + + test('set nick as guest', () => { + localStateBroadcaster = new BaseLocalStateBroadcaster(webRtc, callParticipantCollection, localCallParticipantModel) + + localCallParticipantModel.set('guestName', 'theName') + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(1) + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledWith('status', 'nickChanged', 'theName') + + expect(webRtc.sendToAll).toHaveBeenCalledTimes(1) + expect(webRtc.sendToAll).toHaveBeenCalledWith('nickChanged', { name: 'theName' }) + }) + + test('change state after destroying', () => { + localStateBroadcaster = new BaseLocalStateBroadcaster(webRtc, callParticipantCollection, localCallParticipantModel) + + localStateBroadcaster.destroy() + + webRtc.emit('audioOn') + webRtc.emit('audioOff') + webRtc.emit('speaking') + webRtc.emit('stoppedSpeaking') + webRtc.emit('videoOn') + webRtc.emit('videoOff') + + localCallParticipantModel.set('guestName', 'theName') + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(0) + expect(webRtc.sendToAll).toHaveBeenCalledTimes(0) + }) + + describe('LocalStateBroadcasterMcu', () => { + test('add single participant', () => { + vi.mocked(internalWebRtc.isAudioEnabled).mockReturnValue(true) + vi.mocked(internalWebRtc.isSpeaking).mockReturnValue(true) + vi.mocked(internalWebRtc.isVideoEnabled).mockReturnValue(true) + localCallParticipantModel.set('guestName', 'theName') + + localStateBroadcaster = new LocalStateBroadcasterMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + vi.advanceTimersByTime(0) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(4) + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(1, 'status', 'audioOn') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(2, 'status', 'speaking') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(3, 'status', 'videoOn') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(4, 'status', 'nickChanged', 'theName') + + expect(webRtc.sendTo).toHaveBeenCalledTimes(3) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'unmute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(2, 'thePeerId', 'unmute', { name: 'video' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(3, 'thePeerId', 'nickChanged', { name: 'theName' }) + + let timeoutCount = 1 + + // Test after 1, 2, 4, 8 and 16 seconds have passed since the + // participant was added + const timeouts = [1, 2, 4, 8, 16] + timeouts.forEach((second) => { + vi.advanceTimersByTime(second * 1000 - 1) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(4 * timeoutCount) + expect(webRtc.sendTo).toHaveBeenCalledTimes(3 * timeoutCount) + + vi.advanceTimersByTime(1) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(4 * timeoutCount + 4) + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(4 * timeoutCount + 1, 'status', 'audioOn') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(4 * timeoutCount + 2, 'status', 'speaking') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(4 * timeoutCount + 3, 'status', 'videoOn') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(4 * timeoutCount + 4, 'status', 'nickChanged', 'theName') + + expect(webRtc.sendTo).toHaveBeenCalledTimes(3 * timeoutCount + 3) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(3 * timeoutCount + 1, 'thePeerId', 'unmute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(3 * timeoutCount + 2, 'thePeerId', 'unmute', { name: 'video' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(3 * timeoutCount + 3, 'thePeerId', 'nickChanged', { name: 'theName' }) + + timeoutCount++ + }) + + vi.advanceTimersByTime(100000) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(24) + expect(webRtc.sendTo).toHaveBeenCalledTimes(18) + }) + + test('change current state while sending initial state', () => { + vi.mocked(internalWebRtc.isAudioEnabled).mockReturnValue(true) + vi.mocked(internalWebRtc.isSpeaking).mockReturnValue(true) + vi.mocked(internalWebRtc.isVideoEnabled).mockReturnValue(true) + localCallParticipantModel.set('guestName', 'theName') + + localStateBroadcaster = new LocalStateBroadcasterMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + vi.advanceTimersByTime(1000) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(8) + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(1, 'status', 'audioOn') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(2, 'status', 'speaking') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(3, 'status', 'videoOn') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(4, 'status', 'nickChanged', 'theName') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(5, 'status', 'audioOn') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(6, 'status', 'speaking') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(7, 'status', 'videoOn') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(8, 'status', 'nickChanged', 'theName') + + expect(webRtc.sendTo).toHaveBeenCalledTimes(6) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'unmute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(2, 'thePeerId', 'unmute', { name: 'video' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(3, 'thePeerId', 'nickChanged', { name: 'theName' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(4, 'thePeerId', 'unmute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(5, 'thePeerId', 'unmute', { name: 'video' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(6, 'thePeerId', 'nickChanged', { name: 'theName' }) + + vi.mocked(internalWebRtc.isAudioEnabled).mockReturnValue(false) + vi.mocked(internalWebRtc.isSpeaking).mockReturnValue(false) + vi.mocked(internalWebRtc.isVideoEnabled).mockReturnValue(false) + localCallParticipantModel.set('guestName', 'theNewName') + + vi.advanceTimersByTime(2000) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(12) + // Changing the name on the model triggers the normal state changed + // message + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(9, 'status', 'nickChanged', 'theNewName') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(10, 'status', 'audioOff') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(11, 'status', 'videoOff') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(12, 'status', 'nickChanged', 'theNewName') + + expect(webRtc.sendTo).toHaveBeenCalledTimes(9) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(7, 'thePeerId', 'mute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(8, 'thePeerId', 'mute', { name: 'video' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(9, 'thePeerId', 'nickChanged', { name: 'theNewName' }) + }) + + test('add several participants', () => { + vi.mocked(internalWebRtc.isAudioEnabled).mockReturnValue(true) + vi.mocked(internalWebRtc.isSpeaking).mockReturnValue(true) + vi.mocked(internalWebRtc.isVideoEnabled).mockReturnValue(true) + localCallParticipantModel.set('guestName', 'theName') + + localStateBroadcaster = new LocalStateBroadcasterMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + vi.advanceTimersByTime(1000) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(8) + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(1, 'status', 'audioOn') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(2, 'status', 'speaking') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(3, 'status', 'videoOn') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(4, 'status', 'nickChanged', 'theName') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(5, 'status', 'audioOn') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(6, 'status', 'speaking') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(7, 'status', 'videoOn') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(8, 'status', 'nickChanged', 'theName') + + expect(webRtc.sendTo).toHaveBeenCalledTimes(6) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'unmute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(2, 'thePeerId', 'unmute', { name: 'video' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(3, 'thePeerId', 'nickChanged', { name: 'theName' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(4, 'thePeerId', 'unmute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(5, 'thePeerId', 'unmute', { name: 'video' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(6, 'thePeerId', 'nickChanged', { name: 'theName' }) + + callParticipantCollection.add({ peerId: 'thePeerId2', webRtc }) + + vi.advanceTimersByTime(3000) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(20) + for (let i = 0; i < 3; i++) { + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(i * 4 + 1, 'status', 'audioOn') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(i * 4 + 2, 'status', 'speaking') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(i * 4 + 3, 'status', 'videoOn') + expect(webRtc.sendDataChannelToAll).toHaveBeenNthCalledWith(i * 4 + 4, 'status', 'nickChanged', 'theName') + } + + expect(webRtc.sendTo).toHaveBeenCalledTimes(18) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(7, 'thePeerId2', 'unmute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(8, 'thePeerId2', 'unmute', { name: 'video' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(9, 'thePeerId2', 'nickChanged', { name: 'theName' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(10, 'thePeerId2', 'unmute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(11, 'thePeerId2', 'unmute', { name: 'video' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(12, 'thePeerId2', 'nickChanged', { name: 'theName' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(13, 'thePeerId', 'unmute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(14, 'thePeerId', 'unmute', { name: 'video' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(15, 'thePeerId', 'nickChanged', { name: 'theName' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(16, 'thePeerId2', 'unmute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(17, 'thePeerId2', 'unmute', { name: 'video' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(18, 'thePeerId2', 'nickChanged', { name: 'theName' }) + + vi.advanceTimersByTime(100000) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(32) + expect(webRtc.sendTo).toHaveBeenCalledTimes(36) + }) + + test('remove one of several participants while sending initial state', () => { + vi.mocked(internalWebRtc.isAudioEnabled).mockReturnValue(true) + vi.mocked(internalWebRtc.isSpeaking).mockReturnValue(true) + vi.mocked(internalWebRtc.isVideoEnabled).mockReturnValue(true) + localCallParticipantModel.set('guestName', 'theName') + + localStateBroadcaster = new LocalStateBroadcasterMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + vi.advanceTimersByTime(1000) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(8) + expect(webRtc.sendTo).toHaveBeenCalledTimes(6) + + callParticipantCollection.add({ peerId: 'thePeerId2', webRtc }) + + vi.advanceTimersByTime(3000) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(20) + expect(webRtc.sendTo).toHaveBeenCalledTimes(18) + + callParticipantCollection.remove('thePeerId') + + vi.advanceTimersByTime(100000) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(32) + expect(webRtc.sendTo).toHaveBeenCalledTimes(27) + }) + + test('remove the last participant', () => { + vi.mocked(internalWebRtc.isAudioEnabled).mockReturnValue(true) + vi.mocked(internalWebRtc.isSpeaking).mockReturnValue(true) + vi.mocked(internalWebRtc.isVideoEnabled).mockReturnValue(true) + localCallParticipantModel.set('guestName', 'theName') + + localStateBroadcaster = new LocalStateBroadcasterMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + vi.advanceTimersByTime(1000) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(8) + expect(webRtc.sendTo).toHaveBeenCalledTimes(6) + + callParticipantCollection.remove('thePeerId') + + vi.advanceTimersByTime(100000) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(8) + expect(webRtc.sendTo).toHaveBeenCalledTimes(6) + }) + + test('destroy while sending initial state', () => { + localStateBroadcaster = new LocalStateBroadcasterMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + vi.advanceTimersByTime(1000) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(6) + expect(webRtc.sendTo).toHaveBeenCalledTimes(6) + + localStateBroadcaster.destroy() + + vi.advanceTimersByTime(10000) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(6) + expect(webRtc.sendTo).toHaveBeenCalledTimes(6) + }) + + test('add and remove participant after destroying', () => { + localStateBroadcaster = new LocalStateBroadcasterMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + localStateBroadcaster.destroy() + + callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + vi.advanceTimersByTime(10000) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(0) + expect(webRtc.sendTo).toHaveBeenCalledTimes(0) + + callParticipantCollection.remove('thePeerId') + + vi.advanceTimersByTime(10000) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(0) + expect(webRtc.sendTo).toHaveBeenCalledTimes(0) + }) + }) + + describe('LocalStateBroadcasterNoMcu', () => { + test('add participant and change to connected', () => { + vi.mocked(internalWebRtc.isAudioEnabled).mockReturnValue(true) + vi.mocked(internalWebRtc.isSpeaking).mockReturnValue(true) + vi.mocked(internalWebRtc.isVideoEnabled).mockReturnValue(true) + localCallParticipantModel.set('guestName', 'theName') + + localStateBroadcaster = new LocalStateBroadcasterNoMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + const callParticipantModel = callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + callParticipantModel.set('peer', new PeerMock('thePeerId')) + + callParticipantModel.set('connectionState', ConnectionState.CHECKING) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(0) + expect(webRtc.sendTo).toHaveBeenCalledTimes(0) + + callParticipantModel.set('connectionState', ConnectionState.CONNECTED) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(3) + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'status', 'audioOn') + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(2, 'thePeerId', 'status', 'speaking') + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(3, 'thePeerId', 'status', 'videoOn') + + expect(webRtc.sendTo).toHaveBeenCalledTimes(2) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'unmute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(2, 'thePeerId', 'unmute', { name: 'video' }) + }) + + test('add participant and change to completed', () => { + vi.mocked(internalWebRtc.isAudioEnabled).mockReturnValue(true) + vi.mocked(internalWebRtc.isSpeaking).mockReturnValue(true) + vi.mocked(internalWebRtc.isVideoEnabled).mockReturnValue(true) + localCallParticipantModel.set('guestName', 'theName') + + localStateBroadcaster = new LocalStateBroadcasterNoMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + const callParticipantModel = callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + callParticipantModel.set('peer', new PeerMock('thePeerId')) + + callParticipantModel.set('connectionState', ConnectionState.CHECKING) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(0) + expect(webRtc.sendTo).toHaveBeenCalledTimes(0) + + callParticipantModel.set('connectionState', ConnectionState.COMPLETED) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(3) + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'status', 'audioOn') + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(2, 'thePeerId', 'status', 'speaking') + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(3, 'thePeerId', 'status', 'videoOn') + + expect(webRtc.sendTo).toHaveBeenCalledTimes(2) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'unmute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(2, 'thePeerId', 'unmute', { name: 'video' }) + }) + + test('add participant and change to connected and then completed', () => { + localStateBroadcaster = new LocalStateBroadcasterNoMcu(webRtc, callParticipantCollection, localCallParticipantModel) + localCallParticipantModel.set('guestName', 'theName') + + const callParticipantModel = callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + callParticipantModel.set('peer', new PeerMock('thePeerId')) + + callParticipantModel.set('connectionState', ConnectionState.CHECKING) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(0) + expect(webRtc.sendTo).toHaveBeenCalledTimes(0) + + callParticipantModel.set('connectionState', ConnectionState.CONNECTED) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(2) + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'status', 'audioOff') + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(2, 'thePeerId', 'status', 'videoOff') + + expect(webRtc.sendTo).toHaveBeenCalledTimes(2) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'mute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(2, 'thePeerId', 'mute', { name: 'video' }) + + callParticipantModel.set('connectionState', ConnectionState.COMPLETED) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(2) + expect(webRtc.sendTo).toHaveBeenCalledTimes(2) + }) + + test('add participant and change to connected from different states', () => { + localStateBroadcaster = new LocalStateBroadcasterNoMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + const callParticipantModel = callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + callParticipantModel.set('peer', new PeerMock('thePeerId')) + + callParticipantModel.set('connectionState', ConnectionState.CHECKING) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(0) + expect(webRtc.sendTo).toHaveBeenCalledTimes(0) + + callParticipantModel.set('connectionState', ConnectionState.CONNECTED) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(2) + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'status', 'audioOff') + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(2, 'thePeerId', 'status', 'videoOff') + + expect(webRtc.sendTo).toHaveBeenCalledTimes(2) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'mute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(2, 'thePeerId', 'mute', { name: 'video' }) + + callParticipantModel.set('connectionState', ConnectionState.COMPLETED) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(2) + expect(webRtc.sendTo).toHaveBeenCalledTimes(2) + + // Completed -> Connected could happen with an ICE restart + callParticipantModel.set('connectionState', ConnectionState.CONNECTED) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(2) + expect(webRtc.sendTo).toHaveBeenCalledTimes(2) + + callParticipantModel.set('connectionState', ConnectionState.DISCONNECTED) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(2) + expect(webRtc.sendTo).toHaveBeenCalledTimes(2) + + callParticipantModel.set('connectionState', ConnectionState.CONNECTED) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(2) + expect(webRtc.sendTo).toHaveBeenCalledTimes(2) + + // Failed -> Checking could happen with an ICE restart + callParticipantModel.set('connectionState', ConnectionState.FAILED) + callParticipantModel.set('connectionState', ConnectionState.CHECKING) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(2) + expect(webRtc.sendTo).toHaveBeenCalledTimes(2) + + callParticipantModel.set('connectionState', ConnectionState.CONNECTED) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(2) + expect(webRtc.sendTo).toHaveBeenCalledTimes(2) + }) + + test('add several participants and change to connected', () => { + vi.mocked(internalWebRtc.isAudioEnabled).mockReturnValue(true) + vi.mocked(internalWebRtc.isSpeaking).mockReturnValue(true) + vi.mocked(internalWebRtc.isVideoEnabled).mockReturnValue(true) + localCallParticipantModel.set('guestName', 'theName') + + localStateBroadcaster = new LocalStateBroadcasterNoMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + const callParticipantModel = callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + const callParticipantModel2 = callParticipantCollection.add({ peerId: 'thePeerId2', webRtc }) + + callParticipantModel.set('peer', new PeerMock('thePeerId')) + callParticipantModel2.set('peer', new PeerMock('thePeerId2')) + + callParticipantModel.set('connectionState', ConnectionState.CHECKING) + callParticipantModel2.set('connectionState', ConnectionState.CHECKING) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(0) + expect(webRtc.sendTo).toHaveBeenCalledTimes(0) + + callParticipantModel.set('connectionState', ConnectionState.CONNECTED) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(3) + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'status', 'audioOn') + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(2, 'thePeerId', 'status', 'speaking') + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(3, 'thePeerId', 'status', 'videoOn') + + expect(webRtc.sendTo).toHaveBeenCalledTimes(2) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'unmute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(2, 'thePeerId', 'unmute', { name: 'video' }) + + vi.mocked(internalWebRtc.isAudioEnabled).mockReturnValue(false) + vi.mocked(internalWebRtc.isSpeaking).mockReturnValue(false) + vi.mocked(internalWebRtc.isVideoEnabled).mockReturnValue(false) + + callParticipantModel2.set('connectionState', ConnectionState.CONNECTED) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(5) + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(4, 'thePeerId2', 'status', 'audioOff') + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(5, 'thePeerId2', 'status', 'videoOff') + + expect(webRtc.sendTo).toHaveBeenCalledTimes(4) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(3, 'thePeerId2', 'mute', { name: 'audio' }) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(4, 'thePeerId2', 'mute', { name: 'video' }) + }) + + test('set null peer for participant as user', () => { + webRtc.connection.settings.userId = 'theUserId' + localCallParticipantModel.set('guestName', 'theName') + + localStateBroadcaster = new LocalStateBroadcasterNoMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + const callParticipantModel = callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + callParticipantModel.set('peer', null) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(1) + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'status', 'nickChanged', { name: 'theName', userid: 'theUserId' }) + + expect(webRtc.sendTo).toHaveBeenCalledTimes(1) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'nickChanged', { name: 'theName' }) + }) + + test('set null peer for participant as guest', () => { + localCallParticipantModel.set('guestName', 'theName') + + localStateBroadcaster = new LocalStateBroadcasterNoMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + const callParticipantModel = callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + callParticipantModel.set('peer', null) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(1) + expect(webRtc.sendDataChannelTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'status', 'nickChanged', 'theName') + + expect(webRtc.sendTo).toHaveBeenCalledTimes(1) + expect(webRtc.sendTo).toHaveBeenNthCalledWith(1, 'thePeerId', 'nickChanged', { name: 'theName' }) + }) + + test('remove participant and change to connected', () => { + localStateBroadcaster = new LocalStateBroadcasterNoMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + const callParticipantModel = callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + callParticipantModel.set('peer', new PeerMock('thePeerId')) + + callParticipantModel.set('connectionState', ConnectionState.CHECKING) + + callParticipantCollection.remove('thePeerId') + + callParticipantModel.set('connectionState', ConnectionState.CONNECTED) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(0) + expect(webRtc.sendTo).toHaveBeenCalledTimes(0) + }) + + test('remove participant and change to completed', () => { + localStateBroadcaster = new LocalStateBroadcasterNoMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + const callParticipantModel = callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + callParticipantModel.set('peer', new PeerMock('thePeerId')) + + callParticipantModel.set('connectionState', ConnectionState.CHECKING) + + callParticipantCollection.remove('thePeerId') + + callParticipantModel.set('connectionState', ConnectionState.COMPLETED) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(0) + expect(webRtc.sendTo).toHaveBeenCalledTimes(0) + }) + + test('remove participant and set null peer', () => { + localStateBroadcaster = new LocalStateBroadcasterNoMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + const callParticipantModel = callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + callParticipantCollection.remove('thePeerId') + + callParticipantModel.set('peer', null) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(0) + expect(webRtc.sendTo).toHaveBeenCalledTimes(0) + }) + + test('destroy and change to connected', () => { + localStateBroadcaster = new LocalStateBroadcasterNoMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + const callParticipantModel = callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + callParticipantModel.set('peer', new PeerMock('thePeerId')) + + callParticipantModel.set('connectionState', ConnectionState.CHECKING) + + localStateBroadcaster.destroy() + + callParticipantModel.set('connectionState', ConnectionState.CONNECTED) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(0) + expect(webRtc.sendTo).toHaveBeenCalledTimes(0) + }) + + test('destroy and change to completed', () => { + localStateBroadcaster = new LocalStateBroadcasterNoMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + const callParticipantModel = callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + callParticipantModel.set('peer', new PeerMock('thePeerId')) + + callParticipantModel.set('connectionState', ConnectionState.CHECKING) + + localStateBroadcaster.destroy() + + callParticipantModel.set('connectionState', ConnectionState.COMPLETED) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(0) + expect(webRtc.sendTo).toHaveBeenCalledTimes(0) + }) + + test('destroy and set null peer', () => { + localStateBroadcaster = new LocalStateBroadcasterNoMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + const callParticipantModel = callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + + localStateBroadcaster.destroy() + + callParticipantModel.set('peer', null) + + expect(webRtc.sendDataChannelTo).toHaveBeenCalledTimes(0) + expect(webRtc.sendTo).toHaveBeenCalledTimes(0) + }) + + test('add and remove participant after destroying', () => { + localStateBroadcaster = new LocalStateBroadcasterNoMcu(webRtc, callParticipantCollection, localCallParticipantModel) + + localStateBroadcaster.destroy() + + const callParticipantModel = callParticipantCollection.add({ peerId: 'thePeerId', webRtc }) + const callParticipantModel2 = callParticipantCollection.add({ peerId: 'thePeerId2', webRtc }) + const callParticipantModel3 = callParticipantCollection.add({ peerId: 'thePeerId3', webRtc }) + + callParticipantModel.set('peer', new PeerMock('thePeerId')) + callParticipantModel2.set('peer', new PeerMock('thePeerId2')) + + callParticipantModel.set('connectionState', ConnectionState.CHECKING) + + callParticipantCollection.remove('thePeerId') + + callParticipantModel.set('connectionState', ConnectionState.CONNECTED) + callParticipantModel2.set('connectionState', ConnectionState.CONNECTED) + callParticipantModel3.set('peer', null) + + expect(webRtc.sendDataChannelToAll).toHaveBeenCalledTimes(0) + expect(webRtc.sendTo).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/src/utils/webrtc/LocalStateBroadcaster.ts b/src/utils/webrtc/LocalStateBroadcaster.ts new file mode 100644 index 00000000000..7dd28dd7668 --- /dev/null +++ b/src/utils/webrtc/LocalStateBroadcaster.ts @@ -0,0 +1,430 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { + CallParticipantCollection, + CallParticipantModel, + LocalCallParticipantModel, + WebRtc, +} from '../../types/index.ts' + +import { ConnectionState } from './models/CallParticipantModel.js' + +/** + * Helper class to send the local participant state to the other participants in + * the call. + * + * Once created, and until destroyed, the LocalStateBroadcaster will send the + * changes in the local participant state to all the participants in the call. + * Note that the LocalStateBroadcaster does not check whether the local + * participant is actually in the call or not; it is expected that the + * LocalStateBroadcaster will be created and destroyed when the local + * participant joins and leaves the call. + * + * The LocalStateBroadcaster also sends the current state to remote participants + * when they join (which implicitly sends it to all remote participants when the + * local participant joins the call) so they can set an initial state for the + * local participant. + */ +export abstract class LocalStateBroadcaster { + protected _webRtc: WebRtc + private _callParticipantCollection: CallParticipantCollection + protected _localCallParticipantModel: LocalCallParticipantModel + + private _handleAudioOnBound: () => void + private _handleAudioOffBound: () => void + private _handleSpeakingBound: () => void + private _handleStoppedSpeakingBound: () => void + private _handleVideoOnBound: () => void + private _handleVideoOffBound: () => void + private _handleChangeGuestNameBound: (localCallParticipantModel: LocalCallParticipantModel, guestName: string) => void + + private _handleAddCallParticipantModelBound: (callParticipantCollection: CallParticipantCollection, callParticipantModel: CallParticipantModel) => void + private _handleRemoveCallParticipantModelBound: (callParticipantCollection: CallParticipantCollection, callParticipantModel: CallParticipantModel) => void + + public constructor(webRtc: WebRtc, callParticipantCollection: CallParticipantCollection, localCallParticipantModel: LocalCallParticipantModel) { + this._webRtc = webRtc + this._callParticipantCollection = callParticipantCollection + this._localCallParticipantModel = localCallParticipantModel + + this._handleAudioOnBound = this._handleAudioOn.bind(this) + this._handleAudioOffBound = this._handleAudioOff.bind(this) + this._handleSpeakingBound = this._handleSpeaking.bind(this) + this._handleStoppedSpeakingBound = this._handleStoppedSpeaking.bind(this) + this._handleVideoOnBound = this._handleVideoOn.bind(this) + this._handleVideoOffBound = this._handleVideoOff.bind(this) + this._handleChangeGuestNameBound = this._handleChangeGuestName.bind(this) + + this._handleAddCallParticipantModelBound = this._handleAddCallParticipantModel.bind(this) + this._handleRemoveCallParticipantModelBound = this._handleRemoveCallParticipantModel.bind(this) + + this._webRtc.on('audioOn', this._handleAudioOnBound) + this._webRtc.on('audioOff', this._handleAudioOffBound) + this._webRtc.on('speaking', this._handleSpeakingBound) + this._webRtc.on('stoppedSpeaking', this._handleStoppedSpeakingBound) + this._webRtc.on('videoOn', this._handleVideoOnBound) + this._webRtc.on('videoOff', this._handleVideoOffBound) + + this._localCallParticipantModel.on('change:guestName', this._handleChangeGuestNameBound) + + this._callParticipantCollection.on('add', this._handleAddCallParticipantModelBound) + this._callParticipantCollection.on('remove', this._handleRemoveCallParticipantModelBound) + } + + public destroy(): void { + this._webRtc.off('audioOn', this._handleAudioOnBound) + this._webRtc.off('audioOff', this._handleAudioOffBound) + this._webRtc.off('speaking', this._handleSpeakingBound) + this._webRtc.off('stoppedSpeaking', this._handleStoppedSpeakingBound) + this._webRtc.off('videoOn', this._handleVideoOnBound) + this._webRtc.off('videoOff', this._handleVideoOffBound) + + this._localCallParticipantModel.off('change:guestName', this._handleChangeGuestNameBound) + + this._callParticipantCollection.off('add', this._handleAddCallParticipantModelBound) + this._callParticipantCollection.off('remove', this._handleRemoveCallParticipantModelBound) + } + + protected abstract _handleAddCallParticipantModel(callParticipantCollection: CallParticipantCollection, callParticipantModel: CallParticipantModel): void + protected abstract _handleRemoveCallParticipantModel(callParticipantCollection: CallParticipantCollection, callParticipantModel: CallParticipantModel): void + + private _handleAudioOn(): void { + this._webRtc.sendDataChannelToAll('status', 'audioOn') + + this._webRtc.sendToAll('unmute', { name: 'audio' }) + } + + private _handleAudioOff(): void { + this._webRtc.sendDataChannelToAll('status', 'audioOff') + + this._webRtc.sendToAll('mute', { name: 'audio' }) + } + + private _handleSpeaking(): void { + this._webRtc.sendDataChannelToAll('status', 'speaking') + } + + private _handleStoppedSpeaking(): void { + this._webRtc.sendDataChannelToAll('status', 'stoppedSpeaking') + } + + private _handleVideoOn(): void { + this._webRtc.sendDataChannelToAll('status', 'videoOn') + + this._webRtc.sendToAll('unmute', { name: 'video' }) + } + + private _handleVideoOff(): void { + this._webRtc.sendDataChannelToAll('status', 'videoOff') + + this._webRtc.sendToAll('mute', { name: 'video' }) + } + + private _handleChangeGuestName(localCallParticipantModel: LocalCallParticipantModel, guestName: string): void { + this._webRtc.sendDataChannelToAll('status', 'nickChanged', this._getNickChangedDataChannelMessagePayload(guestName)) + + this._webRtc.sendToAll('nickChanged', { name: guestName }) + } + + protected _getNickChangedDataChannelMessagePayload(name: string): string | object { + if (this._webRtc.connection.settings.userId === null) { + return name + } + + return { + name, + userid: this._webRtc.connection.settings.userId, + } + } +} + +class ExponentialBackoffCallback { + private _timeout?: ReturnType | null | undefined + private _callback: () => void + + constructor(callback: () => void) { + this._callback = callback + + this._runCallbackWithRepetition() + } + + destroy(): void { + if (this._timeout) { + clearTimeout(this._timeout) + } + + this._timeout = null + } + + private _runCallbackWithRepetition(timeout?: number): void { + if (!timeout) { + timeout = 0 + } + + this._timeout = setTimeout(() => { + this._callback() + + if (!timeout) { + timeout = 1000 + } else { + timeout *= 2 + } + + if (timeout > 16000) { + this._timeout = null + return + } + + this._runCallbackWithRepetition(timeout) + }, timeout) + } +} + +/** + * Helper class to send the local participant state to the other participants in + * the call when an MCU is used. + * + * Sending the state when it changes is handled by the base class; this subclass + * only handles sending the initial state when a remote participant is added. + * + * When Janus is used data channel messages are sent to all remote participants + * (with a peer connection to receive from the local participant). Moreover, it + * is not possible to know when the remote participants open the data channel to + * receive the messages, or even when they establish the receiver connection; it + * is only possible to know when the data channel is open for the publisher + * connection of the local participant. Due to all that the state is sent + * several times with an increasing delay whenever a participant joins the call + * (which implicitly broadcasts the initial state when the local participant + * joins the call, as all the remote participants joined from the point of view + * of the local participant). If the state was already being sent the sending is + * restarted with each new participant that joins. + * + * Similarly, in the case of signaling messages it is not possible either to + * know when the remote participants have "seen" the local participant and thus + * are ready to handle signaling messages about the state. However, in the case + * of signaling messages it is possible to send them to a specific participant, + * so the initial state is sent several times with an increasing delay directly + * to the participant that was added. Moreover, if the participant is removed + * the state is no longer directly sent. + * + * In any case, note that the state is sent only when the remote participant + * joins the call. Even in case of temporary disconnections the normal state + * updates sent when the state changes are expected to be received by the + * other participant, as signaling messages are sent through a WebSocket and are + * therefore reliable. Moreover, even if the WebSocket is restarted and the + * connection resumed (rather than joining with a new session ID) the messages + * would be also received, as in that case they would be queued until the + * WebSocket is connected again. + * + * Data channel messages, on the other hand, could be lost if the remote + * participant restarts the peer receiver connection (although they would be + * received in case of temporary disconnections, as data channels use a reliable + * transport by default). Therefore, as the speaking state is sent only through + * data channels, updates of the speaking state could be not received by remote + * participants. + */ +export class LocalStateBroadcasterMcu extends LocalStateBroadcaster { + private _sendStateWithRepetition?: ExponentialBackoffCallback | null + private _sendStateWithRepetitionToParticipant: Map + + public constructor(webRtc: WebRtc, callParticipantCollection: CallParticipantCollection, localCallParticipantModel: LocalCallParticipantModel) { + super(webRtc, callParticipantCollection, localCallParticipantModel) + + this._sendStateWithRepetition = null + this._sendStateWithRepetitionToParticipant = new Map() + } + + public destroy(): void { + super.destroy() + + this._sendStateWithRepetition?.destroy() + + this._sendStateWithRepetitionToParticipant.forEach((sendStateWithRepetitionToParticipant) => { + sendStateWithRepetitionToParticipant.destroy() + }) + } + + protected _handleAddCallParticipantModel(callParticipantCollection: CallParticipantCollection, callParticipantModel: CallParticipantModel): void { + this._sendStateWithRepetition?.destroy() + + this._sendStateWithRepetition = new ExponentialBackoffCallback(() => { + this._sendCurrentStateToAll() + }) + + const peerId = callParticipantModel.get('peerId') + + this._sendStateWithRepetitionToParticipant.get(peerId)?.destroy() + + this._sendStateWithRepetitionToParticipant.set(peerId, new ExponentialBackoffCallback(() => { + this._sendCurrentStateTo(peerId) + })) + } + + protected _handleRemoveCallParticipantModel(callParticipantCollection: CallParticipantCollection, callParticipantModel: CallParticipantModel): void { + if (callParticipantCollection.callParticipantModels.length === 0 && this._sendStateWithRepetition) { + this._sendStateWithRepetition.destroy() + this._sendStateWithRepetition = null + } + + const peerId = callParticipantModel.get('peerId') + + this._sendStateWithRepetitionToParticipant.get(peerId)?.destroy() + + this._sendStateWithRepetitionToParticipant.delete(peerId) + } + + private _sendCurrentStateToAll(): void { + if (!this._webRtc.webrtc.isAudioEnabled()) { + this._webRtc.sendDataChannelToAll('status', 'audioOff') + } else { + this._webRtc.sendDataChannelToAll('status', 'audioOn') + + if (!this._webRtc.webrtc.isSpeaking()) { + this._webRtc.sendDataChannelToAll('status', 'stoppedSpeaking') + } else { + this._webRtc.sendDataChannelToAll('status', 'speaking') + } + } + + if (!this._webRtc.webrtc.isVideoEnabled()) { + this._webRtc.sendDataChannelToAll('status', 'videoOff') + } else { + this._webRtc.sendDataChannelToAll('status', 'videoOn') + } + + const name = this._localCallParticipantModel.get('guestName') + this._webRtc.sendDataChannelToAll('status', 'nickChanged', this._getNickChangedDataChannelMessagePayload(name)) + } + + private _sendCurrentStateTo(peerId: string): void { + if (!this._webRtc.webrtc.isAudioEnabled()) { + this._webRtc.sendTo(peerId, 'mute', { name: 'audio' }) + } else { + this._webRtc.sendTo(peerId, 'unmute', { name: 'audio' }) + } + + if (!this._webRtc.webrtc.isVideoEnabled()) { + this._webRtc.sendTo(peerId, 'mute', { name: 'video' }) + } else { + this._webRtc.sendTo(peerId, 'unmute', { name: 'video' }) + } + + const name = this._localCallParticipantModel.get('guestName') + this._webRtc.sendTo(peerId, 'nickChanged', { name }) + } +} + +/** + * Helper class to send the local participant state to the other participants in + * the call when an MCU is not used. + * + * Sending the state when it changes is handled by the base class; this subclass + * only handles sending the initial state when a remote participant is added. + * + * The state is sent when a connection with another participant is first + * established (which implicitly broadcasts the initial state when the local + * participant joins the call, as a connection is established with all the + * remote participants). Note that, as long as that participant stays in the + * call, the initial state is not sent again, even after a temporary + * disconnection; data channels use a reliable transport by default, so even if + * the state changes while the connection is temporarily interrupted the normal + * state update messages should be received by the other participant once the + * connection is restored. + * + * Nevertheless, in case of a failed connection and an ICE restart it is unclear + * whether the data channel messages would be received or not (as the data + * channel transport may be the one that failed and needs to be restarted). + * However, the state (except the speaking state) is also sent through signaling + * messages, which need to be explicitly fetched from the internal signaling + * server, so even in case of a failed connection they will be eventually + * received once the remote participant connects again. + */ +export class LocalStateBroadcasterNoMcu extends LocalStateBroadcaster { + private _callParticipantModels: Map + + private _handleConnectionStateBound: (callParticipantModel: CallParticipantModel, connectionState: string) => void + private _handlePeerBound: (callParticipantModel: CallParticipantModel, peer?: object) => void + + public constructor(webRtc: WebRtc, callParticipantCollection: CallParticipantCollection, localCallParticipantModel: LocalCallParticipantModel) { + super(webRtc, callParticipantCollection, localCallParticipantModel) + + this._callParticipantModels = new Map() + + this._handleConnectionStateBound = this._handleConnectionState.bind(this) + this._handlePeerBound = this._handlePeer.bind(this) + } + + public destroy(): void { + super.destroy() + + this._callParticipantModels.forEach((callParticipantModel) => { + callParticipantModel.off('change:connectionState', this._handleConnectionStateBound) + callParticipantModel.off('change:peer', this._handlePeerBound) + }) + } + + protected _handleAddCallParticipantModel(callParticipantCollection: CallParticipantCollection, callParticipantModel: CallParticipantModel): void { + this._callParticipantModels.set(callParticipantModel.get('peerId'), callParticipantModel) + + callParticipantModel.on('change:connectionState', this._handleConnectionStateBound) + callParticipantModel.on('change:peer', this._handlePeerBound) + } + + protected _handleRemoveCallParticipantModel(callParticipantCollection: CallParticipantCollection, callParticipantModel: CallParticipantModel): void { + this._callParticipantModels.delete(callParticipantModel.get('peerId')) + + callParticipantModel.off('change:connectionState', this._handleConnectionStateBound) + callParticipantModel.off('change:peer', this._handlePeerBound) + } + + private _handleConnectionState(callParticipantModel: CallParticipantModel, connectionState: string): void { + if (connectionState === ConnectionState.CONNECTED + || connectionState === ConnectionState.COMPLETED) { + this._sendCurrentMediaStateTo(callParticipantModel.get('peerId')) + + callParticipantModel.off('change:connectionState', this._handleConnectionStateBound) + callParticipantModel.off('change:peer', this._handlePeerBound) + } + } + + private _handlePeer(callParticipantModel: CallParticipantModel, peer?: object): void { + if (peer !== null) { + return + } + + this._sendCurrentNameTo(callParticipantModel.get('peerId')) + } + + private _sendCurrentMediaStateTo(peerId: string): void { + if (!this._webRtc.webrtc.isAudioEnabled()) { + this._webRtc.sendDataChannelTo(peerId, 'status', 'audioOff') + this._webRtc.sendTo(peerId, 'mute', { name: 'audio' }) + } else { + this._webRtc.sendDataChannelTo(peerId, 'status', 'audioOn') + this._webRtc.sendTo(peerId, 'unmute', { name: 'audio' }) + + if (!this._webRtc.webrtc.isSpeaking()) { + this._webRtc.sendDataChannelTo(peerId, 'status', 'stoppedSpeaking') + } else { + this._webRtc.sendDataChannelTo(peerId, 'status', 'speaking') + } + } + + if (!this._webRtc.webrtc.isVideoEnabled()) { + this._webRtc.sendDataChannelTo(peerId, 'status', 'videoOff') + this._webRtc.sendTo(peerId, 'mute', { name: 'video' }) + } else { + this._webRtc.sendDataChannelTo(peerId, 'status', 'videoOn') + this._webRtc.sendTo(peerId, 'unmute', { name: 'video' }) + } + } + + private _sendCurrentNameTo(peerId: string): void { + const name = this._localCallParticipantModel.get('guestName') + + this._webRtc.sendDataChannelTo(peerId, 'status', 'nickChanged', this._getNickChangedDataChannelMessagePayload(name)) + this._webRtc.sendTo(peerId, 'nickChanged', { name }) + } +} diff --git a/src/utils/webrtc/index.js b/src/utils/webrtc/index.js index d1d40a55de9..8f1f3122165 100644 --- a/src/utils/webrtc/index.js +++ b/src/utils/webrtc/index.js @@ -17,9 +17,9 @@ import SignalingTypingHandler from '../SignalingTypingHandler.js' import CallAnalyzer from './analyzers/CallAnalyzer.js' import CallParticipantsAudioPlayer from './CallParticipantsAudioPlayer.js' import MediaDevicesManager from './MediaDevicesManager.js' -import CallParticipantCollection from './models/CallParticipantCollection.js' -import LocalCallParticipantModel from './models/LocalCallParticipantModel.js' -import LocalMediaModel from './models/LocalMediaModel.js' +import { CallParticipantCollection } from './models/CallParticipantCollection.js' +import { LocalCallParticipantModel } from './models/LocalCallParticipantModel.js' +import { LocalMediaModel } from './models/LocalMediaModel.js' import SentVideoQualityThrottler from './SentVideoQualityThrottler.js' import SpeakingStatusHandler from './SpeakingStatusHandler.js' import initWebRtc from './webrtc.js' diff --git a/src/utils/webrtc/models/CallParticipantCollection.js b/src/utils/webrtc/models/CallParticipantCollection.js index 0298377d348..9b27d875326 100644 --- a/src/utils/webrtc/models/CallParticipantCollection.js +++ b/src/utils/webrtc/models/CallParticipantCollection.js @@ -5,12 +5,12 @@ import { reactive } from 'vue' import EmitterMixin from '../../EmitterMixin.js' -import CallParticipantModel from './CallParticipantModel.js' +import { CallParticipantModel } from './CallParticipantModel.js' /** * */ -export default function CallParticipantCollection() { +export function CallParticipantCollection() { this._superEmitterMixin() this.callParticipantModels = reactive([]) diff --git a/src/utils/webrtc/models/CallParticipantModel.js b/src/utils/webrtc/models/CallParticipantModel.js index 32866ea7168..525feb23f43 100644 --- a/src/utils/webrtc/models/CallParticipantModel.js +++ b/src/utils/webrtc/models/CallParticipantModel.js @@ -22,7 +22,7 @@ export const ConnectionState = { * @param {string} options.peerId The peerId of the participant * @param {object} options.webRtc The WebRTC connection to the participant */ -export default function CallParticipantModel(options) { +export function CallParticipantModel(options) { this._superEmitterMixin() this.attributes = reactive({ diff --git a/src/utils/webrtc/models/LocalCallParticipantModel.js b/src/utils/webrtc/models/LocalCallParticipantModel.js index 0e4060c5379..44a3dfd4547 100644 --- a/src/utils/webrtc/models/LocalCallParticipantModel.js +++ b/src/utils/webrtc/models/LocalCallParticipantModel.js @@ -13,7 +13,7 @@ const actorStore = useActorStore(pinia) /** * */ -export default function LocalCallParticipantModel() { +export function LocalCallParticipantModel() { this._superEmitterMixin() this.attributes = reactive({ @@ -54,7 +54,7 @@ LocalCallParticipantModel.prototype = { this._webRtc = webRtc this.set('peerId', this._webRtc.connection.getSessionId()) - this.set('guestName', null) + this.set('guestName', actorStore.displayName) this._webRtc.on('forcedMute', this._handleForcedMuteBound) this._unwatchDisplayNameChange = watch( @@ -110,8 +110,6 @@ LocalCallParticipantModel.prototype = { } this.set('guestName', guestName) - - this._webRtc.webrtc.emit('nickChanged', guestName) }, setPeerNeeded(peerNeeded) { diff --git a/src/utils/webrtc/models/LocalMediaModel.js b/src/utils/webrtc/models/LocalMediaModel.js index 433736547cf..532d384eea0 100644 --- a/src/utils/webrtc/models/LocalMediaModel.js +++ b/src/utils/webrtc/models/LocalMediaModel.js @@ -13,7 +13,7 @@ import EmitterMixin from '../../EmitterMixin.js' /** * */ -export default function LocalMediaModel() { +export function LocalMediaModel() { this._superEmitterMixin() this._tokenStore = useTokenStore(pinia) diff --git a/src/utils/webrtc/simplewebrtc/simplewebrtc.js b/src/utils/webrtc/simplewebrtc/simplewebrtc.js index 5a787e85ca4..0fce2fe0021 100644 --- a/src/utils/webrtc/simplewebrtc/simplewebrtc.js +++ b/src/utils/webrtc/simplewebrtc/simplewebrtc.js @@ -199,20 +199,6 @@ export default function SimpleWebRTC(opts) { // remote ice failure }) - // sending mute/unmute to all peers - this.webrtc.on('audioOn', function() { - self.webrtc.sendToAll('unmute', { name: 'audio' }) - }) - this.webrtc.on('audioOff', function() { - self.webrtc.sendToAll('mute', { name: 'audio' }) - }) - this.webrtc.on('videoOn', function() { - self.webrtc.sendToAll('unmute', { name: 'video' }) - }) - this.webrtc.on('videoOff', function() { - self.webrtc.sendToAll('mute', { name: 'video' }) - }) - // screensharing events this.webrtc.on('localScreen', function(stream) { self.emit('localScreenAdded') diff --git a/src/utils/webrtc/webrtc.js b/src/utils/webrtc/webrtc.js index 4d676f94be0..85a6d85369a 100644 --- a/src/utils/webrtc/webrtc.js +++ b/src/utils/webrtc/webrtc.js @@ -16,6 +16,10 @@ import { useActorStore } from '../../stores/actor.ts' import pinia from '../../stores/pinia.ts' import { useTokenStore } from '../../stores/token.ts' import { Sounds } from '../sounds.js' +import { + LocalStateBroadcasterMcu, + LocalStateBroadcasterNoMcu, +} from './LocalStateBroadcaster.ts' import SimpleWebRTC from './simplewebrtc/simplewebrtc.js' let webrtc @@ -34,10 +38,11 @@ const delayedConnectionToPeer = [] let callParticipantCollection = null let localCallParticipantModel = null let showedTURNWarning = false -let sendCurrentStateWithRepetitionTimeout = null const actorStore = useActorStore(pinia) const tokenStore = useTokenStore(pinia) +let localStateBroadcaster + /** * @param {Array} a Source object * @param {Array} b Object to find all items in @@ -175,67 +180,6 @@ function checkStartPublishOwnPeer(signaling) { }, 10000) } -/** - * - */ -function sendCurrentMediaState() { - if (!webrtc.webrtc.isVideoEnabled()) { - webrtc.webrtc.emit('videoOff') - } else { - webrtc.webrtc.emit('videoOn') - } - if (!webrtc.webrtc.isAudioEnabled()) { - webrtc.webrtc.emit('audioOff') - } else { - webrtc.webrtc.emit('audioOn') - - if (!webrtc.webrtc.isSpeaking()) { - webrtc.webrtc.emit('stoppedSpeaking') - } else { - webrtc.webrtc.emit('speaking') - } - } -} - -// TODO The participant name should be got from the participant list, but it is -// not currently possible to associate a Nextcloud ID with a standalone -// signaling ID for guests. -/** - * - */ -function sendCurrentNick() { - webrtc.webrtc.emit('nickChanged', actorStore.displayName) -} - -/** - * @param {number} timeout Time until we give up retrying - */ -function sendCurrentStateWithRepetition(timeout) { - if (!timeout) { - timeout = 0 - - clearTimeout(sendCurrentStateWithRepetitionTimeout) - } - - sendCurrentStateWithRepetitionTimeout = setTimeout(function() { - sendCurrentMediaState() - sendCurrentNick() - - if (!timeout) { - timeout = 1000 - } else { - timeout *= 2 - } - - if (timeout > 16000) { - sendCurrentStateWithRepetitionTimeout = null - return - } - - sendCurrentStateWithRepetition(timeout) - }, timeout) -} - /** * @param {object} user The user to check * @return {boolean} True if the user has an audio or video stream @@ -312,19 +256,6 @@ function usersChanged(signaling, newUsers, disconnectedSessionIds) { if ((signaling.hasFeature('mcu') && user && !userHasStreams(user)) || (!signaling.hasFeature('mcu') && user && !userHasStreams(user) && !webrtc.webrtc.localStreams.length)) { callParticipantModel.setPeer(null) - - // As there is no Peer for the other participant the current state - // will not be sent once it is connected, so it needs to be sent - // now. - // When there is no MCU this is only needed for the nick; as the - // local participant has no streams it will be automatically marked - // with audio and video not available on the other end, so there is - // no need to send the media state. - if (signaling.hasFeature('mcu')) { - sendCurrentStateWithRepetition() - } else { - sendCurrentNick() - } } playJoinSound = true @@ -582,6 +513,12 @@ export default function initWebRtc(signaling, _callParticipantCollection, _local // takes too long to return and the associated signaling message // is received before the "join call" request ends. localUserInCall = true + + if (signaling.hasFeature('mcu')) { + localStateBroadcaster = new LocalStateBroadcasterMcu(webrtc, callParticipantCollection, localCallParticipantModel) + } else { + localStateBroadcaster = new LocalStateBroadcasterNoMcu(webrtc, callParticipantCollection, localCallParticipantModel) + } }) signaling.on('beforeLeaveCall', function(token, reconnect) { // The user needs to be set as not in the call before the request is @@ -589,6 +526,9 @@ export default function initWebRtc(signaling, _callParticipantCollection, _local // takes too long to return and the associated signaling message // is received before the "leave call" request ends. localUserInCall = false + + localStateBroadcaster.destroy() + localStateBroadcaster = null }) signaling.on('leaveCall', function(token, reconnect) { // When the MCU is used and there is a connection error the call is @@ -711,7 +651,7 @@ export default function initWebRtc(signaling, _callParticipantCollection, _local webrtc.joinCall(token, mediaConstraints) } - const sendDataChannelToAll = function(channel, message, payload) { + webrtc.sendDataChannelToAll = function(channel, message, payload) { // If running with MCU, the message must be sent through the // publishing peer and will be distributed by the MCU to subscribers. if (signaling.hasFeature && signaling.hasFeature('mcu')) { @@ -724,19 +664,34 @@ export default function initWebRtc(signaling, _callParticipantCollection, _local webrtc.sendDirectlyToAll(channel, message, payload) } + webrtc.sendDataChannelTo = function(peerId, channel, message, payload) { + // There should be just one video peer with that id, but iterating is + // safer. + const peers = webrtc.getPeers(peerId, 'video') + peers.forEach(function(peer) { + peer.sendDirectly(channel, message, payload) + }) + } + + webrtc.sendTo = function(peerId, messageType, payload) { + const message = { + to: peerId, + // "roomType" is not really relevant without a peer or when + // referring to the whole participant, but it is nevertheless + // expected in the message. As most of the signaling messages + // currently sent to a single participant are related to audio/video + // state "video" is used as the room type. + roomType: 'video', + type: messageType, + payload, + } + signaling.emit('message', message) + } + /** * @param {object} peer The peer connection to handle the state on */ function handleIceConnectionStateConnected(peer) { - // Send the current information about the state. - if (!signaling.hasFeature('mcu')) { - // Only the media state needs to be sent, the nick was already sent - // in the offer/answer. - sendCurrentMediaState() - } else { - sendCurrentStateWithRepetition() - } - // Reset ice restart counter for peer if (spreedPeerConnectionTable[peer.id] > 0) { spreedPeerConnectionTable[peer.id] = 0 @@ -1615,53 +1570,6 @@ export default function initWebRtc(signaling, _callParticipantCollection, _local } }) - // Send the speaking status events via data channel - webrtc.on('speaking', function() { - sendDataChannelToAll('status', 'speaking') - }) - webrtc.on('stoppedSpeaking', function() { - sendDataChannelToAll('status', 'stoppedSpeaking') - }) - - // Send the audio on and off events via data channel - webrtc.on('audioOn', function() { - sendDataChannelToAll('status', 'audioOn') - }) - webrtc.on('audioOff', function() { - sendDataChannelToAll('status', 'audioOff') - }) - webrtc.on('videoOn', function() { - sendDataChannelToAll('status', 'videoOn') - }) - webrtc.on('videoOff', function() { - sendDataChannelToAll('status', 'videoOff') - }) - - // Send the nick changed event via data channel and signaling - // - // The message format is different in each case. Due to historical reasons - // the payload of the data channel message is either a string that contains - // the name (if the participant is a guest) or an object with "name" and - // "userid" string fields (when the participant is a user). - // - // In the newer signaling message, on the other hand, the payload is always - // an object with only a "name" string field. - webrtc.on('nickChanged', function(name) { - let payload - if (signaling.settings.userId === null) { - payload = name - } else { - payload = { - name, - userid: signaling.settings.userId, - } - } - - sendDataChannelToAll('status', 'nickChanged', payload) - - webrtc.sendToAll('nickChanged', { name }) - }) - // Local screen added. webrtc.on('localScreenAdded', function() { const currentSessionId = signaling.getSessionId()