diff --git a/.changes/portals-cinematic-vod-details.md b/.changes/portals-cinematic-vod-details.md new file mode 100644 index 000000000..318668e37 --- /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 full-height 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 backdrop the blurred poster fills the stage. 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..ce447b3d3 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,26 @@ 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'); + 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 { + if (await page.locator('.cdk-overlay-backdrop').isVisible().catch(() => false)) { + await page.keyboard.press('Escape'); + } +} + 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 +157,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 +178,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 +248,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 +258,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 +273,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 +289,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 +316,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})` ); 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..f8475d9a5 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,10 @@ test.describe('Electron Series Download Queue', () => { }) .toEqual(expectedEpisodeQueue.slice(0, 2)); - const seasonAction = app.mainWindow.getByTestId('download-season'); + await app.mainWindow.getByTestId('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(); diff --git a/apps/web-e2e/src/e2e-helpers.ts b/apps/web-e2e/src/e2e-helpers.ts index 4331b36e0..5b79c8ab7 100644 --- a/apps/web-e2e/src/e2e-helpers.ts +++ b/apps/web-e2e/src/e2e-helpers.ts @@ -244,7 +244,9 @@ 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']) { 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 5b2143329..171f9bf3d 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -1104,10 +1104,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})` ); @@ -1116,27 +1115,44 @@ 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); }); + +/** Opens the series "…" menu (if closed) and returns the row with that test id. */ +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; +} + +async function closeSeriesMenu(page: Page): Promise { + if (await page.locator('.cdk-overlay-backdrop').isVisible().catch(() => false)) { + await page.keyboard.press('Escape'); + } +} + test('@stalker series watched toggle — embedded series marks and clears from the header menu', async ({ page, request, @@ -1173,14 +1189,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})` ); @@ -1192,12 +1204,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})` ); @@ -1205,7 +1217,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 ce7514ab7..40cc16adb 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -656,24 +656,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,8 +698,9 @@ 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') @@ -795,6 +798,23 @@ test('@xtream season cover — shows the provider season cover and follows the s // unwatch-all once the whole series is watched, and survives a reload. // --------------------------------------------------------------------------- + +/** Opens the series "…" menu (if closed) and returns the row with that test id. */ +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; +} + +async function closeSeriesMenu(page: Page): Promise { + if (await page.locator('.cdk-overlay-backdrop').isVisible().catch(() => false)) { + await page.keyboard.press('Escape'); + } +} + test('@xtream series watched toggle — marks every season from the header menu, survives reload, and clears again', async ({ page, request, @@ -830,17 +850,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 +868,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,7 +1007,9 @@ 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(); const toggle = shell.locator('mat-button-toggle-group'); await expect(card).toBeVisible(); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index d212503a9..86f0541cc 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -153,14 +153,30 @@ Do not add extra badges, left rails, or second selection systems unless there is ## Detail Views -VOD and series detail screens share the detail-view Sass mixin (`@mixin base`) -from `libs/ui/styles/_detail-view.scss`. Feature-local `styles/detail-view.scss` -files should only `@use` that module and `@include detail-view.base(...)` with -small typography overrides when a provider needs them. +VOD and series detail screens share `app-portal-detail-shell` and +`app-content-hero` (`libs/ui/components`). The hero orders its column as +kind label ("Movie · playlist") → title → chips → description (three lines, +"More") → resume bar → action row → credits, with the poster bottom-aligned +on the left and the backdrop filling the hero behind a two-layer scrim built +from `--app-content-bg`. Without a backdrop, or when the provider sends the +poster as the backdrop, the blurred poster fills the stage. The pane is a +size container (`detail`); the poster hides below 760px of pane width. -Do not copy the full detail-view stylesheet into feature libraries. Add shared -layout changes to the mixin, and keep provider-specific differences explicit in -the wrapper file that includes it. +The pieces are shared and provider-neutral (`libs/ui/components/src/lib/detail-ui/`): +`app-meta-chip` (pill; `rating` and `status` variants; facets as projected +`.meta-chip__facet` buttons), `app-detail-action-button` (the light primary +with a two-line label, or the ghost `secondary` text button), +`app-detail-icon-button` (44px ghost with tooltip and `aria-label`), +`app-vod-more-menu` (the "…" dropdown: right-aligned, flips upward, arrow +keys, Escape, hosts the alternative-sources panel), `app-detail-credits` +("Starring" + three names + "and more", "Director"), `app-cast-crew-row`, +`app-detail-rail`/`app-similar-rail` (hidden scrollbar, prev/next arrows, +title + year) and `TrailerDialogService`. The dashboard hero reuses the same +light primary (`light-primary-button` in `libs/ui/styles/_detail-view-actions.scss`) +and chip. Series titles drop their season marker (`splitSeasonSuffix`) into a +"Season N" chip. Rows a provider cannot serve are left out of the menu, never +disabled. The page-level Sass mixin (`libs/ui/styles/_detail-view.scss`) +only carries the page shell, meta items and the episodes section. ## Electron Drag Regions @@ -631,23 +647,20 @@ If the label is too long for the rail, shorten the label key instead of shrinkin ## Detail Actions And Episode Surfaces -Secondary detail buttons, episode cards and list rows, and the season view -toggle must keep visible edges in both themes before hover. Use app-owned -surface colors and neutral borders derived from `--app-on-surface`; fixed -white-alpha fills and borders disappear over the light detail background. -Grid cards keep the thumbnail and title on one continuous widget surface. -List rows use a subtle neutral fill, with the number on a slightly stronger -inset surface. The checked grid/list toggle uses `--app-selection-surface` -and `--app-selection-color`; hover uses the app's neutral surface treatment. -Keep these treatments in the shared season components and detail-action -partial so Xtream and Stalker share the same behavior. +The action row never wraps on a desktop window: the primary, the Trailer +button, then the 44px icon buttons and the "…" menu. Season and series +actions (mark watched, download season, reset progress) live in that menu +and drive the season container's presenters; the container's header is +"Episodes" with the season pills and the episode count, plus the grid/list +toggle. The checked toggle uses `--app-selection-surface` and +`--app-selection-color`; hover uses the app's neutral surface treatment. -The season header's actions wrap onto their own row, starting under the -"Seasons and Episodes" heading, before the heading itself would wrap. The -detail pane is narrower than the window (context panel, the sticky Back lane), -so the header's own width decides, not a viewport breakpoint. A translation -wider than the pane itself wraps rather than ellipsizing: unlike a fixed-height -panel title, a content heading has room to wrap and should not lose words. +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 · +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 +Xtream and Stalker share the same behavior. Browser regression coverage measures the composited neutral edges and selected toggle fill, in addition to capturing light/dark grid and list screenshots. diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.html b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.html index 44e472557..8a5be583e 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.html @@ -34,7 +34,6 @@ @if (canPlayInline() && !playbackActive()) { +} @else if (isFavorite === false) { - } @else { - {{ - discover.yearLabel(serial.info.releasedate) ?? - serial.info.releasedate - }} - } + @if (hero.statusLabel(); as status) { + + {{ status }} + } - @if (serial.info.tmdb_genres?.length) { - @for (genre of serial.info.tmdb_genres; track genre.id) { - - } - } @else if (serial.info.genre) { - {{ serial.info.genre }} + @if (hero.year(); as year) { + + @if ( + serial.info.releasedate && + discover.canOpenYear(serial.info.releasedate) + ) { + + } @else { + {{ year }} + } + + } + @if (hero.genreChips().length) { + + @for (genre of hero.genreChips(); track genre.label) { + @if (!$first) { + + } + @if (genre.facet; as facet) { + + } @else { + {{ genre.label }} + } + } + + } + @for (country of hero.countryChips(); track country.label) { + + @if (country.facet; as facet) { + + } @else { + {{ country.label }} + } + } @if (serial.info.rating_imdb) { - - ⭐ {{ serial.info.rating_imdb }} - + ★ {{ serial.info.rating_imdb }} } @if (serial.info.rating_kinopoisk) { - - KP {{ serial.info.rating_kinopoisk }} - - } - @if (serial.info.tmdb_countries?.length) { - @for ( - country of serial.info.tmdb_countries; - track country.code - ) { - - } + KP {{ serial.info.rating_kinopoisk }} } - - @if (serial.info.actors || serial.info.tmdb_cast?.length) { -
- {{ 'XTREAM.ACTORS' | translate }} - @if (serial.info.tmdb_cast?.length) { -
- @for ( - member of serial.info.tmdb_cast; - track member.tmdbPersonId - ? 'p' + member.tmdbPersonId - : 'i' + $index - ) { - - @if (member.profileUrl) { - - } @else { - - {{ member.name.charAt(0) }} - - } - {{ - member.name - }} - - } -
- } @else { - {{ serial.info.actors }} - } -
- } - @if (serial.info.director || serial.info.tmdb_directors?.length) { -
- {{ - 'XTREAM.DIRECTOR' | translate - }} - @if (serial.info.tmdb_directors?.length) { -
- @for ( - member of serial.info.tmdb_directors; - track member.tmdbPersonId - ? 'p' + member.tmdbPersonId - : 'i' + $index - ) { - - @if (member.profileUrl) { - - } @else { - - {{ member.name.charAt(0) }} - - } - {{ - member.name - }} - - } -
- } @else { - {{ serial.info.director }} - } -
- } +
- - @if (quickStartAction(); as action) { - + @if (hero.primaryAction(); as primary) { + } - + @if (heroPresenter.trailerEmbedUrl()) { + + } + + @@ -273,71 +195,16 @@ }
- @if (similarInPortals().length) { -
-

{{ 'XTREAM.SIMILAR' | translate }}

-
- @for ( - item of similarInPortals(); - track item.match.xtreamId - ) { - - } -
-
- } - - - @if (trailerEmbedUrl(); as trailerUrl) { -
-

{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}

-
- -
-
- } + +
} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index 6909d20c2..4b9c41805 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -247,9 +247,7 @@ describe('StalkerSeriesViewComponent', () => { ); expect(quickStartButton).not.toBeNull(); - expect(quickStartButton?.textContent).toContain( - 'XTREAM.PLAY_FIRST_EPISODE' - ); + expect(quickStartButton?.textContent).toContain('XTREAM.PLAY'); expect(quickStartButton?.textContent).toContain('S01E01 · Episode 1'); quickStartButton?.click(); @@ -448,7 +446,9 @@ describe('StalkerSeriesViewComponent', () => { expect( fixture.componentInstance.quickStartAction()?.labelParams ).toEqual({ episode: 1 }); - expect(button?.textContent).toContain('Play episode 1'); + // The hero shows "Play" with the episode on the second line. + expect(button?.textContent).toContain('XTREAM.PLAY'); + expect(button?.textContent).toContain('S01E01 · Pilot'); expect(button?.textContent).not.toContain('{{episode}}'); }); @@ -605,9 +605,7 @@ describe('StalkerSeriesViewComponent', () => { expect(quickStartButton).not.toBeNull(); expect(quickStartButton?.disabled).toBe(false); - expect(quickStartButton?.textContent).toContain( - 'XTREAM.PLAY_NEXT_EPISODE' - ); + expect(quickStartButton?.textContent).toContain('XTREAM.PLAY'); expect(quickStartButton?.textContent).toContain('S01E01'); quickStartButton?.click(); @@ -1097,9 +1095,7 @@ describe('StalkerSeriesViewComponent', () => { ); expect(quickStartButton).not.toBeNull(); - expect(quickStartButton?.textContent).toContain( - 'XTREAM.PLAY_NEXT_EPISODE' - ); + expect(quickStartButton?.textContent).toContain('XTREAM.PLAY'); expect(quickStartButton?.textContent).toContain('S02E01'); quickStartButton?.click(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index fb26d5a24..49908dc96 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -9,9 +9,9 @@ import { signal, untracked, ChangeDetectionStrategy, + viewChild, } from '@angular/core'; import { Location } from '@angular/common'; -import { MatIcon } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; @@ -25,27 +25,34 @@ import { stalkerSeriesResumeKey, } from './stalker-series-resume'; import { + CastCrewRowComponent, + DetailActionButtonComponent, DetailActionsTemplateDirective, + DetailCreditsComponent, DetailMetaTemplateDirective, DetailTagsTemplateDirective, + MetaChipComponent, PortalDetailShellComponent, - ViewInPortalActionComponent, SeasonContainerComponent, SeasonContainerPlaybackToggleRequest, SeasonContainerSeasonPlaybackToggleRequest, SeasonContainerSeriesPlaybackToggleRequest, + SimilarRailComponent, + ViewInPortalActionComponent, + VodMoreMenuComponent, buildSeriesWatchToggleRequest, + scrollToCastCrewRow, } from '@iptvnator/ui/components'; import { - pickSeasonMarkedTitle, + ExternalPlayerName, PlaybackPositionData, ResolvedPortalPlayback, - seriesStatusLabelKey, TmdbEnrichedCastMember, XtreamSerieEpisode, + pickSeasonMarkedTitle, + seriesStatusLabelKey, youtubeEmbedUrl, } from '@iptvnator/shared/interfaces'; -import { SafePipe } from '@iptvnator/pipes'; import { isLiveExternalPlayerSession, isPortalPlaybackWatched, @@ -91,6 +98,8 @@ import { TmdbEnrichmentService, } from '@iptvnator/services'; import { StalkerSeriesTmdbSeasonsService } from './stalker-series-tmdb-seasons.service'; +import { StalkerSeriesHeroPresenter } from './stalker-series-hero.presenter'; +import { StalkerSeriesMenuService } from './stalker-series-menu.service'; import { getStalkerSeriesQuickStartButton, type StalkerQuickStartButton, @@ -168,22 +177,34 @@ interface StalkerSeriesPlaybackRequestContext { styleUrls: ['../styles/detail-view.scss'], imports: [ FavoritesButtonComponent, + CastCrewRowComponent, + DetailActionButtonComponent, DetailActionsTemplateDirective, + DetailCreditsComponent, DetailMetaTemplateDirective, DetailTagsTemplateDirective, + MetaChipComponent, PortalDetailShellComponent, + SimilarRailComponent, ViewInPortalActionComponent, + VodMoreMenuComponent, PortalInlinePlayerComponent, - SafePipe, TranslatePipe, SeasonContainerComponent, - MatIcon, ], changeDetection: ChangeDetectionStrategy.Eager, - providers: [StalkerSeriesTmdbSeasonsService], + providers: [ + StalkerSeriesTmdbSeasonsService, + StalkerSeriesHeroPresenter, + StalkerSeriesMenuService, + ], }) export class StalkerSeriesViewComponent implements OnDestroy { readonly stalkerStore = inject(StalkerStore); + readonly heroPresenter = inject(StalkerSeriesHeroPresenter); + readonly menu = inject(StalkerSeriesMenuService); + private readonly seasonContainerRef = + viewChild('seasonContainer'); private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); private readonly migrationPlaybackPositions = { savePlaybackPosition: ( @@ -362,6 +383,20 @@ export class StalkerSeriesViewComponent implements OnDestroy { readonly isSerialSeasonsLoading = this.stalkerStore.isSerialSeasonsLoading; constructor() { + this.heroPresenter.bind({ + displayItem: this.displayItem, + quickStart: this.quickStartAction, + yearLabel: (releaseDate) => this.discover.yearLabel(releaseDate), + similarInPortals: this.similarInPortals, + openSimilarInPortals: (item) => this.openSimilarInPortals(item), + }); + this.menu.bind({ + quickStart: this.quickStartAction, + seasonContainer: this.seasonContainerRef, + hasProgress: computed(() => this.episodePlaybackPositions().size > 0), + resetProgress: () => this.resetProgress(), + openExternal: (player) => this.openQuickStartExternally(player), + }); effect(() => { const ownerKey = this.seriesPlaybackOwnerKey(); untracked(() => this.syncSeriesPlaybackOwner(ownerKey)); @@ -627,10 +662,6 @@ export class StalkerSeriesViewComponent implements OnDestroy { return item ? normalizeStalkerVodDetailsItem(item) : null; }); - readonly trailerEmbedUrl = computed(() => - youtubeEmbedUrl(this.displayItem()?.info?.tmdb_trailer) - ); - private readonly seriesSeasonTitle = computed(() => pickSeasonMarkedTitle( this.displayItem()?.info?.name, @@ -959,7 +990,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { * offset for an episode whose position row the page could not attach * (matched by coordinates only), so it resumes where the card said. */ - onEpisodeClicked(episode: XtreamSerieEpisode, startTimeOverride?: number) { + onEpisodeClicked( + episode: XtreamSerieEpisode, + startTimeOverride?: number, + forcePlayer?: ExternalPlayerName + ) { const item = this.displayItem(); const episodeState = resolveSelectedStalkerEpisodeState({ episodesBySeason: this.mappedSeasons(), @@ -986,7 +1021,8 @@ export class StalkerSeriesViewComponent implements OnDestroy { title, item.info.movie_image, episodeState, - startTime + startTime, + forcePlayer ); } @@ -1006,6 +1042,25 @@ export class StalkerSeriesViewComponent implements OnDestroy { } } + readonly scrollToCast = scrollToCastCrewRow; + + /** Clears every saved episode position of the series. */ + async resetProgress(): Promise { + const request = + this.seasonContainerRef()?.watchPresenter.buildResetRequest(); + if (request) { + await this.handleSeriesPlaybackToggleRequestedFromUi(request); + } + } + + /** "Open in external player": the next episode, straight to MPV/VLC. */ + async openQuickStartExternally(player: ExternalPlayerName): Promise { + const episode = this.quickStartAction()?.action?.episode; + if (episode) { + this.onEpisodeClicked(episode, undefined, player); + } + } + openSimilarInPortals(item: CrossPortalSimilarItem): void { void this.router.navigate(this.crossPortalSimilar.buildLink(item)); } @@ -1152,14 +1207,16 @@ export class StalkerSeriesViewComponent implements OnDestroy { title: string | undefined, thumbnail: string | undefined, episodeState: SeriesPlaybackEpisodeState, - startTime?: number + startTime?: number, + forcePlayer?: ExternalPlayerName ): Promise { const generation = ++this.seriesPlaybackRequestGeneration; const episodeNum = episodeState.episodeNumber; const episodeId = Number(episodeState.episode.id); const request: StalkerSeriesPlaybackRequestContext = { generation, - usesEmbeddedPlayer: this.portalPlayer.isEmbeddedPlayer(), + usesEmbeddedPlayer: + !forcePlayer && this.portalPlayer.isEmbeddedPlayer(), identity: captureStalkerEpisodePlaybackSessionIdentity({ sourceId: this.stalkerStore.currentPlaylist()?._id, parentSeriesId: this.displayItem()?.id, @@ -1199,7 +1256,12 @@ export class StalkerSeriesViewComponent implements OnDestroy { } this.closeInlinePlayer(); - void this.portalPlayer.openResolvedPlayback(resolvedPlayback, true); + void (forcePlayer + ? this.portalPlayer.openExternalPlayback( + resolvedPlayback, + forcePlayer + ) + : this.portalPlayer.openResolvedPlayback(resolvedPlayback, true)); } catch (error) { if (!this.isPlaybackRequestCurrent(request)) return; this.logger.error('Failed to start inline series playback', error); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-download-adapter.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-download-adapter.service.ts new file mode 100644 index 000000000..d08686c20 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-download-adapter.service.ts @@ -0,0 +1,47 @@ +import { computed, inject, Injectable, Signal, signal } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { createXtreamSeriesDownloadMetadataContext } from './serial-download-metadata'; +import { createXtreamSeriesDownloadAdapter } from './xtream-series-download.adapter'; +import type { XtreamSerieDetailsView } from './serial-details-playback.service'; + +/** The season container's download adapter for the series on screen. */ +@Injectable() +export class SerialDetailsDownloadAdapterService { + private readonly xtreamStore = inject(XtreamStore); + private readonly translateService = inject(TranslateService); + private readonly selectedItem = signal | null>( + null + ); + + bind(selectedItem: Signal): void { + this.selectedItem.set(selectedItem); + } + + readonly adapter = computed(() => { + const playlist = this.xtreamStore.currentPlaylist(); + const item = this.selectedItem()?.() ?? null; + if (!playlist || !item) { + return null; + } + + return createXtreamSeriesDownloadAdapter({ + playlistId: playlist.id, + seriesId: Number(item.series_id), + title: item.info.name, + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + userAgent: playlist.userAgent, + referrer: playlist.referrer, + origin: playlist.origin, + metadataContext: createXtreamSeriesDownloadMetadataContext( + item.info, + this.translateService.currentLang || + this.translateService.defaultLang || + 'en' + ), + }); + }); +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-hero.presenter.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-hero.presenter.ts new file mode 100644 index 000000000..10157b114 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-hero.presenter.ts @@ -0,0 +1,132 @@ +import { computed, inject, Injectable, Signal, signal } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { + formatSeriesEpisodeCode, + type SeriesQuickStartAction, +} from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import type { CrossPortalSimilarItem } from '@iptvnator/services'; +import { youtubeEmbedUrl } from '@iptvnator/shared/interfaces'; +import { + createSeriesHeroState, + TrailerDialogService, + type SeriesHeroQuickStart, + type SimilarRailItem, +} from '@iptvnator/ui/components'; +import type { SimilarCatalogItem } from '../tmdb-similar.util'; +import type { XtreamSerieDetailsView } from './serial-details-playback.service'; + +interface SerialDetailsHeroBindings { + readonly selectedItem: Signal; + readonly quickStart: Signal; + readonly yearLabel: (releaseDate: string) => string | null; + readonly similarItems: Signal; + readonly similarInPortals: Signal; + readonly openSimilar: (item: SimilarCatalogItem) => void; + readonly openSimilarInPortals: (item: CrossPortalSimilarItem) => void; +} + +/** Hero presentation of the Xtream series page, see `createSeriesHeroState`. */ +@Injectable() +export class SerialDetailsHeroPresenter { + private readonly translate = inject(TranslateService); + private readonly xtreamStore = inject(XtreamStore); + private readonly trailerDialog = inject(TrailerDialogService); + private readonly bindings = signal(null); + private readonly info = computed( + () => this.bindings()?.selectedItem()?.info ?? null + ); + + bind(bindings: SerialDetailsHeroBindings): void { + this.bindings.set(bindings); + } + + readonly quickStart = computed(() => { + const action = this.bindings()?.quickStart() ?? null; + return action + ? { + labelKey: action.labelKey, + labelParams: action.labelParams, + episodeLabel: action.episodeLabel, + icon: action.icon, + disabled: action.disabled, + kind: action.kind, + position: action.position, + episodeCode: formatSeriesEpisodeCode( + action.episode.season, + action.episode.episode_num + ), + } + : null; + }); + + readonly hero = createSeriesHeroState({ + title: computed(() => this.info()?.name), + sourceLabel: computed(() => this.xtreamStore.currentPlaylist()?.name), + status: computed(() => this.info()?.tmdb_status), + year: computed(() => { + const releaseDate = this.info()?.releaseDate; + return releaseDate + ? (this.bindings()?.yearLabel(releaseDate) ?? + releaseDate.slice(0, 4)) + : null; + }), + tmdbGenres: computed(() => this.info()?.tmdb_genres), + genre: computed(() => this.info()?.genre), + tmdbCountries: computed(() => this.info()?.tmdb_countries), + tmdbCast: computed(() => this.info()?.tmdb_cast), + cast: computed(() => this.info()?.cast), + tmdbDirectors: computed(() => this.info()?.tmdb_directors), + director: computed(() => this.info()?.director), + quickStart: this.quickStart, + translate: this.translate, + }); + + readonly similarRailItems = computed(() => [ + ...(this.bindings()?.similarItems() ?? []).map((item) => ({ + key: `c${item.id}`, + title: item.title, + posterUrl: item.posterUrl, + year: item.year, + })), + ...(this.bindings()?.similarInPortals() ?? []).map((item) => ({ + key: `x${item.match.playlistId}-${item.match.xtreamId}`, + title: item.title, + posterUrl: item.posterUrl, + year: item.year, + tooltip: `${item.title} — ${item.match.playlistName}`, + })), + ]); + + readonly trailerEmbedUrl = computed(() => + youtubeEmbedUrl(this.info()?.youtube_trailer) + ); + + openTrailer(): void { + const embedUrl = this.trailerEmbedUrl(); + if (embedUrl) { + this.trailerDialog.open({ embedUrl, title: this.hero.title() }); + } + } + + openSimilarRailItem(item: SimilarRailItem): void { + const bindings = this.bindings(); + const local = bindings + ?.similarItems() + .find((candidate) => `c${candidate.id}` === item.key); + if (local) { + bindings?.openSimilar(local); + return; + } + const crossPortal = bindings + ?.similarInPortals() + .find( + (candidate) => + `x${candidate.match.playlistId}-${candidate.match.xtreamId}` === + item.key + ); + if (crossPortal) { + bindings?.openSimilarInPortals(crossPortal); + } + } +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts new file mode 100644 index 000000000..ee4ee42d3 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts @@ -0,0 +1,293 @@ +import { computed, inject, Injectable, Signal, signal } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { + createLogger, + formatSeriesEpisodeCode, + PORTAL_PLAYER, + type SeriesQuickStartAction, +} from '@iptvnator/portal/shared/util'; +import { + XTREAM_DATA_SOURCE, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; +import { DownloadsService, SettingsStore } from '@iptvnator/services'; +import { + VideoPlayer, + type ExternalPlayerName, + type PlayerContentInfo, + type ResolvedPortalPlayback, + type XtreamCategory, +} from '@iptvnator/shared/interfaces'; +import { + buildSeriesMenuSections, + SERIES_MENU_ACTION, + type SeasonContainerComponent, + type VodMoreMenuSection, +} from '@iptvnator/ui/components'; +import type { XtreamSerieDetailsView } from './serial-details-playback.service'; + +interface SerialDetailsMenuBindings { + readonly selectedItem: Signal; + readonly quickStart: Signal; + readonly seasonContainer: Signal; + /** The route's category id; the name comes from the store's list. */ + readonly categoryId: Signal; + readonly episodePositions: Signal>; + readonly resetProgress: () => Promise; +} + +/** + * The "…" menu of the Xtream series page: season and series watched toggles + * and the season download (driven through the season container's + * presenters), reset progress, external player and stream URL of the next + * episode, the category and Continue Watching. + */ +@Injectable() +export class SerialDetailsMenuService { + private readonly xtreamStore = inject(XtreamStore); + /** Optional: hosts without the data source simply get no history row. */ + private readonly dataSource = inject(XTREAM_DATA_SOURCE, { optional: true }); + private readonly portalPlayer = inject(PORTAL_PLAYER); + private readonly downloadsService = inject(DownloadsService); + private readonly settingsStore = inject(SettingsStore); + private readonly router = inject(Router); + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); + private readonly logger = createLogger('SerialDetailsMenu'); + private readonly bindings = signal(null); + /** Set once "Hide from Continue Watching" ran for the current series. */ + private readonly hiddenSeriesIds = signal>(new Set()); + + bind(bindings: SerialDetailsMenuBindings): void { + this.bindings.set(bindings); + // The recently viewed list tells whether "Hide from Continue + // Watching" applies; the catalog does not load it on its own. + const playlistId = this.xtreamStore.currentPlaylist()?.id; + if (playlistId) { + this.xtreamStore.loadRecentItems({ id: playlistId }); + } + } + + readonly externalPlayer = computed(() => + this.settingsStore.player() === VideoPlayer.VLC ? 'vlc' : 'mpv' + ); + + private readonly seriesId = computed(() => + Number(this.bindings()?.selectedItem()?.series_id ?? 0) + ); + + /** Any episode with a saved position: "Reset progress" applies. */ + private readonly hasProgress = computed( + () => (this.bindings()?.episodePositions().size ?? 0) > 0 + ); + + private readonly category = computed | null>(() => { + const categoryId = this.bindings()?.categoryId() ?? ''; + if (!categoryId) { + return null; + } + const categories = this.xtreamStore.serialCategories() as ReadonlyArray<{ + category_id?: string | number; + category_name?: string; + }>; + const found = categories.find( + (candidate) => String(candidate.category_id) === categoryId + ); + return { + category_id: String(found?.category_id ?? categoryId), + category_name: found?.category_name, + }; + }); + + /** Whether the series row sits in this playlist's recently viewed list. */ + private readonly inContinueWatching = computed(() => { + const seriesId = this.seriesId(); + if (!seriesId || !this.dataSource || this.hiddenSeriesIds().has(seriesId)) { + return false; + } + return ( + this.hasProgress() && + this.xtreamStore + .recentItems() + .some((item) => Number(item.xtream_id) === seriesId) + ); + }); + + readonly sections = computed(() => { + const container = this.bindings()?.seasonContainer(); + const watch = container?.watchPresenter; + const download = container?.downloadPresenter; + const quickStart = this.bindings()?.quickStart() ?? null; + const category = this.category(); + const episodeCode = quickStart + ? formatSeriesEpisodeCode( + quickStart.episode.season, + quickStart.episode.episode_num + ) + : null; + return buildSeriesMenuSections({ + seasonWatchVisible: watch?.seasonWatchToggleVisible() ?? false, + seasonFullyWatched: watch?.selectedSeasonFullyWatched() ?? false, + seasonEligibleCount: watch?.seasonWatchEligibleCount() ?? 0, + seasonActionDisabled: + (container?.seasonWatchBatchRunning() ?? false) || + (!(watch?.selectedSeasonFullyWatched() ?? false) && + (watch?.seasonWatchEligibleCount() ?? 0) === 0), + seriesMenuVisible: watch?.seriesMenuVisible() ?? false, + seriesFullyWatched: watch?.seriesFullyWatched() ?? false, + seriesEligibleCount: watch?.seriesWatchEligibleCount() ?? 0, + seriesCountKnown: watch?.seriesCountKnown() ?? true, + seriesActionDisabled: watch?.seriesActionDisabled() ?? true, + hasProgress: this.hasProgress(), + sourcesCount: 0, + externalPlayerHint: + quickStart && this.downloadsService.isAvailable() + ? this.externalPlayer() === 'vlc' + ? 'VLC' + : 'MPV' + : null, + copyUrlEpisodeCode: episodeCode, + downloadVisible: download?.presentationVisible() ?? false, + downloadCount: download?.eligibleEpisodeCount() ?? 0, + downloadDisabled: download?.seasonDisabled() ?? true, + downloadBusy: download?.batchRunning() ?? false, + categoryName: category?.category_name ?? null, + inContinueWatching: this.inContinueWatching(), + }); + }); + + async run(actionId: string): Promise { + const container = this.bindings()?.seasonContainer(); + switch (actionId) { + case SERIES_MENU_ACTION.SeasonWatched: + container?.watchPresenter.toggleSeasonWatched(); + return; + case SERIES_MENU_ACTION.SeriesWatched: + container?.watchPresenter.toggleSeriesWatched(); + return; + case SERIES_MENU_ACTION.ResetProgress: + await this.bindings()?.resetProgress(); + return; + case SERIES_MENU_ACTION.DownloadSeason: + await container?.downloadPresenter.enqueueSeason(); + return; + case SERIES_MENU_ACTION.ExternalPlayer: + await this.openExternal(); + return; + case SERIES_MENU_ACTION.CopyUrl: + await this.copyStreamUrl(); + return; + case SERIES_MENU_ACTION.ShowInCategory: + this.showInCategory(); + return; + case SERIES_MENU_ACTION.HideFromContinueWatching: + await this.hideFromContinueWatching(); + return; + } + } + + /** The next episode, resolved the way the inline player would play it. */ + private buildEpisodePlayback(): ResolvedPortalPlayback | null { + const quickStart = this.bindings()?.quickStart(); + const item = this.bindings()?.selectedItem(); + const playlist = this.xtreamStore.currentPlaylist(); + if (!quickStart || !item || !playlist) { + return null; + } + const episode = quickStart.episode; + const contentInfo: PlayerContentInfo = { + playlistId: playlist.id, + contentXtreamId: Number(episode.id), + contentType: 'episode', + seriesXtreamId: Number(item.series_id), + seasonNumber: Number(episode.season), + episodeNumber: Number(episode.episode_num), + }; + return { + streamUrl: this.xtreamStore.constructEpisodeStreamUrl(episode), + title: episode.title, + thumbnail: item.info?.cover, + ...(quickStart.position?.positionSeconds && + quickStart.kind === 'resume' + ? { startTime: quickStart.position.positionSeconds } + : {}), + contentInfo, + }; + } + + private async openExternal(): Promise { + const playback = this.buildEpisodePlayback(); + if (!playback) { + return; + } + try { + await this.portalPlayer.openExternalPlayback( + playback, + this.externalPlayer() + ); + } catch (error) { + this.logger.warn('External episode launch failed', error); + this.notify('PORTALS.PLAYBACK_ERROR'); + } + } + + private async copyStreamUrl(): Promise { + const playback = this.buildEpisodePlayback(); + if (!playback) { + return; + } + try { + await navigator.clipboard.writeText(playback.streamUrl); + this.notify('PORTALS.STREAM_URL_COPIED'); + } catch (error) { + this.logger.warn('Copying the stream URL failed', error); + this.notify('DOWNLOADS.URL_COPY_FAILED'); + } + } + + private showInCategory(): void { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + const categoryId = this.category()?.category_id; + if (!playlistId || categoryId === undefined || categoryId === null) { + return; + } + void this.router.navigate([ + '/workspace/xtreams', + playlistId, + 'series', + String(categoryId), + ]); + } + + private async hideFromContinueWatching(): Promise { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + const seriesId = this.seriesId(); + if (!playlistId || !seriesId || !this.dataSource) { + return; + } + try { + const content = await this.dataSource.getContentByXtreamId( + seriesId, + playlistId, + 'series' + ); + if (!content?.id) { + return; + } + await this.dataSource.removeRecentItem(content.id, playlistId); + this.hiddenSeriesIds.update((ids) => new Set(ids).add(seriesId)); + this.xtreamStore.loadRecentItems({ id: playlistId }); + this.notify('PORTALS.DETAIL.HIDDEN_FROM_CONTINUE_WATCHING'); + } catch (error) { + this.logger.warn('Hiding the series from history failed', error); + } + } + + private notify(key: string): void { + this.snackBar.open(this.translate.instant(key), undefined, { + duration: 3000, + }); + } +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index 1d9ab441f..007ca6892 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -1,8 +1,10 @@ @let item = selectedItem(); @if (item && item.info) { @let info = item.info; + @let hero = heroPresenter.hero; - @if (info.tmdb_status; as status) { - {{ - seriesStatusLabelKey(status) | translate - }} + @if (hero.seasonChip(); as season) { + + {{ 'PORTALS.SEASON_TAB' | translate: { number: season } }} + } - @if (info.releaseDate) { - @if (discover.canOpenYear(info.releaseDate)) { - - } @else { - {{ - discover.yearLabel(info.releaseDate) ?? - (info.releaseDate | slice: 0 : 4) - }} - } + @if (hero.statusLabel(); as status) { + + {{ status }} + } - @if (info.tmdb_genres?.length) { - @for (genre of info.tmdb_genres; track genre.id) { - - } - } @else if (info.genre) { - {{ info.genre }} + @if (hero.year(); as year) { + + @if (info.releaseDate && discover.canOpenYear(info.releaseDate)) { + + } @else { + {{ year }} + } + + } + @if (hero.genreChips().length) { + + @for (genre of hero.genreChips(); track genre.label) { + @if (!$first) { + + } + @if (genre.facet; as facet) { + + } @else { + {{ genre.label }} + } + } + } @if (info.episode_run_time) { - {{ info.episode_run_time }} min/ep + {{ info.episode_run_time }} min/ep + } + @for (country of hero.countryChips(); track country.label) { + + @if (country.facet; as facet) { + + } @else { + {{ country.label }} + } + } @if (info.rating) { - - ⭐ {{ info.rating }} - - } - @if (info.tmdb_countries?.length) { - @for (country of info.tmdb_countries; track country.code) { - - } + ★ {{ info.rating }} } - @if (info.cast || info.tmdb_cast?.length) { -
- {{ 'XTREAM.CAST' | translate }} - @if (info.tmdb_cast?.length) { -
- @for ( - member of info.tmdb_cast; - track member.tmdbPersonId - ? 'p' + member.tmdbPersonId - : 'i' + $index - ) { - - @if (member.profileUrl) { - - } @else { - - {{ member.name.charAt(0) }} - - } - {{ - member.name - }} - - } -
- } @else { - {{ info.cast }} - } -
- } - @if (info.director || info.tmdb_directors?.length) { -
- {{ - 'XTREAM.DIRECTOR' | translate - }} - @if (info.tmdb_directors?.length) { -
- @for ( - member of info.tmdb_directors; - track member.tmdbPersonId - ? 'p' + member.tmdbPersonId - : 'i' + $index - ) { - - @if (member.profileUrl) { - - } @else { - - {{ member.name.charAt(0) }} - - } - {{ - member.name - }} - - } -
- } @else { - {{ info.director }} - } -
- } +
- @if (quickStartAction(); as action) { - + @if (hero.primaryAction(); as primary) { + } - @if (!isFavorite()) { - - } @else { - + @if (heroPresenter.trailerEmbedUrl()) { + } + + @@ -236,6 +159,7 @@ }
- @if (similarItems().length || similarInPortals().length) { -
-

{{ 'XTREAM.SIMILAR' | translate }}

-
- @for (similar of similarItems(); track similar.id) { - - } - @for ( - item of similarInPortals(); - track 'x' + - item.match.playlistId + - '-' + - item.match.xtreamId - ) { - - } -
-
- } + +
} @else if (detailsError()) { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 120846c99..87f56a901 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -25,9 +25,13 @@ import { } from '@iptvnator/portal/shared/util'; import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; +import { + DownloadsService, + PlaybackPositionRuntimeBridgeService, + SettingsStore, +} from '@iptvnator/services'; import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; -import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { PlaybackPositionData, VideoPlayer } from '@iptvnator/shared/interfaces'; import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback'; import { BehaviorSubject, EMPTY, of } from 'rxjs'; import { SerialDetailsComponent } from './serial-details.component'; @@ -247,8 +251,19 @@ describe('SerialDetailsComponent', () => { addRecentItem, backfillContentMetadata: jest.fn(), loadAllPositions, + recentItems: signal([]), + serialCategories: signal([]), + loadRecentItems: jest.fn(), }, }, + { + provide: SettingsStore, + useValue: { player: signal(VideoPlayer.Html5Player) }, + }, + { + provide: DownloadsService, + useValue: { isAvailable: signal(false) }, + }, { provide: PORTAL_EXTERNAL_PLAYBACK, useValue: { @@ -555,9 +570,7 @@ describe('SerialDetailsComponent', () => { ); expect(quickStartButton).not.toBeNull(); - expect(quickStartButton?.textContent).toContain( - 'XTREAM.PLAY_FIRST_EPISODE' - ); + expect(quickStartButton?.textContent).toContain('XTREAM.PLAY'); expect(quickStartButton?.textContent).toContain('S01E01 · Episode 1'); quickStartButton?.click(); @@ -619,9 +632,9 @@ describe('SerialDetailsComponent', () => { ); expect(quickStartButton?.textContent).toContain( - 'XTREAM.RESUME_EPISODE' + 'WORKSPACE.DASHBOARD.HERO_CONTINUE' ); - expect(quickStartButton?.textContent).toContain('S01E01 · Episode 1'); + expect(quickStartButton?.textContent).toContain('S01E01'); quickStartButton?.click(); @@ -671,12 +684,16 @@ describe('SerialDetailsComponent', () => { updatedAt: expect.any(String), }) ); + // The launched episode's position lands two microtasks after the + // save: `recordExternalLaunch` awaits the launch, then the save. + await Promise.resolve(); + await Promise.resolve(); fixture.detectChanges(); const quickStartButton: HTMLButtonElement | null = fixture.nativeElement.querySelector( '[data-testid="series-quick-start"]' ); - expect(quickStartButton?.textContent).toContain('XTREAM.PLAY_EPISODE'); + expect(quickStartButton?.textContent).toContain('XTREAM.PLAY'); expect(quickStartButton?.textContent).toContain( 'S02E01 \u00b7 Season 2 Episode 1' ); @@ -777,7 +794,7 @@ describe('SerialDetailsComponent', () => { '[data-testid="series-quick-start"]' ); expect(quickStartButton()?.textContent).not.toContain( - 'XTREAM.RESUME_EPISODE' + 'WORKSPACE.DASHBOARD.HERO_CONTINUE' ); positionUpdateCallback({ @@ -793,9 +810,9 @@ describe('SerialDetailsComponent', () => { fixture.detectChanges(); expect(quickStartButton()?.textContent).toContain( - 'XTREAM.RESUME_EPISODE' + 'WORKSPACE.DASHBOARD.HERO_CONTINUE' ); - expect(quickStartButton()?.textContent).toContain('S01E01 · Episode 1'); + expect(quickStartButton()?.textContent).toContain('S01E01'); }); it('persists the launched episode after an external fallback succeeds', async () => { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 57d33a8ce..846c1895a 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -1,32 +1,38 @@ -import { Location, SlicePipe } from '@angular/common'; +import { Location } from '@angular/common'; import { Component, computed, effect, inject, OnDestroy, - OnInit, signal, untracked, ChangeDetectionStrategy, + viewChild, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { MatIcon } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { + CastCrewRowComponent, + DetailActionButtonComponent, DetailActionsTemplateDirective, + DetailCreditsComponent, + DetailIconButtonComponent, DetailMetaTemplateDirective, DetailTagsTemplateDirective, + MetaChipComponent, PortalDetailShellComponent, - ViewInPortalActionComponent, SeasonContainerComponent, SeasonContainerPlaybackToggleRequest, SeasonContainerSeasonPlaybackToggleRequest, SeasonContainerSeriesPlaybackToggleRequest, + SimilarRailComponent, + ViewInPortalActionComponent, + VodMoreMenuComponent, + scrollToCastCrewRow, } from '@iptvnator/ui/components'; -import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access'; import { registerContentMetadataBackfill, XtreamStore, @@ -38,7 +44,6 @@ import { type UpNextRailItem, } from '@iptvnator/ui/playback'; import { - seriesStatusLabelKey, TmdbEnrichedCastMember, XtreamSerieDetails, XtreamSerieEpisode, @@ -62,9 +67,10 @@ import { import { SerialDetailsSeasonWatchService } from './serial-details-season-watch.service'; import { SerialDetailsSimilarService } from './serial-details-similar.service'; import { SimilarCatalogItem } from '../tmdb-similar.util'; -import { createXtreamSeriesDownloadMetadataContext } from './serial-download-metadata'; -import { createXtreamSeriesDownloadAdapter } from './xtream-series-download.adapter'; import { createSerialPlaybackSessionKey } from './serial-playback-session-key'; +import { SerialDetailsHeroPresenter } from './serial-details-hero.presenter'; +import { SerialDetailsMenuService } from './serial-details-menu.service'; +import { SerialDetailsDownloadAdapterService } from './serial-details-download-adapter.service'; @Component({ selector: 'app-serial-details', @@ -86,66 +92,56 @@ import { createSerialPlaybackSessionKey } from './serial-playback-session-key'; SerialDetailsPlaybackService, SerialDetailsSeasonWatchService, SerialDetailsSimilarService, + SerialDetailsHeroPresenter, + SerialDetailsMenuService, + SerialDetailsDownloadAdapterService, ], changeDetection: ChangeDetectionStrategy.Eager, imports: [ + CastCrewRowComponent, + DetailActionButtonComponent, DetailActionsTemplateDirective, + DetailCreditsComponent, + DetailIconButtonComponent, DetailMetaTemplateDirective, DetailTagsTemplateDirective, - MatIcon, + MetaChipComponent, PortalDetailShellComponent, - ViewInPortalActionComponent, PortalInlinePlayerComponent, SeasonContainerComponent, - SlicePipe, + SimilarRailComponent, TranslatePipe, + ViewInPortalActionComponent, + VodMoreMenuComponent, ], }) -export class SerialDetailsComponent implements OnInit, OnDestroy { +export class SerialDetailsComponent implements OnDestroy { private readonly location = inject(Location); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly crossPortalSimilar = inject(CrossPortalSimilarService); - /** Maps the status token to its translated label key */ - readonly seriesStatusLabelKey = seriesStatusLabelKey; private readonly xtreamStore = inject(XtreamStore); private readonly playback = inject(SerialDetailsPlaybackService); private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); + readonly heroPresenter = inject(SerialDetailsHeroPresenter); + readonly menu = inject(SerialDetailsMenuService); + readonly seasonContainer = + viewChild('seasonContainer'); + readonly selectedItem = signal(null); readonly selectedContentType = this.xtreamStore.selectedContentType; + readonly scrollToCast = scrollToCastCrewRow; readonly isFavorite = this.xtreamStore.isFavorite; readonly isLoadingDetails = this.xtreamStore.isLoadingDetails; readonly detailsError = this.xtreamStore.detailsError; readonly currentPlaylistId = signal(''); - readonly episodeDownloadAdapter = - computed(() => { - const playlist = this.xtreamStore.currentPlaylist(); - const item = this.selectedItem(); - if (!playlist || !item) { - return null; - } - - return createXtreamSeriesDownloadAdapter({ - playlistId: playlist.id, - seriesId: Number(item.series_id), - title: item.info.name, - serverUrl: playlist.serverUrl, - username: playlist.username, - password: playlist.password, - userAgent: playlist.userAgent, - referrer: playlist.referrer, - origin: playlist.origin, - metadataContext: createXtreamSeriesDownloadMetadataContext( - item.info, - this.translateService.currentLang || - this.translateService.defaultLang || - 'en' - ), - }); - }); + private readonly downloadAdapter = inject( + SerialDetailsDownloadAdapterService + ); + readonly episodeDownloadAdapter = this.downloadAdapter.adapter; /** `playlistId:categoryId:serialId` of the last initialized view */ private readonly lastInitKey = signal(null); @@ -207,6 +203,24 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { constructor() { this.playback.bind({ selectedItem: this.selectedItem }); this.similar.bind({ selectedItem: this.selectedItem }); + this.downloadAdapter.bind(this.selectedItem); + this.heroPresenter.bind({ + selectedItem: this.selectedItem, + quickStart: this.quickStartAction, + yearLabel: (releaseDate) => this.discover.yearLabel(releaseDate), + similarItems: this.similarItems, + similarInPortals: this.similarInPortals, + openSimilar: (item) => this.openSimilar(item), + openSimilarInPortals: (item) => this.openSimilarInPortals(item), + }); + this.menu.bind({ + selectedItem: this.selectedItem, + quickStart: this.quickStartAction, + seasonContainer: this.seasonContainer, + categoryId: computed(() => String(this.routeParams()['categoryId'] ?? '')), + episodePositions: this.episodePlaybackPositions, + resetProgress: () => this.resetProgress(), + }); // TMDB season enrichment, keyed on (tmdb_id, selected season). With // season tabs the first seasonSelected fires as soon as seasons load — @@ -273,11 +287,6 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { }); } - ngOnInit(): void { - // Initialization is handled by the params-driven effect in the - // constructor; the hook remains for interface compatibility. - } - ngOnDestroy(): void { this.xtreamStore.cancelDetailsRequest(); this.playback.closeInlinePlayer(); @@ -288,6 +297,14 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { void this.router.navigate(this.crossPortalSimilar.buildLink(item)); } + /** Clears every saved episode position of the series. */ + resetProgress(): Promise { + const request = this.seasonContainer()?.watchPresenter.buildResetRequest(); + return request + ? this.playback.handleWatchToggleRequested(request, 'series') + : Promise.resolve(); + } + openSimilar(item: SimilarCatalogItem): void { void this.router.navigate(['../..', item.categoryId, item.id], { relativeTo: this.route, diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 5f3f091d8..18c9814b8 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -3,7 +3,6 @@ @if (playableItem || isDownloaded()) { @let primary = hero.primaryAction(); ; + readonly episodeLabel: string | null; + readonly icon: string; + readonly disabled: boolean; + readonly kind: SeriesQuickStartActionKind | null; + readonly position: PlaybackPositionData | null; + /** "S02E03" of the episode the button acts on. */ + readonly episodeCode: string | null; +} + +export interface SeriesHeroFacetChip { + readonly label: string; + readonly facet: F | null; +} + +export interface SeriesHeroPrimaryAction { + readonly label: string; + readonly meta: string | null; + readonly icon: string; + readonly disabled: boolean; +} + +export interface SeriesHeroStateDeps { + readonly title: Signal; + readonly sourceLabel: Signal; + readonly status: Signal; + readonly year: Signal; + readonly tmdbGenres: Signal; + readonly genre: Signal; + readonly tmdbCountries: Signal; + readonly country?: Signal; + readonly tmdbCast: Signal; + readonly cast: Signal; + readonly tmdbDirectors: Signal; + readonly director: Signal; + readonly quickStart: Signal; + readonly translate: Pick; +} + +/** + * Everything the series hero shows: the title without its season suffix, + * the chips (season, status, year, genres, countries), the credits and the + * primary button's two lines ("Continue" / "S02E03 · 18m left"). + */ +export function createSeriesHeroState(deps: SeriesHeroStateDeps) { + const translate = (key: string, params?: Record) => + deps.translate.instant(key, params); + const titleParts = computed(() => splitSeasonSuffix(deps.title())); + const castMembers = computed(() => + deps.tmdbCast()?.length + ? [...(deps.tmdbCast() ?? [])] + : castMembersFromNames(splitPeopleNames(deps.cast())) + ); + const directorMembers = computed(() => + deps.tmdbDirectors()?.length + ? [...(deps.tmdbDirectors() ?? [])] + : castMembersFromNames(splitPeopleNames(deps.director())) + ); + + return { + kindLabel: computed(() => { + const kind = translate('WORKSPACE.DASHBOARD.TYPE_SERIES'); + const source = deps.sourceLabel()?.trim(); + return source ? `${kind} · ${source}` : kind; + }), + title: computed(() => titleParts().title), + seasonChip: computed(() => titleParts().season), + year: computed(() => deps.year() ?? null), + statusLabel: computed(() => { + const status = deps.status(); + return status ? translate(seriesStatusLabelKey(status)) : null; + }), + genreChips: computed[]>(() => { + const facets = deps.tmdbGenres(); + if (facets?.length) { + return facets.map((facet) => ({ label: facet.name, facet })); + } + return (deps.genre() ?? '') + .split(/[,/|]/) + .map((genre) => genre.trim()) + .filter((genre) => genre.length > 0) + .map((label) => ({ label, facet: null })); + }), + countryChips: computed[]>(() => { + const facets = deps.tmdbCountries(); + if (facets?.length) { + return facets.map((facet) => ({ + label: shortCountryName(facet.name, facet.code), + facet, + })); + } + return shortCountryList(deps.country?.()).map((label) => ({ + label, + facet: null, + })); + }), + castMembers, + directorMembers, + castNames: computed(() => castMembers().map((member) => member.name)), + directorNames: computed(() => + directorMembers().map((member) => member.name) + ), + primaryAction: computed(() => { + const quickStart = deps.quickStart(); + if (!quickStart) { + return null; + } + if (quickStart.kind === 'completed') { + return { + label: translate(quickStart.labelKey, quickStart.labelParams), + meta: quickStart.episodeLabel, + icon: quickStart.icon, + disabled: true, + }; + } + if (quickStart.kind === 'resume') { + const remaining = formatRemainingLabel(quickStart.position); + const parts = [ + quickStart.episodeCode, + remaining ? translate(remaining.key, remaining.params) : null, + ].filter((part): part is string => !!part); + return { + label: translate('WORKSPACE.DASHBOARD.HERO_CONTINUE'), + meta: parts.length ? parts.join(' · ') : quickStart.episodeLabel, + icon: quickStart.icon, + disabled: quickStart.disabled, + }; + } + return { + label: translate('XTREAM.PLAY'), + meta: quickStart.episodeLabel, + icon: quickStart.icon, + disabled: quickStart.disabled, + }; + }), + }; +} + +export const SERIES_MENU_ACTION = { + SeasonWatched: 'season-watched', + SeriesWatched: 'series-watched', + ResetProgress: 'reset-progress', + Sources: 'sources', + ExternalPlayer: 'external-player', + CopyUrl: 'copy-url', + DownloadSeason: 'download-season', + ShowInCategory: 'show-in-category', + HideFromContinueWatching: 'hide-continue-watching', +} as const; + +export interface SeriesMenuInput { + readonly seasonWatchVisible: boolean; + readonly seasonFullyWatched: boolean; + readonly seasonEligibleCount: number; + readonly seasonActionDisabled: boolean; + readonly seriesMenuVisible: boolean; + readonly seriesFullyWatched: boolean; + readonly seriesEligibleCount: number; + readonly seriesCountKnown: boolean; + readonly seriesActionDisabled: boolean; + readonly hasProgress: boolean; + readonly sourcesCount: number; + readonly externalPlayerHint: 'MPV' | 'VLC' | null; + readonly copyUrlEpisodeCode: string | null; + readonly downloadVisible: boolean; + readonly downloadCount: number; + readonly downloadDisabled: boolean; + readonly downloadBusy: boolean; + readonly categoryName: string | null; + readonly inContinueWatching: boolean; +} + +/** Rows of the series "…" menu. Rows a provider cannot serve are left out. */ +export function buildSeriesMenuSections(input: SeriesMenuInput): VodMoreMenuSection[] { + const watching: VodMoreMenuSection['items'][number][] = []; + if (input.seasonWatchVisible) { + watching.push({ + id: SERIES_MENU_ACTION.SeasonWatched, + labelKey: input.seasonFullyWatched + ? 'XTREAM.MARK_SEASON_UNWATCHED' + : 'XTREAM.MARK_SEASON_WATCHED', + labelParams: { count: input.seasonEligibleCount }, + icon: input.seasonFullyWatched ? 'remove_done' : 'done_all', + disabled: input.seasonActionDisabled, + testId: 'toggle-season-watched', + }); + } + if (input.seriesMenuVisible) { + watching.push({ + id: SERIES_MENU_ACTION.SeriesWatched, + labelKey: input.seriesFullyWatched + ? 'XTREAM.MARK_SERIES_UNWATCHED' + : input.seriesCountKnown + ? 'XTREAM.MARK_SERIES_WATCHED' + : 'XTREAM.MARK_SERIES_WATCHED_ALL', + labelParams: { count: input.seriesEligibleCount }, + icon: input.seriesFullyWatched ? 'remove_done' : 'done_all', + disabled: input.seriesActionDisabled, + testId: 'toggle-series-watched', + }); + } + if (input.hasProgress && !input.seriesFullyWatched) { + watching.push({ + id: SERIES_MENU_ACTION.ResetProgress, + labelKey: 'PORTALS.DETAIL.RESET_PROGRESS', + icon: 'history_toggle_off', + disabled: input.seriesActionDisabled, + testId: 'vod-menu-reset-progress', + }); + } + const source: VodMoreMenuSection['items'][number][] = []; + if (input.sourcesCount > 1) { + source.push({ + id: SERIES_MENU_ACTION.Sources, + labelKey: 'PORTALS.DETAIL.OTHER_SOURCES', + icon: 'video_library', + hint: input.sourcesCount, + kind: 'sources', + testId: 'vod-menu-sources', + }); + } + if (input.externalPlayerHint) { + source.push({ + id: SERIES_MENU_ACTION.ExternalPlayer, + labelKey: 'PORTALS.DETAIL.OPEN_IN_EXTERNAL_PLAYER', + icon: 'open_in_new', + hint: input.externalPlayerHint, + testId: 'vod-menu-external', + }); + } + if (input.copyUrlEpisodeCode) { + source.push({ + id: SERIES_MENU_ACTION.CopyUrl, + labelKey: 'PORTALS.COPY_STREAM_URL', + icon: 'link', + hint: input.copyUrlEpisodeCode, + testId: 'vod-menu-copy-url', + }); + } + const download: VodMoreMenuSection['items'][number][] = []; + if (input.downloadVisible) { + download.push({ + id: SERIES_MENU_ACTION.DownloadSeason, + labelKey: input.downloadBusy + ? 'DOWNLOADS.ADDING_TO_QUEUE' + : 'DOWNLOADS.DOWNLOAD_SEASON', + labelParams: { count: input.downloadCount }, + icon: 'download', + disabled: input.downloadDisabled, + testId: 'download-season', + }); + } + const place: VodMoreMenuSection['items'][number][] = []; + if (input.categoryName) { + place.push({ + id: SERIES_MENU_ACTION.ShowInCategory, + labelKey: 'PORTALS.DETAIL.SHOW_IN_CATEGORY', + icon: 'folder_open', + hint: input.categoryName, + testId: 'vod-menu-show-in-category', + }); + } + if (input.inContinueWatching) { + place.push({ + id: SERIES_MENU_ACTION.HideFromContinueWatching, + labelKey: 'PORTALS.DETAIL.HIDE_FROM_CONTINUE_WATCHING', + icon: 'visibility_off', + testId: 'vod-menu-hide-continue', + }); + } + return [ + { labelKey: 'PORTALS.DETAIL.GROUP_WATCHING', items: watching }, + { labelKey: 'PORTALS.DETAIL.GROUP_SOURCE', items: source }, + { items: download }, + { items: place }, + ]; +} 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 2bb13242a..285a887dc 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 @@ -1,37 +1,66 @@
- + +
+

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

+ @if (hasSeasons()) { + + @if (selectedSeasonEpisodes().length; as count) { + + {{ + (count === 1 + ? 'PORTALS.EPISODE_COUNT_ONE' + : 'PORTALS.EPISODE_COUNT_OTHER' + ) | translate: { count } + }} + + } + } + @if (hasSeasons() && !showSeasonEmptyState()) { +
+ + + grid_view + + + view_list + + +
+ } +
- @if (hasSeasons()) { - -
+ @if (hasSeasons() && (selectedSeasonPosterUrl() || selectedSeasonDescription())) { + +
@if (selectedSeasonPosterUrl(); as posterUrl) { } - + @if (selectedSeasonDescription(); as description) { + + }
} @@ -133,19 +159,16 @@
} @if (getEpisodeProgress(episode) > 0) { - - @if ( - getEpisodePositionText(episode); - as time - ) { -
- {{ time }} -
- } + } - } - @if (downloadVisible()) { - - } - @if (viewToggleVisible()) { -
- - - grid_view - - - view_list - - -
- } - @if (seriesMenuVisible()) { - - - - - } - - } - diff --git a/libs/ui/components/src/lib/season-container/season-header.component.scss b/libs/ui/components/src/lib/season-container/season-header.component.scss deleted file mode 100644 index 880648db2..000000000 --- a/libs/ui/components/src/lib/season-container/season-header.component.scss +++ /dev/null @@ -1,132 +0,0 @@ -:host { - display: block; -} - -.section-header { - display: flex; - // The actions move onto their own row before the title has to wrap. The - // pane is narrower than the window (context panel, Back lane), so a - // viewport breakpoint cannot tell when the two stop fitting side by side. - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - margin-bottom: 20px; - min-height: 32px; - gap: 16px; - - &__left { - display: flex; - align-items: center; - gap: 16px; - flex: 1 1 auto; - } - - // Beside the title the header's space-between keeps the actions at the - // end; once they move to their own row they start under the title. - &__actions { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: flex-start; - gap: 8px; - } - - .section-title { - font-size: 1.25rem; - font-weight: 600; - margin: 0; - color: var(--text-primary); - letter-spacing: -0.01em; - } - - .view-toggle { - mat-button-toggle-group { - border: 1px solid - color-mix(in srgb, var(--app-on-surface) 14%, transparent); - border-radius: 6px; - overflow: hidden; - background: color-mix( - in srgb, - var(--app-on-surface) 3%, - transparent - ); - - ::ng-deep { - .mat-button-toggle { - background: transparent; - - .mat-button-toggle-button { - padding: 0 8px; - height: 32px; - } - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: var(--text-secondary); - } - - &.mat-button-toggle-checked { - background: var(--app-selection-surface); - - mat-icon { - color: var(--app-selection-color); - } - } - - &:hover:not(.mat-button-toggle-checked) { - background: var(--app-hover-overlay); - } - } - - .mat-button-toggle-appearance-standard { - .mat-button-toggle-label-content { - line-height: 32px; - padding: 0; - } - } - - .mat-button-toggle + .mat-button-toggle { - border-left: 1px solid - color-mix( - in srgb, - var(--app-on-surface) 14%, - transparent - ); - } - } - } - } -} - -.season-download-button, -.season-watch-toggle-button { - min-height: 36px; - - mat-spinner { - display: inline-block; - margin-inline-end: 8px; - } -} - -.series-watch-menu-trigger { - flex-shrink: 0; -} - -.series-watch-menu-item mat-spinner { - display: inline-block; - margin-inline-end: 16px; - vertical-align: middle; -} - -@media (max-width: 560px) { - .section-header { - align-items: flex-start; - - &__actions { - width: 100%; - justify-content: space-between; - } - } -} diff --git a/libs/ui/components/src/lib/season-container/season-header.component.ts b/libs/ui/components/src/lib/season-container/season-header.component.ts deleted file mode 100644 index c3dd3f5c3..000000000 --- a/libs/ui/components/src/lib/season-container/season-header.component.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - input, - output, -} from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatButtonToggleModule } from '@angular/material/button-toggle'; -import { MatIcon } from '@angular/material/icon'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; - -export type EpisodeViewMode = 'grid' | 'list'; - -@Component({ - selector: 'app-season-header', - templateUrl: './season-header.component.html', - styleUrls: ['./season-header.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - MatButtonModule, - MatButtonToggleModule, - MatIcon, - MatMenuModule, - MatProgressSpinnerModule, - MatTooltipModule, - TranslateModule, - ], -}) -export class SeasonHeaderComponent { - readonly downloadVisible = input.required(); - readonly batchRunning = input.required(); - readonly seasonDisabled = input.required(); - readonly eligibleEpisodeCount = input.required(); - readonly watchToggleVisible = input.required(); - readonly watchBatchRunning = input.required(); - readonly seasonFullyWatched = input.required(); - readonly watchEligibleCount = input.required(); - readonly seriesMenuVisible = input.required(); - readonly seriesActionDisabled = input.required(); - readonly seriesFullyWatched = input.required(); - readonly seriesEligibleCount = input.required(); - /** False while unloaded seasons make the series count a guess. */ - readonly seriesCountKnown = input.required(); - readonly viewToggleVisible = input.required(); - readonly viewMode = input.required(); - - readonly downloadSeason = output(); - readonly toggleSeasonWatched = output(); - readonly toggleSeriesWatched = output(); - readonly viewModeChange = output(); - - seriesActionLabelKey(): string { - if (this.seriesFullyWatched()) { - return 'XTREAM.MARK_SERIES_UNWATCHED'; - } - return this.seriesCountKnown() - ? 'XTREAM.MARK_SERIES_WATCHED' - : 'XTREAM.MARK_SERIES_WATCHED_ALL'; - } -} diff --git a/libs/ui/components/src/lib/season-container/season-tabs.component.html b/libs/ui/components/src/lib/season-container/season-tabs.component.html index a2b73eccd..478c73e71 100644 --- a/libs/ui/components/src/lib/season-container/season-tabs.component.html +++ b/libs/ui/components/src/lib/season-container/season-tabs.component.html @@ -96,12 +96,3 @@ } - -@if (seasonDescription(); as description) { - -} diff --git a/libs/ui/components/src/lib/season-container/season-tabs.component.scss b/libs/ui/components/src/lib/season-container/season-tabs.component.scss index 248b7c60d..093642b62 100644 --- a/libs/ui/components/src/lib/season-container/season-tabs.component.scss +++ b/libs/ui/components/src/lib/season-container/season-tabs.component.scss @@ -117,12 +117,3 @@ font-weight: 700; } } - -.season-tabs__description { - margin: 10px 4px 0; - max-width: 760px; - font-size: 12.5px; - line-height: 1.55; - color: var(--text-secondary, rgba(255, 255, 255, 0.7)); - // Clamping + the show more/less toggle live in app-expandable-text. -} diff --git a/libs/ui/components/src/lib/season-container/season-tabs.component.ts b/libs/ui/components/src/lib/season-container/season-tabs.component.ts index 5de4d270a..65ab2edf5 100644 --- a/libs/ui/components/src/lib/season-container/season-tabs.component.ts +++ b/libs/ui/components/src/lib/season-container/season-tabs.component.ts @@ -9,7 +9,6 @@ import { import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { TranslateModule } from '@ngx-translate/core'; -import { ExpandableTextComponent } from '../expandable-text/expandable-text.component'; /** Above this count the pill row becomes a dropdown selector. */ const MAX_SEASON_PILLS = 6; @@ -34,7 +33,7 @@ const MAX_SEASON_PILLS = 6; templateUrl: './season-tabs.component.html', styleUrls: ['./season-tabs.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ExpandableTextComponent, MatIcon, MatMenuModule, TranslateModule], + imports: [MatIcon, MatMenuModule, TranslateModule], }) export class SeasonTabsComponent { /** Season keys, already sorted in display order. */ @@ -43,7 +42,6 @@ export class SeasonTabsComponent { readonly episodeCounts = input>({}); readonly watchedCounts = input>({}); /** Description of the selected season (TMDB/provider), if available. */ - readonly seasonDescription = input(null); /** Season key of the episode currently playing inline, if any. */ readonly playingSeasonKey = input(null); /** diff --git a/libs/ui/components/src/lib/season-container/season-watch-presenter.ts b/libs/ui/components/src/lib/season-container/season-watch-presenter.ts index 8022cd43b..394b1a0f4 100644 --- a/libs/ui/components/src/lib/season-container/season-watch-presenter.ts +++ b/libs/ui/components/src/lib/season-container/season-watch-presenter.ts @@ -219,6 +219,25 @@ export class SeasonWatchPresenter { ); }); + /** + * "Reset progress": clear every loaded episode's saved position. The + * host persists it through the same series toggle request as the menu. + */ + buildResetRequest(): SeasonContainerSeriesPlaybackToggleRequest | null { + const sources = this.sources(); + const playlistId = sources?.playlistId(); + if (!sources || !playlistId || sources.batchRunning()) { + return null; + } + return buildSeriesWatchToggleRequest({ + seasons: sources.seasons(), + seriesId: sources.seriesId(), + playlistId, + isEpisodeWatched: sources.isEpisodeWatched, + markWatched: false, + }); + } + toggleSeriesWatched(): void { const sources = this.sources(); if (!sources) { diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.html b/libs/ui/playback/src/lib/vod-details/vod-details.component.html index a1a57943a..e7c77e6ba 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.html +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.html @@ -97,7 +97,6 @@ @let primary = hero.primaryAction();