Move AudioPlayer to shared components (#30386)
* feat: add `PlayPauseButton` to storybook * feat: add generic media body * feat: add seekbar component * chore: add ViewWrapper to help writing stories with vm * refactor: move `formatBytes` from `formattingUtils` into shared component * refactor: add `className` props to `Clock` * feat: add new audio player component * test(e2e): add screenshots for new shared components * feat: add AudioPlayerViewModel * feat: use new audio player in `MAudioBody` * refactor: remove old audio player * test(e2e): update existing tests * refactor: remove unused `DurationClock` * refactor: rename `SeekBar` into `LegacySeekBar`
@@ -40,7 +40,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
// wait for the tile to finish loading
|
||||
await expect(
|
||||
page
|
||||
.locator(".mx_AudioPlayer_mediaName")
|
||||
.getByTestId("audio-player-name")
|
||||
.last()
|
||||
.filter({ hasText: file.split("/").at(-1) }),
|
||||
).toBeVisible();
|
||||
@@ -55,12 +55,10 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
// Check that the audio player is rendered and its button becomes visible
|
||||
const checkPlayerVisibility = async (locator: Locator) => {
|
||||
// Assert that the audio player and media information are visible
|
||||
const mediaInfo = locator.locator(
|
||||
".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container .mx_AudioPlayer_mediaInfo",
|
||||
);
|
||||
await expect(mediaInfo.locator(".mx_AudioPlayer_mediaName", { hasText: ".ogg" })).toBeVisible(); // extension
|
||||
await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "00:01" })).toBeVisible();
|
||||
await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "(3.56 KB)" })).toBeVisible(); // actual size
|
||||
const mediaInfo = locator.getByRole("region", { name: "Audio player" });
|
||||
await expect(mediaInfo.getByText(".ogg")).toBeVisible(); // extension
|
||||
await expect(mediaInfo.getByRole("time")).toHaveText("00:01"); // duration
|
||||
await expect(mediaInfo.getByText("(3.56 KB)")).toBeVisible(); // actual size;
|
||||
|
||||
// Assert that the play button can be found and is visible
|
||||
await expect(locator.getByRole("button", { name: "Play" })).toBeVisible();
|
||||
@@ -79,7 +77,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
}
|
||||
|
||||
// Check the status of the seek bar
|
||||
expect(await page.locator(".mx_AudioPlayer_seek input[type='range']").count()).toBeGreaterThan(0);
|
||||
expect(await page.getByRole("region", { name: "Audio player" }).getByRole("slider").count()).toBeGreaterThan(0);
|
||||
|
||||
// Enable IRC layout
|
||||
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
|
||||
@@ -101,7 +99,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
mask: [page.locator(".mx_AudioPlayer_seek")],
|
||||
mask: [page.getByTestId("audio-player-seek")],
|
||||
};
|
||||
|
||||
// Take a snapshot of mx_EventTile_last on IRC layout
|
||||
@@ -187,9 +185,9 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
await uploadFile(page, "playwright/sample-files/1sec.ogg");
|
||||
|
||||
// Assert that the audio player is rendered
|
||||
const container = page.locator(".mx_EventTile_last .mx_AudioPlayer_container");
|
||||
const container = page.locator(".mx_EventTile_last").getByRole("region", { name: "Audio player" });
|
||||
// Assert that the counter is zero before clicking the play button
|
||||
await expect(container.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
|
||||
await expect(container.getByRole("timer")).toHaveText("00:00");
|
||||
|
||||
// Find and click "Play" button, the wait is to make the test less flaky
|
||||
await expect(container.getByRole("button", { name: "Play" })).toBeVisible();
|
||||
@@ -199,7 +197,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
await expect(container.getByRole("button", { name: "Pause" })).toBeVisible();
|
||||
|
||||
// Assert that the timer is reset when the audio file finished playing
|
||||
await expect(container.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
|
||||
await expect(container.getByRole("timer")).toHaveText("00:00");
|
||||
|
||||
// Assert that "Play" button can be found
|
||||
await expect(container.getByRole("button", { name: "Play" })).toBeVisible();
|
||||
@@ -227,7 +225,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
await uploadFile(page, "playwright/sample-files/1sec.ogg");
|
||||
|
||||
// Assert the audio player is rendered
|
||||
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
|
||||
await expect(page.getByRole("region", { name: "Audio player" })).toBeVisible();
|
||||
|
||||
// Find and click "Reply" button on MessageActionBar
|
||||
const tile = page.locator(".mx_EventTile_last");
|
||||
@@ -237,7 +235,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
await uploadFile(page, "playwright/sample-files/1sec.ogg");
|
||||
|
||||
// Assert that the audio player is rendered
|
||||
await expect(tile.locator(".mx_AudioPlayer_container")).toBeVisible();
|
||||
await expect(tile.getByRole("region", { name: "Audio player" })).toBeVisible();
|
||||
|
||||
// Assert that replied audio file is rendered as file button inside ReplyChain
|
||||
const button = tile.locator(".mx_ReplyChain_wrapper .mx_MFileBody_info[role='button']");
|
||||
@@ -262,7 +260,9 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
await uploadFile(page, "playwright/sample-files/upload-first.ogg");
|
||||
|
||||
// Assert that the audio player is rendered
|
||||
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
|
||||
await expect(
|
||||
page.locator(".mx_EventTile_last").getByRole("region", { name: "Audio player" }),
|
||||
).toBeVisible();
|
||||
|
||||
await clickButtonReply(tile);
|
||||
|
||||
@@ -270,7 +270,9 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
await uploadFile(page, "playwright/sample-files/upload-second.ogg");
|
||||
|
||||
// Assert that the audio player is rendered
|
||||
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
|
||||
await expect(
|
||||
page.locator(".mx_EventTile_last").getByRole("region", { name: "Audio player" }),
|
||||
).toBeVisible();
|
||||
|
||||
await clickButtonReply(tile);
|
||||
|
||||
@@ -278,7 +280,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
await uploadFile(page, "playwright/sample-files/upload-third.ogg");
|
||||
|
||||
// Assert that the audio player is rendered
|
||||
await expect(tile.locator(".mx_AudioPlayer_container")).toBeVisible();
|
||||
await expect(tile.getByRole("region", { name: "Audio player" })).toBeVisible();
|
||||
|
||||
// Assert that there are two "mx_ReplyChain" elements
|
||||
await expect(tile.locator(".mx_ReplyChain")).toHaveCount(2);
|
||||
@@ -314,7 +316,9 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
// On the main timeline
|
||||
const messageList = page.locator(".mx_RoomView_MessageList");
|
||||
// Assert the audio player is rendered
|
||||
await expect(messageList.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
|
||||
await expect(
|
||||
messageList.locator(".mx_EventTile_last").getByRole("region", { name: "Audio player" }),
|
||||
).toBeVisible();
|
||||
// Find and click "Reply in thread" button
|
||||
await messageList.locator(".mx_EventTile_last").hover();
|
||||
await messageList.locator(".mx_EventTile_last").getByRole("button", { name: "Reply in thread" }).click();
|
||||
@@ -322,10 +326,10 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
// On a thread
|
||||
const thread = page.locator(".mx_ThreadView");
|
||||
const threadTile = thread.locator(".mx_EventTile_last");
|
||||
const audioPlayer = threadTile.locator(".mx_AudioPlayer_container");
|
||||
const audioPlayer = threadTile.getByRole("region", { name: "Audio player" });
|
||||
|
||||
// Assert that the counter is zero before clicking the play button
|
||||
await expect(audioPlayer.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
|
||||
await expect(audioPlayer.getByRole("timer")).toHaveText("00:00");
|
||||
|
||||
// Find and click "Play" button, the wait is to make the test less flaky
|
||||
await expect(audioPlayer.getByRole("button", { name: "Play" })).toBeVisible();
|
||||
@@ -335,7 +339,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
await expect(audioPlayer.getByRole("button", { name: "Pause" })).toBeVisible();
|
||||
|
||||
// Assert that the timer is reset when the audio file finished playing
|
||||
await expect(audioPlayer.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
|
||||
await expect(audioPlayer.getByRole("timer")).toHaveText("00:00");
|
||||
|
||||
// Assert that "Play" button can be found
|
||||
await expect(audioPlayer.getByRole("button", { name: "Play" })).not.toBeDisabled();
|
||||
|
||||
@@ -63,9 +63,7 @@ test.describe("FilePanel", () => {
|
||||
await expect(roomViewBody.locator(".mx_EventTile[data-layout='group'] img[alt='riot.png']")).toBeVisible();
|
||||
|
||||
// Assert that the audio player is rendered
|
||||
await expect(
|
||||
roomViewBody.locator(".mx_EventTile[data-layout='group'] .mx_AudioPlayer_container"),
|
||||
).toBeVisible();
|
||||
await expect(roomViewBody.getByRole("region", { name: "Audio player" })).toBeVisible();
|
||||
|
||||
// Assert that the file button exists
|
||||
await expect(
|
||||
@@ -97,9 +95,7 @@ test.describe("FilePanel", () => {
|
||||
await expect(image.locator("img[alt='riot.png']")).toBeVisible();
|
||||
|
||||
// Detect the audio file
|
||||
const audio = filePanelMessageList.locator(
|
||||
".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container",
|
||||
);
|
||||
const audio = filePanelMessageList.getByRole("region", { name: "Audio player" });
|
||||
// Assert that the play button is rendered
|
||||
await expect(audio.getByRole("button", { name: "Play" })).toBeVisible();
|
||||
|
||||
@@ -130,7 +126,7 @@ test.describe("FilePanel", () => {
|
||||
// Take a snapshot of file tiles list on FilePanel
|
||||
await expect(filePanelMessageList).toMatchScreenshot("file-tiles-list.png", {
|
||||
// Exclude timestamps & flaky seek bar from snapshot
|
||||
mask: [page.locator(".mx_MessageTimestamp, .mx_AudioPlayer_seek")],
|
||||
mask: [page.locator(".mx_MessageTimestamp"), page.getByTestId("audio-player-seek")],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,21 +134,19 @@ test.describe("FilePanel", () => {
|
||||
// Upload an image file
|
||||
await uploadFile(page, "playwright/sample-files/1sec.ogg");
|
||||
|
||||
const audioBody = page.locator(
|
||||
".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container",
|
||||
);
|
||||
const audioBody = page.getByTestId("right-panel").getByRole("region", { name: "Audio player" });
|
||||
|
||||
// Assert that the audio player is rendered
|
||||
// Assert that the audio file information is rendered
|
||||
const mediaInfo = audioBody.locator(".mx_AudioPlayer_mediaInfo");
|
||||
await expect(mediaInfo.locator(".mx_AudioPlayer_mediaName").getByText("1sec.ogg")).toBeVisible();
|
||||
await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "00:01" })).toBeVisible();
|
||||
await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "(3.56 KB)" })).toBeVisible(); // actual size
|
||||
// Assert that the audio file information is rendered;
|
||||
await expect(audioBody.getByText("1sec.ogg")).toBeVisible(); // extension
|
||||
await expect(audioBody.getByRole("time")).toHaveText("00:01"); // duration
|
||||
await expect(audioBody.getByText("(3.56 KB)")).toBeVisible(); // actual size;
|
||||
|
||||
// Assert that the duration counter is 00:01 before clicking the play button
|
||||
await expect(audioBody.locator(".mx_AudioPlayer_mediaInfo time", { hasText: "00:01" })).toBeVisible();
|
||||
await expect(audioBody.getByRole("time")).toHaveText("00:01");
|
||||
|
||||
// Assert that the counter is zero before clicking the play button
|
||||
await expect(audioBody.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
|
||||
await expect(audioBody.getByRole("timer")).toHaveText("00:00");
|
||||
|
||||
// Click the play button
|
||||
await audioBody.getByRole("button", { name: "Play" }).click();
|
||||
@@ -161,7 +155,7 @@ test.describe("FilePanel", () => {
|
||||
await expect(audioBody.getByRole("button", { name: "Pause" })).toBeVisible();
|
||||
|
||||
// Assert that the timer is reset when the audio file finished playing
|
||||
await expect(audioBody.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
|
||||
await expect(audioBody.getByRole("timer")).toHaveText("00:00");
|
||||
|
||||
// Assert that the play button is rendered
|
||||
await expect(audioBody.getByRole("button", { name: "Play" })).toBeVisible();
|
||||
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 33 KiB |
@@ -95,7 +95,6 @@
|
||||
@import "./structures/auth/_Registration.pcss";
|
||||
@import "./structures/auth/_SessionLockStolenView.pcss";
|
||||
@import "./structures/auth/_SetupEncryptionBody.pcss";
|
||||
@import "./views/audio_messages/_AudioPlayer.pcss";
|
||||
@import "./views/audio_messages/_PlayPauseButton.pcss";
|
||||
@import "./views/audio_messages/_PlaybackContainer.pcss";
|
||||
@import "./views/audio_messages/_SeekBar.pcss";
|
||||
|
||||
@@ -1,59 +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.
|
||||
*/
|
||||
|
||||
.mx_MediaBody.mx_AudioPlayer_container {
|
||||
padding: 16px 12px 12px 12px;
|
||||
|
||||
.mx_AudioPlayer_primaryContainer {
|
||||
display: flex;
|
||||
|
||||
.mx_PlayPauseButton {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.mx_AudioPlayer_mediaInfo {
|
||||
flex: 1;
|
||||
overflow: hidden; /* makes the ellipsis on the file name work */
|
||||
|
||||
& > * {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mx_AudioPlayer_mediaName {
|
||||
color: $primary-content;
|
||||
font-size: $font-15px;
|
||||
line-height: $font-15px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
padding-bottom: 4px; /* mimics the line-height differences in the Figma */
|
||||
}
|
||||
|
||||
.mx_AudioPlayer_byline {
|
||||
font-size: $font-12px;
|
||||
line-height: $font-12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_AudioPlayer_seek {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.mx_SeekBar {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mx_Clock {
|
||||
min-width: $font-42px; /* for flexbox */
|
||||
padding-left: $spacing-4; /* isolate from seek bar */
|
||||
text-align: justify;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021-2023 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, { type ReactNode } from "react";
|
||||
|
||||
import PlayPauseButton from "./PlayPauseButton";
|
||||
import { formatBytes } from "../../../utils/FormattingUtils";
|
||||
import DurationClock from "./DurationClock";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SeekBar from "./SeekBar";
|
||||
import PlaybackClock from "./PlaybackClock";
|
||||
import AudioPlayerBase from "./AudioPlayerBase";
|
||||
import { PlaybackState } from "../../../audio/Playback";
|
||||
|
||||
export default class AudioPlayer extends AudioPlayerBase {
|
||||
protected renderFileSize(): string | null {
|
||||
const bytes = this.props.playback.sizeBytes;
|
||||
if (!bytes) return null;
|
||||
|
||||
// Not translated here - we're just presenting the data which should already
|
||||
// be translated if needed.
|
||||
return `(${formatBytes(bytes)})`;
|
||||
}
|
||||
|
||||
protected renderComponent(): ReactNode {
|
||||
// tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
|
||||
// events for accessibility
|
||||
return (
|
||||
<div className="mx_MediaBody mx_AudioPlayer_container" tabIndex={0} onKeyDown={this.onKeyDown}>
|
||||
<div className="mx_AudioPlayer_primaryContainer">
|
||||
<PlayPauseButton
|
||||
playback={this.props.playback}
|
||||
playbackPhase={this.state.playbackPhase}
|
||||
tabIndex={-1} // prevent tabbing into the button
|
||||
ref={this.playPauseRef}
|
||||
/>
|
||||
<div className="mx_AudioPlayer_mediaInfo">
|
||||
<span className="mx_AudioPlayer_mediaName">
|
||||
{this.props.mediaName || _t("timeline|m.audio|unnamed_audio")}
|
||||
</span>
|
||||
<div className="mx_AudioPlayer_byline">
|
||||
<DurationClock playback={this.props.playback} />
|
||||
{/* easiest way to introduce a gap between the components */}
|
||||
{this.renderFileSize()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_AudioPlayer_seek">
|
||||
<SeekBar
|
||||
playback={this.props.playback}
|
||||
tabIndex={-1} // prevent tabbing into the bar
|
||||
disabled={this.state.playbackPhase === PlaybackState.Decoding}
|
||||
ref={this.seekRef}
|
||||
/>
|
||||
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<T extends IProps = IProps> extends React.PureComponent<T, IState> {
|
||||
protected seekRef = createRef<SeekBar>();
|
||||
protected seekRef = createRef<LegacySeekBar>();
|
||||
protected playPauseRef = createRef<PlayPauseButton>();
|
||||
|
||||
public constructor(props: T) {
|
||||
|
||||
@@ -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<IProps, IState> {
|
||||
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 <Clock seconds={this.state.durationSeconds} />;
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,10 @@ interface ISeekCSS extends CSSProperties {
|
||||
|
||||
const ARROW_SKIP_SECONDS = 5; // arbitrary
|
||||
|
||||
export default class SeekBar extends React.PureComponent<IProps, IState> {
|
||||
/**
|
||||
* @deprecated Use {@link SeekBar} instead.
|
||||
*/
|
||||
export default class LegacySeekBar extends React.PureComponent<IProps, IState> {
|
||||
// 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.
|
||||
|
||||
@@ -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<IProps> {
|
||||
<>
|
||||
<div className="mx_RecordingPlayback_timelineLayoutMiddle">
|
||||
<PlaybackWaveform playback={this.props.playback} />
|
||||
<SeekBar
|
||||
<LegacySeekBar
|
||||
playback={this.props.playback}
|
||||
tabIndex={0} // allow keyboard users to fall into the seek bar
|
||||
disabled={this.state.playbackPhase === PlaybackState.Decoding}
|
||||
|
||||
@@ -14,7 +14,6 @@ import { type MediaEventContent } from "matrix-js-sdk/src/types";
|
||||
import { type Playback } from "../../../audio/Playback";
|
||||
import InlineSpinner from "../elements/InlineSpinner";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AudioPlayer from "../audio_messages/AudioPlayer";
|
||||
import MFileBody from "./MFileBody";
|
||||
import { type IBodyProps } from "./IBodyProps";
|
||||
import { PlaybackManager } from "../../../audio/PlaybackManager";
|
||||
@@ -22,10 +21,13 @@ import { isVoiceMessage } from "../../../utils/EventUtils";
|
||||
import { PlaybackQueue } from "../../../audio/PlaybackQueue";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import MediaProcessingError from "./shared/MediaProcessingError";
|
||||
import { AudioPlayerViewModel } from "../../../viewmodels/audio/AudioPlayerViewModel";
|
||||
import { AudioPlayerView } from "../../../shared-components/audio/AudioPlayerView";
|
||||
|
||||
interface IState {
|
||||
error?: boolean;
|
||||
playback?: Playback;
|
||||
audioPlayerVm?: AudioPlayerViewModel;
|
||||
}
|
||||
|
||||
export default class MAudioBody extends React.PureComponent<IBodyProps, IState> {
|
||||
@@ -61,7 +63,7 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
|
||||
// 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<IBodyProps, IState>
|
||||
// At this point we should have a playable state
|
||||
return (
|
||||
<span className="mx_MAudioBody">
|
||||
<AudioPlayer playback={this.state.playback} mediaName={this.props.mxEvent.getContent().body} />
|
||||
{this.state.audioPlayerVm && <AudioPlayerView vm={this.state.audioPlayerVm} />}
|
||||
{this.showFileBody && <MFileBody {...this.props} showGenericPlaceholder={false} />}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
52
src/shared-components/ViewWrapper.tsx
Normal file
@@ -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<V> {
|
||||
/**
|
||||
* 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<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* <ViewWrapper<SnapshotType, ViewModelType> props={Snapshot&Actions} Component={MyComponent} />
|
||||
* ```
|
||||
*/
|
||||
export function ViewWrapper<T, V extends ViewModel<T>>({
|
||||
props,
|
||||
Component,
|
||||
}: Readonly<ViewWrapperProps<V>>): 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<T>(snapshot);
|
||||
Object.assign(vm, actions);
|
||||
|
||||
return vm as unknown as V;
|
||||
}, [props]);
|
||||
|
||||
return <Component vm={vm} />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 => (
|
||||
<ViewWrapper<AudioPlayerViewSnapshot, AudioPlayerViewModel> 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<typeof AudioPlayerViewWrapper>;
|
||||
|
||||
const Template: StoryFn<typeof AudioPlayerViewWrapper> = (args) => <AudioPlayerViewWrapper {...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,
|
||||
};
|
||||
@@ -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(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the audio player without media name", () => {
|
||||
const { container } = render(<NoMediaName />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the audio player without size", () => {
|
||||
const { container } = render(<NoSize />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the audio player in error state", () => {
|
||||
const { container } = render(<HasError />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
const onKeyDown = jest.fn();
|
||||
const togglePlay = jest.fn();
|
||||
const onSeekbarChange = jest.fn();
|
||||
|
||||
class AudioPlayerViewModel extends MockViewModel<AudioPlayerViewSnapshot> 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(<AudioPlayerView vm={vm} />);
|
||||
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" }));
|
||||
});
|
||||
});
|
||||
143
src/shared-components/audio/AudioPlayerView/AudioPlayerView.tsx
Normal file
@@ -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<HTMLDivElement>;
|
||||
/**
|
||||
* Toggles the play/pause state of the audio player.
|
||||
*/
|
||||
togglePlay: MouseEventHandler<HTMLButtonElement>;
|
||||
/**
|
||||
* Handles changes to the seek bar.
|
||||
*/
|
||||
onSeekbarChange: ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model for the audio player.
|
||||
*/
|
||||
export type AudioPlayerViewModel = ViewModel<AudioPlayerViewSnapshot> & 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
|
||||
* <AudioPlayerView vm={audioPlayerViewModel} />
|
||||
* ```
|
||||
*/
|
||||
export function AudioPlayerView({ vm }: Readonly<AudioPlayerViewProps>): 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 (
|
||||
<>
|
||||
<MediaBody
|
||||
className={styles.audioPlayer}
|
||||
tabIndex={0}
|
||||
onKeyDown={vm.onKeyDown}
|
||||
aria-label={_t("timeline|m.audio|audio_player")}
|
||||
role="region"
|
||||
>
|
||||
<Flex gap="var(--cpd-space-2x)" align="center">
|
||||
<PlayPauseButton
|
||||
// Prevent tabbing into the button
|
||||
// Keyboard navigation is handled at the MediaBody level
|
||||
tabIndex={-1}
|
||||
disabled={disabled}
|
||||
playing={playbackState === "playing"}
|
||||
togglePlay={vm.togglePlay}
|
||||
/>
|
||||
<Flex direction="column" className={styles.mediaInfo}>
|
||||
<span className={styles.mediaName} data-testid="audio-player-name">
|
||||
{mediaName}
|
||||
</span>
|
||||
<Flex className={styles.byline} gap="var(--cpd-space-1-5x)">
|
||||
<Clock seconds={durationSeconds} />
|
||||
{fileSize}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex align="center" gap="var(--cpd-space-1x)" data-testid="audio-player-seek">
|
||||
<SeekBar tabIndex={-1} disabled={disabled} value={percentComplete} onChange={vm.onSeekbarChange} />
|
||||
<Clock className={styles.clock} seconds={playedSeconds} role="timer" />
|
||||
</Flex>
|
||||
</MediaBody>
|
||||
{error && <span className={styles.error}>{_t("timeline|m.audio|error_downloading_audio")}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AudioPlayerView renders the audio player in default state 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="Audio player"
|
||||
class="mx_MediaBody mediaBody audioPlayer"
|
||||
role="region"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-label="Play"
|
||||
aria-labelledby="«r0»"
|
||||
class="_icon-button_1pz9o_8 button"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_zr2a0_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="flex mediaInfo"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mediaName"
|
||||
data-testid="audio-player-name"
|
||||
>
|
||||
Sample Audio
|
||||
</span>
|
||||
<div
|
||||
class="flex byline"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<time
|
||||
class="mx_Clock"
|
||||
datetime="PT5M"
|
||||
>
|
||||
05:00
|
||||
</time>
|
||||
(3.42 KB)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="audio-player-seek"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<input
|
||||
aria-label="Audio seek bar"
|
||||
class="seekBar"
|
||||
max="100"
|
||||
min="0"
|
||||
step="1"
|
||||
style="--fillTo: 0.3;"
|
||||
tabindex="-1"
|
||||
type="range"
|
||||
value="30"
|
||||
/>
|
||||
<time
|
||||
class="mx_Clock clock"
|
||||
datetime="PT2M"
|
||||
role="timer"
|
||||
>
|
||||
02:00
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AudioPlayerView renders the audio player in error state 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="Audio player"
|
||||
class="mx_MediaBody mediaBody audioPlayer"
|
||||
role="region"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-label="Play"
|
||||
aria-labelledby="«ri»"
|
||||
class="_icon-button_1pz9o_8 button"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_zr2a0_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="flex mediaInfo"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mediaName"
|
||||
data-testid="audio-player-name"
|
||||
>
|
||||
Sample Audio
|
||||
</span>
|
||||
<div
|
||||
class="flex byline"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<time
|
||||
class="mx_Clock"
|
||||
datetime="PT5M"
|
||||
>
|
||||
05:00
|
||||
</time>
|
||||
(3.42 KB)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="audio-player-seek"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<input
|
||||
aria-label="Audio seek bar"
|
||||
class="seekBar"
|
||||
max="100"
|
||||
min="0"
|
||||
step="1"
|
||||
style="--fillTo: 0.3;"
|
||||
tabindex="-1"
|
||||
type="range"
|
||||
value="30"
|
||||
/>
|
||||
<time
|
||||
class="mx_Clock clock"
|
||||
datetime="PT2M"
|
||||
role="timer"
|
||||
>
|
||||
02:00
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="error"
|
||||
>
|
||||
Error downloading audio
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AudioPlayerView renders the audio player without media name 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="Audio player"
|
||||
class="mx_MediaBody mediaBody audioPlayer"
|
||||
role="region"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-label="Play"
|
||||
aria-labelledby="«r6»"
|
||||
class="_icon-button_1pz9o_8 button"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_zr2a0_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="flex mediaInfo"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mediaName"
|
||||
data-testid="audio-player-name"
|
||||
>
|
||||
Unnamed audio
|
||||
</span>
|
||||
<div
|
||||
class="flex byline"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<time
|
||||
class="mx_Clock"
|
||||
datetime="PT5M"
|
||||
>
|
||||
05:00
|
||||
</time>
|
||||
(3.42 KB)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="audio-player-seek"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<input
|
||||
aria-label="Audio seek bar"
|
||||
class="seekBar"
|
||||
max="100"
|
||||
min="0"
|
||||
step="1"
|
||||
style="--fillTo: 0.3;"
|
||||
tabindex="-1"
|
||||
type="range"
|
||||
value="30"
|
||||
/>
|
||||
<time
|
||||
class="mx_Clock clock"
|
||||
datetime="PT2M"
|
||||
role="timer"
|
||||
>
|
||||
02:00
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AudioPlayerView renders the audio player without size 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="Audio player"
|
||||
class="mx_MediaBody mediaBody audioPlayer"
|
||||
role="region"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-label="Play"
|
||||
aria-labelledby="«rc»"
|
||||
class="_icon-button_1pz9o_8 button"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_zr2a0_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="flex mediaInfo"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mediaName"
|
||||
data-testid="audio-player-name"
|
||||
>
|
||||
Sample Audio
|
||||
</span>
|
||||
<div
|
||||
class="flex byline"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<time
|
||||
class="mx_Clock"
|
||||
datetime="PT5M"
|
||||
>
|
||||
05:00
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="audio-player-seek"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<input
|
||||
aria-label="Audio seek bar"
|
||||
class="seekBar"
|
||||
max="100"
|
||||
min="0"
|
||||
step="1"
|
||||
style="--fillTo: 0.3;"
|
||||
tabindex="-1"
|
||||
type="range"
|
||||
value="30"
|
||||
/>
|
||||
<time
|
||||
class="mx_Clock clock"
|
||||
datetime="PT2M"
|
||||
role="timer"
|
||||
>
|
||||
02:00
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
9
src/shared-components/audio/AudioPlayerView/index.ts
Normal file
@@ -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";
|
||||
@@ -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<HTMLProps<HTMLSpanElement>, "aria-live" | "role"> {
|
||||
export interface Props extends Pick<HTMLProps<HTMLSpanElement>, "aria-live" | "role" | "className"> {
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
@@ -41,7 +42,7 @@ export class Clock extends React.Component<Props> {
|
||||
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)}
|
||||
</time>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<typeof PlayPauseButton>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const Playing: Story = { args: { playing: true } };
|
||||
@@ -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(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the button in playing state", () => {
|
||||
const { container } = render(<Playing />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("calls togglePlay when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const togglePlay = fn();
|
||||
|
||||
const { getByRole } = render(<Default togglePlay={togglePlay} />);
|
||||
await user.click(getByRole("button"));
|
||||
expect(togglePlay).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLButtonElement> {
|
||||
/**
|
||||
* 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<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A button component that toggles between play and pause states for audio playback.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PlayPauseButton playing={true} togglePlay={() => {}} />
|
||||
* ```
|
||||
*/
|
||||
export function PlayPauseButton({
|
||||
disabled = false,
|
||||
playing = false,
|
||||
togglePlay,
|
||||
...rest
|
||||
}: Readonly<PlayPauseButtonProps>): JSX.Element {
|
||||
const label = playing ? _t("action|pause") : _t("action|play");
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
size="32px"
|
||||
aria-label={label}
|
||||
tooltip={label}
|
||||
onClick={togglePlay}
|
||||
className={styles.button}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
>
|
||||
{playing ? <Pause /> : <Play />}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PlayPauseButton renders the button in default state 1`] = `
|
||||
<div>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-label="Play"
|
||||
aria-labelledby="«r0»"
|
||||
class="_icon-button_1pz9o_8 button"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_zr2a0_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PlayPauseButton renders the button in playing state 1`] = `
|
||||
<div>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-label="Pause"
|
||||
aria-labelledby="«r6»"
|
||||
class="_icon-button_1pz9o_8 button"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_zr2a0_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 4a2 2 0 0 0-2 2v12a2 2 0 1 0 4 0V6a2 2 0 0 0-2-2m8 0a2 2 0 0 0-2 2v12a2 2 0 1 0 4 0V6a2 2 0 0 0-2-2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
8
src/shared-components/audio/PlayPauseButton/index.ts
Normal file
@@ -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";
|
||||
99
src/shared-components/audio/SeekBar/SeekBar.module.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
38
src/shared-components/audio/SeekBar/SeekBar.stories.tsx
Normal file
@@ -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<typeof SeekBar>;
|
||||
|
||||
const Template: StoryFn<typeof SeekBar> = (args) => {
|
||||
const [, updateArgs] = useArgs();
|
||||
return <SeekBar onChange={(evt) => updateArgs({ value: parseInt(evt.target.value, 10) })} {...args} />;
|
||||
};
|
||||
|
||||
export const Default = Template.bind({});
|
||||
|
||||
export const Disabled = Template.bind({});
|
||||
Disabled.args = {
|
||||
disabled: true,
|
||||
};
|
||||
20
src/shared-components/audio/SeekBar/SeekBar.test.tsx
Normal file
@@ -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(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
58
src/shared-components/audio/SeekBar/SeekBar.tsx
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 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<HTMLInputElement> {
|
||||
/**
|
||||
* 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
|
||||
* <SeekBar value={50} onChange={(e) => console.log("New value", e.target.value)} />
|
||||
* ```
|
||||
*/
|
||||
export function SeekBar({ value = 0, className, ...rest }: Readonly<SeekBarProps>): 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 (
|
||||
<input
|
||||
type="range"
|
||||
className={classNames(style.seekBar, className)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
min={0}
|
||||
max={100}
|
||||
value={newValue}
|
||||
step={1}
|
||||
style={{ "--fillTo": newValue / 100 } as ISeekCSS}
|
||||
aria-label={_t("a11y|seek_bar_label")}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Seekbar renders the clock 1`] = `
|
||||
<div>
|
||||
<input
|
||||
aria-label="Audio seek bar"
|
||||
class="seekBar"
|
||||
max="100"
|
||||
min="0"
|
||||
step="1"
|
||||
style="--fillTo: 0.5;"
|
||||
type="range"
|
||||
value="50"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
8
src/shared-components/audio/SeekBar/index.ts
Normal file
@@ -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";
|
||||
15
src/shared-components/audio/playback.ts
Normal file
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<typeof MediaBody>;
|
||||
|
||||
const Template: StoryFn<typeof MediaBody> = (args) => <MediaBody {...args} />;
|
||||
|
||||
export const Default = Template.bind({});
|
||||
@@ -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(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
48
src/shared-components/message-body/MediaBody/MediaBody.tsx
Normal 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 { 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<C extends ElementType> = {
|
||||
/**
|
||||
* The HTML tag.
|
||||
* @default "div"
|
||||
*/
|
||||
as?: C;
|
||||
/**
|
||||
* The CSS class name.
|
||||
*/
|
||||
className?: string;
|
||||
} & ComponentProps<C>;
|
||||
|
||||
/**
|
||||
* A component to display the body of a media message.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <MediaBody as="p" className="custom-class">Media body content</MediaBody>
|
||||
* ```
|
||||
*/
|
||||
export function MediaBody<C extends React.ElementType = "div">({
|
||||
as,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PropsWithChildren<MediaBodyProps<C>>): JSX.Element {
|
||||
const Component = as || "div";
|
||||
|
||||
// Keep Mx_MediaBody to support the compatibility with existing timeline and the all the layout
|
||||
return (
|
||||
<Component className={classNames("mx_MediaBody", styles.mediaBody, className)} {...props}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`MediaBody renders the media body 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="mx_MediaBody mediaBody"
|
||||
>
|
||||
Media content goes here
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
8
src/shared-components/message-body/MediaBody/index.tsx
Normal file
@@ -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";
|
||||
22
src/shared-components/utils/FormattingUtils.ts
Normal file
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
150
src/viewmodels/audio/AudioPlayerViewModel.ts
Normal file
@@ -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<AudioPlayerViewSnapshot, Props>
|
||||
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<void> {
|
||||
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<HTMLDivElement>): 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<void> => {
|
||||
await this.props.playback.toggle();
|
||||
};
|
||||
|
||||
public onSeekbarChange = async (ev: ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
await this.props.playback.skipTo((Number(ev.target.value) / 100) * this.props.playback.durationSeconds);
|
||||
};
|
||||
}
|
||||
@@ -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<SeekBar | null>;
|
||||
let seekBarRef: RefObject<LegacySeekBar | null>;
|
||||
|
||||
beforeEach(() => {
|
||||
seekBarRef = createRef();
|
||||
@@ -38,7 +38,7 @@ describe("SeekBar", () => {
|
||||
durationSeconds: 0,
|
||||
timeSeconds: 0,
|
||||
});
|
||||
renderResult = render(<SeekBar ref={seekBarRef} playback={playback} />);
|
||||
renderResult = render(<LegacySeekBar ref={seekBarRef} playback={playback} />);
|
||||
});
|
||||
|
||||
it("should render correctly", () => {
|
||||
@@ -49,7 +49,7 @@ describe("SeekBar", () => {
|
||||
describe("when rendering a SeekBar", () => {
|
||||
beforeEach(() => {
|
||||
playback = createTestPlayback();
|
||||
renderResult = render(<SeekBar ref={seekBarRef} playback={playback} />);
|
||||
renderResult = render(<LegacySeekBar ref={seekBarRef} playback={playback} />);
|
||||
});
|
||||
|
||||
it("should render the initial position", () => {
|
||||
@@ -115,7 +115,7 @@ describe("SeekBar", () => {
|
||||
|
||||
describe("when rendering a disabled SeekBar", () => {
|
||||
beforeEach(async () => {
|
||||
renderResult = render(<SeekBar disabled={true} playback={playback} />);
|
||||
renderResult = render(<LegacySeekBar disabled={true} playback={playback} />);
|
||||
});
|
||||
|
||||
it("should render as expected", () => {
|
||||
103
test/viewmodels/audio/AudioPlayerViewModel-test.tsx
Normal file
@@ -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<HTMLInputElement>);
|
||||
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<HTMLDivElement>;
|
||||
vm.onKeyDown(event);
|
||||
expect(playback.toggle).toHaveBeenCalled();
|
||||
|
||||
event = new KeyboardEvent("keydown", { key: "ArrowLeft" }) as unknown as ReactKeyboardEvent<HTMLDivElement>;
|
||||
vm.onKeyDown(event);
|
||||
expect(playback.skipTo).toHaveBeenCalledWith(10 - 5); // 5 seconds back
|
||||
|
||||
event = new KeyboardEvent("keydown", { key: "ArrowRight" }) as unknown as ReactKeyboardEvent<HTMLDivElement>;
|
||||
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();
|
||||
}
|
||||