diff --git a/web/src/App.tsx b/web/src/App.tsx index 280cbb9..227ee17 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -38,6 +38,7 @@ 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"; @@ -260,11 +261,13 @@ function App() { const [playerRetrying, setPlayerRetrying] = useState(false); const playerRetryMessage = useRef(""); const playerOrigin = useRef(playerOriginFromHistory()); + const originScrollToRestore = useRef(null); const playerOpenRef = useRef(playerOpen); playerOpenRef.current = playerOpen; const pendingPlayback = useRef(null); const navigationRevision = useRef(0); const cancelPlaybackIntent = useCallback(() => { + originScrollToRestore.current = null; navigationRevision.current++; pendingPlayback.current?.abort(); pendingPlayback.current = null; @@ -310,6 +313,22 @@ function App() { const scrolledExpansion = useRef(""); const selectionRef = useRef(selectedId); selectionRef.current = selectedId; + const restoreOriginScroll = useCallback(() => { + const top = originScrollToRestore.current; + const area = expandedArea.current; + // Search animates its returned details. Wait for their final height so the + // browser can restore positions near the bottom without clamping the scroll. + if (top === null || playerOpenRef.current || area?.style.height !== "auto") + return false; + originScrollToRestore.current = null; + scrolledExpansion.current = selectionRef.current; + window.scrollTo({ top, behavior: "instant" }); + const target = + area.querySelector(".play-action:not(:disabled)") || + area.querySelector("h1"); + target?.focus({ preventScroll: true }); + return true; + }, []); const [catalogGrid, setCatalogGrid] = useState(null); const [gridColumns, setGridColumns] = useState(1); const libraryScroll = useRef(0); @@ -474,6 +493,8 @@ function App() { previousOrigin && new URL(previousOrigin.url, window.location.href).href === window.location.href; + if (!next.player && returningToOrigin && next.item && previousOrigin) + originScrollToRestore.current = previousOrigin.scroll; playerOpenRef.current = next.player; playerOrigin.current = next.player ? playerOriginFromHistory() : null; @@ -493,6 +514,7 @@ function App() { setSelected((old) => (old?.id === next.item ? old : null)); window.requestAnimationFrame(() => window.scrollTo({ + behavior: "instant", top: next.player ? 0 : returningToOrigin && previousOrigin @@ -504,6 +526,9 @@ function App() { window.addEventListener("popstate", restore); return () => window.removeEventListener("popstate", restore); }, [cancelPlaybackIntent]); + useLayoutEffect(() => { + if (!playerOpen) restoreOriginScroll(); + }, [playerOpen, selectedId, selected, restoreOriginScroll]); useEffect(() => { if (!selectedId || selected?.id === selectedId || !status?.configured) return; @@ -985,7 +1010,6 @@ function App() { title: playbackTitle, }); setPlaybackDuration(0); - window.scrollTo({ top: 0 }); }, [liveItems, browseItems, categories, tab, category, cancelPlaybackIntent], ); @@ -1040,14 +1064,14 @@ function App() { searchContext, ]); const stopPlayback = () => { + if (!playerOpenRef.current) return; cancelPlaybackIntent(); playerOpenRef.current = false; const title = playback?.title || selected; const episode = playback?.item.kind === "episode" ? playback.item : null; const origin = playerOrigin.current; - setPlayback(null); - setPlayerOpen(false); - setPlayerError(""); + // Let route restoration close the player and reveal its origin together. + // Closing it before asynchronous history.back() temporarily unmounts Search. if (origin && origin.initialURL && origin.url !== origin.initialURL) { window.history.replaceState({}, "", origin.url); window.dispatchEvent(new PopStateEvent("popstate")); @@ -1195,15 +1219,6 @@ function App() { status?.configured, playerRetry, ]); - useEffect(() => { - if (!playerOpen || !playback) return; - const frame = window.requestAnimationFrame(() => - document - .querySelector(".watch-sidebar .dock-title") - ?.focus({ preventScroll: true }), - ); - return () => window.cancelAnimationFrame(frame); - }, [playerOpen, playback?.item.id]); const playbackCategory = playback && categories.find( @@ -1325,7 +1340,11 @@ function App() { if ( (playerOpenRef.current && playingTitleRef.current === selectedId) || - selectionRef.current !== selectedId || + selectionRef.current !== selectedId + ) + return; + if ( + restoreOriginScroll() || scrolledExpansion.current === selectedId ) return; @@ -2378,6 +2397,14 @@ function PlaybackPlayer({ const historyFinished = useRef(false); const playbackArea = useRef(null); const video = useRef(null); + useLayoutEffect(() => { + const area = playbackArea.current; + if (!isPresent || !area) return; + // Reveal the committed player before loading the stream. An instant scroll + // also cancels any smooth title reveal left over from the browsing view. + area.scrollIntoView({ block: "start", behavior: "instant" }); + area.focus({ preventScroll: true }); + }, [isPresent, selection]); const { style: subtitleStyle } = useSubtitleAppearance(); const sessionId = useRef(null); const seekVersion = useRef(0); @@ -2447,12 +2474,14 @@ 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/components/navigation.css b/web/src/components/navigation.css index 584fd57..93de6a6 100644 --- a/web/src/components/navigation.css +++ b/web/src/components/navigation.css @@ -5,60 +5,62 @@ padding-left: var(--sidebar-width); } .app-sidebar { + --navigation-control-size: 56px; position: fixed; inset: 0 auto 0 0; z-index: 50; - display: flex; - flex-direction: column; - align-items: center; + display: grid; + grid-template-rows: var(--navigation-control-size) minmax(0, 1fr); + gap: 16px; + justify-items: center; width: var(--sidebar-width); - height: 100dvh; - padding: 16px 16px max(16px, env(safe-area-inset-bottom)); + padding: max(16px, env(safe-area-inset-top)) 16px + max(16px, env(safe-area-inset-bottom)); background: transparent; } .sidebar-brand { display: grid; flex: none; place-items: center; - width: 56px; - height: 56px; + width: var(--navigation-control-size); + height: var(--navigation-control-size); padding: 0; border: 0; border-radius: 8px; background: transparent; } .side-navigation { - display: flex; - flex: 1; - flex-direction: column; + display: grid; + grid-template-rows: minmax(0, 1fr) var(--navigation-control-size); + gap: 16px; align-items: center; width: 100%; min-height: 0; } .navigation-main { - position: absolute; - top: 50%; - left: 16px; - right: 16px; display: flex; flex-direction: column; align-items: center; + align-self: center; gap: 16px; - transform: translateY(-50%); + width: 100%; + max-height: 100%; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: none; } .navigation-footer { display: grid; flex: none; place-items: center; width: 100%; - height: 56px; - margin-top: auto; + height: var(--navigation-control-size); } .navigation-icon { --focus-indicator-bottom: 6px; position: relative; - width: 56px; - height: 56px; + width: var(--navigation-control-size, 56px); + height: var(--navigation-control-size, 56px); padding: 0; border: 0; border-radius: 8px; @@ -98,6 +100,14 @@ .mobile-bottom-nav { display: none; } +@media (max-height: 500px) { + .app-sidebar { + --navigation-control-size: 44px; + } + .navigation-main { + gap: 8px; + } +} @media (max-width: 820px) { :root { --sidebar-width: 0px; diff --git a/web/src/lib/viewport.ts b/web/src/lib/viewport.ts new file mode 100644 index 0000000..9abf720 --- /dev/null +++ b/web/src/lib/viewport.ts @@ -0,0 +1,132 @@ +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() { + 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; + + // 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 = () => { + if ( + 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(); + }; + const visibilityChanged = () => { + if (!document.hidden && pending) schedule(); + }; + + 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); + return () => { + listeners.abort(); + clearTimeout(timer); + cancelAnimationFrame(frame); + if (frame) restore(); + }; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 7ed2bfb..a523bd0 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -3,11 +3,17 @@ 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 "@fontsource-variable/manrope"; import "./styles.css"; const stopTrackingInput = trackInputModality(); -if (import.meta.hot) import.meta.hot.dispose(stopTrackingInput); +const stopTrackingViewport = trackViewportOrientation(); +if (import.meta.hot) + import.meta.hot.dispose(() => { + stopTrackingInput(); + stopTrackingViewport(); + }); createRoot(document.getElementById("root")!).render( diff --git a/web/src/styles.css b/web/src/styles.css index 97eb584..ede9b3c 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -62,7 +62,7 @@ html { body { margin: 0; min-width: 320px; - min-height: 100vh; + min-height: 100dvh; background: #0c0d10; } button { @@ -150,7 +150,7 @@ p { .app-shell { position: relative; isolation: isolate; - min-height: 100vh; + min-height: 100dvh; } .library-layout { min-height: 100dvh; diff --git a/web/tests/workflow.spec.ts b/web/tests/workflow.spec.ts index ac25de8..4c370f1 100644 --- a/web/tests/workflow.spec.ts +++ b/web/tests/workflow.spec.ts @@ -862,6 +862,58 @@ 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, }) => { @@ -1198,6 +1250,121 @@ for (const item of [live, movie, series]) { }); } +for (const viewport of [ + { + width: 1440, + height: 900, + search: false, + reducedMotion: "no-preference" as const, + }, + { width: 390, height: 844, search: true, reducedMotion: "reduce" as const }, +]) { + test(`Play reveals the loading player and Stop/Back restore the scrolled origin at ${viewport.width}px`, async ({ + page, + }) => { + const titles = Array.from({ length: 36 }, (_, index) => ({ + ...movie, + id: `coast-${index}`, + name: `Coast ${index}`, + })); + let releaseCreation!: () => void; + const createGate = new Promise((resolve) => { + releaseCreation = resolve; + }); + const api = await mockApi(page, { catalogItems: titles, createGate }); + await page.setViewportSize(viewport); + await page.emulateMedia({ reducedMotion: viewport.reducedMotion }); + await page.goto( + viewport.search + ? "/?view=search§ion=movie&search=Coast" + : "/?section=movie&category=drama-movie", + ); + const title = titles[30]; + await page + .getByRole("button", { name: `Open ${title.name}`, exact: true }) + .click(); + const details = page.getByRole("region", { + name: "Expanded title", + exact: true, + }); + await expect( + details.getByRole("heading", { name: title.name, exact: true }), + ).toBeFocused(); + const play = details.getByRole("button", { name: "Play", exact: true }); + + try { + for (const exit of ["Stop", "Back"] as const) { + await play.scrollIntoViewIfNeeded(); + // Settle any preceding smooth reveal before recording the user's position. + await page.evaluate(() => + window.scrollBy({ top: 0, behavior: "instant" }), + ); + const originURL = page.url(); + const originScroll = await page.evaluate(() => window.scrollY); + expect(originScroll).toBeGreaterThan(viewport.height); + if (exit === "Stop") + await page.screenshot({ + path: test.info().outputPath("01-browsing.png"), + }); + await play.click(); + await expect + .poll(() => api.sessionItemIds.length) + .toBe(exit === "Stop" ? 1 : 2); + const area = page.getByLabel("Video player", { exact: true }); + const bounds = (await area.boundingBox())!; + expect(bounds.y).toBeGreaterThanOrEqual(0); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height); + await expect(area).toBeFocused(); + await expect(page.locator(".playback-loading")).toBeInViewport({ + ratio: 0.99, + }); + if (exit === "Stop") + await page.screenshot({ + path: test.info().outputPath("02-player-loading.png"), + }); + releaseCreation(); + await expect(page.locator(".primary-control")).toBeEnabled(); + await playableVideo(page); + if (exit === "Stop") + await page.getByRole("button", { name: "Stop playback" }).click(); + else await page.goBack(); + await expect(page).toHaveURL(originURL); + await expect(page.locator("video")).toHaveCount(0); + await expect( + details.getByRole("heading", { name: title.name, exact: true }), + ).toBeVisible(); + await expect + .poll(async () => + Math.abs( + (await page.evaluate(() => window.scrollY)) - originScroll, + ), + ) + .toBeLessThanOrEqual(2); + await expect(play).toBeFocused(); + await expect(play).toBeInViewport({ ratio: 0.99 }); + if (exit === "Stop") + await page.screenshot({ + path: test.info().outputPath("03-browsing-restored.png"), + }); + if (viewport.search) + await expect( + page.getByRole("searchbox", { name: "Search library" }), + ).toHaveValue("Coast"); + else + await expect( + page.getByRole("combobox", { name: "Category", exact: true }), + ).toContainText("Drama"); + } + expect(api.sessionItemIds).toEqual([title.id, title.id]); + await expect + .poll(() => api.events.filter((event) => event === "DELETE").length) + .toBe(2); + } finally { + releaseCreation(); + } + }); +} + test("player supports pause, keyboard resume, and fullscreen", async ({ page, }) => { @@ -5481,7 +5648,7 @@ test("category dropdown supports keyboard selection and retains scoped VOD contr await expect(page.getByRole("searchbox")).toHaveCount(0); }); -for (const width of [1440, 821, 320]) { +for (const width of [1440, 320]) { test(`icon navigation reaches Home, Search, VOD, Live TV and Account settings at ${width}px`, async ({ page, }) => { @@ -5553,6 +5720,226 @@ for (const width of [1440, 821, 320]) { }); } +test("sidebar destinations remain reachable after rotation and in short windows", async ({ + page, +}) => { + await mockApi(page); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + await expectHome(page); + await page.setViewportSize({ width: 844, height: 390 }); + const navigation = page.getByRole("navigation", { + name: "Library", + exact: true, + }); + const brand = page.getByRole("button", { name: "Restream home" }); + const account = navigation.getByRole("button", { name: "Account" }); + await expect(navigation).toBeVisible(); + for (const height of [390, 280]) { + await page.setViewportSize({ width: 844, height }); + for (const name of ["Search", "Home", "VOD", "Live TV"]) { + const button = navigation.getByRole("button", { name, exact: true }); + // Focusing also exercises scrolling when the window cannot fit all links. + await button.focus(); + await expect(button).toBeInViewport({ ratio: 1 }); + const bounds = (await button.boundingBox())!; + const logoBounds = (await brand.boundingBox())!; + const accountBounds = (await account.boundingBox())!; + expect(bounds.y).toBeGreaterThanOrEqual(logoBounds.y + logoBounds.height); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(accountBounds.y); + } + await expect(brand).toBeInViewport({ ratio: 1 }); + await expect(account).toBeInViewport({ ratio: 1 }); + } + await navigation.getByRole("button", { name: "Search", exact: true }).click(); + await page.getByRole("searchbox", { name: "Search library" }).fill("Coast"); + await expect( + page.getByRole("button", { name: "Open The Quiet Coast" }), + ).toBeVisible(); + await openSettings(page); + await expect( + page.getByRole("heading", { name: "Settings", exact: true }), + ).toBeVisible(); + await page.setViewportSize({ width: 390, height: 844 }); + await page + .getByRole("navigation", { name: "Primary navigation" }) + .getByRole("button", { name: "Home", exact: true }) + .click(); + await expectHome(page); +}); + +test.describe("tablet rotation", () => { + test.use({ isMobile: true, hasTouch: true }); + + test("Movies, Live TV and Search reflow without reloading or losing selection", async ({ + page, + }) => { + const movies = Array.from({ length: 8 }, (_, index) => ({ + ...movie, + id: `coast-${index}`, + name: `Coast ${index}`, + })); + const channels = Array.from({ length: 6 }, (_, index) => ({ + ...live, + id: `channel-${index}`, + name: `Coast News ${index}`, + })); + const api = await mockApi(page, { catalogItems: [...movies, ...channels] }); + await page.setViewportSize({ width: 768, height: 1024 }); + await page.goto("/?section=movie"); + const main = await page.locator("#main-content").elementHandle(); + + for (const section of ["movie", "live", "search"] as const) { + if (section === "search") await (await openSearch(page)).fill("Coast"); + else await navigateLibrary(page, section); + const grid = + section === "search" + ? page + .getByRole("region", { name: "Movies results" }) + .locator(".catalog-grid") + : page + .locator(".library-content .catalog-grid") + .filter({ visible: true }); + const cards = grid.getByRole("button", { name: /^Open / }); + await expect(cards).toHaveCount( + section === "live" ? channels.length : movies.length, + ); + const rowStart = section === "live" ? 2 : 3; + for (const landscape of [true, false]) { + const width = landscape ? 1024 : 768; + const height = landscape ? 768 : 1024; + const url = page.url(); + await page.setViewportSize({ width, height }); + await expect( + page.getByRole("navigation", { + name: landscape ? "Library" : "Primary navigation", + exact: true, + }), + ).toBeVisible(); + await expect + .poll(async () => { + const first = (await cards.first().boundingBox())!; + const next = (await cards.nth(rowStart).boundingBox())!; + return Math.abs(first.y - next.y) < 1; + }) + .toBe(landscape); + const bounds = (await grid.boundingBox())!; + expect(bounds.x + bounds.width).toBeGreaterThan(width - 60); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(width); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBeLessThanOrEqual(width); + expect( + await main!.evaluate( + (element) => element === document.getElementById("main-content"), + ), + ).toBe(true); + expect(page.url()).toBe(url); + if (section === "search") + await expect( + page.getByRole("searchbox", { name: "Search library" }), + ).toHaveValue("Coast"); + } + } + + // 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]) { test(`long category dropdown scrolls and filters titles without page overflow at ${width}px`, async ({ page,