diff --git a/playwright/e2e/audio-player/audio-player.spec.ts b/playwright/e2e/audio-player/audio-player.spec.ts index 8532362f6b..3e5e2ce079 100644 --- a/playwright/e2e/audio-player/audio-player.spec.ts +++ b/playwright/e2e/audio-player/audio-player.spec.ts @@ -40,7 +40,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // wait for the tile to finish loading await expect( page - .locator(".mx_AudioPlayer_mediaName") + .getByTestId("audio-player-name") .last() .filter({ hasText: file.split("/").at(-1) }), ).toBeVisible(); @@ -55,12 +55,10 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // Check that the audio player is rendered and its button becomes visible const checkPlayerVisibility = async (locator: Locator) => { // Assert that the audio player and media information are visible - const mediaInfo = locator.locator( - ".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container .mx_AudioPlayer_mediaInfo", - ); - await expect(mediaInfo.locator(".mx_AudioPlayer_mediaName", { hasText: ".ogg" })).toBeVisible(); // extension - await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "00:01" })).toBeVisible(); - await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "(3.56 KB)" })).toBeVisible(); // actual size + const mediaInfo = locator.getByRole("region", { name: "Audio player" }); + await expect(mediaInfo.getByText(".ogg")).toBeVisible(); // extension + await expect(mediaInfo.getByRole("time")).toHaveText("00:01"); // duration + await expect(mediaInfo.getByText("(3.56 KB)")).toBeVisible(); // actual size; // Assert that the play button can be found and is visible await expect(locator.getByRole("button", { name: "Play" })).toBeVisible(); @@ -79,7 +77,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { } // Check the status of the seek bar - expect(await page.locator(".mx_AudioPlayer_seek input[type='range']").count()).toBeGreaterThan(0); + expect(await page.getByRole("region", { name: "Audio player" }).getByRole("slider").count()).toBeGreaterThan(0); // Enable IRC layout await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC); @@ -101,7 +99,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { display: none !important; } `, - mask: [page.locator(".mx_AudioPlayer_seek")], + mask: [page.getByTestId("audio-player-seek")], }; // Take a snapshot of mx_EventTile_last on IRC layout @@ -187,9 +185,9 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await uploadFile(page, "playwright/sample-files/1sec.ogg"); // Assert that the audio player is rendered - const container = page.locator(".mx_EventTile_last .mx_AudioPlayer_container"); + const container = page.locator(".mx_EventTile_last").getByRole("region", { name: "Audio player" }); // Assert that the counter is zero before clicking the play button - await expect(container.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible(); + await expect(container.getByRole("timer")).toHaveText("00:00"); // Find and click "Play" button, the wait is to make the test less flaky await expect(container.getByRole("button", { name: "Play" })).toBeVisible(); @@ -199,7 +197,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await expect(container.getByRole("button", { name: "Pause" })).toBeVisible(); // Assert that the timer is reset when the audio file finished playing - await expect(container.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible(); + await expect(container.getByRole("timer")).toHaveText("00:00"); // Assert that "Play" button can be found await expect(container.getByRole("button", { name: "Play" })).toBeVisible(); @@ -227,7 +225,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await uploadFile(page, "playwright/sample-files/1sec.ogg"); // Assert the audio player is rendered - await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); + await expect(page.getByRole("region", { name: "Audio player" })).toBeVisible(); // Find and click "Reply" button on MessageActionBar const tile = page.locator(".mx_EventTile_last"); @@ -237,7 +235,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await uploadFile(page, "playwright/sample-files/1sec.ogg"); // Assert that the audio player is rendered - await expect(tile.locator(".mx_AudioPlayer_container")).toBeVisible(); + await expect(tile.getByRole("region", { name: "Audio player" })).toBeVisible(); // Assert that replied audio file is rendered as file button inside ReplyChain const button = tile.locator(".mx_ReplyChain_wrapper .mx_MFileBody_info[role='button']"); @@ -262,7 +260,9 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await uploadFile(page, "playwright/sample-files/upload-first.ogg"); // Assert that the audio player is rendered - await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); + await expect( + page.locator(".mx_EventTile_last").getByRole("region", { name: "Audio player" }), + ).toBeVisible(); await clickButtonReply(tile); @@ -270,7 +270,9 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await uploadFile(page, "playwright/sample-files/upload-second.ogg"); // Assert that the audio player is rendered - await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); + await expect( + page.locator(".mx_EventTile_last").getByRole("region", { name: "Audio player" }), + ).toBeVisible(); await clickButtonReply(tile); @@ -278,7 +280,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await uploadFile(page, "playwright/sample-files/upload-third.ogg"); // Assert that the audio player is rendered - await expect(tile.locator(".mx_AudioPlayer_container")).toBeVisible(); + await expect(tile.getByRole("region", { name: "Audio player" })).toBeVisible(); // Assert that there are two "mx_ReplyChain" elements await expect(tile.locator(".mx_ReplyChain")).toHaveCount(2); @@ -314,7 +316,9 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // On the main timeline const messageList = page.locator(".mx_RoomView_MessageList"); // Assert the audio player is rendered - await expect(messageList.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); + await expect( + messageList.locator(".mx_EventTile_last").getByRole("region", { name: "Audio player" }), + ).toBeVisible(); // Find and click "Reply in thread" button await messageList.locator(".mx_EventTile_last").hover(); await messageList.locator(".mx_EventTile_last").getByRole("button", { name: "Reply in thread" }).click(); @@ -322,10 +326,10 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // On a thread const thread = page.locator(".mx_ThreadView"); const threadTile = thread.locator(".mx_EventTile_last"); - const audioPlayer = threadTile.locator(".mx_AudioPlayer_container"); + const audioPlayer = threadTile.getByRole("region", { name: "Audio player" }); // Assert that the counter is zero before clicking the play button - await expect(audioPlayer.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible(); + await expect(audioPlayer.getByRole("timer")).toHaveText("00:00"); // Find and click "Play" button, the wait is to make the test less flaky await expect(audioPlayer.getByRole("button", { name: "Play" })).toBeVisible(); @@ -335,7 +339,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await expect(audioPlayer.getByRole("button", { name: "Pause" })).toBeVisible(); // Assert that the timer is reset when the audio file finished playing - await expect(audioPlayer.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible(); + await expect(audioPlayer.getByRole("timer")).toHaveText("00:00"); // Assert that "Play" button can be found await expect(audioPlayer.getByRole("button", { name: "Play" })).not.toBeDisabled(); diff --git a/playwright/e2e/right-panel/file-panel.spec.ts b/playwright/e2e/right-panel/file-panel.spec.ts index d69b7d4731..f6d89511b7 100644 --- a/playwright/e2e/right-panel/file-panel.spec.ts +++ b/playwright/e2e/right-panel/file-panel.spec.ts @@ -63,9 +63,7 @@ test.describe("FilePanel", () => { await expect(roomViewBody.locator(".mx_EventTile[data-layout='group'] img[alt='riot.png']")).toBeVisible(); // Assert that the audio player is rendered - await expect( - roomViewBody.locator(".mx_EventTile[data-layout='group'] .mx_AudioPlayer_container"), - ).toBeVisible(); + await expect(roomViewBody.getByRole("region", { name: "Audio player" })).toBeVisible(); // Assert that the file button exists await expect( @@ -97,9 +95,7 @@ test.describe("FilePanel", () => { await expect(image.locator("img[alt='riot.png']")).toBeVisible(); // Detect the audio file - const audio = filePanelMessageList.locator( - ".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container", - ); + const audio = filePanelMessageList.getByRole("region", { name: "Audio player" }); // Assert that the play button is rendered await expect(audio.getByRole("button", { name: "Play" })).toBeVisible(); @@ -130,7 +126,7 @@ test.describe("FilePanel", () => { // Take a snapshot of file tiles list on FilePanel await expect(filePanelMessageList).toMatchScreenshot("file-tiles-list.png", { // Exclude timestamps & flaky seek bar from snapshot - mask: [page.locator(".mx_MessageTimestamp, .mx_AudioPlayer_seek")], + mask: [page.locator(".mx_MessageTimestamp"), page.getByTestId("audio-player-seek")], }); }); @@ -138,21 +134,19 @@ test.describe("FilePanel", () => { // Upload an image file await uploadFile(page, "playwright/sample-files/1sec.ogg"); - const audioBody = page.locator( - ".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container", - ); + const audioBody = page.getByTestId("right-panel").getByRole("region", { name: "Audio player" }); + // Assert that the audio player is rendered - // Assert that the audio file information is rendered - const mediaInfo = audioBody.locator(".mx_AudioPlayer_mediaInfo"); - await expect(mediaInfo.locator(".mx_AudioPlayer_mediaName").getByText("1sec.ogg")).toBeVisible(); - await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "00:01" })).toBeVisible(); - await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "(3.56 KB)" })).toBeVisible(); // actual size + // Assert that the audio file information is rendered; + await expect(audioBody.getByText("1sec.ogg")).toBeVisible(); // extension + await expect(audioBody.getByRole("time")).toHaveText("00:01"); // duration + await expect(audioBody.getByText("(3.56 KB)")).toBeVisible(); // actual size; // Assert that the duration counter is 00:01 before clicking the play button - await expect(audioBody.locator(".mx_AudioPlayer_mediaInfo time", { hasText: "00:01" })).toBeVisible(); + await expect(audioBody.getByRole("time")).toHaveText("00:01"); // Assert that the counter is zero before clicking the play button - await expect(audioBody.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible(); + await expect(audioBody.getByRole("timer")).toHaveText("00:00"); // Click the play button await audioBody.getByRole("button", { name: "Play" }).click(); @@ -161,7 +155,7 @@ test.describe("FilePanel", () => { await expect(audioBody.getByRole("button", { name: "Pause" })).toBeVisible(); // Assert that the timer is reset when the audio file finished playing - await expect(audioBody.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible(); + await expect(audioBody.getByRole("timer")).toHaveText("00:00"); // Assert that the play button is rendered await expect(audioBody.getByRole("button", { name: "Play" })).toBeVisible(); diff --git a/playwright/shared-component-snapshots/audio-audioplayerview--default-linux.png b/playwright/shared-component-snapshots/audio-audioplayerview--default-linux.png new file mode 100644 index 0000000000..e4d6a84d23 Binary files /dev/null and b/playwright/shared-component-snapshots/audio-audioplayerview--default-linux.png differ diff --git a/playwright/shared-component-snapshots/audio-audioplayerview--has-error-linux.png b/playwright/shared-component-snapshots/audio-audioplayerview--has-error-linux.png new file mode 100644 index 0000000000..36684675f7 Binary files /dev/null and b/playwright/shared-component-snapshots/audio-audioplayerview--has-error-linux.png differ diff --git a/playwright/shared-component-snapshots/audio-audioplayerview--no-media-name-linux.png b/playwright/shared-component-snapshots/audio-audioplayerview--no-media-name-linux.png new file mode 100644 index 0000000000..c46e59ac21 Binary files /dev/null and b/playwright/shared-component-snapshots/audio-audioplayerview--no-media-name-linux.png differ diff --git a/playwright/shared-component-snapshots/audio-audioplayerview--no-size-linux.png b/playwright/shared-component-snapshots/audio-audioplayerview--no-size-linux.png new file mode 100644 index 0000000000..928f6f0197 Binary files /dev/null and b/playwright/shared-component-snapshots/audio-audioplayerview--no-size-linux.png differ diff --git a/playwright/shared-component-snapshots/audio-playpausebutton--default-linux.png b/playwright/shared-component-snapshots/audio-playpausebutton--default-linux.png new file mode 100644 index 0000000000..8c8baa7a65 Binary files /dev/null and b/playwright/shared-component-snapshots/audio-playpausebutton--default-linux.png differ diff --git a/playwright/shared-component-snapshots/audio-playpausebutton--playing-linux.png b/playwright/shared-component-snapshots/audio-playpausebutton--playing-linux.png new file mode 100644 index 0000000000..53d58a4c2f Binary files /dev/null and b/playwright/shared-component-snapshots/audio-playpausebutton--playing-linux.png differ diff --git a/playwright/shared-component-snapshots/audio-seekbar--default-linux.png b/playwright/shared-component-snapshots/audio-seekbar--default-linux.png new file mode 100644 index 0000000000..60e51020cf Binary files /dev/null and b/playwright/shared-component-snapshots/audio-seekbar--default-linux.png differ diff --git a/playwright/shared-component-snapshots/audio-seekbar--disabled-linux.png b/playwright/shared-component-snapshots/audio-seekbar--disabled-linux.png new file mode 100644 index 0000000000..128f7e2ee5 Binary files /dev/null and b/playwright/shared-component-snapshots/audio-seekbar--disabled-linux.png differ diff --git a/playwright/shared-component-snapshots/messagebody-mediabody--default-linux.png b/playwright/shared-component-snapshots/messagebody-mediabody--default-linux.png new file mode 100644 index 0000000000..56b8072d2d Binary files /dev/null and b/playwright/shared-component-snapshots/messagebody-mediabody--default-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--bubble-layout-linux.png index 165033dbe9..17bc4bfc78 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--bubble-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--group-layout-linux.png index f309d57bc0..30e0de96ce 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--group-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--irc-layout-linux.png index bd02a2f21a..f2fd0de114 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--irc-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--irc-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--bubble-layout-linux.png index 16e0624b83..ab9b63960d 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--bubble-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--group-layout-linux.png index 1e78930256..7e6137c4eb 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--group-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--irc-layout-linux.png index 6a43aac7ef..519a46371d 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--irc-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--irc-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--bubble-layout-linux.png index 014b8dbaec..0a299b63d1 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--bubble-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--group-layout-linux.png index 156d89053c..8307809872 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--group-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--irc-layout-linux.png index caf6e1e698..f57a1e27a6 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--irc-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--irc-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--bubble-layout-linux.png index c9591ebf49..8a6dec9273 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--bubble-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--group-layout-linux.png index 794ac11b01..b6a2ed0bfc 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--group-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--irc-layout-linux.png index 2b6475fbdf..c59ef184c4 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--irc-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--irc-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-bubble-layout-linux.png index 0f643ee43a..9d86393932 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-bubble-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-bubble-layout-linux.png index bc9d6c88c3..470366cb9b 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-bubble-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-group-layout-linux.png index 2b867170ae..2dac381c78 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-group-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-irc-layout-linux.png index 459ebd3584..b09c0ceed4 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-irc-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-irc-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-group-layout-linux.png index da97c28029..16f51d0262 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-group-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-irc-layout-linux.png index 009ea38f7b..1952c0681d 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-irc-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-irc-layout-linux.png differ diff --git a/playwright/snapshots/right-panel/file-panel.spec.ts/file-tiles-list-linux.png b/playwright/snapshots/right-panel/file-panel.spec.ts/file-tiles-list-linux.png index 1c75a92373..f9170b0cda 100644 Binary files a/playwright/snapshots/right-panel/file-panel.spec.ts/file-tiles-list-linux.png and b/playwright/snapshots/right-panel/file-panel.spec.ts/file-tiles-list-linux.png differ diff --git a/res/css/_components.pcss b/res/css/_components.pcss index bd002fb80c..b140fd1d7e 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -95,7 +95,6 @@ @import "./structures/auth/_Registration.pcss"; @import "./structures/auth/_SessionLockStolenView.pcss"; @import "./structures/auth/_SetupEncryptionBody.pcss"; -@import "./views/audio_messages/_AudioPlayer.pcss"; @import "./views/audio_messages/_PlayPauseButton.pcss"; @import "./views/audio_messages/_PlaybackContainer.pcss"; @import "./views/audio_messages/_SeekBar.pcss"; diff --git a/res/css/views/audio_messages/_AudioPlayer.pcss b/res/css/views/audio_messages/_AudioPlayer.pcss deleted file mode 100644 index 51e97611f5..0000000000 --- a/res/css/views/audio_messages/_AudioPlayer.pcss +++ /dev/null @@ -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; - } - } -} diff --git a/src/components/views/audio_messages/AudioPlayer.tsx b/src/components/views/audio_messages/AudioPlayer.tsx deleted file mode 100644 index 6f674e504d..0000000000 --- a/src/components/views/audio_messages/AudioPlayer.tsx +++ /dev/null @@ -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 ( -
-
- -
- - {this.props.mediaName || _t("timeline|m.audio|unnamed_audio")} - -
- -   {/* easiest way to introduce a gap between the components */} - {this.renderFileSize()} -
-
-
-
- - -
-
- ); - } -} diff --git a/src/components/views/audio_messages/AudioPlayerBase.tsx b/src/components/views/audio_messages/AudioPlayerBase.tsx index 71bea008a2..9da1020634 100644 --- a/src/components/views/audio_messages/AudioPlayerBase.tsx +++ b/src/components/views/audio_messages/AudioPlayerBase.tsx @@ -14,7 +14,7 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { _t } from "../../../languageHandler"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; -import type SeekBar from "./SeekBar"; +import type LegacySeekBar from "./LegacySeekBar"; import type PlayPauseButton from "./PlayPauseButton"; export interface IProps { @@ -31,7 +31,7 @@ interface IState { } export default abstract class AudioPlayerBase extends React.PureComponent { - protected seekRef = createRef(); + protected seekRef = createRef(); protected playPauseRef = createRef(); public constructor(props: T) { diff --git a/src/components/views/audio_messages/DurationClock.tsx b/src/components/views/audio_messages/DurationClock.tsx deleted file mode 100644 index 63d6c0cb23..0000000000 --- a/src/components/views/audio_messages/DurationClock.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2021 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; - -import { Clock } from "../../../shared-components/audio/Clock"; -import { type Playback } from "../../../audio/Playback"; - -interface IProps { - playback: Playback; -} - -interface IState { - durationSeconds: number; -} - -/** - * A clock which shows a clip's maximum duration. - */ -export default class DurationClock extends React.PureComponent { - public constructor(props: IProps) { - super(props); - - this.state = { - // we track the duration on state because we won't really know what the clip duration - // is until the first time update, and as a PureComponent we are trying to dedupe state - // updates as much as possible. This is just the easiest way to avoid a forceUpdate() or - // member property to track "did we get a duration". - durationSeconds: this.props.playback.clockInfo.durationSeconds, - }; - } - - public componentDidMount(): void { - this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate); - } - - private onTimeUpdate = (time: number[]): void => { - this.setState({ durationSeconds: time[1] }); - }; - - public render(): React.ReactNode { - return ; - } -} diff --git a/src/components/views/audio_messages/SeekBar.tsx b/src/components/views/audio_messages/LegacySeekBar.tsx similarity index 96% rename from src/components/views/audio_messages/SeekBar.tsx rename to src/components/views/audio_messages/LegacySeekBar.tsx index c5ec63b359..798a4322b7 100644 --- a/src/components/views/audio_messages/SeekBar.tsx +++ b/src/components/views/audio_messages/LegacySeekBar.tsx @@ -35,7 +35,10 @@ interface ISeekCSS extends CSSProperties { const ARROW_SKIP_SECONDS = 5; // arbitrary -export default class SeekBar extends React.PureComponent { +/** + * @deprecated Use {@link SeekBar} instead. + */ +export default class LegacySeekBar extends React.PureComponent { // We use an animation frame request to avoid overly spamming prop updates, even if we aren't // really using anything demanding on the CSS front. diff --git a/src/components/views/audio_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx index c0e3337787..75e1419ce6 100644 --- a/src/components/views/audio_messages/RecordingPlayback.tsx +++ b/src/components/views/audio_messages/RecordingPlayback.tsx @@ -11,7 +11,7 @@ import React, { type ReactNode } from "react"; import PlayPauseButton from "./PlayPauseButton"; import PlaybackClock from "./PlaybackClock"; import AudioPlayerBase, { type IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase"; -import SeekBar from "./SeekBar"; +import LegacySeekBar from "./LegacySeekBar"; import PlaybackWaveform from "./PlaybackWaveform"; import { PlaybackState } from "../../../audio/Playback"; @@ -49,7 +49,7 @@ export default class RecordingPlayback extends AudioPlayerBase { <>
- { @@ -61,7 +63,7 @@ export default class MAudioBody extends React.PureComponent // We should have a buffer to work with now: let's set it up const playback = PlaybackManager.instance.createPlaybackInstance(buffer, waveform); playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent); - this.setState({ playback }); + this.setState({ playback, audioPlayerVm: new AudioPlayerViewModel({ playback, mediaName: content.body }) }); if (isVoiceMessage(this.props.mxEvent)) { PlaybackQueue.forRoom(this.props.mxEvent.getRoomId()!).unsortedEnqueue(this.props.mxEvent, playback); @@ -113,7 +115,7 @@ export default class MAudioBody extends React.PureComponent // At this point we should have a playable state return ( - + {this.state.audioPlayerVm && } {this.showFileBody && } ); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 712214e036..91bda0a1b0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3398,6 +3398,7 @@ "unable_to_find": "Tried to load a specific point in this room's timeline, but was unable to find it." }, "m.audio": { + "audio_player": "Audio player", "error_downloading_audio": "Error downloading audio", "error_processing_audio": "Error processing audio message", "error_processing_voice_message": "Error processing voice message", diff --git a/src/shared-components/ViewWrapper.tsx b/src/shared-components/ViewWrapper.tsx new file mode 100644 index 0000000000..57b81bd5b9 --- /dev/null +++ b/src/shared-components/ViewWrapper.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX, useMemo, type ComponentType } from "react"; +import { omitBy, pickBy } from "lodash"; + +import { MockViewModel } from "./MockViewModel"; +import { type ViewModel } from "./ViewModel"; + +interface ViewWrapperProps { + /** + * The component to render, which should accept a `vm` prop of type `V`. + */ + Component: ComponentType<{ vm: V }>; + /** + * The props to pass to the component, which can include both snapshot data and actions. + */ + props: Record; +} + +/** + * A wrapper component that creates a view model instance and passes it to the specified component. + * This is useful for testing components in isolation with a mocked view model and allows to use primitive types in stories. + * + * Props is parsed and split into snapshot and actions. Where values that are functions (`typeof Function`) are considered actions and the rest is considered the snapshot. + * + * @example + * ```tsx + * props={Snapshot&Actions} Component={MyComponent} /> + * ``` + */ +export function ViewWrapper>({ + props, + Component, +}: Readonly>): JSX.Element { + const vm = useMemo(() => { + const isFunction = (value: any): value is typeof Function => typeof value === typeof Function; + const snapshot = omitBy(props, isFunction) as T; + const actions = pickBy(props, isFunction); + + const vm = new MockViewModel(snapshot); + Object.assign(vm, actions); + + return vm as unknown as V; + }, [props]); + + return ; +} diff --git a/src/shared-components/audio/AudioPlayerView/AudioPlayerView.module.css b/src/shared-components/audio/AudioPlayerView/AudioPlayerView.module.css new file mode 100644 index 0000000000..905719eedf --- /dev/null +++ b/src/shared-components/audio/AudioPlayerView/AudioPlayerView.module.css @@ -0,0 +1,36 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.audioPlayer { + padding: var(--cpd-space-4x) var(--cpd-space-3x) var(--cpd-space-3x) var(--cpd-space-3x); +} + +.mediaInfo { + /* Makes the ellipsis on the file name work */ + overflow: hidden; +} + +.mediaName { + color: var(--cpd-color-text-primary); + font: var(--cpd-font-body-md-regular); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.byline { + font: var(--cpd-font-body-xs-regular); +} + +.clock { + white-space: nowrap; +} + +.error { + color: var(--cpd-color-text-critical-primary); +} diff --git a/src/shared-components/audio/AudioPlayerView/AudioPlayerView.stories.tsx b/src/shared-components/audio/AudioPlayerView/AudioPlayerView.stories.tsx new file mode 100644 index 0000000000..869e922dd4 --- /dev/null +++ b/src/shared-components/audio/AudioPlayerView/AudioPlayerView.stories.tsx @@ -0,0 +1,66 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX } from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryFn } from "@storybook/react-vite"; +import { + AudioPlayerView, + type AudioPlayerViewActions, + type AudioPlayerViewSnapshot, + type AudioPlayerViewModel, +} from "./AudioPlayerView"; +import { ViewWrapper } from "../../ViewWrapper"; + +type AudioPlayerProps = AudioPlayerViewSnapshot & AudioPlayerViewActions; +const AudioPlayerViewWrapper = (props: AudioPlayerProps): JSX.Element => ( + Component={AudioPlayerView} props={props} /> +); + +export default { + title: "Audio/AudioPlayerView", + component: AudioPlayerViewWrapper, + tags: ["autodocs"], + argTypes: { + playbackState: { + options: ["stopped", "playing", "paused", "decoding"], + control: { type: "select" }, + }, + }, + args: { + mediaName: "Sample Audio", + durationSeconds: 300, + playedSeconds: 120, + percentComplete: 30, + playbackState: "stopped", + sizeBytes: 3500, + error: false, + togglePlay: fn(), + onKeyDown: fn(), + onSeekbarChange: fn(), + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); + +export const NoMediaName = Template.bind({}); +NoMediaName.args = { + mediaName: undefined, +}; + +export const NoSize = Template.bind({}); +NoSize.args = { + sizeBytes: undefined, +}; + +export const HasError = Template.bind({}); +HasError.args = { + error: true, +}; diff --git a/src/shared-components/audio/AudioPlayerView/AudioPlayerView.test.tsx b/src/shared-components/audio/AudioPlayerView/AudioPlayerView.test.tsx new file mode 100644 index 0000000000..3f08eec28c --- /dev/null +++ b/src/shared-components/audio/AudioPlayerView/AudioPlayerView.test.tsx @@ -0,0 +1,78 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { render, screen } from "jest-matrix-react"; +import { composeStories } from "@storybook/react-vite"; +import React from "react"; +import userEvent from "@testing-library/user-event"; +import { fireEvent } from "@testing-library/dom"; + +import * as stories from "./AudioPlayerView.stories.tsx"; +import { AudioPlayerView, type AudioPlayerViewActions, type AudioPlayerViewSnapshot } from "./AudioPlayerView"; +import { MockViewModel } from "../../MockViewModel"; + +const { Default, NoMediaName, NoSize, HasError } = composeStories(stories); + +describe("AudioPlayerView", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("renders the audio player in default state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the audio player without media name", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the audio player without size", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the audio player in error state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + const onKeyDown = jest.fn(); + const togglePlay = jest.fn(); + const onSeekbarChange = jest.fn(); + + class AudioPlayerViewModel extends MockViewModel implements AudioPlayerViewActions { + public onKeyDown = onKeyDown; + public togglePlay = togglePlay; + public onSeekbarChange = onSeekbarChange; + } + + it("should attach vm methods", async () => { + const user = userEvent.setup(); + const vm = new AudioPlayerViewModel({ + playbackState: "stopped", + mediaName: "Test Audio", + durationSeconds: 300, + playedSeconds: 120, + percentComplete: 30, + sizeBytes: 3500, + error: false, + }); + + render(); + await user.click(screen.getByRole("button", { name: "Play" })); + expect(togglePlay).toHaveBeenCalled(); + + // user event doesn't support change events on sliders, so we use fireEvent + fireEvent.change(screen.getByRole("slider", { name: "Audio seek bar" }), { target: { value: "50" } }); + expect(onSeekbarChange).toHaveBeenCalled(); + + await user.type(screen.getByLabelText("Audio player"), "{arrowup}"); + expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "ArrowUp" })); + }); +}); diff --git a/src/shared-components/audio/AudioPlayerView/AudioPlayerView.tsx b/src/shared-components/audio/AudioPlayerView/AudioPlayerView.tsx new file mode 100644 index 0000000000..f963c8b2cf --- /dev/null +++ b/src/shared-components/audio/AudioPlayerView/AudioPlayerView.tsx @@ -0,0 +1,143 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type ChangeEventHandler, type JSX, type KeyboardEventHandler, type MouseEventHandler } from "react"; + +import { type ViewModel } from "../../ViewModel"; +import { useViewModel } from "../../useViewModel"; +import { MediaBody } from "../../message-body/MediaBody"; +import { Flex } from "../../utils/Flex"; +import styles from "./AudioPlayerView.module.css"; +import { PlayPauseButton } from "../PlayPauseButton"; +import { type PlaybackState } from "../playback"; +import { _t } from "../../utils/i18n"; +import { formatBytes } from "../../utils/FormattingUtils"; +import { Clock } from "../Clock"; +import { SeekBar } from "../SeekBar"; + +export interface AudioPlayerViewSnapshot { + /** + * The playback state of the audio player. + */ + playbackState: PlaybackState; + /** + * Name of the media being played. + * @default Fallback to "timeline|m.audio|unnamed_audio" string if not provided. + */ + mediaName?: string; + /** + * Size of the audio file in bytes. + * Hided if not provided. + */ + sizeBytes?: number; + /** + * The duration of the audio clip in seconds. + */ + durationSeconds: number; + /** + * The percentage of the audio that has been played. + * Ranges from 0 to 100. + */ + percentComplete: number; + /** + * The number of seconds that have been played. + */ + playedSeconds: number; + /** + * Indicates if there was an error downloading the audio. + */ + error: boolean; +} + +export interface AudioPlayerViewActions { + /** + * Handles key down events for the audio player. + */ + onKeyDown: KeyboardEventHandler; + /** + * Toggles the play/pause state of the audio player. + */ + togglePlay: MouseEventHandler; + /** + * Handles changes to the seek bar. + */ + onSeekbarChange: ChangeEventHandler; +} + +/** + * The view model for the audio player. + */ +export type AudioPlayerViewModel = ViewModel & AudioPlayerViewActions; + +interface AudioPlayerViewProps { + /** + * The view model for the audio player. + */ + vm: AudioPlayerViewModel; +} + +/** + * AudioPlayer component displays an audio player with play/pause controls, seek bar, and media information. + * The component expects a view model that provides the current state of the audio playback, + * + * @example + * ```tsx + * + * ``` + */ +export function AudioPlayerView({ vm }: Readonly): JSX.Element { + const { + playbackState, + mediaName = _t("timeline|m.audio|unnamed_audio"), + sizeBytes, + durationSeconds, + playedSeconds, + percentComplete, + error, + } = useViewModel(vm); + const fileSize = sizeBytes ? `(${formatBytes(sizeBytes)})` : null; + const disabled = playbackState === "decoding"; + + // tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard + // events for accessibility + return ( + <> + + + + + + {mediaName} + + + + {fileSize} + + + + + + + + + {error && {_t("timeline|m.audio|error_downloading_audio")}} + + ); +} diff --git a/src/shared-components/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap b/src/shared-components/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap new file mode 100644 index 0000000000..fc8cc20a17 --- /dev/null +++ b/src/shared-components/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap @@ -0,0 +1,369 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AudioPlayerView renders the audio player in default state 1`] = ` +
+
+
+ +
+ + Sample Audio + + +
+
+
+ + +
+
+
+`; + +exports[`AudioPlayerView renders the audio player in error state 1`] = ` +
+
+
+ +
+ + Sample Audio + + +
+
+
+ + +
+
+ + Error downloading audio + +
+`; + +exports[`AudioPlayerView renders the audio player without media name 1`] = ` +
+
+
+ +
+ + Unnamed audio + + +
+
+
+ + +
+
+
+`; + +exports[`AudioPlayerView renders the audio player without size 1`] = ` +
+
+
+ +
+ + Sample Audio + + +
+
+
+ + +
+
+
+`; diff --git a/src/shared-components/audio/AudioPlayerView/index.ts b/src/shared-components/audio/AudioPlayerView/index.ts new file mode 100644 index 0000000000..4075f81dde --- /dev/null +++ b/src/shared-components/audio/AudioPlayerView/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export type { AudioPlayerViewModel, AudioPlayerViewSnapshot } from "./AudioPlayerView"; +export { AudioPlayerView } from "./AudioPlayerView"; diff --git a/src/shared-components/audio/Clock/Clock.tsx b/src/shared-components/audio/Clock/Clock.tsx index 9aa114e415..176044269d 100644 --- a/src/shared-components/audio/Clock/Clock.tsx +++ b/src/shared-components/audio/Clock/Clock.tsx @@ -7,10 +7,11 @@ Please see LICENSE files in the repository root for full details. import React, { type HTMLProps } from "react"; import { Temporal } from "temporal-polyfill"; +import classNames from "classnames"; import { formatSeconds } from "../../utils/DateUtils"; -export interface Props extends Pick, "aria-live" | "role"> { +export interface Props extends Pick, "aria-live" | "role" | "className"> { seconds: number; } @@ -41,7 +42,7 @@ export class Clock extends React.Component { aria-live={this.props["aria-live"]} role={role} /* Keep class for backward compatibility with parent component */ - className="mx_Clock" + className={classNames("mx_Clock", this.props.className)} > {formatSeconds(seconds)} diff --git a/src/shared-components/audio/PlayPauseButton/PlayPauseButton.module.css b/src/shared-components/audio/PlayPauseButton/PlayPauseButton.module.css new file mode 100644 index 0000000000..74315673f1 --- /dev/null +++ b/src/shared-components/audio/PlayPauseButton/PlayPauseButton.module.css @@ -0,0 +1,11 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.button { + border-radius: 32px; + background-color: var(--cpd-color-bg-subtle-primary); +} diff --git a/src/shared-components/audio/PlayPauseButton/PlayPauseButton.stories.tsx b/src/shared-components/audio/PlayPauseButton/PlayPauseButton.stories.tsx new file mode 100644 index 0000000000..5bbcfbbf39 --- /dev/null +++ b/src/shared-components/audio/PlayPauseButton/PlayPauseButton.stories.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { fn } from "storybook/test"; + +import { PlayPauseButton } from "./PlayPauseButton"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta = { + title: "Audio/PlayPauseButton", + component: PlayPauseButton, + tags: ["autodocs"], + args: { + togglePlay: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const Playing: Story = { args: { playing: true } }; diff --git a/src/shared-components/audio/PlayPauseButton/PlayPauseButton.test.tsx b/src/shared-components/audio/PlayPauseButton/PlayPauseButton.test.tsx new file mode 100644 index 0000000000..3a032f5e02 --- /dev/null +++ b/src/shared-components/audio/PlayPauseButton/PlayPauseButton.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; +import userEvent from "@testing-library/user-event"; +import { fn } from "storybook/test"; + +import * as stories from "./PlayPauseButton.stories.tsx"; + +const { Default, Playing } = composeStories(stories); + +describe("PlayPauseButton", () => { + it("renders the button in default state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the button in playing state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("calls togglePlay when clicked", async () => { + const user = userEvent.setup(); + const togglePlay = fn(); + + const { getByRole } = render(); + await user.click(getByRole("button")); + expect(togglePlay).toHaveBeenCalled(); + }); +}); diff --git a/src/shared-components/audio/PlayPauseButton/PlayPauseButton.tsx b/src/shared-components/audio/PlayPauseButton/PlayPauseButton.tsx new file mode 100644 index 0000000000..400357a3f5 --- /dev/null +++ b/src/shared-components/audio/PlayPauseButton/PlayPauseButton.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type HTMLAttributes, type JSX, type MouseEventHandler } from "react"; +import { IconButton } from "@vector-im/compound-web"; +import Play from "@vector-im/compound-design-tokens/assets/web/icons/play-solid"; +import Pause from "@vector-im/compound-design-tokens/assets/web/icons/pause-solid"; + +import styles from "./PlayPauseButton.module.css"; +import { _t } from "../../utils/i18n"; + +export interface PlayPauseButtonProps extends HTMLAttributes { + /** + * Whether the button is disabled. + * @default false + */ + disabled?: boolean; + + /** + * Whether the audio is currently playing. + * @default false + */ + playing?: boolean; + + /** + * Function to toggle play/pause state. + */ + togglePlay: MouseEventHandler; +} + +/** + * A button component that toggles between play and pause states for audio playback. + * + * @example + * ```tsx + * {}} /> + * ``` + */ +export function PlayPauseButton({ + disabled = false, + playing = false, + togglePlay, + ...rest +}: Readonly): JSX.Element { + const label = playing ? _t("action|pause") : _t("action|play"); + + return ( + + {playing ? : } + + ); +} diff --git a/src/shared-components/audio/PlayPauseButton/__snapshots__/PlayPauseButton.test.tsx.snap b/src/shared-components/audio/PlayPauseButton/__snapshots__/PlayPauseButton.test.tsx.snap new file mode 100644 index 0000000000..3c60aeae11 --- /dev/null +++ b/src/shared-components/audio/PlayPauseButton/__snapshots__/PlayPauseButton.test.tsx.snap @@ -0,0 +1,65 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PlayPauseButton renders the button in default state 1`] = ` +
+ +
+`; + +exports[`PlayPauseButton renders the button in playing state 1`] = ` +
+ +
+`; diff --git a/src/shared-components/audio/PlayPauseButton/index.ts b/src/shared-components/audio/PlayPauseButton/index.ts new file mode 100644 index 0000000000..93a71cd739 --- /dev/null +++ b/src/shared-components/audio/PlayPauseButton/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { PlayPauseButton } from "./PlayPauseButton"; diff --git a/src/shared-components/audio/SeekBar/SeekBar.module.css b/src/shared-components/audio/SeekBar/SeekBar.module.css new file mode 100644 index 0000000000..54e3b7479f --- /dev/null +++ b/src/shared-components/audio/SeekBar/SeekBar.module.css @@ -0,0 +1,99 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2021 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/* CSS inspiration from: */ +/* * https://www.w3schools.com/howto/howto_js_rangeslider.asp */ +/* * https://stackoverflow.com/a/28283806 */ +/* * https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/ */ + +.seekBar { + /* default, overridden in JS */ + --fillTo: 1; + + /* Dev note: we deliberately do not have the -ms-track (and friends) selectors because we don't */ + /* need to support IE. */ + + appearance: none; /* default style override */ + + width: 100%; + height: 1px; + background: var(--cpd-color-gray-600); + outline: none; /* remove blue selection border */ + position: relative; /* for before+after pseudo elements later on */ + + cursor: pointer; + + &::-webkit-slider-thumb { + appearance: none; /* default style override */ + + /* Dev note: This needs to be duplicated with the -moz-range-thumb selector */ + /* because otherwise Edge (webkit) will fail to see the styles and just refuse */ + /* to apply them. */ + width: 8px; + height: 8px; + border-radius: 8px; + background-color: var(--cpd-color-gray-800); + cursor: pointer; + } + + &::-moz-range-thumb { + width: 8px; + height: 8px; + border-radius: 8px; + background-color: var(--cpd-color-gray-800); + cursor: pointer; + + /* Firefox adds a border on the thumb */ + border: none; + } + + /* This is for webkit support, but we can't limit the functionality of it to just webkit */ + /* browsers. Firefox responds to webkit-prefixed values now, which means we can't use media */ + /* or support queries to selectively apply the rule. An upside is that this CSS doesn't work */ + /* in firefox, so it's just wasted CPU/GPU time. */ + &::before { + /* ::before to ensure it ends up under the thumb */ + content: ""; + background-color: var(--cpd-color-gray-800); + + /* Absolute positioning to ensure it overlaps with the existing bar */ + position: absolute; + top: 0; + left: 0; + + /* Sizing to match the bar */ + width: 100%; + height: 1px; + + /* And finally dynamic width without overly hurting the rendering engine. */ + transform-origin: 0 100%; + transform: scaleX(var(--fillTo)); + } + + /* This is firefox's built-in support for the above, with 100% less hacks. */ + &::-moz-range-progress { + background-color: var(--cpd-color-gray-800); + height: 1px; + } + + &:disabled { + opacity: 0.5; + } + + /* Increase clickable area for the slider (approximately same size as browser default) */ + /* We do it this way to keep the same padding and margins of the element, avoiding margin math. */ + /* Source: https://front-back.com/expand-clickable-areas-for-a-better-touch-experience/ */ + &::after { + content: ""; + position: absolute; + top: -6px; + bottom: -6px; + left: 0; + right: 0; + } +} diff --git a/src/shared-components/audio/SeekBar/SeekBar.stories.tsx b/src/shared-components/audio/SeekBar/SeekBar.stories.tsx new file mode 100644 index 0000000000..6a3ec48666 --- /dev/null +++ b/src/shared-components/audio/SeekBar/SeekBar.stories.tsx @@ -0,0 +1,38 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { useArgs } from "storybook/preview-api"; + +import { SeekBar } from "./SeekBar"; +import type { Meta, StoryFn } from "@storybook/react-vite"; + +export default { + title: "Audio/SeekBar", + component: SeekBar, + tags: ["autodocs"], + argTypes: { + value: { + control: { type: "range", min: 0, max: 100, step: 1 }, + }, + }, + args: { + value: 50, + }, +} as Meta; + +const Template: StoryFn = (args) => { + const [, updateArgs] = useArgs(); + return updateArgs({ value: parseInt(evt.target.value, 10) })} {...args} />; +}; + +export const Default = Template.bind({}); + +export const Disabled = Template.bind({}); +Disabled.args = { + disabled: true, +}; diff --git a/src/shared-components/audio/SeekBar/SeekBar.test.tsx b/src/shared-components/audio/SeekBar/SeekBar.test.tsx new file mode 100644 index 0000000000..0d52ab3993 --- /dev/null +++ b/src/shared-components/audio/SeekBar/SeekBar.test.tsx @@ -0,0 +1,20 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { render } from "jest-matrix-react"; +import React from "react"; +import { composeStories } from "@storybook/react-vite"; + +import * as stories from "./SeekBar.stories.tsx"; +const { Default } = composeStories(stories); + +describe("Seekbar", () => { + it("renders the clock", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/shared-components/audio/SeekBar/SeekBar.tsx b/src/shared-components/audio/SeekBar/SeekBar.tsx new file mode 100644 index 0000000000..3063e2442d --- /dev/null +++ b/src/shared-components/audio/SeekBar/SeekBar.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type CSSProperties, type JSX, useEffect, useMemo, useState } from "react"; +import { throttle } from "lodash"; +import classNames from "classnames"; + +import style from "./SeekBar.module.css"; +import { _t } from "../../utils/i18n"; + +export interface SeekBarProps extends React.InputHTMLAttributes { + /** + * The current value of the seek bar, between 0 and 100. + * @default 0 + */ + value?: number; +} + +interface ISeekCSS extends CSSProperties { + "--fillTo": number; +} + +/** + * A seek bar component for audio playback. + * + * @example + * ```tsx + * console.log("New value", e.target.value)} /> + * ``` + */ +export function SeekBar({ value = 0, className, ...rest }: Readonly): JSX.Element { + const [newValue, setNewValue] = useState(value); + // Throttle the value setting to avoid excessive re-renders + const setThrottledValue = useMemo(() => throttle(setNewValue, 10), []); + + useEffect(() => { + setThrottledValue(value); + }, [value, setThrottledValue]); + + return ( + e.stopPropagation()} + min={0} + max={100} + value={newValue} + step={1} + style={{ "--fillTo": newValue / 100 } as ISeekCSS} + aria-label={_t("a11y|seek_bar_label")} + {...rest} + /> + ); +} diff --git a/src/shared-components/audio/SeekBar/__snapshots__/SeekBar.test.tsx.snap b/src/shared-components/audio/SeekBar/__snapshots__/SeekBar.test.tsx.snap new file mode 100644 index 0000000000..49bfd5dbe7 --- /dev/null +++ b/src/shared-components/audio/SeekBar/__snapshots__/SeekBar.test.tsx.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Seekbar renders the clock 1`] = ` +
+ +
+`; diff --git a/src/shared-components/audio/SeekBar/index.ts b/src/shared-components/audio/SeekBar/index.ts new file mode 100644 index 0000000000..710310198e --- /dev/null +++ b/src/shared-components/audio/SeekBar/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { SeekBar } from "./SeekBar"; diff --git a/src/shared-components/audio/playback.ts b/src/shared-components/audio/playback.ts new file mode 100644 index 0000000000..0979a2441b --- /dev/null +++ b/src/shared-components/audio/playback.ts @@ -0,0 +1,15 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +/** + * Represents the possible states of playback. + * - "decoding": The audio is being decoded and is not ready for playback. + * - "stopped": The playback has been stopped, with no progress on the timeline. + * - "paused": The playback is paused, with some progress on the timeline. + * - "playing": The playback is actively progressing through the timeline. + */ +export type PlaybackState = "decoding" | "stopped" | "paused" | "playing"; diff --git a/src/shared-components/message-body/MediaBody/MediaBody.module.css b/src/shared-components/message-body/MediaBody/MediaBody.module.css new file mode 100644 index 0000000000..7cf125f303 --- /dev/null +++ b/src/shared-components/message-body/MediaBody/MediaBody.module.css @@ -0,0 +1,17 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.mediaBody { + background-color: var(--cpd-color-bg-subtle-secondary); + border-radius: var(--cpd-space-2x); + max-width: 243px; /* use max-width instead of width so it fits within right panels */ + + font: var(--cpd-font-body-md-regular); + color: var(--cpd-color-text-secondary); + + padding: var(--cpd-space-1-5x) var(--cpd-space-3x); +} diff --git a/src/shared-components/message-body/MediaBody/MediaBody.stories.tsx b/src/shared-components/message-body/MediaBody/MediaBody.stories.tsx new file mode 100644 index 0000000000..ee90a37943 --- /dev/null +++ b/src/shared-components/message-body/MediaBody/MediaBody.stories.tsx @@ -0,0 +1,24 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; + +import { MediaBody } from "./MediaBody"; +import type { Meta, StoryFn } from "@storybook/react-vite"; + +export default { + title: "MessageBody/MediaBody", + component: MediaBody, + tags: ["autodocs"], + args: { + children: "Media content goes here", + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); diff --git a/src/shared-components/message-body/MediaBody/MediaBody.test.tsx b/src/shared-components/message-body/MediaBody/MediaBody.test.tsx new file mode 100644 index 0000000000..9d405e1af3 --- /dev/null +++ b/src/shared-components/message-body/MediaBody/MediaBody.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; + +import * as stories from "./MediaBody.stories"; + +const { Default } = composeStories(stories); + +describe("MediaBody", () => { + it("renders the media body", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/shared-components/message-body/MediaBody/MediaBody.tsx b/src/shared-components/message-body/MediaBody/MediaBody.tsx new file mode 100644 index 0000000000..86545db67a --- /dev/null +++ b/src/shared-components/message-body/MediaBody/MediaBody.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type ComponentProps, type ElementType, type JSX, type PropsWithChildren } from "react"; +import React from "react"; +import classNames from "classnames"; + +import styles from "./MediaBody.module.css"; + +export type MediaBodyProps = { + /** + * The HTML tag. + * @default "div" + */ + as?: C; + /** + * The CSS class name. + */ + className?: string; +} & ComponentProps; + +/** + * A component to display the body of a media message. + * + * @example + * ```tsx + * Media body content + * ``` + */ +export function MediaBody({ + as, + className, + children, + ...props +}: PropsWithChildren>): JSX.Element { + const Component = as || "div"; + + // Keep Mx_MediaBody to support the compatibility with existing timeline and the all the layout + return ( + + {children} + + ); +} diff --git a/src/shared-components/message-body/MediaBody/__snapshots__/MediaBody.test.tsx.snap b/src/shared-components/message-body/MediaBody/__snapshots__/MediaBody.test.tsx.snap new file mode 100644 index 0000000000..9d1011254b --- /dev/null +++ b/src/shared-components/message-body/MediaBody/__snapshots__/MediaBody.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MediaBody renders the media body 1`] = ` +
+
+ Media content goes here +
+
+`; diff --git a/src/shared-components/message-body/MediaBody/index.tsx b/src/shared-components/message-body/MediaBody/index.tsx new file mode 100644 index 0000000000..e90b1756d3 --- /dev/null +++ b/src/shared-components/message-body/MediaBody/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { MediaBody } from "./MediaBody"; diff --git a/src/shared-components/utils/FormattingUtils.ts b/src/shared-components/utils/FormattingUtils.ts new file mode 100644 index 0000000000..851d88aa8c --- /dev/null +++ b/src/shared-components/utils/FormattingUtils.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +/** + * format a size in bytes into a human readable form + * e.g: 1024 -> 1.00 KB + */ +export function formatBytes(bytes: number, decimals = 2): string { + if (bytes === 0) return "0 Bytes"; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; +} diff --git a/src/utils/FormattingUtils.ts b/src/utils/FormattingUtils.ts index 051ef7a371..c18600280c 100644 --- a/src/utils/FormattingUtils.ts +++ b/src/utils/FormattingUtils.ts @@ -12,6 +12,9 @@ import { useIdColorHash } from "@vector-im/compound-web"; import { _t, getCurrentLanguage, getUserLanguage } from "../languageHandler"; import { jsxJoin } from "./ReactUtils"; + +export { formatBytes } from "../shared-components/utils/FormattingUtils"; + const locale = getCurrentLanguage(); // It's quite costly to instanciate `Intl.NumberFormat`, hence why we do not do @@ -40,22 +43,6 @@ export function formatCountLong(count: number): string { return formatter.format(count); } -/** - * format a size in bytes into a human readable form - * e.g: 1024 -> 1.00 KB - */ -export function formatBytes(bytes: number, decimals = 2): string { - if (bytes === 0) return "0 Bytes"; - - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; - - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; -} - export function getUserNameColorClass(userId: string): string { // eslint-disable-next-line react-hooks/rules-of-hooks const number = useIdColorHash(userId); diff --git a/src/viewmodels/audio/AudioPlayerViewModel.ts b/src/viewmodels/audio/AudioPlayerViewModel.ts new file mode 100644 index 0000000000..208cd7e907 --- /dev/null +++ b/src/viewmodels/audio/AudioPlayerViewModel.ts @@ -0,0 +1,150 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type ChangeEvent, type KeyboardEvent } from "react"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { + type AudioPlayerViewSnapshot, + type AudioPlayerViewModel as AudioPlayerViewModelInterface, +} from "../../shared-components/audio/AudioPlayerView"; +import { type Playback } from "../../audio/Playback"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import { percentageOf } from "../../shared-components/utils/numbers"; +import { getKeyBindingsManager } from "../../KeyBindingsManager"; +import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; +import { BaseViewModel } from "../base/BaseViewModel"; + +/** + * The number of seconds to skip when the user presses the left or right arrow keys. + */ +const ARROW_SKIP_SECONDS = 5; + +interface Props { + /** + * The playback instance that manages the audio playback. + */ + playback: Playback; + /** + * Optional name of the media being played. + */ + mediaName?: string; +} + +/** + * ViewModel for the audio player, providing the current state of the audio playback. + * It listens to updates from the Playback instance and computes a snapshot. + */ +export class AudioPlayerViewModel + extends BaseViewModel + implements AudioPlayerViewModelInterface +{ + /** + * Indicates if there was an error processing the audio file. + * @private + */ + private error = false; + + /** + * Computes the snapshot of the audio player based on the current playback state. + * This includes the media name, size in bytes, playback state, duration, percentage complete, + * played seconds, and whether there was an error. + * @param playback - The playback instance managing the audio playback. + * @param mediaName - Optional name of the media being played. + * @param error - Indicates if there was an error processing the audio file. + */ + private static readonly computeSnapshot = ( + playback: Playback, + mediaName?: string, + error = false, + ): AudioPlayerViewSnapshot => { + const percentComplete = percentageOf(playback.timeSeconds, 0, playback.durationSeconds) * 100; + + return { + mediaName, + sizeBytes: playback.sizeBytes, + playbackState: playback.currentState, + durationSeconds: playback.durationSeconds, + percentComplete, + playedSeconds: playback.timeSeconds, + error, + }; + }; + + public constructor(props: Props) { + super(props, AudioPlayerViewModel.computeSnapshot(props.playback, props.mediaName)); + + // Don't wait for the promise to complete - it will emit a progress update when it + // is done, and it's not meant to take long anyhow. + this.preparePlayback(); + } + + /** + * Prepares the playback by calling the prepare method on the playback instance. + * @private + */ + private async preparePlayback(): Promise { + try { + await this.props.playback.prepare(); + } catch (e) { + logger.error("Error processing audio file:", e, this.props.playback.currentState); + this.error = true; + this.setSnapshot(); + } + } + + protected addDownstreamSubscription(): void { + this.props.playback.on(UPDATE_EVENT, this.setSnapshot); + // There is no unsubscribe method in SimpleObservable + this.props.playback.clockInfo.liveData.onUpdate(this.setSnapshot); + } + protected removeDownstreamSubscription(): void { + this.props.playback.off(UPDATE_EVENT, this.setSnapshot); + } + + /** + * Sets the snapshot and emits an update to subscribers. + */ + private readonly setSnapshot = (): void => { + this.snapshot.set(AudioPlayerViewModel.computeSnapshot(this.props.playback, this.props.mediaName, this.error)); + }; + + public onKeyDown = (ev: KeyboardEvent): void => { + let handled = true; + const action = getKeyBindingsManager().getAccessibilityAction(ev); + + switch (action) { + case KeyBindingAction.Space: + this.togglePlay(); + break; + case KeyBindingAction.ArrowLeft: + this.props.playback.skipTo(this.props.playback.timeSeconds - ARROW_SKIP_SECONDS); + break; + case KeyBindingAction.ArrowRight: + this.props.playback.skipTo(this.props.playback.timeSeconds + ARROW_SKIP_SECONDS); + break; + default: + handled = false; + break; + } + + // stopPropagation() prevents the FocusComposer catch-all from triggering, + // but we need to do it on key down instead of press (even though the user + // interaction is typically on press). + if (handled) { + ev.stopPropagation(); + } + }; + + public togglePlay = async (): Promise => { + await this.props.playback.toggle(); + }; + + public onSeekbarChange = async (ev: ChangeEvent): Promise => { + await this.props.playback.skipTo((Number(ev.target.value) / 100) * this.props.playback.durationSeconds); + }; +} diff --git a/test/unit-tests/components/views/audio_messages/SeekBar-test.tsx b/test/unit-tests/components/views/audio_messages/LegacySeekBar-test.tsx similarity index 90% rename from test/unit-tests/components/views/audio_messages/SeekBar-test.tsx rename to test/unit-tests/components/views/audio_messages/LegacySeekBar-test.tsx index b723ccb1c4..951f140a09 100644 --- a/test/unit-tests/components/views/audio_messages/SeekBar-test.tsx +++ b/test/unit-tests/components/views/audio_messages/LegacySeekBar-test.tsx @@ -12,13 +12,13 @@ import { act, fireEvent, render, type RenderResult } from "jest-matrix-react"; import { type Playback } from "../../../../../src/audio/Playback"; import { createTestPlayback } from "../../../../test-utils/audio"; -import SeekBar from "../../../../../src/components/views/audio_messages/SeekBar"; +import LegacySeekBar from "../../../../../src/components/views/audio_messages/LegacySeekBar"; describe("SeekBar", () => { let playback: Playback; let renderResult: RenderResult; let frameRequestCallback: FrameRequestCallback; - let seekBarRef: RefObject; + let seekBarRef: RefObject; beforeEach(() => { seekBarRef = createRef(); @@ -38,7 +38,7 @@ describe("SeekBar", () => { durationSeconds: 0, timeSeconds: 0, }); - renderResult = render(); + renderResult = render(); }); it("should render correctly", () => { @@ -49,7 +49,7 @@ describe("SeekBar", () => { describe("when rendering a SeekBar", () => { beforeEach(() => { playback = createTestPlayback(); - renderResult = render(); + renderResult = render(); }); it("should render the initial position", () => { @@ -115,7 +115,7 @@ describe("SeekBar", () => { describe("when rendering a disabled SeekBar", () => { beforeEach(async () => { - renderResult = render(); + renderResult = render(); }); it("should render as expected", () => { diff --git a/test/unit-tests/components/views/audio_messages/__snapshots__/SeekBar-test.tsx.snap b/test/unit-tests/components/views/audio_messages/__snapshots__/LegacySeekBar-test.tsx.snap similarity index 100% rename from test/unit-tests/components/views/audio_messages/__snapshots__/SeekBar-test.tsx.snap rename to test/unit-tests/components/views/audio_messages/__snapshots__/LegacySeekBar-test.tsx.snap diff --git a/test/viewmodels/audio/AudioPlayerViewModel-test.tsx b/test/viewmodels/audio/AudioPlayerViewModel-test.tsx new file mode 100644 index 0000000000..b3e5cb99f2 --- /dev/null +++ b/test/viewmodels/audio/AudioPlayerViewModel-test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import EventEmitter from "events"; +import { SimpleObservable } from "matrix-widget-api"; +import { type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { waitFor } from "@testing-library/dom"; + +import { type Playback, PlaybackState } from "../../../src/audio/Playback"; +import { AudioPlayerViewModel } from "../../../src/viewmodels/audio/AudioPlayerViewModel"; + +describe("AudioPlayerViewModel", () => { + let playback: MockedPlayback & Playback; + beforeEach(() => { + playback = new MockedPlayback(PlaybackState.Decoding, 50, 10) as unknown as MockedPlayback & Playback; + }); + + it("should return the snapshot", () => { + const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" }); + expect(vm.getSnapshot()).toMatchObject({ + mediaName: "mediaName", + sizeBytes: 8000, + playbackState: "decoding", + durationSeconds: 50, + playedSeconds: 10, + percentComplete: 20, + error: false, + }); + }); + + it("should toggle the playback state", async () => { + const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" }); + + await vm.togglePlay(); + expect(playback.toggle).toHaveBeenCalled(); + }); + + it("should move the playback on seekbar change", async () => { + const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" }); + await vm.onSeekbarChange({ target: { value: "20" } } as ChangeEvent); + expect(playback.skipTo).toHaveBeenCalledWith(10); // 20% of 50 seconds + }); + + it("should has error=true when playback.prepare fails", async () => { + jest.spyOn(playback, "prepare").mockRejectedValue(new Error("Failed to prepare playback")); + const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" }); + await waitFor(() => expect(vm.getSnapshot().error).toBe(true)); + }); + + it("should handle key down events", () => { + const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" }); + let event = new KeyboardEvent("keydown", { key: " " }) as unknown as ReactKeyboardEvent; + vm.onKeyDown(event); + expect(playback.toggle).toHaveBeenCalled(); + + event = new KeyboardEvent("keydown", { key: "ArrowLeft" }) as unknown as ReactKeyboardEvent; + vm.onKeyDown(event); + expect(playback.skipTo).toHaveBeenCalledWith(10 - 5); // 5 seconds back + + event = new KeyboardEvent("keydown", { key: "ArrowRight" }) as unknown as ReactKeyboardEvent; + vm.onKeyDown(event); + expect(playback.skipTo).toHaveBeenCalledWith(10 + 5); // 5 seconds forward + }); +}); + +/** + * A mocked playback implementation for testing purposes. + * It simulates a playback with a fixed size and allows state changes. + */ +class MockedPlayback extends EventEmitter { + public sizeBytes = 8000; + + public constructor( + public currentState: PlaybackState, + public durationSeconds: number, + public timeSeconds: number, + ) { + super(); + } + + public setState(state: PlaybackState): void { + this.currentState = state; + this.emit("update", state); + } + + public get isPlaying(): boolean { + return this.currentState === PlaybackState.Playing; + } + + public get clockInfo() { + return { + liveData: new SimpleObservable(), + }; + } + + public prepare = jest.fn().mockResolvedValue(undefined); + public skipTo = jest.fn(); + public toggle = jest.fn(); +}