From 9480dcd70666dd729ef3b8b4abce85e9064765ae Mon Sep 17 00:00:00 2001 From: agent-pushpinderbal Date: Thu, 22 Oct 2026 00:59:32 +0000 Subject: [PATCH 1/2] Recover the actual Chrome iPad viewport width after fullscreen --- web/src/App.tsx | 12 +- web/src/lib/viewport.ts | 168 +++++++-------------- web/src/main.tsx | 4 +- web/tests/workflow.spec.ts | 294 +++++++++++++++++++------------------ 4 files changed, 205 insertions(+), 273 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 227ee17..e1ca077 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -38,7 +38,6 @@ import { } from "./components/watch-suggestions"; import { useWatchHistory, type WatchHistoryEntry } from "./watch-history"; import { createContentPolicy } from "./content-preferences"; -import { requestViewportRecovery } from "./lib/viewport"; import { Spinner } from "./components/spectrumui/spinner-dependencies"; import InfiniteScroll from "./components/spectrumui/infinite-scroll"; import { AnimatedAlert } from "./components/spectrumui/animated-alert"; @@ -1343,11 +1342,12 @@ function App() { selectionRef.current !== selectedId ) return; - if ( - restoreOriginScroll() || - scrolledExpansion.current === selectedId - ) + if (originScrollToRestore.current !== null) { + // Motion restores height:auto after the completion callback. + window.requestAnimationFrame(restoreOriginScroll); return; + } + if (scrolledExpansion.current === selectedId) return; scrolledExpansion.current = selectedId; expandedArea.current?.scrollIntoView({ block: "start", @@ -2474,14 +2474,12 @@ function PlaybackPlayer({ action: "play" | "pause"; } | null>(null); const feedbackSequence = useRef(0); - useEffect(() => () => requestViewportRecovery(), []); useLayoutEffect(() => { if (isPresent) return; saveProgressRef.current(); onFlushProgress(); pendingPlayUrlRef.current = null; clearMediaRef.current?.(); - requestViewportRecovery(); }, [isPresent, onFlushProgress]); useEffect(() => { if (!isPresent) return; diff --git a/web/src/lib/viewport.ts b/web/src/lib/viewport.ts index 9abf720..cbe8271 100644 --- a/web/src/lib/viewport.ts +++ b/web/src/lib/viewport.ts @@ -1,132 +1,64 @@ -const viewportCheckEvent = "restream:viewport-check"; - -export function requestViewportRecovery() { - window.dispatchEvent(new Event(viewportCheckEvent)); -} - -/** Recover stale mobile viewport constraints after rotation or fullscreen playback. */ -export function trackViewportOrientation() { +/** Recover Chrome on iPad retaining its portrait viewport after fullscreen. */ +export function trackViewportRecovery() { const viewport = document.querySelector( 'meta[name="viewport"]', ); - const touch = - navigator.maxTouchPoints > 0 || - window.matchMedia("(any-pointer: coarse)").matches; - if (!viewport || !touch) return () => {}; - - const content = viewport.content; - let timer: ReturnType | undefined; - let frame = 0; - let pending = false; - let fullscreenExitPending = false; - let nativeFullscreen: HTMLVideoElement | null = null; - const fullscreenDocument = document as Document & { - webkitFullscreenElement?: Element | null; - }; - - const restore = () => { - viewport.content = content; - frame = 0; - }; - const resized = () => { - if (pending) schedule(); - }; - const schedule = () => { - pending = true; - clearTimeout(timer); - // Fullscreen and orientation events can precede iOS browser chrome resizing. - timer = setTimeout(() => { - if (nativeFullscreen && !nativeFullscreen.isConnected) - nativeFullscreen = null; - if ( - document.hidden || - document.fullscreenElement || - fullscreenDocument.webkitFullscreenElement || - nativeFullscreen - ) - return; - pending = false; - const width = document.documentElement.clientWidth; - const landscape = screen.orientation - ? screen.orientation.type.startsWith("landscape") - : Math.abs(window.orientation) === 90; - const screenWidth = landscape - ? Math.max(screen.width, screen.height) - : Math.min(screen.width, screen.height); - const scale = window.visualViewport?.scale ?? 1; - // Fullscreen can leave the orientation reading stale as well as the viewport. - const needsRecovery = - (fullscreenExitPending || Math.abs(width - screenWidth) > 1) && - Math.abs(scale - 1) < 0.01; - fullscreenExitPending = false; - if (!needsRecovery) return; + if (!viewport || !/CriOS\//.test(navigator.userAgent)) return () => {}; - // Reapply device-width through a temporary explicit width. This asks the - // browser to recalculate its viewport without reloading React or locking zoom. - viewport.content = content.replace( - /width\s*=\s*device-width/i, - `width=${width}`, - ); - cancelAnimationFrame(frame); - frame = requestAnimationFrame(restore); - }, 300); - }; - const fullscreenChanged = () => { + const original = viewport.content; + let appliedWidth = 0; + let timer: number | undefined; + const update = () => { + const width = window.outerWidth; + const scale = window.visualViewport?.scale ?? 1; + // iOS can fit the previous explicit width to a smaller window on rotation. + const fittedToWindow = + appliedWidth > width && Math.abs(scale - width / appliedWidth) < 0.01; + const video = document.querySelector("video") as + (HTMLVideoElement & { webkitDisplayingFullscreen?: boolean }) | null; if ( + !width || + document.hidden || document.fullscreenElement || - fullscreenDocument.webkitFullscreenElement || - nativeFullscreen - ) { - clearTimeout(timer); - if (frame) { - cancelAnimationFrame(frame); - restore(); - } - } else { - fullscreenExitPending = true; - schedule(); - } - }; - const nativeFullscreenBegan = (event: Event) => { - if (!(event.target instanceof HTMLVideoElement)) return; - nativeFullscreen = event.target; - fullscreenChanged(); - }; - const nativeFullscreenEnded = () => { - nativeFullscreen = null; - fullscreenExitPending = true; - schedule(); + video?.webkitDisplayingFullscreen || + (Math.abs(scale - 1) > 0.01 && !fittedToWindow) + ) + return; + if ( + (!appliedWidth || appliedWidth === width) && + Math.abs(window.innerWidth - width) <= 1 + ) + return; + + // On the affected iPad, outerWidth correctly becomes 1180 while innerWidth, + // screen.width and screen.orientation remain at their portrait values. + // Keep this explicit width in sync with subsequent rotation/window changes. + appliedWidth = width; + viewport.content = original.replace( + /width\s*=\s*[^,;]+/i, + `width=${width}`, + ); }; - const visibilityChanged = () => { - if (!document.hidden && pending) schedule(); + const schedule = () => { + window.clearTimeout(timer); + timer = window.setTimeout(update, 300); }; - const listeners = new AbortController(); const options = { signal: listeners.signal }; - screen.orientation?.addEventListener("change", schedule, options); - window.addEventListener("orientationchange", schedule, options); - window.addEventListener(viewportCheckEvent, schedule, options); - window.addEventListener("resize", resized, options); - window.visualViewport?.addEventListener("resize", resized, options); - document.addEventListener("fullscreenchange", fullscreenChanged, options); - document.addEventListener( - "webkitfullscreenchange", - fullscreenChanged, - options, - ); - document.addEventListener("webkitbeginfullscreen", nativeFullscreenBegan, { - ...options, - capture: true, - }); - document.addEventListener("webkitendfullscreen", nativeFullscreenEnded, { - ...options, - capture: true, - }); - document.addEventListener("visibilitychange", visibilityChanged, options); + for (const event of ["resize", "orientationchange"]) + window.addEventListener(event, schedule, options); + for (const event of [ + "fullscreenchange", + "webkitendfullscreen", + "visibilitychange", + ]) + document.addEventListener(event, schedule, { ...options, capture: true }); + window.visualViewport?.addEventListener("resize", schedule, options); + schedule(); + return () => { listeners.abort(); - clearTimeout(timer); - cancelAnimationFrame(frame); - if (frame) restore(); + window.clearTimeout(timer); + if (appliedWidth) viewport.content = original; }; } diff --git a/web/src/main.tsx b/web/src/main.tsx index a523bd0..1bd0be1 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -3,12 +3,12 @@ import { createRoot } from "react-dom/client"; import App from "./App"; import { AuthGate } from "./auth"; import { trackInputModality } from "./lib/input-modality"; -import { trackViewportOrientation } from "./lib/viewport"; +import { trackViewportRecovery } from "./lib/viewport"; import "@fontsource-variable/manrope"; import "./styles.css"; const stopTrackingInput = trackInputModality(); -const stopTrackingViewport = trackViewportOrientation(); +const stopTrackingViewport = trackViewportRecovery(); if (import.meta.hot) import.meta.hot.dispose(() => { stopTrackingInput(); diff --git a/web/tests/workflow.spec.ts b/web/tests/workflow.spec.ts index 4c370f1..e37080c 100644 --- a/web/tests/workflow.spec.ts +++ b/web/tests/workflow.spec.ts @@ -862,58 +862,6 @@ async function playableVideo(page: Page, position = 30, edge = 100) { ); } -async function emulateStaleViewport(page: Page) { - const state = await page.evaluateHandle(() => { - const viewport = document.querySelector( - 'meta[name="viewport"]', - )!; - const original = viewport.content; - const changes: string[] = []; - const orientation = Object.getOwnPropertyDescriptor( - screen.orientation, - "type", - ); - Object.defineProperty(screen.orientation, "type", { - configurable: true, - value: "landscape-primary", - }); - const observer = new MutationObserver(() => changes.push(viewport.content)); - observer.observe(viewport, { attributes: true }); - return { - original, - changes, - cleanup() { - observer.disconnect(); - viewport.content = original; - if (orientation) - Object.defineProperty(screen.orientation, "type", orientation); - else Reflect.deleteProperty(screen.orientation, "type"); - }, - }; - }); - return { - changes: () => state.evaluate((value) => value.changes), - async expectRecovered() { - await expect - .poll(() => - state.evaluate( - (value) => - value.changes.length >= 2 && - value.changes.at(-1) === value.original, - ), - ) - .toBe(true); - const content = await state.evaluate((value) => value.original); - expect(content).toContain("width=device-width"); - expect(content).not.toMatch(/maximum-scale|user-scalable=no/); - }, - async dispose() { - await state.evaluate((value) => value.cleanup()); - await state.dispose(); - }, - }; -} - test("LIVE greys behind the stream and jumps to the latest seek point while resuming", async ({ page, }) => { @@ -5768,6 +5716,154 @@ test("sidebar destinations remain reachable after rotation and in short windows" await expectHome(page); }); +test.describe("Chrome iPad viewport recovery", () => { + test.use({ + isMobile: true, + hasTouch: true, + viewport: { width: 820, height: 1180 }, + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/156 Version/27.0 Safari/605.1.15", + }); + + test("fullscreen exit restores the landscape layout and later portrait browsing without restarting playback", async ({ + page, + browserName, + }) => { + const api = await mockApi(page); + await page.goto("/?view=search§ion=movie&search=Coast"); + await page.getByRole("button", { name: "Open The Quiet Coast" }).click(); + await expect( + page + .getByRole("region", { name: "Expanded title", exact: true }) + .getByRole("heading", { name: movie.name, exact: true }), + ).toBeFocused(); + await page.getByRole("button", { name: "Play", exact: true }).click(); + await expect(page.locator(".primary-control")).toBeEnabled(); + await playableVideo(page); + const video = await page.locator("video").elementHandle(); + const playerURL = page.url(); + const viewport = page.locator('meta[name="viewport"]'); + const original = await viewport.getAttribute("content"); + await page.clock.install(); + + const reportWindowWidth = async (width: number) => { + // Reproduce the measured Chrome mismatch: its outer window is landscape, + // while the real CSS viewport and orientation still have portrait width. + // Only the outer window reading is overridden; layout must actually reflow. + await page.evaluate((value) => { + Object.defineProperty(window, "outerWidth", { + configurable: true, + value, + }); + window.dispatchEvent(new Event("resize")); + }, width); + }; + // Desktop WebKit's mobile emulation does not provide iPad's element + // fullscreen host. Exercise native-exit handling there; Chromium also uses + // the real element-fullscreen controls. + const fullscreenModes = + browserName === "chromium" ? ["element", "native"] : ["native"]; + for (const fullscreen of fullscreenModes) { + const beforeFullscreen = await viewport.getAttribute("content"); + await page.setViewportSize({ width: 820, height: 682 }); + if (fullscreen === "element") { + await page + .getByRole("button", { name: "Full screen", exact: true }) + .click(); + await expect + .poll(() => page.evaluate(() => !!document.fullscreenElement)) + .toBe(true); + } else { + await video!.evaluate((element) => { + Object.defineProperty(element, "webkitDisplayingFullscreen", { + configurable: true, + value: true, + }); + element.dispatchEvent(new Event("webkitbeginfullscreen")); + }); + } + await reportWindowWidth(1180); + await page.clock.fastForward(500); + await expect(viewport).toHaveAttribute("content", beforeFullscreen!); + + if (fullscreen === "element") + await page + .getByRole("button", { name: "Exit full screen", exact: true }) + .click(); + else + await video!.evaluate((element) => { + Object.defineProperty(element, "webkitDisplayingFullscreen", { + configurable: true, + value: false, + }); + element.dispatchEvent(new Event("webkitendfullscreen")); + }); + await page.clock.fastForward(500); + await expect( + page.getByRole("navigation", { name: "Library", exact: true }), + ).toBeVisible(); + await expect + .poll(() => page.evaluate(() => document.documentElement.clientWidth)) + .toBe(1180); + const bounds = (await page.locator("#main-content").boundingBox())!; + expect(bounds.x + bounds.width).toBe(1180); + expect(page.url()).toBe(playerURL); + expect( + await video!.evaluate( + (element) => element === document.querySelector("video"), + ), + ).toBe(true); + expect(api.sessionItemIds).toEqual([movie.id]); + expect(api.events).not.toContain("DELETE"); + if (fullscreen === "element") + await page.screenshot({ + path: test.info().outputPath("recovered-landscape.png"), + }); + + if (fullscreen === "element") { + // Do not reset pinch zoom when the window changes. Resume width updates + // after the user returns to normal zoom. + await page.evaluate(() => + Object.defineProperty(visualViewport, "scale", { + configurable: true, + value: 2, + }), + ); + await reportWindowWidth(820); + await page.clock.fastForward(500); + await expect + .poll(() => page.evaluate(() => document.documentElement.clientWidth)) + .toBe(1180); + await page.evaluate(() => { + Reflect.deleteProperty(visualViewport!, "scale"); + visualViewport!.dispatchEvent(new Event("resize")); + }); + } + await page.setViewportSize({ width: 820, height: 1180 }); + await reportWindowWidth(820); + await page.clock.fastForward(500); + await expect( + page.getByRole("navigation", { + name: "Primary navigation", + exact: true, + }), + ).toBeVisible(); + await expect + .poll(() => page.evaluate(() => document.documentElement.clientWidth)) + .toBe(820); + } + expect(original).toContain("width=device-width"); + await page + .getByRole("button", { name: "Stop playback", exact: true }) + .click(); + await expect(page.locator("video")).toHaveCount(0); + await expect( + page.getByRole("searchbox", { name: "Search library" }), + ).toHaveValue("Coast"); + expect(api.sessionItemIds).toEqual([movie.id]); + }); +}); + test.describe("tablet rotation", () => { test.use({ isMobile: true, hasTouch: true }); @@ -5842,102 +5938,8 @@ test.describe("tablet rotation", () => { } } - // Emulate iPad Chrome reporting a rotated screen while retaining the old - // portrait viewport. The recovery must request fresh viewport constraints - // without losing the user's search or leaving a fixed width / zoom limit. - const viewport = await emulateStaleViewport(page); - try { - await page.evaluate(() => - window.dispatchEvent(new Event("orientationchange")), - ); - await viewport.expectRecovered(); - } finally { - await viewport.dispose(); - } - await expect( - page.getByRole("searchbox", { name: "Search library" }), - ).toHaveValue("Coast"); - expect( - await main!.evaluate( - (element) => element === document.getElementById("main-content"), - ), - ).toBe(true); expect(api.events).not.toContain("POST"); }); - - test("leaving iPad fullscreen recovers the viewport without restarting playback or losing Search", async ({ - page, - }) => { - const api = await mockApi(page); - await page.setViewportSize({ width: 768, height: 1024 }); - await page.goto("/"); - await (await openSearch(page)).fill("Coast"); - await page.getByRole("button", { name: "Open The Quiet Coast" }).click(); - await page.getByRole("button", { name: "Play", exact: true }).click(); - await playableVideo(page); - const video = await page.locator("video").elementHandle(); - const playerURL = page.url(); - await page.clock.install(); - for (const delayBeforeExit of [5000, 0]) { - // Native iPad video fullscreen does not populate document.fullscreenElement. - await video!.evaluate((element) => - element.dispatchEvent(new Event("webkitbeginfullscreen")), - ); - const viewport = await emulateStaleViewport(page); - try { - // Rotate while fullscreen, then exit either after settling or immediately. - await page.evaluate(() => - window.dispatchEvent(new Event("orientationchange")), - ); - await page.clock.fastForward(delayBeforeExit); - expect(await viewport.changes()).toEqual([]); - - // Chrome can return both a stale portrait viewport and orientation reading. - // Done must recover the page even without another orientation event. - await page.evaluate(() => - Object.defineProperty(screen.orientation, "type", { - configurable: true, - value: "portrait-primary", - }), - ); - await video!.evaluate((element) => - element.dispatchEvent(new Event("webkitendfullscreen")), - ); - await viewport.expectRecovered(); - expect(page.url()).toBe(playerURL); - expect( - await video!.evaluate( - (element) => element === document.querySelector("video"), - ), - ).toBe(true); - expect(api.sessionItemIds).toEqual([movie.id]); - expect(api.events).not.toContain("DELETE"); - } finally { - await viewport.dispose(); - } - } - - const closingViewport = await emulateStaleViewport(page); - try { - await page.getByRole("button", { name: "Stop playback" }).click(); - await closingViewport.expectRecovered(); - await expect(page.locator("video")).toHaveCount(0); - await expect( - page.getByRole("searchbox", { name: "Search library" }), - ).toHaveValue("Coast"); - await expect - .poll(() => api.events.filter((event) => event === "DELETE").length) - .toBe(1); - } finally { - await closingViewport.dispose(); - } - await page.setViewportSize({ width: 1024, height: 768 }); - await expect( - page.getByRole("navigation", { name: "Library", exact: true }), - ).toBeVisible(); - const bounds = (await page.locator("#main-content").boundingBox())!; - expect(bounds.x + bounds.width).toBe(1024); - }); }); for (const width of [1440, 320]) { From 4ecaca4f4e66076453cbb8cd87537752ea3faf6e Mon Sep 17 00:00:00 2001 From: agent-pushpinderbal Date: Thu, 22 Oct 2026 01:04:43 +0000 Subject: [PATCH 2/2] Keep the last visible Search grid measurement during playback --- web/src/App.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index e1ca077..85b615f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -650,11 +650,13 @@ function App() { useEffect(() => { if (!catalogGrid) return; - const measure = () => + const measure = () => { + if (!catalogGrid.clientWidth) return; setGridColumns( getComputedStyle(catalogGrid).gridTemplateColumns.split(" ").length || 1, ); + }; const observer = new ResizeObserver(measure); measure(); observer.observe(catalogGrid);