mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
feat(ui): cinematic movie and series details pages and dashboard hero (#1792)
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user