Merge branch 'livekit' into toger5/track-processor-blur

This commit is contained in:
Timo
2024-12-13 03:37:48 +01:00
14 changed files with 260 additions and 61 deletions

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { import {
ConnectionState, ConnectionState,
type E2EEOptions, type E2EEManagerOptions,
ExternalE2EEKeyProvider, ExternalE2EEKeyProvider,
type LocalVideoTrack, type LocalVideoTrack,
Room, Room,
@@ -51,7 +51,7 @@ export function useLiveKit(
sfuConfig: SFUConfig | undefined, sfuConfig: SFUConfig | undefined,
e2eeSystem: EncryptionSystem, e2eeSystem: EncryptionSystem,
): UseLivekitResult { ): UseLivekitResult {
const e2eeOptions = useMemo((): E2EEOptions | undefined => { const e2eeOptions = useMemo((): E2EEManagerOptions | undefined => {
if (e2eeSystem.kind === E2eeType.NONE) return undefined; if (e2eeSystem.kind === E2eeType.NONE) return undefined;
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) { if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {

View File

@@ -25,7 +25,7 @@ import { useLatest } from "../useLatest";
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8; export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
export const THROTTLE_SOUND_EFFECT_MS = 500; export const THROTTLE_SOUND_EFFECT_MS = 500;
const sounds = prefetchSounds({ export const callEventAudioSounds = prefetchSounds({
join: { join: {
mp3: joinCallSoundMp3, mp3: joinCallSoundMp3,
ogg: joinCallSoundOgg, ogg: joinCallSoundOgg,
@@ -46,7 +46,7 @@ export function CallEventAudioRenderer({
vm: CallViewModel; vm: CallViewModel;
}): ReactNode { }): ReactNode {
const audioEngineCtx = useAudioContext({ const audioEngineCtx = useAudioContext({
sounds, sounds: callEventAudioSounds,
latencyHint: "interactive", latencyHint: "interactive",
}); });
const audioEngineRef = useLatest(audioEngineCtx); const audioEngineRef = useLatest(audioEngineCtx);
@@ -60,7 +60,7 @@ export function CallEventAudioRenderer({
useEffect(() => { useEffect(() => {
if (audioEngineRef.current && previousRaisedHandCount < raisedHandCount) { if (audioEngineRef.current && previousRaisedHandCount < raisedHandCount) {
audioEngineRef.current.playSound("raiseHand"); void audioEngineRef.current.playSound("raiseHand");
} }
}, [audioEngineRef, previousRaisedHandCount, raisedHandCount]); }, [audioEngineRef, previousRaisedHandCount, raisedHandCount]);
@@ -74,7 +74,7 @@ export function CallEventAudioRenderer({
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)), throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
) )
.subscribe(() => { .subscribe(() => {
audioEngineRef.current?.playSound("join"); void audioEngineRef.current?.playSound("join");
}); });
const leftSub = vm.memberChanges const leftSub = vm.memberChanges
@@ -86,7 +86,7 @@ export function CallEventAudioRenderer({
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)), throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
) )
.subscribe(() => { .subscribe(() => {
audioEngineRef.current?.playSound("left"); void audioEngineRef.current?.playSound("left");
}); });
return (): void => { return (): void => {

View File

@@ -0,0 +1,153 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { beforeEach, expect, type MockedFunction, test, vitest } from "vitest";
import { render } from "@testing-library/react";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { of } from "rxjs";
import { JoinRule, type RoomState } from "matrix-js-sdk/src/matrix";
import { Router } from "react-router-dom";
import { createBrowserHistory } from "history";
import userEvent from "@testing-library/user-event";
import { type MuteStates } from "./MuteStates";
import { prefetchSounds } from "../soundUtils";
import { useAudioContext } from "../useAudioContext";
import { ActiveCall } from "./InCallView";
import {
mockMatrixRoom,
mockMatrixRoomMember,
mockRtcMembership,
MockRTCSession,
} from "../utils/test";
import { GroupCallView } from "./GroupCallView";
import { leaveRTCSession } from "../rtcSessionHelpers";
import { type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter";
vitest.mock("../soundUtils");
vitest.mock("../useAudioContext");
vitest.mock("./InCallView");
vitest.mock("../rtcSessionHelpers", async (importOriginal) => {
// TODO: perhaps there is a more elegant way to manage the type import here?
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const orig = await importOriginal<typeof import("../rtcSessionHelpers")>();
vitest.spyOn(orig, "leaveRTCSession");
return orig;
});
let playSound: MockedFunction<
NonNullable<ReturnType<typeof useAudioContext>>["playSound"]
>;
const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
const carol = mockMatrixRoomMember(localRtcMember);
const roomMembers = new Map([carol].map((p) => [p.userId, p]));
const roomId = "!foo:bar";
const soundPromise = Promise.resolve(true);
beforeEach(() => {
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
sound: new ArrayBuffer(0),
});
playSound = vitest.fn().mockReturnValue(soundPromise);
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
});
// A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here.
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(
({ onLeave }) => {
return (
<div>
<button onClick={() => onLeave()}>Leave</button>
</div>
);
},
);
});
function createGroupCallView(widget: WidgetHelpers | null): {
rtcSession: MockRTCSession;
getByText: ReturnType<typeof render>["getByText"];
} {
const history = createBrowserHistory();
const client = {
getUser: () => null,
getUserId: () => localRtcMember.sender,
getDeviceId: () => localRtcMember.deviceId,
getRoom: (rId) => (rId === roomId ? room : null),
} as Partial<MatrixClient> as MatrixClient;
const room = mockMatrixRoom({
client,
roomId,
getMember: (userId) => roomMembers.get(userId) ?? null,
getMxcAvatarUrl: () => null,
getCanonicalAlias: () => null,
currentState: {
getJoinRule: () => JoinRule.Invite,
} as Partial<RoomState> as RoomState,
});
const rtcSession = new MockRTCSession(
room,
localRtcMember,
[],
).withMemberships(of([]));
const muteState = {
audio: { enabled: false },
video: { enabled: false },
} as MuteStates;
const { getByText } = render(
<Router history={history}>
<GroupCallView
client={client}
isPasswordlessUser={false}
confineToRoom={false}
preload={false}
skipLobby={false}
hideHeader={true}
rtcSession={rtcSession as unknown as MatrixRTCSession}
muteStates={muteState}
widget={widget}
/>
</Router>,
);
return {
getByText,
rtcSession,
};
}
test("will play a leave sound asynchronously in SPA mode", async () => {
const user = userEvent.setup();
const { getByText, rtcSession } = createGroupCallView(null);
const leaveButton = getByText("Leave");
await user.click(leaveButton);
expect(playSound).toHaveBeenCalledWith("left");
expect(leaveRTCSession).toHaveBeenCalledWith(rtcSession, undefined);
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
});
test("will play a leave sound synchronously in widget mode", async () => {
const user = userEvent.setup();
const widget = {
api: {
setAlwaysOnScreen: async () => Promise.resolve(true),
} as Partial<WidgetHelpers["api"]>,
lazyActions: new LazyEventEmitter(),
};
const { getByText, rtcSession } = createGroupCallView(
widget as WidgetHelpers,
);
const leaveButton = getByText("Leave");
await user.click(leaveButton);
expect(playSound).toHaveBeenCalledWith("left");
expect(leaveRTCSession).toHaveBeenCalledWith(rtcSession, soundPromise);
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
});

View File

@@ -26,7 +26,11 @@ import { Heading, Text } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { IWidgetApiRequest } from "matrix-widget-api"; import type { IWidgetApiRequest } from "matrix-widget-api";
import { widget, ElementWidgetActions, type JoinCallData } from "../widget"; import {
ElementWidgetActions,
type JoinCallData,
type WidgetHelpers,
} from "../widget";
import { FullScreenView } from "../FullScreenView"; import { FullScreenView } from "../FullScreenView";
import { LobbyView } from "./LobbyView"; import { LobbyView } from "./LobbyView";
import { type MatrixInfo } from "./VideoPreview"; import { type MatrixInfo } from "./VideoPreview";
@@ -51,6 +55,9 @@ import { InviteModal } from "./InviteModal";
import { useUrlParams } from "../UrlParams"; import { useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link"; import { Link } from "../button/Link";
import { useAudioContext } from "../useAudioContext";
import { callEventAudioSounds } from "./CallEventAudioRenderer";
import { useLatest } from "../useLatest";
declare global { declare global {
interface Window { interface Window {
@@ -67,6 +74,7 @@ interface Props {
hideHeader: boolean; hideHeader: boolean;
rtcSession: MatrixRTCSession; rtcSession: MatrixRTCSession;
muteStates: MuteStates; muteStates: MuteStates;
widget: WidgetHelpers | null;
} }
export const GroupCallView: FC<Props> = ({ export const GroupCallView: FC<Props> = ({
@@ -78,10 +86,16 @@ export const GroupCallView: FC<Props> = ({
hideHeader, hideHeader,
rtcSession, rtcSession,
muteStates, muteStates,
widget,
}) => { }) => {
const memberships = useMatrixRTCSessionMemberships(rtcSession); const memberships = useMatrixRTCSessionMemberships(rtcSession);
const isJoined = useMatrixRTCSessionJoinState(rtcSession); const isJoined = useMatrixRTCSessionJoinState(rtcSession);
const leaveSoundContext = useLatest(
useAudioContext({
sounds: callEventAudioSounds,
latencyHint: "interactive",
}),
);
// This should use `useEffectEvent` (only available in experimental versions) // This should use `useEffectEvent` (only available in experimental versions)
useEffect(() => { useEffect(() => {
if (memberships.length >= MUTE_PARTICIPANT_COUNT) if (memberships.length >= MUTE_PARTICIPANT_COUNT)
@@ -195,14 +209,14 @@ export const GroupCallView: FC<Props> = ({
ev.detail.data as unknown as JoinCallData, ev.detail.data as unknown as JoinCallData,
); );
await enterRTCSession(rtcSession, perParticipantE2EE); await enterRTCSession(rtcSession, perParticipantE2EE);
widget!.api.transport.reply(ev.detail, {}); widget.api.transport.reply(ev.detail, {});
})().catch((e) => { })().catch((e) => {
logger.error("Error joining RTC session", e); logger.error("Error joining RTC session", e);
}); });
}; };
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin); widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
return (): void => { return (): void => {
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin); widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
}; };
} else { } else {
// No lobby and no preload: we enter the rtc session right away // No lobby and no preload: we enter the rtc session right away
@@ -216,7 +230,7 @@ export const GroupCallView: FC<Props> = ({
void enterRTCSession(rtcSession, perParticipantE2EE); void enterRTCSession(rtcSession, perParticipantE2EE);
} }
} }
}, [rtcSession, preload, skipLobby, perParticipantE2EE]); }, [widget, rtcSession, preload, skipLobby, perParticipantE2EE]);
const [left, setLeft] = useState(false); const [left, setLeft] = useState(false);
const [leaveError, setLeaveError] = useState<Error | undefined>(undefined); const [leaveError, setLeaveError] = useState<Error | undefined>(undefined);
@@ -224,12 +238,12 @@ export const GroupCallView: FC<Props> = ({
const onLeave = useCallback( const onLeave = useCallback(
(leaveError?: Error): void => { (leaveError?: Error): void => {
setLeaveError(leaveError); const audioPromise = leaveSoundContext.current?.playSound("left");
setLeft(true);
// In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent, // 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. // therefore we want the event to be sent instantly without getting queued/batched.
const sendInstantly = !!widget; const sendInstantly = !!widget;
setLeaveError(leaveError);
setLeft(true);
PosthogAnalytics.instance.eventCallEnded.track( PosthogAnalytics.instance.eventCallEnded.track(
rtcSession.room.roomId, rtcSession.room.roomId,
rtcSession.memberships.length, rtcSession.memberships.length,
@@ -237,8 +251,12 @@ export const GroupCallView: FC<Props> = ({
rtcSession, 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. // Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
leaveRTCSession(rtcSession)
.then(() => { .then(() => {
if ( if (
!isPasswordlessUser && !isPasswordlessUser &&
@@ -252,18 +270,25 @@ export const GroupCallView: FC<Props> = ({
logger.error("Error leaving RTC session", e); logger.error("Error leaving RTC session", e);
}); });
}, },
[rtcSession, isPasswordlessUser, confineToRoom, history], [
widget,
rtcSession,
isPasswordlessUser,
confineToRoom,
leaveSoundContext,
history,
],
); );
useEffect(() => { useEffect(() => {
if (widget && isJoined) { if (widget && isJoined) {
// set widget to sticky once joined. // set widget to sticky once joined.
widget!.api.setAlwaysOnScreen(true).catch((e) => { widget.api.setAlwaysOnScreen(true).catch((e) => {
logger.error("Error calling setAlwaysOnScreen(true)", e); logger.error("Error calling setAlwaysOnScreen(true)", e);
}); });
const onHangup = (ev: CustomEvent<IWidgetApiRequest>): void => { const onHangup = (ev: CustomEvent<IWidgetApiRequest>): void => {
widget!.api.transport.reply(ev.detail, {}); widget.api.transport.reply(ev.detail, {});
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts. // Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
leaveRTCSession(rtcSession).catch((e) => { leaveRTCSession(rtcSession).catch((e) => {
logger.error("Failed to leave RTC session", e); logger.error("Failed to leave RTC session", e);
@@ -271,10 +296,10 @@ export const GroupCallView: FC<Props> = ({
}; };
widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup); widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup);
return (): void => { return (): void => {
widget!.lazyActions.off(ElementWidgetActions.HangupCall, onHangup); widget.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
}; };
} }
}, [isJoined, rtcSession]); }, [widget, isJoined, rtcSession]);
const onReconnect = useCallback(() => { const onReconnect = useCallback(() => {
setLeft(false); setLeft(false);
@@ -367,6 +392,7 @@ export const GroupCallView: FC<Props> = ({
leaveError leaveError
) { ) {
return ( return (
<>
<CallEndedView <CallEndedView
endedCallId={rtcSession.room.roomId} endedCallId={rtcSession.room.roomId}
client={client} client={client}
@@ -375,6 +401,8 @@ export const GroupCallView: FC<Props> = ({
leaveError={leaveError} leaveError={leaveError}
reconnect={onReconnect} reconnect={onReconnect}
/> />
;
</>
); );
} else { } else {
// If the user is a regular user, we'll have sent them back to the homepage, // If the user is a regular user, we'll have sent them back to the homepage,

View File

@@ -60,10 +60,10 @@ export function ReactionsAudioRenderer(): ReactNode {
return; return;
} }
if (soundMap[reactionName]) { if (soundMap[reactionName]) {
audioEngineRef.current.playSound(reactionName); void audioEngineRef.current.playSound(reactionName);
} else { } else {
// Fallback sounds. // Fallback sounds.
audioEngineRef.current.playSound("generic"); void audioEngineRef.current.playSound("generic");
} }
} }
}, [audioEngineRef, shouldPlay, oldReactions, reactions]); }, [audioEngineRef, shouldPlay, oldReactions, reactions]);

View File

@@ -98,6 +98,7 @@ export const RoomPage: FC = () => {
case "loaded": case "loaded":
return ( return (
<GroupCallView <GroupCallView
widget={widget}
client={client!} client={client!}
rtcSession={groupCallState.rtcSession} rtcSession={groupCallState.rtcSession}
isPasswordlessUser={passwordlessUser} isPasswordlessUser={passwordlessUser}

View File

@@ -120,6 +120,7 @@ export async function enterRTCSession(
const widgetPostHangupProcedure = async ( const widgetPostHangupProcedure = async (
widget: WidgetHelpers, widget: WidgetHelpers,
promiseBeforeHangup?: Promise<unknown>,
): Promise<void> => { ): Promise<void> => {
// we need to wait until the callEnded event is tracked on posthog. // we need to wait until the callEnded event is tracked on posthog.
// Otherwise the iFrame gets killed before the callEnded event got tracked. // Otherwise the iFrame gets killed before the callEnded event got tracked.
@@ -132,6 +133,8 @@ const widgetPostHangupProcedure = async (
logger.error("Failed to set call widget `alwaysOnScreen` to false", e); 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 // We send the hangup event after the memberships have been updated
// calling leaveRTCSession. // calling leaveRTCSession.
// We need to wait because this makes the client hosting this widget killing the IFrame. // We need to wait because this makes the client hosting this widget killing the IFrame.
@@ -140,9 +143,12 @@ const widgetPostHangupProcedure = async (
export async function leaveRTCSession( export async function leaveRTCSession(
rtcSession: MatrixRTCSession, rtcSession: MatrixRTCSession,
promiseBeforeHangup?: Promise<unknown>,
): Promise<void> { ): Promise<void> {
await rtcSession.leaveRoomSession(); await rtcSession.leaveRoomSession();
if (widget) { if (widget) {
await widgetPostHangupProcedure(widget); await widgetPostHangupProcedure(widget, promiseBeforeHangup);
} else {
await promiseBeforeHangup;
} }
} }

View File

@@ -14,7 +14,7 @@ import {
showHandRaisedTimer as showHandRaisedTimerSetting, showHandRaisedTimer as showHandRaisedTimerSetting,
showReactions as showReactionsSetting, showReactions as showReactionsSetting,
playReactionsSound as playReactionsSoundSetting, playReactionsSound as playReactionsSoundSetting,
developerSettingsTab as developerSettingsTabSetting, developerMode as developerModeSetting,
useSetting, useSetting,
} from "./settings"; } from "./settings";
@@ -37,9 +37,7 @@ export const PreferencesSettingsTab: FC = () => {
fn(e.target.checked); fn(e.target.checked);
}; };
const [developerSettingsTab, setDeveloperSettingsTab] = useSetting( const [developerMode, setDeveloperMode] = useSetting(developerModeSetting);
developerSettingsTabSetting,
);
return ( return (
<div> <div>
@@ -82,13 +80,13 @@ export const PreferencesSettingsTab: FC = () => {
<InputField <InputField
id="developerSettingsTab" id="developerSettingsTab"
type="checkbox" type="checkbox"
checked={developerSettingsTab} checked={developerMode}
label={t("settings.preferences_tab.developer_mode_label")} label={t("settings.preferences_tab.developer_mode_label")}
description={t( description={t(
"settings.preferences_tab.developer_mode_label_description", "settings.preferences_tab.developer_mode_label_description",
)} )}
onChange={(event: ChangeEvent<HTMLInputElement>): void => onChange={(event: ChangeEvent<HTMLInputElement>): void =>
setDeveloperSettingsTab(event.target.checked) setDeveloperMode(event.target.checked)
} }
/> />
</FieldRow> </FieldRow>

View File

@@ -22,9 +22,9 @@ import {
import { widget } from "../widget"; import { widget } from "../widget";
import { import {
useSetting, useSetting,
developerSettingsTab,
backgroundBlur as backgroundBlurSetting,
soundEffectVolumeSetting, soundEffectVolumeSetting,
backgroundBlur as backgroundBlurSetting,
developerMode,
} from "./settings"; } from "./settings";
import { isFirefox } from "../Platform"; import { isFirefox } from "../Platform";
import { PreferencesSettingsTab } from "./PreferencesSettingsTab"; import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
@@ -97,7 +97,7 @@ export const SettingsModal: FC<Props> = ({
const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting); const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting);
const [soundVolumeRaw, setSoundVolumeRaw] = useState(soundVolume); const [soundVolumeRaw, setSoundVolumeRaw] = useState(soundVolume);
const [showDeveloperSettingsTab] = useSetting(developerSettingsTab); const [showDeveloperSettingsTab] = useSetting(developerMode);
const audioTab: Tab<SettingsTab> = { const audioTab: Tab<SettingsTab> = {
key: "audio", key: "audio",

View File

@@ -68,10 +68,7 @@ export const useOptInAnalytics = (): [
return PosthogAnalytics.instance.isEnabled() ? setting : [false, null]; return PosthogAnalytics.instance.isEnabled() ? setting : [false, null];
}; };
export const developerSettingsTab = new Setting( export const developerMode = new Setting("developer-settings-tab", false);
"developer-settings-tab",
false,
);
export const duplicateTiles = new Setting("duplicate-tiles", 0); export const duplicateTiles = new Setting("duplicate-tiles", 0);

View File

@@ -29,9 +29,11 @@ const TestComponent: FC = () => {
} }
return ( 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*/} {/* 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 Invalid sound
</button> </button>
</> </>
@@ -61,6 +63,7 @@ class MockAudioContext {
vitest.mocked({ vitest.mocked({
connect: (v: unknown) => v, connect: (v: unknown) => v,
start: () => {}, start: () => {},
addEventListener: (_name: string, cb: () => void) => cb(),
}), }),
); );
public createGain = vitest.fn().mockReturnValue(this.gain); public createGain = vitest.fn().mockReturnValue(this.gain);

View File

@@ -22,18 +22,21 @@ import { type PrefetchedSounds } from "./soundUtils";
* @param volume The volume to play at. * @param volume The volume to play at.
* @param ctx The context to play through. * @param ctx The context to play through.
* @param buffer The buffer to play. * @param buffer The buffer to play.
* @returns A promise that resolves when the sound has finished playing.
*/ */
function playSound( async function playSound(
ctx: AudioContext, ctx: AudioContext,
buffer: AudioBuffer, buffer: AudioBuffer,
volume: number, volume: number,
): void { ): Promise<void> {
const gain = ctx.createGain(); const gain = ctx.createGain();
gain.gain.setValueAtTime(volume, 0); gain.gain.setValueAtTime(volume, 0);
const src = ctx.createBufferSource(); const src = ctx.createBufferSource();
src.buffer = buffer; src.buffer = buffer;
src.connect(gain).connect(ctx.destination); src.connect(gain).connect(ctx.destination);
const p = new Promise<void>((r) => src.addEventListener("ended", () => r()));
src.start(); src.start();
return p;
} }
interface Props<S extends string> { interface Props<S extends string> {
@@ -47,7 +50,7 @@ interface Props<S extends string> {
} }
interface UseAudioContext<S> { interface UseAudioContext<S> {
playSound(soundName: S): void; playSound(soundName: S): Promise<void>;
} }
/** /**
@@ -113,7 +116,7 @@ export function useAudioContext<S extends string>(
return null; return null;
} }
return { return {
playSound: (name): void => { playSound: async (name): Promise<void> => {
if (!audioBuffers[name]) { if (!audioBuffers[name]) {
logger.debug(`Tried to play a sound that wasn't buffered (${name})`); logger.debug(`Tried to play a sound that wasn't buffered (${name})`);
return; return;

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { map, type Observable, of, type SchedulerLike } from "rxjs"; import { map, type Observable, of, type SchedulerLike } from "rxjs";
import { type RunHelpers, TestScheduler } from "rxjs/testing"; import { type RunHelpers, TestScheduler } from "rxjs/testing";
import { expect, vi } from "vitest"; import { expect, vi, vitest } from "vitest";
import { import {
type RoomMember, type RoomMember,
type Room as MatrixRoom, type Room as MatrixRoom,
@@ -258,6 +258,12 @@ export class MockRTCSession extends TypedEventEmitter<
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
MatrixRTCSessionEventHandlerMap MatrixRTCSessionEventHandlerMap
> { > {
public readonly statistics = {
counters: {},
};
public leaveRoomSession = vitest.fn().mockResolvedValue(undefined);
public constructor( public constructor(
public readonly room: Room, public readonly room: Room,
private localMembership: CallMembership, private localMembership: CallMembership,
@@ -266,6 +272,10 @@ export class MockRTCSession extends TypedEventEmitter<
super(); super();
} }
public isJoined(): true {
return true;
}
public withMemberships( public withMemberships(
rtcMembers: Observable<Partial<CallMembership>[]>, rtcMembers: Observable<Partial<CallMembership>[]>,
): MockRTCSession { ): MockRTCSession {

View File

@@ -1954,10 +1954,10 @@
resolved "https://registry.yarnpkg.com/@livekit/mutex/-/mutex-1.0.0.tgz#9493102d92ff75dfb0445eccc46c7c7ac189d385" resolved "https://registry.yarnpkg.com/@livekit/mutex/-/mutex-1.0.0.tgz#9493102d92ff75dfb0445eccc46c7c7ac189d385"
integrity sha512-aiUhoThBNF9UyGTxEURFzJLhhPLIVTnQiEVMjRhPnfHNKLfo2JY9xovHKIus7B78UD5hsP6DlgpmAsjrz4U0Iw== integrity sha512-aiUhoThBNF9UyGTxEURFzJLhhPLIVTnQiEVMjRhPnfHNKLfo2JY9xovHKIus7B78UD5hsP6DlgpmAsjrz4U0Iw==
"@livekit/protocol@1.29.3": "@livekit/protocol@1.29.4":
version "1.29.3" version "1.29.4"
resolved "https://registry.yarnpkg.com/@livekit/protocol/-/protocol-1.29.3.tgz#486ce215c0c591ad64036d9b13c7e28f5417cf03" resolved "https://registry.yarnpkg.com/@livekit/protocol/-/protocol-1.29.4.tgz#346906d080bc8207a80570b45db91153a495e0dc"
integrity sha512-5La/pm2LsSeCbm7xNe/TvHGYu7uVwDpLrlycpgo5nzofGq/TH67255vS8ni/1Y7vrFuAI8VYG/s42mcC1UF6tQ== integrity sha512-dsqxvABHilrMA0BU5m1w8cMWSVeDjV2ZUIUDClNQZju3c30DLMfEYDHU5nmXDfaaHjNIgoRbYR7upJMozG8JJg==
dependencies: dependencies:
"@bufbuild/protobuf" "^1.10.0" "@bufbuild/protobuf" "^1.10.0"
@@ -6386,12 +6386,12 @@ lines-and-columns@^1.1.6:
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
livekit-client@^2.5.7: livekit-client@^2.5.7:
version "2.7.3" version "2.7.5"
resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-2.7.3.tgz#70a5f5016f3f50b1282f4b9090aa17a39f8bde09" resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-2.7.5.tgz#2c8e5956c1fda5844799f5a864ac87c803ca1a43"
integrity sha512-oHEmUTFjIJARi5R87PsobZx8y2HCSUwla3Nu71EqDOAMnNY9aoGMLsJVao5Y+v1TSk71rgRm991fihgxtbg5xw== integrity sha512-sPhHYwXvG75y1LDC50dDC9k6Z49L2vc/HcMRhzhi7yBca6ofPEebpB0bmPOry4ovrnFA+a8TL1pFR2mko1/clw==
dependencies: dependencies:
"@livekit/mutex" "1.0.0" "@livekit/mutex" "1.0.0"
"@livekit/protocol" "1.29.3" "@livekit/protocol" "1.29.4"
events "^3.3.0" events "^3.3.0"
loglevel "^1.8.0" loglevel "^1.8.0"
sdp-transform "^2.14.1" sdp-transform "^2.14.1"