diff --git a/src/room/CallEventAudioRenderer.test.tsx b/src/room/CallEventAudioRenderer.test.tsx new file mode 100644 index 00000000..9014e60b --- /dev/null +++ b/src/room/CallEventAudioRenderer.test.tsx @@ -0,0 +1,225 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { render } from "@testing-library/react"; +import { beforeEach, expect, test } from "vitest"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { ConnectionState, RemoteParticipant, Room } from "livekit-client"; +import { of } from "rxjs"; +import { afterEach } from "node:test"; +import { act } from "react"; + +import { soundEffectVolumeSetting } from "../settings/settings"; +import { + EmittableMockLivekitRoom, + mockLivekitRoom, + mockLocalParticipant, + mockMatrixRoom, + mockMatrixRoomMember, + mockMediaPlay, + mockRemoteParticipant, +} from "../utils/test"; +import { E2eeType } from "../e2ee/e2eeType"; +import { CallViewModel } from "../state/CallViewModel"; +import { + CallEventAudioRenderer, + MAX_PARTICIPANT_COUNT_FOR_SOUND, +} from "./CallEventAudioRenderer"; + +const alice = mockMatrixRoomMember({ userId: "@alice:example.org" }); +const bob = mockMatrixRoomMember({ userId: "@bob:example.org" }); +const aliceId = `${alice.userId}:AAAA`; +const bobId = `${bob.userId}:BBBB`; +const localParticipant = mockLocalParticipant({ identity: "" }); +const aliceParticipant = mockRemoteParticipant({ identity: aliceId }); +const bobParticipant = mockRemoteParticipant({ identity: bobId }); + +const originalPlayFn = window.HTMLMediaElement.prototype.play; + +const enterSound = "http://localhost:3000/src/sound/join_call.ogg"; +const leaveSound = "http://localhost:3000/src/sound/left_call.ogg"; + +beforeEach(() => { + soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue); +}); + +afterEach(() => { + window.HTMLMediaElement.prototype.play = originalPlayFn; +}); + +test("plays a sound when entering a call", () => { + const audioIsPlaying: string[] = mockMediaPlay(); + const members = new Map([alice, bob].map((p) => [p.userId, p])); + const remoteParticipants = of([aliceParticipant]); + const liveKitRoom = mockLivekitRoom( + { localParticipant }, + { remoteParticipants }, + ); + + const vm = new CallViewModel( + mockMatrixRoom({ + client: { + getUserId: () => "@carol:example.org", + } as Partial as MatrixClient, + getMember: (userId) => members.get(userId) ?? null, + }), + liveKitRoom, + { + kind: E2eeType.PER_PARTICIPANT, + }, + of(ConnectionState.Connected), + ); + + render(); + expect(audioIsPlaying).toEqual([ + // Joining the call + enterSound, + ]); +}); + +test("plays no sound when muted", () => { + soundEffectVolumeSetting.setValue(0); + const audioIsPlaying: string[] = mockMediaPlay(); + const members = new Map([alice, bob].map((p) => [p.userId, p])); + const remoteParticipants = of([aliceParticipant, bobParticipant]); + const liveKitRoom = mockLivekitRoom( + { localParticipant }, + { remoteParticipants }, + ); + + const vm = new CallViewModel( + mockMatrixRoom({ + client: { + getUserId: () => "@carol:example.org", + } as Partial as MatrixClient, + getMember: (userId) => members.get(userId) ?? null, + }), + liveKitRoom, + { + kind: E2eeType.PER_PARTICIPANT, + }, + of(ConnectionState.Connected), + ); + + render(); + // Play a sound when joining a call. + expect(audioIsPlaying).toHaveLength(0); +}); + +test("plays a sound when a user joins", () => { + const audioIsPlaying: string[] = mockMediaPlay(); + const members = new Map([alice].map((p) => [p.userId, p])); + const remoteParticipants = new Map( + [aliceParticipant].map((p) => [p.identity, p]), + ); + const liveKitRoom = new EmittableMockLivekitRoom({ + localParticipant, + remoteParticipants, + }); + + const vm = new CallViewModel( + mockMatrixRoom({ + client: { + getUserId: () => "@carol:example.org", + } as Partial as MatrixClient, + getMember: (userId) => members.get(userId) ?? null, + }), + liveKitRoom as unknown as Room, + { + kind: E2eeType.PER_PARTICIPANT, + }, + of(ConnectionState.Connected), + ); + render(); + + act(() => { + liveKitRoom.addParticipant(bobParticipant); + }); + // Play a sound when joining a call. + expect(audioIsPlaying).toEqual([ + // Joining the call + enterSound, + // Bob leaves + enterSound, + ]); +}); + +test("plays a sound when a user leaves", () => { + const audioIsPlaying: string[] = mockMediaPlay(); + const members = new Map([alice].map((p) => [p.userId, p])); + const remoteParticipants = new Map( + [aliceParticipant].map((p) => [p.identity, p]), + ); + const liveKitRoom = new EmittableMockLivekitRoom({ + localParticipant, + remoteParticipants, + }); + + const vm = new CallViewModel( + mockMatrixRoom({ + client: { + getUserId: () => "@carol:example.org", + } as Partial as MatrixClient, + getMember: (userId) => members.get(userId) ?? null, + }), + liveKitRoom as unknown as Room, + { + kind: E2eeType.PER_PARTICIPANT, + }, + of(ConnectionState.Connected), + ); + render(); + + act(() => { + liveKitRoom.removeParticipant(aliceParticipant); + }); + expect(audioIsPlaying).toEqual([ + // Joining the call + enterSound, + // Alice leaves + leaveSound, + ]); +}); + +test("plays no sound when the participant list", () => { + const audioIsPlaying: string[] = mockMediaPlay(); + const members = new Map([alice].map((p) => [p.userId, p])); + const remoteParticipants = new Map([ + [aliceParticipant.identity, aliceParticipant], + ...Array.from({ length: MAX_PARTICIPANT_COUNT_FOR_SOUND - 1 }).map< + [string, RemoteParticipant] + >((_, index) => { + const p = mockRemoteParticipant({ identity: `user${index}` }); + return [p.identity, p]; + }), + ]); + const liveKitRoom = new EmittableMockLivekitRoom({ + localParticipant, + remoteParticipants, + }); + + const vm = new CallViewModel( + mockMatrixRoom({ + client: { + getUserId: () => "@carol:example.org", + } as Partial as MatrixClient, + getMember: (userId) => members.get(userId) ?? null, + }), + liveKitRoom as unknown as Room, + { + kind: E2eeType.PER_PARTICIPANT, + }, + of(ConnectionState.Connected), + ); + render(); + expect(audioIsPlaying).toEqual([]); + // When the count drops + act(() => { + liveKitRoom.removeParticipant(aliceParticipant); + }); + expect(audioIsPlaying).toEqual([leaveSound]); +}); diff --git a/src/room/CallEventAudioRenderer.tsx b/src/room/CallEventAudioRenderer.tsx new file mode 100644 index 00000000..4f8213cf --- /dev/null +++ b/src/room/CallEventAudioRenderer.tsx @@ -0,0 +1,117 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { ReactNode, useEffect, useRef } from "react"; +import { filter } from "rxjs"; + +import { + soundEffectVolumeSetting as effectSoundVolumeSetting, + useSetting, +} from "../settings/settings"; +import { CallViewModel } from "../state/CallViewModel"; +import enterCallSoundMp3 from "../sound/join_call.mp3"; +import enterCallSoundOgg from "../sound/join_call.ogg"; +import leftCallSoundMp3 from "../sound/left_call.mp3"; +import leftCallSoundOgg from "../sound/left_call.ogg"; + +// Do not play any sounds if the participant count has exceeded this +// number. +export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8; +export const CONCURRENT_AUDIO_CHANNELS = 2; + +export function CallEventAudioRenderer({ + vm, +}: { + vm: CallViewModel; +}): ReactNode { + const [effectSoundVolume] = useSetting(effectSoundVolumeSetting); + const callEntered = useRef<(HTMLAudioElement | null)[]>([]); + const callLeft = useRef<(HTMLAudioElement | null)[]>([]); + + useEffect(() => { + if (effectSoundVolume === 0) { + return; + } + const joinSub = vm.memberChanges + .pipe( + filter( + ({ joined, ids }) => + ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && joined.length > 0, + ), + ) + .subscribe(({ joined }) => { + const availablePlayer = callEntered.current.find((v) => v?.paused); + void availablePlayer?.play(); + }); + + const leftSub = vm.memberChanges + .pipe( + filter( + ({ ids, left }) => + ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && left.length > 0, + ), + ) + .subscribe(() => { + const availablePlayer = callLeft.current.find((v) => v?.paused); + void availablePlayer?.play(); + }); + + return (): void => { + joinSub.unsubscribe(); + leftSub.unsubscribe(); + }; + }, [effectSoundVolume, callEntered, callLeft, vm]); + + // Set volume. + useEffect(() => { + callEntered.current.forEach((a) => { + if (a) { + a.volume = effectSoundVolume; + } + }); + callLeft.current.forEach((a) => { + if (a) { + a.volume = effectSoundVolume; + } + }); + }, [callEntered, callLeft, effectSoundVolume]); + + // Do not render any audio elements if playback is disabled. Will save + // audio file fetches. + if (effectSoundVolume === 0) { + return null; + } + + return ( + // Will play as soon as it's mounted, which is what we want as this will + // play when the call is entered. + <> + {Array.from({ length: CONCURRENT_AUDIO_CHANNELS }).map((_, index) => ( + + ))} + {Array.from({ length: CONCURRENT_AUDIO_CHANNELS }).map((_, index) => ( + + ))} + + ); +} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index f4340f47..f1afa3e4 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -87,6 +87,7 @@ import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; import { useSwitchCamera } from "./useSwitchCamera"; import { soundEffectVolumeSetting, useSetting } from "../settings/settings"; import { ReactionsOverlay } from "./ReactionsOverlay"; +import { CallEventAudioRenderer } from "./CallEventAudioRenderer"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -670,6 +671,7 @@ export const InCallView: FC = ({ ))} {renderContent()} +