diff --git a/.changes/dashboard-hero-cinematic-alignment.md b/.changes/dashboard-hero-cinematic-alignment.md new file mode 100644 index 000000000..08bbd7f6e --- /dev/null +++ b/.changes/dashboard-hero-cinematic-alignment.md @@ -0,0 +1,6 @@ +--- +type: feature +area: workspace +--- + +The dashboard hero now shares the details pages' look: the same light Continue button and chips, a series title without its "(Season 1)" marker, and the blurred poster alone as the backdrop when a title has none. The hero can be focused: ← and → switch slides, Enter follows the main action, and rotation pauses while the window is hidden. diff --git a/.changes/portals-cinematic-vod-details.md b/.changes/portals-cinematic-vod-details.md new file mode 100644 index 000000000..bb82f01ec --- /dev/null +++ b/.changes/portals-cinematic-vod-details.md @@ -0,0 +1,7 @@ +--- +type: feature +area: portals +highlight: Cinematic movie and series pages +--- + +Movie and series pages open on a cinematic backdrop with chips, plot and one action row: a light Continue/Play button naming the next episode and the time left, a Trailer modal, and a "…" menu with other sources, external player, copy URL, reset progress and season actions. Cast & crew and Similar follow as rows. Without a 16:9 backdrop the hero shrinks to its content over the blurred poster. diff --git a/.changes/portals-trailer-backdrop.md b/.changes/portals-trailer-backdrop.md new file mode 100644 index 000000000..b24d3a1c4 --- /dev/null +++ b/.changes/portals-trailer-backdrop.md @@ -0,0 +1,6 @@ +--- +type: feature +area: portals +--- + +A new Playback setting, "Play trailers in details background" (off by default), plays a movie's or series' trailer muted behind the details a few seconds after the page opens, with a corner button to unmute. It stays off under reduced motion and on metered connections, and stops when the page scrolls away or the window loses focus. diff --git a/apps/electron-backend-e2e/src/detail-header-back.e2e.ts b/apps/electron-backend-e2e/src/detail-header-back.e2e.ts index fbe3b4a73..2f8a4ec57 100644 --- a/apps/electron-backend-e2e/src/detail-header-back.e2e.ts +++ b/apps/electron-backend-e2e/src/detail-header-back.e2e.ts @@ -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 { - 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 { 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, }); 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 1bdf300d7..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,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 { + // 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' ); 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 8236bdcbd..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,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( diff --git a/apps/electron-backend-e2e/src/vod-multi-source.e2e.ts b/apps/electron-backend-e2e/src/vod-multi-source.e2e.ts index ac4f875a7..8c30a4fda 100644 --- a/apps/electron-backend-e2e/src/vod-multi-source.e2e.ts +++ b/apps/electron-backend-e2e/src/vod-multi-source.e2e.ts @@ -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); diff --git a/apps/web-e2e/src/e2e-helpers.ts b/apps/web-e2e/src/e2e-helpers.ts index 4331b36e0..fce681780 100644 --- a/apps/web-e2e/src/e2e-helpers.ts +++ b/apps/web-e2e/src/e2e-helpers.ts @@ -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 { + // 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 }); +} diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index 94b85e778..2f2a49a41 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 ({ .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(); diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index 93f49ede5..afa09de53 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); @@ -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]); diff --git a/apps/web-e2e/src/stalker-season-markers.fixture.ts b/apps/web-e2e/src/stalker-season-markers.fixture.ts index 38181c2f8..3c9e6e94c 100644 --- a/apps/web-e2e/src/stalker-season-markers.fixture.ts +++ b/apps/web-e2e/src/stalker-season-markers.fixture.ts @@ -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(); diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index c23dcd744..99bd00708 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -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); diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 0e4329496..0b7029188 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -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 }) diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index ddf8baa27..ed3765f0e 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -64,6 +64,7 @@ export function createSettingsForm( player: [VideoPlayer.VideoJs], webPlayerSharedControls: true, playerAmbientMode: false, + detailTrailerBackdrop: false, playerUpNextRail: true, playerUpNextCard: true, fullscreenChannelPanel: true, @@ -176,6 +177,7 @@ export function createSettingsFromFormValue( player: value.player ?? VideoPlayer.VideoJs, webPlayerSharedControls: value.webPlayerSharedControls ?? true, playerAmbientMode: value.playerAmbientMode ?? false, + detailTrailerBackdrop: value.detailTrailerBackdrop ?? false, playerUpNextRail: value.playerUpNextRail ?? true, playerUpNextCard: value.playerUpNextCard ?? true, fullscreenChannelPanel: value.fullscreenChannelPanel ?? true, diff --git a/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts b/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts index a43bfe1ad..e4faba996 100644 --- a/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts +++ b/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts @@ -73,6 +73,7 @@ function createForm(player: VideoPlayer): FormGroup { player: new FormControl(player), webPlayerSharedControls: new FormControl(false), playerAmbientMode: new FormControl(false), + detailTrailerBackdrop: new FormControl(false), playerUpNextRail: new FormControl(true), playerUpNextCard: new FormControl(true), fullscreenChannelPanel: new FormControl(true), diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index c3f312fd3..1ee3d3b39 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -137,6 +137,34 @@ } + +
+
+

+ {{ 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND' | translate }} +

+

+ {{ + 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ @if (supportsUpNextCard()) {