Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 8 additions & 0 deletions .changes/tv-readable-type-and-focus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
type: feature
area: tv
---

The TV app is easier to read from the sofa: text is larger throughout, and
there is one clear focus ring. Selected tabs and categories show a bar, saved
titles show a filled heart, and empty Favorites tabs are dimmed and skipped.
177 changes: 177 additions & 0 deletions apps/tv-e2e/src/design-review.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { expect, test, type Page } from '@playwright/test';
import { workspaceRoot } from '@nx/devkit';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { addSource, nav, start } from './helpers';
import { prepareLiveMedia } from './live-media';
import { enableMarketingTmdb } from './marketing-tmdb';

/**
* Captures the 24 screens of the TV design review 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.
*/
const enabled = process.env['TV_DESIGN_REVIEW'] === '1';
const outDir = join(
workspaceRoot,
process.env['TV_DESIGN_REVIEW_DIR'] ?? 'dist/tv-design-review'
);

async function settle(page: Page) {
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() =>
requestAnimationFrame(() => resolve())
)
)
);
// Lazy artwork outside the viewport may never load; wait briefly only.
await expect
.poll(
() =>
page.evaluate(() =>
Array.from(document.images)
.filter((image) => image.getBoundingClientRect().width)
.every((image) => image.complete)
),
{ timeout: 5000 }
)
.toBe(true)
.catch(() => undefined);
await page.waitForTimeout(400);
}

async function shot(page: Page, name: string) {
await settle(page);
await page.screenshot({ path: join(outDir, `${name}.png`) });
}

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 }) => {
await mkdir(outDir, { recursive: true });
await start(page);
await shot(page, '01-first-launch-sources-empty');

await addSource(page, 'xtream', {
name: 'Demo Xtream',
username: 'marketing',
password: 'marketing',
});
await enableMarketingTmdb(page);
await shot(page, '02-settings-tmdb');
await page
.getByRole('tab', { name: 'Connection', exact: false })
.click();
await shot(page, '03-settings-connection');
await page
.getByRole('tab', { name: 'Appearance', exact: false })
.click();
await shot(page, '04-settings-appearance');

await nav(page, 'Home');
await expect(page.locator('.hero h1')).not.toHaveText(
'Make yourself at home.'
);
await page.locator('.hero button').first().focus();
await shot(page, '05-home-hero');
const firstRailCard = page.locator('.rail .poster-card').first();
await firstRailCard.focus();
await firstRailCard.scrollIntoViewIfNeeded();
await shot(page, '06-home-rails-focus');

await nav(page, 'Movies');
await page.locator('.poster-card').first().focus();
await shot(page, '07-movies-grid');
await page.locator('.poster-card').first().click();
await expect(page.locator('.detail-hero h1')).toBeVisible();
await shot(page, '08-movie-detail');

await nav(page, 'Series');
await page.locator('.poster-card').first().focus();
await shot(page, '09-series-grid');
await page.locator('.poster-card').first().click();
await expect(page.locator('.detail-hero h1')).toHaveText(
'Skyline Sentinels'
);
await shot(page, '10-series-detail');
await page.locator('.episode').first().focus();
await page.locator('.episode').first().scrollIntoViewIfNeeded();
await shot(page, '11-series-detail-episodes');

await nav(page, 'Home');
const live = await prepareLiveMedia(page);
await shot(page, '13-live-channel-list');
await page.locator('.channel-pane-header button').click();
await expect(page.locator('.live-category').first()).toBeVisible();
await shot(page, '12-live-categories');
await page.locator('[data-focus="live-category:"]').click();
await page.locator('.channel').first().click();
await expect
.poll(() => live.evaluate((v: HTMLVideoElement) => v.currentTime))
.toBeGreaterThan(0.1);
await page.getByRole('button', { name: 'Open fullscreen' }).click();
await expect(
page.locator('[data-focus="player-toggle"]')
).toBeFocused();
await shot(page, '15-live-player-controls');
await page.waitForTimeout(6000);
await shot(page, '14-live-player-preview-or-fullscreen');
await page.keyboard.press('Enter');
await page
.getByRole('button', { name: 'Channels', exact: true })
.click();
await expect(
page.getByRole('dialog', { name: 'Channel browser' })
).toBeVisible();
await shot(page, '16-live-channel-drawer');
await page.keyboard.press('Escape');
await page.locator('[data-focus="player-grid"]').click();
await expect(page.locator('.grid-channel').first()).toBeVisible();
await shot(page, '17-live-multi-channel-guide');
await page.keyboard.press('Escape');
await page.keyboard.press('Escape');
await page.keyboard.press('Escape');

await nav(page, 'Movies');
await page.locator('.poster-card').first().click();
await page.locator('[data-focus="favorite"]').click();
await nav(page, 'Movies');
await page.locator('.poster-card').nth(1).click();
await page.locator('[data-focus="favorite"]').click();
await nav(page, 'Movies');
await page.locator('.poster-card').first().click();
await page.locator('[data-focus="detail-play"]').click();
const video = page.locator('video');
await expect
.poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime))
.toBeGreaterThan(0.1);
await page.getByLabel('Playback position').fill('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');
await expect(page.locator('.detail-hero h1')).toBeVisible();
await shot(page, '33-movie-detail-resume-state');

