diff --git a/locales/de/app.json b/locales/de/app.json index 7c465406..aa57f824 100644 --- a/locales/de/app.json +++ b/locales/de/app.json @@ -68,8 +68,12 @@ "username": "Benutzername", "video": "Video" }, - "crypto_version": "Krypto-Version:{{version}}", - "device_id": "Geräte-ID: {{id}}", + "developer_mode": { + "crypto_version": "Krypto-Version:{{version}}", + "device_id": "Geräte-ID: {{id}}", + "duplicate_tiles_label": "Anzahl zusätzlicher Kachelkopien pro Teilnehmer", + "matrix_id": "Matrix-ID: {{id}}" + }, "disconnected_banner": "Die Verbindung zum Server wurde getrennt.", "full_screen_view_description": "<0>Übermittelte Problemberichte helfen uns, Fehler zu beheben.", "full_screen_view_h1": "<0>Hoppla, etwas ist schiefgelaufen.", @@ -111,7 +115,6 @@ "login_auth_links_prompt": "Noch nicht registriert?", "login_subheading": "Weiter zu Element", "login_title": "Anmelden", - "matrix_id": "Matrix-ID: {{id}}", "microphone_off": "Mikrofon aus", "microphone_on": "Mikrofon an", "mute_microphone_button_label": "Mikrofon stumm schalten", @@ -149,7 +152,6 @@ "developer_settings_label": "Entwicklereinstellungen", "developer_settings_label_description": "Zeige die Entwicklereinstellungen im Einstellungsfenster.", "developer_tab_title": "Entwickler", - "duplicate_tiles_label": "Anzahl zusätzlicher Kachelkopien pro Teilnehmer", "feedback_tab_body": "Falls du auf Probleme stößt oder einfach nur eine Rückmeldung geben möchtest, sende uns bitte eine kurze Beschreibung.", "feedback_tab_description_label": "Deine Rückmeldung", "feedback_tab_h4": "Rückmeldung geben", diff --git a/locales/en-GB/app.json b/locales/en-GB/app.json index 6340d160..bc37dd7b 100644 --- a/locales/en-GB/app.json +++ b/locales/en-GB/app.json @@ -68,8 +68,13 @@ "username": "Username", "video": "Video" }, - "crypto_version": "Crypto version: {{version}}", - "device_id": "Device ID: {{id}}", + "developer_mode": { + "crypto_version": "Crypto version: {{version}}", + "device_id": "Device ID: {{id}}", + "duplicate_tiles_label": "Number of additional tile copies per participant", + "hostname": "Hostname: {{hostname}}", + "matrix_id": "Matrix ID: {{id}}" + }, "disconnected_banner": "Connectivity to the server has been lost.", "full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.", "full_screen_view_h1": "<0>Oops, something's gone wrong.", @@ -111,7 +116,6 @@ "login_auth_links_prompt": "Not registered yet?", "login_subheading": "To continue to Element", "login_title": "Login", - "matrix_id": "Matrix ID: {{id}}", "microphone_off": "Microphone off", "microphone_on": "Microphone on", "mute_microphone_button_label": "Mute microphone", @@ -149,7 +153,6 @@ "developer_settings_label": "Developer Settings", "developer_settings_label_description": "Expose developer settings in the settings window.", "developer_tab_title": "Developer", - "duplicate_tiles_label": "Number of additional tile copies per participant", "feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.", "feedback_tab_description_label": "Your feedback", "feedback_tab_h4": "Submit feedback", diff --git a/src/input/Input.tsx b/src/input/Input.tsx index cf2902cd..77003e5a 100644 --- a/src/input/Input.tsx +++ b/src/input/Input.tsx @@ -73,6 +73,7 @@ interface InputFieldProps { defaultValue?: string; placeholder?: string; defaultChecked?: boolean; + min?: number; onChange?: (event: ChangeEvent) => void; } @@ -91,6 +92,7 @@ export const InputField = forwardRef< suffix, description, disabled, + min, ...rest }, ref, @@ -127,6 +129,7 @@ export const InputField = forwardRef< checked={checked} disabled={disabled} aria-describedby={descriptionId} + min={min} {...rest} /> )} diff --git a/src/reactions/ReactionIndicator.module.css b/src/reactions/ReactionIndicator.module.css index 32b7fa3d..0fba7351 100644 --- a/src/reactions/ReactionIndicator.module.css +++ b/src/reactions/ReactionIndicator.module.css @@ -1,6 +1,6 @@ .reactionIndicatorWidget { display: flex; - background-color: #00000050; + background-color: #00000030; border-radius: var(--cpd-radius-pill-effect); box-shadow: 0 0 var(--cpd-space-2x) #00000040; background: "ffffff40"; @@ -33,7 +33,7 @@ .reaction { margin: var(--cpd-space-1x); - color: var(--cpd-color-icon-secondary); + color: white; display: flex; align-items: center; border-radius: var(--cpd-radius-pill-effect); 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.module.css b/src/room/InCallView.module.css index fe973132..bf0aabf5 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -36,10 +36,9 @@ Please see LICENSE in the repository root for full details. inset-block-end: 0; z-index: 1; display: grid; - grid-template-columns: minmax(0, var(--inline-content-inset)) 1fr auto 1fr minmax( - 0, - var(--inline-content-inset) - ); + grid-template-columns: + minmax(0, var(--inline-content-inset)) + 1fr auto 1fr minmax(0, var(--inline-content-inset)); grid-template-areas: ". logo buttons layout ."; align-items: center; gap: var(--cpd-space-3x); 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()} +