feat(portals): cinematic series details with the episodes grid in the first screen

Series pages take the same hero as movies: the provider's season marker
moves out of the title into a "Season N" chip, the status chip is localized,
the primary reads Continue / "S02E03 · 18m left" or Play / the next episode,
Trailer opens the modal, and favorites plus a "…" menu complete the row.
The menu carries mark season/series watched, reset progress, open in
external player, copy the next episode's URL, download season, show in
category and hide from Continue Watching — rows a provider cannot serve
are left out. The season container's header is "Episodes" with the season
pills and count; the season actions it used to show live in the menu;
episode cards are flat with a watched bar on the artwork, the plot and
"42 min · 18m left". Cast & crew and Similar follow the episodes. The
Electron and web E2E flows and the season container specs drive the new
menu and presenters; legacy detail styles are removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-02 08:49:36 +02:00
1 parent 075c056ba1
commit c2f5d67120
42 files changed
+1969 -1893

No files matched your search

@@ -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.
@@ -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<void> {
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})`
);
@@ -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();
+3 -1
View File
@@ -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']) {
@@ -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();
+34 -22
View File
@@ -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<void> {
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);
+42 -22
View File
@@ -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<void> {
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();
+36 -23
View File
@@ -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.
@@ -34,7 +34,6 @@
<ng-template appDetailActions>
@if (canPlayInline() && !playbackActive()) {
<app-detail-action-button
class="play-btn"
[label]="'XTREAM.PLAY' | translate"
[meta]="runtimeLabel()"
testId="m3u-vod-play"
@@ -1,5 +1,20 @@
@let isFavorite = isFavorite$ | async;
@if (isFavorite === false) {
@if (appearance() === 'icon') {
<app-detail-icon-button
[label]="
(isFavorite
? 'PORTALS.REMOVE_FROM_FAVORITES'
: 'PORTALS.ADD_TO_FAVORITES'
) | translate
"
[icon]="isFavorite ? 'favorite' : 'favorite_border'"
tone="favorite"
[active]="isFavorite === true"
[pressedState]="isFavorite === true"
testId="series-favorite-toggle"
(pressed)="isFavorite ? removeFromFavorites() : addToFavorites()"
/>
} @else if (isFavorite === false) {
<button class="favorite-btn" (click)="addToFavorites()">
<mat-icon>add</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
@@ -9,6 +9,7 @@ import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject, map, of, switchMap } from 'rxjs';
import { PlaylistsService } from '@iptvnator/services';
import { DetailIconButtonComponent } from '@iptvnator/ui/components';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
matchesFavoriteById,
@@ -27,7 +28,7 @@ type FavoriteButtonItem = StalkerSelectedVodItem & {
templateUrl: './stalker-favorites-button.component.html',
styleUrls: ['./stalker-favorites-button.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
imports: [AsyncPipe, MatIconModule, TranslateModule],
imports: [AsyncPipe, DetailIconButtonComponent, MatIconModule, TranslateModule],
})
export class FavoritesButtonComponent {
private playlistService = inject(PlaylistsService);
@@ -35,6 +36,8 @@ export class FavoritesButtonComponent {
readonly itemId = input.required<string>();
readonly item = input.required<FavoriteButtonItem>();
/** `icon`: the 44px heart of the cinematic action row. */
readonly appearance = input<'text' | 'icon'>('text');
private readonly favoritesChanged$ = new BehaviorSubject<void>(undefined);
@@ -0,0 +1,120 @@
import { computed, inject, Injectable, Signal, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { formatSeriesEpisodeCode } from '@iptvnator/portal/shared/util';
import {
StalkerStore,
type StalkerSelectedVodItem,
} from '@iptvnator/portal/stalker/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 { StalkerQuickStartButton } from './stalker-series-quick-start';
interface StalkerSeriesHeroBindings {
readonly displayItem: Signal<StalkerSelectedVodItem | null>;
readonly quickStart: Signal<StalkerQuickStartButton | null>;
readonly yearLabel: (releaseDate: string) => string | null;
readonly similarInPortals: Signal<readonly CrossPortalSimilarItem[]>;
readonly openSimilarInPortals: (item: CrossPortalSimilarItem) => void;
}
/** Hero presentation of the Stalker series page, see `createSeriesHeroState`. */
@Injectable()
export class StalkerSeriesHeroPresenter {
private readonly translate = inject(TranslateService);
private readonly stalkerStore = inject(StalkerStore);
private readonly trailerDialog = inject(TrailerDialogService);
private readonly bindings = signal<StalkerSeriesHeroBindings | null>(null);
private readonly info = computed(
() => this.bindings()?.displayItem()?.info ?? null
);
bind(bindings: StalkerSeriesHeroBindings): void {
this.bindings.set(bindings);
}
readonly quickStart = computed<SeriesHeroQuickStart | null>(() => {
const button = this.bindings()?.quickStart() ?? null;
if (!button) {
return null;
}
const action = button.action;
return {
labelKey: button.labelKey,
labelParams: button.labelParams,
episodeLabel: button.episodeLabel,
icon: button.icon,
disabled: button.disabled,
kind: action?.kind ?? null,
position: action?.position ?? null,
episodeCode: action
? formatSeriesEpisodeCode(
action.episode.season,
action.episode.episode_num
)
: null,
};
});
readonly hero = createSeriesHeroState({
title: computed(() => this.info()?.name),
sourceLabel: computed(() => this.stalkerStore.currentPlaylist()?.title),
status: computed(() => this.info()?.tmdb_status),
year: computed(() => {
const releaseDate = this.info()?.releasedate;
return releaseDate
? (this.bindings()?.yearLabel(releaseDate) ?? releaseDate)
: 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()?.actors),
tmdbDirectors: computed(() => this.info()?.tmdb_directors),
director: computed(() => this.info()?.director),
quickStart: this.quickStart,
translate: this.translate,
});
readonly similarRailItems = computed<SimilarRailItem[]>(() =>
(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}`,
}))
);
/** TMDB enrichment supplies the trailer; Stalker portals send none. */
readonly trailerEmbedUrl = computed(() =>
youtubeEmbedUrl(this.info()?.tmdb_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 match = bindings
?.similarInPortals()
.find(
(candidate) =>
`x${candidate.match.playlistId}-${candidate.match.xtreamId}` ===
item.key
);
if (match) {
bindings?.openSimilarInPortals(match);
}
}
}
@@ -0,0 +1,97 @@
import { computed, inject, Injectable, Signal, signal } from '@angular/core';
import { DownloadsService, SettingsStore } from '@iptvnator/services';
import {
VideoPlayer,
type ExternalPlayerName,
} from '@iptvnator/shared/interfaces';
import {
buildSeriesMenuSections,
SERIES_MENU_ACTION,
type SeasonContainerComponent,
type VodMoreMenuSection,
} from '@iptvnator/ui/components';
import type { StalkerQuickStartButton } from './stalker-series-quick-start';
interface StalkerSeriesMenuBindings {
readonly quickStart: Signal<StalkerQuickStartButton | null>;
readonly seasonContainer: Signal<SeasonContainerComponent | undefined>;
readonly hasProgress: Signal<boolean>;
readonly resetProgress: () => Promise<void>;
readonly openExternal: (player: ExternalPlayerName) => Promise<void>;
}
/**
* The "…" menu of the Stalker series page. The portal resolves stream URLs
* on demand, so "Copy stream URL" and the category row are not offered.
*/
@Injectable()
export class StalkerSeriesMenuService {
private readonly downloadsService = inject(DownloadsService);
private readonly settingsStore = inject(SettingsStore);
private readonly bindings = signal<StalkerSeriesMenuBindings | null>(null);
bind(bindings: StalkerSeriesMenuBindings): void {
this.bindings.set(bindings);
}
readonly externalPlayer = computed<ExternalPlayerName>(() =>
this.settingsStore.player() === VideoPlayer.VLC ? 'vlc' : 'mpv'
);
readonly sections = computed<VodMoreMenuSection[]>(() => {
const container = this.bindings()?.seasonContainer();
const watch = container?.watchPresenter;
const download = container?.downloadPresenter;
const quickStart = this.bindings()?.quickStart() ?? 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.bindings()?.hasProgress() ?? false,
sourcesCount: 0,
externalPlayerHint:
quickStart?.action && this.downloadsService.isAvailable()
? this.externalPlayer() === 'vlc'
? 'VLC'
: 'MPV'
: null,
copyUrlEpisodeCode: null,
downloadVisible: download?.presentationVisible() ?? false,
downloadCount: download?.eligibleEpisodeCount() ?? 0,
downloadDisabled: download?.seasonDisabled() ?? true,
downloadBusy: download?.batchRunning() ?? false,
categoryName: null,
inContinueWatching: false,
});
});
async run(actionId: string): Promise<void> {
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.bindings()?.openExternal(this.externalPlayer());
return;
}
}
}
@@ -1,6 +1,8 @@
@if (displayItem(); as serial) {
@let hero = heroPresenter.hero;
<app-portal-detail-shell
[title]="serial.info.name"
[title]="hero.title()"
[kindLabel]="hero.kindLabel()"
[description]="serial.info.description"
[posterUrl]="serial.info.movie_image"
[backdropUrl]="serial.info.tmdb_backdrop"
@@ -8,197 +10,117 @@
(backClicked)="goBack()"
(closePlayerRequested)="closeInlinePlayer()"
>
<!-- Tags: Year, Genre, Rating -->
<ng-template appDetailTags>
@if (serial.info.tmdb_status; as status) {
<span class="details__tag details__tag--status">{{
seriesStatusLabelKey(status) | translate
}}</span>
@if (hero.seasonChip(); as season) {
<app-meta-chip data-test-id="series-season-chip">
{{ 'PORTALS.SEASON_TAB' | translate: { number: season } }}
</app-meta-chip>
}
@if (serial.info.releasedate) {
@if (discover.canOpenYear(serial.info.releasedate)) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openYear(serial.info.releasedate)"
>
{{ discover.yearLabel(serial.info.releasedate) }}
</button>
} @else {
<span class="details__tag">{{
discover.yearLabel(serial.info.releasedate) ??
serial.info.releasedate
}}</span>
}
@if (hero.statusLabel(); as status) {
<app-meta-chip variant="status" data-test-id="series-status-chip">
{{ status }}
</app-meta-chip>
}
@if (serial.info.tmdb_genres?.length) {
@for (genre of serial.info.tmdb_genres; track genre.id) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openGenre(genre)"
>
{{ genre.name }}
</button>
}
} @else if (serial.info.genre) {
<span class="details__tag">{{ serial.info.genre }}</span>
@if (hero.year(); as year) {
<app-meta-chip>
@if (
serial.info.releasedate &&
discover.canOpenYear(serial.info.releasedate)
) {
<button
type="button"
class="meta-chip__facet"
(click)="discover.openYear(serial.info.releasedate)"
>
{{ year }}
</button>
} @else {
{{ year }}
}
</app-meta-chip>
}
@if (hero.genreChips().length) {
<app-meta-chip>
@for (genre of hero.genreChips(); track genre.label) {
@if (!$first) {
<span class="meta-chip__sep" aria-hidden="true">·</span>
}
@if (genre.facet; as facet) {
<button
type="button"
class="meta-chip__facet"
(click)="discover.openGenre(facet)"
>
{{ genre.label }}
</button>
} @else {
{{ genre.label }}
}
}
</app-meta-chip>
}
@for (country of hero.countryChips(); track country.label) {
<app-meta-chip>
@if (country.facet; as facet) {
<button
type="button"
class="meta-chip__facet"
(click)="discover.openCountry(facet)"
>
{{ country.label }}
</button>
} @else {
{{ country.label }}
}
</app-meta-chip>
}
@if (serial.info.rating_imdb) {
<span class="details__tag details__tag--rating">
⭐ {{ serial.info.rating_imdb }}
</span>
<app-meta-chip variant="rating">★ {{ serial.info.rating_imdb }}</app-meta-chip>
}
@if (serial.info.rating_kinopoisk) {
<span class="details__tag">
KP {{ serial.info.rating_kinopoisk }}
</span>
}
@if (serial.info.tmdb_countries?.length) {
@for (
country of serial.info.tmdb_countries;
track country.code
) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openCountry(country)"
>
{{ country.name }}
</button>
}
<app-meta-chip>KP {{ serial.info.rating_kinopoisk }}</app-meta-chip>
}
</ng-template>
<!-- Metadata Grid -->
<ng-template appDetailMeta>
@if (serial.info.actors || serial.info.tmdb_cast?.length) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
@if (serial.info.tmdb_cast?.length) {
<div class="details__cast-strip">
@for (
member of serial.info.tmdb_cast;
track member.tmdbPersonId
? 'p' + member.tmdbPersonId
: 'i' + $index
) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
!!member.tmdbPersonId
"
(click)="openActor(member)"
[title]="
member.character
? member.name +
' — ' +
member.character
: member.name
"
>
@if (member.profileUrl) {
<img
class="details__cast-avatar"
[src]="member.profileUrl"
[alt]="member.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
>
{{ member.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
member.name
}}</span>
</span>
}
</div>
} @else {
<span class="value">{{ serial.info.actors }}</span>
}
</div>
}
@if (serial.info.director || serial.info.tmdb_directors?.length) {
<div class="details__meta-item">
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
@if (serial.info.tmdb_directors?.length) {
<div class="details__cast-strip">
@for (
member of serial.info.tmdb_directors;
track member.tmdbPersonId
? 'p' + member.tmdbPersonId
: 'i' + $index
) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
!!member.tmdbPersonId
"
(click)="openActor(member)"
[title]="member.name"
>
@if (member.profileUrl) {
<img
class="details__cast-avatar"
[src]="member.profileUrl"
[alt]="member.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
>
{{ member.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
member.name
}}</span>
</span>
}
</div>
} @else {
<span class="value">{{ serial.info.director }}</span>
}
</div>
}
<app-detail-credits
[cast]="hero.castNames()"
[directors]="hero.directorNames()"
(moreRequested)="scrollToCast()"
/>
</ng-template>
<!-- Action Buttons -->
<ng-template appDetailActions>
@if (quickStartAction(); as action) {
<button
class="play-btn"
data-testid="series-quick-start"
(click)="playQuickStartEpisode()"
[disabled]="action.disabled"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
action.icon
}}</mat-icon>
</span>
<span class="play-btn__copy">
<span class="play-btn__label">
{{
action.labelKey | translate: action.labelParams
}}
</span>
@if (action.episodeLabel) {
<span class="play-btn__meta">
{{ action.episodeLabel }}
</span>
}
</span>
</button>
@if (hero.primaryAction(); as primary) {
<app-detail-action-button
[label]="primary.label"
[meta]="primary.meta"
[icon]="primary.icon"
[disabled]="primary.disabled"
testId="series-quick-start"
(pressed)="playQuickStartEpisode()"
/>
}
<app-favorites-button [itemId]="serial.id" [item]="serial" />
@if (heroPresenter.trailerEmbedUrl()) {
<app-detail-action-button
variant="secondary"
icon="smart_display"
[label]="'PORTALS.DETAIL.TRAILER' | translate"
testId="series-trailer"
(pressed)="heroPresenter.openTrailer()"
/>
}
<app-favorites-button
appearance="icon"
[itemId]="serial.id"
[item]="serial"
/>
<app-vod-more-menu
[sections]="menu.sections()"
[label]="'PORTALS.DETAIL.MORE_ACTIONS' | translate"
testId="series-more-menu"
(selected)="menu.run($event)"
/>
<app-view-in-portal-action />
</ng-template>
@@ -273,71 +195,16 @@
}
<div detail-extras>
@if (similarInPortals().length) {
<section class="details__similar" data-test-id="tmdb-similar">
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@for (
item of similarInPortals();
track item.match.xtreamId
) {
<button
type="button"
class="details__similar-card"
[title]="
item.title + ' — ' + item.match.playlistName
"
(click)="openSimilarInPortals(item)"
>
@if (item.posterUrl) {
<img
class="details__similar-poster"
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ item.title.charAt(0) }}
</span>
}
<span class="details__similar-title">
{{ item.title }}
</span>
<span class="details__similar-source">
{{ item.match.playlistName }}
</span>
</button>
}
</div>
</section>
}
<!-- YouTube Trailer (TMDB enrichment; Stalker provides none) -->
@if (trailerEmbedUrl(); as trailerUrl) {
<div class="youtube-trailer">
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
<div class="trailer-wrapper">
<iframe
[src]="trailerUrl | safe"
title="YouTube video player"
allow="
accelerometer;
autoplay;
clipboard-write;
encrypted-media;
gyroscope;
picture-in-picture;
web-share;
"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</div>
</div>
}
<app-cast-crew-row
[cast]="hero.castMembers()"
[directors]="hero.directorMembers()"
(personSelected)="openActor($event)"
/>
<app-similar-rail
[title]="'XTREAM.SIMILAR' | translate"
[items]="heroPresenter.similarRailItems()"
(selected)="heroPresenter.openSimilarRailItem($event)"
/>
</div>
</app-portal-detail-shell>
}
@@ -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();
@@ -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<SeasonContainerComponent>('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<void> {
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<void> {
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<XtreamSerieEpisode>,
startTime?: number
startTime?: number,
forcePlayer?: ExternalPlayerName
): Promise<void> {
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);
@@ -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<Signal<XtreamSerieDetailsView | null> | null>(
null
);
bind(selectedItem: Signal<XtreamSerieDetailsView | null>): void {
this.selectedItem.set(selectedItem);
}
readonly adapter = computed<SeasonEpisodeDownloadAdapter | null>(() => {
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'
),
});
});
}
@@ -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<XtreamSerieDetailsView | null>;
readonly quickStart: Signal<SeriesQuickStartAction | null>;
readonly yearLabel: (releaseDate: string) => string | null;
readonly similarItems: Signal<readonly SimilarCatalogItem[]>;
readonly similarInPortals: Signal<readonly CrossPortalSimilarItem[]>;
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<SerialDetailsHeroBindings | null>(null);
private readonly info = computed(
() => this.bindings()?.selectedItem()?.info ?? null
);
bind(bindings: SerialDetailsHeroBindings): void {
this.bindings.set(bindings);
}
readonly quickStart = computed<SeriesHeroQuickStart | null>(() => {
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<SimilarRailItem[]>(() => [
...(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);
}
}
}
@@ -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<XtreamSerieDetailsView | null>;
readonly quickStart: Signal<SeriesQuickStartAction | null>;
readonly seasonContainer: Signal<SeasonContainerComponent | undefined>;
/** The route's category id; the name comes from the store's list. */
readonly categoryId: Signal<string>;
readonly episodePositions: Signal<ReadonlyMap<number, unknown>>;
readonly resetProgress: () => Promise<void>;
}
/**
* 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<SerialDetailsMenuBindings | null>(null);
/** Set once "Hide from Continue Watching" ran for the current series. */
private readonly hiddenSeriesIds = signal<ReadonlySet<number>>(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<ExternalPlayerName>(() =>
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<Partial<XtreamCategory> | 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<VodMoreMenuSection[]>(() => {
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<void> {
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<void> {
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<void> {
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<void> {
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,
});
}
}
@@ -1,8 +1,10 @@
@let item = selectedItem();
@if (item && item.info) {
@let info = item.info;
@let hero = heroPresenter.hero;
<app-portal-detail-shell
[title]="info.name"
[title]="hero.title()"
[kindLabel]="hero.kindLabel()"
[description]="info.plot"
[posterUrl]="info.cover"
[backdropUrl]="getBackdropUrl(info)"
@@ -11,201 +13,122 @@
(closePlayerRequested)="closeInlinePlayer()"
>
<ng-template appDetailTags>
@if (info.tmdb_status; as status) {
<span class="details__tag details__tag--status">{{
seriesStatusLabelKey(status) | translate
}}</span>
@if (hero.seasonChip(); as season) {
<app-meta-chip data-test-id="series-season-chip">
{{ 'PORTALS.SEASON_TAB' | translate: { number: season } }}
</app-meta-chip>
}
@if (info.releaseDate) {
@if (discover.canOpenYear(info.releaseDate)) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openYear(info.releaseDate)"
>
{{ discover.yearLabel(info.releaseDate) }}
</button>
} @else {
<span class="details__tag">{{
discover.yearLabel(info.releaseDate) ??
(info.releaseDate | slice: 0 : 4)
}}</span>
}
@if (hero.statusLabel(); as status) {
<app-meta-chip variant="status" data-test-id="series-status-chip">
{{ status }}
</app-meta-chip>
}
@if (info.tmdb_genres?.length) {
@for (genre of info.tmdb_genres; track genre.id) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openGenre(genre)"
>
{{ genre.name }}
</button>
}
} @else if (info.genre) {
<span class="details__tag">{{ info.genre }}</span>
@if (hero.year(); as year) {
<app-meta-chip>
@if (info.releaseDate && discover.canOpenYear(info.releaseDate)) {
<button
type="button"
class="meta-chip__facet"
(click)="discover.openYear(info.releaseDate)"
>
{{ year }}
</button>
} @else {
{{ year }}
}
</app-meta-chip>
}
@if (hero.genreChips().length) {
<app-meta-chip>
@for (genre of hero.genreChips(); track genre.label) {
@if (!$first) {
<span class="meta-chip__sep" aria-hidden="true">·</span>
}
@if (genre.facet; as facet) {
<button
type="button"
class="meta-chip__facet"
(click)="discover.openGenre(facet)"
>
{{ genre.label }}
</button>
} @else {
{{ genre.label }}
}
}
</app-meta-chip>
}
@if (info.episode_run_time) {
<span class="details__tag"
>{{ info.episode_run_time }} min/ep</span
>
<app-meta-chip>{{ info.episode_run_time }} min/ep</app-meta-chip>
}
@for (country of hero.countryChips(); track country.label) {
<app-meta-chip>
@if (country.facet; as facet) {
<button
type="button"
class="meta-chip__facet"
(click)="discover.openCountry(facet)"
>
{{ country.label }}
</button>
} @else {
{{ country.label }}
}
</app-meta-chip>
}
@if (info.rating) {
<span class="details__tag details__tag--rating">
⭐ {{ info.rating }}
</span>
}
@if (info.tmdb_countries?.length) {
@for (country of info.tmdb_countries; track country.code) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openCountry(country)"
>
{{ country.name }}
</button>
}
<app-meta-chip variant="rating">★ {{ info.rating }}</app-meta-chip>
}
</ng-template>
<ng-template appDetailMeta>
@if (info.cast || info.tmdb_cast?.length) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.CAST' | translate }}</span>
@if (info.tmdb_cast?.length) {
<div class="details__cast-strip">
@for (
member of info.tmdb_cast;
track member.tmdbPersonId
? 'p' + member.tmdbPersonId
: 'i' + $index
) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
!!member.tmdbPersonId
"
(click)="openActor(member)"
[title]="
member.character
? member.name +
' — ' +
member.character
: member.name
"
>
@if (member.profileUrl) {
<img
class="details__cast-avatar"
[src]="member.profileUrl"
[alt]="member.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
>
{{ member.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
member.name
}}</span>
</span>
}
</div>
} @else {
<span class="value">{{ info.cast }}</span>
}
</div>
}
@if (info.director || info.tmdb_directors?.length) {
<div class="details__meta-item">
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
@if (info.tmdb_directors?.length) {
<div class="details__cast-strip">
@for (
member of info.tmdb_directors;
track member.tmdbPersonId
? 'p' + member.tmdbPersonId
: 'i' + $index
) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
!!member.tmdbPersonId
"
(click)="openActor(member)"
[title]="member.name"
>
@if (member.profileUrl) {
<img
class="details__cast-avatar"
[src]="member.profileUrl"
[alt]="member.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
>
{{ member.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
member.name
}}</span>
</span>
}
</div>
} @else {
<span class="value">{{ info.director }}</span>
}
</div>
}
<app-detail-credits
[cast]="hero.castNames()"
[directors]="hero.directorNames()"
(moreRequested)="scrollToCast()"
/>
</ng-template>
<ng-template appDetailActions>
@if (quickStartAction(); as action) {
<button
class="play-btn"
data-testid="series-quick-start"
(click)="playQuickStartEpisode()"
[disabled]="action.disabled"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
action.icon
}}</mat-icon>
</span>
<span class="play-btn__copy">
<span class="play-btn__label">
{{
action.labelKey | translate: action.labelParams
}}
</span>
<span class="play-btn__meta">
{{ action.episodeLabel }}
</span>
</span>
</button>
@if (hero.primaryAction(); as primary) {
<app-detail-action-button
[label]="primary.label"
[meta]="primary.meta"
[icon]="primary.icon"
[disabled]="primary.disabled"
testId="series-quick-start"
(pressed)="playQuickStartEpisode()"
/>
}
@if (!isFavorite()) {
<button class="favorite-btn" (click)="toggleFavorite()">
<mat-icon>add</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<button
class="favorite-btn favorite-btn--active"
(click)="toggleFavorite()"
>
<mat-icon>check</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
@if (heroPresenter.trailerEmbedUrl()) {
<app-detail-action-button
variant="secondary"
icon="smart_display"
[label]="'PORTALS.DETAIL.TRAILER' | translate"
testId="series-trailer"
(pressed)="heroPresenter.openTrailer()"
/>
}
<app-detail-icon-button
[label]="
(isFavorite()
? 'PORTALS.REMOVE_FROM_FAVORITES'
: 'PORTALS.ADD_TO_FAVORITES'
) | translate
"
[icon]="isFavorite() ? 'favorite' : 'favorite_border'"
tone="favorite"
[active]="isFavorite()"
[pressedState]="isFavorite()"
testId="series-favorite-toggle"
(pressed)="toggleFavorite()"
/>
<app-vod-more-menu
[sections]="menu.sections()"
[label]="'PORTALS.DETAIL.MORE_ACTIONS' | translate"
testId="series-more-menu"
(selected)="menu.run($event)"
/>
<app-view-in-portal-action />
</ng-template>
@@ -236,6 +159,7 @@
}
<app-season-container
#seasonContainer
detail-episodes
[seasons]="item.episodes"
[seriesId]="item.series_id"
@@ -264,76 +188,16 @@
/>
<div detail-extras>
@if (similarItems().length || similarInPortals().length) {
<section class="details__similar" data-test-id="tmdb-similar">
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@for (similar of similarItems(); track similar.id) {
<button
type="button"
class="details__similar-card"
[title]="similar.title"
(click)="openSimilar(similar)"
>
@if (similar.posterUrl) {
<img
class="details__similar-poster"
[src]="similar.posterUrl"
[alt]="similar.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ similar.title.charAt(0) }}
</span>
}
<span class="details__similar-title">
{{ similar.title }}
</span>
</button>
}
@for (
item of similarInPortals();
track 'x' +
item.match.playlistId +
'-' +
item.match.xtreamId
) {
<button
type="button"
class="details__similar-card"
[title]="
item.title + ' — ' + item.match.playlistName
"
(click)="openSimilarInPortals(item)"
>
@if (item.posterUrl) {
<img
class="details__similar-poster"
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ item.title.charAt(0) }}
</span>
}
<span class="details__similar-title">
{{ item.title }}
</span>
<span class="details__similar-source">
{{ item.match.playlistName }}
</span>
</button>
}
</div>
</section>
}
<app-cast-crew-row
[cast]="hero.castMembers()"
[directors]="hero.directorMembers()"
(personSelected)="openActor($event)"
/>
<app-similar-rail
[title]="'XTREAM.SIMILAR' | translate"
[items]="heroPresenter.similarRailItems()"
(selected)="heroPresenter.openSimilarRailItem($event)"
/>
</div>
</app-portal-detail-shell>
} @else if (detailsError()) {
@@ -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 () => {
@@ -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<SeasonContainerComponent>('seasonContainer');
readonly selectedItem = signal<XtreamSerieDetailsView | null>(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<SeasonEpisodeDownloadAdapter | null>(() => {
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<string | null>(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<void> {
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,
@@ -3,7 +3,6 @@
@if (playableItem || isDownloaded()) {
@let primary = hero.primaryAction();
<app-detail-action-button
class="play-btn"
[label]="primary.label"
[meta]="primary.meta"
[icon]="primary.icon"
+1
View File
@@ -46,3 +46,4 @@ export * from './lib/detail-ui/trailer-dialog.component';
export * from './lib/detail-ui/trailer-dialog.service';
export * from './lib/detail-ui/vod-more-menu.component';
export * from './lib/detail-ui/vod-more-menu.model';
export * from './lib/detail-ui/series-hero.state';
@@ -9,8 +9,6 @@
// light theme gets a light scrim with dark text and the dark theme the
// reverse, with the same geometry.
@use '../../../../styles/detail-view-actions' as detail-actions;
:host {
--poster-width: 190px;
--poster-shadow: 0 18px 40px rgba(0, 0, 0, 0.5);
@@ -107,7 +105,7 @@
width: 100%;
max-width: var(--content-max-width);
margin: 0 auto;
padding: 80px 32px 28px;
padding: 72px 32px 24px;
// Reserved by the detail shell while its sticky Back control shows.
padding-inline-start: max(32px, var(--detail-back-lane, 0px));
box-sizing: border-box;
@@ -245,43 +243,6 @@
}
}
// Legacy `.details__tag` projected by hosts not yet on `app-meta-chip`.
::ng-deep .details__tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
background: var(--tag-bg);
border: 0;
border-radius: 999px;
color: var(--text-primary);
&--rating {
color: var(--app-rating-color, #ffcf5c);
font-weight: 600;
}
&--clickable {
appearance: none;
cursor: pointer;
font-family: inherit;
letter-spacing: inherit;
transition: background 0.15s ease;
&:hover {
background: var(--tag-border);
}
&:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
}
}
}
&__description {
margin: 0;
font-size: 13.5px;
@@ -408,31 +369,6 @@
&:empty {
display: none;
}
// Legacy projected buttons (series hosts until they move to the shared
// pieces) take the cinematic surfaces too.
::ng-deep .play-btn {
@include detail-actions.light-primary-button;
height: 44px;
@include detail-actions.play-button-copy(var(--app-cta-meta-fg, #5a6274));
}
::ng-deep .favorite-btn,
::ng-deep .download-btn {
@include detail-actions.ghost-text-button;
height: 44px;
}
::ng-deep .favorite-btn {
@include detail-actions.favorite-button-state;
}
::ng-deep .download-btn {
@include detail-actions.download-button-state;
}
}
// ============================================================================
@@ -11,6 +11,13 @@ import type { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces';
import { buildCastCrewEntries, type CastCrewEntry } from './cast-crew.util';
import { DetailRailComponent } from './detail-rail.component';
/** Scrolls the page to the Cast & crew row ("and more" in the credits). */
export function scrollToCastCrewRow(): void {
document
.getElementById('detail-cast-crew')
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/**
* "Cast & crew" row under the hero: 72px round avatars with the full name
* and the character (or "Director"), the director first, initials when a
@@ -0,0 +1,300 @@
import { computed, type Signal } from '@angular/core';
import type { TranslateService } from '@ngx-translate/core';
import {
formatRemainingLabel,
shortCountryList,
shortCountryName,
splitSeasonSuffix,
type SeriesQuickStartActionKind,
} from '@iptvnator/portal/shared/util';
import {
seriesStatusLabelKey,
type PlaybackPositionData,
type TmdbCountryFacet,
type TmdbEnrichedCastMember,
type TmdbGenreFacet,
type TmdbSeriesStatus,
} from '@iptvnator/shared/interfaces';
import { castMembersFromNames, splitPeopleNames } from './cast-crew.util';
import type { VodMoreMenuSection } from './vod-more-menu.model';
/** The next episode to play, as the quick-start helpers describe it. */
export interface SeriesHeroQuickStart {
readonly labelKey: string;
readonly labelParams?: Record<string, number>;
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<F> {
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<string | undefined>;
readonly sourceLabel: Signal<string | null | undefined>;
readonly status: Signal<TmdbSeriesStatus | undefined | null>;
readonly year: Signal<string | null | undefined>;
readonly tmdbGenres: Signal<readonly TmdbGenreFacet[] | undefined>;
readonly genre: Signal<string | undefined>;
readonly tmdbCountries: Signal<readonly TmdbCountryFacet[] | undefined>;
readonly country?: Signal<string | undefined>;
readonly tmdbCast: Signal<readonly TmdbEnrichedCastMember[] | undefined>;
readonly cast: Signal<string | undefined>;
readonly tmdbDirectors: Signal<readonly TmdbEnrichedCastMember[] | undefined>;
readonly director: Signal<string | undefined>;
readonly quickStart: Signal<SeriesHeroQuickStart | null>;
readonly translate: Pick<TranslateService, 'instant'>;
}
/**
* 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<string, unknown>) =>
deps.translate.instant(key, params);
const titleParts = computed(() => splitSeasonSuffix(deps.title()));
const castMembers = computed<TmdbEnrichedCastMember[]>(() =>
deps.tmdbCast()?.length
? [...(deps.tmdbCast() ?? [])]
: castMembersFromNames(splitPeopleNames(deps.cast()))
);
const directorMembers = computed<TmdbEnrichedCastMember[]>(() =>
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<SeriesHeroFacetChip<TmdbGenreFacet>[]>(() => {
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<SeriesHeroFacetChip<TmdbCountryFacet>[]>(() => {
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<SeriesHeroPrimaryAction | null>(() => {
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 },
];
}
@@ -1,37 +1,66 @@
<section class="seasons-section">
<app-season-header
[downloadVisible]="downloadPresenter.presentationVisible()"
[batchRunning]="downloadPresenter.batchRunning()"
[seasonDisabled]="downloadPresenter.seasonDisabled()"
[eligibleEpisodeCount]="downloadPresenter.eligibleEpisodeCount()"
[watchToggleVisible]="watchPresenter.seasonWatchToggleVisible()"
[watchBatchRunning]="seasonWatchBatchRunning()"
[seasonFullyWatched]="watchPresenter.selectedSeasonFullyWatched()"
[watchEligibleCount]="watchPresenter.seasonWatchEligibleCount()"
[seriesMenuVisible]="watchPresenter.seriesMenuVisible()"
[seriesActionDisabled]="watchPresenter.seriesActionDisabled()"
[seriesFullyWatched]="watchPresenter.seriesFullyWatched()"
[seriesEligibleCount]="watchPresenter.seriesWatchEligibleCount()"
[seriesCountKnown]="watchPresenter.seriesCountKnown()"
[viewToggleVisible]="hasSeasons() && !showSeasonEmptyState()"
[viewMode]="viewMode()"
(downloadSeason)="downloadPresenter.enqueueSeason()"
(toggleSeasonWatched)="watchPresenter.toggleSeasonWatched()"
(toggleSeriesWatched)="watchPresenter.toggleSeriesWatched()"
(viewModeChange)="setViewMode($event)"
/>
<!-- "Episodes · Season 1 · Season 2 · 8 episodes": the season pills sit
in the header; season and series actions live in the hero's menu. -->
<header class="episodes-header">
<h3 class="episodes-header__title">
{{ 'PORTALS.DETAIL.EPISODES' | translate }}
</h3>
@if (hasSeasons()) {
<app-season-tabs
class="episodes-header__tabs"
[seasonKeys]="sortedSeasonKeys()"
[selectedSeason]="selectedSeason()"
[episodeCounts]="episodeCounts()"
[watchedCounts]="watchedCounts()"
[seasonPosters]="seasonPosters()"
[playingSeasonKey]="playingSeasonKey()"
(seasonSelected)="selectSeason($event)"
(backToPlayingRequested)="scrollToPlayingEpisode()"
/>
@if (selectedSeasonEpisodes().length; as count) {
<span class="episodes-header__count">
{{
(count === 1
? 'PORTALS.EPISODE_COUNT_ONE'
: 'PORTALS.EPISODE_COUNT_OTHER'
) | translate: { count }
}}
</span>
}
}
@if (hasSeasons() && !showSeasonEmptyState()) {
<div class="view-toggle">
<mat-button-toggle-group
[value]="viewMode()"
(change)="setViewMode($event.value)"
hideSingleSelectionIndicator
>
<mat-button-toggle
value="grid"
[aria-label]="'PORTALS.GRID_VIEW' | translate"
[matTooltip]="'PORTALS.GRID_VIEW' | translate"
>
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
<mat-button-toggle
value="list"
[aria-label]="'PORTALS.LIST_VIEW' | translate"
[matTooltip]="'PORTALS.LIST_VIEW' | translate"
>
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
}
</header>
@if (hasSeasons()) {
<!-- Season card: the selected season's own cover beside the tabs and
description. Without a cover the column is not rendered at all,
so the tabs sit exactly where they did before season posters. -->
<div
class="season-card"
[class.season-card--with-cover]="selectedSeasonPosterUrl()"
>
@if (hasSeasons() && (selectedSeasonPosterUrl() || selectedSeasonDescription())) {
<!-- The selected season's own cover and synopsis, compact, under
the header. Without either the row is not rendered at all. -->
<div class="season-strip">
@if (selectedSeasonPosterUrl(); as posterUrl) {
<img
class="season-card__cover"
class="season-strip__cover"
data-testid="season-cover"
[src]="posterUrl"
[alt]="
@@ -42,17 +71,14 @@
(error)="onSeasonPosterError(posterUrl)"
/>
}
<app-season-tabs
[seasonKeys]="sortedSeasonKeys()"
[selectedSeason]="selectedSeason()"
[episodeCounts]="episodeCounts()"
[watchedCounts]="watchedCounts()"
[seasonDescription]="selectedSeasonDescription()"
[seasonPosters]="seasonPosters()"
[playingSeasonKey]="playingSeasonKey()"
(seasonSelected)="selectSeason($event)"
(backToPlayingRequested)="scrollToPlayingEpisode()"
/>
@if (selectedSeasonDescription(); as description) {
<app-expandable-text
class="season-strip__description"
data-testid="season-description"
[text]="description"
[clampLines]="3"
/>
}
</div>
}
@@ -133,19 +159,16 @@
</div>
}
@if (getEpisodeProgress(episode) > 0) {
<app-progress-capsule
[progress]="getEpisodeProgress(episode)"
/>
@if (
getEpisodePositionText(episode);
as time
) {
<div
class="episode-card__position-text"
>
{{ time }}
</div>
}
<span
class="episode-card__progress"
aria-hidden="true"
>
<i
[style.width.%]="
getEpisodeProgress(episode)
"
></i>
</span>
}
<button
mat-icon-button
@@ -248,9 +271,6 @@
}
</div>
</div>
<div class="episode-card__number">
{{ episode.episode_num }}
</div>
</div>
<div class="episode-card__info">
<div class="episode-card__title-row">
@@ -280,10 +300,10 @@
{{ episodeInfo?.plot }}
</p>
}
@if (episodeInfo?.duration) {
<span class="episode-card__duration">
{{ episodeInfo?.duration }}
</span>
@if (getEpisodeSubline(episode); as subline) {
<small class="episode-card__subline">
{{ subline }}
</small>
}
</div>
</article>
@@ -1,6 +1,13 @@
.seasons-section {
width: 100%;
margin-top: 24px;
margin-top: 4px;
}
@media (prefers-reduced-motion: reduce) {
.episode-card,
.episode-card__thumbnail img {
transition: none;
}
}
.loading-container {
@@ -72,82 +79,144 @@
// Grid Layout
.episodes-grid {
display: grid;
gap: 12px;
gap: 16px;
width: 100%;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
}
// Season card: an optional cover column (--season-cover-width follows
// Settings.coverSize: 96 / 120 / 144px, tokens in apps/web/src/_cover-size.scss)
// next to the tabs + description. Without a cover the grid is one column and
// the tabs render exactly as they did before season posters existed.
.season-card {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 20px;
align-items: start;
margin-bottom: 16px;
// "Episodes" header: title, season pills, count and the view toggle on one
// line; the pills wrap first when the pane is narrow.
.episodes-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
margin-bottom: 14px;
&--with-cover {
grid-template-columns: var(--season-cover-width, 120px) minmax(0, 1fr);
&__title {
margin: 0 6px 0 0;
color: var(--text-primary);
font-size: 16px;
font-weight: 600;
line-height: 1.3;
}
&__tabs {
display: block;
min-width: 0;
}
&__count {
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
}
.view-toggle {
margin-left: auto;
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: 30px;
}
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: 30px;
padding: 0;
}
}
.mat-button-toggle + .mat-button-toggle {
border-left: 1px solid
color-mix(
in srgb,
var(--app-on-surface) 14%,
transparent
);
}
}
}
}
}
// The selected season's cover (--season-cover-width follows
// Settings.coverSize) and synopsis under the header.
.season-strip {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 14px;
&__cover {
display: block;
width: 100%;
flex: 0 0 auto;
width: calc(var(--season-cover-width, 120px) * 0.5);
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
background: var(--app-widget-bg);
box-shadow: var(--poster-shadow, 0 8px 40px rgba(0, 0, 0, 0.5));
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
&__description {
flex: 1 1 auto;
min-width: 0;
max-width: 760px;
font-size: 12.5px;
line-height: 1.55;
color: var(--text-secondary);
}
}
app-season-tabs {
display: block;
min-width: 0;
}
@media (max-width: 640px) {
.season-card--with-cover {
grid-template-columns: 88px minmax(0, 1fr);
gap: 14px;
}
}
// Episode Card
// Episode Card — flat: the thumbnail and the text sit on the page, no box.
.episode-card {
background: var(--app-widget-bg);
border-radius: 6px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid color-mix(in srgb, var(--app-on-surface) 14%, transparent);
position: relative;
// In-progress state
&--in-progress {
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent);
&::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--mat-sys-primary) 10%, transparent) 0%,
transparent 50%
);
pointer-events: none;
z-index: 1;
}
}
cursor: pointer;
border-radius: 10px;
transition: opacity 0.2s ease;
&--launching,
&--active-external {
border-color: color-mix(in srgb, var(--mat-sys-tertiary) 56%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mat-sys-tertiary) 28%, transparent);
.episode-card__thumbnail {
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--mat-sys-tertiary) 56%, transparent);
}
}
&--launching {
@@ -157,14 +226,15 @@ app-season-tabs {
}
}
&--active-external {
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 18%, transparent);
// Playing in the inline player
&--playing .episode-card__thumbnail {
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
}
// Playing in the inline player
&--playing {
border-color: color-mix(in srgb, var(--mat-sys-primary) 70%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--mat-sys-primary) 40%, transparent);
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
}
// Watched state — the sole indicator is the watched toggle on the
@@ -235,10 +305,6 @@ app-season-tabs {
}
&:hover {
transform: translateY(-2px);
background: var(--app-card-hover-bg);
border-color: color-mix(in srgb, var(--app-on-surface) 22%, transparent);
.episode-card__overlay {
opacity: 1;
}
@@ -246,32 +312,42 @@ app-season-tabs {
.episode-card__play {
transform: scale(1);
}
.episode-card__thumbnail img {
transform: scale(1.03);
}
}
&__thumbnail {
position: relative;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 10px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
}
}
&__position-text {
// 3px watched share at the bottom of the artwork
&__progress {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.85);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
color: white;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: rgba(255, 255, 255, 0.15);
z-index: 2;
i {
display: block;
height: 100%;
background: var(--app-selection-color, #98b8ff);
}
}
&__placeholder {
@@ -388,34 +464,22 @@ app-season-tabs {
}
}
&__number {
position: absolute;
bottom: 6px;
right: 6px;
background: rgba(0, 0, 0, 0.85);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
color: white;
}
&__info {
padding: 12px;
padding: 8px 2px 0;
}
&__title-row {
display: flex;
align-items: flex-start;
gap: 4px;
margin-bottom: 4px;
margin-bottom: 2px;
}
&__title {
flex: 1;
min-width: 0;
font-size: 0.875rem;
font-weight: 600;
font-size: 13px;
font-weight: 500;
margin: 0;
color: var(--text-primary);
line-height: 1.4;
@@ -469,9 +533,9 @@ app-season-tabs {
}
&__plot {
font-size: 0.75rem;
font-size: 12px;
color: var(--text-secondary);
margin: 0 0 8px 0;
margin: 0 0 4px 0;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
@@ -479,13 +543,10 @@ app-season-tabs {
overflow: hidden;
}
&__duration {
font-size: 0.6875rem;
&__subline {
display: block;
font-size: 11.5px;
color: var(--text-muted);
display: inline-block;
padding: 1px 6px;
background: rgba(255, 255, 255, 0.08);
border-radius: 10px;
}
}
@@ -161,6 +161,9 @@ describe('SeasonContainerComponent', () => {
let emittedSeasons: string[];
const snackBarOpen = jest.fn();
/** The season download, as the hero "…" menu drives it through the presenter. */
const seasonDownload = () => component.downloadPresenter;
const setRequiredInputs = (
seasons: Record<string, XtreamSerieEpisode[]>,
isLoading = false
@@ -593,19 +596,11 @@ describe('SeasonContainerComponent', () => {
setRequiredInputs({ '1': [paused, eligible] });
fixture.detectChanges();
const button = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
expect(button.textContent).toContain('Download season (1)');
expect(button.getAttribute('aria-label')).toBe(
'Download season, 1 episodes available'
);
expect(
button.querySelector('.season-download-button__label')?.textContent
).toContain('Download season (1)');
expect(button.nextElementSibling?.classList).toContain('view-toggle');
expect(seasonDownload().presentationVisible()).toBe(true);
expect(seasonDownload().eligibleEpisodeCount()).toBe(1);
expect(seasonDownload().seasonDisabled()).toBe(false);
button.click();
await seasonDownload().enqueueSeason();
await fixture.whenStable();
expect(downloadsServiceStub.startDownload).toHaveBeenCalledTimes(1);
@@ -628,14 +623,11 @@ describe('SeasonContainerComponent', () => {
fixture.detectChanges();
const action = episodeAction(episode.id);
const seasonButton = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
expect(action.disabled).toBe(true);
expect(action.querySelector('mat-icon')?.textContent).toContain(
'block'
);
expect(seasonButton.disabled).toBe(true);
expect(seasonDownload().seasonDisabled()).toBe(true);
expect(downloadsServiceStub.startDownload).not.toHaveBeenCalled();
});
@@ -654,15 +646,12 @@ describe('SeasonContainerComponent', () => {
fixture.detectChanges();
const action = episodeAction(episode.id);
const seasonButton = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
expect(action.disabled).toBe(false);
expect(action.querySelector('mat-icon')?.textContent).toContain(
'download'
);
expect(seasonButton.disabled).toBe(false);
expect(seasonButton.textContent).toContain('Download season (1)');
expect(seasonDownload().seasonDisabled()).toBe(false);
expect(seasonDownload().eligibleEpisodeCount()).toBe(1);
});
it('gives the grid and list view radios localized accessible names', () => {
@@ -694,38 +683,34 @@ describe('SeasonContainerComponent', () => {
setRequiredInputs({ '1': [first] });
fixture.detectChanges();
const seasonButton = () =>
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
expect(seasonButton().disabled).toBe(true);
expect(seasonDownload().seasonDisabled()).toBe(true);
expect(downloadsServiceStub.hasLoadedDownloads()).toBe(true);
expect(episodeAction(first.id).disabled).toBe(true);
downloadsServiceStub.hasAuthoritativeDownloadList.set(true);
fixture.componentRef.setInput('isLoading', true);
fixture.detectChanges();
expect(seasonButton().disabled).toBe(true);
expect(seasonDownload().seasonDisabled()).toBe(true);
fixture.componentRef.setInput('isLoading', false);
fixture.componentRef.setInput('seasons', { '1': [] });
fixture.detectChanges();
expect(seasonButton().disabled).toBe(true);
expect(seasonDownload().seasonDisabled()).toBe(true);
fixture.componentRef.setInput('seasons', { '1': [first] });
downloadsServiceStub.downloads.set([
createDownload(first, { status: 'paused' }),
]);
fixture.detectChanges();
expect(seasonButton().disabled).toBe(true);
expect(seasonDownload().seasonDisabled()).toBe(true);
downloadsServiceStub.downloads.set([]);
const start = deferred<{ success: boolean }>();
downloadsServiceStub.startDownload.mockReturnValue(start.promise);
fixture.detectChanges();
seasonButton().click();
void seasonDownload().enqueueSeason();
fixture.detectChanges();
expect(seasonButton().disabled).toBe(true);
expect(seasonDownload().seasonDisabled()).toBe(true);
start.resolve({ success: true });
await fixture.whenStable();
@@ -740,33 +725,22 @@ describe('SeasonContainerComponent', () => {
setRequiredInputs({ '1': [createEpisode()] });
fixture.detectChanges();
const seasonButton = () =>
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
seasonButton().click();
void seasonDownload().enqueueSeason();
fixture.detectChanges();
expect(seasonButton().textContent).toContain('Adding to queue…');
expect(seasonButton().getAttribute('aria-label')).toBe(
'Adding to queue…'
);
expect(seasonButton().getAttribute('aria-busy')).toBe('true');
expect(seasonDownload().batchRunning()).toBe(true);
start.resolve({ success: true });
await Promise.resolve();
await Promise.resolve();
fixture.detectChanges();
expect(seasonButton().getAttribute('aria-busy')).toBe('true');
expect(seasonDownload().batchRunning()).toBe(true);
refresh.resolve(undefined);
await fixture.whenStable();
fixture.detectChanges();
expect(seasonButton().hasAttribute('aria-busy')).toBe(false);
expect(seasonButton().textContent).toContain('Download season (1)');
expect(seasonButton().getAttribute('aria-label')).toBe(
'Download season, 1 episodes available'
);
expect(seasonDownload().batchRunning()).toBe(false);
expect(seasonDownload().eligibleEpisodeCount()).toBe(1);
});
it('hides all download presentation on web, in provider-only mode, or without an adapter', () => {
@@ -774,11 +748,7 @@ describe('SeasonContainerComponent', () => {
setRequiredInputs({ '1': [first] });
fixture.componentRef.setInput('downloadAdapter', downloadAdapter);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
)
).toBeNull();
expect(seasonDownload().presentationVisible()).toBe(false);
downloadsServiceStub.isAvailable.set(true);
downloadsServiceStub.hasLoadedDownloads.set(true);
@@ -789,20 +759,12 @@ describe('SeasonContainerComponent', () => {
'[data-test-id^="episode-download-"]'
)
).toBeNull();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
)
).toBeNull();
expect(seasonDownload().presentationVisible()).toBe(false);
fixture.componentRef.setInput('downloadsEnabled', true);
fixture.componentRef.setInput('downloadAdapter', null);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
)
).toBeNull();
expect(seasonDownload().presentationVisible()).toBe(false);
expect(
fixture.nativeElement.querySelector(
'[data-test-id^="episode-download-"]'
@@ -999,12 +961,8 @@ describe('SeasonContainerComponent', () => {
enableDownloads();
setRequiredInputs({ '1': [createEpisode()] });
fixture.detectChanges();
const seasonButton = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
seasonButton.click();
seasonButton.click();
void seasonDownload().enqueueSeason();
void seasonDownload().enqueueSeason();
await Promise.resolve();
await Promise.resolve();
@@ -1082,10 +1040,7 @@ describe('SeasonContainerComponent', () => {
component.selectSeason('1');
fixture.detectChanges();
const seasonButton = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
seasonButton.click();
void seasonDownload().enqueueSeason();
component.selectSeason('2');
fixture.componentRef.setInput('seasons', {
'1': [createEpisode({ id: '103', title: 'Replacement' })],
@@ -1119,11 +1074,7 @@ describe('SeasonContainerComponent', () => {
});
fixture.detectChanges();
(
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement
).click();
await seasonDownload().enqueueSeason();
await fixture.whenStable();
expect(snackBarOpen).toHaveBeenCalledWith(
@@ -1151,10 +1102,28 @@ describe('SeasonContainerComponent', () => {
playlistId: 'playlist-1',
});
const toggleButton = (): HTMLButtonElement | null =>
fixture.nativeElement.querySelector(
'[data-test-id="toggle-season-watched"]'
);
/**
* The season toggle as the hero "…" menu presents it: the presenter
* owns label, count, disabled state and the click.
*/
const toggleButton = () => {
const presenter = component.watchPresenter;
if (!presenter.seasonWatchToggleVisible()) {
return null;
}
const fullyWatched = presenter.selectedSeasonFullyWatched();
const count = presenter.seasonWatchEligibleCount();
return {
textContent: fullyWatched
? 'Mark season as unwatched'
: `Mark season as watched (${count})`,
icon: fullyWatched ? 'remove_done' : 'done_all',
disabled:
component.seasonWatchBatchRunning() ||
(!fullyWatched && count === 0),
click: () => presenter.toggleSeasonWatched(),
};
};
const threeEpisodes = () => ({
'1': [
@@ -1239,9 +1208,7 @@ describe('SeasonContainerComponent', () => {
const button = toggleButton();
expect(button?.textContent).toContain('Mark season as unwatched');
expect(button?.querySelector('mat-icon')?.textContent).toContain(
'remove_done'
);
expect(button?.icon).toBe('remove_done');
button?.click();
expect(emitted).toHaveLength(1);
@@ -1299,8 +1266,7 @@ describe('SeasonContainerComponent', () => {
const button = toggleButton();
expect(button?.disabled).toBe(true);
expect(button?.getAttribute('aria-busy')).toBe('true');
expect(button?.querySelector('mat-spinner')).not.toBeNull();
expect(component.seasonWatchBatchRunning()).toBe(true);
});
it('hides the toggle without a playlist id and on empty seasons', () => {
@@ -12,24 +12,28 @@ import {
untracked,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { type SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import {
createLogger,
formatDurationLabel,
formatRemainingLabel,
getPortalPlaybackProgressPercent,
isPortalPlaybackInProgress,
isPortalPlaybackWatched,
parseDurationSeconds,
} from '@iptvnator/portal/shared/util';
import {
PlaybackPositionData,
XtreamSerieEpisode,
XtreamSerieEpisodeInfo,
} from '@iptvnator/shared/interfaces';
import { ProgressCapsuleComponent } from '../progress-capsule/progress-capsule.component';
import { ExpandableTextComponent } from '../expandable-text/expandable-text.component';
import {
EPISODE_INFO_PLAY,
EpisodeInfoDialogComponent,
@@ -38,10 +42,6 @@ import {
import { formatEpisodePositionText } from './episode-progress.util';
import { resolveAutoSelectedSeason } from './season-auto-select.util';
import { SeasonDownloadPresenter } from './season-download-presenter';
import {
type EpisodeViewMode,
SeasonHeaderComponent,
} from './season-header.component';
import { SeasonTabsComponent } from './season-tabs.component';
import { SeasonWatchPresenter } from './season-watch-presenter';
import {
@@ -54,6 +54,8 @@ import {
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
export type EpisodeViewMode = 'grid' | 'list';
@Component({
selector: 'app-season-container',
templateUrl: './season-container.component.html',
@@ -61,18 +63,19 @@ const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [SeasonDownloadPresenter, SeasonWatchPresenter],
imports: [
ExpandableTextComponent,
MatButtonModule,
MatButtonToggleModule,
MatIcon,
MatProgressSpinnerModule,
MatTooltipModule,
ProgressCapsuleComponent,
SeasonHeaderComponent,
SeasonTabsComponent,
TranslateModule,
],
})
export class SeasonContainerComponent implements OnInit {
private readonly dialog = inject(MatDialog);
private readonly translate = inject(TranslateService);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly logger = createLogger('SeasonContainer');
private lastEmittedSeason: string | undefined;
@@ -427,6 +430,26 @@ export class SeasonContainerComponent implements OnInit {
);
}
/** "42 min · 18m left", "42 min · watched", "42 min" — or null. */
getEpisodeSubline(episode: XtreamSerieEpisode): string | null {
const info = this.getEpisodeInfo(episode);
const seconds = parseDurationSeconds(info?.duration);
const duration = formatDurationLabel(seconds);
const position = this.playbackPositions().get(Number(episode.id));
const remaining = formatRemainingLabel(position);
const parts = [
duration
? this.translate.instant(duration.key, duration.params)
: null,
this.isEpisodeWatched(episode)
? this.translate.instant('PORTALS.DETAIL.WATCHED')
: remaining
? this.translate.instant(remaining.key, remaining.params)
: this.getEpisodePositionText(episode),
].filter((part): part is string => !!part);
return parts.length ? parts.join(' · ') : null;
}
getEpisodePositionText(episode: XtreamSerieEpisode): string | null {
return formatEpisodePositionText(this.getEpisodePosition(episode));
}
@@ -49,8 +49,7 @@ describe('SeasonContainerComponent season cover', () => {
) as HTMLImageElement | null;
const coverColumnRendered = () =>
fixture.nativeElement.querySelector('.season-card--with-cover') !==
null;
fixture.nativeElement.querySelector('.season-strip__cover') !== null;
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -76,21 +76,27 @@ describe('SeasonContainerComponent series watched toggle', () => {
fixture.componentRef.setInput('playlistId', playlistId);
};
const menuTrigger = (): HTMLButtonElement | null =>
fixture.nativeElement.querySelector(
'[data-test-id="series-watch-menu"]'
);
/** The series row of the hero "…" menu exists only when the presenter says so. */
const menuTrigger = (): object | null =>
component.watchPresenter.seriesMenuVisible() ? {} : null;
const openSeriesMenuItem = (): HTMLButtonElement => {
const trigger = menuTrigger();
expect(trigger).not.toBeNull();
trigger?.click();
fixture.detectChanges();
const item = document.querySelector(
'[data-test-id="toggle-series-watched"]'
) as HTMLButtonElement | null;
expect(item).not.toBeNull();
return item as HTMLButtonElement;
/** The series row as the hero "…" menu presents it, from the presenter. */
const openSeriesMenuItem = () => {
expect(menuTrigger()).not.toBeNull();
const presenter = component.watchPresenter;
const translate = TestBed.inject(TranslateService);
const labelKey = presenter.seriesFullyWatched()
? 'XTREAM.MARK_SERIES_UNWATCHED'
: presenter.seriesCountKnown()
? 'XTREAM.MARK_SERIES_WATCHED'
: 'XTREAM.MARK_SERIES_WATCHED_ALL';
return {
textContent: translate.instant(labelKey, {
count: presenter.seriesWatchEligibleCount(),
}) as string,
disabled: presenter.seriesActionDisabled(),
click: () => presenter.toggleSeriesWatched(),
};
};
beforeEach(async () => {
@@ -1,155 +0,0 @@
<div class="section-header">
<div class="section-header__left">
<h3 class="section-title">
{{ 'PORTALS.SEASONS_AND_EPISODES' | translate }}
</h3>
</div>
@if (
downloadVisible() ||
watchToggleVisible() ||
viewToggleVisible() ||
seriesMenuVisible()
) {
<div class="section-header__actions">
@if (watchToggleVisible()) {
<button
mat-stroked-button
class="season-watch-toggle-button"
data-test-id="toggle-season-watched"
[disabled]="
watchBatchRunning() ||
(!seasonFullyWatched() && watchEligibleCount() === 0)
"
[attr.aria-label]="
(seasonFullyWatched()
? 'XTREAM.MARK_SEASON_UNWATCHED'
: 'XTREAM.MARK_SEASON_WATCHED'
) | translate: { count: watchEligibleCount() }
"
[attr.aria-busy]="watchBatchRunning() ? 'true' : null"
(click)="toggleSeasonWatched.emit()"
>
@if (watchBatchRunning()) {
<mat-spinner
diameter="18"
aria-hidden="true"
></mat-spinner>
} @else {
<mat-icon>{{
seasonFullyWatched() ? 'remove_done' : 'done_all'
}}</mat-icon>
}
<span class="season-watch-toggle-button__label">
{{
(seasonFullyWatched()
? 'XTREAM.MARK_SEASON_UNWATCHED'
: 'XTREAM.MARK_SEASON_WATCHED'
) | translate: { count: watchEligibleCount() }
}}
</span>
</button>
}
@if (downloadVisible()) {
<button
mat-stroked-button
class="season-download-button"
data-test-id="download-season"
[disabled]="seasonDisabled()"
[attr.aria-label]="
batchRunning()
? ('DOWNLOADS.ADDING_TO_QUEUE' | translate)
: ('DOWNLOADS.DOWNLOAD_SEASON_ARIA'
| translate: { count: eligibleEpisodeCount() })
"
[attr.aria-busy]="batchRunning() ? 'true' : null"
(click)="downloadSeason.emit()"
>
@if (batchRunning()) {
<mat-spinner
diameter="18"
aria-hidden="true"
></mat-spinner>
} @else {
<mat-icon>download</mat-icon>
}
<span class="season-download-button__label">
@if (batchRunning()) {
{{ 'DOWNLOADS.ADDING_TO_QUEUE' | translate }}
} @else {
{{
'DOWNLOADS.DOWNLOAD_SEASON'
| translate
: { count: eligibleEpisodeCount() }
}}
}
</span>
</button>
}
@if (viewToggleVisible()) {
<div class="view-toggle">
<mat-button-toggle-group
[value]="viewMode()"
(change)="viewModeChange.emit($event.value)"
hideSingleSelectionIndicator
>
<mat-button-toggle
value="grid"
[aria-label]="'PORTALS.GRID_VIEW' | translate"
[matTooltip]="'PORTALS.GRID_VIEW' | translate"
>
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
<mat-button-toggle
value="list"
[aria-label]="'PORTALS.LIST_VIEW' | translate"
[matTooltip]="'PORTALS.LIST_VIEW' | translate"
>
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
}
@if (seriesMenuVisible()) {
<button
mat-icon-button
class="series-watch-menu-trigger"
data-test-id="series-watch-menu"
[matMenuTriggerFor]="seriesWatchMenu"
[attr.aria-label]="'XTREAM.SERIES_WATCH_MENU' | translate"
>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #seriesWatchMenu="matMenu">
<button
mat-menu-item
class="series-watch-menu-item"
data-test-id="toggle-series-watched"
[disabled]="seriesActionDisabled()"
[attr.aria-busy]="watchBatchRunning() ? 'true' : null"
(click)="toggleSeriesWatched.emit()"
>
@if (watchBatchRunning()) {
<mat-spinner
diameter="18"
aria-hidden="true"
></mat-spinner>
} @else {
<mat-icon>{{
seriesFullyWatched()
? 'remove_done'
: 'done_all'
}}</mat-icon>
}
<span>
{{
seriesActionLabelKey()
| translate
: { count: seriesEligibleCount() }
}}
</span>
</button>
</mat-menu>
}
</div>
}
</div>
@@ -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;
}
}
}
@@ -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<boolean>();
readonly batchRunning = input.required<boolean>();
readonly seasonDisabled = input.required<boolean>();
readonly eligibleEpisodeCount = input.required<number>();
readonly watchToggleVisible = input.required<boolean>();
readonly watchBatchRunning = input.required<boolean>();
readonly seasonFullyWatched = input.required<boolean>();
readonly watchEligibleCount = input.required<number>();
readonly seriesMenuVisible = input.required<boolean>();
readonly seriesActionDisabled = input.required<boolean>();
readonly seriesFullyWatched = input.required<boolean>();
readonly seriesEligibleCount = input.required<number>();
/** False while unloaded seasons make the series count a guess. */
readonly seriesCountKnown = input.required<boolean>();
readonly viewToggleVisible = input.required<boolean>();
readonly viewMode = input.required<EpisodeViewMode>();
readonly downloadSeason = output<void>();
readonly toggleSeasonWatched = output<void>();
readonly toggleSeriesWatched = output<void>();
readonly viewModeChange = output<EpisodeViewMode>();
seriesActionLabelKey(): string {
if (this.seriesFullyWatched()) {
return 'XTREAM.MARK_SERIES_UNWATCHED';
}
return this.seriesCountKnown()
? 'XTREAM.MARK_SERIES_WATCHED'
: 'XTREAM.MARK_SERIES_WATCHED_ALL';
}
}
@@ -96,12 +96,3 @@
</button>
}
</div>
@if (seasonDescription(); as description) {
<app-expandable-text
class="season-tabs__description"
data-testid="season-description"
[text]="description"
[clampLines]="3"
/>
}
@@ -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.
}
@@ -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<Record<string, number>>({});
readonly watchedCounts = input<Record<string, number>>({});
/** Description of the selected season (TMDB/provider), if available. */
readonly seasonDescription = input<string | null>(null);
/** Season key of the episode currently playing inline, if any. */
readonly playingSeasonKey = input<string | null>(null);
/**
@@ -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) {
@@ -97,7 +97,6 @@
<ng-template appDetailActions>
@let primary = hero.primaryAction();
<app-detail-action-button
class="play-btn"
[label]="primary.label"
[meta]="primary.meta"
[icon]="primary.icon"
+1 -517
View File
@@ -1,9 +1,7 @@
// ============================================================================
// Netflix-style Detail View - Cinematic Streaming Design
// Detail View - page shell shared by the movie and series pages
// ============================================================================
@use 'detail-view-actions' as detail-actions;
@mixin base(
$season-title-font-size: 1.375rem,
$season-title-font-weight: 600,
@@ -30,35 +28,6 @@
--accent-color: var(--app-selection-color, #2f7bff);
--tag-bg: color-mix(in srgb, var(--app-on-surface, #1d1b1e) 10%, transparent);
--tag-border: color-mix(in srgb, var(--app-on-surface, #1d1b1e) 14%, transparent);
--play-idle-bg: linear-gradient(
135deg,
#2f7bff 0%,
#3b86ff 52%,
#4a90ff 100%
);
--play-idle-fg: #ffffff;
--play-idle-border: rgba(47, 123, 255, 0.4);
--play-idle-shadow: 0 18px 38px rgba(47, 123, 255, 0.32);
--play-launching-bg: linear-gradient(
135deg,
#ffd36f 0%,
#ffb14c 46%,
#f57d35 100%
);
--play-launching-fg: #241103;
--play-launching-border: rgba(142, 66, 0, 0.2);
--play-launching-shadow: 0 22px 44px rgba(244, 136, 55, 0.32);
--play-stop-bg: linear-gradient(
135deg,
#ffd1c8 0%,
#ff9f8d 42%,
#ef5e64 100%
);
--play-stop-fg: #321012;
--play-stop-border: rgba(162, 38, 45, 0.18);
--play-stop-shadow: 0 22px 46px rgba(214, 74, 86, 0.26);
--play-icon-shell-bg: rgba(255, 255, 255, 0.32);
--play-icon-shell-border: rgba(255, 255, 255, 0.3);
width: 100%;
height: 100%;
@@ -71,35 +40,6 @@
// Dark theme overrides - cinematic dark mode
:host-context(.dark-theme) {
--poster-shadow: 0 18px 40px rgba(0, 0, 0, 0.6);
--play-idle-bg: linear-gradient(
135deg,
#2f7bff 0%,
#4f8eff 58%,
#78adff 100%
);
--play-idle-fg: #ffffff;
--play-idle-border: rgba(120, 173, 255, 0.32);
--play-idle-shadow: 0 20px 42px rgba(47, 123, 255, 0.32);
--play-launching-bg: linear-gradient(
135deg,
#ffdd79 0%,
#ffb347 42%,
#ef6a3a 100%
);
--play-launching-fg: #1f1105;
--play-launching-border: rgba(255, 223, 136, 0.26);
--play-launching-shadow: 0 24px 50px rgba(255, 153, 71, 0.3);
--play-stop-bg: linear-gradient(
135deg,
#ffd7cf 0%,
#ff9b8b 40%,
#eb5961 100%
);
--play-stop-fg: #2d1012;
--play-stop-border: rgba(255, 205, 205, 0.24);
--play-stop-shadow: 0 24px 50px rgba(235, 89, 97, 0.24);
--play-icon-shell-bg: rgba(255, 255, 255, 0.28);
--play-icon-shell-border: rgba(255, 255, 255, 0.24);
}
// ============================================================================
@@ -109,32 +49,6 @@
.details {
// Styles for projected content elements
&__tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
font-size: 0.8125rem;
font-weight: 500;
background: var(--tag-bg);
border: 1px solid var(--tag-border);
border-radius: 4px;
backdrop-filter: blur(8px);
&--rating {
color: #ffd700;
border-color: rgba(255, 215, 0, 0.3);
}
// Series production status — a state, not a fact about the
// title, so it reads quieter than the other tags
&--status {
text-transform: uppercase;
font-size: 0.6875rem;
letter-spacing: 0.06em;
}
}
&__meta-item {
font-size: 0.875rem;
@@ -151,365 +65,8 @@
font-weight: 500;
}
}
// Cast avatar chips (populated by TMDB enrichment)
&__cast-strip {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 4px;
}
&__cast-chip {
align-items: center;
display: inline-flex;
gap: 6px;
max-width: 160px;
&--clickable {
cursor: pointer;
&:hover .details__cast-name {
text-decoration: underline;
}
}
}
&__cast-avatar {
background: rgba(255, 255, 255, 0.08);
border-radius: 50%;
flex-shrink: 0;
height: 32px;
object-fit: cover;
width: 32px;
&--fallback {
align-items: center;
color: var(--text-muted);
display: inline-flex;
font-size: 0.875rem;
font-weight: 600;
justify-content: center;
text-transform: uppercase;
}
}
&__cast-name {
color: var(--text-primary);
font-size: 0.8125rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// "Similar" rail (TMDB recommendations matched to the catalog)
&__similar {
margin-top: 24px;
h3 {
color: var(--text-primary);
font-size: 1rem;
font-weight: 600;
margin: 0 0 12px;
}
}
&__similar-strip {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 8px;
scrollbar-width: thin;
scrollbar-color: color-mix(
in srgb,
var(--app-muted-color) 55%,
transparent
)
transparent;
}
&__similar-card {
background: none;
border: none;
cursor: pointer;
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 6px;
padding: 0;
text-align: left;
width: 110px;
&:hover .details__similar-poster {
transform: scale(1.04);
}
}
&__similar-poster {
background: rgba(255, 255, 255, 0.08);
border-radius: 8px;
height: 165px;
object-fit: cover;
transition: transform 0.15s ease;
width: 110px;
&--fallback {
align-items: center;
color: var(--text-muted);
display: inline-flex;
font-size: 2rem;
font-weight: 600;
justify-content: center;
text-transform: uppercase;
}
}
&__similar-title {
color: var(--text-primary);
font-size: 0.8125rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Source playlist badge on cross-portal similar cards
&__similar-source {
color: var(--text-muted);
font-size: 0.6875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// ============================================================================
// Action Buttons - Play, Favorite
// ============================================================================
.play-btn {
--play-btn-bg: var(--play-idle-bg);
--play-btn-fg: var(--play-idle-fg);
--play-btn-border: var(--play-idle-border);
--play-btn-shadow: var(--play-idle-shadow);
position: relative;
isolation: isolate;
overflow: hidden;
display: inline-flex;
align-items: center;
gap: 12px;
padding: 10px 24px 10px 18px;
min-height: 64px;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0;
background: var(--play-btn-bg);
background-size: 140% 140%;
background-position: 0% 50%;
color: var(--play-btn-fg);
border: 1px solid var(--play-btn-border);
border-radius: 16px;
box-shadow: var(--play-btn-shadow);
cursor: pointer;
transition:
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
background-position 280ms ease,
box-shadow 260ms ease,
border-color 220ms ease,
color 220ms ease,
filter 220ms ease;
&::before {
content: '';
position: absolute;
inset: 1px;
border-radius: inherit;
background:
linear-gradient(
115deg,
rgba(255, 255, 255, 0.34),
transparent 36%
),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.18),
transparent 62%
);
opacity: 0.7;
transition: opacity 220ms ease;
z-index: -1;
}
&:hover:not(:disabled) {
transform: translateY(-2px);
background-position: 100% 50%;
box-shadow:
var(--play-btn-shadow),
0 10px 20px rgba(255, 255, 255, 0.16) inset;
}
&:hover:not(:disabled)::before {
opacity: 0.95;
}
&:active:not(:disabled) {
transform: translateY(0);
box-shadow: var(--play-btn-shadow);
}
&:disabled {
cursor: progress;
opacity: 1;
filter: saturate(1.05);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
transition:
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
color 220ms ease,
filter 220ms ease;
}
&:hover:not(:disabled) .play-btn__icon {
transform: scale(1.08);
}
&__icon-shell {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 999px;
background: var(--play-icon-shell-bg);
border: 1px solid var(--play-icon-shell-border);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.38),
0 6px 14px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(10px);
transition:
transform 220ms ease,
background-color 220ms ease,
border-color 220ms ease,
box-shadow 220ms ease;
}
@include detail-actions.play-button-copy(var(--play-btn-fg));
&--state-launching,
&--state-stop {
color: var(--play-btn-fg);
}
&--state-launching {
--play-btn-bg: var(--play-launching-bg);
--play-btn-fg: var(--play-launching-fg);
--play-btn-border: var(--play-launching-border);
--play-btn-shadow: var(--play-launching-shadow);
animation: playButtonPulse 1.8s ease-in-out infinite;
.play-btn__icon-shell {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.42),
0 10px 22px rgba(184, 89, 26, 0.2);
}
.play-btn__icon {
animation: playIconLaunch 1.15s cubic-bezier(0.55, 0, 0.45, 1)
infinite;
}
}
&--state-stop {
--play-btn-bg: var(--play-stop-bg);
--play-btn-fg: var(--play-stop-fg);
--play-btn-border: var(--play-stop-border);
--play-btn-shadow: var(--play-stop-shadow);
.play-btn__icon-shell {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.4),
0 12px 24px rgba(145, 38, 53, 0.18);
}
.play-btn__icon {
animation: playIconStop 1.2s ease-in-out infinite;
}
}
}
@keyframes playButtonPulse {
0%,
100% {
box-shadow: var(--play-btn-shadow);
}
50% {
box-shadow:
var(--play-btn-shadow),
0 0 0 6px rgba(255, 190, 92, 0.14);
}
}
@keyframes playIconLaunch {
0%,
100% {
transform: rotate(0deg) scale(1);
}
50% {
transform: rotate(18deg) scale(1.1);
}
}
@keyframes playIconStop {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.play-btn,
.play-btn::before,
.play-btn mat-icon,
.play-btn__icon-shell {
animation: none !important;
transition-duration: 1ms !important;
}
}
.favorite-btn,
.download-btn {
@include detail-actions.secondary-action-button;
}
.favorite-btn {
@include detail-actions.favorite-button-state;
}
.download-btn {
@include detail-actions.download-button-state;
}
.icon-action-btn {
@include detail-actions.icon-action-button;
@include detail-actions.icon-action-button-states;
@include detail-actions.download-progress-ring;
}
@include detail-actions.icon-action-keyframes;
// ============================================================================
// Content Sections - Below the hero fold
// ============================================================================
@@ -606,42 +163,6 @@
box-sizing: border-box;
}
// ============================================================================
// YouTube Trailer Section - Inside details
// ============================================================================
.youtube-trailer {
margin-top: 24px;
width: 100%;
h3 {
font-size: $episode-title-font-size;
font-weight: 600;
letter-spacing: $episode-title-letter-spacing;
margin: 0 0 12px 0;
color: var(--text-primary);
}
.trailer-wrapper {
position: relative;
width: 100%;
max-width: 560px;
aspect-ratio: 16 / 9;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
}
}
// ============================================================================
// Legacy container support (for gradual migration)
// ============================================================================
@@ -663,48 +184,11 @@
--poster-width: 120px;
}
.action-buttons {
width: 100%;
// This class is in ContentHeroComponent now, but these rules apply to children?
// No, .play-btn is a child of the PROJECTED content.
// So we should target the buttons directly for mobile
}
@include detail-actions.mobile-action-buttons;
.youtube-trailer {
margin-top: 16px;
.trailer-wrapper {
max-width: 100%;
border-radius: 4px;
}
}
.seasons {
padding: 16px 0 0;
}
}
// ============================================================================
// Animations
// ============================================================================
.youtube-trailer {
animation: fadeInUp 0.5s ease-out 0.4s both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// ============================================================================
// Utility - keep .label for backwards compatibility
// ============================================================================