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:
Florian Duros
2025-09-12 10:24:51 +02:00
committed by GitHub
parent 7fc0cb242c
commit b6710d19c0
9 changed files with 219 additions and 54 deletions

View File

@@ -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

View File

@@ -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}

View File

@@ -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} />;
}

View File

@@ -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) {

View File

@@ -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";

View 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);
}

View File

@@ -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();
});
});

View 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 { 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();
});
});

View File

@@ -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();
}