diff --git a/src/audio/Playback.ts b/src/audio/Playback.ts index 96930d43af..b0af13fb56 100644 --- a/src/audio/Playback.ts +++ b/src/audio/Playback.ts @@ -20,6 +20,7 @@ import { DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES } from "./consts"; import { PlaybackEncoder } from "../PlaybackEncoder"; export enum PlaybackState { + Preparing = "preparing", // preparing to decode Decoding = "decoding", Stopped = "stopped", // no progress on timeline Paused = "paused", // some progress on timeline @@ -146,6 +147,8 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte return; } + this.state = PlaybackState.Preparing; + // 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 // messages do not. Using an audio element means we can't show a waveform preview, so diff --git a/src/components/views/audio_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx index 75e1419ce6..4f5c392d41 100644 --- a/src/components/views/audio_messages/RecordingPlayback.tsx +++ b/src/components/views/audio_messages/RecordingPlayback.tsx @@ -74,7 +74,11 @@ export default class RecordingPlayback extends AudioPlayerBase { } return ( -
+
{ @@ -38,7 +35,6 @@ export default class MAudioBody extends React.PureComponent public async componentDidMount(): Promise { let buffer: ArrayBuffer; - try { try { const blob = await this.props.mediaEventHelper!.sourceBlob.value; @@ -63,18 +59,16 @@ export default class MAudioBody extends React.PureComponent // We should have a buffer to work with now: let's set it up const playback = PlaybackManager.instance.createPlaybackInstance(buffer, waveform); playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent); - this.setState({ playback, audioPlayerVm: new AudioPlayerViewModel({ playback, mediaName: content.body }) }); + this.setState({ playback }); - if (isVoiceMessage(this.props.mxEvent)) { - PlaybackQueue.forRoom(this.props.mxEvent.getRoomId()!).unsortedEnqueue(this.props.mxEvent, playback); - } - - // Note: the components later on will handle preparing the Playback class for us. + this.onMount(playback); + // Note: the components later on will handle preparing the Playback class for us } + protected onMount(playback: Playback): void {} + public componentWillUnmount(): void { this.state.playback?.destroy(); - this.state.audioPlayerVm?.dispose(); } protected get showFileBody(): boolean { @@ -116,9 +110,35 @@ export default class MAudioBody extends React.PureComponent // At this point we should have a playable state return ( - {this.state.audioPlayerVm && } + {this.showFileBody && } ); } } + +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 ; +} diff --git a/src/components/views/messages/MVoiceMessageBody.tsx b/src/components/views/messages/MVoiceMessageBody.tsx index cc0e030c78..0f07c11576 100644 --- a/src/components/views/messages/MVoiceMessageBody.tsx +++ b/src/components/views/messages/MVoiceMessageBody.tsx @@ -14,8 +14,17 @@ import RecordingPlayback from "../audio_messages/RecordingPlayback"; import MAudioBody from "./MAudioBody"; import MFileBody from "./MFileBody"; 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 { + 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. public render(): React.ReactNode { if (this.state.error) { diff --git a/src/shared-components/audio/playback.ts b/src/shared-components/audio/playback.ts index 0979a2441b..d7b0fa7634 100644 --- a/src/shared-components/audio/playback.ts +++ b/src/shared-components/audio/playback.ts @@ -7,9 +7,10 @@ /** * 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. * - "stopped": The playback has been stopped, with no progress on the timeline. * - "paused": The playback is paused, with some progress on 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"; diff --git a/test/unit-tests/audio/MockedPlayback.ts b/test/unit-tests/audio/MockedPlayback.ts new file mode 100644 index 0000000000..da7839748e --- /dev/null +++ b/test/unit-tests/audio/MockedPlayback.ts @@ -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(); + public liveData = new SimpleObservable(); + + 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 { + return this.waveformObservable; + } + + public prepare = jest.fn().mockResolvedValue(undefined); + public skipTo = jest.fn(); + public toggle = jest.fn(); + public destroy = jest.fn().mockResolvedValue(undefined); +} diff --git a/test/unit-tests/components/views/messages/MAudioBody-test.tsx b/test/unit-tests/components/views/messages/MAudioBody-test.tsx new file mode 100644 index 0000000000..e2e2245c13 --- /dev/null +++ b/test/unit-tests/components/views/messages/MAudioBody-test.tsx @@ -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("", () => { + 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()); + expect(await screen.findByRole("region", { name: "Audio player" })).toBeInTheDocument(); + }); +}); diff --git a/test/unit-tests/components/views/messages/MVoiceMessageBody-test.tsx b/test/unit-tests/components/views/messages/MVoiceMessageBody-test.tsx new file mode 100644 index 0000000000..4a20266af4 --- /dev/null +++ b/test/unit-tests/components/views/messages/MVoiceMessageBody-test.tsx @@ -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("", () => { + 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()); + expect(await screen.findByTestId("recording-playback")).toBeInTheDocument(); + }); +}); diff --git a/test/viewmodels/audio/AudioPlayerViewModel-test.tsx b/test/viewmodels/audio/AudioPlayerViewModel-test.tsx index b3e5cb99f2..a6c4a04166 100644 --- a/test/viewmodels/audio/AudioPlayerViewModel-test.tsx +++ b/test/viewmodels/audio/AudioPlayerViewModel-test.tsx @@ -5,18 +5,17 @@ * 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 { waitFor } from "@testing-library/dom"; import { type Playback, PlaybackState } from "../../../src/audio/Playback"; import { AudioPlayerViewModel } from "../../../src/viewmodels/audio/AudioPlayerViewModel"; +import { MockedPlayback } from "../../unit-tests/audio/MockedPlayback"; describe("AudioPlayerViewModel", () => { - let playback: MockedPlayback & Playback; + let playback: Playback; 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", () => { @@ -66,38 +65,3 @@ describe("AudioPlayerViewModel", () => { 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(); -}