feat(portals): cinematic series details with the episodes grid in the first screen

Series pages take the same hero as movies: the provider's season marker
moves out of the title into a "Season N" chip, the status chip is localized,
the primary reads Continue / "S02E03 · 18m left" or Play / the next episode,
Trailer opens the modal, and favorites plus a "…" menu complete the row.
The menu carries mark season/series watched, reset progress, open in
external player, copy the next episode's URL, download season, show in
category and hide from Continue Watching — rows a provider cannot serve
are left out. The season container's header is "Episodes" with the season
pills and count; the season actions it used to show live in the menu;
episode cards are flat with a watched bar on the artwork, the plot and
"42 min · 18m left". Cast & crew and Similar follow the episodes. The
Electron and web E2E flows and the season container specs drive the new
menu and presenters; legacy detail styles are removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-02 08:49:36 +02:00
1 parent 075c056ba1
commit c2f5d67120
42 files changed
+1969 -1893

No files matched your search

+3 -1
View File
@@ -244,7 +244,9 @@ 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']) {
@@ -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();
+34 -22
View File
@@ -1104,10 +1104,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})`
);
@@ -1116,27 +1115,44 @@ 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);
});
/** Opens the series "…" menu (if closed) and returns the row with that test id. */
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;
}
async function closeSeriesMenu(page: Page): Promise<void> {
if (await page.locator('.cdk-overlay-backdrop').isVisible().catch(() => false)) {
await page.keyboard.press('Escape');
}
}
test('@stalker series watched toggle — embedded series marks and clears from the header menu', async ({
page,
request,
@@ -1173,14 +1189,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})`
);
@@ -1192,12 +1204,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})`
);
@@ -1205,7 +1217,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);
+42 -22
View File
@@ -656,24 +656,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,8 +698,9 @@ 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')
@@ -795,6 +798,23 @@ test('@xtream season cover — shows the provider season cover and follows the s
// unwatch-all once the whole series is watched, and survives a reload.
// ---------------------------------------------------------------------------
/** Opens the series "…" menu (if closed) and returns the row with that test id. */
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;
}
async function closeSeriesMenu(page: Page): Promise<void> {
if (await page.locator('.cdk-overlay-backdrop').isVisible().catch(() => false)) {
await page.keyboard.press('Escape');
}
}
test('@xtream series watched toggle — marks every season from the header menu, survives reload, and clears again', async ({
page,
request,
@@ -830,17 +850,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 +868,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,7 +1007,9 @@ 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();
const toggle = shell.locator('mat-button-toggle-group');
await expect(card).toBeVisible();