feat(ui): cinematic movie and series details pages and dashboard hero (#1792)

This commit is contained in:
4gray authored and GitHub committed 2026-10-03 23:08:16 +02:00
1 parent 07c5dffab0
commit ab8460338a
213 files changed
+12494 -3930

No files matched your search

@@ -0,0 +1,6 @@
---
type: feature
area: workspace
---
The dashboard hero now shares the details pages' look: the same light Continue button and chips, a series title without its "(Season 1)" marker, and the blurred poster alone as the backdrop when a title has none. The hero can be focused: ← and → switch slides, Enter follows the main action, and rotation pauses while the window is hidden.
@@ -0,0 +1,7 @@
---
type: feature
area: portals
highlight: Cinematic movie and series pages
---
Movie and series pages open on a cinematic backdrop with chips, plot and one action row: a light Continue/Play button naming the next episode and the time left, a Trailer modal, and a "…" menu with other sources, external player, copy URL, reset progress and season actions. Cast & crew and Similar follow as rows. Without a 16:9 backdrop the hero shrinks to its content over the blurred poster.
+6
View File
@@ -0,0 +1,6 @@
---
type: feature
area: portals
---
A new Playback setting, "Play trailers in details background" (off by default), plays a movie's or series' trailer muted behind the details a few seconds after the page opens, with a corner button to unmute. It stays off under reduced motion and on metered connections, and stops when the page scrolls away or the window loses focus.
@@ -21,9 +21,9 @@ import {
// lane. Checked at a wide and a narrow desktop window and at phone width,
// where Back takes the context drawer toggle's slot.
//
// The "Seasons and Episodes" heading must also stay on one line: the detail
// pane is far narrower than the window beside the rail and category panel,
// and the heading used to wrap beside its actions.
// The "Episodes" heading must also stay on one line: the detail pane is far
// narrower than the window beside the rail and category panel, and the
// heading used to wrap beside its actions.
// ---------------------------------------------------------------------------
const widths = [1280, 780, 375];
@@ -36,8 +36,8 @@ const widestHeading = (
join(__dirname, `../../web/src/assets/i18n/${widestLocale}.json`),
'utf8'
)
) as { PORTALS: { SEASONS_AND_EPISODES: string } }
).PORTALS.SEASONS_AND_EPISODES;
) as { PORTALS: { DETAIL: { EPISODES: string } } }
).PORTALS.DETAIL.EPISODES;
const detailUrlPattern = /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/;
@@ -47,14 +47,16 @@ function headerBack(page: Page): Locator {
/** Line boxes of the heading's text; 1 means it did not wrap. */
function headingLineCount(page: Page): Promise<number> {
return page.locator('.section-title').evaluate((heading) => {
const range = document.createRange();
range.selectNodeContents(heading);
const lines = [...range.getClientRects()]
.filter((rect) => rect.width > 0)
.map((rect) => Math.round(rect.top));
return new Set(lines).size;
});
return page
.locator('[data-test-id="episodes-heading"]')
.evaluate((heading) => {
const range = document.createRange();
range.selectNodeContents(heading);
const lines = [...range.getClientRects()]
.filter((rect) => rect.width > 0)
.map((rect) => Math.round(rect.top));
return new Set(lines).size;
});
}
/**
@@ -174,9 +176,12 @@ async function expectWidestHeadingOnOneLine(
await saveSettings(page);
await page.goBack();
await page.waitForURL(detailUrl);
await expect(page.locator('.section-title')).toHaveText(widestHeading, {
timeout: 20_000,
});
await expect(page.locator('[data-test-id="episodes-heading"]')).toHaveText(
widestHeading,
{
timeout: 20_000,
}
);
for (const width of [...widths, 700]) {
await page.setViewportSize({ width, height: 800 });
expect(
@@ -203,9 +208,11 @@ async function openFirstSeries(page: Page): Promise<string> {
await page.getByRole('link', { name: 'Series', exact: true }).click();
await clickFirstGridListCard(page);
await page.waitForURL(detailUrlPattern);
await expect(page.locator('.section-title')).toBeVisible({
timeout: 20_000,
});
await expect(page.locator('[data-test-id="episodes-heading"]')).toBeVisible(
{
timeout: 20_000,
}
);
await expect(page.locator('.episode-card').first()).toBeVisible({
timeout: 20_000,
});
@@ -642,12 +642,12 @@ test.describe('Electron Downloads', () => {
const providerDetail = app.mainWindow.locator(
'main app-portal-detail-shell'
);
await expect(
providerDetail.getByRole('button', {
name: 'Play',
exact: true,
})
).toBeVisible();
// The primary action reads "Play" plus its runtime line.
const primaryAction = providerDetail.locator(
'[data-testid="vod-primary-action"]'
);
await expect(primaryAction).toBeVisible();
await expect(primaryAction).toContainText('Play');
await expect(
providerDetail.getByText('Offline', { exact: true })
).toHaveCount(0);
@@ -107,6 +107,29 @@ async function openSeriesDetail(
return clickedTitle;
}
/** Opens the series "…" menu and returns the row with the given test id. */
async function seriesMenuRow(page: Page, rowTestId: string) {
const trigger = page.locator('[data-testid="series-more-menu"]');
await expect(trigger).toBeVisible({ timeout: 20_000 });
const row = page.locator(`[data-test-id="${rowTestId}"]`);
if (!(await row.isVisible().catch(() => false))) {
await trigger.click();
}
await expect(row).toBeVisible({ timeout: 10_000 });
return row;
}
/** Closes an open menu without choosing a row. */
async function closeSeriesMenu(page: Page): Promise<void> {
// The panel, not the backdrop: a backdrop locator can match a second
// overlay and then fail the strict check, leaving the menu open.
const panel = page.locator('[data-test-id="vod-more-menu"]');
if (await panel.isVisible().catch(() => false)) {
await page.keyboard.press('Escape');
}
await expect(panel).toBeHidden({ timeout: 10_000 });
}
test.describe('Electron Season Watched Toggle', () => {
test('@xtream @persistence @electron marks a season watched via the batch IPC, survives an app restart, and clears again', async ({
dataDir,
@@ -137,13 +160,14 @@ test.describe('Electron Season Watched Toggle', () => {
// Season 1 auto-selects fully unwatched: the toggle offers to mark
// all 8 episodes and neither the DOM nor SQLite knows any watched
// episode yet.
const seasonToggle = app.mainWindow.getByTestId(
let seasonToggle = await seriesMenuRow(
app.mainWindow,
'toggle-season-watched'
);
await expect(seasonToggle).toBeVisible({ timeout: 20_000 });
await expect(seasonToggle).toContainText(
`Mark season as watched (${seasonEpisodeCount})`
);
await closeSeriesMenu(app.mainWindow);
const episodeCards = app.mainWindow.locator('.episode-card');
const watchedCards = app.mainWindow.locator(
@@ -157,16 +181,26 @@ test.describe('Electron Season Watched Toggle', () => {
await readEpisodePositions(app.mainWindow, playlistId)
).toEqual([]);
seasonToggle = await seriesMenuRow(
app.mainWindow,
'toggle-season-watched'
);
await seasonToggle.click();
// The batch save flips the button, marks every card, and fills
// The batch save flips the row, marks every card, and fills
// the per-episode toggles; the untouched second season stays
// unmarked while the selected season's tab shows the check.
await expect(seasonToggle).toContainText(
'Mark season as unwatched',
{ timeout: 15_000 }
await expect(watchedCards).toHaveCount(seasonEpisodeCount, {
timeout: 15_000,
});
seasonToggle = await seriesMenuRow(
app.mainWindow,
'toggle-season-watched'
);
await expect(watchedCards).toHaveCount(seasonEpisodeCount);
await expect(seasonToggle).toContainText(
'Mark season as unwatched'
);
await closeSeriesMenu(app.mainWindow);
await expect(
app.mainWindow.locator(watchedEpisodeToggleSelector)
).toHaveCount(seasonEpisodeCount);
@@ -217,7 +251,8 @@ test.describe('Electron Season Watched Toggle', () => {
seriesTitle
);
const restartedToggle = app.mainWindow.getByTestId(
let restartedToggle = await seriesMenuRow(
app.mainWindow,
'toggle-season-watched'
);
const restartedWatchedCards = app.mainWindow.locator(
@@ -226,10 +261,10 @@ test.describe('Electron Season Watched Toggle', () => {
// With season 1 completed, the fresh mount auto-selects the
// earliest season with unwatched episodes (issue #1441): season 2
// opens unwatched while season 1's tab keeps the check.
await expect(restartedToggle).toBeVisible({ timeout: 20_000 });
await expect(restartedToggle).toContainText(
`Mark season as watched (${seasonEpisodeCount})`
);
await closeSeriesMenu(app.mainWindow);
await expect(restartedWatchedCards).toHaveCount(0, {
timeout: 10_000,
});
@@ -241,6 +276,10 @@ test.describe('Electron Season Watched Toggle', () => {
// Season 1 itself was re-read from SQLite fully watched.
await restartedSeasonTabs.first().click();
restartedToggle = await seriesMenuRow(
app.mainWindow,
'toggle-season-watched'
);
await expect(restartedToggle).toContainText(
'Mark season as unwatched',
{ timeout: 15_000 }
@@ -253,11 +292,17 @@ test.describe('Electron Season Watched Toggle', () => {
// Unmark: the clear batch removes every row again, in the DOM and
// in SQLite.
await restartedToggle.click();
await expect(restartedToggle).toContainText(
`Mark season as watched (${seasonEpisodeCount})`,
{ timeout: 15_000 }
await expect(restartedWatchedCards).toHaveCount(0, {
timeout: 15_000,
});
restartedToggle = await seriesMenuRow(
app.mainWindow,
'toggle-season-watched'
);
await expect(restartedWatchedCards).toHaveCount(0);
await expect(restartedToggle).toContainText(
`Mark season as watched (${seasonEpisodeCount})`
);
await closeSeriesMenu(app.mainWindow);
await expect(
app.mainWindow.locator(watchedEpisodeToggleSelector)
).toHaveCount(0);
@@ -274,14 +319,11 @@ test.describe('Electron Season Watched Toggle', () => {
// the same batch IPC — one action, 24 full-progress rows across
// seasons 1–3 (no second restart: durability of the channel is
// already proven above).
const seriesMenuTrigger =
app.mainWindow.getByTestId('series-watch-menu');
await expect(seriesMenuTrigger).toBeVisible();
await seriesMenuTrigger.click();
const seriesToggle = app.mainWindow.getByTestId(
await closeSeriesMenu(app.mainWindow);
const seriesToggle = await seriesMenuRow(
app.mainWindow,
'toggle-series-watched'
);
await expect(seriesToggle).toBeVisible();
await expect(seriesToggle).toContainText(
`Mark series as watched (${seasonCount * seasonEpisodeCount})`
);
@@ -299,15 +341,15 @@ test.describe('Electron Season Watched Toggle', () => {
app.mainWindow,
playlistId
);
expect(
new Set(seriesRows.map((row) => row.seasonNumber))
).toEqual(new Set([1, 2, 3]));
expect(
seriesRows.every((row) => row.positionSeconds > 0)
).toBe(true);
expect(new Set(seriesRows.map((row) => row.seasonNumber))).toEqual(
new Set([1, 2, 3])
);
expect(seriesRows.every((row) => row.positionSeconds > 0)).toBe(
true
);
// Unwatch-all clears all 24 rows again through the clear batch.
await seriesMenuTrigger.click();
await seriesMenuRow(app.mainWindow, 'toggle-series-watched');
await expect(seriesToggle).toContainText(
'Mark series as unwatched'
);
@@ -160,7 +160,12 @@ test.describe('Electron Series Download Queue', () => {
})
.toEqual(expectedEpisodeQueue.slice(0, 2));
const seasonAction = app.mainWindow.getByTestId('download-season');
await app.mainWindow
.locator('[data-testid="series-more-menu"]')
.click();
const seasonAction = app.mainWindow.locator(
'[data-test-id="download-season"]'
);
await expect(seasonAction).toBeVisible();
await expect(seasonAction).toContainText('Download season (2)');
await expect(seasonAction).toBeEnabled();
@@ -177,8 +182,13 @@ test.describe('Electron Series Download Queue', () => {
})
.toEqual(expectedEpisodeQueue);
// Choosing the row closed the menu; reopen it to read the row.
await app.mainWindow
.locator('[data-testid="series-more-menu"]')
.click();
await expect(seasonAction).toBeDisabled();
await expect(seasonAction).toContainText('Download season (0)');
await app.mainWindow.keyboard.press('Escape');
await waitForQueueStabilityInterval();
expect(await readEpisodeQueue(app.mainWindow)).toEqual(
@@ -62,14 +62,20 @@ test.describe('VOD multi-source', () => {
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
);
// Discovery is lazy, so the chip appears after the detail view.
const chip = app.mainWindow.locator('app-vod-sources-chip button');
await expect(chip).toBeVisible({ timeout: 20000 });
// The badge counts every copy across all playlists — the route's
// own plus the same film in the other portal.
await expect(chip).toContainText('2');
// Discovery is lazy, so the "Other sources" row of the "…" menu
// appears after the detail view.
const more = app.mainWindow.locator('[data-testid="vod-more-menu"]');
await expect(more).toBeVisible({ timeout: 20000 });
await more.click();
const sourcesRow = app.mainWindow.locator(
'[data-test-id="vod-menu-sources"]'
);
await expect(sourcesRow).toBeVisible({ timeout: 20000 });
// The hint counts the alternatives: the same film in the other
// portal, not the route's own copy.
await expect(sourcesRow).toContainText('1');
await chip.click();
await sourcesRow.click();
// Both the playing source and the alternative are listed.
const rows = app.mainWindow.locator('app-vod-source-row');
@@ -87,7 +93,7 @@ test.describe('VOD multi-source', () => {
}
});
test('shows no sources chip when only one playlist has the movie', async ({
test('offers no other sources when only one playlist has the movie', async ({
dataDir,
request,
}) => {
@@ -122,8 +128,14 @@ test.describe('VOD multi-source', () => {
await expect(
app.mainWindow.locator('.play-btn').first()
).toBeVisible({ timeout: 20000 });
await app.mainWindow
.locator('[data-testid="vod-more-menu"]')
.click();
await expect(
app.mainWindow.locator('app-vod-sources-chip')
app.mainWindow.locator('[data-test-id="vod-menu-copy-url"]')
).toBeVisible();
await expect(
app.mainWindow.locator('[data-test-id="vod-menu-sources"]')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
+31 -2
View File
@@ -244,10 +244,18 @@ export async function expectSeriesSurfacesInBothThemes(
const shell = page.locator('app-portal-detail-shell');
await expect
.poll(() =>
rasterizedBorderContrast(shell.locator('.favorite-btn').first())
rasterizedBorderContrast(
shell
.locator('[data-testid="series-favorite-toggle"]')
.first()
)
)
.toBeGreaterThan(1.1);
for (const selector of ['.episode-card', 'mat-button-toggle-group']) {
// The flat card's visible edge is its artwork's hairline.
for (const selector of [
'.episode-card__thumbnail',
'mat-button-toggle-group',
]) {
await expect
.poll(
async () =>
@@ -278,3 +286,24 @@ export async function expectSeriesSurfacesInBothThemes(
.click();
}
}
/** Opens the series "…" menu (if closed) and returns the row with that test id. */
export async function seriesMenuRow(page: Page, rowTestId: string) {
const row = page.locator(`[data-test-id="${rowTestId}"]`);
if (!(await row.isVisible().catch(() => false))) {
await page.locator('[data-testid="series-more-menu"]').click();
}
await expect(row).toBeVisible({ timeout: 15_000 });
return row;
}
/** Closes an open series menu without choosing a row. */
export async function closeSeriesMenu(page: Page): Promise<void> {
// The panel, not the backdrop: a backdrop locator can match a second
// overlay and then fail the strict check, leaving the menu open.
const panel = page.locator('[data-test-id="vod-more-menu"]');
if (await panel.isVisible().catch(() => false)) {
await page.keyboard.press('Escape');
}
await expect(panel).toBeHidden({ timeout: 10_000 });
}
+4 -3
View File
@@ -393,7 +393,7 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
await page.keyboard.press('Escape');
await expect(inlineVideo(page)).toHaveCount(0);
const playButton = page.locator('[data-test-id="m3u-vod-play"]');
const playButton = page.locator('[data-testid="m3u-vod-play"]');
await expect(playButton).toBeVisible();
// Browse → Play remounts the engine without a channel change.
@@ -417,12 +417,13 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
.click();
await expect(inlineVideo(page)).toHaveCount(0);
await expect(headerBack).toHaveCount(0);
// The hero keeps its own inset.
// The hero keeps its own inset (32px, or 20px in a pane narrower than
// 760px) in both states.
expect(
await shell
.locator('.hero__content')
.evaluate((el) => getComputedStyle(el).paddingInlineStart)
).toBe('40px');
).toMatch(/^(20|32)px$/);
await shell.focus();
await page.keyboard.press('Escape');
await expect(playButton).toBeVisible();
+7 -9
View File
@@ -29,9 +29,7 @@ async function addXtreamPortal(page: Page): Promise<void> {
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
await dialog
.getByRole('radio', { name: /Xtream credentials/i })
.click();
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
await dialog.locator('#title').fill('Self-hosted Xtream');
await dialog.locator('#serverUrl').fill(XTREAM_MOCK_SERVER);
@@ -165,9 +163,9 @@ test('@self-hosted Xtream portal loads through web-backend proxy', async ({
await expect(vodItem).toBeVisible({ timeout: 30_000 });
await vodItem.click();
await expect(page).toHaveURL(/\/workspace\/xtreams\/[^/]+\/vod\/\d+\/\d+/);
await expect(
page.getByRole('button', { name: 'Play', exact: true })
).toBeVisible({ timeout: 15_000 });
const primaryAction = page.locator('[data-testid="vod-primary-action"]');
await expect(primaryAction).toBeVisible({ timeout: 15_000 });
await expect(primaryAction).toContainText('Play');
expect(
consoleErrors.filter((message) =>
@@ -262,9 +260,9 @@ test('@self-hosted M3U User-Agent reaches the provider on import and refresh', a
const row = sourceRowByTitle(page, 'Protected M3U');
await row.hover();
await row.locator('.refresh-btn').click();
await expect(page.locator('.mat-mdc-snack-bar-label').last()).toContainText(
'updated'
);
await expect(
page.locator('.mat-mdc-snack-bar-label').last()
).toContainText('updated');
await page.goto(catalogUrl);
await expect(page.getByText('1. Refreshed UA Channel')).toBeVisible();
expect(received.slice(1)).toEqual([userAgent, userAgent]);
@@ -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();
+23 -23
View File
@@ -1,5 +1,10 @@
import { type APIRequestContext, type Page } from '@playwright/test';
import { expectSeriesSurfacesInBothThemes, setInputValue } from './e2e-helpers';
import {
closeSeriesMenu,
expectSeriesSurfacesInBothThemes,
seriesMenuRow,
setInputValue,
} from './e2e-helpers';
import {
verifyStalkerCategorySearch,
verifyStalkerPlaybackCategoryReturn,
@@ -1110,10 +1115,9 @@ test('@stalker season watched toggle — embedded series marks and clears every
await expectSeriesSurfacesInBothThemes(page, testInfo);
// The season header's bulk toggle (data-test-id with a dash — getByTestId
// The hero menu's bulk toggle (data-test-id with a dash — getByTestId
// only matches data-testid in this suite) counts every unwatched episode.
const seasonToggle = page.locator('[data-test-id="toggle-season-watched"]');
await expect(seasonToggle).toBeVisible();
let seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText(
`Mark season as watched (${episodeCount})`
);
@@ -1122,25 +1126,25 @@ test('@stalker season watched toggle — embedded series marks and clears every
await seasonToggle.click();
// All episodes get full-progress positions: the button flips and every
// All episodes get full-progress positions: the row flips and every
// episode card plus per-episode toggle shows the watched state.
await expect(seasonToggle).toContainText('Mark season as unwatched', {
timeout: 15_000,
});
await expect(watchedCards).toHaveCount(episodeCount);
await expect(watchedCards).toHaveCount(episodeCount, { timeout: 15_000 });
await expect(
page.locator(
'[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched'
)
).toHaveCount(episodeCount);
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as unwatched');
// Second click clears every episode's position again.
await seasonToggle.click();
await expect(watchedCards).toHaveCount(0, { timeout: 15_000 });
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText(
`Mark season as watched (${episodeCount})`,
{ timeout: 15_000 }
`Mark season as watched (${episodeCount})`
);
await expect(watchedCards).toHaveCount(0);
await closeSeriesMenu(page);
});
test('@stalker series watched toggle — embedded series marks and clears from the header menu', async ({
@@ -1179,14 +1183,10 @@ test('@stalker series watched toggle — embedded series marks and clears from t
})
).toBeVisible({ timeout: 10_000 });
// The ⋮ trigger sits after the view toggle (data-test-id with a dash —
// getByTestId only matches data-testid in this suite; the menu item
// renders into the CDK overlay).
const menuTrigger = page.locator('[data-test-id="series-watch-menu"]');
await expect(menuTrigger).toBeVisible();
await menuTrigger.click();
const seriesToggle = page.locator('[data-test-id="toggle-series-watched"]');
await expect(seriesToggle).toBeVisible();
// The series row sits in the hero's "…" menu (data-test-id with a dash —
// getByTestId only matches data-testid in this suite; the row renders
// into the CDK overlay).
let seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText(
`Mark series as watched (${episodeCount})`
);
@@ -1198,12 +1198,12 @@ test('@stalker series watched toggle — embedded series marks and clears from t
await expect(watchedCards).toHaveCount(episodeCount, {
timeout: 15_000,
});
await menuTrigger.click();
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText('Mark series as unwatched');
await seriesToggle.click();
await expect(watchedCards).toHaveCount(0, { timeout: 15_000 });
await menuTrigger.click();
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText(
`Mark series as watched (${episodeCount})`
);
@@ -1211,7 +1211,7 @@ test('@stalker series watched toggle — embedded series marks and clears from t
const detailUrl = page.url();
await page.keyboard.press('Escape');
await expect(seriesToggle).toBeHidden();
await menuTrigger.focus();
await page.locator('[data-testid="series-more-menu"]').focus();
await page.keyboard.press('Escape');
await expect(page.locator('app-portal-detail-shell')).toHaveCount(0);
await expect(page).toHaveURL(detailUrl);
+38 -27
View File
@@ -1,8 +1,10 @@
import type { APIRequestContext, Page } from '@playwright/test';
import { expect, test } from './fixtures';
import {
closeSeriesMenu,
pressTab,
rasterizedBorderContrast,
seriesMenuRow,
setInputValue,
surfaceContrast,
waitForScrollIdle,
@@ -656,24 +658,26 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
// Serial details: default scenario has 3 seasons × 8 episodes and no
// playback positions yet, so season 1 is auto-selected fully unwatched.
const seasonToggle = page.locator('[data-test-id="toggle-season-watched"]');
await expect(seasonToggle).toBeVisible({ timeout: 15_000 });
// The season toggle is a row of the hero's "…" menu.
let seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as watched (8)');
await closeSeriesMenu(page);
const episodeCards = page.locator('.episode-card');
await expect(episodeCards).toHaveCount(8, { timeout: 10_000 });
const watchedCards = page.locator('.episode-card--watched');
await expect(watchedCards).toHaveCount(0);
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await seasonToggle.click();
// Full-progress rows land for all 8 episodes: the button flips, every
// Full-progress rows land for all 8 episodes: the row flips, every
// episode card gets the watched state, and the per-episode toggles show
// the filled check.
await expect(seasonToggle).toContainText('Mark season as unwatched', {
timeout: 15_000,
});
await expect(watchedCards).toHaveCount(8);
await expect(watchedCards).toHaveCount(8, { timeout: 15_000 });
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as unwatched');
await closeSeriesMenu(page);
await expect(
page.locator(
'[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched'
@@ -696,26 +700,31 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
// fresh mount auto-selects the earliest season with unwatched episodes
// (issue #1441), so season 2 opens while season 1's tab keeps the check.
await page.reload();
await expect(seasonToggle).toBeVisible({ timeout: 20_000 });
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as watched (8)');
await closeSeriesMenu(page);
await expect(watchedCards).toHaveCount(0, { timeout: 10_000 });
await expect(
seasonTabs.first().locator('.season-tabs__done')
).toBeVisible();
// Season 1 itself still holds the 8 persisted rows.
// Season 1 itself still holds the 8 persisted rows. The row lives in
// the overlay, so it is reacquired after every close.
await seasonTabs.first().click();
await expect(watchedCards).toHaveCount(8, { timeout: 10_000 });
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as unwatched', {
timeout: 15_000,
});
await expect(watchedCards).toHaveCount(8, { timeout: 10_000 });
// Second click clears every episode's position again.
await seasonToggle.click();
await expect(watchedCards).toHaveCount(0, { timeout: 15_000 });
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as watched (8)', {
timeout: 15_000,
});
await expect(watchedCards).toHaveCount(0);
await closeSeriesMenu(page);
await expect(page.locator('.season-tabs__done')).toHaveCount(0);
});
@@ -830,17 +839,13 @@ test('@xtream series watched toggle — marks every season from the header menu,
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
await seriesCard.click();
// The ⋮ series menu sits at the end of the season-header actions row
// (data-test-id with a dash — getByTestId only matches data-testid in
// this suite; the menu item renders into the CDK overlay).
const menuTrigger = page.locator('[data-test-id="series-watch-menu"]');
await expect(menuTrigger).toBeVisible({ timeout: 15_000 });
// The series row sits in the hero's "…" menu (data-test-id with a dash —
// getByTestId only matches data-testid in this suite; the row renders
// into the CDK overlay).
const seasonTabs = page.locator('.season-tabs__pill');
await expect(seasonTabs).toHaveCount(3);
await menuTrigger.click();
const seriesToggle = page.locator('[data-test-id="toggle-series-watched"]');
await expect(seriesToggle).toBeVisible();
let seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText('Mark series as watched (24)');
await seriesToggle.click();
@@ -852,27 +857,29 @@ test('@xtream series watched toggle — marks every season from the header menu,
await expect(page.locator('.episode-card--watched')).toHaveCount(8);
// The action now offers unwatch-all.
await menuTrigger.click();
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText('Mark series as unwatched');
await page.keyboard.press('Escape');
await closeSeriesMenu(page);
// PWA persistence: positions live in localStorage, so a reload must come
// back fully watched across all seasons.
await page.reload();
await expect(menuTrigger).toBeVisible({ timeout: 20_000 });
await expect(page.locator('[data-testid="series-more-menu"]')).toBeVisible({
timeout: 20_000,
});
await expect(page.locator('.season-tabs__done')).toHaveCount(3, {
timeout: 10_000,
});
// Unwatch-all clears every season again.
await menuTrigger.click();
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText('Mark series as unwatched');
await seriesToggle.click();
await expect(page.locator('.season-tabs__done')).toHaveCount(0, {
timeout: 15_000,
});
await expect(page.locator('.episode-card--watched')).toHaveCount(0);
await menuTrigger.click();
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText('Mark series as watched (24)');
});
@@ -989,8 +996,12 @@ for (const theme of ['light', 'dark']) {
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
const favorite = shell.locator('.favorite-btn').first();
const favorite = shell
.locator('[data-testid="series-favorite-toggle"]')
.first();
const card = shell.locator('.episode-card').first();
// The card is flat; its visible edge is the artwork's hairline.
const artwork = card.locator('.episode-card__thumbnail');
const toggle = shell.locator('mat-button-toggle-group');
await expect(card).toBeVisible();
await expect(shell.locator('.hero__content')).toHaveCSS('opacity', '1');
@@ -1000,7 +1011,7 @@ for (const theme of ['light', 'dark']) {
await expect
.poll(() => rasterizedBorderContrast(favorite))
.toBeGreaterThan(1.1);
for (const surface of [card, toggle]) {
for (const surface of [artwork, toggle]) {
await expect
.poll(async () => (await surfaceContrast(surface)).border)
.toBeGreaterThan(1.15);
@@ -1015,7 +1026,7 @@ for (const theme of ['light', 'dark']) {
});
await card.hover();
await expect
.poll(async () => (await surfaceContrast(card)).border)
.poll(async () => (await surfaceContrast(artwork)).border)
.toBeGreaterThan(1.15);
await page
.getByRole('radio', { name: 'List view', exact: true })
@@ -64,6 +64,7 @@ export function createSettingsForm(
player: [VideoPlayer.VideoJs],
webPlayerSharedControls: true,
playerAmbientMode: false,
detailTrailerBackdrop: false,
playerUpNextRail: true,
playerUpNextCard: true,
fullscreenChannelPanel: true,
@@ -176,6 +177,7 @@ export function createSettingsFromFormValue(
player: value.player ?? VideoPlayer.VideoJs,
webPlayerSharedControls: value.webPlayerSharedControls ?? true,
playerAmbientMode: value.playerAmbientMode ?? false,
detailTrailerBackdrop: value.detailTrailerBackdrop ?? false,
playerUpNextRail: value.playerUpNextRail ?? true,
playerUpNextCard: value.playerUpNextCard ?? true,
fullscreenChannelPanel: value.fullscreenChannelPanel ?? true,
@@ -73,6 +73,7 @@ function createForm(player: VideoPlayer): FormGroup {
player: new FormControl(player),
webPlayerSharedControls: new FormControl(false),
playerAmbientMode: new FormControl(false),
detailTrailerBackdrop: new FormControl(false),
playerUpNextRail: new FormControl(true),
playerUpNextCard: new FormControl(true),
fullscreenChannelPanel: new FormControl(true),
@@ -137,6 +137,34 @@
</div>
}
<!-- A details-page feature: shown for every player, not only the web ones. -->
<div
class="setting-item"
data-setting-id="detail-trailer-backdrop"
data-test-id="detail-trailer-backdrop-setting"
>
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND' | translate }}
</h4>
<p>
{{
'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
[aria-label]="
'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND' | translate
"
formControlName="detailTrailerBackdrop"
data-test-id="detail-trailer-backdrop-toggle"
></mat-checkbox>
</div>
</div>
@if (supportsUpNextCard()) {
<div
class="setting-item"
@@ -457,6 +457,7 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup {
player: new FormControl(player),
webPlayerSharedControls: new FormControl(false),
playerAmbientMode: new FormControl(false),
detailTrailerBackdrop: new FormControl(false),
playerUpNextRail: new FormControl(true),
playerUpNextCard: new FormControl(true),
fullscreenChannelPanel: new FormControl(true),
@@ -86,6 +86,7 @@ export const DEFAULT_SETTINGS = {
player: VideoPlayer.VideoJs,
webPlayerSharedControls: true,
playerAmbientMode: false,
detailTrailerBackdrop: false,
playerUpNextRail: true,
playerUpNextCard: true,
fullscreenChannelPanel: true,
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "استخدم عناصر التحكم المشتركة في IPTVnator مع HTML5 وVideo.js وArtPlayer.",
"PLAYER_AMBIENT_MODE": "ملء الخلفية المحيطة",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "تعبئة المساحة حول المشغل بنسخة مشوشة ومعتمة من الملصق بدلاً من الخطوط السوداء.",
"PLAY_TRAILERS_IN_BACKGROUND": "تشغيل الإعلانات في خلفية التفاصيل",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "بعد ثوانٍ من فتح فيلم أو مسلسل، يُشغَّل إعلانه بدون صوت خلف التفاصيل. متوقف على الاتصالات المحدودة ومع تقليل الحركة.",
"PLAYER_UP_NEXT_RAIL": "شريط الحلقات التالية",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "في النوافذ العريضة، تثبيت مشغل المسلسل على اليسار وعرض الحلقات القادمة بجانبه.",
"PLAYER_UP_NEXT_CARD": "بطاقة «التالي»",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "جارٍ تحميل المزيد…",
"LOAD_MORE_FAILED": "تعذر تحميل المزيد من العناصر",
"RETRY": "إعادة المحاولة"
},
"DETAIL": {
"TRAILER": "الإعلان",
"TRAILER_OF": "{{title}} · الإعلان",
"MORE_ACTIONS": "إجراءات إضافية",
"STARRING": "بطولة",
"AND_MORE": "وآخرون",
"CAST_AND_CREW": "طاقم التمثيل والعمل",
"SCROLL_PREVIOUS": "السابق",
"SCROLL_NEXT": "التالي",
"EPISODES": "الحلقات",
"EPISODE_NUMBER": "الحلقة {{episode}}",
"RESET_PROGRESS": "إعادة ضبط التقدم",
"OTHER_SOURCES": "مصادر أخرى",
"OPEN_IN_EXTERNAL_PLAYER": "فتح في مشغّل خارجي",
"SHOW_IN_CATEGORY": "عرض في الفئة",
"HIDE_FROM_CONTINUE_WATCHING": "إخفاء من «متابعة المشاهدة»",
"GROUP_WATCHING": "المشاهدة",
"GROUP_SOURCE": "المصدر",
"DURATION_HOURS_MINUTES": "{{hours}} س {{minutes}} د",
"DURATION_MINUTES": "{{minutes}} د",
"WATCHED": "تمت مشاهدته",
"TRAILER_MUTE": "كتم صوت الإعلان",
"TRAILER_UNMUTE": "تشغيل صوت الإعلان",
"PROGRESS_RESET": "تمت إعادة ضبط التقدم",
"HIDDEN_FROM_CONTINUE_WATCHING": "تم الإخفاء من «متابعة المشاهدة»"
}
},
"INFORMATION": "معلومات",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "استعمل عناصر التحكم المشتركة ديال IPTVnator مع HTML5 وVideo.js وArtPlayer.",
"PLAYER_AMBIENT_MODE": "خلفية محيطة",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "ملي المساحة حول المشغل بغلاف معتم وطابت بلاصة الشرائط السوداء.",
"PLAY_TRAILERS_IN_BACKGROUND": "خدّم الإعلانات فخلفية التفاصيل",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "بعد شي ثواني من فتح فيلم ولا مسلسل، الإعلان ديالو كيتخدّم بلا صوت مورا التفاصيل. مطفي مع الاتصالات المحدودة ومع تقليل الحركة.",
"PLAYER_UP_NEXT_RAIL": "شريط الحلقات الجاية",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "فالنوافذ العريضة، حط مشغل السلسلة على اليسار وبيّن الحلقات الجاية بحداه.",
"PLAYER_UP_NEXT_CARD": "بطاقة «الجاي»",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "جارٍ تحميل المزيد…",
"LOAD_MORE_FAILED": "تعذر تحميل المزيد من العناصر",
"RETRY": "عاود المحاولة"
},
"DETAIL": {
"TRAILER": "الإعلان",
"TRAILER_OF": "{{title}} · الإعلان",
"MORE_ACTIONS": "إجراءات أخرى",
"STARRING": "بطولة",
"AND_MORE": "وآخرين",
"CAST_AND_CREW": "الممثلين والطاقم",
"SCROLL_PREVIOUS": "اللي قبل",
"SCROLL_NEXT": "اللي بعد",
"EPISODES": "الحلقات",
"EPISODE_NUMBER": "الحلقة {{episode}}",
"RESET_PROGRESS": "رجّع التقدم",
"OTHER_SOURCES": "مصادر أخرى",
"OPEN_IN_EXTERNAL_PLAYER": "حلّ فمشغّل خارجي",
"SHOW_IN_CATEGORY": "ورّي فالفئة",
"HIDE_FROM_CONTINUE_WATCHING": "خبّي من «كمّل المشاهدة»",
"GROUP_WATCHING": "المشاهدة",
"GROUP_SOURCE": "المصدر",
"DURATION_HOURS_MINUTES": "{{hours}} س {{minutes}} د",
"DURATION_MINUTES": "{{minutes}} د",
"WATCHED": "تشاف",
"TRAILER_MUTE": "سكّت الإعلان",
"TRAILER_UNMUTE": "خدّم صوت الإعلان",
"PROGRESS_RESET": "ترجّع التقدم",
"HIDDEN_FROM_CONTINUE_WATCHING": "تخبّى من «كمّل المشاهدة»"
}
},
"INFORMATION": "معلومات",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Выкарыстоўваць агульныя элементы кіравання IPTVnator у HTML5, Video.js і ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Атмасфернае запаўненне",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Запаўніць прастор вакол плэйра размытай, затемнёнай вокладкай замяст чорных палос.",
"PLAY_TRAILERS_IN_BACKGROUND": "Прайграваць трэйлеры на фоне дэталяў",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Праз некалькі секунд пасля адкрыцця фільма ці серыяла яго трэйлер прайграецца без гуку на фоне апісання. Выключана пры лімітаваным злучэнні і паменшанай анімацыі.",
"PLAYER_UP_NEXT_RAIL": "Панэль наступных эпізодаў",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "На шырокіх вокнах замацоўваць прайгравальнік серыяла злева і паказваць наступныя эпізоды побач з ім.",
"PLAYER_UP_NEXT_CARD": "Картка «Далей»",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "Загружаем яшчэ…",
"LOAD_MORE_FAILED": "Не атрымалася загрузіць больш элементаў",
"RETRY": "Паўтарыць"
},
"DETAIL": {
"TRAILER": "Трэйлер",
"TRAILER_OF": "{{title}} · Трэйлер",
"MORE_ACTIONS": "Яшчэ дзеянні",
"STARRING": "У ролях",
"AND_MORE": "і іншыя",
"CAST_AND_CREW": "Акцёры і здымачная група",
"SCROLL_PREVIOUS": "Назад",
"SCROLL_NEXT": "Далей",
"EPISODES": "Серыі",
"EPISODE_NUMBER": "Серыя {{episode}}",
"RESET_PROGRESS": "Скінуць прагрэс",
"OTHER_SOURCES": "Іншыя крыніцы",
"OPEN_IN_EXTERNAL_PLAYER": "Адкрыць у знешнім плэеры",
"SHOW_IN_CATEGORY": "Паказаць у катэгорыі",
"HIDE_FROM_CONTINUE_WATCHING": "Схаваць з «Працягнуць прагляд»",
"GROUP_WATCHING": "Прагляд",
"GROUP_SOURCE": "Крыніца",
"DURATION_HOURS_MINUTES": "{{hours}} г {{minutes}} хв",
"DURATION_MINUTES": "{{minutes}} хв",
"WATCHED": "прагледжана",
"TRAILER_MUTE": "Выключыць гук трэйлера",
"TRAILER_UNMUTE": "Уключыць гук трэйлера",
"PROGRESS_RESET": "Прагрэс скінуты",
"HIDDEN_FROM_CONTINUE_WATCHING": "Схавана з «Працягнуць прагляд»"
}
},
"INFORMATION": "Інфармацыя",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Verwendet die gemeinsamen IPTVnator-Steuerelemente in HTML5, Video.js und ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Umgebungs-Hintergrund",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Füllt den Bereich um den Player mit einem unscharfen, gedimmten Poster auf, anstelle von einfachen schwarzen Balken.",
"PLAY_TRAILERS_IN_BACKGROUND": "Trailer im Hintergrund der Details abspielen",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Einige Sekunden nach dem Öffnen eines Films oder einer Serie läuft der Trailer stumm hinter den Details. Bei getakteten Verbindungen und reduzierter Bewegung aus.",
"PLAYER_UP_NEXT_RAIL": "Folgenleiste „Als Nächstes“",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Dockt den Serienplayer in breiten Fenstern links an und zeigt daneben die kommenden Folgen an.",
"PLAYER_UP_NEXT_CARD": "Karte „Als Nächstes“",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "Lade mehr…",
"LOAD_MORE_FAILED": "Weitere Einträge konnten nicht geladen werden",
"RETRY": "Erneut versuchen"
},
"DETAIL": {
"TRAILER": "Trailer",
"TRAILER_OF": "{{title}} · Trailer",
"MORE_ACTIONS": "Weitere Aktionen",
"STARRING": "Mit",
"AND_MORE": "und weitere",
"CAST_AND_CREW": "Besetzung & Crew",
"SCROLL_PREVIOUS": "Zurück",
"SCROLL_NEXT": "Weiter",
"EPISODES": "Episoden",
"EPISODE_NUMBER": "Episode {{episode}}",
"RESET_PROGRESS": "Fortschritt zurücksetzen",
"OTHER_SOURCES": "Andere Quellen",
"OPEN_IN_EXTERNAL_PLAYER": "In externem Player öffnen",
"SHOW_IN_CATEGORY": "In Kategorie anzeigen",
"HIDE_FROM_CONTINUE_WATCHING": "Aus „Weiterschauen“ ausblenden",
"GROUP_WATCHING": "Ansehen",
"GROUP_SOURCE": "Quelle",
"DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min",
"DURATION_MINUTES": "{{minutes}} min",
"WATCHED": "gesehen",
"TRAILER_MUTE": "Trailer stummschalten",
"TRAILER_UNMUTE": "Trailer-Ton einschalten",
"PROGRESS_RESET": "Fortschritt zurückgesetzt",
"HIDDEN_FROM_CONTINUE_WATCHING": "Aus „Weiterschauen“ ausgeblendet"
}
},
"INFORMATION": "Information",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Χρησιμοποιήστε τα κοινά στοιχεία ελέγχου του IPTVnator σε HTML5, Video.js και ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Περιβαλλοντικό γέμισμα",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Γεμίστε το χώρο γύρω από τον αναπαραγωγό με θολωμένη, σκοτεινή αφίσα αντί για απλές μαύρες ράβδους.",
"PLAY_TRAILERS_IN_BACKGROUND": "Αναπαραγωγή τρέιλερ στο φόντο των λεπτομερειών",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Λίγα δευτερόλεπτα μετά το άνοιγμα μιας ταινίας ή σειράς, το τρέιλερ παίζει χωρίς ήχο πίσω από τις λεπτομέρειες. Ανενεργό σε συνδέσεις με χρέωση και με μειωμένη κίνηση.",
"PLAYER_UP_NEXT_RAIL": "Λίστα επόμενων επεισοδίων",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Σε πλατιά παράθυρα, ο αναπαραγωγός σειρών αγκυρώνεται στα αριστερά και τα επόμενα επεισόδια εμφανίζονται δίπλα του.",
"PLAYER_UP_NEXT_CARD": "Κάρτα «Επόμενο»",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "Φόρτωση περισσότερων…",
"LOAD_MORE_FAILED": "Δεν ήταν δυνατή η φόρτωση περισσότερων στοιχείων",
"RETRY": "Επανάληψη"
},
"DETAIL": {
"TRAILER": "Τρέιλερ",
"TRAILER_OF": "{{title}} · Τρέιλερ",
"MORE_ACTIONS": "Περισσότερες ενέργειες",
"STARRING": "Πρωταγωνιστούν",
"AND_MORE": "και άλλοι",
"CAST_AND_CREW": "Ηθοποιοί και συντελεστές",
"SCROLL_PREVIOUS": "Προηγούμενο",
"SCROLL_NEXT": "Επόμενο",
"EPISODES": "Επεισόδια",
"EPISODE_NUMBER": "Επεισόδιο {{episode}}",
"RESET_PROGRESS": "Επαναφορά προόδου",
"OTHER_SOURCES": "Άλλες πηγές",
"OPEN_IN_EXTERNAL_PLAYER": "Άνοιγμα σε εξωτερικό player",
"SHOW_IN_CATEGORY": "Εμφάνιση στην κατηγορία",
"HIDE_FROM_CONTINUE_WATCHING": "Απόκρυψη από το «Συνέχεια παρακολούθησης»",
"GROUP_WATCHING": "Παρακολούθηση",
"GROUP_SOURCE": "Πηγή",
"DURATION_HOURS_MINUTES": "{{hours}} ώ {{minutes}} λ",
"DURATION_MINUTES": "{{minutes}} λεπ.",
"WATCHED": "παρακολουθήθηκε",
"TRAILER_MUTE": "Σίγαση τρέιλερ",
"TRAILER_UNMUTE": "Ενεργοποίηση ήχου τρέιλερ",
"PROGRESS_RESET": "Η πρόοδος επαναφέρθηκε",
"HIDDEN_FROM_CONTINUE_WATCHING": "Αποκρύφθηκε από το «Συνέχεια παρακολούθησης»"
}
},
"INFORMATION": "Πληροφορίες",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Use IPTVnator's shared controls in HTML5, Video.js, and ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Ambient background fill",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Fill the space around the player with a blurred, dimmed poster instead of plain black bars.",
"PLAY_TRAILERS_IN_BACKGROUND": "Play trailers in details background",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "A few seconds after opening a movie or series, its trailer plays muted behind the details. Off on metered connections and with reduced motion.",
"PLAYER_UP_NEXT_RAIL": "Up Next episode rail",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "On wide windows, dock the series player to the left and show the upcoming episodes beside it.",
"PLAYER_UP_NEXT_CARD": "\"Up next\" card",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "Loading more…",
"LOAD_MORE_FAILED": "Couldn't load more items",
"RETRY": "Retry"
},
"DETAIL": {
"TRAILER": "Trailer",
"TRAILER_OF": "{{title}} · Trailer",
"MORE_ACTIONS": "More actions",
"STARRING": "Starring",
"AND_MORE": "and more",
"CAST_AND_CREW": "Cast & crew",
"SCROLL_PREVIOUS": "Previous",
"SCROLL_NEXT": "Next",
"EPISODES": "Episodes",
"EPISODE_NUMBER": "Episode {{episode}}",
"RESET_PROGRESS": "Reset progress",
"OTHER_SOURCES": "Other sources",
"OPEN_IN_EXTERNAL_PLAYER": "Open in external player",
"SHOW_IN_CATEGORY": "Show in category",
"HIDE_FROM_CONTINUE_WATCHING": "Hide from Continue Watching",
"GROUP_WATCHING": "Watching",
"GROUP_SOURCE": "Source",
"DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min",
"DURATION_MINUTES": "{{minutes}} min",
"WATCHED": "watched",
"TRAILER_MUTE": "Mute trailer",
"TRAILER_UNMUTE": "Unmute trailer",
"PROGRESS_RESET": "Progress reset",
"HIDDEN_FROM_CONTINUE_WATCHING": "Hidden from Continue Watching"
}
},
"INFORMATION": "Information",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa los controles compartidos de IPTVnator en HTML5, Video.js y ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Relleno de fondo ambiental",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Rellena el espacio alrededor del reproductor con una imagen de póster desenfocada y oscurecida en lugar de barras negras simples.",
"PLAY_TRAILERS_IN_BACKGROUND": "Reproducir tráilers de fondo en los detalles",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Unos segundos después de abrir una película o serie, su tráiler se reproduce en silencio detrás de los detalles. Desactivado con conexiones limitadas y movimiento reducido.",
"PLAYER_UP_NEXT_RAIL": "Panel de próximos episodios",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "En ventanas anchas, ancla el reproductor de series a la izquierda y muestra los próximos episodios a su lado.",
"PLAYER_UP_NEXT_CARD": "Tarjeta «A continuación»",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "Cargando más…",
"LOAD_MORE_FAILED": "No se pudieron cargar más elementos",
"RETRY": "Reintentar"
},
"DETAIL": {
"TRAILER": "Tráiler",
"TRAILER_OF": "{{title}} · Tráiler",
"MORE_ACTIONS": "Más acciones",
"STARRING": "Reparto",
"AND_MORE": "y más",
"CAST_AND_CREW": "Reparto y equipo",
"SCROLL_PREVIOUS": "Anterior",
"SCROLL_NEXT": "Siguiente",
"EPISODES": "Episodios",
"EPISODE_NUMBER": "Episodio {{episode}}",
"RESET_PROGRESS": "Restablecer progreso",
"OTHER_SOURCES": "Otras fuentes",
"OPEN_IN_EXTERNAL_PLAYER": "Abrir en reproductor externo",
"SHOW_IN_CATEGORY": "Mostrar en la categoría",
"HIDE_FROM_CONTINUE_WATCHING": "Ocultar de «Continuar viendo»",
"GROUP_WATCHING": "Visualización",
"GROUP_SOURCE": "Fuente",
"DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min",
"DURATION_MINUTES": "{{minutes}} min",
"WATCHED": "visto",
"TRAILER_MUTE": "Silenciar tráiler",
"TRAILER_UNMUTE": "Activar sonido del tráiler",
"PROGRESS_RESET": "Progreso restablecido",
"HIDDEN_FROM_CONTINUE_WATCHING": "Oculto de «Continuar viendo»"
}
},
"INFORMATION": "Información",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Utilise les commandes partagées d’IPTVnator dans HTML5, Video.js et ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Mode ambiant",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Remplit l'espace autour du lecteur avec une affiche floue et assombrie au lieu de barres noires simples.",
"PLAY_TRAILERS_IN_BACKGROUND": "Lire les bandes-annonces en fond des détails",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Quelques secondes après l’ouverture d’un film ou d’une série, sa bande-annonce est lue en sourdine derrière les détails. Désactivé sur connexion limitée et avec les animations réduites.",
"PLAYER_UP_NEXT_RAIL": "Rail d'épisodes « À suivre »",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Sur les fenêtres larges, ancre le lecteur de séries à gauche et affiche les épisodes à venir à côté.",
"PLAYER_UP_NEXT_CARD": "Carte « À suivre »",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "Chargement de la suite…",
"LOAD_MORE_FAILED": "Impossible de charger plus d'éléments",
"RETRY": "Réessayer"
},
"DETAIL": {
"TRAILER": "Bande-annonce",
"TRAILER_OF": "{{title}} · Bande-annonce",
"MORE_ACTIONS": "Plus d’actions",
"STARRING": "Avec",
"AND_MORE": "et plus",
"CAST_AND_CREW": "Distribution et équipe",
"SCROLL_PREVIOUS": "Précédent",
"SCROLL_NEXT": "Suivant",
"EPISODES": "Épisodes",
"EPISODE_NUMBER": "Épisode {{episode}}",
"RESET_PROGRESS": "Réinitialiser la progression",
"OTHER_SOURCES": "Autres sources",
"OPEN_IN_EXTERNAL_PLAYER": "Ouvrir dans un lecteur externe",
"SHOW_IN_CATEGORY": "Afficher dans la catégorie",
"HIDE_FROM_CONTINUE_WATCHING": "Masquer de « Reprendre »",
"GROUP_WATCHING": "Visionnage",
"GROUP_SOURCE": "Source",
"DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min",
"DURATION_MINUTES": "{{minutes}} min",
"WATCHED": "vu",
"TRAILER_MUTE": "Couper le son de la bande-annonce",
"TRAILER_UNMUTE": "Activer le son de la bande-annonce",
"PROGRESS_RESET": "Progression réinitialisée",
"HIDDEN_FROM_CONTINUE_WATCHING": "Masqué de « Reprendre »"
}
},
"INFORMATION": "Information",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Az IPTVnator közös vezérlőinek használata a HTML5-, Video.js- és ArtPlayer-lejátszóban.",
"PLAYER_AMBIENT_MODE": "Ambient background fill",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Fill the space around the player with a blurred, dimmed poster instead of plain black bars.",
"PLAY_TRAILERS_IN_BACKGROUND": "Előzetesek lejátszása a részletek hátterében",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Néhány másodperccel egy film vagy sorozat megnyitása után az előzetes némán lejátszódik a részletek mögött. Forgalomkorlátos kapcsolaton és csökkentett mozgásnál kikapcsolva.",
"PLAYER_UP_NEXT_RAIL": "„Következik” epizódsáv",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Széles ablakban a sorozatlejátszó balra kerül, mellette pedig megjelennek a következő epizódok.",
"PLAYER_UP_NEXT_CARD": "„Következik” kártya",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "További elemek betöltése…",
"LOAD_MORE_FAILED": "Nem sikerült több elemet betölteni",
"RETRY": "Újra"
},
"DETAIL": {
"TRAILER": "Előzetes",
"TRAILER_OF": "{{title}} · Előzetes",
"MORE_ACTIONS": "További műveletek",
"STARRING": "Szereplők",
"AND_MORE": "és mások",
"CAST_AND_CREW": "Szereplők és stáb",
"SCROLL_PREVIOUS": "Előző",
"SCROLL_NEXT": "Következő",
"EPISODES": "Epizódok",
"EPISODE_NUMBER": "{{episode}}. epizód",
"RESET_PROGRESS": "Haladás törlése",
"OTHER_SOURCES": "Más források",
"OPEN_IN_EXTERNAL_PLAYER": "Megnyitás külső lejátszóban",
"SHOW_IN_CATEGORY": "Megjelenítés a kategóriában",
"HIDE_FROM_CONTINUE_WATCHING": "Elrejtés a „Folytatás” sorból",
"GROUP_WATCHING": "Megtekintés",
"GROUP_SOURCE": "Forrás",
"DURATION_HOURS_MINUTES": "{{hours}} ó {{minutes}} p",
"DURATION_MINUTES": "{{minutes}} perc",
"WATCHED": "megnézve",
"TRAILER_MUTE": "Előzetes némítása",
"TRAILER_UNMUTE": "Előzetes hangjának bekapcsolása",
"PROGRESS_RESET": "Haladás törölve",
"HIDDEN_FROM_CONTINUE_WATCHING": "Elrejtve a „Folytatás” sorból"
}
},
"INFORMATION": "Információk",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa i controlli condivisi di IPTVnator in HTML5, Video.js e ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Riempimento ambientale dello sfondo",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Riempie lo spazio intorno al lettore con un poster sfocato e scurito invece delle semplici barre nere.",
"PLAY_TRAILERS_IN_BACKGROUND": "Riproduci i trailer sullo sfondo dei dettagli",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Qualche secondo dopo l’apertura di un film o di una serie, il trailer viene riprodotto senza audio dietro i dettagli. Disattivato con connessioni a consumo e animazioni ridotte.",
"PLAYER_UP_NEXT_RAIL": "Pannello \"A seguire\" con i prossimi episodi",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Nelle finestre ampie, ancora il lettore delle serie a sinistra e mostra i prossimi episodi accanto.",
"PLAYER_UP_NEXT_CARD": "Scheda \"A seguire\"",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "Caricamento di altri elementi…",
"LOAD_MORE_FAILED": "Impossibile caricare altri elementi",
"RETRY": "Riprova"
},
"DETAIL": {
"TRAILER": "Trailer",
"TRAILER_OF": "{{title}} · Trailer",
"MORE_ACTIONS": "Altre azioni",
"STARRING": "Con",
"AND_MORE": "e altri",
"CAST_AND_CREW": "Cast e troupe",
"SCROLL_PREVIOUS": "Precedente",
"SCROLL_NEXT": "Successivo",
"EPISODES": "Episodi",
"EPISODE_NUMBER": "Episodio {{episode}}",
"RESET_PROGRESS": "Azzera avanzamento",
"OTHER_SOURCES": "Altre fonti",
"OPEN_IN_EXTERNAL_PLAYER": "Apri nel lettore esterno",
"SHOW_IN_CATEGORY": "Mostra nella categoria",
"HIDE_FROM_CONTINUE_WATCHING": "Nascondi da «Continua a guardare»",
"GROUP_WATCHING": "Visione",
"GROUP_SOURCE": "Fonte",
"DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min",
"DURATION_MINUTES": "{{minutes}} min",
"WATCHED": "visto",
"TRAILER_MUTE": "Disattiva audio del trailer",
"TRAILER_UNMUTE": "Attiva audio del trailer",
"PROGRESS_RESET": "Avanzamento azzerato",
"HIDDEN_FROM_CONTINUE_WATCHING": "Nascosto da «Continua a guardare»"
}
},
"INFORMATION": "Informazioni",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5、Video.js、ArtPlayerでIPTVnator共通のコントロールを使用します。",
"PLAYER_AMBIENT_MODE": "プレーヤー周囲のぼかし背景",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "プレーヤーの周囲をぼかしたポスター画像で埋め、通常の黒い枠の代わりに表示します。",
"PLAY_TRAILERS_IN_BACKGROUND": "詳細の背景で予告編を再生",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "映画やシリーズを開いて数秒後、予告編が詳細の背後でミュート再生されます。従量制接続や視差効果を減らす設定ではオフになります。",
"PLAYER_UP_NEXT_RAIL": "「次に再生」エピソードレール",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "ウィンドウ幅が広い場合、シリーズのプレーヤーを左側に配置し、その横に次のエピソードの一覧を表示します。",
"PLAYER_UP_NEXT_CARD": "「次に再生」カード",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "さらに読み込み中…",
"LOAD_MORE_FAILED": "追加の項目を読み込めませんでした",
"RETRY": "再試行"
},
"DETAIL": {
"TRAILER": "予告編",
"TRAILER_OF": "{{title}} · 予告編",
"MORE_ACTIONS": "その他の操作",
"STARRING": "出演",
"AND_MORE": "ほか",
"CAST_AND_CREW": "キャストとスタッフ",
"SCROLL_PREVIOUS": "前へ",
"SCROLL_NEXT": "次へ",
"EPISODES": "エピソード",
"EPISODE_NUMBER": "第{{episode}}話",
"RESET_PROGRESS": "再生位置をリセット",
"OTHER_SOURCES": "他のソース",
"OPEN_IN_EXTERNAL_PLAYER": "外部プレーヤーで開く",
"SHOW_IN_CATEGORY": "カテゴリで表示",
"HIDE_FROM_CONTINUE_WATCHING": "「続きを見る」から隠す",
"GROUP_WATCHING": "視聴",
"GROUP_SOURCE": "ソース",
"DURATION_HOURS_MINUTES": "{{hours}}時間{{minutes}}分",
"DURATION_MINUTES": "{{minutes}}分",
"WATCHED": "視聴済み",
"TRAILER_MUTE": "予告編をミュート",
"TRAILER_UNMUTE": "予告編の音を出す",
"PROGRESS_RESET": "再生位置をリセットしました",
"HIDDEN_FROM_CONTINUE_WATCHING": "「続きを見る」から隠しました"
}
},
"INFORMATION": "情報",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5, Video.js 및 ArtPlayer에서 IPTVnator 공통 컨트롤을 사용합니다.",
"PLAYER_AMBIENT_MODE": "주변 배경 채우기",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "플레이어 주변을 검은 막대 대신 흐려지고 어두워진 포스터로 채웁니다.",
"PLAY_TRAILERS_IN_BACKGROUND": "상세 화면 배경에서 예고편 재생",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "영화나 시리즈를 연 지 몇 초 후 예고편이 상세 정보 뒤에서 음소거로 재생됩니다. 데이터 절약 연결과 동작 줄이기 설정에서는 꺼집니다.",
"PLAYER_UP_NEXT_RAIL": "다음 에피소드 레일",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "넓은 창에서 시리즈 플레이어를 왼쪽에 고정하고 그 옆에 다음 에피소드를 표시합니다.",
"PLAYER_UP_NEXT_CARD": "다음 에피소드 카드",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "더 불러오는 중…",
"LOAD_MORE_FAILED": "추가 항목을 불러오지 못했습니다",
"RETRY": "다시 시도"
},
"DETAIL": {
"TRAILER": "예고편",
"TRAILER_OF": "{{title}} · 예고편",
"MORE_ACTIONS": "추가 작업",
"STARRING": "출연",
"AND_MORE": "외",
"CAST_AND_CREW": "출연진 및 제작진",
"SCROLL_PREVIOUS": "이전",
"SCROLL_NEXT": "다음",
"EPISODES": "에피소드",
"EPISODE_NUMBER": "{{episode}}화",
"RESET_PROGRESS": "진행 상태 초기화",
"OTHER_SOURCES": "다른 소스",
"OPEN_IN_EXTERNAL_PLAYER": "외부 플레이어로 열기",
"SHOW_IN_CATEGORY": "카테고리에서 보기",
"HIDE_FROM_CONTINUE_WATCHING": "‘이어서 보기’에서 숨기기",
"GROUP_WATCHING": "시청",
"GROUP_SOURCE": "소스",
"DURATION_HOURS_MINUTES": "{{hours}}시간 {{minutes}}분",
"DURATION_MINUTES": "{{minutes}}분",
"WATCHED": "시청함",
"TRAILER_MUTE": "예고편 음소거",
"TRAILER_UNMUTE": "예고편 소리 켜기",
"PROGRESS_RESET": "진행 상태를 초기화했습니다",
"HIDDEN_FROM_CONTINUE_WATCHING": "‘이어서 보기’에서 숨겼습니다"
}
},
"INFORMATION": "정보",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Gebruik de gedeelde IPTVnator-bediening in HTML5, Video.js en ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Ambient achtergrond",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Vult de ruimte rond de speler met een vervaagde, gedimde posterafbeelding in plaats van zwarte balken.",
"PLAY_TRAILERS_IN_BACKGROUND": "Trailers afspelen achter de details",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Enkele seconden na het openen van een film of serie speelt de trailer gedempt achter de details. Uit bij gemeten verbindingen en verminderde beweging.",
"PLAYER_UP_NEXT_RAIL": "Hierna-paneel met afleveringen",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Zet de seriespeler in brede vensters links vast en toont de komende afleveringen ernaast.",
"PLAYER_UP_NEXT_CARD": "'Hierna'-kaart",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "Meer laden…",
"LOAD_MORE_FAILED": "Kan geen extra items laden",
"RETRY": "Opnieuw proberen"
},
"DETAIL": {
"TRAILER": "Trailer",
"TRAILER_OF": "{{title}} · Trailer",
"MORE_ACTIONS": "Meer acties",
"STARRING": "Met",
"AND_MORE": "en meer",
"CAST_AND_CREW": "Cast & crew",
"SCROLL_PREVIOUS": "Vorige",
"SCROLL_NEXT": "Volgende",
"EPISODES": "Afleveringen",
"EPISODE_NUMBER": "Aflevering {{episode}}",
"RESET_PROGRESS": "Voortgang wissen",
"OTHER_SOURCES": "Andere bronnen",
"OPEN_IN_EXTERNAL_PLAYER": "Openen in externe speler",
"SHOW_IN_CATEGORY": "Tonen in categorie",
"HIDE_FROM_CONTINUE_WATCHING": "Verbergen uit ‘Verder kijken’",
"GROUP_WATCHING": "Kijken",
"GROUP_SOURCE": "Bron",
"DURATION_HOURS_MINUTES": "{{hours}} u {{minutes}} min",
"DURATION_MINUTES": "{{minutes}} min",
"WATCHED": "bekeken",
"TRAILER_MUTE": "Trailer dempen",
"TRAILER_UNMUTE": "Trailergeluid aan",
"PROGRESS_RESET": "Voortgang gewist",
"HIDDEN_FROM_CONTINUE_WATCHING": "Verborgen uit ‘Verder kijken’"
}
},
"INFORMATION": "Informatie",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Używaj wspólnych elementów sterowania IPTVnator w HTML5, Video.js i ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Rozmyte tło",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Wypełnij przestrzeń wokół odtwarzacza rozmytym, przyciemnianym plakatem zamiast zwykłych czarnych pasków.",
"PLAY_TRAILERS_IN_BACKGROUND": "Odtwarzaj zwiastuny w tle szczegółów",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Kilka sekund po otwarciu filmu lub serialu zwiastun odtwarza się bez dźwięku za szczegółami. Wyłączone przy połączeniach taryfowych i ograniczonym ruchu.",
"PLAYER_UP_NEXT_RAIL": "Panel następnych odcinków",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "W szerokich oknach zadokuj odtwarzacz serialu po lewej stronie i pokaż obok kolejne odcinki.",
"PLAYER_UP_NEXT_CARD": "Karta „Następny”",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "Wczytywanie kolejnych…",
"LOAD_MORE_FAILED": "Nie udało się wczytać kolejnych pozycji",
"RETRY": "Ponów"
},
"DETAIL": {
"TRAILER": "Zwiastun",
"TRAILER_OF": "{{title}} · Zwiastun",
"MORE_ACTIONS": "Więcej działań",
"STARRING": "W rolach głównych",
"AND_MORE": "i inni",
"CAST_AND_CREW": "Obsada i ekipa",
"SCROLL_PREVIOUS": "Poprzednie",
"SCROLL_NEXT": "Następne",
"EPISODES": "Odcinki",
"EPISODE_NUMBER": "Odcinek {{episode}}",
"RESET_PROGRESS": "Resetuj postęp",
"OTHER_SOURCES": "Inne źródła",
"OPEN_IN_EXTERNAL_PLAYER": "Otwórz w zewnętrznym odtwarzaczu",
"SHOW_IN_CATEGORY": "Pokaż w kategorii",
"HIDE_FROM_CONTINUE_WATCHING": "Ukryj z „Kontynuuj oglądanie”",
"GROUP_WATCHING": "Oglądanie",
"GROUP_SOURCE": "Źródło",
"DURATION_HOURS_MINUTES": "{{hours}} godz. {{minutes}} min",
"DURATION_MINUTES": "{{minutes}} min",
"WATCHED": "obejrzane",
"TRAILER_MUTE": "Wycisz zwiastun",
"TRAILER_UNMUTE": "Włącz dźwięk zwiastuna",
"PROGRESS_RESET": "Postęp zresetowany",
"HIDDEN_FROM_CONTINUE_WATCHING": "Ukryto z „Kontynuuj oglądanie”"
}
},
"INFORMATION": "Informacje",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa os controlos partilhados do IPTVnator em HTML5, Video.js e ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Preenchimento de fundo ambiente",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Preenche o espaço ao redor do reprodutor com um pôster desfocado e escurecido em vez de barras pretas simples.",
"PLAY_TRAILERS_IN_BACKGROUND": "Reproduzir trailers ao fundo dos detalhes",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Alguns segundos após abrir um filme ou série, o trailer é reproduzido sem som atrás dos detalhes. Desativado em conexões limitadas e com movimento reduzido.",
"PLAYER_UP_NEXT_RAIL": "Painel de próximos episódios",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Em janelas largas, ancora o reprodutor de séries à esquerda e mostra os próximos episódios ao lado.",
"PLAYER_UP_NEXT_CARD": "Cartão \"A seguir\"",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "A carregar mais…",
"LOAD_MORE_FAILED": "Não foi possível carregar mais itens",
"RETRY": "Tentar novamente"
},
"DETAIL": {
"TRAILER": "Trailer",
"TRAILER_OF": "{{title}} · Trailer",
"MORE_ACTIONS": "Mais ações",
"STARRING": "Com",
"AND_MORE": "e mais",
"CAST_AND_CREW": "Elenco e equipe",
"SCROLL_PREVIOUS": "Anterior",
"SCROLL_NEXT": "Próximo",
"EPISODES": "Episódios",
"EPISODE_NUMBER": "Episódio {{episode}}",
"RESET_PROGRESS": "Redefinir progresso",
"OTHER_SOURCES": "Outras fontes",
"OPEN_IN_EXTERNAL_PLAYER": "Abrir no reprodutor externo",
"SHOW_IN_CATEGORY": "Mostrar na categoria",
"HIDE_FROM_CONTINUE_WATCHING": "Ocultar de «Continuar assistindo»",
"GROUP_WATCHING": "Assistindo",
"GROUP_SOURCE": "Fonte",
"DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min",
"DURATION_MINUTES": "{{minutes}} min",
"WATCHED": "assistido",
"TRAILER_MUTE": "Silenciar trailer",
"TRAILER_UNMUTE": "Ativar som do trailer",
"PROGRESS_RESET": "Progresso redefinido",
"HIDDEN_FROM_CONTINUE_WATCHING": "Oculto de «Continuar assistindo»"
}
},
"INFORMATION": "Informação",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Использовать общие элементы управления IPTVnator в HTML5, Video.js и ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Заливка фона за плеером",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Заполнять пространство вокруг плеера размытым затемнённым постером вместо чёрных полос.",
"PLAY_TRAILERS_IN_BACKGROUND": "Воспроизводить трейлеры на фоне деталей",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Через несколько секунд после открытия фильма или сериала его трейлер играет без звука на фоне описания. Отключено при лимитном соединении и уменьшенной анимации.",
"PLAYER_UP_NEXT_RAIL": "Панель следующих серий",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "В широких окнах закреплять плеер сериала слева и показывать следующие серии рядом с ним.",
"PLAYER_UP_NEXT_CARD": "Карточка «Далее»",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "Загружаем ещё…",
"LOAD_MORE_FAILED": "Не удалось загрузить больше элементов",
"RETRY": "Повторить"
},
"DETAIL": {
"TRAILER": "Трейлер",
"TRAILER_OF": "{{title}} · Трейлер",
"MORE_ACTIONS": "Ещё действия",
"STARRING": "В ролях",
"AND_MORE": "и другие",
"CAST_AND_CREW": "Актёры и съёмочная группа",
"SCROLL_PREVIOUS": "Назад",
"SCROLL_NEXT": "Вперёд",
"EPISODES": "Серии",
"EPISODE_NUMBER": "Серия {{episode}}",
"RESET_PROGRESS": "Сбросить прогресс",
"OTHER_SOURCES": "Другие источники",
"OPEN_IN_EXTERNAL_PLAYER": "Открыть во внешнем плеере",
"SHOW_IN_CATEGORY": "Показать в категории",
"HIDE_FROM_CONTINUE_WATCHING": "Скрыть из «Продолжить просмотр»",
"GROUP_WATCHING": "Просмотр",
"GROUP_SOURCE": "Источник",
"DURATION_HOURS_MINUTES": "{{hours}} ч {{minutes}} мин",
"DURATION_MINUTES": "{{minutes}} мин",
"WATCHED": "просмотрено",
"TRAILER_MUTE": "Выключить звук трейлера",
"TRAILER_UNMUTE": "Включить звук трейлера",
"PROGRESS_RESET": "Прогресс сброшен",
"HIDDEN_FROM_CONTINUE_WATCHING": "Скрыто из «Продолжить просмотр»"
}
},
"INFORMATION": "Инфо",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5, Video.js ve ArtPlayer'da IPTVnator'ın ortak kontrollerini kullan.",
"PLAYER_AMBIENT_MODE": "Ortam ışığı dolgusu",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Oynatıcının çevresindeki boşluğu siyah çubuklar yerine bulanık ve karartılmış bir poster ile doldurur.",
"PLAY_TRAILERS_IN_BACKGROUND": "Fragmanları ayrıntıların arka planında oynat",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Bir film veya dizi açıldıktan birkaç saniye sonra fragmanı ayrıntıların arkasında sessiz oynatılır. Kotalı bağlantılarda ve azaltılmış hareketle kapalı.",
"PLAYER_UP_NEXT_RAIL": "Sıradaki bölümler paneli",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Geniş pencerelerde dizi oynatıcısını sola yaslar ve sıradaki bölümleri yanında gösterir.",
"PLAYER_UP_NEXT_CARD": "\"Sıradaki\" kartı",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "Daha fazla yükleniyor…",
"LOAD_MORE_FAILED": "Daha fazla öğe yüklenemedi",
"RETRY": "Yeniden dene"
},
"DETAIL": {
"TRAILER": "Fragman",
"TRAILER_OF": "{{title}} · Fragman",
"MORE_ACTIONS": "Diğer işlemler",
"STARRING": "Oyuncular",
"AND_MORE": "ve diğerleri",
"CAST_AND_CREW": "Oyuncular ve ekip",
"SCROLL_PREVIOUS": "Önceki",
"SCROLL_NEXT": "Sonraki",
"EPISODES": "Bölümler",
"EPISODE_NUMBER": "Bölüm {{episode}}",
"RESET_PROGRESS": "İlerlemeyi sıfırla",
"OTHER_SOURCES": "Diğer kaynaklar",
"OPEN_IN_EXTERNAL_PLAYER": "Harici oynatıcıda aç",
"SHOW_IN_CATEGORY": "Kategoride göster",
"HIDE_FROM_CONTINUE_WATCHING": "“İzlemeye devam et”ten gizle",
"GROUP_WATCHING": "İzleme",
"GROUP_SOURCE": "Kaynak",
"DURATION_HOURS_MINUTES": "{{hours}} sa {{minutes}} dk",
"DURATION_MINUTES": "{{minutes}} dk",
"WATCHED": "izlendi",
"TRAILER_MUTE": "Fragmanı sessize al",
"TRAILER_UNMUTE": "Fragman sesini aç",
"PROGRESS_RESET": "İlerleme sıfırlandı",
"HIDDEN_FROM_CONTINUE_WATCHING": "“İzlemeye devam et”ten gizlendi"
}
},
"INFORMATION": "Bilgi",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "在 HTML5、Video.js 和 ArtPlayer 中使用 IPTVnator 的共享控件。",
"PLAYER_AMBIENT_MODE": "环境背景填充",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、暗淡的海报填充播放器周围的空白,而不是黑条纹。",
"PLAY_TRAILERS_IN_BACKGROUND": "在详情背景中播放预告片",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "打开电影或剧集几秒后,预告片会在详情背后静音播放。按流量计费的连接和减弱动态效果时关闭。",
"PLAYER_UP_NEXT_RAIL": "「接下来播放」剧集栏",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "在宽窗口下,将剧集播放器停靠在左侧,并在旁边显示后续剧集。",
"PLAYER_UP_NEXT_CARD": "「接下来播放」卡片",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "正在加载更多…",
"LOAD_MORE_FAILED": "无法加载更多内容",
"RETRY": "重试"
},
"DETAIL": {
"TRAILER": "预告片",
"TRAILER_OF": "{{title}} · 预告片",
"MORE_ACTIONS": "更多操作",
"STARRING": "主演",
"AND_MORE": "等",
"CAST_AND_CREW": "演员与制作人员",
"SCROLL_PREVIOUS": "上一个",
"SCROLL_NEXT": "下一个",
"EPISODES": "剧集",
"EPISODE_NUMBER": "第 {{episode}} 集",
"RESET_PROGRESS": "重置进度",
"OTHER_SOURCES": "其他来源",
"OPEN_IN_EXTERNAL_PLAYER": "在外部播放器中打开",
"SHOW_IN_CATEGORY": "在分类中显示",
"HIDE_FROM_CONTINUE_WATCHING": "从“继续观看”中隐藏",
"GROUP_WATCHING": "观看",
"GROUP_SOURCE": "来源",
"DURATION_HOURS_MINUTES": "{{hours}} 小时 {{minutes}} 分钟",
"DURATION_MINUTES": "{{minutes}} 分钟",
"WATCHED": "已观看",
"TRAILER_MUTE": "预告片静音",
"TRAILER_UNMUTE": "取消预告片静音",
"PROGRESS_RESET": "进度已重置",
"HIDDEN_FROM_CONTINUE_WATCHING": "已从“继续观看”中隐藏"
}
},
"INFORMATION": "信息",
+28
View File
@@ -377,6 +377,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "在 HTML5、Video.js 和 ArtPlayer 中使用 IPTVnator 的共用控制項。",
"PLAYER_AMBIENT_MODE": "環境背景填充",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、昏暗的海報填充播放器周圍的空間,取代純黑色邊框。",
"PLAY_TRAILERS_IN_BACKGROUND": "在詳細資訊背景播放預告片",
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "開啟電影或影集幾秒後,預告片會在詳細資訊後方靜音播放。計量連線與減少動態效果時關閉。",
"PLAYER_UP_NEXT_RAIL": "即將播放集數側欄",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "在較寬的視窗中,將影集播放器停靠在左側,並在旁邊顯示即將播放的集數。",
"PLAYER_UP_NEXT_CARD": "「即將播放」卡片",
@@ -1360,6 +1362,32 @@
"LOADING_MORE": "正在載入更多…",
"LOAD_MORE_FAILED": "無法載入更多內容",
"RETRY": "重試"
},
"DETAIL": {
"TRAILER": "預告片",
"TRAILER_OF": "{{title}} · 預告片",
"MORE_ACTIONS": "更多動作",
"STARRING": "主演",
"AND_MORE": "等",
"CAST_AND_CREW": "演員與工作人員",
"SCROLL_PREVIOUS": "上一個",
"SCROLL_NEXT": "下一個",
"EPISODES": "集數",
"EPISODE_NUMBER": "第 {{episode}} 集",
"RESET_PROGRESS": "重設進度",
"OTHER_SOURCES": "其他來源",
"OPEN_IN_EXTERNAL_PLAYER": "在外部播放器開啟",
"SHOW_IN_CATEGORY": "在分類中顯示",
"HIDE_FROM_CONTINUE_WATCHING": "從「繼續觀看」隱藏",
"GROUP_WATCHING": "觀看",
"GROUP_SOURCE": "來源",
"DURATION_HOURS_MINUTES": "{{hours}} 小時 {{minutes}} 分鐘",
"DURATION_MINUTES": "{{minutes}} 分鐘",
"WATCHED": "已觀看",
"TRAILER_MUTE": "預告片靜音",
"TRAILER_UNMUTE": "取消預告片靜音",
"PROGRESS_RESET": "進度已重設",
"HIDDEN_FROM_CONTINUE_WATCHING": "已從「繼續觀看」隱藏"
}
},
"INFORMATION": "資訊",
+17
View File
@@ -150,6 +150,16 @@ html {
);
--app-hero-surface: rgba(47, 123, 255, 0.04);
// Detail / hero call to action. The dark theme's light primary (#f2f4f8
// on #0a0e14) inverts to a dark button on the light theme, so it reads as
// the one solid CTA on either page background.
--app-cta-bg: #1a1d23;
--app-cta-fg: #f5f6f8;
--app-cta-hover-bg: #2a2f3a;
--app-cta-meta-fg: rgba(245, 246, 248, 0.68);
// Star rating chip: amber on white needs a deeper tone than gold on dark.
--app-rating-color: #a16207;
.dark-theme {
@include mat.all-component-colors($dark-theme);
// Only the color roles differ per theme: the elevation shadows are
@@ -242,6 +252,13 @@ html {
rgba(92, 214, 255, 0.07) 100%
);
--app-hero-surface: rgba(30, 35, 48, 0.9);
// Detail / hero call to action (light primary) and rating accent
--app-cta-bg: #f2f4f8;
--app-cta-fg: #0a0e14;
--app-cta-hover-bg: #ffffff;
--app-cta-meta-fg: #5a6274;
--app-rating-color: #ffcf5c;
}
}
+47 -23
View File
@@ -153,14 +153,41 @@ 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`. The hero keeps `min(480px, 60vh)` of stage for a
16:9 backdrop. Without one, or when the provider sends the poster as the
backdrop, the hero is compact (`hero--compact`, sized by its content) over
the blurred poster. The layout is decided once per title, so a backdrop that
TMDB enrichment adds a moment later fills the compact hero instead of
growing it. 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.
With `detailTrailerBackdrop` on (Settings → Playback → "Play trailers in
details background", default off) the hosts hand the trailer embed URL to the
shell and `app-hero-trailer-backdrop` plays it muted and looping under the
scrim after three idle seconds, with a 32px mute toggle in the corner. It
never starts under `prefers-reduced-motion` or with `saveData`, and stops
while the hero is off screen or the window is unfocused.
## Electron Drag Regions
@@ -664,23 +691,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 (rail and context panel), 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 light hairline so its edge
survives the light theme) and 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.
+11 -3
View File
@@ -132,13 +132,21 @@ discovery slides open the detail page; live slides open the channel. TMDB
extras (backdrop, rating, genres, overview, year) come from
`DashboardHeroTmdbService` per featured title and vanish when TMDB is off.
Artwork: a title's backdrop, else its poster blurred and scaled past the
edges (no second, sharp copy); a live channel's logo sits on the right as key
art over its own wash. Series titles drop their season marker
(`splitSeasonSuffix`) — the `S1·E1` chip names the season. Chips are
`app-meta-chip`; the primary is the details pages' light primary
(`light-primary-button` from `libs/ui/styles`).
Rotation is the active dot's CSS fill animation (8 s); its `animationend`
advances. The fill animates `transform` only (a bar sliding in under the
pill's rounded clip), so it runs on the compositor; animating `width` there
cost a style, layout and paint pass on every frame of an idle dashboard.
Hover, focus inside the hero and the pause button pause it; an
explicit Play clears the hover/focus pause until they re-arm; under
`prefers-reduced-motion` nothing auto-advances. The active slide is tracked
Hover, focus inside the hero, a hidden document and the pause button pause
it; an explicit Play clears the hover/focus pause until they re-arm; under
`prefers-reduced-motion` nothing auto-advances. The hero is a focusable
region: ←/→ switch slides and Enter follows the primary action. The active slide is tracked
by id, so a late live slide never moves the user off the current one. Test
hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
`dashboard-hero-dot`, `dashboard-hero-pause`,
@@ -1,6 +1,8 @@
<app-portal-detail-shell
[backAvailable]="false"
[contentKey]="heroKey()"
[title]="title()"
[kindLabel]="kindLabel()"
[description]="overview()"
[posterUrl]="posterUrl()"
[backdropUrl]="backdropUrl()"
@@ -9,80 +11,35 @@
>
<ng-template appDetailTags>
@if (year()) {
<span class="details__tag">{{ year() }}</span>
<app-meta-chip>{{ year() }}</app-meta-chip>
}
@if (genres()) {
<span class="details__tag">{{ genres() }}</span>
@if (runtimeLabel(); as runtime) {
<app-meta-chip>{{ runtime }}</app-meta-chip>
}
@if (runtimeLabel()) {
<span class="details__tag">{{ runtimeLabel() }}</span>
@if (genres(); as genreList) {
<app-meta-chip>{{ genreList }}</app-meta-chip>
}
@if (rating()) {
<span class="details__tag details__tag--rating">
⭐ {{ rating() }}
</span>
<app-meta-chip variant="rating">★ {{ rating() }}</app-meta-chip>
}
</ng-template>
<ng-template appDetailMeta>
@if (cast().length) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
<div class="details__cast-strip">
@for (
member of cast();
track member.tmdbPersonId
? 'p' + member.tmdbPersonId
: 'i' + $index
) {
<span
class="details__cast-chip"
[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>
</div>
}
@if (directors()) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
<span class="value">{{ directors() }}</span>
</div>
}
<app-detail-credits
[cast]="castNames()"
[directors]="directorNames()"
(moreRequested)="scrollToCast()"
/>
</ng-template>
<ng-template appDetailActions>
@if (canPlayInline() && !playbackActive()) {
<button
class="play-btn"
data-test-id="m3u-vod-play"
(click)="startPlayback()"
>
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}
</button>
<app-detail-action-button
[label]="'XTREAM.PLAY' | translate"
[meta]="runtimeLabel()"
testId="m3u-vod-play"
(pressed)="startPlayback()"
/>
}
</ng-template>
@@ -97,4 +54,13 @@
(externalFallbackRequested)="externalFallbackRequested.emit($event)"
/>
}
<div detail-extras>
<!-- M3U has no actor page: the people stay plain entries -->
<app-cast-crew-row
[cast]="cast()"
[directors]="directorMembers()"
[interactive]="false"
/>
</div>
</app-portal-detail-shell>
@@ -3,7 +3,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TmdbEnrichmentService } from '@iptvnator/services';
import { Channel, ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { TranslatePipe } from '@ngx-translate/core';
import {
TranslateModule,
TranslatePipe,
TranslateService,
} from '@ngx-translate/core';
import { CastCrewRowComponent } from '@iptvnator/ui/components';
import { MockPipe } from 'ng-mocks';
import type { M3uVodDetailComponent as M3uVodDetailComponentType } from './m3u-vod-detail.component';
@@ -25,9 +30,12 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
})
class StubPortalDetailShellComponent {
readonly title = input<string>();
readonly kindLabel = input<string | null>(null);
readonly description = input<string>();
readonly posterUrl = input<string>();
readonly backdropUrl = input<string>();
readonly contentKey = input<string | null>(null);
readonly progress = input<number | null>(null);
readonly isLoading = input(false);
readonly errorMessage = input<string>();
readonly backLabel = input<string>();
@@ -127,7 +135,7 @@ describe('M3uVodDetailComponent', () => {
enrichMovie = jest.fn().mockResolvedValue(null);
await TestBed.configureTestingModule({
imports: [M3uVodDetailComponent],
imports: [M3uVodDetailComponent, TranslateModule.forRoot()],
providers: [
{
provide: TmdbEnrichmentService,
@@ -145,6 +153,7 @@ describe('M3uVodDetailComponent', () => {
.overrideComponent(M3uVodDetailComponent, {
set: {
imports: [
CastCrewRowComponent,
StubPortalDetailShellComponent,
StubPortalInlinePlayerComponent,
MockPipe(
@@ -242,6 +251,19 @@ describe('M3uVodDetailComponent', () => {
},
});
await create({ channel: channel(), playback: playback() });
TestBed.inject(TranslateService).setTranslation(
'en',
{
PORTALS: {
DETAIL: {
DURATION_HOURS_MINUTES: '{{hours}} h {{minutes}} min',
DURATION_MINUTES: '{{minutes}} min',
},
},
},
true
);
TestBed.inject(TranslateService).use('en');
const component = fixture.componentInstance;
expect(component.title()).toBe('Dune');
@@ -250,12 +272,12 @@ describe('M3uVodDetailComponent', () => {
expect(component.backdropUrl()).toContain('/b.jpg');
expect(component.year()).toBe('2021');
expect(component.genres()).toBe('Sci-Fi');
expect(component.runtimeLabel()).toBe('2h 35m');
expect(component.runtimeLabel()).toBe('2 h 35 min');
expect(component.rating()).toBe('8.0');
expect(component.cast().map((member) => member.name)).toEqual([
'Timothée Chalamet',
]);
expect(component.directors()).toBe('Denis Villeneuve');
expect(component.directorNames()).toEqual(['Denis Villeneuve']);
});
it('keeps the provider presentation when TMDB has no match', async () => {
@@ -269,6 +291,26 @@ describe('M3uVodDetailComponent', () => {
expect(component.playbackActive()).toBe(true);
});
it('keys the hero by id and name, like the TMDB lookup', async () => {
await create({ channel: channel() });
const shell = fixture.debugElement.query(
By.directive(StubPortalDetailShellComponent)
).componentInstance as StubPortalDetailShellComponent;
const first = shell.contentKey();
// Entries can share an id and differ by name; the next one may have
// a backdrop where this one had none, so the hero must decide its
// layout again.
fixture.componentRef.setInput(
'channel',
channel({ name: 'Dune Part Two (2024) 1080p' })
);
fixture.detectChanges();
expect(first).toContain('ch1');
expect(shell.contentKey()).not.toBe(first);
});
it('restarts the payload identity when zapping to another movie', async () => {
await create({ channel: channel(), playback: playback() });
const first = fixture.componentInstance.inlinePlayback();
@@ -9,8 +9,7 @@ import {
untracked,
ChangeDetectionStrategy,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import type { PlaybackFallbackRequest } from '@iptvnator/playback/util';
import {
enrichedCast,
@@ -24,13 +23,22 @@ import {
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import {
CastCrewRowComponent,
DetailActionButtonComponent,
DetailActionsTemplateDirective,
DetailCreditsComponent,
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
MetaChipComponent,
PortalDetailShellComponent,
castMembersFromNames,
} from '@iptvnator/ui/components';
import { formatDurationLabel } from '@iptvnator/portal/shared/util';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { M3uVodMetadataService } from './m3u-vod-metadata.service';
import {
M3uVodMetadataService,
m3uVodEntryKey,
} from './m3u-vod-metadata.service';
/**
* VOD detail experience for an M3U entry recognized as a movie: the same
@@ -51,10 +59,13 @@ import { M3uVodMetadataService } from './m3u-vod-metadata.service';
selector: 'app-m3u-vod-detail',
providers: [M3uVodMetadataService],
imports: [
CastCrewRowComponent,
DetailActionButtonComponent,
DetailActionsTemplateDirective,
DetailCreditsComponent,
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
MatIcon,
MetaChipComponent,
PortalDetailShellComponent,
PortalInlinePlayerComponent,
TranslatePipe,
@@ -65,6 +76,7 @@ import { M3uVodMetadataService } from './m3u-vod-metadata.service';
})
export class M3uVodDetailComponent {
private readonly metadata = inject(M3uVodMetadataService);
private readonly translate = inject(TranslateService);
readonly channel = input.required<Channel>();
/** Parent-owned playback payload (headers/DRM already resolved). */
@@ -106,6 +118,13 @@ export class M3uVodDetailComponent {
readonly tmdb = computed(() => this.metadata.state().details);
/**
* What the hero decides its layout for, once per entry. Keyed like the
* TMDB lookup, by id AND name: entries sharing an id differ by name, and
* the next one may have a backdrop where this one had none.
*/
readonly heroKey = computed(() => `m3u:${m3uVodEntryKey(this.channel())}`);
readonly title = computed(
() => this.tmdb()?.title?.trim() || this.channel().name
);
@@ -130,14 +149,12 @@ export class M3uVodDetailComponent {
.join(', ')
);
readonly runtimeLabel = computed(() => {
const runtime = this.tmdb()?.runtime;
if (!runtime || runtime <= 0) {
return '';
}
const hours = Math.floor(runtime / 60);
const minutes = runtime % 60;
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
const label = formatDurationLabel((this.tmdb()?.runtime ?? 0) * 60);
return label ? this.translate.instant(label.key, label.params) : '';
});
readonly kindLabel = computed(() =>
this.translate.instant('WORKSPACE.DASHBOARD.TYPE_MOVIE')
);
readonly rating = computed(() => {
const details = this.tmdb();
const average = details?.vote_average ?? 0;
@@ -150,12 +167,23 @@ export class M3uVodDetailComponent {
const credits = this.tmdb()?.credits;
return credits ? enrichedCast(topCast(credits)) : [];
});
readonly directors = computed(() =>
readonly directorNames = computed(() =>
(this.tmdb()?.credits?.crew ?? [])
.filter((member) => member.job === 'Director')
.map((member) => member.name)
.join(', ')
);
readonly directorMembers = computed(() =>
castMembersFromNames(this.directorNames())
);
readonly castNames = computed(() =>
this.cast().map((member) => member.name)
);
scrollToCast(): void {
document
.getElementById('detail-cast-crew')
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/**
* The playback payload handed to the player. Its OBJECT IDENTITY is the
@@ -23,8 +23,12 @@ const IDLE_STATE: M3uVodMetadataState = {
details: null,
};
/** NUL keeps the two parts unambiguous whatever an entry is named. */
function lookupKeyOf(channel: Pick<Channel, 'id' | 'name'>): string {
/**
* The identity of an M3U entry: entries can share an id and differ by
* name, so both parts count. NUL keeps them unambiguous whatever an entry
* is named.
*/
export function m3uVodEntryKey(channel: Pick<Channel, 'id' | 'name'>): string {
return `${channel.id}\u0000${channel.name ?? ''}`;
}
@@ -50,7 +54,7 @@ export class M3uVodMetadataService {
* only the response for the channel the state currently tracks lands.
*/
load(channel: Pick<Channel, 'id' | 'name'>): void {
const lookupKey = lookupKeyOf(channel);
const lookupKey = m3uVodEntryKey(channel);
if (this.stateSignal().lookupKey === lookupKey) {
return;
}
@@ -1,5 +1,6 @@
@if (detail(); as offlineDetail) {
<app-portal-detail-shell
[contentKey]="heroKey()"
[title]="title()"
[description]="description()"
[posterUrl]="posterUrl()"
@@ -8,111 +9,41 @@
(backClicked)="goBack()"
>
<ng-template appDetailTags>
<span class="details__tag offline-detail__available">
<app-meta-chip class="offline-detail__available">
<mat-icon aria-hidden="true">download_done</mat-icon>
{{ 'DOWNLOADS.OFFLINE_DETAIL.AVAILABLE' | translate }}
</span>
</app-meta-chip>
@if (metadata()?.year; as year) {
@if (positiveFinite(year)) {
<span class="details__tag">{{ year }}</span>
<app-meta-chip>{{ year }}</app-meta-chip>
}
}
@if (durationLabel(); as duration) {
<span class="details__tag">{{ duration }}</span>
<app-meta-chip>{{ duration }}</app-meta-chip>
}
@for (genre of genres(); track genre) {
<span class="details__tag">{{ genre }}</span>
<app-meta-chip>{{ genre }}</app-meta-chip>
}
@if (metadata()?.rating; as rating) {
@if (positiveFinite(rating)) {
<span class="details__tag details__tag--rating">
<app-meta-chip variant="rating">
<span aria-hidden="true">★</span> {{ rating }}
</span>
</app-meta-chip>
}
}
@if (metadata()?.status; as status) {
@if (status.trim()) {
<span class="details__tag details__tag--status">
{{ status }}
</span>
<app-meta-chip variant="status">{{ status }}</app-meta-chip>
}
}
</ng-template>
<ng-template appDetailMeta>
@if (cast().length) {
<div class="details__meta-item offline-detail__people-group">
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
<div class="details__cast-strip">
@for (person of cast(); track personTrackKey(person)) {
<span
class="details__cast-chip offline-detail__person"
[title]="
person.role
? person.name + ' — ' + person.role
: person.name
"
>
@if (person.profileUrl) {
<img
class="details__cast-avatar"
[src]="person.profileUrl"
[alt]="person.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
aria-hidden="true"
>
{{ person.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
person.name
}}</span>
</span>
}
</div>
</div>
}
@if (creators().length) {
<div class="details__meta-item offline-detail__people-group">
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
<div class="details__cast-strip">
@for (
person of creators();
track personTrackKey(person)
) {
<span
class="details__cast-chip offline-detail__person"
[title]="person.name"
>
@if (person.profileUrl) {
<img
class="details__cast-avatar"
[src]="person.profileUrl"
[alt]="person.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
aria-hidden="true"
>
{{ person.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
person.name
}}</span>
</span>
}
</div>
</div>
}
<app-detail-credits
[cast]="castNames()"
[directors]="creatorNames()"
(moreRequested)="scrollToCast()"
/>
@if (movieFileSize(); as size) {
<div class="details__meta-item">
<span class="value">
@@ -455,6 +386,14 @@
}
</section>
}
<div detail-extras>
<!-- No actor page offline: the people stay plain entries -->
<app-cast-crew-row
[cast]="castMembers()"
[directors]="creatorMembers()"
[interactive]="false"
/>
</div>
</app-portal-detail-shell>
} @else {
<app-portal-detail-shell
@@ -22,16 +22,6 @@ button,
}
}
.offline-detail__people-group {
min-width: 0;
max-width: 100%;
}
.offline-detail__person {
cursor: default;
user-select: text;
}
.offline-detail__portal-action {
display: inline-flex;
}
@@ -317,6 +317,12 @@ describe('DownloadOfflineDetailComponent', () => {
expect(text()).toContain('Released');
expect(text()).toContain('Ada Actor');
expect(text()).toContain('Cora Creator');
// No actor page offline: the shared cast row renders plain entries.
const person = (fixture.nativeElement as HTMLElement).querySelector(
'app-cast-crew-row .person'
);
expect(person).not.toBeNull();
expect(person?.getAttribute('role')).toBeNull();
expect(text()).toContain('2 KB');
expect(text()).not.toContain('{{size}}');
expect(downloads.formatBytes).toHaveBeenCalledWith(2_048);
@@ -12,10 +12,14 @@ import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { DownloadsService, SettingsStore } from '@iptvnator/services';
import {
CastCrewRowComponent,
DetailActionsTemplateDirective,
DetailCreditsComponent,
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
MetaChipComponent,
PortalDetailShellComponent,
scrollToCastCrewRow,
} from '@iptvnator/ui/components';
import { DownloadLibraryNavigationService } from '../download-library-navigation.service';
import { DownloadManagerActionsService } from '../download-manager-actions.service';
@@ -32,7 +36,7 @@ import {
offlineEpisodeTitle,
offlineFileByteCount,
offlineMetadataResolutionKey,
offlinePersonTrackKey,
offlinePeopleAsCast,
offlinePositiveFinite,
offlineSeasonTestId,
type OfflineDetailItem,
@@ -47,9 +51,12 @@ import { DownloadOfflineSeasonSelectionService } from './download-offline-season
@Component({
selector: 'app-download-offline-detail',
imports: [
CastCrewRowComponent,
DetailActionsTemplateDirective,
DetailCreditsComponent,
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
MetaChipComponent,
MatButtonModule,
MatIconModule,
MatMenuModule,
@@ -156,6 +163,21 @@ export class DownloadOfflineDetailComponent {
readonly creators = computed(() =>
boundedOfflinePeople(this.metadata()?.creators)
);
readonly castMembers = computed(() => offlinePeopleAsCast(this.cast()));
readonly creatorMembers = computed(() =>
offlinePeopleAsCast(this.creators())
);
readonly castNames = computed(() =>
this.cast().map((person) => person.name)
);
readonly creatorNames = computed(() =>
this.creators().map((person) => person.name)
);
/** The hero decides its layout once per download, not per title: two downloads can share one. */
readonly heroKey = computed(() => {
const downloadId = this.downloadId();
return downloadId === undefined ? null : `download:${downloadId}`;
});
readonly providerState = this.providerCoordinator.state;
readonly canOpenInPortal = computed(
() => this.providerState().status === 'available'
@@ -208,7 +230,7 @@ export class DownloadOfflineDetailComponent {
readonly episodeCoordinate = offlineEpisodeCoordinate;
readonly episodeTitle = offlineEpisodeTitle;
readonly positiveFinite = offlinePositiveFinite;
readonly personTrackKey = offlinePersonTrackKey;
readonly scrollToCast = scrollToCastCrewRow;
constructor() {
void this.downloadsService.loadDownloads();
@@ -1,9 +1,36 @@
import {
boundedOfflinePeople,
offlinePeopleAsCast,
offlinePersonTrackKey,
} from './download-offline-detail.presentation';
describe('offline detail people presentation', () => {
it('hands people to the shared cast row with the role as the character', () => {
expect(
offlinePeopleAsCast([
{
name: 'Ada Actor',
role: 'Mara',
profileUrl: 'https://img/ada.jpg',
},
{ name: 'Cora Creator', tmdbPersonId: 7 },
])
).toEqual([
{
name: 'Ada Actor',
character: 'Mara',
profileUrl: 'https://img/ada.jpg',
tmdbPersonId: undefined,
},
{
name: 'Cora Creator',
character: undefined,
profileUrl: null,
tmdbPersonId: 7,
},
]);
});
it('deduplicates people and supplies unique stable composite track keys', () => {
const people = boundedOfflinePeople([
{ tmdbPersonId: 7, name: 'Ada Actor', role: 'Lead' },
@@ -2,6 +2,7 @@ import type { DownloadItem } from '@iptvnator/services';
import type {
DownloadMetadataPerson,
DownloadMetadataSnapshot,
TmdbEnrichedCastMember,
} from '@iptvnator/shared/interfaces';
import type {
DeepReadonly,
@@ -144,6 +145,18 @@ export function boundedOfflinePeople(
return people;
}
/** The shared cast row's shape: the role becomes the character line. */
export function offlinePeopleAsCast(
people: readonly DownloadMetadataPerson[]
): TmdbEnrichedCastMember[] {
return people.map((person) => ({
name: person.name,
character: person.role,
profileUrl: person.profileUrl ?? null,
tmdbPersonId: person.tmdbPersonId,
}));
}
export function offlinePersonTrackKey(person: DownloadMetadataPerson): string {
return [person.tmdbPersonId ?? 'local', offlinePersonNameKey(person)].join(
':'
@@ -1,5 +1,6 @@
@if (item(); as recording) {
<app-portal-detail-shell
[contentKey]="'recording:' + recordingId()"
[title]="title()"
[description]="recording.programDescription ?? ''"
[posterUrl]="recording.channelLogoUrl ?? ''"
@@ -36,28 +37,30 @@
[disabled]="isPending()"
(click)="runAction('remove')"
>
<mat-icon aria-hidden="true">remove_circle_outline</mat-icon>
<mat-icon aria-hidden="true"
>remove_circle_outline</mat-icon
>
{{ 'DOWNLOADS.REMOVE_FROM_MANAGER' | translate }}
</button>
</div>
} @else {
<ng-template appDetailTags>
<span class="details__tag recording-detail__badge">
<app-meta-chip class="recording-detail__badge">
<mat-icon aria-hidden="true">fiber_manual_record</mat-icon>
{{ 'DOWNLOADS.RECORDING_BADGE' | translate }}
</span>
<span class="details__tag">{{ recording.channelName }}</span>
</app-meta-chip>
<app-meta-chip>{{ recording.channelName }}</app-meta-chip>
@if (durationLabel()) {
<span class="details__tag">{{ durationLabel() }}</span>
<app-meta-chip>{{ durationLabel() }}</app-meta-chip>
}
@if (sizeLabel()) {
<span class="details__tag">{{ sizeLabel() }}</span>
<app-meta-chip>{{ sizeLabel() }}</app-meta-chip>
}
<span class="details__tag">MPEG-TS</span>
<app-meta-chip>MPEG-TS</app-meta-chip>
@if (recording.status === 'interrupted') {
<span class="details__tag details__tag--status">
<app-meta-chip variant="status">
{{ 'DOWNLOADS.STATUS.INTERRUPTED' | translate }}
</span>
</app-meta-chip>
}
</ng-template>
@@ -129,9 +132,7 @@
type="button"
data-testid="recording-overflow"
[matMenuTriggerFor]="detailMenu"
[attr.aria-label]="
'DOWNLOADS.MORE_ACTIONS' | translate
"
[attr.aria-label]="'DOWNLOADS.MORE_ACTIONS' | translate"
>
<mat-icon>more_vert</mat-icon>
</button>
@@ -167,17 +168,22 @@
@for (program of coveredPrograms(); track program.start) {
<li class="recording-detail__program">
<time>
{{ programDisplayMs(program.start) | date: 'HH:mm' }} –
{{ programDisplayMs(program.stop) | date: 'HH:mm' }}
{{
programDisplayMs(program.start)
| date: 'HH:mm'
}}
–
{{
programDisplayMs(program.stop)
| date: 'HH:mm'
}}
</time>
<div>
<div class="recording-detail__program-title">
{{ program.title }}
</div>
@if (program.description) {
<div
class="recording-detail__program-desc"
>
<div class="recording-detail__program-desc">
{{ program.description }}
</div>
}
@@ -4,9 +4,6 @@
}
.recording-detail__badge {
display: inline-flex;
align-items: center;
gap: 4px;
background: color-mix(in srgb, var(--mat-sys-error) 18%, transparent);
color: var(--mat-sys-error);
font-weight: 700;
@@ -18,6 +18,7 @@ import {
DetailActionsTemplateDirective,
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
MetaChipComponent,
PortalDetailShellComponent,
} from '@iptvnator/ui/components';
import { map } from 'rxjs';
@@ -52,6 +53,7 @@ import {
MatButtonModule,
MatIcon,
MatMenuModule,
MetaChipComponent,
PortalDetailShellComponent,
TranslatePipe,
],
+4
View File
@@ -38,9 +38,13 @@ export * from './lib/navigation/workspace-portal-navigation';
export * from './lib/collection';
export * from './lib/xtream-catchup.util';
export * from './lib/external-playback-button-state';
export * from './lib/replace-owned-external-session';
export * from './lib/inline-playback-position-writer';
export * from './lib/downloads/download-metadata-snapshot';
export * from './lib/downloads/episode-download-identity';
export * from './lib/portal-watch-state';
export * from './lib/vod-watched-toggle';
export * from './lib/pending-playback-start';
export * from './lib/detail/season-title';
export * from './lib/detail/playback-progress';
export * from './lib/detail/country-short-name';
@@ -0,0 +1,44 @@
import { shortCountryList, shortCountryName } from './country-short-name';
describe('shortCountryName', () => {
it('shortens by ISO code first', () => {
expect(shortCountryName('United States of America', 'US')).toBe('USA');
expect(shortCountryName('United Kingdom', 'gb')).toBe('UK');
});
it('shortens known full names without a code', () => {
expect(shortCountryName('United States')).toBe('USA');
expect(shortCountryName('Russian Federation')).toBe('Russia');
expect(shortCountryName('Korea, Republic of')).toBe('South Korea');
});
it('keeps unknown names and falls back to the code', () => {
expect(shortCountryName('Fictionland')).toBe('Fictionland');
expect(shortCountryName('', 'xx')).toBe('XX');
});
it('keeps a comma-bearing country name whole inside a list', () => {
expect(shortCountryList('Korea, Republic of, Japan')).toEqual([
'South Korea',
'Japan',
]);
expect(shortCountryList('France, Taiwan, Province of China')).toEqual([
'France',
'Taiwan',
]);
expect(
shortCountryList(
'Iran, Islamic Republic of, Moldova, Republic of; Bolivia, Plurinational State of'
)
).toEqual(['Iran', 'Moldova', 'Bolivia']);
});
it('splits provider lists', () => {
expect(shortCountryList('United States, Canada / France')).toEqual([
'USA',
'Canada',
'France',
]);
expect(shortCountryList(undefined)).toEqual([]);
});
});
@@ -0,0 +1,124 @@
/** Short display names for the countries providers spell out in full. */
const SHORT_BY_CODE: Record<string, string> = {
US: 'USA',
GB: 'UK',
AE: 'UAE',
RU: 'Russia',
KR: 'South Korea',
KP: 'North Korea',
CZ: 'Czechia',
DE: 'Germany',
FR: 'France',
IR: 'Iran',
SY: 'Syria',
VN: 'Vietnam',
TW: 'Taiwan',
BO: 'Bolivia',
VE: 'Venezuela',
TZ: 'Tanzania',
LA: 'Laos',
MD: 'Moldova',
MK: 'North Macedonia',
CD: 'DR Congo',
CG: 'Congo',
FM: 'Micronesia',
BN: 'Brunei',
VA: 'Vatican',
};
const SHORT_BY_NAME: Record<string, string> = {
'united states of america': 'USA',
'united states': 'USA',
usa: 'USA',
'united kingdom': 'UK',
'great britain': 'UK',
'united arab emirates': 'UAE',
'russian federation': 'Russia',
россия: 'Россия',
сша: 'США',
'republic of korea': 'South Korea',
'korea, republic of': 'South Korea',
"korea, democratic people's republic of": 'North Korea',
'korea, democratic people’s republic of': 'North Korea',
'iran, islamic republic of': 'Iran',
'bolivia, plurinational state of': 'Bolivia',
'venezuela, bolivarian republic of': 'Venezuela',
'tanzania, united republic of': 'Tanzania',
'moldova, republic of': 'Moldova',
'micronesia, federated states of': 'Micronesia',
'congo, democratic republic of the': 'DR Congo',
'congo, the democratic republic of the': 'DR Congo',
'macedonia, the former yugoslav republic of': 'North Macedonia',
'palestine, state of': 'Palestine',
'virgin islands, british': 'British Virgin Islands',
'virgin islands, u.s.': 'US Virgin Islands',
'bonaire, sint eustatius and saba': 'Bonaire',
'saint helena, ascension and tristan da cunha': 'Saint Helena',
'gambia, the': 'Gambia',
'bahamas, the': 'Bahamas',
'czech republic': 'Czechia',
'islamic republic of iran': 'Iran',
'syrian arab republic': 'Syria',
'viet nam': 'Vietnam',
'taiwan, province of china': 'Taiwan',
'lao people’s democratic republic': 'Laos',
"lao people's democratic republic": 'Laos',
'republic of moldova': 'Moldova',
'the netherlands': 'Netherlands',
'federal republic of germany': 'Germany',
};
/**
* "United States of America" → "USA", "GB" → "UK". Unknown names come back
* trimmed, so the chip never loses a country it cannot shorten.
*/
export function shortCountryName(
name: string | null | undefined,
code?: string | null
): string {
const trimmed = (name ?? '').trim();
const byCode = code ? SHORT_BY_CODE[code.trim().toUpperCase()] : undefined;
if (byCode) {
return byCode;
}
const byName = SHORT_BY_NAME[trimmed.toLowerCase()];
if (byName) {
return byName;
}
return trimmed || (code ?? '').trim().toUpperCase();
}
/** Canonical names that carry a comma themselves ("Korea, Republic of"). */
const COMMA_NAMES = new Set(
Object.keys(SHORT_BY_NAME).filter((name) => name.includes(','))
);
/** Every country of a provider list, shortened. */
export function shortCountryList(value: string | null | undefined): string[] {
const parts: string[] = [];
for (const group of (value ?? '').split(/[;/]/)) {
parts.push(...splitCommaList(group));
}
return parts
.map((part) => shortCountryName(part))
.filter((part) => part.length > 0);
}
/** Splits on commas but keeps a known comma-bearing country name whole. */
function splitCommaList(value: string): string[] {
const segments = value
.split(',')
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0);
const parts: string[] = [];
for (let index = 0; index < segments.length; index++) {
const joined = `${segments[index]}, ${segments[index + 1] ?? ''}`;
if (COMMA_NAMES.has(joined.toLowerCase())) {
parts.push(joined);
index++;
} else {
parts.push(segments[index]);
}
}
return parts;
}
@@ -0,0 +1,114 @@
import {
formatDurationLabel,
formatRemainingLabel,
parseDurationSeconds,
playbackProgressPercent,
} from './playback-progress';
import { inlineProgressPosition } from './playback-progress';
describe('inlineProgressPosition', () => {
it('writes floored seconds onto the content identity', () => {
expect(
inlineProgressPosition(
{
playlistId: 'p1',
contentXtreamId: 7,
contentType: 'episode',
},
{ currentTime: 61.9, duration: 1800.2 }
)
).toEqual({
playlistId: 'p1',
contentXtreamId: 7,
contentType: 'episode',
positionSeconds: 61,
durationSeconds: 1800,
});
});
});
describe('playbackProgressPercent', () => {
it('floors the watched share to an integer percent', () => {
expect(
playbackProgressPercent({
positionSeconds: 924,
durationSeconds: 1000,
})
).toBe(92);
});
it('returns null without a duration', () => {
expect(playbackProgressPercent({ positionSeconds: 10 })).toBeNull();
expect(playbackProgressPercent(null)).toBeNull();
});
});
describe('formatRemainingLabel', () => {
it('picks seconds, minutes, hours and hours+minutes', () => {
expect(
formatRemainingLabel({ positionSeconds: 70, durationSeconds: 100 })
).toEqual({
key: 'WORKSPACE.DASHBOARD.REMAINING_SECONDS',
params: { seconds: 30 },
});
expect(
formatRemainingLabel({
positionSeconds: 600,
durationSeconds: 1920,
})
).toEqual({
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
params: { minutes: 22 },
});
expect(
formatRemainingLabel({ positionSeconds: 0, durationSeconds: 7200 })
).toEqual({
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS',
params: { hours: 2 },
});
expect(
formatRemainingLabel({ positionSeconds: 60, durationSeconds: 3960 })
).toEqual({
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS_MINUTES',
params: { hours: 1, minutes: 5 },
});
});
it('returns null without a duration', () => {
expect(formatRemainingLabel({ positionSeconds: 5 })).toBeNull();
});
});
describe('formatDurationLabel', () => {
it('formats minutes and hours', () => {
expect(formatDurationLabel(48 * 60)).toEqual({
key: 'PORTALS.DETAIL.DURATION_MINUTES',
params: { minutes: 48 },
});
expect(formatDurationLabel(6720)).toEqual({
key: 'PORTALS.DETAIL.DURATION_HOURS_MINUTES',
params: { hours: 1, minutes: 52 },
});
});
it('returns null for unknown durations', () => {
expect(formatDurationLabel(0)).toBeNull();
expect(formatDurationLabel(undefined)).toBeNull();
});
});
describe('parseDurationSeconds', () => {
it.each([
['45 min', 2700],
['1h 30min', 5400],
['01:52:10', 6730],
['52:10', 3130],
['90', 90],
[120, 120],
['', 0],
[undefined, 0],
])('parses %p', (input, expected) => {
expect(parseDurationSeconds(input)).toBe(expected);
});
});
@@ -0,0 +1,137 @@
import type {
PlaybackPositionData,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
export interface RemainingTimeLabel {
readonly key: string;
readonly params: Record<string, number>;
}
interface ProgressPosition {
positionSeconds: number;
durationSeconds?: number;
}
/** 0–100 watched share, or null when the duration is unknown. */
/** The position row an inline player's time update writes for its content. */
export function inlineProgressPosition(
contentInfo: PlayerContentInfo,
event: { currentTime: number; duration: number }
): PlaybackPositionData {
return {
...contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
}
export function playbackProgressPercent(
position: ProgressPosition | null | undefined
): number | null {
if (
!position ||
position.durationSeconds == null ||
position.durationSeconds <= 0
) {
return null;
}
const ratio = position.positionSeconds / position.durationSeconds;
if (!Number.isFinite(ratio)) {
return null;
}
// Integer percent keeps "92% watched" out of "92.4% watched" territory,
// and matches the resolution of a 3px-tall progress bar.
return Math.max(0, Math.min(100, Math.floor(ratio * 100)));
}
/** "22m left" / "1h 5m left" as an i18n key with parameters. */
export function formatRemainingLabel(
position: ProgressPosition | null | undefined
): RemainingTimeLabel | null {
if (
!position ||
position.durationSeconds == null ||
position.durationSeconds <= 0
) {
return null;
}
const remaining = Math.max(
0,
Math.round(position.durationSeconds - position.positionSeconds)
);
if (remaining < 60) {
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_SECONDS',
params: { seconds: remaining },
};
}
const totalMinutes = Math.round(remaining / 60);
if (totalMinutes < 60) {
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
params: { minutes: totalMinutes },
};
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (minutes === 0) {
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS',
params: { hours },
};
}
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS_MINUTES',
params: { hours, minutes },
};
}
/** "1 h 52 min" / "48 min" as an i18n key with parameters. */
export function formatDurationLabel(
totalSeconds: number | null | undefined
): RemainingTimeLabel | null {
if (!totalSeconds || !Number.isFinite(totalSeconds) || totalSeconds <= 0) {
return null;
}
const totalMinutes = Math.max(1, Math.round(totalSeconds / 60));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours === 0) {
return { key: 'PORTALS.DETAIL.DURATION_MINUTES', params: { minutes } };
}
return {
key: 'PORTALS.DETAIL.DURATION_HOURS_MINUTES',
params: { hours, minutes },
};
}
/**
* Seconds from the duration strings providers send: "45 min", "1h 30min",
* "01:52:10", "52:10", a bare number of seconds.
*/
export function parseDurationSeconds(
duration: string | number | null | undefined
): number {
if (!duration) {
return 0;
}
if (typeof duration === 'number') {
return duration;
}
// "45 min" (Stalker VOD) and "1h 30min" (Xtream): keep the hour part.
const minutesMatch = duration.match(/(?:(\d+)\s*h\w*)?\s*(\d+)\s*min/);
if (minutesMatch) {
return (
parseInt(minutesMatch[1] ?? '0', 10) * 3600 +
parseInt(minutesMatch[2], 10) * 60
);
}
const parts = duration.split(':').map(Number);
if (parts.length === 3 && parts.every(Number.isFinite)) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
if (parts.length === 2 && parts.every(Number.isFinite)) {
return parts[0] * 60 + parts[1];
}
return Number(duration) || 0;
}
@@ -0,0 +1,40 @@
import { splitSeasonSuffix } from './season-title';
describe('splitSeasonSuffix', () => {
it.each([
['Большая фарма (1 сезон)', 'Большая фарма', 1],
['Родители родителей 2 сезон', 'Родители родителей', 2],
['Родители родителей (сезон 3)', 'Родители родителей', 3],
['Big Pharma Season 2', 'Big Pharma', 2],
['Big Pharma - Season 12', 'Big Pharma', 12],
['Big Pharma [Season 4]', 'Big Pharma', 4],
['Big Pharma S02', 'Big Pharma', 2],
['La casa de papel: Temporada 5', 'La casa de papel', 5],
['Dark Staffel 3', 'Dark', 3],
])('strips %s', (raw, title, season) => {
expect(splitSeasonSuffix(raw)).toEqual({ title, season });
});
it.each([
'S.W.A.T.',
'Area 51',
'Season of the Witch',
'Fast 9',
'24',
'The 100',
])('leaves %s alone', (raw) => {
expect(splitSeasonSuffix(raw)).toEqual({ title: raw, season: null });
});
it('keeps a title that is only a season marker', () => {
expect(splitSeasonSuffix('Season 2')).toEqual({
title: 'Season 2',
season: null,
});
});
it('trims and tolerates empty input', () => {
expect(splitSeasonSuffix(' ')).toEqual({ title: '', season: null });
expect(splitSeasonSuffix(undefined)).toEqual({ title: '', season: null });
});
});
@@ -0,0 +1,29 @@
export interface SeasonTitleParts {
/** The provider title without its season marker. */
readonly title: string;
readonly season: number | null;
}
// "(1 сезон)", "Сезон 2", "Season 3", "S04", "- Season 2", "[Season 2]".
// The marker must close the title: a leading season word stays part of it.
const SEASON_SUFFIX =
/[\s\-–—:·|,]*[([]?\s*(?:(\d{1,2})\s*(?:-?й\s*)?сезон|сезон\s*(\d{1,2})|season\s*(\d{1,2})|temporada\s*(\d{1,2})|staffel\s*(\d{1,2})|saison\s*(\d{1,2})|S(\d{2}))\s*[)\]]?\s*$/i;
/**
* Strips a trailing season marker from a provider series title so the hero
* shows "Большая фарма" with a "Season 1" chip instead of "Большая фарма
* (1 сезон)". Titles without a marker are returned unchanged.
*/
export function splitSeasonSuffix(rawTitle: string | null | undefined): SeasonTitleParts {
const title = (rawTitle ?? '').trim();
const match = title.match(SEASON_SUFFIX);
if (!match || match.index === undefined || match.index === 0) {
return { title, season: null };
}
const season = Number(match.slice(1).find((group) => group !== undefined));
const stripped = title.slice(0, match.index).trim();
if (!stripped || !Number.isInteger(season) || season <= 0) {
return { title, season: null };
}
return { title: stripped, season };
}
@@ -16,6 +16,23 @@ describe('createPendingPlaybackStart', () => {
expect(pendingForA()).toBe(false);
});
it('forgets a start whose owner the page left, so a return is not held by it', () => {
const tracker = createPendingPlaybackStart<string>();
const first = tracker.begin('a');
// Back out of the movie while its request still resolves: whatever
// it settles with no longer applies, so reopening it starts afresh.
tracker.retire('a');
expect(tracker.isPendingFor('a')).toBe(false);
// The retired start settling late must not disturb a newer one.
const newer = tracker.begin('a');
tracker.settle(first);
expect(tracker.isPendingFor('a')).toBe(true);
tracker.settle(newer);
expect(tracker.isPendingFor('a')).toBe(false);
});
it('lets only the latest start clear the flag', () => {
const tracker = createPendingPlaybackStart<string>();
const older = tracker.begin('a');
@@ -17,6 +17,12 @@ export interface PendingPlaybackStart<TOwner> {
settle(startId: number): void;
/** Reactive: whether the latest start belongs to this owner. */
isPendingFor(owner: TOwner): boolean;
/**
* Forgets the start of `owner` once the page left it: whatever it still
* settles with no longer applies, and a return to the same owner must
* not find its actions held by a request that may never settle.
*/
retire(owner: TOwner): void;
}
export function createPendingPlaybackStart<
@@ -40,5 +46,10 @@ export function createPendingPlaybackStart<
const current = pending();
return current !== null && current.owner === owner;
},
retire(owner) {
if (pending()?.owner === owner) {
pending.set(null);
}
},
};
}
@@ -0,0 +1,62 @@
import { signal } from '@angular/core';
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import { replaceOwnedExternalSession } from './replace-owned-external-session';
function session(
overrides: Partial<ExternalPlayerSession> = {}
): ExternalPlayerSession {
return {
id: 'mpv-1',
player: 'mpv',
status: 'opened',
canClose: true,
contentInfo: {
playlistId: 'portal-1',
contentXtreamId: 42,
contentType: 'vod',
},
...overrides,
} as ExternalPlayerSession;
}
describe('replaceOwnedExternalSession', () => {
function playback(active: ExternalPlayerSession | null) {
return {
activeSession: signal(active),
closeSession: jest.fn().mockResolvedValue(undefined),
};
}
it('closes the owned session and reports the replacement as safe', async () => {
const running = session();
const external = playback(running);
await expect(
replaceOwnedExternalSession(
external,
(info) => info.contentXtreamId === 42
)
).resolves.toBe(true);
expect(external.closeSession).toHaveBeenCalledWith(running);
});
it('leaves a session the page does not own alone', async () => {
const external = playback(session());
await expect(
replaceOwnedExternalSession(
external,
(info) => info.contentXtreamId === 7
)
).resolves.toBe(true);
expect(external.closeSession).not.toHaveBeenCalled();
});
it('cancels the replacement when the close fails', async () => {
const external = playback(session());
external.closeSession.mockRejectedValue(new Error('busy'));
const warn = jest.fn();
await expect(
replaceOwnedExternalSession(external, () => true, warn)
).resolves.toBe(false);
expect(warn).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,46 @@
import type {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { isLiveExternalPlayerSession } from './external-playback-button-state';
import { createLogger } from './logger';
import type { PortalExternalPlayback } from './portal-external-playback';
const logger = createLogger('ExternalPlayback');
/**
* Closes the external session a page owns before it launches a replacement
* (with instance reuse off a second detached player would start beside it).
* `owns` names the sessions the page may replace; anything else is left
* alone. False when teardown could not be confirmed: the caller keeps the
* running player and cancels the launch.
*/
export async function replaceOwnedExternalSession(
externalPlayback: Pick<
PortalExternalPlayback,
'activeSession' | 'closeSession'
>,
owns: (info: PlayerContentInfo) => boolean,
warn: (message: string, error: unknown) => void = (message, error) =>
logger.warn(message, error)
): Promise<boolean> {
const session: ExternalPlayerSession | null =
externalPlayback.activeSession();
const info = session?.contentInfo;
if (!session || !info || session.status === 'closed' || !owns(info)) {
return true;
}
if (isLiveExternalPlayerSession(session) && !session.canClose) {
return false;
}
try {
await externalPlayback.closeSession(session);
return true;
} catch (error) {
warn(
'Closing the previous external player failed; cancelling the replacement.',
error
);
return false;
}
}
@@ -12,6 +12,8 @@
[item]="vodDetailsItem()!"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
[playbackDurationSeconds]="selectedVodPlaybackDuration()"
[sourceLabel]="sourceLabel()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
[providerOnly]="providerOnly()"
@@ -20,17 +22,19 @@
[watchedToggleReady]="positionLoaded()"
[playbackStartPending]="playbackStartPending()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(resumeClicked)="onVodPlay($event.item, $event.positionSeconds)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(watchedToggled)="onVodWatchedToggled($event)"
(downloadRequested)="onVodDownload($event)"
(downloadRequested)="vodDetailActions.download($event)"
(backClicked)="onVodBack()"
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
(inlinePlaybackClosed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
(streamUrlCopied)="vodDetailActions.notifyCopied()"
(inlineExternalFallbackRequested)="
handleExternalFallbackRequest($event)
vodDetailActions.handleExternalFallback($event)
"
(externalPlayRequested)="vodDetailActions.openExternal($event)"
(resetProgressRequested)="vodDetailActions.resetProgress($event)"
/>
}
} @else if (contentType() === 'series') {
@@ -50,6 +50,8 @@ class StubVodDetailsComponent {
readonly providerOnly = input(false);
readonly isFavorite = input(false);
readonly playbackPosition = input<number | null>(null);
readonly playbackDurationSeconds = input<number | null>(null);
readonly sourceLabel = input<string | null>(null);
readonly inlinePlayback = input<unknown>(null);
readonly externalPlayback = input<unknown>(null);
readonly isWatched = input(false);
@@ -64,6 +66,8 @@ class StubVodDetailsComponent {
readonly backClicked = output<void>();
readonly inlineTimeUpdated = output<unknown>();
readonly inlinePlaybackClosed = output<void>();
readonly externalPlayRequested = output<unknown>();
readonly resetProgressRequested = output<unknown>();
readonly streamUrlCopied = output<void>();
readonly inlineExternalFallbackRequested = output<unknown>();
}
@@ -376,6 +380,41 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
expect(fixture.componentInstance.playbackStartPending()).toBe(false);
});
it('does not hold a reopened movie with a start its earlier visit left pending', async () => {
const original = selectedItem();
let resolve: (value: unknown) => void = () => undefined;
resolveVodPlayback.mockReturnValueOnce(
new Promise((resolvePromise) => {
resolve = resolvePromise;
})
);
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.onVodPlay({
type: 'stalker',
cmd: '/media/42',
data: original,
} as never);
fixture.detectChanges();
expect(fixture.componentInstance.playbackStartPending()).toBe(true);
// Back out to another movie while the portal request hangs, then
// reopen the first one: its Play must not wait for that request.
selectedItem.set({
id: '99',
cmd: '/media/99',
info: { name: 'Replacement movie' },
});
fixture.detectChanges();
selectedItem.set(original);
fixture.detectChanges();
expect(fixture.componentInstance.playbackStartPending()).toBe(false);
resolve({ streamUrl: 'https://stale.example/movie.mpg' });
await fixture.whenStable();
expect(fixture.componentInstance.playbackStartPending()).toBe(false);
});
it('does not mount a VOD resolution after the catalog owner changes', async () => {
let resolve!: (value: { streamUrl: string }) => void;
resolveVodPlayback.mockReturnValueOnce(
@@ -31,27 +31,28 @@ import {
StalkerSelectedVodItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import {
type PlaybackFallbackRequest,
VodDetailsComponent,
} from '@iptvnator/ui/playback';
import { VodDetailsComponent } from '@iptvnator/ui/playback';
import {
DownloadsService,
PlaybackPositionRuntimeBridgeService,
PlaylistsService,
} from '@iptvnator/services';
import {
createStalkerVodItem,
PlaybackPositionData,
ResolvedPortalPlayback,
StalkerVodDetails,
VodDetailsItem,
createStalkerVodItem,
} from '@iptvnator/shared/interfaces';
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { startStalkerVodDownload } from './stalker-vod-download';
import { createStalkerVodWatchedToggle } from '../stalker-vod-watched-toggle';
import {
createStalkerVodDetailActions,
beginTrackedExternalLaunch,
} from '../stalker-vod-detail-actions';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
@Component({
@@ -85,7 +86,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
private readonly downloadsService = inject(DownloadsService);
private readonly logger = createLogger('StalkerCatalogDetail');
private readonly favoritesRefresh = createRefreshTrigger();
private playbackRequestId = 0;
playbackRequestId = 0;
private currentPlaybackOwnerKey = '';
readonly contentType = this.catalog.contentType;
@@ -103,7 +104,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
: '';
});
private readonly playbackOwnerKey = computed(() =>
readonly playbackOwnerKey = computed(() =>
JSON.stringify([this.playbackSessionKey(), this.contentType()])
);
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
@@ -118,7 +119,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
* keyed by its owner: a stale resolution for the previous movie must not
* hold the next movie's toggle hostage.
*/
private readonly pendingStart = createPendingPlaybackStart<string>();
readonly pendingStart = createPendingPlaybackStart<string>();
readonly playbackStartPending = computed(() =>
this.pendingStart.isPendingFor(this.playbackOwnerKey())
);
@@ -144,6 +145,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
);
});
readonly selectedVodPlaybackDuration = computed<number | null>(
() => this.selectedVodPosition()?.durationSeconds ?? null
);
readonly sourceLabel = computed(
() => this.catalog.playlist()?.title ?? null
);
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
);
@@ -209,6 +216,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
effect(() => {
const ownerKey = this.playbackOwnerKey();
if (ownerKey === this.currentPlaybackOwnerKey) return;
// A start the left movie still resolves no longer applies; a
// return to it must not find Play held by a hung request.
this.pendingStart.retire(this.currentPlaybackOwnerKey);
this.currentPlaybackOwnerKey = ownerKey;
this.closeInlinePlayer();
});
@@ -230,26 +240,13 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
) ?? null;
}
onVodPlay(item: VodDetailsItem): void {
onVodPlay(item: VodDetailsItem, positionSeconds?: number): void {
if (item.type === 'stalker') {
void this.startStalkerVodPlayback(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
onVodResume(event: {
item: VodDetailsItem;
positionSeconds: number;
}): void {
if (event.item.type === 'stalker') {
void this.startStalkerVodPlayback(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
event.positionSeconds
item.data.info?.movie_image,
positionSeconds
);
}
}
@@ -273,6 +270,46 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
void this.watchedToggle.toggleItem(event.item);
}
readonly vodDetailActions = createStalkerVodDetailActions({
resolvePlayback: (cmd, title, thumbnail, startTime) =>
this.catalog.resolveVodPlayback(cmd, title, thumbnail, startTime),
portalPlayer: this.portalPlayer,
externalPlayback: this.externalPlayback,
playbackPositions: this.playbackPositions,
playlistId: () => this.catalog.playlist()?.id,
selectedVodId: () =>
this.contentType() === 'vod' && !this.isSeriesDetail()
? Number(this.selectedItem()?.id) || null
: null,
selectedVodPosition: this.selectedVodPosition,
discardPendingPositionLoad: () => ++this.positionLoadGeneration,
beforeExternalLaunch: () => this.closeInlinePlayer(),
beginPendingStart: () => beginTrackedExternalLaunch(this),
afterProgressReset: (playlistId) =>
void this.catalog.refreshPositions(playlistId),
download: (item) =>
startStalkerVodDownload(item, {
playlist: this.catalog.playlist(),
downloadsService: this.downloadsService,
fetchMovieFileId: (id) => this.catalog.fetchMovieFileId(id),
fetchLinkToPlay: (portalUrl, macAddress, cmd, linkFlags) =>
this.catalog.fetchLinkToPlay(
portalUrl,
macAddress,
cmd,
undefined,
linkFlags
),
language:
this.translateService.currentLang ||
this.translateService.defaultLang ||
'en',
}),
snackBar: this.snackBar,
translate: this.translateService,
logError: (message, error) => this.logger.error(message, error),
});
onVodBack(): void {
const back = resolveStalkerBackNavigation(
window.history.state,
@@ -317,45 +354,6 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
this.positionWriter.reset();
}
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
undefined,
{
duration: 2000,
}
);
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
async onVodDownload(item: VodDetailsItem): Promise<void> {
await startStalkerVodDownload(item, {
playlist: this.catalog.playlist(),
downloadsService: this.downloadsService,
fetchMovieFileId: (id) => this.catalog.fetchMovieFileId(id),
fetchLinkToPlay: (portalUrl, macAddress, cmd, linkFlags) =>
this.catalog.fetchLinkToPlay(
portalUrl,
macAddress,
cmd,
undefined,
linkFlags
),
language:
this.translateService.currentLang ||
this.translateService.defaultLang ||
'en',
});
}
ngOnDestroy(): void {
this.closeInlinePlayer();
this.unsubscribePositionUpdates?.();
@@ -79,6 +79,12 @@ class StubStalkerInlineDetailComponent {
readonly inlinePlaybackClosed = output<void>();
readonly streamUrlCopied = output<void>();
readonly inlineExternalFallbackRequested = output<unknown>();
readonly externalPlayRequested = output<{
item: VodDetailsItem;
player: 'mpv' | 'vlc';
positionSeconds: number | null;
}>();
readonly resetProgressRequested = output<VodDetailsItem>();
}
describe('StalkerCollectionDetailComponent', () => {
@@ -622,6 +628,45 @@ describe('StalkerCollectionDetailComponent', () => {
);
});
it('routes the menu launch and reset through the collection actions', async () => {
const sourceItem = createSourceItem();
fixture.componentRef.setInput(
'item',
buildCollectionItem({
contentType: 'movie',
categoryId: 'vod',
stalkerItem: sourceItem,
})
);
await settleDetail(fixture);
await settleDetail(fixture);
const detail = fixture.debugElement.query(
By.directive(StubStalkerInlineDetailComponent)
).componentInstance as StubStalkerInlineDetailComponent;
const item = createStalkerVodItem(sourceItem, playlist._id);
stalkerStore.resolveVodPlayback.mockResolvedValue({
streamUrl: 'http://cdn/1701.mp4',
title: 'Movie',
});
detail.externalPlayRequested.emit({
item,
player: 'vlc',
positionSeconds: null,
});
await settleDetail(fixture);
expect(portalPlayer.openExternalPlayback).toHaveBeenCalledWith(
expect.objectContaining({ streamUrl: 'http://cdn/1701.mp4' }),
'vlc'
);
detail.resetProgressRequested.emit(item);
await settleDetail(fixture);
expect(
playbackPositions.clearPlaybackPositionOrThrow
).toHaveBeenCalledWith('stalker-1', 1701, 'vod');
});
it('blocks the watched toggle while a collection Play is still resolving', async () => {
const sourceItem = createSourceItem();
let resolve!: (value: ResolvedPortalPlayback) => void;
@@ -89,6 +89,12 @@ import {
(inlineExternalFallbackRequested)="
handleExternalFallbackRequest($event)
"
(externalPlayRequested)="
playback.vodDetailActions.openExternal($event)
"
(resetProgressRequested)="
playback.vodDetailActions.resetProgress($event)
"
/>
} @else {
<app-portal-detail-shell
@@ -152,6 +158,7 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff {
stalkerStore: this.stalkerStore,
playbackPositions: this.playbackPositions,
portalPlayer: this.portalPlayer,
externalPlayback: this.externalPlayback,
snackBar: this.snackBar,
translateService: this.translateService,
logger: this.logger,
@@ -206,6 +213,11 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff {
const playbackOwnerKey = this.playbackSessionKey();
untracked(() => {
if (playbackOwnerKey !== this.currentPlaybackOwnerKey) {
// A start the left item still resolves no longer
// applies; a return to it must not find Play held.
this.playback.retirePendingStart(
this.currentPlaybackOwnerKey
);
this.currentPlaybackOwnerKey = playbackOwnerKey;
this.closeInlinePlayer();
}
@@ -3,6 +3,7 @@ import type { MatSnackBar } from '@angular/material/snack-bar';
import type { TranslateService } from '@ngx-translate/core';
import type {
Logger,
PortalExternalPlayback,
PortalPlaybackPositions,
PortalPlayer,
UnifiedCollectionItem,
@@ -18,6 +19,7 @@ import {
ResolvedPortalPlayback,
VodDetailsItem,
} from '@iptvnator/shared/interfaces';
import { createStalkerVodDetailActions } from './stalker-vod-detail-actions';
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
import { createStalkerVodWatchedToggle } from './stalker-vod-watched-toggle';
@@ -32,6 +34,10 @@ interface StalkerCollectionPlaybackControllerConfig {
stalkerStore: InstanceType<typeof StalkerStore>;
playbackPositions: PortalPlaybackPositions;
portalPlayer: PortalPlayer;
externalPlayback: Pick<
PortalExternalPlayback,
'activeSession' | 'closeSession'
>;
snackBar: MatSnackBar;
translateService: TranslateService;
logger: Logger;
@@ -107,6 +113,37 @@ export class StalkerCollectionPlaybackController {
});
}
/** The "…" menu's external launch and progress reset of the shown movie. */
readonly vodDetailActions = createStalkerVodDetailActions({
resolvePlayback: (cmd, title, thumbnail, startTime) =>
this.config.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
),
portalPlayer: this.config.portalPlayer,
externalPlayback: this.config.externalPlayback,
playbackPositions: this.config.playbackPositions,
playlistId: () => this.playbackOwner()?.sourceId,
// Movie and series ids collide: a series now on screen must not
// pass as the movie whose link is still resolving.
selectedVodId: () =>
this.config.item()?.contentType === 'movie'
? Number(this.playbackOwner()?.contentId) || null
: null,
selectedVodPosition: this.selectedVodPosition,
discardPendingPositionLoad: () =>
this.vodPlayback.discardPendingPositionLoad(),
beginPendingStart: () => this.vodPlayback.beginPendingStart(),
beforeExternalLaunch: () => this.closeInlinePlayer(),
snackBar: this.config.snackBar,
translate: this.config.translateService,
logError: (message, error) => this.config.logger.error(message, error),
});
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
void this.startVodPlayback(
@@ -138,6 +175,11 @@ export class StalkerCollectionPlaybackController {
this.vodPlayback.handleInlineTimeUpdate(event);
}
/** The page left `owner`: a start it still resolves no longer holds a return to it. */
retirePendingStart(owner: string): void {
this.vodPlayback.retirePendingStart(owner);
}
closeInlinePlayer(): void {
this.vodPlayback.closeInlinePlayer();
}
@@ -1,5 +1,20 @@
@let isFavorite = isFavorite$ | async;
@if (isFavorite === false) {
@if (appearance() === 'icon') {
<app-detail-icon-button
[label]="
(isFavorite
? 'PORTALS.REMOVE_FROM_FAVORITES'
: 'PORTALS.ADD_TO_FAVORITES'
) | translate
"
[icon]="isFavorite ? 'favorite' : 'favorite_border'"
tone="favorite"
[active]="isFavorite === true"
[pressedState]="isFavorite === true"
testId="series-favorite-toggle"
(pressed)="isFavorite ? removeFromFavorites() : addToFavorites()"
/>
} @else if (isFavorite === false) {
<button class="favorite-btn" (click)="addToFavorites()">
<mat-icon>add</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
@@ -9,6 +9,7 @@ import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject, map, of, switchMap } from 'rxjs';
import { PlaylistsService } from '@iptvnator/services';
import { DetailIconButtonComponent } from '@iptvnator/ui/components';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
matchesFavoriteById,
@@ -27,7 +28,7 @@ type FavoriteButtonItem = StalkerSelectedVodItem & {
templateUrl: './stalker-favorites-button.component.html',
styleUrls: ['./stalker-favorites-button.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
imports: [AsyncPipe, MatIconModule, TranslateModule],
imports: [AsyncPipe, DetailIconButtonComponent, MatIconModule, TranslateModule],
})
export class FavoritesButtonComponent {
private playlistService = inject(PlaylistsService);
@@ -35,6 +36,8 @@ export class FavoritesButtonComponent {
readonly itemId = input.required<string>();
readonly item = input.required<FavoriteButtonItem>();
/** `icon`: the 44px heart of the cinematic action row. */
readonly appearance = input<'text' | 'icon'>('text');
private readonly favoritesChanged$ = new BehaviorSubject<void>(undefined);
@@ -19,6 +19,8 @@
[item]="item"
[isFavorite]="isFavorite()"
[playbackPosition]="playbackPosition()"
[playbackDurationSeconds]="playbackDurationSeconds()"
[sourceLabel]="sourceLabel()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback()"
[providerOnly]="providerOnly()"
@@ -37,6 +39,8 @@
(inlineExternalFallbackRequested)="
onInlineExternalFallbackRequested($event)
"
(externalPlayRequested)="externalPlayRequested.emit($event)"
(resetProgressRequested)="resetProgressRequested.emit($event)"
/>
}
} @else if (categoryId() === 'series') {
@@ -22,6 +22,8 @@ class StubVodDetailsComponent {
readonly providerOnly = input(false);
readonly isFavorite = input(false);
readonly playbackPosition = input<number | null>(null);
readonly playbackDurationSeconds = input<number | null>(null);
readonly sourceLabel = input<string | null>(null);
readonly inlinePlayback = input<unknown>(null);
readonly externalPlayback = input<unknown>(null);
readonly isWatched = input(false);
@@ -35,6 +37,8 @@ class StubVodDetailsComponent {
readonly backClicked = output<void>();
readonly inlineTimeUpdated = output<unknown>();
readonly inlinePlaybackClosed = output<void>();
readonly externalPlayRequested = output<unknown>();
readonly resetProgressRequested = output<unknown>();
readonly streamUrlCopied = output<void>();
readonly inlineExternalFallbackRequested = output<unknown>();
}
@@ -10,6 +10,7 @@ import {
VodDetailsComponent,
} from '@iptvnator/ui/playback';
import {
ExternalPlayerName,
ExternalPlayerSession,
ResolvedPortalPlayback,
VodDetailsItem,
@@ -39,6 +40,8 @@ export class StalkerInlineDetailComponent {
readonly vodDetailsItem = input<VodDetailsItem | null>(null);
readonly isFavorite = input<boolean>(false);
readonly playbackPosition = input<number | null>(null);
readonly playbackDurationSeconds = input<number | null>(null);
readonly sourceLabel = input<string | null>(null);
readonly inlinePlayback = input<ResolvedPortalPlayback | null>(null);
readonly externalPlayback = input<ExternalPlayerSession | null>(null);
readonly providerOnly = input(false);
@@ -69,6 +72,12 @@ export class StalkerInlineDetailComponent {
readonly streamUrlCopied = output<void>();
readonly inlineExternalFallbackRequested =
output<PlaybackFallbackRequest>();
readonly externalPlayRequested = output<{
item: VodDetailsItem;
player: ExternalPlayerName;
positionSeconds: number | null;
}>();
readonly resetProgressRequested = output<VodDetailsItem>();
onBackClicked() {
this.backClicked.emit();
@@ -24,8 +24,11 @@
[vodDetailsItem]="inlineDetail().vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
[playbackDurationSeconds]="selectedVodPlaybackDuration()"
[sourceLabel]="sourceLabel()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
[playbackStartPending]="playbackStartPending()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
@@ -36,6 +39,8 @@
"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
(externalPlayRequested)="vodDetailActions.openExternal($event)"
(resetProgressRequested)="vodDetailActions.resetProgress($event)"
/>
}
@@ -70,6 +70,7 @@ import {
} from '@iptvnator/portal/stalker/data-access';
import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { createStalkerVodDetailActions } from '../stalker-vod-detail-actions';
import { isStalkerSearchRequestCurrent } from './stalker-search-request.util';
interface StalkerFilter {
@@ -203,9 +204,19 @@ export class StalkerSearchComponent {
JSON.stringify([this.playbackSessionKey(), this.selectedFilterType()])
);
readonly selectedVodPosition = signal<PlaybackPositionData | null>(null);
readonly selectedVodPlaybackDuration = computed<number | null>(
() => this.selectedVodPosition()?.durationSeconds ?? null
);
readonly sourceLabel = computed(
() => this.stalkerStore.currentPlaylist()?.title ?? null
);
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
);
/** A Play/Resume or menu launch still resolving its stream. */
readonly playbackStartPending = computed(() =>
this.vodPlayback.playbackStartPending()
);
private readonly vodPlayback = new StalkerVodPlaybackController({
inlinePlayback: this.inlinePlayback,
selectedVodPosition: this.selectedVodPosition,
@@ -845,6 +856,9 @@ export class StalkerSearchComponent {
private syncPlaybackOwner(ownerKey: string): void {
if (ownerKey === this.currentPlaybackOwnerKey) return;
// A start the left movie still resolves no longer applies; a return
// to it must not find Play held by a hung request.
this.vodPlayback.retirePendingStart(this.currentPlaybackOwnerKey);
this.currentPlaybackOwnerKey = ownerKey;
this.closeInlinePlayer();
}
@@ -892,6 +906,35 @@ export class StalkerSearchComponent {
}
}
readonly vodDetailActions = createStalkerVodDetailActions({
resolvePlayback: (cmd, title, thumbnail, startTime) =>
this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
),
portalPlayer: this.portalPlayer,
externalPlayback: this.externalPlayback,
playbackPositions: this.playbackPositions,
playlistId: () => this.stalkerStore.currentPlaylist()?._id,
selectedVodId: () => {
const item = this.vodDetailsItem();
return item?.type === 'stalker'
? Number(item.data.id) || null
: null;
},
selectedVodPosition: this.selectedVodPosition,
discardPendingPositionLoad: () =>
this.vodPlayback.discardPendingPositionLoad(),
beginPendingStart: () => this.vodPlayback.beginPendingStart(),
snackBar: this.snackBar,
translate: this.translateService,
logError: () => undefined,
});
private async startStalkerVodPlayback(
cmd?: string,
title?: string,
@@ -0,0 +1,130 @@
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 {
SettingsStore,
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 settingsStore = inject(SettingsStore);
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)
);
/** Settings → Playback → Play trailers in details background. */
readonly trailerBackdropUrl = computed(() =>
this.settingsStore.detailTrailerBackdrop?.() === true
? this.trailerEmbedUrl()
: null
);
openTrailer(): void {
const embedUrl = this.trailerEmbedUrl();
if (embedUrl) {
this.trailerDialog.open({ embedUrl, title: this.hero.title() });
}
}
openSimilarRailItem(item: SimilarRailItem): void {
const bindings = this.bindings();
const match = bindings
?.similarInPortals()
.find(
(candidate) =>
`x${candidate.match.playlistId}-${candidate.match.xtreamId}` ===
item.key
);
if (match) {
bindings?.openSimilarInPortals(match);
}
}
}
@@ -0,0 +1,156 @@
import {
StalkerSeriesLaunchQueue,
type StalkerSeriesLaunchRelease,
} from './stalker-series-launch-queue';
function deferred<T = void>(): {
promise: Promise<T>;
settle: (value: T) => void;
} {
let settle!: (value: T) => void;
const promise = new Promise<T>((resolve) => (settle = resolve));
return { promise, settle };
}
function release(
overrides: Partial<StalkerSeriesLaunchRelease> = {}
): StalkerSeriesLaunchRelease {
return {
stillShown: () => true,
replacePlayer: () => Promise.resolve(true),
...overrides,
};
}
describe('StalkerSeriesLaunchQueue', () => {
const A = 'playlist-1:100';
const B = 'playlist-1:200';
it('plays the last choice held during a launch once it settles', async () => {
const queue = new StalkerSeriesLaunchQueue();
const launch = deferred();
const run = queue.run(A, () => launch.promise, release());
const first = jest.fn();
const second = jest.fn();
expect(queue.isLaunching(A)).toBe(true);
queue.hold(A, first);
queue.hold(A, second);
expect(second).not.toHaveBeenCalled();
launch.settle();
await run;
expect(queue.isLaunching(A)).toBe(false);
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
});
it('replaces the player the launch opened before the held choice starts', async () => {
const queue = new StalkerSeriesLaunchQueue();
const replace = deferred<boolean>();
const replacePlayer = jest.fn().mockReturnValue(replace.promise);
const run = queue.run(
A,
() => Promise.resolve(),
release({ replacePlayer })
);
const choice = jest.fn();
queue.hold(A, choice);
await Promise.resolve();
await Promise.resolve();
// The choice must not play beside the opened player, and nothing
// else may start while that player closes.
expect(replacePlayer).toHaveBeenCalledTimes(1);
expect(choice).not.toHaveBeenCalled();
expect(queue.isLaunching(A)).toBe(true);
replace.settle(true);
await run;
expect(choice).toHaveBeenCalledTimes(1);
expect(queue.isLaunching(A)).toBe(false);
});
it('starts the choice held while the player closed, not the earlier one', async () => {
const queue = new StalkerSeriesLaunchQueue();
const replace = deferred<boolean>();
const run = queue.run(
A,
() => Promise.resolve(),
release({ replacePlayer: () => replace.promise })
);
const earlier = jest.fn();
const latest = jest.fn();
queue.hold(A, earlier);
await Promise.resolve();
await Promise.resolve();
// Still launching while the player closes: the viewer's newer pick
// must not be stranded behind the one that triggered the close.
expect(queue.isLaunching(A)).toBe(true);
queue.hold(A, latest);
replace.settle(true);
await run;
expect(earlier).not.toHaveBeenCalled();
expect(latest).toHaveBeenCalledTimes(1);
expect(queue.isLaunching(A)).toBe(false);
});
it('keeps the player and drops the choice when it cannot be replaced', async () => {
const queue = new StalkerSeriesLaunchQueue();
const choice = jest.fn();
const run = queue.run(
A,
() => Promise.resolve(),
release({ replacePlayer: () => Promise.resolve(false) })
);
queue.hold(A, choice);
await run;
expect(choice).not.toHaveBeenCalled();
expect(queue.isLaunching(A)).toBe(false);
});
it('keeps a choice for one series when a launch of another settles first', async () => {
const queue = new StalkerSeriesLaunchQueue();
const launchA = deferred();
const launchB = deferred();
const runA = queue.run(
A,
() => launchA.promise,
release({ stillShown: () => false })
);
const runB = queue.run(B, () => launchB.promise, release());
const choiceB = jest.fn();
queue.hold(B, choiceB);
// The page shows B now: A settling must not touch B's choice.
launchA.settle();
await runA;
expect(choiceB).not.toHaveBeenCalled();
expect(queue.isLaunching(B)).toBe(true);
launchB.settle();
await runB;
expect(choiceB).toHaveBeenCalledTimes(1);
});
it('drops the choice when the page moved on, even if the launch failed', async () => {
const queue = new StalkerSeriesLaunchQueue();
const choice = jest.fn();
const replacePlayer = jest.fn();
const run = queue.run(
A,
() => Promise.reject(new Error('launch failed')),
release({ stillShown: () => false, replacePlayer })
);
queue.hold(A, choice);
await expect(run).rejects.toThrow('launch failed');
expect(replacePlayer).not.toHaveBeenCalled();
expect(choice).not.toHaveBeenCalled();
expect(queue.isLaunching(A)).toBe(false);
});
});
@@ -0,0 +1,82 @@
/** What releasing a held choice needs from the page once its launch settled. */
export interface StalkerSeriesLaunchRelease {
/** Whether the page still shows the series: a choice the viewer left behind is dropped. */
readonly stillShown: () => boolean;
/** Closes the player the launch opened; false keeps it and drops the choice. */
readonly replacePlayer: () => Promise<boolean>;
}
/**
* Episode choices made while a forced MPV/VLC launch of their series is
* mid-flight. The launch IPC cannot be cancelled by a later request, so the
* choice waits and, once the launch settled, replaces the player it opened
* instead of playing beside it; the last choice per series wins.
*
* Keyed by `playlist:series`: the series view is reused across series, so a
* launch of one series settling must not drop what another series holds.
*/
export class StalkerSeriesLaunchQueue {
private readonly launching = new Map<string, number>();
private readonly choices = new Map<string, () => void>();
/** True while a launch of `seriesKey`, or the release of its held choice, is mid-flight. */
isLaunching(seriesKey: string): boolean {
return (this.launching.get(seriesKey) ?? 0) > 0;
}
/**
* Holds `choice` until the launch of `seriesKey` settles, replacing an
* earlier one. The choice must start the episode directly: the series
* still counts as launching when it runs.
*/
hold(seriesKey: string, choice: () => void): void {
this.choices.set(seriesKey, choice);
}
/**
* Runs `launch`, then the choice held for `seriesKey` meanwhile, after
* `replacePlayer` closed what the launch opened. The series counts as
* launching until that choice started, so nothing slips in between.
*/
async run(
seriesKey: string,
launch: () => Promise<void>,
release: StalkerSeriesLaunchRelease
): Promise<void> {
this.count(seriesKey, 1);
try {
await launch();
} finally {
try {
await this.releaseChoice(seriesKey, release);
} finally {
this.count(seriesKey, -1);
}
}
}
private async releaseChoice(
seriesKey: string,
release: StalkerSeriesLaunchRelease
): Promise<void> {
if (!this.choices.has(seriesKey)) return;
if (!release.stillShown()) {
this.choices.delete(seriesKey);
return;
}
const replaced = await release.replacePlayer();
// The latest choice wins, including one held while the player closed.
const choice = this.choices.get(seriesKey);
this.choices.delete(seriesKey);
if (replaced && release.stillShown()) choice?.();
}
private count(seriesKey: string, delta: number): void {
const next = (this.launching.get(seriesKey) ?? 0) + delta;
if (next > 0) {
this.launching.set(seriesKey, next);
} else {
this.launching.delete(seriesKey);
}
}
}
@@ -0,0 +1,74 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { VideoPlayer } from '@iptvnator/shared/interfaces';
import { SERIES_MENU_ACTION } from '@iptvnator/ui/components';
import { StalkerSeriesMenuService } from './stalker-series-menu.service';
import type { StalkerQuickStartButton } from './stalker-series-quick-start';
function button(
overrides: Partial<StalkerQuickStartButton> = {}
): StalkerQuickStartButton {
return {
labelKey: 'XTREAM.PLAY_NEXT',
episodeLabel: 'S01E02',
icon: 'play_arrow',
disabled: false,
action: {
kind: 'play-next',
episode: { id: '1002', season: 1, episode_num: 2 },
} as never,
lazySeason: null,
...overrides,
};
}
describe('StalkerSeriesMenuService', () => {
const quickStart = signal<StalkerQuickStartButton | null>(null);
let service: StalkerSeriesMenuService;
beforeEach(() => {
quickStart.set(button());
TestBed.configureTestingModule({
providers: [
StalkerSeriesMenuService,
{
provide: RuntimeCapabilitiesService,
useValue: { supportsManagedExternalPlayers: true },
},
{
provide: SettingsStore,
useValue: { player: signal(VideoPlayer.VLC) },
},
],
});
service = TestBed.inject(StalkerSeriesMenuService);
service.bind({
quickStart,
seasonContainer: signal(undefined),
hasProgress: signal(false),
playbackActive: signal(false),
startPending: signal(false),
resetProgress: jest.fn(),
openExternal: jest.fn(),
});
});
function externalRow() {
return service
.sections()
.flatMap((section) => section.items)
.find((item) => item.id === SERIES_MENU_ACTION.ExternalPlayer);
}
it('offers the next episode to the configured external player', () => {
expect(externalRow()?.hint).toBe('VLC');
});
it('offers no external launch once the series is completed', () => {
// The completed button still carries the last episode as its
// action, disabled: a label, not something to launch.
quickStart.set(button({ disabled: true }));
expect(externalRow()).toBeUndefined();
});
});
@@ -0,0 +1,111 @@
import { computed, inject, Injectable, Signal, signal } from '@angular/core';
import { RuntimeCapabilitiesService, 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>;
/** An episode plays or launches: its next tick would undo a reset. */
readonly playbackActive: Signal<boolean>;
/** A start has not published its session yet: bulk watched actions would include it. */
readonly startPending: 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 runtime = inject(RuntimeCapabilitiesService);
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:
(this.bindings()?.startPending() ?? false) ||
(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:
(this.bindings()?.startPending() ?? false) ||
(watch?.seriesActionDisabled() ?? true),
hasProgress: this.bindings()?.hasProgress() ?? false,
playbackActive: this.bindings()?.playbackActive() ?? false,
startPending: this.bindings()?.startPending() ?? false,
watchBatchRunning: container?.seasonWatchBatchRunning() ?? false,
sourcesCount: 0,
// A completed series keeps a disabled quick start naming the
// last episode: nothing to launch there.
externalPlayerHint:
quickStart?.action &&
!quickStart.disabled &&
this.runtime.supportsManagedExternalPlayers
? this.externalPlayer() === 'vlc'
? 'VLC'
: 'MPV'
: null,
copyUrlEpisodeCode: null,
downloadVisible: download?.presentationVisible() ?? false,
downloadCount: download?.eligibleEpisodeCount() ?? 0,
downloadDisabled: download?.seasonDisabled() ?? true,
downloadBusy: download?.batchRunning() ?? false,
categoryName: null,
inContinueWatching: false,
});
});
async run(actionId: string): Promise<void> {
const container = this.bindings()?.seasonContainer();
switch (actionId) {
case SERIES_MENU_ACTION.SeasonWatched:
container?.watchPresenter.toggleSeasonWatched();
return;
case SERIES_MENU_ACTION.SeriesWatched:
container?.watchPresenter.toggleSeriesWatched();
return;
case SERIES_MENU_ACTION.ResetProgress:
await this.bindings()?.resetProgress();
return;
case SERIES_MENU_ACTION.DownloadSeason:
await container?.downloadPresenter.enqueueSeason();
return;
case SERIES_MENU_ACTION.ExternalPlayer:
await this.bindings()?.openExternal(this.externalPlayer());
return;
}
}
}
@@ -1,204 +1,137 @@
@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"
[trailerBackdropUrl]="heroPresenter.trailerBackdropUrl()"
[contentKey]="'stalker-series:' + currentSeriesKey()"
[playbackActive]="!!inlinePlayback()"
(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>
}
}
</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>
}
</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"
<app-meta-chip
>KP {{ serial.info.rating_kinopoisk }}</app-meta-chip
>
<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>
}
<app-favorites-button [itemId]="serial.id" [item]="serial" />
</ng-template>
<ng-template appDetailMeta>
<app-detail-credits
[cast]="hero.castNames()"
[directors]="hero.directorNames()"
(moreRequested)="scrollToCast()"
/>
</ng-template>
<ng-template appDetailActions>
@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 (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 +206,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>
}
@@ -39,6 +39,7 @@ describe('StalkerSeriesViewComponent', () => {
const resolveVodPlayback = jest.fn();
const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
const openResolvedPlayback = jest.fn();
const openExternalPlayback = jest.fn();
const isEmbeddedPlayer = jest.fn();
const tmdbGetSeason = jest.fn();
const fetchLinkToPlay = jest.fn();
@@ -54,6 +55,13 @@ describe('StalkerSeriesViewComponent', () => {
await fixture.whenStable();
}
/** Lets a start's awaited continuations run, then renders their state. */
async function settleStart(): Promise<void> {
await stabilize();
await new Promise((resolve) => setTimeout(resolve));
fixture.detectChanges();
}
beforeEach(async () => {
selectedContentType.set('series');
selectedItem.set({
@@ -104,6 +112,7 @@ describe('StalkerSeriesViewComponent', () => {
getSeriesPlaybackPositions.mockClear();
getSeriesPlaybackPositions.mockResolvedValue([]);
openResolvedPlayback.mockClear();
openExternalPlayback.mockReset();
isEmbeddedPlayer.mockReset();
isEmbeddedPlayer.mockReturnValue(false);
tmdbGetSeason.mockReset();
@@ -159,6 +168,7 @@ describe('StalkerSeriesViewComponent', () => {
useValue: {
isEmbeddedPlayer,
openResolvedPlayback,
openExternalPlayback,
},
},
{
@@ -247,9 +257,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();
@@ -271,6 +279,62 @@ describe('StalkerSeriesViewComponent', () => {
);
});
it('holds the quick-start button until the start settles', async () => {
let finishResolve!: (playback: unknown) => void;
resolveVodPlayback.mockImplementationOnce(
() => new Promise((resolve) => (finishResolve = resolve))
);
await stabilize();
fixture.detectChanges();
const button = (): HTMLButtonElement | null =>
fixture.nativeElement.querySelector(
'[data-testid="series-quick-start"]'
);
button()?.click();
fixture.detectChanges();
// A second press could not cancel the first start.
expect(button()?.disabled).toBe(true);
finishResolve({ streamUrl: 'http://stalker.example/episode.mpg' });
await settleStart();
expect(button()?.disabled).toBe(false);
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
});
it('plays an episode chosen during a forced launch once that launch settles', async () => {
let finishLaunch!: () => void;
openExternalPlayback.mockImplementationOnce(
() => new Promise<void>((resolve) => (finishLaunch = resolve))
);
await stabilize();
const [first, second] = fixture.componentInstance.mappedSeasons()['1'];
fixture.componentInstance.onEpisodeClicked(first, undefined, 'mpv');
await settleStart();
expect(openExternalPlayback).toHaveBeenCalledTimes(1);
// The launch IPC cannot be cancelled: the choice waits for it instead
// of opening a second player next to the first.
fixture.componentInstance.onEpisodeClicked(second);
await settleStart();
expect(resolveVodPlayback).toHaveBeenCalledTimes(1);
expect(openResolvedPlayback).not.toHaveBeenCalled();
finishLaunch();
await settleStart();
expect(resolveVodPlayback).toHaveBeenCalledTimes(2);
expect(resolveVodPlayback).toHaveBeenLastCalledWith(
'/media/file_30001.mpg',
'Regular Series',
'poster.jpg',
2,
expect.any(Number),
undefined
);
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
});
it('keeps provider episodes playable while hiding download presentation', async () => {
fixture.componentRef.setInput('providerOnly', true);
@@ -448,7 +512,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 +671,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 +1161,7 @@ describe('StalkerSeriesViewComponent', () => {
);
expect(quickStartButton).not.toBeNull();
expect(quickStartButton?.textContent).toContain(
'XTREAM.PLAY_NEXT_EPISODE'
);
expect(quickStartButton?.textContent).toContain('XTREAM.PLAY');
expect(quickStartButton?.textContent).toContain('S02E01');
quickStartButton?.click();
@@ -9,9 +9,9 @@ import {
signal,
untracked,
ChangeDetectionStrategy,
viewChild,
} from '@angular/core';
import { Location } from '@angular/common';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
@@ -25,29 +25,39 @@ 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,
ExternalPlayerSession,
PlaybackPositionData,
PlayerContentInfo,
ResolvedPortalPlayback,
seriesStatusLabelKey,
TmdbEnrichedCastMember,
XtreamSerieEpisode,
pickSeasonMarkedTitle,
seriesStatusLabelKey,
youtubeEmbedUrl,
} from '@iptvnator/shared/interfaces';
import { SafePipe } from '@iptvnator/pipes';
import {
isLiveExternalPlayerSession,
replaceOwnedExternalSession,
isPortalPlaybackWatched,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
@@ -91,6 +101,9 @@ import {
TmdbEnrichmentService,
} from '@iptvnator/services';
import { StalkerSeriesTmdbSeasonsService } from './stalker-series-tmdb-seasons.service';
import { StalkerSeriesHeroPresenter } from './stalker-series-hero.presenter';
import { StalkerSeriesLaunchQueue } from './stalker-series-launch-queue';
import { StalkerSeriesMenuService } from './stalker-series-menu.service';
import {
getStalkerSeriesQuickStartButton,
type StalkerQuickStartButton,
@@ -168,22 +181,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: (
@@ -261,6 +286,32 @@ export class StalkerSeriesViewComponent implements OnDestroy {
private unsubscribePositionUpdates: (() => void) | null = null;
readonly openingEpisodeId = signal<number | null>(null);
readonly activeEpisodeId = signal<number | null>(null);
/**
* `playlist:series` keys of starts still resolving their stream
* (`create_link` round trips): only the series on screen counts as
* starting. Provider series ids are playlist-scoped.
*/
private readonly pendingStartSeriesIds = signal<readonly string[]>([]);
/** Episode choices made while a forced MPV/VLC launch is mid-flight. */
private readonly launchQueue = new StalkerSeriesLaunchQueue();
/**
* The episode chosen while a watched/reset batch still rewrote the rows
* a start resumes from; the last choice plays once the batch settled,
* and only on the series it was made for.
*/
private choiceHeldForBatch: {
readonly seriesKey: string;
readonly play: () => void;
} | null = null;
/** `playlist:series` of the series on screen; provider ids collide across playlists. */
readonly currentSeriesKey = computed(
() =>
`${this.stalkerStore.currentPlaylist()?._id ?? ''}:${this.displayItem()?.id ?? ''}`
);
/** A start of the series on screen that has not settled. */
readonly startPending = computed(() =>
this.pendingStartSeriesIds().includes(this.currentSeriesKey())
);
readonly seasonWatchBatchRunning = signal(false);
/**
@@ -362,6 +413,30 @@ export class StalkerSeriesViewComponent implements OnDestroy {
readonly isSerialSeasonsLoading = this.stalkerStore.isSerialSeasonsLoading;
constructor() {
this.heroPresenter.bind({
displayItem: this.displayItem,
quickStart: this.quickStartButton,
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
),
playbackActive: computed(
() =>
this.startPending() ||
this.inlinePlayback() !== null ||
this.openingEpisodeId() !== null ||
this.activeEpisodeId() !== null
),
startPending: this.startPending,
resetProgress: () => this.resetProgress(),
openExternal: (player) => this.openQuickStartExternally(player),
});
effect(() => {
const ownerKey = this.seriesPlaybackOwnerKey();
untracked(() => this.syncSeriesPlaybackOwner(ownerKey));
@@ -627,10 +702,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,
@@ -708,6 +779,13 @@ export class StalkerSeriesViewComponent implements OnDestroy {
vodSeriesSeasons: this.vodSeriesSeasons(),
});
});
/** The hero's button: held while a start is pending, a second press would double it. */
readonly quickStartButton = computed<StalkerQuickStartButton | null>(() => {
const button = this.quickStartAction();
return button && (this.startPending() || this.seasonWatchBatchRunning())
? { ...button, disabled: true }
: button;
});
readonly inlineEpisodeState = computed(() => {
const identity = this.inlinePlaybackEpisodeIdentity();
const sourceId = this.stalkerStore.currentPlaylist()?._id?.trim() ?? '';
@@ -959,7 +1037,56 @@ 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
) {
if (this.seasonWatchBatchRunning()) {
// The batch rewrites the very rows a start resumes from: the
// choice waits for it, like the Reset and watched rows do.
this.choiceHeldForBatch = {
seriesKey: this.currentSeriesKey(),
play: () =>
this.onEpisodeClicked(
episode,
startTimeOverride,
forcePlayer
),
};
return;
}
const seriesKey = this.currentSeriesKey();
if (this.launchQueue.isLaunching(seriesKey)) {
// The launch cannot be cancelled: the choice replaces its player
// once it settled.
this.launchQueue.hold(seriesKey, () =>
this.startEpisode(episode, startTimeOverride, forcePlayer)
);
return;
}
this.startEpisode(episode, startTimeOverride, forcePlayer);
}
/**
* The batch settled: the choice held meanwhile goes through the usual
* gates, unless the viewer switched series since. Episode identities
* overlap across series, so it must never resolve against another one.
*/
private endWatchBatch(): void {
this.seasonWatchBatchRunning.set(false);
const held = this.choiceHeldForBatch;
this.choiceHeldForBatch = null;
if (held && held.seriesKey === this.currentSeriesKey()) {
held.play();
}
}
private startEpisode(
episode: XtreamSerieEpisode,
startTimeOverride?: number,
forcePlayer?: ExternalPlayerName
): void {
const item = this.displayItem();
const episodeState = resolveSelectedStalkerEpisodeState({
episodesBySeason: this.mappedSeasons(),
@@ -986,7 +1113,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
title,
item.info.movie_image,
episodeState,
startTime
startTime,
forcePlayer
);
}
@@ -1006,6 +1134,28 @@ 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 quickStart = this.quickStartAction();
const episode = quickStart?.disabled
? undefined
: quickStart?.action?.episode;
if (episode) {
this.onEpisodeClicked(episode, undefined, player);
}
}
openSimilarInPortals(item: CrossPortalSimilarItem): void {
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
}
@@ -1147,19 +1297,93 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.playNextEpisode();
}
/**
* The "…" menu's MPV/VLC launch: an episode of this series still running
* externally is closed first, never doubled; a failed close or a page
* that moved on meanwhile keeps the running player.
*/
private async openEpisodeExternally(
playback: ResolvedPortalPlayback,
player: ExternalPlayerName,
request: StalkerSeriesPlaybackRequestContext
): Promise<void> {
// `closeInlinePlayer()` just retired `request`'s generation; a fresh
// one covers the close round trip, the identity check the series.
const generation = this.seriesPlaybackRequestGeneration;
const replaced = await this.replaceOwnExternalSession(
playback.contentInfo
);
if (
!replaced ||
!this.isPlaybackRequestCurrent({ ...request, generation })
) {
return;
}
let session: ExternalPlayerSession | void;
try {
session = await this.portalPlayer.openExternalPlayback(
playback,
player
);
} catch (error) {
// The caller's catch would read the retired generation and stay
// silent; the user chose this launch and gets its failure.
if (generation !== this.seriesPlaybackRequestGeneration) return;
this.logger.error('External episode launch failed', error);
this.snackBar.open(
this.translateService.instant('PORTALS.PLAYBACK_ERROR'),
undefined,
{ duration: 3000 }
);
return;
}
// The viewer left the series, or started something else, while the
// launch sat inside the player IPC: the player it opened must not
// stay beside what they chose since.
if (
session &&
!this.isPlaybackRequestCurrent({ ...request, generation })
) {
await this.externalPlayback
.closeSession(session)
.catch((error: unknown) =>
this.logger.warn(
'Closing a superseded external player failed',
error
)
);
}
}
/** Closes an episode of this series still running externally; false keeps it. */
private replaceOwnExternalSession(
own: PlayerContentInfo | undefined
): Promise<boolean> {
return replaceOwnedExternalSession(
this.externalPlayback,
(info) =>
info.contentType === 'episode' &&
info.playlistId === own?.playlistId &&
info.seriesXtreamId === own?.seriesXtreamId,
(message, error) => this.logger.warn(message, error)
);
}
private async startPlayback(
cmd: string | undefined,
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,
@@ -1169,6 +1393,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
};
if (request.usesEmbeddedPlayer && !request.identity) return;
const pendingSeriesId = this.currentSeriesKey();
this.pendingStartSeriesIds.update((ids) => [...ids, pendingSeriesId]);
try {
const playback = await this.stalkerStore.resolveVodPlayback(
cmd,
@@ -1199,7 +1425,32 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(resolvedPlayback, true);
if (forcePlayer) {
// Awaited so the start stays pending through the close of the
// previous player and the launch itself.
await this.launchQueue.run(
pendingSeriesId,
() =>
this.openEpisodeExternally(
resolvedPlayback,
forcePlayer,
request
),
{
stillShown: () =>
this.currentSeriesKey() === pendingSeriesId,
replacePlayer: () =>
this.replaceOwnExternalSession(
resolvedPlayback.contentInfo
),
}
);
} else {
void this.portalPlayer.openResolvedPlayback(
resolvedPlayback,
true
);
}
} catch (error) {
if (!this.isPlaybackRequestCurrent(request)) return;
this.logger.error('Failed to start inline series playback', error);
@@ -1212,6 +1463,13 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.snackBar.open(errorMessage, undefined, {
duration: 3000,
});
} finally {
this.pendingStartSeriesIds.update((ids) => {
const index = ids.indexOf(pendingSeriesId);
return index < 0
? ids
: [...ids.slice(0, index), ...ids.slice(index + 1)];
});
}
}
@@ -1323,7 +1581,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
SEASON_WATCH_FEEDBACK
);
} finally {
this.seasonWatchBatchRunning.set(false);
this.endWatchBatch();
}
}
@@ -1447,7 +1705,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
SERIES_WATCH_FEEDBACK
);
} finally {
this.seasonWatchBatchRunning.set(false);
this.endWatchBatch();
}
}
@@ -387,6 +387,112 @@ describe('StalkerSeriesViewComponent season watched toggle', () => {
expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe(false);
});
it('holds an episode start until the watched batch settled', async () => {
const [firstId, secondId] = await startWithTwoLoadedEpisodes();
const store = TestBed.inject(StalkerStore);
const resolvePlayback = jest
.spyOn(store, 'resolveVodPlayback')
.mockResolvedValue(null as never);
let finishSave: () => void = () => undefined;
savePlaybackPositionOrThrow.mockImplementationOnce(
() => new Promise<void>((resolve) => (finishSave = resolve))
);
const batch =
fixture.componentInstance.handleSeasonPlaybackToggleRequested(
seasonToggleRequest([firstId, secondId], true)
);
await Promise.resolve();
expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe(true);
// The batch rewrites the rows a start resumes from: the episode
// chosen meanwhile waits for it instead of resuming from a row
// the batch is about to mark.
const [first] = fixture.componentInstance.mappedSeasons()['1'];
fixture.componentInstance.onEpisodeClicked(first);
expect(resolvePlayback).not.toHaveBeenCalled();
expect(fixture.componentInstance.quickStartButton()?.disabled).toBe(
true
);
finishSave();
await batch;
expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe(false);
expect(resolvePlayback).toHaveBeenCalledTimes(1);
});
it('drops an episode held during a batch once the viewer switched series', async () => {
const [firstId, secondId] = await startWithTwoLoadedEpisodes();
const store = TestBed.inject(StalkerStore);
const resolvePlayback = jest
.spyOn(store, 'resolveVodPlayback')
.mockResolvedValue(null as never);
let finishSave: () => void = () => undefined;
savePlaybackPositionOrThrow.mockImplementationOnce(
() => new Promise<void>((resolve) => (finishSave = resolve))
);
const batch =
fixture.componentInstance.handleSeasonPlaybackToggleRequested(
seasonToggleRequest([firstId, secondId], true)
);
await Promise.resolve();
const [first] = fixture.componentInstance.mappedSeasons()['1'];
fixture.componentInstance.onEpisodeClicked(first);
// Episode identities overlap across series: the held choice must
// not resolve against the series now on screen.
selectedItem.set(createVodItem(SERIES_B_ID));
await settle();
finishSave();
await batch;
expect(resolvePlayback).not.toHaveBeenCalled();
});
it('closes the player a forced launch opened after the viewer left the series', async () => {
await startWithTwoLoadedEpisodes();
const store = TestBed.inject(StalkerStore);
jest.spyOn(store, 'resolveVodPlayback').mockResolvedValue({
streamUrl: 'http://stalker.example/episode.mpg',
title: 'Episode',
contentInfo: {
playlistId: PLAYLIST_ID,
contentXtreamId: 1,
contentType: 'episode',
seriesXtreamId: SERIES_A_ID,
},
} as never);
const player = TestBed.inject(PORTAL_PLAYER) as unknown as {
openExternalPlayback: jest.Mock;
};
const external = TestBed.inject(
PORTAL_EXTERNAL_PLAYBACK
) as unknown as { closeSession?: jest.Mock };
external.closeSession = jest.fn().mockResolvedValue(undefined);
const opened = { id: 'mpv-9', status: 'launching' };
let finishLaunch: () => void = () => undefined;
player.openExternalPlayback.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishLaunch = () => resolve(opened);
})
);
const [first] = fixture.componentInstance.mappedSeasons()['1'];
fixture.componentInstance.onEpisodeClicked(first, undefined, 'mpv');
await settle();
await new Promise((resolve) => setTimeout(resolve));
expect(player.openExternalPlayback).toHaveBeenCalledTimes(1);
// The viewer opens another series while the launch sits inside the
// player IPC: the player it opens must not stay beside that one.
selectedItem.set(createVodItem(SERIES_B_ID));
await settle();
finishLaunch();
await settle();
await new Promise((resolve) => setTimeout(resolve));
expect(external.closeSession).toHaveBeenCalledWith(opened);
});
it('keeps surviving clears and reports a partial season unwatch failure', async () => {
const [firstId, secondId] = await startWithTwoLoadedEpisodes();
repositoryRows = [firstId, secondId].map((contentXtreamId, index) =>
@@ -493,9 +599,7 @@ describe('StalkerSeriesViewComponent season watched toggle', () => {
createSecondSeason(),
]);
await settle();
return Number(
fixture.componentInstance.mappedSeasons()['1'][0].id
);
return Number(fixture.componentInstance.mappedSeasons()['1'][0].id);
}
function mockSecondSeasonFetch(): jest.Mock {
@@ -518,9 +622,9 @@ describe('StalkerSeriesViewComponent season watched toggle', () => {
]);
expectSeasonToggleSnackbar('XTREAM.SEASON_MARKED_WATCHED');
expect(refreshPositions).toHaveBeenCalledWith(PLAYLIST_ID);
expect(
fixture.componentInstance.seasonWatchBatchRunning()
).toBe(false);
expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe(
false
);
});
it('hydrates lazy seasons, then marks them with legacy-row cleanup', async () => {
@@ -542,10 +646,7 @@ describe('StalkerSeriesViewComponent season watched toggle', () => {
seriesToggleRequest([firstId], true)
);
expect(fetch).toHaveBeenCalledWith(
String(SERIES_A_ID),
'season-2'
);
expect(fetch).toHaveBeenCalledWith(String(SERIES_A_ID), 'season-2');
expect(repositoryOrder).toEqual([
`save:${firstId}`,
`save:${S2_SCOPED_ID}`,
@@ -577,9 +678,9 @@ describe('StalkerSeriesViewComponent season watched toggle', () => {
expect(fetch).toHaveBeenCalledTimes(1);
expect(repositoryOrder).toEqual([`save:${firstId}`]);
expectSeasonToggleSnackbar('XTREAM.SEASON_MARKED_WATCHED');
expect(
fixture.componentInstance.hasUnloadedVodSeasons()
).toBe(false);
expect(fixture.componentInstance.hasUnloadedVodSeasons()).toBe(
false
);
// Everything loaded is now watched and nothing is pending — an
// empty follow-up mark request is a no-op, not another fetch.
@@ -637,9 +738,9 @@ describe('StalkerSeriesViewComponent season watched toggle', () => {
expect(repositoryOrder).toEqual([]);
expectSeasonToggleSnackbar('XTREAM.SERIES_WATCH_UPDATE_FAILED');
expect(refreshPositions).not.toHaveBeenCalled();
expect(
fixture.componentInstance.seasonWatchBatchRunning()
).toBe(false);
expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe(
false
);
});
it('aborts silently when the series changes during hydration', async () => {
@@ -665,9 +766,9 @@ describe('StalkerSeriesViewComponent season watched toggle', () => {
expect(repositoryOrder).toEqual([]);
expect(snackBarCalls().length).toBe(callsBefore);
expect(
fixture.componentInstance.seasonWatchBatchRunning()
).toBe(false);
expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe(
false
);
});
it('reports a zero count when hydration reveals nothing to mark', async () => {
@@ -715,9 +816,9 @@ describe('StalkerSeriesViewComponent season watched toggle', () => {
fixture.componentInstance.handleSeriesPlaybackToggleRequested(
seriesToggleRequest([firstId], true)
);
expect(
fixture.componentInstance.seasonWatchBatchRunning()
).toBe(true);
expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe(
true
);
// A season toggle during hydration must not enqueue anything.
await fixture.componentInstance.handleSeasonPlaybackToggleRequested(
seasonToggleRequest([firstId], true)
@@ -731,9 +832,9 @@ describe('StalkerSeriesViewComponent season watched toggle', () => {
`save:${firstId}`,
`save:${S2_SCOPED_ID}`,
]);
expect(
fixture.componentInstance.seasonWatchBatchRunning()
).toBe(false);
expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe(
false
);
});
});
});
@@ -0,0 +1,418 @@
import { signal } from '@angular/core';
import type {
ExternalPlayerSession,
PlaybackPositionData,
VodDetailsItem,
} from '@iptvnator/shared/interfaces';
import { createStalkerVodDetailActions } from './stalker-vod-detail-actions';
const MOVIE = {
type: 'stalker',
data: { id: '42' },
playlistId: 'portal-1',
cmd: 'ffmpeg http://portal/42',
} as unknown as VodDetailsItem;
const POSITION = {
playlistId: 'portal-1',
contentXtreamId: 42,
contentType: 'vod',
positionSeconds: 600,
durationSeconds: 5400,
} as PlaybackPositionData;
describe('createStalkerVodDetailActions openExternal', () => {
function setup(selectedVodId: () => number | null) {
let resolveLink: (playback: unknown) => void = () => undefined;
let rejectLink: (error: unknown) => void = () => undefined;
const resolvePlayback = jest.fn(
() =>
new Promise((resolve, reject) => {
resolveLink = resolve;
rejectLink = reject;
})
);
const open = jest.fn();
const logError = jest.fn();
const openExternalPlayback = jest.fn().mockResolvedValue(undefined);
const beforeExternalLaunch = jest.fn();
const closeSession = jest.fn().mockResolvedValue(undefined);
const running = signal<ExternalPlayerSession | null>(null);
const settlePendingStart = jest.fn();
let launchCurrent = true;
const beginPendingStart = jest.fn().mockReturnValue({
settle: settlePendingStart,
isCurrent: () => launchCurrent,
rebase: () => (launchCurrent = true),
});
const actions = createStalkerVodDetailActions({
resolvePlayback: resolvePlayback as never,
portalPlayer: { openExternalPlayback },
externalPlayback: { activeSession: running, closeSession },
beginPendingStart,
playbackPositions: { clearPlaybackPositionOrThrow: jest.fn() },
playlistId: () => 'portal-1',
selectedVodId,
selectedVodPosition: signal(null),
beforeExternalLaunch,
snackBar: { open },
translate: { instant: (key: string) => key },
logError,
});
return {
actions,
openExternalPlayback,
beforeExternalLaunch,
closeSession,
open,
logError,
rejectLink: (error: unknown) => rejectLink(error),
beginPendingStart,
settlePendingStart,
supersede: () => (launchCurrent = false),
setRunning: (session: ExternalPlayerSession | null) =>
running.set(session),
resolveLink: () => resolveLink({ streamUrl: 'http://cdn/42.mp4' }),
};
}
it('closes the movie that is already playing externally before relaunching', async () => {
const t = setup(() => 42);
const running = {
id: 'mpv-1',
player: 'mpv',
status: 'opened',
canClose: true,
contentInfo: {
playlistId: 'portal-1',
contentXtreamId: 42,
contentType: 'vod',
},
} as ExternalPlayerSession;
t.setRunning(running);
const launch = t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
t.resolveLink();
await launch;
expect(t.closeSession).toHaveBeenCalledWith(running);
expect(t.closeSession.mock.invocationCallOrder[0]).toBeLessThan(
t.openExternalPlayback.mock.invocationCallOrder[0]
);
});
it('launches the resolved stream while the movie is still selected', async () => {
const t = setup(() => 42);
const launch = t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
t.resolveLink();
await launch;
expect(t.beforeExternalLaunch).toHaveBeenCalledTimes(1);
expect(t.openExternalPlayback).toHaveBeenCalledWith(
{ streamUrl: 'http://cdn/42.mp4' },
'mpv'
);
// Pending from the click until the launch settled: a reset meanwhile
// would be undone by the start.
expect(t.beginPendingStart).toHaveBeenCalledTimes(1);
expect(t.settlePendingStart).toHaveBeenCalledTimes(1);
expect(t.beginPendingStart.mock.invocationCallOrder[0]).toBeLessThan(
t.openExternalPlayback.mock.invocationCallOrder[0]
);
});
it('ignores a second launch while the first is still resolving', async () => {
const t = setup(() => 42);
const first = t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
await t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
t.resolveLink();
await first;
expect(t.beginPendingStart).toHaveBeenCalledTimes(1);
expect(t.openExternalPlayback).toHaveBeenCalledTimes(1);
});
it('still launches another movie while the first link resolves', async () => {
let selected = 42;
const t = setup(() => selected);
void t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
selected = 7;
const other = {
...MOVIE,
data: { id: '7' },
} as unknown as VodDetailsItem;
const second = t.actions.openExternal({
item: other,
player: 'mpv',
positionSeconds: null,
});
t.resolveLink();
await second;
expect(t.beginPendingStart).toHaveBeenCalledTimes(2);
expect(t.openExternalPlayback).toHaveBeenCalledTimes(1);
});
it('still rejects a repeat of the first movie after another one was started', async () => {
let selected = 42;
const t = setup(() => selected);
const other = {
...MOVIE,
data: { id: '7' },
} as unknown as VodDetailsItem;
const launch = (item: VodDetailsItem) =>
t.actions.openExternal({
item,
player: 'mpv',
positionSeconds: null,
});
void launch(MOVIE);
selected = 7;
void launch(other);
selected = 42;
await launch(MOVIE);
// A, B, A again: the second A is a repeat of a pending launch.
expect(t.beginPendingStart).toHaveBeenCalledTimes(2);
expect(t.openExternalPlayback).not.toHaveBeenCalled();
});
it('closes the player a launch opened after the page moved on', async () => {
let selected = 42;
const t = setup(() => selected);
const opened = {
id: 'mpv-7',
status: 'launching',
} as ExternalPlayerSession;
t.openExternalPlayback.mockResolvedValue(opened);
const launch = t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
t.resolveLink();
await Promise.resolve();
await Promise.resolve();
// The launch is inside the player IPC when the viewer opens another
// movie: its player must not open beside that one.
selected = 43;
await launch;
expect(t.openExternalPlayback).toHaveBeenCalledTimes(1);
expect(t.closeSession).toHaveBeenCalledWith(opened);
expect(t.open).not.toHaveBeenCalled();
});
it('drops the launch once a newer start superseded it', async () => {
const t = setup(() => 42);
const launch = t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
// Play/Resume (or another launch) began while the link resolved.
t.supersede();
t.resolveLink();
await launch;
expect(t.beforeExternalLaunch).not.toHaveBeenCalled();
expect(t.openExternalPlayback).not.toHaveBeenCalled();
expect(t.settlePendingStart).toHaveBeenCalledTimes(1);
});
it('stays silent when a superseded launch fails', async () => {
const t = setup(() => 42);
const launch = t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
t.supersede();
t.rejectLink(new Error('create_link timed out'));
await launch;
expect(t.open).not.toHaveBeenCalled();
expect(t.logError).not.toHaveBeenCalled();
});
it('stays silent when a newer start superseded a launch already running', async () => {
const t = setup(() => 42);
let rejectLaunch: (error: unknown) => void = () => undefined;
t.openExternalPlayback.mockImplementation(
() => new Promise((_, reject) => (rejectLaunch = reject))
);
const launch = t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
t.resolveLink();
await new Promise((resolve) => setTimeout(resolve));
// Play pressed while the IPC was pending, then the old launch fails.
t.supersede();
rejectLaunch(new Error('no player'));
await launch;
expect(t.open).not.toHaveBeenCalled();
});
it('reports a failed launch although its own teardown retired the request', async () => {
const t = setup(() => 42);
// The host closes the inline player before the launch, bumping its
// request id like a start would.
t.beforeExternalLaunch.mockImplementation(() => t.supersede());
t.openExternalPlayback.mockRejectedValue(new Error('no player'));
const launch = t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
t.resolveLink();
await launch;
expect(t.logError).toHaveBeenCalledTimes(1);
expect(t.open).toHaveBeenCalledWith(
'PORTALS.PLAYBACK_ERROR',
undefined,
expect.anything()
);
});
it('drops the stream once another movie was selected meanwhile', async () => {
let selected = 42;
const t = setup(() => selected);
const launch = t.actions.openExternal({
item: MOVIE,
player: 'vlc',
positionSeconds: null,
});
selected = 7;
t.resolveLink();
await launch;
// Neither the old movie's player nor the new movie's inline playback
// is touched.
expect(t.beforeExternalLaunch).not.toHaveBeenCalled();
expect(t.openExternalPlayback).not.toHaveBeenCalled();
});
});
describe('createStalkerVodDetailActions resetProgress', () => {
function setup(selectedVodId: () => number | null) {
const selectedVodPosition = signal<PlaybackPositionData | null>(
POSITION
);
const clearPlaybackPositionOrThrow = jest
.fn()
.mockResolvedValue(undefined);
const discardPendingPositionLoad = jest.fn();
const afterProgressReset = jest.fn();
const open = jest.fn();
const settlePendingStart = jest.fn();
const beginPendingStart = jest.fn().mockReturnValue({
settle: settlePendingStart,
isCurrent: () => true,
rebase: jest.fn(),
});
const actions = createStalkerVodDetailActions({
resolvePlayback: jest.fn(),
portalPlayer: { openExternalPlayback: jest.fn() },
externalPlayback: {
activeSession: signal(null),
closeSession: jest.fn(),
},
playbackPositions: { clearPlaybackPositionOrThrow },
playlistId: () => 'portal-1',
selectedVodId,
selectedVodPosition,
discardPendingPositionLoad,
beginPendingStart,
afterProgressReset,
snackBar: { open },
translate: { instant: (key: string) => key },
logError: jest.fn(),
});
return {
actions,
selectedVodPosition,
clearPlaybackPositionOrThrow,
discardPendingPositionLoad,
afterProgressReset,
open,
beginPendingStart,
settlePendingStart,
};
}
it("holds the movie's starts until the clear landed", async () => {
const t = setup(() => 42);
let finishClear: () => void = () => undefined;
t.clearPlaybackPositionOrThrow.mockImplementationOnce(
() => new Promise<void>((resolve) => (finishClear = resolve))
);
const reset = t.actions.resetProgress(MOVIE);
// A start made now would resume from the row being cleared.
expect(t.beginPendingStart).toHaveBeenCalledTimes(1);
expect(t.settlePendingStart).not.toHaveBeenCalled();
finishClear();
await reset;
expect(t.settlePendingStart).toHaveBeenCalledTimes(1);
expect(t.selectedVodPosition()).toBeNull();
});
it('clears the shown position of the movie that is still selected', async () => {
const t = setup(() => 42);
await t.actions.resetProgress(MOVIE);
expect(t.clearPlaybackPositionOrThrow).toHaveBeenCalledWith(
'portal-1',
42,
'vod'
);
// No read still in flight may put the row back.
expect(t.discardPendingPositionLoad).toHaveBeenCalledTimes(1);
expect(t.selectedVodPosition()).toBeNull();
expect(t.afterProgressReset).toHaveBeenCalledWith('portal-1');
expect(t.open).toHaveBeenCalledWith(
'PORTALS.DETAIL.PROGRESS_RESET',
undefined,
expect.anything()
);
});
it('leaves the position of a movie selected meanwhile untouched', async () => {
// The user moved on to movie 7 while the clear for 42 was pending.
const t = setup(() => 7);
await t.actions.resetProgress(MOVIE);
expect(t.clearPlaybackPositionOrThrow).toHaveBeenCalledWith(
'portal-1',
42,
'vod'
);
expect(t.discardPendingPositionLoad).not.toHaveBeenCalled();
expect(t.selectedVodPosition()).toEqual(POSITION);
expect(t.afterProgressReset).toHaveBeenCalledWith('portal-1');
});
});
@@ -0,0 +1,233 @@
import type { WritableSignal } from '@angular/core';
import type { MatSnackBar } from '@angular/material/snack-bar';
import type { TranslateService } from '@ngx-translate/core';
import {
replaceOwnedExternalSession,
type PortalExternalPlayback,
type PortalPlaybackPositions,
type PortalPlayer,
} from '@iptvnator/portal/shared/util';
import type {
ExternalPlayerName,
PlaybackPositionData,
ResolvedPortalPlayback,
VodDetailsItem,
} from '@iptvnator/shared/interfaces';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
/** A start the explicit launch joins: superseded by, and superseding, every other start of its host. */
export interface PendingExternalLaunch {
settle(): void;
isCurrent(): boolean;
/** Re-anchors to the host's current request id after the host's own teardown bumped it. */
rebase(): void;
}
/** Builds a `PendingExternalLaunch` on a host that tracks its own request ids. */
export function beginTrackedExternalLaunch(host: {
readonly pendingStart: {
begin(owner: string): number;
settle(startId: number): void;
};
playbackOwnerKey(): string;
playbackRequestId: number;
}): PendingExternalLaunch {
let requestId = ++host.playbackRequestId;
const ownerKey = host.playbackOwnerKey();
const startId = host.pendingStart.begin(ownerKey);
return {
settle: () => host.pendingStart.settle(startId),
isCurrent: () =>
requestId === host.playbackRequestId &&
host.playbackOwnerKey() === ownerKey,
rebase: () => {
requestId = host.playbackRequestId;
},
};
}
export interface StalkerVodExternalPlayEvent {
readonly item: VodDetailsItem;
readonly player: ExternalPlayerName;
readonly positionSeconds: number | null;
}
interface StalkerVodDetailActionsDeps {
readonly resolvePlayback: (
cmd: string | undefined,
title: string | undefined,
thumbnail: string | undefined,
startTime: number | undefined
) => Promise<ResolvedPortalPlayback>;
readonly portalPlayer: Pick<PortalPlayer, 'openExternalPlayback'>;
/** The running external session; this movie's own is replaced, never doubled. */
readonly externalPlayback: Pick<
PortalExternalPlayback,
'activeSession' | 'closeSession'
>;
/** Download of the movie file; absent for hosts without downloads. */
readonly download?: (item: VodDetailsItem) => Promise<void>;
readonly playbackPositions: Pick<
PortalPlaybackPositions,
'clearPlaybackPositionOrThrow'
>;
readonly playlistId: () => string | undefined;
/**
* The movie on screen now, null while a series or nothing is selected:
* Stalker movie and series ids collide, so the type matters as much as
* the number. A reset or a launch finished for another one leaves it alone.
*/
readonly selectedVodId: () => number | null;
readonly selectedVodPosition: WritableSignal<PlaybackPositionData | null>;
/** Retires a stored-position read in flight, which would restore the row. */
readonly discardPendingPositionLoad?: () => void;
readonly beforeExternalLaunch?: () => void;
/** The explicit launch as one of the host's starts: pending while it resolves, dropped once superseded. */
readonly beginPendingStart?: () => PendingExternalLaunch;
readonly afterProgressReset?: (playlistId: string) => void;
readonly snackBar: Pick<MatSnackBar, 'open'>;
readonly translate: Pick<TranslateService, 'instant'>;
readonly logError: (message: string, error: unknown) => void;
}
/**
* The "…" menu actions of a Stalker movie that need the store: "Open in
* external player" resolves the stream (a `create_link` round trip) and
* hands it to MPV/VLC; "Reset progress" clears the saved position.
*/
export function createStalkerVodDetailActions(
deps: StalkerVodDetailActionsDeps
) {
const notify = (key: string) =>
deps.snackBar.open(deps.translate.instant(key), undefined, {
duration: 3000,
});
// A second "Open in external player" for a movie whose `create_link` is
// still resolving would resolve beside the first and start a second
// player; other movies' launches are not held back by it.
const externalLaunchesInFlight = new Set<string>();
return {
/** The inline player copied the stream URL. */
notifyCopied(): void {
notify('PORTALS.STREAM_URL_COPIED');
},
/** The inline player asked for MPV/VLC after a failure. */
handleExternalFallback(request: PlaybackFallbackRequest): void {
request.trackLaunch(
deps.portalPlayer.openExternalPlayback(
request.playback,
request.player
)
);
},
download(item: VodDetailsItem): Promise<void> {
return deps.download?.(item) ?? Promise.resolve();
},
async openExternal(event: StalkerVodExternalPlayEvent): Promise<void> {
if (event.item.type !== 'stalker') {
return;
}
// The `create_link` round trip may outlive the selection: a
// stream resolved for a movie the user left is dropped, and its
// failure is not reported over the new one.
const playlistId = deps.playlistId();
const vodId = Number(event.item.data.id);
const launchKey = `${playlistId}:${vodId}`;
if (externalLaunchesInFlight.has(launchKey)) {
return;
}
externalLaunchesInFlight.add(launchKey);
const stillSelected = () =>
deps.selectedVodId() === vodId &&
deps.playlistId() === playlistId;
const pending = deps.beginPendingStart?.();
const superseded = () => pending?.isCurrent() === false;
try {
const playback = await deps.resolvePlayback(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
event.positionSeconds ?? undefined
);
if (!stillSelected() || superseded()) {
return;
}
const replaced = await replaceOwnedExternalSession(
deps.externalPlayback,
(info) =>
info.contentType === 'vod' &&
info.playlistId === playlistId &&
Number(info.contentXtreamId) === vodId,
deps.logError
);
if (!replaced || !stillSelected() || superseded()) {
return;
}
deps.beforeExternalLaunch?.();
// The host's inline teardown retired the request id too;
// only a start made after this point supersedes the launch.
pending?.rebase();
const session = await deps.portalPlayer.openExternalPlayback(
playback,
event.player
);
// The page moved on, or a newer start took over, while the
// launch sat inside the player IPC: the player it opened
// must not stay beside what the viewer chose since.
if (session && (!stillSelected() || superseded())) {
await deps.externalPlayback.closeSession(session);
}
} catch (error) {
// A launch a newer start superseded fails on its own; the
// newer one reports for the movie now.
if (!stillSelected() || superseded()) {
return;
}
deps.logError('External VOD playback failed', error);
notify('PORTALS.PLAYBACK_ERROR');
} finally {
externalLaunchesInFlight.delete(launchKey);
pending?.settle();
}
},
async resetProgress(item: VodDetailsItem): Promise<void> {
const playlistId = deps.playlistId();
const vodId = Number(item.type === 'stalker' ? item.data.id : NaN);
if (!playlistId || !Number.isFinite(vodId) || vodId <= 0) {
return;
}
// Counted as a pending start of this movie: its Play, Start over
// and launches are held until the write landed, or one made
// meanwhile would resume from the very row being cleared.
const pending = deps.beginPendingStart?.();
try {
await deps.playbackPositions.clearPlaybackPositionOrThrow(
playlistId,
vodId,
'vod'
);
} catch (error) {
deps.logError('Resetting the VOD position failed', error);
return;
} finally {
pending?.settle();
}
// The clear was async: only the movie still on screen loses its
// shown progress, and no older read may put it back.
if (
deps.selectedVodId() === vodId &&
deps.playlistId() === playlistId
) {
deps.discardPendingPositionLoad?.();
deps.selectedVodPosition.set(null);
}
deps.afterProgressReset?.(playlistId);
notify('PORTALS.DETAIL.PROGRESS_RESET');
},
};
}
@@ -41,7 +41,7 @@ function createPosition(
};
}
function createController() {
function createController(playbackOwnerKey?: () => string) {
const inlinePlayback = signal(null);
const selectedVodPosition = signal<PlaybackPositionData | null>(null);
const playbackPositions = {
@@ -79,6 +79,7 @@ function createController() {
translateService,
logger,
playbackErrorLogMessage: 'Playback failed',
playbackOwnerKey,
});
return {
@@ -120,6 +121,24 @@ describe('StalkerVodPlaybackController', () => {
expect(selectedVodPosition()?.positionSeconds).toBe(20);
});
it('does not hold a reopened movie with a start its earlier visit left pending', () => {
let owner = 'movie-a';
const { controller } = createController(() => owner);
const pending = controller.beginPendingStart();
expect(controller.playbackStartPending()).toBe(true);
// Back out of the movie while the portal request hangs, then
// reopen it: the new visit must be playable.
owner = '';
controller.retirePendingStart('movie-a');
owner = 'movie-a';
expect(controller.playbackStartPending()).toBe(false);
expect(pending.isCurrent()).toBe(true);
pending.settle();
expect(controller.playbackStartPending()).toBe(false);
});
it('does not mount playback that resolves after the detail closes', async () => {
const { controller, inlinePlayback } = createController();
const pending = createDeferred<ResolvedPortalPlayback>();
@@ -12,6 +12,7 @@ import {
PlaybackPositionData,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { PendingExternalLaunch } from './stalker-vod-detail-actions';
interface StalkerVodPlaybackControllerConfig {
inlinePlayback: WritableSignal<ResolvedPortalPlayback | null>;
@@ -37,12 +38,35 @@ export class StalkerVodPlaybackController {
readonly playbackStartPending = computed(() =>
this.pendingStart.isPendingFor(this.config.playbackOwnerKey?.())
);
/** The page left `owner`: a start it still resolves no longer holds a return to it. */
retirePendingStart(owner: string | undefined): void {
this.pendingStart.retire(owner);
}
private lastInlineSaveTime = 0;
private loadSelectedVodPositionRequestId = 0;
private playbackRequestId = 0;
constructor(private readonly config: StalkerVodPlaybackControllerConfig) {}
/**
* An explicit launch as one of this controller's starts: it supersedes
* an earlier start and is dropped once a later one begins.
*/
beginPendingStart(): PendingExternalLaunch {
let requestId = ++this.playbackRequestId;
const playbackOwnerKey = this.config.playbackOwnerKey?.();
const startId = this.pendingStart.begin(playbackOwnerKey);
return {
settle: () => this.pendingStart.settle(startId),
isCurrent: () =>
this.isPlaybackRequestCurrent(requestId, playbackOwnerKey),
rebase: () => {
requestId = this.playbackRequestId;
},
};
}
async startVodPlayback(
resolvePlayback: () => Promise<ResolvedPortalPlayback>
): Promise<void> {
@@ -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,393 @@
import { signal } from '@angular/core';
import type {
ExternalPlayerSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import {
externalEpisodeSessionIds,
isEpisodeLaunchPending,
openEpisodeExternally,
queueEpisodeChoice,
} from './serial-details-external-launch';
describe('externalEpisodeSessionIds', () => {
it('reports the launching or playing episode of the shown series only', () => {
expect(externalEpisodeSessionIds(null, '103', 'xtream-1')).toEqual({
opening: null,
active: null,
});
expect(
externalEpisodeSessionIds(
session({ status: 'launching' }),
'103',
'xtream-1'
)
).toEqual({ opening: 1001, active: null });
expect(externalEpisodeSessionIds(session(), '103', 'xtream-1')).toEqual(
{
opening: null,
active: 1001,
}
);
expect(externalEpisodeSessionIds(session(), '104', 'xtream-1')).toEqual(
{
opening: null,
active: null,
}
);
expect(externalEpisodeSessionIds(session(), '103', 'xtream-2')).toEqual(
{
opening: null,
active: null,
}
);
});
});
const PLAYBACK: ResolvedPortalPlayback = {
streamUrl: 'http://xtream.example/series/1002.mp4',
title: 'Episode 2',
contentInfo: {
playlistId: 'xtream-1',
contentXtreamId: 1002,
contentType: 'episode',
seriesXtreamId: 103,
},
};
function session(
overrides: Partial<ExternalPlayerSession> = {}
): ExternalPlayerSession {
return {
id: 'mpv-1',
player: 'mpv',
status: 'opened',
canClose: true,
contentInfo: {
playlistId: 'xtream-1',
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
},
...overrides,
} as ExternalPlayerSession;
}
/** Lets the launch chain's microtasks run up to the player call. */
const flush = () => new Promise((resolve) => setTimeout(resolve));
describe('openEpisodeExternally', () => {
function host(active: ExternalPlayerSession | null) {
const openExternalPlayback = jest.fn().mockResolvedValue(undefined);
const closeSession = jest.fn().mockResolvedValue(undefined);
const launchOwner = jest.fn().mockReturnValue('xtream-1:103');
const pageToken = jest.fn().mockReturnValue('xtream-1:103#1');
return {
openExternalPlayback,
closeSession,
launchOwner,
pageToken,
host: {
portalPlayer: { openExternalPlayback },
externalPlayback: {
activeSession: signal(active),
closeSession,
},
launchOwner,
pageToken,
},
};
}
it('closes the running episode of this series before launching', async () => {
const running = session();
const t = host(running);
await openEpisodeExternally(t.host, PLAYBACK, 'vlc');
expect(t.closeSession).toHaveBeenCalledWith(running);
expect(t.closeSession.mock.invocationCallOrder[0]).toBeLessThan(
t.openExternalPlayback.mock.invocationCallOrder[0]
);
expect(t.openExternalPlayback).toHaveBeenCalledWith(PLAYBACK, 'vlc');
});
it('leaves another title alone and launches beside it', async () => {
const other = session({
contentInfo: {
playlistId: 'xtream-1',
contentXtreamId: 777,
contentType: 'vod',
},
});
const t = host(other);
await openEpisodeExternally(t.host, PLAYBACK, 'mpv');
expect(t.closeSession).not.toHaveBeenCalled();
expect(t.openExternalPlayback).toHaveBeenCalledWith(PLAYBACK, 'mpv');
});
it('drops the launch when the page moved on while the close ran', async () => {
const t = host(session());
t.closeSession.mockImplementation(async () => {
t.launchOwner.mockReturnValue('xtream-1:999');
});
await openEpisodeExternally(t.host, PLAYBACK, 'mpv');
expect(t.closeSession).toHaveBeenCalledTimes(1);
expect(t.openExternalPlayback).not.toHaveBeenCalled();
});
it('closes the player a launch opened after the page moved on', async () => {
const t = host(null);
const opened = session({ id: 'mpv-2', status: 'launching' });
t.openExternalPlayback.mockImplementation(async () => {
// The viewer opens another title while the launch sits inside
// the player IPC.
t.launchOwner.mockReturnValue('xtream-1:999');
return opened;
});
await expect(
openEpisodeExternally(t.host, PLAYBACK, 'mpv')
).resolves.toBeUndefined();
expect(t.closeSession).toHaveBeenCalledWith(opened);
});
it('ignores a repeat before the first launch settled', async () => {
const t = host(null);
let settleLaunch: () => void = () => undefined;
t.openExternalPlayback.mockImplementation(
() => new Promise<void>((resolve) => (settleLaunch = resolve))
);
const first = openEpisodeExternally(t.host, PLAYBACK, 'mpv');
await openEpisodeExternally(t.host, PLAYBACK, 'mpv');
await flush();
expect(t.openExternalPlayback).toHaveBeenCalledTimes(1);
settleLaunch();
await first;
expect(t.openExternalPlayback).toHaveBeenCalledTimes(1);
});
it('queues another episode behind the first launch instead of dropping it', async () => {
const t = host(null);
let settleLaunch: () => void = () => undefined;
t.openExternalPlayback.mockImplementationOnce(
() => new Promise<void>((resolve) => (settleLaunch = resolve))
);
const first = openEpisodeExternally(t.host, PLAYBACK, 'mpv');
const second = openEpisodeExternally(
t.host,
{
...PLAYBACK,
streamUrl: 'http://xtream.example/series/1003.mp4',
contentInfo: {
...PLAYBACK.contentInfo!,
contentXtreamId: 1003,
},
},
'mpv'
);
await flush();
expect(t.openExternalPlayback).toHaveBeenCalledTimes(1);
settleLaunch();
await first;
await second;
expect(t.openExternalPlayback).toHaveBeenCalledTimes(2);
expect(t.openExternalPlayback.mock.calls[1][0]).toEqual(
expect.objectContaining({
streamUrl: 'http://xtream.example/series/1003.mp4',
})
);
});
it('reports the owner as pending until the launch settles', async () => {
const t = host(null);
let settleLaunch: () => void = () => undefined;
t.openExternalPlayback.mockImplementation(
() => new Promise<void>((resolve) => (settleLaunch = resolve))
);
expect(isEpisodeLaunchPending('xtream-1:103')).toBe(false);
const launch = openEpisodeExternally(t.host, PLAYBACK, 'mpv');
expect(isEpisodeLaunchPending('xtream-1:103')).toBe(true);
expect(isEpisodeLaunchPending('xtream-1:104')).toBe(false);
await flush();
settleLaunch();
await launch;
expect(isEpisodeLaunchPending('xtream-1:103')).toBe(false);
});
it('leaves the player alone when the page moved on before a queued launch ran', async () => {
const running = session();
const t = host(running);
let settleLaunch: () => void = () => undefined;
t.openExternalPlayback.mockImplementationOnce(
() => new Promise<void>((resolve) => (settleLaunch = resolve))
);
const first = openEpisodeExternally(t.host, PLAYBACK, 'mpv');
const second = openEpisodeExternally(
t.host,
{
...PLAYBACK,
contentInfo: {
...PLAYBACK.contentInfo!,
contentXtreamId: 1003,
},
},
'mpv'
);
await flush();
t.closeSession.mockClear();
// The viewer left the series before the queued launch's turn.
t.launchOwner.mockReturnValue('xtream-1:999');
settleLaunch();
await first;
await second;
expect(t.closeSession).not.toHaveBeenCalled();
expect(t.openExternalPlayback).toHaveBeenCalledTimes(1);
});
it('starts only the latest queued choice once the launch settled', async () => {
const t = host(null);
let settleLaunch: () => void = () => undefined;
t.openExternalPlayback.mockImplementation(
() => new Promise<void>((resolve) => (settleLaunch = resolve))
);
const launch = openEpisodeExternally(t.host, PLAYBACK, 'mpv');
// The first page queues, the viewer reopens the series and queues
// again: the reopened page's start runs, with its episode.
const startOld = jest.fn();
const startNew = jest.fn();
queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-1', startOld);
queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-2', startNew);
await flush();
expect(startOld).not.toHaveBeenCalled();
settleLaunch();
await launch;
await flush();
expect(startOld).not.toHaveBeenCalled();
expect(startNew).toHaveBeenCalledTimes(1);
expect(startNew).toHaveBeenCalledWith('episode-2');
});
it('closes the player the launch opened before the queued choice starts', async () => {
const t = host(null);
const opened = session({ id: 'mpv-2', status: 'launching' });
t.openExternalPlayback.mockImplementation(async () => {
t.host.externalPlayback.activeSession.set(opened);
return opened;
});
let settleClose: () => void = () => undefined;
t.closeSession.mockImplementation(
() => new Promise<void>((resolve) => (settleClose = resolve))
);
const launch = openEpisodeExternally(t.host, PLAYBACK, 'mpv');
const start = jest.fn();
queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-3', start);
await launch;
await flush();
// The choice must not play beside the player that just opened, and
// nothing else may start while that player closes.
expect(t.closeSession).toHaveBeenCalledWith(opened);
expect(start).not.toHaveBeenCalled();
expect(isEpisodeLaunchPending('xtream-1:103')).toBe(true);
settleClose();
await flush();
expect(start).toHaveBeenCalledWith('episode-3');
expect(isEpisodeLaunchPending('xtream-1:103')).toBe(false);
});
it('starts only the choice made while the opened player closed', async () => {
const t = host(null);
const opened = session({ id: 'mpv-2' });
t.openExternalPlayback.mockImplementation(async () => {
t.host.externalPlayback.activeSession.set(opened);
return opened;
});
let settleClose: () => void = () => undefined;
t.closeSession.mockImplementation(
() => new Promise<void>((resolve) => (settleClose = resolve))
);
const launch = openEpisodeExternally(t.host, PLAYBACK, 'mpv');
const earlier = jest.fn();
const latest = jest.fn();
queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-3', earlier);
await launch;
await flush();
expect(t.closeSession).toHaveBeenCalledTimes(1);
// Still pending while the player closes: the newer choice replaces
// the queued one instead of starting beside it afterwards.
expect(isEpisodeLaunchPending('xtream-1:103')).toBe(true);
queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-4', latest);
settleClose();
await flush();
expect(earlier).not.toHaveBeenCalled();
expect(latest).toHaveBeenCalledTimes(1);
expect(latest).toHaveBeenCalledWith('episode-4');
expect(t.closeSession).toHaveBeenCalledTimes(1);
});
it('drops the queued choice when the opened player cannot be closed', async () => {
const t = host(null);
const opened = session({ id: 'mpv-2' });
t.openExternalPlayback.mockImplementation(async () => {
t.host.externalPlayback.activeSession.set(opened);
return opened;
});
t.closeSession.mockRejectedValue(new Error('busy'));
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
const launch = openEpisodeExternally(t.host, PLAYBACK, 'mpv');
const start = jest.fn();
queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-3', start);
await launch;
await flush();
expect(t.closeSession).toHaveBeenCalledWith(opened);
expect(start).not.toHaveBeenCalled();
expect(isEpisodeLaunchPending('xtream-1:103')).toBe(false);
});
it('lets a reopened series launch the episode an earlier visit still settles', async () => {
const first = host(session());
let settleClose: () => void = () => undefined;
first.closeSession.mockImplementation(
() => new Promise<void>((resolve) => (settleClose = resolve))
);
const stale = openEpisodeExternally(first.host, PLAYBACK, 'mpv');
// The viewer left; this visit's page is gone.
first.launchOwner.mockReturnValue(null);
const reopened = host(null);
reopened.pageToken.mockReturnValue('xtream-1:103#2');
const fresh = openEpisodeExternally(reopened.host, PLAYBACK, 'mpv');
settleClose();
await stale;
await fresh;
expect(first.openExternalPlayback).not.toHaveBeenCalled();
expect(reopened.openExternalPlayback).toHaveBeenCalledTimes(1);
});
it('keeps the running player when closing it fails', async () => {
const t = host(session());
t.closeSession.mockRejectedValue(new Error('still busy'));
const warn = jest
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
await expect(
openEpisodeExternally(t.host, PLAYBACK, 'mpv')
).resolves.toBeUndefined();
expect(t.openExternalPlayback).not.toHaveBeenCalled();
warn.mockRestore();
});
});
@@ -0,0 +1,272 @@
import { signal } from '@angular/core';
import type {
PortalExternalPlayback,
PortalPlayer,
} from '@iptvnator/portal/shared/util';
import type {
ExternalPlayerName,
ExternalPlayerSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import {
createLogger,
isLiveExternalPlayerSession,
} from '@iptvnator/portal/shared/util';
import { closeRunningExternalSession } from '../vod-details/vod-details-external-session';
const logger = createLogger('SerialDetailsPlayback');
/** The episode ids an external session contributes to the series page. */
export interface ExternalEpisodeSessionIds {
readonly opening: number | null;
readonly active: number | null;
}
/**
* Which episode of the shown series the active external session is
* opening or playing; both null when the session belongs to something
* else or has ended.
*/
export function externalEpisodeSessionIds(
session: ExternalPlayerSession | null,
seriesXtreamId: string | number | undefined,
playlistId: string
): ExternalEpisodeSessionIds {
const info = session?.contentInfo;
if (
!session ||
!info ||
!seriesXtreamId ||
!playlistId ||
info.contentType !== 'episode' ||
info.playlistId !== playlistId ||
info.seriesXtreamId !== Number(seriesXtreamId)
) {
return { opening: null, active: null };
}
if (session.status === 'launching') {
return { opening: info.contentXtreamId, active: null };
}
if (isLiveExternalPlayerSession(session)) {
return { opening: null, active: info.contentXtreamId };
}
return { opening: null, active: null };
}
/** What a forced MPV/VLC episode launch reads from the series page. */
export interface SeriesExternalLaunchHost {
readonly portalPlayer: Pick<PortalPlayer, 'openExternalPlayback'>;
readonly externalPlayback: Pick<
PortalExternalPlayback,
'activeSession' | 'closeSession'
>;
/** `playlist:series` on screen, null once the page is gone. */
launchOwner(): string | null;
/** Owner plus visit: a reopened series is a new page for the duplicate guard. */
pageToken(): string;
}
/** `page:episode` keys of launches that have not settled yet. */
const launchesInFlight = new Set<string>();
/** Per owner, the tail of its launch chain: a second episode waits its turn. */
const launchChains = new Map<string, Promise<unknown>>();
/** Owners with a launch in flight, reactive for the hosts' start guards. */
const pendingOwners = signal<ReadonlyMap<string, number>>(new Map());
function countPending(owner: string, delta: number): void {
pendingOwners.update((owners) => {
const next = new Map(owners);
const count = (next.get(owner) ?? 0) + delta;
if (count > 0) {
next.set(owner, count);
} else {
next.delete(owner);
}
return next;
});
}
/** Whether a forced launch of this owner (`playlist:series`) has not settled yet. */
export function isEpisodeLaunchPending(owner: string | null): boolean {
return !!owner && (pendingOwners().get(owner) ?? 0) > 0;
}
/** Resolves once every queued launch of the owner settled (at once when none is pending). */
export function whenEpisodeLaunchesSettle(owner: string | null): Promise<void> {
const tail = owner ? launchChains.get(owner) : undefined;
return tail ? tail.then(() => undefined) : Promise.resolve();
}
/** What replaying a queued choice reads from the page that made it. */
type QueuedChoiceHost = Pick<
SeriesExternalLaunchHost,
'externalPlayback' | 'launchOwner'
>;
interface QueuedEpisodeChoice {
readonly host: QueuedChoiceHost;
readonly episode: unknown;
readonly start: (episode: never) => void;
}
/** Per owner, the latest Play/episode choice made while its forced launch settled. */
const queuedChoices = new Map<string, QueuedEpisodeChoice>();
/**
* Keeps the latest choice made while the owner's forced launch settles and
* hands it to the `start` that came with it once that launch settled. One
* settle handler per owner: a page reopened meanwhile queues with its own
* `host` and `start`, and those run, not the ones of the page the viewer left.
*/
export function queueEpisodeChoice<TEpisode>(
host: QueuedChoiceHost,
owner: string,
episode: TEpisode,
start: (episode: TEpisode) => void
): void {
const handlerRegistered = queuedChoices.has(owner);
queuedChoices.set(owner, {
host,
episode,
start: start as (episode: never) => void,
});
if (handlerRegistered) {
return;
}
void whenEpisodeLaunchesSettle(owner).then(() => replayQueuedChoice(owner));
}
/**
* The settled launch opened a player for the series; the queued choice
* replaces it, never plays beside it. The owner stays pending while that
* player closes so no other start slips in between. The choice is dropped
* when the page moved on meanwhile or the player has to stay.
*/
async function replayQueuedChoice(owner: string): Promise<void> {
const queued = queuedChoices.get(owner);
if (!queued) {
return;
}
if (queued.host.launchOwner() !== owner) {
queuedChoices.delete(owner);
return;
}
countPending(owner, 1);
let replaced = false;
try {
replaced = await closeOwnedEpisodeSession(queued.host, owner);
} finally {
countPending(owner, -1);
}
// The entry stayed in the map while the player closed, so a choice made
// meanwhile replaced it instead of registering a second replay: the
// latest choice wins.
const latest = queuedChoices.get(owner);
queuedChoices.delete(owner);
if (latest && replaced && latest.host.launchOwner() === owner) {
latest.start(latest.episode as never);
}
}
/**
* Closes the external session when it plays an episode of `owner`; false
* when it has to stay (the close failed) and nothing may start beside it.
*/
function closeOwnedEpisodeSession(
host: Pick<SeriesExternalLaunchHost, 'externalPlayback'>,
owner: string
): Promise<boolean> {
const session = host.externalPlayback.activeSession();
const info = session?.contentInfo;
const ownSession =
session &&
info &&
session.status !== 'closed' &&
info.contentType === 'episode' &&
`${info.playlistId}:${info.seriesXtreamId}` === owner
? session
: null;
return closeRunningExternalSession(
ownSession,
(running) => host.externalPlayback.closeSession(running),
(message, error) => logger.warn(message, error)
);
}
/**
* The "…" menu's MPV/VLC launch of an episode. An episode of this series
* still running externally is closed first: with instance reuse off a
* second detached player would start beside it. When that close fails, or
* the user moved on while it ran, the running player stays and nothing new
* launches. A repeat of the same episode before its launch settled
* (Electron publishes the session only afterwards) is ignored; another
* episode of the series waits for that launch to settle and then replaces
* it like any later start.
*/
export async function openEpisodeExternally(
host: SeriesExternalLaunchHost,
playback: ResolvedPortalPlayback,
player: ExternalPlayerName
): Promise<ExternalPlayerSession | void> {
const owner = host.launchOwner();
if (!owner) {
return;
}
// Keyed by the visit, not the owner: a launch left behind by an earlier
// visit of the same series must not swallow the reopened page's click,
// which instead queues behind it on the owner's chain.
const key = `${host.pageToken()}:${playback.contentInfo?.contentXtreamId ?? playback.streamUrl}`;
if (launchesInFlight.has(key)) {
return;
}
launchesInFlight.add(key);
countPending(owner, 1);
const previous = launchChains.get(owner) ?? Promise.resolve();
const run = previous.then(() =>
launchEpisode(host, owner, playback, player)
);
const tail = run.catch(() => undefined);
launchChains.set(owner, tail);
try {
return await run;
} finally {
launchesInFlight.delete(key);
countPending(owner, -1);
if (launchChains.get(owner) === tail) {
launchChains.delete(owner);
}
}
}
async function launchEpisode(
host: SeriesExternalLaunchHost,
owner: string,
playback: ResolvedPortalPlayback,
player: ExternalPlayerName
): Promise<ExternalPlayerSession | void> {
// Queued behind another launch: the page may have moved on meanwhile,
// and the running player then belongs to a series the viewer left.
if (host.launchOwner() !== owner) {
return;
}
const replaced = await closeOwnedEpisodeSession(host, owner);
if (!replaced || host.launchOwner() !== owner) {
return;
}
const session = await host.portalPlayer.openExternalPlayback(
playback,
player
);
// The page moved on while the launch sat inside the player IPC: pending
// launches are owner-scoped, so the new title may already play, and the
// player this one opened must not stay beside it.
if (session && host.launchOwner() !== owner) {
try {
await host.externalPlayback.closeSession(session);
} catch (error) {
logger.warn('Closing a superseded external player failed', error);
}
return;
}
return session;
}
@@ -0,0 +1,140 @@
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 { SettingsStore } from '@iptvnator/services';
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 settingsStore = inject(SettingsStore);
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)
);
/** Settings → Playback → Play trailers in details background. */
readonly trailerBackdropUrl = computed(() =>
this.settingsStore.detailTrailerBackdrop?.() === true
? this.trailerEmbedUrl()
: null
);
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,112 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import type { SeriesQuickStartAction } from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { VideoPlayer } from '@iptvnator/shared/interfaces';
import { SERIES_MENU_ACTION } from '@iptvnator/ui/components';
import { SerialDetailsMenuService } from './serial-details-menu.service';
function quickStart(
overrides: Partial<SeriesQuickStartAction> = {}
): SeriesQuickStartAction {
return {
kind: 'play-next',
labelKey: 'XTREAM.PLAY_NEXT',
episodeLabel: 'S01E02',
icon: 'play_arrow',
episode: { id: '1002', season: 1, episode_num: 2, title: 'Two' },
position: null,
disabled: false,
...overrides,
} as SeriesQuickStartAction;
}
describe('SerialDetailsMenuService', () => {
const quickStartSignal = signal<SeriesQuickStartAction | null>(null);
const openEpisodeExternally = jest.fn().mockResolvedValue(undefined);
let service: SerialDetailsMenuService;
beforeEach(() => {
quickStartSignal.set(quickStart());
openEpisodeExternally.mockClear();
TestBed.configureTestingModule({
providers: [
SerialDetailsMenuService,
{
provide: XtreamStore,
useValue: {
currentPlaylist: signal({ id: 'xtream-1' }),
loadRecentItems: jest.fn(),
serialCategories: signal([]),
recentItems: signal([]),
constructEpisodeStreamUrl: () =>
'http://xtream.example/episode.mp4',
},
},
{
provide: RuntimeCapabilitiesService,
useValue: { supportsManagedExternalPlayers: true },
},
{
provide: SettingsStore,
useValue: { player: signal(VideoPlayer.MPV) },
},
{ provide: Router, useValue: { navigate: jest.fn() } },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
},
],
});
service = TestBed.inject(SerialDetailsMenuService);
service.bind({
selectedItem: signal({
series_id: 103,
info: {},
episodes: {},
} as never),
quickStart: quickStartSignal,
seasonContainer: signal(undefined),
categoryId: signal(''),
episodePositions: signal(new Map()),
playbackActive: signal(false),
startPending: signal(false),
resetProgress: jest.fn(),
pageToken: () => 'xtream-1:103#1.0',
openEpisodeExternally,
});
});
function rowIds(): string[] {
return service
.sections()
.flatMap((section) => section.items)
.map((item) => item.id);
}
it('offers the next episode to an external player and to the clipboard', () => {
expect(rowIds()).toEqual(
expect.arrayContaining([
SERIES_MENU_ACTION.ExternalPlayer,
SERIES_MENU_ACTION.CopyUrl,
])
);
});
it('offers no episode rows once the series is completed', async () => {
// The completed quick start still names the last episode, but it
// is a label, not something to launch or copy.
quickStartSignal.set(quickStart({ kind: 'completed', disabled: true }));
expect(rowIds()).not.toContain(SERIES_MENU_ACTION.ExternalPlayer);
expect(rowIds()).not.toContain(SERIES_MENU_ACTION.CopyUrl);
await service.run(SERIES_MENU_ACTION.ExternalPlayer);
expect(openEpisodeExternally).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,334 @@
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,
type SeriesQuickStartAction,
} from '@iptvnator/portal/shared/util';
import {
XTREAM_DATA_SOURCE,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
VideoPlayer,
type ExternalPlayerName,
type PlayerContentInfo,
type ResolvedPortalPlayback,
type XtreamCategory,
type XtreamSerieEpisode,
} 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>>;
/** An episode plays or launches: its next tick would undo a reset. */
readonly playbackActive: Signal<boolean>;
/** A forced launch has not published its session yet: bulk watched actions would include it. */
readonly startPending: Signal<boolean>;
readonly resetProgress: () => Promise<void>;
/** Series and visit on screen; a launch's failure is reported to that page only. */
readonly pageToken: () => string;
/** The regular episode start forced to MPV/VLC, so history and the launch position are recorded. */
readonly openEpisodeExternally: (
episode: XtreamSerieEpisode,
player: ExternalPlayerName
) => Promise<unknown> | 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 runtime = inject(RuntimeCapabilitiesService);
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);
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)
);
/**
* The episode the menu's rows act on. A completed series has a quick
* start too, a disabled one naming the last episode: nothing to launch
* or copy there.
*/
private readonly startableQuickStart = computed(() => {
const quickStart = this.bindings()?.quickStart() ?? null;
return quickStart && !quickStart.disabled ? quickStart : null;
});
/** 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) {
return false;
}
// Xtream ids collide across live, movies and series: the row has to
// be the series' own.
return (
this.hasProgress() &&
this.xtreamStore
.recentItems()
.some(
(item) =>
item.type === 'series' &&
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.startableQuickStart();
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:
(this.bindings()?.startPending() ?? false) ||
(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:
(this.bindings()?.startPending() ?? false) ||
(watch?.seriesActionDisabled() ?? true),
hasProgress: this.hasProgress(),
playbackActive: this.bindings()?.playbackActive() ?? false,
startPending: this.bindings()?.startPending() ?? false,
watchBatchRunning: container?.seasonWatchBatchRunning() ?? false,
sourcesCount: 0,
externalPlayerHint:
quickStart && this.runtime.supportsManagedExternalPlayers
? 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.startableQuickStart();
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 bindings = this.bindings();
const episode = this.startableQuickStart()?.episode;
if (!bindings || !episode) {
return;
}
const token = bindings.pageToken();
try {
await bindings.openEpisodeExternally(
episode,
this.externalPlayer()
);
} catch (error) {
this.logger.warn('External episode launch failed', error);
// The page is reused across series and visits: a failure that
// lands after the viewer moved on (or came back) is not the
// current visit's problem.
if (bindings.pageToken() === token) {
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);
// The refreshed list drops the row, and brings it back once the
// series is played again.
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,
});
}
}
@@ -151,7 +151,9 @@ export class SerialDetailsPlaybackPositionState {
save: (
playlistId: string,
position: PlaybackPositionData
) => Promise<void>
) => Promise<void>,
/** The page still shows the series; checked again after the save. */
stillShown: () => boolean = () => true
): Promise<void> {
const session = await launch;
if (!session) {
@@ -178,6 +180,9 @@ export class SerialDetailsPlaybackPositionState {
};
await save(contentInfo.playlistId, position);
if (!stillShown()) {
return;
}
this.update(position);
}
@@ -0,0 +1,80 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import {
SerialDetailsPlaybackService,
type XtreamSerieDetailsView,
} from './serial-details-playback.service';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
import { SerialDetailsSeasonWatchService } from './serial-details-season-watch.service';
const series = {
series_id: 103,
info: {},
episodes: {},
} as unknown as XtreamSerieDetailsView;
/** A fresh page: the component provides the service, so each visit gets its own instance. */
function createPage(): SerialDetailsPlaybackService {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
SerialDetailsPlaybackService,
{ provide: ActivatedRoute, useValue: {} },
{
provide: XtreamStore,
useValue: { currentPlaylist: signal({ id: 'xtream-1' }) },
},
{ provide: PORTAL_PLAYBACK_POSITIONS, useValue: {} },
{ provide: PORTAL_PLAYER, useValue: {} },
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession: signal(null) },
},
{
provide: PlaybackPositionRuntimeBridgeService,
useValue: { onPlaybackPositionUpdate: () => undefined },
},
{ provide: PlaybackHistoryGate, useValue: {} },
{ provide: XTREAM_SERIES_RESUME_TARGET, useValue: signal(null) },
{
provide: SerialDetailsSeasonWatchService,
useValue: { batchRunning: signal(false) },
},
],
});
const service = TestBed.inject(SerialDetailsPlaybackService);
service.bind({ selectedItem: signal(series) });
return service;
}
describe('SerialDetailsPlaybackService page token', () => {
it('gives a recreated page of the same series a token of its own', () => {
const first = createPage();
const firstOwner = first.launchOwner();
const firstToken = first.pageToken();
const reopened = createPage();
expect(reopened.launchOwner()).toBe(firstOwner);
expect(reopened.pageToken()).not.toBe(firstToken);
});
it('changes the token when the page moves on to another series', () => {
const page = createPage();
const token = page.pageToken();
page.resetForNewSeries();
expect(page.launchOwner()).toBe('xtream-1:103');
expect(page.pageToken()).not.toBe(token);
});
});
@@ -10,15 +10,18 @@ import {
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
isLiveExternalPlayerSession,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
createLogger,
getSeriesQuickStartAction,
inlineProgressPosition,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import {
ExternalPlayerName,
ExternalPlayerSession,
PlaybackPositionData,
PlayerContentInfo,
ResolvedPortalPlayback,
@@ -32,12 +35,19 @@ import {
import {
getSeriesEpisodeMetadata,
getSeriesPlaybackNavigation,
inlineSeriesEpisodeState,
type PlaybackFallbackRequest,
resolveSeriesPlaybackEpisodeState,
type SeriesPlaybackEpisodeState,
} from '@iptvnator/ui/playback';
import { injectXtreamRecentHistory } from '../xtream-recent-history';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
import {
externalEpisodeSessionIds,
isEpisodeLaunchPending,
openEpisodeExternally,
queueEpisodeChoice,
} from './serial-details-external-launch';
import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
import {
SerialDetailsSeasonWatchService,
@@ -52,6 +62,9 @@ interface SerialDetailsPlaybackBindings {
readonly selectedItem: Signal<XtreamSerieDetailsView | null>;
}
/** Page instances created so far: a recreated page must not reuse a token. */
let pageInstances = 0;
/**
* Component-provided service that owns the episode playback concern of the
* serial details view: inline playback state, per-episode playback
@@ -65,11 +78,16 @@ export class SerialDetailsPlaybackService {
private readonly playbackPositionBridge = inject(
PlaybackPositionRuntimeBridgeService
);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
readonly portalPlayer = inject(PORTAL_PLAYER);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly recordRecentItem = injectXtreamRecentHistory();
private readonly resumeTarget = inject(XTREAM_SERIES_RESUME_TARGET);
private readonly seasonWatch = inject(SerialDetailsSeasonWatchService);
private readonly logger = createLogger('SerialDetailsPlayback');
private readonly savePosition = (
playlistId: string,
position: PlaybackPositionData
) => this.playbackPositions.savePlaybackPosition(playlistId, position);
private readonly bindings = signal<SerialDetailsPlaybackBindings | null>(
null
@@ -102,7 +120,10 @@ export class SerialDetailsPlaybackService {
});
readonly inlineEpisodeState =
computed<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(() =>
this.getInlineEpisodeState()
inlineSeriesEpisodeState(
this.inlinePlayback(),
this.selectedItem()?.episodes
)
);
readonly inlineEpisodeMetadata = computed(() =>
getSeriesEpisodeMetadata(this.inlineEpisodeState())
@@ -112,39 +133,16 @@ export class SerialDetailsPlaybackService {
);
constructor() {
// A launch still closing its predecessor must not outlive the page.
inject(DestroyRef).onDestroy(() => this.bindings.set(null));
effect(() => {
const session = this.externalPlayback.activeSession();
const selectedItem = this.selectedItem();
const playlistId = this.currentPlaylistId();
if (
!session?.contentInfo ||
!selectedItem?.series_id ||
!playlistId ||
session.contentInfo.contentType !== 'episode' ||
session.contentInfo.playlistId !== playlistId ||
session.contentInfo.seriesXtreamId !==
Number(selectedItem.series_id)
) {
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(null);
return;
}
if (session.status === 'launching') {
this.openingEpisodeId.set(session.contentInfo.contentXtreamId);
this.activeEpisodeId.set(null);
return;
}
if (isLiveExternalPlayerSession(session)) {
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(session.contentInfo.contentXtreamId);
return;
}
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(null);
const ids = externalEpisodeSessionIds(
this.externalPlayback.activeSession(),
this.selectedItem()?.series_id,
this.currentPlaylistId()
);
this.openingEpisodeId.set(ids.opening);
this.activeEpisodeId.set(ids.active);
});
effect(() => {
@@ -198,13 +196,27 @@ export class SerialDetailsPlaybackService {
/** Clears all playback state when switching to another series. */
resetForNewSeries(): void {
this.pageGeneration += 1;
this.closeInlinePlayer();
this.playbackPositionState.reset();
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(null);
}
playEpisode(episode: XtreamSerieEpisode): void {
/** `player` forces MPV/VLC (the "…" menu); history and the launch position are recorded either way. */
playEpisode(
episode: XtreamSerieEpisode,
player?: ExternalPlayerName
): Promise<ExternalPlayerSession | void> | void {
// A forced launch still settling owns the next start: the latest
// choice made meanwhile replaces its player once it settled.
const owner = this.launchOwner();
if (!player && owner && this.forcedLaunchPending()) {
queueEpisodeChoice(this, owner, episode, (queued) =>
this.playEpisode(queued)
);
return;
}
const playlist = this.xtreamStore.currentPlaylist();
const selectedItem = this.selectedItem();
if (!playlist || !selectedItem) {
@@ -244,32 +256,28 @@ export class SerialDetailsPlaybackService {
fallbackSeasonNumber: Number(episode.season),
fallbackEpisodeNumber: Number(episode.episode_num),
});
this.startPlayback(playback, episodeState);
return this.startPlayback(playback, episodeState, player);
}
playQuickStartEpisode(): void {
const action = this.quickStartAction();
if (!action || action.disabled) {
return;
if (action && !action.disabled) {
this.playEpisode(action.episode);
}
this.playEpisode(action.episode);
}
playPreviousEpisode(): void {
const previous = this.inlineEpisodeState()?.previous;
if (!previous) {
return;
if (previous) {
this.playEpisode(previous);
}
this.playEpisode(previous);
}
playNextEpisode(): void {
const next = this.inlineEpisodeState()?.next;
if (!next) {
return;
if (next) {
this.playEpisode(next);
}
this.playEpisode(next);
}
handleInlinePlaybackEnded(): void {
@@ -291,21 +299,11 @@ export class SerialDetailsPlaybackService {
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) return;
const now = Date.now();
if (now - this.lastSaveTime <= 15000) return;
if (!playback?.contentInfo || now - this.lastSaveTime <= 15000) return;
this.lastSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.playbackPositions.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
const position = inlineProgressPosition(playback.contentInfo, event);
void this.savePosition(playback.contentInfo.playlistId, position);
this.playbackPositionState.update(position);
}
@@ -315,14 +313,12 @@ export class SerialDetailsPlaybackService {
request.player
);
request.trackLaunch(launch);
const token = this.pageToken();
void this.playbackPositionState.recordExternalLaunch(
request.playback,
launch,
(playlistId, position) =>
this.playbackPositions.savePlaybackPosition(
playlistId,
position
)
this.savePosition,
() => this.pageToken() === token
);
}
@@ -386,10 +382,7 @@ export class SerialDetailsPlaybackService {
} catch (error) {
// The toggle itself succeeded; a failed refresh keeps the store
// populated-but-stale, which beats wiping it with a bad read.
console.warn(
'[SerialDetailsPlayback] Store position refresh failed',
error
);
this.logger.warn('Store position refresh failed', error);
}
}
@@ -409,47 +402,61 @@ export class SerialDetailsPlaybackService {
return this.bindings()?.selectedItem() ?? null;
}
/** A forced MPV/VLC launch of this series that has not settled yet. */
readonly forcedLaunchPending = computed(() =>
isEpisodeLaunchPending(this.launchOwner())
);
/** Distinguishes this page instance from one that replaced it. */
private readonly pageInstance = ++pageInstances;
/** Bumped for every series the page shows: a return to the same series is a new visit. */
private pageGeneration = 0;
/**
* Identifies the series AND the visit: a launch's bookkeeping applies
* only while the page still shows what it showed when the launch
* started, not after leaving and coming back.
*/
pageToken(): string {
return `${this.launchOwner()}#${this.pageInstance}.${this.pageGeneration}`;
}
/** `playlist:series` of the page, null once it is gone or shows another series. */
launchOwner(): string | null {
const seriesId = this.selectedItem()?.series_id;
return seriesId ? `${this.currentPlaylistId()}:${seriesId}` : null;
}
private startPlayback(
playback: ResolvedPortalPlayback,
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
): void {
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null,
player?: ExternalPlayerName
): Promise<ExternalPlayerSession | void> | void {
this.lastSaveTime = 0;
if (this.portalPlayer.isEmbeddedPlayer()) {
if (!player && this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlaybackSessionEpisodeState.set(episodeState);
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
const launch = player
? openEpisodeExternally(this, playback, player)
: this.portalPlayer.openResolvedPlayback(playback, true);
// The bookkeeping never rejects; a forced launch's failure is the
// menu's to report, which awaits the launch it asked for.
const token = this.pageToken();
const stillShown = () => this.pageToken() === token;
const settled = launch.catch((error) => {
this.logger.warn('External launch failed', error);
return undefined;
});
void this.playbackPositionState.recordExternalLaunch(
playback,
this.portalPlayer.openResolvedPlayback(playback, true),
(playlistId, position) =>
this.playbackPositions.savePlaybackPosition(
playlistId,
position
)
settled.then((session) => (stillShown() ? session : undefined)),
this.savePosition,
stillShown
);
}
private getInlineEpisodeState(): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
const playback = this.inlinePlayback();
const episodesBySeason = this.selectedItem()?.episodes;
const currentEpisodeId = playback?.contentInfo?.contentXtreamId;
if (
!episodesBySeason ||
playback?.contentInfo?.contentType !== 'episode' ||
currentEpisodeId === undefined
) {
return null;
}
return resolveSeriesPlaybackEpisodeState({
episodesBySeason,
currentEpisodeId,
fallbackSeasonNumber: playback.contentInfo.seasonNumber,
fallbackEpisodeNumber: playback.contentInfo.episodeNumber,
});
return launch;
}
}
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff. Show more