-
- {/* easiest way to introduce a gap between the components */}
- {this.renderFileSize()}
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/src/components/views/audio_messages/AudioPlayerBase.tsx b/src/components/views/audio_messages/AudioPlayerBase.tsx
index 71bea008a2..9da1020634 100644
--- a/src/components/views/audio_messages/AudioPlayerBase.tsx
+++ b/src/components/views/audio_messages/AudioPlayerBase.tsx
@@ -14,7 +14,7 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { _t } from "../../../languageHandler";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
-import type SeekBar from "./SeekBar";
+import type LegacySeekBar from "./LegacySeekBar";
import type PlayPauseButton from "./PlayPauseButton";
export interface IProps {
@@ -31,7 +31,7 @@ interface IState {
}
export default abstract class AudioPlayerBase extends React.PureComponent {
- protected seekRef = createRef();
+ protected seekRef = createRef();
protected playPauseRef = createRef();
public constructor(props: T) {
diff --git a/src/components/views/audio_messages/DurationClock.tsx b/src/components/views/audio_messages/DurationClock.tsx
deleted file mode 100644
index 63d6c0cb23..0000000000
--- a/src/components/views/audio_messages/DurationClock.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
-Copyright 2024 New Vector Ltd.
-Copyright 2021 The Matrix.org Foundation C.I.C.
-
-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 { Clock } from "../../../shared-components/audio/Clock";
-import { type Playback } from "../../../audio/Playback";
-
-interface IProps {
- playback: Playback;
-}
-
-interface IState {
- durationSeconds: number;
-}
-
-/**
- * A clock which shows a clip's maximum duration.
- */
-export default class DurationClock extends React.PureComponent {
- public constructor(props: IProps) {
- super(props);
-
- this.state = {
- // we track the duration on state because we won't really know what the clip duration
- // is until the first time update, and as a PureComponent we are trying to dedupe state
- // updates as much as possible. This is just the easiest way to avoid a forceUpdate() or
- // member property to track "did we get a duration".
- durationSeconds: this.props.playback.clockInfo.durationSeconds,
- };
- }
-
- public componentDidMount(): void {
- this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate);
- }
-
- private onTimeUpdate = (time: number[]): void => {
- this.setState({ durationSeconds: time[1] });
- };
-
- public render(): React.ReactNode {
- return ;
- }
-}
diff --git a/src/components/views/audio_messages/SeekBar.tsx b/src/components/views/audio_messages/LegacySeekBar.tsx
similarity index 96%
rename from src/components/views/audio_messages/SeekBar.tsx
rename to src/components/views/audio_messages/LegacySeekBar.tsx
index c5ec63b359..798a4322b7 100644
--- a/src/components/views/audio_messages/SeekBar.tsx
+++ b/src/components/views/audio_messages/LegacySeekBar.tsx
@@ -35,7 +35,10 @@ interface ISeekCSS extends CSSProperties {
const ARROW_SKIP_SECONDS = 5; // arbitrary
-export default class SeekBar extends React.PureComponent {
+/**
+ * @deprecated Use {@link SeekBar} instead.
+ */
+export default class LegacySeekBar extends React.PureComponent {
// We use an animation frame request to avoid overly spamming prop updates, even if we aren't
// really using anything demanding on the CSS front.
diff --git a/src/components/views/audio_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx
index c0e3337787..75e1419ce6 100644
--- a/src/components/views/audio_messages/RecordingPlayback.tsx
+++ b/src/components/views/audio_messages/RecordingPlayback.tsx
@@ -11,7 +11,7 @@ import React, { type ReactNode } from "react";
import PlayPauseButton from "./PlayPauseButton";
import PlaybackClock from "./PlaybackClock";
import AudioPlayerBase, { type IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase";
-import SeekBar from "./SeekBar";
+import LegacySeekBar from "./LegacySeekBar";
import PlaybackWaveform from "./PlaybackWaveform";
import { PlaybackState } from "../../../audio/Playback";
@@ -49,7 +49,7 @@ export default class RecordingPlayback extends AudioPlayerBase {
<>
- {
@@ -61,7 +63,7 @@ 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 });
+ this.setState({ playback, audioPlayerVm: new AudioPlayerViewModel({ playback, mediaName: content.body }) });
if (isVoiceMessage(this.props.mxEvent)) {
PlaybackQueue.forRoom(this.props.mxEvent.getRoomId()!).unsortedEnqueue(this.props.mxEvent, playback);
@@ -113,7 +115,7 @@ export default class MAudioBody extends React.PureComponent
// At this point we should have a playable state
return (
-
+ {this.state.audioPlayerVm && }
{this.showFileBody && }
);
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 712214e036..91bda0a1b0 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -3398,6 +3398,7 @@
"unable_to_find": "Tried to load a specific point in this room's timeline, but was unable to find it."
},
"m.audio": {
+ "audio_player": "Audio player",
"error_downloading_audio": "Error downloading audio",
"error_processing_audio": "Error processing audio message",
"error_processing_voice_message": "Error processing voice message",
diff --git a/src/shared-components/ViewWrapper.tsx b/src/shared-components/ViewWrapper.tsx
new file mode 100644
index 0000000000..57b81bd5b9
--- /dev/null
+++ b/src/shared-components/ViewWrapper.tsx
@@ -0,0 +1,52 @@
+/*
+ * 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, { type JSX, useMemo, type ComponentType } from "react";
+import { omitBy, pickBy } from "lodash";
+
+import { MockViewModel } from "./MockViewModel";
+import { type ViewModel } from "./ViewModel";
+
+interface ViewWrapperProps {
+ /**
+ * The component to render, which should accept a `vm` prop of type `V`.
+ */
+ Component: ComponentType<{ vm: V }>;
+ /**
+ * The props to pass to the component, which can include both snapshot data and actions.
+ */
+ props: Record;
+}
+
+/**
+ * A wrapper component that creates a view model instance and passes it to the specified component.
+ * This is useful for testing components in isolation with a mocked view model and allows to use primitive types in stories.
+ *
+ * Props is parsed and split into snapshot and actions. Where values that are functions (`typeof Function`) are considered actions and the rest is considered the snapshot.
+ *
+ * @example
+ * ```tsx
+ * props={Snapshot&Actions} Component={MyComponent} />
+ * ```
+ */
+export function ViewWrapper>({
+ props,
+ Component,
+}: Readonly>): JSX.Element {
+ const vm = useMemo(() => {
+ const isFunction = (value: any): value is typeof Function => typeof value === typeof Function;
+ const snapshot = omitBy(props, isFunction) as T;
+ const actions = pickBy(props, isFunction);
+
+ const vm = new MockViewModel(snapshot);
+ Object.assign(vm, actions);
+
+ return vm as unknown as V;
+ }, [props]);
+
+ return ;
+}
diff --git a/src/shared-components/audio/AudioPlayerView/AudioPlayerView.module.css b/src/shared-components/audio/AudioPlayerView/AudioPlayerView.module.css
new file mode 100644
index 0000000000..905719eedf
--- /dev/null
+++ b/src/shared-components/audio/AudioPlayerView/AudioPlayerView.module.css
@@ -0,0 +1,36 @@
+/*
+ * 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.
+ */
+
+.audioPlayer {
+ padding: var(--cpd-space-4x) var(--cpd-space-3x) var(--cpd-space-3x) var(--cpd-space-3x);
+}
+
+.mediaInfo {
+ /* Makes the ellipsis on the file name work */
+ overflow: hidden;
+}
+
+.mediaName {
+ color: var(--cpd-color-text-primary);
+ font: var(--cpd-font-body-md-regular);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 100%;
+}
+
+.byline {
+ font: var(--cpd-font-body-xs-regular);
+}
+
+.clock {
+ white-space: nowrap;
+}
+
+.error {
+ color: var(--cpd-color-text-critical-primary);
+}
diff --git a/src/shared-components/audio/AudioPlayerView/AudioPlayerView.stories.tsx b/src/shared-components/audio/AudioPlayerView/AudioPlayerView.stories.tsx
new file mode 100644
index 0000000000..869e922dd4
--- /dev/null
+++ b/src/shared-components/audio/AudioPlayerView/AudioPlayerView.stories.tsx
@@ -0,0 +1,66 @@
+/*
+ * 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, { type JSX } from "react";
+import { fn } from "storybook/test";
+
+import type { Meta, StoryFn } from "@storybook/react-vite";
+import {
+ AudioPlayerView,
+ type AudioPlayerViewActions,
+ type AudioPlayerViewSnapshot,
+ type AudioPlayerViewModel,
+} from "./AudioPlayerView";
+import { ViewWrapper } from "../../ViewWrapper";
+
+type AudioPlayerProps = AudioPlayerViewSnapshot & AudioPlayerViewActions;
+const AudioPlayerViewWrapper = (props: AudioPlayerProps): JSX.Element => (
+ Component={AudioPlayerView} props={props} />
+);
+
+export default {
+ title: "Audio/AudioPlayerView",
+ component: AudioPlayerViewWrapper,
+ tags: ["autodocs"],
+ argTypes: {
+ playbackState: {
+ options: ["stopped", "playing", "paused", "decoding"],
+ control: { type: "select" },
+ },
+ },
+ args: {
+ mediaName: "Sample Audio",
+ durationSeconds: 300,
+ playedSeconds: 120,
+ percentComplete: 30,
+ playbackState: "stopped",
+ sizeBytes: 3500,
+ error: false,
+ togglePlay: fn(),
+ onKeyDown: fn(),
+ onSeekbarChange: fn(),
+ },
+} as Meta;
+
+const Template: StoryFn = (args) => ;
+
+export const Default = Template.bind({});
+
+export const NoMediaName = Template.bind({});
+NoMediaName.args = {
+ mediaName: undefined,
+};
+
+export const NoSize = Template.bind({});
+NoSize.args = {
+ sizeBytes: undefined,
+};
+
+export const HasError = Template.bind({});
+HasError.args = {
+ error: true,
+};
diff --git a/src/shared-components/audio/AudioPlayerView/AudioPlayerView.test.tsx b/src/shared-components/audio/AudioPlayerView/AudioPlayerView.test.tsx
new file mode 100644
index 0000000000..3f08eec28c
--- /dev/null
+++ b/src/shared-components/audio/AudioPlayerView/AudioPlayerView.test.tsx
@@ -0,0 +1,78 @@
+/*
+ * 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 { render, screen } from "jest-matrix-react";
+import { composeStories } from "@storybook/react-vite";
+import React from "react";
+import userEvent from "@testing-library/user-event";
+import { fireEvent } from "@testing-library/dom";
+
+import * as stories from "./AudioPlayerView.stories.tsx";
+import { AudioPlayerView, type AudioPlayerViewActions, type AudioPlayerViewSnapshot } from "./AudioPlayerView";
+import { MockViewModel } from "../../MockViewModel";
+
+const { Default, NoMediaName, NoSize, HasError } = composeStories(stories);
+
+describe("AudioPlayerView", () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders the audio player in default state", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders the audio player without media name", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders the audio player without size", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders the audio player in error state", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ const onKeyDown = jest.fn();
+ const togglePlay = jest.fn();
+ const onSeekbarChange = jest.fn();
+
+ class AudioPlayerViewModel extends MockViewModel implements AudioPlayerViewActions {
+ public onKeyDown = onKeyDown;
+ public togglePlay = togglePlay;
+ public onSeekbarChange = onSeekbarChange;
+ }
+
+ it("should attach vm methods", async () => {
+ const user = userEvent.setup();
+ const vm = new AudioPlayerViewModel({
+ playbackState: "stopped",
+ mediaName: "Test Audio",
+ durationSeconds: 300,
+ playedSeconds: 120,
+ percentComplete: 30,
+ sizeBytes: 3500,
+ error: false,
+ });
+
+ render();
+ await user.click(screen.getByRole("button", { name: "Play" }));
+ expect(togglePlay).toHaveBeenCalled();
+
+ // user event doesn't support change events on sliders, so we use fireEvent
+ fireEvent.change(screen.getByRole("slider", { name: "Audio seek bar" }), { target: { value: "50" } });
+ expect(onSeekbarChange).toHaveBeenCalled();
+
+ await user.type(screen.getByLabelText("Audio player"), "{arrowup}");
+ expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "ArrowUp" }));
+ });
+});
diff --git a/src/shared-components/audio/AudioPlayerView/AudioPlayerView.tsx b/src/shared-components/audio/AudioPlayerView/AudioPlayerView.tsx
new file mode 100644
index 0000000000..f963c8b2cf
--- /dev/null
+++ b/src/shared-components/audio/AudioPlayerView/AudioPlayerView.tsx
@@ -0,0 +1,143 @@
+/*
+ * 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, { type ChangeEventHandler, type JSX, type KeyboardEventHandler, type MouseEventHandler } from "react";
+
+import { type ViewModel } from "../../ViewModel";
+import { useViewModel } from "../../useViewModel";
+import { MediaBody } from "../../message-body/MediaBody";
+import { Flex } from "../../utils/Flex";
+import styles from "./AudioPlayerView.module.css";
+import { PlayPauseButton } from "../PlayPauseButton";
+import { type PlaybackState } from "../playback";
+import { _t } from "../../utils/i18n";
+import { formatBytes } from "../../utils/FormattingUtils";
+import { Clock } from "../Clock";
+import { SeekBar } from "../SeekBar";
+
+export interface AudioPlayerViewSnapshot {
+ /**
+ * The playback state of the audio player.
+ */
+ playbackState: PlaybackState;
+ /**
+ * Name of the media being played.
+ * @default Fallback to "timeline|m.audio|unnamed_audio" string if not provided.
+ */
+ mediaName?: string;
+ /**
+ * Size of the audio file in bytes.
+ * Hided if not provided.
+ */
+ sizeBytes?: number;
+ /**
+ * The duration of the audio clip in seconds.
+ */
+ durationSeconds: number;
+ /**
+ * The percentage of the audio that has been played.
+ * Ranges from 0 to 100.
+ */
+ percentComplete: number;
+ /**
+ * The number of seconds that have been played.
+ */
+ playedSeconds: number;
+ /**
+ * Indicates if there was an error downloading the audio.
+ */
+ error: boolean;
+}
+
+export interface AudioPlayerViewActions {
+ /**
+ * Handles key down events for the audio player.
+ */
+ onKeyDown: KeyboardEventHandler;
+ /**
+ * Toggles the play/pause state of the audio player.
+ */
+ togglePlay: MouseEventHandler;
+ /**
+ * Handles changes to the seek bar.
+ */
+ onSeekbarChange: ChangeEventHandler;
+}
+
+/**
+ * The view model for the audio player.
+ */
+export type AudioPlayerViewModel = ViewModel & AudioPlayerViewActions;
+
+interface AudioPlayerViewProps {
+ /**
+ * The view model for the audio player.
+ */
+ vm: AudioPlayerViewModel;
+}
+
+/**
+ * AudioPlayer component displays an audio player with play/pause controls, seek bar, and media information.
+ * The component expects a view model that provides the current state of the audio playback,
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function AudioPlayerView({ vm }: Readonly): JSX.Element {
+ const {
+ playbackState,
+ mediaName = _t("timeline|m.audio|unnamed_audio"),
+ sizeBytes,
+ durationSeconds,
+ playedSeconds,
+ percentComplete,
+ error,
+ } = useViewModel(vm);
+ const fileSize = sizeBytes ? `(${formatBytes(sizeBytes)})` : null;
+ const disabled = playbackState === "decoding";
+
+ // tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
+ // events for accessibility
+ return (
+ <>
+
+
+
+
+
+ {mediaName}
+
+
+
+ {fileSize}
+
+
+
+
+
+
+
+
+ {error && {_t("timeline|m.audio|error_downloading_audio")}}
+ >
+ );
+}
diff --git a/src/shared-components/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap b/src/shared-components/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap
new file mode 100644
index 0000000000..fc8cc20a17
--- /dev/null
+++ b/src/shared-components/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap
@@ -0,0 +1,369 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AudioPlayerView renders the audio player in default state 1`] = `
+
+
+
+
+
+
+ Sample Audio
+
+
+
+ (3.42 KB)
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`AudioPlayerView renders the audio player in error state 1`] = `
+
+
+
+
+
+
+ Sample Audio
+
+
+
+ (3.42 KB)
+
+
+
+
+
+
+
+
+
+ Error downloading audio
+
+
+`;
+
+exports[`AudioPlayerView renders the audio player without media name 1`] = `
+
+
+
+
+
+
+ Unnamed audio
+
+
+
+ (3.42 KB)
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`AudioPlayerView renders the audio player without size 1`] = `
+
+
+
+
+
+
+ Sample Audio
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/shared-components/audio/AudioPlayerView/index.ts b/src/shared-components/audio/AudioPlayerView/index.ts
new file mode 100644
index 0000000000..4075f81dde
--- /dev/null
+++ b/src/shared-components/audio/AudioPlayerView/index.ts
@@ -0,0 +1,9 @@
+/*
+ * 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.
+ */
+
+export type { AudioPlayerViewModel, AudioPlayerViewSnapshot } from "./AudioPlayerView";
+export { AudioPlayerView } from "./AudioPlayerView";
diff --git a/src/shared-components/audio/Clock/Clock.tsx b/src/shared-components/audio/Clock/Clock.tsx
index 9aa114e415..176044269d 100644
--- a/src/shared-components/audio/Clock/Clock.tsx
+++ b/src/shared-components/audio/Clock/Clock.tsx
@@ -7,10 +7,11 @@ Please see LICENSE files in the repository root for full details.
import React, { type HTMLProps } from "react";
import { Temporal } from "temporal-polyfill";
+import classNames from "classnames";
import { formatSeconds } from "../../utils/DateUtils";
-export interface Props extends Pick, "aria-live" | "role"> {
+export interface Props extends Pick, "aria-live" | "role" | "className"> {
seconds: number;
}
@@ -41,7 +42,7 @@ export class Clock extends React.Component {
aria-live={this.props["aria-live"]}
role={role}
/* Keep class for backward compatibility with parent component */
- className="mx_Clock"
+ className={classNames("mx_Clock", this.props.className)}
>
{formatSeconds(seconds)}
diff --git a/src/shared-components/audio/PlayPauseButton/PlayPauseButton.module.css b/src/shared-components/audio/PlayPauseButton/PlayPauseButton.module.css
new file mode 100644
index 0000000000..74315673f1
--- /dev/null
+++ b/src/shared-components/audio/PlayPauseButton/PlayPauseButton.module.css
@@ -0,0 +1,11 @@
+/*
+ * 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.
+ */
+
+.button {
+ border-radius: 32px;
+ background-color: var(--cpd-color-bg-subtle-primary);
+}
diff --git a/src/shared-components/audio/PlayPauseButton/PlayPauseButton.stories.tsx b/src/shared-components/audio/PlayPauseButton/PlayPauseButton.stories.tsx
new file mode 100644
index 0000000000..5bbcfbbf39
--- /dev/null
+++ b/src/shared-components/audio/PlayPauseButton/PlayPauseButton.stories.tsx
@@ -0,0 +1,26 @@
+/*
+ * 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 { fn } from "storybook/test";
+
+import { PlayPauseButton } from "./PlayPauseButton";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+const meta = {
+ title: "Audio/PlayPauseButton",
+ component: PlayPauseButton,
+ tags: ["autodocs"],
+ args: {
+ togglePlay: fn(),
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Playing: Story = { args: { playing: true } };
diff --git a/src/shared-components/audio/PlayPauseButton/PlayPauseButton.test.tsx b/src/shared-components/audio/PlayPauseButton/PlayPauseButton.test.tsx
new file mode 100644
index 0000000000..3a032f5e02
--- /dev/null
+++ b/src/shared-components/audio/PlayPauseButton/PlayPauseButton.test.tsx
@@ -0,0 +1,37 @@
+/*
+ * 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 { composeStories } from "@storybook/react-vite";
+import { render } from "jest-matrix-react";
+import React from "react";
+import userEvent from "@testing-library/user-event";
+import { fn } from "storybook/test";
+
+import * as stories from "./PlayPauseButton.stories.tsx";
+
+const { Default, Playing } = composeStories(stories);
+
+describe("PlayPauseButton", () => {
+ it("renders the button in default state", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders the button in playing state", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("calls togglePlay when clicked", async () => {
+ const user = userEvent.setup();
+ const togglePlay = fn();
+
+ const { getByRole } = render();
+ await user.click(getByRole("button"));
+ expect(togglePlay).toHaveBeenCalled();
+ });
+});
diff --git a/src/shared-components/audio/PlayPauseButton/PlayPauseButton.tsx b/src/shared-components/audio/PlayPauseButton/PlayPauseButton.tsx
new file mode 100644
index 0000000000..400357a3f5
--- /dev/null
+++ b/src/shared-components/audio/PlayPauseButton/PlayPauseButton.tsx
@@ -0,0 +1,64 @@
+/*
+ * 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, { type HTMLAttributes, type JSX, type MouseEventHandler } from "react";
+import { IconButton } from "@vector-im/compound-web";
+import Play from "@vector-im/compound-design-tokens/assets/web/icons/play-solid";
+import Pause from "@vector-im/compound-design-tokens/assets/web/icons/pause-solid";
+
+import styles from "./PlayPauseButton.module.css";
+import { _t } from "../../utils/i18n";
+
+export interface PlayPauseButtonProps extends HTMLAttributes {
+ /**
+ * Whether the button is disabled.
+ * @default false
+ */
+ disabled?: boolean;
+
+ /**
+ * Whether the audio is currently playing.
+ * @default false
+ */
+ playing?: boolean;
+
+ /**
+ * Function to toggle play/pause state.
+ */
+ togglePlay: MouseEventHandler;
+}
+
+/**
+ * A button component that toggles between play and pause states for audio playback.
+ *
+ * @example
+ * ```tsx
+ * {}} />
+ * ```
+ */
+export function PlayPauseButton({
+ disabled = false,
+ playing = false,
+ togglePlay,
+ ...rest
+}: Readonly): JSX.Element {
+ const label = playing ? _t("action|pause") : _t("action|play");
+
+ return (
+
+ {playing ? : }
+
+ );
+}
diff --git a/src/shared-components/audio/PlayPauseButton/__snapshots__/PlayPauseButton.test.tsx.snap b/src/shared-components/audio/PlayPauseButton/__snapshots__/PlayPauseButton.test.tsx.snap
new file mode 100644
index 0000000000..3c60aeae11
--- /dev/null
+++ b/src/shared-components/audio/PlayPauseButton/__snapshots__/PlayPauseButton.test.tsx.snap
@@ -0,0 +1,65 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PlayPauseButton renders the button in default state 1`] = `
+
+
+
+`;
+
+exports[`PlayPauseButton renders the button in playing state 1`] = `
+
+
+
+`;
diff --git a/src/shared-components/audio/PlayPauseButton/index.ts b/src/shared-components/audio/PlayPauseButton/index.ts
new file mode 100644
index 0000000000..93a71cd739
--- /dev/null
+++ b/src/shared-components/audio/PlayPauseButton/index.ts
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+export { PlayPauseButton } from "./PlayPauseButton";
diff --git a/src/shared-components/audio/SeekBar/SeekBar.module.css b/src/shared-components/audio/SeekBar/SeekBar.module.css
new file mode 100644
index 0000000000..54e3b7479f
--- /dev/null
+++ b/src/shared-components/audio/SeekBar/SeekBar.module.css
@@ -0,0 +1,99 @@
+/*
+Copyright 2024 New Vector Ltd.
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+/* CSS inspiration from: */
+/* * https://www.w3schools.com/howto/howto_js_rangeslider.asp */
+/* * https://stackoverflow.com/a/28283806 */
+/* * https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/ */
+
+.seekBar {
+ /* default, overridden in JS */
+ --fillTo: 1;
+
+ /* Dev note: we deliberately do not have the -ms-track (and friends) selectors because we don't */
+ /* need to support IE. */
+
+ appearance: none; /* default style override */
+
+ width: 100%;
+ height: 1px;
+ background: var(--cpd-color-gray-600);
+ outline: none; /* remove blue selection border */
+ position: relative; /* for before+after pseudo elements later on */
+
+ cursor: pointer;
+
+ &::-webkit-slider-thumb {
+ appearance: none; /* default style override */
+
+ /* Dev note: This needs to be duplicated with the -moz-range-thumb selector */
+ /* because otherwise Edge (webkit) will fail to see the styles and just refuse */
+ /* to apply them. */
+ width: 8px;
+ height: 8px;
+ border-radius: 8px;
+ background-color: var(--cpd-color-gray-800);
+ cursor: pointer;
+ }
+
+ &::-moz-range-thumb {
+ width: 8px;
+ height: 8px;
+ border-radius: 8px;
+ background-color: var(--cpd-color-gray-800);
+ cursor: pointer;
+
+ /* Firefox adds a border on the thumb */
+ border: none;
+ }
+
+ /* This is for webkit support, but we can't limit the functionality of it to just webkit */
+ /* browsers. Firefox responds to webkit-prefixed values now, which means we can't use media */
+ /* or support queries to selectively apply the rule. An upside is that this CSS doesn't work */
+ /* in firefox, so it's just wasted CPU/GPU time. */
+ &::before {
+ /* ::before to ensure it ends up under the thumb */
+ content: "";
+ background-color: var(--cpd-color-gray-800);
+
+ /* Absolute positioning to ensure it overlaps with the existing bar */
+ position: absolute;
+ top: 0;
+ left: 0;
+
+ /* Sizing to match the bar */
+ width: 100%;
+ height: 1px;
+
+ /* And finally dynamic width without overly hurting the rendering engine. */
+ transform-origin: 0 100%;
+ transform: scaleX(var(--fillTo));
+ }
+
+ /* This is firefox's built-in support for the above, with 100% less hacks. */
+ &::-moz-range-progress {
+ background-color: var(--cpd-color-gray-800);
+ height: 1px;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ }
+
+ /* Increase clickable area for the slider (approximately same size as browser default) */
+ /* We do it this way to keep the same padding and margins of the element, avoiding margin math. */
+ /* Source: https://front-back.com/expand-clickable-areas-for-a-better-touch-experience/ */
+ &::after {
+ content: "";
+ position: absolute;
+ top: -6px;
+ bottom: -6px;
+ left: 0;
+ right: 0;
+ }
+}
diff --git a/src/shared-components/audio/SeekBar/SeekBar.stories.tsx b/src/shared-components/audio/SeekBar/SeekBar.stories.tsx
new file mode 100644
index 0000000000..6a3ec48666
--- /dev/null
+++ b/src/shared-components/audio/SeekBar/SeekBar.stories.tsx
@@ -0,0 +1,38 @@
+/*
+ * 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 { useArgs } from "storybook/preview-api";
+
+import { SeekBar } from "./SeekBar";
+import type { Meta, StoryFn } from "@storybook/react-vite";
+
+export default {
+ title: "Audio/SeekBar",
+ component: SeekBar,
+ tags: ["autodocs"],
+ argTypes: {
+ value: {
+ control: { type: "range", min: 0, max: 100, step: 1 },
+ },
+ },
+ args: {
+ value: 50,
+ },
+} as Meta;
+
+const Template: StoryFn = (args) => {
+ const [, updateArgs] = useArgs();
+ return updateArgs({ value: parseInt(evt.target.value, 10) })} {...args} />;
+};
+
+export const Default = Template.bind({});
+
+export const Disabled = Template.bind({});
+Disabled.args = {
+ disabled: true,
+};
diff --git a/src/shared-components/audio/SeekBar/SeekBar.test.tsx b/src/shared-components/audio/SeekBar/SeekBar.test.tsx
new file mode 100644
index 0000000000..0d52ab3993
--- /dev/null
+++ b/src/shared-components/audio/SeekBar/SeekBar.test.tsx
@@ -0,0 +1,20 @@
+/*
+ * 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 { render } from "jest-matrix-react";
+import React from "react";
+import { composeStories } from "@storybook/react-vite";
+
+import * as stories from "./SeekBar.stories.tsx";
+const { Default } = composeStories(stories);
+
+describe("Seekbar", () => {
+ it("renders the clock", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/src/shared-components/audio/SeekBar/SeekBar.tsx b/src/shared-components/audio/SeekBar/SeekBar.tsx
new file mode 100644
index 0000000000..3063e2442d
--- /dev/null
+++ b/src/shared-components/audio/SeekBar/SeekBar.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 React, { type CSSProperties, type JSX, useEffect, useMemo, useState } from "react";
+import { throttle } from "lodash";
+import classNames from "classnames";
+
+import style from "./SeekBar.module.css";
+import { _t } from "../../utils/i18n";
+
+export interface SeekBarProps extends React.InputHTMLAttributes {
+ /**
+ * The current value of the seek bar, between 0 and 100.
+ * @default 0
+ */
+ value?: number;
+}
+
+interface ISeekCSS extends CSSProperties {
+ "--fillTo": number;
+}
+
+/**
+ * A seek bar component for audio playback.
+ *
+ * @example
+ * ```tsx
+ * console.log("New value", e.target.value)} />
+ * ```
+ */
+export function SeekBar({ value = 0, className, ...rest }: Readonly): JSX.Element {
+ const [newValue, setNewValue] = useState(value);
+ // Throttle the value setting to avoid excessive re-renders
+ const setThrottledValue = useMemo(() => throttle(setNewValue, 10), []);
+
+ useEffect(() => {
+ setThrottledValue(value);
+ }, [value, setThrottledValue]);
+
+ return (
+ e.stopPropagation()}
+ min={0}
+ max={100}
+ value={newValue}
+ step={1}
+ style={{ "--fillTo": newValue / 100 } as ISeekCSS}
+ aria-label={_t("a11y|seek_bar_label")}
+ {...rest}
+ />
+ );
+}
diff --git a/src/shared-components/audio/SeekBar/__snapshots__/SeekBar.test.tsx.snap b/src/shared-components/audio/SeekBar/__snapshots__/SeekBar.test.tsx.snap
new file mode 100644
index 0000000000..49bfd5dbe7
--- /dev/null
+++ b/src/shared-components/audio/SeekBar/__snapshots__/SeekBar.test.tsx.snap
@@ -0,0 +1,16 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Seekbar renders the clock 1`] = `
+
+
+
+`;
diff --git a/src/shared-components/audio/SeekBar/index.ts b/src/shared-components/audio/SeekBar/index.ts
new file mode 100644
index 0000000000..710310198e
--- /dev/null
+++ b/src/shared-components/audio/SeekBar/index.ts
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+export { SeekBar } from "./SeekBar";
diff --git a/src/shared-components/audio/playback.ts b/src/shared-components/audio/playback.ts
new file mode 100644
index 0000000000..0979a2441b
--- /dev/null
+++ b/src/shared-components/audio/playback.ts
@@ -0,0 +1,15 @@
+/*
+ * 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.
+ */
+
+/**
+ * Represents the possible states of 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.
+ * - "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";
diff --git a/src/shared-components/message-body/MediaBody/MediaBody.module.css b/src/shared-components/message-body/MediaBody/MediaBody.module.css
new file mode 100644
index 0000000000..7cf125f303
--- /dev/null
+++ b/src/shared-components/message-body/MediaBody/MediaBody.module.css
@@ -0,0 +1,17 @@
+/*
+ * 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.
+ */
+
+.mediaBody {
+ background-color: var(--cpd-color-bg-subtle-secondary);
+ border-radius: var(--cpd-space-2x);
+ max-width: 243px; /* use max-width instead of width so it fits within right panels */
+
+ font: var(--cpd-font-body-md-regular);
+ color: var(--cpd-color-text-secondary);
+
+ padding: var(--cpd-space-1-5x) var(--cpd-space-3x);
+}
diff --git a/src/shared-components/message-body/MediaBody/MediaBody.stories.tsx b/src/shared-components/message-body/MediaBody/MediaBody.stories.tsx
new file mode 100644
index 0000000000..ee90a37943
--- /dev/null
+++ b/src/shared-components/message-body/MediaBody/MediaBody.stories.tsx
@@ -0,0 +1,24 @@
+/*
+ * 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 { MediaBody } from "./MediaBody";
+import type { Meta, StoryFn } from "@storybook/react-vite";
+
+export default {
+ title: "MessageBody/MediaBody",
+ component: MediaBody,
+ tags: ["autodocs"],
+ args: {
+ children: "Media content goes here",
+ },
+} as Meta;
+
+const Template: StoryFn = (args) => ;
+
+export const Default = Template.bind({});
diff --git a/src/shared-components/message-body/MediaBody/MediaBody.test.tsx b/src/shared-components/message-body/MediaBody/MediaBody.test.tsx
new file mode 100644
index 0000000000..9d405e1af3
--- /dev/null
+++ b/src/shared-components/message-body/MediaBody/MediaBody.test.tsx
@@ -0,0 +1,21 @@
+/*
+ * 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 { composeStories } from "@storybook/react-vite";
+import { render } from "jest-matrix-react";
+import React from "react";
+
+import * as stories from "./MediaBody.stories";
+
+const { Default } = composeStories(stories);
+
+describe("MediaBody", () => {
+ it("renders the media body", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/src/shared-components/message-body/MediaBody/MediaBody.tsx b/src/shared-components/message-body/MediaBody/MediaBody.tsx
new file mode 100644
index 0000000000..86545db67a
--- /dev/null
+++ b/src/shared-components/message-body/MediaBody/MediaBody.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 { type ComponentProps, type ElementType, type JSX, type PropsWithChildren } from "react";
+import React from "react";
+import classNames from "classnames";
+
+import styles from "./MediaBody.module.css";
+
+export type MediaBodyProps = {
+ /**
+ * The HTML tag.
+ * @default "div"
+ */
+ as?: C;
+ /**
+ * The CSS class name.
+ */
+ className?: string;
+} & ComponentProps;
+
+/**
+ * A component to display the body of a media message.
+ *
+ * @example
+ * ```tsx
+ * Media body content
+ * ```
+ */
+export function MediaBody({
+ as,
+ className,
+ children,
+ ...props
+}: PropsWithChildren>): JSX.Element {
+ const Component = as || "div";
+
+ // Keep Mx_MediaBody to support the compatibility with existing timeline and the all the layout
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/shared-components/message-body/MediaBody/__snapshots__/MediaBody.test.tsx.snap b/src/shared-components/message-body/MediaBody/__snapshots__/MediaBody.test.tsx.snap
new file mode 100644
index 0000000000..9d1011254b
--- /dev/null
+++ b/src/shared-components/message-body/MediaBody/__snapshots__/MediaBody.test.tsx.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`MediaBody renders the media body 1`] = `
+
+
+ Media content goes here
+
+
+`;
diff --git a/src/shared-components/message-body/MediaBody/index.tsx b/src/shared-components/message-body/MediaBody/index.tsx
new file mode 100644
index 0000000000..e90b1756d3
--- /dev/null
+++ b/src/shared-components/message-body/MediaBody/index.tsx
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+export { MediaBody } from "./MediaBody";
diff --git a/src/shared-components/utils/FormattingUtils.ts b/src/shared-components/utils/FormattingUtils.ts
new file mode 100644
index 0000000000..851d88aa8c
--- /dev/null
+++ b/src/shared-components/utils/FormattingUtils.ts
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+
+/**
+ * format a size in bytes into a human readable form
+ * e.g: 1024 -> 1.00 KB
+ */
+export function formatBytes(bytes: number, decimals = 2): string {
+ if (bytes === 0) return "0 Bytes";
+
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
+
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
+}
diff --git a/src/utils/FormattingUtils.ts b/src/utils/FormattingUtils.ts
index 051ef7a371..c18600280c 100644
--- a/src/utils/FormattingUtils.ts
+++ b/src/utils/FormattingUtils.ts
@@ -12,6 +12,9 @@ import { useIdColorHash } from "@vector-im/compound-web";
import { _t, getCurrentLanguage, getUserLanguage } from "../languageHandler";
import { jsxJoin } from "./ReactUtils";
+
+export { formatBytes } from "../shared-components/utils/FormattingUtils";
+
const locale = getCurrentLanguage();
// It's quite costly to instanciate `Intl.NumberFormat`, hence why we do not do
@@ -40,22 +43,6 @@ export function formatCountLong(count: number): string {
return formatter.format(count);
}
-/**
- * format a size in bytes into a human readable form
- * e.g: 1024 -> 1.00 KB
- */
-export function formatBytes(bytes: number, decimals = 2): string {
- if (bytes === 0) return "0 Bytes";
-
- const k = 1024;
- const dm = decimals < 0 ? 0 : decimals;
- const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
-
- const i = Math.floor(Math.log(bytes) / Math.log(k));
-
- return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
-}
-
export function getUserNameColorClass(userId: string): string {
// eslint-disable-next-line react-hooks/rules-of-hooks
const number = useIdColorHash(userId);
diff --git a/src/viewmodels/audio/AudioPlayerViewModel.ts b/src/viewmodels/audio/AudioPlayerViewModel.ts
new file mode 100644
index 0000000000..208cd7e907
--- /dev/null
+++ b/src/viewmodels/audio/AudioPlayerViewModel.ts
@@ -0,0 +1,150 @@
+/*
+ * 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 { type ChangeEvent, type KeyboardEvent } from "react";
+import { logger } from "matrix-js-sdk/src/logger";
+
+import {
+ type AudioPlayerViewSnapshot,
+ type AudioPlayerViewModel as AudioPlayerViewModelInterface,
+} from "../../shared-components/audio/AudioPlayerView";
+import { type Playback } from "../../audio/Playback";
+import { UPDATE_EVENT } from "../../stores/AsyncStore";
+import { percentageOf } from "../../shared-components/utils/numbers";
+import { getKeyBindingsManager } from "../../KeyBindingsManager";
+import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
+import { BaseViewModel } from "../base/BaseViewModel";
+
+/**
+ * The number of seconds to skip when the user presses the left or right arrow keys.
+ */
+const ARROW_SKIP_SECONDS = 5;
+
+interface Props {
+ /**
+ * The playback instance that manages the audio playback.
+ */
+ playback: Playback;
+ /**
+ * Optional name of the media being played.
+ */
+ mediaName?: string;
+}
+
+/**
+ * ViewModel for the audio player, providing the current state of the audio playback.
+ * It listens to updates from the Playback instance and computes a snapshot.
+ */
+export class AudioPlayerViewModel
+ extends BaseViewModel
+ implements AudioPlayerViewModelInterface
+{
+ /**
+ * Indicates if there was an error processing the audio file.
+ * @private
+ */
+ private error = false;
+
+ /**
+ * Computes the snapshot of the audio player based on the current playback state.
+ * This includes the media name, size in bytes, playback state, duration, percentage complete,
+ * played seconds, and whether there was an error.
+ * @param playback - The playback instance managing the audio playback.
+ * @param mediaName - Optional name of the media being played.
+ * @param error - Indicates if there was an error processing the audio file.
+ */
+ private static readonly computeSnapshot = (
+ playback: Playback,
+ mediaName?: string,
+ error = false,
+ ): AudioPlayerViewSnapshot => {
+ const percentComplete = percentageOf(playback.timeSeconds, 0, playback.durationSeconds) * 100;
+
+ return {
+ mediaName,
+ sizeBytes: playback.sizeBytes,
+ playbackState: playback.currentState,
+ durationSeconds: playback.durationSeconds,
+ percentComplete,
+ playedSeconds: playback.timeSeconds,
+ error,
+ };
+ };
+
+ public constructor(props: Props) {
+ super(props, AudioPlayerViewModel.computeSnapshot(props.playback, props.mediaName));
+
+ // Don't wait for the promise to complete - it will emit a progress update when it
+ // is done, and it's not meant to take long anyhow.
+ this.preparePlayback();
+ }
+
+ /**
+ * Prepares the playback by calling the prepare method on the playback instance.
+ * @private
+ */
+ private async preparePlayback(): Promise {
+ try {
+ await this.props.playback.prepare();
+ } catch (e) {
+ logger.error("Error processing audio file:", e, this.props.playback.currentState);
+ this.error = true;
+ this.setSnapshot();
+ }
+ }
+
+ protected addDownstreamSubscription(): void {
+ this.props.playback.on(UPDATE_EVENT, this.setSnapshot);
+ // There is no unsubscribe method in SimpleObservable
+ this.props.playback.clockInfo.liveData.onUpdate(this.setSnapshot);
+ }
+ protected removeDownstreamSubscription(): void {
+ this.props.playback.off(UPDATE_EVENT, this.setSnapshot);
+ }
+
+ /**
+ * Sets the snapshot and emits an update to subscribers.
+ */
+ private readonly setSnapshot = (): void => {
+ this.snapshot.set(AudioPlayerViewModel.computeSnapshot(this.props.playback, this.props.mediaName, this.error));
+ };
+
+ public onKeyDown = (ev: KeyboardEvent): void => {
+ let handled = true;
+ const action = getKeyBindingsManager().getAccessibilityAction(ev);
+
+ switch (action) {
+ case KeyBindingAction.Space:
+ this.togglePlay();
+ break;
+ case KeyBindingAction.ArrowLeft:
+ this.props.playback.skipTo(this.props.playback.timeSeconds - ARROW_SKIP_SECONDS);
+ break;
+ case KeyBindingAction.ArrowRight:
+ this.props.playback.skipTo(this.props.playback.timeSeconds + ARROW_SKIP_SECONDS);
+ break;
+ default:
+ handled = false;
+ break;
+ }
+
+ // stopPropagation() prevents the FocusComposer catch-all from triggering,
+ // but we need to do it on key down instead of press (even though the user
+ // interaction is typically on press).
+ if (handled) {
+ ev.stopPropagation();
+ }
+ };
+
+ public togglePlay = async (): Promise => {
+ await this.props.playback.toggle();
+ };
+
+ public onSeekbarChange = async (ev: ChangeEvent): Promise => {
+ await this.props.playback.skipTo((Number(ev.target.value) / 100) * this.props.playback.durationSeconds);
+ };
+}
diff --git a/test/unit-tests/components/views/audio_messages/SeekBar-test.tsx b/test/unit-tests/components/views/audio_messages/LegacySeekBar-test.tsx
similarity index 90%
rename from test/unit-tests/components/views/audio_messages/SeekBar-test.tsx
rename to test/unit-tests/components/views/audio_messages/LegacySeekBar-test.tsx
index b723ccb1c4..951f140a09 100644
--- a/test/unit-tests/components/views/audio_messages/SeekBar-test.tsx
+++ b/test/unit-tests/components/views/audio_messages/LegacySeekBar-test.tsx
@@ -12,13 +12,13 @@ import { act, fireEvent, render, type RenderResult } from "jest-matrix-react";
import { type Playback } from "../../../../../src/audio/Playback";
import { createTestPlayback } from "../../../../test-utils/audio";
-import SeekBar from "../../../../../src/components/views/audio_messages/SeekBar";
+import LegacySeekBar from "../../../../../src/components/views/audio_messages/LegacySeekBar";
describe("SeekBar", () => {
let playback: Playback;
let renderResult: RenderResult;
let frameRequestCallback: FrameRequestCallback;
- let seekBarRef: RefObject;
+ let seekBarRef: RefObject;
beforeEach(() => {
seekBarRef = createRef();
@@ -38,7 +38,7 @@ describe("SeekBar", () => {
durationSeconds: 0,
timeSeconds: 0,
});
- renderResult = render();
+ renderResult = render();
});
it("should render correctly", () => {
@@ -49,7 +49,7 @@ describe("SeekBar", () => {
describe("when rendering a SeekBar", () => {
beforeEach(() => {
playback = createTestPlayback();
- renderResult = render();
+ renderResult = render();
});
it("should render the initial position", () => {
@@ -115,7 +115,7 @@ describe("SeekBar", () => {
describe("when rendering a disabled SeekBar", () => {
beforeEach(async () => {
- renderResult = render();
+ renderResult = render();
});
it("should render as expected", () => {
diff --git a/test/unit-tests/components/views/audio_messages/__snapshots__/SeekBar-test.tsx.snap b/test/unit-tests/components/views/audio_messages/__snapshots__/LegacySeekBar-test.tsx.snap
similarity index 100%
rename from test/unit-tests/components/views/audio_messages/__snapshots__/SeekBar-test.tsx.snap
rename to test/unit-tests/components/views/audio_messages/__snapshots__/LegacySeekBar-test.tsx.snap
diff --git a/test/viewmodels/audio/AudioPlayerViewModel-test.tsx b/test/viewmodels/audio/AudioPlayerViewModel-test.tsx
new file mode 100644
index 0000000000..b3e5cb99f2
--- /dev/null
+++ b/test/viewmodels/audio/AudioPlayerViewModel-test.tsx
@@ -0,0 +1,103 @@
+/*
+ * 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 { 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";
+
+describe("AudioPlayerViewModel", () => {
+ let playback: MockedPlayback & Playback;
+ beforeEach(() => {
+ playback = new MockedPlayback(PlaybackState.Decoding, 50, 10) as unknown as MockedPlayback & Playback;
+ });
+
+ it("should return the snapshot", () => {
+ const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
+ expect(vm.getSnapshot()).toMatchObject({
+ mediaName: "mediaName",
+ sizeBytes: 8000,
+ playbackState: "decoding",
+ durationSeconds: 50,
+ playedSeconds: 10,
+ percentComplete: 20,
+ error: false,
+ });
+ });
+
+ it("should toggle the playback state", async () => {
+ const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
+
+ await vm.togglePlay();
+ expect(playback.toggle).toHaveBeenCalled();
+ });
+
+ it("should move the playback on seekbar change", async () => {
+ const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
+ await vm.onSeekbarChange({ target: { value: "20" } } as ChangeEvent);
+ expect(playback.skipTo).toHaveBeenCalledWith(10); // 20% of 50 seconds
+ });
+
+ it("should has error=true when playback.prepare fails", async () => {
+ jest.spyOn(playback, "prepare").mockRejectedValue(new Error("Failed to prepare playback"));
+ const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
+ await waitFor(() => expect(vm.getSnapshot().error).toBe(true));
+ });
+
+ it("should handle key down events", () => {
+ const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
+ let event = new KeyboardEvent("keydown", { key: " " }) as unknown as ReactKeyboardEvent;
+ vm.onKeyDown(event);
+ expect(playback.toggle).toHaveBeenCalled();
+
+ event = new KeyboardEvent("keydown", { key: "ArrowLeft" }) as unknown as ReactKeyboardEvent;
+ vm.onKeyDown(event);
+ expect(playback.skipTo).toHaveBeenCalledWith(10 - 5); // 5 seconds back
+
+ event = new KeyboardEvent("keydown", { key: "ArrowRight" }) as unknown as ReactKeyboardEvent;
+ vm.onKeyDown(event);
+ 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();
+}