await nav(page, 'Favorites');
await shot(page, '34-favorites-filled');
await nav(page, 'History');
await shot(page, '35-history-filled');
await nav(page, 'Search');
const search = page.getByRole('searchbox').first();
await search.click();
await search.fill('the');
await expect(page.locator('.poster-card').first()).toBeVisible();
await page.locator('.poster-card').first().focus();
await shot(page, '36-search-results');
await nav(page, 'Home');
await page.locator('.hero button').first().focus();
await shot(page, '37-home-with-resume');
});
});
17 changes: 17 additions & 0 deletions apps/tv-e2e/src/episode-layout.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,23 @@ for (const { index, actions } of [
: 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
);
Expand Down
6 changes: 3 additions & 3 deletions apps/tv-e2e/src/tmdb-series.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ test('@tmdb credits and season stills/descriptions preserve episode playback and
.locator('.episode')
.first()
.evaluate((el) => el.getBoundingClientRect().height)
).toBe(260);
).toBe(300);
failSeason = false;
await page.getByRole('button', { name: 'Retry episode metadata' }).click();
await expect(page.locator('.episode-description').first()).toHaveText(
Expand All @@ -155,13 +155,13 @@ test('@tmdb credits and season stills/descriptions preserve episode playback and
.locator('.episode')
.first()
.evaluate((el) => el.getBoundingClientRect().height)
).toBe(480);
).toBe(500);
expect(
await page
.locator('.episode')
.nth(1)
.evaluate((el) => el.getBoundingClientRect().height)
).toBe(260);
).toBe(300);
expect(
await page
.locator('.episode-still')
Expand Down
3 changes: 2 additions & 1 deletion apps/tv-e2e/src/visual-refinement.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ test('compact page headers leave room for posters and remove prototype chrome at
expect(geometry.headingTop).toBeLessThan(65);
expect(geometry.countTop).toBeLessThan(110);
expect(geometry.headingRight).toBeLessThan(geometry.sourceLeft);
expect(geometry.posterTop).toBeLessThan(240);
// The 1920 canvas uses the TV type scale (72px controls, 28px body).
expect(geometry.posterTop).toBeLessThan(width === 1920 ? 300 : 240);
expect(geometry.ratio).toBeCloseTo(1.5, 1);
expect(geometry.overflow).toBe(false);
}
Expand Down
8 changes: 4 additions & 4 deletions apps/tv/src/about.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
margin-top: 0;
}
.about-page h3 {
font-size: 24px;
font-size: var(--tv-type-heading);
font-weight: 500;
margin: 0 0 20px;
}
Expand All @@ -25,11 +25,11 @@
}
.about-row dt {
color: var(--tv-muted);
font-size: 20px;
font-size: var(--tv-type-meta);
margin-bottom: 4px;
}
.about-row dd {
font-size: 24px;
font-size: var(--tv-type-body);
line-height: 1.4;
margin: 0;
overflow-wrap: break-word;
Expand All @@ -38,7 +38,7 @@
margin-top: 28px;
}
.about-project p {
font-size: 22px;
font-size: var(--tv-type-meta);
line-height: 1.5;
overflow-wrap: break-word;
margin: 8px 0;
Expand Down
23 changes: 11 additions & 12 deletions apps/tv/src/dialog.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
justify-content: space-between;
min-width: 0;
width: 100%;
font-size: 24px;
border: 1px solid var(--tv-line);
font-size: var(--tv-type-meta);
border: 2px solid var(--tv-line);
}
.tv-select > span {
overflow: hidden;
Expand Down Expand Up @@ -46,31 +46,31 @@
margin-bottom: 28px;
}
.tv-dialog-header h2 {
font-size: 36px;
font-size: var(--tv-type-heading);
margin: 0 28px 0 0;
}
.tv-dialog-header button {
flex-shrink: 0;
}
.tv-picker-search {
flex-shrink: 0;
font-size: 22px;
font-size: var(--tv-type-meta);
}
.tv-picker-options {
min-height: 0;
overflow-y: auto;
padding: 8px;
margin: -8px;
padding: var(--tv-focus-reach);
margin: calc(-1 * var(--tv-focus-reach));
}
.tv-picker-options button {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 76px;
margin-bottom: 12px;
min-height: var(--tv-list-row);
margin-bottom: var(--tv-space-1);
padding: 16px 24px;
font-size: 28px;
font-size: var(--tv-type-body);
overflow-wrap: break-word;
}
.tv-picker-options button > span:first-child {
Expand All @@ -81,15 +81,14 @@
margin-left: 24px;
}
.tv-picker-options [aria-selected='true'] {
color: var(--tv-accent);
background: var(--tv-bg);
}
.tv-description-text {
min-height: 0;
overflow-y: auto;
padding: 8px;
font-size: 26px;
line-height: 1.6;
font-size: var(--tv-type-body);
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: break-word;
}
Expand Down
Loading
Loading