Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for playing a sound when the user exits a call. #2860

Open
wants to merge 6 commits into
base: hs/reduce-join-sound-noises
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/room/CallEventAudioRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { useLatest } from "../useLatest";
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
export const THROTTLE_SOUND_EFFECT_MS = 500;

const Sounds = prefetchSounds({
export const CallEventAudioSounds = prefetchSounds({
join: {
mp3: joinCallSoundMp3,
ogg: joinCallSoundOgg,
Expand All @@ -45,7 +45,7 @@ export function CallEventAudioRenderer({
vm: CallViewModel;
}): ReactNode {
const audioEngineCtx = useAudioContext({
sounds: Sounds,
sounds: CallEventAudioSounds,
latencyHint: "interactive",
});
const audioEngineRef = useLatest(audioEngineCtx);
Expand All @@ -59,7 +59,7 @@ export function CallEventAudioRenderer({

useEffect(() => {
if (audioEngineRef.current && previousRaisedHandCount < raisedHandCount) {
audioEngineRef.current.playSound("raiseHand");
void audioEngineRef.current.playSound("raiseHand");
}
}, [audioEngineRef, previousRaisedHandCount, raisedHandCount]);

Expand All @@ -73,7 +73,7 @@ export function CallEventAudioRenderer({
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
)
.subscribe(() => {
audioEngineRef.current?.playSound("join");
void audioEngineRef.current?.playSound("join");
});

const leftSub = vm.memberChanges
Expand All @@ -85,7 +85,7 @@ export function CallEventAudioRenderer({
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
)
.subscribe(() => {
audioEngineRef.current?.playSound("left");
void audioEngineRef.current?.playSound("left");
});

return (): void => {
Expand Down
45 changes: 30 additions & 15 deletions src/room/GroupCallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ import { InviteModal } from "./InviteModal";
import { useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link";
import { useAudioContext } from "../useAudioContext";
import { CallEventAudioSounds } from "./CallEventAudioRenderer";
import { useLatest } from "../useLatest";

declare global {
interface Window {
Expand Down Expand Up @@ -71,7 +74,12 @@ export const GroupCallView: FC<Props> = ({
}) => {
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const isJoined = useMatrixRTCSessionJoinState(rtcSession);

const leaveSoundContext = useLatest(
useAudioContext({
sounds: CallEventAudioSounds,
latencyHint: "interactive",
}),
);
// This should use `useEffectEvent` (only available in experimental versions)
useEffect(() => {
if (memberships.length >= MUTE_PARTICIPANT_COUNT)
Expand Down Expand Up @@ -214,22 +222,26 @@ export const GroupCallView: FC<Props> = ({

const onLeave = useCallback(
(leaveError?: Error): void => {
setLeaveError(leaveError);
setLeft(true);

const audioPromise = leaveSoundContext.current?.playSound("left");
// In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent,
// therefore we want the event to be sent instantly without getting queued/batched.
const sendInstantly = !!widget;
setLeaveError(leaveError);
PosthogAnalytics.instance.eventCallEnded.track(
rtcSession.room.roomId,
rtcSession.memberships.length,
sendInstantly,
rtcSession,
);

// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
leaveRTCSession(rtcSession)
leaveRTCSession(
rtcSession,
// Wait for the sound in widget mode (it's not long)
sendInstantly && audioPromise ? audioPromise : undefined,
)
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
.then(() => {
setLeft(true);
if (
!isPasswordlessUser &&
!confineToRoom &&
Expand All @@ -242,7 +254,7 @@ export const GroupCallView: FC<Props> = ({
logger.error("Error leaving RTC session", e);
});
},
[rtcSession, isPasswordlessUser, confineToRoom, history],
[rtcSession, isPasswordlessUser, confineToRoom, leaveSoundContext, history],
);

useEffect(() => {
Expand Down Expand Up @@ -357,14 +369,17 @@ export const GroupCallView: FC<Props> = ({
leaveError
) {
return (
<CallEndedView
endedCallId={rtcSession.room.roomId}
client={client}
isPasswordlessUser={isPasswordlessUser}
confineToRoom={confineToRoom}
leaveError={leaveError}
reconnect={onReconnect}
/>
<>
<CallEndedView
endedCallId={rtcSession.room.roomId}
client={client}
isPasswordlessUser={isPasswordlessUser}
confineToRoom={confineToRoom}
leaveError={leaveError}
reconnect={onReconnect}
/>
;
</>
);
} else {
// If the user is a regular user, we'll have sent them back to the homepage,
Expand Down
4 changes: 2 additions & 2 deletions src/room/ReactionAudioRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ export function ReactionsAudioRenderer(): ReactNode {
return;
}
if (SoundMap[reactionName]) {
audioEngineRef.current.playSound(reactionName);
void audioEngineRef.current.playSound(reactionName);
} else {
// Fallback sounds.
audioEngineRef.current.playSound("generic");
void audioEngineRef.current.playSound("generic");
}
}
}, [audioEngineRef, shouldPlay, oldReactions, reactions]);
Expand Down
8 changes: 7 additions & 1 deletion src/rtcSessionHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export async function enterRTCSession(

const widgetPostHangupProcedure = async (
widget: WidgetHelpers,
promiseBeforeHangup?: Promise<unknown>,
): Promise<void> => {
// we need to wait until the callEnded event is tracked on posthog.
// Otherwise the iFrame gets killed before the callEnded event got tracked.
Expand All @@ -132,6 +133,8 @@ const widgetPostHangupProcedure = async (
logger.error("Failed to set call widget `alwaysOnScreen` to false", e);
}

// Wait for any last bits before hanging up.
await promiseBeforeHangup;
// We send the hangup event after the memberships have been updated
// calling leaveRTCSession.
// We need to wait because this makes the client hosting this widget killing the IFrame.
Expand All @@ -140,9 +143,12 @@ const widgetPostHangupProcedure = async (

export async function leaveRTCSession(
rtcSession: MatrixRTCSession,
promiseBeforeHangup?: Promise<unknown>,
): Promise<void> {
await rtcSession.leaveRoomSession();
if (widget) {
await widgetPostHangupProcedure(widget);
await widgetPostHangupProcedure(widget, promiseBeforeHangup);
} else {
await promiseBeforeHangup;
}
}
7 changes: 5 additions & 2 deletions src/useAudioContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ const TestComponent: FC = () => {
}
return (
<>
<button onClick={() => audioCtx.playSound("aSound")}>Valid sound</button>
<button onClick={() => void audioCtx.playSound("aSound")}>
Valid sound
</button>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any*/}
<button onClick={() => audioCtx.playSound("not-valid" as any)}>
<button onClick={() => void audioCtx.playSound("not-valid" as any)}>
Invalid sound
</button>
</>
Expand Down Expand Up @@ -59,6 +61,7 @@ class MockAudioContext {
vitest.mocked({
connect: (v: unknown) => v,
start: () => {},
addEventListener: (_eventType: string, cb: () => void) => cb(),
}),
);
public createGain = vitest.fn().mockReturnValue(this.gain);
Expand Down
11 changes: 7 additions & 4 deletions src/useAudioContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,21 @@ type SoundDefinition = { mp3?: string; ogg: string };
* @param volume The volume to play at.
* @param ctx The context to play through.
* @param buffer The buffer to play.
* @returns A promise that resolves when the sound has finished playing.
*/
function playSound(
async function playSound(
ctx: AudioContext,
buffer: AudioBuffer,
volume: number,
): void {
): Promise<void> {
const gain = ctx.createGain();
gain.gain.setValueAtTime(volume, 0);
const src = ctx.createBufferSource();
src.buffer = buffer;
src.connect(gain).connect(ctx.destination);
const p = new Promise<void>((r) => src.addEventListener("ended", () => r()));
src.start();
return p;
}

/**
Expand Down Expand Up @@ -97,7 +100,7 @@ interface Props<S extends string> {
}

interface UseAudioContext<S> {
playSound(soundName: S): void;
playSound(soundName: S): Promise<void>;
}

/**
Expand Down Expand Up @@ -163,7 +166,7 @@ export function useAudioContext<S extends string>(
return null;
}
return {
playSound: (name): void => {
playSound: async (name): Promise<void> => {
if (!audioBuffers[name]) {
logger.debug(`Tried to play a sound that wasn't buffered (${name})`);
return;
Expand Down
Loading