mirror of https://github.com/vector-im/riot-web
Prevent Element appearing in system media controls (#10995)
* Use WebAudio API to play notification sound So that it won't appear in system media control. * Run prettier * Chosse from mp3 and ogg * Run prettier * Use WebAudioAPI everywhere There's still one remoteAudio. I'm not sure what it does. It seems it's only used in tests... * Run prettier * Eliminate a stupid error * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update setupManualMocks.ts * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * delint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * mocks Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * mocks Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Simplify Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * covg Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>pull/28217/head
parent
c61eca8c24
commit
e288f61f0a
|
@ -22,7 +22,7 @@ const config: Config = {
|
||||||
testEnvironment: "jsdom",
|
testEnvironment: "jsdom",
|
||||||
testMatch: ["<rootDir>/test/**/*-test.[jt]s?(x)"],
|
testMatch: ["<rootDir>/test/**/*-test.[jt]s?(x)"],
|
||||||
globalSetup: "<rootDir>/test/globalSetup.ts",
|
globalSetup: "<rootDir>/test/globalSetup.ts",
|
||||||
setupFiles: ["jest-canvas-mock"],
|
setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
|
||||||
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
|
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
"\\.(gif|png|ttf|woff2)$": "<rootDir>/__mocks__/imageMock.js",
|
"\\.(gif|png|ttf|woff2)$": "<rootDir>/__mocks__/imageMock.js",
|
||||||
|
|
|
@ -227,7 +227,8 @@
|
||||||
"stylelint-config-standard": "^36.0.0",
|
"stylelint-config-standard": "^36.0.0",
|
||||||
"stylelint-scss": "^6.0.0",
|
"stylelint-scss": "^6.0.0",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "5.5.2"
|
"typescript": "5.5.2",
|
||||||
|
"web-streams-polyfill": "^4.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"postcss": "^8.4.19",
|
"postcss": "^8.4.19",
|
||||||
|
|
|
@ -66,6 +66,7 @@ import { localNotificationsAreSilenced } from "./utils/notifications";
|
||||||
import { SdkContextClass } from "./contexts/SDKContext";
|
import { SdkContextClass } from "./contexts/SDKContext";
|
||||||
import { showCantStartACallDialog } from "./voice-broadcast/utils/showCantStartACallDialog";
|
import { showCantStartACallDialog } from "./voice-broadcast/utils/showCantStartACallDialog";
|
||||||
import { isNotNull } from "./Typeguards";
|
import { isNotNull } from "./Typeguards";
|
||||||
|
import { BackgroundAudio } from "./audio/BackgroundAudio";
|
||||||
|
|
||||||
export const PROTOCOL_PSTN = "m.protocol.pstn";
|
export const PROTOCOL_PSTN = "m.protocol.pstn";
|
||||||
export const PROTOCOL_PSTN_PREFIXED = "im.vector.protocol.pstn";
|
export const PROTOCOL_PSTN_PREFIXED = "im.vector.protocol.pstn";
|
||||||
|
@ -157,8 +158,6 @@ export default class LegacyCallHandler extends EventEmitter {
|
||||||
// Calls started as an attended transfer, ie. with the intention of transferring another
|
// Calls started as an attended transfer, ie. with the intention of transferring another
|
||||||
// call with a different party to this one.
|
// call with a different party to this one.
|
||||||
private transferees = new Map<string, MatrixCall>(); // callId (target) -> call (transferee)
|
private transferees = new Map<string, MatrixCall>(); // callId (target) -> call (transferee)
|
||||||
private audioPromises = new Map<AudioID, Promise<void>>();
|
|
||||||
private audioElementsWithListeners = new Map<HTMLMediaElement, boolean>();
|
|
||||||
private supportsPstnProtocol: boolean | null = null;
|
private supportsPstnProtocol: boolean | null = null;
|
||||||
private pstnSupportPrefixed: boolean | null = null; // True if the server only support the prefixed pstn protocol
|
private pstnSupportPrefixed: boolean | null = null; // True if the server only support the prefixed pstn protocol
|
||||||
private supportsSipNativeVirtual: boolean | null = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
|
private supportsSipNativeVirtual: boolean | null = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
|
||||||
|
@ -170,6 +169,9 @@ export default class LegacyCallHandler extends EventEmitter {
|
||||||
|
|
||||||
private silencedCalls = new Set<string>(); // callIds
|
private silencedCalls = new Set<string>(); // callIds
|
||||||
|
|
||||||
|
private backgroundAudio = new BackgroundAudio();
|
||||||
|
private playingSources: Record<string, AudioBufferSourceNode> = {}; // Record them for stopping
|
||||||
|
|
||||||
public static get instance(): LegacyCallHandler {
|
public static get instance(): LegacyCallHandler {
|
||||||
if (!window.mxLegacyCallHandler) {
|
if (!window.mxLegacyCallHandler) {
|
||||||
window.mxLegacyCallHandler = new LegacyCallHandler();
|
window.mxLegacyCallHandler = new LegacyCallHandler();
|
||||||
|
@ -199,33 +201,11 @@ export default class LegacyCallHandler extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
public start(): void {
|
public start(): void {
|
||||||
// add empty handlers for media actions, otherwise the media keys
|
|
||||||
// end up causing the audio elements with our ring/ringback etc
|
|
||||||
// audio clips in to play.
|
|
||||||
if (navigator.mediaSession) {
|
|
||||||
navigator.mediaSession.setActionHandler("play", function () {});
|
|
||||||
navigator.mediaSession.setActionHandler("pause", function () {});
|
|
||||||
navigator.mediaSession.setActionHandler("seekbackward", function () {});
|
|
||||||
navigator.mediaSession.setActionHandler("seekforward", function () {});
|
|
||||||
navigator.mediaSession.setActionHandler("previoustrack", function () {});
|
|
||||||
navigator.mediaSession.setActionHandler("nexttrack", function () {});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SettingsStore.getValue(UIFeature.Voip)) {
|
if (SettingsStore.getValue(UIFeature.Voip)) {
|
||||||
MatrixClientPeg.safeGet().on(CallEventHandlerEvent.Incoming, this.onCallIncoming);
|
MatrixClientPeg.safeGet().on(CallEventHandlerEvent.Incoming, this.onCallIncoming);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS);
|
this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS);
|
||||||
|
|
||||||
// Add event listeners for the <audio> elements
|
|
||||||
Object.values(AudioID).forEach((audioId) => {
|
|
||||||
const audioElement = document.getElementById(audioId) as HTMLMediaElement;
|
|
||||||
if (audioElement) {
|
|
||||||
this.addEventListenersForAudioElement(audioElement);
|
|
||||||
} else {
|
|
||||||
logger.warn(`LegacyCallHandler: missing <audio id="${audioId}"> from page`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public stop(): void {
|
public stop(): void {
|
||||||
|
@ -233,27 +213,6 @@ export default class LegacyCallHandler extends EventEmitter {
|
||||||
if (cli) {
|
if (cli) {
|
||||||
cli.removeListener(CallEventHandlerEvent.Incoming, this.onCallIncoming);
|
cli.removeListener(CallEventHandlerEvent.Incoming, this.onCallIncoming);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove event listeners for the <audio> elements
|
|
||||||
Array.from(this.audioElementsWithListeners.keys()).forEach((audioElement) => {
|
|
||||||
this.removeEventListenersForAudioElement(audioElement);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private addEventListenersForAudioElement(audioElement: HTMLMediaElement): void {
|
|
||||||
// Only need to setup the listeners once
|
|
||||||
if (!this.audioElementsWithListeners.get(audioElement)) {
|
|
||||||
MEDIA_EVENT_TYPES.forEach((errorEventType) => {
|
|
||||||
audioElement.addEventListener(errorEventType, this);
|
|
||||||
this.audioElementsWithListeners.set(audioElement, true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private removeEventListenersForAudioElement(audioElement: HTMLMediaElement): void {
|
|
||||||
MEDIA_EVENT_TYPES.forEach((errorEventType) => {
|
|
||||||
audioElement.removeEventListener(errorEventType, this);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* istanbul ignore next (remove if we start using this function for things other than debug logging) */
|
/* istanbul ignore next (remove if we start using this function for things other than debug logging) */
|
||||||
|
@ -465,74 +424,37 @@ export default class LegacyCallHandler extends EventEmitter {
|
||||||
return this.transferees.get(callId);
|
return this.transferees.get(callId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public play(audioId: AudioID): void {
|
public async play(audioId: AudioID): Promise<void> {
|
||||||
const logPrefix = `LegacyCallHandler.play(${audioId}):`;
|
const logPrefix = `LegacyCallHandler.play(${audioId}):`;
|
||||||
logger.debug(`${logPrefix} beginning of function`);
|
logger.debug(`${logPrefix} beginning of function`);
|
||||||
// TODO: Attach an invisible element for this instead
|
|
||||||
// which listens?
|
|
||||||
const audio = document.getElementById(audioId) as HTMLMediaElement;
|
|
||||||
if (audio) {
|
|
||||||
this.addEventListenersForAudioElement(audio);
|
|
||||||
const playAudio = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (audio.muted) {
|
|
||||||
logger.error(
|
|
||||||
`${logPrefix} <audio> element was unexpectedly muted but we recovered ` +
|
|
||||||
`gracefully by unmuting it`,
|
|
||||||
);
|
|
||||||
// Recover gracefully
|
|
||||||
audio.muted = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This still causes the chrome debugger to break on promise rejection if
|
const audioInfo: Record<AudioID, [prefix: string, loop: boolean]> = {
|
||||||
// the promise is rejected, even though we're catching the exception.
|
[AudioID.Ring]: [`./media/ring`, true],
|
||||||
logger.debug(`${logPrefix} attempting to play audio at volume=${audio.volume}`);
|
[AudioID.Ringback]: [`./media/ringback`, true],
|
||||||
await audio.play();
|
[AudioID.CallEnd]: [`./media/callend`, false],
|
||||||
logger.debug(`${logPrefix} playing audio successfully`);
|
[AudioID.Busy]: [`./media/busy`, false],
|
||||||
} catch (e) {
|
|
||||||
// This is usually because the user hasn't interacted with the document,
|
|
||||||
// or chrome doesn't think so and is denying the request. Not sure what
|
|
||||||
// we can really do here...
|
|
||||||
// https://github.com/vector-im/element-web/issues/7657
|
|
||||||
logger.warn(`${logPrefix} unable to play audio clip`, e);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
if (this.audioPromises.has(audioId)) {
|
|
||||||
this.audioPromises.set(
|
const [urlPrefix, loop] = audioInfo[audioId];
|
||||||
audioId,
|
const source = await this.backgroundAudio.pickFormatAndPlay(urlPrefix, ["mp3", "ogg"], loop);
|
||||||
this.audioPromises.get(audioId)!.then(() => {
|
this.playingSources[audioId] = source;
|
||||||
audio.load();
|
logger.debug(`${logPrefix} playing audio successfully`);
|
||||||
return playAudio();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.audioPromises.set(audioId, playAudio());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.warn(`${logPrefix} unable to find <audio> element for ${audioId}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public pause(audioId: AudioID): void {
|
public pause(audioId: AudioID): void {
|
||||||
const logPrefix = `LegacyCallHandler.pause(${audioId}):`;
|
const logPrefix = `LegacyCallHandler.pause(${audioId}):`;
|
||||||
logger.debug(`${logPrefix} beginning of function`);
|
logger.debug(`${logPrefix} beginning of function`);
|
||||||
// TODO: Attach an invisible element for this instead
|
|
||||||
// which listens?
|
const source = this.playingSources[audioId];
|
||||||
const audio = document.getElementById(audioId) as HTMLMediaElement;
|
if (!source) {
|
||||||
const pauseAudio = (): void => {
|
logger.debug(`${logPrefix} audio not playing`);
|
||||||
logger.debug(`${logPrefix} pausing audio`);
|
return;
|
||||||
// pause doesn't return a promise, so just do it
|
|
||||||
audio.pause();
|
|
||||||
};
|
|
||||||
if (audio) {
|
|
||||||
if (this.audioPromises.has(audioId)) {
|
|
||||||
this.audioPromises.set(audioId, this.audioPromises.get(audioId)!.then(pauseAudio));
|
|
||||||
} else {
|
|
||||||
pauseAudio();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.warn(`${logPrefix} unable to find <audio> element for ${audioId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
source.stop();
|
||||||
|
delete this.playingSources[audioId];
|
||||||
|
|
||||||
|
logger.debug(`${logPrefix} paused audio`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private matchesCallForThisRoom(call: MatrixCall): boolean {
|
private matchesCallForThisRoom(call: MatrixCall): boolean {
|
||||||
|
|
|
@ -58,6 +58,7 @@ import ToastStore from "./stores/ToastStore";
|
||||||
import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType } from "./voice-broadcast";
|
import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType } from "./voice-broadcast";
|
||||||
import { getSenderName } from "./utils/event/getSenderName";
|
import { getSenderName } from "./utils/event/getSenderName";
|
||||||
import { stripPlainReply } from "./utils/Reply";
|
import { stripPlainReply } from "./utils/Reply";
|
||||||
|
import { BackgroundAudio } from "./audio/BackgroundAudio";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Dispatches:
|
* Dispatches:
|
||||||
|
@ -112,6 +113,8 @@ class NotifierClass {
|
||||||
private toolbarHidden?: boolean;
|
private toolbarHidden?: boolean;
|
||||||
private isSyncing?: boolean;
|
private isSyncing?: boolean;
|
||||||
|
|
||||||
|
private backgroundAudio = new BackgroundAudio();
|
||||||
|
|
||||||
public notificationMessageForEvent(ev: MatrixEvent): string | null {
|
public notificationMessageForEvent(ev: MatrixEvent): string | null {
|
||||||
const msgType = ev.getContent().msgtype;
|
const msgType = ev.getContent().msgtype;
|
||||||
if (msgType && msgTypeHandlers.hasOwnProperty(msgType)) {
|
if (msgType && msgTypeHandlers.hasOwnProperty(msgType)) {
|
||||||
|
@ -226,28 +229,14 @@ class NotifierClass {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Play notification sound here
|
||||||
const sound = this.getSoundForRoom(room.roomId);
|
const sound = this.getSoundForRoom(room.roomId);
|
||||||
logger.log(`Got sound ${(sound && sound.name) || "default"} for ${room.roomId}`);
|
logger.log(`Got sound ${(sound && sound.name) || "default"} for ${room.roomId}`);
|
||||||
|
|
||||||
try {
|
if (sound) {
|
||||||
const selector = document.querySelector<HTMLAudioElement>(
|
await this.backgroundAudio.play(sound.url);
|
||||||
sound ? `audio[src='${sound.url}']` : "#messageAudio",
|
} else {
|
||||||
);
|
await this.backgroundAudio.pickFormatAndPlay("media/message", ["mp3", "ogg"]);
|
||||||
let audioElement = selector;
|
|
||||||
if (!audioElement) {
|
|
||||||
if (!sound) {
|
|
||||||
logger.error("No audio element or sound to play for notification");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
audioElement = new Audio(sound.url);
|
|
||||||
if (sound.type) {
|
|
||||||
audioElement.type = sound.type;
|
|
||||||
}
|
|
||||||
document.body.appendChild(audioElement);
|
|
||||||
}
|
|
||||||
await audioElement.play();
|
|
||||||
} catch (ex) {
|
|
||||||
logger.warn("Caught error when trying to fetch room notification sound:", ex);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
/*
|
||||||
|
Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
|
import { createAudioContext } from "./compat";
|
||||||
|
|
||||||
|
const formatMap = {
|
||||||
|
mp3: "audio/mpeg",
|
||||||
|
ogg: "audio/ogg",
|
||||||
|
};
|
||||||
|
|
||||||
|
export class BackgroundAudio {
|
||||||
|
private audioContext = createAudioContext();
|
||||||
|
private sounds: Record<string, AudioBuffer> = {};
|
||||||
|
|
||||||
|
public async pickFormatAndPlay<F extends Array<keyof typeof formatMap>>(
|
||||||
|
urlPrefix: string,
|
||||||
|
formats: F,
|
||||||
|
loop = false,
|
||||||
|
): Promise<AudioBufferSourceNode> {
|
||||||
|
const format = this.pickFormat(...formats);
|
||||||
|
if (!format) {
|
||||||
|
console.log("Browser doesn't support any of the formats", formats);
|
||||||
|
// Will probably never happen. If happened, format="" and will fail to load audio. Who cares...
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.play(`${urlPrefix}.${format}`, loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async play(url: string, loop = false): Promise<AudioBufferSourceNode> {
|
||||||
|
if (!this.sounds.hasOwnProperty(url)) {
|
||||||
|
// No cache, fetch it
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (response.status != 200) {
|
||||||
|
logger.warn("Failed to fetch error audio");
|
||||||
|
}
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
const sound = await this.audioContext.decodeAudioData(buffer);
|
||||||
|
this.sounds[url] = sound;
|
||||||
|
}
|
||||||
|
const source = this.audioContext.createBufferSource();
|
||||||
|
source.buffer = this.sounds[url];
|
||||||
|
source.loop = loop;
|
||||||
|
source.connect(this.audioContext.destination);
|
||||||
|
source.start();
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickFormat<F extends Array<keyof typeof formatMap>>(...formats: F): F[number] | null {
|
||||||
|
// Detect supported formats
|
||||||
|
const audioElement = document.createElement("audio");
|
||||||
|
|
||||||
|
for (const format of formats) {
|
||||||
|
if (audioElement.canPlayType(formatMap[format])) {
|
||||||
|
return format;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -47,6 +47,7 @@ import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents";
|
||||||
import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper";
|
import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper";
|
||||||
import { createReconnectedListener } from "../../utils/connection";
|
import { createReconnectedListener } from "../../utils/connection";
|
||||||
import { localNotificationsAreSilenced } from "../../utils/notifications";
|
import { localNotificationsAreSilenced } from "../../utils/notifications";
|
||||||
|
import { BackgroundAudio } from "../../audio/BackgroundAudio";
|
||||||
|
|
||||||
export enum VoiceBroadcastRecordingEvent {
|
export enum VoiceBroadcastRecordingEvent {
|
||||||
StateChanged = "liveness_changed",
|
StateChanged = "liveness_changed",
|
||||||
|
@ -75,6 +76,7 @@ export class VoiceBroadcastRecording
|
||||||
private reconnectedListener: ClientEventHandlerMap[ClientEvent.Sync];
|
private reconnectedListener: ClientEventHandlerMap[ClientEvent.Sync];
|
||||||
private roomId: string;
|
private roomId: string;
|
||||||
private infoEventId: string;
|
private infoEventId: string;
|
||||||
|
private backgroundAudio = new BackgroundAudio();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcast chunks have a sequence number to bring them in the correct order and to know if a message is missing.
|
* Broadcast chunks have a sequence number to bring them in the correct order and to know if a message is missing.
|
||||||
|
@ -346,15 +348,7 @@ export class VoiceBroadcastRecording
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio files are added to the document in Element Web.
|
await this.backgroundAudio.pickFormatAndPlay("./media/error", ["mp3", "ogg"]);
|
||||||
// See <audio> elements in https://github.com/vector-im/element-web/blob/develop/src/vector/index.html
|
|
||||||
const audioElement = document.querySelector<HTMLAudioElement>("audio#errorAudio");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await audioElement?.play();
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn("error playing 'errorAudio'", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async uploadFile(chunk: ChunkRecordedPayload): ReturnType<typeof uploadFile> {
|
private async uploadFile(chunk: ChunkRecordedPayload): ReturnType<typeof uploadFile> {
|
||||||
|
|
|
@ -28,16 +28,18 @@ import { CallEvent, CallState, CallType, MatrixCall } from "matrix-js-sdk/src/we
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
import { mocked } from "jest-mock";
|
import { mocked } from "jest-mock";
|
||||||
import { CallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/callEventHandler";
|
import { CallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/callEventHandler";
|
||||||
|
import fetchMock from "fetch-mock-jest";
|
||||||
|
import { waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
import LegacyCallHandler, {
|
import LegacyCallHandler, {
|
||||||
LegacyCallHandlerEvent,
|
|
||||||
AudioID,
|
AudioID,
|
||||||
|
LegacyCallHandlerEvent,
|
||||||
PROTOCOL_PSTN,
|
PROTOCOL_PSTN,
|
||||||
PROTOCOL_PSTN_PREFIXED,
|
PROTOCOL_PSTN_PREFIXED,
|
||||||
PROTOCOL_SIP_NATIVE,
|
PROTOCOL_SIP_NATIVE,
|
||||||
PROTOCOL_SIP_VIRTUAL,
|
PROTOCOL_SIP_VIRTUAL,
|
||||||
} from "../src/LegacyCallHandler";
|
} from "../src/LegacyCallHandler";
|
||||||
import { stubClient, mkStubRoom, untilDispatch } from "./test-utils";
|
import { mkStubRoom, stubClient, untilDispatch } from "./test-utils";
|
||||||
import { MatrixClientPeg } from "../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../src/MatrixClientPeg";
|
||||||
import DMRoomMap from "../src/utils/DMRoomMap";
|
import DMRoomMap from "../src/utils/DMRoomMap";
|
||||||
import SdkConfig from "../src/SdkConfig";
|
import SdkConfig from "../src/SdkConfig";
|
||||||
|
@ -49,6 +51,7 @@ import { VoiceBroadcastInfoState, VoiceBroadcastPlayback, VoiceBroadcastRecordin
|
||||||
import { mkVoiceBroadcastInfoStateEvent } from "./voice-broadcast/utils/test-utils";
|
import { mkVoiceBroadcastInfoStateEvent } from "./voice-broadcast/utils/test-utils";
|
||||||
import { SdkContextClass } from "../src/contexts/SDKContext";
|
import { SdkContextClass } from "../src/contexts/SDKContext";
|
||||||
import Modal from "../src/Modal";
|
import Modal from "../src/Modal";
|
||||||
|
import { createAudioContext } from "../src/audio/compat";
|
||||||
|
|
||||||
jest.mock("../src/Modal");
|
jest.mock("../src/Modal");
|
||||||
|
|
||||||
|
@ -72,6 +75,11 @@ jest.mock("../src/utils/room/getFunctionalMembers", () => ({
|
||||||
getFunctionalMembers: jest.fn(),
|
getFunctionalMembers: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock("../src/audio/compat", () => ({
|
||||||
|
...jest.requireActual("../src/audio/compat"),
|
||||||
|
createAudioContext: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
// The Matrix IDs that the user sees when talking to Alice & Bob
|
// The Matrix IDs that the user sees when talking to Alice & Bob
|
||||||
const NATIVE_ALICE = "@alice:example.org";
|
const NATIVE_ALICE = "@alice:example.org";
|
||||||
const NATIVE_BOB = "@bob:example.org";
|
const NATIVE_BOB = "@bob:example.org";
|
||||||
|
@ -449,6 +457,20 @@ describe("LegacyCallHandler without third party protocols", () => {
|
||||||
let audioElement: HTMLAudioElement;
|
let audioElement: HTMLAudioElement;
|
||||||
let fakeCall: MatrixCall | null;
|
let fakeCall: MatrixCall | null;
|
||||||
|
|
||||||
|
const mockAudioBufferSourceNode = {
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
connect: jest.fn(),
|
||||||
|
start: jest.fn(),
|
||||||
|
stop: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockAudioContext = {
|
||||||
|
decodeAudioData: jest.fn().mockResolvedValue({}),
|
||||||
|
suspend: jest.fn(),
|
||||||
|
resume: jest.fn(),
|
||||||
|
createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode),
|
||||||
|
currentTime: 1337,
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
stubClient();
|
stubClient();
|
||||||
fakeCall = null;
|
fakeCall = null;
|
||||||
|
@ -464,6 +486,7 @@ describe("LegacyCallHandler without third party protocols", () => {
|
||||||
throw new Error("Endpoint unsupported.");
|
throw new Error("Endpoint unsupported.");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mocked(createAudioContext).mockReturnValue(mockAudioContext as unknown as AudioContext);
|
||||||
callHandler = new LegacyCallHandler();
|
callHandler = new LegacyCallHandler();
|
||||||
callHandler.start();
|
callHandler.start();
|
||||||
|
|
||||||
|
@ -506,6 +529,12 @@ describe("LegacyCallHandler without third party protocols", () => {
|
||||||
|
|
||||||
SdkContextClass.instance.voiceBroadcastPlaybacksStore.clearCurrent();
|
SdkContextClass.instance.voiceBroadcastPlaybacksStore.clearCurrent();
|
||||||
SdkContextClass.instance.voiceBroadcastRecordingsStore.clearCurrent();
|
SdkContextClass.instance.voiceBroadcastRecordingsStore.clearCurrent();
|
||||||
|
|
||||||
|
fetchMock.get(
|
||||||
|
"/media/ring.mp3",
|
||||||
|
{ body: new Blob(["1", "2", "3", "4"], { type: "audio/mpeg" }) },
|
||||||
|
{ sendAsJson: false },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -520,6 +549,20 @@ describe("LegacyCallHandler without third party protocols", () => {
|
||||||
SdkConfig.reset();
|
SdkConfig.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should cache sounds between playbacks", async () => {
|
||||||
|
await callHandler.play(AudioID.Ring);
|
||||||
|
expect(mockAudioBufferSourceNode.start).toHaveBeenCalled();
|
||||||
|
expect(fetchMock.calls("/media/ring.mp3")).toHaveLength(1);
|
||||||
|
await callHandler.play(AudioID.Ring);
|
||||||
|
expect(fetchMock.calls("/media/ring.mp3")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow silencing an incoming call ring", async () => {
|
||||||
|
await callHandler.play(AudioID.Ring);
|
||||||
|
await callHandler.silenceCall("call123");
|
||||||
|
expect(mockAudioBufferSourceNode.stop).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("should still start a native call", async () => {
|
it("should still start a native call", async () => {
|
||||||
callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice);
|
callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice);
|
||||||
|
|
||||||
|
@ -537,13 +580,6 @@ describe("LegacyCallHandler without third party protocols", () => {
|
||||||
describe("incoming calls", () => {
|
describe("incoming calls", () => {
|
||||||
const roomId = "test-room-id";
|
const roomId = "test-room-id";
|
||||||
|
|
||||||
const mockAudioElement = {
|
|
||||||
play: jest.fn(),
|
|
||||||
pause: jest.fn(),
|
|
||||||
addEventListener: jest.fn(),
|
|
||||||
removeEventListener: jest.fn(),
|
|
||||||
muted: false,
|
|
||||||
} as unknown as HTMLMediaElement;
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === UIFeature.Voip);
|
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === UIFeature.Voip);
|
||||||
|
@ -571,8 +607,6 @@ describe("LegacyCallHandler without third party protocols", () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.spyOn(document, "getElementById").mockReturnValue(mockAudioElement);
|
|
||||||
|
|
||||||
// silence local notifications by default
|
// silence local notifications by default
|
||||||
jest.spyOn(MatrixClientPeg.safeGet(), "getAccountData").mockImplementation((eventType) => {
|
jest.spyOn(MatrixClientPeg.safeGet(), "getAccountData").mockImplementation((eventType) => {
|
||||||
if (eventType.includes(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) {
|
if (eventType.includes(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) {
|
||||||
|
@ -586,19 +620,6 @@ describe("LegacyCallHandler without third party protocols", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should unmute <audio> before playing", () => {
|
|
||||||
// Test setup: set the audio element as muted
|
|
||||||
mockAudioElement.muted = true;
|
|
||||||
expect(mockAudioElement.muted).toStrictEqual(true);
|
|
||||||
|
|
||||||
callHandler.play(AudioID.Ring);
|
|
||||||
|
|
||||||
// Ensure audio is no longer muted
|
|
||||||
expect(mockAudioElement.muted).toStrictEqual(false);
|
|
||||||
// Ensure the audio was played
|
|
||||||
expect(mockAudioElement.play).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("listens for incoming call events when voip is enabled", () => {
|
it("listens for incoming call events when voip is enabled", () => {
|
||||||
const call = new MatrixCall({
|
const call = new MatrixCall({
|
||||||
client: MatrixClientPeg.safeGet(),
|
client: MatrixClientPeg.safeGet(),
|
||||||
|
@ -612,7 +633,7 @@ describe("LegacyCallHandler without third party protocols", () => {
|
||||||
expect(callHandler.getCallForRoom(roomId)).toEqual(call);
|
expect(callHandler.getCallForRoom(roomId)).toEqual(call);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rings when incoming call state is ringing and notifications set to ring", () => {
|
it("rings when incoming call state is ringing and notifications set to ring", async () => {
|
||||||
// remove local notification silencing mock for this test
|
// remove local notification silencing mock for this test
|
||||||
jest.spyOn(MatrixClientPeg.safeGet(), "getAccountData").mockReturnValue(undefined);
|
jest.spyOn(MatrixClientPeg.safeGet(), "getAccountData").mockReturnValue(undefined);
|
||||||
const call = new MatrixCall({
|
const call = new MatrixCall({
|
||||||
|
@ -627,8 +648,8 @@ describe("LegacyCallHandler without third party protocols", () => {
|
||||||
expect(callHandler.getCallForRoom(roomId)).toEqual(call);
|
expect(callHandler.getCallForRoom(roomId)).toEqual(call);
|
||||||
call.emit(CallEvent.State, CallState.Ringing, CallState.Connected, fakeCall!);
|
call.emit(CallEvent.State, CallState.Ringing, CallState.Connected, fakeCall!);
|
||||||
|
|
||||||
// ringer audio element started
|
// ringer audio started
|
||||||
expect(mockAudioElement.play).toHaveBeenCalled();
|
await waitFor(() => expect(mockAudioBufferSourceNode.start).toHaveBeenCalled());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not ring when incoming call state is ringing but local notifications are silenced", () => {
|
it("does not ring when incoming call state is ringing but local notifications are silenced", () => {
|
||||||
|
@ -645,7 +666,7 @@ describe("LegacyCallHandler without third party protocols", () => {
|
||||||
call.emit(CallEvent.State, CallState.Ringing, CallState.Connected, fakeCall!);
|
call.emit(CallEvent.State, CallState.Ringing, CallState.Connected, fakeCall!);
|
||||||
|
|
||||||
// ringer audio element started
|
// ringer audio element started
|
||||||
expect(mockAudioElement.play).not.toHaveBeenCalled();
|
expect(mockAudioBufferSourceNode.start).not.toHaveBeenCalled();
|
||||||
expect(callHandler.isCallSilenced(call.callId)).toEqual(true);
|
expect(callHandler.isCallSilenced(call.callId)).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -679,7 +700,7 @@ describe("LegacyCallHandler without third party protocols", () => {
|
||||||
// call still silenced
|
// call still silenced
|
||||||
expect(callHandler.isCallSilenced(call.callId)).toEqual(true);
|
expect(callHandler.isCallSilenced(call.callId)).toEqual(true);
|
||||||
// ringer not played
|
// ringer not played
|
||||||
expect(mockAudioElement.play).not.toHaveBeenCalled();
|
expect(mockAudioBufferSourceNode.start).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -60,6 +60,11 @@ jest.mock("../src/utils/notifications", () => ({
|
||||||
createLocalNotificationSettingsIfNeeded: jest.fn(),
|
createLocalNotificationSettingsIfNeeded: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock("../src/audio/compat", () => ({
|
||||||
|
...jest.requireActual("../src/audio/compat"),
|
||||||
|
createAudioContext: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("Notifier", () => {
|
describe("Notifier", () => {
|
||||||
const roomId = "!room1:server";
|
const roomId = "!room1:server";
|
||||||
const testEvent = mkEvent({
|
const testEvent = mkEvent({
|
||||||
|
@ -103,6 +108,19 @@ describe("Notifier", () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockAudioBufferSourceNode = {
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
connect: jest.fn(),
|
||||||
|
start: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockAudioContext = {
|
||||||
|
decodeAudioData: jest.fn(),
|
||||||
|
suspend: jest.fn(),
|
||||||
|
resume: jest.fn(),
|
||||||
|
createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode),
|
||||||
|
currentTime: 1337,
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
accountDataStore = {};
|
accountDataStore = {};
|
||||||
mockClient = getMockClientWithEventEmitter({
|
mockClient = getMockClientWithEventEmitter({
|
||||||
|
@ -144,6 +162,9 @@ describe("Notifier", () => {
|
||||||
if (id) return new Room(id, mockClient, mockClient.getSafeUserId());
|
if (id) return new Room(id, mockClient, mockClient.getSafeUserId());
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
Notifier.backgroundAudio.audioContext = mockAudioContext;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("triggering notification from events", () => {
|
describe("triggering notification from events", () => {
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const mocks = {
|
||||||
|
AudioBufferSourceNode: {
|
||||||
|
connect: jest.fn(),
|
||||||
|
start: jest.fn(),
|
||||||
|
} as unknown as AudioBufferSourceNode,
|
||||||
|
AudioContext: {
|
||||||
|
close: jest.fn(),
|
||||||
|
createMediaElementSource: jest.fn(),
|
||||||
|
createMediaStreamDestination: jest.fn(),
|
||||||
|
createMediaStreamSource: jest.fn(),
|
||||||
|
createStreamTrackSource: jest.fn(),
|
||||||
|
createBufferSource: jest.fn((): AudioBufferSourceNode => ({ ...mocks.AudioBufferSourceNode })),
|
||||||
|
getOutputTimestamp: jest.fn(),
|
||||||
|
resume: jest.fn(),
|
||||||
|
setSinkId: jest.fn(),
|
||||||
|
suspend: jest.fn(),
|
||||||
|
decodeAudioData: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
|
@ -18,6 +18,8 @@ import fetchMock from "fetch-mock-jest";
|
||||||
import { TextDecoder, TextEncoder } from "util";
|
import { TextDecoder, TextEncoder } from "util";
|
||||||
import { Response } from "node-fetch";
|
import { Response } from "node-fetch";
|
||||||
|
|
||||||
|
import { mocks } from "./mocks";
|
||||||
|
|
||||||
// Stub ResizeObserver
|
// Stub ResizeObserver
|
||||||
// @ts-ignore - we know it's a duplicate (that's why we're stubbing it)
|
// @ts-ignore - we know it's a duplicate (that's why we're stubbing it)
|
||||||
class ResizeObserver {
|
class ResizeObserver {
|
||||||
|
@ -76,6 +78,7 @@ global.TextDecoder = TextDecoder;
|
||||||
|
|
||||||
// prevent errors whenever a component tries to manually scroll.
|
// prevent errors whenever a component tries to manually scroll.
|
||||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||||
|
window.HTMLAudioElement.prototype.canPlayType = jest.fn((format) => (format === "audio/mpeg" ? "probably" : ""));
|
||||||
|
|
||||||
// set up fetch API mock
|
// set up fetch API mock
|
||||||
fetchMock.config.overwriteRoutes = false;
|
fetchMock.config.overwriteRoutes = false;
|
||||||
|
@ -87,3 +90,6 @@ window.fetch = fetchMock.sandbox();
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.Response = Response;
|
window.Response = Response;
|
||||||
|
|
||||||
|
// set up AudioContext API mock
|
||||||
|
global.AudioContext = jest.fn().mockImplementation(() => ({ ...mocks.AudioContext }));
|
||||||
|
|
|
@ -30,6 +30,7 @@ import {
|
||||||
SyncState,
|
SyncState,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import { EncryptedFile } from "matrix-js-sdk/src/types";
|
import { EncryptedFile } from "matrix-js-sdk/src/types";
|
||||||
|
import fetchMock from "fetch-mock-jest";
|
||||||
|
|
||||||
import { uploadFile } from "../../../src/ContentMessages";
|
import { uploadFile } from "../../../src/ContentMessages";
|
||||||
import { createVoiceMessageContent } from "../../../src/utils/createVoiceMessageContent";
|
import { createVoiceMessageContent } from "../../../src/utils/createVoiceMessageContent";
|
||||||
|
@ -49,6 +50,7 @@ import {
|
||||||
import { mkEvent, mkStubRoom, stubClient } from "../../test-utils";
|
import { mkEvent, mkStubRoom, stubClient } from "../../test-utils";
|
||||||
import dis from "../../../src/dispatcher/dispatcher";
|
import dis from "../../../src/dispatcher/dispatcher";
|
||||||
import { VoiceRecording } from "../../../src/audio/VoiceRecording";
|
import { VoiceRecording } from "../../../src/audio/VoiceRecording";
|
||||||
|
import { createAudioContext } from "../../../src/audio/compat";
|
||||||
|
|
||||||
jest.mock("../../../src/voice-broadcast/audio/VoiceBroadcastRecorder", () => ({
|
jest.mock("../../../src/voice-broadcast/audio/VoiceBroadcastRecorder", () => ({
|
||||||
...(jest.requireActual("../../../src/voice-broadcast/audio/VoiceBroadcastRecorder") as object),
|
...(jest.requireActual("../../../src/voice-broadcast/audio/VoiceBroadcastRecorder") as object),
|
||||||
|
@ -79,6 +81,11 @@ jest.mock("../../../src/utils/createVoiceMessageContent", () => ({
|
||||||
createVoiceMessageContent: jest.fn(),
|
createVoiceMessageContent: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../../src/audio/compat", () => ({
|
||||||
|
...jest.requireActual("../../../src/audio/compat"),
|
||||||
|
createAudioContext: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("VoiceBroadcastRecording", () => {
|
describe("VoiceBroadcastRecording", () => {
|
||||||
const roomId = "!room:example.com";
|
const roomId = "!room:example.com";
|
||||||
const uploadedUrl = "mxc://example.com/vb";
|
const uploadedUrl = "mxc://example.com/vb";
|
||||||
|
@ -198,6 +205,19 @@ describe("VoiceBroadcastRecording", () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockAudioBufferSourceNode = {
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
connect: jest.fn(),
|
||||||
|
start: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockAudioContext = {
|
||||||
|
decodeAudioData: jest.fn(),
|
||||||
|
suspend: jest.fn(),
|
||||||
|
resume: jest.fn(),
|
||||||
|
createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode),
|
||||||
|
currentTime: 1337,
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
client = stubClient();
|
client = stubClient();
|
||||||
room = mkStubRoom(roomId, "Test Room", client);
|
room = mkStubRoom(roomId, "Test Room", client);
|
||||||
|
@ -265,6 +285,8 @@ describe("VoiceBroadcastRecording", () => {
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mocked(createAudioContext).mockReturnValue(mockAudioContext as unknown as AudioContext);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -546,12 +568,13 @@ describe("VoiceBroadcastRecording", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocked(client.sendMessage).mockRejectedValue("Error");
|
mocked(client.sendMessage).mockRejectedValue("Error");
|
||||||
emitFirsChunkRecorded();
|
emitFirsChunkRecorded();
|
||||||
|
fetchMock.get("media/error.mp3", 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
itShouldBeInState("connection_error");
|
itShouldBeInState("connection_error");
|
||||||
|
|
||||||
it("should play a notification", () => {
|
it("should play a notification", () => {
|
||||||
expect(audioElement.play).toHaveBeenCalled();
|
expect(mockAudioBufferSourceNode.start).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("and the connection is back", () => {
|
describe("and the connection is back", () => {
|
||||||
|
|
|
@ -9230,6 +9230,11 @@ walker@^1.0.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
makeerror "1.0.12"
|
makeerror "1.0.12"
|
||||||
|
|
||||||
|
web-streams-polyfill@^4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0.tgz#74cedf168339ee6e709532f76c49313a8c7acdac"
|
||||||
|
integrity sha512-0zJXHRAYEjM2tUfZ2DiSOHAa2aw1tisnnhU3ufD57R8iefL+DcdJyRBRyJpG+NUimDgbTI/lH+gAE1PAvV3Cgw==
|
||||||
|
|
||||||
web-vitals@^4.0.1:
|
web-vitals@^4.0.1:
|
||||||
version "4.2.0"
|
version "4.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-4.2.0.tgz#008949ab79717a68ccaaa3c4371cbc7bbbd78a92"
|
resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-4.2.0.tgz#008949ab79717a68ccaaa3c4371cbc7bbbd78a92"
|
||||||
|
|
Loading…
Reference in New Issue