feat(ui): cinematic movie and series details pages and dashboard hero (#1792)

This commit is contained in:
4gray authored and GitHub committed 2026-10-03 23:08:16 +02:00
1 parent 07c5dffab0
commit ab8460338a
213 files changed
+12494 -3930

No files matched your search

+31 -2
View File
@@ -244,10 +244,18 @@ export async function expectSeriesSurfacesInBothThemes(
const shell = page.locator('app-portal-detail-shell');
await expect
.poll(() =>
rasterizedBorderContrast(shell.locator('.favorite-btn').first())
rasterizedBorderContrast(
shell
.locator('[data-testid="series-favorite-toggle"]')
.first()
)
)
.toBeGreaterThan(1.1);
for (const selector of ['.episode-card', 'mat-button-toggle-group']) {
// The flat card's visible edge is its artwork's hairline.
for (const selector of [
'.episode-card__thumbnail',
'mat-button-toggle-group',
]) {
await expect
.poll(
async () =>
@@ -278,3 +286,24 @@ export async function expectSeriesSurfacesInBothThemes(
.click();
}
}
/** Opens the series "…" menu (if closed) and returns the row with that test id. */
export async function seriesMenuRow(page: Page, rowTestId: string) {
const row = page.locator(`[data-test-id="${rowTestId}"]`);
if (!(await row.isVisible().catch(() => false))) {
await page.locator('[data-testid="series-more-menu"]').click();
}
await expect(row).toBeVisible({ timeout: 15_000 });
return row;
}
/** Closes an open series menu without choosing a row. */
export async function closeSeriesMenu(page: Page): Promise<void> {
// The panel, not the backdrop: a backdrop locator can match a second
// overlay and then fail the strict check, leaving the menu open.
const panel = page.locator('[data-test-id="vod-more-menu"]');
if (await panel.isVisible().catch(() => false)) {
await page.keyboard.press('Escape');
}
await expect(panel).toBeHidden({ timeout: 10_000 });
}
+4 -3
View File
@@ -393,7 +393,7 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
await page.keyboard.press('Escape');
await expect(inlineVideo(page)).toHaveCount(0);
const playButton = page.locator('[data-test-id="m3u-vod-play"]');
const playButton = page.locator('[data-testid="m3u-vod-play"]');
await expect(playButton).toBeVisible();
// Browse → Play remounts the engine without a channel change.
@@ -417,12 +417,13 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
.click();
await expect(inlineVideo(page)).toHaveCount(0);
await expect(headerBack).toHaveCount(0);
// The hero keeps its own inset.
// The hero keeps its own inset (32px, or 20px in a pane narrower than
// 760px) in both states.
expect(
await shell
.locator('.hero__content')
.evaluate((el) => getComputedStyle(el).paddingInlineStart)
).toBe('40px');
).toMatch(/^(20|32)px$/);
await shell.focus();
await page.keyboard.press('Escape');
await expect(playButton).toBeVisible();
+7 -9
View File
@@ -29,9 +29,7 @@ async function addXtreamPortal(page: Page): Promise<void> {
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
await dialog
.getByRole('radio', { name: /Xtream credentials/i })
.click();
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
await dialog.locator('#title').fill('Self-hosted Xtream');
await dialog.locator('#serverUrl').fill(XTREAM_MOCK_SERVER);
@@ -165,9 +163,9 @@ test('@self-hosted Xtream portal loads through web-backend proxy', async ({
await expect(vodItem).toBeVisible({ timeout: 30_000 });
await vodItem.click();
await expect(page).toHaveURL(/\/workspace\/xtreams\/[^/]+\/vod\/\d+\/\d+/);
await expect(
page.getByRole('button', { name: 'Play', exact: true })
).toBeVisible({ timeout: 15_000 });
const primaryAction = page.locator('[data-testid="vod-primary-action"]');
await expect(primaryAction).toBeVisible({ timeout: 15_000 });
await expect(primaryAction).toContainText('Play');
expect(
consoleErrors.filter((message) =>
@@ -262,9 +260,9 @@ test('@self-hosted M3U User-Agent reaches the provider on import and refresh', a
const row = sourceRowByTitle(page, 'Protected M3U');
await row.hover();
await row.locator('.refresh-btn').click();
await expect(page.locator('.mat-mdc-snack-bar-label').last()).toContainText(
'updated'
);
await expect(
page.locator('.mat-mdc-snack-bar-label').last()
).toContainText('updated');
await page.goto(catalogUrl);
await expect(page.getByText('1. Refreshed UA Channel')).toBeVisible();
expect(received.slice(1)).toEqual([userAgent, userAgent]);
@@ -147,6 +147,7 @@ export async function verifyStalkerSeasonMarkers(
'src',
new RegExp(`/season-${season}\\.jpg$`)
);
await page.locator('[data-testid="series-more-menu"]').click();
await page.locator('[data-test-id="toggle-season-watched"]').click();
await expect(page.locator('.episode-card--watched')).toHaveCount(1);
await page.getByRole('button', { name: 'Back', exact: true }).click();
+23 -23
View File
@@ -1,5 +1,10 @@
import { type APIRequestContext, type Page } from '@playwright/test';
import { expectSeriesSurfacesInBothThemes, setInputValue } from './e2e-helpers';
import {
closeSeriesMenu,
expectSeriesSurfacesInBothThemes,
seriesMenuRow,
setInputValue,
} from './e2e-helpers';
import {
verifyStalkerCategorySearch,
verifyStalkerPlaybackCategoryReturn,
@@ -1110,10 +1115,9 @@ test('@stalker season watched toggle — embedded series marks and clears every
await expectSeriesSurfacesInBothThemes(page, testInfo);
// The season header's bulk toggle (data-test-id with a dash — getByTestId
// The hero menu's bulk toggle (data-test-id with a dash — getByTestId
// only matches data-testid in this suite) counts every unwatched episode.
const seasonToggle = page.locator('[data-test-id="toggle-season-watched"]');
await expect(seasonToggle).toBeVisible();
let seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText(
`Mark season as watched (${episodeCount})`
);
@@ -1122,25 +1126,25 @@ test('@stalker season watched toggle — embedded series marks and clears every
await seasonToggle.click();
// All episodes get full-progress positions: the button flips and every
// All episodes get full-progress positions: the row flips and every
// episode card plus per-episode toggle shows the watched state.
await expect(seasonToggle).toContainText('Mark season as unwatched', {
timeout: 15_000,
});
await expect(watchedCards).toHaveCount(episodeCount);
await expect(watchedCards).toHaveCount(episodeCount, { timeout: 15_000 });
await expect(
page.locator(
'[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched'
)
).toHaveCount(episodeCount);
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as unwatched');
// Second click clears every episode's position again.
await seasonToggle.click();
await expect(watchedCards).toHaveCount(0, { timeout: 15_000 });
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText(
`Mark season as watched (${episodeCount})`,
{ timeout: 15_000 }
`Mark season as watched (${episodeCount})`
);
await expect(watchedCards).toHaveCount(0);
await closeSeriesMenu(page);
});
test('@stalker series watched toggle — embedded series marks and clears from the header menu', async ({
@@ -1179,14 +1183,10 @@ test('@stalker series watched toggle — embedded series marks and clears from t
})
).toBeVisible({ timeout: 10_000 });
// The ⋮ trigger sits after the view toggle (data-test-id with a dash —
// getByTestId only matches data-testid in this suite; the menu item
// renders into the CDK overlay).
const menuTrigger = page.locator('[data-test-id="series-watch-menu"]');
await expect(menuTrigger).toBeVisible();
await menuTrigger.click();
const seriesToggle = page.locator('[data-test-id="toggle-series-watched"]');
await expect(seriesToggle).toBeVisible();
// The series row sits in the hero's "…" menu (data-test-id with a dash —
// getByTestId only matches data-testid in this suite; the row renders
// into the CDK overlay).
let seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText(
`Mark series as watched (${episodeCount})`
);
@@ -1198,12 +1198,12 @@ test('@stalker series watched toggle — embedded series marks and clears from t
await expect(watchedCards).toHaveCount(episodeCount, {
timeout: 15_000,
});
await menuTrigger.click();
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText('Mark series as unwatched');
await seriesToggle.click();
await expect(watchedCards).toHaveCount(0, { timeout: 15_000 });
await menuTrigger.click();
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText(
`Mark series as watched (${episodeCount})`
);
@@ -1211,7 +1211,7 @@ test('@stalker series watched toggle — embedded series marks and clears from t
const detailUrl = page.url();
await page.keyboard.press('Escape');
await expect(seriesToggle).toBeHidden();
await menuTrigger.focus();
await page.locator('[data-testid="series-more-menu"]').focus();
await page.keyboard.press('Escape');
await expect(page.locator('app-portal-detail-shell')).toHaveCount(0);
await expect(page).toHaveURL(detailUrl);
+38 -27
View File
@@ -1,8 +1,10 @@
import type { APIRequestContext, Page } from '@playwright/test';
import { expect, test } from './fixtures';
import {
closeSeriesMenu,
pressTab,
rasterizedBorderContrast,
seriesMenuRow,
setInputValue,
surfaceContrast,
waitForScrollIdle,
@@ -656,24 +658,26 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
// Serial details: default scenario has 3 seasons × 8 episodes and no
// playback positions yet, so season 1 is auto-selected fully unwatched.
const seasonToggle = page.locator('[data-test-id="toggle-season-watched"]');
await expect(seasonToggle).toBeVisible({ timeout: 15_000 });
// The season toggle is a row of the hero's "…" menu.
let seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as watched (8)');
await closeSeriesMenu(page);
const episodeCards = page.locator('.episode-card');
await expect(episodeCards).toHaveCount(8, { timeout: 10_000 });
const watchedCards = page.locator('.episode-card--watched');
await expect(watchedCards).toHaveCount(0);
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await seasonToggle.click();
// Full-progress rows land for all 8 episodes: the button flips, every
// Full-progress rows land for all 8 episodes: the row flips, every
// episode card gets the watched state, and the per-episode toggles show
// the filled check.
await expect(seasonToggle).toContainText('Mark season as unwatched', {
timeout: 15_000,
});
await expect(watchedCards).toHaveCount(8);
await expect(watchedCards).toHaveCount(8, { timeout: 15_000 });
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as unwatched');
await closeSeriesMenu(page);
await expect(
page.locator(
'[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched'
@@ -696,26 +700,31 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
// fresh mount auto-selects the earliest season with unwatched episodes
// (issue #1441), so season 2 opens while season 1's tab keeps the check.
await page.reload();
await expect(seasonToggle).toBeVisible({ timeout: 20_000 });
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as watched (8)');
await closeSeriesMenu(page);
await expect(watchedCards).toHaveCount(0, { timeout: 10_000 });
await expect(
seasonTabs.first().locator('.season-tabs__done')
).toBeVisible();
// Season 1 itself still holds the 8 persisted rows.
// Season 1 itself still holds the 8 persisted rows. The row lives in
// the overlay, so it is reacquired after every close.
await seasonTabs.first().click();
await expect(watchedCards).toHaveCount(8, { timeout: 10_000 });
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as unwatched', {
timeout: 15_000,
});
await expect(watchedCards).toHaveCount(8, { timeout: 10_000 });
// Second click clears every episode's position again.
await seasonToggle.click();
await expect(watchedCards).toHaveCount(0, { timeout: 15_000 });
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as watched (8)', {
timeout: 15_000,
});
await expect(watchedCards).toHaveCount(0);
await closeSeriesMenu(page);
await expect(page.locator('.season-tabs__done')).toHaveCount(0);
});
@@ -830,17 +839,13 @@ test('@xtream series watched toggle — marks every season from the header menu,
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
await seriesCard.click();
// The ⋮ series menu sits at the end of the season-header actions row
// (data-test-id with a dash — getByTestId only matches data-testid in
// this suite; the menu item renders into the CDK overlay).
const menuTrigger = page.locator('[data-test-id="series-watch-menu"]');
await expect(menuTrigger).toBeVisible({ timeout: 15_000 });
// The series row sits in the hero's "…" menu (data-test-id with a dash —
// getByTestId only matches data-testid in this suite; the row renders
// into the CDK overlay).
const seasonTabs = page.locator('.season-tabs__pill');
await expect(seasonTabs).toHaveCount(3);
await menuTrigger.click();
const seriesToggle = page.locator('[data-test-id="toggle-series-watched"]');
await expect(seriesToggle).toBeVisible();
let seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText('Mark series as watched (24)');
await seriesToggle.click();
@@ -852,27 +857,29 @@ test('@xtream series watched toggle — marks every season from the header menu,
await expect(page.locator('.episode-card--watched')).toHaveCount(8);
// The action now offers unwatch-all.
await menuTrigger.click();
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText('Mark series as unwatched');
await page.keyboard.press('Escape');
await closeSeriesMenu(page);
// PWA persistence: positions live in localStorage, so a reload must come
// back fully watched across all seasons.
await page.reload();
await expect(menuTrigger).toBeVisible({ timeout: 20_000 });
await expect(page.locator('[data-testid="series-more-menu"]')).toBeVisible({
timeout: 20_000,
});
await expect(page.locator('.season-tabs__done')).toHaveCount(3, {
timeout: 10_000,
});
// Unwatch-all clears every season again.
await menuTrigger.click();
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText('Mark series as unwatched');
await seriesToggle.click();
await expect(page.locator('.season-tabs__done')).toHaveCount(0, {
timeout: 15_000,
});
await expect(page.locator('.episode-card--watched')).toHaveCount(0);
await menuTrigger.click();
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText('Mark series as watched (24)');
});
@@ -989,8 +996,12 @@ for (const theme of ['light', 'dark']) {
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
const favorite = shell.locator('.favorite-btn').first();
const favorite = shell
.locator('[data-testid="series-favorite-toggle"]')
.first();
const card = shell.locator('.episode-card').first();
// The card is flat; its visible edge is the artwork's hairline.
const artwork = card.locator('.episode-card__thumbnail');
const toggle = shell.locator('mat-button-toggle-group');
await expect(card).toBeVisible();
await expect(shell.locator('.hero__content')).toHaveCSS('opacity', '1');
@@ -1000,7 +1011,7 @@ for (const theme of ['light', 'dark']) {
await expect
.poll(() => rasterizedBorderContrast(favorite))
.toBeGreaterThan(1.1);
for (const surface of [card, toggle]) {
for (const surface of [artwork, toggle]) {
await expect
.poll(async () => (await surfaceContrast(surface)).border)
.toBeGreaterThan(1.15);
@@ -1015,7 +1026,7 @@ for (const theme of ['light', 'dark']) {
});
await card.hover();
await expect
.poll(async () => (await surfaceContrast(card)).border)
.poll(async () => (await surfaceContrast(artwork)).border)
.toBeGreaterThan(1.15);
await page
.getByRole('radio', { name: 'List view', exact: true })