From ab8460338aaac54d9d1470c7dd4ae17fa6cd185d Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:08:16 +0200 Subject: [PATCH] feat(ui): cinematic movie and series details pages and dashboard hero (#1792) --- .../dashboard-hero-cinematic-alignment.md | 6 + .changes/portals-cinematic-vod-details.md | 7 + .changes/portals-trailer-backdrop.md | 6 + .../src/detail-header-back.e2e.ts | 45 +- .../electron-backend-e2e/src/downloads.e2e.ts | 12 +- .../src/season-watched-toggle.e2e.ts | 94 ++- .../src/series-download-queue.e2e.ts | 12 +- .../src/vod-multi-source.e2e.ts | 30 +- apps/web-e2e/src/e2e-helpers.ts | 33 +- apps/web-e2e/src/m3u-movie-details.e2e.ts | 7 +- apps/web-e2e/src/self-hosted.e2e.ts | 16 +- .../src/stalker-season-markers.fixture.ts | 1 + apps/web-e2e/src/stalker.e2e.ts | 46 +- apps/web-e2e/src/xtream.e2e.ts | 65 +- .../src/app/settings/settings-form.utils.ts | 2 + ...ngs-playback-section.auto-failover.spec.ts | 1 + .../settings-playback-section.component.html | 28 + ...ettings-playback-section.component.spec.ts | 1 + .../test-stubs/settings-test-harness.stub.ts | 1 + apps/web/src/assets/i18n/ar.json | 28 + apps/web/src/assets/i18n/ary.json | 28 + apps/web/src/assets/i18n/by.json | 28 + apps/web/src/assets/i18n/de.json | 28 + apps/web/src/assets/i18n/el.json | 28 + apps/web/src/assets/i18n/en.json | 28 + apps/web/src/assets/i18n/es.json | 28 + apps/web/src/assets/i18n/fr.json | 28 + apps/web/src/assets/i18n/hu.json | 28 + apps/web/src/assets/i18n/it.json | 28 + apps/web/src/assets/i18n/ja.json | 28 + apps/web/src/assets/i18n/ko.json | 28 + apps/web/src/assets/i18n/nl.json | 28 + apps/web/src/assets/i18n/pl.json | 28 + apps/web/src/assets/i18n/pt.json | 28 + apps/web/src/assets/i18n/ru.json | 28 + apps/web/src/assets/i18n/tr.json | 28 + apps/web/src/assets/i18n/zh.json | 28 + apps/web/src/assets/i18n/zhtw.json | 28 + apps/web/src/m3-theme.scss | 17 + docs/architecture/iptvnator-ui-guidelines.md | 70 +- docs/architecture/workspace-dashboard.md | 14 +- .../m3u-vod-detail.component.html | 90 +-- .../m3u-vod-detail.component.spec.ts | 50 +- .../m3u-vod-detail.component.ts | 54 +- .../m3u-vod-metadata.service.ts | 10 +- .../download-offline-detail.component.html | 105 +-- .../download-offline-detail.component.scss | 10 - .../download-offline-detail.component.spec.ts | 6 + .../download-offline-detail.component.ts | 26 +- ...wnload-offline-detail.presentation.spec.ts | 27 + .../download-offline-detail.presentation.ts | 13 + .../recording-detail.component.html | 40 +- .../recording-detail.component.scss | 3 - .../recording-detail.component.ts | 2 + libs/portal/shared/util/src/index.ts | 4 + .../src/lib/detail/country-short-name.spec.ts | 44 ++ .../util/src/lib/detail/country-short-name.ts | 124 +++ .../src/lib/detail/playback-progress.spec.ts | 114 +++ .../util/src/lib/detail/playback-progress.ts | 137 ++++ .../util/src/lib/detail/season-title.spec.ts | 40 + .../util/src/lib/detail/season-title.ts | 29 + .../src/lib/pending-playback-start.spec.ts | 17 + .../util/src/lib/pending-playback-start.ts | 11 + .../replace-owned-external-session.spec.ts | 62 ++ .../src/lib/replace-owned-external-session.ts | 46 ++ .../stalker-catalog-detail.component.html | 12 +- .../stalker-catalog-detail.component.spec.ts | 39 + .../stalker-catalog-detail.component.ts | 124 ++- ...talker-collection-detail.component.spec.ts | 45 ++ .../stalker-collection-detail.component.ts | 12 + .../stalker-collection-playback.controller.ts | 42 + .../stalker-favorites-button.component.html | 17 +- .../stalker-favorites-button.component.ts | 5 +- .../stalker-inline-detail.component.html | 4 + .../stalker-inline-detail.component.spec.ts | 4 + .../stalker-inline-detail.component.ts | 9 + .../stalker-search.component.html | 5 + .../stalker-search.component.ts | 43 + .../stalker-series-hero.presenter.ts | 130 ++++ .../stalker-series-launch-queue.spec.ts | 156 ++++ .../stalker-series-launch-queue.ts | 82 ++ .../stalker-series-menu.service.spec.ts | 74 ++ .../stalker-series-menu.service.ts | 111 +++ .../stalker-series-view.component.html | 374 +++------ .../stalker-series-view.component.spec.ts | 82 +- .../stalker-series-view.component.ts | 296 ++++++- .../stalker-series-view.season-watch.spec.ts | 151 +++- .../lib/stalker-vod-detail-actions.spec.ts | 418 ++++++++++ .../src/lib/stalker-vod-detail-actions.ts | 233 ++++++ .../stalker-vod-playback-controller.spec.ts | 21 +- .../lib/stalker-vod-playback-controller.ts | 24 + ...serial-details-download-adapter.service.ts | 47 ++ .../serial-details-external-launch.spec.ts | 393 ++++++++++ .../serial-details-external-launch.ts | 272 +++++++ .../serial-details-hero.presenter.ts | 140 ++++ .../serial-details-menu.service.spec.ts | 112 +++ .../serial-details-menu.service.ts | 334 ++++++++ .../serial-details-playback-position-state.ts | 7 +- .../serial-details-playback.service.spec.ts | 80 ++ .../serial-details-playback.service.ts | 207 ++--- .../serial-details.component.html | 380 ++++----- .../serial-details.component.spec.ts | 70 +- .../serial-details.component.ts | 121 +-- .../feature/src/lib/tmdb-similar.util.spec.ts | 2 + .../feature/src/lib/tmdb-similar.util.ts | 3 + .../vod-details-external-launch.ts | 7 +- .../vod-details-external-playback.spec.ts | 81 ++ .../vod-details/vod-details-hero.presenter.ts | 214 +++++ .../vod-details-menu.service.spec.ts | 262 +++++++ .../vod-details/vod-details-menu.service.ts | 262 +++++++ .../vod-details-multi-source-ui.service.ts | 26 + .../vod-details-playback.service.spec.ts | 67 ++ .../vod-details-playback.service.ts | 194 ++--- .../vod-details/vod-details-reset-target.ts | 39 + .../vod-details-route-playback.spec.ts | 40 +- .../vod-details-route.actions.spec.ts | 10 +- .../vod-details-route.component.html | 732 ++++++------------ .../vod-details-route.component.scss | 65 +- .../vod-details-route.component.spec.ts | 2 + .../vod-details-route.component.ts | 164 +++- .../vod-multi-source-host-pin.spec.ts | 57 ++ .../vod-multi-source-host-races.spec.ts | 3 +- .../vod-multi-source-host.service.spec.ts | 4 +- .../vod-multi-source-host.service.ts | 45 +- .../vod-primary-action-position.spec.ts | 27 + .../vod-primary-action-position.ts | 25 +- .../src/lib/settings-store.defaults.ts | 1 + .../src/lib/settings-store.service.ts | 3 + .../interfaces/src/lib/settings.interface.ts | 5 + libs/ui/components/src/index.ts | 16 + .../content-hero/content-hero.component.html | 113 ++- .../content-hero/content-hero.component.scss | 576 ++++++-------- .../content-hero.component.spec.ts | 152 ++++ .../content-hero/content-hero.component.ts | 51 +- .../detail-ui/cast-crew-row.component.html | 37 + .../detail-ui/cast-crew-row.component.scss | 66 ++ .../detail-ui/cast-crew-row.component.spec.ts | 56 ++ .../lib/detail-ui/cast-crew-row.component.ts | 62 ++ .../src/lib/detail-ui/cast-crew.util.spec.ts | 51 ++ .../src/lib/detail-ui/cast-crew.util.ts | 90 +++ .../detail-action-button.component.html | 20 + .../detail-action-button.component.scss | 106 +++ .../detail-action-button.component.ts | 31 + .../detail-ui/detail-credits.component.html | 23 + .../detail-ui/detail-credits.component.scss | 46 ++ .../detail-credits.component.spec.ts | 49 ++ .../lib/detail-ui/detail-credits.component.ts | 36 + .../detail-icon-button.component.html | 20 + .../detail-icon-button.component.scss | 59 ++ .../detail-ui/detail-icon-button.component.ts | 32 + .../lib/detail-ui/detail-rail.component.html | 32 + .../lib/detail-ui/detail-rail.component.scss | 80 ++ .../lib/detail-ui/detail-rail.component.ts | 68 ++ .../hero-trailer-backdrop.component.html | 12 + .../hero-trailer-backdrop.component.scss | 30 + .../hero-trailer-backdrop.component.spec.ts | 182 +++++ .../hero-trailer-backdrop.component.ts | 171 ++++ .../lib/detail-ui/meta-chip.component.scss | 56 ++ .../lib/detail-ui/meta-chip.component.spec.ts | 36 + .../src/lib/detail-ui/meta-chip.component.ts | 26 + .../lib/detail-ui/series-hero.state.spec.ts | 69 ++ .../src/lib/detail-ui/series-hero.state.ts | 318 ++++++++ .../lib/detail-ui/similar-rail.component.html | 32 + .../lib/detail-ui/similar-rail.component.scss | 68 ++ .../detail-ui/similar-rail.component.spec.ts | 42 + .../lib/detail-ui/similar-rail.component.ts | 47 ++ .../src/lib/detail-ui/trailer-dialog-state.ts | 21 + .../lib/detail-ui/trailer-dialog.component.ts | 120 +++ .../detail-ui/trailer-dialog.service.spec.ts | 26 + .../lib/detail-ui/trailer-dialog.service.ts | 29 + .../detail-ui/vod-more-menu.component.html | 117 +++ .../detail-ui/vod-more-menu.component.scss | 151 ++++ .../detail-ui/vod-more-menu.component.spec.ts | 131 ++++ .../lib/detail-ui/vod-more-menu.component.ts | 225 ++++++ .../src/lib/detail-ui/vod-more-menu.model.ts | 19 + .../portal-detail-shell.component.html | 4 + .../portal-detail-shell.component.scss | 48 +- .../portal-detail-shell.component.ts | 8 + .../season-container/episode-progress.util.ts | 43 +- .../season-container/episode-utils.spec.ts | 15 + .../season-container.component.html | 145 ++-- .../season-container.component.scss | 341 +++++--- .../season-container.component.spec.ts | 138 ++-- .../season-container.component.ts | 42 +- .../season-container.season-cover.spec.ts | 3 +- .../season-container.series-watch.spec.ts | 34 +- .../season-header.component.html | 155 ---- .../season-header.component.scss | 132 ---- .../season-header.component.ts | 63 -- .../season-tabs.component.html | 9 - .../season-tabs.component.scss | 9 - .../season-container/season-tabs.component.ts | 4 +- .../season-watch-presenter.ts | 19 + .../series-playback-navigation.ts | 24 + .../lib/vod-details/vod-details-hero.state.ts | 132 ++++ .../vod-details-presentation.spec.ts | 69 ++ .../vod-details/vod-details-presentation.ts | 224 ++++++ .../vod-details/vod-details.component.html | 481 ++++-------- .../vod-details/vod-details.component.spec.ts | 137 ++-- .../lib/vod-details/vod-details.component.ts | 146 +++- libs/ui/styles/_detail-view-actions.scss | 107 +++ libs/ui/styles/_detail-view.scss | 556 +------------ libs/workspace/dashboard/feature/project.json | 1 + .../dashboard-hero-slides.presenter.spec.ts | 32 + .../rails/dashboard-hero-slides.presenter.ts | 37 +- .../lib/rails/dashboard-hero.component.html | 40 +- .../lib/rails/dashboard-hero.component.scss | 56 +- .../rails/dashboard-hero.component.spec.ts | 62 ++ .../src/lib/rails/dashboard-hero.component.ts | 48 +- .../src/lib/rails/dashboard-playback.utils.ts | 72 +- .../settings-search-entries-playback.ts | 8 + tools/i18n/identical-en-baseline.json | 45 +- .../capture-navigation-portal-actions.ts | 9 +- 213 files changed, 12494 insertions(+), 3930 deletions(-) create mode 100644 .changes/dashboard-hero-cinematic-alignment.md create mode 100644 .changes/portals-cinematic-vod-details.md create mode 100644 .changes/portals-trailer-backdrop.md create mode 100644 libs/portal/shared/util/src/lib/detail/country-short-name.spec.ts create mode 100644 libs/portal/shared/util/src/lib/detail/country-short-name.ts create mode 100644 libs/portal/shared/util/src/lib/detail/playback-progress.spec.ts create mode 100644 libs/portal/shared/util/src/lib/detail/playback-progress.ts create mode 100644 libs/portal/shared/util/src/lib/detail/season-title.spec.ts create mode 100644 libs/portal/shared/util/src/lib/detail/season-title.ts create mode 100644 libs/portal/shared/util/src/lib/replace-owned-external-session.spec.ts create mode 100644 libs/portal/shared/util/src/lib/replace-owned-external-session.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-hero.presenter.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-launch-queue.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-launch-queue.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-download-adapter.service.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-hero.presenter.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-reset-target.ts create mode 100644 libs/ui/components/src/lib/detail-ui/cast-crew-row.component.html create mode 100644 libs/ui/components/src/lib/detail-ui/cast-crew-row.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/cast-crew-row.component.spec.ts create mode 100644 libs/ui/components/src/lib/detail-ui/cast-crew-row.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/cast-crew.util.spec.ts create mode 100644 libs/ui/components/src/lib/detail-ui/cast-crew.util.ts create mode 100644 libs/ui/components/src/lib/detail-ui/detail-action-button.component.html create mode 100644 libs/ui/components/src/lib/detail-ui/detail-action-button.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/detail-action-button.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/detail-credits.component.html create mode 100644 libs/ui/components/src/lib/detail-ui/detail-credits.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/detail-credits.component.spec.ts create mode 100644 libs/ui/components/src/lib/detail-ui/detail-credits.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/detail-icon-button.component.html create mode 100644 libs/ui/components/src/lib/detail-ui/detail-icon-button.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/detail-icon-button.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/detail-rail.component.html create mode 100644 libs/ui/components/src/lib/detail-ui/detail-rail.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/detail-rail.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.html create mode 100644 libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.spec.ts create mode 100644 libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/meta-chip.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/meta-chip.component.spec.ts create mode 100644 libs/ui/components/src/lib/detail-ui/meta-chip.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/series-hero.state.spec.ts create mode 100644 libs/ui/components/src/lib/detail-ui/series-hero.state.ts create mode 100644 libs/ui/components/src/lib/detail-ui/similar-rail.component.html create mode 100644 libs/ui/components/src/lib/detail-ui/similar-rail.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/similar-rail.component.spec.ts create mode 100644 libs/ui/components/src/lib/detail-ui/similar-rail.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/trailer-dialog-state.ts create mode 100644 libs/ui/components/src/lib/detail-ui/trailer-dialog.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/trailer-dialog.service.spec.ts create mode 100644 libs/ui/components/src/lib/detail-ui/trailer-dialog.service.ts create mode 100644 libs/ui/components/src/lib/detail-ui/vod-more-menu.component.html create mode 100644 libs/ui/components/src/lib/detail-ui/vod-more-menu.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/vod-more-menu.component.spec.ts create mode 100644 libs/ui/components/src/lib/detail-ui/vod-more-menu.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/vod-more-menu.model.ts delete mode 100644 libs/ui/components/src/lib/season-container/season-header.component.html delete mode 100644 libs/ui/components/src/lib/season-container/season-header.component.scss delete mode 100644 libs/ui/components/src/lib/season-container/season-header.component.ts create mode 100644 libs/ui/playback/src/lib/vod-details/vod-details-hero.state.ts create mode 100644 libs/ui/playback/src/lib/vod-details/vod-details-presentation.spec.ts create mode 100644 libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts 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()) {