Preload reaction sounds to prevent delays.

This commit is contained in:
Half-Shot
2024-11-07 09:01:58 +00:00
parent b4a126d466
commit 6294fbb8e1
4 changed files with 83 additions and 36 deletions

View File

@@ -659,7 +659,7 @@ export const InCallView: FC<InCallViewProps> = ({
))}
<RoomAudioRenderer />
{renderContent()}
<audio ref={handRaisePlayer} hidden>
<audio ref={handRaisePlayer} preload="auto" hidden>
<source src={handSoundOgg} type="audio/ogg; codecs=vorbis" />
<source src={handSoundMp3} type="audio/mpeg" />
</audio>

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { act, render } from "@testing-library/react";
import { expect, test } from "vitest";
import { afterAll, expect, test } from "vitest";
import { TooltipProvider } from "@vector-im/compound-web";
import { ReactNode } from "react";
@@ -17,6 +17,7 @@ import {
} from "../utils/testReactions";
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { ReactionSet } from "../reactions";
import { playReactionsSound } from "../settings/settings";
const memberUserIdAlice = "@alice:example.org";
const memberUserIdBob = "@bob:example.org";
@@ -45,7 +46,26 @@ function TestComponent({
);
}
test("defaults to no audio elements", () => {
const originalPlayFn = window.HTMLMediaElement.prototype.play;
afterAll(() => {
playReactionsSound.setValue(playReactionsSound.defaultValue);
window.HTMLMediaElement.prototype.play = originalPlayFn;
});
test("preloads all audio elements", () => {
playReactionsSound.setValue(true);
const rtcSession = new MockRTCSession(
new MockRoom(memberUserIdAlice),
membership,
);
const { container } = render(<TestComponent rtcSession={rtcSession} />);
expect(container.getElementsByTagName("audio")).toHaveLength(
ReactionSet.filter((r) => r.sound).length,
);
});
test("loads no audio elements when disabled in settings", () => {
playReactionsSound.setValue(false);
const rtcSession = new MockRTCSession(
new MockRoom(memberUserIdAlice),
membership,
@@ -55,9 +75,15 @@ test("defaults to no audio elements", () => {
});
test("will play an audio sound when there is a reaction", () => {
const audioIsPlaying: string[] = [];
window.HTMLMediaElement.prototype.play = async function (): Promise<void> {
audioIsPlaying.push((this.children[0] as HTMLSourceElement).src);
return Promise.resolve();
};
playReactionsSound.setValue(true);
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { container } = render(<TestComponent rtcSession={rtcSession} />);
render(<TestComponent rtcSession={rtcSession} />);
// Find the first reaction with a sound effect
const chosenReaction = ReactionSet.find((r) => !!r.sound);
@@ -69,24 +95,21 @@ test("will play an audio sound when there is a reaction", () => {
act(() => {
room.testSendReaction(memberEventAlice, chosenReaction, membership);
});
const elements = container.getElementsByTagName("audio");
expect(elements).toHaveLength(1);
const audioElement = elements[0];
expect(audioElement.autoplay).toBe(true);
const sources = audioElement.getElementsByTagName("source");
expect(sources).toHaveLength(2);
// The element will be the full URL, whereas the chosenReaction will have the path.
expect(sources[0].src).toContain(chosenReaction.sound?.ogg);
expect(sources[1].src).toContain(chosenReaction.sound?.mp3);
expect(audioIsPlaying).toHaveLength(1);
expect(audioIsPlaying[0]).toContain(chosenReaction.sound?.ogg);
});
test("will play multiple audio sounds when there are multiple different reactions", () => {
const audioIsPlaying: string[] = [];
window.HTMLMediaElement.prototype.play = async function (): Promise<void> {
audioIsPlaying.push((this.children[0] as HTMLSourceElement).src);
return Promise.resolve();
};
playReactionsSound.setValue(true);
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { container } = render(<TestComponent rtcSession={rtcSession} />);
render(<TestComponent rtcSession={rtcSession} />);
// Find the first reaction with a sound effect
const [reaction1, reaction2] = ReactionSet.filter((r) => !!r.sound);
@@ -100,7 +123,7 @@ test("will play multiple audio sounds when there are multiple different reaction
room.testSendReaction(memberEventBob, reaction2, membership);
room.testSendReaction(memberEventCharlie, reaction1, membership);
});
const elements = container.getElementsByTagName("audio");
// Do not play the same reaction twice.
expect(elements).toHaveLength(2);
expect(audioIsPlaying).toHaveLength(2);
expect(audioIsPlaying[0]).toContain(reaction1.sound?.ogg);
expect(audioIsPlaying[1]).toContain(reaction2.sound?.ogg);
});

View File

@@ -5,35 +5,56 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { ReactNode, useMemo } from "react";
import { ReactNode, useEffect, useRef } from "react";
import { useReactions } from "../useReactions";
import { playReactionsSound, useSetting } from "../settings/settings";
import { ReactionSet } from "../reactions";
export function ReactionsAudioRenderer(): ReactNode {
const { reactions } = useReactions();
const [shouldPlay] = useSetting(playReactionsSound);
const audioElements = useRef<Record<string, HTMLAudioElement | null>>({});
const expectedReactions = useMemo(() => {
if (!shouldPlay) {
return [];
useEffect(() => {
if (!audioElements.current) {
return;
}
const reactionsToPlayNames = new Set();
return Object.values(reactions).filter((r) => {
if (reactionsToPlayNames.has(r.name)) {
return false;
}
reactionsToPlayNames.add(r.name);
return true;
});
}, [shouldPlay, reactions]);
if (!shouldPlay) {
return;
}
for (const reactionName of new Set(
Object.values(reactions).map((r) => r.name),
)) {
const audioElement = audioElements.current[reactionName];
if (audioElement?.paused) {
void audioElement.play();
}
}
}, [audioElements, shouldPlay, reactions]);
// Do not render any audio elements if playback is disabled. Will save
// audio file fetches.
if (!shouldPlay) {
return null;
}
// NOTE: We load all audio elements ahead of time to allow the cache
// to be populated, rather than risk a cache miss and have the audio
// be delayed.
return (
<>
{expectedReactions.map(
{ReactionSet.map(
(r) =>
r.sound && (
<audio key={r.name} autoPlay hidden>
<audio
ref={(el) => (audioElements.current[r.name] = el)}
data-testid={r.name}
key={r.name}
preload="auto"
hidden
>
<source src={r.sound.ogg} type="audio/ogg; codecs=vorbis" />
{r.sound.mp3 ? (
<source src={r.sound.mp3} type="audio/mpeg" />

View File

@@ -12,7 +12,10 @@ import { useObservableEagerState } from "observable-hooks";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
export class Setting<T> {
public constructor(key: string, defaultValue: T) {
public constructor(
key: string,
public readonly defaultValue: T,
) {
this.key = `matrix-setting-${key}`;
const storedValue = localStorage.getItem(this.key);