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
|
// wait for the tile to finish loading
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.locator(".mx_AudioPlayer_mediaName")
|
.getByTestId("audio-player-name")
|
||||||
.last()
|
.last()
|
||||||
.filter({ hasText: file.split("/").at(-1) }),
|
.filter({ hasText: file.split("/").at(-1) }),
|
||||||
).toBeVisible();
|
).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
|
// Check that the audio player is rendered and its button becomes visible
|
||||||
const checkPlayerVisibility = async (locator: Locator) => {
|
const checkPlayerVisibility = async (locator: Locator) => {
|
||||||
// Assert that the audio player and media information are visible
|
// Assert that the audio player and media information are visible
|
||||||
const mediaInfo = locator.locator(
|
const mediaInfo = locator.getByRole("region", { name: "Audio player" });
|
||||||
".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container .mx_AudioPlayer_mediaInfo",
|
await expect(mediaInfo.getByText(".ogg")).toBeVisible(); // extension
|
||||||
);
|
await expect(mediaInfo.getByRole("time")).toHaveText("00:01"); // duration
|
||||||
await expect(mediaInfo.locator(".mx_AudioPlayer_mediaName", { hasText: ".ogg" })).toBeVisible(); // extension
|
await expect(mediaInfo.getByText("(3.56 KB)")).toBeVisible(); // actual size;
|
||||||
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 play button can be found and is visible
|
// Assert that the play button can be found and is visible
|
||||||
await expect(locator.getByRole("button", { name: "Play" })).toBeVisible();
|
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
|
// 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
|
// Enable IRC layout
|
||||||
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
|
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;
|
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
|
// 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");
|
await uploadFile(page, "playwright/sample-files/1sec.ogg");
|
||||||
|
|
||||||
// Assert that the audio player is rendered
|
// 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
|
// 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
|
// Find and click "Play" button, the wait is to make the test less flaky
|
||||||
await expect(container.getByRole("button", { name: "Play" })).toBeVisible();
|
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();
|
await expect(container.getByRole("button", { name: "Pause" })).toBeVisible();
|
||||||
|
|
||||||
// Assert that the timer is reset when the audio file finished playing
|
// 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
|
// Assert that "Play" button can be found
|
||||||
await expect(container.getByRole("button", { name: "Play" })).toBeVisible();
|
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");
|
await uploadFile(page, "playwright/sample-files/1sec.ogg");
|
||||||
|
|
||||||
// Assert the audio player is rendered
|
// 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
|
// Find and click "Reply" button on MessageActionBar
|
||||||
const tile = page.locator(".mx_EventTile_last");
|
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");
|
await uploadFile(page, "playwright/sample-files/1sec.ogg");
|
||||||
|
|
||||||
// Assert that the audio player is rendered
|
// 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
|
// Assert that replied audio file is rendered as file button inside ReplyChain
|
||||||
const button = tile.locator(".mx_ReplyChain_wrapper .mx_MFileBody_info[role='button']");
|
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");
|
await uploadFile(page, "playwright/sample-files/upload-first.ogg");
|
||||||
|
|
||||||
// Assert that the audio player is rendered
|
// 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);
|
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");
|
await uploadFile(page, "playwright/sample-files/upload-second.ogg");
|
||||||
|
|
||||||
// Assert that the audio player is rendered
|
// 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);
|
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");
|
await uploadFile(page, "playwright/sample-files/upload-third.ogg");
|
||||||
|
|
||||||
// Assert that the audio player is rendered
|
// 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
|
// Assert that there are two "mx_ReplyChain" elements
|
||||||
await expect(tile.locator(".mx_ReplyChain")).toHaveCount(2);
|
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
|
// On the main timeline
|
||||||
const messageList = page.locator(".mx_RoomView_MessageList");
|
const messageList = page.locator(".mx_RoomView_MessageList");
|
||||||
// Assert the audio player is rendered
|
// 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
|
// Find and click "Reply in thread" button
|
||||||
await messageList.locator(".mx_EventTile_last").hover();
|
await messageList.locator(".mx_EventTile_last").hover();
|
||||||
await messageList.locator(".mx_EventTile_last").getByRole("button", { name: "Reply in thread" }).click();
|
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
|
// On a thread
|
||||||
const thread = page.locator(".mx_ThreadView");
|
const thread = page.locator(".mx_ThreadView");
|
||||||
const threadTile = thread.locator(".mx_EventTile_last");
|
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
|
// 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
|
// Find and click "Play" button, the wait is to make the test less flaky
|
||||||
await expect(audioPlayer.getByRole("button", { name: "Play" })).toBeVisible();
|
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();
|
await expect(audioPlayer.getByRole("button", { name: "Pause" })).toBeVisible();
|
||||||
|
|
||||||
// Assert that the timer is reset when the audio file finished playing
|
// 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
|
// Assert that "Play" button can be found
|
||||||
await expect(audioPlayer.getByRole("button", { name: "Play" })).not.toBeDisabled();
|
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();
|
await expect(roomViewBody.locator(".mx_EventTile[data-layout='group'] img[alt='riot.png']")).toBeVisible();
|
||||||
|
|
||||||
// Assert that the audio player is rendered
|
// Assert that the audio player is rendered
|
||||||
await expect(
|
await expect(roomViewBody.getByRole("region", { name: "Audio player" })).toBeVisible();
|
||||||
roomViewBody.locator(".mx_EventTile[data-layout='group'] .mx_AudioPlayer_container"),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Assert that the file button exists
|
// Assert that the file button exists
|
||||||
await expect(
|
await expect(
|
||||||
@@ -97,9 +95,7 @@ test.describe("FilePanel", () => {
|
|||||||
await expect(image.locator("img[alt='riot.png']")).toBeVisible();
|
await expect(image.locator("img[alt='riot.png']")).toBeVisible();
|
||||||
|
|
||||||
// Detect the audio file
|
// Detect the audio file
|
||||||
const audio = filePanelMessageList.locator(
|
const audio = filePanelMessageList.getByRole("region", { name: "Audio player" });
|
||||||
".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container",
|
|
||||||
);
|
|
||||||
// Assert that the play button is rendered
|
// Assert that the play button is rendered
|
||||||
await expect(audio.getByRole("button", { name: "Play" })).toBeVisible();
|
await expect(audio.getByRole("button", { name: "Play" })).toBeVisible();
|
||||||
|
|
||||||
@@ -130,7 +126,7 @@ test.describe("FilePanel", () => {
|
|||||||
// Take a snapshot of file tiles list on FilePanel
|
// Take a snapshot of file tiles list on FilePanel
|
||||||
await expect(filePanelMessageList).toMatchScreenshot("file-tiles-list.png", {
|
await expect(filePanelMessageList).toMatchScreenshot("file-tiles-list.png", {
|
||||||
// Exclude timestamps & flaky seek bar from snapshot
|
// 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
|
// Upload an image file
|
||||||
await uploadFile(page, "playwright/sample-files/1sec.ogg");
|
await uploadFile(page, "playwright/sample-files/1sec.ogg");
|
||||||
|
|
||||||
const audioBody = page.locator(
|
const audioBody = page.getByTestId("right-panel").getByRole("region", { name: "Audio player" });
|
||||||
".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container",
|
|
||||||
);
|
|
||||||
// Assert that the audio player is rendered
|
// Assert that the audio player is rendered
|
||||||
// Assert that the audio file information is rendered
|
// Assert that the audio file information is rendered;
|
||||||
const mediaInfo = audioBody.locator(".mx_AudioPlayer_mediaInfo");
|
await expect(audioBody.getByText("1sec.ogg")).toBeVisible(); // extension
|
||||||
await expect(mediaInfo.locator(".mx_AudioPlayer_mediaName").getByText("1sec.ogg")).toBeVisible();
|
await expect(audioBody.getByRole("time")).toHaveText("00:01"); // duration
|
||||||
await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "00:01" })).toBeVisible();
|
await expect(audioBody.getByText("(3.56 KB)")).toBeVisible(); // actual size;
|
||||||
await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "(3.56 KB)" })).toBeVisible(); // actual size
|
|
||||||
|
|
||||||
// Assert that the duration counter is 00:01 before clicking the play button
|
// 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
|
// 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
|
// Click the play button
|
||||||
await audioBody.getByRole("button", { name: "Play" }).click();
|
await audioBody.getByRole("button", { name: "Play" }).click();
|
||||||
@@ -161,7 +155,7 @@ test.describe("FilePanel", () => {
|
|||||||
await expect(audioBody.getByRole("button", { name: "Pause" })).toBeVisible();
|
await expect(audioBody.getByRole("button", { name: "Pause" })).toBeVisible();
|
||||||
|
|
||||||
// Assert that the timer is reset when the audio file finished playing
|
// 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
|
// Assert that the play button is rendered
|
||||||
await expect(audioBody.getByRole("button", { name: "Play" })).toBeVisible();
|
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/_Registration.pcss";
|
||||||
@import "./structures/auth/_SessionLockStolenView.pcss";
|
@import "./structures/auth/_SessionLockStolenView.pcss";
|
||||||
@import "./structures/auth/_SetupEncryptionBody.pcss";
|
@import "./structures/auth/_SetupEncryptionBody.pcss";
|
||||||
@import "./views/audio_messages/_AudioPlayer.pcss";
|
|
||||||
@import "./views/audio_messages/_PlayPauseButton.pcss";
|
@import "./views/audio_messages/_PlayPauseButton.pcss";
|
||||||
@import "./views/audio_messages/_PlaybackContainer.pcss";
|
@import "./views/audio_messages/_PlaybackContainer.pcss";
|
||||||
@import "./views/audio_messages/_SeekBar.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 { _t } from "../../../languageHandler";
|
||||||
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
||||||
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
||||||
import type SeekBar from "./SeekBar";
|
import type LegacySeekBar from "./LegacySeekBar";
|
||||||
import type PlayPauseButton from "./PlayPauseButton";
|
import type PlayPauseButton from "./PlayPauseButton";
|
||||||
|
|
||||||
export interface IProps {
|
export interface IProps {
|
||||||
@@ -31,7 +31,7 @@ interface IState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default abstract class AudioPlayerBase<T extends IProps = IProps> extends React.PureComponent<T, 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>();
|
protected playPauseRef = createRef<PlayPauseButton>();
|
||||||
|
|
||||||
public constructor(props: T) {
|
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
|
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
|
// 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.
|
// really using anything demanding on the CSS front.
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ import React, { type ReactNode } from "react";
|
|||||||
import PlayPauseButton from "./PlayPauseButton";
|
import PlayPauseButton from "./PlayPauseButton";
|
||||||
import PlaybackClock from "./PlaybackClock";
|
import PlaybackClock from "./PlaybackClock";
|
||||||
import AudioPlayerBase, { type IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase";
|
import AudioPlayerBase, { type IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase";
|
||||||
import SeekBar from "./SeekBar";
|
import LegacySeekBar from "./LegacySeekBar";
|
||||||
import PlaybackWaveform from "./PlaybackWaveform";
|
import PlaybackWaveform from "./PlaybackWaveform";
|
||||||
import { PlaybackState } from "../../../audio/Playback";
|
import { PlaybackState } from "../../../audio/Playback";
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export default class RecordingPlayback extends AudioPlayerBase<IProps> {
|
|||||||
<>
|
<>
|
||||||
<div className="mx_RecordingPlayback_timelineLayoutMiddle">
|
<div className="mx_RecordingPlayback_timelineLayoutMiddle">
|
||||||
<PlaybackWaveform playback={this.props.playback} />
|
<PlaybackWaveform playback={this.props.playback} />
|
||||||
<SeekBar
|
<LegacySeekBar
|
||||||
playback={this.props.playback}
|
playback={this.props.playback}
|
||||||
tabIndex={0} // allow keyboard users to fall into the seek bar
|
tabIndex={0} // allow keyboard users to fall into the seek bar
|
||||||
disabled={this.state.playbackPhase === PlaybackState.Decoding}
|
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 { type Playback } from "../../../audio/Playback";
|
||||||
import InlineSpinner from "../elements/InlineSpinner";
|
import InlineSpinner from "../elements/InlineSpinner";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import AudioPlayer from "../audio_messages/AudioPlayer";
|
|
||||||
import MFileBody from "./MFileBody";
|
import MFileBody from "./MFileBody";
|
||||||
import { type IBodyProps } from "./IBodyProps";
|
import { type IBodyProps } from "./IBodyProps";
|
||||||
import { PlaybackManager } from "../../../audio/PlaybackManager";
|
import { PlaybackManager } from "../../../audio/PlaybackManager";
|
||||||
@@ -22,10 +21,13 @@ import { isVoiceMessage } from "../../../utils/EventUtils";
|
|||||||
import { PlaybackQueue } from "../../../audio/PlaybackQueue";
|
import { PlaybackQueue } from "../../../audio/PlaybackQueue";
|
||||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||||
import MediaProcessingError from "./shared/MediaProcessingError";
|
import MediaProcessingError from "./shared/MediaProcessingError";
|
||||||
|
import { AudioPlayerViewModel } from "../../../viewmodels/audio/AudioPlayerViewModel";
|
||||||
|
import { AudioPlayerView } from "../../../shared-components/audio/AudioPlayerView";
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
playback?: Playback;
|
playback?: Playback;
|
||||||
|
audioPlayerVm?: AudioPlayerViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class MAudioBody extends React.PureComponent<IBodyProps, IState> {
|
export default class MAudioBody extends React.PureComponent<IBodyProps, IState> {
|
||||||
@@ -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
|
// We should have a buffer to work with now: let's set it up
|
||||||
const playback = PlaybackManager.instance.createPlaybackInstance(buffer, waveform);
|
const playback = PlaybackManager.instance.createPlaybackInstance(buffer, waveform);
|
||||||
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
|
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
|
||||||
this.setState({ playback });
|
this.setState({ playback, audioPlayerVm: new AudioPlayerViewModel({ playback, mediaName: content.body }) });
|
||||||
|
|
||||||
if (isVoiceMessage(this.props.mxEvent)) {
|
if (isVoiceMessage(this.props.mxEvent)) {
|
||||||
PlaybackQueue.forRoom(this.props.mxEvent.getRoomId()!).unsortedEnqueue(this.props.mxEvent, playback);
|
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
|
// At this point we should have a playable state
|
||||||
return (
|
return (
|
||||||
<span className="mx_MAudioBody">
|
<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} />}
|
{this.showFileBody && <MFileBody {...this.props} showGenericPlaceholder={false} />}
|
||||||
</span>
|
</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."
|
"unable_to_find": "Tried to load a specific point in this room's timeline, but was unable to find it."
|
||||||
},
|
},
|
||||||
"m.audio": {
|
"m.audio": {
|
||||||
|
"audio_player": "Audio player",
|
||||||
"error_downloading_audio": "Error downloading audio",
|
"error_downloading_audio": "Error downloading audio",
|
||||||
"error_processing_audio": "Error processing audio message",
|
"error_processing_audio": "Error processing audio message",
|
||||||
"error_processing_voice_message": "Error processing voice message",
|
"error_processing_voice_message": "Error processing voice message",
|
||||||
|
|||||||
@@ -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" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
@@ -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 React, { type HTMLProps } from "react";
|
||||||
import { Temporal } from "temporal-polyfill";
|
import { Temporal } from "temporal-polyfill";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { formatSeconds } from "../../utils/DateUtils";
|
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;
|
seconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export class Clock extends React.Component<Props> {
|
|||||||
aria-live={this.props["aria-live"]}
|
aria-live={this.props["aria-live"]}
|
||||||
role={role}
|
role={role}
|
||||||
/* Keep class for backward compatibility with parent component */
|
/* Keep class for backward compatibility with parent component */
|
||||||
className="mx_Clock"
|
className={classNames("mx_Clock", this.props.className)}
|
||||||
>
|
>
|
||||||
{formatSeconds(seconds)}
|
{formatSeconds(seconds)}
|
||||||
</time>
|
</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>
|
||||||
|
`;
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
@@ -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";
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
@@ -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";
|
||||||
@@ -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 { _t, getCurrentLanguage, getUserLanguage } from "../languageHandler";
|
||||||
import { jsxJoin } from "./ReactUtils";
|
import { jsxJoin } from "./ReactUtils";
|
||||||
|
|
||||||
|
export { formatBytes } from "../shared-components/utils/FormattingUtils";
|
||||||
|
|
||||||
const locale = getCurrentLanguage();
|
const locale = getCurrentLanguage();
|
||||||
|
|
||||||
// It's quite costly to instanciate `Intl.NumberFormat`, hence why we do not do
|
// 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);
|
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 {
|
export function getUserNameColorClass(userId: string): string {
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
const number = useIdColorHash(userId);
|
const number = useIdColorHash(userId);
|
||||||
|
|||||||
@@ -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 { type Playback } from "../../../../../src/audio/Playback";
|
||||||
import { createTestPlayback } from "../../../../test-utils/audio";
|
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", () => {
|
describe("SeekBar", () => {
|
||||||
let playback: Playback;
|
let playback: Playback;
|
||||||
let renderResult: RenderResult;
|
let renderResult: RenderResult;
|
||||||
let frameRequestCallback: FrameRequestCallback;
|
let frameRequestCallback: FrameRequestCallback;
|
||||||
let seekBarRef: RefObject<SeekBar | null>;
|
let seekBarRef: RefObject<LegacySeekBar | null>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
seekBarRef = createRef();
|
seekBarRef = createRef();
|
||||||
@@ -38,7 +38,7 @@ describe("SeekBar", () => {
|
|||||||
durationSeconds: 0,
|
durationSeconds: 0,
|
||||||
timeSeconds: 0,
|
timeSeconds: 0,
|
||||||
});
|
});
|
||||||
renderResult = render(<SeekBar ref={seekBarRef} playback={playback} />);
|
renderResult = render(<LegacySeekBar ref={seekBarRef} playback={playback} />);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render correctly", () => {
|
it("should render correctly", () => {
|
||||||
@@ -49,7 +49,7 @@ describe("SeekBar", () => {
|
|||||||
describe("when rendering a SeekBar", () => {
|
describe("when rendering a SeekBar", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
playback = createTestPlayback();
|
playback = createTestPlayback();
|
||||||
renderResult = render(<SeekBar ref={seekBarRef} playback={playback} />);
|
renderResult = render(<LegacySeekBar ref={seekBarRef} playback={playback} />);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render the initial position", () => {
|
it("should render the initial position", () => {
|
||||||
@@ -115,7 +115,7 @@ describe("SeekBar", () => {
|
|||||||
|
|
||||||
describe("when rendering a disabled SeekBar", () => {
|
describe("when rendering a disabled SeekBar", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
renderResult = render(<SeekBar disabled={true} playback={playback} />);
|
renderResult = render(<LegacySeekBar disabled={true} playback={playback} />);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render as expected", () => {
|
it("should render as expected", () => {
|
||||||
@@ -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();
|
||||||
|
}
|
||||||