diff --git a/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts b/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts index 607303622..07d43b8db 100644 --- a/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts +++ b/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts @@ -17,8 +17,8 @@ import { // --------------------------------------------------------------------------- // The detail shell's sticky Back control floats over its scroll owner. While // it is shown, every content column reserves the control's lane, so no -// scroll position may put it over the "Seasons and Episodes" heading or the -// inline player's top-left corner. Tested at a wide and a narrow window; the +// scroll position may put it over the "Episodes" heading or the inline +// player's top-left corner. Tested at a wide and a narrow window; the // workspace rail and category panel leave the detail pane far narrower than // either, and the heading used to wrap beside its actions (two lines at // 1280px, three at 780px) with its first word under the arrow. @@ -40,8 +40,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; type Sweep = { overlaps: string[]; @@ -68,7 +68,7 @@ function sweepDetail(shell: Locator): Promise { element.scrollTop = top; const arrow = back.getBoundingClientRect(); const title = element - .querySelector('.section-title') + .querySelector('[data-test-id="episodes-heading"]') ?.getBoundingClientRect(); const player = element .querySelector('app-portal-inline-player') @@ -127,7 +127,7 @@ function sweepBar(shell: Locator): Promise { element.scrollTop = top; const band = bar.getBoundingClientRect(); const title = element - .querySelector('.section-title') + .querySelector('[data-test-id="episodes-heading"]') ?.getBoundingClientRect(); const player = element .querySelector('app-portal-inline-player') @@ -166,14 +166,16 @@ function sweepBar(shell: Locator): Promise { /** Line boxes of the heading's text; 1 means it did not wrap. */ function headingLineCount(page: Page): Promise { - 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; + }); } /** @@ -263,9 +265,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, ...compactWidths]) { await page.setViewportSize({ width, height: 800 }); expect( @@ -312,7 +317,9 @@ test.describe('Portal detail Back lane', () => { const detailUrl = page.url(); const shell = page.locator('app-portal-detail-shell'); const episodes = page.locator('.episode-card'); - await expect(page.locator('.section-title')).toBeVisible({ + await expect( + page.locator('[data-test-id="episodes-heading"]') + ).toBeVisible({ timeout: 20_000, }); await expect(episodes.first()).toBeVisible({ timeout: 20_000 }); diff --git a/apps/electron-backend-e2e/src/downloads.e2e.ts b/apps/electron-backend-e2e/src/downloads.e2e.ts index 75b91056c..d21501779 100644 --- a/apps/electron-backend-e2e/src/downloads.e2e.ts +++ b/apps/electron-backend-e2e/src/downloads.e2e.ts @@ -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); diff --git a/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts b/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts index ce447b3d3..d40295db6 100644 --- a/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts +++ b/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts @@ -107,10 +107,9 @@ 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.getByTestId('series-more-menu'); + 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))) { @@ -122,9 +121,13 @@ async function seriesMenuRow(page: Page, rowTestId: string) { /** Closes an open menu without choosing a row. */ async function closeSeriesMenu(page: Page): Promise { - if (await page.locator('.cdk-overlay-backdrop').isVisible().catch(() => false)) { + // 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', () => { @@ -338,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' ); diff --git a/apps/electron-backend-e2e/src/series-download-queue.e2e.ts b/apps/electron-backend-e2e/src/series-download-queue.e2e.ts index f8475d9a5..494f7461c 100644 --- a/apps/electron-backend-e2e/src/series-download-queue.e2e.ts +++ b/apps/electron-backend-e2e/src/series-download-queue.e2e.ts @@ -160,7 +160,9 @@ test.describe('Electron Series Download Queue', () => { }) .toEqual(expectedEpisodeQueue.slice(0, 2)); - await app.mainWindow.getByTestId('series-more-menu').click(); + await app.mainWindow + .locator('[data-testid="series-more-menu"]') + .click(); const seasonAction = app.mainWindow.locator( '[data-test-id="download-season"]' ); @@ -180,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( diff --git a/apps/web-e2e/src/e2e-helpers.ts b/apps/web-e2e/src/e2e-helpers.ts index 5b79c8ab7..3dd3fdfa1 100644 --- a/apps/web-e2e/src/e2e-helpers.ts +++ b/apps/web-e2e/src/e2e-helpers.ts @@ -245,11 +245,17 @@ export async function expectSeriesSurfacesInBothThemes( await expect .poll(() => rasterizedBorderContrast( - shell.locator('[data-testid="series-favorite-toggle"]').first() + 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 () => diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index 7c45b82ed..c7a23d639 100644 --- a/apps/web-e2e/src/m3u-movie-details.e2e.ts +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -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 ({ await expect(inlineVideo(page)).toHaveCount(0); await expect(shell.locator('.shell__back-button')).toHaveCount(0); // Without the arrow there is no lane to reserve: the hero keeps its own - // inset instead of the 72px column portal details give the Back control. + // inset (32px, or 20px in a pane narrower than 760px) instead of the + // 72px column portal details give the Back control. 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(); diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index 574b11b0b..174d83a72 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -29,9 +29,7 @@ async function addXtreamPortal(page: Page): Promise { 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); @@ -160,9 +158,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) => @@ -257,9 +255,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]); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 23433f05b..5762337ea 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -666,8 +666,8 @@ and drive the season container's presenters; the container's header is toggle. The checked toggle uses `--app-selection-surface` and `--app-selection-color`; hover uses the app's neutral surface treatment. -Episode cards are flat: a 16:9 thumbnail with a 3px watched bar at its -bottom edge, "N. Title", the plot clamped to two lines and a "42 min · +Episode cards are flat: a 16:9 thumbnail (with a light hairline so its edge +survives the light theme) and a 3px watched bar at its bottom edge, "N. Title", the plot clamped to two lines and a "42 min · 18m left / watched" line. List rows keep a subtle neutral fill. The selected season's cover and synopsis sit in a compact strip under the header only when present. Keep these treatments in the shared season components so diff --git a/libs/ui/components/src/lib/season-container/season-container.component.html b/libs/ui/components/src/lib/season-container/season-container.component.html index 285a887dc..6f257527c 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.html +++ b/libs/ui/components/src/lib/season-container/season-container.component.html @@ -2,7 +2,7 @@
-

+

{{ 'PORTALS.DETAIL.EPISODES' | translate }}

@if (hasSeasons()) { @@ -54,7 +54,10 @@ }
- @if (hasSeasons() && (selectedSeasonPosterUrl() || selectedSeasonDescription())) { + @if ( + hasSeasons() && + (selectedSeasonPosterUrl() || selectedSeasonDescription()) + ) {
diff --git a/libs/ui/components/src/lib/season-container/season-container.component.scss b/libs/ui/components/src/lib/season-container/season-container.component.scss index e2f8eb3c4..ce593c99b 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.scss +++ b/libs/ui/components/src/lib/season-container/season-container.component.scss @@ -28,8 +28,16 @@ border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 16px; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03)), - radial-gradient(circle at top, rgba(255, 255, 255, 0.06), transparent 60%); + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.06), + rgba(255, 255, 255, 0.03) + ), + radial-gradient( + circle at top, + rgba(255, 255, 255, 0.06), + transparent 60% + ); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 18px 40px rgba(0, 0, 0, 0.22); @@ -71,8 +79,16 @@ &--season { border-color: rgba(255, 255, 255, 0.1); background: - linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.04)), - radial-gradient(circle at top, rgba(255, 255, 255, 0.08), transparent 65%); + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.07), + rgba(255, 255, 255, 0.04) + ), + radial-gradient( + circle at top, + rgba(255, 255, 255, 0.08), + transparent 65% + ); } } @@ -322,6 +338,8 @@ position: relative; aspect-ratio: 16 / 9; background: #000; + // A hairline keeps the artwork's edge visible on the light theme too. + border: 1px solid rgba(255, 255, 255, 0.14); border-radius: 10px; overflow: hidden; @@ -421,7 +439,11 @@ color: var(--mat-sys-tertiary) !important; &:hover { - background: color-mix(in srgb, var(--mat-sys-tertiary) 80%, transparent); + background: color-mix( + in srgb, + var(--mat-sys-tertiary) 80%, + transparent + ); } } @@ -571,7 +593,11 @@ &:hover { background: var(--app-card-hover-bg); - border-color: color-mix(in srgb, var(--app-on-surface) 22%, transparent); + border-color: color-mix( + in srgb, + var(--app-on-surface) 22%, + transparent + ); .episode-list-item__actions { opacity: 1; @@ -587,12 +613,24 @@ } &--in-progress { - border-color: color-mix(in srgb, var(--mat-sys-primary) 40%, transparent); + border-color: color-mix( + in srgb, + var(--mat-sys-primary) 40%, + transparent + ); background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent); &:hover { - border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent); - background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent); + border-color: color-mix( + in srgb, + var(--mat-sys-primary) 60%, + transparent + ); + background: color-mix( + in srgb, + var(--mat-sys-primary) 8%, + transparent + ); } } @@ -773,7 +811,11 @@ color: var(--mat-sys-tertiary); &:hover { - background: color-mix(in srgb, var(--mat-sys-tertiary) 15%, transparent); + background: color-mix( + in srgb, + var(--mat-sys-tertiary) 15%, + transparent + ); } } @@ -787,12 +829,24 @@ &--launching, &--active-external { - border-color: color-mix(in srgb, var(--mat-sys-tertiary) 46%, transparent); - background: color-mix(in srgb, var(--mat-sys-tertiary-container) 12%, transparent); + border-color: color-mix( + in srgb, + var(--mat-sys-tertiary) 46%, + transparent + ); + background: color-mix( + in srgb, + var(--mat-sys-tertiary-container) 12%, + transparent + ); } &--playing { - border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent); + border-color: color-mix( + in srgb, + var(--mat-sys-primary) 60%, + transparent + ); background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent); }