From eb4d868930cb3543ecab6a32837d023d01f24855 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 13 Oct 2026 16:23:32 -0700 Subject: [PATCH 1/3] Fix compact web player volume visibility and cover responsive boundaries --- js/app/components/mobile/player.tsx | 4 +- js/app/components/mobile/ui.tsx | 4 + js/e2e-web/README.md | 10 ++ js/e2e-web/flows/player-breakpoints.spec.ts | 116 ++++++++++++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 js/e2e-web/flows/player-breakpoints.spec.ts diff --git a/js/app/components/mobile/player.tsx b/js/app/components/mobile/player.tsx index 7cd2347c2..de869d07b 100644 --- a/js/app/components/mobile/player.tsx +++ b/js/app/components/mobile/player.tsx @@ -127,7 +127,9 @@ function PlayerWithProvider( ? (videoBoxHeight ?? 0) + safeTop + (Platform.OS !== "web" ? Math.max(0, chromeFade.value * 68 - 12) : 0) - : screenHeight, + : Platform.OS === "web" + ? "100%" + : screenHeight, })); const websocketConnected = useLivestreamStore((x) => x.websocketConnected); diff --git a/js/app/components/mobile/ui.tsx b/js/app/components/mobile/ui.tsx index 389014c7b..85c61ec15 100644 --- a/js/app/components/mobile/ui.tsx +++ b/js/app/components/mobile/ui.tsx @@ -319,6 +319,10 @@ export function MobileUi({ /> {shouldShowChatSidePanel && setShowChat && ( { setShowChat(!showChat); }} diff --git a/js/e2e-web/README.md b/js/e2e-web/README.md index 036620ba5..08a19346a 100644 --- a/js/e2e-web/README.md +++ b/js/e2e-web/README.md @@ -64,6 +64,16 @@ live video sits above chat. It reveals the player chrome, exercises mute and fullscreen entry/exit, and verifies that faded controls reveal instead of accepting an unseen tap. +`player-breakpoints` exercises live playback at 23 widths in portrait and +landscape, including 1080 × 722 and immediately below, at, and above the theme +breakpoints (640, 768, 1024, 1280, 1536), sidebar boundary (981), and desktop +player boundary (1201). It checks that the mute button and volume slider are +inside the viewport and change the actual video element's audio state. Side-chat +layouts repeat those checks with chat open, closed, and reopened. This catches +controls clipped below the web shell's header: the compact player's height must +fill its available parent, not the whole browser window. These controls are +web-only; native live playback uses device volume controls. + `auto-publish-vods` saves and reloads the automatic publishing preference, checks that the stream form's Create tab and Privacy & Security share it, including the popout form, and hides the control without VOD beta access or diff --git a/js/e2e-web/flows/player-breakpoints.spec.ts b/js/e2e-web/flows/player-breakpoints.spec.ts new file mode 100644 index 000000000..a0fc834ac --- /dev/null +++ b/js/e2e-web/flows/player-breakpoints.spec.ts @@ -0,0 +1,116 @@ +import { expect, test } from "@playwright/test"; + +// Web-only: native live playback uses the device's hardware volume controls. +// Cover both sides of theme, sidebar (981), and desktop-player (1201) boundaries. +const widths = [ + 1080, 320, 639, 640, 641, 767, 768, 769, 980, 981, 982, 1023, 1024, 1025, + 1199, 1200, 1201, 1279, 1280, 1281, 1535, 1536, 1537, +]; + +for (const orientation of ["portrait", "landscape"] as const) { + test(`player-breakpoints: ${orientation} resize boundaries`, async ({ + page, + }, testInfo) => { + test.setTimeout(600_000); + const viewport = (width: number) => ({ + width, + height: + orientation === "portrait" + ? width + 200 + : width === 1080 + ? 722 + : Math.min(568, Math.round((width * 3) / 4)), + }); + await page.setViewportSize(viewport(widths[0])); + await page.goto("/"); + await page.getByTestId("home-stream-card").first().click(); + const video = page.locator("video").first(); + await expect(video).toBeVisible(); + await expect + .poll(() => + video.evaluate((element: HTMLVideoElement) => element.readyState), + ) + .toBeGreaterThanOrEqual(2); + + const revealControls = async () => { + const box = await video.boundingBox(); + if (!box) throw new Error("player video has no bounding box"); + await page.mouse.move(0, 0); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + }; + + const exerciseVolume = async () => { + await revealControls(); + const mute = page.getByRole("button", { name: "Mute", exact: true }); + await expect(mute).toBeInViewport(); + await mute.click({ timeout: 5_000 }); + await expect + .poll(() => + video.evaluate((element: HTMLVideoElement) => element.muted), + ) + .toBe(true); + const unmute = page.getByRole("button", { + name: "Unmute", + exact: true, + }); + await unmute.hover(); + const slider = page.getByRole("slider"); + await expect(slider).toBeInViewport(); + await unmute.click(); + await expect + .poll(() => + video.evaluate((element: HTMLVideoElement) => element.muted), + ) + .toBe(false); + await slider.focus(); + await slider.press("Home"); + await expect + .poll(() => + video.evaluate((element: HTMLVideoElement) => element.muted), + ) + .toBe(true); + await slider.press("ArrowRight"); + await expect + .poll(() => + video.evaluate((element: HTMLVideoElement) => element.volume), + ) + .toBeGreaterThan(0); + await expect + .poll(() => + video.evaluate((element: HTMLVideoElement) => element.muted), + ) + .toBe(false); + }; + + for (const width of widths) { + const size = viewport(width); + await test.step(`${size.width}x${size.height}`, async () => { + await page.setViewportSize(size); + await exerciseVolume(); + if (orientation === "landscape" && width >= 768) { + const collapseChat = page.getByRole("button", { + name: "Collapse chat", + exact: true, + }); + await expect(collapseChat).toBeInViewport(); + await collapseChat.click(); + await expect(collapseChat).not.toBeInViewport(); + await exerciseVolume(); + await revealControls(); + await page + .getByRole("button", { name: "Show chat", exact: true }) + .click(); + await expect(collapseChat).toBeInViewport(); + await exerciseVolume(); + } + if (width === 1080 && orientation === "landscape") { + await revealControls(); + await testInfo.attach("1080x722-chat-open", { + body: await page.screenshot(), + contentType: "image/png", + }); + } + }); + } + }); +} -- knotmirror 0.0.1 From 64e12214ff7d58b2a247bf2e8dafb5af8483e1ab Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 13 Oct 2026 16:41:54 -0700 Subject: [PATCH 2/3] Require complete volume-control visibility in breakpoint coverage --- js/app/components/mobile/desktop-ui/volume-slider.tsx | 1 + js/e2e-web/README.md | 6 +++--- js/e2e-web/flows/player-breakpoints.spec.ts | 8 ++++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/js/app/components/mobile/desktop-ui/volume-slider.tsx b/js/app/components/mobile/desktop-ui/volume-slider.tsx index bc95106c9..11d97be2c 100644 --- a/js/app/components/mobile/desktop-ui/volume-slider.tsx +++ b/js/app/components/mobile/desktop-ui/volume-slider.tsx @@ -102,6 +102,7 @@ export function VolumeSlider({ sliderWidth = 200 }: { sliderWidth?: number }) { asChild > { await revealControls(); const mute = page.getByRole("button", { name: "Mute", exact: true }); - await expect(mute).toBeInViewport(); + await expect(mute).toBeInViewport({ ratio: 1 }); await mute.click({ timeout: 5_000 }); await expect .poll(() => @@ -53,9 +53,13 @@ for (const orientation of ["portrait", "landscape"] as const) { name: "Unmute", exact: true, }); + await expect(unmute).toBeInViewport({ ratio: 1 }); await unmute.hover(); const slider = page.getByRole("slider"); - await expect(slider).toBeInViewport(); + await expect(slider).toBeInViewport({ ratio: 1 }); + await expect(page.getByTestId("player-volume-track")).toBeInViewport({ + ratio: 1, + }); await unmute.click(); await expect .poll(() => -- knotmirror 0.0.1 From 851f9db6a55b2bf19aa46f00b84b070c9a3f010b Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 13 Oct 2026 17:43:22 -0700 Subject: [PATCH 3/3] Wait for video remounts while testing responsive player transitions --- js/e2e-web/flows/player-breakpoints.spec.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/js/e2e-web/flows/player-breakpoints.spec.ts b/js/e2e-web/flows/player-breakpoints.spec.ts index b8396aa7e..e36261315 100644 --- a/js/e2e-web/flows/player-breakpoints.spec.ts +++ b/js/e2e-web/flows/player-breakpoints.spec.ts @@ -25,18 +25,21 @@ for (const orientation of ["portrait", "landscape"] as const) { await page.goto("/"); await page.getByTestId("home-stream-card").first().click(); const video = page.locator("video").first(); - await expect(video).toBeVisible(); - await expect - .poll(() => - video.evaluate((element: HTMLVideoElement) => element.readyState), - ) - .toBeGreaterThanOrEqual(2); const revealControls = async () => { - const box = await video.boundingBox(); - if (!box) throw new Error("player video has no bounding box"); await page.mouse.move(0, 0); - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + // Resizing can remount the video. Wait for its replacement's geometry; + // the player overlay, not the video element, receives the pointer. + await expect(async () => { + const box = await video.boundingBox(); + if (!box) throw new Error("player video has no bounding box"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + }).toPass({ timeout: 30_000 }); + await expect + .poll(() => + video.evaluate((element: HTMLVideoElement) => element.readyState), + ) + .toBeGreaterThanOrEqual(2); }; const exerciseVolume = async () => { -- knotmirror 0.0.1