mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
+16
-1
@@ -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 }}
|
||||
|
||||
+4
-1
@@ -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);
|
||||
|
||||
|
||||
+120
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
-242
@@ -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>
|
||||
}
|
||||
+6
-10
@@ -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();
|
||||
|
||||
+79
-17
@@ -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);
|
||||
|
||||
+47
@@ -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()) {
|
||||
|
||||
+28
-11
@@ -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"
|
||||
|
||||
@@ -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,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
|
||||
// ============================================================================
|
||||
|
||||
Reference in new issue
Block a user