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

@@ -21,9 +21,9 @@ import {
// lane. Checked at a wide and a narrow desktop window and at phone width,
// where Back takes the context drawer toggle's slot.
//
// The "Seasons and Episodes" heading must also stay on one line: the detail
// pane is far narrower than the window beside the rail and category panel,
// and the heading used to wrap beside its actions.
// The "Episodes" heading must also stay on one line: the detail pane is far
// narrower than the window beside the rail and category panel, and the
// heading used to wrap beside its actions.
// ---------------------------------------------------------------------------
const widths = [1280, 780, 375];
@@ -36,8 +36,8 @@ const widestHeading = (
join(__dirname, `../../web/src/assets/i18n/${widestLocale}.json`),
'utf8'
)
) as { PORTALS: { SEASONS_AND_EPISODES: string } }
).PORTALS.SEASONS_AND_EPISODES;
) as { PORTALS: { DETAIL: { EPISODES: string } } }
).PORTALS.DETAIL.EPISODES;
const detailUrlPattern = /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/;
@@ -47,14 +47,16 @@ function headerBack(page: Page): Locator {
/** Line boxes of the heading's text; 1 means it did not wrap. */
function headingLineCount(page: Page): Promise<number> {
return page.locator('.section-title').evaluate((heading) => {
const range = document.createRange();
range.selectNodeContents(heading);
const lines = [...range.getClientRects()]
.filter((rect) => rect.width > 0)
.map((rect) => Math.round(rect.top));
return new Set(lines).size;
});
return page
.locator('[data-test-id="episodes-heading"]')
.evaluate((heading) => {
const range = document.createRange();
range.selectNodeContents(heading);
const lines = [...range.getClientRects()]
.filter((rect) => rect.width > 0)
.map((rect) => Math.round(rect.top));
return new Set(lines).size;
});
}
/**
@@ -174,9 +176,12 @@ async function expectWidestHeadingOnOneLine(
await saveSettings(page);
await page.goBack();
await page.waitForURL(detailUrl);
await expect(page.locator('.section-title')).toHaveText(widestHeading, {
timeout: 20_000,
});
await expect(page.locator('[data-test-id="episodes-heading"]')).toHaveText(
widestHeading,
{
timeout: 20_000,
}
);
for (const width of [...widths, 700]) {
await page.setViewportSize({ width, height: 800 });
expect(
@@ -203,9 +208,11 @@ async function openFirstSeries(page: Page): Promise<string> {
await page.getByRole('link', { name: 'Series', exact: true }).click();
await clickFirstGridListCard(page);
await page.waitForURL(detailUrlPattern);
await expect(page.locator('.section-title')).toBeVisible({
timeout: 20_000,
});
await expect(page.locator('[data-test-id="episodes-heading"]')).toBeVisible(
{
timeout: 20_000,
}
);
await expect(page.locator('.episode-card').first()).toBeVisible({
timeout: 20_000,
});
@@ -642,12 +642,12 @@ test.describe('Electron Downloads', () => {
const providerDetail = app.mainWindow.locator(
'main app-portal-detail-shell'
);
await expect(
providerDetail.getByRole('button', {
name: 'Play',
exact: true,
})
).toBeVisible();
// The primary action reads "Play" plus its runtime line.
const primaryAction = providerDetail.locator(
'[data-testid="vod-primary-action"]'
);
await expect(primaryAction).toBeVisible();
await expect(primaryAction).toContainText('Play');
await expect(
providerDetail.getByText('Offline', { exact: true })
).toHaveCount(0);
@@ -107,6 +107,29 @@ async function openSeriesDetail(
return clickedTitle;
}
/** Opens the series "…" menu and returns the row with the given test id. */
async function seriesMenuRow(page: Page, rowTestId: string) {
const trigger = page.locator('[data-testid="series-more-menu"]');
await expect(trigger).toBeVisible({ timeout: 20_000 });
const row = page.locator(`[data-test-id="${rowTestId}"]`);
if (!(await row.isVisible().catch(() => false))) {
await trigger.click();
}
await expect(row).toBeVisible({ timeout: 10_000 });
return row;
}
/** Closes an open menu without choosing a row. */
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 });
}
test.describe('Electron Season Watched Toggle', () => {
test('@xtream @persistence @electron marks a season watched via the batch IPC, survives an app restart, and clears again', async ({
dataDir,
@@ -137,13 +160,14 @@ test.describe('Electron Season Watched Toggle', () => {
// Season 1 auto-selects fully unwatched: the toggle offers to mark
// all 8 episodes and neither the DOM nor SQLite knows any watched
// episode yet.
const seasonToggle = app.mainWindow.getByTestId(
let seasonToggle = await seriesMenuRow(
app.mainWindow,
'toggle-season-watched'
);
await expect(seasonToggle).toBeVisible({ timeout: 20_000 });
await expect(seasonToggle).toContainText(
`Mark season as watched (${seasonEpisodeCount})`
);
await closeSeriesMenu(app.mainWindow);
const episodeCards = app.mainWindow.locator('.episode-card');
const watchedCards = app.mainWindow.locator(
@@ -157,16 +181,26 @@ test.describe('Electron Season Watched Toggle', () => {
await readEpisodePositions(app.mainWindow, playlistId)
).toEqual([]);
seasonToggle = await seriesMenuRow(
app.mainWindow,
'toggle-season-watched'
);
await seasonToggle.click();
// The batch save flips the button, marks every card, and fills
// The batch save flips the row, marks every card, and fills
// the per-episode toggles; the untouched second season stays
// unmarked while the selected season's tab shows the check.
await expect(seasonToggle).toContainText(
'Mark season as unwatched',
{ timeout: 15_000 }
await expect(watchedCards).toHaveCount(seasonEpisodeCount, {
timeout: 15_000,
});
seasonToggle = await seriesMenuRow(
app.mainWindow,
'toggle-season-watched'
);
await expect(watchedCards).toHaveCount(seasonEpisodeCount);
await expect(seasonToggle).toContainText(
'Mark season as unwatched'
);
await closeSeriesMenu(app.mainWindow);
await expect(
app.mainWindow.locator(watchedEpisodeToggleSelector)
).toHaveCount(seasonEpisodeCount);
@@ -217,7 +251,8 @@ test.describe('Electron Season Watched Toggle', () => {
seriesTitle
);
const restartedToggle = app.mainWindow.getByTestId(
let restartedToggle = await seriesMenuRow(
app.mainWindow,
'toggle-season-watched'
);
const restartedWatchedCards = app.mainWindow.locator(
@@ -226,10 +261,10 @@ test.describe('Electron Season Watched Toggle', () => {
// With season 1 completed, the fresh mount auto-selects the
// earliest season with unwatched episodes (issue #1441): season 2
// opens unwatched while season 1's tab keeps the check.
await expect(restartedToggle).toBeVisible({ timeout: 20_000 });
await expect(restartedToggle).toContainText(
`Mark season as watched (${seasonEpisodeCount})`
);
await closeSeriesMenu(app.mainWindow);
await expect(restartedWatchedCards).toHaveCount(0, {
timeout: 10_000,
});
@@ -241,6 +276,10 @@ test.describe('Electron Season Watched Toggle', () => {
// Season 1 itself was re-read from SQLite fully watched.
await restartedSeasonTabs.first().click();
restartedToggle = await seriesMenuRow(
app.mainWindow,
'toggle-season-watched'
);
await expect(restartedToggle).toContainText(
'Mark season as unwatched',
{ timeout: 15_000 }
@@ -253,11 +292,17 @@ test.describe('Electron Season Watched Toggle', () => {
// Unmark: the clear batch removes every row again, in the DOM and
// in SQLite.
await restartedToggle.click();
await expect(restartedToggle).toContainText(
`Mark season as watched (${seasonEpisodeCount})`,
{ timeout: 15_000 }
await expect(restartedWatchedCards).toHaveCount(0, {
timeout: 15_000,
});
restartedToggle = await seriesMenuRow(
app.mainWindow,
'toggle-season-watched'
);
await expect(restartedWatchedCards).toHaveCount(0);
await expect(restartedToggle).toContainText(
`Mark season as watched (${seasonEpisodeCount})`
);
await closeSeriesMenu(app.mainWindow);
await expect(
app.mainWindow.locator(watchedEpisodeToggleSelector)
).toHaveCount(0);
@@ -274,14 +319,11 @@ test.describe('Electron Season Watched Toggle', () => {
// the same batch IPC — one action, 24 full-progress rows across
// seasons 1–3 (no second restart: durability of the channel is
// already proven above).
const seriesMenuTrigger =
app.mainWindow.getByTestId('series-watch-menu');
await expect(seriesMenuTrigger).toBeVisible();
await seriesMenuTrigger.click();
const seriesToggle = app.mainWindow.getByTestId(
await closeSeriesMenu(app.mainWindow);
const seriesToggle = await seriesMenuRow(
app.mainWindow,
'toggle-series-watched'
);
await expect(seriesToggle).toBeVisible();
await expect(seriesToggle).toContainText(
`Mark series as watched (${seasonCount * seasonEpisodeCount})`
);
@@ -299,15 +341,15 @@ test.describe('Electron Season Watched Toggle', () => {
app.mainWindow,
playlistId
);
expect(
new Set(seriesRows.map((row) => row.seasonNumber))
).toEqual(new Set([1, 2, 3]));
expect(
seriesRows.every((row) => row.positionSeconds > 0)
).toBe(true);
expect(new Set(seriesRows.map((row) => row.seasonNumber))).toEqual(
new Set([1, 2, 3])
);
expect(seriesRows.every((row) => row.positionSeconds > 0)).toBe(
true
);
// Unwatch-all clears all 24 rows again through the clear batch.
await seriesMenuTrigger.click();
await seriesMenuRow(app.mainWindow, 'toggle-series-watched');
await expect(seriesToggle).toContainText(
'Mark series as unwatched'
);
@@ -160,7 +160,12 @@ test.describe('Electron Series Download Queue', () => {
})
.toEqual(expectedEpisodeQueue.slice(0, 2));
const seasonAction = app.mainWindow.getByTestId('download-season');
await app.mainWindow
.locator('[data-testid="series-more-menu"]')
.click();
const seasonAction = app.mainWindow.locator(
'[data-test-id="download-season"]'
);
await expect(seasonAction).toBeVisible();
await expect(seasonAction).toContainText('Download season (2)');
await expect(seasonAction).toBeEnabled();
@@ -177,8 +182,13 @@ test.describe('Electron Series Download Queue', () => {
})
.toEqual(expectedEpisodeQueue);
// Choosing the row closed the menu; reopen it to read the row.
await app.mainWindow
.locator('[data-testid="series-more-menu"]')
.click();
await expect(seasonAction).toBeDisabled();
await expect(seasonAction).toContainText('Download season (0)');
await app.mainWindow.keyboard.press('Escape');
await waitForQueueStabilityInterval();
expect(await readEpisodeQueue(app.mainWindow)).toEqual(
@@ -62,14 +62,20 @@ test.describe('VOD multi-source', () => {
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
);
// Discovery is lazy, so the chip appears after the detail view.
const chip = app.mainWindow.locator('app-vod-sources-chip button');
await expect(chip).toBeVisible({ timeout: 20000 });
// The badge counts every copy across all playlists — the route's
// own plus the same film in the other portal.
await expect(chip).toContainText('2');
// Discovery is lazy, so the "Other sources" row of the "…" menu
// appears after the detail view.
const more = app.mainWindow.locator('[data-testid="vod-more-menu"]');
await expect(more).toBeVisible({ timeout: 20000 });
await more.click();
const sourcesRow = app.mainWindow.locator(
'[data-test-id="vod-menu-sources"]'
);
await expect(sourcesRow).toBeVisible({ timeout: 20000 });
// The hint counts the alternatives: the same film in the other
// portal, not the route's own copy.
await expect(sourcesRow).toContainText('1');
await chip.click();
await sourcesRow.click();
// Both the playing source and the alternative are listed.
const rows = app.mainWindow.locator('app-vod-source-row');
@@ -87,7 +93,7 @@ test.describe('VOD multi-source', () => {
}
});
test('shows no sources chip when only one playlist has the movie', async ({
test('offers no other sources when only one playlist has the movie', async ({
dataDir,
request,
}) => {
@@ -122,8 +128,14 @@ test.describe('VOD multi-source', () => {
await expect(
app.mainWindow.locator('.play-btn').first()
).toBeVisible({ timeout: 20000 });
await app.mainWindow
.locator('[data-testid="vod-more-menu"]')
.click();
await expect(
app.mainWindow.locator('app-vod-sources-chip')
app.mainWindow.locator('[data-test-id="vod-menu-copy-url"]')
).toBeVisible();
await expect(
app.mainWindow.locator('[data-test-id="vod-menu-sources"]')
).toHaveCount(0);
} finally {
await closeElectronApp(app);