Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changes/tv-home-detail-episodes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
type: feature
area: tv
---

On the TV app, Continue watching now starts on the first screen of Home. Movie
and series pages keep one row of actions with rare ones in a ⋯ menu, and the
watched and remaining time match the progress bar. Series show episodes as a
horizontal rail under the actions, with progress and a watched check on each.
20 changes: 11 additions & 9 deletions apps/tv-e2e/src/comfort-integration.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { expect, test, type Page } from '@playwright/test';
import type { UserData } from '@iptvnator/tv/util';
import { addSource, backend, chooseTv, nav, start } from './helpers';
import {
addSource,
backend,
chooseTv,
moreAction,
nav,
start,
} from './helpers';
import { playLive, prepareLiveMedia } from './live-media';

async function saved(page: Page): Promise<UserData> {
Expand Down Expand Up @@ -118,9 +125,7 @@ test('TV daily comfort an older delayed launch cannot borrow a newer manual watc
try {
await page.locator('[data-focus="detail-play"]').click();
await expect.poll(() => requests).toBe(1);
await page
.getByRole('button', { name: 'Mark watched', exact: true })
.click();
await moreAction(page, 'Mark watched');
await expect(page.locator('.watch-status').first()).toContainText(
'Watched'
);
Expand Down Expand Up @@ -270,11 +275,8 @@ test('TV daily comfort VOD resume ended Next and Live Previous share one player
await nav(page, 'Series');
await page.locator('.poster-card').first().click();
await chooseTv(page, 'Season', '2');
const watchActions = page
.locator('.episode-entry [role="combobox"]')
.first();
const actionLabel = (await watchActions.getAttribute('aria-label'))!;
await chooseTv(page, actionLabel, 'reset');
await page.locator('.episode').first().focus();
await moreAction(page, 'Reset progress', '[data-focus="episode-options"]');
await expect(primary).toContainText('Continue · S2 · E1');
await nav(page, 'Home');
await page.reload();
Expand Down
34 changes: 32 additions & 2 deletions apps/tv-e2e/src/design-review.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { prepareLiveMedia } from './live-media';
import { enableMarketingTmdb } from './marketing-tmdb';

/**
* Captures the 24 screens of the TV design review at 1920×1080 with the
* Captures the TV design review screens (24 plus two menus) at 1920×1080 with the
* fictional marketing catalog. Opt-in: set TV_DESIGN_REVIEW=1 (and optionally
* TV_DESIGN_REVIEW_DIR) so ordinary E2E runs skip it.
*/
Expand Down Expand Up @@ -51,7 +51,7 @@ test.describe('TV design review capture', () => {
test.skip(!enabled, 'Set TV_DESIGN_REVIEW=1 to capture review screens');
test.setTimeout(600000);

test('captures the 24 review screens', async ({ page }) => {
test('captures the design review screens', async ({ page }) => {
await mkdir(outDir, { recursive: true });
await start(page);
await shot(page, '01-first-launch-sources-empty');
Expand Down Expand Up @@ -101,6 +101,14 @@ test.describe('TV design review capture', () => {
await page.locator('.episode').first().focus();
await page.locator('.episode').first().scrollIntoViewIfNeeded();
await shot(page, '11-series-detail-episodes');
await page.keyboard.press('ArrowDown');
await expect(
page.locator('[data-focus="episode-options"]')
).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.getByRole('dialog')).toBeVisible();
await shot(page, '39-series-episode-options');
await page.keyboard.press('Escape');

await nav(page, 'Home');
const live = await prepareLiveMedia(page);
Expand Down Expand Up @@ -150,14 +158,36 @@ test.describe('TV design review capture', () => {
.poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime))
.toBeGreaterThan(0.1);
await page.getByLabel('Playback position').fill('4');
await expect
.poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime))
.toBeGreaterThanOrEqual(4);
await page.getByRole('button', { name: 'Pause', exact: false }).focus();
await shot(page, '31-movie-player-overlay');
await page.getByLabel('Playback position').focus();
await shot(page, '32-movie-player-controls');
await page.getByRole('button', { name: 'Pause', exact: false }).click();
await page.keyboard.press('Escape');
// The 30 s sample may have ended during the captures, so play it once
// more from the start and stop at 8 s for a meaningful resume state.
await page.locator('[data-focus="detail-start-over"]').click();
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
await expect
.poll(
() => video.evaluate((v: HTMLVideoElement) => v.currentTime),
{
timeout: 20000,
}
)
.toBeGreaterThanOrEqual(8);
// Back stops playback and saves the position.
await page.mouse.move(1900, 1070);
await page.keyboard.press('Escape');
await expect(page.locator('.detail-hero h1')).toBeVisible();
await page.locator('[data-focus="detail-play"]').focus();
await shot(page, '33-movie-detail-resume-state');
await page.locator('[data-focus="detail-more"]').click();
await expect(page.getByRole('dialog')).toBeVisible();
await shot(page, '38-movie-detail-more-actions');
await page.keyboard.press('Escape');

await nav(page, 'Favorites');
await shot(page, '34-favorites-filled');
Expand Down
94 changes: 27 additions & 67 deletions apps/tv-e2e/src/episode-layout.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { expect, test } from '@playwright/test';
import { fillTvField, addSource, nav, start } from './helpers';

for (const { index, actions } of [
{ index: 72, actions: false },
{ index: 180, actions: true },
])
test(`@tmdb delayed episode stills preserve ${actions ? 'watch actions' : 'playback focus'} at episode ${index + 1} and its viewport anchor`, async ({
for (const index of [30, 120])
test(`@tmdb a long season stays a windowed rail; delayed stills keep focus on episode ${index + 1}`, async ({
page,
}) => {
await start(page);
Expand Down Expand Up @@ -93,75 +90,38 @@ for (const { index, actions } of [
await nav(page, 'Series');
await page.locator('.poster-card').first().click();
await expect.poll(() => !!release).toBe(true);
await expect(
page.locator('.virtual-collection[data-loaded="200"]')
).toBeVisible();
const columns = await page
.locator('.episode-grid')
.evaluate(
(el) =>
getComputedStyle(el).gridTemplateColumns.split(' ').length
);
await page.locator('.episode').first().focus();
for (let next = columns; next <= index; next += columns) {
await page.keyboard.press('ArrowDown');
await expect(
page.locator('.episode-entry .tv-select:focus')
).toHaveCount(1);
await page.keyboard.press('ArrowDown');
const rail = page.locator('.episode-rail');
await expect(rail.locator('.episode').first()).toBeVisible();
await rail.locator('.episode').first().focus();
for (let next = 1; next <= index; next++) {
await page.keyboard.press('ArrowRight');
await expect(page.locator('.episode:focus')).toHaveAttribute(
'data-virtual-index',
'data-episode-index',
String(next)
);
}
const chosen = page.locator(`.episode[data-virtual-index="${index}"]`);
const chosen = rail.locator(`[data-episode-index="${index}"]`);
await expect(chosen).toBeFocused();
const focused = actions
? chosen.locator('..').getByRole('combobox')
: chosen;
if (actions) await page.keyboard.press('ArrowDown');
await expect(focused).toBeFocused();
// Leave room below for the taller still card so the anchor, not the
// focused-card visibility rule, decides the scroll after reflow.
await page.locator('main').evaluate(
(main, top) => {
main.scrollTop += top - main.clientHeight / 4;
},
await chosen.evaluate((el) => el.getBoundingClientRect().top)
);
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() =>
requestAnimationFrame(() => resolve())
)
)
);
await expect(focused).toBeFocused();
const before = await chosen.evaluate(
(el) => el.getBoundingClientRect().top
// Only a window around the selected episode is in the DOM.
expect(await rail.locator('.episode').count()).toBeLessThanOrEqual(24);
await expect(page.locator('.episode-focus')).toContainText(
`Anchored episode ${index + 1}`
);
// Layout position in the rail: the focus scale transition does not count.
const position = () =>
chosen.evaluate(
(el) => el.offsetLeft - (el.parentElement?.scrollLeft ?? 0)
);
const before = await position();
release!();
await expect(page.locator('.rich-episodes')).toBeVisible();
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() =>
requestAnimationFrame(() => resolve())
)
)
);
await expect(focused).toBeFocused();
await expect
.poll(() =>
chosen.evaluate(
(el, top) => Math.abs(el.getBoundingClientRect().top - top),
before
)
)
.toBeLessThan(2);
expect(await page.locator('.episode').count()).toBeLessThanOrEqual(30);
if (actions) await page.keyboard.press('ArrowUp');
await expect(chosen.locator('.episode-still')).toBeVisible();
await expect(chosen).toBeFocused();
expect(Math.abs((await position()) - before)).toBeLessThan(2);
await page.keyboard.press('ArrowDown');
await expect(
page.locator('[data-focus="episode-options"]')
).toBeFocused();
await page.keyboard.press('ArrowUp');
await expect(chosen).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.locator('.player-bottom')).toContainText(
Expand Down
12 changes: 12 additions & 0 deletions apps/tv-e2e/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ export async function cardAction(
.getByRole('button', { name, exact: true })
.click();
}
/** Runs a rare viewing action from a detail page's ⋯ or episode options. */
export async function moreAction(
page: Page,
name: string,
opener = '[data-focus="detail-more"]'
) {
await page.locator(opener).click();
await page
.getByRole('dialog')
.getByRole('button', { name, exact: true })
.click();
}
export async function start(page: Page) {
await page.route('**/*', async (route) => {
const url = route.request().url();
Expand Down
5 changes: 4 additions & 1 deletion apps/tv-e2e/src/home-feature.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ test('home features three unfinished titles with manual wrap, remote focus, deta
});
expect(geometry.bottom).toBeLessThan(geometry.rail);
expect(geometry.bottom).toBeLessThan(900);
// Continue watching starts on the first screen, right under the hero.
expect(geometry.rail).toBeLessThan(width === 1920 ? 760 : 1080);
expect(geometry.overflow).toBe(false);
}
await page.locator('[data-focus="hero-resume"]').click();
Expand Down Expand Up @@ -545,7 +547,8 @@ test('Home retains saved series-wide artwork without using episode stills and pr
await addSource(page, 'xtream');
const title = await watch(page, 0, true);
await expect(page.locator('.hero h1')).toHaveText(title);
await expect(page.locator('.hero p')).toHaveText(overview);
// While resuming, the description lives in Details, not in the hero.
await expect(page.locator('.hero p')).toHaveCount(0);
await expect(page.locator('.page-backdrop img')).toHaveAttribute(
'src',
savedWide
Expand Down
15 changes: 7 additions & 8 deletions apps/tv-e2e/src/tmdb-series.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,11 @@ test('@tmdb credits and season stills/descriptions preserve episode playback and
page.getByRole('button', { name: 'Retry episode metadata' })
).toBeVisible();
await expect(page.locator('.episode').first()).toBeVisible();
expect(
await page
.locator('.episode')
.first()
.evaluate((el) => el.getBoundingClientRect().height)
).toBe(300);
// Episode cards keep one size whether or not a still arrives.
const cardHeight = await page
.locator('.episode')
.first()
.evaluate((el) => el.getBoundingClientRect().height);
failSeason = false;
await page.getByRole('button', { name: 'Retry episode metadata' }).click();
await expect(page.locator('.episode-description').first()).toHaveText(
Expand All @@ -155,13 +154,13 @@ test('@tmdb credits and season stills/descriptions preserve episode playback and
.locator('.episode')
.first()
.evaluate((el) => el.getBoundingClientRect().height)
).toBe(500);
).toBe(cardHeight);
expect(
await page
.locator('.episode')
.nth(1)
.evaluate((el) => el.getBoundingClientRect().height)
).toBe(300);
).toBe(cardHeight);
expect(
await page
.locator('.episode-still')
Expand Down
Loading
Loading