Prevent voice message from displaying spurious errors (#30736)
* fix: avoid to render `AudioPlayerViewModel` when `MAudioBody` is inherited * fix: avoid `Playback.prepare` to fail when called twice * fix: add `decoding` to playback type * refactor: fix circular deps * refactor: extract `MockedPlayback` from `AudioPlayerViewModel` * test: add `MAudioBody` basic test * test: add tests for `MVoiceMessageBody` * fix: lint
This commit is contained in:
@@ -20,6 +20,7 @@ import { DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES } from "./consts";
|
|||||||
import { PlaybackEncoder } from "../PlaybackEncoder";
|
import { PlaybackEncoder } from "../PlaybackEncoder";
|
||||||
|
|
||||||
export enum PlaybackState {
|
export enum PlaybackState {
|
||||||
|
Preparing = "preparing", // preparing to decode
|
||||||
Decoding = "decoding",
|
Decoding = "decoding",
|
||||||
Stopped = "stopped", // no progress on timeline
|
Stopped = "stopped", // no progress on timeline
|
||||||
Paused = "paused", // some progress on timeline
|
Paused = "paused", // some progress on timeline
|
||||||
@@ -146,6 +147,8 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.state = PlaybackState.Preparing;
|
||||||
|
|
||||||
// The point where we use an audio element is fairly arbitrary, though we don't want
|
// The point where we use an audio element is fairly arbitrary, though we don't want
|
||||||
// it to be too low. As of writing, voice messages want to show a waveform but audio
|
// it to be too low. As of writing, voice messages want to show a waveform but audio
|
||||||
// messages do not. Using an audio element means we can't show a waveform preview, so
|
// messages do not. Using an audio element means we can't show a waveform preview, so
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ export default class RecordingPlayback extends AudioPlayerBase<IProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_MediaBody mx_VoiceMessagePrimaryContainer" onKeyDown={this.onKeyDown}>
|
<div
|
||||||
|
className="mx_MediaBody mx_VoiceMessagePrimaryContainer"
|
||||||
|
onKeyDown={this.onKeyDown}
|
||||||
|
data-testid="recording-playback"
|
||||||
|
>
|
||||||
<PlayPauseButton
|
<PlayPauseButton
|
||||||
playback={this.props.playback}
|
playback={this.props.playback}
|
||||||
playbackPhase={this.state.playbackPhase}
|
playbackPhase={this.state.playbackPhase}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
|||||||
Please see LICENSE files in the repository root for full details.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React, { type JSX, useEffect, useMemo } from "react";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { type IContent } from "matrix-js-sdk/src/matrix";
|
import { type IContent } from "matrix-js-sdk/src/matrix";
|
||||||
import { type MediaEventContent } from "matrix-js-sdk/src/types";
|
import { type MediaEventContent } from "matrix-js-sdk/src/types";
|
||||||
@@ -17,8 +17,6 @@ import { _t } from "../../../languageHandler";
|
|||||||
import MFileBody from "./MFileBody";
|
import MFileBody from "./MFileBody";
|
||||||
import { type IBodyProps } from "./IBodyProps";
|
import { type IBodyProps } from "./IBodyProps";
|
||||||
import { PlaybackManager } from "../../../audio/PlaybackManager";
|
import { PlaybackManager } from "../../../audio/PlaybackManager";
|
||||||
import { isVoiceMessage } from "../../../utils/EventUtils";
|
|
||||||
import { PlaybackQueue } from "../../../audio/PlaybackQueue";
|
|
||||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||||
import MediaProcessingError from "./shared/MediaProcessingError";
|
import MediaProcessingError from "./shared/MediaProcessingError";
|
||||||
import { AudioPlayerViewModel } from "../../../viewmodels/audio/AudioPlayerViewModel";
|
import { AudioPlayerViewModel } from "../../../viewmodels/audio/AudioPlayerViewModel";
|
||||||
@@ -27,7 +25,6 @@ import { AudioPlayerView } from "../../../shared-components/audio/AudioPlayerVie
|
|||||||
interface IState {
|
interface IState {
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
playback?: Playback;
|
playback?: Playback;
|
||||||
audioPlayerVm?: AudioPlayerViewModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class MAudioBody extends React.PureComponent<IBodyProps, IState> {
|
export default class MAudioBody extends React.PureComponent<IBodyProps, IState> {
|
||||||
@@ -38,7 +35,6 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
|
|||||||
|
|
||||||
public async componentDidMount(): Promise<void> {
|
public async componentDidMount(): Promise<void> {
|
||||||
let buffer: ArrayBuffer;
|
let buffer: ArrayBuffer;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
const blob = await this.props.mediaEventHelper!.sourceBlob.value;
|
const blob = await this.props.mediaEventHelper!.sourceBlob.value;
|
||||||
@@ -63,18 +59,16 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
|
|||||||
// We should have a buffer to work with now: let's set it up
|
// We should have a buffer to work with now: let's set it up
|
||||||
const playback = PlaybackManager.instance.createPlaybackInstance(buffer, waveform);
|
const playback = PlaybackManager.instance.createPlaybackInstance(buffer, waveform);
|
||||||
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
|
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
|
||||||
this.setState({ playback, audioPlayerVm: new AudioPlayerViewModel({ playback, mediaName: content.body }) });
|
this.setState({ playback });
|
||||||
|
|
||||||
if (isVoiceMessage(this.props.mxEvent)) {
|
this.onMount(playback);
|
||||||
PlaybackQueue.forRoom(this.props.mxEvent.getRoomId()!).unsortedEnqueue(this.props.mxEvent, playback);
|
// Note: the components later on will handle preparing the Playback class for us
|
||||||
}
|
|
||||||
|
|
||||||
// Note: the components later on will handle preparing the Playback class for us.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected onMount(playback: Playback): void {}
|
||||||
|
|
||||||
public componentWillUnmount(): void {
|
public componentWillUnmount(): void {
|
||||||
this.state.playback?.destroy();
|
this.state.playback?.destroy();
|
||||||
this.state.audioPlayerVm?.dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected get showFileBody(): boolean {
|
protected get showFileBody(): boolean {
|
||||||
@@ -116,9 +110,35 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
|
|||||||
// At this point we should have a playable state
|
// At this point we should have a playable state
|
||||||
return (
|
return (
|
||||||
<span className="mx_MAudioBody">
|
<span className="mx_MAudioBody">
|
||||||
{this.state.audioPlayerVm && <AudioPlayerView vm={this.state.audioPlayerVm} />}
|
<AudioPlayer playback={this.state.playback} mediaName={this.props.mxEvent.getContent().body} />
|
||||||
{this.showFileBody && <MFileBody {...this.props} showGenericPlaceholder={false} />}
|
{this.showFileBody && <MFileBody {...this.props} showGenericPlaceholder={false} />}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AudioPlayerProps {
|
||||||
|
/**
|
||||||
|
* The playback instance to control audio playback.
|
||||||
|
*/
|
||||||
|
playback: Playback;
|
||||||
|
/**
|
||||||
|
* The name of the media being played
|
||||||
|
*/
|
||||||
|
mediaName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AudioPlayer component that initializes the AudioPlayerViewModel and renders the AudioPlayerView.
|
||||||
|
*/
|
||||||
|
function AudioPlayer({ playback, mediaName }: AudioPlayerProps): JSX.Element {
|
||||||
|
const vm = useMemo(() => new AudioPlayerViewModel({ playback, mediaName }), [playback, mediaName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
vm.dispose();
|
||||||
|
};
|
||||||
|
}, [vm]);
|
||||||
|
|
||||||
|
return <AudioPlayerView vm={vm} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,8 +14,17 @@ import RecordingPlayback from "../audio_messages/RecordingPlayback";
|
|||||||
import MAudioBody from "./MAudioBody";
|
import MAudioBody from "./MAudioBody";
|
||||||
import MFileBody from "./MFileBody";
|
import MFileBody from "./MFileBody";
|
||||||
import MediaProcessingError from "./shared/MediaProcessingError";
|
import MediaProcessingError from "./shared/MediaProcessingError";
|
||||||
|
import { isVoiceMessage } from "../../../utils/EventUtils";
|
||||||
|
import { PlaybackQueue } from "../../../audio/PlaybackQueue";
|
||||||
|
import { type Playback } from "../../../audio/Playback";
|
||||||
|
|
||||||
export default class MVoiceMessageBody extends MAudioBody {
|
export default class MVoiceMessageBody extends MAudioBody {
|
||||||
|
protected onMount(playback: Playback): void {
|
||||||
|
if (isVoiceMessage(this.props.mxEvent)) {
|
||||||
|
PlaybackQueue.forRoom(this.props.mxEvent.getRoomId()!).unsortedEnqueue(this.props.mxEvent, playback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A voice message is an audio file but rendered in a special way.
|
// A voice message is an audio file but rendered in a special way.
|
||||||
public render(): React.ReactNode {
|
public render(): React.ReactNode {
|
||||||
if (this.state.error) {
|
if (this.state.error) {
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the possible states of playback.
|
* Represents the possible states of playback.
|
||||||
|
* - "preparing": The audio is being prepared for playback (e.g., loading or buffering).
|
||||||
* - "decoding": The audio is being decoded and is not ready for playback.
|
* - "decoding": The audio is being decoded and is not ready for playback.
|
||||||
* - "stopped": The playback has been stopped, with no progress on the timeline.
|
* - "stopped": The playback has been stopped, with no progress on the timeline.
|
||||||
* - "paused": The playback is paused, with some progress on the timeline.
|
* - "paused": The playback is paused, with some progress on the timeline.
|
||||||
* - "playing": The playback is actively progressing through the timeline.
|
* - "playing": The playback is actively progressing through the timeline.
|
||||||
*/
|
*/
|
||||||
export type PlaybackState = "decoding" | "stopped" | "paused" | "playing";
|
export type PlaybackState = "decoding" | "stopped" | "paused" | "playing" | "preparing";
|
||||||
|
|||||||
58
test/unit-tests/audio/MockedPlayback.ts
Normal file
58
test/unit-tests/audio/MockedPlayback.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import EventEmitter from "events";
|
||||||
|
import { SimpleObservable } from "matrix-widget-api";
|
||||||
|
|
||||||
|
import { PlaybackState } from "../../../src/audio/Playback";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mocked playback implementation for testing purposes.
|
||||||
|
* It simulates a playback with a fixed size and allows state changes.
|
||||||
|
*/
|
||||||
|
export class MockedPlayback extends EventEmitter {
|
||||||
|
public sizeBytes = 8000;
|
||||||
|
private waveformObservable = new SimpleObservable<number[]>();
|
||||||
|
public liveData = new SimpleObservable<number[]>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
public currentState: PlaybackState,
|
||||||
|
public durationSeconds: number,
|
||||||
|
public timeSeconds: number,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setState(state: PlaybackState): void {
|
||||||
|
this.currentState = state;
|
||||||
|
this.emit("update", state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isPlaying(): boolean {
|
||||||
|
return this.currentState === PlaybackState.Playing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get clockInfo() {
|
||||||
|
return {
|
||||||
|
liveData: this.liveData,
|
||||||
|
populatePlaceholdersFrom: () => undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public get waveform(): number[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public get waveformData(): SimpleObservable<number[]> {
|
||||||
|
return this.waveformObservable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public prepare = jest.fn().mockResolvedValue(undefined);
|
||||||
|
public skipTo = jest.fn();
|
||||||
|
public toggle = jest.fn();
|
||||||
|
public destroy = jest.fn().mockResolvedValue(undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { render, screen, act } from "jest-matrix-react";
|
||||||
|
|
||||||
|
import { MockedPlayback } from "../../../audio/MockedPlayback";
|
||||||
|
import { type Playback, PlaybackState } from "../../../../../src/audio/Playback";
|
||||||
|
import MAudioBody from "../../../../../src/components/views/messages/MAudioBody";
|
||||||
|
import { PlaybackManager } from "../../../../../src/audio/PlaybackManager";
|
||||||
|
import { type MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
||||||
|
|
||||||
|
describe("<MAudioBody />", () => {
|
||||||
|
let event: MatrixEvent;
|
||||||
|
beforeEach(() => {
|
||||||
|
const playback = new MockedPlayback(PlaybackState.Decoding, 50, 10) as unknown as Playback;
|
||||||
|
jest.spyOn(PlaybackManager.instance, "createPlaybackInstance").mockReturnValue(playback);
|
||||||
|
|
||||||
|
event = new MatrixEvent({
|
||||||
|
room_id: "!room:server",
|
||||||
|
sender: "@alice.example.org",
|
||||||
|
type: EventType.RoomMessage,
|
||||||
|
content: {
|
||||||
|
body: "audio name ",
|
||||||
|
msgtype: "m.audio",
|
||||||
|
url: "mxc://server/audio",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render", async () => {
|
||||||
|
const mediaEventHelper = {
|
||||||
|
sourceBlob: {
|
||||||
|
value: {
|
||||||
|
arrayBuffer: () => new ArrayBuffer(8),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as MediaEventHelper;
|
||||||
|
|
||||||
|
await act(() => render(<MAudioBody mxEvent={event} mediaEventHelper={mediaEventHelper} />));
|
||||||
|
expect(await screen.findByRole("region", { name: "Audio player" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { act, render, screen } from "jest-matrix-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { MockedPlayback } from "../../../audio/MockedPlayback";
|
||||||
|
import { type Playback, PlaybackState } from "../../../../../src/audio/Playback";
|
||||||
|
import { PlaybackManager } from "../../../../../src/audio/PlaybackManager";
|
||||||
|
import type { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
||||||
|
import MVoiceMessageBody from "../../../../../src/components/views/messages/MVoiceMessageBody";
|
||||||
|
import { PlaybackQueue } from "../../../../../src/audio/PlaybackQueue";
|
||||||
|
import { createTestClient } from "../../../../test-utils";
|
||||||
|
|
||||||
|
describe("<MVvoiceMessageBody />", () => {
|
||||||
|
let event: MatrixEvent;
|
||||||
|
beforeEach(() => {
|
||||||
|
const playback = new MockedPlayback(PlaybackState.Decoding, 50, 10) as unknown as Playback;
|
||||||
|
jest.spyOn(PlaybackManager.instance, "createPlaybackInstance").mockReturnValue(playback);
|
||||||
|
|
||||||
|
const matrixClient = createTestClient();
|
||||||
|
const room = new Room("!TESTROOM", matrixClient, "@alice:example.org");
|
||||||
|
const playbackQueue = new PlaybackQueue(room);
|
||||||
|
|
||||||
|
jest.spyOn(PlaybackQueue, "forRoom").mockReturnValue(playbackQueue);
|
||||||
|
jest.spyOn(playbackQueue, "unsortedEnqueue").mockReturnValue(undefined);
|
||||||
|
|
||||||
|
event = new MatrixEvent({
|
||||||
|
room_id: "!room:server",
|
||||||
|
sender: "@alice.example.org",
|
||||||
|
type: EventType.RoomMessage,
|
||||||
|
content: {
|
||||||
|
"body": "audio name ",
|
||||||
|
"msgtype": "m.audio",
|
||||||
|
"url": "mxc://server/audio",
|
||||||
|
"org.matrix.msc3946.voice": true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render", async () => {
|
||||||
|
const mediaEventHelper = {
|
||||||
|
sourceBlob: {
|
||||||
|
value: {
|
||||||
|
arrayBuffer: () => new ArrayBuffer(8),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as MediaEventHelper;
|
||||||
|
|
||||||
|
await act(() => render(<MVoiceMessageBody mxEvent={event} mediaEventHelper={mediaEventHelper} />));
|
||||||
|
expect(await screen.findByTestId("recording-playback")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,18 +5,17 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import EventEmitter from "events";
|
|
||||||
import { SimpleObservable } from "matrix-widget-api";
|
|
||||||
import { type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
import { type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||||
import { waitFor } from "@testing-library/dom";
|
import { waitFor } from "@testing-library/dom";
|
||||||
|
|
||||||
import { type Playback, PlaybackState } from "../../../src/audio/Playback";
|
import { type Playback, PlaybackState } from "../../../src/audio/Playback";
|
||||||
import { AudioPlayerViewModel } from "../../../src/viewmodels/audio/AudioPlayerViewModel";
|
import { AudioPlayerViewModel } from "../../../src/viewmodels/audio/AudioPlayerViewModel";
|
||||||
|
import { MockedPlayback } from "../../unit-tests/audio/MockedPlayback";
|
||||||
|
|
||||||
describe("AudioPlayerViewModel", () => {
|
describe("AudioPlayerViewModel", () => {
|
||||||
let playback: MockedPlayback & Playback;
|
let playback: Playback;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
playback = new MockedPlayback(PlaybackState.Decoding, 50, 10) as unknown as MockedPlayback & Playback;
|
playback = new MockedPlayback(PlaybackState.Decoding, 50, 10) as unknown as Playback;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return the snapshot", () => {
|
it("should return the snapshot", () => {
|
||||||
@@ -66,38 +65,3 @@ describe("AudioPlayerViewModel", () => {
|
|||||||
expect(playback.skipTo).toHaveBeenCalledWith(10 + 5); // 5 seconds forward
|
expect(playback.skipTo).toHaveBeenCalledWith(10 + 5); // 5 seconds forward
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* A mocked playback implementation for testing purposes.
|
|
||||||
* It simulates a playback with a fixed size and allows state changes.
|
|
||||||
*/
|
|
||||||
class MockedPlayback extends EventEmitter {
|
|
||||||
public sizeBytes = 8000;
|
|
||||||
|
|
||||||
public constructor(
|
|
||||||
public currentState: PlaybackState,
|
|
||||||
public durationSeconds: number,
|
|
||||||
public timeSeconds: number,
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
public setState(state: PlaybackState): void {
|
|
||||||
this.currentState = state;
|
|
||||||
this.emit("update", state);
|
|
||||||
}
|
|
||||||
|
|
||||||
public get isPlaying(): boolean {
|
|
||||||
return this.currentState === PlaybackState.Playing;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get clockInfo() {
|
|
||||||
return {
|
|
||||||
liveData: new SimpleObservable(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public prepare = jest.fn().mockResolvedValue(undefined);
|
|
||||||
public skipTo = jest.fn();
|
|
||||||
public toggle = jest.fn();
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user