diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index 7a390c2f..3c09aa82 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -144,6 +144,10 @@ "room_auth_view_eula_caption": "By clicking \"Continue\", you agree to our <2>End User Licensing Agreement (EULA)", "screenshare_button_label": "Share screen", "settings": { + "audio_tab": { + "effect_volume_description": "Adjust the volume at which reactions and hand raised effects play", + "effect_volume_label": "Sound effect volume" + }, "developer_settings_label": "Developer Settings", "developer_settings_label_description": "Expose developer settings in the settings window.", "developer_tab_title": "Developer", diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 06173c8f..132c6364 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -85,7 +85,11 @@ import handSoundOgg from "../sound/raise_hand.ogg?url"; import handSoundMp3 from "../sound/raise_hand.mp3?url"; import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; import { useSwitchCamera } from "./useSwitchCamera"; -import { showReactions, useSetting } from "../settings/settings"; +import { + soundEffectVolumeSetting, + showReactions, + useSetting, +} from "../settings/settings"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -182,6 +186,7 @@ export const InCallView: FC = ({ onShareClick, }) => { const [shouldShowReactions] = useSetting(showReactions); + const [soundEffectVolume] = useSetting(soundEffectVolumeSetting); const { supportsReactions, raisedHands, reactions } = useReactions(); const raisedHandCount = useMemo( () => Object.keys(raisedHands).length, @@ -344,11 +349,17 @@ export const InCallView: FC = ({ return; } if (previousRaisedHandCount < raisedHandCount) { + handRaisePlayer.current.volume = soundEffectVolume; handRaisePlayer.current.play().catch((ex) => { logger.warn("Failed to play raise hand sound", ex); }); } - }, [raisedHandCount, handRaisePlayer, previousRaisedHandCount]); + }, [ + raisedHandCount, + handRaisePlayer, + previousRaisedHandCount, + soundEffectVolume, + ]); useEffect(() => { widget?.api.transport diff --git a/src/room/ReactionAudioRenderer.test.tsx b/src/room/ReactionAudioRenderer.test.tsx index 371054b4..cf9d7fad 100644 --- a/src/room/ReactionAudioRenderer.test.tsx +++ b/src/room/ReactionAudioRenderer.test.tsx @@ -17,7 +17,10 @@ import { } from "../utils/testReactions"; import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; import { GenericReaction, ReactionSet } from "../reactions"; -import { playReactionsSound } from "../settings/settings"; +import { + playReactionsSound, + soundEffectVolumeSetting, +} from "../settings/settings"; const memberUserIdAlice = "@alice:example.org"; const memberUserIdBob = "@bob:example.org"; @@ -49,6 +52,7 @@ function TestComponent({ const originalPlayFn = window.HTMLMediaElement.prototype.play; afterAll(() => { playReactionsSound.setValue(playReactionsSound.defaultValue); + soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue); window.HTMLMediaElement.prototype.play = originalPlayFn; }); @@ -125,6 +129,28 @@ test("will play the generic audio sound when there is soundless reaction", () => expect(audioIsPlaying[0]).toContain(GenericReaction.sound?.ogg); }); +test("will play an audio sound with the correct volume", () => { + playReactionsSound.setValue(true); + soundEffectVolumeSetting.setValue(0.5); + const room = new MockRoom(memberUserIdAlice); + const rtcSession = new MockRTCSession(room, membership); + const { getByTestId } = render(); + + // Find the first reaction with a sound effect + const chosenReaction = ReactionSet.find((r) => !!r.sound); + if (!chosenReaction) { + throw Error( + "No reactions have sounds configured, this test cannot succeed", + ); + } + act(() => { + room.testSendReaction(memberEventAlice, chosenReaction, membership); + }); + expect((getByTestId(chosenReaction.name) as HTMLAudioElement).volume).toEqual( + 0.5, + ); +}); + test("will play multiple audio sounds when there are multiple different reactions", () => { const audioIsPlaying: string[] = []; window.HTMLMediaElement.prototype.play = async function (): Promise { diff --git a/src/room/ReactionAudioRenderer.tsx b/src/room/ReactionAudioRenderer.tsx index 12977629..cc0b4a57 100644 --- a/src/room/ReactionAudioRenderer.tsx +++ b/src/room/ReactionAudioRenderer.tsx @@ -8,12 +8,17 @@ Please see LICENSE in the repository root for full details. import { ReactNode, useEffect, useRef } from "react"; import { useReactions } from "../useReactions"; -import { playReactionsSound, useSetting } from "../settings/settings"; +import { + playReactionsSound, + soundEffectVolumeSetting as effectSoundVolumeSetting, + useSetting, +} from "../settings/settings"; import { GenericReaction, ReactionSet } from "../reactions"; export function ReactionsAudioRenderer(): ReactNode { const { reactions } = useReactions(); const [shouldPlay] = useSetting(playReactionsSound); + const [effectSoundVolume] = useSetting(effectSoundVolumeSetting); const audioElements = useRef>({}); useEffect(() => { @@ -30,10 +35,11 @@ export function ReactionsAudioRenderer(): ReactNode { const audioElement = audioElements.current[reactionName] ?? audioElements.current.generic; if (audioElement?.paused) { + audioElement.volume = effectSoundVolume; void audioElement.play(); } } - }, [audioElements, shouldPlay, reactions]); + }, [audioElements, shouldPlay, reactions, effectSoundVolume]); // Do not render any audio elements if playback is disabled. Will save // audio file fetches. diff --git a/src/settings/SettingsModal.module.css b/src/settings/SettingsModal.module.css index b8c2857e..3d8f0cbf 100644 --- a/src/settings/SettingsModal.module.css +++ b/src/settings/SettingsModal.module.css @@ -16,3 +16,20 @@ Please see LICENSE in the repository root for full details. .fieldRowText { margin-bottom: 0; } + +.volumeSlider { + margin-top: var(--cpd-space-2x); +} + +.volumeSlider > label { + margin-bottom: var(--cpd-space-1x); + display: block; +} + +.volumeSlider > span { + max-width: 20em; +} + +.volumeSlider > p { + color: var(--cpd-color-text-secondary); +} diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index db702ef8..07ca5753 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details. import { ChangeEvent, FC, ReactNode, useCallback } from "react"; import { Trans, useTranslation } from "react-i18next"; import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { Dropdown, Text } from "@vector-im/compound-web"; +import { Dropdown, Separator, Text } from "@vector-im/compound-web"; import { Modal } from "../Modal"; import styles from "./SettingsModal.module.css"; @@ -28,9 +28,11 @@ import { developerSettingsTab as developerSettingsTabSetting, duplicateTiles as duplicateTilesSetting, useOptInAnalytics, + soundEffectVolumeSetting, } from "./settings"; import { isFirefox } from "../Platform"; import { PreferencesSettingsTab } from "./PreferencesSettingsTab"; +import { Slider } from "../Slider"; type SettingsTab = | "audio" @@ -116,6 +118,8 @@ export const SettingsModal: FC = ({ const devices = useMediaDevices(); useMediaDeviceNames(devices, open); + const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting); + const audioTab: Tab = { key: "audio", name: t("common.audio"), @@ -127,6 +131,19 @@ export const SettingsModal: FC = ({ devices.audioOutput, t("settings.speaker_device_selection_label"), )} + +
+ +

{t("settings.audio_tab.effect_volume_description")}

+ +
), }; diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 09b4dbdc..f2f7980b 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -100,4 +100,9 @@ export const playReactionsSound = new Setting( true, ); +export const soundEffectVolumeSetting = new Setting( + "sound-effect-volume", + 1, +); + export const alwaysShowSelf = new Setting("always-show-self", true); diff --git a/src/useReactions.tsx b/src/useReactions.tsx index 9111d5a9..15b7b714 100644 --- a/src/useReactions.tsx +++ b/src/useReactions.tsx @@ -182,7 +182,7 @@ export const ReactionsProvider = ({ // This effect handles any *live* reaction/redactions in the room. useEffect(() => { - const reactionTimeouts = new Set(); + const reactionTimeouts = new Set(); const handleReactionEvent = (event: MatrixEvent): void => { if (event.isSending()) { // Skip any events that are still sending. @@ -245,7 +245,7 @@ export const ReactionsProvider = ({ // We've still got a reaction from this user, ignore it to prevent spamming return reactions; } - const timeout = setTimeout(() => { + const timeout = window.setTimeout(() => { // Clear the reaction after some time. setReactions(({ [sender]: _unused, ...remaining }) => remaining); reactionTimeouts.delete(timeout);